B9: In einen Kategorie-Kanal gehoert Team Dogi -- und alle davon

VanVans Befund, von Filipe weitergegeben:
  * "Patrick und BananaStift (stifti) aus der Erwaehnen-Liste
     entfernen -- sie stehen dort als SCOUT."
  * "Die Modis zum Erwaehnen hinzufuegen."
  * "Die Modis sehen den Chat Moderation auch gar nicht."

AN DER LIVE-DATENBANK NACHGEMESSEN, bevor etwas gebaut wurde:

    Raum 3  kanal  Chat-Moderation   admin, hand, SCOUT, SCOUT, linke
    Raum 7  kanal  Der Treff         admin, hand, 4x modi, linke, 3x gast
    Raum 8  gruppe Dogi und Modis    admin, hand, 4x modi, linke
    Raum 9  gruppe Abmeldungen       admin, hand, 4x modi, linke

Damit waren alle drei Punkte EIN Befund. Die Erwaehnen-Liste im
Browser zeigt genau `offen.teilnehmer`, also die Teilnehmer des Raums
(teilnehmerVon) -- eine zweite Quelle gibt es nicht. Standen dort zwei
Scouts und kein Modi, dann schlug sie Scouts vor, und die Modis sahen
den Kanal gar nicht. An der Erwaehnung selbst war nichts kaputt.

DIE REGEL

Ein Kategorie-Kanal gehoert Team Dogi: admin + TEAM_DOGI_ROLLEN
(hand, linke, modi), abgeleitet aus der einen Liste des Hauses. Der
Abgleich beim Blick in die Gespraechsliste heilt jetzt in BEIDE
Richtungen:

  hinein  jeder aktive Mensch aus Team Dogi, der im Raum NOCH NIE eine
          Zeile hatte
  hinaus  jeder aktive Teilnehmer, dessen Rolle nicht dazugehoert
          (raus_am, kein DELETE -- der Verlauf bleibt lesbar)

"NOCH NIE EINE ZEILE" ist der wichtige Teil: Wer ueber die
Mitglieder-Route bewusst herausgenommen wurde, hat eine Zeile mit
raus_am und wird NICHT zurueckgeholt. Ein Abgleich, der eine
Entscheidung von Hand beim naechsten Seitenaufruf ueberschreibt,
macht die Route wertlos -- dieselbe Ueberlegung wie bei
treffAngleichen(). Der Treff bleibt ausgenommen: Dort gehoert die
Community dazu.

Dazu dieselbe Schranke an beiden Schreibwegen (Kanal anlegen und
umbesetzen). `darfSchreibenMit` beantwortet eine ANDERE Frage -- "darf
ich diesen Menschen ueberhaupt anschreiben" -- und sagt bei einem
Scout zu Recht ja. Genau diese Luecke hat die zwei Scouts in den
Moderations-Kanal gebracht.

WARUM NICHT "NUR DIE ZUSTAENDIGEN"

Weil es die Zuordnung nicht gibt: `kategorienFuer` gibt JEDEM aus Team
Dogi ALLE Kategorien, eine Tabelle Person -> Kategorie existiert
nirgends. Eine Regel "nur die Zustaendigen" waere eine Liste, die
niemand pflegt. Wer einen engeren Kanal will, nimmt jemanden heraus --
und das haelt.

PRUEFUNGEN

  pruef-kanal-besetzung.mjs (NEU, 16/0): baut die gemeldete Lage nach
  (Scout drin, kein Modi), laesst den Abgleich darueberlaufen und
  misst beide Richtungen. Vier Gegenproben: der von Hand Entfernte
  bleibt draussen; ein Scout kommt auch ueber die Mitglieder-Route
  nicht hinein; der Gast bleibt im Treff; und ein wieder
  hereingeschmuggelter Scout fliegt beim naechsten Abgleich erneut
  hinaus -- die Regel wirkt dauerhaft, nicht einmalig.

  pruef-chat-kanaele.mjs (79 mit 3 Fehlern -> 81/0): Die Zeile "wer
  nicht drin ist, sieht ihn nicht" hat den alten Zuschnitt
  festgeschrieben -- sie prueft jetzt die schaerfere Grenze: wer
  HERAUSGENOMMEN wurde, bleibt draussen, auch nach dem naechsten
  Abgleich. Dieser Weg war bis heute ungeprueft.

