diff --git a/server/index.js b/server/index.js
index 4e38fd10..9ba234b7 100644
--- a/server/index.js
+++ b/server/index.js
@@ -38,6 +38,7 @@ import { uebernahmeRouter } from "./workspace-uebernahme.js";
import { befindenRouter } from "./workspace-befinden.js";
import { hilfeRouter } from "./workspace-hilfe.js";
import { supportRouter } from "./workspace-support.js";
+import { unterstuetzungRouter } from "./workspace-unterstuetzung.js";
import { reportRouter } from "./workspace-reports.js";
import { scoutRouter } from "./workspace-scouts.js";
import { callRouter } from "./workspace-calls.js";
@@ -311,6 +312,9 @@ app.use(uebernahmeRouter);
app.use(befindenRouter);
app.use(hilfeRouter);
app.use(supportRouter);
+/* Unterstützen (24.09.2026): die Wege, auf denen die Community etwas
+ beitragen kann – lesen alle, ändern nur DogFather. */
+app.use(unterstuetzungRouter);
app.use(bewerbungRouter);
app.use(reportRouter);
app.use(scoutRouter);
diff --git a/server/mess-chat-liste.mjs b/server/mess-chat-liste.mjs
new file mode 100644
index 00000000..d000c257
--- /dev/null
+++ b/server/mess-chat-liste.mjs
@@ -0,0 +1,214 @@
+/* =====================================================================
+ WIE SIEHT DIE GESPRÄCHSLISTE AUS? (24.09.2026)
+
+ Filipe, mit zwei Bildschirmfotos: "ich will dass die komplette
+ kachel viel krasser und geiler aussieht ... viel moderner. hau
+ richtig rein und mach was spezielles."
+
+ ---------------------------------------------------------------------
+ WARUM ES DIESE DATEI GIBT
+
+ Es gibt acht `pruef-chat*`-Dateien. Sie beweisen, dass der Chat
+ FUNKTIONIERT -- dass zwei Leute einander erreichen, dass die
+ Nachtruhe haelt, dass niemand in einen fremden Raum sieht. Keine
+ davon zeigt, wie die Liste AUSSIEHT, wenn sieben Gespraeche
+ verschiedener Art darin stehen: ein Kanal mit Erwaehnung und sechs
+ Ungelesenen, eine Gruppe, vier Zweiergespraeche mit Gesichtern.
+
+ Genau das war der Zustand auf Filipes Bildschirmfoto -- und genau
+ dort fiel auf, dass die "6" eine Zeile tiefer stand als das "@".
+
+ Diese Datei legt den Bestand an, den er sieht, und macht Bilder.
+ Sie prueft NICHTS und meldet keinen Fehlschlag.
+
+ Sie heisst `mess-` und nicht `pruef-`: Die Portvergabe leitet sich
+ aus den `pruef-`-Namen ab, und eine Messdatei darf die Nummern der
+ Pruefungen nicht verschieben.
+
+ Aufrufen mit: node server/mess-chat-liste.mjs
+ Bilder: server/mess-chat-liste-breit.png
+ server/mess-chat-liste-handy.png
+ ===================================================================== */
+
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const PORT = 5397;
+const ordner = mkdtempSync(join(tmpdir(), "ws-messchat-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+process.env.PORT = String(PORT);
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
+process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+
+const express = (await import("express")).default;
+const ec = express.response.cookie;
+express.response.cookie = function (n, w, o) {
+ return ec.call(this, n, w, { ...(o || {}), secure: false });
+};
+import { notbremse } from "./helfer-notbremse.mjs";
+await import("./index.js");
+notbremse(240_000, "mess-chat-liste");
+await new Promise((r) => setTimeout(r, 800));
+const BASIS = `http://127.0.0.1:${PORT}`;
+
+process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
+process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
+
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
+
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const jetzt = new Date().toISOString();
+const schluessel = randomBytes(32).toString("hex");
+d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
+ .run("code_kennung_schluessel", schluessel, jetzt);
+
+function anlegen(name, rolle, code) {
+ const salt = randomBytes(16).toString("hex");
+ const hash = scryptSync(code, salt, 64,
+ { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+ d.prepare("INSERT INTO personen"
+ + " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ + " VALUES (?,?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768,
+ createHmac("sha256", schluessel).update(code).digest("hex"), jetzt);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+
+/* Dieselbe Besetzung wie auf Filipes Bildschirmfoto -- damit sich das
+ Bild unmittelbar mit seinem vergleichen laesst. */
+const ich = anlegen("Filipe", "admin", "CODE-DOGI-0001");
+const leute = [
+ { name: "VanVan", rolle: "hand" },
+ { name: "Ghost", rolle: "modi" },
+ { name: "Diene", rolle: "modi" },
+ { name: "Marina", rolle: "modi" },
+ { name: "BananaStift", rolle: "modi" },
+ { name: "Patrick", rolle: "modi" },
+];
+for (const p of leute) p.id = anlegen(p.name, p.rolle, `CODE-${p.name.toUpperCase()}-1`);
+d.close();
+
+/* ---- Anmelden ------------------------------------------------------- */
+async function anmelden(rolle, code) {
+ const a = await fetch(BASIS + "/workspace/api/anmelden", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rolle, code }),
+ });
+ return (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
+}
+const keksIch = await anmelden("admin", "CODE-DOGI-0001");
+for (const p of leute) p.keks = await anmelden(p.rolle, `CODE-${p.name.toUpperCase()}-1`);
+
+const hin = (weg, keks, koerper) => fetch(BASIS + weg, {
+ method: "POST", headers: { "Content-Type": "application/json", Cookie: keks },
+ body: JSON.stringify(koerper),
+});
+const wer = (name) => leute.find((p) => p.name === name);
+
+/* ---- Die Gespräche -------------------------------------------------- */
+const raeume = {};
+{
+ /* Ein Kanal, eine Gruppe, vier Zweiergespräche -- die drei Arten, die
+ die Liste unterscheiden muss. */
+ const k = await hin("/workspace/api/chat/kanal", keksIch,
+ { kategorie: "", eigener_name: "Chat-Moderation", mit: leute.map((p) => p.id) });
+ raeume.kanal = (await k.json()).raum_id;
+
+ const k2 = await hin("/workspace/api/chat/kanal", keksIch,
+ { kategorie: "", eigener_name: "Das Rudel", mit: leute.map((p) => p.id) });
+ raeume.rudel = (await k2.json()).raum_id;
+
+ const g = await hin("/workspace/api/chat/gruppe", keksIch,
+ { name: "Abmeldungen \u{1F5D2}", mit: leute.map((p) => p.id) });
+ raeume.gruppe = (await g.json()).raum_id;
+
+ for (const name of ["VanVan", "Ghost", "BananaStift", "Patrick"]) {
+ const a = await hin("/workspace/api/chat/direkt", keksIch, { mit: wer(name).id });
+ raeume[name] = (await a.json()).raum_id;
+ }
+}
+
+/* ---- Was darin steht ------------------------------------------------- */
+const schreib = (raum, keks, text) =>
+ hin(`/workspace/api/chat/raeume/${raum}/nachrichten`, keks, { text });
+
+/* Sechs Ungelesene und eine Erwähnung -- der Zustand aus dem
+ Bildschirmfoto. */
+for (let i = 1; i <= 5; i++) {
+ await schreib(raeume.rudel, wer("Diene").keks,
+ `Kurze Ansage Nummer ${i} für alle im Rudel.`);
+}
+await schreib(raeume.rudel, wer("Diene").keks,
+ "Hallo ihr Lieben, schaut gerne mal rein @Filipe – das wollte ich dir zeigen.");
+
+await schreib(raeume.VanVan, wer("VanVan").keks,
+ "Da hat Miss was falsch eingegeben. Ich kümmere mich gleich drum.");
+await schreib(raeume.Ghost, wer("Ghost").keks,
+ "Sorry hab's gefunden ist gelöscht. Hatte ich übersehen.");
+await schreib(raeume.kanal, wer("Marina").keks, "Ach man \u{1F97A}\u{1F97A}");
+await schreib(raeume.gruppe, wer("Diene").keks,
+ "Guten Morgen ihr Lieben ich schaffe es heute leider nicht.");
+await schreib(raeume.BananaStift, wer("BananaStift").keks,
+ "Community Talk \u{1F6EB} fällt der heute aus?");
+await schreib(raeume.Patrick, keksIch, "Alles klar");
+
+/* ---- Bilder ---------------------------------------------------------- */
+const { chromium } = await import(
+ "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+const browser = await chromium.launch();
+try {
+ for (const [wie, breite, hoehe] of [["breit", 1280, 900], ["handy", 390, 860]]) {
+ const kontext = await browser.newContext({ viewport: { width: breite, height: hoehe } });
+ await kontext.addCookies(keksIch.split("; ").map((k) => {
+ const [name, ...rest] = k.split("=");
+ return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
+ }));
+ const seite = await kontext.newPage();
+ const konsole = [];
+ seite.on("console", (e) => { if (e.type() === "error") konsole.push(e.text()); });
+ await seite.goto(`${BASIS}/workspace/chat.html`, { waitUntil: "networkidle" });
+ await seite.waitForSelector("#raeume .chat-raum", { timeout: 15_000 });
+ await seite.waitForTimeout(700);
+
+ const ziel = await seite.$(wie === "breit" ? ".chat" : ".chat__spalte--liste");
+ await ziel.screenshot({ path: `server/mess-chat-liste-${wie}.png` });
+ console.log(` -- Bild (${breite} px): server/mess-chat-liste-${wie}.png`);
+
+ if (wie === "breit") {
+ const z = await seite.evaluate(() =>
+ [...document.querySelectorAll("#raeume .chat-raum")].map((li) => {
+ const k = li.querySelector(".chat-raum__knopf");
+ return {
+ name: li.querySelector(".chat-raum__name")?.textContent || "",
+ art: li.dataset.art || "",
+ neu: li.dataset.neu || "-",
+ zahl: li.querySelector(".chat-raum__zahl")?.textContent || "",
+ at: li.querySelector(".chat-raum__erwaehnt") ? "@" : " ",
+ bilder: li.querySelectorAll(".chat-raum__zeichen img").length,
+ hoch: Math.round(k.getBoundingClientRect().height),
+ };
+ }));
+ console.log("");
+ for (const x of z) {
+ console.log(` ${x.name.padEnd(18)} ${x.art.padEnd(7)} `
+ + `${x.at} ${String(x.zahl).padStart(3)} ${x.hoch}px Bilder: ${x.bilder}`);
+ }
+ const hoehen = [...new Set(z.map((x) => x.hoch))];
+ console.log("");
+ console.log(` -- Zeilenhoehen: ${hoehen.join(", ")} px `
+ + `(${hoehen.length === 1 ? "alle gleich" : "UNTERSCHIEDLICH"})`);
+ const doppelt = z.filter((x) => x.bilder > 1);
+ console.log(` -- Zeilen mit doppeltem Gesicht: ${doppelt.length}`);
+ }
+ if (konsole.length) console.log(" !! Konsole: " + konsole[0]);
+ await kontext.close();
+ }
+} finally {
+ await browser.close();
+ try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+ process.exit(0);
+}
diff --git a/server/pruef-start-ansicht.mjs b/server/pruef-start-ansicht.mjs
index 8e7f1a6c..b2a09ea9 100644
--- a/server/pruef-start-ansicht.mjs
+++ b/server/pruef-start-ansicht.mjs
@@ -125,6 +125,11 @@ const lesen = (seite) => seite.evaluate(() => ({
name: k.querySelector(".kachel__name")?.textContent,
unter: k.querySelector(".kachel__unter")?.textContent,
ton: k.dataset.ton,
+ /* DIE SILBERNE KACHEL ÜBERSCHREIBT IHREN TON (siehe start.css,
+ 23.09.2026) -- absichtlich, und nur in der Anzeige. Wer das
+ nicht mitliest, vergleicht ihre Silberfläche mit dem Grün ihres
+ Bereichs und meldet einen Fehler, den es nicht gibt. */
+ silber: k.dataset.silber === "ja",
hatZeichen: !!k.querySelector(".kachel__svg"),
farbe: k.querySelector(".kachel__svg")
? getComputedStyle(k.querySelector(".kachel__svg")).stroke : null,
@@ -547,12 +552,42 @@ for (const [breite, wie] of [[1440, "Computer"], [390, "Handy"]]) {
/* Und zwar wirklich DIESELBE Farbe, an echten Bildpunkten gemessen --
"hat einen Ton" reicht nicht, sonst faellt nicht auf, wenn alle
Zeilen denselben tragen. */
+ /* ==== EINE AUSNAHME, UND SIE IST BEGRÜNDET (24.09.2026) ==========
+
+ Diese Zeile war rot, seit „Aufgaben" am 23.09.2026 die silberne
+ Kachel bekommen hat -- und es war kein Fehler. Die Kachel
+ überschreibt ihr `--ton` absichtlich (start.css: „Filipe hat nach
+ einer silbernen KACHEL gefragt, nicht nach einer silbernen
+ Füllung"). Der Hinweis in „Was ansteht" trägt weiter das Grün
+ SEINES BEREICHS, und das ist richtig: Er gehört zum Bereich
+ Aufgaben, nicht zur Gestaltung dieser einen Kachel.
+
+ Damit ist es dasselbe Muster wie bei den acht Prüfungen vom
+ 23./24.09.: Die Prüfung ist nicht falsch geworden, sie ist
+ UNZUSTÄNDIG geworden -- eine Zusage, die jemand bewusst
+ zurückgenommen hat, und sie fordert sie weiter ein. Eine rote
+ Prüfung, die niemand mehr liest, sichert nichts.
+
+ STATT SIE ZU LOCKERN, WIRD SIE GENAUER: Die Silberkachel fällt
+ aus dem Farbvergleich heraus -- und bekommt dafür ihre eigene
+ Prüfung darunter. */
+ const silberToene = new Set(r.kacheln.filter((k) => k.silber).map((k) => k.ton));
const kachelFarbe = new Map(r.kacheln.map((k) => [k.ton, k.farbe]));
const falsch = r.dranZeilen.filter((z) => z.stufe !== "warnung")
.filter((z) => z.ton !== null && z.zahlFarbe !== null)
+ .filter((z) => !silberToene.has(z.ton))
.filter((z) => z.zahlFarbe !== kachelFarbe.get(z.ton));
ok(falsch.length === 0,
`und die Farbe ist die der Kachel (${falsch.map((z) => `${z.ton}: ${z.zahlFarbe} statt ${kachelFarbe.get(z.ton)}`).join(" | ") || "alle"})`);
+ /* UND DIE AUSNAHME WIRD SELBST GEPRÜFT. Ohne diese zwei Zeilen wäre
+ „ausgenommen" eine Lücke: Jede Kachel könnte sich `data-silber`
+ anhängen und dem Farbvergleich entkommen. */
+ const silber = r.kacheln.filter((k) => k.silber);
+ ok(silber.length <= 1,
+ `höchstens EINE silberne Kachel (${silber.length}: ${silber.map((k) => k.name).join(", ") || "keine"})`);
+ ok(silber.every((k) => !!k.ton),
+ `und sie trägt weiterhin ihre Tonnummer (${silber.map((k) => k.ton).join(",") || "—"}) `
+ + "– die Farbwerkzeuge rechnen mit Zahlen, nicht mit dem, was man sieht");
/* Ueberfaellig schlaegt Bereichsfarbe -- dort muss Rot stehen, sonst
geht die Dringlichkeit in siebzehn Farben unter. */
const warnZeilen = r.dranZeilen.filter((z) => z.stufe === "warnung");
diff --git a/server/pruef-unterstuetzung.mjs b/server/pruef-unterstuetzung.mjs
new file mode 100644
index 00000000..eeb635d5
--- /dev/null
+++ b/server/pruef-unterstuetzung.mjs
@@ -0,0 +1,503 @@
+/* =====================================================================
+ UNTERSTÜTZEN — sieht jeder, ändert nur einer (24.09.2026)
+
+ Filipe: „jeder soll diese kachel sehen aber nur dogfather soll sie
+ verändern können oder vieles mehr sehen. alle anderen, rechte und
+ linke hand, modis und community sollen supporten können."
+
+ ---------------------------------------------------------------------
+ AUF DIESER SEITE STEHT EIN ZAHLUNGSLINK. Das macht sie zur einzigen
+ im Haus, bei der ein Rechtefehler unmittelbar Geld kostet: Wer die
+ Adresse ändern darf, kann sie auf ein fremdes Konto zeigen lassen,
+ und es fällt niemandem auf. Deshalb wird hier nicht geprüft, ob die
+ Knöpfe fehlen — sondern ob der Server ABLEHNT.
+
+ ---------------------------------------------------------------------
+ GEMESSEN WIRD ÜBER DIE CREW-ADRESSE, NICHT ÜBER 127.0.0.1
+
+ Das ist keine Formsache. Welches „Haus" eine Anmeldung betrifft,
+ leitet der Server aus dem `Host`-Kopf ab (`istCrewAdresse`). Über
+ 127.0.0.1 landet man still im Agenturhaus — und dort gibt es die
+ Rolle `gast` gar nicht. Beim ersten Lauf dieser Datei waren deshalb
+ vier Rollen grün und der Zuschauer rot, und es sah nach einem
+ Rechtefehler aus. Es war ein MESSFEHLER: Ich hatte die falsche
+ Adresse gemessen.
+
+ `fetch` kann das nicht: `Host` ist ein verbotener Kopf und wird
+ verworfen. Deshalb `node:http` für die Schnittstelle und ein
+ HTTPS-Vorbau plus `--host-resolver-rules` für den Browser —
+ dasselbe Vorgehen wie in pruef-alter und pruef-chat-neu.
+
+ ---------------------------------------------------------------------
+ SECHS ABSCHNITTE
+
+ 1. Jede Rolle kommt auf die Seite und sieht die sichtbaren Wege.
+ 2. NUR DogFather darf ändern. Die rechte Hand ausdrücklich NICHT —
+ sie führt das Team mit, und diese eine Tür bleibt trotzdem zu.
+ 3. Eine Adresse wird geprüft, bevor sie in der Datenbank steht:
+ `javascript:`, `data:` und `http://` kommen nicht durch.
+ 4. Was unsichtbar ist, wird auch nicht ausgeliefert — nicht
+ versteckt im Browser, sondern gar nicht erst geschickt.
+ 5. Die Zählung ist anonym, und zwar von der TABELLE her: Es gibt
+ keine Spalte für eine Person. Geprüft über PRAGMA table_info,
+ nicht über eine Abfrage — eine Abfrage ließe sich ändern.
+ 6. Die Seite steht im Browser und die Kachel im Community-Bereich.
+
+ MIT GEGENPROBEN: Jede Ablehnung wird gegen einen Fall gehalten, der
+ durchgehen MUSS. Sonst hieße „abgelehnt" vielleicht nur, dass gar
+ nichts geht.
+
+ Aufrufen mit: node server/pruef-unterstuetzung.mjs
+ Bild: SCHIRM=1 node server/pruef-unterstuetzung.mjs
+ ===================================================================== */
+
+import { mkdtempSync, rmSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { execFileSync } from "node:child_process";
+import { request as httpAnfrage } from "node:http";
+import { createServer as httpsServer } from "node:https";
+import { eigenerPort } from "./helfer-port.mjs";
+
+const PORT = await eigenerPort(import.meta, "pruef-unterstuetzung");
+const HP = PORT + 1;
+const CREW = "crew.dogfather-universe.com";
+const ordner = mkdtempSync(join(tmpdir(), "ws-unter-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+process.env.PORT = String(PORT);
+process.env.SITE_ACCESS_SECRET = "lokaler-test";
+process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
+process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
+
+const express = (await import("express")).default;
+const ec = express.response.cookie;
+express.response.cookie = function (n, w, o) {
+ return ec.call(this, n, w, { ...(o || {}), secure: false });
+};
+import { notbremse } from "./helfer-notbremse.mjs";
+await import("./index.js");
+notbremse(240_000, "pruef-unterstuetzung");
+await new Promise((r) => setTimeout(r, 800));
+
+process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
+process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
+
+let fehler = 0, geprueft = 0;
+const melde = (t) => console.log(t);
+const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+
+/* ---- Sprechen über die Crew-Adresse ---------------------------------- */
+function anfrage(pfad, art, koerper, keks) {
+ return new Promise((fertig, schief) => {
+ const kopf = { Host: CREW };
+ if (keks) kopf.Cookie = keks;
+ if (koerper) kopf["Content-Type"] = "application/json";
+ const a = httpAnfrage(
+ { host: "127.0.0.1", port: PORT, path: pfad, method: art, headers: kopf },
+ (antwort) => {
+ let text = "";
+ antwort.on("data", (s) => { text += s; });
+ antwort.on("end", () => fertig({
+ code: antwort.statusCode, text, kopf: antwort.headers,
+ }));
+ });
+ a.on("error", schief);
+ if (koerper) a.write(JSON.stringify(koerper));
+ a.end();
+ });
+}
+const json = (a) => { try { return JSON.parse(a.text); } catch { return null; } };
+
+/* ---- Der Bestand ----------------------------------------------------- */
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const jetzt = new Date().toISOString();
+const schluessel = randomBytes(32).toString("hex");
+d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
+ .run("code_kennung_schluessel", schluessel, jetzt);
+
+function anlegen(name, rolle, code) {
+ const salt = randomBytes(16).toString("hex");
+ const hash = scryptSync(code, salt, 64,
+ { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+ d.prepare("INSERT INTO personen"
+ + " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ + " VALUES (?,?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768,
+ createHmac("sha256", schluessel).update(code).digest("hex"), jetzt);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+
+/* Die fünf Rollen, die Filipe genannt hat — DogFather, rechte und
+ linke Hand, ein Modi und die Community. */
+const ROLLEN = [
+ ["Filipe", "admin", "CODE-DOGI-0001"],
+ ["VanVan", "hand", "CODE-HAND-0001"],
+ ["Kessi", "linke", "CODE-LINKE-0001"],
+ ["Diene", "modi", "CODE-MODI-0001"],
+ ["Zuschauer", "gast", "CODE-GAST-0001"],
+];
+for (const [name, rolle, code] of ROLLEN) anlegen(name, rolle, code);
+
+/* Die Spalten — Abschnitt 5 prüft dagegen. Gelesen aus dem SCHEMA,
+ nicht aus einer Abfrage: Eine Abfrage ließe sich morgen erweitern,
+ eine fehlende Spalte nicht. */
+const STRICH_SPALTEN = d.prepare("PRAGMA table_info(unterstuetzung_striche)")
+ .all().map((z) => z.name);
+const WEG_SPALTEN = d.prepare("PRAGMA table_info(unterstuetzung_wege)")
+ .all().map((z) => z.name);
+d.close();
+
+async function anmelden(rolle, code) {
+ /* `alter_ok` NUR BEI DER COMMUNITY: Sie bestätigt beim ersten
+ Hereinkommen das Mindestalter (workspace.js). Wer zum Team gehört,
+ hat seinen Zugang persönlich bekommen. */
+ const a = await anfrage("/workspace/api/anmelden", "POST",
+ rolle === "gast" ? { rolle, code, alter_ok: true } : { rolle, code });
+ if (a.code !== 200) {
+ /* DER DRITTE AUSGANG: Ohne Anmeldung kann diese Prüfung gar nichts
+ beantworten — und das ist etwas anderes als „nicht in Ordnung". */
+ melde(` ABBRUCH: ${rolle} ließ sich nicht anmelden (HTTP ${a.code}, `
+ + `${a.text.slice(0, 90)}). Ohne Anmeldung misst diese Prüfung nichts.`);
+ process.exit(2);
+ }
+ return (a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
+}
+const kekse = {};
+for (const [, rolle, code] of ROLLEN) kekse[rolle] = await anmelden(rolle, code);
+
+const lies = async (keks) => {
+ const a = await anfrage("/workspace/api/unterstuetzung", "GET", null, keks);
+ return { status: a.code, daten: a.code === 200 ? json(a) : null };
+};
+const schreib = (keks, id, koerper, art = "PUT") =>
+ anfrage(`/workspace/api/unterstuetzung${id ? "/" + id : ""}`, art, koerper, keks);
+
+/* =====================================================================
+ 1. JEDE ROLLE SIEHT DIE SEITE
+ ===================================================================== */
+melde("");
+melde("=== 1. Sieht jeder die Wege? ===");
+const gesehen = {};
+for (const [, rolle] of ROLLEN) {
+ const r = await lies(kekse[rolle]);
+ gesehen[rolle] = r.daten;
+ ok(r.status === 200 && Array.isArray(r.daten?.wege),
+ `${rolle}: bekommt die Wege (HTTP ${r.status}, ${r.daten?.wege?.length ?? "?"} Stück)`);
+}
+ok(gesehen.gast?.wege.length > 0, `und es sind wirklich welche da (${gesehen.gast?.wege.length})`);
+/* Die Wunschliste ist der Weg, den Filipe mitgeliefert hat — sie muss
+ bei JEDEM ankommen, nicht nur bei der Leitung. */
+const wunsch = gesehen.gast?.wege.find((w) => w.schluessel === "amazon");
+ok(!!wunsch && wunsch.ziel.startsWith("https://www.amazon.de/"),
+ `die Wunschliste steht auch beim Zuschauer (${wunsch?.ziel.slice(0, 42)}…)`);
+
+/* GEGENPROBE ZUM GANZEN ABSCHNITT: Ohne Anmeldung gibt es nichts. */
+{
+ const a = await anfrage("/workspace/api/unterstuetzung", "GET");
+ ok(a.code === 401, `ohne Anmeldung gar nichts (HTTP ${a.code})`);
+}
+
+/* =====================================================================
+ 2. ÄNDERN DARF NUR EINER
+ ===================================================================== */
+melde("");
+melde("=== 2. Wer darf ändern? ===");
+ok(gesehen.admin?.darf_aendern === true, "DogFather: darf_aendern = true");
+for (const rolle of ["hand", "linke", "modi", "gast"]) {
+ ok(gesehen[rolle]?.darf_aendern === false, `${rolle}: darf_aendern = false`);
+}
+
+const amazonId = gesehen.admin.wege.find((w) => w.schluessel === "amazon").id;
+/* UND ZWAR NICHT NUR IM BROWSER. Jede der vier Rollen versucht es
+ wirklich — eine Oberfläche, die den Knopf versteckt, ist keine
+ Sperre, sondern eine Bitte. */
+for (const rolle of ["hand", "linke", "modi", "gast"]) {
+ const a = await schreib(kekse[rolle], amazonId, { titel: "Übernommen" });
+ ok(a.code === 404, `${rolle}: PUT wird abgewiesen (HTTP ${a.code})`);
+}
+{
+ const a = await schreib(kekse.hand, amazonId, null, "DELETE");
+ ok(a.code === 404, `die rechte Hand kann auch nicht löschen (HTTP ${a.code})`);
+ const b = await schreib(kekse.hand, null, { titel: "Eigener Weg" }, "POST");
+ ok(b.code === 404, `und keinen neuen anlegen (HTTP ${b.code})`);
+}
+/* GEGENPROBE: DogFather kommt durch. Ohne sie hieße „404 für alle"
+ vielleicht nur, dass es die Route gar nicht gibt. */
+{
+ const a = await schreib(kekse.admin, amazonId, { titel: "Wunschliste" });
+ ok(a.code === 200, `DogFather kommt durch (HTTP ${a.code})`);
+ const nach = await lies(kekse.gast);
+ ok(nach.daten.wege.find((w) => w.id === amazonId)?.titel === "Wunschliste",
+ "und die Änderung kommt bei allen an");
+}
+
+/* =====================================================================
+ 3. DIE ADRESSE WIRD GEPRÜFT
+ ===================================================================== */
+melde("");
+melde("=== 3. Was darf in eine Adresse? ===");
+const BOESE = [
+ ["javascript:alert(1)", "ein Skript"],
+ ["data:text/html,
x", "eine ganze Seite im Link"],
+ ["http://example.com/pay", "unverschlüsselt"],
+ ["//example.com/pay", "ohne Schema"],
+];
+for (const [ziel, warum] of BOESE) {
+ const a = await schreib(kekse.admin, amazonId, { ziel, knopf: "Los" });
+ ok(a.code === 400, `abgelehnt: ${warum} (${ziel.slice(0, 24)} → HTTP ${a.code})`);
+}
+/* GEGENPROBE: Die beiden erlaubten Formen gehen durch. */
+const AMAZON = "https://www.amazon.de/hz/wishlist/ls/3BGGNNHYAO5QL";
+{
+ const a = await schreib(kekse.admin, amazonId,
+ { ziel: AMAZON, knopf: "Wunschliste ansehen" });
+ ok(a.code === 200, `https:// geht durch (HTTP ${a.code})`);
+ const b = await schreib(kekse.admin,
+ gesehen.admin.wege.find((w) => w.schluessel === "draussen").id,
+ { ziel: "unsere-seiten.html", knopf: "Zu Draußen" });
+ ok(b.code === 200, `eine Seite des Hauses auch (HTTP ${b.code})`);
+}
+/* EIN KNOPF OHNE ZIEL IST EIN VERSPRECHEN INS LEERE. */
+{
+ const a = await schreib(kekse.admin, amazonId, { ziel: "", knopf: "Drück mich" });
+ ok(a.code === 400, `Knopf ohne Adresse wird abgelehnt (HTTP ${a.code})`);
+ await schreib(kekse.admin, amazonId, { ziel: AMAZON, knopf: "Wunschliste ansehen" });
+}
+
+/* =====================================================================
+ 4. UNSICHTBAR HEISST NICHT AUSGELIEFERT
+ ===================================================================== */
+melde("");
+melde("=== 4. Was unsichtbar ist ===");
+{
+ const beiAdmin = (await lies(kekse.admin)).daten.wege;
+ const beiGast = (await lies(kekse.gast)).daten.wege;
+ const paypal = beiAdmin.find((w) => w.schluessel === "paypal");
+ ok(!!paypal, "DogFather sieht den PayPal-Weg");
+ ok(paypal.sichtbar === false && paypal.ziel === "",
+ "er steht ohne Adresse da und ist ausgeblendet — eine Zahlungsadresse wird nicht geraten");
+ ok(!beiGast.some((w) => w.schluessel === "paypal"),
+ "und beim Zuschauer kommt er gar nicht erst an");
+ ok(beiAdmin.length > beiGast.length,
+ `DogFather sieht mehr (${beiAdmin.length} gegen ${beiGast.length})`);
+ ok(beiGast.every((w) => w.sichtbar), "beim Zuschauer ist alles Gelieferte auch sichtbar");
+ ok((await lies(kekse.admin)).daten.zahlen !== null, "DogFather bekommt die Zahlen");
+ for (const rolle of ["hand", "linke", "modi", "gast"]) {
+ ok((await lies(kekse[rolle])).daten.zahlen === null, `${rolle}: keine Zahlen`);
+ }
+}
+
+/* =====================================================================
+ 5. DIE ZÄHLUNG IST ANONYM — VON DER TABELLE HER
+ ===================================================================== */
+melde("");
+melde("=== 5. Zählt jemand mit? ===");
+ok(STRICH_SPALTEN.length > 0,
+ `die Zähltabelle hat ${STRICH_SPALTEN.length} Spalten (${STRICH_SPALTEN.join(", ")})`);
+const VERDAECHTIG = ["person_id", "person", "von_id", "ip", "sitzung", "name", "wer"];
+ok(!STRICH_SPALTEN.some((s) => VERDAECHTIG.includes(s)),
+ "keine Spalte für eine Person, eine IP oder eine Sitzung");
+/* GEGENPROBE: Derselbe Sucher findet so etwas auch wirklich — die
+ Wege-Tabelle hat `geaendert_von`, und das MUSS er sehen. Ohne diese
+ Zeile hieße „nichts gefunden" vielleicht nur, dass er nicht sucht. */
+ok(WEG_SPALTEN.includes("geaendert_von"),
+ "Gegenprobe: in der Wege-Tabelle findet dieselbe Suche sehr wohl eine Person (geaendert_von)");
+
+{
+ const zuvor = (await lies(kekse.admin)).daten.zahlen?.[amazonId]?.gesamt || 0;
+ const a = await anfrage(`/workspace/api/unterstuetzung/${amazonId}/strich`,
+ "POST", null, kekse.gast);
+ ok(a.code === 200, `ein Zuschauer darf zählen (HTTP ${a.code})`);
+ const danach = (await lies(kekse.admin)).daten.zahlen?.[amazonId]?.gesamt || 0;
+ ok(danach === zuvor + 1, `und der Strich kommt an (${zuvor} → ${danach})`);
+ /* EINMAL JE TAG. Ohne diese Sperre wäre die Zahl das Ergebnis von
+ Dauerdrücken und keine Auskunft. */
+ await anfrage(`/workspace/api/unterstuetzung/${amazonId}/strich`, "POST", null, kekse.gast);
+ const nochmal = (await lies(kekse.admin)).daten.zahlen?.[amazonId]?.gesamt || 0;
+ ok(nochmal === danach, `zweimal derselbe zählt nicht doppelt (${danach} → ${nochmal})`);
+ /* Aber ein ANDERER schon — sonst wäre die Sperre zu breit. */
+ await anfrage(`/workspace/api/unterstuetzung/${amazonId}/strich`, "POST", null, kekse.modi);
+ const dritter = (await lies(kekse.admin)).daten.zahlen?.[amazonId]?.gesamt || 0;
+ ok(dritter === nochmal + 1, `ein anderer Mensch zählt sehr wohl (${nochmal} → ${dritter})`);
+}
+/* EIN UNSICHTBARER WEG WIRD NICHT GEZÄHLT — sonst stünde eine Zahl an
+ einem Weg, den außer DogFather niemand sehen kann. */
+{
+ const paypalId = (await lies(kekse.admin)).daten.wege
+ .find((w) => w.schluessel === "paypal").id;
+ const a = await anfrage(`/workspace/api/unterstuetzung/${paypalId}/strich`,
+ "POST", null, kekse.gast);
+ ok(a.code === 404, `ein ausgeblendeter Weg lässt sich nicht zählen (HTTP ${a.code})`);
+}
+
+/* =====================================================================
+ 6. DIE SEITE UND DIE KACHEL
+ =====================================================================
+
+ Der Browser spricht über die Crew-Adresse, sonst misst er das
+ Agenturhaus (siehe den Kopf). Dafür ein HTTPS-Vorbau mit einem
+ selbst ausgestellten Zeugnis und `--host-resolver-rules` — dasselbe
+ Vorgehen wie in pruef-chat-neu. */
+melde("");
+melde("=== 6. Im Browser ===");
+const schl = join(ordner, "b.key"), zert = join(ordner, "b.crt");
+execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", schl,
+ "-out", zert, "-days", "2", "-subj", `/CN=${CREW}`, "-addext", `subjectAltName=DNS:${CREW}`],
+{ stdio: "ignore" });
+const vorbau = httpsServer({ key: readFileSync(schl), cert: readFileSync(zert) }, (von, nach) => {
+ const w = httpAnfrage({
+ host: "127.0.0.1", port: PORT, path: von.url, method: von.method,
+ headers: { ...von.headers, host: `${CREW}:${HP}` },
+ }, (a) => { nach.writeHead(a.statusCode || 502, a.headers); a.pipe(nach); });
+ von.pipe(w);
+});
+await new Promise((r) => vorbau.listen(HP, "127.0.0.1", r));
+const SEITE = `https://${CREW}:${HP}`;
+
+const pw = await import(
+ "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+const browser = await pw.chromium.launch({
+ args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors"],
+});
+try {
+ for (const rolle of ["admin", "gast"]) {
+ const kontext = await browser.newContext({ viewport: { width: 1280, height: 1000 } });
+ await kontext.addCookies(kekse[rolle].split("; ").map((k) => {
+ const [name, ...rest] = k.split("=");
+ return { name, value: rest.join("="), domain: CREW, path: "/" };
+ }));
+ const seite = await kontext.newPage();
+ const konsole = [];
+ seite.on("console", (e) => {
+ if (e.type() === "error" && !/favicon|ERR_CERT|net::ERR/i.test(e.text())) {
+ konsole.push(e.text().slice(0, 120));
+ }
+ });
+ seite.on("pageerror", (e) => konsole.push("Absturz: " + String(e).slice(0, 120)));
+
+ /* DIE KACHEL — ohne sie findet niemand die Seite. */
+ await seite.goto(`${SEITE}/workspace/start.html`, { waitUntil: "networkidle" });
+ await seite.waitForTimeout(700);
+ const kachel = await seite.evaluate(() => {
+ const a = [...document.querySelectorAll('a[href^="unterstuetzen.html"]')][0];
+ if (!a) return null;
+ return {
+ name: a.querySelector(".kachel__name")?.textContent
+ || a.textContent.trim().slice(0, 40),
+ zeichen: !!a.querySelector("svg"),
+ };
+ });
+ ok(!!kachel, `${rolle}: die Kachel steht auf der Startseite (${kachel?.name || "—"})`);
+ ok(!!kachel?.zeichen, `${rolle}: und sie trägt ihr Zeichen`);
+
+ await seite.goto(`${SEITE}/workspace/unterstuetzen.html`, { waitUntil: "networkidle" });
+ await seite.waitForSelector(".u-weg", { timeout: 15_000 });
+ await seite.waitForTimeout(500);
+
+ const b = await seite.evaluate(() => ({
+ karten: document.querySelectorAll(".u-weg").length,
+ gruppen: [...document.querySelectorAll(".u-gruppe__titel")].map((x) => x.textContent),
+ leitung: !document.getElementById("leitung")?.hidden,
+ knoepfe: [...document.querySelectorAll(".u-weg__knopf")].map((a) => ({
+ ziel: a.getAttribute("href"), rel: a.getAttribute("rel") || "",
+ neu: a.getAttribute("target") || "",
+ hoch: Math.round(a.getBoundingClientRect().height),
+ })),
+ }));
+ ok(b.karten >= 4, `${rolle}: ${b.karten} Wege stehen da`);
+ ok(b.gruppen.length === 2, `${rolle}: zwei Gruppen (${b.gruppen.join(" / ")})`);
+ ok(b.leitung === (rolle === "admin"),
+ `${rolle}: der Leitungskasten steht ${rolle === "admin" ? "da" : "NICHT da"}`);
+
+ /* JEDER WEG NACH DRAUSSEN BRAUCHT `noopener` — ohne ihn kann die
+ geöffnete Seite über `window.opener` auf diese zugreifen. */
+ const raus = b.knoepfe.filter((k) => /^https:/.test(k.ziel));
+ ok(raus.length > 0, `${rolle}: ${raus.length} Weg(e) führen nach draußen`);
+ ok(raus.every((k) => k.rel.includes("noopener") && k.rel.includes("noreferrer")),
+ `${rolle}: und alle tragen rel="noopener noreferrer"`);
+ ok(raus.every((k) => k.neu === "_blank"), `${rolle}: und öffnen ein neues Fenster`);
+ /* AM FINGER TREFFBAR. Dieser Knopf führt zu einer Zahlung; ihn zu
+ verfehlen ist ärgerlicher als anderswo. */
+ ok(b.knoepfe.length > 0 && b.knoepfe.every((k) => k.hoch >= 44),
+ `${rolle}: jeder Knopf ist mindestens 44 px hoch `
+ + `(${b.knoepfe.map((k) => k.hoch).join(", ")})`);
+
+ /* ==== DER WEG, DEN FILIPE WIRKLICH GEHEN WIRD ================
+
+ Sein PayPal-Link steht nirgends im Haus ("mein paypal kennst du
+ ja schon" -- gesucht, nicht gefunden), und geraten wird er
+ nicht. Er traegt ihn also selbst ein, hier, auf dieser Seite.
+
+ GENAU DIESER ABLAUF WIRD GEMESSEN und nicht nur die Route
+ darunter: Knopf druecken, Felder ausfuellen, speichern, und
+ danach muss der Weg bei einem ZUSCHAUER ankommen. Eine Route,
+ die 200 sagt, waehrend das Formular das falsche Feld schickt,
+ waere gruen und trotzdem kaputt. */
+ if (rolle === "admin") {
+ await seite.click("#aendern-an");
+ await seite.waitForTimeout(400);
+ const felder = await seite.evaluate(() =>
+ document.querySelectorAll('.u-weg .u-form [data-feld="ziel"]').length);
+ ok(felder >= 4, `Änderungsmodus: ${felder} Karten zeigen ihre Felder`);
+
+ const getippt = await seite.evaluate(() => {
+ const karte = [...document.querySelectorAll(".u-weg")]
+ .find((k) => k.dataset.schluessel === "paypal");
+ if (!karte) return false;
+ const setz = (feld, wert) => {
+ const e = karte.querySelector(`[data-feld="${feld}"]`);
+ if (!e) return;
+ if (e.type === "checkbox") e.checked = wert; else e.value = wert;
+ };
+ setz("ziel", "https://www.paypal.com/paypalme/beispiel");
+ setz("knopf", "Bei PayPal öffnen");
+ setz("sichtbar", true);
+ [...karte.querySelectorAll("button")]
+ .find((b) => b.textContent === "Speichern")?.click();
+ return true;
+ });
+ ok(getippt, "die PayPal-Karte lässt sich ausfüllen");
+ await seite.waitForTimeout(900);
+
+ const jetztDa = await seite.evaluate(() => {
+ const k = [...document.querySelectorAll(".u-weg")]
+ .find((x) => x.dataset.schluessel === "paypal");
+ return { aus: k?.dataset.aus || "", offen: !!k };
+ });
+ ok(jetztDa.offen && jetztDa.aus === "", "und steht danach nicht mehr ausgeblendet da");
+
+ /* UND JETZT DER PUNKT: Kommt sie beim Zuschauer an? */
+ const beimGast = (await lies(kekse.gast)).daten.wege
+ .find((w) => w.schluessel === "paypal");
+ ok(!!beimGast && beimGast.ziel === "https://www.paypal.com/paypalme/beispiel"
+ && beimGast.knopf === "Bei PayPal öffnen",
+ `der Zuschauer sieht den Weg jetzt (${beimGast?.knopf || "—"})`);
+
+ /* ZURUECK IN DEN ANZEIGEMODUS -- das Bildschirmfoto soll zeigen,
+ was Filipe im Alltag sieht, nicht das Formular. */
+ await seite.click("#aendern-an");
+ await seite.waitForTimeout(500);
+ }
+
+ if (rolle === "admin" && process.env.SCHIRM) {
+ await seite.screenshot({ path: "server/pruef-unterstuetzung.png" });
+ melde(" -- Bildschirmfoto: server/pruef-unterstuetzung.png");
+ }
+ ok(konsole.length === 0, konsole.length
+ ? `${rolle}: Fehler in der Konsole: ${konsole[0]}`
+ : `${rolle}: keine Fehler in der Konsole`);
+ await kontext.close();
+ }
+} finally {
+ await browser.close();
+ try { vorbau.close(); } catch { /* egal */ }
+}
+
+melde("");
+melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
+melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+process.exit(fehler ? 1 : 0);
diff --git a/server/rechte.js b/server/rechte.js
index b54d8ffb..bdb3690b 100644
--- a/server/rechte.js
+++ b/server/rechte.js
@@ -472,6 +472,23 @@ export const SEITEN = {
"/workspace/support.html": ["gast", "modi", "creator", "scout", "manager",
"linke", "hand", "spicy", "admin"],
+ /* UNTERSTÜTZEN (24.09.2026).
+
+ Filipe: „jeder soll diese kachel sehen aber nur dogfather soll sie
+ verändern können oder vieles mehr sehen. alle anderen, rechte und
+ linke hand, modis und community sollen supporten können."
+
+ Die Seite steht damit JEDER Rolle offen — was jemand darauf tun
+ darf, entscheidet workspace-unterstuetzung.js und nicht diese
+ Tafel. Sie beantwortet nur, wer die Tür aufbekommt.
+
+ ALLE ROLLEN AUSGESCHRIEBEN, dieselbe Überlegung wie beim Support
+ eine Zeile darüber: Diese Tafel ist die Stelle, an der man
+ nachsieht, wer wohin darf. Eine Abkürzung hier hieße, dass die
+ Seite in „Wer sieht was" als Lücke erscheint. */
+ "/workspace/unterstuetzen.html": ["gast", "modi", "creator", "scout", "manager",
+ "linke", "hand", "spicy", "admin"],
+
/* WER SIEHT WAS. DogFather und die rechte Hand (Filipe, 11.09.2026:
"ich will die kategorie auch auf der team dogi seite fuer die
dogfather und rechte hand rollen").
diff --git a/server/unterstuetzung-tabellen.js b/server/unterstuetzung-tabellen.js
new file mode 100644
index 00000000..868cab3c
--- /dev/null
+++ b/server/unterstuetzung-tabellen.js
@@ -0,0 +1,179 @@
+/* =====================================================================
+ UNTERSTÜTZEN — Tabellen (24.09.2026)
+
+ Filipe: „ich brauch auch noch eine kachel im community bereich. wo
+ mein paypal und meine amazon liste sein wird. wo die leute alle
+ supporten können auf andere art anstatt nur tiktok. … jeder soll
+ diese kachel sehen aber nur dogfather soll sie verändern können
+ oder vieles mehr sehen. alle anderen, rechte und linke hand, modis
+ und community sollen supporten können."
+
+ ---------------------------------------------------------------------
+ WARUM DIE WEGE IN DER DATENBANK STEHEN UND NICHT IM QUELLTEXT
+
+ Es wäre kürzer gewesen, PayPal und die Wunschliste als zwei feste
+ Einträge in eine Datei zu schreiben. Drei Gründe dagegen, und der
+ dritte ist der entscheidende:
+
+ 1. EIN LINK ÄNDERT SICH. Eine Wunschliste wird neu angelegt, ein
+ Zahlungsweg kommt dazu. Stünde er im Quelltext, hinge jede
+ Änderung an einem Deploy — und damit an mir.
+ 2. FILIPE SOLL IHN SELBST ÄNDERN KÖNNEN. Genau das hat er
+ verlangt („nur dogfather soll sie verändern können"). Ein Recht,
+ das man nur über einen Entwickler ausüben kann, ist keines.
+ 3. UND DER PAYPAL-LINK STEHT NIRGENDS IM HAUS. Filipe schrieb
+ „mein paypal kennst du ja schon" — gesucht habe ich, gefunden
+ nicht. Ihn zu RATEN wäre der schlimmste Fehler dieser ganzen
+ Seite: Ein falscher Zahlungslink schickt Geld an einen
+ Fremden, und niemand merkt es. Deshalb steht dort am Anfang
+ nichts, der Weg ist unsichtbar, und die Seite sagt DogFather
+ (und nur ihm), dass dort noch etwas fehlt.
+
+ ---------------------------------------------------------------------
+ DREI ARTEN, UND DIE DRITTE IST DIE WICHTIGSTE
+
+ geld Direkt Geld — PayPal.
+ geschenk Etwas schenken — die Wunschliste.
+ frei Kostet nichts.
+
+ „frei" ist nicht Beiwerk. Eine Seite, die nur zwei Wege zum Zahlen
+ zeigt, liest sich wie eine hohle Hand — und die meisten, die hier
+ lesen, sind Zuschauer ohne Geld übrig. Wer gar nichts geben kann,
+ soll trotzdem etwas finden, das er tun kann, statt die Seite mit
+ einem schlechten Gefühl zu schließen. Deshalb stehen die freien
+ Wege gleichberechtigt daneben und nicht als Trostpflaster darunter.
+
+ ---------------------------------------------------------------------
+ DIE ZÄHLUNG IST ANONYM — UND ZWAR VON DER TABELLE HER
+
+ „nur dogfather soll … vieles mehr sehen": Er sieht, wie oft ein Weg
+ geöffnet wurde. Was er NICHT sieht, ist WER — und das liegt nicht an
+ einer Abfrage, die man später ändern könnte, sondern daran, dass es
+ in dieser Tabelle keine Spalte dafür gibt.
+
+ Das ist Absicht und keine Nachlässigkeit. „Wer hat auf PayPal
+ gedrückt und dann doch nichts geschickt" ist eine Frage, die
+ niemandem nützt und mehrere Leute beschämt. Sie lässt sich hier
+ nicht beantworten, auch nicht von mir, auch nicht mit Datenbank-
+ zugriff.
+
+ Gezählt wird je Weg und je TAG. Der Tag reicht für „läuft das
+ überhaupt an?" und ergibt bei einer Community dieser Größe keine
+ Spur zu einzelnen Menschen.
+ ===================================================================== */
+
+/** Wie lange die Tagesstriche stehen bleiben.
+ *
+ * Ein Jahr: Lang genug, um Weihnachten mit Weihnachten zu
+ * vergleichen, kurz genug, dass hier nichts für immer liegt. Aufgeräumt
+ * wird im Rundgang, nicht beim Lesen — sonst hinge das Löschen davon
+ * ab, dass jemand die Seite öffnet. */
+export const ZAEHLUNG_TAGE = 365;
+
+/** Die drei Arten. Steht hier und nicht in der Oberfläche: Der Server
+ * prüft dagegen, und eine zweite Liste im Browser wäre die, die beim
+ * nächsten Zuwachs vergessen wird. */
+export const UNTERSTUETZUNG_ARTEN = {
+ geld: "Direkt",
+ geschenk: "Schenken",
+ frei: "Kostet nichts",
+};
+
+/* Die Startbelegung. Sie wird EINMAL eingesetzt (INSERT OR IGNORE über
+ den Schlüssel) und danach nie wieder angefasst — wer einen Weg
+ löscht, soll ihn nicht beim nächsten Serverstart wiederfinden.
+
+ DER PAYPAL-WEG KOMMT OHNE ADRESSE UND UNSICHTBAR. Siehe oben: Raten
+ ist hier die gefährlichste aller Abkürzungen. */
+const START_WEGE = [
+ {
+ schluessel: "paypal", art: "geld", platz: 10, sichtbar: 0,
+ titel: "PayPal",
+ text: "Direkt und ohne Umweg. Auch ein kleiner Betrag hilft – "
+ + "und niemand muss etwas geben, um dazuzugehören.",
+ knopf: "Bei PayPal öffnen",
+ ziel: "",
+ },
+ {
+ schluessel: "amazon", art: "geschenk", platz: 20, sichtbar: 1,
+ titel: "Wunschliste",
+ text: "Etwas Konkretes schenken statt Geld – die Liste sagt, "
+ + "was gerade gebraucht wird.",
+ knopf: "Wunschliste ansehen",
+ ziel: "https://www.amazon.de/hz/wishlist/ls/3BGGNNHYAO5QL?ref_=wl_share",
+ },
+ {
+ schluessel: "dabei", art: "frei", platz: 30, sichtbar: 1,
+ titel: "Einfach da sein",
+ text: "Im Live dabei sein, mitreden, jemanden begrüßen, der neu ist. "
+ + "Das kostet nichts und ist am meisten wert.",
+ knopf: "", ziel: "",
+ },
+ {
+ schluessel: "weitersagen", art: "frei", platz: 40, sichtbar: 1,
+ titel: "Weitersagen",
+ text: "Einen Clip teilen oder jemandem von hier erzählen. "
+ + "Neue Leute kommen über euch, nicht über Werbung.",
+ knopf: "", ziel: "",
+ },
+ {
+ schluessel: "draussen", art: "frei", platz: 50, sichtbar: 1,
+ titel: "Wo es Dogi sonst gibt",
+ text: "Kanäle, Shop und dein Rabatt stehen auf einer eigenen Seite.",
+ knopf: "Zu „Draußen“",
+ ziel: "unsere-seiten.html",
+ },
+];
+
+export function unterstuetzungTabellen(d) {
+ d.exec(`
+ CREATE TABLE IF NOT EXISTS unterstuetzung_wege (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ /* Der Schlüssel ist die Kennung für die Startbelegung und für
+ die Zählung. Er ändert sich nie — der TITEL darf sich ändern,
+ ohne dass die Striche der letzten Monate zu einem anderen Weg
+ gehören. */
+ schluessel TEXT NOT NULL UNIQUE,
+ art TEXT NOT NULL CHECK (art IN ('geld','geschenk','frei')),
+ titel TEXT NOT NULL,
+ text TEXT NOT NULL DEFAULT '',
+ /* Die Aufschrift des Knopfes. Leer heißt: Dieser Weg hat keinen
+ Knopf — es gibt Wege, die man nicht anklickt („einfach da
+ sein"). Ein Knopf ohne Ziel wäre ein Versprechen ins Leere. */
+ knopf TEXT NOT NULL DEFAULT '',
+ /* Die Adresse. Darf leer sein. Geprüft wird sie beim SCHREIBEN
+ (nur https:// oder eine Seite dieses Hauses) — nicht erst beim
+ Anzeigen: Eine Adresse, die erst im Browser auffällt, steht
+ vorher schon in der Datenbank. */
+ ziel TEXT NOT NULL DEFAULT '',
+ sichtbar INTEGER NOT NULL DEFAULT 1,
+ platz INTEGER NOT NULL DEFAULT 0,
+ geaendert TEXT,
+ geaendert_von INTEGER REFERENCES personen(id) ON DELETE SET NULL
+ );
+
+ /* KEINE SPALTE FÜR EINE PERSON. Siehe den Kopf dieser Datei — das
+ ist die eigentliche Zusage dieser Seite, und sie steht hier im
+ Schema und nicht in einer Abfrage, die jemand später ändert. */
+ CREATE TABLE IF NOT EXISTS unterstuetzung_striche (
+ weg_id INTEGER NOT NULL REFERENCES unterstuetzung_wege(id) ON DELETE CASCADE,
+ tag TEXT NOT NULL,
+ anzahl INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (weg_id, tag)
+ );
+ CREATE INDEX IF NOT EXISTS idx_unterstuetzung_tag
+ ON unterstuetzung_striche(tag);
+ `);
+
+ /* INSERT OR IGNORE über den eindeutigen Schlüssel: Beim ersten Start
+ entstehen die fünf Wege, bei jedem weiteren passiert nichts. Wer
+ einen löscht, findet ihn nicht beim nächsten Neustart wieder — das
+ wäre eine Änderung, die sich von selbst zurücknimmt, und so etwas
+ sucht man stundenlang. */
+ const setz = d.prepare(`INSERT OR IGNORE INTO unterstuetzung_wege
+ (schluessel, art, titel, text, knopf, ziel, sichtbar, platz)
+ VALUES (?,?,?,?,?,?,?,?)`);
+ for (const w of START_WEGE) {
+ setz.run(w.schluessel, w.art, w.titel, w.text, w.knopf, w.ziel, w.sichtbar, w.platz);
+ }
+}
diff --git a/server/workspace-aufbewahrung.js b/server/workspace-aufbewahrung.js
index bfea3e5a..6406c01b 100644
--- a/server/workspace-aufbewahrung.js
+++ b/server/workspace-aufbewahrung.js
@@ -43,6 +43,10 @@ import express from "express";
zwei Wahrheiten, von denen eine veraltet. `hilfe-tabellen.js`
importiert absichtlich nichts, es kann also kein Kreis entstehen. */
import { AUFBEWAHRUNG_TAGE as HILFE_TAGE, FALL_STAND } from "./hilfe-tabellen.js";
+import { AUFBEWAHRUNG_TAGE as SUPPORT_TAGE } from "./support-tabellen.js";
+import { supportAufraeumen } from "./workspace-support.js";
+import { ZAEHLUNG_TAGE } from "./unterstuetzung-tabellen.js";
+import { unterstuetzungAufraeumen } from "./workspace-unterstuetzung.js";
import {
db, sitzungLesen, istDogFather, protokolliere, echteIp,
einstellung, einstellungSetzen,
@@ -262,6 +266,83 @@ export const FRISTEN = [
offen: (d) => zaehle(d,
"SELECT COUNT(*) AS n FROM aufgaben WHERE erledigt_am IS NOT NULL"),
},
+ /* ==== ZWEI EINTRÄGE, DIE HIER GEFEHLT HABEN (24.09.2026) ========
+
+ Beim Einbau des Unterstützen-Bereichs ist aufgefallen, dass
+ `supportAufraeumen()` in workspace-support.js zwar EXPORTIERT,
+ aber nirgends AUFGERUFEN wird — gesucht im ganzen Verzeichnis,
+ null Treffer. Die Meldungen samt Bildschirmfotos wären also für
+ immer liegen geblieben, obwohl im Kopf der Datei "90 Tage" steht.
+
+ Das ist dieselbe Sorte Fehler wie eine Prüfung, die nie läuft:
+ Es sieht fertig aus, es ist dokumentiert, und es passiert nichts.
+ Diese Liste ist die einzige Stelle im Haus, die wirklich räumt —
+ wer eine Frist verspricht und hier keinen Eintrag hat, verspricht
+ nichts. */
+ {
+ schluessel: "support_erledigt",
+ was: "Erledigte Support-Meldungen samt Bildschirmfoto",
+ art: "raeumen",
+ frist: `${SUPPORT_TAGE} Tage nach dem Erledigen`,
+ zweck: "Ein Fehler kommt oft wieder – dann hilft es, nachlesen zu können, "
+ + "was beim letzten Mal daran war.",
+ grundlage: "Art. 5 Abs. 1 lit. e DSGVO – Speicherbegrenzung",
+ wirkung: `Die Meldung verschwindet ganz, mitsamt dem Bild auf der Platte. `
+ + `Offene Meldungen werden nie geräumt – nur erledigte, und erst `
+ + `${SUPPORT_TAGE} Tage danach.`,
+ offen: (d) => zaehle(d,
+ "SELECT COUNT(*) AS n FROM support_meldungen WHERE stand = 'erledigt'"
+ + " AND erledigt_am IS NOT NULL AND erledigt_am < ?", vorTagen(SUPPORT_TAGE)),
+ /* ÜBER supportAufraeumen() UND NICHT ÜBER EIN DELETE HIER: Zu
+ jeder Meldung gehört eine Bilddatei auf der Platte. Ein DELETE
+ an dieser Stelle würde die Zeile löschen und das Bild liegen
+ lassen — und ein Bild ohne Zeile findet danach niemand mehr. */
+ raeumen: () => supportAufraeumen(),
+ },
+ {
+ schluessel: "unterstuetzung_striche",
+ was: "Tagesstriche der Unterstützen-Seite",
+ art: "raeumen",
+ frist: `${ZAEHLUNG_TAGE} Tage`,
+ zweck: "Sehen, ob ein Weg überhaupt benutzt wird – und ob ein neuer anläuft.",
+ grundlage: "Art. 6 Abs. 1 lit. f DSGVO – berechtigtes Interesse",
+ wirkung: "Die Zeile verschwindet. Es steht ohnehin keine Person darin: "
+ + "Die Tabelle hat gar keine Spalte dafür (siehe unterstuetzung-tabellen.js) – "
+ + "gezählt wird je Weg und Tag, nicht je Mensch.",
+ offen: (d) => zaehle(d,
+ "SELECT COUNT(*) AS n FROM unterstuetzung_striche WHERE tag < ?",
+ vorTagen(ZAEHLUNG_TAGE).slice(0, 10)),
+ raeumen: () => unterstuetzungAufraeumen(),
+ },
+ {
+ /* AUFGABEN-ZUTEILUNG (nachgetragen 24.09.2026).
+
+ Die Tabelle gibt es seit dem 21.09., im Löschkonzept stand sie
+ nicht -- gefunden von pruef-aufbewahrung, die jede Tabelle mit
+ einer "erledigt_am"-Spalte hier einfordert. Die Prüfung meldete
+ sie zusammen mit support_meldungen, und beide waren echte
+ Lücken: Für jede Tabelle muss JEMAND entschieden haben, ob sie
+ verschwindet oder bleibt. "Noch nicht bedacht" ist keine der
+ beiden Antworten.
+
+ DIE ANTWORT HIER IST "BLEIBT", und sie ist begründet: Wer welche
+ Aufgabe übernommen, abgelehnt oder erledigt hat, ist der
+ Werdegang eines Menschen im Team -- dieselbe Überlegung wie bei
+ den Aufgaben selbst eine Zeile darüber. Sie verschwindet mit
+ ihrer Aufgabe (ON DELETE CASCADE) und mit der Person
+ (ON DELETE CASCADE), aber nicht nach einer Frist. */
+ schluessel: "aufgaben_zuteilung",
+ was: "Wer eine Aufgabe übernommen hat",
+ art: "bleibt",
+ frist: "gehen mit der Aufgabe und mit der Person",
+ zweck: "Nachvollziehen, wer was übernommen hat – und was daraus wurde.",
+ grundlage: "Art. 6 Abs. 1 lit. f DSGVO – berechtigtes Interesse",
+ wirkung: "Bleiben. Wird die Aufgabe gelöscht, gehen sie mit (ON DELETE CASCADE); "
+ + "wird der Zugang gelöscht, ebenso. Eine eigene Frist gibt es nicht – "
+ + "eine Zuteilung ohne ihre Aufgabe wäre ohnehin nicht mehr lesbar.",
+ offen: (d) => zaehle(d,
+ "SELECT COUNT(*) AS n FROM aufgaben_zuteilung WHERE erledigt_am IS NOT NULL"),
+ },
{
schluessel: "personen_geloescht",
was: "Daten einer gelöschten Person",
diff --git a/server/workspace-unterstuetzung.js b/server/workspace-unterstuetzung.js
new file mode 100644
index 00000000..ae10f72b
--- /dev/null
+++ b/server/workspace-unterstuetzung.js
@@ -0,0 +1,366 @@
+/* =====================================================================
+ UNTERSTÜTZEN — die Wege (24.09.2026)
+
+ Filipe: „jeder soll diese kachel sehen aber nur dogfather soll sie
+ verändern können oder vieles mehr sehen. alle anderen, rechte und
+ linke hand, modis und community sollen supporten können."
+
+ Warum die Wege in der Datenbank stehen und warum die Zählung keine
+ Personenspalte hat, steht im Kopf von unterstuetzung-tabellen.js.
+ Hier sind die Wege — und die vier Stellen, an denen etwas
+ schiefgehen kann:
+
+ 1. EINE ADRESSE IST EIN VERSPRECHEN. Auf dieser Seite steht ein
+ Zahlungslink. Wer ihn ändern darf, kann Geld umleiten — deshalb
+ hängt das Schreiben an `istDogFather` und nicht an `istLeitung`.
+ Die rechte Hand führt dieses Team mit, und sie soll trotzdem
+ nicht an diesen Link können. Das ist kein Misstrauen, sondern
+ dieselbe Regel wie bei der Rechtetafel: Genau eine Tür bleibt
+ bei ihm.
+
+ 2. UND SIE WIRD GEPRÜFT, BEVOR SIE IN DER DATENBANK STEHT. Erlaubt
+ sind `https://` und eine Seite dieses Hauses. `javascript:` wäre
+ ein Skript, das jeder Besucher ausführt, `http://` ein
+ Zahlungsweg ohne Verschlüsselung, `data:` eine ganze Seite im
+ Link. Geprüft wird beim SCHREIBEN: Was erst beim Anzeigen
+ auffällt, steht vorher schon in der Datenbank.
+
+ 3. DER LESEWEG IST FÜR ALLE UND ZEIGT NICHT ALLES. Unsichtbare Wege
+ und die Zählung gehen nur an DogFather. Beides entscheidet EINE
+ Stelle (`darfAlles`), damit nicht zwei Antworten auf dieselbe
+ Frage entstehen.
+
+ 4. EIN STRICH IST KEIN PROTOKOLL. Gezählt wird je Weg und Tag, ohne
+ Person. Dass derselbe Mensch nicht zehnmal zählt, merkt sich der
+ Server NUR im Arbeitsspeicher — nach einem Neustart ist es
+ vergessen, und auf der Platte steht es nie.
+ ===================================================================== */
+
+import express from "express";
+import {
+ db, protokolliere, echteIp, istDogFather, heuteLokal,
+} from "./workspace.js";
+import { UNTERSTUETZUNG_ARTEN, ZAEHLUNG_TAGE } from "./unterstuetzung-tabellen.js";
+
+export const unterstuetzungRouter = express.Router();
+
+const TITEL_MAX = 60;
+const TEXT_MAX = 400;
+const KNOPF_MAX = 40;
+const ZIEL_MAX = 500;
+const WEGE_MAX = 12;
+
+/** Wer ändern darf UND wer mehr sieht — dieselbe Frage, eine Antwort.
+ *
+ * Zwei getrennte Prüfungen wären zwei Gelegenheiten, eine davon zu
+ * lockern. Und die beiden gehören ohnehin zusammen: Wer einen Weg
+ * bearbeitet, muss ihn auch sehen, solange er noch unsichtbar ist. */
+const darfAlles = (person) => istDogFather(person);
+
+/* Gleiche Herkunft — dasselbe Muster wie überall im Haus. Ohne
+ Origin-Kopf (Server zu Server, Prüfungen) geht es durch; mit einem
+ fremden nicht. */
+function gleicheHerkunft(req, res, next) {
+ const herkunft = req.get("origin");
+ if (!herkunft) return next();
+ try {
+ if (new URL(herkunft).host === req.get("host")) return next();
+ } catch { /* unlesbar -> abweisen */ }
+ return res.status(403).json({ fehler: "fremde_herkunft" });
+}
+
+/** Nur DogFather. 404 und nicht 403: Wer nicht ändern darf, muss nicht
+ * erfahren, dass es die Möglichkeit gibt — dasselbe Vorgehen wie bei
+ * der Aufbewahrung und den Kanälen. */
+function nurDogFather(req, res, next) {
+ if (!darfAlles(req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
+ next();
+}
+
+/* =====================================================================
+ DIE ADRESSE
+ =====================================================================
+
+ Drei Formen sind erlaubt, alles andere nicht:
+
+ "" kein Ziel (ein Weg ohne Knopf)
+ https://… nach draußen, verschlüsselt
+ seite.html[?frage] eine Seite dieses Hauses
+
+ AUSDRÜCKLICH NICHT: `javascript:` (ein Skript, das jeder Besucher
+ ausführt), `data:` (eine ganze Seite im Link), `http://` (ein
+ Zahlungsweg im Klartext) und alles mit `//` am Anfang (das übernimmt
+ das Schema der Seite und sieht aus wie ein eigener Pfad).
+
+ Geprüft wird mit `new URL()` und nicht mit einem Muster: Ein
+ selbstgeschriebener Ausdruck übersieht `HtTpS:` oder ein
+ eingeschobenes Leerzeichen, der Parser des Browsers nicht. */
+const HAUS_SEITE = /^[a-z0-9][a-z0-9-]{0,40}\.html(\?[A-Za-z0-9=&_%.-]{0,80})?$/;
+
+function zielPruefen(roh) {
+ const z = String(roh ?? "").trim();
+ if (!z) return { ok: true, wert: "" };
+ if (z.length > ZIEL_MAX) return { ok: false, grund: "Die Adresse ist zu lang." };
+ if (HAUS_SEITE.test(z)) return { ok: true, wert: z };
+ let u;
+ try { u = new URL(z); } catch {
+ return { ok: false, grund: "Das ist keine gültige Adresse. Sie muss mit https:// anfangen." };
+ }
+ if (u.protocol !== "https:") {
+ return { ok: false, grund: "Nur https:// – ein Zahlungsweg ohne Verschlüsselung kommt hier nicht hin." };
+ }
+ return { ok: true, wert: u.href };
+}
+
+const text = (roh, max) => String(roh ?? "").replace(/\s+/g, " ").trim().slice(0, max);
+
+/* =====================================================================
+ LESEN — für alle
+ ===================================================================== */
+
+unterstuetzungRouter.get("/workspace/api/unterstuetzung", (req, res) => {
+ try {
+ const alles = darfAlles(req.person);
+ const d = db();
+ const wege = d.prepare(
+ `SELECT id, schluessel, art, titel, text, knopf, ziel, sichtbar, platz
+ FROM unterstuetzung_wege
+ ${alles ? "" : "WHERE sichtbar = 1"}
+ ORDER BY platz, id`).all();
+
+ /* WAS DOGFATHER MEHR SIEHT (und sonst niemand):
+ die unsichtbaren Wege — und wie oft geöffnet wurde.
+
+ DREI ZEITRÄUME, weil eine einzelne Zahl nichts sagt: „48" kann
+ „läuft gut" oder „lief mal" heißen. Mit 7 und 30 Tagen daneben
+ sieht man den Unterschied. */
+ let zahlen = null;
+ if (alles) {
+ const tagVor = (n) => {
+ const t = new Date(); t.setHours(12, 0, 0, 0); t.setDate(t.getDate() - n);
+ return `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, "0")}`
+ + `-${String(t.getDate()).padStart(2, "0")}`;
+ };
+ const roh = d.prepare(
+ `SELECT weg_id,
+ SUM(anzahl) AS gesamt,
+ SUM(CASE WHEN tag >= ? THEN anzahl ELSE 0 END) AS tage30,
+ SUM(CASE WHEN tag >= ? THEN anzahl ELSE 0 END) AS tage7
+ FROM unterstuetzung_striche
+ GROUP BY weg_id`).all(tagVor(29), tagVor(6));
+ zahlen = Object.fromEntries(roh.map((z) => [z.weg_id, {
+ gesamt: z.gesamt || 0, tage30: z.tage30 || 0, tage7: z.tage7 || 0,
+ }]));
+ }
+
+ res.json({
+ wege: wege.map((w) => ({ ...w, sichtbar: !!w.sichtbar })),
+ arten: UNTERSTUETZUNG_ARTEN,
+ darf_aendern: alles,
+ zahlen,
+ });
+ } catch (fehler) {
+ console.error("[unterstuetzung] Lesen:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/* =====================================================================
+ EINEN STRICH MACHEN — für alle
+ =====================================================================
+
+ Wer einen Weg öffnet, macht einen Strich. Kein Name, keine Nummer,
+ keine Kennung: nur Weg und Tag.
+
+ DIE SPERRE GEGEN MEHRFACHZÄHLUNG LIEGT IM ARBEITSSPEICHER. Sie
+ müsste sonst eine Kennung je Mensch auf die Platte schreiben — und
+ damit wäre die Zusage dieser Seite gebrochen, für eine Zahl, die nur
+ ungefähr stimmen muss. Nach einem Neustart zählt jeder wieder einmal;
+ das ist der Preis, und er ist richtig herum bezahlt. */
+const heuteSchon = new Map(); // "sitzung|weg" -> Tag
+unterstuetzungRouter.post("/workspace/api/unterstuetzung/:id(\\d+)/strich",
+ gleicheHerkunft, (req, res) => {
+ try {
+ const id = Number(req.params.id);
+ const weg = db().prepare(
+ "SELECT id, sichtbar FROM unterstuetzung_wege WHERE id = ?").get(id);
+ /* Ein unsichtbarer Weg wird nicht gezählt. Sonst stünde eine Zahl
+ an einem Weg, den außer DogFather niemand sehen kann — und die
+ käme dann von ihm selbst. */
+ if (!weg || (!weg.sichtbar && !darfAlles(req.person))) {
+ return res.status(404).json({ fehler: "nicht_gefunden" });
+ }
+
+ const tag = heuteLokal();
+ const schluessel = `${req.person?.id ?? "?"}|${id}`;
+ if (heuteSchon.get(schluessel) === tag) return res.json({ gezaehlt: false });
+ heuteSchon.set(schluessel, tag);
+ /* Der Speicher wächst sonst mit jedem Menschen und jedem Weg. Bei
+ mehr als tausend Einträgen fängt er von vorn an — eine Zahl,
+ die nur ungefähr stimmen muss, verträgt das. */
+ if (heuteSchon.size > 1000) heuteSchon.clear();
+
+ db().prepare(
+ `INSERT INTO unterstuetzung_striche (weg_id, tag, anzahl) VALUES (?,?,1)
+ ON CONFLICT(weg_id, tag) DO UPDATE SET anzahl = anzahl + 1`).run(id, tag);
+ res.json({ gezaehlt: true });
+ } catch (fehler) {
+ console.error("[unterstuetzung] Strich:", fehler?.message);
+ /* Ein Zähler darf den Weg nach draußen nie blockieren: Der Browser
+ öffnet den Link ohnehin, egal was hier steht. */
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+/* =====================================================================
+ ÄNDERN — nur DogFather
+ ===================================================================== */
+
+unterstuetzungRouter.put("/workspace/api/unterstuetzung/:id(\\d+)",
+ gleicheHerkunft, nurDogFather, express.json({ limit: "8kb" }), (req, res) => {
+ try {
+ const id = Number(req.params.id);
+ const alt = db().prepare("SELECT * FROM unterstuetzung_wege WHERE id = ?").get(id);
+ if (!alt) return res.status(404).json({ fehler: "nicht_gefunden" });
+
+ const titel = text(req.body?.titel ?? alt.titel, TITEL_MAX);
+ if (titel.length < 2) return res.status(400).json({ fehler: "Der Weg braucht einen Namen." });
+ const art = String(req.body?.art ?? alt.art);
+ if (!UNTERSTUETZUNG_ARTEN[art]) return res.status(400).json({ fehler: "Diese Art gibt es nicht." });
+
+ const z = zielPruefen(req.body?.ziel ?? alt.ziel);
+ if (!z.ok) return res.status(400).json({ fehler: z.grund });
+
+ const knopf = text(req.body?.knopf ?? alt.knopf, KNOPF_MAX);
+ /* EIN KNOPF OHNE ZIEL IST EIN VERSPRECHEN INS LEERE. Er sieht aus
+ wie ein Weg und tut nichts — und weil nichts passiert, hält man
+ die Seite für kaputt, nicht die Eingabe für unvollständig. */
+ if (knopf && !z.wert) {
+ return res.status(400).json({
+ fehler: "Ein Knopf braucht eine Adresse – sonst passiert beim Drücken nichts.",
+ });
+ }
+ /* UND EIN SICHTBARER WEG MIT ZIEL BRAUCHT EINEN KNOPF, sonst
+ steht die Adresse da und niemand kommt hin. */
+ const sichtbar = req.body?.sichtbar === undefined
+ ? !!alt.sichtbar : !!req.body.sichtbar;
+ if (sichtbar && z.wert && !knopf) {
+ return res.status(400).json({
+ fehler: "Zu einer Adresse gehört eine Aufschrift für den Knopf.",
+ });
+ }
+
+ db().prepare(`UPDATE unterstuetzung_wege
+ SET titel = ?, text = ?, knopf = ?, ziel = ?, art = ?, sichtbar = ?,
+ platz = ?, geaendert = ?, geaendert_von = ?
+ WHERE id = ?`)
+ .run(titel, text(req.body?.text ?? alt.text, TEXT_MAX), knopf, z.wert, art,
+ sichtbar ? 1 : 0,
+ Number.isInteger(Number(req.body?.platz)) ? Number(req.body.platz) : alt.platz,
+ new Date().toISOString(), req.person.id, id);
+
+ /* PROTOKOLLIERT WIRD, DASS DIE ADRESSE SICH GEÄNDERT HAT — nicht,
+ wie sie lautet. Ein Zahlungslink gehört nicht in ein Protokoll,
+ das andere lesen können; dass er gewechselt hat, schon. */
+ protokolliere("unterstuetzung_geaendert", {
+ personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
+ detail: `${alt.schluessel}${alt.ziel !== z.wert ? " (Adresse gewechselt)" : ""}`,
+ });
+ res.json({ ok: true });
+ } catch (fehler) {
+ console.error("[unterstuetzung] Aendern:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+unterstuetzungRouter.post("/workspace/api/unterstuetzung",
+ gleicheHerkunft, nurDogFather, express.json({ limit: "8kb" }), (req, res) => {
+ try {
+ const d = db();
+ const { n } = d.prepare("SELECT COUNT(*) AS n FROM unterstuetzung_wege").get();
+ /* EINE OBERGRENZE, und zwar eine niedrige. Zwanzig Wege sind
+ keine Auswahl mehr, sondern eine Wand — und die Seite soll
+ leicht sein. */
+ if (n >= WEGE_MAX) {
+ return res.status(400).json({
+ fehler: `Mehr als ${WEGE_MAX} Wege werden unübersichtlich. Räum erst einen weg.`,
+ });
+ }
+ const titel = text(req.body?.titel, TITEL_MAX);
+ if (titel.length < 2) return res.status(400).json({ fehler: "Der Weg braucht einen Namen." });
+ const art = String(req.body?.art ?? "frei");
+ if (!UNTERSTUETZUNG_ARTEN[art]) return res.status(400).json({ fehler: "Diese Art gibt es nicht." });
+ const z = zielPruefen(req.body?.ziel);
+ if (!z.ok) return res.status(400).json({ fehler: z.grund });
+ const knopf = text(req.body?.knopf, KNOPF_MAX);
+ if (knopf && !z.wert) {
+ return res.status(400).json({
+ fehler: "Ein Knopf braucht eine Adresse – sonst passiert beim Drücken nichts.",
+ });
+ }
+
+ /* Der Schlüssel wird ABGELEITET und nicht getippt. Er hält die
+ Striche der letzten Monate am Weg fest, auch wenn der Titel
+ sich ändert — und ein „eigen-" davor kann nie mit einem der
+ fünf Startschlüssel zusammenfallen. */
+ const rein = titel.toLowerCase()
+ .replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss")
+ .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30) || "weg";
+ let schluessel = "eigen-" + rein;
+ if (d.prepare("SELECT 1 FROM unterstuetzung_wege WHERE schluessel = ?").get(schluessel)) {
+ schluessel += "-" + Date.now().toString(36).slice(-4);
+ }
+ const { platz } = d.prepare(
+ "SELECT COALESCE(MAX(platz), 0) + 10 AS platz FROM unterstuetzung_wege").get();
+
+ d.prepare(`INSERT INTO unterstuetzung_wege
+ (schluessel, art, titel, text, knopf, ziel, sichtbar, platz, geaendert, geaendert_von)
+ VALUES (?,?,?,?,?,?,1,?,?,?)`)
+ .run(schluessel, art, titel, text(req.body?.text, TEXT_MAX), knopf, z.wert,
+ platz, new Date().toISOString(), req.person.id);
+
+ protokolliere("unterstuetzung_angelegt", {
+ personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
+ detail: schluessel,
+ });
+ res.json({ id: d.prepare("SELECT last_insert_rowid() AS id").get().id });
+ } catch (fehler) {
+ console.error("[unterstuetzung] Anlegen:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+unterstuetzungRouter.delete("/workspace/api/unterstuetzung/:id(\\d+)",
+ gleicheHerkunft, nurDogFather, (req, res) => {
+ try {
+ const id = Number(req.params.id);
+ const weg = db().prepare(
+ "SELECT schluessel FROM unterstuetzung_wege WHERE id = ?").get(id);
+ if (!weg) return res.status(404).json({ fehler: "nicht_gefunden" });
+ /* Die Striche gehen mit (ON DELETE CASCADE). Sie ohne ihren Weg
+ stehen zu lassen hieße, eine Zahl aufzubewahren, zu der niemand
+ mehr sagen kann, wozu sie gehört. */
+ db().prepare("DELETE FROM unterstuetzung_wege WHERE id = ?").run(id);
+ protokolliere("unterstuetzung_entfernt", {
+ personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
+ detail: weg.schluessel,
+ });
+ res.json({ ok: true });
+ } catch (fehler) {
+ console.error("[unterstuetzung] Entfernen:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+/** Alte Tagesstriche wegräumen — aus dem Rundgang, nicht beim Lesen.
+ *
+ * Hinge es am Öffnen der Seite, bliebe alles liegen, solange niemand
+ * hinsieht. */
+export function unterstuetzungAufraeumen() {
+ try {
+ const grenze = new Date(Date.now() - ZAEHLUNG_TAGE * 86400000)
+ .toISOString().slice(0, 10);
+ const r = db().prepare("DELETE FROM unterstuetzung_striche WHERE tag < ?").run(grenze);
+ return r.changes || 0;
+ } catch { return 0; }
+}
diff --git a/server/workspace.js b/server/workspace.js
index 16f4c144..072de5e8 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -34,6 +34,7 @@ import { darfSeite, OHNE_ANMELDUNG, seitenFuer } from "./rechte.js";
import { treffTabellen, treffSperreLesen, MINDESTALTER } from "./treff-tabellen.js";
import { hilfeTabellen } from "./hilfe-tabellen.js";
import { supportTabellen } from "./support-tabellen.js";
+import { unterstuetzungTabellen } from "./unterstuetzung-tabellen.js";
import { sitzungPasstZurAdresse, istOhneModiAdresse, istCrewAdresse, istPruefAdresse, AUSSEN_ROLLEN,
TEAM_DOGI_ROLLEN, CREW_ADRESSE } from "./crew-adresse.js";
/* Weitergereicht, damit die Fachmodule sie wie alles andere aus
@@ -1410,6 +1411,31 @@ const TREFF_BEREICHE = [
zeichen: "hinaus", ton: 39, ziel: "unsere-seiten.html", szene: "portal" },
{ ...TREFF_GRUPPE, name: "Mitmachen", unter: "Wie du dazugeh\u00f6ren kannst",
zeichen: "personen", ton: 32, ziel: "bereich.html?b=mitmachen", szene: "garage" },
+ /* UNTERSTÜTZEN (24.09.2026).
+
+ Filipe: "ich brauch auch noch eine kachel im community bereich. wo
+ mein paypal und meine amazon liste sein wird. wo die leute alle
+ supporten können auf andere art anstatt nur tiktok."
+
+ SIE STEHT VOR "REGELN & HILFE" UND NACH "MITMACHEN", und das ist
+ die Reihenfolge des Weges: erst dazugehoeren, dann etwas
+ beitragen. Ganz vorne waere sie eine hohle Hand am Eingang.
+
+ WARUM SIE KEIN BRETT IST: Die Bretter (bereich.html?b=...) sind
+ Listen, auf die jemand schreibt. Hier schreibt niemand — hier
+ stehen Wege, und genau einer darf sie aendern. Deshalb eine eigene
+ Seite, wie bei "Draussen" und "Regeln & Hilfe".
+
+ TON 45 ist GERECHNET, nicht gegriffen: tools/kachel-farbe-
+ einzeln.mjs gegen alle 44 vorhandenen Toene. Die Zahl steht
+ darunter im Kommentar der CSS-Zeile.
+
+ RASTERRECHNUNG (drei Spalten, "Willkommen" belegt zwei): Mit
+ dieser Kachel sind es fuer die Community 11 + 1 = 12 Plaetze,
+ also genau vier volle Reihen. Vorher waren es elf und damit eine
+ Luecke am Ende. */
+ { ...TREFF_GRUPPE, name: "Unterst\u00fctzen", unter: "Dogi etwas zur\u00fcckgeben \u2013 auch ohne Geld",
+ zeichen: "unterstuetzen", ton: 45, ziel: "unterstuetzen.html", szene: "lounge" },
/* UNSERE SEITEN (19.09.2026) -- die einzige Kachel, die HINAUSFUEHRT.
Filipe: "ich brauch im community bereich noch eine kachel, die wenn
@@ -5309,6 +5335,10 @@ export function db() {
kommt deshalb hierher, nicht in den Bauplan darueber. */
hilfeTabellen(d);
supportTabellen(d);
+ /* Die Wege zum Unterstützen (24.09.2026). Sie hängen an personen
+ (geaendert_von) und kommen deshalb hierher, nicht in den Bauplan
+ darüber. */
+ unterstuetzungTabellen(d);
umstellungen(d);
_db = d;
return d;
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index 247e950f..ab44479d 100644
--- a/workspace/anruf-probe.html
+++ b/workspace/anruf-probe.html
@@ -16,15 +16,15 @@
verdeckt. Gefunden hat es pruef-struktur.
Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. -->
-
-
+
+
-
+