Files
snakkimo-API/src/db-migrate.js
admin 9738d3e35a feat: Profil-Kategorien + Begrüßung in Zielsprache
languages.greeting (de/en/sv geseedet), neue pair_categories-Tabelle
(abgeleitet aus statement- und objektverknüpften Wörtern via
word_categories) inkl. Backfill für bereits veröffentlichte Pairs.
derivePairCategories() wird beim Publish (pairs + pipeline) aufgerufen.
/auth/me liefert language_target_greeting, /auth/stats liefert
categories[] mit Punkten je Kategorie fürs Profil.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 12:55:57 +02:00

793 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 01 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;