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
This commit is contained in:
213
server.js
Normal file
213
server.js
Normal file
@@ -0,0 +1,213 @@
|
||||
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 1–5
|
||||
*/
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user