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:
70
src/lib/achievements.js
Normal file
70
src/lib/achievements.js
Normal 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 };
|
||||
Reference in New Issue
Block a user