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:
@@ -463,6 +463,21 @@ async function migrate() {
|
|||||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at()
|
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');
|
console.log('Migration complete');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// POST /auth/register
|
||||||
router.post('/register', async (req, res, next) => {
|
router.post('/register', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -25,7 +44,6 @@ router.post('/register', async (req, res, next) => {
|
|||||||
|
|
||||||
const normalizedEmail = email.toLowerCase().trim();
|
const normalizedEmail = email.toLowerCase().trim();
|
||||||
|
|
||||||
// Blocklist prüfen
|
|
||||||
const blockCheck = await query(
|
const blockCheck = await query(
|
||||||
`SELECT id FROM blocklist WHERE is_blocked = true AND lower(email) = $1 LIMIT 1`,
|
`SELECT id FROM blocklist WHERE is_blocked = true AND lower(email) = $1 LIMIT 1`,
|
||||||
[normalizedEmail]
|
[normalizedEmail]
|
||||||
@@ -33,7 +51,6 @@ router.post('/register', async (req, res, next) => {
|
|||||||
if (blockCheck.rows.length)
|
if (blockCheck.rows.length)
|
||||||
return res.status(403).json({ error: 'Registration not allowed' });
|
return res.status(403).json({ error: 'Registration not allowed' });
|
||||||
|
|
||||||
// Bereits registriert?
|
|
||||||
const existing = await query(
|
const existing = await query(
|
||||||
`SELECT id FROM users WHERE lower(email) = $1`, [normalizedEmail]
|
`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];
|
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); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,7 +98,111 @@ router.post('/login', async (req, res, next) => {
|
|||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
|
||||||
const { password_hash: _, is_active: __, ...safeUser } = user;
|
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); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user