mirror of
https://github.com/guyg1474/hcrb.git
synced 2026-10-16 07:13:28 +02:00
Erste Version: HCRB Spieltag-Kalender und Fahrtplaner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
10400eee59
30 files changed
+3771
No files matched your search
@@ -0,0 +1,331 @@
|
||||
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}`);
|
||||
});
|
||||
Reference in new issue
Block a user