Kategorie-Kanaele und angeheftete Ankuendigungen (Kapitel 7.2)
Die vier Saetze aus dem Anforderungsdokument, der Reihe nach: Team- Gruppenchat, Kategorie-Kanaele mit Zugriff fuer Owner und rechte Hand, private 1:1-Chats OHNE diesen Zugriff, und Pin-Nachrichten an alle. EIN KANAL IST KEINE VIERTE TABELLE, sondern eine dritte Art Raum (`art = 'kanal'` neben 'direkt' und 'gruppe'). Damit gilt fuer ihn ohne eine einzige neue Zeile alles, was schon da ist: Verlauf, Anhaenge, Suche, Ungelesen-Zaehler, Live-Strom, Wegraeumen. Eine eigene Tabelle haette all das ein zweites Mal gebraucht -- und die zweite Fassung waere die gewesen, in der die Zugriffsregel fehlt. Welche Zustaendigkeit, kommt aus MODI_KATEGORIEN -- derselben Liste, aus der auch die Aufgaben ihre Kategorie nehmen. Der NAME kommt aus der Kategorie und ist kein freies Feld: "Clipping" neben "Clipping-Team" waeren zwei halbe Verlaeufe, und man merkt es erst, wenn jemand die Antwort im falschen sucht. Ein eindeutiger Teilindex haelt das auch dann fest, wenn zwei Anfragen im selben Augenblick ankommen. DIE ZUGRIFFSREGEL STEHT IN istDrin() -- der Funktion, durch die alle sieben lesenden und schreibenden Wege gehen. In den Routen stuende sie in sechs davon und in der siebten nicht. Sie gilt AUSDRUECKLICH nur fuer 'kanal': Zweier-Gespraeche bleiben zu, auch fuer DogFather (so steht es im Dokument), und Gruppen ebenfalls -- 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. Wenn Filipe das anders will, ist es eine Zeile -- aber es waere seine Entscheidung und muesste fuer die Beteiligten SICHTBAR sein. istDrin() nimmt dafuer die PERSON statt ihrer Nummer und wirft bei einer Nummer einen Fehler, statt stillschweigend "nein" zu antworten. HOECHSTENS DREI ANKUENDIGUNGEN je Raum. Nicht eine (Regeln, Live-Plan und Frist muessen gleichzeitig oben stehen koennen) und nicht beliebig viele -- eine Pinnwand, die scrollt, ist ein zweiter Verlauf. Der Aushang hat eine EIGENE Abfrage, weil der Verlauf nur 200 Zeilen liefert: Eine Ansage von vorletzter Woche waere sonst genau dann weg, wenn sie am laengsten oben stehen sollte. Wird die Nachricht zurueckgenommen, faellt sie ab UND gibt den Platz frei. DREI DINGE, DIE ERST DAS HINSEHEN GEZEIGT HAT: Der frisch angelegte Kanal hatte zwei Leute statt vier. Die Personenauswahl zeichnete nach der Rollenfolge aus bereiche.js -- wer dort nicht steht, wurde NICHT GEZEICHNET. Team Dogi steht dort nicht und darf es auch nicht (der Rollenname gehoert in keine Datei, die jeder herunterlaedt). Folge: DogFather konnte ueber die Auswahl niemandem aus seinem Team schreiben. Der Server schickt die Ueberschrift jetzt mit; der Browser braucht dafuer keinen Rollennamen. Der Kommentar, der genau davor warnte, stand die ganze Zeit darueber. Dieser Fehler war nebenbei ein Netz: Was nicht gezeichnet wird, kann auch nicht falsch gezeichnet werden. Deshalb ist jetzt gemessen, dass ein Manager und Spicy Media Team Dogi gar nicht erst geschickt bekommen -- und dabei fiel auf, dass Spicy Media in der EIGENEN Auswahl stand: Sobald es jemanden zu verbergen gibt, schreibt ohneVerborgene() aus "sieht alles" eine echte Liste, und darin steckt man selbst. Am Fuss jeder Nachricht stehen jetzt vier Handgriffe statt drei. Bei 390 px -- der haeufigsten Handybreite -- stand "kopieren" 18 px ueber der Blase, bei 320 px 75. Behoben mit `flex-wrap: wrap` und nicht mit einer Schwelle: Eine Regel, die misst, bleibt beim fuenften Handgriff richtig; eine Zahl nicht. pruef-chat-optik misst es ab jetzt. NACHGEZOGEN, was pruef-css-klassen an MEINEM letzten Commit fand: teamlage.html lud kopf.js ohne wahl.js (der Sicht-Umschalter sah aus wie aus einem anderen Programm -- kaputt war nichts, und genau deshalb faellt es niemandem auf), und .tampel__tag stand auf 11,2 px. Beides war schon gepusht, weil ich die Pruefung nicht laufen liess. pruef-chat-kanaele 73 (neu) · pruef-chat 48 · pruef-chat-ausbau 64 · pruef-chat-anhaenge 60 · pruef-chat-optik 24 -> 26 · pruef-css-klassen gruen · pruef-rollen 274 · pruef-modi-verborgen 78 · pruef-team-ampel 28 · pruef-start-ansicht gruen · pruef-modi-wortleck 5 · pruef-zwischenspeicher 21. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+448
-16
@@ -44,6 +44,7 @@ import { mkdirSync, writeFileSync, createReadStream, statSync, unlinkSync } from
|
||||
import {
|
||||
db, protokolliere, echteIp, sitzungLesen, istDogFather,
|
||||
schreibbareIds, darfSchreibenMit, ROLLEN_SORTIERUNG, DATEN_ORDNER,
|
||||
fuehrtTeamDogi, MODI_KATEGORIEN, ROLLEN_GRUPPE,
|
||||
} from "./workspace.js";
|
||||
|
||||
export const chatRouter = express.Router();
|
||||
@@ -95,12 +96,42 @@ chatRouter.use("/workspace/api/chat", angemeldet);
|
||||
* DIE WICHTIGSTE FUNKTION DER DATEI. Jede Route, die einen Verlauf
|
||||
* liest oder etwas hineinschreibt, geht hier durch. Wer sie umgeht,
|
||||
* macht aus einer Raumnummer einen Schlüssel. */
|
||||
function istDrin(raumId, personId) {
|
||||
/* 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, personId);
|
||||
return !!z;
|
||||
.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; }
|
||||
}
|
||||
|
||||
@@ -286,7 +317,86 @@ function geloeschtBis(raumId, personId) {
|
||||
* Ein Zweier-Gespräch heißt für jede Seite anders -- nach dem
|
||||
* Gegenüber. Deshalb steht der Name nicht in der Tabelle: Er wäre für
|
||||
* eine der beiden Seiten immer falsch. */
|
||||
/* =====================================================================
|
||||
KATEGORIE-KANAELE (10.09.2026, Kapitel 7.2)
|
||||
===================================================================== */
|
||||
|
||||
/** Hoechstens drei Ankuendigungen je Raum.
|
||||
*
|
||||
* Nicht eine (dann kann man Regeln, Live-Plan und Frist nicht
|
||||
* gleichzeitig oben haben) und nicht beliebig viele: Eine Pinnwand,
|
||||
* die scrollt, ist keine Pinnwand mehr, sondern ein zweiter Verlauf.
|
||||
* Wer die vierte anheften will, muss sich fuer eine entscheiden --
|
||||
* und genau diese Entscheidung ist der Sinn der Funktion. */
|
||||
const PIN_MAX = 3;
|
||||
|
||||
/** Der Name eines Kanals kommt aus der Kategorie, nicht aus einem Feld.
|
||||
*
|
||||
* Ein freies Namensfeld waere die naheliegende Freiheit -- und der
|
||||
* sichere Weg zu "Clipping" neben "Clipping-Team", mit je der Haelfte
|
||||
* der Nachrichten darin. Wer die Kategorie umbenennt, benennt damit
|
||||
* jeden Kanal um; sie stehen nicht doppelt. */
|
||||
function kanalName(kategorie) {
|
||||
return MODI_KATEGORIEN.find((k) => k.wert === kategorie)?.name || "Kanal";
|
||||
}
|
||||
|
||||
/** Wer darf hier etwas anheften?
|
||||
*
|
||||
* Die Teamfuehrung ueberall -- so steht es im Dokument. UND wer eine
|
||||
* Gruppe angelegt hat, in seiner eigenen: Der Chat gehoert nicht nur
|
||||
* Team Dogi, auch Manager und Scouts fuehren Gruppen. Waere das
|
||||
* Anheften allein der Teamfuehrung vorbehalten, gaebe es die Funktion
|
||||
* fuer die Haelfte des Hauses nicht -- und sie waere trotzdem
|
||||
* sichtbar. */
|
||||
function darfAnheften(person, raumId) {
|
||||
if (fuehrtTeamDogi(person)) return true;
|
||||
try {
|
||||
return !!db().prepare(`SELECT 1 FROM chat_teilnehmer
|
||||
WHERE raum_id = ? AND person_id = ? AND raus_am IS NULL AND leitung = 1`)
|
||||
.get(raumId, person.id);
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/** Traegt die Teamfuehrung in jeden Kanal ein, in dem sie fehlt.
|
||||
*
|
||||
* WARUM SELBSTHEILEND UND NICHT AN DREI HAKEN: Die Zeile muesste sonst
|
||||
* beim Anlegen eines Kanals, beim Anlegen einer Person und beim
|
||||
* Rollenwechsel entstehen -- drei Stellen, von denen die dritte beim
|
||||
* naechsten Umbau vergessen wird. Hier laeuft es genau dann, wenn es
|
||||
* gebraucht wird: beim Blick in die Gespraechsliste, und nur fuer
|
||||
* zwei Rollen.
|
||||
*
|
||||
* `gelesen_bis` STARTET AM AKTUELLEN STAND, nicht bei null. Wer heute
|
||||
* dazukommt, hat die vierhundert Nachrichten von vorher nicht
|
||||
* verpasst -- eine rote Vierhundert waere keine Auskunft, sondern
|
||||
* Rauschen, und man schaltet sie ab statt sie zu lesen. */
|
||||
function kanaeleAngleichen(person) {
|
||||
if (!fuehrtTeamDogi(person)) return 0;
|
||||
try {
|
||||
const d = db();
|
||||
const offen = d.prepare(`
|
||||
SELECT r.id FROM chat_raeume r
|
||||
WHERE r.art = 'kanal'
|
||||
AND NOT EXISTS (SELECT 1 FROM chat_teilnehmer t
|
||||
WHERE t.raum_id = r.id AND t.person_id = ?)`).all(person.id);
|
||||
if (!offen.length) return 0;
|
||||
const rein = d.prepare(`
|
||||
INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis, leitung)
|
||||
SELECT ?, ?, ?, COALESCE((SELECT MAX(id) FROM chat_nachrichten WHERE raum_id = ?), 0), 1`);
|
||||
const n = jetzt();
|
||||
for (const r of offen) rein.run(r.id, person.id, n, r.id);
|
||||
return offen.length;
|
||||
} catch (fehler) {
|
||||
/* Ein Fehler hier darf die Gespraechsliste nicht kosten. Der
|
||||
Zugriff selbst haengt ohnehin nicht daran -- istDrin() laesst die
|
||||
Teamfuehrung auch ohne Zeile in einen Kanal. */
|
||||
console.error("[chat] Kanal-Abgleich:", fehler?.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function raumName(raum, teilnehmer, ichId) {
|
||||
if (raum.art === "kanal") return raum.name || kanalName(raum.kategorie);
|
||||
if (raum.art === "gruppe") return raum.name || "Gruppe";
|
||||
const andere = teilnehmer.filter((t) => t.id !== ichId);
|
||||
return andere.map((t) => t.name).join(", ") || "Gespräch";
|
||||
@@ -305,10 +415,19 @@ chatRouter.get("/workspace/api/chat/partner", (req, res) => {
|
||||
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),
|
||||
ORDER BY ` + ROLLEN_SORTIERUNG + ", name").all(...werte)
|
||||
.map((p) => ({ ...p, gruppe: ROLLEN_GRUPPE[p.rolle] || "Weitere" })),
|
||||
});
|
||||
} catch (fehler) {
|
||||
console.error("[chat] Partner:", fehler?.message);
|
||||
@@ -330,8 +449,16 @@ chatRouter.get("/workspace/api/chat/raeume", (req, res) => {
|
||||
mit dem Neuen (id > geloescht_bis).
|
||||
* Es kam nichts -> es bleibt weg. Auch dann, wenn es nie eine
|
||||
Nachricht gab; dafür steht geloescht_am. */
|
||||
/* ERST ANGLEICHEN, DANN LESEN. Ein Kanal, in dem die Teamfuehrung
|
||||
keine Zeile hat, waere ihr zugaenglich (istDrin laesst sie
|
||||
hinein) und stuende trotzdem nicht in ihrer Liste -- erreichbar
|
||||
nur ueber einen Link, den ihr jemand schickt. Das ist genau die
|
||||
Sorte Loch, die man nicht bemerkt, weil nichts kaputt aussieht. */
|
||||
kanaeleAngleichen(req.person);
|
||||
|
||||
const reihen = db().prepare(`
|
||||
SELECT r.id, r.art, r.name, r.letzte_am, t.gelesen_bis, t.leitung, t.geloescht_bis
|
||||
SELECT r.id, r.art, r.name, r.kategorie, r.letzte_am,
|
||||
t.gelesen_bis, t.leitung, t.geloescht_bis
|
||||
FROM chat_raeume r
|
||||
JOIN chat_teilnehmer t ON t.raum_id = r.id AND t.person_id = ?
|
||||
WHERE t.raus_am IS NULL
|
||||
@@ -361,6 +488,7 @@ chatRouter.get("/workspace/api/chat/raeume", (req, res) => {
|
||||
return {
|
||||
id: r.id,
|
||||
art: r.art,
|
||||
kategorie: r.kategorie || null,
|
||||
name: raumName(r, leute, req.person.id),
|
||||
leitung: !!r.leitung,
|
||||
teilnehmer: leute.map((t) => ({ id: t.id, name: t.name, rolle: t.rolle })),
|
||||
@@ -378,7 +506,26 @@ chatRouter.get("/workspace/api/chat/raeume", (req, res) => {
|
||||
ungelesen,
|
||||
};
|
||||
});
|
||||
res.json({ raeume });
|
||||
/* WELCHE KANAELE ES NOCH NICHT GIBT -- und nur fuer die, die sie
|
||||
anlegen duerfen.
|
||||
|
||||
Die Liste steht bewusst NICHT in einer Datei im Browser. Sie
|
||||
verraet fuer sich genommen nichts Schlimmes, aber sie ist der
|
||||
Anfang einer Aufzaehlung dessen, was das Team tut; und die
|
||||
Oberflaeche zeigt den Knopf ohnehin nur, wenn hier etwas
|
||||
ankommt. Wer nichts anlegen darf, bekommt ein leeres Feld -- und
|
||||
ein leeres Feld sagt nichts. */
|
||||
const darfKanal = fuehrtTeamDogi(req.person);
|
||||
const belegt = darfKanal
|
||||
? new Set(db().prepare("SELECT kategorie FROM chat_raeume WHERE art = 'kanal'")
|
||||
.all().map((z) => z.kategorie))
|
||||
: new Set();
|
||||
|
||||
res.json({
|
||||
raeume,
|
||||
darf_kanal: darfKanal,
|
||||
kanal_frei: darfKanal ? MODI_KATEGORIEN.filter((k) => !belegt.has(k.wert)) : [],
|
||||
});
|
||||
} catch (fehler) {
|
||||
console.error("[chat] Räume:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
@@ -449,6 +596,173 @@ chatRouter.post("/workspace/api/chat/gruppe", gleicheHerkunft, express.json({ li
|
||||
}
|
||||
});
|
||||
|
||||
/* =====================================================================
|
||||
EINEN KANAL ANLEGEN (10.09.2026, Kapitel 7.2)
|
||||
|
||||
Kein Name im Rumpf, nur die Kategorie -- siehe kanalName(). Und keine
|
||||
Pflicht, gleich jemanden hineinzusetzen: Ein Kanal, den DogFather
|
||||
morgens anlegt und mittags fuellt, ist ein normaler Vorgang; ihn
|
||||
dafuer zweimal aufmachen zu muessen waere eine Regel ohne Zweck.
|
||||
===================================================================== */
|
||||
chatRouter.post("/workspace/api/chat/kanal", gleicheHerkunft, express.json({ limit: "8kb" }), (req, res) => {
|
||||
try {
|
||||
/* 404 und nicht 403: Wer keine Kanaele fuehren darf, soll auch
|
||||
nicht erfahren, dass es die Moeglichkeit gibt. */
|
||||
if (!fuehrtTeamDogi(req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
|
||||
const kategorie = String(req.body?.kategorie ?? "").trim();
|
||||
if (!MODI_KATEGORIEN.some((k) => k.wert === kategorie)) {
|
||||
return res.status(400).json({ fehler: "Unbekannte Kategorie." });
|
||||
}
|
||||
|
||||
const d = db();
|
||||
/* Die Regel steht hier UND als eindeutiger Index in der Datenbank.
|
||||
Doppelt gemoppelt ist es nicht: Hier gibt es eine Meldung, die
|
||||
jemand lesen kann, und dort eine Zusage, die auch dann gilt, wenn
|
||||
einmal zwei Anfragen im selben Augenblick ankommen. */
|
||||
const schon = d.prepare("SELECT id FROM chat_raeume WHERE art = 'kanal' AND kategorie = ?")
|
||||
.get(kategorie);
|
||||
if (schon) {
|
||||
return res.status(409).json({ fehler: "Diesen Kanal gibt es schon.", raum_id: schon.id });
|
||||
}
|
||||
|
||||
const roh = Array.isArray(req.body?.mit) ? req.body.mit : [];
|
||||
const mit = [...new Set(roh.map(Number).filter((n) => Number.isInteger(n) && n > 0))]
|
||||
.filter((id) => id !== req.person.id);
|
||||
/* Jede Nummer einzeln gegen dieselbe Regel wie bei einer Gruppe.
|
||||
Ein Kanal ist kein Schlupfloch, um jemanden zu erreichen, den man
|
||||
einzeln nicht anschreiben duerfte. */
|
||||
if (mit.some((id) => !darfSchreibenMit(req.person, id))) {
|
||||
return res.status(403).json({ fehler: "Mit mindestens einer Person kannst du nicht schreiben." });
|
||||
}
|
||||
|
||||
const n = jetzt();
|
||||
d.prepare(`INSERT INTO chat_raeume (art, name, kategorie, erstellt, erstellt_von)
|
||||
VALUES ('kanal',?,?,?,?)`)
|
||||
.run(kanalName(kategorie), kategorie, n, req.person.id);
|
||||
const raumId = d.prepare("SELECT last_insert_rowid() AS id").get().id;
|
||||
|
||||
/* DIE GANZE TEAMFUEHRUNG KOMMT HINEIN, nicht nur der Anlegende.
|
||||
kanaeleAngleichen() wuerde das beim naechsten Blick in die Liste
|
||||
ohnehin nachholen -- aber "beim naechsten Blick" heisst: Die
|
||||
rechte Hand sieht den Kanal erst, wenn sie die Seite neu laedt.
|
||||
Hier ist er sofort da. */
|
||||
const fuehrung = d.prepare(
|
||||
"SELECT id FROM personen WHERE aktiv = 1 AND rolle IN ('admin','hand')").all().map((z) => z.id);
|
||||
const reinLeitung = d.prepare(
|
||||
"INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis, leitung) VALUES (?,?,?,0,1)");
|
||||
for (const id of new Set([req.person.id, ...fuehrung])) reinLeitung.run(raumId, id, n);
|
||||
|
||||
const rein = d.prepare(
|
||||
"INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis) VALUES (?,?,?,0)");
|
||||
let drin = 0;
|
||||
for (const id of mit) {
|
||||
if (fuehrung.includes(id)) continue; /* steht schon als Leitung drin */
|
||||
if (d.prepare("SELECT 1 FROM personen WHERE id = ? AND aktiv = 1").get(id)) {
|
||||
rein.run(raumId, id, n); drin++;
|
||||
}
|
||||
}
|
||||
|
||||
protokolliere("chat_kanal_angelegt", {
|
||||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||||
detail: `#${raumId} ${kategorie} (${drin})`.slice(0, 120),
|
||||
});
|
||||
res.status(201).json({ raum_id: raumId, name: kanalName(kategorie) });
|
||||
} catch (fehler) {
|
||||
console.error("[chat] Kanal:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/* =====================================================================
|
||||
WER IST IM KANAL (10.09.2026)
|
||||
|
||||
Ohne diese Route waere ein Kanal beim Anlegen fuer immer festgelegt:
|
||||
Ein neuer Modi koennte nie in "Clipping" -- der Kanal waere richtig
|
||||
gebaut und trotzdem unbrauchbar.
|
||||
|
||||
ENTFERNT WIRD MIT `raus_am`, nicht mit DELETE. Wer draussen ist,
|
||||
schreibt und liest nichts Neues mehr; der Verlauf bis dahin bleibt
|
||||
aber lesbar, und niemand verschwindet rueckwirkend aus der
|
||||
Geschichte. Dieselbe Entscheidung wie bei den Gruppen.
|
||||
===================================================================== */
|
||||
chatRouter.put("/workspace/api/chat/kanal/:id/mitglieder", gleicheHerkunft,
|
||||
express.json({ limit: "8kb" }), (req, res) => {
|
||||
try {
|
||||
if (!fuehrtTeamDogi(req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
const raumId = Number(req.params.id);
|
||||
if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" });
|
||||
|
||||
const d = db();
|
||||
const raum = d.prepare("SELECT id, art FROM chat_raeume WHERE id = ?").get(raumId);
|
||||
/* Ausdruecklich nur Kanaele. Diese Route auf Gruppen loszulassen
|
||||
hiesse, dass die Teamfuehrung fremde Gruppen umbesetzen kann --
|
||||
etwas voellig anderes, und niemand hat darum gebeten. */
|
||||
if (!raum || raum.art !== "kanal") return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
|
||||
const roh = Array.isArray(req.body?.mit) ? req.body.mit : [];
|
||||
const soll = new Set(roh.map(Number).filter((n) => Number.isInteger(n) && n > 0));
|
||||
if ([...soll].some((id) => !darfSchreibenMit(req.person, id))) {
|
||||
return res.status(403).json({ fehler: "Mit mindestens einer Person kannst du nicht schreiben." });
|
||||
}
|
||||
|
||||
/* Die Teamfuehrung bleibt unangetastet -- sie hat laut Kapitel 7.2
|
||||
Zugriff auf alle Kanaele. Sie aus der Liste entfernen zu koennen
|
||||
waere eine Tuer, die man hinter sich zuzieht: istDrin() liesse
|
||||
sie zwar weiter hinein, in der Gespraechsliste stuende der Kanal
|
||||
aber nicht mehr. */
|
||||
const fuehrung = new Set(d.prepare(
|
||||
"SELECT id FROM personen WHERE rolle IN ('admin','hand')").all().map((z) => z.id));
|
||||
const ist = d.prepare(
|
||||
"SELECT person_id FROM chat_teilnehmer WHERE raum_id = ? AND raus_am IS NULL")
|
||||
.all(raumId).map((z) => z.person_id).filter((id) => !fuehrung.has(id));
|
||||
|
||||
const n = jetzt();
|
||||
/* WER HEUTE DAZUKOMMT, HAT NICHTS VERPASST. Ohne diesen Stand
|
||||
staende beim ersten Oeffnen eine rote Zahl ueber allem, was das
|
||||
Team je in diesem Kanal geschrieben hat -- lesbar ist es
|
||||
weiterhin, ungelesen war es nie. */
|
||||
const hoechste = d.prepare(
|
||||
"SELECT COALESCE(MAX(id), 0) AS m FROM chat_nachrichten WHERE raum_id = ?").get(raumId).m;
|
||||
|
||||
let dazu = 0, raus = 0;
|
||||
for (const id of soll) {
|
||||
if (fuehrung.has(id) || ist.includes(id)) continue;
|
||||
if (!d.prepare("SELECT 1 FROM personen WHERE id = ? AND aktiv = 1").get(id)) continue;
|
||||
/* Zurueckgeholt oder neu -- eine Zeile kann es schon geben (mit
|
||||
raus_am). Ein blankes INSERT liefe dann auf den
|
||||
Primaerschluessel auf. */
|
||||
const wieder = d.prepare(
|
||||
"SELECT 1 FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?").get(raumId, id);
|
||||
if (wieder) {
|
||||
d.prepare("UPDATE chat_teilnehmer SET raus_am = NULL, seit = ? WHERE raum_id = ? AND person_id = ?")
|
||||
.run(n, raumId, id);
|
||||
} else {
|
||||
d.prepare(`INSERT INTO chat_teilnehmer (raum_id, person_id, seit, gelesen_bis)
|
||||
VALUES (?,?,?,?)`).run(raumId, id, n, hoechste);
|
||||
}
|
||||
dazu++;
|
||||
}
|
||||
for (const id of ist) {
|
||||
if (soll.has(id)) continue;
|
||||
d.prepare("UPDATE chat_teilnehmer SET raus_am = ? WHERE raum_id = ? AND person_id = ?")
|
||||
.run(n, raumId, id);
|
||||
raus++;
|
||||
}
|
||||
|
||||
if (dazu || raus) {
|
||||
protokolliere("chat_kanal_besetzung", {
|
||||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||||
detail: `#${raumId} +${dazu} -${raus}`,
|
||||
});
|
||||
}
|
||||
res.json({ ok: true, dazu, raus, teilnehmer: teilnehmerVon(raumId).map((t) => t.id) });
|
||||
} catch (fehler) {
|
||||
console.error("[chat] Kanal-Besetzung:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/** Der Verlauf eines Gesprächs. */
|
||||
chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
|
||||
try {
|
||||
@@ -457,7 +771,7 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
|
||||
/* Was man nicht sehen darf, existiert nicht -- 404 statt 403,
|
||||
sonst liesse sich durch Ausprobieren herausfinden, welche
|
||||
Gespräche es gibt. */
|
||||
if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
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
|
||||
@@ -478,7 +792,7 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
|
||||
const reihen = db().prepare(`
|
||||
SELECT n.id, n.person_id, n.text, n.erstellt, n.weg_am, p.name AS von, p.rolle,
|
||||
p.bild AS von_bild,
|
||||
n.antwort_auf,
|
||||
n.antwort_auf, n.angeheftet_am,
|
||||
n.anhang_name, n.anhang_art, n.anhang_typ, n.anhang_groesse,
|
||||
n.anhang_breite, n.anhang_hoehe,
|
||||
a.text AS zitat_text, a.weg_am AS zitat_weg, ap.name AS zitat_von,
|
||||
@@ -500,17 +814,53 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
|
||||
"SELECT gelesen_bis FROM chat_teilnehmer WHERE raum_id = ? AND person_id = ?")
|
||||
.get(raumId, req.person.id)?.gelesen_bis ?? 0;
|
||||
|
||||
const raum = db().prepare("SELECT id, art, name FROM chat_raeume WHERE id = ?").get(raumId);
|
||||
const raum = db().prepare(
|
||||
"SELECT id, art, name, kategorie FROM chat_raeume WHERE id = ?").get(raumId);
|
||||
const leute = teilnehmerVon(raumId);
|
||||
|
||||
/* DIE ANGEHEFTETEN, in einer eigenen Abfrage.
|
||||
|
||||
Sie MUESSEN getrennt geholt werden: Der Verlauf oben liefert nur
|
||||
die letzten 200 Zeilen. Eine Ankuendigung von vorletzter Woche
|
||||
faellt da heraus -- und waere damit ausgerechnet dann verschwunden,
|
||||
wenn sie am laengsten oben stehen sollte.
|
||||
|
||||
DIESELBEN ZWEI GRENZEN WIE DER VERLAUF: `n.id > weg` (was ich
|
||||
weggeraeumt habe, kommt nicht ueber die Hintertuer zurueck) und
|
||||
`weg_am IS NULL` (eine zurueckgenommene Nachricht bleibt
|
||||
zurueckgenommen, auch als Aushang). */
|
||||
const weggeraeumt = weg;
|
||||
const angeheftet = db().prepare(`
|
||||
SELECT n.id, n.text, n.erstellt, n.angeheftet_am, n.anhang_art,
|
||||
p.name AS von, q.name AS heftete
|
||||
FROM chat_nachrichten n
|
||||
LEFT JOIN personen p ON p.id = n.person_id
|
||||
LEFT JOIN personen q ON q.id = n.angeheftet_von
|
||||
WHERE n.raum_id = ? AND n.angeheftet_am IS NOT NULL
|
||||
AND n.weg_am IS NULL AND n.id > ?
|
||||
ORDER BY n.angeheftet_am DESC LIMIT ?`).all(raumId, weggeraeumt, PIN_MAX);
|
||||
|
||||
res.json({
|
||||
raum: {
|
||||
id: raum.id, art: raum.art,
|
||||
id: raum.id, art: raum.art, kategorie: raum.kategorie || null,
|
||||
name: raumName(raum, leute, req.person.id),
|
||||
teilnehmer: leute.map((t) => ({ id: t.id, name: t.name, rolle: t.rolle,
|
||||
bild: t.bild, leitung: !!t.leitung })),
|
||||
},
|
||||
gelesen_bis: gelesenBis,
|
||||
/* OB DER KNOPF UEBERHAUPT ERSCHEINT, entscheidet der Server.
|
||||
Die Oberflaeche koennte `rolle === 'admin'` vergleichen -- aber
|
||||
assets/js/chat.js bekommt jeder, der die Seite oeffnet. Ein
|
||||
Ja/Nein verraet nichts ueber die Rollen dahinter. */
|
||||
darf_anheften: darfAnheften(req.person, raumId),
|
||||
angeheftet: angeheftet.map((a) => ({
|
||||
id: a.id,
|
||||
von: a.von || "Gelöscht",
|
||||
heftete: a.heftete || null,
|
||||
seit: a.angeheftet_am,
|
||||
text: String(a.text || "").slice(0, 200)
|
||||
|| (a.anhang_art === "bild" ? "Foto" : a.anhang_art === "pdf" ? "PDF" : ""),
|
||||
})),
|
||||
/* Aufsteigend zurück -- gelesen wird von oben nach unten. */
|
||||
nachrichten: reihen.reverse().map((n) => ({
|
||||
id: n.id, von_id: n.person_id, von: n.von || "Gelöscht", rolle: n.rolle,
|
||||
@@ -519,6 +869,7 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
|
||||
zurueckgenommen: !!n.weg_am,
|
||||
erstellt: n.erstellt,
|
||||
selbst: n.person_id === req.person.id,
|
||||
angeheftet: !!n.angeheftet_am,
|
||||
/* Das Zitat wird GEKUERZT geschickt, nicht ganz: Es ist ein
|
||||
Hinweis auf die Stelle, nicht ihre Wiederholung. Und eine
|
||||
zurueckgenommene Nachricht bleibt auch im Zitat
|
||||
@@ -555,7 +906,7 @@ chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft,
|
||||
try {
|
||||
const raumId = Number(req.params.id);
|
||||
if (!Number.isInteger(raumId)) return res.status(400).json({ fehler: "ungueltig" });
|
||||
if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
if (!istDrin(raumId, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
|
||||
const text = String(req.body?.text ?? "").trim();
|
||||
if (!text) return res.status(400).json({ fehler: "leer" });
|
||||
@@ -640,7 +991,7 @@ chatRouter.post("/workspace/api/chat/raeume/:id/nachrichten", gleicheHerkunft,
|
||||
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.id)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
if (!istDrin(raumId, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
next();
|
||||
}
|
||||
|
||||
@@ -733,7 +1084,7 @@ chatRouter.get("/workspace/api/chat/anhang/:id", (req, res) => {
|
||||
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.id)) 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" });
|
||||
}
|
||||
@@ -788,7 +1139,7 @@ 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.id)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
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 = ?")
|
||||
@@ -904,7 +1255,7 @@ chatRouter.put("/workspace/api/chat/raeume/:id/gelesen", gleicheHerkunft,
|
||||
if (!Number.isInteger(raumId) || !Number.isInteger(bis)) {
|
||||
return res.status(400).json({ fehler: "ungueltig" });
|
||||
}
|
||||
if (!istDrin(raumId, req.person.id)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
if (!istDrin(raumId, req.person)) return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
/* NUR VORWÄRTS. Ein zu spät eintreffender Aufruf einer älteren
|
||||
Ansicht darf den Stand nicht zurückdrehen -- sonst stünde eine
|
||||
gelesene Nachricht wieder als ungelesen da. */
|
||||
@@ -918,6 +1269,80 @@ chatRouter.put("/workspace/api/chat/raeume/:id/gelesen", gleicheHerkunft,
|
||||
}
|
||||
});
|
||||
|
||||
/* =====================================================================
|
||||
ANKUENDIGUNGEN ANHEFTEN (10.09.2026, Kapitel 7.2)
|
||||
|
||||
"Ankuendigungs-Funktion: Pin-Nachrichten von Owner/rechte Hand an
|
||||
alle."
|
||||
|
||||
KEINE ZWEITE SORTE NACHRICHT. Naheliegend waere eine eigene Tabelle
|
||||
"ankuendigungen" gewesen -- und damit ein zweiter Verlauf mit
|
||||
eigenen Rechten, eigener Suche, eigenem Loeschen. Angeheftet wird
|
||||
deshalb eine ganz normale Nachricht: Sie steht im Verlauf, sie ist
|
||||
ueber die Suche zu finden, sie laesst sich zuruecknehmen, und sie
|
||||
traegt zusaetzlich ein Datum und einen Namen.
|
||||
|
||||
WAS PASSIERT, WENN DER ABSENDER SIE ZURUECKNIMMT: Sie faellt von
|
||||
selbst ab (siehe unten im Zuruecknehmen). Ein Aushang, dessen Text
|
||||
"Nachricht zurueckgenommen" lautet, waere die schlechteste aller
|
||||
Antworten -- er steht oben, faellt auf und sagt nichts.
|
||||
===================================================================== */
|
||||
chatRouter.put("/workspace/api/chat/nachrichten/:id/anheften", gleicheHerkunft,
|
||||
express.json({ limit: "2kb" }), (req, res) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
|
||||
|
||||
const d = db();
|
||||
const n = d.prepare(
|
||||
"SELECT id, raum_id, weg_am, angeheftet_am FROM chat_nachrichten WHERE id = ?").get(id);
|
||||
/* Erst die Mitgliedschaft (404 -- was man nicht sehen darf,
|
||||
existiert nicht), dann das Recht (403 -- man sieht es, darf es
|
||||
aber nicht). Zwei verschiedene Antworten fuer zwei verschiedene
|
||||
Faelle; sie zu vermischen hiesse, ueber die eine die andere
|
||||
verraten zu koennen. */
|
||||
if (!n || !istDrin(n.raum_id, req.person)) {
|
||||
return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
}
|
||||
if (!darfAnheften(req.person, n.raum_id)) {
|
||||
return res.status(403).json({ fehler: "Anheften darf nur die Leitung." });
|
||||
}
|
||||
|
||||
const an = req.body?.an !== false;
|
||||
if (an && n.weg_am) {
|
||||
return res.status(400).json({ fehler: "Eine zurückgenommene Nachricht geht nicht." });
|
||||
}
|
||||
|
||||
if (an && !n.angeheftet_am) {
|
||||
const schon = d.prepare(`SELECT COUNT(*) AS c FROM chat_nachrichten
|
||||
WHERE raum_id = ? AND angeheftet_am IS NOT NULL AND weg_am IS NULL`).get(n.raum_id).c;
|
||||
if (schon >= PIN_MAX) {
|
||||
return res.status(409).json({
|
||||
fehler: `Es hängen schon ${PIN_MAX} oben. Löse zuerst eine ab.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
d.prepare("UPDATE chat_nachrichten SET angeheftet_am = ?, angeheftet_von = ? WHERE id = ?")
|
||||
.run(an ? jetzt() : null, an ? req.person.id : null, id);
|
||||
|
||||
/* ALLE IM RAUM SEHEN ES SOFORT. Ein Aushang, den die anderen erst
|
||||
beim naechsten Laden bemerken, ist bei einer kurzfristigen
|
||||
Ansage genau einen Live-Abend zu spaet. Keine Push-Meldung
|
||||
dazu: Das Anheften ist kein neuer Satz, sondern eine Betonung. */
|
||||
chatEreignis(n.raum_id, { raum_id: n.raum_id, id, angeheftet: an }, [], "pin");
|
||||
|
||||
protokolliere(an ? "chat_angeheftet" : "chat_abgeheftet", {
|
||||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||||
detail: `#${id} in Raum #${n.raum_id}`,
|
||||
});
|
||||
res.json({ ok: true, angeheftet: an });
|
||||
} catch (fehler) {
|
||||
console.error("[chat] Anheften:", fehler?.message);
|
||||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||||
}
|
||||
});
|
||||
|
||||
/** Eine eigene Nachricht zurücknehmen. */
|
||||
chatRouter.delete("/workspace/api/chat/nachrichten/:id", gleicheHerkunft, (req, res) => {
|
||||
try {
|
||||
@@ -926,7 +1351,7 @@ chatRouter.delete("/workspace/api/chat/nachrichten/:id", gleicheHerkunft, (req,
|
||||
|
||||
const n = db().prepare(
|
||||
"SELECT id, raum_id, person_id, anhang_datei FROM chat_nachrichten WHERE id = ?").get(id);
|
||||
if (!n || !istDrin(n.raum_id, req.person.id)) {
|
||||
if (!n || !istDrin(n.raum_id, req.person)) {
|
||||
return res.status(404).json({ fehler: "nicht_gefunden" });
|
||||
}
|
||||
/* NUR EIGENE. DogFather ebenfalls nicht: Fremde Worte aus einem
|
||||
@@ -945,8 +1370,15 @@ chatRouter.delete("/workspace/api/chat/nachrichten/:id", gleicheHerkunft, (req,
|
||||
einer. Erst die Zeile leeren, dann die Datei -- scheitert das
|
||||
Entfernen (Datei schon weg), ist der Verweis trotzdem fort. */
|
||||
const dateiWeg = n.anhang_datei;
|
||||
/* UND SIE FAELLT VOM AUSHANG (10.09.2026). Ohne diese zwei
|
||||
Spalten stuende die Nachricht weiter oben angeheftet -- mit dem
|
||||
Text "Nachricht zurueckgenommen", an der auffaelligsten Stelle
|
||||
des Raums. Die Anzeige filtert zwar auf `weg_am IS NULL`, aber
|
||||
der Platz waere belegt: PIN_MAX zaehlt mit, und niemand kaeme
|
||||
darauf, warum sich nichts mehr anheften laesst. */
|
||||
db().prepare(`UPDATE chat_nachrichten
|
||||
SET text = '', weg_am = ?,
|
||||
angeheftet_am = NULL, angeheftet_von = NULL,
|
||||
anhang_datei = NULL, anhang_name = NULL, anhang_art = NULL,
|
||||
anhang_typ = NULL, anhang_groesse = NULL,
|
||||
anhang_breite = NULL, anhang_hoehe = NULL
|
||||
|
||||
Reference in New Issue
Block a user