Files
comarques-de-catalunya/server.js
Jaume Garriga Maestre 9e6cc91a16
All checks were successful
CI · Test & Deploy / Playwright tests (push) Successful in 1m50s
CI · Test & Deploy / Deploy to VPS (push) Successful in 21s
Security · npm audit / npm audit (push) Successful in 9m28s
feat(admin): per-player stats drill-down from admin panel
2026-05-15 09:26:09 +02:00

391 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 18) 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<Object>} 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 18, 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);
});