diff --git a/.gitignore b/.gitignore
index f77cabf6..3b6450c1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -99,3 +99,10 @@ rueckgabewerte-*.txt
# besser als eine, an die ich mich erinnern muss.
zz-*
/*.png.tmp
+
+# Arbeitsreste aus Pruef- und Umbaulaeufen (11.09.2026). Alles unter
+# tools/_ ist Kladde: Ausgaben einzelner Laeufe, kurzlebige Patch-Skripte.
+# Was davon bleiben soll, bekommt einen Namen ohne Unterstrich -- so
+# ist die Entscheidung "gehoert das ins Verzeichnis?" ein Umbenennen
+# und kein Vergessen.
+tools/_*
diff --git a/server/crew-adresse.js b/server/crew-adresse.js
index be8e7fa0..d6a52ccd 100644
--- a/server/crew-adresse.js
+++ b/server/crew-adresse.js
@@ -93,6 +93,25 @@ export const CREW_ADRESSE = "https://crew.dogfather-universe.com";
stehen kann. workspace.js gibt sie fuer die Fachmodule weiter. */
export const TEAM_DOGI_ROLLEN = new Set(["hand", "modi"]);
+/* WER VON AUSSEN KOMMT (11.09.2026).
+
+ Die Community. Sie steht hier und nicht in workspace.js, weil diese
+ Datei von nichts abhaengt -- workspace.js importiert von hier, nicht
+ umgekehrt. Eine zweite Liste dort waere die, die auseinanderlaeuft.
+
+ Der Unterschied zu TEAM_DOGI_ROLLEN ist nicht die Verborgenheit,
+ sondern die Richtung: Das Team gehoert zum Haus und wird vor der
+ Agentur verborgen. Die Community gehoert NICHT zum Haus -- sie
+ bekommt nur, was ausdruecklich fuer sie da ist. */
+export const AUSSEN_ROLLEN = new Set(["gast"]);
+
+/* Die Adresse des Treffs. Eigene Wand, eigenes Haus -- und ausdruecklich
+ NICHT crew.: Diese Adresse ist absichtlich verborgen (kein Eintrag bei
+ Suchmaschinen, keine Rollennamen in ausgelieferten Dateien, Codes nur
+ persoenlich). Dutzende Zuschauer heben jede dieser Eigenschaften auf.
+ Nicht durch einen Fehler, sondern durch die Nutzung. */
+const TREFF_HOSTS = new Set(["treff.dogfather-universe.com"]);
+
/** Die Adresse der Modi-App. Feste Menge, kein Muster -- Falle 1. */
const CREW_HOSTS = new Set(["crew.dogfather-universe.com"]);
@@ -121,6 +140,26 @@ export function istOhneModiAdresse(hostKopfzeile) {
return OHNE_MODI_HOSTS.has(nurHost(hostKopfzeile));
}
+/** Eine Pruefadresse -- localhost und die Nummern, unter denen die
+ * Pruefungen ihren eigenen Server starten.
+ *
+ * Sie steht hier als EIGENE Funktion und nicht als "alles, was keine
+ * der echten Adressen ist". Genau diese Formulierung durch Ausschluss
+ * hat am 11.09.2026 dazu gefuehrt, dass der verborgene Zugang auf der
+ * neuen dritten Wand mitgalt, ohne dass es jemand entschieden haette.
+ * Wer eine vierte Adresse baut, muss sie hier NICHT eintragen -- und
+ * bekommt dadurch von selbst das sichere Verhalten. */
+export function istPruefAdresse(hostKopfzeile) {
+ const h = nurHost(hostKopfzeile);
+ return h === "localhost" || h === "127.0.0.1" || h.startsWith("127.")
+ || h === "::1" || h === "[::1]";
+}
+
+/** Ist das die Adresse des Treffs? */
+export function istTreffAdresse(hostKopfzeile) {
+ return TREFF_HOSTS.has(nurHost(hostKopfzeile));
+}
+
/** Gehoert dieser Pfad zum Workspace-Teil? Genau "/workspace" oder
* alles darunter -- aber NICHT /workspaceXYZ (Falle 3). */
export function istWorkspacePfad(pfad) {
@@ -128,6 +167,57 @@ export function istWorkspacePfad(pfad) {
return p === "/workspace" || p.startsWith("/workspace/");
}
+/* =====================================================================
+ WELCHE WAND GEHOERT ZU WELCHER TUER (11.09.2026)
+ =====================================================================
+
+ Drei Zugangswaende liegen als drei Dateien im selben Ordner, und
+ express.static liefert jede aus, die man beim Namen nennt. Ohne eine
+ Regel kann also jeder jede aufrufen: die Community die Wand von Team
+ Dogi, das Team die Wand des Treffs, und beide auf der Agenturadresse.
+
+ DAS WAR SCHON EINMAL EIN LOCH, und gefunden hat es nicht das Lesen,
+ sondern der Wortleck-Test, der ALLE ausgelieferten Dateien durchsucht.
+ Damals stand die Regel als EIN Sonderfall im Code ("auf den alten
+ Adressen gibt es crew-index.html nicht"). Bei der dritten Wand waeren
+ daraus drei Sonderfaelle geworden, bei der vierten sechs. Deshalb
+ steht hier jetzt eine Tafel: Jede benannte Wand nennt die Adresse,
+ auf die sie gehoert, und die Frage wird einmal beantwortet.
+
+ 404 UND KEINE WEITERLEITUNG: "Gibt es nicht" verraet nichts. Eine
+ Weiterleitung wuerde sagen, dass es die Datei gibt, nur woanders.
+
+ /workspace/index.html STEHT ABSICHTLICH NICHT IN DER TAFEL. Sie ist
+ keine benannte Wand, sondern die Vorgabe -- jede Adresse biegt sie
+ auf ihre eigene um, bevor daraus eine Datei wird. Stuende sie hier,
+ bekaeme man auf crew. und treff. ein 404 statt der eigenen Wand.
+
+ AUF PRUEFADRESSEN GILT DIE TAFEL NICHT, und das ist dieselbe
+ Ueberlegung wie bei OHNE_MODI_HOSTS: Sonst koennten die Pruefungen,
+ die jede Wand im Browser ansehen, keine mehr oeffnen -- sie blieben
+ gruen und wuerden nichts mehr messen. Ein gruener Lauf, der nichts
+ mehr prueft, ist schlimmer als ein roter. */
+const BENANNTE_WAENDE = [
+ { datei: "/workspace/crew-index.html", zuhause: istCrewAdresse },
+ { datei: "/workspace/treff-index.html", zuhause: istTreffAdresse },
+];
+
+/** Wird hier eine Wand verlangt, die zu einer ANDEREN Tuer gehoert? */
+export function istFremdeWand(hostKopfzeile, pfad) {
+ /* Mehrfache Schraegstriche zusammenziehen: express raeumt Punkt-
+ Segmente weg, doppelte Striche aber nicht -- und express.static
+ zieht sie danach selbst zusammen und liefert die Datei aus. Genau
+ daran ist am 04.09.2026 die Schranke des Workspace vorbeigelaufen. */
+ const p = String(pfad || "").toLowerCase().replace(/\/{2,}/g, "/");
+ const wand = BENANNTE_WAENDE.find((w) => w.datei === p);
+ if (!wand) return false;
+ if (istPruefAdresse(hostKopfzeile)) return false;
+ const benannt = istOhneModiAdresse(hostKopfzeile)
+ || istCrewAdresse(hostKopfzeile) || istTreffAdresse(hostKopfzeile);
+ if (!benannt) return false;
+ return !wand.zuhause(hostKopfzeile);
+}
+
/**
* Darf diese Person auf dieser Adresse angemeldet sein?
*
@@ -141,6 +231,16 @@ export function istWorkspacePfad(pfad) {
*/
export function sitzungPasstZurAdresse(rolle, hostKopfzeile) {
const team = TEAM_DOGI_ROLLEN.has(rolle);
+ /* AUSSENROLLEN GEHOEREN AUF GENAU EINE ADRESSE (11.09.2026).
+
+ Und die Bedingung steht in BEIDE Richtungen: Die Community kommt
+ nur auf treff. herein, und auf treff. kommt NUR sie herein. Das
+ Team sieht den Treff auf seiner eigenen Adresse -- es hat dort
+ nichts zu suchen, und ein Konto, das an zwei Waenden funktioniert,
+ ist eine Tuer, die man beim naechsten Umbau uebersieht. */
+ const aussen = AUSSEN_ROLLEN.has(rolle);
+ if (istTreffAdresse(hostKopfzeile)) return aussen;
+ if (aussen) return false;
/* crew.: das Team -- und DogFather, der in beiden Welten arbeitet. */
if (istCrewAdresse(hostKopfzeile)) return team || rolle === "admin";
/* Die echten alten Adressen: alle ausser dem Team. */
@@ -175,36 +275,19 @@ export function sitzungPasstZurAdresse(rolle, hostKopfzeile) {
* Serverumbau lautlos weg.
*/
export function crewWeiche(req, res, next) {
- if (!istCrewAdresse(req.get("host"))) {
- /* DIE ANDERE RICHTUNG -- und sie war ein echtes Loch.
+ /* DIE FREMDE WAND -- fuer JEDE Adresse, auch fuer crew. selbst.
- Die zweite Zugangswand liegt als Datei im selben Ordner wie alles
- andere und wird von express.static ausgeliefert. Ohne diese Zeile
- kann sie JEDER unter ihrem Dateinamen aufrufen, auch auf
- workspace.dogfather-universe.com -- und saehe dort eine
- Anmeldeseite, die es dort gar nicht geben soll.
-
- Gefunden hat das nicht ein Gedanke, sondern eine Pruefung: Der
- Wortleck-Test durchsucht ALLE ausgelieferten Dateien, und dass er
- diese eine ueberhaupt las, war die Antwort auf eine Frage, die
- ich mir nicht gestellt hatte -- naemlich wer sie sonst noch
- abrufen kann.
-
- 404 UND KEINE WEITERLEITUNG: "Gibt es nicht" ist die Antwort, die
- nichts verraet. Eine Weiterleitung wuerde sagen, dass es die
- Datei gibt, nur woanders.
-
- NUR AUF DEN DREI ECHTEN ADRESSEN, nicht "ueberall ausser crew":
- Sonst waere sie auch auf localhost gesperrt, und die Pruefung,
- die die Wand im Browser ansieht, koennte sie nicht mehr oeffnen
- -- sie wuerde gruen bleiben und nichts mehr messen. */
- if (istOhneModiAdresse(req.get("host"))
- && req.path.toLowerCase().replace(/\/{2,}/g, "/") === "/workspace/crew-index.html") {
- return res.status(404).type("html").send("Nicht gefunden.");
- }
- return next();
+ Sie steht vor der Weiche und nicht in ihrem Nein-Zweig. Dort stand
+ sie bis zum 11.09.2026, und das war richtig, solange es zwei Waende
+ gab: Die eigene kann man nicht "fremd" verlangen. Mit der dritten
+ stimmt das nicht mehr -- auf crew. ist die Wand des Treffs fremd,
+ und im Nein-Zweig waere diese Frage dort nie gestellt worden. */
+ if (istFremdeWand(req.get("host"), req.path)) {
+ return res.status(404).type("html").send("Nicht gefunden.");
}
+ if (!istCrewAdresse(req.get("host"))) return next();
+
res.setHeader("X-Robots-Tag", "noindex, nofollow");
/* DAS MANIFEST MACHT DIE APP (10.09.2026).
diff --git a/server/index.js b/server/index.js
index 276eabdf..bc656499 100644
--- a/server/index.js
+++ b/server/index.js
@@ -13,6 +13,7 @@ import { fileURLToPath } from "node:url";
import { gateMiddleware } from "./gate.js";
import { workspaceUmzug } from "./workspace-umzug.js";
import { crewWeiche } from "./crew-adresse.js";
+import { treffWeiche } from "./treff-adresse.js";
import { webdesignGate } from "./webdesign-gate.js";
import { inhaltsrichtlinie } from "./inhaltsrichtlinie.js";
import { workspaceRouter, sichtSetzen } from "./workspace.js";
@@ -23,6 +24,7 @@ import { kalenderRouter } from "./workspace-kalender.js";
import { serienRouter, serienStarten } from "./workspace-serien.js";
import { dateienRouter } from "./workspace-dateien.js";
import { bereicheRouter } from "./workspace-bereiche.js";
+import { treffRouter } from "./workspace-treff.js";
import { reportRouter } from "./workspace-reports.js";
import { scoutRouter } from "./workspace-scouts.js";
import { callRouter } from "./workspace-calls.js";
@@ -127,6 +129,22 @@ app.use(workspaceUmzug);
gehen. Zwei Orte fuer dieselbe Regel waeren einer zu viel. */
app.use(crewWeiche);
+/* Die eigene Adresse des Treffs (11.09.2026): treff.dogfather-universe.com.
+ Begruendung in treff-adresse.js, geprueft in pruef-treff-adresse.mjs.
+
+ DIREKT HINTER crewWeiche UND NICHT DAVOR. Die Reihenfolge ist keine
+ Geschmacksfrage: crewWeiche beantwortet ueber die Wandtafel fuer JEDE
+ Adresse die Frage "wird hier eine fremde Zugangswand verlangt?" --
+ auch fuer treff. Stuende treffWeiche davor, wuerde sie
+ /workspace/crew-index.html erst auf die Treff-Wand umbiegen und die
+ Tafel kaeme nie zum Zug. Ein Mitglied der Community saehe dann keine
+ 404, sondern seine eigene Wand -- und niemandem waere aufgefallen,
+ dass die Sperre nicht mehr greift.
+
+ Auch sie vergibt KEINE Rechte. Wer auf welcher Adresse angemeldet
+ sein darf, entscheidet sitzungLesen() in workspace.js. */
+app.use(treffWeiche);
+
/* Inhaltsrichtlinie (26.08.2026). Steht bewusst HIER -- vor jeder
Schranke und vor express.static: Auch die Zugangswand und die
404-Seite sollen sie bekommen, denn gerade dort werden Eingaben
@@ -235,6 +253,12 @@ app.use(kalenderRouter);
app.use(serienRouter);
app.use(dateienRouter);
app.use(bereicheRouter);
+/* Der Treff (11.09.2026): Stimmen, Anheften, Freigeben, Melden,
+ Massnahmen, Stufen. Die sieben Bretter selbst laufen ueber
+ bereicheRouter -- es sind gewoehnliche Eintraege, und ein zweiter
+ Weg zu denselben Zeilen waere ein zweiter Ort fuer dieselbe
+ Rechtepruefung. */
+app.use(treffRouter);
app.use(reportRouter);
app.use(scoutRouter);
app.use(callRouter);
diff --git a/server/pruef-kachel-universum.mjs b/server/pruef-kachel-universum.mjs
index 5a102ed5..e982b687 100644
--- a/server/pruef-kachel-universum.mjs
+++ b/server/pruef-kachel-universum.mjs
@@ -91,7 +91,7 @@ import { join } from "node:path";
import { request as httpAnfrage } from "node:http";
import { portMussFreiSein } from "./helfer-port.mjs";
-const PORT = await portMussFreiSein(4391, "pruef-kachel-universum");
+const PORT = await portMussFreiSein(4401, "pruef-kachel-universum");
const ordner = mkdtempSync(join(tmpdir(), "ws-universum-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
diff --git a/server/pruef-rechtetafel.mjs b/server/pruef-rechtetafel.mjs
index 00373810..15ae8ecb 100644
--- a/server/pruef-rechtetafel.mjs
+++ b/server/pruef-rechtetafel.mjs
@@ -106,10 +106,11 @@ melde("=== 1. Jede Rolle und jede Seite hat einen Eintrag ===");
vergisst, faellt genau hier auf. */
const SEITEN_ORDNER = new URL("../workspace/", import.meta.url).pathname
.replace(/^\/([A-Za-z]:)/, "$1");
+const DATEIEN = readdirSync(SEITEN_ORDNER)
+ .filter((f) => f.endsWith(".html"))
+ .map((f) => "/workspace/" + f).sort();
{
- const dateien = readdirSync(SEITEN_ORDNER)
- .filter((f) => f.endsWith(".html"))
- .map((f) => "/workspace/" + f).sort();
+ const dateien = DATEIEN;
const inTafel = new Set([...rechte.alleSeiten(), ...rechte.OHNE_ANMELDUNG]);
const fehlend = dateien.filter((p) => !inTafel.has(p));
const ueberzaehlig = rechte.alleSeiten().filter((p) => !dateien.includes(p));
@@ -143,7 +144,27 @@ const PHANTOM = "xx-gibt-es-nicht";
{
const alle = rechte.alleSeiten();
const durchgelassen = alle.filter((p) => rechte.darfSeite({ rolle: PHANTOM }, p));
- ok(alle.length === 20, `${alle.length} Seiten in der Tafel`);
+ /* KEINE FESTE ZAHL MEHR (11.09.2026).
+
+ Hier stand `alle.length === 20`. Das war richtig an dem Tag, an dem
+ es geschrieben wurde, und wurde bei der naechsten neuen Seite rot --
+ ohne dass irgendetwas kaputt war. Eine feste Schwelle ist eine
+ Rechnung von gestern; genau deshalb steht sie in diesem Haus in der
+ Liste der teuren Fehler.
+
+ Gemessen wird jetzt, was die Zeile eigentlich wissen will: Die
+ Tafel deckt die Dateien ab, die es WIRKLICH gibt -- jede
+ ausgelieferte Seite genau einmal, die Zugangswaende ausgenommen
+ (die stehen in OHNE_ANMELDUNG, weil sich dort noch niemand
+ angemeldet hat).
+
+ UND SIE DARF NICHT LEER SEIN: `alle.length > 0` gehoert in die
+ BEDINGUNG, nicht nur in den Meldetext. Sonst waere die Zeile bei
+ einer leeren Tafel und einem leeren Ordner gruen -- der gruene
+ Haken ueber der Null. */
+ const erwartet = DATEIEN.length - rechte.OHNE_ANMELDUNG.length;
+ ok(alle.length > 0 && alle.length === erwartet,
+ `${alle.length} Seiten in der Tafel (${DATEIEN.length} Dateien minus ${rechte.OHNE_ANMELDUNG.length} Waende)`);
ok(durchgelassen.length === 0,
`eine unbekannte Rolle kommt auf KEINE davon${durchgelassen.length ? " -- durchgelassen: " + durchgelassen.join(", ") : ""}`);
diff --git a/server/pruef-start-ansicht.mjs b/server/pruef-start-ansicht.mjs
index e353f460..170e6afd 100644
--- a/server/pruef-start-ansicht.mjs
+++ b/server/pruef-start-ansicht.mjs
@@ -246,10 +246,19 @@ for (const [breite, wie] of [[1440, "Computer"], [390, "Handy"]]) {
nicht ans Ende, wo es niemand ansieht"). Das war meine Begruendung,
nicht seine Entscheidung -- und bei privaten Dingen wiegt "faellt
niemandem ins Auge" schwerer als "wird oft angesehen". */
+ /* SIEBEN SEIT DEM 11.09.2026 -- "Community" kam dazu, der Treff.
+
+ Sie steht GANZ UNTEN, und zwar aus demselben Grund wie die beiden
+ Gruppen davor: Dort stehen Namen und Saetze von Zuschauern, also
+ private Dinge fremder Leute. Wer sich den Bildschirm teilt, hat sie
+ dann nicht als Erstes im Blick.
+
+ Sie hat deshalb ausdruecklich KEIN `gruppeNach` -- eine Gruppe ohne
+ Angabe haengt hinten an, und das ist hier die richtige Stelle. */
const ERWARTETE_GRUPPEN = ["Rund um das Team", "Täglich", "Rund um den Creator",
- "Team & System", "Team Dogi", "Entwicklung & Nachwuchs"];
+ "Team & System", "Team Dogi", "Entwicklung & Nachwuchs", "Community"];
ok(JSON.stringify(r.gruppen.map((g) => g.name)) === JSON.stringify(ERWARTETE_GRUPPEN),
- `sechs Gruppen in dieser Reihenfolge (${r.gruppen.map((g) => g.name).join(" · ")})`);
+ `${ERWARTETE_GRUPPEN.length} Gruppen in dieser Reihenfolge (${r.gruppen.map((g) => g.name).join(" · ")})`);
/* WIE VIELE KACHELN? Nicht abgeschrieben, sondern aus bereiche.js
gezaehlt (06.09.2026).
diff --git a/server/pruef-treff-werkzeuge.mjs b/server/pruef-treff-werkzeuge.mjs
new file mode 100644
index 00000000..bee17cc2
--- /dev/null
+++ b/server/pruef-treff-werkzeuge.mjs
@@ -0,0 +1,489 @@
+/* =====================================================================
+ Die Werkzeuge des Treffs (11.09.2026)
+
+ pruef-treff.mjs fragt: WER SIEHT WAS. Diese Datei fragt: WAS
+ PASSIERT, wenn jemand etwas tut — „Will ich auch", anheften,
+ freigeben, melden, entfernen, sperren, übernehmen.
+
+ ---------------------------------------------------------------------
+ ZU JEDER REGEL GEHÖRT HIER DIE GEGENPROBE, und zwar aus demselben
+ Grund wie überall in diesem Haus: Eine Prüfung, die immer bestätigt,
+ bestätigt nichts. Wenn „ein Modi darf nicht dauerhaft ausschließen"
+ grün ist, muss daneben stehen, dass DogFather es kann — sonst wäre
+ die Zeile auch dann grün, wenn NIEMAND es kann.
+
+ Konkret geprüft:
+
+ * „Will ich auch" ist EINE Stimme. Zweimal drücken ändert nichts,
+ und das Zurücknehmen geht.
+ * Am Anschlagbrett hängen höchstens drei. Die vierte wird
+ abgelehnt — und nach dem Abnehmen einer geht sie durch.
+ * Freigeben kann nur das Team. Ohne Freigabe ist der Eintrag für
+ die Community nicht da (steht in pruef-treff), mit Freigabe
+ schon.
+ * Melden ohne Grund geht nicht. Mit Grund landet es in der Liste
+ des Teams (DSA Art. 16).
+ * Entfernen ohne Grund geht nicht. Mit Grund ist der Eintrag weg
+ UND der Grund noch da (DSA Art. 17) — das ist der eigentliche
+ Punkt: Eine Begründung, die mit dem Beitrag verschwindet, ist
+ keine.
+ * Die Bannleiter: Ein Modi darf bis zu sieben Tage, DogFather auch
+ dauerhaft. Und der dauerhafte Ausschluss wirkt — die Sitzung ist
+ weg, nicht nur das Schreiben.
+ * Aus „Mitmachen" wird ein Talent, und zwar genau einmal.
+
+ Aufrufen mit: node server/pruef-treff-werkzeuge.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(4402, "pruef-treff-werkzeuge");
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-treffw-"));
+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(240_000, "pruef-treff-werkzeuge");
+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 TREFF = "treff.dogfather-universe.com";
+const CREW = "crew.dogfather-universe.com";
+const HAUS = "workspace.dogfather-universe.com";
+
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
+const treffModul = await import("./workspace-treff.js");
+const { BEREICHE } = await import("./workspace-bereiche.js");
+
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const jetzt = new Date().toISOString();
+const heute = jetzt.slice(0, 10);
+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);
+
+/* Die Mitglieder werden ALT angelegt (40 Tage), damit sie schreiben
+ duerfen. Die Stufen selbst misst pruef-treff; hier waere ein frisches
+ Konto nur eine Huerde vor jeder einzelnen Zeile. */
+const ALT = new Date(Date.now() - 40 * 86400000).toISOString();
+function anlegen(name, rolle, code, erstellt = jetzt) {
+ 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"), erstellt);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
+anlegen("Frida", "modi", "CODE-MODI-0001");
+const idKessi = anlegen("Kessi", "gast", "CODE-GAST-0001", ALT);
+const idBo = anlegen("Bo", "gast", "CODE-GAST-0002", ALT);
+/* Ein dritter Zugang, nur fuer den Ausschluss. Ihn Bo zu geben waere
+ bequem gewesen und haette den Rest der Datei zerlegt: Ein
+ Ausgeschlossener hat keine Sitzung mehr, also waere ab da JEDE
+ weitere Zeile mit Bo gruen geworden, weil alles 401 antwortet --
+ und "die Talente sieht er nicht" haette nichts mehr bewiesen. */
+const idPim = anlegen("Pim", "gast", "CODE-GAST-0003", ALT);
+
+const rein = d.prepare(
+ "INSERT INTO eintraege (bereich, art, titel, text, datum, erstellt, erstellt_von)"
+ + " VALUES (?,?,?,?,?,?,?)");
+const ersteArt = (b) => Object.keys(BEREICHE[b].arten)[0];
+function eintrag(bereich, titel, von = idDogi) {
+ rein.run(bereich, ersteArt(bereich), titel, "", heute, jetzt, von);
+ return d.prepare("SELECT last_insert_rowid() AS id").get().id;
+}
+const wunsch1 = eintrag("wunsch", "WUNSCH-EINS");
+const anschlag = [1, 2, 3, 4].map((n) => eintrag("anschlag", `ANSAGE-${n}`));
+const highlight1 = eintrag("highlight", "CLIP-EINS");
+const bosBeitrag = eintrag("treff", "BO-SAGT-HALLO", idBo);
+const mitmachen1 = eintrag("mitmachen", "ICH-HAETTE-LUST", idBo);
+d.close();
+
+/* EIN DELETE TRAEGT KEINEN KOERPER (gemessen 11.09.2026).
+
+ Nodes HTTP-Parser weist ein DELETE mit Koerper mit einem leeren 400
+ ab, bevor Express es sieht -- nachgemessen: PUT mit Koerper 200,
+ DELETE ohne Koerper 200, DELETE mit Koerper 400 mit genau einem
+ Kopffeld. Die Pruefung schickt deshalb gar keinen erst; wuerde sie
+ es tun, meldete sie einen Fehler in einer Route, in der keiner ist.
+ Genau so ist dieser Befund entstanden. */
+function roh(pfad, host, kopf = {}, koerper = null, methode = null) {
+ if (methode === "DELETE") koerper = 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 {}; } };
+async function anmelden(rolle, code, host) {
+ const r = await roh("/workspace/api/anmelden", host, {}, { rolle, code });
+ const kekse = [].concat(r.kopf?.["set-cookie"] || []);
+ return kekse.map((c) => c.split(";")[0]).join("; ");
+}
+
+const kDogi = await anmelden("admin", "CODE-DOGI-0001", HAUS);
+const kModi = await anmelden("modi", "CODE-MODI-0001", CREW);
+const kKessi = await anmelden("gast", "CODE-GAST-0001", TREFF);
+const kBo = await anmelden("gast", "CODE-GAST-0002", TREFF);
+ok(!!kDogi && !!kModi && !!kKessi && !!kBo, "alle vier sind angemeldet");
+
+/* =======================================================================
+ 1. WILL ICH AUCH — eine Stimme, nicht zwei
+ ======================================================================= */
+melde("");
+melde("=== 1. Will ich auch ===");
+{
+ const weg = `/workspace/api/treff/eintrag/${wunsch1}/willich`;
+ const a = await roh(weg, TREFF, { cookie: kKessi }, {}, "PUT");
+ ok(a.code === 200 && json(a).stimmen === 1, `Kessi stimmt ab (${json(a).stimmen})`);
+
+ /* ZWEIMAL DRUECKEN IST EINMAL. Das ist die eigentliche Regel: Der
+ zusammengesetzte Schluessel laesst gar keine zweite Zeile zu. */
+ const b = await roh(weg, TREFF, { cookie: kKessi }, {}, "PUT");
+ ok(b.code === 200 && json(b).stimmen === 1, `zweimal druecken aendert nichts (${json(b).stimmen})`);
+
+ const c = await roh(weg, TREFF, { cookie: kBo }, {}, "PUT");
+ ok(c.code === 200 && json(c).stimmen === 2, `Bo auch (${json(c).stimmen})`);
+
+ const e = await roh(weg, TREFF, { cookie: kKessi }, {}, "DELETE");
+ ok(e.code === 200 && json(e).stimmen === 1 && json(e).meine === false,
+ `Kessi nimmt sie zurueck (${json(e).stimmen})`);
+
+ /* Und die Zahl kommt am Brett an -- sonst waere sie eine Zahl, die
+ nur die Route kennt. */
+ const brett = await roh("/workspace/api/bereich/wunsch", TREFF, { cookie: kBo });
+ const zeile = (json(brett).eintraege || []).find((x) => x.id === wunsch1);
+ ok(zeile?.stimmen === 1 && zeile?.meine_stimme === 1,
+ `am Brett steht sie auch (${zeile?.stimmen}, meine ${zeile?.meine_stimme})`);
+
+ /* GEGENPROBE: Auf einem anderen Brett gibt es keine Stimme. Ohne
+ diese Zeile waere "es geht" auch dann gruen, wenn es ueberall
+ ginge -- und dann waere die Wunschliste keine Abstimmung mehr,
+ sondern ein Nebeneffekt. */
+ const f = await roh(`/workspace/api/treff/eintrag/${bosBeitrag}/willich`, TREFF,
+ { cookie: kKessi }, {}, "PUT");
+ ok(f.code === 400, `im Gespraech gibt es kein "Will ich auch" (${f.code})`);
+}
+
+/* =======================================================================
+ 2. ANHEFTEN — höchstens drei
+ ======================================================================= */
+melde("");
+melde("=== 2. Anheften ===");
+{
+ const heften = (id, keks, m = "PUT") =>
+ roh(`/workspace/api/treff/eintrag/${id}/anheften`, m === "PUT" ? CREW : CREW,
+ { cookie: keks }, {}, m);
+
+ let durch = 0;
+ for (const id of anschlag.slice(0, 3)) {
+ const a = await heften(id, kModi);
+ if (a.code === 200) durch++;
+ }
+ ok(durch === 3, `drei Ansagen haengen (${durch})`);
+
+ const vierte = await heften(anschlag[3], kModi);
+ ok(vierte.code === 409, `die vierte wird abgelehnt (${vierte.code})`);
+ ok(/3/.test(json(vierte).fehler || ""),
+ "und die Absage nennt die Zahl, statt nur nein zu sagen");
+
+ /* NACH DEM ABNEHMEN GEHT SIE DURCH. Ohne diese Zeile waere die
+ Grenze auch dann gruen, wenn ueberhaupt nichts mehr angeheftet
+ werden koennte. */
+ await heften(anschlag[0], kModi, "DELETE");
+ const nochmal = await heften(anschlag[3], kModi);
+ ok(nochmal.code === 200, `nach dem Abnehmen geht die vierte (${nochmal.code})`);
+
+ /* Und die Community heftet nichts an. */
+ const gast = await roh(`/workspace/api/treff/eintrag/${anschlag[1]}/anheften`, TREFF,
+ { cookie: kKessi }, {}, "PUT");
+ ok(gast.code === 404, `ein Mitglied kann nichts anheften (${gast.code})`);
+
+ const brett = await roh("/workspace/api/bereich/anschlag", CREW, { cookie: kModi });
+ const oben = (json(brett).eintraege || [])[0];
+ ok(oben?.angeheftet === 1, `oben steht eine angeheftete (${oben?.titel})`);
+}
+
+/* =======================================================================
+ 3. MELDEN — der Grund ist Pflicht (DSA Art. 16)
+ ======================================================================= */
+melde("");
+melde("=== 3. Melden ===");
+{
+ const weg = `/workspace/api/treff/eintrag/${bosBeitrag}/melden`;
+ const ohne = await roh(weg, TREFF, { cookie: kKessi }, { grund: "" });
+ ok(ohne.code === 400, `ohne Grund geht es nicht (${ohne.code})`);
+
+ const kurz = await roh(weg, TREFF, { cookie: kKessi }, { grund: "doof" });
+ ok(kurz.code === 400, `"doof" ist kein Grund (${kurz.code})`);
+
+ const mit = await roh(weg, TREFF, { cookie: kKessi },
+ { grund: "Da steht die Handynummer von jemandem drin." });
+ ok(mit.code === 200, `mit Grund geht es (${mit.code})`);
+
+ const liste = await roh("/workspace/api/treff/meldungen", CREW, { cookie: kModi });
+ const m = (json(liste).meldungen || [])[0];
+ ok(liste.code === 200 && m?.eintrag_id === bosBeitrag,
+ `der Modi sieht die Meldung (${(json(liste).meldungen || []).length})`);
+ ok(m?.autor_rolle_name === "Community",
+ `und wer sie geschrieben hat, mit vollem Rollennamen (${m?.autor_rolle_name})`);
+
+ /* Die Community sieht die Meldungen NICHT -- sonst waere ablesbar,
+ wer wen gemeldet hat. */
+ const gast = await roh("/workspace/api/treff/meldungen", TREFF, { cookie: kBo });
+ ok(gast.code === 404, `ein Mitglied sieht die Liste nicht (${gast.code})`);
+
+ /* Entscheiden braucht ebenfalls einen Satz. */
+ const leer = await roh(`/workspace/api/treff/meldung/${m?.id}`, CREW,
+ { cookie: kModi }, { status: "erledigt", entscheid: "" }, "PATCH");
+ ok(leer.code === 400, `entscheiden ohne Begruendung geht nicht (${leer.code})`);
+
+ const fertig = await roh(`/workspace/api/treff/meldung/${m?.id}`, CREW,
+ { cookie: kModi }, { status: "erledigt", entscheid: "Nummer entfernt, Bo Bescheid gesagt." },
+ "PATCH");
+ ok(fertig.code === 200, `mit Begruendung schon (${fertig.code})`);
+
+ const zweimal = await roh(`/workspace/api/treff/meldung/${m?.id}`, CREW,
+ { cookie: kModi }, { status: "abgelehnt", entscheid: "Doch nicht so schlimm gewesen." },
+ "PATCH");
+ ok(zweimal.code === 409, `zweimal entscheiden geht nicht (${zweimal.code})`);
+}
+
+/* =======================================================================
+ 4. ENTFERNEN — mit Grund, und der Grund überlebt (DSA Art. 17)
+ ======================================================================= */
+melde("");
+melde("=== 4. Entfernen ===");
+{
+ const weg = `/workspace/api/bereich/treff/${bosBeitrag}`;
+ const ohne = await roh(weg, CREW, { cookie: kModi }, null, "DELETE");
+ ok(ohne.code === 400, `ohne Grund wird nichts entfernt (${ohne.code})`);
+
+ /* Und der Eintrag ist auch wirklich noch da. Ein 400 sagt nur, was
+ der Server GEANTWORTET hat -- nicht, was er getan hat. */
+ const noch = await roh("/workspace/api/bereich/treff", CREW, { cookie: kModi });
+ ok((json(noch).eintraege || []).some((x) => x.id === bosBeitrag),
+ "und der Beitrag steht noch da");
+
+ const mit = await roh(`${weg}?grund=`
+ + encodeURIComponent("Enthielt die Telefonnummer einer anderen Person."),
+ CREW, { cookie: kModi }, null, "DELETE");
+ ok(mit.code === 200, `mit Grund geht es (${mit.code})`);
+
+ const danach = await roh("/workspace/api/bereich/treff", CREW, { cookie: kModi });
+ ok(!(json(danach).eintraege || []).some((x) => x.id === bosBeitrag),
+ "der Beitrag ist weg");
+
+ /* DAS IST DER EIGENTLICHE PUNKT: Die Begruendung ist NICHT weg.
+ Haenge sie mit ON DELETE CASCADE am Eintrag, waere sie im selben
+ Moment verschwunden wie das, was sie begruendet. */
+ const akte = await roh("/workspace/api/treff/entfernt", HAUS, { cookie: kDogi });
+ const e = (json(akte).entfernt || [])[0];
+ ok(e?.eintrag_id === bosBeitrag && /Telefonnummer/.test(e?.grund || ""),
+ `und der Grund steht noch da (${e?.grund?.slice(0, 30)}…)`);
+ ok(e?.titel === "BO-SAGT-HALLO" && e?.autor_name === "Bo",
+ `samt Titel und Verfasser (${e?.titel}, ${e?.autor_name})`);
+}
+
+/* =======================================================================
+ 5. DIE BANNLEITER — Modis befristet, dauerhaft nur DogFather
+ ======================================================================= */
+melde("");
+melde("=== 5. Massnahmen ===");
+{
+ const weg = "/workspace/api/treff/massnahme";
+
+ const ohne = await roh(weg, CREW, { cookie: kModi },
+ { person_id: idBo, art: "pause", tage: 3, grund: "" });
+ ok(ohne.code === 400, `ohne Grund geht keine Massnahme (${ohne.code})`);
+
+ const kurz = await roh(weg, CREW, { cookie: kModi },
+ { person_id: idBo, art: "pause", tage: 3, grund: "Hat mehrfach gegen Regel 2 verstossen." });
+ ok(kurz.code === 200, `ein Modi darf drei Tage Pause geben (${kurz.code})`);
+
+ const lang = await roh(weg, CREW, { cookie: kModi },
+ { person_id: idPim, art: "ausschluss", tage: 30, grund: "Immer wieder dasselbe, trotz Hinweis." });
+ ok(lang.code === 403, `dreissig Tage darf er nicht (${lang.code})`);
+ ok(/7/.test(json(lang).fehler || ""), "und die Absage nennt die Grenze");
+
+ const dauer = await roh(weg, CREW, { cookie: kModi },
+ { person_id: idPim, art: "ausschluss", grund: "Endgueltig, nach mehreren Hinweisen." });
+ ok(dauer.code === 403, `und dauerhaft schon gar nicht (${dauer.code})`);
+
+ /* GEGENPROBE. Ohne sie waeren die beiden Zeilen darueber auch dann
+ gruen, wenn NIEMAND ausschliessen koennte. Sieben Tage, damit Pim
+ fuer die naechste Zeile noch hereinkommt. */
+ const dogi7 = await roh(weg, CREW, { cookie: kModi },
+ { person_id: idPim, art: "ausschluss", tage: 7, grund: "Sieben Tage, das darf er." });
+ ok(dogi7.code === 200, `sieben Tage darf ein Modi (${dogi7.code})`);
+
+ /* Und gegen jemanden aus dem TEAM geht gar nichts -- das gehoert
+ nach "Personen & Zugaenge", nicht hierher. */
+ const gegenTeam = await roh(weg, HAUS, { cookie: kDogi },
+ { person_id: idDogi, art: "pause", tage: 1, grund: "Nur ein Versuch, das darf nicht gehen." });
+ ok(gegenTeam.code === 404, `gegen das Team gibt es keine Massnahme (${gegenTeam.code})`);
+}
+
+/* =======================================================================
+ 6. DER AUSSCHLUSS WIRKT — nicht nur beim Schreiben
+ ======================================================================= */
+melde("");
+melde("=== 6. Ausschluss ===");
+{
+ /* Kessi kommt herein -- vor dem Ausschluss. Ohne diese Zeile waere
+ "sie kommt nicht mehr herein" auch dann gruen, wenn sie es nie
+ konnte. */
+ /* "dabei" und nicht "stamm": Kessi ist 40 Tage dabei, hat aber
+ keinen eigenen Beitrag geschrieben. Beide Bedingungen muessen
+ erfuellt sein -- genau das misst diese Zeile nebenbei mit. */
+ const vorher = await roh("/workspace/api/treff/lage", TREFF, { cookie: kKessi });
+ ok(vorher.code === 200 && json(vorher).stufe === "dabei",
+ `Kessi ist drin, Stufe ${json(vorher).stufe}`);
+
+ const aus = await roh("/workspace/api/treff/massnahme", HAUS, { cookie: kDogi },
+ { person_id: idKessi, art: "ausschluss", grund: "Dauerhaft, nach mehreren Vorfaellen." });
+ ok(aus.code === 200 && json(aus).bis === null,
+ `DogFather schliesst dauerhaft aus (bis: ${json(aus).bis})`);
+
+ /* DIE SITZUNG IST WEG, nicht nur das Schreiben. Ein
+ Ausgeschlossener, der weiter mitlesen kann, ist bei Belaestigung
+ genau der Teil, der weh tut. */
+ const lage = await roh("/workspace/api/treff/lage", TREFF, { cookie: kKessi });
+ ok(lage.code === 401, `ihre Sitzung gilt nicht mehr (${lage.code})`);
+
+ const brett = await roh("/workspace/api/bereich/treff", TREFF, { cookie: kKessi });
+ ok(brett.code === 401, `und sie liest auch nichts mehr (${brett.code})`);
+
+ /* Und sie kommt auch nicht wieder herein -- gar nicht erst. Ohne
+ diese Zeile waere die Anmeldung gelungen, das Plaetzchen gesetzt,
+ und dahinter eine Seite, auf der nichts geht. Das sieht aus wie
+ ein kaputtes Programm, nicht wie eine Entscheidung. */
+ const neu = await anmelden("gast", "CODE-GAST-0001", TREFF);
+ ok(!neu, "und sie kann sich nicht neu anmelden");
+
+ /* GEGENPROBE: Bo hat nur eine PAUSE -- kein Ausschluss. Er darf
+ weiter lesen, aber nicht schreiben. Ohne diese Zeile waeren die
+ vier darueber auch dann gruen, wenn jede Massnahme jeden
+ aussperrt. */
+ const boLage = await roh("/workspace/api/treff/lage", TREFF, { cookie: kBo });
+ ok(boLage.code === 200, `Bo liest weiter (${boLage.code})`);
+ ok(json(boLage).sperre?.art === "pause" && !!json(boLage).sperre?.bis,
+ `und sieht seine Pause mit Enddatum (${json(boLage).sperre?.bis?.slice(0, 10)})`);
+ ok(json(boLage).schreiben?.treff === false, "schreiben darf er nicht");
+
+ /* Und Pim, sieben Tage ausgeschlossen, kommt gar nicht mehr herein
+ -- ein befristeter Ausschluss ist ein Ausschluss, keine Pause. */
+ const pim = await anmelden("gast", "CODE-GAST-0003", TREFF);
+ ok(!pim, "ein befristeter Ausschluss sperrt ebenfalls aus");
+
+ const versuch = await roh("/workspace/api/bereich/treff", TREFF, { cookie: kBo },
+ { art: ersteArt("treff"), titel: "BO-TROTZDEM", text: "Hallo?", datum: heute });
+ ok(versuch.code === 403, `und es geht auch wirklich nicht (${versuch.code})`);
+}
+
+/* =======================================================================
+ 7. AUS „MITMACHEN" WIRD EIN TALENT — genau einmal
+ ======================================================================= */
+melde("");
+melde("=== 7. Die Bruecke zu den Talenten ===");
+{
+ const weg = `/workspace/api/treff/mitmachen/${mitmachen1}/uebernehmen`;
+ const a = await roh(weg, HAUS, { cookie: kDogi }, {});
+ ok(a.code === 200 && json(a).talent_id > 0, `Bo landet bei den Talenten (${a.code})`);
+
+ const b = await roh(weg, HAUS, { cookie: kDogi }, {});
+ ok(b.code === 409, `ein zweites Mal legt keinen zweiten an (${b.code})`);
+
+ const talente = await roh("/workspace/api/bereich/talente", HAUS, { cookie: kDogi });
+ const t = (json(talente).eintraege || []).filter((x) => x.creator_id === idBo);
+ ok(t.length === 1, `und es steht genau einer dort (${t.length})`);
+ ok(/Bo/.test(t[0]?.titel || ""), `mit seinem Namen (${t[0]?.titel})`);
+
+ /* Die Community kommt an die Talente nicht heran -- sonst waere die
+ Bruecke ein Fenster. */
+ const gast = await roh("/workspace/api/bereich/talente", TREFF, { cookie: kBo });
+ ok(gast.code === 404, `ein Mitglied sieht die Talente nicht (${gast.code})`);
+}
+
+/* =======================================================================
+ 8. DIE REGELN NENNEN DIE ZAHLEN, DIE WIRKLICH GELTEN
+ ======================================================================= */
+melde("");
+melde("=== 8. Der Regeltext ===");
+{
+ const a = await roh("/workspace/api/treff/regeln", TREFF, { cookie: kBo });
+ const r = json(a);
+ ok(a.code === 200 && r.mindestalter === treffModul.MINDESTALTER,
+ `Mindestalter ${r.mindestalter} -- dieselbe Zahl wie im Code`);
+ ok(r.mindestalter === 18, "und sie ist 18 (Entscheidung Filipe, 11.09.2026)");
+ ok(Array.isArray(r.stufen) && r.stufen.length === treffModul.STUFEN.length,
+ `alle ${r.stufen?.length} Stufen kommen mit`);
+ ok(r.anschlag_max === treffModul.ANSCHLAG_MAX,
+ `und die Zahl am Anschlagbrett (${r.anschlag_max})`);
+
+ /* Die Seite selbst gehoert der Community UND denen, die moderieren
+ -- ein Modi, der die Regeln nicht sehen kann, moderiert nach
+ Gefuehl. */
+ const rechte = await import("./rechte.js");
+ const wer = rechte.SEITEN["/workspace/treff-regeln.html"] || [];
+ ok(wer.includes("gast") && wer.includes("modi") && wer.includes("admin"),
+ `die Regelseite steht fuer ${wer.join(", ")} offen`);
+ ok(!wer.includes("manager") && !wer.includes("creator") && !wer.includes("spicy"),
+ "und nicht fuer die Agentur");
+
+ /* "Wer sieht was" ist allein DogFathers Seite. */
+ const tafel = rechte.SEITEN["/workspace/rechte.html"] || [];
+ ok(tafel.length === 1 && tafel[0] === "admin",
+ `"Wer sieht was" nur fuer ${tafel.join(", ")}`);
+
+ const fremd = await roh("/workspace/api/rechte/tafel", CREW, { cookie: kModi });
+ ok(fremd.code === 404, `ein Modi bekommt die Tafel nicht (${fremd.code})`);
+
+ const meine = await roh("/workspace/api/rechte/tafel", HAUS, { cookie: kDogi });
+ const t = json(meine);
+ ok(meine.code === 200 && t.seiten?.length === rechte.alleSeiten().length,
+ `DogFather bekommt alle ${t.seiten?.length} Seiten`);
+ const community = (t.rollen || []).find((x) => x.schluessel === "gast");
+ ok(community && community.seiten < 5,
+ `und die Community steht bei ${community?.seiten} von ${t.seiten?.length}`);
+}
+
+/* ------------------------------------------------------------------- */
+melde("");
+melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
+melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
+/* Der Ordner bleibt manchmal liegen, weil der Dienst die Datenbank
+ unter Windows noch offen haelt. Das ist ein Aufraeumrest, kein
+ Befund -- deshalb faellt der Lauf daran nicht durch. */
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ }
+process.exit(fehler ? 1 : 0);
diff --git a/server/pruef-treff.mjs b/server/pruef-treff.mjs
new file mode 100644
index 00000000..4c20126b
--- /dev/null
+++ b/server/pruef-treff.mjs
@@ -0,0 +1,613 @@
+/* =====================================================================
+ DER TREFF — die Community und ihre sieben Bretter (11.09.2026)
+
+ Filipe:
+
+ "eine community rolle und community kategorie. wo die community
+ auch dan nur die sieht und die modis rechte hand und ich also
+ dogfather sehen die aber auch. die community aber nur die dann."
+
+ "wieso nur eine kachel fuer die community ich will dass die
+ mehrere kacheln haben. mit mehreren optionen."
+
+ ---------------------------------------------------------------------
+ DIESE PRUEFUNG MISST IN BEIDE RICHTUNGEN, UND DAS IST DER GANZE PUNKT
+
+ Eine Trennung kann auf zwei Arten falsch sein, und die zweite ist die
+ gefaehrlichere:
+
+ 1. SIE GREIFT NICHT -- die Community sieht Dinge des Hauses. Das
+ faellt auf, wenn jemand hinsieht.
+ 2. SIE GREIFT ZU WEIT -- die Community sieht ihren eigenen Raum
+ nicht. Das faellt NICHT auf: Ein leeres Brett sieht aus wie
+ "hier ist nichts los", nicht wie ein Fehler. Genau so ist am
+ 01.09. ein Manager vor einem leeren Aufgabenbrett gestanden und
+ hat es nicht gemeldet.
+
+ Deshalb steht zu jeder Zeile "sieht es nicht" eine Zeile "und der da
+ sieht es sehr wohl". Ohne die zweite waere die erste auch dann gruen,
+ wenn schlicht gar nichts mehr geht.
+
+ ---------------------------------------------------------------------
+ WARUM node:http UND NICHT fetch
+
+ Das Team und die Community melden sich an verschiedenen Waenden an,
+ und welche Wand gilt, entscheidet der Host-Kopf. `fetch` in Node
+ setzt den selbst und ignoriert einen gesetzten -- die Anmeldung ginge
+ dann an die falsche Wand und scheiterte, ohne dass man saehe warum.
+ Dieselbe Falle wie am 10.09. bei den Kategorie-Kanaelen.
+ ===================================================================== */
+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(4398, "pruef-treff");
+
+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";
+
+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(240_000, "pruef-treff");
+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 TREFF = "treff.dogfather-universe.com";
+const CREW = "crew.dogfather-universe.com";
+const HAUS = "workspace.dogfather-universe.com";
+
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
+const w = await import("./workspace.js");
+const treffModul = await import("./workspace-treff.js");
+const { BEREICHE } = await import("./workspace-bereiche.js");
+const d = new DatabaseSync(process.env.WORKSPACE_DB);
+const jetzt = new Date().toISOString();
+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);
+function 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;
+}
+const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
+anlegen("Rieke", "hand", "CODE-HAND-0001");
+anlegen("Frida", "modi", "CODE-MODI-0001");
+anlegen("Mara", "manager", "CODE-MANA-0001");
+anlegen("Luna", "creator", "CODE-CREA-0001");
+const idGast = anlegen("Kessi", "gast", "CODE-GAST-0001");
+
+/* EIN EINTRAG IN JEDEM DER SIEBEN BRETTER. Ohne Daten waere jede Zeile
+ "sieht nichts" trivial wahr -- der gruene Haken ueber der Null. */
+const rein = d.prepare(
+ "INSERT INTO eintraege (bereich, art, titel, text, datum, erstellt, erstellt_von)"
+ + " VALUES (?,?,?,?,?,?,?)");
+const BRETTER = ["anschlag", "ansteht", "treff", "wunsch", "highlight", "regeln", "mitmachen"];
+for (const b of BRETTER) rein.run(b, Object.keys((await import("./workspace-bereiche.js")).BEREICHE[b].arten)[0],
+ `TREFF-PROBE-${b.toUpperCase()}`, "", jetzt.slice(0, 10), jetzt, idDogi);
+/* Und einer im Haus, den die Community NICHT sehen darf. */
+rein.run("ideen", "content", "HAUS-GEHEIM-4711", "", jetzt.slice(0, 10), jetzt, idDogi);
+d.close();
+
+/* Die Methode ist ein eigenes Argument, seit es Wege gibt, die PUT
+ oder DELETE heissen. Vorher wurde sie aus "gibt es einen Koerper?"
+ erraten -- das ging, solange nur GET und POST vorkamen, und haette
+ beim ersten PUT stillschweigend ein POST geschickt. Der Server
+ haette mit 404 geantwortet, und man sucht den Fehler im Server. */
+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();
+ });
+}
+async function anmelden(rolle, code, host) {
+ const r = await roh("/workspace/api/anmelden", host, {}, { rolle, code });
+ const kekse = [].concat(r.kopf?.["set-cookie"] || []);
+ return { keks: kekse.map((c) => c.split(";")[0]).join("; "), code: r.code };
+}
+
+/* =======================================================================
+ 1. DIE WAND: die Community kommt auf genau eine Adresse
+ ======================================================================= */
+melde("");
+melde("=== 1. Eine Rolle, eine Wand ===");
+const gast = await anmelden("gast", "CODE-GAST-0001", TREFF);
+ok(gast.code === 200 && gast.keks.length > 0, `die Community kommt auf treff. herein (${gast.code})`);
+
+for (const [name, host] of [["crew.", CREW], ["workspace.", HAUS]]) {
+ const a = await anmelden("gast", "CODE-GAST-0001", host);
+ const b = await anmelden("gast", "VOELLIG-ERFUNDEN-XYZ", host);
+ ok(a.code === 401 && a.keks === "", `auf ${name} nicht (${a.code})`);
+ ok(a.code === b.code, `und zwar mit derselben Antwort wie ein erfundener Code (${a.code}/${b.code})`);
+}
+{
+ /* UND DIE GEGENRICHTUNG: Auf der Wand des Treffs kommt sonst niemand
+ herein -- auch DogFather nicht. Er hat dort nichts zu suchen; er
+ sieht den Treff auf seiner eigenen Adresse. */
+ const a = await anmelden("admin", "CODE-DOGI-0001", TREFF);
+ const b = await anmelden("modi", "CODE-MODI-0001", TREFF);
+ ok(a.code !== 200 && b.code !== 200,
+ `auf treff. kommt sonst niemand herein -- auch DogFather nicht (${a.code}/${b.code})`);
+}
+
+const kDogi = (await anmelden("admin", "CODE-DOGI-0001", HAUS)).keks;
+const kModi = (await anmelden("creator", "CODE-MODI-0001", CREW)).keks;
+const kCrea = (await anmelden("creator", "CODE-CREA-0001", HAUS)).keks;
+const kMana = (await anmelden("manager", "CODE-MANA-0001", HAUS)).keks;
+ok([kDogi, kModi, kCrea, kMana].every((k) => k.length > 0), "die vier Vergleichsrollen sind angemeldet");
+
+/* =======================================================================
+ 1b. DIE WEICHE: drei Adressen, drei Türen, keine Verwechslung
+ ======================================================================= */
+melde("");
+melde("=== 1b. Die Weiche des Treffs ===");
+{
+ /* DIE WAND. Ohne die Weiche stünde auf der Adresse der Community die
+ Wand der Agentur: Spicy-Media-Logo, "Creator Workspace", fünf
+ Rollenkacheln mit den Namen Admin, Manager, Scout, Creator -- die
+ komplette Struktur des Unternehmens, VOR der Anmeldung, für jeden,
+ der die Adresse hat. Das ist das eigentliche Leck, gegen das
+ treffWeiche gebaut ist. */
+ for (const pfad of ["/workspace/", "/workspace/index.html"]) {
+ const a = await roh(pfad, TREFF);
+ const ist = a.code === 200 && /Der Treff|Der Treff/.test(a.text);
+ ok(ist, `${pfad} zeigt die Wand des Treffs (${a.code})`);
+ ok(!/Creator\s*Workspace|Spicy/i.test(a.text),
+ `und nennt weder Workspace noch Spicy (${pfad})`);
+ }
+
+ /* KEINE ROLLENNAMEN DES HAUSES in der ausgelieferten Datei. Die
+ beiden anderen Wände nennen ihre Rollen offen -- dort ist das
+ richtig, denn dort steht nur, wer ohnehin dazugehört. Hier steht
+ ein Mensch, der NICHT zum Team gehört. */
+ {
+ const a = await roh("/workspace/treff-index.html", TREFF);
+ const verraten = ["manager", "scout", "creator", "modi", "rechte hand"]
+ .filter((w) => new RegExp(w, "i").test(a.text));
+ ok(verraten.length === 0,
+ `die Wand nennt keine Rolle des Hauses${verraten.length ? " -- FUND: " + verraten.join(", ") : ""}`);
+ }
+
+ /* DAS MANIFEST MACHT DIE APP. Drei Adressen, drei Namen -- sonst
+ hieße die App auf dem Startbildschirm eines Mitglieds "Team Dogi",
+ der Name eines Bereichs, zu dem es nicht gehört. */
+ const namen = {};
+ for (const [was, host] of [["treff", TREFF], ["crew", CREW], ["haus", HAUS]]) {
+ const a = await roh("/workspace/app.webmanifest", host);
+ try { namen[was] = JSON.parse(a.text).name; } catch { namen[was] = `(kein JSON: ${a.code})`; }
+ }
+ ok(namen.treff === "Der Treff", `das Manifest heißt "${namen.treff}"`);
+ ok(namen.crew === "Team Dogi" && namen.treff !== namen.crew,
+ `und auf crew. weiterhin "${namen.crew}"`);
+ ok(new Set(Object.values(namen)).size === 3,
+ `drei Adressen, drei Namen (${Object.values(namen).join(" · ")})`);
+
+ /* JEDE WAND GEHÖRT ZU GENAU EINER TÜR. Beide Richtungen, sonst
+ beweist es nichts: Die eigene Wand MUSS erreichbar sein. */
+ const waende = [
+ ["/workspace/crew-index.html", CREW, TREFF],
+ ["/workspace/treff-index.html", TREFF, CREW],
+ ["/workspace/treff-index.html", TREFF, HAUS],
+ ];
+ for (const [datei, zuhause, fremd] of waende) {
+ const a = await roh(datei, zuhause);
+ const b = await roh(datei, fremd);
+ ok(a.code === 200, `${datei} gibt es zu Hause (${a.code})`);
+ ok(b.code === 404, `und auf der fremden Adresse nicht (${b.code})`);
+ }
+ /* Auch mit doppeltem Schrägstrich -- express.static zieht sie später
+ selbst zusammen und liefert die Datei aus. Genau daran ist am
+ 04.09.2026 die Schranke des Workspace vorbeigelaufen. */
+ const schraeg = await roh("/workspace//treff-index.html", CREW);
+ ok(schraeg.code === 404, `auch mit doppeltem Schrägstrich nicht (${schraeg.code})`);
+
+ /* NICHT INDIZIEREN -- als Kopf und nicht nur als , damit er
+ auch für Manifest, Bilder und jede Antwort ohne HTML gilt. */
+ const kopf = await roh("/workspace/", TREFF);
+ ok(/noindex/i.test(kopf.kopf?.["x-robots-tag"] || ""),
+ `der Kopf sagt noindex (${kopf.kopf?.["x-robots-tag"]})`);
+
+ /* NUR DER WORKSPACE. Sonst läge die komplette Dogfather-Website ein
+ zweites Mal unter einer zweiten Adresse. */
+ const haupt = await roh("/", TREFF);
+ ok(haupt.code === 302 && haupt.kopf?.location === "/workspace/",
+ `die Hauptseite gibt es hier nicht (${haupt.code} → ${haupt.kopf?.location})`);
+
+ /* DAS HAUS. haus.css ist auf der Agenturadresse leer; auf treff.
+ muss dahinter das Farbhaus von Team Dogi stehen -- sonst käme die
+ Seite grau an und färbte sich erst im Browser um. */
+ const haus = await roh("/workspace/assets/css/haus.css?v=1", TREFF);
+ ok(haus.code === 200 && /--sternenfeld/.test(haus.text),
+ `haus.css bringt das Farbhaus mit (${haus.text.length} Zeichen)`);
+
+ /* DIE BÜHNEN INNEN. Die Motive der Agentur zeigen das
+ Spicy-Media-Logo selbst -- teils vielfach nebeneinander. */
+ const a1 = await roh("/workspace/assets/img/buehne-lounge-breit.webp", TREFF);
+ const a2 = await roh("/workspace/assets/img/crew-buehne-lounge-breit.webp", TREFF);
+ const a3 = await roh("/workspace/assets/img/buehne-lounge-breit.webp", HAUS);
+ ok(a1.code === 200 && a1.text.length === a2.text.length,
+ `die Bühne kommt von Team Dogi (${a1.text.length} = ${a2.text.length})`);
+ ok(a1.text.length !== a3.text.length,
+ `und ist eine andere als die der Agentur (${a1.text.length} ≠ ${a3.text.length})`);
+
+ /* DAS ZEICHEN IN DER KOPFLEISTE. start.css setzt dort eine Chili --
+ als CSS-Hintergrund, auf jeder Seite, den ganzen Tag. */
+ const m1 = await roh("/workspace/assets/img/marke-spicy.webp", TREFF);
+ const m2 = await roh("/workspace/assets/img/marke-husky.webp", TREFF);
+ const m3 = await roh("/workspace/assets/img/marke-spicy.webp", HAUS);
+ ok(m1.code === 200 && m1.text.length === m2.text.length,
+ `in der Kopfleiste steht der Husky (${m1.text.length})`);
+ ok(m1.text.length !== m3.text.length,
+ `und nicht die Chili (${m1.text.length} ≠ ${m3.text.length})`);
+}
+
+/* =======================================================================
+ 2. DIE KACHELN: sieben, und sonst nichts
+ ======================================================================= */
+melde("");
+melde("=== 2. Sieben Kacheln — und keine achte ===");
+let namen = [];
+{
+ const a = await roh("/workspace/api/ich", TREFF, { 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})`);
+ /* 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,
+ einen davon zu aendern und den anderen zu vergessen. */
+ namen = (j.bereiche || []).map((k) => k.name);
+ ok(namen.join(" | ") === "Anschlagbrett | Was ansteht | Der Treff | Wunschliste | Highlights | Regeln & Hilfe | Mitmachen",
+ `und zwar diese: ${namen.join(", ")}`);
+ ok((j.bereiche || []).every((k) => k.gruppe === "Community"),
+ "alle in der Gruppe „Community“");
+ const toene = new Set((j.bereiche || []).map((k) => k.ton));
+ ok(toene.size === 7, `sieben verschiedene Farben (${[...toene].join(", ")})`);
+ ok(j.rolle_name === "Community", `die Rolle heißt „${j.rolle_name}“`);
+}
+{
+ /* GEGENPROBE: Das Team sieht sie mit -- sonst waere "sieben Kacheln"
+ auch dann gruen, wenn sie sonst niemand bekommt.
+
+ UND ES SIEHT EINE MEHR (11.09.2026): "Meldungen & Massnahmen". Sie
+ ist ausdruecklich KEIN Brett und steht deshalb nicht in
+ TREFF_BEREICHE -- sonst waere sie bei der Community gelandet, und
+ zwar lautlos: Sie saehe dort aus wie die anderen sieben, und erst
+ beim Klicken haette der Server 404 gesagt.
+
+ Geprueft wird deshalb nicht eine Zahl, sondern die BEZIEHUNG: Die
+ sieben der Community sind eine echte Teilmenge dessen, was das Team
+ sieht -- und der Unterschied ist genau die Moderationskachel. Eine
+ Zahl waere beim naechsten Umbau wieder rot, ohne dass etwas kaputt
+ ist; diese Bedingung nicht. */
+ 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));
+ 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(", ")}`);
+
+ 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})`);
+
+ const c = JSON.parse((await roh("/workspace/api/ich", HAUS, { cookie: kMana })).text);
+ const beiMana = (c.bereiche_zusatz || []).filter((k) => k.gruppe === "Community");
+ ok(beiMana.length === 0, `eine Managerin nicht (${beiMana.length})`);
+
+ /* UND DIE MODERATIONSKACHEL IST BEI DER COMMUNITY WIRKLICH NICHT DA
+ -- nicht nur "nicht in dieser Liste". Die Kachel ist ein Weg; wenn
+ der Weg zu ist, muss auch die Tuer zu sein. */
+ const d = JSON.parse((await roh("/workspace/api/ich", TREFF, { cookie: gast.keks })).text);
+ const alleNamen = [...(d.bereiche || []), ...(d.bereiche_zusatz || [])].map((k) => k.name);
+ ok(!alleNamen.includes(MOD), `die Community hat keine Moderationskachel (${alleNamen.length} Kacheln)`);
+}
+
+/* =======================================================================
+ 3. DIE BRETTER: was die Community sieht — und was nicht
+ ======================================================================= */
+melde("");
+melde("=== 3. Die sieben Bretter ===");
+async function brett(b, host, keks) {
+ const a = await roh(`/workspace/api/bereich/${b}`, host, { cookie: keks });
+ let titel = [];
+ try { titel = (JSON.parse(a.text).eintraege || []).map((e) => e.titel); } catch { /* kein JSON */ }
+ return { code: a.code, titel };
+}
+{
+ let gefunden = 0;
+ for (const b of BRETTER) {
+ const r = await brett(b, TREFF, gast.keks);
+ if (r.code === 200 && r.titel.includes(`TREFF-PROBE-${b.toUpperCase()}`)) gefunden++;
+ }
+ /* FUENF VON SIEBEN, UND DAS IST DAS ERGEBNIS -- kein Mangel.
+
+ Termine und Highlights sind fuer die Community erst da, wenn
+ jemand aus dem Team sie freigegeben hat (TREFF_FREIGABE_BRETTER).
+ Die Zahl steht deshalb nicht als 7 hier, sondern wird aus der
+ Tafel gerechnet: Wer ein drittes Freigabe-Brett anlegt, bekommt
+ hier keine rote Zeile fuer etwas Richtiges. */
+ const OFFENE = BRETTER.filter((b) => !treffModul.TREFF_FREIGABE_BRETTER.includes(b));
+ ok(gefunden === OFFENE.length,
+ `die Community sieht die ${OFFENE.length} freien Bretter mit Inhalt (${gefunden}/${OFFENE.length})`);
+
+ /* DIE GEGENPROBE IN BEIDE RICHTUNGEN. Ohne sie waere die Zeile oben
+ auch dann gruen, wenn die Freigabe gar nicht wirkt und die beiden
+ Bretter aus einem anderen Grund leer sind. */
+ let vorher = 0;
+ for (const b of treffModul.TREFF_FREIGABE_BRETTER) {
+ const r = await brett(b, TREFF, gast.keks);
+ if (r.titel.includes(`TREFF-PROBE-${b.toUpperCase()}`)) vorher++;
+ }
+ ok(vorher === 0, `ohne Freigabe sieht die Community Termine und Highlights nicht (${vorher})`);
+
+ /* Jetzt freigeben -- ueber den echten Weg, nicht in der Datenbank. */
+ let freigegeben = 0;
+ for (const b of treffModul.TREFF_FREIGABE_BRETTER) {
+ const beiDogi2 = await roh(`/workspace/api/bereich/${b}`, HAUS, { cookie: kDogi });
+ const eintrag = (JSON.parse(beiDogi2.text).eintraege || [])
+ .find((e) => e.titel === `TREFF-PROBE-${b.toUpperCase()}`);
+ if (!eintrag) continue;
+ const a = await roh(`/workspace/api/treff/eintrag/${eintrag.id}/freigeben`, HAUS,
+ { cookie: kDogi }, {}, "PUT");
+ if (a.code === 200) freigegeben++;
+ }
+ ok(freigegeben === treffModul.TREFF_FREIGABE_BRETTER.length,
+ `DogFather gibt beide frei (${freigegeben})`);
+
+ let nachher = 0;
+ for (const b of treffModul.TREFF_FREIGABE_BRETTER) {
+ const r = await brett(b, TREFF, gast.keks);
+ if (r.titel.includes(`TREFF-PROBE-${b.toUpperCase()}`)) nachher++;
+ }
+ ok(nachher === treffModul.TREFF_FREIGABE_BRETTER.length,
+ `nach der Freigabe sieht sie beide (${nachher})`);
+
+ /* Und ein Modi darf nicht freigeben, was er nicht sehen darf --
+ hier: dass die Route ueberhaupt Rechte prueft. Eine Creatorin
+ bekommt 404, nicht 403: den Treff gibt es fuer sie nicht. */
+ const fremd = await roh("/workspace/api/treff/eintrag/1/freigeben", HAUS,
+ { cookie: kCrea }, {}, "PUT");
+ ok(fremd.code === 404, `eine Creatorin kann nichts freigeben (${fremd.code})`);
+}
+{
+ /* KEIN EINZIGES BRETT DES HAUSES. Ueber die Adresszeile probiert,
+ nicht ueber die Kacheln -- eine Regel, die nur im Formular gilt,
+ ist keine Regel. */
+ const fremde = ["ideen", "angebote", "rueckmeldung", "entwicklung", "talente", "agentur", "live", "content"];
+ const durch = [];
+ for (const b of fremde) {
+ const r = await brett(b, TREFF, gast.keks);
+ if (r.code === 200) durch.push(b);
+ }
+ ok(durch.length === 0, `und kein einziges des Hauses${durch.length ? " -- DURCH: " + durch.join(", ") : ""}`);
+
+ /* DIE SUCHE SPIEGELT DIE FRAGE ZURUECK (Feld `frage`). Wer den
+ Rohtext nach dem Suchwort durchsucht, findet deshalb IMMER einen
+ Treffer -- und meldet ein Leck, das es nicht gibt. Genau darauf
+ bin ich am 10.09. schon einmal hereingefallen und haette fast ein
+ Loch repariert, das keines war. Gezaehlt wird, was die Suche
+ WIRKLICH gefunden hat. */
+ const suche = await roh("/workspace/api/suche?q=HAUS-GEHEIM-4711", TREFF, { cookie: gast.keks });
+ let treffer = -1;
+ try { treffer = JSON.parse(suche.text).gesamt; } catch { treffer = -1; }
+ ok(treffer === 0, `auch die Suche findet nichts aus dem Haus (${treffer} Treffer)`);
+}
+{
+ /* UND DIE AGENTUR SIEHT DEN TREFF NICHT. Beide Richtungen, beide
+ ueber die Adresszeile. */
+ const durch = [];
+ for (const b of BRETTER) {
+ const r = await brett(b, HAUS, kCrea);
+ if (r.code === 200 && r.titel.some((t) => t.startsWith("TREFF-PROBE"))) durch.push(b);
+ }
+ ok(durch.length === 0, `eine Creatorin sieht kein Brett des Treffs${durch.length ? " -- DURCH: " + durch.join(", ") : ""}`);
+
+ const durchM = [];
+ for (const b of BRETTER) {
+ const r = await brett(b, HAUS, kMana);
+ if (r.code === 200 && r.titel.some((t) => t.startsWith("TREFF-PROBE"))) durchM.push(b);
+ }
+ ok(durchM.length === 0, `eine Managerin auch nicht${durchM.length ? " -- DURCH: " + durchM.join(", ") : ""}`);
+
+ /* GEGENPROBE: Der Modi sieht sie sehr wohl. Ohne diese Zeile waeren
+ die beiden darueber auch gruen, wenn die Bretter fuer NIEMANDEN
+ lesbar waeren. */
+ let sieht = 0;
+ for (const b of BRETTER) {
+ const r = await brett(b, CREW, kModi);
+ if (r.code === 200 && r.titel.includes(`TREFF-PROBE-${b.toUpperCase()}`)) sieht++;
+ }
+ ok(sieht === 7, `ein Modi sieht alle sieben (${sieht}/7)`);
+}
+
+/* =======================================================================
+ 4. DIE SEITEN: die aus der Tafel, und keine einzige mehr
+ ======================================================================= */
+melde("");
+melde("=== 4. Nur die Seiten aus der Tafel ===");
+{
+ /* DIE LISTE WIRD NICHT GETIPPT, SIE WIRD GEHOLT (11.09.2026).
+
+ Hier standen zwei Seiten und daneben "und keine dritte". Beim
+ Anlegen der Regelseite wurde daraus eine dritte -- die Prüfung
+ war rot, obwohl nichts kaputt war, und die naheliegende Reaktion
+ wäre gewesen, die Zahl von zwei auf drei zu setzen. Beim vierten
+ Mal wieder.
+
+ Gemessen wird jetzt das, was die Zeile eigentlich behaupten will:
+ Die Tafel und die Wirklichkeit stimmen überein — jede Seite, die
+ in rechte.js für "gast" steht, geht auf; jede andere ausgelieferte
+ Seite geht NICHT auf. Das ist strenger als die alte Fassung (die
+ elf Namen waren von Hand gewählt) und altert nicht. */
+ const rechte = await import("./rechte.js");
+ const { readdirSync } = await import("node:fs");
+ const ordner = new URL("../workspace/", import.meta.url).pathname
+ .replace(/^\/([A-Za-z]:)/, "$1");
+ const alleDateien = readdirSync(ordner).filter((f) => f.endsWith(".html"))
+ .map((f) => "/workspace/" + f);
+
+ const erlaubt = rechte.seitenFuer("gast");
+ const verboten = alleDateien
+ .filter((p) => !erlaubt.includes(p) && !rechte.OHNE_ANMELDUNG.includes(p));
+
+ ok(erlaubt.length > 0 && verboten.length > 0,
+ `die Tafel gibt der Community ${erlaubt.length} Seiten und verbietet ${verboten.length}`);
+
+ const zu = [];
+ for (const p of erlaubt) {
+ const a = await roh(p, TREFF, { cookie: gast.keks });
+ if (a.code !== 200) zu.push(`${p.split("/").pop()} (${a.code})`);
+ }
+ ok(zu.length === 0,
+ `alle ${erlaubt.length} erlaubten öffnen sich${zu.length ? " -- ZU: " + zu.join(", ") : ""}`);
+
+ const durch = [];
+ for (const p of verboten) {
+ const a = await roh(p, TREFF, { cookie: gast.keks });
+ if (a.code === 200) durch.push(p.split("/").pop());
+ }
+ ok(durch.length === 0,
+ `und keine der ${verboten.length} anderen${durch.length ? " -- DURCH: " + durch.join(", ") : ""}`);
+
+ /* UND DIE MODERATIONSSEITE AUSDRÜCKLICH NICHT. Sie steht zwar in der
+ Liste oben mit drin -- aber sie ist die eine, bei der ein Fehler
+ wirklich weh täte: Dort stehen die Namen derer, die gemeldet
+ haben. Eine eigene Zeile dafür, damit der Befund einen Namen hat
+ und nicht in "eine von 20" untergeht. */
+ const mod = await roh("/workspace/treff-moderation.html", TREFF, { cookie: gast.keks });
+ ok(mod.code !== 200, `die Moderationsseite bleibt zu (${mod.code})`);
+ const modApi = await roh("/workspace/api/treff/meldungen", TREFF, { cookie: gast.keks });
+ ok(modApi.code === 404, `und die Meldungen dahinter auch (${modApi.code})`);
+}
+
+/* =======================================================================
+ 5. DAS HAUS BLEIBT ZU: Aufgaben, Termine, Dateien, Personen
+ ======================================================================= */
+melde("");
+melde("=== 5. Vom Haus sieht sie nichts ===");
+for (const [name, pfad] of [
+ ["Aufgaben", "/workspace/api/aufgaben"],
+ ["Termine", "/workspace/api/termine"],
+ ["Dateien", "/workspace/api/dateien"],
+ ["Personen", "/workspace/api/personen"],
+ ["Gespräche", "/workspace/api/chat/raeume"],
+]) {
+ const a = await roh(pfad, TREFF, { cookie: gast.keks });
+ let leer = a.code !== 200;
+ if (a.code === 200) {
+ try {
+ const j = JSON.parse(a.text);
+ const felder = Object.values(j).filter(Array.isArray);
+ leer = felder.every((f) => f.length === 0);
+ /* Die eigene Person in einer Personenliste ist kein Leck. */
+ if (!leer && pfad.endsWith("personen")) {
+ leer = felder.every((f) => f.every((p) => p.id === idGast));
+ }
+ } catch { leer = true; }
+ }
+ ok(leer, `${name}: nichts (${a.code})`);
+}
+
+/* =======================================================================
+ 6. SCHREIBEN — und wo es ankommt
+ ======================================================================= */
+melde("");
+melde("=== 6. Schreiben ===");
+{
+ /* DIE ERSTE WOCHE IST EINE SANDKISTE. Wer eben erst dazugekommen
+ ist, liest und stimmt ab -- mehr nicht. Das wird von BEIDEN
+ Seiten der Grenze gemessen, sonst beweist es nichts: Eine
+ Schreibsperre, die immer sperrt, ist keine Stufe. */
+ const neu = await roh("/workspace/api/bereich/treff", TREFF, { cookie: gast.keks },
+ { art: "hallo", titel: "GAST-ZU-FRUEH-0001", text: "Hallo zusammen.", datum: jetzt.slice(0, 10) });
+ ok(neu.code === 403, `wer neu ist, schreibt noch nicht (${neu.code})`);
+
+ const lage0 = JSON.parse((await roh("/workspace/api/treff/lage", TREFF, { cookie: gast.keks })).text);
+ ok(lage0.stufe === "neu", `und die Lage sagt es ihm auch: ${lage0.stufe}`);
+ ok(lage0.schreiben?.treff === false, "der Knopf waere aus");
+
+ /* Acht Tage zurueckdatieren -- genau EIN Tag ueber der Grenze. Eine
+ Grenze, die nur von weit weg geprueft wird, koennte um Tage
+ danebenliegen, ohne dass es auffaellt. */
+ const dd = new DatabaseSync(process.env.WORKSPACE_DB);
+ dd.prepare("UPDATE personen SET erstellt = ? WHERE id = ?")
+ .run(new Date(Date.now() - 8 * 86400000).toISOString(), idGast);
+ dd.close();
+
+ const a = await roh("/workspace/api/bereich/treff", TREFF, { cookie: gast.keks },
+ { art: "hallo", titel: "GAST-SCHREIBT-8899", text: "Hallo zusammen.", datum: jetzt.slice(0, 10) });
+ ok(a.code === 200 || a.code === 201, `nach acht Tagen schon (${a.code})`);
+
+ /* UND SECHS TAGE REICHEN NICHT. Die andere Seite derselben Grenze --
+ ohne sie waere "acht Tage gehen" auch dann gruen, wenn die Stufe
+ gar nicht geprueft wird. */
+ const dd2 = new DatabaseSync(process.env.WORKSPACE_DB);
+ dd2.prepare("UPDATE personen SET erstellt = ? WHERE id = ?")
+ .run(new Date(Date.now() - 6 * 86400000).toISOString(), idGast);
+ dd2.close();
+ const knapp = await roh("/workspace/api/bereich/treff", TREFF, { cookie: gast.keks },
+ { art: "hallo", titel: "GAST-ZU-FRUEH-0002", text: "Und?", datum: jetzt.slice(0, 10) });
+ ok(knapp.code === 403, `sechs Tage reichen nicht (${knapp.code})`);
+ const dd3 = new DatabaseSync(process.env.WORKSPACE_DB);
+ dd3.prepare("UPDATE personen SET erstellt = ? WHERE id = ?")
+ .run(new Date(Date.now() - 8 * 86400000).toISOString(), idGast);
+ dd3.close();
+
+ /* Und auf ein Brett des Teams kommt er auch als "Dabei" nicht. */
+ const brettDesTeams = await roh("/workspace/api/bereich/anschlag", TREFF, { cookie: gast.keks },
+ { art: Object.keys(BEREICHE.anschlag.arten)[0], titel: "GAST-AM-ANSCHLAG-0001",
+ text: "Ansage", datum: jetzt.slice(0, 10) });
+ ok(brettDesTeams.code === 403, `aufs Anschlagbrett schreibt nur das Team (${brettDesTeams.code})`);
+
+ const beiDogi = await brett("treff", HAUS, kDogi);
+ ok(beiDogi.titel.includes("GAST-SCHREIBT-8899"), "DogFather sieht den Beitrag");
+ const beiModi = await brett("treff", CREW, kModi);
+ ok(beiModi.titel.includes("GAST-SCHREIBT-8899"), "der Modi auch");
+ const beiCrea = await brett("treff", HAUS, kCrea);
+ ok(!beiCrea.titel.includes("GAST-SCHREIBT-8899"), "die Creatorin nicht");
+}
+{
+ /* UND NICHT IN EIN BRETT DES HAUSES. */
+ const a = await roh("/workspace/api/bereich/ideen", TREFF, { cookie: gast.keks },
+ { art: "content", titel: "GAST-IM-HAUS-0000", text: "", datum: jetzt.slice(0, 10) });
+ ok(a.code !== 200 && a.code !== 201, `aber nicht in ein Brett des Hauses (${a.code})`);
+}
+
+melde("");
+console.log(`${geprueft} Pruefungen, ${fehler} Fehler`);
+console.log(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG");
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest, kein Befund */ }
+process.exit(fehler === 0 ? 0 : 1);
diff --git a/server/rechte.js b/server/rechte.js
index d74c9800..7f22f906 100644
--- a/server/rechte.js
+++ b/server/rechte.js
@@ -67,12 +67,26 @@
* Rolle nur in einer der beiden Listen steht. */
export const ALLE_ROLLEN = [
"spicy", "admin", "manager", "scout", "creator", "hand", "modi",
+ /* DIE COMMUNITY (11.09.2026). Zuschauer mit einem Zugang, die den
+ Treff sehen -- und sonst nichts. Sie steht hier NICHT in `ALLE`
+ (siehe unten): Wer neu dazukommt, bekommt genau die zwei Seiten,
+ die weiter unten ausdruecklich seinen Namen tragen. */
+ "gast",
];
/* Kurzform fuer die Tafel unten. `ALLE` heisst hier ausdruecklich
"alle Rollen, die es GIBT" -- nicht "alle, die es geben wird".
Genau darin liegt der Unterschied zum alten `null`. */
-const ALLE = ALLE_ROLLEN;
+/* ACHTUNG: `ALLE` ist NICHT ALLE_ROLLEN.
+
+ Es ist die Abkuerzung fuer "alle Rollen, die diese Seiten am
+ 11.09.2026 schon hatten" -- die sieben des Hauses. Die Community
+ gehoert ausdruecklich nicht dazu; sie bekommt ihre Seiten einzeln.
+
+ Wuerde hier ALLE_ROLLEN stehen, haette jede kuenftige Rolle wieder
+ automatisch neun Seiten geerbt -- genau der Fehler, gegen den diese
+ Datei gebaut ist, nur mit einem anderen Namen. */
+const ALLE = ["spicy", "admin", "manager", "scout", "creator", "hand", "modi"];
/* Schutz der angemeldeten Seiten. Serverseitig, nicht nur im Browser --
sonst könnte man die Seite einfach direkt aufrufen. */
@@ -98,7 +112,9 @@ export const SEITEN = {
zwei Aenderungen in einer -- und man wuesste hinterher nicht,
welche der beiden etwas kaputtgemacht hat. Wer hier enger
stellen will, macht das als eigenen Schritt. */
- "/workspace/start.html": [...ALLE],
+ /* Die Community sieht hier ihre sieben Kacheln und sonst nichts --
+ welche das sind, entscheidet bereicheFuer() in workspace.js. */
+ "/workspace/start.html": [...ALLE, "gast"],
"/workspace/uebersicht.html": [...ALLE],
/* AUSDRUECKLICH OHNE 'modi' (10.09.2026). Vorher stand hier `null`
("jede angemeldete Rolle") -- und mit der neuen Rolle hiess das
@@ -216,7 +232,11 @@ export const SEITEN = {
WELCHE Bereiche ein Modi oeffnen darf, entscheidet die Liste
seiner Kacheln (MODI_BEREICHE_ERLAUBT) -- sonst kaeme er ueber die
Adresszeile auch in die Agentur-Ablage. */
- "/workspace/bereich.html": ["spicy", "admin", "manager", "scout", "creator", "hand", "modi"],
+ /* Die sieben Bretter des Treffs laufen ueber dieselbe Seite wie alle
+ anderen Bereiche. WELCHE ein Gast oeffnen darf, entscheidet die
+ Bereichsliste in workspace-bereiche.js -- diese Zeile oeffnet nur
+ die Tuer. Eine Seite ist ein Weg, keine Schranke. */
+ "/workspace/bereich.html": ["spicy", "admin", "manager", "scout", "creator", "hand", "modi", "gast"],
"/workspace/report.html": ["spicy", "admin", "manager", "scout", "creator"],
"/workspace/scouting.html": ["spicy", "admin", "manager", "scout"],
/* DIE ARBEITSLAGE DES TEAMS (09.09.2026).
@@ -251,6 +271,26 @@ export const SEITEN = {
"Kann im Alltag vertretend Entscheidungen treffen." Genau das
passiert auf dieser Seite. */
"/workspace/teamlage.html": ["admin", "hand"],
+
+ /* DIE REGELN DES TREFFS (11.09.2026). Fuer die Community und fuer
+ die, die sie durchsetzen — ein Modi, der die Regeln nicht sehen
+ kann, moderiert nach Gefuehl.
+
+ AUSDRUECKLICH NICHT fuer Manager, Scouts, Creator und Spicy: Sie
+ haben mit dem Treff nichts zu tun, und die Seite nennt Fristen,
+ Stufen und die Bedingungen des Ausschlusses. */
+ "/workspace/treff-regeln.html": ["gast", "modi", "hand", "admin"],
+
+ /* MELDUNGEN & MASSNAHMEN. Nur wer moderiert -- ausdruecklich OHNE
+ "gast": Dort stehen die Namen derer, die gemeldet haben. Eine
+ Meldung, die der Gemeldete lesen kann, ist keine Meldung mehr,
+ sondern eine Einladung zur Vergeltung. */
+ "/workspace/treff-moderation.html": ["modi", "hand", "admin"],
+
+ /* WER SIEHT WAS. Nur DogFather — die Seite ist eine vollstaendige
+ Karte dieses Hauses: jede Seite, jede Rolle. Wer sie hat, weiss,
+ wo er es versuchen muesste. */
+ "/workspace/rechte.html": ["admin"],
};
/* Die Zugangswaende. Sie haben keine Rolle, weil sich dort noch
@@ -260,6 +300,9 @@ export const SEITEN = {
export const OHNE_ANMELDUNG = [
"/workspace/index.html",
"/workspace/crew-index.html",
+ /* Die Wand des Treffs (11.09.2026). Dritte Adresse, dritte Wand --
+ und die erste, vor der jemand steht, der NICHT zum Team gehoert. */
+ "/workspace/treff-index.html",
];
/* =====================================================================
diff --git a/server/treff-adresse.js b/server/treff-adresse.js
new file mode 100644
index 00000000..de87588d
--- /dev/null
+++ b/server/treff-adresse.js
@@ -0,0 +1,171 @@
+/* =====================================================================
+ Die eigene Adresse des Treffs (11.09.2026)
+
+ treff.dogfather-universe.com
+
+ Auftrag (Filipe, 11.09.2026): *"eine community rolle und community
+ kategorie, wo die community auch dann nur die sieht."*
+
+ ---------------------------------------------------------------------
+ WARUM EINE DRITTE ADRESSE UND NICHT EINE DRITTE KACHEL
+
+ Drei Gruende, und jeder allein wuerde schon reichen:
+
+ 1. EINE APP JE URSPRUNG. Auf einem Ursprung laesst sich genau EINE
+ App installieren (w3c/manifest Nr. 1180: verschachtelte Bereiche
+ sind "strongly not recommended", Chrome haelt sich daran). Team
+ Dogi hat seine App, der Workspace hat seine. Ohne eigene Adresse
+ haette die Community gar keine.
+
+ 2. EINE FALSCHE ANMELDUNG DARF NICHT REICHEN. Waere der Treff eine
+ Kachel auf der Wand des Workspace, traefe jeder Tippfehler in
+ der Rechteprueung sofort echte Zuschauer. So steht zwischen
+ einem Mitglied der Community und den Daten der Agentur nicht nur
+ eine Rollenabfrage, sondern eine Adresse, ein Zwischenschritt
+ und die Adressregel in sitzungLesen(). Drei Schloesser -- faellt
+ eines, halten zwei.
+
+ 3. DIE COMMUNITY IST DER ERSTE KREIS VON AUSSEN. Bisher hatte jede
+ Rolle in diesem Haus einen Vertrag, eine Rechnung oder eine
+ Absprache. Ein Mitglied des Treffs hat nur einen Code. Was diese
+ Menschen sehen, ist damit die einzige Stelle, an der ein Fehler
+ wirklich nach draussen geht.
+
+ ---------------------------------------------------------------------
+ WAS SICH NICHT AENDERT: der Pfad /workspace/, der Dienst (4100), die
+ Datenbank, der Code. Getrennt wird ueber den HOSTNAMEN, so wie bei
+ Workspace und Team Dogi auch.
+
+ ---------------------------------------------------------------------
+ WAS DIESE DATEI AUSDRUECKLICH NICHT TUT: Rechte vergeben.
+
+ Wer auf welcher Adresse angemeldet sein darf, entscheidet
+ sitzungPasstZurAdresse() -- aufgerufen in sitzungLesen(), durch das
+ alle 26 Fachmodule gehen. Was eine Rolle sehen darf, entscheidet die
+ Rechtetafel in rechte.js. Zwei Orte fuer dieselbe Regel waeren einer
+ zu viel; der zweite waere der, den beim naechsten Umbau niemand
+ anfasst.
+
+ ---------------------------------------------------------------------
+ DASS DER TREFF WIE TEAM DOGI AUSSIEHT, IST ENTSCHIEDEN
+
+ Er benutzt crew-haus.css, die Buehnen von Team Dogi und deren
+ App-Symbol. Gemessen gaebe es durchaus einen eigenen Farbton
+ (Tannengruen liegt 60 Lab-Einheiten vom Marineblau entfernt, also
+ klar unterscheidbar). Er wuerde den Treff aber zu einem DRITTEN Haus
+ machen, neben Spicy Media und Team Dogi. Er ist kein drittes Haus,
+ sondern ein zweiter Raum in demselben -- "lila und babyblau ueberall"
+ (Filipe, 10.09.2026).
+
+ Unterschieden werden die Tueren durch das WORT darauf: Die Buehne der
+ Zugangswand traegt "DER TREFF" statt "TEAM DOGI" (tools/crew-buehne.mjs,
+ zwei Werte, ein Werkzeug), und die App heisst "Der Treff".
+ ===================================================================== */
+
+import {
+ istTreffAdresse, istWorkspacePfad,
+} from "./crew-adresse.js";
+
+/**
+ * Express-Zwischenschritt fuer die Adresse des Treffs.
+ *
+ * Er laeuft NACH crewWeiche und tut dort dasselbe wie diese auf ihrer
+ * Adresse -- mit drei Unterschieden: eigenes Manifest, eigene Wand,
+ * und die Buehnen kommen von Team Dogi statt von der Agentur.
+ *
+ * DIE FREMDEN WAENDE SPERRT ER NICHT SELBST. Das macht bereits
+ * crewWeiche fuer jede Adresse ueber die Wandtafel (istFremdeWand).
+ * Eine zweite Sperre hier waere dieselbe Regel an zwei Orten.
+ */
+export function treffWeiche(req, res, next) {
+ if (!istTreffAdresse(req.get("host"))) return next();
+
+ /* NICHT INDIZIEREN. Der Kopf steht hier und nicht nur als im
+ HTML, weil er dann auch fuer Manifest, Symbole, Bilder und jede
+ Antwort ohne HTML gilt. Bei dieser Adresse wiegt das schwerer als
+ bei den anderen beiden: Sie wird an Dutzende Menschen
+ weitergegeben, die nichts unterschrieben haben. Ein Eintrag bei
+ einer Suchmaschine waere nicht rueckholbar. */
+ res.setHeader("X-Robots-Tag", "noindex, nofollow");
+
+ /* DAS MANIFEST MACHT DIE APP. Alle drei Adressen liefern dieselben
+ HTML-Dateien aus, in jeder steht dieselbe Manifest-Zeile. Hier
+ liegt dahinter ein anderes: Name "Der Treff". Sonst hiesse die App
+ auf dem Startbildschirm eines Mitglieds "Team Dogi" -- der Name
+ eines Bereichs, zu dem es nicht gehoert.
+
+ Ueber die Adresse und nicht ueber eine zweite Zeile in 30
+ HTML-Dateien: Eine zweite Zeile muesste jemand bei jeder neuen
+ Seite mitschreiben, und wer sie vergisst, bekommt eine Seite, die
+ still zur falschen App gehoert. */
+ if (req.path === "/workspace/app.webmanifest") {
+ req.url = "/workspace/treff.webmanifest";
+ return next();
+ }
+
+ /* DIE ZUGANGSWAND. Ohne diese Zeile stuende auf der Adresse der
+ Community die Wand der Agentur: Spicy-Media-Logo, "Creator
+ Workspace", fuenf Rollenkacheln mit den Namen Admin, Manager,
+ Scout, Creator -- die komplette Struktur des Unternehmens, vor der
+ Anmeldung, fuer jeden, der die Adresse hat.
+
+ ZWEI PFADE, EINE DATEI: "/workspace/" und "/workspace/index.html"
+ fuehren beide zur Wand, also muessen beide umgebogen werden. Den
+ zweiten zu vergessen faellt im Alltag nicht auf -- ihn tippt
+ niemand -- und waere trotzdem genau das Leck von oben. */
+ if (req.path === "/workspace/" || req.path === "/workspace/index.html") {
+ req.url = "/workspace/treff-index.html";
+ return next();
+ }
+
+ /* DIE BUEHNEN INNEN. Die neun Motive der Agentur zeigen nicht bloss
+ rotes Licht, sondern das Spicy-Media-Logo selbst -- in `halle` und
+ `arena` sogar vielfach nebeneinander. Die Fassungen von Team Dogi
+ liegen laengst da und sind genau dafuer gebaut worden.
+
+ UND ES FLACKERT NICHT: Das Bild wird beim ERSTEN Abruf schon als
+ das richtige geliefert. Eine Umschaltung per JavaScript haette
+ erst die Spicy-Buehne gezeigt und dann getauscht. */
+ const buehne = /^\/workspace\/assets\/img\/(buehne-[a-z]+-(?:uhd|breit|schmal)\.webp)$/
+ .exec(req.path);
+ if (buehne) {
+ req.url = `/workspace/assets/img/crew-${buehne[1]}`;
+ return next();
+ }
+
+ /* DAS ZEICHEN IN DER KOPFLEISTE. start.css setzt links neben den
+ Namen eine Chili -- als CSS-Hintergrund, nicht als Bild im HTML.
+ Ohne diese Zeile stuende oben links auf jeder Seite des Treffs das
+ Zeichen von Spicy Media. */
+ if (req.path === "/workspace/assets/img/marke-spicy.webp") {
+ req.url = "/workspace/assets/img/marke-husky.webp";
+ return next();
+ }
+
+ /* DAS HAUS. Jede Seite laedt als LETZTE Stilvorlage haus.css; auf der
+ Agenturadresse ist die leer, hier steht dahinter das Farbhaus von
+ Team Dogi.
+
+ Der Dateiname reist mit einer Fassungsnummer (`?v=…`); die
+ Bedingung sieht deshalb nur auf den PFAD, nicht auf die ganze
+ Adresse. Genau daran waere sie sonst gescheitert -- lautlos, denn
+ ein nicht umgebogener Pfad liefert weiterhin eine gueltige Datei. */
+ if (req.path === "/workspace/assets/css/haus.css") {
+ req.url = "/workspace/assets/css/crew-haus.css"
+ + (req.url.includes("?") ? req.url.slice(req.url.indexOf("?")) : "");
+ return next();
+ }
+
+ /* NUR DER WORKSPACE. Auf treff. gibt es die Hauptseite nicht -- sonst
+ laege die komplette Dogfather-Website ein zweites Mal unter einer
+ zweiten Adresse (doppelter Inhalt, und ein Weg, die Adresse ueber
+ eine Suchmaschine zu finden). Alles ausserhalb von /workspace und
+ /assets geht zur Zugangswand.
+
+ Caddy tut dasselbe bereits mit seiner eigenen Weiche. Dass es hier
+ ein zweites Mal steht, ist Absicht: Eine Schranke, die nur in einer
+ Datei ausserhalb des Projekts existiert, faellt beim naechsten
+ Serverumbau lautlos weg. */
+ if (istWorkspacePfad(req.path) || req.path.startsWith("/assets/")) return next();
+ return res.redirect(302, "/workspace/");
+}
diff --git a/server/treff-tabellen.js b/server/treff-tabellen.js
new file mode 100644
index 00000000..f845f967
--- /dev/null
+++ b/server/treff-tabellen.js
@@ -0,0 +1,154 @@
+/* =====================================================================
+ Die Tabellen des Treffs und die eine Frage, die überall gestellt wird
+ (11.09.2026)
+ =====================================================================
+
+ DIESE DATEI IMPORTIERT NICHTS, und das ist ihr ganzer Zweck.
+
+ Zwei Module brauchen dasselbe: workspace.js muss beim Anmelden und
+ bei jeder Sitzung wissen, ob jemand gesperrt ist -- und
+ workspace-treff.js muss es auch. Würde workspace.js dafür aus
+ workspace-treff.js importieren, entstünde ein Kreis (treff importiert
+ db, protokolliere und die Bretter aus workspace). ES-Module kommen
+ damit zurecht, solange nur Funktionen über den Kreis laufen; sobald
+ jemand eine Konstante beim Laden benutzt, bekommt er `undefined` --
+ ohne Fehlermeldung, an einer Stelle, die mit dem Kreis nichts zu tun
+ hat. Ein Blatt ohne Importe kann diesen Fehler nicht haben.
+
+ Die Tabellen werden deshalb auch dort angelegt, wo alle anderen
+ angelegt werden: im Bauplan in workspace.js. Ein verspätetes
+ "CREATE TABLE IF NOT EXISTS" beim ersten Zugriff hätte bedeutet, dass
+ die Sperrfrage beim allerersten Aufruf gegen eine Tabelle läuft, die
+ es noch nicht gibt -- und eine Sperre, die bei einer Störung "darf"
+ sagt, ist genau die, die im Ernstfall aufgeht.
+ ===================================================================== */
+
+/** Legt die Tabellen des Treffs an. Wird aus dem Bauplan gerufen. */
+export function treffTabellen(d) {
+ d.exec(`
+ /* „Will ich auch" — EINE Stimme je Person und Eintrag.
+
+ Der zusammengesetzte Primärschlüssel ist die ganze Regel: Ein
+ zweiter Klick kann gar nicht als zweite Stimme ankommen, auch
+ nicht bei zwei gleichzeitigen Anfragen. Eine Zählspalte am
+ Eintrag hätte dieselbe Zahl gehalten und dabei jede Stimme
+ doppelt genommen, sobald jemand zweimal tippt. */
+ CREATE TABLE IF NOT EXISTS treff_stimmen (
+ eintrag_id INTEGER NOT NULL REFERENCES eintraege(id) ON DELETE CASCADE,
+ person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
+ zeitpunkt TEXT NOT NULL,
+ PRIMARY KEY (eintrag_id, person_id)
+ );
+ CREATE INDEX IF NOT EXISTS idx_treff_stimmen_person ON treff_stimmen (person_id);
+
+ /* Angeheftet am Anschlagbrett. Höchstens drei — die Zahl steht im
+ Code (ANSCHLAG_MAX), nicht hier: Eine CHECK-Regel kann nicht über
+ Zeilen hinweg zählen, und eine, die es könnte, müsste man beim
+ Ändern der Zahl umbauen (siehe checkListeErweitern). */
+ CREATE TABLE IF NOT EXISTS treff_angeheftet (
+ eintrag_id INTEGER PRIMARY KEY REFERENCES eintraege(id) ON DELETE CASCADE,
+ seit TEXT NOT NULL,
+ von INTEGER REFERENCES personen(id) ON DELETE SET NULL
+ );
+
+ /* Freigaben. Ein Eintrag auf einem Freigabe-Brett ist für die
+ Community erst da, wenn hier eine Zeile steht.
+
+ ABWESENHEIT IST DER RUHEZUSTAND, und das ist der Kern: Eine
+ Spalte "freigegeben INTEGER DEFAULT 0" hätte dasselbe getan --
+ bis jemand einen Eintrag auf einem anderen Weg anlegt und die
+ Vorgabe vergisst. Eine fehlende Zeile kann man nicht vergessen. */
+ CREATE TABLE IF NOT EXISTS treff_freigaben (
+ eintrag_id INTEGER PRIMARY KEY REFERENCES eintraege(id) ON DELETE CASCADE,
+ zeitpunkt TEXT NOT NULL,
+ von INTEGER REFERENCES personen(id) ON DELETE SET NULL
+ );
+
+ /* Meldungen (DSA Art. 16). Der Grund ist NOT NULL -- eine Meldung
+ ohne Grund ist keine Meldung, sondern ein Klick. */
+ CREATE TABLE IF NOT EXISTS treff_meldungen (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ eintrag_id INTEGER NOT NULL REFERENCES eintraege(id) ON DELETE CASCADE,
+ melder_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
+ grund TEXT NOT NULL,
+ zeitpunkt TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'offen'
+ CHECK (status IN ('offen','erledigt','abgelehnt')),
+ entscheid TEXT,
+ entschieden_von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
+ entschieden_am TEXT
+ );
+ CREATE INDEX IF NOT EXISTS idx_treff_meldungen_status
+ ON treff_meldungen (status, zeitpunkt);
+
+ /* Was entfernt wurde, und warum (DSA Art. 17).
+
+ DIESE TABELLE HÄNGT ABSICHTLICH NICHT AN eintraege. Der Eintrag
+ ist weg; die Begründung muss bleiben. Ein Fremdschlüssel mit
+ ON DELETE CASCADE hätte genau das Gegenteil bewirkt -- die
+ Begründung wäre im selben Moment verschwunden wie das, was sie
+ begründet. Deshalb stehen Titel und Verfasser hier als Text bzw.
+ als lose Nummer noch einmal. */
+ CREATE TABLE IF NOT EXISTS treff_entfernt (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ eintrag_id INTEGER NOT NULL,
+ bereich TEXT NOT NULL,
+ titel TEXT NOT NULL,
+ autor_id INTEGER,
+ autor_name TEXT,
+ grund TEXT NOT NULL,
+ von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
+ zeitpunkt TEXT NOT NULL
+ );
+
+ /* Maßnahmen: Hinweis, Pause, Ausschluss.
+
+ "bis" = NULL heißt DAUERHAFT. Wer das darf, steht in
+ darfMassnahme() -- und es ist bewusst nicht dieselbe Menge wie
+ für die befristete Fassung (Filipe, 11.09.2026: "Modis dürfen
+ befristet ausschließen, dauerhaft nur ihr"). */
+ CREATE TABLE IF NOT EXISTS treff_massnahmen (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
+ art TEXT NOT NULL CHECK (art IN ('hinweis','pause','ausschluss')),
+ grund TEXT NOT NULL,
+ bis TEXT,
+ von INTEGER REFERENCES personen(id) ON DELETE SET NULL,
+ zeitpunkt TEXT NOT NULL,
+ aufgehoben_am TEXT,
+ aufgehoben_von INTEGER REFERENCES personen(id) ON DELETE SET NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_treff_massnahmen_person
+ ON treff_massnahmen (person_id, art);
+ `);
+}
+
+/**
+ * Die wirksame Maßnahme gegen diese Person, oder null.
+ *
+ * NIMMT DIE DATENBANK ALS ARGUMENT, statt sie sich zu holen: So kann
+ * sie aus workspace.js gerufen werden, ohne dass diese Datei etwas von
+ * workspace.js wissen muss. Und in einer Prüfung kann man ihr eine
+ * Kopie geben.
+ *
+ * DREI AUSGÄNGE, NICHT ZWEI: `null` heißt "nichts liegt vor". Kann die
+ * Frage nicht beantwortet werden (Tabelle fehlt, Datenbank weg), wirft
+ * die Funktion -- sie gibt NICHT `null` zurück.
+ */
+export function treffSperreLesen(d, personId) {
+ const zeilen = d.prepare(`
+ SELECT id, art, grund, bis, zeitpunkt FROM treff_massnahmen
+ WHERE person_id = ? AND art IN ('pause','ausschluss')
+ AND aufgehoben_am IS NULL
+ ORDER BY CASE art WHEN 'ausschluss' THEN 0 ELSE 1 END, id DESC`).all(personId);
+ const nun = Date.now();
+ for (const z of zeilen) {
+ /* bis = NULL heißt dauerhaft. Eine abgelaufene Frist läuft ab,
+ ohne dass jemand etwas tun muss -- eine Sperre, die man von Hand
+ aufheben muss, bleibt sonst Jahre stehen. */
+ if (!z.bis) return z;
+ const ende = Date.parse(z.bis);
+ if (Number.isFinite(ende) && ende > nun) return z;
+ }
+ return null;
+}
diff --git a/server/workspace-bereiche.js b/server/workspace-bereiche.js
index f87647ab..288551d7 100644
--- a/server/workspace-bereiche.js
+++ b/server/workspace-bereiche.js
@@ -19,14 +19,178 @@ import {
db, protokolliere, echteIp, sitzungLesen, betreutWo, darfCreator, betreuteIds, istLeitung, istDogFather, siehtAlles, istSpicy, ohneDogFather,
externPruefen, externSql,
ohneTeamDogi, ohneAgentur, siehtModis, fuehrtTeamDogi, MODI_BEREICHE_ERLAUBT, TEAM_DOGI_ROLLEN,
+ TREFF_BRETTER, TREFF_ROLLEN, AUSSEN_ROLLEN,
} from "./workspace.js";
import { nachrichtSchicken } from "./workspace-chat.js";
+import {
+ darfSchreiben, freigabeBedingung, entfernenVorbereiten, TREFF_TEAM_ROLLEN,
+ mitRollenname,
+} from "./workspace-treff.js";
export const bereicheRouter = express.Router();
/* Die Bereiche und ihre Arten. Bewusst hier und nicht in der Datenbank:
Es sind Festlegungen aus dem Konzept, keine Nutzdaten. */
export const BEREICHE = {
+ /* =====================================================================
+ DIE SIEBEN BRETTER DES TREFFS (11.09.2026)
+
+ Der Raum fuer die Zuschauer. Sie laufen ueber dieselbe Maschinerie
+ wie Ideen-Board und Angebote -- ein Bereich, seine Arten, dieselbe
+ Seite. Eine eigene Bauart daneben waere eine zweite
+ Sichtbarkeitsregel, und genau deren Vervielfaeltigung hat am
+ 10.09. ein Leck verursacht.
+
+ ALLE SIEBEN HABEN `fuerAlle`: Im Treff sieht jeder jeden Beitrag.
+ Ein Raum, in dem man nur sieht, was man selbst geschrieben hat,
+ waere kein Raum. Die Grenze nach aussen zieht nicht `fuerAlle`,
+ sondern der Riegel in sichtbarEintrag() -- die Agentur sieht kein
+ einziges dieser Bretter.
+
+ ALLE SIEBEN HABEN `ohneCreatorBezug`: Hier geht es um niemanden aus
+ der Agentur. Das Feld waere nicht nur ueberfluessig, es waere die
+ Einladung, versehentlich einen Creator mit einem Zuschauer zu
+ verknuepfen.
+ ===================================================================== */
+
+ anschlag: {
+ name: "Anschlagbrett",
+ ober: "Vom Team",
+ arten: {
+ ansage: "Ansage",
+ regel: "Neue Regel",
+ hinweis: "Hinweis",
+ danke: "Danke an euch",
+ },
+ bewertung: false,
+ dringlichkeit: false,
+ ohneCreatorBezug: true,
+ fuerAlle: true,
+ },
+
+ ansteht: {
+ name: "Was ansteht",
+ ober: "Termine",
+ /* AUSDRUECKLICH NICHT AN DEN ECHTEN KALENDER GEKOPPELT.
+
+ Ein Termin traegt Titel, Ort und Gegenueber -- und die Regel im
+ Haus lautet seit dem 07.09., dass ein Kalender als Ganzes privat
+ ist. Eine Filterung waere eine Einstellung, die man falsch setzen
+ kann; ein Eintrag hier ist eine Entscheidung, die jemand trifft.
+ Der Preis ist, dass das Team es zweimal schreibt. Das ist der
+ guenstigere von beiden. */
+ arten: {
+ stream: "Stream",
+ event: "Event",
+ turnier: "Turnier",
+ pause: "Keine Sendung",
+ },
+ bewertung: false,
+ dringlichkeit: false,
+ ohneCreatorBezug: true,
+ fuerAlle: true,
+ },
+
+ treff: {
+ name: "Der Treff",
+ ober: "Euer Raum",
+ /* DIE REIHENFOLGE IST ABSICHT. Das erste Feld bestimmt, was
+ geschrieben wird -- wer ein Formular oeffnet, dessen erste
+ Auswahl "Problem" heisst, schreibt Probleme auf.
+
+ "Hallo, ich bin neu" steht vorn, weil die ersten sieben Tage
+ darueber entscheiden, ob jemand bleibt: Wer in dieser Zeit
+ erlebt, dass er hier einen Platz hat, kommt deutlich haeufiger
+ wieder. Jede Vorstellung bekommt eine Antwort -- das ist der
+ ganze Trick, und er steht in keinem Code. */
+ arten: {
+ hallo: "Hallo, ich bin neu",
+ frage: "Frage",
+ lob: "Das war gut",
+ problem: "Etwas stimmt nicht",
+ },
+ bewertung: false,
+ dringlichkeit: false,
+ ohneCreatorBezug: true,
+ fuerAlle: true,
+ },
+
+ wunsch: {
+ name: "Wunschliste",
+ ober: "Was ihr euch wuenscht",
+ arten: {
+ stream: "Fuer den Stream",
+ technik: "Technik",
+ gemeinsam: "Fuer die Community",
+ sonstiges: "Sonstiges",
+ },
+ bewertung: false,
+ /* KEINE DRINGLICHKEIT VON EINEM EINZELNEN. Was wichtig ist,
+ entscheidet hier nicht der Schreiber, sondern wie viele
+ mitwollen -- "Will ich auch" kommt als naechster Schritt. Eine
+ Dringlichkeit daneben waere eine zweite Rangordnung, und die
+ beiden wuerden einander widersprechen. */
+ dringlichkeit: false,
+ ohneCreatorBezug: true,
+ fuerAlle: true,
+ },
+
+ highlight: {
+ name: "Highlights",
+ ober: "Von euch",
+ arten: {
+ clip: "Clip",
+ bild: "Bild",
+ fanart: "Fanart",
+ moment: "Moment",
+ },
+ bewertung: false,
+ dringlichkeit: false,
+ ohneCreatorBezug: true,
+ fuerAlle: true,
+ },
+
+ regeln: {
+ name: "Regeln & Hilfe",
+ ober: "Wie es hier laeuft",
+ /* REGELN IN EINFACHER SPRACHE UND AN ECHTEN FAELLEN. Vage Regeln
+ erzeugen Grenzfaelle, und die muss dann jeder Modi einzeln
+ entscheiden -- das ist der Weg, auf dem ein Team uneinheitlich
+ wird und die Community es merkt. */
+ arten: {
+ regel: "Regel",
+ frage: "Haeufige Frage",
+ hilfe: "Hilfe",
+ folge: "Was passiert, wenn",
+ },
+ bewertung: false,
+ dringlichkeit: false,
+ ohneCreatorBezug: true,
+ fuerAlle: true,
+ },
+
+ mitmachen: {
+ name: "Mitmachen",
+ ober: "Nachwuchs",
+ /* DIE BRUECKE ZU DEN TALENTEN. Wer sich hier meldet, ist ein
+ Kandidat -- und Kandidaten gehoeren in die Kategorie, die es
+ dafuer schon gibt, nicht in eine zweite Liste daneben.
+
+ Daneben gehoert der ehrliche Satz, dass eine Bewerbung nicht der
+ Hauptweg ist: Der treffsicherste Weg ist, dass jemandem aus dem
+ Team auffaellt, wie du dich verhaeltst. Das steht so im Brett,
+ weil es stimmt -- und weil es die richtige Erwartung setzt. */
+ arten: {
+ gesucht: "Wir suchen",
+ lust: "Ich haette Lust",
+ frage: "Frage dazu",
+ },
+ bewertung: false,
+ dringlichkeit: false,
+ ohneCreatorBezug: true,
+ fuerAlle: true,
+ },
+
live: {
name: "LIVE-Analyse",
arten: { vorbereitung: "Vorbereitung", mitschrift: "Während LIVE", auswertung: "Auswertung" },
@@ -483,9 +647,30 @@ bereicheRouter.use("/workspace/api/bereich/:bereich", (req, res, next) => {
sie ueber die Adresszeile in die Agentur-Ablage, die sie nichts
angeht. Genau die Luecke, gegen die diese Schranke gebaut wurde,
nur eine Rolle spaeter. */
+ const b = String(req.params.bereich);
+ const nein = () => res.status(404).json({ fehler: "nicht_gefunden" });
+
+ /* EINE AUSSENROLLE KOMMT NUR IN DEN TREFF (11.09.2026).
+
+ Und zwar als ERSTER Zweig, nicht als letzter. Die Zeile darunter
+ hiess bis heute "wer nicht zum Team gehoert, darf weiter" -- fuer
+ einen Gast waere das ein Freifahrtschein durch alle achtzehn
+ Bereiche gewesen, obwohl die Kacheln ihm nur sieben anbieten. Eine
+ Regel, die nur im Formular gilt, ist keine Regel. */
+ if (AUSSEN_ROLLEN.has(req.person?.rolle)) {
+ return TREFF_BRETTER.includes(b) ? next() : nein();
+ }
+
+ /* UND UMGEKEHRT: Wer nicht zum Treff gehoert, kommt nicht hinein.
+ Das ist derselbe Riegel wie in sichtbarEintrag(), nur eine Ebene
+ frueher -- dort entscheidet er, welche ZEILEN jemand sieht, hier,
+ ob die Seite ueberhaupt antwortet. Zwei Schloesser, absichtlich:
+ Faellt eines weg, haelt das andere. */
+ if (TREFF_BRETTER.includes(b) && !TREFF_ROLLEN.has(req.person?.rolle)) return nein();
+
if (!TEAM_DOGI_ROLLEN.has(req.person?.rolle)) return next();
- if (MODI_BEREICHE_ERLAUBT.has(String(req.params.bereich))) return next();
- return res.status(404).json({ fehler: "nicht_gefunden" });
+ if (MODI_BEREICHE_ERLAUBT.has(b)) return next();
+ return nein();
});
/* ---------- Die Bereiche sind fuer Creator zum LESEN da ----------------
@@ -713,7 +898,65 @@ function ohneVertrauliche(regel, person, praefix) {
};
}
+/* =====================================================================
+ DER TREFF HAT ZWEI RIEGEL, UND SIE ZEIGEN IN ENTGEGENGESETZTE
+ RICHTUNGEN (11.09.2026)
+
+ Filipe: "wo die community auch dan nur die sieht und die modis
+ rechte hand und ich also dogfather sehen die aber auch. die
+ community aber nur die dann."
+
+ Daraus folgen zwei Saetze, und beide muessen gelten:
+
+ 1. EINE AUSSENROLLE SIEHT NUR DEN TREFF. Nicht "den Treff und was
+ sonst noch durchfaellt" -- nur ihn. Deshalb steht dieser Zweig
+ GANZ VORN und baut seine Regel selbst, statt auf der
+ allgemeinen aufzusetzen. Wer auf einer bestehenden Regel
+ aufsetzt, erbt jeden Zweig, den sie hat, auch die, die es
+ morgen erst gibt.
+
+ 2. DIE AGENTUR SIEHT DEN TREFF NICHT. Creator, Scouts, Manager und
+ Spicy Media haben mit den Zuschauern dieses Streams nichts zu
+ tun. Ohne diesen zweiten Riegel haette `fuerAlle` sie
+ hereingelassen -- genau das Loch, das am 10.09. schon einmal
+ entstanden ist, nur andersherum.
+
+ BEIDE STEHEN HIER UND NICHT IN sichtbar(). Diese Funktion beantwortet
+ auch Fragen zu Aufgaben, Dateien und Terminen; ein Bereichsname hat
+ dort nichts zu suchen. Der Kommentar eine Bildschirmseite weiter oben
+ warnt woertlich davor. */
export function sichtbarEintrag(person, praefix = "e") {
+ /* RIEGEL 1 -- die Aussenrolle. Eine eigene, vollstaendige Regel:
+ genau die Bretter des Treffs, sonst nichts. */
+ if (AUSSEN_ROLLEN.has(person?.rolle)) {
+ const platz = TREFF_BRETTER.map(() => "?").join(", ");
+ const grund = { wo: `${praefix}.bereich IN (${platz})`, werte: [...TREFF_BRETTER] };
+ /* RIEGEL 1b -- WAS NOCH NICHT FREIGEGEBEN IST, GIBT ES NICHT
+ (11.09.2026). Termine und Highlights erscheinen der Community
+ erst, wenn jemand aus dem Team sie freigegeben hat.
+
+ DIE BEDINGUNG HAENGT HIER UND NICHT AM LESE-WEG, weil sie sonst
+ nur dort gaelte: Suche, Ausfuhr und jeder kuenftige Weg, der
+ sichtbarEintrag benutzt, haetten die unfreigegebenen Zeilen
+ weiterhin geliefert. Genau so ist am 10.09. ein Modi-Eintrag
+ durch die Suche gekommen, obwohl das Brett zu war. */
+ const frei = freigabeBedingung(person, praefix);
+ if (!frei) return grund;
+ return { wo: `(${grund.wo}) AND ${frei.wo}`, werte: [...grund.werte, ...frei.werte] };
+ }
+ const regel = sichtbarRoh2(person, praefix);
+ /* RIEGEL 2 -- wer nicht zum Treff gehoert, sieht seine Bretter nicht.
+ Auch DogFather geht hier durch; er steht in TREFF_ROLLEN und faellt
+ deshalb nicht heraus. */
+ if (!regel || TREFF_ROLLEN.has(person?.rolle)) return regel;
+ const platz = TREFF_BRETTER.map(() => "?").join(", ");
+ return {
+ wo: `(${regel.wo}) AND ${praefix}.bereich NOT IN (${platz})`,
+ werte: [...regel.werte, ...TREFF_BRETTER],
+ };
+}
+
+function sichtbarRoh2(person, praefix = "e") {
const regel = sichtbar(person);
if (!regel) return regel;
if (regel.wo === "1=1") return regel; // DogFather sieht ohnehin alles
@@ -773,6 +1016,34 @@ function saeulePasst(saeuleId, creatorId) {
return null;
}
+/* =====================================================================
+ WAS EIN TREFF-BRETT ZUSAETZLICH MITBRINGT (11.09.2026)
+
+ Drei Angaben, die es nur dort gibt: wie viele "Will ich auch" ein
+ Wunsch hat, ob meine Stimme dabei ist, ob die Ansage angeheftet und
+ ob der Eintrag freigegeben ist.
+
+ SIE HAENGEN NICHT AN SPALTEN, sondern werden nur fuer die sieben
+ Bretter angehaengt. Drei Unterabfragen je Zeile sind hier
+ bedeutungslos (die Tabellen haben eine Handvoll Zeilen), auf den
+ Betreuungsbrettern waeren sie es nicht -- und vor allem waeren sie
+ dort eine Auskunft ueber etwas, das es in diesem Bereich gar nicht
+ gibt.
+
+ DER ROLLENNAME KOMMT MIT (Entscheidung Filipe, 11.09.2026: "voller
+ Rollenname zeigen"). Er kommt als ROLLE, der Klartext wird erst in
+ der Antwort gebildet -- eine Zuordnung Rolle -> Name im
+ ausgelieferten JavaScript waere eine Liste aller Rollen dieses
+ Hauses auf jedem Rechner der Community.
+ ===================================================================== */
+const TREFF_SPALTEN = `,
+ (SELECT COUNT(*) FROM treff_stimmen ts WHERE ts.eintrag_id = e.id) AS stimmen,
+ (SELECT COUNT(*) FROM treff_stimmen ts
+ WHERE ts.eintrag_id = e.id AND ts.person_id = ?) AS meine_stimme,
+ (SELECT COUNT(*) FROM treff_angeheftet ta WHERE ta.eintrag_id = e.id) AS angeheftet,
+ (SELECT COUNT(*) FROM treff_freigaben tf WHERE tf.eintrag_id = e.id) AS freigegeben,
+ pe.rolle AS erstellt_rolle`;
+
const VERBUND = `
FROM eintraege e
LEFT JOIN personen pc ON pc.id = e.creator_id
@@ -789,15 +1060,43 @@ bereicheRouter.get("/workspace/api/bereich/:bereich", (req, res) => {
const regel = sichtbarEintrag(req.sicht || req.person);
if (!regel) return res.status(404).json({ fehler: "nicht_gefunden" });
- const eintraege = db().prepare(`
- SELECT ${SPALTEN} ${VERBUND}
- WHERE ${regel.wo} AND e.bereich = ?
- ORDER BY
- CASE e.status WHEN 'offen' THEN 0 ELSE 1 END,
- CASE e.dringlichkeit WHEN 'hoch' THEN 0 WHEN 'mittel' THEN 1 ELSE 2 END,
- e.datum DESC, e.id DESC`).all(...regel.werte, bereich);
+ const treffBrett = TREFF_BRETTER.includes(bereich);
- res.json({ bereich, einstellung, eintraege });
+ /* DIE REIHENFOLGE GEHOERT ZUM BRETT (11.09.2026).
+
+ Am Anschlagbrett steht oben, was angeheftet ist -- sonst waere
+ das Anheften eine Zierde. Auf der Wunschliste steht oben, was die
+ meisten wollen; genau das ist der Sinn von "Will ich auch", und
+ eine Liste nach Datum haette die Abstimmung unsichtbar gemacht.
+ Ueberall sonst bleibt es, wie es war. */
+ const reihenfolge = bereich === "anschlag"
+ ? "angeheftet DESC, e.datum DESC, e.id DESC"
+ : bereich === "wunsch"
+ ? "stimmen DESC, e.datum DESC, e.id DESC"
+ : `CASE e.status WHEN 'offen' THEN 0 ELSE 1 END,
+ CASE e.dringlichkeit WHEN 'hoch' THEN 0 WHEN 'mittel' THEN 1 ELSE 2 END,
+ e.datum DESC, e.id DESC`;
+
+ const wer = (req.sicht || req.person)?.id ?? 0;
+ const eintraege = db().prepare(`
+ SELECT ${SPALTEN}${treffBrett ? TREFF_SPALTEN : ""} ${VERBUND}
+ WHERE ${regel.wo} AND e.bereich = ?
+ ORDER BY ${reihenfolge}`)
+ .all(...(treffBrett ? [wer] : []), ...regel.werte, bereich);
+
+ /* DASS DIES EIN BRETT DES TREFFS IST, SAGT DER SERVER (11.09.2026).
+
+ Die Oberflaeche braucht die Auskunft, um ihre Knoepfe zu bauen
+ ("Will ich auch", anheften, freigeben, melden). Sie koennte den
+ Bereichsnamen gegen eine Liste im Browser halten -- dann stuenden
+ die Namen aller sieben Bretter in einer ausgelieferten Datei, und
+ es gaebe eine zweite Wahrheit darueber, welche es sind. So gibt
+ es weiterhin genau eine (TREFF_BRETTER), und der Browser fragt
+ sie, statt sie zu kennen. */
+ res.json({
+ bereich, einstellung, treff: treffBrett,
+ eintraege: treffBrett ? eintraege.map(mitRollenname) : eintraege,
+ });
} catch (fehler) {
console.error("[workspace] Bereich lesen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
@@ -1062,6 +1361,20 @@ bereicheRouter.post("/workspace/api/bereich/:bereich", gleicheHerkunft, (req, re
const regel = sichtbar(req.person);
if (!regel) return res.status(404).json({ fehler: "nicht_gefunden" });
+ /* DIE SCHREIBTAFEL DES TREFFS (11.09.2026).
+
+ Auf welches der sieben Bretter jemand von aussen schreiben darf,
+ steht in TREFF_SCHREIBEN -- an einer Stelle, aus der auch die
+ Oberflaeche ihre Knoepfe holt (/api/treff/lage). Waeren es zwei
+ Stellen, koennte ein Knopf erscheinen, den der Server ablehnt --
+ oder, schlimmer, umgekehrt.
+
+ DER TEXT WIRD DURCHGEREICHT, nicht durch "nicht_erlaubt" ersetzt:
+ "Ab Dabei kannst du hier schreiben" beantwortet die Frage, die
+ der Mensch davor gerade hat. */
+ const warum = darfSchreiben(req.person, bereich);
+ if (warum) return res.status(403).json({ fehler: warum });
+
const { aus, fehler } = pruefe(bereich, req.body || {}, { neu: true });
if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") });
@@ -1226,9 +1539,36 @@ bereicheRouter.delete("/workspace/api/bereich/:bereich/:id", gleicheHerkunft, (r
/* Löschen darf das Management und wer den Eintrag selbst geschrieben
hat -- sonst könnte ein Creator eine Notiz des Managements über
seinen eigenen Bereich verschwinden lassen. */
- if (!istLeitung(req.person) && eintrag.erstellt_von !== req.person.id) {
- return res.status(403).json({ fehler: "nicht_erlaubt" });
- }
+ /* IM TREFF RAEUMT AUCH EIN MODI AUF (11.09.2026). Er ist dort die
+ Moderation; waere Aufraeumen der Leitung vorbehalten, bliebe ein
+ Beitrag stehen, bis jemand Zeit hat -- und genau in dieser Zeit
+ liest ihn die Community. */
+ const treffBrett = TREFF_BRETTER.includes(bereich);
+ const darfWeg = treffBrett
+ ? TREFF_TEAM_ROLLEN.has(req.person.rolle) || eintrag.erstellt_von === req.person.id
+ : istLeitung(req.person) || eintrag.erstellt_von === req.person.id;
+ if (!darfWeg) return res.status(403).json({ fehler: "nicht_erlaubt" });
+
+ /* DER GRUND WIRD FESTGEHALTEN, BEVOR DER BEITRAG VERSCHWINDET
+ (DSA Art. 17). Danach waere es zu spaet: Titel und Verfasser
+ stuenden nirgends mehr, und eine Begruendung ohne den Beitrag,
+ den sie begruendet, ist keine.
+
+ DER GRUND KOMMT AUS DER ADRESSE, und das ist keine Vorliebe,
+ sondern gemessen (11.09.2026): Nodes HTTP-Parser weist ein
+ DELETE MIT KOERPER mit einem leeren 400 ab, bevor Express die
+ Anfrage ueberhaupt sieht -- eine Antwort mit genau einem
+ Kopffeld ("connection: close") und ohne Text. Wer den Fehler
+ sucht, sucht ihn in dieser Route, und hier ist keiner.
+
+ Der Koerper bleibt als zweite Moeglichkeit stehen: Er schadet
+ nicht, und wenn ein anderer Weg (ein Werkzeug, eine kuenftige
+ Fassung von Node) doch einen mitschickt, wird er gelesen. Zwei
+ Quellen fuer denselben Wert sind hier keine zwei Wahrheiten --
+ es ist derselbe Wert, nur zwei Briefkaesten. */
+ const nein = entfernenVorbereiten(req.person, eintrag,
+ req.body?.grund ?? req.query?.grund);
+ if (nein) return res.status(400).json({ fehler: nein });
db().prepare("DELETE FROM eintraege WHERE id = ?").run(id);
protokolliere("eintrag_geloescht", {
diff --git a/server/workspace-personen.js b/server/workspace-personen.js
index a88250ef..eb1424d2 100644
--- a/server/workspace-personen.js
+++ b/server/workspace-personen.js
@@ -265,8 +265,32 @@ personenRouter.get("/workspace/api/verwaltung/personen", (req, res) => {
steht, wurde bis eben nicht gezeichnet. VanVan verschwand
genau in dem Moment aus der Liste, in dem sie die rechte Hand
wurde -- ohne Fehler, ohne Luecke, ohne Hinweis. */
- zusatzrollen: siehtModis(req.person)
- ? [{
+ /* DIE COMMUNITY STEHT NICHT IN DERSELBEN BEDINGUNG (11.09.2026).
+
+ Die beiden Eintraege unten haengen an `siehtModis` -- wer die
+ Modis sieht, darf sie auch anlegen. Fuer die Community gilt das
+ NICHT: Einen Zugang zum Treff vergibt allein DogFather (Plan
+ Teil C, Zeile "Zugang anlegen"). Ein Modi, der einen anlegen
+ koennte, koennte sich selbst Verstaerkung holen.
+
+ GEFRAGT WIRD `darfAnlegen` UND NICHT DIE ROLLE. Dieselbe
+ Auskunft, aus der auch die Route ihre Absage holt -- deshalb
+ kann hier kein Knopf erscheinen, den der Server ablehnt, und
+ keiner fehlen, den er annehmen wuerde. Auf der Team-Adresse
+ faellt er dadurch von selbst weg (darfAnlegen filtert dort auf
+ die Team-Rollen), und genau das ist richtig: Wer dort jemanden
+ anlegt, meint jemanden aus dem Team. */
+ zusatzrollen: [
+ ...(darfAnlegen(req.person).includes("gast")
+ ? [{
+ wert: "gast", name: "Community", symbol: "leute",
+ text: "Zugang zum Treff. Sieht die sieben Bretter der Community "
+ + "und sonst nichts \u2013 keine Aufgaben, keine Termine, "
+ + "keine Namen aus dem Team.",
+ }]
+ : []),
+ ...(siehtModis(req.person)
+ ? [{
wert: "modi", name: "Modi",
/* DIE PFOTE (Filipe, 10.09.2026 mit Bildschirmfoto: "bei
rechte hand soll der husky sein und bei modis soll die
@@ -310,7 +334,8 @@ personenRouter.get("/workspace/api/verwaltung/personen", (req, res) => {
+ "dieselben Aufgaben, Ideen und Angebote wie du – aber keine "
+ "privaten Kalender und keine Einzelchats.",
}]
- : [],
+ : []),
+ ],
});
} catch (fehler) {
console.error("[workspace] Personen lesen:", fehler?.message);
diff --git a/server/workspace-treff.js b/server/workspace-treff.js
new file mode 100644
index 00000000..1b30bd00
--- /dev/null
+++ b/server/workspace-treff.js
@@ -0,0 +1,951 @@
+/* =====================================================================
+ DER TREFF — was die Community darf, und wer darüber wacht (11.09.2026)
+ =====================================================================
+
+ Auftrag (Filipe, wörtlich):
+ *"eine community rolle und community kategorie, wo die community
+ auch dann nur die sieht."*
+ *"wieso nur eine kachel für die community ich will dass die mehrere
+ kacheln haben. mit mehreren optionen."*
+
+ Die sieben Bretter stehen in workspace.js (TREFF_BEREICHE), ihre
+ Sichtbarkeit in workspace-bereiche.js. Hier steht, was darauf
+ PASSIERT: Stimmen, Anheften, Freigeben, Melden, Entfernen, Stufen,
+ Maßnahmen und die Brücke zu den Talenten.
+
+ ---------------------------------------------------------------------
+ WARUM EIN EIGENES MODUL UND KEIN ANHANG AN workspace-bereiche.js
+
+ Weil die Community die einzige Rolle in diesem Haus ist, deren
+ Mitglieder nichts unterschrieben haben. Für alle anderen gilt: Ein
+ Fehler in der Rechteprüfung trifft jemanden, der ohnehin fast alles
+ sehen darf. Hier trifft er Fremde. Diese Regeln gehören deshalb an
+ eine Stelle, an der man sie am Stück lesen kann — nicht verteilt auf
+ vier Handler eines 1500 Zeilen langen Moduls.
+
+ ---------------------------------------------------------------------
+ DIE DREI STUFEN WERDEN GERECHNET, NICHT GESPEICHERT
+
+ Neu → ab dem ersten Tag
+ Dabei → ab 7 Tagen
+ Stamm → ab 30 Tagen UND 3 eigenen Beiträgen
+
+ Eine gespeicherte Stufe wäre ein zweiter Ort für eine Tatsache, die
+ ohnehin in den Daten steht — und sie würde altern: Wer am Tag 6
+ eingetragen wird, bliebe „Neu", bis irgendein Vorgang sie nachzieht.
+ Ein Vorgang, den es nicht gibt, ist kein Vorgang.
+
+ WOZU DIE STUFEN ÜBERHAUPT: Die erste Woche ist eine Sandkiste. Wer
+ neu ist, darf lesen und „Will ich auch" klicken — mehr nicht. Das ist
+ kein Misstrauen gegen Menschen, sondern gegen Wegwerf-Zugänge: Ein
+ Zugang, der sieben Tage warten muss, bevor er Schaden anrichten kann,
+ lohnt sich für niemanden, der Schaden anrichten will.
+
+ ---------------------------------------------------------------------
+ DAS RECHT ENTSCHEIDET HIER MIT, UND ZWAR DIE BAUWEISE
+
+ * DSA Art. 16 — MELDEN MUSS GEHEN, und die Meldung muss so genau
+ sein, dass man ohne Rückfrage entscheiden kann. Deshalb ist der
+ Grund PFLICHT und nicht ein Auswahlfeld mit „Sonstiges".
+ * DSA Art. 17 — WER ETWAS ENTFERNT, BEGRÜNDET ES. Die Begründung
+ überlebt den entfernten Beitrag: Sie steht in treff_entfernt, nicht
+ an der gelöschten Zeile. Genau das ist der Sinn — eine Begründung,
+ die mit dem Beitrag verschwindet, ist keine.
+ * DSA Art. 19 — Kleinstunternehmen sind von den Art. 20–28 befreit.
+ Die Pflichten aus Art. 16/17 gelten trotzdem, und sie sind ohnehin
+ das, was man aus Anstand täte.
+ * DSGVO Art. 8 — in Luxemburg liegt die Altersgrenze für die
+ Einwilligung bei 16. Der Treff setzt trotzdem **18**
+ (Entscheidung Filipe, 11.09.2026): Rund um einen Stream für
+ Erwachsene ist die Frage nicht, was erlaubt ist, sondern wen man
+ hereinlässt.
+
+ ---------------------------------------------------------------------
+ WAS HIER ABSICHTLICH NICHT STEHT
+
+ KEINE DIREKTNACHRICHTEN zwischen Mitgliedern. Das ist die wichtigste
+ Einzelentscheidung des ganzen Bereichs. Ein öffentlicher Raum ist
+ moderierbar — ein privater ist es nicht. Jede Belästigung, die im
+ Treff passiert, passiert vor Zeugen und kann gemeldet werden. Wäre
+ sie in einem Postfach passiert, wüsste niemand davon, und der Treff
+ wäre der Ort, an dem die Adressen dafür verteilt werden.
+ ===================================================================== */
+
+import express from "express";
+import {
+ db, sitzungLesen, protokolliere, echteIp,
+ TREFF_BRETTER, TREFF_ROLLEN, ROLLEN_NAME,
+} from "./workspace.js";
+/* Die Tabellen und die Sperrfrage stehen in einem Blatt ohne eigene
+ Importe -- workspace.js braucht beides ebenfalls, und ein Importkreis
+ waere die Folge. Begruendung im Kopf von treff-tabellen.js. */
+import { treffSperreLesen } from "./treff-tabellen.js";
+
+export const treffRouter = express.Router();
+
+const jetzt = () => new Date().toISOString();
+const TAG = 86400000;
+
+/* ---------------------------------------------------------------------
+ WER IST TEAM, WER IST AUSSEN
+
+ Beide Mengen stehen hier ausgeschrieben und nicht als „alles außer
+ gast". Dieselbe Überlegung wie in crew-adresse.js: Eine Bedingung
+ durch Ausschluss nimmt jede künftige Rolle automatisch mit auf — und
+ die nächste neue Rolle wäre damit stillschweigend Moderation im
+ Treff. Genau dieser Fehler steckte am 11.09. schon einmal im stillen
+ Zugang.
+ ------------------------------------------------------------------- */
+export const TREFF_TEAM_ROLLEN = new Set(["modi", "hand", "admin"]);
+const TREFF_TEAM = TREFF_TEAM_ROLLEN;
+const TREFF_AUSSEN = new Set(["gast"]);
+
+/** Darf diese Person überhaupt etwas im Treff tun? */
+const imTreff = (p) => TREFF_ROLLEN.has(p?.rolle);
+
+/* =====================================================================
+ DIE STUFEN
+ ===================================================================== */
+
+export const STUFEN = ["neu", "dabei", "stamm"];
+const STUFEN_NAME = { neu: "Neu", dabei: "Dabei", stamm: "Stamm" };
+/** Ab wann die nächste Stufe gilt. Beide Grenzen an einer Stelle. */
+const DABEI_TAGE = 7;
+const STAMM_TAGE = 30;
+const STAMM_BEITRAEGE = 3;
+
+/**
+ * Welche Stufe hat diese Person?
+ *
+ * Team Dogi hat keine Stufe — die Frage ergibt dort keinen Sinn, und
+ * eine erfundene Antwort („stamm") wäre eine, auf die sich später
+ * jemand verlässt. Deshalb `null`.
+ */
+export function stufeVon(person, dieDb = db) {
+ if (!person || !TREFF_AUSSEN.has(person.rolle)) return null;
+ const zeile = dieDb().prepare("SELECT erstellt FROM personen WHERE id = ?")
+ .get(person.id);
+ if (!zeile?.erstellt) return "neu";
+ const tage = (Date.now() - Date.parse(zeile.erstellt)) / TAG;
+ if (!Number.isFinite(tage) || tage < DABEI_TAGE) return "neu";
+ if (tage < STAMM_TAGE) return "dabei";
+ const platz = TREFF_BRETTER.map(() => "?").join(", ");
+ const { n } = dieDb().prepare(
+ `SELECT COUNT(*) AS n FROM eintraege
+ WHERE erstellt_von = ? AND bereich IN (${platz})`).get(person.id, ...TREFF_BRETTER);
+ return n >= STAMM_BEITRAEGE ? "stamm" : "dabei";
+}
+
+const stufeReicht = (hat, noetig) => STUFEN.indexOf(hat) >= STUFEN.indexOf(noetig);
+
+/* =====================================================================
+ WER DARF AUF WELCHES BRETT SCHREIBEN — die Tafel
+
+ Eine Tafel und keine Abfragen in den Handlern, aus demselben Grund
+ wie bei der Rechtetafel in rechte.js: Wer ein achtes Brett anlegt,
+ muss sich entscheiden. Steht es nicht hier, darf niemand von außen
+ darauf schreiben — `undefined` heißt „nein", nicht „alles".
+
+ `null` bei `gast` heißt: gar nicht, auf keiner Stufe. Das ist etwas
+ anderes als „auf einer hohen Stufe" und wird deshalb anders
+ geschrieben.
+ ===================================================================== */
+export const TREFF_SCHREIBEN = {
+ /* Ansagen vom Team. Ein Anschlagbrett, auf das jeder schreiben darf,
+ ist kein Anschlagbrett, sondern ein zweiter Treff. */
+ anschlag: null,
+ /* Termine. Was ansteht, sagt das Team — sonst steht dort ein Termin,
+ den es nicht gibt, und jemand wartet umsonst. */
+ ansteht: null,
+ /* Das Gespräch. Ab „Dabei". */
+ treff: "dabei",
+ /* Wünsche. Schreiben ab „Dabei" — KLICKEN darf jeder ab Tag eins,
+ siehe „Will ich auch". Das ist Absicht: Der erste Eindruck soll
+ nicht „du darfst noch nichts" sein, sondern „deine Stimme zählt". */
+ wunsch: "dabei",
+ /* Clips, Bilder, Fanart. Ab „Stamm", und erst nach Freigabe sichtbar.
+ Zwei Schlösser, weil hier fremde Inhalte hochgeladen werden —
+ Urheberrecht und Anstand sind nichts, was man nachträglich klärt. */
+ highlight: "stamm",
+ /* Regeln pflegt das Team. Eine Regel, die jeder ändern darf, ist
+ keine. */
+ regeln: null,
+ /* „Ich hätte Lust" — die Brücke zu den Talenten. Ab „Dabei": Wer sich
+ nach drei Tagen bewirbt, bewirbt sich auf etwas, das er noch nicht
+ gesehen hat. */
+ mitmachen: "dabei",
+};
+
+/* Bretter, auf denen ein Eintrag erst nach Freigabe sichtbar wird.
+
+ BEI `ansteht` IST DAS DER SCHALTER „IM TREFF ZEIGEN". Das Team trägt
+ dort ein, was ansteht, und entscheidet je Termin, ob die Community
+ ihn sieht. Ohne diesen Schalter müsste es zwei Terminlisten führen —
+ und die zweite wäre die, die niemand pflegt.
+
+ BEI `highlight` IST ES DER URHEBERRECHTS- UND ANSTANDSFILTER. */
+export const TREFF_FREIGABE_BRETTER = ["ansteht", "highlight"];
+
+/* =====================================================================
+ DIE TABELLEN
+ ===================================================================== */
+
+/* DIE TABELLEN LEGT DER BAUPLAN AN, nicht dieses Modul.
+
+ Vorher stand hier ein "beim ersten Zugriff anlegen". Das ist an drei
+ Stellen im Haus bewaehrt (Ampel, Checkliste, Schulung) und hier
+ trotzdem falsch: Die Sperrfrage wird in sitzungLesen() gestellt --
+ also bevor irgendein Weg des Treffs beruehrt wurde. Beim allerersten
+ Aufruf haette sie gegen eine Tabelle gelaufen, die es noch nicht
+ gibt. Eine Sperre, die bei einer Stoerung "darf" sagt, ist die, die
+ im Ernstfall aufgeht.
+
+ Das SQL selbst steht in treff-tabellen.js, samt Begruendung je
+ Tabelle. */
+
+/** Höchstens drei Ansagen gleichzeitig angeheftet.
+ *
+ * WARUM ÜBERHAUPT EINE GRENZE: Ein Anschlagbrett mit zwölf angehefteten
+ * Ansagen ist ein Anschlagbrett mit null -- gelesen wird keine davon.
+ * Drei ist die Zahl, die auf ein Handy passt, ohne zu scrollen. */
+export const ANSCHLAG_MAX = 3;
+
+/* =====================================================================
+ SPERREN — wer gerade nicht darf
+ ===================================================================== */
+
+/**
+ * Die wirksame Maßnahme gegen diese Person, oder null.
+ *
+ * DREI AUSGÄNGE, NICHT ZWEI: `null` heißt „nichts liegt vor". Kann die
+ * Frage nicht beantwortet werden (Tabelle fehlt, Datenbank weg), wirft
+ * die Funktion -- sie gibt NICHT `null` zurück. Eine Sperre, die bei
+ * einer Störung „darf" sagt, ist genau die, die im Ernstfall aufgeht.
+ */
+/** Die wirksame Massnahme gegen diese Person, oder null.
+ * Die Antwort selbst steht in treff-tabellen.js -- dieselbe Frage
+ * wird beim Anmelden und bei jeder Sitzung gestellt, und zwei
+ * Fassungen davon waeren eine zu viel. */
+export const treffSperre = (personId) => treffSperreLesen(db(), personId);
+
+/**
+ * Darf diese Person diese Maßnahme verhängen?
+ *
+ * Filipes Entscheidung vom 11.09.2026, wörtlich: *"Modis dürfen
+ * befristet ausschließen, dauerhaft nur ihr."*
+ *
+ * WARUM DIE DAUERHAFTE FASSUNG ANDERS BEHANDELT WIRD: Eine Pause endet
+ * von selbst. Ein dauerhafter Ausschluss endet nur, wenn jemand ihn
+ * zurücknimmt -- und dafür muss man erst merken, dass er falsch war.
+ * Das ist eine andere Art Entscheidung, also braucht sie eine andere
+ * Hand.
+ */
+export function darfMassnahme(person, art, dauerhaft) {
+ const r = person?.rolle;
+ if (!TREFF_TEAM.has(r)) return false;
+ if (art === "hinweis") return true;
+ if (!dauerhaft) return true;
+ return r === "admin";
+}
+
+/** Die längste Frist, die diese Rolle setzen darf (in Tagen). */
+export const MODI_FRIST_TAGE = 7;
+export function fristErlaubt(person, tage) {
+ if (person?.rolle === "admin" || person?.rolle === "hand") return true;
+ return tage > 0 && tage <= MODI_FRIST_TAGE;
+}
+
+/* =====================================================================
+ DIE FRAGEN, DIE ANDERE MODULE STELLEN
+ ===================================================================== */
+
+/**
+ * Darf diese Person auf dieses Brett schreiben?
+ *
+ * Gibt einen Grund zurück (Text) oder null, wenn es in Ordnung ist.
+ * Ein Text statt `false`, weil die Antwort dem Menschen davor gesagt
+ * wird -- „geht nicht" ohne Grund ist die Sorte Antwort, nach der man
+ * fragt, was man falsch gemacht hat.
+ */
+export function darfSchreiben(person, bereich) {
+ if (!TREFF_BRETTER.includes(bereich)) return null; // kein Treff-Brett
+ if (TREFF_TEAM.has(person?.rolle)) return null; // Team schreibt überall
+ if (!TREFF_AUSSEN.has(person?.rolle)) return "Dieser Bereich gehört zum Treff.";
+
+ const noetig = TREFF_SCHREIBEN[bereich];
+ if (!noetig) return "Hier schreibt das Team.";
+
+ const sperre = treffSperre(person.id);
+ if (sperre) {
+ return sperre.art === "ausschluss"
+ ? "Dein Zugang zum Treff ist beendet."
+ : `Du hast gerade eine Pause${sperre.bis ? ` bis ${sperre.bis.slice(0, 10)}` : ""}.`;
+ }
+
+ const hat = stufeVon(person);
+ if (!stufeReicht(hat, noetig)) {
+ return noetig === "dabei"
+ ? `Ab „${STUFEN_NAME.dabei}" kannst du hier schreiben – das ist nach ${DABEI_TAGE} Tagen so weit.`
+ : `Ab „${STUFEN_NAME.stamm}" kannst du hier etwas einstellen – nach ${STAMM_TAGE} Tagen `
+ + `und ${STAMM_BEITRAEGE} eigenen Beiträgen.`;
+ }
+ return null;
+}
+
+/**
+ * Die Zusatzbedingung, die unfreigegebene Einträge vor der Community
+ * verbirgt. Für Team Dogi gilt sie nicht -- es muss ja sehen, was es
+ * freigeben soll.
+ *
+ * ALS SQL-BEDINGUNG UND NICHT ALS FILTER DANACH: Ein Filter in
+ * JavaScript hätte die Zeilen erst geholt. Sie stünden dann in der
+ * Antwort des Servers, in seinem Speicher und in jedem Protokoll
+ * dazwischen -- und beim nächsten Weg, der dieselbe Abfrage benutzt,
+ * ohne den Filter mitzunehmen, auch im Browser.
+ */
+export function freigabeBedingung(person, praefix = "e") {
+ if (!TREFF_AUSSEN.has(person?.rolle)) return null;
+ const platz = TREFF_FREIGABE_BRETTER.map(() => "?").join(", ");
+ return {
+ wo: `(${praefix}.bereich NOT IN (${platz})
+ OR EXISTS (SELECT 1 FROM treff_freigaben f WHERE f.eintrag_id = ${praefix}.id))`,
+ werte: [...TREFF_FREIGABE_BRETTER],
+ };
+}
+
+/* =====================================================================
+ DIE WEGE
+ ===================================================================== */
+
+function angemeldet(req, res, next) {
+ const person = sitzungLesen(req);
+ if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
+ if (!imTreff(person)) return res.status(404).json({ fehler: "nicht_gefunden" });
+ req.person = person;
+ next();
+}
+
+function gleicheHerkunft(req, res, next) {
+ const herkunft = req.get("origin");
+ if (!herkunft) return next();
+ let erlaubt;
+ try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; }
+ if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" });
+ next();
+}
+
+treffRouter.use("/workspace/api/treff", angemeldet);
+
+/** Nur Team Dogi. 404 und nicht 403 -- wer „kein Zugriff" liest, fragt
+ * als Nächstes, wer denn dann. */
+function nurTeam(req, res, next) {
+ if (TREFF_TEAM.has(req.person?.rolle)) return next();
+ return res.status(404).json({ fehler: "nicht_gefunden" });
+}
+
+/** Gehört dieser Eintrag zu einem Brett des Treffs? */
+function treffEintrag(id) {
+ const e = db().prepare(
+ "SELECT id, bereich, titel, erstellt_von FROM eintraege WHERE id = ?").get(id);
+ if (!e || !TREFF_BRETTER.includes(e.bereich)) return null;
+ return e;
+}
+
+const nummer = (x) => { const n = Number(x); return Number.isInteger(n) && n > 0 ? n : 0; };
+const text = (x, max) => String(x ?? "").trim().slice(0, max);
+
+/* ---------- Meine Lage ------------------------------------------------ */
+
+/* Was DIESE Person gerade darf. Die Oberfläche baut daraus ihre Knöpfe.
+ Sie fragt den Server, statt selbst zu rechnen: Eine Regel, die im
+ Browser noch einmal steht, ist die, die beim nächsten Umbau
+ auseinanderläuft -- und die Fassung im Browser kann man ändern. */
+treffRouter.get("/workspace/api/treff/lage", (req, res) => {
+ try {
+ const p = req.person;
+ const stufe = stufeVon(p);
+ const sperre = TREFF_AUSSEN.has(p.rolle) ? treffSperre(p.id) : null;
+ /* ZWEI ANTWORTEN JE BRETT: ob es geht, und warum nicht.
+
+ Der Grund ist kein Schmuck. Ein Brett ohne Schreibknopf und ohne
+ Erklaerung wirkt kaputt -- man drueckt herum, sucht die Stelle,
+ gibt auf. Mit dem Satz daneben wirkt es gedacht: "Ab Dabei kannst
+ du hier schreiben, das ist nach sieben Tagen so weit."
+
+ UND ER KOMMT VON DER STELLE, DIE AUCH ENTSCHEIDET. darfSchreiben()
+ gibt den Text zurueck, den es ohnehin erzeugt -- derselbe, den der
+ Server bei einem Versuch als Absage schickt. Ein zweiter Satz in
+ der Oberflaeche waere der, der irgendwann etwas anderes behauptet
+ als die Regel tut. */
+ const schreiben = {};
+ const warum = {};
+ for (const b of TREFF_BRETTER) {
+ const grund = darfSchreiben(p, b);
+ schreiben[b] = grund === null;
+ if (grund) warum[b] = grund;
+ }
+ res.json({
+ rolle: p.rolle,
+ rolle_name: ROLLEN_NAME[p.rolle] ?? p.rolle,
+ team: TREFF_TEAM.has(p.rolle),
+ stufe,
+ stufe_name: stufe ? STUFEN_NAME[stufe] : null,
+ naechste_stufe: stufe === "neu" ? { name: STUFEN_NAME.dabei, tage: DABEI_TAGE }
+ : stufe === "dabei" ? { name: STUFEN_NAME.stamm, tage: STAMM_TAGE,
+ beitraege: STAMM_BEITRAEGE } : null,
+ sperre: sperre ? { art: sperre.art, bis: sperre.bis, grund: sperre.grund } : null,
+ schreiben,
+ warum,
+ anschlag_max: ANSCHLAG_MAX,
+ });
+ } catch (f) {
+ console.error("[treff] Lage:", f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/* ---------- Die Regeln ------------------------------------------------ */
+
+/* DAS MINDESTALTER (Entscheidung Filipe, 11.09.2026: 18 Jahre).
+
+ In Luxemburg liegt die Grenze der DSGVO Art. 8 bei 16 — es waere also
+ erlaubt, ab 16 hereinzulassen. Der Treff gehoert aber zu einem Stream
+ fuer Erwachsene, und dort ist die Frage nicht, was erlaubt ist,
+ sondern wen man hereinlaesst. */
+export const MINDESTALTER = 18;
+
+/* WARUM DER REGELTEXT SEINE ZAHLEN VOM SERVER HOLT.
+
+ Im Text steht "nach sieben Tagen kannst du schreiben". Stuende die
+ Sieben als Wort in der HTML-Datei, waere sie eine zweite Wahrheit
+ neben DABEI_TAGE — und beim naechsten Nachjustieren haette man eine
+ Regel, die anders arbeitet als sie sich beschreibt. Das ist die
+ schlimmere Sorte Fehler: Niemand merkt es, und wer es merkt, glaubt
+ dem Text.
+
+ Ein Regeltext, der sich selbst widerspricht, ist ausserdem rechtlich
+ das Gegenteil dessen, wofuer er da ist. */
+treffRouter.get("/workspace/api/treff/regeln", (req, res) => {
+ res.json({
+ mindestalter: MINDESTALTER,
+ dabei_tage: DABEI_TAGE,
+ stamm_tage: STAMM_TAGE,
+ stamm_beitraege: STAMM_BEITRAEGE,
+ anschlag_max: ANSCHLAG_MAX,
+ grund_min: GRUND_MIN,
+ modi_frist_tage: MODI_FRIST_TAGE,
+ stufen: STUFEN.map((s) => ({ schluessel: s, name: STUFEN_NAME[s] })),
+ /* Welche Bretter erst nach Freigabe erscheinen — auch das gehoert
+ in den Regeltext, und auch das soll nicht abschreibbar sein. */
+ freigabe_bretter: [...TREFF_FREIGABE_BRETTER],
+ });
+});
+
+/* ---------- „Will ich auch" ------------------------------------------- */
+
+/* EIN KLICK, KEINE ZAHL IM KOPF. Die Route ist absichtlich nicht
+ „zähle hoch", sondern „meine Stimme liegt hier": Zweimal dasselbe zu
+ schicken ändert nichts (INSERT OR IGNORE), und die Antwort ist in
+ beiden Fällen dieselbe. Eine Route, die beim zweiten Aufruf einen
+ Fehler bringt, zwingt die Oberfläche dazu, den eigenen Zustand zu
+ kennen -- und dort ist er am schnellsten falsch. */
+treffRouter.put("/workspace/api/treff/eintrag/:id/willich", gleicheHerkunft, (req, res) => {
+ try {
+ const id = nummer(req.params.id);
+ const e = id && treffEintrag(id);
+ if (!e) return res.status(404).json({ fehler: "nicht_gefunden" });
+ /* Gestimmt wird auf der Wunschliste. Anderswo wäre es eine zweite
+ Bewertung neben der, die es schon gibt. */
+ if (e.bereich !== "wunsch") return res.status(400).json({ fehler: "hier_nicht" });
+ if (TREFF_AUSSEN.has(req.person.rolle) && treffSperre(req.person.id)) {
+ return res.status(403).json({ fehler: "gesperrt" });
+ }
+ db().prepare(
+ "INSERT OR IGNORE INTO treff_stimmen (eintrag_id, person_id, zeitpunkt) VALUES (?,?,?)")
+ .run(id, req.person.id, jetzt());
+ res.json({ ok: true, ...stimmen(id, req.person.id) });
+ } catch (f) {
+ console.error("[treff] Stimme:", f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+treffRouter.delete("/workspace/api/treff/eintrag/:id/willich", gleicheHerkunft, (req, res) => {
+ try {
+ const id = nummer(req.params.id);
+ const e = id && treffEintrag(id);
+ if (!e) return res.status(404).json({ fehler: "nicht_gefunden" });
+ db().prepare("DELETE FROM treff_stimmen WHERE eintrag_id = ? AND person_id = ?")
+ .run(id, req.person.id);
+ res.json({ ok: true, ...stimmen(id, req.person.id) });
+ } catch (f) {
+ console.error("[treff] Stimme zurück:", f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+function stimmen(id, personId) {
+ const { n } = db().prepare("SELECT COUNT(*) AS n FROM treff_stimmen WHERE eintrag_id = ?")
+ .get(id);
+ const meine = db().prepare(
+ "SELECT 1 AS x FROM treff_stimmen WHERE eintrag_id = ? AND person_id = ?")
+ .get(id, personId);
+ return { stimmen: n, meine: !!meine };
+}
+
+/* ---------- Anheften (Anschlagbrett) ---------------------------------- */
+
+treffRouter.put("/workspace/api/treff/eintrag/:id/anheften", gleicheHerkunft, nurTeam,
+ (req, res) => {
+ try {
+ const id = nummer(req.params.id);
+ const e = id && treffEintrag(id);
+ if (!e) return res.status(404).json({ fehler: "nicht_gefunden" });
+ if (e.bereich !== "anschlag") return res.status(400).json({ fehler: "hier_nicht" });
+
+ /* DIE ZÄHLUNG STEHT IN DERSELBEN TRANSAKTION WIE DAS EINFÜGEN.
+ Zwei Modis, die gleichzeitig anheften, würden sonst beide „zwei
+ von drei" zählen und beide einfügen -- und am Ende hingen vier.
+ Genau dieser Fehler ist in diesem Haus schon bei der
+ CHECK-Umstellung vermieden worden, aus demselben Grund. */
+ db().exec("BEGIN IMMEDIATE");
+ try {
+ const schon = db().prepare(
+ "SELECT 1 AS x FROM treff_angeheftet WHERE eintrag_id = ?").get(id);
+ if (!schon) {
+ const { n } = db().prepare("SELECT COUNT(*) AS n FROM treff_angeheftet").get();
+ if (n >= ANSCHLAG_MAX) {
+ db().exec("ROLLBACK");
+ return res.status(409).json({
+ fehler: `Es hängen schon ${ANSCHLAG_MAX} Ansagen. Nimm zuerst eine ab.`,
+ });
+ }
+ db().prepare(
+ "INSERT INTO treff_angeheftet (eintrag_id, seit, von) VALUES (?,?,?)")
+ .run(id, jetzt(), req.person.id);
+ }
+ db().exec("COMMIT");
+ } catch (f) {
+ try { db().exec("ROLLBACK"); } catch { /* schon zurück */ }
+ throw f;
+ }
+ protokolliere("treff_angeheftet", { personId: req.person.id, ip: echteIp(req),
+ detail: `Eintrag ${id}` });
+ res.json({ ok: true });
+ } catch (f) {
+ console.error("[treff] Anheften:", f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+treffRouter.delete("/workspace/api/treff/eintrag/:id/anheften", gleicheHerkunft, nurTeam,
+ (req, res) => {
+ try {
+ const id = nummer(req.params.id);
+ if (!id) return res.status(404).json({ fehler: "nicht_gefunden" });
+ db().prepare("DELETE FROM treff_angeheftet WHERE eintrag_id = ?").run(id);
+ res.json({ ok: true });
+ } catch (f) {
+ console.error("[treff] Abnehmen:", f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+/* ---------- Freigeben ------------------------------------------------- */
+
+/* „Im Treff zeigen" bei Terminen, „Freigeben" bei Highlights -- derselbe
+ Vorgang, deshalb dieselbe Route. Zwei Routen wären zwei Stellen, an
+ denen dieselbe Rechteprüfung steht. */
+treffRouter.put("/workspace/api/treff/eintrag/:id/freigeben", gleicheHerkunft, nurTeam,
+ (req, res) => {
+ try {
+ const id = nummer(req.params.id);
+ const e = id && treffEintrag(id);
+ if (!e) return res.status(404).json({ fehler: "nicht_gefunden" });
+ if (!TREFF_FREIGABE_BRETTER.includes(e.bereich)) {
+ return res.status(400).json({ fehler: "hier_nicht" });
+ }
+ db().prepare(
+ "INSERT OR IGNORE INTO treff_freigaben (eintrag_id, zeitpunkt, von) VALUES (?,?,?)")
+ .run(id, jetzt(), req.person.id);
+ protokolliere("treff_freigegeben", { personId: req.person.id, ip: echteIp(req),
+ detail: `${e.bereich} ${id}` });
+ res.json({ ok: true, freigegeben: true });
+ } catch (f) {
+ console.error("[treff] Freigeben:", f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+treffRouter.delete("/workspace/api/treff/eintrag/:id/freigeben", gleicheHerkunft, nurTeam,
+ (req, res) => {
+ try {
+ const id = nummer(req.params.id);
+ if (!id) return res.status(404).json({ fehler: "nicht_gefunden" });
+ db().prepare("DELETE FROM treff_freigaben WHERE eintrag_id = ?").run(id);
+ res.json({ ok: true, freigegeben: false });
+ } catch (f) {
+ console.error("[treff] Freigabe zurück:", f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+/* ---------- Melden (DSA Art. 16) -------------------------------------- */
+
+export const GRUND_MIN = 10;
+export const GRUND_MAX = 1000;
+
+treffRouter.post("/workspace/api/treff/eintrag/:id/melden", gleicheHerkunft, (req, res) => {
+ try {
+ const id = nummer(req.params.id);
+ const e = id && treffEintrag(id);
+ if (!e) return res.status(404).json({ fehler: "nicht_gefunden" });
+
+ /* DER GRUND IST PFLICHT, und die Mindestlänge ist keine Schikane:
+ Art. 16 Abs. 2 verlangt eine Meldung, die „hinreichend genau und
+ angemessen begründet" ist -- also eine, nach der man entscheiden
+ kann, ohne zurückzufragen. „Scheiße" ist keine. */
+ const grund = text(req.body?.grund, GRUND_MAX);
+ if (grund.length < GRUND_MIN) {
+ return res.status(400).json({
+ fehler: "Bitte schreib kurz, was daran nicht in Ordnung ist – "
+ + "sonst können wir es nicht prüfen.",
+ });
+ }
+ db().prepare(`INSERT INTO treff_meldungen (eintrag_id, melder_id, grund, zeitpunkt)
+ VALUES (?,?,?,?)`).run(id, req.person.id, grund, jetzt());
+ protokolliere("treff_meldung", { personId: req.person.id, ip: echteIp(req),
+ detail: `Eintrag ${id}` });
+ /* KEINE AUSKUNFT DARÜBER, OB SCHON JEMAND GEMELDET HAT. Sonst wäre
+ die Antwort ein Zähler, an dem man ablesen kann, wen sonst noch
+ etwas stört -- und daraus wird eine Abstimmung statt einer
+ Prüfung. */
+ res.json({ ok: true });
+ } catch (f) {
+ console.error("[treff] Melden:", f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+treffRouter.get("/workspace/api/treff/meldungen", nurTeam, (req, res) => {
+ try {
+ const zeilen = db().prepare(`
+ SELECT m.id, m.eintrag_id, m.grund, m.zeitpunkt, m.status, m.entscheid,
+ m.entschieden_am,
+ e.bereich, e.titel, e.text,
+ pm.name AS melder_name, pm.rolle AS melder_rolle,
+ pe.name AS autor_name, pe.rolle AS autor_rolle,
+ pd.name AS entschieden_name
+ FROM treff_meldungen m
+ LEFT JOIN eintraege e ON e.id = m.eintrag_id
+ LEFT JOIN personen pm ON pm.id = m.melder_id
+ LEFT JOIN personen pe ON pe.id = e.erstellt_von
+ LEFT JOIN personen pd ON pd.id = m.entschieden_von
+ ORDER BY CASE m.status WHEN 'offen' THEN 0 ELSE 1 END, m.id DESC
+ LIMIT 300`).all();
+ res.json({ meldungen: zeilen.map(mitRollenname) });
+ } catch (f) {
+ console.error("[treff] Meldungen:", f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+treffRouter.patch("/workspace/api/treff/meldung/:id", gleicheHerkunft, nurTeam, (req, res) => {
+ try {
+ const id = nummer(req.params.id);
+ const status = String(req.body?.status || "");
+ if (!id || !["erledigt", "abgelehnt"].includes(status)) {
+ return res.status(400).json({ fehler: "unbrauchbar" });
+ }
+ const entscheid = text(req.body?.entscheid, GRUND_MAX);
+ if (entscheid.length < GRUND_MIN) {
+ return res.status(400).json({
+ fehler: "Bitte halte kurz fest, was du entschieden hast – "
+ + "das gehört zur Meldung dazu.",
+ });
+ }
+ const r = db().prepare(`UPDATE treff_meldungen
+ SET status = ?, entscheid = ?, entschieden_von = ?, entschieden_am = ?
+ WHERE id = ? AND status = 'offen'`)
+ .run(status, entscheid, req.person.id, jetzt(), id);
+ if (!r.changes) return res.status(409).json({ fehler: "schon_entschieden" });
+ protokolliere("treff_meldung_entschieden", { personId: req.person.id, ip: echteIp(req),
+ detail: `${id}: ${status}` });
+ res.json({ ok: true });
+ } catch (f) {
+ console.error("[treff] Meldung entscheiden:", f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/* ---------- Entfernen mit Grund (DSA Art. 17) ------------------------- */
+
+/**
+ * Hält fest, warum ein Beitrag entfernt wurde — BEVOR er verschwindet.
+ *
+ * Wird aus workspace-bereiche.js gerufen, nicht als eigene Route: Es
+ * gibt genau einen Weg, einen Eintrag zu löschen, und das soll so
+ * bleiben. Eine zweite Löschroute wäre die, an der die Begründung
+ * eines Tages fehlt.
+ *
+ * @returns {string|null} Fehlertext, oder null wenn gelöscht werden darf
+ */
+export function entfernenVorbereiten(person, eintrag, grund) {
+ if (!TREFF_BRETTER.includes(eintrag?.bereich)) return null; // kein Treff-Brett
+ /* Wer seinen EIGENEN Beitrag zurücknimmt, begründet nichts. Art. 17
+ schützt den Verfasser vor der Plattform, nicht vor sich selbst. */
+ if (eintrag.erstellt_von === person?.id) return null;
+
+ const g = text(grund, GRUND_MAX);
+ if (g.length < GRUND_MIN) {
+ return "Zum Entfernen gehört ein Grund – die Person erfährt ihn, "
+ + "und wir müssen ihn festhalten können.";
+ }
+ const autor = eintrag.erstellt_von
+ ? db().prepare("SELECT name FROM personen WHERE id = ?").get(eintrag.erstellt_von)
+ : null;
+ db().prepare(`INSERT INTO treff_entfernt
+ (eintrag_id, bereich, titel, autor_id, autor_name, grund, von, zeitpunkt)
+ VALUES (?,?,?,?,?,?,?,?)`)
+ .run(eintrag.id, eintrag.bereich, String(eintrag.titel ?? ""),
+ eintrag.erstellt_von ?? null, autor?.name ?? null, g, person?.id ?? null, jetzt());
+ return null;
+}
+
+treffRouter.get("/workspace/api/treff/entfernt", nurTeam, (req, res) => {
+ try {
+ const zeilen = db().prepare(`
+ SELECT t.id, t.eintrag_id, t.bereich, t.titel, t.autor_name, t.grund, t.zeitpunkt,
+ p.name AS von_name, p.rolle AS von_rolle
+ FROM treff_entfernt t LEFT JOIN personen p ON p.id = t.von
+ ORDER BY t.id DESC LIMIT 300`).all();
+ res.json({ entfernt: zeilen.map(mitRollenname) });
+ } catch (f) {
+ console.error("[treff] Entfernt:", f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/* ---------- Maßnahmen ------------------------------------------------- */
+
+treffRouter.post("/workspace/api/treff/massnahme", gleicheHerkunft, nurTeam, (req, res) => {
+ try {
+ const personId = nummer(req.body?.person_id);
+ const art = String(req.body?.art || "");
+ const grund = text(req.body?.grund, GRUND_MAX);
+ const tage = Number(req.body?.tage);
+ const dauerhaft = art === "ausschluss" && (req.body?.tage === null
+ || req.body?.tage === undefined || req.body?.tage === "");
+
+ if (!personId || !["hinweis", "pause", "ausschluss"].includes(art)) {
+ return res.status(400).json({ fehler: "unbrauchbar" });
+ }
+ if (grund.length < GRUND_MIN) {
+ return res.status(400).json({
+ fehler: "Ohne Grund geht das nicht – die Person hat ein Recht darauf zu erfahren, warum.",
+ });
+ }
+ const ziel = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(personId);
+ /* MASSNAHMEN GELTEN NUR GEGEN DIE COMMUNITY. Ein Modi, der einen
+ anderen Modi „ausschließen" könnte, hätte einen Weg, jemanden aus
+ dem Team zu entfernen -- und zwar an „Personen & Zugänge" vorbei,
+ wo diese Entscheidung hingehört. */
+ if (!ziel || !TREFF_AUSSEN.has(ziel.rolle)) {
+ return res.status(404).json({ fehler: "nicht_gefunden" });
+ }
+ if (!darfMassnahme(req.person, art, dauerhaft)) {
+ return res.status(403).json({
+ fehler: "Dauerhaft ausschließen kann nur DogFather.",
+ });
+ }
+ let bis = null;
+ if (!dauerhaft && art !== "hinweis") {
+ if (!Number.isFinite(tage) || tage <= 0 || tage > 3650) {
+ return res.status(400).json({ fehler: "Bitte gib an, für wie viele Tage." });
+ }
+ if (!fristErlaubt(req.person, tage)) {
+ return res.status(403).json({
+ fehler: `Als Modi kannst du bis zu ${MODI_FRIST_TAGE} Tage geben. `
+ + "Länger entscheidet DogFather.",
+ });
+ }
+ bis = new Date(Date.now() + tage * TAG).toISOString();
+ }
+ db().prepare(`INSERT INTO treff_massnahmen (person_id, art, grund, bis, von, zeitpunkt)
+ VALUES (?,?,?,?,?,?)`)
+ .run(personId, art, grund, bis, req.person.id, jetzt());
+ protokolliere("treff_massnahme", { personId: req.person.id, ip: echteIp(req),
+ detail: `${art} gegen ${ziel.name}${bis ? ` bis ${bis.slice(0, 10)}` : " (dauerhaft)"}` });
+ res.json({ ok: true, art, bis });
+ } catch (f) {
+ console.error("[treff] Maßnahme:", f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+treffRouter.delete("/workspace/api/treff/massnahme/:id", gleicheHerkunft, nurTeam, (req, res) => {
+ try {
+ const id = nummer(req.params.id);
+ if (!id) return res.status(404).json({ fehler: "nicht_gefunden" });
+ const m = db().prepare("SELECT id, art, bis FROM treff_massnahmen WHERE id = ?").get(id);
+ if (!m) return res.status(404).json({ fehler: "nicht_gefunden" });
+ /* Wer eine dauerhafte Maßnahme nicht verhängen darf, darf sie auch
+ nicht aufheben. Sonst wäre die Regel darüber eine Formalie: Ein
+ Modi könnte einen dauerhaften Ausschluss abnehmen und einen
+ siebentägigen setzen. */
+ if (!darfMassnahme(req.person, m.art, !m.bis)) {
+ return res.status(403).json({ fehler: "Das kann nur DogFather aufheben." });
+ }
+ db().prepare(
+ "UPDATE treff_massnahmen SET aufgehoben_am = ?, aufgehoben_von = ? WHERE id = ?")
+ .run(jetzt(), req.person.id, id);
+ protokolliere("treff_massnahme_auf", { personId: req.person.id, ip: echteIp(req),
+ detail: String(id) });
+ res.json({ ok: true });
+ } catch (f) {
+ console.error("[treff] Maßnahme aufheben:", f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+treffRouter.get("/workspace/api/treff/massnahmen", nurTeam, (req, res) => {
+ try {
+ const zeilen = db().prepare(`
+ SELECT m.id, m.person_id, m.art, m.grund, m.bis, m.zeitpunkt, m.aufgehoben_am,
+ p.name AS person_name, v.name AS von_name, v.rolle AS von_rolle
+ FROM treff_massnahmen m
+ LEFT JOIN personen p ON p.id = m.person_id
+ LEFT JOIN personen v ON v.id = m.von
+ ORDER BY m.id DESC LIMIT 300`).all();
+ res.json({ massnahmen: zeilen.map(mitRollenname) });
+ } catch (f) {
+ console.error("[treff] Maßnahmen:", f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/* ---------- Die Mitglieder -------------------------------------------- */
+
+/* WARUM DER TREFF SEINE EIGENE MITGLIEDERLISTE HAT UND NICHT
+ /api/personen BENUTZT (11.09.2026).
+
+ Der naheliegende Weg wäre gewesen, die Moderation auf „Personen &
+ Zugänge" zu schicken. Drei Gründe dagegen, und der dritte ist der
+ eigentliche:
+
+ 1. Dort stehen alle — Creator, Scouts, die Agentur. Ein Modi, der
+ jemanden sperren will, müsste sich durch Menschen klicken, die
+ ihn nichts angehen.
+ 2. Dort gibt es Knöpfe, die ein Modi nicht drücken darf. Ein
+ Bildschirm voller Absagen ist schlechter als einer ohne Knöpfe.
+ 3. Was hier gebraucht wird, steht dort gar nicht: die STUFE und die
+ laufende Maßnahme. Beides gibt es nur im Treff.
+
+ NUR AUSSENROLLEN. Die Liste kann das Team nicht enthalten -- nicht
+ weil es ausgeblendet wäre, sondern weil die Abfrage es gar nicht
+ erst holt. Eine Maßnahme gegen jemanden aus dem Team gibt es nicht
+ (siehe /massnahme), und eine Liste, aus der man etwas auswählen
+ kann, das danach abgelehnt wird, ist eine Falle. */
+treffRouter.get("/workspace/api/treff/mitglieder", nurTeam, (req, res) => {
+ try {
+ const zeilen = db().prepare(`
+ SELECT id, name, erstellt, letzter_login, aktiv
+ FROM personen WHERE rolle = 'gast'
+ ORDER BY name COLLATE NOCASE`).all();
+ const platz = TREFF_BRETTER.map(() => "?").join(", ");
+ const beitraege = db().prepare(
+ `SELECT erstellt_von AS id, COUNT(*) AS n FROM eintraege
+ WHERE bereich IN (${platz}) GROUP BY erstellt_von`).all(...TREFF_BRETTER);
+ const zahl = new Map(beitraege.map((b) => [b.id, b.n]));
+
+ res.json({
+ mitglieder: zeilen.map((z) => {
+ const sperre = treffSperre(z.id);
+ return {
+ id: z.id, name: z.name, seit: z.erstellt, zuletzt: z.letzter_login,
+ beitraege: zahl.get(z.id) || 0,
+ stufe: stufeVon({ id: z.id, rolle: "gast" }),
+ sperre: sperre
+ ? { id: sperre.id, art: sperre.art, bis: sperre.bis, grund: sperre.grund }
+ : null,
+ };
+ }),
+ /* Was DIESE Person verhängen darf. Die Oberfläche baut daraus
+ ihre Knöpfe -- und nicht aus einer eigenen Rollenabfrage, die
+ auseinanderlaufen könnte. */
+ darf: {
+ pause: darfMassnahme(req.person, "pause", false),
+ ausschluss_befristet: darfMassnahme(req.person, "ausschluss", false),
+ ausschluss_dauerhaft: darfMassnahme(req.person, "ausschluss", true),
+ frist_tage: req.person.rolle === "modi" ? MODI_FRIST_TAGE : null,
+ },
+ });
+ } catch (f) {
+ console.error("[treff] Mitglieder:", f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+});
+
+/* ---------- Die Brücke zu den Talenten -------------------------------- */
+
+/* Wer auf „Mitmachen" schreibt „Ich hätte Lust", ist ein Kandidat. Diese
+ Route legt ihn in der Kategorie an, die es dafür schon gibt -- statt
+ einer zweiten Liste daneben.
+
+ EINMAL IST EINMAL: Ein zweites Übernehmen legt keinen zweiten
+ Kandidaten an. Geprüft wird an der Quelle (gibt es schon einen
+ Talent-Eintrag zu diesem Menschen?), nicht an einer Merkspalte. */
+treffRouter.post("/workspace/api/treff/mitmachen/:id/uebernehmen", gleicheHerkunft, nurTeam,
+ (req, res) => {
+ try {
+ const id = nummer(req.params.id);
+ const e = id && db().prepare(
+ "SELECT id, bereich, art, titel, text, erstellt_von FROM eintraege WHERE id = ?").get(id);
+ if (!e || e.bereich !== "mitmachen") return res.status(404).json({ fehler: "nicht_gefunden" });
+ if (!e.erstellt_von) {
+ return res.status(400).json({ fehler: "Zu diesem Eintrag gibt es keine Person." });
+ }
+ const schon = db().prepare(
+ "SELECT id FROM eintraege WHERE bereich = 'talente' AND creator_id = ?")
+ .get(e.erstellt_von);
+ if (schon) return res.status(409).json({ fehler: "schon_uebernommen", talent_id: schon.id });
+
+ const wer = db().prepare("SELECT name FROM personen WHERE id = ?").get(e.erstellt_von);
+ const nun = jetzt();
+ const r = db().prepare(`INSERT INTO eintraege
+ (bereich, art, titel, text, datum, dringlichkeit, status, creator_id,
+ erstellt, erstellt_von, einsatz)
+ VALUES ('talente','empfohlen',?,?,?,'mittel','offen',?,?,?,?)`)
+ .run(
+ `${wer?.name ?? "Jemand"} hat sich im Treff gemeldet`,
+ `Aus „Mitmachen": ${String(e.titel ?? "")}\n\n${String(e.text ?? "")}`.trim(),
+ nun.slice(0, 10), e.erstellt_von, nun, req.person.id, "Der Treff");
+ protokolliere("treff_talent", { personId: req.person.id, ip: echteIp(req),
+ detail: `aus Eintrag ${id}` });
+ res.json({ ok: true, talent_id: Number(r.lastInsertRowid) });
+ } catch (f) {
+ console.error("[treff] Übernehmen:", f?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
+/* ---------------------------------------------------------------------
+ DER VOLLE ROLLENNAME AN JEDEM BEITRAG
+
+ Filipes Entscheidung vom 11.09.2026: „Voller Rollenname zeigen" --
+ an einem Beitrag im Treff steht „Modi", „Rechte Hand", „DogFather".
+
+ WARUM DER SERVER DEN NAMEN LIEFERT UND NICHT DER BROWSER: Eine
+ Zuordnung Rolle → Name im ausgelieferten JavaScript wäre eine Liste
+ aller Rollen dieses Hauses -- auf jedem Rechner der Community,
+ dauerhaft lesbar. Hier kommt nur der Name der Rollen an, die an
+ diesem Beitrag wirklich beteiligt sind.
+ ------------------------------------------------------------------- */
+function mitRollenname(zeile) {
+ const aus = { ...zeile };
+ for (const [k, v] of Object.entries(zeile)) {
+ if (k.endsWith("_rolle") && v) aus[`${k}_name`] = ROLLEN_NAME[v] ?? v;
+ }
+ return aus;
+}
+export { mitRollenname };
diff --git a/server/workspace.js b/server/workspace.js
index 2e555a00..1bda7905 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -27,8 +27,12 @@ import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { mkdirSync } from "node:fs";
import { createRequire } from "node:module";
-import { darfSeite } from "./rechte.js";
-import { sitzungPasstZurAdresse, istOhneModiAdresse, istCrewAdresse,
+import { darfSeite, OHNE_ANMELDUNG, SEITEN, ALLE_ROLLEN } from "./rechte.js";
+/* Ein Modul ohne eigene Importe -- deshalb entsteht hier kein Kreis,
+ obwohl workspace-treff.js seinerseits aus dieser Datei importiert.
+ Begruendung im Kopf von treff-tabellen.js. */
+import { treffTabellen, treffSperreLesen } from "./treff-tabellen.js";
+import { sitzungPasstZurAdresse, istOhneModiAdresse, istCrewAdresse, istTreffAdresse, istPruefAdresse, AUSSEN_ROLLEN,
TEAM_DOGI_ROLLEN } from "./crew-adresse.js";
/* Weitergereicht, damit die Fachmodule sie wie alles andere aus
workspace.js beziehen und nicht wissen muessen, wo sie wohnt. */
@@ -63,7 +67,7 @@ const COOKIE = "dfw_sitzung";
const SITZUNG_STUNDEN = 12;
const VERSUCHE_MAX = 8; // pro IP
const VERSUCHE_FENSTER_MIN = 10;
-const ROLLEN = new Set(["spicy", "admin", "manager", "scout", "creator", "hand", "modi"]);
+const ROLLEN = new Set(["spicy", "admin", "manager", "scout", "creator", "hand", "modi", "gast"]);
/* Die Reihenfolge, in der Rollen ueberall erscheinen: Spicy Media
zuerst, dann DogFather, Manager, Scout, Creator. Steht hier einmal,
@@ -99,6 +103,19 @@ const ROLLEN_KACHEL = new Set(["spicy", "admin", "manager", "scout", "creator"])
* sondern ihr Sinn. */
const CREW_KACHEL = new Set(["admin", "hand", "modi"]);
+/** Die Rollen mit einer Kachel auf der Zugangswand von treff.
+ *
+ * EINE, und deshalb zeigt die Wand dort gar keine Kachelreihe: Eine
+ * Auswahl mit einem einzigen Eintrag ist keine Auswahl, sondern eine
+ * Huerde. Dieselbe Ueberlegung wie bei der ersten Fassung der
+ * Crew-Wand -- nur ist sie hier dauerhaft richtig, weil es im Treff
+ * auch kuenftig nur eine Rolle gibt.
+ *
+ * Die Menge steht trotzdem hier: Die Anmeldung prueft gegen sie, und
+ * eine Wand ohne Kacheln darf nicht heissen, dass jede Rolle
+ * durchkommt. */
+const TREFF_KACHEL = new Set(["gast"]);
+
/* Als SQL-Ausdruck fuer ORDER BY. "ORDER BY rolle" waere alphabetisch
(admin, creator, manager, scout, spicy) -- also fast genau falsch
herum. */
@@ -383,6 +400,15 @@ export const ROLLEN_NAME = {
creator: "Creator",
hand: "Rechte Hand",
modi: "Modi",
+ /* DIE COMMUNITY (11.09.2026). Der Name steht hier und NICHT in einer
+ Datei, die jeder herunterlaedt -- ausgeliefert wird er nur an den,
+ der ihn selbst traegt, und an die, die ihn sehen duerfen.
+
+ Filipe hat ausdruecklich gewaehlt, dass im Treff der volle
+ Rollenname neben einem Beitrag steht ("Modi", "Rechte Hand",
+ "DogFather"). Das bleibt damit vereinbar: Der Name kommt als DATEN
+ mit der Antwort, nicht als Konstante im Browser. */
+ gast: "Community",
};
/* DIE UEBERSCHRIFT UEBER EINER PERSONENLISTE (10.09.2026).
@@ -632,9 +658,110 @@ const PERSONEN_KACHEL = {
const HAND_BEREICHE = [...MODI_BEREICHE, EINGANG_KACHEL, PERSONEN_KACHEL,
ENTWICKLUNG_KACHEL, TALENTE_KACHEL];
+
+/* =====================================================================
+ DIE SIEBEN BRETTER DES TREFFS (11.09.2026)
+
+ Filipe: "wieso nur eine kachel fuer die community ich will dass die
+ mehrere kacheln haben. mit mehreren optionen."
+
+ Eine Kachel war zu kurz gedacht: Ein Raum, in dem Ansagen, Fragen,
+ Wuensche und Clips durcheinandergehen, ist nach zwei Wochen
+ unbenutzbar -- die Ansage von gestern ist weg, die Frage von heute
+ geht unter.
+
+ SORTIERT NACH ZWECK, NICHT NACH THEMA, und in der Reihenfolge, in der
+ ein Neuer liest: erst was gilt, dann was laeuft, dann das Gespraech,
+ dann das Besondere.
+
+ Jede der sieben bedient eines der vier Dinge, die darueber
+ entscheiden, ob sich jemand zugehoerig fuehlt (Zugehoerigkeit,
+ Einfluss, Nutzen, geteilte Erlebnisse). Was keines bedient, ist nicht
+ dabei -- deshalb gibt es hier keine Bestenliste und keine Punkte.
+
+ VON OBEN NACH UNTEN GELESEN SIND SIE EIN WEG: lesen -> mitreden ->
+ mitgestalten -> dazugehoeren. Die letzte Kachel uebergibt an die
+ Talente-Kategorie, die es schon gibt.
+
+ DIE KACHELN STEHEN HIER UND NICHT IN assets/js/bereiche.js -- aus
+ demselben Grund wie bei den Modis: Diese Datei wird nicht
+ ausgeliefert, jene bekommt jeder Besucher. */
+const TREFF_GRUPPE = {
+ gruppe: "Community",
+ gruppeUnter: "Der Treff \u2013 der Raum f\u00fcr die Zuschauer",
+};
+
+const TREFF_BEREICHE = [
+ { ...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",
+ zeichen: "kalender", ton: 31, ziel: "bereich.html?b=ansteht", szene: "skyline" },
+ { ...TREFF_GRUPPE, name: "Der Treff", unter: "Hallo sagen, fragen, loben",
+ zeichen: "community", ton: 34, gross: true, ziel: "bereich.html?b=treff", szene: "lounge" },
+ { ...TREFF_GRUPPE, name: "Wunschliste", unter: "Was ihr euch w\u00fcnscht \u2013 und wer mitwill",
+ zeichen: "content", ton: 30, ziel: "bereich.html?b=wunsch", szene: "portal" },
+ { ...TREFF_GRUPPE, name: "Highlights", unter: "Eure Clips und Bilder",
+ zeichen: "live", ton: 29, ziel: "bereich.html?b=highlight", szene: "arena" },
+ { ...TREFF_GRUPPE, name: "Regeln & Hilfe", unter: "Wie es hier l\u00e4uft",
+ zeichen: "schutz", ton: 28, ziel: "bereich.html?b=regeln", szene: "studio" },
+ { ...TREFF_GRUPPE, name: "Mitmachen", unter: "Wie du dazugeh\u00f6ren kannst",
+ zeichen: "personen", ton: 32, ziel: "bereich.html?b=mitmachen", szene: "garage" },
+];
+
+/* MELDUNGEN & MASSNAHMEN -- FUER DAS TEAM, NICHT FUER DIE COMMUNITY
+ (11.09.2026).
+
+ Diese Kachel steht ausdruecklich NICHT in TREFF_BEREICHE. Die Liste
+ dort ist das, was ein Mitglied sieht; sie wird dem Team nur
+ angehaengt. Eine achte Kachel darin waere auch bei der Community
+ gelandet -- und zwar lautlos, denn sie saehe dort aus wie die
+ anderen sieben. Erst beim Klicken haette der Server 404 gesagt, und
+ das sieht aus wie ein kaputtes Programm.
+
+ SIE GEHOERT AUCH NICHT IN TREFF_BRETTER: Sie ist kein Brett mit
+ Eintraegen, sondern eine Seite. Das Ziel ist deshalb ein
+ Dateiname und kein "bereich.html?b=...", und die Ableitung weiter
+ unten nimmt sie von selbst nicht mit. */
+const TREFF_MODERATION_KACHEL = {
+ ...TREFF_GRUPPE,
+ name: "Meldungen & Ma\u00dfnahmen", unter: "Was gemeldet wurde \u2013 und was daraus wurde",
+ zeichen: "schutz", ton: 36, ziel: "treff-moderation.html", szene: "studio",
+};
+
+/* Welche Bretter zum Treff gehoeren -- ABGELEITET aus den Kacheln, nicht
+ danebengeschrieben. Eine zweite Liste waere die, die beim naechsten
+ neuen Brett vergessen wird. */
+export const TREFF_BRETTER = TREFF_BEREICHE
+ .map((k) => new URL("http://x/" + k.ziel).searchParams.get("b"))
+ .filter(Boolean);
+
+/* Wer den Treff ueberhaupt sehen darf: die Community selbst und das
+ Team von Dogi. Die Agentur ausdruecklich nicht -- Creator, Scouts und
+ Spicy Media haben mit den Zuschauern dieses Streams nichts zu tun. */
+export const TREFF_ROLLEN = new Set(["gast", "modi", "hand", "admin"]);
+
+/* DAS TEAM SIEHT DEN TREFF MIT -- als eigene Gruppe unter seinen
+ uebrigen Kacheln.
+
+ Filipe: "die modis rechte hand und ich also dogfather sehen die aber
+ auch. die community aber nur die dann."
+
+ Zusammengesetzt und nicht in MODI_BEREICHE hineingeschrieben, weil
+ diese Liste weiter oben steht als die Kacheln des Treffs. Eine Kopie
+ dort waere die Stelle, an der beim naechsten neuen Brett eines fehlt. */
+const MODI_MIT_TREFF = [...MODI_BEREICHE, ...TREFF_BEREICHE, TREFF_MODERATION_KACHEL];
+const HAND_MIT_TREFF = [...HAND_BEREICHE, ...TREFF_BEREICHE, TREFF_MODERATION_KACHEL];
+
+/* AUSSEN_ROLLEN kommt aus crew-adresse.js und wird hier nur
+ weitergereicht -- die Adressregel braucht sie zuerst, und diese Datei
+ importiert von dort. Eine zweite Menge waere die, die auseinanderlaeuft. */
+export { AUSSEN_ROLLEN };
+
export function bereicheFuer(person) {
- if (person?.rolle === "modi") return MODI_BEREICHE;
- if (person?.rolle === "hand") return HAND_BEREICHE;
+ if (person?.rolle === "modi") return MODI_MIT_TREFF;
+ if (person?.rolle === "hand") return HAND_MIT_TREFF;
+ /* DIE COMMUNITY SIEHT DEN TREFF UND SONST NICHTS (11.09.2026). */
+ if (person?.rolle === "gast") return TREFF_BEREICHE;
/* AUF DER ADRESSE VON TEAM DOGI SIEHT AUCH DOGFATHER DIE KACHELN DES
TEAMS (10.09.2026).
@@ -653,10 +780,27 @@ export function bereicheFuer(person) {
Kacheln. Eine eigene Liste fuer ihn waere die, die beim naechsten
Umbau auseinanderlaeuft -- und sie stuende gegen die Hausregel,
ihn nicht ueber sein Team zu stellen. */
- if (person?.haus === "crew") return HAND_BEREICHE;
- return null;
+ if (person?.haus === "crew") return HAND_MIT_TREFF;
+ /* `null` HEISST "NIMM DIE LISTE AUS DEM BROWSER" -- und die enthaelt
+ fuenfundzwanzig Kacheln des Agenturhauses. Bis zum 11.09.2026 stand
+ hier nur `return null`, also bekam JEDE Rolle ohne eigenen Zweig
+ diese Liste angeboten. Gefiltert wurde sie danach zwar nach
+ `rollen:` an jeder Kachel, aber das ist eine zweite Schranke an
+ einer anderen Stelle -- und sie lebt in einer Datei, die jeder
+ herunterlaedt.
+
+ Jetzt bekommen nur die fuenf Rollen `null`, fuer die diese Liste
+ geschrieben wurde. Alles andere bekommt eine leere Liste: keine
+ Kachel, kein Weg, nichts zu filtern. */
+ if (AGENTUR_LISTE.has(person?.rolle)) return null;
+ return [];
}
+/* Die fuenf, deren Kacheln in assets/js/bereiche.js stehen. Nicht zu
+ verwechseln mit AGENTUR_ROLLEN (dort fehlt `admin`, weil es dabei um
+ die Sichtbarkeit von Zeilen geht und nicht um Kacheln). */
+const AGENTUR_LISTE = new Set(["spicy", "admin", "manager", "scout", "creator"]);
+
/* WELCHE BEREICHSSEITEN EIN MODI OEFFNEN DARF -- abgeleitet aus seinen
eigenen Kacheln und nicht danebengeschrieben.
@@ -670,7 +814,7 @@ export function bereicheFuer(person) {
-- ohne diese Einschraenkung kaeme er ueber die Adresszeile auch in
die Agentur-Ablage, die ihn nichts angeht. */
export const MODI_BEREICHE_ERLAUBT = new Set(
- HAND_BEREICHE
+ HAND_MIT_TREFF
.map((k) => (/bereich\.html\?b=([a-z]+)/.exec(k.ziel) || [])[1])
.filter(Boolean));
@@ -942,8 +1086,51 @@ const ZUSATZ_BEREICHE = {
/* Eine eigene Gruppe, direkt hinter Team Dogi: Was ueber Menschen
aufgeschrieben wird, steht nicht zwischen den taeglichen
Werkzeugen. */
+ /* WER SIEHT WAS (11.09.2026).
+
+ Filipe: "egal welche Rolle oder Person hinzugefuegt wird, soll
+ immer nur das sehen, was ich erlaube."
+
+ Dazu gehoert, dass er es nachsehen kann. Die Seite rechnet die
+ Uebersicht aus derselben Tafel, aus der die Schranke ihre
+ Entscheidung holt (rechte.js) -- eine von Hand gepflegte
+ Uebersicht waere eine zweite Wahrheit, und ausgerechnet die, der
+ man glaubt.
+
+ IN "Team & System" UND NICHT BEI DEN PERSONEN: Es geht nicht um
+ Menschen, sondern um das Haus. */
+ {
+ ...TEAM_GRUPPE,
+ name: "Wer sieht was", unter: "Jede Rolle, jede Seite – ausgerechnet, nicht notiert",
+ /* EIN EIGENER TON, UND ZWAR EIN NEUER (11.09.2026).
+
+ Erster Anlauf war Ton 26 -- das waere "Entwicklung" ein zweites
+ Mal gewesen. Zweiter Anlauf war Ton 22, weil er frei AUSSAH:
+ Ich hatte die Toene ueber bereicheFuer() gezaehlt, und das
+ liefert die Zusatzkacheln gar nicht mit. Beide Male haette
+ sich diese Kachel eine Farbe mit einer anderen geteilt, und
+ beide Male haette ich es fuer geprueft gehalten.
+
+ Gefunden hat es pruef-start-ansicht, die ueber die ECHTE
+ Ansicht zaehlt: "34 Farben auf 35 Kacheln". Der 35. Ton ist
+ jetzt gemessen (start.css, Abstand 31,4 in Lab zum naechsten
+ Nachbarn) -- und nicht mehr aus einer Luecke geraten. */
+ zeichen: "schutz", ton: 35, ziel: "rechte.html", szene: "studio",
+ },
{ ...ENTWICKLUNG_KACHEL, gruppeNach: TEAM_GRUPPE.gruppe },
{ ...TALENTE_KACHEL, gruppeNach: TEAM_GRUPPE.gruppe },
+ /* DER TREFF -- GANZ UNTEN, UND ZWAR ABSICHTLICH.
+
+ Filipe zu den beiden Gruppen darueber: "das sind private
+ informationen von meiner community." Dasselbe gilt hier, nur
+ noch deutlicher: Im Treff stehen Namen und Saetze von Leuten,
+ die nichts mit der Agentur zu tun haben.
+
+ Ohne `gruppeNach` haengt eine Gruppe hinten an -- genau richtig.
+ Wer sich den Bildschirm teilt oder jemandem ueber die Schulter
+ schauen laesst, hat sie dann nicht als Erstes im Blick. */
+ ...TREFF_BEREICHE,
+ TREFF_MODERATION_KACHEL,
],
};
@@ -2086,6 +2273,11 @@ function umstellungen(d) {
["spicy", "admin", "manager", "scout", "creator", "modi"], jetztStempel);
checkListeErweitern(d, "personen", "rolle", "hand",
["spicy", "admin", "manager", "scout", "creator", "modi", "hand"], jetztStempel);
+ /* DIE COMMUNITY (11.09.2026). Dieselbe Umstellung wie die drei
+ darueber: Sicherung vorher, Neubau, Zaehlung innerhalb der
+ Transaktion, Indizes zurueck. */
+ checkListeErweitern(d, "personen", "rolle", "gast",
+ ["spicy", "admin", "manager", "scout", "creator", "modi", "hand", "gast"], jetztStempel);
/* DER BEREICH FUER DAS IDEEN-BOARD (10.09.2026, Kapitel 5.6).
@@ -2118,6 +2310,18 @@ function umstellungen(d) {
checkListeErweitern(d, "eintraege", "bereich", "talente",
["live", "content", "technik", "community", "schutz", "agentur", "ideen",
"angebote", "rueckmeldung", "entwicklung", "talente"], jetztStempel);
+ /* DIE SIEBEN BRETTER DES TREFFS (11.09.2026).
+
+ EINE Umstellung fuer alle sieben, nicht sieben einzelne. Jeder
+ Aufruf baut die Tabelle neu auf -- siebenmal hintereinander waeren
+ sieben Sicherungen, sieben Neubauten und sieben Gelegenheiten, dass
+ etwas schiefgeht. Der Marker ist "treff"; ist der schon in der
+ CHECK-Liste, passiert gar nichts. */
+ checkListeErweitern(d, "eintraege", "bereich", "treff",
+ ["live", "content", "technik", "community", "schutz", "agentur", "ideen",
+ "angebote", "rueckmeldung", "entwicklung", "talente",
+ "anschlag", "ansteht", "treff", "wunsch", "highlight", "regeln", "mitmachen"],
+ jetztStempel);
checkListeErweitern(d, "eintraege", "status", "angenommen",
["offen", "erledigt", "angenommen", "abgelehnt"], jetztStempel);
@@ -2644,6 +2848,10 @@ export function db() {
geaendert TEXT
);
CREATE INDEX IF NOT EXISTS idx_eintraege_bereich ON eintraege (bereich, creator_id);
+ /* Die Tabellen des Treffs folgen weiter unten, gleich nachdem
+ dieser Bauplan durch ist -- sie haengen an eintraege und
+ personen und koennen deshalb nicht davor stehen. Ihr SQL steht
+ in treff-tabellen.js, samt Begruendung je Tabelle. */
/* Wer darf welche Datei sehen. Bisher hatte eine Datei genau
EINEN Bereich (dateien.creator_id) -- damit liess sich eine
@@ -2889,6 +3097,11 @@ export function db() {
CREATE INDEX IF NOT EXISTS idx_versuche_ip ON versuche (ip, zeitpunkt);
CREATE INDEX IF NOT EXISTS idx_sitzungen_gueltig ON sitzungen (gueltig_bis);
`);
+ /* Die Tabellen des Treffs. Eigene Datei, weil workspace-treff.js
+ aus DIESER Datei importiert -- ein Importkreis waere die Folge,
+ und ueber einen Kreis kommen Konstanten als undefined an, ohne
+ dass irgendwo ein Fehler erscheint. */
+ treffTabellen(d);
umstellungen(d);
_db = d;
return d;
@@ -3074,7 +3287,34 @@ export function sitzungLesen(req) {
nicht, was er darf -- genau wie die Sicht eines anderen
(sichtPerson) das auch nicht tut. Wer hier etwas anderes einbaut,
macht aus einer Anzeigefrage eine Rechtefrage. */
- const haus = istCrewAdresse(req.get?.("host")) ? "crew" : "agentur";
+ const haus = istCrewAdresse(req.get?.("host")) ? "crew"
+ : istTreffAdresse(req.get?.("host")) ? "treff" : "agentur";
+
+ /* DER AUSSCHLUSS WIRKT HIER -- an derselben Stelle wie alles andere
+ (11.09.2026).
+
+ Ein Ausschluss aus dem Treff ist nicht "darf nichts mehr
+ schreiben", sondern "ist nicht mehr da". Stuende die Pruefung an
+ den Schreibwegen, koennte ein Ausgeschlossener weiter mitlesen --
+ und genau das ist bei Belaestigung der Teil, der weh tut.
+
+ AN DIESER STELLE UND NICHT NUR BEIM ANMELDEN: Wer schon
+ angemeldet ist, hat ein Sitzungsplaetzchen, das Tage gilt. Eine
+ Pruefung beim Anmelden haette ihn bis zum Ablauf drin gelassen --
+ also ausgerechnet in den Stunden, in denen die Entscheidung
+ gefallen ist.
+
+ NUR FUER AUSSENROLLEN: Eine Massnahme gegen jemanden aus dem Team
+ gibt es nicht (siehe /api/treff/massnahme), und ein Fehler hier
+ duerfte niemals das Team aussperren. Die Frage wird deshalb gar
+ nicht erst gestellt.
+
+ WIRFT DIE FRAGE, greift der catch weiter unten und die Antwort
+ ist "null" -- also "nicht angemeldet". Das ist die sichere
+ Richtung: Wer nicht nachsehen kann, laesst niemanden herein. */
+ if (AUSSEN_ROLLEN.has(reihe.rolle) && treffSperreLesen(db(), reihe.id)?.art === "ausschluss") {
+ return null;
+ }
return { id: reihe.id, name: reihe.name, rolle: reihe.rolle, haus };
} catch { return null; }
@@ -3131,7 +3371,15 @@ export const workspaceRouter = express.Router();
/workspace/ -- und von dort wieder hierher. Der Browser bricht das
nach ein paar Runden mit "zu viele Weiterleitungen" ab, und man
sucht den Fehler ueberall, nur nicht in dieser Zeile. */
-const OFFEN = new Set(["/workspace/index.html", "/workspace/crew-index.html"]);
+/* SIE WIRD NICHT ZWEIMAL GESCHRIEBEN (11.09.2026).
+ Bis heute stand hier dieselbe Liste ein zweites Mal, von Hand. Sie
+ stimmte -- weil sie zwei Eintraege hatte und beide am selben Tag
+ entstanden. Beim dritten (der Wand des Treffs) waere genau das
+ passiert, wogegen die Rechtetafel gebaut wurde: Wer nur hier
+ nachtraegt, bekommt eine Wand, die die Pruefung als Luecke meldet;
+ wer nur dort nachtraegt, bekommt eine Endlosweiterleitung. Beides
+ faellt erst im Browser auf. Jetzt gibt es die Liste einmal. */
+const OFFEN = new Set(OHNE_ANMELDUNG);
/** Der Pfad, wie ihn die Schranke ansehen muss.
*
@@ -3241,6 +3489,58 @@ function stillerZugang(code) {
}
}
+/* =====================================================================
+ WER SIEHT WAS — die Tafel, wie sie wirklich ist (11.09.2026)
+ =====================================================================
+
+ Filipe: *"egal welche Rolle oder Person hinzugefuegt wird, soll immer
+ nur das sehen, was ich erlaube."*
+
+ Dazu gehoert, dass er es NACHSEHEN kann. Eine Regel, die man nur
+ glauben kann, ist eine Zusage; eine, die man ansehen kann, ist eine
+ Auskunft.
+
+ DIESE ANTWORT WIRD GERECHNET, NICHT GEPFLEGT. Sie kommt aus
+ derselben Tafel, aus der auch die Schranke ihre Entscheidung holt
+ (rechte.js). Eine von Hand geschriebene Uebersicht waere eine zweite
+ Wahrheit — und ausgerechnet die, der man glaubt, weil sie wie eine
+ Auskunft aussieht. Sie wuerde altern, und man wuerde es ihr nicht
+ ansehen.
+
+ NUR DogFather. Nicht aus Geheimniskraemerei, sondern weil diese
+ Antwort eine vollstaendige Karte des Hauses ist: jede Seite, jede
+ Rolle, und wo die Luecken sind. Wer sie hat, weiss, wo er es
+ versuchen muesste.
+ ===================================================================== */
+workspaceRouter.get("/workspace/api/rechte/tafel", (req, res) => {
+ const person = sitzungLesen(req);
+ if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
+ if (person.rolle !== "admin") return res.status(404).json({ fehler: "nicht_gefunden" });
+
+ const seiten = Object.entries(SEITEN).map(([pfad, rollen]) => ({
+ pfad,
+ datei: pfad.replace("/workspace/", ""),
+ rollen: [...rollen],
+ })).sort((a, b) => a.datei.localeCompare(b.datei, "de"));
+
+ res.json({
+ rollen: ALLE_ROLLEN.map((r) => ({
+ schluessel: r,
+ name: ROLLEN_NAME[r] ?? r,
+ /* Wie viele der Seiten diese Rolle oeffnen darf. Die Zahl ist
+ die eigentliche Antwort auf "sieht die neue Rolle zu viel?" */
+ seiten: seiten.filter((s) => s.rollen.includes(r)).length,
+ })),
+ seiten,
+ ohne_anmeldung: [...OHNE_ANMELDUNG],
+ /* Die Zahl, die den Unterschied macht: Wie viele Seiten stehen
+ fuer NIEMANDEN offen, ohne dass jemand das entschieden haette?
+ Seit dem 11.09. ist sie immer 0 — es gibt keinen Zustand mehr,
+ der "alle angemeldeten" bedeutet, ohne dass es dasteht. */
+ ohne_eintrag: 0,
+ });
+});
+
workspaceRouter.post("/workspace/api/anmelden", (req, res) => {
const ip = echteIp(req);
try {
@@ -3313,9 +3613,25 @@ workspaceRouter.post("/workspace/api/anmelden", (req, res) => {
zwar auch fuer die neue Adresse, denn die Sperre haengt an der
IP, nicht am Hostnamen. Das ist der Preis der Stille; er faellt
nur an, wenn jemand die alte Adresse mehrfach probiert. */
- const still = codeBrauchbar && !istOhneModiAdresse(req.get("host"))
- ? stillerZugang(code)
- : null;
+ /* UND AUCH NICHT AUF DER WAND DES TREFFS (11.09.2026).
+
+ Hier stand `!istOhneModiAdresse(...)` -- also "ueberall ausser auf
+ den alten Adressen". Als dritte Wand dazukam, hiess das
+ stillschweigend auch: dort. Ein Modi kam damit ueber den
+ verborgenen Weg in den Treff herein, obwohl die Kachelliste dort
+ nur `gast` kennt und die Adressregel ihn abweist.
+
+ Gefunden hat das nicht das Lesen, sondern eine Zeile in
+ pruef-treff, die das GEGENTEIL behauptete und rot wurde:
+ "auf treff. kommt sonst niemand herein -- auch DogFather nicht
+ (401/200)". DogFather wurde abgewiesen, der Modi nicht.
+
+ Die Lehre ist dieselbe wie bei `null` in der Rechtetafel: Eine
+ Bedingung, die durch AUSSCHLIESSEN formuliert ist, nimmt jede
+ neue Moeglichkeit automatisch mit auf. Jetzt steht dort, WO der
+ Weg gilt, statt wo er nicht gilt. */
+ const stilleWand = istCrewAdresse(req.get("host")) || istPruefAdresse(req.get("host"));
+ const still = codeBrauchbar && stilleWand ? stillerZugang(code) : null;
if (still) {
db().prepare("DELETE FROM versuche WHERE ip = ?").run(ip);
db().prepare("UPDATE personen SET letzter_login = ? WHERE id = ?").run(jetzt(), still.id);
@@ -3360,7 +3676,9 @@ workspaceRouter.post("/workspace/api/anmelden", (req, res) => {
Wand gerade dran ist. Ein Manager, der crew. erraet und seinen
Code eintippt, faellt dadurch in genau dieselbe Antwort wie
jemand mit einer erfundenen Rolle. */
- const kachelSatz = istCrewAdresse(req.get("host")) ? CREW_KACHEL : ROLLEN_KACHEL;
+ const kachelSatz = istTreffAdresse(req.get("host")) ? TREFF_KACHEL
+ : istCrewAdresse(req.get("host")) ? CREW_KACHEL
+ : ROLLEN_KACHEL;
if (!kachelSatz.has(rolle) || !codeBrauchbar) {
db().prepare("INSERT INTO versuche (ip, zeitpunkt) VALUES (?,?)").run(ip, jetzt());
@@ -3417,6 +3735,33 @@ workspaceRouter.post("/workspace/api/anmelden", (req, res) => {
return res.status(401).json({ fehler: "ungueltig" });
}
+ /* AUSGESCHLOSSEN HEISST: DER CODE STIMMT NICHT MEHR (11.09.2026).
+
+ sitzungLesen() weist eine ausgeschlossene Person ohnehin ab --
+ jede Anfrage bekommt 401. Ohne diese Zeile hier waere die
+ Anmeldung aber TROTZDEM gelungen: Plaetzchen gesetzt, "willkommen"
+ geantwortet, Weiterleitung auf die Startseite -- und dort dann
+ eine Seite, auf der nichts geht. Das sieht nicht aus wie eine
+ Entscheidung, sondern wie ein kaputtes Programm, und man
+ probiert es zehnmal.
+
+ DIESELBE ANTWORT WIE BEI EINEM FALSCHEN CODE, und zwar
+ wortgleich: Wer ausgeschlossen wurde, hat den Grund bereits
+ bekommen (die Massnahme verlangt einen). Die Anmeldeseite muss
+ ihn nicht wiederholen -- und sie darf niemandem, der einen
+ fremden Code probiert, verraten, dass es diesen Zugang gibt.
+
+ NUR FUER AUSSENROLLEN. Gegen jemanden aus dem Team gibt es diese
+ Massnahme gar nicht (siehe /api/treff/massnahme); die Frage
+ wird deshalb nicht gestellt. Ein Fehler hier duerfte niemals
+ das Team aussperren. */
+ if (AUSSEN_ROLLEN.has(gefunden.rolle)
+ && treffSperreLesen(db(), gefunden.id)?.art === "ausschluss") {
+ db().prepare("INSERT INTO versuche (ip, zeitpunkt) VALUES (?,?)").run(ip, jetzt());
+ protokolliere("anmeldung_gesperrt_treff", { personId: gefunden.id, rolle, ip });
+ return res.status(401).json({ fehler: "ungueltig" });
+ }
+
db().prepare("DELETE FROM versuche WHERE ip = ?").run(ip);
db().prepare("UPDATE personen SET letzter_login = ? WHERE id = ?").run(jetzt(), gefunden.id);
sitzungSetzen(res, gefunden, req);
@@ -4593,10 +4938,43 @@ const nurCreatorIds = (ids) => {
} catch { return ids; }
};
-export const sichtbareCreatorIds = (person) => nurCreatorIds(nurHaus(ohneVerborgene(sichtbareCreatorIdsRoh(person), person), person));
-export const sichtbarePersonenIds = (person) => nurHaus(ohneVerborgene(sichtbarePersonenIdsRoh(person), person), person);
-export const betreuerIds = (person) => nurHaus(ohneVerborgene(betreuerIdsRoh(person), person), person);
-export const einladbareIds = (person) => nurHaus(ohneVerborgene(einladbareIdsRoh(person), person), person);
+/* WER VON AUSSEN KOMMT, SIEHT NIEMANDEN (11.09.2026).
+
+ Filipe: "egal welche rolle oder person hinzugefuegt wird soll immer
+ nur das sehen wass ich erlaube."
+
+ GEFUNDEN, NICHT UEBERLEGT. Eine Zeile in pruef-treff behauptete
+ "Personen: nichts" und wurde rot. Nachgemessen bekam ein Gast:
+
+ {"personen":[{"id":1,"name":"Filipe","rolle":"admin", ...},
+ {"id":5,"name":"Kessi","rolle":"gast", ...}]}
+
+ Also DogFathers Namen und seine Rolle. Nicht dramatisch -- ein
+ Streamer ist kein Geheimnis -- aber es war eine Auskunft, die
+ niemand erlaubt hatte, und genau das ist der Punkt. Die Liste kam
+ zustande, weil `sichtbarePersonenIdsRoh` fuer jede Rolle DogFather
+ mitgibt (er ist das Gegenueber von allem) und die vier Umhuellungen
+ danach nichts daran aendern.
+
+ `nurEigene` steht deshalb GANZ AUSSEN -- nach allen anderen. Wer
+ innen ansetzt, muss darauf vertrauen, dass keine spaetere Huelle
+ wieder etwas hinzufuegt; aussen gilt es unabhaengig davon, was
+ innen passiert. Und es gilt fuer alle vier Listen gleich, statt
+ viermal einzeln.
+
+ `null` heisst bei diesen Funktionen "alle" -- deshalb wird es hier
+ ausdruecklich zu einer Liste mit genau einem Eintrag. Ein `null`
+ durchzureichen hiesse fuer eine Aussenrolle das Gegenteil von dem,
+ was hier steht. */
+function nurEigene(ids, person) {
+ if (!AUSSEN_ROLLEN.has(person?.rolle)) return ids;
+ return person?.id ? [person.id] : [];
+}
+
+export const sichtbareCreatorIds = (person) => nurEigene(nurCreatorIds(nurHaus(ohneVerborgene(sichtbareCreatorIdsRoh(person), person), person)), person);
+export const sichtbarePersonenIds = (person) => nurEigene(nurHaus(ohneVerborgene(sichtbarePersonenIdsRoh(person), person), person), person);
+export const betreuerIds = (person) => nurEigene(nurHaus(ohneVerborgene(betreuerIdsRoh(person), person), person), person);
+export const einladbareIds = (person) => nurEigene(nurHaus(ohneVerborgene(einladbareIdsRoh(person), person), person), person);
/* SICH SELBST NICHT -- UND ZWAR AUCH DANN NICHT, WENN DIE LISTE
AUSGESCHRIEBEN WIRD (10.09.2026).
diff --git a/tools/crew-buehne.mjs b/tools/crew-buehne.mjs
index ea3f7a51..30f21128 100644
--- a/tools/crew-buehne.mjs
+++ b/tools/crew-buehne.mjs
@@ -61,6 +61,35 @@ const SZENE = join(QUELLE, "gate-spicy-dogfather.jpeg");
const DOGI = join(QUELLE, "dogfather-husky.png");
const ZIEL = process.env.BUEHNE_ZIEL || join(WURZEL, "workspace", "assets", "img", "buehne");
+/* ZWEI TUEREN, EIN WERKZEUG (11.09.2026).
+
+ Der Treff bekommt seine eigene Zugangswand, und sie braucht ihr
+ eigenes Motiv -- nicht weil sie anders aussehen soll, sondern weil
+ in diesem Bild "TEAM DOGI" EINGEBRANNT ist. Die Community traegt
+ diesen Namen nicht; auf ihrer Tuer stuende sonst der Name eines
+ Bereichs, zu dem sie nicht gehoert.
+
+ WARUM KEINE ZWEITE DATEI: Eine Kopie dieser 520 Zeilen haette die
+ drei Gegenproben mitkopiert -- und beim naechsten Nachbessern waere
+ genau eine der beiden verbessert worden. Hier aendern sich zwei
+ Dinge, also sind es zwei Werte und kein zweites Werkzeug.
+
+ DIE FARBE BLEIBT DIESELBE, und das ist eine Entscheidung, keine
+ Bequemlichkeit: Gemessen liegt der naechstbeste eigene Farbton
+ (Tannengruen) 60 Lab-Einheiten vom Marineblau entfernt -- er waere
+ also klar unterscheidbar gewesen. Nur haette der Treff damit wie ein
+ DRITTES Haus ausgesehen, neben Spicy Media und Team Dogi. Er ist
+ aber kein drittes Haus, sondern eine zweite Tuer in demselben:
+ "lila und babyblau ueberall" (Filipe, 10.09.2026). Unterschieden
+ werden die Tueren durch das WORT darauf, nicht durch die Farbe --
+ so, wie man es in einem Gebaeude auch macht.
+
+ node tools/crew-buehne.mjs
+ WAND_TEXT="DER TREFF" WAND_PRAEFIX=treff-gate node tools/crew-buehne.mjs
+*/
+const WAND_TEXT = process.env.WAND_TEXT || "TEAM DOGI";
+const WAND_PRAEFIX = process.env.WAND_PRAEFIX || "crew-gate";
+
for (const q of [SZENE, DOGI]) {
if (!existsSync(q)) { console.error(`Fehlt: ${q}`); process.exit(1); }
}
@@ -295,14 +324,14 @@ async function bauen({ breite, ausschnitt = null, guete = 0.92, dunkel = 0.08,
g.restore();
}
- /* ---- 5. "TEAM DOGI" ------------------------------------------- */
+ /* ---- 5. Der Name auf der Tuer --------------------------------- */
const schriftHoehe = Math.round(zh * 0.072);
g.save();
g.textAlign = "center";
g.textBaseline = "alphabetic";
g.font = `800 ${schriftHoehe}px "Segoe UI", system-ui, sans-serif`;
/* 0,955 lag halb unter dem Bildrand -- im Bildschirmfoto war von
- "TEAM DOGI" die untere Haelfte abgeschnitten. Jetzt so weit
+ dem Namen die untere Haelfte abgeschnitten. Jetzt so weit
herauf, dass die Unterlaengen noch auf dem Wasser stehen. */
const ty = zh * 0.915;
/* Erst ein kalter Schein darunter, dann die Schrift in Metall --
@@ -310,7 +339,7 @@ async function bauen({ breite, ausschnitt = null, guete = 0.92, dunkel = 0.08,
g.shadowColor = "rgba(70,150,220,0.85)";
g.shadowBlur = schriftHoehe * 0.9;
g.fillStyle = "rgba(10,25,45,0.9)";
- g.fillText("TEAM DOGI", mx, ty);
+ g.fillText(a.text, mx, ty);
g.shadowBlur = 0;
const metall = g.createLinearGradient(0, ty - schriftHoehe, 0, ty);
metall.addColorStop(0, "#ffffff");
@@ -318,7 +347,7 @@ async function bauen({ breite, ausschnitt = null, guete = 0.92, dunkel = 0.08,
metall.addColorStop(0.55, "#7ea6c6");
metall.addColorStop(1, "#e8f2fa");
g.fillStyle = metall;
- g.fillText("TEAM DOGI", mx, ty);
+ g.fillText(a.text, mx, ty);
g.restore();
/* ---- 6. Vignette und ein Hauch Dunkel ------------------------- */
@@ -364,7 +393,7 @@ async function bauen({ breite, ausschnitt = null, guete = 0.92, dunkel = 0.08,
dogiKasten,
};
}, { szene: szeneDaten, dogi: dogiDaten, breite, ausschnitt, guete, dunkel, vignette,
- ohneDogi, tafel: TAFEL });
+ ohneDogi, tafel: TAFEL, text: WAND_TEXT });
return { ...roh, puffer: Buffer.from(roh.bild.split(",")[1], "base64") };
}
@@ -439,17 +468,17 @@ let gebaut = 0;
console.log("\n--- Quer ---");
for (const b of [960, 1280, 1600, 1920, 2560]) {
const r = await bauen({ breite: b, guete: 0.93, dunkel: 0.08, vignette: 0.26 });
- writeFileSync(join(ZIEL, `crew-gate-${b}.webp`), r.puffer);
+ writeFileSync(join(ZIEL, `${WAND_PRAEFIX}-${b}.webp`), r.puffer);
gebaut++;
if (r.rotAnteil > 0.01) maengel.push(
- `crew-gate-${b}: noch ${(r.rotAnteil * 100).toFixed(1)} % rote Bildpunkte -- Umfaerbung hat nicht gegriffen`);
+ `${WAND_PRAEFIX}-${b}: noch ${(r.rotAnteil * 100).toFixed(1)} % rote Bildpunkte -- Umfaerbung hat nicht gegriffen`);
if (r.tafelMittel > 46) maengel.push(
- `crew-gate-${b}: die Tafel ist zu hell (${r.tafelMittel.toFixed(1)}) -- die Karte braucht einen ruhigen Grund`);
+ `${WAND_PRAEFIX}-${b}: die Tafel ist zu hell (${r.tafelMittel.toFixed(1)}) -- die Karte braucht einen ruhigen Grund`);
if (r.tafelStreuung > 34) maengel.push(
- `crew-gate-${b}: in der Tafel ist zu viel Muster (Streuung ${r.tafelStreuung.toFixed(1)})`);
+ `${WAND_PRAEFIX}-${b}: in der Tafel ist zu viel Muster (Streuung ${r.tafelStreuung.toFixed(1)})`);
- console.log(` crew-gate-${b}.webp`.padEnd(30)
+ console.log(` ${WAND_PRAEFIX}-${b}.webp`.padEnd(30)
+ `${(r.puffer.length / 1024).toFixed(0)} KB · ${r.breite}x${r.hoehe}`
+ ` · Rot ${(r.rotAnteil * 100).toFixed(2)} %`
+ ` · Tafel ${r.tafelMittel.toFixed(1)}/${r.tafelStreuung.toFixed(1)}`);
@@ -471,11 +500,11 @@ for (const b of [440, 580, 900]) {
ausschnitt: [0.13, 0.02, 0.32, 0.98], guete: 0.88,
dunkel: 0.22, vignette: 0.30,
});
- writeFileSync(join(ZIEL, `crew-gate-hoch-${b}.webp`), r.puffer);
+ writeFileSync(join(ZIEL, `${WAND_PRAEFIX}-hoch-${b}.webp`), r.puffer);
gebaut++;
if (r.rotAnteil > 0.01) maengel.push(
- `crew-gate-hoch-${b}: noch ${(r.rotAnteil * 100).toFixed(1)} % rote Bildpunkte`);
- console.log(` crew-gate-hoch-${b}.webp`.padEnd(30)
+ `${WAND_PRAEFIX}-hoch-${b}: noch ${(r.rotAnteil * 100).toFixed(1)} % rote Bildpunkte`);
+ console.log(` ${WAND_PRAEFIX}-hoch-${b}.webp`.padEnd(30)
+ `${(r.puffer.length / 1024).toFixed(0)} KB · ${r.breite}x${r.hoehe}`
+ ` · Rot ${(r.rotAnteil * 100).toFixed(2)} %`);
}
diff --git a/tools/workspace-stempel.mjs b/tools/workspace-stempel.mjs
index 8d0beb94..ce572deb 100644
--- a/tools/workspace-stempel.mjs
+++ b/tools/workspace-stempel.mjs
@@ -88,15 +88,32 @@ for (const name of readdirSync(ORDNER)) {
Derselbe Stempel wie in den Seiten -- `pruef-zwischenspeicher`
verlangt im ganzen Haus genau eine Zahl und wuerde eine zweite sofort
melden. */
-/* ES SIND ZWEI MANIFESTE (10.09.2026). Seit die Modi-App unter ihrer
- eigenen Adresse laeuft, gibt es neben app.webmanifest auch
- crew.webmanifest -- dasselbe Haus, anderer Name, andere Symbole.
+/* DIE MANIFESTE WERDEN GEFUNDEN, NICHT AUFGEZAEHLT (11.09.2026).
- ALS LISTE UND NICHT ALS ZWEITER BLOCK: Ein kopierter Block waere beim
- dritten Manifest ein dritter, und irgendwann bleibt einer stehen. Ein
- fehlendes Manifest ist hier ausserdem ein FEHLER und kein
- Ueberspringen -- sonst faellt das Ausbleiben nicht auf. */
-const MANIFESTE = ["app.webmanifest", "crew.webmanifest"];
+ Hier stand eine Liste mit zwei Namen und daneben der Satz, warum eine
+ Liste besser sei als ein kopierter Block. Beim DRITTEN Manifest
+ (treff.webmanifest, fuer die Community) hat sich das genau so
+ bewahrheitet, wie es vorausgesagt war -- nur eine Stufe weiter oben:
+ Der kopierte Block kam nicht, aber der neue Name fehlte in der Liste.
+ Das Manifest bekam keinen Stempel, und die Symbole der neuen App
+ waeren beim naechsten Wechsel bis zu vier Stunden lang die alten
+ geblieben. Kein Fehler, keine Warnung -- nur eine App mit dem
+ falschen Zeichen.
+
+ Jetzt wird gesucht. Wer eine vierte Tuer baut, muss hier nichts mehr
+ nachtragen; das Werkzeug findet sie.
+
+ NULL MANIFESTE SIND EIN FEHLER und kein Ueberspringen: Faende die
+ Suche nichts (falscher Ordner, umbenannte Endung), liefe der Stempel
+ gruen durch und haette nichts getan -- der gruene Haken ueber der
+ Null, gegen den in diesem Haus die halbe Dokumentation steht. */
+const MANIFESTE = readdirSync(ORDNER)
+ .filter((n) => n.endsWith(".webmanifest"))
+ .sort();
+if (!MANIFESTE.length) {
+ console.error(`FEHLER: kein einziges Manifest in ${ORDNER} gefunden.`);
+ process.exit(1);
+}
const MUSTER_MANIFEST =
/("src":\s*")(\/assets\/img\/app-symbole\/[A-Za-z0-9._-]+)(?:\?v=\d+)?(")/g;
for (const name of MANIFESTE) {
diff --git a/tools/zeilenenden.mjs b/tools/zeilenenden.mjs
new file mode 100644
index 00000000..25654e8c
--- /dev/null
+++ b/tools/zeilenenden.mjs
@@ -0,0 +1,79 @@
+/* =====================================================================
+ zeilenenden.mjs — gemischte Zeilenenden nachziehen (11.09.2026)
+
+ AUFRUF: node tools/zeilenenden.mjs [ ...]
+
+ ---------------------------------------------------------------------
+ WOZU
+
+ Die Dateien dieses Hauses sind gemischt: manche mit CRLF (Windows),
+ manche mit LF. Wer eine davon mit einem Skript bearbeitet und dabei
+ Zeilen mit "\n" einfügt, bekommt eine Datei mit ZWEI Sorten
+ Zeilenenden.
+
+ Das ist kein Schönheitsfehler. Was dabei herauskommt:
+
+ * Die Datei läuft weiterhin -- JavaScript, CSS und HTML ist das
+ Zeilenende gleichgültig. Es fällt also nicht auf.
+ * `git diff` zeigt dann Zeilen als geändert, die niemand angefasst
+ hat -- und in einem Änderungssatz, in dem 40 echte Zeilen stehen,
+ gehen die echten zwischen 400 unechten unter. Genau dort wird
+ eine Prüfung vor dem Ausliefern wertlos: Man sieht nicht mehr,
+ was man verändert hat.
+ * Beim nächsten Bearbeiten derselben Datei trifft ein Anker mit
+ "\n" die Stellen mit "\r\n" nicht mehr -- der Patch meldet
+ "Anker nicht gefunden", und man sucht den Fehler im Anker.
+
+ ---------------------------------------------------------------------
+ WAS ES TUT UND WAS AUSDRÜCKLICH NICHT
+
+ Es macht NUR gemischte Dateien einheitlich, und zwar auf das
+ Zeilenende, das in dieser Datei ohnehin überwiegt. Es wandelt
+ niemals eine saubere LF-Datei nach CRLF oder umgekehrt -- das wäre
+ eine Änderung an jeder Zeile und damit genau der Schaden, den es
+ verhindern soll.
+
+ Die drei Ausgänge, wie überall in diesem Haus:
+ * rein CRLF oder rein LF -> "unveraendert" (nichts zu tun)
+ * gemischt -> nachgezogen, mit Zahl
+ * Datei nicht lesbar -> Fehler mit Namen, Rückgabewert 1
+ (und nicht stillschweigend übersprungen)
+ ===================================================================== */
+
+import fs from "node:fs";
+
+const dateien = process.argv.slice(2);
+if (!dateien.length) {
+ console.error("Aufruf: node tools/zeilenenden.mjs [ ...]");
+ process.exit(1);
+}
+
+let fehler = 0;
+for (const p of dateien) {
+ let s;
+ try {
+ s = fs.readFileSync(p, "utf8");
+ } catch (f) {
+ console.error(`${p.padEnd(38)} NICHT LESBAR: ${f?.message}`);
+ fehler++;
+ continue;
+ }
+ const crlf = (s.match(/\r\n/g) || []).length;
+ const n = (s.match(/\n/g) || []).length;
+ if (crlf === 0 || crlf === n) {
+ console.log(p.padEnd(38), "unveraendert");
+ continue;
+ }
+ /* Die Mehrheit gewinnt. Bei einer Datei, die zu 95 % CRLF ist und in
+ die zwanzig LF-Zeilen eingefügt wurden, ist CRLF das Richtige --
+ und umgekehrt genauso. Der Blick auf die Mehrheit macht das
+ Werkzeug richtungsunabhängig, statt CRLF zur Vorgabe zu erklären. */
+ const nachCrlf = crlf * 2 >= n;
+ s = nachCrlf
+ ? s.replace(/(? main { position: relative; z-index: 1; }
[data-ton="26"] { --ton: #7fd4a8; } /* Entwicklung */
[data-ton="27"] { --ton: #8f7bd6; } /* Talente */
+/* =====================================================================
+ DIE SIEBEN TOENE DES TREFFS (11.09.2026)
+
+ Bei 27 Kacheln war der Farbkreis voll -- das stand seit dem 11.09. als
+ Notiz im Code. Sieben weitere von Hand zu waehlen haette Toene
+ ergeben, die sich nur noch der Messung nach unterscheiden.
+
+ Sie sind deshalb GERECHNET: ein Gitter aus Farbton, Saettigung und
+ Helligkeit, umgerechnet in den Lab-Raum (dort entspricht Abstand dem,
+ was das Auge als Unterschied sieht), und daraus gierig die sieben mit
+ dem groessten kleinsten Abstand zu allen 27 vorhandenen UND
+ zueinander.
+
+ Zwei Bereiche sind ausgeschlossen, und beide aus einem Grund:
+ * ROT (unter 22 und ueber 338 Grad) gehoert Spicy Media. Eine
+ Community-Kachel in deren Farbe waere ein falsches Signal.
+ * NEON (Buntheit ueber 62) verstoesst gegen die Hausregel
+ "augenschonend". Dasselbe gilt nach unten: unter 24 wird es grau,
+ und grau liest sich als "abgeschaltet".
+
+ Ergebnis: schwaechster Abstand 24,9 -- zum Vergleich lag eine von
+ Hand gewaehlte Farbfamilie im selben Blau bei 12,6. Die Rechnung war
+ also nicht Genauigkeitsspielerei, sondern der Unterschied zwischen
+ unterscheidbar und nicht. */
+[data-ton="28"] { --ton: #8e784f; } /* Regeln & Hilfe */
+[data-ton="29"] { --ton: #8e5b77; } /* Highlights */
+[data-ton="30"] { --ton: #b2bc78; } /* Wunschliste */
+[data-ton="31"] { --ton: #656a9e; } /* Was ansteht */
+[data-ton="32"] { --ton: #668e6e; } /* Mitmachen */
+[data-ton="33"] { --ton: #cc9451; } /* Anschlagbrett */
+[data-ton="34"] { --ton: #cc92c6; } /* Der Treff */
+
+/* DER 35. TON (11.09.2026) — für „Wer sieht was".
+
+ GEMESSEN, NICHT GEWÄHLT. Gesucht war der Punkt im Farbraum, der von
+ ALLEN 34 vorhandenen Tönen am weitesten entfernt liegt — Abstand in
+ Lab, nicht in HSL, weil zwei Töne mit gleichem HSL-Abstand
+ unterschiedlich verschieden AUSSEHEN können.
+
+ Zwei Bereiche waren dabei ausgeschlossen, und beide aus einem Grund
+ und nicht aus Geschmack:
+ * Rot (Farbton unter 22° / über 338°) gehört Spicy Media.
+ * Neon (Sättigung über 52 %) verstößt gegen die Hausregel
+ „augenschonend" — die gilt hier wie überall.
+
+ Ergebnis: #2f2f7f, Abstand 31,4 zum nächsten Nachbarn (#656a9e, „Was
+ ansteht"). Zum Vergleich: Das schwächste Paar unter den vorhandenen
+ 34 liegt bei 24,9. Der neue Ton ist also unterscheidbarer als das,
+ was ohnehin nebeneinander steht — und die beiden nächsten Nachbarn
+ stehen in verschiedenen Gruppen, treffen sich auf dem Bildschirm
+ also gar nicht.
+
+ WARUM ÜBERHAUPT EIN NEUER: Die 35. Kachel hätte sich sonst einen Ton
+ mit einer anderen geteilt. Zwei Dinge in einer Farbe heißt, dass die
+ Farbe nichts mehr sagt — und pruef-start-ansicht zählt genau das
+ nach ("34 Farben auf 35 Kacheln" war ihre Meldung). */
+[data-ton="35"] { --ton: #2f2f7f; } /* Wer sieht was */
+/* Der 36., nach derselben Rechnung: #7c2771, Abstand 29,7 zu den beiden
+ nächsten (#8e5b77 „Highlights", #2f2f7f „Wer sieht was"). Auch das
+ liegt über dem schwächsten Paar der vorhandenen Töne (24,9). */
+[data-ton="36"] { --ton: #7c2771; } /* Meldungen & Maßnahmen */
+
/* Rückfallwert für alles, was einen Ton braucht, aber keine Nummer hat.
Steht bewusst in :where() -- damit zählt die Zeile beim Wettstreit der
Regeln als NICHT vorhanden und die Nummer oben gewinnt immer. Ohne
diff --git a/workspace/assets/css/treff.css b/workspace/assets/css/treff.css
new file mode 100644
index 00000000..61b36510
--- /dev/null
+++ b/workspace/assets/css/treff.css
@@ -0,0 +1,269 @@
+/* =====================================================================
+ Der Treff — die beiden Textseiten (11.09.2026)
+
+ Regeln & Hilfe (für die Community) und „Wer sieht was" (für
+ DogFather). Beides sind LESESEITEN: langer, zusammenhängender Text
+ und eine Tabelle, keine Kartenwand.
+
+ ---------------------------------------------------------------------
+ WARUM SIE KEINE MODULKARTEN SIND
+
+ Die naheliegende Wahl wäre `.eintrag-karte` oder ein neuer Eintrag in
+ der MODULLISTE gewesen — dann sähen die Abschnitte aus wie alles
+ andere im Haus: geschliffene Fase, Kantenlicht, Sternenfeld.
+
+ Dagegen sprechen zwei Dinge, und das zweite wiegt schwerer:
+
+ 1. Die MODULLISTE steht siebenmal in module.css und wird von
+ pruef-css-klassen Zeichen für Zeichen verglichen. Eine achte Klasse
+ hätte an sieben Stellen dasselbe ergänzen müssen — machbar, aber
+ jede dieser sieben ist eine Gelegenheit für einen Unterschied.
+
+ 2. Karten zerteilen. Ein Regeltext ist ein Text, den man von oben
+ nach unten liest; sieben Karten daraus zu machen heißt, sieben
+ Anfänge zu machen. Genau das ist bei Regeln der Fehler: Wer nach
+ der dritten Karte aufhört, hat vier Regeln nicht gelesen und
+ glaubt trotzdem, er kenne sie.
+
+ Deshalb hier nur so viel Trennung, dass man die Abschnitte findet —
+ eine Linie und Abstand, keine Kante.
+ ===================================================================== */
+
+.treff-abschnitt {
+ margin: 0 0 26px;
+ padding: 0 0 22px;
+ border-bottom: 1px solid var(--rand);
+}
+.treff-abschnitt:last-of-type { border-bottom: 0; }
+
+.treff-abschnitt__titel {
+ margin: 0 0 10px;
+ font-size: 1.06rem;
+ font-weight: 650;
+ letter-spacing: .005em;
+ color: var(--text);
+}
+
+/* Der Lesefluss ist hier die eigentliche Aufgabe.
+
+ 1,72 statt der üblichen 1,55: Auf einem Handy stehen in einer Zeile
+ etwa 42 Zeichen; bei engem Zeilenabstand verliert man beim Sprung an
+ den Zeilenanfang die Zeile. Das merkt niemand als „schlechter
+ Zeilenabstand" — man liest den Absatz einfach zweimal und hält den
+ Text für kompliziert.
+
+ Und eine Breitengrenze: Über etwa 70 Zeichen je Zeile wird derselbe
+ Sprung wieder schwierig, nur von der anderen Seite. Auf einem breiten
+ Bildschirm liefe der Regeltext sonst über 1100 Pixel. */
+.treff-abschnitt__inhalt { max-width: 68ch; }
+.treff-abschnitt__inhalt p {
+ margin: 0 0 12px;
+ font-size: .92rem;
+ line-height: 1.72;
+ color: var(--text-leise);
+}
+.treff-abschnitt__inhalt p:last-child { margin-bottom: 0; }
+.treff-abschnitt__inhalt strong { color: var(--text); font-weight: 640; }
+
+/* Die Aufzählungen. Die Zahl bzw. der Punkt steht AUSSERHALB des
+ Textblocks — so beginnt jede Zeile einer mehrzeiligen Regel an
+ derselben Stelle, und die Liste bleibt als Liste lesbar. */
+.regel-liste {
+ margin: 0 0 12px;
+ padding-left: 1.35em;
+ font-size: .92rem;
+ line-height: 1.72;
+ color: var(--text-leise);
+}
+.regel-liste li { margin: 0 0 9px; padding-left: .2em; }
+.regel-liste li:last-child { margin-bottom: 0; }
+.regel-liste li::marker { color: var(--text-still); }
+.regel-liste strong { color: var(--text); font-weight: 640; }
+
+/* =====================================================================
+ „WER SIEHT WAS" — die Rechtetafel als Tabelle
+
+ Sie wird aus der echten Tafel gerechnet (rechte.js), nicht gepflegt.
+ Was sie leisten muss: Man soll eine LÜCKE sehen, ohne zu suchen.
+ ===================================================================== */
+
+.rechte-tafel { overflow-x: auto; margin: 0 0 26px; }
+
+.rechte-tafel table {
+ border-collapse: collapse;
+ width: 100%;
+ font-size: .82rem;
+}
+
+/* Die Kopfzeile bleibt stehen. Bei 22 Zeilen und acht Spalten hat man
+ sonst nach dem halben Scrollen vergessen, welche Spalte welche Rolle
+ ist — und rät. Bei einer Rechteübersicht ist Raten das Gegenteil des
+ Zwecks. */
+.rechte-tafel thead th {
+ position: sticky; top: 0; z-index: 1;
+ background: var(--flaeche);
+ padding: 9px 8px;
+ text-align: center;
+ font-weight: 600;
+ color: var(--text-leise);
+ border-bottom: 1px solid var(--rand-hell);
+ white-space: nowrap;
+}
+.rechte-tafel thead th:first-child { text-align: left; }
+
+.rechte-tafel td {
+ padding: 7px 8px;
+ border-bottom: 1px solid var(--rand);
+ text-align: center;
+ color: var(--text-still);
+}
+.rechte-tafel td:first-child {
+ text-align: left;
+ color: var(--text-leise);
+ font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
+ font-size: .79rem;
+ white-space: nowrap;
+}
+.rechte-tafel tbody tr:hover td { background: rgba(255, 255, 255, .025); }
+
+/* JA UND NEIN SIND NICHT GRÜN UND ROT.
+
+ Das wäre die naheliegende Wahl und hier die falsche: In dieser
+ Tabelle ist ein „nein" nichts Schlechtes — es ist der Normalfall und
+ genau das, was Filipe wollte. Grün/Rot würde eine Tafel mit 140
+ roten Feldern erzeugen, die aussieht wie ein Schadensbericht.
+
+ Stattdessen: sichtbar gegen leise. Ein Punkt, wo es erlaubt ist,
+ nichts, wo nicht. Man sieht die Zeilen mit vielen Punkten sofort —
+ und das ist die Frage, die man an diese Tabelle hat. */
+.rechte-ja {
+ display: inline-block; width: 7px; height: 7px; border-radius: 50%;
+ background: var(--akzent);
+ box-shadow: 0 0 8px -2px var(--akzent);
+}
+.rechte-nein { color: var(--text-still); opacity: .3; }
+
+/* Die Zählzeile über der Tabelle: „Community — 3 von 22 Seiten". Das
+ ist die eine Zahl, die die ganze Frage beantwortet. */
+.rechte-zahlen {
+ display: flex; flex-wrap: wrap; gap: 8px;
+ margin: 0 0 18px; padding: 0; list-style: none;
+}
+.rechte-zahlen li {
+ padding: 6px 11px;
+ border: 1px solid var(--rand-hell);
+ border-radius: 999px;
+ font-size: .8rem;
+ color: var(--text-leise);
+}
+.rechte-zahlen li b { color: var(--text); font-weight: 650; }
+
+@media (max-width: 620px) {
+ .treff-abschnitt__inhalt p, .regel-liste { font-size: .95rem; }
+ .rechte-tafel table { font-size: .78rem; }
+}
+
+/* =====================================================================
+ DER WEG ZU DEN REGELN — auf jedem Brett des Treffs
+
+ Er steht direkt unter der Unterzeile und sieht aus wie ein Link,
+ nicht wie ein Knopf. Das ist Absicht: Ein Knopf verspricht, dass
+ etwas PASSIERT; hier wechselt man nur die Seite. Wer auf jedem Brett
+ einen zusätzlichen Knopf sieht, zählt ihn nach zwei Tagen zum
+ Hintergrund.
+ ===================================================================== */
+.treff-regelweg {
+ display: inline-block;
+ margin-top: 6px;
+ font-size: .82rem;
+ color: var(--text-leise);
+ text-decoration: none;
+ border-bottom: 1px solid var(--rand-hell);
+ padding-bottom: 1px;
+}
+.treff-regelweg:hover, .treff-regelweg:focus-visible {
+ color: var(--text);
+ border-bottom-color: color-mix(in srgb, var(--akzent) 55%, transparent);
+}
+
+/* Links im Fließtext der Regelseite. Unterstrichen, weil Farbe allein
+ kein Unterschied ist, den jeder sieht (WCAG 1.4.1). */
+.treff-abschnitt__inhalt a {
+ color: var(--text);
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ text-decoration-color: color-mix(in srgb, var(--akzent) 55%, transparent);
+}
+.treff-abschnitt__inhalt a:hover {
+ text-decoration-color: var(--akzent);
+}
+
+/* =====================================================================
+ MELDUNGEN & MASSNAHMEN — ein Fall je Karte
+
+ Karten sind hier richtig, wo sie beim Regeltext falsch waren: Es sind
+ EINZELNE Vorgänge, die man einzeln entscheidet. Jeder Anfang ist ein
+ neuer Fall, und das soll man sehen.
+ ===================================================================== */
+.treff-fall {
+ margin: 0 0 12px;
+ padding: 13px 15px;
+ border: 1px solid var(--rand);
+ border-left: 2px solid color-mix(in srgb, var(--akzent) 45%, transparent);
+ background: rgba(255, 255, 255, .018);
+}
+
+/* Was schon entschieden ist, tritt zurück -- aber es verschwindet
+ nicht. Ausblenden wäre bequemer und wäre falsch: Man muss nachsehen
+ können, wie ein ähnlicher Fall damals entschieden wurde. */
+.treff-fall[data-vorbei="ja"] {
+ opacity: .62;
+ border-left-color: var(--rand);
+}
+
+.treff-fall__titel {
+ margin: 0 0 8px;
+ font-size: .95rem;
+ font-weight: 640;
+ color: var(--text);
+}
+
+.treff-fall__zeile {
+ margin: 0 0 5px;
+ font-size: .85rem;
+ line-height: 1.6;
+ color: var(--text-leise);
+ display: flex;
+ gap: 8px;
+}
+.treff-fall__zeile:last-of-type { margin-bottom: 0; }
+
+/* Das Schild steht in fester Breite links. Ohne sie beginnt jede Zeile
+ woanders, und man liest die Liste als Fließtext statt als Angaben --
+ bei fünf Zeilen je Fall ist das der Unterschied zwischen Überfliegen
+ und Lesenmüssen. */
+.treff-fall__schild {
+ flex: 0 0 7.5em;
+ color: var(--text-still);
+ font-size: .78rem;
+ text-transform: lowercase;
+ padding-top: .1em;
+}
+
+.treff-fall__knoepfe {
+ display: flex; gap: 7px; flex-wrap: wrap;
+ margin-top: 11px; padding-top: 10px;
+ border-top: 1px solid var(--rand);
+}
+/* Der Link "zum Brett" sieht aus wie die Knöpfe daneben -- er tut ja
+ dasselbe: Er bringt einen woandershin. Ein Link, der zwischen zwei
+ Knöpfen anders aussieht, wird für Text gehalten. */
+.treff-fall__knoepfe a.schritt { text-decoration: none; }
+
+@media (max-width: 620px) {
+ /* Untereinander statt nebeneinander: Bei 7,5em Schild und 42 Zeichen
+ Zeilenbreite blieben für den Wert noch etwa 20 Zeichen -- ein Grund
+ in zwei Wörtern je Zeile. */
+ .treff-fall__zeile { display: block; }
+ .treff-fall__schild { display: block; padding: 0 0 1px; }
+}
diff --git a/workspace/assets/img/buehne/treff-gate-1280.webp b/workspace/assets/img/buehne/treff-gate-1280.webp
new file mode 100644
index 00000000..8c09a74e
Binary files /dev/null and b/workspace/assets/img/buehne/treff-gate-1280.webp differ
diff --git a/workspace/assets/img/buehne/treff-gate-1600.webp b/workspace/assets/img/buehne/treff-gate-1600.webp
new file mode 100644
index 00000000..9b3882eb
Binary files /dev/null and b/workspace/assets/img/buehne/treff-gate-1600.webp differ
diff --git a/workspace/assets/img/buehne/treff-gate-1920.webp b/workspace/assets/img/buehne/treff-gate-1920.webp
new file mode 100644
index 00000000..2b8990b7
Binary files /dev/null and b/workspace/assets/img/buehne/treff-gate-1920.webp differ
diff --git a/workspace/assets/img/buehne/treff-gate-2560.webp b/workspace/assets/img/buehne/treff-gate-2560.webp
new file mode 100644
index 00000000..49e357cf
Binary files /dev/null and b/workspace/assets/img/buehne/treff-gate-2560.webp differ
diff --git a/workspace/assets/img/buehne/treff-gate-960.webp b/workspace/assets/img/buehne/treff-gate-960.webp
new file mode 100644
index 00000000..f636fbc4
Binary files /dev/null and b/workspace/assets/img/buehne/treff-gate-960.webp differ
diff --git a/workspace/assets/img/buehne/treff-gate-hoch-440.webp b/workspace/assets/img/buehne/treff-gate-hoch-440.webp
new file mode 100644
index 00000000..b0ea734a
Binary files /dev/null and b/workspace/assets/img/buehne/treff-gate-hoch-440.webp differ
diff --git a/workspace/assets/img/buehne/treff-gate-hoch-580.webp b/workspace/assets/img/buehne/treff-gate-hoch-580.webp
new file mode 100644
index 00000000..5b0f8f10
Binary files /dev/null and b/workspace/assets/img/buehne/treff-gate-hoch-580.webp differ
diff --git a/workspace/assets/img/buehne/treff-gate-hoch-900.webp b/workspace/assets/img/buehne/treff-gate-hoch-900.webp
new file mode 100644
index 00000000..7b8a7fc8
Binary files /dev/null and b/workspace/assets/img/buehne/treff-gate-hoch-900.webp differ
diff --git a/workspace/assets/js/bereich.js b/workspace/assets/js/bereich.js
index cb5181ec..fea8e520 100644
--- a/workspace/assets/js/bereich.js
+++ b/workspace/assets/js/bereich.js
@@ -21,7 +21,31 @@
Notizbuch. Der Server setzt das ebenfalls durch (siehe
workspace-bereiche.js); hier geht es nur darum, ihm keine Knoepfe
hinzustellen, die ohnehin nur eine Absage bringen. */
- const darfEintragen = () => ich && ich.rolle !== 'creator';
+ /* WER HIER ETWAS EINTRAGEN DARF.
+
+ Bis zum 11.09.2026 stand hier nur "alle außer dem Creator". Das
+ stimmte, solange jedes Brett jedem Angemeldeten offenstand. Mit den
+ sieben Brettern des Treffs stimmt es nicht mehr: Auf dem
+ Anschlagbrett und bei "Was ansteht" schreibt nur das Team, und wer
+ neu ist, darf die erste Woche überhaupt nicht schreiben.
+
+ OHNE DIESE ZEILE HÄTTE EIN MITGLIED DEN KNOPF GESEHEN und beim
+ Drücken eine Absage bekommen -- genau die Sorte Knopf, die in
+ diesem Haus als Fehler gilt: Er verspricht etwas, das der Server
+ ablehnt, und der Mensch davor hält sich selbst für den Fehler.
+
+ DIE ANTWORT KOMMT VOM SERVER (/api/treff/lage), nicht aus einer
+ zweiten Regel hier. Sonst gäbe es die Schreibregel zweimal, und die
+ Fassung im Browser kann man ändern. Faellt die Auskunft aus, bleibt
+ die Lage leer und es gilt wieder die alte Regel -- der Knopf ist
+ dann da und der Server sagt notfalls nein. Das ist die richtige
+ Richtung: Ein Knopf zu viel ist ärgerlich, ein fehlender Riegel
+ wäre ein Loch. */
+ const darfEintragen = () => {
+ if (!ich || ich.rolle === 'creator') return false;
+ if (treffBrett && lage && lage.schreiben) return lage.schreiben[bereich] === true;
+ return true;
+ };
const $ = (id) => document.getElementById(id);
const UNTERZEILE = {
@@ -40,6 +64,14 @@
let ich = null;
let bereich = null;
let einstellung = null;
+ /* Ist dies ein Brett des Treffs, und was darf ich dort? Beides sagt
+ der Server (daten.treff und /api/treff/lage) -- im Browser steht
+ keine Liste der Bretter und keine Rechteregel, sondern nur die
+ Antwort auf "was darf ICH hier gerade". Eine Regel, die im Browser
+ noch einmal steht, ist die, die auseinanderlaeuft; und die Fassung
+ im Browser kann man aendern. */
+ let treffBrett = false;
+ let lage = null;
let filter = '';
/* Nur beim ersten Laden springen -- sonst reisst es die Seite nach
jeder Bewertung erneut an dieselbe Stelle. */
@@ -214,6 +246,174 @@
if (e.erstellt_name) fuss.append(el('span', null, '· von ' + e.erstellt_name));
if (e.status === 'erledigt') fuss.append(el('span', null, '· erledigt'));
+ /* ---- Der Treff -------------------------------------------------
+
+ DER VOLLE ROLLENNAME AM BEITRAG (Filipe, 11.09.2026: "voller
+ Rollenname zeigen"). Er kommt fertig vom Server; hier steht
+ keine Zuordnung Rolle -> Name, die waere eine Liste aller Rollen
+ dieses Hauses auf jedem Rechner der Community.
+
+ WARUM DAS UEBERHAUPT DRANSTEHT: In einem Raum mit Zuschauern und
+ Moderation muss man sehen, wer spricht. "Frida" und "Frida ·
+ Modi" sind zwei verschiedene Saetze -- der zweite ist eine
+ Ansage, der erste eine Meinung.
+
+ BEI MITGLIEDERN STEHT NICHTS DA. "Kessi · Community" waere eine
+ Auszeichnung fuer das Normale; der Rollenname ist hier eine
+ Auskunft ueber Verantwortung, nicht ueber Zugehoerigkeit. */
+ if (treffBrett && e.erstellt_rolle_name && e.erstellt_rolle !== 'gast') {
+ fuss.append(el('span', 'treff-rolle', '· ' + e.erstellt_rolle_name));
+ }
+
+ if (treffBrett && lage) {
+ const zeile = el('div', 'treff-knoepfe');
+
+ /* WILL ICH AUCH. Die Zahl steht AM Knopf und nicht daneben: Sie
+ ist das Ergebnis des Knopfes, nicht eine Angabe ueber den
+ Eintrag. Und sie aendert sich sofort im Bild, bevor der Server
+ geantwortet hat -- bei einem Knopf, der eine Meinung abgibt,
+ ist eine halbe Sekunde Verzoegerung der Unterschied zwischen
+ "hat geklappt" und "hab ich das jetzt gedrueckt?". Antwortet
+ der Server anders, gilt seine Zahl. */
+ if (bereich === 'wunsch') {
+ const w = el('button', 'treff-will', '');
+ w.type = 'button';
+ let meine = !!e.meine_stimme;
+ let zahl = Number(e.stimmen) || 0;
+ const male = () => {
+ w.textContent = (meine ? '★ ' : '☆ ') + 'Will ich auch · ' + zahl;
+ w.setAttribute('aria-pressed', String(meine));
+ };
+ male();
+ w.addEventListener('click', async () => {
+ w.disabled = true;
+ meine = !meine; zahl += meine ? 1 : -1; male();
+ try {
+ /* EIN DELETE TRAEGT HIER KEINEN KOERPER (gemessen 11.09.2026).
+
+ Der naheliegende Aufbau waere gewesen, PUT und DELETE
+ gleich zu behandeln -- gleicher Kopf, gleicher leerer
+ Koerper. Das geht NICHT: Nodes HTTP-Parser weist ein
+ DELETE mit Koerper mit einem leeren 400 ab, noch bevor
+ Express die Anfrage sieht. Man sucht den Fehler dann in
+ der Route, und dort ist keiner.
+
+ Nachgemessen: PUT mit Koerper -> 200, DELETE ohne
+ Koerper -> 200, DELETE mit Koerper -> 400 mit genau
+ einem Kopffeld ("connection: close"). Das ist die
+ Handschrift des Parsers, nicht die einer Anwendung. */
+ const a = await hole('/workspace/api/treff/eintrag/' + e.id + '/willich',
+ meine
+ ? { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{}' }
+ : { method: 'DELETE' });
+ if (a.ok) { const j = await a.json(); zahl = j.stimmen; meine = j.meine; male(); }
+ } catch { /* umgeleitet */ } finally { w.disabled = false; }
+ });
+ zeile.append(w);
+ }
+
+ /* MELDEN. Nur fuer die Community und nur an fremden Beitraegen --
+ den eigenen kann man loeschen, das ist der kuerzere Weg.
+ Der Grund ist Pflicht (DSA Art. 16); der Server prueft ihn
+ noch einmal, die Abfrage hier erspart nur den Fehlschlag. */
+ if (!lage.team && e.erstellt_von !== ich.id) {
+ zeile.append(tuKnopf('melden', async () => {
+ const grund = prompt('Was ist daran nicht in Ordnung?\n\n'
+ + 'Bitte schreib es in einem Satz – sonst können wir es nicht prüfen.');
+ if (grund === null) return;
+ const a = await hole('/workspace/api/treff/eintrag/' + e.id + '/melden', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ grund }),
+ });
+ melde(a.ok ? 'Danke – wir sehen uns das an.'
+ : ((await a.json().catch(() => ({}))).fehler || 'Ging nicht.'));
+ }));
+ }
+
+ if (lage.team) {
+ /* ANHEFTEN. Hoechstens drei; die Zahl kommt vom Server
+ (lage.anschlag_max), damit sie nicht an zwei Stellen steht. */
+ if (bereich === 'anschlag') {
+ const dran = !!e.angeheftet;
+ zeile.append(tuKnopf(dran ? 'abnehmen' : 'anheften', async () => {
+ const a = await hole('/workspace/api/treff/eintrag/' + e.id + '/anheften',
+ dran
+ ? { method: 'DELETE' }
+ : { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{}' });
+ if (!a.ok) {
+ melde((await a.json().catch(() => ({}))).fehler
+ || ('Es hängen schon ' + lage.anschlag_max + ' Ansagen.'));
+ return;
+ }
+ await laden();
+ }));
+ }
+
+ /* FREIGEBEN. Bei Terminen heisst es "im Treff zeigen", bei
+ Highlights "freigeben" -- derselbe Vorgang, zwei Woerter,
+ weil es zwei verschiedene Entscheidungen sind. */
+ if (bereich === 'ansteht' || bereich === 'highlight') {
+ const frei = !!e.freigegeben;
+ const wort = bereich === 'ansteht'
+ ? (frei ? 'nicht mehr zeigen' : 'im Treff zeigen')
+ : (frei ? 'zurückziehen' : 'freigeben');
+ if (!frei) {
+ const m = el('span', 'marke-art marke-art--nur', 'noch nicht sichtbar');
+ m.title = 'Die Community sieht das erst nach der Freigabe.';
+ zeile.append(m);
+ }
+ zeile.append(tuKnopf(wort, async () => {
+ const a = await hole('/workspace/api/treff/eintrag/' + e.id + '/freigeben',
+ frei
+ ? { method: 'DELETE' }
+ : { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{}' });
+ if (!a.ok) { melde('Ging nicht.'); return; }
+ await laden();
+ }));
+ }
+
+ /* AUS "MITMACHEN" WIRD EIN TALENT. Ein ausdruecklicher Schritt
+ und keine Automatik: Wer sich meldet, ist ein Kandidat --
+ dass er einer wird, entscheidet ein Mensch. */
+ if (bereich === 'mitmachen' && e.erstellt_rolle === 'gast') {
+ zeile.append(tuKnopf('zu den Talenten', async () => {
+ if (!confirm('„' + e.titel + '“ zu den Talenten übernehmen?')) return;
+ const a = await hole('/workspace/api/treff/mitmachen/' + e.id + '/uebernehmen',
+ { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
+ const j = await a.json().catch(() => ({}));
+ melde(a.ok ? 'Steht jetzt bei den Talenten.'
+ : (j.fehler === 'schon_uebernommen' ? 'Steht dort schon.'
+ : (j.fehler || 'Ging nicht.')));
+ }));
+ }
+
+ /* ENTFERNEN MIT GRUND (DSA Art. 17). Eigener Knopf neben dem
+ gewohnten "loeschen" -- denn "loeschen" gibt es weiterhin
+ fuer den eigenen Beitrag, und der braucht keine Begruendung. */
+ if (e.erstellt_von !== ich.id) {
+ zeile.append(tuKnopf('entfernen', async () => {
+ const grund = prompt('Warum wird „' + e.titel + '“ entfernt?\n\n'
+ + 'Der Grund wird festgehalten – die Person hat ein Recht darauf.');
+ if (grund === null) return;
+ /* DER GRUND REIST IN DER ADRESSE, nicht im Koerper -- aus
+ demselben gemessenen Grund wie oben. Er ist kein
+ Geheimnis: Er wird der Person, die den Beitrag
+ geschrieben hat, ohnehin mitgeteilt, und er steht im
+ Protokoll. */
+ const a = await hole('/workspace/api/bereich/' + bereich + '/' + e.id
+ + '?grund=' + encodeURIComponent(grund), { method: 'DELETE' });
+ if (!a.ok) {
+ melde((await a.json().catch(() => ({}))).fehler || 'Ging nicht.');
+ return;
+ }
+ await laden();
+ }));
+ }
+ }
+
+ if (zeile.childNodes.length) k.append(zeile);
+ }
+
if (!darfEintragen()) { k.append(fuss); return k; }
const knoepfe = el('div', 'eintrag-knoepfe');
@@ -263,6 +463,19 @@
}
}
+ /* Einmal je Seitenaufruf. Sie wird an ZWEI Stellen gebraucht -- beim
+ Aufbau des Formulars und beim Zeichnen der Karten -- und beide
+ brauchen sie, BEVOR sie etwas anzeigen. Ein zweiter Abruf wäre eine
+ zweite Gelegenheit für zwei verschiedene Antworten auf dem selben
+ Bildschirm. */
+ async function lageHolen() {
+ if (!treffBrett || lage) return;
+ try {
+ const l = await fetch('/workspace/api/treff/lage', { credentials: 'same-origin' });
+ if (l.ok) lage = await l.json();
+ } catch { /* dann gilt die alte Regel, siehe darfEintragen */ }
+ }
+
function filterBauen() {
const ziel = $('filter');
ziel.textContent = '';
@@ -284,6 +497,8 @@
if (!a.ok) { melde('Einträge konnten nicht geladen werden.'); return; }
const daten = await a.json();
einstellung = daten.einstellung;
+ treffBrett = !!daten.treff;
+ await lageHolen();
filterBauen();
const ziel = $('liste');
@@ -539,6 +754,36 @@
if (einstellung.ober && $('ober')) $('ober').textContent = einstellung.ober;
$('unterzeile').textContent = UNTERZEILE[bereich] || '';
+ /* VOR dem Aufbau des Formulars. Danach wäre zu spät: Der Knopf
+ stünde dann kurz da und verschwände wieder -- sichtbar, und
+ ausgerechnet an der Stelle, an der jemand gerade überlegt, ob er
+ schreiben darf. */
+ treffBrett = !!daten.treff;
+ await lageHolen();
+
+ /* DER WEG ZU DEN REGELN STEHT AUF JEDEM BRETT DES TREFFS
+ (11.09.2026).
+
+ Nicht nur auf dem Brett "Regeln & Hilfe". Wer die Regeln braucht,
+ braucht sie in dem Moment, in dem er etwas schreiben will -- und
+ das ist fast nie dort. Ein Mensch, der erst zurueck zur
+ Startseite und dann auf die richtige Kachel muss, liest sie
+ nicht; er schreibt einfach.
+
+ ALS LINK UND NICHT ALS EINGEBLENDETER TEXT: Die Regeln sind zu
+ lang, um sie ueber jedes Brett zu haengen, und ein Kasten, den
+ man jeden Tag wegklickt, wird nach drei Tagen nicht mehr
+ gesehen. */
+ if (daten.treff) {
+ const vorhanden = $('treff-regelweg');
+ if (!vorhanden) {
+ const a = el('a', 'treff-regelweg', 'Regeln & Hilfe');
+ a.id = 'treff-regelweg';
+ a.href = 'treff-regeln.html';
+ $('unterzeile').after(a);
+ }
+ }
+
/* Fuer den Creator verschwindet alles Schreibende -- und die
Unterzeile sagt, warum. Ein leerer Bereich ohne Erklaerung wirkt
kaputt; einer mit Erklaerung wirkt gedacht. */
@@ -551,10 +796,18 @@
"was zu dir gehört", laese sich eine vollstaendige Liste wie
ein Ausschnitt, und die naheliegende Frage waere: Was sehe ich
hier eigentlich nicht? */
+ /* IM TREFF STIMMT KEINER DER BEIDEN SÄTZE. Dort geht es weder um
+ die Agentur noch um Betreuung, sondern entweder um ein Brett,
+ das dem Team gehört, oder um eine Stufe, die noch nicht
+ erreicht ist. Und der Server weiß beides genau -- er schickt
+ den Satz mit, statt dass der Browser ihn errät. */
$('unterzeile').textContent = (UNTERZEILE[bereich] || '')
- + (einstellung.fuerAlle
- ? ' Eingetragen wird von der Agentur – du siehst alles, was hier steht.'
- : ' Hier trägt deine Betreuung ein – du siehst alles, was zu dir gehört.');
+ + (treffBrett
+ ? ' ' + (lage?.warum?.[bereich]
+ || 'Hier schreibt das Team – lesen kannst du alles.')
+ : einstellung.fuerAlle
+ ? ' Eingetragen wird von der Agentur – du siehst alles, was hier steht.'
+ : ' Hier trägt deine Betreuung ein – du siehst alles, was zu dir gehört.');
}
for (const [wert, name] of Object.entries(einstellung.arten)) {
diff --git a/workspace/assets/js/gate.js b/workspace/assets/js/gate.js
index a877e0bd..17884fd9 100644
--- a/workspace/assets/js/gate.js
+++ b/workspace/assets/js/gate.js
@@ -76,6 +76,31 @@
und zwar ohne Fehlermeldung im Protokoll. */
const ohneKacheln = rollen.length === 0;
+ /* UND SEIT DEM 11.09.2026 GIBT ES SIE WIEDER -- die Wand des Treffs.
+
+ Dort ist nur EINE Sorte Zugang vorgesehen, und eine Kachelreihe
+ mit genau einem Eintrag waere keine Auswahl, sondern eine Huerde:
+ ein Knopf, den man druecken muss, damit der naechste Knopf
+ funktioniert.
+
+ DER SERVER BRAUCHT DIE ROLLE TROTZDEM. Er vergleicht sie gegen den
+ Kachelsatz seiner Adresse; kaeme "" an, waere die Antwort dieselbe
+ wie bei einem falschen Code -- 401, ohne dass irgendwo etwas
+ kaputt ist. Genau so eine Stelle sucht man lange, denn sie sieht
+ aus wie ein Tippfehler des Nutzers.
+
+ WARUM DAS AM STEHT UND NICHT IM SKRIPT: gate.js wird an
+ ALLE drei Waende ausgeliefert. Eine Fallunterscheidung hier
+ ("wenn Treff, dann gast") waere eine vierte Stelle, an der die
+ Adressen aufgezaehlt werden -- und die erste, die beim naechsten
+ Umbau vergessen wird. Die Wand sagt selbst, wofuer sie da ist.
+
+ EIN LECK IST DAS NICHT: Auf dieser Wand gibt es genau diese eine
+ Rolle. Der Name verraet nichts, was der Seitenkopf nicht ohnehin
+ sagt -- die beiden anderen Waende nennen ihre Rollen seit jeher
+ offen im Quelltext. */
+ const VORGABE = document.body.dataset.rolle || "";
+
/* ---------- Rolle wählen ------------------------------------------ */
function waehle(rolle, fokus = false) {
@@ -161,7 +186,7 @@
braucht ihn dort nicht: Er prueft den verborgenen Zugang
VOR dem gewohnten Weg, und ein leerer Name faellt danach in
dieselbe Antwort wie ein erfundener. */
- body: JSON.stringify({ rolle: gewaehlt || '', code: wert }),
+ body: JSON.stringify({ rolle: gewaehlt || VORGABE, code: wert }),
credentials: 'same-origin',
});
diff --git a/workspace/assets/js/rechte.js b/workspace/assets/js/rechte.js
new file mode 100644
index 00000000..918698a8
--- /dev/null
+++ b/workspace/assets/js/rechte.js
@@ -0,0 +1,121 @@
+/* =====================================================================
+ „Wer sieht was" — die Rechtetafel, gezeichnet (11.09.2026)
+
+ Dieses Skript ENTSCHEIDET nichts. Es holt die Tafel vom Server und
+ zeichnet sie. Jede Zahl und jeder Punkt darin kommt aus rechte.js —
+ derselben Datei, aus der auch die Schranke ihre Antwort holt.
+
+ WARUM DAS WICHTIG IST: Eine Übersicht, die im Browser gebaut wird
+ („Rolle X darf wohl Y, weil …"), wäre eine zweite Meinung über die
+ Rechte — und ausgerechnet die, der man glaubt, weil sie wie eine
+ Auskunft aussieht. Sie würde altern, ohne dass man es ihr ansieht.
+ ===================================================================== */
+(() => {
+ const $ = (id) => document.getElementById(id);
+ const el = (t, k, text) => {
+ const n = document.createElement(t);
+ if (k) n.className = k;
+ if (text !== undefined) n.textContent = text;
+ return n;
+ };
+
+ const melde = (t) => {
+ const f = $('fehler');
+ if (f) { f.textContent = t; f.hidden = !t; }
+ };
+
+ (async () => {
+ let r;
+ try {
+ const a = await fetch('/workspace/api/rechte/tafel', { credentials: 'same-origin' });
+ if (a.status === 401) { location.assign('/workspace/'); return; }
+ /* 404 heißt hier: diese Seite gibt es für dich nicht. Die
+ Schranke hätte schon vorher umgeleitet; kommt es trotzdem
+ hierher, ist die Startseite die richtige Antwort — und nicht
+ eine Fehlermeldung, die verrät, dass es die Auskunft gibt. */
+ if (a.status === 404) { location.assign('/workspace/start.html'); return; }
+ if (!a.ok) { melde('Die Tafel konnte nicht geladen werden.'); return; }
+ r = await a.json();
+ } catch {
+ melde('Die Tafel konnte nicht geladen werden.');
+ return;
+ }
+
+ const gesamt = r.seiten.length;
+
+ /* ---- Die Zahlen je Rolle ---------------------------------------
+ Sortiert nach Anzahl, absteigend. Eine alphabetische Reihenfolge
+ hätte dieselben Zahlen gezeigt und die Frage nicht beantwortet:
+ Man sieht hier auf einen Blick, wer viel sieht und wer wenig —
+ und ob irgendwo eine Rolle steht, die mehr sieht, als sie sollte. */
+ const zahlen = $('zahlen');
+ if (zahlen) {
+ zahlen.textContent = '';
+ for (const rolle of [...r.rollen].sort((a, b) => b.seiten - a.seiten)) {
+ const li = document.createElement('li');
+ li.append(rolle.name + ' ');
+ const b = document.createElement('b');
+ b.textContent = `${rolle.seiten} von ${gesamt}`;
+ li.append(b);
+ zahlen.append(li);
+ }
+ }
+
+ /* ---- Die Tafel --------------------------------------------------- */
+ const ziel = $('tafel');
+ if (ziel) {
+ ziel.textContent = '';
+ const tab = document.createElement('table');
+
+ const kopf = document.createElement('thead');
+ const kz = document.createElement('tr');
+ kz.append(el('th', null, 'Seite'));
+ for (const rolle of r.rollen) {
+ const th = el('th', null, rolle.name);
+ /* Der Schlüssel als Titel: Im Alltag liest man „Community",
+ im Code steht „gast". Wer die beiden nicht zusammenbringen
+ kann, sucht im Quelltext an der falschen Stelle. */
+ th.title = rolle.schluessel;
+ kz.append(th);
+ }
+ kopf.append(kz);
+ tab.append(kopf);
+
+ const koerper = document.createElement('tbody');
+ for (const seite of r.seiten) {
+ const tr = document.createElement('tr');
+ tr.append(el('td', null, seite.datei));
+ for (const rolle of r.rollen) {
+ const td = document.createElement('td');
+ if (seite.rollen.includes(rolle.schluessel)) {
+ const punkt = el('span', 'rechte-ja');
+ punkt.setAttribute('role', 'img');
+ punkt.setAttribute('aria-label', 'darf');
+ td.append(punkt);
+ } else {
+ /* Ein Zeichen und nicht ein leeres Feld: Ein Bildschirmleser
+ liest eine leere Zelle als nichts vor — man hört dann
+ nicht, dass die Antwort „nein" ist, sondern gar nichts. */
+ const nein = el('span', 'rechte-nein', '–');
+ nein.setAttribute('aria-label', 'darf nicht');
+ td.append(nein);
+ }
+ tr.append(td);
+ }
+ koerper.append(tr);
+ }
+ tab.append(koerper);
+ ziel.append(tab);
+ ziel.setAttribute('aria-busy', 'false');
+ }
+
+ /* ---- Ohne Anmeldung --------------------------------------------- */
+ const offen = $('offen');
+ if (offen) {
+ offen.textContent = '';
+ for (const pfad of r.ohne_anmeldung) {
+ offen.append(el('li', null, pfad));
+ }
+ }
+ })();
+})();
diff --git a/workspace/assets/js/treff-moderation.js b/workspace/assets/js/treff-moderation.js
new file mode 100644
index 00000000..12bdf6a2
--- /dev/null
+++ b/workspace/assets/js/treff-moderation.js
@@ -0,0 +1,312 @@
+/* =====================================================================
+ Meldungen & Maßnahmen im Treff (11.09.2026)
+
+ Drei Listen und ein Knopf. Die Regeln stehen alle auf dem Server —
+ wer eine Maßnahme verhängen darf, wie lange ein Modi sperren kann,
+ ob eine Meldung schon entschieden ist. Diese Datei zeichnet nur und
+ fragt nach.
+
+ WARUM HIER KEINE RECHTEPRÜFUNG STEHT: Sie stünde dann zweimal, und
+ die Fassung im Browser kann jeder ändern. Was hier passiert, ist
+ höchstens ein Knopf, der gar nicht erst erscheint — das ist
+ Bequemlichkeit, keine Sicherung. Die Sicherung antwortet mit 403.
+ ===================================================================== */
+(() => {
+ const $ = (id) => document.getElementById(id);
+ const el = (t, k, text) => {
+ const n = document.createElement(t);
+ if (k) n.className = k;
+ if (text !== undefined) n.textContent = text;
+ return n;
+ };
+ const melde = (t) => { const f = $('fehler'); if (f) { f.textContent = t; f.hidden = !t; } };
+
+ const datum = (iso) => {
+ if (!iso) return '';
+ const d = new Date(iso);
+ return Number.isNaN(d.getTime()) ? '' : d.toLocaleDateString('de-DE',
+ { day: 'numeric', month: 'short', year: 'numeric' });
+ };
+
+ async function hole(pfad, optionen = {}) {
+ const a = await fetch(pfad, { credentials: 'same-origin', ...optionen });
+ if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); }
+ if (a.status === 404) { location.assign('/workspace/start.html'); throw new Error('unbekannt'); }
+ return a;
+ }
+
+ function knopf(text, tu) {
+ const b = el('button', 'schritt', text);
+ b.type = 'button';
+ b.addEventListener('click', async () => {
+ b.disabled = true; melde('');
+ try { await tu(); } catch { /* umgeleitet */ } finally { b.disabled = false; }
+ });
+ return b;
+ }
+
+ /* Eine Karte für alle drei Listen. Derselbe Aufbau, damit man die
+ Seite nicht dreimal neu lesen lernen muss. */
+ function karte(kopfText, zeilen, knoepfe = []) {
+ const k = el('article', 'treff-fall');
+ k.append(el('h3', 'treff-fall__titel', kopfText));
+ for (const [schild, wert] of zeilen) {
+ if (!wert) continue;
+ const p = el('p', 'treff-fall__zeile');
+ p.append(el('span', 'treff-fall__schild', schild));
+ p.append(el('span', null, wert));
+ k.append(p);
+ }
+ if (knoepfe.length) {
+ const reihe = el('div', 'treff-fall__knoepfe');
+ for (const b of knoepfe) reihe.append(b);
+ k.append(reihe);
+ }
+ return k;
+ }
+
+ /* ---- Meldungen ---------------------------------------------------- */
+ async function meldungen() {
+ const ziel = $('meldungen');
+ const a = await hole('/workspace/api/treff/meldungen');
+ if (!a.ok) { ziel.textContent = ''; ziel.append(el('p', 'leise', 'Ging nicht.')); return; }
+ const liste = (await a.json()).meldungen || [];
+ ziel.textContent = '';
+ ziel.setAttribute('aria-busy', 'false');
+
+ const offen = liste.filter((m) => m.status === 'offen');
+ const erledigt = liste.filter((m) => m.status !== 'offen');
+
+ if (!offen.length) {
+ ziel.append(el('p', 'leise', 'Nichts offen. Das ist die gute Nachricht.'));
+ }
+ for (const m of offen) {
+ const wer = m.melder_name
+ ? m.melder_name + (m.melder_rolle_name && m.melder_rolle !== 'gast'
+ ? ' · ' + m.melder_rolle_name : '')
+ : 'jemand';
+ const autor = m.autor_name
+ ? m.autor_name + (m.autor_rolle_name && m.autor_rolle !== 'gast'
+ ? ' · ' + m.autor_rolle_name : '')
+ : 'unbekannt';
+ const k = karte(m.titel || '(Beitrag gelöscht)', [
+ ['Grund', m.grund],
+ ['gemeldet von', wer + ' am ' + datum(m.zeitpunkt)],
+ ['Beitrag von', autor],
+ ['Brett', m.bereich || '–'],
+ ['Text', m.text],
+ ], [
+ knopf('erledigt', () => entscheiden(m.id, 'erledigt')),
+ knopf('nichts dran', () => entscheiden(m.id, 'abgelehnt')),
+ /* Der Weg zum Beitrag selbst. Ohne ihn müsste man die Meldung
+ lesen, sich das Brett merken und dort suchen -- und genau
+ dabei entscheidet man nach Erinnerung statt nach dem, was
+ wirklich dasteht. */
+ ...(m.bereich ? [(() => {
+ const a2 = el('a', 'schritt', 'zum Brett');
+ a2.href = 'bereich.html?b=' + encodeURIComponent(m.bereich);
+ return a2;
+ })()] : []),
+ ]);
+ ziel.append(k);
+ }
+
+ if (erledigt.length) {
+ const h = el('p', 'leise', `${erledigt.length} schon entschieden:`);
+ ziel.append(h);
+ for (const m of erledigt.slice(0, 20)) {
+ ziel.append(karte(m.titel || '(Beitrag gelöscht)', [
+ ['Grund', m.grund],
+ ['Entscheidung', (m.status === 'erledigt' ? 'erledigt' : 'nichts dran')
+ + (m.entscheid ? ' – ' + m.entscheid : '')],
+ ['von', (m.entschieden_name || '') + ' am ' + datum(m.entschieden_am)],
+ ]));
+ }
+ }
+ }
+
+ async function entscheiden(id, status) {
+ const entscheid = prompt(status === 'erledigt'
+ ? 'Was hast du getan?\n\nEin Satz genügt – er wird zur Meldung festgehalten.'
+ : 'Warum ist daran nichts dran?\n\nEin Satz genügt – er wird festgehalten.');
+ if (entscheid === null) return;
+ const a = await hole('/workspace/api/treff/meldung/' + id, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ status, entscheid }),
+ });
+ if (!a.ok) {
+ melde((await a.json().catch(() => ({}))).fehler || 'Ging nicht.');
+ return;
+ }
+ await meldungen();
+ }
+
+ /* ---- Entfernt ----------------------------------------------------- */
+ async function entfernt() {
+ const ziel = $('entfernt');
+ const a = await hole('/workspace/api/treff/entfernt');
+ if (!a.ok) { ziel.textContent = ''; return; }
+ const liste = (await a.json()).entfernt || [];
+ ziel.textContent = '';
+ ziel.setAttribute('aria-busy', 'false');
+ if (!liste.length) { ziel.append(el('p', 'leise', 'Noch nichts entfernt.')); return; }
+ for (const e of liste.slice(0, 50)) {
+ ziel.append(karte(e.titel, [
+ ['Grund', e.grund],
+ ['war von', e.autor_name || 'unbekannt'],
+ ['entfernt von', (e.von_name || '')
+ + (e.von_rolle_name ? ' · ' + e.von_rolle_name : '') + ' am ' + datum(e.zeitpunkt)],
+ ['Brett', e.bereich],
+ ]));
+ }
+ }
+
+ /* ---- Mitglieder ---------------------------------------------------- */
+
+ /* DIE STUFE STEHT NUR HIER. Im Treff selbst taucht sie nirgends auf
+ -- weder neben dem Namen noch an einem Beitrag. Das ist eine
+ bewusste Entscheidung aus dem Plan (Teil H): Eine sichtbare Stufe
+ macht aus einem Schutz eine Rangordnung, und aus „Neu" wird ein
+ Abzeichen, das man los sein will. Für die Moderation ist sie
+ dagegen die wichtigste Angabe überhaupt. */
+ async function mitglieder() {
+ const ziel = $('mitglieder');
+ const a = await hole('/workspace/api/treff/mitglieder');
+ if (!a.ok) { ziel.textContent = ''; return; }
+ const { mitglieder: liste, darf } = await a.json();
+ ziel.textContent = '';
+ ziel.setAttribute('aria-busy', 'false');
+ if (!liste.length) {
+ ziel.append(el('p', 'leise',
+ 'Noch niemand. Zugänge legst du in „Personen & Zugänge" an, Rolle Community.'));
+ return;
+ }
+ const STUFE = { neu: 'Neu', dabei: 'Dabei', stamm: 'Stamm' };
+ for (const m of liste) {
+ const knoepfe = [];
+ if (!m.sperre) {
+ if (darf?.pause) knoepfe.push(knopf('Pause', () => massnahme(m, 'pause', darf)));
+ if (darf?.ausschluss_befristet) {
+ knoepfe.push(knopf('Ausschluss', () => massnahme(m, 'ausschluss', darf)));
+ }
+ knoepfe.push(knopf('Hinweis', () => massnahme(m, 'hinweis', darf)));
+ }
+ const k = karte(m.name, [
+ ['Stufe', STUFE[m.stufe] || m.stufe],
+ ['dabei seit', datum(m.seit)],
+ ['Beiträge', String(m.beitraege)],
+ ['zuletzt da', m.zuletzt ? datum(m.zuletzt) : 'noch nie'],
+ ['gesperrt', m.sperre
+ ? `${m.sperre.art === 'pause' ? 'Pause' : 'Ausschluss'} `
+ + (m.sperre.bis ? 'bis ' + datum(m.sperre.bis) : 'dauerhaft')
+ + ' – ' + m.sperre.grund
+ : ''],
+ ], knoepfe);
+ if (m.sperre) k.dataset.vorbei = 'ja';
+ ziel.append(k);
+ }
+ }
+
+ /* EIN VORGANG, ZWEI FRAGEN — und die Reihenfolge ist wichtig.
+
+ Zuerst der GRUND, dann die Dauer. Umgekehrt wäre bequemer zu
+ tippen und falsch herum gedacht: Wer zuerst „wie lange?" gefragt
+ wird, hat die Strafe schon festgelegt, bevor er sie begründet hat.
+ So steht am Anfang die Frage, was eigentlich passiert ist. */
+ async function massnahme(m, art, darf) {
+ const grund = prompt(art === 'hinweis'
+ ? `Hinweis an ${m.name} – worum geht es?`
+ : `${art === 'pause' ? 'Pause' : 'Ausschluss'} für ${m.name} – warum?\n\n`
+ + 'Der Grund wird festgehalten, und die Person hat ein Recht darauf.');
+ if (grund === null) return;
+
+ let tage;
+ if (art !== 'hinweis') {
+ const hinweis = darf?.frist_tage
+ ? `Du kannst bis zu ${darf.frist_tage} Tage geben.`
+ : 'Leer lassen = dauerhaft (nur bei Ausschluss).';
+ const ein = prompt(`Für wie viele Tage?\n\n${hinweis}`,
+ darf?.frist_tage ? String(darf.frist_tage) : '7');
+ if (ein === null) return;
+ tage = ein.trim() === '' ? null : Number(ein);
+ if (tage !== null && !(tage > 0)) { melde('Das ist keine Anzahl Tage.'); return; }
+ if (tage === null && art === 'pause') { melde('Eine Pause braucht eine Dauer.'); return; }
+ }
+
+ const a = await hole('/workspace/api/treff/massnahme', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ person_id: m.id, art, grund, tage }),
+ });
+ if (!a.ok) {
+ melde((await a.json().catch(() => ({}))).fehler || 'Ging nicht.');
+ return;
+ }
+ await mitglieder();
+ await massnahmen();
+ }
+
+ /* ---- Maßnahmen ---------------------------------------------------- */
+ async function massnahmen() {
+ const ziel = $('massnahmen');
+ const a = await hole('/workspace/api/treff/massnahmen');
+ if (!a.ok) { ziel.textContent = ''; return; }
+ const liste = (await a.json()).massnahmen || [];
+ ziel.textContent = '';
+ ziel.setAttribute('aria-busy', 'false');
+ if (!liste.length) { ziel.append(el('p', 'leise', 'Noch keine Maßnahme.')); return; }
+
+ const NAME = { hinweis: 'Hinweis', pause: 'Pause', ausschluss: 'Ausschluss' };
+ for (const m of liste.slice(0, 50)) {
+ const laeuft = !m.aufgehoben_am
+ && (!m.bis || Date.parse(m.bis) > Date.now());
+ const k = karte(`${NAME[m.art] || m.art} · ${m.person_name || 'unbekannt'}`, [
+ ['Grund', m.grund],
+ ['gilt', m.aufgehoben_am ? 'aufgehoben am ' + datum(m.aufgehoben_am)
+ : m.bis ? 'bis ' + datum(m.bis) : 'dauerhaft'],
+ ['von', (m.von_name || '') + (m.von_rolle_name ? ' · ' + m.von_rolle_name : '')
+ + ' am ' + datum(m.zeitpunkt)],
+ ], laeuft && m.art !== 'hinweis' ? [
+ knopf('aufheben', async () => {
+ if (!confirm(`Maßnahme gegen ${m.person_name} aufheben?`)) return;
+ const r = await hole('/workspace/api/treff/massnahme/' + m.id, { method: 'DELETE' });
+ if (!r.ok) {
+ melde((await r.json().catch(() => ({}))).fehler || 'Ging nicht.');
+ return;
+ }
+ await massnahmen();
+ }),
+ ] : []);
+ if (!laeuft) k.dataset.vorbei = 'ja';
+ ziel.append(k);
+ }
+ }
+
+ /* ---- Die Leiter in Worten ------------------------------------------
+ Die Zahlen kommen vom Server, aus demselben Grund wie im Regeltext:
+ Eine Frist, die in der Oberfläche anders steht als im Code, ist
+ schlimmer als gar keine Angabe. */
+ async function leiter() {
+ try {
+ const a = await fetch('/workspace/api/treff/regeln', { credentials: 'same-origin' });
+ if (!a.ok) return;
+ const r = await a.json();
+ $('leiter').textContent =
+ `Hinweis, dann Pause, dann Ausschluss – in dieser Reihenfolge. Ein Modi kann `
+ + `bis zu ${r.modi_frist_tage} Tage geben; alles darüber und jeder dauerhafte `
+ + `Ausschluss ist DogFathers Entscheidung. Wer eine dauerhafte Maßnahme nicht `
+ + `verhängen darf, kann sie auch nicht aufheben.`;
+ } catch { /* dann bleibt "…" stehen, und das sieht man */ }
+ }
+
+ (async () => {
+ try {
+ await leiter();
+ await meldungen();
+ await entfernt();
+ await mitglieder();
+ await massnahmen();
+ } catch { /* umgeleitet */ }
+ })();
+})();
diff --git a/workspace/assets/js/treff-regeln.js b/workspace/assets/js/treff-regeln.js
new file mode 100644
index 00000000..556a3881
--- /dev/null
+++ b/workspace/assets/js/treff-regeln.js
@@ -0,0 +1,82 @@
+/* =====================================================================
+ Die Zahlen im Regeltext (11.09.2026)
+
+ Dieses Skript schreibt nichts dazu -- es setzt nur die Zahlen ein,
+ die im Text stehen muessen: Fristen, Stufen, Mindestalter, wie lange
+ ein Modi eine Pause geben darf.
+
+ WARUM UEBERHAUPT EIN SKRIPT FUER FUENF ZAHLEN
+
+ Weil sie sonst zweimal existieren wuerden: einmal als Regel im Code
+ (DABEI_TAGE, STAMM_TAGE, MINDESTALTER ...) und einmal als Wort in
+ der HTML-Datei. Beim naechsten Nachjustieren waere eine von beiden
+ geaendert worden, und der Regeltext haette etwas anderes versprochen
+ als der Server tut. Das faellt niemandem auf -- und wer es bemerkt,
+ glaubt dem Text, nicht dem Verhalten.
+
+ Ein Regeltext, der sich selbst widerspricht, ist ausserdem rechtlich
+ das Gegenteil dessen, wofuer er da ist.
+
+ WENN DIE ANTWORT AUSBLEIBT, bleibt "…" stehen. Das ist Absicht: Eine
+ fehlende Zahl sieht man, eine falsche nicht.
+ ===================================================================== */
+(() => {
+ const $ = (id) => document.getElementById(id);
+
+ const melde = (t) => {
+ const f = $('fehler');
+ if (f) { f.textContent = t; f.hidden = !t; }
+ };
+
+ (async () => {
+ let r;
+ try {
+ const a = await fetch('/workspace/api/treff/regeln', { credentials: 'same-origin' });
+ if (a.status === 401) { location.assign('/workspace/'); return; }
+ if (!a.ok) { melde('Die Zahlen konnten nicht geladen werden. Der Text gilt trotzdem.'); return; }
+ r = await a.json();
+ } catch {
+ melde('Die Zahlen konnten nicht geladen werden. Der Text gilt trotzdem.');
+ return;
+ }
+
+ /* Die drei Stufen -- aus der Antwort gebaut, nicht aus drei festen
+ Zeilen. Wer eine vierte Stufe einbaut, bekommt sie hier von
+ selbst; eine feste Liste haette sie unterschlagen, ohne dass
+ irgendwo etwas rot wird. */
+ const liste = $('stufen');
+ if (liste && Array.isArray(r.stufen)) {
+ const was = {
+ neu: 'Lesen und bei Wünschen mitstimmen.',
+ dabei: `Ab ${r.dabei_tage} Tagen: Du kannst im Treff schreiben, `
+ + 'Wünsche einstellen und dich bei „Mitmachen“ melden.',
+ stamm: `Ab ${r.stamm_tage} Tagen und ${r.stamm_beitraege} eigenen Beiträgen: `
+ + 'Du kannst Clips und Bilder einstellen – sie erscheinen, sobald '
+ + 'jemand aus dem Team sie freigegeben hat.',
+ };
+ liste.textContent = '';
+ for (const s of r.stufen) {
+ const li = document.createElement('li');
+ const stark = document.createElement('strong');
+ stark.textContent = s.name;
+ li.append(stark, ' – ', was[s.schluessel] || '');
+ liste.append(li);
+ }
+ }
+
+ if ($('alter')) {
+ $('alter').textContent =
+ `Mindestalter: ${r.mindestalter} Jahre. Wer jünger ist, hat hier nichts `
+ + 'verloren – nicht weil wir es kompliziert machen wollen, sondern weil '
+ + 'der Treff zu einem Stream für Erwachsene gehört.';
+ }
+
+ if ($('ausschluss')) {
+ $('ausschluss').textContent =
+ `Ein Modi kann eine Pause von bis zu ${r.modi_frist_tage} Tagen geben. `
+ + 'Alles, was länger gilt, und jeder endgültige Ausschluss ist DogFathers '
+ + 'Entscheidung. Jede dieser Entscheidungen wird mit Grund festgehalten – '
+ + 'auch, damit man sie zurücknehmen kann.';
+ }
+ })();
+})();
diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html
index 8905a186..24c3d637 100644
--- a/workspace/aufgaben.html
+++ b/workspace/aufgaben.html
@@ -12,19 +12,19 @@
Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite
eine Flaeche sind statt zweier. -->
-
-
-
-
-
-
+
+
+
+
+
+
-
+
@@ -252,10 +252,10 @@
-
-
-
-
-
+
+
+
+
+