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 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index cb4372a2..a92509de 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -147,10 +147,10 @@ - - - - - + + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index 8be62dad..82742759 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -181,14 +181,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index 6b1d16ce..1b96687d 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -89,11 +89,11 @@ - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index bc8c500c..61fd7926 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -12,26 +12,26 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -341,10 +341,10 @@ - - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index 6b200d1c..41b6925f 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -179,12 +179,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/crew-index.html b/workspace/crew-index.html index 333226a8..e5846454 100644 --- a/workspace/crew-index.html +++ b/workspace/crew-index.html @@ -30,15 +30,15 @@ - - - + + + - + @@ -252,6 +252,6 @@ - + diff --git a/workspace/crew.webmanifest b/workspace/crew.webmanifest index eb2f0776..bb8fe385 100644 --- a/workspace/crew.webmanifest +++ b/workspace/crew.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/crew-192.png?v=202609111617", + "src": "/assets/img/app-symbole/crew-192.png?v=202609111736", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-512.png?v=202609111617", + "src": "/assets/img/app-symbole/crew-512.png?v=202609111736", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609111617", + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609111736", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609111617", + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609111736", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/dateien.html b/workspace/dateien.html index 1728a7f3..d926684c 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -108,10 +108,10 @@ - - - - - + + + + + diff --git a/workspace/index.html b/workspace/index.html index ab135d52..1894fd06 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -30,9 +30,9 @@ - - - + + + @@ -401,6 +401,6 @@ - + diff --git a/workspace/kalender.html b/workspace/kalender.html index 80c6a041..f832a0bd 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -377,11 +377,11 @@ - - - - - - + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index 42c6efc7..29d88836 100644 --- a/workspace/leistung.html +++ b/workspace/leistung.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -382,10 +382,10 @@ - - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index c186d22e4..598f7fca 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -270,6 +270,33 @@ + + + + + + + + +
@@ -396,10 +423,10 @@
- - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index b48a3224..f10d8293 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -214,11 +214,11 @@ - - - - - - + + + + + + diff --git a/workspace/rechte.html b/workspace/rechte.html new file mode 100644 index 00000000..eb9d280b --- /dev/null +++ b/workspace/rechte.html @@ -0,0 +1,125 @@ + + + + + +Wer sieht was · Creator Workspace + + + + + + + + + + + + + + + + + +
+ +

Creator Workspace · Wer sieht was

+
+ … + +
+
+ +
+ +
+
+

Rechte · nur für dich

+

Wer sieht was

+

+ Nicht aufgeschrieben, sondern ausgerechnet – aus derselben Tafel, + aus der die Schranke ihre Entscheidung holt. +

+
+
+ + + + + +
+

Wie viele Seiten je Rolle

+
+

+ Das ist die Zahl, um die es geht. Eine neue Rolle startet bei + null – sie bekommt nichts, bis es jemand einträgt. +

+
+
    +
  • wird geladen …
  • +
+
+ +
+

Die ganze Tafel

+
+

+ Ein Punkt heißt: diese Rolle darf diese Seite öffnen. Leer heißt + nein – und zwar auch dann, wenn niemand je darüber nachgedacht hat. + Das ist der Unterschied zu vorher. +

+
+
+

wird geladen …

+
+
+ +
+

Ohne Anmeldung

+
+

+ Diese Seiten sind offen – man kann sich schlecht anmelden, wenn die + Anmeldeseite eine Anmeldung verlangt. Jede andere Seite ist zu. +

+
  • wird geladen …
+
+
+ +
+ + + + + + + + diff --git a/workspace/report.html b/workspace/report.html index d2c7d1b9..37dda6fe 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -106,11 +106,11 @@ - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 6d1ccc85..eeb49b61 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -173,10 +173,10 @@ - - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index 1736cc80..d9b5921a 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -12,21 +12,21 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - + + - + @@ -674,10 +674,10 @@

- - - - - + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index ad08e922..9f998064 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -85,11 +85,11 @@ - - - - - - + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index e9717271..9e7bd332 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -126,10 +126,10 @@ - - - - - + + + + + diff --git a/workspace/team.html b/workspace/team.html index 50ee4f0a..4aa28ea9 100644 --- a/workspace/team.html +++ b/workspace/team.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + @@ -110,10 +110,10 @@ - - - - - + + + + + diff --git a/workspace/teamlage.html b/workspace/teamlage.html index d0e447b3..a993dbef 100644 --- a/workspace/teamlage.html +++ b/workspace/teamlage.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + - - - - + + + + diff --git a/workspace/treff-index.html b/workspace/treff-index.html new file mode 100644 index 00000000..4fd0a57f --- /dev/null +++ b/workspace/treff-index.html @@ -0,0 +1,163 @@ + + + + + +Anmeldung · Der Treff + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+

+ DogFather Universe +

+

Der Treff

+

Schön, dass du da bist. Gib deinen Code ein.

+
+ +
+ + +
+ + +
+ + + + + +
+ + +

+ Verschlüsselte Verbindung · Zugriffe werden protokolliert +

+
+
+ + + + diff --git a/workspace/treff-moderation.html b/workspace/treff-moderation.html new file mode 100644 index 00000000..9ac44e97 --- /dev/null +++ b/workspace/treff-moderation.html @@ -0,0 +1,118 @@ + + + + + +Meldungen & Maßnahmen · Der Treff + + + + + + + + + + + + + + + + + +
+ +

Der Treff · Meldungen

+
+ … + +
+
+ +
+ +
+
+

Der Treff · Moderation

+

Meldungen & Maßnahmen

+

+ Was gemeldet wurde, was entfernt wurde und mit welcher Begründung – + alles an einer Stelle. +

+
+
+ + + + + +
+

Offene Meldungen

+
+

+ Zu jeder Entscheidung gehört ein Satz. Er wird festgehalten – + auch damit man später weiß, warum etwas so entschieden wurde. +

+
+

wird geladen …

+
+ +
+

Entfernt

+
+

+ Was entfernt wurde, und warum. Die Begründung überlebt den Beitrag – + das ist der Sinn der Sache + (Digital Services Act, Artikel 17). +

+
+

wird geladen …

+
+ +
+

Die Mitglieder

+
+

+ Wer da ist, seit wann, auf welcher Stufe – und wer gerade eine Pause + hat. Von hier aus wird gehandelt; die Stufe steht nur hier und + nie neben dem Namen im Treff. +

+
+

wird geladen …

+
+ +
+

Hinweise, Pausen, Ausschlüsse

+
+

…

+
+

wird geladen …

+
+ +
+ + + + + + + diff --git a/workspace/treff-regeln.html b/workspace/treff-regeln.html new file mode 100644 index 00000000..4aa85f98 --- /dev/null +++ b/workspace/treff-regeln.html @@ -0,0 +1,198 @@ + + + + + +Regeln & Hilfe · Der Treff + + + + + + + + + + + + + + + + + + +
+ +

Der Treff · Regeln

+
+ … + +
+
+ +
+ +
+
+

Der Treff

+

Regeln & Hilfe

+

+ Kurz, in normaler Sprache, und alles, was hier steht, gilt auch. +

+
+
+ + + + +
+

Schön, dass du da bist

+
+

+ Der Treff ist der Ort rund um DogFather: Hier steht, was gerade gilt, + was ansteht, und hier redet ihr miteinander. Er ist nicht öffentlich – + du bist hier, weil dir jemand einen Code gegeben hat. +

+

+ Gib diesen Code nicht weiter. Er gehört zu dir, und was damit + geschrieben wird, steht unter deinem Namen. +

+
+
+ +
+

Die fünf Regeln

