diff --git a/server/pruef-chat-aufloesen.mjs b/server/pruef-chat-aufloesen.mjs new file mode 100644 index 00000000..70db66f9 --- /dev/null +++ b/server/pruef-chat-aufloesen.mjs @@ -0,0 +1,521 @@ +/* EINE GRUPPE ODER EINEN KANAL AUFLÖSEN — FÜR ALLE (11.09.2026) + + Filipe: "wenn ein gruppenchat oder kanal geöffnet wird, die person + die ihn öffnet soll auch das recht haben das zu löschen und so dass + es dan für jeden gelöscht ist. aber nur die person die es gründet." + + --------------------------------------------------------------------- + WARUM DIESE DATEI EXISTIERT + + Es gibt jetzt ZWEI Löschknöpfe nebeneinander, und sie tun etwas sehr + Verschiedenes: + + DELETE /raeume/:id -> Wegräumen. Nur bei mir. Umkehrbar in + dem Sinn, dass die anderen alles haben. + DELETE /raeume/:id/ganz -> Auflösen. Für alle. Endgültig. + + Eine Verwechslung der beiden ist der teuerste Fehler, den dieser + Chat machen kann -- ein ganzer Verlauf ist weg, bei allen, ohne + Sicherung. Deshalb prüft diese Datei nicht nur, dass das Auflösen + funktioniert, sondern vor allem, was es NICHT tut und wer es NICHT + darf. + + GEMESSEN WIRD AM BESTAND, NICHT AN DER ANTWORT. Ein "ok: true" sagt + nur, dass der Server nicht gestolpert ist. Ob der Raum wirklich weg + ist, steht in der Datenbank -- und ob der Anhang weg ist, auf der + Platte. Beides wird hier nachgesehen. + + Aufruf: node server/pruef-chat-aufloesen.mjs + ===================================================================== */ + +import { mkdtempSync, rmSync, existsSync, readdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +const HIER_BILD = dirname(fileURLToPath(import.meta.url)); + +const ordner = mkdtempSync(join(tmpdir(), "ws-aufloesen-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +const { portMussFreiSein } = await import("./helfer-port.mjs"); +await portMussFreiSein(4391, "die Aufloesen-Pruefung"); + +process.env.PORT = "4391"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; +const BASIS = "http://127.0.0.1:4391"; + +await import("./index.js"); + +/* Eigene Zuhoerer neben denen aus index.js -- sonst wird ein Absturz + dieser Datei nur notiert und der Lauf haengt (siehe die + ausfuehrliche Begruendung in pruef-chat-kanaele.mjs). */ +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."); + 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 ---------------------------------------------------------- */ +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; +} +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"); + +/* Anmeldung an der Crew-Wand: Modis und die rechte Hand kommen an der + Agenturwand nicht herein, und `fetch` laesst einen selbst gesetzten + Host-Kopf fallen -- deshalb node:http. */ +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: 4391, 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, Origin: BASIS }, + 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"); + +const raumVon = (keks, id) => holen(keks, "/workspace/api/chat/raeume") + .then((a) => (a.raeume || []).find((r) => r.id === id) || null); +const stehtDa = (id) => !!d.prepare("SELECT 1 FROM chat_raeume WHERE id = ?").get(id); + +/* ======================================================================= + 1. Eine Gruppe, gegruendet von Ayla + ======================================================================= */ +console.log(""); +console.log("=== Wer darf aufloesen? ==="); +const gruppe = await (await rufe(eins.keks, "/workspace/api/chat/gruppe", + { mit: [idZwei, idDrei], name: "Clipping Mo-Fr" }, "POST")).json(); +ok(Number.isInteger(gruppe.raum_id), `Ayla legt eine Gruppe an (#${gruppe.raum_id})`); + +/* Zwei Nachrichten hinein, damit es auch etwas zu verlieren gibt. */ +for (const [k, t] of [[eins, "Moin"], [zwei, "Bin dabei"]]) { + await rufe(k.keks, `/workspace/api/chat/raeume/${gruppe.raum_id}/nachrichten`, + { text: t }, "POST"); +} +const wieViele = d.prepare( + "SELECT COUNT(*) AS n FROM chat_nachrichten WHERE raum_id = ?").get(gruppe.raum_id).n; +ok(wieViele === 2, `zwei Nachrichten stehen darin (${wieViele})`); + +/* ---- Das Recht steht in der Liste, nicht im Browser ------------------- */ +{ + const beiAyla = await raumVon(eins.keks, gruppe.raum_id); + const beiBen = await raumVon(zwei.keks, gruppe.raum_id); + ok(beiAyla?.darf_aufloesen === true, "Ayla bekommt das Recht mitgeliefert"); + ok(beiBen?.darf_aufloesen === false, + "Ben nicht -- er ist dabei, aber er hat sie nicht angelegt"); +} + +/* ---- Und wer es nicht hat, kommt auch nicht durch --------------------- */ +{ + const a = await rufe(zwei.keks, `/workspace/api/chat/raeume/${gruppe.raum_id}/ganz`, + undefined, "DELETE"); + const d2 = await a.json().catch(() => ({})); + ok(a.status === 403 && d2.fehler === "nur_gruender", + `Ben wird abgewiesen (HTTP ${a.status}, ${d2.fehler})`); + ok(stehtDa(gruppe.raum_id), "und die Gruppe steht noch"); +} +{ + /* Die rechte Hand darf ALLES sehen -- aufloesen darf sie trotzdem + nicht. Das ist der Fall, den man beim Bauen am ehesten aufweicht + ("sie ist ja die Leitung"), und genau deshalb steht er hier. */ + const a = await rufe(hand.keks, `/workspace/api/chat/raeume/${gruppe.raum_id}/ganz`, + undefined, "DELETE"); + ok(a.status === 403 || a.status === 404, + `auch die rechte Hand nicht (HTTP ${a.status})`); + ok(stehtDa(gruppe.raum_id), "und die Gruppe steht immer noch"); +} + +/* ======================================================================= + 2. Wegraeumen ist NICHT aufloesen + ======================================================================= */ +console.log(""); +console.log("=== Der Unterschied zum Knopf daneben ==="); +{ + const a = await rufe(zwei.keks, `/workspace/api/chat/raeume/${gruppe.raum_id}`, + undefined, "DELETE"); + ok(a.status === 200, `Ben raeumt sie bei sich weg (HTTP ${a.status})`); + ok(stehtDa(gruppe.raum_id), "der Raum bleibt in der Datenbank"); + const beiBen = await raumVon(zwei.keks, gruppe.raum_id); + ok(beiBen === null, "bei Ben steht sie nicht mehr in der Liste"); + const beiCem = await raumVon(drei.keks, gruppe.raum_id); + ok(beiCem !== null, "bei Cem dagegen schon -- niemand nimmt einem anderen etwas weg"); + const uebrig = d.prepare( + "SELECT COUNT(*) AS n FROM chat_nachrichten WHERE raum_id = ?").get(gruppe.raum_id).n; + ok(uebrig === 2, `und beide Nachrichten sind noch da (${uebrig})`); +} + +/* ======================================================================= + 3. Ein Zweier-Gespraech laesst sich gar nicht aufloesen + ======================================================================= */ +console.log(""); +console.log("=== Zweier-Gespraeche bleiben unantastbar ==="); +{ + const direkt = await (await rufe(eins.keks, "/workspace/api/chat/direkt", + { mit: idZwei }, "POST")).json(); + ok(Number.isInteger(direkt.raum_id), `Ayla schreibt Ben direkt (#${direkt.raum_id})`); + const liste = await raumVon(eins.keks, direkt.raum_id); + ok(liste?.darf_aufloesen === false, + "auch die Anlegerin bekommt dort kein Aufloesen-Recht"); + const a = await rufe(eins.keks, `/workspace/api/chat/raeume/${direkt.raum_id}/ganz`, + undefined, "DELETE"); + const d2 = await a.json().catch(() => ({})); + ok(a.status === 403 && d2.fehler === "nur_gruppe_oder_kanal", + `und der Weg weist sie ab (HTTP ${a.status}, ${d2.fehler})`); + ok(stehtDa(direkt.raum_id), "das Gespraech steht weiterhin"); +} + +/* ======================================================================= + 4. Fremde kommen nicht einmal in die Naehe + ======================================================================= */ +console.log(""); +console.log("=== Wer nicht dabei ist ==="); +{ + /* Cem ist in der Gruppe, Ayla hat sie gegruendet. Ein VIERTER, der + nicht dabei ist, muss 404 bekommen -- nicht 403. 403 waere die + Auskunft, dass es diesen Raum gibt. */ + const vier = anlegen("Dilan", "modi", "CODE-TEAM-0004"); + const vierK = await anmelden("modi", "CODE-TEAM-0004"); + ok(vierK.ok && Number.isInteger(vier), "ein Vierter ist angemeldet"); + const a = await rufe(vierK.keks, `/workspace/api/chat/raeume/${gruppe.raum_id}/ganz`, + undefined, "DELETE"); + ok(a.status === 404, `er bekommt 404, nicht 403 (HTTP ${a.status})`); +} + +/* ======================================================================= + 5. Und jetzt wirklich: die Gruenderin loest auf + ======================================================================= */ +console.log(""); +console.log("=== Aufloesen, gemessen am Bestand ==="); +{ + const vorher = { + raum: stehtDa(gruppe.raum_id), + nachrichten: d.prepare("SELECT COUNT(*) AS n FROM chat_nachrichten WHERE raum_id = ?") + .get(gruppe.raum_id).n, + leute: d.prepare("SELECT COUNT(*) AS n FROM chat_teilnehmer WHERE raum_id = ?") + .get(gruppe.raum_id).n, + }; + ok(vorher.raum && vorher.nachrichten === 2 && vorher.leute === 3, + `vorher: Raum da, ${vorher.nachrichten} Nachrichten, ${vorher.leute} Beteiligte`); + + const a = await rufe(eins.keks, `/workspace/api/chat/raeume/${gruppe.raum_id}/ganz`, + undefined, "DELETE"); + const antwort = await a.json().catch(() => ({})); + ok(a.status === 200 && antwort.ok === true, + `Ayla loest auf (HTTP ${a.status})`); + ok(antwort.nachrichten === 2 && antwort.beteiligte === 3, + `die Antwort nennt, was wegfiel (${antwort.nachrichten} Nachrichten, ` + + `${antwort.beteiligte} Beteiligte)`); + + ok(!stehtDa(gruppe.raum_id), "der Raum ist aus der Datenbank verschwunden"); + const restN = d.prepare("SELECT COUNT(*) AS n FROM chat_nachrichten WHERE raum_id = ?") + .get(gruppe.raum_id).n; + const restT = d.prepare("SELECT COUNT(*) AS n FROM chat_teilnehmer WHERE raum_id = ?") + .get(gruppe.raum_id).n; + ok(restN === 0, `keine Nachricht blieb liegen (${restN})`); + ok(restT === 0, `keine Teilnehmerzeile blieb liegen (${restT})`); + + /* UND BEI DEN ANDEREN. Das ist der eigentliche Satz aus Filipes + Wunsch: "so dass es dan fuer jeden geloescht ist." */ + const beiCem = await raumVon(drei.keks, gruppe.raum_id); + ok(beiCem === null, "bei Cem ist sie ebenfalls weg -- fuer jeden"); + + /* GEGENPROBE: Ein zweiter Versuch findet nichts mehr. Ohne sie + koennte der Weg beim zweiten Aufruf stillschweigend "ok" sagen. */ + const nochmal = await rufe(eins.keks, `/workspace/api/chat/raeume/${gruppe.raum_id}/ganz`, + undefined, "DELETE"); + ok(nochmal.status === 404, `ein zweiter Versuch findet nichts (HTTP ${nochmal.status})`); +} + +/* ======================================================================= + 6. Ein Kanal -- und seine Zustaendigkeit wird wieder frei + ======================================================================= */ +console.log(""); +console.log("=== Kanaele ==="); +{ + const frei = await holen(hand.keks, "/workspace/api/chat/raeume"); + const kat = (frei.kanal_frei || [])[0]?.wert; + ok(!!kat, `es gibt eine freie Zustaendigkeit (${kat || "keine"})`); + + const kanal = await (await rufe(hand.keks, "/workspace/api/chat/kanal", + { kategorie: kat }, "POST")).json(); + ok(Number.isInteger(kanal.raum_id), `die rechte Hand legt den Kanal an (#${kanal.raum_id})`); + + /* DogFather kommt in jeden Kanal -- aufloesen darf er trotzdem + nicht, denn angelegt hat ihn die rechte Hand. */ + const beiDogi = await raumVon(dogi.keks, kanal.raum_id); + ok(beiDogi !== null, "DogFather sieht den Kanal"); + ok(beiDogi?.darf_aufloesen === false, + "und darf ihn NICHT aufloesen -- er hat ihn nicht angelegt"); + const versuch = await rufe(dogi.keks, `/workspace/api/chat/raeume/${kanal.raum_id}/ganz`, + undefined, "DELETE"); + ok(versuch.status === 403, `der Weg bestaetigt das (HTTP ${versuch.status})`); + + const beiHand = await raumVon(hand.keks, kanal.raum_id); + ok(beiHand?.darf_aufloesen === true, "die Gruenderin dagegen darf"); + + const weg = await rufe(hand.keks, `/workspace/api/chat/raeume/${kanal.raum_id}/ganz`, + undefined, "DELETE"); + ok(weg.status === 200, `sie loest ihn auf (HTTP ${weg.status})`); + ok(!stehtDa(kanal.raum_id), "der Kanal ist weg"); + + /* Und die Zustaendigkeit steht wieder zur Verfuegung. Ohne diese + Zeile koennte der eindeutige Index sie dauerhaft blockieren -- ein + Kanal, den es nicht gibt und den man nicht anlegen kann. */ + const danach = await holen(hand.keks, "/workspace/api/chat/raeume"); + ok((danach.kanal_frei || []).some((k) => k.wert === kat), + `die Zustaendigkeit "${kat}" ist wieder frei`); + const neu = await (await rufe(hand.keks, "/workspace/api/chat/kanal", + { kategorie: kat }, "POST")).json(); + ok(Number.isInteger(neu.raum_id), `und laesst sich neu anlegen (#${neu.raum_id})`); +} + +/* ======================================================================= + 7. Anhaenge verschwinden von der Platte + ======================================================================= */ +console.log(""); +console.log("=== Anhaenge ==="); +{ + const g = await (await rufe(eins.keks, "/workspace/api/chat/gruppe", + { mit: [idZwei], name: "Mit Anhang" }, "POST")).json(); + ok(Number.isInteger(g.raum_id), `eine Gruppe mit Anhang (#${g.raum_id})`); + + /* Ein winziges PNG, damit es wirklich durch die Bildpruefung geht. */ + const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk" + + "YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64"); + /* ROH, NICHT ALS FORMULAR. Der Weg nimmt den Dateiinhalt direkt + entgegen (express.raw) und erkennt den Typ an den ersten Bytes -- + ein multipart-Umschlag kommt dort als Unsinn an und wird mit 415 + abgewiesen. Beim ersten Anlauf stand hier FormData, und die + Pruefung mass daraufhin ein Loeschen von nichts. */ + const hoch = await fetch(BASIS + `/workspace/api/chat/raeume/${g.raum_id}/anhang`, { + method: "POST", + headers: { + Cookie: eins.keks, Origin: BASIS, + "Content-Type": "application/octet-stream", + "x-dateiname": "punkt.png", + }, + body: png }); + ok(hoch.status === 200 || hoch.status === 201, + `der Anhang ist oben (HTTP ${hoch.status})`); + + const datei = d.prepare( + `SELECT anhang_datei FROM chat_nachrichten + WHERE raum_id = ? AND anhang_datei IS NOT NULL`).get(g.raum_id)?.anhang_datei; + ok(!!datei, `die Datei steht in der Tabelle (${datei || "keine"})`); + + const { DATEN_ORDNER } = await import("./workspace.js"); + const pfad = join(DATEN_ORDNER, "chat-anhaenge", datei || "-"); + ok(existsSync(pfad), "und liegt auf der Platte"); + + const weg = await rufe(eins.keks, `/workspace/api/chat/raeume/${g.raum_id}/ganz`, + undefined, "DELETE"); + ok(weg.status === 200, `die Gruppe wird aufgeloest (HTTP ${weg.status})`); + ok(!existsSync(pfad), "und die Datei ist von der Platte verschwunden"); + + /* GEGENPROBE ZUM PFAD: Der Ordner selbst bleibt stehen. Ein + `existsSync` auf einen Pfad, den es nie gab, waere sonst auch + "weg" -- und die Zeile darueber bewiese nichts. */ + ok(existsSync(join(DATEN_ORDNER, "chat-anhaenge")), + "der Anhang-Ordner selbst steht noch (die Pruefung misst die Datei, nicht den Ordner)"); +} + +/* ======================================================================= + 8. Und wie es auf dem Bildschirm aussieht + ======================================================================= + Filipe: "ich will eine bessere erklaerung dafuer bitte." -- gemeint + war der Umschalter "Gespraech | Kanal" im Neu-Fenster. Daneben stand + ein Satz ueber das ANTIPPEN von Personen, also ueber den naechsten + Schritt, nicht ueber die Wahl, die gerade ansteht. + + EIN TEXT, DEN NIEMAND PRUEFT, VERSCHWINDET beim naechsten Umbau, und + es faellt keinem auf, weil ein fehlender Satz nichts kaputtmacht. + + DIE ANMELDUNG LAEUFT UEBER DEN KEKS, NICHT UEBER DAS FORMULAR: Die + Crew-Rollen kommen an der Agenturwand nicht herein, und ein Browser + laesst den Host-Kopf nicht setzen (Chrome verbietet es). Der Keks + aus der Anmeldung oben gilt fuer 127.0.0.1 -- also wird er direkt + in den Browser gelegt. Das umgeht keine Regel, es umgeht ein + Werkzeug-Hindernis. */ +console.log(""); +console.log("=== Das Neu-Fenster ==="); +{ + const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); + const browser = await pw.chromium.launch(); + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const [kname, kwert] = hand.keks.split("="); + await ctx.addCookies([{ name: kname, value: kwert, domain: "127.0.0.1", path: "/" }]); + const seite = await ctx.newPage(); + const skriptfehler = []; + seite.on("pageerror", (e) => skriptfehler.push(String(e).slice(0, 140))); + await seite.goto(BASIS + "/workspace/chat.html", { waitUntil: "networkidle" }); + await seite.waitForTimeout(1400); + ok(skriptfehler.length === 0, `kein Skriptfehler (${skriptfehler.join(" | ") || "keiner"})`); + + await seite.click("#neu-oeffnen"); + await seite.waitForSelector("#neu-dialog[open]", { timeout: 5000 }); + await seite.waitForTimeout(400); + + const lage = await seite.evaluate(() => { + const e = document.getElementById("neu-erklaerung"); + const u = document.getElementById("neu-unter"); + return { + umschalter: document.getElementById("neu-art")?.hidden === false, + /* Ohne Leerraum-Muster: Ein regulaerer Ausdruck stand hier mit + einem verschluckten Backslash da ("/s+/g") und hat jedes "s" + durch ein Leerzeichen ersetzt -- aus "Gespraech" wurde + "Ge praech", und die Pruefung fand das Wort nicht mehr. + Zum Suchen von Woertern braucht es keine Normalisierung. */ + erklaerung: e && !e.hidden ? e.textContent.trim() : "", + unter: u ? u.textContent.trim() : "", + }; + }); + ok(lage.umschalter === true, "die rechte Hand sieht den Umschalter"); + ok(lage.erklaerung.length > 60, + `und darunter eine Erklaerung (${lage.erklaerung.length} Zeichen)`); + /* Sie muss BEIDE Woerter erklaeren -- eine Erklaerung, die nur eines + der beiden nennt, laesst genau die Frage offen, die Filipe hatte. */ + ok(/Gespräch/.test(lage.erklaerung) && /Kanal/.test(lage.erklaerung), + "sie nennt beide Arten"); + /* Und sie muss den UNTERSCHIED benennen, nicht zweimal dasselbe: + Leute gegen Thema. */ + ok(/Leute/.test(lage.erklaerung) && /Thema/.test(lage.erklaerung), + "und sagt, worin sie sich unterscheiden (Leute / Thema)"); + ok(/einmal/.test(lage.erklaerung), + "dass es einen Kanal je Zustaendigkeit nur einmal gibt"); + ok(!/antippen/i.test(lage.erklaerung), + "sie erklaert die WAHL, nicht den naechsten Schritt"); + + /* Umschalten auf Kanal: Der Satz darunter wechselt mit. */ + await seite.click('#neu-art [data-art="kanal"]'); + await seite.waitForTimeout(250); + const unterKanal = await seite.$eval("#neu-unter", (e) => e.textContent.trim()); + ok(unterKanal !== lage.unter && /Zuständigkeit/.test(unterKanal), + `im Kanal-Modus steht etwas anderes darunter ("${unterKanal.slice(0, 48)}…")`); + + if (process.env.SCHIRM) { + await seite.screenshot({ path: join(HIER_BILD, "pruef-chat-neu.png") }); + } + await seite.click("#neu-zu"); + await seite.waitForTimeout(250); + + /* ---- UND DER KNOPF SELBST ----------------------------------------- + Bis hierher ist nur das RECHT geprueft (darf_aufloesen) und der + Weg dahinter. Ob die Oberflaeche beides verbindet, war unbewiesen + -- und genau dort sitzt der Fehler, den man am ehesten macht: + ein Knopf, der immer dasteht, oder einer, der nie erscheint. + + Beide Faelle in einem Raum zu messen geht nicht; es braucht zwei. + Deshalb legt die rechte Hand hier eine eigene Gruppe an (sie darf) + und oeffnet danach ein Zweier-Gespraech (sie darf nicht). */ + const eigene = await (await rufe(hand.keks, "/workspace/api/chat/gruppe", + { mit: [idEins, idDrei], name: "Leitungsrunde" }, "POST")).json(); + ok(Number.isInteger(eigene.raum_id), + `die rechte Hand legt eine eigene Gruppe an (#${eigene.raum_id})`); + + await seite.goto(BASIS + `/workspace/chat.html?raum=${eigene.raum_id}`, + { waitUntil: "networkidle" }); + await seite.waitForTimeout(1500); + const beiEigener = await seite.evaluate(() => ({ + aufloesen: document.getElementById("raum-aufloesen")?.hidden, + wegraeumen: document.getElementById("gespraech-weg")?.hidden, + titel: document.getElementById("verlauf-titel")?.textContent.trim(), + })); + ok(beiEigener.titel === "Leitungsrunde", + `die Gruppe ist offen ("${beiEigener.titel}")`); + ok(beiEigener.aufloesen === false, + "in ihrer eigenen Gruppe steht der Aufloesen-Knopf da"); + ok(beiEigener.wegraeumen === false, + "und der Wegraeumen-Knopf ebenfalls -- beide Wege bleiben offen"); + if (process.env.SCHIRM) { + await seite.locator(".chat__kopf--verlauf").screenshot( + { path: join(HIER_BILD, "pruef-chat-kopf.png") }); + } + + const fremd = await (await rufe(hand.keks, "/workspace/api/chat/direkt", + { mit: idEins }, "POST")).json(); + await seite.goto(BASIS + `/workspace/chat.html?raum=${fremd.raum_id}`, + { waitUntil: "networkidle" }); + await seite.waitForTimeout(1500); + const beiDirekt = await seite.evaluate(() => ({ + aufloesen: document.getElementById("raum-aufloesen")?.hidden, + wegraeumen: document.getElementById("gespraech-weg")?.hidden, + })); + ok(beiDirekt.aufloesen === true, + "im Zweier-Gespraech ist er weg"); + ok(beiDirekt.wegraeumen === false, + "der Wegraeumen-Knopf bleibt aber -- die beiden sind nicht dasselbe"); + + await browser.close(); +} + +/* ---- Ende ------------------------------------------------------------- */ +d.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +console.log(""); +console.log(`${geprueft} Pruefungen`); +console.log(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`); +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-chat.js b/server/workspace-chat.js index a5d1c6e2..94fad61d 100644 --- a/server/workspace-chat.js +++ b/server/workspace-chat.js @@ -457,7 +457,7 @@ chatRouter.get("/workspace/api/chat/raeume", (req, res) => { kanaeleAngleichen(req.person); const reihen = db().prepare(` - SELECT r.id, r.art, r.name, r.kategorie, r.letzte_am, + SELECT r.id, r.art, r.name, r.kategorie, r.letzte_am, r.erstellt_von, 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 = ? @@ -491,6 +491,13 @@ chatRouter.get("/workspace/api/chat/raeume", (req, res) => { kategorie: r.kategorie || null, name: raumName(r, leute, req.person.id), leitung: !!r.leitung, + /* DARF ICH DIESEN RAUM FUER ALLE AUFLOESEN? (11.09.2026) + Vom Server beantwortet, nicht im Browser nachgerechnet -- die + Oberflaeche hat `erstellt_von` sonst gar nicht. Die Regel + selbst steht in darfAufloesen(), damit sie nicht an zwei + Stellen steht; genau daran ist es beim ersten Anlauf + gescheitert. */ + darf_aufloesen: darfAufloesen(r, req.person), teilnehmer: leute.map((t) => ({ id: t.id, name: t.name, rolle: t.rolle })), letzte: letzte ? { /* Ein Anhang ohne Begleittext hätte hier eine leere Zeile @@ -815,7 +822,7 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { .get(raumId, req.person.id)?.gelesen_bis ?? 0; const raum = db().prepare( - "SELECT id, art, name, kategorie FROM chat_raeume WHERE id = ?").get(raumId); + "SELECT id, art, name, kategorie, erstellt_von FROM chat_raeume WHERE id = ?").get(raumId); const leute = teilnehmerVon(raumId); /* DIE ANGEHEFTETEN, in einer eigenen Abfrage. @@ -846,6 +853,11 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { 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 })), + /* Dasselbe Feld wie in der Liste, aus derselben Regel. Die + Oberflaeche nimmt den offenen Raum von HIER -- ohne diese + Zeile blieb der Aufloesen-Knopf unsichtbar, obwohl das Recht + da war. */ + darf_aufloesen: darfAufloesen(raum, req.person), }, gelesen_bis: gelesenBis, /* OB DER KNOPF UEBERHAUPT ERSCHEINT, entscheidet der Server. @@ -1161,6 +1173,137 @@ chatRouter.delete("/workspace/api/chat/raeume/:id", gleicheHerkunft, (req, res) } }); +/** Darf diese Person diesen Raum fuer ALLE aufloesen? + * + * EINE REGEL, EINE STELLE (11.09.2026). Sie wurde zuerst in der + * Raumliste ausgerechnet -- und der Knopf blieb trotzdem unsichtbar, + * weil die Oberflaeche den offenen Raum aus dem Nachrichten-Weg + * bezieht, nicht aus der Liste. Zwei Wege, ein Raumobjekt, und nur + * einer kannte das Feld. + * + * Gefunden hat das nicht das Lesen, sondern eine Pruefung, die den + * KNOPF gemessen hat statt des Rechts dahinter. + * + * `erstellt_von` muss im uebergebenen Raum stehen -- wer die Spalte + * im SELECT vergisst, bekommt `undefined` und damit `false`. Das ist + * die sichere Richtung: kein Knopf statt eines falschen. + */ +function darfAufloesen(raum, person) { + return !!raum && raum.art !== "direkt" + && !!raum.erstellt_von && raum.erstellt_von === person.id; +} + +/* ===================================================================== + EINE GRUPPE ODER EINEN KANAL AUFLOESEN — FUER ALLE (11.09.2026) + + Filipe: "wenn ein gruppenchat oder kanal geoeffnet wird, die person + die ihn oeffnet soll auch das recht haben das zu loeschen und so + dass es dan fuer jeden geloescht ist. aber nur die person die es + gruendet." + + DAS IST ETWAS ANDERES ALS DER WEG DARUEBER, und die beiden duerfen + nie verwechselt werden: + + Wegraeumen -> nur bei mir. Die anderen behalten alles. + Aufloesen -> fuer alle. Der Raum ist weg, endgueltig. + + Deshalb ein eigener Weg (/ganz) und nicht ein Zusatzfeld am + bestehenden: Ein vergessenes Feld waere hier der Unterschied + zwischen "bei mir ausgeblendet" und "bei allen geloescht". + + NUR DER GRUENDER, und zwar woertlich. Nicht die Teamleitung, nicht + DogFather, nicht wer `leitung` in der Gruppe hat. `erstellt_von` + steht seit jeher in der Tabelle und ist die einzige Angabe, die + "wer hat das aufgemacht" beantwortet. + + NICHT BEI ZWEIER-GESPRAECHEN. Dort gibt es keinen Gruender, nur + zwei Beteiligte -- und "niemand kann einem anderen die Unterhaltung + wegnehmen" ist die Regel von 09.09., die hier gilt. + + DIE OFFENE STELLE, damit sie niemand suchen muss: Wird das Konto + des Gruenders geloescht, wird `erstellt_von` auf NULL gesetzt + (ON DELETE SET NULL) -- dann kann NIEMAND mehr aufloesen. Das ist + die ehrliche Folge von "nur der Gruender"; wer das aendern will, + muss eine zweite Person benennen, und das ist eine Entscheidung, + keine Reparatur. + + REIHENFOLGE, und sie ist nicht beliebig: + 1. Das Ereignis geht RAUS, solange es die Teilnehmer noch gibt -- + chatEreignis liest sie aus der Tabelle. Danach waere die Liste + leer und niemand erfuehre, dass der Raum weg ist. + 2. Die Zeilen fallen in EINER Transaktion. + 3. Die Anhaenge auf der Platte erst NACH dem Commit. Andersherum + haetten wir bei einem Ruecklauf Nachrichten, die auf Dateien + zeigen, die es nicht mehr gibt. + ===================================================================== */ +chatRouter.delete("/workspace/api/chat/raeume/:id/ganz", 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)) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const d = db(); + const raum = d.prepare( + "SELECT id, art, name, kategorie, erstellt_von FROM chat_raeume WHERE id = ?").get(raumId); + if (!raum) return res.status(404).json({ fehler: "nicht_gefunden" }); + if (raum.art === "direkt") return res.status(403).json({ fehler: "nur_gruppe_oder_kanal" }); + /* Dieselbe Regel wie fuer den Knopf -- nicht "auch so eine". Waere + hier eine zweite Bedingung ausgeschrieben, koennte der Knopf + eines Tages erscheinen, wo der Weg ablehnt, oder umgekehrt. */ + if (!darfAufloesen(raum, req.person)) { + return res.status(403).json({ fehler: "nur_gruender" }); + } + + const dateien = d.prepare( + `SELECT anhang_datei FROM chat_nachrichten + WHERE raum_id = ? AND anhang_datei IS NOT NULL`) + .all(raumId).map((z) => z.anhang_datei); + const wieViele = d.prepare( + "SELECT COUNT(*) AS n FROM chat_nachrichten WHERE raum_id = ?").get(raumId).n; + const leute = teilnehmerVon(raumId).map((t) => t.id); + + /* Schritt 1: erst sagen, dann loeschen (siehe oben). */ + chatEreignis(raumId, { raum_id: raumId, aufgeloest: true }, [], "raum-weg"); + + /* Schritt 2: alles in einem Zug. Ein halb aufgeloester Raum -- + Nachrichten weg, Raum noch da -- waere in jeder Liste ein + leerer Eintrag, den niemand mehr loswird. */ + d.exec("BEGIN"); + try { + d.prepare("DELETE FROM chat_nachrichten WHERE raum_id = ?").run(raumId); + d.prepare("DELETE FROM chat_teilnehmer WHERE raum_id = ?").run(raumId); + d.prepare("DELETE FROM chat_raeume WHERE id = ?").run(raumId); + d.exec("COMMIT"); + } catch (fehler) { + try { d.exec("ROLLBACK"); } catch { /* schon zurueck */ } + throw fehler; + } + + /* Schritt 3: die Anhaenge. Was hier liegen bleibt, ist Muell -- + aber kein Datenschaden, und es wird gemeldet statt verschwiegen. */ + let dateienWeg = 0; + for (const name of dateien) { + try { unlinkSync(join(ANHANG_ORDNER, name)); dateienWeg++; } + catch (f) { + if (f?.code === "ENOENT") dateienWeg++; + else console.error("[chat] Anhang bleibt liegen:", f?.message); + } + } + + protokolliere("chat_aufgeloest", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `${raum.art} #${raumId} "${raum.name || raum.kategorie || ""}": ` + + `${wieViele} Nachrichten, ${leute.length} Beteiligte, ` + + `${dateienWeg}/${dateien.length} Anhaenge`.slice(0, 200), + }); + res.json({ ok: true, nachrichten: wieViele, beteiligte: leute.length, + anhaenge: dateien.length }); + } catch (fehler) { + console.error("[chat] Aufloesen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + /* ===================================================================== SUCHE (09.09.2026, Punkt 15) diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index 91886021..075b7aee 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=202609111501", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609111522", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609111501", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609111522", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609111501", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609111522", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609111501", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609111522", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/chat.css b/workspace/assets/css/chat.css index ae1b801f..c958dc60 100644 --- a/workspace/assets/css/chat.css +++ b/workspace/assets/css/chat.css @@ -963,8 +963,17 @@ Löschen ist die seltene Handlung, und ein roter Knopf, der jeden Tag danebensteht, wird irgendwann aus Versehen getroffen. Rot wird er erst beim Darüberfahren -- dann, wenn jemand ihn wirklich meint. */ -.chat__weg { +/* Der Behaelter schiebt, die Knoepfe stehen. Vorher schob sich jeder + Knopf mit `margin-left: auto` selbst -- bei zwei sichtbaren teilen + sich zwei auto-Raender den freien Platz und reissen sie auseinander. + Welche sichtbar sind, entscheidet der Server; eine Regel, die das + aushaelt, ist besser als eine Zahl, die fuer einen Fall stimmt. */ +.chat__kopf-knoepfe { flex: none; margin-left: auto; + display: flex; align-items: center; gap: 6px; +} +.chat__weg { + flex: none; width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid var(--rand); border-radius: 10px; @@ -979,6 +988,20 @@ } .chat__weg:hover { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 45%, transparent); } .chat__weg:focus-visible { outline: 2px solid var(--akzent); outline-offset: 2px; } +/* AUFLOESEN SIEHT ANDERS AUS ALS WEGRAEUMEN (11.09.2026). Zwei gleich + aussehende Knoepfe nebeneinander, von denen einer nur bei mir und + einer bei ALLEN loescht, waeren eine Falle. Anderes Zeichen, eigener + Rand -- aber gedeckt, nicht signalrot: Der Knopf steht im Kopf und + soll nicht den ganzen Tag schreien. */ +.chat__weg--ganz { + border-color: color-mix(in srgb, var(--warn) 30%, var(--rand)); + color: color-mix(in srgb, var(--warn) 55%, var(--text-leise)); +} +.chat__weg--ganz:hover { + color: var(--warn); + border-color: color-mix(in srgb, var(--warn) 65%, transparent); + background: color-mix(in srgb, var(--warn) 9%, transparent); +} /* Steht der Knopf da, braucht der Text daneben seinen eigenen Rand -- sonst hat .chat__kopf-text keine Grenze und schiebt ihn hinaus. */ .chat__kopf--verlauf .chat__kopf-text { min-width: 0; flex: 1; } @@ -1218,7 +1241,6 @@ /* 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); @@ -1227,11 +1249,27 @@ /* ---- Die zwei Arten im Neu-Dialog ----------------------------------- */ .neu__art { display: flex; gap: 6px; - margin: 0 0 14px; + margin: 0 0 9px; padding: 4px; border: 1px solid var(--rand); border-radius: 12px; background: rgba(255, 255, 255, .02); } +/* Die Erklaerung zum Umschalter (11.09.2026). Gedaempft und klein -- + sie soll beim ERSTEN Mal gelesen werden und danach nicht im Weg sein. + Der Rand links fuehrt das Auge an beiden Zeilen entlang, ohne dass es + ein Kasten sein muss. */ +.neu__erklaerung { + margin: 0 0 14px; + padding: 2px 0 2px 11px; + border-left: 2px solid color-mix(in srgb, var(--akzent) 34%, transparent); + font-size: .79rem; line-height: 1.55; + color: var(--text-still); +} +.neu__erklaerung p { margin: 0; } +.neu__erklaerung p + p { margin-top: 5px; } +.neu__erklaerung strong { color: var(--text-leise); font-weight: 650; } +.neu__erklaerung em { font-style: normal; color: var(--text-leise); } + .neu__art button { flex: 1; appearance: none; border: 0; border-radius: 9px; diff --git a/workspace/assets/js/chat.js b/workspace/assets/js/chat.js index 2c535c3e..c533c5a7 100644 --- a/workspace/assets/js/chat.js +++ b/workspace/assets/js/chat.js @@ -125,6 +125,9 @@ Ein Knopf, der beim Druecken "darfst du nicht" sagt, ist eine Enttaeuschung mit Ansage. */ $('neu-art').hidden = !darfKanal; + /* Abgeleitet, nicht zweimal entschieden: Die Erklaerung gehoert + genau dann hin, wenn es auch etwas zu entscheiden gibt. */ + $('neu-erklaerung').hidden = $('neu-art').hidden; if (!darfKanal) neuArt = 'gespraech'; } catch { return; } raeumeZeichnen(); @@ -327,6 +330,12 @@ $('chat').dataset.ansicht = 'verlauf'; /* Erst jetzt gibt es etwas zum Wegräumen. */ $('gespraech-weg').hidden = false; + /* AUFLOESEN: nur beim Gruender, und nur bei Gruppe oder Kanal. + Die Antwort kommt vom Server (`darf_aufloesen`) -- hier wird + nichts nachgerechnet. Der Browser kennt `erstellt_von` gar + nicht, und eine zweite Rechenvorschrift waere die, die + irgendwann von der ersten abweicht. */ + $('raum-aufloesen').hidden = !offen.darf_aufloesen; /* Ein frisch geoeffnetes Gespraech steht immer am Ende -- man will das Letzte sehen, nicht das Erste. */ @@ -996,6 +1005,28 @@ raeumeLaden(); }); + /* DER RAUM IST AUFGELOEST (11.09.2026). + + Kommt beim Gruender genauso an wie bei allen anderen -- er + braucht keinen Sonderweg, und dadurch gibt es keinen zweiten + Ablauf, der irgendwann anders aussieht. + + Wer ihn gerade offen hat, bekommt einen Satz dazu. Ohne den + spraenge die Seite wortlos auf die Liste zurueck, und man + haelt es fuer einen Fehler. Wer ihn NICHT offen hat, merkt nur, + dass ein Eintrag aus der Liste verschwindet -- das reicht. */ + strom.addEventListener('raum-weg', (e) => { + let n; + try { n = JSON.parse(e.data); } catch { return; } + if (offen && n.raum_id === offen.id) { + verlaufSchliessen(); + melde('Dieses Gespräch wurde von der Person aufgelöst, die es ' + + 'angelegt hat.'); + setTimeout(() => melde(''), 8000); + } + raeumeLaden(); + }); + /* ANGEHEFTET ODER GELOEST -- alle im Raum sehen es sofort. Betroffen ist nur, wer denselben Raum offen hat; sonst genuegt die Liste. */ @@ -1140,11 +1171,17 @@ $('kanal-zeile').hidden = art !== 'kanal' || !!kanalUmbau; $('neu-titel').textContent = kanalUmbau ? 'Wer ist im Kanal?' : art === 'kanal' ? 'Neuer Kanal' : 'Neues Gespräch'; + /* Der Satz sagt, was JETZT zu tun ist -- der Unterschied zwischen + den beiden Arten steht darueber in #neu-erklaerung und muss hier + nicht noch einmal stehen. Zwei Texte fuer dieselbe Auskunft + laufen auseinander, sobald einer geaendert wird. */ $('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.'; + ? 'Zuständigkeit wählen – der Rest ergibt sich daraus. Leute kannst du gleich ' + + 'mitnehmen oder später hinzufügen.' + : 'Eine Person antippen, um ihr zu schreiben. Mehrere auswählen ergibt eine ' + + 'Gruppe – die bekommt einen Namen und bleibt bestehen.'; wahlGeaendert(); } @@ -1625,6 +1662,28 @@ Die Rückfrage sagt ausdrücklich, was NICHT passiert. "Wirklich löschen?" liesse offen, ob der andere seinen Verlauf verliert -- und das ist genau die Frage, die man in dem Moment hat. */ + /* ZURUECK AUF "nichts offen" -- an EINER Stelle (11.09.2026). + Diese zehn Zeilen standen beim Wegraeumen. Mit dem Aufloesen und + dem Ereignis "der Raum ist weg" braeuchte es sie dreimal, und die + dritte Abschrift waere die, in der eine Zeile fehlt: ein Knopf, + der stehen bleibt, oder ein Entwurf zu einem Raum, den es nicht + mehr gibt. */ + function verlaufSchliessen() { + if (offen) entwurfWeg(offen.id); + offen = null; + nachrichten = []; + $('schreiben').hidden = true; + $('gespraech-weg').hidden = true; + $('raum-aufloesen').hidden = true; + $('kanal-leute').hidden = true; + $('verlauf-titel').textContent = 'Wähle ein Gespräch'; + $('verlauf-unter').textContent = ''; + $('verlauf-leer').hidden = false; + $('chat').dataset.ansicht = 'liste'; + verlaufZeichnen(); + history.replaceState(null, '', 'chat.html'); + } + $('gespraech-weg').addEventListener('click', async () => { if (!offen) return; const wer = offen.name; @@ -1636,22 +1695,62 @@ try { const a = await hole(`/workspace/api/chat/raeume/${offen.id}`, { method: 'DELETE' }); if (!a.ok) { melde('Ging nicht.'); return; } - entwurfWeg(offen.id); - offen = null; - nachrichten = []; - $('schreiben').hidden = true; - $('gespraech-weg').hidden = true; - $('verlauf-titel').textContent = 'Wähle ein Gespräch'; - $('verlauf-unter').textContent = ''; - $('verlauf-leer').hidden = false; - $('chat').dataset.ansicht = 'liste'; - verlaufZeichnen(); - history.replaceState(null, '', 'chat.html'); + verlaufSchliessen(); await raeumeLaden(); melde(''); } catch { melde('Keine Verbindung.'); } }); + /* ===================================================================== + FUER ALLE AUFLOESEN (11.09.2026) + + Filipe: "die person die ihn oeffnet soll auch das recht haben das + zu loeschen und so dass es dan fuer jeden geloescht ist. aber nur + die person die es gruendet." + + DIE RUECKFRAGE NENNT ZAHLEN, KEINE FLOSKEL. "Wirklich loeschen?" + beantwortet die Frage nicht, die man in dem Moment hat -- naemlich + WAS genau verschwindet und bei WEM. Deshalb stehen der Name, die + Zahl der Beteiligten und das Wort "endgueltig" darin. + + Und sie sagt ausdruecklich, was der Unterschied zum Knopf daneben + ist. Zwei Loeschknoepfe nebeneinander sind sonst eine Falle. */ + $('raum-aufloesen').addEventListener('click', async () => { + if (!offen || !offen.darf_aufloesen) return; + const wasIstEs = offen.art === 'kanal' ? 'Kanal' : 'Gruppe'; + const andere = Math.max(0, (offen.teilnehmer?.length || 1) - 1); + if (!window.confirm( + `${wasIstEs} „${offen.name}" für alle auflösen?\n\n` + + `Der ganze Verlauf wird gelöscht – auch bei ${andere === 1 + ? 'der anderen Person' : `den ${andere} anderen`}, samt aller ` + + 'Anhänge. Das lässt sich nicht rückgängig machen.\n\n' + + 'Wenn du sie nur bei dir loswerden willst, nimm den Knopf ' + + 'daneben – dann behalten die anderen alles.')) return; + const knopf = $('raum-aufloesen'); + knopf.disabled = true; + try { + const a = await hole(`/workspace/api/chat/raeume/${offen.id}/ganz`, + { method: 'DELETE' }); + if (!a.ok) { + /* Der Server ist die Wahrheit, nicht der Knopf. Sagt er nein, + steht der Grund da -- nicht "Ging nicht." */ + const d = await a.json().catch(() => ({})); + melde(d.fehler === 'nur_gruender' + ? `Nur wer ${wasIstEs === 'Kanal' ? 'den Kanal' : 'die Gruppe'} ` + + 'angelegt hat, kann sie auflösen.' + : 'Ging nicht.'); + return; + } + const d = await a.json().catch(() => ({})); + verlaufSchliessen(); + await raeumeLaden(); + melde(`${wasIstEs} aufgelöst – ${d.nachrichten ?? 0} Nachrichten ` + + 'für alle gelöscht.', 'gut'); + setTimeout(() => melde(''), 6000); + } catch { melde('Keine Verbindung.'); } + finally { knopf.disabled = false; } + }); + /* Zurück an den Bildschirm: nachsehen, was inzwischen kam, und melden, dass jetzt wirklich jemand hinsieht. */ document.addEventListener('visibilitychange', () => { diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 8d4b579b..2aa06edf 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -12,19 +12,19 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - + + + + + + - +
@@ -252,10 +252,10 @@ - - - - - + + + + +