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) await query(`CREATE UNIQUE INDEX IF NOT EXISTS languages_short_en_idx ON languages (short_en) WHERE short_en IS NOT NULL`).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 NOTHING `).catch(() => {}); console.log('Migration complete'); } module.exports = migrate;