diff --git a/server/mess-kopfleiste.mjs b/server/mess-kopfleiste.mjs
new file mode 100644
index 00000000..d4c4f271
--- /dev/null
+++ b/server/mess-kopfleiste.mjs
@@ -0,0 +1,148 @@
+/* =====================================================================
+ WIE DIE KOPFLEISTE AUF DEM HANDY STEHT -- UND WAS IM MENUE LANDET
+ =====================================================================
+
+ Filipe, 09.09.2026: „es soll alles in einer reihe sein."
+
+ Seit dem 25.09.2026 sorgt kopf.js dafuer, indem es MISST statt zu
+ rechnen: Passt die Leiste nicht in eine Zeile, wandert der naechste
+ Kandidat in ein Mehr-Menue. Diese Messung zeigt, was dabei
+ herauskommt -- je Breite, mit Bild.
+
+ SIE PRUEFT NICHTS. `pruef-handy` misst die Spanne der Oberkanten und
+ wird rot, wenn zwei Zeilen entstehen. Hier geht es um das, was man
+ nur sieht: Steht der Mehr-Knopf am richtigen Platz? Geht das Fach
+ auf? Sind die Woerter darin lesbar?
+
+ Aufrufen mit: node server/mess-kopfleiste.mjs
+ ===================================================================== */
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const PORT = 5471;
+const ordner = mkdtempSync(join(tmpdir(), "ws-kopf-"));
+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, "mess-kopfleiste");
+await new Promise((r) => setTimeout(r, 900));
+
+const BASIS = `http://127.0.0.1:${PORT}`;
+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 sch = randomBytes(32).toString("hex");
+d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
+ .run("code_kennung_schluessel", sch, jetzt);
+const anlegen = (name, rolle, code) => {
+ const s = randomBytes(16).toString("hex");
+ const h = scryptSync(code, s, 64,
+ { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+ return d.prepare("INSERT INTO personen"
+ + " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ + " VALUES (?,?,?,?,?,?,1,?) RETURNING id")
+ .get(name, rolle, h, s, 32768,
+ createHmac("sha256", sch).update(code).digest("hex"), jetzt).id;
+};
+anlegen("Filipe", "admin", "CODE-DOGI-0001");
+anlegen("Rieke", "hand", "CODE-HAND-0001");
+d.close();
+
+const an = await fetch(BASIS + "/workspace/api/anmelden", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ rolle: "admin", code: "CODE-DOGI-0001" }),
+});
+const keks = (an.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; ");
+
+const pw = await import(
+ "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+const browser = await pw.chromium.launch();
+
+for (const [wie, breite] of [["320", 320], ["360", 360], ["390", 390],
+ ["412", 412], ["768", 768], ["1280", 1280]]) {
+ const kontext = await browser.newContext({
+ viewport: { width: breite, height: 860 },
+ deviceScaleFactor: 2, isMobile: breite < 700, hasTouch: breite < 700,
+ });
+ await kontext.addCookies(keks.split("; ").map((k) => {
+ const [n, ...r] = k.split("=");
+ return { name: n, value: r.join("="), domain: "127.0.0.1", path: "/" };
+ }));
+ const seite = await kontext.newPage();
+ await seite.goto(BASIS + "/workspace/start.html", { waitUntil: "networkidle" });
+ await seite.waitForTimeout(1200);
+
+ const lage = await seite.evaluate(() => {
+ const leiste = document.querySelector(".kopfleiste__rechts");
+ const fach = document.getElementById("kopf-mehr-fach");
+ const knopf = document.getElementById("kopf-mehr");
+ const namen = (n) => [...n.children]
+ .filter((k) => k.id !== "kopf-mehr-fach")
+ .map((k) => `${String(k.className).split(" ")[0] || k.tagName.toLowerCase()}`
+ + `(${Math.round(k.getBoundingClientRect().width)}x`
+ + `${Math.round(k.getBoundingClientRect().height)})`);
+ const oben = [...leiste.children]
+ .filter((k) => k.id !== "kopf-mehr-fach" && !k.hidden
+ && k.getBoundingClientRect().height > 0)
+ .map((k) => Math.round(k.getBoundingClientRect().top));
+ return {
+ inDerLeiste: namen(leiste),
+ imFach: fach ? namen(fach) : [],
+ mehrKnopf: knopf ? (knopf.hidden ? "versteckt" : "da") : "gibt es nicht",
+ spanne: oben.length ? Math.max(...oben) - Math.min(...oben) : 0,
+ hoehe: Math.round(document.querySelector(".kopfleiste").getBoundingClientRect().height),
+ };
+ });
+
+ console.log(`\n=== ${wie} px ===`);
+ console.log(` Leiste (${lage.spanne} px Spanne, Kopf ${lage.hoehe} px hoch):`);
+ console.log(` ${lage.inDerLeiste.join(" ")}`);
+ console.log(` Mehr-Knopf: ${lage.mehrKnopf}`);
+ console.log(` im Fach: ${lage.imFach.join(" ") || "nichts"}`);
+
+ /* Und einmal aufmachen -- ein Menue, das nicht aufgeht, ist schlimmer
+ als kein Menue. */
+ if (lage.mehrKnopf === "da") {
+ await seite.click("#kopf-mehr", { timeout: 4000 })
+ .catch((f) => console.log(" KLICK SCHEITERT: " + String(f.message).split(String.fromCharCode(10))[0].slice(0, 120)));
+ await seite.waitForTimeout(400);
+ const auf = await seite.evaluate(() => {
+ const f = document.getElementById("kopf-mehr-fach");
+ if (!f || f.hidden) return null;
+ const r = f.getBoundingClientRect();
+ return {
+ kasten: `${Math.round(r.width)}x${Math.round(r.height)} bei ${Math.round(r.left)}`,
+ ausDemBild: r.right > window.innerWidth + 1 || r.left < -1,
+ woerter: [...f.children].map((k) => (k.textContent || "").trim().slice(0, 16)),
+ zuKlein: [...f.children]
+ .filter((k) => k.getBoundingClientRect().height < 44)
+ .map((k) => (k.textContent || "").trim().slice(0, 12)),
+ };
+ });
+ if (!auf) console.log(" FACH GEHT NICHT AUF");
+ else {
+ console.log(` Fach offen: ${auf.kasten}`
+ + (auf.ausDemBild ? " STEHT AUS DEM BILD" : ""));
+ console.log(` ${auf.woerter.join(" | ")}`);
+ if (auf.zuKlein.length) console.log(` ZU KLEIN: ${auf.zuKlein.join(", ")}`);
+ await seite.screenshot({ path: `server/mess-kopf-${wie}.png` }).catch(() => {});
+ }
+ }
+ await kontext.close();
+}
+
+await browser.close();
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+process.exit(0);
diff --git a/server/pruef-installieren.mjs b/server/pruef-installieren.mjs
index 483ffa5e..8770ef9f 100644
--- a/server/pruef-installieren.mjs
+++ b/server/pruef-installieren.mjs
@@ -338,16 +338,43 @@ try {
await cs.click("button[type=submit]");
await cs.waitForURL("**/start.html", { timeout: 25000 });
await cs.waitForTimeout(1300);
+ /* ==== ER KANN SEIT DEM 25.09.2026 IM MEHR-MENUE STEHEN ==========
+
+ Die Kopfleiste haelt sich seither selbst einreihig: Passt sie
+ nicht in eine Zeile, wandert der naechste Kandidat in ein
+ Mehr-Menue, und der Installieren-Knopf ist der zweite auf dieser
+ Liste. Auf einem iPhone mit 390 px ist er deshalb zu finden --
+ nur eben hinter einem Knopf.
+
+ GEMESSEN WIRD DAMIT, WAS ZAEHLT: Ist er ERREICHBAR und, wenn man
+ ihn sieht, treffbar? Nicht: Steht er an einer bestimmten Stelle.
+ Die alte Fassung verlangte das Zweite und war deshalb rot,
+ obwohl der Knopf zwei Fingertipps entfernt stand.
+
+ ZUERST WIRD AUFGEMACHT, DANN GEMESSEN -- und wenn es kein Menue
+ gibt, bleibt alles wie vorher. */
+ await cs.evaluate(() => {
+ const m = document.getElementById("kopf-mehr");
+ if (m && !m.hidden) m.click();
+ });
+ await cs.waitForTimeout(300);
const beiApple = await cs.evaluate(() => {
const k = document.querySelector(".install-knopf");
- return k ? { sichtbar: !k.hidden, breit: Math.round(k.getBoundingClientRect().width) } : null;
+ if (!k) return null;
+ const r = k.getBoundingClientRect();
+ return {
+ sichtbar: !k.hidden,
+ breit: Math.round(r.width), hoch: Math.round(r.height),
+ imMenue: !!k.closest(".kopf-mehr__fach"),
+ };
});
/* DER WICHTIGSTE FALL: Dort gibt es `beforeinstallprompt` nicht --
der Knopf muss trotzdem dastehen und erklaeren. */
ok(beiApple?.sichtbar === true,
- "auf dem iPhone steht er von Anfang an da (dort fragt der Browser nie)");
- ok((beiApple?.breit || 0) >= 44,
- ` und ist auch dort treffbar (${beiApple?.breit} px breit)`);
+ "auf dem iPhone steht er von Anfang an da (dort fragt der Browser nie)"
+ + (beiApple?.imMenue ? " -- im Mehr-Menue" : ""));
+ ok((beiApple?.breit || 0) >= 44 && (beiApple?.hoch || 0) >= 44,
+ ` und ist auch dort treffbar (${beiApple?.breit} x ${beiApple?.hoch} px)`);
await cs.click(".install-knopf");
await cs.waitForTimeout(700);
diff --git a/server/pruef-pin-fuer-mich.mjs b/server/pruef-pin-fuer-mich.mjs
new file mode 100644
index 00000000..690620fc
--- /dev/null
+++ b/server/pruef-pin-fuer-mich.mjs
@@ -0,0 +1,385 @@
+/* =====================================================================
+ EINEN AUSHANG FUER SICH WEGNEHMEN -- ODER FUER ALLE (25.09.2026)
+ =====================================================================
+
+ Filipe:
+
+ „jeder soll das fixierte individuel für sich lösen können aber
+ niemals so dass es sich für alle löst. außer dogfather macht es
+ oder die rechte hand dan ist es bei jedem weg ansonsten sollen
+ alle anderen rollen es individuell für sich lösen können.
+ dogfather und die rechte hand sollen die option haben für sich
+ selbst oder für alle zu lösen."
+
+ ---------------------------------------------------------------------
+ DREI FRAGEN, UND DIE MITTLERE IST DIE, UM DIE ES GEHT
+
+ 1. Kann jeder den Aushang bei sich wegnehmen? (Und zurueckholen --
+ sonst braeuchte es eine Rueckfrage davor.)
+ 2. BLEIBT ER DABEI BEI ALLEN ANDEREN STEHEN? Das ist der Satz
+ „niemals so dass es sich fuer alle loest", und er laesst sich
+ nur mit ZWEI Anmeldungen messen: Was der eine wegnimmt, muss
+ der andere weiterhin sehen. Eine Pruefung mit einer einzigen
+ Sitzung koennte hier gruen sein und trotzdem falsch.
+ 3. Und duerfen DogFather und die rechte Hand es fuer alle loesen,
+ waehrend alle anderen das nicht koennen?
+
+ ---------------------------------------------------------------------
+ WARUM DAZU EINE VIERTE FRAGE GEHOERT
+
+ Es haengen hoechstens DREI Aushaenge. Nimmt jemand einen fuer sich
+ weg, darf er nicht einfach fehlen -- der naechste muss nachruecken,
+ sonst sieht er zwei, waehrend ein vierter danebenliegt und nie
+ erscheint. Das ist der Unterschied zwischen „ausgeblendet" und
+ „verloren", und man sieht ihn nur, wenn man vier anheftet.
+
+ Aufrufen mit: node server/pruef-pin-fuer-mich.mjs
+ ===================================================================== */
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-pinmich-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+
+const { eigenerPort } = await import("./helfer-port.mjs");
+const PORT = await eigenerPort(import.meta, "die Aushang-Pruefung");
+
+process.env.PORT = `${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(120_000, "pruef-pin-fuer-mich");
+await new Promise((r) => setTimeout(r, 1000));
+process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
+process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
+
+const BASIS = `http://127.0.0.1:${PORT}`;
+const CREW = "crew.dogfather-universe.com";
+
+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++; };
+
+/* ---- Leute ---------------------------------------------------------- */
+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 sch = randomBytes(32).toString("hex");
+d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)"
+ + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING")
+ .run("code_kennung_schluessel", sch, jetzt);
+const anlegen = (name, rolle, code) => {
+ const s = randomBytes(16).toString("hex");
+ const h = scryptSync(code, s, 64,
+ { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
+ return d.prepare("INSERT INTO personen"
+ + " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)"
+ + " VALUES (?,?,?,?,?,?,1,?) RETURNING id")
+ .get(name, rolle, h, s, 32768,
+ createHmac("sha256", sch).update(code).digest("hex"), jetzt).id;
+};
+const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
+const idHand = anlegen("Rieke", "hand", "CODE-HAND-0001");
+const idModi = anlegen("Kessi", "modi", "CODE-MODI-0001");
+const idModi2 = anlegen("Mara", "modi", "CODE-MODI-0002");
+const idFremd = anlegen("Tili", "modi", "CODE-TILI-0001");
+d.close();
+
+/* ==== `node:http` UND NICHT `fetch` ================================
+
+ Diese Pruefung braucht Team-Dogi-Rollen (Modi, rechte Hand), und die
+ gibt es nur auf der CREW-Adresse. Dafuer muss der `Host`-Kopf
+ gesetzt werden -- und genau den laesst `fetch` nicht zu: Er steht
+ auf der Liste der verbotenen Koepfe, undici verwirft ihn stumm.
+ Die Anfrage kam damit auf 127.0.0.1 an, waehrend der `Origin`-Kopf
+ die Crew-Adresse nannte; `gleicheHerkunft` verglich beides und
+ antwortete mit 403 „fremde_herkunft" -- auf JEDE schreibende
+ Anfrage. Im ersten Lauf sah das aus, als sei die neue Route kaputt.
+
+ Mit `node:http` setzt man den Kopf selbst, und beides passt
+ zusammen. Dieselbe Loesung benutzen pruef-anruf und die anderen
+ Pruefungen, die ueber Haeuser hinweg messen. */
+const { request: httpAnfrage } = await import("node:http");
+const roh = (keks, pfad, methode = "GET", koerper = null) =>
+ new Promise((fertig, schief) => {
+ const daten = koerper === null ? null : JSON.stringify(koerper);
+ const a = httpAnfrage({
+ host: "127.0.0.1", port: PORT, path: pfad, method: methode,
+ headers: {
+ Host: CREW,
+ ...(keks ? { Cookie: keks } : {}),
+ Origin: `https://${CREW}`,
+ Referer: `https://${CREW}/workspace/chat.html`,
+ ...(daten ? { "Content-Type": "application/json",
+ "Content-Length": Buffer.byteLength(daten) } : {}),
+ },
+ }, (r) => {
+ let t = ""; r.on("data", (x) => { t += x; });
+ r.on("end", () => fertig({
+ status: r.statusCode, kopf: r.headers, text: t,
+ json: () => { try { return JSON.parse(t); } catch { return {}; } },
+ }));
+ });
+ a.on("error", schief);
+ if (daten) a.write(daten);
+ a.end();
+ });
+
+const hol = async (keks, pfad, wie = {}) =>
+ roh(keks, pfad, wie.method || "GET", wie.body ? JSON.parse(wie.body) : null);
+
+const anmelden = async (rolle, code) => {
+ const a = await roh(null, "/workspace/api/anmelden", "POST", { rolle, code });
+ if (a.status !== 200) { console.log(`ABBRUCH: Anmeldung ${rolle} ${a.status}`); process.exit(2); }
+ return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
+};
+
+const kDogi = await anmelden("admin", "CODE-DOGI-0001");
+const kHand = await anmelden("hand", "CODE-HAND-0001");
+const kModi = await anmelden("modi", "CODE-MODI-0001");
+const kModi2 = await anmelden("modi", "CODE-MODI-0002");
+const kFremd = await anmelden("modi", "CODE-TILI-0001");
+ok(!!kDogi && !!kHand && !!kModi && !!kModi2 && !!kFremd, "fuenf Leute sind angemeldet");
+
+/* ---- Ein Raum mit vier Leuten --------------------------------------- */
+const gr = await hol(kDogi, "/workspace/api/chat/gruppe", {
+ method: "POST", body: JSON.stringify({ name: "Ansagen", mit: [idHand, idModi, idModi2] }),
+});
+const raumId = (gr.json()).raum_id;
+ok(gr.status === 201 && !!raumId, `ein Raum entsteht (${gr.status}, #${raumId})`);
+
+const schreiben = async (keks, text) => {
+ const a = await hol(keks, `/workspace/api/chat/raeume/${raumId}/nachrichten`, {
+ method: "POST", body: JSON.stringify({ text }),
+ });
+ const j = a.json();
+ return j.nachricht?.id || j.id;
+};
+const anheften = (keks, id, an) =>
+ hol(keks, `/workspace/api/chat/nachrichten/${id}/anheften`, {
+ method: "PUT", body: JSON.stringify({ an }),
+ });
+const fuerMich = (keks, id, aus) =>
+ hol(keks, `/workspace/api/chat/nachrichten/${id}/pin-fuer-mich`, {
+ method: "PUT", body: JSON.stringify({ aus }),
+ });
+const aushaenge = async (keks) => {
+ const a = await hol(keks, `/workspace/api/chat/raeume/${raumId}/nachrichten`);
+ const j = a.json();
+ return {
+ ids: (j.angeheftet || []).map((x) => x.id),
+ darfFuerAlle: Object.fromEntries((j.angeheftet || []).map((x) => [x.id, x.darf_fuer_alle])),
+ ausBeiMir: (j.nachrichten || []).filter((n) => n.pin_fuer_mich_aus).map((n) => n.id),
+ darfPinFuerAlle: j.darf_pin_fuer_alle,
+ };
+};
+
+const n1 = await schreiben(kDogi, "Freitag um 20 Uhr geht es los.");
+const a1 = await anheften(kDogi, n1, true);
+ok(a1.status === 200, `DogFather heftet eine Ansage an (${a1.status})`);
+
+/* =======================================================================
+ 1. JEDER NIMMT SIE BEI SICH WEG -- UND NUR BEI SICH
+ ======================================================================= */
+melde("");
+melde("=== 1. Fuer mich wegnehmen ===");
+{
+ const vorher = await aushaenge(kModi);
+ ok(vorher.ids.includes(n1), `der Modi sieht die Ansage (${vorher.ids.join(",")})`);
+
+ const weg = await fuerMich(kModi, n1, true);
+ ok(weg.status === 200, `er nimmt sie bei sich weg (${weg.status})`);
+
+ const beiIhm = await aushaenge(kModi);
+ ok(!beiIhm.ids.includes(n1),
+ `bei ihm haengt sie nicht mehr (${beiIhm.ids.join(",") || "nichts"})`);
+
+ /* DER KERN DES GANZEN AUFTRAGS. Ohne diese zwei Zeilen koennte das
+ „Wegnehmen" in Wahrheit ein Loesen fuer alle sein, und niemand
+ wuerde es bemerken -- bis jemand fragt, wo die Ansage hin ist. */
+ const beiDogi = await aushaenge(kDogi);
+ const beiModi2 = await aushaenge(kModi2);
+ ok(beiDogi.ids.includes(n1), "bei DogFather haengt sie weiter");
+ ok(beiModi2.ids.includes(n1), "und beim zweiten Modi auch");
+
+ ok(beiIhm.ausBeiMir.includes(n1),
+ "die Nachricht selbst weiss, dass ER sie weggenommen hat");
+ ok(!beiModi2.ausBeiMir.includes(n1),
+ " und beim anderen steht das NICHT dran (Gegenprobe)");
+}
+
+/* =======================================================================
+ 2. UND ER HOLT SIE ZURUECK
+ ======================================================================= */
+melde("");
+melde("=== 2. Der Rueckweg ===");
+{
+ const zurueck = await fuerMich(kModi, n1, false);
+ ok(zurueck.status === 200, `er holt sie zurueck (${zurueck.status})`);
+ const jetztWieder = await aushaenge(kModi);
+ ok(jetztWieder.ids.includes(n1), "sie haengt wieder oben");
+ ok(!jetztWieder.ausBeiMir.includes(n1), "und die Marke ist weg");
+
+ /* Noch einmal wegnehmen -- fuer die Pruefungen darunter. Zweimal
+ dasselbe darf keinen Fehler geben (ON CONFLICT DO NOTHING). */
+ await fuerMich(kModi, n1, true);
+ const nochmal = await fuerMich(kModi, n1, true);
+ ok(nochmal.status === 200, `zweimal wegnehmen ist kein Fehler (${nochmal.status})`);
+}
+
+/* =======================================================================
+ 3. FUER ALLE LOESEN DARF NICHT JEDER
+ ======================================================================= */
+melde("");
+melde("=== 3. Fuer alle loesen ===");
+{
+ const modiVersuch = await anheften(kModi, n1, false);
+ ok(modiVersuch.status === 403,
+ `ein Modi kann sie NICHT fuer alle loesen (${modiVersuch.status})`);
+ const grund = modiVersuch.json();
+ ok(/für dich selbst/i.test(grund.fehler || ""),
+ ` und die Absage sagt, was stattdessen geht ("${(grund.fehler || "").slice(0, 60)}")`);
+
+ const nochDa = await aushaenge(kModi2);
+ ok(nochDa.ids.includes(n1), " die Ansage haengt nach dem Versuch weiter");
+
+ const lage = await aushaenge(kModi);
+ ok(lage.darfPinFuerAlle === false, "der Modi bekommt die Wahl gar nicht angeboten");
+ const lageDogi = await aushaenge(kDogi);
+ ok(lageDogi.darfPinFuerAlle === true, "DogFather schon");
+ ok(lageDogi.darfFuerAlle[n1] === true, " und an der Ansage steht es auch");
+}
+
+/* =======================================================================
+ 4. DIE RECHTE HAND DARF ES -- UND DANN IST SIE BEI JEDEM WEG
+ ======================================================================= */
+melde("");
+melde("=== 4. Die rechte Hand loest fuer alle ===");
+{
+ const los = await anheften(kHand, n1, false);
+ ok(los.status === 200, `die rechte Hand loest fuer alle (${los.status})`);
+
+ for (const [wer, keks] of [["DogFather", kDogi], ["Modi 2", kModi2], ["die Hand", kHand]]) {
+ const l = await aushaenge(keks);
+ ok(!l.ids.includes(n1), `bei ${wer} ist sie weg`);
+ }
+}
+
+/* =======================================================================
+ 5. WER SELBST ANGEHEFTET HAT, NIMMT ES AUCH ZURUECK
+ =======================================================================
+
+ Steht nicht in Filipes Satz und ist trotzdem noetig: Es haengen
+ hoechstens drei. Eine Gruppenleitung, die drei angeheftet hat und
+ keinen abnehmen darf, koennte nie wieder etwas anheften. */
+melde("");
+melde("=== 5. Wer anheftet, darf auch abheften ===");
+{
+ /* Der zweite Modi bekommt die Leitung im Raum -- dann darf er
+ anheften. */
+ const dd = new DatabaseSync(process.env.WORKSPACE_DB);
+ dd.prepare("UPDATE chat_teilnehmer SET leitung = 1 WHERE raum_id = ? AND person_id = ?")
+ .run(raumId, idModi2);
+ dd.close();
+
+ const n2 = await schreiben(kModi2, "Kurze Zwischenansage.");
+ const an2 = await anheften(kModi2, n2, true);
+ ok(an2.status === 200, `der zweite Modi darf anheften (${an2.status})`);
+
+ const seins = await aushaenge(kModi2);
+ ok(seins.darfFuerAlle[n2] === true, " und darf seinen eigenen Aushang wieder loesen");
+
+ const fremderAushang = await schreiben(kDogi, "Und noch eine von DogFather.");
+ await anheften(kDogi, fremderAushang, true);
+ const seins2 = await aushaenge(kModi2);
+ ok(seins2.darfFuerAlle[fremderAushang] === false,
+ " aber NICHT den von DogFather (Gegenprobe)");
+
+ const versuch = await anheften(kModi2, fremderAushang, false);
+ ok(versuch.status === 403,
+ ` und der Versuch wird abgewiesen (${versuch.status})`);
+
+ const eigenes = await anheften(kModi2, n2, false);
+ ok(eigenes.status === 200, ` seinen eigenen loest er (${eigenes.status})`);
+}
+
+/* =======================================================================
+ 6. ES HAENGEN DREI -- UND DER VIERTE RUECKT NACH
+ ======================================================================= */
+melde("");
+melde("=== 6. Der naechste rueckt nach ===");
+{
+ /* Aufraeumen: alles loesen, was noch haengt. */
+ const stand = await aushaenge(kDogi);
+ for (const id of stand.ids) await anheften(kDogi, id, false);
+
+ const vier = [];
+ for (let i = 1; i <= 4; i++) vier.push(await schreiben(kDogi, `Ansage ${i}`));
+ for (const id of vier.slice(0, 3)) await anheften(kDogi, id, true);
+
+ const voll = await anheften(kDogi, vier[3], true);
+ ok(voll.status === 409, `der vierte passt nicht drauf (${voll.status})`);
+
+ const dreiDa = await aushaenge(kModi);
+ ok(dreiDa.ids.length === 3, `es haengen drei (${dreiDa.ids.length})`);
+
+ /* Jetzt nimmt der Modi einen fuer sich weg. Der vierte haengt ja
+ nicht -- also darf danach nichts nachruecken, und er sieht ZWEI.
+ Das ist die richtige Antwort: „fuer mich weg" ist kein Platz
+ schaffen. */
+ await fuerMich(kModi, dreiDa.ids[0], true);
+ const zweiDa = await aushaenge(kModi);
+ ok(zweiDa.ids.length === 2,
+ `nach dem Wegnehmen sieht er zwei (${zweiDa.ids.length})`);
+ const beiDogiDrei = await aushaenge(kDogi);
+ ok(beiDogiDrei.ids.length === 3,
+ ` und DogFather weiterhin drei (${beiDogiDrei.ids.length})`);
+
+ /* UND JETZT DER FALL, DER DAS NACHRUECKEN BEWEIST: Vier haengen
+ nicht gleichzeitig -- also loest DogFather einen fuer alle und
+ heftet den vierten an. Der Modi hat einen der drei bei sich
+ weggenommen; er muss trotzdem drei sehen, sobald es vier gibt,
+ von denen einer bei ihm aus ist. */
+ await anheften(kDogi, dreiDa.ids[2], false);
+ await anheften(kDogi, vier[3], true);
+ const nachher = await aushaenge(kModi);
+ ok(nachher.ids.length === 2 && !nachher.ids.includes(dreiDa.ids[0]),
+ `der weggenommene bleibt weg, die anderen ruecken nach `
+ + `(${nachher.ids.length}: ${nachher.ids.join(",")})`);
+}
+
+/* =======================================================================
+ 7. GEGENPROBEN
+ ======================================================================= */
+melde("");
+melde("=== 7. Gegenproben (jede MUSS anschlagen) ===");
+{
+ const stand = await aushaenge(kDogi);
+ const einer = stand.ids[0];
+
+ const fremd = await fuerMich(kFremd, einer, true);
+ ok(fremd.status === 404,
+ `wer nicht im Raum ist, kommt nicht heran (${fremd.status})`);
+
+ const ohne = await roh(null, `/workspace/api/chat/nachrichten/${einer}/pin-fuer-mich`,
+ "PUT", { aus: true });
+ ok(ohne.status === 401, `ohne Anmeldung gar nichts (${ohne.status})`);
+
+ const erfunden = await fuerMich(kDogi, 999999, true);
+ ok(erfunden.status === 404, `eine erfundene Nummer gibt es nicht (${erfunden.status})`);
+}
+
+melde("");
+melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
+melde(fehler === 0 ? "ALLES IN ORDNUNG" : "NICHT IN ORDNUNG");
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+process.exit(fehler === 0 ? 0 : 1);
diff --git a/server/workspace-chat.js b/server/workspace-chat.js
index 77db76a1..ebc40202 100644
--- a/server/workspace-chat.js
+++ b/server/workspace-chat.js
@@ -1685,15 +1685,44 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
`weg_am IS NULL` (eine zurueckgenommene Nachricht bleibt
zurueckgenommen, auch als Aushang). */
const weggeraeumt = weg;
+ /* ==== WAS ICH FUER MICH WEGGENOMMEN HABE (25.09.2026) ==========
+
+ Filipe: „jeder soll das fixierte individuel fuer sich loesen
+ koennen aber niemals so dass es sich fuer alle loest."
+
+ DER VERBUND STEHT HIER UND NICHT IM BROWSER. Eine Liste, die
+ alles schickt und im Browser gefiltert wird, ist eine Liste, die
+ alles schickt -- und der naechste, der sie benutzt, vergisst den
+ Filter. Ausserdem stimmte dann die Grenze von drei nicht mehr:
+ Wer einen Aushang weggenommen hat, saehe zwei statt drei,
+ obwohl ein vierter danebenliegt.
+
+ DESHALB `NOT EXISTS` UND EIN GROESSERES LIMIT: Gesucht wird
+ unter denen, die fuer MICH noch gelten -- und davon die
+ obersten drei. So ruecken die anderen nach, genau wie an einem
+ Brett aus Kork. */
const angeheftet = db().prepare(`
SELECT n.id, n.text, n.erstellt, n.angeheftet_am, n.anhang_art,
- p.name AS von, q.name AS heftete
+ n.angeheftet_von, p.name AS von, q.name AS heftete
FROM chat_nachrichten n
LEFT JOIN personen p ON p.id = n.person_id
LEFT JOIN personen q ON q.id = n.angeheftet_von
WHERE n.raum_id = ? AND n.angeheftet_am IS NOT NULL
AND n.weg_am IS NULL AND n.id > ?
- ORDER BY n.angeheftet_am DESC LIMIT ?`).all(raumId, weggeraeumt, PIN_MAX);
+ AND NOT EXISTS (SELECT 1 FROM chat_pin_aus x
+ WHERE x.nachricht_id = n.id AND x.person_id = ?)
+ ORDER BY n.angeheftet_am DESC LIMIT ?`)
+ .all(raumId, weggeraeumt, req.person.id, PIN_MAX);
+
+ /* UND WELCHE ICH WEGGENOMMEN HABE -- damit das Menue an der
+ Nachricht „wieder oben anzeigen" anbieten kann. Ohne diese Liste
+ waere das Wegnehmen eine Einbahnstrasse, und dann braeuchte es
+ eine Rueckfrage davor. So braucht es keine. */
+ const pinAus = new Set(db().prepare(`
+ SELECT x.nachricht_id AS id FROM chat_pin_aus x
+ JOIN chat_nachrichten n ON n.id = x.nachricht_id
+ WHERE x.person_id = ? AND n.raum_id = ?`)
+ .all(req.person.id, raumId).map((z) => z.id));
res.json({
raum: {
@@ -1713,6 +1742,14 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
assets/js/chat.js bekommt jeder, der die Seite oeffnet. Ein
Ja/Nein verraet nichts ueber die Rollen dahinter. */
darf_anheften: darfAnheften(req.person, raumId),
+ /* WER DARF FUER ALLE LOESEN? Vom Server beantwortet, nicht im
+ Browser nachgerechnet -- dort stuende die Regel ein zweites
+ Mal, und die zweite Fassung waere ab der naechsten Aenderung
+ falsch. Dieselbe Ueberlegung wie bei darf_anheften daneben.
+ Das „oder wer es selbst angeheftet hat" entscheidet die Route
+ je Nachricht; hier steht nur, ob die Wahl ueberhaupt
+ angeboten wird. */
+ darf_pin_fuer_alle: fuehrtTeamDogi(req.person),
/* WER JEDE NACHRICHT LOESCHEN DARF -- vom Server, nicht aus
der Rolle im Browser geraten. assets/js/chat.js bekommt
JEDER, der die Seite oeffnet; ein `rolle === 'hand'` darin
@@ -1786,6 +1823,21 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
seit: a.angeheftet_am,
text: String(a.text || "").slice(0, 200)
|| anhangWort(a.anhang_art),
+ /* DARF ICH DIESEN HIER FUER ALLE LOESEN? (25.09.2026)
+
+ Je Aushang und nicht je Person, weil die Antwort davon
+ abhaengt, WER ihn angeheftet hat: DogFather und die rechte
+ Hand duerfen immer; alle anderen nur das, was sie selbst
+ angeheftet haben (sonst koennte eine Gruppenleitung ihre
+ drei Aushaenge nie wieder abnehmen -- siehe die Begruendung
+ an der Route).
+
+ VOM SERVER BEANTWORTET, damit die Regel nicht im Browser ein
+ zweites Mal steht. Die Route prueft sie ohnehin; hier geht
+ es nur darum, ob der Knopf ueberhaupt angeboten wird -- ein
+ Knopf, der nur eine Absage kann, ist schlimmer als keiner. */
+ darf_fuer_alle: fuehrtTeamDogi(req.person)
+ || Number(a.angeheftet_von) === Number(req.person.id),
})),
/* Aufsteigend zurück -- gelesen wird von oben nach unten. */
/* Die Reaktionen in EINER Abfrage fuer alle Nachrichten -- nicht
@@ -1816,6 +1868,12 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
erstellt: n.erstellt,
selbst: n.person_id === req.person.id,
angeheftet: !!n.angeheftet_am,
+ /* OB ICH SIE OBEN WEGGENOMMEN HABE (25.09.2026). Steht an der
+ Nachricht und nicht in einer zweiten Liste im Browser: Das
+ Menue an der Nachricht muss daraus „oben wegnehmen" oder
+ „wieder oben anzeigen" machen, und zwei Quellen fuer
+ dasselbe laufen auseinander. */
+ pin_fuer_mich_aus: pinAus.has(n.id),
/* WURDE HIER DAS RUDEL GERUFEN? Steht an der Nachricht, weil
der Browser sonst die damalige Rolle des Absenders kennen
muesste -- siehe die Spalte in workspace.js. */
@@ -3075,6 +3133,67 @@ chatRouter.put("/workspace/api/chat/raeume/:id/fixieren", gleicheHerkunft,
}
});
+/* =====================================================================
+ EINEN AUSHANG NUR FUER SICH WEGNEHMEN (25.09.2026)
+ =====================================================================
+
+ Filipe: „jeder soll das fixierte individuel fuer sich loesen koennen
+ aber niemals so dass es sich fuer alle loest."
+
+ KEIN RECHT NOETIG, NUR EIN PLATZ IM RAUM. Wer die Nachricht sehen
+ darf, darf sie auch aus seiner eigenen Kopfzeile nehmen -- das ist
+ keine Aenderung am Raum, sondern eine Einstellung an sich selbst.
+ Deshalb steht hier `istDrin` und nicht `darfAnheften`.
+
+ UMKEHRBAR, und das ist der Grund fuer `aus: false`: Wer sich
+ verklickt, holt den Aushang ueber das Menue an der Nachricht wieder
+ nach oben. Ein Handgriff ohne Rueckweg braucht eine Rueckfrage; mit
+ Rueckweg braucht er keine, und zwei Klicks fuer „ist mir schon
+ bekannt" waeren einer zu viel.
+
+ DIE NACHRICHT BLEIBT ANGEHEFTET. Hier steht nur, dass ICH sie oben
+ nicht mehr sehen will. Fuer alle anderen haengt sie weiter, und
+ wenn die Leitung sie spaeter fuer alle loest, verschwindet sie
+ ueberall -- diese Zeile hier wird dann bedeutungslos und faellt mit
+ der Nachricht weg (ON DELETE CASCADE).
+ ===================================================================== */
+chatRouter.put("/workspace/api/chat/nachrichten/:id/pin-fuer-mich", gleicheHerkunft,
+ express.json({ limit: "2kb" }), (req, res) => {
+ try {
+ const id = Number(req.params.id);
+ if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
+
+ const d = db();
+ const n = d.prepare(
+ "SELECT id, raum_id FROM chat_nachrichten WHERE id = ?").get(id);
+ if (!n || !istDrin(n.raum_id, req.person)) {
+ return res.status(404).json({ fehler: "nicht_gefunden" });
+ }
+
+ /* AUSDRUECKLICH `=== true`, kein Umschalten ohne Angabe --
+ dieselbe Ueberlegung wie beim Fixieren der Gespraeche: Ein
+ Schalter, der den Gegenwert selbst ausrechnet, kippt bei zwei
+ offenen Fenstern in den falschen Zustand. */
+ const aus = req.body?.aus === true;
+ if (aus) {
+ d.prepare(`INSERT INTO chat_pin_aus (nachricht_id, person_id, am)
+ VALUES (?,?,?) ON CONFLICT DO NOTHING`)
+ .run(id, req.person.id, jetzt());
+ } else {
+ d.prepare("DELETE FROM chat_pin_aus WHERE nachricht_id = ? AND person_id = ?")
+ .run(id, req.person.id);
+ }
+
+ /* KEIN chatEreignis: Das geht nur mich etwas an. Ein Ereignis an
+ den Raum wuerde bei allen anderen ein Neuzeichnen ausloesen --
+ fuer eine Aenderung, die sie nicht sehen. */
+ res.json({ ok: true, aus });
+ } catch (fehler) {
+ console.error("[chat] Pin fuer mich:", fehler?.message);
+ res.status(503).json({ fehler: "nicht_verfuegbar" });
+ }
+ });
+
/* =====================================================================
ANKUENDIGUNGEN ANHEFTEN (10.09.2026, Kapitel 7.2)
@@ -3100,8 +3219,8 @@ chatRouter.put("/workspace/api/chat/nachrichten/:id/anheften", gleicheHerkunft,
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
const d = db();
- const n = d.prepare(
- "SELECT id, raum_id, weg_am, angeheftet_am FROM chat_nachrichten WHERE id = ?").get(id);
+ const n = d.prepare("SELECT id, raum_id, weg_am, angeheftet_am, angeheftet_von"
+ + " FROM chat_nachrichten WHERE id = ?").get(id);
/* Erst die Mitgliedschaft (404 -- was man nicht sehen darf,
existiert nicht), dann das Recht (403 -- man sieht es, darf es
aber nicht). Zwei verschiedene Antworten fuer zwei verschiedene
@@ -3110,11 +3229,42 @@ chatRouter.put("/workspace/api/chat/nachrichten/:id/anheften", gleicheHerkunft,
if (!n || !istDrin(n.raum_id, req.person)) {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
- if (!darfAnheften(req.person, n.raum_id)) {
- return res.status(403).json({ fehler: "Anheften darf nur die Leitung." });
- }
-
const an = req.body?.an !== false;
+
+ /* ==== ANHEFTEN UND FUER-ALLE-LOESEN SIND ZWEI FRAGEN (25.09.2026)
+
+ Filipe: „niemals so dass es sich fuer alle loest. ausser
+ dogfather macht es oder die rechte hand, dann ist es bei jedem
+ weg."
+
+ ANHEFTEN bleibt bei der Leitung -- des Hauses oder des Raumes.
+ Daran aendert sich nichts.
+
+ FUER ALLE LOESEN ist enger: DogFather und die rechte Hand,
+ und ausserdem der, DER ES SELBST ANGEHEFTET HAT. Die letzte
+ Gruppe steht nicht in Filipes Satz und ist trotzdem noetig --
+ sonst entsteht eine Sackgasse: Es haengen hoechstens drei
+ Aushaenge, und eine Gruppenleitung, die drei angeheftet hat
+ und keinen mehr abnehmen darf, kann nie wieder etwas
+ anheften. Sie nimmt damit nur zurueck, was sie selbst getan
+ hat -- das ist die Kehrseite derselben Erlaubnis, keine neue.
+
+ ALLE UEBRIGEN nehmen den Aushang fuer SICH weg. Dafuer gibt es
+ die Route darunter; sie braucht kein Recht, nur einen Platz im
+ Raum. */
+ if (an) {
+ if (!darfAnheften(req.person, n.raum_id)) {
+ return res.status(403).json({ fehler: "Anheften darf nur die Leitung." });
+ }
+ } else {
+ const meins = n.angeheftet_von && Number(n.angeheftet_von) === Number(req.person.id);
+ if (!fuehrtTeamDogi(req.person) && !meins) {
+ return res.status(403).json({
+ fehler: "Für alle lösen dürfen nur DogFather und die rechte Hand. "
+ + "Für dich selbst kannst du es jederzeit wegnehmen.",
+ });
+ }
+ }
if (an && n.weg_am) {
return res.status(400).json({ fehler: "Eine zurückgenommene Nachricht geht nicht." });
}
diff --git a/server/workspace.js b/server/workspace.js
index bf8eb56c..28648236 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -5359,6 +5359,49 @@ export function db() {
CREATE INDEX IF NOT EXISTS idx_chat_favoriten_person
ON chat_reaktion_favoriten (person_id, platz);
+ /* =================================================================
+ EINEN AUSHANG FUER SICH WEGNEHMEN (25.09.2026)
+ =================================================================
+
+ Filipe: "jeder soll das fixierte individuel fuer sich loesen
+ koennen aber niemals so dass es sich fuer alle loest. ausser
+ dogfather macht es oder die rechte hand, dann ist es bei jedem
+ weg. ansonsten sollen alle anderen rollen es individuell fuer
+ sich loesen koennen. dogfather und die rechte hand sollen die
+ option haben fuer sich selbst oder fuer alle zu loesen."
+
+ BIS HEUTE GAB ES NUR EIN LOESEN, UND DAS GALT FUER ALLE. Wer
+ den Knopf sah, nahm damit jedem im Raum den Aushang weg -- und
+ wer ihn nicht sah, musste die Ansage vom Montag bis Freitag
+ ueber jedem Gespraech stehen lassen.
+
+ DIESE TABELLE IST DAS PERSOENLICHE "gesehen, weg damit". Sie
+ sagt NICHTS darueber, ob die Nachricht angeheftet ist -- das
+ steht weiter in chat_nachrichten.angeheftet_am und gilt fuer
+ den Raum. Zwei verschiedene Fragen, zwei verschiedene Orte:
+ die eine beantwortet der Raum, die andere jeder fuer sich.
+
+ KEIN EINTRAG HEISST SICHTBAR. Nicht umgekehrt -- sonst
+ muesste beim Anheften fuer jeden Teilnehmer eine Zeile
+ entstehen, und wer spaeter dazukommt, saehe den Aushang nie.
+
+ ON DELETE CASCADE an beiden Enden (KEINE Gegen-Apostrophe in
+ diesem Kommentar -- er steht in einem Template-Literal, und
+ einer davon wuerde es beenden; das hat heute schon einmal den
+ Start gekostet): Verschwindet die
+ Nachricht oder die Person, verschwindet die Notiz mit. Eine
+ Zeile, die auf nichts mehr zeigt, ist kein Datenbestand,
+ sondern Streu. */
+ CREATE TABLE IF NOT EXISTS chat_pin_aus (
+ nachricht_id INTEGER NOT NULL
+ REFERENCES chat_nachrichten(id) ON DELETE CASCADE,
+ person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
+ am TEXT NOT NULL,
+ PRIMARY KEY (nachricht_id, person_id)
+ );
+ CREATE INDEX IF NOT EXISTS idx_chat_pin_aus_person
+ ON chat_pin_aus (person_id, nachricht_id);
+
/* Creator-Profil (Onboarding aus dem Konzept). Eine Zeile je
Creator, entsteht erst beim ersten Speichern.
admin_notiz ist bewusst Teil dieser Tabelle, wird aber nur an
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index 70fba30e..5d130db6 100644
--- a/workspace/anruf-probe.html
+++ b/workspace/anruf-probe.html
@@ -16,8 +16,8 @@
verdeckt. Gefunden hat es pruef-struktur.
Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. -->
-
-
+
+
-
+