diff --git a/server/pruef-manager-ziele.mjs b/server/pruef-manager-ziele.mjs index 7badb48f..c43d25ec 100644 --- a/server/pruef-manager-ziele.mjs +++ b/server/pruef-manager-ziele.mjs @@ -901,6 +901,125 @@ ok(zurueck.status === 200, `Rueckgaengig geht (${zurueck.status})`); ok(zurueckD.stand?.aufgaben.find((a) => a.schluessel === "meeting")?.zahl === 0, "…und der Zaehler steht wieder bei 0"); +/* ===================================================================== + 12c · AB WANN JEMAND PFLICHTIG IST -- ueber den ECHTEN Weg + ===================================================================== + + Vorlage, Punkt 09: „Wer erst im Laufe des Monats eine der Rollen + bekommt, erhaelt in diesem Monat keine Warnungen." + + WARUM HIER DIE RICHTIGE ROUTE BENUTZT WIRD und nicht ein + handgeschriebener Protokolleintrag: Der Pflichtbeginn wird aus dem + Protokolltext gelesen (`detail` enthaelt „# : alt -> + neu"). Wuerde die Pruefung diesen Text selbst schreiben, pruefte + sie ihre eigene Annahme -- und merkte nie, wenn die + Personenverwaltung ihn eines Tages anders schreibt. + + DER FEHLER, DEN DAS GEFUNDEN HAT (02.10.2026): Die Abfrage suchte + `WHERE person_id = ?`. Im Protokoll steht dort aber der, der die + Aenderung GEMACHT hat -- also DogFather. Fuer den Betroffenen fand + sie deshalb nie etwas, und bei DogFather schob jede fremde + Rollenaenderung SEINEN Pflichtbeginn. Beides sah plausibel aus. + ===================================================================== */ +melde("\n=== Ab wann jemand pflichtig ist ==="); + +const dbP0 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); +const vorherStart = dbP0.prepare( + "SELECT person_id, erster_monat, gesetzt_am, grund FROM mz_start").all(); +dbP0.close(); +const dogiVorher = vorherStart.find((z) => z.person_id === IDS.admin); +braucht(vorherStart.length > 0, "es steht noch kein Pflichtbeginn in der Tabelle"); + +/* Ein eigener Mensch, dessen Rolle gleich wandert. */ +const WANDER_CODE = "CODE-Z80-000001"; +const dbW = new DatabaseSync(process.env.WORKSPACE_DB); +{ + const salz = randomBytes(16).toString("hex"); + const h = scryptSync(WANDER_CODE, salz, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + dbW.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n," + + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)") + .run("Wanderer", "scout", h, salz, 32768, + createHmac("sha256", kennSchluessel).update(WANDER_CODE).digest("hex"), ALT); +} +const idWander = dbW.prepare("SELECT last_insert_rowid() AS id").get().id; +dbW.close(); +CODES.wander = WANDER_CODE; +kekse.wander = await anmelden("wander", "scout"); +braucht(!!kekse.wander, "der Wanderer kam nicht herein"); + +/* Einmal den Stand abrufen, damit sein Pflichtbeginn festgehalten wird. */ +const wanderVorher = await (await hol("wander", `${API}/stand`)).json(); +ok(wanderVorher.pflichtig === true, + "ein ein Jahr alter Scout ist pflichtig (Ausgangspunkt)"); + +/* ---- Jetzt der ECHTE Rollenwechsel, zweimal ---------------------- */ +const rolleSetzen = (ziel) => schick("admin", + `/workspace/api/verwaltung/personen/${idWander}/rolle`, "PUT", { rolle: ziel }); + +const rolleWeg = await rolleSetzen("creator"); +ok(rolleWeg.status === 200, `DogFather macht ihn zum Creator (${rolleWeg.status})`); +const zurueckRolle = await rolleSetzen("scout"); +ok(zurueckRolle.status === 200, `…und wieder zum Scout (${zurueckRolle.status})`); + +/* Das Rollenwechseln meldet alle Sitzungen dieser Person ab -- also + noch einmal anmelden, sonst misst der naechste Aufruf nur ein 401. */ +kekse.wander = await anmelden("wander", "scout"); +braucht(!!kekse.wander, "nach dem Rollenwechsel kam er nicht wieder herein"); + +const wanderNachher = await (await hol("wander", `${API}/stand`)).json(); +const dbP = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); +const wanderStart = dbP.prepare( + "SELECT erster_monat, grund FROM mz_start WHERE person_id = ?").get(idWander); +const dogiNachher = dbP.prepare( + "SELECT erster_monat, grund FROM mz_start WHERE person_id = ?").get(IDS.admin); +dbP.close(); + +ok(wanderStart?.grund === "rollenwechsel", + `der Pflichtbeginn kommt jetzt aus SEINEM Rollenwechsel (${wanderStart?.grund})`); +ok(wanderStart?.erster_monat === mz.monatPlus(MONAT, 1) + || (HEUTE.slice(8) === "01" && wanderStart?.erster_monat === MONAT), + `er ist erst ab dem Folgemonat pflichtig (${wanderStart?.erster_monat})`); +ok(wanderNachher.pflichtig === false || HEUTE.slice(8) === "01", + "…und bekommt in diesem Monat keine Warnungen mehr"); +ok(wanderNachher.aufgaben.every((a) => a.stufe === "neutral" || a.stufe === "aus"), + "…keine einzige seiner Zeilen warnt"); + +/* GEGENPROBE 1: DogFather hat die Aenderung gemacht -- SEIN + Pflichtbeginn darf sich davon nicht bewegen. Genau das war der + Fehler. */ +ok(dogiNachher?.erster_monat === dogiVorher?.erster_monat, + `DogFathers Pflichtbeginn ist unveraendert (${dogiVorher?.erster_monat}` + + ` -> ${dogiNachher?.erster_monat}) — er hat die Aenderung nur GEMACHT`); + +/* GEGENPROBE 2: Wer gar nicht angefasst wurde, behaelt seinen Wert. + Ohne diese Zeile hiesse „der eine hat sich geaendert" vielleicht + „alle haben sich geaendert". */ +const dbP2 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); +const scoutStart = dbP2.prepare( + "SELECT erster_monat, grund FROM mz_start WHERE person_id = ?").get(IDS.scout); +dbP2.close(); +const scoutVorher = vorherStart.find((z) => z.person_id === IDS.scout); +ok(scoutStart?.erster_monat === scoutVorher?.erster_monat, + `ein unbeteiligter Scout behaelt seinen Wert (${scoutStart?.erster_monat})`); + +/* GEGENPROBE 3: Der Eintrag wird NICHT bei jedem Abruf neu gerechnet. + Sonst waere der Pflichtbeginn keine Festlegung, sondern eine + Momentaufnahme. */ +await hol("wander", `${API}/stand`); +const dbP3 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); +const nochmal = dbP3.prepare( + "SELECT gesetzt_am FROM mz_start WHERE person_id = ?").get(idWander); +dbP3.close(); +await new Promise((r) => setTimeout(r, 20)); +await hol("wander", `${API}/stand`); +const dbP4 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); +const undnochmal = dbP4.prepare( + "SELECT gesetzt_am FROM mz_start WHERE person_id = ?").get(idWander); +dbP4.close(); +ok(nochmal?.gesetzt_am === undnochmal?.gesetzt_am, + "ein zweiter Abruf rechnet ihn nicht neu — er steht fest"); + /* ===================================================================== 13 · WER GEHT, NIMMT SEINE ZAHLEN NICHT MIT ===================================================================== diff --git a/server/pruef-monatswechsel.mjs b/server/pruef-monatswechsel.mjs new file mode 100644 index 00000000..915d14bb --- /dev/null +++ b/server/pruef-monatswechsel.mjs @@ -0,0 +1,339 @@ +/* ===================================================================== + DER MONATSWECHSEL (02.10.2026) + ===================================================================== + + WARUM ES DIESE PRUEFUNG GIBT, obwohl pruef-manager-ziele schon 206 + Dinge misst: Weil keines davon den Monatswechsel erlebt hat. + + Die Vorlage sagt: „Am 1. jedes Monats um 00:00 Uhr starten alle + Zaehler automatisch bei 0." Das ist der zentrale Weg dieser Kachel, + er laeuft ohne Zutun, er betrifft alle gleichzeitig, und er war am + Tag der Auslieferung noch nie gelaufen -- der erste Oktober lag + davor, der erste November liegt dahinter. + + Genau diese Lage gab es in diesem Haus schon einmal: Am 01.09.2026 + war in RunOne der Umbau „Hashkette beginnt bei der Festschreibung" + seit Tagen live und NIE gelaufen -- null festgeschriebene + Buchungen. Der zentrale Weg war unerprobt, ausgerechnet der + einzige, den man nicht rueckgaengig machen kann. Die Lehre stand + danach in der Projektnotiz: „Was sich nicht zuruecknehmen laesst, + wird vorher auf einer Kopie durchgespielt." + + --------------------------------------------------------------------- + WIE HIER DIE ZEIT VERSTELLT WIRD -- und was das NICHT beweist + + Der ganze Code rechnet ueber `new Date()` und `Date.now()`. Diese + Datei setzt davor eine Huelle, die beide um einen festen Betrag + verschiebt. Damit laeuft der ECHTE Code durch einen echten + Monatswechsel, ohne dass irgendetwas umgebaut werden muesste. + + WAS DAMIT NICHT GEMESSEN WIRD, und das gehoert dazugesagt: + SQLite rechnet in den Triggern mit `datetime('now')` und sieht + weiterhin die wahre Uhr. Fuer die Freigabe alter Monate heisst das: + Ihr Ablaufzeitpunkt liegt in der verschobenen Zukunft und damit + immer in der Zukunft. Dass die Freigabe nach zwei Minuten + verfaellt, prueft deshalb weiterhin pruef-manager-ziele, nicht + diese Datei. + + DIE HUELLE STEHT GANZ OBEN, vor jedem Import. Ein Modul, das beim + Laden einmal `new Date()` aufruft, wuerde sonst die echte Zeit + festhalten -- und die Pruefung maesse danach zwei verschiedene + Uhren gegeneinander. + ===================================================================== */ + +/* ---------- Die Uhr ---------------------------------------------- */ +const ECHTE_ZEIT = Date; +let versatz = 0; + +globalThis.Date = class extends ECHTE_ZEIT { + constructor(...a) { + if (a.length === 0) super(ECHTE_ZEIT.now() + versatz); + else super(...a); + } + static now() { return ECHTE_ZEIT.now() + versatz; } +}; + +/** Setzt die Uhr auf einen bestimmten Zeitpunkt. */ +function uhrAuf(iso) { + versatz = 0; // erst zurueck auf echt + versatz = new ECHTE_ZEIT(iso).getTime() - ECHTE_ZEIT.now(); + return iso; +} + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-mw-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); + +import { notbremse } from "./helfer-notbremse.mjs"; +const { eigenerPort } = await import("./helfer-port.mjs"); +const PORT = await eigenerPort(import.meta, "die Pruefung des Monatswechsels"); + +process.env.PORT = `${PORT}`; +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_ACCESS_CODE_DOGI = "DOGI-TEST-1"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +/* OKTOBER, UND ZWAR MITTEN DRIN. Der 20. ist bewusst gewaehlt: nach + dem 16. (also in der Orange-Zone) und vor den letzten sieben Tagen. + So ist am Ausgangspunkt eine Warnung aktiv, die nach dem Wechsel + verschwunden sein MUSS -- waere der Ausgangspunkt neutral, bewiese + das Verschwinden nichts. */ +uhrAuf("2026-10-20T09:00:00+02: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"); +notbremse(300_000, "pruef-monatswechsel"); +await new Promise((r) => setTimeout(r, 900)); +const BASIS = `http://127.0.0.1:${PORT}`; +const API = "/workspace/api/manager-ziele"; + +let fehler = 0, geprueft = 0; +const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; +const melde = (t) => console.log(t); +function braucht(bedingung, was) { + if (bedingung) return; + console.log(`\nABBRUCH: konnte nicht nachsehen — ${was}`); + console.log("Das ist KEIN Befund am Code, sondern eine Voraussetzung,"); + console.log("die sich nicht herstellen liess.\n"); + try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + process.exit(2); +} + +/* ---------- Leute ------------------------------------------------- */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const kennSchluessel = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", kennSchluessel, new Date().toISOString()); + +/* Ein Jahr alt -- damit alle vom ersten Tag an pflichtig sind. Waeren + sie frisch angelegt, waere jede Ampel neutral und die Pruefung + saehe gruen aus, ohne etwas gemessen zu haben. */ +const ALT = "2025-10-01T08:00:00Z"; +const CODES = {}, IDS = {}; +function anlegen(name, rolle, code) { + const salz = randomBytes(16).toString("hex"); + const h = scryptSync(code, salz, 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," + + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)") + .run(name, rolle, h, salz, 32768, + createHmac("sha256", kennSchluessel).update(code).digest("hex"), ALT); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +for (const [schluessel, rolle, name] of [ + ["admin", "admin", "Filipe"], + ["fleissig", "scout", "Fleissig"], + ["faul", "scout", "Faul"], +]) { + CODES[schluessel] = `CODE-MW${schluessel.slice(0, 3).toUpperCase()}-01`; + IDS[schluessel] = anlegen(name, rolle, CODES[schluessel]); +} +d.close(); + +/* ---------- Anmelden ---------------------------------------------- */ +const kekse = {}; +for (const schluessel of Object.keys(CODES)) { + const rolle = schluessel === "admin" ? "admin" : "scout"; + const a = await fetch(`${BASIS}/workspace/api/anmelden`, { + method: "POST", + headers: { "Content-Type": "application/json", Origin: BASIS }, + body: JSON.stringify({ rolle, code: CODES[schluessel] }), + }); + if (a.status === 200) { + kekse[schluessel] = (a.headers.getSetCookie?.() || []) + .map((z) => z.split(";")[0]).join("; "); + } +} +braucht(Object.keys(kekse).length === 3, "nicht alle drei kamen herein"); + +const hol = (wer, pfad, einst = {}) => fetch(BASIS + pfad, { + ...einst, + headers: { + Cookie: kekse[wer] || "", Origin: BASIS, + ...(einst.body ? { "Content-Type": "application/json" } : {}), + ...(einst.headers || {}), + }, +}); +const schick = (wer, pfad, art, rumpf) => + hol(wer, pfad, { method: art, body: JSON.stringify(rumpf) }); + +/* ===================================================================== + 1 · OKTOBER: ein voller und ein halber Monat + ===================================================================== */ +melde("\n=== Oktober, am 20. ==="); +const okt = await (await hol("fleissig", `${API}/stand`)).json(); +ok(okt.monat === "2026-10", `wir sind im Oktober (${okt.monat})`); +ok(okt.tag === 20, `am 20. (${okt.tag})`); +ok(okt.aufgaben.find((a) => a.schluessel === "creator").stufe === "orange", + "und die offene Aufgabe steht auf ORANGE — Ausgangspunkt mit Warnung"); + +/* Fleissig macht alles fertig. */ +const erledigt = await schick("fleissig", `${API}/eintraege`, "POST", + { aufgabe: "creator", datum: "2026-10-18", namen: ["@a", "@b", "@c"] }); +ok((await erledigt.json()).angelegt.length === 3, "drei Creator eingetragen"); +for (const [aufgabe, extra, mal] of [["meeting", {}, 2], ["schulung", { art: "talk" }, 2], + ["werbung", { art: "agentur", link: "https://tiktok.com/@x/video/9" }, 1]]) { + for (let i = 0; i < mal; i++) { + await schick("fleissig", `${API}/eintraege`, "POST", + { aufgabe, datum: `2026-10-1${i}`, ...extra, + ...(aufgabe === "werbung" ? { link: `https://tiktok.com/@x/video/${i}9` } : {}) }); + } +} +/* Faul macht genau einen Creator. */ +await schick("faul", `${API}/eintraege`, "POST", + { aufgabe: "creator", datum: "2026-10-05", namen: ["@nurEiner"] }); + +const oktFleissig = await (await hol("fleissig", `${API}/stand`)).json(); +const oktFaul = await (await hol("faul", `${API}/stand`)).json(); +ok(oktFleissig.gesamt.erledigt === 8 && oktFleissig.gesamt.offen === 0, + `Fleissig hat alles (${oktFleissig.gesamt.erledigt} von ${oktFleissig.gesamt.ziel})`); +ok(oktFaul.gesamt.erledigt === 1 && oktFaul.gesamt.offen === 4, + `Faul hat eins von acht (${oktFaul.gesamt.erledigt} von ${oktFaul.gesamt.ziel})`); + +/* DogFather setzt die Zielzahlen fuer den naechsten Monat hoch. */ +const gesetzt = await schick("admin", `${API}/ziele`, "PUT", + { ziele: { scout: { creator: 5, meeting: 1 } } }); +const gesetztD = await gesetzt.json(); +ok(gesetzt.status === 200 && gesetztD.monat === "2026-11", + `neue Zielzahlen gelten ab November (${gesetztD.monat})`); +ok((await (await hol("fleissig", `${API}/stand`)).json()) + .aufgaben.find((a) => a.schluessel === "creator").ziel === 3, + "…und der Oktober bleibt bei 3 — die Aenderung wirkt nicht rueckwaerts"); + +/* ===================================================================== + 2 · DER WECHSEL + ===================================================================== */ +melde("\n=== Und jetzt ist es der 1. November, 00:05 Uhr ==="); +uhrAuf("2026-11-01T00:05:00+01:00"); + +const nov = await (await hol("fleissig", `${API}/stand`)).json(); +ok(nov.monat === "2026-11", `der Monat ist gewechselt (${nov.monat})`); +ok(nov.tag === 1, `wir sind am 1. (${nov.tag})`); +ok(nov.aufgaben.every((a) => a.zahl === 0), + "ALLE Zaehler stehen bei 0 — ohne dass jemand etwas angefasst haette"); +ok(nov.aufgaben.every((a) => a.stufe === "neutral" || a.stufe === "aus"), + "keine einzige Warnung am ersten Tag"); +ok(nov.aufgaben.find((a) => a.schluessel === "creator").ziel === 5, + `die neue Zielzahl gilt jetzt (${nov.aufgaben.find((a) => a.schluessel === "creator").ziel})`); +ok(nov.aufgaben.find((a) => a.schluessel === "meeting").ziel === 1, + "…auch die zweite geaenderte"); +ok(nov.aufgaben.find((a) => a.schluessel === "schulung").ziel === 2, + "…und was nicht geaendert wurde, bleibt bei der Vorgabe"); + +/* DIE ALTEN DATEN SIND NICHT WEG -- das ist der Kern der Regel + „Zuordnung zu Monat/Jahr, nicht Loeschen von Daten". */ +const oktNochDa = await (await hol("fleissig", + `${API}/eintraege?monat=2026-10&aufgabe=creator`)).json(); +ok(oktNochDa.eintraege.length === 3, + `die Oktober-Eintraege stehen noch da (${oktNochDa.eintraege.length})`); +ok(oktNochDa.bearbeitbar === false, + "…und sind fuer den Scout nicht mehr bearbeitbar"); + +const dbL = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); +const lage = dbL.prepare("SELECT monat FROM mz_lage WHERE eins = 1").get(); +const oktZeilen = dbL.prepare( + "SELECT COUNT(*) n FROM mz_eintrag WHERE monat = '2026-10'").get().n; +dbL.close(); +ok(lage.monat === "2026-11", `die Sperre kennt den neuen Monat (${lage.monat})`); +ok(oktZeilen === 9, `neun Oktober-Zeilen unveraendert in der Datenbank (${oktZeilen})`); + +/* GEGENPROBE: Der Oktober ist wirklich zu. */ +const altAendern = await schick("fleissig", `${API}/eintrag/${oktNochDa.eintraege[0].id}`, + "PATCH", { datum: "2026-10-19", name: "@heimlich" }); +ok(altAendern.status === 409, + `der Scout kommt im Oktober nicht mehr hinein (${altAendern.status})`); +const altAdmin = await schick("admin", `${API}/eintrag/${oktNochDa.eintraege[0].id}`, + "PATCH", { datum: "2026-10-19", name: "@berichtigt" }); +ok(altAdmin.status === 200, `DogFather schon (${altAdmin.status})`); + +/* ===================================================================== + 3 · DER VERLAUF + ===================================================================== */ +melde("\n=== Der Verlauf zeigt den Oktober ==="); +const verlaufF = await (await hol("fleissig", `${API}/verlauf`)).json(); +const verlaufA = await (await hol("faul", `${API}/verlauf`)).json(); +const oktF = verlaufF.monate.find((m) => m.monat === "2026-10"); +const oktA = verlaufA.monate.find((m) => m.monat === "2026-10"); +ok(!!oktF, "der Oktober steht im Verlauf"); +ok(oktF?.aufgaben.every((a) => a.erreicht === true), + "bei Fleissig ist jede Aufgabe als erreicht vermerkt"); +ok(oktA?.aufgaben.filter((a) => a.erreicht === false).length === 4, + `bei Faul sind vier als nicht erreicht vermerkt (${oktA?.aufgaben.filter((a) => a.erreicht === false).length})`); +ok(oktF?.aufgaben.find((a) => a.schluessel === "creator")?.ziel === 3, + "…und gemessen wird am OKTOBER-Ziel (3), nicht am neuen (5)"); +ok(!verlaufF.monate.some((m) => m.monat === "2026-11"), + "der laufende Monat steht NICHT im Verlauf — er ist noch nicht vorbei"); + +/* ===================================================================== + 4 · DIE ERINNERUNG AM ERSTEN + ===================================================================== */ +melde("\n=== Die Meldung zum Monatsanfang ==="); +const mz = await import("./workspace-manager-ziele.js"); +const rufe = mz.zielRufe(new Date()); +ok(rufe.length === 3, + `alle drei Pflichtigen bekommen eine Meldung (${rufe.length})`); +ok(rufe.every((r) => r.titel === "Neuer Monat"), + '…und zwar „Neuer Monat"'); +ok(rufe.every((r) => r.merkmal === "mz:2026-11-01"), + "…mit dem Tag im Merkmal, also hoechstens eine je Person"); +ok(rufe.every((r) => /starten bei 0/.test(r.text)), + "…und dem Satz aus der Vorlage"); + +/* GEGENPROBE: Wer im Oktober alles geschafft hatte, bekommt im + November trotzdem eine -- es ist ein neuer Monat. Haette der alte + Glueckwunsch die Erinnerungen dauerhaft abgestellt, waere Fleissig + hier still, und das waere der schlimmste denkbare Fehler: Der + Beste bekaeme als Einziger keine Erinnerung mehr. */ +ok(rufe.some((r) => r.personId === IDS.fleissig), + "auch der, der den Oktober voll hatte — der Glueckwunsch galt nur fuer den Oktober"); + +/* ===================================================================== + 5 · NOCH EIN WECHSEL -- und ein Monat ohne eigene Zielzahl + ===================================================================== */ +melde("\n=== Und weiter in den Dezember ==="); +uhrAuf("2026-12-03T10:00:00+01:00"); +const dez = await (await hol("fleissig", `${API}/stand`)).json(); +ok(dez.monat === "2026-12", `Dezember (${dez.monat})`); +ok(dez.aufgaben.find((a) => a.schluessel === "creator").ziel === 5, + "ein Monat ohne eigene Festlegung erbt die letzte geltende (5)"); +ok(dez.aufgaben.every((a) => a.zahl === 0), "und faengt wieder bei 0 an"); + +const verlaufDez = await (await hol("fleissig", `${API}/verlauf`)).json(); +ok(verlaufDez.monate.length === 2, + `der Verlauf kennt jetzt zwei Monate (${verlaufDez.monate.length})`); +ok(verlaufDez.monate[0].monat === "2026-11" && verlaufDez.monate[1].monat === "2026-10", + "…der juengste zuerst"); +ok(verlaufDez.monate[0].aufgaben.every((a) => a.erreicht === false), + "der leere November steht als nicht erreicht da"); + +/* UND DER OKTOBER HAT SICH NICHT VERAENDERT, obwohl inzwischen zwei + Monatswechsel und eine Zielzahlaenderung dazwischenliegen. Das ist + die eigentliche Zusage des eingefrorenen Ziels. */ +const oktSpaet = verlaufDez.monate.find((m) => m.monat === "2026-10"); +ok(oktSpaet?.aufgaben.find((a) => a.schluessel === "creator")?.ziel === 3, + `der Oktober steht zwei Monate spaeter immer noch auf 3 (${oktSpaet?.aufgaben.find((a) => a.schluessel === "creator")?.ziel})`); +ok(oktSpaet?.aufgaben.every((a) => a.erreicht === true), + "…und gilt weiterhin als vollstaendig erreicht"); + +/* ---------- Die Team-Uebersicht eines vergangenen Monats ---------- */ +const teamOkt = await (await hol("admin", `${API}/team?monat=2026-10`)).json(); +const fleissigOkt = teamOkt.leute.find((p) => p.id === IDS.fleissig); +ok(fleissigOkt?.gesamt.erledigt === 8, + `DogFather sieht den Oktober unveraendert (${fleissigOkt?.gesamt.erledigt} von ${fleissigOkt?.gesamt.ziel})`); +ok(fleissigOkt?.stufe === "gruen", "…und Fleissig steht dort auf gruen"); + +/* ===================================================================== + SCHLUSS + ===================================================================== */ +versatz = 0; +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +melde(`\n${geprueft} Pruefungen, ${fehler} Fehler`); +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-manager-ziele.js b/server/workspace-manager-ziele.js index a1dd84ed..d384827c 100644 --- a/server/workspace-manager-ziele.js +++ b/server/workspace-manager-ziele.js @@ -268,23 +268,99 @@ function zielFuer(monat, rolle, aufgabe) { bei jeder Abfrage daraus gerechnet, verschöbe ein Aufräumlauf rückwirkend, seit wann jemand verantwortlich ist. ===================================================================== */ +/** Wann hat sich DIE ROLLE DIESER PERSON zuletzt geändert? + * + * BERICHTIGT AM 02.10.2026 -- und das war ein echter Fehler, kein + * Schönheitsfleck. + * + * Hier stand `WHERE person_id = ?`. Im Protokoll steht unter + * `person_id` aber der, der die Änderung GEMACHT hat (also + * DogFather), nicht der, dessen Rolle sich geändert hat -- siehe + * workspace-personen.js: `protokolliere("rolle_geaendert", + * { personId: req.person.id, … detail: "# : alt -> neu" })`. + * + * Die Abfrage hat damit zwei Dinge falsch gemacht, und beide sahen + * richtig aus: + * + * 1. Für den BETROFFENEN fand sie nie etwas -- sein Pflichtbeginn + * wurde aus dem Anlegedatum gerechnet, als hätte er die Rolle + * seit jeher. + * 2. Für DogFather fand sie JEDE Rollenänderung, die er an anderen + * vorgenommen hat, und schob SEINEN Pflichtbeginn davon. In der + * echten Datenbank stand deshalb bei ihm „grund: rollenwechsel" + * -- ein plausibler Wert, der aus der falschen Zeile kam. + * + * Gefunden hat es nicht die Prüfung, sondern ein Blick in die Quelle + * beim Nachlesen, wie das Protokoll geschrieben wird. Das ist genau + * die Sorte Fehler, vor der die Hausnotiz warnt: Eine Prüfung, die + * grün ist, weil sie das Falsche misst. + * + * JETZT ÜBER DAS `detail`, denn dort steht die betroffene Nummer. + * Das ist eine Textsuche in einem Protokolltext und damit die + * schwächste Stelle hier -- deshalb wird das Ergebnis nicht jedes + * Mal neu gerechnet, sondern in `mz_start` festgehalten. */ +function rollenWechselAm(personId) { + /* DAS MUSTER WIRD HIER GEBAUT UND NICHT IN SQL ZUSAMMENGESETZT. + + Hier stand `detail LIKE ('#' || ? || ' %')`, und das hat NIE + getroffen -- ohne Fehler, ohne Warnung, einfach immer leer. + Nachgemessen in einer leeren Datenbank: + + SELECT ('#' || ? || ' %') mit der Zahl 42 -> '#42.0 %' + + `node:sqlite` bindet eine JavaScript-Zahl als REAL, und SQLite + macht aus 42.0 beim Verketten den Text „42.0". Gesucht wurde + also nach „#42.0 ", gespeichert ist „#42 ". Mit derselben Zahl + als Zeichenkette (`"42"`) stimmt es sofort. + + Das ist genau die Sorte Fehler, vor der die Hausnotiz warnt: Die + Abfrage lief, sie war fehlerfrei, und sie war wertlos. Gefunden + hat sie nicht das Lesen, sondern eine Pruefung, die den ECHTEN + Weg benutzt hat statt den Protokolltext selbst zu schreiben. + + `Number.isInteger` davor ist kein Schmuck: Eine Zeichenkette mit + `%` darin waere sonst ein Platzhalter im Muster. */ + if (!Number.isInteger(Number(personId))) return null; + try { + return db().prepare( + `SELECT zeitpunkt FROM protokoll + WHERE aktion = 'rolle_geaendert' AND detail LIKE ? + ORDER BY zeitpunkt DESC LIMIT 1`) + .get(`#${Number(personId)} %`)?.zeitpunkt || null; + } catch (fehler) { + console.error("[manager-ziele] Rollenwechsel suchen:", fehler?.message); + return null; + } +} + function startMonatFuer(personId) { const d = db(); - const da = d.prepare("SELECT erster_monat FROM mz_start WHERE person_id = ?").get(personId); - if (da) return da.erster_monat; + const da = d.prepare( + "SELECT erster_monat, gesetzt_am FROM mz_start WHERE person_id = ?").get(personId); + const wechsel = rollenWechselAm(personId); + + /* NEU GERECHNET WIRD NUR, WENN SICH SEITHER ETWAS GEÄNDERT HAT. + Vorher stand hier `if (da) return` -- einmal gesetzt, für immer. + Damit bekam jemand, der die Rolle verliert und später + zurückbekommt, KEINEN Schonmonat mehr, obwohl die Vorlage ihm + genau den zusichert („Wer erst im Laufe des Monats eine der + Rollen bekommt …"). Es wäre erst in Monaten aufgefallen, und + dann als „der wird zu Unrecht gemahnt". */ + if (da && !(wechsel && wechsel > da.gesetzt_am)) return da.erster_monat; const p = d.prepare("SELECT erstellt FROM personen WHERE id = ?").get(personId); - if (!p) return ERSTER_MONAT; + if (!p) return da?.erster_monat || ERSTER_MONAT; /* Der jüngste Rollenwechsel schlägt das Anlegedatum: Wer seit einem Jahr Creator war und gestern Scout wurde, ist nicht seit einem - Jahr pflichtig. */ - const wechsel = d.prepare( - `SELECT zeitpunkt FROM protokoll - WHERE person_id = ? AND aktion = 'rolle_geaendert' - ORDER BY zeitpunkt DESC LIMIT 1`).get(personId); + Jahr pflichtig. - const quelle = wechsel?.zeitpunkt || p.erstellt; + JEDER Rollenwechsel zählt, auch der von Scout zu Manager. Das ist + eine Entscheidung und keine Nachlässigkeit: Seit die Zielzahlen + je Rolle gelten, bekommt man mit einer neuen Rolle wirklich neue + Pflichten -- und Rollen ändert ohnehin nur DogFather, hier ist + also nichts zu holen. */ + const quelle = wechsel || p.erstellt; let grund = wechsel ? "rollenwechsel" : "angelegt"; let monat = ERSTER_MONAT; try { @@ -295,8 +371,13 @@ function startMonatFuer(personId) { } if (monat < ERSTER_MONAT) { monat = ERSTER_MONAT; grund += "+erster_monat"; } - d.prepare(`INSERT OR IGNORE INTO mz_start (person_id, erster_monat, gesetzt_am, grund) - VALUES (?,?,?,?)`).run(personId, monat, jetzt(), grund); + d.prepare(`INSERT INTO mz_start (person_id, erster_monat, gesetzt_am, grund) + VALUES (?,?,?,?) + ON CONFLICT(person_id) DO UPDATE SET + erster_monat = excluded.erster_monat, + gesetzt_am = excluded.gesetzt_am, + grund = excluded.grund`) + .run(personId, monat, jetzt(), grund); return monat; }