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

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 };