Files
dogfather-universe/server/workspace-hilfe.js
T
DogFatherGitandClaude Opus 5 d2f02ba84e Spenden: Stufen gestaltbar, Bild hochladen, Größe je Stufe
Filipes Wunsch: „mach paar fertige und so dass ich auch hochladen
kann. auch so dass ich das anders gestalten kann oder die größe
verändern kann. ... auch spezielle sachen bei speziellen spenden."

WAS ES SCHON GAB, WAS FEHLTE
Drei Stufen ab Werk, fünf gezeichnete Zeichen, Farbe und Dauer je
Stufe — und sogar schon ein Feld für ein eigenes Bild. Es fehlte der
Weg, das alles zu ÄNDERN: Um eine Stufe umzubenennen, hätte jemand in
die Datenbank greifen müssen.

DIE GRÖSSE IST DAS „SPEZIELLE BEI SPEZIELLEN SPENDEN"
Je Stufe, nicht einmal für alle: Eine Rudel-Legende darf größer
dastehen als ein Danke. Eine einzige Größe für alle wäre wieder eine
Preisliste. Umgesetzt als EINE Schriftgröße, alles darin in `em` —
nicht `transform: scale()`, denn die Karte kommt schon mit
`translateX()` herein, und zwei `transform` an derselben Stelle
schließen einander aus; außerdem wird Text beim Skalieren matschig.
Nur nach oben (1 bis 2,5), und das ist eine ehrliche Grenze: Das
kleinste Wort auf der Karte steht bei 11,52 px, die Hausgrenze ist
11,5. Ein Faktor von 0,8 machte daraus 9,2 px. Kleiner geht an der
richtigen Stelle — die OBS-Tafel hat ihren eigenen Regler in der
Adresse, dort ist es eine Videoeinblendung und kein Text zum Lesen.

AUSPROBIEREN, OHNE EINE SPENDE ANZULEGEN
Der naheliegende Weg wäre gewesen: eine Spende eintragen und danach
löschen. Das ist verboten — in ein laufendes System kommen keine
Testdaten, und „gelöscht" heißt bei Geld nicht „war nie da". Die
Probe schreibt deshalb NICHTS und geht nur an den, der drückt; eine
Probe im ganzen Saal wäre eine Spende, die es nicht gab.

DREI FEHLER, DIE DIE PRÜFUNG GEFUNDEN HAT

1. `protokolliere()` wurde an 17 Stellen falsch herum gerufen —
   `(personId, aktion, detail, ip)` statt `(aktion, {…})`. JavaScript
   beschwert sich nicht: Das zweite Argument war ein Text, und einen
   Text zu zerlegen ergibt lauter `undefined`. Auf dem echten Server
   nachgemessen: 39 Protokollzeilen mit Aktionen wie „16.0", alle
   ohne Person, ohne Detail, ohne IP. Betroffen waren Material,
   Hilfe, Bühne, Reaction und Spenden — also jede Änderung an
   Dogi-Media und jede Maßnahme im Live-Chat, ausgerechnet das,
   wofür es ein Protokoll gibt. Alle 17 berichtigt, und
   pruef-struktur wacht jetzt darüber (mit Gegenprobe).

2. Beim Speichern der Leiter bekam jede Stufe eine NEUE Kennung
   (DELETE + INSERT). Ein Bild-Hochladen gegen die eben noch gültige
   Kennung antwortete mit 404 — im Alltag trifft das jeden, der einen
   zweiten Bildschirm offen hat. Jetzt werden vorhandene Zeilen
   geändert statt ersetzt; das Bild bleibt von selbst daran hängen.

3. `ab_cent` ist eindeutig. Zwei Stufen ihre Beträge tauschen zu
   lassen scheiterte mit „UNIQUE constraint failed", obwohl das
   Ergebnis in Ordnung gewesen wäre: Beim Umschreiben stößt die
   Leiter auf sich selbst. Jetzt in drei Schritten — löschen,
   geparkte Zwischenwerte, endgültige Werte —, und das ist nach
   außen nie sichtbar.

