DER GRUND JEDER KACHEL IM HAUS VON TEAM DOGI
Filipe: "ich will dass die hintergrunde von den kacheln immer unviersum
artig ist, es muss richtig geil sein aber immer so dass man alles noch
gut erkkent. und das IN DER GANZEN WEBSITE VON TEAM DOGI. nur die
kacheln. [...] wichtig ist die form der kacheln soll gleich bleiben."
module.css hat eine kanonische KACHELLISTE -- 48 Klassen, siebenmal in
der Datei, von pruef-css-klassen gegeneinander gehalten. Eine achte
Abschrift in crew-haus.css waere die Sorte Fehler, die nicht auffaellt:
heute vollstaendig, bei der naechsten neuen Kachel lautlos nicht mehr.
Deshalb faerbt crew-haus.css keine einzige Kachel. module.css baut den
Grund jetzt aus vier Werten (--sternenfeld, -mass, -lage,
--modul-schleier), und das zweite Haus setzt nur diese vier um. Damit
hat jede Kachel der ganzen Adresse den Himmel -- auch die, die es noch
nicht gibt. Im Agenturhaus steht `none`: kein Pixel aendert sich.
ZWEI DINGE HAT ERST DIE MESSUNG GEFUNDEN, NICHT DAS NACHDENKEN:
1. Die Nebel standen zuerst oben links. Dort ist aber JEDE Kachel dieses
Hauses schon von sich aus am hellsten -- ihr eigener Lichtverlauf
laeuft bei allen aus derselben Richtung (155/150/158 Grad) --, und
genau dort stehen ueberall die Ueberschriften. Hinter der leisesten
Textzeile lagen dadurch 2,09 % der Bildpunkte unter 4,5:1; im
Agenturhaus sind es an derselben Stelle 0,115 %. Nach dem Umzug in
die beiden gegenueberliegenden Ecken: 0,22 % -- und der Nebel durfte
dabei KRAEFTIGER werden (.80 statt .62), weil er nicht mehr auf dem
hellsten Punkt liegt. Besser lesbar und deutlicher zu sehen; das ist
selten und war hier umsonst zu haben.
2. Kleinere, dafuer hellere Sternkerne waren der falsche Weg: Der
hellste Punkt blieb fast gleich, der Stern wurde nur unschaerfer.
Entschieden hat die Deckkraft, nicht die Groesse.
Form unangetastet: Fase, Silhouette und die drei Eckwinkel werden vor
und nach dem Hauswechsel Zeichen fuer Zeichen verglichen.
pruef-kachel-universum.mjs (NEU, 37 Pruefungen, Port 4391) misst an
echten Bildpunkten und fragt nicht nach Durchschnitt allein, sondern
nach dem ANTEIL der Punkte unter 4,5:1 -- das unterscheidet einen Punkt
von einer Flaeche. Zwei Gegenproben: ein zu dunkler Text UND ein zu
heller Nebel muessen durchfallen.
MEINE SICHT -- "GENAU SO WIE SIE ES SEHEN"
Filipe: "oben bei meine sicht soll ich auch die sicht von allen jeden
moment sehen koennen und das genau genau so wie sie es sehen alles.
ausser die kalender daten oder chat daten wo ich nicht mit drin bin.."
Gemessen wurde nicht "mit Umschalter gegen ohne" -- das ist bei duenner
Datenlage ueberall gleich und beweist nichts. Gemessen wurde die
Antwort mit Umschalter gegen die Antwort, die die Person SELBST bekommt.
Das hat sechs Stellen gefunden:
* workspace-zentrale.js las `req.person.sicht` -- ein Feld, das es nicht
gibt. Der Ausdruck war immer `undefined || req.person`, daneben ein
ausfuehrlicher Kommentar, der genau das Richtige beschrieb. Die grosse
Kachel zeigte verlaesslich die eigene Lage, waehrend die Zahlen
darunter der fremden folgten -- zwei Wahrheiten in einer Kachel. Ein
Tippfehler in einem Variablennamen macht nichts kaputt; er tut nur
nichts, und genau deshalb faellt so etwas nie von selbst auf. Die
Route hatte ausserdem ZWEI Personenvariablen; jetzt hat sie eine.
* sichtPerson() gab die angesehene Person ohne Feld `haus` zurueck --
und nurHaus()/hausBedingung() fangen beide mit `haus !== "crew"` an.
Jede fremde Sicht war damit eine Agentursicht: In der Sicht auf einen
Modi kamen die Dateien, Personen und Berichte des anderen Hauses.
Das Haus haengt jetzt an der ROLLE, nicht an der Adresse.
* leistung, profil, schulung, fruehwarnung, report und teamlage lasen
weiterhin den Angemeldeten. Nur LESEN ist umgestellt, nie ein Recht --
und weil DogFather ohnehin alles sehen darf, kann das nichts oeffnen,
nur weniger zeigen.
Der Sicht-Umschalter zeichnete ausserdem nur die fuenf Rollen aus
bereiche.js; wer eine sechste hat, stand nicht darin. Dieselbe Luecke
wie in der Chat-Auswahl und der Personenliste, zum dritten Mal. Die
Ueberschrift kommt jetzt vom Server (`gruppe`), der Browser zeichnet,
was ankommt -- auch eine Rolle, deren Namen er nicht kennen darf.
DREI STELLEN FOLGEN BEWUSST NICHT: die Personenliste (aus ihr wird der
Umschalter gebaut -- folgte sie der Sicht, kaeme man aus einer fremden
nicht mehr heraus), die Auswahllisten beim Anlegen (Kategorien,
Empfaenger) und steckbrief/mein (ein Formular, das fremd liest und
eigen speichert, zerstoert Daten).
KALENDER UND CHAT BLEIBEN PRIVAT, auch mit Umschalter -- Termine,
Calls und Wiederholungen lesen ab jetzt immer die eigene Person. Eine
Pruefung musste dafuer umgedreht werden: pruef-sicht verlangte bis
heute das Gegenteil ("dafuer gibt es den Umschalter"). Die Gegenprobe
bleibt dieselbe Frage, nur andersherum -- Patrick selbst MUSS seine
Termine sehen, sonst hiesse "DogFather sieht sie nicht" nur, dass sie
niemand sieht. Dabei fiel auf, dass die Managerin gar keinen Call
hatte: Die Pruefung "bleibt privat" war nicht bestanden, sondern nicht
durchfuehrbar. Antwort darauf sind Daten, keine weichere Bedingung.
Gruen: pruef-sicht 84 (vorher 53), pruef-kachel-universum 37 (neu),
pruef-rollen 282, pruef-crew-adresse 129, pruef-start-ansicht 143,
pruef-kalender 104, pruef-haus-trennung 62, pruef-team-ampel 32,
pruef-team-stufen 26, pruef-css-klassen. Stempel 202609110209.
Co-Authored-By: Claude Opus 5 <[email protected]>
665 lines
28 KiB
JavaScript
665 lines
28 KiB
JavaScript
/* =====================================================================
|
||
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, betreuteIds,
|
||
} 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)`);
|
||
|
||
/* DIE TEILNEHMER DER SERIE ERBEN (05.09.2026).
|
||
|
||
Die Teilnehmer gehoeren zur REGEL, nicht zur einzelnen Auspraegung
|
||
-- sonst muesste man sie jede Woche neu eintragen. Beim Anlegen
|
||
eines Termins werden sie deshalb mit hinuebergeschrieben.
|
||
|
||
Das ist nicht nur Bequemlichkeit: An der Teilnehmerliste haengt die
|
||
SICHTBARKEIT. Ohne diese Zeilen saehe der zweite Teilnehmer den
|
||
einzelnen Termin nicht, obwohl er in der Serie steht -- der Call
|
||
stuende in seinem Kalender einmal drin und beim naechsten Mal
|
||
nicht. */
|
||
const serienLeute = d.prepare(
|
||
"SELECT person_id FROM serie_teilnehmer WHERE serie_id = ?").all(s.id).map((r) => r.person_id);
|
||
const dazu = d.prepare(
|
||
"INSERT OR IGNORE INTO termin_teilnehmer (termin_id, person_id) VALUES (?,?)");
|
||
|
||
let neu = 0;
|
||
const stempel = jetztStempel();
|
||
for (const tag of tage) {
|
||
if (da.has(tag) || aus.has(tag)) continue;
|
||
const { lastInsertRowid } = 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);
|
||
/* Haupt-Gegenueber und Serienteilnehmer -- dieselbe Regel wie beim
|
||
einzelnen Termin: Die Liste ist die vollstaendige Antwort. */
|
||
for (const pid of new Set([...(s.teilnehmer_id ? [s.teilnehmer_id] : []), ...serienLeute])) {
|
||
dazu.run(Number(lastInsertRowid), pid);
|
||
}
|
||
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, teilnehmer = null) {
|
||
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);
|
||
|
||
/* Die Teilnehmer VOR dem Nachfuellen eintragen -- der Nachfueller
|
||
kopiert sie an jeden erzeugten Termin. Stuenden sie erst danach da,
|
||
waeren die Termine des ersten Horizonts ohne Teilnehmer, und der
|
||
zweite Mensch saehe genau die nicht. */
|
||
serieTeilnehmerSetzen(id, teilnehmer, person, { hauptId: aus.teilnehmer_id ?? null });
|
||
|
||
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 };
|
||
}
|
||
|
||
/** Teilnehmer einer SERIE setzen. Wortgleich zur Fassung im Kalender --
|
||
* dieselbe Grenze, wer eingetragen werden darf, nur auf der anderen
|
||
* Tabelle. Ein Aufruf ueber die Modulgrenze waere hier ein Ringschluss
|
||
* (kalender importiert serien und umgekehrt), deshalb die zweite
|
||
* Fassung; die gemeinsame Regel dahinter (`betreuteIds`) ist aber
|
||
* dieselbe. */
|
||
export function serieTeilnehmerSetzen(serieId, ids, person, { hauptId = null } = {}) {
|
||
const d = db();
|
||
const erlaubt = istLeitung(person) ? null : new Set([person.id, ...betreuteIds(person)]);
|
||
const sauber = new Set();
|
||
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;
|
||
sauber.add(z);
|
||
}
|
||
const gueltig = [...sauber].filter((id) =>
|
||
d.prepare("SELECT 1 FROM personen WHERE id = ? AND aktiv = 1").get(id));
|
||
|
||
d.prepare("DELETE FROM serie_teilnehmer WHERE serie_id = ?").run(serieId);
|
||
const einf = d.prepare("INSERT OR IGNORE INTO serie_teilnehmer (serie_id, person_id) VALUES (?,?)");
|
||
for (const id of gueltig) einf.run(serieId, id);
|
||
return gueltig;
|
||
}
|
||
|
||
/** 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();
|
||
/* Wiederholungen sind Termine. Dieselbe Regel, dieselbe
|
||
Begruendung -- und dies ist die Stelle, die man vergisst. */
|
||
const person = 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, req.body?.teilnehmer);
|
||
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" });
|
||
}
|
||
});
|