diff --git a/server/index.js b/server/index.js index 99060e90..abbbbbc9 100644 --- a/server/index.js +++ b/server/index.js @@ -71,6 +71,7 @@ import { anrufRouter } from "./workspace-anruf.js"; import { videoRouter, videoWacheStarten } from "./workspace-video.js"; import { bewerbungRouter } from "./workspace-bewerbung.js"; import { leistungRouter } from "./workspace-leistung.js"; +import { managerZieleRouter } from "./workspace-manager-ziele.js"; import { fruehwarnungRouter } from "./workspace-fruehwarnung.js"; import { icsRouter } from "./workspace-ics.js"; @@ -349,6 +350,12 @@ app.use(unterstuetzungRouter); app.use(bewerbungRouter); app.use(reportRouter); app.use(scoutRouter); +/* Manager-Ziele (02.10.2026): die vier festen Monatsaufgaben. Steht + hinter dem Scout-CRM, weil es dessen Pipeline mitbenutzt -- die + Reihenfolge der Router entscheidet hier zwar nichts (die Wege + ueberschneiden sich nicht), aber wer suchen muss, sucht sie + nebeneinander. */ +app.use(managerZieleRouter); app.use(callRouter); app.use(hinweisRouter); app.use(zentraleRouter); diff --git a/server/manager-ziele-tabellen.js b/server/manager-ziele-tabellen.js new file mode 100644 index 00000000..8a7a94ef --- /dev/null +++ b/server/manager-ziele-tabellen.js @@ -0,0 +1,320 @@ +/* ===================================================================== + MANAGER-ZIELE — die Tabellen (02.10.2026) + ===================================================================== + + Vier feste Monatsaufgaben für Scouts, Manager, DogFather und Spicy + Media: Creator rekrutieren, Manager-Meeting, Schulung oder Community + Talk, Werbung auf TikTok. Jede mit einer Zielzahl, einer Ampel und + einem Monatsschnitt. + + WARUM EINE EIGENE DATEI: Die Tabellen hängen an `personen` (CASCADE) + und an `leads`. Beide entstehen im Bauplan von workspace.js, und + workspace-manager-ziele.js importiert AUS workspace.js — stünden die + Tabellen dort, wäre das ein Importkreis. Über einen Kreis kommen + Konstanten als `undefined` an, ohne dass irgendwo ein Fehler + erscheint; hilfe-tabellen.js, support-tabellen.js und + notiz-tabellen.js stehen aus demselben Grund getrennt. + + --------------------------------------------------------------------- + DREI ENTSCHEIDUNGEN, DIE MAN SPÄTER NICHT MEHR BILLIG ÄNDERN KANN + + 1. DAS ZIEL WIRD PRO MONAT EINGEFROREN, nicht als eine laufende + Einstellung geführt. + + Filipe wollte die Zielzahlen änderbar haben, wirksam ab dem + nächsten Monat. Läge nur EIN aktueller Wert in `einstellungen`, + dann schriebe jede spätere Änderung rückwirkend den gesamten + Verlauf um: Ein Monat, der mit 2/2 als „erreicht" abgeschlossen + war, stünde nach einer Erhöhung auf 4 plötzlich als „nicht + erreicht" da — ohne dass jemand etwas getan hätte. Ein Verlauf, + der sich rückwirkend ändert, ist kein Verlauf. + + Deshalb: `mz_ziel` hat den Monat im Schlüssel. Beim ersten + Zugriff auf einen Monat wird er festgeschrieben (siehe + `monatSichern` im Fachmodul), und ab da steht er fest. + + 2. DIE SPERRE VERGANGENER MONATE SITZT IN DER DATENBANK, nicht nur + im Code. + + Dieselbe Überlegung wie bei workspace-schulung.js: „Ein Schutz, + der nur im Code steht, ist beim nächsten neuen Weg zur Datenbank + wieder weg." Drei Trigger lehnen jede Änderung an einem + abgeschlossenen Monat ab. + + DIE AUSNAHME FÜR DOGFATHER LÄSST SICH IN SQLITE NICHT ÜBER DIE + SITZUNG ABFRAGEN — es gibt keine Sitzungsvariablen. Deshalb + `mz_freigabe`: eine einzige Zeile, die seine Route INNERHALB + derselben Transaktion auf 1 setzt und danach wieder auf 0. Das + ist ehrlicher als ein Trigger, den man mit einem Kommentar + „gilt nicht für den Chef" einfach weglässt — die Ausnahme ist + damit ein sichtbarer, zählbarer Vorgang und kein Loch. + + WARUM DIE FREIGABE NICHT VERGESSEN WERDEN KANN: Sie wird im + `finally` einer synchronen Transaktion zurückgesetzt, und + `mz_freigabe.bis` trägt zusätzlich einen Zeitpunkt. Was älter + als zwei Minuten ist, gilt als nicht freigegeben — ein + vergessenes Schloss wäre sonst dasselbe wie gar keines + (dieselbe Erfahrung wie beim Arbeitsschloss in der Buchhaltung). + + 3. DER AKTUELLE MONAT STEHT IN EINER TABELLE, nicht in `strftime`. + + Ein Trigger müsste sonst `strftime('%Y-%m','now','localtime')` + rechnen — und `localtime` ist die Zeitzone des SERVERS. Steht der + auf UTC, wäre am 1. eines Monats zwischen 00:00 und 02:00 Uhr + deutscher Zeit noch der Vormonat „aktuell", und die Sperre + griffe eine Stunde zu früh oder zu spät. `mz_lage` hält den + Monat, den die Anwendung selbst in Europa/Berlin ausrechnet. + ===================================================================== */ + +/** Die vier Aufgaben. Reihenfolge = Anzeigereihenfolge. + * + * `feld` sagt, welche Zusatzangabe die Aufgabe braucht — daraus + * entstehen Formular, Prüfung und Dublettenschutz, damit die Liste + * nicht an vier Stellen getrennt gepflegt werden muss. Eine Liste, + * die niemand pflegt, kann nicht veralten. */ +export const AUFGABEN = [ + { + schluessel: "creator", + name: "Creator rekrutieren", + kurz: "Creator", + einheit: ["Creator", "Creator"], + vorgabe: 3, + feld: "name", + hinweis: "TikTok-Name des Creators", + }, + { + schluessel: "meeting", + name: "Manager Meeting über Discord", + kurz: "Meeting", + einheit: ["Manager Meeting", "Manager Meetings"], + vorgabe: 2, + feld: null, + hinweis: "Nur Datum und auf Wunsch eine Notiz", + }, + { + schluessel: "schulung", + name: "Schulung oder Community Talk", + kurz: "Schulung", + einheit: ["Schulung oder Talk", "Schulungen oder Talks"], + vorgabe: 2, + feld: "art", + arten: [ + { wert: "schulung", name: "Schulung" }, + { wert: "talk", name: "Community Talk" }, + ], + hinweis: "Über Discord", + }, + { + schluessel: "werbung", + name: "Werbung auf TikTok hochladen", + kurz: "Werbung", + einheit: ["Werbe-Video", "Werbe-Videos"], + vorgabe: 1, + feld: "link", + arten: [ + { wert: "agentur", name: "Für die Agentur" }, + { wert: "eigen", name: "Eigenwerbung" }, + { wert: "beides", name: "Beides" }, + ], + hinweis: "Link zum Video", + }, +]; + +export const AUFGABEN_SCHLUESSEL = AUFGABEN.map((a) => a.schluessel); + +/** Wer die vier Aufgaben erfüllen muss. + * + * Filipe am 02.10.2026 auf die Frage, wer eigene Zähler hat: „Alle + * vier Rollen." Creator stehen bewusst NICHT dabei — sie bekommen + * weder die Kachel noch eine Erinnerung. Modis und die Community + * kommen hier gar nicht vor: Sie gehören zum Haus Team Dogi, und die + * beiden Häuser sind seit dem 24.09.2026 getrennt. */ +export const PFLICHT_ROLLEN = ["spicy", "admin", "manager", "scout"]; + +/** Wer die Zielzahlen ändern darf. + * + * Filipe: „dogfather und spicy rolle sollen aber gleichzeitig es + * immer ändern können für alle." Das ist dieselbe Menge wie + * `siehtAlles()` in workspace.js — sie wird dort abgefragt und nicht + * hier abgeschrieben, damit es nicht zwei Mengen gibt, die + * auseinanderlaufen. Diese Zeile steht nur als Erinnerung daran, dass + * es eine bewusste Entscheidung war und keine Nebenwirkung. */ + +/** Der erste Monat, den es überhaupt gibt. + * + * Aus der Vorlage: „Der erste gültige Monat ist Oktober 2026; davor + * gibt es keine Daten und keine Warnungen." Steht hier und nicht + * dreimal im Fachmodul. */ +export const ERSTER_MONAT = "2026-10"; + +/** Wie lange eine Freigabe für einen abgeschlossenen Monat gilt. + * + * Zwei Minuten. Lang genug für jede Korrektur, kurz genug, dass ein + * vergessenes Zurücksetzen keine dauerhaft offene Tür ist. */ +export const FREIGABE_SEKUNDEN = 120; + +export function managerZieleTabellen(d) { + d.exec(` + /* ---------- 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, + aufgabe TEXT NOT NULL + CHECK (aufgabe IN ('creator','meeting','schulung','werbung')), + /* 'JJJJ-MM'. Der Monatsschnitt entsteht über diese Spalte und + NICHT über das Löschen alter Zeilen -- aus der Vorlage: + „Löse das über die Zuordnung der Einträge zu Monat/Jahr, + nicht über das Löschen von Daten." */ + monat TEXT NOT NULL CHECK (length(monat) = 7), + datum TEXT NOT NULL CHECK (length(datum) = 10), + /* Der TikTok-Name bei 'creator'. Zusätzlich normalisiert in + name_klein, damit der Dublettenschutz nicht an einem + Großbuchstaben oder einem führenden @ scheitert. Zwei Spalten + statt einer Rechnung beim Suchen: So kann ein Index darauf + liegen. */ + name TEXT, + name_klein TEXT, + /* Der Link bei 'werbung', ebenfalls mit normalisierter Fassung. + Filipe am 02.10.2026: jede gültige Internetadresse, nicht nur + TikTok. */ + link TEXT, + link_klein TEXT, + /* schulung|talk bzw. agentur|eigen|beides */ + art TEXT, + notiz TEXT, + /* DIE BRÜCKE ZUR SCOUT-PIPELINE. Darf leer sein: Nicht jede + Rekrutierung läuft über einen Lead, und ein Pflichtfeld hier + würde den Weg von Hand unmöglich machen. SET NULL, weil ein + gelöschter Lead den Monatseintrag nicht entwerten darf — die + Rekrutierung hat ja stattgefunden. */ + lead_id INTEGER REFERENCES leads(id) ON DELETE SET NULL, + /* Ob jemand anders denselben Creator bzw. denselben Link in + diesem Monat schon eingetragen hatte. Kein Grund abzulehnen + (zwei Menschen können denselben Creator ansprechen), aber + DogFather soll es in der Übersicht sehen können. */ + doppelt_bei INTEGER REFERENCES personen(id) ON DELETE SET NULL, + erstellt TEXT NOT NULL, + geaendert TEXT, + geaendert_von INTEGER REFERENCES personen(id) ON DELETE SET NULL + ); + CREATE INDEX IF NOT EXISTS idx_mz_person_monat + ON mz_eintrag (person_id, monat, aufgabe); + CREATE INDEX IF NOT EXISTS idx_mz_monat ON mz_eintrag (monat); + CREATE INDEX IF NOT EXISTS idx_mz_name ON mz_eintrag (monat, name_klein); + CREATE INDEX IF NOT EXISTS idx_mz_link ON mz_eintrag (monat, link_klein); + CREATE INDEX IF NOT EXISTS idx_mz_lead ON mz_eintrag (lead_id); + + /* ---------- Das Ziel, eingefroren je Monat und Rolle ----------- */ + CREATE TABLE IF NOT EXISTS mz_ziel ( + monat TEXT NOT NULL CHECK (length(monat) = 7), + rolle TEXT NOT NULL, + aufgabe TEXT NOT NULL + CHECK (aufgabe IN ('creator','meeting','schulung','werbung')), + /* 0 heißt ausdrücklich „für diese Rolle nicht gefordert" und + nicht „vergessen". Eine Zeile mit 0 erzeugt keine Warnung und + zählt nicht in den Gesamtfortschritt. */ + ziel INTEGER NOT NULL CHECK (ziel >= 0 AND ziel <= 99), + gesetzt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL, + gesetzt_am TEXT NOT NULL, + PRIMARY KEY (monat, rolle, aufgabe) + ); + + /* ---------- Ab wann jemand pflichtig ist ----------------------- */ + /* Aus der Vorlage: „Wer erst im Laufe des Monats eine der Rollen + bekommt, erhält in diesem Monat keine Warnungen; die Pflicht + beginnt im Folgemonat." + + EINMAL GEMESSEN UND GESPEICHERT statt bei jeder Abfrage aus dem + Protokoll gerechnet. Das Protokoll ist ein Log: Es wird + irgendwann aufgeräumt, und dann änderte sich rückwirkend, ab + wann jemand pflichtig war. Ein Fundament darf nicht aus etwas + bestehen, das ein Aufräumlauf kürzen darf. */ + CREATE TABLE IF NOT EXISTS mz_start ( + person_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE, + erster_monat TEXT NOT NULL CHECK (length(erster_monat) = 7), + gesetzt_am TEXT NOT NULL, + /* Woraus die Zahl entstanden ist — damit man es später + nachvollziehen kann, ohne das Protokoll zu durchsuchen. */ + grund TEXT + ); + + /* ---------- Welcher Monat gerade läuft ------------------------- */ + CREATE TABLE IF NOT EXISTS mz_lage ( + eins INTEGER PRIMARY KEY CHECK (eins = 1), + monat TEXT NOT NULL + ); + INSERT OR IGNORE INTO mz_lage (eins, monat) VALUES (1, '0000-00'); + + /* ---------- Die Freigabe für abgeschlossene Monate ------------- */ + CREATE TABLE IF NOT EXISTS mz_freigabe ( + eins INTEGER PRIMARY KEY CHECK (eins = 1), + offen INTEGER NOT NULL DEFAULT 0 CHECK (offen IN (0, 1)), + bis TEXT + ); + INSERT OR IGNORE INTO mz_freigabe (eins, offen, bis) VALUES (1, 0, NULL); + `); + + /* ---------- Die Sperre ------------------------------------------ + Drei Trigger, einer je Richtung. Sie stehen hier und nicht in + einer Umstellung: Sie gehören zur Tabelle wie ihre Spalten. + + DIE BEDINGUNG IST DREITEILIG und jeder Teil hat einen Grund: + * der Monat des Eintrags liegt vor dem laufenden + * die Freigabe steht nicht auf 1 + * ODER die Freigabe ist abgelaufen (`bis` liegt in der + Vergangenheit) -- eine vergessene Freigabe ist damit nach + zwei Minuten von selbst wieder zu. + + `datetime('now')` ist hier unbedenklich, obwohl die Zeitzone des + Servers unbekannt ist: Verglichen wird ein UTC-Zeitstempel mit + einem UTC-Zeitstempel (die Anwendung schreibt `toISOString()`), + und beide wandern gemeinsam. Beim MONAT wäre genau das nicht so -- + deshalb steht er in `mz_lage`. + --------------------------------------------------------------- */ + d.exec(` + CREATE TRIGGER IF NOT EXISTS mz_kein_alter_insert + BEFORE INSERT ON mz_eintrag + WHEN NEW.monat <> (SELECT monat FROM mz_lage WHERE eins = 1) + AND NOT (SELECT offen FROM mz_freigabe WHERE eins = 1) + BEGIN + SELECT RAISE(ABORT, 'monat_gesperrt'); + END; + + 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 ((SELECT offen FROM mz_freigabe WHERE eins = 1) + AND (SELECT bis FROM mz_freigabe WHERE eins = 1) > datetime('now')) + BEGIN + SELECT RAISE(ABORT, 'monat_gesperrt'); + END; + + CREATE TRIGGER IF NOT EXISTS mz_kein_alter_delete + BEFORE DELETE ON mz_eintrag + WHEN OLD.monat <> (SELECT monat FROM mz_lage WHERE eins = 1) + AND NOT ((SELECT offen FROM mz_freigabe WHERE eins = 1) + AND (SELECT bis FROM mz_freigabe WHERE eins = 1) > datetime('now')) + BEGIN + SELECT RAISE(ABORT, 'monat_gesperrt'); + END; + `); + + /* ---------- Nachrüstung für Bestände ohne die neuen Spalten ------- + ABGELEITET, NICHT ABGESCHRIEBEN. Am 11.09.2026 hat im Creator + Workspace eine von Hand gepflegte Spaltenliste beim Tabellenumbau + drei Spalten mit Inhalt verloren -- ohne Fehlermeldung, bei + unveränderter Zeilenzahl. Deshalb wird hier gefragt, was DA ist, + statt eine Liste zu pflegen. */ + const da = new Set(d.prepare("PRAGMA table_info(mz_eintrag)").all().map((s) => s.name)); + const fehlt = [ + ["name_klein", "TEXT"], ["link_klein", "TEXT"], + ["doppelt_bei", "INTEGER REFERENCES personen(id) ON DELETE SET NULL"], + ].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 new file mode 100644 index 00000000..e566cdca --- /dev/null +++ b/server/mess-manager-ziele.mjs @@ -0,0 +1,224 @@ +/* ===================================================================== + WIE DIE MANAGER-ZIELE AUSSEHEN (02.10.2026) + + SIE PRUEFT NICHTS und meldet keinen Fehlschlag -- sie macht Bilder, + auf denen man nachsehen kann, und misst drei Dinge, die man auf + einem Bild schlecht sieht: waagerechtes Schieben, Beruehrziele unter + 44 px und Ueberdeckungen. + + Deshalb `mess-` und nicht `pruef-`. + + ZWEI BREITEN, und die kleinere ist die wichtigere: 412 px ist die + haeufigste Android-Breite. Am 06.09.2026 hat genau diese Breite im + Creator Workspace eine Ueberdeckung aufgedeckt, bei der ein Knopf + ueber einem anderen lag -- auf dem Computer war nichts zu sehen. + + EIGENE WEGWERF-DATENBANK in einem mktemp-Ordner. In einem laufenden + System waeren das Testdaten, und die sind verboten. + + Aufrufen mit: node server/mess-manager-ziele.mjs + ===================================================================== */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { request as httpAnfrage } from "node:http"; + +import { eigenerPort } from "./helfer-port.mjs"; +const PORT = await eigenerPort(import.meta, "mess-manager-ziele"); +const ordner = mkdtempSync(join(tmpdir(), "ws-mz-bild-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = String(PORT); +process.env.SITE_ACCESS_SECRET = "lokaler-test"; +process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; + +const express = (await import("express")).default; +const ec = express.response.cookie; +express.response.cookie = function (n, w, o) { + return ec.call(this, n, w, { ...(o || {}), secure: false }); +}; +import { notbremse } from "./helfer-notbremse.mjs"; +await import("./index.js"); +notbremse(240_000, "mess-manager-ziele"); +await new Promise((r) => setTimeout(r, 900)); +process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); +process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); + +/* ---------- Leute und ein paar echte Eintraege ----------------------- */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +const alt = new Date(Date.now() - 365 * 86400_000).toISOString(); +const sch = randomBytes(32).toString("hex"); +d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") + .run("code_kennung_schluessel", sch, jetzt); +const anlegen = (name, rolle, code) => { + const s = randomBytes(16).toString("hex"); + const h = scryptSync(code, s, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + return d.prepare("INSERT INTO personen" + + " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + + " VALUES (?,?,?,?,?,?,1,?) RETURNING id") + .get(name, rolle, h, s, 32768, + createHmac("sha256", sch).update(code).digest("hex"), alt).id; +}; +anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idScout = anlegen("Kevin", "scout", "CODE-KEVIN-0001"); +anlegen("Patrick", "manager", "CODE-PAT-000001"); +anlegen("Sandra", "scout", "CODE-SAND-00001"); +anlegen("Spicy Media", "spicy", "CODE-SPICY-0001"); + +const { monatJetzt, tagBerlin } = await import("./workspace-manager-ziele.js"); +const MONAT = monatJetzt(); +const HEUTE = tagBerlin(); + +/* DER LAUFENDE MONAT MUSS DER DATENBANK GESAGT WERDEN, bevor hier von + Hand eingetragen wird. `mz_lage` steht nach dem Anlegen auf + '0000-00', und die Trigger lehnen dann JEDEN Eintrag ab -- auch den + fuer den aktuellen Monat. Das ist richtig so (zu, solange niemand + gesagt hat, welcher Monat laeuft) und hat diesen Lauf beim ersten + Mal abgebrochen. Im Betrieb setzt es jede Anfrage selbst. */ +d.prepare("UPDATE mz_lage SET monat = ? WHERE eins = 1").run(MONAT); + +/* EIN GEMISCHTER STAND -- genau darum geht es beim Ansehen: eine + erfuellte Zeile, eine halbe, eine leere. Stuende alles auf null, + sieht man die Ampel gar nicht. */ +const setz = d.prepare(`INSERT INTO mz_eintrag + (person_id, aufgabe, monat, datum, name, name_klein, link, link_klein, art, notiz, erstellt) + VALUES (?,?,?,?,?,?,?,?,?,?,?)`); +setz.run(idScout, "creator", MONAT, HEUTE, "@lunatanzt", "lunatanzt", null, null, null, + "über Discord gefunden, sehr aktiv", jetzt); +setz.run(idScout, "creator", MONAT, HEUTE, "@marakocht", "marakocht", null, null, null, null, jetzt); +setz.run(idScout, "meeting", MONAT, HEUTE, null, null, null, null, null, "Wochenrunde", jetzt); +setz.run(idScout, "werbung", MONAT, HEUTE, null, null, + "https://www.tiktok.com/@dogfather/video/7312", "tiktok.com/@dogfather/video/7312", + "agentur", null, jetzt); +/* 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) + VALUES (?,?,?,?,?,?,?)`) + .run("Nina B.", "@ninab", "uebergeben", idScout, jetzt, idScout, `${HEUTE}T09:00:00Z`); +d.close(); + +/* ---------- Anmelden -------------------------------------------------- */ +const anfrage = (pfad, art, koerper) => new Promise((fertig, schief) => { + const a = httpAnfrage({ + host: "127.0.0.1", port: PORT, path: pfad, method: art, + headers: { "Content-Type": "application/json", Origin: `http://127.0.0.1:${PORT}` }, + }, (r) => { + let t = ""; r.on("data", (x) => { t += x; }); + r.on("end", () => fertig({ code: r.statusCode, kopf: r.headers, text: t })); + }); + a.on("error", schief); + if (koerper) a.write(JSON.stringify(koerper)); + a.end(); +}); + +async function keksFuer(rolle, code) { + const an = await anfrage("/workspace/api/anmelden", "POST", { rolle, code }); + if (an.code !== 200) { console.log(`ABBRUCH: Anmeldung ${rolle} ${an.code}`); process.exit(2); } + return (an.kopf["set-cookie"] || []).map((x) => x.split(";")[0]).join("; "); +} +const keksScout = await keksFuer("scout", "CODE-KEVIN-0001"); +const keksDogi = await keksFuer("admin", "CODE-DOGI-0001"); + +/* ---------- Bilder ---------------------------------------------------- */ +const pw = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await pw.chromium.launch(); + +/** Was man auf einem Bild schlecht sieht. */ +const nachmessen = () => ({ + /* Waagerechtes Schieben ist im Haus ausdruecklich unerwuenscht. + Zwei Pixel Toleranz fuer Rundungen. */ + schiebtQuer: document.documentElement.scrollWidth > window.innerWidth + 2, + breite: window.innerWidth, + /* Jedes Beruehrziel. 44 px ist die Hausgroesse (`--feld-ziel`). */ + zuKlein: [...document.querySelectorAll("button, a[href], input, select")] + .filter((e) => { + const k = e.getBoundingClientRect(); + return k.width > 0 && k.height > 0 && k.height < 40; + }) + .map((e) => `${e.tagName.toLowerCase()}.${e.className || "-"}: ${Math.round(e.getBoundingClientRect().height)}px`), + /* Ueberdeckungen zwischen den Knoepfen der Kopfzeile einer Aufgabe. */ + zeilen: [...document.querySelectorAll(".mz-zeile")].map((z) => ({ + aufgabe: z.dataset.aufgabe, + stufe: z.dataset.stufe, + zahl: z.querySelector(".mz-zeile__zahl")?.textContent, + satz: z.querySelector(".mz-zeile__lage")?.textContent, + })), + ring: document.getElementById("ring-zahl")?.textContent + + " " + document.getElementById("ring-von")?.textContent, + kopf: document.getElementById("kopf-stand")?.textContent, + rest: document.getElementById("kopf-rest")?.textContent, + vorschlaege: document.querySelectorAll(".mz-vorschlag__knopf").length, +}); + +async function bild(name, keks, breite, hoehe, vorher) { + const kontext = await browser.newContext({ + viewport: { width: breite, height: hoehe }, hasTouch: breite <= 860, + }); + await kontext.addCookies(keks.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(); + const konsole = []; + seite.on("console", (e) => { + if (e.type() === "error" && !/favicon|net::ERR/i.test(e.text())) konsole.push(e.text().slice(0, 160)); + }); + seite.on("pageerror", (e) => konsole.push("Absturz: " + e.message)); + + await seite.goto(`http://127.0.0.1:${PORT}/workspace/manager-ziele.html`, + { waitUntil: "networkidle" }); + await seite.waitForTimeout(1200); + if (vorher) await vorher(seite); + await seite.waitForTimeout(500); + + const blick = await seite.evaluate(nachmessen); + await seite.screenshot({ path: `bild-manager-ziele-${name}.png`, fullPage: true }); + + console.log(`\n--- ${name} (${breite}px) ---`); + console.log(` Ring: ${blick.ring}`); + console.log(` Kopf: ${blick.kopf}`); + console.log(` Rest: ${blick.rest}`); + for (const z of blick.zeilen) { + console.log(` ${String(z.aufgabe).padEnd(9)} ${String(z.zahl).padEnd(6)} ${String(z.stufe).padEnd(8)} ${z.satz}`); + } + console.log(` Vorschlaege: ${blick.vorschlaege}`); + console.log(` Quer schieben: ${blick.schiebtQuer ? "JA — Befund!" : "nein"}`); + console.log(` Zu kleine Ziele: ${blick.zuKlein.length ? blick.zuKlein.join(", ") : "keine"}`); + if (konsole.length) console.log(` KONSOLE: ${konsole.join(" | ")}`); + await kontext.close(); + return blick; +} + +/* Die vier Aufgabenzeilen einmal aufklappen, damit die Eintraege und + der Vorschlagskasten auf dem Bild stehen. */ +const aufklappen = async (seite) => { + for (const kopf of await seite.$$(".mz-zeile__kopf")) { + await kopf.click(); + await seite.waitForTimeout(250); + } +}; + +const handy = await bild("scout-handy", keksScout, 412, 915, aufklappen); +const computer = await bild("scout-computer", keksScout, 1280, 1100, aufklappen); +await bild("dogfather-team", keksDogi, 1280, 1200, null); +await bild("dogfather-handy", keksDogi, 412, 915, null); + +/* Und das Formular, denn dort wird wirklich getippt. */ +await bild("formular-handy", keksScout, 412, 915, async (seite) => { + const knopf = await seite.$(".mz-zeile__fuss .knopf"); + if (knopf) { await knopf.click(); await seite.waitForTimeout(400); } +}); + +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + +const befunde = [handy, computer].filter((b) => b.schiebtQuer || b.zuKlein.length); +console.log(befunde.length + ? `\n${befunde.length} Breite(n) mit Befund — siehe oben.` + : "\nKein waagerechtes Schieben, kein zu kleines Beruehrziel."); +process.exit(0); diff --git a/server/pruef-manager-ziele.mjs b/server/pruef-manager-ziele.mjs new file mode 100644 index 00000000..a4233dae --- /dev/null +++ b/server/pruef-manager-ziele.mjs @@ -0,0 +1,734 @@ +/* ===================================================================== + MANAGER-ZIELE (02.10.2026) + ===================================================================== + + Geprüft wird, was Filipe bestellt hat — und fünf Dinge, die er nicht + bestellt hat, weil sie sonst niemandem auffallen: + + * JEDE Sperre muss auch NEIN sagen können. Eine Prüfung, die nur + den Erfolgsfall ansieht, beweist nichts: `every()` liefert auf + einem leeren Feld `true`, und ein Trigger, dessen Spaltenname + falsch ist, lehnt die Änderung ab, ohne je gelaufen zu sein + (RunOne, 01.09.2026). Zu jeder „geht"-Prüfung steht hier eine + „geht nicht"-Prüfung. + + * DIE DATUMSLOGIK WIRD MIT EINGESETZTEN TAGEN GEMESSEN, nie gegen + die Wanduhr. `gate-oeffnung.mjs` im Shop war grün, bis der + Kalender sie überholte, und dann rot, ohne dass etwas kaputt + war. Hier liegen die Tage als Zahl im Aufruf — diese Prüfung + sagt am 16. November dasselbe wie heute. + + * DIE ZAHL DER GEPRÜFTEN DINGE STEHT IN DER BEDINGUNG, nicht nur + im Meldetext. „Alle Rollen abgewiesen" ist auf einer leeren + Liste wahr. + + * DIE BEIDEN HÄUSER BLEIBEN GETRENNT. Auf der Crew-Adresse gibt es + diese Kachel nicht, auch nicht für DogFather. + + * DER MONATSSCHNITT ÄNDERT DEN VERLAUF NICHT. Wer die Zielzahlen + erhöht, darf einen abgeschlossenen Monat nicht nachträglich auf + „nicht erreicht" drehen. + ===================================================================== */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-mz-")); +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 der Manager-Ziele"); + +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"; + +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(240_000, "pruef-manager-ziele"); +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); + +/* DER DRITTE AUSGANG. Kann die Prüfung ihre Voraussetzung nicht + herstellen, ist das WEDER in Ordnung NOCH ein Befund am Code -- + sie muss es sagen und abbrechen, statt grün zu melden. */ +function braucht(bedingung, was) { + if (bedingung) return; + console.log(`\nABBRUCH: konnte nicht nachsehen — ${was}`); + console.log("Das ist KEIN Befund am Code. Die Pruefung konnte ihre"); + console.log("Voraussetzung nicht herstellen und sagt das, statt zu schweigen.\n"); + try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } + process.exit(2); +} + +/* ---------- Personen anlegen ---------------------------------------- */ +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetztIso = new Date().toISOString(); +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, jetztIso); + +/* ERSTELLT: ein Jahr alt. Sonst wären alle erst im Folgemonat + pflichtig (Regel 09 der Vorlage) und jede Ampelprüfung wäre + neutral — die Prüfung sähe grün aus und hätte nichts gemessen. */ +const ALT = new Date(Date.now() - 365 * 86400_000).toISOString(); + +function anlegen(name, rolle, code, erstellt = ALT) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n," + + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, + createHmac("sha256", kennSchluessel).update(code).digest("hex"), erstellt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} + +const ROLLEN = [ + ["admin", "Filipe"], ["spicy", "Spicy Media"], ["manager", "Patrick"], + ["scout", "Kevin"], ["scout", "Sandra"], ["creator", "Luna"], + ["hand", "VanVan"], ["modi", "Miss"], +]; +const CODES = {}, IDS = {}; +for (const [i, [rolle, name]] of ROLLEN.entries()) { + const schluessel = IDS[rolle] === undefined ? rolle : `${rolle}2`; + CODES[schluessel] = `CODE-Z${String(i).padStart(2, "0")}-000001`; + IDS[schluessel] = anlegen(name, rolle, CODES[schluessel]); +} +d.close(); + +/* ---------- Anmelden ------------------------------------------------- */ +const { request } = await import("node:http"); +const CREW_HOST = "crew.dogfather-universe.com"; + +function roh(pfad, host, koerper, methode) { + return new Promise((fertig, schief) => { + const daten = koerper ? JSON.stringify(koerper) : null; + const a = request({ + host: "127.0.0.1", port: PORT, path: pfad, + method: methode || (daten ? "POST" : "GET"), + headers: { + ...(host ? { Host: host } : {}), + Origin: host ? `https://${host}` : BASIS, + ...(daten ? { "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(daten) } : {}), + }, + }, (antwort) => { + let text = ""; + antwort.on("data", (t) => { text += t; }); + antwort.on("end", () => fertig({ code: antwort.statusCode, kopf: antwort.headers, text })); + }); + a.on("error", schief); + if (daten) a.write(daten); + a.end(); + }); +} + +/** Derselbe Aufruf mit Hausadresse UND Sitzung. `fetch` verwirft den + * Host-Kopf (er steht auf der Liste der verbotenen Kopfzeilen), also + * geht das nur ueber node:http. */ +function rohMitKeks(pfad, host, keks) { + return new Promise((fertig, schief) => { + const a = request({ + host: "127.0.0.1", port: PORT, path: pfad, method: "GET", + headers: { Host: host, Origin: `https://${host}`, Cookie: keks || "" }, + }, (antwort) => { + let text = ""; + antwort.on("data", (t) => { text += t; }); + antwort.on("end", () => fertig({ code: antwort.statusCode, text })); + }); + a.on("error", schief); + a.end(); + }); +} + +async function anmelden(schluessel, rolle, host = null) { + const r = await roh("/workspace/api/anmelden", host, { rolle, code: CODES[schluessel] }); + if (r.code !== 200) return null; + return [].concat(r.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ") || null; +} + +const kekse = {}; +melde("\n=== Alle Rollen kommen herein ==="); +for (const schluessel of Object.keys(CODES)) { + const rolle = schluessel.replace(/2$/, ""); + kekse[schluessel] = await anmelden(schluessel, rolle); + ok(!!kekse[schluessel], `${schluessel}: angemeldet`); +} +braucht(Object.values(kekse).every(Boolean), "nicht alle Rollen konnten sich anmelden"); + +async function hol(schluessel, pfad, einst = {}) { + return fetch(BASIS + pfad, { + ...einst, + headers: { + Cookie: kekse[schluessel] || "", Origin: BASIS, + ...(einst.body ? { "Content-Type": "application/json" } : {}), + ...(einst.headers || {}), + }, + }); +} +const schick = (schluessel, pfad, methode, rumpf) => + hol(schluessel, pfad, { method: methode, body: JSON.stringify(rumpf) }); + +/* ===================================================================== + 1 · WER DARF HEREIN — und wer ausdrücklich nicht + ===================================================================== */ +melde("\n=== Sichtbarkeit: vier Rollen ja, der Rest nein ==="); +const DUERFEN = ["admin", "spicy", "manager", "scout"]; +const DUERFEN_NICHT = ["creator", "hand", "modi"]; + +let drin = 0; +for (const r of DUERFEN) { + const a = await hol(r, `${API}/stand`); + if (a.status === 200) drin++; + ok(a.status === 200, `${r} kommt an den Stand (${a.status})`); +} +/* DIE ZAHL STEHT IN DER BEDINGUNG. „Alle durchgelassen" wäre auf einer + leeren Liste wahr. */ +ok(drin === DUERFEN.length, `genau ${DUERFEN.length} Rollen kommen herein (${drin})`); + +let draussen = 0; +for (const r of DUERFEN_NICHT) { + const a = await hol(r, `${API}/stand`); + if (a.status === 404) draussen++; + ok(a.status === 404, `${r} bekommt 404 (nicht 403) (${a.status})`); +} +ok(draussen === DUERFEN_NICHT.length, + `genau ${DUERFEN_NICHT.length} Rollen bleiben draussen (${draussen})`); + +melde("\n=== Die Seite selbst ist ebenfalls zu ==="); +const seiteCreator = await hol("creator", "/workspace/manager-ziele.html", { redirect: "manual" }); +ok(seiteCreator.status === 302, + `Creator wird von der Seite weggeschickt (${seiteCreator.status})`); +const seiteScout = await hol("scout", "/workspace/manager-ziele.html", { redirect: "manual" }); +ok(seiteScout.status === 200, `Scout bekommt die Seite (${seiteScout.status})`); + +melde("\n=== Zwei getrennte Haeuser: auf crew. gibt es die Kachel nicht ==="); +const crewKeks = await anmelden("admin", "admin", CREW_HOST); +braucht(!!crewKeks, "DogFather konnte sich auf der Crew-Adresse nicht anmelden"); +/* DER KEKS MUSS MIT. Beim ersten Lauf stand hier 401 statt 404 -- + und das sah aus wie ein Befund an der Haustrennung. Es war einer an + meiner Messung: Ohne Sitzung kommt man ueberall nicht herein, das + beweist nichts ueber DIESE Kachel. */ +const crewStand = await rohMitKeks(`${API}/stand`, CREW_HOST, crewKeks); +ok(crewStand.code === 404, + `DogFather bekommt auf crew. ein 404 auf die Schnittstelle (${crewStand.code})`); +const crewMitKeks = await rohMitKeks("/workspace/start.html", CREW_HOST, crewKeks); +ok(crewMitKeks.code === 200, + `…und ist dort trotzdem angemeldet (${crewMitKeks.code}) — die Sitzung war es nicht`); + +/* ===================================================================== + 2 · DIE AMPEL — reine Rechnung, mit eingesetzten Tagen + ===================================================================== */ +melde("\n=== Die Ampel (eingesetzte Tage, nicht die Wanduhr) ==="); +const mz = await import("./workspace-manager-ziele.js"); + +/* Ein Monat mit 31 Tagen. `bisEnde = letzter - tag`. */ +const L = 31; +ok(mz.stufeFuer(3, 3, 5, L) === "gruen", "Ziel erreicht am 5. -> gruen"); +ok(mz.stufeFuer(4, 3, 5, L) === "gruen", "uebererfuellt (4/3) -> gruen"); +ok(mz.stufeFuer(0, 3, 1, L) === "neutral", "Tag 1, offen -> neutral"); +ok(mz.stufeFuer(1, 3, 15, L) === "neutral", "Tag 15, offen -> neutral"); +ok(mz.stufeFuer(1, 3, 16, L) === "orange", "Tag 16, offen -> orange"); +ok(mz.stufeFuer(1, 3, 23, L) === "orange", "Tag 23 (8 Tage uebrig) -> noch orange"); +ok(mz.stufeFuer(1, 3, 24, L) === "rot", "Tag 24 (7 Tage uebrig) -> rot"); +ok(mz.stufeFuer(1, 3, 31, L) === "rot", "letzter Tag, offen -> rot"); +ok(mz.stufeFuer(0, 0, 31, L) === "aus", "Ziel 0 -> aus, nie eine Warnung"); +/* GEGENPROBE: Die Rechnung muss auch dann rot sagen koennen, wenn der + 16. gar nicht erreicht wurde -- in einem kurzen Monat. */ +ok(mz.stufeFuer(0, 1, 21, 28) === "rot", "Februar, Tag 21 (7 uebrig) -> rot"); +ok(mz.stufeFuer(0, 1, 13, 28) === "neutral", "Februar, Tag 13 -> neutral"); + +ok(mz.stufenText("rot", 0) === "Dringend – heute ist der letzte Tag", + "der letzte Tag bekommt seinen eigenen Satz"); +ok(mz.stufenText("rot", 1) === "Dringend – noch 1 Tag", "Einzahl bei einem Tag"); +ok(mz.stufenText("rot", 5) === "Dringend – noch 5 Tage", "Mehrzahl ab zwei Tagen"); +ok(mz.stufenText("orange", 9) === "Sollte bis Monatsmitte erledigt sein", + "der Orange-Satz steht woertlich wie in der Vorlage"); +ok(mz.stufenText("neutral", 20) === "", "neutral sagt ausdruecklich nichts"); + +ok(mz.schlechteste(["gruen", "neutral", "rot"]) === "rot", + "die Kachel traegt die schlechteste Farbe"); +ok(mz.schlechteste(["gruen", "aus"]) === "gruen", + "eine abgeschaltete Aufgabe verschlechtert nichts"); + +melde("\n=== Monatsrechnung ==="); +ok(mz.letzterTagVon("2026-02") === 28, "Februar 2026 hat 28 Tage"); +ok(mz.letzterTagVon("2024-02") === 29, "Februar 2024 hat 29 Tage"); +ok(mz.letzterTagVon("2026-10") === 31, "Oktober hat 31 Tage"); +ok(mz.monatPlus("2026-12", 1) === "2027-01", "Dezember + 1 = Januar des Folgejahres"); +ok(mz.monatPlus("2026-01", -1) === "2025-12", "Januar - 1 = Dezember des Vorjahres"); +/* Die Zeitzone: ein Zeitpunkt, der in UTC noch zum Vortag gehoert, + aber in Berlin schon zum naechsten Tag. Ohne Europe/Berlin faende + diese Zeile den falschen Tag -- und am Monatsersten den falschen + Monat. */ +ok(mz.tagBerlin(new Date("2026-06-30T23:30:00Z")) === "2026-07-01", + "23:30 UTC am 30.06. ist in Berlin schon der 1. Juli"); + +/* ===================================================================== + 3 · EINTRAGEN + ===================================================================== */ +melde("\n=== Eintragen ==="); +const standVorher = await (await hol("scout", `${API}/stand`)).json(); +braucht(standVorher && Array.isArray(standVorher.aufgaben), + "der Stand kam nicht als Liste zurueck"); +const MONAT = standVorher.monat; +const HEUTE = standVorher.heute; +ok(standVorher.aufgaben.length === 4, `vier Aufgaben (${standVorher.aufgaben.length})`); +ok(standVorher.pflichtig === true, "ein ein Jahr alter Scout ist pflichtig"); + +const vorherCreator = standVorher.aufgaben.find((a) => a.schluessel === "creator"); +ok(vorherCreator.ziel === 3, `Vorgabe fuer Creator ist 3 (${vorherCreator.ziel})`); +ok(vorherCreator.zahl === 0, "noch nichts eingetragen"); + +const neu1 = await schick("scout", `${API}/eintrag`, "POST", + { aufgabe: "creator", datum: HEUTE, name: "@LunaTanzt", notiz: "über Discord gefunden" }); +const neu1d = await neu1.json(); +ok(neu1.status === 201, `Creator-Eintrag angelegt (${neu1.status})`); +ok(neu1d.stand?.aufgaben?.find((a) => a.schluessel === "creator")?.zahl === 1, + "der Zaehler steht sofort auf 1 (ohne Neuladen)"); + +for (const [aufgabe, rumpf] of [ + ["meeting", { datum: HEUTE }], + ["schulung", { datum: HEUTE, art: "talk" }], + ["werbung", { datum: HEUTE, art: "agentur", link: "https://www.tiktok.com/@dogi/video/123" }], +]) { + const a = await schick("scout", `${API}/eintrag`, "POST", { aufgabe, ...rumpf }); + ok(a.status === 201, `${aufgabe}: angelegt (${a.status})`); +} + +const standNachher = await (await hol("scout", `${API}/stand`)).json(); +ok(standNachher.gesamt.erledigt === 4, + `Gesamtfortschritt 4 von 8 (${standNachher.gesamt.erledigt} von ${standNachher.gesamt.ziel})`); +ok(standNachher.gesamt.ziel === 8, "Zielsumme 3+2+2+1 = 8"); +ok(standNachher.aufgaben.find((a) => a.schluessel === "werbung").stufe === "gruen", + "Werbung (1 von 1) steht auf gruen"); + +/* ===================================================================== + 4 · WAS ABGELEHNT WERDEN MUSS — die Gegenproben + ===================================================================== */ +melde("\n=== Abgelehnt wird, was abgelehnt gehoert ==="); +const morgen = new Date(Date.parse(HEUTE + "T12:00:00Z") + 86400_000).toISOString().slice(0, 10); +const fremderMonat = `${mz.monatPlus(MONAT, -1)}-15`; + +const absagen = [ + ["Datum in der Zukunft", { aufgabe: "meeting", datum: morgen }, "datum_zukunft"], + ["Datum aus dem Vormonat", { aufgabe: "meeting", datum: fremderMonat }, "datum_fremder_monat"], + ["gar kein Datum", { aufgabe: "meeting", datum: "" }, "datum_fehlt"], + ["Creator ohne Namen", { aufgabe: "creator", datum: HEUTE, name: " " }, "name_fehlt"], + ["Werbung ohne Link", { aufgabe: "werbung", datum: HEUTE, art: "eigen", link: "" }, "link_ungueltig"], + ["Werbung mit javascript:", { aufgabe: "werbung", datum: HEUTE, art: "eigen", + link: "javascript:alert(1)" }, "link_ungueltig"], + ["Werbung mit blossem Wort", { aufgabe: "werbung", datum: HEUTE, art: "eigen", + link: "tiktok" }, "link_ungueltig"], + ["Schulung ohne Art", { aufgabe: "schulung", datum: HEUTE, art: "" }, "art_fehlt"], + ["Aufgabe, die es nicht gibt", { aufgabe: "kaffee", datum: HEUTE }, "aufgabe_unbekannt"], +]; +let abgelehnt = 0; +for (const [was, rumpf, grund] of absagen) { + const a = await schick("scout", `${API}/eintrag`, "POST", rumpf); + const j = await a.json().catch(() => ({})); + const richtig = a.status === 400 && j.fehler === grund; + if (richtig) abgelehnt++; + ok(richtig, `${was} -> abgelehnt mit „${grund}" (${a.status}/${j.fehler})`); +} +ok(abgelehnt === absagen.length, + `alle ${absagen.length} Absagen kamen mit dem richtigen Grund (${abgelehnt})`); + +/* EINE GUELTIGE FREMDE ADRESSE MUSS DURCH -- Filipe am 02.10.2026: + „jede gültige Internetadresse". Ohne diese Zeile bewiese der Block + darueber nur, dass die Pruefung streng ist, nicht dass sie richtig + ist. */ +const fremdLink = await schick("scout", `${API}/eintrag`, "POST", + { aufgabe: "werbung", datum: HEUTE, art: "eigen", link: "https://www.instagram.com/p/abc" }); +ok(fremdLink.status === 201, + `eine Instagram-Adresse wird angenommen (${fremdLink.status}) — nicht nur TikTok`); + +melde("\n=== Dubletten ==="); +const doppelt = await schick("scout", `${API}/eintrag`, "POST", + { aufgabe: "creator", datum: HEUTE, name: "lunatanzt" }); +const doppeltD = await doppelt.json().catch(() => ({})); +ok(doppelt.status === 409 && doppeltD.fehler === "schon_eingetragen", + `„lunatanzt" gilt als derselbe wie „@LunaTanzt" (${doppelt.status})`); + +const anderer = await schick("scout2", `${API}/eintrag`, "POST", + { aufgabe: "creator", datum: HEUTE, name: "@LunaTanzt" }); +const andererD = await anderer.json().catch(() => ({})); +ok(anderer.status === 201, `eine ANDERE Person darf denselben Namen eintragen (${anderer.status})`); +ok(!!andererD.warnung, "…bekommt aber einen Hinweis, dass es jemand anders auch hat"); + +const neuerName = await schick("scout", `${API}/eintrag`, "POST", + { aufgabe: "creator", datum: HEUTE, name: "@ZweiterCreator" }); +ok(neuerName.status === 201, `ein anderer Name geht weiterhin (${neuerName.status})`); + +/* ===================================================================== + 5 · AENDERN UND LOESCHEN + ===================================================================== */ +melde("\n=== Aendern und Loeschen ==="); +const liste = await (await hol("scout", `${API}/eintraege?monat=${MONAT}&aufgabe=creator`)).json(); +braucht(liste.eintraege?.length >= 2, "es gibt zu wenige Eintraege zum Aendern"); +const einId = liste.eintraege.find((e) => e.name === "@ZweiterCreator").id; + +const geaendert = await schick("scout", `${API}/eintrag/${einId}`, "PATCH", + { datum: HEUTE, name: "@ZweiterCreatorNeu", notiz: "Name berichtigt" }); +ok(geaendert.status === 200, `eigener Eintrag geaendert (${geaendert.status})`); + +const fremdAendern = await schick("manager", `${API}/eintrag/${einId}`, "PATCH", + { datum: HEUTE, name: "@Geklaut" }); +ok(fremdAendern.status === 403, + `ein Manager kann einen fremden Eintrag NICHT aendern (${fremdAendern.status})`); + +const adminAendern = await schick("admin", `${API}/eintrag/${einId}`, "PATCH", + { datum: HEUTE, name: "@VonDogfatherBerichtigt" }); +ok(adminAendern.status === 200, `DogFather darf korrigieren (${adminAendern.status})`); + +const dd = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); +const protokolliert = dd.prepare( + "SELECT COUNT(*) n FROM protokoll WHERE aktion = 'manager_ziele_korrigiert'").get().n; +dd.close(); +ok(protokolliert >= 1, `jede Korrektur steht im Protokoll (${protokolliert})`); + +const fremdLoeschen = await hol("manager", `${API}/eintrag/${einId}`, { method: "DELETE" }); +ok(fremdLoeschen.status === 403, + `ein Manager kann einen fremden Eintrag NICHT loeschen (${fremdLoeschen.status})`); + +const vorLoeschen = await (await hol("scout", `${API}/stand`)).json(); +const geloescht = await hol("scout", `${API}/eintrag/${einId}`, { method: "DELETE" }); +const geloeschtD = await geloescht.json().catch(() => ({})); +ok(geloescht.status === 200, `eigener Eintrag geloescht (${geloescht.status})`); +ok(geloeschtD.stand?.aufgaben?.find((a) => a.schluessel === "creator")?.zahl + === vorLoeschen.aufgaben.find((a) => a.schluessel === "creator").zahl - 1, + "der Zaehler geht nach dem Loeschen um genau eins zurueck"); + +/* ===================================================================== + 6 · DER MONATSSCHNITT — die Sperre muss wirklich sperren + ===================================================================== */ +melde("\n=== Vergangene Monate sind zu ==="); +const VORMONAT = mz.monatPlus(MONAT, -1); + +/* Eine Zeile aus dem Vormonat einsetzen. Das geht NUR mit geoeffneter + Freigabe -- und genau das ist schon die erste Gegenprobe: Ohne sie + lehnt der Trigger ab. */ +const db2 = new DatabaseSync(process.env.WORKSPACE_DB); +let triggerHaelt = false; +try { + db2.prepare("INSERT INTO mz_eintrag (person_id, aufgabe, monat, datum, erstellt)" + + " VALUES (?,?,?,?,?)").run(IDS.scout, "meeting", VORMONAT, `${VORMONAT}-05`, jetztIso); +} catch (f) { + triggerHaelt = String(f?.message || "").includes("monat_gesperrt"); +} +ok(triggerHaelt, + "die DATENBANK lehnt einen Eintrag im Vormonat ab (nicht erst der Code)"); + +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); +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(); +braucht(!!altId, "es liess sich keine Zeile im Vormonat anlegen"); + +const altAendern = await schick("scout", `${API}/eintrag/${altId}`, "PATCH", + { datum: `${VORMONAT}-06` }); +ok(altAendern.status === 409, + `ein Scout kann im Vormonat nichts mehr aendern (${altAendern.status})`); +const altLoeschen = await hol("scout", `${API}/eintrag/${altId}`, { method: "DELETE" }); +ok(altLoeschen.status === 409, + `…und auch nichts loeschen (${altLoeschen.status})`); + +const altAdmin = await schick("admin", `${API}/eintrag/${altId}`, "PATCH", + { datum: `${VORMONAT}-07`, notiz: "Korrektur im Nachhinein" }); +ok(altAdmin.status === 200, + `DogFather darf auch im Vormonat korrigieren (${altAdmin.status})`); + +/* UND DIE FREIGABE IST DANACH WIEDER ZU. Ein Schloss, das offen + bleibt, ist dasselbe wie keines. */ +const db3 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); +const freigabe = db3.prepare("SELECT offen FROM mz_freigabe WHERE eins = 1").get(); +db3.close(); +ok(freigabe.offen === 0, `die Freigabe steht nach der Korrektur wieder auf zu (${freigabe.offen})`); + +const altNochmal = await schick("scout", `${API}/eintrag/${altId}`, "PATCH", + { datum: `${VORMONAT}-08` }); +ok(altNochmal.status === 409, + `der Scout kommt auch danach nicht hinein (${altNochmal.status}) — die Freigabe wirkte nur einmal`); + +/* ===================================================================== + 7 · DIE ZIELZAHLEN + ===================================================================== */ +melde("\n=== Zielzahlen: aenderbar, aber erst ab naechstem Monat ==="); +const zieleScout = await hol("scout", `${API}/ziele`); +ok(zieleScout.status === 403, `ein Scout sieht die Zielzahlen nicht (${zieleScout.status})`); +const zieleManager = await hol("manager", `${API}/ziele`); +ok(zieleManager.status === 403, `ein Manager auch nicht (${zieleManager.status})`); + +for (const r of ["admin", "spicy"]) { + const a = await hol(r, `${API}/ziele`); + ok(a.status === 200, `${r} darf die Zielzahlen sehen (${a.status})`); +} + +const vorAenderung = await (await hol("scout", `${API}/stand`)).json(); +const zielVorher = vorAenderung.aufgaben.find((a) => a.schluessel === "creator").ziel; + +const gesetzt = await schick("spicy", `${API}/ziele`, "PUT", + { ziele: { scout: { creator: 9 }, admin: { creator: 0 } } }); +const gesetztD = await gesetzt.json().catch(() => ({})); +ok(gesetzt.status === 200, `Spicy Media darf die Zielzahlen setzen (${gesetzt.status})`); +ok(gesetztD.monat === mz.monatPlus(MONAT, 1), + `gespeichert wird fuer den NAECHSTEN Monat (${gesetztD.monat})`); + +const nachAenderung = await (await hol("scout", `${API}/stand`)).json(); +ok(nachAenderung.aufgaben.find((a) => a.schluessel === "creator").ziel === zielVorher, + `der laufende Monat bleibt bei ${zielVorher} — eine Aenderung wirkt NICHT rueckwirkend`); + +const zieleJetzt = await (await hol("admin", `${API}/ziele`)).json(); +ok(zieleJetzt.naechster.find((r) => r.rolle === "scout").ziele.creator === 9, + "im naechsten Monat steht beim Scout die 9"); +ok(zieleJetzt.jetzt.find((r) => r.rolle === "scout").ziele.creator === zielVorher, + "im laufenden Monat steht dort weiterhin die alte Zahl"); + +const unsinn = await schick("admin", `${API}/ziele`, "PUT", { ziele: { scout: { creator: 500 } } }); +ok(unsinn.status === 400, `eine Zielzahl von 500 wird abgelehnt (${unsinn.status})`); +const negativ = await schick("admin", `${API}/ziele`, "PUT", { ziele: { scout: { creator: -1 } } }); +ok(negativ.status === 400, `eine negative Zielzahl wird abgelehnt (${negativ.status})`); + +/* ===================================================================== + 8 · DIE TEAM-UEBERSICHT UND DER EXPORT + ===================================================================== */ +melde("\n=== Team-Uebersicht ==="); +for (const r of ["manager", "scout"]) { + const a = await hol(r, `${API}/team`); + ok(a.status === 403, `${r} sieht die Team-Uebersicht nicht (${a.status})`); +} +const team = await hol("admin", `${API}/team`); +const teamD = await team.json(); +ok(team.status === 200, `DogFather sieht die Team-Uebersicht (${team.status})`); +ok(teamD.leute.length === 5, + `fuenf pflichtige Personen in der Liste (${teamD.leute.length}) — Creator, Hand und Modi fehlen zu Recht`); +ok(teamD.leute.every((p) => ["spicy", "admin", "manager", "scout"].includes(p.rolle)), + "und keine einzige fremde Rolle darunter"); + +const spicyTeam = await hol("spicy", `${API}/team`); +ok(spicyTeam.status === 200, `Spicy Media ebenfalls (${spicyTeam.status})`); + +const fremdeSicht = await hol("scout", `${API}/stand?person=${IDS.scout2}`); +const fremdeSichtD = await fremdeSicht.json(); +ok(fremdeSichtD.person?.id === IDS.scout, + "ein Scout, der eine fremde Nummer in die Adresse schreibt, sieht trotzdem nur sich selbst"); + +melde("\n=== CSV-Export ==="); +const csvScout = await hol("scout", `${API}/export.csv`); +ok(csvScout.status === 403, `ein Scout bekommt keinen Export (${csvScout.status})`); +const csv = await hol("admin", `${API}/export.csv?monat=${MONAT}`); +/* IN BYTES GEMESSEN, nicht in Zeichen. `Response.text()` entfernt ein + fuehrendes BOM beim Dekodieren -- die Pruefung meldete deshalb beim + ersten Lauf "kein BOM", obwohl es da war. Das war ein Fehler an der + Messung, nicht am Export. */ +const csvBytes = new Uint8Array(await csv.clone().arrayBuffer()); +const csvText = await csv.text(); +ok(csv.status === 200, `DogFather bekommt den Export (${csv.status})`); +ok(csvBytes[0] === 0xEF && csvBytes[1] === 0xBB && csvBytes[2] === 0xBF, + `die Datei beginnt mit dem BOM (${[...csvBytes.slice(0, 3)].map((b) => b.toString(16)).join(" ")}) — sonst zeigt deutsches Excel kaputte Umlaute`); +ok(csvText.split("\n")[0].includes(";"), + "getrennt wird mit Semikolon — sonst steht alles in einer Spalte"); +const csvZeilen = csvText.trim().split(/\r?\n/); +ok(csvZeilen.length === teamD.leute.length + 1, + `eine Kopfzeile und ${teamD.leute.length} Personen (${csvZeilen.length} Zeilen)`); +ok((csv.headers.get("content-disposition") || "").includes(`manager-ziele-${MONAT}.csv`), + "der Dateiname trägt den Monat"); + +/* ===================================================================== + 9 · DIE PIPELINE-BRUECKE + ===================================================================== */ +melde("\n=== Die Bruecke zur Scout-Pipeline ==="); +const db4 = new DatabaseSync(process.env.WORKSPACE_DB); +db4.prepare(`INSERT INTO leads (name, handle, status, scout_id, erstellt, erstellt_von, uebergeben_am) + VALUES (?,?,?,?,?,?,?)`) + .run("Mara K.", "@maratanzt", "uebergeben", IDS.scout, jetztIso, IDS.scout, `${HEUTE}T10:00:00Z`); +const leadId = db4.prepare("SELECT last_insert_rowid() AS id").get().id; +/* Ein Lead, der jemand ANDEREM gehoert -- fuer die Gegenprobe. */ +db4.prepare(`INSERT INTO leads (name, handle, status, scout_id, erstellt, erstellt_von, uebergeben_am) + VALUES (?,?,?,?,?,?,?)`) + .run("Fremd F.", "@fremd", "uebergeben", IDS.scout2, jetztIso, IDS.scout2, `${HEUTE}T10:00:00Z`); +const fremdLeadId = db4.prepare("SELECT last_insert_rowid() AS id").get().id; +db4.close(); +braucht(!!leadId && !!fremdLeadId, "die Leads liessen sich nicht anlegen"); + +const vorschlaege = await (await hol("scout", `${API}/vorschlaege`)).json(); +ok(vorschlaege.vorschlaege.some((v) => v.id === leadId), + "der eigene uebergebene Lead steht als Vorschlag da"); +ok(!vorschlaege.vorschlaege.some((v) => v.id === fremdLeadId), + "ein fremder Lead steht NICHT darin"); + +const uebernommen = await schick("scout", `${API}/eintrag`, "POST", + { aufgabe: "creator", datum: HEUTE, name: "@maratanzt", lead_id: leadId }); +ok(uebernommen.status === 201, `der Lead laesst sich uebernehmen (${uebernommen.status})`); + +const danach = await (await hol("scout", `${API}/vorschlaege`)).json(); +ok(!danach.vorschlaege.some((v) => v.id === leadId), + "…und verschwindet danach aus den Vorschlaegen"); + +/* GEGENPROBE: Eine fremde Lead-Nummer im Rumpf darf nicht uebernommen + werden -- der Eintrag entsteht, aber OHNE die Verbindung. Eine + Nummer in einem Formular ist kein Besitznachweis. */ +const mitFremd = await schick("scout", `${API}/eintrag`, "POST", + { aufgabe: "creator", datum: HEUTE, name: "@nochwer", lead_id: fremdLeadId }); +ok(mitFremd.status === 201, `der Eintrag entsteht trotzdem (${mitFremd.status})`); +const db5 = new DatabaseSync(process.env.WORKSPACE_DB, { readOnly: true }); +const ohneVerbindung = db5.prepare( + "SELECT lead_id FROM mz_eintrag WHERE name = ?").get("@nochwer"); +const mitVerbindung = db5.prepare( + "SELECT lead_id FROM mz_eintrag WHERE name = ?").get("@maratanzt"); +db5.close(); +ok(ohneVerbindung?.lead_id === null, + "…aber die fremde Lead-Verbindung wird verworfen"); +ok(mitVerbindung?.lead_id === leadId, + "die eigene Verbindung dagegen bleibt gespeichert"); + +const treffer = await (await hol("scout", `${API}/namen?q=mara`)).json(); +ok(treffer.treffer.some((t) => t.id === leadId), + "beim Tippen schlaegt die eigene Pipeline den Namen vor"); +const trefferFremd = await (await hol("scout", `${API}/namen?q=fremd`)).json(); +ok(!trefferFremd.treffer.length, + "fremde Leads werden beim Tippen NICHT vorgeschlagen"); + +/* ===================================================================== + 10 · DIE ERINNERUNGEN + ===================================================================== */ +melde("\n=== Die Erinnerungen (eingesetzte Tage) ==="); +const { ARTEN } = await import("./workspace-push.js"); +ok(ARTEN.some((a) => a.schluessel === "manager_ziele"), + "es gibt eine eigene, abschaltbare Benachrichtigungsart"); + +/* `zielRufe` bekommt das Datum als Argument -- dadurch misst diese + Prüfung dasselbe, egal an welchem Tag sie läuft. Gerechnet wird in + Berlin, deshalb 12:00 UTC: Da ist der Tag in beiden Zeitzonen + derselbe. */ +const amTag = (tag) => new Date(`${MONAT}-${String(tag).padStart(2, "0")}T12:00:00Z`); +const letzterTag = mz.letzterTagVon(MONAT); + +const rufe1 = mz.zielRufe(amTag(1)); +ok(rufe1.length > 0, `am 1. geht eine Meldung an alle mit offenen Zielen (${rufe1.length})`); +ok(rufe1.every((r) => r.merkmal.includes(`${MONAT}-01`) || r.merkmal.endsWith(":fertig")), + "das Merkmal enthaelt den TAG — dadurch kann es hoechstens eine pro Tag geben"); +ok(rufe1.some((r) => r.titel === "Neuer Monat"), "…mit dem Titel aus der Vorlage"); +ok(rufe1.every((r) => r.ziel === "/workspace/manager-ziele.html"), + "jede Meldung fuehrt direkt zur Kachel"); + +const RUF_TAGE = [ + [1, "Neuer Monat"], [10, "Kurze Erinnerung"], + [15, "Heute ist Stichtag Monatsmitte"], [16, "Monatsmitte ist vorbei"], + [letzterTag - 7, "Dringend – noch 7 Tage"], + [letzterTag - 2, "Noch 2 Tage"], [letzterTag - 1, "Noch 1 Tag"], + [letzterTag, "Letzter Tag des Monats"], +]; +let getroffen = 0; +for (const [tag, titel] of RUF_TAGE) { + const r = mz.zielRufe(amTag(tag)).filter((x) => !x.merkmal.endsWith(":fertig")); + const passt = r.some((x) => x.titel === titel); + if (passt) getroffen++; + ok(passt, `Tag ${tag}: „${titel}" (${r[0]?.titel || "nichts"})`); +} +ok(getroffen === RUF_TAGE.length, + `alle ${RUF_TAGE.length} Anlaesse der Vorlage sind belegt (${getroffen})`); + +/* GEGENPROBE: An einem Tag OHNE Anlass darf nichts herausgehen. Ohne + diese Zeile bewiese der Block darueber nur, dass immer etwas kommt. */ +const stillerTag = mz.zielRufe(amTag(7)).filter((x) => !x.merkmal.endsWith(":fertig")); +ok(stillerTag.length === 0, + `am 7. ist Ruhe (${stillerTag.length} Meldungen) — es wird nicht jeden Tag gemahnt`); + +/* UND WER ALLES HAT, BEKOMMT GENAU EINEN GLUECKWUNSCH. Dafuer wird + einer Person alles abgenommen: Ziel 0 ueberall heisst „nichts + offen". */ +const db6 = new DatabaseSync(process.env.WORKSPACE_DB); +for (const a of ["creator", "meeting", "schulung", "werbung"]) { + db6.prepare(`INSERT INTO mz_ziel (monat, rolle, aufgabe, ziel, gesetzt_am) + VALUES (?,?,?,?,?) + ON CONFLICT(monat, rolle, aufgabe) DO UPDATE SET ziel = 0`) + .run(MONAT, "manager", a, 0, jetztIso); +} +db6.close(); +const fertig = mz.zielRufe(amTag(10)).filter((r) => r.personId === IDS.manager); +ok(fertig.length === 0, + "wer gar keine Ziele hat, bekommt weder Mahnung noch Glueckwunsch"); + +/* ===================================================================== + 11 · DIE HINWEISE AUF DER STARTSEITE + ===================================================================== */ +melde("\n=== Die Zahl auf der Kachel ==="); +const hinweise = await (await hol("scout", "/workspace/api/hinweise")).json(); +const meine = (hinweise.hinweise || []).filter((h) => String(h.art).startsWith("mz_")); +ok(meine.length > 0, `der Scout bekommt einen Hinweis zu den Monatszielen (${meine.length})`); +ok(meine.every((h) => h.ziel === "manager-ziele.html"), + "…und er fuehrt auf die richtige Seite"); + +const hinweiseCreator = await (await hol("creator", "/workspace/api/hinweise")).json(); +const creatorMz = (hinweiseCreator.hinweise || []).filter((h) => String(h.art).startsWith("mz_")); +ok(creatorMz.length === 0, + `ein Creator bekommt keinen (${creatorMz.length}) — kein Weg zu einer Seite, die er nicht hat`); + +/* GEGENPROBE: Die Summe der Hinweise muss zur Zahl der offenen + Aufgaben passen. Zwei Zaehler waeren zwei Wahrheiten. */ +const standJetzt = await (await hol("scout", `${API}/stand`)).json(); +const summe = meine.reduce((s, h) => s + h.anzahl, 0); +ok(summe === standJetzt.gesamt.offen, + `die Kachelzahl (${summe}) ist dieselbe wie auf der Seite (${standJetzt.gesamt.offen})`); + +/* ===================================================================== + 12 · WER NEU DAZUKOMMT, WIRD NICHT GEMAHNT + ===================================================================== */ +melde("\n=== Wer im Laufe des Monats dazukommt ==="); +const dbNeu = new DatabaseSync(process.env.WORKSPACE_DB); +const heuteNr = Number(HEUTE.slice(8, 10)); +dbNeu.close(); + +const neuerCode = "CODE-Z99-000001"; +const dbA = new DatabaseSync(process.env.WORKSPACE_DB); +const salt = randomBytes(16).toString("hex"); +const hash = scryptSync(neuerCode, salt, 64, + { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); +dbA.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n," + + " code_kennung, aktiv, erstellt) VALUES (?,?,?,?,?,?,1,?)") + .run("Ganz Neu", "scout", hash, salt, 32768, + createHmac("sha256", kennSchluessel).update(neuerCode).digest("hex"), + `${HEUTE}T09:00:00Z`); +const neuId = dbA.prepare("SELECT last_insert_rowid() AS id").get().id; +dbA.close(); +CODES.scout3 = neuerCode; +kekse.scout3 = await anmelden("scout3", "scout"); +braucht(!!kekse.scout3, "der neue Scout konnte sich nicht anmelden"); + +const standNeu = await (await hol("scout3", `${API}/stand`)).json(); +if (heuteNr === 1) { + ok(standNeu.pflichtig === true, + "wer am 1. dazukommt, ist sofort pflichtig — er hat den vollen Monat"); +} else { + ok(standNeu.pflichtig === false, + `wer am ${heuteNr}. dazukommt, bekommt diesen Monat keine Warnungen`); + const rufeNeu = mz.zielRufe(amTag(Math.max(heuteNr, 16))) + .filter((r) => r.personId === neuId); + ok(rufeNeu.length === 0, "…und auch keine Erinnerung"); + ok(standNeu.aufgaben.every((a) => a.stufe === "neutral" || a.stufe === "aus"), + "…alle seine Zeilen stehen auf neutral, keine einzige warnt"); +} + +/* ===================================================================== + SCHLUSS + ===================================================================== */ +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +melde(`\n${geprueft} Pruefungen, ${fehler} Fehler`); +process.exit(fehler ? 1 : 0); diff --git a/server/rechte.js b/server/rechte.js index c38b8404..ca9f3240 100644 --- a/server/rechte.js +++ b/server/rechte.js @@ -185,6 +185,24 @@ export const SEITEN = { bleibt unangetastet -- es war nur niemand da, der es benutzen konnte. */ "/workspace/leistung.html": ["spicy", "admin", "manager", "scout", "creator"], + /* MANAGER-ZIELE (02.10.2026). Die vier festen Monatsaufgaben. + + VIER ROLLEN UND AUSDRUECKLICH OHNE `creator`. Filipe hat am + 02.10.2026 entschieden, dass alle vier -- Scout, Manager, + DogFather und Spicy Media -- eigene Zaehler haben; Creator haben + die Aufgaben nicht und sollen die Kachel auch nicht sehen. + + KACHEL UND TUER GEHOEREN ZUSAMMEN: Diese Zeile ist die Schranke, + die Kachel steht in workspace/assets/js/bereiche.js. Nur eines von + beiden zu setzen ergibt entweder einen Knopf, der auf die + Startseite zurueckwirft, oder einen Weg, den man nur kennen muss. + + UND SIE IST NICHT DIE EINZIGE SCHRANKE: Die Schnittstelle in + workspace-manager-ziele.js prueft die Rolle noch einmal selbst + und antwortet einem Creator mit 404. Eine Seite zu sperren und + ihre Daten offen zu lassen waere dieselbe Luecke wie am + 11.09.2026 bei content.html, nur andersherum. */ + "/workspace/manager-ziele.html": ["spicy", "admin", "manager", "scout"], /* NUR DogFather (02.09.2026). Die Rollenauswahl verspricht das seit jeher ("Manager -- dieselben Rechte, ausser der Personenverwaltung"), die Schranke hielt sich nur nicht daran. diff --git a/server/workspace-hinweise.js b/server/workspace-hinweise.js index 0949f580..fb3f875a 100644 --- a/server/workspace-hinweise.js +++ b/server/workspace-hinweise.js @@ -33,6 +33,7 @@ import { sichtbar as sichtbarTermine } from "./workspace-kalender.js"; import { sichtbar as sichtbarDateien } from "./workspace-dateien.js"; import { sichtbar as sichtbarEintraege } from "./workspace-bereiche.js"; import { stufenFuer } from "./workspace-checkliste.js"; +import { offeneHinweise as zielHinweise } from "./workspace-manager-ziele.js"; export const hinweisRouter = express.Router(); @@ -414,6 +415,21 @@ hinweisRouter.get("/workspace/api/hinweise", (req, res) => { } } + /* ---------- Manager-Ziele (02.10.2026) ---------- + + KOMMT FERTIG AUS DEM FACHMODUL und wird hier nicht nachgerechnet. + Das ist Regel 3 dieser Datei ("die Regeln werden aus den + Fachmodulen importiert, nicht abgeschrieben") und zugleich der + Grund, warum die Zahl auf der Kachel und die Zahl auf der Seite + nie auseinandergehen koennen: Es ist dieselbe Rechnung. + + `dazu` filtert danach weiter -- Adresse und Rolle. Fuer einen + Creator liefert das Modul ohnehin nichts; faende sich hier + spaeter doch ein Weg, haelt ihn die Rechtetafel auf. */ + for (const h of zielHinweise(person)) { + dazu(h.art, h.stufe, h.text, h.ziel, h.anzahl); + } + hinweise.sort((x, y) => (RANG[x.stufe] - RANG[y.stufe]) || (y.anzahl - x.anzahl)); res.json({ hinweise, stand: new Date().toISOString() }); } catch (fehler) { diff --git a/server/workspace-manager-ziele.js b/server/workspace-manager-ziele.js new file mode 100644 index 00000000..d90520f9 --- /dev/null +++ b/server/workspace-manager-ziele.js @@ -0,0 +1,1153 @@ +/* ===================================================================== + MANAGER-ZIELE — vier feste Monatsaufgaben (02.10.2026) + ===================================================================== + + Vorlage: „Prompt · Kachel Agentur-Aufgaben". Vier Pflichten je Monat, + Fortschritt, Ampel, Warnungen, Monatsschnitt, Team-Übersicht. + + --------------------------------------------------------------------- + WAS HIER ANDERS IST ALS IN DER VORLAGE — und warum + + 1. DIE KACHEL HEISST „Manager-Ziele", nicht „Agentur-Aufgaben". + Es gibt im Haus bereits eine Kachel „Agentur" und eine Kachel + „Aufgaben". Eine dritte mit beiden Wörtern im Namen wäre auf + einem Handy nicht mehr auseinanderzuhalten. Entschieden von + Filipe am 02.10.2026. + + 2. DIE ZIELZAHLEN GELTEN JE ROLLE und sind von DogFather UND Spicy + Media änderbar („dogfather und spicy rolle sollen aber + gleichzeitig es immer ändern können für alle"). Ein Scout muss + nicht dieselbe Zahl schaffen wie DogFather. + + 3. DIE PIPELINE IST ANGEBUNDEN, in beide Richtungen. Die Vorlage + kennt nur die Handeingabe. Das Haus hat aber ein vollständiges + Scout-CRM (`leads`), und zwei Listen derselben Rekrutierungen + laufen auseinander. Also: Vorschläge aus der Pipeline, Übernahme + mit einem Klick, Verbindung bleibt gespeichert — aber NICHTS + zählt von selbst. Gezählt wird nur, was ein Mensch bestätigt hat. + + --------------------------------------------------------------------- + KEIN ZWEITER ZÄHLER FÜR DIE KACHELWAND + + Die Zahl auf einer Kachel kommt im Haus immer aus den Hinweisen + (`workspace-hinweise.js`), nie aus einer eigenen Rechnung. Dieses + Modul liefert dafür `offeneHinweise()` und rechnet nicht ein + zweites Mal — sonst stünden irgendwann zwei verschiedene Wahrheiten + übereinander auf einem Bildschirm. + + --------------------------------------------------------------------- + KEIN IMPORT AUS workspace-push.js + + Die Erinnerungen entstehen hier als LISTE (`zielRufe`), verschickt + werden sie im Takt von workspace-push.js. Andersherum wäre es ein + Importkreis: push müsste dieses Modul kennen und dieses Modul push. + Über einen Kreis kommen Konstanten als `undefined` an, ohne dass + irgendwo ein Fehler erscheint. + ===================================================================== */ + +import express from "express"; +import { + db, sitzungLesen, protokolliere, echteIp, siehtAlles, ROLLEN_NAME, +} from "./workspace.js"; +import { + AUFGABEN, AUFGABEN_SCHLUESSEL, PFLICHT_ROLLEN, ERSTER_MONAT, + FREIGABE_SEKUNDEN, +} from "./manager-ziele-tabellen.js"; + +export const managerZieleRouter = express.Router(); + +const BASIS = "/workspace/api/manager-ziele"; +const jetzt = () => new Date().toISOString(); + +const NAME_MAX = 120; +const LINK_MAX = 500; +const NOTIZ_MAX = 1000; + +const AUFGABE_VON = new Map(AUFGABEN.map((a) => [a.schluessel, a])); + +/* ===================================================================== + ZEIT — immer Europa/Berlin, nie die Uhr des Servers + ===================================================================== + + Die Vorlage nennt Europe/Luxembourg. Luxemburg und Berlin haben + dieselbe Zeit und dieselben Umstellungstage (beide MEZ/MESZ nach + EU-Regel), es gibt also keinen Unterschied im Verhalten — und das + ganze übrige Haus rechnet in Europe/Berlin. Zwei Zeitrechnungen in + einem Haus wären die Stelle, an der es später auseinanderläuft. + + `getMonth()` oder `new Date().getDate()` kommen hier NICHT vor: Sie + lesen die Zeitzone des Servers, und die ist auf einem gemieteten + Rechner nicht verlässlich deutsch. Ein Monatswechsel, der zwei + Stunden zu früh passiert, verschiebt jede Zählung. + ===================================================================== */ +const TAG_BERLIN = new Intl.DateTimeFormat("sv-SE", { + timeZone: "Europe/Berlin", year: "numeric", month: "2-digit", day: "2-digit", +}); + +/** Der heutige Tag in Berlin als 'JJJJ-MM-TT'. */ +export function tagBerlin(d = new Date()) { + return TAG_BERLIN.format(d); +} + +/** Der laufende Monat als 'JJJJ-MM'. */ +export function monatJetzt(d = new Date()) { + return tagBerlin(d).slice(0, 7); +} + +/** Wie viele Tage dieser Monat hat. `Date.UTC(j, m, 0)` ist der letzte + * Tag des Vormonats von m+1 — also der letzte des gesuchten. */ +export function letzterTagVon(monat) { + const [j, m] = String(monat).split("-").map(Number); + if (!j || !m) return 30; + return new Date(Date.UTC(j, m, 0)).getUTCDate(); +} + +/** Monat um `n` verschieben. Negativ heißt zurück. */ +export function monatPlus(monat, n) { + const [j, m] = String(monat).split("-").map(Number); + const d = new Date(Date.UTC(j, (m - 1) + n, 1)); + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`; +} + +const istMonat = (w) => /^\d{4}-(0[1-9]|1[0-2])$/.test(String(w || "")); +const istDatum = (w) => /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/.test(String(w || "")); + +/* ===================================================================== + DIE AMPEL + ===================================================================== */ + +/** Die Stufe einer einzelnen Aufgabe. + * + * Reihenfolge der Prüfungen ist die Dringlichkeit: erreicht schlägt + * alles, danach gewinnt Rot vor Orange. Stünde die 16er-Regel zuerst, + * bliebe eine Aufgabe am 28. orange — die schwächere von zwei + * zutreffenden Warnungen. + * + * `ziel = 0` heißt „für diese Rolle nicht gefordert". Das ist kein + * Sonderfall, sondern eine gültige Einstellung: DogFather kann für + * sich selbst z. B. 0 Creator setzen. Eine solche Zeile warnt nie und + * zählt nicht in den Gesamtfortschritt. */ +export function stufeFuer(zahl, ziel, tag, letzter) { + if (!(ziel > 0)) return "aus"; + if (zahl >= ziel) return "gruen"; + const bisEnde = letzter - tag; + if (bisEnde <= 7) return "rot"; + if (tag >= 16) return "orange"; + return "neutral"; +} + +/** Der Satz zur Stufe. FARBE NIE ALLEIN — Hausregel: zu jeder Farbe + * gehört ein Text und ein Zeichen, sonst ist sie für einen Teil der + * Menschen gar nicht da. */ +export function stufenText(stufe, bisEnde) { + if (stufe === "gruen") return "Ziel erreicht"; + if (stufe === "orange") return "Sollte bis Monatsmitte erledigt sein"; + if (stufe === "rot") { + if (bisEnde <= 0) return "Dringend – heute ist der letzte Tag"; + if (bisEnde === 1) return "Dringend – noch 1 Tag"; + return `Dringend – noch ${bisEnde} Tage`; + } + if (stufe === "aus") return "Für deine Rolle nicht gefordert"; + return ""; +} + +const RANG = { rot: 0, orange: 1, neutral: 2, gruen: 3, aus: 4 }; +/** Die schlechteste Stufe einer Liste — das ist die Farbe der Kachel. */ +export function schlechteste(stufen) { + let beste = "aus"; + for (const s of stufen) if (RANG[s] < RANG[beste]) beste = s; + return beste; +} + +/* ===================================================================== + DER LAUFENDE MONAT UND DIE EINGEFRORENEN ZIELE + ===================================================================== */ + +/** Sagt der Datenbank, welcher Monat läuft — die Trigger lesen das. + * + * Wird vor jedem schreibenden Zugriff gesetzt und nicht einmal beim + * Start: Ein Server, der über den Monatswechsel durchläuft, hätte + * sonst einen Monat lang den falschen Wert stehen, und die Sperre + * griffe am 1. für den gerade begonnenen Monat. */ +function lageSetzen(monat = monatJetzt()) { + db().prepare("UPDATE mz_lage SET monat = ? WHERE eins = 1").run(monat); + return monat; +} + +/** Das geltende Ziel für (Monat, Rolle, Aufgabe), ohne zu schreiben. + * + * Gesucht wird die jüngste Festlegung, die NICHT NACH diesem Monat + * liegt. Dadurch gilt eine Änderung ab dem Monat, für den sie + * eingetragen wurde, und kein Monat davor ändert sich. Findet sich + * gar nichts, gilt die Vorgabe aus der Vorlage (3/2/2/1). */ +function zielGeltend(monat, rolle, aufgabe) { + const z = db().prepare( + `SELECT ziel FROM mz_ziel + WHERE rolle = ? AND aufgabe = ? AND monat <= ? + ORDER BY monat DESC LIMIT 1`).get(rolle, aufgabe, monat); + if (z) return z.ziel; + return AUFGABE_VON.get(aufgabe)?.vorgabe ?? 0; +} + +/** Schreibt die Ziele eines Monats fest, falls noch nicht geschehen. + * + * DAS IST DER KERN DER REGEL „Änderungen gelten ab dem nächsten + * Monat": Sobald ein Monat einmal angefasst wurde, stehen seine + * Zahlen. Eine spätere Änderung trägt sich für einen SPÄTEREN Monat + * ein und kann diesen hier nicht mehr erreichen. + * + * `INSERT OR IGNORE` und nicht `REPLACE` — genau deshalb. */ +function monatSichern(monat) { + if (!istMonat(monat) || monat < ERSTER_MONAT) return; + const d = db(); + const schon = d.prepare("SELECT COUNT(*) n FROM mz_ziel WHERE monat = ?").get(monat)?.n ?? 0; + if (schon >= PFLICHT_ROLLEN.length * AUFGABEN.length) return; + const einfuegen = d.prepare( + `INSERT OR IGNORE INTO mz_ziel (monat, rolle, aufgabe, ziel, gesetzt_von, gesetzt_am) + VALUES (?,?,?,?,NULL,?)`); + const am = jetzt(); + for (const rolle of PFLICHT_ROLLEN) { + for (const a of AUFGABEN) { + einfuegen.run(monat, rolle, a.schluessel, zielGeltend(monat, rolle, a.schluessel), am); + } + } +} + +/** Das Ziel, wie es für diesen Monat FESTSTEHT. */ +function zielFuer(monat, rolle, aufgabe) { + const z = db().prepare( + "SELECT ziel FROM mz_ziel WHERE monat = ? AND rolle = ? AND aufgabe = ?") + .get(monat, rolle, aufgabe); + return z ? z.ziel : zielGeltend(monat, rolle, aufgabe); +} + +/* ===================================================================== + AB WANN JEMAND PFLICHTIG IST + ===================================================================== + + Vorlage: „Wer erst im Laufe des Monats eine der Rollen bekommt, + erhält in diesem Monat keine Warnungen; die Pflicht beginnt im + Folgemonat." + + „IM LAUFE DES MONATS" HEISST AB DEM 2. Wer am 1. dazukommt, hat den + vollen Monat vor sich; ihm die Pflicht zu erlassen wäre ein + geschenkter Monat ohne Grund. Diese eine Zeile ist der ganze + Unterschied, und sie steht hier, damit man sie findet. + + GEMESSEN WIRD EINMAL. Der Rollenwechsel steht im Protokoll, und ein + Protokoll ist ein Log — es darf gekürzt werden. Würde die Pflicht + bei jeder Abfrage daraus gerechnet, verschöbe ein Aufräumlauf + rückwirkend, seit wann jemand verantwortlich ist. + ===================================================================== */ +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 p = d.prepare("SELECT erstellt FROM personen WHERE id = ?").get(personId); + if (!p) return 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); + + const quelle = wechsel?.zeitpunkt || p.erstellt; + let grund = wechsel ? "rollenwechsel" : "angelegt"; + let monat = ERSTER_MONAT; + try { + const tag = tagBerlin(new Date(quelle)); + monat = tag.slice(8) === "01" ? tag.slice(0, 7) : monatPlus(tag.slice(0, 7), 1); + } catch { + grund = "unlesbar"; + } + 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); + return monat; +} + +/** Gilt die Pflicht für diese Person in diesem Monat schon? */ +function pflichtigIn(person, monat) { + if (!person || !PFLICHT_ROLLEN.includes(person.rolle)) return false; + if (monat < ERSTER_MONAT) return false; + return startMonatFuer(person.id) <= monat; +} + +/* ===================================================================== + DER STAND + ===================================================================== */ + +/** Zählt die Einträge einer Person je Aufgabe in einem Monat. */ +function zaehlen(personId, monat) { + const zeilen = db().prepare( + "SELECT aufgabe, COUNT(*) n FROM mz_eintrag WHERE person_id = ? AND monat = ? GROUP BY aufgabe") + .all(personId, monat); + const raus = Object.create(null); + for (const s of AUFGABEN_SCHLUESSEL) raus[s] = 0; + for (const z of zeilen) raus[z.aufgabe] = z.n; + return raus; +} + +/** Der vollständige Stand einer Person in einem Monat. + * + * EINE Rechnung für alles: Kachel, Seite, Erinnerung, Verlauf und + * Team-Übersicht fragen diese Funktion. Vier eigene Rechnungen wären + * vier Gelegenheiten, dass die Zahlen auseinandergehen. */ +export function standFuer(person, monat = monatJetzt(), heute = tagBerlin()) { + const letzter = letzterTagVon(monat); + /* Für einen vergangenen Monat ist der „heutige" Tag der letzte: + Sonst stünde ein abgeschlossener Monat auf „neutral, noch Zeit". */ + const imMonat = heute.slice(0, 7) === monat; + const tag = imMonat ? Number(heute.slice(8, 10)) : (monat < heute.slice(0, 7) ? letzter : 1); + const bisEnde = letzter - tag; + + const zahl = zaehlen(person.id, monat); + const pflichtig = pflichtigIn(person, monat); + + const aufgaben = AUFGABEN.map((a) => { + const ziel = zielFuer(monat, person.rolle, a.schluessel); + const n = zahl[a.schluessel] || 0; + /* OHNE PFLICHT KEINE WARNUNG — aber die Zeile bleibt sichtbar und + zählbar. Wer im ersten Monat schon etwas einträgt, soll es + sehen; er soll nur nicht dafür gemahnt werden. */ + const stufe = pflichtig ? stufeFuer(n, ziel, tag, letzter) + : (ziel > 0 && n >= ziel ? "gruen" : "neutral"); + return { + schluessel: a.schluessel, + name: a.name, + kurz: a.kurz, + hinweis: a.hinweis, + feld: a.feld, + arten: a.arten || null, + einheit: a.einheit, + zahl: n, + ziel, + fehlt: Math.max(0, ziel - n), + stufe, + text: stufenText(stufe, bisEnde), + }; + }); + + const zielSumme = aufgaben.reduce((s, a) => s + a.ziel, 0); + const erledigt = aufgaben.reduce((s, a) => s + Math.min(a.zahl, a.ziel), 0); + const offene = aufgaben.filter((a) => a.ziel > 0 && a.zahl < a.ziel); + + return { + monat, heute, tag, letzter, bisEnde, pflichtig, + aufgaben, + gesamt: { + erledigt, ziel: zielSumme, + anteil: zielSumme ? Math.round((erledigt / zielSumme) * 100) : 100, + offen: offene.length, + }, + stufe: schlechteste(aufgaben.map((a) => a.stufe)), + }; +} + +/* ===================================================================== + DIE SCHRANKE + ===================================================================== */ + +function angemeldet(req, res, next) { + const person = sitzungLesen(req); + if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); + + /* ZWEI GETRENNTE HÄUSER (Regel vom 24.09.2026). Auf der Adresse von + Team Dogi gibt es diese Kachel nicht — auch nicht für DogFather, + der in beiden Häusern arbeitet. 404 und nicht 403: Was es hier + nicht gibt, soll man auch nicht erahnen können. */ + if (person.haus === "crew") return res.status(404).json({ fehler: "nicht_hier" }); + + /* Creator haben die Kachel nicht. 404 aus demselben Grund wie im + Scout-CRM: Sie sollen nicht einmal erfahren, dass es diesen + Bereich gibt. Die Prüfung ist SERVERSEITIG — die Rechtetafel + sperrt die Seite, diese Zeile die Schnittstelle. Nur eines von + beiden wäre eine Tür, die man bloß kennen muss. */ + if (!PFLICHT_ROLLEN.includes(person.rolle)) { + return res.status(404).json({ fehler: "nicht_zustaendig" }); + } + + req.person = person; + lageSetzen(); + monatSichern(monatJetzt()); + next(); +} + +managerZieleRouter.use(BASIS, angemeldet); + +function nurLeitung(req, res, next) { + if (!siehtAlles(req.person)) return res.status(403).json({ fehler: "nicht_erlaubt" }); + next(); +} + +/* ===================================================================== + PRÜFUNGEN AN EINEM EINTRAG + ===================================================================== */ + +const sauber = (w, max) => { + const t = String(w ?? "").trim().replace(/\s+/g, " "); + return t.slice(0, max); +}; + +/** Der Name ohne Zierrat — für den Dublettenschutz. + * „@LunaTanzt", „luna tanzt" und „LUNATANZT" sind derselbe Mensch. */ +const nameSchluessel = (w) => + String(w || "").toLowerCase().replace(/^@+/, "").replace(/[\s._-]+/g, ""); + +/** Der Link ohne Zierrat. Groß/klein, abschließender Schrägstrich und + * Verfolgungsparameter (`?is_from_webapp=…`) machen aus demselben + * Video sonst zwei. */ +function linkSchluessel(w) { + try { + const u = new URL(String(w)); + return (u.host.replace(/^www\./, "") + u.pathname.replace(/\/+$/, "")).toLowerCase(); + } catch { + return String(w || "").trim().toLowerCase(); + } +} + +/** Ist das eine brauchbare Internetadresse? + * + * Filipe am 02.10.2026: jede gültige Internetadresse, nicht nur + * TikTok. Geprüft wird deshalb nur, dass es wirklich eine Adresse mit + * http(s) und einem Punkt im Namen ist — „tiktok" allein oder + * „javascript:…" sind keine. */ +function linkOk(w) { + try { + const u = new URL(String(w).trim()); + if (u.protocol !== "http:" && u.protocol !== "https:") return false; + return u.hostname.includes(".") && u.hostname.length >= 4; + } catch { + return false; + } +} + +/** Prüft und normalisiert, was aus dem Formular kommt. + * Gibt `{ fehler }` oder `{ werte }` zurück — nie beides. */ +function eintragPruefen(rumpf, monat, heute) { + const aufgabe = String(rumpf?.aufgabe || ""); + const a = AUFGABE_VON.get(aufgabe); + if (!a) return { fehler: "aufgabe_unbekannt" }; + + const datum = String(rumpf?.datum || "").trim(); + if (!istDatum(datum)) return { fehler: "datum_fehlt" }; + /* KEIN DATUM IN DER ZUKUNFT und keines außerhalb des Monats + (Vorlage, Punkt 09). Beide Prüfungen gegen BERLINER Tage, nicht + gegen die Uhr des Servers. */ + if (datum > heute) return { fehler: "datum_zukunft" }; + if (datum.slice(0, 7) !== monat) return { fehler: "datum_fremder_monat" }; + + const werte = { + aufgabe, datum, + name: null, name_klein: null, + link: null, link_klein: null, + art: null, + notiz: sauber(rumpf?.notiz, NOTIZ_MAX) || null, + lead_id: null, + }; + + if (a.feld === "name") { + const name = sauber(rumpf?.name, NAME_MAX); + if (name.length < 2) return { fehler: "name_fehlt" }; + werte.name = name; + werte.name_klein = nameSchluessel(name); + if (!werte.name_klein) return { fehler: "name_fehlt" }; + } + + if (a.feld === "link") { + const link = String(rumpf?.link || "").trim().slice(0, LINK_MAX); + if (!linkOk(link)) return { fehler: "link_ungueltig" }; + werte.link = link; + werte.link_klein = linkSchluessel(link); + } + + if (a.arten) { + const art = String(rumpf?.art || ""); + if (!a.arten.some((x) => x.wert === art)) return { fehler: "art_fehlt" }; + werte.art = art; + } + + const lead = Number(rumpf?.lead_id); + if (Number.isInteger(lead) && lead > 0) werte.lead_id = lead; + + return { werte }; +} + +/** Sucht Dubletten im selben Monat. + * + * ZWEI VERSCHIEDENE ANTWORTEN, und der Unterschied ist wichtig: + * + * EIGENE Dublette -> wird ABGELEHNT. Denselben Creator zweimal + * selbst einzutragen ist doppelt gezählt, und + * eine Zahl, die man sich selbst verdoppeln + * kann, ist keine Zahl. + * FREMDE Dublette -> wird ANGENOMMEN und vermerkt. Zwei Menschen + * können denselben Creator angesprochen haben; + * das ist eine Sache zum Klären, kein Grund, + * jemandem die Eingabe zu verweigern. DogFather + * sieht es in der Übersicht. */ +function dublette(personId, monat, werte, ausser = 0) { + const spalte = werte.name_klein ? "name_klein" : (werte.link_klein ? "link_klein" : null); + if (!spalte) return { eigen: false, fremd: null }; + const wert = werte.name_klein || werte.link_klein; + + const eigen = db().prepare( + `SELECT id FROM mz_eintrag + WHERE person_id = ? AND monat = ? AND aufgabe = ? AND ${spalte} = ? AND id <> ?`) + .get(personId, monat, werte.aufgabe, wert, ausser); + if (eigen) return { eigen: true, fremd: null }; + + const fremd = db().prepare( + `SELECT e.person_id, p.name FROM mz_eintrag e + LEFT JOIN personen p ON p.id = e.person_id + WHERE e.person_id <> ? AND e.monat = ? AND e.aufgabe = ? AND e.${spalte} = ? + LIMIT 1`).get(personId, monat, werte.aufgabe, wert); + return { eigen: false, fremd: fremd || null }; +} + +/* ===================================================================== + DIE FREIGABE FÜR ABGESCHLOSSENE MONATE + ===================================================================== + + Nur DogFather, nur für eine einzelne Handlung, nur zwei Minuten. + Die Vorlage sagt „Vergangene Monate sind gesperrt und nicht mehr + bearbeitbar (Ausnahme: DogFather)" — und der Trigger in der + Datenbank kann keine Sitzung lesen. Also wird die Ausnahme ein + sichtbarer Vorgang: Schloss auf, Handlung, Schloss zu, protokolliert. + + `finally` und nicht am Ende des Blocks: Wirft die Handlung, bliebe + das Schloss sonst offen. Eine Sicherung, die bei einem Fehler + aufgeht, ist keine. */ +function mitFreigabe(fn) { + const d = db(); + const bis = new Date(Date.now() + FREIGABE_SEKUNDEN * 1000).toISOString() + .replace("T", " ").slice(0, 19); + d.prepare("UPDATE mz_freigabe SET offen = 1, bis = ? WHERE eins = 1").run(bis); + try { + return fn(); + } finally { + d.prepare("UPDATE mz_freigabe SET offen = 0, bis = NULL WHERE eins = 1").run(); + } +} + +/* ===================================================================== + DIE WEGE + ===================================================================== */ + +/** Der eigene Stand (oder der einer anderen Person, für die Leitung). */ +managerZieleRouter.get(`${BASIS}/stand`, (req, res) => { + try { + const monat = istMonat(req.query.monat) ? req.query.monat : monatJetzt(); + if (monat < ERSTER_MONAT) { + return res.json({ vorErstemMonat: true, erster: ERSTER_MONAT, monat }); + } + monatSichern(monat); + + let person = req.person; + if (req.query.person && siehtAlles(req.person)) { + const p = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?") + .get(Number(req.query.person)); + if (!p) return res.status(404).json({ fehler: "unbekannt" }); + person = p; + } + + const stand = standFuer(person, monat); + res.json({ + ...stand, + person: { id: person.id, name: person.name, rolle: person.rolle, + rollenName: ROLLEN_NAME[person.rolle] || person.rolle }, + eigen: person.id === req.person.id, + darfZiele: siehtAlles(req.person), + darfKorrigieren: req.person.rolle === "admin", + erster: ERSTER_MONAT, + jetztMonat: monatJetzt(), + }); + } catch (fehler) { + console.error("[manager-ziele] Stand:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Die Einträge einer Aufgabe in einem Monat. */ +managerZieleRouter.get(`${BASIS}/eintraege`, (req, res) => { + try { + const monat = istMonat(req.query.monat) ? req.query.monat : monatJetzt(); + const aufgabe = String(req.query.aufgabe || ""); + if (!AUFGABE_VON.has(aufgabe)) return res.status(400).json({ fehler: "aufgabe_unbekannt" }); + + let personId = req.person.id; + if (req.query.person && siehtAlles(req.person)) personId = Number(req.query.person); + + const zeilen = db().prepare( + `SELECT e.id, e.datum, e.name, e.link, e.art, e.notiz, e.lead_id, + e.erstellt, e.geaendert, e.doppelt_bei, + p.name AS doppelt_name, l.status AS lead_status, l.name AS lead_name + FROM mz_eintrag e + LEFT JOIN personen p ON p.id = e.doppelt_bei + LEFT JOIN leads l ON l.id = e.lead_id + WHERE e.person_id = ? AND e.monat = ? AND e.aufgabe = ? + ORDER BY e.datum DESC, e.id DESC`).all(personId, monat, aufgabe); + + 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", + }); + } catch (fehler) { + console.error("[manager-ziele] Eintraege:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Einen Eintrag anlegen. */ +managerZieleRouter.post(`${BASIS}/eintrag`, (req, res) => { + try { + const heute = tagBerlin(); + const monat = monatJetzt(); + monatSichern(monat); + + const gepruefte = eintragPruefen(req.body, monat, heute); + if (gepruefte.fehler) return res.status(400).json({ fehler: gepruefte.fehler }); + const w = gepruefte.werte; + + const doppel = dublette(req.person.id, monat, w); + if (doppel.eigen) { + return res.status(409).json({ + fehler: "schon_eingetragen", + text: w.name + ? `„${w.name}" steht diesen Monat schon in deiner Liste.` + : "Dieses Video steht diesen Monat schon in deiner Liste.", + }); + } + + /* Der Lead wird nur übernommen, wenn er wirklich existiert und zu + dieser Person gehört. Eine Nummer in der Adresse ist kein + Besitznachweis — dieselbe Lücke, die am 03.09.2026 bei + `darfCreator` steckte. */ + if (w.lead_id) { + const l = db().prepare("SELECT id, scout_id, erstellt_von FROM leads WHERE id = ?") + .get(w.lead_id); + const meiner = l && (l.scout_id === req.person.id || l.erstellt_von === req.person.id); + if (!meiner && !siehtAlles(req.person)) w.lead_id = null; + else if (!l) w.lead_id = null; + } + + 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, + w.link, w.link_klein, w.art, w.notiz, w.lead_id, + doppel.fremd?.person_id ?? null, jetzt()); + + res.status(201).json({ + id: Number(info.lastInsertRowid), + stand: standFuer(req.person, monat), + warnung: doppel.fremd + ? { art: "auch_bei", name: doppel.fremd.name, + text: `Hinweis: ${doppel.fremd.name || "jemand anderes"} hat das diesen Monat auch eingetragen.` } + : null, + }); + } catch (fehler) { + if (String(fehler?.message || "").includes("monat_gesperrt")) { + return res.status(409).json({ fehler: "monat_gesperrt" }); + } + console.error("[manager-ziele] Anlegen:", fehler?.message); + res.status(503).json({ fehler: "nicht_gespeichert" }); + } +}); + +/** Einen Eintrag ändern. */ +managerZieleRouter.patch(`${BASIS}/eintrag/:id`, (req, res) => { + try { + const id = Number(req.params.id); + const alt = db().prepare("SELECT * FROM mz_eintrag WHERE id = ?").get(id); + if (!alt) return res.status(404).json({ fehler: "unbekannt" }); + + const fremd = alt.person_id !== req.person.id; + const istAdmin = req.person.rolle === "admin"; + if (fremd && !istAdmin) return res.status(403).json({ fehler: "nicht_deins" }); + + const heute = tagBerlin(); + const gepruefte = eintragPruefen({ ...req.body, aufgabe: alt.aufgabe }, alt.monat, heute); + if (gepruefte.fehler) return res.status(400).json({ fehler: gepruefte.fehler }); + const w = gepruefte.werte; + + const doppel = dublette(alt.person_id, alt.monat, w, id); + if (doppel.eigen) return res.status(409).json({ fehler: "schon_eingetragen" }); + + const schreiben = () => db().prepare( + `UPDATE mz_eintrag + SET datum = ?, name = ?, name_klein = ?, link = ?, link_klein = ?, + art = ?, notiz = ?, geaendert = ?, geaendert_von = ? + WHERE id = ?`).run( + w.datum, w.name, w.name_klein, w.link, w.link_klein, w.art, w.notiz, + jetzt(), req.person.id, id); + + const altMonat = alt.monat !== monatJetzt(); + if (altMonat && !istAdmin) return res.status(409).json({ fehler: "monat_gesperrt" }); + if (altMonat) mitFreigabe(schreiben); else schreiben(); + + if (fremd || altMonat) { + protokolliere("manager_ziele_korrigiert", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `Eintrag ${id} (${alt.aufgabe}, ${alt.monat}) von Person ${alt.person_id}`, + }); + } + + const wem = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?") + .get(alt.person_id) || req.person; + res.json({ ok: true, stand: standFuer(wem, alt.monat) }); + } catch (fehler) { + if (String(fehler?.message || "").includes("monat_gesperrt")) { + return res.status(409).json({ fehler: "monat_gesperrt" }); + } + console.error("[manager-ziele] Aendern:", fehler?.message); + res.status(503).json({ fehler: "nicht_gespeichert" }); + } +}); + +/** Einen Eintrag löschen. */ +managerZieleRouter.delete(`${BASIS}/eintrag/:id`, (req, res) => { + try { + const id = Number(req.params.id); + const alt = db().prepare("SELECT * FROM mz_eintrag WHERE id = ?").get(id); + if (!alt) return res.status(404).json({ fehler: "unbekannt" }); + + const fremd = alt.person_id !== req.person.id; + const istAdmin = req.person.rolle === "admin"; + if (fremd && !istAdmin) return res.status(403).json({ fehler: "nicht_deins" }); + + const altMonat = alt.monat !== monatJetzt(); + if (altMonat && !istAdmin) return res.status(409).json({ fehler: "monat_gesperrt" }); + + const loeschen = () => db().prepare("DELETE FROM mz_eintrag WHERE id = ?").run(id); + if (altMonat) mitFreigabe(loeschen); else loeschen(); + + if (fremd || altMonat) { + protokolliere("manager_ziele_geloescht", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `Eintrag ${id} (${alt.aufgabe}, ${alt.monat}) von Person ${alt.person_id}`, + }); + } + + const wem = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?") + .get(alt.person_id) || req.person; + res.json({ ok: true, stand: standFuer(wem, alt.monat) }); + } catch (fehler) { + if (String(fehler?.message || "").includes("monat_gesperrt")) { + return res.status(409).json({ fehler: "monat_gesperrt" }); + } + console.error("[manager-ziele] Loeschen:", fehler?.message); + res.status(503).json({ fehler: "nicht_geloescht" }); + } +}); + +/* ===================================================================== + DIE BRÜCKE ZUR SCOUT-PIPELINE + ===================================================================== */ + +/** Übergebene Leads dieses Monats, die noch nicht eingetragen sind. + * + * KEIN AUTOMATISMUS. Die Liste schlägt vor, sie zählt nicht. Wer + * einen Lead übernimmt, bestätigt damit die Rekrutierung — und erst + * dann steht sie in der Zahl. Ein Zähler, der sich von selbst füllt, + * ist ein Zähler, dem niemand mehr glaubt. */ +managerZieleRouter.get(`${BASIS}/vorschlaege`, (req, res) => { + try { + const monat = monatJetzt(); + const zeilen = db().prepare( + `SELECT l.id, l.name, l.handle, l.plattform, l.uebergeben_am + FROM leads l + WHERE l.status = 'uebergeben' + AND l.uebergeben_am IS NOT NULL + AND substr(l.uebergeben_am, 1, 7) = ? + AND (l.scout_id = ? OR l.erstellt_von = ?) + AND NOT EXISTS (SELECT 1 FROM mz_eintrag e + WHERE e.lead_id = l.id AND e.monat = ?) + ORDER BY l.uebergeben_am DESC + LIMIT 25`).all(monat, req.person.id, req.person.id, monat); + res.json({ monat, vorschlaege: zeilen }); + } catch (fehler) { + console.error("[manager-ziele] Vorschlaege:", fehler?.message); + res.json({ monat: monatJetzt(), vorschlaege: [] }); + } +}); + +/** Namensvorschläge beim Tippen — aus der eigenen Pipeline. */ +managerZieleRouter.get(`${BASIS}/namen`, (req, res) => { + try { + const q = nameSchluessel(req.query.q); + if (q.length < 2) return res.json({ treffer: [] }); + const zeilen = db().prepare( + `SELECT id, name, handle, status FROM leads + WHERE (scout_id = ? OR erstellt_von = ?) + AND (replace(replace(lower(name), ' ', ''), '@', '') LIKE ? + OR replace(replace(lower(COALESCE(handle,'')), ' ', ''), '@', '') LIKE ?) + ORDER BY erstellt DESC LIMIT 8`) + .all(req.person.id, req.person.id, `%${q}%`, `%${q}%`); + res.json({ treffer: zeilen }); + } catch (fehler) { + console.error("[manager-ziele] Namen:", fehler?.message); + res.json({ treffer: [] }); + } +}); + +/* ===================================================================== + DER VERLAUF + ===================================================================== */ + +managerZieleRouter.get(`${BASIS}/verlauf`, (req, res) => { + try { + let person = req.person; + if (req.query.person && siehtAlles(req.person)) { + const p = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?") + .get(Number(req.query.person)); + if (!p) return res.status(404).json({ fehler: "unbekannt" }); + person = p; + } + + const jetztM = monatJetzt(); + const start = startMonatFuer(person.id); + const von = start > ERSTER_MONAT ? start : ERSTER_MONAT; + + const monate = []; + let m = monatPlus(jetztM, -1); + /* Höchstens 24 Monate zurück — ein Verlauf, der endlos scrollt, + wird nicht gelesen. Die Daten bleiben trotzdem alle da. */ + for (let i = 0; i < 24 && m >= von; i++, m = monatPlus(m, -1)) { + const s = standFuer(person, m); + monate.push({ + monat: m, + gesamt: s.gesamt, + aufgaben: s.aufgaben.map((a) => ({ + schluessel: a.schluessel, kurz: a.kurz, + zahl: a.zahl, ziel: a.ziel, + erreicht: a.ziel === 0 ? null : a.zahl >= a.ziel, + })), + }); + } + res.json({ monate, von, person: { id: person.id, name: person.name } }); + } catch (fehler) { + console.error("[manager-ziele] Verlauf:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ===================================================================== + DIE TEAM-ÜBERSICHT — nur DogFather und Spicy Media + ===================================================================== */ + +/** 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); + + return leute.map((p) => { + const s = standFuer(p, monat); + return { + id: p.id, name: p.name, rolle: p.rolle, + rollenName: ROLLEN_NAME[p.rolle] || p.rolle, + pflichtig: s.pflichtig, + stufe: s.stufe, + gesamt: s.gesamt, + aufgaben: s.aufgaben.map((a) => ({ + schluessel: a.schluessel, zahl: a.zahl, ziel: a.ziel, stufe: a.stufe, + })), + }; + }); +} + +managerZieleRouter.get(`${BASIS}/team`, nurLeitung, (req, res) => { + try { + const monat = istMonat(req.query.monat) ? req.query.monat : monatJetzt(); + if (monat < ERSTER_MONAT) return res.json({ monat, leute: [], erster: ERSTER_MONAT }); + monatSichern(monat); + res.json({ + monat, erster: ERSTER_MONAT, jetztMonat: monatJetzt(), + leute: teamStand(monat), + aufgaben: AUFGABEN.map((a) => ({ schluessel: a.schluessel, kurz: a.kurz, name: a.name })), + }); + } catch (fehler) { + console.error("[manager-ziele] Team:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Der Monat als CSV. + * + * SEMIKOLON UND BOM, und beides aus demselben Grund: Ein deutsches + * Excel trennt an Semikolons und erkennt UTF-8 nur mit Vorzeichen. + * Eine Datei mit Komma und ohne BOM öffnet sich als eine einzige + * Spalte voller „ü" — technisch richtig und praktisch unbrauchbar. */ +managerZieleRouter.get(`${BASIS}/export.csv`, nurLeitung, (req, res) => { + try { + const monat = istMonat(req.query.monat) ? req.query.monat : monatJetzt(); + monatSichern(monat); + const leute = teamStand(monat); + + const feld = (w) => { + const t = String(w ?? ""); + return /[";\n\r]/.test(t) ? `"${t.replace(/"/g, '""')}"` : t; + }; + const kopf = ["Name", "Rolle", "Pflichtig", + ...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) { + zeilen.push([ + p.name, p.rollenName, p.pflichtig ? "ja" : "nein", + ...p.aufgaben.flatMap((a) => [a.zahl, a.ziel]), + p.gesamt.erledigt, p.gesamt.ziel, p.stufe, + ].map(feld).join(";")); + } + + res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.setHeader("Content-Disposition", + `attachment; filename="manager-ziele-${monat}.csv"`); + res.send("" + zeilen.join("\r\n") + "\r\n"); + } catch (fehler) { + console.error("[manager-ziele] Export:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/* ===================================================================== + DIE ZIELZAHLEN + ===================================================================== */ + +managerZieleRouter.get(`${BASIS}/ziele`, nurLeitung, (req, res) => { + try { + const jetztM = monatJetzt(); + const naechster = monatPlus(jetztM, 1); + monatSichern(jetztM); + + const bauen = (monat) => PFLICHT_ROLLEN.map((rolle) => ({ + rolle, rollenName: ROLLEN_NAME[rolle] || rolle, + ziele: Object.fromEntries(AUFGABEN.map((a) => + [a.schluessel, monat === jetztM + ? zielFuer(monat, rolle, a.schluessel) + : zielGeltend(monat, rolle, a.schluessel)])), + })); + + res.json({ + jetztMonat: jetztM, naechsterMonat: naechster, + aufgaben: AUFGABEN.map((a) => ({ + schluessel: a.schluessel, name: a.name, kurz: a.kurz, vorgabe: a.vorgabe, + })), + jetzt: bauen(jetztM), + naechster: bauen(naechster), + }); + } catch (fehler) { + console.error("[manager-ziele] Ziele lesen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + +/** Ziele setzen — IMMER für den nächsten Monat. + * + * Aus der Vorlage: „Änderungen gelten ab dem nächsten Monat." Das ist + * hier keine Anzeige, sondern der Weg selbst: Der laufende Monat ist + * bereits festgeschrieben, geschrieben wird in den nächsten. Es gibt + * gar keinen Aufruf, mit dem man den laufenden Monat umschreiben + * könnte — eine Regel, die man nicht umgehen kann, braucht keine + * Disziplin. */ +managerZieleRouter.put(`${BASIS}/ziele`, nurLeitung, (req, res) => { + try { + const naechster = monatPlus(monatJetzt(), 1); + const eingabe = req.body?.ziele; + if (!eingabe || typeof eingabe !== "object") { + return res.status(400).json({ fehler: "nichts_angegeben" }); + } + + const setzen = db().prepare( + `INSERT INTO mz_ziel (monat, rolle, aufgabe, ziel, gesetzt_von, gesetzt_am) + VALUES (?,?,?,?,?,?) + ON CONFLICT(monat, rolle, aufgabe) + DO UPDATE SET ziel = excluded.ziel, gesetzt_von = excluded.gesetzt_von, + gesetzt_am = excluded.gesetzt_am`); + const am = jetzt(); + let gesetzt = 0; + + for (const rolle of PFLICHT_ROLLEN) { + const je = eingabe[rolle]; + if (!je || typeof je !== "object") continue; + for (const a of AUFGABEN) { + if (!(a.schluessel in je)) continue; + const wert = Number(je[a.schluessel]); + if (!Number.isInteger(wert) || wert < 0 || wert > 99) { + return res.status(400).json({ fehler: "ziel_ungueltig", rolle, aufgabe: a.schluessel }); + } + setzen.run(naechster, rolle, a.schluessel, wert, req.person.id, am); + gesetzt++; + } + } + + if (!gesetzt) return res.status(400).json({ fehler: "nichts_angegeben" }); + + protokolliere("manager_ziele_gesetzt", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `${gesetzt} Zielzahlen für ${naechster}`, + }); + + res.json({ ok: true, monat: naechster, gesetzt }); + } catch (fehler) { + console.error("[manager-ziele] Ziele setzen:", fehler?.message); + res.status(503).json({ fehler: "nicht_gespeichert" }); + } +}); + +/* ===================================================================== + DIE HINWEISE FÜR DIE STARTSEITE + ===================================================================== + + Liefert, was `workspace-hinweise.js` braucht — und zwar aus + derselben Rechnung wie die Seite selbst (`standFuer`). Die Zahl auf + der Kachel ist damit kein zweiter Zähler, sondern dieselbe Auskunft + an einer anderen Stelle. + ===================================================================== */ +export function offeneHinweise(person) { + try { + if (!person || person.haus === "crew") return []; + if (!PFLICHT_ROLLEN.includes(person.rolle)) return []; + const monat = monatJetzt(); + if (monat < ERSTER_MONAT) return []; + lageSetzen(monat); + monatSichern(monat); + + const stand = standFuer(person, monat); + if (!stand.pflichtig || !stand.gesamt.offen) return []; + + /* Zwei Hinweise, nicht einer: Eine Warnung steht im Haus immer + oben und ist etwas anderes als „liegt noch an". Wer am 3. des + Monats dieselbe rote Zeile sieht wie am 29., liest sie beim + zweiten Mal nicht mehr. */ + const dringend = stand.aufgaben.filter((a) => a.stufe === "rot").length; + const offen = stand.gesamt.offen - dringend; + const raus = []; + if (dringend) { + raus.push({ art: "mz_dringend", stufe: "warnung", + text: "dringend", ziel: "manager-ziele.html", anzahl: dringend }); + } + if (offen > 0) { + raus.push({ art: "mz_offen", stufe: "offen", + text: "offen", ziel: "manager-ziele.html", anzahl: offen }); + } + return raus; + } catch (fehler) { + console.error("[manager-ziele] Hinweise:", fehler?.message); + return []; + } +} + +/* ===================================================================== + DIE ERINNERUNGEN + ===================================================================== + + Diese Funktion VERSCHICKT NICHTS. Sie sagt nur, was heute anstünde; + verschickt wird im Takt von workspace-push.js (siehe Kopf dieser + Datei: kein Importkreis). + + DER ANLASS-PLAN kommt wörtlich aus der Vorlage. Dass pro Tag + höchstens EINE Meldung herausgeht, steckt im Merkmal: Es enthält + den Tag, und `benachrichtige` schickt ein Merkmal nie zweimal. + Fallen zwei Anlässe auf denselben Tag (etwa der 1. eines Monats, + der zugleich drei Tage vor Monatsende läge — es gibt keinen solchen + Monat, aber die Rechnung soll auch dann stimmen), gewinnt der + dringendere, und der andere entfällt. + ===================================================================== */ +export function zielRufe(d = new Date()) { + try { + const heute = tagBerlin(d); + const monat = heute.slice(0, 7); + if (monat < ERSTER_MONAT) return []; + lageSetzen(monat); + monatSichern(monat); + + const tag = Number(heute.slice(8, 10)); + const letzter = letzterTagVon(monat); + const bisEnde = letzter - tag; + + const leute = db().prepare( + `SELECT id, name, rolle FROM personen + WHERE aktiv = 1 AND rolle IN (${PFLICHT_ROLLEN.map(() => "?").join(",")})`) + .all(...PFLICHT_ROLLEN); + + const rufe = []; + for (const p of leute) { + const stand = standFuer(p, monat, heute); + if (!stand.pflichtig) continue; + + /* ---- Alles geschafft: einmal gratulieren, dann Ruhe ---------- */ + if (!stand.gesamt.offen) { + if (stand.gesamt.ziel > 0) { + rufe.push({ + personId: p.id, art: "manager_ziele", + titel: "Alles geschafft 🎉", + text: `Du hast diesen Monat alle ${stand.gesamt.ziel} Ziele erreicht. Danke!`, + /* ABSOLUT, nicht relativ: Push-Ziele gehen durch + `zielFuer()` und werden mit dem Pfad verglichen. Die + Hinweise auf der Startseite setzen das "/workspace/" + dagegen selbst davor -- deshalb steht es dort nicht. */ + ziel: "/workspace/manager-ziele.html", + merkmal: `mz:${monat}:fertig`, + }); + } + continue; + } + + /* ---- Was genau fehlt, als Satz ------------------------------- */ + const fehlt = stand.aufgaben.filter((a) => a.ziel > 0 && a.zahl < a.ziel) + .map((a) => { + const n = a.ziel - a.zahl; + return `${n} ${n === 1 ? a.einheit[0] : a.einheit[1]}`; + }); + const liste = fehlt.length === 1 ? fehlt[0] + : fehlt.slice(0, -1).join(", ") + " und " + fehlt[fehlt.length - 1]; + + let titel = null; + if (bisEnde <= 2) { + titel = bisEnde <= 0 ? "Letzter Tag des Monats" : `Noch ${bisEnde} Tag${bisEnde === 1 ? "" : "e"}`; + } else if (bisEnde === 7) { + titel = "Dringend – noch 7 Tage"; + } else if (tag === 16) { + titel = "Monatsmitte ist vorbei"; + } else if (tag === 15) { + titel = "Heute ist Stichtag Monatsmitte"; + } else if (tag === 10) { + titel = "Kurze Erinnerung"; + } else if (tag === 1) { + titel = "Neuer Monat"; + } + if (!titel) continue; + + const text = tag === 1 + ? "Deine Aufgaben starten bei 0. Viel Erfolg!" + : `Dir fehlt noch ${liste}.`; + + rufe.push({ + personId: p.id, art: "manager_ziele", titel, text, + ziel: "/workspace/manager-ziele.html", + /* DER TAG STEHT IM MERKMAL, nicht der Anlass: So kann pro Tag + nur eine einzige Meldung herausgehen — genau das verlangt + die Vorlage. */ + merkmal: `mz:${heute}`, + zahl: stand.gesamt.offen, + }); + } + return rufe; + } catch (fehler) { + console.error("[manager-ziele] Rufe:", fehler?.message); + return []; + } +} diff --git a/server/workspace-push.js b/server/workspace-push.js index 4502ac47..9493ae8f 100644 --- a/server/workspace-push.js +++ b/server/workspace-push.js @@ -192,6 +192,25 @@ export const ARTEN = [ { schluessel: "support", name: "Support: neue Meldung oder Antwort darauf", vorgabe: true }, + /* ---- MANAGER-ZIELE (02.10.2026) --------------------------------- + + Die vier festen Monatsaufgaben. EINE Art fuer alle sieben + Anlaesse (1., 10., 15., 16., sieben Tage vorher, die letzten drei + Tage, der Glueckwunsch) und nicht sieben Schalter: Es ist + dieselbe Sache in verschiedenen Lautstaerken. Wer die Erinnerung + an seine Monatsziele abschalten will, will sie ganz abschalten -- + nicht "die vom 10., aber die vom 16. behalten". + + `vorgabe: true`, wie alles im Haus, was auf etwas Vereinbartes + hinweist. Wer nichts davon hoeren will, schaltet es im + Glocken-Knopf ab; das ist eine Entscheidung, die man selbst + trifft. + + KEINE AUSNAHME VON DER RUHEZEIT. Eine fehlende Schulung weckt + niemanden um drei Uhr nachts. */ + { schluessel: "manager_ziele", + name: "Deine Monatsziele (Manager-Ziele)", vorgabe: true }, + /* ER IST LIVE (22.09.2026). Filipe: "die leute sollen auch automatisch eine benarichtigung @@ -957,6 +976,33 @@ export async function laufen() { console.error("[push] Live-Hinweis:", f?.message); } + /* --- MANAGER-ZIELE: die vier Monatsaufgaben (02.10.2026) -------- + + GERECHNET WIRD IM FACHMODUL, verschickt hier. `zielRufe()` + liefert fertige Meldungen; dieser Lauf kennt weder die + Zielzahlen noch die Ampel. Andersherum waere es ein + Importkreis, und ueber einen Kreis kommen Konstanten als + `undefined` an, ohne dass irgendwo ein Fehler erscheint. + + DAS MERKMAL ENTHAELT DEN TAG -- dadurch geht pro Person und Tag + hoechstens EINE Meldung heraus, auch wenn dieser Lauf alle + fuenf Minuten stattfindet. Ohne das waeren es 288 am Tag. + + Eigener try-Block aus demselben Grund wie beim Live-Hinweis: + Ein Fehler in den Monatszielen darf die Aufgaben- und + Terminerinnerungen nicht mitnehmen. */ + try { + const { zielRufe } = await import("./workspace-manager-ziele.js"); + for (const r of zielRufe()) { + await senden(r.personId, r.art, { + titel: r.titel, text: r.text, ziel: r.ziel, + merkmal: r.merkmal, zahl: r.zahl, + }); + } + } catch (f) { + console.error("[push] Manager-Ziele:", f?.message); + } + /* Alte Merkmale aufraeumen -- nach 60 Tagen braucht sie niemand mehr, und die Tabelle soll nicht ewig wachsen. */ const grenze = new Date(Date.now() - 60 * 86400_000).toISOString(); diff --git a/server/workspace.js b/server/workspace.js index e5e021c1..4ec28e0b 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -35,6 +35,7 @@ import { treffTabellen, treffSperreLesen, MINDESTALTER } from "./treff-tabellen. import { hilfeTabellen } from "./hilfe-tabellen.js"; import { supportTabellen } from "./support-tabellen.js"; import { notizTabellen } from "./notiz-tabellen.js"; +import { managerZieleTabellen } from "./manager-ziele-tabellen.js"; import { reaktionTabellen } from "./reaktion-tabellen.js"; import { spendenTabellen } from "./spenden-tabellen.js"; import { unterstuetzungTabellen } from "./unterstuetzung-tabellen.js"; @@ -6197,6 +6198,11 @@ export function db() { /* Der Notizblock (26.09.2026). Haengt an personen (CASCADE) und kommt deshalb hierher, nicht in den Bauplan darueber. */ notizTabellen(d); + /* Die Manager-Ziele (02.10.2026). Hängen an personen (CASCADE) UND + an leads (SET NULL) -- beide entstehen im Bauplan darüber, also + muss diese Zeile danach kommen. Stünde sie davor, legte SQLite + die Fremdschlüssel auf eine Tabelle an, die es noch nicht gibt. */ + managerZieleTabellen(d); reaktionTabellen(d); spendenTabellen(d); /* Die Wege zum Unterstützen (24.09.2026). Sie hängen an personen diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index a1dfba7e..ad01f3e8 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`. --> - - + + - +