From f23260dcbb26fdeb7258bdef005ee68ee049f80e Mon Sep 17 00:00:00 2001 From: Dogfather Date: Fri, 11 Sep 2026 15:22:09 +0200 Subject: [PATCH] =?UTF-8?q?Chat:=20Gruender=20duerfen=20ihre=20Gruppe=20od?= =?UTF-8?q?er=20ihren=20Kanal=20aufloesen=20=E2=80=94=20und=20der=20Umscha?= =?UTF-8?q?lter=20erklaert=20sich?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei Wuensche von Filipe, beide am Neu-Fenster. 1) "ich will eine bessere erklaerung dafuer bitte." Neben dem Umschalter "Gespraech | Kanal" stand ein Satz ueber das ANTIPPEN von Personen -- also ueber den naechsten Schritt, nicht ueber die Wahl, die gerade ansteht. Wer die beiden Woerter zum ersten Mal sieht, erfuhr nirgends, was sie bedeuten. Der Unterschied ist nicht "wenige/viele Leute", sondern WONACH der Raum benannt ist: ein Gespraech nach den Menschen darin, ein Kanal nach einem Thema. Daran haengt alles Weitere -- dass es einen Kanal je Zustaendigkeit nur einmal gibt (eindeutiger Index, nachgesehen), dass sein Name festliegt, dass die Teamleitung immer dabei ist (kanaeleAngleichen, nachgesehen) und dass Leute wechseln koennen, ohne dass der Raum ein anderer wird. Genau das steht jetzt da, und nichts davon ist behauptet. 2) "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." Ein EIGENER Weg (/ganz), kein Zusatzfeld am bestehenden. Es gibt jetzt zwei Loeschknoepfe nebeneinander, und sie tun etwas sehr Verschiedenes: Wegraeumen ist nur bei mir, Aufloesen ist fuer alle und endgueltig. Ein vergessenes Feld waere genau dieser Unterschied gewesen. Aus demselben Grund ein anderes Zeichen und eine eigene Warnfarbe -- zwei gleich aussehende Papierkoerbe waeren eine Falle. Nur der Gruender, woertlich: nicht die Teamleitung, nicht DogFather, nicht wer `leitung` in der Gruppe hat. Nicht bei Zweier-Gespraechen -- dort gibt es keinen Gruender, und "niemand nimmt einem anderen die Unterhaltung weg" gilt weiter. Reihenfolge beim Loeschen ist nicht beliebig: erst das Live-Ereignis (chatEreignis liest die Teilnehmer aus der Tabelle -- danach waere die Liste leer), dann die Zeilen in EINER Transaktion, dann die Anhaenge von der Platte. Umgekehrt haetten wir bei einem Ruecklauf Nachrichten, die auf geloeschte Dateien zeigen. WAS DIE PRUEFUNG GEFUNDEN HAT, BEVOR ES JEMAND GEMERKT HAETTE: Der Knopf blieb unsichtbar, obwohl das Recht stimmte. Die Oberflaeche holt den offenen Raum aus dem Nachrichten-Weg, nicht aus der Raumliste -- zwei Wege, ein Raumobjekt, und nur einer kannte das neue Feld. Die Regel steht jetzt in darfAufloesen() und wird von allen dreien benutzt: Liste, Nachrichten-Weg und der Loeschweg selbst. Gefunden hat das die Pruefung, weil sie den KNOPF misst und nicht das Recht dahinter. Haette sie nur `darf_aufloesen` geprueft, waere sie gruen gewesen und der Knopf nie erschienen. NEU: server/pruef-chat-aufloesen.mjs (59 Pruefungen). Sie misst am BESTAND, nicht an der Antwort: ob der Raum wirklich aus der Datenbank weg ist, ob keine Teilnehmerzeile liegen blieb, ob der Anhang von der Platte verschwand -- und mit Gegenprobe, dass der Anhang-Ordner selbst stehen bleibt. Ohne die waere "Datei ist weg" auch dann gruen, wenn es sie nie gab; genau das ist beim ersten Lauf passiert (der Upload lief ins 415, weil ich ihn als Formular statt roh geschickt hatte). Dazu: Wegraeumen ist NICHT Aufloesen (Ben raeumt weg, Cem hat alles noch), ein Zweier-Gespraech laesst sich gar nicht aufloesen, ein Aussenstehender bekommt 404 statt 403, ein zweiter Versuch findet nichts, und die Zustaendigkeit eines aufgeloesten Kanals wird wieder frei (sonst haette der eindeutige Index sie dauerhaft blockiert). Nebenbei: Die drei Kopfknoepfe schoben sich jeder einzeln mit `margin-left: auto` nach rechts. Bei zwei sichtbaren teilen sich zwei auto-Raender den freien Platz und reissen sie auseinander -- und WELCHE sichtbar sind, entscheidet der Server. Jetzt schiebt ein Behaelter einmal, die Knoepfe stehen beieinander, egal wie viele es sind. Co-Authored-By: Claude Opus 5 --- server/pruef-chat-aufloesen.mjs | 521 ++++++++++++++++++++++++++++++++ server/workspace-chat.js | 147 ++++++++- workspace/app.webmanifest | 8 +- workspace/assets/css/chat.css | 44 ++- workspace/assets/js/chat.js | 125 +++++++- workspace/aufgaben.html | 24 +- workspace/automation.html | 26 +- workspace/bereich.html | 34 +-- workspace/calls.html | 28 +- workspace/chat.html | 78 ++++- workspace/content.html | 30 +- workspace/crew-index.html | 10 +- workspace/crew.webmanifest | 8 +- workspace/dateien.html | 26 +- workspace/index.html | 8 +- workspace/kalender.html | 28 +- workspace/leistung.html | 26 +- workspace/personen.html | 26 +- workspace/profil.html | 28 +- workspace/report.html | 28 +- workspace/scouting.html | 26 +- workspace/start.html | 24 +- workspace/startcheck.html | 28 +- workspace/steckbrief.html | 26 +- workspace/team.html | 26 +- workspace/teamlage.html | 24 +- workspace/uebersicht.html | 26 +- workspace/wissen.html | 26 +- 28 files changed, 1156 insertions(+), 303 deletions(-) create mode 100644 server/pruef-chat-aufloesen.mjs 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 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index ced152f7..9909421c 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -147,10 +147,10 @@ - - - - - + + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index e19e9e9a..5a6719cd 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -181,14 +181,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index b630f19e..0fb005db 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -89,11 +89,11 @@ - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index 5a95a67b..d6004f11 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -12,26 +12,26 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -127,6 +127,14 @@ + +
+ + +
+ + - + diff --git a/workspace/crew.webmanifest b/workspace/crew.webmanifest index d9752d3d..86f77415 100644 --- a/workspace/crew.webmanifest +++ b/workspace/crew.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/crew-192.png?v=202609111501", + "src": "/assets/img/app-symbole/crew-192.png?v=202609111522", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-512.png?v=202609111501", + "src": "/assets/img/app-symbole/crew-512.png?v=202609111522", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609111501", + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609111522", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609111501", + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609111522", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/dateien.html b/workspace/dateien.html index 2b57b8a6..021a8d26 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -108,10 +108,10 @@ - - - - - + + + + + diff --git a/workspace/index.html b/workspace/index.html index 6413b85c..54f8a0fa 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -30,9 +30,9 @@ - - - + + + @@ -401,6 +401,6 @@ - + diff --git a/workspace/kalender.html b/workspace/kalender.html index 2e2108f5..c697db52 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -377,11 +377,11 @@ - - - - - - + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index ec8afcfc..cc6cdc67 100644 --- a/workspace/leistung.html +++ b/workspace/leistung.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -353,10 +353,10 @@ - - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index 5a8158eb..32c4641d 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -396,10 +396,10 @@ - - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index 3d9b0072..ec231d14 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -214,11 +214,11 @@ - - - - - - + + + + + + diff --git a/workspace/report.html b/workspace/report.html index d4fd79ad..b37a5fb3 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -106,11 +106,11 @@ - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 8514356e..cfa82b03 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -173,10 +173,10 @@ - - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index b5384eb5..e59b7e4e 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -12,21 +12,21 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - + + - + @@ -674,10 +674,10 @@

- - - - - + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index 65238c5f..fa0da082 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -85,11 +85,11 @@ - - - - - - + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index b257d4cf..d13eba2d 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -126,10 +126,10 @@ - - - - - + + + + + diff --git a/workspace/team.html b/workspace/team.html index 09e0c488..3abe3786 100644 --- a/workspace/team.html +++ b/workspace/team.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + @@ -110,10 +110,10 @@ - - - - - + + + + + diff --git a/workspace/teamlage.html b/workspace/teamlage.html index 06721499..2305940d 100644 --- a/workspace/teamlage.html +++ b/workspace/teamlage.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + - - - - + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 02f139b4..a96d0b3e 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -12,22 +12,22 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -149,11 +149,11 @@ - + - - - - + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index a2c86ecf..fd9188a3 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -190,10 +190,10 @@ - - - - - + + + + +