+
+
    +
  1. Sei anständig. Keine Beleidigungen, keine Angriffe + auf Menschen, keine Hetze. Streiten über eine Sache ist in Ordnung – + über einen Menschen nicht.
  2. +
  3. Keine privaten Daten. Keine Adressen, keine + Telefonnummern, keine Klarnamen von anderen. Auch nicht von dir + selbst, es liest mehr mit, als du denkst.
  4. +
  5. Nichts Fremdes hochladen. Was du einstellst, muss + von dir sein oder dir gehören.
  6. +
  7. Keine Werbung, kein Verkauf. Auch nicht für + andere Streams.
  8. +
  9. Was hier passiert, bleibt hier. Keine + Bildschirmfotos nach draußen.
  10. +
+
+
+ +
+

Wie du weiterkommst

+
+

+ In der ersten Zeit kannst du lesen und bei Wünschen mitstimmen. + Das ist kein Misstrauen gegen dich – es ist eines gegen + Wegwerf-Zugänge. +

+
    +
  • wird geladen …
  • +
+
+
+ +
+

Wenn etwas nicht stimmt

+
+

+ An jedem Beitrag, der nicht von dir ist, steht melden. + Schreib in einem Satz, was daran nicht in Ordnung ist – ohne das + können wir es nicht prüfen. +

+

+ Wir sehen uns jede Meldung an. Wird ein Beitrag entfernt, halten wir + fest, warum – und die Person, die ihn geschrieben hat, + erfährt den Grund. Das ist keine Höflichkeit, sondern + Artikel 17 des Digital Services Act. +

+

+ Du erreichst uns außerdem direkt: Schreib es auf das Brett + Der Treff oder sag es einem Modi. +

+

+ Häufige Fragen und Antworten stehen auf dem Brett + Regeln & Hilfe. +

+
+
+ +
+

Was passiert, wenn jemand sich nicht daran hält

+
+

Wir gehen der Reihe nach vor, und niemand fliegt ohne Ansage:

+
    +
  1. Hinweis. Jemand sagt dir, was nicht ging.
  2. +
  3. Pause. Ein paar Tage nichts schreiben. Lesen + geht weiter.
  4. +
  5. Ende. Der Zugang wird geschlossen.
  6. +
+

…

+
+
+ +
+

Das Kleingedruckte – kurz

+
+

…

+

+ Was gespeichert wird: dein Name, dein Zugangscode + (verschlüsselt, niemand kann ihn lesen), was du schreibst, und wann + du dich anmeldest. Mehr nicht – kein Standort, keine Werbe-IDs, + keine Weitergabe an Dritte. +

+

+ Du willst weg? Sag Bescheid. Dein Zugang wird + geschlossen und deine Beiträge werden entfernt. +

+

+ Der Treff ist ein kleiner, geschlossener Bereich. Es gibt keine + Direktnachrichten zwischen Mitgliedern – absichtlich: Ein + öffentlicher Raum lässt sich moderieren, ein privater nicht. +

+
+
+ +
+ + + + + + + + diff --git a/workspace/treff.webmanifest b/workspace/treff.webmanifest new file mode 100644 index 00000000..640714c9 --- /dev/null +++ b/workspace/treff.webmanifest @@ -0,0 +1,40 @@ +{ + "name": "Der Treff", + "short_name": "Der Treff", + "description": "Der Treffpunkt rund um DogFather: Ansagen, Termine, Wuensche und das Gespraech. Nur mit persoenlichem Zugangscode.", + "start_url": "/workspace/start.html", + "id": "/workspace/start.html", + "scope": "/workspace/", + "display": "standalone", + "orientation": "any", + "lang": "de", + "dir": "ltr", + "background_color": "#0a121e", + "theme_color": "#06090f", + "icons": [ + { + "src": "/assets/img/app-symbole/crew-192.png?v=202609111736", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/assets/img/app-symbole/crew-512.png?v=202609111736", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609111736", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609111736", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index fc64bc09..add8ed1c 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -12,22 +12,22 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -149,11 +149,11 @@ - + - - - - + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index b44acb08..65dc9f53 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -190,10 +190,10 @@ - - - - - + + + + +