Files
dogfather-universe/server/workspace-kalender.js
T
DogFatherGitandClaude Opus 5 ad47fc2b00 Team Dogi: Sternenfeld auf jeder Kachel, und die Sicht zeigt endlich, was sie verspricht
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]>
2026-09-11 02:10:15 +02:00

747 lines
33 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, siehtAlles, betreutWo, termineSichtbar, heuteLokal,
kategorienFuer,
externPruefen, externSql, betreuteIds, einladbareIds, verborgeneIds, ROLLEN_SORTIERUNG,
} from "./workspace.js";
import {
nachfuellenAlle, serienPruefen, serieAnlegen, zuordnungErzwingen,
} from "./workspace-serien.js";
export const kalenderRouter = express.Router();
/* DIE TERMINARTEN (08.09.2026 erweitert).
Wunsch Filipe: "da sollen auch noch kategorien wie bigmatch,
turniere, special-live ... informier dich, was man da alles noch
gebrauchen koennte."
Recherchiert (Streams Charts, Kick, StreamerCollabs): Was ein
Creator-Team im Kalender wirklich unterscheidet, sind neben den
internen Terminen die AUFTRITTE -- Wettkaempfe, gemeinsame Formate
und die grossen Ausnahmen. Daraus die sechs neuen:
bigmatch ein angesetztes Duell gegen einen bestimmten Gegner
turnier ein mehrrundiges Format ueber laengere Zeit
special ein angekuendigter Sonder-Livestream
collab ein gemeinsamer Stream mit anderen Creators
raid eine Raid-Kette, bei der Zuschauer weitergereicht werden
charity ein Spendenformat
Die Liste steht HIER und nicht in der Datenbank: Sie ist eine
Entscheidung ueber das Format, keine Nutzdaten. Der CHECK in
workspace.js fuehrt dieselben Werte -- wer hier einen hinzufuegt,
muss ihn dort ebenfalls eintragen, sonst lehnt die Datenbank ab.
pruef-arten haelt beide gegeneinander. */
const ARTEN = ["termin", "call", "review",
"bigmatch", "turnier", "special", "collab", "raid", "charity"];
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) {
/* AUCH DIE LEITUNG DARF NICHT JEDEN EINTRAGEN (09.09.2026).
Hier stand `if (istLeitung(person)) return null;` GANZ oben --
also vor jeder Liste. Damit haette Spicy Media einen verborgenen
Zugang als Teilnehmer eines Termins eintragen koennen; er waere in
der Teilnehmerliste des Termins aufgetaucht, obwohl er in keiner
Personenliste steht. Eine Sichtbarkeitsregel, die nur beim LESEN
gilt und nicht beim SCHREIBEN, hat ein Loch in der Mitte.
Gibt es nichts zu verbergen, bleibt alles wie vorher (`null` =
alle). */
const weg = verborgeneIds(person);
if (istLeitung(person)) {
if (!weg.length) return null;
const alle = new Set(db().prepare("SELECT id FROM personen WHERE aktiv = 1")
.all().map((z) => z.id));
for (const id of weg) alle.delete(id);
alle.add(person.id);
return 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();
/* DER KALENDER BLEIBT IMMER DER EIGENE (11.09.2026).
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.. also private daten."
Hier stand `req.sicht || req.person`. Damit zeigte die fremde
Sicht die Termine der anderen Person -- und ein Termin traegt
Titel, Ort und Gegenueber. Das ist genau die Sorte Daten, die er
ausnimmt.
Es steht `req.person` und nicht eine Bedingung: Eine Ausnahme
("ausser bei privaten Terminen") muesste jeder Termin einzeln
beantworten, und die Antwort waere raten. Der Kalender ist als
Ganzes privat -- so wie der Chat, der die Sicht ohnehin noch nie
benutzt hat. */
const { wo, werte } = sichtbar(req.person);
/* Zeitraum. Ohne Angabe: ab heute 00:00, 90 Tage nach vorn -- das
deckt den 90-Tage-Plan aus dem Konzept ab. */
const von = /^\d{4}-\d{2}-\d{2}$/.test(String(req.query.von || ""))
/* 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. */
/* Auch hier die eigene Person -- siehe oben. */
const 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 = siehtAlles(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 NOT IN ('erledigt', 'abgebrochen')
AND a.frist >= ? AND a.frist <= ?
ORDER BY a.frist`).all(...aufgabenWo.werte, von, bis);
/* =================================================================
DIE LIVE-CHECKLISTE AM TERMIN (10.09.2026, Kapitel 5.7/11)
Das Dokument sagt "wird pro Live-Termin automatisch als
Checkliste erzeugt". Filipes Entscheidung: ein KNOPF statt
Automatik -- vierzehn Aufgaben, die bei jedem Termin von selbst
erscheinen, ueberrumpeln, und was ungefragt Dinge anlegt, ist
schwer wieder loszuwerden.
Hier kommt nur die AUSKUNFT mit, ob der Knopf hingehoert und ob
es die Liste schon gibt. Als Zahl und als Ja/Nein -- KEIN
Rollenname: Der Browser bekommt diese Datei ausgeliefert, und aus
einer Zahl laesst sich nichts schliessen.
IN EINER ABFRAGE FUER ALLE TERMINE, nicht je Zeile eine. Bei
dreissig Terminen im Blick waeren das dreissig Abfragen -- den
Fehler hat das Haus bei den Teilnehmern schon einmal gemacht und
eine Zeile darueber ausdruecklich vermerkt. */
const moeglich = kategorienFuer(req.person).length > 0;
if (moeglich && termine.length) {
const nummern = termine.map((t) => t.id).filter(Boolean);
if (nummern.length) {
const platz = nummern.map(() => "?").join(",");
const gezaehlt = new Map();
for (const z of db().prepare(
`SELECT CAST(substr(vorlage, instr(vorlage, '#') + 1) AS INTEGER) AS termin_id,
COUNT(*) AS n
FROM aufgaben
WHERE vorlage LIKE 'mk-%#%'
AND CAST(substr(vorlage, instr(vorlage, '#') + 1) AS INTEGER) IN (${platz})
GROUP BY termin_id`).all(...nummern)) {
gezaehlt.set(z.termin_id, z.n);
}
for (const t of termine) t.checkliste_aufgaben = gezaehlt.get(t.id) || 0;
}
}
res.json({ termine, fristen, von, bis, checkliste_moeglich: moeglich });
} 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" });
/* AENDERN DARF, WER IHN EINGETRAGEN HAT -- und die Leitung
(07.09.2026).
Filipe: "jeder der in seinem kalender was eintraegt soll es auch
bearbeiten koennen, nur diese person selber."
Bis hierher durfte JEDER aendern, der den Termin ueberhaupt sieht.
Seit der Kalender nur noch eigene Termine zeigt, faellt das kaum
auf -- aber ein Termin mit mehreren Teilnehmern sehen eben alle,
und dann haette jeder von ihnen Titel und Uhrzeit verstellen
koennen. Dieselbe Regel wie beim Loeschen ein paar Zeilen
weiter unten; sie stand dort schon richtig.
AUSNAHME "erledigt": Ein Haken, dass ein Gespraech stattgefunden
hat, ist keine Aenderung am Termin, sondern eine Rueckmeldung
dazu -- die darf jeder Beteiligte setzen. Sonst muesste man den
Anleger bitten, den eigenen Call abzuhaken. */
const nurErledigt = Object.keys(req.body || {}).every((k) => k === "erledigt");
if (!nurErledigt && !istLeitung(req.person) && termin.erstellt_von !== req.person.id) {
return res.status(403).json({ fehler: "nicht_erlaubt" });
}
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" });
}
});
/* =====================================================================
DER WECKER -- MEINE EIGENEN, ZU DIESEM TERMIN (07.09.2026)
Filipe: "das soll man auch selbst jeder fuer sich einstellen
koennen."
Deshalb steht in KEINER dieser beiden Routen eine Rollenfrage: Es
geht nie um fremde Wecker. Wer den Termin sehen darf, darf sich zu
ihm wecken lassen -- und aendert dabei ausschliesslich seine eigenen
Zeilen. Ein Wecker ist keine Eigenschaft des Termins, sondern eine
Verabredung mit sich selbst.
Die Sichtbarkeit wird trotzdem geprueft: Ohne sie koennte man sich
ueber eine geratene Nummer wecken lassen und aus dem Meldungstext
Titel und Uhrzeit eines fremden Termins lesen.
===================================================================== */
/** Erlaubte Vorlaufzeiten in Minuten. Eine feste Liste, keine freie
* Zahl: Ein Wecker "in 7 Minuten" waere im Fuenf-Minuten-Takt des
* Melders ohnehin ungenau, und eine offene Zahl ist eine Eingabe, die
* jemand pruefen muss. Die Staffel folgt der Recherche (Google,
* Outlook, Morgen): eine Woche, drei Tage, ein Tag, der Morgen des
* Tages, eine Stunde, zehn Minuten. */
const WECKER_STUFEN = [10080, 4320, 1440, 720, 60, 10];
const WECKER_MAX = 5; /* wie bei Google -- mehr weckt niemanden besser */
kalenderRouter.get("/workspace/api/termine/:id/wecker", (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 da = db().prepare(`SELECT t.id ${VERBUND} WHERE ${wo} AND t.id = ?`).get(...werte, id);
if (!da) return res.status(404).json({ fehler: "nicht_gefunden" });
res.json({
stufen: WECKER_STUFEN,
hoechstens: WECKER_MAX,
meine: db().prepare(
"SELECT minuten_vorher FROM termin_wecker WHERE termin_id = ? AND person_id = ? ORDER BY minuten_vorher DESC")
.all(id, req.person.id).map((z) => z.minuten_vorher),
});
} catch (fehler) {
console.error("[workspace] Wecker lesen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
kalenderRouter.put("/workspace/api/termine/:id/wecker", 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 da = db().prepare(`SELECT t.id ${VERBUND} WHERE ${wo} AND t.id = ?`).get(...werte, id);
if (!da) return res.status(404).json({ fehler: "nicht_gefunden" });
/* Nur bekannte Stufen, ohne Dubletten, hoechstens fuenf. Gefiltert
statt abgelehnt: Eine Oberflaeche, die eine unbekannte Zahl
schickt, hat einen Fehler -- aber die vier richtigen daneben
sollen deshalb nicht verlorengehen. */
const roh = Array.isArray(req.body?.minuten) ? req.body.minuten : [];
const gewaehlt = [...new Set(roh.map(Number).filter((m) => WECKER_STUFEN.includes(m)))]
.sort((a, b) => b - a).slice(0, WECKER_MAX);
const d = db();
d.exec("BEGIN");
try {
/* Erst raeumen, dann setzen -- das ist der einzige Weg, bei dem
ein ABGEWAEHLTER Wecker auch wirklich verschwindet. Wer nur
einfuegt, sammelt bei jedem Speichern eine Zeile mehr an. */
d.prepare("DELETE FROM termin_wecker WHERE termin_id = ? AND person_id = ?")
.run(id, req.person.id);
const rein = d.prepare(
"INSERT INTO termin_wecker (termin_id, person_id, minuten_vorher, gesetzt) VALUES (?,?,?,?)");
const jetzt = new Date().toISOString();
for (const m of gewaehlt) rein.run(id, req.person.id, m, jetzt);
d.exec("COMMIT");
} catch (f) { d.exec("ROLLBACK"); throw f; }
res.json({ meine: gewaehlt });
} catch (fehler) {
console.error("[workspace] Wecker setzen:", 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" });
}
});