Compare commits

5 Commits

Author SHA1 Message Date
root d5fb1404e3 Fix: dubbele SIGTERM/SIGINT verwijderd (closeSignal blijft) 2026-05-25 18:45:42 +00:00
root c0b33c2144 fix: missing brace in ranked score block 2026-05-25 09:44:34 +00:00
root d192a02cc5 feat: match_type (ranked/friendly) + /api/matches/:id/type endpoint 2026-05-25 09:27:31 +00:00
root 558544e461 feat: team indeling endpoint + proposed_teams field 2026-05-25 08:29:01 +00:00
Nova Coder 099908c303 Initial commit: API server + DB layer v24/05/2026
- 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
2026-05-24 20:39:43 +00:00
7 changed files with 81 additions and 253 deletions
-5
View File
@@ -1,5 +0,0 @@
node_modules/
.env
*.log
npm-debug.log*
.DS_Store
-8
View File
@@ -1,8 +0,0 @@
# 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
+28 -9
View File
@@ -32,8 +32,10 @@ function migrateSchema() {
hours INTEGER NOT NULL DEFAULT 0, hours INTEGER NOT NULL DEFAULT 0,
wins INTEGER NOT NULL DEFAULT 0, wins INTEGER NOT NULL DEFAULT 0,
games INTEGER NOT NULL DEFAULT 0, games INTEGER NOT NULL DEFAULT 0,
avail_mode TEXT NOT NULL DEFAULT 'flex', avail_mode TEXT NOT NULL DEFAULT 'flex',
created_at TEXT NOT NULL DEFAULT (datetime('now')) availability_temp TEXT,
rejected_slots TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
CREATE TABLE IF NOT EXISTS player_availability ( CREATE TABLE IF NOT EXISTS player_availability (
@@ -75,6 +77,9 @@ function migrateSchema() {
CREATE INDEX IF NOT EXISTS idx_matchp_match ON match_players(match_id); 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 INDEX IF NOT EXISTS idx_matchp_player ON match_players(player_id);
-- Migration: match type kolom
ALTER TABLE matches ADD COLUMN match_type TEXT NOT NULL DEFAULT 'friendly';
CREATE TABLE IF NOT EXISTS sessions ( CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
player_id TEXT REFERENCES players(id) ON DELETE SET NULL, player_id TEXT REFERENCES players(id) ON DELETE SET NULL,
@@ -96,6 +101,12 @@ function migrateSchema() {
PRIMARY KEY (player_id, match_id) PRIMARY KEY (player_id, match_id)
); );
`); `);
// Idempotent column migrations
try { db.exec("ALTER TABLE matches ADD COLUMN proposed_teams TEXT"); } catch(e) {}
try { db.exec("ALTER TABLE matches ADD COLUMN players_arr TEXT DEFAULT '[]'"); } catch(e) {}
try { db.exec("ALTER TABLE players ADD COLUMN availability_temp TEXT"); } catch(e) {}
try { db.exec("ALTER TABLE players ADD COLUMN rejected_slots TEXT"); } catch(e) {}
} }
// ─── Players ─── // ─── Players ───
@@ -167,13 +178,13 @@ function updatePlayer(id, player) {
const merged = { ...existing, ...player }; const merged = { ...existing, ...player };
db.prepare(` db.prepare(`
UPDATE players SET name=?, level=?, position=?, telegram_id=?, pin=?, UPDATE players SET name=?, level=?, position=?, telegram_id=?, pin=?,
sessions=?, hours=?, wins=?, games=?, avail_mode=?, availability_temp=? sessions=?, hours=?, wins=?, games=?, avail_mode=?, availability_temp=?, rejected_slots=?
WHERE id=? WHERE id=?
`).run( `).run(
merged.name, merged.level, merged.position, merged.name, merged.level, merged.position,
String(merged.telegram_id || ''), String(merged.pin || ''), String(merged.telegram_id || ''), String(merged.pin || ''),
merged.sessions, merged.hours, merged.wins, merged.games, merged.sessions, merged.hours, merged.wins, merged.games,
merged.avail_mode, merged.availability_temp || null, id merged.avail_mode, merged.availability_temp || null, merged.rejected_slots || null, id
); );
if (player.availability !== undefined) { if (player.availability !== undefined) {
@@ -249,8 +260,8 @@ function addMatch(match) {
const db = getDb(); const db = getDb();
const id = match.id || String(Date.now()); const id = match.id || String(Date.now());
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT INTO matches (id, status, responses, proposed_at, date, start, end, location, day, score, booker_id, booker_name) INSERT INTO matches (id, status, responses, proposed_at, date, start, end, location, day, score, booker_id, booker_name, match_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
stmt.run(id, match.status || 'proposed', stmt.run(id, match.status || 'proposed',
JSON.stringify(match.responses || {}), JSON.stringify(match.responses || {}),
@@ -260,7 +271,8 @@ function addMatch(match) {
match.day || null, match.day || null,
typeof match.score === 'object' ? JSON.stringify(match.score) : (match.score || null), typeof match.score === 'object' ? JSON.stringify(match.score) : (match.score || null),
match.booker_id || null, match.booker_id || null,
match.booker_name || null); match.booker_name || null,
match.match_type || 'friendly');
if (Array.isArray(match.players)) { if (Array.isArray(match.players)) {
const pstmt = db.prepare(`INSERT INTO match_players (match_id, player_id, team, score) VALUES (?, ?, ?, ?)`); const pstmt = db.prepare(`INSERT INTO match_players (match_id, player_id, team, score) VALUES (?, ?, ?, ?)`);
@@ -286,11 +298,11 @@ function updateMatch(id, match) {
// Build SET dynamically to only update provided fields // Build SET dynamically to only update provided fields
const fields = []; const fields = [];
const values = []; const values = [];
for (const key of ['status', 'responses', 'proposed_at', 'date', 'start', 'end', 'location', 'day', 'score', 'booker_id', 'booker_name']) { for (const key of ['status', 'responses', 'proposed_at', 'date', 'start', 'end', 'location', 'day', 'score', 'booker_id', 'booker_name', 'proposed_teams', 'match_type']) {
if (match[key] !== undefined) { if (match[key] !== undefined) {
fields.push(`${key}=?`); fields.push(`${key}=?`);
let val = merged[key]; let val = merged[key];
if (key === 'responses' || (key === 'score' && typeof val === 'object' && val !== null)) { if (key === 'responses' || key === 'score' || key === 'proposed_teams') {
val = JSON.stringify(val); val = JSON.stringify(val);
} }
values.push(key === 'booker_id' || key === 'booker_name' ? String(val) : val); values.push(key === 'booker_id' || key === 'booker_name' ? String(val) : val);
@@ -446,6 +458,11 @@ function rowToMatch(r) {
try { score = JSON.parse(r.score); } catch (e) { /* ignore */ } try { score = JSON.parse(r.score); } catch (e) { /* ignore */ }
} }
let proposed_teams = null;
if (r.proposed_teams) {
try { proposed_teams = JSON.parse(r.proposed_teams); } catch (e) { /* ignore */ }
}
return { return {
id: r.id, id: r.id,
status: r.status, status: r.status,
@@ -457,6 +474,8 @@ function rowToMatch(r) {
location: r.location, location: r.location,
day: r.day, day: r.day,
score: score, score: score,
proposed_teams: proposed_teams,
match_type: r.match_type || 'friendly',
booker_id: r.booker_id || null, booker_id: r.booker_id || null,
booker_name: r.booker_name || null, booker_name: r.booker_name || null,
players: players players: players
-103
View File
@@ -1,103 +0,0 @@
// 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
@@ -1,13 +0,0 @@
{
"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
@@ -1,97 +0,0 @@
#!/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);
});
+53 -18
View File
@@ -11,7 +11,7 @@ const PORT = 3000;
// Load SQLite db layer (CommonJS module via createRequire) // Load SQLite db layer (CommonJS module via createRequire)
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const db = require("./db/db.cjs"); const db = require("./db.cjs");
app.use(express.json()); app.use(express.json());
app.use(express.static(path.join(__dirname, "..", "public"))); app.use(express.static(path.join(__dirname, "..", "public")));
@@ -363,6 +363,42 @@ app.delete("/api/matches/:id", (req, res) => {
res.json({ ok: true }); 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 ─── // ─── SCORES ───
app.post("/api/matches/:id/score", (req, res) => { app.post("/api/matches/:id/score", (req, res) => {
let m = db.getMatch(String(req.params.id)); let m = db.getMatch(String(req.params.id));
@@ -403,22 +439,25 @@ app.post("/api/matches/:id/verify", (req, res) => {
score.confirmed_at = new Date().toISOString(); score.confirmed_at = new Date().toISOString();
// Update player wins/losses (idempotent via player_match_stats tabel) // Update player wins/losses (idempotent via player_match_stats tabel)
for (const pid of score.team1) { // Alleen voor ranked matches
const p = db.getPlayer(String(pid)); if (m.match_type === 'ranked') {
if (p && !db.hasMatchStat(String(pid), m.id)) { for (const pid of score.team1) {
db.updatePlayer(String(pid), { wins: (p.wins || 0) + 1, games: (p.games || 0) + 1 }); const p = db.getPlayer(String(pid));
db.addMatchStat(String(pid), m.id); 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) {
for (const pid of score.team2) { const p = db.getPlayer(String(pid));
const p = db.getPlayer(String(pid)); if (p && !db.hasMatchStat(String(pid), m.id)) {
if (p && !db.hasMatchStat(String(pid), m.id)) { db.updatePlayer(String(pid), { games: (p.games || 0) + 1 });
db.updatePlayer(String(pid), { games: (p.games || 0) + 1 }); db.addMatchStat(String(pid), m.id);
db.addMatchStat(String(pid), m.id); }
} }
} else {
score.status = hasOpponentConfirm ? "confirmed" : "partial";
} }
} else {
score.status = hasOpponentConfirm ? "confirmed" : "partial";
} }
db.updateMatch(String(req.params.id), { score }); db.updateMatch(String(req.params.id), { score });
@@ -522,10 +561,6 @@ app.listen(PORT, () => {
}); });
// 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 // Graceful shutdown
const closeSignal = (sig) => { const closeSignal = (sig) => {
console.log("Shutting down (" + sig + ")..."); console.log("Shutting down (" + sig + ")...");