From 61b3bcb5ff1917a7961ecc06c5b43a3cea040e8d Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 17 Jun 2026 21:53:49 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Erfolge=20(Achievements)=20=E2=80=93=20?= =?UTF-8?q?Unlock-Erkennung=20+=20Listing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/db-migrate.js | 10 ++++++ src/lib/achievements.js | 70 +++++++++++++++++++++++++++++++++++++++++ src/routes/auth.js | 19 +++++++++++ 3 files changed, 99 insertions(+) create mode 100644 src/lib/achievements.js diff --git a/src/db-migrate.js b/src/db-migrate.js index 8088851..b60bd81 100644 --- a/src/db-migrate.js +++ b/src/db-migrate.js @@ -653,6 +653,16 @@ async function migrate() { // 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(() => {}); + // 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 await query(` CREATE TABLE IF NOT EXISTS audios ( diff --git a/src/lib/achievements.js b/src/lib/achievements.js new file mode 100644 index 0000000..677fd40 --- /dev/null +++ b/src/lib/achievements.js @@ -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 }; diff --git a/src/routes/auth.js b/src/routes/auth.js index a36b329..833d70a 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -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 {