UND DREI, DIE IN MEINER MESSUNG STECKTEN
Die Messung hat eine noch laufende Karte aus dem vorigen Abschnitt
erwischt und daraus drei Fehler gemeldet, die keine waren —
darunter „die Probe läuft im ganzen Saal". Sie zählte außerdem die
versteckten Dateifelder als zu kleine Tippziele. Jetzt räumt sie
vorher auf, wartet auf die Karte MIT DER ERWARTETEN GRÖSSE (die
Karten laufen in einer Schlange — einen Knoten zu entfernen beendet
sie nicht) und lässt die Einblendung zur Ruhe kommen, bevor sie misst.
Ein Bildschirmfoto aus der Einblendphase sah aus, als stünde die
Karte links heraus; nachgemessen: links 18 px, ganz im Bild.

GEMESSEN, NICHT ANGENOMMEN
Karte bei Größe 1: Schrift 16 px, Betrag 25,92 px. Bei Größe 2:
32 px und 51,84 px — Faktor exakt 2,00. Hätte eine einzige Regel noch
in `rem` gestanden, wäre die Karte ungleichmäßig gewachsen, und auf
einem Bild sieht beides nur „größer" aus.

AUCH DAS BILD IST GEPRÜFT
Es liegt am Bühnen-Router und nicht am Spenden-Router: Die
Spendentafel in OBS hat keine Anmeldung, und ein 401 als JSON in
einem `<img>` ergibt ein kaputtes Bild ohne jeden Hinweis. Ohne
Schlüssel, aber mit 128 Bit zufälligem Dateinamen — dieselbe
Größenordnung wie der Bühnenschlüssel, und es ist ein Zierbild, das
ohnehin im Stream steht. Kein Ausbruch aus dem Ordner (vier Wege
geprüft, gemessen wird die Wirkung und nicht der Statuscode).

NACHGETRAGEN AUS BLOCK 4
`reaktion_meldungen` fehlte im Löschkonzept — eine bestehende Prüfung
hat es gefunden. 30 Tage nach dem Erledigen; meistens sind sie
ohnehin früher weg, weil der Live-Chat beim Beenden gelöscht wird und
die Meldungen daran hängen. Wer meldet, muss sich darauf verlassen
können, dass daraus keine dauerhafte Liste wird.

GEPRUEFT
pruef-spenden: 95 Prüfungen (vorher 46), 0 Fehler.
pruef-reaktion 260, pruef-buehne 36, pruef-aufbewahrung 45,
pruef-struktur, pruef-meldungen, pruef-css-klassen, pruef-tippziele,
pruef-deutsche-texte, pruef-auskunft alle grün.
mess-reaktion und mess-buehne: Rückgabewert 0, kein ACHTUNG.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-28 10:14:26 +02:00

367 lines
16 KiB
JavaScript

