Files
dogfather-universe/server-internal/index.js
T
DogFatherGitandClaude Opus 5 65b98ace89 Die Uhr laeuft erst, wenn die Anzahlung da ist
Bisher begann der Liefertermin mit der Annahme. Das ist unfair in beide
Richtungen: Wer zehn Tage bis zur Zahlung braucht, verbraucht zehn Tage
der zugesagten Zeit, ohne dass ein Handschlag Arbeit passiert waere --
und ich stehe am Ende als der da, der seinen Termin reisst.

Ein Projekt hat jetzt drei Abschnitte statt zwei:
  1. angenommen, wartet auf Anzahlung  -> Uhr steht
  2. Anzahlung da                      -> Uhr laeuft, Termin ab HEUTE neu
  3. uebergeben oder abgebrochen       -> Uhr steht wieder

Der Zahlungseingang loest alles Weitere von selbst aus: Uhr starten,
Termin neu rechnen, Status von briefing auf design, 'wer ist am Zug' auf
mich, Benachrichtigung in der Verwaltung. Der bei der Annahme genannte
Termin bleibt als termin_geplant_am erhalten, und die Meldung nennt
BEIDE -- so sieht man, dass sich etwas verschoben hat, ohne nachrechnen
zu muessen.

Eingehaengt an der Stelle, an der beide Wege zusammenlaufen (PayPals
Meldung UND das Vermerken von Hand). Nur am Webhook haenge sich die
Seite verschieden verhalten, je nachdem WIE das Geld ankam -- eine von
Hand verbuchte Zahlung startete die Uhr nie.

Zwei Grundsaetze fuer die Automatik: Sie setzt Dinge in Gang, nimmt aber
nie eine Entscheidung zurueck, die ein Mensch getroffen hat (ein von Hand
pausiertes Projekt wird nicht kommentarlos wieder gestartet). Und jeder
Schritt hinterlaesst eine Spur im Verlauf UND als Meldung -- eine
Automatik, die stillschweigend arbeitet, ist kein Helfer, sondern ein
Raetsel.

ABBRECHEN
Ein angenommener Auftrag bleibt abbrechbar: Der Kunde zahlt nicht,
meldet sich nicht, springt ab. Vorschau und Ausfuehrung sind getrennt --
die Seite rechnet aus dem Aufgabenfortschritt vor, wie viel Leistung
erbracht wurde, und schlaegt daraus einen Erstattungsbetrag vor. Der
Betrag ist ein VORSCHLAG: Ob im Einzelfall mehr oder weniger angemessen
ist, haengt an Dingen, die keine Tabelle kennt. Offene Rechnungen werden
storniert (eine Zahlungsaufforderung ohne Gegenleistung), bezahlte
bleiben unangetastet, und die Rueckzahlung loest die Seite bewusst NICHT
selbst aus -- PayPal-Rueckzahlungen sind nicht umkehrbar.

BENACHRICHTIGUNGEN
Eigene Tabelle statt im Verlauf: Der Verlauf haelt fest, WAS geschehen
ist -- vollstaendig, zum Nachschlagen. Eine Benachrichtigung ist ein
Anstupsen, das gelesen und weggelegt wird. Beides in einer Tabelle
hiesse: entweder ein Verlauf voller Rauschen oder Meldungen, die man
nicht wegklicken kann. Wegklicken markiert nur als gelesen, loescht
nichts.

Dazu die Liste der Projekte, die seit ueber einer Woche auf ihre
Anzahlung warten. Sie stehen in keiner anderen Zahl, weil ihre Uhr nie
zu laufen begann -- ohne diesen Hinweis vergisst man sie.

Geprueft: 44 gegen eine echte Datenbank. Darunter der Kern -- die
Annahme wird zehn Tage zurueckdatiert, und der Termin muss danach
trotzdem volle 20 Werktage entfernt liegen. Beim Bauen des Tests selbst
ein Fehler gefunden: Die erste Fassung datierte nur die Annahme zurueck,
nicht den damals errechneten Termin, und bildete damit genau den Fall
nicht ab, um den es geht.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 11:56:08 +02:00

528 lines
30 KiB
JavaScript

