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>
This commit is contained in:
2026-06-17 21:53:49 +02:00
parent bb863640c0
commit 61b3bcb5ff
3 changed files with 99 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ 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(
@@ -221,6 +222,16 @@ router.post('/progress', requireJwt, async (req, res, next) => {
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),
@@ -231,10 +242,18 @@ router.post('/progress', requireJwt, async (req, res, next) => {
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 {