Filipe: "ich will dass ich genau so eine kategorie habe in der dogfather und rechten hand rollen, wo wir aufgaben oder bewertungen ueber modis eingeben koennen. auch fuer zuschauer die vielleicht modis werden koennten waere auch geil. mach dich schlau informier dich so krass wie es nur geht, hol die besten der besten und krassesten sachen, perfektionnier die dan auch alle und dan erst setzt du alles um." === WAS DIE RECHERCHE ERGEBEN HAT === DREI BEFUNDE HABEN DAS DESIGN BESTIMMT, und der erste hat es fast umgedreht: WARUM MODERATOREN AUFHOEREN (Schoepke-Gonzalez u. a., New Media & Society 2024): zwei Hauptgruende -- zu wenig Zeit, und Konflikte im Team beziehungsweise schaedliches Verhalten der Leitung. Eine Bewertungsfunktion ist damit genau das Werkzeug, mit dem man ein Team verliert, wenn man sie als Ueberwachung baut. Das ist kein Bauchgefuehl und keine Zimperlichkeit -- es ist der haeufigste gemessene Grund. WAS HAELT: Anerkennung. Dank und Rueckmeldung erhoehen die Verweildauer messbar; Rollenklarheit senkt Burnout. WORAN MAN GUTE MODERATOREN ERKENNT (ModSquad, Kitfox Games, Discord-Leitfaeden): nicht an Zahlen. Ruhig bleiben, wenn es hitzig wird; von selbst helfen; die Regeln UND die Leute kennen; regelmaessig da sein. Ausdruecklich NICHT: "schreibt viel" -- angenehm im Chat zu sein ist nachweislich etwas anderes. Und der treffsicherste Weg ueberhaupt ist die Empfehlung aus dem Team, gefolgt von einer Probezeit. === WAS DARAUS GEBAUT WURDE === ENTWICKLUNG. Keine Note, sondern eine Aufzeichnung ueber die Zeit, je Person. Fuenf Arten, und ihre REIHENFOLGE ist Absicht: "Das laeuft gut" und "Danke dafuer" stehen vorn. Wer ein Formular oeffnet, dessen erstes Feld "Problem" heisst, schreibt Probleme auf. "ZU VIEL GERADE" IST DIE WICHTIGSTE ART und die, die es sonst nirgends gibt. Der haeufigste Grund zu gehen ist Zeitmangel, und der zeigt sich frueh -- nur schreibt ihn niemand auf, weil es kein Feld dafuer gibt. TALENTE. Die vier Merkmale sind die aus der Literatur, nicht ausgedacht, dazu die Empfehlung aus dem Team. Der Status IST die Probezeit: `offen` heisst beobachtet, `angenommen` heisst angesprochen -- und was daraus wird, entscheidet sich in "Personen & Zugaenge" mit einem Zugang auf Stufe "Probe". Eine eigene Kandidatentabelle waere ein zweiter Ort fuer dieselbe Frage. DIE BRUECKE. Die Person sieht den Entwicklungs-Bereich NICHT -- eine halb sichtbare Akte ist schlimmer als eine geschlossene, weil niemand mehr weiss, was der andere gerade liest. Damit Anerkennung trotzdem ankommt, laesst sich jeder Eintrag EINMAL als Nachricht in den Chat schicken, mit Art, Titel und dem, was daraus folgen soll. Danach steht im Eintrag, wann es geschehen ist: Die Frage "habe ich ihr das eigentlich schon gesagt?" beantwortet man nach zwei Wochen falsch. ZWEI KACHELN UND NICHT DREI: Aufgaben an das Team gibt es laengst, mit Person, Frist und Status. Eine zweite Stelle dafuer waere ein zweiter Ort, an dem man nachsehen muesste, welche Aufgabe wirklich gilt. DIE GRUPPE HEISST NICHT "TEAM FUEHREN". Das waere der bequeme Name und der falsche: Im Haus gilt, dass DogFather nicht ueber seinem Team steht. "Entwicklung & Nachwuchs" sagt, was drinsteht. === KEIN NEUER BAUKASTEN === Beides sind BEREICHE wie das Ideen-Board: dieselbe Tabelle, dieselbe Seite, dieselben Regeln fuers Anlegen, Aendern und Loeschen. Neu sind eine Spalte (`gesendet_am`) und eine Route. Die Sendefunktion selbst steht im Chat-Modul und nicht daneben: Ein Zweier-Gespraech darf es nur einmal geben, die Leseraender muessen mitwandern, Live-Strom und Benachrichtigung haengen daran -- wer das nachbaut, hat zwei Fassungen, und die zweite ist die, in der jemand eine Nachricht nicht bekommt. === WAS DIE PRUEFUNG GEFUNDEN HAT === "Zugeordneter Creator existiert nicht" -- beim Anlegen eines Eintrags ueber ein Teammitglied. Die Regel verlangte einen Creator; bei Entwicklung geht es um Menschen aus dem Team. Die Meldung war dabei selbst irrefuehrend: Die Person existiert sehr wohl, sie ist nur kein Creator. Beides behoben. Und meine EIGENE Pruefung von vor einer Stunde wurde rot: Sie verlangte, dass jede Zusatzkachel in der Gruppe "Team Dogi" steht. Die naheliegende Reparatur waere gewesen, die Gruppe gar nicht mehr zu pruefen -- das haette die Aussage weggeworfen. Jetzt steht die erlaubte Liste ausgeschrieben da: Eine Kachel fuer zwei Menschen darf nicht in einer Gruppe landen, die alle sehen. Kommt eine dritte Gruppe, wird die Zeile rot, und das ist Absicht. Die zwei neuen Farbtoene trennen ueber Saettigung und Helligkeit statt ueber den Farbwinkel -- bei 25 Toenen ist der Kreis voll, und die groessten Luecken waeren ein drittes Gruen zwischen zwei vorhandenen. Bei einer achtundzwanzigsten Kachel brauchen die Kacheln Gruppenfarben statt Einzelfarben; das steht als Notiz im Quelltext. pruef-entwicklung 33 (neu) · pruef-start-ansicht 27 Kacheln, sechs Gruppen · pruef-rollen 278 -> 282 · pruef-modi-checkliste 70 · pruef-haus-trennung 62 · pruef-rueckmeldung 30 · pruef-modi-ideen 30 · pruef-modi-verborgen 80 · pruef-css-klassen gruen · pruef-modi-wortleck 5. Co-Authored-By: Claude Opus 5 <[email protected]>
1593 lines
72 KiB
JavaScript
1593 lines
72 KiB
JavaScript
/* =====================================================================
|
||
workspace-chat.js — Gespräche zwischen den Leuten im Workspace.
|
||
|
||
Wunsch Filipe, 06.09.2026: *"eine kategorie chat, wo die creator mit
|
||
ihren manager nachrichten austauschen können, wie erinnerungen,
|
||
fragen und noch vieles mehr, wo jeder immer mit denen schreiben kann
|
||
wie verbindung auch läuft, und jeder kann dann eine nachricht
|
||
reinschreiben, die andere person bekommt eine benachrichtigung und
|
||
kann dann antworten."*
|
||
|
||
Auf Nachfrage entschieden: Zweier-Gespräche UND Gruppen; Manager und
|
||
Scouts dürfen sich auch ohne gemeinsamen Creator schreiben.
|
||
|
||
---------------------------------------------------------------------
|
||
DIE DREI REGELN, die alles andere tragen
|
||
|
||
1. WER MIT WEM steht an EINER Stelle: schreibbareIds() in
|
||
workspace.js. Nicht hier, nicht in der Oberfläche. Jede Route
|
||
fragt dieselbe Funktion -- eine zweite Regel wäre die sichere
|
||
Zusage, dass beide irgendwann auseinanderlaufen.
|
||
|
||
2. WER IM RAUM IST, entscheidet über den Verlauf. Die Mitgliedschaft
|
||
wird bei JEDEM Zugriff geprüft, nicht nur beim Öffnen. Sonst
|
||
könnte man eine Raumnummer erraten und mitlesen.
|
||
|
||
3. EINE NACHRICHT WIRD NICHT GELÖSCHT, sondern geleert. Ein Loch im
|
||
Verlauf wirft mehr Fragen auf als der Hinweis, dass jemand etwas
|
||
zurückgenommen hat -- und in einem Gespräch, in dem Absprachen
|
||
stehen, ist Nachvollziehbarkeit mehr wert als ein sauberer Schnitt.
|
||
|
||
---------------------------------------------------------------------
|
||
WAS BEWUSST FEHLT
|
||
|
||
Kein Bearbeiten einer abgeschickten Nachricht. Wer eine Absprache
|
||
nachträglich umschreiben kann, macht den Verlauf wertlos --
|
||
zurücknehmen und neu schreiben ist ehrlicher und hinterlässt eine
|
||
Spur. (Zurücknehmen selbst ist da, siehe weg_am.)
|
||
===================================================================== */
|
||
|
||
import express from "express";
|
||
import { 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";
|
||
|
||
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) {
|
||
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);
|
||
|
||
const reihen = db().prepare(`
|
||
SELECT r.id, r.art, r.name, r.kategorie, r.letzte_am,
|
||
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,
|
||
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)) : [],
|
||
});
|
||
} 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 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 })),
|
||
},
|
||
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. */
|
||
nachrichten: reihen.reverse().map((n) => ({
|
||
id: n.id, von_id: n.person_id, von: n.von || "Gelöscht", rolle: n.rolle,
|
||
von_bild: n.von_bild ? `/workspace/api/steckbrief/bild/${n.von_bild}` : null,
|
||
text: n.weg_am ? null : n.text,
|
||
zurueckgenommen: !!n.weg_am,
|
||
erstellt: n.erstellt,
|
||
selbst: n.person_id === req.person.id,
|
||
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" });
|
||
|
||
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);
|
||
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" });
|
||
}
|
||
});
|
||
|
||
/* =====================================================================
|
||
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) {
|
||
if ((zuschauer.get(personId) || []).length) continue;
|
||
void meldeNeueNachricht(personId, raumId, nachricht);
|
||
}
|
||
}
|
||
|
||
/** Die Push-Benachrichtigung. Absichtlich träge geladen: Der Chat soll
|
||
* auch dann laufen, wenn am Push-Teil etwas klemmt. */
|
||
async function meldeNeueNachricht(personId, raumId, nachricht) {
|
||
try {
|
||
const { benachrichtige } = await import("./workspace-push.js");
|
||
await benachrichtige(personId, "chat_nachricht", {
|
||
titel: `Nachricht von ${nachricht.von}`,
|
||
/* Der Anfang der Nachricht steht in der Meldung -- eine
|
||
Benachrichtigung, die nur "Neue Nachricht" sagt, zwingt zum
|
||
Öffnen, um zu erfahren, ob es wichtig war. */
|
||
text: String(nachricht.text || "").slice(0, 120)
|
||
|| (nachricht.anhang?.art === "bild" ? "Foto"
|
||
: nachricht.anhang?.art === "pdf" ? "PDF" : ""),
|
||
ziel: `/workspace/chat.html?raum=${raumId}`,
|
||
});
|
||
} catch (fehler) {
|
||
console.error("[chat] Benachrichtigung:", fehler?.message);
|
||
}
|
||
}
|
||
|
||
chatRouter.get("/workspace/api/chat/strom", (req, res) => {
|
||
res.set({
|
||
"Content-Type": "text/event-stream",
|
||
"Cache-Control": "no-cache, no-transform",
|
||
Connection: "keep-alive",
|
||
/* Ohne das puffert ein davorliegender Reverse Proxy (Caddy, nginx)
|
||
den Strom -- und dann kommt die Nachricht erst, wenn genug
|
||
zusammen ist. Genau das Gegenteil des Zwecks. */
|
||
"X-Accel-Buffering": "no",
|
||
});
|
||
res.flushHeaders?.();
|
||
res.write("retry: 3000\n\n");
|
||
|
||
const personId = req.person.id;
|
||
if (!zuschauer.has(personId)) zuschauer.set(personId, new Set());
|
||
zuschauer.get(personId).add(res);
|
||
|
||
/* Ein Lebenszeichen alle 25 Sekunden. Ohne das schliessen manche
|
||
Vermittlungsstellen die Verbindung nach einer Minute Stille -- der
|
||
Browser verbindet sich dann zwar neu, aber jedes Mal mit einer
|
||
Lücke von ein paar Sekunden. */
|
||
const puls = setInterval(() => {
|
||
try { res.write(": puls\n\n"); } catch { /* gleich weg */ }
|
||
}, 25_000);
|
||
puls.unref?.();
|
||
|
||
const aufraeumen = () => {
|
||
clearInterval(puls);
|
||
const menge = zuschauer.get(personId);
|
||
if (menge) {
|
||
menge.delete(res);
|
||
if (!menge.size) zuschauer.delete(personId);
|
||
}
|
||
};
|
||
req.on("close", aufraeumen);
|
||
req.on("error", aufraeumen);
|
||
});
|
||
|
||
/** Wie viele Nachrichten warten insgesamt auf mich?
|
||
* Für die Zahl in der Kopfleiste -- eine Abfrage statt einer je Raum. */
|
||
chatRouter.get("/workspace/api/chat/ungelesen", (req, res) => {
|
||
try {
|
||
const z = db().prepare(`
|
||
SELECT COUNT(*) AS n FROM chat_nachrichten m
|
||
JOIN chat_teilnehmer t ON t.raum_id = m.raum_id AND t.person_id = ?
|
||
WHERE t.raus_am IS NULL AND m.id > t.gelesen_bis AND m.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 };
|
||
}
|
||
|
||
export function siehtZu(personId) {
|
||
return (zuschauer.get(Number(personId)) || []).size > 0;
|
||
}
|