const express = require('express'); const { Pool } = require('pg'); const path = require('path'); const app = express(); const PORT = parseInt(process.env.PORT || '3003'); const ADMIN_PIN = process.env.ADMIN_PIN || '1234'; // ── Database ────────────────────────────────────────────────────────────────── // Requires env var DATABASE_URL with a valid PostgreSQL connection string. const pool = new Pool({ connectionString: process.env.DATABASE_URL }); /** * Run schema migrations on startup. * CREATE TABLE IF NOT EXISTS / ADD COLUMN IF NOT EXISTS — idempotent, safe every boot. */ async function initSchema() { await pool.query(` CREATE TABLE IF NOT EXISTS players ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, avatar TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS sessions ( id SERIAL PRIMARY KEY, player_id INTEGER NOT NULL REFERENCES players(id) ON DELETE CASCADE, level INTEGER NOT NULL, score INTEGER NOT NULL, total INTEGER NOT NULL, stars INTEGER NOT NULL, duration_seconds INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_sessions_player ON sessions(player_id); CREATE INDEX IF NOT EXISTS idx_sessions_level ON sessions(player_id, level); `); // Idempotent column additions (PostgreSQL ≥9.6 supports IF NOT EXISTS on ALTER) await pool.query(` ALTER TABLE sessions ADD COLUMN IF NOT EXISTS filter_group TEXT DEFAULT 'all'; `); // Per-question answer tracking await pool.query(` CREATE TABLE IF NOT EXISTS session_answers ( id SERIAL PRIMARY KEY, session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, comarca_name TEXT NOT NULL, correct BOOLEAN NOT NULL, response_ms INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_sa_session ON session_answers(session_id); CREATE INDEX IF NOT EXISTS idx_sa_comarca ON session_answers(comarca_name); `); console.log('✅ Schema ready'); } // ── Middleware ──────────────────────────────────────────────────────────────── app.use(express.json()); app.use(express.static(path.join(__dirname, 'public'))); // ── Helpers ─────────────────────────────────────────────────────────────────── /** * Compute consecutive-day streak up to today (UTC). * A streak starts if the most recent session was today or yesterday. * @param {Array<{created_at: string|Date}>} sessions Ordered DESC by created_at. * @returns {number} */ function calcStreak(sessions) { if (!sessions.length) return 0; // Unique UTC date strings, sorted descending const dates = [...new Set( sessions.map(s => new Date(s.created_at).toISOString().slice(0, 10)) )].sort().reverse(); const today = new Date().toISOString().slice(0, 10); const yesterday = new Date(Date.now() - 86400000).toISOString().slice(0, 10); // Streak only counts if last session was today or yesterday if (dates[0] !== today && dates[0] !== yesterday) return 0; let streak = 1; for (let i = 1; i < dates.length; i++) { const prev = new Date(dates[i - 1]); const curr = new Date(dates[i]); const diffDays = Math.round((prev - curr) / 86400000); if (diffDays === 1) streak++; else break; } return streak; } /** * Build per-level stats (levels 1–8) from a flat sessions array. * Returns bestScore, bestTotal, bestPct, avgPct, played, evolution (last 10 %). * @param {Array} sessions Must include created_at. * @returns {Object} */ function buildLevelStats(sessions) { const stats = {}; for (let lvl = 1; lvl <= 8; lvl++) { // Sort chronologically for evolution calculation const ls = sessions .filter(s => s.level === lvl) .sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); if (!ls.length) continue; const best = ls.reduce((b, s) => (s.score / s.total > b.score / b.total ? s : b)); const evolution = ls.slice(-10).map(s => Math.round(s.score / s.total * 100)); const avgPct = Math.round(ls.reduce((sum, s) => sum + s.score / s.total * 100, 0) / ls.length); stats[lvl] = { played: ls.length, bestScore: best.score, bestTotal: best.total, bestPct: Math.round(best.score / best.total * 100), avgPct, evolution, }; } return stats; } // ── Routes ──────────────────────────────────────────────────────────────────── // GET /api/health — also verifies DB connectivity app.get('/api/health', async (_req, res) => { try { await pool.query('SELECT 1'); res.json({ ok: true }); } catch { res.status(503).json({ ok: false }); } }); // GET /api/players app.get('/api/players', async (_req, res) => { try { const { rows } = await pool.query( 'SELECT id, name, avatar, created_at FROM players ORDER BY name' ); res.json(rows); } catch (err) { res.status(500).json({ error: err.message }); } }); // POST /api/players { name, avatar } app.post('/api/players', async (req, res) => { const { name, avatar } = req.body ?? {}; if (!name?.trim() || !avatar) { return res.status(400).json({ error: 'name i avatar són obligatoris' }); } try { // Case-insensitive duplicate check const dup = await pool.query( 'SELECT id FROM players WHERE lower(name) = lower($1)', [name.trim()] ); if (dup.rows.length) { return res.status(409).json({ error: 'Ja existeix un jugador amb aquest nom' }); } const { rows } = await pool.query( 'INSERT INTO players (name, avatar) VALUES ($1, $2) RETURNING id, name, avatar', [name.trim(), avatar] ); res.status(201).json(rows[0]); } catch (err) { res.status(500).json({ error: err.message }); } }); /** * Fetch full stats for a player by id. * Shared between the player-facing and admin endpoints to avoid code duplication. * @param {number|string} playerId * @returns {Promise} Stats payload (player, levelStats, hardComarques, groupStats, …) * @throws {Error} with .status = 404 if player not found */ async function getPlayerFullStats(playerId) { const playerRes = await pool.query( 'SELECT id, name, avatar, created_at FROM players WHERE id = $1', [playerId] ); if (!playerRes.rows.length) { const err = new Error('Jugador no trobat'); err.status = 404; throw err; } const player = playerRes.rows[0]; // All sessions for this player (include created_at for streak + evolution) const sessionRes = await pool.query( `SELECT level, score, total, stars, duration_seconds, created_at FROM sessions WHERE player_id = $1 ORDER BY created_at DESC`, [playerId] ); const sessions = sessionRes.rows; // Aggregate scalars const totalStars = sessions.reduce((s, r) => s + r.stars, 0); const totalSessions = sessions.length; const totalSeconds = sessions.reduce((s, r) => s + (r.duration_seconds || 0), 0); const currentStreak = calcStreak(sessions); // Level breakdown (levels 1–8, best + avg + evolution) const levelStats = buildLevelStats(sessions); // Recent 10 sessions const recent = sessions.slice(0, 10).map(s => ({ level: s.level, score: s.score, total: s.total, stars: s.stars, date: s.created_at, })); // Hard comarques: top 5 by error rate (min 3 attempts) const hardRes = await pool.query( `SELECT comarca_name, COUNT(*)::int AS attempts, SUM(CASE WHEN correct = false THEN 1 ELSE 0 END)::int AS wrong FROM session_answers JOIN sessions ON sessions.id = session_answers.session_id WHERE sessions.player_id = $1 GROUP BY comarca_name HAVING COUNT(*) >= 3 ORDER BY (SUM(CASE WHEN correct = false THEN 1 ELSE 0 END)::float / COUNT(*)) DESC LIMIT 5`, [playerId] ); const hardComarques = hardRes.rows.map(r => ({ comarca: r.comarca_name, attempts: r.attempts, wrong: r.wrong, errorRate: Math.round(r.wrong / r.attempts * 100), })); // Group performance (exclude 'all' entries, exclude null) const groupRes = await pool.query( `SELECT filter_group, COUNT(*)::int AS sessions, ROUND(AVG(score::float / total * 100))::int AS avg_pct FROM sessions WHERE player_id = $1 AND filter_group IS NOT NULL AND filter_group != 'all' AND total > 0 GROUP BY filter_group`, [playerId] ); const groupStats = {}; for (const r of groupRes.rows) { groupStats[r.filter_group] = { sessions: r.sessions, avgPct: r.avg_pct }; } return { player, totalStars, totalSessions, totalSeconds, currentStreak, levelStats, hardComarques, groupStats, recent, }; } // GET /api/players/:id/stats — full stats including streak, hardComarques, groupStats app.get('/api/players/:id/stats', async (req, res) => { try { const data = await getPlayerFullStats(req.params.id); res.json(data); } catch (err) { res.status(err.status || 500).json({ error: err.message }); } }); // GET /api/admin/player/:id/stats Header: x-admin-pin // Returns the same full stats payload as /api/players/:id/stats but requires admin PIN. app.get('/api/admin/player/:id/stats', async (req, res) => { if (req.headers['x-admin-pin'] !== ADMIN_PIN) { return res.status(401).json({ error: 'PIN incorrecte' }); } try { const data = await getPlayerFullStats(req.params.id); res.json(data); } catch (err) { res.status(err.status || 500).json({ error: err.message }); } }); // POST /api/sessions // Body: { player_id, level, score, total, stars, duration_seconds?, filter_group?, answers? } // answers: [{ comarca, correct, response_ms }] app.post('/api/sessions', async (req, res) => { const { player_id, level, score, total, stars, duration_seconds = 0, filter_group = 'all', answers = [], } = req.body ?? {}; if ([player_id, level, score, total, stars].some(v => v == null)) { return res.status(400).json({ error: 'Falten camps obligatoris' }); } try { const playerRes = await pool.query( 'SELECT id FROM players WHERE id = $1', [player_id] ); if (!playerRes.rows.length) { return res.status(404).json({ error: 'Jugador no trobat' }); } // Insert session const { rows } = await pool.query( `INSERT INTO sessions (player_id, level, score, total, stars, duration_seconds, filter_group) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`, [player_id, level, score, total, stars, duration_seconds, filter_group] ); const sessionId = rows[0].id; // Insert per-question answers (optional) if (Array.isArray(answers) && answers.length > 0) { const values = answers.map((_, i) => { const base = i * 4; return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4})`; }).join(', '); const params = answers.flatMap(a => [ sessionId, String(a.comarca), Boolean(a.correct), parseInt(a.response_ms, 10) || 0, ]); await pool.query( `INSERT INTO session_answers (session_id, comarca_name, correct, response_ms) VALUES ${values}`, params ); } res.status(201).json({ id: sessionId }); } catch (err) { res.status(500).json({ error: err.message }); } }); // GET /api/admin/stats Header: x-admin-pin app.get('/api/admin/stats', async (req, res) => { if (req.headers['x-admin-pin'] !== ADMIN_PIN) { return res.status(401).json({ error: 'PIN incorrecte' }); } try { const { rows: players } = await pool.query( 'SELECT id, name, avatar, created_at FROM players ORDER BY name' ); // Fetch all sessions in one query to avoid N+1 const { rows: allSessions } = await pool.query( `SELECT player_id, level, score, total, stars, created_at FROM sessions ORDER BY created_at DESC` ); const full = players.map(p => { const sessions = allSessions.filter(s => s.player_id === p.id); const totalStars = sessions.reduce((s, r) => s + r.stars, 0); const levelStats = buildLevelStats(sessions); const lastActivity = sessions[0]?.created_at ?? null; return { ...p, totalSessions: sessions.length, totalStars, levelStats, lastActivity }; }); res.json(full); } catch (err) { res.status(500).json({ error: err.message }); } }); // ── Start ───────────────────────────────────────────────────────────────────── initSchema() .then(() => app.listen(PORT, '0.0.0.0', () => console.log(`🏔️ Comarques API · port ${PORT} · PostgreSQL`) )) .catch(err => { console.error('❌ DB init failed:', err.message); process.exit(1); });