Files
padel-api/server.js
T
2026-05-25 09:44:34 +00:00

576 lines
21 KiB
JavaScript

// 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 });
});
// ─── TEAM INDELING (voorgesteld) ───
app.post("/api/matches/:id/teams", (req, res) => {
let m = db.getMatch(String(req.params.id));
if (!m) return res.status(404).json({ error: "match not found" });
const { team1, team2 } = req.body;
if (!Array.isArray(team1) || !Array.isArray(team2))
return res.status(400).json({ error: "team1 and team2 must be arrays" });
const allIds = [...team1, ...team2];
if (allIds.length !== 4)
return res.status(400).json({ error: "expected 4 players total" });
db.updateMatch(String(req.params.id), { proposed_teams: { team1, team2 } });
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.get("/api/matches/:id/teams", (req, res) => {
const m = db.getMatch(String(req.params.id));
if (!m) return res.status(404).json({ error: "match not found" });
const teams = m.proposed_teams || null;
res.json({ teams, match: { id: m.id, date: m.date, start: m.start, status: m.status, match_type: m.match_type || 'friendly' } });
});
// ─── MATCH TYPE (ranked/friendly) ───
app.post("/api/matches/:id/type", (req, res) => {
let m = db.getMatch(String(req.params.id));
if (!m) return res.status(404).json({ error: "match not found" });
const { match_type } = req.body;
if (match_type !== 'ranked' && match_type !== 'friendly')
return res.status(400).json({ error: "match_type must be 'ranked' or 'friendly'" });
db.updateMatch(String(req.params.id), { match_type });
m = db.getMatch(String(req.params.id));
const playerIds = (m.players || []).map(p => p.id);
res.json({ ...m, players: playerIds, players_array: m.players });
});
// ─── 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)
// Alleen voor ranked matches
if (m.match_type === 'ranked') {
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"));