Filipe: "die nachrichten die man in den chat reinschreibt. jeder soll
seine eigene nachricht bearbeiten koennen. diese option soll jeder
fuer seine eigene nachrichten haben die er selber verfasst hat."
NUR DIE EIGENE -- OHNE AUSNAHME, auch nicht fuer DogFather und die
rechte Hand. Beim LOESCHEN gibt es diese Ausnahme seit dem 22.09.
(fuer den Notfall), und es waere naheliegend gewesen, sie
mitzunehmen. Das waere falsch: Eine fremde Nachricht zu entfernen
heisst "das soll hier nicht stehen". Eine fremde zu AENDERN heisst,
jemandem Worte in den Mund zu legen, die unter seinem Namen und
seinem Bild stehen bleiben. Nicht dieselbe Befugnis in groesser,
sondern eine andere. Genau das ist die wichtigste Pruefzeile:
DogFather darf loeschen und bekommt beim Bearbeiten 403/404.
AN DER NACHRICHT STEHT "BEARBEITET". Ein Text, der sich still
aendert, nachdem andere darauf geantwortet haben, ist ein
Vertrauensproblem und kein Komfort. Der Vermerk traegt die Zeit im
Titel. Derselbe Text setzt ihn NICHT -- sonst stuende er irgendwann
ueberall und waere nichts mehr wert.
ERWAEHNUNGEN BLEIBEN, WIE SIE BEIM SENDEN WAREN. Wer beim Bearbeiten
"@Anna" ergaenzt, spricht Anna damit nicht an. Sonst gaebe es nur
schlechte Wege: nachtraeglich benachrichtigen laesst sich beliebig
oft wiederholen, und still eintragen setzt jemanden auf eine Liste,
von der er nie erfaehrt. Ansprechen tut man mit einer neuen
Nachricht. (Falls das anders gewuenscht ist, ist es eine eigene
Entscheidung -- nicht etwas, das hier nebenbei mitpassiert.)
DER RAUM RUECKT NICHT NACH OBEN und niemand bekommt die Nachricht
als ungelesen: Eine Tippfehlerkorrektur ist keine Wortmeldung.
`letzte_am` wird deshalb nicht angefasst.
Leer geht nicht -- dafuer steht "loeschen" daneben, mit Rueckfrage.
Grenzen (4000 Zeichen) sind dieselben wie beim Senden; eine zweite
Rechnung waere die, die auseinanderlaeuft.
Das Feld sitzt AN der Nachricht, nicht im Schreibfeld unten: Wer
seinen Text zum Bearbeiten unten wiederfindet, schickt ihn beim
naechsten Enter als NEUE Nachricht ab und hat ihn zweimal im Raum.
Enter speichert, Shift+Enter macht eine Zeile, Escape bricht ab --
dieselben Tasten wie beim Schreiben. 16 px Schrift, sonst zoomt iOS
beim Hineintippen die ganze Seite heran.
Der Stift traegt sich in die Familie der Handgriffe ein, wie es der
Hinweis in chat.css ausdruecklich verlangt ("wer einen sechsten
Handgriff baut, traegt ihn hier ein und bekommt sein Zeichen").
EIN FEHLER, DEN NUR DAS BILD GEZEIGT HAT. Ich hatte im Code
behauptet, die Gespraechsliste aendere sich beim Bearbeiten nicht,
und darum auf das Nachladen verzichtet. Auf dem Bildschirmfoto stand
rechts "Treffen um 15 Uhr" und links in der Liste weiter "Du:
Treffen um 15 Urh" -- derselbe Satz, zweimal verschieden, auf einem
Schirm. Richtig ist: Die REIHENFOLGE aendert sich nicht, die
VORSCHAU sehr wohl. Beide Haelften waren fuer sich gemessen und
gruen; keine Zahl hat es gemerkt.
pruef-chat 17 neue Pruefungen: eigene geht, fremde nicht, DogFather
nicht, Vermerk kommt mit nach draussen (auch im SELECT -- genau das
hat am 03.10. bei den Anhaengen einen halben Tag gekostet), Raum
rueckt nicht, Vorschau zieht nach, leer/zu lang abgelehnt,
unveraendert ohne Vermerk, geloeschte nicht bearbeitbar, wer nicht
im Raum ist bekommt 404 statt 403.
pruef-chat-optik 71 -> 84: im echten Browser, mit zwei Sitzungen.
Darunter die Zeile, auf die es ankommt -- der neue Text steht bei
Patrick, OHNE Neuladen. Ein Bearbeiten, das nur der Schreibende
sieht, waere schlimmer als keins.
Dabei zwei eigene Messfehler behoben: Die Sitzungen von oben waren
laengst geschlossen (Playwright meldet nur "Target page has been
closed"), und beide klickten "das oberste Gespraech" statt
denselben Raum -- wodurch die Pruefung "an ihr steht KEIN
bearbeiten" gruen war, weil die Nachricht gar nicht da war. Sie
haengt jetzt daran, dass er sie wirklich sieht.
Datenbank vorher gesichert und zurueckgelesen (integrity_check,
324 Nachrichten, 20 Personen).
Co-Authored-By: Claude Opus 5 <[email protected]>
4304 lines
199 KiB
JavaScript
4304 lines
199 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, createHash } from "node:crypto";
|
||
import { join, extname, basename } from "node:path";
|
||
import { mkdirSync, writeFileSync, statSync, unlinkSync,
|
||
copyFileSync } from "node:fs";
|
||
import { liefereDatei } from "./helfer-ausliefern.mjs";
|
||
/* Die zweite Fassung jeder Sprachnachricht -- siehe
|
||
helfer-ffmpeg.mjs. Sie ist der Grund, warum Miss sie ab heute
|
||
hoeren kann (Support-Meldung #12). */
|
||
import {
|
||
zweitfassungBauen, zweitfassungPfad, brauchtZweitfassung, zweitfassungTaugt,
|
||
ZWEITFASSUNG_TYP,
|
||
} from "./helfer-ffmpeg.mjs";
|
||
|
||
/** Eine Anhangsdatei entfernen -- MITSAMT ihrer zweiten Fassung.
|
||
*
|
||
* ==== SONST BLEIBT DIE HAELFTE LIEGEN (03.10.2026) ===============
|
||
*
|
||
* Zwei Stellen loeschen Anhaenge: das Wegraeumen eines ganzen
|
||
* Gespraechs und das Zuruecknehmen einer einzelnen Nachricht. Beide
|
||
* haben bis heute genau eine Datei entfernt. Mit der Zweitfassung
|
||
* waere ab sofort bei jeder geloeschten Sprachnachricht eine
|
||
* m4a liegengeblieben -- ohne Zeile, die auf sie zeigt, und damit
|
||
* fuer immer unzuordenbar.
|
||
*
|
||
* Genau diese Luecke habe ich gestern bei den Supportbildern
|
||
* gefunden, und sie war dort schon einen Tag alt. Hier steht sie
|
||
* deshalb von Anfang an an EINER Stelle: Zwei Aufrufer koennen
|
||
* nicht auseinanderlaufen.
|
||
*
|
||
* Gibt `true` zurueck, wenn danach nichts mehr daliegt -- auch wenn
|
||
* schon vorher nichts da war (ENOENT ist kein Fehlschlag). */
|
||
function anhangWeg(datei) {
|
||
let gut = true;
|
||
for (const pfad of [join(ANHANG_ORDNER, datei), zweitfassungPfad(join(ANHANG_ORDNER, datei))]) {
|
||
try { unlinkSync(pfad); } catch (f) {
|
||
if (f?.code !== "ENOENT") {
|
||
console.error("[chat] Anhang bleibt liegen:", f?.message);
|
||
gut = false;
|
||
}
|
||
}
|
||
}
|
||
return gut;
|
||
}
|
||
|
||
/** Liegt neben dieser Tondatei schon die universelle Fassung?
|
||
*
|
||
* GEFRAGT WIRD DIE PLATTE, nicht eine Spalte in der Datenbank. Eine
|
||
* Spalte muesste gepflegt werden -- und sie waere genau dann falsch,
|
||
* wenn der Nachruestlauf gerade eine Datei erzeugt hat oder jemand
|
||
* eine von Hand entfernt. Was wirklich da ist, weiss nur der Ordner.
|
||
*
|
||
* `statSync` je Nachricht klingt nach viel; gemessen ist es ein
|
||
* Verzeichniszugriff aus dem Betriebssystem-Zwischenspeicher, und er
|
||
* faellt nur bei Toenen an -- davon gibt es im ganzen Haus sechs. */
|
||
function zweitfassungDa(art, datei) {
|
||
if (art !== "ton" || !datei) return false;
|
||
if (!brauchtZweitfassung(datei)) return false;
|
||
try {
|
||
statSync(zweitfassungPfad(join(ANHANG_ORDNER, datei)));
|
||
return true;
|
||
} catch { return false; }
|
||
}
|
||
|
||
/** Was noch keine Zweitfassung hat, bekommt eine.
|
||
*
|
||
* ==== WARUM EIN NACHRUESTLAUF UND NICHT NUR BEIM HOCHLADEN =======
|
||
*
|
||
* Beim Hochladen zu wandeln hilft ab heute. Miss' Problem sind aber
|
||
* die SECHS, die seit September liegen -- und genau die waeren mit
|
||
* einer Loesung „ab jetzt" weiter stumm geblieben. Das ist dieselbe
|
||
* Falle wie bei den Supportbildern gestern: Ein Umbau, der nur
|
||
* vorwaerts gilt, laesst den vorhandenen Bestand still zurueck.
|
||
*
|
||
* GEDECKELT. Hoechstens `hoechstens` Dateien je Durchgang -- bei
|
||
* einem Ordner mit tausend Toenen soll der Rundgang nicht eine
|
||
* Viertelstunde rechnen. Was uebrig bleibt, kommt beim naechsten
|
||
* Mal dran; der Lauf merkt sich nichts, er sieht einfach nach.
|
||
*
|
||
* Gibt zurueck, wie viele neu entstanden sind. */
|
||
export async function toeneNachruesten(hoechstens = 20) {
|
||
try {
|
||
const zeilen = db().prepare(`SELECT anhang_datei FROM chat_nachrichten
|
||
WHERE anhang_art = 'ton' AND anhang_datei IS NOT NULL AND weg_am IS NULL`).all();
|
||
let gebaut = 0;
|
||
for (const z of zeilen) {
|
||
if (gebaut >= hoechstens) break;
|
||
if (!brauchtZweitfassung(z.anhang_datei)) continue;
|
||
const pfad = join(ANHANG_ORDNER, z.anhang_datei);
|
||
try { statSync(pfad); } catch { continue; }
|
||
/* ==== EINE UNTAUGLICHE FASSUNG WIRD ERSETZT (03.10.2026) ===
|
||
|
||
Hier stand nur „gibt es schon? dann weiter". Die ersten
|
||
sechs Zweitfassungen sind aber entstanden, BEVOR das Loch am
|
||
Anfang bekannt war -- vier davon tragen es (387 s, 164 s,
|
||
134 s, 63,8 s Zeitachse fuer wenige Sekunden Ton). Mit „gibt
|
||
es schon" waeren sie fuer immer so geblieben.
|
||
|
||
`zweitfassungTaugt` fragt die Datei selbst, nicht eine
|
||
Versionsnummer: Passt ihre Zeitachse zu der Tonmenge, die
|
||
sie traegt? Was nicht passt, wird weggeraeumt und neu
|
||
gebaut -- einmal, denn danach passt es. */
|
||
const ziel = zweitfassungPfad(pfad);
|
||
let daUndGut = false;
|
||
try {
|
||
statSync(ziel);
|
||
daUndGut = zweitfassungTaugt(ziel);
|
||
if (!daUndGut) {
|
||
unlinkSync(ziel);
|
||
console.log(`[ton] Zweitfassung von ${z.anhang_datei} war unbrauchbar `
|
||
+ "(Zeitachse passt nicht zum Ton) -- wird neu gebaut.");
|
||
}
|
||
} catch { /* es gibt sie noch nicht */ }
|
||
if (daUndGut) continue;
|
||
/* NACHEINANDER, nicht alle auf einmal: Zwanzig ffmpeg-Laeufe
|
||
gleichzeitig nehmen dem Server genau die Rechenzeit weg, die
|
||
er fuer die Seite braucht. */
|
||
// eslint-disable-next-line no-await-in-loop
|
||
if (await zweitfassungBauen(pfad)) gebaut++;
|
||
}
|
||
if (gebaut) console.log(`[ton] ${gebaut} Sprachnachricht(en) zweitfassung ergaenzt.`);
|
||
return gebaut;
|
||
} catch (fehler) {
|
||
console.error("[ton] Nachruesten:", fehler?.message);
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
/** Einmal kurz nach dem Start, danach alle sechs Stunden.
|
||
*
|
||
* NICHT IM STARTMOMENT -- erst soll die Seite antworten. Dieselbe
|
||
* Ueberlegung wie beim Aufbewahrungslauf: Ein ffmpeg-Durchgang im
|
||
* Startmoment macht aus einem Fehler dort einen Startfehler.
|
||
*
|
||
* SECHS STUNDEN UND NICHT EINE MINUTE: Was beim Hochladen entsteht,
|
||
* wird dort schon gewandelt. Dieser Lauf ist das Netz darunter --
|
||
* fuer die Dateien von frueher und fuer den Fall, dass eine
|
||
* Umwandlung einmal nicht geklappt hat. Haeufiger waere Rechenzeit
|
||
* fuer eine Liste, die fast immer leer ist.
|
||
*
|
||
* `unref` DAMIT ER NICHTS AM LEBEN HAELT: Ein Taktgeber ohne das
|
||
* haelt den Prozess offen, und eine Pruefung, die den Server
|
||
* einbindet, endet dann nie. Genau daran hing im Haus schon einmal
|
||
* ein dreistuendiger Stillstand. */
|
||
export function toeneNachruestenStarten() {
|
||
const ersterLauf = setTimeout(() => { toeneNachruesten().catch(() => {}); }, 120_000);
|
||
const takt = setInterval(() => { toeneNachruesten().catch(() => {}); }, 6 * 3600_000);
|
||
ersterLauf.unref?.();
|
||
takt.unref?.();
|
||
}
|
||
import {
|
||
db, protokolliere, echteIp, sitzungLesen, istDogFather,
|
||
schreibbareIds, darfSchreibenMit, ROLLEN_SORTIERUNG, DATEN_ORDNER,
|
||
fuehrtTeamDogi, MODI_KATEGORIEN, ROLLEN_GRUPPE, TEAM_DOGI_ROLLEN, hausWo,
|
||
istTeamDogi,
|
||
} from "./workspace.js";
|
||
|
||
import { erwaehnteIds, rudelGerufen, RUDEL_WORT } from "./chat-erwaehnung.js";
|
||
import { KREIS, winkelAus } from "./chat-kreis.js";
|
||
import { KREIS_STUFEN, KREIS_MITTE, tonAus } from "./chat-kreis-stufen.js";
|
||
import { KATALOG, ERLAUBT, VORSCHLAG, FAVORITEN_MAX } from "./workspace-reaktionen.js";
|
||
|
||
/* Der Treff-Chat: eigener Raum, eigene Nachtruhe, keine Anrufe.
|
||
Eigene Datei, weil er eigene Regeln hat -- und weil diese hier
|
||
mit 2000 Zeilen schon gross genug ist. */
|
||
import {
|
||
TREFF_KATEGORIE, TREFF_NAME, istTreffRaum, treffAngleichen,
|
||
gehoertInTreff, nachtruheStand, istNachtruhe, darfTelefonieren,
|
||
} from "./workspace-treffchat.js";
|
||
|
||
export const chatRouter = express.Router();
|
||
|
||
const TEXT_MAX = 4000;
|
||
const NAME_MAX = 80;
|
||
|
||
/* ---------- Anhänge ------------------------------------------------------
|
||
Derselbe Weg wie in der Dateiablage, und aus denselben Gründen:
|
||
Der Browser schickt die Datei roh im Rumpf, der Name steht im Kopf.
|
||
express.raw() nimmt das entgegen -- multipart/form-data von Hand zu
|
||
zerlegen wäre an dieser Stelle die gefährlichere Übung.
|
||
|
||
12 MB und nicht 25 wie in der Ablage: Ein Chat sammelt viele kleine
|
||
Dateien, kein Archiv grosser. Ein Handyfoto liegt bei 2 bis 5 MB,
|
||
ein PDF mit Bildern selten über 10. */
|
||
const ANHANG_MAX = 12 * 1024 * 1024;
|
||
const ANHANG_ORDNER = join(DATEN_ORDNER, "chat-anhaenge");
|
||
|
||
const ANHANG_NAME_MAX = 200;
|
||
|
||
/* EIGENE GRENZE FUER TON (23.09.2026).
|
||
|
||
Zwoelf Megabyte sind fuer ein Foto richtig und fuer eine
|
||
Sprachnachricht sinnlos: Bei Opus mit 32 kbit/s waeren das rund
|
||
fuenfzig Minuten am Stueck. Vier Megabyte reichen fuer etwa zwanzig
|
||
Minuten -- mehr als genug fuer „hoer mal kurz" und immer noch eine
|
||
echte Schranke.
|
||
|
||
SIE WIRD NACH DEM EMPFANG GEPRUEFT, und das ist kein Versehen: Ob
|
||
etwas Ton ist, sagen erst die ersten Bytes. Vorher weiss niemand,
|
||
welche Grenze gilt. Gespeichert wird trotzdem nichts -- abgelehnt
|
||
wird, bevor die Datei die Platte sieht.
|
||
|
||
DIE LAENGE IN MINUTEN STEHT IM BROWSER (chat.js) und stoppt die
|
||
Aufnahme von selbst. Hier zaehlen Bytes, weil nur die hier messbar
|
||
sind -- eine Dauer waere eine Behauptung des Absenders. */
|
||
const TON_MAX = 4 * 1024 * 1024;
|
||
|
||
/* DIE GIF-KISTE DES RUDELS (23.09.2026) -- eigener Ordner, und das ist
|
||
kein Ordnungssinn.
|
||
|
||
Eine Nachricht loeschen entfernt ihre Datei von der Platte (siehe
|
||
das Zuruecknehmen weiter unten). Laege ein Kachel-GIF in demselben
|
||
Ordner und mehrere Nachrichten zeigten darauf, wuerde das Loeschen
|
||
EINER Nachricht das GIF fuer alle anderen mitnehmen -- und in drei
|
||
Gespraechen stuende ab da ein kaputtes Bild. Deshalb liegt die
|
||
Kiste fuer sich, und beim Verschicken wird KOPIERT. Ein GIF ist
|
||
klein genug, dass die Kopie nichts kostet; ein kaputtes Bild in
|
||
einem fremden Gespraech ist teuer.
|
||
|
||
Der Ordner steht ueber join(DATEN_ORDNER, ...) -- genau dieses
|
||
Muster liest tools/wiederherstellung-proben.mjs aus, um zu pruefen,
|
||
ob die Sicherung ihn mitnimmt. Eine gepflegte Liste waere die, die
|
||
beim naechsten Ordner vergessen wird. */
|
||
const GIF_ORDNER = join(DATEN_ORDNER, "chat-gifs");
|
||
|
||
/* ACHT MEGABYTE. Ein GIF darueber ist kein GIF mehr, sondern ein
|
||
Video mit einem falschen Hut auf -- und es laeuft in der Auswahl
|
||
dauerhaft im Kreis, waehrend das Handy heiss wird. */
|
||
const GIF_MAX = 8 * 1024 * 1024;
|
||
|
||
/** Alle GIFs der Kiste, das Neueste zuerst.
|
||
* An einer Stelle, weil drei danach fragen: die Liste, das
|
||
* Hineinlegen (es gibt die frische zurueck) und das Herausnehmen. */
|
||
function gifListe() {
|
||
return db().prepare(`SELECT g.id, g.name, g.groesse, g.breite, g.hoehe,
|
||
g.erstellt, p.name AS von
|
||
FROM chat_gifs g LEFT JOIN personen p ON p.id = g.von_id
|
||
ORDER BY g.id DESC LIMIT 200`).all()
|
||
.map((g) => ({
|
||
id: g.id, name: g.name, groesse: g.groesse,
|
||
breite: g.breite || 0, hoehe: g.hoehe || 0,
|
||
von: g.von || null, erstellt: g.erstellt,
|
||
/* Als fertige ADRESSE, nicht als Dateiname -- sonst setzt jede
|
||
Stelle im Browser denselben Pfad zusammen, und eine davon
|
||
setzt ihn irgendwann anders. Dieselbe Entscheidung wie beim
|
||
Anhang. */
|
||
weg: `/workspace/api/chat/gifs/${g.id}/bild`,
|
||
}));
|
||
}
|
||
|
||
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. */
|
||
/** Der Weg zum Profilbild dieser Person -- oder null.
|
||
*
|
||
* ALS EIGENE FUNKTION, weil die Zeile
|
||
* `bild ? "/workspace/api/steckbrief/bild/" + bild : null`
|
||
* sonst an drei Stellen steht. Die dritte Abschrift ist die, die
|
||
* den Pfad falsch schreibt, und ein kaputter Bildpfad sieht aus wie
|
||
* "die Person hat halt kein Bild". */
|
||
function bildWegFuer(personId) {
|
||
try {
|
||
const z = db().prepare("SELECT bild FROM personen WHERE id = ?").get(personId);
|
||
return z?.bild ? `/workspace/api/steckbrief/bild/${z.bild}` : null;
|
||
} catch { return null; }
|
||
}
|
||
|
||
/* =====================================================================
|
||
JEDER ACCOUNT SEINE EIGENE KACHEL (20.09.2026)
|
||
|
||
Filipe: „jeder account soll seine kachel personalisieren koennen."
|
||
|
||
---------------------------------------------------------------------
|
||
EINE AUSWAHL, KEIN FARBWAEHLER
|
||
|
||
Ein freier Farbwaehler klingt grosszuegiger und ist die schlechtere
|
||
Loesung: Mit ihm laesst sich Schwarz auf Schwarz einstellen, oder
|
||
Neongelb, das jedem anderen im Gespraech in die Augen sticht. Die
|
||
Kachel gehoert einem selbst, GESEHEN wird sie von allen anderen --
|
||
deshalb ist sie kein Platz fuer etwas, das man nicht abschalten kann.
|
||
|
||
Die zwoelf Toene hier liegen alle auf DERSELBEN LEUCHTDICHTE. Sie
|
||
unterscheiden sich im Farbton und in sonst nichts. Damit ist der
|
||
Kontrast zur Schrift bei jedem gleich -- er muss nicht bei jedem
|
||
einzeln geprueft werden, und niemand kann sich unlesbar machen.
|
||
Augenschonend ist hier keine Geschmacksfrage: Ein Chat ist die
|
||
Seite, auf der am laengsten gelesen wird.
|
||
|
||
KEINE der zwoelf ist hell. Das ist Absicht und nicht Sparsamkeit --
|
||
die Schrift ist hell, und eine helle Kachel waere fuer die Person,
|
||
die sie waehlt, huebsch und fuer alle anderen unlesbar.
|
||
|
||
---------------------------------------------------------------------
|
||
DIESE LISTE IST GERECHNET, NICHT GEWAEHLT (22.09.2026)
|
||
|
||
Filipe: „ich will dass die farben viel klarer und stärker sind ...
|
||
die kachel von den texten von den leuten im chat soll auch klarer
|
||
und stärker sein bitte."
|
||
|
||
Bis heute lagen alle zwoelf bei einer Buntheit zwischen 0,002 und
|
||
0,048 -- auf dem Bildschirm zwoelf Grautoene mit einem Hauch Farbe.
|
||
Das lag nicht an fehlendem Mut, sondern an der Fusszeile der Blase:
|
||
Sie stand auf der leisen Hausschrift, und gegen die darf die Flaeche
|
||
kaum Farbe haben.
|
||
|
||
Geaendert wurde deshalb ZUERST die Schrift der Blase (--blase-text /
|
||
--blase-leise in chat.css, beide heller als die der Seite) und DANN
|
||
die Flaeche. Andersherum waere es der Fehler vom selben Vormittag
|
||
gewesen, als 27 von 31 Startkacheln unlesbar wurden.
|
||
|
||
NICHT VON HAND AENDERN. Die Werte kommen aus
|
||
`tools/chat-kacheln-rechnen.mjs`; es liest die beiden Schriftfarben
|
||
aus dem CSS und rechnet daraus die hellste Flaeche aus, die beide
|
||
noch tragen (7:1 fuer den Nachrichtentext, 4,8:1 fuer die
|
||
Fusszeile). Wer hier eine Farbe eintippt, umgeht genau die Rechnung,
|
||
die die Lesbarkeit garantiert. Richtig ist:
|
||
|
||
node tools/chat-kacheln-rechnen.mjs --schreiben
|
||
node server/pruef-chatkachel.mjs
|
||
|
||
DIE SCHLUESSEL LINKS STEHEN IN DER DATENBANK. Wer einen umbenennt,
|
||
setzt die Wahl der betroffenen Person still auf „Standard" zurueck.
|
||
Vor diesem Umbau nachgesehen: gewaehlt waren `veilchen`, `ziegel`
|
||
und `moos` -- alle drei stehen weiter unten und haben ihren
|
||
Farbbereich behalten.
|
||
===================================================================== */
|
||
export const CHAT_KACHELN = [
|
||
{ wert: "grund", name: "Neutral", farbe: "#4f545c" },
|
||
/* BABYBLAU -- die einzige helle Kachel (22.09.2026).
|
||
------------------------------------------------------------------
|
||
Filipe: „jeder normale blaue farbe, sei es die kachel im chat oder
|
||
emojis, nur babyblau bitte." Und im selben Auftrag: „Textfarbe
|
||
soll mitwechseln, am besten schwarz auf hellen Kacheln -- und
|
||
weiss, wenn jemand eine schwarze Kachel waehlt."
|
||
|
||
Die zwei Saetze gehoeren zusammen, und in dieser Reihenfolge.
|
||
Babyblau ist HELL (Leuchtdichte 0,547); die helle Schrift der
|
||
Blase kaeme darauf auf 1,63:1 -- unlesbar. Erst weil die Schrift
|
||
jetzt mitwechselt (schriftFuer weiter unten), ist diese Kachel
|
||
ueberhaupt moeglich.
|
||
|
||
Sie steht deshalb NICHT in der Rechnung von
|
||
tools/chat-kacheln-rechnen.mjs: Das Werkzeug rechnet elf bunte
|
||
Toene auf EINE gemeinsame Leuchtdichte, damit niemand sich durch
|
||
seine Wahl schlechter lesbar machen kann. Diese hier hat eine
|
||
andere Leuchtdichte und dafuer ihre eigene Schrift -- gemessen:
|
||
Text 10,78:1 (noetig 7), Fusszeile 4,87:1 (noetig 4,5). Dasselbe
|
||
Versprechen, auf anderem Weg eingeloest.
|
||
|
||
Der Wert ist exakt `--akzent` des Hauses. Eine zweite Zahl daneben
|
||
waere zwei Babyblau, und beim naechsten Mal drei. */
|
||
{ wert: "babyblau", name: "Babyblau", farbe: "#8ec9ff" },
|
||
{ wert: "tinte", name: "Tinte", farbe: "#0150af" },
|
||
{ wert: "veilchen", name: "Veilchen", farbe: "#6a01e5" },
|
||
{ wert: "pflaume", name: "Pflaume", farbe: "#9301a2" },
|
||
{ wert: "kirsche", name: "Kirsche", farbe: "#a5015e" },
|
||
{ wert: "ziegel", name: "Ziegel", farbe: "#ad0103" },
|
||
{ wert: "bernstein", name: "Bernstein", farbe: "#7e4601" },
|
||
{ wert: "oliv", name: "Oliv", farbe: "#635201" },
|
||
{ wert: "moos", name: "Moos", farbe: "#405c01" },
|
||
{ wert: "tanne", name: "Tanne", farbe: "#00603d" },
|
||
{ wert: "lagune", name: "Lagune", farbe: "#035e5c" },
|
||
{ wert: "ozean", name: "Ozean", farbe: "#045b74" },
|
||
];
|
||
|
||
/* =====================================================================
|
||
WELCHE SCHRIFT AUF WELCHER KACHEL (22.09.2026)
|
||
=====================================================================
|
||
|
||
Filipe: „Textfarbe soll mitwechseln, am besten schwarz auf hellen
|
||
Kacheln -- und weiss, wenn jemand eine schwarze Kachel waehlt."
|
||
|
||
GERECHNET, NICHT JE KACHEL EINGETRAGEN. Eine dritte Spalte in
|
||
CHAT_KACHELN waere eine Liste, die jemand pflegen muss -- und beim
|
||
naechsten Ton vergisst. Hier steht stattdessen die Regel, und die
|
||
Farbe folgt aus der Flaeche.
|
||
|
||
ZWEI PAARE, NICHT BELIEBIGE FARBEN: Ein frei gerechneter Grauwert je
|
||
Kachel ergaebe zwoelf verschiedene Schriften, und der Chat saehe aus
|
||
wie zwoelf verschiedene Programme. Es gibt ein helles Paar (das
|
||
bisherige, aus chat.css) und ein dunkles; genommen wird das, das die
|
||
noetigen Kontraste schafft.
|
||
|
||
DIE SCHWELLEN SIND DIESELBEN WIE IM RECHENWERKZEUG: 7:1 (AAA) fuer
|
||
den Text -- der Chat ist die Seite, auf der am laengsten gelesen
|
||
wird -- und 4,5:1 fuer die Fusszeile. Sie stehen hier einmal und
|
||
werden von pruef-chatkachel.mjs aus DIESER Datei gelesen; zwei
|
||
Zahlen in zwei Dateien waeren der Widerspruch, den niemand bemerkt.
|
||
===================================================================== */
|
||
export const SCHRIFT_NOETIG = { text: 7, leise: 4.5 };
|
||
|
||
/** Das helle Paar -- dieselben Werte wie `--blase-text` / `--blase-leise`
|
||
* in chat.css. Sie stehen dort als Rueckfall fuer den Fall, dass gar
|
||
* keine Kachel gesetzt ist. */
|
||
export const SCHRIFT_HELL = { text: "#f3f7fc", leise: "#c4d2e2" };
|
||
/** Das dunkle Paar fuer helle Kacheln. Gemessen auf Babyblau:
|
||
* 10,78:1 und 4,87:1. */
|
||
export const SCHRIFT_DUNKEL = { text: "#0b1119", leise: "#3b4e63" };
|
||
|
||
const alsLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
||
function leuchtdichte(hex) {
|
||
const z = String(hex).replace("#", "");
|
||
const [r, g, b] = [0, 2, 4]
|
||
.map((i) => parseInt(z.slice(i, i + 2), 16) / 255).map(alsLinear);
|
||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||
}
|
||
function kontrastZwischen(a, b) {
|
||
const x = leuchtdichte(a), y = leuchtdichte(b);
|
||
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
|
||
}
|
||
|
||
/** Welches Schriftpaar traegt auf dieser Flaeche?
|
||
*
|
||
* GIBT IMMER EINES ZURUECK. Ein dritter Ausgang ("keines passt")
|
||
* waere hier falsch: Der Chat muss etwas anzeigen. Dass beide Paare
|
||
* reichen, ist die Aufgabe von pruef-chatkachel.mjs -- sie misst
|
||
* jeden Ton der Liste gegen genau diese Funktion. Faellt einer durch,
|
||
* ist das ein Befund und keine Notlage zur Laufzeit. */
|
||
export function schriftFuer(farbe) {
|
||
const passt = (paar) =>
|
||
kontrastZwischen(paar.text, farbe) >= SCHRIFT_NOETIG.text
|
||
&& kontrastZwischen(paar.leise, farbe) >= SCHRIFT_NOETIG.leise;
|
||
if (passt(SCHRIFT_HELL)) return SCHRIFT_HELL;
|
||
if (passt(SCHRIFT_DUNKEL)) return SCHRIFT_DUNKEL;
|
||
/* Schafft keines beide Schwellen, entscheidet der TEXT -- er ist
|
||
das, was man liest. */
|
||
return kontrastZwischen(SCHRIFT_HELL.text, farbe)
|
||
>= kontrastZwischen(SCHRIFT_DUNKEL.text, farbe) ? SCHRIFT_HELL : SCHRIFT_DUNKEL;
|
||
}
|
||
|
||
/** Nur ein Ton aus der Liste kommt durch -- sonst nichts.
|
||
*
|
||
* DIE PRUEFUNG STEHT HIER UND NICHT NUR IM FORMULAR. Was aus der
|
||
* Datenbank kommt, koennte aus einer aelteren Fassung stammen oder
|
||
* von einem Aufruf an der Oberflaeche vorbei; ein unbekannter Wert
|
||
* wanderte sonst als CSS-Farbe in die Seite. `null` heisst
|
||
* „Standard", und das ist ein gueltiger Zustand. */
|
||
export function kachelPruefen(wert) {
|
||
if (!wert) return null;
|
||
/* SEIT DEM 23.09.2026 GIBT ES ZWEI SORTEN SCHLUESSEL.
|
||
|
||
Filipe wollte statt der Kachelreihe einen Kreis, auf dem man die
|
||
Farbe "ganz genau selber auswaehlen kann". Ein Winkel ist damit
|
||
eine gueltige Wahl: "ton-214" heisst 214 Grad.
|
||
|
||
WARUM NICHT EINFACH DIE FARBE SPEICHERN: Dann koennte sich jeder
|
||
ein "#ffffff" eintragen -- und die helle Schrift der Blase waere
|
||
darauf unlesbar. Der Winkel dagegen ist eine Nummer in einer
|
||
Tabelle, die aus der Kontrastrechnung stammt. Wer sich etwas
|
||
Unlesbares wuenscht, bekommt schlicht keinen Treffer.
|
||
|
||
Die alten Schluessel bleiben gueltig. Wer "veilchen" gewaehlt hat,
|
||
behaelt es -- eine Umstellung, die stillschweigend die Wahl von
|
||
fuenfzehn Leuten zuruecksetzt, waere kein Fortschritt. */
|
||
/* SEIT DEM 25.09.2026 GIBT ES ZU JEDEM WINKEL SIEBEN HELLIGKEITEN.
|
||
|
||
Filipe: „ich will dass die leute auch heller und dunkler
|
||
aussuchen koennen."
|
||
|
||
Der Schluessel heisst dann "ton-214-s6". Ohne Zusatz gilt die
|
||
Mitte -- die alten Schluessel bleiben damit Zeichen fuer Zeichen
|
||
gueltig und bedeuten dieselbe Farbe wie vorher.
|
||
|
||
`tonAus` ERSETZT `winkelAus` HIER, laesst es aber stehen: Es wird
|
||
an anderer Stelle noch gebraucht, und zwei Pruefungen fuer
|
||
denselben Schluessel waeren eine Gelegenheit, dass eine davon
|
||
mehr durchlaesst. */
|
||
if (tonAus(wert) !== null) return String(wert);
|
||
return CHAT_KACHELN.some((k) => k.wert === String(wert)) ? String(wert) : null;
|
||
}
|
||
|
||
/* WER NICHTS EINSTELLT, BEKOMMT TROTZDEM EINE FARBE (22.09.2026)
|
||
---------------------------------------------------------------------
|
||
Filipe: „die kachel von den texten von den leuten im chat soll auch
|
||
klarer und stärker sein ... so dass die leute richtig bekommen es zu
|
||
benutzen."
|
||
|
||
Vorher hiess „nichts eingestellt" GRAU -- und eingestellt hatten es
|
||
vier von fuenfzehn. Auf dem Bildschirm war der Chat damit einfarbig,
|
||
egal wie gut die zwoelf Toene gerechnet sind. Eine Einstellung, die
|
||
man erst finden muss, damit ueberhaupt etwas passiert, benutzt kaum
|
||
jemand; das Ergebnis sehen aber alle.
|
||
|
||
Jetzt bekommt jede Person von sich aus einen der elf bunten Toene.
|
||
Dieselbe Person hat immer denselben -- man erkennt sie an der Farbe,
|
||
noch bevor man den Namen liest. Wer einen anderen will, waehlt ihn
|
||
weiterhin; die eigene Wahl gewinnt immer.
|
||
|
||
WARUM AUS DER ID UND NICHT AUS DEM NAMEN: Ein Name aendert sich
|
||
(Heirat, Schreibweise, Kuerzel), eine ID nie. Eine Farbe, die beim
|
||
Umbenennen springt, waere schlimmer als gar keine.
|
||
|
||
Der neutrale Ton bleibt in der Liste -- er ist jetzt eine WAHL
|
||
(„ich will keine Farbe"), keine Voreinstellung. */
|
||
/* WARUM NICHT EINFACH `id % 11`.
|
||
---------------------------------------------------------------------
|
||
Die elf Toene stehen in der Reihenfolge ihres Farbwinkels -- Nachbarn
|
||
in der Liste sind auch Nachbarn im Farbkreis. Mit `id % 11` bekommen
|
||
zwei Leute, deren Nummern aufeinanderfolgen, deshalb zwei aehnliche
|
||
Farben. Gemessen am Bildschirm: Person 13 und 14 sassen im selben
|
||
Gespraech, eine magenta, eine rot -- auf dem Bildschirmfoto kaum zu
|
||
trennen, und genau das, was die Farbe verhindern soll.
|
||
|
||
Elf ist eine Primzahl. Damit ist JEDE Schrittweite von 1 bis 10
|
||
teilerfremd, laeuft also durch alle elf Toene, bevor sich einer
|
||
wiederholt -- keiner geht verloren. Die 4 ist gewaehlt, weil sie den
|
||
groessten Sprung ergibt: vier Schritte sind rund 131 Grad im
|
||
Farbkreis, fast das Maximum von 180. Aufeinanderfolgende Nummern
|
||
landen damit so weit auseinander wie moeglich. */
|
||
const SPRUNG = 4;
|
||
|
||
export function autoKachelFuer(personId) {
|
||
const bunte = CHAT_KACHELN.filter((k) => k.wert !== "grund");
|
||
if (!bunte.length) return null;
|
||
const n = Number(personId);
|
||
if (!Number.isFinite(n) || n < 0) return null;
|
||
return bunte[(n * SPRUNG) % bunte.length].wert;
|
||
}
|
||
|
||
/** Der Ton, den diese Person TRAEGT -- eigene Wahl, sonst die
|
||
* automatische. Fuer die Frage „was hat sie eingestellt?" gibt es
|
||
* `gewaehlteKachelFuer`; die beiden zu verwechseln hiesse, dass im
|
||
* Waehler ploetzlich ein Ton angehakt waere, den niemand gewaehlt hat. */
|
||
function kachelFuer(personId) {
|
||
return gewaehlteKachelFuer(personId) || autoKachelFuer(personId);
|
||
}
|
||
|
||
function gewaehlteKachelFuer(personId) {
|
||
try {
|
||
return kachelPruefen(db().prepare(
|
||
"SELECT chat_kachel FROM personen WHERE id = ?").get(personId)?.chat_kachel);
|
||
} catch { return null; }
|
||
}
|
||
|
||
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();
|
||
/* GESUCHT WIRD NUR IM EIGENEN HAUS (24.09.2026). Ohne das faende
|
||
DogFather auf der Team-Adresse sein Agentur-Gespraech mit
|
||
derselben Person wieder -- und schriebe hinein, ohne es zu
|
||
merken. Zwei Haeuser heisst: notfalls zwei Gespraeche. */
|
||
const vorhanden = d.prepare(`
|
||
SELECT r.id FROM chat_raeume r
|
||
WHERE r.art = 'direkt'
|
||
AND ${hausWo(personA, "r")}
|
||
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();
|
||
/* DAS HAUS WIRD BEIM ANLEGEN GESCHRIEBEN, nicht spaeter gerechnet.
|
||
`personA.haus` kommt aus der Adresse, auf der gerade gearbeitet
|
||
wird -- die einzige Auskunft, die auch dann stimmt, wenn der Raum
|
||
noch leer ist. */
|
||
d.prepare("INSERT INTO chat_raeume (art, erstellt, erstellt_von, letzte_am, haus)"
|
||
+ " VALUES ('direkt',?,?,NULL,?)")
|
||
.run(n, personA.id, personA.haus || null);
|
||
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.
|
||
===================================================================== */
|
||
/* EXPORTIERT SEIT DEM 24.09.2026 -- der Support braucht dieselbe
|
||
Erkennung fuer seine Bildschirmfotos. Eine zweite Fassung dort waere
|
||
die, die beim naechsten Dateiformat vergessen wird, und an dieser
|
||
Funktion haengt die ganze Sicherheit der Uploads (siehe der Kopf
|
||
darueber). Eine Wahrheit, zwei Benutzer. */
|
||
export 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 };
|
||
}
|
||
|
||
/* =====================================================================
|
||
SPRACHNACHRICHTEN (23.09.2026)
|
||
|
||
Filipe: „dan will ich auch dass man im chat auch sprachnachrichten
|
||
und gifts reinschicken kann. also nur die modis rechte linke hand
|
||
und dogfather."
|
||
|
||
DREI BEHAELTER, WEIL DIE GERAETE DREI LIEFERN -- das ist keine
|
||
Grosszuegigkeit, sondern die Bedingung dafuer, dass es ueberhaupt
|
||
auf jedem Handy geht:
|
||
WebM/Opus Chrome, Edge, Android -- und, gemessen, auch Safari
|
||
MP4/AAC Safari, iPhone, iPad
|
||
Ogg/Opus Firefox
|
||
Wer nur einen davon nimmt, baut eine Funktion, die bei der Haelfte
|
||
des Teams stumm bleibt.
|
||
|
||
BERICHTIGUNG VOM 02.10.2026: Die Zuordnung oben las sich wie
|
||
„Safari liefert MP4". Das stimmt so nicht. An einer echten
|
||
Aufnahme von einem iPhone (iOS 18.7) nachgemessen: Behaelter
|
||
WebM, Mux-Programm „WebKit", Tonspur A_OPUS. Safari NIMMT WebM
|
||
auf. Ob es WebM auch ABSPIELT, ist eine andere Frage -- und sie
|
||
war der Grund, warum Miss sieben Tage lang nichts hoeren konnte.
|
||
Die Aufnahmeseite bevorzugt deshalb seit heute AAC in MP4, den
|
||
einzigen Behaelter, den nachweislich jeder abspielt (siehe
|
||
`tonTypWaehlen()` in chat.js). Hier bleibt alles erlaubt: Diese
|
||
Erkennung ist die Tuer, nicht die Wahl.
|
||
|
||
WARUM KEIN MP3: Kein Aufnahmegeraet im Browser erzeugt MP3. Es
|
||
zuzulassen hiesse, eine Tuer fuer Musikdateien aufzumachen, nach
|
||
der niemand gefragt hat. Bestellt waren Sprachnachrichten.
|
||
|
||
DER BEHAELTER ALLEIN GENUEGT NICHT. WebM und MP4 tragen genauso
|
||
gut Video -- und ein Video, das hier als "Ton" durchginge, landete
|
||
in einem Abspielgeraet ohne Bild: Es liefe, man hoerte etwas, und
|
||
niemand wuesste, dass die Haelfte fehlt. Deshalb wird im Kopf nach
|
||
der KENNUNG DER SPUR gesucht: Steht dort eine Videospur, wird
|
||
abgelehnt; steht keine Tonspur darin, ebenfalls.
|
||
|
||
GESUCHT WIRD NUR IM ANFANG (16 kB). Dort stehen bei allen drei
|
||
Formaten die Spurangaben. Die ganze Datei zu durchsuchen hiesse,
|
||
bei vier Megabyte nach Zeichenketten zu graben -- und dabei
|
||
irgendwann ein "avc1" mitten in den Tondaten zu finden.
|
||
===================================================================== */
|
||
const anfang = b.toString("latin1", 0, Math.min(b.length, 16384));
|
||
const hatVideo = /V_VP8|V_VP9|V_AV1|V_MPEG|avc1|hvc1|hev1|av01|mp4v|theora/.test(anfang);
|
||
|
||
/* --- WebM / Matroska: EBML-Kennung ------------------------------- */
|
||
if (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) {
|
||
if (hatVideo) return null;
|
||
if (!/A_OPUS|A_VORBIS|A_AAC|A_PCM/.test(anfang)) return null;
|
||
return { art: "ton", typ: "audio/webm", endung: ".webm", breite: 0, hoehe: 0 };
|
||
}
|
||
|
||
/* --- Ogg ---------------------------------------------------------- */
|
||
if (kopf(0, 4) === "OggS") {
|
||
if (hatVideo) return null;
|
||
if (!/OpusHead|vorbis|Speex|FLAC/.test(anfang)) return null;
|
||
return { art: "ton", typ: "audio/ogg", endung: ".ogg", breite: 0, hoehe: 0 };
|
||
}
|
||
|
||
/* --- MP4 / M4A: "ftyp" steht an Stelle 4, nicht 0 ----------------- */
|
||
if (kopf(4, 8) === "ftyp") {
|
||
if (hatVideo) return null;
|
||
if (!/M4A |mp4a|Opus/.test(anfang)) return null;
|
||
return { art: "ton", typ: "audio/mp4", endung: ".m4a", breite: 0, hoehe: 0 };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/** Wie heisst ein Anhang in einem Satz?
|
||
*
|
||
* „Foto" steht in der Gespraechsliste, im Zitat, an einer angehefteten
|
||
* Nachricht und in der Benachrichtigung -- an vier Stellen stand
|
||
* dieselbe Aufzaehlung, jede mit ihrem eigenen `? :`. Beim Hinzukommen
|
||
* der Sprachnachricht waeren es vier Aenderungen gewesen, und die
|
||
* vierte ist die, die man vergisst. Jetzt eine Stelle.
|
||
*
|
||
* LEER FUER UNBEKANNTES, nicht "Anhang": Der Aufrufer setzt das Wort
|
||
* hinter ein `||` -- ein Ersatzwort fuer etwas, das es nicht gibt,
|
||
* stuende sonst in einer Vorschau, in der nichts ist.
|
||
*/
|
||
export function anhangWort(art) {
|
||
return art === "bild" ? "Foto"
|
||
: art === "pdf" ? "PDF"
|
||
: art === "ton" ? "Sprachnachricht"
|
||
: "";
|
||
}
|
||
|
||
/** Der Anzeigename. Er steht nur in der Datenbank -- auf der Platte
|
||
* bekommt jede Datei einen erzeugten Zufallsnamen, damit ein Name mit
|
||
* "../" darin nirgendwo hinkommt. */
|
||
function anhangName(roh, endung) {
|
||
let name;
|
||
try { name = decodeURIComponent(roh || ""); } catch { name = String(roh || ""); }
|
||
name = basename(name).replace(/[\x00-\x1f<>:"/\\|?*]/g, "_").trim();
|
||
name = name.slice(0, ANHANG_NAME_MAX);
|
||
if (!name) name = "anhang" + endung;
|
||
return name;
|
||
}
|
||
|
||
/** Bis zu welcher Nachricht hat DIESE Person das Gespräch weggeräumt?
|
||
* 0 heisst: gar nicht. Steht an einer Stelle, damit keine Abfrage sie
|
||
* vergisst -- eine zweite Fassung wäre ein Gespräch, das in der Liste
|
||
* weg ist und in der Suche noch auftaucht. */
|
||
function geloeschtBis(raumId, personId) {
|
||
try {
|
||
return db().prepare(
|
||
"SELECT geloescht_bis FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?")
|
||
.get(raumId, personId)?.geloescht_bis ?? 0;
|
||
} catch { return 0; }
|
||
}
|
||
|
||
/** Wie heißt dieser Raum für DIESE Person?
|
||
*
|
||
* Ein Zweier-Gespräch heißt für jede Seite anders -- nach dem
|
||
* Gegenüber. Deshalb steht der Name nicht in der Tabelle: Er wäre für
|
||
* eine der beiden Seiten immer falsch. */
|
||
/* =====================================================================
|
||
KATEGORIE-KANAELE (10.09.2026, Kapitel 7.2)
|
||
===================================================================== */
|
||
|
||
/** Hoechstens drei Ankuendigungen je Raum.
|
||
*
|
||
* Nicht eine (dann kann man Regeln, Live-Plan und Frist nicht
|
||
* gleichzeitig oben haben) und nicht beliebig viele: Eine Pinnwand,
|
||
* die scrollt, ist keine Pinnwand mehr, sondern ein zweiter Verlauf.
|
||
* Wer die vierte anheften will, muss sich fuer eine entscheiden --
|
||
* und genau diese Entscheidung ist der Sinn der Funktion. */
|
||
const PIN_MAX = 3;
|
||
|
||
/** Der Name eines Kanals kommt aus der Kategorie, nicht aus einem Feld.
|
||
*
|
||
* Ein freies Namensfeld waere die naheliegende Freiheit -- und der
|
||
* sichere Weg zu "Clipping" neben "Clipping-Team", mit je der Haelfte
|
||
* der Nachrichten darin. Wer die Kategorie umbenennt, benennt damit
|
||
* jeden Kanal um; sie stehen nicht doppelt. */
|
||
function kanalName(kategorie) {
|
||
/* Der Treff steht nicht in MODI_KATEGORIEN -- er ist kein Thema des
|
||
Teams, sondern ein eigener Raum mit eigenen Regeln. Ohne diese
|
||
Zeile hiesse er schlicht "Kanal". */
|
||
if (kategorie === TREFF_KATEGORIE) return TREFF_NAME;
|
||
/* EIN EIGENER KANAL TRAEGT SEINEN NAMEN IN DER ZEILE (24.09.2026).
|
||
MODI_KATEGORIEN kennt ihn nicht -- fuer ihn steht der Name in
|
||
der Spalte "name" des Raums. Ohne diesen Weg hiesse jeder eigene
|
||
Kanal schlicht „Kanal". */
|
||
return MODI_KATEGORIEN.find((k) => k.wert === kategorie)?.name || null;
|
||
}
|
||
|
||
/** 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. */
|
||
/* =====================================================================
|
||
WER IN EINEN KATEGORIE-KANAL GEHOERT (22.09.2026)
|
||
=====================================================================
|
||
|
||
VanVans Befund, von Filipe weitergegeben:
|
||
|
||
* "Patrick und BananaStift (stifti) aus der Erwaehnen-Liste
|
||
entfernen -- sie stehen dort als SCOUT und gehoeren nicht in
|
||
diesen Chat."
|
||
* "Die Modis zum Erwaehnen hinzufuegen."
|
||
* "Die Modis sehen den Chat Moderation auch gar nicht."
|
||
|
||
AN DER LIVE-DATENBANK NACHGEMESSEN, bevor irgendetwas gebaut wurde:
|
||
|
||
Raum 3 kanal Chat-Moderation admin, hand, SCOUT, SCOUT, linke
|
||
Raum 7 kanal Der Treff admin, hand, 4x modi, linke, 3x gast
|
||
Raum 8 gruppe Dogi und Modis admin, hand, 4x modi, linke
|
||
Raum 9 gruppe Abmeldungen admin, hand, 4x modi, linke
|
||
|
||
Damit waren alle drei Punkte EIN Befund: Die Erwaehnen-Liste zeigt
|
||
genau die Teilnehmer des Raums (teilnehmerVon). Im
|
||
Moderations-Kanal standen zwei Scouts und kein einziger Modi --
|
||
also schlug die Liste Scouts vor, und die Modis sahen den Kanal gar
|
||
nicht. Nichts davon war ein Fehler der Erwaehnung.
|
||
|
||
DIE REGEL: Ein Kategorie-Kanal gehoert Team Dogi. Scouts, Manager
|
||
und Creator arbeiten im anderen Haus; sie haben in einem Kanal
|
||
ueber Chat-Moderation nichts verloren -- und dass sie dort standen,
|
||
war kein Rechtefehler, sondern eine Besetzung von Hand, die nie
|
||
jemand nachgeprueft hat.
|
||
|
||
SELBSTHEILEND, IN BEIDE RICHTUNGEN -- und das ist der Unterschied
|
||
zum alten Abgleich, der nur die Teamfuehrung nachtrug:
|
||
|
||
hinein jeder aktive Mensch aus Team Dogi, der in diesem Raum
|
||
NOCH NIE eine Zeile hatte
|
||
hinaus jeder aktive Teilnehmer, dessen Rolle nicht dazugehoert
|
||
(raus_am, kein DELETE -- der Verlauf bleibt lesbar)
|
||
|
||
"NOCH NIE EINE ZEILE" IST DER WICHTIGE TEIL. Wer ueber die
|
||
Mitglieder-Route bewusst herausgenommen wurde, hat eine Zeile mit
|
||
`raus_am` -- und wird deshalb NICHT wieder hineingezogen. Ein
|
||
Abgleich, der eine Entscheidung von Hand beim naechsten Seitenaufruf
|
||
ueberschreibt, macht die Mitglieder-Route wertlos; dieselbe
|
||
Ueberlegung steht schon bei treffAngleichen().
|
||
|
||
DER TREFF BLEIBT AUSSEN VOR. Er ist zwar technisch ein 'kanal',
|
||
aber kein Thema des Teams -- dort gehoert die Community dazu, und
|
||
TREFF_ROLLEN sagt das an seiner eigenen Stelle.
|
||
===================================================================== */
|
||
|
||
/** Wer in einen Kategorie-Kanal gehoert.
|
||
*
|
||
* ABGELEITET, NICHT ABGESCHRIEBEN: TEAM_DOGI_ROLLEN ist die eine
|
||
* Liste des Hauses (hand, linke, modi); DogFather kommt dazu, weil er
|
||
* zum Team gehoert -- er steht nicht darueber, er ist dabei. Kaeme
|
||
* morgen eine weitere Team-Rolle, waere sie hier von selbst dabei. */
|
||
export const KANAL_ROLLEN = ["admin", ...TEAM_DOGI_ROLLEN];
|
||
|
||
function kanaeleAngleichen(person) {
|
||
if (!fuehrtTeamDogi(person)) return 0;
|
||
try {
|
||
const d = db();
|
||
const n = jetzt();
|
||
const platz = KANAL_ROLLEN.map(() => "?").join(", ");
|
||
|
||
/* 1. WER FEHLT, KOMMT HINEIN. `gelesen_bis` auf den letzten Stand:
|
||
Wer heute dazukommt, hat nichts verpasst -- sonst staende beim
|
||
ersten Oeffnen eine rote Zahl ueber allem, was das Team je
|
||
geschrieben hat. Dieselbe Ueberlegung wie in der
|
||
Mitglieder-Route. */
|
||
const dazu = d.prepare(`
|
||
INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis, leitung)
|
||
SELECT r.id, p.id, ?,
|
||
COALESCE((SELECT MAX(id) FROM chat_nachrichten WHERE raum_id = r.id), 0),
|
||
CASE WHEN p.rolle IN ('admin','hand') THEN 1 ELSE 0 END
|
||
FROM chat_raeume r
|
||
JOIN personen p ON p.aktiv = 1 AND p.rolle IN (${platz})
|
||
WHERE r.art = 'kanal' AND COALESCE(r.kategorie, '') <> ?
|
||
AND NOT EXISTS (SELECT 1 FROM chat_teilnehmer t
|
||
WHERE t.raum_id = r.id AND t.person_id = p.id)`)
|
||
.run(n, ...KANAL_ROLLEN, TREFF_KATEGORIE).changes;
|
||
|
||
/* 2. WER NICHT DAZUGEHOERT, GEHT HINAUS. Nicht geloescht: Was er
|
||
geschrieben hat, bleibt lesbar, und niemand verschwindet
|
||
rueckwirkend aus der Geschichte. */
|
||
const raus = d.prepare(`
|
||
UPDATE chat_teilnehmer SET raus_am = ?
|
||
WHERE raus_am IS NULL
|
||
AND raum_id IN (SELECT id FROM chat_raeume
|
||
WHERE art = 'kanal' AND COALESCE(kategorie, '') <> ?)
|
||
AND person_id IN (SELECT id FROM personen WHERE rolle NOT IN (${platz}))`)
|
||
.run(n, TREFF_KATEGORIE, ...KANAL_ROLLEN).changes;
|
||
|
||
if (dazu || raus) {
|
||
console.log(`[chat] Kanal-Abgleich: +${dazu} / -${raus}`);
|
||
}
|
||
return dazu + raus;
|
||
} 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;
|
||
}
|
||
}
|
||
|
||
/** Gehoert diese Person in einen Kategorie-Kanal?
|
||
*
|
||
* STEHT AN EINER STELLE, weil drei danach fragen: der Abgleich oben,
|
||
* das Anlegen eines Kanals und das Umbesetzen. Drei Fassungen waeren
|
||
* die, bei der die dritte beim naechsten Umbau vergessen wird -- und
|
||
* genau diese dritte Luecke hat VanVan gefunden. */
|
||
function gehoertInKanal(id) {
|
||
try {
|
||
const r = db().prepare("SELECT rolle FROM personen WHERE id = ? AND aktiv = 1").get(id);
|
||
return !!r && KANAL_ROLLEN.includes(r.rolle);
|
||
} catch { return false; }
|
||
}
|
||
|
||
function raumName(raum, teilnehmer, ichId) {
|
||
/* Der gespeicherte Name zuerst -- bei einem eigenen Kanal ist er
|
||
die einzige Quelle. Der Rueckfall auf "Kanal" gilt Raeumen aus
|
||
der Zeit vor der Namensspalte; ohne ihn stuende dort seit dem
|
||
24.09. gar nichts, weil kanalName() jetzt null liefert. */
|
||
if (raum.art === "kanal") return raum.name || kanalName(raum.kategorie) || "Kanal";
|
||
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 {
|
||
/* ==== FUER EINEN KANAL GILT EINE ANDERE LISTE (24.09.2026) ====
|
||
|
||
Filipe: „ich will auch die option kanäle mit den leuten mit der
|
||
community rolle."
|
||
|
||
Seit dem 19.09. nimmt "ohneAussen()" die Community aus den
|
||
Listen aller anderen -- weil ein Zweier-Gespraech mit einem
|
||
Zuschauer eine Standleitung ohne Moderation waere. Diese
|
||
Begruendung gilt fuer ein Gespraech und NICHT fuer einen Kanal:
|
||
Dort sitzt die ganze Teamfuehrung drin, und er ist moderierbar.
|
||
|
||
"?fuer=kanal" fragt also eine andere Frage, nicht dieselbe mit
|
||
weniger Sorgfalt. Wer Kanaele gar nicht aufmachen darf, bekommt
|
||
die erweiterte Liste auch nicht -- sonst waere der Parameter
|
||
ein Weg, an "ohneAussen" vorbei Namen zu erfahren. */
|
||
const fuerKanal = String(req.query?.fuer || "") === "kanal"
|
||
&& darfKanalAufmachen(req.person);
|
||
const ids = fuerKanal ? null : 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. */
|
||
/* =====================================================================
|
||
DIE EIGENE KACHEL EINSTELLEN (20.09.2026)
|
||
|
||
Filipe: „jeder account soll seine kachel personalisieren koennen."
|
||
|
||
ZWEI WEGE, WEIL ES ZWEI FRAGEN SIND: „welche gibt es" und „das will
|
||
ich". Die Liste kommt vom Server, damit im Browser keine zweite
|
||
Fassung der zwoelf Toene steht -- die waere beim dreizehnten die
|
||
vergessene.
|
||
|
||
NUR FUER SICH SELBST. Es gibt keinen Weg, die Kachel eines anderen
|
||
zu setzen, und das ist keine Luecke: Die Kachel ist das Einzige auf
|
||
dieser Seite, das einem selbst gehoert. Ein Recht der Leitung darauf
|
||
waere ein Recht, jemandem seine Farbe wegzunehmen.
|
||
===================================================================== */
|
||
chatRouter.get("/workspace/api/chat/kacheln", (req, res) => {
|
||
try {
|
||
res.json({
|
||
/* DIE SCHRIFT GEHT MIT. Der Browser soll sie nicht selbst
|
||
rechnen -- dann staende die Regel zweimal da, und beim
|
||
naechsten Ton waere eine der beiden Fassungen aelter. */
|
||
kacheln: CHAT_KACHELN.map((k) => ({ ...k, schrift: schriftFuer(k.farbe) })),
|
||
/* DER KREIS: 360 fertige Farben, Index = Winkel.
|
||
|
||
Der Browser rechnet sie NICHT selbst aus. Die Formel steht in
|
||
tools/chat-kacheln-rechnen.mjs und haengt an den Schriftfarben
|
||
aus dem CSS; sie ein zweites Mal in JavaScript zu fuehren
|
||
hiesse, dass die zweite Fassung ab dem naechsten
|
||
Schriftwechsel falsch ist -- und zwar unbemerkt, denn beide
|
||
liefern ja "irgendeine Farbe".
|
||
|
||
Rund 3 kB. Sie kommen mit derselben Anfrage, die es ohnehin
|
||
gibt; eine eigene waere eine zweite Wartezeit fuer nichts. */
|
||
kreis: KREIS,
|
||
/* Alle 360 liegen auf derselben Leuchtdichte wie die elf bunten
|
||
Kacheln -- also tragen sie auch dieselbe Schrift. Einmal
|
||
gerechnet und mitgeschickt, statt 360-mal dasselbe. */
|
||
kreis_schrift: schriftFuer(KREIS[0]),
|
||
/* DIE SIEBEN HELLIGKEITEN (25.09.2026).
|
||
|
||
Nicht 2520 fertige Farben, sondern 360 Grundtoene und sieben
|
||
Mischanteile: Der Browser mischt selbst, und zwar mit genau
|
||
den Zahlen, die hier stehen. Die ganze Tabelle zu schicken
|
||
waere 20 kB fuer etwas, das eine Zeile Rechnung ist.
|
||
|
||
DIE ZAHLEN KOMMEN TROTZDEM VOM SERVER und stehen nicht im
|
||
Browser: Sie sind aus der Kontrastrechnung hergeleitet (siehe
|
||
chat-kreis-stufen.js), und eine zweite Fassung im Browser
|
||
waere ab der naechsten Messung falsch -- unbemerkt, denn sie
|
||
lieferte ja weiter „irgendeine Farbe". */
|
||
kreis_stufen: KREIS_STUFEN,
|
||
kreis_mitte: KREIS_MITTE,
|
||
/* Die eigene WAHL, nicht der getragene Ton -- sonst stuende im
|
||
Waehler ein Haken bei einer Farbe, die niemand gewaehlt hat. */
|
||
meine: gewaehlteKachelFuer(req.person.id),
|
||
/* Damit der Waehler beim neutralen Eintrag zeigen kann, was
|
||
stattdessen getragen wird, wenn man nichts waehlt. */
|
||
automatisch: autoKachelFuer(req.person.id),
|
||
});
|
||
} catch (f) {
|
||
console.error("[chat] Kacheln:", f?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
chatRouter.put("/workspace/api/chat/kachel", express.json({ limit: "2kb" }),
|
||
gleicheHerkunft, (req, res) => {
|
||
try {
|
||
const roh = req.body?.kachel;
|
||
/* LEER IST GUELTIG und heisst "Standard". Wer sich umentscheidet,
|
||
braucht einen Weg zurueck -- eine Einstellung ohne Rueckweg ist
|
||
eine Falle, keine Einstellung. */
|
||
const wert = (roh === null || roh === "" || roh === "grund")
|
||
? null : kachelPruefen(roh);
|
||
if (roh && roh !== "grund" && wert === null) {
|
||
return res.status(400).json({ fehler: "unbekannte_kachel" });
|
||
}
|
||
db().prepare("UPDATE personen SET chat_kachel = ? WHERE id = ?")
|
||
.run(wert, req.person.id);
|
||
res.json({ kachel: wert });
|
||
} catch (f) {
|
||
console.error("[chat] Kachel setzen:", f?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
chatRouter.get("/workspace/api/chat/raeume", (req, res) => {
|
||
try {
|
||
/* WEGGERÄUMTE GESPRÄCHE STEHEN NICHT IN DER LISTE.
|
||
|
||
Die Bedingung sitzt hier in der Abfrage und nicht in einem
|
||
Filter danach: Was nicht herauskommt, kann auch niemand
|
||
versehentlich wieder hineinreichen.
|
||
|
||
Zwei Fälle, und beide müssen stimmen:
|
||
* Es kam etwas Neues -> das Gespräch ist wieder da, aber nur
|
||
mit dem Neuen (id > geloescht_bis).
|
||
* Es kam nichts -> es bleibt weg. Auch dann, wenn es nie eine
|
||
Nachricht gab; dafür steht geloescht_am. */
|
||
/* ERST ANGLEICHEN, DANN LESEN. Ein Kanal, in dem die Teamfuehrung
|
||
keine Zeile hat, waere ihr zugaenglich (istDrin laesst sie
|
||
hinein) und stuende trotzdem nicht in ihrer Liste -- erreichbar
|
||
nur ueber einen Link, den ihr jemand schickt. Das ist genau die
|
||
Sorte Loch, die man nicht bemerkt, weil nichts kaputt aussieht. */
|
||
kanaeleAngleichen(req.person);
|
||
/* UND DER TREFF. `kanaeleAngleichen` traegt nur die Teamfuehrung
|
||
ein; im Treff stehen ausserdem die Community und die Modis.
|
||
Derselbe Gedanke, andere Personengruppe -- und aus demselben
|
||
Grund selbstheilend: Ein neuer Zugang aus der Community soll im
|
||
Chat stehen, ohne dass jemand daran denken muss. */
|
||
if (gehoertInTreff(req.person)) treffAngleichen();
|
||
|
||
/* ==== NUR DIE RAEUME DIESES HAUSES (24.09.2026) ================
|
||
|
||
Filipe: „die seiten soll nicht mehr im chat kalender und alles
|
||
andere verbunden sein."
|
||
|
||
DIESE LISTE HATTE KEINEN HAUS-FILTER, und das fiel nur bei einem
|
||
einzigen Menschen auf: DogFather. Alle anderen sehen ohnehin nur
|
||
Raeume, in denen sie sitzen, und sie sitzen nur in denen ihres
|
||
Hauses. Er sitzt in beiden -- gemessen am echten Stand standen
|
||
auf der Team-Adresse in seiner Gespraechsliste auch seine zwei
|
||
Zweier-Gespraeche mit Scouts.
|
||
|
||
UEBER DIE SPALTE UND NICHT UEBER DIE TEILNEHMER: Ein Raum, in
|
||
dem nur DogFather sitzt (er legt einen Kanal an, es ist noch
|
||
niemand drin), haette ueber die Teilnehmer keinen Beleg und
|
||
stuende in beiden Haeusern. Die Spalte weiss, wo er ihn
|
||
angelegt hat. */
|
||
const reihen = db().prepare(`
|
||
SELECT r.id, r.art, r.name, r.kategorie, r.letzte_am, r.erstellt_von,
|
||
t.gelesen_bis, t.leitung, t.geloescht_bis, t.fixiert_am
|
||
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 ${hausWo(req.person, "r")}
|
||
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))
|
||
/* ANGEHEFTETES ZUERST (25.09.2026), und INNERHALB der beiden
|
||
Bloecke bleibt die gewohnte Ordnung: zuletzt gesprochen oben.
|
||
Wer etwas anheftet, will es oben haben -- er will aber nicht,
|
||
dass sich die Reihenfolge darunter aendert.
|
||
|
||
ZULETZT ANGEHEFTET STEHT GANZ OBEN. Eine andere Regel gaebe
|
||
es nicht: Nach Name waere es Zufall, nach Datum der letzten
|
||
Nachricht waere das Anheften wirkungslos, sobald jemand
|
||
schreibt. */
|
||
ORDER BY CASE WHEN t.fixiert_am IS NULL THEN 1 ELSE 0 END,
|
||
t.fixiert_am DESC,
|
||
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;
|
||
|
||
/* STEHT IN DEN UNGELESENEN EIN "@" AN MICH? (20.09.2026)
|
||
|
||
Eine Zahl sagt "hier ist etwas". Sie sagt nicht, ob es an
|
||
DICH war. Wer morgens vier Raeume mit Zahlen sieht, macht
|
||
den lautesten zuerst auf -- und genau dort steht selten das,
|
||
was auf ihn wartet. Das Zeichen daneben dreht die Reihenfolge
|
||
um.
|
||
|
||
DIESELBEN ZWEI GRENZEN wie bei der Zahl darueber: was schon
|
||
gelesen ist, und was weggeraeumt wurde. Ohne die zweite
|
||
leuchtete ein Gespraech wieder auf, das jemand bewusst
|
||
beiseitegelegt hat.
|
||
|
||
UND `weg_am IS NULL`: Eine zurueckgenommene Nachricht spricht
|
||
niemanden mehr an. Der Eintrag bleibt stehen (er gehoert zur
|
||
Nachricht), zaehlt hier aber nicht mehr mit -- gerechnet wird
|
||
beim Lesen, nicht beim Zuruecknehmen, damit es keine zweite
|
||
Stelle gibt, die man vergessen kann. */
|
||
const erwaehnt = !!db().prepare(`
|
||
SELECT 1 FROM chat_erwaehnungen e
|
||
JOIN chat_nachrichten n ON n.id = e.nachricht_id
|
||
WHERE e.person_id = ? AND n.raum_id = ?
|
||
AND n.id > ? AND n.id > ? AND n.weg_am IS NULL
|
||
LIMIT 1`)
|
||
.get(req.person.id, r.id, r.gelesen_bis, r.geloescht_bis);
|
||
|
||
return {
|
||
id: r.id,
|
||
art: r.art,
|
||
kategorie: r.kategorie || null,
|
||
name: raumName(r, leute, req.person.id),
|
||
leitung: !!r.leitung,
|
||
/* DARF ICH DIESEN RAUM FUER ALLE AUFLOESEN? (11.09.2026)
|
||
Vom Server beantwortet, nicht im Browser nachgerechnet -- die
|
||
Oberflaeche hat `erstellt_von` sonst gar nicht. Die Regel
|
||
selbst steht in darfAufloesen(), damit sie nicht an zwei
|
||
Stellen steht; genau daran ist es beim ersten Anlauf
|
||
gescheitert. */
|
||
darf_aufloesen: darfAufloesen(r, req.person),
|
||
/* DAS BILD GEHOERT DAZU (20.09.2026).
|
||
|
||
Filipe: "da soll man im chat auch die profilfotos von den
|
||
leuten sehen wenn die schon eins drin haben."
|
||
|
||
Es fehlte genau hier: `teilnehmerVon` liest das Bild aus der
|
||
Datenbank, und diese Zeile warf es weg. Die Detailansicht
|
||
weiter unten nimmt es mit -- die LISTE nicht. Deshalb stand
|
||
im offenen Gespraech ein Gesicht und in der Liste daneben ein
|
||
Buchstabe, vom selben Menschen. */
|
||
teilnehmer: leute.map((t) => ({ id: t.id, name: t.name, rolle: t.rolle,
|
||
bild: t.bild })),
|
||
letzte: letzte ? {
|
||
/* Ein Anhang ohne Begleittext hätte hier eine leere Zeile
|
||
hinterlassen. „Foto", „PDF", „Sprachnachricht" ist die
|
||
Vorschau, die man in jeder Gesprächsliste erwartet -- das
|
||
Wort kommt aus anhangWort(), an einer Stelle für alle vier
|
||
Orte, an denen es gebraucht wird. */
|
||
text: letzte.weg_am ? null
|
||
: (letzte.text ? letzte.text.slice(0, 140)
|
||
: anhangWort(letzte.anhang_art)),
|
||
zurueckgenommen: !!letzte.weg_am,
|
||
von: letzte.von, erstellt: letzte.erstellt,
|
||
} : null,
|
||
ungelesen,
|
||
erwaehnt,
|
||
/* Als Ja/Nein an die Oberflaeche -- WANN jemand etwas
|
||
angeheftet hat, geht niemanden etwas an, auch ihn selbst
|
||
nicht. Der Zeitstempel ist Sortierung, keine Auskunft. */
|
||
fixiert: !!r.fixiert_am,
|
||
};
|
||
});
|
||
/* 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. */
|
||
/* DIESELBE FRAGE, DIESELBE ANTWORT. Die Oberflaeche zeigt den
|
||
Knopf nur, wenn der Server ihn auch annimmt -- ein Knopf, der
|
||
mit 404 antwortet, ist schlimmer als keiner. */
|
||
const darfKanal = darfKanalAufmachen(req.person);
|
||
const belegt = darfKanal
|
||
? new Set(db().prepare("SELECT kategorie FROM chat_raeume WHERE art = 'kanal'")
|
||
.all().map((z) => z.kategorie))
|
||
: new Set();
|
||
|
||
res.json({
|
||
raeume,
|
||
darf_kanal: darfKanal,
|
||
kanal_frei: darfKanal ? MODI_KATEGORIEN.filter((k) => !belegt.has(k.wert)) : [],
|
||
/* WAS FUER DEN TREFF GILT, STEHT IN DER ANTWORT -- die Seite
|
||
rechnet die Nachtruhe NICHT selbst nach. Eine zweite Rechnung
|
||
im Browser waere die, die bei der naechsten Zeitumstellung
|
||
anders herauskommt als die im Server; dann sagt die Seite
|
||
"offen", waehrend der Server ablehnt. */
|
||
treff: gehoertInTreff(req.person) ? nachtruheStand() : null,
|
||
/* AUSDRUECKLICH GESAGT, NICHT ERRATEN (19.09.2026).
|
||
|
||
Die Anrufknoepfe verschwinden fuer die Community nicht von
|
||
selbst, nur weil die Schnittstelle 404 antwortet -- sie stuenden
|
||
da und taeten nichts. Ein Knopf, der nichts tut, ist schlimmer
|
||
als kein Knopf: Man drueckt ihn wieder.
|
||
|
||
Die Antwort kommt aus DERSELBEN Funktion, die auch der
|
||
Anruf-Router benutzt. Zwei Rechnungen waeren die Stelle, an der
|
||
die Seite einen Knopf zeigt, den der Server ablehnt. */
|
||
darf_anrufen: darfTelefonieren(req.person),
|
||
});
|
||
} catch (fehler) {
|
||
console.error("[chat] Räume:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/** Ein Zweier-Gespräch öffnen (und beim ersten Mal anlegen). */
|
||
chatRouter.post("/workspace/api/chat/direkt", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => {
|
||
try {
|
||
const mit = Number(req.body?.mit);
|
||
if (!Number.isInteger(mit) || mit < 1) return res.status(400).json({ fehler: "ungueltig" });
|
||
/* DIE REGEL SITZT HIER, nicht in der Oberfläche. Wer die Anfrage
|
||
von Hand baut, kommt damit keinen Schritt weiter. */
|
||
if (!darfSchreibenMit(req.person, mit)) {
|
||
return res.status(403).json({ fehler: "Mit dieser Person kannst du nicht schreiben." });
|
||
}
|
||
const ziel = db().prepare("SELECT id FROM personen WHERE id = ? AND aktiv = 1").get(mit);
|
||
if (!ziel) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
|
||
const raumId = direktRaum(req.person, mit);
|
||
res.json({ raum_id: raumId });
|
||
} catch (fehler) {
|
||
console.error("[chat] Direkt:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/** Eine Gruppe anlegen. Wer sie anlegt, führt sie. */
|
||
chatRouter.post("/workspace/api/chat/gruppe", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => {
|
||
try {
|
||
const name = String(req.body?.name ?? "").trim();
|
||
if (name.length < 2) return res.status(400).json({ fehler: "Die Gruppe braucht einen Namen." });
|
||
if (name.length > NAME_MAX) return res.status(400).json({ fehler: "Der Name ist zu lang." });
|
||
|
||
const roh = Array.isArray(req.body?.mit) ? req.body.mit : [];
|
||
const mit = [...new Set(roh.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
|
||
.filter((id) => id !== req.person.id);
|
||
/* JEDE Nummer einzeln gegen die Regel -- eine Gruppe ist kein
|
||
Schlupfloch, um jemanden zu erreichen, den man einzeln nicht
|
||
anschreiben dürfte. */
|
||
const verboten = mit.filter((id) => !darfSchreibenMit(req.person, id));
|
||
if (verboten.length) {
|
||
return res.status(403).json({ fehler: "Mit mindestens einer Person kannst du nicht schreiben." });
|
||
}
|
||
if (!mit.length) return res.status(400).json({ fehler: "Wähle mindestens eine Person aus." });
|
||
|
||
const d = db();
|
||
const n = jetzt();
|
||
/* Auch die Gruppe merkt sich ihr Haus (24.09.2026). Sie hat beim
|
||
Anlegen genau EIN Mitglied -- den, der sie anlegt. Ueber die
|
||
Beteiligten waere sie also gar nicht einzuordnen, solange
|
||
DogFather sie anlegt und noch niemand dazugekommen ist. */
|
||
d.prepare("INSERT INTO chat_raeume (art, name, erstellt, erstellt_von, haus)"
|
||
+ " VALUES ('gruppe',?,?,?,?)")
|
||
.run(name, n, req.person.id, req.person.haus || null);
|
||
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.
|
||
===================================================================== */
|
||
/** Wer einen Kanal AUFMACHEN darf (23.09.2026).
|
||
*
|
||
* Filipe: "kanaele sollen auch nur die rechte hand und dogfather
|
||
* aufmachen koennen."
|
||
*
|
||
* NICHT `fuehrtTeamDogi`: Das schliesst die linke Hand ein
|
||
* (WIE_RECHTE_HAND = {hand, linke}), und genau die zwei Worte im
|
||
* Auftrag schliessen sie aus. NICHT `istLeitung`: Das schliesst
|
||
* Spicy Media ein, und genannt wurden zwei Rollen, nicht drei.
|
||
*
|
||
* Dieselbe Bauweise wie `darfJedeNachrichtLoeschen` weiter unten:
|
||
* woertlich die zwei aus dem Auftrag, an EINER Stelle. Wer sie
|
||
* spaeter aendert, aendert sie ueberall.
|
||
*
|
||
* WAS ES NICHT BETRIFFT: Wer in einem bestehenden Kanal die Leute
|
||
* aendert -- das ist eine andere Frage, und "aufmachen" beantwortet
|
||
* sie nicht. Dort gilt weiter `fuehrtTeamDogi`. */
|
||
const darfKanalAufmachen = (person) =>
|
||
istDogFather(person) || person?.rolle === "hand";
|
||
|
||
chatRouter.post("/workspace/api/chat/kanal", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => {
|
||
try {
|
||
/* 404 und nicht 403: Wer keine Kanaele aufmachen darf, soll auch
|
||
nicht erfahren, dass es die Moeglichkeit gibt. */
|
||
if (!darfKanalAufmachen(req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
|
||
/* ==== EIN EIGENER NAME IST ERLAUBT (24.09.2026) ===============
|
||
|
||
Filipe: „ich will dass man die option hat einen neuen namen zu
|
||
erstellen den es noch nicht da in der liste gibt."
|
||
|
||
Bis heute musste die Zustaendigkeit aus MODI_KATEGORIEN
|
||
stammen -- neun feste Themen. Jetzt geht auch ein eigener
|
||
Name, und die Liste bleibt als Vorschlag.
|
||
|
||
DER SCHLUESSEL WIRD ABGELEITET, NICHT GETIPPT. Er landet in
|
||
der Datenbank und in Adressen; Leerzeichen, Umlaute und
|
||
Satzzeichen haetten dort nichts verloren. Aus „Technik & Ton"
|
||
wird "technik-ton". Der ANGEZEIGTE Name bleibt, wie er
|
||
geschrieben wurde -- er steht in "name".
|
||
|
||
WARUM EIN PRAEFIX: Ein eigener Schluessel darf nie mit einem
|
||
festen zusammenfallen. Schriebe jemand „Community", entstuende
|
||
"community" -- derselbe Schluessel wie das feste Thema, und
|
||
der eindeutige Index in der Datenbank wuerde ihn ablehnen,
|
||
obwohl der Kanal noch gar nicht existiert. Mit "eigen-" davor
|
||
kann das nicht passieren.
|
||
|
||
DER NAME WIRD MITGESPEICHERT. Bei den festen Themen kommt er
|
||
aus MODI_KATEGORIEN (siehe kanalName weiter oben); ein eigener
|
||
hat dort keinen Eintrag, deshalb steht er in der Spalte
|
||
"name". */
|
||
const kategorie = String(req.body?.kategorie ?? "").trim();
|
||
const eigenerName = String(req.body?.eigener_name ?? "").trim()
|
||
.replace(/\s+/g, " ").slice(0, 60);
|
||
const fest = MODI_KATEGORIEN.some((k) => k.wert === kategorie);
|
||
let schluessel = kategorie;
|
||
let anzeigeName = null;
|
||
if (!fest) {
|
||
if (eigenerName.length < 2) {
|
||
return res.status(400).json({
|
||
fehler: "Für einen eigenen Kanal brauchst du einen Namen (mindestens zwei Zeichen).",
|
||
});
|
||
}
|
||
const rein = eigenerName.toLowerCase()
|
||
.replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss")
|
||
.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
|
||
if (!rein) {
|
||
return res.status(400).json({
|
||
fehler: "Der Name braucht mindestens einen Buchstaben oder eine Ziffer.",
|
||
});
|
||
}
|
||
schluessel = "eigen-" + rein;
|
||
anzeigeName = eigenerName;
|
||
}
|
||
|
||
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(schluessel);
|
||
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." });
|
||
}
|
||
/* UND SIE MUESSEN ZU TEAM DOGI GEHOEREN (22.09.2026, VanVans
|
||
Befund). `darfSchreibenMit` beantwortet eine andere Frage --
|
||
"darf ich diesen Menschen ueberhaupt anschreiben" --, und die
|
||
sagt bei einem Scout zu Recht ja. Ein Kanal ueber
|
||
Chat-Moderation ist trotzdem kein Ort fuer ihn. Genau diese
|
||
Luecke hat die zwei Scouts in den Moderations-Kanal gebracht. */
|
||
if (mit.some((id) => !gehoertInKanal(id))) {
|
||
return res.status(403).json({
|
||
fehler: "In einen Kanal gehören nur Leute aus Team Dogi." });
|
||
}
|
||
|
||
const n = jetzt();
|
||
d.prepare(`INSERT INTO chat_raeume (art, name, kategorie, erstellt, erstellt_von, haus)
|
||
VALUES ('kanal',?,?,?,?,?)`)
|
||
/* Der eigene Name geht in die Spalte, der feste kommt aus
|
||
MODI_KATEGORIEN. Beides landet in derselben Spalte -- die
|
||
Zeile im Chat liest nur noch dort nach. */
|
||
.run(anzeigeName || kanalName(schluessel), schluessel, n, req.person.id,
|
||
req.person.haus || null);
|
||
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: anzeigeName || kanalName(schluessel) });
|
||
} 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." });
|
||
}
|
||
/* DIESELBE SCHRANKE WIE BEIM ANLEGEN. Ohne sie waere die Regel
|
||
eine Tuer mit zwei Eingaengen, von denen nur einer ein Schloss
|
||
hat -- und der ohne Schloss ist der, den man haeufiger
|
||
benutzt. */
|
||
if ([...soll].some((id) => !gehoertInKanal(id))) {
|
||
return res.status(403).json({
|
||
fehler: "In einen Kanal gehören nur Leute aus Team Dogi." });
|
||
}
|
||
|
||
/* 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, p.chat_kachel AS von_kachel,
|
||
n.antwort_auf, n.angeheftet_am, n.rudel,
|
||
n.anhang_name, n.anhang_art, n.anhang_typ, n.anhang_groesse,
|
||
n.anhang_breite, n.anhang_hoehe, n.anhang_dauer,
|
||
/* DER DATEINAME WIRD GEBRAUCHT, ABER NICHT AUSGELIEFERT
|
||
(03.10.2026). zweitfassungDa() fragt mit ihm die
|
||
Platte; nach draussen geht er nicht -- was dort liegt,
|
||
geht niemanden etwas an, der nur zuhoeren will.
|
||
Dass er hier fehlte, war der Grund, warum die zweite
|
||
Fassung nie angeboten wurde: Die Funktion bekam nichts
|
||
und sagte brav „gibt es nicht". Gefunden hat das die
|
||
Pruefung, nicht das Lesen.
|
||
(Und KEINE Gegen-Apostrophe in diesem Text: Er steht
|
||
in einem Template-Literal, und einer davon beendet
|
||
es. Heute schon das zweite Mal.) */
|
||
n.anhang_datei, n.bearbeitet_am,
|
||
a.text AS zitat_text, a.weg_am AS zitat_weg, ap.name AS zitat_von,
|
||
a.anhang_art AS zitat_anhang
|
||
FROM chat_nachrichten n
|
||
LEFT JOIN personen p ON p.id = n.person_id
|
||
LEFT JOIN chat_nachrichten a ON a.id = n.antwort_auf AND a.raum_id = n.raum_id
|
||
LEFT JOIN personen ap ON ap.id = a.person_id
|
||
WHERE n.raum_id = ? AND n.id > ? ${vor ? "AND n.id < ?" : ""}
|
||
ORDER BY n.id DESC LIMIT 200`).all(...(vor ? [raumId, weg, vor] : [raumId, weg]));
|
||
|
||
/* DER GELESEN-STAND, BEVOR ER GESETZT WIRD.
|
||
|
||
Die Oberflaeche zieht daraus die Linie "ab hier ist es neu". Sie
|
||
muss ihn deshalb beim OEFFNEN bekommen -- eine Zeile spaeter
|
||
(nach dem PUT auf /gelesen) waere er immer die letzte Nachricht,
|
||
und die Linie staende nie irgendwo. */
|
||
const gelesenBis = db().prepare(
|
||
"SELECT gelesen_bis FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?")
|
||
.get(raumId, req.person.id)?.gelesen_bis ?? 0;
|
||
|
||
const raum = db().prepare(
|
||
"SELECT id, art, name, kategorie, erstellt_von FROM chat_raeume WHERE id = ?").get(raumId);
|
||
const leute = teilnehmerVon(raumId);
|
||
|
||
/* DIE ANGEHEFTETEN, in einer eigenen Abfrage.
|
||
|
||
Sie MUESSEN getrennt geholt werden: Der Verlauf oben liefert nur
|
||
die letzten 200 Zeilen. Eine Ankuendigung von vorletzter Woche
|
||
faellt da heraus -- und waere damit ausgerechnet dann verschwunden,
|
||
wenn sie am laengsten oben stehen sollte.
|
||
|
||
DIESELBEN ZWEI GRENZEN WIE DER VERLAUF: `n.id > weg` (was ich
|
||
weggeraeumt habe, kommt nicht ueber die Hintertuer zurueck) und
|
||
`weg_am IS NULL` (eine zurueckgenommene Nachricht bleibt
|
||
zurueckgenommen, auch als Aushang). */
|
||
const weggeraeumt = weg;
|
||
/* ==== WAS ICH FUER MICH WEGGENOMMEN HABE (25.09.2026) ==========
|
||
|
||
Filipe: „jeder soll das fixierte individuel fuer sich loesen
|
||
koennen aber niemals so dass es sich fuer alle loest."
|
||
|
||
DER VERBUND STEHT HIER UND NICHT IM BROWSER. Eine Liste, die
|
||
alles schickt und im Browser gefiltert wird, ist eine Liste, die
|
||
alles schickt -- und der naechste, der sie benutzt, vergisst den
|
||
Filter. Ausserdem stimmte dann die Grenze von drei nicht mehr:
|
||
Wer einen Aushang weggenommen hat, saehe zwei statt drei,
|
||
obwohl ein vierter danebenliegt.
|
||
|
||
DESHALB `NOT EXISTS` UND EIN GROESSERES LIMIT: Gesucht wird
|
||
unter denen, die fuer MICH noch gelten -- und davon die
|
||
obersten drei. So ruecken die anderen nach, genau wie an einem
|
||
Brett aus Kork. */
|
||
const angeheftet = db().prepare(`
|
||
SELECT n.id, n.text, n.erstellt, n.angeheftet_am, n.anhang_art,
|
||
n.angeheftet_von, 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 > ?
|
||
AND NOT EXISTS (SELECT 1 FROM chat_pin_aus x
|
||
WHERE x.nachricht_id = n.id AND x.person_id = ?)
|
||
ORDER BY n.angeheftet_am DESC LIMIT ?`)
|
||
.all(raumId, weggeraeumt, req.person.id, PIN_MAX);
|
||
|
||
/* UND WELCHE ICH WEGGENOMMEN HABE -- damit das Menue an der
|
||
Nachricht „wieder oben anzeigen" anbieten kann. Ohne diese Liste
|
||
waere das Wegnehmen eine Einbahnstrasse, und dann braeuchte es
|
||
eine Rueckfrage davor. So braucht es keine. */
|
||
const pinAus = new Set(db().prepare(`
|
||
SELECT x.nachricht_id AS id FROM chat_pin_aus x
|
||
JOIN chat_nachrichten n ON n.id = x.nachricht_id
|
||
WHERE x.person_id = ? AND n.raum_id = ?`)
|
||
.all(req.person.id, raumId).map((z) => z.id));
|
||
|
||
res.json({
|
||
raum: {
|
||
id: raum.id, art: raum.art, kategorie: raum.kategorie || null,
|
||
name: raumName(raum, leute, req.person.id),
|
||
teilnehmer: leute.map((t) => ({ id: t.id, name: t.name, rolle: t.rolle,
|
||
bild: t.bild, leitung: !!t.leitung })),
|
||
/* Dasselbe Feld wie in der Liste, aus derselben Regel. Die
|
||
Oberflaeche nimmt den offenen Raum von HIER -- ohne diese
|
||
Zeile blieb der Aufloesen-Knopf unsichtbar, obwohl das Recht
|
||
da war. */
|
||
darf_aufloesen: darfAufloesen(raum, req.person),
|
||
},
|
||
gelesen_bis: gelesenBis,
|
||
/* OB DER KNOPF UEBERHAUPT ERSCHEINT, entscheidet der Server.
|
||
Die Oberflaeche koennte `rolle === 'admin'` vergleichen -- aber
|
||
assets/js/chat.js bekommt jeder, der die Seite oeffnet. Ein
|
||
Ja/Nein verraet nichts ueber die Rollen dahinter. */
|
||
darf_anheften: darfAnheften(req.person, raumId),
|
||
/* WER DARF FUER ALLE LOESEN? Vom Server beantwortet, nicht im
|
||
Browser nachgerechnet -- dort stuende die Regel ein zweites
|
||
Mal, und die zweite Fassung waere ab der naechsten Aenderung
|
||
falsch. Dieselbe Ueberlegung wie bei darf_anheften daneben.
|
||
Das „oder wer es selbst angeheftet hat" entscheidet die Route
|
||
je Nachricht; hier steht nur, ob die Wahl ueberhaupt
|
||
angeboten wird. */
|
||
darf_pin_fuer_alle: fuehrtTeamDogi(req.person),
|
||
/* WER JEDE NACHRICHT LOESCHEN DARF -- vom Server, nicht aus
|
||
der Rolle im Browser geraten. assets/js/chat.js bekommt
|
||
JEDER, der die Seite oeffnet; ein `rolle === 'hand'` darin
|
||
waere eine Auskunft ueber das Haus an jeden Leser. Und es
|
||
waere eine zweite Wahrheit neben der Schranke im Server. */
|
||
darf_alles_loeschen: darfJedeNachrichtLoeschen(req.person),
|
||
/* DARF ICH DAS RUDEL RUFEN? (23.09.2026)
|
||
|
||
Vom Server, aus derselben Funktion wie die Schranke beim
|
||
Schreiben (istTeamDogi). Die Oberflaeche koennte
|
||
`ich.rolle === 'modi' || ...` vergleichen -- das waere eine
|
||
zweite Wahrheit, und assets/js/chat.js bekommt jeder, der die
|
||
Seite oeffnet. Ein Ja/Nein verraet nichts ueber das Haus.
|
||
|
||
DAS WORT KOMMT MIT. Stuende "rudel" im Browser fest, hiesse es
|
||
nach einer Umbenennung an einer Stelle anders als an der
|
||
anderen -- und der Vorschlag beim Tippen setzte etwas ein, das
|
||
der Server nicht mehr erkennt. */
|
||
/* UND OB ER HIER ueberhaupt jemanden erreichen wuerde. In einem
|
||
Zweier-Gespraech mit einem Creator ist ausser mir niemand aus
|
||
dem Team -- der Vorschlag "rudel, alle im Team" waere dort ein
|
||
Eintrag, der nichts tut. Dieselbe Ueberlegung wie beim eigenen
|
||
Namen, den die Liste auch nicht anbietet.
|
||
|
||
DIE SCHRANKE BEIM SCHREIBEN HAENGT NICHT DARAN. Wer den Ruf
|
||
von Hand tippt, ruft eben niemanden -- genau wie ein "@Anna",
|
||
das Anna nicht beantwortet. Verboten ist es nicht, es ist nur
|
||
keine Hilfe, es vorzuschlagen. */
|
||
/* WEN DER VORSCHLAG ERREICHT, HAENGT AM RAUM -- und die
|
||
Schranke davor muss dieselbe Frage stellen.
|
||
|
||
Sonst entsteht ein stiller Widerspruch: Sitzt im Rudel-Raum
|
||
ein Modi mit neun Zuschauern, aber ohne zweites Teammitglied,
|
||
dann traefe "@rudel" jetzt neun Leute -- der Vorschlag beim
|
||
Tippen waere aber ausgeblendet, weil er nach einem zweiten
|
||
Teammitglied sucht. Die Funktion gaebe es, und niemand faende
|
||
sie. Also wird hier genau das gefragt, was der Ruf nachher
|
||
auch tut. */
|
||
darf_rudel: istTeamDogi(req.person)
|
||
&& (istTreffRaum(raumId)
|
||
? leute.some((t) => t.id !== req.person.id)
|
||
: leute.some((t) => t.id !== req.person.id && istTeamDogi(t))),
|
||
rudel_wort: RUDEL_WORT,
|
||
/* UND WEN ER MEINT, IN WORTEN.
|
||
|
||
Der Browser koennte die Kategorie des Raums vergleichen --
|
||
dann stuende die Regel an zwei Stellen und liefe irgendwann
|
||
auseinander. Er bekommt stattdessen die Antwort: "raum" oder
|
||
"team". Die Worte dazu bildet er selbst, damit deutscher Text
|
||
nicht im Server liegt. */
|
||
rudel_wen: istTreffRaum(raumId) ? "raum" : "team",
|
||
/* DARF ICH SPRACHNACHRICHTEN SCHICKEN? (23.09.2026)
|
||
|
||
Aus derselben Funktion wie die Schranke in der Anhang-Route.
|
||
Der Browser vergleicht keine Rollen -- assets/js/chat.js
|
||
bekommt jeder, der die Seite oeffnet.
|
||
|
||
ANDERS ALS BEIM RUDEL HAENGT ES NICHT AM RAUM: Eine
|
||
Sprachnachricht an einen Creator ist eine Sprachnachricht an
|
||
einen Creator; sie erreicht ihn. Der Ruf ans Rudel erreicht in
|
||
so einem Raum niemanden -- deshalb dort die zweite Bedingung
|
||
und hier nicht. */
|
||
darf_ton: istTeamDogi(req.person),
|
||
/* DIE GIF-KISTE, aus derselben Regel. Filipes „also nur" stand
|
||
im selben Satz wie die Sprachnachrichten. */
|
||
darf_gif: istTeamDogi(req.person),
|
||
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)
|
||
|| anhangWort(a.anhang_art),
|
||
/* DARF ICH DIESEN HIER FUER ALLE LOESEN? (25.09.2026)
|
||
|
||
Je Aushang und nicht je Person, weil die Antwort davon
|
||
abhaengt, WER ihn angeheftet hat: DogFather und die rechte
|
||
Hand duerfen immer; alle anderen nur das, was sie selbst
|
||
angeheftet haben (sonst koennte eine Gruppenleitung ihre
|
||
drei Aushaenge nie wieder abnehmen -- siehe die Begruendung
|
||
an der Route).
|
||
|
||
VOM SERVER BEANTWORTET, damit die Regel nicht im Browser ein
|
||
zweites Mal steht. Die Route prueft sie ohnehin; hier geht
|
||
es nur darum, ob der Knopf ueberhaupt angeboten wird -- ein
|
||
Knopf, der nur eine Absage kann, ist schlimmer als keiner. */
|
||
darf_fuer_alle: fuehrtTeamDogi(req.person)
|
||
|| Number(a.angeheftet_von) === Number(req.person.id),
|
||
})),
|
||
/* Aufsteigend zurück -- gelesen wird von oben nach unten. */
|
||
/* Die Reaktionen in EINER Abfrage fuer alle Nachrichten -- nicht
|
||
je Zeile eine. Bei 200 Zeilen waeren das 200 Abfragen fuer eine
|
||
Ansicht, die man beim Scrollen staendig neu holt. */
|
||
nachrichten: (() => {
|
||
const gedreht = reihen.reverse();
|
||
const reakt = reaktionenZu(gedreht.map((n) => n.id), req.person.id);
|
||
return gedreht.map((n) => ({
|
||
reaktionen: reakt.get(n.id) || [],
|
||
id: n.id, von_id: n.person_id, von: n.von || "Gelöscht", rolle: n.rolle,
|
||
von_bild: n.von_bild ? `/workspace/api/steckbrief/bild/${n.von_bild}` : null,
|
||
/* DIE EIGENE KACHELFARBE (20.09.2026).
|
||
|
||
Filipe: "jeder account soll seine kachel personalisieren
|
||
koennen."
|
||
|
||
Sie kommt aus `personen`, nicht aus der Nachricht: Wer seine
|
||
Farbe aendert, aendert sie fuer ALLE seine Nachrichten,
|
||
auch die von gestern. Die Farbe an jede Zeile zu schreiben
|
||
waere eine zweite Wahrheit -- und nach der ersten Aenderung
|
||
haette dieselbe Person zwei Farben im selben Gespraech. */
|
||
/* Eigene Wahl, sonst die automatische -- dieselbe Regel wie
|
||
ueberall, damit eine Person in jeder Liste gleich aussieht. */
|
||
von_kachel: kachelPruefen(n.von_kachel) || autoKachelFuer(n.person_id),
|
||
text: n.weg_am ? null : n.text,
|
||
zurueckgenommen: !!n.weg_am,
|
||
/* WANN BEARBEITET -- oder null. Geht nach draussen, weil an
|
||
der Nachricht "bearbeitet" stehen soll: Ein Text, der sich
|
||
hinterher still aendert, waehrend andere schon darauf
|
||
geantwortet haben, ist ein Vertrauensproblem und kein
|
||
Komfort. An einer geloeschten steht es nicht, dort gibt es
|
||
keinen Text mehr, auf den es sich beziehen koennte. */
|
||
bearbeitet_am: n.weg_am ? null : (n.bearbeitet_am || null),
|
||
erstellt: n.erstellt,
|
||
selbst: n.person_id === req.person.id,
|
||
angeheftet: !!n.angeheftet_am,
|
||
/* OB ICH SIE OBEN WEGGENOMMEN HABE (25.09.2026). Steht an der
|
||
Nachricht und nicht in einer zweiten Liste im Browser: Das
|
||
Menue an der Nachricht muss daraus „oben wegnehmen" oder
|
||
„wieder oben anzeigen" machen, und zwei Quellen fuer
|
||
dasselbe laufen auseinander. */
|
||
pin_fuer_mich_aus: pinAus.has(n.id),
|
||
/* WURDE HIER DAS RUDEL GERUFEN? Steht an der Nachricht, weil
|
||
der Browser sonst die damalige Rolle des Absenders kennen
|
||
muesste -- siehe die Spalte in workspace.js. */
|
||
rudel: !!n.rudel,
|
||
/* 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)
|
||
|| anhangWort(n.zitat_anhang)),
|
||
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,
|
||
dauer: n.anhang_dauer || 0,
|
||
weg: `/workspace/api/chat/anhang/${n.id}`,
|
||
/* WO DIE ZWEITE FASSUNG LIEGT -- oder `null`, wenn es
|
||
keine gibt (Bild, PDF, oder ffmpeg hat noch nicht).
|
||
GEFRAGT WIRD DIE PLATTE, nicht eine Spalte: Eine Spalte
|
||
muesste gepflegt werden, und sie waere genau dann
|
||
falsch, wenn jemand eine Datei von Hand nachlegt oder
|
||
der Nachruestlauf gerade durchgelaufen ist. */
|
||
weg_auch: zweitfassungDa(n.anhang_art, n.anhang_datei)
|
||
? `/workspace/api/chat/anhang/${n.id}?form=mp4` : null,
|
||
} : null,
|
||
}));
|
||
})(),
|
||
});
|
||
} catch (fehler) {
|
||
console.error("[chat] Verlauf:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/** Etwas schreiben. */
|
||
chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft,
|
||
express.json({ limit: "16kb" }), async (req, res) => {
|
||
try {
|
||
const raumId = Number(req.params.id);
|
||
if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" });
|
||
if (!istDrin(raumId, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
|
||
/* ==== DIE NACHTRUHE ==============================================
|
||
|
||
Filipe: "quasi nach mitternacht bis morgens 6 uhr keiner
|
||
schreiben kann damit auch die privatsphaere beruecksichtig
|
||
wird ueber nacht."
|
||
|
||
SIE STEHT HIER UND NICHT NUR IM BROWSER. Ein ausgegrauter
|
||
Knopf ist eine Bitte; abgelehnt wird erst hier. Wer die
|
||
Schnittstelle direkt anspricht -- und das kann jeder, der die
|
||
Adresse kennt --, kaeme sonst durch.
|
||
|
||
SIE GILT FUER ALLE, AUCH FUER DIE LEITUNG. Eine Nachtruhe, von
|
||
der DogFather ausgenommen waere, ist keine: Wenn er um halb
|
||
vier schreibt, liest es jemand, und wer es liest, fuehlt sich
|
||
zustaendig. Genau den Druck soll sie wegnehmen.
|
||
|
||
NUR IM TREFF. Das Team hat keine Nachtruhe im Chat -- dort
|
||
arbeiten Leute nachts, das ist der Beruf. Hier geht es um
|
||
Zuschauer und um die, die sie betreuen.
|
||
|
||
MODERIEREN GEHT WEITER: Verbergen, loeschen, jemanden
|
||
herausnehmen sind keine Nachrichten und laufen ueber andere
|
||
Wege. Wer nachts etwas Schlimmes sieht, kann es wegnehmen --
|
||
er kann nur nicht darueber diskutieren. */
|
||
if (istTreffRaum(raumId) && istNachtruhe()) {
|
||
const stand = nachtruheStand();
|
||
return res.status(403).json({
|
||
fehler: stand.satz, nachtruhe: stand,
|
||
});
|
||
}
|
||
|
||
const text = String(req.body?.text ?? "").trim();
|
||
if (!text) return res.status(400).json({ fehler: "leer" });
|
||
if (text.length > TEXT_MAX) {
|
||
return res.status(400).json({ fehler: `Länger als ${TEXT_MAX} Zeichen geht nicht.` });
|
||
}
|
||
|
||
/* WORAUF GEANTWORTET WIRD, MUSS IN DEMSELBEN RAUM LIEGEN.
|
||
|
||
Ohne diese Pruefung koennte jemand die Kennung einer Nachricht
|
||
aus einem fremden Gespraech mitschicken -- und beim Empfaenger
|
||
stuende ein Zitat aus einem Raum, den er nie gesehen hat. Die
|
||
Abfrage bindet deshalb `raum_id` mit ein; passt es nicht, wird
|
||
das Zitat still weggelassen statt die Nachricht abzulehnen.
|
||
Der Text ist das Wichtige, das Zitat die Zugabe. */
|
||
let antwortAuf = Number(req.body?.antwort_auf) || null;
|
||
if (antwortAuf) {
|
||
const da = db().prepare(
|
||
"SELECT 1 FROM chat_nachrichten WHERE id = ? AND raum_id = ?").get(antwortAuf, raumId);
|
||
if (!da) antwortAuf = null;
|
||
}
|
||
|
||
const d = db();
|
||
const n = jetzt();
|
||
d.prepare("INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt, antwort_auf) VALUES (?,?,?,?,?)")
|
||
.run(raumId, req.person.id, text, n, antwortAuf);
|
||
const id = d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||
d.prepare("UPDATE chat_raeume SET letzte_am = ? WHERE id = ?").run(n, raumId);
|
||
/* Wer schreibt, hat das Eigene gelesen. */
|
||
d.prepare("UPDATE chat_teilnehmer SET gelesen_bis = ? WHERE raum_id = ? AND person_id = ?")
|
||
.run(id, raumId, req.person.id);
|
||
|
||
const zitat = antwortAuf ? d.prepare(`
|
||
SELECT a.text, a.weg_am, p.name AS von FROM chat_nachrichten a
|
||
LEFT JOIN personen p ON p.id = a.person_id WHERE a.id = ?`).get(antwortAuf) : null;
|
||
|
||
const nachricht = {
|
||
id, raum_id: raumId, von_id: req.person.id, von: req.person.name,
|
||
/* DAS BILD MUSS MIT (20.09.2026). Der Verlauf beim Laden bringt
|
||
`von_bild` mit -- diese Nachricht hier nicht, und sie ist die,
|
||
die per Ereignisstrom bei allen anderen ankommt. Folge: Wer
|
||
gerade zusieht, bekommt einen Buchstaben; wer die Seite neu
|
||
laedt, ein Gesicht. Derselbe Mensch, zwei Darstellungen, und
|
||
der Unterschied haengt nur daran, wann man geschaut hat. */
|
||
von_bild: bildWegFuer(req.person.id),
|
||
/* Auch hier -- sonst kaeme die frisch gesendete Nachricht ohne
|
||
Farbe bei den anderen an und faerbte sich erst beim naechsten
|
||
Laden. Derselbe Fehler wie beim Bild eine Zeile darueber. */
|
||
von_kachel: kachelFuer(req.person.id),
|
||
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,
|
||
/* Wird gleich gesetzt, wenn das Rudel gerufen wurde. Steht
|
||
hier schon drin, damit das Feld in JEDER Nachricht vorkommt
|
||
-- eine Eigenschaft, die mal da ist und mal nicht, ist die,
|
||
die im Browser irgendwann `undefined` heisst. */
|
||
rudel: false,
|
||
};
|
||
|
||
/* ==== WER IST MIT "@" GEMEINT? (20.09.2026) =====================
|
||
|
||
Aufgeloest wird HIER, gegen die Teilnehmer DIESES Raums -- nicht
|
||
im Browser. Der Browser schickt nur Text. Wer daraus eine
|
||
Person wird, entscheidet der Server, und damit gilt:
|
||
|
||
* Ein von Hand getipptes "@Anna" wirkt genauso wie eines aus
|
||
der Auswahlliste. Wer schnell schreibt, soll nicht bestraft
|
||
werden.
|
||
* Niemand kann jemanden ansprechen, der gar nicht im Raum
|
||
sitzt -- auch nicht, indem er die Schnittstelle direkt
|
||
anspricht.
|
||
|
||
Die Regeln stehen in chat-erwaehnung.js, samt Begruendung je
|
||
Regel. Sie sind bewusst ohne Abhaengigkeiten, damit die
|
||
Pruefung sie lesen kann, ohne einen Server hochzufahren. */
|
||
/* ==== "@RUDEL" -- ALLE AUF EINMAL (23.09.2026) =================
|
||
|
||
Filipe: „dan will ich auch dass nur die modis, rechte hand,
|
||
linke hand und dogfather, alle auch auf einmal markieren
|
||
koennen im chat mit einem, @rudel ,dann sollen alle eine
|
||
benarichtigung bekommen."
|
||
|
||
WER DARF, WIRD HIER ENTSCHIEDEN -- nicht im Browser und nicht
|
||
in chat-erwaehnung.js. Der Browser schickt nur Text; die Regeln
|
||
haben keine Abhaengigkeiten und sollen keine bekommen. Bleibt
|
||
diese Stelle, und sie fragt die eine Funktion des Hauses:
|
||
istTeamDogi(). Schreibt ein Creator "@rudel", passiert nichts
|
||
-- kein Ruf, kein Eintrag, keine Hervorhebung. */
|
||
const darfRudel = istTeamDogi(req.person);
|
||
const alleLeute = teilnehmerVon(raumId);
|
||
const rudel = rudelGerufen(text, alleLeute, { rudel: darfRudel });
|
||
|
||
/* WEN DER RUF ERREICHT -- und das haengt vom RAUM ab.
|
||
|
||
Bis zum 23.09.2026 galt ueberall dasselbe: Der Ruf erreichte
|
||
nur die aus Team Dogi. Die Begruendung stand hier und war
|
||
nicht falsch -- im Treff sitzt die Community, und "@rudel"
|
||
haette dort jeden geweckt.
|
||
|
||
GENAU DAS WILL FILIPE JETZT, und zwar ausdruecklich nur dort:
|
||
"wenn wir da im chat @rudel machen will ich dass jeder in dem
|
||
chat markiert wird. nicht nur team ... und sehr wichtig ich
|
||
rede nur von dem chat wo die ganze community auch drin ist.
|
||
sonst nichts anfassen."
|
||
|
||
Also zwei Bedeutungen, nach Raum getrennt:
|
||
|
||
im Rudel-Raum -> ALLE, die drin sind. Der Raum IST das
|
||
Rudel; er heisst auch so.
|
||
ueberall sonst -> Team Dogi, wie bisher. Ein Gruppenchat
|
||
mit drei Creatorinnen bleibt unberuehrt.
|
||
|
||
WAS DIE ALTE SORGE ENTSCHAERFT, und warum das hier tragbar
|
||
ist: RUFEN darf weiterhin nur Team Dogi (siehe darfRudel eine
|
||
Zeile hoeher) -- kein Zuschauer kann alle wecken. Und die
|
||
Nachtruhe gilt in diesem Raum ohnehin, der Ruf kommt also
|
||
nicht um drei Uhr nachts. Bleibt die eine Stelle, an der man
|
||
hinsehen muss: Waechst der Raum auf Hunderte, unterbricht ein
|
||
Ruf sie alle. Das steht im Protokoll (chat_rudel mit Anzahl),
|
||
damit es eine Zahl bleibt und kein Gefuehl. */
|
||
const ausDemRudel = !rudel ? []
|
||
: istTreffRaum(raumId)
|
||
? alleLeute.map((t) => t.id)
|
||
: alleLeute.filter((t) => istTeamDogi(t)).map((t) => t.id);
|
||
|
||
const erwaehnt = [...new Set([
|
||
...erwaehnteIds(text, alleLeute, { rudel: darfRudel }),
|
||
...ausDemRudel,
|
||
])]
|
||
/* SICH SELBST ANSPRECHEN LOEST NICHTS AUS. Man schreibt den
|
||
eigenen Namen durchaus ("@Anna hat recht, ich auch") -- eine
|
||
Meldung an sich selbst waere Unsinn. Beim Rudel genauso: Wer
|
||
das Team ruft, ruft nicht sich. */
|
||
.filter((pid) => pid !== req.person.id);
|
||
if (erwaehnt.length) {
|
||
const einf = d.prepare(
|
||
"INSERT OR IGNORE INTO chat_erwaehnungen (nachricht_id, person_id) VALUES (?,?)");
|
||
for (const pid of erwaehnt) einf.run(id, pid);
|
||
}
|
||
if (rudel) {
|
||
/* DIE NACHRICHT MERKT SICH, DASS DAS RUDEL GERUFEN WURDE.
|
||
Daraus entsteht beim Lesen die Hervorhebung -- ohne dass der
|
||
Browser die Rolle des Absenders von damals nachschlagen
|
||
muesste. Begruendung steht bei der Spalte in workspace.js. */
|
||
d.prepare("UPDATE chat_nachrichten SET rudel = 1 WHERE id = ?").run(id);
|
||
nachricht.rudel = true;
|
||
/* INS PROTOKOLL. Ein Ruf ans ganze Team ist der eine Handgriff
|
||
im Chat, der alle gleichzeitig unterbricht. Wer ihn benutzt,
|
||
soll das nicht heimlich tun koennen -- nicht als Misstrauen,
|
||
sondern weil "@rudel wird zu oft benutzt" sonst ein Gefuehl
|
||
bleibt statt einer Zahl. */
|
||
protokolliere("chat_rudel", {
|
||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||
detail: `#${id} an ${erwaehnt.length} im Raum ${raumId}`.slice(0, 120),
|
||
});
|
||
}
|
||
|
||
/* 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.
|
||
Auch die Erwaehnung: Sie ist keine ZWEITE Meldung, sondern eine
|
||
ANDERE -- wer angesprochen wurde, bekommt statt "Nachricht von
|
||
Anna" ein "Anna hat dich erwaehnt". Zwei Meldungen fuer eine
|
||
Nachricht waeren der schnellste Weg, dass jemand beide
|
||
abschaltet. */
|
||
const empfaenger = alleLeute.filter((t) => t.id !== req.person.id);
|
||
chatEreignis(raumId, nachricht, empfaenger.map((t) => t.id), "neu",
|
||
new Set(erwaehnt), rudel);
|
||
|
||
res.status(201).json({ nachricht });
|
||
} catch (fehler) {
|
||
console.error("[chat] Schreiben:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/* =====================================================================
|
||
ANHÄNGE: FOTOS UND PDF (09.09.2026)
|
||
|
||
Filipe: "am besten wäre es auch wenn man da auch im chat pdfs
|
||
schicken könnte. pdfs und fotos."
|
||
|
||
Wer im Gespräch schreiben darf, darf auch etwas schicken -- auch
|
||
Creator. Das ist ausdrücklich ANDERS als in der Dateiablage, wo
|
||
Creator nicht hochladen dürfen: Dort landet etwas in einem Bereich,
|
||
den mehrere sehen; hier bekommt es genau der, mit dem man ohnehin
|
||
gerade spricht. Ein Creator, der seinem Scout einen Screenshot
|
||
schicken will, soll das können.
|
||
|
||
DER RUMPF WIRD ERST ANGENOMMEN, WENN DIE BERECHTIGUNG STEHT. Die
|
||
Mitgliedschaft wird VOR express.raw geprüft -- sonst wanderten
|
||
zwölf Megabyte durch die Leitung, nur um danach verworfen zu
|
||
werden, und jeder Angemeldete könnte damit an jeder Raumnummer
|
||
Speicher verbrauchen.
|
||
===================================================================== */
|
||
|
||
/** Vor express.raw: Bin ich in diesem Raum? */
|
||
function drinFuerAnhang(req, res, next) {
|
||
const raumId = Number(req.params.id);
|
||
if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" });
|
||
if (!istDrin(raumId, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
next();
|
||
}
|
||
|
||
chatRouter.post("/workspace/api/chat/raeume/:id/anhang",
|
||
gleicheHerkunft,
|
||
drinFuerAnhang,
|
||
express.raw({ type: "*/*", limit: ANHANG_MAX }),
|
||
(req, res) => {
|
||
try {
|
||
const raumId = Number(req.params.id);
|
||
/* DIE NACHTRUHE GILT AUCH FUER ANHAENGE. Sie nur beim Text zu
|
||
pruefen hiesse, dass man nachts zwar nicht schreiben, aber ein
|
||
Foto mit Bildunterschrift schicken kann -- der Weg, den jeder
|
||
findet, der es einmal versucht. */
|
||
if (istTreffRaum(raumId) && istNachtruhe()) {
|
||
const stand = nachtruheStand();
|
||
return res.status(403).json({ fehler: stand.satz, nachtruhe: stand });
|
||
}
|
||
if (!Buffer.isBuffer(req.body) || !req.body.length) {
|
||
return res.status(400).json({ fehler: "Keine Datei empfangen." });
|
||
}
|
||
|
||
/* DER TYP KOMMT AUS DEM INHALT, nicht aus dem Namen und nicht aus
|
||
dem Content-Type. Siehe dateiErkennen(). */
|
||
const erkannt = dateiErkennen(req.body);
|
||
if (!erkannt) {
|
||
return res.status(415).json({
|
||
fehler: "Das geht nicht – erlaubt sind Fotos (PNG, JPEG, WebP, GIF), PDF und Sprachnachrichten.",
|
||
});
|
||
}
|
||
|
||
/* ==== SPRACHNACHRICHTEN SIND NICHT FUER ALLE (23.09.2026) =====
|
||
|
||
Filipe: „dan will ich auch dass man im chat auch
|
||
sprachnachrichten und gifts reinschicken kann. also nur die
|
||
modis rechte linke hand und dogfather."
|
||
|
||
Das „also nur" ist der Punkt. Fotos und PDF darf jeder
|
||
schicken, auch Creator -- das steht als Entscheidung im
|
||
Kommentar ueber dieser Route und bleibt. Ton nicht.
|
||
|
||
HIER UND NICHT NUR IM BROWSER. Ein ausgeblendeter Knopf ist
|
||
eine Bitte; abgelehnt wird erst an dieser Stelle. Dieselbe
|
||
Ueberlegung wie bei der Nachtruhe zwanzig Zeilen darueber. */
|
||
if (erkannt.art === "ton" && !istTeamDogi(req.person)) {
|
||
return res.status(403).json({
|
||
fehler: "Sprachnachrichten schicken nur Team Dogi und DogFather.",
|
||
});
|
||
}
|
||
if (erkannt.art === "ton" && req.body.length > TON_MAX) {
|
||
return res.status(413).json({
|
||
fehler: `Die Sprachnachricht ist zu lang – erlaubt sind ${Math.round(TON_MAX / 1048576)} MB.`,
|
||
});
|
||
}
|
||
|
||
/* WIE LANG SIE IST, SAGT DER ABSENDER -- und das ist eine
|
||
Behauptung, keine Messung. Sie steht nur in der Vorschau, bis
|
||
das Abspielgeraet die wahre Laenge kennt. Begrenzt wird sie
|
||
trotzdem: Ohne Obergrenze stuende in der Gespraechsliste
|
||
„Sprachnachricht (999:59)", und die Liste saehe kaputt aus. */
|
||
let dauer = null;
|
||
if (erkannt.art === "ton") {
|
||
const roh = Number.parseInt(req.get("x-dauer") || "", 10);
|
||
dauer = Number.isFinite(roh) && roh > 0 ? Math.min(roh, 60 * 60 * 1000) : null;
|
||
}
|
||
|
||
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" });
|
||
|
||
/* ==== DIE ZWEITE FASSUNG (03.10.2026) =======================
|
||
|
||
Miss im Support, Meldung #12, fuenf Runden: „Bei mir laesst
|
||
sich die Nachricht nicht abspielen." Bei allen anderen ging
|
||
es; alle sechs vorhandenen Sprachnachrichten waren WebM.
|
||
|
||
NEBENHER UND NICHT DAVOR. `await` hier hiesse: Wer eine
|
||
Sprachnachricht schickt, wartet auf ffmpeg, bevor sie im
|
||
Chat steht -- und wenn ffmpeg haengt, haengt die Nachricht.
|
||
Sie geht sofort raus; die zweite Fassung kommt Sekunden
|
||
spaeter dazu, und bis dahin spielt das Original. Genau
|
||
deshalb sagt die Antwort unten auch nichts ueber sie: Was
|
||
es noch nicht gibt, wird nicht versprochen.
|
||
|
||
NUR FUER TON. Ein Bild braucht keine zweite Fassung, und
|
||
ffmpeg auf ein PDF loszulassen waere eine Tuer, nach der
|
||
niemand gefragt hat. */
|
||
if (erkannt.art === "ton" && brauchtZweitfassung(aufPlatte)) {
|
||
zweitfassungBauen(join(ANHANG_ORDNER, aufPlatte))
|
||
.catch(() => { /* der Helfer meldet es selbst; die Nachricht
|
||
steht ohnehin schon */ });
|
||
}
|
||
|
||
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, anhang_dauer)
|
||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||
.run(raumId, req.person.id, begleittext, n,
|
||
aufPlatte, name, erkannt.art, erkannt.typ,
|
||
req.body.length, erkannt.breite || null, erkannt.hoehe || null, dauer);
|
||
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,
|
||
dauer: dauer || 0,
|
||
weg: `/workspace/api/chat/anhang/${id}`,
|
||
/* Gleich nach dem Hochladen gibt es sie noch nicht -- die
|
||
Umwandlung laeuft nebenher. Dass hier `null` steht, ist
|
||
deshalb richtig und keine Luecke: Beim naechsten Laden
|
||
der Nachrichten ist sie da. */
|
||
weg_auch: zweitfassungDa(erkannt.art, aufPlatte)
|
||
? `/workspace/api/chat/anhang/${id}?form=mp4` : null,
|
||
},
|
||
rudel: false,
|
||
};
|
||
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" });
|
||
}
|
||
});
|
||
|
||
/* =====================================================================
|
||
DIE GIF-KISTE (23.09.2026)
|
||
|
||
Filipe: „dan will ich auch dass man im chat auch sprachnachrichten
|
||
und gifts reinschicken kann. also nur die modis rechte linke hand
|
||
und dogfather."
|
||
|
||
ICH LESE „gifts" ALS GIFs -- die bewegten Bildchen -- und sage das
|
||
hier ausdruecklich, statt es stillschweigend anzunehmen. Im
|
||
Zusammenhang („im chat reinschicken", direkt neben Sprachnachrichten)
|
||
passt nichts anderes; sollte er Geschenke gemeint haben, steht das
|
||
hier als Stelle, an der man es sieht.
|
||
|
||
WARUM EINE EIGENE KISTE UND KEIN GIPHY: Die Begruendung steht bei
|
||
der Tabelle in workspace.js -- kurz: fremder Schluessel, fremde
|
||
Firma, jede Suche geht hinaus, und ab einer Menge kostet es. Die
|
||
Kiste laeuft hier, kostet nichts und wird mit der Zeit besser:
|
||
Was darin liegt, hat das Team ausgesucht.
|
||
|
||
DIE VIER GELTEN AUCH HIER. „also nur die modis rechte linke hand und
|
||
dogfather" stand im selben Satz wie die Sprachnachrichten; es gibt
|
||
keinen Grund, warum es fuer das eine gelten sollte und fuer das
|
||
andere nicht.
|
||
===================================================================== */
|
||
|
||
/** Vor jeder GIF-Route: Gehoere ich dazu? */
|
||
function nurRudel(req, res, next) {
|
||
if (!istTeamDogi(req.person)) {
|
||
return res.status(403).json({ fehler: "Die GIF-Kiste gehört Team Dogi." });
|
||
}
|
||
next();
|
||
}
|
||
|
||
chatRouter.get("/workspace/api/chat/gifs", nurRudel, (req, res) => {
|
||
try {
|
||
res.json({ gifs: gifListe(), hoechstens: GIF_MAX });
|
||
} catch (fehler) {
|
||
console.error("[chat] GIF-Liste:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/** Ein GIF in die Kiste legen. */
|
||
chatRouter.post("/workspace/api/chat/gifs", gleicheHerkunft, nurRudel,
|
||
express.raw({ type: "*/*", limit: GIF_MAX }), (req, res) => {
|
||
try {
|
||
if (!Buffer.isBuffer(req.body) || !req.body.length) {
|
||
return res.status(400).json({ fehler: "Keine Datei empfangen." });
|
||
}
|
||
/* AUS DEM INHALT, nicht aus dem Namen und nicht aus dem
|
||
Content-Type -- beide kommen vom Absender. */
|
||
const erkannt = dateiErkennen(req.body);
|
||
if (!erkannt || erkannt.typ !== "image/gif") {
|
||
return res.status(415).json({ fehler: "In die Kiste kommen nur GIFs." });
|
||
}
|
||
|
||
/* HABEN WIR DAS SCHON? Beantwortet am INHALT, nicht am Namen.
|
||
Ueber den Namen zu vergleichen waere die naheliegende
|
||
Abkuerzung -- und sie versagt genau dort, wo Dateien
|
||
"giphy.gif", "giphy(1).gif" und "download.gif" heissen, also
|
||
fast immer. Dasselbe GIF waere dreimal dieselbe Kachel. */
|
||
const pruef = createHash("sha256").update(req.body).digest("hex");
|
||
const d = db();
|
||
const schon = d.prepare("SELECT id FROM chat_gifs WHERE pruef = ?").get(pruef);
|
||
if (schon) {
|
||
/* KEIN FEHLER. Wer dasselbe GIF noch einmal hineinlegt, wollte
|
||
es in der Kiste haben -- und es ist drin. Eine Absage waere
|
||
formal richtig und im Erleben falsch. */
|
||
return res.status(200).json({ gif: gifListe().find((g) => g.id === schon.id) || null,
|
||
schon: true });
|
||
}
|
||
|
||
const name = anhangName(req.get("x-dateiname"), ".gif").replace(/\.gif$/i, "") || "GIF";
|
||
mkdirSync(GIF_ORDNER, { recursive: true });
|
||
const aufPlatte = `${Date.now().toString(36)}-${randomBytes(8).toString("hex")}.gif`;
|
||
writeFileSync(join(GIF_ORDNER, aufPlatte), req.body, { flag: "wx" });
|
||
|
||
const n = jetzt();
|
||
d.prepare(`INSERT INTO chat_gifs
|
||
(datei, name, pruef, typ, groesse, breite, hoehe, von_id, erstellt)
|
||
VALUES (?,?,?,?,?,?,?,?,?)`)
|
||
.run(aufPlatte, name.slice(0, 60), pruef, erkannt.typ, req.body.length,
|
||
erkannt.breite || null, erkannt.hoehe || null, req.person.id, n);
|
||
const id = d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||
|
||
protokolliere("chat_gif_dazu", {
|
||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||
detail: `#${id} ${name} ${Math.round(req.body.length / 1024)} kB`.slice(0, 120),
|
||
});
|
||
res.status(201).json({ gif: gifListe().find((g) => g.id === id) || null, schon: false });
|
||
} catch (fehler) {
|
||
console.error("[chat] GIF hineinlegen:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/** Ein GIF aus der Kiste ansehen. */
|
||
chatRouter.get("/workspace/api/chat/gifs/:id/bild", nurRudel, (req, res) => {
|
||
try {
|
||
const id = Number(req.params.id);
|
||
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
|
||
const g = db().prepare("SELECT datei, name, typ FROM chat_gifs WHERE id = ?").get(id);
|
||
if (!g) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
const pfad = join(GIF_ORDNER, g.datei);
|
||
try { statSync(pfad); } catch { return res.status(410).json({ fehler: "Datei fehlt auf der Platte." }); }
|
||
/* Dieselben Kopfzeilen wie beim Anhang -- der Typ aus der
|
||
Erkennung, nosniff, und eine Richtlinie, die dem Dokument jede
|
||
Ausfuehrung nimmt. Ein GIF kostet das nichts. */
|
||
res.setHeader("Content-Type", g.typ || "image/gif");
|
||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||
res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
|
||
res.setHeader("Cache-Control", "private, max-age=86400");
|
||
liefereDatei(req, res, pfad);
|
||
} catch (fehler) {
|
||
console.error("[chat] GIF ausliefern:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/** Ein GIF wieder aus der Kiste nehmen.
|
||
*
|
||
* WER: der, der es hineingelegt hat -- und wer ohnehin jede Nachricht
|
||
* wegnehmen darf (DogFather und die rechte Hand). Die Kiste gehoert
|
||
* allen; ein GIF, das nicht mehr passt, muss jemand herausnehmen
|
||
* koennen, ohne den zu suchen, der es damals gebracht hat.
|
||
*
|
||
* DIE DATEI GEHT MIT. Anders als bei einer Nachricht gibt es hier
|
||
* keinen Verlauf, den man lesbar halten muesste -- was aus der Kiste
|
||
* ist, ist aus der Kiste. Bereits verschickte GIFs bleiben
|
||
* unberuehrt: Sie sind Kopien (siehe oben). */
|
||
chatRouter.delete("/workspace/api/chat/gifs/:id", gleicheHerkunft, nurRudel, (req, res) => {
|
||
try {
|
||
const id = Number(req.params.id);
|
||
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
|
||
const d = db();
|
||
const g = d.prepare("SELECT id, datei, name, von_id FROM chat_gifs WHERE id = ?").get(id);
|
||
if (!g) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
if (g.von_id !== req.person.id && !darfJedeNachrichtLoeschen(req.person)) {
|
||
return res.status(403).json({ fehler: "Das hat jemand anderes hineingelegt." });
|
||
}
|
||
const weg = d.prepare("DELETE FROM chat_gifs WHERE id = ?").run(id).changes;
|
||
if (weg) {
|
||
try { unlinkSync(join(GIF_ORDNER, g.datei)); } catch { /* war schon weg */ }
|
||
}
|
||
protokolliere("chat_gif_weg", {
|
||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||
detail: `#${id} ${g.name}`.slice(0, 120),
|
||
});
|
||
res.json({ weg: weg > 0, gifs: gifListe() });
|
||
} catch (fehler) {
|
||
console.error("[chat] GIF herausnehmen:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/** Ein GIF aus der Kiste in ein Gespraech schicken.
|
||
*
|
||
* ES WIRD KOPIERT, NICHT VERWIESEN -- die Begruendung steht oben bei
|
||
* GIF_ORDNER: Eine geloeschte Nachricht nimmt ihre Datei mit, und ein
|
||
* gemeinsamer Verweis waere ein kaputtes Bild in drei Gespraechen.
|
||
*
|
||
* DANACH IST ES EIN GANZ NORMALER ANHANG. Keine zweite Sorte
|
||
* Nachricht: Es steht im Verlauf, es laesst sich zuruecknehmen, es
|
||
* faellt unter dieselbe Nachtruhe. Eine eigene Sorte haette eigene
|
||
* Rechte, eigene Suche, eigenes Loeschen -- drei Dinge, die es schon
|
||
* gibt. */
|
||
chatRouter.post("/workspace/api/chat/raeume/:id/gif", gleicheHerkunft, nurRudel,
|
||
express.json({ limit: "2kb" }), (req, res) => {
|
||
try {
|
||
const raumId = Number(req.params.id);
|
||
if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" });
|
||
if (!istDrin(raumId, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
/* DIE NACHTRUHE GILT AUCH HIER. Sie nur beim Text und beim
|
||
Anhang zu pruefen hiesse, dass man nachts zwar nicht schreiben,
|
||
aber ein GIF schicken kann -- der Weg, den jeder findet, der
|
||
es einmal versucht. */
|
||
if (istTreffRaum(raumId) && istNachtruhe()) {
|
||
const stand = nachtruheStand();
|
||
return res.status(403).json({ fehler: stand.satz, nachtruhe: stand });
|
||
}
|
||
|
||
const gifId = Number(req.body?.gif_id);
|
||
const d = db();
|
||
const g = Number.isInteger(gifId)
|
||
? d.prepare("SELECT id, datei, name, typ, groesse, breite, hoehe FROM chat_gifs WHERE id = ?")
|
||
.get(gifId) : null;
|
||
if (!g) return res.status(404).json({ fehler: "Dieses GIF gibt es nicht mehr." });
|
||
|
||
const quelle = join(GIF_ORDNER, g.datei);
|
||
try { statSync(quelle); } catch {
|
||
return res.status(410).json({ fehler: "Die Datei fehlt auf der Platte." });
|
||
}
|
||
mkdirSync(ANHANG_ORDNER, { recursive: true });
|
||
const aufPlatte = `${Date.now().toString(36)}-${randomBytes(8).toString("hex")}.gif`;
|
||
copyFileSync(quelle, join(ANHANG_ORDNER, aufPlatte));
|
||
|
||
let begleittext = String(req.body?.text ?? "").trim().slice(0, TEXT_MAX);
|
||
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, `${g.name}.gif`, "bild", g.typ,
|
||
g.groesse, g.breite || null, g.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,
|
||
von_bild: bildWegFuer(req.person.id),
|
||
von_kachel: kachelFuer(req.person.id),
|
||
rolle: req.person.rolle, text: begleittext, erstellt: n,
|
||
zurueckgenommen: false, antwort: null, rudel: false,
|
||
anhang: {
|
||
art: "bild", typ: g.typ, name: `${g.name}.gif`,
|
||
groesse: g.groesse, breite: g.breite || 0, hoehe: g.hoehe || 0,
|
||
dauer: 0,
|
||
weg: `/workspace/api/chat/anhang/${id}`,
|
||
/* EIN GIF HAT KEINE ZWEITE FASSUNG, und zwar nie -- das ist
|
||
hier ein Bild, kein Ton. Die Zeile steht trotzdem da:
|
||
Faehlte sie, haette dieselbe Nachricht je nach Weg einmal
|
||
das Feld und einmal nicht, und der Browser muesste beide
|
||
Faelle kennen. Ein ausdrueckliches `null` ist eine
|
||
Auskunft, ein fehlendes Feld ist eine Frage.
|
||
(Mein erster Anlauf rief hier `zweitfassungDa(erkannt...)`
|
||
auf -- `erkannt` gibt es in dieser Route gar nicht. Das
|
||
waere ein Absturz beim ersten GIF gewesen, und zwar erst
|
||
im Betrieb.) */
|
||
weg_auch: null,
|
||
},
|
||
};
|
||
const empfaenger = teilnehmerVon(raumId).filter((t) => t.id !== req.person.id);
|
||
chatEreignis(raumId, nachricht, empfaenger.map((t) => t.id));
|
||
|
||
protokolliere("chat_gif", {
|
||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||
detail: `#${id} aus Kiste #${g.id}`.slice(0, 120),
|
||
});
|
||
res.status(201).json({ nachricht });
|
||
} catch (fehler) {
|
||
console.error("[chat] GIF schicken:", 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" });
|
||
}
|
||
|
||
/* ==== DIE ZWEITE FASSUNG, WENN SIE VERLANGT WIRD (03.10.2026)
|
||
|
||
`?form=mp4` liefert die AAC-Fassung derselben Sprachnachricht.
|
||
Miss im Support, #12: Auf ihrem iPhone laesst sich WebM nicht
|
||
abspielen; dieselbe Nachricht in MP4 schon.
|
||
|
||
EINE ROUTE, KEINE ZWEITE. Es ist dieselbe Nachricht mit
|
||
denselben Rechten -- wer sie sehen darf, darf sie in beiden
|
||
Fassungen sehen. Eine eigene Route haette dieselbe
|
||
Sichtbarkeitspruefung ein zweites Mal gebraucht, und es ist
|
||
immer die zweite, die beim naechsten Umbau vergessen wird.
|
||
|
||
STILL ZURUECK AUF DAS ORIGINAL, wenn es die Fassung nicht
|
||
gibt: Wer sie anfragt, hat sie in der Nachrichtenliste
|
||
angeboten bekommen -- ist sie seitdem verschwunden, soll er
|
||
etwas hoeren und keinen Fehler lesen. */
|
||
let pfad = join(ANHANG_ORDNER, n.anhang_datei);
|
||
let typ = n.anhang_typ || "application/octet-stream";
|
||
if (req.query.form === "mp4" && n.anhang_art === "ton"
|
||
&& brauchtZweitfassung(n.anhang_datei)) {
|
||
const zweit = zweitfassungPfad(pfad);
|
||
try { statSync(zweit); pfad = zweit; typ = ZWEITFASSUNG_TYP; } catch { /* Original */ }
|
||
}
|
||
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", typ);
|
||
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")}`);
|
||
liefereDatei(req, res, pfad);
|
||
} catch (fehler) {
|
||
console.error("[chat] Anhang ausliefern:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/* =====================================================================
|
||
EIN GESPRÄCH WEGRÄUMEN — NUR BEI MIR (09.09.2026)
|
||
|
||
Filipe: "man muss die chats auch gelöscht bekommen!!!"
|
||
Auf Nachfrage: nur bei mir.
|
||
|
||
Was hier NICHT passiert: Es wird nichts aus der Datenbank entfernt.
|
||
Das Gegenüber behält seinen Verlauf vollständig -- niemand kann
|
||
einem anderen die Unterhaltung wegnehmen. Und es wird auch nicht
|
||
die Mitgliedschaft beendet: Schreibt der andere morgen wieder,
|
||
kommt das Gespräch mit dem NEUEN zurück, das Alte bleibt weg.
|
||
Genau so verhält sich jeder Messenger, den Filipe kennt.
|
||
|
||
`gelesen_bis` wandert mit. Ohne das käme das Gespräch sofort wieder
|
||
als "ungelesen" hoch -- weggeräumt und trotzdem eine rote Zahl in
|
||
der Kopfleiste wäre schlimmer als gar nicht löschen.
|
||
===================================================================== */
|
||
chatRouter.delete("/workspace/api/chat/raeume/:id", gleicheHerkunft, (req, res) => {
|
||
try {
|
||
const raumId = Number(req.params.id);
|
||
if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" });
|
||
if (!istDrin(raumId, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
|
||
const d = db();
|
||
const bis = d.prepare("SELECT COALESCE(MAX(id), 0) AS m FROM chat_nachrichten WHERE raum_id = ?")
|
||
.get(raumId).m;
|
||
d.prepare(`UPDATE chat_teilnehmer
|
||
SET geloescht_bis = ?, geloescht_am = ?,
|
||
gelesen_bis = CASE WHEN gelesen_bis < ? THEN ? ELSE gelesen_bis END
|
||
WHERE raum_id = ? AND person_id = ?`)
|
||
.run(bis, jetzt(), bis, bis, raumId, req.person.id);
|
||
|
||
protokolliere("chat_weggeraeumt", {
|
||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||
detail: `Raum #${raumId} bis #${bis}`,
|
||
});
|
||
res.json({ ok: true, bis });
|
||
} catch (fehler) {
|
||
console.error("[chat] Wegräumen:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/** Darf diese Person diesen Raum fuer ALLE aufloesen?
|
||
*
|
||
* EINE REGEL, EINE STELLE (11.09.2026). Sie wurde zuerst in der
|
||
* Raumliste ausgerechnet -- und der Knopf blieb trotzdem unsichtbar,
|
||
* weil die Oberflaeche den offenen Raum aus dem Nachrichten-Weg
|
||
* bezieht, nicht aus der Liste. Zwei Wege, ein Raumobjekt, und nur
|
||
* einer kannte das Feld.
|
||
*
|
||
* Gefunden hat das nicht das Lesen, sondern eine Pruefung, die den
|
||
* KNOPF gemessen hat statt des Rechts dahinter.
|
||
*
|
||
* `erstellt_von` muss im uebergebenen Raum stehen -- wer die Spalte
|
||
* im SELECT vergisst, bekommt `undefined` und damit `false`. Das ist
|
||
* die sichere Richtung: kein Knopf statt eines falschen.
|
||
*/
|
||
function darfAufloesen(raum, person) {
|
||
return !!raum && raum.art !== "direkt"
|
||
&& !!raum.erstellt_von && raum.erstellt_von === person.id;
|
||
}
|
||
|
||
/* =====================================================================
|
||
REAKTIONEN — auf die Nachricht, ohne zu antworten (14.09.2026)
|
||
|
||
Filipe: "ich will dass man auf die nachrichten auch reagieren kann
|
||
mit einem emoji, die nachricht selbst ohne zu antworten."
|
||
|
||
EINE FESTE AUSWAHL, KEIN FREIES FELD. Sechs Zeichen, hier
|
||
aufgeschrieben. Drei Gruende, und der erste ist der wichtigste:
|
||
|
||
* Eine feste Liste laesst sich pruefen. Was hier nicht drinsteht,
|
||
kommt nicht in die Datenbank -- kein beliebiger Text, keine
|
||
Zeichenketten von 4 KB, keine unsichtbaren Steuerzeichen.
|
||
* Alle sehen dasselbe. Ein selbst getipptes Zeichen sieht auf
|
||
einem anderen Geraet anders aus oder gar nicht.
|
||
* Sechs Zeichen kann man ueberfliegen. Eine volle Emoji-Tafel ist
|
||
eine Suchaufgabe, und dann tippt man doch wieder "ok".
|
||
|
||
DIE LISTE STEHT AUF DEM SERVER und wird an die Oberflaeche geliefert
|
||
(/reaktionen/auswahl). Stuende sie im Browser, gaebe es sie zweimal
|
||
-- und die zweite Fassung waere die, in der ein Zeichen fehlt, das
|
||
der Server noch annimmt.
|
||
===================================================================== */
|
||
/* Die drei Listen stehen in workspace-reaktionen.js -- Katalog,
|
||
Vorschlag, erlaubte Zeichen. Hier stehen nur die Wege dorthin. */
|
||
|
||
/** Die Favoriten EINES Menschen, in seiner Reihenfolge. */
|
||
function favoritenVon(personId) {
|
||
return db().prepare(
|
||
"SELECT zeichen FROM chat_reaktion_favoriten WHERE person_id = ? ORDER BY platz, zeichen")
|
||
.all(personId).map((z) => z.zeichen)
|
||
/* Was nicht mehr im Katalog steht, faellt still weg -- aber nur
|
||
beim LESEN. Die Zeile bleibt stehen; kaeme das Zeichen zurueck,
|
||
ist der Favorit wieder da. Ein Loeschen waere eine Entscheidung
|
||
ueber fremde Daten wegen einer Katalogaenderung. */
|
||
.filter((z) => ERLAUBT.has(z));
|
||
}
|
||
|
||
/* WAS DIE OBERFLAECHE BRAUCHT, IN EINER ANTWORT: die Vorschlagszeile,
|
||
den ganzen Katalog mit Suchwoertern und die eigenen Favoriten.
|
||
|
||
Drei Abrufe daraus zu machen waere sparsamer aussehend und in
|
||
Wahrheit teurer: Die Tafel kann erst erscheinen, wenn alle drei da
|
||
sind. */
|
||
chatRouter.get("/workspace/api/chat/reaktionen/auswahl", (req, res) => {
|
||
try {
|
||
const favoriten = favoritenVon(req.person.id);
|
||
res.json({
|
||
/* Die Vorschlagszeile sind die FAVORITEN, sobald es welche gibt
|
||
-- sonst die Vorgabe. Beides an einer Stelle entschieden,
|
||
damit die Oberflaeche nicht selbst waehlen muss. */
|
||
zeichen: favoriten.length ? favoriten : VORSCHLAG,
|
||
eigene: favoriten.length > 0,
|
||
favoriten,
|
||
favoriten_max: FAVORITEN_MAX,
|
||
katalog: KATALOG,
|
||
});
|
||
} catch (fehler) {
|
||
console.error("[chat] Auswahl:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/* EINEN FAVORITEN SETZEN ODER WEGNEHMEN -- wieder ein Umschalter,
|
||
wie das Reagieren selbst. Ein eigener Weg zum Entfernen waere ein
|
||
zweiter Weg, den man absichern muss. */
|
||
chatRouter.put("/workspace/api/chat/reaktionen/favorit", gleicheHerkunft,
|
||
express.json({ limit: "2kb" }), (req, res) => {
|
||
try {
|
||
const zeichen = String(req.body?.zeichen ?? "");
|
||
if (!ERLAUBT.has(zeichen)) return res.status(400).json({ fehler: "unbekanntes_zeichen" });
|
||
|
||
const d = db();
|
||
const da = d.prepare(
|
||
"SELECT 1 FROM chat_reaktion_favoriten WHERE person_id = ? AND zeichen = ?")
|
||
.get(req.person.id, zeichen);
|
||
|
||
if (da) {
|
||
d.prepare("DELETE FROM chat_reaktion_favoriten WHERE person_id = ? AND zeichen = ?")
|
||
.run(req.person.id, zeichen);
|
||
} else {
|
||
const wieViele = d.prepare(
|
||
"SELECT COUNT(*) AS n FROM chat_reaktion_favoriten WHERE person_id = ?")
|
||
.get(req.person.id).n;
|
||
if (wieViele >= FAVORITEN_MAX) {
|
||
/* NICHT STILL DEN AELTESTEN WEGWERFEN. Wer neun ablegen will,
|
||
soll erfahren, dass acht das Maximum sind -- und selbst
|
||
entscheiden, welcher geht. Ein Favorit, der ohne Ansage
|
||
verschwindet, sieht wie ein Fehler aus. */
|
||
return res.status(409).json({
|
||
fehler: `Mehr als ${FAVORITEN_MAX} Favoriten passen nicht in die Zeile. `
|
||
+ "Nimm erst einen weg.",
|
||
favoriten: favoritenVon(req.person.id),
|
||
});
|
||
}
|
||
/* Ans ENDE, nicht an den Anfang: Die vorhandenen sollen nicht
|
||
wandern -- man greift sonst daneben. */
|
||
const platz = (d.prepare(
|
||
"SELECT COALESCE(MAX(platz), 0) AS p FROM chat_reaktion_favoriten WHERE person_id = ?")
|
||
.get(req.person.id).p) + 1;
|
||
d.prepare(`INSERT INTO chat_reaktion_favoriten (person_id, zeichen, platz)
|
||
VALUES (?,?,?)`).run(req.person.id, zeichen, platz);
|
||
}
|
||
|
||
const favoriten = favoritenVon(req.person.id);
|
||
res.json({ favoriten, gesetzt: !da,
|
||
zeichen: favoriten.length ? favoriten : VORSCHLAG });
|
||
} catch (fehler) {
|
||
console.error("[chat] Favorit:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/** Alle Reaktionen zu einer Menge von Nachrichten/** Alle Reaktionen zu einer Menge von Nachrichten -- in EINER Abfrage.
|
||
*
|
||
* Je Nachricht einzeln zu fragen waere bei 200 Nachrichten 200
|
||
* Abfragen fuer eine Ansicht. Zurueck kommt eine Karte:
|
||
* nachricht_id -> [{ zeichen, anzahl, selbst, wer }].
|
||
*
|
||
* `wer` ist bewusst dabei: "drei Daumen" sagt weniger als "Ayla, Ben
|
||
* und Cem". Es sind Namen aus DEMSELBEN Raum -- wer die Nachricht
|
||
* sieht, sieht die Leute ohnehin.
|
||
*/
|
||
function reaktionenZu(ids, personId) {
|
||
const karte = new Map();
|
||
if (!ids.length) return karte;
|
||
const platz = ids.map(() => "?").join(",");
|
||
const reihen = db().prepare(`
|
||
SELECT r.nachricht_id, r.zeichen, r.person_id, p.name
|
||
FROM chat_reaktionen r
|
||
LEFT JOIN personen p ON p.id = r.person_id
|
||
WHERE r.nachricht_id IN (${platz})
|
||
ORDER BY r.wann`).all(...ids);
|
||
for (const z of reihen) {
|
||
if (!karte.has(z.nachricht_id)) karte.set(z.nachricht_id, new Map());
|
||
const proNachricht = karte.get(z.nachricht_id);
|
||
if (!proNachricht.has(z.zeichen)) {
|
||
proNachricht.set(z.zeichen, { zeichen: z.zeichen, anzahl: 0, selbst: false, wer: [] });
|
||
}
|
||
const eintrag = proNachricht.get(z.zeichen);
|
||
eintrag.anzahl++;
|
||
if (z.person_id === personId) eintrag.selbst = true;
|
||
eintrag.wer.push(z.name || "Gelöscht");
|
||
}
|
||
const fertig = new Map();
|
||
for (const [id, proNachricht] of karte) fertig.set(id, [...proNachricht.values()]);
|
||
return fertig;
|
||
}
|
||
|
||
/* REAGIEREN IST EIN UMSCHALTER, kein Hinzufuegen.
|
||
Zweimal dasselbe Zeichen heisst: wieder weg. Das erspart einen
|
||
zweiten Weg zum Zuruecknehmen -- und einen zweiten Weg, den man
|
||
vergisst abzusichern. */
|
||
/* ==== DIE NACHTRUHE GILT HIER ABSICHTLICH NICHT (19.09.2026) ========
|
||
|
||
Eine Reaktion ist keine Nachricht. Sie sagt nichts, was jemand
|
||
beantworten muesste, sie kann keinen Streit anfangen, und sie loest
|
||
keine Meldung aus (siehe die Begruendung weiter unten: „Ein Daumen
|
||
ist keine Nachricht").
|
||
|
||
Filipes Ansage war „keiner schreiben kann". Ein Daumen ist kein
|
||
Schreiben -- und genau darum geht es bei der Nachtruhe: Sie soll den
|
||
Druck wegnehmen, nachts antworten zu muessen, nicht den Raum
|
||
abschliessen.
|
||
|
||
DAS STEHT HIER, damit es nicht wie ein Versehen aussieht. Eine
|
||
Luecke, die niemand begruendet hat, wird beim naechsten Durchsehen
|
||
entweder geschlossen oder uebersehen -- beides falsch. */
|
||
chatRouter.put("/workspace/api/chat/nachrichten/:id/reaktion", gleicheHerkunft,
|
||
express.json({ limit: "2kb" }), (req, res) => {
|
||
try {
|
||
const id = Number(req.params.id);
|
||
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
|
||
const zeichen = String(req.body?.zeichen ?? "");
|
||
if (!ERLAUBT.has(zeichen)) {
|
||
return res.status(400).json({ fehler: "unbekanntes_zeichen" });
|
||
}
|
||
|
||
const d = db();
|
||
const n = d.prepare(
|
||
"SELECT id, raum_id, weg_am FROM chat_nachrichten WHERE id = ?").get(id);
|
||
if (!n) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
/* Die Raumzugehoerigkeit ist die ganze Sicherheit dieses Wegs --
|
||
dieselbe Pruefung wie beim Lesen des Verlaufs. */
|
||
if (!istDrin(n.raum_id, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
/* Auf eine zurueckgenommene Nachricht wird nicht reagiert. Dort
|
||
steht nichts mehr, worauf man reagieren koennte. */
|
||
if (n.weg_am) return res.status(409).json({ fehler: "zurueckgenommen" });
|
||
|
||
const da = d.prepare(`SELECT 1 FROM chat_reaktionen
|
||
WHERE nachricht_id = ? AND person_id = ? AND zeichen = ?`)
|
||
.get(id, req.person.id, zeichen);
|
||
if (da) {
|
||
d.prepare(`DELETE FROM chat_reaktionen
|
||
WHERE nachricht_id = ? AND person_id = ? AND zeichen = ?`)
|
||
.run(id, req.person.id, zeichen);
|
||
} else {
|
||
d.prepare(`INSERT INTO chat_reaktionen (nachricht_id, person_id, zeichen, wann)
|
||
VALUES (?,?,?,?)`).run(id, req.person.id, zeichen, jetzt());
|
||
}
|
||
|
||
const stand = reaktionenZu([id], req.person.id).get(id) || [];
|
||
/* ALLE IM RAUM SEHEN ES SOFORT -- ohne Benachrichtigung aufs
|
||
Handy. Ein Daumen ist keine Nachricht; wer dafuer eine Meldung
|
||
bekaeme, schaltet Meldungen ab. */
|
||
chatEreignis(n.raum_id, { id, raum_id: n.raum_id, reaktionen: stand }, [], "reaktion");
|
||
|
||
res.json({ id, reaktionen: stand, gesetzt: !da });
|
||
} catch (fehler) {
|
||
console.error("[chat] Reaktion:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/* =====================================================================
|
||
EINE GRUPPE ODER EINEN KANAL AUFLOESEN — FUER ALLE (11.09.2026)
|
||
|
||
Filipe: "wenn ein gruppenchat oder kanal geoeffnet wird, die person
|
||
die ihn oeffnet soll auch das recht haben das zu loeschen und so
|
||
dass es dan fuer jeden geloescht ist. aber nur die person die es
|
||
gruendet."
|
||
|
||
DAS IST ETWAS ANDERES ALS DER WEG DARUEBER, und die beiden duerfen
|
||
nie verwechselt werden:
|
||
|
||
Wegraeumen -> nur bei mir. Die anderen behalten alles.
|
||
Aufloesen -> fuer alle. Der Raum ist weg, endgueltig.
|
||
|
||
Deshalb ein eigener Weg (/ganz) und nicht ein Zusatzfeld am
|
||
bestehenden: Ein vergessenes Feld waere hier der Unterschied
|
||
zwischen "bei mir ausgeblendet" und "bei allen geloescht".
|
||
|
||
NUR DER GRUENDER, und zwar woertlich. Nicht die Teamleitung, nicht
|
||
DogFather, nicht wer `leitung` in der Gruppe hat. `erstellt_von`
|
||
steht seit jeher in der Tabelle und ist die einzige Angabe, die
|
||
"wer hat das aufgemacht" beantwortet.
|
||
|
||
NICHT BEI ZWEIER-GESPRAECHEN. Dort gibt es keinen Gruender, nur
|
||
zwei Beteiligte -- und "niemand kann einem anderen die Unterhaltung
|
||
wegnehmen" ist die Regel von 09.09., die hier gilt.
|
||
|
||
DIE OFFENE STELLE, damit sie niemand suchen muss: Wird das Konto
|
||
des Gruenders geloescht, wird `erstellt_von` auf NULL gesetzt
|
||
(ON DELETE SET NULL) -- dann kann NIEMAND mehr aufloesen. Das ist
|
||
die ehrliche Folge von "nur der Gruender"; wer das aendern will,
|
||
muss eine zweite Person benennen, und das ist eine Entscheidung,
|
||
keine Reparatur.
|
||
|
||
REIHENFOLGE, und sie ist nicht beliebig:
|
||
1. Das Ereignis geht RAUS, solange es die Teilnehmer noch gibt --
|
||
chatEreignis liest sie aus der Tabelle. Danach waere die Liste
|
||
leer und niemand erfuehre, dass der Raum weg ist.
|
||
2. Die Zeilen fallen in EINER Transaktion.
|
||
3. Die Anhaenge auf der Platte erst NACH dem Commit. Andersherum
|
||
haetten wir bei einem Ruecklauf Nachrichten, die auf Dateien
|
||
zeigen, die es nicht mehr gibt.
|
||
===================================================================== */
|
||
chatRouter.delete("/workspace/api/chat/raeume/:id/ganz", gleicheHerkunft, (req, res) => {
|
||
try {
|
||
const raumId = Number(req.params.id);
|
||
if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" });
|
||
if (!istDrin(raumId, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
|
||
const d = db();
|
||
const raum = d.prepare(
|
||
"SELECT id, art, name, kategorie, erstellt_von FROM chat_raeume WHERE id = ?").get(raumId);
|
||
if (!raum) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
if (raum.art === "direkt") return res.status(403).json({ fehler: "nur_gruppe_oder_kanal" });
|
||
/* Dieselbe Regel wie fuer den Knopf -- nicht "auch so eine". Waere
|
||
hier eine zweite Bedingung ausgeschrieben, koennte der Knopf
|
||
eines Tages erscheinen, wo der Weg ablehnt, oder umgekehrt. */
|
||
if (!darfAufloesen(raum, req.person)) {
|
||
return res.status(403).json({ fehler: "nur_gruender" });
|
||
}
|
||
|
||
const dateien = d.prepare(
|
||
`SELECT anhang_datei FROM chat_nachrichten
|
||
WHERE raum_id = ? AND anhang_datei IS NOT NULL`)
|
||
.all(raumId).map((z) => z.anhang_datei);
|
||
const wieViele = d.prepare(
|
||
"SELECT COUNT(*) AS n FROM chat_nachrichten WHERE raum_id = ?").get(raumId).n;
|
||
const leute = teilnehmerVon(raumId).map((t) => t.id);
|
||
|
||
/* Schritt 1: erst sagen, dann loeschen (siehe oben). */
|
||
chatEreignis(raumId, { raum_id: raumId, aufgeloest: true }, [], "raum-weg");
|
||
|
||
/* Schritt 2: alles in einem Zug. Ein halb aufgeloester Raum --
|
||
Nachrichten weg, Raum noch da -- waere in jeder Liste ein
|
||
leerer Eintrag, den niemand mehr loswird. */
|
||
d.exec("BEGIN");
|
||
try {
|
||
d.prepare("DELETE FROM chat_nachrichten WHERE raum_id = ?").run(raumId);
|
||
d.prepare("DELETE FROM chat_teilnehmer WHERE raum_id = ?").run(raumId);
|
||
d.prepare("DELETE FROM chat_raeume WHERE id = ?").run(raumId);
|
||
d.exec("COMMIT");
|
||
} catch (fehler) {
|
||
try { d.exec("ROLLBACK"); } catch { /* schon zurueck */ }
|
||
throw fehler;
|
||
}
|
||
|
||
/* Schritt 3: die Anhaenge. Was hier liegen bleibt, ist Muell --
|
||
aber kein Datenschaden, und es wird gemeldet statt verschwiegen. */
|
||
let dateienWeg = 0;
|
||
for (const name of dateien) {
|
||
if (anhangWeg(name)) dateienWeg++;
|
||
}
|
||
|
||
protokolliere("chat_aufgeloest", {
|
||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||
detail: `${raum.art} #${raumId} "${raum.name || raum.kategorie || ""}": `
|
||
+ `${wieViele} Nachrichten, ${leute.length} Beteiligte, `
|
||
+ `${dateienWeg}/${dateien.length} Anhaenge`.slice(0, 200),
|
||
});
|
||
res.json({ ok: true, nachrichten: wieViele, beteiligte: leute.length,
|
||
anhaenge: dateien.length });
|
||
} catch (fehler) {
|
||
console.error("[chat] Aufloesen:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/* =====================================================================
|
||
SUCHE (09.09.2026, Punkt 15)
|
||
|
||
Filipe: "ich will dass du diese seite viel krasser und detaillierter
|
||
machst ... alles reinsetzt was wir noch gebrauchen koennten."
|
||
|
||
Ein Chat ohne Suche ist ab dem zweiten Monat ein Archiv, in dem man
|
||
nichts findet. Das ist die Funktion, die am haeufigsten fehlt und am
|
||
meisten Zeit spart -- vor Reaktionen, vor Anhaengen, vor allem
|
||
anderen.
|
||
|
||
DIE EINGRENZUNG AUF DIE EIGENEN RAEUME IST DIE GANZE SICHERHEIT
|
||
DIESER FUNKTION. Sie steht deshalb im JOIN und nicht in einer
|
||
nachtraeglichen Pruefung: `chat_teilnehmer` wird mit der eigenen
|
||
Personenkennung verbunden, und was dort nicht drinsteht, kommt gar
|
||
nicht erst aus der Datenbank. Ein Filter, der erst hinterher
|
||
aussortiert, ist eine Zeile davon entfernt, vergessen zu werden.
|
||
|
||
OHNE VOLLTEXTINDEX, MIT ABSICHT. `LIKE '%...%'` kann keinen Index
|
||
benutzen und liest die Tabelle. Bei einem Team dieser Groesse sind
|
||
das einige tausend Zeilen -- gemessen unter einer Millisekunde. Ein
|
||
FTS5-Index waere eine zweite Tabelle, die synchron gehalten werden
|
||
muss, und genau daran gehen solche Sachen kaputt. Wenn der Verlauf
|
||
einmal sechsstellig wird, ist das der richtige Zeitpunkt dafuer,
|
||
nicht heute.
|
||
|
||
`escape` FUER DIE PLATZHALTER: In `LIKE` sind `%` und `_` Zeichen
|
||
mit Bedeutung. Wer nach "100 %" sucht, wuerde sonst alles finden.
|
||
===================================================================== */
|
||
chatRouter.get("/workspace/api/chat/suche", (req, res) => {
|
||
try {
|
||
const roh = String(req.query.q ?? "").trim();
|
||
if (roh.length < 2) return res.json({ frage: roh, treffer: [], zuKurz: true });
|
||
if (roh.length > 120) return res.status(400).json({ fehler: "zu_lang" });
|
||
|
||
/* AUCH DER BACKSLASH SELBST. Er ist hier das Fluchtzeichen
|
||
(`ESCAPE '\'`); wer ihn eingibt, wuerde sonst das naechste
|
||
Zeichen entwerten und im Zweifel ein ungueltiges Muster erzeugen.
|
||
Drei Zeichen haben in `LIKE` eine Bedeutung, nicht zwei. */
|
||
const muster = "%" + roh.replace(/[\\%_]/g, (z) => "\\" + z) + "%";
|
||
const reihen = db().prepare(`
|
||
SELECT n.id, n.raum_id, n.text, n.erstellt,
|
||
p.name AS von, p.rolle,
|
||
r.art, r.name AS raum_name
|
||
FROM chat_nachrichten n
|
||
JOIN chat_teilnehmer t ON t.raum_id = n.raum_id AND t.person_id = ?
|
||
JOIN chat_raeume r ON r.id = n.raum_id
|
||
LEFT JOIN personen p ON p.id = n.person_id
|
||
WHERE n.weg_am IS NULL
|
||
AND n.id > t.geloescht_bis
|
||
AND n.text LIKE ? ESCAPE '\\'
|
||
ORDER BY n.id DESC LIMIT 60`).all(req.person.id, muster);
|
||
|
||
res.json({
|
||
frage: roh,
|
||
treffer: reihen.map((n) => {
|
||
const leute = teilnehmerVon(n.raum_id);
|
||
return {
|
||
id: n.id, raum_id: n.raum_id,
|
||
raum: raumName({ id: n.raum_id, art: n.art, name: n.raum_name }, leute, req.person.id),
|
||
von: n.von || "Gelöscht", rolle: n.rolle,
|
||
erstellt: n.erstellt,
|
||
/* Der Ausschnitt wird um den Treffer herum geschnitten, nicht
|
||
vom Anfang: Bei einer langen Nachricht stuende sonst der
|
||
Anfang da und das gesuchte Wort waere nicht dabei. */
|
||
text: ausschnitt(n.text, roh),
|
||
};
|
||
}),
|
||
});
|
||
} catch (fehler) {
|
||
console.error("[chat] Suche:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/** Ein Stueck Text um die Fundstelle herum. */
|
||
function ausschnitt(text, frage) {
|
||
const t = String(text || "");
|
||
const i = t.toLowerCase().indexOf(frage.toLowerCase());
|
||
if (i < 0 || t.length <= 160) return t.slice(0, 160);
|
||
const von = Math.max(0, i - 50);
|
||
const bis = Math.min(t.length, i + frage.length + 90);
|
||
return (von > 0 ? "… " : "") + t.slice(von, bis) + (bis < t.length ? " …" : "");
|
||
}
|
||
|
||
/** Bis hierher gelesen. */
|
||
chatRouter.put("/workspace/api/chat/raeume/:id/gelesen", gleicheHerkunft,
|
||
express.json({ limit: "4kb" }), (req, res) => {
|
||
try {
|
||
const raumId = Number(req.params.id);
|
||
const bis = Number(req.body?.bis);
|
||
if (!Number.isInteger(raumId) || !Number.isInteger(bis)) {
|
||
return res.status(400).json({ fehler: "ungueltig" });
|
||
}
|
||
if (!istDrin(raumId, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
|
||
/* ==== NICHT WEITER ALS BIS ZUR LETZTEN NACHRICHT (20.09.2026) ====
|
||
|
||
Hier stand nur `Number.isInteger(bis)`. Damit liess sich der
|
||
Stand auf JEDE Zahl setzen -- auch auf eine, die weit hinter
|
||
allem liegt, was es gibt.
|
||
|
||
Die Folge ist still und dauerhaft: Wer einmal "gelesen bis
|
||
999999" schickt, hat in diesem Gespraech nie wieder einen
|
||
Zaehler und nie wieder ein @-Zeichen. Alles Kuenftige ist
|
||
schon gelesen, bevor es geschrieben wurde. Niemand wuerde den
|
||
Zusammenhang je finden -- man merkt nur, dass "die
|
||
Benachrichtigungen nicht gehen".
|
||
|
||
Gefunden beim Bauen der Erwaehnungen: Die Pruefung setzte zum
|
||
Aufraeumen 99999 und wunderte sich danach, warum das frische
|
||
"@" nicht leuchtete. Der Fehler lag nicht in der Erwaehnung.
|
||
|
||
Der Browser schickt heute immer die Nummer der letzten
|
||
gesehenen Nachricht -- er ist also nicht der Grund. Aber eine
|
||
Grenze, die nur davon lebt, dass der Aufrufer sich benimmt,
|
||
ist keine.
|
||
|
||
ABGELEITET STATT GERATEN: Weiter als bis zur juengsten
|
||
Nachricht dieses Raums kann niemand gelesen haben. */
|
||
const neueste = db().prepare(
|
||
"SELECT COALESCE(MAX(id), 0) AS n FROM chat_nachrichten WHERE raum_id = ?")
|
||
.get(raumId).n;
|
||
const stand = Math.max(0, Math.min(bis, neueste));
|
||
|
||
/* 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(stand, raumId, req.person.id, stand);
|
||
res.json({ ok: true });
|
||
} catch (fehler) {
|
||
console.error("[chat] Gelesen:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/* =====================================================================
|
||
EIN GESPRAECH OBEN ANHEFTEN (25.09.2026)
|
||
=====================================================================
|
||
|
||
Filipe: „ich will dass man auch individuel jeder fuer sich auch in
|
||
der liste chats fixieren kann. auch mehrere nicht nur eins."
|
||
|
||
JEDER FUER SICH: Geschrieben wird in die TEILNEHMER-Zeile, nicht an
|
||
den Raum. Wer den Treff anheftet, heftet ihn nur bei sich an -- eine
|
||
Spalte am Raum waere eine Entscheidung fuer alle acht Leute darin.
|
||
|
||
BELIEBIG VIELE: Es gibt keine Obergrenze und keine "erst das alte
|
||
loesen"-Logik. Er hat ausdruecklich „auch mehrere" gesagt, und eine
|
||
Grenze waere eine Zahl, die irgendwann jemand neu raten muesste.
|
||
|
||
`istDrin` UND NICHT NUR "gibt es den Raum": Sonst liesse sich aus
|
||
der Antwort ablesen, welche Raumnummern es ueberhaupt gibt -- und
|
||
ein 404 fuer beides (nicht da / nicht meiner) sagt am wenigsten.
|
||
|
||
DIE ANTWORT TRAEGT DEN NEUEN STAND und nicht nur „ok". Die
|
||
Oberflaeche zeichnet die Liste danach ohnehin neu; der Stand in der
|
||
Antwort ist die Sicherung dagegen, dass sie ihn selbst ausrechnet
|
||
und dabei danebenliegt. */
|
||
chatRouter.put("/workspace/api/chat/raeume/:id/fixieren", gleicheHerkunft,
|
||
express.json({ limit: "4kb" }), (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" });
|
||
|
||
/* AUSDRUECKLICH `=== true` / `=== false` und kein Umschalten
|
||
ohne Angabe. Ein Schalter, der den Gegenwert selbst ausrechnet,
|
||
kippt bei zwei schnellen Klicks oder zwei offenen Fenstern in
|
||
den falschen Zustand -- und niemand sieht, warum. Die
|
||
Oberflaeche weiss, was sie will; sie sagt es. */
|
||
const an = req.body?.an === true;
|
||
db().prepare(`UPDATE chat_teilnehmer SET fixiert_am = ?
|
||
WHERE raum_id = ? AND person_id = ?`)
|
||
.run(an ? new Date().toISOString() : null, raumId, req.person.id);
|
||
res.json({ ok: true, fixiert: an });
|
||
} catch (fehler) {
|
||
console.error("[chat] Anheften:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/* =====================================================================
|
||
EINEN AUSHANG NUR FUER SICH WEGNEHMEN (25.09.2026)
|
||
=====================================================================
|
||
|
||
Filipe: „jeder soll das fixierte individuel fuer sich loesen koennen
|
||
aber niemals so dass es sich fuer alle loest."
|
||
|
||
KEIN RECHT NOETIG, NUR EIN PLATZ IM RAUM. Wer die Nachricht sehen
|
||
darf, darf sie auch aus seiner eigenen Kopfzeile nehmen -- das ist
|
||
keine Aenderung am Raum, sondern eine Einstellung an sich selbst.
|
||
Deshalb steht hier `istDrin` und nicht `darfAnheften`.
|
||
|
||
UMKEHRBAR, und das ist der Grund fuer `aus: false`: Wer sich
|
||
verklickt, holt den Aushang ueber das Menue an der Nachricht wieder
|
||
nach oben. Ein Handgriff ohne Rueckweg braucht eine Rueckfrage; mit
|
||
Rueckweg braucht er keine, und zwei Klicks fuer „ist mir schon
|
||
bekannt" waeren einer zu viel.
|
||
|
||
DIE NACHRICHT BLEIBT ANGEHEFTET. Hier steht nur, dass ICH sie oben
|
||
nicht mehr sehen will. Fuer alle anderen haengt sie weiter, und
|
||
wenn die Leitung sie spaeter fuer alle loest, verschwindet sie
|
||
ueberall -- diese Zeile hier wird dann bedeutungslos und faellt mit
|
||
der Nachricht weg (ON DELETE CASCADE).
|
||
===================================================================== */
|
||
chatRouter.put("/workspace/api/chat/nachrichten/:id/pin-fuer-mich", 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 FROM chat_nachrichten WHERE id = ?").get(id);
|
||
if (!n || !istDrin(n.raum_id, req.person)) {
|
||
return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
}
|
||
|
||
/* AUSDRUECKLICH `=== true`, kein Umschalten ohne Angabe --
|
||
dieselbe Ueberlegung wie beim Fixieren der Gespraeche: Ein
|
||
Schalter, der den Gegenwert selbst ausrechnet, kippt bei zwei
|
||
offenen Fenstern in den falschen Zustand. */
|
||
const aus = req.body?.aus === true;
|
||
if (aus) {
|
||
d.prepare(`INSERT INTO chat_pin_aus (nachricht_id, person_id, am)
|
||
VALUES (?,?,?) ON CONFLICT DO NOTHING`)
|
||
.run(id, req.person.id, jetzt());
|
||
} else {
|
||
d.prepare("DELETE FROM chat_pin_aus WHERE nachricht_id = ? AND person_id = ?")
|
||
.run(id, req.person.id);
|
||
}
|
||
|
||
/* KEIN chatEreignis: Das geht nur mich etwas an. Ein Ereignis an
|
||
den Raum wuerde bei allen anderen ein Neuzeichnen ausloesen --
|
||
fuer eine Aenderung, die sie nicht sehen. */
|
||
res.json({ ok: true, aus });
|
||
} catch (fehler) {
|
||
console.error("[chat] Pin fuer mich:", 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, angeheftet_von"
|
||
+ " 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" });
|
||
}
|
||
const an = req.body?.an !== false;
|
||
|
||
/* ==== ANHEFTEN UND FUER-ALLE-LOESEN SIND ZWEI FRAGEN (25.09.2026)
|
||
|
||
Filipe: „niemals so dass es sich fuer alle loest. ausser
|
||
dogfather macht es oder die rechte hand, dann ist es bei jedem
|
||
weg."
|
||
|
||
ANHEFTEN bleibt bei der Leitung -- des Hauses oder des Raumes.
|
||
Daran aendert sich nichts.
|
||
|
||
FUER ALLE LOESEN ist enger: DogFather und die rechte Hand,
|
||
und ausserdem der, DER ES SELBST ANGEHEFTET HAT. Die letzte
|
||
Gruppe steht nicht in Filipes Satz und ist trotzdem noetig --
|
||
sonst entsteht eine Sackgasse: Es haengen hoechstens drei
|
||
Aushaenge, und eine Gruppenleitung, die drei angeheftet hat
|
||
und keinen mehr abnehmen darf, kann nie wieder etwas
|
||
anheften. Sie nimmt damit nur zurueck, was sie selbst getan
|
||
hat -- das ist die Kehrseite derselben Erlaubnis, keine neue.
|
||
|
||
ALLE UEBRIGEN nehmen den Aushang fuer SICH weg. Dafuer gibt es
|
||
die Route darunter; sie braucht kein Recht, nur einen Platz im
|
||
Raum. */
|
||
if (an) {
|
||
if (!darfAnheften(req.person, n.raum_id)) {
|
||
return res.status(403).json({ fehler: "Anheften darf nur die Leitung." });
|
||
}
|
||
} else {
|
||
const meins = n.angeheftet_von && Number(n.angeheftet_von) === Number(req.person.id);
|
||
if (!fuehrtTeamDogi(req.person) && !meins) {
|
||
return res.status(403).json({
|
||
fehler: "Für alle lösen dürfen nur DogFather und die rechte Hand. "
|
||
+ "Für dich selbst kannst du es jederzeit wegnehmen.",
|
||
});
|
||
}
|
||
}
|
||
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" });
|
||
}
|
||
});
|
||
|
||
/* =====================================================================
|
||
LÖSCHEN HEISST LÖSCHEN (22.09.2026)
|
||
=====================================================================
|
||
|
||
Filipe: „Gelöschte Nachrichten verschwinden vollständig – keine
|
||
Spur, kein ‚wurde gelöscht'-Hinweis, bei niemandem, auch nicht bei
|
||
DogFather." Und: „Löschen darf weiterhin jeder nur seine eigenen –
|
||
außer DogFather und rechte Hand: die dürfen jede Nachricht löschen,
|
||
für den Notfall."
|
||
|
||
---------------------------------------------------------------------
|
||
WAS SICH DAMIT UMDREHT
|
||
|
||
Hier stand bis heute das Gegenteil, und zwar begründet: „Ein Loch im
|
||
Verlauf wirft mehr Fragen auf als der Hinweis, dass hier etwas
|
||
zurückgenommen wurde." Die Zeile blieb, der Text wurde geleert, und
|
||
der Chat zeigte „Nachricht zurückgenommen".
|
||
|
||
Das Argument war nicht falsch -- es beantwortet nur eine andere
|
||
Frage als die, die Filipe stellt. Ein Hinweis „hier stand etwas"
|
||
ist für den, der gelöscht hat, oft schlimmer als der Satz selbst:
|
||
Er markiert die Stelle. Wer etwas aus Versehen schreibt, will es
|
||
weg haben und nicht unterstrichen.
|
||
|
||
DESHALB JETZT EIN ECHTES DELETE. Das geht sauber, weil die beiden
|
||
abhängigen Tabellen es mittragen:
|
||
|
||
chat_reaktionen ... REFERENCES chat_nachrichten(id) ON DELETE CASCADE
|
||
chat_erwaehnungen ... REFERENCES chat_nachrichten(id) ON DELETE CASCADE
|
||
|
||
und `PRAGMA foreign_keys = ON` beim Öffnen steht. Nachgesehen, nicht
|
||
angenommen.
|
||
|
||
DREI DINGE, DIE SONST EINE SPUR HINTERLIESSEN:
|
||
|
||
1. `antwort_auf` zeigt weiter auf die Nummer. Der Verlauf holt das
|
||
Zitat über einen LEFT JOIN -- es kommt nichts mehr zurück, und
|
||
die Anzeige lässt das Zitat dann weg. Ein Zitat auf etwas, das
|
||
es nicht gibt, wäre die Spur.
|
||
2. `letzte_am` am Raum. War die gelöschte die letzte Nachricht,
|
||
stünde die Gesprächsliste weiter auf ihrem Zeitpunkt -- ein
|
||
Raum, der ganz oben steht und nichts Neues hat. Wird neu
|
||
gerechnet.
|
||
3. Der Anhang auf der Platte. Das war schon vorher richtig gelöst
|
||
und bleibt so: erst die Zeile, dann die Datei.
|
||
|
||
WAS BLEIBT: die Protokollzeile. Sie nennt WER WESSEN Nachricht
|
||
gelöscht hat -- keinen Text, keinen Inhalt. Beim Notfall-Löschen
|
||
durch DogFather oder die rechte Hand ist das der Unterschied
|
||
zwischen einer Befugnis und einer unsichtbaren Macht. Das Protokoll
|
||
ist kein Chatverlauf; es steht an einer anderen Stelle und
|
||
beantwortet eine andere Frage.
|
||
===================================================================== */
|
||
|
||
/** Wer JEDE Nachricht löschen darf -- für den Notfall.
|
||
*
|
||
* NICHT `fuehrtTeamDogi`: Das schlösse die linke Hand ein, und Filipe
|
||
* hat ausdrücklich zwei genannt. Nicht `istLeitung`: Das schlösse
|
||
* Spicy Media ein -- die private Chats ausdrücklich nicht einmal
|
||
* sehen darf. Zwei Rollen, wörtlich die zwei aus dem Auftrag. */
|
||
const darfJedeNachrichtLoeschen = (person) =>
|
||
istDogFather(person) || person?.rolle === "hand";
|
||
|
||
/* =====================================================================
|
||
EIGENE NACHRICHT BEARBEITEN (03.10.2026)
|
||
|
||
Filipe: "die nachrichten die man in den chat reinschreibt. jeder
|
||
soll seine eigene nachricht bearbeiten koennen. diese option soll
|
||
jeder fuer seine eigene nachrichten haben die er selber verfasst
|
||
hat."
|
||
|
||
NUR DIE EIGENE -- OHNE AUSNAHME, auch nicht fuer DogFather und die
|
||
rechte Hand. Beim LOESCHEN gibt es diese Ausnahme (siehe
|
||
`darfJedeNachrichtLoeschen`, fuer den Notfall), und es waere
|
||
naheliegend, sie hier mitzunehmen. Das waere falsch: Eine fremde
|
||
Nachricht zu entfernen heisst "das soll hier nicht stehen". Eine
|
||
fremde Nachricht zu AENDERN heisst, jemandem Worte in den Mund zu
|
||
legen, die unter seinem Namen und seinem Bild stehen bleiben. Das
|
||
ist nicht dieselbe Befugnis in groesser, sondern eine andere.
|
||
|
||
ERWAEHNUNGEN BLEIBEN, WIE SIE BEIM SENDEN WAREN. Wer beim
|
||
Bearbeiten "@Anna" ergaenzt, spricht Anna damit NICHT an. Der
|
||
Grund ist die Richtung, in die es sonst schiefgeht: Entweder man
|
||
benachrichtigt nachtraeglich -- dann laesst sich jemand beliebig
|
||
oft anpingen, indem man dieselbe Nachricht immer wieder
|
||
bearbeitet -- oder man traegt die Erwaehnung still ein, und dann
|
||
steht jemand auf einer Liste, von der er nie erfaehrt. Beides ist
|
||
schlechter als die klare Regel: Ansprechen tut man mit einer neuen
|
||
Nachricht.
|
||
|
||
DER RAUM RUECKT NICHT NACH OBEN, und niemand bekommt sie als
|
||
ungelesen. Ein Bearbeiten ist keine Wortmeldung; wuerde der Raum
|
||
dadurch hochspringen, waere jede Tippfehlerkorrektur eine Stoerung
|
||
fuer alle anderen. Genau deshalb wird `letzte_am` hier NICHT
|
||
angefasst -- anders als beim Loeschen, wo es neu gerechnet werden
|
||
muss, weil die juengste Nachricht verschwunden sein kann.
|
||
===================================================================== */
|
||
chatRouter.patch("/workspace/api/chat/nachrichten/:id", gleicheHerkunft,
|
||
express.json({ limit: "8kb" }), (req, res) => {
|
||
try {
|
||
const id = Number(req.params.id);
|
||
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
|
||
|
||
/* DIESELBEN GRENZEN WIE BEIM SENDEN. Eine zweite Rechnung waere
|
||
die, die auseinanderlaeuft -- und ein Text, der sich senden,
|
||
aber nicht bearbeiten laesst, waere eine Falle. */
|
||
const text = String(req.body?.text ?? "").trim();
|
||
if (!text) {
|
||
/* LEEREN IST LOESCHEN, und dafuer steht der Knopf daneben. Wer
|
||
hier eine leere Nachricht zuliesse, haette zwei Wege zu
|
||
derselben Sache -- und einen davon ohne Rueckfrage. */
|
||
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 = d.prepare(
|
||
"SELECT id, raum_id, person_id, text, weg_am FROM chat_nachrichten WHERE id = ?").get(id);
|
||
/* Was jemand nicht sehen darf, gibt es fuer ihn nicht: 404 statt
|
||
403 -- dieselbe Ueberlegung wie beim Loeschen eine Route
|
||
weiter unten. */
|
||
if (!n || !istDrin(n.raum_id, req.person)) {
|
||
return res.status(404).json({ fehler: "nicht_gefunden" });
|
||
}
|
||
if (n.person_id !== req.person.id) {
|
||
return res.status(403).json({ fehler: "Nur eigene Nachrichten." });
|
||
}
|
||
if (n.weg_am) {
|
||
return res.status(409).json({ fehler: "Diese Nachricht ist zurückgenommen." });
|
||
}
|
||
/* UNVERAENDERT IST KEINE AENDERUNG. Sonst stuende "bearbeitet" an
|
||
einer Nachricht, an der niemand etwas getan hat -- und der
|
||
Vermerk waere ab da nichts mehr wert. */
|
||
if (text === n.text) {
|
||
return res.json({ ok: true, unveraendert: true });
|
||
}
|
||
|
||
const wann = new Date().toISOString();
|
||
d.prepare("UPDATE chat_nachrichten SET text = ?, bearbeitet_am = ? WHERE id = ?")
|
||
.run(text, wann, id);
|
||
|
||
/* An ALLE im Raum, aber an niemanden als Benachrichtigung (leere
|
||
Empfaengerliste) -- siehe oben. */
|
||
chatEreignis(n.raum_id,
|
||
{ id, raum_id: n.raum_id, text, bearbeitet_am: wann }, [], "geaendert");
|
||
|
||
protokolliere("chat_bearbeitet", {
|
||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||
detail: `#${id}`,
|
||
});
|
||
res.json({ ok: true, bearbeitet_am: wann, text });
|
||
} catch (fehler) {
|
||
console.error("[chat] Bearbeiten:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
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 d = db();
|
||
const n = d.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" });
|
||
}
|
||
const fremd = n.person_id !== req.person.id;
|
||
if (fremd && !darfJedeNachrichtLoeschen(req.person)) {
|
||
return res.status(403).json({ fehler: "Nur eigene Nachrichten." });
|
||
}
|
||
|
||
const dateiWeg = n.anhang_datei;
|
||
/* ERST DIE ZEILE, DANN DIE DATEI. Scheitert das Entfernen (Datei
|
||
schon weg), ist der Verweis trotzdem fort -- andersherum bliebe
|
||
im schlechten Fall eine Zeile ohne Datei stehen. */
|
||
d.prepare("DELETE FROM chat_nachrichten WHERE id = ?").run(id);
|
||
if (dateiWeg) anhangWeg(dateiWeg);
|
||
/* DER RAUM RECHNET SEINEN LETZTEN ZEITPUNKT NEU. Ohne das stuende
|
||
er in der Liste weiterhin ganz oben, mit einem Zeitpunkt, zu dem
|
||
es nichts mehr gibt. */
|
||
d.prepare(`UPDATE chat_raeume
|
||
SET letzte_am = (SELECT MAX(erstellt) FROM chat_nachrichten WHERE raum_id = ?)
|
||
WHERE id = ?`).run(n.raum_id, n.raum_id);
|
||
|
||
const empfaenger = teilnehmerVon(n.raum_id).map((t) => t.id);
|
||
/* `geloescht: true` STATT `zurueckgenommen`. Das Wort entscheidet,
|
||
was der Browser tut: Das alte hiess "zeig einen Hinweis", das
|
||
neue heisst "nimm es aus dem Bild". Beides gleichzeitig zu
|
||
schicken waere die Fassung, bei der eine alte Seite den Hinweis
|
||
weiter anzeigt. */
|
||
chatEreignis(n.raum_id, { id, raum_id: n.raum_id, geloescht: true }, [], "weg");
|
||
|
||
protokolliere("chat_geloescht", {
|
||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||
detail: fremd ? `#${id} (fremde Nachricht von #${n.person_id})` : `#${id}`,
|
||
});
|
||
res.json({ ok: true, geloescht: true, empfaenger: empfaenger.length });
|
||
} catch (fehler) {
|
||
console.error("[chat] Löschen:", 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();
|
||
|
||
/* ==== SIEHT JEMAND HIN? (30.09.2026) ================================
|
||
|
||
Diene im Support: „Die Benachrichtigungen werden nicht angezeigt,
|
||
wenn neue Nachrichten reinkommen. Erst, wenn man die App öffnet."
|
||
|
||
Nachgestellt (pruef-abzeichen, Abschnitt 8): Mit offener Verbindung
|
||
kam KEINE Meldung, ohne sie sehr wohl.
|
||
|
||
Hier stand `zuschauer.size > 0`. Der Gedanke dahinter ist richtig
|
||
-- wer die Seite offen hat, sieht die Nachricht ohnehin, und eine
|
||
Meldung obendrauf ist der schnellste Weg, dass er sie abschaltet.
|
||
Nur beantwortet `zuschauer` eine ANDERE Frage: ob eine VERBINDUNG
|
||
offen ist. Ein Handy mit der App im Hintergrund haelt sie weiter.
|
||
|
||
Die Seite weiss es (`document.visibilityState`), der Server nicht.
|
||
Also sagt sie es ihm -- ueber `/workspace/api/chat/sicht`.
|
||
|
||
MIT VERFALL, und das ist der wichtige Teil: Ein Geraet, das
|
||
abstuerzt, im Funkloch steht oder eingefroren wird, sagt nichts
|
||
mehr. Ohne Verfall bliebe es fuer immer „sichtbar" und fuer immer
|
||
still. Wer nicht widerspricht, gilt nach zweieinhalb Minuten als
|
||
weg -- eine Meldung zu viel ist laestig, eine zu wenig ist genau
|
||
der Fehler, den Diene gemeldet hat. */
|
||
const sichtbarSeit = new Map();
|
||
const SICHT_FRIST = 150_000;
|
||
|
||
/**
|
||
* @param erwaehnte Wer in DIESER Nachricht mit "@" angesprochen wurde.
|
||
* Diese Menge bekommt eine ANDERE Meldung, nicht eine zweite
|
||
* (20.09.2026). Sie steht hier und nicht an der Aufrufstelle,
|
||
* weil der Kommentar unten es verlangt: Die Entscheidung "wer
|
||
* bekommt was" gehoert an EINE Stelle.
|
||
*/
|
||
function chatEreignis(raumId, nachricht, benachrichtigen, art = "neu",
|
||
erwaehnte = new Set(), rudel = false) {
|
||
/* 1. An alle offenen Verbindungen der Beteiligten. */
|
||
const leute = teilnehmerVon(raumId).map((t) => t.id);
|
||
for (const personId of leute) {
|
||
for (const antwort of zuschauer.get(personId) || []) {
|
||
try {
|
||
antwort.write(`event: ${art}\ndata: ${JSON.stringify(nachricht)}\n\n`);
|
||
} catch { /* die Verbindung räumt sich selbst ab */ }
|
||
}
|
||
}
|
||
|
||
/* 2. Eine Benachrichtigung an die, die NICHT zusehen. Wer die Seite
|
||
offen hat, sieht die Nachricht ohnehin -- ihm auch noch eine
|
||
Meldung aufs Handy zu schicken, ist der schnellste Weg, dass er
|
||
Benachrichtigungen abschaltet. */
|
||
for (const personId of benachrichtigen) {
|
||
/* `.size` UND NICHT `.length` (berichtigt 19.09.2026).
|
||
|
||
`zuschauer` haelt Mengen (`new Set()`), und eine Menge hat kein
|
||
`length` -- der Ausdruck war IMMER `undefined`, also immer
|
||
falsch, also wurde nie uebersprungen. Folge: Wer die Seite offen
|
||
hatte, bekam zusaetzlich zur Nachricht auf dem Bildschirm noch
|
||
eine Meldung aufs Handy.
|
||
|
||
Genau davor warnt der Kommentar drei Zeilen darueber: „der
|
||
schnellste Weg, dass er Benachrichtigungen abschaltet". Die
|
||
Absicht stand da, die Zeile tat das Gegenteil -- und `siehtZu()`
|
||
weiter unten macht es mit `.size` schon richtig. Gefunden beim
|
||
Nachverfolgen eines ganz anderen Fehlers. */
|
||
/* ==== GEFRAGT WIRD, OB JEMAND HINSIEHT (berichtigt 30.09.2026)
|
||
|
||
Hier stand `zuschauer.get(personId).size` -- also „ist eine
|
||
Verbindung offen". Ein Handy mit der App im Hintergrund haelt
|
||
sie weiter, und damit blieb Diene ohne Meldung, solange die App
|
||
nur im Hintergrund lag. Gemessen in pruef-abzeichen,
|
||
Abschnitt 8.
|
||
|
||
Jetzt entscheidet `siehtZu()` -- dieselbe Funktion, die auch
|
||
andere Module fragen. Eine Abschrift daneben war genau der
|
||
Fehler. */
|
||
if (siehtZu(personId)) continue;
|
||
/* DIE ART GEHT MIT (18.09.2026). Sie stand als Parameter oben und
|
||
endete im Ereignisstrom -- die Benachrichtigung bekam sie nie zu
|
||
sehen und behandelte deshalb JEDES Ereignis als Chatnachricht.
|
||
Bei einem Anruf kam dabei woertlich „Nachricht von [object
|
||
Object]" heraus. */
|
||
/* ANGESPROCHEN ODER NUR DABEI? Der Unterschied entscheidet ueber
|
||
Wortlaut UND Art -- und die Art kann jeder einzeln abschalten.
|
||
Wer den Chat stumm stellt, weil dort viel geredet wird, will
|
||
trotzdem wissen, wenn ihn jemand direkt anspricht. */
|
||
/* DREI STUFEN STATT ZWEI (23.09.2026): nur dabei -- angesprochen
|
||
-- das ganze Rudel gerufen. Der Unterschied steht im WORTLAUT,
|
||
nicht in einer zweiten Meldung: "Anna hat das Rudel gerufen"
|
||
sagt sofort, dass es alle betrifft und nicht nur mich.
|
||
|
||
AUF DEMSELBEN SCHALTER WIE DIE ERWAEHNUNG, mit Absicht (siehe
|
||
meldeNeueNachricht). Ein Ruf ans Team IST eine Erwaehnung -- nur
|
||
eben von allen. Ein eigener vierter Schalter waere der, den
|
||
jemand abschaltet und der dann genau im wichtigen Moment fehlt. */
|
||
void meldeNeueNachricht(personId, raumId, nachricht,
|
||
art === "neu" && erwaehnte.has(personId)
|
||
? (rudel ? "rudel" : "erwaehnung")
|
||
: art);
|
||
}
|
||
}
|
||
|
||
/** Die Push-Benachrichtigung. Absichtlich träge geladen: Der Chat soll
|
||
* auch dann laufen, wenn am Push-Teil etwas klemmt. */
|
||
/** Was in der Benachrichtigung steht.
|
||
*
|
||
* ALS EIGENE FUNKTION UND EXPORTIERT, damit eine Pruefung sie lesen
|
||
* kann, ohne einen Push-Dienst nachzubauen. Genau hier stand der
|
||
* Fehler, den Filipe am 18.09.2026 gemeldet hat -- und er war von
|
||
* aussen nicht messbar, weil der Text tief in einer Funktion entstand,
|
||
* die nur der Push-Weg aufruft.
|
||
*
|
||
* @param art "anruf" oder alles andere
|
||
* @returns {{titel: string, text: string, ziel: string}}
|
||
*/
|
||
export function pushTextFuer(art, nachricht, raumId) {
|
||
const ziel = `/workspace/chat.html?raum=${raumId}`;
|
||
|
||
/* ---- EIN ANRUF IST KEINE NACHRICHT ----------------------------
|
||
|
||
Filipe: „wenn ich vanvan anrufe kriegt sie nur eine benarichtigung
|
||
aber keinen anruf was soll das."
|
||
|
||
Gemessen kam bei ihr an:
|
||
|
||
Nachricht von [object Object]
|
||
(kein Text)
|
||
|
||
Zwei Fehler auf einmal. `nachricht.von` ist beim Klingeln ein
|
||
OBJEKT (`{id, name}`) und keine Zeichenkette -- aus der Vorlage
|
||
wurde deshalb „[object Object]". Und einen `text` gibt es bei
|
||
einem Anruf gar nicht, also blieb die Zeile leer.
|
||
|
||
Dass es so weit kam, lag daran, dass die ART des Ereignisses hier
|
||
nie ankam: Jedes Ereignis wurde als Chatnachricht behandelt, auch
|
||
das Klingeln. */
|
||
if (art === "anruf") {
|
||
const wer = nachricht.von?.name || "Jemand";
|
||
return {
|
||
titel: `${wer} ruft an`,
|
||
text: nachricht.video ? "Videoanruf – tippen zum Rangehen"
|
||
: "Tippen zum Rangehen",
|
||
ziel,
|
||
};
|
||
}
|
||
|
||
const wer = typeof nachricht.von === "string"
|
||
? nachricht.von : (nachricht.von?.name || "jemandem");
|
||
|
||
/* ---- ANGESPROCHEN IST ETWAS ANDERES ALS DABEI (20.09.2026) ----
|
||
|
||
"Nachricht von Anna" sagt nicht, dass sie DICH meint. In einem
|
||
Raum mit acht Leuten ist das der Unterschied zwischen "kann
|
||
warten" und "gleich nachsehen" -- und genau diesen Unterschied
|
||
soll das Markieren mit "@" machen. Steht er nicht in der Meldung,
|
||
ist das Markieren nur Farbe.
|
||
|
||
Der Anfang der Nachricht bleibt darunter stehen, wie sonst auch:
|
||
Wer auf dem Sperrbildschirm liest, soll nicht oeffnen muessen, um
|
||
zu erfahren, ob es wichtig war. */
|
||
if (art === "erwaehnung") {
|
||
return {
|
||
titel: `${wer} hat dich erwähnt`,
|
||
text: String(nachricht.text || "").slice(0, 120),
|
||
ziel,
|
||
};
|
||
}
|
||
|
||
/* ---- DAS GANZE RUDEL (23.09.2026) ----------------------------
|
||
|
||
Filipe: „dann sollen alle eine benarichtigung bekommen."
|
||
|
||
WARUM NICHT EINFACH „hat dich erwähnt": Weil es nicht stimmt.
|
||
Beim Rudel ist man nicht gemeint, sondern mitgemeint -- und wer
|
||
dreimal „hat dich erwähnt" liest und jedes Mal feststellt, dass
|
||
es alle betraf, glaubt beim vierten Mal auch dem echten nicht
|
||
mehr. Der Wortlaut sagt deshalb, was los ist.
|
||
|
||
ES IST TROTZDEM DIESELBE ART von Meldung (chat_erwaehnung, siehe
|
||
meldeNeueNachricht) -- ein Ruf ans Team ist eine Erwähnung, nur
|
||
eben von allen. */
|
||
if (art === "rudel") {
|
||
return {
|
||
titel: `${wer} hat das Rudel gerufen`,
|
||
text: String(nachricht.text || "").slice(0, 120),
|
||
ziel,
|
||
};
|
||
}
|
||
|
||
return {
|
||
titel: `Nachricht von ${wer}`,
|
||
/* 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)
|
||
|| anhangWort(nachricht.anhang?.art),
|
||
ziel,
|
||
};
|
||
}
|
||
|
||
async function meldeNeueNachricht(personId, raumId, nachricht, art = "neu") {
|
||
try {
|
||
const { benachrichtige } = await import("./workspace-push.js");
|
||
|
||
/* ---- EIN ANRUF IST KEINE NACHRICHT (18.09.2026) --------------
|
||
|
||
Filipe: „wenn ich vanvan anrufe kriegt sie nur eine
|
||
benarichtigung aber keinen anruf was soll das."
|
||
|
||
Gemessen kam bei ihr an:
|
||
|
||
Nachricht von [object Object]
|
||
(kein Text)
|
||
|
||
Zwei Fehler auf einmal. `nachricht.von` ist beim Klingeln ein
|
||
OBJEKT (`{id, name}`) und keine Zeichenkette -- aus der Vorlage
|
||
wurde deshalb „[object Object]". Und einen `text` gibt es bei
|
||
einem Anruf gar nicht, also blieb die Zeile leer.
|
||
|
||
Dass es ueberhaupt so weit kam, lag daran, dass die ART des
|
||
Ereignisses hier nie ankam: Jedes Ereignis wurde als
|
||
Chatnachricht behandelt, auch das Klingeln.
|
||
|
||
Jetzt steht dort, was los ist -- und beim Tippen landet man im
|
||
richtigen Gespraech, wo es dank der Route `/anruf/offen` auch
|
||
wirklich klingelt. */
|
||
/* ALS EIGENE ART, wenn es ein Anruf ist. Sie umgeht die Ruhezeit
|
||
und laesst sich getrennt abschalten -- siehe workspace-push.js. */
|
||
/* DREI ARTEN, DREI SCHALTER. Jede laesst sich einzeln abschalten
|
||
(siehe ARTEN in workspace-push.js) -- und genau darum geht es:
|
||
Wer den lauten Chat stumm stellt, verliert damit weder Anrufe
|
||
noch die Faelle, in denen ihn jemand direkt anspricht. */
|
||
const pushArt = art === "anruf" ? "anruf"
|
||
/* Der Ruf ans Rudel laeuft ueber DENSELBEN Schalter wie die
|
||
Erwaehnung. Kein vierter Schalter: Wer "@rudel" stummschaltet,
|
||
schaltet den Fall stumm, in dem das ganze Team gebraucht wird
|
||
-- und genau dafuer gibt es ihn. Wem es zu viel wird, der sagt
|
||
es demjenigen, der ruft; im Protokoll steht, wer das war. */
|
||
: (art === "erwaehnung" || art === "rudel") ? "chat_erwaehnung"
|
||
: "chat_nachricht";
|
||
/* DIE ZAHL GEHT MIT (30.09.2026). Der Service Worker setzt damit
|
||
das Abzeichen am App-Symbol — und zwar genau dann, wenn es
|
||
etwas wert ist: bei geschlossener App. Ist die Seite offen,
|
||
steht die Zahl ohnehin am Chat-Knopf.
|
||
|
||
Sie wird HIER gerechnet und nicht im Push-Modul: Was ungelesen
|
||
ist, weiß der Chat. Und andersherum wäre es ein Kreis — dieses
|
||
Modul holt sich das Push-Modul, nicht umgekehrt. */
|
||
/* DAS HAUS KOMMT VOM RAUM (30.09.2026). Auf welcher Adresse das
|
||
Geraet installiert wurde, weiss hier niemand -- wohl aber, aus
|
||
welchem Haus die Nachricht stammt, die gerade eine Meldung
|
||
ausloest. Genau dessen Zahl gehoert aufs Abzeichen: Eine
|
||
Meldung aus dem Crew-Chat darf keine Agenturzahl tragen. */
|
||
const raumHaus = db().prepare("SELECT haus FROM chat_raeume WHERE id = ?")
|
||
.get(raumId)?.haus || null;
|
||
await benachrichtige(personId, pushArt, {
|
||
...pushTextFuer(art, nachricht, raumId),
|
||
zahl: ungeleseneZahl({ id: personId, haus: raumHaus }),
|
||
});
|
||
} catch (fehler) {
|
||
console.error("[chat] Benachrichtigung:", fehler?.message);
|
||
}
|
||
}
|
||
|
||
/* ==== DIE SEITE SAGT, OB SIE VORN LIEGT (30.09.2026) ===============
|
||
|
||
Ein winziger Weg, und er traegt die ganze Reparatur: Ohne ihn
|
||
koennte der Server „Verbindung offen" nicht von „sieht hin"
|
||
unterscheiden.
|
||
|
||
OHNE RUMPF IST ES EIN „NEIN". Wer beim Weggehen noch schnell etwas
|
||
schickt (`keepalive`), soll nicht darauf angewiesen sein, dass ein
|
||
JSON-Koerper heil ankommt -- und „weg" ist die sichere Vorgabe: Sie
|
||
fuehrt zu einer Meldung zu viel, nicht zu einer zu wenig. */
|
||
chatRouter.post("/workspace/api/chat/sicht", gleicheHerkunft,
|
||
express.json({ limit: "1kb" }), (req, res) => {
|
||
try {
|
||
const id = req.person.id;
|
||
if (req.body?.sichtbar === true) sichtbarSeit.set(id, Date.now());
|
||
else sichtbarSeit.delete(id);
|
||
res.json({ ok: true });
|
||
} catch (fehler) {
|
||
console.error("[chat] Sicht:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
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);
|
||
/* UND DIE SICHTMELDUNG GEHT MIT. Sonst bliebe sie stehen,
|
||
bis sie verfaellt -- und beim naechsten Verbindungsaufbau
|
||
gaelte die Person sofort als „sieht hin", ohne dass sie es
|
||
gesagt haette. */
|
||
sichtbarSeit.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. */
|
||
/** Wie viele Nachrichten hat diese Person in DIESEM Haus nicht gelesen?
|
||
*
|
||
* ALS FUNKTION UND NICHT NUR ALS WEG (30.09.2026): Dieselbe Zahl
|
||
* braucht seit heute auch die Benachrichtigung — sie geht in die
|
||
* Nachricht mit, damit der Service Worker das Abzeichen am
|
||
* App-Symbol setzen kann, während die App geschlossen ist. Zweimal
|
||
* abgeschrieben wäre sie die Zahl, die irgendwann abweicht.
|
||
*
|
||
* ==== UND SIE ZÄHLT NUR EIN HAUS (30.09.2026) =====================
|
||
*
|
||
* Hier stand keine Hausregel — die Abfrage verband Nachrichten und
|
||
* Teilnehmer und fragte nie, wohin der Raum gehört. Zwanzig Zeilen
|
||
* weiter oben macht es die Raumliste längst richtig
|
||
* (`hausWo(req.person, "r")`); diese eine Zählung war seit dem
|
||
* 24.09.2026 daran vorbeigelaufen.
|
||
*
|
||
* GEMESSEN, NICHT VERMUTET (pruef-abzeichen, Abschnitt 7): Eine
|
||
* Managerin schreibt DogFather an der Agenturwand an. Sein Zähler
|
||
* auf crew. sprang von 0 auf 1 — eine Auskunft über das andere
|
||
* Haus, genau das, was seit dem 24.09. nicht mehr sein soll.
|
||
*
|
||
* Aufgefallen ist es erst beim Abzeichen: Bis heute war das eine
|
||
* Zahl an einem Knopf, ab heute steht sie auf dem Startbildschirm.
|
||
* Eine Zahl, die etwas Falsches zeigt, ist schlimmer als keine —
|
||
* man glaubt ihr ja.
|
||
*
|
||
* `person` STATT `personId`: `hausWo` braucht das Haus, und das
|
||
* hängt an der Adresse, auf der gerade gearbeitet wird, nicht an der
|
||
* Person. Ein Raum ohne Haus (aus der Zeit davor) zählt weiterhin
|
||
* überall — dieselbe Regel wie in der Raumliste. */
|
||
export function ungeleseneZahl(person) {
|
||
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 = ?
|
||
JOIN chat_raeume r ON r.id = m.raum_id
|
||
WHERE t.raus_am IS NULL AND m.id > t.gelesen_bis AND m.id > t.geloescht_bis
|
||
AND m.person_id <> ?
|
||
AND ${hausWo(person, "r")}`).get(person.id, person.id);
|
||
return z?.n || 0;
|
||
}
|
||
|
||
chatRouter.get("/workspace/api/chat/ungelesen", (req, res) => {
|
||
try {
|
||
res.json({ ungelesen: ungeleseneZahl(req.person) });
|
||
} catch (fehler) {
|
||
console.error("[chat] Ungelesen:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/* ---------- Fehler aus express.raw ---------------------------------------
|
||
Ist die Datei zu gross, bricht express.raw ab, BEVOR der eigene Code
|
||
laeuft -- das try/catch dort greift also nicht, und der Fehler landet
|
||
im allgemeinen Behandler als HTTP 500. Fuer den Absender saehe das
|
||
aus wie ein kaputter Server statt wie "Datei zu gross"; er wuerde es
|
||
wieder und wieder versuchen. Derselbe Behandler steht aus demselben
|
||
Grund in workspace-dateien.js.
|
||
|
||
Er muss ZULETZT stehen: Express erkennt ihn an den vier Parametern
|
||
und ruft ihn nur fuer Fehler aus den Routen davor auf. */
|
||
// eslint-disable-next-line no-unused-vars
|
||
chatRouter.use((fehler, req, res, next) => {
|
||
if (!req.path.startsWith("/workspace/api/chat")) return next(fehler);
|
||
if (fehler?.type === "entity.too.large" || fehler?.status === 413) {
|
||
return res.status(413).json({
|
||
fehler: `Zu groß – erlaubt sind ${Math.round(ANHANG_MAX / 1024 / 1024)} MB je Datei.`,
|
||
});
|
||
}
|
||
console.error("[chat] Unerwartet:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
});
|
||
|
||
/* Für Prüfungen und andere Module: Sieht diese Person gerade zu? */
|
||
/* =====================================================================
|
||
EINE NACHRICHT AUS EINEM ANDEREN MODUL SCHICKEN (11.09.2026)
|
||
|
||
Gebraucht vom Entwicklungs-Bereich: Dort steht, was DogFather und die
|
||
rechte Hand ueber die Zeit beobachten -- und die Person selbst sieht
|
||
diesen Bereich nicht. Damit Anerkennung trotzdem ankommt, laesst sich
|
||
ein Eintrag als Nachricht schicken.
|
||
|
||
WARUM HIER UND NICHT DORT: Ein Zweier-Gespraech darf es nur EINMAL
|
||
geben (siehe direktRaum), die Leseraender muessen mitwandern, der
|
||
Live-Strom und die Benachrichtigung haengen daran. Wer das im
|
||
Nachbarmodul nachbaut, hat zwei Fassungen -- und die zweite ist die,
|
||
in der jemand eine Nachricht nicht bekommt.
|
||
|
||
DIE REGEL "wer darf mit wem" WIRD MITGEPRUEFT, obwohl der einzige
|
||
Aufrufer heute ohnehin darf. Eine Funktion, die aus einem fremden
|
||
Modul heraus jeden erreichen kann, ist genau die Abkuerzung, die man
|
||
spaeter woanders benutzt.
|
||
===================================================================== */
|
||
export function nachrichtSchicken(person, anId, text) {
|
||
const ziel = Number(anId);
|
||
const satz = String(text ?? "").trim().slice(0, TEXT_MAX);
|
||
if (!person || !Number.isInteger(ziel) || ziel < 1 || !satz) return null;
|
||
if (!darfSchreibenMit(person, ziel)) return null;
|
||
if (!db().prepare("SELECT 1 FROM personen WHERE id = ? AND aktiv = 1").get(ziel)) return null;
|
||
|
||
const raumId = direktRaum(person, ziel);
|
||
const d = db();
|
||
const n = jetzt();
|
||
d.prepare("INSERT INTO chat_nachrichten (raum_id, person_id, text, erstellt) VALUES (?,?,?,?)")
|
||
.run(raumId, person.id, satz, n);
|
||
const id = d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||
d.prepare("UPDATE chat_raeume SET letzte_am = ? WHERE id = ?").run(n, raumId);
|
||
d.prepare("UPDATE chat_teilnehmer SET gelesen_bis = ? WHERE raum_id = ? AND person_id = ?")
|
||
.run(id, raumId, person.id);
|
||
|
||
const nachricht = {
|
||
id, raum_id: raumId, von_id: person.id, von: person.name,
|
||
rolle: person.rolle, text: satz, erstellt: n,
|
||
zurueckgenommen: false, antwort: null, anhang: null,
|
||
};
|
||
chatEreignis(raumId, nachricht, [ziel], "neu");
|
||
return { id, raum_id: raumId };
|
||
}
|
||
|
||
/* =====================================================================
|
||
WAS DER ANRUF BRAUCHT (18.09.2026)
|
||
|
||
Drei Dinge, und alle drei gibt es hier schon: den Ereigniskanal, die
|
||
Zugangspruefung und die Teilnehmerliste. Sie werden ausdruecklich
|
||
hier freigegeben und nicht drueben nachgebaut -- eine zweite Fassung
|
||
von „wer darf in diesen Raum" waere die, die beim naechsten Umbau
|
||
nicht mitgeaendert wird, und genau daran haengt, wer mithoeren kann.
|
||
|
||
`istDrinFuerAnruf` und `teilnehmerFuerAnruf` heissen nicht wie die
|
||
inneren Funktionen, die sie weiterreichen: Die Namen sagen, WOFUER
|
||
sie freigegeben sind. Wer sie spaeter aendert, sieht am Namen, dass
|
||
noch jemand daran haengt.
|
||
===================================================================== */
|
||
|
||
/** Darf diese Person in diesem Raum telefonieren?
|
||
* Dieselbe Regel wie fuers Mitlesen -- es ist dieselbe Frage. */
|
||
export function istDrinFuerAnruf(raumId, person) {
|
||
return istDrin(raumId, person);
|
||
}
|
||
|
||
/** Wer ist in diesem Raum? Fuer die Frage „wie viele koennen mitmachen". */
|
||
export function teilnehmerFuerAnruf(raumId) {
|
||
return teilnehmerVon(raumId);
|
||
}
|
||
|
||
/** Der Ereigniskanal -- der Hinweg fuer Klingeln und Verbindungsdaten. */
|
||
export { chatEreignis };
|
||
|
||
/** Sieht diese Person gerade hin?
|
||
*
|
||
* ZWEI BEDINGUNGEN, ZWEI GRUENDE:
|
||
* eine offene Verbindung -- die Seite ist ueberhaupt geladen
|
||
* eine frische Sichtmeldung -- und sie liegt vorn, nicht hinten
|
||
*
|
||
* DIE EINE STELLE FUER DIESE FRAGE (30.09.2026). Sie wurde an zwei
|
||
* Orten gestellt: hier und als eigene Zeile in `chatEreignis`. Die
|
||
* dortige Abschrift war es, die Dienes Meldung verursacht hat --
|
||
* sie fragte nur nach der Verbindung. Zwei Rechnungen fuer dieselbe
|
||
* Frage laufen auseinander; jetzt gibt es nur noch eine. */
|
||
export function siehtZu(personId) {
|
||
const id = Number(personId);
|
||
if (!(zuschauer.get(id) || []).size) return false;
|
||
const wann = sichtbarSeit.get(id);
|
||
return !!wann && (Date.now() - wann) < SICHT_FRIST;
|
||
}
|
||
|
||
/* =====================================================================
|
||
DER KANAL FUER ANDERE MODULE (28.09.2026)
|
||
|
||
Der Ereignisstrom liegt beim Chat, weil er dort entstanden ist --
|
||
aber er ist laengst der einzige offene Draht zwischen Server und
|
||
Browser. `chatEreignis()` schickt an die Teilnehmer eines CHATRAUMS;
|
||
wer keinen Raum hat, kommt damit nicht weiter.
|
||
|
||
Die Reaction hat keinen Chatraum. Sie hat einen Kreis von Leuten,
|
||
der sich jede Minute aendert -- wer gerade zusieht. Genau dafuer
|
||
ist das hier: Empfaenger werden uebergeben, nicht nachgeschlagen.
|
||
|
||
EIN ZWEITER STROM WAERE DER FALSCHE WEG. Jeder Browser haelt dann
|
||
zwei offene Verbindungen, jede mit eigenem Puls, eigenem
|
||
Wiederverbinden und eigener Luecke danach. Und Anker: Manche
|
||
Browser lassen nur sechs gleichzeitige Verbindungen je Adresse zu --
|
||
zwei davon fuer Ereignisse wegzugeben, ist teuer.
|
||
===================================================================== */
|
||
export function stromAn(personIds, art, daten) {
|
||
let erreicht = 0;
|
||
for (const roh of personIds) {
|
||
const id = Number(roh);
|
||
for (const antwort of zuschauer.get(id) || []) {
|
||
try {
|
||
antwort.write(`event: ${art}
|
||
data: ${JSON.stringify(daten)}
|
||
|
||
`);
|
||
erreicht++;
|
||
} catch { /* die Verbindung raeumt sich selbst ab */ }
|
||
}
|
||
}
|
||
return erreicht;
|
||
}
|