From 60802170a1876d3246e4c1ce15da93e7db385dac Mon Sep 17 00:00:00 2001 From: Dogfather Date: Tue, 22 Sep 2026 23:33:43 +0200 Subject: [PATCH] B4 + B5: Loeschen heisst Loeschen, die Schrift wechselt mit, und Blau wird Babyblau Die beiden Auftraege gehoeren zusammen, und zwar in dieser Reihenfolge: Eine babyblaue Kachel ist HELL. Ohne mitwechselnde Schrift waere sie unlesbar (1,63:1 gemessen). Erst B4 macht B5 moeglich. B4a -- LOESCHEN HEISST LOESCHEN "Geloeschte Nachrichten verschwinden vollstaendig - keine Spur, kein 'wurde geloescht'-Hinweis, bei niemandem, auch nicht bei DogFather." Bis heute blieb die Zeile stehen: Text geleert, weg_am gesetzt, und im Chat stand "Nachricht zurueckgenommen". Das war ausdruecklich so begruendet ("ein Loch im Verlauf wirft mehr Fragen auf"). Das Argument beantwortet aber eine andere Frage: Ein Hinweis "hier stand etwas" MARKIERT die Stelle. Wer etwas aus Versehen schreibt, will es weg haben und nicht unterstrichen. Jetzt ein echtes DELETE. Vier Raender, an denen eine Spur bleiben koennte, alle gemessen: 1. die Zeile selbst 2. chat_reaktionen (ON DELETE CASCADE - nachgesehen, nicht angenommen) 3. chat_erwaehnungen (ebenso) 4. letzte_am am Raum - wird neu gerechnet, sonst stuende er oben in der Liste mit einem Zeitpunkt, zu dem es nichts mehr gibt Dazu: ein Zitat auf eine geloeschte Nachricht wird weggelassen; in der Gespraechsliste steht kein Hinweis mehr; der Knopf heisst "loeschen". Die 5 alten zurueckgenommenen Zeilen (Text bereits leer) raeumt eine einmalige, wiederholbare Umstellung ab - mit Sicherung davor, und nur wenn es wirklich etwas zu tun gibt. B4b -- WER DARF WAS "jeder nur seine eigenen - ausser DogFather und rechte Hand" `darfJedeNachrichtLoeschen` = admin oder hand. NICHT fuehrtTeamDogi (das schloesse die linke Hand ein) und nicht istLeitung (das schloesse Spicy Media ein, die private Chats nicht einmal sehen darf). Das Recht kommt vom Server ins Skript, nicht aus einer Rolle im Browser: chat.js bekommt jeder, der die Seite oeffnet. B4c -- DIE SCHRIFT WECHSELT MIT "am besten schwarz auf hellen Kacheln - und weiss, wenn jemand eine schwarze Kachel waehlt" `schriftFuer(farbe)` waehlt zwischen zwei Paaren, gerechnet aus der Leuchtdichte, mit denselben Schwellen wie das Rechenwerkzeug (7:1 fuer den Text, 4,5:1 fuer die Fusszeile). Keine dritte Spalte in CHAT_KACHELN, die jemand pflegen muesste. Flaeche und Schrift werden im Browser in EINEM Griff gesetzt (blaseFaerben) - zwei Stellen waeren irgendwann eine helle Kachel mit heller Schrift. B4d -- DIE NAMEN Der Name ist jetzt ein eigener Streifen mit Kante darunter, .84rem, und der Rollenpunkt wird ein 3x14-Balken. Vorher stand er als erste ZEILE in der Blase und las sich wie der Anfang des Satzes. B5 -- BABYBLAU "jedes normales blaues herz durch babyblaues herz ersetzen ... jeder normale blaue farbe, sei es die kachel im chat oder emojis, nur babyblau bitte." * Herz: U+1F499 -> U+1FA75. Nicht geglaubt, sondern gemessen: 43,9 px breit wie die anderen Herzen, ein Ersatzkaestchen waere 20,7. Die vier vorhandenen blauen Herzen in der Datenbank wandern mit - sonst waeren es vier Reaktionen, die ERLAUBT nicht mehr kennt: still weg. * Kachel: neue, HELLE Kachel "Babyblau" mit dem Wert von --akzent. Gemessen: Text 10,78:1, Fusszeile 4,87:1. Sie steht bewusst nicht in der Rechnung des Werkzeugs (das rechnet elf Toene auf EINE Leuchtdichte) - sie hat eine andere Leuchtdichte und dafuer ihre eigene Schrift. Dasselbe Versprechen, anderer Weg. PRUEFUNGEN pruef-loeschen.mjs (NEU, 20/0): alle vier Raender, die vier Faelle der Rechtegrenze (auch: die linke Hand darf NICHT), das babyblaue Herz am laufenden Server, und dass der Satz "Nachricht zurueckgenommen" nur noch in Kommentaren steht - mit Gegenprobe, dass die Suche diesen Unterschied wirklich macht. pruef-chatkachel.mjs (33/0): misst jede Kachel mit IHRER Schrift und fragt dafuer dieselbe Funktion wie der Server. Neu: dass die Schrift ueberhaupt wechselt. Die Leuchtdichte-Regel gilt jetzt fuer die gerechneten Toene - das Versprechen dahinter loest die Kontrastzeile darueber direkt ein. Vier alte Pruefungen umgedreht, die den alten Zustand festgeschrieben hatten: pruef-chat, pruef-chat-ausbau, pruef-chat-aufloesen, pruef-chat-optik. Alle mit scharferer Messung als vorher (Zahl davor/danach statt "ein Hinweis ist da"). Mitgelaufen und gruen: pruef-alle-sehen-es, pruef-treffchat (110/0). Gesichert: workspace-vor-loeschumbau-20260922-233313.db (946 KB, integrity_check ok, 111 Nachrichten, 49 Reaktionen). Co-Authored-By: Claude Opus 5 --- server/pruef-chat-aufloesen.mjs | 17 +- server/pruef-chat-ausbau.mjs | 23 ++- server/pruef-chat-optik.mjs | 30 +++- server/pruef-chat.mjs | 21 ++- server/pruef-chatkachel.mjs | 82 +++++++-- server/pruef-loeschen.mjs | 296 ++++++++++++++++++++++++++++++++ server/workspace-chat.js | 222 ++++++++++++++++++++---- server/workspace-reaktionen.js | 18 +- server/workspace.js | 93 ++++++++++ workspace/anruf-probe.html | 10 +- workspace/app.webmanifest | 8 +- workspace/assets/css/chat.css | 32 +++- workspace/assets/js/chat.js | 150 +++++++++++++--- workspace/aufgaben.html | 36 ++-- workspace/automation.html | 32 ++-- workspace/befinden.html | 32 ++-- workspace/bereich.html | 44 ++--- workspace/bewerben.html | 36 ++-- workspace/bewerbungen.html | 34 ++-- workspace/calls.html | 34 ++-- workspace/chat.html | 38 ++-- workspace/content.html | 34 ++-- workspace/crew-index.html | 12 +- workspace/crew.webmanifest | 8 +- workspace/dateien.html | 34 ++-- workspace/entwicklung.html | 32 ++-- workspace/hilfe.html | 28 +-- workspace/index.html | 10 +- workspace/kalender.html | 34 ++-- workspace/leistung.html | 32 ++-- workspace/material.html | 32 ++-- workspace/personen.html | 32 ++-- workspace/profil.html | 36 ++-- workspace/rechte.html | 32 ++-- workspace/report.html | 36 ++-- workspace/scouting.html | 32 ++-- workspace/start.html | 32 ++-- workspace/startcheck.html | 34 ++-- workspace/steckbrief.html | 36 ++-- workspace/talente.html | 34 ++-- workspace/team.html | 32 ++-- workspace/teamlage.html | 32 ++-- workspace/teilen.html | 28 +-- workspace/treff-moderation.html | 30 ++-- workspace/treff-regeln.html | 30 ++-- workspace/uebersicht.html | 32 ++-- workspace/unsere-seiten.html | 28 +-- workspace/werdegang.html | 30 ++-- workspace/willkommen.html | 30 ++-- workspace/wissen.html | 34 ++-- 50 files changed, 1464 insertions(+), 690 deletions(-) create mode 100644 server/pruef-loeschen.mjs 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`. --> - - + + - +