Initial commit: Padel Planner API
- Express API server met SQLite - Database migratie (migrate.cjs) - REST endpoints voor players, matches, scores - CHANGELOG.md en .gitignore
This commit is contained in:
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user