diff --git a/assets/img/marke/maske-dogi.png b/assets/img/marke/maske-dogi.png new file mode 100644 index 00000000..8c7cd041 Binary files /dev/null and b/assets/img/marke/maske-dogi.png differ diff --git a/assets/img/marke/maske-vanvan.png b/assets/img/marke/maske-vanvan.png new file mode 100644 index 00000000..a763dfb9 Binary files /dev/null and b/assets/img/marke/maske-vanvan.png differ diff --git a/assets/img/marke/vanvan-logo-quelle.png b/assets/img/marke/vanvan-logo-quelle.png new file mode 100644 index 00000000..7c4505df Binary files /dev/null and b/assets/img/marke/vanvan-logo-quelle.png differ diff --git a/server/_foto-logos.png b/server/_foto-logos.png new file mode 100644 index 00000000..7d01a224 Binary files /dev/null and b/server/_foto-logos.png differ diff --git a/server/hilfe-tabellen.js b/server/hilfe-tabellen.js new file mode 100644 index 00000000..340bf38f --- /dev/null +++ b/server/hilfe-tabellen.js @@ -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); + `); +} diff --git a/server/index.js b/server/index.js index 5a719d54..a549edcf 100644 --- a/server/index.js +++ b/server/index.js @@ -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); diff --git a/server/pruef-hilfe.mjs b/server/pruef-hilfe.mjs new file mode 100644 index 00000000..cf18eed1 --- /dev/null +++ b/server/pruef-hilfe.mjs @@ -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); diff --git a/server/pruef-wege-nach-draussen.mjs b/server/pruef-wege-nach-draussen.mjs index ef317248..50a53b8d 100644 --- a/server/pruef-wege-nach-draussen.mjs +++ b/server/pruef-wege-nach-draussen.mjs @@ -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. */ diff --git a/server/rechte.js b/server/rechte.js index 92c64b09..09754538 100644 --- a/server/rechte.js +++ b/server/rechte.js @@ -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"). diff --git a/server/workspace-hilfe.js b/server/workspace-hilfe.js new file mode 100644 index 00000000..794bd695 --- /dev/null +++ b/server/workspace-hilfe.js @@ -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; + } +} diff --git a/server/workspace-push.js b/server/workspace-push.js index f5746609..f93a46fd 100644 --- a/server/workspace-push.js +++ b/server/workspace-push.js @@ -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)); diff --git a/server/workspace.js b/server/workspace.js index 0ca66858..0db3a165 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -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; diff --git a/tools/logo-maske.mjs b/tools/logo-maske.mjs new file mode 100644 index 00000000..11291762 --- /dev/null +++ b/tools/logo-maske.mjs @@ -0,0 +1,134 @@ +/* ===================================================================== + AUS EINEM LOGO EINE MASKE MACHEN (19.09.2026) + + Filipe: „die kachel links soll dogfather logo als hintergrund haben + und rechts das logo von vanvan. die logos sollen auch richtig geil im + hintegrrund sein. die sollen paar mal sein in der kacheln und so wie + schweben, sich ganz leicht bewegen." + + WARUM EINE MASKE UND NICHT DAS BILD SELBST: + + Ein Logo als Hintergrundbild bringt seine eigenen Farben mit. Beide + hier sind schwarz auf weiss -- auf einer dunklen Kachel waere das + entweder ein weisser Kasten (VanVan, kein Alphakanal) oder ein + schwarzer Fleck, den man nicht sieht (DogFather). Umfaerben liesse + sich das nur mit Filtern, die man raten muss. + + Eine Maske traegt gar keine Farbe. Sie sagt nur, WO etwas zu sehen + ist -- die Farbe kommt aus dem CSS. Damit laesst sich dasselbe Logo + in Babyblau, in Lila und morgen in Gruen zeichnen, ohne eine neue + Datei. + + WIE DIE MASKE ENTSTEHT: Alpha = (1 - Helligkeit) x vorhandenes + Alpha. Wo das Logo schwarz ist, wird die Maske dicht; wo es weiss + oder durchsichtig ist, bleibt sie offen. Die Zeichnung bleibt + dadurch erhalten -- eine reine Silhouette waere ein Klecks. + + UND KLEINER. Das Original von VanVan ist 387 KB. Fuer ein + Hintergrundmuster, das nie groesser als ein paar hundert Pixel + erscheint, ist das Verschwendung: Es wird auf 320 px gerechnet, und + uebrig bleiben wenige Kilobyte. + + Aufrufen mit: node tools/logo-maske.mjs + ===================================================================== */ +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HIER = dirname(fileURLToPath(import.meta.url)); +const WURZEL = join(HIER, ".."); +const ZIEL = join(WURZEL, "assets", "img", "marke"); + +/** Woraus wird was gemacht. Beide Quellen sind schwarz-weiss. */ +const AUFTRAEGE = [ + { quelle: join(WURZEL, "assets/img/marke/logo-kopf-frei.png"), + ziel: join(ZIEL, "maske-dogi.png"), kante: 320, + was: "Der Huskykopf" }, + { quelle: join(WURZEL, "assets/img/marke/vanvan-logo-quelle.png"), + ziel: join(ZIEL, "maske-vanvan.png"), kante: 320, + was: "Van's DIY & Bastelbedarf" }, +]; + +const fehlend = AUFTRAEGE.filter((a) => !existsSync(a.quelle)); +if (fehlend.length) { + console.error("KANN NICHT ARBEITEN -- diese Vorlagen fehlen:"); + for (const f of fehlend) console.error(" " + f.quelle); + console.error(""); + console.error("Das VanVan-Logo liegt unter https://vans-diy-bastelbedarf.com/logo.png"); + console.error("und gehoert VanVan. Es wird hier nur verkleinert und entfaerbt."); + process.exit(2); +} + +const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await chromium.launch(); +const seite = await browser.newPage(); + +for (const auftrag of AUFTRAEGE) { + const roh = readFileSync(auftrag.quelle).toString("base64"); + const ergebnis = await seite.evaluate(async ([daten, kante]) => { + const bild = new Image(); + bild.src = "data:image/png;base64," + daten; + await bild.decode(); + + /* Quadratisch und mittig -- beide Logos sind schon nahezu + quadratisch, aber "nahezu" ist keine Zusicherung. */ + const s = Math.max(bild.width, bild.height); + const gross = document.createElement("canvas"); + gross.width = s; gross.height = s; + const gk = gross.getContext("2d"); + gk.drawImage(bild, (s - bild.width) / 2, (s - bild.height) / 2); + + const d = gk.getImageData(0, 0, s, s); + const p = d.data; + for (let i = 0; i < p.length; i += 4) { + /* Helligkeit nach Wahrnehmung, nicht als Mittelwert: Gruen + wiegt schwerer als Blau. Bei Schwarz-Weiss macht das keinen + Unterschied, bei einem spaeter farbigen Logo schon. */ + const hell = (0.2126 * p[i] + 0.7152 * p[i + 1] + 0.0722 * p[i + 2]) / 255; + const alpha = p[i + 3] / 255; + const neu = Math.round((1 - hell) * alpha * 255); + p[i] = p[i + 1] = p[i + 2] = 255; // Farbe egal, die Maske traegt nur Alpha + p[i + 3] = neu; + } + gk.putImageData(d, 0, 0); + + /* Verkleinern in EINEM Schritt mit hoher Glaettung. Mehrere + Halbierungen waeren schaerfer, aber hier ist ein weiches + Hintergrundmuster gewollt. */ + const klein = document.createElement("canvas"); + klein.width = kante; klein.height = kante; + const kk = klein.getContext("2d"); + kk.imageSmoothingEnabled = true; + kk.imageSmoothingQuality = "high"; + kk.drawImage(gross, 0, 0, kante, kante); + + /* Wie viel ist ueberhaupt sichtbar? Eine Maske, die fast leer ist, + waere ein stiller Fehlschlag -- man sieht auf der Kachel nichts + und sucht im CSS. */ + const kd = kk.getImageData(0, 0, kante, kante).data; + let sichtbar = 0; + for (let i = 3; i < kd.length; i += 4) if (kd[i] > 24) sichtbar++; + + return { + png: klein.toDataURL("image/png").split(",")[1], + anteil: sichtbar / (kante * kante), + }; + }, [roh, auftrag.kante]); + + const bytes = Buffer.from(ergebnis.png, "base64"); + if (!existsSync(ZIEL)) mkdirSync(ZIEL, { recursive: true }); + writeFileSync(auftrag.ziel, bytes); + + const vorher = readFileSync(auftrag.quelle).length; + const anteil = (ergebnis.anteil * 100).toFixed(1); + const warnung = ergebnis.anteil < 0.03 || ergebnis.anteil > 0.9 + ? " ACHTUNG: sieht nicht nach einem Logo aus" : ""; + console.log(`${auftrag.was}:`); + console.log(` ${Math.round(vorher / 1024)} KB -> ${Math.round(bytes.length / 1024)} KB` + + ` (${auftrag.kante} px, ${anteil} % gedeckt)${warnung}`); +} + +await browser.close(); +console.log(""); +console.log("Fertig. Die Masken tragen keine Farbe -- die kommt aus dem CSS."); diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index dffc98c1..9ab1fd76 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -5,9 +5,9 @@