Files
dogfather-universe/server/workspace-chat.js
T
DogFatherGitandClaude Opus 5 7de32a5ec7 Punkt 15: Der Chat bekommt Suche, Antworten mit Zitat und die Ungelesen-Linie
Wunsch Filipe: "ich will dass du diese seite viel krasser und
detaillierter machst, ich will dass du dich informierst und alles
reinsetzt was wir noch gebrauchen koennten."

NICHT ALLES, SONDERN WAS TAEGLICH FEHLT. Der Chat konnte schon Raeume,
Verlauf, Gelesen-Stand, Live-Zustellung, Gruppen und Zuruecknehmen.
Drei Dinge fehlten, und jedes davon kostet ohne es echte Zeit:

1. SUCHE IN DEN NACHRICHTEN. Ein Chat ohne sie ist ab dem zweiten
   Monat ein Archiv, in dem man nichts findet. Ein Suchfeld gab es --
   es durchsuchte aber nur die NAMEN der Gespraeche, also die kleinere
   Haelfte. Jetzt durchsucht dasselbe Feld beides und zeigt die
   Fundstellen UNTER der Gespraechsliste: Wer "Patrick" eingibt, will
   vielleicht das Gespraech und vielleicht die Nachricht -- ein
   Umschalter haette ihn zwingen wollen, das vorher zu wissen.

2. ANTWORTEN MIT ZITAT. Zu zweit weiss man meistens, worauf sich etwas
   bezieht. In einer Gruppe laufen drei Faeden parallel, und "ja, mach
   das" kann alles heissen. Das Zitat steht IN der Blase (es gehoert
   zur Antwort, nicht darueber) und fuehrt per Klick zur Stelle.

3. DIE LINIE "AB HIER NEU". Wer nach zwei Tagen zurueckkommt, sucht
   sonst die Stelle, an der er aufgehoert hat, indem er Uhrzeiten
   liest.

BEWUSST NICHT GEBAUT: Anhaenge (dafuer gibt es den Dateien-Bereich mit
Rechten und Ablauf), Reaktionen (eine vierte Sache, bevor die drei sich
bewaehrt haben) und Tipp-Anzeigen (dauernder Verkehr fuer eine
Auskunft, die man in zwei Sekunden ohnehin sieht).

DIE SICHERHEIT DER SUCHE STEHT IM JOIN, nicht in einer nachtraeglichen
Pruefung: `chat_teilnehmer` wird mit der eigenen Personenkennung
verbunden, und was dort nicht drinsteht, kommt gar nicht erst aus der
Datenbank. Ein Filter, der erst hinterher aussortiert, ist eine Zeile
davon entfernt, vergessen zu werden. Ebenso beim Zitat: Worauf
geantwortet wird, muss im SELBEN Raum liegen -- sonst koennte jemand
die Kennung aus einem fremden Gespraech mitschicken, und beim
Empfaenger stuende ein Zitat aus einem Raum, den er nie gesehen hat.

DREI FEHLER, DIE DER DURCHLAUF GEFUNDEN HAT:

1. `ESCAPE '\'` IN EINEM TEMPLATE-LITERAL. Dort ist `\'` eine
   Fluchtsequenz fuer das Anfuehrungszeichen -- SQLite bekam ein
   LEERES Fluchtzeichen und antwortete "ESCAPE expression must be a
   single character". Die Suche war damit komplett tot. Kein
   Syntaxfehler, kein Warnhinweis: Erst der Aufruf mit echten Daten
   hat es gezeigt.

2. DIE MASKIERUNG KANNTE ZWEI VON DREI ZEICHEN. `%` und `_` waren
   dabei, der Backslash nicht -- ausgerechnet das Fluchtzeichen selbst.
   Geprueft wird das jetzt an der ZEILE AUS DER DATEI, nicht an einem
   Nachbau: Mein erster Test hat die Maskierung nachgebaut und dabei
   die Shell-Maskierung mitgeschleppt -- er meldete einen Fehler, den
   nur er hatte.

3. DIE UNGELESEN-LINIE SCHIEN NICHT ZU FUNKTIONIEREN. Sie tat es --
   mein Testaufbau war falsch: Filipes Seite war noch offen, die neuen
   Nachrichten kamen ueber den Live-Strom an und wurden sofort als
   gelesen gemeldet. Es gab schlicht nichts Ungelesenes. Erst als er
   die Seite verlaesst, bevor Patrick schreibt, steht die Linie da --
   und zwar genau vor "Neu von Patrick, eins", und beim zweiten
   Oeffnen ist sie weg.

