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>
793 lines
33 KiB
JavaScript
793 lines
33 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', '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;
|