Vertraulicher Meldeweg, klappbare Abschnitte, lila Stunden, Logos

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]>
This commit is contained in:
2026-09-19 02:04:03 +02:00
co-authored by Claude Opus 5
parent 63f2b2bc55
commit f62ade3573
56 changed files with 2708 additions and 452 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

+132
View File
@@ -0,0 +1,132 @@
/* =====================================================================
DER VERTRAULICHE MELDEWEG -- Tabellen (19.09.2026)
Filipe: „es soll auch eine kategorie also eine hauptkachel geben wo
die community leute sich anonym melden können wenn sie probleme
haben. so wie ein support sie melden was und müssen dan auf die
antwort warten, dan können die erst antworten dan wieder auf antwort
von mir warten und immer so weiter bis ich es zu mache. rechte hand
und dogfather sollen zugriff auf diese anonyme nachrichten haben.
also anonym für die anderen. keiner soll sie sehen in dem sinne aber
wir sollen sehen wer die geschrieben hat, den namen."
---------------------------------------------------------------------
„ANONYM" HEISST HIER VERTRAULICH -- UND DAS MUSS DRANSTEHEN
Der Melder ist NICHT anonym: DogFather und die rechte Hand sehen
seinen Namen, genau so hat Filipe es bestellt, und es ist auch
richtig so (ohne Namen kann man niemandem helfen und keine Maßnahme
ergreifen). Anonym ist er gegenüber allen ANDEREN -- kein anderes
Mitglied, kein Modi, niemand sonst sieht diese Fälle.
DAS IST EIN UNTERSCHIED, DER AUF DIE SEITE GEHÖRT. Wer glaubt,
niemand wisse, dass er es war, schreibt anders -- und fühlt sich
hinterher getäuscht. Der Satz „DogFather und die rechte Hand sehen
deinen Namen. Sonst niemand." steht deshalb im Formular, bevor man
tippt, und nicht im Kleingedruckten darunter.
---------------------------------------------------------------------
WARUM DER STRIKTE WECHSEL
Filipe will: melden -> warten -> antworten -> warten. Immer
abwechselnd. Das ist keine technische Einschränkung, sondern die
eigentliche Idee:
1. Es sagt IMMER, wer am Zug ist. Ein Verlauf, in den beide
jederzeit schreiben können, endet damit, dass niemand weiß, ob
noch eine Antwort kommt.
2. Es schützt den, der meldet. Wer aufgeregt ist, schreibt
sonst fünf Nachrichten hintereinander -- und liest die Antwort
dann als Reaktion auf die letzte statt auf alles.
3. Es schützt den, der antwortet. Ein Fall mit drei offenen
Fragen wird nicht beantwortet, er wird verschoben.
Geschlossen wird nur von der Leitung. Ein Fall, den der Melder
selbst zumachen kann, wird in einem schlechten Moment zugemacht.
---------------------------------------------------------------------
DIESE DATEI IMPORTIERT NICHTS -- derselbe Grund wie bei
treff-tabellen.js: Sie wird aus dem Bauplan in workspace.js
aufgerufen und darf keinen Kreis erzeugen.
===================================================================== */
/** Die drei Zustände eines Falls.
*
* Sie stehen hier und nicht als Zeichenkette an zwanzig Stellen --
* ein Tippfehler in einem `WHERE status = 'offen'` findet lautlos
* nichts.
*
* „wartet_auf_antwort" Der Melder hat geschrieben, die Leitung ist dran.
* „antwort_da" Die Leitung hat geantwortet, der Melder ist dran.
* „geschlossen" Erledigt. Nur die Leitung setzt das.
*/
export const FALL_STAND = {
wartet: "wartet_auf_antwort",
antwort: "antwort_da",
zu: "geschlossen",
};
/** Wie lange ein geschlossener Fall aufgehoben wird (Tage).
*
* NICHT FÜR IMMER. Hier stehen die empfindlichsten Texte des ganzen
* Hauses -- jemand beschreibt ein Problem, oft mit anderen Menschen
* darin. Was niemand mehr braucht, soll auch nicht mehr da sein.
*
* 90 Tage, weil ein Fall manchmal wieder aufkommt („das ist schon
* wieder passiert") und man dann nachlesen können muss, was damals
* vereinbart wurde. Länger hilft niemandem. */
export const AUFBEWAHRUNG_TAGE = 90;
export function hilfeTabellen(d) {
d.exec(`
/* EIN FALL. Nicht "Ticket" -- das Wort kennt hier niemand.
"betreff" ist kurz und steht in der Liste; der eigentliche Text
ist die erste Nachricht. Das trennt "worum geht es" von "was ist
passiert" und macht eine Liste von zwanzig Fällen lesbar.
"person_id" MIT ON DELETE CASCADE: Wer sein Konto verliert,
verliert auch seine Fälle. Ein Fall ohne Melder ist eine
Beschwerde von niemandem -- daran kann man nichts mehr klären,
und aufheben sollte man ihn erst recht nicht. */
CREATE TABLE IF NOT EXISTS hilfe_faelle (
id INTEGER PRIMARY KEY AUTOINCREMENT,
person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
betreff TEXT NOT NULL,
stand TEXT NOT NULL DEFAULT 'wartet_auf_antwort'
CHECK (stand IN ('wartet_auf_antwort','antwort_da','geschlossen')),
erstellt TEXT NOT NULL,
geaendert TEXT NOT NULL,
geschlossen_am TEXT,
geschlossen_von INTEGER REFERENCES personen(id) ON DELETE SET NULL
);
/* DIE NACHRICHTEN. "von_leitung" statt eines Rollennamens: Wer
geantwortet hat, ist für den Melder unerheblich -- er soll nicht
überlegen müssen, ob eine Antwort von DogFather mehr gilt als
eine von der rechten Hand. Für das Protokoll bleibt "von_id".
Das ist ausserdem stabil gegen Rollenwechsel: Wird jemand später
etwas anderes, bleibt seine Antwort trotzdem eine der Leitung. */
CREATE TABLE IF NOT EXISTS hilfe_nachrichten (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fall_id INTEGER NOT NULL REFERENCES hilfe_faelle(id) ON DELETE CASCADE,
von_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
von_leitung INTEGER NOT NULL DEFAULT 0,
text TEXT NOT NULL,
erstellt TEXT NOT NULL,
/* Wann der jeweils andere es gelesen hat. Nur dafür da, dem
Melder zu zeigen, dass seine Meldung angekommen ist -- das ist
die erste Frage, die jemand hat, der etwas Unangenehmes
geschrieben hat. */
gelesen_am TEXT
);
CREATE INDEX IF NOT EXISTS idx_hilfe_faelle_person
ON hilfe_faelle (person_id, geaendert DESC);
CREATE INDEX IF NOT EXISTS idx_hilfe_faelle_stand
ON hilfe_faelle (stand, geaendert DESC);
CREATE INDEX IF NOT EXISTS idx_hilfe_nachrichten_fall
ON hilfe_nachrichten (fall_id, id);
`);
}
+2
View File
@@ -30,6 +30,7 @@ import { aufbewahrungRouter, aufbewahrungStarten } from "./workspace-aufbewahrun
import { auskunftRouter } from "./workspace-auskunft.js";
import { uebernahmeRouter } from "./workspace-uebernahme.js";
import { befindenRouter } from "./workspace-befinden.js";
import { hilfeRouter } from "./workspace-hilfe.js";
import { reportRouter } from "./workspace-reports.js";
import { scoutRouter } from "./workspace-scouts.js";
import { callRouter } from "./workspace-calls.js";
@@ -292,6 +293,7 @@ app.use(aufbewahrungRouter);
app.use(auskunftRouter);
app.use(uebernahmeRouter);
app.use(befindenRouter);
app.use(hilfeRouter);
app.use(bewerbungRouter);
app.use(reportRouter);
app.use(scoutRouter);
+402
View File
@@ -0,0 +1,402 @@
/* =====================================================================
DER VERTRAULICHE MELDEWEG -- 19.09.2026
Filipe: "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."
HIER LIEGEN DIE EMPFINDLICHSTEN TEXTE DES GANZEN HAUSES. Jemand
beschreibt ein Problem, oft mit anderen Menschen darin. Diese
Pruefung fragt deshalb vor allem eines: Kann irgendjemand etwas
sehen, das nicht fuer ihn ist?
DREI SORTEN FEHLER WERDEN GESUCHT:
1. EIN FALL BEIM FALSCHEN. Ein zweites Mitglied darf einen fremden
Fall weder in der Liste noch ueber die Fallnummer sehen. Auch ein
Modi nicht -- sehr oft geht es genau um eine
Moderationsentscheidung.
2. DER WECHSEL LAESST SICH UMGEHEN. Die Oberflaeche blendet den
Knopf aus; wer die Route direkt aufruft, kaeme daran vorbei. Die
Regel muss im Server stehen, und genau das wird hier geprueft --
nicht, ob ein Knopf verschwindet.
3. EIN GESCHLOSSENER FALL NIMMT NOCH NACHRICHTEN AN. Dann ist
"zugemacht" eine Meinung und keine Tatsache.
Aufrufen mit: node server/pruef-hilfe.mjs
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { request as httpAnfrage } from "node:http";
import { portMussFreiSein } from "./helfer-port.mjs";
const PORT = await portMussFreiSein(4188, "pruef-hilfe");
const ordner = mkdtempSync(join(tmpdir(), "ws-hilfe-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00";
const express = (await import("express")).default;
const ec = express.response.cookie;
express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); };
import { notbremse } from "./helfer-notbremse.mjs";
await import("./index.js");
notbremse(150_000, "pruef-hilfe");
await new Promise((r) => setTimeout(r, 700));
process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
let fehler = 0, geprueft = 0;
const melde = (t) => console.log(t);
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const CREW = "crew.dogfather-universe.com";
function roh(pfad, host, kopf = {}, koerper = null, methode = null) {
return new Promise((fertig, schief) => {
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad,
method: methode || (koerper ? "POST" : "GET"),
headers: { Host: host, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf },
}, (antwort) => {
let text = "";
antwort.on("data", (s) => { text += s; });
antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers }));
});
a.on("error", schief);
if (koerper) a.write(JSON.stringify(koerper));
a.end();
});
}
const json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } };
const { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes } = await import("node:crypto");
const LEUTE = [
["Filipe", "admin", "CODE-DOGI-0001"],
["Rieke", "hand", "CODE-HAND-0001"],
["Kessi", "modi", "CODE-MODI-0001"],
["Mara", "gast", "CODE-GAST-0001"],
["Tim", "gast", "CODE-GAST-0002"],
];
{
const d = new DatabaseSync(process.env.WORKSPACE_DB);
for (const [name, rolle, code] of LEUTE) {
const salz = randomBytes(16).toString("hex");
const hash = scryptSync(code, salz, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt)"
+ " VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salz, 32768, new Date().toISOString());
}
d.close();
}
async function anmelden(rolle, code) {
let a = await roh("/workspace/api/anmelden", CREW, {}, { rolle, code });
if (a.code === 400 && a.text.includes("alter_offen")) {
a = await roh("/workspace/api/anmelden", CREW, {}, { rolle, code, alter_ok: true });
}
return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
}
const k = {};
k.dogi = await anmelden("admin", "CODE-DOGI-0001");
k.hand = await anmelden("hand", "CODE-HAND-0001");
k.modi = await anmelden("modi", "CODE-MODI-0001");
k.mara = await anmelden("gast", "CODE-GAST-0001");
k.tim = await anmelden("gast", "CODE-GAST-0002");
/* =======================================================================
1. WER DIE SEITE UEBERHAUPT OEFFNEN DARF
======================================================================= */
melde("");
melde("=== 1. Die Tuer ===");
{
for (const [wer, keks, soll] of [
["Mara (Mitglied)", k.mara, 200],
["DogFather", k.dogi, 200],
["die rechte Hand", k.hand, 200],
]) {
const a = await roh("/workspace/hilfe.html", CREW, { cookie: keks });
ok(a.code === soll, `${wer}: ${a.code}`);
}
/* MODIS AUSDRUECKLICH NICHT -- sehr oft geht es um sie. */
const m = await roh("/workspace/hilfe.html", CREW, { cookie: k.modi });
ok(m.code === 302 || m.code === 403, `ein Modi kommt NICHT hinein (${m.code})`);
const o = await roh("/workspace/hilfe.html", CREW);
ok(o.code === 302 || o.code === 401 || o.code === 403, `ohne Anmeldung nicht (${o.code})`);
}
/* =======================================================================
2. MELDEN
======================================================================= */
melde("");
melde("=== 2. Eine Meldung aufmachen ===");
let fallId = 0;
{
const zuKurz = await roh("/workspace/api/hilfe", CREW, { cookie: k.mara },
{ betreff: "Hm", text: "kurz" });
ok(zuKurz.code === 400, `zu kurz wird abgelehnt (${zuKurz.code})`);
const a = await roh("/workspace/api/hilfe", CREW, { cookie: k.mara }, {
betreff: "Jemand geht mich im Chat immer wieder an",
text: "Seit einer Woche schreibt mir dieselbe Person nach jedem Stream. "
+ "Ich habe schon geblockt, dann kommt ein neuer Zugang.",
});
ok(a.code === 201, `Mara kann melden (${a.code})`);
fallId = json(a).id;
ok(Number.isInteger(fallId) && fallId > 0, `der Fall hat eine Nummer (${fallId})`);
/* DIE LEITUNG MELDET SICH NICHT SELBST. */
const d = await roh("/workspace/api/hilfe", CREW, { cookie: k.dogi },
{ betreff: "Test von DogFather", text: "Das sollte nicht gehen, denke ich." });
ok(d.code === 400, `DogFather kann keinen Fall aufmachen (${d.code})`);
}
/* =======================================================================
3. WER SIEHT WAS -- der wichtigste Abschnitt
======================================================================= */
melde("");
melde("=== 3. Vertraulich heisst vertraulich ===");
{
const mara = json(await roh("/workspace/api/hilfe", CREW, { cookie: k.mara }));
ok(mara.faelle?.length === 1, `Mara sieht ihren Fall (${mara.faelle?.length})`);
ok(mara.leitung === false, "und ist nicht Leitung");
/* IHR EIGENER NAME KOMMT NICHT MIT -- sie weiss, wer sie ist. Das
Feld gibt es fuer sie gar nicht, also kann es auch nicht
versehentlich angezeigt werden. */
ok(!("melder" in (mara.faelle?.[0] || {})),
"in ihrer Antwort steht kein Melder-Feld");
/* EIN ZWEITES MITGLIED SIEHT NICHTS. Das ist der Kern. */
const tim = json(await roh("/workspace/api/hilfe", CREW, { cookie: k.tim }));
ok(tim.faelle?.length === 0, `Tim sieht Maras Fall NICHT (${tim.faelle?.length})`);
const timDirekt = await roh(`/workspace/api/hilfe/${fallId}`, CREW, { cookie: k.tim });
/* 404 UND NICHT 403: Ein "darfst du nicht" verriete, dass es den
Fall gibt. */
ok(timDirekt.code === 404, `und auch nicht ueber die Nummer (${timDirekt.code})`);
/* EIN MODI AUCH NICHT. */
const modi = await roh(`/workspace/api/hilfe/${fallId}`, CREW, { cookie: k.modi });
ok(modi.code === 404 || modi.code === 302 || modi.code === 403,
`ein Modi kommt nicht an den Fall (${modi.code})`);
/* DIE LEITUNG SIEHT IHN -- MIT NAMEN. Genau so bestellt. */
const dogi = json(await roh("/workspace/api/hilfe", CREW, { cookie: k.dogi }));
ok(dogi.faelle?.length === 1, `DogFather sieht den Fall (${dogi.faelle?.length})`);
ok(dogi.faelle?.[0]?.melder === "Mara",
`und den Namen dazu (${dogi.faelle?.[0]?.melder})`);
const hand = json(await roh("/workspace/api/hilfe", CREW, { cookie: k.hand }));
ok(hand.faelle?.[0]?.melder === "Mara", "die rechte Hand ebenso");
}
/* =======================================================================
4. DER WECHSEL -- und zwar im Server, nicht im Knopf
======================================================================= */
melde("");
melde("=== 4. Immer abwechselnd ===");
{
/* Mara ist gerade NICHT dran -- sie hat gerade geschrieben. */
const nochmal = await roh(`/workspace/api/hilfe/${fallId}/antwort`, CREW,
{ cookie: k.mara }, { text: "Und noch etwas dazu." });
ok(nochmal.code === 409,
`Mara kann nicht zweimal hintereinander schreiben (${nochmal.code})`);
const d = json(await roh(`/workspace/api/hilfe/${fallId}`, CREW, { cookie: k.mara }));
ok(d.ich_bin_dran === false, "und der Server sagt ihr das auch");
/* Die Leitung antwortet. */
const antwort = await roh(`/workspace/api/hilfe/${fallId}/antwort`, CREW,
{ cookie: k.dogi }, { text: "Danke, dass du dich meldest. Wie heisst der Zugang?" });
ok(antwort.code === 200, `DogFather antwortet (${antwort.code})`);
/* Jetzt ist die Leitung NICHT mehr dran. */
const nochmal2 = await roh(`/workspace/api/hilfe/${fallId}/antwort`, CREW,
{ cookie: k.hand }, { text: "Ergaenzung." });
ok(nochmal2.code === 409,
`auch die Leitung kann nicht zweimal (${nochmal2.code})`);
/* Und Mara schon. */
const maraJetzt = json(await roh(`/workspace/api/hilfe/${fallId}`, CREW, { cookie: k.mara }));
ok(maraJetzt.ich_bin_dran === true, "Mara ist jetzt dran");
const maraAntwort = await roh(`/workspace/api/hilfe/${fallId}/antwort`, CREW,
{ cookie: k.mara }, { text: "Der Zugang heisst xyz123." });
ok(maraAntwort.code === 200, `und kann antworten (${maraAntwort.code})`);
/* GEGENPROBE: Tim kann in einen fremden Fall nichts schreiben. */
const timSchreibt = await roh(`/workspace/api/hilfe/${fallId}/antwort`, CREW,
{ cookie: k.tim }, { text: "Ich mische mich mal ein." });
ok(timSchreibt.code === 404, `Tim kann nicht hineinschreiben (${timSchreibt.code})`);
}
/* =======================================================================
5. ZUMACHEN
======================================================================= */
melde("");
melde("=== 5. Zumachen kann nur die Leitung ===");
{
const mara = await roh(`/workspace/api/hilfe/${fallId}/schliessen`, CREW,
{ cookie: k.mara }, { text: "Passt schon." });
ok(mara.code === 403, `Mara kann ihren Fall NICHT schliessen (${mara.code})`);
const zu = await roh(`/workspace/api/hilfe/${fallId}/schliessen`, CREW,
{ cookie: k.hand }, { text: "Zugang gesperrt, melde dich wenn wieder etwas ist." });
ok(zu.code === 200, `die rechte Hand schliesst ihn (${zu.code})`);
/* EIN GESCHLOSSENER FALL IST GESCHLOSSEN -- auch fuer die Leitung. */
const danach = await roh(`/workspace/api/hilfe/${fallId}/antwort`, CREW,
{ cookie: k.dogi }, { text: "Noch ein Nachtrag." });
ok(danach.code === 409, `danach nimmt er nichts mehr an (${danach.code})`);
const maraDanach = await roh(`/workspace/api/hilfe/${fallId}/antwort`, CREW,
{ cookie: k.mara }, { text: "Doch noch etwas." });
ok(maraDanach.code === 409, `auch von Mara nicht (${maraDanach.code})`);
const d = json(await roh(`/workspace/api/hilfe/${fallId}`, CREW, { cookie: k.mara }));
ok(d.fall?.stand === "geschlossen", `der Stand steht auf geschlossen (${d.fall?.stand})`);
ok(d.nachrichten?.length === 4,
`vier Nachrichten im Verlauf, das Schlusswort mitgezaehlt (${d.nachrichten?.length})`);
}
/* =======================================================================
6. DIE GRENZE FUER OFFENE FAELLE
======================================================================= */
melde("");
melde("=== 6. Drei offene sind genug ===");
{
let letzte = 0;
for (let i = 1; i <= 4; i++) {
const a = await roh("/workspace/api/hilfe", CREW, { cookie: k.tim }, {
betreff: `Sache Nummer ${i}`,
text: `Das ist die ${i}. Meldung und sie ist lang genug fuer die Pruefung.`,
});
letzte = a.code;
if (i <= 3) ok(a.code === 201, `Meldung ${i} geht durch (${a.code})`);
}
ok(letzte === 429, `die vierte wird abgelehnt (${letzte})`);
/* UND NACH DEM SCHLIESSEN GEHT WIEDER EINE. Ohne diese Zeile waere
die Grenze eine Sackgasse statt einer Bremse. */
const tim = json(await roh("/workspace/api/hilfe", CREW, { cookie: k.tim }));
const ersterOffener = tim.faelle?.[tim.faelle.length - 1]?.id;
await roh(`/workspace/api/hilfe/${ersterOffener}/schliessen`, CREW, { cookie: k.dogi }, {});
const wieder = await roh("/workspace/api/hilfe", CREW, { cookie: k.tim }, {
betreff: "Jetzt wieder eine",
text: "Nachdem einer geschlossen wurde, sollte das hier wieder gehen.",
});
ok(wieder.code === 201, `nach dem Schliessen geht wieder eine (${wieder.code})`);
}
/* =======================================================================
7. AUFRAEUMEN
======================================================================= */
melde("");
melde("=== 7. Was niemand mehr braucht, verschwindet ===");
{
const { hilfeAufraeumen } = await import("./workspace-hilfe.js");
const { db } = await import("./workspace.js");
/* Einen geschlossenen Fall kuenstlich altern lassen. KEIN echtes
Warten -- eine Pruefung, die 90 Tage braucht, ist keine. */
const alt = new Date(Date.now() - 100 * 86400000).toISOString();
db().prepare("UPDATE hilfe_faelle SET geschlossen_am = ? WHERE id = ?").run(alt, fallId);
const vorher = db().prepare("SELECT COUNT(*) AS n FROM hilfe_faelle").get().n;
const weg = hilfeAufraeumen(90);
const nachher = db().prepare("SELECT COUNT(*) AS n FROM hilfe_faelle").get().n;
ok(weg === 1 && nachher === vorher - 1,
`ein alter, geschlossener Fall wird geloescht (${weg}, ${vorher} -> ${nachher})`);
/* UND DIE NACHRICHTEN MIT IHM. Ein Fall ohne Text waere geloescht,
die Texte laegen aber weiter in der Datenbank -- genau das, was
man beim Aufraeumen vermeiden will. */
const reste = db().prepare(
"SELECT COUNT(*) AS n FROM hilfe_nachrichten WHERE fall_id = ?").get(fallId).n;
ok(reste === 0, `und seine ${4 - reste} Nachrichten ebenfalls (${reste} uebrig)`);
/* GEGENPROBE: Ein OFFENER Fall wird nicht angefasst, egal wie alt. */
const offen = db().prepare("SELECT COUNT(*) AS n FROM hilfe_faelle WHERE stand != 'geschlossen'").get().n;
const weg2 = hilfeAufraeumen(0);
const offenDanach = db().prepare("SELECT COUNT(*) AS n FROM hilfe_faelle WHERE stand != 'geschlossen'").get().n;
ok(offen === offenDanach && offen > 0,
`offene Faelle bleiben, auch bei Aufbewahrung 0 (${offen} vorher, ${offenDanach} danach, ${weg2} geschlossene weg)`);
}
/* =======================================================================
8. IM BROWSER
======================================================================= */
melde("");
melde("=== 8. Im Browser ===");
{
let pw = null;
try {
pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
} catch { /* dritter Ausgang */ }
if (!pw) {
melde(" -- Playwright ist nicht da -- im Browser wurde NICHT nachgesehen.");
} else {
const browser = await pw.chromium.launch();
try {
for (const [name, keks, breite] of [
["Mara", k.mara, 1400], ["Mara am Handy", k.mara, 412], ["DogFather", k.dogi, 1400],
]) {
const ctx = await browser.newContext({
viewport: { width: breite, height: 950 },
isMobile: breite < 700, hasTouch: breite < 700,
reducedMotion: "reduce",
});
await ctx.addCookies(keks.split("; ").filter(Boolean).map((z) => {
const [n, ...r] = z.split("=");
return { name: n, value: r.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await ctx.newPage();
const kaputt = [];
seite.on("pageerror", (e) => kaputt.push(String(e).slice(0, 140)));
await seite.goto(`http://127.0.0.1:${PORT}/workspace/hilfe.html`, { waitUntil: "networkidle" });
await seite.waitForTimeout(900);
const z = await seite.evaluate(() => ({
titel: document.getElementById("seiten-titel")?.textContent?.trim() || "",
hinweis: document.getElementById("vertrauen-text")?.textContent?.trim() || "",
neuSichtbar: !document.getElementById("neu-block")?.hidden,
zeilen: document.querySelectorAll(".fall-zeile").length,
/* Nichts darf ueber den rechten Rand laufen. */
ueber: Math.max(0, document.documentElement.scrollWidth - window.innerWidth),
}));
ok(z.titel.length > 0, `${name}: die Seite hat einen Titel (${z.titel})`);
/* DER HINWEIS MUSS DA SEIN, IMMER. Er ist der Unterschied
zwischen "vertraulich" und einem falschen Versprechen. */
ok(/sonst niemand|nur du und die rechte Hand/i.test(z.hinweis),
" der Hinweis, wer mitliest, steht da");
ok(z.ueber === 0, ` nichts laeuft ueber den Rand (${z.ueber} px)`);
ok(kaputt.length === 0,
` kein Skriptfehler${kaputt.length ? ": " + kaputt.join(" | ") : ""}`);
if (name.startsWith("Mara")) {
ok(z.neuSichtbar, " sie kann etwas Neues melden");
} else {
ok(!z.neuSichtbar, " DogFather bekommt KEIN Melde-Formular");
ok(z.zeilen > 0, ` und sieht die Meldungen (${z.zeilen})`);
}
await ctx.close();
}
} finally {
await browser.close();
}
}
}
melde("");
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Rest */ }
process.exit(fehler ? 1 : 0);
+23 -5
View File
@@ -142,11 +142,29 @@ const { bereicheFuer, TREFF_BRETTER } = await import("./workspace.js");
ok(TREFF_BRETTER.length === 7,
`es bleiben genau die sieben Bretter (${TREFF_BRETTER.join(", ")})`);
/* DAS RASTER. Drei Spalten, „Der Treff“ belegt zwei -- die Summe
muss durch drei teilbar sein, sonst klafft eine Luecke. */
const plaetze = TREFF_BEREICHE.reduce((n, k) => n + (k.gross ? 2 : 1), 0);
ok(plaetze % 3 === 0,
`das Raster geht auf: ${TREFF_BEREICHE.length} Kacheln = ${plaetze} Plaetze, ${plaetze / 3} volle Reihen`);
/* DAS RASTER -- JE GRUPPE, NICHT UEBER ALLES.
Der erste Entwurf zaehlte alle Kacheln zusammen und meldete ein
Loch, sobald die Community eine zweite Gruppe bekam (Fuer dich).
Das war ein Fehler der Pruefung, nicht der Oberflaeche: Jede
Gruppe hat ihr EIGENES Raster und faengt in einer neuen Zeile an.
Eine freie Zelle am ENDE einer Gruppe ist erlaubt -- ein Loch in
der MITTE nicht. Geprueft wird deshalb die Gruppe mit den sieben
Brettern: Dort haette eine falsch einsortierte breite Kachel eine
Luecke mitten hinein geschoben. */
const jeGruppe = new Map();
for (const x of TREFF_BEREICHE) {
if (!jeGruppe.has(x.gruppe)) jeGruppe.set(x.gruppe, []);
jeGruppe.get(x.gruppe).push(x);
}
const treffGruppe = jeGruppe.get("Community") || [];
const plaetze = treffGruppe.reduce((n, k) => n + (k.gross ? 2 : 1), 0);
ok(treffGruppe.length > 0 && plaetze % 3 === 0,
`die Community-Gruppe geht auf: ${treffGruppe.length} Kacheln = ${plaetze} Plaetze,`
+ ` ${plaetze / 3} volle Reihen`);
ok(treffGruppe.findIndex((k) => k.gross) === 0,
"und die doppelt breite Kachel steht an erster Stelle");
/* EIGENER TON. Zwei Kacheln mit derselben Farbe nebeneinander sind
auf einem Bildschirmfoto nicht zu unterscheiden. */
+18
View File
@@ -380,6 +380,24 @@ export const SEITEN = {
nichts zu tun. */
"/workspace/unsere-seiten.html": ["gast", "modi", "hand", "admin"],
/* DER VERTRAULICHE MELDEWEG (19.09.2026).
Filipe: "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."
DREI ROLLEN, UND MODIS AUSDRUECKLICH NICHT. Sehr oft geht es in
diesen Meldungen um eine Moderationsentscheidung -- wer beteiligt
ist, darf die Beschwerde ueber sich nicht lesen. Das ist keine
Geringschaetzung der Modis, sondern dieselbe Regel, die auch
anderswo gilt: Niemand prueft sich selbst.
WAS AUF DER SEITE ZU SEHEN IST, entscheidet nicht diese Zeile,
sondern `fallFuer` in workspace-hilfe.js: Ein Mitglied sieht seine
eigenen Faelle, die Leitung alle. */
"/workspace/hilfe.html": ["gast", "hand", "admin"],
/* WER SIEHT WAS. DogFather und die rechte Hand (Filipe, 11.09.2026:
"ich will die kategorie auch auf der team dogi seite fuer die
dogfather und rechte hand rollen").
+342
View File
@@ -0,0 +1,342 @@
/* =====================================================================
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;
}
}
+11
View File
@@ -141,6 +141,17 @@ export const ARTEN = [
Einstellungen. Das ist der Unterschied zwischen einer Vorgabe und
einem Zwang. */
{ schluessel: "befinden", name: "Alle zwei Wochen: wie geht es dir?", vorgabe: true },
/* DER VERTRAULICHE MELDEWEG (19.09.2026).
Sie geht in BEIDE Richtungen: An die Leitung, wenn etwas
hereinkommt -- und an den Melder, wenn eine Antwort da ist. Genau
das ist der Punkt: Wer etwas Unangenehmes geschrieben hat, will
nicht stuendlich nachsehen muessen, ob schon jemand geantwortet
hat.
IM TEXT STEHT NIE, WORUM ES GEHT. Eine Benachrichtigung erscheint
auf einem Sperrbildschirm, den auch andere sehen. */
{ schluessel: "hilfe", name: "Vertrauliche Meldung: neu oder beantwortet", vorgabe: true },
{ schluessel: "test", name: "Probe-Benachrichtigung", vorgabe: true },
];
const ARTEN_SCHLUESSEL = new Set(ARTEN.map((a) => a.schluessel));
+42 -3
View File
@@ -32,6 +32,7 @@ import { darfSeite, OHNE_ANMELDUNG, seitenFuer } from "./rechte.js";
obwohl workspace-treff.js seinerseits aus dieser Datei importiert.
Begruendung im Kopf von treff-tabellen.js. */
import { treffTabellen, treffSperreLesen, MINDESTALTER } from "./treff-tabellen.js";
import { hilfeTabellen } from "./hilfe-tabellen.js";
import { sitzungPasstZurAdresse, istOhneModiAdresse, istCrewAdresse, istPruefAdresse, AUSSEN_ROLLEN,
TEAM_DOGI_ROLLEN } from "./crew-adresse.js";
/* Weitergereicht, damit die Fachmodule sie wie alles andere aus
@@ -720,9 +721,27 @@ const FUEHRUNG_GRUPPE = {
gruppeUnter: "Was ihr über die Zeit beobachtet – und wer dazukommen könnte",
};
/* UMBENANNT AM 19.09.2026 -- von "Entwicklung" zu "Eure Aufgaben".
Filipe: "diese kategorie ist ja die aufgaben die ich an die modis
habe, also eher aufgabe und dan perfektionierst du eine neue wo
diagramme texte und so zu jedem modi gemacht wird und diese
kategorie kannst du entwicklung nenen."
Er hat recht, und der Name war seit dem 11.09. falsch: Damals fuehrte
die Kachel auf das Notizbrett (dort standen wirklich Beobachtungen),
seitdem fuehrt sie auf den KATALOG -- eine Liste dessen, was ein Modi
koennen soll. Das ist eine Aufgabenliste, und sie hiess weiter
"Entwicklung", weil beim Umhaengen des Ziels niemand den Namen
angefasst hat.
DER NAME "ENTWICKLUNG" WIRD DAMIT FREI fuer das, was Filipe wirklich
darunter versteht: eine Auswertung je Person, mit Verlauf und Text.
Die gibt es noch nicht -- sie ist der naechste Schritt und
ausdruecklich nicht diese Kachel. */
const ENTWICKLUNG_KACHEL = {
...FUEHRUNG_GRUPPE,
name: "Entwicklung", unter: "Was gut läuft, woran ihr arbeitet – je Person",
name: "Eure Aufgaben", unter: "Was ihr könnt – und was als Nächstes kommt",
/* SEIT DEM 11.09.2026 FUEHRT DIE KACHEL AUF DEN KATALOG, nicht mehr
auf das Notizbrett. Filipe: "ich will dass fertige aufgaben da
stehen und so". Das Brett gibt es weiter -- es ist von der
@@ -1022,6 +1041,22 @@ export const TREFF_BRETTER = TREFF_BEREICHE
Spicy Media haben mit den Zuschauern dieses Streams nichts zu tun. */
export const TREFF_ROLLEN = new Set(["gast", "modi", "hand", "admin"]);
/* DER VERTRAULICHE MELDEWEG (19.09.2026).
Eigene Gruppe, nicht bei den Brettern: Auf den sieben Brettern
schreibt man oeffentlich. Hier schreibt man an genau zwei Menschen.
Zwischen den anderen Kacheln wuerde das verschwimmen -- und wer ein
Problem hat, sucht nicht lange.
SIE STEHT VOR "FUER DICH": Ein Problem ist dringender als der eigene
Steckbrief. Die Reihenfolge innerhalb der Gruppe "Fuer dich" ergibt
sich aus der Liste, die Sortierung in start.js fasst beide ans Ende. */
const HILFE_KACHEL = {
gruppe: "Für dich", gruppeUnter: "Deine eigene Seite",
name: "Vertraulich melden", unter: "Probleme, die niemand sonst sieht",
zeichen: "schutz", ton: 39, ziel: "hilfe.html", szene: "studio",
};
/* DAS TEAM SIEHT DEN TREFF MIT -- als eigene Gruppe unter seinen
uebrigen Kacheln.
@@ -1033,7 +1068,8 @@ export const TREFF_ROLLEN = new Set(["gast", "modi", "hand", "admin"]);
dort waere die Stelle, an der beim naechsten neuen Brett eines fehlt. */
const MODI_MIT_TREFF = [...MODI_BEREICHE, BEFINDEN_KACHEL, ...TREFF_BEREICHE,
TREFF_MODERATION_KACHEL];
const HAND_MIT_TREFF = [...HAND_BEREICHE, ...TREFF_BEREICHE, TREFF_MODERATION_KACHEL];
const HAND_MIT_TREFF = [...HAND_BEREICHE, ...TREFF_BEREICHE,
TREFF_MODERATION_KACHEL, HILFE_KACHEL];
/* AUSSEN_ROLLEN kommt aus crew-adresse.js und wird hier nur
weitergereicht -- die Adressregel braucht sie zuerst, und diese Datei
@@ -1094,7 +1130,7 @@ const STECKBRIEF_KACHEL_TREFF = {
};
/* Die Community: der Treff und ihre eigene Seite. Sonst nichts. */
const TREFF_MIT_EIGENEM = [...TREFF_BEREICHE, STECKBRIEF_KACHEL_TREFF];
const TREFF_MIT_EIGENEM = [...TREFF_BEREICHE, HILFE_KACHEL, STECKBRIEF_KACHEL_TREFF];
export function bereicheFuer(person) {
if (person?.rolle === "modi") return MODI_MIT_TREFF;
@@ -3992,6 +4028,9 @@ export function db() {
und ueber einen Kreis kommen Konstanten als undefined an, ohne
dass irgendwo ein Fehler erscheint. */
treffTabellen(d);
/* Der vertrauliche Meldeweg (19.09.2026). Haengt an personen und
kommt deshalb hierher, nicht in den Bauplan darueber. */
hilfeTabellen(d);
umstellungen(d);
_db = d;
return d;