Support: melden mit Bild, und für die Leitung ein Eingang

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. … so einfach wie
möglich, will nichts kompliziertes oder zu viel. kurz und knapp …
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 VORHANDENE MELDEWEG IST. Es gibt ihn schon
(hilfe.html) und er sieht aehnlich aus. Drei Unterschiede machen ihn
zu etwas anderem:
  1. Er ist NICHT fuer alle -- in rechte.js steht
     `["gast","hand","admin"]`. Modis, Creator, Scouts, Manager und
     die linke Hand hatten gar keinen Weg, einen kaputten Knopf zu
     melden.
  2. Er kann keine Bilder. Bei „der Knopf tut nichts" ist ein
     Bildschirmfoto die halbe Antwort.
  3. Er ist bewusst schwer: strikter Wechsel, hoechstens drei offene
     Faelle, ein Betreff. Richtig bei einem Vorfall zwischen
     Menschen, zu viel fuer „das Datum steht falsch da".

Deshalb eigen und sehr kurz: EIN Feld, EIN Bild, fertig. Kein
Betreff, keine Kategorie, keine Dringlichkeitsstufe -- wer ein
Formular mit fuenf Feldern sieht, meldet den kleinen Fehler nicht,
und genau die kleinen erfaehrt sonst niemand.

DIE LEITUNG SIEHT ETWAS ANDERES: einen Eingang mit Zahlenband
(neu / in Arbeit / erledigt), drei Filtern und zwei Handgriffen --
„Ich kuemmere mich" und „Erledigt …". Zwei, nicht fuenf; eine
Zuweisung an eine Person und Prioritaeten waeren ein Ticketsystem
fuer zwei Menschen, die nebeneinander sitzen. Das Melde-Feld bleibt
auch fuer sie da, rutscht aber unter den Eingang.

Wer zumacht, schreibt einen Satz dazu -- der Melder sieht nur diesen
Satz, und ohne ihn weiss er nicht, ob etwas behoben wurde oder ob
niemand Zeit hatte. Beide Seiten bekommen eine Benachrichtigung.

UEBERNOMMEN STATT NEU ERFUNDEN:
  * `dateiErkennen` aus workspace-chat.js -- EXPORTIERT, nicht
    abgeschrieben. An ihr haengt die ganze Sicherheit der Uploads
    (der Typ kommt aus den ersten Bytes, nicht aus Name oder
    Content-Type), und eine zweite Fassung waere die, die beim
    naechsten Dateiformat vergessen wird.
  * Die zweigeteilte Sicht und `meldungFuer()` (gibt den Datensatz
    oder null zurueck, nie true/false) aus dem vertraulichen
    Meldeweg.
  * Der Upload-Weg (express.raw, Text im Kopf, Ordner unter
    DATEN_ORDNER, damit die Sicherungspruefung ihn findet).

DIE KACHEL BEKOMMT JEDER -- an EINER Stelle angehaengt: `bereicheFuer`
haengt sie an jede Rollenliste, statt sie in fuenf Listen
einzutragen. Genau so ist heute der Benachrichtigungs-Knopf auf 14
Seiten verschwunden. Dazu der Eintrag in rechte.js (ohne ihn
verschwindet die Kachel lautlos, `nurOffeneKacheln` filtert dagegen)
und in der Browser-Kachelliste fuer die Agentur-Rollen.

TON 44 GERECHNET, NICHT GEWAEHLT -- und dabei zweimal danebengelegen:
  * Ich habe erst einen eigenen Modus in kachel-farben.mjs gebaut.
    `tools/kachel-farbe-einzeln.mjs` gibt es aber seit dem 22.09. fuer
    genau diesen Zweck. Meine Dopplung ist wieder weg.
  * Von Hand hatte ich #bcdbff eingetragen. pruef-kachelfarben lehnte
    es ab (Buntheit 0,060, verlangt sind 0,12) -- und das vorhandene
    Werkzeug verteidigte es trotzdem, weil sein Abstand gut war. Ein
    Werkzeug, das einen regelwidrigen Zustand haelt, weil er zufaellig
    gut misst, behebt genau den Fehler nicht, fuer den es da ist.
    Behoben: Zuerst die Regeln, dann der Abstand. Ergebnis #fd6401,
    Abstand 0,0914 (engstes vorhandenes Paar: 0,0154).

DREI MESSFEHLER VON MIR, die wie schwere Befunde aussahen: `fetch`
verwirft den Host-Kopf (die Community kommt nur auf crew. herein),
die Community bestaetigt ihr Alter (`alter_ok`), und die Startroute
heisst /api/ich. Alle drei stehen in pruef-treff.mjs richtig.

Ein ECHTER Fehler kam dazu: support.html lud bereiche.js nicht, und
kopf.js stuerzte mit „Cannot read properties of undefined (reading
'LEITUNG')" ab -- sichtbar nur in der Browserkonsole. Und
pruef-css-klassen fand sofort, dass auch wahl.js fehlte.

Gemessen: pruef-support 45 Pruefungen, 0 Fehler (alle neun Rollen
kommen herein, jede sieht die Kachel, als Bild getarntes HTML wird
abgelehnt, eine fremde Meldung bleibt fremd, erledigt bleibt
erledigt). Dazu 24 Messungen am Bild in drei Groessen. Gruen:
pruef-kachelfarben (22), pruef-css-klassen.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-24 01:32:55 +02:00
co-authored by Claude Opus 5
parent 4d36f1cbf0
commit 9f5dbb42e3
54 changed files with 2654 additions and 696 deletions
+2
View File
@@ -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);
+457
View File
@@ -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("<html><script>alert(1)</script></html>"),
});
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);
+17
View File
@@ -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").
+127
View File
@@ -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);
`);
}
+6 -1
View File
@@ -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);
+12
View File
@@ -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
+436
View File
@@ -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;
}
}
+4
View File
@@ -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":
+48
View File
@@ -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;