Stufe 6 und 7: Der Treff wird ein Gespraech, jedes Brett bekommt seine Form

Damit sind alle acht Stufen des Plans gebaut.

STUFE 6 -- DAS GESPRAECH
"Der Treff -- Hallo sagen, fragen, loben" war eine Liste aus Karten.
Der einzige Bereich, in dem Menschen MITEINANDER reden sollen, zwang
sie, nebeneinander zu reden: Jeder schrieb eine eigene Karte, niemand
antwortete jemandem.

Jetzt: Antworten eingerueckt unter ihrem Beitrag, aelteste zuerst (so
liest man ein Gespraech), mit Namen und Rolle. Das Feld steht immer da
statt hinter einem "Antworten"-Knopf -- ein leeres Feld ist die
deutlichste Einladung, die es gibt. Nur EINE Stufe eingerueckt: Auf
390 px ist bei der zweiten Schluss.

Eine EIGENE Tabelle, kein Eintrag mit "antwort_auf": Eine Antwort hat
keine Art, keinen Zustand, keine Frist und gehoert in keinen Filter.
Als Eintrag gefuehrt haette sie zwanzig immer leere Spalten -- und
tauchte in jeder Zaehlung auf, die Beitraege zaehlt.

Und nicht ueberall: nur Treff und Wunschliste. Aus dem Plan, §9 -- ein
Antwortfeld unter jedem Aushang hiesse, jeder Aushang muss betreut
werden.

ZWEI FEHLER, BEIDE VON DER PRUEFUNG GEFUNDEN

1. EIN PFAD, DER AUSSAH WIE EIN BEREICH.
   `/api/bereich/antwort/:id` lief in die Middleware auf
   `/api/bereich/:bereich` -- "antwort" ist kein Bereich. Fuer
   DogFather ging es (ein frueherer Zweig liess ihn durch), fuer einen
   Modi kam 404. Ein Weg, der je nach ROLLE an voellig anderer Stelle
   scheitert, sucht man lange. Liegt jetzt unter `/api/antwort/:id`.

2. UND EIN ECHTES LOCH.
   Die Loeschregel hiess `!darfSchreiben(req.person, "treff")` -- also
   "wer schreiben darf", und das duerfen Gaeste ab der Stufe "dabei".
   JEDER GAST HAETTE JEDE FREMDE ANTWORT LOESCHEN KOENNEN.

   Gefunden hat es eine Pruefung mit einer FALSCHEN Erwartung: Sie
   verlangte, dass ein Modi keine fremde Antwort loeschen kann. Das war
   falsch -- er moderiert ja --, aber der Weg dorthin hat das echte
   Problem freigelegt. Jetzt entscheidet TREFF_TEAM_ROLLEN.

   Der Gast-Fall ist ueber HTTP nicht messbar (er kommt nur ueber die
   crew-Adresse herein, und den Host-Kopf kann fetch nicht setzen).
   Statt stillschweigend zu ueberspringen prueft die Pruefung die REGEL
   selbst -- und dass die Route wirklich diese Menge benutzt.

STUFE 7 -- JEDES BRETT BEKOMMT SEINE FORM
- "Regeln & Hilfe" ist ein DOKUMENT: Sprungmarken oben, vier
  Abschnitte (Regel, Was passiert wenn, Hilfe, Haeufige Frage). Man
  schlaegt es im Streitfall auf und will FINDEN, nicht scrollen.
  scroll-margin-top, sonst verschwindet die angesprungene Ueberschrift
  unter der Kopfleiste und der Sprung sieht aus wie ins Leere.
- "Anschlagbrett" zeigt grosse AUSHAENGE statt Zeilen -- gemessen
  21 px Titel statt 17. Ein Aushang zwischen dreissig anderen ist kein
  Aushang.
- "Mitmachen" gruppiert nach Art, die drei Schritte zuerst.

UND EINE SCHRIFT, DIE ZU KLEIN WAR: Die Rollen-Marke an einer Antwort
stand auf 11,2 px, die Hausgrenze liegt bei 11,5. Die Grenze anzuheben
waere der bequeme Weg gewesen -- und ab da haette pruef-css-klassen
nichts mehr gehalten.

