import express from 'express'; import { randomUUID } from 'node:crypto'; import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { authRoutes, requireAuth } from './auth.js'; const ROOT = dirname(fileURLToPath(import.meta.url)); const DATA_DIR = join(ROOT, 'data'); const DB_FILE = join(DATA_DIR, 'db.json'); const PORT = process.env.PORT || 3000; const HOST = process.env.HOST; // optional, z. B. 127.0.0.1, damit nur ein Proxy auf demselben Rechner zugreifen kann // ---------- Datenspeicher (JSON-Datei) ---------- function loadDb() { if (!existsSync(DB_FILE)) return { members: [], events: [], meetingPoints: [], statsArchive: [] }; const db = JSON.parse(readFileSync(DB_FILE, 'utf8')); const members = db.members || []; // Früher war die manuelle Korrektur eine einzelne Zahl – sie gilt für das laufende Jahr for (const m of members) { if (typeof m.playsManual === 'number') m.playsManual = { [new Date().getFullYear()]: m.playsManual }; } return { members, events: db.events || [], meetingPoints: db.meetingPoints || [], statsArchive: db.statsArchive || [], }; } function saveDb() { mkdirSync(DATA_DIR, { recursive: true }); const tmp = DB_FILE + '.tmp'; writeFileSync(tmp, JSON.stringify(db, null, 2)); renameSync(tmp, DB_FILE); } const db = loadDb(); // ---------- Validierung ---------- class BadRequest extends Error {} // memberIds: erlaubte IDs für "fährt am liebsten mit bei", pointIds: erlaubte Standard-Treffpunkte function cleanMember(body, { memberIds, pointIds = new Set(db.meetingPoints.map((p) => p.id)), selfId }) { const name = String(body?.name ?? '').trim(); if (!name) throw new BadRequest('Vorname fehlt.'); const hasCar = !!body.hasCar; const seats = hasCar ? Math.min(8, Math.max(1, parseInt(body.seats, 10) || 1)) : 0; const alwaysDrives = hasCar && !!body.alwaysDrives; const ridesWith = memberIds.has(body.ridesWith) && body.ridesWith !== selfId ? body.ridesWith : null; const meetingPoint = hasCar && pointIds.has(body.meetingPoint) ? body.meetingPoint : null; const external = !!body.external; return { name: name.slice(0, 50), external, hasCar, seats, alwaysDrives, meetingPoint, ridesWith }; } function cleanEvent(body) { const date = String(body?.date ?? ''); if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new BadRequest('Ungültiges Datum.'); const title = String(body.title ?? '').trim(); if (!title) throw new BadRequest('Titel fehlt.'); const note = String(body.note ?? '').trim(); return { date, title: title.slice(0, 80), note: note.slice(0, 500) }; } function cleanMeetingPoint(body) { const name = String(body?.name ?? '').trim(); if (!name) throw new BadRequest('Name des Treffpunkts fehlt.'); const address = String(body.address ?? '').trim(); if (!address) throw new BadRequest('Adresse fehlt.'); return { name: name.slice(0, 60), address: address.slice(0, 200) }; } function cleanPlaysCount(value) { return Math.min(999, Math.max(-999, parseInt(value, 10) || 0)); } // Manuelle Korrekturen pro Jahr: { "2026": 2, ... } function cleanPlaysManual(value) { const out = {}; if (value && typeof value === 'object') { for (const [year, n] of Object.entries(value)) { if (/^\d{4}$/.test(year) && cleanPlaysCount(n)) out[year] = cleanPlaysCount(n); } } return out; } function memberIds() { return new Set(db.members.map((m) => m.id)); } function findOr404(list, id, res, what) { const item = list.find((x) => x.id === id); if (!item) res.status(404).json({ error: `${what} nicht gefunden.` }); return item; } // ---------- App ---------- const app = express(); // Hinter nginx: echte Client-IP und HTTPS erkennen. Standard: Proxy auf demselben Rechner. // Im Docker-Container kommt nginx über das Docker-Netz → TRUST_PROXY="loopback, uniquelocal" app.set('trust proxy', process.env.TRUST_PROXY || 'loopback'); app.use(express.json({ limit: '1mb' })); // Öffentlich: Startseite, Login, CSS/Bilder app.use(express.static(join(ROOT, 'public'), { extensions: ['html'] })); authRoutes(app); // Nächste Spieltage für die Startseite – nur Datum, Titel und Anzahl der Zusagen function todayIso() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } app.get('/api/upcoming', (req, res) => { const today = todayIso(); res.json(db.events .filter((e) => e.date >= today) .sort((a, b) => a.date.localeCompare(b.date)) .slice(0, 3) .map((e) => ({ date: e.date, title: e.title, attendeeCount: e.attendees.length }))); }); // ---------- Jahresstatistik ---------- // Spiele pro Mitglied (ohne Externe) in einem Jahr: Spieltage mit Zusage bis heute + manuelle Korrektur function yearStats(year) { const today = todayIso(); const fromCalendar = new Map(); for (const ev of db.events) { if (!ev.date.startsWith(`${year}-`) || ev.date > today) continue; for (const id of ev.attendees) fromCalendar.set(id, (fromCalendar.get(id) || 0) + 1); } return db.members.filter((m) => !m.external).map((m) => { const calendar = fromCalendar.get(m.id) || 0; const manual = m.playsManual?.[year] || 0; return { id: m.id, name: m.name, calendar, manual, total: Math.max(0, calendar + manual) }; }).sort((a, b) => b.total - a.total || a.name.localeCompare(b.name, 'de')); } // Abgeschlossene Jahre werden einmalig eingefroren; das neue Jahr startet automatisch bei 0 function archiveFinishedYears() { const current = new Date().getFullYear(); const years = new Set(db.events.map((e) => +e.date.slice(0, 4))); for (const m of db.members) for (const y of Object.keys(m.playsManual || {})) years.add(+y); let changed = false; for (const year of [...years].sort()) { if (year >= current || db.statsArchive.some((a) => a.year === year)) continue; db.statsArchive.push({ year, savedAt: new Date().toISOString(), entries: yearStats(year) }); changed = true; console.log(`Jahresstatistik ${year} archiviert.`); } if (changed) { db.statsArchive.sort((a, b) => b.year - a.year); saveDb(); } } archiveFinishedYears(); setInterval(archiveFinishedYears, 60 * 60 * 1000); // ?year=2025 → archiviertes Jahr, sonst das laufende Jahr (live) app.get('/api/stats', (req, res) => { archiveFinishedYears(); const current = new Date().getFullYear(); const years = [current, ...db.statsArchive.map((a) => a.year)]; const year = parseInt(req.query.year, 10) || current; if (year === current) return res.json({ year, live: true, years, entries: yearStats(year) }); const archived = db.statsArchive.find((a) => a.year === year); if (!archived) return res.status(404).json({ error: `Für ${year} gibt es keine Statistik.` }); res.json({ year, live: false, years, savedAt: archived.savedAt, entries: archived.entries }); }); // Ab hier nur mit Login app.use(requireAuth); app.use(express.static(join(ROOT, 'private'), { extensions: ['html'], setHeaders: (res) => res.setHeader('Cache-Control', 'private, no-cache'), })); // Mitglieder app.get('/api/members', (req, res) => { res.json(db.members); }); app.post('/api/members', (req, res) => { const member = { id: randomUUID(), ...cleanMember(req.body, { memberIds: memberIds() }) }; db.members.push(member); saveDb(); res.status(201).json(member); }); app.put('/api/members/:id', (req, res) => { const member = findOr404(db.members, req.params.id, res, 'Mitglied'); if (!member) return; Object.assign(member, cleanMember(req.body, { memberIds: memberIds(), selfId: member.id })); saveDb(); res.json(member); }); app.delete('/api/members/:id', (req, res) => { const { id } = req.params; db.members = db.members.filter((m) => m.id !== id); for (const m of db.members) if (m.ridesWith === id) m.ridesWith = null; for (const ev of db.events) ev.attendees = ev.attendees.filter((a) => a !== id); saveDb(); res.status(204).end(); }); // Manuelle Korrektur der gespielten Spiele (z. B. spontane Fahrten, die nicht im Kalender stehen) app.put('/api/members/:id/plays-manual', (req, res) => { const member = findOr404(db.members, req.params.id, res, 'Mitglied'); if (!member) return; const year = new Date().getFullYear(); member.playsManual = { ...member.playsManual, [year]: cleanPlaysCount(req.body?.manual) }; saveDb(); res.json({ id: member.id, year, manual: member.playsManual[year] }); }); // Ersetzt die komplette Gruppe (Import aus JSON-Datei) app.post('/api/members/import', (req, res) => { if (!Array.isArray(req.body)) throw new BadRequest('Erwarte eine Liste von Mitgliedern.'); const rows = req.body .filter((m) => String(m?.name ?? '').trim()) .map((m) => ({ ...m, id: typeof m.id === 'string' && m.id ? m.id : randomUUID() })); const ids = new Set(rows.map((m) => m.id)); const imported = rows.map((m) => ({ id: m.id, ...cleanMember(m, { memberIds: ids, selfId: m.id }), playsManual: cleanPlaysManual(m.playsManual), })); db.members = imported; for (const ev of db.events) ev.attendees = ev.attendees.filter((a) => ids.has(a)); saveDb(); res.json(db.members); }); // Treffpunkte app.get('/api/meeting-points', (req, res) => { res.json([...db.meetingPoints].sort((a, b) => a.name.localeCompare(b.name, 'de'))); }); app.post('/api/meeting-points', (req, res) => { const point = { id: randomUUID(), ...cleanMeetingPoint(req.body) }; db.meetingPoints.push(point); saveDb(); res.status(201).json(point); }); app.put('/api/meeting-points/:id', (req, res) => { const point = findOr404(db.meetingPoints, req.params.id, res, 'Treffpunkt'); if (!point) return; Object.assign(point, cleanMeetingPoint(req.body)); saveDb(); res.json(point); }); app.delete('/api/meeting-points/:id', (req, res) => { db.meetingPoints = db.meetingPoints.filter((p) => p.id !== req.params.id); for (const m of db.members) if (m.meetingPoint === req.params.id) m.meetingPoint = null; saveDb(); res.status(204).end(); }); // Spieltage app.get('/api/events', (req, res) => { res.json([...db.events].sort((a, b) => a.date.localeCompare(b.date))); }); app.get('/api/events/:id', (req, res) => { const ev = findOr404(db.events, req.params.id, res, 'Termin'); if (ev) res.json(ev); }); app.post('/api/events', (req, res) => { const createdBy = db.members.some((m) => m.id === req.body?.createdBy) ? req.body.createdBy : null; const ev = { id: randomUUID(), ...cleanEvent(req.body), createdBy, attendees: createdBy ? [createdBy] : [], }; db.events.push(ev); saveDb(); res.status(201).json(ev); }); app.put('/api/events/:id', (req, res) => { const ev = findOr404(db.events, req.params.id, res, 'Termin'); if (!ev) return; Object.assign(ev, cleanEvent(req.body)); saveDb(); res.json(ev); }); app.delete('/api/events/:id', (req, res) => { db.events = db.events.filter((e) => e.id !== req.params.id); saveDb(); res.status(204).end(); }); // Zusagen / Absagen app.put('/api/events/:id/attendees/:memberId', (req, res) => { const ev = findOr404(db.events, req.params.id, res, 'Termin'); if (!ev) return; if (!findOr404(db.members, req.params.memberId, res, 'Mitglied')) return; if (!ev.attendees.includes(req.params.memberId)) ev.attendees.push(req.params.memberId); saveDb(); res.json(ev); }); app.delete('/api/events/:id/attendees/:memberId', (req, res) => { const ev = findOr404(db.events, req.params.id, res, 'Termin'); if (!ev) return; ev.attendees = ev.attendees.filter((a) => a !== req.params.memberId); saveDb(); res.json(ev); }); app.use('/api', (req, res) => { res.status(404).json({ error: 'Unbekannte Anfrage.' }); }); app.use((err, req, res, next) => { if (err instanceof BadRequest) return res.status(400).json({ error: err.message }); console.error(err); res.status(500).json({ error: 'Serverfehler.' }); }); app.listen(PORT, HOST, () => { console.log(`HCRB läuft auf http://${HOST || 'localhost'}:${PORT}`); });