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 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 17:56:19 +02:00
parent 52dce342f4
commit 2f4285dbe9
2 changed files with 140 additions and 4 deletions

View File

@@ -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');
}

View File

@@ -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); }
});