Files
padel-api/db.cjs
T

547 lines
17 KiB
JavaScript

// 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',
availability_temp TEXT,
rejected_slots TEXT,
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);
-- Migration: match type kolom
ALTER TABLE matches ADD COLUMN match_type TEXT NOT NULL DEFAULT 'friendly';
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)
);
`);
// 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 ───
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=?, rejected_slots=?
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, merged.rejected_slots || 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, match_type)
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,
match.match_type || 'friendly');
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', 'match_type']) {
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,
match_type: r.match_type || 'friendly',
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
};