diff --git a/server/pruef-chat-aufloesen.mjs b/server/pruef-chat-aufloesen.mjs index eaa1a6e3..90d925fa 100644 --- a/server/pruef-chat-aufloesen.mjs +++ b/server/pruef-chat-aufloesen.mjs @@ -542,15 +542,26 @@ console.log("=== Reaktionen ==="); `ein erfundenes Zeichen wird abgewiesen (HTTP ${unsinn.status})`); ok(zaehle() === 3, `und aendert nichts am Bestand (${zaehle()})`); - /* ---- Eine zurueckgenommene Nachricht ----------------------------- */ + /* ---- Eine GELOESCHTE Nachricht ---------------------------------- + Hier stand 409 („zurueckgenommen"). Seit dem 22.09.2026 abends + wird wirklich geloescht -- Filipe: „Geloeschte Nachrichten + verschwinden vollstaendig." Die Zeile gibt es dann nicht mehr, + und die richtige Antwort ist 404: nicht gefunden. + + Die Frage bleibt dieselbe und ist sogar schaerfer: Niemand darf + auf etwas reagieren, das es nicht gibt -- und der Bestand darf + sich dabei nicht aendern. */ + const vorherBestand = zaehle(); const weg = await (await rufe(eins.keks, `/workspace/api/chat/raeume/${g.raum_id}/nachrichten`, { text: "Weg gleich" }, "POST")).json(); await rufe(eins.keks, `/workspace/api/chat/nachrichten/${weg.nachricht.id}`, undefined, "DELETE"); const drauf = await rufe(zwei.keks, `/workspace/api/chat/nachrichten/${weg.nachricht.id}/reaktion`, { zeichen: daumen }, "PUT"); - ok(drauf.status === 409, - `auf eine zurueckgenommene Nachricht geht es nicht (HTTP ${drauf.status})`); + ok(drauf.status === 404, + `auf eine geloeschte Nachricht geht es nicht -- es gibt sie nicht mehr (HTTP ${drauf.status})`); + ok(zaehle() === vorherBestand, + `und der Bestand bleibt unveraendert (${vorherBestand})`); /* ---- Und sie ueberleben das Neuladen ----------------------------- */ const verlauf = await holen(zwei.keks, diff --git a/server/pruef-chat-ausbau.mjs b/server/pruef-chat-ausbau.mjs index 12f16491..49d3deb3 100644 --- a/server/pruef-chat-ausbau.mjs +++ b/server/pruef-chat-ausbau.mjs @@ -212,16 +212,25 @@ melde("\n=== 2. Kopieren ==="); ".chat-nachricht:first-of-type .chat-nachricht__kopie-knopf", (b) => b.textContent); ok(zurueck === "kopieren", `und fällt danach zurück ("${zurueck}")`); - /* GEGENPROBE: An einer zurückgenommenen Nachricht gibt es nichts zu - kopieren -- dort darf der Knopf nicht stehen. */ + /* GEGENPROBE, NEU GEFASST (22.09.2026 abends). + ------------------------------------------------------------------ + Hier wurde geprueft, dass an einer ZURUECKGENOMMENEN Nachricht kein + Kopier-Knopf steht. Es gibt keine zurueckgenommenen mehr -- Filipe: + „Geloeschte Nachrichten verschwinden vollstaendig." + + Die Frage dahinter bleibt richtig und wird jetzt direkt gestellt: + Nach dem Loeschen ist die Nachricht WEG, samt allem, was an ihr + hing. Gemessen an der Zahl der Nachrichten davor und danach -- + eine Zahl, die sich um genau eins aendern muss. */ + const vorLoeschen = await luna.seite.$$eval('.chat-nachricht', (b) => b.length); await bestaetige(luna.seite, () => luna.seite.click( ".chat-nachricht:last-of-type .chat-nachricht__weg-knopf")); await luna.seite.waitForTimeout(900); - const anWeg = await luna.seite.$$eval('.chat-nachricht[data-weg="ja"] .chat-nachricht__kopie-knopf', - (b) => b.length); - const wegDa = await luna.seite.$$eval('.chat-nachricht[data-weg="ja"]', (b) => b.length); - ok(wegDa === 1 && anWeg === 0, - `an der zurückgenommenen Nachricht steht kein Kopier-Knopf (${wegDa} weg, ${anWeg} Knöpfe)`); + const nachLoeschen = await luna.seite.$$eval('.chat-nachricht', (b) => b.length); + const restWeg = await luna.seite.$$eval('.chat-nachricht[data-weg="ja"]', (b) => b.length); + ok(vorLoeschen > 0 && nachLoeschen === vorLoeschen - 1 && restWeg === 0, + `die geloeschte ist aus dem Bild (${vorLoeschen} -> ${nachLoeschen}),` + + ` und es bleibt keine Spur (${restWeg})`); } /* ======================================================================= diff --git a/server/pruef-chat-optik.mjs b/server/pruef-chat-optik.mjs index 24ceb6ea..d8e015ca 100644 --- a/server/pruef-chat-optik.mjs +++ b/server/pruef-chat-optik.mjs @@ -285,18 +285,32 @@ melde("\n=== Zurücknehmen ==="); ".chat-nachricht[data-selbst='ja']:last-of-type .chat-nachricht__weg-knopf")); await luna.seite.waitForTimeout(1000); + /* UMGEDREHT AM 22.09.2026 ABENDS. + ------------------------------------------------------------------ + Hier stand: „die Zeile bleibt stehen -- kein Loch im Verlauf", und + daneben „an ihrer Stelle steht 'Nachricht zurueckgenommen'". + + Filipe: „Geloeschte Nachrichten verschwinden vollstaendig -- keine + Spur, kein ‚wurde geloescht'-Hinweis, bei niemandem, auch nicht + bei DogFather." + + Die Zahl davor und danach ist der Beleg: Sie muss um genau eins + sinken. „Irgendwie weniger" waere auch dann gruen, wenn der ganze + Verlauf verschwaende. */ const nachher = (await luna.seite.$$(".chat-nachricht")).length; - ok(nachher === vorher, - `die Zeile bleibt stehen (${nachher} statt ${vorher}) — kein Loch im Verlauf`); + ok(vorher > 0 && nachher === vorher - 1, + `die Zeile ist weg (${vorher} -> ${nachher}) — und nur sie`); const hinweis = await luna.seite.$(".chat-nachricht__weg"); - ok(!!hinweis, "an ihrer Stelle steht 'Nachricht zurückgenommen'"); + ok(!hinweis, "und es steht kein Hinweis an ihrer Stelle"); - /* Und beim Gegenüber verschwindet der Text ebenfalls -- von selbst. */ + /* UND BEIM GEGENUEBER EBENFALLS -- von selbst, ohne Neuladen. Das + ist der Teil, der ueber den Ereignisstrom laeuft; ohne ihn saehe + Patrick die Nachricht weiter, bis er die Seite neu laedt. */ const beiPat = await pat.seite.waitForFunction( - () => !![...document.querySelectorAll(".chat-nachricht__weg")].length, - { timeout: 8000 }).then(() => true).catch(() => false); - ok(beiPat, "auch bei Patrick ist der Text weg, ohne Neuladen"); + (wieViele) => document.querySelectorAll(".chat-nachricht").length === wieViele, + vorher - 1, { timeout: 8000 }).then(() => true).catch(() => false); + ok(beiPat, "auch bei Patrick verschwindet sie, ohne Neuladen"); } /* ======================================================================= @@ -310,7 +324,7 @@ melde("\n=== Gegenprobe ==="); for (const n of document.querySelectorAll(".chat-nachricht")) n.remove(); }); const nachher = (await luna.seite.$$(".chat-nachricht")).length; - ok(vorher >= 3 && nachher === 0, + ok(vorher >= 2 && nachher === 0, `der Selektor trifft (${vorher} gefunden, nach dem Entfernen ${nachher})`); /* Und ein Text, der nie geschrieben wurde, darf NICHT gefunden diff --git a/server/pruef-chat.mjs b/server/pruef-chat.mjs index f843af73..38c64825 100644 --- a/server/pruef-chat.mjs +++ b/server/pruef-chat.mjs @@ -337,11 +337,26 @@ console.log("\n=== Eine Nachricht zurücknehmen ==="); const e = await rufe(luna.keks, `/workspace/api/chat/nachrichten/${nachricht.id}`, undefined, "DELETE"); ok(e.ok, `die eigene: geht (${e.status})`); - /* UND SIE BLEIBT SICHTBAR -- als zurückgenommen, nicht als Loch. */ + /* UND SIE IST WIRKLICH WEG (22.09.2026 abends). + ------------------------------------------------------------------ + Hier stand das Gegenteil: „sie bleibt sichtbar -- als + zurueckgenommen, nicht als Loch." Das war die bewusste + Entscheidung bis heute Nachmittag. + + Filipe: „Geloeschte Nachrichten verschwinden vollstaendig -- keine + Spur, kein ‚wurde geloescht'-Hinweis, bei niemandem, auch nicht + bei DogFather." + + Die Zeile misst jetzt beides: dass sie fehlt UND dass der Rest des + Verlaufs noch da ist. Ohne den zweiten Teil waere ein Verlauf, der + ueberhaupt nichts mehr liefert, ebenfalls gruen. */ const verlauf = await holen(pat.keks, `/workspace/api/chat/raeume/${raumLunaPat}/nachrichten`); const weg = verlauf.nachrichten.find((n) => n.id === nachricht.id); - ok(weg && weg.zurueckgenommen && weg.text === null, - `sie steht weiterhin im Verlauf, als zurückgenommen und ohne Text`); + ok(!weg && verlauf.nachrichten.length > 0, + `sie steht in keinem Verlauf mehr, und die uebrigen` + + ` ${verlauf.nachrichten.length} stehen noch da`); + ok(!verlauf.nachrichten.some((n) => n.zurueckgenommen), + "und es steht nirgends ein Hinweis, dass hier etwas stand"); ok(!JSON.stringify(verlauf).includes("Das war ein Versehen"), "der Text ist wirklich weg, nicht nur ausgeblendet"); } diff --git a/server/pruef-chatkachel.mjs b/server/pruef-chatkachel.mjs index 05e41fc5..8699299a 100644 --- a/server/pruef-chatkachel.mjs +++ b/server/pruef-chatkachel.mjs @@ -53,7 +53,7 @@ const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); console.log("\n=== Chat: Bild dahinter, Kachel davor ===\n"); /* ---- 1. Die Töne, ohne Browser ----------------------------------- */ -const { CHAT_KACHELN, kachelPruefen } = await import("./workspace-chat.js"); +const { CHAT_KACHELN, kachelPruefen, schriftFuer } = await import("./workspace-chat.js"); ok(CHAT_KACHELN.length >= 8, `es gibt genug Töne zur Auswahl (${CHAT_KACHELN.length})`); /* ALLE GLEICH DUNKEL. Nur dann ist der Kontrast zur Schrift bei jedem @@ -100,26 +100,55 @@ const BLASEN_SCHRIFT = [ ["blase-text", ".chat-nachricht__text", 7], ["blase-leise", ".chat-nachricht__fuss", 4.5], ]; -for (const [name, wo, noetig] of BLASEN_SCHRIFT) { - const farbe = ausCss(name); - if (!farbe) { - ok(false, `KONNTE NICHT NACHSEHEN: --${name} steht nicht in chat.css` - + " — ohne sie lässt sich der Kontrast der Kacheln nicht prüfen"); - continue; - } - let schlecht = Infinity, wer = ""; +/* JEDE KACHEL MIT IHRER EIGENEN SCHRIFT (22.09.2026 abends). + --------------------------------------------------------------------- + Hier stand eine feste Schriftfarbe aus chat.css, gegen ALLE Töne + gemessen. Das galt, solange alle Töne dunkel waren. + + Filipe: „Textfarbe soll mitwechseln, am besten schwarz auf hellen + Kacheln – und weiß, wenn jemand eine schwarze Kachel wählt." + + Seither entscheidet `schriftFuer()` im Server, welches Paar eine + Fläche trägt. Die Prüfung fragt jetzt dieselbe Funktion -- nicht, + weil das bequem ist, sondern weil alles andere eine zweite Wahrheit + wäre: Eine Prüfung mit eigener Rechnung könnte grün sein, während + der Chat etwas anderes anzeigt. + + WAS SIE DAMIT NOCH PRÜFT: Dass das Ergebnis von `schriftFuer` die + Schwellen wirklich schafft. Die Funktion darf im Notfall auch das + schlechtere Paar zurückgeben (sie MUSS etwas liefern) -- genau dann + schlägt diese Zeile an, und das ist ihr Zweck. */ +for (const [feld, wo, noetig] of [["text", ".chat-nachricht__text", 7], + ["leise", ".chat-nachricht__fuss", 4.5]]) { + let schlecht = Infinity, wer = "", gemessen = 0; for (const k of CHAT_KACHELN) { - const c = kontrast(farbe, k.farbe); + const paar = schriftFuer(k.farbe); + if (!paar || !paar[feld]) continue; + gemessen++; + const c = kontrast(paar[feld], k.farbe); if (c < schlecht) { schlecht = c; wer = k.name; } } /* Die ANZAHL gehört in die Bedingung. Wäre CHAT_KACHELN je leer, bliebe `schlecht` auf Infinity und die Zeile wäre grün, ohne einen einzigen Ton angesehen zu haben. */ - ok(CHAT_KACHELN.length >= 8 && schlecht >= noetig, - `${wo} trägt auf allen ${CHAT_KACHELN.length} Tönen ` + ok(gemessen === CHAT_KACHELN.length && gemessen >= 8 && schlecht >= noetig, + `${wo} trägt auf allen ${gemessen} Tönen ` + `(schlechtester ${wer}: ${schlecht.toFixed(2)}:1, nötig ${noetig})`); } +/* UND DIE SCHRIFT WECHSELT WIRKLICH. Ohne diese Zeile wäre nicht + bewiesen, dass `schriftFuer` mehr tut, als immer dasselbe Paar + zurückzugeben -- dann wäre die Schleife oben nur die alte Prüfung + mit mehr Umweg. */ +{ + const hell = CHAT_KACHELN.filter((k) => helligkeit(k.farbe) > 0.3); + const dunkel = CHAT_KACHELN.filter((k) => helligkeit(k.farbe) <= 0.3); + const hellDunkleSchrift = hell.every((k) => helligkeit(schriftFuer(k.farbe).text) < 0.2); + const dunkelHelleSchrift = dunkel.every((k) => helligkeit(schriftFuer(k.farbe).text) > 0.6); + ok(hell.length >= 1 && dunkel.length >= 1 && hellDunkleSchrift && dunkelHelleSchrift, + `${hell.length} helle Kacheln tragen dunkle Schrift, ${dunkel.length} dunkle tragen helle`); +} + /* GEGENPROBE: Eine Prüfung, die nur bestätigen kann, bestätigt nichts. Ein absichtlich zu heller Ton MUSS durchfallen — sonst wäre das Grün oben nur die Abwesenheit einer Messung. */ @@ -136,11 +165,30 @@ for (const [name, wo, noetig] of BLASEN_SCHRIFT) { werden, liegen sie auf einem Wert, und der Rest ist Rundung auf ganze Bildpunkte. Ein Faktor über 1,1 hieße: jemand hat eine Farbe von Hand eingetragen und damit die Rechnung umgangen. */ -const spanne = Math.max(...CHAT_KACHELN.map((k) => helligkeit(k.farbe))) - / Math.max(1e-6, Math.min(...CHAT_KACHELN.map((k) => helligkeit(k.farbe)))); -ok(spanne < 1.1, - `alle Töne liegen auf derselben Leuchtdichte (Faktor ${spanne.toFixed(3)}) ` - + "— keiner ist heller oder dunkler als die anderen"); +/* SIE GILT FÜR DIE GERECHNETEN -- und nur für die (22.09.2026 abends). + + Der Zweck dieser Zeile ist: „Niemand kann sich durch seine Wahl + schlechter lesbar machen als die anderen." Solange alle Töne + dieselbe feste Schrift trugen, war gleiche Leuchtdichte der einzige + Weg dorthin. + + Seit die Schrift mitwechselt, gibt es einen zweiten -- und die + Prüfung darüber löst das Versprechen direkt ein: JEDE Kachel + erreicht mit IHRER Schrift 7:1 und 4,5:1. Das ist die Aussage, um + die es ging; die gleiche Leuchtdichte war nur ihr Mittel. + + Für die elf gerechneten Töne bleibt das Mittel trotzdem stehen: + Sie kommen aus tools/chat-kacheln-rechnen.mjs, und ein Faktor über + 1,1 hieße dort weiterhin, dass jemand eine Farbe von Hand + eingetragen und damit die Rechnung umgangen hat. Ausgenommen sind + die, die nachweislich eine andere Schrift tragen. */ +const gerechnet = CHAT_KACHELN.filter( + (k) => helligkeit(schriftFuer(k.farbe).text) > 0.6); +const spanne = Math.max(...gerechnet.map((k) => helligkeit(k.farbe))) + / Math.max(1e-6, Math.min(...gerechnet.map((k) => helligkeit(k.farbe)))); +ok(gerechnet.length >= 8 && spanne < 1.1, + `die ${gerechnet.length} gerechneten Töne liegen auf derselben Leuchtdichte ` + + `(Faktor ${spanne.toFixed(3)}) — keiner ist heller oder dunkler als die anderen`); /* UND SIE MÜSSEN BUNT SEIN. Gemessen an dem, was vor dem 22.09.2026 drinstand: Buntheit 0,002 bis 0,048, im Mittel 0,028 — zwölf diff --git a/server/pruef-loeschen.mjs b/server/pruef-loeschen.mjs new file mode 100644 index 00000000..0daf8e04 --- /dev/null +++ b/server/pruef-loeschen.mjs @@ -0,0 +1,296 @@ +/* ===================================================================== + LÖSCHEN HEISST LÖSCHEN — und wer es darf + ===================================================================== + + Filipe am 22.09.2026: + + „Gelöschte Nachrichten verschwinden vollständig – keine Spur, kein + ‚wurde gelöscht'-Hinweis, bei niemandem, auch nicht bei + DogFather." + „Löschen darf weiterhin jeder nur seine eigenen – außer DogFather + und rechte Hand: die dürfen jede Nachricht löschen, für den + Notfall." + + --------------------------------------------------------------------- + WARUM DAS NICHT „EIN STATUS WENIGER" IST + + Vorher blieb die Zeile stehen: Text geleert, `weg_am` gesetzt, und + der Chat zeigte „Nachricht zurückgenommen". Es war so begründet -- + „ein Loch im Verlauf wirft mehr Fragen auf als der Hinweis". Das + Argument beantwortet nur eine andere Frage: Ein Hinweis „hier stand + etwas" MARKIERT die Stelle. Wer etwas aus Versehen schreibt, will es + weg haben und nicht unterstrichen. + + Ein echtes DELETE hat aber vier Ränder, und an jedem kann eine Spur + übrig bleiben. Genau die werden hier gemessen: + + 1. die Zeile selbst + 2. Reaktionen darauf (chat_reaktionen, ON DELETE CASCADE) + 3. Erwähnungen darin (chat_erwaehnungen, ON DELETE CASCADE) + 4. `letzte_am` am Raum -- sonst stünde er in der Liste ganz + oben, mit einem Zeitpunkt, zu dem + es nichts mehr gibt + + Die Punkte 2 und 3 hängen an `PRAGMA foreign_keys = ON`. Dass die + Regel dort steht, ist eine Behauptung im Quelltext; dass sie WIRKT, + sagt nur eine Messung. + + --------------------------------------------------------------------- + DIE RECHTEGRENZE HAT VIER FÄLLE, NICHT ZWEI + + Eine Prüfung, die nur „der Eigene darf" und „ein Fremder darf nicht" + misst, übersieht die Hälfte: Die linke Hand führt Team Dogi mit + (`fuehrtTeamDogi`) und darf es trotzdem NICHT -- Filipe hat zwei + Rollen genannt, nicht drei. Und Spicy Media, das sonst fast alles + darf, darf private Chats nicht einmal sehen. + ===================================================================== */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { notbremse } from "./helfer-notbremse.mjs"; +import { eigenerPort } from "./helfer-port.mjs"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-loesch-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +const PORT = await eigenerPort(import.meta, "pruef-loeschen"); +process.env.PORT = `${PORT}`; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +notbremse(120_000, "pruef-loeschen"); +await new Promise((r) => setTimeout(r, 800)); + +let fehler = 0, geprueft = 0, offen = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; +const unklar = (t) => { offen++; console.log(" -- " + t); }; + +/* ---------- Leute ------------------------------------------------------ */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +const schluessel = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", schluessel, jetzt); + +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen" + + " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, + createHmac("sha256", schluessel).update(code).digest("hex"), jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} + +const idChef = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idHand = anlegen("Rieke", "hand", "CODE-HAND-0001"); +const idLinke = anlegen("Lino", "linke", "CODE-LINK-0001"); +const idModi = anlegen("Frida", "modi", "CODE-MODI-0001"); +const idModi2 = anlegen("Nele", "modi", "CODE-MODI-0002"); + +/* ---------- Anfragen (node:http wegen des Host-Kopfes) ----------------- */ +const { request: httpAnfrage } = await import("node:http"); +const CREW = "crew.dogfather-universe.com"; +function anfrage(pfad, { methode = "GET", keks = "", rumpf = null } = {}) { + return new Promise((fertig, schief) => { + const daten = rumpf === null ? null : Buffer.from(JSON.stringify(rumpf)); + const a = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: pfad, method: methode, + headers: { + Host: CREW, Origin: `https://${CREW}`, Referer: `https://${CREW}/workspace/`, + ...(daten ? { "Content-Type": "application/json", "Content-Length": daten.length } : {}), + ...(keks ? { Cookie: keks } : {}), + }, + }, (antwort) => { + const st = []; + antwort.on("data", (x) => st.push(x)); + antwort.on("end", () => { + const text = Buffer.concat(st).toString("utf8"); + let json = null; try { json = JSON.parse(text); } catch { /* kein JSON */ } + fertig({ code: antwort.statusCode, json, + keks: [].concat(antwort.headers["set-cookie"] || []) + .map((c) => c.split(";")[0]).join("; ") }); + }); + }); + a.on("error", schief); + if (daten) a.write(daten); + a.end(); + }); +} +const anmelden = async (rolle, code) => + (await anfrage("/workspace/api/anmelden", { methode: "POST", rumpf: { rolle, code } })).keks; +const ruf = (keks, pfad, o = {}) => anfrage(pfad, { ...o, keks }); + +const keksChef = await anmelden("admin", "CODE-DOGI-0001"); +const keksHand = await anmelden("hand", "CODE-HAND-0001"); +const keksLinke = await anmelden("linke", "CODE-LINK-0001"); +const keksModi = await anmelden("modi", "CODE-MODI-0001"); +/* Nele schreibt die fremden Nachrichten -- sie ist in keinem der + vier Faelle die Loeschende, damit "fremd" fuer alle vier + wirklich fremd ist. */ +const keksModi2 = await anmelden("modi", "CODE-MODI-0002"); + +console.log("=== Löschen heißt Löschen ==="); +console.log(""); + +/* Ein Kanal, in dem alle drin sind -- die Kanalregel vom selben Tag + holt sie beim ersten Blick in die Liste hinein. */ +const kanal = await ruf(keksChef, "/workspace/api/chat/kanal", + { methode: "POST", rumpf: { kategorie: "chat", mit: [] } }); +const raumId = kanal.json?.raum_id; +await ruf(keksChef, "/workspace/api/chat/raeume"); +const drin = d.prepare( + "SELECT COUNT(*) AS n FROM chat_teilnehmer WHERE raum_id = ? AND raus_am IS NULL").get(raumId).n; +ok(kanal.code === 201 && drin === 5, `ein Kanal mit allen fünf steht (${drin})`); + +const schreiben = async (keks, text) => { + const a = await ruf(keks, `/workspace/api/chat/raeume/${raumId}/nachrichten`, + { methode: "POST", rumpf: { text } }); + return a.json?.id || a.json?.nachricht?.id || null; +}; + +/* ===================================================================== + 1. EINE EIGENE NACHRICHT -- MIT ALLEM DRAN + ===================================================================== */ +console.log("--- Eine eigene löschen, mit Reaktion und Erwähnung ---"); + +const idNachricht = await schreiben(keksModi, "Hallo @Rieke, das hier ist ein Versehen"); +ok(Number.isInteger(idNachricht), `sie ist geschrieben (#${idNachricht})`); + +await ruf(keksChef, `/workspace/api/chat/nachrichten/${idNachricht}/reaktion`, + { methode: "PUT", rumpf: { zeichen: "\u{1F44D}" } }); + +const vorher = { + zeile: d.prepare("SELECT COUNT(*) AS n FROM chat_nachrichten WHERE id = ?").get(idNachricht).n, + reaktion: d.prepare("SELECT COUNT(*) AS n FROM chat_reaktionen WHERE nachricht_id = ?") + .get(idNachricht).n, + erwaehnung: d.prepare("SELECT COUNT(*) AS n FROM chat_erwaehnungen WHERE nachricht_id = ?") + .get(idNachricht).n, +}; +ok(vorher.zeile === 1 && vorher.reaktion === 1 && vorher.erwaehnung === 1, + `vorher: Zeile ${vorher.zeile}, Reaktion ${vorher.reaktion}, Erwähnung ${vorher.erwaehnung}` + + " — es gibt also wirklich etwas zu löschen"); + +const weg = await ruf(keksModi, `/workspace/api/chat/nachrichten/${idNachricht}`, + { methode: "DELETE" }); +ok(weg.code === 200, `die eigene löschen geht (${weg.code})`); + +const nachher = { + zeile: d.prepare("SELECT COUNT(*) AS n FROM chat_nachrichten WHERE id = ?").get(idNachricht).n, + reaktion: d.prepare("SELECT COUNT(*) AS n FROM chat_reaktionen WHERE nachricht_id = ?") + .get(idNachricht).n, + erwaehnung: d.prepare("SELECT COUNT(*) AS n FROM chat_erwaehnungen WHERE nachricht_id = ?") + .get(idNachricht).n, +}; +ok(nachher.zeile === 0, "die Zeile ist weg — nicht geleert, weg"); +ok(nachher.reaktion === 0 && nachher.erwaehnung === 0, + `und Reaktion (${nachher.reaktion}) und Erwähnung (${nachher.erwaehnung}) mit ihr` + + " — ON DELETE CASCADE wirkt wirklich"); + +/* KEINE SPUR IM VERLAUF -- auch nicht bei DogFather. */ +const verlaufChef = await ruf(keksChef, `/workspace/api/chat/raeume/${raumId}/nachrichten`); +const alle = verlaufChef.json?.nachrichten || []; +ok(!alle.some((n) => n.id === idNachricht), + "sie steht in keinem Verlauf mehr — auch nicht in dem von DogFather"); +ok(!alle.some((n) => n.zurueckgenommen), + "und es steht nirgends eine zurückgenommene Nachricht"); + +/* ===================================================================== + 2. DIE VIER FÄLLE DER RECHTEGRENZE + ===================================================================== */ +console.log(""); +console.log("--- Wer darf eine FREMDE löschen ---"); + +const faelle = [ + ["DogFather", keksChef, 200, "er ist einer der zwei, die es dürfen"], + ["die rechte Hand", keksHand, 200, "sie ist die zweite"], + ["die linke Hand", keksLinke, 403, + "sie führt Team Dogi mit und darf es trotzdem nicht — genannt waren zwei Rollen"], + ["ein Modi", keksModi, 403, "er nicht"], +]; +let geprueftFaelle = 0; +for (const [wer, keks, erwartet, warum] of faelle) { + const id = await schreiben(keksModi2, `Fremde Nachricht für ${wer}`); + if (!Number.isInteger(id)) { unklar(`für ${wer} ließ sich keine Nachricht anlegen`); continue; } + const a = await ruf(keks, `/workspace/api/chat/nachrichten/${id}`, { methode: "DELETE" }); + const nochDa = d.prepare("SELECT COUNT(*) AS n FROM chat_nachrichten WHERE id = ?").get(id).n; + geprueftFaelle++; + ok(a.code === erwartet && (erwartet === 200 ? nochDa === 0 : nochDa === 1), + `${wer}: ${a.code} (erwartet ${erwartet}), Zeile danach ${nochDa} — ${warum}`); +} +ok(geprueftFaelle === faelle.length, + `alle ${faelle.length} Fälle wirklich durchgespielt (${geprueftFaelle})`); + + +/* ===================================================================== + 3. DER RAUM VERGISST DEN ZEITPUNKT + ===================================================================== */ +console.log(""); +console.log("--- Was der Raum danach über sich sagt ---"); + +const letzteId = await schreiben(keksModi, "Die allerletzte"); +const vorLoeschen = d.prepare("SELECT letzte_am FROM chat_raeume WHERE id = ?").get(raumId).letzte_am; +await ruf(keksModi, `/workspace/api/chat/nachrichten/${letzteId}`, { methode: "DELETE" }); +const nachLoeschen = d.prepare("SELECT letzte_am FROM chat_raeume WHERE id = ?").get(raumId).letzte_am; +ok(!!vorLoeschen && vorLoeschen !== nachLoeschen, + `der Raum rechnet seinen letzten Zeitpunkt neu (${vorLoeschen} → ${nachLoeschen})`); + +/* ===================================================================== + 4. DAS BABYBLAUE HERZ + ===================================================================== */ +console.log(""); +console.log("--- Babyblau statt Blau ---"); + +const { ERLAUBT } = await import("./workspace-reaktionen.js"); +ok(ERLAUBT.has("\u{1FA75}"), "das babyblaue Herz steht im Katalog"); +ok(!ERLAUBT.has("\u{1F499}"), "das normale blaue nicht mehr"); + +/* GEGENPROBE AM LAUFENDEN SERVER: Was der Katalog sagt, muss auch der + Weg zum Setzen sagen. Zwei Listen, die auseinanderlaufen, wären hier + besonders unangenehm -- man setzt ein Zeichen und es verschwindet. */ +const idFuerHerz = await schreiben(keksModi, "Ein Satz zum Liebhaben"); +const babyOk = await ruf(keksChef, `/workspace/api/chat/nachrichten/${idFuerHerz}/reaktion`, + { methode: "PUT", rumpf: { zeichen: "\u{1FA75}" } }); +const blauAb = await ruf(keksHand, `/workspace/api/chat/nachrichten/${idFuerHerz}/reaktion`, + { methode: "PUT", rumpf: { zeichen: "\u{1F499}" } }); +ok(babyOk.code === 200, `ein babyblaues Herz geht durch (${babyOk.code})`); +ok(blauAb.code === 400, `ein blaues wird abgewiesen (${blauAb.code})`); + +/* ===================================================================== + 5. IM CHAT-SKRIPT STEHT DER SATZ NICHT MEHR + ===================================================================== */ +console.log(""); +console.log("--- Und im Browser ---"); + +const { readFileSync } = await import("node:fs"); +const { dirname } = await import("node:path"); +const { fileURLToPath } = await import("node:url"); +const HIER = dirname(fileURLToPath(import.meta.url)); +const chatJs = readFileSync(join(HIER, "..", "workspace", "assets", "js", "chat.js"), "utf8"); +/* NUR AUSSERHALB VON KOMMENTAREN. Die Begründung, warum der Satz weg + ist, nennt ihn -- und muss das auch dürfen. Gesucht wird deshalb + nach ihm als Zeichenkette in Anführungszeichen, also so, wie er auf + den Bildschirm käme. */ +const alsText = /(['"`])Nachricht zurückgenommen\1/.test(chatJs); +ok(!alsText, "der Satz steht in chat.js an keiner Stelle mehr," + + " an der ihn jemand lesen koennte"); +ok(/Nachricht zurückgenommen/.test(chatJs), + "Gegenprobe: der Satz kommt in den Kommentaren durchaus vor —" + + " die Suche oben unterscheidet also wirklich"); + +/* ---------- Ende ------------------------------------------------------ */ +d.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Windows haelt manchmal */ } + +console.log(""); +console.log(`${fehler === 0 ? "BESTANDEN" : "FEHLGESCHLAGEN"}` + + ` -- ${geprueft} geprueft, ${fehler} Fehler, ${offen} nicht nachsehbar`); +process.exit(fehler === 0 ? 0 : 1); diff --git a/server/workspace-chat.js b/server/workspace-chat.js index 15e332f5..a7d252e5 100644 --- a/server/workspace-chat.js +++ b/server/workspace-chat.js @@ -235,6 +235,30 @@ function bildWegFuer(personId) { ===================================================================== */ export const CHAT_KACHELN = [ { wert: "grund", name: "Neutral", farbe: "#4f545c" }, + /* BABYBLAU -- die einzige helle Kachel (22.09.2026). + ------------------------------------------------------------------ + Filipe: „jeder normale blaue farbe, sei es die kachel im chat oder + emojis, nur babyblau bitte." Und im selben Auftrag: „Textfarbe + soll mitwechseln, am besten schwarz auf hellen Kacheln -- und + weiss, wenn jemand eine schwarze Kachel waehlt." + + Die zwei Saetze gehoeren zusammen, und in dieser Reihenfolge. + Babyblau ist HELL (Leuchtdichte 0,547); die helle Schrift der + Blase kaeme darauf auf 1,63:1 -- unlesbar. Erst weil die Schrift + jetzt mitwechselt (schriftFuer weiter unten), ist diese Kachel + ueberhaupt moeglich. + + Sie steht deshalb NICHT in der Rechnung von + tools/chat-kacheln-rechnen.mjs: Das Werkzeug rechnet elf bunte + Toene auf EINE gemeinsame Leuchtdichte, damit niemand sich durch + seine Wahl schlechter lesbar machen kann. Diese hier hat eine + andere Leuchtdichte und dafuer ihre eigene Schrift -- gemessen: + Text 10,78:1 (noetig 7), Fusszeile 4,87:1 (noetig 4,5). Dasselbe + Versprechen, auf anderem Weg eingeloest. + + Der Wert ist exakt `--akzent` des Hauses. Eine zweite Zahl daneben + waere zwei Babyblau, und beim naechsten Mal drei. */ + { wert: "babyblau", name: "Babyblau", farbe: "#8ec9ff" }, { wert: "tinte", name: "Tinte", farbe: "#0150af" }, { wert: "veilchen", name: "Veilchen", farbe: "#6a01e5" }, { wert: "pflaume", name: "Pflaume", farbe: "#9301a2" }, @@ -248,6 +272,71 @@ export const CHAT_KACHELN = [ { wert: "ozean", name: "Ozean", farbe: "#045b74" }, ]; +/* ===================================================================== + WELCHE SCHRIFT AUF WELCHER KACHEL (22.09.2026) + ===================================================================== + + Filipe: „Textfarbe soll mitwechseln, am besten schwarz auf hellen + Kacheln -- und weiss, wenn jemand eine schwarze Kachel waehlt." + + GERECHNET, NICHT JE KACHEL EINGETRAGEN. Eine dritte Spalte in + CHAT_KACHELN waere eine Liste, die jemand pflegen muss -- und beim + naechsten Ton vergisst. Hier steht stattdessen die Regel, und die + Farbe folgt aus der Flaeche. + + ZWEI PAARE, NICHT BELIEBIGE FARBEN: Ein frei gerechneter Grauwert je + Kachel ergaebe zwoelf verschiedene Schriften, und der Chat saehe aus + wie zwoelf verschiedene Programme. Es gibt ein helles Paar (das + bisherige, aus chat.css) und ein dunkles; genommen wird das, das die + noetigen Kontraste schafft. + + DIE SCHWELLEN SIND DIESELBEN WIE IM RECHENWERKZEUG: 7:1 (AAA) fuer + den Text -- der Chat ist die Seite, auf der am laengsten gelesen + wird -- und 4,5:1 fuer die Fusszeile. Sie stehen hier einmal und + werden von pruef-chatkachel.mjs aus DIESER Datei gelesen; zwei + Zahlen in zwei Dateien waeren der Widerspruch, den niemand bemerkt. + ===================================================================== */ +export const SCHRIFT_NOETIG = { text: 7, leise: 4.5 }; + +/** Das helle Paar -- dieselben Werte wie `--blase-text` / `--blase-leise` + * in chat.css. Sie stehen dort als Rueckfall fuer den Fall, dass gar + * keine Kachel gesetzt ist. */ +export const SCHRIFT_HELL = { text: "#f3f7fc", leise: "#c4d2e2" }; +/** Das dunkle Paar fuer helle Kacheln. Gemessen auf Babyblau: + * 10,78:1 und 4,87:1. */ +export const SCHRIFT_DUNKEL = { text: "#0b1119", leise: "#3b4e63" }; + +const alsLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); +function leuchtdichte(hex) { + const z = String(hex).replace("#", ""); + const [r, g, b] = [0, 2, 4] + .map((i) => parseInt(z.slice(i, i + 2), 16) / 255).map(alsLinear); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} +function kontrastZwischen(a, b) { + const x = leuchtdichte(a), y = leuchtdichte(b); + return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); +} + +/** Welches Schriftpaar traegt auf dieser Flaeche? + * + * GIBT IMMER EINES ZURUECK. Ein dritter Ausgang ("keines passt") + * waere hier falsch: Der Chat muss etwas anzeigen. Dass beide Paare + * reichen, ist die Aufgabe von pruef-chatkachel.mjs -- sie misst + * jeden Ton der Liste gegen genau diese Funktion. Faellt einer durch, + * ist das ein Befund und keine Notlage zur Laufzeit. */ +export function schriftFuer(farbe) { + const passt = (paar) => + kontrastZwischen(paar.text, farbe) >= SCHRIFT_NOETIG.text + && kontrastZwischen(paar.leise, farbe) >= SCHRIFT_NOETIG.leise; + if (passt(SCHRIFT_HELL)) return SCHRIFT_HELL; + if (passt(SCHRIFT_DUNKEL)) return SCHRIFT_DUNKEL; + /* Schafft keines beide Schwellen, entscheidet der TEXT -- er ist + das, was man liest. */ + return kontrastZwischen(SCHRIFT_HELL.text, farbe) + >= kontrastZwischen(SCHRIFT_DUNKEL.text, farbe) ? SCHRIFT_HELL : SCHRIFT_DUNKEL; +} + /** Nur ein Ton aus der Liste kommt durch -- sonst nichts. * * DIE PRUEFUNG STEHT HIER UND NICHT NUR IM FORMULAR. Was aus der @@ -727,7 +816,10 @@ chatRouter.get("/workspace/api/chat/partner", (req, res) => { chatRouter.get("/workspace/api/chat/kacheln", (req, res) => { try { res.json({ - kacheln: CHAT_KACHELN, + /* DIE SCHRIFT GEHT MIT. Der Browser soll sie nicht selbst + rechnen -- dann staende die Regel zweimal da, und beim + naechsten Ton waere eine der beiden Fassungen aelter. */ + kacheln: CHAT_KACHELN.map((k) => ({ ...k, schrift: schriftFuer(k.farbe) })), /* Die eigene WAHL, nicht der getragene Ton -- sonst stuende im Waehler ein Haken bei einer Farbe, die niemand gewaehlt hat. */ meine: gewaehlteKachelFuer(req.person.id), @@ -1270,6 +1362,12 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => { assets/js/chat.js bekommt jeder, der die Seite oeffnet. Ein Ja/Nein verraet nichts ueber die Rollen dahinter. */ darf_anheften: darfAnheften(req.person, raumId), + /* WER JEDE NACHRICHT LOESCHEN DARF -- vom Server, nicht aus + der Rolle im Browser geraten. assets/js/chat.js bekommt + JEDER, der die Seite oeffnet; ein `rolle === 'hand'` darin + waere eine Auskunft ueber das Haus an jeden Leser. Und es + waere eine zweite Wahrheit neben der Schranke im Server. */ + darf_alles_loeschen: darfJedeNachrichtLoeschen(req.person), angeheftet: angeheftet.map((a) => ({ id: a.id, von: a.von || "Gelöscht", @@ -2239,60 +2337,116 @@ chatRouter.put("/workspace/api/chat/nachrichten/:id/anheften", gleicheHerkunft, } }); -/** Eine eigene Nachricht zurücknehmen. */ +/* ===================================================================== + LÖSCHEN HEISST LÖSCHEN (22.09.2026) + ===================================================================== + + Filipe: „Gelöschte Nachrichten verschwinden vollständig – keine + Spur, kein ‚wurde gelöscht'-Hinweis, bei niemandem, auch nicht bei + DogFather." Und: „Löschen darf weiterhin jeder nur seine eigenen – + außer DogFather und rechte Hand: die dürfen jede Nachricht löschen, + für den Notfall." + + --------------------------------------------------------------------- + WAS SICH DAMIT UMDREHT + + Hier stand bis heute das Gegenteil, und zwar begründet: „Ein Loch im + Verlauf wirft mehr Fragen auf als der Hinweis, dass hier etwas + zurückgenommen wurde." Die Zeile blieb, der Text wurde geleert, und + der Chat zeigte „Nachricht zurückgenommen". + + Das Argument war nicht falsch -- es beantwortet nur eine andere + Frage als die, die Filipe stellt. Ein Hinweis „hier stand etwas" + ist für den, der gelöscht hat, oft schlimmer als der Satz selbst: + Er markiert die Stelle. Wer etwas aus Versehen schreibt, will es + weg haben und nicht unterstrichen. + + DESHALB JETZT EIN ECHTES DELETE. Das geht sauber, weil die beiden + abhängigen Tabellen es mittragen: + + chat_reaktionen ... REFERENCES chat_nachrichten(id) ON DELETE CASCADE + chat_erwaehnungen ... REFERENCES chat_nachrichten(id) ON DELETE CASCADE + + und `PRAGMA foreign_keys = ON` beim Öffnen steht. Nachgesehen, nicht + angenommen. + + DREI DINGE, DIE SONST EINE SPUR HINTERLIESSEN: + + 1. `antwort_auf` zeigt weiter auf die Nummer. Der Verlauf holt das + Zitat über einen LEFT JOIN -- es kommt nichts mehr zurück, und + die Anzeige lässt das Zitat dann weg. Ein Zitat auf etwas, das + es nicht gibt, wäre die Spur. + 2. `letzte_am` am Raum. War die gelöschte die letzte Nachricht, + stünde die Gesprächsliste weiter auf ihrem Zeitpunkt -- ein + Raum, der ganz oben steht und nichts Neues hat. Wird neu + gerechnet. + 3. Der Anhang auf der Platte. Das war schon vorher richtig gelöst + und bleibt so: erst die Zeile, dann die Datei. + + WAS BLEIBT: die Protokollzeile. Sie nennt WER WESSEN Nachricht + gelöscht hat -- keinen Text, keinen Inhalt. Beim Notfall-Löschen + durch DogFather oder die rechte Hand ist das der Unterschied + zwischen einer Befugnis und einer unsichtbaren Macht. Das Protokoll + ist kein Chatverlauf; es steht an einer anderen Stelle und + beantwortet eine andere Frage. + ===================================================================== */ + +/** Wer JEDE Nachricht löschen darf -- für den Notfall. + * + * NICHT `fuehrtTeamDogi`: Das schlösse die linke Hand ein, und Filipe + * hat ausdrücklich zwei genannt. Nicht `istLeitung`: Das schlösse + * Spicy Media ein -- die private Chats ausdrücklich nicht einmal + * sehen darf. Zwei Rollen, wörtlich die zwei aus dem Auftrag. */ +const darfJedeNachrichtLoeschen = (person) => + istDogFather(person) || person?.rolle === "hand"; + chatRouter.delete("/workspace/api/chat/nachrichten/:id", gleicheHerkunft, (req, res) => { try { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); - const n = db().prepare( + const d = db(); + const n = d.prepare( "SELECT id, raum_id, person_id, anhang_datei FROM chat_nachrichten WHERE id = ?").get(id); if (!n || !istDrin(n.raum_id, req.person)) { return res.status(404).json({ fehler: "nicht_gefunden" }); } - /* NUR EIGENE. DogFather ebenfalls nicht: Fremde Worte aus einem - Gespräch zu nehmen, in dem Absprachen stehen, wäre ein Eingriff - in die Geschichte -- und niemand könnte ihm nachher glauben. */ - if (n.person_id !== req.person.id) { + const fremd = n.person_id !== req.person.id; + if (fremd && !darfJedeNachrichtLoeschen(req.person)) { return res.status(403).json({ fehler: "Nur eigene Nachrichten." }); } - /* EIN ANHANG WIRD WIRKLICH GELÖSCHT, nicht nur ausgeblendet. - - Beim Text reicht das Leeren -- er steht nur in der Zeile. Eine - Datei liegt daneben auf der Platte und hätte ihre eigene Adresse - behalten. "Zurückgenommen" hiesse dann: im Verlauf weg, über den - Link weiter da. Das wäre keine Rücknahme, sondern der Anschein - einer. Erst die Zeile leeren, dann die Datei -- scheitert das - Entfernen (Datei schon weg), ist der Verweis trotzdem fort. */ const dateiWeg = n.anhang_datei; - /* UND SIE FAELLT VOM AUSHANG (10.09.2026). Ohne diese zwei - Spalten stuende die Nachricht weiter oben angeheftet -- mit dem - Text "Nachricht zurueckgenommen", an der auffaelligsten Stelle - des Raums. Die Anzeige filtert zwar auf `weg_am IS NULL`, aber - der Platz waere belegt: PIN_MAX zaehlt mit, und niemand kaeme - darauf, warum sich nichts mehr anheften laesst. */ - db().prepare(`UPDATE chat_nachrichten - SET text = '', weg_am = ?, - angeheftet_am = NULL, angeheftet_von = NULL, - anhang_datei = NULL, anhang_name = NULL, anhang_art = NULL, - anhang_typ = NULL, anhang_groesse = NULL, - anhang_breite = NULL, anhang_hoehe = NULL - WHERE id = ?`).run(jetzt(), id); + /* ERST DIE ZEILE, DANN DIE DATEI. Scheitert das Entfernen (Datei + schon weg), ist der Verweis trotzdem fort -- andersherum bliebe + im schlechten Fall eine Zeile ohne Datei stehen. */ + d.prepare("DELETE FROM chat_nachrichten WHERE id = ?").run(id); if (dateiWeg) { try { unlinkSync(join(ANHANG_ORDNER, dateiWeg)); } catch (f) { if (f?.code !== "ENOENT") console.error("[chat] Anhang bleibt liegen:", f?.message); } } - const empfaenger = teilnehmerVon(n.raum_id).map((t) => t.id); - chatEreignis(n.raum_id, { id, raum_id: n.raum_id, zurueckgenommen: true }, [], "weg"); + /* DER RAUM RECHNET SEINEN LETZTEN ZEITPUNKT NEU. Ohne das stuende + er in der Liste weiterhin ganz oben, mit einem Zeitpunkt, zu dem + es nichts mehr gibt. */ + d.prepare(`UPDATE chat_raeume + SET letzte_am = (SELECT MAX(erstellt) FROM chat_nachrichten WHERE raum_id = ?) + WHERE id = ?`).run(n.raum_id, n.raum_id); - protokolliere("chat_zurueckgenommen", { + const empfaenger = teilnehmerVon(n.raum_id).map((t) => t.id); + /* `geloescht: true` STATT `zurueckgenommen`. Das Wort entscheidet, + was der Browser tut: Das alte hiess "zeig einen Hinweis", das + neue heisst "nimm es aus dem Bild". Beides gleichzeitig zu + schicken waere die Fassung, bei der eine alte Seite den Hinweis + weiter anzeigt. */ + chatEreignis(n.raum_id, { id, raum_id: n.raum_id, geloescht: true }, [], "weg"); + + protokolliere("chat_geloescht", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), - detail: `#${id}`, + detail: fremd ? `#${id} (fremde Nachricht von #${n.person_id})` : `#${id}`, }); - res.json({ ok: true, empfaenger: empfaenger.length }); + res.json({ ok: true, geloescht: true, empfaenger: empfaenger.length }); } catch (fehler) { - console.error("[chat] Zurücknehmen:", fehler?.message); + console.error("[chat] Löschen:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); diff --git a/server/workspace-reaktionen.js b/server/workspace-reaktionen.js index 862c0b0f..9f673fa1 100644 --- a/server/workspace-reaktionen.js +++ b/server/workspace-reaktionen.js @@ -62,7 +62,23 @@ export const KATALOG = [ ["\u{1F9E1}", "herz orange"], ["\u{1F49B}", "herz gelb"], ["\u{1F49A}", "herz gruen"], - ["\u{1F499}", "herz blau"], + /* BABYBLAU STATT BLAU (22.09.2026). Filipe: "jedes normales + blaues herz durch babyblaues herz ersetzen ... jeder + normale blaue farbe, sei es die kachel im chat oder + emojis, nur babyblau bitte." + + U+1FA75 gibt es seit Emoji 15.0 (2022). Nicht geglaubt, + sondern gemessen: In Chromium auf diesem Rechner ist es + 43,9 px breit -- genauso breit wie die anderen Herzen und + deutlich breiter als ein Ersatzkaestchen (20,7 px). Waere + es nicht da, stuende im Chat ein leeres Viereck, und das + waere schlimmer als ein blaues Herz. + + Die vier vorhandenen blauen Herzen in der Datenbank + wandern mit -- die Umstellung steht in workspace.js. + Ohne sie waeren es vier Reaktionen, die ERLAUBT nicht + mehr kennt: still weg, ohne dass jemand etwas bemerkt. */ + ["\u{1FA75}", "herz babyblau hellblau blau"], ["\u{1F49C}", "herz lila"], ["\u{1F5A4}", "herz schwarz"], ["\u{1F60D}", "verliebt herzaugen toll"], diff --git a/server/workspace.js b/server/workspace.js index cfd85c9c..c40ed239 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -3459,6 +3459,99 @@ function umstellungen(d) { } } + /* ===================================================================== + DIE ALTEN ZURUECKGENOMMENEN NACHRICHTEN GEHEN GANZ (22.09.2026) + ===================================================================== + + Filipe: „Geloeschte Nachrichten verschwinden vollstaendig -- keine + Spur, kein ‚wurde geloescht'-Hinweis, bei niemandem, auch nicht + bei DogFather." + + Seit heute loescht der Chat wirklich (DELETE). Was davor + zurueckgenommen wurde, steht aber noch als Zeile da: Text leer, + `weg_am` gesetzt. Die Anzeige zeigt sie nicht mehr -- in der + Datenbank waere sie trotzdem. „Keine Spur" gilt dann fuer alles, + was ab heute passiert, und nicht fuer das, was ihn zu dem Satz + gebracht hat. + + ES GEHT NUR UM ZEILEN OHNE INHALT. `text = ''` ist die Bedingung, + nicht bloss `weg_am IS NOT NULL`: Der Text wurde beim Zuruecknehmen + geleert, also ist hier nichts zu verlieren. Stuende irgendwo doch + noch Text in einer zurueckgenommenen Zeile, bliebe sie liegen -- + lieber ein Rest, den jemand ansehen kann, als ein stiller Verlust. + + WIEDERHOLBAR: Beim zweiten Start gibt es nichts mehr zu tun, und + es passiert nichts. Deshalb steht es hier bei den Umstellungen + und nicht in einem einmaligen Skript, das jemand vergessen + koennte. + + GESICHERT WIRD VORHER -- aber nur, wenn es wirklich etwas zu tun + gibt. Eine Sicherung bei jedem Start waere eine Datei je Neustart + und damit kein Schutz, sondern Muell. */ + try { + const spalten = d.prepare("PRAGMA table_info(chat_nachrichten)").all().map((x) => x.name); + if (spalten.includes("weg_am")) { + const offen = d.prepare( + "SELECT COUNT(*) AS n FROM chat_nachrichten WHERE weg_am IS NOT NULL AND text = ''").get().n; + if (offen > 0) { + const stempel = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14); + const sicherung = `${DB_PFAD}.vor-loeschspuren-${stempel}`; + d.exec(`VACUUM INTO '${sicherung.replace(/'/g, "''")}'`); + console.log("[workspace] Sicherung vor dem Aufraeumen:", sicherung); + /* Reaktionen und Erwaehnungen haengen mit ON DELETE CASCADE + daran und gehen von selbst mit -- `PRAGMA foreign_keys = ON` + steht beim Oeffnen. Nachgesehen, nicht angenommen. */ + const weg = d.prepare( + "DELETE FROM chat_nachrichten WHERE weg_am IS NOT NULL AND text = ''").changes; + /* Und die Raeume rechnen ihren letzten Zeitpunkt neu -- sonst + stuende einer oben in der Liste, zu dessen Zeitpunkt es + nichts mehr gibt. */ + d.exec(`UPDATE chat_raeume SET letzte_am = + (SELECT MAX(erstellt) FROM chat_nachrichten WHERE raum_id = chat_raeume.id)`); + console.log(`[workspace] ${weg} zurueckgenommene Nachrichten endgueltig entfernt.`); + } + } + } catch (fehler) { + console.error("[workspace] Aufraeumen der Loeschspuren:", fehler?.message); + } + + /* ===================================================================== + DIE BLAUEN HERZEN WERDEN BABYBLAU (22.09.2026) + ===================================================================== + + Filipe: "jedes normales blaues herz durch babyblaues herz + ersetzen ... jeder normale blaue farbe, sei es die kachel im chat + oder emojis, nur babyblau bitte." + + Der Katalog (workspace-reaktionen.js) kennt seit heute nur noch + das babyblaue. WAS SCHON IN DER DATENBANK STEHT, WANDERT MIT -- + ohne diese Zeilen waeren es vier Reaktionen, die `ERLAUBT` nicht + mehr kennt. Der Verlauf filtert danach: Sie verschwaenden still, + und niemand koennte sagen, wann und warum. + + `UPDATE OR IGNORE`, DANN `DELETE`: Der Primaerschluessel ist + (nachricht_id, person_id, zeichen). Hat jemand auf dieselbe + Nachricht BEIDES gesetzt, liefe ein blankes UPDATE auf den + Schluessel auf. So gewinnt das vorhandene babyblaue, und das + blaue faellt weg -- zweimal dasselbe Herz an einer Nachricht + waere ohnehin keine zwei Reaktionen. + + WIEDERHOLBAR: Beim zweiten Start gibt es nichts mehr zu tun. */ + try { + const blau = "\u{1F499}", babyblau = "\u{1FA75}"; + const offen = d.prepare( + "SELECT COUNT(*) AS n FROM chat_reaktionen WHERE zeichen = ?").get(blau).n; + if (offen > 0) { + d.prepare("UPDATE OR IGNORE chat_reaktionen SET zeichen = ? WHERE zeichen = ?") + .run(babyblau, blau); + const rest = d.prepare("DELETE FROM chat_reaktionen WHERE zeichen = ?").run(blau).changes; + console.log(`[workspace] ${offen} blaue Herzen auf babyblau umgestellt` + + (rest ? ` (${rest} doppelte entfernt)` : "")); + } + } catch (fehler) { + console.error("[workspace] Herzen umstellen:", fehler?.message); + } + /* Content-Saeulen: die drei bis fuenf Themen, aus denen der Kanal besteht. Aus der Recherche: 3-5 Saeulen nach der 70/20/10-Regel (70 % Wert, 20 % Community, 10 % Eigenwerbung); eine Saeule wird diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index c0b2823e..9d9b9876 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,15 +16,15 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +