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:
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Playwright test screenshots
|
||||||
|
*.png
|
||||||
|
*.jpg
|
||||||
|
.playwright-mcp/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# SQLite (només al VPS)
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# Mac
|
||||||
|
.DS_Store
|
||||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Multi-arch: funciona en ARM64 (Oracle Ampere) i AMD64
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# pg (node-postgres) és pur JS — no cal compilació nativa
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --omit=dev --no-audit --no-fund
|
||||||
|
|
||||||
|
COPY server.js ./
|
||||||
|
|
||||||
|
# Els fitxers estàtics (index.html, comarca-paths.js) es munten com a volum
|
||||||
|
# en /app/public/ — no s'inclouen a la imatge per facilitar actualitzacions.
|
||||||
|
RUN mkdir -p /app/public
|
||||||
|
|
||||||
|
EXPOSE 3003
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
|
||||||
|
CMD wget -qO- http://localhost:3003/api/health || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
43
comarca-paths.js
Normal file
43
comarca-paths.js
Normal file
File diff suppressed because one or more lines are too long
44
deploy/deploy.sh
Executable file
44
deploy/deploy.sh
Executable file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# deploy.sh — Desplegar Comarques de Catalunya a l'Oracle VPS
|
||||||
|
# Ús: ./deploy.sh [usuari@host]
|
||||||
|
# Exemple: ./deploy.sh ubuntu@80.225.185.50
|
||||||
|
#
|
||||||
|
# NOTA: Aquest script NO toca la configuració de Nginx.
|
||||||
|
# La config SSL es gestiona manualment al VPS (veure docs Craft).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VPS="${1:-ubuntu@80.225.185.50}"
|
||||||
|
REMOTE_PUBLIC="/srv/docker/data/comarques/public"
|
||||||
|
REMOTE_COMPOSE="/srv/docker/compose"
|
||||||
|
LOCAL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
DOMAIN="comarques.jaumegar.work"
|
||||||
|
|
||||||
|
echo "▶ Desplegant a ${VPS}..."
|
||||||
|
|
||||||
|
# 1. Crear directoris remots si no existeixen
|
||||||
|
ssh "$VPS" "sudo mkdir -p ${REMOTE_PUBLIC} /var/www/certbot && \
|
||||||
|
sudo chown -R ubuntu:ubuntu /srv/docker/data/comarques"
|
||||||
|
|
||||||
|
# 2. Fitxers estàtics (Nginx els serveix directament — sense reiniciar Docker)
|
||||||
|
echo "▶ Pujant fitxers estàtics..."
|
||||||
|
scp "${LOCAL_DIR}/index.html" "${VPS}:${REMOTE_PUBLIC}/"
|
||||||
|
scp "${LOCAL_DIR}/comarca-paths.js" "${VPS}:${REMOTE_PUBLIC}/"
|
||||||
|
|
||||||
|
# 3. Codi del servidor Node.js
|
||||||
|
echo "▶ Pujant codi del servidor..."
|
||||||
|
ssh "$VPS" "mkdir -p /srv/docker/builds/comarques"
|
||||||
|
scp "${LOCAL_DIR}/server.js" "${VPS}:/srv/docker/builds/comarques/"
|
||||||
|
scp "${LOCAL_DIR}/package.json" "${VPS}:/srv/docker/builds/comarques/"
|
||||||
|
scp "${LOCAL_DIR}/Dockerfile" "${VPS}:/srv/docker/builds/comarques/"
|
||||||
|
|
||||||
|
# 4. Construir imatge i reiniciar contenidor
|
||||||
|
echo "▶ Construint imatge Docker..."
|
||||||
|
ssh "$VPS" "cd /srv/docker/builds/comarques && docker build -t comarques-de-catalunya:latest ."
|
||||||
|
|
||||||
|
echo "▶ Reiniciant contenidor..."
|
||||||
|
ssh "$VPS" "cd ${REMOTE_COMPOSE} && docker compose up -d comarques"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Desplegament completat!"
|
||||||
|
echo " https://${DOMAIN}"
|
||||||
35
deploy/docker-compose.snippet.yml
Normal file
35
deploy/docker-compose.snippet.yml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# ── Afegir aquest servei al docker-compose.yml existent ──────────────────────
|
||||||
|
# Fitxer: /srv/docker/compose/docker-compose.yml
|
||||||
|
#
|
||||||
|
# PREREQUISIT: crear la BD al contenidor gitea_db ABANS d'arrencar:
|
||||||
|
# docker exec -it gitea_db psql -U gitea -c "CREATE DATABASE comarques;"
|
||||||
|
# docker exec -it gitea_db psql -U gitea -c "CREATE USER comarques WITH PASSWORD 'el_teu_password';"
|
||||||
|
# docker exec -it gitea_db psql -U gitea -c "GRANT ALL PRIVILEGES ON DATABASE comarques TO comarques;"
|
||||||
|
|
||||||
|
comarques:
|
||||||
|
image: comarques-de-catalunya:latest
|
||||||
|
container_name: comarques
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3003:3003" # només accessible des de localhost (Nginx fa de proxy)
|
||||||
|
environment:
|
||||||
|
PORT: 3003
|
||||||
|
ADMIN_PIN: "${COMARQUES_ADMIN_PIN}"
|
||||||
|
DATABASE_URL: "${COMARQUES_DATABASE_URL}"
|
||||||
|
volumes:
|
||||||
|
- /srv/docker/data/comarques/public:/app/public:ro # fitxers estàtics (read-only)
|
||||||
|
networks:
|
||||||
|
- oracle-services
|
||||||
|
depends_on:
|
||||||
|
- gitea_db
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:3003/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
|
# ── Variables d'entorn ────────────────────────────────────────────────────────
|
||||||
|
# Afegir a /srv/docker/compose/.env:
|
||||||
|
# COMARQUES_ADMIN_PIN=el_teu_pin_secret
|
||||||
|
# COMARQUES_DATABASE_URL=postgresql://comarques:el_teu_password@gitea_db:5432/comarques
|
||||||
24
deploy/nginx-comarques-http.conf
Normal file
24
deploy/nginx-comarques-http.conf
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name comarques.jaumegar.work;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
root /srv/docker/data/comarques/public;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:3003;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
deploy/nginx-comarques.conf
Normal file
41
deploy/nginx-comarques.conf
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name comarques.jaumegar.work;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name comarques.jaumegar.work;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/comarques.jaumegar.work/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/comarques.jaumegar.work/privkey.pem;
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
|
# Static files served directly by Nginx (faster, no Node overhead)
|
||||||
|
root /srv/docker/data/comarques/public;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API proxied to Node.js backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:3003;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
}
|
||||||
|
}
|
||||||
1462
index.html
Normal file
1462
index.html
Normal file
File diff suppressed because it is too large
Load Diff
17
package.json
Normal file
17
package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "comarques-de-muntanya",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"description": "App educativa per aprendre les comarques de muntanya de Catalunya",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "node --watch server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.3",
|
||||||
|
"pg": "^8.11.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
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