diff --git a/server/pruef-chat-kanaele.mjs b/server/pruef-chat-kanaele.mjs index 656106cf..aacd0fd3 100644 --- a/server/pruef-chat-kanaele.mjs +++ b/server/pruef-chat-kanaele.mjs @@ -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"); diff --git a/server/pruef-kanal-besetzung.mjs b/server/pruef-kanal-besetzung.mjs new file mode 100644 index 00000000..16902db4 --- /dev/null +++ b/server/pruef-kanal-besetzung.mjs @@ -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); diff --git a/server/workspace-chat.js b/server/workspace-chat.js index 68325ca1..15e332f5 100644 --- a/server/workspace-chat.js +++ b/server/workspace-chat.js @@ -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