/* ===================================================================== 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; } }