Files
hcrb/server.js
T
2026-10-10 13:54:33 +02:00

332 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}`);
});