Files
comarques-de-catalunya/server.js
Jaume Garriga Maestre 3f251d6dc2 feat: app educativa comarques de Catalunya v3
- 6 nivells de dificultat (flashcards, tria, uneix, escriu, mapa, mapa cec)
- Registre de jugadors sense contrasenya (nom + emoji avatar)
- Backend Node.js + Express + PostgreSQL (pg)
- Mapa SVG interactiu amb dades GeoJSON reals (ICGC)
- Filtre de comarques per jugador (muntanya, BCN, GI, LL, T, totes)
- Estadistiques per nivell guardades a PostgreSQL
- Panel d'administrador amb PIN
- Manual integrat per a nens de 10-12 anys
- Mode offline (fallback sense backend)
- Deploy: Docker + Nginx + Let's Encrypt a Oracle Cloud ARM
2026-05-02 00:15:30 +02:00

214 lines
7.7 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 is idempotent — safe to run 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);
`);
console.log('✅ Schema ready');
}
// ── Middleware ────────────────────────────────────────────────────────────────
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
// ── Helpers ───────────────────────────────────────────────────────────────────
/**
* Build per-level best-score stats from a flat sessions array.
* @param {Array} sessions
* @returns {Object} keyed by level 15
*/
function buildLevelStats(sessions) {
const stats = {};
for (let lvl = 1; lvl <= 5; lvl++) {
const ls = sessions.filter(s => s.level === lvl);
if (!ls.length) continue;
const best = ls.reduce((b, s) => (s.score / s.total > b.score / b.total ? s : b));
stats[lvl] = {
played: ls.length,
bestScore: best.score,
bestTotal: best.total,
bestPct: Math.round(best.score / best.total * 100),
};
}
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 });
}
});
// GET /api/players/:id/stats
app.get('/api/players/:id/stats', async (req, res) => {
try {
const playerRes = await pool.query(
'SELECT id, name, avatar, created_at FROM players WHERE id = $1',
[req.params.id]
);
if (!playerRes.rows.length) {
return res.status(404).json({ error: 'Jugador no trobat' });
}
const player = playerRes.rows[0];
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`,
[req.params.id]
);
const sessions = sessionRes.rows;
const totalStars = sessions.reduce((s, r) => s + r.stars, 0);
const totalSessions = sessions.length;
const levelStats = buildLevelStats(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,
}));
res.json({ player, totalStars, totalSessions, levelStats, recent });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/sessions { player_id, level, score, total, stars, duration_seconds? }
app.post('/api/sessions', async (req, res) => {
const { player_id, level, score, total, stars, duration_seconds = 0 } = 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' });
}
const { rows } = await pool.query(
`INSERT INTO sessions (player_id, level, score, total, stars, duration_seconds)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
[player_id, level, score, total, stars, duration_seconds]
);
res.status(201).json({ id: rows[0].id });
} 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);
});