diff --git a/server/manager-ziele-tabellen.js b/server/manager-ziele-tabellen.js
index 8a7a94ef..a231e26c 100644
--- a/server/manager-ziele-tabellen.js
+++ b/server/manager-ziele-tabellen.js
@@ -153,16 +153,40 @@ export const ERSTER_MONAT = "2026-10";
* vergessenes Zurücksetzen keine dauerhaft offene Tür ist. */
export const FREIGABE_SEKUNDEN = 120;
-export function managerZieleTabellen(d) {
- d.exec(`
+/* Die Tabellen als TEXT und nicht direkt ausgefuehrt: Der Umbau
+ weiter unten muss sie nach einem DROP TABLE ein zweites Mal
+ anlegen koennen -- samt der Indizes, die mit der Tabelle
+ verschwinden. Zweimal hingeschrieben waeren es zwei Fassungen,
+ von denen eine veraltet. */
+const TABELLEN = `
/* ---------- Ein Eintrag --------------------------------------- */
CREATE TABLE IF NOT EXISTS mz_eintrag (
id INTEGER PRIMARY KEY AUTOINCREMENT,
- /* CASCADE und nicht SET NULL: Ein Eintrag ohne Person ist in
- dieser Tabelle sinnlos — gezählt wird je Person. Was bleiben
- soll, wenn jemand geht, ist die Pipeline und das Protokoll,
- nicht sein Monatszähler. */
- person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
+ /* SET NULL UND NICHT CASCADE -- berichtigt am 02.10.2026, am
+ selben Tag, an dem die Tabelle entstand.
+
+ Zuerst stand hier CASCADE mit der Begruendung, ein Eintrag
+ ohne Person sei sinnlos. Die Vorlage sagt aber ausdruecklich:
+ „Wer die Rolle verliert, sieht die Kachel nicht mehr; die
+ Daten bleiben fuer den DogFather erhalten." Mit CASCADE waere
+ genau das nicht wahr gewesen -- DELETE FROM personen in
+ workspace-personen.js haette den ganzen Monatsverlauf eines
+ Menschen lautlos mitgenommen, mitten in einem laufenden Jahr.
+
+ Das ist dieselbe Entscheidung wie bei support_meldungen: Wird
+ ein Zugang geloescht, verliert der Eintrag seinen Absender,
+ nicht seinen Inhalt. Deshalb stehen Name und Rolle zusaetzlich
+ als Text daneben -- eine Verknuepfung allein ueberlebt das
+ Loeschen nicht. */
+ person_id INTEGER REFERENCES personen(id) ON DELETE SET NULL,
+ /* WER ES WAR, als er es eintrug. Nicht nur verknuepft, sondern
+ mitgeschrieben: Nach einem Rollenwechsel oder einer Loeschung
+ stuende sonst „—" neben einer Zahl, die in den Monatsverlauf
+ eingeht. Die Rolle entscheidet ausserdem, welches Ziel fuer
+ diesen Monat galt -- eine heute andere Rolle wuerde den
+ abgeschlossenen Monat rueckwirkend an anderen Zahlen messen. */
+ von_name TEXT,
+ von_rolle TEXT,
aufgabe TEXT NOT NULL
CHECK (aufgabe IN ('creator','meeting','schulung','werbung')),
/* 'JJJJ-MM'. Der Monatsschnitt entsteht über diese Spalte und
@@ -256,7 +280,139 @@ export function managerZieleTabellen(d) {
bis TEXT
);
INSERT OR IGNORE INTO mz_freigabe (eins, offen, bis) VALUES (1, 0, NULL);
- `);
+`;
+
+/* =====================================================================
+ DER UMBAU: aus CASCADE wird SET NULL (02.10.2026)
+ =====================================================================
+
+ Noetig, weil die Tabelle am selben Tag schon mit CASCADE ausgeliefert
+ wurde. Ein ALTER TABLE kann in SQLite weder einen Fremdschluessel
+ noch ein NOT NULL aendern -- dafuer gibt es nur den Weg ueber eine
+ neue Tabelle.
+
+ DREI DINGE, DIE HIER ANDERS GEMACHT SIND ALS BEIM LETZTEN MAL
+
+ 1. DIE SPALTENLISTE WIRD ABGELEITET, NICHT GEPFLEGT. Am 11.09.2026
+ hat im Creator Workspace genau so ein Umbau DREI Spalten mit
+ Inhalt verloren -- die Liste war von Hand abgeschrieben, zweimal
+ (einmal fuer CREATE, einmal fuer INSERT), und beim Hinzufuegen
+ der naechsten Spalte hat sie niemand nachgezogen. Hier fragt
+ PRAGMA, was in BEIDEN Tabellen steht; eine Liste, die niemand
+ pflegt, kann nicht veralten.
+
+ 2. GEZAEHLT WERDEN ZEILEN *UND* SPALTEN. Die Zeilenzaehlung war
+ damals als Sicherung gedacht und konnte den Spaltenverlust gar
+ nicht sehen: 24 Spalten hinein, 21 heraus, bei unveraenderter
+ Zeilenzahl, ohne Fehlermeldung. Stimmt hier etwas nicht, wird
+ geworfen -- und die Umstellung laeuft in einer Transaktion,
+ die dann zurueckrollt.
+
+ 3. ER LAEUFT NUR, WENN ER MUSS. Gefragt wird die Datenbank selbst
+ (PRAGMA foreign_key_list), nicht eine Fassungsnummer, die jemand
+ hochzaehlen muesste.
+ ===================================================================== */
+function umbauWennNoetig(d) {
+ let brauchtEs = false;
+ try {
+ const verweise = d.prepare("PRAGMA foreign_key_list(mz_eintrag)").all();
+ brauchtEs = verweise.some((v) => v.table === "personen"
+ && v.from === "person_id" && String(v.on_delete).toUpperCase() === "CASCADE");
+ } catch {
+ return; // Tabelle gibt es (noch) nicht
+ }
+ if (!brauchtEs) return;
+
+ const spaltenVon = (t) => d.prepare(`PRAGMA table_info(${t})`).all().map((s) => s.name);
+ const vorherZeilen = d.prepare("SELECT COUNT(*) n FROM mz_eintrag").get().n;
+ const vorherSpalten = spaltenVon("mz_eintrag");
+
+ /* Die Fremdschluessel muessen WAEHREND des Umbaus aus sein, sonst
+ laufen die Verweise waehrend der Umbenennung ins Leere. Danach
+ wieder an -- und ueberprueft. */
+ const warenAn = d.prepare("PRAGMA foreign_keys").get()?.foreign_keys;
+ d.exec("PRAGMA foreign_keys = OFF");
+ /* EIGENE TRANSAKTION NUR, WENN NOCH KEINE LAEUFT. Diese Funktion
+ wird aus dem Bauplan in workspace.js gerufen, und der oeffnet an
+ zwei Stellen selbst eine. Ein BEGIN darin wirft („cannot start a
+ transaction within a transaction") -- und das waere ein Abbruch
+ beim Serverstart, also die schlimmste Stelle fuer einen Fehler,
+ der sich vermeiden laesst. */
+ let meine = false;
+ try {
+ try { d.exec("BEGIN"); meine = true; } catch { meine = false; }
+ /* Die neue Tabelle entsteht aus derselben Vorlage wie die echte --
+ nur unter anderem Namen. Ein zweiter, von Hand geschriebener
+ CREATE waere die Fassung, die auseinanderlaeuft. */
+ d.exec(TABELLEN.replace(/mz_eintrag/g, "mz_eintrag_neu"));
+
+ const nachher = spaltenVon("mz_eintrag_neu");
+ const gemeinsam = vorherSpalten.filter((s) => nachher.includes(s));
+ if (!gemeinsam.includes("id") || !gemeinsam.includes("person_id")) {
+ throw new Error("Umbau: die Schluesselspalten fehlen in der neuen Tabelle");
+ }
+ const liste = gemeinsam.join(", ");
+ d.exec(`INSERT INTO mz_eintrag_neu (${liste}) SELECT ${liste} FROM mz_eintrag`);
+
+ const kopiert = d.prepare("SELECT COUNT(*) n FROM mz_eintrag_neu").get().n;
+ if (kopiert !== vorherZeilen) {
+ throw new Error(`Umbau: ${kopiert} statt ${vorherZeilen} Zeilen uebernommen`);
+ }
+ /* DIE SPALTENPROBE. Jede alte Spalte muss es weiterhin geben --
+ sonst ist still Inhalt verschwunden. */
+ const verloren = vorherSpalten.filter((s) => !nachher.includes(s));
+ if (verloren.length) {
+ throw new Error(`Umbau: Spalten verloren: ${verloren.join(", ")}`);
+ }
+
+ d.exec("DROP TABLE mz_eintrag");
+ d.exec("ALTER TABLE mz_eintrag_neu RENAME TO mz_eintrag");
+ if (meine) d.exec("COMMIT");
+ console.log(`[manager-ziele] person_id auf SET NULL umgestellt --`
+ + ` ${kopiert} Zeilen, ${nachher.length} Spalten (vorher ${vorherSpalten.length}).`);
+ } catch (fehler) {
+ if (meine) { try { d.exec("ROLLBACK"); } catch { /* schon zurueck */ } }
+ try { d.exec("DROP TABLE IF EXISTS mz_eintrag_neu"); } catch { /* egal */ }
+ console.error("[manager-ziele] Umbau abgebrochen:", fehler?.message);
+ throw fehler;
+ } finally {
+ if (warenAn) d.exec("PRAGMA foreign_keys = ON");
+ }
+
+ /* NACH dem Umbau nachsehen, ob die Verweise noch stimmen. Ein
+ Umbau mit abgeschalteten Fremdschluesseln kann sie hinterlassen,
+ ohne dass irgendetwas meckert. */
+ const kaputt = d.prepare("PRAGMA foreign_key_check(mz_eintrag)").all();
+ if (kaputt.length) {
+ console.error(`[manager-ziele] ACHTUNG: ${kaputt.length} Verweise zeigen ins Leere.`);
+ }
+}
+
+export function managerZieleTabellen(d) {
+ d.exec(TABELLEN);
+ umbauWennNoetig(d);
+ d.exec(TABELLEN); // nach einem Umbau fehlen die Indizes
+
+ /* EIN GEAENDERTER TRIGGER WIRD NICHT VON SELBST NEU (02.10.2026).
+ `CREATE TRIGGER IF NOT EXISTS` sieht den Namen, findet ihn und
+ tut nichts -- die alte Fassung bliebe stehen, und zwar fuer
+ immer. Deshalb wird nachgesehen, ob die geltende Fassung die
+ Ausnahme fuer ON DELETE SET NULL schon kennt; wenn nicht, kommt
+ sie weg und wird unten neu gebaut.
+
+ Erkannt am INHALT und nicht an einer Fassungsnummer: Eine Nummer
+ muesste jemand hochzaehlen, und genau das wird vergessen. */
+ try {
+ const alt = d.prepare(
+ "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='mz_kein_alter_update'")
+ .get()?.sql || "";
+ if (alt && !alt.includes("NEW.person_id IS NULL")) {
+ d.exec("DROP TRIGGER mz_kein_alter_update");
+ console.log("[manager-ziele] Sperr-Trigger erneuert (laesst jetzt ON DELETE SET NULL durch).");
+ }
+ } catch (fehler) {
+ console.error("[manager-ziele] Trigger pruefen:", fehler?.message);
+ }
/* ---------- Die Sperre ------------------------------------------
Drei Trigger, einer je Richtung. Sie stehen hier und nicht in
@@ -284,9 +440,33 @@ export function managerZieleTabellen(d) {
SELECT RAISE(ABORT, 'monat_gesperrt');
END;
+ /* DIE AUSNAHME IN ZEILE DREI IST KEINE BEQUEMLICHKEIT, sondern die
+ Reparatur eines Fehlers, den die Pruefung gefunden hat
+ (02.10.2026).
+
+ ON DELETE SET NULL ist kein Loeschen -- es ist ein UPDATE auf
+ mz_eintrag.person_id. Dieser Trigger hat es als Aenderung an
+ einem abgeschlossenen Monat gesehen und abgebrochen. Folge:
+ Wer eine Person loeschen wollte, die irgendwann einmal etwas
+ eingetragen hatte, bekam 'monat_gesperrt' -- und die ganze
+ Personenverwaltung in workspace-personen.js waere daran
+ gescheitert, an einer Stelle, die mit Monatszielen nichts zu
+ tun hat.
+
+ Erlaubt ist deshalb genau EINE Aenderung an einem alten Monat:
+ dem Eintrag seinen Besitzer zu nehmen. Alles andere -- Datum,
+ Name, Link, Notiz -- bleibt gesperrt.
+
+ ALS BEDINGUNG UND NICHT ALS SPALTENLISTE (UPDATE OF ...):
+ Eine Liste muesste jemand pflegen, und beim Hinzufuegen der
+ naechsten Spalte wuerde sie vergessen -- dann waere die Sperre
+ dort still unwirksam. Diese Bedingung beschreibt, WAS erlaubt
+ ist, und gilt damit auch fuer jede Spalte, die es noch nicht
+ gibt. */
CREATE TRIGGER IF NOT EXISTS mz_kein_alter_update
BEFORE UPDATE ON mz_eintrag
WHEN OLD.monat <> (SELECT monat FROM mz_lage WHERE eins = 1)
+ AND NOT (NEW.person_id IS NULL AND OLD.person_id IS NOT NULL)
AND NOT ((SELECT offen FROM mz_freigabe WHERE eins = 1)
AND (SELECT bis FROM mz_freigabe WHERE eins = 1) > datetime('now'))
BEGIN
@@ -313,6 +493,7 @@ export function managerZieleTabellen(d) {
const fehlt = [
["name_klein", "TEXT"], ["link_klein", "TEXT"],
["doppelt_bei", "INTEGER REFERENCES personen(id) ON DELETE SET NULL"],
+ ["von_name", "TEXT"], ["von_rolle", "TEXT"],
].filter(([spalte]) => !da.has(spalte));
for (const [spalte, typ] of fehlt) {
d.exec(`ALTER TABLE mz_eintrag ADD COLUMN ${spalte} ${typ}`);
diff --git a/server/mess-manager-ziele.mjs b/server/mess-manager-ziele.mjs
index e566cdca..9883cc5a 100644
--- a/server/mess-manager-ziele.mjs
+++ b/server/mess-manager-ziele.mjs
@@ -94,6 +94,21 @@ setz.run(idScout, "meeting", MONAT, HEUTE, null, null, null, null, null, "Wochen
setz.run(idScout, "werbung", MONAT, HEUTE, null, null,
"https://www.tiktok.com/@dogfather/video/7312", "tiktok.com/@dogfather/video/7312",
"agentur", null, jetzt);
+/* EINE UEBERFAELLIGE AUFGABE -- nicht fuer diese Seite, sondern fuer
+ die KACHELWAND davor.
+
+ Seit dem 02.10.2026 traegt eine Kachel, an der eine Warnung haengt,
+ einen farbigen Saum (Vorlage, Punkt 05: „farbiger Rand + Badge").
+ Ohne irgendeine Warnung im Bestand waere auf dem Bild der
+ Startseite nichts davon zu sehen -- und „kein Saum" sieht genauso
+ aus wie „Saum kaputt". Eine ueberfaellige Aufgabe ist die
+ einfachste Warnung, die sich herstellen laesst, und sie beweist
+ den Saum fuer JEDE Kachel, nicht nur fuer eine. */
+d.prepare(`INSERT INTO aufgaben (titel, status, prioritaet, verantwortlich_id, frist, erstellt, erstellt_von)
+ VALUES (?,?,?,?,?,?,?)`)
+ .run("Laengst faellig (nur fuer das Bild)", "offen", "hoch", idScout,
+ "2026-09-01", jetzt, idScout);
+
/* Ein uebergebener Lead, der noch nicht eingetragen ist -- damit der
Vorschlagskasten auf dem Bild zu sehen ist. */
d.prepare(`INSERT INTO leads (name, handle, status, scout_id, erstellt, erstellt_von, uebergeben_am)
@@ -214,6 +229,52 @@ await bild("formular-handy", keksScout, 412, 915, async (seite) => {
if (knopf) { await knopf.click(); await seite.waitForTimeout(400); }
});
+/* ---------- Und die Kachelwand davor --------------------------------
+ Gemessen wird nicht, wie es aussieht, sondern WAS wirklich gesetzt
+ ist: das Merkmal am Element und der Schatten, den der Browser
+ daraus errechnet. Ein Bild allein kann einen Saum von einem Pixel
+ nicht beweisen. */
+{
+ const kontext = await browser.newContext({ viewport: { width: 412, height: 915 }, hasTouch: true });
+ await kontext.addCookies(keksScout.split("; ").map((k) => {
+ const [n, ...r] = k.split("=");
+ return { name: n, value: r.join("="), domain: "127.0.0.1", path: "/" };
+ }));
+ const seite = await kontext.newPage();
+ await seite.goto(`http://127.0.0.1:${PORT}/workspace/start.html`, { waitUntil: "networkidle" });
+ await seite.waitForTimeout(1800);
+ const wand = await seite.evaluate(() => {
+ const meine = [...document.querySelectorAll('.kachel')]
+ .find((k) => (k.querySelector('.kachel__link')?.getAttribute('href') || '')
+ .startsWith('manager-ziele.html'));
+ const gewarnt = [...document.querySelectorAll('.kachel[data-warn="ja"]')];
+ return {
+ kachelDa: !!meine,
+ name: meine?.querySelector('.kachel__name')?.textContent || '-',
+ zahl: meine?.querySelector('.kachel__zahl')?.textContent || '-',
+ gewarnte: gewarnt.length,
+ saum: gewarnt[0] ? getComputedStyle(gewarnt[0]).boxShadow.slice(0, 60) : '-',
+ /* GEGENPROBE: Eine Kachel OHNE Warnung darf den Saum nicht haben.
+ Sonst waere „alle haben ihn" dasselbe wie „keiner hat ihn". */
+ ohneWarnung: [...document.querySelectorAll('.kachel:not([data-warn])')].length,
+ };
+ });
+ /* ZU DER KACHEL HIN, UM DIE ES GEHT. Ein Bild vom Seitenanfang
+ zeigte beim ersten Lauf den Begruessungsblock -- richtig
+ aufgenommen und ohne jeden Nutzen. */
+ const gewarnteKachel = await seite.$('.kachel[data-warn="ja"]');
+ if (gewarnteKachel) await gewarnteKachel.scrollIntoViewIfNeeded();
+ await seite.waitForTimeout(400);
+ await seite.screenshot({ path: "bild-manager-ziele-wand.png", fullPage: false });
+ console.log("\n--- Die Kachelwand (412px) ---");
+ console.log(` Kachel da: ${wand.kachelDa ? "ja" : "NEIN — Befund!"} (${wand.name})`);
+ console.log(` Abzeichen: ${wand.zahl}`);
+ console.log(` Kacheln mit Saum: ${wand.gewarnte}`);
+ console.log(` ohne Saum: ${wand.ohneWarnung}`);
+ console.log(` Schatten: ${wand.saum}`);
+ await kontext.close();
+}
+
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
diff --git a/server/pruef-manager-ziele.mjs b/server/pruef-manager-ziele.mjs
index a4233dae..43986567 100644
--- a/server/pruef-manager-ziele.mjs
+++ b/server/pruef-manager-ziele.mjs
@@ -434,8 +434,13 @@ ok(triggerHaelt,
const weit = new Date(Date.now() + 600_000).toISOString().replace("T", " ").slice(0, 19);
db2.prepare("UPDATE mz_freigabe SET offen = 1, bis = ? WHERE eins = 1").run(weit);
-db2.prepare("INSERT INTO mz_eintrag (person_id, aufgabe, monat, datum, erstellt)"
- + " VALUES (?,?,?,?,?)").run(IDS.scout, "meeting", VORMONAT, `${VORMONAT}-05`, jetztIso);
+/* Name und Rolle stehen mit drin -- genau so, wie der Server es tut.
+ Ohne sie waere dieser Eintrag der einzige ohne, und die Pruefung
+ weiter unten („an allen Eintraegen stehen Name und Rolle") haette
+ einen Fehler gemeldet, den nur diese Zeile verursacht hat. */
+db2.prepare("INSERT INTO mz_eintrag (person_id, von_name, von_rolle, aufgabe, monat, datum, erstellt)"
+ + " VALUES (?,?,?,?,?,?,?)")
+ .run(IDS.scout, "Kevin", "scout", "meeting", VORMONAT, `${VORMONAT}-05`, jetztIso);
const altId = db2.prepare("SELECT last_insert_rowid() AS id").get().id;
db2.prepare("UPDATE mz_freigabe SET offen = 0, bis = NULL WHERE eins = 1").run();
db2.close();
@@ -726,6 +731,222 @@ if (heuteNr === 1) {
"…alle seine Zeilen stehen auf neutral, keine einzige warnt");
}
+/* =====================================================================
+ 13 · WER GEHT, NIMMT SEINE ZAHLEN NICHT MIT
+ =====================================================================
+
+ Vorlage, Punkt 09: „Wer die Rolle verliert, sieht die Kachel nicht
+ mehr; die Daten bleiben fuer den DogFather erhalten."
+
+ DREI WEGE, AUF DENEN JEMAND VERSCHWINDEN KANN, und alle drei muessen
+ die Zahlen stehen lassen: Rollenwechsel, Stilllegung, Loeschung.
+ Der dritte ist der gefaehrliche -- er loescht wirklich eine Zeile
+ in `personen`, und mit ON DELETE CASCADE waere der Monatsverlauf
+ lautlos mitgegangen.
+ ===================================================================== */
+melde("\n=== Wer die Rolle verliert ===");
+
+const dbV = new DatabaseSync(process.env.WORKSPACE_DB);
+/* Sandra (scout2) hat weiter oben einen Creator eingetragen. Jetzt
+ wird sie Creator -- sie verliert damit die Kachel. */
+dbV.prepare("UPDATE personen SET rolle = 'creator' WHERE id = ?").run(IDS.scout2);
+dbV.close();
+
+const teamNachWechsel = await (await hol("admin", `${API}/team?monat=${MONAT}`)).json();
+const sandra = teamNachWechsel.leute.find((x) => x.id === IDS.scout2);
+ok(!!sandra, "wer die Rolle verliert, steht weiter in der Team-Uebersicht");
+ok(sandra?.ehemalig === true, '…und ist als „nicht mehr dabei" gekennzeichnet');
+ok(sandra?.rolle === "scout",
+ `…und wird an der Rolle von DAMALS gemessen (${sandra?.rolle}), nicht an der von heute`);
+ok(sandra?.aufgaben.find((a) => a.schluessel === "creator")?.zahl >= 1,
+ "…seine Zahlen stehen unveraendert da");
+
+/* Und die Kachel ist fuer sie zu. */
+const sandraKeks = await anmelden("scout2", "creator");
+braucht(!!sandraKeks, "die Person konnte sich nach dem Rollenwechsel nicht anmelden");
+kekse.scout2 = sandraKeks;
+const sandraStand = await hol("scout2", `${API}/stand`);
+ok(sandraStand.status === 404,
+ `…sie selbst kommt nicht mehr an die Kachel (${sandraStand.status})`);
+
+melde("\n=== Und wer geloescht wird ===");
+const dbL = new DatabaseSync(process.env.WORKSPACE_DB);
+dbL.exec("PRAGMA foreign_keys = ON");
+const vorherZeilen = dbL.prepare(
+ "SELECT COUNT(*) n FROM mz_eintrag WHERE person_id = ?").get(IDS.scout2).n;
+braucht(vorherZeilen > 0, "die zu loeschende Person hatte gar keine Eintraege");
+dbL.prepare("DELETE FROM personen WHERE id = ?").run(IDS.scout2);
+const uebrig = dbL.prepare(
+ "SELECT COUNT(*) n FROM mz_eintrag WHERE person_id IS NULL AND von_name IS NOT NULL").get().n;
+const weg = dbL.prepare("SELECT COUNT(*) n FROM personen WHERE id = ?").get(IDS.scout2).n;
+dbL.close();
+ok(weg === 0, "die Person ist wirklich geloescht (sonst beweist der Rest nichts)");
+ok(uebrig === vorherZeilen,
+ `ihre ${vorherZeilen} Eintraege stehen noch da, ohne Person (${uebrig})`);
+
+const teamNachLoeschen = await (await hol("admin", `${API}/team?monat=${MONAT}`)).json();
+const verwaist = teamNachLoeschen.leute.find((x) => x.id === null);
+ok(!!verwaist, "DogFather sieht die verwaisten Eintraege als eigene Zeile");
+ok(verwaist?.name === "Sandra",
+ `…unter dem mitgeschriebenen Namen (${verwaist?.name})`);
+
+/* =====================================================================
+ 14 · DER UMBAU VON CASCADE AUF SET NULL
+ =====================================================================
+
+ Die Tabelle wurde am 02.10.2026 zuerst mit ON DELETE CASCADE
+ ausgeliefert. Der Umbau muss auf einem solchen Bestand laufen --
+ und zwar OHNE eine Zeile und ohne eine Spalte zu verlieren. Genau
+ das ist am 11.09.2026 im Creator Workspace schiefgegangen: 24
+ Spalten hinein, 21 heraus, ohne Fehlermeldung, bei unveraenderter
+ Zeilenzahl.
+ ===================================================================== */
+melde("\n=== Der Umbau auf einem alten Bestand ===");
+const { managerZieleTabellen } = await import("./manager-ziele-tabellen.js");
+const altOrdner = mkdtempSync(join(tmpdir(), "ws-mz-alt-"));
+const altPfad = join(altOrdner, "alt.db");
+const dbA2 = new DatabaseSync(altPfad);
+/* Der Stand von heute Mittag, Wort fuer Wort: NOT NULL und CASCADE,
+ ohne von_name und von_rolle. */
+dbA2.exec(`
+ CREATE TABLE personen (id INTEGER PRIMARY KEY, name TEXT, rolle TEXT, erstellt TEXT);
+ CREATE TABLE leads (id INTEGER PRIMARY KEY, name TEXT);
+ CREATE TABLE mz_eintrag (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ person_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE,
+ aufgabe TEXT NOT NULL CHECK (aufgabe IN ('creator','meeting','schulung','werbung')),
+ monat TEXT NOT NULL, datum TEXT NOT NULL,
+ name TEXT, name_klein TEXT, link TEXT, link_klein TEXT,
+ art TEXT, notiz TEXT, lead_id INTEGER, doppelt_bei INTEGER,
+ erstellt TEXT NOT NULL, geaendert TEXT, geaendert_von INTEGER);
+ INSERT INTO personen (id,name,rolle,erstellt) VALUES (1,'Alt','scout','2026-01-01T00:00:00Z');
+ INSERT INTO mz_eintrag (person_id,aufgabe,monat,datum,name,notiz,erstellt)
+ VALUES (1,'creator','2026-10','2026-10-01','@bleibtdrin','wichtige Notiz','x');
+`);
+const altSpaltenVorher = dbA2.prepare("PRAGMA table_info(mz_eintrag)").all().length;
+const altZeilenVorher = dbA2.prepare("SELECT COUNT(*) n FROM mz_eintrag").get().n;
+
+managerZieleTabellen(dbA2);
+
+const altSpaltenNachher = dbA2.prepare("PRAGMA table_info(mz_eintrag)").all().map((x) => x.name);
+const altZeilenNachher = dbA2.prepare("SELECT COUNT(*) n FROM mz_eintrag").get().n;
+const altVerweis = dbA2.prepare("PRAGMA foreign_key_list(mz_eintrag)").all()
+ .find((v) => v.from === "person_id");
+const altInhalt = dbA2.prepare("SELECT name, notiz FROM mz_eintrag").get();
+
+ok(String(altVerweis?.on_delete).toUpperCase() === "SET NULL",
+ `aus CASCADE wurde SET NULL (${altVerweis?.on_delete})`);
+ok(altZeilenNachher === altZeilenVorher,
+ `keine Zeile verloren (${altZeilenVorher} -> ${altZeilenNachher})`);
+ok(altSpaltenNachher.length >= altSpaltenVorher,
+ `keine Spalte verloren (${altSpaltenVorher} -> ${altSpaltenNachher.length})`);
+ok(altSpaltenNachher.includes("von_name") && altSpaltenNachher.includes("von_rolle"),
+ "die beiden neuen Spalten sind da");
+ok(altInhalt?.name === "@bleibtdrin" && altInhalt?.notiz === "wichtige Notiz",
+ "und der INHALT steht noch drin (nicht nur die Zeile)");
+
+/* Die Indizes muessen den Umbau ueberlebt haben -- sie haengen an der
+ Tabelle und verschwinden mit ihr. */
+const altIndizes = dbA2.prepare(
+ "SELECT COUNT(*) n FROM sqlite_master WHERE type='index' AND tbl_name='mz_eintrag'"
+ + " AND name LIKE 'idx_mz%'").get().n;
+ok(altIndizes >= 5, `die ${altIndizes} Indizes sind wieder da`);
+const altTrigger = dbA2.prepare(
+ "SELECT COUNT(*) n FROM sqlite_master WHERE type='trigger' AND tbl_name='mz_eintrag'").get().n;
+ok(altTrigger === 3, `und die drei Sperr-Trigger ebenfalls (${altTrigger})`);
+
+/* GEGENPROBE: Ein zweiter Lauf darf NICHTS mehr tun. Ein Umbau, der
+ bei jedem Serverstart wieder losliefe, waere eine Zeitbombe. */
+const vorZweitem = dbA2.prepare("SELECT COUNT(*) n FROM mz_eintrag").get().n;
+managerZieleTabellen(dbA2);
+const nachZweitem = dbA2.prepare("SELECT COUNT(*) n FROM mz_eintrag").get().n;
+ok(vorZweitem === nachZweitem,
+ `ein zweiter Lauf laesst alles, wie es ist (${vorZweitem} -> ${nachZweitem})`);
+
+/* DIE LOESCHUNG EINER PERSON MIT ALTEN EINTRAEGEN.
+
+ Der wichtigste Fall dieses ganzen Blocks, und er ist beim Bauen
+ schiefgegangen: ON DELETE SET NULL ist kein Loeschen, sondern ein
+ UPDATE auf mz_eintrag.person_id. Der Sperr-Trigger hat das als
+ Aenderung an einem abgeschlossenen Monat gesehen und abgebrochen --
+ damit waere das Loeschen einer Person in workspace-personen.js
+ gescheitert, an einer Stelle, die mit Monatszielen nichts zu tun
+ hat. Hier steht der Eintrag ausdruecklich in einem Monat, der NICHT
+ der laufende ist (mz_lage steht in dieser Wegwerf-Datenbank auf
+ '0000-00'). */
+dbA2.exec("PRAGMA foreign_keys = ON");
+let loeschenGing = true;
+try {
+ dbA2.prepare("DELETE FROM personen WHERE id = 1").run();
+} catch (f) {
+ loeschenGing = false;
+ console.log(" (Grund: " + f?.message + ")");
+}
+ok(loeschenGing,
+ "eine Person mit Eintraegen aus einem ABGESCHLOSSENEN Monat laesst sich loeschen");
+
+/* GEGENPROBE: Der Inhalt eines alten Monats bleibt trotzdem gesperrt.
+ Ohne diese Zeile waere nur bewiesen, dass die Sperre nachgibt --
+ nicht, dass sie noch etwas haelt. */
+let inhaltGesperrt = false;
+try {
+ dbA2.prepare("UPDATE mz_eintrag SET notiz = 'heimlich' WHERE name = '@bleibtdrin'").run();
+} catch (f) {
+ inhaltGesperrt = String(f?.message || "").includes("monat_gesperrt");
+}
+ok(inhaltGesperrt,
+ "…aber den INHALT eines alten Monats laesst dieselbe Sperre weiterhin nicht aendern");
+
+const nachLoeschen = dbA2.prepare("SELECT person_id, name, notiz FROM mz_eintrag").get();
+ok(nachLoeschen?.notiz === "wichtige Notiz",
+ "…und die Notiz steht unveraendert da");
+dbA2.close();
+try { rmSync(altOrdner, { recursive: true, force: true }); } catch { /* egal */ }
+ok(nachLoeschen && nachLoeschen.person_id === null && nachLoeschen.name === "@bleibtdrin",
+ "nach dem Loeschen der Person bleibt der Eintrag, nur ohne Besitzer");
+
+/* =====================================================================
+ 15 · WAS AM EINTRAG MITGESCHRIEBEN WIRD
+ ===================================================================== */
+melde("\n=== Name und Rolle stehen am Eintrag ===");
+const dbN = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true });
+const mitNamen = dbN.prepare(
+ "SELECT COUNT(*) n FROM mz_eintrag WHERE von_name IS NOT NULL AND von_rolle IS NOT NULL").get().n;
+const gesamtEintraege = dbN.prepare("SELECT COUNT(*) n FROM mz_eintrag").get().n;
+dbN.close();
+ok(gesamtEintraege > 0, `es gibt ueberhaupt Eintraege zum Pruefen (${gesamtEintraege})`);
+ok(mitNamen === gesamtEintraege,
+ `an allen ${gesamtEintraege} Eintraegen stehen Name und Rolle (${mitNamen})`);
+
+/* =====================================================================
+ 16 · KEIN KNOPF, DER NICHTS TUT
+ ===================================================================== */
+melde("\n=== Bearbeitbar heisst wirklich bearbeitbar ===");
+const spicySicht = await (await hol("spicy",
+ `${API}/eintraege?monat=${MONAT}&aufgabe=creator&person=${IDS.scout}`)).json();
+ok(spicySicht.bearbeitbar === false,
+ "Spicy Media bekommt an einer FREMDEN Liste keine Bearbeiten-Knoepfe angeboten");
+const dogiSicht = await (await hol("admin",
+ `${API}/eintraege?monat=${MONAT}&aufgabe=creator&person=${IDS.scout}`)).json();
+ok(dogiSicht.bearbeitbar === true, "DogFather schon");
+const eigeneSicht = await (await hol("scout",
+ `${API}/eintraege?monat=${MONAT}&aufgabe=creator`)).json();
+ok(eigeneSicht.bearbeitbar === true, "und jeder an seiner eigenen");
+
+/* GEGENPROBE: Die Angabe muss mit dem uebereinstimmen, was der Server
+ dann WIRKLICH tut. Eine Anzeige, die „darfst du" sagt und danach
+ 403 liefert, ist schlimmer als gar keine. */
+/* EIN EINTRAG, DEN ES WIRKLICH NOCH GIBT. Beim ersten Lauf stand
+ hier `einId` -- der war weiter oben geloescht worden, und die
+ Antwort lautete 404 statt 403. Das haette wie ein bestandener
+ Riegel ausgesehen und war nur „gibt es nicht". */
+const lebtNoch = eigeneSicht.eintraege[0]?.id;
+braucht(!!lebtNoch, "es gibt keinen Eintrag, an dem sich die Absage messen liesse");
+const spicyVersuch = await schick("spicy", `${API}/eintrag/${lebtNoch}`, "PATCH",
+ { datum: HEUTE, name: "@vonSpicy" });
+ok(spicyVersuch.status === 403,
+ `…und der Server weist Spicy auch wirklich ab, mit 403 (${spicyVersuch.status})`);
+
/* =====================================================================
SCHLUSS
===================================================================== */
diff --git a/server/workspace-manager-ziele.js b/server/workspace-manager-ziele.js
index d90520f9..f9f2e83a 100644
--- a/server/workspace-manager-ziele.js
+++ b/server/workspace-manager-ziele.js
@@ -595,10 +595,17 @@ managerZieleRouter.get(`${BASIS}/eintraege`, (req, res) => {
res.json({
monat, aufgabe, eintraege: zeilen,
- /* Ob die Liste noch bearbeitbar ist, entscheidet der Server und
- nicht der Browser — sonst wäre der graue Knopf die ganze
- Sicherung. */
- bearbeitbar: monat === monatJetzt() || req.person.rolle === "admin",
+ /* Ob die Liste bearbeitbar ist, entscheidet der Server und nicht
+ der Browser — sonst wäre der graue Knopf die ganze Sicherung.
+
+ ZWEI BEDINGUNGEN, UND DIE ERSTE HATTE GEFEHLT: Es muss die
+ eigene Liste sein (oder man ist DogFather), UND der Monat
+ muss offen sein (oder man ist DogFather). Ohne die erste sah
+ Spicy Media an einer fremden Liste „Bearbeiten" und
+ „Löschen" stehen, und der Server antwortete mit 403 — ein
+ Knopf, der nichts tut, ist schlimmer als kein Knopf. */
+ bearbeitbar: (personId === req.person.id || req.person.rolle === "admin")
+ && (monat === monatJetzt() || req.person.rolle === "admin"),
});
} catch (fehler) {
console.error("[manager-ziele] Eintraege:", fehler?.message);
@@ -639,12 +646,20 @@ managerZieleRouter.post(`${BASIS}/eintrag`, (req, res) => {
else if (!l) w.lead_id = null;
}
+ /* NAME UND ROLLE WERDEN MITGESCHRIEBEN, nicht nur verknuepft.
+ Zwei Gruende, und beide stehen in der Vorlage: „Wer die Rolle
+ verliert, sieht die Kachel nicht mehr; die Daten bleiben fuer
+ den DogFather erhalten." Ohne diese zwei Spalten staende nach
+ einem Rollenwechsel ein abgeschlossener Monat ploetzlich an den
+ Zielzahlen der NEUEN Rolle -- und nach einer Loeschung ohne
+ Namen da. */
const info = db().prepare(
`INSERT INTO mz_eintrag
- (person_id, aufgabe, monat, datum, name, name_klein, link, link_klein,
- art, notiz, lead_id, doppelt_bei, erstellt)
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(
- req.person.id, w.aufgabe, monat, w.datum, w.name, w.name_klein,
+ (person_id, von_name, von_rolle, aufgabe, monat, datum, name, name_klein,
+ link, link_klein, art, notiz, lead_id, doppelt_bei, erstellt)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(
+ req.person.id, req.person.name || null, req.person.rolle || null,
+ w.aufgabe, monat, w.datum, w.name, w.name_klein,
w.link, w.link_klein, w.art, w.notiz, w.lead_id,
doppel.fremd?.person_id ?? null, jetzt());
@@ -848,16 +863,45 @@ managerZieleRouter.get(`${BASIS}/verlauf`, (req, res) => {
/** Alle pflichtigen Personen mit ihrem Stand in einem Monat. */
function teamStand(monat) {
- const leute = db().prepare(
- `SELECT id, name, rolle FROM personen
- WHERE aktiv = 1 AND rolle IN (${PFLICHT_ROLLEN.map(() => "?").join(",")})
- ORDER BY rolle, name COLLATE NOCASE`).all(...PFLICHT_ROLLEN);
+ const platzhalter = PFLICHT_ROLLEN.map(() => "?").join(",");
+ /* WER IN DIESER LISTE STEHT -- und warum es nicht nur die aktiven
+ vier Rollen sind.
- return leute.map((p) => {
- const s = standFuer(p, monat);
+ Aus der Vorlage: „Wer die Rolle verliert, sieht die Kachel nicht
+ mehr; die Daten bleiben fuer den DogFather erhalten." Eine
+ Abfrage nur auf `aktiv = 1 AND rolle IN (...)` haette genau das
+ nicht geleistet: Wer im Oktober drei Creator gebracht hat und im
+ November Creator wird, waere aus dem Oktober verschwunden -- aus
+ einem abgeschlossenen Monat, den niemand mehr aendern darf.
+
+ Deshalb zweiteilig: die heute Pflichtigen UND jeder, der in
+ diesem Monat etwas eingetragen hat. Und fuer die zweite Gruppe
+ zaehlt die Rolle VON DAMALS (`von_rolle` am Eintrag), nicht die
+ von heute -- sonst wuerde ein abgeschlossener Monat rueckwirkend
+ an den Zielzahlen einer anderen Rolle gemessen. */
+ const leute = db().prepare(
+ `SELECT p.id, p.name, p.rolle, p.aktiv,
+ (SELECT e.von_rolle FROM mz_eintrag e
+ WHERE e.person_id = p.id AND e.monat = ? AND e.von_rolle IS NOT NULL
+ LIMIT 1) AS rolle_damals
+ FROM personen p
+ WHERE (p.aktiv = 1 AND p.rolle IN (${platzhalter}))
+ OR EXISTS (SELECT 1 FROM mz_eintrag e
+ WHERE e.person_id = p.id AND e.monat = ?)
+ ORDER BY p.rolle, p.name COLLATE NOCASE`)
+ .all(monat, ...PFLICHT_ROLLEN, monat);
+
+ const reihen = leute.map((p) => {
+ const heutePflichtig = p.aktiv === 1 && PFLICHT_ROLLEN.includes(p.rolle);
+ const rolle = heutePflichtig ? p.rolle : (p.rolle_damals || p.rolle);
+ const s = standFuer({ id: p.id, name: p.name, rolle }, monat);
return {
- id: p.id, name: p.name, rolle: p.rolle,
- rollenName: ROLLEN_NAME[p.rolle] || p.rolle,
+ id: p.id, name: p.name, rolle,
+ rollenName: ROLLEN_NAME[rolle] || rolle,
+ /* „Nicht mehr dabei" ist etwas anderes als „neu und noch ohne
+ Pflicht". Beides getrennt zu nennen erspart die Rueckfrage,
+ warum jemand mit 0/3 nicht rot ist. */
+ ehemalig: !heutePflichtig,
pflichtig: s.pflichtig,
stufe: s.stufe,
gesamt: s.gesamt,
@@ -866,6 +910,47 @@ function teamStand(monat) {
})),
};
});
+
+ /* EINE GELOESCHTE PERSON hat keine Zeile in `personen` mehr, ihre
+ Eintraege aber schon (person_id ist dann NULL). Sie als eigene,
+ zusammengefasste Zeile zu zeigen ist ehrlicher, als die Zahlen
+ verschwinden zu lassen -- im Monatsverlauf fehlten sonst
+ Eintraege, die es wirklich gab. */
+ const verwaist = db().prepare(
+ `SELECT COALESCE(von_name, 'Gelöschter Zugang') AS name,
+ COALESCE(von_rolle, 'scout') AS rolle,
+ aufgabe, COUNT(*) AS n
+ FROM mz_eintrag
+ WHERE person_id IS NULL AND monat = ?
+ GROUP BY name, rolle, aufgabe`).all(monat);
+ if (verwaist.length) {
+ const nachName = new Map();
+ for (const v of verwaist) {
+ const schluessel = `${v.name}|${v.rolle}`;
+ if (!nachName.has(schluessel)) nachName.set(schluessel, {});
+ nachName.get(schluessel)[v.aufgabe] = v.n;
+ }
+ for (const [schluessel, zahlen] of nachName) {
+ const [name, rolle] = schluessel.split("|");
+ const aufgaben = AUFGABEN.map((a) => {
+ const ziel = zielFuer(monat, rolle, a.schluessel);
+ const zahl = zahlen[a.schluessel] || 0;
+ return { schluessel: a.schluessel, zahl, ziel, stufe: zahl >= ziel && ziel > 0 ? "gruen" : "aus" };
+ });
+ reihen.push({
+ id: null, name, rolle, rollenName: ROLLEN_NAME[rolle] || rolle,
+ ehemalig: true, pflichtig: false, stufe: "aus",
+ gesamt: {
+ erledigt: aufgaben.reduce((x, a) => x + Math.min(a.zahl, a.ziel), 0),
+ ziel: aufgaben.reduce((x, a) => x + a.ziel, 0),
+ anteil: 100, offen: 0,
+ },
+ aufgaben,
+ });
+ }
+ }
+
+ return reihen;
}
managerZieleRouter.get(`${BASIS}/team`, nurLeitung, (req, res) => {
@@ -900,13 +985,19 @@ managerZieleRouter.get(`${BASIS}/export.csv`, nurLeitung, (req, res) => {
const t = String(w ?? "");
return /[";\n\r]/.test(t) ? `"${t.replace(/"/g, '""')}"` : t;
};
- const kopf = ["Name", "Rolle", "Pflichtig",
+ const kopf = ["Name", "Rolle", "Stand",
...AUFGABEN.flatMap((a) => [`${a.kurz} erreicht`, `${a.kurz} Ziel`]),
"Gesamt erledigt", "Gesamt Ziel", "Ampel"];
const zeilen = [kopf.map(feld).join(";")];
for (const p of leute) {
+ /* DREI STAENDE, NICHT ZWEI. „nein" hiesse sonst dasselbe fuer
+ jemanden, der gerade erst dazugekommen ist, und fuer jemanden,
+ der nicht mehr dabei ist -- und wer die Liste spaeter liest,
+ kann die beiden nicht mehr auseinanderhalten. */
+ const stand = p.ehemalig ? "nicht mehr dabei"
+ : (p.pflichtig ? "pflichtig" : "neu, noch ohne Pflicht");
zeilen.push([
- p.name, p.rollenName, p.pflichtig ? "ja" : "nein",
+ p.name, p.rollenName, stand,
...p.aufgaben.flatMap((a) => [a.zahl, a.ziel]),
p.gesamt.erledigt, p.gesamt.ziel, p.stufe,
].map(feld).join(";"));
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index ad01f3e8..5666d22e 100644
--- a/workspace/anruf-probe.html
+++ b/workspace/anruf-probe.html
@@ -16,8 +16,8 @@
verdeckt. Gefunden hat es pruef-struktur.
Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. -->
-
-
+
+
-
+