Der Gelesen-Stand wird deshalb beim OEFFNEN mitgeschickt, bevor er
gesetzt wird -- eine Zeile spaeter waere er immer die letzte Nachricht,
und die Linie staende nie irgendwo.

Ohne Volltextindex, mit Absicht: `LIKE` liest die Tabelle, und bei
einem Team dieser Groesse sind das einige tausend Zeilen. Ein
FTS5-Index waere eine zweite Tabelle, die synchron gehalten werden
muss -- genau daran gehen solche Sachen kaputt. Wenn der Verlauf
sechsstellig wird, ist das der Zeitpunkt dafuer, nicht heute.

Geprueft: pruef-chat, pruef-chat-optik, pruef-css-klassen,
pruef-workspace-seiten, pruef-handy -- alle in Ordnung. Dazu im
Browser durchgespielt: Zitat gesetzt und gelesen, Suche nach
"Bitrate" (1 Treffer), nach "100 %" (1 Treffer -- die Maskierung
haelt), nach Unsinn (0), einbuchstabige Suche (zu kurz), Antwortleiste
mit dem richtigen Namen, Ungelesen-Linie an der richtigen Stelle und
beim zweiten Oeffnen weg.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-09 02:18:59 +02:00

695 lines
30 KiB
JavaScript

/* =====================================================================
workspace-chat.js — Gespräche zwischen den Leuten im Workspace.
Wunsch Filipe, 06.09.2026: *"eine kategorie chat, wo die creator mit
ihren manager nachrichten austauschen können, wie erinnerungen,
fragen und noch vieles mehr, wo jeder immer mit denen schreiben kann
wie verbindung auch läuft, und jeder kann dann eine nachricht
reinschreiben, die andere person bekommt eine benachrichtigung und
kann dann antworten."*
Auf Nachfrage entschieden: Zweier-Gespräche UND Gruppen; Manager und
Scouts dürfen sich auch ohne gemeinsamen Creator schreiben.
---------------------------------------------------------------------
DIE DREI REGELN, die alles andere tragen
1. WER MIT WEM steht an EINER Stelle: schreibbareIds() in
workspace.js. Nicht hier, nicht in der Oberfläche. Jede Route
fragt dieselbe Funktion -- eine zweite Regel wäre die sichere
Zusage, dass beide irgendwann auseinanderlaufen.
2. WER IM RAUM IST, entscheidet über den Verlauf. Die Mitgliedschaft
wird bei JEDEM Zugriff geprüft, nicht nur beim Öffnen. Sonst
könnte man eine Raumnummer erraten und mitlesen.
3. EINE NACHRICHT WIRD NICHT GELÖSCHT, sondern geleert. Ein Loch im
Verlauf wirft mehr Fragen auf als der Hinweis, dass jemand etwas
zurückgenommen hat -- und in einem Gespräch, in dem Absprachen
stehen, ist Nachvollziehbarkeit mehr wert als ein sauberer Schnitt.
---------------------------------------------------------------------
WAS BEWUSST FEHLT
Kein Bearbeiten einer abgeschickten Nachricht. Wer eine Absprache
nachträglich umschreiben kann, macht den Verlauf wertlos --
zurücknehmen und neu schreiben ist ehrlicher und hinterlässt eine
Spur. (Zurücknehmen selbst ist da, siehe weg_am.)
===================================================================== */
import express from "express";
import {
db, protokolliere, echteIp, sitzungLesen, istDogFather,
schreibbareIds, darfSchreibenMit, ROLLEN_SORTIERUNG,
} from "./workspace.js";
export const chatRouter = express.Router();
const TEXT_MAX = 4000;
const NAME_MAX = 80;
const jetzt = () => new Date().toISOString();
/* ---------- Schranke ---------------------------------------------------- */
function angemeldet(req, res, next) {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
req.person = person;
next();
}
function gleicheHerkunft(req, res, next) {
const herkunft = req.get("origin");
if (!herkunft) return next();
let erlaubt;
try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; }
if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" });
next();
}
chatRouter.use("/workspace/api/chat", angemeldet);
/* =====================================================================
Bausteine
===================================================================== */
/** Ist die Person in diesem Raum -- und nicht ausgetreten?
*
* DIE WICHTIGSTE FUNKTION DER DATEI. Jede Route, die einen Verlauf
* liest oder etwas hineinschreibt, geht hier durch. Wer sie umgeht,
* macht aus einer Raumnummer einen Schlüssel. */
function istDrin(raumId, personId) {
try {
const z = db().prepare(
"SELECT 1 FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ? AND raus_am IS NULL")
.get(raumId, personId);
return !!z;
} catch { return false; }
}
/** Alle (noch aktiven) Teilnehmer eines Raums. */
/* Das Profilbild gehoert dazu (07.09.2026). Ohne es zeigt die
Gespraechsliste Initialen, obwohl ein Bild hochgeladen ist -- der
Fehler liegt dann nicht in der Anzeige, sondern in der Abfrage. Die
fertige ADRESSE wird danach gebaut, nicht der Dateiname
weitergereicht: Sonst setzt jede Stelle im Browser denselben Pfad
zusammen.
UND DER KOMMENTAR STEHT AUSSERHALB DER ABFRAGE. Beim ersten Anlauf
stand er darin -- mit dem Wort `bild` in schraegen Anfuehrungszeichen.
Die beenden in JavaScript eine Vorlagenzeichenkette; die halbe SQL
wurde damit zu Programmtext, und die Datei liess sich nicht mehr
lesen. Ein Kommentar, der die Zeichenkette schliesst, in der er
steht. */
function teilnehmerVon(raumId) {
return db().prepare(`
SELECT t.person_id AS id, p.name, p.rolle, p.bild, t.leitung, t.gelesen_bis
FROM chat_teilnehmer t
JOIN personen p ON p.id = t.person_id
WHERE t.raum_id = ? AND t.raus_am IS NULL
ORDER BY ` + ROLLEN_SORTIERUNG + ", p.name").all(raumId)
.map((z) => ({ ...z, bild: z.bild ? `/workspace/api/steckbrief/bild/${z.bild}` : null }));
}
/** Findet das Zweier-Gespräch der beiden -- oder legt es an.
*
* ES DARF NUR EINES GEBEN. Zwei Gespräche zwischen denselben zwei
* Leuten wären ein Verlauf an zwei Orten: Man schreibt in das eine,
* der andere liest im anderen und wartet. Deshalb wird zuerst
* gesucht, und zwar über die Teilnehmer -- ein "Paarschlüssel" als
* Spalte wäre eine zweite Wahrheit neben chat_teilnehmer. */
function direktRaum(personA, idB, { anlegen = true } = {}) {
const d = db();
const vorhanden = d.prepare(`
SELECT r.id FROM chat_raeume r
WHERE r.art = 'direkt'
AND EXISTS (SELECT 1 FROM chat_teilnehmer t WHERE t.raum_id = r.id AND t.person_id = ?)
AND EXISTS (SELECT 1 FROM chat_teilnehmer t WHERE t.raum_id = r.id AND t.person_id = ?)
AND (SELECT COUNT(*) FROM chat_teilnehmer t WHERE t.raum_id = r.id) = 2`)
.get(personA.id, idB);
if (vorhanden) return vorhanden.id;
if (!anlegen) return null;
const n = jetzt();
d.prepare("INSERT INTO chat_raeume (art, erstellt, erstellt_von, letzte_am) VALUES ('direkt',?,?,NULL)")
.run(n, personA.id);
const raumId = d.prepare("SELECT last_insert_rowid() AS id").get().id;
const rein = d.prepare(
"INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis) VALUES (?,?,?,0)");
rein.run(raumId, personA.id, n);
rein.run(raumId, idB, n);
return raumId;
}
/** Wie heißt dieser Raum für DIESE Person?
*
* Ein Zweier-Gespräch heißt für jede Seite anders -- nach dem
* Gegenüber. Deshalb steht der Name nicht in der Tabelle: Er wäre für
* eine der beiden Seiten immer falsch. */
function raumName(raum, teilnehmer, ichId) {
if (raum.art === "gruppe") return raum.name || "Gruppe";
const andere = teilnehmer.filter((t) => t.id !== ichId);
return andere.map((t) => t.name).join(", ") || "Gespräch";
}
/* =====================================================================
Die Schnittstellen
===================================================================== */
/** Mit wem kann ich ein Gespräch anfangen? */
chatRouter.get("/workspace/api/chat/partner", (req, res) => {
try {
const ids = schreibbareIds(req.person);
const wo = ids === null
? "id <> ?" : `id IN (${ids.map(() => "?").join(",")})`;
const werte = ids === null ? [req.person.id] : ids;
if (ids !== null && !ids.length) return res.json({ personen: [] });
res.json({
personen: db().prepare(
`SELECT id, name, rolle FROM personen WHERE aktiv = 1 AND ${wo}
ORDER BY ` + ROLLEN_SORTIERUNG + ", name").all(...werte),
});
} catch (fehler) {
console.error("[chat] Partner:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Meine Gespräche, das jüngste zuerst. */
chatRouter.get("/workspace/api/chat/raeume", (req, res) => {
try {
const reihen = db().prepare(`
SELECT r.id, r.art, r.name, r.letzte_am, t.gelesen_bis, t.leitung
FROM chat_raeume r
JOIN chat_teilnehmer t ON t.raum_id = r.id AND t.person_id = ?
WHERE t.raus_am IS NULL
ORDER BY CASE WHEN r.letzte_am IS NULL THEN 1 ELSE 0 END, r.letzte_am DESC, r.id DESC`)
.all(req.person.id);
const raeume = reihen.map((r) => {
const leute = teilnehmerVon(r.id);
/* Die letzte Nachricht als Vorschau -- ohne sie ist eine
Gesprächsliste nur eine Namensliste. */
const letzte = db().prepare(`
SELECT n.text, n.erstellt, n.weg_am, p.name AS von
FROM chat_nachrichten n
LEFT JOIN personen p ON p.id = n.person_id
WHERE n.raum_id = ? ORDER BY n.id DESC LIMIT 1`).get(r.id);
const ungelesen = db().prepare(`
SELECT COUNT(*) AS n FROM chat_nachrichten
WHERE raum_id = ? AND id > ? AND person_id <> ?`)
.get(r.id, r.gelesen_bis, req.person.id).n;
return {
id: r.id,
art: r.art,
name: raumName(r, leute, req.person.id),
leitung: !!r.leitung,
teilnehmer: leute.map((t) => ({ id: t.id, name: t.name, rolle: t.rolle })),
letzte: letzte ? {
text: letzte.weg_am ? null : letzte.text.slice(0, 140),
zurueckgenommen: !!letzte.weg_am,
von: letzte.von, erstellt: letzte.erstellt,
} : null,
ungelesen,
};
});
res.json({ raeume });
} catch (fehler) {
console.error("[chat] Räume:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Ein Zweier-Gespräch öffnen (und beim ersten Mal anlegen). */
chatRouter.post("/workspace/api/chat/direkt", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => {
try {
const mit = Number(req.body?.mit);
if (!Number.isInteger(mit) || mit < 1) return res.status(400).json({ fehler: "ungueltig" });
/* DIE REGEL SITZT HIER, nicht in der Oberfläche. Wer die Anfrage
von Hand baut, kommt damit keinen Schritt weiter. */
if (!darfSchreibenMit(req.person, mit)) {
return res.status(403).json({ fehler: "Mit dieser Person kannst du nicht schreiben." });
}
const ziel = db().prepare("SELECT id FROM personen WHERE id = ? AND aktiv = 1").get(mit);
if (!ziel) return res.status(404).json({ fehler: "nicht_gefunden" });
const raumId = direktRaum(req.person, mit);
res.json({ raum_id: raumId });
} catch (fehler) {
console.error("[chat] Direkt:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Eine Gruppe anlegen. Wer sie anlegt, führt sie. */
chatRouter.post("/workspace/api/chat/gruppe", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => {
try {
const name = String(req.body?.name ?? "").trim();
if (name.length < 2) return res.status(400).json({ fehler: "Die Gruppe braucht einen Namen." });
if (name.length > NAME_MAX) return res.status(400).json({ fehler: "Der Name ist zu lang." });
const roh = Array.isArray(req.body?.mit) ? req.body.mit : [];
const mit = [...new Set(roh.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
.filter((id) => id !== req.person.id);
/* JEDE Nummer einzeln gegen die Regel -- eine Gruppe ist kein
Schlupfloch, um jemanden zu erreichen, den man einzeln nicht
anschreiben dürfte. */
const verboten = mit.filter((id) => !darfSchreibenMit(req.person, id));
if (verboten.length) {
return res.status(403).json({ fehler: "Mit mindestens einer Person kannst du nicht schreiben." });
}
if (!mit.length) return res.status(400).json({ fehler: "Wähle mindestens eine Person aus." });
const d = db();
const n = jetzt();
d.prepare("INSERT INTO chat_raeume (art, name, erstellt, erstellt_von) VALUES ('gruppe',?,?,?)")
.run(name, n, req.person.id);
const raumId = d.prepare("SELECT last_insert_rowid() AS id").get().id;
d.prepare("INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis, leitung) VALUES (?,?,?,0,1)")
.run(raumId, req.person.id, n);
const rein = d.prepare(
"INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis) VALUES (?,?,?,0)");
for (const id of mit) {
if (d.prepare("SELECT 1 FROM personen WHERE id = ? AND aktiv = 1").get(id)) rein.run(raumId, id, n);
}
protokolliere("chat_gruppe_angelegt", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${raumId} ${name} (${mit.length + 1})`.slice(0, 120),
});
res.status(201).json({ raum_id: raumId });
} catch (fehler) {
console.error("[chat] Gruppe:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Der Verlauf eines Gesprächs. */
chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
try {
const raumId = Number(req.params.id);
if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" });
/* Was man nicht sehen darf, existiert nicht -- 404 statt 403,
sonst liesse sich durch Ausprobieren herausfinden, welche
Gespräche es gibt. */
if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" });
/* Nur die letzten 200. Ein Gespräch, das ein Jahr läuft, soll den
Browser nicht mit zehntausend Zeilen bewerfen; ältere holt man
über `vor`. */
const vor = Number(req.query.vor) || 0;
/* MIT DEM ZITAT IN EINER ABFRAGE (09.09.2026, Punkt 15).
Der zweite Verbund holt die Nachricht, auf die geantwortet
wurde. Sie einzeln nachzuladen waere je Antwort eine weitere
Abfrage -- bei 200 Zeilen im schlimmsten Fall 200 Stueck.
`LEFT` und nicht `INNER`: Die meisten Nachrichten sind keine
Antworten, und die duerfen nicht verschwinden. */
const reihen = db().prepare(`
SELECT n.id, n.person_id, n.text, n.erstellt, n.weg_am, p.name AS von, p.rolle,
p.bild AS von_bild,
n.antwort_auf,
a.text AS zitat_text, a.weg_am AS zitat_weg, ap.name AS zitat_von
FROM chat_nachrichten n
LEFT JOIN personen p ON p.id = n.person_id
LEFT JOIN chat_nachrichten a ON a.id = n.antwort_auf AND a.raum_id = n.raum_id
LEFT JOIN personen ap ON ap.id = a.person_id
WHERE n.raum_id = ? ${vor ? "AND n.id < ?" : ""}
ORDER BY n.id DESC LIMIT 200`).all(...(vor ? [raumId, vor] : [raumId]));
/* DER GELESEN-STAND, BEVOR ER GESETZT WIRD.
Die Oberflaeche zieht daraus die Linie "ab hier ist es neu". Sie
muss ihn deshalb beim OEFFNEN bekommen -- eine Zeile spaeter
(nach dem PUT auf /gelesen) waere er immer die letzte Nachricht,
und die Linie staende nie irgendwo. */
const gelesenBis = db().prepare(
"SELECT gelesen_bis FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?")
.get(raumId, req.person.id)?.gelesen_bis ?? 0;
const raum = db().prepare("SELECT id, art, name FROM chat_raeume WHERE id = ?").get(raumId);
const leute = teilnehmerVon(raumId);
res.json({
raum: {
id: raum.id, art: raum.art,
name: raumName(raum, leute, req.person.id),
teilnehmer: leute.map((t) => ({ id: t.id, name: t.name, rolle: t.rolle,
bild: t.bild, leitung: !!t.leitung })),
},
gelesen_bis: gelesenBis,
/* Aufsteigend zurück -- gelesen wird von oben nach unten. */
nachrichten: reihen.reverse().map((n) => ({
id: n.id, von_id: n.person_id, von: n.von || "Gelöscht", rolle: n.rolle,
von_bild: n.von_bild ? `/workspace/api/steckbrief/bild/${n.von_bild}` : null,
text: n.weg_am ? null : n.text,
zurueckgenommen: !!n.weg_am,
erstellt: n.erstellt,
selbst: n.person_id === req.person.id,
/* Das Zitat wird GEKUERZT geschickt, nicht ganz: Es ist ein
Hinweis auf die Stelle, nicht ihre Wiederholung. Und eine
zurueckgenommene Nachricht bleibt auch im Zitat
zurueckgenommen -- sonst waere das Zuruecknehmen ueber ein
Zitat zu umgehen. */
antwort: n.antwort_auf ? {
id: n.antwort_auf,
von: n.zitat_von || "Gelöscht",
text: n.zitat_weg ? null : String(n.zitat_text || "").slice(0, 140),
zurueckgenommen: !!n.zitat_weg,
} : null,
})),
});
} catch (fehler) {
console.error("[chat] Verlauf:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Etwas schreiben. */
chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft,
express.json({ limit: "16kb" }), async (req, res) => {
try {
const raumId = Number(req.params.id);
if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" });
if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" });
const text = String(req.body?.text ?? "").trim();
if (!text) return res.status(400).json({ fehler: "leer" });
if (text.length > TEXT_MAX) {
return res.status(400).json({ fehler: `Länger als ${TEXT_MAX} Zeichen geht nicht.` });
}
/* WORAUF GEANTWORTET WIRD, MUSS IN DEMSELBEN RAUM LIEGEN.
Ohne diese Pruefung koennte jemand die Kennung einer Nachricht
aus einem fremden Gespraech mitschicken -- und beim Empfaenger
stuende ein Zitat aus einem Raum, den er nie gesehen hat. Die
Abfrage bindet deshalb `raum_id` mit ein; passt es nicht, wird
das Zitat still weggelassen statt die Nachricht abzulehnen.
Der Text ist das Wichtige, das Zitat die Zugabe. */
let antwortAuf = Number(req.body?.antwort_auf) || null;
if (antwortAuf) {
const da = db().prepare(
"SELECT 1 FROM chat_nachrichten WHERE id = ? AND raum_id = ?").get(antwortAuf, raumId);
if (!da) antwortAuf = null;
}
const d = db();
const n = jetzt();
d.prepare("INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt, antwort_auf) VALUES (?,?,?,?,?)")
.run(raumId, req.person.id, text, n, antwortAuf);
const id = d.prepare("SELECT last_insert_rowid() AS id").get().id;
d.prepare("UPDATE chat_raeume SET letzte_am = ? WHERE id = ?").run(n, raumId);
/* Wer schreibt, hat das Eigene gelesen. */
d.prepare("UPDATE chat_teilnehmer SET gelesen_bis = ? WHERE raum_id = ? AND person_id = ?")
.run(id, raumId, req.person.id);
const zitat = antwortAuf ? d.prepare(`
SELECT a.text, a.weg_am, p.name AS von FROM chat_nachrichten a
LEFT JOIN personen p ON p.id = a.person_id WHERE a.id = ?`).get(antwortAuf) : null;
const nachricht = {
id, raum_id: raumId, von_id: req.person.id, von: req.person.name,
rolle: req.person.rolle, text, erstellt: n, zurueckgenommen: false,
antwort: zitat ? {
id: antwortAuf, von: zitat.von || "Gelöscht",
text: zitat.weg_am ? null : String(zitat.text || "").slice(0, 140),
zurueckgenommen: !!zitat.weg_am,
} : null,
};
/* 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));
res.status(201).json({ nachricht });
} catch (fehler) {
console.error("[chat] Schreiben:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* =====================================================================
SUCHE (09.09.2026, Punkt 15)
Filipe: "ich will dass du diese seite viel krasser und detaillierter
machst ... alles reinsetzt was wir noch gebrauchen koennten."
Ein Chat ohne Suche ist ab dem zweiten Monat ein Archiv, in dem man
nichts findet. Das ist die Funktion, die am haeufigsten fehlt und am
meisten Zeit spart -- vor Reaktionen, vor Anhaengen, vor allem
anderen.
DIE EINGRENZUNG AUF DIE EIGENEN RAEUME IST DIE GANZE SICHERHEIT
DIESER FUNKTION. Sie steht deshalb im JOIN und nicht in einer
nachtraeglichen Pruefung: `chat_teilnehmer` wird mit der eigenen
Personenkennung verbunden, und was dort nicht drinsteht, kommt gar
nicht erst aus der Datenbank. Ein Filter, der erst hinterher
aussortiert, ist eine Zeile davon entfernt, vergessen zu werden.
OHNE VOLLTEXTINDEX, MIT ABSICHT. `LIKE '%...%'` kann keinen Index
benutzen und liest die Tabelle. Bei einem Team dieser Groesse sind
das einige tausend Zeilen -- gemessen unter einer Millisekunde. Ein
FTS5-Index waere eine zweite Tabelle, die synchron gehalten werden
muss, und genau daran gehen solche Sachen kaputt. Wenn der Verlauf
einmal sechsstellig wird, ist das der richtige Zeitpunkt dafuer,
nicht heute.
`escape` FUER DIE PLATZHALTER: In `LIKE` sind `%` und `_` Zeichen
mit Bedeutung. Wer nach "100 %" sucht, wuerde sonst alles finden.
===================================================================== */
chatRouter.get("/workspace/api/chat/suche", (req, res) => {
try {
const roh = String(req.query.q ?? "").trim();
if (roh.length < 2) return res.json({ frage: roh, treffer: [], zuKurz: true });
if (roh.length > 120) return res.status(400).json({ fehler: "zu_lang" });
/* AUCH DER BACKSLASH SELBST. Er ist hier das Fluchtzeichen
(`ESCAPE '\'`); wer ihn eingibt, wuerde sonst das naechste
Zeichen entwerten und im Zweifel ein ungueltiges Muster erzeugen.
Drei Zeichen haben in `LIKE` eine Bedeutung, nicht zwei. */
const muster = "%" + roh.replace(/[\\%_]/g, (z) => "\\" + z) + "%";
const reihen = db().prepare(`
SELECT n.id, n.raum_id, n.text, n.erstellt,
p.name AS von, p.rolle,
r.art, r.name AS raum_name
FROM chat_nachrichten n
JOIN chat_teilnehmer t ON t.raum_id = n.raum_id AND t.person_id = ?
JOIN chat_raeume r ON r.id = n.raum_id
LEFT JOIN personen p ON p.id = n.person_id
WHERE n.weg_am IS NULL
AND n.text LIKE ? ESCAPE '\\'
ORDER BY n.id DESC LIMIT 60`).all(req.person.id, muster);
res.json({
frage: roh,
treffer: reihen.map((n) => {
const leute = teilnehmerVon(n.raum_id);
return {
id: n.id, raum_id: n.raum_id,
raum: raumName({ id: n.raum_id, art: n.art, name: n.raum_name }, leute, req.person.id),
von: n.von || "Gelöscht", rolle: n.rolle,
erstellt: n.erstellt,
/* Der Ausschnitt wird um den Treffer herum geschnitten, nicht
vom Anfang: Bei einer langen Nachricht stuende sonst der
Anfang da und das gesuchte Wort waere nicht dabei. */
text: ausschnitt(n.text, roh),
};
}),
});
} catch (fehler) {
console.error("[chat] Suche:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Ein Stueck Text um die Fundstelle herum. */
function ausschnitt(text, frage) {
const t = String(text || "");
const i = t.toLowerCase().indexOf(frage.toLowerCase());
if (i < 0 || t.length <= 160) return t.slice(0, 160);
const von = Math.max(0, i - 50);
const bis = Math.min(t.length, i + frage.length + 90);
return (von > 0 ? "… " : "") + t.slice(von, bis) + (bis < t.length ? " …" : "");
}
/** Bis hierher gelesen. */
chatRouter.put("/workspace/api/chat/raeume/:id/gelesen", gleicheHerkunft,
express.json({ limit: "4kb" }), (req, res) => {
try {
const raumId = Number(req.params.id);
const bis = Number(req.body?.bis);
if (!Number.isInteger(raumId) || !Number.isInteger(bis)) {
return res.status(400).json({ fehler: "ungueltig" });
}
if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" });
/* 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);
res.json({ ok: true });
} catch (fehler) {
console.error("[chat] Gelesen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Eine eigene Nachricht zurücknehmen. */
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("SELECT id, raum_id, person_id FROM chat_nachrichten WHERE id = ?").get(id);
if (!n || !istDrin(n.raum_id, req.person.id)) {
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) {
return res.status(403).json({ fehler: "Nur eigene Nachrichten." });
}
db().prepare("UPDATE chat_nachrichten SET text = '', weg_am = ? WHERE id = ?").run(jetzt(), id);
const empfaenger = teilnehmerVon(n.raum_id).map((t) => t.id);
chatEreignis(n.raum_id, { id, raum_id: n.raum_id, zurueckgenommen: true }, [], "weg");
protokolliere("chat_zurueckgenommen", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${id}`,
});
res.json({ ok: true, empfaenger: empfaenger.length });
} catch (fehler) {
console.error("[chat] Zurücknehmen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* =====================================================================
Sofort zusehen (Server-Sent Events)
=====================================================================
WARUM SSE UND NICHT NACHFRAGEN IM TAKT: Ein Chat, der alle fünf
Sekunden nachfragt, ist fünf Sekunden langsam und stellt bei zehn
Angemeldeten 7200 Anfragen in der Stunde -- für Nachrichten, die es
meistens nicht gibt.
WARUM SSE UND NICHT WEBSOCKET: Hier fliesst alles in EINE Richtung,
vom Server zum Browser. Geschrieben wird über die normale
Schnittstelle. SSE ist dafür gebaut, kommt ohne fremde Bibliothek
aus und übersteht einen Verbindungsabriss von selbst -- der Browser
verbindet sich neu, ohne dass man dafür Code schreibt.
Der Browser holt sich nach einem Abriss die Lücke über den normalen
Verlauf nach; deshalb ist hier keine Nachrichtenwarteschlange nötig. */
/** Wer sieht gerade zu: Personennummer → Menge offener Verbindungen.
* Eine Person kann an zwei Geräten sitzen. */
const zuschauer = new Map();
function chatEreignis(raumId, nachricht, benachrichtigen, art = "neu") {
/* 1. An alle offenen Verbindungen der Beteiligten. */
const leute = teilnehmerVon(raumId).map((t) => t.id);
for (const personId of leute) {
for (const antwort of zuschauer.get(personId) || []) {
try {
antwort.write(`event: ${art}\ndata: ${JSON.stringify(nachricht)}\n\n`);
} catch { /* die Verbindung räumt sich selbst ab */ }
}
}
/* 2. Eine Benachrichtigung an die, die NICHT zusehen. Wer die Seite
offen hat, sieht die Nachricht ohnehin -- ihm auch noch eine
Meldung aufs Handy zu schicken, ist der schnellste Weg, dass er
Benachrichtigungen abschaltet. */
for (const personId of benachrichtigen) {
if ((zuschauer.get(personId) || []).length) continue;
void meldeNeueNachricht(personId, raumId, nachricht);
}
}
/** Die Push-Benachrichtigung. Absichtlich träge geladen: Der Chat soll
* auch dann laufen, wenn am Push-Teil etwas klemmt. */
async function meldeNeueNachricht(personId, raumId, nachricht) {
try {
const { benachrichtige } = await import("./workspace-push.js");
await benachrichtige(personId, "chat_nachricht", {
titel: `Nachricht von ${nachricht.von}`,
/* 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. */
text: String(nachricht.text || "").slice(0, 120),
ziel: `/workspace/chat.html?raum=${raumId}`,
});
} catch (fehler) {
console.error("[chat] Benachrichtigung:", fehler?.message);
}
}
chatRouter.get("/workspace/api/chat/strom", (req, res) => {
res.set({
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
/* Ohne das puffert ein davorliegender Reverse Proxy (Caddy, nginx)
den Strom -- und dann kommt die Nachricht erst, wenn genug
zusammen ist. Genau das Gegenteil des Zwecks. */
"X-Accel-Buffering": "no",
});
res.flushHeaders?.();
res.write("retry: 3000\n\n");
const personId = req.person.id;
if (!zuschauer.has(personId)) zuschauer.set(personId, new Set());
zuschauer.get(personId).add(res);
/* Ein Lebenszeichen alle 25 Sekunden. Ohne das schliessen manche
Vermittlungsstellen die Verbindung nach einer Minute Stille -- der
Browser verbindet sich dann zwar neu, aber jedes Mal mit einer
Lücke von ein paar Sekunden. */
const puls = setInterval(() => {
try { res.write(": puls\n\n"); } catch { /* gleich weg */ }
}, 25_000);
puls.unref?.();
const aufraeumen = () => {
clearInterval(puls);
const menge = zuschauer.get(personId);
if (menge) {
menge.delete(res);
if (!menge.size) zuschauer.delete(personId);
}
};
req.on("close", aufraeumen);
req.on("error", aufraeumen);
});
/** Wie viele Nachrichten warten insgesamt auf mich?
* Für die Zahl in der Kopfleiste -- eine Abfrage statt einer je Raum. */
chatRouter.get("/workspace/api/chat/ungelesen", (req, res) => {
try {
const z = db().prepare(`
SELECT COUNT(*) AS n FROM chat_nachrichten m
JOIN chat_teilnehmer t ON t.raum_id = m.raum_id AND t.person_id = ?
WHERE t.raus_am IS NULL AND m.id > t.gelesen_bis AND m.person_id <> ?`)
.get(req.person.id, req.person.id);
res.json({ ungelesen: z?.n || 0 });
} catch (fehler) {
console.error("[chat] Ungelesen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* Für Prüfungen und andere Module: Sieht diese Person gerade zu? */
export function siehtZu(personId) {
return (zuschauer.get(Number(personId)) || []).size > 0;
}