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:
@@ -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); }
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user