Initial commit: Padel Planner API
- Express API server met SQLite - Database migratie (migrate.cjs) - REST endpoints voor players, matches, scores - CHANGELOG.md en .gitignore
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
@@ -0,0 +1,8 @@
|
||||
# CHANGELOG
|
||||
|
||||
## 1.0.0 (2026-05-24)
|
||||
|
||||
- Eerste commit — Express API voor Padel Planner
|
||||
- SQLite database met players, matches, scores
|
||||
- REST endpoints: /players, /matches, /scores
|
||||
- PIN authenticatie voor spelers
|
||||
@@ -0,0 +1,527 @@
|
||||
// db.js — SQLite database layer voor Padel Planner API
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || '/data/data.db';
|
||||
|
||||
let db;
|
||||
|
||||
function getDb() {
|
||||
if (!db) {
|
||||
const dir = path.dirname(DB_PATH);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL'); // faster writes
|
||||
db.pragma('foreign_keys = ON');
|
||||
migrateSchema();
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
function migrateSchema() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS players (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
level INTEGER NOT NULL DEFAULT 5,
|
||||
position TEXT NOT NULL DEFAULT 'Beide',
|
||||
telegram_id TEXT,
|
||||
pin TEXT,
|
||||
sessions INTEGER NOT NULL DEFAULT 0,
|
||||
hours INTEGER NOT NULL DEFAULT 0,
|
||||
wins INTEGER NOT NULL DEFAULT 0,
|
||||
games INTEGER NOT NULL DEFAULT 0,
|
||||
avail_mode TEXT NOT NULL DEFAULT 'flex',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS player_availability (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_id TEXT NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
day_name TEXT NOT NULL, -- 'maandag', 'dinsdag', ...
|
||||
start_time TEXT NOT NULL, -- 'HH:MM'
|
||||
end_time TEXT NOT NULL, -- 'HH:MM'
|
||||
duration INTEGER NOT NULL DEFAULT 90
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_avail_player ON player_availability(player_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_avail_day ON player_availability(day_name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS matches (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT 'proposed',
|
||||
responses TEXT NOT NULL DEFAULT '{}',
|
||||
proposed_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
date TEXT,
|
||||
start TEXT,
|
||||
end TEXT,
|
||||
location TEXT,
|
||||
day TEXT,
|
||||
score TEXT, -- JSON score object
|
||||
booker_id TEXT, -- player id who should book
|
||||
booker_name TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS match_players (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
match_id TEXT NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
|
||||
player_id TEXT NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
team INTEGER, -- 1 or 2 (for scoring)
|
||||
score TEXT -- player's submitted score JSON
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_matchp_match ON match_players(match_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_matchp_player ON match_players(player_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
player_id TEXT REFERENCES players(id) ON DELETE SET NULL,
|
||||
data TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sesh_player ON sessions(player_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS player_match_stats (
|
||||
player_id TEXT NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
match_id TEXT NOT NULL REFERENCES matches(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (player_id, match_id)
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
// ─── Players ───
|
||||
|
||||
function getPlayers() {
|
||||
const db = getDb();
|
||||
const rows = db.prepare(`
|
||||
SELECT p.*, json_group_array(
|
||||
json_object('day_name', a.day_name, 'start', a.start_time, 'end', a.end_time, 'duration', a.duration)
|
||||
) FILTER (WHERE a.id IS NOT NULL) as availability_arr
|
||||
FROM players p
|
||||
LEFT JOIN player_availability a ON a.player_id = p.id
|
||||
GROUP BY p.id
|
||||
`).all();
|
||||
|
||||
return rows.map(r => rowToPlayer(r));
|
||||
}
|
||||
|
||||
function getPlayer(id) {
|
||||
const db = getDb();
|
||||
const r = db.prepare(`
|
||||
SELECT p.*, json_group_array(
|
||||
json_object('day_name', a.day_name, 'start', a.start_time, 'end', a.end_time, 'duration', a.duration)
|
||||
) FILTER (WHERE a.id IS NOT NULL) as availability_arr
|
||||
FROM players p
|
||||
LEFT JOIN player_availability a ON a.player_id = p.id
|
||||
WHERE p.id = ?
|
||||
GROUP BY p.id
|
||||
`).get(id);
|
||||
return r ? rowToPlayer(r) : null;
|
||||
}
|
||||
|
||||
function getPlayerByTelegram(telegramId) {
|
||||
const db = getDb();
|
||||
const r = db.prepare(`
|
||||
SELECT p.*, json_group_array(
|
||||
json_object('day_name', a.day_name, 'start', a.start_time, 'end', a.end_time, 'duration', a.duration)
|
||||
) FILTER (WHERE a.id IS NOT NULL) as availability_arr
|
||||
FROM players p
|
||||
LEFT JOIN player_availability a ON a.player_id = p.id
|
||||
WHERE p.telegram_id = ?
|
||||
GROUP BY p.id
|
||||
`).get(telegramId);
|
||||
return r ? rowToPlayer(r) : null;
|
||||
}
|
||||
|
||||
function addPlayer(player) {
|
||||
const db = getDb();
|
||||
const id = player.id || String(Date.now());
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO players (id, name, level, position, telegram_id, pin, sessions, hours, wins, games, avail_mode, availability_temp)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(id, player.name, player.level || 5, player.position || 'Beide',
|
||||
String(player.telegram_id || ''), String(player.pin || ''),
|
||||
player.sessions || 0, player.hours || 0,
|
||||
player.wins || 0, player.games || 0,
|
||||
player.avail_mode || 'flex',
|
||||
player.availability_temp || null);
|
||||
setPlayerAvailability(id, player.availability || {});
|
||||
return getPlayer(id);
|
||||
}
|
||||
|
||||
function updatePlayer(id, player) {
|
||||
const db = getDb();
|
||||
const existing = getPlayer(id);
|
||||
if (!existing) return null;
|
||||
|
||||
const merged = { ...existing, ...player };
|
||||
db.prepare(`
|
||||
UPDATE players SET name=?, level=?, position=?, telegram_id=?, pin=?,
|
||||
sessions=?, hours=?, wins=?, games=?, avail_mode=?, availability_temp=?
|
||||
WHERE id=?
|
||||
`).run(
|
||||
merged.name, merged.level, merged.position,
|
||||
String(merged.telegram_id || ''), String(merged.pin || ''),
|
||||
merged.sessions, merged.hours, merged.wins, merged.games,
|
||||
merged.avail_mode, merged.availability_temp || null, id
|
||||
);
|
||||
|
||||
if (player.availability !== undefined) {
|
||||
setPlayerAvailability(id, player.availability);
|
||||
}
|
||||
|
||||
return getPlayer(id);
|
||||
}
|
||||
|
||||
function deletePlayer(id) {
|
||||
if (!id) return false;
|
||||
const db = getDb();
|
||||
db.prepare(`DELETE FROM player_availability WHERE player_id = ?`).run(id);
|
||||
db.prepare(`DELETE FROM match_players WHERE player_id = ?`).run(id);
|
||||
db.prepare(`DELETE FROM sessions WHERE player_id = ?`).run(id);
|
||||
const result = db.prepare(`DELETE FROM players WHERE id = ?`).run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
// ─── Availability helpers ───
|
||||
|
||||
function setPlayerAvailability(playerId, availability) {
|
||||
const db = getDb();
|
||||
db.prepare(`DELETE FROM player_availability WHERE player_id = ?`).run(playerId);
|
||||
|
||||
// availability = { "maandag": [{start, end, duration}], ... }
|
||||
for (const [dayName, slots] of Object.entries(availability)) {
|
||||
if (dayName === 'isSet') continue;
|
||||
if (Array.isArray(slots) && slots.length > 0 && slots[0] && slots[0].start) {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO player_availability (player_id, day_name, start_time, end_time, duration)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`);
|
||||
for (const slot of slots) {
|
||||
stmt.run(playerId, dayName, slot.start, slot.end, slot.duration || 90);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Matches ───
|
||||
|
||||
function getMatches() {
|
||||
const db = getDb();
|
||||
const rows = db.prepare(`
|
||||
SELECT m.*, json_group_array(
|
||||
json_object('player_id', mp.player_id, 'team', mp.team, 'score', mp.score)
|
||||
) FILTER (WHERE mp.id IS NOT NULL) as players_arr
|
||||
FROM matches m
|
||||
LEFT JOIN match_players mp ON mp.match_id = m.id
|
||||
GROUP BY m.id
|
||||
ORDER BY m.created_at DESC
|
||||
`).all();
|
||||
|
||||
return rows.map(r => rowToMatch(r));
|
||||
}
|
||||
|
||||
function getMatch(id) {
|
||||
const db = getDb();
|
||||
const r = db.prepare(`
|
||||
SELECT m.*, json_group_array(
|
||||
json_object('player_id', mp.player_id, 'team', mp.team, 'score', mp.score)
|
||||
) FILTER (WHERE mp.id IS NOT NULL) as players_arr
|
||||
FROM matches m
|
||||
LEFT JOIN match_players mp ON mp.match_id = m.id
|
||||
WHERE m.id = ?
|
||||
GROUP BY m.id
|
||||
`).get(id);
|
||||
return r ? rowToMatch(r) : null;
|
||||
}
|
||||
|
||||
function addMatch(match) {
|
||||
const db = getDb();
|
||||
const id = match.id || String(Date.now());
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO matches (id, status, responses, proposed_at, date, start, end, location, day, score, booker_id, booker_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(id, match.status || 'proposed',
|
||||
JSON.stringify(match.responses || {}),
|
||||
match.proposed_at || new Date().toISOString(),
|
||||
match.date || null, match.start || null,
|
||||
match.end || null, match.location || null,
|
||||
match.day || null,
|
||||
typeof match.score === 'object' ? JSON.stringify(match.score) : (match.score || null),
|
||||
match.booker_id || null,
|
||||
match.booker_name || null);
|
||||
|
||||
if (Array.isArray(match.players)) {
|
||||
const pstmt = db.prepare(`INSERT INTO match_players (match_id, player_id, team, score) VALUES (?, ?, ?, ?)`);
|
||||
for (const p of match.players) {
|
||||
const pid = typeof p === 'object' ? p.id || p.player_id : p;
|
||||
pstmt.run(id, pid, p.team || null, p.score || null);
|
||||
}
|
||||
}
|
||||
|
||||
return getMatch(id);
|
||||
}
|
||||
|
||||
function updateMatch(id, match) {
|
||||
const db = getDb();
|
||||
const existing = getMatch(id);
|
||||
if (!existing) return null;
|
||||
|
||||
const merged = { ...existing, ...match };
|
||||
|
||||
const responsesStr = typeof merged.responses === 'object'
|
||||
? JSON.stringify(merged.responses) : merged.responses;
|
||||
|
||||
// Build SET dynamically to only update provided fields
|
||||
const fields = [];
|
||||
const values = [];
|
||||
for (const key of ['status', 'responses', 'proposed_at', 'date', 'start', 'end', 'location', 'day', 'score', 'booker_id', 'booker_name']) {
|
||||
if (match[key] !== undefined) {
|
||||
fields.push(`${key}=?`);
|
||||
let val = merged[key];
|
||||
if (key === 'responses' || (key === 'score' && typeof val === 'object' && val !== null)) {
|
||||
val = JSON.stringify(val);
|
||||
}
|
||||
values.push(key === 'booker_id' || key === 'booker_name' ? String(val) : val);
|
||||
}
|
||||
}
|
||||
if (fields.length > 0) {
|
||||
values.push(id);
|
||||
db.prepare(`UPDATE matches SET ${fields.join(', ')} WHERE id=?`).run(...values);
|
||||
}
|
||||
|
||||
if (match.players !== undefined) {
|
||||
db.prepare(`DELETE FROM match_players WHERE match_id = ?`).run(id);
|
||||
if (Array.isArray(match.players)) {
|
||||
const pstmt = db.prepare(`INSERT INTO match_players (match_id, player_id, team, score) VALUES (?, ?, ?, ?)`);
|
||||
for (const p of match.players) {
|
||||
const pid = typeof p === 'object' ? p.id || p.player_id : p;
|
||||
pstmt.run(id, pid, p.team || null, p.score || null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return getMatch(id);
|
||||
}
|
||||
|
||||
function deleteMatch(id) {
|
||||
if (!id) return false;
|
||||
const db = getDb();
|
||||
db.prepare(`DELETE FROM match_players WHERE match_id = ?`).run(id);
|
||||
const result = db.prepare(`DELETE FROM matches WHERE id = ?`).run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
// ─── Sessions ───
|
||||
|
||||
function getSessions() {
|
||||
const db = getDb();
|
||||
return db.prepare(`SELECT * FROM sessions`).all().map(r => ({
|
||||
...r,
|
||||
data: safeJsonParse(r.data, {})
|
||||
}));
|
||||
}
|
||||
|
||||
function getSession(id) {
|
||||
const db = getDb();
|
||||
const r = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(id);
|
||||
if (!r) return null;
|
||||
return { ...r, data: safeJsonParse(r.data, {}) };
|
||||
}
|
||||
|
||||
function setSession(id, playerId, data) {
|
||||
const db = getDb();
|
||||
const dataStr = JSON.stringify(data || {});
|
||||
const existing = db.prepare(`SELECT id FROM sessions WHERE id = ?`).get(id);
|
||||
if (existing) {
|
||||
db.prepare(`UPDATE sessions SET data=?, updated_at=datetime('now') WHERE id=?`).run(dataStr, id);
|
||||
} else {
|
||||
db.prepare(`INSERT INTO sessions (id, player_id, data) VALUES (?, ?, ?)`).run(id, playerId, dataStr);
|
||||
}
|
||||
return getSession(id);
|
||||
}
|
||||
|
||||
function deleteSession(id) {
|
||||
if (!id) return false;
|
||||
const db = getDb();
|
||||
const result = db.prepare(`DELETE FROM sessions WHERE id = ?`).run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
// ─── Settings ───
|
||||
|
||||
function getSettings() {
|
||||
const db = getDb();
|
||||
const rows = db.prepare(`SELECT * FROM settings`).all();
|
||||
const obj = {};
|
||||
for (const r of rows) obj[r.key] = r.value;
|
||||
return obj;
|
||||
}
|
||||
|
||||
function setSetting(key, value) {
|
||||
const db = getDb();
|
||||
db.prepare(`INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)`).run(key, String(value));
|
||||
}
|
||||
|
||||
function getSetting(key) {
|
||||
const db = getDb();
|
||||
const r = db.prepare(`SELECT value FROM settings WHERE key = ?`).get(key);
|
||||
return r ? r.value : null;
|
||||
}
|
||||
|
||||
// ─── Row Converters ───
|
||||
|
||||
function rowToPlayer(r) {
|
||||
let avail = {};
|
||||
try {
|
||||
const arr = JSON.parse(r.availability_arr);
|
||||
if (Array.isArray(arr)) {
|
||||
for (const a of arr) {
|
||||
if (!a || !a.day_name) continue;
|
||||
if (!avail[a.day_name]) avail[a.day_name] = [];
|
||||
avail[a.day_name].push({ start: a.start, end: a.end, duration: a.duration });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
// Ensure no empty slots create { day_name: [] }
|
||||
for (const k of Object.keys(avail)) {
|
||||
if (avail[k].length === 0) delete avail[k];
|
||||
}
|
||||
// Restore empty availability
|
||||
if (Object.keys(avail).length === 0) {
|
||||
avail = { isSet: false, days: {} };
|
||||
}
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
level: r.level,
|
||||
position: r.position,
|
||||
telegram_id: r.telegram_id ? Number(r.telegram_id) : null,
|
||||
pin: r.pin,
|
||||
sessions: r.sessions || 0,
|
||||
hours: r.hours || 0,
|
||||
wins: r.wins || 0,
|
||||
games: r.games || 0,
|
||||
avail_mode: r.avail_mode || 'flex',
|
||||
weekly_schedule: null,
|
||||
availability: avail,
|
||||
availability_temp: (r.availability_temp && r.availability_temp !== 'null') ? JSON.parse(r.availability_temp) : null
|
||||
};
|
||||
}
|
||||
|
||||
function rowToMatch(r) {
|
||||
let players = [];
|
||||
try {
|
||||
const arr = JSON.parse(r.players_arr);
|
||||
if (Array.isArray(arr)) {
|
||||
players = arr.filter(p => p && p.player_id).map(p => ({
|
||||
id: p.player_id,
|
||||
team: p.team || null,
|
||||
score: p.score || null
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
let responses = {};
|
||||
try { responses = JSON.parse(r.responses); } catch (e) { /* ignore */ }
|
||||
|
||||
let score = null;
|
||||
if (r.score) {
|
||||
try { score = JSON.parse(r.score); } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
status: r.status,
|
||||
responses: responses,
|
||||
proposed_at: r.proposed_at,
|
||||
date: r.date,
|
||||
start: r.start,
|
||||
end: r.end,
|
||||
location: r.location,
|
||||
day: r.day,
|
||||
score: score,
|
||||
booker_id: r.booker_id || null,
|
||||
booker_name: r.booker_name || null,
|
||||
players: players
|
||||
};
|
||||
}
|
||||
|
||||
function safeJsonParse(str, fallback) {
|
||||
try { return JSON.parse(str); } catch (e) { return fallback; }
|
||||
}
|
||||
|
||||
// ─── Player Match Stats (idempotent score tracking) ───
|
||||
|
||||
function hasMatchStat(playerId, matchId) {
|
||||
const db = getDb();
|
||||
const r = db.prepare('SELECT 1 FROM player_match_stats WHERE player_id = ? AND match_id = ?').get(String(playerId), String(matchId));
|
||||
return !!r;
|
||||
}
|
||||
|
||||
function addMatchStat(playerId, matchId) {
|
||||
const db = getDb();
|
||||
db.prepare('INSERT OR IGNORE INTO player_match_stats (player_id, match_id) VALUES (?, ?)').run(String(playerId), String(matchId));
|
||||
}
|
||||
|
||||
// ─── Close (for graceful shutdown) ───
|
||||
function close() {
|
||||
if (db) {
|
||||
db.close();
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ─── Availability Temp helpers ───
|
||||
function getPlayerAvailTempWeek(playerId, weekKey) {
|
||||
const db = getDb();
|
||||
const row = db.prepare("SELECT availability_temp FROM players WHERE id = ?").get(playerId);
|
||||
if (!row || !row.availability_temp) return null;
|
||||
try {
|
||||
const temp = JSON.parse(row.availability_temp);
|
||||
return temp[weekKey] || null;
|
||||
} catch(e) { return null; }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getDb,
|
||||
close,
|
||||
getPlayerAvailTempWeek,
|
||||
getPlayers,
|
||||
getPlayer,
|
||||
getPlayerByTelegram,
|
||||
addPlayer,
|
||||
updatePlayer,
|
||||
deletePlayer,
|
||||
getMatches,
|
||||
getMatch,
|
||||
addMatch,
|
||||
updateMatch,
|
||||
deleteMatch,
|
||||
getSessions,
|
||||
getSession,
|
||||
setSession,
|
||||
deleteSession,
|
||||
getSettings,
|
||||
setSetting,
|
||||
getSetting,
|
||||
hasMatchStat,
|
||||
addMatchStat
|
||||
};
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
// migrate.js — Migreer data.json naar SQLite
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Laad de db.js module — we moeten even opletten dat we in de juiste dir staan
|
||||
process.env.DB_PATH = '/data/data.db';
|
||||
|
||||
// We importeren db.js met require, maar omdat we het nog niet in de container hebben
|
||||
// zetten we de module in de juiste dir
|
||||
console.log('📦 Data migratie: JSON → SQLite');
|
||||
console.log('────────────────────────────────\n');
|
||||
|
||||
const DATA_PATH = process.env.DATA_PATH || '/data/data.json';
|
||||
|
||||
if (!fs.existsSync(DATA_PATH)) {
|
||||
console.log('❌ Geen data.json gevonden op', DATA_PATH);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const data = JSON.parse(fs.readFileSync(DATA_PATH, 'utf-8'));
|
||||
console.log(`📖 Ingelezen: ${data.players ? data.players.length : 0} spelers, ${data.matches ? data.matches.length : 0} matches, ${data.settings ? Object.keys(data.settings).length : 0} settings`);
|
||||
|
||||
// Laad db.js — gebruik een relatief pad zodat we ook via node /app/api/migrate.js kunnen draaien
|
||||
const db = require('./db.cjs');
|
||||
|
||||
// ─── Players migreren ───
|
||||
if (Array.isArray(data.players)) {
|
||||
console.log(`\n👥 Spelers (${data.players.length}):`);
|
||||
for (const p of data.players) {
|
||||
try {
|
||||
const existing = db.getPlayerByTelegram(p.telegram_id);
|
||||
if (existing) {
|
||||
console.log(` ⏭️ ${p.name} — bestaat al (telegram_id ${p.telegram_id})`);
|
||||
continue;
|
||||
}
|
||||
db.addPlayer(p);
|
||||
console.log(` ✅ ${p.name} — level ${p.level}, ${p.position} (PIN: ${p.pin})`);
|
||||
} catch (e) {
|
||||
console.log(` ❌ ${p.name} — FOUT: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Matches migreren ───
|
||||
if (Array.isArray(data.matches)) {
|
||||
console.log(`\n🎾 Matches (${data.matches.length}):`);
|
||||
for (const m of data.matches) {
|
||||
try {
|
||||
// Check of match al bestaat
|
||||
const existing = db.getMatch(m.id);
|
||||
if (existing) {
|
||||
console.log(` ⏭️ Match ${m.id} — bestaat al`);
|
||||
continue;
|
||||
}
|
||||
db.addMatch(m);
|
||||
console.log(` ✅ Match ${m.id} — status: ${m.status}, spelers: ${Array.isArray(m.players) ? m.players.length : 0}`);
|
||||
} catch (e) {
|
||||
console.log(` ❌ Match ${m.id} — FOUT: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Settings migreren ───
|
||||
if (data.settings && typeof data.settings === 'object') {
|
||||
console.log(`\n⚙️ Settings (${Object.keys(data.settings).length}):`);
|
||||
for (const [key, value] of Object.entries(data.settings)) {
|
||||
db.setSetting(key, value);
|
||||
console.log(` ✅ ${key} = ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sessions migreren ───
|
||||
if (Array.isArray(data.sessions)) {
|
||||
console.log(`\n🔑 Sessions (${data.sessions.length}):`);
|
||||
for (const s of data.sessions) {
|
||||
try {
|
||||
db.setSession(s.id, s.player_id || s.playerId, s.data || {});
|
||||
console.log(` ✅ Session ${s.id}`);
|
||||
} catch (e) {
|
||||
console.log(` ❌ Session ${s.id} — FOUT: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Verificatie ───
|
||||
console.log('\n📊 Verificatie:');
|
||||
const players = db.getPlayers();
|
||||
const matches = db.getMatches();
|
||||
console.log(` 👥 Spelers in DB: ${players.length}`);
|
||||
console.log(` 🎾 Matches in DB: ${matches.length}`);
|
||||
console.log(` ⚙️ Settings in DB:`, JSON.stringify(db.getSettings()));
|
||||
|
||||
// Toon spelers
|
||||
for (const p of players) {
|
||||
const days = p.availability && !p.availability.isSet
|
||||
? Object.keys(p.availability).filter(k => k !== 'isSet' && k !== 'days').length
|
||||
: 0;
|
||||
console.log(` 📍 ${p.name}: level ${p.level}, ${days} beschikbare dagen`);
|
||||
}
|
||||
|
||||
console.log('\n✅ Migratie voltooid!');
|
||||
console.log(`📁 data.json: ${DATA_PATH}`);
|
||||
console.log(`🗄️ SQLite: ${process.env.DB_PATH}`);
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "padel-planner",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node api/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"express": "^4.21.0",
|
||||
"playwright": "^1.60.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env node
|
||||
// Peakz Padel beschikbaarheid scraper
|
||||
// Aanroep: node padel-peakz.mjs [datum] [locatie]
|
||||
// Voorbeeld: node padel-peakz.mjs 2026-05-24 Atoomweg
|
||||
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const LOCATIONS = {
|
||||
'Atoomweg': '6637dd8e-cd4b-4fee-af49-196f6828b7dc',
|
||||
'Groningen': '6637dd8e-cd4b-4fee-af49-196f6828b7dc',
|
||||
};
|
||||
const RESERVATION_TYPE = '6';
|
||||
const PLAYING_TIME = '90';
|
||||
|
||||
async function getAvailability(dateStr, locationName) {
|
||||
const locationId = LOCATIONS[locationName] || LOCATIONS['Atoomweg'];
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36'
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
try {
|
||||
const url = `https://www.peakzpadel.nl/reserveren/court-booking/reservation?daypart=---&date=${dateStr}&location=${encodeURIComponent(locationName)}&playingTimes=${PLAYING_TIME}&courtTypeIds=13`;
|
||||
await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const slots = await page.evaluate(() => {
|
||||
const buttons = document.querySelectorAll('button');
|
||||
const results = [];
|
||||
for (const btn of buttons) {
|
||||
const text = btn.textContent.trim();
|
||||
if (text.match(/^\d{2}:\d{2}/)) {
|
||||
const price = text.match(/€\s*([\d,]+)/);
|
||||
results.push({
|
||||
time: text.split(' ')[0],
|
||||
price: price ? price[1] : null,
|
||||
available: !btn.disabled
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
});
|
||||
|
||||
return {
|
||||
date: dateStr,
|
||||
location: locationName,
|
||||
slots,
|
||||
available: slots.filter(s => s.available),
|
||||
unavailable: slots.filter(s => !s.available)
|
||||
};
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
// CLI mode
|
||||
const dateArg = process.argv[2] || new Date().toISOString().split('T')[0];
|
||||
const locationArg = process.argv[3] || 'Atoomweg';
|
||||
|
||||
getAvailability(dateArg, locationArg).then(result => {
|
||||
if (result.error) {
|
||||
console.error('Fout:', result.error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Output als JSON voor machine-readable
|
||||
if (process.argv.includes('--json')) {
|
||||
console.log(JSON.stringify(result));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Human readable
|
||||
const dayNames = ['zo', 'ma', 'di', 'wo', 'do', 'vr', 'za'];
|
||||
const d = new Date(result.date);
|
||||
const dayLabel = dayNames[d.getDay()];
|
||||
const dateLabel = `${dayLabel} ${d.getDate()}/${d.getMonth()+1}`;
|
||||
|
||||
console.log(`\n🏓 Peakz Padel — ${result.location}`);
|
||||
console.log(`📅 ${dateLabel}`);
|
||||
console.log(`🟢 ${result.available.length} banen vrij`);
|
||||
console.log(`🔴 ${result.unavailable.length} banen bezet`);
|
||||
console.log('');
|
||||
|
||||
if (result.available.length > 0) {
|
||||
console.log('Vrije tijden:');
|
||||
for (const s of result.available) {
|
||||
const price = s.price ? ` — €${s.price}` : '';
|
||||
console.log(` 🟢 ${s.time}${price}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('');
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,536 @@
|
||||
// server.js — Padel Planner API (SQLite backend)
|
||||
// Migrated from JSON file storage to SQLite with better-sqlite3
|
||||
import express from "express";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { createRequire } from "module";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
const PORT = 3000;
|
||||
|
||||
// Load SQLite db layer (CommonJS module via createRequire)
|
||||
const require = createRequire(import.meta.url);
|
||||
const db = require("./db/db.cjs");
|
||||
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, "..", "public")));
|
||||
|
||||
// ─── Helpers ───
|
||||
function getCurrentSeason() {
|
||||
const now = new Date();
|
||||
const q = Math.ceil((now.getMonth() + 1) / 3);
|
||||
return `Q${q}-${now.getFullYear()}`;
|
||||
}
|
||||
|
||||
function getSeasons() {
|
||||
const now = new Date();
|
||||
const seasons = [];
|
||||
for (let i = 3; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i * 3, 1);
|
||||
const q = Math.ceil((d.getMonth() + 1) / 3);
|
||||
seasons.push(`Q${q}-${d.getFullYear()}`);
|
||||
}
|
||||
return [...new Set(seasons)];
|
||||
}
|
||||
|
||||
function calcAdjustedLevel(player) {
|
||||
const base = player.level || 5;
|
||||
const wins = player.wins || 0;
|
||||
const games = player.games || 0;
|
||||
if (games < 3) return base;
|
||||
const winrate = wins / games;
|
||||
let adj = 0;
|
||||
if (winrate > 0.75) adj = 2;
|
||||
else if (winrate > 0.60) adj = 1;
|
||||
else if (winrate < 0.30) adj = -1;
|
||||
return Math.max(1, Math.min(10, base + adj));
|
||||
}
|
||||
|
||||
function genPin() {
|
||||
return String(Math.floor(1000 + Math.random() * 9000));
|
||||
}
|
||||
|
||||
function matchSeason(m) {
|
||||
if (!m.date) return null;
|
||||
const d = new Date(m.date);
|
||||
const q = Math.ceil((d.getMonth() + 1) / 3);
|
||||
return `Q${q}-${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
// ─── PLAYERS ───
|
||||
app.get("/api/players", (_, res) => {
|
||||
res.json(db.getPlayers());
|
||||
});
|
||||
|
||||
app.post("/api/players", (req, res) => {
|
||||
const p = req.body;
|
||||
p.id = Date.now() + Math.floor(Math.random() * 1000);
|
||||
p.pin = p.pin || genPin();
|
||||
p.sessions = p.sessions || 0;
|
||||
p.hours = p.hours || 0;
|
||||
p.availability = p.availability || { isSet: false, days: {} };
|
||||
p.wins = 0;
|
||||
p.games = 0;
|
||||
const created = db.addPlayer(p);
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
app.post("/api/login", (req, res) => {
|
||||
const { pin, name, telegram_id } = req.body;
|
||||
let player;
|
||||
if (telegram_id) {
|
||||
player = db.getPlayerByTelegram(String(telegram_id));
|
||||
} else {
|
||||
const players = db.getPlayers();
|
||||
if (name) {
|
||||
player = players.find(p => p.pin === pin && p.name.trim().toLowerCase() === name.trim().toLowerCase());
|
||||
} else {
|
||||
player = players.find(p => p.pin === pin);
|
||||
}
|
||||
}
|
||||
if (!player) return res.status(401).json({ error: "Ongeldige combinatie" });
|
||||
const resp = { id: player.id, name: player.name, level: player.level, position: player.position, pin: player.pin, availability: player.availability, avail_mode: player.avail_mode, weekly_schedule: player.weekly_schedule, telegram_id: player.telegram_id, availability_temp: player.availability_temp || null };
|
||||
res.json(resp);
|
||||
});
|
||||
|
||||
app.put("/api/players/:id", (req, res) => {
|
||||
const updated = db.updatePlayer(String(req.params.id), req.body);
|
||||
if (!updated) return res.status(404).json({ error: "not found" });
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
app.put("/api/players/:id/availability", (req, res) => {
|
||||
const existing = db.getPlayer(String(req.params.id));
|
||||
if (!existing) return res.status(404).json({ error: "not found" });
|
||||
const body = req.body.availability ? req.body : { availability: req.body };
|
||||
const updates = {
|
||||
availability: body.availability !== undefined ? body.availability : existing.availability,
|
||||
avail_mode: body.avail_mode || existing.avail_mode || "flex",
|
||||
};
|
||||
// availability_temp = { "2026-W22": { maandag: [...], dinsdag: [...] } }
|
||||
if (body.availability_temp !== undefined) {
|
||||
updates.availability_temp = JSON.stringify(body.availability_temp);
|
||||
}
|
||||
const updated = db.updatePlayer(String(req.params.id), updates);
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
app.delete("/api/players/:id", (req, res) => {
|
||||
db.deletePlayer(String(req.params.id));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── SESSIONS ───
|
||||
app.get("/api/sessions", (req, res) => {
|
||||
let sessions = db.getSessions();
|
||||
if (req.query.date) {
|
||||
sessions = sessions.filter(s => {
|
||||
// Check zowel top-level als in data object
|
||||
const sDate = s.data?.date || s.date;
|
||||
return sDate === req.query.date;
|
||||
});
|
||||
}
|
||||
res.json(sessions);
|
||||
});
|
||||
|
||||
app.post("/api/sessions", (req, res) => {
|
||||
const s = req.body;
|
||||
s.id = Date.now() + Math.floor(Math.random() * 1000);
|
||||
s.status = s.status || 'scheduled';
|
||||
s.player_id = s.player_id || s.playerId || "";
|
||||
// Store structured data for backward compat (stats/date queries)
|
||||
const sessionData = {
|
||||
date: s.date || null,
|
||||
start: s.start || null,
|
||||
end: s.end || null,
|
||||
players: s.players || [],
|
||||
status: s.status,
|
||||
match_id: s.match_id || null
|
||||
};
|
||||
db.setSession(s.id, s.player_id, sessionData);
|
||||
// Backward compat: return the raw body + generated id
|
||||
res.status(201).json({ ...s, id: s.id });
|
||||
});
|
||||
|
||||
app.put("/api/sessions/:id", (req, res) => {
|
||||
const existing = db.getSession(String(req.params.id));
|
||||
if (!existing) return res.status(404).json({ error: "not found" });
|
||||
const updated = db.setSession(String(req.params.id), existing.player_id, { ...existing.data, ...req.body.data, ...req.body });
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
app.delete("/api/sessions/:id", (req, res) => {
|
||||
db.deleteSession(String(req.params.id));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── GROUPS (settings-based — stored as setting "group:<id>") ───
|
||||
app.get("/api/groups", (_, res) => {
|
||||
const settings = db.getSettings();
|
||||
const groups = [];
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
if (key.startsWith("group:")) {
|
||||
try {
|
||||
groups.push(JSON.parse(value));
|
||||
} catch { /* skip invalid */ }
|
||||
}
|
||||
}
|
||||
res.json(groups);
|
||||
});
|
||||
|
||||
app.post("/api/groups", (req, res) => {
|
||||
const g = req.body;
|
||||
g.id = Date.now() + Math.floor(Math.random() * 1000);
|
||||
db.setSetting(`group:${g.id}`, JSON.stringify(g));
|
||||
res.status(201).json(g);
|
||||
});
|
||||
|
||||
app.delete("/api/groups/:id", (req, res) => {
|
||||
// Remove setting key
|
||||
const settings = db.getSettings();
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
if (key.startsWith("group:") && key === `group:${req.params.id}`) {
|
||||
// Can't easily delete — use sqlite directly
|
||||
const dbase = db.getDb();
|
||||
dbase.prepare("DELETE FROM settings WHERE key = ?").run(key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── STATS (from players data only, sessions are legacy) ───
|
||||
app.get("/api/stats", (_, res) => {
|
||||
const sessions = db.getSessions();
|
||||
const total_sessions = sessions.length;
|
||||
const total_hours = sessions.reduce((sum, s) => {
|
||||
const d = s.data || {};
|
||||
const start = s.start || d.start || "00:00";
|
||||
const end = s.end || d.end || "00:00";
|
||||
const [sh, sm] = start.split(":").map(Number);
|
||||
const [eh, em] = end.split(":").map(Number);
|
||||
return sum + ((eh + em / 60) - (sh + sm / 60));
|
||||
}, 0);
|
||||
|
||||
const playerHours = {};
|
||||
sessions.forEach(s => {
|
||||
const d = s.data || {};
|
||||
const players = d.players || [];
|
||||
const start = s.start || d.start || "00:00";
|
||||
const end = s.end || d.end || "00:00";
|
||||
const [sh, sm] = start.split(":").map(Number);
|
||||
const [eh, em] = end.split(":").map(Number);
|
||||
const hrs = (eh + em / 60) - (sh + sm / 60);
|
||||
(players || []).forEach(pid => {
|
||||
playerHours[String(pid)] = (playerHours[String(pid)] || 0) + hrs;
|
||||
});
|
||||
});
|
||||
|
||||
const allPlayers = db.getPlayers();
|
||||
const top_players = Object.entries(playerHours)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([pid, hours]) => {
|
||||
const p = allPlayers.find(pl => String(pl.id) === pid);
|
||||
return { id: Number(pid), name: p?.name || "Unknown", hours: Math.round(hours * 10) / 10 };
|
||||
});
|
||||
|
||||
const byMonth = {};
|
||||
sessions.forEach(s => {
|
||||
const d = s.data || {};
|
||||
const dateStr = s.date || d.date || "";
|
||||
const m = dateStr.substring(0, 7);
|
||||
if (!m) return;
|
||||
const start = s.start || d.start || "00:00";
|
||||
const end = s.end || d.end || "00:00";
|
||||
const [sh, sm] = start.split(":").map(Number);
|
||||
const [eh, em] = end.split(":").map(Number);
|
||||
const hrs = (eh + em / 60) - (sh + sm / 60);
|
||||
byMonth[m] = (byMonth[m] || 0) + hrs;
|
||||
});
|
||||
|
||||
const hours_by_month = Object.entries(byMonth)
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([month, hours]) => ({ month, hours: Math.round(hours * 10) / 10 }));
|
||||
|
||||
res.json({ total_sessions, total_hours: Math.round(total_hours * 10) / 10, top_players, hours_by_month });
|
||||
});
|
||||
|
||||
// ─── MATCHES ───
|
||||
app.get("/api/matches", (req, res) => {
|
||||
// Return matches without internal fields, consistent with old API
|
||||
const matches = db.getMatches();
|
||||
// The old API returns matches with `players` as array of IDs (legacy)
|
||||
// Make backward-compatible: keep the new players format but add .players as IDs
|
||||
res.json(matches.map(m => {
|
||||
const playerIds = (m.players || []).map(p => p.id);
|
||||
return { ...m, players: playerIds, players_array: m.players };
|
||||
}));
|
||||
});
|
||||
|
||||
app.post("/api/matches", (req, res) => {
|
||||
const m = req.body;
|
||||
m.id = Date.now() + Math.floor(Math.random() * 1000);
|
||||
m.status = m.status || "proposed";
|
||||
m.responses = m.responses || {};
|
||||
m.proposed_at = new Date().toISOString();
|
||||
const created = db.addMatch(m);
|
||||
const playerIds = (created.players || []).map(p => p.id);
|
||||
res.status(201).json({ ...created, players: playerIds, players_array: created.players });
|
||||
});
|
||||
|
||||
app.get("/api/matches/open", (req, res) => {
|
||||
const playerId = req.query.player_id;
|
||||
if (!playerId) return res.status(400).json({ error: "player_id required" });
|
||||
const pid = String(playerId);
|
||||
const allMatches = db.getMatches();
|
||||
const open = allMatches.filter(m =>
|
||||
m.players && m.players.some(p => String(p.id) === pid) &&
|
||||
(m.status === "confirmed" || m.status === "completed") &&
|
||||
(!m.score || m.score.status !== "confirmed")
|
||||
);
|
||||
res.json(open.map(m => {
|
||||
const playerIds = (m.players || []).map(p => p.id);
|
||||
return { ...m, players: playerIds, players_array: m.players };
|
||||
}));
|
||||
});
|
||||
|
||||
app.post("/api/matches/:id/respond", (req, res) => {
|
||||
let m = db.getMatch(String(req.params.id));
|
||||
if (!m) return res.status(404).json({ error: "not found" });
|
||||
const { player_id, response } = req.body;
|
||||
|
||||
m.responses = m.responses || {};
|
||||
m.responses[String(player_id)] = response;
|
||||
|
||||
const allPlayers = m.players || [];
|
||||
const responses = m.responses || {};
|
||||
const allAccepted = allPlayers.every(p => responses[String(p.id)] === "accepted");
|
||||
const anyRejected = allPlayers.some(p => responses[String(p.id)] === "rejected");
|
||||
|
||||
if (allAccepted && allPlayers.length >= 4) {
|
||||
m.status = "confirmed";
|
||||
const bookerIdx = Math.floor(Math.random() * allPlayers.length);
|
||||
m.booker_id = allPlayers[bookerIdx].id;
|
||||
const allP = db.getPlayers();
|
||||
const booker = allP.find(p => String(p.id) === String(m.booker_id));
|
||||
m.booker_name = booker ? booker.name : "Onbekend";
|
||||
|
||||
// Create session
|
||||
const endTime = m.end || (() => {
|
||||
const [h, min] = (m.start || "17:00").split(":").map(Number);
|
||||
const endMin = h * 60 + min + 90;
|
||||
return `${String(Math.floor(endMin/60)).padStart(2,"0")}:${String(endMin%60).padStart(2,"0")}`;
|
||||
})();
|
||||
const session = {
|
||||
id: Date.now() + Math.floor(Math.random() * 1000),
|
||||
date: m.date,
|
||||
start: m.start,
|
||||
end: endTime,
|
||||
players: allPlayers.map(p => p.id),
|
||||
status: "scheduled",
|
||||
match_id: m.id,
|
||||
};
|
||||
db.setSession(session.id, "", { ...session });
|
||||
|
||||
// Mark rejected_slots for players who rejected — store in DB
|
||||
} else if (anyRejected) {
|
||||
m.status = "cancelled";
|
||||
const rejectedPlayers = allPlayers.filter(p => responses[String(p.id)] === "rejected");
|
||||
for (const rp of rejectedPlayers) {
|
||||
const p = db.getPlayer(String(rp.id));
|
||||
if (p) {
|
||||
if (!p.rejected_slots) p.rejected_slots = {};
|
||||
const key = `${m.date}_${m.start}`;
|
||||
if (!p.rejected_slots[key]) p.rejected_slots[key] = [];
|
||||
p.rejected_slots[key].push(m.id);
|
||||
db.updatePlayer(String(rp.id), { rejected_slots: p.rejected_slots });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve responses and status in DB
|
||||
db.updateMatch(String(req.params.id), { status: m.status, responses: m.responses, booker_id: m.booker_id, booker_name: m.booker_name });
|
||||
|
||||
m = db.getMatch(String(req.params.id));
|
||||
const playerIds = (m.players || []).map(p => p.id);
|
||||
res.json({ ...m, players: playerIds, players_array: m.players });
|
||||
});
|
||||
|
||||
app.delete("/api/matches/:id", (req, res) => {
|
||||
db.deleteMatch(String(req.params.id));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── SCORES ───
|
||||
app.post("/api/matches/:id/score", (req, res) => {
|
||||
let m = db.getMatch(String(req.params.id));
|
||||
if (!m) return res.status(404).json({ error: "match not found" });
|
||||
const { team1, team2, score, sets, submitted_by } = req.body;
|
||||
if (!team1 || !team2 || !submitted_by) return res.status(400).json({ error: "team1, team2, submitted_by required" });
|
||||
if (!Array.isArray(team1) || !Array.isArray(team2)) return res.status(400).json({ error: "team1/team2 must be arrays" });
|
||||
if (!m.players || !m.players.some(p => String(p.id) === String(submitted_by))) return res.status(400).json({ error: "submitted_by must be in match players" });
|
||||
|
||||
// Update match in DB
|
||||
const update = {
|
||||
status: m.status !== "completed" ? "completed" : m.status,
|
||||
score: { team1, team2, score, sets: sets || null, submitted_by: Number(submitted_by), verified_by: [], status: "pending" }
|
||||
};
|
||||
db.updateMatch(String(req.params.id), update);
|
||||
m = db.getMatch(String(req.params.id));
|
||||
const playerIds = (m.players || []).map(p => p.id);
|
||||
res.json({ ...m, players: playerIds, players_array: m.players });
|
||||
});
|
||||
|
||||
app.post("/api/matches/:id/verify", (req, res) => {
|
||||
let m = db.getMatch(String(req.params.id));
|
||||
if (!m) return res.status(404).json({ error: "match not found" });
|
||||
const { player_id } = req.body;
|
||||
if (!player_id) return res.status(400).json({ error: "player_id required" });
|
||||
|
||||
let score = m.score;
|
||||
if (!score) return res.status(400).json({ error: "no score to verify" });
|
||||
if (!score.verified_by) score.verified_by = [];
|
||||
if (score.verified_by.includes(Number(player_id))) return res.status(400).json({ error: "already verified" });
|
||||
|
||||
score.verified_by.push(Number(player_id));
|
||||
const opponentIds = [...(score.team1 || []), ...(score.team2 || [])].filter(id => Number(id) !== Number(score.submitted_by)).map(id => Number(id));
|
||||
const hasOpponentConfirm = score.verified_by.some(v => opponentIds.includes(Number(v)));
|
||||
|
||||
if (hasOpponentConfirm && score.status !== "confirmed") {
|
||||
score.status = "confirmed";
|
||||
score.confirmed_at = new Date().toISOString();
|
||||
|
||||
// Update player wins/losses (idempotent via player_match_stats tabel)
|
||||
for (const pid of score.team1) {
|
||||
const p = db.getPlayer(String(pid));
|
||||
if (p && !db.hasMatchStat(String(pid), m.id)) {
|
||||
db.updatePlayer(String(pid), { wins: (p.wins || 0) + 1, games: (p.games || 0) + 1 });
|
||||
db.addMatchStat(String(pid), m.id);
|
||||
}
|
||||
}
|
||||
for (const pid of score.team2) {
|
||||
const p = db.getPlayer(String(pid));
|
||||
if (p && !db.hasMatchStat(String(pid), m.id)) {
|
||||
db.updatePlayer(String(pid), { games: (p.games || 0) + 1 });
|
||||
db.addMatchStat(String(pid), m.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
score.status = hasOpponentConfirm ? "confirmed" : "partial";
|
||||
}
|
||||
|
||||
db.updateMatch(String(req.params.id), { score });
|
||||
m = db.getMatch(String(req.params.id));
|
||||
const playerIds = (m.players || []).map(p => p.id);
|
||||
res.json({ ...m, players: playerIds, players_array: m.players });
|
||||
});
|
||||
|
||||
// ─── RANKINGS ───
|
||||
app.get("/api/rankings", (req, res) => {
|
||||
const season = req.query.season || getCurrentSeason();
|
||||
const allMatches = db.getMatches();
|
||||
|
||||
const seasonMatches = allMatches.filter(m => {
|
||||
return matchSeason(m) === season && m.score && m.score.status === "confirmed";
|
||||
});
|
||||
|
||||
const playerSeasonStats = {};
|
||||
for (const m of seasonMatches) {
|
||||
if (!m.score) continue;
|
||||
for (const pid of m.score.team1) {
|
||||
const key = String(pid);
|
||||
if (!playerSeasonStats[key]) playerSeasonStats[key] = { wins: 0, losses: 0, games: 0 };
|
||||
playerSeasonStats[key].wins++;
|
||||
playerSeasonStats[key].games++;
|
||||
}
|
||||
for (const pid of m.score.team2) {
|
||||
const key = String(pid);
|
||||
if (!playerSeasonStats[key]) playerSeasonStats[key] = { wins: 0, losses: 0, games: 0 };
|
||||
playerSeasonStats[key].losses++;
|
||||
playerSeasonStats[key].games++;
|
||||
}
|
||||
}
|
||||
|
||||
const allPlayers = db.getPlayers();
|
||||
const rankings = allPlayers
|
||||
.filter(p => playerSeasonStats[String(p.id)])
|
||||
.map(p => {
|
||||
const ss = playerSeasonStats[String(p.id)] || { wins: 0, losses: 0, games: 0 };
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
wins: ss.wins,
|
||||
losses: ss.losses,
|
||||
games: ss.games,
|
||||
winrate: ss.games > 0 ? Math.round((ss.wins / ss.games) * 100) : 0,
|
||||
level: calcAdjustedLevel({ ...p, wins: ss.wins, games: ss.games }),
|
||||
points: 1000 + (ss.wins * 25) - (ss.losses * 20) + (calcAdjustedLevel({ ...p, wins: ss.wins, games: ss.games }) * 10)
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.points - a.points);
|
||||
|
||||
res.json({ rankings, seasons: getSeasons(), currentSeason: getCurrentSeason() });
|
||||
});
|
||||
|
||||
app.get("/api/matches/history", (req, res) => {
|
||||
const season = req.query.season || getCurrentSeason();
|
||||
const allMatches = db.getMatches();
|
||||
const allPlayers = db.getPlayers();
|
||||
|
||||
const completed = allMatches.filter(m => {
|
||||
if (!m.score || m.score.status !== "confirmed") return false;
|
||||
return matchSeason(m) === season;
|
||||
});
|
||||
|
||||
const history = completed.map(m => {
|
||||
const t1 = (m.score.team1 || []).map(pid => allPlayers.find(p => String(p.id) === String(pid))?.name || "?");
|
||||
const t2 = (m.score.team2 || []).map(pid => allPlayers.find(p => String(p.id) === String(pid))?.name || "?");
|
||||
return {
|
||||
id: m.id,
|
||||
date: m.date,
|
||||
start: m.start,
|
||||
score: m.score.score,
|
||||
sets: m.score.sets || null,
|
||||
team1: t1,
|
||||
team1_ids: m.score.team1,
|
||||
team2: t2,
|
||||
team2_ids: m.score.team2,
|
||||
season: matchSeason(m),
|
||||
confirmed_at: m.score.confirmed_at || m.proposed_at
|
||||
};
|
||||
}).reverse();
|
||||
|
||||
res.json(history);
|
||||
});
|
||||
|
||||
app.get("/api/seasons", (_, res) => {
|
||||
res.json({ seasons: getSeasons(), currentSeason: getCurrentSeason() });
|
||||
});
|
||||
|
||||
// Catch-all → index.html voor SPA routing
|
||||
app.get("*", (_, res) => {
|
||||
res.sendFile(path.join(__dirname, "..", "public", "index.html"));
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🏓 Padel Planner API running on port ${PORT} (SQLite)`);
|
||||
const players = db.getPlayers();
|
||||
const matches = db.getMatches();
|
||||
console.log(` 👥 Spelers: ${players.length} 🎾 Matches: ${matches.length}`);
|
||||
});
|
||||
|
||||
|
||||
// Graceful shutdown
|
||||
process.on("SIGTERM", () => { console.log("Shutting down..."); db.close(); process.exit(0); });
|
||||
process.on("SIGINT", () => { console.log("Shutting down..."); db.close(); process.exit(0); });
|
||||
|
||||
// Graceful shutdown
|
||||
const closeSignal = (sig) => {
|
||||
console.log("Shutting down (" + sig + ")...");
|
||||
if (typeof db.close === "function") db.close();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGTERM", () => closeSignal("SIGTERM"));
|
||||
process.on("SIGINT", () => closeSignal("SIGINT"));
|
||||
Reference in New Issue
Block a user