/* =====================================================================
DOGFATHER UNIVERSE — Interner Bereich (Postfach, Team-Verwaltung,
DogiCrew-Supporter-Abo). Node.js/Express-Server als 1:1-Ersatz für den
Cloudflare Worker "dogfather-universe-postfach"
(siehe ../cloudflare-worker/src/worker.js für das Original).
Architektur (unverändert zum Original):
db.js SQLite-Datenbank (better-sqlite3), Migrationen
lib/crypto.js Hashing, AES-GCM (Code-Verschlüsselung), IDs
lib/auth.js Login-Prüfung, Sessions, Rate-Limiting (Team)
lib/supporter-auth.js Login/Sessions für den öffentlichen Bereich
lib/permissions.js Vollständiger Permission-Katalog
lib/audit.js Aktivitätsprotokoll schreiben
lib/http.js JSON-Response-Helfer
lib/live-status.js TikTok-Live-Erkennung (ersetzt Cloudflare-Cron)
routes/*.js Endpoint-Handler pro Bereich
Unterschiede zum Original (rein technisch, gleiches Verhalten):
- D1 (async) → better-sqlite3 (synchron)
- KV BEWERBUNGEN wurde nie für mehr als den Live-Status-Schalter
genutzt, der jetzt komplett in app_settings (SQLite) lebt.
- ctx.waitUntil() → einfaches "nicht abwarten" (Node-Prozess läuft
durchgehend weiter, kein Cold-Start-Risiko wie bei Workers).
- scheduled()-Cron → node-cron, gleicher Rhythmus ("* * * * *").
===================================================================== */
import "dotenv/config";
import express from "express";
import cors from "cors";
import cron from "node-cron";
import multer from "multer";
import { mkdirSync } from "node:fs";
import { extname } from "node:path";
import { initDb } from "./db.js";
import { json } from "./lib/http.js";
import { requireSession } from "./lib/auth.js";
import { requireSupporterSession } from "./lib/supporter-auth.js";
import { handleLiveStatusGet, handleLiveStatusSet, refreshLiveFromTikTok } from "./lib/live-status.js";
import { login, logout, setOwnerCode } from "./routes/auth.js";
import { listRoles, createRole, updateRole, deleteRole } from "./routes/roles.js";
import {
listUsers, prepareUser, finalizeUser, setUserCode, revealUserCode,
lockUser, unlockUser, deleteUser, setUserRole, setUserPermissions,
endUserSessions, updateUserMeta,
} from "./routes/users.js";
import {
submitApplication, listApplications, changeApplicationStatus,
takeOverApplication, assignApplication, archiveApplication,
restoreApplication, deleteApplication, markAnswered,
listNotes, addNote, deleteNote, listDrafts, saveDraft,
} from "./routes/applications.js";
import { listAuditLog } from "./routes/audit.js";
import {
registerSupporter, verifySupporterEmail, requestSupporterLogin, verifySupporterLogin, logoutSupporter,
loginSupporterMitCode, setSupporterAccessCode,
googleLoginSupporter, getPublicConfig,
getSupporterMe, updateSupporterProfile, requestSupporterEmailChange, confirmSupporterEmailChange,
redeemPremium, deleteSupporterAccount,
} from "./routes/supporter.js";
import {
startPaypalSubscription, confirmPaypalSubscription, cancelPaypalSubscription, handlePaypalWebhook,
} from "./routes/supporter-paypal.js";
import {
listSupporters, getSupporterDetail, updatePremium, listCollections, upsertCollection, getAdminOverview,
} from "./routes/supporter-admin.js";
import { getEventsPublic, getEventsAdmin, saveEvent, uploadEventImage } from "./routes/events.js";
import { getSpecialEventPublic, getSpecialEventAdmin, saveSpecialEvent } from "./routes/special-event.js";
import {
submitTestimonial, getApprovedTestimonialsPublic, getTestimonialsAdmin, setTestimonialStatus, deleteTestimonial,
uploadEigenesTestimonialBild,
setTestimonialAvatar, uploadTestimonialAvatar,
} from "./routes/testimonials.js";
import {
createPoll, listPolls, closePoll, reopenPoll, deletePoll, getActivePollPublic, voteOnPoll,
} from "./routes/polls.js";
import {
getTeamPublic, getTeamAdmin, saveTeamMember, importLegacyMember, deleteTeamMember, uploadTeamPhoto,
} from "./routes/team.js";
import { getSiteTextsPublic, saveSiteText } from "./routes/site-texts.js";
/* Webdesign-Bereich (22.08.2026). Die Rechteprüfung sitzt bewusst IN den
Handlern statt in einer Middleware davor: die Endpunkte brauchen
unterschiedliche Rechte (ansehen vs. bearbeiten), und ein einzelnes
requireTeamSession davor würde diesen Unterschied verwischen. */
import {
anfrageAbsenden, anfragenListe, anfrageDetail,
anfrageStatus, anfrageArchivieren, anfrageNotiz,
} from "./routes/webdesign-anfragen.js";
/* Kundenportal (Masterplan S.12). Eigene Anmeldung mit eigener Sitzung --
Kunden sind KEINE Teammitglieder und duerfen mit deren Rechtesystem
nichts zu tun haben. Die Mandantentrennung sitzt in den Handlern:
jede Abfrage filtert auf die kunde_id aus der Sitzung. */
import {
portalAnmelden, portalAbmelden, portalEinladungPruefen, portalPasswortSetzen,
portalUebersicht, portalProjekt, portalAenderungEinreichen,
portalAenderungEntscheiden, portalNachricht, portalProfilSpeichern,
} from "./routes/webdesign-portal.js";
/* Kunden und Projekte verwalten (Masterplan S.13). Hier entstehen
Kundenzugaenge -- es gibt bewusst keine Selbstregistrierung. */
import {
kundeAnlegen, kundeEinladungNeu, kundenListe, kundeSchalten,
projektAnlegen, projektAendern, aenderungBeziffern, projektNachricht, projekteListe,
} from "./routes/webdesign-kunden.js";
/* Aufgabenliste je Projekt ("was ist schon gemacht von dem, was im Plan
war") und allgemeines Postfach fuer Nachrichten OHNE Projektbezug. */
import {
portalAufgaben, portalAufgabeErledigt, adminAufgaben, adminAufgabeAnlegen, adminProjektAlles,
adminAufgabeAendern, adminAufgabeLoeschen, adminVorlageAnwenden,
} from "./routes/webdesign-aufgaben.js";
import {
portalPostfach, portalPostfachSenden, adminPostfachListe,
adminPostfachVerlauf, adminPostfachSenden, adminPostfachZaehler,
} from "./routes/webdesign-postfach.js";
/* Elektronische Widerrufsfunktion (§ 356a BGB). Bewusst OHNE Anmeldung
erreichbar -- das Gesetz verlangt "leicht zugaenglich", und wer einen
Vertrag loswerden will, darf nicht erst an einer Anmeldung scheitern. */
import {
widerrufAbsenden, widerrufeListe, widerrufBearbeiten,
} from "./routes/webdesign-widerruf.js";
/* Zahlungen im Webdesign-Bereich. Der Webhook ist oeffentlich erreichbar
(PayPal hat keine Sitzung) und ausschliesslich durch die
Signaturpruefung geschuetzt -- ohne gueltige Signatur wird nichts
verarbeitet. */
import {
zahlungAnsehen, zahlungStarten, zahlungAbschliessen,
paypalWebhook, zahlungAnlegen, zahlungenListe,
einstellungenLesen, einstellungenSchreiben, einstellungenPruefen, uebersicht, meldungGelesen,
} from "./routes/webdesign-zahlungen.js";
import {
annahmeVorschau, anfrageAnnehmen, absageVorschau, anfrageAblehnen,
abbruchVorschau, projektAbbrechen,
} from "./routes/webdesign-annahme.js";
import { suche } from "./routes/webdesign-suche.js";
/* PayPal-Zugangsdaten koennen verschluesselt in der Datenbank liegen,
damit Filipe sie in seiner Verwaltung eintragen kann statt per SSH.
Sie muessen beim Start EINMAL entschluesselt werden, weil die
Pruefungen des PayPal-Moduls synchron sind. */
import { geheimnisseLaden } from "./lib/webdesign-geheimnisse.js";
import { vorlagenSicherstellen } from "./lib/webdesign-vorlagen.js";
initDb();
/* Muss NACH initDb laufen -- die Tabelle entsteht erst dort. Schreibt
die Standard-Aufgabenlisten beim allerersten Start in die Datenbank
und tut danach nichts mehr. Vorhandene Vorlagen werden nie
ueberschrieben, sonst waeren von Hand angepasste nach dem naechsten
Neustart weg -- und niemand kaeme auf die Idee, dass der Neustart
daran schuld war. */
vorlagenSicherstellen();
/* Ebenfalls nach initDb. Faellt das aus (z.B. weil der ENCRYPTION_KEY
fehlt), laeuft der Dienst trotzdem weiter -- er verhaelt sich dann so,
als waere in der Verwaltung nichts hinterlegt, und die .env gilt
weiter. Ein fehlender Zahlungsanbieter darf nicht die ganze Seite
anhalten. */
geheimnisseLaden()
.then((n) => { if (n) console.log(`[webdesign] ${n} Einstellungen aus der Datenbank geladen.`); })
.catch((e) => console.error("[webdesign] Einstellungen nicht ladbar:", e?.message));
const PORT = Number(process.env.PORT || 4200);
const app = express();
app.set("trust proxy", 1);
app.disable("x-powered-by"); // verrät sonst unnötig den eingesetzten Technik-Stack
/* Bild-Uploads für admin-gepflegte Inhalte (aktuell: "Event des Jahres",
20.08.2026). Liegt bewusst NEBEN der Datenbank unter /var/lib/... statt im
Git-Repo-Ordner — Uploads sollen einen `git pull`/Deploy unbeschadet
überstehen und nicht versehentlich mit eingecheckt werden. */
const UPLOADS_BASIS_DIR = process.env.UPLOADS_DIR || "/var/lib/dogfather-internal/uploads";
const EVENTS_UPLOADS_DIR = `${UPLOADS_BASIS_DIR}/events`;
mkdirSync(EVENTS_UPLOADS_DIR, { recursive: true });
// Team-Fotos (21.08.2026, "Verwaltungsseite perfektionieren"-Auftrag) --
// eigener Unterordner, gleiches Muster wie bei den Event-Bildern oben.
const TEAM_UPLOADS_DIR = `${UPLOADS_BASIS_DIR}/team`;
mkdirSync(TEAM_UPLOADS_DIR, { recursive: true });
const ERLAUBTE_BILD_TYPEN = new Set(["image/jpeg", "image/png", "image/webp"]);
const bildUpload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, EVENTS_UPLOADS_DIR),
// Zufälliger Dateiname (crypto.randomUUID) statt des Originalnamens —
// verhindert Pfad-/Namenskollisionen und Path-Traversal-Versuche über
// einen manipulierten Dateinamen komplett, ganz ohne Validierung nötig.
filename: (req, file, cb) => cb(null, `${crypto.randomUUID()}${extname(file.originalname).toLowerCase()}`),
}),
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
fileFilter: (req, file, cb) => cb(null, ERLAUBTE_BILD_TYPEN.has(file.mimetype)),
});
const teamBildUpload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, TEAM_UPLOADS_DIR),
filename: (req, file, cb) => cb(null, `${crypto.randomUUID()}${extname(file.originalname).toLowerCase()}`),
}),
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
fileFilter: (req, file, cb) => cb(null, ERLAUBTE_BILD_TYPEN.has(file.mimetype)),
});
// Profilbilder für die "Stimmen" (21.08.2026) — gleiches Muster wie oben.
// Nur aus der Verwaltung heraus erreichbar; das öffentliche Stimmen-Formular
// nimmt bewusst keine Dateien entgegen (siehe routes/testimonials.js).
const TESTIMONIAL_UPLOADS_DIR = `${UPLOADS_BASIS_DIR}/testimonials`;
mkdirSync(TESTIMONIAL_UPLOADS_DIR, { recursive: true });
const stimmenBildUpload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, TESTIMONIAL_UPLOADS_DIR),
filename: (req, file, cb) => cb(null, `${crypto.randomUUID()}${extname(file.originalname).toLowerCase()}`),
}),
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB
fileFilter: (req, file, cb) => cb(null, ERLAUBTE_BILD_TYPEN.has(file.mimetype)),
});
app.use((req, res, next) => {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
next();
});
app.use(cors());
// Bild-Uploads oeffentlich ausliefern (index.html zeigt "Event des Jahres"-Bilder
// JEDER Besucherin/JEDEM Besucher, ohne Login) — nur lesend, Schreiben bleibt
// ausschliesslich ueber /admin/events/upload-image mit EVENTS_MANAGE-Berechtigung.
app.use("/uploads", express.static(UPLOADS_BASIS_DIR));
app.use(
express.json({
limit: "2mb",
// Roh-Body wird zusätzlich als String aufbewahrt — die PayPal-Webhook-
// Signaturprüfung braucht den unveränderten Original-Text (siehe
// routes/supporter-paypal.js), nicht das nachträglich neu serialisierte
// req.body.
verify: (req, res, buf) => {
req.rawBody = buf.toString("utf8");
},
})
);
/* ---------- Absturzsicherheit ----------
Express 4 fängt Fehler aus async-Handlern NICHT selbst ab: eine abgelehnte Promise wird zu einer
"unhandled rejection", und Node beendet daraufhin den ganzen Prozess — im Cloudflare Worker war
ein Fehler dagegen immer auf die eine betroffene Anfrage begrenzt. Ohne die folgende Absicherung
könnte z.B. eine Anfrage mit fehlender ID (better-sqlite3 wirft bei `undefined` als Parameter)
den kompletten internen Bereich offline nehmen. `wrap()` leitet jeden Fehler stattdessen an den
Fehler-Handler am Ende der Datei weiter. */
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
/* ---------- Middlewares: interne Team-Session bzw. Supporter-Session ---------- */
const requireTeamSession = wrap(async (req, res, next) => {
const session = await requireSession(req);
if (!session) return json(res, { ok: false, error: "Sitzung abgelaufen oder ungültig. Bitte erneut anmelden." }, 401);
req.session = session;
next();
});
const requireSupporterAuth = wrap(async (req, res, next) => {
const supporter = await requireSupporterSession(req);
if (!supporter) return json(res, { ok: false, error: "Sitzung abgelaufen oder ungültig. Bitte erneut einloggen." }, 401);
req.supporter = supporter;
next();
});
/** Reicht die injizierte session/supporter als drittes Argument an den Handler weiter (wie im Cloudflare-Original). */
const withSession = (handler) => wrap((req, res) => handler(req, res, req.session));
const withSupporter = (handler) => wrap((req, res) => handler(req, res, req.supporter));
/* ---------- Öffentliche Routen (keine Session) ---------- */
app.get("/health", (req, res) => json(res, { ok: true, service: "dogfather-universe-interner-bereich" }));
app.post("/submit", wrap(submitApplication));
app.get("/live-status", wrap(handleLiveStatusGet));
app.post("/auth/login", wrap(login));
app.get("/events-of-year", wrap(getEventsPublic));
app.get("/special-event", wrap(getSpecialEventPublic));
app.post("/testimonials/submit", wrap(submitTestimonial));
/* Öffentlich, ohne Login: eigenes Profilbild für die eigene Stimme
hochladen (21.08.2026). Bewusst mit derselben multer-Härtung wie der
Verwaltungs-Upload (nur JPG/PNG/WebP, max. 5 MB, zufälliger Dateiname);
zusätzlich bremst die Route selbst Massen-Uploads pro IP aus (siehe
uploadEigenesTestimonialBild in routes/testimonials.js). Sichtbar wird
ein hochgeladenes Bild erst, wenn die zugehörige Stimme freigegeben
wird -- Einreichungen landen immer zuerst in der Warteschlange. */
app.post("/testimonials/upload-avatar", stimmenBildUpload.single("image"), wrap(uploadEigenesTestimonialBild));
app.get("/testimonials", wrap(getApprovedTestimonialsPublic));
app.get("/team", wrap(getTeamPublic));
app.get("/site-texts", wrap(getSiteTextsPublic));
/* ---------- Webdesign-Bereich (22.08.2026) ----------
Genau EIN öffentlicher Endpunkt: das Anfrageformular. Alles Weitere
verlangt eine Team-Anmeldung, die in den Handlern selbst geprüft wird
(unterschiedliche Rechte je Endpunkt, siehe Import oben). */
app.post("/webdesign/anfrage", wrap(anfrageAbsenden));
/* Zweiter oeffentlicher Endpunkt des Bereichs -- und der einzige, bei dem
das so sein MUSS: § 356a BGB verlangt eine leicht zugaengliche
Widerrufsfunktion. Eine Anmeldung davor waere ein Rechtsverlust fuer
jeden, der sein Passwort vergessen hat. */
app.post("/webdesign/widerruf", wrap(widerrufAbsenden));
/* Dritter oeffentlicher Endpunkt: PayPal ruft ihn direkt auf und kann
sich nicht anmelden. Die Echtheit wird ueber die Signatur geprueft. */
app.post("/webdesign/paypal-webhook", wrap(paypalWebhook));
app.get("/webdesign/admin/widerrufe", wrap(widerrufeListe));
app.post("/webdesign/admin/widerrufe/:id", wrap(widerrufBearbeiten));
app.get("/webdesign/admin/anfragen", wrap(anfragenListe));
app.get("/webdesign/admin/anfragen/:id", wrap(anfrageDetail));
app.post("/webdesign/admin/anfragen/:id/status", wrap(anfrageStatus));
app.post("/webdesign/admin/anfragen/:id/archivieren", wrap(anfrageArchivieren));
app.post("/webdesign/admin/anfragen/:id/notiz", wrap(anfrageNotiz));
/* Annehmen und Ablehnen.
Die Vorschau-Endpunkte lesen nur und veraendern nichts -- deshalb GET.
Sie fuettern den Bestaetigungsdialog, damit der zeigen kann, WAS
passieren wird (Termin, Zahl der Arbeitsschritte, ob der Kunde schon
existiert), statt nur "wirklich?" zu fragen. */
app.get("/webdesign/admin/anfragen/:id/annahme-vorschau", wrap(annahmeVorschau));
app.post("/webdesign/admin/anfragen/:id/annehmen", wrap(anfrageAnnehmen));
app.get("/webdesign/admin/anfragen/:id/absage-vorschau", wrap(absageVorschau));
app.post("/webdesign/admin/anfragen/:id/ablehnen", wrap(anfrageAblehnen));
/* ---------- Kundenportal ----------
Oeffentlich sind nur Anmeldung und das Einloesen eines Einladungslinks.
Alles Weitere prueft die Kundensitzung im Handler selbst. */
app.post("/webdesign/portal/anmelden", wrap(portalAnmelden));
app.post("/webdesign/portal/abmelden", wrap(portalAbmelden));
app.get("/webdesign/portal/einladung", wrap(portalEinladungPruefen));
app.post("/webdesign/portal/passwort", wrap(portalPasswortSetzen));
app.get("/webdesign/portal/uebersicht", wrap(portalUebersicht));
app.get("/webdesign/portal/projekt/:id", wrap(portalProjekt));
app.post("/webdesign/portal/aenderung", wrap(portalAenderungEinreichen));
app.post("/webdesign/portal/aenderung/:id/entscheiden", wrap(portalAenderungEntscheiden));
app.post("/webdesign/portal/nachricht", wrap(portalNachricht));
app.post("/webdesign/portal/profil", wrap(portalProfilSpeichern));
/* ---------- Kunden und Projekte (Verwaltung) ---------- */
app.get("/webdesign/admin/kunden", wrap(kundenListe));
app.post("/webdesign/admin/kunden", wrap(kundeAnlegen));
app.post("/webdesign/admin/kunden/:id/einladung", wrap(kundeEinladungNeu));
app.post("/webdesign/admin/kunden/:id/zugang", wrap(kundeSchalten));
app.get("/webdesign/admin/projekte", wrap(projekteListe));
app.post("/webdesign/admin/projekte", wrap(projektAnlegen));
app.post("/webdesign/admin/projekte/:id", wrap(projektAendern));
app.post("/webdesign/admin/aenderungen/:id/beziffern", wrap(aenderungBeziffern));
app.post("/webdesign/admin/projekt-nachricht", wrap(projektNachricht));
/* ---------- Aufgabenliste je Projekt ----------
Kundenseite links, Verwaltung rechts. Der Kunde darf nur Punkte
abhaken, die IHM zugewiesen sind -- geprueft im Handler, nicht hier. */
app.get("/webdesign/portal/projekt/:id/aufgaben", wrap(portalAufgaben));
app.post("/webdesign/portal/aufgabe/:id/erledigt", wrap(portalAufgabeErledigt));
app.get("/webdesign/admin/projekte/:id/aufgaben", wrap(adminAufgaben));
/* Das ganze Projekt in EINER Antwort -- Stand, Aufgaben, Aenderungswuensche
und Nachrichten. Fuenf Anfragen hintereinander wuerden die Ansicht
sichtbar ruckelnd aufbauen. */
app.get("/webdesign/admin/projekte/:id/alles", wrap(adminProjektAlles));
app.post("/webdesign/admin/projekte/:id/aufgaben", wrap(adminAufgabeAnlegen));
app.post("/webdesign/admin/projekte/:id/aufgaben/vorlage", wrap(adminVorlageAnwenden));
app.post("/webdesign/admin/aufgaben/:id", wrap(adminAufgabeAendern));
app.post("/webdesign/admin/aufgaben/:id/loeschen", wrap(adminAufgabeLoeschen));
/* ---------- Postfach (Nachrichten ohne Projektbezug) ---------- */
app.get("/webdesign/portal/postfach", wrap(portalPostfach));
app.post("/webdesign/portal/postfach", wrap(portalPostfachSenden));
app.get("/webdesign/portal/zahlung/:id", wrap(zahlungAnsehen));
app.post("/webdesign/portal/zahlung/:id/start", wrap(zahlungStarten));
app.post("/webdesign/portal/zahlung/:id/abschluss", wrap(zahlungAbschliessen));
app.get("/webdesign/admin/zahlungen", wrap(zahlungenListe));
app.post("/webdesign/admin/zahlungen", wrap(zahlungAnlegen));
app.get("/webdesign/admin/uebersicht", wrap(uebersicht));
app.get("/webdesign/admin/suche", wrap(suche));
app.post("/webdesign/admin/meldungen/:id/gelesen", wrap(meldungGelesen));
app.get("/webdesign/admin/projekte/:id/abbruch-vorschau", wrap(abbruchVorschau));
app.post("/webdesign/admin/projekte/:id/abbrechen", wrap(projektAbbrechen));
app.get("/webdesign/admin/einstellungen", wrap(einstellungenLesen));
app.post("/webdesign/admin/einstellungen", wrap(einstellungenSchreiben));
app.post("/webdesign/admin/einstellungen/pruefen", wrap(einstellungenPruefen));
app.get("/webdesign/admin/postfach", wrap(adminPostfachListe));
app.get("/webdesign/admin/postfach-zaehler", wrap(adminPostfachZaehler));
app.get("/webdesign/admin/postfach/:kundeId", wrap(adminPostfachVerlauf));
app.post("/webdesign/admin/postfach/:kundeId", wrap(adminPostfachSenden));
app.post("/supporter/register", wrap(registerSupporter));
app.post("/supporter/verify-email", wrap(verifySupporterEmail));
// Normaler Login seit 21.08.2026: E-Mail + selbst gewählter Zugangscode.
app.post("/supporter/login-code", wrap(loginSupporterMitCode));
// login-request/verify-login sind jetzt der "Code vergessen?"-Weg.
app.post("/supporter/login-request", wrap(requestSupporterLogin));
app.post("/supporter/verify-login", wrap(verifySupporterLogin));
app.post("/supporter/google-login", wrap(googleLoginSupporter));
app.get("/supporter/public-config", wrap(getPublicConfig));
// PayPal ruft diese Adresse direkt auf — eigene Signaturprüfung statt Session (siehe lib/paypal.js).
app.post("/supporter/paypal-webhook", wrap(handlePaypalWebhook));
/* ---------- Team-Bereich (interne Session, requireTeamSession) ---------- */
app.post("/auth/logout", requireTeamSession, wrap(logout));
app.post("/admin/owner/set-code", requireTeamSession, withSession(setOwnerCode));
app.post("/live-status/set", requireTeamSession, withSession(handleLiveStatusSet));
app.post("/admin/applications/list", requireTeamSession, withSession(listApplications));
app.post("/admin/applications/status", requireTeamSession, withSession(changeApplicationStatus));
app.post("/admin/applications/take-over", requireTeamSession, withSession(takeOverApplication));
app.post("/admin/applications/assign", requireTeamSession, withSession(assignApplication));
app.post("/admin/applications/archive", requireTeamSession, withSession(archiveApplication));
app.post("/admin/applications/restore", requireTeamSession, withSession(restoreApplication));
app.post("/admin/applications/delete", requireTeamSession, withSession(deleteApplication));
app.post("/admin/applications/mark-answered", requireTeamSession, withSession(markAnswered));
app.post("/admin/notes/list", requireTeamSession, withSession(listNotes));
app.post("/admin/notes/add", requireTeamSession, withSession(addNote));
app.post("/admin/notes/delete", requireTeamSession, withSession(deleteNote));
app.post("/admin/drafts/list", requireTeamSession, withSession(listDrafts));
app.post("/admin/drafts/save", requireTeamSession, withSession(saveDraft));
// Startseite: "Event des Jahres" (20.08.2026)
app.post("/admin/events/list", requireTeamSession, withSession(getEventsAdmin));
app.post("/admin/events/save", requireTeamSession, withSession(saveEvent));
app.post("/admin/events/upload-image", requireTeamSession, bildUpload.single("image"), withSession(uploadEventImage));
// Spezielles Live-Event mit Countdown auf streamplan.html (20.08.2026)
app.post("/admin/special-event", requireTeamSession, withSession(getSpecialEventAdmin));
app.post("/admin/special-event/save", requireTeamSession, withSession(saveSpecialEvent));
// "Stimmen"-Warteschlange (20.08.2026)
app.post("/admin/testimonials/list", requireTeamSession, withSession(getTestimonialsAdmin));
app.post("/admin/testimonials/status", requireTeamSession, withSession(setTestimonialStatus));
app.post("/admin/testimonials/delete", requireTeamSession, withSession(deleteTestimonial));
// Profilbild einer Stimme setzen bzw. eigenes Foto hochladen (21.08.2026).
app.post("/admin/testimonials/avatar", requireTeamSession, withSession(setTestimonialAvatar));
app.post("/admin/testimonials/upload-avatar", requireTeamSession, stimmenBildUpload.single("image"), withSession(uploadTestimonialAvatar));
// Team-Mitglieder (Modis/Scouts/Creator/Manager) — 21.08.2026
app.post("/admin/team/list", requireTeamSession, withSession(getTeamAdmin));
app.post("/admin/team/save", requireTeamSession, withSession(saveTeamMember));
app.post("/admin/team/import", requireTeamSession, withSession(importLegacyMember));
app.post("/admin/team/delete", requireTeamSession, withSession(deleteTeamMember));
app.post("/admin/team/upload-photo", requireTeamSession, teamBildUpload.single("image"), withSession(uploadTeamPhoto));
app.post("/admin/site-texts/save", requireTeamSession, withSession(saveSiteText));
app.post("/admin/roles/list", requireTeamSession, withSession(listRoles));
app.post("/admin/roles/create", requireTeamSession, withSession(createRole));
app.post("/admin/roles/update", requireTeamSession, withSession(updateRole));
app.post("/admin/roles/delete", requireTeamSession, withSession(deleteRole));
app.post("/admin/users/list", requireTeamSession, withSession(listUsers));
app.post("/admin/users/prepare", requireTeamSession, withSession(prepareUser));
app.post("/admin/users/finalize", requireTeamSession, withSession(finalizeUser));
app.post("/admin/users/set-code", requireTeamSession, withSession(setUserCode));
app.post("/admin/users/reveal-code", requireTeamSession, withSession(revealUserCode));
app.post("/admin/users/lock", requireTeamSession, withSession(lockUser));
app.post("/admin/users/unlock", requireTeamSession, withSession(unlockUser));
app.post("/admin/users/delete", requireTeamSession, withSession(deleteUser));
app.post("/admin/users/set-role", requireTeamSession, withSession(setUserRole));
app.post("/admin/users/set-permissions", requireTeamSession, withSession(setUserPermissions));
app.post("/admin/users/end-sessions", requireTeamSession, withSession(endUserSessions));
app.post("/admin/users/update-meta", requireTeamSession, withSession(updateUserMeta));
app.post("/admin/audit/list", requireTeamSession, withSession(listAuditLog));
// Supporter-Abo — Administrationsbereich (interne Team-Session + eigene
// SUPPORTER_*-Rechte, siehe lib/permissions.js).
app.post("/admin/overview", requireTeamSession, withSession(getAdminOverview));
app.post("/admin/supporter/list", requireTeamSession, withSession(listSupporters));
app.post("/admin/supporter/detail", requireTeamSession, withSession(getSupporterDetail));
app.post("/admin/supporter/premium/update", requireTeamSession, withSession(updatePremium));
app.post("/admin/supporter/collections/list", requireTeamSession, withSession(listCollections));
app.post("/admin/supporter/collections/upsert", requireTeamSession, withSession(upsertCollection));
// Supporter-Abstimmungen (20.08.2026)
app.post("/admin/polls/list", requireTeamSession, withSession(listPolls));
app.post("/admin/polls/create", requireTeamSession, withSession(createPoll));
app.post("/admin/polls/close", requireTeamSession, withSession(closePoll));
app.post("/admin/polls/reopen", requireTeamSession, withSession(reopenPoll));
app.post("/admin/polls/delete", requireTeamSession, withSession(deletePoll));
/* ---------- Öffentlicher Supporter-Bereich (eigene Session, requireSupporterAuth) ---------- */
app.post("/supporter/logout", requireSupporterAuth, wrap(logoutSupporter));
app.post("/supporter/me", requireSupporterAuth, withSupporter(getSupporterMe));
app.post("/supporter/update-profile", requireSupporterAuth, withSupporter(updateSupporterProfile));
// Neuen Zugangscode setzen — freiwillig im Supporter-Bereich oder als
// Abschluss des "Code vergessen?"-Weges (da ist man bereits eingeloggt).
app.post("/supporter/set-access-code", requireSupporterAuth, withSupporter(setSupporterAccessCode));
app.post("/supporter/request-email-change", requireSupporterAuth, withSupporter(requestSupporterEmailChange));
app.post("/supporter/confirm-email-change", requireSupporterAuth, withSupporter(confirmSupporterEmailChange));
app.post("/supporter/redeem-premium", requireSupporterAuth, withSupporter(redeemPremium));
app.post("/supporter/delete-account", requireSupporterAuth, withSupporter(deleteSupporterAccount));
app.post("/supporter/paypal/start", requireSupporterAuth, withSupporter(startPaypalSubscription));
app.post("/supporter/paypal/confirm", requireSupporterAuth, withSupporter(confirmPaypalSubscription));
app.post("/supporter/paypal/cancel", requireSupporterAuth, withSupporter(cancelPaypalSubscription));
app.post("/supporter/polls/active", requireSupporterAuth, withSupporter(getActivePollPublic));
app.post("/supporter/polls/vote", requireSupporterAuth, withSupporter(voteOnPoll));
app.use((req, res) => json(res, { ok: false, error: "Not found." }, 404));
/* ---------- Fehler-Handler (muss NACH allen Routen stehen) ----------
Fängt alles ab, was in einem Handler schiefgeht, und antwortet mit sauberem JSON statt einer
HTML-Fehlerseite mit Stacktrace. Ersetzt gleichzeitig die try/catch-Behandlung für kaputtes
JSON, die im Cloudflare-Original noch in jeder Route einzeln stand. */
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
if (err?.type === "entity.parse.failed") {
return json(res, { ok: false, error: "Ungültiges JSON." }, 400);
}
if (err?.type === "entity.too.large") {
return json(res, { ok: false, error: "Die Anfrage ist zu groß." }, 413);
}
if (err instanceof multer.MulterError) {
const nachricht = err.code === "LIMIT_FILE_SIZE" ? "Das Bild ist zu groß (maximal 5 MB)." : "Bild-Upload fehlgeschlagen.";
return json(res, { ok: false, error: nachricht }, 400);
}
console.error(`[fehler] ${req.method} ${req.originalUrl}:`, err?.stack || err);
if (res.headersSent) return;
return json(res, { ok: false, error: "Interner Fehler." }, 500);
});
/* Letztes Netz: Ein unbeaufsichtigter Fehler darf den Dienst nicht beenden. Ohne diese beiden
Zeilen würde Node den Prozess bei einer unbehandelten Promise-Ablehnung sofort beenden und der
gesamte interne Bereich (inkl. Zahlungsverarbeitung) wäre bis zum systemd-Neustart offline. */
process.on("unhandledRejection", (grund) => console.error("[unhandledRejection]", grund));
process.on("uncaughtException", (fehler) => console.error("[uncaughtException]", fehler?.stack || fehler));
/* ---------- Cron: läuft jede Minute, hält den Live-Punkt aktuell (ersetzt Cloudflares scheduled()) ---------- */
cron.schedule("* * * * *", () => {
refreshLiveFromTikTok().catch((err) => console.error("[live-status] Fehler beim Aktualisieren:", err?.message || err));
});
app.listen(PORT, "127.0.0.1", () => {
console.log(`DOGFATHER UNIVERSE — Interner Bereich läuft auf http://127.0.0.1:${PORT}`);
});