diff --git a/pruef-ampel.png b/pruef-ampel.png index ba8c9fa0..6b2dea56 100644 Binary files a/pruef-ampel.png and b/pruef-ampel.png differ diff --git a/pruef-buehne.png b/pruef-buehne.png index f33340a2..8ca07666 100644 Binary files a/pruef-buehne.png and b/pruef-buehne.png differ diff --git a/pruef-checkliste.png b/pruef-checkliste.png index a7245b0e..e2ff793e 100644 Binary files a/pruef-checkliste.png and b/pruef-checkliste.png differ diff --git a/pruef-handy.png b/pruef-handy.png index 51ed5bf5..86b68b04 100644 Binary files a/pruef-handy.png and b/pruef-handy.png differ diff --git a/pruef-lesbarkeit-wissen.png b/pruef-lesbarkeit-wissen.png index 2095ac69..95eb76ee 100644 Binary files a/pruef-lesbarkeit-wissen.png and b/pruef-lesbarkeit-wissen.png differ diff --git a/pruef-merkzeichen.png b/pruef-merkzeichen.png index 2d0c34cf..23e92f7c 100644 Binary files a/pruef-merkzeichen.png and b/pruef-merkzeichen.png differ diff --git a/pruef-personen.png b/pruef-personen.png index 0f6d26c9..b10fb65b 100644 Binary files a/pruef-personen.png and b/pruef-personen.png differ diff --git a/pruef-protokoll-auf.png b/pruef-protokoll-auf.png index 0f021e4c..28dcdaf7 100644 Binary files a/pruef-protokoll-auf.png and b/pruef-protokoll-auf.png differ diff --git a/pruef-protokoll-zu.png b/pruef-protokoll-zu.png index 0e4e6aa9..712675fc 100644 Binary files a/pruef-protokoll-zu.png and b/pruef-protokoll-zu.png differ diff --git a/pruef-schulung.png b/pruef-schulung.png index f10b11c9..38d51f60 100644 Binary files a/pruef-schulung.png and b/pruef-schulung.png differ diff --git a/pruef-scout-zuteilung.png b/pruef-scout-zuteilung.png new file mode 100644 index 00000000..8b5388d6 Binary files /dev/null and b/pruef-scout-zuteilung.png differ diff --git a/pruef-sprung.png b/pruef-sprung.png index 1037057f..6b48e0bc 100644 Binary files a/pruef-sprung.png and b/pruef-sprung.png differ diff --git a/pruef-start-computer.png b/pruef-start-computer.png index 1f8341c4..b99db0cf 100644 Binary files a/pruef-start-computer.png and b/pruef-start-computer.png differ diff --git a/pruef-start-creator.png b/pruef-start-creator.png index 99715f76..e237bfd1 100644 Binary files a/pruef-start-creator.png and b/pruef-start-creator.png differ diff --git a/pruef-start-handy.png b/pruef-start-handy.png index e84160bc..3b6dba53 100644 Binary files a/pruef-start-handy.png and b/pruef-start-handy.png differ diff --git a/pruef-team.png b/pruef-team.png index 2b4556d6..147068ff 100644 Binary files a/pruef-team.png and b/pruef-team.png differ diff --git a/pruef-uebersicht-computer.png b/pruef-uebersicht-computer.png index 51c56564..867d9c84 100644 Binary files a/pruef-uebersicht-computer.png and b/pruef-uebersicht-computer.png differ diff --git a/pruef-uebersicht-creator.png b/pruef-uebersicht-creator.png index 850d2b87..b3b1255c 100644 Binary files a/pruef-uebersicht-creator.png and b/pruef-uebersicht-creator.png differ diff --git a/pruef-uebersicht-handy.png b/pruef-uebersicht-handy.png index 1a5b26cb..21867ee4 100644 Binary files a/pruef-uebersicht-handy.png and b/pruef-uebersicht-handy.png differ diff --git a/pruef-zustand.png b/pruef-zustand.png index fb76e26d..8cd114a9 100644 Binary files a/pruef-zustand.png and b/pruef-zustand.png differ diff --git a/server/pruef-scout-zuteilung.mjs b/server/pruef-scout-zuteilung.mjs new file mode 100644 index 00000000..9119a04c --- /dev/null +++ b/server/pruef-scout-zuteilung.mjs @@ -0,0 +1,335 @@ +/* Prueft die Zuteilung Manager -> Scouts. + + Wunsch vom 01.09.2026: "er soll nur die Scouts sehen, die ihm + zugeteilt sind." Dazu entschieden: Ein Manager sieht dann AUCH die + Creator dieser Scouts ("ja, alles seiner Scouts"), und zuteilen darf + NUR DogFather. + + WARUM DAS DIE HEIKELSTE AENDERUNG DES TAGES IST. + + Diese Zuteilung ERWEITERT Sichtbarkeit. Alles andere heute hat sie + eingeschraenkt -- ein Fehler dort zeigt jemandem zu wenig, und das + faellt auf. Ein Fehler HIER zeigt jemandem zu viel, und das faellt + niemandem auf. Deshalb steht der Missbrauch am Anfang: + + Darf ein Manager sich selbst Scouts zuteilen? -> muss 404 sein + Darf ein Scout das? -> muss 404 sein + Kann man einen Scout an einen Scout haengen? -> muss 400 sein + Kann man einen Creator zuteilen? -> muss 404 sein + + Und danach die Kette in beide Richtungen: Was der Manager sehen SOLL, + sieht er -- und was er nicht sehen soll, sieht er nicht. */ + +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const ordner = mkdtempSync(join(tmpdir(), "ws-zut-")); +process.env.WORKSPACE_DB = join(ordner, "workspace.db"); +process.env.PORT = "4217"; +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"); +await new Promise((r) => setTimeout(r, 700)); +const BASIS = "http://127.0.0.1:4217"; +setTimeout(() => { console.log("ABBRUCH"); process.exit(1); }, 300_000).unref?.(); + +let fehler = 0; +const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; + +await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle: "admin", code: "x" }) }).catch(() => {}); + +const { DatabaseSync } = await import("node:sqlite"); +const { scryptSync, randomBytes } = await import("node:crypto"); +for (let i = 0; i < 60; i++) { + try { + const p = new DatabaseSync(process.env.WORKSPACE_DB); + try { p.prepare("SELECT 1 FROM personen LIMIT 1").get(); break; } finally { p.close(); } + } catch { await new Promise((r) => setTimeout(r, 250)); } +} +const d = new DatabaseSync(process.env.WORKSPACE_DB); +const jetzt = new Date().toISOString(); +const heute = jetzt.slice(0, 10); +function anlegen(name, rolle, code) { + const salt = randomBytes(16).toString("hex"); + const hash = scryptSync(code, salt, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); + d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)") + .run(name, rolle, hash, salt, 32768, jetzt); + return d.prepare("SELECT last_insert_rowid() AS id").get().id; +} +const idDogi = anlegen("Filipe", "admin", "CODE-DOGI-0001"); +const idMara = anlegen("Mara", "manager", "CODE-MANA-0001"); +const idNina = anlegen("Nina", "manager", "CODE-MANB-0001"); +const idPat = anlegen("Patrick", "scout", "CODE-SCOU-0001"); +const idBen = anlegen("Ben", "scout", "CODE-SCOB-0001"); +const idTili = anlegen("Tili", "creator", "CODE-ANNA-0001"); +const idLuna = anlegen("Luna", "creator", "CODE-LUNA-0001"); + +/* Patrick betreut Tili, Ben betreut Luna. Noch KEINE Scout-Zuteilung -- + die wird gleich ueber die Schnittstelle gesetzt, damit auch der Weg + dorthin geprueft ist und nicht nur die Datenbank. */ +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idTili, idPat, jetzt); +d.prepare("INSERT INTO betreuung (creator_id, betreuer_id, seit) VALUES (?,?,?)").run(idLuna, idBen, jetzt); + +const lead = (name, scout) => + d.prepare(`INSERT INTO leads (name, plattform, handle, status, prioritaet, scout_id, erstellt) + VALUES (?,?,?,?,?,?,?)`) + .run(name, "tiktok", "@" + name.toLowerCase(), "neu", "mittel", scout, jetzt); +lead("PATRICK-LEAD", idPat); +lead("BEN-LEAD", idBen); + +const aufgabe = (titel, creator, verant) => + d.prepare(`INSERT INTO aufgaben (titel, beschreibung, status, prioritaet, + creator_id, verantwortlich_id, erstellt, erstellt_von) VALUES (?,?,?,?,?,?,?,?)`) + .run(titel, "", "offen", "mittel", creator, verant, jetzt, idDogi); +aufgabe("TILI-AUFGABE", idTili, idPat); +aufgabe("LUNA-AUFGABE", idLuna, idBen); +d.close(); + +async function anmelden(rolle, code) { + const a = await fetch(BASIS + "/workspace/api/anmelden", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rolle, code }), + }); + return (a.headers.getSetCookie?.() || []).map((x) => x.split(";")[0]).join("; "); +} +const setzen = (scoutId, managerId, keks) => + fetch(`${BASIS}/workspace/api/verwaltung/scout-zuteilung/${scoutId}`, { + method: "PUT", + headers: { cookie: keks, "Content-Type": "application/json", origin: BASIS }, + body: JSON.stringify({ manager_id: managerId }), + }); +const holen = async (pfad, keks) => { + const a = await fetch(BASIS + pfad, { headers: { cookie: keks } }); + return { status: a.status, daten: a.ok ? await a.json() : null }; +}; + +const kDogi = await anmelden("admin", "CODE-DOGI-0001"); +const kMara = await anmelden("manager", "CODE-MANA-0001"); +const kNina = await anmelden("manager", "CODE-MANB-0001"); +const kPat = await anmelden("scout", "CODE-SCOU-0001"); + +/* ================================================================ + 1. ZUERST DER MISSBRAUCH + ================================================================ */ +console.log("\n=== Nur DogFather darf zuteilen"); + +{ + const a = await setzen(idPat, idMara, kMara); + ok(a.status === 404, + `ein Manager kann sich NICHT selbst einen Scout zuteilen (${a.status}, 404 erwartet)`); + /* 404 und nicht 403: Wer es nicht darf, soll nicht einmal erfahren, + dass es diesen Weg gibt. */ + const b = await setzen(idPat, idMara, kPat); + ok(b.status === 404, `ein Scout auch nicht (${b.status})`); + + /* Und wirklich nichts passiert? Eine abgewiesene Anfrage, die trotzdem + schreibt, waere der schlimmste Fall -- deshalb wird nachgesehen. */ + const d2 = new DatabaseSync(process.env.WORKSPACE_DB); + const n = d2.prepare("SELECT COUNT(*) AS n FROM scout_zuteilung").get().n; + d2.close(); + ok(n === 0, `und es wurde auch nichts geschrieben (${n} Zuteilungen)`); +} + +console.log("\n=== Unsinnige Zuteilungen"); +{ + const a = await setzen(idPat, idBen, kDogi); + ok(a.status === 400, `Scout an Scout geht nicht (${a.status})`); + const b = await setzen(idPat, idDogi, kDogi); + ok(b.status === 400, + `Scout an DogFather geht nicht (${b.status}) – er sieht ohnehin alles, ` + + "und ein Eintrag ohne Wirkung wird spaeter fuer eine Wirkung gehalten"); + const c = await setzen(idTili, idMara, kDogi); + ok(c.status === 404, `einen Creator kann man nicht zuteilen (${c.status})`); + const e = await setzen(999999, idMara, kDogi); + ok(e.status === 404, `eine erfundene Nummer ebenfalls nicht (${e.status})`); +} + +/* ================================================================ + 2. DIE KETTE: Manager -> Scout -> dessen Creator + ================================================================ */ +console.log("\n=== Vorher: Mara sieht nichts von Patrick"); +{ + const l = await holen("/workspace/api/leads", kMara); + ok((l.daten?.leads || []).length === 0, + `ohne Zuteilung keine fremden Leads (${(l.daten?.leads || []).length})`); + const a = await holen("/workspace/api/aufgaben", kMara); + ok((a.daten?.aufgaben || []).length === 0, + `und keine fremden Aufgaben (${(a.daten?.aufgaben || []).length})`); +} + +console.log("\n=== DogFather teilt Patrick an Mara zu"); +{ + const a = await setzen(idPat, idMara, kDogi); + ok(a.status === 200, `die Zuteilung geht durch (${a.status})`); + + const l = await holen("/workspace/api/leads", kMara); + const namen = (l.daten?.leads || []).map((x) => x.name).sort(); + ok(namen.length === 1 && namen[0] === "PATRICK-LEAD", + `Mara sieht jetzt Patricks Lead (${namen.join(", ")})`); + ok(!namen.includes("BEN-LEAD"), + " und NICHT den des anderen Scouts"); + + /* DIE KETTE -- der eigentliche Punkt der Entscheidung: Sie sieht auch + den Creator, den Patrick betreut, obwohl er ihr nie direkt zugeteilt + wurde. */ + const auf = await holen("/workspace/api/aufgaben", kMara); + const titel = (auf.daten?.aufgaben || []).map((x) => x.titel).sort(); + ok(titel.length === 1 && titel[0] === "TILI-AUFGABE", + `und die Aufgaben von Patricks Creator (${titel.join(", ")})`); + ok(!titel.includes("LUNA-AUFGABE"), + " aber nicht die des Creators vom anderen Scout"); +} + +console.log("\n=== Die zweite Managerin sieht davon nichts"); +{ + const l = await holen("/workspace/api/leads", kNina); + const a = await holen("/workspace/api/aufgaben", kNina); + ok((l.daten?.leads || []).length === 0 && (a.daten?.aufgaben || []).length === 0, + `Nina sieht weiterhin nichts (${(l.daten?.leads || []).length} Leads, ` + + `${(a.daten?.aufgaben || []).length} Aufgaben) – eine Zuteilung gilt nur fuer den, ` + + "dem zugeteilt wurde"); +} + +console.log("\n=== Auch Suche und Hinweise ziehen mit"); +{ + /* Ein Hinweis oder Suchtreffer, der mehr verraet als die Seite + dahinter, waere ein Leck -- deshalb dieselbe Regel, aus derselben + Quelle. */ + const s = await holen("/workspace/api/suche?q=LEAD", kMara); + const treffer = (s.daten?.gruppen || []).flatMap((g) => g.treffer.map((t) => t.titel || t.name)); + ok(!treffer.some((t) => String(t).includes("BEN")), + `die Suche zeigt Mara keinen fremden Lead (${treffer.join(", ") || "nichts"})`); + + const sN = await holen("/workspace/api/suche?q=LEAD", kNina); + const trefferN = (sN.daten?.gruppen || []).flatMap((g) => g.treffer.map((t) => t.titel || t.name)); + ok(trefferN.length === 0, + `und Nina gar keinen (${trefferN.join(", ") || "nichts"})`); +} + +/* ================================================================ + 3. ZURUECKNEHMEN -- und die Sicht geht wirklich wieder weg + ================================================================ */ +console.log("\n=== Zuruecknehmen"); +{ + const a = await setzen(idPat, null, kDogi); + ok(a.status === 200, `die Zuteilung laesst sich loesen (${a.status})`); + const l = await holen("/workspace/api/leads", kMara); + const auf = await holen("/workspace/api/aufgaben", kMara); + ok((l.daten?.leads || []).length === 0 && (auf.daten?.aufgaben || []).length === 0, + `und Mara sieht danach wieder nichts (${(l.daten?.leads || []).length} Leads, ` + + `${(auf.daten?.aufgaben || []).length} Aufgaben)`); +} + +/* Ein Scout gehoert zu HOECHSTENS einem Manager -- eine zweite Zuteilung + ersetzt die erste, sie kommt nicht dazu. Sonst waere "wer ist + zustaendig?" eine Frage ohne Antwort. */ +console.log("\n=== Ein Scout gehoert zu genau einem Manager"); +{ + await setzen(idPat, idMara, kDogi); + await setzen(idPat, idNina, kDogi); + const d2 = new DatabaseSync(process.env.WORKSPACE_DB); + const z = d2.prepare("SELECT COUNT(*) AS n FROM scout_zuteilung WHERE scout_id = ?").get(idPat); + const wer = d2.prepare("SELECT manager_id FROM scout_zuteilung WHERE scout_id = ?").get(idPat); + d2.close(); + ok(z.n === 1, `nach zwei Zuteilungen genau ein Eintrag (${z.n})`); + ok(wer.manager_id === idNina, " und zwar der zuletzt gesetzte"); + + const lMara = await holen("/workspace/api/leads", kMara); + ok((lMara.daten?.leads || []).length === 0, + `die erste Managerin sieht Patricks Lead nicht mehr (${(lMara.daten?.leads || []).length})`); +} + +/* ================================================================ + 4. IM BROWSER: die Auswahl steht da -- und nur bei DogFather + ================================================================ */ +console.log("\n=== Die Auswahl in der Personenliste"); +const { chromium } = await import( + "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); +const browser = await chromium.launch(); + +async function seiteAls(rolle, code) { + const kontext = await browser.newContext({ viewport: { width: 1280, height: 1100 } }); + const konsole = []; + const seite = await kontext.newPage(); + seite.on("console", (m) => { if (m.type() === "error") konsole.push(m.text()); }); + seite.on("pageerror", (e) => konsole.push("pageerror: " + e.message)); + await seite.goto(BASIS + "/workspace/", { waitUntil: "domcontentloaded" }); + await seite.click(`.rolle[data-rolle="${rolle}"]`); + await seite.fill("#code", code); + await seite.click("button[type=submit]"); + await seite.waitForURL("**/start.html", { timeout: 15000 }); + await seite.goto(BASIS + "/workspace/personen.html", { waitUntil: "networkidle" }); + await seite.waitForTimeout(1200); + /* DIE LISTE IST NACH ROLLEN ZUGEKLAPPT -- offen ist nur die eigene + Gruppe. Der erste Anlauf dieser Pruefung mass deshalb genau eine + Zeile ("Filipe (du)") und meldete, die Auswahl fehle. Sie fehlte + nicht, sie war nur zugeklappt. + + Ein lehrreicher Fehlschlag: Er sah aus wie ein Produktfehler und war + einer der Pruefung. Wer ihn ungeprueft geglaubt haette, haette am + falschen Ende repariert. */ + await seite.click("#rest-schalter").catch(() => {}); + await seite.waitForTimeout(600); + return { kontext, seite, konsole }; +} + +const bDogi = await seiteAls("admin", "CODE-DOGI-0001"); +const r = await bDogi.seite.evaluate(() => ({ + personen: document.querySelectorAll(".person").length, + /* Die Auswahl "Gehört zu" -- eine je Scout. */ + wahlen: [...document.querySelectorAll('select[id^="manager-"]')].map((s) => ({ + id: s.id, + wert: s.value, + erste: s.querySelector("option")?.textContent, + anzahl: s.querySelectorAll("option").length, + })), + /* Die Zeilen sollen es auch in Worten sagen. */ + texte: [...document.querySelectorAll(".person__zeile")].map((z) => z.textContent), + ueber: document.documentElement.scrollWidth - document.documentElement.clientWidth, +})); +ok(r.personen === 7, + `aufgeklappt stehen alle sieben Personen da (${r.personen}) – ` + + "sonst misst die Pruefung eine zugeklappte Liste"); +ok(r.wahlen.length === 2, `zwei Scouts, zwei Auswahlfelder (${r.wahlen.length})`); +ok(/direkt bei DogFather/.test(r.wahlen[0]?.erste || ""), + `der Leer-Wert heisst, was er ist ("${r.wahlen[0]?.erste}") – ` + + '"niemand" waere falsch, ein Scout ohne Manager haengt an dir'); +ok(r.wahlen.some((w) => w.wert === String(idNina)), + "die bestehende Zuteilung ist vorausgewaehlt"); +ok(r.texte.some((t) => /gehört zu Nina/.test(t)), + "und steht auch in Worten in der Zeile"); +ok(r.texte.some((t) => /1 Scout zugeteilt/.test(t)), + "beim Manager steht die Zahl seiner Scouts"); +ok(r.ueber <= 0, `nichts steht ueber (${r.ueber}px)`); +ok(bDogi.konsole.length === 0, + `keine Fehler in der Konsole${bDogi.konsole.length ? " -> " + bDogi.konsole.join(" | ") : ""}`); +await bDogi.seite.screenshot({ path: "pruef-scout-zuteilung.png", fullPage: false }); +await bDogi.kontext.close(); + +/* GEGENPROBE: Ein Manager darf die Personenliste sehen -- aber diese + Auswahl nicht. */ +const bMara = await seiteAls("manager", "CODE-MANA-0001"); +const rM = await bMara.seite.evaluate(() => ({ + wahlen: document.querySelectorAll('select[id^="manager-"]').length, + personen: document.querySelectorAll(".person").length, +})); +ok(rM.personen >= 7, + `ein Manager sieht die ganze Personenliste (${rM.personen} Zeilen) – ` + + "die Personenverwaltung ist ihm nicht genommen worden"); +ok(rM.wahlen === 0, + `Gegenprobe: aber KEINE Zuteilungs-Auswahl (${rM.wahlen}) – er koennte sich sonst ` + + "seine eigene Sichtbarkeit vergeben"); +await bMara.kontext.close(); + +await browser.close(); +try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } +console.log(fehler ? `\n${fehler} Prüfung(en) fehlgeschlagen.` : "\nAlles in Ordnung."); +process.exit(fehler ? 1 : 0); diff --git a/server/workspace-hinweise.js b/server/workspace-hinweise.js index 1f54a5ed..370a945a 100644 --- a/server/workspace-hinweise.js +++ b/server/workspace-hinweise.js @@ -24,7 +24,7 @@ import express from "express"; import { - db, sitzungLesen, betreuteIds, istLeitung, + db, sitzungLesen, betreuteIds, istLeitung, istDogFather, pipelineIds, } from "./workspace.js"; import { sichtbar as sichtbarAufgaben } from "./workspace-aufgaben.js"; import { sichtbar as sichtbarTermine } from "./workspace-kalender.js"; @@ -204,9 +204,16 @@ hinweisRouter.get("/workspace/api/hinweise", (req, res) => { } /* ---------- Scout-Pipeline ---------- */ - if (istLeitung(person) || person.rolle === "scout") { - const nur = istLeitung(person) ? "1=1" : "l.scout_id = ?"; - const werte = istLeitung(person) ? [] : [person.id]; + /* NUR DogFather sieht alle Leads (01.09.2026). Ein Scout seine + eigenen, ein Manager seine eigenen und die seiner zugeteilten + Scouts -- dieselbe Regel wie in der Pipeline selbst, aus + derselben Quelle (pipelineIds in workspace.js). Ein Hinweis, der + mehr verraet als die Seite dahinter, waere ein Leck. */ + if (istDogFather(person) || person.rolle === "scout" || person.rolle === "manager") { + const ids = istDogFather(person) ? null : pipelineIds(person); + const nur = ids === null ? "1=1" + : ids.length ? `l.scout_id IN (${ids.map(() => "?").join(",")})` : "0=1"; + const werte = ids === null ? [] : ids; dazu("followup_faellig", "warnung", "Follow-up fällig", "scouting.html", zaehle(`SELECT COUNT(*) n FROM leads l diff --git a/server/workspace-personen.js b/server/workspace-personen.js index bccf9b2c..8044538d 100644 --- a/server/workspace-personen.js +++ b/server/workspace-personen.js @@ -15,7 +15,7 @@ import express from "express"; import { - db, protokolliere, echteIp, sitzungLesen, personAnlegen, codeNeu, personSperren, betreuungSetzen, istLeitung, istDogFather, ROLLEN_SORTIERUNG, ROLLEN_REIHE, + db, protokolliere, echteIp, sitzungLesen, personAnlegen, codeNeu, personSperren, betreuungSetzen, scoutZuteilungSetzen, istLeitung, istDogFather, ROLLEN_SORTIERUNG, ROLLEN_REIHE, } from "./workspace.js"; import { sicherungJetzt } from "./workspace-sicherung.js"; @@ -98,9 +98,18 @@ personenRouter.get("/workspace/api/verwaltung/personen", (req, res) => { AND a.status <> 'erledigt') AS offene_aufgaben, b.betreuer_id, (SELECT name FROM personen x WHERE x.id = b.betreuer_id) AS betreuer_name, - (SELECT COUNT(*) FROM betreuung y WHERE y.betreuer_id = p.id) AS betreut_anzahl + (SELECT COUNT(*) FROM betreuung y WHERE y.betreuer_id = p.id) AS betreut_anzahl, + /* Manager -> Scouts (01.09.2026). Bei einem Scout steht + hier, wem er zugeteilt ist; bei einem Manager, wie viele + Scouts an ihm haengen. Beides aus derselben Abfrage -- + eine zweite waere eine zweite Gelegenheit, dass die + Liste etwas anderes sagt als die Rechte. */ + sz.manager_id, + (SELECT name FROM personen m WHERE m.id = sz.manager_id) AS manager_name, + (SELECT COUNT(*) FROM scout_zuteilung z WHERE z.manager_id = p.id) AS scouts_anzahl FROM personen p LEFT JOIN betreuung b ON b.creator_id = p.id + LEFT JOIN scout_zuteilung sz ON sz.scout_id = p.id ORDER BY ${ROLLEN_SORTIERUNG.replace("rolle", "p.rolle")}, p.aktiv DESC, p.name`).all(), /* Wer ueberhaupt als zustaendig eingetragen werden kann. @@ -151,11 +160,17 @@ personenRouter.put("/workspace/api/verwaltung/betreuung/:id", gleicheHerkunft, ( /* DogFather, Manager und Scouts. Ein Creator kann nicht fuer einen anderen zustaendig sein -- das waere eine Rolle, die es nicht gibt. - WICHTIG: Ein Eintrag auf DogFather oder Manager aendert an den - RECHTEN nichts. Die Leitung sieht ohnehin jeden Creator; der - Eintrag sagt nur, WER sich kuemmert. Nur beim Scout entscheidet er - zusaetzlich ueber die Sichtbarkeit -- deshalb steht die Regel - dafuer unveraendert in betreuteIds(). */ + ACHTUNG, SEIT DEM 01.09.2026 ANDERS: Hier stand frueher "ein + Eintrag auf DogFather oder Manager aendert an den Rechten nichts, + die Leitung sieht ohnehin jeden Creator". Das gilt nicht mehr -- + nur noch DogFather sieht alles. Fuer einen MANAGER entscheidet + dieser Eintrag jetzt genauso ueber die Sichtbarkeit wie fuer einen + Scout: Wer ihm nicht zugeteilt ist (weder direkt noch ueber einen + seiner Scouts), taucht bei ihm nicht auf. + + Nur bei DogFather sagt der Eintrag weiterhin bloss, WER sich + kuemmert. Die Regel dazu steht an genau einer Stelle: + betreuteIds() in workspace.js. */ if (!betreuer || !["admin", "manager", "scout"].includes(betreuer.rolle)) { return res.status(400).json({ fehler: "Zuständig können nur aktive DogFather, Manager oder Scouts sein.", @@ -170,6 +185,63 @@ personenRouter.put("/workspace/api/verwaltung/betreuung/:id", gleicheHerkunft, ( } }); +/* ---------- Welcher Scout gehoert zu welchem Manager -------------------- + + "er soll nur die Scouts sehen, die ihm zugeteilt sind." (01.09.2026) + + NUR DOGFATHER darf das setzen -- ausdruecklich so entschieden, und + der Grund ist nicht Misstrauen, sondern Bauart: Diese Zuteilung + ERWEITERT die Sicht eines Managers (auf die Leads seiner Scouts und + auf deren Creator). Duerfte er sie selbst setzen, koennte er sich + seine eigene Sichtbarkeit vergeben -- und eine Grenze, die der + Begrenzte selbst verschieben kann, ist keine. + + Deshalb istDogFather und NICHT istLeitung. Der Unterschied ist hier + der ganze Punkt. */ + +personenRouter.put("/workspace/api/verwaltung/scout-zuteilung/:id", gleicheHerkunft, (req, res) => { + try { + if (!istDogFather(req.person)) { + /* 404 und nicht 403: Wer es nicht darf, soll nicht einmal + erfahren, dass es diesen Weg gibt. */ + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + + const scout = db().prepare("SELECT id, rolle FROM personen WHERE id = ?").get(id); + if (!scout || scout.rolle !== "scout") { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + + const w = req.body?.manager_id; + if (w === null || w === "" || w === undefined) { + scoutZuteilungSetzen(id, null, req.person.id); + protokolliere("scout_zuteilung", req.person, echteIp(req), `${id}: geloest`); + return res.json({ ok: true, manager_id: null }); + } + + const z = Number(w); + if (!Number.isInteger(z) || z < 1) return res.status(400).json({ fehler: "Ungültige Auswahl." }); + const manager = db().prepare( + "SELECT id, name, rolle FROM personen WHERE id = ? AND aktiv = 1").get(z); + /* NUR ein Manager. Ein Scout unter einem Scout waere eine Ordnung, + die es nicht gibt; ein Scout unter DogFather ist ueberfluessig, da + er ohnehin alles sieht -- und ein Eintrag, der nichts bewirkt, + wird beim naechsten Mal fuer eine Wirkung gehalten. */ + if (!manager || manager.rolle !== "manager") { + return res.status(400).json({ fehler: "Zuteilen kann man nur an einen aktiven Manager." }); + } + + scoutZuteilungSetzen(id, z, req.person.id); + protokolliere("scout_zuteilung", req.person, echteIp(req), `${id} -> ${z}`); + res.json({ ok: true, manager_id: z, manager_name: manager.name }); + } catch (fehler) { + console.error("[workspace] Scout-Zuteilung:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + /* ---------- Anlegen ----------------------------------------------------- */ personenRouter.post("/workspace/api/verwaltung/personen", gleicheHerkunft, (req, res) => { diff --git a/server/workspace-scouts.js b/server/workspace-scouts.js index 511dd594..e1ad4a91 100644 --- a/server/workspace-scouts.js +++ b/server/workspace-scouts.js @@ -17,7 +17,7 @@ import express from "express"; import { - db, protokolliere, echteIp, sitzungLesen, personAnlegen, betreuungSetzen, istLeitung, + db, protokolliere, echteIp, sitzungLesen, personAnlegen, betreuungSetzen, istLeitung, istDogFather, pipelineIds, } from "./workspace.js"; export const scoutRouter = express.Router(); @@ -57,9 +57,23 @@ function gleicheHerkunft(req, res, next) { scoutRouter.use("/workspace/api/leads", angemeldet); + /* Wessen Pipeline sieht diese Person? + + NUR DogFather sieht alle (01.09.2026). Ein Scout sieht seine + eigenen Leads, ein Manager seine eigenen UND die der Scouts, die + ihm zugeteilt sind: "er soll nur die Scouts sehen, die ihm + zugeteilt sind." + + Vorher stand hier istLeitung() -- damit sah jeder Manager jeden + Lead jedes Scouts. pipelineIds() steht in workspace.js und wird von + drei Stellen benutzt (Liste, Suche, Hinweise); dieselbe Regel + dreimal abgeschrieben waere dreimal Gelegenheit, sie einmal falsch + zu aendern. */ function sichtbar(person) { - if (istLeitung(person)) return { wo: "1=1", werte: [] }; - return { wo: "l.scout_id = ?", werte: [person.id] }; + if (istDogFather(person)) return { wo: "1=1", werte: [] }; + const ids = pipelineIds(person); + if (!ids.length) return { wo: "0=1", werte: [] }; + return { wo: `l.scout_id IN (${ids.map(() => "?").join(",")})`, werte: ids }; } const SPALTEN = ` diff --git a/server/workspace-suche.js b/server/workspace-suche.js index 2e7464b5..28b7ad72 100644 --- a/server/workspace-suche.js +++ b/server/workspace-suche.js @@ -24,7 +24,7 @@ import express from "express"; import { - db, sitzungLesen, betreuteIds, istLeitung, + db, sitzungLesen, betreuteIds, istLeitung, istDogFather, pipelineIds, } from "./workspace.js"; import { sichtbar as sichtbarAufgaben } from "./workspace-aufgaben.js"; import { sichtbar as sichtbarTermine } from "./workspace-kalender.js"; @@ -186,9 +186,16 @@ sucheRouter.get("/workspace/api/suche", (req, res) => { } /* ---------- Scout-Pipeline ---------- */ - if (istLeitung(person) || person.rolle === "scout") { - const nur = istLeitung(person) ? "1=1" : "l.scout_id = ?"; - const werte = istLeitung(person) ? [] : [person.id]; + /* NUR DogFather sieht alle Leads (01.09.2026). Ein Scout seine + eigenen, ein Manager seine eigenen und die seiner zugeteilten + Scouts -- dieselbe Regel wie in der Pipeline selbst, aus + derselben Quelle (pipelineIds in workspace.js). Ein Hinweis, der + mehr verraet als die Seite dahinter, waere ein Leck. */ + if (istDogFather(person) || person.rolle === "scout" || person.rolle === "manager") { + const ids = istDogFather(person) ? null : pipelineIds(person); + const nur = ids === null ? "1=1" + : ids.length ? `l.scout_id IN (${ids.map(() => "?").join(",")})` : "0=1"; + const werte = ids === null ? [] : ids; quelle("Scout-Pipeline", ` SELECT l.id, l.name, l.plattform, l.handle, l.status, l.notizen, l.potenzial, l.aktivitaet FROM leads l diff --git a/server/workspace.js b/server/workspace.js index 1105ce11..5e00a851 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -553,6 +553,34 @@ export function db() { ); CREATE INDEX IF NOT EXISTS idx_betreuung_betreuer ON betreuung (betreuer_id); + /* Welcher Scout gehoert zu welchem Manager (01.09.2026). + + "er soll nur die Scouts sehen, die ihm zugeteilt sind." + + WARUM EINE EIGENE TABELLE und nicht ein zweiter Eintrag in + betreuung: Dort heisst die Spalte creator_id, und der ganze + uebrige Code liest sie als "das ist ein Creator". Ein Scout in + dieser Spalte waere technisch moeglich und fachlich eine Luege -- + betreuteIds() gaebe dann Scout-Nummern zurueck, die anderswo als + Creator behandelt wuerden. Solche Abkuerzungen raechen sich + genau dann, wenn niemand mehr weiss, dass sie getroffen wurden. + + Ein Scout gehoert zu hoechstens EINEM Manager -- deshalb ist + scout_id der Schluessel. Eine Zuteilung an mehrere waere die + Frage "wer ist zustaendig?" ohne Antwort. + + ON DELETE CASCADE auf beiden Seiten: Verschwindet der Scout oder + der Manager, ist die Zuteilung gegenstandslos. Sie stehenzulassen + hiesse, dass eine geloeschte Person weiter Sichtbarkeit steuert. */ + CREATE TABLE IF NOT EXISTS scout_zuteilung ( + scout_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE, + manager_id INTEGER NOT NULL REFERENCES personen(id) ON DELETE CASCADE, + seit TEXT NOT NULL, + gesetzt_von INTEGER REFERENCES personen(id) ON DELETE SET NULL + ); + CREATE INDEX IF NOT EXISTS idx_scout_zuteilung_manager + ON scout_zuteilung (manager_id); + /* Meeting-Protokoll zu einem Termin (Konzept, Seite 13). Bewusst eine eigene Tabelle statt neuer Spalten in termine: Es gibt hier keine Migrationen, und ein Protokoll gehoert ohnehin @@ -909,13 +937,75 @@ export function codeErzeugen(gruppen = 4, laenge = 4) { export function betreuteIds(person) { if (!person || (person.rolle !== "scout" && person.rolle !== "manager")) return []; try { - return db().prepare("SELECT creator_id FROM betreuung WHERE betreuer_id = ?") + const eigene = db().prepare("SELECT creator_id FROM betreuung WHERE betreuer_id = ?") .all(person.id).map((z) => z.creator_id); + if (person.rolle !== "manager") return eigene; + + /* DIE KETTE: Manager -> seine Scouts -> deren Creator (01.09.2026). + + Entschieden auf die Frage "sieht er dann auch die Creator dieser + Scouts?" -- "ja, alles seiner Scouts". Das ist die uebliche + Ordnung: Wer einen Scout fuehrt, muss sehen, woran der arbeitet. + Ohne das muesste jeder Creator einem Manager EINZELN zugewiesen + werden, und beim ersten vergessenen faende er ein Loch in seiner + Uebersicht, ohne zu merken, dass es eines ist. + + Ein Set, weil ein Creator auf beiden Wegen kommen kann: direkt + zugeteilt UND ueber seinen Scout. Doppelte Nummern wuerden in den + IN-Listen zu doppelten Fragezeichen -- fachlich harmlos, aber + jede Abfrage unnoetig laenger. */ + const scouts = scoutsVon(person.id); + if (!scouts.length) return eigene; + const ueberScouts = db().prepare( + `SELECT creator_id FROM betreuung + WHERE betreuer_id IN (${scouts.map(() => "?").join(",")})`) + .all(...scouts).map((z) => z.creator_id); + return [...new Set([...eigene, ...ueberScouts])]; } catch { return []; // im Zweifel nichts sehen, nie mehr } } +/** Die Scouts, die diesem Manager zugeteilt sind. Fuer alle anderen + * leer -- ein Scout fuehrt keine Scouts, ein Creator schon gar nicht. + * DogFather braucht sie nicht: Er sieht ohnehin alles. */ +export function scoutsVon(managerId) { + if (!managerId) return []; + try { + return db().prepare("SELECT scout_id FROM scout_zuteilung WHERE manager_id = ?") + .all(managerId).map((z) => z.scout_id); + } catch { + return []; + } +} + +/** Wessen Leads darf diese Person sehen? Gibt die Personennummern + * zurueck, deren Pipeline sichtbar ist -- der eigene immer dabei. + * Ein Scout sieht nur sich, ein Manager sich und seine Scouts. */ +export function pipelineIds(person) { + if (!person) return []; + if (person.rolle !== "manager") return [person.id]; + return [...new Set([person.id, ...scoutsVon(person.id)])]; +} + +/** Zuteilung setzen oder loesen (null loest sie). + * Wer das DARF, entscheidet die Route -- hier steht nur, WIE. */ +export function scoutZuteilungSetzen(scoutId, managerId, akteur = null) { + const d = db(); + if (managerId === null) { + d.prepare("DELETE FROM scout_zuteilung WHERE scout_id = ?").run(scoutId); + return; + } + d.prepare(` + INSERT INTO scout_zuteilung (scout_id, manager_id, seit, gesetzt_von) + VALUES (?,?,?,?) + ON CONFLICT(scout_id) DO UPDATE SET + manager_id = excluded.manager_id, + seit = excluded.seit, + gesetzt_von = excluded.gesetzt_von`) + .run(scoutId, managerId, new Date().toISOString(), akteur); +} + /* SQL-Baustein "diese Spalte gehoert zu einem meiner Creator". Gibt null zurueck, wenn es nichts zu ergaenzen gibt -- eine leere IN-Liste waere ungueltiges SQL. */ diff --git a/workspace/assets/js/personen.js b/workspace/assets/js/personen.js index 83bfb433..b7b445b5 100644 --- a/workspace/assets/js/personen.js +++ b/workspace/assets/js/personen.js @@ -137,6 +137,21 @@ if (p.betreut_anzahl) { z.append(el('span', null, `betreut ${p.betreut_anzahl} Creator`)); } + /* Bei einem Manager: wie viele Scouts an ihm haengen. Ohne diese Zahl + muesste man die ganze Liste durchgehen, um zu sehen, ob eine + Zuteilung ueberhaupt angekommen ist. + + Steht in DERSELBEN Zeile wie "betreut N Creator" -- ein eigenes + Element haette eine eigene Klasse gebraucht, die es im Stil nicht + gibt, und waere als nackter Text erschienen. */ + if (p.rolle === 'manager' && p.scouts_anzahl) { + z.append(el('span', null, + `${p.scouts_anzahl} ${p.scouts_anzahl === 1 ? 'Scout' : 'Scouts'} zugeteilt`)); + } + /* Beim Scout umgekehrt: zu wem er gehoert. */ + if (p.rolle === 'scout' && p.manager_name) { + z.append(el('span', null, `gehört zu ${p.manager_name}`)); + } links.append(z); /* Zuständigkeit. Steht nur bei Creator und nur dann, wenn es @@ -194,6 +209,51 @@ links.append(b); } + /* ---- Welchem Manager gehoert dieser Scout? (01.09.2026) ---- + + "er soll nur die Scouts sehen, die ihm zugeteilt sind." + + NUR fuer DogFather sichtbar -- und das ist kein Schoenheitsgrund: + Diese Zuteilung ERWEITERT die Sicht eines Managers (auf die Leads + seiner Scouts und auf deren Creator). Duerfte er sie selbst + setzen, koennte er sich seine eigene Sichtbarkeit vergeben. Der + Server weist es ohnehin ab; hier geht es nur darum, niemandem + einen Knopf hinzustellen, der nur eine Absage bringt. + + Und nur, wenn es ueberhaupt einen Manager gibt -- eine leere + Auswahl sieht aus wie ein Fehler. */ + const managerListe = betreuerListe.filter((x) => x.rolle === 'manager'); + if (p.rolle === 'scout' && ich.rolle === 'admin' && managerListe.length) { + const b = el('div', 'betreuung'); + const s2 = el('label', 'betreuung__schild', 'Gehört zu'); + s2.htmlFor = 'manager-' + p.id; + const w2 = el('select', 'betreuung__wahl'); + w2.id = 'manager-' + p.id; + const leer2 = document.createElement('option'); + leer2.value = ''; + /* Nicht "— niemand —": Ein Scout ohne Manager ist nicht + unbetreut, er haengt direkt an dir. Das ist ein Zustand, kein + Mangel. */ + leer2.textContent = '— direkt bei DogFather —'; + w2.append(leer2); + for (const m of managerListe) w2.append(new Option(m.name, String(m.id))); + w2.value = p.manager_id ? String(p.manager_id) : ''; + w2.addEventListener('change', async () => { + w2.disabled = true; melde(''); + try { + const a = await hole(`/workspace/api/verwaltung/scout-zuteilung/${p.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ manager_id: w2.value || null }), + }); + if (!a.ok) { melde((await a.json().catch(() => ({}))).fehler || 'Ging nicht.'); return; } + await laden(); + } catch { /* umgeleitet */ } finally { w2.disabled = false; } + }); + b.append(s2, w2); + links.append(b); + } + const knoepfe = el('div', 'person__knoepfe'); knoepfe.append(tuKnopf('Neuer Code', async () => { const eigen = p.id === ich.id; diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 202b0cfd..6044f8c3 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -7,9 +7,9 @@ - - - + + + @@ -152,9 +152,9 @@ - - - - + + + + diff --git a/workspace/automation.html b/workspace/automation.html index ebe98aae..56f08ec1 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -132,9 +132,9 @@ - - - - + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index 75775f8a..d3d3b4ca 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -115,13 +115,13 @@ - - - - - - - - + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index 2ac22974..7c74cec6 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -54,10 +54,10 @@ - - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index b0390df6..a5e71ee2 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -164,11 +164,11 @@ - - - - - - + + + + + + diff --git a/workspace/dateien.html b/workspace/dateien.html index e7a3f0e1..50211153 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -93,9 +93,9 @@ - - - - + + + + diff --git a/workspace/index.html b/workspace/index.html index f56f395b..c6c4ea6b 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -25,7 +25,7 @@ type="image/webp" media="(max-width: 899px)" /> - + @@ -143,6 +143,6 @@

