From 10570786e9cc3ef253fb6b81684ed1382af88832 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 21 May 2026 13:47:52 +0200 Subject: [PATCH] =?UTF-8?q?Add=20languages,=20user=5Fnames,=20users=5Fpubl?= =?UTF-8?q?ic=20tables=20and=20routes;=20fix=20=5Fse=E2=86=92=5Fsv=20renam?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix broken rename migration array (sed had corrupted from values to _sv) - Add languages table with status lifecycle and trilingual titles - Add user_names table with unique lowercase index - Add users_public table linking to user_names and languages (native/target) - Wire all three new routes under /api/languages, /api/user-names, /api/users-public Co-Authored-By: Claude Sonnet 4.6 --- src/db-migrate.js | 86 ++++++++++++++++++++++++++++++++++---- src/index.js | 3 ++ src/routes/categories.js | 8 ++-- src/routes/languages.js | 81 +++++++++++++++++++++++++++++++++++ src/routes/questions.js | 8 ++-- src/routes/statements.js | 16 +++---- src/routes/user-names.js | 67 +++++++++++++++++++++++++++++ src/routes/users-public.js | 84 +++++++++++++++++++++++++++++++++++++ src/routes/words.js | 8 ++-- 9 files changed, 332 insertions(+), 29 deletions(-) create mode 100644 src/routes/languages.js create mode 100644 src/routes/user-names.js create mode 100644 src/routes/users-public.js diff --git a/src/db-migrate.js b/src/db-migrate.js index 732a109..227b23e 100644 --- a/src/db-migrate.js +++ b/src/db-migrate.js @@ -1,6 +1,18 @@ 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(), @@ -40,7 +52,7 @@ async function migrate() { id UUID PRIMARY KEY DEFAULT gen_random_uuid(), titel_de TEXT, titel_en TEXT, - titel_se 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), @@ -73,7 +85,7 @@ async function migrate() { id UUID PRIMARY KEY DEFAULT gen_random_uuid(), titel_de TEXT, titel_en TEXT, - titel_se 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), @@ -86,7 +98,7 @@ async function migrate() { `); // Felder nachrüsten falls Tabelle schon als Platzhalter existiert - const catCols = ['titel_de TEXT', 'titel_en TEXT', 'titel_se TEXT', + 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']; @@ -123,7 +135,7 @@ async function migrate() { CHECK (status IN ('draft', 'blocked', 'published')), sentence_de TEXT, sentence_en TEXT, - sentence_se TEXT, + sentence_sv TEXT, blocked_topic TEXT, published_at TIMESTAMPTZ, blocked_at TIMESTAMPTZ, @@ -134,7 +146,7 @@ async function migrate() { const questionCols = [ "status VARCHAR(20) NOT NULL DEFAULT 'draft'", - 'sentence_de TEXT', 'sentence_en TEXT', 'sentence_se TEXT', + 'sentence_de TEXT', 'sentence_en TEXT', 'sentence_sv TEXT', 'blocked_topic TEXT', 'published_at TIMESTAMPTZ', 'blocked_at TIMESTAMPTZ', ]; for (const col of questionCols) @@ -156,10 +168,10 @@ async function migrate() { CHECK (status IN ('draft', 'blocked', 'published')), negative_sentence_de TEXT, negative_sentence_en TEXT, - negative_sentence_se TEXT, + negative_sentence_sv TEXT, positive_sentence_de TEXT, positive_sentence_en TEXT, - positive_sentence_se TEXT, + positive_sentence_sv TEXT, blocked_topic TEXT, published_at TIMESTAMPTZ, blocked_at TIMESTAMPTZ, @@ -170,8 +182,8 @@ async function migrate() { const stmtCols = [ "status VARCHAR(20) NOT NULL DEFAULT 'draft'", - 'negative_sentence_de TEXT', 'negative_sentence_en TEXT', 'negative_sentence_se TEXT', - 'positive_sentence_de TEXT', 'positive_sentence_en TEXT', 'positive_sentence_se TEXT', + '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) @@ -358,6 +370,62 @@ async function migrate() { 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() + `); + console.log('Migration complete'); } diff --git a/src/index.js b/src/index.js index bc63937..0197ae4 100644 --- a/src/index.js +++ b/src/index.js @@ -35,6 +35,9 @@ app.use('/api/pairs', auth, require('./routes/pairs')); app.use('/api/questions', auth, require('./routes/questions')); app.use('/api/statements', auth, require('./routes/statements')); app.use('/api/blocklist', auth, require('./routes/blocklist')); +app.use('/api/languages', auth, require('./routes/languages')); +app.use('/api/user-names', auth, require('./routes/user-names')); +app.use('/api/users-public', auth, require('./routes/users-public')); // 404 app.use((req, res) => { diff --git a/src/routes/categories.js b/src/routes/categories.js index 965f2e9..b14455c 100644 --- a/src/routes/categories.js +++ b/src/routes/categories.js @@ -53,11 +53,11 @@ router.get('/:id', async (req, res, next) => { // POST /api/categories router.post('/', async (req, res, next) => { try { - const { titel_de, titel_en, titel_se, difficulty_level } = req.body; + const { titel_de, titel_en, titel_sv, difficulty_level } = req.body; const result = await query( - `INSERT INTO categories (titel_de, titel_en, titel_se, difficulty_level, requested_at) + `INSERT INTO categories (titel_de, titel_en, titel_sv, difficulty_level, requested_at) VALUES ($1, $2, $3, $4, NOW()) RETURNING *`, - [titel_de || null, titel_en || null, titel_se || null, difficulty_level || null] + [titel_de || null, titel_en || null, titel_sv || null, difficulty_level || null] ); res.status(201).json({ ...result.rows[0], word_ids: [] }); } catch (err) { next(err); } @@ -66,7 +66,7 @@ router.post('/', async (req, res, next) => { // PATCH /api/categories/:id router.patch('/:id', async (req, res, next) => { try { - const allowed = ['titel_de', 'titel_en', 'titel_se', 'status', + const allowed = ['titel_de', 'titel_en', 'titel_sv', 'status', 'difficulty_level', 'requested_at', 'published_at', 'blocked_at']; const fields = Object.keys(req.body).filter(k => allowed.includes(k)); if (!fields.length) return res.status(400).json({ error: 'No valid fields provided' }); diff --git a/src/routes/languages.js b/src/routes/languages.js new file mode 100644 index 0000000..618867a --- /dev/null +++ b/src/routes/languages.js @@ -0,0 +1,81 @@ +const router = require('express').Router(); +const { query } = require('../db'); + +const STATUSES = ['draft', 'blocked', 'published']; +const STATUS_TIMESTAMP = { published: 'published_at', blocked: 'blocked_at' }; + +// GET /api/languages +router.get('/', async (req, res, next) => { + try { + const { status, limit = 50, offset = 0 } = req.query; + const params = [Math.min(parseInt(limit), 500), parseInt(offset)]; + const where = status ? `WHERE status = $3` : ''; + if (status) params.push(status); + const result = await query( + `SELECT * FROM languages ${where} ORDER BY created_at DESC LIMIT $1 OFFSET $2`, + params + ); + res.json(result.rows); + } catch (err) { next(err); } +}); + +// GET /api/languages/:id +router.get('/:id', async (req, res, next) => { + try { + const result = await query('SELECT * FROM languages WHERE id = $1', [req.params.id]); + if (!result.rows.length) return res.status(404).json({ error: 'Not found' }); + res.json(result.rows[0]); + } catch (err) { next(err); } +}); + +// POST /api/languages +router.post('/', async (req, res, next) => { + try { + const { titel_en, titel_de, titel_sv, short_en, blocked_topic } = req.body; + const result = await query( + `INSERT INTO languages (titel_en, titel_de, titel_sv, short_en, blocked_topic) + VALUES ($1, $2, $3, $4, $5) RETURNING *`, + [titel_en || null, titel_de || null, titel_sv || null, short_en || null, blocked_topic || null] + ); + res.status(201).json(result.rows[0]); + } catch (err) { next(err); } +}); + +// PATCH /api/languages/:id +router.patch('/:id', async (req, res, next) => { + try { + const allowed = ['titel_en', 'titel_de', 'titel_sv', 'short_en', 'status', + 'published_at', 'blocked_at', 'blocked_topic']; + const fields = Object.keys(req.body).filter(k => allowed.includes(k)); + if (!fields.length) return res.status(400).json({ error: 'No valid fields provided' }); + + if (req.body.status && !STATUSES.includes(req.body.status)) + return res.status(400).json({ error: `status must be one of: ${STATUSES.join(', ')}` }); + + const tsField = STATUS_TIMESTAMP[req.body.status]; + if (tsField && !req.body[tsField]) { + fields.push(tsField); + req.body[tsField] = new Date().toISOString(); + } + + const setClauses = fields.map((f, i) => `${f} = $${i + 1}`).join(', '); + const values = [...fields.map(f => req.body[f]), req.params.id]; + const result = await query( + `UPDATE languages SET ${setClauses} WHERE id = $${fields.length + 1} RETURNING *`, + values + ); + if (!result.rows.length) return res.status(404).json({ error: 'Not found' }); + res.json(result.rows[0]); + } catch (err) { next(err); } +}); + +// DELETE /api/languages/:id +router.delete('/:id', async (req, res, next) => { + try { + const result = await query('DELETE FROM languages WHERE id = $1 RETURNING id', [req.params.id]); + if (!result.rows.length) return res.status(404).json({ error: 'Not found' }); + res.status(204).end(); + } catch (err) { next(err); } +}); + +module.exports = router; diff --git a/src/routes/questions.js b/src/routes/questions.js index d5b6bb3..fccf31a 100644 --- a/src/routes/questions.js +++ b/src/routes/questions.js @@ -31,11 +31,11 @@ router.get('/:id', async (req, res, next) => { // POST /api/questions router.post('/', async (req, res, next) => { try { - const { sentence_de, sentence_en, sentence_se, blocked_topic } = req.body; + const { sentence_de, sentence_en, sentence_sv, blocked_topic } = req.body; const result = await query( - `INSERT INTO questions (sentence_de, sentence_en, sentence_se, blocked_topic) + `INSERT INTO questions (sentence_de, sentence_en, sentence_sv, blocked_topic) VALUES ($1, $2, $3, $4) RETURNING *`, - [sentence_de || null, sentence_en || null, sentence_se || null, blocked_topic || null] + [sentence_de || null, sentence_en || null, sentence_sv || null, blocked_topic || null] ); res.status(201).json(result.rows[0]); } catch (err) { next(err); } @@ -44,7 +44,7 @@ router.post('/', async (req, res, next) => { // PATCH /api/questions/:id router.patch('/:id', async (req, res, next) => { try { - const allowed = ['status', 'sentence_de', 'sentence_en', 'sentence_se', + const allowed = ['status', 'sentence_de', 'sentence_en', 'sentence_sv', 'blocked_topic', 'published_at', 'blocked_at']; const fields = Object.keys(req.body).filter(k => allowed.includes(k)); if (!fields.length) return res.status(400).json({ error: 'No valid fields provided' }); diff --git a/src/routes/statements.js b/src/routes/statements.js index 3fc7b81..3c6e697 100644 --- a/src/routes/statements.js +++ b/src/routes/statements.js @@ -56,17 +56,17 @@ router.get('/:id', async (req, res, next) => { router.post('/', async (req, res, next) => { try { const { - negative_sentence_de, negative_sentence_en, negative_sentence_se, - positive_sentence_de, positive_sentence_en, positive_sentence_se, + negative_sentence_de, negative_sentence_en, negative_sentence_sv, + positive_sentence_de, positive_sentence_en, positive_sentence_sv, blocked_topic, } = req.body; const result = await query( `INSERT INTO statements - (negative_sentence_de, negative_sentence_en, negative_sentence_se, - positive_sentence_de, positive_sentence_en, positive_sentence_se, blocked_topic) + (negative_sentence_de, negative_sentence_en, negative_sentence_sv, + positive_sentence_de, positive_sentence_en, positive_sentence_sv, blocked_topic) VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`, - [negative_sentence_de || null, negative_sentence_en || null, negative_sentence_se || null, - positive_sentence_de || null, positive_sentence_en || null, positive_sentence_se || null, + [negative_sentence_de || null, negative_sentence_en || null, negative_sentence_sv || null, + positive_sentence_de || null, positive_sentence_en || null, positive_sentence_sv || null, blocked_topic || null] ); res.status(201).json({ ...result.rows[0], positive_word_ids: [], negative_word_ids: [] }); @@ -77,8 +77,8 @@ router.post('/', async (req, res, next) => { router.patch('/:id', async (req, res, next) => { try { const allowed = ['status', 'blocked_topic', 'published_at', 'blocked_at', - 'negative_sentence_de', 'negative_sentence_en', 'negative_sentence_se', - 'positive_sentence_de', 'positive_sentence_en', 'positive_sentence_se']; + 'negative_sentence_de', 'negative_sentence_en', 'negative_sentence_sv', + 'positive_sentence_de', 'positive_sentence_en', 'positive_sentence_sv']; const fields = Object.keys(req.body).filter(k => allowed.includes(k)); if (!fields.length) return res.status(400).json({ error: 'No valid fields provided' }); diff --git a/src/routes/user-names.js b/src/routes/user-names.js new file mode 100644 index 0000000..d7e8cde --- /dev/null +++ b/src/routes/user-names.js @@ -0,0 +1,67 @@ +const router = require('express').Router(); +const { query } = require('../db'); + +// GET /api/user-names +router.get('/', async (req, res, next) => { + try { + const { limit = 50, offset = 0 } = req.query; + const result = await query( + `SELECT * FROM user_names ORDER BY created_at DESC LIMIT $1 OFFSET $2`, + [Math.min(parseInt(limit), 500), parseInt(offset)] + ); + res.json(result.rows); + } catch (err) { next(err); } +}); + +// GET /api/user-names/:id +router.get('/:id', async (req, res, next) => { + try { + const result = await query('SELECT * FROM user_names WHERE id = $1', [req.params.id]); + if (!result.rows.length) return res.status(404).json({ error: 'Not found' }); + res.json(result.rows[0]); + } catch (err) { next(err); } +}); + +// POST /api/user-names +router.post('/', async (req, res, next) => { + try { + const { username } = req.body; + if (!username) return res.status(400).json({ error: 'username is required' }); + const result = await query( + `INSERT INTO user_names (username, username_lowercase) VALUES ($1, $2) RETURNING *`, + [username, username.toLowerCase()] + ); + res.status(201).json(result.rows[0]); + } catch (err) { + if (err.code === '23505') return res.status(409).json({ error: 'Username already taken' }); + next(err); + } +}); + +// PATCH /api/user-names/:id +router.patch('/:id', async (req, res, next) => { + try { + const { username } = req.body; + if (!username) return res.status(400).json({ error: 'No valid fields provided' }); + const result = await query( + `UPDATE user_names SET username = $1, username_lowercase = $2 WHERE id = $3 RETURNING *`, + [username, username.toLowerCase(), req.params.id] + ); + if (!result.rows.length) return res.status(404).json({ error: 'Not found' }); + res.json(result.rows[0]); + } catch (err) { + if (err.code === '23505') return res.status(409).json({ error: 'Username already taken' }); + next(err); + } +}); + +// DELETE /api/user-names/:id +router.delete('/:id', async (req, res, next) => { + try { + const result = await query('DELETE FROM user_names WHERE id = $1 RETURNING id', [req.params.id]); + if (!result.rows.length) return res.status(404).json({ error: 'Not found' }); + res.status(204).end(); + } catch (err) { next(err); } +}); + +module.exports = router; diff --git a/src/routes/users-public.js b/src/routes/users-public.js new file mode 100644 index 0000000..04a06f9 --- /dev/null +++ b/src/routes/users-public.js @@ -0,0 +1,84 @@ +const router = require('express').Router(); +const { query } = require('../db'); + +// GET /api/users-public +router.get('/', async (req, res, next) => { + try { + const { limit = 50, offset = 0 } = req.query; + const result = await query( + `SELECT up.*, + un.username, un.username_lowercase, + ln.short_en AS language_native_short, ln.titel_en AS language_native_en, + lt.short_en AS language_target_short, lt.titel_en AS language_target_en + FROM users_public up + LEFT JOIN user_names un ON un.id = up.username_id + LEFT JOIN languages ln ON ln.id = up.language_native_id + LEFT JOIN languages lt ON lt.id = up.language_target_id + ORDER BY up.created_at DESC LIMIT $1 OFFSET $2`, + [Math.min(parseInt(limit), 500), parseInt(offset)] + ); + res.json(result.rows); + } catch (err) { next(err); } +}); + +// GET /api/users-public/:id +router.get('/:id', async (req, res, next) => { + try { + const result = await query( + `SELECT up.*, + un.username, un.username_lowercase, + ln.short_en AS language_native_short, ln.titel_en AS language_native_en, + lt.short_en AS language_target_short, lt.titel_en AS language_target_en + FROM users_public up + LEFT JOIN user_names un ON un.id = up.username_id + LEFT JOIN languages ln ON ln.id = up.language_native_id + LEFT JOIN languages lt ON lt.id = up.language_target_id + WHERE up.id = $1`, + [req.params.id] + ); + if (!result.rows.length) return res.status(404).json({ error: 'Not found' }); + res.json(result.rows[0]); + } catch (err) { next(err); } +}); + +// POST /api/users-public +router.post('/', async (req, res, next) => { + try { + const { username_id, language_native_id, language_target_id } = req.body; + const result = await query( + `INSERT INTO users_public (username_id, language_native_id, language_target_id) + VALUES ($1, $2, $3) RETURNING *`, + [username_id || null, language_native_id || null, language_target_id || null] + ); + res.status(201).json(result.rows[0]); + } catch (err) { next(err); } +}); + +// PATCH /api/users-public/:id +router.patch('/:id', async (req, res, next) => { + try { + const allowed = ['username_id', 'language_native_id', 'language_target_id']; + const fields = Object.keys(req.body).filter(k => allowed.includes(k)); + if (!fields.length) return res.status(400).json({ error: 'No valid fields provided' }); + + const setClauses = fields.map((f, i) => `${f} = $${i + 1}`).join(', '); + const values = [...fields.map(f => req.body[f]), req.params.id]; + const result = await query( + `UPDATE users_public SET ${setClauses} WHERE id = $${fields.length + 1} RETURNING *`, + values + ); + if (!result.rows.length) return res.status(404).json({ error: 'Not found' }); + res.json(result.rows[0]); + } catch (err) { next(err); } +}); + +// DELETE /api/users-public/:id +router.delete('/:id', async (req, res, next) => { + try { + const result = await query('DELETE FROM users_public WHERE id = $1 RETURNING id', [req.params.id]); + if (!result.rows.length) return res.status(404).json({ error: 'Not found' }); + res.status(204).end(); + } catch (err) { next(err); } +}); + +module.exports = router; diff --git a/src/routes/words.js b/src/routes/words.js index ebfbcbd..638c666 100644 --- a/src/routes/words.js +++ b/src/routes/words.js @@ -59,11 +59,11 @@ router.get('/:id', async (req, res, next) => { // POST /api/words router.post('/', async (req, res, next) => { try { - const { titel_de, titel_en, titel_se, difficulty_level } = req.body; + const { titel_de, titel_en, titel_sv, difficulty_level } = req.body; const result = await query( - `INSERT INTO words (titel_de, titel_en, titel_se, difficulty_level, requested_at) + `INSERT INTO words (titel_de, titel_en, titel_sv, difficulty_level, requested_at) VALUES ($1, $2, $3, $4, NOW()) RETURNING *`, - [titel_de || null, titel_en || null, titel_se || null, difficulty_level || null] + [titel_de || null, titel_en || null, titel_sv || null, difficulty_level || null] ); res.status(201).json({ ...result.rows[0], picture_ids: [], category_ids: [] }); } catch (err) { next(err); } @@ -72,7 +72,7 @@ router.post('/', async (req, res, next) => { // PATCH /api/words/:id router.patch('/:id', async (req, res, next) => { try { - const allowed = ['titel_de', 'titel_en', 'titel_se', 'status', + const allowed = ['titel_de', 'titel_en', 'titel_sv', 'status', 'difficulty_level', 'requested_at', 'published_at', 'blocked_at']; const fields = Object.keys(req.body).filter(k => allowed.includes(k)); if (!fields.length) return res.status(400).json({ error: 'No valid fields provided' });