- Tabelle user_achievements (Migration in db-migrate.js) - src/lib/achievements.js: Definitionen + dedup-sichere Freischaltung (ON CONFLICT DO NOTHING … RETURNING → nur Neues), Listing mit Status - /auth/progress liefert unlocked_achievements (defensiv gekapselt) - neue Route GET /auth/achievements Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
422 lines
16 KiB
JavaScript
422 lines
16 KiB
JavaScript
const router = require('express').Router();
|
||
const bcrypt = require('bcryptjs');
|
||
const jwt = require('jsonwebtoken');
|
||
const { query } = require('../db');
|
||
const { levelForEp, levelInfo } = require('../lib/leveling');
|
||
const { evaluateAchievements, listAchievements } = require('../lib/achievements');
|
||
|
||
function signToken(user) {
|
||
return jwt.sign(
|
||
{ userId: user.id, email: user.email, role: user.role, native_lang: user.native_lang || 'de' },
|
||
process.env.JWT_SECRET,
|
||
{ expiresIn: process.env.JWT_EXPIRES_IN || '7d' }
|
||
);
|
||
}
|
||
|
||
// 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 {
|
||
const { email, password } = req.body;
|
||
|
||
if (!email || !password)
|
||
return res.status(400).json({ error: 'email and password are required' });
|
||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
|
||
return res.status(400).json({ error: 'Invalid email format' });
|
||
if (password.length < 8)
|
||
return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||
|
||
const normalizedEmail = email.toLowerCase().trim();
|
||
|
||
const blockCheck = await query(
|
||
`SELECT id FROM blocklist WHERE is_blocked = true AND lower(email) = $1 LIMIT 1`,
|
||
[normalizedEmail]
|
||
);
|
||
if (blockCheck.rows.length)
|
||
return res.status(403).json({ error: 'Registration not allowed' });
|
||
|
||
const existing = await query(
|
||
`SELECT id FROM users WHERE lower(email) = $1`, [normalizedEmail]
|
||
);
|
||
if (existing.rows.length)
|
||
return res.status(409).json({ error: 'Email already registered' });
|
||
|
||
const password_hash = await bcrypt.hash(password, 12);
|
||
const result = await query(
|
||
`INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id, email, role`,
|
||
[normalizedEmail, password_hash]
|
||
);
|
||
|
||
const user = result.rows[0];
|
||
res.status(201).json({ user, token: signToken(user), needsProfile: true });
|
||
} catch (err) { next(err); }
|
||
});
|
||
|
||
// POST /auth/login
|
||
router.post('/login', async (req, res, next) => {
|
||
try {
|
||
const { email, password } = req.body;
|
||
|
||
if (!email || !password)
|
||
return res.status(400).json({ error: 'email and password are required' });
|
||
|
||
const result = await query(
|
||
`SELECT u.id, u.email, u.role, u.password_hash, u.is_active,
|
||
COALESCE(l.short_en, 'de') AS native_lang
|
||
FROM users u
|
||
LEFT JOIN languages l ON l.id = u.language_native_id
|
||
WHERE lower(u.email) = $1`,
|
||
[email.toLowerCase().trim()]
|
||
);
|
||
|
||
if (!result.rows.length)
|
||
return res.status(401).json({ error: 'Invalid credentials' });
|
||
|
||
const user = result.rows[0];
|
||
|
||
if (!user.is_active)
|
||
return res.status(403).json({ error: 'Account deactivated' });
|
||
|
||
const valid = await bcrypt.compare(password, user.password_hash);
|
||
if (!valid)
|
||
return res.status(401).json({ error: 'Invalid credentials' });
|
||
|
||
const { password_hash: _, is_active: __, ...safeUser } = user;
|
||
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,
|
||
COALESCE(up.total_ep, 0) AS total_ep,
|
||
COALESCE(up.streak_days, 0) AS streak_days,
|
||
COALESCE(up.daily_goal_ep, 30) AS daily_goal_ep,
|
||
up.last_practice_at,
|
||
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,
|
||
lt.greeting AS language_target_greeting
|
||
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' });
|
||
const row = r.rows[0];
|
||
Object.assign(row, levelInfo(row.total_ep)); // level + ep_into_level + ep_to_next_level
|
||
res.json(row);
|
||
} catch (err) { next(err); }
|
||
});
|
||
|
||
// POST /auth/progress — eine gelöste Karte verbuchen (EP + Streak + Detail pro Pair)
|
||
router.post('/progress', requireJwt, async (req, res, next) => {
|
||
try {
|
||
const userId = req.user.userId;
|
||
const { pair_id, correct, points } = req.body;
|
||
if (!pair_id) return res.status(400).json({ error: 'pair_id is required' });
|
||
const pts = Math.max(0, parseInt(points) || 0);
|
||
const isCorrect = correct === true || correct === 'true';
|
||
|
||
// Detail pro Pair upserten
|
||
await query(
|
||
`INSERT INTO user_pair_progress (user_id, pair_id, seen_count, correct_count, wrong_count, earned_points)
|
||
VALUES ($1, $2, 1, $3, $4, $5)
|
||
ON CONFLICT (user_id, pair_id) DO UPDATE SET
|
||
seen_count = user_pair_progress.seen_count + 1,
|
||
correct_count = user_pair_progress.correct_count + $3,
|
||
wrong_count = user_pair_progress.wrong_count + $4,
|
||
earned_points = user_pair_progress.earned_points + $5`,
|
||
[userId, pair_id, isCorrect ? 1 : 0, isCorrect ? 0 : 1, pts]
|
||
);
|
||
|
||
// Tagesverlauf upserten (für Streak-Kalender, Wochengraph, Tagesziel).
|
||
// RETURNING ep_earned = NEUER Tagesstand → Tagesziel-Übergang erkennbar.
|
||
const day = await query(
|
||
`INSERT INTO user_daily_activity (user_id, activity_date, ep_earned, cards_done, correct_count)
|
||
VALUES ($1, CURRENT_DATE, $2, 1, $3)
|
||
ON CONFLICT (user_id, activity_date) DO UPDATE SET
|
||
ep_earned = user_daily_activity.ep_earned + $2,
|
||
cards_done = user_daily_activity.cards_done + 1,
|
||
correct_count = user_daily_activity.correct_count + $3
|
||
RETURNING ep_earned`,
|
||
[userId, pts, isCorrect ? 1 : 0]
|
||
);
|
||
|
||
// EP + Streak auf users_public; Streak: +1 bei neuem Tag, Reset bei Lücke > 1 Tag.
|
||
// CTE fängt die Pre-Update-Werte mit, damit Level-Up/Streak-Up atomar erkennbar sind.
|
||
const upd = await query(
|
||
`WITH prev AS (
|
||
SELECT total_ep AS prev_ep, streak_days AS prev_streak
|
||
FROM users_public WHERE user_id = $1
|
||
)
|
||
UPDATE users_public up SET
|
||
total_ep = up.total_ep + $2,
|
||
streak_days = CASE
|
||
WHEN up.last_practice_at IS NULL THEN 1
|
||
WHEN up.last_practice_at::date = CURRENT_DATE THEN up.streak_days
|
||
WHEN up.last_practice_at::date = CURRENT_DATE - INTERVAL '1 day' THEN up.streak_days + 1
|
||
ELSE 1
|
||
END,
|
||
last_practice_at = NOW()
|
||
FROM prev
|
||
WHERE up.user_id = $1
|
||
RETURNING up.total_ep, up.streak_days, up.daily_goal_ep, prev.prev_ep, prev.prev_streak`,
|
||
[userId, pts]
|
||
);
|
||
|
||
if (!upd.rows.length)
|
||
return res.status(409).json({ error: 'Kein Profil vorhanden. Bitte zuerst Profil anlegen.' });
|
||
|
||
const r = upd.rows[0];
|
||
const daily_ep = day.rows[0]?.ep_earned ?? pts;
|
||
const daily_goal_ep = r.daily_goal_ep || 30;
|
||
|
||
// Erfolge auswerten (nur neu freigeschaltete kommen zurück). Fehler hier dürfen
|
||
// die Buchung nicht kippen → defensiv leer.
|
||
let unlocked_achievements = [];
|
||
try {
|
||
unlocked_achievements = await evaluateAchievements(userId, {
|
||
total_ep: r.total_ep, streak_days: r.streak_days,
|
||
});
|
||
} catch (e) { /* Erfolge optional – Buchung steht bereits */ }
|
||
|
||
res.json({
|
||
total_ep: r.total_ep,
|
||
level: levelForEp(r.total_ep),
|
||
prev_level: levelForEp(r.prev_ep),
|
||
streak_days: r.streak_days,
|
||
streak_increased: r.streak_days > r.prev_streak,
|
||
daily_ep,
|
||
daily_goal_ep,
|
||
// Schwellen-Übergang: jetzt erreicht, vorher (ohne diese Karte) noch nicht
|
||
goal_just_reached: daily_ep >= daily_goal_ep && (daily_ep - pts) < daily_goal_ep,
|
||
unlocked_achievements,
|
||
});
|
||
} catch (err) { next(err); }
|
||
});
|
||
|
||
// GET /auth/achievements — alle Erfolge mit Freischalt-Status (für die Profil-Sektion)
|
||
router.get('/achievements', requireJwt, async (req, res, next) => {
|
||
try {
|
||
res.json(await listAchievements(req.user.userId));
|
||
} catch (err) { next(err); }
|
||
});
|
||
|
||
// GET /auth/stats — Fortschrittsdaten für das Profil (Verlauf, Tagesziel, Skills)
|
||
router.get('/stats', requireJwt, async (req, res, next) => {
|
||
try {
|
||
const userId = req.user.userId;
|
||
|
||
// Tagesverlauf der letzten ~84 Tage (für Heatmap-Kalender + Wochengraph)
|
||
const daily = await query(
|
||
`SELECT to_char(activity_date, 'YYYY-MM-DD') AS date, ep_earned AS ep, cards_done AS cards, correct_count AS correct
|
||
FROM user_daily_activity
|
||
WHERE user_id = $1 AND activity_date >= CURRENT_DATE - INTERVAL '83 days'
|
||
ORDER BY activity_date ASC`,
|
||
[userId]
|
||
);
|
||
|
||
// Heute (für Tagesziel-Ring) + Tagesziel aus dem Profil
|
||
const today = await query(
|
||
`SELECT COALESCE(da.ep_earned, 0) AS ep, COALESCE(da.cards_done, 0) AS cards,
|
||
COALESCE(up.daily_goal_ep, 30) AS daily_goal_ep
|
||
FROM users_public up
|
||
LEFT JOIN user_daily_activity da
|
||
ON da.user_id = up.user_id AND da.activity_date = CURRENT_DATE
|
||
WHERE up.user_id = $1`,
|
||
[userId]
|
||
);
|
||
|
||
// Gesamtstatistik aus user_pair_progress
|
||
const totals = await query(
|
||
`SELECT COUNT(*)::int AS pairs_practiced,
|
||
COALESCE(SUM(seen_count), 0)::int AS total_seen,
|
||
COALESCE(SUM(correct_count), 0)::int AS total_correct
|
||
FROM user_pair_progress
|
||
WHERE user_id = $1`,
|
||
[userId]
|
||
);
|
||
|
||
// Skills: echte Genauigkeit je answer_type des Pairs.
|
||
// Mapping answer_type → Skill-Label: word/question → Vokabular, text → Lesen, yes_no → Verständnis.
|
||
const skillRows = await query(
|
||
`SELECT p.answer_type,
|
||
COALESCE(SUM(upp.correct_count), 0)::int AS correct,
|
||
COALESCE(SUM(upp.seen_count), 0)::int AS seen
|
||
FROM user_pair_progress upp
|
||
JOIN pairs p ON p.id = upp.pair_id
|
||
WHERE upp.user_id = $1
|
||
GROUP BY p.answer_type`,
|
||
[userId]
|
||
);
|
||
|
||
const SKILL_MAP = { word: 'Vokabular', question: 'Vokabular', text: 'Lesen', yes_no: 'Verständnis' };
|
||
const skillAcc = {}; // label -> { correct, seen }
|
||
for (const r of skillRows.rows) {
|
||
const label = SKILL_MAP[r.answer_type] || 'Sonstige';
|
||
const acc = (skillAcc[label] ||= { correct: 0, seen: 0 });
|
||
acc.correct += r.correct;
|
||
acc.seen += r.seen;
|
||
}
|
||
// Feste Reihenfolge, damit der Radar stabil bleibt; value = Genauigkeit (0..1)
|
||
const skills = ['Vokabular', 'Lesen', 'Verständnis'].map((label) => {
|
||
const acc = skillAcc[label];
|
||
return { label, value: acc && acc.seen > 0 ? acc.correct / acc.seen : 0, seen: acc?.seen || 0 };
|
||
});
|
||
|
||
// Punkte je Kategorie (Lebensmittel/Tiere/Beruf …) — abgeleitet über pair_categories.
|
||
// Mehrfach-Kategorien eines Pairs zählen bewusst zu jeder Kategorie.
|
||
const categoryRows = await query(
|
||
`SELECT c.id, c.titel_de AS label,
|
||
COALESCE(SUM(upp.earned_points), 0)::int AS points,
|
||
COALESCE(SUM(upp.seen_count), 0)::int AS seen
|
||
FROM user_pair_progress upp
|
||
JOIN pair_categories pc ON pc.pair_id = upp.pair_id
|
||
JOIN categories c ON c.id = pc.category_id
|
||
WHERE upp.user_id = $1
|
||
GROUP BY c.id, c.titel_de
|
||
HAVING SUM(upp.earned_points) > 0
|
||
ORDER BY points DESC`,
|
||
[userId]
|
||
);
|
||
|
||
const t = totals.rows[0] || { pairs_practiced: 0, total_seen: 0, total_correct: 0 };
|
||
const td = today.rows[0] || { ep: 0, cards: 0, daily_goal_ep: 30 };
|
||
|
||
res.json({
|
||
daily: daily.rows,
|
||
today: { ep: td.ep, cards: td.cards, daily_goal_ep: td.daily_goal_ep },
|
||
totals: {
|
||
pairs_practiced: t.pairs_practiced,
|
||
total_seen: t.total_seen,
|
||
total_correct: t.total_correct,
|
||
accuracy: t.total_seen > 0 ? t.total_correct / t.total_seen : 0,
|
||
},
|
||
skills,
|
||
categories: categoryRows.rows,
|
||
});
|
||
} catch (err) { next(err); }
|
||
});
|
||
|
||
// PUT /auth/goal — Tagesziel (EP/Tag) setzen
|
||
router.put('/goal', requireJwt, async (req, res, next) => {
|
||
try {
|
||
const goal = Math.max(5, Math.min(500, parseInt(req.body?.daily_goal_ep) || 0));
|
||
const upd = await query(
|
||
`UPDATE users_public SET daily_goal_ep = $2 WHERE user_id = $1 RETURNING daily_goal_ep`,
|
||
[req.user.userId, goal]
|
||
);
|
||
if (!upd.rows.length) return res.status(409).json({ error: 'Kein Profil vorhanden.' });
|
||
res.json({ daily_goal_ep: upd.rows[0].daily_goal_ep });
|
||
} 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); }
|
||
});
|
||
|
||
module.exports = router;
|