Files
dogfather-universe/server/workspace-serien.js
T
DogFatherGitandClaude Opus 5 540b5d8838 Freie Namen, ein Call-Knopf, und drei Seiten, die fuer Scouts kaputt waren
DREI SACHEN AUF EINMAL, alle aus derselben Sitzung.

1. AUSSUCHEN ODER SELBST EINTRAGEN
   Wunsch: "ich will da auch sachen selber noch eintragen koennen, also
   aussuchen und selbst eintragen."

   Jede Personenauswahl (Kalender, Aufgaben, Bereiche, Content, Dateien)
   nimmt jetzt auch einen getippten Namen an -- eine Agentur, eine Marke,
   einen Gast ohne Konto. Dazu bekommt JEDE Auswahl ab acht Eintraegen
   ein Suchfeld: tippen statt scrollen.

   Gebaut IM vorhandenen Auswahl-Bauteil (wahl.js), nicht daneben. Der
   erste Anlauf war ein zweites Bauteil -- es hat sich prompt mit dem
   ersten gebissen, beide haben denselben <select> eingepackt. Ein
   zweites haette ausserdem anders ausgesehen und waere beim naechsten
   Umbau nur an einer von zwei Stellen nachgezogen worden.

   Der freie Name steht in einer EIGENEN Spalte je Feld; die Verknuepfung
   bleibt leer. Entweder eine Person ODER ein Name, nie beides.

   Filipe hat ausdruecklich auch bei den Creator-Feldern freie Namen
   gewollt, nachdem der Nachteil benannt war: Der Eintrag gehoert dann zu
   keinem Konto. Damit daraus kein STILLER Ausfall wird, faellt jede
   Abfrage, die bisher den Namen der verknuepften Person las, jetzt auf
   den freien Text zurueck (externSql) -- gekennzeichnet als "(extern)".
   Der Eintrag verschwindet dadurch aus keiner Liste, keiner Suche und
   keiner Uebersicht.

2. WO FUEHRE ICH DEN CALL?
   Den Knopf gab es, aber nur wenn jemand von Hand einen Link ins
   Ortsfeld getippt hatte UND das Gespraech noch bevorstand. Stand dort
   "Hier", war nichts zum Anklicken da.

   Jetzt hat das Team einen festen Call-Raum (Discord-Sprachkanal), den
   das Management einmal hinterlegt. Danach hat JEDER Call den Knopf --
   und er bleibt, solange das Gespraech laufen kann, nicht nur bis zur
   Startzeit. Ein eigener Link am Termin schlaegt den festen Raum.

3. WAS EINE ROLLE SIEHT, MUSS AUCH FUNKTIONIEREN
   Gemeldet: "cigdem kriegt als manager gewisse sachen nicht auf die sie
   sieht, check jede rolle ab."

   Neue Pruefung server/pruef-rollen.mjs schickt SECHS Rollen-Zustaende
   ueber alle 16 Seiten und misst Konsolenfehler, fehlgeschlagene
   Serveraufrufe, tote Verweise, haengende Ladeanzeigen und wortlos leere
   Seiten. 96 Durchgaenge.

   Der sechste Zustand ist der wichtige: eine Rolle OHNE zugeteilte
   Creator -- der Normalfall am ersten Tag und Cigdems echte Lage.
   Genau dort fielen die Seiten durch, waehrend dieselben Seiten MIT
   Zuteilung tadellos waren.

   VIER ECHTE FEHLER GEFUNDEN UND BEHOBEN:

   a) Eine Aufgabe, die eine Managerin ohne Creator anlegte, war fuer sie
      im selben Moment unsichtbar -- creator_id und verantwortlich_id
      leer, und "von mir selbst angelegt" stand in keiner
      Sichtbarkeitsregel. Nur DogFather sah sie noch. Kein Fehler, keine
      Meldung, die Aufgabe war einfach weg. Dasselbe bei den
      Bereichseintraegen. Beide Regeln kennen jetzt erstellt_von.
      Niemand sieht dadurch etwas Fremdes -- nur das Eigene.

   b) Ein Scout ohne zugeteilten Creator bekam auf die GESAMTE
      Report-Seite 404, obwohl sie fuer ihn verlinkt ist. Die Seite
      antwortet jetzt sauber und leer, statt sich zu verweigern.

   c) Start-Check und Report blieben fuer immer auf "wird geladen"
      stehen, wenn es nichts zu laden gab.

   d) Das Creator-Profil war fuer Scouts ohne Zuteilung wortlos leer --
      der erklaerende Satz stand nur in der grauen Unterzeile.

   Ausserdem meldete die bestehende Lesbarkeitspruefung zwei neue
   Beschriftungen von mir als zu klein fuers Handy (10,88 statt 11,5 px).
   Behoben, und dieselbe Groesse an der Serien-Karte gleich mit -- dort
   waere es erst aufgefallen, sobald jemand eine Wiederholung anlegt.

