Zwei Wuensche vom 05.09.2026, dazu drei Fehler, die dabei ans Licht kamen.
ABBRECHEN (Wunsch: "in jedem status die aufgaben auch abbrechen koennen,
nur ich die manager und scouts")
Neuer Status mit Pflicht-Grund, aus jedem der vier Status heraus.
Festgehalten wird auch, WO die Aufgabe stand -- "im Review abgebrochen"
ist eine andere Aussage als "nie angefangen", und das Wiederaufnehmen
geht dorthin zurueck statt nach "offen".
Eigener Weg statt "abgebrochen" in der Statusliste: Dort entscheidet
darfAendern(), und das laesst auch den zustaendigen Creator aendern.
Der gewoehnliche PATCH kann diesen Zustand deshalb gar nicht erreichen
-- auch nicht fuer DogFather, sonst waere die Grund-Pflicht umgehbar.
Abgebrochenes steht in einem zugeklappten Bereich unter dem Brett, nicht
als fuenfte Spalte: Am Handy waeren dann alle fuenf unlesbar schmal.
Verschwinden darf es nicht, sonst waere der Abbruch ein Loeschen mit
Zwischenschritt.
Die Tabelle musste dafuer getauscht werden (SQLite kann CHECK nicht
aendern). Vorher auf einer Kopie durchgespielt: 40 von 40 Aufgaben,
Inhalte, Verweise und Indizes geprueft, Gegenprobe zeigt, dass der CHECK
noch lebt.
TEILNEHMERWAHL (Wunsch: "das soll viel besser aussehen und fuer jeden
verfuegbar sein")
Auf dem Bildschirm klebten die Namen aneinander: "DogfatherDogFather".
Ursache war, dass kalender.html das Stylesheet mit diesen Klassen nie
eingebunden hat -- sie standen in dateien.css. Vierzig gruene Pruefungen
zur Teilnehmerwahl hatten das nicht gemerkt, weil keine je gefragt hat,
ob es AUSSIEHT wie gedacht.
Jetzt eigene Klassen im eigenen Stylesheet, nach Rollen gruppiert: Die
Rolle steht einmal als Ueberschrift statt neunmal am Namen. Damit ist
das Kleben an der Wurzel weg, nicht zugepflastert.
"Fuer jeden" war mehr als ein hidden zu entfernen: darfEintragen() haette
einem Creator nur sich selbst erlaubt. Er haette seinen Scout gesehen,
angeklickt, und der Server haette ihn still weggelassen -- ein Knopf, der
nichts tut. einladbareIds() schaut jetzt in beide Richtungen, bewusst
getrennt von /api/personen: Wer die erweitert, gibt einem Creator
nebenbei die Moeglichkeit, seinem Scout Aufgaben zuzuweisen.
BENACHRICHTIGUNGEN (Wunsch: "sowas, und dass es perfekt funktioniert
fuer jeden")
Web Push nach RFC 8291/8292, ohne fremde Abhaengigkeit. Der Knopf sagt
in jeder Lage die Wahrheit, auch die unbequemen: abgelehnt (mit dem
Hinweis, wo man es zuruecknimmt), iPhone im Reiter (mit Anleitung),
Browser ohne Push. Ein Knopf, der bei abgelehnter Berechtigung nur
nichts tut, ist der sichere Weg zu "das funktioniert nicht".
DREI FEHLER, DIE DABEI AUFFIELEN
1. Ein defekter Zugangsdatensatz sperrte ALLE einer Rolle aus. Wirft
hashe() bei einer Person, flog die ganze Anmeldung in den catch: 503
"nicht verfuegbar" fuer jeden mit dieser Rolle. Aufgefallen durch
einen eigenen Testfehler. Jetzt wird die defekte Person uebersprungen
und laut protokolliert; die Gegenprobe zeigt, dass ein falscher Code
weiterhin abgelehnt wird.
2. Die Glocke sprengte die Kopfleiste -- zweimal. Bei 320 px lag die
Lupe des Suchknopfes auf dem Sicht-Umschalter (ein Knopf, der auf 12
Seiten ins Leere tippt), bei 768 px wurde der Abmelden-Knopf bis zu
15 px aus dem Bild geschoben, weil die Textgrenze auf 760 stand und
ein Tablet 768 hat. Nachgewiesen durch Messen mit und ohne Glocke,
nicht durch Vermuten.
3. .block__frage war viermal gestaltet und stand auf einer Seite, die
keine dieser Dateien laedt -- derselbe Fehler wie bei der
Teilnehmerwahl. Gefunden von der neuen Klassenpruefung beim ersten
Lauf.
NEUE PRUEFUNGEN
pruef-css-klassen jede gestaltete Klasse muss auf ihrer Seite ankommen
(unterscheidet Struktur-Anker von echtem Verlust)
pruef-dabei-optik die Wahl im Browser, an den echten Pixeln
pruef-abbrechen Umstellung auf einer Kopie, Rechte, Rueckweg
pruef-abbrechen-optik Knopf, Dialog, Bereich, Handy
pruef-glocke Zustaende, An/Abmelden, jede Rolle
pruef-push(-weg) Rechnung gegen die RFC-Vektoren, Zustellung
pruef-struktur prueft jetzt zusaetzlich, ob sich jedes Server-Modul als
ESM laden laesst. node --check auf einer .js-Datei prueft als CommonJS
und meldete "ok", waehrend der Import scheiterte.
Gesamtlauf: 55 von 55 Dateien, 1973 von 1973 Punkten.
Was NICHT geprueft werden konnte und deshalb dasteht: Der Schritt
"Browser holt eine Adresse beim Push-Dienst" braucht eine Verbindung zu
Googles FCM, die ein Pruef-Browser nicht hat. Die Pruefung misst das
zuerst und meldet es als dritten Ausgang, statt gruen zu sein.
Verschluesselung und Zustellung sind getrennt geprueft; diese eine
Strecke beweist sich erst auf dem Server.
Co-Authored-By: Claude Opus 5 <[email protected]>
542 lines
24 KiB
JavaScript
542 lines
24 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, istLeitung, istDogFather, betreutWo, termineSichtbar, heuteLokal,
|
|
externPruefen, externSql, betreuteIds, einladbareIds, ROLLEN_SORTIERUNG,
|
|
} 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`;
|
|
|
|
/* =====================================================================
|
|
MEHRERE TEILNEHMER (05.09.2026)
|
|
|
|
Wunsch Filipe: *"wenn ich im kalender was eintrage will ich dass ich
|
|
auch 2 leute markieren kann mit denen der call ist."*
|
|
|
|
`teilnehmer_id` bleibt das Haupt-Gegenueber; die Tabelle
|
|
`termin_teilnehmer` sagt, wer sonst noch dabei ist. Beim Speichern
|
|
wird das Haupt-Gegenueber IMMER mit in die Liste geschrieben -- so
|
|
muss keine Abfrage zwei Stellen zusammensuchen, und die Liste ist die
|
|
vollstaendige Antwort auf "wer ist dabei".
|
|
|
|
WER DARF EINGETRAGEN WERDEN. Dieselbe Grenze wie bei den Dateien:
|
|
Die Leitung jeden, ein Scout seine betreuten Creator und sich selbst,
|
|
ein Creator nur sich selbst. Sonst koennte man sich in fremde Termine
|
|
eintragen -- und weil an der Liste die SICHTBARKEIT haengt, waere das
|
|
ein Weg, fremde Termine lesbar zu machen. Deshalb wird hier gefiltert
|
|
und nicht in der Oberflaeche.
|
|
===================================================================== */
|
|
|
|
/** Die Nummern, die diese Person an einem Termin eintragen darf.
|
|
*
|
|
* ERWEITERT AM 05.09.2026, und zwar aus einem Fehler heraus: Hier
|
|
* stand nur `person.id` plus die BETREUTEN. Für einen Creator ergab
|
|
* das genau eine Nummer -- seine eigene. Er hätte in der neuen Wahl
|
|
* seinen Scout gesehen, ihn angeklickt, gespeichert, und der Server
|
|
* hätte ihn still weggelassen. Ein Knopf, der nichts tut und nichts
|
|
* sagt, ist schlimmer als ein fehlender.
|
|
*
|
|
* einladbareIds() nimmt jetzt beide Richtungen: wen ich führe UND wer
|
|
* mich führt, dazu DogFather. Die Begründung, warum das keine
|
|
* Aufweichung der Sichtbarkeit ist, steht dort. */
|
|
function darfEintragen(person) {
|
|
if (istLeitung(person)) return null; // null = alle
|
|
const ids = einladbareIds(person);
|
|
if (ids === null) return null;
|
|
const erlaubt = new Set(ids);
|
|
erlaubt.add(person.id); // sich selbst immer
|
|
for (const id of betreuteIds(person)) erlaubt.add(id);
|
|
return erlaubt;
|
|
}
|
|
|
|
/** Teilnehmerliste eines Termins setzen. Ersetzt die bisherige. */
|
|
export function teilnehmerSetzen(terminId, ids, person, { hauptId = null } = {}) {
|
|
const d = db();
|
|
const erlaubt = darfEintragen(person);
|
|
const sauber = new Set();
|
|
|
|
/* Das Haupt-Gegenueber gehoert immer dazu -- auch wenn die Oberflaeche
|
|
es nicht noch einmal mitschickt. */
|
|
if (hauptId) sauber.add(Number(hauptId));
|
|
|
|
for (const roh of Array.isArray(ids) ? ids : []) {
|
|
const z = Number(roh);
|
|
if (!Number.isInteger(z) || z < 1) continue;
|
|
if (erlaubt && !erlaubt.has(z)) continue; // still weglassen, nicht ablehnen
|
|
sauber.add(z);
|
|
}
|
|
|
|
/* Nur aktive Personen. Eine gesperrte Person in der Liste zu lassen
|
|
hiesse, ihr einen Termin sichtbar zu halten, den sie nicht mehr
|
|
sehen soll. */
|
|
const gueltig = [...sauber].filter((id) =>
|
|
d.prepare("SELECT 1 FROM personen WHERE id = ? AND aktiv = 1").get(id));
|
|
|
|
d.prepare("DELETE FROM termin_teilnehmer WHERE termin_id = ?").run(terminId);
|
|
const einf = d.prepare("INSERT OR IGNORE INTO termin_teilnehmer (termin_id, person_id) VALUES (?,?)");
|
|
for (const id of gueltig) einf.run(terminId, id);
|
|
return gueltig;
|
|
}
|
|
|
|
/** Die Teilnehmer mehrerer Termine auf einmal -- eine Abfrage statt
|
|
* einer je Zeile. Bei dreissig Terminen im Blick waeren das sonst
|
|
* dreissig Abfragen fuer eine Liste.
|
|
*
|
|
* DAS HAUPT-GEGENUEBER IST IMMER DABEI, auch wenn es in
|
|
* termin_teilnehmer fehlt.
|
|
*
|
|
* Beim Umstellen werden die vorhandenen `teilnehmer_id` einmalig in die
|
|
* neue Tabelle uebernommen (siehe workspace.js). Diese Abfrage
|
|
* verlaesst sich aber NICHT darauf: Sie liest das Haupt-Gegenueber
|
|
* ohnehin mit und legt es dazu.
|
|
*
|
|
* Der Unterschied faellt genau dann auf, wenn die Uebernahme nicht
|
|
* gelaufen ist -- etwa bei einem Termin, der aus einer Sicherung
|
|
* zurueckkam, oder waehrend eines Deploys, bei dem der Neustart
|
|
* ausblieb. Dann stuende an einem Termin mit Gegenueber "niemand
|
|
* dabei". Eine Liste, die je nach Zeitpunkt etwas anderes behauptet,
|
|
* ist schlimmer als eine, die einmal zu viel nachsieht. */
|
|
export function teilnehmerZu(terminIds) {
|
|
const karte = new Map();
|
|
if (!terminIds.length) return karte;
|
|
const platz = terminIds.map(() => "?").join(",");
|
|
|
|
const zeilen = db().prepare(`
|
|
SELECT tt.termin_id, p.id, p.name, p.rolle
|
|
FROM termin_teilnehmer tt JOIN personen p ON p.id = tt.person_id
|
|
WHERE tt.termin_id IN (${platz})
|
|
ORDER BY ${ROLLEN_SORTIERUNG.replace(/rolle/g, "p.rolle")}, p.name`).all(...terminIds);
|
|
for (const z of zeilen) {
|
|
if (!karte.has(z.termin_id)) karte.set(z.termin_id, []);
|
|
karte.get(z.termin_id).push({ id: z.id, name: z.name, rolle: z.rolle });
|
|
}
|
|
|
|
/* Das Haupt-Gegenueber dazu, falls es nicht ohnehin schon dasteht. */
|
|
const haupt = db().prepare(`
|
|
SELECT t.id AS termin_id, p.id, p.name, p.rolle
|
|
FROM termine t JOIN personen p ON p.id = t.teilnehmer_id
|
|
WHERE t.id IN (${platz})`).all(...terminIds);
|
|
for (const z of haupt) {
|
|
if (!karte.has(z.termin_id)) karte.set(z.termin_id, []);
|
|
const liste = karte.get(z.termin_id);
|
|
if (!liste.some((p) => p.id === z.id)) {
|
|
liste.unshift({ id: z.id, name: z.name, rolle: z.rolle });
|
|
}
|
|
}
|
|
return karte;
|
|
}
|
|
|
|
/* ---------- Lesen ------------------------------------------------------- */
|
|
|
|
/** WEN KANN ICH ZU EINEM TERMIN DAZUSTELLEN?
|
|
*
|
|
* Eigener Weg statt /api/personen (05.09.2026). Der dortige liefert
|
|
* die Liste für ZUWEISUNGEN und zeigt deshalb nur nach unten -- ein
|
|
* Creator fände sich dort allein wieder und hätte ein leeres Feld.
|
|
*
|
|
* Die beiden Listen dürfen nicht dieselbe sein: Wer /api/personen
|
|
* erweitert, gibt einem Creator nebenbei die Möglichkeit, seinem
|
|
* Scout Aufgaben zuzuweisen. Deshalb zwei Wege mit zwei Regeln --
|
|
* und beide enden bei derselben Prüfung, die teilnehmerSetzen()
|
|
* ohnehin noch einmal anwendet. Diese Liste ist eine Bequemlichkeit,
|
|
* keine Sicherung. */
|
|
kalenderRouter.get("/workspace/api/einladbar", (req, res) => {
|
|
try {
|
|
const person = sitzungLesen(req);
|
|
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
|
|
|
|
const ids = einladbareIds(person);
|
|
const wo = ids === null ? "" : ` AND id IN (${ids.map(() => "?").join(",")})`;
|
|
const werte = ids === null ? [] : ids;
|
|
/* Ist die Liste leer, wäre "id IN ()" ungültiges SQL -- der Fall
|
|
tritt ein, sobald jemand niemandem zugeordnet ist. */
|
|
if (ids !== null && !ids.length) return res.json({ personen: [] });
|
|
|
|
res.json({
|
|
personen: db().prepare(
|
|
`SELECT id, name, rolle FROM personen WHERE aktiv = 1${wo}
|
|
ORDER BY ` + ROLLEN_SORTIERUNG + ", name").all(...werte),
|
|
});
|
|
} catch (fehler) {
|
|
console.error("[workspace] Einladbar:", fehler?.message);
|
|
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
|
}
|
|
});
|
|
|
|
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");
|
|
|
|
/* Die Teilnehmer in EINER Abfrage fuer alle Termine nachladen --
|
|
nicht je Zeile eine. Bei dreissig Terminen im Blick waeren das
|
|
sonst dreissig zusaetzliche Abfragen fuer eine einzige Ansicht. */
|
|
const wer = teilnehmerZu(termine.map((t) => t.id));
|
|
for (const t of termine) t.teilnehmer = wer.get(t.id) || [];
|
|
|
|
/* 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 <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;
|
|
}
|
|
|
|
/* 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, req.body?.teilnehmer);
|
|
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);
|
|
|
|
/* Die weiteren Teilnehmer. Das Haupt-Gegenueber kommt als `hauptId`
|
|
mit hinein, damit die Liste vollstaendig ist -- an ihr haengt die
|
|
Sichtbarkeit. */
|
|
const dabei = teilnehmerSetzen(Number(lastInsertRowid), req.body?.teilnehmer, req.person,
|
|
{ hauptId: aus.teilnehmer_id ?? null });
|
|
|
|
protokolliere("termin_angelegt", {
|
|
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
|
detail: `#${lastInsertRowid} ${aus.titel}`.slice(0, 120)
|
|
+ (dabei.length > 1 ? ` (${dabei.length} Teilnehmer)` : ""),
|
|
});
|
|
res.status(201).json({ id: Number(lastInsertRowid), teilnehmer: dabei });
|
|
} 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;
|
|
}
|
|
|
|
/* Die Teilnehmerliste aendern darf, wer den Termin aendern darf --
|
|
aber nur die Leitung darf ueberhaupt Zuordnungen verschieben
|
|
(siehe oben). Fuer alle anderen wird die Liste gar nicht erst
|
|
angefasst; sonst koennte ein Creator sich selbst aus einem
|
|
Termin entfernen, den das Management angesetzt hat, oder sich in
|
|
einen fremden eintragen. */
|
|
const teilnehmerNeu = istLeitung(req.person) && req.body?.teilnehmer !== undefined;
|
|
|
|
const felder = Object.keys(aus);
|
|
if (!felder.length && !teilnehmerNeu) {
|
|
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" : "";
|
|
if (felder.length) {
|
|
db().prepare(`UPDATE termine SET ${felder.map((f) => `${f} = ?`).join(", ")}${beruehrt}
|
|
WHERE id = ?`).run(...felder.map((f) => aus[f]), id);
|
|
} else if (beruehrt) {
|
|
db().prepare("UPDATE termine SET serie_beruehrt = 1 WHERE id = ?").run(id);
|
|
}
|
|
|
|
/* Das Haupt-Gegenueber ist nach dem Aendern moeglicherweise ein
|
|
anderes -- deshalb der Wert aus `aus`, nicht der alte aus
|
|
`termin`. Wurde es nicht angefasst, gilt der alte weiter. */
|
|
let dabei = null;
|
|
if (teilnehmerNeu) {
|
|
const haupt = aus.teilnehmer_id !== undefined ? aus.teilnehmer_id : termin.teilnehmer_id;
|
|
dabei = teilnehmerSetzen(id, req.body.teilnehmer, req.person, { hauptId: haupt });
|
|
}
|
|
|
|
protokolliere("termin_geaendert", {
|
|
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
|
detail: `#${id} ${felder.join(",")}${dabei ? ` teilnehmer:${dabei.length}` : ""}`.slice(0, 120),
|
|
});
|
|
res.json({ ok: true, ...(dabei ? { teilnehmer: dabei } : {}) });
|
|
} 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" });
|
|
}
|
|
});
|