534 lines
16 KiB
JavaScript
534 lines
16 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',
|
|
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
|
|
};
|