diff --git a/server/index.js b/server/index.js
index 2ef1280c..4e38fd10 100644
--- a/server/index.js
+++ b/server/index.js
@@ -37,6 +37,7 @@ import { auskunftRouter } from "./workspace-auskunft.js";
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 { reportRouter } from "./workspace-reports.js";
import { scoutRouter } from "./workspace-scouts.js";
import { callRouter } from "./workspace-calls.js";
@@ -309,6 +310,7 @@ app.use(auskunftRouter);
app.use(uebernahmeRouter);
app.use(befindenRouter);
app.use(hilfeRouter);
+app.use(supportRouter);
app.use(bewerbungRouter);
app.use(reportRouter);
app.use(scoutRouter);
diff --git a/server/pruef-support.mjs b/server/pruef-support.mjs
new file mode 100644
index 00000000..c9f9d013
--- /dev/null
+++ b/server/pruef-support.mjs
@@ -0,0 +1,457 @@
+/* =====================================================================
+ DER SUPPORT (24.09.2026)
+
+ Filipe: „da fehlt auch eine support kachel. die jeder sieht. jeder
+ benutzen kann. jeder kann da probleme von der seite melden und
+ hinweisen. fotos mit schicken mit einem text. … also bei dogfather
+ und der rechten hand soll die kachel anders gebaut sein weil die
+ sind die die sich um die probleme kümmern."
+
+ GEPRUEFT WIRD, WAS ER BESTELLT HAT — und drei Dinge, die er nicht
+ bestellt hat, weil sie sonst niemandem auffallen:
+
+ * JEDE Rolle sieht die Kachel und kommt auf die Seite. Nicht vier
+ von acht -- genau daran ist heute der Benachrichtigungs-Knopf
+ gescheitert.
+ * Eine fremde Meldung bleibt fremd. Wer meldet, sieht seine
+ eigene; alles andere gehoert der Leitung.
+ * Eine als Bild getarnte HTML-Datei kommt nicht herein. Das ist
+ der Standardfehler bei selbstgebauten Uploads und von aussen
+ nicht zu sehen.
+ ===================================================================== */
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-sup-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+
+import { notbremse } from "./helfer-notbremse.mjs";
+const { eigenerPort } = await import("./helfer-port.mjs");
+const PORT = await eigenerPort(import.meta, "die Supportpruefung");
+
+process.env.PORT = `${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 }); };
+await import("./index.js");
+notbremse(240_000, "pruef-support");
+await new Promise((r) => setTimeout(r, 900));
+const BASIS = `http://127.0.0.1:${PORT}`;
+
+let fehler = 0, geprueft = 0;
+const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+const melde = (t) => console.log(t);
+
+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 kennSchluessel = randomBytes(32).toString("hex");
+d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
+ .run("code_kennung_schluessel", kennSchluessel, 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", kennSchluessel).update(code).digest("hex"), jetzt);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+
+/* ALLE ACHT ROLLEN. „Die jeder sieht" heisst jede -- und die Liste
+ steht hier vollstaendig, damit eine fehlende auffaellt. */
+const ROLLEN = [
+ ["admin", "Filipe"], ["hand", "VanVan"], ["linke", "Funny"],
+ ["modi", "Miss"], ["spicy", "Spicy Media"], ["manager", "Patrick"],
+ ["scout", "Kevin"], ["creator", "Luna"], ["gast", "Community-Mitglied"],
+];
+const CODES = {};
+for (const [i, [rolle, name]] of ROLLEN.entries()) {
+ CODES[rolle] = `CODE-S${String(i).padStart(2, "0")}-000001`;
+ anlegen(name, rolle, CODES[rolle]);
+}
+d.close();
+
+/* ---------- Anmelden ohne Browser ------------------------------------
+ Die Sitzung kommt aus einem echten Anmelde-Aufruf; danach reist sie
+ als Keks mit. Ein Browser waere hier Aufwand ohne Gewinn -- geprueft
+ werden Wege, nicht Aussehen. */
+/* DIE COMMUNITY KOMMT NUR AUF EINER ADRESSE HEREIN (gemessen, nicht
+ geraten): `crew.dogfather-universe.com`. Auf 127.0.0.1 antwortet
+ die Anmeldung mit 401 -- beim ersten Lauf stand deshalb
+ "gast: kommt nicht herein", und das sah aus wie ein schwerer
+ Befund am Support. Es war einer an meiner Messung.
+
+ Versucht wird zuerst ohne Host, dann mit -- so faellt auch auf,
+ wenn eine Rolle PLOETZLICH einen braucht. */
+const CREW_HOST = "crew.dogfather-universe.com";
+
+/* ZWEI DINGE, DIE ICH ERST NACH DREI FEHLLAEUFEN GEMESSEN HABE --
+ beide stehen in pruef-treff.mjs, das sie seit dem 15.09. richtig
+ macht:
+
+ 1. `fetch` VERWIRFT DEN Host-KOPF. Er steht auf der Liste der
+ verbotenen Kopfzeilen; Node setzt stattdessen die Adresse des
+ Ziels. Wer eine Hausadresse braucht, muss node:http nehmen.
+ 2. DIE COMMUNITY BESTAETIGT IHR ALTER (`alter_ok`). Ohne das Feld
+ antwortet die Anmeldung mit 401 -- und das sah aus wie ein
+ Zugangsproblem am Support.
+
+ Beim ersten Lauf stand deshalb dreimal rot, und kein einziges Mal
+ lag es am Support. */
+const { request } = await import("node:http");
+function roh(pfad, host, koerper) {
+ return new Promise((fertig, schief) => {
+ const daten = koerper ? JSON.stringify(koerper) : null;
+ const a = request({
+ host: "127.0.0.1", port: PORT, path: pfad,
+ method: daten ? "POST" : "GET",
+ headers: {
+ ...(host ? { Host: host } : {}),
+ Origin: host ? `https://${host}` : BASIS,
+ ...(daten ? { "Content-Type": "application/json",
+ "Content-Length": Buffer.byteLength(daten) } : {}),
+ },
+ }, (antwort) => {
+ let text = "";
+ antwort.on("data", (t) => { text += t; });
+ antwort.on("end", () => fertig({
+ code: antwort.statusCode, kopf: antwort.headers, text,
+ }));
+ });
+ a.on("error", schief);
+ if (daten) a.write(daten);
+ a.end();
+ });
+}
+
+async function anmeldeVersuch(rolle, host) {
+ const r = await roh("/workspace/api/anmelden", host,
+ rolle === "gast"
+ ? { rolle, code: CODES[rolle], alter_ok: true }
+ : { rolle, code: CODES[rolle] });
+ if (r.code !== 200) return null;
+ const keks = [].concat(r.kopf["set-cookie"] || [])
+ .map((z) => z.split(";")[0]).join("; ");
+ return keks || null;
+}
+const HOST_FUER = {};
+async function anmelden(rolle) {
+ const ohne = await anmeldeVersuch(rolle, null);
+ if (ohne) { HOST_FUER[rolle] = null; return ohne; }
+ const mit = await anmeldeVersuch(rolle, CREW_HOST);
+ if (mit) { HOST_FUER[rolle] = CREW_HOST; return mit; }
+ return null;
+}
+
+/** Ein winziges, gueltiges PNG (1x1, schwarz) -- echte Kopfbytes,
+ * damit die Erkennung wirklich etwas zu erkennen hat. */
+const PNG = Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk"
+ + "YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64");
+
+const kekse = {};
+melde("\n=== Alle neun Rollen kommen herein ===");
+for (const [rolle] of ROLLEN) {
+ kekse[rolle] = await anmelden(rolle);
+ ok(!!kekse[rolle], `${rolle}: angemeldet`);
+}
+
+/* Fuer Rollen OHNE Hausadresse genuegt fetch -- es ist kuerzer und
+ kann Buffer als Rumpf. Wer eine Adresse braucht, geht ueber
+ node:http (siehe oben, `fetch` verwirft den Host-Kopf). */
+async function hol(rolle, pfad, einst = {}) {
+ if (HOST_FUER[rolle]) {
+ const r = await rohMitKeks(rolle, pfad, einst);
+ return r;
+ }
+ return fetch(BASIS + pfad, {
+ ...einst,
+ headers: { Cookie: kekse[rolle] || "", Origin: BASIS, ...(einst.headers || {}) },
+ });
+}
+
+/** Derselbe Aufruf ueber node:http -- mit Hausadresse und Sitzung.
+ * Gibt etwas zurueck, das sich wie eine fetch-Antwort benutzen
+ * laesst: `status`, `ok`, `json()`, `headers.get()`. */
+function rohMitKeks(rolle, pfad, einst = {}) {
+ return new Promise((fertig, schief) => {
+ const rumpf = einst.body === undefined ? null
+ : (Buffer.isBuffer(einst.body) ? einst.body : Buffer.from(String(einst.body)));
+ const a = request({
+ host: "127.0.0.1", port: PORT, path: pfad,
+ method: einst.method || "GET",
+ headers: {
+ Host: HOST_FUER[rolle],
+ Origin: `https://${HOST_FUER[rolle]}`,
+ Cookie: kekse[rolle] || "",
+ ...(rumpf ? { "Content-Length": rumpf.length } : {}),
+ ...(einst.headers || {}),
+ },
+ }, (antwort) => {
+ const teile = [];
+ antwort.on("data", (t) => teile.push(t));
+ antwort.on("end", () => {
+ const text = Buffer.concat(teile).toString("utf8");
+ fertig({
+ status: antwort.statusCode,
+ ok: antwort.statusCode >= 200 && antwort.statusCode < 300,
+ headers: { get: (n) => antwort.headers[String(n).toLowerCase()] || null },
+ json: async () => JSON.parse(text),
+ text: async () => text,
+ });
+ });
+ });
+ a.on("error", schief);
+ if (rumpf) a.write(rumpf);
+ a.end();
+ });
+}
+
+/* =====================================================================
+ 1. DIE KACHEL SIEHT JEDER
+ ===================================================================== */
+melde("\n=== Die Kachel steht bei jeder Rolle ===");
+{
+ const ohne = [];
+ for (const [rolle] of ROLLEN) {
+ /* DIE ROUTE HEISST /api/ich, nicht /api/start. Beim ersten Lauf
+ stand hier ein erfundener Name, und alle neun Rollen meldeten
+ "Kachel fehlt (HTTP 404)" -- ein Messfehler, der wie ein
+ Totalausfall aussah. */
+ const a = await hol(rolle, "/workspace/api/ich");
+ if (!a.ok) { ohne.push(`${rolle} (HTTP ${a.status})`); continue; }
+ const r = await a.json();
+ /* `bereiche` ist null, wenn die Oberflaeche ihre eigene Liste
+ nimmt -- dann steht die Kachel in assets/js/bereiche.js und
+ wird eine Pruefung weiter unten geprueft. */
+ if (r.bereiche === null) { ohne.push(`${rolle} (eigene Liste im Browser)`); continue; }
+ const da = (r.bereiche || []).some((k) => k?.ziel === "support.html");
+ if (!da) ohne.push(rolle);
+ }
+ /* DIE AGENTUR-ROLLEN bekommen `bereiche: null` und ihre Liste aus
+ dem Browser. Das ist kein Mangel, aber es muss genannt werden --
+ sonst sieht "alle haben sie" gruen aus, obwohl vier gar nicht
+ geprueft wurden. */
+ const ausBrowser = ohne.filter((x) => x.includes("eigene Liste"));
+ const echtOhne = ohne.filter((x) => !x.includes("eigene Liste"));
+ ok(echtOhne.length === 0,
+ "jede Rolle mit Server-Liste hat die Support-Kachel"
+ + (echtOhne.length ? ` -- FEHLT bei: ${echtOhne.join(", ")}` : ""));
+ melde(` (${ausBrowser.length} Rollen nehmen ihre Liste aus dem Browser)`);
+
+ /* UND DIE BROWSER-LISTE AUCH. Ohne diese Zeile waere die Haelfte
+ der Rollen ungeprueft -- und genau dort hat heute schon einmal
+ etwas gefehlt. */
+ const { readFileSync } = await import("node:fs");
+ const { fileURLToPath } = await import("node:url");
+ const bereicheJs = readFileSync(fileURLToPath(
+ new URL("../workspace/assets/js/bereiche.js", import.meta.url)), "utf8");
+ ok(/ziel:\s*'support\.html'/.test(bereicheJs),
+ "auch die Kachelliste im Browser kennt den Support");
+}
+
+/* =====================================================================
+ 2. AUF DIE SEITE DARF JEDER
+ ===================================================================== */
+melde("\n=== Die Seite ist fuer jede Rolle offen ===");
+{
+ const zu = [];
+ for (const [rolle] of ROLLEN) {
+ const a = await hol(rolle, "/workspace/support.html", { redirect: "manual" });
+ if (a.status !== 200) zu.push(`${rolle}=${a.status}`);
+ }
+ ok(zu.length === 0,
+ "support.html antwortet jeder Rolle mit 200"
+ + (zu.length ? ` -- nicht bei: ${zu.join(", ")}` : ""));
+}
+
+/* =====================================================================
+ 3. MELDEN — mit Text, mit Bild, und was nicht geht
+ ===================================================================== */
+melde("\n=== Melden ===");
+let meldungMitBild = 0;
+{
+ /* Ohne Text geht nichts -- sonst stehen leere Zeilen im Eingang. */
+ const leer = await hol("creator", "/workspace/api/support", {
+ method: "POST", headers: { "x-text": encodeURIComponent("hm") },
+ });
+ ok(leer.status === 400, `ein Wort reicht nicht (${leer.status})`);
+
+ const nurText = await hol("creator", "/workspace/api/support", {
+ method: "POST",
+ headers: { "x-text": encodeURIComponent("Der Haken auf der Aufgabenseite geht nicht.") },
+ });
+ ok(nurText.status === 201, `Text allein genuegt (${nurText.status})`);
+
+ const mitBild = await hol("modi", "/workspace/api/support", {
+ method: "POST",
+ headers: {
+ "x-text": encodeURIComponent("Hier stimmt die Farbe nicht, siehe Bild."),
+ "x-seite": "/workspace/chat.html",
+ "Content-Type": "application/octet-stream",
+ },
+ body: PNG,
+ });
+ ok(mitBild.status === 201, `mit Bild geht auch (${mitBild.status})`);
+ if (mitBild.status === 201) {
+ const j = await mitBild.json();
+ meldungMitBild = j.meldung?.id || 0;
+ ok(!!j.meldung?.bild, "und die Antwort nennt die Adresse des Bildes");
+ } else { ok(false, "kein Bild in der Antwort"); }
+
+ /* DER STANDARDFEHLER BEI UPLOADS: eine HTML-Datei mit Bildnamen.
+ Sie liefe im Browser als Seite DIESER Domain, mit Zugriff auf die
+ Sitzung des Empfaengers. */
+ const getarnt = await hol("scout", "/workspace/api/support", {
+ method: "POST",
+ headers: {
+ "x-text": encodeURIComponent("Test mit einer getarnten Datei."),
+ "Content-Type": "image/png",
+ },
+ body: Buffer.from(""),
+ });
+ ok(getarnt.status === 415,
+ `als Bild getarntes HTML wird abgelehnt (${getarnt.status})`);
+
+ /* Und ein PDF ebenfalls -- dateiErkennen kennt es, der Support
+ will es nicht. */
+ const pdf = await hol("scout", "/workspace/api/support", {
+ method: "POST",
+ headers: {
+ "x-text": encodeURIComponent("Test mit einem PDF im Anhang."),
+ "Content-Type": "application/pdf",
+ },
+ body: Buffer.concat([Buffer.from("%PDF-1.4\n"), Buffer.alloc(64, 0x20)]),
+ });
+ ok(pdf.status === 415, `ein PDF ist kein Foto (${pdf.status})`);
+}
+
+/* =====================================================================
+ 4. WER SIEHT WAS
+ ===================================================================== */
+melde("\n=== Wer sieht was ===");
+{
+ const alsCreator = await (await hol("creator", "/workspace/api/support")).json();
+ ok(alsCreator.leitung === false, "ein Creator ist nicht die Leitung");
+ ok(alsCreator.meldungen.length === 1,
+ `und sieht genau seine eigene (${alsCreator.meldungen.length})`);
+
+ const alsModi = await (await hol("modi", "/workspace/api/support")).json();
+ ok(alsModi.meldungen.length === 1 && alsModi.meldungen[0].id === meldungMitBild,
+ "ein Modi sieht seine eigene und nicht die des Creators");
+
+ for (const rolle of ["admin", "hand"]) {
+ const r = await (await hol(rolle, "/workspace/api/support")).json();
+ ok(r.leitung === true && r.meldungen.length >= 2,
+ `${rolle} sieht alle (${r.meldungen.length})`);
+ }
+ for (const rolle of ["linke", "modi", "spicy", "manager", "scout", "creator", "gast"]) {
+ const r = await (await hol(rolle, "/workspace/api/support")).json();
+ ok(r.leitung !== true, `${rolle} ist NICHT die Leitung`);
+ }
+
+ /* DAS BILD EINER FREMDEN MELDUNG bleibt fremd. */
+ const fremd = await hol("creator", `/workspace/api/support/${meldungMitBild}/bild`);
+ ok(fremd.status === 404,
+ `das Bild einer fremden Meldung gibt es fuer andere nicht (${fremd.status})`);
+
+ const eigen = await hol("modi", `/workspace/api/support/${meldungMitBild}/bild`);
+ ok(eigen.status === 200, `der Melder sieht sein eigenes Bild (${eigen.status})`);
+ ok(eigen.headers.get("content-type") === "image/png",
+ `ausgeliefert wird der ERKANNTE Typ (${eigen.headers.get("content-type")})`);
+ ok((eigen.headers.get("x-content-type-options") || "") === "nosniff",
+ "mit nosniff");
+ ok(/sandbox/.test(eigen.headers.get("content-security-policy") || ""),
+ "und einer Richtlinie, die jede Ausfuehrung nimmt");
+
+ const leitungSieht = await hol("hand", `/workspace/api/support/${meldungMitBild}/bild`);
+ ok(leitungSieht.status === 200, "die Leitung sieht es auch");
+}
+
+/* =====================================================================
+ 5. SICH KUEMMERN
+ ===================================================================== */
+melde("\n=== Sich kuemmern ===");
+{
+ /* Wer nicht Leitung ist, kann nichts aendern -- 404 und nicht 403:
+ Wer keine Zustaendigkeit hat, soll nicht erfahren, dass es sie
+ gibt. */
+ const versuch = await hol("creator", `/workspace/api/support/${meldungMitBild}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ stand: "erledigt", antwort: "einfach mal zu" }),
+ });
+ ok(versuch.status === 404, `ein Creator kann nichts erledigen (${versuch.status})`);
+
+ const dran = await hol("hand", `/workspace/api/support/${meldungMitBild}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ stand: "in_arbeit" }),
+ });
+ ok(dran.status === 200, `die rechte Hand uebernimmt (${dran.status})`);
+
+ /* ZUMACHEN OHNE SATZ GEHT NICHT. Der Melder sieht nur diesen Satz. */
+ const ohneSatz = await hol("hand", `/workspace/api/support/${meldungMitBild}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ stand: "erledigt" }),
+ });
+ ok(ohneSatz.status === 400, `erledigen ohne Satz wird abgelehnt (${ohneSatz.status})`);
+
+ const zu = await hol("hand", `/workspace/api/support/${meldungMitBild}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ stand: "erledigt", antwort: "Behoben, die Farbe stimmt wieder." }),
+ });
+ ok(zu.status === 200, `mit Satz geht es (${zu.status})`);
+
+ /* ERLEDIGT IST ERLEDIGT -- auch fuer die Leitung. */
+ const nochmal = await hol("admin", `/workspace/api/support/${meldungMitBild}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ stand: "in_arbeit", antwort: "doch nicht" }),
+ });
+ ok(nochmal.status === 409,
+ `eine erledigte Meldung wird nicht wieder geoeffnet (${nochmal.status})`);
+
+ /* Und der Melder sieht die Antwort. */
+ const beimMelder = await (await hol("modi", "/workspace/api/support")).json();
+ const meine = beimMelder.meldungen.find((m) => m.id === meldungMitBild);
+ ok(meine?.stand === "erledigt" && /Behoben/.test(meine?.antwort || ""),
+ "der Melder sieht Stand und Antwort");
+}
+
+/* =====================================================================
+ 6. GEGENPROBEN — kann diese Pruefung ueberhaupt rot werden?
+ ===================================================================== */
+melde("\n=== Gegenproben (jede MUSS anschlagen) ===");
+{
+ /* Eine Meldung, die es nicht gibt. */
+ const nichts = await hol("admin", "/workspace/api/support/999999");
+ ok(nichts.status === 404, "eine erfundene Nummer gibt es nicht");
+
+ /* Ohne Anmeldung kommt niemand an die Liste. */
+ const ohne = await fetch(BASIS + "/workspace/api/support");
+ ok(ohne.status === 401 || ohne.status === 302 || ohne.status === 404,
+ `ohne Anmeldung keine Liste (${ohne.status})`);
+
+ /* Und ohne Anmeldung auch nicht an ein Bild. */
+ const ohneBild = await fetch(BASIS + `/workspace/api/support/${meldungMitBild}/bild`);
+ ok(ohneBild.status !== 200, `ohne Anmeldung kein Bild (${ohneBild.status})`);
+}
+
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+
+console.log("");
+console.log(`${geprueft} Pruefungen, ${fehler} Fehler`);
+console.log(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
+process.exit(fehler ? 1 : 0);
diff --git a/server/rechte.js b/server/rechte.js
index ff66ece8..abb65316 100644
--- a/server/rechte.js
+++ b/server/rechte.js
@@ -455,6 +455,23 @@ export const SEITEN = {
eigenen Faelle, die Leitung alle. */
"/workspace/hilfe.html": ["gast", "hand", "admin"],
+ /* DER SUPPORT: WIRKLICH JEDER (24.09.2026).
+
+ Filipe: „da fehlt auch eine support kachel. die jeder sieht.
+ jeder benutzen kann."
+
+ Das ist der Unterschied zum vertraulichen Meldeweg eine Zeile
+ darueber: Der gilt nur fuer gast, hand und admin -- Modis,
+ Creator, Scouts, Manager und die linke Hand hatten gar keinen
+ Weg, einen kaputten Knopf zu melden.
+
+ ALLE ROLLEN AUSGESCHRIEBEN und nicht „* " oder ein Weglassen:
+ Diese Tafel ist die Stelle, an der man nachsieht, wer wohin darf.
+ Eine Abkuerzung hier hiesse, dass der Support in der Uebersicht
+ „Wer sieht was" als Luecke erscheint. */
+ "/workspace/support.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/support-tabellen.js b/server/support-tabellen.js
new file mode 100644
index 00000000..98ac8590
--- /dev/null
+++ b/server/support-tabellen.js
@@ -0,0 +1,127 @@
+/* =====================================================================
+ DER SUPPORT — Tabellen (24.09.2026)
+
+ Filipe: „da fehlt auch eine support kachel. die jeder sieht. jeder
+ benutzen kann. jeder kann da probleme von der seite melden und
+ hinweisen. fotos mit schicken mit einem text. … es geht drum dass
+ die leute einfach ein text mit einem foto schicken und dan die
+ rechte hand und dogfather es bekommen. es sehen in dieser support
+ kachel. also bei dogfather und der rechten hand soll die kachel
+ anders gebaut sein weil die sind die die sich um die probleme
+ kümmern."
+
+ ---------------------------------------------------------------------
+ WARUM DAS NICHT DER VERTRAULICHE MELDEWEG IST
+
+ Es gibt ihn schon (hilfe.html, hilfe_faelle) und er sieht auf den
+ ersten Blick gleich aus. Drei Unterschiede machen ihn zu etwas
+ anderem:
+
+ 1. ER IST NICHT FÜR ALLE. In rechte.js steht
+ `"/workspace/hilfe.html": ["gast", "hand", "admin"]` — Modis,
+ Creator, Scouts, Manager und die linke Hand haben ihn nicht.
+ Das Team selbst hatte damit gar keinen Weg, einen kaputten
+ Knopf zu melden.
+ 2. ER KANN KEINE BILDER. Bei „der Knopf oben rechts tut nichts"
+ ist ein Bildschirmfoto die halbe Antwort. Ein Meldeweg ohne
+ Bild führt zu drei Rückfragen.
+ 3. ER IST BEWUSST SCHWER. Strikter Wechsel (melden → warten →
+ antworten → warten), drei offene Fälle höchstens, ein Betreff.
+ Das ist richtig, wenn es um einen Vorfall zwischen Menschen
+ geht. Für „das Datum steht falsch da" ist es zu viel.
+
+ Deshalb eine eigene, sehr kurze Sache: EIN Feld, EIN Bild, fertig.
+ Kein Betreff, keine Kategorie, keine Dringlichkeitsstufe. Wer ein
+ Formular mit fünf Feldern sieht, meldet den kleinen Fehler nicht —
+ und genau die kleinen sind es, die sonst niemand erfährt.
+
+ ---------------------------------------------------------------------
+ DREI STÄNDE, NICHT FÜNF
+
+ „neu" Niemand hat es angesehen.
+ „in_arbeit" Jemand kümmert sich — sichtbar auch für den Melder,
+ damit er weiß, dass es angekommen ist.
+ „erledigt" Zu. Mit einer Antwort, wenn es etwas zu sagen gab.
+
+ Eine vierte Stufe („abgelehnt") gibt es absichtlich nicht. Ein
+ Hinweis, den man nicht umsetzt, wird trotzdem erledigt — mit einem
+ Satz warum. „Abgelehnt" ist eine Tür, die man dem zuschlägt, der
+ sich die Mühe gemacht hat.
+
+ ---------------------------------------------------------------------
+ DAS BILD LIEGT AUF DER PLATTE, NICHT IN DER DATENBANK
+
+ Dieselbe Entscheidung wie bei Chat-Anhängen und der Dateiablage: Ein
+ Bildschirmfoto sind ein bis fünf Megabyte, und eine SQLite-Datei,
+ die man täglich sichert, soll das nicht tragen. In der Tabelle steht
+ nur der Dateiname; der Ordner steht in workspace-support.js über
+ `join(DATEN_ORDNER, …)` — genau dieses Muster liest
+ tools/wiederherstellung-proben.mjs, um zu prüfen, ob die Sicherung
+ ihn mitnimmt.
+
+ DIESE DATEI IMPORTIERT NICHTS — derselbe Grund wie bei
+ hilfe-tabellen.js und treff-tabellen.js: Sie wird aus dem Bauplan in
+ workspace.js aufgerufen und darf keinen Kreis erzeugen.
+ ===================================================================== */
+
+/** Die drei Stände einer Meldung.
+ *
+ * Hier und nicht als Zeichenkette an zwanzig Stellen — ein Tippfehler
+ * in einem `WHERE stand = 'neu'` findet lautlos nichts. */
+export const SUPPORT_STAND = {
+ neu: "neu",
+ inArbeit: "in_arbeit",
+ erledigt: "erledigt",
+};
+
+/** Wie lange eine erledigte Meldung aufbewahrt wird.
+ *
+ * Neunzig Tage. Lang genug, um ein wiederkehrendes Problem zu
+ * erkennen („das hatten wir im Juli schon"), kurz genug, dass
+ * Bildschirmfotos aus dem Arbeitsalltag nicht ewig herumliegen.
+ * Aufgeräumt wird im Rundgang, nicht beim Lesen — sonst hängt das
+ * Löschen davon ab, dass jemand die Seite öffnet. */
+export const AUFBEWAHRUNG_TAGE = 90;
+
+export function supportTabellen(d) {
+ d.exec(`
+ CREATE TABLE IF NOT EXISTS support_meldungen (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ /* ON DELETE SET NULL und nicht CASCADE: Wird ein Zugang
+ gelöscht, ist der gemeldete Fehler immer noch da. Die Meldung
+ verliert ihren Absender, nicht ihren Inhalt. */
+ person_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
+ /* Der Name wird MITGESCHRIEBEN, nicht nur verknüpft. Sonst steht
+ nach einem Rollenwechsel oder einer Löschung „—" neben einer
+ Meldung, zu der jemand etwas fragen möchte. */
+ von_name TEXT,
+ von_rolle TEXT,
+ text TEXT NOT NULL,
+ /* WO ES PASSIERT IST. Kommt nicht aus einem Auswahlfeld, sondern
+ aus der Adresse, von der aus gemeldet wurde — eine Angabe, die
+ niemand tippen muss und die trotzdem stimmt. */
+ seite TEXT,
+ /* Was der Browser über sich sagt, gekürzt. Bei „geht bei mir
+ nicht" ist das die erste Frage, und sie erübrigt sich damit. */
+ geraet TEXT,
+ bild_datei TEXT,
+ bild_typ TEXT,
+ bild_breite INTEGER,
+ bild_hoehe INTEGER,
+ stand TEXT NOT NULL DEFAULT 'neu'
+ CHECK (stand IN ('neu','in_arbeit','erledigt')),
+ antwort TEXT,
+ erstellt TEXT NOT NULL,
+ geaendert TEXT NOT NULL,
+ erledigt_am TEXT,
+ erledigt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL
+ );
+
+ /* Die Leitung fragt „was ist offen?", der Melder „was ist meins?".
+ Zwei Indizes für zwei Fragen. */
+ CREATE INDEX IF NOT EXISTS idx_support_stand
+ ON support_meldungen(stand, id DESC);
+ CREATE INDEX IF NOT EXISTS idx_support_person
+ ON support_meldungen(person_id, id DESC);
+ `);
+}
diff --git a/server/workspace-chat.js b/server/workspace-chat.js
index e3bdb022..5cc04af1 100644
--- a/server/workspace-chat.js
+++ b/server/workspace-chat.js
@@ -557,7 +557,12 @@ function direktRaum(personA, idB, { anlegen = true } = {}) {
Laden nicht. Lassen sie sich nicht ablesen, ist das kein Grund zur
Ablehnung -- dann steht eben 0, und der Platz wird geschätzt.
===================================================================== */
-function dateiErkennen(b) {
+/* EXPORTIERT SEIT DEM 24.09.2026 -- der Support braucht dieselbe
+ Erkennung fuer seine Bildschirmfotos. Eine zweite Fassung dort waere
+ die, die beim naechsten Dateiformat vergessen wird, und an dieser
+ Funktion haengt die ganze Sicherheit der Uploads (siehe der Kopf
+ darueber). Eine Wahrheit, zwei Benutzer. */
+export function dateiErkennen(b) {
if (!Buffer.isBuffer(b) || b.length < 16) return null;
const kopf = (von, bis) => b.toString("latin1", von, bis);
diff --git a/server/workspace-push.js b/server/workspace-push.js
index 018b3e73..c64388d4 100644
--- a/server/workspace-push.js
+++ b/server/workspace-push.js
@@ -141,6 +141,18 @@ export const ARTEN = [
{ schluessel: "bewerbung_antwort",
name: "Antwort auf deine eigene Bewerbung", vorgabe: true },
+ /* DER SUPPORT (24.09.2026).
+
+ EINE ART FUER BEIDE RICHTUNGEN -- die neue Meldung an die Leitung
+ und die Antwort an den Melder. Zwei Schluessel waeren zwei
+ Schalter in den Einstellungen fuer dieselbe Sache, und wer den
+ einen abschaltet, wundert sich ueber den anderen.
+
+ NICHT DRINGEND: Ein kaputter Knopf weckt niemanden um drei Uhr
+ nachts; die Ruhezeit gilt. */
+ { schluessel: "support",
+ name: "Support: neue Meldung oder Antwort darauf", vorgabe: true },
+
/* ER IST LIVE (22.09.2026).
Filipe: "die leute sollen auch automatisch eine benarichtigung
diff --git a/server/workspace-support.js b/server/workspace-support.js
new file mode 100644
index 00000000..7bb5f537
--- /dev/null
+++ b/server/workspace-support.js
@@ -0,0 +1,436 @@
+/* =====================================================================
+ DER SUPPORT (24.09.2026)
+
+ Filipe: „da fehlt auch eine support kachel. die jeder sieht. jeder
+ benutzen kann. jeder kann da probleme von der seite melden und
+ hinweisen. fotos mit schicken mit einem text. es soll alles hoch
+ profissionell gemacht werden aber so einfach wie möglich, will
+ nichts kompliziertes oder zu viel. kurz und knapp … also bei
+ dogfather und der rechten hand soll die kachel anders gebaut sein
+ weil die sind die die sich um die probleme kümmern."
+
+ Die Regeln und ihre Begründung stehen im Kopf von
+ support-tabellen.js. Hier sind die Wege — und die vier Stellen, an
+ denen etwas schiefgehen kann:
+
+ 1. EINE MELDUNG DARF NIE BEIM FALSCHEN LANDEN. Jede Leseroute geht
+ über `meldungFuer()`, das den Datensatz zurückgibt oder `null` —
+ nie `true`/`false`. So kann keine Route „darf sehen" beantworten
+ und danach trotzdem den falschen Datensatz laden. Dasselbe
+ Muster wie im vertraulichen Meldeweg.
+
+ 2. DER TYP EINES BILDES KOMMT AUS DEM INHALT. `dateiErkennen()`
+ liest die ersten Bytes; Dateiname und Content-Type kommen vom
+ Absender und sind frei erfunden. Die Funktion wird aus
+ workspace-chat.js IMPORTIERT und nicht abgeschrieben: Eine
+ zweite Fassung wäre die, die beim nächsten Dateiformat vergessen
+ wird — und an ihr hängt die ganze Sicherheit der Uploads.
+
+ 3. DER RUMPF WIRD ERST ANGENOMMEN, WENN DIE BERECHTIGUNG STEHT.
+ Wer nicht angemeldet ist, kommt gar nicht bis zur Datei.
+
+ 4. ERLEDIGT IST ERLEDIGT. Auch für die Leitung — eine geschlossene
+ Meldung wird nicht stillschweigend wieder geöffnet und anders
+ beantwortet.
+ ===================================================================== */
+
+import express from "express";
+import { createReadStream, statSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs";
+import { join } from "node:path";
+import { randomBytes } from "node:crypto";
+import { db, protokolliere, DATEN_ORDNER, echteIp } from "./workspace.js";
+import { SUPPORT_STAND, AUFBEWAHRUNG_TAGE } from "./support-tabellen.js";
+import { dateiErkennen } from "./workspace-chat.js";
+import { benachrichtige } from "./workspace-push.js";
+
+export const supportRouter = express.Router();
+
+/** Wer alle Meldungen sieht und sie bearbeitet.
+ *
+ * GENAU ZWEI ROLLEN, wie bestellt: DogFather und die rechte Hand.
+ * Dieselbe Menge wie im vertraulichen Meldeweg — und aus einem
+ * ähnlichen Grund: Wer sich kümmert, muss alles sehen; wer nur
+ * meldet, sieht seine eigene Meldung. */
+const LEITUNG = new Set(["admin", "hand"]);
+const istLeitung = (person) => LEITUNG.has(person?.rolle);
+
+const TEXT_MAX = 2000;
+const SEITE_MAX = 120;
+const GERAET_MAX = 200;
+const ANTWORT_MAX = 2000;
+
+/** Zwölf Megabyte — dieselbe Grenze wie bei Chat-Anhängen.
+ * Ein Bildschirmfoto vom Handy liegt bei 2 bis 5 MB. */
+const BILD_MAX = 12 * 1024 * 1024;
+
+/** Wie viele offene Meldungen eine Person gleichzeitig haben darf.
+ *
+ * Zehn, und damit deutlich großzügiger als die drei im vertraulichen
+ * Meldeweg. Dort ist die Grenze ein Schutz vor Überforderung auf
+ * beiden Seiten; hier wäre sie eine Bremse für genau die Person, die
+ * sich die Mühe macht, fünf kleine Fehler einzeln zu melden. Sie
+ * steht nur da, damit ein hängendes Skript nicht tausend Zeilen
+ * anlegt. */
+const OFFEN_MAX = 10;
+
+/* Der Ordner steht über join(DATEN_ORDNER, …) — genau dieses Muster
+ liest tools/wiederherstellung-proben.mjs aus, um zu prüfen, ob die
+ Sicherung ihn mitnimmt. Eine gepflegte Liste wäre die, die beim
+ nächsten Ordner vergessen wird. */
+const BILD_ORDNER = join(DATEN_ORDNER, "support-bilder");
+
+const jetzt = () => new Date().toISOString();
+
+function sauber(s, max) {
+ return String(s ?? "").replace(/\s+/g, " ").trim().slice(0, max);
+}
+
+/** Mehrzeiliger Text: Absätze bleiben, Leerzeilenwüsten nicht.
+ *
+ * Beim Melden schreibt man „Schritt 1 … Schritt 2 …" untereinander.
+ * Das über `\s+` plattzumachen wie einen Betreff würde genau die
+ * Struktur zerstören, die den Fehler nachvollziehbar macht. */
+function textSauber(s, max) {
+ return String(s ?? "")
+ .replace(/\r\n/g, "\n")
+ .replace(/[ \t]+/g, " ")
+ .replace(/\n{3,}/g, "\n\n")
+ .trim()
+ .slice(0, max);
+}
+
+/** Darf diese Person diese Meldung sehen?
+ *
+ * Gibt die Meldung zurück oder `null`. NIE `true`/`false` — dasselbe
+ * Muster wie `fallFuer()` im vertraulichen Meldeweg, und aus
+ * demselben Grund. */
+function meldungFuer(person, id) {
+ const m = db().prepare("SELECT * FROM support_meldungen WHERE id = ?").get(Number(id));
+ if (!m) return null;
+ if (istLeitung(person)) return m;
+ return m.person_id === person?.id ? m : null;
+}
+
+/** Wie eine Meldung nach draußen aussieht.
+ *
+ * AN EINER STELLE, weil vier Routen sie ausgeben. Vier Stellen wären
+ * vier Gelegenheiten, ein Feld zu vergessen — oder eines zu viel
+ * mitzuschicken.
+ *
+ * `fuerLeitung` entscheidet nur über die Zusatzangaben, nicht über
+ * den Inhalt: Wer meldet, sieht seine eigene Meldung vollständig,
+ * auch den Namen, den er selbst trägt. */
+function nachDraussen(m, fuerLeitung) {
+ return {
+ id: m.id,
+ text: m.text,
+ stand: m.stand,
+ antwort: m.antwort || null,
+ erstellt: m.erstellt,
+ geaendert: m.geaendert,
+ erledigt_am: m.erledigt_am || null,
+ seite: m.seite || null,
+ /* Das Bild als fertige ADRESSE, nicht als Dateiname — sonst setzt
+ jede Stelle im Browser denselben Pfad zusammen, und eine davon
+ setzt ihn irgendwann anders. Dieselbe Entscheidung wie beim
+ Chat-Anhang. */
+ bild: m.bild_datei ? `/workspace/api/support/${m.id}/bild` : null,
+ bild_breite: m.bild_breite || 0,
+ bild_hoehe: m.bild_hoehe || 0,
+ von: m.von_name || null,
+ von_rolle: m.von_rolle || null,
+ /* Nur die Leitung braucht zu wissen, mit welchem Gerät gemeldet
+ wurde. Für den Melder ist es seine eigene Angabe und nur
+ Rauschen. */
+ geraet: fuerLeitung ? (m.geraet || null) : undefined,
+ };
+}
+
+/* =====================================================================
+ LESEN
+ ===================================================================== */
+
+/** Die eigenen Meldungen — oder für die Leitung alle.
+ *
+ * EINE ROUTE FÜR BEIDE, nicht zwei. Zwei Routen hießen zwei Stellen,
+ * an denen die Sichtbarkeitsregel gepflegt werden muss — und eine
+ * davon läuft irgendwann auseinander. */
+supportRouter.get("/workspace/api/support", (req, res) => {
+ try {
+ const leitung = istLeitung(req.person);
+ const zeilen = leitung
+ ? db().prepare(`SELECT * FROM support_meldungen ORDER BY
+ /* Offene zuerst, und darin die ältesten oben: Was am
+ längsten wartet, gehört nach vorn. Erledigte danach, das
+ Neueste zuerst. */
+ CASE stand WHEN 'neu' THEN 0 WHEN 'in_arbeit' THEN 1 ELSE 2 END,
+ CASE WHEN stand = 'erledigt' THEN -id ELSE id END
+ LIMIT 300`).all()
+ : db().prepare(`SELECT * FROM support_meldungen WHERE person_id = ?
+ ORDER BY id DESC LIMIT 100`).all(req.person.id);
+
+ res.json({
+ leitung,
+ meldungen: zeilen.map((m) => nachDraussen(m, leitung)),
+ /* Die Zahlen stehen in der Antwort und werden nicht im Browser
+ nachgezählt: Die Liste ist bei 300 abgeschnitten, eine Zählung
+ darauf wäre ab der 301. Meldung still falsch. */
+ offen: leitung
+ ? db().prepare("SELECT COUNT(*) AS n FROM support_meldungen WHERE stand <> 'erledigt'")
+ .get().n
+ : db().prepare(`SELECT COUNT(*) AS n FROM support_meldungen
+ WHERE person_id = ? AND stand <> 'erledigt'`).get(req.person.id).n,
+ hoechstens_bytes: BILD_MAX,
+ text_max: TEXT_MAX,
+ });
+ } catch (fehler) {
+ console.error("[support] Liste:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/** Das Bild einer Meldung.
+ *
+ * Dieselben Kopfzeilen wie beim Chat-Anhang: der Typ aus der
+ * ERKENNUNG (nicht der eingeschickte), nosniff, und eine Richtlinie,
+ * die dem Dokument jede Ausführung nimmt. */
+supportRouter.get("/workspace/api/support/:id(\\d+)/bild", (req, res) => {
+ try {
+ const m = meldungFuer(req.person, req.params.id);
+ if (!m || !m.bild_datei) return res.status(404).json({ fehler: "nicht_gefunden" });
+ const pfad = join(BILD_ORDNER, m.bild_datei);
+ try { statSync(pfad); } catch {
+ return res.status(410).json({ fehler: "Das Bild fehlt auf der Platte." });
+ }
+ res.setHeader("Content-Type", m.bild_typ || "image/png");
+ res.setHeader("X-Content-Type-Options", "nosniff");
+ res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
+ res.setHeader("Cache-Control", "private, max-age=86400");
+ createReadStream(pfad).pipe(res);
+ } catch (fehler) {
+ console.error("[support] Bild:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/* =====================================================================
+ MELDEN
+ ===================================================================== */
+
+/** Vor express.raw: Darf diese Person überhaupt melden?
+ *
+ * Angemeldet sein genügt — „die jeder sieht, jeder benutzen kann".
+ * Die Prüfung steht trotzdem VOR der Annahme des Rumpfes, sonst
+ * wanderten zwölf Megabyte durch die Leitung, bevor irgendjemand
+ * fragt, wer da schickt. */
+function darfMelden(req, res, next) {
+ if (!req.person?.id) return res.status(401).json({ fehler: "nicht_angemeldet" });
+ const offen = db().prepare(`SELECT COUNT(*) AS n FROM support_meldungen
+ WHERE person_id = ? AND stand <> 'erledigt'`).get(req.person.id).n;
+ if (offen >= OFFEN_MAX) {
+ return res.status(429).json({
+ fehler: `Du hast schon ${offen} offene Meldungen. Warte kurz ab, `
+ + "bis sich jemand darum gekümmert hat.",
+ });
+ }
+ next();
+}
+
+/** Eine Meldung abschicken.
+ *
+ * EIN WEG FÜR BEIDES — mit Bild und ohne. Der Text reist im Kopf
+ * (`x-text`, prozentkodiert), das Bild im Rumpf. Zwei Routen zu
+ * bauen („/support" für Text, "/support/bild" für Bild) wäre die
+ * naheliegende Aufteilung und hätte einen Zustand dazwischen: eine
+ * Meldung, die schon existiert, während das Bild noch hochlädt —
+ * und bei einem Abbruch für immer ohne Bild dasteht.
+ *
+ * Ein leerer Rumpf ist zulässig: Dann gibt es eben kein Bild. */
+supportRouter.post("/workspace/api/support",
+ darfMelden,
+ express.raw({ type: "*/*", limit: BILD_MAX }),
+ (req, res) => {
+ let geschrieben = null;
+ try {
+ let text = "";
+ try { text = decodeURIComponent(req.get("x-text") || ""); } catch { text = ""; }
+ text = textSauber(text, TEXT_MAX);
+ if (text.length < 5) {
+ return res.status(400).json({
+ fehler: "Schreib bitte kurz, was los ist – ein Satz reicht.",
+ });
+ }
+
+ let bildDatei = null, bildTyp = null, bildBreite = 0, bildHoehe = 0;
+ if (Buffer.isBuffer(req.body) && req.body.length) {
+ const erkannt = dateiErkennen(req.body);
+ /* NUR BILDER. dateiErkennen kennt auch PDF und Ton — hier geht
+ es um Bildschirmfotos, und alles andere wäre eine Tür, die
+ niemand bestellt hat. */
+ if (!erkannt || erkannt.art !== "bild") {
+ return res.status(415).json({
+ fehler: "Als Anhang geht ein Foto – PNG, JPEG, WebP oder GIF.",
+ });
+ }
+ mkdirSync(BILD_ORDNER, { recursive: true });
+ bildDatei = `${Date.now()}-${randomBytes(8).toString("hex")}${erkannt.endung}`;
+ writeFileSync(join(BILD_ORDNER, bildDatei), req.body);
+ geschrieben = join(BILD_ORDNER, bildDatei);
+ bildTyp = erkannt.typ;
+ bildBreite = erkannt.breite || 0;
+ bildHoehe = erkannt.hoehe || 0;
+ }
+
+ const n = jetzt();
+ const info = db().prepare(`INSERT INTO support_meldungen
+ (person_id, von_name, von_rolle, text, seite, geraet,
+ bild_datei, bild_typ, bild_breite, bild_hoehe,
+ stand, erstellt, geaendert)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`)
+ .run(req.person.id, req.person.name || null, req.person.rolle || null,
+ text, sauber(req.get("x-seite"), SEITE_MAX) || null,
+ sauber(req.get("user-agent"), GERAET_MAX) || null,
+ bildDatei, bildTyp, bildBreite, bildHoehe,
+ SUPPORT_STAND.neu, n, n);
+ const id = Number(info.lastInsertRowid);
+
+ protokolliere("support_gemeldet", {
+ personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
+ detail: `#${id} ${text.slice(0, 80)}`,
+ });
+
+ /* DIE LEITUNG ERFÄHRT ES SOFORT. Eine Meldung, die erst auffällt,
+ wenn jemand die Seite zufällig öffnet, ist keine Meldung,
+ sondern ein Zettel im Schrank.
+
+ NICHT DRINGEND: Ein kaputter Knopf weckt niemanden um drei Uhr
+ nachts. Die Ruhezeit gilt (siehe workspace-push.js). */
+ for (const p of db().prepare(
+ `SELECT id FROM personen WHERE aktiv = 1 AND rolle IN ('admin','hand')`).all()) {
+ benachrichtige(p.id, "support", {
+ titel: "Neue Support-Meldung",
+ text: text.slice(0, 120),
+ ziel: `/workspace/support.html?meldung=${id}`,
+ }).catch(() => { /* eine ausgefallene Benachrichtigung darf die
+ Meldung nicht verhindern */ });
+ }
+
+ const frisch = db().prepare("SELECT * FROM support_meldungen WHERE id = ?").get(id);
+ res.status(201).json({ meldung: nachDraussen(frisch, istLeitung(req.person)) });
+ } catch (fehler) {
+ /* DIE DATEI GEHT MIT, WENN DIE ZEILE NICHT ZUSTANDE KAM. Sonst
+ sammelt der Ordner Bilder an, zu denen es keine Meldung gibt —
+ und niemand merkt es, weil nichts darauf zeigt. */
+ if (geschrieben) { try { unlinkSync(geschrieben); } catch { /* egal */ } }
+ console.error("[support] Melden:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+/* =====================================================================
+ SICH KÜMMERN — nur die Leitung
+ ===================================================================== */
+
+/** Stand ändern und antworten.
+ *
+ * EINE ROUTE FÜR BEIDES, weil es in der Oberfläche eine Handlung ist:
+ * „erledigt, und zwar deshalb". Zwei Routen hießen zwei Anfragen, und
+ * bei der zweiten kann das Netz abbrechen — dann steht die Meldung
+ * auf „erledigt" ohne den Satz, der sie erklärt. */
+supportRouter.patch("/workspace/api/support/:id(\\d+)", express.json(), (req, res) => {
+ try {
+ if (!istLeitung(req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
+ const m = db().prepare("SELECT * FROM support_meldungen WHERE id = ?")
+ .get(Number(req.params.id));
+ if (!m) return res.status(404).json({ fehler: "nicht_gefunden" });
+
+ /* ERLEDIGT IST ERLEDIGT. Eine geschlossene Meldung wird nicht
+ stillschweigend wieder geöffnet und anders beantwortet — sonst
+ ist „zugemacht" eine Meinung und keine Tatsache. Dieselbe Regel
+ wie im vertraulichen Meldeweg. */
+ if (m.stand === SUPPORT_STAND.erledigt) {
+ return res.status(409).json({
+ fehler: "Diese Meldung ist erledigt. Für etwas Neues bitte eine neue.",
+ });
+ }
+
+ const wunsch = String(req.body?.stand || "");
+ if (!Object.values(SUPPORT_STAND).includes(wunsch)) {
+ return res.status(400).json({ fehler: "ungueltiger_stand" });
+ }
+ const antwort = textSauber(req.body?.antwort, ANTWORT_MAX);
+ /* WER ZUMACHT, SAGT WARUM. Nicht als Schikane: Der Melder sieht
+ nur, dass seine Sache zu ist — ohne einen Satz weiß er nicht, ob
+ sie behoben wurde oder ob niemand Lust hatte. */
+ if (wunsch === SUPPORT_STAND.erledigt && antwort.length < 3) {
+ return res.status(400).json({
+ fehler: "Schreib kurz dazu, was daraus geworden ist – der Melder sieht nur das.",
+ });
+ }
+
+ const n = jetzt();
+ db().prepare(`UPDATE support_meldungen
+ SET stand = ?, antwort = ?, geaendert = ?,
+ erledigt_am = ?, erledigt_von = ?
+ WHERE id = ?`)
+ .run(wunsch, antwort || m.antwort || null, n,
+ wunsch === SUPPORT_STAND.erledigt ? n : null,
+ wunsch === SUPPORT_STAND.erledigt ? req.person.id : null,
+ m.id);
+
+ protokolliere("support_bearbeitet", {
+ personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
+ detail: `#${m.id} -> ${wunsch}`,
+ });
+
+ /* DER MELDER ERFÄHRT ES. Wer etwas meldet und nie wieder davon
+ hört, meldet beim nächsten Mal nichts mehr. */
+ if (m.person_id && m.person_id !== req.person.id) {
+ benachrichtige(m.person_id, "support", {
+ titel: wunsch === SUPPORT_STAND.erledigt
+ ? "Deine Meldung ist erledigt" : "Jemand kümmert sich",
+ text: antwort ? antwort.slice(0, 120) : m.text.slice(0, 120),
+ ziel: `/workspace/support.html?meldung=${m.id}`,
+ }).catch(() => { /* siehe oben */ });
+ }
+
+ const frisch = db().prepare("SELECT * FROM support_meldungen WHERE id = ?").get(m.id);
+ res.json({ meldung: nachDraussen(frisch, true) });
+ } catch (fehler) {
+ console.error("[support] Bearbeiten:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/* =====================================================================
+ AUFRÄUMEN
+ ===================================================================== */
+
+/** Erledigte Meldungen nach der Aufbewahrungsfrist entfernen.
+ *
+ * Wird aus dem Rundgang aufgerufen, nicht beim Lesen — sonst hängt
+ * das Aufräumen davon ab, dass jemand die Seite öffnet, und auf einem
+ * ruhigen System wächst der Bilderordner still weiter.
+ *
+ * DIE DATEI GEHT MIT DER ZEILE. Ein Bild ohne Meldung zeigt auf
+ * nichts und niemand findet es je wieder. */
+export function supportAufraeumen() {
+ try {
+ const grenze = new Date(Date.now() - AUFBEWAHRUNG_TAGE * 86400000).toISOString();
+ const alt = db().prepare(`SELECT id, bild_datei FROM support_meldungen
+ WHERE stand = 'erledigt' AND erledigt_am IS NOT NULL AND erledigt_am < ?`).all(grenze);
+ if (!alt.length) return 0;
+ for (const m of alt) {
+ if (m.bild_datei) {
+ try { unlinkSync(join(BILD_ORDNER, m.bild_datei)); } catch { /* schon weg */ }
+ }
+ db().prepare("DELETE FROM support_meldungen WHERE id = ?").run(m.id);
+ }
+ console.log(`[support] ${alt.length} erledigte Meldungen aelter als `
+ + `${AUFBEWAHRUNG_TAGE} Tage entfernt.`);
+ return alt.length;
+ } catch (fehler) {
+ console.error("[support] Aufraeumen:", fehler?.message);
+ return 0;
+ }
+}
diff --git a/server/workspace-willkommen.js b/server/workspace-willkommen.js
index 8b061c73..b8e59dd0 100644
--- a/server/workspace-willkommen.js
+++ b/server/workspace-willkommen.js
@@ -140,6 +140,10 @@ const LANG = {
"hilfe.html":
"Wenn etwas vorgefallen ist, das niemand mitlesen soll. Nur DogFather "
+ "und die rechte Hand sehen diese Meldungen.",
+ "support.html":
+ "Etwas an der Seite funktioniert nicht oder sieht falsch aus? Schreib "
+ + "einen Satz dazu und häng ein Bildschirmfoto an — DogFather und die "
+ + "rechte Hand bekommen es sofort und melden sich, wenn es erledigt ist.",
"treff-regeln.html":
"Wie wir hier miteinander umgehen. Kurz, und es lohnt sich einmal.",
"unsere-seiten.html":
diff --git a/server/workspace.js b/server/workspace.js
index 121707c4..a66f6b94 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -33,6 +33,7 @@ import { darfSeite, OHNE_ANMELDUNG, seitenFuer } from "./rechte.js";
Begruendung im Kopf von treff-tabellen.js. */
import { treffTabellen, treffSperreLesen, MINDESTALTER } from "./treff-tabellen.js";
import { hilfeTabellen } from "./hilfe-tabellen.js";
+import { supportTabellen } from "./support-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
@@ -1569,6 +1570,31 @@ export const TREFF_ROLLEN = new Set(["gast", "modi", "hand", "linke", "admin"]);
SIE STEHT VOR "FUER DICH": Ein Problem ist dringender als der eigene
Steckbrief. Die Reihenfolge innerhalb der Gruppe "Fuer dich" ergibt
sich aus der Liste, die Sortierung in start.js fasst beide ans Ende. */
+/* DER SUPPORT (24.09.2026).
+
+ Filipe: „da fehlt auch eine support kachel. die jeder sieht. jeder
+ benutzen kann. jeder kann da probleme von der seite melden und
+ hinweisen. fotos mit schicken mit einem text."
+
+ SIE STEHT NICHT IN EINER ROLLENLISTE, sondern wird in
+ `bereicheFuer()` an JEDE angehaengt -- siehe dort. Sie in fuenf
+ Listen einzutragen waere fuenf Gelegenheiten, sie bei der sechsten
+ Rolle zu vergessen; und genau so ist heute der
+ Benachrichtigungs-Knopf auf 14 Seiten verschwunden.
+
+ TON 44: gezaehlt in start.css, wo die Farben wirklich stehen (bis
+ 43 vergeben). Danach laeuft `node tools/kachel-farben.mjs
+ --schreiben`, das alle Toene neu gegeneinander rechnet.
+
+ ZEICHEN "schutz" wie beim vertraulichen Meldeweg -- beides sind
+ Wege, auf denen man sich meldet, wenn etwas nicht stimmt. Ein
+ eigenes Zeichen zu erfinden hiesse, zwei Dinge zu unterscheiden,
+ die sich fuer den Benutzer gleich anfuehlen. */
+const SUPPORT_KACHEL = {
+ name: "Support", unter: "Etwas kaputt? Schreib es mit Bild",
+ zeichen: "schutz", ton: 44, ziel: "support.html", szene: "studio",
+};
+
const HILFE_KACHEL = {
gruppe: "Für dich", gruppeUnter: "Deine eigene Seite",
name: "Vertraulich melden", unter: "Nur DogFather und die rechte Hand",
@@ -1858,7 +1884,28 @@ const STECKBRIEF_KACHEL_TREFF = {
/* Die Community: der Treff und ihre eigene Seite. Sonst nichts. */
const TREFF_MIT_EIGENEM = [...TREFF_BEREICHE, HILFE_KACHEL, STECKBRIEF_KACHEL_TREFF];
+/* DIE SUPPORT-KACHEL BEKOMMT JEDER -- an genau einer Stelle
+ angehaengt (24.09.2026).
+
+ `bereicheFuer` gibt je nach Rolle eine von fuenf Listen zurueck,
+ dazu `null` ("nimm die Liste aus dem Browser") und `[]` ("nichts").
+ Die Kachel in jede Liste einzutragen waeren fuenf Aenderungen und
+ fuenf kuenftige Gelegenheiten, eine zu vergessen. Stattdessen
+ haengt diese Huelle sie an das an, was herauskommt.
+
+ `null` UND `[]` BLEIBEN UNANGETASTET, und das ist Absicht:
+ `null` heisst "der Browser hat die Liste" -- dort steht sie in
+ assets/js/bereiche.js und wird dort ergaenzt. `[]` heisst "diese
+ Rolle bekommt gar nichts"; ihr eine einzelne Kachel zu geben waere
+ die Ausnahme, die spaeter niemand mehr erklaeren kann. */
export function bereicheFuer(person) {
+ const liste = bereicheRoh(person);
+ if (liste === null || !Array.isArray(liste) || !liste.length) return liste;
+ return liste.some((k) => k?.ziel === "support.html")
+ ? liste : [...liste, SUPPORT_KACHEL];
+}
+
+function bereicheRoh(person) {
if (person?.rolle === "modi") return MODI_MIT_TREFF;
/* Die linke Hand vor der allgemeinen Hand-Regel -- sonst faenge
`istHand()` sie ab, und der Filter darunter liefe nie. */
@@ -5261,6 +5308,7 @@ export function db() {
/* Der vertrauliche Meldeweg (19.09.2026). Haengt an personen und
kommt deshalb hierher, nicht in den Bauplan darueber. */
hilfeTabellen(d);
+ supportTabellen(d);
umstellungen(d);
_db = d;
return d;
diff --git a/tools/kachel-farbe-einzeln.mjs b/tools/kachel-farbe-einzeln.mjs
index 410d3176..fdff0bdd 100644
--- a/tools/kachel-farbe-einzeln.mjs
+++ b/tools/kachel-farbe-einzeln.mjs
@@ -234,7 +234,36 @@ for (const ziel of nummern) {
const alt = Math.min(...andere.map((t) => abstand(vorher.lab, t.lab)));
const neu = beste(andere);
- if (neu.abstand <= alt) {
+ /* EIN UNGUELTIGER IST-WERT IST KEINE MESSLATTE (24.09.2026).
+
+ Gefunden beim Eintragen von Ton 44 -- und es war GENAU DERSELBE
+ FEHLER wie am 22.09., nur eine Ebene hoeher.
+
+ Damals: Die Suche lief bis Buntheit 0,04 herunter, die Pruefung
+ verlangt 0,12. Behoben, indem beide dieselbe Zahl lesen.
+
+ Heute: Ich hatte fuer Ton 44 von Hand ein zu blasses #bcdbff
+ eingetragen (Buntheit 0,060). Dieses Werkzeug las es als
+ Ausgangswert, verglich den besten NEUEN Vorschlag dagegen -- und
+ meldete "bleibt #bcdbff, nichts Besseres gefunden". Der Abstand
+ war tatsaechlich groesser; nur war der Ist-Wert gar nicht
+ zulaessig. Ein Werkzeug, das einen regelwidrigen Zustand
+ verteidigt, weil er zufaellig gut misst, haelt genau den Fehler
+ fest, den es beheben soll.
+
+ Geprueft werden deshalb ZUERST die Regeln, dann der Abstand. */
+ const istBuntheit = Math.hypot(vorher.lab[1], vorher.lab[2]);
+ const istGueltig = istBuntheit >= MINDEST_BUNTHEIT
+ && kontrast(vorher.hex) >= MINDEST_KONTRAST;
+ if (!istGueltig) {
+ console.log(`
+Ton ${ziel} (${vorher.wofuer}): ${vorher.hex} haelt die Regeln `
+ + `nicht ein (Buntheit ${istBuntheit.toFixed(3)}, `
+ + `Kontrast ${kontrast(vorher.hex).toFixed(2)}:1) -- er wird ersetzt, `
+ + "egal wie gut sein Abstand ist.");
+ }
+
+ if (istGueltig && neu.abstand <= alt) {
console.log(`\nTon ${ziel} (${vorher.wofuer}): bleibt ${vorher.hex} — `
+ `nichts Besseres gefunden (${alt.toFixed(4)}).`);
continue;
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index bae2b423..5e1643d0 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`. -->
-
-
+
+
-
+