Eine Nacht voller Auftraege von Filipe. Der Reihe nach: 1. VERTRAULICH MELDEN -- der groesste neue Teil. "es soll auch eine kategorie also eine hauptkachel geben wo die community leute sich anonym melden koennen wenn sie probleme haben ... rechte hand und dogfather sollen zugriff auf diese anonyme nachrichten haben. also anonym fuer die anderen." WICHTIG, UND ES STEHT SO AUF DER SEITE: "anonym" heisst hier VERTRAULICH. DogFather und die rechte Hand sehen den Namen -- so bestellt und auch richtig, ohne Namen kann man niemandem helfen. Anonym ist es gegenueber allen anderen: kein Modi, kein anderes Mitglied. Wer glaubt, er schreibe unerkannt, schreibt anders und fuehlt sich hinterher getaeuscht -- deshalb steht der Satz im Formular, bevor jemand tippt. Der strikte Wechsel (melden -> warten -> antworten -> warten) ist im SERVER erzwungen, nicht nur im Knopf. Ein ausgegrauter Knopf ist keine Regel. Geschlossen wird nur von der Leitung; abgeschlossene Faelle werden nach 90 Tagen samt Nachrichten geloescht -- hier stehen die empfindlichsten Texte des Hauses. Modis sind ausdruecklich AUSSEN VOR: Sehr oft geht es in diesen Meldungen um eine Moderationsentscheidung. 2. JEDE KATEGORIE LAESST SICH ZUKLAPPEN. "man soll auf dieser seite jede kategorie auf und zu klappen koennen mit einem button ... ueberall wo so eine liste entstehen kann." Das Muster gab es auf der Startseite schon; es steht jetzt als `abschnittKlappbar` in kopf.js und wird von den Brettern und dem Zeitstrahl benutzt. Die Koepfe sind echte <button> (fuer die Tastatur), der Zustand haelt in localStorage, und er haengt je Brett UND Art -- sonst waere "Regel zugeklappt" ueberall gleichzeitig zu. 3. DIE SPRUNGLEISTE SIEHT AUS WIE ETWAS ZUM ANFASSEN. Vorher unterstrichene Woerter, die aussahen wie eine Fusszeile. Jetzt Marken mit Rahmen in einem eigenen Block. 4. DIE STUNDEN SIND LILA. UMGERECHNET, NICHT NEU GEWAEHLT: Jeder der fuenf Verlaufsstopps wurde nach OKLCH zerlegt, der Farbton auf 303 Grad gedreht, zurueckgerechnet. Helligkeit und Buntheit blieben gleich -- eine frei gegriffene Palette waere heller oder bunter geworden, und die Uhr damit unruhiger. 5. DIE KARTEN "UNSERE SEITEN": babyblau und lila, mit schwebenden Logos. Beide Logos wurden zu MASKEN gerechnet (tools/logo-maske.mjs): Die PNG tragen nur Alpha, die Farbe kommt aus dem CSS. Dadurch passt dasselbe Logo in jede Kachel, egal welchen Ton sie hat -- und VanVans 378-KB- Logo wurde dabei zu 109 KB. Vier Stueck je Karte, verschieden gross, zwei Bahnen, 26 bis 41 Sekunden Umlauf. Der erste Versuch war mit 5-9 % Deckkraft praktisch unsichtbar; jetzt 10-16 %. 6. "ENTWICKLUNG" HEISST JETZT "EURE AUFGABEN". Filipe hat recht: Die Kachel fuehrt seit dem 11.09. auf den Katalog -- eine Aufgabenliste. Der Name blieb stehen, weil beim Umhaengen des Ziels niemand ihn angefasst hat. Der Name "Entwicklung" ist damit frei fuer das, was er darunter versteht (Auswertung je Person mit Verlauf und Text) -- das ist noch NICHT gebaut. DREI PRUEFUNGEN, DIE ZUFAELLIG GRUEN WAREN, sind nebenbei aufgefallen: - pruef-kachelraster wartete feste 500 ms auf eine gestaffelte Einblendung und mass vier Reihen, wo drei sind. Jetzt reducedMotion. - pruef-start-ansicht scrollte 600 px und nahm an, danach liege keine Kachel mehr unter dem Zeiger. - pruef-wege-nach-draussen zaehlte das Kachelraster ueber ALLE Gruppen statt je Gruppe. GEMESSEN: pruef-hilfe (neu) 55/0, pruef-wege-nach-draussen 64/0, pruef-jeder-hat-eine-seite 65/0, pruef-community-sicht 10/0, pruef-bereiche-lesend, pruef-steckbrief, pruef-start-ansicht, pruef-kachelraster alle gruen. Co-Authored-By: Claude Opus 5 <[email protected]>
343 lines
14 KiB
JavaScript
343 lines
14 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.
|
|
*
|
|
* Wird beim Start aufgerufen (siehe index.js). 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;
|
|
}
|
|
}
|