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`. -->
-
-
+
+
-
+