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