commit da64562eb4cf43cd050df0b86fcb246a4739bf52 Author: root Date: Sun May 24 20:25:54 2026 +0000 Initial commit: Padel Planner API - Express API server met SQLite - Database migratie (migrate.cjs) - REST endpoints voor players, matches, scores - CHANGELOG.md en .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e78bd86 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +*.log +npm-debug.log* +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..afbc9c9 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/db.cjs b/db.cjs new file mode 100644 index 0000000..d5522ae --- /dev/null +++ b/db.cjs @@ -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 +}; diff --git a/migrate.cjs b/migrate.cjs new file mode 100644 index 0000000..11aab1e --- /dev/null +++ b/migrate.cjs @@ -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}`); diff --git a/package.json b/package.json new file mode 100644 index 0000000..d4d3882 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/padel-peakz.js b/padel-peakz.js new file mode 100644 index 0000000..b66a440 --- /dev/null +++ b/padel-peakz.js @@ -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); +}); diff --git a/server.js b/server.js new file mode 100644 index 0000000..a429b5e --- /dev/null +++ b/server.js @@ -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:") ─── +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"));