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:
root
2026-05-24 20:25:54 +00:00
commit da64562eb4
7 changed files with 1289 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
.env
*.log
npm-debug.log*
.DS_Store
+8
View File
@@ -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
+527
View File
@@ -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
View File
@@ -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}`);
+13
View File
@@ -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"
}
}
+97
View File
@@ -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);
});
+536
View File
@@ -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"));