/* ===================================================================== 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, betreutWo, istLeitung, istDogFather, } from "./workspace.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? Gleiche Logik wie bei den Aufgaben: Management alles, sonst nur, woran man selbst beteiligt ist. */ export function sichtbar(person) { /* NUR DogFather sieht alles (01.09.2026). Vorher stand hier istLeitung() -- damit sah auch jeder Manager jeden Creator. Ein Manager faellt jetzt in dieselbe Regel wie ein Scout: nur die Creator, die ihm zugeteilt sind. Ausdruecklicher Wunsch: "NUR DIE ROLLE DOGFATHER SOLL WIRKLICH ALLEINE ALLES SEHEN." Geaendert wird ausschliesslich, wer was SIEHT. Was ein Manager darf (freigeben, aendern, Personen verwalten), haengt weiterhin an istLeitung und bleibt unveraendert -- sonst haette dieser eine Wunsch stillschweigend seine halben Rechte mitgenommen. */ if (istDogFather(person)) return { wo: "1=1", werte: [] }; const eigen = "(t.creator_id = ? OR t.teilnehmer_id = ? OR t.erstellt_von = ?)"; const werte = [person.id, person.id, person.id]; const b = betreutWo(person, "t.creator_id"); if (!b) return { wo: eigen, werte }; return { wo: `(${eigen} OR ${b.wo})`, werte: [...werte, ...b.werte] }; } 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, pc.name AS creator_name, pt.name 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 { 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 || "")) ? String(req.query.von) : jetzt().slice(0, 10); 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. */ const aufgabenWo = istLeitung(req.person) ? { wo: "1=1", werte: [] } : req.person.rolle === "creator" ? { wo: "(a.creator_id = ? OR a.verantwortlich_id = ?)", werte: [req.person.id, req.person.id] } : { wo: "a.verantwortlich_id = ?", werte: [req.person.id] }; 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; } 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. */ if (!istLeitung(req.person)) { aus.creator_id = req.person.rolle === "creator" ? req.person.id : null; aus.teilnehmer_id = req.person.id; } 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." }); } } const { lastInsertRowid } = db().prepare(` INSERT INTO termine (titel, beschreibung, art, beginn, dauer_min, ort, creator_id, teilnehmer_id, 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, 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; } const felder = Object.keys(aus); if (!felder.length) return res.status(400).json({ fehler: "nichts_zu_aendern" }); db().prepare(`UPDATE termine SET ${felder.map((f) => `${f} = ?`).join(", ")} 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 ${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" }); } 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" }); } });