diff --git a/server/pruef-treff.mjs b/server/pruef-treff.mjs
index c286d0e4..5ee24bba 100644
--- a/server/pruef-treff.mjs
+++ b/server/pruef-treff.mjs
@@ -290,13 +290,30 @@ melde("=== 1b. Eine Wand für beide ===");
2. DIE KACHELN: sieben, und sonst nichts
======================================================================= */
melde("");
-melde("=== 2. Sieben Kacheln — und keine achte ===");
+melde("=== 2. Die Kacheln der Community ===");
let namen = [];
{
const a = await roh("/workspace/api/ich", CREW, { cookie: gast.keks });
const j = JSON.parse(a.text);
- ok(Array.isArray(j.bereiche) && j.bereiche.length === 7,
- `die Community bekommt sieben Kacheln (${j.bereiche?.length})`);
+ /* ==== ABGELEITET STATT ABGESCHRIEBEN (19.09.2026) ================
+
+ Hier stand siebenmal die Zahl SIEBEN und einmal die Namensliste
+ ausgeschrieben. Beides war zu diesem Zeitpunkt schon falsch:
+ Gestern Nacht kamen "Unsere Seiten", "Vertraulich melden" und
+ "Mein Steckbrief" dazu -- die Pruefung war also bereits rot, ohne
+ dass irgendetwas kaputt war, und heute kam der Chat als elfte
+ hinzu.
+
+ Genau die Sorte Zahl, vor der die Hausregel warnt: eine Rechnung
+ von gestern, die still falsch wird. Gezaehlt wird ab jetzt gegen
+ die Liste, die der Server wirklich fuehrt. Was diese Pruefung
+ sichern soll, ist nicht die ZAHL, sondern dass die Community
+ genau das bekommt, was fuer sie gebaut wurde -- und nichts vom
+ Team. */
+ const { bereicheFuer } = await import("./workspace.js");
+ const erwartet = bereicheFuer({ rolle: "gast" }).map((k) => k.name);
+ ok(Array.isArray(j.bereiche) && j.bereiche.length === erwartet.length,
+ `die Community bekommt ${erwartet.length} Kacheln (${j.bereiche?.length})`);
/* Die Namen werden im naechsten Block noch einmal gebraucht -- dort
wird gegen sie geprueft, statt sie ein zweites Mal zu tippen. Zwei
Abschriften derselben sieben Namen waeren zwei Gelegenheiten,
@@ -315,12 +332,37 @@ let namen = [];
Wer sie wieder aendert, aendert beides. `pruef-kachelraster`
rechnet zusaetzlich nach, ob daraus ein Loch entsteht. */
- ok(namen.join(" | ") === "Der Treff | Anschlagbrett | Was ansteht | Wunschliste | Highlights | Regeln & Hilfe | Mitmachen",
+ ok(namen.join(" | ") === erwartet.join(" | "),
`und zwar diese: ${namen.join(", ")}`);
- ok((j.bereiche || []).every((k) => k.gruppe === "Community"),
- "alle in der Gruppe „Community“");
+ /* DIE BREITE KACHEL STEHT VORNE, und das bleibt eine echte Aussage:
+ Das Raster hat drei Spalten, "Der Treff" ist doppelt breit. An
+ dritter Stelle passt sie nicht mehr in die erste Reihe und reisst
+ ein Loch hinein -- genau das, was Filipe am 17.09. auf dem
+ Bildschirmfoto gesehen hat. */
+ ok(namen[0] === "Der Treff", `und „Der Treff“ steht vorne (${namen[0]})`);
+ /* NICHTS VOM TEAM. Der eigentliche Zweck dieses Abschnitts: Eine
+ Kachel des Teams bei der Community waere lautlos falsch -- sie
+ saehe aus wie die anderen, und erst beim Klicken sagte der Server
+ 404. */
+ const teamKacheln = ["Aufgaben", "Kalender", "Dateien", "Personen & Zugänge",
+ "Eure Aufgaben", "Talente", "Entwicklung", "Meldungen & Maßnahmen"];
+ ok(!namen.some((n) => teamKacheln.includes(n)),
+ "und keine einzige Kachel des Teams ist dabei");
+ /* ZWEI STEHEN IN "FUER DICH", NICHT IN "COMMUNITY" -- und das ist
+ die Aussage, nicht die Ausnahme: "Vertraulich melden" und "Mein
+ Steckbrief" gehoeren der einzelnen Person, nicht dem Raum. Sie
+ zwischen die Bretter zu stellen hiesse, dass eine vertrauliche
+ Meldung aussieht wie ein oeffentliches Brett. */
+ const PERSOENLICH = ["Vertraulich melden", "Mein Steckbrief"];
+ const bretter = (j.bereiche || []).filter((k) => !PERSOENLICH.includes(k.name));
+ ok(bretter.every((k) => k.gruppe === "Community"),
+ `die ${bretter.length} Bretter stehen in der Gruppe „Community“`);
+ ok((j.bereiche || []).filter((k) => PERSOENLICH.includes(k.name))
+ .every((k) => k.gruppe === "Für dich"),
+ "und die zwei persönlichen in „Für dich“ — eine Meldung ist kein Brett");
const toene = new Set((j.bereiche || []).map((k) => k.ton));
- ok(toene.size === 7, `sieben verschiedene Farben (${[...toene].join(", ")})`);
+ ok(toene.size === (j.bereiche || []).length,
+ `${toene.size} Kacheln, ${toene.size} verschiedene Farben (${[...toene].join(", ")})`);
ok(j.rolle_name === "Community", `die Rolle heißt „${j.rolle_name}“`);
}
{
@@ -341,16 +383,75 @@ let namen = [];
const MOD = "Meldungen & Ma\u00dfnahmen";
const a = JSON.parse((await roh("/workspace/api/ich", CREW, { cookie: kModi })).text);
const beimModi = (a.bereiche || []).filter((k) => k.gruppe === "Community").map((k) => k.name);
- const fehlt = namen.filter((n) => !beimModi.includes(n));
+ /* ==== ZWEI KACHELN SIEHT EIN MODI AUSDRUECKLICH NICHT ============
+
+ Bis zum 18.09. stimmte "ein Modi sieht alle Kacheln der Community
+ mit". Seitdem gibt es zwei, die ihm NICHT gehoeren -- und beide
+ sind Absicht, keine Luecke:
+
+ Vertraulich melden Sehr oft geht es in diesen Meldungen um eine
+ Moderationsentscheidung. Wer beteiligt ist,
+ darf die Beschwerde ueber sich nicht lesen.
+ Mein Steckbrief Jeder hat seinen eigenen. "Der von jemand
+ anderem" ist hier gar nicht gemeint.
+
+ Die Pruefung war dadurch seit gestern rot, ohne dass etwas kaputt
+ war. Jetzt steht die Ausnahme ausdruecklich da -- und sie ist
+ eine ECHTE Pruefung: Taucht "Vertraulich melden" eines Tages bei
+ einem Modi auf, wird diese Zeile rot. */
+ const NUR_COMMUNITY = ["Vertraulich melden", "Mein Steckbrief"];
+ /* Verglichen wird die GRUPPE "Community" auf beiden Seiten -- die
+ zwei persoenlichen Kacheln stehen dort ohnehin nicht drin. */
+ const sollteSehen = namen.filter((n) => !NUR_COMMUNITY.includes(n));
+ const fehlt = sollteSehen.filter((n) => !beimModi.includes(n));
ok(fehlt.length === 0,
- `ein Modi sieht alle sieben der Community mit${fehlt.length ? " -- FEHLT: " + fehlt.join(", ") : ""}`);
- ok(beimModi.includes(MOD) && !namen.includes(MOD),
- `und genau eine mehr: ${beimModi.filter((n) => !namen.includes(n)).join(", ")}`);
+ `ein Modi sieht die ${sollteSehen.length} Bretter der Community mit`
+ + `${fehlt.length ? " -- FEHLT: " + fehlt.join(", ") : ""}`);
+ const zuViel = NUR_COMMUNITY.filter((n) => beimModi.includes(n));
+ ok(zuViel.length === 0,
+ `aber NICHT den vertraulichen Meldeweg${zuViel.length ? " -- DURCH: " + zuViel.join(", ") : ""}`);
+ /* "Meldungen & Massnahmen" steht in der Gruppe MODERATION, nicht in
+ "Community" -- gemessen, nicht angenommen. Gesucht wird deshalb in
+ seiner ganzen Liste. Dass sie eine EIGENE Gruppe hat, ist richtig:
+ Sie ist kein Brett der Community, sondern das Werkzeug derer, die
+ sie betreuen. */
+ const modiAlles = [...(a.bereiche || []), ...(a.bereiche_zusatz || [])]
+ .map((k) => k.name);
+ ok(modiAlles.includes(MOD) && !namen.includes(MOD),
+ `und genau eine mehr, die die Community NICHT hat: ${MOD}`);
const b = JSON.parse((await roh("/workspace/api/ich", HAUS, { cookie: kDogi })).text);
const beiDogi = (b.bereiche_zusatz || []).filter((k) => k.gruppe === "Community").map((k) => k.name);
- ok(namen.every((n) => beiDogi.includes(n)) && beiDogi.includes(MOD),
- `und DogFather auf seiner Adresse auch (${beiDogi.length})`);
+ /* DogFather sieht den Meldeweg sehr wohl -- er beantwortet ihn. */
+ /* Auch hier: MOD steht in der Gruppe "Moderation", nicht in
+ "Community". `beiDogi` ist nach Gruppe gefiltert und kann ihn
+ deshalb gar nicht enthalten -- die alte Bedingung war unerfuellbar,
+ sobald die Moderationskachel ihre eigene Gruppe bekam. */
+ const dogiAlles = [...(b.bereiche || []), ...(b.bereiche_zusatz || [])]
+ .map((k) => k.name);
+ ok(sollteSehen.every((n) => beiDogi.includes(n)),
+ `und DogFather sieht auf seiner Adresse dieselben ${beiDogi.length} Bretter`);
+ ok(dogiAlles.includes(MOD), `und die Moderationskachel dazu (${MOD})`);
+ /* DER MELDEWEG BEI DOGFATHER: Er beantwortet ihn, also muss er ihn
+ haben -- aber er steht bei ihm NICHT in der Gruppe "Community",
+ sondern dort, wo seine eigenen Werkzeuge stehen. Gesucht wird
+ deshalb in seiner GANZEN Liste, nicht nur in einer Gruppe. */
+ /* DER MELDEWEG LEBT AUF DER TEAM-ADRESSE, nicht auf der Agentur-.
+ Gemessen: Auf crew. hat DogFather ihn in der Gruppe "Fuer dich";
+ auf workspace. steht er nicht, und das ist richtig -- dort geht es
+ um die Agentur, nicht um den Treff. Geprueft wird deshalb dort, wo
+ er hingehoert. */
+ const cDogi = JSON.parse((await roh("/workspace/api/ich", CREW, { cookie: kDogi })).text);
+ const dogiAufCrew = [...(cDogi.bereiche || []), ...(cDogi.bereiche_zusatz || [])]
+ .map((k) => k.name);
+ ok(dogiAufCrew.includes("Vertraulich melden"),
+ "auf der Team-Adresse steht der vertrauliche Meldeweg bei ihm — er beantwortet ihn");
+ /* GEGENPROBE: bei einem Modi nicht -- und zwar auf DERSELBEN
+ Adresse. Ohne diese Zeile waere die darueber auch gruen, wenn ihn
+ jeder saehe; mit einer anderen Adresse waere sie gruen, ohne
+ etwas zu beweisen. */
+ ok(!modiAlles.includes("Vertraulich melden"),
+ "bei einem Modi auf derselben Adresse nicht — er ist oft der Anlass");
const c = JSON.parse((await roh("/workspace/api/ich", HAUS, { cookie: kMana })).text);
const beiMana = (c.bereiche_zusatz || []).filter((k) => k.gruppe === "Community");
@@ -552,7 +653,19 @@ for (const [name, pfad] of [
["Termine", "/workspace/api/termine"],
["Dateien", "/workspace/api/dateien"],
["Personen", "/workspace/api/personen"],
- ["Gespräche", "/workspace/api/chat/raeume"],
+ /* "Gespräche" IST HIER HERAUSGENOMMEN (19.09.2026).
+
+ Bis heute stimmte die Zeile: Die Community hatte keinen Chat, also
+ musste dort nichts herauskommen. Filipe hat das geändert -- sie
+ bekommt genau EINEN Raum, den Treff, mit Nachtruhe und ohne
+ Anrufe.
+
+ SIE FÄLLT NICHT WEG, SIE ZIEHT UM: Was der Chat für die Community
+ darf und was nicht, prüft jetzt `pruef-treffchat` -- und zwar
+ genauer, als diese Zeile es je konnte (ein Raum und nicht mehr,
+ keine Zweier-Gespräche, kein Telefon, Nachtruhe am Server).
+ Sie hier stehen zu lassen hiesse, eine bestellte Funktion für
+ einen Fehler zu halten. */
]) {
const a = await roh(pfad, CREW, { cookie: gast.keks });
let leer = a.code !== 200;
diff --git a/server/pruef-treffchat.mjs b/server/pruef-treffchat.mjs
new file mode 100644
index 00000000..f269dc70
--- /dev/null
+++ b/server/pruef-treffchat.mjs
@@ -0,0 +1,625 @@
+/* =====================================================================
+ DER TREFF-CHAT MIT NACHTRUHE (19.09.2026)
+
+ Filipe: „eine geile mischung von punkt 2 und drei. mach es perfekt
+ so das quasi nach mitternacht bis morgens 6 uhr keiner schreiben
+ kann damit auch die privatsphäre berücksichtig wird über nacht …
+ und die sollen nicht anrufen können. nur schreiben."
+
+ ---------------------------------------------------------------------
+ DREI FRAGEN, UND NUR DIE ERSTE IST OFFENSICHTLICH
+
+ 1. Kann die Community überhaupt schreiben — und nur im Treff?
+ 2. Hält die Nachtruhe auch dann, wenn niemand auf die Oberfläche
+ schaut? Ein ausgegrautes Feld ist eine Höflichkeit. Geprüft
+ wird die SCHNITTSTELLE, denn die kann jeder direkt ansprechen.
+ 3. Ist das Telefon wirklich zu? Ein Anruf hängt in diesem Haus an
+ der RAUMMITGLIEDSCHAFT, nicht an der Rolle. Seit die Community
+ einen Raum hat, wäre Telefonieren erlaubt — nicht, weil es
+ jemand entschieden hätte, sondern weil es niemand verboten
+ hat. Das ist die gefährlichste Art von Erlaubnis.
+
+ ---------------------------------------------------------------------
+ DIE ZEIT WIRD NICHT GEFÄLSCHT, SIE WIRD ÜBERGEBEN
+
+ Die Nachtruhe hängt an der Uhr. Eine Prüfung, die die Systemzeit
+ verstellt, fälscht danach auch anderes mit — und sie wäre nur
+ nachts grün oder nur tagsüber. Deshalb nimmt `istNachtruhe(d)` ein
+ Datum entgegen, und geprüft werden feste Zeitpunkte als EINGABE.
+ Das gilt zu jeder Tages- und Nachtzeit gleich.
+
+ Für den Weg durch die Schnittstelle wird stattdessen die GRENZE
+ verschoben (Umgebungsvariable), nicht die Uhr.
+
+ Aufrufen mit: node server/pruef-treffchat.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";
+
+/* ==== ZWEI LAEUFE, EIN PROGRAMM =====================================
+
+ Die Nachtruhe laesst sich nicht in EINEM Lauf in beiden Zustaenden
+ pruefen: Die Zeiten werden beim Laden des Moduls gelesen, und ein
+ zweiter Server im selben Prozess braeuchte denselben Port.
+
+ Deshalb ruft sich diese Datei am Ende SELBST noch einmal auf -- mit
+ `TREFF_PRUEF_NACHT=ja` und einem Fenster, das JETZT gilt. Derselbe
+ Code, dieselben Pruefungen, andere Lage.
+
+ Das ist dieselbe Bauweise wie in pruef-content.mjs (zwei Server
+ nacheinander) und aus demselben Grund: Eine Gegenprobe, die nur
+ behauptet wird, ist keine. */
+const NACHTLAUF = process.env.TREFF_PRUEF_NACHT === "ja";
+const PORT = await portMussFreiSein(NACHTLAUF ? 4424 : 4423,
+ NACHTLAUF ? "pruef-treffchat (Nachtlauf)" : "pruef-treffchat");
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-treff-"));
+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";
+
+/* ==== DIE GRENZE STATT DER UHR ======================================
+
+ Damit der Weg durch die Schnittstelle in BEIDEN Zuständen prüfbar
+ ist, ohne die Systemzeit anzufassen: Die Nachtruhe wird auf ein
+ Fenster gelegt, das JETZT gerade nicht gilt — dann muss Schreiben
+ gehen. Für den gesperrten Fall wird der Server ein zweites Mal
+ gestartet, mit einem Fenster, das jetzt gilt.
+
+ Gerechnet wird die aktuelle Stunde in Ortszeit, genau wie im
+ Programm. Eine feste Zahl hier wäre eine Prüfung, die zu einer
+ bestimmten Uhrzeit rot wird — und ein Fehlalarm, den man nicht
+ nachstellen kann, ist der sicherste Weg, eine Prüfung abzuschalten. */
+const jetztStunde = Number(new Intl.DateTimeFormat("de-DE", {
+ timeZone: "Europe/Berlin", hour: "2-digit", hour12: false,
+}).format(new Date()).replace(/\D/g, "")) % 24;
+
+/* Ein Fenster von zwei Stunden, das JETZT nicht gilt: drei Stunden
+ voraus. */
+const OFFEN_AB = (jetztStunde + 3) % 24;
+const OFFEN_BIS = (jetztStunde + 5) % 24;
+/* Und eines, das JETZT gilt: von der aktuellen Stunde bis zwei
+ spaeter. Die laufende Stunde ist immer enthalten. */
+const ZU_AB = jetztStunde;
+const ZU_BIS = (jetztStunde + 2) % 24;
+process.env.TREFF_NACHT_AB = String(NACHTLAUF ? ZU_AB : OFFEN_AB);
+process.env.TREFF_NACHT_BIS = String(NACHTLAUF ? ZU_BIS : OFFEN_BIS);
+
+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(180_000, "pruef-treffchat");
+await new Promise((r) => setTimeout(r, 700));
+
+process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
+process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", 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";
+/* Die Agenturadresse. Ein Creator gehoert dorthin und kommt auf crew.
+ gar nicht erst herein -- das ist strenger als alles, was diese Datei
+ sonst prueft, und es ist der Grund, warum die Gegenprobe unten den
+ Creator DORT anmeldet. */
+const HAUS = "workspace.dogfather-universe.com";
+
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
+const T = await import("./workspace-treffchat.js");
+const rechte = await import("./rechte.js");
+
+const oeffne = () => new DatabaseSync(process.env.WORKSPACE_DB);
+const jetzt = new Date().toISOString();
+
+let idDogi, idModi, idGast, idGast2, idCreator;
+{
+ const d = oeffne();
+ const schluessel = randomBytes(32).toString("hex");
+ d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
+ .run("code_kennung_schluessel", schluessel, jetzt);
+ const anlegen = (name, rolle, code) => {
+ const salt = randomBytes(16).toString("hex");
+ const hash = scryptSync(code, salt, 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, code_kennung, aktiv, erstellt)"
+ + " VALUES (?,?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salt, 32768, createHmac("sha256", schluessel).update(code).digest("hex"), jetzt);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+ };
+ idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
+ idModi = anlegen("Diene", "modi", "CODE-MODI-0001");
+ idGast = anlegen("Melli", "gast", "CODE-GAST-0001");
+ idGast2 = anlegen("Toni", "gast", "CODE-GAST-0002");
+ idCreator = anlegen("Tili", "creator", "CODE-CREA-0001");
+ d.close();
+}
+
+function ruf(pfad, host, methode, kopf = {}, koerper = null) {
+ return new Promise((fertig, schief) => {
+ const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: pfad, method: methode,
+ 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 {}; } };
+async function anmelden(rolle, code, host) {
+ /* `alter_ok` NUR fuer die Community. Fuer alle anderen Rollen darf
+ der Server das Feld gar nicht kennen -- so steht es in
+ pruef-treff.mjs, und so wird es hier genauso gemacht. Ohne dieses
+ Feld weist er einen Gast ab, und die Pruefung haette gemeldet
+ "ein Gast bekommt keine Gespraechsliste": wahr, und aus dem
+ voellig falschen Grund. */
+ const koerper = rolle === "gast" ? { rolle, code, alter_ok: true } : { rolle, code };
+ const r = await ruf("/workspace/api/anmelden", host, "POST", {}, koerper);
+ if (r.code !== 200) console.log(` (Anmeldung ${rolle}: ${r.code} ${r.text.slice(0, 80)})`);
+ return [].concat(r.kopf?.["set-cookie"] || []).map((c) => c.split(";")[0]).join("; ");
+}
+
+const kDogi = await anmelden("admin", "CODE-DOGI-0001", CREW);
+const kModi = await anmelden("modi", "CODE-MODI-0001", CREW);
+const kGast = await anmelden("gast", "CODE-GAST-0001", CREW);
+const kGast2 = await anmelden("gast", "CODE-GAST-0002", CREW);
+const kCreator = await anmelden("creator", "CODE-CREA-0001", HAUS);
+ok(!!kDogi && !!kModi && !!kGast, "DogFather, ein Modi und zwei Gäste sind angemeldet");
+
+/* =======================================================================
+ 1. DIE RECHNUNG — ohne Server, ohne Uhr zu fälschen
+ ======================================================================= */
+melde("");
+melde("=== 1. Wann schläft der Treff ===");
+{
+ /* Die Vorgabe ist 0 bis 6 -- geprüft wird gegen die KONSTANTEN, nicht
+ gegen eine abgeschriebene Zahl. Wer die Zeiten ändert, ändert sie
+ an einer Stelle und diese Prüfung bleibt richtig. */
+ const inBerlin = (tag, stunde, minute = 0) =>
+ new Date(`2026-${tag}T${String(stunde).padStart(2, "0")}:${String(minute).padStart(2, "0")}:00+02:00`);
+
+ /* HIER WERDEN DIE ECHTEN VORGABEN GEPRÜFT (0 bis 6), nicht das
+ verschobene Fenster der Umgebungsvariable -- deshalb direkt gegen
+ die Rechenvorschrift mit festen Werten. */
+ const zu = (s) => (0 < 6 ? (s >= 0 && s < 6) : (s >= 0 || s < 6));
+ ok(zu(0) && zu(3) && zu(5), "0, 3 und 5 Uhr liegen in der Nachtruhe");
+ ok(!zu(6) && !zu(12) && !zu(23), "6, 12 und 23 Uhr nicht");
+
+ /* DIE EIGENTLICHE FALLE: Ortszeit, nicht UTC. Am 20.12. um 00:30
+ Ortszeit ist es 23:30 UTC -- `getHours()` auf einem UTC-Server
+ ergäbe 23 und damit „offen". Dieses Haus ist mit genau dieser
+ Rechnung schon zweimal hereingefallen. */
+ const winterNacht = new Date("2026-12-19T23:30:00Z"); // = 20.12., 00:30 MEZ
+ ok(T.stundeInBerlin(winterNacht) === 0,
+ `im Winter um 23:30 UTC ist es in Berlin ${T.stundeInBerlin(winterNacht)} Uhr, nicht 23`);
+ const sommerNacht = new Date("2026-06-19T22:30:00Z"); // = 20.06., 00:30 MESZ
+ ok(T.stundeInBerlin(sommerNacht) === 0,
+ `im Sommer um 22:30 UTC ebenfalls ${T.stundeInBerlin(sommerNacht)} Uhr`);
+ /* GEGENPROBE: Mittags darf sie NICHT null sagen -- sonst wäre die
+ Zeile darüber auch mit einer Funktion grün, die immer 0 liefert. */
+ ok(T.stundeInBerlin(new Date("2026-06-19T10:00:00Z")) === 12,
+ "und mittags um 10:00 UTC ist es 12 Uhr in Berlin");
+
+ ok(inBerlin("09-19", 3) instanceof Date, "die Zeitpunkte sind lesbar");
+}
+
+/* =======================================================================
+ 2. DER RAUM — genau einer, und die Richtigen sind drin
+ ======================================================================= */
+melde("");
+melde("=== 2. Der Raum ===");
+{
+ const r = json(await ruf("/workspace/api/chat/raeume", CREW, "GET", { cookie: kGast }));
+ ok(Array.isArray(r.raeume), "ein Gast bekommt eine Gesprächsliste");
+ /* NICHT WEITERRECHNEN, WENN DIE GRUNDLAGE FEHLT. Sonst stirbt die
+ Datei an einem `undefined.length` und meldet einen Absturz, wo ein
+ Befund stehen sollte -- die naechsten dreissig Pruefungen laufen
+ dann gar nicht erst. */
+ const raeume = Array.isArray(r.raeume) ? r.raeume : [];
+ ok(raeume.length === 1, `und darin genau einen Raum (${raeume.length})`);
+ const treff = raeume[0];
+ ok(treff?.art === "kanal" && treff?.kategorie === "treff",
+ `den Treff (${treff?.art}/${treff?.kategorie})`);
+ ok(treff?.name === "Der Treff", `mit lesbarem Namen: „${treff?.name}“`);
+
+ /* GENAU EINER. Zwei Räume mit demselben Zweck wären zwei Räume mit
+ je der Hälfte der Nachrichten darin. */
+ await ruf("/workspace/api/chat/raeume", CREW, "GET", { cookie: kGast2 });
+ await ruf("/workspace/api/chat/raeume", CREW, "GET", { cookie: kModi });
+ const d = oeffne();
+ const anzahl = d.prepare(
+ "SELECT COUNT(*) AS n FROM chat_raeume WHERE kategorie = 'treff'").get().n;
+ ok(anzahl === 1, `es gibt genau einen Treff-Raum (${anzahl})`);
+
+ const drin = d.prepare(`SELECT p.rolle, t.leitung FROM chat_teilnehmer t
+ JOIN personen p ON p.id = t.person_id
+ JOIN chat_raeume r ON r.id = t.raum_id
+ WHERE r.kategorie = 'treff' AND t.raus_am IS NULL`).all();
+ const rollen = drin.map((z) => z.rolle).sort();
+ ok(rollen.includes("gast") && rollen.includes("modi") && rollen.includes("admin"),
+ `Community, Modi und Leitung sind drin (${rollen.join(", ")})`);
+ /* GEGENPROBE: Ein Creator gehört NICHT hinein -- der Treff ist nicht
+ die Agentur. */
+ ok(!rollen.includes("creator"), "ein Creator nicht");
+ /* Ein Gast ist Teilnehmer, kein Leiter -- sonst dürfte er anheften
+ und andere herausnehmen. */
+ const gastZeile = d.prepare(`SELECT t.leitung FROM chat_teilnehmer t
+ JOIN chat_raeume r ON r.id = t.raum_id
+ WHERE r.kategorie = 'treff' AND t.person_id = ?`).get(idGast);
+ ok(gastZeile?.leitung === 0, "ein Gast steht als Teilnehmer, nicht als Leitung");
+ const modiZeile = d.prepare(`SELECT t.leitung FROM chat_teilnehmer t
+ JOIN chat_raeume r ON r.id = t.raum_id
+ WHERE r.kategorie = 'treff' AND t.person_id = ?`).get(idModi);
+ ok(modiZeile?.leitung === 1, "ein Modi dagegen als Leitung — er moderiert hier");
+ d.close();
+
+ /* DIE COMMUNITY BEKOMMT KEIN ZWEIER-GESPRÄCH. `schreibbareIds`
+ liefert ihr weiterhin nichts -- ein Chat mit einzelnen Zuschauern
+ ist etwas ganz anderes als ein Raum, in dem alle mitlesen. */
+ const partner = json(await ruf("/workspace/api/chat/partner", CREW, "GET", { cookie: kGast }));
+ ok((partner.partner || partner.leute || []).length === 0,
+ "ein Gast hat niemanden für ein Zweier-Gespräch");
+ const direkt = await ruf("/workspace/api/chat/direkt", CREW, "POST",
+ { cookie: kGast }, { mit: idDogi });
+ ok(direkt.code === 403,
+ `und kann auch keines aufmachen (${direkt.code}) — die Regel sitzt im Server`);
+}
+
+/* =======================================================================
+ 3. SCHREIBEN — geht, solange der Treff wach ist
+ ======================================================================= */
+melde("");
+melde("=== 3. Schreiben bei Tag ===");
+let treffId = null;
+{
+ const r = json(await ruf("/workspace/api/chat/raeume", CREW, "GET", { cookie: kGast }));
+ treffId = r.raeume?.[0]?.id ?? null;
+ ok(!!treffId, `der Treff hat eine Nummer (${treffId})`);
+
+ ok(r.treff && r.treff.zu === NACHTLAUF,
+ NACHTLAUF
+ ? `die Nachtruhe gilt gerade (Fenster ${ZU_AB}–${ZU_BIS} Uhr, jetzt ${jetztStunde})`
+ : `die Nachtruhe ist gerade aus (Fenster ${OFFEN_AB}–${OFFEN_BIS} Uhr, jetzt ${jetztStunde})`);
+ ok(typeof r.treff?.satz === "string" && r.treff.satz.length > 20,
+ `der Server liefert den fertigen Satz: „${(r.treff?.satz || "").slice(0, 58)}…“`);
+ ok(Number.isFinite(r.treff?.minuten) && r.treff.minuten > 0,
+ `und wie lange es noch dauert (${r.treff?.minuten} Minuten)`);
+
+ const a = await ruf(`/workspace/api/chat/raeume/${treffId}/nachrichten`, CREW, "POST",
+ { cookie: kGast }, { text: "Hallo zusammen!" });
+ const b = await ruf(`/workspace/api/chat/raeume/${treffId}/nachrichten`, CREW, "POST",
+ { cookie: kModi }, { text: "Hi Melli!" });
+
+ if (NACHTLAUF) {
+ /* ==== DER EIGENTLICHE RIEGEL ====================================
+
+ Geprueft wird die SCHNITTSTELLE, nicht die Oberflaeche. Ein
+ ausgegrautes Feld ist eine Hoeflichkeit -- wer die Adresse
+ kennt, spricht den Server direkt an. */
+ ok(a.code === 403, `ein Gast kann nachts NICHT schreiben (${a.code})`);
+ ok(/schläft|ab 0?\d:00/i.test(json(a).fehler || ""),
+ `und die Absage sagt warum: „${(json(a).fehler || "").slice(0, 60)}…“`);
+ ok(json(a).nachtruhe?.zu === true, "sie liefert den Stand gleich mit");
+
+ /* SIE GILT FUER ALLE, AUCH FUER DIE LEITUNG. Das ist die
+ unbequeme Entscheidung dieses Bauteils: Wenn DogFather um halb
+ vier schreibt, liest es jemand -- und wer es liest, fuehlt sich
+ zustaendig. Genau den Druck soll die Nachtruhe wegnehmen. */
+ ok(b.code === 403, `ein Modi ebenfalls nicht (${b.code})`);
+ const beiDogi = await ruf(`/workspace/api/chat/raeume/${treffId}/nachrichten`, CREW, "POST",
+ { cookie: kDogi }, { text: "Ich bin der Chef" });
+ ok(beiDogi.code === 403, `und DogFather auch nicht (${beiDogi.code})`);
+
+ /* DER ANHANG IST DERSELBE WEG. Ihn offen zu lassen hiesse, dass
+ man nachts zwar nicht schreiben, aber ein Foto schicken kann --
+ der Weg, den jeder findet, der es einmal versucht. */
+ const anhang = await ruf(`/workspace/api/chat/raeume/${treffId}/anhang`, CREW, "POST",
+ { cookie: kGast, "Content-Type": "image/png" }, { egal: 1 });
+ ok(anhang.code === 403, `auch kein Anhang (${anhang.code})`);
+
+ /* GELESEN WERDEN DARF DIE GANZE NACHT. Sonst waere es keine
+ Nachtruhe, sondern eine geschlossene Tuer -- und wer nachts
+ nachsehen will, was los war, koennte es nicht. */
+ const lesen = await ruf(`/workspace/api/chat/raeume/${treffId}/nachrichten`,
+ CREW, "GET", { cookie: kGast });
+ ok(lesen.code === 200, `gelesen werden darf trotzdem (${lesen.code})`);
+
+ /* UND DAS TEAM HAT KEINE NACHTRUHE. Sie gilt fuer den Treff, nicht
+ fuer den ganzen Chat -- im Team arbeiten Leute nachts. */
+ /* Das Feld heisst `mit`, nicht `id` -- erster Anlauf schickte `id`
+ und bekam 400. Die Pruefung meldete daraufhin "Gegenprobe nicht
+ moeglich": richtig gemeldet, und aus dem falschen Grund. */
+ const teamRaum = json(await ruf("/workspace/api/chat/direkt", CREW, "POST",
+ { cookie: kModi }, { mit: idDogi }));
+ /* Das Feld heisst `raum_id`, nicht `id` -- die Route gibt
+ `{ raum_id }` zurueck. Zweiter Anlauf, nachdem der erste auf
+ `id` gesetzt hatte und die Pruefung "Gegenprobe nicht moeglich"
+ meldete: wieder richtig gemeldet und wieder aus dem falschen
+ Grund. Genau deshalb hat dieser Zweig ueberhaupt einen
+ Fehlerausgang und faellt nicht still weg. */
+ if (teamRaum?.raum_id) {
+ const imTeam = await ruf(`/workspace/api/chat/raeume/${teamRaum.raum_id}/nachrichten`,
+ CREW, "POST", { cookie: kModi }, { text: "Nachtschicht" });
+ ok(imTeam.code === 201 || imTeam.code === 200,
+ `im Teamchat geht es nachts weiter (${imTeam.code})`);
+ } else {
+ ok(false, "der Teamraum liess sich nicht anlegen — Gegenprobe nicht moeglich");
+ }
+ } else {
+ ok(a.code === 201 || a.code === 200, `ein Gast kann schreiben (${a.code})`);
+ ok(b.code === 201 || b.code === 200, `ein Modi auch (${b.code})`);
+ }
+
+ if (!NACHTLAUF) {
+ const lesen = json(await ruf(`/workspace/api/chat/raeume/${treffId}/nachrichten`,
+ CREW, "GET", { cookie: kGast2 }));
+ const texte = (lesen.nachrichten || []).map((n) => n.text);
+ ok(texte.includes("Hallo zusammen!") && texte.includes("Hi Melli!"),
+ `der zweite Gast liest beide Nachrichten (${texte.length})`);
+ }
+
+ /* GEGENPROBE: Ein Creator hat mit dem Treff nichts zu tun.
+
+ UND ZWAR DOPPELT. Erster Anlauf pruefte ihn auf crew. und bekam
+ 401 statt 404 -- er kommt auf der Team-Adresse ueberhaupt nicht
+ herein. Das ist die staerkere Aussage, aber sie beweist nichts
+ ueber den Treff-Raum. Deshalb jetzt beides: auf seiner eigenen
+ Adresse angemeldet, und dort am Raum abgewiesen. */
+ const aufCrew = await ruf(`/workspace/api/chat/raeume/${treffId}/nachrichten`, CREW, "POST",
+ { cookie: kCreator }, { text: "Ich auch!" });
+ ok(aufCrew.code === 401 || aufCrew.code === 404,
+ `auf der Team-Adresse kommt ein Creator gar nicht erst herein (${aufCrew.code})`);
+ const imHaus = await ruf(`/workspace/api/chat/raeume/${treffId}/nachrichten`, HAUS, "POST",
+ { cookie: kCreator }, { text: "Ich auch!" });
+ ok(imHaus.code === 404,
+ `und auf seiner eigenen Adresse kennt der Treff ihn nicht (${imHaus.code})`);
+ /* Dass seine Anmeldung ueberhaupt gilt -- sonst waeren beide Zeilen
+ oben nur deshalb gruen, weil der Keks nichts wert ist. */
+ const seins = await ruf("/workspace/api/chat/raeume", HAUS, "GET", { cookie: kCreator });
+ ok(seins.code === 200, `sein Zugang funktioniert dort sehr wohl (${seins.code})`);
+}
+
+/* =======================================================================
+ 4. DAS TELEFON IST ZU
+
+ Der Teil, der am leichtesten durchrutscht: Ein Anruf hängt an der
+ RAUMMITGLIEDSCHAFT. Seit die Community einen Raum hat, wäre er
+ erlaubt — nicht, weil es jemand entschieden hätte, sondern weil es
+ niemand verboten hat.
+ ======================================================================= */
+melde("");
+melde("=== 4. Keine Anrufe ===");
+{
+ ok(T.darfTelefonieren({ rolle: "gast" }) === false, "die Rechnung sagt: ein Gast nicht");
+ ok(T.darfTelefonieren({ rolle: "modi" }) === true, "ein Modi schon");
+
+ /* JEDE EINZELNE ROUTE, nicht nur eine. Der Riegel sitzt vorne am
+ Router -- aber „sitzt vorne" ist eine Behauptung, bis man hinten
+ nachsieht. */
+ const wege = [
+ ["GET", "/workspace/api/anruf/adressen"],
+ ["GET", "/workspace/api/anruf/offen"],
+ ["GET", `/workspace/api/anruf/${treffId}`],
+ ["POST", `/workspace/api/anruf/${treffId}/start`],
+ ["POST", `/workspace/api/anruf/${treffId}/dabei`],
+ ["POST", `/workspace/api/anruf/${treffId}/ende`],
+ ["POST", `/workspace/api/anruf/${treffId}/signal`],
+ ];
+ let zu = 0;
+ for (const [m, w] of wege) {
+ const a = await ruf(w, CREW, m, { cookie: kGast }, m === "POST" ? {} : null);
+ if (a.code === 404) zu++;
+ else console.log(` offen geblieben: ${m} ${w} -> ${a.code}`);
+ }
+ ok(zu === wege.length, `alle ${wege.length} Anruf-Wege sind für einen Gast zu (${zu})`);
+
+ /* GEGENPROBE: Für einen Modi sind sie offen -- sonst wäre die Zeile
+ oben auch dann grün, wenn die Anruffunktion für JEDEN kaputt wäre. */
+ const fuerModi = await ruf("/workspace/api/anruf/adressen", CREW, "GET", { cookie: kModi });
+ ok(fuerModi.code === 200, `für einen Modi ist derselbe Weg offen (${fuerModi.code})`);
+
+ /* UND DIE SEITE ZEIGT IHM KEINEN KNOPF. Beides gehört zusammen:
+ Ohne den Riegel wäre es kosmetisch, ohne den fehlenden Knopf
+ stünde ein Angebot da, das mit 404 antwortet. */
+ const beiGast = json(await ruf("/workspace/api/chat/raeume", CREW, "GET", { cookie: kGast }));
+ ok(beiGast.darf_anrufen === false, "und die Seite bekommt gesagt, dass es nichts zu zeigen gibt");
+ const beiModi = json(await ruf("/workspace/api/chat/raeume", CREW, "GET", { cookie: kModi }));
+ ok(beiModi.darf_anrufen === true, "einem Modi dagegen schon");
+}
+
+/* =======================================================================
+ 5. DIE RECHTETAFEL
+ ======================================================================= */
+melde("");
+melde("=== 5. Wer die Seite öffnen darf ===");
+{
+ const erlaubt = rechte.tafelJetzt()["/workspace/chat.html"] || [];
+ ok(erlaubt.includes("gast"), "die Community darf die Chatseite öffnen");
+ ok(erlaubt.includes("modi") && erlaubt.includes("admin"),
+ "Modi und Leitung ebenfalls");
+ /* GEGENPROBE: anruf-probe.html bleibt OHNE gast -- die Community
+ bekommt keine Erinnerungen, und sieben Prüfpunkte über etwas, das
+ es für sie nicht gibt, wären nur Verwirrung. */
+ ok(!(rechte.tafelJetzt()["/workspace/anruf-probe.html"] || []).includes("gast"),
+ "die Anruf-Probe dagegen nicht — sie telefoniert ja nicht");
+}
+
+/* =======================================================================
+ 6. DIE KACHEL
+ ======================================================================= */
+melde("");
+melde("=== 6. Die Kachel ===");
+{
+ const ich = json(await ruf("/workspace/api/ich", CREW, "GET", { cookie: kGast }));
+ const alle = [...(ich.bereiche || []), ...(ich.bereiche_zusatz || [])];
+ const k = alle.find((x) => x.ziel === "chat.html");
+ ok(!!k, "die Community hat eine Chat-Kachel");
+ ok(k?.name === "Chat", `sie heißt „${k?.name}“`);
+ ok(/nacht/i.test(k?.unter || ""),
+ `und sagt schon auf der Startseite, dass er nachts schläft: „${k?.unter}“`);
+
+ const css = (await import("node:fs")).readFileSync(
+ new URL("../workspace/assets/css/start.css", import.meta.url), "utf8");
+ ok(new RegExp(`\\[data-ton="${k?.ton}"\\]`).test(css),
+ `ihr Farbton ${k?.ton} ist in start.css definiert`);
+ /* Keine zwei Kacheln mit derselben Farbe -- Filipe mehrfach:
+ „keine die sich irgendwie ähnlich sind". */
+ const toene = alle.map((x) => x.ton).filter(Boolean);
+ ok(new Set(toene).size === toene.length,
+ `kein Farbton doppelt bei den ${toene.length} Kacheln der Community`);
+}
+
+/* =======================================================================
+ 6b. IM BROWSER
+
+ Der Teil, den keine Schnittstellenpruefung ersetzt: Sieht ein Gast
+ wirklich einen Raum und keinen Telefonknopf -- und steht das Band
+ ueber dem Eingabefeld, wo man es liest, bevor man tippt?
+ ======================================================================= */
+melde("");
+melde("=== 6b. Im Browser ===");
+{
+ const { chromium } = await import(
+ "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+ const browser = await chromium.launch();
+ const kontext = await browser.newContext({ viewport: { width: 412, height: 915 } });
+ const seite = await kontext.newPage();
+ const konsole = [];
+ seite.on("console", (m) => { if (m.type() === "error" && !/favicon|ERR_/i.test(m.text())) konsole.push(m.text()); });
+ seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message));
+ /* WELCHE Adresse 404 sagt, nicht nur DASS eine es tut. "Failed to
+ load resource" ohne Adresse ist eine Meldung, mit der man nichts
+ anfangen kann -- und dann sucht man an der falschen Stelle. */
+ const vergeblich = [];
+ seite.on("response", (r) => {
+ if (r.status() >= 400 && !/favicon/.test(r.url())) {
+ vergeblich.push(`${r.status()} ${r.url().replace(/^https?:\/\/[^/]+/, "")}`);
+ }
+ });
+
+ await kontext.addCookies(kGast.split("; ").filter(Boolean).map((teil) => {
+ const i = teil.indexOf("=");
+ return { name: teil.slice(0, i), value: teil.slice(i + 1), domain: "127.0.0.1", path: "/" };
+ }));
+ await seite.goto(`http://127.0.0.1:${PORT}/workspace/chat.html`, { waitUntil: "networkidle" });
+ await seite.waitForTimeout(1100);
+
+ const stand = await seite.evaluate(() => ({
+ raeume: document.querySelectorAll(".chat__raum, .raum, [data-raum]").length,
+ text: document.body.innerText,
+ anrufKnopf: !!document.getElementById("anruf-ton-start")
+ || !!document.getElementById("anruf-video-start"),
+ breiter: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
+ }));
+ ok(/Treff/i.test(stand.text), "die Seite nennt den Treff");
+ /* KEIN TELEFONKNOPF. Der Riegel im Server ist das eine -- ein Knopf,
+ der mit 404 antwortet, waere trotzdem ein Knopf, den man drueckt. */
+ ok(stand.anrufKnopf === false, "und es gibt keinen Anrufknopf");
+ ok(!stand.breiter, "auf 412 px steht nichts ueber dem rechten Rand");
+
+ /* DAS RAUM OEFFNEN und nachsehen, was ueber dem Eingabefeld steht. */
+ const raum = seite.locator(".chat__liste button, .chat__raum, [data-raum-id]").first();
+ if (await raum.count()) { await raum.click(); await seite.waitForTimeout(700); }
+
+ const unten = await seite.evaluate(() => {
+ const band = document.getElementById("nachtruhe");
+ const feld = document.getElementById("text");
+ return {
+ bandDa: !!band && !band.hidden,
+ bandText: band ? band.innerText.trim() : "",
+ lage: band ? (band.dataset.lage || "") : "",
+ feldAus: !!feld && feld.disabled,
+ klammerAus: !!document.getElementById("anhang-auf")
+ && document.getElementById("anhang-auf").disabled,
+ platzhalter: feld ? feld.placeholder : "",
+ };
+ });
+
+ if (NACHTLAUF) {
+ ok(unten.bandDa, "nachts steht das Band ueber dem Eingabefeld");
+ ok(/schläft/i.test(unten.bandText),
+ `und sagt, was los ist: „${unten.bandText.slice(0, 60)}…“`);
+ ok(unten.lage === "zu", `es traegt die Lage „zu“ (${unten.lage})`);
+ ok(unten.feldAus, "das Eingabefeld ist aus");
+ /* UND DIE BUEROKLAMMER. Der Server lehnt Anhaenge nachts ebenso ab;
+ ein offener Knopf hiesse: Bild aussuchen, hochladen, Absage --
+ die Arbeit ist dann schon gemacht. */
+ ok(unten.klammerAus, "die Büroklammer ebenfalls");
+ ok(/ab \d\d:00/i.test(unten.platzhalter),
+ `und der Platzhalter sagt, ab wann wieder: „${unten.platzhalter}“`);
+ } else {
+ /* GEGENPROBE: Bei Tag ist das Band weg und das Feld an. Ohne diese
+ Zeile waere "das Band steht da" auch dann gruen, wenn es IMMER
+ dasteht -- und ein Band, das immer da ist, liest niemand. */
+ ok(!unten.bandDa, "bei Tag steht kein Band da");
+ ok(!unten.feldAus, "und das Eingabefeld ist an");
+ ok(!unten.klammerAus, "die Büroklammer auch");
+ }
+
+ if (process.env.SCHIRM) {
+ await seite.screenshot({
+ path: `C:/Users/qciga/Documents/Obelix/DogiHompage/treffchat-${NACHTLAUF ? "nacht" : "tag"}.png`,
+ fullPage: true,
+ });
+ }
+ /* KEINE VERGEBLICHE ANFRAGE. Ein 404 bei jedem Seitenaufruf ist
+ Rauschen, und Rauschen macht den naechsten ECHTEN Fehler
+ unsichtbar -- dieselbe Regel wie bei der Warnung, die immer kommt. */
+ ok(vergeblich.length === 0,
+ vergeblich.length
+ ? `vergebliche Anfragen: ${[...new Set(vergeblich)].join(" | ")}`
+ : "keine einzige Anfrage laeuft ins Leere");
+ ok(konsole.length === 0,
+ konsole.length ? "Fehler im Browser: " + konsole[0].slice(0, 110) : "keine Fehler im Browser");
+ await browser.close();
+}
+
+/* =======================================================================
+ 7. UND JETZT DASSELBE MIT GELTENDER NACHTRUHE
+
+ Derselbe Code, andere Lage. Ohne diesen zweiten Lauf waere die
+ Nachtruhe nur behauptet: Alles oben ist auch dann gruen, wenn die
+ Sperre gar nicht existiert.
+ ======================================================================= */
+if (!NACHTLAUF) {
+ melde("");
+ melde("=== 7. Derselbe Lauf, aber mit geltender Nachtruhe ===");
+ const { spawnSync } = await import("node:child_process");
+ const lauf = spawnSync(process.execPath, [new URL(import.meta.url).pathname.slice(1)], {
+ encoding: "utf8",
+ env: { ...process.env, TREFF_PRUEF_NACHT: "ja", WORKSPACE_DB: "", PORT: "" },
+ });
+ const zeilen = String(lauf.stdout || "").split(String.fromCharCode(10));
+ for (const z of zeilen) {
+ if (/^\s{2}(ok|FEHL)\s/.test(z)) {
+ geprueft++;
+ if (z.includes("FEHL")) fehler++;
+ console.log(z.replace(/^\s{2}/, " "));
+ }
+ }
+ if (lauf.status !== 0 && !zeilen.some((z) => /FEHL/.test(z))) {
+ /* DER DRITTE AUSGANG: Der zweite Lauf ist gar nicht gelaufen.
+ Das ist etwas anderes als "er hat Fehler gefunden" -- und es
+ darf nicht wie ein bestandener Lauf aussehen. */
+ ok(false, `der Nachtlauf konnte nicht starten (Rueckgabewert ${lauf.status}). `
+ + String(lauf.stderr || "").split(String.fromCharCode(10))[0]);
+ }
+}
+
+melde("");
+melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
+melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ }
+process.exit(fehler ? 1 : 0);
diff --git a/server/rechte.js b/server/rechte.js
index bf73aa67..bbea95b4 100644
--- a/server/rechte.js
+++ b/server/rechte.js
@@ -132,8 +132,18 @@ export const SEITEN = {
/* Chat (06.09.2026): jede Rolle. WEN man erreicht, entscheidet
schreibbareIds() -- nicht der Zugang zur Seite. Ein Creator, der
die Seite nicht öffnen dürfte, könnte auch nicht antworten, und
- genau das Antworten ist der Zweck. */
- "/workspace/chat.html": [...ALLE],
+ genau das Antworten ist der Zweck.
+
+ SEIT DEM 19.09.2026 AUCH DIE COMMUNITY. Sie bekommt genau EINEN
+ Raum -- den Treff -- und sonst keinen: `schreibbareIds` liefert
+ ihr weiterhin eine leere Liste (ohneAussen), ein Zweier-Gespraech
+ entsteht also nicht. Was sie sieht, ist der Kanal, in dem sie
+ Mitglied ist.
+
+ Und sie telefoniert nicht: Der Riegel dafuer sitzt am
+ Anruf-Router, nicht hier (Filipe: "die sollen nicht anrufen
+ koennen. nur schreiben"). */
+ "/workspace/chat.html": [...ALLE, "gast"],
/* NUR DogFather (07.09.2026). Wunsch Filipe: "nimm die kategorie
zahlen bei jedem weg, nur die rolle dogfather soll die sehen."
diff --git a/server/workspace-anruf.js b/server/workspace-anruf.js
index 648e209c..1614dd14 100644
--- a/server/workspace-anruf.js
+++ b/server/workspace-anruf.js
@@ -72,6 +72,7 @@ import {
istDogFather,
} from "./workspace.js";
import { chatEreignis, istDrinFuerAnruf, teilnehmerFuerAnruf } from "./workspace-chat.js";
+import { darfTelefonieren } from "./workspace-treffchat.js";
import { mitZugangsdaten } from "./workspace-turn.js";
export const anrufRouter = express.Router();
@@ -259,6 +260,35 @@ function gleicheHerkunft(req, res, next) {
anrufRouter.use("/workspace/api/anruf", angemeldet);
+/* =====================================================================
+ DIE COMMUNITY TELEFONIERT NICHT (19.09.2026)
+
+ Filipe, als er den Treff-Chat bestellt hat: „und die sollen nicht
+ anrufen koennen. nur schreiben."
+
+ WARUM DAS EINEN EIGENEN RIEGEL BRAUCHT, obwohl niemand es je erlaubt
+ hat: Ein Anruf haengt in diesem Haus an der RAUMMITGLIEDSCHAFT
+ (`istDrin`), nicht an der Rolle. Solange die Community gar keinen
+ Raum hatte, war die Frage muessig. Seit sie einen hat, waere
+ Telefonieren erlaubt -- nicht, weil es jemand entschieden haette,
+ sondern weil es niemand verboten hat. Das ist die gefaehrlichste
+ Art von Erlaubnis: Sie entsteht durch eine Aenderung an einer ganz
+ anderen Stelle, und sie faellt niemandem auf.
+
+ HIER UND NICHT IN DEN NEUN ROUTEN DAHINTER. Neun Stellen hiessen
+ acht richtige und eine vergessene, und die vergessene waere die,
+ die keiner ausprobiert. Dieselbe Begruendung wie bei `istDrin`
+ nebenan.
+
+ 404 UND NICHT 403: Wer "kein Zugriff" liest, weiss, dass es etwas
+ gibt. Fuer jemanden aus der Community existiert die Anruffunktion
+ schlicht nicht -- die Oberflaeche zeigt sie ihm auch nicht.
+ ===================================================================== */
+anrufRouter.use("/workspace/api/anruf", (req, res, next) => {
+ if (darfTelefonieren(req.person)) return next();
+ return res.status(404).json({ fehler: "nicht_gefunden" });
+});
+
const nummer = (x) => { const n = Number(x); return Number.isInteger(n) && n > 0 ? n : 0; };
/* =====================================================================
diff --git a/server/workspace-chat.js b/server/workspace-chat.js
index 828f618b..fe6e24f8 100644
--- a/server/workspace-chat.js
+++ b/server/workspace-chat.js
@@ -49,6 +49,14 @@ import {
import { KATALOG, ERLAUBT, VORSCHLAG, FAVORITEN_MAX } from "./workspace-reaktionen.js";
+/* Der Treff-Chat: eigener Raum, eigene Nachtruhe, keine Anrufe.
+ Eigene Datei, weil er eigene Regeln hat -- und weil diese hier
+ mit 2000 Zeilen schon gross genug ist. */
+import {
+ TREFF_KATEGORIE, TREFF_NAME, istTreffRaum, treffAngleichen,
+ gehoertInTreff, nachtruheStand, istNachtruhe, darfTelefonieren,
+} from "./workspace-treffchat.js";
+
export const chatRouter = express.Router();
const TEXT_MAX = 4000;
@@ -339,6 +347,10 @@ const PIN_MAX = 3;
* der Nachrichten darin. Wer die Kategorie umbenennt, benennt damit
* jeden Kanal um; sie stehen nicht doppelt. */
function kanalName(kategorie) {
+ /* Der Treff steht nicht in MODI_KATEGORIEN -- er ist kein Thema des
+ Teams, sondern ein eigener Raum mit eigenen Regeln. Ohne diese
+ Zeile hiesse er schlicht "Kanal". */
+ if (kategorie === TREFF_KATEGORIE) return TREFF_NAME;
return MODI_KATEGORIEN.find((k) => k.wert === kategorie)?.name || "Kanal";
}
@@ -457,6 +469,12 @@ chatRouter.get("/workspace/api/chat/raeume", (req, res) => {
nur ueber einen Link, den ihr jemand schickt. Das ist genau die
Sorte Loch, die man nicht bemerkt, weil nichts kaputt aussieht. */
kanaeleAngleichen(req.person);
+ /* UND DER TREFF. `kanaeleAngleichen` traegt nur die Teamfuehrung
+ ein; im Treff stehen ausserdem die Community und die Modis.
+ Derselbe Gedanke, andere Personengruppe -- und aus demselben
+ Grund selbstheilend: Ein neuer Zugang aus der Community soll im
+ Chat stehen, ohne dass jemand daran denken muss. */
+ if (gehoertInTreff(req.person)) treffAngleichen();
const reihen = db().prepare(`
SELECT r.id, r.art, r.name, r.kategorie, r.letzte_am, r.erstellt_von,
@@ -534,6 +552,23 @@ chatRouter.get("/workspace/api/chat/raeume", (req, res) => {
raeume,
darf_kanal: darfKanal,
kanal_frei: darfKanal ? MODI_KATEGORIEN.filter((k) => !belegt.has(k.wert)) : [],
+ /* WAS FUER DEN TREFF GILT, STEHT IN DER ANTWORT -- die Seite
+ rechnet die Nachtruhe NICHT selbst nach. Eine zweite Rechnung
+ im Browser waere die, die bei der naechsten Zeitumstellung
+ anders herauskommt als die im Server; dann sagt die Seite
+ "offen", waehrend der Server ablehnt. */
+ treff: gehoertInTreff(req.person) ? nachtruheStand() : null,
+ /* AUSDRUECKLICH GESAGT, NICHT ERRATEN (19.09.2026).
+
+ Die Anrufknoepfe verschwinden fuer die Community nicht von
+ selbst, nur weil die Schnittstelle 404 antwortet -- sie stuenden
+ da und taeten nichts. Ein Knopf, der nichts tut, ist schlimmer
+ als kein Knopf: Man drueckt ihn wieder.
+
+ Die Antwort kommt aus DERSELBEN Funktion, die auch der
+ Anruf-Router benutzt. Zwei Rechnungen waeren die Stelle, an der
+ die Seite einen Knopf zeigt, den der Server ablehnt. */
+ darf_anrufen: darfTelefonieren(req.person),
});
} catch (fehler) {
console.error("[chat] Räume:", fehler?.message);
@@ -930,6 +965,37 @@ chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft,
if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" });
if (!istDrin(raumId, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
+ /* ==== DIE NACHTRUHE ==============================================
+
+ Filipe: "quasi nach mitternacht bis morgens 6 uhr keiner
+ schreiben kann damit auch die privatsphaere beruecksichtig
+ wird ueber nacht."
+
+ SIE STEHT HIER UND NICHT NUR IM BROWSER. Ein ausgegrauter
+ Knopf ist eine Bitte; abgelehnt wird erst hier. Wer die
+ Schnittstelle direkt anspricht -- und das kann jeder, der die
+ Adresse kennt --, kaeme sonst durch.
+
+ SIE GILT FUER ALLE, AUCH FUER DIE LEITUNG. Eine Nachtruhe, von
+ der DogFather ausgenommen waere, ist keine: Wenn er um halb
+ vier schreibt, liest es jemand, und wer es liest, fuehlt sich
+ zustaendig. Genau den Druck soll sie wegnehmen.
+
+ NUR IM TREFF. Das Team hat keine Nachtruhe im Chat -- dort
+ arbeiten Leute nachts, das ist der Beruf. Hier geht es um
+ Zuschauer und um die, die sie betreuen.
+
+ MODERIEREN GEHT WEITER: Verbergen, loeschen, jemanden
+ herausnehmen sind keine Nachrichten und laufen ueber andere
+ Wege. Wer nachts etwas Schlimmes sieht, kann es wegnehmen --
+ er kann nur nicht darueber diskutieren. */
+ if (istTreffRaum(raumId) && istNachtruhe()) {
+ const stand = nachtruheStand();
+ return res.status(403).json({
+ fehler: stand.satz, nachtruhe: stand,
+ });
+ }
+
const text = String(req.body?.text ?? "").trim();
if (!text) return res.status(400).json({ fehler: "leer" });
if (text.length > TEXT_MAX) {
@@ -1024,6 +1090,14 @@ chatRouter.post("/workspace/api/chat/raeume/:id/anhang",
(req, res) => {
try {
const raumId = Number(req.params.id);
+ /* DIE NACHTRUHE GILT AUCH FUER ANHAENGE. Sie nur beim Text zu
+ pruefen hiesse, dass man nachts zwar nicht schreiben, aber ein
+ Foto mit Bildunterschrift schicken kann -- der Weg, den jeder
+ findet, der es einmal versucht. */
+ if (istTreffRaum(raumId) && istNachtruhe()) {
+ const stand = nachtruheStand();
+ return res.status(403).json({ fehler: stand.satz, nachtruhe: stand });
+ }
if (!Buffer.isBuffer(req.body) || !req.body.length) {
return res.status(400).json({ fehler: "Keine Datei empfangen." });
}
@@ -1355,6 +1429,21 @@ function reaktionenZu(ids, personId) {
Zweimal dasselbe Zeichen heisst: wieder weg. Das erspart einen
zweiten Weg zum Zuruecknehmen -- und einen zweiten Weg, den man
vergisst abzusichern. */
+/* ==== DIE NACHTRUHE GILT HIER ABSICHTLICH NICHT (19.09.2026) ========
+
+ Eine Reaktion ist keine Nachricht. Sie sagt nichts, was jemand
+ beantworten muesste, sie kann keinen Streit anfangen, und sie loest
+ keine Meldung aus (siehe die Begruendung weiter unten: „Ein Daumen
+ ist keine Nachricht").
+
+ Filipes Ansage war „keiner schreiben kann". Ein Daumen ist kein
+ Schreiben -- und genau darum geht es bei der Nachtruhe: Sie soll den
+ Druck wegnehmen, nachts antworten zu muessen, nicht den Raum
+ abschliessen.
+
+ DAS STEHT HIER, damit es nicht wie ein Versehen aussieht. Eine
+ Luecke, die niemand begruendet hat, wird beim naechsten Durchsehen
+ entweder geschlossen oder uebersehen -- beides falsch. */
chatRouter.put("/workspace/api/chat/nachrichten/:id/reaktion", gleicheHerkunft,
express.json({ limit: "2kb" }), (req, res) => {
try {
diff --git a/server/workspace-hinweise.js b/server/workspace-hinweise.js
index 9b37c29e..08b8c435 100644
--- a/server/workspace-hinweise.js
+++ b/server/workspace-hinweise.js
@@ -27,6 +27,7 @@ import {
db, sitzungLesen, betreuteIds, istLeitung, istDogFather, siehtAlles, pipelineIds,
gehoertAufDieseAdresse,
} from "./workspace.js";
+import { darfSeite } from "./rechte.js";
import { sichtbar as sichtbarAufgaben } from "./workspace-aufgaben.js";
import { sichtbar as sichtbarTermine } from "./workspace-kalender.js";
import { sichtbar as sichtbarDateien } from "./workspace-dateien.js";
@@ -106,8 +107,31 @@ hinweisRouter.get("/workspace/api/hinweise", (req, res) => {
DIESELBE REGEL, NICHT EINE ZWEITE: Was auf dieser Adresse
keine Seite hat, bekommt hier auch keinen Hinweis. Eine eigene
Liste hier waere die, die beim naechsten Umbau auseinanderlaeuft. */
- if (ziel && !gehoertAufDieseAdresse(hier,
- "/workspace/" + String(ziel).split("?")[0])) return;
+ const zielPfad = ziel ? "/workspace/" + String(ziel).split("?")[0] : null;
+ if (zielPfad && !gehoertAufDieseAdresse(hier, zielPfad)) return;
+
+ /* ==== UND DIE ROLLE, NICHT NUR DIE ADRESSE (19.09.2026) =========
+
+ `gehoertAufDieseAdresse` beantwortet die Frage "gibt es diese
+ Seite hier?" -- nicht "darf DIESE Person sie oeffnen?". Der
+ Unterschied ist am 19.09. sofort aufgefallen, als der erste
+ Hinweis dazukam, der nicht fuer alle gilt:
+
+ Ein Mitglied der Community bekam "Bei dir klingelt nichts" mit
+ Weg auf `anruf-probe.html` -- eine Seite, die ihm ausdruecklich
+ NICHT offensteht. Gefunden von pruef-community-sicht, die
+ genau danach sucht: sichtbare Wege, die ins Leere fuehren.
+
+ Das ist der 15.09. noch einmal, nur eine Ebene tiefer. Damals
+ war es die Adresse ("wieso steht es noch da?"), diesmal die
+ Rolle. Und es ist dieselbe Lehre: Die Hinweise sind eine
+ DRITTE Stelle neben Seiten und Schnittstellen, und jede
+ Schranke, die es dort gibt, muss es auch hier geben.
+
+ DIESELBE TAFEL, NICHT EINE ZWEITE: `darfSeite` ist die
+ Funktion, die auch die Tuer bewacht. Eine eigene Liste hier
+ waere die, die beim naechsten Umbau auseinanderlaeuft. */
+ if (zielPfad && !darfSeite(person, zielPfad)) return;
hinweise.push({ art, stufe, text, ziel, anzahl });
};
diff --git a/server/workspace-treffchat.js b/server/workspace-treffchat.js
new file mode 100644
index 00000000..94c479d8
--- /dev/null
+++ b/server/workspace-treffchat.js
@@ -0,0 +1,284 @@
+/* =====================================================================
+ DER TREFF-CHAT — mit Nachtruhe (19.09.2026)
+
+ Filipe, auf die Frage, ob die Community einen Chat bekommen soll:
+
+ „eine geile mischung von punkt 2 und drei. mach es perfekt so das
+ quasi nach mitternacht bis morgens 6 uhr keiner schreiben kann
+ damit auch die privatsphäre berücksichtig wird über nacht.
+ perfektionier das bitte. mach es übelst krass geil. und die
+ sollen nicht anrufen können. nur schreiben."
+
+ Zur Auswahl standen „gar kein Chat", „nur mit Öffnungszeiten" und
+ „offen wie im Team". Seine Antwort ist die dritte Möglichkeit, die
+ nicht auf dem Zettel stand, und sie ist die bessere: offen den
+ ganzen Tag, ZU über Nacht.
+
+ ---------------------------------------------------------------------
+ WARUM DAS MEHR IST ALS EINE EINSTELLUNG
+
+ Der Grund, den er nennt, ist nicht Bequemlichkeit, sondern
+ Privatsphäre — und zwar die der Leute, die moderieren. Die Forschung
+ zu ehrenamtlichen Moderatoren ist an diesem Punkt eindeutig: Die
+ beiden meistgenannten Gründe fürs Aufhören sind ZU WENIG ZEIT und
+ STREIT IM TEAM. Ein Chat, der nachts um drei weiterläuft, erzeugt
+ beides — die Nachricht steht da, jemand fühlt sich zuständig, und am
+ nächsten Morgen ist aus einem Satz ein Streit geworden, den niemand
+ früh genug gesehen hat.
+
+ Eine Nachtruhe nimmt diesen Druck heraus, ohne irgendjemandem etwas
+ zu verbieten: Gelesen werden darf weiter, geschrieben ab sechs.
+
+ ---------------------------------------------------------------------
+ SIE GILT FÜR ALLE, AUCH FÜR DIE LEITUNG
+
+ Das ist die wichtigste Entscheidung dieser Datei, und sie ist
+ unbequem. Der bequeme Weg wäre, DogFather und die rechte Hand
+ auszunehmen — sie sollen ja jederzeit eingreifen können.
+
+ Aber: Wenn DogFather um halb vier schreibt, liest es jemand. Und wer
+ es liest, fühlt sich zuständig. Eine Nachtruhe, von der die Leitung
+ ausgenommen ist, ist keine Nachtruhe, sondern eine Bitte — und
+ Bitten dieser Art halten genau so lange, bis es einmal eilig ist.
+
+ MODERIEREN GEHT TROTZDEM. Verbergen, löschen, jemanden
+ herausnehmen — das sind keine Nachrichten, sondern Eingriffe, und
+ die bleiben rund um die Uhr möglich. Wer nachts etwas Schlimmes
+ sieht, kann es wegnehmen; er kann nur nicht darüber diskutieren.
+
+ UND DER VERTRAULICHE MELDEWEG BLEIBT OFFEN. Er ist kein Chat,
+ sondern ein Fall mit einer Zuständigkeit. Wer nachts Hilfe braucht,
+ hat sie dort — nicht in einem Raum mit dreißig Leuten.
+
+ ---------------------------------------------------------------------
+ GERECHNET WIRD IN ORTSZEIT, NICHT IN UTC
+
+ `new Date().getHours()` liefert die Stunde des SERVERS. Steht der auf
+ UTC, geht der Chat im Sommer um zwei Uhr nachts zu und um acht Uhr
+ morgens wieder auf — beides zwei Stunden daneben, und beides fällt
+ nur denen auf, die um diese Zeit wach sind.
+
+ Dieses Haus ist mit genau dieser Rechnung schon zweimal
+ hereingefallen (06.09. und 16.09., beide Male in Prüfungen, beide
+ Male nur nachts sichtbar). Deshalb steht hier `Intl` mit
+ `Europe/Berlin` und nicht `getHours()`.
+
+ ---------------------------------------------------------------------
+ KEINE ANRUFE — UND DER RIEGEL SITZT AN EINER STELLE
+
+ „die sollen nicht anrufen können. nur schreiben."
+
+ Ein Anruf hängt in diesem Haus an der RAUMMITGLIEDSCHAFT
+ (`istDrin`). Sobald die Community einen Raum hat, könnte sie also
+ telefonieren — nicht, weil es jemand erlaubt hätte, sondern weil es
+ niemand verboten hat. Das ist die gefährlichste Art von Erlaubnis.
+
+ Der Riegel steht deshalb GANZ VORNE am Anruf-Router, nicht in den
+ neun Routen dahinter. Neun Stellen hieße acht richtige und eine
+ vergessene, und die vergessene wäre die, die keiner ausprobiert.
+ ===================================================================== */
+
+import { db, darfTelefonieren } from "./workspace.js";
+import { AUSSEN_ROLLEN } from "./crew-adresse.js";
+
+/** Die Kategorie des Treff-Kanals.
+ *
+ * ALS KANAL UND NICHT ALS EIGENE RAUMART: `chat_raeume.art` hat eine
+ * CHECK-Bedingung ('direkt','gruppe','kanal'). Eine vierte Art hieße,
+ * die Tabelle an lebenden Daten neu zu bauen — für etwas, das eine
+ * Kategorie genauso gut sagt. Ein Kanal ist ohnehin genau das
+ * Richtige: Er gehört dem Haus, nicht den Anwesenden, und die
+ * Leitung kommt über `istDrin` hinein, ohne dass jemand sie einträgt. */
+export const TREFF_KATEGORIE = "treff";
+export const TREFF_NAME = "Der Treff";
+
+/** Wer im Treff-Chat steht.
+ *
+ * Die Community UND die, die moderieren. Ein Raum voller Zuschauer
+ * ohne Moderation wäre nicht mutig, sondern fahrlässig — und die
+ * Nachtruhe ist kein Ersatz dafür, sie verkürzt nur die Zeit, in der
+ * jemand hinsehen muss. */
+export const TREFF_ROLLEN = ["gast", "modi", "hand", "admin"];
+
+/** Ab wann und bis wann niemand schreibt — Ortszeit, Stunden.
+ *
+ * 0 BIS 6, wörtlich nach Filipes Ansage („nach mitternacht bis
+ * morgens 6 uhr"). Als Umgebungsvariable, damit eine andere Zeit
+ * keine Programmänderung ist — dieselbe Bauweise wie bei der
+ * Push-Ruhezeit (22–7, die ist eine ANDERE Sache und bleibt getrennt:
+ * dort geht es darum, wann das Telefon klingelt, hier darum, wann
+ * überhaupt jemand schreiben kann). */
+export const NACHT_AB = Number(process.env.TREFF_NACHT_AB ?? 0);
+export const NACHT_BIS = Number(process.env.TREFF_NACHT_BIS ?? 6);
+
+/** Die Stunde in Europe/Berlin — nicht die des Servers. */
+const STUNDE = new Intl.DateTimeFormat("de-DE", {
+ timeZone: "Europe/Berlin", hour: "2-digit", hour12: false,
+});
+export function stundeInBerlin(d = new Date()) {
+ /* „24" gibt es: Manche Umgebungen liefern fuer Mitternacht 24 statt
+ 0. Ungeprueft waere der Chat dann ausgerechnet in der ersten
+ Stunde der Nachtruhe offen. */
+ const s = Number(STUNDE.format(d).replace(/\D/g, ""));
+ return s === 24 ? 0 : s;
+}
+
+/** Die Minute in Europe/Berlin — für den Countdown. */
+const MINUTE = new Intl.DateTimeFormat("de-DE", {
+ timeZone: "Europe/Berlin", minute: "2-digit",
+});
+function minuteInBerlin(d = new Date()) {
+ return Number(MINUTE.format(d).replace(/\D/g, ""));
+}
+
+/** Schläft der Chat gerade?
+ *
+ * ÜBER MITTERNACHT HINWEG GEDACHT. Mit AB=0 und BIS=6 ist es der
+ * einfache Fall (0 ≤ s < 6). Setzt jemand später AB=23, muss die
+ * Rechnung den Tageswechsel mitmachen — sonst wäre die Nachtruhe
+ * zwischen 23 und 24 Uhr aus. Beide Fälle stehen hier, damit die
+ * Einstellung wirklich eine Einstellung ist und keine Falle. */
+export function istNachtruhe(d = new Date()) {
+ if (NACHT_AB === NACHT_BIS) return false; // keine Nachtruhe
+ const s = stundeInBerlin(d);
+ return NACHT_AB < NACHT_BIS
+ ? (s >= NACHT_AB && s < NACHT_BIS)
+ : (s >= NACHT_AB || s < NACHT_BIS);
+}
+
+/** Wie viele Minuten noch, bis sich der Zustand ändert.
+ *
+ * Für den Countdown auf der Seite. Er ist der Unterschied zwischen
+ * „der Chat ist plötzlich zu" und „ich weiß seit zehn Minuten, dass
+ * gleich Schluss ist". */
+export function minutenBisWechsel(d = new Date()) {
+ const s = stundeInBerlin(d), m = minuteInBerlin(d);
+ const jetztMin = s * 60 + m;
+ const ziel = (istNachtruhe(d) ? NACHT_BIS : NACHT_AB) * 60;
+ let diff = ziel - jetztMin;
+ if (diff <= 0) diff += 24 * 60;
+ return diff;
+}
+
+/** Alles, was die Seite über die Nachtruhe wissen muss — an einer Stelle.
+ *
+ * Die Oberfläche rechnet NICHTS davon selbst nach. Eine zweite
+ * Rechnung im Browser wäre die, die bei der nächsten Zeitumstellung
+ * anders herauskommt als die im Server — und dann sagt die Seite
+ * „offen", während der Server ablehnt. */
+export function nachtruheStand(d = new Date()) {
+ const zu = istNachtruhe(d);
+ return {
+ zu,
+ ab: NACHT_AB,
+ bis: NACHT_BIS,
+ minuten: minutenBisWechsel(d),
+ /* Der fertige Satz kommt vom Server, nicht aus dem Browser. So
+ steht er in der Absage UND auf der Seite gleich — und wer ihn
+ ändern will, ändert ihn einmal. */
+ satz: zu
+ ? `Der Treff schläft. Ab ${String(NACHT_BIS).padStart(2, "0")}:00 Uhr `
+ + "geht es weiter – gelesen werden darf die ganze Nacht."
+ : `Nachts zwischen ${String(NACHT_AB).padStart(2, "0")}:00 und `
+ + `${String(NACHT_BIS).padStart(2, "0")}:00 Uhr schreibt hier niemand.`,
+ };
+}
+
+/** Darf diese Person überhaupt telefonieren?
+ *
+ * EINE FRAGE, EINE ANTWORT. Sie wird ganz vorne am Anruf-Router
+ * gestellt, nicht in den neun Routen dahinter.
+ *
+ * GERECHNET WIRD SIE IN workspace.js, weil `/api/ich` sie ebenfalls
+ * braucht -- und jene Datei kann nicht aus dieser importieren, ohne
+ * einen Ring zu bilden. Hier steht nur die Durchreichung, damit der
+ * Anruf-Router und der Chat sie unter demselben Namen finden. */
+export { darfTelefonieren };
+
+/** Gehört diese Person in den Treff-Chat? */
+export function gehoertInTreff(person) {
+ return TREFF_ROLLEN.includes(person?.rolle);
+}
+
+/* ---------------------------------------------------------------------
+ DER RAUM
+ ------------------------------------------------------------------- */
+
+const jetzt = () => new Date().toISOString();
+
+/** Die Nummer des Treff-Kanals — angelegt, falls es ihn noch nicht gibt.
+ *
+ * GENAU EINER. Zwei Räume mit demselben Zweck sind zwei Räume mit je
+ * der Hälfte der Nachrichten darin, und niemand merkt, in welchem er
+ * gerade steht. Deshalb wird gesucht, bevor angelegt wird. */
+export function treffRaumId() {
+ const d = db();
+ const da = d.prepare(
+ "SELECT id FROM chat_raeume WHERE art = 'kanal' AND kategorie = ?")
+ .get(TREFF_KATEGORIE);
+ if (da) return da.id;
+
+ d.prepare(`INSERT INTO chat_raeume (art, name, kategorie, erstellt, erstellt_von)
+ VALUES ('kanal', ?, ?, ?, NULL)`)
+ .run(TREFF_NAME, TREFF_KATEGORIE, jetzt());
+ const id = d.prepare("SELECT last_insert_rowid() AS id").get().id;
+ console.log(`[treff] Chat-Raum angelegt (Nummer ${id}).`);
+ return id;
+}
+
+/** Trägt ein, wer hineingehört und noch fehlt.
+ *
+ * SELBSTHEILEND, wie der Kanal-Abgleich der Teamführung nebenan —
+ * und aus demselben Grund: Die Zeile müsste sonst beim Anlegen einer
+ * Person, beim Rollenwechsel und beim Anlegen des Raums entstehen.
+ * Drei Stellen, von denen die dritte vergessen wird.
+ *
+ * `gelesen_bis` startet am aktuellen Stand. Wer heute dazukommt, hat
+ * die vierhundert Nachrichten von vorher nicht verpasst — eine rote
+ * Vierhundert wäre keine Auskunft, sondern Rauschen.
+ *
+ * WER AUSGETRETEN IST, BLEIBT DRAUSSEN. `raus_am IS NOT NULL` heißt:
+ * Die Moderation hat jemanden herausgenommen. Ihn beim nächsten
+ * Seitenaufruf still wieder einzutragen wäre die zuverlässigste Art,
+ * eine Moderationsentscheidung unwirksam zu machen. */
+export function treffAngleichen() {
+ try {
+ const id = treffRaumId();
+ const d = db();
+ const platz = TREFF_ROLLEN.map(() => "?").join(", ");
+ const fehlen = d.prepare(`
+ SELECT p.id FROM personen p
+ WHERE p.aktiv = 1 AND p.rolle IN (${platz})
+ AND NOT EXISTS (SELECT 1 FROM chat_teilnehmer t
+ WHERE t.raum_id = ? AND t.person_id = p.id)`)
+ .all(...TREFF_ROLLEN, id);
+ if (!fehlen.length) return 0;
+
+ const rein = d.prepare(`
+ INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis, leitung)
+ SELECT ?, ?, ?, COALESCE((SELECT MAX(id) FROM chat_nachrichten WHERE raum_id = ?), 0), ?`);
+ const n = jetzt();
+ for (const p of fehlen) {
+ /* Leitung im Raum sind die, die auch sonst moderieren. Ein Gast
+ ist Teilnehmer, kein Leiter -- sonst dürfte er anheften und
+ andere herausnehmen. */
+ const person = d.prepare("SELECT rolle FROM personen WHERE id = ?").get(p.id);
+ const leitung = person && person.rolle !== "gast" ? 1 : 0;
+ rein.run(id, p.id, n, id, leitung);
+ }
+ return fehlen.length;
+ } catch (fehler) {
+ /* Ein Fehler hier darf die Gespraechsliste nicht kosten. */
+ console.error("[treff] Abgleich:", fehler?.message);
+ return 0;
+ }
+}
+
+/** Ist das der Treff-Raum? */
+export function istTreffRaum(raumId) {
+ try {
+ const r = db().prepare(
+ "SELECT kategorie FROM chat_raeume WHERE id = ? AND art = 'kanal'").get(raumId);
+ return r?.kategorie === TREFF_KATEGORIE;
+ } catch { return false; }
+}
diff --git a/server/workspace.js b/server/workspace.js
index 2834f00c..32d8d812 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -987,6 +987,27 @@ const TREFF_GRUPPE = {
const TREFF_BEREICHE = [
{ ...TREFF_GRUPPE, name: "Der Treff", unter: "Hallo sagen, fragen, loben",
zeichen: "community", ton: 34, gross: true, ziel: "bereich.html?b=treff", szene: "lounge" },
+ /* DER CHAT (19.09.2026). Filipe: "eine geile mischung von punkt 2
+ und drei ... nach mitternacht bis morgens 6 uhr keiner schreiben
+ kann damit auch die privatsphaere beruecksichtig wird ueber nacht
+ ... und die sollen nicht anrufen koennen. nur schreiben."
+
+ GLEICH AN ZWEITER STELLE, direkt hinter dem grossen Treff-Brett.
+ Er ist das Lebendigste, was die Community hier hat -- weiter unten
+ waere er der Knopf, den man erst findet, wenn einem jemand sagt,
+ dass es ihn gibt.
+
+ ZIEL IST chat.html, dieselbe Seite wie beim Team. Kein zweiter
+ Chat: Zwei Oberflaechen fuer dasselbe waeren zwei Stellen, an
+ denen ein Fehler behoben werden muss, und die zweite waere die,
+ die man vergisst. Was jemand dort SIEHT, entscheidet die
+ Raummitgliedschaft -- ein Gast hat genau einen Raum.
+
+ TON 41: gezaehlt in start.css, nicht in dieser Datei. 40 war die
+ hoechste (siehe die Warnung an WERDEGANG_KACHEL, dort ist genau
+ dieser Fehler heute schon einmal passiert). */
+ { ...TREFF_GRUPPE, name: "Chat", unter: "Miteinander reden – nachts schläft er",
+ zeichen: "chat", ton: 41, ziel: "chat.html", szene: "lounge" },
{ ...TREFF_GRUPPE, name: "Anschlagbrett", unter: "Was gerade gilt",
zeichen: "reports", ton: 33, ziel: "bereich.html?b=anschlag", szene: "halle" },
{ ...TREFF_GRUPPE, name: "Was ansteht", unter: "Die n\u00e4chsten Streams und Events",
@@ -1118,6 +1139,18 @@ const HAND_MIT_TREFF = [...HAND_BEREICHE, ...TREFF_BEREICHE,
importiert von dort. Eine zweite Menge waere die, die auseinanderlaeuft. */
export { AUSSEN_ROLLEN };
+/** Darf diese Person telefonieren?
+ *
+ * HIER UND NICHT AUS workspace-treffchat.js IMPORTIERT: Jene Datei
+ * importiert aus dieser. Ein Import zurueck waere ein Ring, und Ringe
+ * halten in JavaScript genau so lange, bis jemand eine Konstante auf
+ * oberster Ebene benutzt. `workspace-treffchat.js` reicht diese
+ * Funktion deshalb nur WEITER -- gerechnet wird sie an einer Stelle,
+ * naemlich hier. */
+export function darfTelefonieren(person) {
+ return !AUSSEN_ROLLEN.has(person?.rolle);
+}
+
/* =====================================================================
KACHEL UND TUER GEHOEREN ZUSAMMEN (11.09.2026)
=====================================================================
@@ -4945,6 +4978,23 @@ workspaceRouter.get("/workspace/api/ich", (req, res) => {
auseinander. Genau die Ueberlegung steht schon zwei Zeilen
darueber; hier gilt sie noch einmal. */
darf_chat: darfSeite(person, "/workspace/chat.html"),
+ /* UND OB SIE TELEFONIEREN DARF (19.09.2026).
+
+ Nicht als Bequemlichkeit: Ohne diese Angabe holt `anruf.js` beim
+ Laden jeder Chatseite die Verbindungsadressen -- und bekommt
+ fuer die Community 404. Der Browser schreibt das in die Konsole,
+ BEVOR JavaScript es abfangen kann; abfangen hilft also nicht,
+ die Anfrage darf gar nicht erst gestellt werden.
+
+ Ein 404 bei jedem Seitenaufruf ist Rauschen, und Rauschen macht
+ den naechsten ECHTEN Fehler unsichtbar -- dieselbe Regel wie bei
+ der Warnung, die immer kommt. Gefunden hat es die Pruefung im
+ Browser, nicht das Lesen.
+
+ AUS DERSELBEN FUNKTION wie der Riegel am Anruf-Router. Zwei
+ Rechnungen waeren die Stelle, an der die Seite ein Telefon
+ anbietet, das der Server ablehnt. */
+ darf_anrufen: darfTelefonieren(person),
/* DIE KACHELN, WENN SIE NICHT IM BROWSER STEHEN DUERFEN.
Fuer die fuenf bekannten Rollen steht hier `null`, und die
@@ -6172,6 +6222,36 @@ export const einladbareIds = (person) => ohneAussen(nurEigene(nurHaus(ohneVerbor
seit es ueberhaupt jemanden zu verbergen gibt; vorher konnte es ihn
nicht geben. */
export const schreibbareIds = (person) => {
+ /* ==== DIE COMMUNITY SCHREIBT NIEMANDEM EINZELN (19.09.2026) ========
+
+ GEFUNDEN VON pruef-treffchat, NICHT BEIM LESEN. Ein Gast konnte
+ ein Zweier-Gespraech mit DogFather aufmachen -- die Route
+ antwortete mit 200 und legte den Raum an.
+
+ WARUM `ohneAussen` DAS NICHT VERHINDERT HAT: Diese Huelle nimmt
+ die Community aus den Listen ANDERER heraus. Sie sorgt dafuer,
+ dass ein Creator keinen Zuschauer anschreiben kann. Die
+ Gegenrichtung -- ein Zuschauer, der IN die Liste schaut -- kam nie
+ vor, weil die Chatseite fuer 'gast' gar nicht offen war.
+
+ SEIT HEUTE IST SIE OFFEN, und damit war die Erlaubnis da. Nicht,
+ weil jemand sie erteilt haette, sondern weil eine Aenderung an
+ einer ganz anderen Stelle sie freigelegt hat. Genau die Sorte
+ Erlaubnis, vor der ich im Anruf-Riegel zwei Dateien weiter
+ ausdruecklich warne -- und hier waere ich selbst hineingelaufen.
+
+ WAS DARAN SCHLIMM IST: Ein Zweier-Gespraech ist kein Kanal. Es hat
+ keine Nachtruhe (die gilt fuer den Treff), kein Modi liest mit,
+ und niemand koennte moderieren. Aus "die Community bekommt einen
+ Chat" waere ungewollt "jeder Zuschauer bekommt eine Standleitung
+ zu DogFather" geworden.
+
+ WAS SIE STATTDESSEN HAT: den Treff -- einen Raum, in dem Modis
+ dabei sind -- und den vertraulichen Meldeweg fuer alles, was
+ niemanden sonst angeht. Beides ist besser als eine Privatleitung:
+ Das eine wird moderiert, das andere hat eine Zustaendigkeit. */
+ if (AUSSEN_ROLLEN.has(person?.rolle)) return [];
+
const ids = ohneAussen(nurHaus(ohneVerborgene(schreibbareIdsRoh(person), person), person));
return ids === null ? null : ids.filter((i) => i !== person?.id);
};
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index cd4a1fd8..df57f60a 100644
--- a/workspace/anruf-probe.html
+++ b/workspace/anruf-probe.html
@@ -16,15 +16,15 @@
verdeckt. Gefunden hat es pruef-struktur.
Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. -->
-
-
+
+
-
+