/* ===================================================================== workspace-kalender.js — Termine, Calls und Fristen an einem Ort. Der Kalender zeigt zwei Quellen zusammen: * eigene Termine aus dieser Tabelle * Fristen aus den Aufgaben (nur lesend eingeblendet) Das Konzept verlangt genau das -- "Kalender & Calls" neben "Deadlines" und "Wiedervorlagen" in einer Ansicht, nicht in zwei getrennten Listen. Die Sichtbarkeit folgt derselben Regel wie bei den Aufgaben und ist wieder an einer Stelle definiert. ===================================================================== */ import express from "express"; import { db, protokolliere, echteIp, sitzungLesen, istLeitung, istDogFather, betreutWo, termineSichtbar, heuteLokal, externPruefen, externSql, } from "./workspace.js"; import { nachfuellenAlle, serienPruefen, serieAnlegen, zuordnungErzwingen, } from "./workspace-serien.js"; export const kalenderRouter = express.Router(); const ARTEN = ["termin", "call", "review"]; const TITEL_MAX = 160; const TEXT_MAX = 4000; const ORT_MAX = 400; const jetzt = () => new Date().toISOString(); 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(); } kalenderRouter.use("/workspace/api/termine", angemeldet); /* Wer sieht welchen Termin? Die Regel selbst steht seit dem 02.09.2026 in workspace.js, weil die Wiederholungen (workspace-serien.js) genau dieselbe brauchen -- nur auf einer anderen Tabelle. Zwei fast gleiche Fassungen wären früher oder später auseinandergelaufen, und dann hätte eine Wiederholung jemandem etwas gezeigt, was der einzelne Termin ihm verbirgt. Der Name bleibt hier stehen: workspace-calls.js, -hinweise.js und -suche.js holen ihn von hier. */ export const sichtbar = (person) => termineSichtbar(person, "t"); /* Der Name faellt auf den frei eingetragenen Text zurueck, wenn keine Person verknuepft ist. Beide Namen greifen auf dasselbe Feld zurueck, weil das Formular auch nur EINE Auswahl hat ("Mit wem") und aus ihr beide Spalten fuellt -- eine getrennte Behandlung waere hier eine Unterscheidung ohne Unterschied. */ const SPALTEN = ` t.id, t.titel, t.beschreibung, t.art, t.beginn, t.dauer_min, t.ort, t.creator_id, t.teilnehmer_id, t.erledigt, t.erstellt, t.erstellt_von, t.serie_id, t.serie_tag, t.teilnehmer_extern, ${externSql("pc.name", "t.teilnehmer_extern")} AS creator_name, ${externSql("pt.name", "t.teilnehmer_extern")} AS teilnehmer_name`; const VERBUND = ` FROM termine t LEFT JOIN personen pc ON pc.id = t.creator_id LEFT JOIN personen pt ON pt.id = t.teilnehmer_id`; /* ---------- Lesen ------------------------------------------------------- */ kalenderRouter.get("/workspace/api/termine", (req, res) => { try { /* Wiederkehrende Termine nachlegen, BEVOR gelesen wird -- sonst fehlte im Kalender genau der Termin, für den man ihn öffnet. Der Nachfüller bremst sich selbst (höchstens alle fünf Minuten) und legt nur an, was noch fehlt. */ nachfuellenAlle(); const { wo, werte } = sichtbar(req.sicht || req.person); /* Zeitraum. Ohne Angabe: ab heute 00:00, 90 Tage nach vorn -- das deckt den 90-Tage-Plan aus dem Konzept ab. */ const von = /^\d{4}-\d{2}-\d{2}$/.test(String(req.query.von || "")) /* ORTSZEIT fuer "ab heute": jetzt() ist UTC und lieferte nachts den Vortag -- die Vorgabe begann dann einen Tag zu frueh. Das RECHNEN darunter (von + Tage) bleibt bewusst UTC: Es arbeitet auf reinen Datumstexten und ueberlebt so die Zeitumstellung. */ ? String(req.query.von) : heuteLokal(); const bisTage = Math.min(Math.max(Number(req.query.tage) || 90, 1), 400); const bis = new Date(Date.parse(von + "T00:00:00Z") + bisTage * 86400_000) .toISOString().slice(0, 10); const termine = db().prepare(` SELECT ${SPALTEN} ${VERBUND} WHERE ${wo} AND t.beginn >= ? AND t.beginn < ? ORDER BY t.beginn`).all(...werte, von, bis + "T23:59:59Z"); /* Fristen aus den Aufgaben -- nur zum Anzeigen, nicht bearbeitbar. Die Sichtbarkeitsregel der Aufgaben gilt dabei unveraendert. */ /* HIER STAND istLeitung -- und damit sah JEDE Managerin die Fristen ALLER Aufgaben im Kalender, auch die von Creators, die ihr nie zugeteilt waren. Gefunden bei der Lecksuche am 03.09.2026. Besonders unangenehm, weil es nicht wie ein Datenleck aussieht: Im Kalender steht nur eine kleine orange Marke mit einem Titel. Dass darin fremde Namen und Vorhaben stecken, faellt niemandem auf, der nicht danach sucht. Jetzt gilt dieselbe Regel wie fuer die Termine daneben: eigene plus die der zugeteilten Creator. */ const sicht = req.sicht || req.person; const eigenA = "(a.creator_id = ? OR a.verantwortlich_id = ? OR a.erstellt_von = ?)"; const werteA = [sicht.id, sicht.id, sicht.id]; const bA = betreutWo(sicht, "a.creator_id"); const aufgabenWo = istDogFather(sicht) ? { wo: "1=1", werte: [] } : bA ? { wo: `(${eigenA} OR ${bA.wo})`, werte: [...werteA, ...bA.werte] } : { wo: eigenA, werte: werteA }; const fristen = db().prepare(` SELECT a.id, a.titel, a.frist, a.status, a.prioritaet, pv.name AS verantwortlich_name FROM aufgaben a LEFT JOIN personen pv ON pv.id = a.verantwortlich_id WHERE ${aufgabenWo.wo} AND a.frist IS NOT NULL AND a.status <> 'erledigt' AND a.frist >= ? AND a.frist <= ? ORDER BY a.frist`).all(...aufgabenWo.werte, von, bis); res.json({ termine, fristen, von, bis }); } catch (fehler) { console.error("[workspace] Termine lesen:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /* ---------- Prüfen ------------------------------------------------------- */ function pruefe(körper, { neu }) { const fehler = []; const aus = {}; if (neu || körper.titel !== undefined) { const t = String(körper.titel ?? "").trim(); if (t.length < 2) fehler.push("Titel fehlt."); else if (t.length > TITEL_MAX) fehler.push("Titel ist zu lang."); else aus.titel = t; } if (neu || körper.beginn !== undefined) { const b = String(körper.beginn ?? "").trim(); /* Erwartet wird, was ein liefert. */ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(b) || Number.isNaN(Date.parse(b))) { fehler.push("Zeitpunkt fehlt oder ist ungültig."); } else aus.beginn = b; } if (körper.art !== undefined) { if (!ARTEN.includes(körper.art)) fehler.push("Unbekannte Art."); else aus.art = körper.art; } if (körper.dauer_min !== undefined) { const d = Number(körper.dauer_min); if (!Number.isInteger(d) || d < 5 || d > 24 * 60) fehler.push("Dauer muss zwischen 5 und 1440 Minuten liegen."); else aus.dauer_min = d; } if (körper.ort !== undefined) { const o = String(körper.ort ?? "").trim(); if (o.length > ORT_MAX) fehler.push("Ort/Link ist zu lang."); else aus.ort = o || null; } if (körper.beschreibung !== undefined) { const t = String(körper.beschreibung ?? "").trim(); if (t.length > TEXT_MAX) fehler.push("Beschreibung ist zu lang."); else aus.beschreibung = t || null; } if (körper.erledigt !== undefined) aus.erledigt = körper.erledigt ? 1 : 0; for (const feld of ["creator_id", "teilnehmer_id"]) { if (körper[feld] === undefined) continue; const w = körper[feld]; if (w === null || w === "") { aus[feld] = null; continue; } const z = Number(w); if (!Number.isInteger(z) || z < 1) fehler.push("Ungültige Zuordnung."); else aus[feld] = z; } /* Ein frei eingetragener Name statt einer Person ("Agentur Müller", eine Marke, ein Gast). Er schlaegt die Auswahl: Wer tippt, meint das Getippte. Weil das Formular mit EINER Auswahl beide Spalten fuellt, faellt hier auch creator_id -- sonst gehoerte der Termin weiterhin einem Creator, waehrend "mit wem" jemand anderes sagt. */ externPruefen(körper, aus, "teilnehmer", fehler); if (aus.teilnehmer_extern) aus.creator_id = null; return { aus, fehler }; } /* ---------- Anlegen ------------------------------------------------------ */ kalenderRouter.post("/workspace/api/termine", gleicheHerkunft, (req, res) => { try { const { aus, fehler } = pruefe(req.body || {}, { neu: true }); if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") }); /* Wer nicht Management ist, legt nur für sich selbst an. Das Feld "Mit wem" bekommt er gar nicht zu sehen -- ein trotzdem mitgeschickter freier Name faellt hier weg, nicht erst in der Anzeige. */ if (!istLeitung(req.person)) { aus.creator_id = req.person.rolle === "creator" ? req.person.id : null; aus.teilnehmer_id = req.person.id; aus.teilnehmer_extern = null; } for (const feld of ["creator_id", "teilnehmer_id"]) { if (aus[feld] && !db().prepare("SELECT 1 FROM personen WHERE id = ? AND aktiv = 1").get(aus[feld])) { return res.status(400).json({ fehler: "Zugeordnete Person gibt es nicht." }); } } /* Wurde im Formular ein Rhythmus gewählt, entsteht KEIN einzelner Termin, sondern eine Regel -- und aus ihr sofort die Termine des Horizonts, den ersten eingeschlossen. Beides anzulegen wäre der naheliegende Fehler gewesen: Der erste Termin stünde dann doppelt da, einmal von Hand und einmal aus der Serie. Der Weg führt bewusst über dieselbe Prüfung und dieselbe Anlege-Funktion wie die eigene Serien-Schnittstelle. Eine zweite, verkürzte Fassung hier hätte irgendwann andere Grenzen gehabt. */ const w = req.body?.wiederholung; if (w && w.takt) { const serie = { ...aus, takt: w.takt, start_tag: aus.beginn.slice(0, 10), uhrzeit: aus.beginn.slice(11, 16), ende_tag: w.ende_tag ?? null, }; const gepruft = serienPruefen(serie, { neu: true }); if (gepruft.fehler.length) return res.status(400).json({ fehler: gepruft.fehler.join(" ") }); zuordnungErzwingen(gepruft.aus, req.person); const { id, angelegt } = serieAnlegen(gepruft.aus, req.person); return res.status(201).json({ serie_id: id, angelegt }); } const { lastInsertRowid } = db().prepare(` INSERT INTO termine (titel, beschreibung, art, beginn, dauer_min, ort, creator_id, teilnehmer_id, teilnehmer_extern, erledigt, erstellt, erstellt_von) VALUES (?,?,?,?,?,?,?,?,?,0,?,?)`).run( aus.titel, aus.beschreibung ?? null, aus.art ?? "termin", aus.beginn, aus.dauer_min ?? 30, aus.ort ?? null, aus.creator_id ?? null, aus.teilnehmer_id ?? null, aus.teilnehmer_extern ?? null, jetzt(), req.person.id); protokolliere("termin_angelegt", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), detail: `#${lastInsertRowid} ${aus.titel}`.slice(0, 120), }); res.status(201).json({ id: Number(lastInsertRowid) }); } catch (fehler) { console.error("[workspace] Termin anlegen:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /* ---------- Ändern und Löschen ------------------------------------------- */ kalenderRouter.patch("/workspace/api/termine/:id", gleicheHerkunft, (req, res) => { try { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); const { wo, werte } = sichtbar(req.person); const termin = db().prepare(`SELECT t.* ${VERBUND} WHERE ${wo} AND t.id = ?`).get(...werte, id); if (!termin) return res.status(404).json({ fehler: "nicht_gefunden" }); const { aus, fehler } = pruefe(req.body || {}, { neu: false }); if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") }); if (!istLeitung(req.person)) { delete aus.creator_id; delete aus.teilnehmer_id; delete aus.teilnehmer_extern; } const felder = Object.keys(aus); if (!felder.length) return res.status(400).json({ fehler: "nichts_zu_aendern" }); /* Stammt der Termin aus einer Wiederholung, gilt er ab jetzt als "angefasst". Das entscheidet später zweierlei: Beim Abstellen der Serie bleibt er stehen, und beim Ändern der Regel wird er nicht neu gebaut. Was jemand verschoben, umbenannt oder abgehakt hat, räumt die Automatik ihm nicht weg. */ const beruehrt = termin.serie_id ? ", serie_beruehrt = 1" : ""; db().prepare(`UPDATE termine SET ${felder.map((f) => `${f} = ?`).join(", ")}${beruehrt} WHERE id = ?`).run(...felder.map((f) => aus[f]), id); protokolliere("termin_geaendert", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), detail: `#${id} ${felder.join(",")}`.slice(0, 120), }); res.json({ ok: true }); } catch (fehler) { console.error("[workspace] Termin ändern:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); kalenderRouter.delete("/workspace/api/termine/:id", gleicheHerkunft, (req, res) => { try { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); const { wo, werte } = sichtbar(req.person); const termin = db().prepare( `SELECT t.id, t.titel, t.erstellt_von, t.serie_id, t.serie_tag ${VERBUND} WHERE ${wo} AND t.id = ?`) .get(...werte, id); if (!termin) return res.status(404).json({ fehler: "nicht_gefunden" }); /* Löschen darf das Management -- und wer den Termin selbst angelegt hat. Sonst könnte ein Creator einen Call absagen, den das Management angesetzt hat. */ if (!istLeitung(req.person) && termin.erstellt_von !== req.person.id) { return res.status(403).json({ fehler: "nicht_erlaubt" }); } /* Eine einzelne Ausprägung zu löschen heisst "dieses eine Mal nicht" -- nicht "die ganze Serie weg". Ohne diese Vormerkung legte der Nachfüller den Termin beim nächsten Öffnen des Kalenders wieder an, und der gelöschte Termin wäre kommentarlos zurück. Der Sinn der Serie bleibt erhalten: Sie läuft weiter, nur dieser Tag fällt aus. */ if (termin.serie_id && termin.serie_tag) { db().prepare("INSERT OR IGNORE INTO termin_serien_aus (serie_id, tag) VALUES (?,?)") .run(termin.serie_id, termin.serie_tag); } db().prepare("DELETE FROM termine WHERE id = ?").run(id); protokolliere("termin_geloescht", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), detail: `#${id} ${termin.titel}`.slice(0, 120), }); res.json({ ok: true }); } catch (fehler) { console.error("[workspace] Termin löschen:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } });