// 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:") ─── 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"));