/* =====================================================================
DER VERTRAULICHE MELDEWEG (19.09.2026)
Die Regeln stehen samt Begründung im Kopf von hilfe-tabellen.js.
Hier sind die Wege, und die drei Stellen, an denen etwas schiefgehen
kann:
1. EIN FALL DARF NIE BEIM FALSCHEN LANDEN. Jede Leseroute fragt
zuerst `darfSehen()`. Es gibt keinen Weg, der eine Fallnummer
ohne diese Frage nimmt -- auch nicht die Antwortroute.
2. DER WECHSEL MUSS ERZWUNGEN SEIN, nicht nur in der Oberfläche
angedeutet. Ein ausgegrauter Knopf ist keine Regel; wer die
Route direkt aufruft, käme daran vorbei.
3. EIN GESCHLOSSENER FALL IST GESCHLOSSEN. Auch für die Leitung --
sonst ist "zugemacht" eine Meinung und keine Tatsache.
===================================================================== */
import express from "express";
import { db, protokolliere } from "./workspace.js";
import { FALL_STAND, AUFBEWAHRUNG_TAGE } from "./hilfe-tabellen.js";
import { benachrichtige } from "./workspace-push.js";
export const hilfeRouter = express.Router();
/** Wer alle Fälle sieht und beantworten darf.
*
* GENAU ZWEI ROLLEN, wie bestellt: DogFather und die rechte Hand.
* Modis ausdrücklich NICHT -- sehr oft geht es in diesen Meldungen um
* eine Moderationsentscheidung, und wer beteiligt ist, darf die
* Beschwerde über sich nicht lesen. */
const LEITUNG = new Set(["admin", "hand"]);
const istLeitung = (person) => LEITUNG.has(person?.rolle);
/** Grenzen. Nicht schön, aber ohne sie ist eine Textbox ein Einfallstor. */
const BETREFF_MAX = 120;
const TEXT_MAX = 4000;
/** Wie viele offene Fälle eine Person gleichzeitig haben darf.
*
* Drei. Nicht als Misstrauen, sondern weil ein Mensch mit acht
* offenen Meldungen keine Antwort mehr zuordnen kann -- und weil der,
* der antwortet, dann keine mehr ernst nimmt. */
const OFFEN_MAX = 3;
const jetzt = () => new Date().toISOString();
function sauber(s, max) {
return String(s ?? "").replace(/\s+/g, " ").trim().slice(0, max);
}
/** Darf diese Person diesen Fall sehen?
*
* Gibt den Fall zurück oder `null`. NIE `true`/`false` -- so kann
* keine Route "darf sehen" beantworten und danach trotzdem den
* falschen Datensatz laden. */
function fallFuer(person, id) {
const fall = db().prepare("SELECT * FROM hilfe_faelle WHERE id = ?").get(Number(id));
if (!fall) return null;
if (istLeitung(person)) return fall;
return fall.person_id === person.id ? fall : null;
}
/* ---------- Was jemand sehen darf ------------------------------------ */
/** Die eigenen Fälle -- 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. */
hilfeRouter.get("/workspace/api/hilfe", (req, res) => {
try {
if (!req.person) return res.status(401).json({ fehler: "nicht_angemeldet" });
const leitung = istLeitung(req.person);
const zeilen = leitung
? db().prepare(`
SELECT f.*, p.name AS melder_name, p.rolle AS melder_rolle,
(SELECT COUNT(*) FROM hilfe_nachrichten n WHERE n.fall_id = f.id) AS nachrichten
FROM hilfe_faelle f
LEFT JOIN personen p ON p.id = f.person_id
ORDER BY CASE f.stand WHEN 'wartet_auf_antwort' THEN 0
WHEN 'antwort_da' THEN 1 ELSE 2 END,
f.geaendert DESC`).all()
: db().prepare(`
SELECT f.*,
(SELECT COUNT(*) FROM hilfe_nachrichten n WHERE n.fall_id = f.id) AS nachrichten
FROM hilfe_faelle f
WHERE f.person_id = ?
ORDER BY CASE f.stand WHEN 'antwort_da' THEN 0
WHEN 'wartet_auf_antwort' THEN 1 ELSE 2 END,
f.geaendert DESC`).all(req.person.id);
res.json({
faelle: zeilen.map((z) => ({
id: z.id,
betreff: z.betreff,
stand: z.stand,
erstellt: z.erstellt,
geaendert: z.geaendert,
nachrichten: z.nachrichten,
/* DER NAME NUR FÜR DIE LEITUNG. Er wird gar nicht erst in die
Antwort geschrieben, wenn jemand anderes fragt -- eine
Oberfläche, die ihn "nicht anzeigt", wäre kein Schutz. */
...(leitung ? { melder: z.melder_name || "(gelöscht)" } : {}),
})),
leitung,
/* Damit die Oberfläche weiß, ob noch ein Fall aufgemacht werden
darf, ohne es durch einen Fehlschlag herauszufinden. */
offen_eigene: leitung ? null : zeilen.filter((z) => z.stand !== FALL_STAND.zu).length,
offen_max: OFFEN_MAX,
/* Damit der Abschluss-Dialog die Frist nennen kann, ohne sie
ein zweites Mal im Browser stehen zu haben. Eine Zahl, die an
zwei Stellen steht, ist morgen an einer davon falsch. */
aufbewahrung_tage: AUFBEWAHRUNG_TAGE,
});
} catch (fehler) {
console.error("[hilfe] Liste:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Ein Fall mit seinem ganzen Verlauf. */
hilfeRouter.get("/workspace/api/hilfe/:id(\\d+)", (req, res) => {
try {
if (!req.person) return res.status(401).json({ fehler: "nicht_angemeldet" });
const fall = fallFuer(req.person, req.params.id);
/* 404 UND NICHT 403: Ein "darfst du nicht" verrät, dass es diesen
Fall gibt. Bei vertraulichen Meldungen ist schon das zu viel. */
if (!fall) return res.status(404).json({ fehler: "nicht_gefunden" });
const leitung = istLeitung(req.person);
const nachrichten = db().prepare(
"SELECT id, von_leitung, text, erstellt, gelesen_am FROM hilfe_nachrichten"
+ " WHERE fall_id = ? ORDER BY id").all(fall.id);
/* GELESEN-STEMPEL SETZEN. Nur für die Nachrichten der jeweils
anderen Seite -- die eigenen hat man selbst geschrieben. */
const fremd = leitung ? 0 : 1;
db().prepare(
"UPDATE hilfe_nachrichten SET gelesen_am = ? WHERE fall_id = ?"
+ " AND von_leitung = ? AND gelesen_am IS NULL").run(jetzt(), fall.id, fremd);
const melder = leitung
? db().prepare("SELECT name FROM personen WHERE id = ?").get(fall.person_id)
: null;
res.json({
fall: {
id: fall.id, betreff: fall.betreff, stand: fall.stand,
erstellt: fall.erstellt, geaendert: fall.geaendert,
geschlossen_am: fall.geschlossen_am,
/* BIS WANN ER NOCH DA IST (20.09.2026).
----------------------------------------------------------
Ein abgeschlossener Fall wird nach 90 Tagen geloescht --
das steht im Loeschkonzept und laeuft taeglich. Bis heute
stand auf der Seite nur „Dieser Fall ist abgeschlossen".
Niemand konnte wissen, dass er verschwindet, und erst recht
nicht, wann.
Das ist keine Kleinigkeit: Wer eine Meldung gemacht hat,
hat ein Recht darauf zu erfahren, wie lange das gespeichert
bleibt (Art. 13 Abs. 2 lit. a DSGVO). Und praktisch braucht
er es auch -- wer nachlesen will, was vereinbart wurde,
soll nicht erst merken, dass es weg ist.
GERECHNET WIRD HIER, nicht im Browser: Sonst stuende
dieselbe Frist an zwei Stellen, und eine davon altert. */
lesbar_bis: fall.geschlossen_am
? new Date(Date.parse(fall.geschlossen_am) + AUFBEWAHRUNG_TAGE * 86400000).toISOString()
: null,
...(leitung ? { melder: melder?.name || "(gelöscht)" } : {}),
},
nachrichten: nachrichten.map((n) => ({
id: n.id, von_leitung: !!n.von_leitung, text: n.text,
erstellt: n.erstellt, gelesen: !!n.gelesen_am,
})),
leitung,
/* WER IST DRAN. Vom Server gerechnet, nicht im Browser -- sonst
steht in zwei Dateien dieselbe Regel, und eine davon altert. */
ich_bin_dran: fall.stand === FALL_STAND.zu ? false
: leitung ? fall.stand === FALL_STAND.wartet
: fall.stand === FALL_STAND.antwort,
});
} catch (fehler) {
console.error("[hilfe] Fall lesen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Einen Fall aufmachen -------------------------------------- */
hilfeRouter.post("/workspace/api/hilfe", express.json(), (req, res) => {
try {
if (!req.person) return res.status(401).json({ fehler: "nicht_angemeldet" });
/* DIE LEITUNG MELDET SICH NICHT SELBST. Sie hat andere Wege --
und ein Fall, den man sich selbst beantwortet, verwirrt die
Liste für alle. */
if (istLeitung(req.person)) {
return res.status(400).json({ fehler: "leitung_meldet_nicht" });
}
const betreff = sauber(req.body?.betreff, BETREFF_MAX);
const text = String(req.body?.text ?? "").trim().slice(0, TEXT_MAX);
if (betreff.length < 3) return res.status(400).json({ fehler: "betreff_fehlt" });
if (text.length < 10) return res.status(400).json({ fehler: "text_zu_kurz" });
const offen = db().prepare(
"SELECT COUNT(*) AS n FROM hilfe_faelle WHERE person_id = ? AND stand != ?")
.get(req.person.id, FALL_STAND.zu).n;
if (offen >= OFFEN_MAX) {
return res.status(429).json({ fehler: "zu_viele_offen", offen, max: OFFEN_MAX });
}
const t = jetzt();
db().prepare("INSERT INTO hilfe_faelle (person_id, betreff, stand, erstellt, geaendert)"
+ " VALUES (?,?,?,?,?)").run(req.person.id, betreff, FALL_STAND.wartet, t, t);
const id = db().prepare("SELECT last_insert_rowid() AS id").get().id;
db().prepare("INSERT INTO hilfe_nachrichten (fall_id, von_id, von_leitung, text, erstellt)"
+ " VALUES (?,?,0,?,?)").run(id, req.person.id, text, t);
/* IM PROTOKOLL STEHT NUR, DASS ES EINEN FALL GIBT -- nicht, worum
es geht. Das Protokoll lesen mehr Leute als den Fall. */
protokolliere("hilfe_fall_neu", { personId: req.person.id, detail: `Fall ${id}` });
leitungBenachrichtigen(id, betreff).catch(() => { /* Push ist Zugabe */ });
res.status(201).json({ id });
} catch (fehler) {
console.error("[hilfe] anlegen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Antworten -------------------------------------------------- */
hilfeRouter.post("/workspace/api/hilfe/:id(\\d+)/antwort", express.json(), (req, res) => {
try {
if (!req.person) return res.status(401).json({ fehler: "nicht_angemeldet" });
const fall = fallFuer(req.person, req.params.id);
if (!fall) return res.status(404).json({ fehler: "nicht_gefunden" });
if (fall.stand === FALL_STAND.zu) {
return res.status(409).json({ fehler: "geschlossen" });
}
const leitung = istLeitung(req.person);
/* DER WECHSEL, ERZWUNGEN. Die Oberfläche blendet den Knopf aus --
das ist Höflichkeit. Diese Zeile ist die Regel. */
const dran = leitung ? fall.stand === FALL_STAND.wartet
: fall.stand === FALL_STAND.antwort;
if (!dran) return res.status(409).json({ fehler: "nicht_dran" });
const text = String(req.body?.text ?? "").trim().slice(0, TEXT_MAX);
if (text.length < 2) return res.status(400).json({ fehler: "text_zu_kurz" });
const t = jetzt();
db().prepare("INSERT INTO hilfe_nachrichten (fall_id, von_id, von_leitung, text, erstellt)"
+ " VALUES (?,?,?,?,?)").run(fall.id, req.person.id, leitung ? 1 : 0, text, t);
db().prepare("UPDATE hilfe_faelle SET stand = ?, geaendert = ? WHERE id = ?")
.run(leitung ? FALL_STAND.antwort : FALL_STAND.wartet, t, fall.id);
if (leitung) {
antwortBenachrichtigen(fall).catch(() => { /* Zugabe */ });
} else {
leitungBenachrichtigen(fall.id, fall.betreff).catch(() => { /* Zugabe */ });
}
res.json({ ok: true });
} catch (fehler) {
console.error("[hilfe] antworten:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Zumachen --------------------------------------------------- */
hilfeRouter.post("/workspace/api/hilfe/:id(\\d+)/schliessen", express.json(), (req, res) => {
try {
if (!req.person) return res.status(401).json({ fehler: "nicht_angemeldet" });
/* NUR DIE LEITUNG. Siehe hilfe-tabellen.js: Ein Fall, den der
Melder selbst zumachen kann, wird in einem schlechten Moment
zugemacht. */
if (!istLeitung(req.person)) return res.status(403).json({ fehler: "nur_leitung" });
const fall = fallFuer(req.person, req.params.id);
if (!fall) return res.status(404).json({ fehler: "nicht_gefunden" });
if (fall.stand === FALL_STAND.zu) return res.json({ ok: true, schon: true });
const t = jetzt();
const wort = String(req.body?.text ?? "").trim().slice(0, TEXT_MAX);
/* EIN SCHLUSSWORT IST ERLAUBT, ABER NICHT PFLICHT. Manche Fälle
enden mit "ist geklärt, danke" -- andere brauchen kein Wort
mehr, und dann ist ein Pflichtfeld nur eine Hürde. */
if (wort.length >= 2) {
db().prepare("INSERT INTO hilfe_nachrichten (fall_id, von_id, von_leitung, text, erstellt)"
+ " VALUES (?,?,1,?,?)").run(fall.id, req.person.id, wort, t);
}
db().prepare("UPDATE hilfe_faelle SET stand = ?, geaendert = ?,"
+ " geschlossen_am = ?, geschlossen_von = ? WHERE id = ?")
.run(FALL_STAND.zu, t, t, req.person.id, fall.id);
protokolliere("hilfe_fall_zu", { personId: req.person.id, detail: `Fall ${fall.id}` });
schlussBenachrichtigen(fall).catch(() => { /* Zugabe */ });
res.json({ ok: true });
} catch (fehler) {
console.error("[hilfe] schliessen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Benachrichtigungen ----------------------------------------- */
/** Die Leitung anstupsen, dass etwas hereingekommen ist.
*
* OHNE DEN BETREFF IM TEXT. Eine Benachrichtigung erscheint auf einem
* Sperrbildschirm, den auch andere sehen -- und der Betreff ist hier
* regelmäßig das Empfindliche. Es steht nur, DASS etwas da ist. */
async function leitungBenachrichtigen(fallId) {
const leute = db().prepare(
"SELECT id FROM personen WHERE rolle IN ('admin','hand') AND aktiv = 1").all();
for (const p of leute) {
await benachrichtige(p.id, "hilfe", {
titel: "Eine vertrauliche Meldung",
text: "Jemand aus der Community braucht etwas.",
ziel: `/workspace/hilfe.html?fall=${fallId}`,
}).catch(() => { /* einer weniger ist kein Grund abzubrechen */ });
}
}
async function antwortBenachrichtigen(fall) {
await benachrichtige(fall.person_id, "hilfe", {
titel: "Antwort auf deine Meldung",
text: "Es gibt eine Antwort für dich.",
ziel: `/workspace/hilfe.html?fall=${fall.id}`,
});
}
async function schlussBenachrichtigen(fall) {
await benachrichtige(fall.person_id, "hilfe", {
titel: "Deine Meldung ist erledigt",
text: "Der Fall wurde abgeschlossen.",
ziel: `/workspace/hilfe.html?fall=${fall.id}`,
});
}
/* ---------- Aufräumen: steht jetzt im Löschkonzept --------------------
Hier stand `hilfeAufraeumen(tage)` -- mit einem Kommentar darüber,
sie werde „beim Start aufgerufen (siehe index.js)". Das war nie
wahr: Gesucht im ganzen Verzeichnis war der einzige Aufrufer ihre
eigene Prüfung. Die Prüfung war grün und bewies nichts über den
Betrieb.
Am 20.09.2026 ist die Regel dorthin gewandert, wo sie sich selbst
durchsetzt: `workspace-aufbewahrung.js`, Eintrag
`hilfe_geschlossen`. Diese Datei ist beides -- die Übersicht, was
wie lange aufgehoben wird, UND das Programm, das es tut. Sie läuft
beim Start und danach täglich.
ZWEI STELLEN WÄREN SCHLIMMER ALS KEINE. Eine zweite Fassung
desselben DELETE hier wäre morgen anders als die dort, und der
Unterschied fiele erst auf, wenn er zählt -- beim Löschen der
empfindlichsten Texte des Hauses. Deshalb ist sie weg, nicht
doppelt.
Die Frist selbst (`AUFBEWAHRUNG_TAGE = 90`) steht unverändert in
`hilfe-tabellen.js`, samt Begründung. */