099908c303
- Express server met 25 endpoints - SQLite via better-sqlite3 met WAL mode - player_match_stats voor idempotente score tracking - availability_temp voor per-week beschikbaarheid
537 lines
19 KiB
JavaScript
537 lines
19 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 });
|
|
});
|
|
|
|
// ─── 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"));
|