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

@@ -653,6 +653,16 @@ async function migrate() {
// Tagesziel (EP/Tag) auf dem App-Profil // Tagesziel (EP/Tag) auf dem App-Profil
await query(`ALTER TABLE users_public ADD COLUMN IF NOT EXISTS daily_goal_ep INTEGER NOT NULL DEFAULT 30`).catch(() => {}); await query(`ALTER TABLE users_public ADD COLUMN IF NOT EXISTS daily_goal_ep INTEGER NOT NULL DEFAULT 30`).catch(() => {});
// Freigeschaltete Erfolge je User (ein Eintrag pro Erfolg, dedup-sicher)
await query(`
CREATE TABLE IF NOT EXISTS user_achievements (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
achievement_key VARCHAR(40) NOT NULL,
unlocked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, achievement_key)
)
`);
// audios // audios
await query(` await query(`
CREATE TABLE IF NOT EXISTS audios ( CREATE TABLE IF NOT EXISTS audios (

70
src/lib/achievements.js Normal file
View File

@@ -0,0 +1,70 @@
const { query } = require('../db');
const { levelForEp } = require('./leveling');
// Erfolg-Definitionen. check(s) bekommt aggregierte Kennzahlen des Users.
const DEFS = [
{ key: 'first_card', label: 'Erster Schritt', icon: '🌱', check: s => s.total_cards >= 1 },
{ key: 'cards_50', label: '50 Karten', icon: '📦', check: s => s.total_cards >= 50 },
{ key: 'cards_100', label: '100 Karten', icon: '💯', check: s => s.total_cards >= 100 },
{ key: 'streak_3', label: '3 Tage am Stück', icon: '🔥', check: s => s.streak_days >= 3 },
{ key: 'streak_7', label: '7 Tage am Stück', icon: '🔥', check: s => s.streak_days >= 7 },
{ key: 'streak_30', label: '30 Tage am Stück',icon: '🏅', check: s => s.streak_days >= 30 },
{ key: 'level_5', label: 'Level 5', icon: '⭐', check: s => s.level >= 5 },
{ key: 'level_10', label: 'Level 10', icon: '🌟', check: s => s.level >= 10 },
{ key: 'category_master', label: 'Themen-Meister', icon: '🏆', check: s => s.max_cat >= 25 },
];
const BY_KEY = Object.fromEntries(DEFS.map(d => [d.key, d]));
// Aggregierte Kennzahlen für die Erfolg-Checks (eine Query).
async function aggregates(userId, known = {}) {
const r = await query(
`SELECT
COALESCE((SELECT SUM(seen_count) FROM user_pair_progress WHERE user_id = $1), 0)::int AS total_cards,
COALESCE((SELECT MAX(pts) FROM (
SELECT SUM(upp.earned_points) AS pts
FROM user_pair_progress upp
JOIN pair_categories pc ON pc.pair_id = upp.pair_id
WHERE upp.user_id = $1
GROUP BY pc.category_id
) s), 0)::int AS max_cat`,
[userId]
);
return { total_cards: r.rows[0].total_cards, max_cat: r.rows[0].max_cat, ...known };
}
// Wertet Erfolge aus und schaltet neue frei. Gibt NUR neu freigeschaltete zurück
// (ON CONFLICT DO NOTHING … RETURNING liefert ausschließlich neu eingefügte Zeilen).
async function evaluateAchievements(userId, { total_ep, streak_days }) {
const level = levelForEp(total_ep || 0);
const agg = await aggregates(userId, { total_ep, streak_days, level });
const satisfied = DEFS.filter(d => d.check(agg)).map(d => d.key);
if (!satisfied.length) return [];
const values = satisfied.map((_, i) => `($1, $${i + 2})`).join(', ');
const r = await query(
`INSERT INTO user_achievements (user_id, achievement_key)
VALUES ${values}
ON CONFLICT (user_id, achievement_key) DO NOTHING
RETURNING achievement_key`,
[userId, ...satisfied]
);
return r.rows.map(row => {
const d = BY_KEY[row.achievement_key];
return { key: d.key, label: d.label, icon: d.icon };
});
}
// Alle Erfolge mit Freischalt-Status (für die Profil-Sektion).
async function listAchievements(userId) {
const r = await query(
`SELECT achievement_key, unlocked_at FROM user_achievements WHERE user_id = $1`,
[userId]
);
const unlocked = new Map(r.rows.map(x => [x.achievement_key, x.unlocked_at]));
return DEFS.map(d => ({
key: d.key, label: d.label, icon: d.icon,
unlocked: unlocked.has(d.key),
unlocked_at: unlocked.get(d.key) || null,
}));
}
module.exports = { evaluateAchievements, listAchievements, DEFS };

View File

@@ -3,6 +3,7 @@ const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { query } = require('../db'); const { query } = require('../db');
const { levelForEp, levelInfo } = require('../lib/leveling'); const { levelForEp, levelInfo } = require('../lib/leveling');
const { evaluateAchievements, listAchievements } = require('../lib/achievements');
function signToken(user) { function signToken(user) {
return jwt.sign( return jwt.sign(
@@ -221,6 +222,16 @@ router.post('/progress', requireJwt, async (req, res, next) => {
const r = upd.rows[0]; const r = upd.rows[0];
const daily_ep = day.rows[0]?.ep_earned ?? pts; const daily_ep = day.rows[0]?.ep_earned ?? pts;
const daily_goal_ep = r.daily_goal_ep || 30; 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({ res.json({
total_ep: r.total_ep, total_ep: r.total_ep,
level: levelForEp(r.total_ep), level: levelForEp(r.total_ep),
@@ -231,10 +242,18 @@ router.post('/progress', requireJwt, async (req, res, next) => {
daily_goal_ep, daily_goal_ep,
// Schwellen-Übergang: jetzt erreicht, vorher (ohne diese Karte) noch nicht // Schwellen-Übergang: jetzt erreicht, vorher (ohne diese Karte) noch nicht
goal_just_reached: daily_ep >= daily_goal_ep && (daily_ep - pts) < daily_goal_ep, goal_just_reached: daily_ep >= daily_goal_ep && (daily_ep - pts) < daily_goal_ep,
unlocked_achievements,
}); });
} catch (err) { next(err); } } 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) // GET /auth/stats — Fortschrittsdaten für das Profil (Verlauf, Tagesziel, Skills)
router.get('/stats', requireJwt, async (req, res, next) => { router.get('/stats', requireJwt, async (req, res, next) => {
try { try {