- + diff --git a/workspace/kalender.html b/workspace/kalender.html index 7b0d5e6c..6a55dd42 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -147,9 +147,9 @@ - - - - + + + + diff --git a/workspace/personen.html b/workspace/personen.html index 3e379058..9821afb8 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -121,9 +121,9 @@ - - - - + + + + diff --git a/workspace/profil.html b/workspace/profil.html index 1e5e0b53..375dc97f 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -193,10 +193,10 @@ - - - - - + + + + + diff --git a/workspace/report.html b/workspace/report.html index c583a4cb..e7b91dcd 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -91,10 +91,10 @@ - - - - - + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index f74c5db3..c7e75262 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -124,9 +124,9 @@ - - - - + + + + diff --git a/workspace/start.html b/workspace/start.html index 407497de..5e5c7d55 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -7,8 +7,8 @@ - - + + @@ -99,9 +99,9 @@

- - - - + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index 51013a90..ed61bd40 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -70,10 +70,10 @@ - - - - - + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index f39d22ed..4ce18a5e 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -111,9 +111,9 @@ - - - - + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 12d38027..9eb5c323 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -7,9 +7,9 @@ - - - + + + @@ -57,9 +57,9 @@ - + - - + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 6de33050..4d722d2f 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -175,9 +175,9 @@ - - - - + + + +