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]>
This commit is contained in:
2026-09-09 02:18:59 +02:00
co-authored by Claude Opus 5
parent e616d88a30
commit 7de32a5ec7
23 changed files with 725 additions and 197 deletions
+145 -3
View File
@@ -300,14 +300,36 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
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
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);
@@ -318,6 +340,7 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
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,
@@ -326,6 +349,17 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
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) {
@@ -348,19 +382,43 @@ chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft,
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) VALUES (?,?,?,?)")
.run(raumId, req.person.id, text, n);
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
@@ -376,6 +434,90 @@ chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft,
}
});
/* =====================================================================
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) => {