Schritt 3 von 4 zu den zwei Wuenschen vom 22.08.2026. Die Datenbank und
die Server-Routen standen schon; das hier ist die Seite, auf der ICH
arbeite. Was hier passiert, sieht der Kunde unmittelbar in seinem Portal
(Schritt 4).
ZWEI NEUE REITER
"Projekte" -- bisher gab es Projekte nur als Zahl neben dem Kunden
("3 Proj."), man konnte kein einzelnes oeffnen. Man denkt aber in
Projekten, nicht in Kunden. Neuer Endpunkt projekteListe liefert die
flache Liste samt Aufgabenzahlen; die einzeln nachzufragen haette bei
zwanzig Projekten einundzwanzig Anfragen bedeutet.
"Postfach" -- mit Abzeichen fuer Ungelesenes. Steht nichts an, ist das
Abzeichen GANZ weg statt eine 0 zu zeigen: Eine Null, die man taeglich
sieht, wird zu Rauschen, und irgendwann uebersieht man auch die 3.
Die Reiterumschaltung war vorher ein Ja/Nein zwischen zwei Ansichten. Mit
vier Reitern haette jede neue Ansicht eine weitere "hidden = ..."-Zeile
gebraucht -- und beim naechsten Reiter vergisst man eine, dann liegen zwei
Ansichten uebereinander. Jetzt eine Zuordnung Reiter -> Kasten, die sich
selbst aufraeumt.
AUFGABENLISTE
Vier Abschnitte in den vier Prozessfarben -- dieselben wie im Portal und
auf der Ablaufseite. Das ist der Punkt: Eine Farbe bedeutet ueberall
dasselbe, sonst waere sie Deko.
Ein Klick aufs Kaestchen schaltet weiter: offen -> in Arbeit -> erledigt.
Drei Zustaende ueber einen Knopf statt eines Auswahlfeldes -- beim
Abarbeiten einer Liste zaehlt jeder gesparte Klick.
Punkte, die auf den KUNDEN warten, sind golden hinterlegt und tragen eine
Marke. Ohne diese Unterscheidung liest man die Liste als reinen
Fortschrittsbericht und uebersieht, dass man selbst gar nicht am Zug ist.
Der goldene Grund verschwindet, sobald der Punkt erledigt ist -- er
braucht dann keine Aufmerksamkeit mehr.
Abhaken faerbt die Zeile sofort um, ohne auf den Server zu warten. Bei
zehn Punkten hintereinander waere ein Neuaufbau nach jedem Klick zaeh, und
man verliert die Stelle. Geht es schief, wird die Liste neu geholt und der
Zustand ist wieder ehrlich.
POSTFACH
Links Verlaeufe, rechts das Gespraech. Nicht aus Nachahmung, sondern weil
man ein Gespraech abarbeitet und nicht eine Zeile: Man will sehen, was
vorher besprochen wurde, waehrend man antwortet. Kunde links, eigene
Nachrichten rechts -- man erkennt den Absender an der Seite, bevor man den
Namen liest.
Der Schalter "Nur interne Notiz" faerbt das ganze Schreibfeld um. Ein
Haekchen allein uebersieht man, und eine interne Bemerkung, die beim
Kunden landet, ist der peinlichste Fehler, den dieses System machen kann.
Interne Notizen im Verlauf sind gestrichelt umrandet und golden -- sie
duerfen nicht wie etwas Gesendetes aussehen.
Strg+Enter sendet, Enter allein nicht: In einem mehrzeiligen Feld will man
Absaetze machen koennen, ohne dass die halbe Nachricht rausgeht.
EIN FEHLER, DEN DIE TESTS NICHT GEFUNDEN HABEN
Der erste Lauf meldete 37 von 37 gruen -- und jede Aufgabenzeile war
sichtbar falsch herum gebaut: Kaestchen, dann die kleinen Knoepfe, Titel
ganz rechts. Aufgefallen ist es erst am Screenshot.
Ursache: Das Raster platziert zuerst alle Elemente mit FESTGELEGTER Zeile
und erst danach die freien. Kaestchen und Knopfgruppe hatten beide
"grid-row: 1 / span 2" und wurden deshalb zusammen nach vorne gesetzt; der
Titel landete als letzter in der dritten Spalte. Jetzt bekommt jedes Kind
Zeile UND Spalte ausdruecklich.
Die eigentliche Lehre steckt im Test: Alle 37 Pruefungen betrafen
Bestandteile (Farben, Anzahl, Durchgestrichenes), keine einzige die
ANORDNUNG. Wer eine Anordnung baut, muss die Anordnung pruefen. Drei neue
Pruefungen vergleichen jetzt die tatsaechlichen Bildschirmpositionen --
und sie wurden gegengeprueft: Mit dem alten Zustand schlagen sie fehl,
mit dem neuen nicht. Ein Test, der nie fehlschlaegt, ist wertlos.
GEPRUEFT: 43 Pruefungen, Computer und Handy, alle bestanden. Darunter:
alle Touch-Ziele mindestens 24 px, interne Notiz sieht anders aus als eine
gesendete, Verlauf springt ans Ende, keine Fehler im Protokoll.
Versionsstempel und Cache-Name auf v4.
Co-Authored-By: Claude Opus 5 <[email protected]>
457 lines
26 KiB
JavaScript
457 lines
26 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,
|
|
adminAufgabeAendern, adminAufgabeLoeschen, adminVorlageAnwenden,
|
|
} from "./routes/webdesign-aufgaben.js";
|
|
import {
|
|
portalPostfach, portalPostfachSenden, adminPostfachListe,
|
|
adminPostfachVerlauf, adminPostfachSenden, adminPostfachZaehler,
|
|
} from "./routes/webdesign-postfach.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();
|
|
|
|
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));
|
|
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));
|
|
|
|
/* ---------- 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));
|
|
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/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}`);
|
|
});
|