Files
dogfather-universe/server/workspace-chat.js
T
DogFatherGitandClaude Opus 5 26937dda16 Ein klingelndes Telefon haengt nicht mehr an einem einzigen Kanal
Filipe: "wenn vanvan rangeht und redet klingelt es immer noch bei mir
weiter, der anruf verbindet nicht richtig."

=== WAS DAS PROTOKOLL SAGT ===

    17:04:52  anruf_start  Person 1 (Filipe)
    17:05:25  anruf_ende   Person 4 (VanVan)  24 s
    17:05:31  anruf_ende   Person 1 (Filipe)  39 s

Sie WAR im Gespraech -- der Server hat sie 24 Sekunden als
Teilnehmerin gefuehrt. Das Ereignis "dabei" ist also verschickt
worden. Bei Filipe kam es nicht an: `tonAus()` ist das Erste im
`dabei`-Zweig, noch vor jeder Pruefung, und das Tuten lief weiter.

=== GEPRUEFT UND AUSGESCHLOSSEN ===

  Raumzugehoerigkeit   beide in Raum 1, bei keinem `raus_am` gesetzt
  Verkabelung          Server sendet mit `art: "anruf"`, chat.js reicht
                       an window.anrufEreignis weiter, anruf.js nimmt
                       entgegen -- alle drei Stellen stimmen
  Tonsteuerung         ein einziger Taktgeber, `tonAus` raeumt ihn;
                       kein zweiter Weg, der ihn neu startet
  Ereignisstrom        Keep-alive vorhanden, Kopfzeilen richtig
                       (no-transform, X-Accel-Buffering: no)
  Service Worker       hat gar keinen fetch-Handler, kann also kein
                       altes Skript ausliefern
  teilnehmerVon vs.
  teilnehmerFuerAnruf  reicht nur durch, dieselbe Abfrage

Es geht unterwegs verloren, auf einem Weg, der von hier aus nicht
messbar ist: Ereignisstrom ueber Cloudflare, ein schlafender Reiter,
ein Neustart im falschen Moment.

=== ALSO NICHT WEITERSUCHEN, SONDERN DIE ABHAENGIGKEIT BESEITIGEN ===

Ein klingelndes Telefon darf nicht an einem einzigen, zerbrechlichen
Kanal haengen. Solange es klingelt, fragt der Anrufer jetzt SELBST
nach: "ist schon jemand dran?" -- alle zwei Sekunden an
`/workspace/api/anruf/:raum`, das es laengst gibt.

Der Ereignisstrom bleibt der erste Weg, er ist schneller. Das hier ist
das Netz darunter. Kommt das Ereignis an, hat die Nachfrage nichts
mehr zu tun und haelt von selbst an (sie prueft `anruf.beginn` und die
bekannten Teilnehmer).

Sie hoert an JEDEM Ende auf: beim Auflegen, wenn die Verbindung steht,
wenn das Ereignis doch ankommt, wenn der Anruf vorbei ist. Eine
Schleife, die weiterlaeuft, fragt sonst auf jedem Geraet, das je
telefoniert hat, alle zwei Sekunden nach einem Anruf, den es nicht
mehr gibt.

Alle zwei Sekunden und nicht jede halbe: Es klingelt hoechstens zwei
Minuten, das sind sechzig Abrufe.

=== ZWEI DINGE, DIE DIESE SUCHE ERST SO MUEHSAM GEMACHT HABEN ===

DAS PROTOKOLL KANNTE ANFANG UND ENDE, ABER NICHT DEN MOMENT DAZWISCHEN.
Die wichtigste Frage -- "ist sie ueberhaupt rangegangen?" -- war nur
ueber einen Umweg zu beantworten (ein `anruf_ende` mit ihrer Nummer).
Das ist eine Schlussfolgerung, keine Auskunft. `anruf_dabei` steht
jetzt drin, mit der Zahl der Beteiligten.

UND EINE PRUEFUNG WAR GRUEN, OHNE ETWAS ZU PRUEFEN. In chatEreignis
stand `(zuschauer.get(personId) || []).length` -- `zuschauer` haelt
aber Mengen, und eine Menge hat kein `length`. Der Ausdruck war IMMER
undefined, also immer falsch, also wurde nie uebersprungen: Wer die
Seite offen hatte, bekam zusaetzlich zur Nachricht auf dem Bildschirm
noch eine Meldung aufs Handy.

