From 2f4285dbe9d5405658d704554048ed67e6dc3e08 Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 25 May 2026 17:56:19 +0200 Subject: [PATCH] feat: add user profile endpoints + language seed for LanguParent app - 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 --- src/db-migrate.js | 15 ++++++ src/routes/auth.js | 129 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 140 insertions(+), 4 deletions(-) diff --git a/src/db-migrate.js b/src/db-migrate.js index a6845e5..bd0661b 100644 --- a/src/db-migrate.js +++ b/src/db-migrate.js @@ -463,6 +463,21 @@ async function migrate() { 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'); } diff --git a/src/routes/auth.js b/src/routes/auth.js index fc94fdb..728def0 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -11,6 +11,25 @@ function signToken(user) { ); } +// Lightweight JWT-only middleware (allows end-user role). +// Used for self-service endpoints under /auth/* that the app needs after login. +function requireJwt(req, res, next) { + const header = req.headers['authorization'] || ''; + const token = header.startsWith('Bearer ') ? header.slice(7) : null; + if (!token) return res.status(401).json({ error: 'Unauthorized' }); + try { + req.user = jwt.verify(token, process.env.JWT_SECRET); + next(); + } catch (err) { + return res.status(401).json({ error: 'Invalid or expired token' }); + } +} + +async function hasProfile(userId) { + const r = await query(`SELECT 1 FROM users_public WHERE user_id = $1 LIMIT 1`, [userId]); + return r.rows.length > 0; +} + // POST /auth/register router.post('/register', async (req, res, next) => { try { @@ -25,7 +44,6 @@ router.post('/register', async (req, res, next) => { const normalizedEmail = email.toLowerCase().trim(); - // Blocklist prüfen const blockCheck = await query( `SELECT id FROM blocklist WHERE is_blocked = true AND lower(email) = $1 LIMIT 1`, [normalizedEmail] @@ -33,7 +51,6 @@ router.post('/register', async (req, res, next) => { if (blockCheck.rows.length) return res.status(403).json({ error: 'Registration not allowed' }); - // Bereits registriert? const existing = await query( `SELECT id FROM users WHERE lower(email) = $1`, [normalizedEmail] ); @@ -47,7 +64,7 @@ router.post('/register', async (req, res, next) => { ); const user = result.rows[0]; - res.status(201).json({ user, token: signToken(user) }); + res.status(201).json({ user, token: signToken(user), needsProfile: true }); } catch (err) { next(err); } }); @@ -81,7 +98,111 @@ router.post('/login', async (req, res, next) => { return res.status(401).json({ error: 'Invalid credentials' }); const { password_hash: _, is_active: __, ...safeUser } = user; - res.json({ user: safeUser, token: signToken(safeUser), native_lang: safeUser.native_lang }); + const needsProfile = !(await hasProfile(user.id)); + res.json({ user: safeUser, token: signToken(safeUser), needsProfile }); + } catch (err) { next(err); } +}); + +// GET /auth/languages — public, published only +router.get('/languages', async (req, res, next) => { + try { + const r = await query( + `SELECT id, short_en, titel_de, titel_en, titel_sv + FROM languages + WHERE status = 'published' + ORDER BY titel_de NULLS LAST` + ); + res.json(r.rows); + } catch (err) { next(err); } +}); + +// GET /auth/check-username?username=foo +router.get('/check-username', requireJwt, async (req, res, next) => { + try { + const username = (req.query.username || '').toString().trim(); + if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) + return res.status(400).json({ error: 'Invalid username format', available: false }); + const r = await query( + `SELECT 1 FROM user_names WHERE username_lowercase = $1 LIMIT 1`, + [username.toLowerCase()] + ); + res.json({ available: r.rows.length === 0 }); + } catch (err) { next(err); } +}); + +// GET /auth/me +router.get('/me', requireJwt, async (req, res, next) => { + try { + const r = await query( + `SELECT u.id, u.email, u.role, + un.username, + ln.id AS language_native_id, ln.short_en AS language_native_short, ln.titel_de AS language_native_titel, + lt.id AS language_target_id, lt.short_en AS language_target_short, lt.titel_de AS language_target_titel + FROM users u + LEFT JOIN users_public up ON up.user_id = u.id + 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 u.id = $1`, + [req.user.userId] + ); + if (!r.rows.length) return res.status(404).json({ error: 'User not found' }); + res.json(r.rows[0]); + } catch (err) { next(err); } +}); + +// POST /auth/profile — one-time profile creation for the authed user +router.post('/profile', requireJwt, async (req, res, next) => { + try { + const { username, nativeLang, targetLang } = req.body; + const userId = req.user.userId; + + if (!username || !nativeLang || !targetLang) + return res.status(400).json({ error: 'username, nativeLang and targetLang are required' }); + if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) + return res.status(400).json({ error: 'Invalid username format' }); + if (nativeLang === targetLang) + return res.status(400).json({ error: 'nativeLang and targetLang must differ' }); + + if (await hasProfile(userId)) + return res.status(409).json({ error: 'Profile already exists' }); + + const taken = await query( + `SELECT 1 FROM user_names WHERE username_lowercase = $1`, + [username.toLowerCase()] + ); + if (taken.rows.length) return res.status(409).json({ error: 'Username already taken' }); + + const langs = await query( + `SELECT id FROM languages WHERE id IN ($1, $2) AND status = 'published'`, + [nativeLang, targetLang] + ); + if (langs.rows.length !== 2) + return res.status(400).json({ error: 'Invalid language id(s)' }); + + const nameRow = await query( + `INSERT INTO user_names (username, username_lowercase) VALUES ($1, $2) RETURNING id`, + [username, username.toLowerCase()] + ); + const usernameId = nameRow.rows[0].id; + + const publicRow = await query( + `INSERT INTO users_public (user_id, username_id, language_native_id, language_target_id) + VALUES ($1, $2, $3, $4) + RETURNING id`, + [userId, usernameId, nativeLang, targetLang] + ); + + await query(`UPDATE users SET language_native_id = $1 WHERE id = $2`, [nativeLang, userId]) + .catch(() => {}); + + res.status(201).json({ + id: publicRow.rows[0].id, + user_id: userId, + username, + language_native_id: nativeLang, + language_target_id: targetLang, + }); } catch (err) { next(err); } });