Files
dogfather-universe/server/workspace-kalender.js
T
DogFatherGit ef79659f8f Workspace: Kalender mit Terminen, Calls und Aufgaben-Fristen
/workspace/kalender.html -- letzter grosser Baustein aus Phase 1.

Zeigt zwei Quellen in EINER Ansicht, wie im Konzept gefordert
("Kalender & Calls" neben "Deadlines" und "Wiedervorlagen"):
  * eigene Termine (Arten: Call, Termin, Review)
  * Fristen offener Aufgaben, nur lesend eingeblendet

Nach Tagen gruppiert statt als Monatsraster: Es geht um wenige, dafuer
konkrete Termine. Eine Liste liest sich dabei besser und funktioniert auf
dem Handy ohne Umbau. Zeitraum umschaltbar: 14 / 30 / 90 Tage, wobei 90
den 90-Tage-Plan aus dem Konzept abdeckt.

Sichtbarkeit wie bei den Aufgaben, wieder an einer Stelle definiert.
Geprueft mit drei Konten:
  Chef 2 Termine + 2 Fristen | Luna 2 + 1 | Sam 0 + 1
Die Fristen folgen dabei exakt der Aufgaben-Regel -- Sam sieht nur seine.

Loeschen darf das Management und wer den Termin selbst angelegt hat.
Sonst koennte ein Creator einen Call absagen, den das Management
angesetzt hat. Geprueft: Luna auf Chefs Call 403, auf ihren eigenen 200,
Sam sieht ihn gar nicht (404).

Weiteres:
- Ort/Link wird nur dann als Verweis dargestellt, wenn er wirklich mit
  http(s) beginnt -- sonst liesse sich javascript: einschleusen
- Zeitpunkt und Dauer werden geprueft (kaputtes Datum 400,
  5000 Minuten 400)
- Beginn steht als lokale Zeit ohne Zeitzone: Alle Beteiligten sitzen in
  derselben, eine falsch umgerechnete Uhrzeit waere schlimmer als gar
  keine Umrechnung
2026-08-28 00:24:12 +02:00

254 lines
10 KiB
JavaScript

/* =====================================================================
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 } 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. */
function sichtbar(person) {
if (person.rolle === "admin") return { wo: "1=1", werte: [] };
return {
wo: "(t.creator_id = ? OR t.teilnehmer_id = ? OR t.erstellt_von = ?)",
werte: [person.id, person.id, person.id],
};
}
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.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 = req.person.rolle === "admin"
? { 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 <input type="datetime-local"> 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 (req.person.rolle !== "admin") {
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 (req.person.rolle !== "admin") { 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 (req.person.rolle !== "admin" && 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" });
}
});