Der Kommentar drei Zeilen darueber warnt woertlich davor ("der
schnellste Weg, dass er Benachrichtigungen abschaltet"), und
`siehtZu()` weiter unten macht es mit `.size` richtig. Die Absicht
stand da, die Zeile tat das Gegenteil.

Meine eigene Pruefung hat das mitgetragen: Sie bestaetigte den alten
WORTLAUT statt sein VERHALTEN und war deshalb gruen. Genau die
Hausregel vom 01.09. -- ein gruener Haken sagt nur, dass die Bedingung
erfuellt war, nicht dass sie das Richtige geprueft hat. Jetzt prueft
sie auf `.size`.

GEMESSEN: pruef-anruf-klingelt 24/0 (vorher 17), pruef-anruf 114/0,
pruef-turn-wege 15/0, pruef-meldungen 8/0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-19 19:15:51 +02:00

2152 lines
97 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
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 { randomBytes } from "node:crypto";
import { join, extname, basename } from "node:path";
import { mkdirSync, writeFileSync, createReadStream, statSync, unlinkSync } from "node:fs";
import {
db, protokolliere, echteIp, sitzungLesen, istDogFather,
schreibbareIds, darfSchreibenMit, ROLLEN_SORTIERUNG, DATEN_ORDNER,
fuehrtTeamDogi, MODI_KATEGORIEN, ROLLEN_GRUPPE,
} from "./workspace.js";
import { KATALOG, ERLAUBT, VORSCHLAG, FAVORITEN_MAX } from "./workspace-reaktionen.js";
/* Der Treff-Chat: eigener Raum, eigene Nachtruhe, keine Anrufe.
Eigene Datei, weil er eigene Regeln hat -- und weil diese hier
mit 2000 Zeilen schon gross genug ist. */
import {
TREFF_KATEGORIE, TREFF_NAME, istTreffRaum, treffAngleichen,
gehoertInTreff, nachtruheStand, istNachtruhe, darfTelefonieren,
} from "./workspace-treffchat.js";
export const chatRouter = express.Router();
const TEXT_MAX = 4000;
const NAME_MAX = 80;
/* ---------- Anhänge ------------------------------------------------------
Derselbe Weg wie in der Dateiablage, und aus denselben Gründen:
Der Browser schickt die Datei roh im Rumpf, der Name steht im Kopf.
express.raw() nimmt das entgegen -- multipart/form-data von Hand zu
zerlegen wäre an dieser Stelle die gefährlichere Übung.
12 MB und nicht 25 wie in der Ablage: Ein Chat sammelt viele kleine
Dateien, kein Archiv grosser. Ein Handyfoto liegt bei 2 bis 5 MB,
ein PDF mit Bildern selten über 10. */
const ANHANG_MAX = 12 * 1024 * 1024;
const ANHANG_ORDNER = join(DATEN_ORDNER, "chat-anhaenge");
const ANHANG_NAME_MAX = 200;
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. */
/* SIE NIMMT DIE PERSON, NICHT IHRE NUMMER (10.09.2026, Kapitel 7.2).
*
* Seit es Kanaele gibt, haengt die Antwort auch an der ROLLE. Eine
* Nummer allein koennte die Frage nicht mehr beantworten -- und haette
* sie trotzdem beantwortet, mit "nein", ohne ein Wort dazu. Deshalb
* wirft ein Aufruf mit einer Nummer einen Fehler statt weiterzulaufen:
* Er faellt beim ersten Durchgang auf, nicht im Betrieb. */
function istDrin(raumId, person) {
if (!person || typeof person !== "object") {
throw new TypeError("istDrin() braucht die Person, nicht ihre Nummer.");
}
try {
const z = db().prepare(
"SELECT 1 FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ? AND raus_am IS NULL")
.get(raumId, person.id);
if (z) return true;
/* KANAELE STEHEN DER TEAMFUEHRUNG OFFEN -- Kapitel 7.2: "Owner/
rechte Hand haben Zugriff auf alle Kanaele."
DIE AUSNAHME STEHT HIER und nicht in den sieben Routen darueber.
Dort waere sie in sechs davon gelandet und in der siebten nicht,
und die siebte waere die gewesen, die keiner ausprobiert.
SIE GILT NUR FUER 'kanal'. Zweier-Gespraeche bleiben zu, auch
fuer DogFather -- Kapitel 7.2 sagt das ausdruecklich: "Private
1:1-Chats: ausschliesslich fuer die beteiligten Personen
sichtbar - kein Zugriff durch Owner/rechte Hand." Und Gruppen
bleiben ebenfalls zu: Wer eine Gruppe aufmacht, hat sich fuer
einen geschlossenen Kreis entschieden, und den nachtraeglich
still zu oeffnen waere das Gegenteil dessen, was er getan hat.
Ein Kanal ist etwas anderes: Er gehoert dem Team, nicht den
Anwesenden. */
if (!fuehrtTeamDogi(person)) return false;
const raum = db().prepare("SELECT art FROM chat_raeume WHERE id = ?").get(raumId);
return raum?.art === "kanal";
} 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;
}
/* =====================================================================
WAS IST DAS WIRKLICH? — die Erkennung an den ersten Bytes
DIE GANZE SICHERHEIT DER ANHÄNGE HÄNGT AN DIESER FUNKTION.
Der Dateiname kommt vom Absender. Der Content-Type kommt vom
Absender. Beide kann man frei erfinden. Wer sich auf sie verlässt,
liefert irgendwann eine HTML- oder SVG-Datei mit der Endung .png
aus -- und die läuft dann im Browser als Seite DIESER Domain, mit
Zugriff auf die Sitzung des Empfängers. Das ist der Standardfehler
bei selbst gebauten Uploads, und er ist von aussen nicht zu sehen.
Deshalb: Der Typ wird aus dem Inhalt bestimmt. Was hier nicht
erkannt wird, kommt nicht herein -- und ausgeliefert wird später
genau der Typ, der HIER festgestellt wurde, nie der eingeschickte.
SVG STEHT ABSICHTLICH NICHT IN DER LISTE. Eine SVG-Datei ist ein
XML-Dokument und darf Skripte enthalten; sie ist als "Bild"
getarnter Programmtext. Fotos sind PNG, JPEG, WebP oder GIF.
Breite und Höhe kommen im selben Durchgang mit: Der Verlauf
reserviert damit den Platz, bevor das Bild da ist, und springt beim
Laden nicht. Lassen sie sich nicht ablesen, ist das kein Grund zur
Ablehnung -- dann steht eben 0, und der Platz wird geschätzt.
===================================================================== */
function dateiErkennen(b) {
if (!Buffer.isBuffer(b) || b.length < 16) return null;
const kopf = (von, bis) => b.toString("latin1", von, bis);
/* --- PNG: acht feste Bytes, danach muss IHDR kommen. -------------- */
if (b[0] === 0x89 && kopf(1, 4) === "PNG" && b[4] === 0x0d && b[5] === 0x0a
&& b[6] === 0x1a && b[7] === 0x0a) {
if (b.length >= 24 && kopf(12, 16) === "IHDR") {
return { art: "bild", typ: "image/png", endung: ".png",
breite: b.readUInt32BE(16), hoehe: b.readUInt32BE(20) };
}
return null; /* PNG-Kopf ohne IHDR ist keins */
}
/* --- GIF ---------------------------------------------------------- */
if (kopf(0, 6) === "GIF87a" || kopf(0, 6) === "GIF89a") {
return { art: "bild", typ: "image/gif", endung: ".gif",
breite: b.readUInt16LE(6), hoehe: b.readUInt16LE(8) };
}
/* --- WebP: RIFF-Behälter mit der Kennung WEBP ---------------------- */
if (kopf(0, 4) === "RIFF" && kopf(8, 12) === "WEBP") {
let breite = 0, hoehe = 0;
const sorte = kopf(12, 16);
try {
if (sorte === "VP8 " && b.length > 30 && b[23] === 0x9d && b[24] === 0x01 && b[25] === 0x2a) {
breite = b.readUInt16LE(26) & 0x3fff;
hoehe = b.readUInt16LE(28) & 0x3fff;
} else if (sorte === "VP8L" && b.length > 25 && b[20] === 0x2f) {
/* Vier Bytes, aus denen zweimal 14 Bit gelesen werden. */
const w = b.readUInt32LE(21);
breite = (w & 0x3fff) + 1;
hoehe = ((w >> 14) & 0x3fff) + 1;
} else if (sorte === "VP8X" && b.length > 30) {
breite = (b[24] | (b[25] << 8) | (b[26] << 16)) + 1;
hoehe = (b[27] | (b[28] << 8) | (b[29] << 16)) + 1;
}
} catch { breite = 0; hoehe = 0; }
return { art: "bild", typ: "image/webp", endung: ".webp", breite, hoehe };
}
/* --- JPEG: SOI, danach muss ein weiterer Marker folgen. ------------ */
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {
let breite = 0, hoehe = 0;
let i = 2;
/* Durch die Abschnitte springen, bis ein Bildkopf (SOF) kommt.
0xc4/0xc8/0xcc sind KEINE Bildköpfe (Huffman-Tabellen u. ä.) --
wer die mitnimmt, liest dort Unsinn als Maße. */
while (i + 9 < b.length) {
if (b[i] !== 0xff) { i++; continue; }
const m = b[i + 1];
if (m === 0xff) { i++; continue; } /* Füllbyte */
if (m === 0xd8 || m === 0x01 || (m >= 0xd0 && m <= 0xd7)) { i += 2; continue; }
if (m === 0xda) break; /* ab hier Bilddaten */
const laenge = b.readUInt16BE(i + 2);
if (laenge < 2) break;
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) {
hoehe = b.readUInt16BE(i + 5);
breite = b.readUInt16BE(i + 7);
break;
}
i += 2 + laenge;
}
return { art: "bild", typ: "image/jpeg", endung: ".jpg", breite, hoehe };
}
/* --- PDF ---------------------------------------------------------- */
if (kopf(0, 5) === "%PDF-") {
return { art: "pdf", typ: "application/pdf", endung: ".pdf", breite: 0, hoehe: 0 };
}
return null;
}
/** Der Anzeigename. Er steht nur in der Datenbank -- auf der Platte
* bekommt jede Datei einen erzeugten Zufallsnamen, damit ein Name mit
* "../" darin nirgendwo hinkommt. */
function anhangName(roh, endung) {
let name;
try { name = decodeURIComponent(roh || ""); } catch { name = String(roh || ""); }
name = basename(name).replace(/[\x00-\x1f<>:"/\\|?*]/g, "_").trim();
name = name.slice(0, ANHANG_NAME_MAX);
if (!name) name = "anhang" + endung;
return name;
}
/** Bis zu welcher Nachricht hat DIESE Person das Gespräch weggeräumt?
* 0 heisst: gar nicht. Steht an einer Stelle, damit keine Abfrage sie
* vergisst -- eine zweite Fassung wäre ein Gespräch, das in der Liste
* weg ist und in der Suche noch auftaucht. */
function geloeschtBis(raumId, personId) {
try {
return db().prepare(
"SELECT geloescht_bis FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?")
.get(raumId, personId)?.geloescht_bis ?? 0;
} catch { return 0; }
}
/** 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. */
/* =====================================================================
KATEGORIE-KANAELE (10.09.2026, Kapitel 7.2)
===================================================================== */
/** Hoechstens drei Ankuendigungen je Raum.
*
* Nicht eine (dann kann man Regeln, Live-Plan und Frist nicht
* gleichzeitig oben haben) und nicht beliebig viele: Eine Pinnwand,
* die scrollt, ist keine Pinnwand mehr, sondern ein zweiter Verlauf.
* Wer die vierte anheften will, muss sich fuer eine entscheiden --
* und genau diese Entscheidung ist der Sinn der Funktion. */
const PIN_MAX = 3;
/** Der Name eines Kanals kommt aus der Kategorie, nicht aus einem Feld.
*
* Ein freies Namensfeld waere die naheliegende Freiheit -- und der
* sichere Weg zu "Clipping" neben "Clipping-Team", mit je der Haelfte
* der Nachrichten darin. Wer die Kategorie umbenennt, benennt damit
* jeden Kanal um; sie stehen nicht doppelt. */
function kanalName(kategorie) {
/* Der Treff steht nicht in MODI_KATEGORIEN -- er ist kein Thema des
Teams, sondern ein eigener Raum mit eigenen Regeln. Ohne diese
Zeile hiesse er schlicht "Kanal". */
if (kategorie === TREFF_KATEGORIE) return TREFF_NAME;
return MODI_KATEGORIEN.find((k) => k.wert === kategorie)?.name || "Kanal";
}
/** Wer darf hier etwas anheften?
*
* Die Teamfuehrung ueberall -- so steht es im Dokument. UND wer eine
* Gruppe angelegt hat, in seiner eigenen: Der Chat gehoert nicht nur
* Team Dogi, auch Manager und Scouts fuehren Gruppen. Waere das
* Anheften allein der Teamfuehrung vorbehalten, gaebe es die Funktion
* fuer die Haelfte des Hauses nicht -- und sie waere trotzdem
* sichtbar. */
function darfAnheften(person, raumId) {
if (fuehrtTeamDogi(person)) return true;
try {
return !!db().prepare(`SELECT 1 FROM chat_teilnehmer
WHERE raum_id = ? AND person_id = ? AND raus_am IS NULL AND leitung = 1`)
.get(raumId, person.id);
} catch { return false; }
}
/** Traegt die Teamfuehrung in jeden Kanal ein, in dem sie fehlt.
*
* WARUM SELBSTHEILEND UND NICHT AN DREI HAKEN: Die Zeile muesste sonst
* beim Anlegen eines Kanals, beim Anlegen einer Person und beim
* Rollenwechsel entstehen -- drei Stellen, von denen die dritte beim
* naechsten Umbau vergessen wird. Hier laeuft es genau dann, wenn es
* gebraucht wird: beim Blick in die Gespraechsliste, und nur fuer
* zwei Rollen.
*
* `gelesen_bis` STARTET AM AKTUELLEN STAND, nicht bei null. Wer heute
* dazukommt, hat die vierhundert Nachrichten von vorher nicht
* verpasst -- eine rote Vierhundert waere keine Auskunft, sondern
* Rauschen, und man schaltet sie ab statt sie zu lesen. */
function kanaeleAngleichen(person) {
if (!fuehrtTeamDogi(person)) return 0;
try {
const d = db();
const offen = d.prepare(`
SELECT r.id FROM chat_raeume r
WHERE r.art = 'kanal'
AND NOT EXISTS (SELECT 1 FROM chat_teilnehmer t
WHERE t.raum_id = r.id AND t.person_id = ?)`).all(person.id);
if (!offen.length) return 0;
const rein = d.prepare(`
INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis, leitung)
SELECT ?, ?, ?, COALESCE((SELECT MAX(id) FROM chat_nachrichten WHERE raum_id = ?), 0), 1`);
const n = jetzt();
for (const r of offen) rein.run(r.id, person.id, n, r.id);
return offen.length;
} catch (fehler) {
/* Ein Fehler hier darf die Gespraechsliste nicht kosten. Der
Zugriff selbst haengt ohnehin nicht daran -- istDrin() laesst die
Teamfuehrung auch ohne Zeile in einen Kanal. */
console.error("[chat] Kanal-Abgleich:", fehler?.message);
return 0;
}
}
function raumName(raum, teilnehmer, ichId) {
if (raum.art === "kanal") return raum.name || kanalName(raum.kategorie);
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: [] });
/* DIE UEBERSCHRIFT KOMMT MIT (10.09.2026).
Die Auswahl im Browser zeichnete nach der Rollenfolge aus
bereiche.js -- und wer dort nicht steht, wurde nicht gezeichnet.
Still: keine Luecke, kein Fehler, nur ein Mensch, den es fuer
die Auswahl nicht gab. Mit einem Text vom Server kann der
Browser auch eine Rolle zeigen, deren Namen er gar nicht kennen
darf. */
res.json({
personen: db().prepare(
`SELECT id, name, rolle FROM personen WHERE aktiv = 1 AND ${wo}
ORDER BY ` + ROLLEN_SORTIERUNG + ", name").all(...werte)
.map((p) => ({ ...p, gruppe: ROLLEN_GRUPPE[p.rolle] || "Weitere" })),
});
} 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 {
/* WEGGERÄUMTE GESPRÄCHE STEHEN NICHT IN DER LISTE.
Die Bedingung sitzt hier in der Abfrage und nicht in einem
Filter danach: Was nicht herauskommt, kann auch niemand
versehentlich wieder hineinreichen.
Zwei Fälle, und beide müssen stimmen:
* Es kam etwas Neues -> das Gespräch ist wieder da, aber nur
mit dem Neuen (id > geloescht_bis).
* Es kam nichts -> es bleibt weg. Auch dann, wenn es nie eine
Nachricht gab; dafür steht geloescht_am. */
/* ERST ANGLEICHEN, DANN LESEN. Ein Kanal, in dem die Teamfuehrung
keine Zeile hat, waere ihr zugaenglich (istDrin laesst sie
hinein) und stuende trotzdem nicht in ihrer Liste -- erreichbar
nur ueber einen Link, den ihr jemand schickt. Das ist genau die
Sorte Loch, die man nicht bemerkt, weil nichts kaputt aussieht. */
kanaeleAngleichen(req.person);
/* UND DER TREFF. `kanaeleAngleichen` traegt nur die Teamfuehrung
ein; im Treff stehen ausserdem die Community und die Modis.
Derselbe Gedanke, andere Personengruppe -- und aus demselben
Grund selbstheilend: Ein neuer Zugang aus der Community soll im
Chat stehen, ohne dass jemand daran denken muss. */
if (gehoertInTreff(req.person)) treffAngleichen();
const reihen = db().prepare(`
SELECT r.id, r.art, r.name, r.kategorie, r.letzte_am, r.erstellt_von,
t.gelesen_bis, t.leitung, t.geloescht_bis
FROM chat_raeume r
JOIN chat_teilnehmer t ON t.raum_id = r.id AND t.person_id = ?
WHERE t.raus_am IS NULL
AND (t.geloescht_am IS NULL
OR EXISTS (SELECT 1 FROM chat_nachrichten n
WHERE n.raum_id = r.id AND n.id > t.geloescht_bis))
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. Sie muss dieselbe Grenze
achten wie der Verlauf: Sonst stünde in der Vorschau ein Satz,
den man beim Öffnen nirgends findet. */
const letzte = db().prepare(`
SELECT n.text, n.erstellt, n.weg_am, n.anhang_art, p.name AS von
FROM chat_nachrichten n
LEFT JOIN personen p ON p.id = n.person_id
WHERE n.raum_id = ? AND n.id > ? ORDER BY n.id DESC LIMIT 1`)
.get(r.id, r.geloescht_bis);
const ungelesen = db().prepare(`
SELECT COUNT(*) AS n FROM chat_nachrichten
WHERE raum_id = ? AND id > ? AND id > ? AND person_id <> ?`)
.get(r.id, r.gelesen_bis, r.geloescht_bis, req.person.id).n;
return {
id: r.id,
art: r.art,
kategorie: r.kategorie || null,
name: raumName(r, leute, req.person.id),
leitung: !!r.leitung,
/* DARF ICH DIESEN RAUM FUER ALLE AUFLOESEN? (11.09.2026)
Vom Server beantwortet, nicht im Browser nachgerechnet -- die
Oberflaeche hat `erstellt_von` sonst gar nicht. Die Regel
selbst steht in darfAufloesen(), damit sie nicht an zwei
Stellen steht; genau daran ist es beim ersten Anlauf
gescheitert. */
darf_aufloesen: darfAufloesen(r, req.person),
teilnehmer: leute.map((t) => ({ id: t.id, name: t.name, rolle: t.rolle })),
letzte: letzte ? {
/* Ein Anhang ohne Begleittext hätte hier eine leere Zeile
hinterlassen. "Foto"/"PDF" ist die Vorschau, die man in
jeder Gesprächsliste erwartet. */
text: letzte.weg_am ? null
: (letzte.text ? letzte.text.slice(0, 140)
: (letzte.anhang_art === "bild" ? "Foto"
: letzte.anhang_art === "pdf" ? "PDF" : "")),
zurueckgenommen: !!letzte.weg_am,
von: letzte.von, erstellt: letzte.erstellt,
} : null,
ungelesen,
};
});
/* WELCHE KANAELE ES NOCH NICHT GIBT -- und nur fuer die, die sie
anlegen duerfen.
Die Liste steht bewusst NICHT in einer Datei im Browser. Sie
verraet fuer sich genommen nichts Schlimmes, aber sie ist der
Anfang einer Aufzaehlung dessen, was das Team tut; und die
Oberflaeche zeigt den Knopf ohnehin nur, wenn hier etwas
ankommt. Wer nichts anlegen darf, bekommt ein leeres Feld -- und
ein leeres Feld sagt nichts. */
const darfKanal = fuehrtTeamDogi(req.person);
const belegt = darfKanal
? new Set(db().prepare("SELECT kategorie FROM chat_raeume WHERE art = 'kanal'")
.all().map((z) => z.kategorie))
: new Set();
res.json({
raeume,
darf_kanal: darfKanal,
kanal_frei: darfKanal ? MODI_KATEGORIEN.filter((k) => !belegt.has(k.wert)) : [],
/* WAS FUER DEN TREFF GILT, STEHT IN DER ANTWORT -- die Seite
rechnet die Nachtruhe NICHT selbst nach. Eine zweite Rechnung
im Browser waere die, die bei der naechsten Zeitumstellung
anders herauskommt als die im Server; dann sagt die Seite
"offen", waehrend der Server ablehnt. */
treff: gehoertInTreff(req.person) ? nachtruheStand() : null,
/* AUSDRUECKLICH GESAGT, NICHT ERRATEN (19.09.2026).
Die Anrufknoepfe verschwinden fuer die Community nicht von
selbst, nur weil die Schnittstelle 404 antwortet -- sie stuenden
da und taeten nichts. Ein Knopf, der nichts tut, ist schlimmer
als kein Knopf: Man drueckt ihn wieder.
Die Antwort kommt aus DERSELBEN Funktion, die auch der
Anruf-Router benutzt. Zwei Rechnungen waeren die Stelle, an der
die Seite einen Knopf zeigt, den der Server ablehnt. */
darf_anrufen: darfTelefonieren(req.person),
});
} 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" });
}
});
/* =====================================================================
EINEN KANAL ANLEGEN (10.09.2026, Kapitel 7.2)
Kein Name im Rumpf, nur die Kategorie -- siehe kanalName(). Und keine
Pflicht, gleich jemanden hineinzusetzen: Ein Kanal, den DogFather
morgens anlegt und mittags fuellt, ist ein normaler Vorgang; ihn
dafuer zweimal aufmachen zu muessen waere eine Regel ohne Zweck.
===================================================================== */
chatRouter.post("/workspace/api/chat/kanal", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => {
try {
/* 404 und nicht 403: Wer keine Kanaele fuehren darf, soll auch
nicht erfahren, dass es die Moeglichkeit gibt. */
if (!fuehrtTeamDogi(req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
const kategorie = String(req.body?.kategorie ?? "").trim();
if (!MODI_KATEGORIEN.some((k) => k.wert === kategorie)) {
return res.status(400).json({ fehler: "Unbekannte Kategorie." });
}
const d = db();
/* Die Regel steht hier UND als eindeutiger Index in der Datenbank.
Doppelt gemoppelt ist es nicht: Hier gibt es eine Meldung, die
jemand lesen kann, und dort eine Zusage, die auch dann gilt, wenn
einmal zwei Anfragen im selben Augenblick ankommen. */
const schon = d.prepare("SELECT id FROM chat_raeume WHERE art = 'kanal' AND kategorie = ?")
.get(kategorie);
if (schon) {
return res.status(409).json({ fehler: "Diesen Kanal gibt es schon.", raum_id: schon.id });
}
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 dieselbe Regel wie bei einer Gruppe.
Ein Kanal ist kein Schlupfloch, um jemanden zu erreichen, den man
einzeln nicht anschreiben duerfte. */
if (mit.some((id) => !darfSchreibenMit(req.person, id))) {
return res.status(403).json({ fehler: "Mit mindestens einer Person kannst du nicht schreiben." });
}
const n = jetzt();
d.prepare(`INSERT INTO chat_raeume (art, name, kategorie, erstellt, erstellt_von)
VALUES ('kanal',?,?,?,?)`)
.run(kanalName(kategorie), kategorie, n, req.person.id);
const raumId = d.prepare("SELECT last_insert_rowid() AS id").get().id;
/* DIE GANZE TEAMFUEHRUNG KOMMT HINEIN, nicht nur der Anlegende.
kanaeleAngleichen() wuerde das beim naechsten Blick in die Liste
ohnehin nachholen -- aber "beim naechsten Blick" heisst: Die
rechte Hand sieht den Kanal erst, wenn sie die Seite neu laedt.
Hier ist er sofort da. */
const fuehrung = d.prepare(
"SELECT id FROM personen WHERE aktiv = 1 AND rolle IN ('admin','hand')").all().map((z) => z.id);
const reinLeitung = d.prepare(
"INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis, leitung) VALUES (?,?,?,0,1)");
for (const id of new Set([req.person.id, ...fuehrung])) reinLeitung.run(raumId, id, n);
const rein = d.prepare(
"INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis) VALUES (?,?,?,0)");
let drin = 0;
for (const id of mit) {
if (fuehrung.includes(id)) continue; /* steht schon als Leitung drin */
if (d.prepare("SELECT 1 FROM personen WHERE id = ? AND aktiv = 1").get(id)) {
rein.run(raumId, id, n); drin++;
}
}
protokolliere("chat_kanal_angelegt", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${raumId} ${kategorie} (${drin})`.slice(0, 120),
});
res.status(201).json({ raum_id: raumId, name: kanalName(kategorie) });
} catch (fehler) {
console.error("[chat] Kanal:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* =====================================================================
WER IST IM KANAL (10.09.2026)
Ohne diese Route waere ein Kanal beim Anlegen fuer immer festgelegt:
Ein neuer Modi koennte nie in "Clipping" -- der Kanal waere richtig
gebaut und trotzdem unbrauchbar.
ENTFERNT WIRD MIT `raus_am`, nicht mit DELETE. Wer draussen ist,
schreibt und liest nichts Neues mehr; der Verlauf bis dahin bleibt
aber lesbar, und niemand verschwindet rueckwirkend aus der
Geschichte. Dieselbe Entscheidung wie bei den Gruppen.
===================================================================== */
chatRouter.put("/workspace/api/chat/kanal/:id/mitglieder", gleicheHerkunft,
express.json({ limit: "8kb" }), (req, res) => {
try {
if (!fuehrtTeamDogi(req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
const raumId = Number(req.params.id);
if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" });
const d = db();
const raum = d.prepare("SELECT id, art FROM chat_raeume WHERE id = ?").get(raumId);
/* Ausdruecklich nur Kanaele. Diese Route auf Gruppen loszulassen
hiesse, dass die Teamfuehrung fremde Gruppen umbesetzen kann --
etwas voellig anderes, und niemand hat darum gebeten. */
if (!raum || raum.art !== "kanal") return res.status(404).json({ fehler: "nicht_gefunden" });
const roh = Array.isArray(req.body?.mit) ? req.body.mit : [];
const soll = new Set(roh.map(Number).filter((n) => Number.isInteger(n) && n > 0));
if ([...soll].some((id) => !darfSchreibenMit(req.person, id))) {
return res.status(403).json({ fehler: "Mit mindestens einer Person kannst du nicht schreiben." });
}
/* Die Teamfuehrung bleibt unangetastet -- sie hat laut Kapitel 7.2
Zugriff auf alle Kanaele. Sie aus der Liste entfernen zu koennen
waere eine Tuer, die man hinter sich zuzieht: istDrin() liesse
sie zwar weiter hinein, in der Gespraechsliste stuende der Kanal
aber nicht mehr. */
const fuehrung = new Set(d.prepare(
"SELECT id FROM personen WHERE rolle IN ('admin','hand')").all().map((z) => z.id));
const ist = d.prepare(
"SELECT person_id FROM chat_teilnehmer WHERE raum_id = ? AND raus_am IS NULL")
.all(raumId).map((z) => z.person_id).filter((id) => !fuehrung.has(id));
const n = jetzt();
/* WER HEUTE DAZUKOMMT, HAT NICHTS VERPASST. Ohne diesen Stand
staende beim ersten Oeffnen eine rote Zahl ueber allem, was das
Team je in diesem Kanal geschrieben hat -- lesbar ist es
weiterhin, ungelesen war es nie. */
const hoechste = d.prepare(
"SELECT COALESCE(MAX(id), 0) AS m FROM chat_nachrichten WHERE raum_id = ?").get(raumId).m;
let dazu = 0, raus = 0;
for (const id of soll) {
if (fuehrung.has(id) || ist.includes(id)) continue;
if (!d.prepare("SELECT 1 FROM personen WHERE id = ? AND aktiv = 1").get(id)) continue;
/* Zurueckgeholt oder neu -- eine Zeile kann es schon geben (mit
raus_am). Ein blankes INSERT liefe dann auf den
Primaerschluessel auf. */
const wieder = d.prepare(
"SELECT 1 FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?").get(raumId, id);
if (wieder) {
d.prepare("UPDATE chat_teilnehmer SET raus_am = NULL, seit = ? WHERE raum_id = ? AND person_id = ?")
.run(n, raumId, id);
} else {
d.prepare(`INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis)
VALUES (?,?,?,?)`).run(raumId, id, n, hoechste);
}
dazu++;
}
for (const id of ist) {
if (soll.has(id)) continue;
d.prepare("UPDATE chat_teilnehmer SET raus_am = ? WHERE raum_id = ? AND person_id = ?")
.run(n, raumId, id);
raus++;
}
if (dazu || raus) {
protokolliere("chat_kanal_besetzung", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${raumId} +${dazu} -${raus}`,
});
}
res.json({ ok: true, dazu, raus, teilnehmer: teilnehmerVon(raumId).map((t) => t.id) });
} catch (fehler) {
console.error("[chat] Kanal-Besetzung:", 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)) 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. */
/* WAS ICH WEGGERÄUMT HABE, KOMMT NICHT ZURÜCK. Die Grenze steht im
WHERE und nicht in einem Filter danach -- sonst wäre sie eine
Zeile davon entfernt, beim nächsten Umbau vergessen zu werden. */
const weg = geloeschtBis(raumId, req.person.id);
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, n.angeheftet_am,
n.anhang_name, n.anhang_art, n.anhang_typ, n.anhang_groesse,
n.anhang_breite, n.anhang_hoehe,
a.text AS zitat_text, a.weg_am AS zitat_weg, ap.name AS zitat_von,
a.anhang_art AS zitat_anhang
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 = ? AND n.id > ? ${vor ? "AND n.id < ?" : ""}
ORDER BY n.id DESC LIMIT 200`).all(...(vor ? [raumId, weg, vor] : [raumId, weg]));
/* 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, kategorie, erstellt_von FROM chat_raeume WHERE id = ?").get(raumId);
const leute = teilnehmerVon(raumId);
/* DIE ANGEHEFTETEN, in einer eigenen Abfrage.
Sie MUESSEN getrennt geholt werden: Der Verlauf oben liefert nur
die letzten 200 Zeilen. Eine Ankuendigung von vorletzter Woche
faellt da heraus -- und waere damit ausgerechnet dann verschwunden,
wenn sie am laengsten oben stehen sollte.
DIESELBEN ZWEI GRENZEN WIE DER VERLAUF: `n.id > weg` (was ich
weggeraeumt habe, kommt nicht ueber die Hintertuer zurueck) und
`weg_am IS NULL` (eine zurueckgenommene Nachricht bleibt
zurueckgenommen, auch als Aushang). */
const weggeraeumt = weg;
const angeheftet = db().prepare(`
SELECT n.id, n.text, n.erstellt, n.angeheftet_am, n.anhang_art,
p.name AS von, q.name AS heftete
FROM chat_nachrichten n
LEFT JOIN personen p ON p.id = n.person_id
LEFT JOIN personen q ON q.id = n.angeheftet_von
WHERE n.raum_id = ? AND n.angeheftet_am IS NOT NULL
AND n.weg_am IS NULL AND n.id > ?
ORDER BY n.angeheftet_am DESC LIMIT ?`).all(raumId, weggeraeumt, PIN_MAX);
res.json({
raum: {
id: raum.id, art: raum.art, kategorie: raum.kategorie || null,
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 })),
/* Dasselbe Feld wie in der Liste, aus derselben Regel. Die
Oberflaeche nimmt den offenen Raum von HIER -- ohne diese
Zeile blieb der Aufloesen-Knopf unsichtbar, obwohl das Recht
da war. */
darf_aufloesen: darfAufloesen(raum, req.person),
},
gelesen_bis: gelesenBis,
/* OB DER KNOPF UEBERHAUPT ERSCHEINT, entscheidet der Server.
Die Oberflaeche koennte `rolle === 'admin'` vergleichen -- aber
assets/js/chat.js bekommt jeder, der die Seite oeffnet. Ein
Ja/Nein verraet nichts ueber die Rollen dahinter. */
darf_anheften: darfAnheften(req.person, raumId),
angeheftet: angeheftet.map((a) => ({
id: a.id,
von: a.von || "Gelöscht",
heftete: a.heftete || null,
seit: a.angeheftet_am,
text: String(a.text || "").slice(0, 200)
|| (a.anhang_art === "bild" ? "Foto" : a.anhang_art === "pdf" ? "PDF" : ""),
})),
/* Aufsteigend zurück -- gelesen wird von oben nach unten. */
/* Die Reaktionen in EINER Abfrage fuer alle Nachrichten -- nicht
je Zeile eine. Bei 200 Zeilen waeren das 200 Abfragen fuer eine
Ansicht, die man beim Scrollen staendig neu holt. */
nachrichten: (() => {
const gedreht = reihen.reverse();
const reakt = reaktionenZu(gedreht.map((n) => n.id), req.person.id);
return gedreht.map((n) => ({
reaktionen: reakt.get(n.id) || [],
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,
angeheftet: !!n.angeheftet_am,
/* 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)
|| (n.zitat_anhang === "bild" ? "Foto" : n.zitat_anhang === "pdf" ? "PDF" : "")),
zurueckgenommen: !!n.zitat_weg,
} : null,
/* Der Anhang wird als fertige ADRESSE geliefert, nicht als
Dateiname. Sonst setzt jede Stelle im Browser denselben Pfad
zusammen -- und eine davon setzt ihn irgendwann anders. */
anhang: (!n.weg_am && n.anhang_art) ? {
art: n.anhang_art, typ: n.anhang_typ,
name: n.anhang_name, groesse: n.anhang_groesse,
breite: n.anhang_breite || 0, hoehe: n.anhang_hoehe || 0,
weg: `/workspace/api/chat/anhang/${n.id}`,
} : 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)) return res.status(404).json({ fehler: "nicht_gefunden" });
/* ==== DIE NACHTRUHE ==============================================
Filipe: "quasi nach mitternacht bis morgens 6 uhr keiner
schreiben kann damit auch die privatsphaere beruecksichtig
wird ueber nacht."
SIE STEHT HIER UND NICHT NUR IM BROWSER. Ein ausgegrauter
Knopf ist eine Bitte; abgelehnt wird erst hier. Wer die
Schnittstelle direkt anspricht -- und das kann jeder, der die
Adresse kennt --, kaeme sonst durch.
SIE GILT FUER ALLE, AUCH FUER DIE LEITUNG. Eine Nachtruhe, von
der DogFather ausgenommen waere, ist keine: Wenn er um halb
vier schreibt, liest es jemand, und wer es liest, fuehlt sich
zustaendig. Genau den Druck soll sie wegnehmen.
NUR IM TREFF. Das Team hat keine Nachtruhe im Chat -- dort
arbeiten Leute nachts, das ist der Beruf. Hier geht es um
Zuschauer und um die, die sie betreuen.
MODERIEREN GEHT WEITER: Verbergen, loeschen, jemanden
herausnehmen sind keine Nachrichten und laufen ueber andere
Wege. Wer nachts etwas Schlimmes sieht, kann es wegnehmen --
er kann nur nicht darueber diskutieren. */
if (istTreffRaum(raumId) && istNachtruhe()) {
const stand = nachtruheStand();
return res.status(403).json({
fehler: stand.satz, nachtruhe: stand,
});
}
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,
anhang: 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" });
}
});
/* =====================================================================
ANHÄNGE: FOTOS UND PDF (09.09.2026)
Filipe: "am besten wäre es auch wenn man da auch im chat pdfs
schicken könnte. pdfs und fotos."
Wer im Gespräch schreiben darf, darf auch etwas schicken -- auch
Creator. Das ist ausdrücklich ANDERS als in der Dateiablage, wo
Creator nicht hochladen dürfen: Dort landet etwas in einem Bereich,
den mehrere sehen; hier bekommt es genau der, mit dem man ohnehin
gerade spricht. Ein Creator, der seinem Scout einen Screenshot
schicken will, soll das können.
DER RUMPF WIRD ERST ANGENOMMEN, WENN DIE BERECHTIGUNG STEHT. Die
Mitgliedschaft wird VOR express.raw geprüft -- sonst wanderten
zwölf Megabyte durch die Leitung, nur um danach verworfen zu
werden, und jeder Angemeldete könnte damit an jeder Raumnummer
Speicher verbrauchen.
===================================================================== */
/** Vor express.raw: Bin ich in diesem Raum? */
function drinFuerAnhang(req, res, next) {
const raumId = Number(req.params.id);
if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" });
if (!istDrin(raumId, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
next();
}
chatRouter.post("/workspace/api/chat/raeume/:id/anhang",
gleicheHerkunft,
drinFuerAnhang,
express.raw({ type: "*/*", limit: ANHANG_MAX }),
(req, res) => {
try {
const raumId = Number(req.params.id);
/* DIE NACHTRUHE GILT AUCH FUER ANHAENGE. Sie nur beim Text zu
pruefen hiesse, dass man nachts zwar nicht schreiben, aber ein
Foto mit Bildunterschrift schicken kann -- der Weg, den jeder
findet, der es einmal versucht. */
if (istTreffRaum(raumId) && istNachtruhe()) {
const stand = nachtruheStand();
return res.status(403).json({ fehler: stand.satz, nachtruhe: stand });
}
if (!Buffer.isBuffer(req.body) || !req.body.length) {
return res.status(400).json({ fehler: "Keine Datei empfangen." });
}
/* DER TYP KOMMT AUS DEM INHALT, nicht aus dem Namen und nicht aus
dem Content-Type. Siehe dateiErkennen(). */
const erkannt = dateiErkennen(req.body);
if (!erkannt) {
return res.status(415).json({
fehler: "Das geht nicht – erlaubt sind Fotos (PNG, JPEG, WebP, GIF) und PDF.",
});
}
let begleittext = "";
try { begleittext = decodeURIComponent(req.get("x-text") || ""); } catch { begleittext = ""; }
begleittext = begleittext.trim().slice(0, TEXT_MAX);
const name = anhangName(req.get("x-dateiname"), erkannt.endung);
/* Die Endung auf der Platte kommt aus der ERKENNUNG, nicht aus dem
eingeschickten Namen. Damit kann dort nichts liegen, das anders
heisst, als es ist. */
mkdirSync(ANHANG_ORDNER, { recursive: true });
const aufPlatte = `${Date.now().toString(36)}-${randomBytes(8).toString("hex")}${erkannt.endung}`;
writeFileSync(join(ANHANG_ORDNER, aufPlatte), req.body, { flag: "wx" });
const d = db();
const n = jetzt();
d.prepare(`INSERT INTO chat_nachrichten
(raum_id, person_id, text, erstellt,
anhang_datei, anhang_name, anhang_art, anhang_typ,
anhang_groesse, anhang_breite, anhang_hoehe)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`)
.run(raumId, req.person.id, begleittext, n,
aufPlatte, name, erkannt.art, erkannt.typ,
req.body.length, erkannt.breite || null, erkannt.hoehe || null);
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);
d.prepare("UPDATE chat_teilnehmer SET gelesen_bis = ? WHERE raum_id = ? AND person_id = ?")
.run(id, raumId, req.person.id);
const nachricht = {
id, raum_id: raumId, von_id: req.person.id, von: req.person.name,
rolle: req.person.rolle, text: begleittext, erstellt: n,
zurueckgenommen: false, antwort: null,
anhang: {
art: erkannt.art, typ: erkannt.typ, name,
groesse: req.body.length,
breite: erkannt.breite || 0, hoehe: erkannt.hoehe || 0,
weg: `/workspace/api/chat/anhang/${id}`,
},
};
const empfaenger = teilnehmerVon(raumId).filter((t) => t.id !== req.person.id);
chatEreignis(raumId, nachricht, empfaenger.map((t) => t.id));
protokolliere("chat_anhang", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${id} ${erkannt.art} ${Math.round(req.body.length / 1024)} kB`.slice(0, 120),
});
res.status(201).json({ nachricht });
} catch (fehler) {
console.error("[chat] Anhang:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Einen Anhang ansehen oder laden.
*
* Die Kennung ist die der NACHRICHT, nicht die der Datei. Damit
* hängt der Zugriff an derselben Prüfung wie der Verlauf: Wer nicht
* im Raum ist, bekommt 404 -- und wer das Gespräch weggeräumt hat,
* auch. Ein eigener Dateischlüssel wäre eine zweite Tür mit einem
* zweiten Schloss. */
chatRouter.get("/workspace/api/chat/anhang/:id", (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, weg_am,
anhang_datei, anhang_name, anhang_art, anhang_typ
FROM chat_nachrichten WHERE id = ?`).get(id);
if (!n || !n.anhang_datei || n.weg_am) return res.status(404).json({ fehler: "nicht_gefunden" });
if (!istDrin(n.raum_id, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
if (n.id <= geloeschtBis(n.raum_id, req.person.id)) {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
const pfad = join(ANHANG_ORDNER, n.anhang_datei);
try { statSync(pfad); } catch { return res.status(410).json({ fehler: "Datei fehlt auf der Platte." }); }
/* AUSGELIEFERT WIRD DER TYP AUS DER ERKENNUNG, nie ein
eingeschickter. Er steht seit dem Hochladen in der Datenbank und
kann seitdem von niemandem mehr beeinflusst werden.
`nosniff` verbietet dem Browser, sich einen anderen Typ
auszudenken. Die enge Richtlinie mit `sandbox` nimmt dem
Dokument jede Ausführung -- das ist die Sicherung für den Fall,
dass in einem PDF ein Skript steckt (PDF kann das). Bei einem
Bild kostet sie nichts. */
res.setHeader("Content-Type", n.anhang_typ || "application/octet-stream");
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
res.setHeader("Cache-Control", "private, max-age=86400");
/* Ansehen ist die Vorgabe -- ein Foto, das man erst herunterladen
muss, um es zu sehen, ist kein Foto im Chat. `?laden=1` macht
daraus einen Download. */
const alsDownload = req.query.laden === "1";
res.setHeader("Content-Disposition",
`${alsDownload ? "attachment" : "inline"}; filename*=UTF-8''${encodeURIComponent(n.anhang_name || "anhang")}`);
createReadStream(pfad).pipe(res);
} catch (fehler) {
console.error("[chat] Anhang ausliefern:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* =====================================================================
EIN GESPRÄCH WEGRÄUMEN — NUR BEI MIR (09.09.2026)
Filipe: "man muss die chats auch gelöscht bekommen!!!"
Auf Nachfrage: nur bei mir.
Was hier NICHT passiert: Es wird nichts aus der Datenbank entfernt.
Das Gegenüber behält seinen Verlauf vollständig -- niemand kann
einem anderen die Unterhaltung wegnehmen. Und es wird auch nicht
die Mitgliedschaft beendet: Schreibt der andere morgen wieder,
kommt das Gespräch mit dem NEUEN zurück, das Alte bleibt weg.
Genau so verhält sich jeder Messenger, den Filipe kennt.
`gelesen_bis` wandert mit. Ohne das käme das Gespräch sofort wieder
als "ungelesen" hoch -- weggeräumt und trotzdem eine rote Zahl in
der Kopfleiste wäre schlimmer als gar nicht löschen.
===================================================================== */
chatRouter.delete("/workspace/api/chat/raeume/:id", gleicheHerkunft, (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)) return res.status(404).json({ fehler: "nicht_gefunden" });
const d = db();
const bis = d.prepare("SELECT COALESCE(MAX(id), 0) AS m FROM chat_nachrichten WHERE raum_id = ?")
.get(raumId).m;
d.prepare(`UPDATE chat_teilnehmer
SET geloescht_bis = ?, geloescht_am = ?,
gelesen_bis = CASE WHEN gelesen_bis < ? THEN ? ELSE gelesen_bis END
WHERE raum_id = ? AND person_id = ?`)
.run(bis, jetzt(), bis, bis, raumId, req.person.id);
protokolliere("chat_weggeraeumt", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `Raum #${raumId} bis #${bis}`,
});
res.json({ ok: true, bis });
} catch (fehler) {
console.error("[chat] Wegräumen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Darf diese Person diesen Raum fuer ALLE aufloesen?
*
* EINE REGEL, EINE STELLE (11.09.2026). Sie wurde zuerst in der
* Raumliste ausgerechnet -- und der Knopf blieb trotzdem unsichtbar,
* weil die Oberflaeche den offenen Raum aus dem Nachrichten-Weg
* bezieht, nicht aus der Liste. Zwei Wege, ein Raumobjekt, und nur
* einer kannte das Feld.
*
* Gefunden hat das nicht das Lesen, sondern eine Pruefung, die den
* KNOPF gemessen hat statt des Rechts dahinter.
*
* `erstellt_von` muss im uebergebenen Raum stehen -- wer die Spalte
* im SELECT vergisst, bekommt `undefined` und damit `false`. Das ist
* die sichere Richtung: kein Knopf statt eines falschen.
*/
function darfAufloesen(raum, person) {
return !!raum && raum.art !== "direkt"
&& !!raum.erstellt_von && raum.erstellt_von === person.id;
}
/* =====================================================================
REAKTIONEN — auf die Nachricht, ohne zu antworten (14.09.2026)
Filipe: "ich will dass man auf die nachrichten auch reagieren kann
mit einem emoji, die nachricht selbst ohne zu antworten."
EINE FESTE AUSWAHL, KEIN FREIES FELD. Sechs Zeichen, hier
aufgeschrieben. Drei Gruende, und der erste ist der wichtigste:
* Eine feste Liste laesst sich pruefen. Was hier nicht drinsteht,
kommt nicht in die Datenbank -- kein beliebiger Text, keine
Zeichenketten von 4 KB, keine unsichtbaren Steuerzeichen.
* Alle sehen dasselbe. Ein selbst getipptes Zeichen sieht auf
einem anderen Geraet anders aus oder gar nicht.
* Sechs Zeichen kann man ueberfliegen. Eine volle Emoji-Tafel ist
eine Suchaufgabe, und dann tippt man doch wieder "ok".
DIE LISTE STEHT AUF DEM SERVER und wird an die Oberflaeche geliefert
(/reaktionen/auswahl). Stuende sie im Browser, gaebe es sie zweimal
-- und die zweite Fassung waere die, in der ein Zeichen fehlt, das
der Server noch annimmt.
===================================================================== */
/* Die drei Listen stehen in workspace-reaktionen.js -- Katalog,
Vorschlag, erlaubte Zeichen. Hier stehen nur die Wege dorthin. */
/** Die Favoriten EINES Menschen, in seiner Reihenfolge. */
function favoritenVon(personId) {
return db().prepare(
"SELECT zeichen FROM chat_reaktion_favoriten WHERE person_id = ? ORDER BY platz, zeichen")
.all(personId).map((z) => z.zeichen)
/* Was nicht mehr im Katalog steht, faellt still weg -- aber nur
beim LESEN. Die Zeile bleibt stehen; kaeme das Zeichen zurueck,
ist der Favorit wieder da. Ein Loeschen waere eine Entscheidung
ueber fremde Daten wegen einer Katalogaenderung. */
.filter((z) => ERLAUBT.has(z));
}
/* WAS DIE OBERFLAECHE BRAUCHT, IN EINER ANTWORT: die Vorschlagszeile,
den ganzen Katalog mit Suchwoertern und die eigenen Favoriten.
Drei Abrufe daraus zu machen waere sparsamer aussehend und in
Wahrheit teurer: Die Tafel kann erst erscheinen, wenn alle drei da
sind. */
chatRouter.get("/workspace/api/chat/reaktionen/auswahl", (req, res) => {
try {
const favoriten = favoritenVon(req.person.id);
res.json({
/* Die Vorschlagszeile sind die FAVORITEN, sobald es welche gibt
-- sonst die Vorgabe. Beides an einer Stelle entschieden,
damit die Oberflaeche nicht selbst waehlen muss. */
zeichen: favoriten.length ? favoriten : VORSCHLAG,
eigene: favoriten.length > 0,
favoriten,
favoriten_max: FAVORITEN_MAX,
katalog: KATALOG,
});
} catch (fehler) {
console.error("[chat] Auswahl:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* EINEN FAVORITEN SETZEN ODER WEGNEHMEN -- wieder ein Umschalter,
wie das Reagieren selbst. Ein eigener Weg zum Entfernen waere ein
zweiter Weg, den man absichern muss. */
chatRouter.put("/workspace/api/chat/reaktionen/favorit", gleicheHerkunft,
express.json({ limit: "2kb" }), (req, res) => {
try {
const zeichen = String(req.body?.zeichen ?? "");
if (!ERLAUBT.has(zeichen)) return res.status(400).json({ fehler: "unbekanntes_zeichen" });
const d = db();
const da = d.prepare(
"SELECT 1 FROM chat_reaktion_favoriten WHERE person_id = ? AND zeichen = ?")
.get(req.person.id, zeichen);
if (da) {
d.prepare("DELETE FROM chat_reaktion_favoriten WHERE person_id = ? AND zeichen = ?")
.run(req.person.id, zeichen);
} else {
const wieViele = d.prepare(
"SELECT COUNT(*) AS n FROM chat_reaktion_favoriten WHERE person_id = ?")
.get(req.person.id).n;
if (wieViele >= FAVORITEN_MAX) {
/* NICHT STILL DEN AELTESTEN WEGWERFEN. Wer neun ablegen will,
soll erfahren, dass acht das Maximum sind -- und selbst
entscheiden, welcher geht. Ein Favorit, der ohne Ansage
verschwindet, sieht wie ein Fehler aus. */
return res.status(409).json({
fehler: `Mehr als ${FAVORITEN_MAX} Favoriten passen nicht in die Zeile. `
+ "Nimm erst einen weg.",
favoriten: favoritenVon(req.person.id),
});
}
/* Ans ENDE, nicht an den Anfang: Die vorhandenen sollen nicht
wandern -- man greift sonst daneben. */
const platz = (d.prepare(
"SELECT COALESCE(MAX(platz), 0) AS p FROM chat_reaktion_favoriten WHERE person_id = ?")
.get(req.person.id).p) + 1;
d.prepare(`INSERT INTO chat_reaktion_favoriten (person_id, zeichen, platz)
VALUES (?,?,?)`).run(req.person.id, zeichen, platz);
}
const favoriten = favoritenVon(req.person.id);
res.json({ favoriten, gesetzt: !da,
zeichen: favoriten.length ? favoriten : VORSCHLAG });
} catch (fehler) {
console.error("[chat] Favorit:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Alle Reaktionen zu einer Menge von Nachrichten/** Alle Reaktionen zu einer Menge von Nachrichten -- in EINER Abfrage.
*
* Je Nachricht einzeln zu fragen waere bei 200 Nachrichten 200
* Abfragen fuer eine Ansicht. Zurueck kommt eine Karte:
* nachricht_id -> [{ zeichen, anzahl, selbst, wer }].
*
* `wer` ist bewusst dabei: "drei Daumen" sagt weniger als "Ayla, Ben
* und Cem". Es sind Namen aus DEMSELBEN Raum -- wer die Nachricht
* sieht, sieht die Leute ohnehin.
*/
function reaktionenZu(ids, personId) {
const karte = new Map();
if (!ids.length) return karte;
const platz = ids.map(() => "?").join(",");
const reihen = db().prepare(`
SELECT r.nachricht_id, r.zeichen, r.person_id, p.name
FROM chat_reaktionen r
LEFT JOIN personen p ON p.id = r.person_id
WHERE r.nachricht_id IN (${platz})
ORDER BY r.wann`).all(...ids);
for (const z of reihen) {
if (!karte.has(z.nachricht_id)) karte.set(z.nachricht_id, new Map());
const proNachricht = karte.get(z.nachricht_id);
if (!proNachricht.has(z.zeichen)) {
proNachricht.set(z.zeichen, { zeichen: z.zeichen, anzahl: 0, selbst: false, wer: [] });
}
const eintrag = proNachricht.get(z.zeichen);
eintrag.anzahl++;
if (z.person_id === personId) eintrag.selbst = true;
eintrag.wer.push(z.name || "Gelöscht");
}
const fertig = new Map();
for (const [id, proNachricht] of karte) fertig.set(id, [...proNachricht.values()]);
return fertig;
}
/* REAGIEREN IST EIN UMSCHALTER, kein Hinzufuegen.
Zweimal dasselbe Zeichen heisst: wieder weg. Das erspart einen
zweiten Weg zum Zuruecknehmen -- und einen zweiten Weg, den man
vergisst abzusichern. */
/* ==== DIE NACHTRUHE GILT HIER ABSICHTLICH NICHT (19.09.2026) ========
Eine Reaktion ist keine Nachricht. Sie sagt nichts, was jemand
beantworten muesste, sie kann keinen Streit anfangen, und sie loest
keine Meldung aus (siehe die Begruendung weiter unten: „Ein Daumen
ist keine Nachricht").
Filipes Ansage war „keiner schreiben kann". Ein Daumen ist kein
Schreiben -- und genau darum geht es bei der Nachtruhe: Sie soll den
Druck wegnehmen, nachts antworten zu muessen, nicht den Raum
abschliessen.
DAS STEHT HIER, damit es nicht wie ein Versehen aussieht. Eine
Luecke, die niemand begruendet hat, wird beim naechsten Durchsehen
entweder geschlossen oder uebersehen -- beides falsch. */
chatRouter.put("/workspace/api/chat/nachrichten/:id/reaktion", gleicheHerkunft,
express.json({ limit: "2kb" }), (req, res) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
const zeichen = String(req.body?.zeichen ?? "");
if (!ERLAUBT.has(zeichen)) {
return res.status(400).json({ fehler: "unbekanntes_zeichen" });
}
const d = db();
const n = d.prepare(
"SELECT id, raum_id, weg_am FROM chat_nachrichten WHERE id = ?").get(id);
if (!n) return res.status(404).json({ fehler: "nicht_gefunden" });
/* Die Raumzugehoerigkeit ist die ganze Sicherheit dieses Wegs --
dieselbe Pruefung wie beim Lesen des Verlaufs. */
if (!istDrin(n.raum_id, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
/* Auf eine zurueckgenommene Nachricht wird nicht reagiert. Dort
steht nichts mehr, worauf man reagieren koennte. */
if (n.weg_am) return res.status(409).json({ fehler: "zurueckgenommen" });
const da = d.prepare(`SELECT 1 FROM chat_reaktionen
WHERE nachricht_id = ? AND person_id = ? AND zeichen = ?`)
.get(id, req.person.id, zeichen);
if (da) {
d.prepare(`DELETE FROM chat_reaktionen
WHERE nachricht_id = ? AND person_id = ? AND zeichen = ?`)
.run(id, req.person.id, zeichen);
} else {
d.prepare(`INSERT INTO chat_reaktionen (nachricht_id, person_id, zeichen, wann)
VALUES (?,?,?,?)`).run(id, req.person.id, zeichen, jetzt());
}
const stand = reaktionenZu([id], req.person.id).get(id) || [];
/* ALLE IM RAUM SEHEN ES SOFORT -- ohne Benachrichtigung aufs
Handy. Ein Daumen ist keine Nachricht; wer dafuer eine Meldung
bekaeme, schaltet Meldungen ab. */
chatEreignis(n.raum_id, { id, raum_id: n.raum_id, reaktionen: stand }, [], "reaktion");
res.json({ id, reaktionen: stand, gesetzt: !da });
} catch (fehler) {
console.error("[chat] Reaktion:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* =====================================================================
EINE GRUPPE ODER EINEN KANAL AUFLOESEN — FUER ALLE (11.09.2026)
Filipe: "wenn ein gruppenchat oder kanal geoeffnet wird, die person
die ihn oeffnet soll auch das recht haben das zu loeschen und so
dass es dan fuer jeden geloescht ist. aber nur die person die es
gruendet."
DAS IST ETWAS ANDERES ALS DER WEG DARUEBER, und die beiden duerfen
nie verwechselt werden:
Wegraeumen -> nur bei mir. Die anderen behalten alles.
Aufloesen -> fuer alle. Der Raum ist weg, endgueltig.
Deshalb ein eigener Weg (/ganz) und nicht ein Zusatzfeld am
bestehenden: Ein vergessenes Feld waere hier der Unterschied
zwischen "bei mir ausgeblendet" und "bei allen geloescht".
NUR DER GRUENDER, und zwar woertlich. Nicht die Teamleitung, nicht
DogFather, nicht wer `leitung` in der Gruppe hat. `erstellt_von`
steht seit jeher in der Tabelle und ist die einzige Angabe, die
"wer hat das aufgemacht" beantwortet.
NICHT BEI ZWEIER-GESPRAECHEN. Dort gibt es keinen Gruender, nur
zwei Beteiligte -- und "niemand kann einem anderen die Unterhaltung
wegnehmen" ist die Regel von 09.09., die hier gilt.
DIE OFFENE STELLE, damit sie niemand suchen muss: Wird das Konto
des Gruenders geloescht, wird `erstellt_von` auf NULL gesetzt
(ON DELETE SET NULL) -- dann kann NIEMAND mehr aufloesen. Das ist
die ehrliche Folge von "nur der Gruender"; wer das aendern will,
muss eine zweite Person benennen, und das ist eine Entscheidung,
keine Reparatur.
REIHENFOLGE, und sie ist nicht beliebig:
1. Das Ereignis geht RAUS, solange es die Teilnehmer noch gibt --
chatEreignis liest sie aus der Tabelle. Danach waere die Liste
leer und niemand erfuehre, dass der Raum weg ist.
2. Die Zeilen fallen in EINER Transaktion.
3. Die Anhaenge auf der Platte erst NACH dem Commit. Andersherum
haetten wir bei einem Ruecklauf Nachrichten, die auf Dateien
zeigen, die es nicht mehr gibt.
===================================================================== */
chatRouter.delete("/workspace/api/chat/raeume/:id/ganz", gleicheHerkunft, (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)) return res.status(404).json({ fehler: "nicht_gefunden" });
const d = db();
const raum = d.prepare(
"SELECT id, art, name, kategorie, erstellt_von FROM chat_raeume WHERE id = ?").get(raumId);
if (!raum) return res.status(404).json({ fehler: "nicht_gefunden" });
if (raum.art === "direkt") return res.status(403).json({ fehler: "nur_gruppe_oder_kanal" });
/* Dieselbe Regel wie fuer den Knopf -- nicht "auch so eine". Waere
hier eine zweite Bedingung ausgeschrieben, koennte der Knopf
eines Tages erscheinen, wo der Weg ablehnt, oder umgekehrt. */
if (!darfAufloesen(raum, req.person)) {
return res.status(403).json({ fehler: "nur_gruender" });
}
const dateien = d.prepare(
`SELECT anhang_datei FROM chat_nachrichten
WHERE raum_id = ? AND anhang_datei IS NOT NULL`)
.all(raumId).map((z) => z.anhang_datei);
const wieViele = d.prepare(
"SELECT COUNT(*) AS n FROM chat_nachrichten WHERE raum_id = ?").get(raumId).n;
const leute = teilnehmerVon(raumId).map((t) => t.id);
/* Schritt 1: erst sagen, dann loeschen (siehe oben). */
chatEreignis(raumId, { raum_id: raumId, aufgeloest: true }, [], "raum-weg");
/* Schritt 2: alles in einem Zug. Ein halb aufgeloester Raum --
Nachrichten weg, Raum noch da -- waere in jeder Liste ein
leerer Eintrag, den niemand mehr loswird. */
d.exec("BEGIN");
try {
d.prepare("DELETE FROM chat_nachrichten WHERE raum_id = ?").run(raumId);
d.prepare("DELETE FROM chat_teilnehmer WHERE raum_id = ?").run(raumId);
d.prepare("DELETE FROM chat_raeume WHERE id = ?").run(raumId);
d.exec("COMMIT");
} catch (fehler) {
try { d.exec("ROLLBACK"); } catch { /* schon zurueck */ }
throw fehler;
}
/* Schritt 3: die Anhaenge. Was hier liegen bleibt, ist Muell --
aber kein Datenschaden, und es wird gemeldet statt verschwiegen. */
let dateienWeg = 0;
for (const name of dateien) {
try { unlinkSync(join(ANHANG_ORDNER, name)); dateienWeg++; }
catch (f) {
if (f?.code === "ENOENT") dateienWeg++;
else console.error("[chat] Anhang bleibt liegen:", f?.message);
}
}
protokolliere("chat_aufgeloest", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `${raum.art} #${raumId} "${raum.name || raum.kategorie || ""}": `
+ `${wieViele} Nachrichten, ${leute.length} Beteiligte, `
+ `${dateienWeg}/${dateien.length} Anhaenge`.slice(0, 200),
});
res.json({ ok: true, nachrichten: wieViele, beteiligte: leute.length,
anhaenge: dateien.length });
} catch (fehler) {
console.error("[chat] Aufloesen:", 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.id > t.geloescht_bis
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)) 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" });
}
});
/* =====================================================================
ANKUENDIGUNGEN ANHEFTEN (10.09.2026, Kapitel 7.2)
"Ankuendigungs-Funktion: Pin-Nachrichten von Owner/rechte Hand an
alle."
KEINE ZWEITE SORTE NACHRICHT. Naheliegend waere eine eigene Tabelle
"ankuendigungen" gewesen -- und damit ein zweiter Verlauf mit
eigenen Rechten, eigener Suche, eigenem Loeschen. Angeheftet wird
deshalb eine ganz normale Nachricht: Sie steht im Verlauf, sie ist
ueber die Suche zu finden, sie laesst sich zuruecknehmen, und sie
traegt zusaetzlich ein Datum und einen Namen.
WAS PASSIERT, WENN DER ABSENDER SIE ZURUECKNIMMT: Sie faellt von
selbst ab (siehe unten im Zuruecknehmen). Ein Aushang, dessen Text
"Nachricht zurueckgenommen" lautet, waere die schlechteste aller
Antworten -- er steht oben, faellt auf und sagt nichts.
===================================================================== */
chatRouter.put("/workspace/api/chat/nachrichten/:id/anheften", gleicheHerkunft,
express.json({ limit: "2kb" }), (req, res) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
const d = db();
const n = d.prepare(
"SELECT id, raum_id, weg_am, angeheftet_am FROM chat_nachrichten WHERE id = ?").get(id);
/* Erst die Mitgliedschaft (404 -- was man nicht sehen darf,
existiert nicht), dann das Recht (403 -- man sieht es, darf es
aber nicht). Zwei verschiedene Antworten fuer zwei verschiedene
Faelle; sie zu vermischen hiesse, ueber die eine die andere
verraten zu koennen. */
if (!n || !istDrin(n.raum_id, req.person)) {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
if (!darfAnheften(req.person, n.raum_id)) {
return res.status(403).json({ fehler: "Anheften darf nur die Leitung." });
}
const an = req.body?.an !== false;
if (an && n.weg_am) {
return res.status(400).json({ fehler: "Eine zurückgenommene Nachricht geht nicht." });
}
if (an && !n.angeheftet_am) {
const schon = d.prepare(`SELECT COUNT(*) AS c FROM chat_nachrichten
WHERE raum_id = ? AND angeheftet_am IS NOT NULL AND weg_am IS NULL`).get(n.raum_id).c;
if (schon >= PIN_MAX) {
return res.status(409).json({
fehler: `Es hängen schon ${PIN_MAX} oben. Löse zuerst eine ab.`,
});
}
}
d.prepare("UPDATE chat_nachrichten SET angeheftet_am = ?, angeheftet_von = ? WHERE id = ?")
.run(an ? jetzt() : null, an ? req.person.id : null, id);
/* ALLE IM RAUM SEHEN ES SOFORT. Ein Aushang, den die anderen erst
beim naechsten Laden bemerken, ist bei einer kurzfristigen
Ansage genau einen Live-Abend zu spaet. Keine Push-Meldung
dazu: Das Anheften ist kein neuer Satz, sondern eine Betonung. */
chatEreignis(n.raum_id, { raum_id: n.raum_id, id, angeheftet: an }, [], "pin");
protokolliere(an ? "chat_angeheftet" : "chat_abgeheftet", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${id} in Raum #${n.raum_id}`,
});
res.json({ ok: true, angeheftet: an });
} catch (fehler) {
console.error("[chat] Anheften:", 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, anhang_datei FROM chat_nachrichten WHERE id = ?").get(id);
if (!n || !istDrin(n.raum_id, req.person)) {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
/* NUR EIGENE. DogFather ebenfalls nicht: Fremde Worte aus einem
Gespräch zu nehmen, in dem Absprachen stehen, wäre ein Eingriff
in die Geschichte -- und niemand könnte ihm nachher glauben. */
if (n.person_id !== req.person.id) {
return res.status(403).json({ fehler: "Nur eigene Nachrichten." });
}
/* EIN ANHANG WIRD WIRKLICH GELÖSCHT, nicht nur ausgeblendet.
Beim Text reicht das Leeren -- er steht nur in der Zeile. Eine
Datei liegt daneben auf der Platte und hätte ihre eigene Adresse
behalten. "Zurückgenommen" hiesse dann: im Verlauf weg, über den
Link weiter da. Das wäre keine Rücknahme, sondern der Anschein
einer. Erst die Zeile leeren, dann die Datei -- scheitert das
Entfernen (Datei schon weg), ist der Verweis trotzdem fort. */
const dateiWeg = n.anhang_datei;
/* UND SIE FAELLT VOM AUSHANG (10.09.2026). Ohne diese zwei
Spalten stuende die Nachricht weiter oben angeheftet -- mit dem
Text "Nachricht zurueckgenommen", an der auffaelligsten Stelle
des Raums. Die Anzeige filtert zwar auf `weg_am IS NULL`, aber
der Platz waere belegt: PIN_MAX zaehlt mit, und niemand kaeme
darauf, warum sich nichts mehr anheften laesst. */
db().prepare(`UPDATE chat_nachrichten
SET text = '', weg_am = ?,
angeheftet_am = NULL, angeheftet_von = NULL,
anhang_datei = NULL, anhang_name = NULL, anhang_art = NULL,
anhang_typ = NULL, anhang_groesse = NULL,
anhang_breite = NULL, anhang_hoehe = NULL
WHERE id = ?`).run(jetzt(), id);
if (dateiWeg) {
try { unlinkSync(join(ANHANG_ORDNER, dateiWeg)); }
catch (f) { if (f?.code !== "ENOENT") console.error("[chat] Anhang bleibt liegen:", f?.message); }
}
const empfaenger = teilnehmerVon(n.raum_id).map((t) => t.id);
chatEreignis(n.raum_id, { id, raum_id: n.raum_id, zurueckgenommen: true }, [], "weg");
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) {
/* `.size` UND NICHT `.length` (berichtigt 19.09.2026).
`zuschauer` haelt Mengen (`new Set()`), und eine Menge hat kein
`length` -- der Ausdruck war IMMER `undefined`, also immer
falsch, also wurde nie uebersprungen. Folge: Wer die Seite offen
hatte, bekam zusaetzlich zur Nachricht auf dem Bildschirm noch
eine Meldung aufs Handy.
Genau davor warnt der Kommentar drei Zeilen darueber: „der
schnellste Weg, dass er Benachrichtigungen abschaltet". Die
Absicht stand da, die Zeile tat das Gegenteil -- und `siehtZu()`
weiter unten macht es mit `.size` schon richtig. Gefunden beim
Nachverfolgen eines ganz anderen Fehlers. */
if ((zuschauer.get(personId) || new Set()).size) continue;
/* DIE ART GEHT MIT (18.09.2026). Sie stand als Parameter oben und
endete im Ereignisstrom -- die Benachrichtigung bekam sie nie zu
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);
}
}
/** Die Push-Benachrichtigung. Absichtlich träge geladen: Der Chat soll
* auch dann laufen, wenn am Push-Teil etwas klemmt. */
/** Was in der Benachrichtigung steht.
*
* ALS EIGENE FUNKTION UND EXPORTIERT, damit eine Pruefung sie lesen
* kann, ohne einen Push-Dienst nachzubauen. Genau hier stand der
* Fehler, den Filipe am 18.09.2026 gemeldet hat -- und er war von
* aussen nicht messbar, weil der Text tief in einer Funktion entstand,
* die nur der Push-Weg aufruft.
*
* @param art "anruf" oder alles andere
* @returns {{titel: string, text: string, ziel: string}}
*/
export function pushTextFuer(art, nachricht, raumId) {
const ziel = `/workspace/chat.html?raum=${raumId}`;
/* ---- EIN ANRUF IST KEINE NACHRICHT ----------------------------
Filipe: „wenn ich vanvan anrufe kriegt sie nur eine benarichtigung
aber keinen anruf was soll das."
Gemessen kam bei ihr an:
Nachricht von [object Object]
(kein Text)
Zwei Fehler auf einmal. `nachricht.von` ist beim Klingeln ein
OBJEKT (`{id, name}`) und keine Zeichenkette -- aus der Vorlage
wurde deshalb „[object Object]". Und einen `text` gibt es bei
einem Anruf gar nicht, also blieb die Zeile leer.
Dass es so weit kam, lag daran, dass die ART des Ereignisses hier
nie ankam: Jedes Ereignis wurde als Chatnachricht behandelt, auch
das Klingeln. */
if (art === "anruf") {
const wer = nachricht.von?.name || "Jemand";
return {
titel: `${wer} ruft an`,
text: nachricht.video ? "Videoanruf – tippen zum Rangehen"
: "Tippen zum Rangehen",
ziel,
};
}
return {
titel: `Nachricht von ${typeof nachricht.von === "string"
? nachricht.von : (nachricht.von?.name || "jemandem")}`,
/* 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)
|| (nachricht.anhang?.art === "bild" ? "Foto"
: nachricht.anhang?.art === "pdf" ? "PDF" : ""),
ziel,
};
}
async function meldeNeueNachricht(personId, raumId, nachricht, art = "neu") {
try {
const { benachrichtige } = await import("./workspace-push.js");
/* ---- EIN ANRUF IST KEINE NACHRICHT (18.09.2026) --------------
Filipe: „wenn ich vanvan anrufe kriegt sie nur eine
benarichtigung aber keinen anruf was soll das."
Gemessen kam bei ihr an:
Nachricht von [object Object]
(kein Text)
Zwei Fehler auf einmal. `nachricht.von` ist beim Klingeln ein
OBJEKT (`{id, name}`) und keine Zeichenkette -- aus der Vorlage
wurde deshalb „[object Object]". Und einen `text` gibt es bei
einem Anruf gar nicht, also blieb die Zeile leer.
Dass es ueberhaupt so weit kam, lag daran, dass die ART des
Ereignisses hier nie ankam: Jedes Ereignis wurde als
Chatnachricht behandelt, auch das Klingeln.
Jetzt steht dort, was los ist -- und beim Tippen landet man im
richtigen Gespraech, wo es dank der Route `/anruf/offen` auch
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));
} 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.id > t.geloescht_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" });
}
});
/* ---------- Fehler aus express.raw ---------------------------------------
Ist die Datei zu gross, bricht express.raw ab, BEVOR der eigene Code
laeuft -- das try/catch dort greift also nicht, und der Fehler landet
im allgemeinen Behandler als HTTP 500. Fuer den Absender saehe das
aus wie ein kaputter Server statt wie "Datei zu gross"; er wuerde es
wieder und wieder versuchen. Derselbe Behandler steht aus demselben
Grund in workspace-dateien.js.
Er muss ZULETZT stehen: Express erkennt ihn an den vier Parametern
und ruft ihn nur fuer Fehler aus den Routen davor auf. */
// eslint-disable-next-line no-unused-vars
chatRouter.use((fehler, req, res, next) => {
if (!req.path.startsWith("/workspace/api/chat")) return next(fehler);
if (fehler?.type === "entity.too.large" || fehler?.status === 413) {
return res.status(413).json({
fehler: `Zu groß – erlaubt sind ${Math.round(ANHANG_MAX / 1024 / 1024)} MB je Datei.`,
});
}
console.error("[chat] Unerwartet:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
});
/* Für Prüfungen und andere Module: Sieht diese Person gerade zu? */
/* =====================================================================
EINE NACHRICHT AUS EINEM ANDEREN MODUL SCHICKEN (11.09.2026)
Gebraucht vom Entwicklungs-Bereich: Dort steht, was DogFather und die
rechte Hand ueber die Zeit beobachten -- und die Person selbst sieht
diesen Bereich nicht. Damit Anerkennung trotzdem ankommt, laesst sich
ein Eintrag als Nachricht schicken.
WARUM HIER UND NICHT DORT: Ein Zweier-Gespraech darf es nur EINMAL
geben (siehe direktRaum), die Leseraender muessen mitwandern, der
Live-Strom und die Benachrichtigung haengen daran. Wer das im
Nachbarmodul nachbaut, hat zwei Fassungen -- und die zweite ist die,
in der jemand eine Nachricht nicht bekommt.
DIE REGEL "wer darf mit wem" WIRD MITGEPRUEFT, obwohl der einzige
Aufrufer heute ohnehin darf. Eine Funktion, die aus einem fremden
Modul heraus jeden erreichen kann, ist genau die Abkuerzung, die man
spaeter woanders benutzt.
===================================================================== */
export function nachrichtSchicken(person, anId, text) {
const ziel = Number(anId);
const satz = String(text ?? "").trim().slice(0, TEXT_MAX);
if (!person || !Number.isInteger(ziel) || ziel < 1 || !satz) return null;
if (!darfSchreibenMit(person, ziel)) return null;
if (!db().prepare("SELECT 1 FROM personen WHERE id = ? AND aktiv = 1").get(ziel)) return null;
const raumId = direktRaum(person, ziel);
const d = db();
const n = jetzt();
d.prepare("INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt) VALUES (?,?,?,?)")
.run(raumId, person.id, satz, n);
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);
d.prepare("UPDATE chat_teilnehmer SET gelesen_bis = ? WHERE raum_id = ? AND person_id = ?")
.run(id, raumId, person.id);
const nachricht = {
id, raum_id: raumId, von_id: person.id, von: person.name,
rolle: person.rolle, text: satz, erstellt: n,
zurueckgenommen: false, antwort: null, anhang: null,
};
chatEreignis(raumId, nachricht, [ziel], "neu");
return { id, raum_id: raumId };
}
/* =====================================================================
WAS DER ANRUF BRAUCHT (18.09.2026)
Drei Dinge, und alle drei gibt es hier schon: den Ereigniskanal, die
Zugangspruefung und die Teilnehmerliste. Sie werden ausdruecklich
hier freigegeben und nicht drueben nachgebaut -- eine zweite Fassung
von „wer darf in diesen Raum" waere die, die beim naechsten Umbau
nicht mitgeaendert wird, und genau daran haengt, wer mithoeren kann.
`istDrinFuerAnruf` und `teilnehmerFuerAnruf` heissen nicht wie die
inneren Funktionen, die sie weiterreichen: Die Namen sagen, WOFUER
sie freigegeben sind. Wer sie spaeter aendert, sieht am Namen, dass
noch jemand daran haengt.
===================================================================== */
/** Darf diese Person in diesem Raum telefonieren?
* Dieselbe Regel wie fuers Mitlesen -- es ist dieselbe Frage. */
export function istDrinFuerAnruf(raumId, person) {
return istDrin(raumId, person);
}
/** Wer ist in diesem Raum? Fuer die Frage „wie viele koennen mitmachen". */
export function teilnehmerFuerAnruf(raumId) {
return teilnehmerVon(raumId);
}
/** Der Ereigniskanal -- der Hinweg fuer Klingeln und Verbindungsdaten. */
export { chatEreignis };
export function siehtZu(personId) {
return (zuschauer.get(Number(personId)) || []).size > 0;
}