/* ===================================================================== workspace-calls.js — Calls & Meeting-Protokolle (Konzept, Seite 13). Calls sind KEINE eigene Terminart neben dem Kalender, sondern dieselben Termine mit art = 'call'. Ein zweiter Terminspeicher waere die sichere Art, irgendwann zwei widerspruechliche Uhrzeiten zu haben. Diese Datei fuegt nur das hinzu, was ein Gespraech vom blossen Termin unterscheidet: das Protokoll danach. Der tragende Satz aus dem Deck: "Jeder Call endet mit klaren To-dos, die direkt ins Board uebernommen werden." Woertlich genommen heisst das: To-dos werden nicht als Text im Protokoll abgelegt, sondern sofort zu echten Aufgaben. Sonst steht die Verabredung in einem Dokument, das niemand mehr oeffnet. ===================================================================== */ import express from "express"; import { db, protokolliere, echteIp, sitzungLesen } from "./workspace.js"; import { sichtbar } from "./workspace-kalender.js"; export const callRouter = express.Router(); const TITEL_MAX = 160; const TEXT_MAX = 6000; const TODOS_MAX = 12; const jetzt = () => new Date().toISOString(); /* Lokale Zeit im selben Format wie ein datetime-local-Feld -- damit ist "vergangen" derselbe Vergleich wie im Kalender. */ function jetztLokal() { const d = new Date(); const p = (n) => String(n).padStart(2, "0"); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`; } 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(); } callRouter.use("/workspace/api/calls", angemeldet); /* Die Sichtbarkeitsregel kommt aus dem Kalender und wird hier NICHT wiederholt. Sie stand kurzzeitig zweimal da -- und prompt wurde beim Erweitern nur eine der beiden Kopien geaendert: Scouts sahen die Termine ihrer Creator, dieselben Termine als Call aber nicht. Eine Regel, zwei Orte, ein Fehler. */ 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, pc.name AS creator_name, pt.name AS teilnehmer_name, pr.id AS protokoll_id, pr.punkte, pr.entscheidungen, pr.erstellt AS protokoll_erstellt, pr.naechster_termin_id, pe.name AS protokoll_von`; 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 LEFT JOIN protokolle pr ON pr.termin_id = t.id LEFT JOIN personen pe ON pe.id = pr.erstellt_von`; /* Aufgaben, die aus einem Gespraech entstanden sind. */ function aufgabenZu(protokollIds) { if (!protokollIds.length) return new Map(); const platz = protokollIds.map(() => "?").join(","); const zeilen = db().prepare(` SELECT pa.protokoll_id, a.id, a.titel, a.status, a.frist, p.name AS verantwortlich_name FROM protokoll_aufgaben pa JOIN aufgaben a ON a.id = pa.aufgabe_id LEFT JOIN personen p ON p.id = a.verantwortlich_id WHERE pa.protokoll_id IN (${platz}) ORDER BY a.id`).all(...protokollIds); const karte = new Map(); for (const z of zeilen) { if (!karte.has(z.protokoll_id)) karte.set(z.protokoll_id, []); karte.get(z.protokoll_id).push({ id: z.id, titel: z.titel, status: z.status, frist: z.frist, verantwortlich_name: z.verantwortlich_name, }); } return karte; } /* ---------- Liste ------------------------------------------------------- */ callRouter.get("/workspace/api/calls", (req, res) => { try { const { wo, werte } = sichtbar(req.sicht || req.person); const alle = db().prepare(` SELECT ${SPALTEN} ${VERBUND} WHERE ${wo} AND t.art IN ('call','review') ORDER BY t.beginn DESC`).all(...werte); const karte = aufgabenZu(alle.filter((c) => c.protokoll_id).map((c) => c.protokoll_id)); for (const c of alle) c.aufgaben = karte.get(c.protokoll_id) || []; const grenze = jetztLokal(); res.json({ jetzt: grenze, /* Die Reihenfolge der Gruppen ist die Dringlichkeit: Was fehlt, steht oben -- nicht was als Naechstes kommt. */ offen: alle.filter((c) => c.beginn <= grenze && !c.protokoll_id).reverse(), anstehend: alle.filter((c) => c.beginn > grenze).reverse(), erledigt: alle.filter((c) => c.beginn <= grenze && c.protokoll_id), }); } catch (fehler) { console.error("[workspace] Calls lesen:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /* ---------- Protokoll schreiben ----------------------------------------- */ callRouter.post("/workspace/api/calls/:id/protokoll", gleicheHerkunft, (req, res) => { const d = db(); 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 = d.prepare(` SELECT t.id, t.titel, t.beginn, t.dauer_min, t.ort, t.art, t.creator_id, t.teilnehmer_id, pr.id AS protokoll_id FROM termine t LEFT JOIN protokolle pr ON pr.termin_id = t.id WHERE ${wo} AND t.id = ? AND t.art IN ('call','review')`).get(...werte, id); if (!termin) return res.status(404).json({ fehler: "nicht_gefunden" }); const k = req.body || {}; const punkte = String(k.punkte ?? "").trim().slice(0, TEXT_MAX) || null; const entscheidungen = String(k.entscheidungen ?? "").trim().slice(0, TEXT_MAX) || null; /* To-dos pruefen, BEVOR irgendetwas geschrieben wird. */ const roh = Array.isArray(k.todos) ? k.todos : []; if (roh.length > TODOS_MAX) { return res.status(400).json({ fehler: `Höchstens ${TODOS_MAX} To-dos auf einmal.` }); } const todos = []; for (const t of roh) { const titel = String(t?.titel ?? "").trim(); if (!titel) continue; if (titel.length > TITEL_MAX) return res.status(400).json({ fehler: "Ein To-do ist zu lang." }); const frist = String(t?.frist ?? "").trim(); if (frist && (!/^\d{4}-\d{2}-\d{2}$/.test(frist) || Number.isNaN(Date.parse(frist)))) { return res.status(400).json({ fehler: "Frist eines To-dos ist ungültig." }); } todos.push({ titel, frist: frist || null }); } /* Nächster Termin, falls im Protokoll vereinbart. */ const nb = String(k.naechster_beginn ?? "").trim(); if (nb && !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(nb)) { return res.status(400).json({ fehler: "Der nächste Termin hat kein gültiges Datum." }); } let dauer = Number(k.naechster_dauer ?? termin.dauer_min ?? 30); if (!Number.isInteger(dauer) || dauer < 5 || dauer > 480) dauer = 30; if (!punkte && !entscheidungen && !todos.length) { return res.status(400).json({ fehler: "Ein leeres Protokoll hilft niemandem." }); } /* Alles zusammen oder gar nicht -- sonst gaebe es Aufgaben ohne Protokoll oder umgekehrt. */ let antwort; d.exec("BEGIN"); try { let protokollId = termin.protokoll_id; if (protokollId) { d.prepare("UPDATE protokolle SET punkte = ?, entscheidungen = ?, geaendert = ? WHERE id = ?") .run(punkte, entscheidungen, jetzt(), protokollId); } else { protokollId = Number(d.prepare(` INSERT INTO protokolle (termin_id, punkte, entscheidungen, erstellt, erstellt_von) VALUES (?,?,?,?,?)`).run(id, punkte, entscheidungen, jetzt(), req.person.id).lastInsertRowid); } /* To-dos werden zu echten Aufgaben -- genau das ist der Punkt. */ const neueAufgaben = []; for (const t of todos) { const a = Number(d.prepare(` INSERT INTO aufgaben (titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id, frist, erstellt, erstellt_von) VALUES (?,?,'offen','mittel',?,?,?,?,?)`).run( t.titel, `Aus dem Gespräch „${termin.titel}"`, termin.creator_id, termin.teilnehmer_id ?? termin.creator_id, t.frist, jetzt(), req.person.id).lastInsertRowid); d.prepare("INSERT INTO protokoll_aufgaben (protokoll_id, aufgabe_id) VALUES (?,?)") .run(protokollId, a); neueAufgaben.push(a); } /* Nächster Termin als echter Kalendereintrag, mit demselben Gegenüber und demselben Meeting-Link. */ let naechster = null; if (nb) { naechster = Number(d.prepare(` INSERT INTO termine (titel, beschreibung, art, beginn, dauer_min, ort, creator_id, teilnehmer_id, erstellt, erstellt_von) VALUES (?,?,?,?,?,?,?,?,?,?)`).run( termin.titel, `Folgetermin zu „${termin.titel}"`, termin.art, nb, dauer, termin.ort, termin.creator_id, termin.teilnehmer_id, jetzt(), req.person.id).lastInsertRowid); d.prepare("UPDATE protokolle SET naechster_termin_id = ? WHERE id = ?").run(naechster, protokollId); } d.prepare("UPDATE termine SET erledigt = 1 WHERE id = ?").run(id); d.exec("COMMIT"); antwort = { protokoll_id: protokollId, aufgaben: neueAufgaben.length, naechster_termin_id: naechster }; } catch (fehler) { d.exec("ROLLBACK"); throw fehler; } protokolliere("protokoll_geschrieben", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), detail: `Termin #${id}, ${antwort.aufgaben} To-do(s)`.slice(0, 120), }); res.status(201).json(antwort); } catch (fehler) { console.error("[workspace] Protokoll schreiben:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } });