Files
dogfather-universe/server/workspace-hilfe.js
T
DogFatherGit c516aad4ed Nichts verschwindet mehr ohne eine Nachfrage, die sagt was passiert
Filipe: "Es darf vor allem keine Stellen geben, an denen ein Benutzer
etwas falsch machen kann, nur weil die Seite es nicht verstaendlich
genug erklaert."

Gemessen: 30 Stellen in 16 Dateien benutzten confirm() oder prompt().
Das Haus hatte die richtige Bauweise laengst -- einen <dialog>, in
aufgaben.html sogar ausfuehrlich begruendet -- aber sie stand IN EINER
SEITE. Wer anderswo etwas loeschen liess, hatte sie nicht.

confirm('Wirklich loeschen?') stellt die falsche Frage: Es fragt, ob
man sicher ist, und nennt nicht, WAS passiert, was BLEIBT und ob es
ZURUECK geht. Jetzt beantwortet jeder der 41 Dialoge alle drei.

Neu: workspace/assets/js/nachfrage.js -- window.frageNach() mit
Pflichtgrund, Zahlenfeld, einzeiliger Eingabe und Abtippsicherung.
Drei Ausgaenge: <dialog> / confirm()-Notnagel fuer Safari vor 15.4 /
Abbruch (Esc, Klick daneben, "Doch nicht" -- immer false).

DREIMAL DERSELBE FALLSTRICK, dreimal nachgemessen statt vermutet:
  .dialog stand in aufgaben.css und leistung.css -> auf dateien.html
    waere der Dialog ein weisser Systemkasten gewesen. 14 Regeln
    klammergenau nach module.css verschoben (Klammern gezaehlt, nicht
    per Muster geschnitten -- heute frueh hat ein nicht-gieriges
    Muster schon einmal CSS zerrissen).
  Das Formular trug .neu neu--blank -- und .neu gibt seine Abstaende
    nur in aufgaben.css. Gemessen: padding 0px, und die Felder
    verloren ihre height:44px. Jetzt steht alles unter
    .nachfrage__form in module.css; der Dialog borgt nichts mehr.
  Die erste Fassung der Pruefung zaehlte nachfrage.js SELBST als
    Nutzer -- damit war jede Seite trivialerweise "Nutzer" und die
    Pruefung gruen ohne Inhalt. Jetzt ausdruecklich ausgenommen.

ZWEI FUNDE NEBENBEI:
  hilfeAufraeumen() wird im Betrieb NIE aufgerufen. Der Kommentar
    behauptete "wird beim Start aufgerufen (siehe index.js)" -- das
    war nie wahr; einziger Aufrufer ist die eigene Pruefung. Folge:
    geschlossene vertrauliche Faelle bleiben unbegrenzt stehen. NICHT
    eingeschaltet (das loescht echte Daten und ist Filipes
    Entscheidung), sondern der Kommentar richtiggestellt.
  Einen Hilfe-Fall zu schliessen ist endgueltig -- es gibt keine
    Route, die ihn wieder oeffnet. Vorher stand darueber nur die
    Frage nach einem Schlusswort. Jetzt sagt der Dialog es.

pruef-struktur hat meine eigene Pruefung von heute Nachmittag
erwischt: Sie bildete ihr Datum aus UTC. Beim Beheben erst
heuteLokal(datum) genommen -- die Funktion nimmt gar kein Argument
und haette still "heute" statt "+3 Tage" geliefert. Jetzt tagLokal(3),
nachgerechnet: Abstand 3 Tage.

Am Bildschirm angesehen (Rechner 1280, Handy 390): passt rein, Esc
ergibt false, Fokus liegt auf dem harmlosen Knopf, Knoepfe 44px auf
Touch. Der Platzhalter im Abtippfeld zeigte den erwarteten Namen --
das sah aus wie ein schon ausgefuelltes Feld, entfernt.

Neu: server/pruef-nachfrage.mjs -- 17/0, mit sechs Gegenproben und
beiden Richtungen (wer fragt, laedt die Datei; wer nie fragt, laedt
sie nicht -- sonst truege die Anmeldewand 4,8 KB fuer nichts).

pruef-meldungen 8/0, pruef-css-klassen gruen, pruef-struktur gruen,
pruef-leistung gruen.
2026-09-19 19:57:43 +02:00

362 lines
15 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 } 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,
});
} 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,
...(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(req.person.id, "hilfe_fall_neu", `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(req.person.id, "hilfe_fall_zu", `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 --------------------------------------------------- */
/** Löscht geschlossene Fälle, die älter sind als die Aufbewahrung.
*
* ACHTUNG, NACHGEMESSEN AM 19.09.2026: Diese Funktion wird im Betrieb
* NICHT aufgerufen. Hier stand vorher "Wird beim Start aufgerufen
* (siehe index.js)" -- das war nie wahr. Gesucht im ganzen Verzeichnis:
* der einzige Aufrufer ist `pruef-hilfe.mjs`, also ihre eigene Prüfung.
*
* Die Prüfung ist deshalb grün und beweist nichts über den Betrieb:
* Sie zeigt, dass die Funktion richtig löscht -- nicht, dass jemand
* sie aufruft. Genau das Muster, vor dem die Hausregeln warnen.
*
* FOLGE: Geschlossene Fälle bleiben derzeit unbegrenzt stehen. Das ist
* die sichere Richtung (nichts geht verloren), aber nicht die
* gewollte -- ein vertraulicher Fall über einen Menschen soll nicht
* ewig gespeichert bleiben.
*
* Das EINZUSCHALTEN löscht echte Daten und ist deshalb keine
* Reparatur, die nebenbei passiert, sondern Filipes Entscheidung:
* Wie lange sollen abgeschlossene Fälle stehen bleiben? Die Prüfung
* rechnet mit 90 Tagen, das ist aber nur ihre Annahme.
*
* Läuft still: Wenn es nichts zu tun gibt, sagt es auch nichts --
* eine Meldung, die jeden Start begleitet, wird nach einer Woche
* nicht mehr gelesen. */
export function hilfeAufraeumen(tage) {
try {
const grenze = new Date(Date.now() - tage * 86400000).toISOString();
const weg = db().prepare(
"DELETE FROM hilfe_faelle WHERE stand = ? AND geschlossen_am IS NOT NULL"
+ " AND geschlossen_am < ?").run(FALL_STAND.zu, grenze);
if (weg.changes > 0) {
console.log(`[hilfe] ${weg.changes} abgeschlossene Fälle nach ${tage} Tagen gelöscht.`);
}
return weg.changes;
} catch (fehler) {
console.error("[hilfe] aufraeumen:", fehler?.message);
return 0;
}
}