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.
362 lines
15 KiB
JavaScript
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;
|
|
}
|
|
}
|