diff --git a/server/pruef-chat-kanaele.mjs b/server/pruef-chat-kanaele.mjs new file mode 100644 index 00000000..564a8df9 --- /dev/null +++ b/server/pruef-chat-kanaele.mjs @@ -0,0 +1,554 @@ +/* KATEGORIE-KANÄLE UND ANGEHEFTETE ANKÜNDIGUNGEN (10.09.2026) + + Kapitel 7.2 des Anforderungsdokuments, die vier Sätze im Wortlaut: + + "Team-Gruppenchat: für alle Mitglieder, vollständig einsehbar für + Owner & rechte Hand. + Kategorie-Kanäle: z. B. 'Clipping-Team', 'Live-Moderation' - + Owner/rechte Hand haben Zugriff auf alle Kanäle. + Private 1:1-Chats: ausschließlich für die beteiligten Personen + sichtbar - kein Zugriff durch Owner/rechte Hand. + Ankündigungs-Funktion: 'Pin'-Nachrichten von Owner/rechte Hand + an alle." + + --------------------------------------------------------------------- + DIE EINE PRÜFUNG, DERENTWEGEN ES DIESE DATEI GIBT + + Der Zugriff auf Kanäle wurde in istDrin() eingebaut -- der Funktion, + durch die JEDER Verlauf, JEDER Anhang und JEDE Suche geht. Das ist + die richtige Stelle (sonst gäbe es die Regel in sechs von sieben + Routen), aber es ist auch die gefährlichste: Ein zu weit gefasstes + `return true` dort öffnet in EINEM Schritt jedes Gespräch im Haus. + + Deshalb steht die Gegenprobe hier vorn und nicht als Anhängsel: + DogFather muss an einem Kanal vorbeikommen UND an einem + Zweier-Gespräch scheitern, das ihn nichts angeht. Eine Prüfung, die + nur das Erste zeigt, wäre grün, wenn die Regel gar nicht mehr + unterscheidet. + + AUSSERDEM GEMESSEN: + * Ein Kanal je Zuständigkeit -- der zweite wird abgelehnt. + * Die rechte Hand kommt in einen Kanal, in den sie nie jemand + gesetzt hat (das ist der Sinn des Satzes im Dokument). + * Anheften: nur die Leitung, höchstens drei, und eine + zurückgenommene Nachricht fällt vom Aushang UND gibt den Platz frei. + * Eine Ankündigung von vor 300 Nachrichten steht immer noch oben. + (Der Verlauf liefert nur 200 Zeilen -- deshalb hat der Aushang + eine eigene Abfrage. Ohne diese Prüfung wäre der Grund dafür in + einem Kommentar begraben und die Abfrage beim nächsten Aufräumen + wegoptimiert.) + ===================================================================== */ + +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-kanal-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +const { portMussFreiSein } = await import("./helfer-port.mjs"); +await portMussFreiSein(4359, "die Kanalpruefung"); + +process.env.PORT = "4359"; +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"); +await new Promise((r) => setTimeout(r, 1000)); +const BASIS = "http://127.0.0.1:4359"; + +/* ---- Notbremse und eigener Zuhoerer ------------------------------------- + + BEIDES WURDE BEIM ERSTEN LAUF GEBRAUCHT, und zwar sofort. + + Ein Zugriff auf `undefined[0]` liess diese Datei mitten im Lauf + abstuerzen. Zu sehen war davon: nichts. `index.js` haengt fuer den + BETRIEB ein Auffangnetz an `uncaughtException` -- dort voellig + richtig, ein Fehler in einer Nebensache darf die Website nicht + offline nehmen. Eine Pruefung importiert dieselbe Datei in DENSELBEN + Prozess und erbt es: Der Absturz wurde nur notiert, der Server hielt + den Prozess am Leben, und von aussen sah es fuenf Minuten lang aus + wie "laeuft noch". + + `process.on` ERGAENZT, es ersetzt nicht -- der Zuhoerer unten laeuft + also zusaetzlich zu dem aus index.js und beendet den Lauf wirklich. + Die Notbremse faengt den anderen Fall: nicht Absturz, sondern + Warten. `unref()`, damit sie einen sauberen Lauf nie aufhaelt. */ +process.on("uncaughtException", (f) => { + console.error(""); console.error("ABBRUCH durch einen Absturz:", f?.stack || f); + process.exit(1); +}); +process.on("unhandledRejection", (f) => { + console.error(""); console.error("ABBRUCH durch ein unbehandeltes Versprechen:", f?.stack || f); + process.exit(1); +}); +const notbremse = setTimeout(() => { + console.error(""); console.error("ABBRUCH: Die Pruefung laeuft seit 120 s. Die letzte Zeile oben" + + " ist die Stelle, an der sie stehengeblieben ist."); + process.exit(1); +}, 120000); +notbremse.unref?.(); + +let fehler = 0; +let geprueft = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +/* ---- Bestand ------------------------------------------------------------ + DogFather, seine rechte Hand und drei Modis. Drei und nicht zwei: + Fuer das Zweier-Gespraech, das DogFather nichts angeht, braucht es + zwei, die NICHT er sind -- und fuer die Besetzung eines Kanals noch + einen dritten, den man hinein- und wieder herausnehmen kann. */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +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, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idHand = anlegen("Vanessa", "hand", "CODE-HAND-0001"); +const idEins = anlegen("Ayla", "modi", "CODE-TEAM-0001"); +const idZwei = anlegen("Ben", "modi", "CODE-TEAM-0002"); +const idDrei = anlegen("Cem", "modi", "CODE-TEAM-0003"); +/* Die Agenturseite des Hauses. Sie kommt in dieser Pruefung nur einmal + vor -- aber an der Stelle, die ohne sie unbewiesen bliebe. */ +anlegen("Cigdem", "manager", "CODE-MANA-0001"); +anlegen("VanVan", "spicy", "CODE-SPIC-0001"); + +/* DIE ANMELDUNG KLOPFT AN DIE CREW-WAND -- und das ist kein Kniff, + sondern die Regel, die am 10.09.2026 auf Filipes Wunsch entstanden + ist: "auf der workspace seite fuer die agentur und so sollen die + modis von mir nicht mehr rein kommen. die sollen nur noch ueber die + crew seite rein kommen." Wer sie an der Agenturwand versucht, + bekommt 401 -- ohne Hinweis, absichtlich. + + Beim ersten Anlauf stand hier ein blankes `fetch` auf 127.0.0.1. + Ergebnis: DogFather kam herein, die rechte Hand und alle drei + Teammitglieder nicht, und die halbe Pruefung meldete 401 statt der + Sache, die sie messen wollte. Die Sperre hat also getan, was sie + soll -- sie hat nur zuerst mich erwischt. + + WARUM node:http UND NICHT fetch: `fetch` (undici) setzt den + Host-Kopf selbst aus der Adresse und ignoriert einen mitgegebenen -- + nachgemessen, nicht vermutet. Fuer alles danach genuegt der Keks: + sitzungPasstZurAdresse() laesst 127.0.0.1 unberuehrt. */ +const { request: httpAnfrage } = await import("node:http"); +function anmelden(rolle, code, wand = "crew.dogfather-universe.com") { + return new Promise((fertig) => { + const rumpf = JSON.stringify({ rolle, code }); + const a = httpAnfrage({ + host: "127.0.0.1", port: 4359, path: "/workspace/api/anmelden", method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(rumpf), + Host: wand, + }, + }, (antwort) => { + antwort.resume(); + antwort.on("end", () => fertig({ + ok: antwort.statusCode === 200, + keks: (antwort.headers["set-cookie"] || []).map((k) => k.split(";")[0]).join("; "), + })); + }); + a.on("error", () => fertig({ ok: false, keks: "" })); + a.end(rumpf); + }); +} +const rufe = (keks, weg, koerper, methode = "GET") => + fetch(BASIS + weg, { + method: methode, + headers: { "Content-Type": "application/json", Cookie: keks }, + body: koerper === undefined ? undefined : JSON.stringify(koerper), + }); +const holen = async (keks, weg) => (await rufe(keks, weg)).json(); + +const dogi = await anmelden("admin", "CODE-DOGI-0001"); +const hand = await anmelden("hand", "CODE-HAND-0001"); +const eins = await anmelden("modi", "CODE-TEAM-0001"); +const zwei = await anmelden("modi", "CODE-TEAM-0002"); +const drei = await anmelden("modi", "CODE-TEAM-0003"); +ok([dogi, hand, eins, zwei, drei].every((x) => x.ok && x.keks), + "alle fuenf sind angemeldet"); + +/* ======================================================================= + 1. Einen Kanal anlegen -- und wer das darf + ======================================================================= */ +console.log("\n=== Kanaele anlegen ==="); +let kanalId = 0; +{ + const a = await rufe(dogi.keks, "/workspace/api/chat/kanal", + { kategorie: "clipping", mit: [idEins, idZwei] }, "POST"); + const antwort = await a.json(); + kanalId = antwort.raum_id; + ok(a.status === 201 && kanalId > 0, `DogFather legt einen Kanal an (${a.status})`); + ok(antwort.name === "Clipping", + `der Name kommt aus der Kategorie ("${antwort.name}")`); + + /* GEGENPROBE ZUR REGEL, nicht nur zur Sperre: Eine zweite Kategorie + muss gehen. Ginge sie nicht, waere die 409 unten wertlos -- sie + koennte auch heissen "Kanaele gehen ueberhaupt nicht mehr". */ + const b = await rufe(dogi.keks, "/workspace/api/chat/kanal", { kategorie: "technik" }, "POST"); + ok(b.status === 201, `eine zweite Zustaendigkeit geht (${b.status})`); + + const c = await rufe(dogi.keks, "/workspace/api/chat/kanal", { kategorie: "clipping" }, "POST"); + ok(c.status === 409, `dieselbe Zustaendigkeit ein zweites Mal: abgelehnt (${c.status})`); + const anzahl = d.prepare( + "SELECT COUNT(*) AS n FROM chat_raeume WHERE art = 'kanal' AND kategorie = 'clipping'").get().n; + ok(anzahl === 1, `und es steht wirklich nur einer in der Datenbank (${anzahl})`); + + const e = await rufe(dogi.keks, "/workspace/api/chat/kanal", { kategorie: "gibtsnicht" }, "POST"); + ok(e.status === 400, `eine erfundene Kategorie wird abgelehnt (${e.status})`); + + /* Wer keine Kanaele fuehrt, soll nicht einmal erfahren, dass es sie + gibt -- deshalb 404 und nicht 403. */ + const f = await rufe(eins.keks, "/workspace/api/chat/kanal", { kategorie: "community" }, "POST"); + ok(f.status === 404, `ein Teammitglied kann keinen anlegen (${f.status})`); + const g = d.prepare("SELECT COUNT(*) AS n FROM chat_raeume WHERE kategorie = 'community'").get().n; + ok(g === 0, "und es ist auch keiner entstanden"); +} + +/* ======================================================================= + 2. Wer sieht den Kanal? + ======================================================================= */ +console.log("\n=== Wer sieht ihn ==="); +{ + const raeumeVon = async (keks) => (await holen(keks, "/workspace/api/chat/raeume")).raeume || []; + + const beiDogi = await raeumeVon(dogi.keks); + const k = beiDogi.find((r) => r.id === kanalId); + ok(!!k, "DogFather hat den Kanal in seiner Liste"); + ok(k?.art === "kanal" && k?.kategorie === "clipping", + `er kommt als Kanal mit seiner Kategorie an (${k?.art}/${k?.kategorie})`); + + /* DER SATZ AUS DEM DOKUMENT. Die rechte Hand wurde NIRGENDS + eingetragen -- sie war beim Anlegen nicht dabei und steht in + keiner Auswahl. Trotzdem muss der Kanal bei ihr stehen. */ + const beiHand = await raeumeVon(hand.keks); + ok(beiHand.some((r) => r.id === kanalId), + "die rechte Hand sieht ihn, ohne je hinzugefuegt worden zu sein"); + const lesen = await rufe(hand.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`); + ok(lesen.status === 200, `und kommt in den Verlauf (${lesen.status})`); + + 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. */ + const beiDrei = await raeumeVon(drei.keks); + ok(!beiDrei.some((r) => r.id === kanalId), "wer nicht drin ist, sieht ihn nicht"); + 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})`); + + const wahl = await holen(dogi.keks, "/workspace/api/chat/raeume"); + ok(wahl.darf_kanal === true, "DogFather bekommt das Ja zum Anlegen"); + ok(Array.isArray(wahl.kanal_frei) && !wahl.kanal_frei.some((x) => x.wert === "clipping"), + "die belegte Zustaendigkeit steht nicht mehr zur Auswahl"); + const wahlModi = await holen(eins.keks, "/workspace/api/chat/raeume"); + ok(wahlModi.darf_kanal === false && wahlModi.kanal_frei.length === 0, + "ein Teammitglied bekommt weder das Ja noch die Liste"); +} + +/* ======================================================================= + 3. DIE WICHTIGSTE PRÜFUNG: Die Ausnahme bleibt bei den Kanaelen + ======================================================================= */ +console.log("\n=== Und was NICHT offensteht ==="); +{ + /* Ein Zweier-Gespraech und eine Gruppe, beide OHNE DogFather. Direkt + in die Datenbank gesetzt und nicht ueber die Schnittstelle: Diese + Pruefung gilt der Zugriffsregel, nicht dem Anlegeweg -- und ein + Anlegeweg, der sich aendert, duerfte sie nicht mit umwerfen. */ + function raumMit(art, name, leute) { + d.prepare("INSERT INTO chat_raeume (art, name, erstellt, letzte_am) VALUES (?,?,?,?)") + .run(art, name, jetzt, jetzt); + const id = d.prepare("SELECT last_insert_rowid() AS id").get().id; + for (const p of leute) { + d.prepare("INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis) VALUES (?,?,?,0)") + .run(id, p, jetzt); + } + d.prepare("INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt) VALUES (?,?,?,?)") + .run(id, leute[0], "Etwas, das niemanden sonst angeht.", jetzt); + return id; + } + const privat = raumMit("direkt", null, [idEins, idZwei]); + const gruppe = raumMit("gruppe", "Unter uns", [idEins, idZwei, idDrei]); + + for (const [wer, keks] of [["DogFather", dogi.keks], ["die rechte Hand", hand.keks]]) { + const a = await rufe(keks, `/workspace/api/chat/raeume/${privat}/nachrichten`); + ok(a.status === 404, `${wer} kommt NICHT in ein fremdes Zweier-Gespraech (${a.status})`); + const b = await rufe(keks, `/workspace/api/chat/raeume/${gruppe}/nachrichten`); + ok(b.status === 404, `${wer} kommt NICHT in eine fremde Gruppe (${b.status})`); + const c = await rufe(keks, `/workspace/api/chat/raeume/${privat}/nachrichten`, + { text: "hallo" }, "POST"); + ok(c.status === 404, `${wer} kann dort auch nichts hineinschreiben (${c.status})`); + const liste = (await holen(keks, "/workspace/api/chat/raeume")).raeume || []; + ok(!liste.some((r) => r.id === privat || r.id === gruppe), + `und beides steht nicht in der Liste von ${wer}`); + } + + /* GEGENPROBE: Die Beteiligten kommen sehr wohl hinein. Ohne sie + koennte die Sperre oben auch "diese Raeume gibt es gar nicht" + heissen. */ + const drin = await rufe(eins.keks, `/workspace/api/chat/raeume/${privat}/nachrichten`); + ok(drin.status === 200, `wer dabei ist, kommt hinein (${drin.status})`); +} + +/* ======================================================================= + 4. Besetzung aendern + ======================================================================= */ +console.log("\n=== Wer ist im Kanal ==="); +{ + 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})`); + + const beiDrei = (await holen(drei.keks, "/workspace/api/chat/raeume")).raeume || []; + ok(beiDrei.some((r) => r.id === kanalId), "der Neue sieht den Kanal jetzt"); + const raus = await rufe(zwei.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`); + ok(raus.status === 404, `wer herausgenommen wurde, kommt nicht mehr hinein (${raus.status})`); + + /* NIEMAND VERSCHWINDET RUECKWIRKEND. Die Zeile bleibt mit Datum + stehen -- geloescht wird sie nicht. */ + const zeile = d.prepare( + "SELECT raus_am FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?").get(kanalId, idZwei); + ok(!!zeile && !!zeile.raus_am, "die Zeile bleibt stehen, mit Austrittsdatum"); + + /* DIE LEITUNG LAESST SICH NICHT HINAUSDRAENGEN. Eine leere Liste + muesste alle Nicht-Leitungen entfernen und die Leitung lassen. */ + const b = await rufe(dogi.keks, `/workspace/api/chat/kanal/${kanalId}/mitglieder`, + { mit: [] }, "PUT"); + ok(b.status === 200, `eine leere Besetzung ist erlaubt (${b.status})`); + const nachher = (await holen(dogi.keks, "/workspace/api/chat/raeume")).raeume || []; + ok(nachher.some((r) => r.id === kanalId), "DogFather ist immer noch drin"); + const handNachher = (await holen(hand.keks, "/workspace/api/chat/raeume")).raeume || []; + ok(handNachher.some((r) => r.id === kanalId), "die rechte Hand auch"); + + /* Und wieder zurueck -- fuer den Rest der Pruefung wird jemand + gebraucht, der drin ist, aber nichts anheften darf. */ + await rufe(dogi.keks, `/workspace/api/chat/kanal/${kanalId}/mitglieder`, + { mit: [idEins] }, "PUT"); + const wieder = await rufe(eins.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`); + ok(wieder.status === 200, `zurueckgeholt kommt er wieder hinein (${wieder.status})`); + + const c = await rufe(eins.keks, `/workspace/api/chat/kanal/${kanalId}/mitglieder`, + { mit: [idEins, idZwei] }, "PUT"); + ok(c.status === 404, `ein Teammitglied kann die Besetzung nicht aendern (${c.status})`); + + /* Und diese Route fasst KEINE Gruppen an -- sonst waere sie ein + Werkzeug, um fremde Runden umzubesetzen. */ + const gruppeId = d.prepare( + "SELECT id FROM chat_raeume WHERE art = 'gruppe' ORDER BY id DESC LIMIT 1").get()?.id; + const g = await rufe(dogi.keks, `/workspace/api/chat/kanal/${gruppeId}/mitglieder`, + { mit: [idDogi] }, "PUT"); + ok(g.status === 404, `sie greift nicht auf eine Gruppe durch (${g.status})`); +} + +/* ======================================================================= + 5. Ankuendigungen anheften + ======================================================================= */ +console.log("\n=== Anheften ==="); +{ + async function schreiben(keks, text) { + const a = await rufe(keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`, { text }, "POST"); + return (await a.json())?.nachricht?.id || 0; + } + const n1 = await schreiben(dogi.keks, "Heute 20 Uhr Live, bitte alle da."); + ok(n1 > 0, `eine Nachricht im Kanal (#${n1})`); + + const a = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${n1}/anheften`, { an: true }, "PUT"); + ok(a.status === 200, `DogFather heftet sie an (${a.status})`); + + const verlauf = await holen(eins.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`); + ok((verlauf.angeheftet || []).some((x) => x.id === n1), + "sie steht bei ALLEN im Kanal oben, nicht nur bei ihm"); + ok(verlauf.angeheftet[0]?.heftete === "Filipe", + `und es steht dabei, wer sie angeheftet hat (${verlauf.angeheftet[0]?.heftete})`); + ok(verlauf.darf_anheften === false, "ein Teammitglied bekommt kein Ja zum Anheften"); + ok((verlauf.nachrichten || []).find((x) => x.id === n1)?.angeheftet === true, + "auch im Verlauf ist sie als angeheftet erkennbar"); + + const b = await rufe(eins.keks, `/workspace/api/chat/nachrichten/${n1}/anheften`, { an: true }, "PUT"); + ok(b.status === 403, `ein Teammitglied darf nicht anheften (${b.status})`); + + /* HOECHSTENS DREI. Der vierte muss abgelehnt werden -- und die + Gegenprobe steckt in den drei davor: Gingen die auch nicht, waere + die 409 nichtssagend. */ + const n2 = await schreiben(dogi.keks, "Zweite Ansage."); + const n3 = await schreiben(dogi.keks, "Dritte Ansage."); + const n4 = await schreiben(dogi.keks, "Vierte Ansage."); + const r2 = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${n2}/anheften`, { an: true }, "PUT"); + const r3 = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${n3}/anheften`, { an: true }, "PUT"); + ok(r2.status === 200 && r3.status === 200, "zwei weitere gehen"); + const r4 = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${n4}/anheften`, { an: true }, "PUT"); + ok(r4.status === 409, `die vierte wird abgelehnt (${r4.status})`); + + /* ZURUECKNEHMEN LOEST DEN AUSHANG -- und gibt den Platz frei. Der + zweite Teil ist der wichtigere: Ohne ihn zaehlte die Nachricht + weiter mit, und niemand kaeme darauf, warum sich nichts mehr + anheften laesst. */ + const weg = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${n3}`, undefined, "DELETE"); + ok(weg.status === 200, `die dritte wird zurueckgenommen (${weg.status})`); + const nachWeg = await holen(dogi.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`); + ok(!(nachWeg.angeheftet || []).some((x) => x.id === n3), "sie faellt vom Aushang"); + const r4b = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${n4}/anheften`, { an: true }, "PUT"); + ok(r4b.status === 200, `und der Platz ist wieder frei (${r4b.status})`); + + const los = await rufe(dogi.keks, `/workspace/api/chat/nachrichten/${n1}/anheften`, { an: false }, "PUT"); + ok(los.status === 200, `loesen geht (${los.status})`); + const nachLos = await holen(dogi.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`); + ok(!(nachLos.angeheftet || []).some((x) => x.id === n1), "und sie ist oben verschwunden"); + + /* Wer gar nicht im Raum ist, bekommt 404 -- nicht 403. Sonst liesse + sich durch Ausprobieren herausfinden, welche Nachrichten es gibt. */ + const fremd = await rufe(drei.keks, `/workspace/api/chat/nachrichten/${n2}/anheften`, { an: false }, "PUT"); + ok(fremd.status === 404, `wer nicht im Raum ist, sieht die Nachricht nicht (${fremd.status})`); +} + +/* ======================================================================= + 6. Eine Ankuendigung von vor 300 Nachrichten + ======================================================================= */ +console.log("\n=== Der Aushang haelt laenger als der Verlauf ==="); +{ + const alt = d.prepare( + "SELECT id FROM chat_nachrichten WHERE raum_id = ? ORDER BY id LIMIT 1").get(kanalId).id; + d.prepare("UPDATE chat_nachrichten SET angeheftet_am = ?, angeheftet_von = ? WHERE id = ?") + .run(jetzt, idDogi, alt); + for (let i = 0; i < 300; i++) { + d.prepare("INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt) VALUES (?,?,?,?)") + .run(kanalId, idDogi, `Zeile ${i}`, jetzt); + } + const v = await holen(dogi.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`); + ok((v.nachrichten || []).length === 200, + `der Verlauf liefert 200 Zeilen (${(v.nachrichten || []).length})`); + ok(!(v.nachrichten || []).some((x) => x.id === alt), + "die alte Nachricht ist aus dem Verlauf herausgefallen"); + ok((v.angeheftet || []).some((x) => x.id === alt), + "und steht trotzdem noch oben -- genau dafuer hat der Aushang eine eigene Abfrage"); +} + +/* ======================================================================= + 7. Weggeraeumt heisst weggeraeumt -- auch fuer den Aushang + ======================================================================= */ +console.log("\n=== Wer wegraeumt, sieht auch den Aushang nicht mehr ==="); +{ + const vorher = await holen(eins.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`); + ok((vorher.angeheftet || []).length > 0, "vorher steht etwas oben"); + const weg = await rufe(eins.keks, `/workspace/api/chat/raeume/${kanalId}`, undefined, "DELETE"); + ok(weg.status === 200, `das Teammitglied raeumt den Kanal weg (${weg.status})`); + const nachher = await holen(eins.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`); + ok((nachher.angeheftet || []).length === 0, + `bei ihm ist der Aushang jetzt leer (${(nachher.angeheftet || []).length})`); + const beiDogi = await holen(dogi.keks, `/workspace/api/chat/raeume/${kanalId}/nachrichten`); + ok((beiDogi.angeheftet || []).length > 0, + "bei DogFather steht er unveraendert -- weggeraeumt wird nur bei einem selbst"); +} + +/* ======================================================================= + 8. Wer steht ueberhaupt in der Personenauswahl? + ======================================================================= + + WARUM DAS HIER STEHT, und zwar seit heute: Bis eben zeichnete die + Auswahl im Browser nur die fuenf Rollen aus bereiche.js. Team Dogi + steht dort nicht (der Rollenname gehoert in keine Datei, die jeder + herunterlaedt) -- und wurde deshalb NICHT GEZEICHNET. Das war ein + Fehler (DogFather konnte niemandem aus seinem Team schreiben), aber + es war nebenbei auch ein Netz: Selbst wenn der Server jemanden + mitgeschickt haette, den man nicht sehen soll, waere er unsichtbar + geblieben. + + Dieses Netz ist jetzt weg -- die Auswahl zeichnet, was ankommt. + Also muss bewiesen sein, dass beim Falschen nichts ankommt. Genau + diese Sorte Nebeneffekt ist gemeint, wenn im Haus steht: eine + Zustaendigkeit, die nur zufaellig aus einer anderen Regel + herausfaellt, faellt beim naechsten Umbau zufaellig wieder heraus. + ======================================================================= */ +console.log(""); +console.log("=== Die Personenauswahl ==="); +{ + /* Manager und Spicy Media melden sich an der AGENTURWAND an -- an der + Crew-Wand haetten sie gar keine Kachel. Zwei Waende, zwei Tueren; + die Pruefung benutzt beide, weil das Haus beide hat. */ + const cigdem = await anmelden("manager", "CODE-MANA-0001", "workspace.dogfather-universe.com"); + const vanvan = await anmelden("spicy", "CODE-SPIC-0001", "workspace.dogfather-universe.com"); + ok(cigdem.ok && vanvan.ok, "Manager und Spicy Media kommen ueber die Agenturwand herein"); + + const namen = async (keks) => { + const { personen } = await holen(keks, "/workspace/api/chat/partner"); + return (personen || []).map((p) => p.name); + }; + const teamNamen = ["Ayla", "Ben", "Cem", "Vanessa"]; + + const beiDogi = await namen(dogi.keks); + ok(teamNamen.every((n) => beiDogi.includes(n)), + `DogFather hat sein Team in der Auswahl (${beiDogi.join(", ")})`); + + const beiCigdem = await namen(cigdem.keks); + ok(!teamNamen.some((n) => beiCigdem.includes(n)), + `ein Manager hat es NICHT (${beiCigdem.join(", ") || "leer"})`); + ok(beiCigdem.length > 0, "und seine Auswahl ist trotzdem nicht leer"); + + const beiVanVan = await namen(vanvan.keks); + ok(!teamNamen.some((n) => beiVanVan.includes(n)), + `Spicy Media hat es NICHT (${beiVanVan.join(", ") || "leer"})`); + ok(beiVanVan.includes("Cigdem"), "sieht aber die Agentur"); + /* NIEMAND STEHT IN SEINER EIGENEN AUSWAHL. Bei Spicy Media stand er + es doch -- siehe schreibbareIds() in workspace.js. Der Fall + entsteht nur, wenn es jemanden zu verbergen gibt, und genau den + stellt diese Pruefung her. */ + ok(!beiVanVan.includes("VanVan"), "und nicht sich selbst"); + ok(!beiDogi.includes("Filipe"), "DogFather ebenfalls nicht"); + ok(!beiCigdem.includes("Cigdem"), "der Manager auch nicht"); + + /* DIE UEBERSCHRIFT KOMMT MIT. Ohne sie faellt der Browser auf + "Weitere" zurueck -- richtig gezeichnet, aber ohne Auskunft. */ + const roh = await holen(dogi.keks, "/workspace/api/chat/partner"); + const ayla = (roh.personen || []).find((p) => p.name === "Ayla"); + ok(!!ayla?.gruppe, `jede Person bringt ihre Ueberschrift mit ("${ayla?.gruppe}")`); +} + +/* ======================================================================= + 9. Eine Quelle fuer die Zugriffsfrage + ======================================================================= */ +console.log("\n=== Kein Aufruf mit blanker Nummer ==="); +{ + /* istDrin() braucht seit heute die PERSON, weil die Antwort an der + Rolle haengt. Ein Aufruf mit `.id` liefe in den TypeError -- aber + erst dann, wenn jemand genau diese Route benutzt. Hier faellt es + sofort auf. + + GEGENPROBE zur Suche selbst: Sie muss ein absichtlich falsches + Muster erkennen. Sonst waere sie gruen, weil sie nie etwas findet. */ + const quelle = readFileSync(new URL("./workspace-chat.js", import.meta.url), "utf8"); + const muster = /istDrin\([^)]*\.id\s*\)/g; + const treffer = quelle.match(muster) || []; + ok(treffer.length === 0, `kein Aufruf uebergibt nur die Nummer (${treffer.join(", ") || "keiner"})`); + const aufrufe = (quelle.match(/istDrin\(/g) || []).length; + ok(aufrufe >= 8, `und es gibt wirklich Aufrufe zu pruefen (${aufrufe})`); + ok("istDrin(raumId, req.person.id)".match(muster)?.length === 1, + "die Suche erkennt einen falschen Aufruf"); +} + +d.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + +console.log(`\n${fehler === 0 ? "BESTANDEN" : "FEHLGESCHLAGEN"} -- ${geprueft} geprueft, ${fehler} Fehler`); +/* process.exit(), wie in pruef-chat: Der Express-Server und die + offenen Ereignisstroeme halten den Prozess sonst am Leben. */ +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-chat-optik.mjs b/server/pruef-chat-optik.mjs index ed95249b..f9d6b912 100644 --- a/server/pruef-chat-optik.mjs +++ b/server/pruef-chat-optik.mjs @@ -319,6 +319,78 @@ melde("\n=== Gegenprobe ==="); ok(!erfunden, "ein nie geschriebener Satz wird nicht gefunden"); } +/* ======================================================================= + DER FUSS EINER NACHRICHT BLEIBT IN SEINER BLASE (10.09.2026) + + Am Fuss jeder Nachricht stehen die Handgriffe: Zeit, antworten, + zuruecknehmen, kopieren -- und seit Kapitel 7.2 auch anheften. Mit + drei Eintraegen ging die Rechnung auf, mit vier nicht mehr: + Gemessen stand "kopieren" bei 390 px, der haeufigsten Handybreite, + 18 px UEBER der Blase; bei 320 px waren es 75. + + Gefunden hat das kein Testlauf, sondern ein Blick auf ein + Bildschirmfoto. Damit das beim FUENFTEN Handgriff nicht wieder + passiert, wird es ab jetzt gemessen -- und zwar an echten + Bildpunkten, nicht an der Rechnung, die zu dem Fehler gefuehrt hat. + + GEPRUEFT WIRD NICHT DIE ZAHL DER ZEILEN. Ob der Fuss ein- oder + zweizeilig ist, darf sich aendern; dass etwas darin ueber den Rand + haengt, nicht. Ein `flex-wrap: wrap` ist deshalb die Loesung und + keine Schwelle: Es bricht genau dann um, wenn der Platz nicht reicht. + ======================================================================= */ +melde("\n=== Der Fuss bleibt in der Blase ==="); +{ + let gemessen = 0; + let heraus = []; + for (const breite of [320, 360, 390, 430]) { + await luna.seite.setViewportSize({ width: breite, height: 844 }); + await luna.seite.waitForTimeout(350); + const m = await luna.seite.evaluate(() => { + const raus = []; + let stuecke = 0; + for (const f of document.querySelectorAll(".chat-nachricht__fuss")) { + const blase = f.closest(".chat-nachricht__blase")?.getBoundingClientRect(); + if (!blase) continue; + for (const k of f.children) { + stuecke++; + const r = k.getBoundingClientRect(); + if (r.width === 0) continue; /* ausgeblendet: kein Fall */ + if (r.right > blase.right + 0.5 || r.left < blase.left - 0.5) { + raus.push((k.textContent || "?").trim() + " +" + Math.round(r.right - blase.right)); + } + } + } + return { raus, stuecke }; + }); + gemessen += m.stuecke; + if (m.raus.length) heraus.push(`${breite}px: ${m.raus.join(", ")}`); + } + /* DIE ZAHL GEHOERT IN DIE BEDINGUNG, nicht nur in den Meldetext. + Findet der Selektor nichts, waere `heraus.length === 0` gruen -- + und haette nichts angesehen. Genau der Fehler vom 01.09. */ + ok(gemessen >= 12 && heraus.length === 0, + `${gemessen} Fusselemente auf vier Breiten gemessen, nichts haengt heraus` + + (heraus.length ? ` -- ${heraus.join(" | ")}` : "")); + + /* GEGENPROBE: Kann die Messung ueberhaupt "haengt heraus" sagen? + Ein Element wird absichtlich hinausgeschoben. Sagt sie dann + weiterhin "alles gut", misst sie gar nichts. */ + const erkannt = await luna.seite.evaluate(() => { + const f = document.querySelector(".chat-nachricht__fuss"); + const k = f?.lastElementChild; + if (!k) return null; + const alt = k.style.marginLeft; + k.style.marginLeft = "400px"; + const blase = f.closest(".chat-nachricht__blase").getBoundingClientRect(); + const draussen = k.getBoundingClientRect().right > blase.right + 0.5; + k.style.marginLeft = alt; + return draussen; + }); + ok(erkannt === true, "und sie erkennt ein absichtlich hinausgeschobenes Element"); + await luna.seite.setViewportSize({ width: 1280, height: 900 }); + await luna.seite.waitForTimeout(300); +} + await luna.ctx.close(); await pat.ctx.close(); await browser.close(); diff --git a/server/workspace-chat.js b/server/workspace-chat.js index 1f174a30..f922c532 100644 --- a/server/workspace-chat.js +++ b/server/workspace-chat.js @@ -44,6 +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, } from "./workspace.js"; export const chatRouter = express.Router(); @@ -95,12 +96,42 @@ chatRouter.use("/workspace/api/chat", angemeldet); * DIE WICHTIGSTE FUNKTION DER DATEI. Jede Route, die einen Verlauf * liest oder etwas hineinschreibt, geht hier durch. Wer sie umgeht, * macht aus einer Raumnummer einen Schlüssel. */ -function istDrin(raumId, personId) { +/* SIE NIMMT DIE PERSON, NICHT IHRE NUMMER (10.09.2026, Kapitel 7.2). + * + * Seit es Kanaele gibt, haengt die Antwort auch an der ROLLE. Eine + * Nummer allein koennte die Frage nicht mehr beantworten -- und haette + * sie trotzdem beantwortet, mit "nein", ohne ein Wort dazu. Deshalb + * wirft ein Aufruf mit einer Nummer einen Fehler statt weiterzulaufen: + * Er faellt beim ersten Durchgang auf, nicht im Betrieb. */ +function istDrin(raumId, person) { + if (!person || typeof person !== "object") { + throw new TypeError("istDrin() braucht die Person, nicht ihre Nummer."); + } try { const z = db().prepare( "SELECT 1 FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ? AND raus_am IS NULL") - .get(raumId, personId); - return !!z; + .get(raumId, person.id); + if (z) return true; + + /* KANAELE STEHEN DER TEAMFUEHRUNG OFFEN -- Kapitel 7.2: "Owner/ + rechte Hand haben Zugriff auf alle Kanaele." + + DIE AUSNAHME STEHT HIER und nicht in den sieben Routen darueber. + Dort waere sie in sechs davon gelandet und in der siebten nicht, + und die siebte waere die gewesen, die keiner ausprobiert. + + SIE GILT NUR FUER 'kanal'. Zweier-Gespraeche bleiben zu, auch + fuer DogFather -- Kapitel 7.2 sagt das ausdruecklich: "Private + 1:1-Chats: ausschliesslich fuer die beteiligten Personen + sichtbar - kein Zugriff durch Owner/rechte Hand." Und Gruppen + bleiben ebenfalls zu: Wer eine Gruppe aufmacht, hat sich fuer + einen geschlossenen Kreis entschieden, und den nachtraeglich + still zu oeffnen waere das Gegenteil dessen, was er getan hat. + Ein Kanal ist etwas anderes: Er gehoert dem Team, nicht den + Anwesenden. */ + if (!fuehrtTeamDogi(person)) return false; + const raum = db().prepare("SELECT art FROM chat_raeume WHERE id = ?").get(raumId); + return raum?.art === "kanal"; } catch { return false; } } @@ -286,7 +317,86 @@ function geloeschtBis(raumId, personId) { * Ein Zweier-Gespräch heißt für jede Seite anders -- nach dem * Gegenüber. Deshalb steht der Name nicht in der Tabelle: Er wäre für * eine der beiden Seiten immer falsch. */ +/* ===================================================================== + KATEGORIE-KANAELE (10.09.2026, Kapitel 7.2) + ===================================================================== */ + +/** Hoechstens drei Ankuendigungen je Raum. + * + * Nicht eine (dann kann man Regeln, Live-Plan und Frist nicht + * gleichzeitig oben haben) und nicht beliebig viele: Eine Pinnwand, + * die scrollt, ist keine Pinnwand mehr, sondern ein zweiter Verlauf. + * Wer die vierte anheften will, muss sich fuer eine entscheiden -- + * und genau diese Entscheidung ist der Sinn der Funktion. */ +const PIN_MAX = 3; + +/** Der Name eines Kanals kommt aus der Kategorie, nicht aus einem Feld. + * + * Ein freies Namensfeld waere die naheliegende Freiheit -- und der + * sichere Weg zu "Clipping" neben "Clipping-Team", mit je der Haelfte + * der Nachrichten darin. Wer die Kategorie umbenennt, benennt damit + * jeden Kanal um; sie stehen nicht doppelt. */ +function kanalName(kategorie) { + return MODI_KATEGORIEN.find((k) => k.wert === kategorie)?.name || "Kanal"; +} + +/** Wer darf hier etwas anheften? + * + * Die Teamfuehrung ueberall -- so steht es im Dokument. UND wer eine + * Gruppe angelegt hat, in seiner eigenen: Der Chat gehoert nicht nur + * Team Dogi, auch Manager und Scouts fuehren Gruppen. Waere das + * Anheften allein der Teamfuehrung vorbehalten, gaebe es die Funktion + * fuer die Haelfte des Hauses nicht -- und sie waere trotzdem + * sichtbar. */ +function darfAnheften(person, raumId) { + if (fuehrtTeamDogi(person)) return true; + try { + return !!db().prepare(`SELECT 1 FROM chat_teilnehmer + WHERE raum_id = ? AND person_id = ? AND raus_am IS NULL AND leitung = 1`) + .get(raumId, person.id); + } catch { return false; } +} + +/** Traegt die Teamfuehrung in jeden Kanal ein, in dem sie fehlt. + * + * WARUM SELBSTHEILEND UND NICHT AN DREI HAKEN: Die Zeile muesste sonst + * beim Anlegen eines Kanals, beim Anlegen einer Person und beim + * Rollenwechsel entstehen -- drei Stellen, von denen die dritte beim + * naechsten Umbau vergessen wird. Hier laeuft es genau dann, wenn es + * gebraucht wird: beim Blick in die Gespraechsliste, und nur fuer + * zwei Rollen. + * + * `gelesen_bis` STARTET AM AKTUELLEN STAND, nicht bei null. Wer heute + * 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. */ +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; + } catch (fehler) { + /* Ein Fehler hier darf die Gespraechsliste nicht kosten. Der + Zugriff selbst haengt ohnehin nicht daran -- istDrin() laesst die + Teamfuehrung auch ohne Zeile in einen Kanal. */ + console.error("[chat] Kanal-Abgleich:", fehler?.message); + return 0; + } +} + function raumName(raum, teilnehmer, ichId) { + if (raum.art === "kanal") return raum.name || kanalName(raum.kategorie); if (raum.art === "gruppe") return raum.name || "Gruppe"; const andere = teilnehmer.filter((t) => t.id !== ichId); return andere.map((t) => t.name).join(", ") || "Gespräch"; @@ -305,10 +415,19 @@ chatRouter.get("/workspace/api/chat/partner", (req, res) => { const werte = ids === null ? [req.person.id] : ids; if (ids !== null && !ids.length) return res.json({ personen: [] }); + /* DIE UEBERSCHRIFT KOMMT MIT (10.09.2026). + + Die Auswahl im Browser zeichnete nach der Rollenfolge aus + bereiche.js -- und wer dort nicht steht, wurde nicht gezeichnet. + Still: keine Luecke, kein Fehler, nur ein Mensch, den es fuer + die Auswahl nicht gab. Mit einem Text vom Server kann der + Browser auch eine Rolle zeigen, deren Namen er gar nicht kennen + darf. */ res.json({ personen: db().prepare( `SELECT id, name, rolle FROM personen WHERE aktiv = 1 AND ${wo} - ORDER BY ` + ROLLEN_SORTIERUNG + ", name").all(...werte), + ORDER BY ` + ROLLEN_SORTIERUNG + ", name").all(...werte) + .map((p) => ({ ...p, gruppe: ROLLEN_GRUPPE[p.rolle] || "Weitere" })), }); } catch (fehler) { console.error("[chat] Partner:", fehler?.message); @@ -330,8 +449,16 @@ chatRouter.get("/workspace/api/chat/raeume", (req, res) => { mit dem Neuen (id > geloescht_bis). * Es kam nichts -> es bleibt weg. Auch dann, wenn es nie eine Nachricht gab; dafür steht geloescht_am. */ + /* ERST ANGLEICHEN, DANN LESEN. Ein Kanal, in dem die Teamfuehrung + keine Zeile hat, waere ihr zugaenglich (istDrin laesst sie + hinein) und stuende trotzdem nicht in ihrer Liste -- erreichbar + nur ueber einen Link, den ihr jemand schickt. Das ist genau die + Sorte Loch, die man nicht bemerkt, weil nichts kaputt aussieht. */ + kanaeleAngleichen(req.person); + const reihen = db().prepare(` - SELECT r.id, r.art, r.name, r.letzte_am, t.gelesen_bis, t.leitung, t.geloescht_bis + SELECT r.id, r.art, r.name, r.kategorie, r.letzte_am, + t.gelesen_bis, t.leitung, t.geloescht_bis FROM chat_raeume r JOIN chat_teilnehmer t ON t.raum_id = r.id AND t.person_id = ? WHERE t.raus_am IS NULL @@ -361,6 +488,7 @@ chatRouter.get("/workspace/api/chat/raeume", (req, res) => { return { id: r.id, art: r.art, + kategorie: r.kategorie || null, name: raumName(r, leute, req.person.id), leitung: !!r.leitung, teilnehmer: leute.map((t) => ({ id: t.id, name: t.name, rolle: t.rolle })), @@ -378,7 +506,26 @@ chatRouter.get("/workspace/api/chat/raeume", (req, res) => { ungelesen, }; }); - res.json({ raeume }); + /* WELCHE KANAELE ES NOCH NICHT GIBT -- und nur fuer die, die sie + anlegen duerfen. + + Die Liste steht bewusst NICHT in einer Datei im Browser. Sie + verraet fuer sich genommen nichts Schlimmes, aber sie ist der + Anfang einer Aufzaehlung dessen, was das Team tut; und die + Oberflaeche zeigt den Knopf ohnehin nur, wenn hier etwas + ankommt. Wer nichts anlegen darf, bekommt ein leeres Feld -- und + ein leeres Feld sagt nichts. */ + const darfKanal = fuehrtTeamDogi(req.person); + const belegt = darfKanal + ? new Set(db().prepare("SELECT kategorie FROM chat_raeume WHERE art = 'kanal'") + .all().map((z) => z.kategorie)) + : new Set(); + + res.json({ + raeume, + darf_kanal: darfKanal, + kanal_frei: darfKanal ? MODI_KATEGORIEN.filter((k) => !belegt.has(k.wert)) : [], + }); } catch (fehler) { console.error("[chat] Räume:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); @@ -449,6 +596,173 @@ chatRouter.post("/workspace/api/chat/gruppe", gleicheHerkunft, express.json({ li } }); +/* ===================================================================== + EINEN KANAL ANLEGEN (10.09.2026, Kapitel 7.2) + + Kein Name im Rumpf, nur die Kategorie -- siehe kanalName(). Und keine + Pflicht, gleich jemanden hineinzusetzen: Ein Kanal, den DogFather + morgens anlegt und mittags fuellt, ist ein normaler Vorgang; ihn + dafuer zweimal aufmachen zu muessen waere eine Regel ohne Zweck. + ===================================================================== */ +chatRouter.post("/workspace/api/chat/kanal", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => { + try { + /* 404 und nicht 403: Wer keine Kanaele fuehren darf, soll auch + nicht erfahren, dass es die Moeglichkeit gibt. */ + if (!fuehrtTeamDogi(req.person)) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const kategorie = String(req.body?.kategorie ?? "").trim(); + if (!MODI_KATEGORIEN.some((k) => k.wert === kategorie)) { + return res.status(400).json({ fehler: "Unbekannte Kategorie." }); + } + + const d = db(); + /* Die Regel steht hier UND als eindeutiger Index in der Datenbank. + Doppelt gemoppelt ist es nicht: Hier gibt es eine Meldung, die + jemand lesen kann, und dort eine Zusage, die auch dann gilt, wenn + einmal zwei Anfragen im selben Augenblick ankommen. */ + const schon = d.prepare("SELECT id FROM chat_raeume WHERE art = 'kanal' AND kategorie = ?") + .get(kategorie); + if (schon) { + return res.status(409).json({ fehler: "Diesen Kanal gibt es schon.", raum_id: schon.id }); + } + + const roh = Array.isArray(req.body?.mit) ? req.body.mit : []; + const mit = [...new Set(roh.map(Number).filter((n) => Number.isInteger(n) && n > 0))] + .filter((id) => id !== req.person.id); + /* Jede Nummer einzeln gegen dieselbe Regel wie bei einer Gruppe. + Ein Kanal ist kein Schlupfloch, um jemanden zu erreichen, den man + einzeln nicht anschreiben duerfte. */ + if (mit.some((id) => !darfSchreibenMit(req.person, id))) { + return res.status(403).json({ fehler: "Mit mindestens einer Person kannst du nicht schreiben." }); + } + + const n = jetzt(); + d.prepare(`INSERT INTO chat_raeume (art, name, kategorie, erstellt, erstellt_von) + VALUES ('kanal',?,?,?,?)`) + .run(kanalName(kategorie), kategorie, n, req.person.id); + const raumId = d.prepare("SELECT last_insert_rowid() AS id").get().id; + + /* DIE GANZE TEAMFUEHRUNG KOMMT HINEIN, nicht nur der Anlegende. + kanaeleAngleichen() wuerde das beim naechsten Blick in die Liste + ohnehin nachholen -- aber "beim naechsten Blick" heisst: Die + rechte Hand sieht den Kanal erst, wenn sie die Seite neu laedt. + Hier ist er sofort da. */ + const fuehrung = d.prepare( + "SELECT id FROM personen WHERE aktiv = 1 AND rolle IN ('admin','hand')").all().map((z) => z.id); + const reinLeitung = d.prepare( + "INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis, leitung) VALUES (?,?,?,0,1)"); + for (const id of new Set([req.person.id, ...fuehrung])) reinLeitung.run(raumId, id, n); + + const rein = d.prepare( + "INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis) VALUES (?,?,?,0)"); + let drin = 0; + for (const id of mit) { + if (fuehrung.includes(id)) continue; /* steht schon als Leitung drin */ + if (d.prepare("SELECT 1 FROM personen WHERE id = ? AND aktiv = 1").get(id)) { + rein.run(raumId, id, n); drin++; + } + } + + protokolliere("chat_kanal_angelegt", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${raumId} ${kategorie} (${drin})`.slice(0, 120), + }); + res.status(201).json({ raum_id: raumId, name: kanalName(kategorie) }); + } catch (fehler) { + console.error("[chat] Kanal:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ===================================================================== + WER IST IM KANAL (10.09.2026) + + Ohne diese Route waere ein Kanal beim Anlegen fuer immer festgelegt: + Ein neuer Modi koennte nie in "Clipping" -- der Kanal waere richtig + gebaut und trotzdem unbrauchbar. + + ENTFERNT WIRD MIT `raus_am`, nicht mit DELETE. Wer draussen ist, + schreibt und liest nichts Neues mehr; der Verlauf bis dahin bleibt + aber lesbar, und niemand verschwindet rueckwirkend aus der + Geschichte. Dieselbe Entscheidung wie bei den Gruppen. + ===================================================================== */ +chatRouter.put("/workspace/api/chat/kanal/:id/mitglieder", gleicheHerkunft, + express.json({ limit: "8kb" }), (req, res) => { + try { + if (!fuehrtTeamDogi(req.person)) return res.status(404).json({ fehler: "nicht_gefunden" }); + const raumId = Number(req.params.id); + if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" }); + + const d = db(); + const raum = d.prepare("SELECT id, art FROM chat_raeume WHERE id = ?").get(raumId); + /* Ausdruecklich nur Kanaele. Diese Route auf Gruppen loszulassen + hiesse, dass die Teamfuehrung fremde Gruppen umbesetzen kann -- + etwas voellig anderes, und niemand hat darum gebeten. */ + if (!raum || raum.art !== "kanal") return res.status(404).json({ fehler: "nicht_gefunden" }); + + const roh = Array.isArray(req.body?.mit) ? req.body.mit : []; + const soll = new Set(roh.map(Number).filter((n) => Number.isInteger(n) && n > 0)); + if ([...soll].some((id) => !darfSchreibenMit(req.person, id))) { + return res.status(403).json({ fehler: "Mit mindestens einer Person kannst du nicht schreiben." }); + } + + /* Die Teamfuehrung bleibt unangetastet -- sie hat laut Kapitel 7.2 + Zugriff auf alle Kanaele. Sie aus der Liste entfernen zu koennen + waere eine Tuer, die man hinter sich zuzieht: istDrin() liesse + sie zwar weiter hinein, in der Gespraechsliste stuende der Kanal + aber nicht mehr. */ + const fuehrung = new Set(d.prepare( + "SELECT id FROM personen WHERE rolle IN ('admin','hand')").all().map((z) => z.id)); + const ist = d.prepare( + "SELECT person_id FROM chat_teilnehmer WHERE raum_id = ? AND raus_am IS NULL") + .all(raumId).map((z) => z.person_id).filter((id) => !fuehrung.has(id)); + + const n = jetzt(); + /* WER HEUTE DAZUKOMMT, HAT NICHTS VERPASST. Ohne diesen Stand + staende beim ersten Oeffnen eine rote Zahl ueber allem, was das + Team je in diesem Kanal geschrieben hat -- lesbar ist es + weiterhin, ungelesen war es nie. */ + const hoechste = d.prepare( + "SELECT COALESCE(MAX(id), 0) AS m FROM chat_nachrichten WHERE raum_id = ?").get(raumId).m; + + let dazu = 0, raus = 0; + for (const id of soll) { + if (fuehrung.has(id) || ist.includes(id)) continue; + if (!d.prepare("SELECT 1 FROM personen WHERE id = ? AND aktiv = 1").get(id)) continue; + /* Zurueckgeholt oder neu -- eine Zeile kann es schon geben (mit + raus_am). Ein blankes INSERT liefe dann auf den + Primaerschluessel auf. */ + const wieder = d.prepare( + "SELECT 1 FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?").get(raumId, id); + if (wieder) { + d.prepare("UPDATE chat_teilnehmer SET raus_am = NULL, seit = ? WHERE raum_id = ? AND person_id = ?") + .run(n, raumId, id); + } else { + d.prepare(`INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis) + VALUES (?,?,?,?)`).run(raumId, id, n, hoechste); + } + dazu++; + } + for (const id of ist) { + if (soll.has(id)) continue; + d.prepare("UPDATE chat_teilnehmer SET raus_am = ? WHERE raum_id = ? AND person_id = ?") + .run(n, raumId, id); + raus++; + } + + if (dazu || raus) { + protokolliere("chat_kanal_besetzung", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${raumId} +${dazu} -${raus}`, + }); + } + res.json({ ok: true, dazu, raus, teilnehmer: teilnehmerVon(raumId).map((t) => t.id) }); + } catch (fehler) { + console.error("[chat] Kanal-Besetzung:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + /** Der Verlauf eines Gesprächs. */ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { try { @@ -457,7 +771,7 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { /* Was man nicht sehen darf, existiert nicht -- 404 statt 403, sonst liesse sich durch Ausprobieren herausfinden, welche Gespräche es gibt. */ - if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" }); + if (!istDrin(raumId, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" }); /* Nur die letzten 200. Ein Gespräch, das ein Jahr läuft, soll den Browser nicht mit zehntausend Zeilen bewerfen; ältere holt man @@ -478,7 +792,7 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { const reihen = db().prepare(` SELECT n.id, n.person_id, n.text, n.erstellt, n.weg_am, p.name AS von, p.rolle, p.bild AS von_bild, - n.antwort_auf, + n.antwort_auf, n.angeheftet_am, n.anhang_name, n.anhang_art, n.anhang_typ, n.anhang_groesse, n.anhang_breite, n.anhang_hoehe, a.text AS zitat_text, a.weg_am AS zitat_weg, ap.name AS zitat_von, @@ -500,17 +814,53 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { "SELECT gelesen_bis FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?") .get(raumId, req.person.id)?.gelesen_bis ?? 0; - const raum = db().prepare("SELECT id, art, name FROM chat_raeume WHERE id = ?").get(raumId); + const raum = db().prepare( + "SELECT id, art, name, kategorie FROM chat_raeume WHERE id = ?").get(raumId); const leute = teilnehmerVon(raumId); + /* DIE ANGEHEFTETEN, in einer eigenen Abfrage. + + Sie MUESSEN getrennt geholt werden: Der Verlauf oben liefert nur + die letzten 200 Zeilen. Eine Ankuendigung von vorletzter Woche + faellt da heraus -- und waere damit ausgerechnet dann verschwunden, + wenn sie am laengsten oben stehen sollte. + + DIESELBEN ZWEI GRENZEN WIE DER VERLAUF: `n.id > weg` (was ich + weggeraeumt habe, kommt nicht ueber die Hintertuer zurueck) und + `weg_am IS NULL` (eine zurueckgenommene Nachricht bleibt + zurueckgenommen, auch als Aushang). */ + const weggeraeumt = weg; + 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 + 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); + res.json({ raum: { - id: raum.id, art: raum.art, + id: raum.id, art: raum.art, kategorie: raum.kategorie || null, name: raumName(raum, leute, req.person.id), teilnehmer: leute.map((t) => ({ id: t.id, name: t.name, rolle: t.rolle, bild: t.bild, leitung: !!t.leitung })), }, gelesen_bis: gelesenBis, + /* OB DER KNOPF UEBERHAUPT ERSCHEINT, entscheidet der Server. + Die Oberflaeche koennte `rolle === 'admin'` vergleichen -- aber + 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), + angeheftet: angeheftet.map((a) => ({ + id: a.id, + von: a.von || "Gelöscht", + heftete: a.heftete || null, + seit: a.angeheftet_am, + text: String(a.text || "").slice(0, 200) + || (a.anhang_art === "bild" ? "Foto" : a.anhang_art === "pdf" ? "PDF" : ""), + })), /* Aufsteigend zurück -- gelesen wird von oben nach unten. */ nachrichten: reihen.reverse().map((n) => ({ id: n.id, von_id: n.person_id, von: n.von || "Gelöscht", rolle: n.rolle, @@ -519,6 +869,7 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { zurueckgenommen: !!n.weg_am, erstellt: n.erstellt, selbst: n.person_id === req.person.id, + angeheftet: !!n.angeheftet_am, /* Das Zitat wird GEKUERZT geschickt, nicht ganz: Es ist ein Hinweis auf die Stelle, nicht ihre Wiederholung. Und eine zurueckgenommene Nachricht bleibt auch im Zitat @@ -555,7 +906,7 @@ chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft, try { const raumId = Number(req.params.id); if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" }); - if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" }); + if (!istDrin(raumId, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" }); const text = String(req.body?.text ?? "").trim(); if (!text) return res.status(400).json({ fehler: "leer" }); @@ -640,7 +991,7 @@ chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft, function drinFuerAnhang(req, res, next) { const raumId = Number(req.params.id); if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" }); - if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" }); + if (!istDrin(raumId, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" }); next(); } @@ -733,7 +1084,7 @@ chatRouter.get("/workspace/api/chat/anhang/:id", (req, res) => { anhang_datei, anhang_name, anhang_art, anhang_typ FROM chat_nachrichten WHERE id = ?`).get(id); if (!n || !n.anhang_datei || n.weg_am) return res.status(404).json({ fehler: "nicht_gefunden" }); - if (!istDrin(n.raum_id, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" }); + if (!istDrin(n.raum_id, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" }); if (n.id <= geloeschtBis(n.raum_id, req.person.id)) { return res.status(404).json({ fehler: "nicht_gefunden" }); } @@ -788,7 +1139,7 @@ chatRouter.delete("/workspace/api/chat/raeume/:id", gleicheHerkunft, (req, res) try { const raumId = Number(req.params.id); if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" }); - if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" }); + if (!istDrin(raumId, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" }); const d = db(); const bis = d.prepare("SELECT COALESCE(MAX(id), 0) AS m FROM chat_nachrichten WHERE raum_id = ?") @@ -904,7 +1255,7 @@ chatRouter.put("/workspace/api/chat/raeume/:id/gelesen", gleicheHerkunft, if (!Number.isInteger(raumId) || !Number.isInteger(bis)) { return res.status(400).json({ fehler: "ungueltig" }); } - if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" }); + if (!istDrin(raumId, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" }); /* NUR VORWÄRTS. Ein zu spät eintreffender Aufruf einer älteren Ansicht darf den Stand nicht zurückdrehen -- sonst stünde eine gelesene Nachricht wieder als ungelesen da. */ @@ -918,6 +1269,80 @@ chatRouter.put("/workspace/api/chat/raeume/:id/gelesen", gleicheHerkunft, } }); +/* ===================================================================== + ANKUENDIGUNGEN ANHEFTEN (10.09.2026, Kapitel 7.2) + + "Ankuendigungs-Funktion: Pin-Nachrichten von Owner/rechte Hand an + alle." + + KEINE ZWEITE SORTE NACHRICHT. Naheliegend waere eine eigene Tabelle + "ankuendigungen" gewesen -- und damit ein zweiter Verlauf mit + eigenen Rechten, eigener Suche, eigenem Loeschen. Angeheftet wird + deshalb eine ganz normale Nachricht: Sie steht im Verlauf, sie ist + ueber die Suche zu finden, sie laesst sich zuruecknehmen, und sie + traegt zusaetzlich ein Datum und einen Namen. + + WAS PASSIERT, WENN DER ABSENDER SIE ZURUECKNIMMT: Sie faellt von + selbst ab (siehe unten im Zuruecknehmen). Ein Aushang, dessen Text + "Nachricht zurueckgenommen" lautet, waere die schlechteste aller + Antworten -- er steht oben, faellt auf und sagt nichts. + ===================================================================== */ +chatRouter.put("/workspace/api/chat/nachrichten/:id/anheften", 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, weg_am, angeheftet_am 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 + Faelle; sie zu vermischen hiesse, ueber die eine die andere + verraten zu koennen. */ + 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; + if (an && n.weg_am) { + return res.status(400).json({ fehler: "Eine zurückgenommene Nachricht geht nicht." }); + } + + if (an && !n.angeheftet_am) { + const schon = d.prepare(`SELECT COUNT(*) AS c FROM chat_nachrichten + WHERE raum_id = ? AND angeheftet_am IS NOT NULL AND weg_am IS NULL`).get(n.raum_id).c; + if (schon >= PIN_MAX) { + return res.status(409).json({ + fehler: `Es hängen schon ${PIN_MAX} oben. Löse zuerst eine ab.`, + }); + } + } + + d.prepare("UPDATE chat_nachrichten SET angeheftet_am = ?, angeheftet_von = ? WHERE id = ?") + .run(an ? jetzt() : null, an ? req.person.id : null, id); + + /* ALLE IM RAUM SEHEN ES SOFORT. Ein Aushang, den die anderen erst + beim naechsten Laden bemerken, ist bei einer kurzfristigen + Ansage genau einen Live-Abend zu spaet. Keine Push-Meldung + dazu: Das Anheften ist kein neuer Satz, sondern eine Betonung. */ + chatEreignis(n.raum_id, { raum_id: n.raum_id, id, angeheftet: an }, [], "pin"); + + protokolliere(an ? "chat_angeheftet" : "chat_abgeheftet", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `#${id} in Raum #${n.raum_id}`, + }); + res.json({ ok: true, angeheftet: an }); + } catch (fehler) { + console.error("[chat] Anheften:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + /** Eine eigene Nachricht zurücknehmen. */ chatRouter.delete("/workspace/api/chat/nachrichten/:id", gleicheHerkunft, (req, res) => { try { @@ -926,7 +1351,7 @@ chatRouter.delete("/workspace/api/chat/nachrichten/:id", gleicheHerkunft, (req, const n = db().prepare( "SELECT id, raum_id, person_id, anhang_datei FROM chat_nachrichten WHERE id = ?").get(id); - if (!n || !istDrin(n.raum_id, req.person.id)) { + if (!n || !istDrin(n.raum_id, req.person)) { return res.status(404).json({ fehler: "nicht_gefunden" }); } /* NUR EIGENE. DogFather ebenfalls nicht: Fremde Worte aus einem @@ -945,8 +1370,15 @@ chatRouter.delete("/workspace/api/chat/nachrichten/:id", gleicheHerkunft, (req, einer. Erst die Zeile leeren, dann die Datei -- scheitert das Entfernen (Datei schon weg), ist der Verweis trotzdem fort. */ const dateiWeg = n.anhang_datei; + /* UND SIE FAELLT VOM AUSHANG (10.09.2026). Ohne diese zwei + Spalten stuende die Nachricht weiter oben angeheftet -- mit dem + Text "Nachricht zurueckgenommen", an der auffaelligsten Stelle + des Raums. Die Anzeige filtert zwar auf `weg_am IS NULL`, aber + der Platz waere belegt: PIN_MAX zaehlt mit, und niemand kaeme + darauf, warum sich nichts mehr anheften laesst. */ db().prepare(`UPDATE chat_nachrichten SET text = '', weg_am = ?, + angeheftet_am = NULL, angeheftet_von = NULL, anhang_datei = NULL, anhang_name = NULL, anhang_art = NULL, anhang_typ = NULL, anhang_groesse = NULL, anhang_breite = NULL, anhang_hoehe = NULL diff --git a/server/workspace-teamlage.js b/server/workspace-teamlage.js index 7b16a7ed..fac14237 100644 --- a/server/workspace-teamlage.js +++ b/server/workspace-teamlage.js @@ -33,7 +33,7 @@ ===================================================================== */ import express from "express"; import { - db, sitzungLesen, istDogFather, heuteLokal, protokolliere, echteIp, + db, sitzungLesen, istDogFather, fuehrtTeamDogi, heuteLokal, protokolliere, echteIp, } from "./workspace.js"; import { MODI_PUNKTE } from "./workspace-modi-punkte.js"; import { checklistenTabellen } from "./workspace-checkliste.js"; @@ -112,8 +112,12 @@ function stufeVon(wert) { return STUFEN.includes(String(wert || "")) ? String(wert) : "probe"; } +/* AUS DER EINEN QUELLE (10.09.2026). Hier stand + `istDogFather(person) || person?.rolle === "hand"` -- dieselbe Regel + noch einmal. Beim naechsten Mal (den Kategorie-Kanaelen) waere sie + ein drittes Mal dagestanden. Jetzt fragen alle dieselbe Funktion. */ function darfEingang(person) { - return istDogFather(person) || person?.rolle === "hand"; + return fuehrtTeamDogi(person); } teamlageRouter.get("/workspace/api/teamlage", (req, res) => { diff --git a/server/workspace.js b/server/workspace.js index 6773934d..d614271f 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -115,6 +115,21 @@ const LEITUNG = new Set(["spicy", "admin", "manager"]); export const istLeitung = (person) => !!person && LEITUNG.has(person.rolle); export const istDogFather = (person) => !!person && person.rolle === "admin"; +/* WER FUEHRT TEAM DOGI? (10.09.2026) + * + * DogFather und seine rechte Hand -- und sonst niemand. Spicy Media und + * die Manager stehen bewusst NICHT darin: Sie fuehren die Agentur, nicht + * das Team. + * + * WARUM DAS HIER STEHT UND NICHT DORT, WO ES GEBRAUCHT WIRD: Es wurde + * schon zweimal gebraucht -- im Eingang (`darfEingang`) und jetzt bei + * den Kategorie-Kanaelen. Beim dritten Mal waere es dreimal + * hingeschrieben, und die dritte Abschrift ist die, die eine Rolle + * vergisst. Der Eingang ruft ab jetzt hierher. + */ +export const fuehrtTeamDogi = (person) => + !!person && (person.rolle === "admin" || person.rolle === "hand"); + /* ======================================================================= SPICY MEDIA (07.09.2026) @@ -262,6 +277,36 @@ export const ROLLEN_NAME = { modi: "Modi", }; +/* DIE UEBERSCHRIFT UEBER EINER PERSONENLISTE (10.09.2026). + * + * Sie ist NICHT dasselbe wie das Namensschild: Ueber einer Liste steht + * die Mehrzahl ("Scouts"), am einzelnen Menschen die Einzahl ("Scout"). + * Der Browser hat diese Zuordnung in bereiche.js ebenfalls -- aber nur + * fuer die fuenf Rollen, die dort stehen duerfen. + * + * WARUM SIE HIER GEBRAUCHT WIRD: In der Personenauswahl des Chats + * wurde nach `ROLLENFOLGE` aus bereiche.js gezeichnet. Wer dort nicht + * vorkommt, wurde nicht gezeichnet -- ohne Fehler, ohne Luecke, ohne + * Hinweis. Team Dogi kommt dort nicht vor und darf es auch nicht (der + * Rollenname gehoert in keine Datei, die jeder herunterlaedt). Folge: + * DogFather konnte ueber die Auswahl niemandem aus seinem Team + * schreiben und keinen in einen Kanal setzen. Aufgefallen ist es nicht + * beim Lesen, sondern beim Hinsehen: Der Kanal hatte hinterher zwei + * Leute statt vier. + * + * Jetzt schickt der Server die Ueberschrift mit. Der Browser braucht + * dafuer keinen Rollennamen zu kennen -- er bekommt einen Text. + */ +export const ROLLEN_GRUPPE = { + ...ROLLEN_NAME, + scout: "Scouts", + /* Beide unter EINER Ueberschrift: In der Auswahl sucht man einen + Menschen, keine Rangordnung -- und zwei Ueberschriften mit je + einem Namen darunter waeren mehr Gliederung als Inhalt. */ + hand: "Team Dogi", + modi: "Team Dogi", +}; + /* ===================================================================== DIE KACHELN EINES MODIS (10.09.2026) @@ -1131,6 +1176,40 @@ function umstellungen(d) { sich keine Aufgabe machen, ohne zu raten, welcher Satz gemeint war. */ ["eintraege", "einsatz", "TEXT"], + + /* KATEGORIE-KANAELE (10.09.2026, Kapitel 7.2). + + "Kategorie-Kanaele: z. B. Clipping-Team, Live-Moderation -- + Owner/rechte Hand haben Zugriff auf alle Kanaele." + + Ein Kanal ist KEINE vierte Tabelle, sondern eine dritte Art Raum. + Damit gilt fuer ihn ohne eine einzige neue Zeile alles, was schon + da ist: Verlauf, Anhaenge, Suche, Ungelesen-Zaehler, der + Live-Strom, das Wegraeumen. Eine eigene Tabelle haette all das + ein zweites Mal gebraucht -- und die zweite Fassung waere die + gewesen, in der die Zugriffsregel fehlt. + + WELCHE Zustaendigkeit, steht in kategorie und kommt aus + MODI_KATEGORIEN -- derselben Liste, aus der auch die Aufgaben + ihre Kategorie nehmen. Ein Kanal "Clipping" und Aufgaben + "clipping" waeren sonst zwei Woerter fuer dieselbe Sache. */ + ["chat_raeume", "kategorie", "TEXT"], + + /* ANGEHEFTETE NACHRICHTEN (10.09.2026, Kapitel 7.2). + + "Ankuendigungs-Funktion: Pin-Nachrichten von Owner/rechte Hand + an alle." + + Am Anheften haengt ein ZWEITER Name (angeheftet_von) und nicht + nur ein Datum. Eine Ankuendigung ohne Absender ist ein Aushang + ohne Unterschrift -- und wer sie geloest hat, ist genau die + Frage, die spaeter gestellt wird. + + KEIN FREMDSCHLUESSEL AUF personen: Wird jemand geloescht, soll + die Ankuendigung nicht mitverschwinden. Dieselbe Ueberlegung wie + bei antwort_auf weiter oben. */ + ["chat_nachrichten", "angeheftet_am", "TEXT"], + ["chat_nachrichten", "angeheftet_von", "INTEGER"], ]) { try { const vorhanden = d.prepare(`PRAGMA table_info(${tabelle})`).all().map((s) => s.name); @@ -1695,6 +1774,36 @@ function umstellungen(d) { Vorschlag geworden ist. */ checkListeErweitern(d, "eintraege", "status", "angenommen", ["offen", "erledigt", "angenommen", "abgelehnt"], jetztStempel); + + /* DIE DRITTE RAUMART (10.09.2026, Kapitel 7.2). Auf einer frischen + Anlage steht 'kanal' schon im Bauplan -- dann findet der Aufruf die + Regel bereits erweitert und tut nichts. */ + checkListeErweitern(d, "chat_raeume", "art", "kanal", + ["direkt", "gruppe", "kanal"], jetztStempel); + + /* EIN KANAL JE ZUSTAENDIGKEIT, und die Datenbank haelt das fest. + + Die Regel steht auch in der Route -- aber eine Regel, die nur in + einer Route steht, gilt genau so lange, bis jemand eine zweite + Route schreibt. Zwei Kanaele "Clipping" waeren zwei halbe + Verlaeufe, und man merkt es erst, wenn jemand die Antwort im + falschen sucht. + + TEILINDEX (`WHERE art = 'kanal'`): Gruppen und Zweier-Gespraeche + haben gar keine Kategorie; ohne die Bedingung waere schon die + zweite Gruppe mit NULL ein Verstoss -- nein, NULL zaehlt in SQLite + nicht als Dublette, aber der Index waere trotzdem groesser als + noetig und wuerde etwas versprechen, was er nicht meint. + + ER STEHT HIER UNTEN und nicht im Bauplan: Die Spalte `kategorie` + entsteht auf einer bestehenden Anlage erst durch die Schleife + weiter oben -- im Bauplan gaebe es sie beim Start noch nicht. */ + try { + d.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_kanal_kategorie + ON chat_raeume (kategorie) WHERE art = 'kanal'`); + } catch (fehler) { + console.error("[workspace] Kanal-Index:", fehler?.message); + } } export function db() { @@ -1902,11 +2011,17 @@ export function db() { CREATE TABLE IF NOT EXISTS chat_raeume ( id INTEGER PRIMARY KEY AUTOINCREMENT, art TEXT NOT NULL DEFAULT 'direkt' - CHECK (art IN ('direkt','gruppe')), - /* Nur Gruppen haben einen Namen. Ein Zweier-Gespraech heisst - immer nach dem Gegenueber -- und zwar fuer jeden anders. Ein - gespeicherter Name waere fuer eine der beiden Seiten falsch. */ + CHECK (art IN ('direkt','gruppe','kanal')), + /* Gruppen und Kanaele haben einen Namen. Ein Zweier-Gespraech + heisst immer nach dem Gegenueber -- und zwar fuer jeden + anders. Ein gespeicherter Name waere fuer eine der beiden + Seiten falsch. + Bei einem Kanal steht in kategorie, WORUM es geht; der Name + wird daraus gebildet. Zwei Namen fuer dieselbe Zustaendigkeit + sind der sichere Weg zu zwei Kanaelen mit je der Haelfte der + Nachrichten darin. */ name TEXT, + kategorie TEXT, erstellt TEXT NOT NULL, erstellt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL, /* Der Zeitpunkt der letzten Nachricht, mitgefuehrt. Ohne ihn @@ -4047,5 +4162,28 @@ export const sichtbareCreatorIds = (person) => ohneVerborgene(sichtbareCreatorId export const sichtbarePersonenIds = (person) => ohneVerborgene(sichtbarePersonenIdsRoh(person), person); export const betreuerIds = (person) => ohneVerborgene(betreuerIdsRoh(person), person); export const einladbareIds = (person) => ohneVerborgene(einladbareIdsRoh(person), person); -export const schreibbareIds = (person) => ohneVerborgene(schreibbareIdsRoh(person), person); +/* SICH SELBST NICHT -- UND ZWAR AUCH DANN NICHT, WENN DIE LISTE + AUSGESCHRIEBEN WIRD (10.09.2026). + + schreibbareIdsRoh() endet mit `basis.delete(person.id)`. Das greift + aber nur, solange eine Liste gebaut wird. Wer `siehtAlles` hat, + bekam `null` = jeder, und die Route setzte dafuer `id <> ?` ein -- + auch richtig. + + Dazwischen liegt der Fall, den beide nicht abdecken: Sobald es + jemanden zu VERBERGEN gibt, schreibt ohneVerborgene() aus `null` + eine echte Liste -- und die enthaelt alle ausser den Verborgenen, + also auch die fragende Person selbst. Spicy Media stand dadurch in + der eigenen Auswahl, und darfSchreibenMit() haette ein Gespraech mit + sich selbst durchgelassen. + + Aufgefallen ist das nicht beim Lesen des Codes, sondern an einer + Zeile Messausgabe: "Spicy Media hat es NICHT (VanVan, Filipe, + Cigdem)" -- der erste Name war ihr eigener. Der Fall entsteht erst, + seit es ueberhaupt jemanden zu verbergen gibt; vorher konnte es ihn + nicht geben. */ +export const schreibbareIds = (person) => { + const ids = ohneVerborgene(schreibbareIdsRoh(person), person); + return ids === null ? null : ids.filter((i) => i !== person?.id); +}; export const pipelineIds = (person) => ohneVerborgene(pipelineIdsRoh(person), person); diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index 05e5b235..c550a4a5 100644 --- a/workspace/app.webmanifest +++ b/workspace/app.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/workspace-192.png?v=202609101807", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609102008", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609101807", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609102008", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609101807", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609102008", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609101807", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609102008", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/chat.css b/workspace/assets/css/chat.css index 41375dd0..ae1b801f 100644 --- a/workspace/assets/css/chat.css +++ b/workspace/assets/css/chat.css @@ -340,8 +340,27 @@ Die Kinder erben sie (color: inherit). Vier eigene Farbangaben wären vier Stellen, die beim nächsten Mal auseinanderlaufen -- der Fehler, der in diesem Haus am häufigsten vorkommt. */ +/* VIER HANDGRIFFE PASSEN NICHT IN EINE ZEILE (10.09.2026). + + Hier stand `display: flex` ohne Umbruch. Mit drei Eintraegen (Zeit, + antworten, kopieren) ging die Rechnung auf. Seit "anheften" + danebensteht, geht sie nicht mehr: Gemessen an echten Bildpunkten + stand "kopieren" bei 390 px -- der haeufigsten Handybreite -- 18 px + ueber der Blase, bei 320 px waren es 75. Die Zeile lief einfach + weiter, ueber ihren eigenen Rand hinaus. + + DIE LEHRE IST NICHT "eine Schwelle einbauen". Das waere derselbe + Fehler wie in der Kopfleiste am 06.09., nur mit einer anderen Zahl + -- und beim fuenften Eintrag stuende sie wieder falsch da. + `flex-wrap: wrap` ist eine REGEL statt einer Rechnung: Sie bricht + genau dann um, wenn der Platz nicht reicht, und zwar unabhaengig + davon, wie viele Handgriffe es einmal sein werden. + + `row-gap` kleiner als `column-gap`: Zwei Zeilen Fusstext sollen wie + ein Block wirken, nicht wie zwei Absaetze. */ .chat-nachricht__fuss { - display: flex; align-items: center; gap: 9px; + display: flex; align-items: center; flex-wrap: wrap; + column-gap: 9px; row-gap: 2px; margin-top: 4px; color: var(--text-leise); } @@ -1080,3 +1099,159 @@ @media (forced-colors: active) { .chat-datei, .chat__ablage { border: 1px solid CanvasText; } } + +/* ===================================================================== + KATEGORIE-KANÄLE UND ANGEHEFTETE ANKÜNDIGUNGEN (10.09.2026) + + Kapitel 7.2 des Anforderungsdokuments: "Kategorie-Kanäle" und + "Ankündigungs-Funktion: Pin-Nachrichten von Owner/rechte Hand an + alle." + + DER TON IST GEDÄMPFTES BERNSTEIN, nicht Signalgelb. Eine Ankündigung + steht dauerhaft oben im Bild – sie ist das Element, das am längsten + im Blick liegt, und genau deshalb darf es nicht das lauteste sein. + Auffallen soll sie durch ihre POSITION, nicht durch ihre Farbe. + ===================================================================== */ + +.chat { + /* Bernstein, entsättigt. Auf dem dunklen Grund der Seite kommt es + als warmes Signal an, ohne zu blenden. */ + --pin: #d9ae62; +} + +/* ---- Ein Kanal in der Gesprächsliste -------------------------------- */ +/* Die Raute statt einer Zahl oder Initiale: Ein Kanal hat keine feste + Größe (es kommen Leute dazu) und keinen Menschen dahinter. Eckiger + als ein Kreis, runder als eine Gruppe -- so sind die drei Arten auch + ohne Farbe auseinanderzuhalten. */ +.chat-raum__zeichen[data-kanal="ja"] { + --r: var(--pin); + border-radius: 10px; + font-size: 1.05rem; + font-weight: 700; +} + +/* ---- Die Pinnwand über dem Verlauf ---------------------------------- */ +.chat-pins { + flex: none; + display: flex; flex-direction: column; gap: 6px; + padding: 8px 12px; + border-bottom: 1px solid var(--rand); + background: color-mix(in srgb, var(--pin) 6%, transparent); +} +/* Drei Aushänge sind das Höchste (PIN_MAX im Server). Selbst dann + bleibt die Wand niedrig genug, dass der Verlauf die Seite behält -- + mehr als ein Drittel der Höhe darf sie nie nehmen. */ +.chat-pin { + display: flex; align-items: stretch; gap: 8px; + border: 1px solid color-mix(in srgb, var(--pin) 26%, transparent); + border-radius: 10px; + background: rgba(255, 255, 255, .025); +} +.chat-pin__auf { + flex: 1; min-width: 0; + display: block; text-align: left; + appearance: none; border: 0; background: none; + padding: 7px 10px; + font: inherit; color: inherit; cursor: pointer; + border-radius: 10px 0 0 10px; +} +.chat-pin__auf:hover { background: rgba(255, 255, 255, .03); } +.chat-pin__auf:focus-visible { outline: 2px solid var(--akzent); outline-offset: -2px; } + +.chat-pin__kopf { + display: flex; align-items: center; gap: 6px; + font-size: .72rem; line-height: 1.2; +} +.chat-pin__nadel { flex: none; width: 13px; height: 13px; color: var(--pin); } +.chat-pin__von { font-weight: 700; color: var(--pin); } +/* Wer angeheftet hat, steht daneben und nicht darunter: Es ist eine + Randnotiz, keine zweite Zeile. Am Handy fällt sie weg -- dort zählt + der Text der Ankündigung, nicht ihre Herkunft. */ +/* .72rem und nicht .68 (10.09.2026, gefunden von pruef-css-klassen). + Unter 11,5 px wird Text auf einem Handy zur Zumutung -- und diese + Zeile steht dauerhaft im Bild. Sie soll leiser sein als der Text + daneben; das macht die FARBE, nicht die Groesse. */ +.chat-pin__wer { color: var(--text-still); font-size: .72rem; } +.chat-pin__text { + display: block; margin-top: 2px; + font-size: .8rem; color: var(--text-leise); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.chat-pin__los { + flex: none; + appearance: none; border: 0; border-left: 1px solid color-mix(in srgb, var(--pin) 20%, transparent); + background: none; padding: 0 10px; + font: inherit; font-size: .72rem; + color: var(--text-still); cursor: pointer; + border-radius: 0 10px 10px 0; +} +.chat-pin__los:hover { color: var(--text); background: rgba(255, 255, 255, .04); } +.chat-pin__los:focus-visible { outline: 2px solid var(--akzent); outline-offset: -2px; } + +@media (max-width: 560px) { + .chat-pin__wer { display: none; } +} + +/* ---- Die angeheftete Nachricht im Verlauf --------------------------- */ +/* Ein schmaler Streifen an der Blase, mehr nicht. Wer im Verlauf + zurückrollt, soll erkennen, dass genau DIESE Zeile oben hängt -- + ohne dass sie sich wie eine andere Sorte Nachricht liest. */ +.chat-nachricht[data-angeheftet="ja"] .chat-nachricht__blase { + box-shadow: inset 3px 0 0 color-mix(in srgb, var(--pin) 70%, transparent); +} + +/* Der Anheften-Knopf sieht aus wie "antworten" daneben -- dieselbe + Sorte Handgriff, dieselbe Zeile, dieselbe Lautstärke. */ +.chat-nachricht__pin-knopf { + appearance: none; border: 0; background: none; padding: 0; + font: inherit; font-size: .72rem; font-weight: 600; + color: inherit; cursor: pointer; + text-decoration: underline; text-underline-offset: 2px; +} +.chat-nachricht__pin-knopf:hover { color: var(--pin); } +.chat-nachricht__pin-knopf:focus-visible { + outline: 2px solid var(--akzent); outline-offset: 2px; border-radius: 4px; +} + +/* ---- Wer ist im Kanal ----------------------------------------------- */ +/* Derselbe Knopf wie das Wegräumen, nur wird er beim Darüberfahren + nicht rot: Leute hinzuzunehmen ist keine gefährliche Handlung. */ +.chat__leute { margin-left: auto; } +.chat__leute + .chat__weg { margin-left: 6px; } +.chat__leute:hover { + color: var(--pin); + border-color: color-mix(in srgb, var(--pin) 45%, transparent); +} + +/* ---- Die zwei Arten im Neu-Dialog ----------------------------------- */ +.neu__art { + display: flex; gap: 6px; + margin: 0 0 14px; + padding: 4px; + border: 1px solid var(--rand); border-radius: 12px; + background: rgba(255, 255, 255, .02); +} +.neu__art button { + flex: 1; + appearance: none; border: 0; border-radius: 9px; + padding: 8px 10px; + background: none; + font: inherit; font-size: .82rem; font-weight: 600; + color: var(--text-still); cursor: pointer; + transition: color var(--tempo), background var(--tempo); +} +.neu__art button[data-an="ja"] { + background: color-mix(in srgb, var(--akzent) 14%, transparent); + color: var(--text); +} +.neu__art button:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; } + +.neu__hinweis { + margin: 6px 0 0; + font-size: .78rem; color: var(--text-still); +} + +@media (prefers-reduced-motion: reduce) { + .chat-pin__auf, .chat-pin__los, .neu__art button { transition: none; } +} diff --git a/workspace/assets/css/team.css b/workspace/assets/css/team.css index d7602e4f..83fa5599 100644 --- a/workspace/assets/css/team.css +++ b/workspace/assets/css/team.css @@ -601,10 +601,16 @@ display: grid; gap: 4px; grid-template-columns: repeat(7, 1fr); } +/* .72rem und nicht .7 (10.09.2026, nachgezogen). + In den sieben Feldern steht die Zahl der Termine -- klein, aber es + ist die Auskunft, wegen der die Ampel da ist. Unter 11,5 px gilt im + Haus als zu klein; .7rem waren 11,2. Gefunden hat es + pruef-css-klassen, EINEN Commit zu spaet: Die Ampel war schon + gepusht, weil ich diese Pruefung nicht laufen liess. */ .tampel__tag { display: grid; place-items: center; padding: 7px 0 6px; border-radius: 8px; - font-size: .7rem; font-weight: 700; letter-spacing: .02em; + font-size: .72rem; font-weight: 700; letter-spacing: .02em; cursor: default; border: 1px solid var(--rand); } diff --git a/workspace/assets/js/chat.js b/workspace/assets/js/chat.js index 35adcaba..2c535c3e 100644 --- a/workspace/assets/js/chat.js +++ b/workspace/assets/js/chat.js @@ -48,6 +48,19 @@ let strom = null; // die EventSource let gewaehlt = new Set(); // im Neu-Dialog + /* KANAELE UND ANGEHEFTETES (10.09.2026, Kapitel 7.2). + + `darfKanal` und `darfAnheftenHier` kommen vom SERVER, nicht aus + einem Rollenvergleich hier. Diese Datei bekommt jeder, der die + Seite oeffnet -- ein `rolle === 'admin'` darin waere eine Auskunft + ueber den Aufbau des Hauses an alle. Ein Ja/Nein ist keine. */ + let angeheftet = []; + let darfAnheftenHier = false; + let darfKanal = false; + let kanalFrei = []; + let neuArt = 'gespraech'; // 'gespraech' | 'kanal' + let kanalUmbau = null; // Raumnummer, wenn die Besetzung geaendert wird + /* ---------- Kleinkram ---------------------------------------------------- */ async function hole(weg, einst) { @@ -104,7 +117,15 @@ try { const a = await hole('/workspace/api/chat/raeume'); if (!a.ok) return; - raeume = (await a.json()).raeume || []; + const daten = await a.json(); + raeume = daten.raeume || []; + darfKanal = !!daten.darf_kanal; + kanalFrei = daten.kanal_frei || []; + /* Der Knopf erscheint nur, wenn der Server etwas zu bieten hat. + Ein Knopf, der beim Druecken "darfst du nicht" sagt, ist eine + Enttaeuschung mit Ansage. */ + $('neu-art').hidden = !darfKanal; + if (!darfKanal) neuArt = 'gespraech'; } catch { return; } raeumeZeichnen(); ungelesenMelden(); @@ -141,7 +162,12 @@ const zeichen = el('span', 'chat-raum__zeichen'); zeichen.setAttribute('aria-hidden', 'true'); - if (r.art === 'gruppe') { + if (r.art === 'kanal') { + /* Das Rautenzeichen ist die Schreibweise, die jeder aus + anderen Messengern kennt -- es muss nicht erklaert werden. */ + zeichen.dataset.kanal = 'ja'; + zeichen.textContent = '#'; + } else if (r.art === 'gruppe') { zeichen.dataset.gruppe = 'ja'; zeichen.textContent = String(r.teilnehmer.length); } else { @@ -167,7 +193,12 @@ const vorschau = el('span', 'chat-raum__vorschau'); if (r.letzte) { - const wer = r.letzte.von === ich.name ? 'Du: ' : (r.art === 'gruppe' ? `${r.letzte.von}: ` : ''); + /* Wer geschrieben hat, gehoert ueberall dorthin, wo mehr als + zwei Leute schreiben koennen -- in einer Gruppe wie in einem + Kanal. Hier stand `=== 'gruppe'`, und ein Kanal waere damit + die einzige Stelle ohne Absender in der Vorschau gewesen. */ + const wer = r.letzte.von === ich.name ? 'Du: ' + : (r.art !== 'direkt' ? `${r.letzte.von}: ` : ''); vorschau.textContent = r.letzte.zurueckgenommen ? `${wer}Nachricht zurückgenommen` : wer + r.letzte.text; @@ -271,6 +302,8 @@ const daten = await a.json(); offen = daten.raum; nachrichten = daten.nachrichten; + angeheftet = daten.angeheftet || []; + darfAnheftenHier = !!daten.darf_anheften; /* DER STAND VON VORHIN, nicht der von jetzt. `gelesenMelden()` weiter unten setzt ihn gleich auf die letzte Nachricht -- die Linie muss also aus dem Wert gezogen werden, der beim OEFFNEN @@ -280,9 +313,15 @@ } catch { return; } $('verlauf-titel').textContent = offen.name; - $('verlauf-unter').textContent = offen.art === 'gruppe' + /* Bei allem, was mehr als zwei Leute hat, stehen die Namen darunter + -- bei einem Zweier-Gespraech die Rolle des Gegenuebers. */ + $('verlauf-unter').textContent = offen.art !== 'direkt' ? offen.teilnehmer.map((t) => t.name).join(' · ') : (ROLLENKURZ[offen.teilnehmer.find((t) => t.id !== ich.id)?.rolle] || ''); + /* Die Besetzung aendern kann nur, wer Kanaele fuehrt -- und nur in + einem Kanal. In einer Gruppe waere es ein Eingriff in fremde + Runden; siehe die Route dazu im Server. */ + $('kanal-leute').hidden = !(darfKanal && offen.art === 'kanal'); $('schreiben').hidden = false; $('verlauf-leer').hidden = true; $('chat').dataset.ansicht = 'verlauf'; @@ -292,6 +331,7 @@ /* Ein frisch geoeffnetes Gespraech steht immer am Ende -- man will das Letzte sehen, nicht das Erste. */ unten = true; neuUnten = 0; + pinsZeichnen(); verlaufZeichnen(); /* Der Entwurf von vorhin, falls einer da ist. */ $('text').value = entwurfHolen(id); @@ -306,6 +346,79 @@ raeumeZeichnen(); } + /* ======================================================================= + DIE ANGEHEFTETEN (10.09.2026, Kapitel 7.2) + + Sie stehen UEBER dem Verlauf und rollen nicht mit. Eine Ankuendigung, + die man erst suchen muss, ist keine. + + Jeder Aushang fuehrt zu seiner Stelle im Verlauf. Ohne das waere er + ein Bild von einer Nachricht: Man liest den Anfang und kann nicht + nachsehen, was darauf geantwortet wurde. + ======================================================================= */ + function pinNadel() { + const raum = 'http://www.w3.org/2000/svg'; + const s = document.createElementNS(raum, 'svg'); + s.setAttribute('viewBox', '0 0 24 24'); + s.setAttribute('aria-hidden', 'true'); + s.setAttribute('class', 'chat-pin__nadel'); + const pfad = document.createElementNS(raum, 'path'); + pfad.setAttribute('d', 'M9 3h6l-1 5 3 3v2h-5v6l-1 2-1-2v-6H5v-2l3-3z'); + pfad.setAttribute('fill', 'currentColor'); + s.append(pfad); + return s; + } + + function pinsZeichnen() { + const ziel = $('pins'); + ziel.textContent = ''; + ziel.hidden = !angeheftet.length; + if (!angeheftet.length) return; + + for (const a of angeheftet) { + const kasten = el('div', 'chat-pin'); + + const auf = el('button', 'chat-pin__auf'); + auf.type = 'button'; + auf.title = 'Zur Nachricht springen'; + const kopf = el('span', 'chat-pin__kopf'); + kopf.append(pinNadel()); + kopf.append(el('span', 'chat-pin__von', a.von)); + if (a.heftete) kopf.append(el('span', 'chat-pin__wer', `angeheftet von ${a.heftete}`)); + auf.append(kopf); + auf.append(el('span', 'chat-pin__text', a.text || 'Ohne Text')); + auf.addEventListener('click', () => zurStelle(a.id)); + kasten.append(auf); + + if (darfAnheftenHier) { + const los = el('button', 'chat-pin__los', 'lösen'); + los.type = 'button'; + los.setAttribute('aria-label', `„${(a.text || '').slice(0, 40)}“ lösen`); + los.addEventListener('click', () => anheften(a.id, false)); + kasten.append(los); + } + ziel.append(kasten); + } + } + + async function anheften(id, an) { + try { + const a = await hole(`/workspace/api/chat/nachrichten/${id}/anheften`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ an }), + }); + if (!a.ok) { + melde((await a.json().catch(() => ({}))).fehler || 'Ging nicht.'); + return; + } + /* Neu holen statt hier nachzurechnen: Der Server kennt die Grenze + von drei, das Abfallen zurueckgenommener Nachrichten und die + Reihenfolge. Zwei Rechnungen darueber liefen irgendwann + auseinander. */ + if (offen) await raumOeffnen(offen.id, { sanft: true }); + } catch { /* umgeleitet */ } + } + function verlaufZeichnen() { const ziel = $('verlauf'); /* NUR DIE NACHRICHTEN WEG — NICHT DER GANZE KASTEN. @@ -396,8 +509,10 @@ zeile.append(z); } + if (n.angeheftet) zeile.dataset.angeheftet = 'ja'; + const blase = el('div', 'chat-nachricht__blase'); - if (!gleicheFolge && !n.selbst && offen.art === 'gruppe') { + if (!gleicheFolge && !n.selbst && offen.art !== 'direkt') { blase.append(el('p', 'chat-nachricht__von', n.von)); } @@ -456,6 +571,18 @@ weg.addEventListener('click', () => zuruecknehmen(n.id)); fuss.append(weg); } + /* ANHEFTEN steht an derselben Stelle wie "antworten" -- dieselbe + Sorte Handgriff, dieselbe Zeile. Nur fuer die, die es duerfen, + und nie an einer zurueckgenommenen oder noch unterwegs + befindlichen Nachricht (`n.id > 0`): Die haette noch keine + Nummer, unter der man sie anheften koennte. */ + if (darfAnheftenHier && !n.zurueckgenommen && n.id > 0) { + const pin = el('button', 'chat-nachricht__pin-knopf', + n.angeheftet ? 'lösen' : 'anheften'); + pin.type = 'button'; + pin.addEventListener('click', () => anheften(n.id, !n.angeheftet)); + fuss.append(pin); + } /* KOPIEREN. Eine Adresse, ein Code, eine Uhrzeit -- man will sie weitergeben, nicht abtippen. Der Knopf bestaetigt sich selbst und faellt danach zurueck; eine Meldung anderswo waere ein @@ -869,6 +996,15 @@ raeumeLaden(); }); + /* ANGEHEFTET ODER GELOEST -- alle im Raum sehen es sofort. + Betroffen ist nur, wer denselben Raum offen hat; sonst genuegt + die Liste. */ + strom.addEventListener('pin', (e) => { + let n; + try { n = JSON.parse(e.data); } catch { return; } + if (offen && n.raum_id === offen.id) raumOeffnen(offen.id, { sanft: true }); + }); + /* Bei einem Abriss verbindet EventSource von selbst neu. Wir holen danach den Verlauf nach -- sonst fehlte, was während der Pause geschrieben wurde. */ @@ -880,10 +1016,10 @@ /* ---------- Neues Gespräch ----------------------------------------------- */ - async function partnerLaden() { + async function partnerLaden(vorbelegt = []) { const ziel = $('partner'); ziel.textContent = ''; - gewaehlt = new Set(); + gewaehlt = new Set(vorbelegt); $('gruppenname-zeile').hidden = true; $('neu-los').disabled = true; $('neu-fehler').textContent = ''; @@ -906,17 +1042,13 @@ fehlte 'spicy' -- und wer nicht in der Schleife vorkommt, taucht in der Gesprächsliste gar nicht auf. Kein Fehler, nur eine Person, die es fuer die Anderen nicht gibt. */ - for (const rolle of window.Bereiche.ROLLENFOLGE) { - const leute = personen.filter((p) => p.rolle === rolle); - if (!leute.length) continue; - + function gruppeBauen(schluessel, ueberschrift, leute) { const gruppe = el('div', 'chat-wahl__gruppe'); - gruppe.dataset.rolle = rolle; + gruppe.dataset.rolle = schluessel; const titel = el('p', 'chat-wahl__titel'); const punkt = el('span', 'chat-wahl__punkt'); punkt.setAttribute('aria-hidden', 'true'); - titel.append(punkt, document.createTextNode( - window.Bereiche.ROLLEN_GRUPPE[rolle])); + titel.append(punkt, document.createTextNode(ueberschrift)); gruppe.append(titel); const liste = el('div', 'chat-wahl__liste'); @@ -926,7 +1058,8 @@ b.type = 'button'; b.dataset.id = String(p.id); b.dataset.rolle = p.rolle; - b.setAttribute('aria-pressed', 'false'); + if (gewaehlt.has(p.id)) { b.dataset.an = 'ja'; } + b.setAttribute('aria-pressed', gewaehlt.has(p.id) ? 'true' : 'false'); const z = el('span', 'chat-wahl__zeichen', initialen(p.name)); z.setAttribute('aria-hidden', 'true'); @@ -943,10 +1076,89 @@ gruppe.append(liste); ziel.append(gruppe); } + + const gezeichnet = new Set(); + for (const rolle of window.Bereiche.ROLLENFOLGE) { + const leute = personen.filter((p) => p.rolle === rolle); + gezeichnet.add(rolle); + if (!leute.length) continue; + gruppeBauen(rolle, window.Bereiche.ROLLEN_GRUPPE[rolle], leute); + } + + /* UND DANN ALLE UEBRIGEN (10.09.2026). + + Genau die Lücke, vor der der Kommentar oben warnt -- sie war + offen. `ROLLENFOLGE` hat fünf Einträge; wer eine sechste Rolle + hat, wurde von der Schleife darüber nie erfasst und verschwand + lautlos aus der Auswahl. Der Server schickte ihn mit, der + Browser zeichnete ihn nicht, und niemand konnte sehen, dass + etwas fehlt. + + Aufgefallen ist es beim HINSEHEN, nicht beim Lesen: Ein frisch + angelegter Kanal hatte hinterher zwei Leute statt vier. + + Die Überschrift kommt vom Server -- so kann hier auch eine Rolle + stehen, deren Namen diese Datei gar nicht kennen darf. Nach + Überschrift gruppiert und nicht nach Rolle: Wer einen Menschen + sucht, sucht ihn in einer Liste, nicht in einer Rangordnung. */ + const uebrig = personen.filter((p) => !gezeichnet.has(p.rolle)); + const nachTitel = new Map(); + for (const p of uebrig) { + const t = p.gruppe || 'Weitere'; + if (!nachTitel.has(t)) nachTitel.set(t, []); + nachTitel.get(t).push(p); + } + for (const [titel, leute] of nachTitel) gruppeBauen(leute[0].rolle, titel, leute); + + wahlGeaendert(); + } + + /* Welche Zustaendigkeiten noch keinen Kanal haben. Die Liste kommt vom + Server; steht nichts darin, gibt es alle schon. */ + function kanalWahlFuellen() { + const feld = $('kanal-kategorie'); + feld.textContent = ''; + for (const k of kanalFrei) { + const o = document.createElement('option'); + o.value = k.wert; o.textContent = k.name; + feld.append(o); + } + $('kanal-alle').hidden = kanalFrei.length > 0; + feld.hidden = kanalFrei.length === 0; + } + + /* Umschalten zwischen "Gespräch" und "Kanal". Ein zweiter Dialog + waere dieselbe Personenwahl ein zweites Mal -- und die zweite + Fassung waere die, in der eine Regel fehlt. */ + function neuArtSetzen(art) { + neuArt = art; + for (const b of document.querySelectorAll('#neu-art [data-art]')) { + const an = b.dataset.art === art; + b.dataset.an = an ? 'ja' : ''; + b.setAttribute('aria-pressed', an ? 'true' : 'false'); + } + $('kanal-zeile').hidden = art !== 'kanal' || !!kanalUmbau; + $('neu-titel').textContent = kanalUmbau ? 'Wer ist im Kanal?' + : art === 'kanal' ? 'Neuer Kanal' : 'Neues Gespräch'; + $('neu-unter').textContent = kanalUmbau + ? 'Antippen, um jemanden hinein- oder herauszunehmen. Die Leitung bleibt immer dabei.' + : art === 'kanal' + ? 'Ein Kanal je Zuständigkeit. Der Name kommt aus der Kategorie – so gibt es ihn nur einmal.' + : 'Eine Person antippen, um ihr zu schreiben. Mehrere auswählen ergibt eine Gruppe.'; + wahlGeaendert(); } function wahlGeaendert() { const n = gewaehlt.size; + if (neuArt === 'kanal') { + /* Ein Kanal braucht keinen Namen (er kommt aus der Kategorie) und + auch niemanden -- man legt ihn an und fuellt ihn spaeter. Nur + eine Kategorie muss es geben, sonst gibt es nichts anzulegen. */ + $('gruppenname-zeile').hidden = true; + $('neu-los').disabled = !kanalUmbau && !$('kanal-kategorie').value; + $('neu-los').textContent = kanalUmbau ? 'Übernehmen' : 'Kanal anlegen'; + return; + } /* Ab zwei ist es eine Gruppe -- und die braucht einen Namen. */ $('gruppenname-zeile').hidden = n < 2; $('neu-los').disabled = n === 0; @@ -956,8 +1168,48 @@ async function neuAbschicken(e) { e.preventDefault(); const ids = [...gewaehlt]; - if (!ids.length) return; const knopf = $('neu-los'); + + /* KANAL: anlegen oder umbesetzen. Steht zuerst, weil hier auch eine + LEERE Auswahl erlaubt ist -- ein Kanal ohne Leute ist ein + gueltiger Zwischenstand, ein Gespraech ohne Gegenueber nicht. */ + if (neuArt === 'kanal') { + knopf.disabled = true; + try { + if (kanalUmbau) { + const a = await hole(`/workspace/api/chat/kanal/${kanalUmbau}/mitglieder`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mit: ids }), + }); + if (!a.ok) { + $('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.'; + return; + } + const raumId = kanalUmbau; + $('neu-dialog').close(); + await raeumeLaden(); + await raumOeffnen(raumId, { sanft: true }); + return; + } + const kategorie = $('kanal-kategorie').value; + if (!kategorie) { $('neu-fehler').textContent = 'Wähle eine Kategorie.'; return; } + const a = await hole('/workspace/api/chat/kanal', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ kategorie, mit: ids }), + }); + if (!a.ok) { + $('neu-fehler').textContent = (await a.json().catch(() => ({}))).fehler || 'Ging nicht.'; + return; + } + const raumId = (await a.json()).raum_id; + $('neu-dialog').close(); + await raeumeLaden(); + await raumOeffnen(raumId); + } catch { /* umgeleitet */ } finally { knopf.disabled = false; } + return; + } + + if (!ids.length) return; knopf.disabled = true; try { @@ -1194,9 +1446,29 @@ $('suche').addEventListener('input', () => { raeumeZeichnen(); nachrichtenSuchen(); }); $('neu-oeffnen').addEventListener('click', async () => { + kanalUmbau = null; + kanalWahlFuellen(); + neuArtSetzen('gespraech'); await partnerLaden(); $('neu-dialog').showModal(); }); + + for (const b of document.querySelectorAll('#neu-art [data-art]')) { + b.addEventListener('click', () => neuArtSetzen(b.dataset.art)); + } + $('kanal-kategorie').addEventListener('change', wahlGeaendert); + + /* WER IST IM KANAL. Derselbe Dialog, vorbelegt mit denen, die schon + drin sind -- so sieht man beim Oeffnen den Stand und muss ihn nicht + erst aus der Kopfzeile abschreiben. */ + $('kanal-leute').addEventListener('click', async () => { + if (!offen || offen.art !== 'kanal') return; + kanalUmbau = offen.id; + kanalWahlFuellen(); + neuArtSetzen('kanal'); + await partnerLaden(offen.teilnehmer.map((t) => t.id)); + $('neu-dialog').showModal(); + }); $('neu-zu').addEventListener('click', () => $('neu-dialog').close()); $('neu-form').addEventListener('submit', neuAbschicken); $('zur-liste').addEventListener('click', () => { diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index e4a4e5c2..816e63b4 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -12,12 +12,12 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - + + + + + +
@@ -245,10 +245,10 @@ - - - - - + + + + +