diff --git a/pruef-serien-computer.png b/pruef-serien-computer.png index 62db1f5a..8d3bf16e 100644 Binary files a/pruef-serien-computer.png and b/pruef-serien-computer.png differ diff --git a/server/pruef-kalender-computer.png b/server/pruef-kalender-computer.png index 7f9c5567..2471e844 100644 Binary files a/server/pruef-kalender-computer.png and b/server/pruef-kalender-computer.png differ diff --git a/server/pruef-kalender-handy.png b/server/pruef-kalender-handy.png index 97d38c62..30c67baf 100644 Binary files a/server/pruef-kalender-handy.png and b/server/pruef-kalender-handy.png differ diff --git a/server/pruef-serien-computer.png b/server/pruef-serien-computer.png index 54a58bee..dd997ad3 100644 Binary files a/server/pruef-serien-computer.png and b/server/pruef-serien-computer.png differ diff --git a/server/pruef-serien-handy.png b/server/pruef-serien-handy.png index d45b105d..07a0cc8b 100644 Binary files a/server/pruef-serien-handy.png and b/server/pruef-serien-handy.png differ diff --git a/server/pruef-serien-liste.png b/server/pruef-serien-liste.png index 92461c1e..37a92161 100644 Binary files a/server/pruef-serien-liste.png and b/server/pruef-serien-liste.png differ diff --git a/server/pruef-teilnehmer.mjs b/server/pruef-teilnehmer.mjs new file mode 100644 index 00000000..a42e1388 --- /dev/null +++ b/server/pruef-teilnehmer.mjs @@ -0,0 +1,295 @@ +/* MEHRERE TEILNEHMER AN EINEM TERMIN + + Wunsch Filipe (05.09.2026): *"wenn ich im kalender was eintrage will + ich dass ich auch 2 leute markieren kann mit denen der call ist."* + + WARUM DAS EINE EIGENE PRUEFUNG BRAUCHT. An der Teilnehmerliste haengt + die SICHTBARKEIT: Wer eingetragen ist, sieht den Termin. Damit ist + jede Zeile hier gleichzeitig eine Rechteregel. Zwei Fehler waeren + moeglich, und beide fallen im Alltag nicht auf: + + zu wenig Ein Eingetragener sieht seinen eigenen Termin nicht. + Er merkt es nur, wenn er ihn sucht -- und haelt es dann + fuer sein eigenes Versehen. + + zu viel Jemand traegt sich in einen fremden Termin ein und macht + ihn sich damit sichtbar. Das ist ein Datenleck mit + Selbstbedienung. + + Beides wird hier gemessen, nicht angenommen. + + DIE ANZAHL GEHOERT IN DIE BEDINGUNG -- ein Lauf, der keinen Termin + angelegt hat, ist kein bestandener Lauf. + + GEGENPROBE: Am Ende muss nachgewiesen sein, dass diese Pruefung + ueberhaupt "nicht sichtbar" erkennen kann. */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-teiln-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +/* Der Port muss frei sein -- sonst wird gegen einen fremden Server + gemessen. Begruendung in helfer-port.mjs. */ +const { portMussFreiSein } = await import("./helfer-port.mjs"); +await portMussFreiSein(4321, "die Teilnehmerpruefung"); + +process.env.PORT = "4321"; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; +await import("./index.js"); +await new Promise((r) => setTimeout(r, 900)); +const BASIS = "http://127.0.0.1:4321"; + +let fehler = 0; +const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +/* ---- Bestand ------------------------------------------------------------ */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +const heute = jetzt.slice(0, 10); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Chef", "admin", "CODE-DOGI-0001"); +const idPat = anlegen("Pat", "scout", "CODE-SCOU-0001"); +const idBea = anlegen("Bea", "scout", "CODE-SCOU-0002"); +const idLuna = anlegen("Luna", "creator", "CODE-CREA-0001"); +const idNora = anlegen("Nora", "creator", "CODE-CREA-0002"); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idPat, jetzt); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idNora, idBea, jetzt); + +/* EIN ALTER TERMIN, angelegt wie vor der Umstellung: nur + teilnehmer_id, kein Eintrag in termin_teilnehmer. Er prueft, dass die + Uebernahme beim Start wirklich gelaufen ist. */ +d.prepare(`INSERT INTO termine (titel, art, beginn, dauer_min, teilnehmer_id, erstellt, erstellt_von) + VALUES ('Alter Termin von frueher','call',?,30,?,?,?)`) + .run(heute + "T09:00", idLuna, jetzt, idDogi); +const idAlt = d.prepare("SELECT last_insert_rowid() AS id").get().id; +d.close(); + +async function anmelden(rolle, code) { + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle, code }) }); + const keks = (a.headers.getSetCookie?.() || []).map((z) => z.split(";")[0]).join("; "); + if (!keks) throw new Error(`Anmeldung ${rolle} fehlgeschlagen (${a.status})`); + return keks; +} +const K = { + dogi: await anmelden("admin", "CODE-DOGI-0001"), + pat: await anmelden("scout", "CODE-SCOU-0001"), + bea: await anmelden("scout", "CODE-SCOU-0002"), + luna: await anmelden("creator", "CODE-CREA-0001"), + nora: await anmelden("creator", "CODE-CREA-0002"), +}; + +const ruf = async (art, pfad, rumpf, keks) => { + const a = await fetch(BASIS + pfad, { + method: art, + headers: { "Content-Type": "application/json", cookie: keks, origin: BASIS }, + body: rumpf === undefined ? undefined : JSON.stringify(rumpf), + }); + let text = ""; try { text = await a.text(); } catch { /* egal */ } + let daten = null; try { daten = JSON.parse(text); } catch { /* kein JSON */ } + return { status: a.status, text, daten }; +}; + +/** Sieht diese Person den Termin mit dieser Nummer? */ +async function siehtTermin(keks, id) { + const a = await ruf("GET", "/workspace/api/termine?tage=30", undefined, keks); + if (a.status !== 200) return false; + return (a.daten?.termine || []).some((t) => t.id === id); +} + +/* ======================================================================= + 1. Die Uebernahme alter Termine + ======================================================================= */ +console.log("\n=== Alte Termine behalten ihren Teilnehmer ==="); +{ + const a = await ruf("GET", "/workspace/api/termine?tage=30", undefined, K.dogi); + const alt = (a.daten?.termine || []).find((t) => t.id === idAlt); + ok(!!alt, "der alte Termin ist da"); + ok(Array.isArray(alt?.teilnehmer) && alt.teilnehmer.some((p) => p.id === idLuna), + `sein Teilnehmer wurde uebernommen (${(alt?.teilnehmer || []).map((p) => p.name).join(", ") || "LEER"})`); + ok(await siehtTermin(K.luna, idAlt), "und Luna sieht ihn weiterhin"); +} + +/* ======================================================================= + 2. Zwei Teilnehmer anlegen + ======================================================================= */ +console.log("\n=== Ein Call mit zwei Leuten ==="); +let idCall = 0; +{ + const a = await ruf("POST", "/workspace/api/termine", { + titel: "Call zu dritt", art: "call", beginn: heute + "T14:00", + teilnehmer_id: idLuna, teilnehmer: [idLuna, idPat], + }, K.dogi); + ok(a.status === 201, `angelegt (${a.status})`); + idCall = a.daten?.id || 0; + ok(idCall > 0, `hat eine Nummer bekommen (#${idCall})`); + ok((a.daten?.teilnehmer || []).length === 2, + `zwei Teilnehmer gespeichert (${(a.daten?.teilnehmer || []).length})`); + + const b = await ruf("GET", "/workspace/api/termine?tage=30", undefined, K.dogi); + const t = (b.daten?.termine || []).find((x) => x.id === idCall); + ok((t?.teilnehmer || []).length === 2, + `und sie kommen zurueck: ${(t?.teilnehmer || []).map((p) => p.name).join(", ") || "LEER"}`); +} + +/* ======================================================================= + 3. BEIDE sehen den Termin — das ist der Kern + ======================================================================= */ +console.log("\n=== Beide Eingetragenen sehen ihn ==="); +ok(await siehtTermin(K.luna, idCall), "Luna sieht den Call (Haupt-Gegenueber)"); +ok(await siehtTermin(K.pat, idCall), "Pat sieht den Call (zweiter Teilnehmer)"); +ok(await siehtTermin(K.dogi, idCall), "DogFather sieht ihn ohnehin"); + +/* ======================================================================= + 4. Und die anderen NICHT + ======================================================================= */ +console.log("\n=== Wer nicht dabei ist, sieht ihn nicht ==="); +ok(!(await siehtTermin(K.nora, idCall)), "Nora (fremde Creatorin) sieht ihn nicht"); +ok(!(await siehtTermin(K.bea, idCall)), "Bea (fremde Scoutin) sieht ihn nicht"); + +/* ======================================================================= + 5. Niemand traegt sich selbst in fremde Termine ein + ======================================================================= */ +console.log("\n=== Kein Eintragen in fremde Termine ==="); +{ + /* Nora versucht, sich in den Call einzutragen. Sie sieht ihn nicht -- + also muss schon das Aendern scheitern (404, nicht 403: Was man + nicht sehen darf, existiert fuer einen nicht). */ + const a = await ruf("PATCH", `/workspace/api/termine/${idCall}`, + { teilnehmer: [idNora] }, K.nora); + ok(a.status === 404, `Nora aendert den fremden Call -> ${a.status} (erwartet 404)`); + ok(!(await siehtTermin(K.nora, idCall)), "und sie sieht ihn danach immer noch nicht"); + + /* Pat SIEHT den Call (er ist Teilnehmer). Aendert er die Liste, + darf das nichts bewirken -- Zuordnungen verschiebt nur die + Leitung. */ + const b = await ruf("PATCH", `/workspace/api/termine/${idCall}`, + { teilnehmer: [idPat, idNora] }, K.pat); + ok(b.status < 500, `Pat aendert die Liste -> ${b.status} (kein Serverfehler)`); + ok(!(await siehtTermin(K.nora, idCall)), + "Nora ist dadurch NICHT hineingekommen — ein Scout verschiebt keine Zuordnungen"); +} + +/* ======================================================================= + 6. Aendern: jemanden hinzufuegen und wieder entfernen + ======================================================================= */ +console.log("\n=== Hinzufuegen und Entfernen ==="); +{ + const a = await ruf("PATCH", `/workspace/api/termine/${idCall}`, + { teilnehmer: [idLuna, idPat, idBea] }, K.dogi); + ok(a.status === 200, `dritte Person hinzugefuegt (${a.status})`); + ok(await siehtTermin(K.bea, idCall), "Bea sieht den Call jetzt"); + + const b = await ruf("PATCH", `/workspace/api/termine/${idCall}`, + { teilnehmer: [idLuna] }, K.dogi); + ok(b.status === 200, `wieder entfernt (${b.status})`); + ok(!(await siehtTermin(K.bea, idCall)), "Bea sieht ihn nicht mehr"); + ok(await siehtTermin(K.luna, idCall), "Luna weiterhin schon"); + + /* Das Haupt-Gegenueber bleibt drin, auch wenn die Liste es nicht + nennt -- sonst verlöre der Termin seinen eigentlichen Partner. */ + const c = await ruf("PATCH", `/workspace/api/termine/${idCall}`, + { teilnehmer: [] }, K.dogi); + ok(c.status === 200, `leere Liste geschickt (${c.status})`); + ok(await siehtTermin(K.luna, idCall), + "das Haupt-Gegenueber bleibt trotzdem eingetragen"); +} + +/* ======================================================================= + 7. Serien vererben ihre Teilnehmer + ======================================================================= */ +console.log("\n=== Wiederkehrende Termine ==="); +{ + const a = await ruf("POST", "/workspace/api/serien", { + titel: "Woechentlicher Team-Call", art: "call", takt: "woechentlich", + start_tag: heute, uhrzeit: "11:00", dauer_min: 30, + teilnehmer_id: idLuna, teilnehmer: [idLuna, idPat], + }, K.dogi); + ok(a.status === 201, `Serie angelegt (${a.status})`); + ok((a.daten?.angelegt || 0) > 1, `${a.daten?.angelegt} Termine erzeugt`); + + const b = await ruf("GET", "/workspace/api/termine?tage=60", undefined, K.dogi); + const ausSerie = (b.daten?.termine || []).filter((t) => t.titel === "Woechentlicher Team-Call"); + ok(ausSerie.length > 1, `${ausSerie.length} davon stehen im Kalender`); + const alleMitBeiden = ausSerie.every((t) => + (t.teilnehmer || []).some((p) => p.id === idLuna) + && (t.teilnehmer || []).some((p) => p.id === idPat)); + ok(alleMitBeiden, "JEDE Auspraegung hat beide Teilnehmer geerbt"); + + const c = await ruf("GET", "/workspace/api/termine?tage=60", undefined, K.pat); + const patSieht = (c.daten?.termine || []).filter((t) => t.titel === "Woechentlicher Team-Call"); + ok(patSieht.length === ausSerie.length, + `Pat sieht alle ${patSieht.length} von ${ausSerie.length} — nicht nur den ersten`); +} + +/* ======================================================================= + 8. Unsinn in der Liste + ======================================================================= */ +console.log("\n=== Unsinn in der Teilnehmerliste ==="); +{ + for (const [wie, wert] of [ + ["Text statt Nummern", ["abc", "xyz"]], + ["negative Nummer", [-1]], + ["null", [null]], + ["gar keine Liste", "keine Liste"], + ["riesige Liste", Array.from({ length: 500 }, (_, i) => i + 1)], + ["doppelte Eintraege", [idLuna, idLuna, idLuna]], + ["Person, die es nicht gibt", [999999]], + ]) { + const a = await ruf("PATCH", `/workspace/api/termine/${idCall}`, + { teilnehmer: wert }, K.dogi); + ok(a.status < 500, `${wie} -> ${a.status} (kein Serverfehler)`); + } + ok(await siehtTermin(K.luna, idCall), + "nach all dem ist das Haupt-Gegenueber immer noch eingetragen"); +} + +/* ======================================================================= + GEGENPROBE — kann diese Pruefung "nicht sichtbar" erkennen? + ======================================================================= */ +console.log("\n=== Gegenprobe ==="); +{ + const a = await ruf("POST", "/workspace/api/termine", { + titel: "Nur fuer DogFather", art: "termin", beginn: heute + "T20:00", + }, K.dogi); + const geheim = a.daten?.id; + ok(geheim > 0, `ein Termin ohne Teilnehmer angelegt (#${geheim})`); + ok(await siehtTermin(K.dogi, geheim), "DogFather sieht ihn"); + ok(!(await siehtTermin(K.luna, geheim)), + "Luna sieht ihn NICHT — die Messung erkennt also 'nicht sichtbar'"); + + const b = await ruf("PATCH", `/workspace/api/termine/${geheim}`, + { teilnehmer: [idLuna] }, K.dogi); + ok(b.status === 200, `Luna eingetragen (${b.status})`); + ok(await siehtTermin(K.luna, geheim), + "jetzt sieht sie ihn — die Messung erkennt auch 'sichtbar'"); +} + +console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-kalender.js b/server/workspace-kalender.js index d8a2c735..757ffc71 100644 --- a/server/workspace-kalender.js +++ b/server/workspace-kalender.js @@ -14,7 +14,7 @@ import express from "express"; import { db, protokolliere, echteIp, sitzungLesen, istLeitung, istDogFather, betreutWo, termineSichtbar, heuteLokal, - externPruefen, externSql, + externPruefen, externSql, betreuteIds, ROLLEN_SORTIERUNG, } from "./workspace.js"; import { nachfuellenAlle, serienPruefen, serieAnlegen, zuordnungErzwingen, @@ -76,6 +76,111 @@ const VERBUND = ` 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. */ +function darfEintragen(person) { + if (istLeitung(person)) return null; // null = alle + const erlaubt = new Set([person.id]); + 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 ------------------------------------------------------- */ kalenderRouter.get("/workspace/api/termine", (req, res) => { @@ -105,6 +210,12 @@ kalenderRouter.get("/workspace/api/termine", (req, res) => { 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 @@ -245,7 +356,7 @@ kalenderRouter.post("/workspace/api/termine", gleicheHerkunft, (req, res) => { 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); + const { id, angelegt } = serieAnlegen(gepruft.aus, req.person, req.body?.teilnehmer); return res.status(201).json({ serie_id: id, angelegt }); } @@ -258,11 +369,18 @@ kalenderRouter.post("/workspace/api/termine", gleicheHerkunft, (req, res) => { 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), + detail: `#${lastInsertRowid} ${aus.titel}`.slice(0, 120) + + (dabei.length > 1 ? ` (${dabei.length} Teilnehmer)` : ""), }); - res.status(201).json({ id: Number(lastInsertRowid) }); + 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" }); @@ -286,8 +404,18 @@ kalenderRouter.patch("/workspace/api/termine/:id", gleicheHerkunft, (req, res) = 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) return res.status(400).json({ fehler: "nichts_zu_aendern" }); + 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 @@ -295,14 +423,27 @@ kalenderRouter.patch("/workspace/api/termine/:id", gleicheHerkunft, (req, res) = neu gebaut. Was jemand verschoben, umbenannt oder abgehakt hat, räumt die Automatik ihm nicht weg. */ const beruehrt = termin.serie_id ? ", serie_beruehrt = 1" : ""; - db().prepare(`UPDATE termine SET ${felder.map((f) => `${f} = ?`).join(", ")}${beruehrt} - WHERE id = ?`).run(...felder.map((f) => aus[f]), id); + 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(",")}`.slice(0, 120), + detail: `#${id} ${felder.join(",")}${dabei ? ` teilnehmer:${dabei.length}` : ""}`.slice(0, 120), }); - res.json({ ok: true }); + res.json({ ok: true, ...(dabei ? { teilnehmer: dabei } : {}) }); } catch (fehler) { console.error("[workspace] Termin ändern:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); diff --git a/server/workspace-serien.js b/server/workspace-serien.js index 25dcdf77..57335820 100644 --- a/server/workspace-serien.js +++ b/server/workspace-serien.js @@ -59,7 +59,7 @@ import express from "express"; import { db, protokolliere, echteIp, sitzungLesen, istLeitung, termineSichtbar, - heuteLokal, tagLokal, externPruefen, externSql, + heuteLokal, tagLokal, externPruefen, externSql, betreuteIds, } from "./workspace.js"; export const serienRouter = express.Router(); @@ -269,13 +269,35 @@ export function serieNachfuellen(serieId) { 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; - einfuegen.run(s.titel, s.beschreibung, s.art, `${tag}T${s.uhrzeit}`, s.dauer_min, + 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; @@ -405,7 +427,7 @@ export function serienPruefen(körper, { neu }) { /** 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) { +export function serieAnlegen(aus, person, teilnehmer = null) { const d = db(); const { lastInsertRowid } = d.prepare(` INSERT INTO termin_serien @@ -418,6 +440,13 @@ export function serieAnlegen(aus, person) { 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, @@ -426,6 +455,32 @@ export function serieAnlegen(aus, person) { 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. */ @@ -521,7 +576,7 @@ serienRouter.post("/workspace/api/serien", gleicheHerkunft, (req, res) => { return res.status(400).json({ fehler: "Zugeordnete Person gibt es nicht." }); } } - const { id, angelegt } = serieAnlegen(aus, req.person); + 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); diff --git a/server/workspace.js b/server/workspace.js index 085e5fc7..77887d79 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -248,6 +248,38 @@ function umstellungen(d) { console.error("[workspace] Index idx_termine_serie:", fehler?.message); } + /* ---- Die vorhandenen Teilnehmer in die neue Tabelle uebernehmen ---- + + Ohne diesen Schritt haette jeder Termin, der vor dem 05.09.2026 + angelegt wurde, eine LEERE Teilnehmerliste -- und weil an dieser + Liste die Sichtbarkeit haengt, faende die eingetragene Person ihren + eigenen Termin nicht mehr. Ein stiller Datenverlust, der erst + auffaellt, wenn jemand einen Call sucht, den es noch gibt. + + `INSERT OR IGNORE` macht den Lauf wiederholbar: Beim zweiten Start + ist alles schon da und nichts passiert. Deshalb steht das hier bei + den Umstellungen und nicht in einem einmaligen Skript, das jemand + vergessen koennte. */ + for (const [tabelle, quelle, ziel, schluessel] of [ + ["termin_teilnehmer", "termine", "termin_id", "id"], + ["serie_teilnehmer", "termin_serien", "serie_id", "id"], + ]) { + try { + const spalten = d.prepare(`PRAGMA table_info(${quelle})`).all().map((s) => s.name); + if (!spalten.includes("teilnehmer_id")) continue; + const vorher = d.prepare(`SELECT COUNT(*) AS n FROM ${tabelle}`).get().n; + d.exec(`INSERT OR IGNORE INTO ${tabelle} (${ziel}, person_id) + SELECT ${schluessel}, teilnehmer_id FROM ${quelle} + WHERE teilnehmer_id IS NOT NULL`); + const nachher = d.prepare(`SELECT COUNT(*) AS n FROM ${tabelle}`).get().n; + if (nachher > vorher) { + console.log(`[workspace] ${nachher - vorher} vorhandene Teilnehmer nach ${tabelle} uebernommen.`); + } + } catch (fehler) { + console.error(`[workspace] Uebernahme nach ${tabelle}:`, fehler?.message); + } + } + /* Content-Saeulen: die drei bis fuenf Themen, aus denen der Kanal besteht. Aus der Recherche: 3-5 Saeulen nach der 70/20/10-Regel (70 % Wert, 20 % Community, 10 % Eigenwerbung); eine Saeule wird @@ -492,6 +524,52 @@ export function db() { ); CREATE INDEX IF NOT EXISTS idx_termine_beginn ON termine (beginn); + /* MEHRERE TEILNEHMER AN EINEM TERMIN (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."* + + Bis hierher hatte ein Termin GENAU EIN Gegenueber + (termine.teilnehmer_id). Fuer ein Gespraech zu dritt musste man + zwei Termine anlegen -- und hatte damit zwei Wahrheiten ueber + dieselbe halbe Stunde. + + Gebaut nach demselben Muster wie datei_personen weiter unten: + eine eigene kleine Tabelle statt weiterer Spalten. Zwei Spalten + "teilnehmer2_id", "teilnehmer3_id" waeren beim vierten Menschen + wieder am Ende, und jede Abfrage muesste alle einzeln + aufzaehlen. + + WICHTIG -- teilnehmer_id BLEIBT und behaelt seine Bedeutung: + Es ist das HAUPT-Gegenueber, mit dem der Termin vereinbart + wurde. Diese Tabelle sagt, WER SONST NOCH dabei ist. Beim + Umstellen wird der vorhandene Wert mit uebernommen, damit die + Teilnehmerliste von Anfang an vollstaendig ist und keine + Abfrage zwei Stellen zusammensuchen muss. + + An dieser Tabelle haengt die SICHTBARKEIT (siehe + termineSichtbar): Wer hier steht, sieht den Termin. Ein + vergessener Eintrag ist deshalb kein Schoenheitsfehler, + sondern ein Termin, den jemand nicht findet. */ + CREATE TABLE IF NOT EXISTS termin_teilnehmer ( + termin_id INTEGER NOT NULL REFERENCES termine(id) ON DELETE CASCADE, + person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE, + PRIMARY KEY (termin_id, person_id) + ); + CREATE INDEX IF NOT EXISTS idx_termin_teilnehmer ON termin_teilnehmer (person_id); + + /* Dasselbe fuer die Wiederholungen. Eine Serie ist eine Regel -- + die Teilnehmer gehoeren zur Regel, nicht zur einzelnen + Auspraegung, sonst muesste man sie jede Woche neu eintragen. + Der Nachfueller kopiert sie beim Anlegen jedes Termins hierher + hinueber (siehe workspace-serien.js). */ + CREATE TABLE IF NOT EXISTS serie_teilnehmer ( + serie_id INTEGER NOT NULL REFERENCES termin_serien(id) ON DELETE CASCADE, + person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE, + PRIMARY KEY (serie_id, person_id) + ); + CREATE INDEX IF NOT EXISTS idx_serie_teilnehmer ON serie_teilnehmer (person_id); + /* Wiederkehrende Termine (02.09.2026) -------------------------------- Eine Serie ist eine REGEL, kein Termin: "jeden Dienstag um 18:00 @@ -1373,8 +1451,30 @@ export const externSql = (personSpalte, externSpalte) => export function termineSichtbar(person, praefix = "t") { if (istDogFather(person)) return { wo: "1=1", werte: [] }; const p = praefix; - const eigen = `(${p}.creator_id = ? OR ${p}.teilnehmer_id = ? OR ${p}.erstellt_von = ?)`; - const werte = [person.id, person.id, person.id]; + + /* MITGEZAEHLT WIRD AUCH DIE TEILNEHMERLISTE (05.09.2026). + + Seit ein Termin mehrere Teilnehmer haben kann, reicht + `teilnehmer_id` nicht mehr: Das ist nur das Haupt-Gegenueber. Wer + als zweiter oder dritter dabei ist, steht in termin_teilnehmer -- + und ohne diese Zeile saehe er den Termin nicht, an dem er + teilnimmt. + + Die beiden Aufrufer arbeiten auf verschiedenen Tabellen: der + Kalender auf `termine` (praefix t), die Wiederholungen auf + `termin_serien` (praefix s). Deshalb wird hier die passende + Nebentabelle gewaehlt statt einer festen -- eine falsche Zuordnung + waere kein Fehler, den man sieht, sondern eine Liste, in der + jemandem etwas fehlt. */ + const nebenTabelle = p === "s" + ? { tabelle: "serie_teilnehmer", spalte: "serie_id" } + : { tabelle: "termin_teilnehmer", spalte: "termin_id" }; + const dabei = `EXISTS (SELECT 1 FROM ${nebenTabelle.tabelle} tn` + + ` WHERE tn.${nebenTabelle.spalte} = ${p}.id AND tn.person_id = ?)`; + + const eigen = `(${p}.creator_id = ? OR ${p}.teilnehmer_id = ? OR ${p}.erstellt_von = ?` + + ` OR ${dabei})`; + const werte = [person.id, person.id, person.id, person.id]; const b = betreutWo(person, `${p}.creator_id`); if (!b) return { wo: eigen, werte }; return { wo: `(${eigen} OR ${b.wo})`, werte: [...werte, ...b.werte] }; diff --git a/workspace/assets/js/kalender.js b/workspace/assets/js/kalender.js index 41f2a25a..e4bf8f94 100644 --- a/workspace/assets/js/kalender.js +++ b/workspace/assets/js/kalender.js @@ -75,6 +75,59 @@ let ich = null; let daten = null; let serien = []; + + /* WER SONST NOCH DABEI IST (05.09.2026). + Einzelne Schalter statt eines