Files
snakkimo-API/src/db-migrate.js
admin 6d13000248 feat: add /auth/feed endpoint for hydrated learning pairs
- GET /auth/feed?lang=sv&limit=20 (JWT, end-user allowed)
- Resolves {{uuid}} placeholders to word labels in all languages
- Includes picture URLs, pos/neg words per statement
- Fix migration seed: use full unique index (non-partial) for ON CONFLICT

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:37:06 +02:00

486 lines
20 KiB
JavaScript

const { query } = require('./db');
async function migrate() {
// Rename _se → _sv (Swedish ISO 639-1 correction)
const renames = [
['words', 'titel_se', 'titel_sv'],
['categories', 'titel_se', 'titel_sv'],
['questions', 'sentence_se', 'sentence_sv'],
['statements', 'negative_sentence_se', 'negative_sentence_sv'],
['statements', 'positive_sentence_se', 'positive_sentence_sv'],
];
for (const [table, from, to] of renames) {
await query(`ALTER TABLE ${table} RENAME COLUMN ${from} TO ${to}`).catch(() => {});
}
await query(`
CREATE TABLE IF NOT EXISTS pictures (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
status VARCHAR(20) NOT NULL DEFAULT 'uploaded'
CHECK (status IN ('uploaded', 'published', 'blocked')),
blocked_reason VARCHAR(20) CHECK (blocked_reason IN ('regenerate', 'not_to_use')),
generation_prompt TEXT,
generation_timestamp TIMESTAMPTZ,
generation_duration_s NUMERIC(10,3),
published_timestamp TIMESTAMPTZ,
blocked_timestamp TIMESTAMPTZ,
blurhash TEXT,
picture_link TEXT,
design TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await query(`
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN NEW.updated_at = NOW(); RETURN NEW; END;
$$ LANGUAGE plpgsql
`);
await query(`
DROP TRIGGER IF EXISTS pictures_updated_at ON pictures;
CREATE TRIGGER pictures_updated_at
BEFORE UPDATE ON pictures
FOR EACH ROW EXECUTE FUNCTION update_updated_at()
`);
// words
await query(`
CREATE TABLE IF NOT EXISTS words (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
titel_de TEXT,
titel_en TEXT,
titel_sv TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'requested'
CHECK (status IN ('requested', 'translated', 'generated', 'blocked', 'published')),
difficulty_level SMALLINT CHECK (difficulty_level BETWEEN 1 AND 50),
requested_at TIMESTAMPTZ,
published_at TIMESTAMPTZ,
blocked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await query(`
DROP TRIGGER IF EXISTS words_updated_at ON words;
CREATE TRIGGER words_updated_at
BEFORE UPDATE ON words
FOR EACH ROW EXECUTE FUNCTION update_updated_at()
`);
// M2M: words <-> pictures
await query(`
CREATE TABLE IF NOT EXISTS word_pictures (
word_id UUID NOT NULL REFERENCES words(id) ON DELETE CASCADE,
picture_id UUID NOT NULL REFERENCES pictures(id) ON DELETE CASCADE,
PRIMARY KEY (word_id, picture_id)
)
`);
await query(`
CREATE TABLE IF NOT EXISTS categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
titel_de TEXT,
titel_en TEXT,
titel_sv TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'requested'
CHECK (status IN ('requested', 'blocked', 'published')),
difficulty_level SMALLINT CHECK (difficulty_level BETWEEN 1 AND 50),
requested_at TIMESTAMPTZ,
published_at TIMESTAMPTZ,
blocked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
// Felder nachrüsten falls Tabelle schon als Platzhalter existiert
const catCols = ['titel_de TEXT', 'titel_en TEXT', 'titel_sv TEXT',
"status VARCHAR(20) NOT NULL DEFAULT 'requested'",
'difficulty_level SMALLINT', 'requested_at TIMESTAMPTZ',
'published_at TIMESTAMPTZ', 'blocked_at TIMESTAMPTZ'];
for (const col of catCols) {
const name = col.split(' ')[0];
await query(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS ${col}`).catch(() => {});
// CHECK constraint nur wenn noch nicht vorhanden
if (name === 'status') {
await query(`ALTER TABLE categories DROP CONSTRAINT IF EXISTS categories_status_check`).catch(() => {});
await query(`ALTER TABLE categories ADD CONSTRAINT categories_status_check CHECK (status IN ('requested', 'blocked', 'published'))`).catch(() => {});
}
}
await query(`
DROP TRIGGER IF EXISTS categories_updated_at ON categories;
CREATE TRIGGER categories_updated_at
BEFORE UPDATE ON categories
FOR EACH ROW EXECUTE FUNCTION update_updated_at()
`);
// M2M: words <-> categories
await query(`
CREATE TABLE IF NOT EXISTS word_categories (
word_id UUID NOT NULL REFERENCES words(id) ON DELETE CASCADE,
category_id UUID NOT NULL REFERENCES categories(id) ON DELETE CASCADE,
PRIMARY KEY (word_id, category_id)
)
`);
await query(`
CREATE TABLE IF NOT EXISTS questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'blocked', 'published')),
sentence_de TEXT,
sentence_en TEXT,
sentence_sv TEXT,
blocked_topic TEXT,
published_at TIMESTAMPTZ,
blocked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
const questionCols = [
"status VARCHAR(20) NOT NULL DEFAULT 'draft'",
'sentence_de TEXT', 'sentence_en TEXT', 'sentence_sv TEXT',
'blocked_topic TEXT', 'published_at TIMESTAMPTZ', 'blocked_at TIMESTAMPTZ',
];
for (const col of questionCols)
await query(`ALTER TABLE questions ADD COLUMN IF NOT EXISTS ${col}`).catch(() => {});
await query(`ALTER TABLE questions DROP CONSTRAINT IF EXISTS questions_status_check`).catch(() => {});
await query(`ALTER TABLE questions ADD CONSTRAINT questions_status_check CHECK (status IN ('draft', 'blocked', 'published'))`).catch(() => {});
await query(`
DROP TRIGGER IF EXISTS questions_updated_at ON questions;
CREATE TRIGGER questions_updated_at
BEFORE UPDATE ON questions
FOR EACH ROW EXECUTE FUNCTION update_updated_at()
`);
await query(`
CREATE TABLE IF NOT EXISTS statements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'blocked', 'published')),
negative_sentence_de TEXT,
negative_sentence_en TEXT,
negative_sentence_sv TEXT,
positive_sentence_de TEXT,
positive_sentence_en TEXT,
positive_sentence_sv TEXT,
blocked_topic TEXT,
published_at TIMESTAMPTZ,
blocked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
const stmtCols = [
"status VARCHAR(20) NOT NULL DEFAULT 'draft'",
'negative_sentence_de TEXT', 'negative_sentence_en TEXT', 'negative_sentence_sv TEXT',
'positive_sentence_de TEXT', 'positive_sentence_en TEXT', 'positive_sentence_sv TEXT',
'blocked_topic TEXT', 'published_at TIMESTAMPTZ', 'blocked_at TIMESTAMPTZ',
];
for (const col of stmtCols)
await query(`ALTER TABLE statements ADD COLUMN IF NOT EXISTS ${col}`).catch(() => {});
await query(`ALTER TABLE statements DROP CONSTRAINT IF EXISTS statements_status_check`).catch(() => {});
await query(`ALTER TABLE statements ADD CONSTRAINT statements_status_check CHECK (status IN ('draft', 'blocked', 'published'))`).catch(() => {});
await query(`
DROP TRIGGER IF EXISTS statements_updated_at ON statements;
CREATE TRIGGER statements_updated_at
BEFORE UPDATE ON statements
FOR EACH ROW EXECUTE FUNCTION update_updated_at()
`);
// M2M: statements <-> words (positive)
await query(`
CREATE TABLE IF NOT EXISTS statement_positive_words (
statement_id UUID NOT NULL REFERENCES statements(id) ON DELETE CASCADE,
word_id UUID NOT NULL REFERENCES words(id) ON DELETE CASCADE,
PRIMARY KEY (statement_id, word_id)
)
`);
// M2M: statements <-> words (negative)
await query(`
CREATE TABLE IF NOT EXISTS statement_negative_words (
statement_id UUID NOT NULL REFERENCES statements(id) ON DELETE CASCADE,
word_id UUID NOT NULL REFERENCES words(id) ON DELETE CASCADE,
PRIMARY KEY (statement_id, word_id)
)
`);
// pairs
await query(`
CREATE TABLE IF NOT EXISTS pairs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'blocked', 'published')),
difficulty_level SMALLINT CHECK (difficulty_level BETWEEN 1 AND 50),
answer_type VARCHAR(20) NOT NULL
CHECK (answer_type IN ('yes_no', 'text', 'word')),
blocked_topic TEXT,
question_id UUID REFERENCES questions(id) ON DELETE SET NULL,
positive_statement_id UUID REFERENCES statements(id) ON DELETE SET NULL,
negative_statement_id UUID REFERENCES statements(id) ON DELETE SET NULL,
published_at TIMESTAMPTZ,
blocked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
// Spalten nachrüsten falls Platzhalter-Tabelle bereits existiert
const pairCols = [
"status VARCHAR(20) NOT NULL DEFAULT 'draft'",
'difficulty_level SMALLINT',
"answer_type VARCHAR(20)",
'blocked_topic TEXT',
'question_id UUID',
'positive_statement_id UUID',
'negative_statement_id UUID',
'published_at TIMESTAMPTZ',
'blocked_at TIMESTAMPTZ',
];
for (const col of pairCols) {
const name = col.split(' ')[0];
await query(`ALTER TABLE pairs ADD COLUMN IF NOT EXISTS ${col}`).catch(() => {});
}
await query(`ALTER TABLE pairs DROP CONSTRAINT IF EXISTS pairs_status_check`).catch(() => {});
await query(`ALTER TABLE pairs ADD CONSTRAINT pairs_status_check CHECK (status IN ('draft', 'blocked', 'published'))`).catch(() => {});
await query(`ALTER TABLE pairs DROP CONSTRAINT IF EXISTS pairs_answer_type_check`).catch(() => {});
await query(`ALTER TABLE pairs ADD CONSTRAINT pairs_answer_type_check CHECK (answer_type IN ('yes_no', 'text', 'word'))`).catch(() => {});
await query(`ALTER TABLE pairs DROP CONSTRAINT IF EXISTS pairs_difficulty_level_check`).catch(() => {});
await query(`ALTER TABLE pairs ADD CONSTRAINT pairs_difficulty_level_check CHECK (difficulty_level BETWEEN 1 AND 50)`).catch(() => {});
await query(`ALTER TABLE pairs ADD CONSTRAINT IF NOT EXISTS pairs_question_id_fkey FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE SET NULL`).catch(() => {});
await query(`ALTER TABLE pairs ADD CONSTRAINT IF NOT EXISTS pairs_positive_statement_id_fkey FOREIGN KEY (positive_statement_id) REFERENCES statements(id) ON DELETE SET NULL`).catch(() => {});
await query(`ALTER TABLE pairs ADD CONSTRAINT IF NOT EXISTS pairs_negative_statement_id_fkey FOREIGN KEY (negative_statement_id) REFERENCES statements(id) ON DELETE SET NULL`).catch(() => {});
await query(`
DROP TRIGGER IF EXISTS pairs_updated_at ON pairs;
CREATE TRIGGER pairs_updated_at
BEFORE UPDATE ON pairs
FOR EACH ROW EXECUTE FUNCTION update_updated_at()
`);
// objects
await query(`
CREATE TABLE IF NOT EXISTS objects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'blocked', 'published')),
selections JSONB,
notes TEXT,
blocked_topic TEXT,
published_at TIMESTAMPTZ,
blocked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await query(`
DROP TRIGGER IF EXISTS objects_updated_at ON objects;
CREATE TRIGGER objects_updated_at
BEFORE UPDATE ON objects
FOR EACH ROW EXECUTE FUNCTION update_updated_at()
`);
// M2M: objects <-> words
await query(`
CREATE TABLE IF NOT EXISTS object_words (
object_id UUID NOT NULL REFERENCES objects(id) ON DELETE CASCADE,
word_id UUID NOT NULL REFERENCES words(id) ON DELETE CASCADE,
PRIMARY KEY (object_id, word_id)
)
`);
// M2M: objects <-> pictures
await query(`
CREATE TABLE IF NOT EXISTS object_pictures (
object_id UUID NOT NULL REFERENCES objects(id) ON DELETE CASCADE,
picture_id UUID NOT NULL REFERENCES pictures(id) ON DELETE CASCADE,
PRIMARY KEY (object_id, picture_id)
)
`);
// M2M: objects <-> pairs (Platzhalter)
await query(`
CREATE TABLE IF NOT EXISTS object_pairs (
object_id UUID NOT NULL REFERENCES objects(id) ON DELETE CASCADE,
pair_id UUID NOT NULL REFERENCES pairs(id) ON DELETE CASCADE,
PRIMARY KEY (object_id, pair_id)
)
`);
// pairs.answer_type → single TEXT (was TEXT[], now back to single value + new 'question' type)
await query(`ALTER TABLE pairs DROP CONSTRAINT IF EXISTS pairs_answer_type_check`).catch(() => {});
await query(`
ALTER TABLE pairs ALTER COLUMN answer_type TYPE TEXT
USING (CASE
WHEN answer_type IS NULL OR array_length(answer_type::TEXT[], 1) IS NULL THEN 'text'
ELSE (answer_type::TEXT[])[1]
END)
`).catch(() => {});
await query(`
ALTER TABLE pairs ADD CONSTRAINT pairs_answer_type_check
CHECK (answer_type IN ('yes_no', 'text', 'question', 'word'))
`).catch(() => {});
// statements.answer — boolean nullable (for yes/no correct answer)
await query(`ALTER TABLE statements ADD COLUMN IF NOT EXISTS answer BOOLEAN`);
// objects_created on pictures
await query(`ALTER TABLE pictures ADD COLUMN IF NOT EXISTS objects_created BOOLEAN NOT NULL DEFAULT false`);
await query(`ALTER TABLE pictures ADD COLUMN IF NOT EXISTS objects_created_at TIMESTAMPTZ`);
// language_native_id on users
await query(`ALTER TABLE users ADD COLUMN IF NOT EXISTS language_native_id UUID REFERENCES languages(id) ON DELETE SET NULL`);
// Seed German language (idempotent)
await query(`
INSERT INTO languages (titel_en, titel_de, titel_sv, short_en, status)
SELECT 'German', 'Deutsch', 'Tyska', 'de', 'published'
WHERE NOT EXISTS (SELECT 1 FROM languages WHERE short_en = 'de')
`);
// Set all users without a native language to German
await query(`
UPDATE users SET language_native_id = (SELECT id FROM languages WHERE short_en = 'de' LIMIT 1)
WHERE language_native_id IS NULL
`);
// blocklist
await query(`
CREATE TABLE IF NOT EXISTS blocklist (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
is_blocked BOOLEAN NOT NULL DEFAULT true,
username TEXT,
email TEXT,
phone TEXT,
ip INET,
blocked_at TIMESTAMPTZ,
unblocked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await query(`
DROP TRIGGER IF EXISTS blocklist_updated_at ON blocklist;
CREATE TRIGGER blocklist_updated_at
BEFORE UPDATE ON blocklist
FOR EACH ROW EXECUTE FUNCTION update_updated_at()
`);
await query(`CREATE INDEX IF NOT EXISTS blocklist_email_idx ON blocklist (lower(email)) WHERE email IS NOT NULL`);
await query(`CREATE INDEX IF NOT EXISTS blocklist_username_idx ON blocklist (lower(username)) WHERE username IS NOT NULL`);
await query(`CREATE INDEX IF NOT EXISTS blocklist_phone_idx ON blocklist (phone) WHERE phone IS NOT NULL`);
await query(`CREATE INDEX IF NOT EXISTS blocklist_ip_idx ON blocklist (ip) WHERE ip IS NOT NULL`);
// users
await query(`
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role VARCHAR(20) NOT NULL DEFAULT 'end-user'
CHECK (role IN ('end-user', 'admin')),
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await query(`CREATE UNIQUE INDEX IF NOT EXISTS users_email_idx ON users (lower(email))`);
await query(`
DROP TRIGGER IF EXISTS users_updated_at ON users;
CREATE TRIGGER users_updated_at
BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at()
`);
// languages
await query(`
CREATE TABLE IF NOT EXISTS languages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
titel_en TEXT,
titel_de TEXT,
titel_sv TEXT,
short_en VARCHAR(10),
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'blocked', 'published')),
published_at TIMESTAMPTZ,
blocked_at TIMESTAMPTZ,
blocked_topic TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await query(`
DROP TRIGGER IF EXISTS languages_updated_at ON languages;
CREATE TRIGGER languages_updated_at
BEFORE UPDATE ON languages
FOR EACH ROW EXECUTE FUNCTION update_updated_at()
`);
// user_names
await query(`
CREATE TABLE IF NOT EXISTS user_names (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username_lowercase TEXT NOT NULL UNIQUE,
username TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await query(`CREATE UNIQUE INDEX IF NOT EXISTS user_names_lowercase_idx ON user_names (username_lowercase)`);
// users_public
await query(`
CREATE TABLE IF NOT EXISTS users_public (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username_id UUID REFERENCES user_names(id) ON DELETE SET NULL,
language_native_id UUID REFERENCES languages(id) ON DELETE SET NULL,
language_target_id UUID REFERENCES languages(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`);
await query(`
DROP TRIGGER IF EXISTS users_public_updated_at ON users_public;
CREATE TRIGGER users_public_updated_at
BEFORE UPDATE ON users_public
FOR EACH ROW EXECUTE FUNCTION update_updated_at()
`);
// Link users_public ↔ users (1:1, app-profile per auth user)
await query(`ALTER TABLE users_public ADD COLUMN IF NOT EXISTS user_id UUID`).catch(() => {});
await query(`ALTER TABLE users_public ADD CONSTRAINT users_public_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE`).catch(() => {});
await query(`CREATE UNIQUE INDEX IF NOT EXISTS users_public_user_id_idx ON users_public (user_id) WHERE user_id IS NOT NULL`);
// Seed languages (de exists, add en + sv)
// Full unique constraint (not partial) so ON CONFLICT works cleanly
await query(`CREATE UNIQUE INDEX IF NOT EXISTS languages_short_en_idx ON languages (short_en)`).catch(() => {});
await query(`
INSERT INTO languages (short_en, titel_de, titel_en, titel_sv, status, published_at)
VALUES
('en', 'Englisch', 'English', 'Engelska', 'published', NOW()),
('sv', 'Schwedisch', 'Swedish', 'Svenska', 'published', NOW())
ON CONFLICT (short_en) DO UPDATE SET status = EXCLUDED.status, published_at = COALESCE(languages.published_at, EXCLUDED.published_at)
`).catch(() => {});
console.log('Migration complete');
}
module.exports = migrate;