/* ===================================================================== routes/events.js — "Event des Jahres" auf der Startseite (index.html), admin-pflegbar aus der Verwaltung heraus (Nutzer-Wunsch 20.08.2026: "will ich in der admin seite das selbst gestalten können, mit bild und text und am besten auch einen link"). Zeigt IMMER genau 2 feste Slots (1 und 2) — kein wachsender Katalog, deshalb genügt die bereits bestehende app_settings-Tabelle (key/value) statt einer eigenen Tabelle. Öffentlicher Lesezugriff (getEventsPublic) ohne Session, damit index.html die Inhalte für jede Besucherin/jeden Besucher laden kann — Schreibzugriff nur mit EVENTS_MANAGE-Berechtigung (Owner immer erlaubt, siehe can()). ===================================================================== */ import { db } from "../db.js"; import { hasPermission } from "../lib/permissions.js"; import { json } from "../lib/http.js"; import { logAction } from "../lib/audit.js"; import { uebersetzeAlleSprachen } from "../lib/translate.js"; function can(session, key) { if (!session) return false; if (session.isOwner) return true; return hasPermission(session.permissions, session.overrides, key); } function forbidden(res) { return json(res, { ok: false, error: "Keine Berechtigung." }, 403); } function settingsKey(slot) { return `event_des_jahres_${slot}`; } function ladeSlot(slot) { const row = db.prepare(`SELECT value FROM app_settings WHERE key = ?`).get(settingsKey(slot)); if (!row) return null; try { return JSON.parse(row.value); } catch { return null; } } /** Öffentlich, keine Session: liefert nur AKTIVIERTE Slots (Nutzer-Wunsch 20.08.2026: "die kisten sollen leer sein solang wie ich nichts rein setze, am besten sollen die sogar immer nur erscheinen wenn ich was rein setze und sie aktiviere") -- ein Slot mit gespeichertem Inhalt, aber ohne gesetztes "aktiv", bleibt für Besucher:innen unsichtbar (Entwurfsmodus), obwohl er in der Verwaltung längst vorausgefüllt ist. */ export async function getEventsPublic(req, res) { const gibFreiWennAktiv = (slot) => { const eintrag = ladeSlot(slot); return eintrag && eintrag.aktiv ? eintrag : null; }; return json(res, { ok: true, events: [gibFreiWennAktiv(1), gibFreiWennAktiv(2)] }); } /** Für die Verwaltung: liefert BEIDE Slots roh (auch inaktive/Entwürfe), damit das Formular beim Öffnen alles vorausfüllen kann. */ export async function getEventsAdmin(req, res, session) { if (!can(session, "EVENTS_MANAGE")) return forbidden(res); return json(res, { ok: true, events: [ladeSlot(1), ladeSlot(2)] }); } /** NUR Bild-Upload, kein JSON-Body (multipart, siehe index.js multer-Middleware). Bugfix 21.08.2026 (Nutzer-Report: "wieso seh ich das bild nicht" bei "Event 1" in der Verwaltung): vorher wurde hier nur der RELATIVE Pfad ("/uploads/events/...") zurückgegeben. verwaltung.html UND index.html laufen aber auf dogfather-universe.com (dogiweb-Dienst), während die hochgeladene Datei tatsächlich unter postfach.dogfather-universe.com (dieser Dienst, dogiintern, siehe /uploads-Static-Route in index.js) liegt -- der Browser hat den relativen Pfad also fälschlich gegen die falsche Domain aufgelöst und ein kaputtes Bild-Symbol angezeigt, sowohl in der Verwaltungs-Vorschau als auch (sobald ein Slot aktiviert wird) auf der echten Startseite. Jetzt vollständige Adresse, damit das Bild unabhängig davon lädt, von welcher Domain aus die Seite gerade läuft. */ export async function uploadEventImage(req, res, session) { if (!can(session, "EVENTS_MANAGE")) return forbidden(res); if (!req.file) return json(res, { ok: false, error: "Keine Bilddatei erhalten." }, 400); const url = `https://postfach.dogfather-universe.com/uploads/events/${req.file.filename}`; logAction(session.actor, "events.image_uploaded", req.file.filename, null); return json(res, { ok: true, url }); } export async function saveEvent(req, res, session) { if (!can(session, "EVENTS_MANAGE")) return forbidden(res); const body = req.body || {}; const slot = Number(body.slot); if (slot !== 1 && slot !== 2) return json(res, { ok: false, error: "Ungültiger Slot (nur 1 oder 2)." }, 400); const datum = String(body.datum || "").trim().slice(0, 80); const titelDe = String(body.titel || "").trim().slice(0, 120); const textDe = String(body.text || "").trim().slice(0, 400); const link = String(body.link || "").trim().slice(0, 300) || null; const bild = String(body.bild || "").trim().slice(0, 300) || null; const aktiv = !!body.aktiv; if (!titelDe || !textDe) { return json(res, { ok: false, error: "Titel und Text sind Pflicht." }, 400); } // Nur echte http(s)-Links zulassen (kein javascript:-Link etc.) if (link && !/^https?:\/\//i.test(link)) { return json(res, { ok: false, error: "Link muss mit http:// oder https:// beginnen." }, 400); } // Aktivieren nur mit vollständigem Pflichtinhalt möglich -- verhindert ein // leeres/kaputtes, aber "aktives" Feld auf der Startseite. if (aktiv && (!titelDe || !textDe)) { return json(res, { ok: false, error: "Zum Aktivieren müssen Titel und Text ausgefüllt sein." }, 400); } const [titel, text] = await Promise.all([uebersetzeAlleSprachen(titelDe), uebersetzeAlleSprachen(textDe)]); const eintrag = { datum, titel, text, link, bild, aktiv, aktualisiertVon: session.actor, aktualisiertAm: new Date().toISOString(), }; db.prepare( `INSERT INTO app_settings (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at` ).run(settingsKey(slot), JSON.stringify(eintrag), eintrag.aktualisiertAm); logAction(session.actor, "events.saved", String(slot), { titel: titelDe }); return json(res, { ok: true, event: eintrag }); }