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', 'reviewed', '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(`ALTER TABLE pictures DROP CONSTRAINT IF EXISTS pictures_status_check`).catch(() => {}); await query(`ALTER TABLE pictures ADD CONSTRAINT pictures_status_check CHECK (status IN ('uploaded', 'reviewed', 'published', 'blocked'))`).catch(() => {}); 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', 'reviewed', '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', 'reviewed', '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', 'reviewed', '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', 'reviewed', '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', 'reviewed', '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', 'reviewed', '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', 'reviewed', '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(`ALTER TABLE objects DROP CONSTRAINT IF EXISTS objects_status_check`).catch(() => {}); await query(`ALTER TABLE objects ADD CONSTRAINT objects_status_check CHECK (status IN ('draft', 'reviewed', 'blocked', 'published'))`).catch(() => {}); 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) ) `); // Bounding-box columns for object highlights (percentage 0–1 of image size) await query(`ALTER TABLE object_pictures ADD COLUMN IF NOT EXISTS bbox_x FLOAT`); await query(`ALTER TABLE object_pictures ADD COLUMN IF NOT EXISTS bbox_y FLOAT`); await query(`ALTER TABLE object_pictures ADD COLUMN IF NOT EXISTS bbox_w FLOAT`); await query(`ALTER TABLE object_pictures ADD COLUMN IF NOT EXISTS bbox_h FLOAT`); // 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) ) `); // M2M: pairs <-> categories — abgeleitet aus den verknüpften Wörtern (Statements + Objekte). // Wird beim Publish materialisiert (src/lib/pairCategories.js). Basis für die Kategorie-Punkte im Profil. await query(` CREATE TABLE IF NOT EXISTS pair_categories ( pair_id UUID NOT NULL REFERENCES pairs(id) ON DELETE CASCADE, category_id UUID NOT NULL REFERENCES categories(id) ON DELETE CASCADE, PRIMARY KEY (pair_id, category_id) ) `); // Backfill: Kategorien für bereits veröffentlichte Pairs ableiten. Idempotent (ON CONFLICT DO NOTHING), // nach dem Erstlauf praktisch leer, da neue Pairs ihre Kategorien beim Publish selbst materialisieren. await query(` INSERT INTO pair_categories (pair_id, category_id) SELECT DISTINCT pid, category_id FROM ( SELECT p.id AS pid, wc.category_id FROM pairs p JOIN ( SELECT statement_id, word_id FROM statement_positive_words UNION SELECT statement_id, word_id FROM statement_negative_words ) sw ON sw.statement_id IN (p.positive_statement_id, p.negative_statement_id) JOIN word_categories wc ON wc.word_id = sw.word_id WHERE p.status = 'published' UNION SELECT op.pair_id AS pid, wc.category_id FROM object_pairs op JOIN pairs p2 ON p2.id = op.pair_id AND p2.status = 'published' JOIN object_words ow ON ow.object_id = op.object_id JOIN word_categories wc ON wc.word_id = ow.word_id ) src WHERE category_id IS NOT NULL ON CONFLICT (pair_id, category_id) DO NOTHING `).catch(() => {}); // 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() `); // Begrüßung pro Sprache (in der Sprache selbst, z. B. sv = "Hej") — für die persönliche Profil-Anrede await query(`ALTER TABLE languages ADD COLUMN IF NOT EXISTS greeting TEXT`).catch(() => {}); // 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`); // Gamification: EP-Total, Streak, letzter Übungstag await query(`ALTER TABLE users_public ADD COLUMN IF NOT EXISTS total_ep INTEGER NOT NULL DEFAULT 0`).catch(() => {}); await query(`ALTER TABLE users_public ADD COLUMN IF NOT EXISTS streak_days INTEGER NOT NULL DEFAULT 0`).catch(() => {}); await query(`ALTER TABLE users_public ADD COLUMN IF NOT EXISTS last_practice_at TIMESTAMPTZ`).catch(() => {}); // 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, greeting, status, published_at) VALUES ('en', 'Englisch', 'English', 'Engelska', 'Hi', 'published', NOW()), ('sv', 'Schwedisch', 'Swedish', 'Svenska', 'Hej', 'published', NOW()) ON CONFLICT (short_en) DO UPDATE SET status = EXCLUDED.status, published_at = COALESCE(languages.published_at, EXCLUDED.published_at), greeting = COALESCE(languages.greeting, EXCLUDED.greeting) `).catch(() => {}); // Deutsch wird separat angelegt → Begrüßung nachtragen await query(`UPDATE languages SET greeting = 'Hallo' WHERE short_en = 'de' AND greeting IS NULL`).catch(() => {}); // Seed bbox for watermelon test object (only if bbox_x is still NULL) await query(` UPDATE object_pictures SET bbox_x = 0.08, bbox_y = 0.10, bbox_w = 0.78, bbox_h = 0.76 WHERE object_id = '67a609af-55c9-4560-ba63-c8ef93429ec0' AND picture_id = 'fa776286-1df1-4b47-a29c-fc6e83e6e2da' AND bbox_x IS NULL `).catch(() => {}); // user_pair_progress await query(` CREATE TABLE IF NOT EXISTS user_pair_progress ( user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, pair_id UUID NOT NULL REFERENCES pairs(id) ON DELETE CASCADE, seen_count INTEGER NOT NULL DEFAULT 1, correct_count INTEGER NOT NULL DEFAULT 0, wrong_count INTEGER NOT NULL DEFAULT 0, last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (user_id, pair_id) ) `); await query(`ALTER TABLE user_pair_progress ADD COLUMN IF NOT EXISTS earned_points INTEGER NOT NULL DEFAULT 0`).catch(() => {}); await query(` CREATE OR REPLACE FUNCTION update_last_seen_at() RETURNS TRIGGER AS $$ BEGIN NEW.last_seen_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql `); await query(` DROP TRIGGER IF EXISTS user_pair_progress_last_seen_at ON user_pair_progress; CREATE TRIGGER user_pair_progress_last_seen_at BEFORE UPDATE ON user_pair_progress FOR EACH ROW EXECUTE FUNCTION update_last_seen_at() `); // user_daily_activity — Tagesverlauf für Streak-Kalender, Wochengraph, Tagesziel await query(` CREATE TABLE IF NOT EXISTS user_daily_activity ( user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, activity_date DATE NOT NULL, ep_earned INTEGER NOT NULL DEFAULT 0, cards_done INTEGER NOT NULL DEFAULT 0, correct_count INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (user_id, activity_date) ) `); // Tagesziel (EP/Tag) auf dem App-Profil await query(`ALTER TABLE users_public ADD COLUMN IF NOT EXISTS daily_goal_ep INTEGER NOT NULL DEFAULT 30`).catch(() => {}); // audios await query(` CREATE TABLE IF NOT EXISTS audios ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), status VARCHAR(20) NOT NULL DEFAULT 'generated' CHECK (status IN ('generated', 'published', 'blocked')), text TEXT, audio_link TEXT, alignment JSONB, voice_id TEXT, model_id TEXT, speed NUMERIC(4,2), stability NUMERIC(4,2), similarity_boost NUMERIC(4,2), style NUMERIC(4,2), published_at TIMESTAMPTZ, blocked_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); // Verknüpfung Audio → Quelle (Wort/Frage/Statement) + Sprache. // source_field: 'titel' | 'sentence' | 'positive_sentence' | 'negative_sentence' await query(`ALTER TABLE audios ADD COLUMN IF NOT EXISTS source_table TEXT`).catch(() => {}); await query(`ALTER TABLE audios ADD COLUMN IF NOT EXISTS source_id UUID`).catch(() => {}); await query(`ALTER TABLE audios ADD COLUMN IF NOT EXISTS source_field TEXT`).catch(() => {}); await query(`ALTER TABLE audios ADD COLUMN IF NOT EXISTS language VARCHAR(10)`).catch(() => {}); await query(` CREATE UNIQUE INDEX IF NOT EXISTS audios_source_uq ON audios (source_table, source_id, source_field, language) WHERE source_table IS NOT NULL `).catch(() => {}); await query(` DROP TRIGGER IF EXISTS audios_updated_at ON audios; CREATE TRIGGER audios_updated_at BEFORE UPDATE ON audios FOR EACH ROW EXECUTE FUNCTION update_updated_at() `); // tts_settings — Stimme + Parameter pro Sprache (zentral konfigurierbar) await query(` CREATE TABLE IF NOT EXISTS tts_settings ( language VARCHAR(10) PRIMARY KEY, voice_id TEXT NOT NULL, model_id TEXT NOT NULL DEFAULT 'eleven_multilingual_v2', speed NUMERIC(4,2) NOT NULL DEFAULT 1.0, stability NUMERIC(4,2) NOT NULL DEFAULT 0.5, similarity_boost NUMERIC(4,2) NOT NULL DEFAULT 0.75, style NUMERIC(4,2) NOT NULL DEFAULT 0.0, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await query(` DROP TRIGGER IF EXISTS tts_settings_updated_at ON tts_settings; CREATE TRIGGER tts_settings_updated_at BEFORE UPDATE ON tts_settings FOR EACH ROW EXECUTE FUNCTION update_updated_at() `); // Seed-Stimmen (nur einfügen wenn fehlt — manuelle Änderungen bleiben erhalten) await query(` INSERT INTO tts_settings (language, voice_id) VALUES ('de', 'rKiu7lQ4c5P3az3745s3'), ('en', 'cVd39cx0VtXNC13y5Y7z'), ('sv', 'XB0fDUnXU5powFXDhCwa') ON CONFLICT (language) DO NOTHING `).catch(() => {}); // Defekte sv-Seed-Voice ersetzen: 'XXCqsM8I9KhqA7jLGj1U' existiert bei ElevenLabs nicht // (voice_not_found) — dadurch schlug jede schwedische Audio-Generierung fehl. // 'XB0fDUnXU5powFXDhCwa' = Premade-Voice "Charlotte" (schwedischer Akzent), in jedem Account verfügbar. await query(` UPDATE tts_settings SET voice_id = 'XB0fDUnXU5powFXDhCwa' WHERE language = 'sv' AND voice_id = 'XXCqsM8I9KhqA7jLGj1U' `).catch(() => {}); // ── Content-Pipeline: Job-Tracking direkt auf der Picture-Zeile ────────────── await query(`ALTER TABLE pictures ADD COLUMN IF NOT EXISTS pipeline_status TEXT NOT NULL DEFAULT 'none'`).catch(() => {}); await query(`ALTER TABLE pictures DROP CONSTRAINT IF EXISTS pictures_pipeline_status_check`).catch(() => {}); await query(` ALTER TABLE pictures ADD CONSTRAINT pictures_pipeline_status_check CHECK (pipeline_status IN ('none', 'queued', 'running', 'failed', 'ready', 'published')) `).catch(() => {}); await query(`ALTER TABLE pictures ADD COLUMN IF NOT EXISTS pipeline_step TEXT`).catch(() => {}); await query(`ALTER TABLE pictures ADD COLUMN IF NOT EXISTS pipeline_progress JSONB`).catch(() => {}); await query(`ALTER TABLE pictures ADD COLUMN IF NOT EXISTS pipeline_error TEXT`).catch(() => {}); await query(`ALTER TABLE pictures ADD COLUMN IF NOT EXISTS pipeline_started_at TIMESTAMPTZ`).catch(() => {}); await query(`ALTER TABLE pictures ADD COLUMN IF NOT EXISTS pipeline_finished_at TIMESTAMPTZ`).catch(() => {}); // app_settings — generischer Key/Value-Store (JSONB) für Konfiguration await query(` CREATE TABLE IF NOT EXISTS app_settings ( key TEXT PRIMARY KEY, value JSONB NOT NULL, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); await query(` DROP TRIGGER IF EXISTS app_settings_updated_at ON app_settings; CREATE TRIGGER app_settings_updated_at BEFORE UPDATE ON app_settings FOR EACH ROW EXECUTE FUNCTION update_updated_at() `); await query(` INSERT INTO app_settings (key, value) VALUES ('pipeline.pairs_per_object', '5'::jsonb) ON CONFLICT (key) DO NOTHING `).catch(() => {}); // ── Migrate old {{uuid}} placeholders → new {{label.w:uuid}} / {{label.o:uuid}} ── await migratePlaceholders(); console.log('Migration complete'); } // UUID regex — matches bare {{uuid}} but NOT already-migrated {{label.w:uuid}} const UUID_RE = /\{\{([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\}\}/gi; async function migratePlaceholders() { const textCols = { questions: ['sentence_de', 'sentence_en', 'sentence_sv'], statements: [ 'positive_sentence_de', 'positive_sentence_en', 'positive_sentence_sv', 'negative_sentence_de', 'negative_sentence_en', 'negative_sentence_sv', ], }; const uuidSet = new Set(); const affected = {}; for (const [table, cols] of Object.entries(textCols)) { const whereClause = cols .map(c => `${c} ~ '\\{\\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\}\\}'`) .join(' OR '); const { rows } = await query(`SELECT id, ${cols.join(', ')} FROM ${table} WHERE ${whereClause}`); if (rows.length) { affected[table] = rows; rows.forEach(row => cols.forEach(col => { for (const m of (row[col] || '').matchAll(UUID_RE)) uuidSet.add(m[1]); })); } } if (uuidSet.size === 0) return; const uuids = [...uuidSet]; const labelMap = {}; // Words first const { rows: wordRows } = await query( `SELECT id, titel_de, titel_en FROM words WHERE id = ANY($1::uuid[])`, [uuids] ); wordRows.forEach(w => { labelMap[w.id] = { label: w.titel_de || w.titel_en || 'Wort', type: 'w' }; }); // Remaining → objects const missing = uuids.filter(id => !labelMap[id]); if (missing.length) { const { rows: objRows } = await query( `SELECT o.id, w.titel_de, w.titel_en FROM objects o LEFT JOIN object_words ow ON ow.object_id = o.id LEFT JOIN words w ON w.id = ow.word_id WHERE o.id = ANY($1::uuid[])`, [missing] ); const seen = new Set(); objRows.forEach(r => { if (!seen.has(r.id)) { seen.add(r.id); labelMap[r.id] = { label: r.titel_de || r.titel_en || 'Objekt', type: 'o' }; } }); } // UPDATE affected rows for (const [table, rows] of Object.entries(affected)) { const cols = textCols[table]; for (const row of rows) { const updates = {}; for (const col of cols) { const text = row[col]; if (!text) continue; const replaced = text.replace(UUID_RE, (_, uuid) => { const info = labelMap[uuid]; return info ? `{{${info.label}.${info.type}:${uuid}}}` : `{{${uuid}}}`; }); if (replaced !== text) updates[col] = replaced; } if (Object.keys(updates).length) { const setClauses = Object.keys(updates).map((k, i) => `${k} = $${i + 2}`).join(', '); await query(`UPDATE ${table} SET ${setClauses} WHERE id = $1`, [row.id, ...Object.values(updates)]); } } } const count = Object.values(affected).reduce((s, r) => s + r.length, 0); if (count > 0) console.log(`Placeholder migration: updated ${count} rows`); } module.exports = migrate;