Mitgelaufen und gruen: pruef-chat, pruef-treffchat (110/0).

Vor dem Ausliefern gesichert: workspace-vor-kanalregel-20260922-231409.db
(946 KB, integrity_check ok, 17 Personen, 37 Teilnehmerzeilen,
111 Nachrichten).

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-22 23:14:38 +02:00
co-authored by Claude Opus 5
parent 803750edbc
commit 95da1345fc
3 changed files with 473 additions and 16 deletions
+44 -4
View File
@@ -313,12 +313,48 @@ console.log("\n=== Wer sieht ihn ===");
const beiEins = await raeumeVon(eins.keks);
ok(beiEins.some((r) => r.id === kanalId), "wer hineingesetzt wurde, sieht ihn auch");
/* GEGENPROBE: Der dritte ist NICHT drin. Er darf ihn weder in der
Liste haben noch ueber die Nummer erreichen. */
/* =================================================================
GEGENPROBE: WER DRAUSSEN IST, BLEIBT DRAUSSEN
=================================================================
HIER STAND ETWAS ANDERES (bis 22.09.2026 abends): "Der dritte ist
NICHT drin, weil ihn niemand eingetragen hat."
Das galt, solange ein Kategorie-Kanal nur die enthielt, die man
von Hand hineingesetzt hatte. Genau daran ist VanVan haengen
geblieben: Im Moderations-Kanal standen zwei SCOUTS und kein
einziger Modi -- die Modis sahen den Kanal gar nicht und konnten
dort niemanden erwaehnen.
Filipe: „Die Modis zum Erwaehnen hinzufuegen." /
„Die Modis sehen den Chat Moderation auch gar nicht."
Seither gilt: Ein Kategorie-Kanal gehoert dem ganzen Team Dogi.
Es gibt auch keine Handhabe fuer etwas anderes -- `kategorienFuer`
gibt JEDEM aus Team Dogi ALLE Kategorien; eine Zuordnung Person ->
Kategorie existiert nirgends. Eine Regel "nur die Zustaendigen"
waere also eine Liste, die niemand pflegt.
DIE GRENZE GIBT ES WEITERHIN, sie faengt nur an einer anderen
Stelle an: Wer ueber die Mitglieder-Route HERAUSGENOMMEN wurde,
bleibt draussen -- auch ueber die Nummer. Das ist die schaerfere
Messung: Vorher wurde jemand geprueft, den nie jemand eingetragen
hat; jetzt einer, der drin WAR. Der Weg dorthin (raus_am, und der
Abgleich holt ihn nicht zurueck) war bis heute ungeprueft. */
const raus = await rufe(dogi.keks, `/workspace/api/chat/kanal/${kanalId}/mitglieder`,
{ mit: [idEins, idZwei] }, "PUT");
ok(raus.status === 200, `der dritte wird herausgenommen (${raus.status})`);
const beiDrei = await raeumeVon(drei.keks);
ok(!beiDrei.some((r) => r.id === kanalId), "wer nicht drin ist, sieht ihn nicht");
ok(!beiDrei.some((r) => r.id === kanalId), "wer herausgenommen wurde, sieht ihn nicht mehr");
const versuch = await rufe(drei.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`);
ok(versuch.status === 404, `und kommt auch ueber die Nummer nicht hinein (${versuch.status})`);
/* UND DER ABGLEICH HOLT IHN NICHT ZURUECK. Ohne diese Zeile waere
die Sperre oben nur ein Augenblick: Der naechste Blick in die
Gespraechsliste stellt sie wieder her -- oder eben nicht. */
await raeumeVon(dogi.keks);
const nochDraussen = await raeumeVon(drei.keks);
ok(!nochDraussen.some((r) => r.id === kanalId),
"und der naechste Abgleich holt ihn nicht zurueck");
const wahl = await holen(dogi.keks, "/workspace/api/chat/raeume");
ok(wahl.darf_kanal === true, "DogFather bekommt das Ja zum Anlegen");
@@ -378,11 +414,15 @@ console.log("\n=== Und was NICHT offensteht ===");
======================================================================= */
console.log("\n=== Wer ist im Kanal ===");
{
/* Vorher steht der dritte draussen (Abschnitt 2 hat ihn
herausgenommen) und der zweite drin. Diese Runde dreht genau das
um: der dritte kommt zurueck, der zweite geht. Die Zahlen sind
damit nicht geraten, sondern die Folge des Standes davor. */
const a = await rufe(dogi.keks, `/workspace/api/chat/kanal/${kanalId}/mitglieder`,
{ mit: [idEins, idDrei] }, "PUT");
const antwort = await a.json();
ok(a.status === 200 && antwort.dazu === 1 && antwort.raus === 1,
`einer kommt dazu, einer geht (${antwort.dazu}/${antwort.raus})`);
`der Herausgenommene kommt zurueck, ein anderer geht (${antwort.dazu}/${antwort.raus})`);
const beiDrei = (await holen(drei.keks, "/workspace/api/chat/raeume")).raeume || [];
ok(beiDrei.some((r) => r.id === kanalId), "der Neue sieht den Kanal jetzt");
+304
View File
@@ -0,0 +1,304 @@
/* =====================================================================
WER IN EINEM KATEGORIE-KANAL STEHT — und wer nicht
=====================================================================
VANVANS BEFUND, weitergegeben von Filipe am 22.09.2026:
* „Patrick und BananaStift (stifti) aus der Erwähnen-Liste
entfernen — sie stehen dort als SCOUT und gehören nicht in
diesen Chat."
* „Die Modis zum Erwähnen hinzufügen."
* „Die Modis sehen den Chat Moderation auch gar nicht."
AN DER LIVE-DATENBANK NACHGEMESSEN, bevor etwas gebaut wurde:
Raum 3 kanal Chat-Moderation admin, hand, SCOUT, SCOUT, linke
Damit waren alle drei Punkte EIN Befund. Die Erwähnen-Liste zeigt
genau die Teilnehmer des Raums; standen dort zwei Scouts und kein
Modi, dann schlug sie Scouts vor und die Modis sahen den Kanal gar
nicht. An der Erwähnung selbst war nichts kaputt.
---------------------------------------------------------------------
WAS HIER GEMESSEN WIRD
Die Regel hat zwei Richtungen, und eine Prüfung, die nur eine misst,
ist die halbe Prüfung:
hinein jeder aktive Mensch aus Team Dogi, der im Raum noch NIE
eine Zeile hatte
hinaus jeder aktive Teilnehmer, dessen Rolle nicht dazugehört
Dazu drei Gegenproben, von denen die erste die wichtigste ist:
(1) WER BEWUSST HERAUSGENOMMEN WURDE, BLEIBT DRAUSSEN. Ein
Abgleich, der eine Entscheidung von Hand beim nächsten
Seitenaufruf überschreibt, macht die Mitglieder-Route wertlos.
(2) DER TREFF BLEIBT UNBERÜHRT. Er ist technisch ein 'kanal', aber
dort gehört die Community dazu. Eine Regel, die über ihr Ziel
hinausschießt, wirft die Gäste hinaus.
(3) DIE ALTE LAGE WIRD NACHGEBAUT und muss sich heilen. Ohne das
wäre nur bewiesen, dass ein FRISCHER Kanal richtig besetzt ist
— und genau das war er die ganze Zeit.
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { notbremse } from "./helfer-notbremse.mjs";
import { eigenerPort } from "./helfer-port.mjs";
const ordner = mkdtempSync(join(tmpdir(), "ws-kanal-"));
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
const PORT = await eigenerPort(import.meta, "pruef-kanal-besetzung");
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 }); };
await import("./index.js");
notbremse(120_000, "pruef-kanal-besetzung");
await new Promise((r) => setTimeout(r, 800));
const BASIS = `http://127.0.0.1:${PORT}`;
let fehler = 0, geprueft = 0, offen = 0;
const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
const unklar = (t) => { offen++; console.log(" -- " + t); };
/* ---------- 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 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 idChef = anlegen("Filipe", "admin", "CODE-DOGI-0001");
const idHand = anlegen("Rieke", "hand", "CODE-HAND-0001");
const idLinke = anlegen("Lino", "linke", "CODE-LINK-0001");
const idModi = anlegen("Frida", "modi", "CODE-MODI-0001");
const idModi2 = anlegen("Nele", "modi", "CODE-MODI-0002");
const idScout = anlegen("Scout Sam", "scout", "CODE-SCOU-0001");
/* ---------- Anfragen --------------------------------------------------
UEBER node:http UND NICHT UEBER fetch. `fetch` in Node verwirft den
Host-Kopf -- und genau an ihm entscheidet der Server, in welchem
Haus er antwortet. Ohne ihn kaeme jede Anfrage auf der Agenturseite
an, und Team Dogi gibt es dort nicht. Dieselbe Falle hat in diesem
Haus schon zweimal Zeit gekostet; deshalb steht sie hier als
Kommentar und nicht nur im Gedaechtnis. */
const { request: httpAnfrage } = await import("node:http");
const CREW = "crew.dogfather-universe.com";
function anfrage(pfad, { methode = "GET", keks = "", rumpf = null } = {}) {
return new Promise((fertig, schief) => {
const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf));
const a = httpAnfrage({
host: "127.0.0.1", port: PORT, path: pfad, method: methode,
headers: {
Host: CREW, Origin: `https://${CREW}`, Referer: `https://${CREW}/workspace/`,
...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}),
...(keks ? { Cookie: keks } : {}),
},
}, (antwort) => {
const st = [];
antwort.on("data", (x) => st.push(x));
antwort.on("end", () => {
const text = Buffer.concat(st).toString("utf8");
let json = null; try { json = JSON.parse(text); } catch { /* kein JSON */ }
fertig({
code: antwort.statusCode, json,
keks: [].concat(antwort.headers["set-cookie"] || [])
.map((c) => c.split(";")[0]).join("; "),
});
});
});
a.on("error", schief);
if (daten) a.write(daten);
a.end();
});
}
/* Die Rolle gehoert in den Rumpf -- die Anmeldewand schickt sie mit,
und der Server prueft Code UND Rolle gegeneinander. */
const anmelden = async (rolle, code) =>
(await anfrage("/workspace/api/anmelden", { methode: "POST", rumpf: { rolle, code } })).keks;
const ruf = (keks, pfad, { methode = "GET", rumpf = null } = {}) =>
anfrage(pfad, { methode, keks, rumpf });
const keksChef = await anmelden("admin", "CODE-DOGI-0001");
const keksModi = await anmelden("modi", "CODE-MODI-0001");
console.log("=== Wer in einem Kategorie-Kanal steht ===");
console.log("");
/* =====================================================================
1. EIN KANAL LAESST NIEMANDEN VON AUSSEN HEREIN
===================================================================== */
console.log("--- Anlegen ---");
const mitScout = await ruf(keksChef, "/workspace/api/chat/kanal", {
methode: "POST", rumpf: { kategorie: "clipping", mit: [idScout] },
});
ok(mitScout.code === 403,
`ein Scout kommt beim Anlegen nicht hinein (${mitScout.code})`);
const angelegt = await ruf(keksChef, "/workspace/api/chat/kanal", {
methode: "POST", rumpf: { kategorie: "chat", mit: [] },
});
ok(angelegt.code === 201, `der Kanal entsteht (${angelegt.code})`);
const raumId = angelegt.json?.raum_id;
/* =====================================================================
2. DIE ALTE LAGE NACHBAUEN — genau der Zustand aus der Live-Datenbank
===================================================================== */
console.log("");
console.log("--- Die gemeldete Lage nachgebaut: zwei Scouts drin, kein Modi ---");
d.prepare("INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis) VALUES (?,?,?,0)")
.run(raumId, idScout, jetzt);
/* Und die Modis wieder hinaus, so als hätte sie nie jemand
eingetragen — DELETE und nicht raus_am, denn „nie eine Zeile" ist
der Zustand, den es nachzubilden gilt. */
d.prepare("DELETE FROM chat_teilnehmer WHERE raum_id = ? AND person_id IN (?,?)")
.run(raumId, idModi, idModi2);
/* Eine Nachricht, damit es etwas zu bewahren gibt. */
d.prepare(`INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt)
VALUES (?,?,?,?)`).run(raumId, idScout, "Alte Nachricht vom Scout", jetzt);
const vorher = d.prepare(`SELECT p.rolle FROM chat_teilnehmer t JOIN personen p ON p.id = t.person_id
WHERE t.raum_id = ? AND t.raus_am IS NULL ORDER BY p.rolle`).all(raumId).map((z) => z.rolle);
ok(vorher.includes("scout") && !vorher.includes("modi"),
`nachgebaut: ${vorher.join(", ")} — ein Scout drin, kein Modi`);
/* =====================================================================
3. DER ABGLEICH HEILT ES
===================================================================== */
console.log("");
console.log("--- Ein Blick in die Gespraechsliste gleicht ab ---");
const liste = await ruf(keksChef, "/workspace/api/chat/raeume");
ok(liste.code === 200, `die Liste kommt (${liste.code})`);
const raum = (liste.json?.raeume || []).find((r) => r.id === raumId);
const namen = (raum?.teilnehmer || []).map((t) => t.name);
ok(!!raum, "der Kanal steht in der Liste");
ok(namen.length > 0 && namen.includes("Frida") && namen.includes("Nele"),
`die Modis stehen jetzt drin (${namen.join(", ")})`);
ok(namen.length > 0 && !namen.includes("Scout Sam"),
`und der Scout nicht mehr (${namen.join(", ")})`);
/* DIE ERWAEHNEN-LISTE IST GENAU DIESE LISTE. Der Browser filtert
`offen.teilnehmer` — eine zweite Quelle gibt es nicht. Wer das hier
misst, misst damit auch, was beim Tippen von „@" vorgeschlagen wird. */
ok(namen.length === 5,
`fünf Leute im Kanal: DogFather, beide Hände, beide Modis (${namen.length})`);
/* DER VERLAUF BLEIBT. `raus_am` und kein DELETE — was der Scout
geschrieben hat, verschwindet nicht rückwirkend. */
const nachrichtDa = d.prepare(
"SELECT COUNT(*) AS n FROM chat_nachrichten WHERE raum_id = ?").get(raumId).n;
const zeileDa = d.prepare(
"SELECT raus_am FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?")
.get(raumId, idScout);
ok(nachrichtDa === 1 && !!zeileDa?.raus_am,
`seine Nachricht bleibt lesbar, seine Zeile trägt raus_am (${nachrichtDa} Nachricht)`);
/* =====================================================================
4. UND DER MODI SIEHT DEN KANAL
===================================================================== */
console.log("");
console.log("--- Aus der Sicht eines Modis ---");
const modiListe = await ruf(keksModi, "/workspace/api/chat/raeume");
const modiRaum = (modiListe.json?.raeume || []).find((r) => r.id === raumId);
ok(!!modiRaum, "der Modi hat den Kanal in seiner Liste — genau der gemeldete Punkt");
ok((modiRaum?.teilnehmer || []).some((t) => t.name === "Rieke"),
"und er kann die rechte Hand darin erwähnen");
/* =====================================================================
5. GEGENPROBEN
===================================================================== */
console.log("");
console.log("--- Gegenproben ---");
/* (1) Wer bewusst herausgenommen wurde, bleibt draussen. */
const raus = await ruf(keksChef, `/workspace/api/chat/kanal/${raumId}/mitglieder`, {
methode: "PUT", rumpf: { mit: [idLinke, idModi2] },
});
ok(raus.code === 200 && raus.json?.raus >= 1,
`Frida wird von Hand herausgenommen (${raus.code}, -${raus.json?.raus})`);
await ruf(keksChef, "/workspace/api/chat/raeume");
const nachAbgleich = d.prepare(
"SELECT raus_am FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?")
.get(raumId, idModi);
ok(!!nachAbgleich?.raus_am,
"und der nächste Abgleich holt sie NICHT zurück — sonst wäre die"
+ " Mitglieder-Route wertlos");
/* (2) Ein Scout kommt auch über die Mitglieder-Route nicht hinein. */
const scoutRein = await ruf(keksChef, `/workspace/api/chat/kanal/${raumId}/mitglieder`, {
methode: "PUT", rumpf: { mit: [idModi2, idScout] },
});
ok(scoutRein.code === 403,
`und über die Mitglieder-Route auch nicht (${scoutRein.code})`);
/* (3) Der Treff bleibt unberührt — dort gehört die Community dazu. */
const treff = d.prepare(
"SELECT id FROM chat_raeume WHERE art = 'kanal' AND kategorie = 'treff'").get();
if (!treff) {
unklar("der Treff-Kanal existiert in dieser Probe-Datenbank nicht —"
+ " ohne ihn lässt sich nicht messen, ob er verschont bleibt");
} else {
const gastId = anlegen("Gast Greta", "gast", "CODE-GAST-0001");
d.prepare("INSERT OR IGNORE INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis)"
+ " VALUES (?,?,?,0)").run(treff.id, gastId, jetzt);
await ruf(keksChef, "/workspace/api/chat/raeume");
const gastNoch = d.prepare(
"SELECT raus_am FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?")
.get(treff.id, gastId);
ok(gastNoch && !gastNoch.raus_am,
"der Gast bleibt im Treff — die Regel schießt nicht über ihr Ziel hinaus");
}
/* (4) Und die Messung selbst kann „nicht in Ordnung" sagen: Ein Scout,
der NEU in einen Kanal geschrieben wird, muss beim nächsten
Abgleich wieder verschwinden. Ohne diesen Durchgang wäre nur
bewiesen, dass einmal aufgeräumt wurde. */
d.prepare("UPDATE chat_teilnehmer SET raus_am = NULL WHERE raum_id = ? AND person_id = ?")
.run(raumId, idScout);
const wiederDrin = d.prepare(
"SELECT raus_am FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?")
.get(raumId, idScout);
await ruf(keksChef, "/workspace/api/chat/raeume");
const wiederRaus = d.prepare(
"SELECT raus_am FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?")
.get(raumId, idScout);
ok(!wiederDrin?.raus_am && !!wiederRaus?.raus_am,
"ein wieder hereingeschmuggelter Scout fliegt beim nächsten Abgleich erneut hinaus"
+ " — die Regel wirkt dauerhaft, nicht einmalig");
/* ---------- Ende ------------------------------------------------------ */
d.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Windows haelt manchmal */ }
console.log("");
console.log(`${fehler === 0 ? "BESTANDEN" : "FEHLGESCHLAGEN"}`
+ ` -- ${geprueft} geprueft, ${fehler} Fehler, ${offen} nicht nachsehbar`);
process.exit(fehler === 0 ? 0 : 1);
+125 -12
View File
@@ -44,7 +44,7 @@ import { mkdirSync, writeFileSync, createReadStream, statSync, unlinkSync } from
import {
db, protokolliere, echteIp, sitzungLesen, istDogFather,
schreibbareIds, darfSchreibenMit, ROLLEN_SORTIERUNG, DATEN_ORDNER,
fuehrtTeamDogi, MODI_KATEGORIEN, ROLLEN_GRUPPE,
fuehrtTeamDogi, MODI_KATEGORIEN, ROLLEN_GRUPPE, TEAM_DOGI_ROLLEN,
} from "./workspace.js";
import { erwaehnteIds } from "./chat-erwaehnung.js";
@@ -548,22 +548,104 @@ function darfAnheften(person, raumId) {
* dazukommt, hat die vierhundert Nachrichten von vorher nicht
* verpasst -- eine rote Vierhundert waere keine Auskunft, sondern
* Rauschen, und man schaltet sie ab statt sie zu lesen. */
/* =====================================================================
WER IN EINEN KATEGORIE-KANAL GEHOERT (22.09.2026)
=====================================================================
VanVans Befund, von Filipe weitergegeben:
* "Patrick und BananaStift (stifti) aus der Erwaehnen-Liste
entfernen -- sie stehen dort als SCOUT und gehoeren nicht in
diesen Chat."
* "Die Modis zum Erwaehnen hinzufuegen."
* "Die Modis sehen den Chat Moderation auch gar nicht."
AN DER LIVE-DATENBANK NACHGEMESSEN, bevor irgendetwas gebaut wurde:
Raum 3 kanal Chat-Moderation admin, hand, SCOUT, SCOUT, linke
Raum 7 kanal Der Treff admin, hand, 4x modi, linke, 3x gast
Raum 8 gruppe Dogi und Modis admin, hand, 4x modi, linke
Raum 9 gruppe Abmeldungen admin, hand, 4x modi, linke
Damit waren alle drei Punkte EIN Befund: Die Erwaehnen-Liste zeigt
genau die Teilnehmer des Raums (teilnehmerVon). Im
Moderations-Kanal standen zwei Scouts und kein einziger Modi --
also schlug die Liste Scouts vor, und die Modis sahen den Kanal gar
nicht. Nichts davon war ein Fehler der Erwaehnung.
DIE REGEL: Ein Kategorie-Kanal gehoert Team Dogi. Scouts, Manager
und Creator arbeiten im anderen Haus; sie haben in einem Kanal
ueber Chat-Moderation nichts verloren -- und dass sie dort standen,
war kein Rechtefehler, sondern eine Besetzung von Hand, die nie
jemand nachgeprueft hat.
SELBSTHEILEND, IN BEIDE RICHTUNGEN -- und das ist der Unterschied
zum alten Abgleich, der nur die Teamfuehrung nachtrug:
hinein jeder aktive Mensch aus Team Dogi, der in diesem Raum
NOCH NIE eine Zeile hatte
hinaus jeder aktive Teilnehmer, dessen Rolle nicht dazugehoert
(raus_am, kein DELETE -- der Verlauf bleibt lesbar)
"NOCH NIE EINE ZEILE" IST DER WICHTIGE TEIL. Wer ueber die
Mitglieder-Route bewusst herausgenommen wurde, hat eine Zeile mit
`raus_am` -- und wird deshalb NICHT wieder hineingezogen. Ein
Abgleich, der eine Entscheidung von Hand beim naechsten Seitenaufruf
ueberschreibt, macht die Mitglieder-Route wertlos; dieselbe
Ueberlegung steht schon bei treffAngleichen().
DER TREFF BLEIBT AUSSEN VOR. Er ist zwar technisch ein 'kanal',
aber kein Thema des Teams -- dort gehoert die Community dazu, und
TREFF_ROLLEN sagt das an seiner eigenen Stelle.
===================================================================== */
/** Wer in einen Kategorie-Kanal gehoert.
*
* ABGELEITET, NICHT ABGESCHRIEBEN: TEAM_DOGI_ROLLEN ist die eine
* Liste des Hauses (hand, linke, modi); DogFather kommt dazu, weil er
* zum Team gehoert -- er steht nicht darueber, er ist dabei. Kaeme
* morgen eine weitere Team-Rolle, waere sie hier von selbst dabei. */
export const KANAL_ROLLEN = ["admin", ...TEAM_DOGI_ROLLEN];
function kanaeleAngleichen(person) {
if (!fuehrtTeamDogi(person)) return 0;
try {
const d = db();
const offen = d.prepare(`
SELECT r.id FROM chat_raeume r
WHERE r.art = 'kanal'
AND NOT EXISTS (SELECT 1 FROM chat_teilnehmer t
WHERE t.raum_id = r.id AND t.person_id = ?)`).all(person.id);
if (!offen.length) return 0;
const rein = d.prepare(`
INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis, leitung)
SELECT ?, ?, ?, COALESCE((SELECT MAX(id) FROM chat_nachrichten WHERE raum_id = ?), 0), 1`);
const n = jetzt();
for (const r of offen) rein.run(r.id, person.id, n, r.id);
return offen.length;
const platz = KANAL_ROLLEN.map(() => "?").join(", ");
/* 1. WER FEHLT, KOMMT HINEIN. `gelesen_bis` auf den letzten Stand:
Wer heute dazukommt, hat nichts verpasst -- sonst staende beim
ersten Oeffnen eine rote Zahl ueber allem, was das Team je
geschrieben hat. Dieselbe Ueberlegung wie in der
Mitglieder-Route. */
const dazu = d.prepare(`
INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis, leitung)
SELECT r.id, p.id, ?,
COALESCE((SELECT MAX(id) FROM chat_nachrichten WHERE raum_id = r.id), 0),
CASE WHEN p.rolle IN ('admin','hand') THEN 1 ELSE 0 END
FROM chat_raeume r
JOIN personen p ON p.aktiv = 1 AND p.rolle IN (${platz})
WHERE r.art = 'kanal' AND COALESCE(r.kategorie, '') <> ?
AND NOT EXISTS (SELECT 1 FROM chat_teilnehmer t
WHERE t.raum_id = r.id AND t.person_id = p.id)`)
.run(n, ...KANAL_ROLLEN, TREFF_KATEGORIE).changes;
/* 2. WER NICHT DAZUGEHOERT, GEHT HINAUS. Nicht geloescht: Was er
geschrieben hat, bleibt lesbar, und niemand verschwindet
rueckwirkend aus der Geschichte. */
const raus = d.prepare(`
UPDATE chat_teilnehmer SET raus_am = ?
WHERE raus_am IS NULL
AND raum_id IN (SELECT id FROM chat_raeume
WHERE art = 'kanal' AND COALESCE(kategorie, '') <> ?)
AND person_id IN (SELECT id FROM personen WHERE rolle NOT IN (${platz}))`)
.run(n, TREFF_KATEGORIE, ...KANAL_ROLLEN).changes;
if (dazu || raus) {
console.log(`[chat] Kanal-Abgleich: +${dazu} / -${raus}`);
}
return dazu + raus;
} catch (fehler) {
/* Ein Fehler hier darf die Gespraechsliste nicht kosten. Der
Zugriff selbst haengt ohnehin nicht daran -- istDrin() laesst die
@@ -573,6 +655,19 @@ function kanaeleAngleichen(person) {
}
}
/** Gehoert diese Person in einen Kategorie-Kanal?
*
* STEHT AN EINER STELLE, weil drei danach fragen: der Abgleich oben,
* das Anlegen eines Kanals und das Umbesetzen. Drei Fassungen waeren
* die, bei der die dritte beim naechsten Umbau vergessen wird -- und
* genau diese dritte Luecke hat VanVan gefunden. */
function gehoertInKanal(id) {
try {
const r = db().prepare("SELECT rolle FROM personen WHERE id = ? AND aktiv = 1").get(id);
return !!r && KANAL_ROLLEN.includes(r.rolle);
} catch { return false; }
}
function raumName(raum, teilnehmer, ichId) {
if (raum.art === "kanal") return raum.name || kanalName(raum.kategorie);
if (raum.art === "gruppe") return raum.name || "Gruppe";
@@ -934,6 +1029,16 @@ chatRouter.post("/workspace/api/chat/kanal", gleicheHerkunft, express.json({ lim
if (mit.some((id) => !darfSchreibenMit(req.person, id))) {
return res.status(403).json({ fehler: "Mit mindestens einer Person kannst du nicht schreiben." });
}
/* UND SIE MUESSEN ZU TEAM DOGI GEHOEREN (22.09.2026, VanVans
Befund). `darfSchreibenMit` beantwortet eine andere Frage --
"darf ich diesen Menschen ueberhaupt anschreiben" --, und die
sagt bei einem Scout zu Recht ja. Ein Kanal ueber
Chat-Moderation ist trotzdem kein Ort fuer ihn. Genau diese
Luecke hat die zwei Scouts in den Moderations-Kanal gebracht. */
if (mit.some((id) => !gehoertInKanal(id))) {
return res.status(403).json({
fehler: "In einen Kanal gehören nur Leute aus Team Dogi." });
}
const n = jetzt();
d.prepare(`INSERT INTO chat_raeume (art, name, kategorie, erstellt, erstellt_von)
@@ -1004,6 +1109,14 @@ chatRouter.put("/workspace/api/chat/kanal/:id/mitglieder", gleicheHerkunft,
if ([...soll].some((id) => !darfSchreibenMit(req.person, id))) {
return res.status(403).json({ fehler: "Mit mindestens einer Person kannst du nicht schreiben." });
}
/* DIESELBE SCHRANKE WIE BEIM ANLEGEN. Ohne sie waere die Regel
eine Tuer mit zwei Eingaengen, von denen nur einer ein Schloss
hat -- und der ohne Schloss ist der, den man haeufiger
benutzt. */
if ([...soll].some((id) => !gehoertInKanal(id))) {
return res.status(403).json({
fehler: "In einen Kanal gehören nur Leute aus Team Dogi." });
}
/* Die Teamfuehrung bleibt unangetastet -- sie hat laut Kapitel 7.2
Zugriff auf alle Kanaele. Sie aus der Liste entfernen zu koennen