Filipe: "ich will dass du zuerst die komplette site vn der workspace
seite trennst. da soll nichts verknüpft sein. wenn ich bei der einen
was mache soll nichts bei der anderen passieren. … es soll nur für
dogfather eine kachel geben wo er mit einem einfachen klick von der
einen auf die anderen seite kommt aber sonst garnichts."
Das kehrt die Entscheidung vom 10.09.2026 um ("getrennt wird das
AUSSEHEN, nicht der Bestand"). Wer den alten Kommentar liest, liest
einen überholten Stand -- das steht jetzt an jeder betroffenen Stelle.
WAS GEMESSEN WAR, BEVOR ETWAS GEBAUT WURDE
* Nur drei Module trennten nach Haus (Aufgaben, Bereiche, Dateien).
Chat, Kalender, Wissen, Material, Personenlisten und der Rest nicht.
* Die Trennung war EINSEITIG: nurHaus() griff nur auf crew.
* siehtModis() hebelte sie für DogFather auf der Agenturseite aus --
in seiner Gesprächsliste standen dort beide Häuser nebeneinander.
* Keine haus-Spalte in der Datenbank.
* Der Bestand kreuzte aber kaum: 0 von 86 Terminen gemischt, 0 von 7
Zweier-/Gruppengesprächen, genau EIN Kanal.
WAS JETZT DASTEHT
* Drei Rollenmengen in crew-adresse.js (crew / agentur / beide) und
hausVonRolle(); eine unbekannte Rolle bekommt null, kein Haus.
* Spalte `haus` an acht Wurzeltabellen, nachgetragen aus Belegen:
238 Zeilen eindeutig, die Wissensablage geschlossen der Agentur,
vier Restzeilen namentlich, der gemischte Kanal aufgelöst
(die zwei Scouts gehen heraus, die 7 Nachrichten sind alle vom
Team). Offen bleiben: null.
* nurHaus, hausBedingung und darfAnlegen gelten in BEIDE Richtungen.
* Der siehtModis-Durchgriff ist weg -- aber in DREI Fällen, nicht
zwei: Prüfadressen bekommen gar kein Haus und verhalten sich exakt
wie vorher. Die erste Fassung hatte das übersehen und 19 Prüfungen
umgeworfen, an denen nichts kaputt war.
* Kalender: getrennt, aber "belegt" bleibt (Filipes Entscheidung).
Die Blöcke tragen NUR Beginn und Dauer -- kein Titel, keine Person.
Gebaut als Gegenstück zur Liste (meine Termine MINUS die sichtbaren),
damit beide nicht auseinanderlaufen können.
* Die Wissens-Kachel ist auf der Team-Adresse weg UND die Route
antwortet dort mit 404 -- eine fehlende Kachel ist nur eine Bitte.
* Die Wechsel-Kachel für DogFather geht jetzt in beide Richtungen.
GEPRÜFT: pruef-haus-trennung 97 statt 81, 0 Fehler (vorher 7, alle
haben die alte Regel behauptet). Die neuen Abschnitte sind DORT
eingezogen statt in eine zweite Datei -- `pruef-haustrennung.mjs` hätte
sich von `pruef-haus-trennung.mjs` um einen Bindestrich unterschieden.
Dazu grün: haus-seiten, crew-adresse, chat, chat-kanaele,
kanal-besetzung, kalender, serien, treffchat, wissen-neu,
modi-checkliste, modi-katalog, rechtetafel, personen-liste, sicht,
verborgen, fremde-sicht, alle-wege.
NICHT VON MIR: pruef-treff (3) und pruef-kachel-universum (2) waren
schon vorher rot -- beim Treff auf dem Stand 40b48e89 nachgemessen,
bei den Farben steht dieselbe Zahl im Kopf der Prüfung selbst.
Co-Authored-By: Claude Opus 5 <[email protected]>
907 lines
41 KiB
JavaScript
907 lines
41 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, ROLLEN_GRUPPE,
|
||
hausWo, ohneAnderesHaus,
|
||
} from "./workspace.js";
|
||
import {
|
||
nachfuellenAlle, serienPruefen, serieAnlegen, zuordnungErzwingen,
|
||
} from "./workspace-serien.js";
|
||
/* DIE SICHTBARKEIT DER BRETTEINTRAEGE KOMMT AUS IHREM EIGENEN HAUS.
|
||
|
||
`sichtbarEintrag` ist dieselbe Funktion, durch die auch das Lesen
|
||
eines Bretts, das Aendern und das Loeschen gehen. Eine hier
|
||
nachgebaute Fassung waere eine zweite Meinung darueber, wer was
|
||
sehen darf -- und die zweite ist erfahrungsgemaess die, die mehr
|
||
durchlaesst. Genau so kam am 03.09. eine Managerin an fremde
|
||
Aufgabenfristen: nicht durch ein Loch, sondern durch eine zweite
|
||
Regel, die "istLeitung" fragte statt der richtigen. */
|
||
import { sichtbarEintrag, BEREICHE } from "./workspace-bereiche.js";
|
||
/* Die Regel, WANN ein Zettel einen Termin hat -- als eigenes Blatt.
|
||
Seit die Brettkarte dasselbe sagt ("steht im Kalender am 24."),
|
||
haette sie sonst eine zweite Fassung. Zwei Fassungen derselben Regel
|
||
laufen auseinander, und dann zeigt die Karte etwas anderes als der
|
||
Kalender. */
|
||
import {
|
||
TERMIN_TAG_SQL, TERMIN_ENDE_SQL, TERMIN_BEDINGUNG_SQL, TERMIN_GRUND_SQL, OHNE_ZUSTAENDE,
|
||
} from "./workspace-termin-regel.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: [] });
|
||
|
||
/* DIE UEBERSCHRIFT KOMMT MIT (11.09.2026) -- wie beim Chat. Ohne
|
||
sie kann der Browser eine Gruppe nicht beschriften, deren
|
||
Rollennamen er gar nicht kennen darf, und liess sie deshalb
|
||
einfach weg. Team Dogi stand in dieser Auswahl nie. */
|
||
res.json({
|
||
personen: db().prepare(
|
||
`SELECT id, name, rolle FROM personen WHERE aktiv = 1${wo}
|
||
ORDER BY ` + ROLLEN_SORTIERUNG + ", name").all(...werte)
|
||
.map((p) => ({ ...p, gruppe: ROLLEN_GRUPPE[p.rolle] || "Weitere" })),
|
||
});
|
||
} 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;
|
||
}
|
||
}
|
||
/* =================================================================
|
||
WAS AUF DEN BRETTERN STEHT UND EINEN TERMIN HAT (17.09.2026)
|
||
|
||
Filipe, zu einem Bildschirmfoto einer Clip-Karte: „ich will dass
|
||
die auch in einer kalender drin sind."
|
||
|
||
WELCHE? Das ist die ganze Frage, und sie laesst sich messen
|
||
statt raten. `eintraege.datum` ist ein PFLICHTFELD und faellt
|
||
auf „heute" zurueck, wenn niemand eins waehlt (workspace-
|
||
bereiche.js, pruefe()). Alle Eintraege in den Kalender zu kippen
|
||
hiesse deshalb: jeder je angelegte Zettel steht an dem Tag, an
|
||
dem ihn jemand getippt hat. Nach einem Monat waere der Kalender
|
||
ein Protokoll und kein Plan -- und ein Kalender, in dem alles
|
||
steht, sagt nichts mehr.
|
||
|
||
GEZEIGT WIRD DESHALB NUR, WOFUER JEMAND EINE ZEIT BESTIMMT HAT.
|
||
Vier Faelle, jeder einzeln begruendbar:
|
||
|
||
geplant Eine Zusage. „Wird gemacht -- am 24.09." Das ist
|
||
ein Termin, den jemand jemandem gegeben hat.
|
||
event_ende Ein Zeitraum. Wer ein Ende eintraegt, meint eine
|
||
Veranstaltung, keine Notiz.
|
||
uhrzeit Wer eine Uhrzeit tippt, meint einen Zeitpunkt.
|
||
Niemand traegt aus Versehen 20:00 ein.
|
||
datum > heute Der Rueckfall ist IMMER heute oder frueher. Ein
|
||
Datum in der Zukunft kann nur Absicht sein.
|
||
|
||
Der vierte Fall ist der wichtigste und der unauffaelligste: Er
|
||
braucht kein neues Feld und keine Umgewoehnung. Wer einem
|
||
Eintrag ein kuenftiges Datum gibt, hat ihn geplant -- und sieht
|
||
ihn ab sofort im Kalender.
|
||
|
||
ERLEDIGTES BLEIBT DRAUSSEN. Ein Kalender zeigt, was ansteht.
|
||
Was schon gemacht ist, gehoert auf sein Brett, nicht in die
|
||
Planung der naechsten Woche. */
|
||
const eintragRegel = sichtbarEintrag(req.person, "e");
|
||
const brettsachen = eintragRegel ? db().prepare(`
|
||
SELECT e.id, e.bereich, e.art, e.titel, e.text, e.status,
|
||
e.dringlichkeit, e.datum, e.uhrzeit, e.geplant, e.event_ende,
|
||
pe.name AS von_name,
|
||
${TERMIN_TAG_SQL} AS wann,
|
||
${TERMIN_GRUND_SQL} AS grund
|
||
FROM eintraege e
|
||
LEFT JOIN personen pe ON pe.id = e.erstellt_von
|
||
WHERE ${eintragRegel.wo}
|
||
AND e.status NOT IN (${OHNE_ZUSTAENDE.map(() => "?").join(", ")})
|
||
AND ${TERMIN_BEDINGUNG_SQL}
|
||
/* UEBERSCHNEIDUNG, NICHT ANFANG (20.09.2026).
|
||
Vorher stand hier zweimal TERMIN_TAG_SQL -- also: „faengt
|
||
im sichtbaren Fenster an". Eine Aktion vom 28.09. bis zum
|
||
05.10. fiel damit im Oktober komplett heraus. Richtig ist
|
||
die Frage, ob sich Zeitraum und Fenster BERUEHREN:
|
||
faengt vor dem Fensterende an UND hoert nach dem
|
||
Fensteranfang auf. */
|
||
AND ${TERMIN_TAG_SQL} <= ?
|
||
AND ${TERMIN_ENDE_SQL} >= ?
|
||
ORDER BY wann, e.uhrzeit, e.id`)
|
||
.all(...eintragRegel.werte, ...OHNE_ZUSTAENDE, heuteLokal(), bis, von) : [];
|
||
|
||
/* DER NAME DES BRETTS KOMMT MIT -- und zwar aus dem Katalog, nicht
|
||
als abgeschriebene Liste im Browser. Ein Brett, das morgen
|
||
dazukommt, steht damit von selbst richtig da; eine zweite Liste
|
||
waere die, die beim naechsten Mal vergessen wird. */
|
||
for (const b of brettsachen) {
|
||
b.brett_name = BEREICHE[b.bereich]?.name || b.bereich;
|
||
/* Nur die ersten Zeilen -- im Kalender steht eine Pille, kein
|
||
Aufsatz. Der ganze Text steht auf dem Brett. */
|
||
b.text = b.text ? String(b.text).slice(0, 180) : null;
|
||
}
|
||
|
||
/* ==== WAS AUF DER ANDEREN SEITE SCHON BELEGT IST (24.09.2026) ===
|
||
|
||
Filipe, zur Frage, was aus der Kalender-Ausnahme vom 11.09. wird:
|
||
„Getrennt, aber ‚belegt' bleibt sichtbar."
|
||
|
||
ZWEI ANSPRUECHE, EINE ANZEIGE. Der INHALT gehoert getrennt --
|
||
kein Titel, kein Teilnehmer, keine Notiz aus dem anderen Haus.
|
||
Die ZEIT gehoert nicht getrennt: Wer die Haelfte seines Tages
|
||
nicht sieht, legt einen Call auf eine Stunde, in der er schon
|
||
woanders sitzt. Genau das war der Grund fuer die alte Ausnahme,
|
||
und der Grund gilt weiter.
|
||
|
||
WAS HIER RUEBERGEHT, IST DAS MINIMUM: Beginn und Dauer. Kein
|
||
Titel, keine Nummer, keine Person -- nicht einmal die Art. Man
|
||
kann daraus ablesen, DASS etwas ist, und nichts darueber, WAS.
|
||
|
||
NUR FUER DIE DOGFATHER-ROLLE, und das ist unveraendert sein Wort
|
||
vom 11.09.2026: „NUR IN DER DOGFATHER ROLLE BEI DOGFATHER: NICHT
|
||
BEI VANVAN." Die rechte Hand sieht von der anderen Seite nichts,
|
||
auch keine Bloecke.
|
||
|
||
UND ES SIND SEINE EIGENEN TERMINE, nicht alle des anderen
|
||
Hauses. `eigen` fragt dasselbe wie die Liste darueber: angelegt,
|
||
zugeordnet, Gegenueber oder Teilnehmer. Ein Kalender, der die
|
||
Verabredungen fremder Leute als „belegt" zeigt, waere
|
||
unbrauchbar -- und ein Leck obendrein. */
|
||
let belegt = [];
|
||
if (req.person.rolle === "admin"
|
||
&& (req.person.haus === "crew" || req.person.haus === "agentur")) {
|
||
/* ==== DAS GEGENSTUECK ZUR LISTE, NICHT EINE ZWEITE REGEL =======
|
||
|
||
Der erste Entwurf fragte `t.haus = <das andere>`. Das war eine
|
||
ZWEITE Vorstellung davon, was "die andere Seite" ist -- und
|
||
zwei Vorstellungen laufen auseinander. Gemessen ist genau das
|
||
sofort passiert: Termine ohne eingetragenes Haus (aus einem
|
||
Schreibweg, der es noch nicht setzt) wurden von der Liste
|
||
korrekt ueber die BETEILIGTEN getrennt, vom Block aber nicht
|
||
gefunden -- null Bloecke, und der Kalender waere wieder die
|
||
Falle vom 11.09. gewesen.
|
||
|
||
Jetzt steht hier die Rechnung: MEINE Termine MINUS die, die
|
||
dieses Haus zeigt. Was die Liste verbirgt, wird zum Block --
|
||
beides kann sich nicht mehr widersprechen, egal wie das Haus
|
||
einer Zeile bestimmt wird.
|
||
|
||
`haus: null` HOLT NUR DEN "EIGEN"-TEIL: termineSichtbar haengt
|
||
die Hausbedingung nur an, wenn ein Haus dasteht. So muss die
|
||
Frage "was sind meine Termine" nicht ein zweites Mal
|
||
formuliert werden. */
|
||
const SPALTEN_HAUS = ["creator_id", "teilnehmer_id", "erstellt_von"];
|
||
const meine = termineSichtbar({ ...req.person, haus: null }, "t");
|
||
const hierSichtbar = `${hausWo(req.person, "t")}`
|
||
+ ` AND ${ohneAnderesHaus("t", req.person, SPALTEN_HAUS)}`;
|
||
belegt = db().prepare(`
|
||
SELECT t.beginn, t.dauer_min
|
||
FROM termine t
|
||
WHERE (${meine.wo})
|
||
AND NOT (${hierSichtbar})
|
||
AND t.erledigt = 0
|
||
AND t.beginn >= ? AND t.beginn < ?
|
||
ORDER BY t.beginn`)
|
||
.all(...meine.werte, von, bis + "T23:59:59Z");
|
||
}
|
||
|
||
res.json({ termine, fristen, brettsachen, von, bis,
|
||
checkliste_moeglich: moeglich, belegt });
|
||
} 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, haus)
|
||
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,
|
||
req.person.haus || null);
|
||
|
||
/* 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" });
|
||
}
|
||
});
|