Files
snakkimo-API/src/routes/auth.js
admin 61b3bcb5ff feat: Erfolge (Achievements) – Unlock-Erkennung + Listing
- 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>
2026-06-17 21:53:49 +02:00

422 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;