GEPRUEFT: pruef-rollen 97, pruef-freie-namen 32 (mit Gegenproben:
Ben sieht Cigdems Aufgabe NICHT; die Saeulen-Zuordnung nimmt
ausdruecklich KEINEN freien Namen). Alle bestehenden Laeufe gruen:
Startansicht 133, Kalender 84, Serien 67, Protokoll 52, Handy 50, Sicht
48, Ampel 47, Content 45, Aufgabenbrett 44, Sprung 43,
Personen-Loeschen 40, Bereiche 37, Uebersicht 33, Workspace-Seiten 32,
Formulare 19, Betreuung 18, Grosscheck 15, Lesbarkeit 14.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-02 20:10:09 +02:00

608 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
workspace-serien.js — Termine, die von allein weiterlaufen.
Wunsch vom 02.09.2026: "ich will dass ich da im kalender auch sachen
machen die jede woche jeden monat automatisiert immer weiter laufen
so wie ich es auswähle bis ich es selber deaktiviere."
---------------------------------------------------------------------
WARUM ECHTE TERMINE UND KEINE GERECHNETEN AUSPRÄGUNGEN
Der naheliegende Weg wäre gewesen, die Wiederholung nur beim Anzeigen
auszurechnen: eine Regel in der Datenbank, und der Kalender malt die
Termine dazu. Das wäre falsch gewesen. ACHT andere Stellen lesen die
Tabelle `termine` direkt:
workspace-calls.js die Call-Liste und die Protokolle
workspace-reports.js "Termine gewesen / stehen an"
workspace-hinweise.js die Ampel und die Hinweise
workspace-suche.js die Suche über alles
workspace-aufgaben.js "nächster Termin" an einer Aufgabe
workspace-personen.js was an einer Person hängt
workspace-content.js Veröffentlichungstermine
die Startseite/Übersicht "heute steht an"
Eine nur im Kalender gerechnete Wiederholung wäre in all diesen
Ansichten unsichtbar geblieben — und niemandem wäre es aufgefallen,
weil dort einfach nichts gestanden hätte. Genau die Sorte stiller
Ausfall, die man erst Monate später bemerkt.
Deshalb: Die Serie ist die Regel, der Nachfüller macht daraus echte
Zeilen in `termine`, und jedes andere Modul sieht sie mit, ohne dass
dort eine einzige Zeile geändert werden musste. Beim geplanten
ICS-Abo (Perfektionsplan 3.2) gilt dasselbe.
---------------------------------------------------------------------
KEIN ZEITGEBER, KEIN DIENST
Der Nachfüller hängt an keinem Cron und an keinem Timer. Er läuft beim
Start, beim Anlegen/Ändern einer Serie und beim Öffnen des Kalenders
(dort höchstens alle fünf Minuten). Er ist idempotent: Was es schon
gibt, legt er nicht noch einmal an. Ein Zeitgeber hätte nichts
hinzugefügt, aber eine weitere Sache, die stillschweigend ausfallen
kann.
---------------------------------------------------------------------
GERECHNET WIRD AUF DATUMSTEXTEN, IN UTC-MITTAG
Dieselbe Regel wie im Kalender-Frontend: Wer mit lokalen Zeiten
rechnet, verliert bei der Zeitumstellung einen Tag — der 27. Oktober
wird zum 26., und niemand findet den Fehler, weil er nur zweimal im
Jahr auftritt. Mittag ist von beiden Umstellungen weit genug weg.
Die Uhrzeit wird NICHT mitgerechnet, sondern als Text angehängt.
Dadurch bleibt "18:00" über die Zeitumstellung hinweg 18:00 und
wandert nicht auf 17:00 — was bei einem wöchentlichen Call genau das
Falsche wäre.
===================================================================== */
import express from "express";
import {
db, protokolliere, echteIp, sitzungLesen, istLeitung, termineSichtbar,
heuteLokal, tagLokal, externPruefen, externSql,
} from "./workspace.js";
export const serienRouter = express.Router();
/* Wie weit im Voraus echte Termine bereitliegen. 180 Tage decken den
90-Tage-Plan aus dem Konzept mit Reserve ab und halten die Tabelle
klein: täglich sind das 180 Zeilen je Serie, monatlich sechs. */
export const HORIZONT_TAGE = 180;
/* Notbremse gegen eine Regel, die versehentlich Unmengen erzeugt. 400
Ausprägungen sind mehr als der Horizont je hergibt (täglich = 180) --
greift die Grenze trotzdem, stimmt etwas an der Rechnung nicht. */
const GRENZE = 400;
const TITEL_MAX = 160;
const TEXT_MAX = 4000;
const ORT_MAX = 400;
const ARTEN = ["termin", "call", "review"];
const TAKTE = [
"taeglich", "werktags", "woechentlich", "zweiwoechentlich",
"monatlich_datum", "monatlich_letzter", "monatlich_wochentag", "jaehrlich",
];
const jetztStempel = () => new Date().toISOString();
/* "Jetzt" in derselben Schreibweise wie `beginn` (Ortszeit, auf die
Minute). Damit lässt sich "liegt noch vor uns" mit einem
Text-Vergleich beantworten -- dasselbe Format, dieselbe Sortierung. */
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())}`;
}
/* ---------- Datumsrechnung (UTC-Mittag, siehe Kopf) ---------------------- */
const alsText = (d) => d.toISOString().slice(0, 10);
const ausText = (t) => new Date(t + "T12:00:00Z");
const plusTage = (d, n) => { const k = new Date(d); k.setUTCDate(k.getUTCDate() + n); return k; };
const tagImMonat = (j, m, tag) => new Date(Date.UTC(j, m, tag, 12));
const letzterImMonat = (j, m) => new Date(Date.UTC(j, m + 1, 0, 12)).getUTCDate();
const WOCHENTAGE = ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"];
const ORDNUNG = ["", "ersten", "zweiten", "dritten", "vierten", "fünften"];
/** Was die Regel eines Starttags über den Monat aussagt: das wievielte
* Vorkommen dieses Wochentags es ist, und ob es das letzte im Monat
* ist. Beides braucht sowohl die Rechnung als auch der Klartext. */
function monatslage(startTag) {
const d = ausText(startTag);
const j = d.getUTCFullYear(), m = d.getUTCMonth(), tag = d.getUTCDate();
return {
jahr: j, monat: m, tag, wochentag: d.getUTCDay(),
nter: Math.ceil(tag / 7),
letzterWochentag: tag + 7 > letzterImMonat(j, m),
letzterTag: tag === letzterImMonat(j, m),
};
}
/** Alle Tage, an denen diese Serie zwischen `von` und `bis` stattfindet
* (beide einschliesslich), als 'JJJJ-MM-TT'.
*
* Kein Termin liegt je vor dem Starttag oder nach `ende_tag` -- diese
* beiden Grenzen werden hier durchgesetzt und nicht beim Aufrufer:
* Eine Grenze, die jeder Aufrufer selbst ziehen muss, wird irgendwo
* vergessen. */
export function serienTage(serie, von, bis, grenze = GRENZE) {
const raus = [];
if (!serie?.start_tag || !TAKTE.includes(serie.takt)) return raus;
/* Der frühere der beiden Endpunkte gewinnt, der spätere der beiden
Anfänge -- so bleibt der Zeitraum immer innerhalb der Serie. */
const ende = serie.ende_tag && serie.ende_tag < bis ? serie.ende_tag : bis;
const anfang = von > serie.start_tag ? von : serie.start_tag;
if (anfang > ende) return raus;
/* --- Takte mit festem Tagesabstand -------------------------------- */
const schritt = { taeglich: 1, werktags: 1, woechentlich: 7, zweiwoechentlich: 14 }[serie.takt];
if (schritt) {
const start = ausText(serie.start_tag);
/* Vorspulen statt Tag für Tag zählen: Eine Serie, die vor drei
Jahren begann, soll nicht tausend Schleifendurchläufe kosten. */
const abstand = Math.round((ausText(anfang) - start) / 86400000);
let d = abstand > 0 ? plusTage(start, Math.floor(abstand / schritt) * schritt) : start;
while (raus.length < grenze) {
const t = alsText(d);
if (t > ende) break;
/* "Werktags" ist täglich ohne Samstag und Sonntag. Ein Wochenende
wird ausgelassen, nicht verschoben -- ein Montagstermin, der
plötzlich am Sonntag steht, wäre schlimmer als keiner. */
const werktag = d.getUTCDay() >= 1 && d.getUTCDay() <= 5;
if (t >= anfang && (serie.takt !== "werktags" || werktag)) raus.push(t);
d = plusTage(d, schritt);
}
return raus;
}
/* --- Takte, die in Monaten oder Jahren springen -------------------- */
const lage = monatslage(serie.start_tag);
const schrittM = serie.takt === "jaehrlich" ? 12 : 1;
const vonD = ausText(anfang);
const abstandM = (vonD.getUTCFullYear() - lage.jahr) * 12 + (vonD.getUTCMonth() - lage.monat);
let i = abstandM > 0 ? Math.floor(abstandM / schrittM) : 0;
for (let n = 0; raus.length < grenze && n < 700; n++) {
const versatz = (i + n) * schrittM;
const j = lage.jahr + Math.floor((lage.monat + versatz) / 12);
const m = (lage.monat + versatz) % 12;
const letzter = letzterImMonat(j, m);
/* Sind wir am Ende vorbei? Der Erste des Monats ist der früheste
Tag, den dieser Durchlauf noch liefern könnte. */
if (alsText(tagImMonat(j, m, 1)) > ende) break;
let tag = null;
if (serie.takt === "monatlich_letzter") {
tag = letzter;
} else if (serie.takt === "monatlich_wochentag") {
if (lage.letzterWochentag) {
/* "Jeden letzten Freitag im Monat" -- rückwärts vom Monatsende.
Vorwärts zu zählen wäre in Monaten mit fünf Freitagen um eine
Woche daneben. */
const letzterD = tagImMonat(j, m, letzter);
tag = letzter - ((letzterD.getUTCDay() - lage.wochentag + 7) % 7);
} else {
const erster = tagImMonat(j, m, 1);
const z = 1 + ((lage.wochentag - erster.getUTCDay() + 7) % 7) + (lage.nter - 1) * 7;
if (z <= letzter) tag = z;
}
} else {
/* Gleiches Datum. Monate, die diesen Tag nicht haben, werden
AUSGELASSEN und nicht auf den 28. vorgezogen: "am 31." heisst
am 31. Wer den Monatsletzten will, wählt genau das -- die
Auswahl im Formular bietet beides getrennt an, damit hier
nichts geraten werden muss. */
if (lage.tag <= letzter) tag = lage.tag;
}
if (!tag) continue;
const t = alsText(tagImMonat(j, m, tag));
if (t > ende) break;
if (t >= anfang) raus.push(t);
}
return raus;
}
/** Der Takt in einem Satz, den man vorlesen kann. Steht im Formular als
* Vorschau und in der Liste der Wiederholungen -- wer eine Regel
* abstellen will, muss sie zuerst wiedererkennen. */
export function taktText(serie) {
const l = monatslage(serie.start_tag);
const wt = WOCHENTAGE[l.wochentag];
switch (serie.takt) {
case "taeglich": return "jeden Tag";
case "werktags": return "jeden Werktag (Mo–Fr)";
case "woechentlich": return `jeden ${wt}`;
case "zweiwoechentlich": return `alle zwei Wochen, ${wt}s`;
case "monatlich_datum": return `jeden Monat am ${l.tag}.`;
case "monatlich_letzter": return "jeden Monat am letzten Tag";
case "monatlich_wochentag":
return l.letzterWochentag
? `jeden Monat am letzten ${wt}`
: `jeden Monat am ${ORDNUNG[l.nter]} ${wt}`;
case "jaehrlich":
return `jedes Jahr am ${l.tag}. ${["Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember"][l.monat]}`;
default: return serie.takt;
}
}
/** Der ganze Satz, inklusive Uhrzeit und Ende. */
export function serienText(serie) {
const bis = serie.ende_tag
? ` bis zum ${serie.ende_tag.slice(8)}.${serie.ende_tag.slice(5, 7)}.${serie.ende_tag.slice(0, 4)}`
: ", bis du sie abstellst";
return `${taktText(serie)} um ${serie.uhrzeit} Uhr${bis}`;
}
/* ---------- Der Nachfüller ------------------------------------------------
Legt fehlende Ausprägungen an. Idempotent: Was schon da ist oder
ausdrücklich ausgelassen wurde, bleibt unangetastet. */
export function serieNachfuellen(serieId) {
const d = db();
const s = d.prepare("SELECT * FROM termin_serien WHERE id = ?").get(serieId);
if (!s || !s.aktiv) return 0;
const von = heuteLokal();
const bis = tagLokal(HORIZONT_TAGE);
const tage = serienTage(s, von, bis);
if (!tage.length) return 0;
/* Was es schon gibt und was ausdrücklich ausgelassen wurde -- beides
in einem Rutsch, nicht je Tag einzeln abgefragt. */
const da = new Set(d.prepare(
"SELECT serie_tag FROM termine WHERE serie_id = ? AND serie_tag >= ?")
.all(s.id, von).map((r) => r.serie_tag));
const aus = new Set(d.prepare(
"SELECT tag FROM termin_serien_aus WHERE serie_id = ?")
.all(s.id).map((r) => r.tag));
const einfuegen = d.prepare(`
INSERT INTO termine
(titel, beschreibung, art, beginn, dauer_min, ort, creator_id, teilnehmer_id,
teilnehmer_extern, erledigt, erstellt, erstellt_von, serie_id, serie_tag,
serie_beruehrt)
VALUES (?,?,?,?,?,?,?,?,?,0,?,?,?,?,0)`);
let neu = 0;
const stempel = jetztStempel();
for (const tag of tage) {
if (da.has(tag) || aus.has(tag)) continue;
einfuegen.run(s.titel, s.beschreibung, s.art, `${tag}T${s.uhrzeit}`, s.dauer_min,
s.ort, s.creator_id, s.teilnehmer_id, s.teilnehmer_extern ?? null,
stempel, s.erstellt_von, s.id, tag);
neu++;
}
return neu;
}
/* Wann zuletzt über alle Serien nachgelegt wurde. Der Kalender wird oft
geöffnet; ohne diese Bremse liefe die Rechnung bei jedem Aufruf,
obwohl sich frühestens am nächsten Tag etwas ändern kann. */
let letzterLauf = 0;
const BREMSE_MS = 5 * 60_000;
export function nachfuellenAlle(erzwingen = false) {
const jetzt = Date.now();
if (!erzwingen && jetzt - letzterLauf < BREMSE_MS) return 0;
letzterLauf = jetzt;
let neu = 0;
try {
for (const s of db().prepare("SELECT id FROM termin_serien WHERE aktiv = 1").all()) {
neu += serieNachfuellen(s.id);
}
} catch (fehler) {
console.error("[workspace] Wiederholungen nachfüllen:", fehler?.message);
}
return neu;
}
/** Beim Serverstart einmal auffüllen -- damit die Termine auch dann
* bereitliegen, wenn an diesem Tag niemand den Kalender öffnet, aber
* jemand die Startseite oder die Call-Liste ansieht. */
export function serienStarten() {
try {
const neu = nachfuellenAlle(true);
if (neu) console.log(`[workspace] ${neu} wiederkehrende Termine ergänzt.`);
} catch (fehler) {
console.error("[workspace] Wiederholungen beim Start:", fehler?.message);
}
}
/** Räumt die Zukunft einer Serie ab: alles, was noch bevorsteht und
* niemand angefasst hat. Berührte und vergangene Termine bleiben --
* ausdrücklicher Wunsch: was geleistet oder bearbeitet wurde, wird
* nicht weggeräumt. */
function zukunftEntfernen(serieId) {
return db().prepare(`
DELETE FROM termine
WHERE serie_id = ? AND serie_beruehrt = 0 AND erledigt = 0 AND beginn > ?`)
.run(serieId, jetztLokal()).changes ?? 0;
}
/* ---------- Prüfen -------------------------------------------------------- */
export function serienPruefen(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.takt !== undefined) {
if (!TAKTE.includes(körper.takt)) fehler.push("Unbekannter Rhythmus.");
else aus.takt = körper.takt;
}
if (neu || körper.start_tag !== undefined) {
const t = String(körper.start_tag ?? "").trim();
if (!/^\d{4}-\d{2}-\d{2}$/.test(t) || Number.isNaN(Date.parse(t + "T12:00:00Z"))) {
fehler.push("Startdatum fehlt oder ist ungültig.");
} else aus.start_tag = t;
}
if (neu || körper.uhrzeit !== undefined) {
const u = String(körper.uhrzeit ?? "").trim();
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(u)) fehler.push("Uhrzeit fehlt oder ist ungültig.");
else aus.uhrzeit = u;
}
if (körper.ende_tag !== undefined) {
const t = String(körper.ende_tag ?? "").trim();
if (!t) aus.ende_tag = null;
else if (!/^\d{4}-\d{2}-\d{2}$/.test(t)) fehler.push("Enddatum ist ungültig.");
else aus.ende_tag = t;
}
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;
}
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 -- gleiche Regel wie
beim einzelnen Termin, damit eine Wiederholung nicht plötzlich
etwas anderes kann als der Termin, aus dem sie entsteht. */
externPruefen(körper, aus, "teilnehmer", fehler);
if (aus.teilnehmer_extern) aus.creator_id = null;
/* Ein Ende vor dem Anfang ergäbe eine Serie ohne einen einzigen
Termin -- sie stünde in der Liste und täte nichts. */
const start = aus.start_tag ?? körper.start_tag;
if (aus.ende_tag && start && aus.ende_tag < start) {
fehler.push("Das Ende liegt vor dem Start.");
}
return { aus, fehler };
}
/** Legt eine Serie an und füllt sie sofort auf. Wird auch vom
* Kalender-Router benutzt, wenn im Terminformular ein Rhythmus gewählt
* wurde -- ein Weg, eine Prüfung, ein Protokolleintrag. */
export function serieAnlegen(aus, person) {
const d = db();
const { lastInsertRowid } = d.prepare(`
INSERT INTO termin_serien
(titel, beschreibung, art, takt, start_tag, uhrzeit, ende_tag, dauer_min, ort,
creator_id, teilnehmer_id, teilnehmer_extern, aktiv, erstellt, erstellt_von)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)`).run(
aus.titel, aus.beschreibung ?? null, aus.art ?? "termin", aus.takt, aus.start_tag,
aus.uhrzeit, aus.ende_tag ?? null, aus.dauer_min ?? 30, aus.ort ?? null,
aus.creator_id ?? null, aus.teilnehmer_id ?? null, aus.teilnehmer_extern ?? null,
jetztStempel(), person.id);
const id = Number(lastInsertRowid);
const angelegt = serieNachfuellen(id);
protokolliere("serie_angelegt", {
personId: person.id, rolle: person.rolle, ip: null,
detail: `#${id} ${aus.titel} (${aus.takt}, ${angelegt} Termine)`.slice(0, 120),
});
return { id, angelegt };
}
/** Zuordnung erzwingen für alle, die nicht Leitung sind: Sie legen nur
* für sich selbst an. Dieselbe Regel wie beim einzelnen Termin -- sonst
* wäre die Wiederholung ein Weg, sie zu umgehen. */
export function zuordnungErzwingen(aus, person) {
if (istLeitung(person)) return;
aus.creator_id = person.rolle === "creator" ? person.id : null;
aus.teilnehmer_id = person.id;
aus.teilnehmer_extern = null;
}
/* ---------- Schranken ----------------------------------------------------- */
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();
}
serienRouter.use("/workspace/api/serien", angemeldet);
/** Die Serie holen, die diese Person sehen darf. Gibt es sie nicht oder
* darf sie diese Person nicht sehen, ist die Antwort dieselbe (404) --
* sonst verriete ein 403, dass es die Serie gibt. */
function serieHolen(person, id) {
const { wo, werte } = termineSichtbar(person, "s");
return db().prepare(`SELECT s.* FROM termin_serien s WHERE ${wo} AND s.id = ?`)
.get(...werte, id);
}
/* Ändern und Abstellen darf die Leitung -- und wer die Serie angelegt
hat. Sonst könnte ein Creator einen Wochen-Call abstellen, den das
Management angesetzt hat. Gleiche Regel wie beim Löschen eines
einzelnen Termins. */
const darfAendern = (person, serie) => istLeitung(person) || serie.erstellt_von === person.id;
/* ---------- Lesen ---------------------------------------------------------- */
serienRouter.get("/workspace/api/serien", (req, res) => {
try {
nachfuellenAlle();
const person = req.sicht || req.person;
const { wo, werte } = termineSichtbar(person, "s");
const roh = db().prepare(`
SELECT s.*,
${externSql("pc.name", "s.teilnehmer_extern")} AS creator_name,
${externSql("pt.name", "s.teilnehmer_extern")} AS teilnehmer_name
FROM termin_serien s
LEFT JOIN personen pc ON pc.id = s.creator_id
LEFT JOIN personen pt ON pt.id = s.teilnehmer_id
WHERE ${wo}
ORDER BY s.aktiv DESC, s.erstellt DESC`).all(...werte);
const heute = heuteLokal();
const serien = roh.map((s) => {
/* Der nächste Termin wird gerechnet, nicht aus der Tabelle
gelesen: Eine abgestellte Serie hat dort keine Zukunft mehr,
soll aber trotzdem zeigen können, wann sie wieder liefe. */
const naechste = s.aktiv ? serienTage(s, heute, tagLokal(HORIZONT_TAGE), 1) : [];
return {
...s,
satz: serienText(s),
naechster: naechste[0] ? `${naechste[0]}T${s.uhrzeit}` : null,
darf_aendern: darfAendern(req.person, s) ? 1 : 0,
};
});
res.json({ serien });
} catch (fehler) {
console.error("[workspace] Serien lesen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Anlegen -------------------------------------------------------- */
serienRouter.post("/workspace/api/serien", gleicheHerkunft, (req, res) => {
try {
const { aus, fehler } = serienPruefen(req.body || {}, { neu: true });
if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") });
zuordnungErzwingen(aus, req.person);
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 { id, angelegt } = serieAnlegen(aus, req.person);
res.status(201).json({ id, angelegt });
} catch (fehler) {
console.error("[workspace] Serie anlegen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Ändern, Abstellen, Wieder anstellen ---------------------------- */
serienRouter.patch("/workspace/api/serien/:id", gleicheHerkunft, (req, res) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
const serie = serieHolen(req.person, id);
if (!serie) return res.status(404).json({ fehler: "nicht_gefunden" });
if (!darfAendern(req.person, serie)) return res.status(403).json({ fehler: "nicht_erlaubt" });
const { aus, fehler } = serienPruefen(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;
}
if (req.body?.aktiv !== undefined) aus.aktiv = req.body.aktiv ? 1 : 0;
/* Auch gegen den BESTEHENDEN Start prüfen, nicht nur gegen einen
mitgeschickten: Wer nur ein Enddatum nachreicht, könnte es sonst
vor den Anfang legen. */
if (aus.ende_tag && aus.ende_tag < (aus.start_tag ?? serie.start_tag)) {
return res.status(400).json({ fehler: "Das Ende liegt vor dem Start." });
}
const felder = Object.keys(aus);
if (!felder.length) return res.status(400).json({ fehler: "nichts_zu_aendern" });
db().prepare(`UPDATE termin_serien SET ${felder.map((f) => `${f} = ?`).join(", ")},
geaendert = ? WHERE id = ?`)
.run(...felder.map((f) => aus[f]), jetztStempel(), id);
/* Die noch unberührte Zukunft wird neu gebaut. Sie einfach stehen zu
lassen wäre der stille Fehler: Man verschiebt den Wochen-Call auf
19:00, die nächsten zwölf Termine stünden weiter auf 18:00, und
auffallen würde es erst beim ersten verpassten Call. */
const weg = zukunftEntfernen(id);
const neu = aus.aktiv === 0 ? 0 : serieNachfuellen(id);
protokolliere(aus.aktiv === 0 ? "serie_abgestellt"
: aus.aktiv === 1 ? "serie_angestellt" : "serie_geaendert", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${id} ${felder.join(",")} (−${weg}/+${neu})`.slice(0, 120),
});
res.json({ ok: true, entfernt: weg, angelegt: neu });
} catch (fehler) {
console.error("[workspace] Serie ändern:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Löschen -------------------------------------------------------- */
serienRouter.delete("/workspace/api/serien/:id", gleicheHerkunft, (req, res) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" });
const serie = serieHolen(req.person, id);
if (!serie) return res.status(404).json({ fehler: "nicht_gefunden" });
if (!darfAendern(req.person, serie)) return res.status(403).json({ fehler: "nicht_erlaubt" });
/* Erst die unberührte Zukunft, dann die Regel. Was war und was
jemand angefasst hat, bleibt als ganz normaler Termin stehen --
serie_id fällt dabei auf NULL (ON DELETE SET NULL). */
const weg = zukunftEntfernen(id);
db().prepare("DELETE FROM termin_serien WHERE id = ?").run(id);
protokolliere("serie_geloescht", {
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
detail: `#${id} ${serie.titel} (−${weg})`.slice(0, 120),
});
res.json({ ok: true, entfernt: weg });
} catch (fehler) {
console.error("[workspace] Serie löschen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});