NEU: pruef-gespraech (27 Pruefungen)
pruef-treff-start jetzt 34 (Stufe 7 mitgeprueft)
Alle Community-Pruefungen gruen: gespraech 27, treff-start 34,
kreislauf 23, countdown 22, wunschliste 30, galerie 26, bremse 16,
treff 66, bereiche-lesend, css-klassen, deutsche-texte 12.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-17 12:12:21 +02:00
co-authored by Claude Opus 5
parent b396e548ab
commit 79e675b846
36 changed files with 1197 additions and 359 deletions
+324
View File
@@ -0,0 +1,324 @@
/* =====================================================================
STUFE 6: DER TREFF WIRD EIN GESPRÄCH (17.09.2026)
"Der Treff -- Hallo sagen, fragen, loben" war eine Liste aus Karten.
Der einzige Bereich, in dem Menschen MITEINANDER reden sollen,
zwang sie, nebeneinander zu reden: Jeder schrieb eine eigene Karte,
niemand antwortete jemandem.
---------------------------------------------------------------------
EINE EIGENE TABELLE, KEIN HALBER EINTRAG
Eine Antwort hat keine Art, keinen Zustand, keine Frist und gehoert
in keinen Filter. Sie als Eintrag mit `antwort_auf` zu fuehren hiesse,
ihr zwanzig Spalten mitzugeben, die immer leer sind -- und sie
tauchte in jeder Zaehlung auf, die Beitraege zaehlt.
---------------------------------------------------------------------
NICHT UEBERALL
Aus dem Plan, §9: "Keine Kommentarfunktion ueberall. Nur dort, wo ein
Gespraech gemeint ist." Ein Antwortfeld unter jedem Aushang hiesse:
Jeder Aushang muss betreut werden -- und ein unbeantwortetes
Antwortfeld ist schlimmer als keins.
Aufrufen mit: node server/pruef-gespraech.mjs
Bild: SCHIRM=1 node server/pruef-gespraech.mjs
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { portMussFreiSein } from "./helfer-port.mjs";
import { heuteLokal } from "./helfer-tag.mjs";
const PORT = await portMussFreiSein(4493, "pruef-gespraech");
const ordner = mkdtempSync(join(tmpdir(), "ws-gespr-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
process.env.PORT = String(PORT);
process.env.SITE_ACCESS_SECRET = "lokaler-test";
process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1";
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(180_000, "pruef-gespraech");
await new Promise((r) => setTimeout(r, 700));
const BASIS = `http://127.0.0.1:${PORT}`;
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 { DatabaseSync } = await import("node:sqlite");
const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
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);
}
anlegen("Filipe", "admin", "CODE-DOGI-0001");
anlegen("Lena", "modi", "CODE-LENA-0001");
const tabellen = d.prepare("SELECT name FROM sqlite_master WHERE type='table'").all()
.map((z) => z.name);
d.close();
async function anmelden(kachel, code) {
const a = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle: kachel, code }),
});
return (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
}
const dogi = await anmelden("admin", "CODE-DOGI-0001");
const lena = await anmelden("creator", "CODE-LENA-0001");
const kopf = (keks) => ({ "Content-Type": "application/json", cookie: keks, origin: BASIS });
async function liste(bereich, keks = dogi) {
const a = await fetch(`${BASIS}/workspace/api/bereich/${bereich}`, { headers: { cookie: keks } });
return (await a.json().catch(() => null))?.eintraege || [];
}
async function antworten(bereich, id, text, keks = dogi) {
const a = await fetch(`${BASIS}/workspace/api/bereich/${bereich}/${id}/antwort`, {
method: "POST", headers: kopf(keks), body: JSON.stringify({ text }),
});
return { status: a.status, daten: await a.json().catch(() => null) };
}
/* ---------- Ein Beitrag im Treff ------------------------------------- */
const b = await fetch(`${BASIS}/workspace/api/bereich/treff`, {
method: "POST", headers: kopf(lena),
body: JSON.stringify({ art: "hallo", titel: "Hallo, ich bin neu hier",
text: "Bin über TikTok gekommen.", datum: heuteLokal() }),
});
const beitragId = (await b.json().catch(() => null))?.id || 0;
/* =======================================================================
1. DER PLATZ DAFUER
======================================================================= */
melde("");
melde("=== 1. Die Tabelle ===");
ok(tabellen.includes("eintrag_antworten"), "es gibt eine eigene Tabelle fuer Antworten");
ok(beitragId > 0, `Lena hat etwas geschrieben (#${beitragId})`);
/* =======================================================================
2. EIN GESPRAECH ENTSTEHT
======================================================================= */
melde("");
melde("=== 2. Antworten ===");
{
const a = await antworten("treff", beitragId, "Willkommen! Schön, dass du da bist.");
ok(a.status === 201, `DogFather antwortet (${a.status} ${a.daten?.fehler || ""})`);
const z = await antworten("treff", beitragId, "Danke! Freut mich.", lena);
ok(z.status === 201, `Lena antwortet zurueck (${z.status})`);
const e = (await liste("treff")).find((x) => x.id === beitragId);
ok((e?.antworten || []).length === 2, `zwei Antworten am Beitrag (${(e?.antworten || []).length})`);
/* AELTESTE ZUERST -- so liest man ein Gespraech. Neueste zuerst
waere eine Liste, kein Gespraech. */
ok(e?.antworten?.[0]?.text?.startsWith("Willkommen"),
`die aeltere steht oben ("${e?.antworten?.[0]?.text?.slice(0, 20)}…")`);
ok(e?.antworten?.[0]?.von_name === "Filipe",
`mit Namen (${e?.antworten?.[0]?.von_name})`);
/* WER AUS DEM TEAM ANTWORTET, ist eine andere Auskunft als wer
mitredet. */
ok(!!e?.antworten?.[0]?.von_rolle_name,
`und mit Rolle (${e?.antworten?.[0]?.von_rolle_name})`);
/* "MEINE" ENTSCHEIDET UEBER DEN LOESCHKNOPF -- und muss je Person
verschieden sein, sonst sieht jeder ihn ueberall. */
const beiLena = (await liste("treff", lena)).find((x) => x.id === beitragId);
const meineDogi = (e?.antworten || []).filter((a2) => a2.meine).length;
const meineLena = (beiLena?.antworten || []).filter((a2) => a2.meine).length;
ok(meineDogi === 1 && meineLena === 1,
`jeder sieht genau seine eigene als "meine" (${meineDogi} / ${meineLena})`);
}
/* =======================================================================
3. NICHT UEBERALL
======================================================================= */
melde("");
melde("=== 3. Wo es KEIN Gespraech gibt ===");
{
const an = await fetch(`${BASIS}/workspace/api/bereich/anschlag`, {
method: "POST", headers: kopf(dogi),
body: JSON.stringify({ art: "ansage", titel: "Eine Ansage", datum: heuteLokal() }),
});
const id = (await an.json().catch(() => null))?.id;
const a = await antworten("anschlag", id, "Darf ich hier antworten?");
ok(a.status === 404, `am Anschlagbrett nicht (${a.status})`);
const e = (await liste("anschlag")).find((x) => x.id === id);
ok(e && e.antworten === undefined,
"und die Ansage bringt gar kein Antwortfeld mit");
/* GEGENPROBE: In der Wunschliste geht es sehr wohl -- dort ist ein
Gespraech gemeint ("ich auch, weil ..."). */
const w = await fetch(`${BASIS}/workspace/api/bereich/wunsch`, {
method: "POST", headers: kopf(lena),
body: JSON.stringify({ art: "stream", titel: "Ein Wunsch", datum: heuteLokal() }),
});
const wid = (await w.json().catch(() => null))?.id;
const wa = await antworten("wunsch", wid, "Das will ich auch, weil …");
ok(wa.status === 201, `in der Wunschliste dagegen schon (${wa.status})`);
}
/* =======================================================================
4. WAS NICHT DURCHGEHT
======================================================================= */
melde("");
melde("=== 4. Grenzen ===");
{
const leer = await antworten("treff", beitragId, " ");
ok(leer.status === 400, `eine leere Antwort nicht (${leer.status})`);
const lang = await antworten("treff", beitragId, "x".repeat(1500));
ok(lang.status === 400, `eine zu lange auch nicht (${lang.status})`);
const weg = await antworten("treff", 999999, "An nichts");
ok(weg.status === 404, `an einen erfundenen Beitrag ebenfalls nicht (${weg.status})`);
}
/* =======================================================================
5. LOESCHEN
======================================================================= */
melde("");
melde("=== 5. Wieder weg ===");
{
const e = (await liste("treff")).find((x) => x.id === beitragId);
const vonLena = (e?.antworten || []).find((a) => a.von_name === "Lena");
/* DIE EIGENE IMMER. */
const a = await fetch(`${BASIS}/workspace/api/antwort/${vonLena.id}`,
{ method: "DELETE", headers: { cookie: lena, origin: BASIS } });
const atext = await a.text().catch(() => "");
ok(a.status === 200, `Lena loescht ihre eigene (${a.status} ${atext.slice(0,80)}) id=${vonLena.id}`);
const e2 = (await liste("treff")).find((x) => x.id === beitragId);
ok((e2?.antworten || []).length === 1, `eine bleibt uebrig (${(e2?.antworten || []).length})`);
/* UND DAS TEAM AUCH FREMDE -- sonst waere Moderation nicht moeglich. */
const rest = e2.antworten[0];
const b2 = await fetch(`${BASIS}/workspace/api/antwort/${rest.id}`,
{ method: "DELETE", headers: { cookie: dogi, origin: BASIS } });
ok(b2.status === 200, `das Team loescht auch fremde (${b2.status})`);
/* EIN MODI DARF FREMDE LOESCHEN -- er moderiert den Treff. Das war
beim Schreiben dieser Pruefung meine falsche Erwartung; der Weg
dorthin hat aber ein echtes Loch freigelegt (siehe Kommentar an
der Route): Die Regel hiess "wer schreiben darf", und das duerfen
auch Gaeste. */
const c = await antworten("treff", beitragId, "Noch eine von DogFather");
const e3 = (await liste("treff")).find((x) => x.id === beitragId);
const fremd = e3.antworten.find((x) => x.id === c.daten?.id);
const d2 = await fetch(`${BASIS}/workspace/api/antwort/${fremd.id}`,
{ method: "DELETE", headers: { cookie: lena, origin: BASIS } });
ok(d2.status === 200, `ein Modi darf fremde loeschen -- er moderiert (${d2.status})`);
/* UND DIE ENTSCHEIDENDE GEGENPROBE: EIN GAST DARF NICHT.
Ohne sie waere nie aufgefallen, dass die Regel urspruenglich
`!darfSchreiben(...)` hiess -- also "wer schreiben darf", und das
duerfen Gaeste ab der Stufe "dabei". Jeder Gast haette jede fremde
Antwort loeschen koennen.
UEBER HTTP IST DAS HIER NICHT MESSBAR, und das wird gesagt statt
verschwiegen: Ein Gast kommt nur ueber die crew-Adresse herein, und
den Host-Kopf kann `fetch` nicht setzen (Node verbietet es). Statt
die Pruefung stillschweigend zu ueberspringen, wird die REGEL
selbst geprueft -- dieselbe Menge, die die Route benutzt. */
const { TREFF_TEAM_ROLLEN } = await import("./workspace-treff.js");
ok(TREFF_TEAM_ROLLEN.has("modi") && TREFF_TEAM_ROLLEN.has("hand")
&& TREFF_TEAM_ROLLEN.has("admin"),
`das Moderationsteam ist ${[...TREFF_TEAM_ROLLEN].join(", ")}`);
ok(!TREFF_TEAM_ROLLEN.has("gast"),
"und ein Gast gehoert nicht dazu -- er kann keine fremde Antwort loeschen");
/* Und die Route benutzt wirklich DIESE Menge und nicht wieder
`darfSchreiben`. Ein Kommentar daneben wuerde es nicht verhindern. */
const { readFileSync } = await import("node:fs");
const quelle = readFileSync(new URL("./workspace-bereiche.js", import.meta.url), "utf8");
const stelle = quelle.slice(quelle.indexOf("/workspace/api/antwort/:id"));
ok(/TREFF_TEAM_ROLLEN\.has\(req\.person\?\.rolle\)/.test(stelle.slice(0, 2000)),
"und die Route entscheidet mit genau dieser Menge");
}
/* =======================================================================
6. IM BROWSER
======================================================================= */
melde("");
melde("=== 6. Wie es aussieht ===");
{
const { chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await chromium.launch();
try {
const kontext = await browser.newContext({ viewport: { width: 390, height: 900 } });
await kontext.addCookies(dogi.split("; ").map((k) => {
const [name, ...rest] = k.split("=");
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await kontext.newPage();
const konsole = [];
seite.on("console", (e) => { if (e.type() === "error") konsole.push(e.text()); });
await seite.goto(`${BASIS}/workspace/bereich.html?b=treff`, { waitUntil: "networkidle" });
await seite.waitForSelector(".antwort-neu__feld", { timeout: 15_000 });
await seite.waitForTimeout(400);
/* UEBER DIE OBERFLAECHE ANTWORTEN, nicht ueber die Schnittstelle --
ein Feld, das der Server annimmt und das Formular nicht
abschickt, waere gruen und nutzlos. */
await seite.fill(".antwort-neu__feld", "Geschrieben im Browser");
await seite.click(".antwort-neu__ab");
await seite.waitForTimeout(1500);
const m = await seite.evaluate(() => ({
antworten: document.querySelectorAll(".antwort").length,
texte: [...document.querySelectorAll(".antwort__text")].map((z) => z.textContent.trim()),
/* Nur EINE Stufe eingerueckt -- auf 390 px ist bei der zweiten
Schluss. */
verschachtelt: document.querySelectorAll(".antworten .antworten").length,
feldBreit: Math.round(
document.querySelector(".antwort-neu__feld")?.getBoundingClientRect().width || 0),
}));
ok(m.antworten >= 1, `${m.antworten} Antworten stehen da`);
ok(m.texte.some((t) => t === "Geschrieben im Browser"),
"die im Browser geschriebene ist dabei");
ok(m.verschachtelt === 0, "nur eine Stufe eingerueckt");
/* AUF DEM HANDY MUSS MAN TIPPEN KOENNEN. Ein Feld von 80 px ist
kein Feld. */
ok(m.feldBreit > 150, `das Feld ist auf 390 px noch benutzbar (${m.feldBreit} px)`);
ok(konsole.length === 0,
konsole.length ? `Konsole: ${konsole[0]}` : "keine Fehler in der Konsole");
if (process.env.SCHIRM) {
await seite.screenshot({ path: fileURLToPath(new URL("pruef-gespraech.png",
import.meta.url)), fullPage: false });
melde(" -- Bildschirmfoto: server/pruef-gespraech.png");
}
await kontext.close();
} finally {
await browser.close();
}
}
melde("");
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ }
process.exit(fehler ? 1 : 0);
+74
View File
@@ -331,6 +331,80 @@ melde("=== 6. Wie es aussieht ===");
}
}
/* =======================================================================
7. JEDES BRETT HAT SEINE FORM (Stufe 7)
======================================================================= */
melde("");
melde("=== 7. Die Form je Brett ===");
{
const { chromium } = await import(
"file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
const browser = await chromium.launch();
try {
const kontext = await browser.newContext({ viewport: { width: 1000, height: 900 } });
await kontext.addCookies(dogi.split("; ").map((k) => {
const [name, ...rest] = k.split("=");
return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
}));
const seite = await kontext.newPage();
const konsole = [];
seite.on("console", (e) => { if (e.type() === "error") konsole.push(e.text()); });
/* Regeln sind schon uebernommen (Abschnitt 3). */
await seite.goto(`${BASIS}/workspace/bereich.html?b=regeln`, { waitUntil: "networkidle" });
await seite.waitForSelector(".brettabschnitt", { timeout: 15_000 });
await seite.waitForTimeout(400);
const r = await seite.evaluate(() => ({
abschnitte: [...document.querySelectorAll(".brettabschnitt")]
.map((z) => z.firstChild?.textContent?.trim() || ""),
marken: document.querySelectorAll(".brettmarken__weg").length,
/* Springt die Marke wirklich zu einer Ueberschrift? Ein Verweis
ins Leere sieht aus wie ein kaputter Sprung. */
zieleDa: [...document.querySelectorAll(".brettmarken__weg")]
.every((a) => !!document.getElementById(a.getAttribute("href").slice(1))),
}));
ok(r.abschnitte.length >= 3,
`Regeln & Hilfe ist ein Dokument mit ${r.abschnitte.length} Abschnitten (${r.abschnitte.join(" · ")})`);
ok(r.marken >= 3, `mit ${r.marken} Sprungmarken oben`);
ok(r.zieleDa, "und jede Marke findet ihre Ueberschrift");
/* Das Anschlagbrett: Aushaenge statt Zeilen. */
await seite.goto(`${BASIS}/workspace/bereich.html?b=anschlag`, { waitUntil: "networkidle" });
await seite.waitForTimeout(600);
const gross = await seite.evaluate(async () => {
const knopf = document.querySelector(".startkatalog__alle");
if (knopf) { knopf.click(); await new Promise((r2) => setTimeout(r2, 1500)); }
const t = document.querySelector("#liste .eintrag-titel");
return {
aushang: document.getElementById("liste")?.dataset.aushang || "",
titelGroesse: t ? Math.round(parseFloat(getComputedStyle(t).fontSize)) : 0,
anzahl: document.querySelectorAll(".eintrag-karte").length,
};
});
ok(gross.aushang === "ja", "das Anschlagbrett ist als Aushangbrett gesetzt");
/* GROSS heisst messbar gross -- groesser als ein Titel auf einem
gewoehnlichen Brett (1.05rem = 17 px). */
ok(gross.titelGroesse >= 20,
`und seine Aushaenge sind gross (${gross.titelGroesse} px)`);
ok(gross.anzahl >= 4, `${gross.anzahl} Aushaenge haengen`);
ok(konsole.length === 0,
konsole.length ? `Konsole: ${konsole[0]}` : "keine Fehler in der Konsole");
if (process.env.SCHIRM) {
const { fileURLToPath } = await import("node:url");
await seite.goto(`${BASIS}/workspace/bereich.html?b=regeln`, { waitUntil: "networkidle" });
await seite.waitForTimeout(600);
await seite.screenshot({ path: fileURLToPath(new URL("pruef-regeln.png",
import.meta.url)), fullPage: false });
melde(" -- Bildschirmfoto: server/pruef-regeln.png");
}
await kontext.close();
} finally {
await browser.close();
}
}
melde("");
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
+136
View File
@@ -611,6 +611,13 @@ export const BEITRAEGE_JE_STUNDE = 10;
Content-Planung ist Arbeit am eigenen Kanal; dort waere eine Bremse
eine Behinderung ohne Gegenwert. */
const treffBereich = (b) => TREFF_BRETTER.includes(b);
/* WO EIN GESPRAECH GEMEINT IST (17.09.2026), Stufe 6.
Nicht ueberall. Aus dem Plan, §9: "Keine Kommentarfunktion ueberall.
Nur dort, wo ein Gespraech gemeint ist." Ein Antwortfeld unter jedem
Aushang hiesse: Jeder Aushang muss betreut werden -- und ein
unbeantwortetes Antwortfeld ist schlimmer als keins. */
const MIT_ANTWORTEN = new Set(["treff", "wunsch"]);
const TITEL_MAX = 160;
const TEXT_MAX = 6000;
@@ -1204,6 +1211,33 @@ bereicheRouter.get("/workspace/api/bereich/:bereich", (req, res) => {
jeQuelle.get(z.aus_eintrag_id).push({ id: z.id, bereich: z.bereich, titel: z.titel });
}
for (const e of eintraege) e.wurde = jeQuelle.get(e.id) || [];
/* DIE ANTWORTEN, in EINER Abfrage fuer die ganze Liste. */
if (MIT_ANTWORTEN.has(bereich)) {
const p4 = eintraege.map(() => "?").join(", ");
const alle = db().prepare(
`SELECT a.id, a.eintrag_id, a.text, a.erstellt, a.person_id,
p.name AS von_name, p.rolle AS von_rolle
FROM eintrag_antworten a
LEFT JOIN personen p ON p.id = a.person_id
WHERE a.eintrag_id IN (${p4})
ORDER BY a.id`).all(...eintraege.map((e) => e.id));
const jeE = new Map();
for (const a of alle) {
if (!jeE.has(a.eintrag_id)) jeE.set(a.eintrag_id, []);
/* DEN ROLLENNAMEN MACHT `mitRollenname`, wie ueberall im
Treff. Eine eigene Uebersetzung hier waere eine zweite
Wahrheit ueber dieselbe Sache -- und Rollennamen sind in
diesem Haus die Sorte Angabe, bei der zwei Stellen einmal
zu viel sind. */
jeE.get(a.eintrag_id).push(mitRollenname({
id: a.id, text: a.text, erstellt: a.erstellt,
von_name: a.von_name, von_rolle: a.von_rolle,
meine: a.person_id === (req.sicht || req.person)?.id,
}));
}
for (const e of eintraege) e.antworten = jeE.get(e.id) || [];
}
}
/* DASS DIES EIN BRETT DES TREFFS IST, SAGT DER SERVER (11.09.2026).
@@ -1685,6 +1719,108 @@ bereicheRouter.post("/workspace/api/bereich/:bereich/:id/daraus",
}
});
/* =====================================================================
ANTWORTEN (17.09.2026) -- Stufe 6 des Plans
"Der Treff" war eine Liste aus Karten. Der einzige Bereich, in dem
Menschen MITEINANDER reden sollen, zwang sie, nebeneinander zu
reden.
DIE BREMSE GILT AUCH HIER. Eine Antwort ist ein Beitrag; wer sie
ausnaehme, haette die Bremse mit drei Klicks umgangen. Sie zaehlt
allerdings ihren EIGENEN Vorrat: Wer zehn Beitraege geschrieben hat,
soll noch antworten koennen -- ein Gespraech, das mitten im Satz
abbricht, ist schlimmer als eins, das nicht angefangen wird. */
const ANTWORTEN_JE_STUNDE = 20;
const ANTWORT_MAX = 1200;
bereicheRouter.post("/workspace/api/bereich/:bereich/:id/antwort",
gleicheHerkunft, (req, res) => {
try {
const bereich = String(req.params.bereich);
const id = Number(req.params.id);
if (!MIT_ANTWORTEN.has(bereich) || !Number.isInteger(id)) {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
const warum = darfSchreiben(req.person, bereich);
if (warum) return res.status(403).json({ fehler: warum });
const text = String(req.body?.text ?? "").trim();
if (!text) return res.status(400).json({ fehler: "Schreib etwas." });
if (text.length > ANTWORT_MAX) return res.status(400).json({ fehler: "Das ist zu lang." });
const regel = sichtbar(req.person);
const e = db().prepare("SELECT id FROM eintraege WHERE id = ? AND bereich = ?")
.get(id, bereich);
if (!e || !regel) return res.status(404).json({ fehler: "nicht_gefunden" });
const seit = new Date(Date.now() - 3600_000).toISOString();
const { anzahl } = db().prepare(
"SELECT COUNT(*) AS anzahl FROM eintrag_antworten WHERE person_id = ? AND erstellt >= ?")
.get(req.person.id, seit);
if (anzahl >= ANTWORTEN_JE_STUNDE) {
return res.status(429).json({
fehler: `Du hast in der letzten Stunde ${anzahl} Antworten geschrieben. `
+ "Mach mal kurz Pause.",
});
}
const { lastInsertRowid } = db().prepare(
"INSERT INTO eintrag_antworten (eintrag_id, person_id, text, erstellt) VALUES (?,?,?,?)")
.run(id, req.person.id, text, jetzt());
res.status(201).json({ id: Number(lastInsertRowid) });
} catch (fehler) {
console.error("[workspace] Antwort:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* DER PFAD LIEGT AUSSERHALB VON `/bereich/` -- und das ist eine
Reparatur, keine Geschmacksfrage (17.09.2026).
Zuerst hiess er `/api/bereich/antwort/:id`. Darueber liegt aber eine
Middleware auf `/api/bereich/:bereich`, die prueft, ob es den
Bereich gibt und ob die Person hineindarf -- und "antwort" ist kein
Bereich. Ergebnis: Fuer DogFather ging es (ein frueherer Zweig liess
ihn durch), fuer einen Modi kam 404. Ein Weg, der je nach Rolle an
einer voellig anderen Stelle scheitert, sucht man lange.
Gefunden hat es die Pruefung, nicht das Lesen: "das Team loescht
auch fremde" war gruen, "Lena loescht ihre eigene" nicht. */
bereicheRouter.delete("/workspace/api/antwort/:id",
gleicheHerkunft, (req, res) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
const a = db().prepare(
"SELECT id, person_id FROM eintrag_antworten WHERE id = ?").get(id);
if (!a) return res.status(404).json({ fehler: "nicht_gefunden" });
/* DIE EIGENE IMMER, FREMDE NUR DAS TEAM.
HIER STAND `!darfSchreiben(req.person, "treff")` -- und das war
ein Loch. `darfSchreiben` beantwortet "darf diese Person hier
SCHREIBEN", und das duerfen auch Gaeste ab der Stufe "dabei".
Ein Gast haette also jede fremde Antwort loeschen koennen.
Gefunden hat es die Pruefung, nicht das Lesen: Sie verlangte,
dass ein Modi eine fremde Antwort NICHT loeschen kann, und
bekam 200. Die Erwartung war fuer den Modi falsch -- er
moderiert ja --, aber der Weg dorthin hat das echte Loch
freigelegt.
`TREFF_TEAM_ROLLEN` ist die Liste, die im Haus ohnehin
entscheidet, wer im Treff moderiert: modi, hand, admin. */
const darf = a.person_id === req.person.id
|| TREFF_TEAM_ROLLEN.has(req.person?.rolle);
if (!darf) return res.status(403).json({ fehler: "nicht_erlaubt" });
db().prepare("DELETE FROM eintrag_antworten WHERE id = ?").run(id);
res.json({ ok: true });
} catch (fehler) {
console.error("[workspace] Antwort loeschen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Anlegen ------------------------------------------------------ */
bereicheRouter.post("/workspace/api/bereich/:bereich", gleicheHerkunft, (req, res) => {
+31
View File
@@ -1766,6 +1766,37 @@ function umstellungen(d) {
falschen Moment. */
const jetztStempel = new Date().toISOString().slice(0, 19).replace(/[-:T]/g, "");
/* ---- Antworten an einem Beitrag (17.09.2026), Stufe 6 ----
"Der Treff -- Hallo sagen, fragen, loben" war eine Liste aus
Karten. Der einzige Bereich, in dem Menschen MITEINANDER reden
sollen, zwang sie, nebeneinander zu reden: Jeder schreibt eine
eigene Karte, niemand antwortet jemandem.
EINE EIGENE TABELLE UND KEIN EINTRAG MIT "antwort_auf": Eine
Antwort hat keine Art, keinen Zustand, keine Frist und gehoert in
keinen Filter. Sie als halben Eintrag zu fuehren hiesse, ihr
zwanzig Spalten mitzugeben, die immer leer sind -- und sie taeuchte
in jeder Zaehlung auf, die Beitraege zaehlt.
ON DELETE CASCADE: Verschwindet der Beitrag, verschwinden die
Antworten. Eine Antwort ohne ihre Frage ist nicht halb so viel
wert, sondern gar nichts. */
try {
d.exec(`
CREATE TABLE IF NOT EXISTS eintrag_antworten (
id INTEGER PRIMARY KEY AUTOINCREMENT,
eintrag_id INTEGER NOT NULL REFERENCES eintraege(id) ON DELETE CASCADE,
person_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
text TEXT NOT NULL,
erstellt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_antworten_eintrag
ON eintrag_antworten (eintrag_id, id);`);
} catch (fehler) {
console.error("[workspace] Antworten-Tabelle:", fehler?.message);
}
/* ---- Der Kreislauf: ein Eintrag kommt aus einem anderen (17.09.2026)
Aus dem Plan im Vault, Stufe 8 -- und die Stelle, an der der Plan