VIER DINGE AUS DEN BILDSCHIRMFOTOS, jedes ein echter Fehler:
1. SPICY MEDIA SAH DOGFATHERS DATEN. Beim ersten Anlauf bekam die Rolle
dieselbe Regel wie DogFather (`1=1`) -- damit stimmte der Ueberblick
ueber Manager, Scouts und Creator, und nebenbei standen seine eigenen
Termine, Aufgaben, Dateien und Eintraege mit drin. Jetzt gibt es
ohneDogFather() als EINE Stelle dafuer: Zwei Spalten werden geprueft,
`creator_id` (um wen geht es) und `erstellt_von` (wer hat es
geschrieben) -- ein Termin, den er sich selbst anlegt, haengt nur an
der zweiten. `IS NULL OR NOT IN` und nicht bloss `NOT IN`: In SQL ist
`NULL NOT IN (...)` weder wahr noch falsch, die Zeile fiele
stillschweigend heraus.
2. "PERSOENLICHER ZUGANGSCODE · undefined" auf der Anmeldeseite. Eine
zweite Namensliste im Browser, die beim Hinzufuegen der Rolle
niemand gepflegt hat. Ein unbekannter Schluessel faellt jetzt auf
sich selbst zurueck statt auf `undefined` -- haesslich, aber
sichtbar.
3. SPICY KAM NICHT IN DIE PERSONENVERWALTUNG. Der Server liess sie
herein, das Skript warf sie wieder hinaus: zwei Listen fuer dieselbe
Frage, gepflegt wurde nur die erste.
4. DIE ANMELDEKARTE WAR ZU KLEIN FUER FUENF ROLLEN. Der Kommentar an
genau dieser Stelle warnt woertlich davor -- und ich habe getan,
wovor er warnt: eine Rolle eingefuegt und die Zahl daneben nicht
angefasst. Gemessen: Inhalt 573 px in einer 526 px hohen Tafel, die
Fusszeile stand unter dem Rahmen. Schrift kleiner half nicht (sie lag
schon auf dem Anschlag), also sind die Abstaende an neun Stellen
enger. Nachgemessen auf fuenf Groessen: passt ueberall.
DIE ZEIT WAR WIRKLICH FALSCH. Nicht nur in meinem Satz: `datum()` in
personen.js schnitt die ISO-Zeichenkette ab -- und die ist UTC. Im
Protokoll stand 03:00, wo 05:00 war. Das Tueckische daran ist, dass es
nie kaputt aussieht: Eine Uhrzeit ist immer plausibel.
PROFILBILDER WERDEN JETZT GEZEICHNET. Der Server lieferte sie seit
gestern mit, gezeichnet wurden sie nirgends -- deshalb aenderte sich
nichts. Jetzt in der Personenauswahl (Aufgaben, Termine, Dateien,
Bereiche), in der Gespraechsliste und an jeder Nachricht. Der
Anfangsbuchstabe bleibt als Unterlage LIEGEN: Faellt das Bild aus, steht
dort weiter etwas Sinnvolles.
DER CHAT: Gesichter mit Rollenfarbe, Blasen mit Richtung (die erste
einer Folge eckig, die naechsten rund -- so sieht man, wo ein Gedanke
anfaengt), das offene Gespraech mit Schiene, Ungelesenes hervorgehoben,
und das Eingabefeld in einer eigenen Leiste, die sich beim Schreiben
hebt.
ZWEI EIGENE PATZER, beide von Pruefungen gefunden:
- `o is not defined`: Mein Suchmuster hat die zwei Zeilen fuer das
Bild ans DATEIENDE gesetzt statt in die Schleife -- und in
kalender.js an einen <span> statt ans <option>. Gemeldet von
pruef-sicht, das die Browserkonsole mitliest.
- Ein Kommentar mit `bild` in schraegen Anfuehrungszeichen stand INNEN
in einer Vorlagenzeichenkette und hat sie geschlossen. Die halbe SQL
wurde zu Programmtext.
Dazu: `.chat-neu` gibt es nicht (heisst `.chat__eingabe`), und der
Rollenknopf hatte fuer den Manager keinen sichtbaren Fokus -- ein
box-shadow wird von `overflow: hidden` abgeschnitten, ein outline nicht.
Gruen: spicy (43), creator-anlegen (29), personen-liste (33), sicht (48),
chat (48), chat-optik (24), buehne (38), struktur (32), css-klassen (15),
handy (59), breiten (23), formulare (19), start-ansicht (136),
lesbarkeit (14), barrierefrei (18), tempo (8), kopf-messen (3),
glocke (26), haerte (20), manager-sicht (43), alle-wege (19),
aufgabenbrett (44), kalender (84).
Co-Authored-By: Claude Opus 5 <[email protected]>
553 lines
23 KiB
JavaScript
553 lines
23 KiB
JavaScript
/* =====================================================================
|
|
workspace-chat.js — Gespräche zwischen den Leuten im Workspace.
|
|
|
|
Wunsch Filipe, 06.09.2026: *"eine kategorie chat, wo die creator mit
|
|
ihren manager nachrichten austauschen können, wie erinnerungen,
|
|
fragen und noch vieles mehr, wo jeder immer mit denen schreiben kann
|
|
wie verbindung auch läuft, und jeder kann dann eine nachricht
|
|
reinschreiben, die andere person bekommt eine benachrichtigung und
|
|
kann dann antworten."*
|
|
|
|
Auf Nachfrage entschieden: Zweier-Gespräche UND Gruppen; Manager und
|
|
Scouts dürfen sich auch ohne gemeinsamen Creator schreiben.
|
|
|
|
---------------------------------------------------------------------
|
|
DIE DREI REGELN, die alles andere tragen
|
|
|
|
1. WER MIT WEM steht an EINER Stelle: schreibbareIds() in
|
|
workspace.js. Nicht hier, nicht in der Oberfläche. Jede Route
|
|
fragt dieselbe Funktion -- eine zweite Regel wäre die sichere
|
|
Zusage, dass beide irgendwann auseinanderlaufen.
|
|
|
|
2. WER IM RAUM IST, entscheidet über den Verlauf. Die Mitgliedschaft
|
|
wird bei JEDEM Zugriff geprüft, nicht nur beim Öffnen. Sonst
|
|
könnte man eine Raumnummer erraten und mitlesen.
|
|
|
|
3. EINE NACHRICHT WIRD NICHT GELÖSCHT, sondern geleert. Ein Loch im
|
|
Verlauf wirft mehr Fragen auf als der Hinweis, dass jemand etwas
|
|
zurückgenommen hat -- und in einem Gespräch, in dem Absprachen
|
|
stehen, ist Nachvollziehbarkeit mehr wert als ein sauberer Schnitt.
|
|
|
|
---------------------------------------------------------------------
|
|
WAS BEWUSST FEHLT
|
|
|
|
Kein Bearbeiten einer abgeschickten Nachricht. Wer eine Absprache
|
|
nachträglich umschreiben kann, macht den Verlauf wertlos --
|
|
zurücknehmen und neu schreiben ist ehrlicher und hinterlässt eine
|
|
Spur. (Zurücknehmen selbst ist da, siehe weg_am.)
|
|
===================================================================== */
|
|
|
|
import express from "express";
|
|
import {
|
|
db, protokolliere, echteIp, sitzungLesen, istDogFather,
|
|
schreibbareIds, darfSchreibenMit, ROLLEN_SORTIERUNG,
|
|
} from "./workspace.js";
|
|
|
|
export const chatRouter = express.Router();
|
|
|
|
const TEXT_MAX = 4000;
|
|
const NAME_MAX = 80;
|
|
const jetzt = () => new Date().toISOString();
|
|
|
|
/* ---------- Schranke ---------------------------------------------------- */
|
|
|
|
function angemeldet(req, res, next) {
|
|
const person = sitzungLesen(req);
|
|
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
|
|
req.person = person;
|
|
next();
|
|
}
|
|
|
|
function gleicheHerkunft(req, res, next) {
|
|
const herkunft = req.get("origin");
|
|
if (!herkunft) return next();
|
|
let erlaubt;
|
|
try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; }
|
|
if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" });
|
|
next();
|
|
}
|
|
|
|
chatRouter.use("/workspace/api/chat", angemeldet);
|
|
|
|
/* =====================================================================
|
|
Bausteine
|
|
===================================================================== */
|
|
|
|
/** Ist die Person in diesem Raum -- und nicht ausgetreten?
|
|
*
|
|
* DIE WICHTIGSTE FUNKTION DER DATEI. Jede Route, die einen Verlauf
|
|
* liest oder etwas hineinschreibt, geht hier durch. Wer sie umgeht,
|
|
* macht aus einer Raumnummer einen Schlüssel. */
|
|
function istDrin(raumId, personId) {
|
|
try {
|
|
const z = db().prepare(
|
|
"SELECT 1 FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ? AND raus_am IS NULL")
|
|
.get(raumId, personId);
|
|
return !!z;
|
|
} catch { return false; }
|
|
}
|
|
|
|
/** Alle (noch aktiven) Teilnehmer eines Raums. */
|
|
/* Das Profilbild gehoert dazu (07.09.2026). Ohne es zeigt die
|
|
Gespraechsliste Initialen, obwohl ein Bild hochgeladen ist -- der
|
|
Fehler liegt dann nicht in der Anzeige, sondern in der Abfrage. Die
|
|
fertige ADRESSE wird danach gebaut, nicht der Dateiname
|
|
weitergereicht: Sonst setzt jede Stelle im Browser denselben Pfad
|
|
zusammen.
|
|
|
|
UND DER KOMMENTAR STEHT AUSSERHALB DER ABFRAGE. Beim ersten Anlauf
|
|
stand er darin -- mit dem Wort `bild` in schraegen Anfuehrungszeichen.
|
|
Die beenden in JavaScript eine Vorlagenzeichenkette; die halbe SQL
|
|
wurde damit zu Programmtext, und die Datei liess sich nicht mehr
|
|
lesen. Ein Kommentar, der die Zeichenkette schliesst, in der er
|
|
steht. */
|
|
function teilnehmerVon(raumId) {
|
|
return db().prepare(`
|
|
SELECT t.person_id AS id, p.name, p.rolle, p.bild, t.leitung, t.gelesen_bis
|
|
FROM chat_teilnehmer t
|
|
JOIN personen p ON p.id = t.person_id
|
|
WHERE t.raum_id = ? AND t.raus_am IS NULL
|
|
ORDER BY ` + ROLLEN_SORTIERUNG + ", p.name").all(raumId)
|
|
.map((z) => ({ ...z, bild: z.bild ? `/workspace/api/steckbrief/bild/${z.bild}` : null }));
|
|
}
|
|
|
|
/** Findet das Zweier-Gespräch der beiden -- oder legt es an.
|
|
*
|
|
* ES DARF NUR EINES GEBEN. Zwei Gespräche zwischen denselben zwei
|
|
* Leuten wären ein Verlauf an zwei Orten: Man schreibt in das eine,
|
|
* der andere liest im anderen und wartet. Deshalb wird zuerst
|
|
* gesucht, und zwar über die Teilnehmer -- ein "Paarschlüssel" als
|
|
* Spalte wäre eine zweite Wahrheit neben chat_teilnehmer. */
|
|
function direktRaum(personA, idB, { anlegen = true } = {}) {
|
|
const d = db();
|
|
const vorhanden = d.prepare(`
|
|
SELECT r.id FROM chat_raeume r
|
|
WHERE r.art = 'direkt'
|
|
AND EXISTS (SELECT 1 FROM chat_teilnehmer t WHERE t.raum_id = r.id AND t.person_id = ?)
|
|
AND EXISTS (SELECT 1 FROM chat_teilnehmer t WHERE t.raum_id = r.id AND t.person_id = ?)
|
|
AND (SELECT COUNT(*) FROM chat_teilnehmer t WHERE t.raum_id = r.id) = 2`)
|
|
.get(personA.id, idB);
|
|
if (vorhanden) return vorhanden.id;
|
|
if (!anlegen) return null;
|
|
|
|
const n = jetzt();
|
|
d.prepare("INSERT INTO chat_raeume (art, erstellt, erstellt_von, letzte_am) VALUES ('direkt',?,?,NULL)")
|
|
.run(n, personA.id);
|
|
const raumId = d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
|
const rein = d.prepare(
|
|
"INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis) VALUES (?,?,?,0)");
|
|
rein.run(raumId, personA.id, n);
|
|
rein.run(raumId, idB, n);
|
|
return raumId;
|
|
}
|
|
|
|
/** Wie heißt dieser Raum für DIESE Person?
|
|
*
|
|
* Ein Zweier-Gespräch heißt für jede Seite anders -- nach dem
|
|
* Gegenüber. Deshalb steht der Name nicht in der Tabelle: Er wäre für
|
|
* eine der beiden Seiten immer falsch. */
|
|
function raumName(raum, teilnehmer, ichId) {
|
|
if (raum.art === "gruppe") return raum.name || "Gruppe";
|
|
const andere = teilnehmer.filter((t) => t.id !== ichId);
|
|
return andere.map((t) => t.name).join(", ") || "Gespräch";
|
|
}
|
|
|
|
/* =====================================================================
|
|
Die Schnittstellen
|
|
===================================================================== */
|
|
|
|
/** Mit wem kann ich ein Gespräch anfangen? */
|
|
chatRouter.get("/workspace/api/chat/partner", (req, res) => {
|
|
try {
|
|
const ids = schreibbareIds(req.person);
|
|
const wo = ids === null
|
|
? "id <> ?" : `id IN (${ids.map(() => "?").join(",")})`;
|
|
const werte = ids === null ? [req.person.id] : ids;
|
|
if (ids !== null && !ids.length) return res.json({ personen: [] });
|
|
|
|
res.json({
|
|
personen: db().prepare(
|
|
`SELECT id, name, rolle FROM personen WHERE aktiv = 1 AND ${wo}
|
|
ORDER BY ` + ROLLEN_SORTIERUNG + ", name").all(...werte),
|
|
});
|
|
} catch (fehler) {
|
|
console.error("[chat] Partner:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/** Meine Gespräche, das jüngste zuerst. */
|
|
chatRouter.get("/workspace/api/chat/raeume", (req, res) => {
|
|
try {
|
|
const reihen = db().prepare(`
|
|
SELECT r.id, r.art, r.name, r.letzte_am, t.gelesen_bis, t.leitung
|
|
FROM chat_raeume r
|
|
JOIN chat_teilnehmer t ON t.raum_id = r.id AND t.person_id = ?
|
|
WHERE t.raus_am IS NULL
|
|
ORDER BY CASE WHEN r.letzte_am IS NULL THEN 1 ELSE 0 END, r.letzte_am DESC, r.id DESC`)
|
|
.all(req.person.id);
|
|
|
|
const raeume = reihen.map((r) => {
|
|
const leute = teilnehmerVon(r.id);
|
|
/* Die letzte Nachricht als Vorschau -- ohne sie ist eine
|
|
Gesprächsliste nur eine Namensliste. */
|
|
const letzte = db().prepare(`
|
|
SELECT n.text, n.erstellt, n.weg_am, p.name AS von
|
|
FROM chat_nachrichten n
|
|
LEFT JOIN personen p ON p.id = n.person_id
|
|
WHERE n.raum_id = ? ORDER BY n.id DESC LIMIT 1`).get(r.id);
|
|
const ungelesen = db().prepare(`
|
|
SELECT COUNT(*) AS n FROM chat_nachrichten
|
|
WHERE raum_id = ? AND id > ? AND person_id <> ?`)
|
|
.get(r.id, r.gelesen_bis, req.person.id).n;
|
|
|
|
return {
|
|
id: r.id,
|
|
art: r.art,
|
|
name: raumName(r, leute, req.person.id),
|
|
leitung: !!r.leitung,
|
|
teilnehmer: leute.map((t) => ({ id: t.id, name: t.name, rolle: t.rolle })),
|
|
letzte: letzte ? {
|
|
text: letzte.weg_am ? null : letzte.text.slice(0, 140),
|
|
zurueckgenommen: !!letzte.weg_am,
|
|
von: letzte.von, erstellt: letzte.erstellt,
|
|
} : null,
|
|
ungelesen,
|
|
};
|
|
});
|
|
res.json({ raeume });
|
|
} catch (fehler) {
|
|
console.error("[chat] Räume:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/** Ein Zweier-Gespräch öffnen (und beim ersten Mal anlegen). */
|
|
chatRouter.post("/workspace/api/chat/direkt", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => {
|
|
try {
|
|
const mit = Number(req.body?.mit);
|
|
if (!Number.isInteger(mit) || mit < 1) return res.status(400).json({ fehler: "ungueltig" });
|
|
/* DIE REGEL SITZT HIER, nicht in der Oberfläche. Wer die Anfrage
|
|
von Hand baut, kommt damit keinen Schritt weiter. */
|
|
if (!darfSchreibenMit(req.person, mit)) {
|
|
return res.status(403).json({ fehler: "Mit dieser Person kannst du nicht schreiben." });
|
|
}
|
|
const ziel = db().prepare("SELECT id FROM personen WHERE id = ? AND aktiv = 1").get(mit);
|
|
if (!ziel) return res.status(404).json({ fehler: "nicht_gefunden" });
|
|
|
|
const raumId = direktRaum(req.person, mit);
|
|
res.json({ raum_id: raumId });
|
|
} catch (fehler) {
|
|
console.error("[chat] Direkt:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/** Eine Gruppe anlegen. Wer sie anlegt, führt sie. */
|
|
chatRouter.post("/workspace/api/chat/gruppe", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => {
|
|
try {
|
|
const name = String(req.body?.name ?? "").trim();
|
|
if (name.length < 2) return res.status(400).json({ fehler: "Die Gruppe braucht einen Namen." });
|
|
if (name.length > NAME_MAX) return res.status(400).json({ fehler: "Der Name ist zu lang." });
|
|
|
|
const roh = Array.isArray(req.body?.mit) ? req.body.mit : [];
|
|
const mit = [...new Set(roh.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
|
|
.filter((id) => id !== req.person.id);
|
|
/* JEDE Nummer einzeln gegen die Regel -- eine Gruppe ist kein
|
|
Schlupfloch, um jemanden zu erreichen, den man einzeln nicht
|
|
anschreiben dürfte. */
|
|
const verboten = mit.filter((id) => !darfSchreibenMit(req.person, id));
|
|
if (verboten.length) {
|
|
return res.status(403).json({ fehler: "Mit mindestens einer Person kannst du nicht schreiben." });
|
|
}
|
|
if (!mit.length) return res.status(400).json({ fehler: "Wähle mindestens eine Person aus." });
|
|
|
|
const d = db();
|
|
const n = jetzt();
|
|
d.prepare("INSERT INTO chat_raeume (art, name, erstellt, erstellt_von) VALUES ('gruppe',?,?,?)")
|
|
.run(name, n, req.person.id);
|
|
const raumId = d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
|
d.prepare("INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis, leitung) VALUES (?,?,?,0,1)")
|
|
.run(raumId, req.person.id, n);
|
|
const rein = d.prepare(
|
|
"INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis) VALUES (?,?,?,0)");
|
|
for (const id of mit) {
|
|
if (d.prepare("SELECT 1 FROM personen WHERE id = ? AND aktiv = 1").get(id)) rein.run(raumId, id, n);
|
|
}
|
|
|
|
protokolliere("chat_gruppe_angelegt", {
|
|
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
|
detail: `#${raumId} ${name} (${mit.length + 1})`.slice(0, 120),
|
|
});
|
|
res.status(201).json({ raum_id: raumId });
|
|
} catch (fehler) {
|
|
console.error("[chat] Gruppe:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/** Der Verlauf eines Gesprächs. */
|
|
chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
|
|
try {
|
|
const raumId = Number(req.params.id);
|
|
if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" });
|
|
/* Was man nicht sehen darf, existiert nicht -- 404 statt 403,
|
|
sonst liesse sich durch Ausprobieren herausfinden, welche
|
|
Gespräche es gibt. */
|
|
if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
|
|
|
/* Nur die letzten 200. Ein Gespräch, das ein Jahr läuft, soll den
|
|
Browser nicht mit zehntausend Zeilen bewerfen; ältere holt man
|
|
über `vor`. */
|
|
const vor = Number(req.query.vor) || 0;
|
|
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
|
|
FROM chat_nachrichten n
|
|
LEFT JOIN personen p ON p.id = n.person_id
|
|
WHERE n.raum_id = ? ${vor ? "AND n.id < ?" : ""}
|
|
ORDER BY n.id DESC LIMIT 200`).all(...(vor ? [raumId, vor] : [raumId]));
|
|
|
|
const raum = db().prepare("SELECT id, art, name FROM chat_raeume WHERE id = ?").get(raumId);
|
|
const leute = teilnehmerVon(raumId);
|
|
|
|
res.json({
|
|
raum: {
|
|
id: raum.id, art: raum.art,
|
|
name: raumName(raum, leute, req.person.id),
|
|
teilnehmer: leute.map((t) => ({ id: t.id, name: t.name, rolle: t.rolle,
|
|
bild: t.bild, leitung: !!t.leitung })),
|
|
},
|
|
/* 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,
|
|
})),
|
|
});
|
|
} catch (fehler) {
|
|
console.error("[chat] Verlauf:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/** Etwas schreiben. */
|
|
chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft,
|
|
express.json({ limit: "16kb" }), async (req, res) => {
|
|
try {
|
|
const raumId = Number(req.params.id);
|
|
if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" });
|
|
if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
|
|
|
const text = String(req.body?.text ?? "").trim();
|
|
if (!text) return res.status(400).json({ fehler: "leer" });
|
|
if (text.length > TEXT_MAX) {
|
|
return res.status(400).json({ fehler: `Länger als ${TEXT_MAX} Zeichen geht nicht.` });
|
|
}
|
|
|
|
const d = db();
|
|
const n = jetzt();
|
|
d.prepare("INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt) VALUES (?,?,?,?)")
|
|
.run(raumId, req.person.id, text, n);
|
|
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 nachricht = {
|
|
id, raum_id: raumId, von_id: req.person.id, von: req.person.name,
|
|
rolle: req.person.rolle, text, erstellt: n, zurueckgenommen: false,
|
|
};
|
|
|
|
/* 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" });
|
|
}
|
|
});
|
|
|
|
/** Bis hierher gelesen. */
|
|
chatRouter.put("/workspace/api/chat/raeume/:id/gelesen", gleicheHerkunft,
|
|
express.json({ limit: "4kb" }), (req, res) => {
|
|
try {
|
|
const raumId = Number(req.params.id);
|
|
const bis = Number(req.body?.bis);
|
|
if (!Number.isInteger(raumId) || !Number.isInteger(bis)) {
|
|
return res.status(400).json({ fehler: "ungueltig" });
|
|
}
|
|
if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
|
/* NUR VORWÄRTS. Ein zu spät eintreffender Aufruf einer älteren
|
|
Ansicht darf den Stand nicht zurückdrehen -- sonst stünde eine
|
|
gelesene Nachricht wieder als ungelesen da. */
|
|
db().prepare(`UPDATE chat_teilnehmer SET gelesen_bis = ?
|
|
WHERE raum_id = ? AND person_id = ? AND gelesen_bis < ?`)
|
|
.run(bis, raumId, req.person.id, bis);
|
|
res.json({ ok: true });
|
|
} catch (fehler) {
|
|
console.error("[chat] Gelesen:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/** Eine eigene Nachricht zurücknehmen. */
|
|
chatRouter.delete("/workspace/api/chat/nachrichten/:id", gleicheHerkunft, (req, res) => {
|
|
try {
|
|
const id = Number(req.params.id);
|
|
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
|
|
|
|
const n = db().prepare("SELECT id, raum_id, person_id FROM chat_nachrichten WHERE id = ?").get(id);
|
|
if (!n || !istDrin(n.raum_id, req.person.id)) {
|
|
return res.status(404).json({ fehler: "nicht_gefunden" });
|
|
}
|
|
/* NUR EIGENE. DogFather ebenfalls nicht: Fremde Worte aus einem
|
|
Gespräch zu nehmen, in dem Absprachen stehen, wäre ein Eingriff
|
|
in die Geschichte -- und niemand könnte ihm nachher glauben. */
|
|
if (n.person_id !== req.person.id) {
|
|
return res.status(403).json({ fehler: "Nur eigene Nachrichten." });
|
|
}
|
|
|
|
db().prepare("UPDATE chat_nachrichten SET text = '', weg_am = ? WHERE id = ?").run(jetzt(), id);
|
|
const empfaenger = teilnehmerVon(n.raum_id).map((t) => t.id);
|
|
chatEreignis(n.raum_id, { id, raum_id: n.raum_id, zurueckgenommen: true }, [], "weg");
|
|
|
|
protokolliere("chat_zurueckgenommen", {
|
|
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
|
detail: `#${id}`,
|
|
});
|
|
res.json({ ok: true, empfaenger: empfaenger.length });
|
|
} catch (fehler) {
|
|
console.error("[chat] Zurücknehmen:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/* =====================================================================
|
|
Sofort zusehen (Server-Sent Events)
|
|
=====================================================================
|
|
|
|
WARUM SSE UND NICHT NACHFRAGEN IM TAKT: Ein Chat, der alle fünf
|
|
Sekunden nachfragt, ist fünf Sekunden langsam und stellt bei zehn
|
|
Angemeldeten 7200 Anfragen in der Stunde -- für Nachrichten, die es
|
|
meistens nicht gibt.
|
|
|
|
WARUM SSE UND NICHT WEBSOCKET: Hier fliesst alles in EINE Richtung,
|
|
vom Server zum Browser. Geschrieben wird über die normale
|
|
Schnittstelle. SSE ist dafür gebaut, kommt ohne fremde Bibliothek
|
|
aus und übersteht einen Verbindungsabriss von selbst -- der Browser
|
|
verbindet sich neu, ohne dass man dafür Code schreibt.
|
|
|
|
Der Browser holt sich nach einem Abriss die Lücke über den normalen
|
|
Verlauf nach; deshalb ist hier keine Nachrichtenwarteschlange nötig. */
|
|
|
|
/** Wer sieht gerade zu: Personennummer → Menge offener Verbindungen.
|
|
* Eine Person kann an zwei Geräten sitzen. */
|
|
const zuschauer = new Map();
|
|
|
|
function chatEreignis(raumId, nachricht, benachrichtigen, art = "neu") {
|
|
/* 1. An alle offenen Verbindungen der Beteiligten. */
|
|
const leute = teilnehmerVon(raumId).map((t) => t.id);
|
|
for (const personId of leute) {
|
|
for (const antwort of zuschauer.get(personId) || []) {
|
|
try {
|
|
antwort.write(`event: ${art}\ndata: ${JSON.stringify(nachricht)}\n\n`);
|
|
} catch { /* die Verbindung räumt sich selbst ab */ }
|
|
}
|
|
}
|
|
|
|
/* 2. Eine Benachrichtigung an die, die NICHT zusehen. Wer die Seite
|
|
offen hat, sieht die Nachricht ohnehin -- ihm auch noch eine
|
|
Meldung aufs Handy zu schicken, ist der schnellste Weg, dass er
|
|
Benachrichtigungen abschaltet. */
|
|
for (const personId of benachrichtigen) {
|
|
if ((zuschauer.get(personId) || []).length) continue;
|
|
void meldeNeueNachricht(personId, raumId, nachricht);
|
|
}
|
|
}
|
|
|
|
/** Die Push-Benachrichtigung. Absichtlich träge geladen: Der Chat soll
|
|
* auch dann laufen, wenn am Push-Teil etwas klemmt. */
|
|
async function meldeNeueNachricht(personId, raumId, nachricht) {
|
|
try {
|
|
const { benachrichtige } = await import("./workspace-push.js");
|
|
await benachrichtige(personId, "chat_nachricht", {
|
|
titel: `Nachricht von ${nachricht.von}`,
|
|
/* Der Anfang der Nachricht steht in der Meldung -- eine
|
|
Benachrichtigung, die nur "Neue Nachricht" sagt, zwingt zum
|
|
Öffnen, um zu erfahren, ob es wichtig war. */
|
|
text: String(nachricht.text || "").slice(0, 120),
|
|
ziel: `/workspace/chat.html?raum=${raumId}`,
|
|
});
|
|
} catch (fehler) {
|
|
console.error("[chat] Benachrichtigung:", fehler?.message);
|
|
}
|
|
}
|
|
|
|
chatRouter.get("/workspace/api/chat/strom", (req, res) => {
|
|
res.set({
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache, no-transform",
|
|
Connection: "keep-alive",
|
|
/* Ohne das puffert ein davorliegender Reverse Proxy (Caddy, nginx)
|
|
den Strom -- und dann kommt die Nachricht erst, wenn genug
|
|
zusammen ist. Genau das Gegenteil des Zwecks. */
|
|
"X-Accel-Buffering": "no",
|
|
});
|
|
res.flushHeaders?.();
|
|
res.write("retry: 3000\n\n");
|
|
|
|
const personId = req.person.id;
|
|
if (!zuschauer.has(personId)) zuschauer.set(personId, new Set());
|
|
zuschauer.get(personId).add(res);
|
|
|
|
/* Ein Lebenszeichen alle 25 Sekunden. Ohne das schliessen manche
|
|
Vermittlungsstellen die Verbindung nach einer Minute Stille -- der
|
|
Browser verbindet sich dann zwar neu, aber jedes Mal mit einer
|
|
Lücke von ein paar Sekunden. */
|
|
const puls = setInterval(() => {
|
|
try { res.write(": puls\n\n"); } catch { /* gleich weg */ }
|
|
}, 25_000);
|
|
puls.unref?.();
|
|
|
|
const aufraeumen = () => {
|
|
clearInterval(puls);
|
|
const menge = zuschauer.get(personId);
|
|
if (menge) {
|
|
menge.delete(res);
|
|
if (!menge.size) zuschauer.delete(personId);
|
|
}
|
|
};
|
|
req.on("close", aufraeumen);
|
|
req.on("error", aufraeumen);
|
|
});
|
|
|
|
/** Wie viele Nachrichten warten insgesamt auf mich?
|
|
* Für die Zahl in der Kopfleiste -- eine Abfrage statt einer je Raum. */
|
|
chatRouter.get("/workspace/api/chat/ungelesen", (req, res) => {
|
|
try {
|
|
const z = db().prepare(`
|
|
SELECT COUNT(*) AS n FROM chat_nachrichten m
|
|
JOIN chat_teilnehmer t ON t.raum_id = m.raum_id AND t.person_id = ?
|
|
WHERE t.raus_am IS NULL AND m.id > t.gelesen_bis AND m.person_id <> ?`)
|
|
.get(req.person.id, req.person.id);
|
|
res.json({ ungelesen: z?.n || 0 });
|
|
} catch (fehler) {
|
|
console.error("[chat] Ungelesen:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
/* Für Prüfungen und andere Module: Sieht diese Person gerade zu? */
|
|
export function siehtZu(personId) {
|
|
return (zuschauer.get(Number(personId)) || []).size > 0;
|
|
}
|