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`. --> - - + + - +