// 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', 'proposed_teams']) { if (match[key] !== undefined) { fields.push(`${key}=?`); let val = merged[key]; if (key === 'responses' || key === 'score' || key === 'proposed_teams') { 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 */ } } let proposed_teams = null; if (r.proposed_teams) { try { proposed_teams = JSON.parse(r.proposed_teams); } 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, proposed_teams: proposed_teams, 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 };