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;