diff --git a/server/chat-erwaehnung.js b/server/chat-erwaehnung.js
new file mode 100644
index 00000000..c3929a1a
--- /dev/null
+++ b/server/chat-erwaehnung.js
@@ -0,0 +1,152 @@
+/* =====================================================================
+ WER IST MIT "@" GEMEINT? (20.09.2026)
+
+ Filipe: "ich will das man die leute mit @ markieren kann im chat."
+
+ ---------------------------------------------------------------------
+ WARUM DAS EINE EIGENE DATEI IST
+
+ Die Auflösung "Text -> Menschen" wird an DREI Stellen gebraucht und
+ muss überall dasselbe ergeben:
+
+ 1. beim Schreiben -- wer bekommt eine Benachrichtigung,
+ 2. im Browser -- welcher Teil des Satzes wird hervorgehoben,
+ 3. in der Prüfung -- ohne dafür einen Server hochzufahren.
+
+ Stünde sie in workspace-chat.js, müsste die Prüfung die halbe
+ Anwendung laden, um einen Satz zu zerlegen. Und der Browser hätte
+ ohnehin eine zweite, eigene Fassung -- also genau die Stelle, an der
+ zwei Rechnungen irgendwann auseinanderlaufen und die Seite jemanden
+ farbig hervorhebt, den der Server nie benachrichtigt hat.
+
+ DIE DATEI HAT DESHALB KEINE ABHÄNGIGKEITEN. Kein Datenbankzugriff,
+ kein Import. Herein kommt ein Text und eine Liste von Menschen,
+ heraus kommt, wer gemeint ist. Der Browser bekommt dieselbe Logik in
+ `chat.js` -- bewusst als kurze, gleichlautende Fassung, die
+ pruef-erwaehnung.mjs Zeile für Zeile gegen diese hier prüft.
+
+ ---------------------------------------------------------------------
+ DIE REGELN, UND WARUM JEDE EINZELNE NÖTIG IST
+
+ (1) NUR WER IM RAUM IST.
+ "@Marina" in einem Gespräch, in dem Marina nicht sitzt, ist kein
+ Ansprechen, sondern ein Wort über sie. Sie bekäme sonst eine
+ Meldung zu einem Gespräch, das sie nicht öffnen darf -- eine
+ Benachrichtigung, die auf eine verschlossene Tür führt.
+
+ (2) DAS ZEICHEN DAVOR DARF KEIN BUCHSTABE UND KEINE ZIFFER SEIN.
+ Ohne diese Regel wird aus "filipe@dogfather-universe.com" ein
+ Ansprechen von Dogfather -- jede E-Mail-Adresse im Chat würde
+ jemanden anpiepsen. Gemessen an einer echten Adresse, siehe
+ Prüfung.
+
+ (3) DAS ZEICHEN DANACH DARF KEIN BUCHSTABE UND KEINE ZIFFER SEIN.
+ Sonst wäre "@Tili" in "@Tilikum" enthalten. Satzzeichen dagegen
+ schon: "@Tili, kannst du?" und "@Tili!" sind normale Sätze.
+
+ (4) DER LÄNGSTE PASSENDE NAME GEWINNT.
+ Gäbe es "Van" und "VanVan", träfe die kurze Fassung zuerst und
+ ließe "Van" stehen. Heute hat niemand einen Namen, der im
+ Namen eines anderen steckt -- aber das ist eine Tatsache von
+ heute, kein Gesetz, und sie ändert sich mit dem nächsten Zugang.
+
+ (5) GROSS UND KLEIN EGAL.
+ Wer "@vanvan" tippt, meint VanVan. Auf einer Handytastatur
+ entscheidet die Autokorrektur mit, nicht der Mensch.
+
+ (6) JEDE PERSON HÖCHSTENS EINMAL.
+ Dreimal "@Anna" in einem Satz ist trotzdem eine Ansprache.
+
+ ---------------------------------------------------------------------
+ WAS ES BEWUSST NICHT GIBT
+
+ KEIN "@alle". Es wäre in fünf Minuten gebaut und ist der zuverlässigste
+ Weg, dass alle die Benachrichtigungen abschalten -- und dann kommt
+ auch die an, die wirklich für einen bestimmten Menschen gedacht war.
+ Wenn Filipe es ausdrücklich will, gehört dazu eine Entscheidung, WER
+ es benutzen darf; das ist eine Frage an ihn, keine, die ich hier
+ beantworte.
+
+ AUFRUF (auch aus einer Prüfung heraus):
+ import { erwaehnungenFinden } from "./chat-erwaehnung.js";
+ ===================================================================== */
+
+/** Ein Zeichen, das einen Namen fortsetzen könnte.
+ *
+ * BUCHSTABE ODER ZIFFER, und zwar auch mit Umlaut oder Akzent:
+ * `\p{L}` statt `[a-z]`. Mit `[a-zA-Z0-9]` wäre "@Jose" in "@José"
+ * ein Treffer gewesen -- das "é" hätte als Grenze gezählt. */
+const WORTZEICHEN = /[\p{L}\p{N}_]/u;
+
+/** Steht an dieser Stelle ein Wortzeichen?
+ * Außerhalb des Textes (Anfang/Ende) ist die Antwort NEIN -- ein
+ * Satzanfang ist eine Grenze wie jede andere. */
+function wortzeichenBei(text, i) {
+ if (i < 0 || i >= text.length) return false;
+ return WORTZEICHEN.test(text[i]);
+}
+
+/**
+ * Wer ist in diesem Text mit "@" angesprochen?
+ *
+ * @param {string} text Die geschriebene Nachricht.
+ * @param {Array<{id:number,name:string}>} leute
+ * Die Teilnehmer DIESES Raums. Wer nicht darin steht, kann
+ * nicht gemeint sein -- siehe Regel (1) oben.
+ * @returns {Array<{id:number, name:string, von:number, bis:number}>}
+ * Je Treffer die Person und die Stelle im Text (`von` bis
+ * `bis`, ohne das "@"-Zeichen gerechnet wie `slice`). Die
+ * Stellen braucht der Browser zum Hervorheben; der Server
+ * benutzt nur `id`. Sortiert nach Vorkommen im Text.
+ */
+export function erwaehnungenFinden(text, leute) {
+ const satz = String(text || "");
+ if (!satz.includes("@")) return [];
+
+ /* REGEL (4): der längste Name zuerst. Einmal sortiert statt bei
+ jedem Treffer neu -- und auf einer Kopie, damit die Liste des
+ Aufrufers unberührt bleibt. */
+ const nachLaenge = (Array.isArray(leute) ? leute.slice() : [])
+ .filter((p) => p && p.id != null && String(p.name || "").trim())
+ .sort((a, b) => String(b.name).length - String(a.name).length);
+ if (!nachLaenge.length) return [];
+
+ const klein = satz.toLowerCase();
+ const treffer = [];
+ const schonDa = new Set(); // Regel (6)
+
+ for (let i = klein.indexOf("@"); i !== -1; i = klein.indexOf("@", i + 1)) {
+ /* REGEL (2): Was steht vor dem "@"? */
+ if (wortzeichenBei(satz, i - 1)) continue;
+
+ for (const person of nachLaenge) {
+ const name = String(person.name);
+ const nk = name.toLowerCase();
+ if (!klein.startsWith(nk, i + 1)) continue;
+ /* REGEL (3): Was steht hinter dem Namen? */
+ if (wortzeichenBei(satz, i + 1 + name.length)) continue;
+
+ /* Die Stelle wird IMMER gemeldet, auch wenn dieselbe Person
+ schon einmal vorkam: Der Browser soll jedes "@Anna" im Satz
+ hervorheben, nicht nur das erste. Nur die ID-Liste für die
+ Benachrichtigung wird entdoppelt -- das macht `ideeVon()`
+ weiter unten. */
+ treffer.push({ id: person.id, name, von: i, bis: i + 1 + name.length });
+ schonDa.add(person.id);
+ break; // Regel (4): der Längste hat gewonnen
+ }
+ }
+ return treffer;
+}
+
+/**
+ * Nur die Personennummern, jede einmal -- das, was der Server zum
+ * Benachrichtigen braucht.
+ *
+ * ALS EIGENE FUNKTION, damit an der Aufrufstelle nicht steht
+ * `[...new Set(x.map(t => t.id))]`. Diese Zeile wäre an drei Orten
+ * gleich zu schreiben, und an einem davon irgendwann anders.
+ */
+export function erwaehnteIds(text, leute) {
+ return [...new Set(erwaehnungenFinden(text, leute).map((t) => t.id))];
+}
diff --git a/server/pruef-erwaehnung.mjs b/server/pruef-erwaehnung.mjs
new file mode 100644
index 00000000..e71109d0
--- /dev/null
+++ b/server/pruef-erwaehnung.mjs
@@ -0,0 +1,612 @@
+/* =====================================================================
+ pruef-erwaehnung.mjs — "@Name" im Chat
+
+ Filipe, 20.09.2026: "ich will das man die leute mit @ markieren
+ kann im chat."
+
+ ---------------------------------------------------------------------
+ WAS HIER GEPRÜFT WIRD, UND WARUM GERADE DAS
+
+ Markieren ist keine Farbe, sondern eine Ansprache. Es funktioniert
+ nur, wenn DREI Dinge zusammen stimmen:
+
+ 1. Der Richtige ist gemeint. -> Abschnitt 1 und 3
+ 2. Er merkt es. -> Abschnitt 2 und 4
+ 3. Man sieht es im Satz. -> Abschnitt 5 (echter Browser)
+
+ Stimmt eines davon nicht, ist die ganze Funktion eine Attrappe --
+ und zwar eine, die gut aussieht. Deshalb hört diese Prüfung nicht
+ beim "wird gespeichert" auf.
+
+ ---------------------------------------------------------------------
+ DIE GEFÄHRLICHSTE STELLE
+
+ Es gibt ZWEI Auflösungen desselben Problems: eine im Server
+ (chat-erwaehnung.js) und eine im Browser (chat.js). Die zweite ist
+ nötig, weil der Browser sofort hervorheben muss, ohne zu fragen.
+
+ Zwei Rechnungen für dieselbe Frage sind genau die Stelle, an der
+ etwas auseinanderläuft -- dann hebt die Seite jemanden hervor, den
+ der Server nie benachrichtigt hat. Abschnitt 6 stellt beide
+ nebeneinander und prüft sie an denselben Fällen.
+
+ AUFRUF: node server/pruef-erwaehnung.mjs
+ ===================================================================== */
+
+import { mkdtempSync, rmSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+import { request as httpAnfrage } from "node:http";
+import { portMussFreiSein } from "./helfer-port.mjs";
+import { notbremse } from "./helfer-notbremse.mjs";
+import { erwaehnungenFinden, erwaehnteIds } from "./chat-erwaehnung.js";
+
+const HIER = dirname(fileURLToPath(import.meta.url));
+const PORT = await portMussFreiSein(4426, "pruef-erwaehnung");
+
+const ordner = mkdtempSync(join(tmpdir(), "ws-erwaehnung-"));
+process.env.WORKSPACE_DB = join(ordner, "workspace.db");
+process.env.PORT = String(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(300_000, "pruef-erwaehnung");
+await new Promise((r) => setTimeout(r, 900));
+
+process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
+process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); });
+
+let fehler = 0, geprueft = 0;
+const melde = (t) => console.log(t);
+const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+
+/* KEINE HOST-UMSCHREIBUNG, UND DAS HAT EINEN GRUND.
+
+ Der erste Anlauf meldete sich ueber HTTP mit `Host: crew.…` an und
+ liess den Browser auf 127.0.0.1 laufen. Beides zusammen geht nicht:
+ Auf 127.0.0.1 liefert der Server die AGENTUR-Anmeldewand, und dort
+ gibt es die Rolle "hand" gar nicht -- die Pruefung wartete 30
+ Sekunden auf eine Kachel, die es nie geben konnte.
+
+ Das Markieren mit "@" ist von Haus und Rolle voellig unabhaengig:
+ Es fragt nur, wer im RAUM sitzt. Also wird hier durchgehend die
+ Adresse benutzt, auf der auch der Browser landet, und es werden
+ Rollen genommen, die dort vorkommen. Wer die Trennung der beiden
+ Haeuser pruefen will, findet das in pruef-haus-trennung. */
+
+console.log('\n=== @Name im Chat ===');
+
+/* =====================================================================
+ 1. DIE REGELN — ohne Server, ohne Datenbank
+ ===================================================================== */
+melde("\n=== 1. Wer ist gemeint? ===");
+{
+ const leute = [
+ { id: 1, name: "Dogfather" }, { id: 2, name: "VanVan" }, { id: 3, name: "Tili" },
+ { id: 4, name: "Miss" }, { id: 5, name: "Marina" },
+ ];
+ /* Jeder Fall steht für eine Regel aus chat-erwaehnung.js. Die
+ beiden wichtigsten sind die E-Mail-Adresse und das längere Wort:
+ Ohne sie piepst jede Adresse im Chat jemanden an, und "@Tilikum"
+ spricht Tili an. */
+ const faelle = [
+ ["@VanVan kannst du kurz?", [2], "der einfache Fall"],
+ ["@vanvan schaust du mal", [2], "klein geschrieben trifft trotzdem"],
+ ["Hey @Tili, und @Miss auch!", [3, 4], "zwei Leute, mit Satzzeichen dahinter"],
+ ["@Tili @Tili @Tili", [3], "dreimal derselbe zählt einmal"],
+ ["schreib an filipe@dogfather.com", [], "eine E-Mail-Adresse spricht niemanden an"],
+ ["@Tilikum ist ein Wal", [], "ein längeres Wort ist kein Name"],
+ ["@Missgeschick passiert", [], "ein Name im Wortinneren zählt nicht"],
+ ["@Ghost ist nicht im Raum", [], "wer nicht im Raum sitzt, ist nicht gemeint"],
+ ["@Marina", [5], "ganz am Ende des Satzes"],
+ ["(@Marina)", [5], "in Klammern"],
+ ["@VanVan-Team", [2], "ein Bindestrich beendet den Namen"],
+ ["x@VanVan", [], "ein Buchstabe davor macht es zur Adresse"],
+ ["Ohne at-Zeichen hier", [], "kein @ — keine Ansprache"],
+ ];
+ let stimmt = 0;
+ for (const [text, soll, was] of faelle) {
+ const ist = erwaehnteIds(text, leute);
+ const passt = JSON.stringify(ist) === JSON.stringify(soll);
+ if (passt) stimmt++;
+ ok(passt, `${was}${passt ? "" : ` (${JSON.stringify(text)} -> ${JSON.stringify(ist)}, erwartet ${JSON.stringify(soll)})`}`);
+ }
+ /* DIE ZAHL GEHÖRT IN DIE BEDINGUNG: Liefe die Schleife über null
+ Fälle, wäre oben nichts rot -- und nichts geprüft. */
+ ok(stimmt === faelle.length && faelle.length >= 13,
+ `alle ${faelle.length} Regelfälle stimmen`);
+
+ /* DER LÄNGSTE NAME GEWINNT. Heute steckt kein Name im anderen --
+ das ist eine Tatsache von heute und kein Gesetz. Geprüft mit
+ erfundenen Namen, damit die Regel unabhängig vom Bestand hält. */
+ const ineinander = [{ id: 10, name: "Van" }, { id: 11, name: "VanVan" }];
+ ok(JSON.stringify(erwaehnteIds("@VanVan hallo", ineinander)) === "[11]",
+ "steckt ein Name im anderen, gewinnt der längere");
+ ok(JSON.stringify(erwaehnteIds("@Van hallo", ineinander)) === "[10]",
+ " und der kürzere trifft trotzdem, wenn er allein steht");
+
+ /* Die Stellen fürs Hervorheben. Jedes Vorkommen wird gemeldet, nicht
+ nur das erste -- sonst bliebe das zweite "@Anna" im Satz grau. */
+ const roh = "Hey @Tili und nochmal @Tili";
+ const st = erwaehnungenFinden(roh, leute);
+ ok(st.length === 2 && st.every((t) => roh.slice(t.von, t.bis) === "@Tili"),
+ `jedes Vorkommen bekommt eine Stelle (${st.length} Stück)`);
+
+ /* GEGENPROBE: Sie muss auch NEIN sagen können. Eine Auflösung, die
+ immer jemanden findet, findet nichts. */
+ ok(erwaehnteIds("@VanVan", []).length === 0,
+ "Gegenprobe: ohne Teilnehmerliste ist niemand gemeint");
+}
+
+/* =====================================================================
+ 2. DURCH DIE SCHNITTSTELLE
+ ===================================================================== */
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes, createHmac } = await import("node:crypto");
+
+const jetzt = new Date().toISOString();
+let idDogi, idVan, idFunny;
+{
+ const d = new DatabaseSync(process.env.WORKSPACE_DB);
+ 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);
+ const 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;
+ };
+ idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001");
+ idVan = anlegen("VanVan", "scout", "CODE-SCOU-0001");
+ idFunny = anlegen("Funny", "scout", "CODE-SCOU-0002");
+ d.close();
+}
+
+function ruf(pfad, keks, methode = "GET", koerper = null) {
+ return new Promise((fertig, schief) => {
+ const a = httpAnfrage({
+ host: "127.0.0.1", port: PORT, path: pfad, method: methode,
+ headers: {
+ ...(keks ? { Cookie: keks } : {}),
+ ...(koerper ? { "Content-Type": "application/json",
+ Origin: `http://127.0.0.1:${PORT}` } : {}),
+ },
+ }, (antwort) => {
+ let text = "";
+ antwort.on("data", (s) => { text += s; });
+ antwort.on("end", () => fertig({ code: antwort.statusCode, text }));
+ });
+ a.on("error", schief);
+ if (koerper) a.write(JSON.stringify(koerper));
+ a.end();
+ });
+}
+const json = (a) => { try { return JSON.parse(a.text); } catch { return {}; } };
+
+async function anmelden(rolle, code) {
+ const a = await ruf("/workspace/api/anmelden", null, "POST", { rolle, code });
+ if (a.code !== 200) console.log(` (Anmeldung ${rolle}: ${a.code} ${a.text.slice(0, 80)})`);
+ return "";
+}
+/* Die Kekse holen wir direkt aus der Antwort -- `ruf` gibt sie nicht
+ zurück, also ein eigener Weg fürs Anmelden. */
+function anmeldenMitKeks(rolle, code) {
+ return new Promise((fertig, schief) => {
+ const koerper = JSON.stringify({ rolle, code });
+ const a = httpAnfrage({
+ host: "127.0.0.1", port: PORT, path: "/workspace/api/anmelden", method: "POST",
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(koerper) },
+ }, (antwort) => {
+ let text = "";
+ antwort.on("data", (s) => { text += s; });
+ antwort.on("end", () => fertig({
+ code: antwort.statusCode, text,
+ keks: [].concat(antwort.headers["set-cookie"] || []).map((c) => c.split(";")[0]).join("; "),
+ }));
+ });
+ a.on("error", schief);
+ a.write(koerper);
+ a.end();
+ });
+}
+void anmelden;
+
+melde('\n=== 2. Ein @ geht durch die Schnittstelle ===');
+const aDogi = await anmeldenMitKeks("admin", "CODE-DOGI-0001");
+const aVan = await anmeldenMitKeks("scout", "CODE-SCOU-0001");
+const aFunny = await anmeldenMitKeks("scout", "CODE-SCOU-0002");
+ok(!!aDogi.keks && !!aVan.keks && !!aFunny.keks, "drei Zugänge sind angemeldet");
+
+/* Eine Gruppe mit allen dreien. */
+const gruppe = await ruf("/workspace/api/chat/gruppe", aDogi.keks, "POST",
+ { name: "Testgruppe", mit: [idVan, idFunny] });
+const raumId = json(gruppe).raum_id;
+ok(Number.isInteger(raumId), `eine Gruppe mit drei Leuten steht (Raum ${raumId})`);
+
+/* Die Teilnehmer kommen MIT der Raumliste -- die Auswahlliste im
+ Browser braucht deshalb keine eigene Abfrage. Genau das wird hier
+ festgehalten, damit niemand später eine zweite baut. */
+{
+ const l = json(await ruf("/workspace/api/chat/raeume", aDogi.keks));
+ const r = (l.raeume || []).find((x) => x.id === raumId);
+ ok(Array.isArray(r?.teilnehmer) && r.teilnehmer.length === 3,
+ `die Raumliste bringt die Teilnehmer schon mit (${r?.teilnehmer?.length})`);
+ ok(r?.teilnehmer?.every((t) => t.id && t.name),
+ " mit Nummer und Namen — mehr braucht die Auswahl nicht");
+}
+
+/* Jetzt die eigentliche Nachricht. */
+const gesendet = await ruf(`/workspace/api/chat/raeume/${raumId}/nachrichten`,
+ aDogi.keks, "POST", { text: "Kannst du das übernehmen, @VanVan? Danke!" });
+ok(gesendet.code === 201, `die Nachricht geht raus (${gesendet.code})`);
+const nachrichtId = json(gesendet).nachricht?.id;
+
+{
+ const d = new DatabaseSync(process.env.WORKSPACE_DB);
+ const zeilen = d.prepare("SELECT person_id FROM chat_erwaehnungen WHERE nachricht_id = ?")
+ .all(nachrichtId);
+ d.close();
+ ok(zeilen.length === 1 && zeilen[0].person_id === idVan,
+ `genau VanVan ist vermerkt (${zeilen.length} Eintrag/Einträge)`);
+}
+
+/* SELBST ERWÄHNEN LÖST NICHTS AUS. */
+{
+ const s = await ruf(`/workspace/api/chat/raeume/${raumId}/nachrichten`,
+ aDogi.keks, "POST", { text: "@Filipe hat recht, sagt @Filipe" });
+ const d = new DatabaseSync(process.env.WORKSPACE_DB);
+ const n = d.prepare("SELECT COUNT(*) AS n FROM chat_erwaehnungen WHERE nachricht_id = ?")
+ .get(json(s).nachricht?.id).n;
+ d.close();
+ ok(n === 0, `wer sich selbst erwähnt, spricht niemanden an (${n} Einträge)`);
+}
+
+/* WER NICHT IM RAUM IST, KANN NICHT ANGESPROCHEN WERDEN -- auch nicht
+ über die Schnittstelle. Dafür ein vierter Mensch, der draußen bleibt. */
+{
+ const d = new DatabaseSync(process.env.WORKSPACE_DB);
+ const salt = randomBytes(16).toString("hex");
+ d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt)"
+ + " VALUES (?,?,?,?,?,1,?)").run("Draussen", "modi", "x", salt, 32768, jetzt);
+ const idAussen = d.prepare("SELECT last_insert_rowid() AS id").get().id;
+ d.close();
+
+ const s = await ruf(`/workspace/api/chat/raeume/${raumId}/nachrichten`,
+ aDogi.keks, "POST", { text: "@Draussen bist du da?" });
+ const d2 = new DatabaseSync(process.env.WORKSPACE_DB);
+ const n = d2.prepare("SELECT COUNT(*) AS n FROM chat_erwaehnungen WHERE nachricht_id = ? AND person_id = ?")
+ .get(json(s).nachricht?.id, idAussen).n;
+ d2.close();
+ ok(n === 0, "wer nicht im Raum sitzt, wird auch über die Schnittstelle nicht angesprochen");
+}
+
+/* =====================================================================
+ 3. MERKT ES DER RICHTIGE? — das Zeichen in der Gesprächsliste
+ ===================================================================== */
+melde("\n=== 3. Man merkt es auch ohne offenen Raum ===");
+{
+ const beiVan = json(await ruf("/workspace/api/chat/raeume", aVan.keks))
+ .raeume?.find((r) => r.id === raumId);
+ const beiFunny = json(await ruf("/workspace/api/chat/raeume", aFunny.keks))
+ .raeume?.find((r) => r.id === raumId);
+ ok(beiVan?.erwaehnt === true, "VanVan sieht das Zeichen in ihrer Gesprächsliste");
+ /* DIE GEGENPROBE, die alles trägt: Funny war im selben Raum, hat
+ dieselben Nachrichten ungelesen -- und ist NICHT gemeint. Leuchtete
+ es bei ihr auch, wäre das Zeichen wertlos. */
+ ok(beiFunny?.erwaehnt === false,
+ "Gegenprobe: Funny war dabei, aber nicht gemeint — bei ihr leuchtet nichts");
+ ok((beiFunny?.ungelesen || 0) > 0,
+ ` und sie hat sehr wohl Ungelesenes (${beiFunny?.ungelesen}) — es liegt nicht daran`);
+}
+
+/* GELESEN HEISST WEG. Sonst leuchtet es für immer. */
+{
+ /* 99999 IST ABSICHTLICH ZU GROSS -- und genau daran ist eine echte
+ Luecke aufgefallen (20.09.2026): Der Server nahm jede Zahl an.
+ Wer einmal "gelesen bis 999999" schickte, sah in diesem Gespraech
+ nie wieder einen Zaehler und nie wieder ein @-Zeichen; alles
+ Kuenftige galt als gelesen, bevor es geschrieben war. Niemand
+ haette den Zusammenhang je gefunden.
+
+ Der Wert bleibt deshalb stehen: Er ist ab jetzt die Gegenprobe. */
+ await ruf(`/workspace/api/chat/raeume/${raumId}/gelesen`, aVan.keks, "PUT", { bis: 99999 });
+ {
+ const d = new DatabaseSync(process.env.WORKSPACE_DB);
+ const stand = d.prepare(
+ "SELECT gelesen_bis FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?")
+ .get(raumId, idVan).gelesen_bis;
+ const hoechste = d.prepare(
+ "SELECT COALESCE(MAX(id),0) AS n FROM chat_nachrichten WHERE raum_id = ?").get(raumId).n;
+ d.close();
+ ok(stand === hoechste,
+ `„gelesen bis 99999" wird auf die letzte echte Nachricht begrenzt (${stand} statt 99999, höchste ist ${hoechste})`);
+ }
+ const beiVan = json(await ruf("/workspace/api/chat/raeume", aVan.keks))
+ .raeume?.find((r) => r.id === raumId);
+ ok(beiVan?.erwaehnt === false, "nach dem Lesen ist das Zeichen weg");
+}
+
+/* =====================================================================
+ 4. WAS AUF DEM HANDY STEHT
+ ===================================================================== */
+melde("\n=== 4. Die Meldung sagt, dass DU gemeint bist ===");
+{
+ const { pushTextFuer } = await import("./workspace-chat.js");
+ const n = { von: "VanVan", text: "schaust du mal drauf?" };
+ const erw = pushTextFuer("erwaehnung", n, raumId);
+ const normal = pushTextFuer("neu", n, raumId);
+ ok(/hat dich erwähnt/.test(erw.titel), `-> ${erw.titel}`);
+ ok(erw.titel !== normal.titel,
+ ` und das ist etwas anderes als: ${normal.titel}`);
+ ok(erw.text.includes("schaust du mal"),
+ " der Anfang der Nachricht steht darunter — man muss nicht öffnen");
+ ok(erw.ziel === normal.ziel && erw.ziel.includes(String(raumId)),
+ " und beide führen in dasselbe Gespräch");
+
+ /* Die Art muss in der Liste stehen, sonst lässt sie sich nicht
+ abschalten -- und eine Meldung, die man nicht abschalten kann,
+ schaltet irgendwann alle ab. */
+ const { ARTEN } = await import("./workspace-push.js");
+ const art = ARTEN.find((a) => a.schluessel === "chat_erwaehnung");
+ ok(!!art, 'chat_erwaehnung steht in den Benachrichtigungs-Einstellungen');
+ ok(art?.vorgabe === true, " und ist von sich aus an");
+ ok(ARTEN.find((a) => a.schluessel === "chat_nachricht")?.schluessel !== art?.schluessel,
+ " getrennt von der gewöhnlichen Chatnachricht — zwei Schalter, zwei Entscheidungen");
+}
+
+/* =====================================================================
+ 5. BEIDE RECHNUNGEN, NEBENEINANDER
+ ===================================================================== */
+melde("\n=== 5. Server und Browser rechnen gleich ===");
+{
+ /* Der Browser hat eine eigene, kurze Fassung derselben Regeln --
+ er muss sofort hervorheben können, ohne zu fragen. Genau deshalb
+ wird sie hier gegen das Original gestellt: Zwei Rechnungen für
+ dieselbe Frage laufen sonst irgendwann auseinander, und dann hebt
+ die Seite jemanden hervor, den niemand benachrichtigt hat. */
+ /* ZEILENENDEN VEREINHEITLICHEN -- und das ist kein Schoenheitsfehler.
+
+ Der erste Anlauf suchte das Ende der Funktion mit einem Muster,
+ das auf einem Zeilenumbruch endete. Die Dateien dieses Hauses
+ stehen unter Windows aber mit CRLF auf der Platte; nach " }"
+ kommt also erst ein Wagenruecklauf -- und das Muster fand
+ NICHTS. Die Pruefung stuerzte ab, was hier Glueck war: Haette
+ sie mit dem leeren Ergebnis weitergerechnet, waere sie gruen
+ geworden und haette null Proben verglichen. */
+ const chatJs = readFileSync(join(HIER, "..", "workspace", "assets", "js", "chat.js"), "utf8")
+ .replace(/\r\n/g, "\n");
+ ok(/erwaehnungenFinden/.test(chatJs),
+ "der Browser hat eine Fassung derselben Auflösung");
+
+ /* Sie wird aus der Datei geholt und ausgeführt -- nicht nachgebaut.
+ Ein Nachbau würde prüfen, ob ICH zweimal dasselbe schreiben kann. */
+ const anfang = chatJs.indexOf("function erwaehnungenFinden");
+ ok(anfang > 0, " und sie steht als eigene Funktion da");
+ if (anfang > 0) {
+ /* Den Block bis zur schließenden Klammer auf Spaltenhöhe holen. */
+ const rest = chatJs.slice(anfang);
+ const ende = rest.indexOf("\n }\n");
+ /* DRITTER AUSGANG: Findet der Schnitt sein Ende nicht, wird das
+ GESAGT -- statt mit einem Stummel weiterzurechnen und dabei
+ null Proben zu vergleichen. */
+ ok(ende > 100, `das Ende der Funktion ist auffindbar (Zeichen ${ende})`);
+ const quelle = rest.slice(0, ende + 4);
+ // eslint-disable-next-line no-new-func
+ const browserFassung = new Function(`${quelle}; return erwaehnungenFinden;`)();
+
+ const leute = [
+ { id: 1, name: "Dogfather" }, { id: 2, name: "VanVan" }, { id: 3, name: "Tili" },
+ { id: 4, name: "Miss" },
+ ];
+ const proben = [
+ "@VanVan kannst du?", "@vanvan klein", "Hey @Tili, und @Miss!",
+ "filipe@dogfather.com", "@Tilikum", "@Missgeschick", "x@VanVan",
+ "(@Miss)", "@Tili @Tili", "nichts hier", "@VanVan-Team",
+ ];
+ let gleich = 0;
+ for (const t of proben) {
+ const a = JSON.stringify(erwaehnungenFinden(t, leute));
+ const b = JSON.stringify(browserFassung(t, leute));
+ if (a === b) gleich++;
+ else console.log(` ungleich bei ${JSON.stringify(t)}:\n Server: ${a}\n Browser: ${b}`);
+ }
+ ok(gleich === proben.length && proben.length >= 10,
+ `beide kommen bei allen ${proben.length} Proben auf dasselbe Ergebnis`);
+ }
+}
+
+
+/* =====================================================================
+ 6. AM ECHTEN BILDSCHIRM
+
+ Alles bisher hat bewiesen, dass der Richtige vermerkt wird. Das ist
+ die Hälfte. Die andere: Sieht man es? Und kommt man mit der Tastatur
+ dorthin, ohne mitten im Satz zur Maus zu greifen?
+
+ Es gibt einen Weg, auf dem alles Bisherige grün ist und die Funktion
+ trotzdem nicht benutzbar: wenn die Liste hinter der Schreibzeile
+ liegt, wenn Enter statt zu wählen die halbe Nachricht abschickt,
+ oder wenn der Name am Ende grau bleibt wie jedes andere Wort.
+ ===================================================================== */
+melde("\n=== 6. Am echten Bildschirm ===");
+const BASIS = `http://127.0.0.1:${PORT}`;
+const pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+const browser = await pw.chromium.launch();
+try {
+ async function alsRolle(rolle, code, breite = 1280, wohin = `chat.html?raum=${raumId}`) {
+ const ctx = await browser.newContext({ viewport: { width: breite, height: 900 } });
+ const seite = await ctx.newPage();
+ await seite.goto(BASIS + "/workspace/", { waitUntil: "networkidle" });
+ await seite.click(`.rolle[data-rolle="${rolle}"]`);
+ await seite.fill("#code", code);
+ await seite.click("button[type=submit]");
+ await seite.waitForURL("**/start.html", { timeout: 25000 });
+ await seite.goto(`${BASIS}/workspace/${wohin}`, { waitUntil: "networkidle" });
+ await seite.waitForTimeout(1100);
+ return { ctx, seite };
+ }
+
+ const dogi = await alsRolle("admin", "CODE-DOGI-0001");
+ const s = dogi.seite;
+ ok(await s.locator("#text").isVisible(), "das Schreibfeld steht da");
+
+ /* ---- Die Liste erscheint beim Tippen --------------------------- */
+ const vorher = await s.locator(".chat-nachricht").count();
+ await s.click("#text");
+ await s.type("#text", "Hallo @", { delay: 40 });
+ await s.waitForTimeout(400);
+ const liste = s.locator("#erwaehn-liste");
+ ok(await liste.isVisible(), 'ein @ oeffnet die Liste');
+ const namen = await liste.locator(".erwaehn-liste__name").allInnerTexts();
+ ok(namen.length === 2, `sie zeigt die anderen zwei aus diesem Raum (${namen.join(", ")})`);
+ ok(!namen.includes("Filipe"),
+ " und NICHT mich selbst — ein Eintrag, der nichts bewirkt, gehört nicht hinein");
+
+ /* Liegt sie auch wirklich sichtbar, oder hinter der Schreibzeile? */
+ const lage = await s.evaluate(() => {
+ const l = document.getElementById("erwaehn-liste");
+ const f = document.getElementById("text");
+ const a = l.getBoundingClientRect(), b = f.getBoundingClientRect();
+ return { unten: a.bottom, oben: b.top, breit: a.width, hoch: a.height,
+ imBild: a.top >= 0 && a.left >= 0 && a.right <= innerWidth };
+ });
+ ok(lage.unten <= lage.oben + 12,
+ `sie steht ÜBER der Schreibzeile (${Math.round(lage.unten)} ≤ ${Math.round(lage.oben)})`);
+ ok(lage.imBild && lage.breit > 100 && lage.hoch > 40,
+ ` und vollständig im Bild (${Math.round(lage.breit)}×${Math.round(lage.hoch)})`);
+
+ /* ---- Tippen filtert --------------------------------------------- */
+ await s.type("#text", "Van", { delay: 40 });
+ await s.waitForTimeout(350);
+ const gefiltert = await liste.locator(".erwaehn-liste__name").allInnerTexts();
+ ok(gefiltert.length === 1 && gefiltert[0] === "VanVan",
+ `Weitertippen grenzt ein (${gefiltert.join(", ") || "nichts"})`);
+
+ /* ---- Die Berührfläche ------------------------------------------- */
+ const hoch = await liste.locator(".erwaehn-liste__eintrag").first()
+ .evaluate((e) => e.getBoundingClientRect().height);
+ ok(hoch >= 44, `ein Eintrag ist ${Math.round(hoch)} px hoch — am Daumen treffbar`);
+
+ /* ---- Enter WÄHLT, es schickt nicht ab ---------------------------- */
+ await s.keyboard.press("Enter");
+ await s.waitForTimeout(400);
+ const nachEnter = await s.inputValue("#text");
+ ok(nachEnter === "Hallo @VanVan ",
+ `Enter setzt den Namen ein: ${JSON.stringify(nachEnter)}`);
+ ok(!(await liste.isVisible()), " und die Liste ist wieder zu");
+ /* DER ENTSCHEIDENDE PUNKT: Der halbe Satz darf NICHT rausgegangen
+ sein. Ohne diese Prüfung wäre der häufigste Handgriff überhaupt
+ eine versehentlich abgeschickte Nachricht. */
+ const raus = await s.locator(".chat-nachricht").count();
+ /* GEGEN DEN AUSGANGSWERT, NICHT GEGEN NULL. Im Raum stehen schon
+ die Nachrichten aus Abschnitt 2 -- der erste Anlauf verglich mit
+ 0 und wurde rot, obwohl nichts abgeschickt worden war. Eine
+ Pruefung, die den Anfangszustand nicht kennt, misst ihn mit. */
+ ok(raus === vorher, `nichts wurde abgeschickt (${raus}, vorher ${vorher})`);
+
+ /* ---- Und jetzt wirklich abschicken ------------------------------- */
+ await s.type("#text", "schaust du mal?", { delay: 20 });
+ await s.keyboard.press("Enter");
+ await s.waitForTimeout(1400);
+ const blase = s.locator(".chat-nachricht").last();
+ ok(await blase.isVisible(), "die Nachricht steht im Verlauf");
+ const marke = blase.locator(".chat-erwaehnung");
+ ok(await marke.count() === 1, "der Name ist im Satz hervorgehoben");
+ ok((await marke.innerText()).trim() === "@VanVan",
+ ` und zwar genau: ${(await marke.innerText()).trim()}`);
+ /* GEGENPROBE: Beim Absender ist es NICHT der eigene Name -- die
+ stärkere Hervorhebung gehört nur dem, der gemeint ist. */
+ ok(await marke.getAttribute("data-ich") === null,
+ "Gegenprobe: beim Absender ist es nicht die eigene Marke");
+
+ /* ---- Und bei der, die gemeint ist -------------------------------- */
+ const van = await alsRolle("scout", "CODE-SCOU-0001");
+ const vs = van.seite;
+ await vs.waitForTimeout(900);
+ const vMarke = vs.locator(".chat-nachricht").last().locator(".chat-erwaehnung");
+ ok(await vMarke.count() === 1, "VanVan sieht den Namen ebenfalls hervorgehoben");
+ ok(await vMarke.getAttribute("data-ich") === "ja",
+ " und bei ihr deutlicher — sie ist gemeint");
+ /* NICHT NUR FARBE: Im Kontrastmodus fällt jede Tönung weg. Geprüft
+ wird, dass auch etwas anderes anders ist. */
+ const anders = await vMarke.evaluate((e) => {
+ const m = getComputedStyle(e);
+ const nachbar = e.parentElement;
+ const n = getComputedStyle(nachbar);
+ return { gewicht: m.fontWeight, gewichtNachbar: n.fontWeight,
+ schatten: m.boxShadow !== "none", grund: m.backgroundColor };
+ });
+ ok(Number(anders.gewicht) > Number(anders.gewichtNachbar) || anders.schatten,
+ ` erkennbar nicht nur an der Farbe (Gewicht ${anders.gewicht} statt ${anders.gewichtNachbar}, Rahmen: ${anders.schatten})`);
+
+ /* ---- Das Zeichen in der Gesprächsliste ---------------------------
+
+ ERST ZURÜCK ZUR LISTE, DANN EINE FRISCHE ERWÄHNUNG. Der erste
+ Anlauf prüfte es direkt nach dem Lesen und wurde rot -- zu Recht:
+ VanVan hatte den Raum offen, also hat sie es gesehen, und ein
+ Zeichen für etwas Gelesenes wäre falsch. Die Prüfung mass die
+ eigene Reihenfolge, nicht das Verhalten.
+
+ Jetzt der Fall, um den es wirklich geht: Sie ist woanders, und es
+ kommt etwas für sie herein. */
+ await vs.goto(`${BASIS}/workspace/chat.html`, { waitUntil: "networkidle" });
+ await vs.waitForTimeout(900);
+ const vorSchon = await vs.locator(".chat-raum__erwaehnt").count();
+ ok(vorSchon === 0,
+ `solange nichts Neues da ist, leuchtet auch nichts (${vorSchon})`);
+
+ await s.click("#text");
+ await s.type("#text", "@VanVan noch eine Sache", { delay: 15 });
+ await s.keyboard.press("Enter");
+ await s.waitForTimeout(1500);
+ await vs.reload({ waitUntil: "networkidle" });
+ await vs.waitForTimeout(1200);
+
+ const zeichen = vs.locator(".chat-raum__erwaehnt");
+ ok(await zeichen.count() === 1, `in ihrer Gesprächsliste steht das @-Zeichen (${await zeichen.count()})`);
+ ok((await zeichen.first().getAttribute("aria-label") || "").includes("erwähnt"),
+ " mit einem Satz für Vorleseprogramme statt eines nackten Zeichens");
+
+ /* GEGENPROBE BEI FUNNY: Sie sitzt im selben Raum und hat dieselben
+ Nachrichten ungelesen -- und ist nicht gemeint. Leuchtete es bei
+ ihr auch, wäre das Zeichen wertlos. Am Bildschirm, nicht nur in
+ der Antwort des Servers. */
+ const funny = await alsRolle("scout", "CODE-SCOU-0002", 1280, "chat.html");
+ await funny.seite.waitForTimeout(1200);
+ const beiFunny = await funny.seite.locator(".chat-raum__erwaehnt").count();
+ const zahlFunny = await funny.seite.locator(".chat-raum__zahl").count();
+ ok(beiFunny === 0, `Gegenprobe: bei Funny leuchtet nichts (${beiFunny})`);
+ ok(zahlFunny >= 1,
+ ` obwohl sie Ungelesenes hat (${zahlFunny} Gespräch(e) mit Zahl) — es liegt nicht daran`);
+ await funny.ctx.close();
+
+ /* ---- Die Gegenprobe der Liste: ein @ ohne Treffer -------------- */
+ await s.click("#text");
+ await s.type("#text", "@Zzz", { delay: 30 });
+ await s.waitForTimeout(350);
+ ok(!(await liste.isVisible()),
+ "Gegenprobe: passt niemand, bleibt die Liste zu");
+ /* Und Enter schickt dann wieder ab, wie immer. */
+ await s.keyboard.press("Enter");
+ await s.waitForTimeout(1200);
+ const jetztRaus = await s.locator(".chat-nachricht").count();
+ ok(jetztRaus > vorher + 1, ` und Enter schickt wieder ab (${jetztRaus}, vorher ${vorher})`);
+
+ await dogi.ctx.close();
+ await van.ctx.close();
+} finally {
+ await browser.close();
+}
+
+console.log(`\n${fehler ? fehler + " FEHLER" : "Alles in Ordnung"} — ${geprueft} Prüfungen\n`);
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
+process.exit(fehler ? 1 : 0);
diff --git a/server/workspace-chat.js b/server/workspace-chat.js
index f1e664d4..f920f08b 100644
--- a/server/workspace-chat.js
+++ b/server/workspace-chat.js
@@ -47,6 +47,7 @@ import {
fuehrtTeamDogi, MODI_KATEGORIEN, ROLLEN_GRUPPE,
} from "./workspace.js";
+import { erwaehnteIds } from "./chat-erwaehnung.js";
import { KATALOG, ERLAUBT, VORSCHLAG, FAVORITEN_MAX } from "./workspace-reaktionen.js";
/* Der Treff-Chat: eigener Raum, eigene Nachtruhe, keine Anrufe.
@@ -505,6 +506,32 @@ chatRouter.get("/workspace/api/chat/raeume", (req, res) => {
WHERE raum_id = ? AND id > ? AND id > ? AND person_id <> ?`)
.get(r.id, r.gelesen_bis, r.geloescht_bis, req.person.id).n;
+ /* STEHT IN DEN UNGELESENEN EIN "@" AN MICH? (20.09.2026)
+
+ Eine Zahl sagt "hier ist etwas". Sie sagt nicht, ob es an
+ DICH war. Wer morgens vier Raeume mit Zahlen sieht, macht
+ den lautesten zuerst auf -- und genau dort steht selten das,
+ was auf ihn wartet. Das Zeichen daneben dreht die Reihenfolge
+ um.
+
+ DIESELBEN ZWEI GRENZEN wie bei der Zahl darueber: was schon
+ gelesen ist, und was weggeraeumt wurde. Ohne die zweite
+ leuchtete ein Gespraech wieder auf, das jemand bewusst
+ beiseitegelegt hat.
+
+ UND `weg_am IS NULL`: Eine zurueckgenommene Nachricht spricht
+ niemanden mehr an. Der Eintrag bleibt stehen (er gehoert zur
+ Nachricht), zaehlt hier aber nicht mehr mit -- gerechnet wird
+ beim Lesen, nicht beim Zuruecknehmen, damit es keine zweite
+ Stelle gibt, die man vergessen kann. */
+ const erwaehnt = !!db().prepare(`
+ SELECT 1 FROM chat_erwaehnungen e
+ JOIN chat_nachrichten n ON n.id = e.nachricht_id
+ WHERE e.person_id = ? AND n.raum_id = ?
+ AND n.id > ? AND n.id > ? AND n.weg_am IS NULL
+ LIMIT 1`)
+ .get(req.person.id, r.id, r.gelesen_bis, r.geloescht_bis);
+
return {
id: r.id,
art: r.art,
@@ -531,6 +558,7 @@ chatRouter.get("/workspace/api/chat/raeume", (req, res) => {
von: letzte.von, erstellt: letzte.erstellt,
} : null,
ungelesen,
+ erwaehnt,
};
});
/* WELCHE KANAELE ES NOCH NICHT GIBT -- und nur fuer die, die sie
@@ -1042,11 +1070,45 @@ chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft,
anhang: null,
};
+ /* ==== WER IST MIT "@" GEMEINT? (20.09.2026) =====================
+
+ Aufgeloest wird HIER, gegen die Teilnehmer DIESES Raums -- nicht
+ im Browser. Der Browser schickt nur Text. Wer daraus eine
+ Person wird, entscheidet der Server, und damit gilt:
+
+ * Ein von Hand getipptes "@Anna" wirkt genauso wie eines aus
+ der Auswahlliste. Wer schnell schreibt, soll nicht bestraft
+ werden.
+ * Niemand kann jemanden ansprechen, der gar nicht im Raum
+ sitzt -- auch nicht, indem er die Schnittstelle direkt
+ anspricht.
+
+ Die Regeln stehen in chat-erwaehnung.js, samt Begruendung je
+ Regel. Sie sind bewusst ohne Abhaengigkeiten, damit die
+ Pruefung sie lesen kann, ohne einen Server hochzufahren. */
+ const alleLeute = teilnehmerVon(raumId);
+ const erwaehnt = erwaehnteIds(text, alleLeute)
+ /* SICH SELBST ANSPRECHEN LOEST NICHTS AUS. Man schreibt den
+ eigenen Namen durchaus ("@Anna hat recht, ich auch") -- eine
+ Meldung an sich selbst waere Unsinn. */
+ .filter((pid) => pid !== req.person.id);
+ if (erwaehnt.length) {
+ const einf = d.prepare(
+ "INSERT OR IGNORE INTO chat_erwaehnungen (nachricht_id, person_id) VALUES (?,?)");
+ for (const pid of erwaehnt) einf.run(id, pid);
+ }
+
/* Sofort an alle, die gerade zusehen -- und per Benachrichtigung
an die, die es nicht tun. Beides läuft über chatEreignis(),
- damit die Entscheidung "wer bekommt was" an einer Stelle steht. */
- const empfaenger = teilnehmerVon(raumId).filter((t) => t.id !== req.person.id);
- chatEreignis(raumId, nachricht, empfaenger.map((t) => t.id));
+ damit die Entscheidung "wer bekommt was" an einer Stelle steht.
+ Auch die Erwaehnung: Sie ist keine ZWEITE Meldung, sondern eine
+ ANDERE -- wer angesprochen wurde, bekommt statt "Nachricht von
+ Anna" ein "Anna hat dich erwaehnt". Zwei Meldungen fuer eine
+ Nachricht waeren der schnellste Weg, dass jemand beide
+ abschaltet. */
+ const empfaenger = alleLeute.filter((t) => t.id !== req.person.id);
+ chatEreignis(raumId, nachricht, empfaenger.map((t) => t.id), "neu",
+ new Set(erwaehnt));
res.status(201).json({ nachricht });
} catch (fehler) {
@@ -1696,12 +1758,42 @@ chatRouter.put("/workspace/api/chat/raeume/:id/gelesen", gleicheHerkunft,
return res.status(400).json({ fehler: "ungueltig" });
}
if (!istDrin(raumId, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
+
+ /* ==== NICHT WEITER ALS BIS ZUR LETZTEN NACHRICHT (20.09.2026) ====
+
+ Hier stand nur `Number.isInteger(bis)`. Damit liess sich der
+ Stand auf JEDE Zahl setzen -- auch auf eine, die weit hinter
+ allem liegt, was es gibt.
+
+ Die Folge ist still und dauerhaft: Wer einmal "gelesen bis
+ 999999" schickt, hat in diesem Gespraech nie wieder einen
+ Zaehler und nie wieder ein @-Zeichen. Alles Kuenftige ist
+ schon gelesen, bevor es geschrieben wurde. Niemand wuerde den
+ Zusammenhang je finden -- man merkt nur, dass "die
+ Benachrichtigungen nicht gehen".
+
+ Gefunden beim Bauen der Erwaehnungen: Die Pruefung setzte zum
+ Aufraeumen 99999 und wunderte sich danach, warum das frische
+ "@" nicht leuchtete. Der Fehler lag nicht in der Erwaehnung.
+
+ Der Browser schickt heute immer die Nummer der letzten
+ gesehenen Nachricht -- er ist also nicht der Grund. Aber eine
+ Grenze, die nur davon lebt, dass der Aufrufer sich benimmt,
+ ist keine.
+
+ ABGELEITET STATT GERATEN: Weiter als bis zur juengsten
+ Nachricht dieses Raums kann niemand gelesen haben. */
+ const neueste = db().prepare(
+ "SELECT COALESCE(MAX(id), 0) AS n FROM chat_nachrichten WHERE raum_id = ?")
+ .get(raumId).n;
+ const stand = Math.max(0, Math.min(bis, neueste));
+
/* NUR VORWÄRTS. Ein zu spät eintreffender Aufruf einer älteren
Ansicht darf den Stand nicht zurückdrehen -- sonst stünde eine
gelesene Nachricht wieder als ungelesen da. */
db().prepare(`UPDATE chat_teilnehmer SET gelesen_bis = ?
WHERE raum_id = ? AND person_id = ? AND gelesen_bis < ?`)
- .run(bis, raumId, req.person.id, bis);
+ .run(stand, raumId, req.person.id, stand);
res.json({ ok: true });
} catch (fehler) {
console.error("[chat] Gelesen:", fehler?.message);
@@ -1863,7 +1955,15 @@ chatRouter.delete("/workspace/api/chat/nachrichten/:id", gleicheHerkunft, (req,
* Eine Person kann an zwei Geräten sitzen. */
const zuschauer = new Map();
-function chatEreignis(raumId, nachricht, benachrichtigen, art = "neu") {
+/**
+ * @param erwaehnte Wer in DIESER Nachricht mit "@" angesprochen wurde.
+ * Diese Menge bekommt eine ANDERE Meldung, nicht eine zweite
+ * (20.09.2026). Sie steht hier und nicht an der Aufrufstelle,
+ * weil der Kommentar unten es verlangt: Die Entscheidung "wer
+ * bekommt was" gehoert an EINE Stelle.
+ */
+function chatEreignis(raumId, nachricht, benachrichtigen, art = "neu",
+ erwaehnte = new Set()) {
/* 1. An alle offenen Verbindungen der Beteiligten. */
const leute = teilnehmerVon(raumId).map((t) => t.id);
for (const personId of leute) {
@@ -1898,7 +1998,12 @@ function chatEreignis(raumId, nachricht, benachrichtigen, art = "neu") {
sehen und behandelte deshalb JEDES Ereignis als Chatnachricht.
Bei einem Anruf kam dabei woertlich „Nachricht von [object
Object]" heraus. */
- void meldeNeueNachricht(personId, raumId, nachricht, art);
+ /* ANGESPROCHEN ODER NUR DABEI? Der Unterschied entscheidet ueber
+ Wortlaut UND Art -- und die Art kann jeder einzeln abschalten.
+ Wer den Chat stumm stellt, weil dort viel geredet wird, will
+ trotzdem wissen, wenn ihn jemand direkt anspricht. */
+ void meldeNeueNachricht(personId, raumId, nachricht,
+ art === "neu" && erwaehnte.has(personId) ? "erwaehnung" : art);
}
}
@@ -1946,9 +2051,30 @@ export function pushTextFuer(art, nachricht, raumId) {
};
}
+ const wer = typeof nachricht.von === "string"
+ ? nachricht.von : (nachricht.von?.name || "jemandem");
+
+ /* ---- ANGESPROCHEN IST ETWAS ANDERES ALS DABEI (20.09.2026) ----
+
+ "Nachricht von Anna" sagt nicht, dass sie DICH meint. In einem
+ Raum mit acht Leuten ist das der Unterschied zwischen "kann
+ warten" und "gleich nachsehen" -- und genau diesen Unterschied
+ soll das Markieren mit "@" machen. Steht er nicht in der Meldung,
+ ist das Markieren nur Farbe.
+
+ Der Anfang der Nachricht bleibt darunter stehen, wie sonst auch:
+ Wer auf dem Sperrbildschirm liest, soll nicht oeffnen muessen, um
+ zu erfahren, ob es wichtig war. */
+ if (art === "erwaehnung") {
+ return {
+ titel: `${wer} hat dich erwähnt`,
+ text: String(nachricht.text || "").slice(0, 120),
+ ziel,
+ };
+ }
+
return {
- titel: `Nachricht von ${typeof nachricht.von === "string"
- ? nachricht.von : (nachricht.von?.name || "jemandem")}`,
+ titel: `Nachricht von ${wer}`,
/* Der Anfang der Nachricht steht in der Meldung -- eine
Benachrichtigung, die nur "Neue Nachricht" sagt, zwingt zum
Öffnen, um zu erfahren, ob es wichtig war. */
@@ -1987,8 +2113,14 @@ async function meldeNeueNachricht(personId, raumId, nachricht, art = "neu") {
wirklich klingelt. */
/* ALS EIGENE ART, wenn es ein Anruf ist. Sie umgeht die Ruhezeit
und laesst sich getrennt abschalten -- siehe workspace-push.js. */
- await benachrichtige(personId, art === "anruf" ? "anruf" : "chat_nachricht",
- pushTextFuer(art, nachricht, raumId));
+ /* DREI ARTEN, DREI SCHALTER. Jede laesst sich einzeln abschalten
+ (siehe ARTEN in workspace-push.js) -- und genau darum geht es:
+ Wer den lauten Chat stumm stellt, verliert damit weder Anrufe
+ noch die Faelle, in denen ihn jemand direkt anspricht. */
+ const pushArt = art === "anruf" ? "anruf"
+ : art === "erwaehnung" ? "chat_erwaehnung"
+ : "chat_nachricht";
+ await benachrichtige(personId, pushArt, pushTextFuer(art, nachricht, raumId));
} catch (fehler) {
console.error("[chat] Benachrichtigung:", fehler?.message);
}
diff --git a/server/workspace-push.js b/server/workspace-push.js
index fc36cd79..ddc025bc 100644
--- a/server/workspace-push.js
+++ b/server/workspace-push.js
@@ -87,6 +87,25 @@ export const ARTEN = [
die am haeufigsten kommt, und wer die Liste ueberfliegt, sucht
zuerst sie. */
{ schluessel: "chat_nachricht", name: "Neue Nachricht im Chat", vorgabe: true },
+ /* ---- ANGESPROCHEN WERDEN IST EINE EIGENE ART (20.09.2026) ------
+
+ Filipe: "ich will das man die leute mit @ markieren kann im chat."
+
+ Sie steht direkt hinter "Neue Nachricht", weil sie daneben
+ gehoert -- und sie ist trotzdem getrennt, aus demselben Grund,
+ aus dem Anrufe getrennt sind: Es sind zwei verschiedene
+ Entscheidungen.
+
+ In einem Raum mit acht Leuten ist "jemand hat geschrieben" oft
+ Hintergrund. "Jemand meint DICH" ist es nie. Wer das Erste stumm
+ stellt, will das Zweite meist behalten -- und muesste sonst
+ beides zusammen abschalten oder beides zusammen ertragen.
+
+ `vorgabe: true`, wie alles, was auf ein Ereignis antwortet. Und
+ KEINE Ausnahme von der Ruhezeit: Ein Anruf wartet auf eine
+ Antwort, ein "@Anna" nicht. Wer um halb vier angesprochen wird,
+ liest es um sieben -- das reicht. */
+ { schluessel: "chat_erwaehnung", name: "Jemand erwähnt dich im Chat (@)", vorgabe: true },
/* ---- ANRUFE SIND EINE EIGENE ART (18.09.2026) ------------------
Filipe: „sie bekommt nur eine benarichtigung das eine neue
diff --git a/server/workspace.js b/server/workspace.js
index f8d7b66e..17d7528c 100644
--- a/server/workspace.js
+++ b/server/workspace.js
@@ -3624,6 +3624,49 @@ export function db() {
CREATE INDEX IF NOT EXISTS idx_chat_nachrichten_raum
ON chat_nachrichten (raum_id, id);
+ /* ERWAEHNUNGEN -- "@Name" IM CHAT (20.09.2026).
+
+ Filipe: "ich will das man die leute mit @ markieren kann im
+ chat."
+
+ WARUM EINE EIGENE TABELLE UND NICHT NUR FARBIGER TEXT:
+ Markieren ist kein Schmuck, sondern eine Ansprache. Wer
+ gemeint ist, soll es MERKEN -- auch wenn er den Raum gerade
+ nicht offen hat und auch dann noch, wenn er ihn Stunden
+ spaeter aufmacht. Dafuer muss irgendwo stehen, wer gemeint
+ war.
+
+ WARUM NICHT AUS DEM TEXT NACHRECHNEN: Man koennte beim Lesen
+ jedes Mal "@" suchen. Dann muesste man aber bei jedem Aufbau
+ der Gespraechsliste den Text JEDER ungelesenen Nachricht
+ durchgehen -- bei einem lebhaften Raum sind das hunderte. Hier
+ steht das Ergebnis einmal und wird nie wieder gerechnet.
+
+ WER GEMEINT IST, ENTSCHEIDET DER SERVER -- nicht der Browser.
+ Der Browser schickt nur Text; wer daraus eine Person wird,
+ loest erwaehnungenFinden() gegen die TEILNEHMER DES RAUMS
+ auf. So kann niemand jemanden ansprechen, der gar nicht dabei
+ ist, und ein von Hand getipptes "@Anna" wirkt genauso wie
+ eines aus der Auswahlliste.
+
+ ON DELETE CASCADE an beiden Enden, wie bei den Reaktionen:
+ Verschwindet die Nachricht oder der Mensch, geht der Eintrag
+ mit. Eine Erwaehnung ohne Nachricht waere ein Hinweis, der
+ nirgendwohin fuehrt.
+
+ Der zusammengesetzte Primaerschluessel sagt "jede Person hoechstens
+ einmal je Nachricht": Wer dreimal "@Anna" in einen Satz
+ schreibt, spricht sie trotzdem nur einmal an. */
+ CREATE TABLE IF NOT EXISTS chat_erwaehnungen (
+ nachricht_id INTEGER NOT NULL REFERENCES chat_nachrichten(id) ON DELETE CASCADE,
+ person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
+ PRIMARY KEY (nachricht_id, person_id)
+ );
+ /* Die Frage, die im Betrieb gestellt wird, lautet "bin ICH
+ irgendwo erwaehnt?" -- deshalb liegt person_id vorn. */
+ CREATE INDEX IF NOT EXISTS idx_chat_erwaehnungen_person
+ ON chat_erwaehnungen (person_id, nachricht_id);
+
/* REAKTIONEN (14.09.2026).
Filipe: "ich will dass man auf die nachrichten auch reagieren
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index 5189b2e3..40c27813 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`. -->
-
-
+
+
-
+