diff --git a/server/workspace-aufgaben.js b/server/workspace-aufgaben.js index ab3bb879..8a6f2e1a 100644 --- a/server/workspace-aufgaben.js +++ b/server/workspace-aufgaben.js @@ -9,7 +9,9 @@ ===================================================================== */ import express from "express"; -import { db, protokolliere, echteIp, sitzungLesen } from "./workspace.js"; +import { + db, protokolliere, echteIp, sitzungLesen, betreutWo, +} from "./workspace.js"; export const aufgabenRouter = express.Router(); @@ -55,8 +57,14 @@ function sichtbar(person) { return { wo: "1=1", werte: [] }; case "creator": return { wo: "(a.creator_id = ? OR a.verantwortlich_id = ?)", werte: [person.id, person.id] }; - case "scout": - return { wo: "a.verantwortlich_id = ?", werte: [person.id] }; + case "scout": { + /* Eigene Aufgaben -- und die der Creator, die dieser Scout + betreut. Wer jemanden betreut, muss dessen Brett sehen. */ + const b = betreutWo(person, "a.creator_id"); + return b + ? { wo: `(a.verantwortlich_id = ? OR ${b.wo})`, werte: [person.id, ...b.werte] } + : { wo: "a.verantwortlich_id = ?", werte: [person.id] }; + } default: return { wo: "0=1", werte: [] }; // unbekannte Rolle sieht nichts } diff --git a/server/workspace-bereiche.js b/server/workspace-bereiche.js index 877dda03..f6e32f81 100644 --- a/server/workspace-bereiche.js +++ b/server/workspace-bereiche.js @@ -15,7 +15,9 @@ ===================================================================== */ import express from "express"; -import { db, protokolliere, echteIp, sitzungLesen } from "./workspace.js"; +import { + db, protokolliere, echteIp, sitzungLesen, betreutWo, darfCreator, betreuteIds, +} from "./workspace.js"; export const bereicheRouter = express.Router(); @@ -84,7 +86,9 @@ bereicheRouter.use("/workspace/api/bereich", angemeldet); function sichtbar(person) { if (person.rolle === "admin") return { wo: "1=1", werte: [] }; if (person.rolle === "creator") return { wo: "e.creator_id = ?", werte: [person.id] }; - return null; + /* Ein Scout sieht die Bereiche der Creator, die er betreut -- und ohne + Zuteilung gar nichts (null heisst: Bereich existiert fuer dich nicht). */ + return betreutWo(person, "e.creator_id"); } const SPALTEN = ` @@ -196,8 +200,19 @@ bereicheRouter.post("/workspace/api/bereich/:bereich", gleicheHerkunft, (req, re if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") }); /* Ein Creator schreibt immer in den eigenen Bereich, egal was im - Aufruf steht. */ - if (req.person.rolle !== "admin") aus.creator_id = req.person.id; + Aufruf steht. Ein Scout in den eines Creators, den er betreut -- + niemals in einen fremden und niemals "auf sich selbst", denn ein + Scout hat gar keinen eigenen Betreuungsbereich. */ + if (req.person.rolle === "creator") { + aus.creator_id = req.person.id; + } else if (req.person.rolle === "scout") { + const w = Number(aus.creator_id); + if (!darfCreator(req.person, w)) { + const erster = betreuteIds(req.person)[0]; + if (!erster) return res.status(403).json({ fehler: "Dir ist kein Creator zugeteilt." }); + aus.creator_id = erster; + } + } if (aus.creator_id && !db().prepare("SELECT 1 FROM personen WHERE id = ? AND rolle = 'creator'").get(aus.creator_id)) { return res.status(400).json({ fehler: "Zugeordneter Creator existiert nicht." }); diff --git a/server/workspace-calls.js b/server/workspace-calls.js index c45de467..90bdff71 100644 --- a/server/workspace-calls.js +++ b/server/workspace-calls.js @@ -18,6 +18,7 @@ import express from "express"; import { db, protokolliere, echteIp, sitzungLesen } from "./workspace.js"; +import { sichtbar } from "./workspace-kalender.js"; export const callRouter = express.Router(); @@ -53,16 +54,11 @@ function gleicheHerkunft(req, res, next) { callRouter.use("/workspace/api/calls", angemeldet); -/* Dieselbe Sichtbarkeitsregel wie im Kalender -- absichtlich Wort fuer - Wort, damit ein Termin nicht in der einen Ansicht auftaucht und in der - anderen fehlt. */ -function sichtbar(person) { - if (person.rolle === "admin") return { wo: "1=1", werte: [] }; - return { - wo: "(t.creator_id = ? OR t.teilnehmer_id = ? OR t.erstellt_von = ?)", - werte: [person.id, person.id, person.id], - }; -} +/* Die Sichtbarkeitsregel kommt aus dem Kalender und wird hier NICHT + wiederholt. Sie stand kurzzeitig zweimal da -- und prompt wurde beim + Erweitern nur eine der beiden Kopien geaendert: Scouts sahen die + Termine ihrer Creator, dieselben Termine als Call aber nicht. Eine + Regel, zwei Orte, ein Fehler. */ const SPALTEN = ` t.id, t.titel, t.beschreibung, t.art, t.beginn, t.dauer_min, t.ort, diff --git a/server/workspace-dateien.js b/server/workspace-dateien.js index f0463f92..3b193018 100644 --- a/server/workspace-dateien.js +++ b/server/workspace-dateien.js @@ -28,7 +28,9 @@ import express from "express"; import { randomBytes } from "node:crypto"; import { join, extname, basename } from "node:path"; import { mkdirSync, createReadStream, writeFileSync, unlinkSync, statSync } from "node:fs"; -import { db, protokolliere, echteIp, sitzungLesen, DATEN_ORDNER } from "./workspace.js"; +import { + db, protokolliere, echteIp, sitzungLesen, DATEN_ORDNER, betreutWo, darfCreator, +} from "./workspace.js"; export const dateienRouter = express.Router(); @@ -70,7 +72,10 @@ function sichtbar(person) { if (person.rolle === "creator") { return { wo: "(d.creator_id = ? OR d.hochgeladen_von = ?)", werte: [person.id, person.id] }; } - return { wo: "d.hochgeladen_von = ?", werte: [person.id] }; + const b = betreutWo(person, "d.creator_id"); + return b + ? { wo: `(d.hochgeladen_von = ? OR ${b.wo})`, werte: [person.id, ...b.werte] } + : { wo: "d.hochgeladen_von = ?", werte: [person.id] }; } const SPALTEN = ` @@ -145,9 +150,18 @@ dateienRouter.post("/workspace/api/dateien", try { notiz = decodeURIComponent(req.get("x-notiz") || ""); } catch { notiz = ""; } notiz = notiz.slice(0, NOTIZ_MAX).trim() || null; - /* Wer nicht Management ist, lädt immer in den eigenen Bereich. */ + /* Wer nicht Management ist, lädt immer in den eigenen Bereich -- + ein betreuender Scout in den seines Creators. */ let creatorId = null; - if (req.person.rolle === "admin") { + if (req.person.rolle === "scout") { + const w = Number(req.get("x-creator")); + if (w) { + if (!darfCreator(req.person, w)) { + return res.status(403).json({ fehler: "Dieser Creator ist dir nicht zugeteilt." }); + } + creatorId = w; + } + } else if (req.person.rolle === "admin") { const w = req.get("x-creator"); if (w) { const z = Number(w); diff --git a/server/workspace-kalender.js b/server/workspace-kalender.js index 6286c55c..0afa3950 100644 --- a/server/workspace-kalender.js +++ b/server/workspace-kalender.js @@ -12,7 +12,9 @@ ===================================================================== */ import express from "express"; -import { db, protokolliere, echteIp, sitzungLesen } from "./workspace.js"; +import { + db, protokolliere, echteIp, sitzungLesen, betreutWo, +} from "./workspace.js"; export const kalenderRouter = express.Router(); @@ -43,12 +45,13 @@ kalenderRouter.use("/workspace/api/termine", angemeldet); /* Wer sieht welchen Termin? Gleiche Logik wie bei den Aufgaben: Management alles, sonst nur, woran man selbst beteiligt ist. */ -function sichtbar(person) { +export function sichtbar(person) { if (person.rolle === "admin") return { wo: "1=1", werte: [] }; - return { - wo: "(t.creator_id = ? OR t.teilnehmer_id = ? OR t.erstellt_von = ?)", - werte: [person.id, person.id, person.id], - }; + const eigen = "(t.creator_id = ? OR t.teilnehmer_id = ? OR t.erstellt_von = ?)"; + const werte = [person.id, person.id, person.id]; + const b = betreutWo(person, "t.creator_id"); + if (!b) return { wo: eigen, werte }; + return { wo: `(${eigen} OR ${b.wo})`, werte: [...werte, ...b.werte] }; } const SPALTEN = ` diff --git a/server/workspace-personen.js b/server/workspace-personen.js index 3d7cc6fd..76ff61ae 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, + db, protokolliere, echteIp, sitzungLesen, personAnlegen, codeNeu, personSperren, betreuungSetzen, } from "./workspace.js"; export const personenRouter = express.Router(); @@ -54,9 +54,16 @@ personenRouter.get("/workspace/api/verwaltung/personen", (req, res) => { (SELECT COUNT(*) FROM sitzungen s WHERE s.person_id = p.id) AS sitzungen, (SELECT COUNT(*) FROM aufgaben a WHERE (a.creator_id = p.id OR a.verantwortlich_id = p.id) - AND a.status <> 'erledigt') AS offene_aufgaben + 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 FROM personen p + LEFT JOIN betreuung b ON b.creator_id = p.id ORDER BY p.aktiv DESC, p.rolle, p.name`).all(), + /* Wer ueberhaupt als zustaendig eingetragen werden kann. */ + betreuer: db().prepare( + "SELECT id, name FROM personen WHERE rolle = 'scout' AND aktiv = 1 ORDER BY name").all(), }); } catch (fehler) { console.error("[workspace] Personen lesen:", fehler?.message); @@ -64,6 +71,45 @@ personenRouter.get("/workspace/api/verwaltung/personen", (req, res) => { } }); +/* ---------- Zuständigkeit ------------------------------------------------ + Wer betreut welchen Creator. Nur das Management setzt das -- sonst + koennte sich ein Scout selbst Creator zuteilen und haette damit die + Rechtevergabe in der Hand, die ihn eigentlich begrenzen soll. */ + +personenRouter.put("/workspace/api/verwaltung/betreuung/:id", gleicheHerkunft, (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); + + const creator = db().prepare("SELECT id, rolle FROM personen WHERE id = ?").get(id); + if (!creator || creator.rolle !== "creator") { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } + + const w = req.body?.betreuer_id; + if (w === null || w === "" || w === undefined) { + betreuungSetzen(id, null, { ...req.person, ip: echteIp(req) }); + return res.json({ ok: true, betreuer_id: null }); + } + + const z = Number(w); + if (!Number.isInteger(z) || z < 1) return res.status(400).json({ fehler: "Ungültige Auswahl." }); + const betreuer = db().prepare( + "SELECT id, name, rolle FROM personen WHERE id = ? AND aktiv = 1").get(z); + /* Nur Scouts. Das Management sieht ohnehin alle Creator und braucht + keinen Eintrag -- einer waere irrefuehrend. */ + if (!betreuer || betreuer.rolle !== "scout") { + return res.status(400).json({ fehler: "Zuständig können nur aktive Scouts sein." }); + } + + betreuungSetzen(id, z, { ...req.person, ip: echteIp(req) }); + res.json({ ok: true, betreuer_id: z, betreuer_name: betreuer.name }); + } catch (fehler) { + console.error("[workspace] Betreuung setzen:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } +}); + /* ---------- Anlegen ----------------------------------------------------- */ personenRouter.post("/workspace/api/verwaltung/personen", gleicheHerkunft, (req, res) => { diff --git a/server/workspace-profil.js b/server/workspace-profil.js index b86990a4..a4aad071 100644 --- a/server/workspace-profil.js +++ b/server/workspace-profil.js @@ -11,7 +11,9 @@ ===================================================================== */ import express from "express"; -import { db, protokolliere, echteIp, sitzungLesen } from "./workspace.js"; +import { + db, protokolliere, echteIp, sitzungLesen, darfCreator, betreuteIds, +} from "./workspace.js"; export const profilRouter = express.Router(); @@ -48,11 +50,20 @@ function gleicheHerkunft(req, res, next) { profilRouter.use("/workspace/api/profil", angemeldet); -/* Wer darf welches Profil? Scouts gar keins -- im Konzept haben sie mit - der Creator-Betreuung nichts zu tun. */ +/* Wer darf welches Profil? + + "voll" = alle Felder, auch die management-internen (Plan-Start, + Review-Termin, interne Notiz). Nur das Management. + "betreuend"= ein Scout bei einem ihm zugeteilten Creator. Er arbeitet + mit dem Profil wie ein Manager, sieht aber die internen + Notizen NICHT -- eine Notiz UEBER die Betreuung gehoert + nicht in die Hand dessen, der betreut. + "eigen" = der Creator selbst. + null = 404. */ function darf(person, zielId) { if (person.rolle === "admin") return "voll"; - if (person.rolle === "creator" && person.id === zielId) return "eigen"; + if (person.rolle === "creator") return person.id === zielId ? "eigen" : null; + if (person.rolle === "scout") return darfCreator(person, zielId) ? "betreuend" : null; return null; } @@ -96,7 +107,18 @@ profilRouter.get("/workspace/api/profil", (req, res) => { if (req.person.rolle === "creator") { return res.json({ creator: [{ id: req.person.id, name: req.person.name }], eigen: true }); } - if (req.person.rolle !== "admin") return res.status(404).json({ fehler: "nicht_gefunden" }); + /* Ein Scout bekommt genau die Creator, die er betreut -- und ohne + Zuteilung eine leere Liste statt einer Fehlermeldung. */ + let nur = ""; + let werte = []; + if (req.person.rolle === "scout") { + const ids = betreuteIds(req.person); + if (!ids.length) return res.json({ creator: [], eigen: false, betreuend: true }); + nur = ` AND p.id IN (${ids.map(() => "?").join(",")})`; + werte = ids; + } else if (req.person.rolle !== "admin") { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } res.json({ creator: db().prepare(` SELECT p.id, p.name, p.aktiv, p.letzter_login, @@ -104,9 +126,10 @@ profilRouter.get("/workspace/api/profil", (req, res) => { WHERE a.creator_id = p.id AND a.status <> 'erledigt') AS offene_aufgaben, (SELECT naechster_review FROM profile f WHERE f.person_id = p.id) AS naechster_review, (SELECT geaendert FROM profile f WHERE f.person_id = p.id) AS profil_geaendert - FROM personen p WHERE p.rolle = 'creator' - ORDER BY p.aktiv DESC, p.name`).all(), + FROM personen p WHERE p.rolle = 'creator'${nur} + ORDER BY p.aktiv DESC, p.name`).all(...werte), eigen: false, + betreuend: req.person.rolle === "scout", }); } catch (fehler) { console.error("[workspace] Creator-Liste:", fehler?.message); diff --git a/server/workspace-reports.js b/server/workspace-reports.js index 73dc81e7..eb30bf29 100644 --- a/server/workspace-reports.js +++ b/server/workspace-reports.js @@ -19,7 +19,9 @@ ===================================================================== */ import express from "express"; -import { db, protokolliere, echteIp, sitzungLesen } from "./workspace.js"; +import { + db, protokolliere, echteIp, sitzungLesen, betreuteIds, darfCreator, +} from "./workspace.js"; export const reportRouter = express.Router(); @@ -29,8 +31,11 @@ const tagOffset = (tage) => new Date(Date.now() - tage * 86400_000).toISOString( function angemeldet(req, res, next) { const person = sitzungLesen(req); if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); - /* Scouts haben mit der Creator-Betreuung nichts zu tun. */ - if (person.rolle === "scout") return res.status(404).json({ fehler: "nicht_gefunden" }); + /* Ein Scout ohne zugeteilte Creator hat hier nichts zu suchen -- und + erfaehrt per 404 auch nicht, dass es den Bereich gibt. */ + if (person.rolle === "scout" && !betreuteIds(person).length) { + return res.status(404).json({ fehler: "nicht_gefunden" }); + } req.person = person; next(); } @@ -41,11 +46,21 @@ reportRouter.use("/workspace/api/report", angemeldet); (oder alle sehen), ein Creator immer nur sich selbst -- unabhaengig davon, was im Aufruf steht. */ function zielCreator(req) { - if (req.person.rolle !== "admin") return req.person.id; + if (req.person.rolle === "creator") return req.person.id; + const w = req.query.creator; - if (!w || w === "alle") return null; - const z = Number(w); - return Number.isInteger(z) && z > 0 ? z : null; + const gewuenscht = w && w !== "alle" && Number.isInteger(Number(w)) && Number(w) > 0 + ? Number(w) : null; + + if (req.person.rolle === "admin") return gewuenscht; + + /* Scout: darf nur ueber die eigenen Creator einen Report ziehen. Ein + fremder Wunsch wird nicht abgelehnt, sondern still auf den ersten + eigenen zurueckgesetzt -- so kann man den Parameter nicht dazu + benutzen, die Zuteilung anderer abzufragen. */ + const meine = betreuteIds(req.person); + if (gewuenscht && meine.includes(gewuenscht)) return gewuenscht; + return meine[0] ?? -1; } reportRouter.get("/workspace/api/report", (req, res) => { @@ -176,9 +191,18 @@ reportRouter.get("/workspace/api/report", (req, res) => { /* Liste der Creator fuer die Auswahl oben. */ reportRouter.get("/workspace/api/report/creator", (req, res) => { try { - if (req.person.rolle !== "admin") { + if (req.person.rolle === "creator") { return res.json({ creator: [{ id: req.person.id, name: req.person.name }] }); } + if (req.person.rolle === "scout") { + const ids = betreuteIds(req.person); + if (!ids.length) return res.json({ creator: [] }); + return res.json({ + creator: db().prepare( + `SELECT id, name FROM personen WHERE id IN (${ids.map(() => "?").join(",")}) + AND aktiv = 1 ORDER BY name`).all(...ids), + }); + } res.json({ creator: db().prepare( "SELECT id, name FROM personen WHERE rolle = 'creator' AND aktiv = 1 ORDER BY name").all(), @@ -222,6 +246,13 @@ reportRouter.post("/workspace/api/report/entscheidung", (req, res) => { } creator = z; } + } else if (req.person.rolle === "scout") { + /* Ein Scout haelt die Entscheidung fuer einen SEINER Creator fest, + nicht fuer sich selbst -- sonst haette die Aufgabe einen Scout + als "Creator" und liefe in jeder Auswertung falsch mit. */ + const w = Number(req.body?.creator_id); + creator = darfCreator(req.person, w) ? w : (betreuteIds(req.person)[0] ?? null); + if (!creator) return res.status(400).json({ fehler: "Dir ist kein Creator zugeteilt." }); } else { creator = req.person.id; } diff --git a/server/workspace-scouts.js b/server/workspace-scouts.js index e7effa8b..2941a6bb 100644 --- a/server/workspace-scouts.js +++ b/server/workspace-scouts.js @@ -16,7 +16,9 @@ ===================================================================== */ import express from "express"; -import { db, protokolliere, echteIp, sitzungLesen, personAnlegen } from "./workspace.js"; +import { + db, protokolliere, echteIp, sitzungLesen, personAnlegen, betreuungSetzen, +} from "./workspace.js"; export const scoutRouter = express.Router(); @@ -279,6 +281,13 @@ scoutRouter.post("/workspace/api/leads/:id/onboarding", gleicheHerkunft, (req, r jetzt(), req.person.id); } + /* Wer jemanden gefunden hat, betreut ihn weiter -- das ist der + ganze Sinn der Uebergabe. Das Management kann die Zustaendigkeit + jederzeit umhaengen. */ + if (lead.scout_id) { + betreuungSetzen(neu.id, lead.scout_id, { ...req.person, ip: echteIp(req) }); + } + protokolliere("onboarding_gestartet", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), detail: `Lead #${id} -> Creator ${name}`.slice(0, 120), diff --git a/server/workspace.js b/server/workspace.js index 9806d687..f4d0b3ee 100644 --- a/server/workspace.js +++ b/server/workspace.js @@ -222,6 +222,20 @@ export function db() { ); CREATE INDEX IF NOT EXISTS idx_eintraege_bereich ON eintraege (bereich, creator_id); + /* Betreuung: wer kuemmert sich um welchen Creator. + Scouts betreuen Creator wie das Management, aber nur die ihnen + zugeteilten -- deshalb braucht es eine ausdrueckliche Zuordnung + und keine Rollenregel. Ein Creator hat genau eine zustaendige + Person (PRIMARY KEY), damit nie unklar ist, wer gefragt ist. + Das Management sieht ohnehin alle und braucht keinen Eintrag. */ + CREATE TABLE IF NOT EXISTS betreuung ( + creator_id INTEGER PRIMARY KEY REFERENCES personen(id) ON DELETE CASCADE, + betreuer_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_betreuung_betreuer ON betreuung (betreuer_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 @@ -407,11 +421,11 @@ const GESCHUETZT = { "/workspace/start.html": null, "/workspace/aufgaben.html": null, "/workspace/personen.html": ["admin"], - "/workspace/profil.html": ["admin", "creator"], + "/workspace/profil.html": ["admin", "creator", "scout"], "/workspace/kalender.html": null, "/workspace/dateien.html": null, - "/workspace/bereich.html": ["admin", "creator"], - "/workspace/report.html": ["admin", "creator"], + "/workspace/bereich.html": ["admin", "creator", "scout"], + "/workspace/report.html": ["admin", "creator", "scout"], "/workspace/scouting.html": ["admin", "scout"], "/workspace/calls.html": null, }; @@ -523,6 +537,66 @@ export function codeErzeugen(gruppen = 4, laenge = 4) { person_id, las sich der Eintrag so, als haette sie sich selbst angelegt. Wer betroffen ist, steht im Text. Ohne Akteur (Kommandozeile) bleibt das Feld leer. */ +/* ===================================================================== + Betreuung — wer darf sich um welchen Creator kuemmern. + + Scouts betreuen Creator wie das Management, aber nur die ihnen + zugeteilten. Diese Regel steht bewusst NUR hier: Sie wird von sechs + Modulen gebraucht (Profile, Bereiche, Reports, Aufgaben, Kalender, + Dateien), und eine Rechteregel, die an sechs Stellen steht, ist eine + Rechteregel, die irgendwann an fuenf Stellen stimmt. + ===================================================================== */ + +/* Ids der Creator, die diese Person betreut. Fuer alle anderen Rollen + leer: Das Management sieht ohnehin alles, ein Creator sich selbst. */ +export function betreuteIds(person) { + if (!person || person.rolle !== "scout") return []; + try { + return db().prepare("SELECT creator_id FROM betreuung WHERE betreuer_id = ?") + .all(person.id).map((z) => z.creator_id); + } catch { + return []; // im Zweifel nichts sehen, nie mehr + } +} + +/* 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. */ +export function betreutWo(person, spalte) { + const ids = betreuteIds(person); + if (!ids.length) return null; + return { wo: `${spalte} IN (${ids.map(() => "?").join(",")})`, werte: ids }; +} + +/* Darf diese Person den Bereich dieses Creators sehen und bearbeiten? */ +export function darfCreator(person, creatorId) { + if (!person || !creatorId) return false; + if (person.rolle === "admin") return true; + if (person.rolle === "creator") return person.id === Number(creatorId); + return betreuteIds(person).includes(Number(creatorId)); +} + +/* Zustaendige Person setzen oder entfernen (null loest die Zuordnung). */ +export function betreuungSetzen(creatorId, betreuerId, akteur = null) { + const d = db(); + if (betreuerId === null) { + d.prepare("DELETE FROM betreuung WHERE creator_id = ?").run(creatorId); + } else { + d.prepare(` + INSERT INTO betreuung (creator_id, betreuer_id, seit, gesetzt_von) + VALUES (?,?,?,?) + ON CONFLICT(creator_id) DO UPDATE SET + betreuer_id = excluded.betreuer_id, + seit = excluded.seit, + gesetzt_von = excluded.gesetzt_von`).run( + creatorId, betreuerId, new Date().toISOString(), akteur?.id ?? null); + } + protokolliere("betreuung_gesetzt", { + personId: akteur?.id ?? null, rolle: akteur?.rolle ?? null, ip: akteur?.ip ?? null, + detail: `Creator #${creatorId} -> ${betreuerId === null ? "niemand" : "#" + betreuerId}`, + }); +} + export function personAnlegen(name, rolle, akteur = null) { if (!ROLLEN.has(rolle)) throw new Error(`Unbekannte Rolle: ${rolle}`); const code = codeErzeugen(); diff --git a/workspace/assets/css/personen.css b/workspace/assets/css/personen.css index 4b249baa..cfca322a 100644 --- a/workspace/assets/css/personen.css +++ b/workspace/assets/css/personen.css @@ -71,3 +71,23 @@ .person__knoepfe { justify-content: flex-start; margin-top: 10px; } .protokoll-zeile { grid-template-columns: 1fr; gap: 2px; padding: 10px; } } + +/* ---------- Zuständigkeit ------------------------------------------------ + Wer betreut welchen Creator. Bewusst direkt in der Personenzeile und + nicht auf einer eigenen Seite: Es ist eine Eigenschaft der Person, kein + eigener Vorgang. */ +.betreuung { display: flex; align-items: center; gap: 9px; margin-top: 9px; flex-wrap: wrap; } +.betreuung__schild { + font-size: .7rem; font-weight: 700; letter-spacing: .07em; + text-transform: uppercase; color: var(--text-still); +} +.betreuung__wahl { + padding: 6px 10px; min-width: 165px; + background: rgba(255, 255, 255, .05); + border: 1px solid var(--rand); border-radius: var(--radius-klein); + color: var(--text); font: inherit; font-size: .84rem; +} +.betreuung__wahl:focus { + outline: none; border-color: rgba(63, 189, 245, .65); + box-shadow: 0 0 0 3px rgba(63, 189, 245, .14); +} diff --git a/workspace/assets/js/personen.js b/workspace/assets/js/personen.js index 90e7a29f..b705f9dd 100644 --- a/workspace/assets/js/personen.js +++ b/workspace/assets/js/personen.js @@ -57,6 +57,8 @@ /* ---------- Liste ----------------------------------------------------- */ + let betreuerListe = []; + function zeile(p) { const k = el('div', 'person'); k.dataset.aktiv = p.aktiv ? 'ja' : 'nein'; @@ -72,8 +74,44 @@ z.append(el('span', null, `letzte Anmeldung: ${datum(p.letzter_login)}`)); z.append(el('span', null, `offene Aufgaben: ${p.offene_aufgaben}`)); if (p.sitzungen) z.append(el('span', null, `${p.sitzungen} offene Sitzung(en)`)); + if (p.rolle === 'scout' && p.betreut_anzahl) { + z.append(el('span', null, `betreut ${p.betreut_anzahl} Creator`)); + } links.append(z); + /* Zuständigkeit. Steht nur bei Creator und nur dann, wenn es + überhaupt einen Scout gibt, dem man sie geben könnte. */ + if (p.rolle === 'creator' && betreuerListe.length) { + const b = el('div', 'betreuung'); + const s = el('label', 'betreuung__schild', 'Betreut von'); + s.htmlFor = 'betreuer-' + p.id; + const w = el('select', 'betreuung__wahl'); + w.id = 'betreuer-' + p.id; + const leer = document.createElement('option'); + leer.value = ''; leer.textContent = 'nur Management'; + w.append(leer); + for (const s2 of betreuerListe) { + const o = document.createElement('option'); + o.value = String(s2.id); o.textContent = s2.name; + w.append(o); + } + w.value = p.betreuer_id ? String(p.betreuer_id) : ''; + w.addEventListener('change', async () => { + w.disabled = true; melde(''); + try { + const a = await hole(`/workspace/api/verwaltung/betreuung/${p.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ betreuer_id: w.value || null }), + }); + if (!a.ok) { melde((await a.json().catch(() => ({}))).fehler || 'Ging nicht.'); return; } + await laden(); + } catch { /* umgeleitet */ } finally { w.disabled = false; } + }); + b.append(s, w); + links.append(b); + } + const knoepfe = el('div', 'person__knoepfe'); knoepfe.append(tuKnopf('Neuer Code', async () => { const eigen = p.id === ich.id; @@ -123,7 +161,8 @@ try { const a = await hole('/workspace/api/verwaltung/personen'); if (!a.ok) { melde('Personen konnten nicht geladen werden.'); return; } - const { personen } = await a.json(); + const { personen, betreuer } = await a.json(); + betreuerListe = betreuer || []; ziel.textContent = ''; ziel.setAttribute('aria-busy', 'false'); for (const p of personen) ziel.append(zeile(p)); diff --git a/workspace/assets/js/profil.js b/workspace/assets/js/profil.js index 4b0dafa1..d23efd43 100644 --- a/workspace/assets/js/profil.js +++ b/workspace/assets/js/profil.js @@ -97,18 +97,25 @@ if (!a.ok) { location.assign('/workspace/'); return; } ich = await a.json(); } catch { return; } - if (ich.rolle === 'scout') { location.assign('/workspace/start.html'); return; } + /* Scouts betreuen ihre zugeteilten Creator wie das Management -- + sie sehen aber nur die management-internen Felder nicht. */ $('wer').textContent = ich.name + ' · ' + ich.rolle; let liste; try { liste = await (await hole('/workspace/api/profil')).json(); } catch { return; } - if (ich.rolle === 'admin') { + if (ich.rolle === 'admin' || ich.rolle === 'scout') { if (!liste.creator.length) { - $('unterzeile').textContent = - 'Es gibt noch keinen Creator. Lege zuerst unter Personen & Zugänge einen an.'; + $('unterzeile').textContent = ich.rolle === 'scout' + ? 'Dir ist noch kein Creator zugeteilt. Das Management legt fest, wen du betreust.' + : 'Es gibt noch keinen Creator. Lege zuerst unter Personen & Zugänge einen an.'; return; } + if (ich.rolle === 'scout') { + $('unterzeile').textContent = liste.creator.length === 1 + ? 'Der Creator, den du betreust.' + : `Die ${liste.creator.length} Creator, die du betreust.`; + } $('auswahl-block').hidden = false; for (const c of liste.creator) { const o = document.createElement('option'); diff --git a/workspace/assets/js/report.js b/workspace/assets/js/report.js index b7c3cc8a..6eadd914 100644 --- a/workspace/assets/js/report.js +++ b/workspace/assets/js/report.js @@ -200,7 +200,6 @@ if (!a.ok) { location.assign('/workspace/'); return; } ich = await a.json(); } catch { return; } - if (ich.rolle === 'scout') { location.assign('/workspace/start.html'); return; } $('wer').textContent = ich.name + ' · ' + ich.rolle; try { @@ -210,13 +209,22 @@ const alle = document.createElement('option'); alle.value = ''; alle.textContent = 'Alle Creator'; $('f-creator').append(alle); + } else if (ich.rolle === 'scout') { + /* Ein Scout waehlt zwischen SEINEN Creator -- "alle" gibt es + fuer ihn nicht, das waere die Gesamtuebersicht. */ + if (!creator.length) { + $('unterzeile').textContent = + 'Dir ist noch kein Creator zugeteilt. Das Management legt fest, wen du betreust.'; + return; + } + $('creator-block').hidden = false; } for (const c of creator) { const o = document.createElement('option'); o.value = String(c.id); o.textContent = c.name; $('f-creator').append(o); } - if (ich.rolle !== 'admin') $('f-creator').value = String(ich.id); + if (ich.rolle === 'creator') $('f-creator').value = String(ich.id); } catch { return; } $('f-creator').addEventListener('change', laden); diff --git a/workspace/assets/js/start.js b/workspace/assets/js/start.js index 1ad9291d..c07d60d5 100644 --- a/workspace/assets/js/start.js +++ b/workspace/assets/js/start.js @@ -9,7 +9,7 @@ const ROLLENTEXT = { admin: 'Management · Gesamtübersicht und Freigaben', creator: 'Creator · dein eigener Bereich', - scout: 'Scout · deine Pipeline und Kontakte', + scout: 'Scout · deine Pipeline und die Creator, die du betreust', }; /* Bereiche aus dem Konzept, mit der Phase aus dem Umsetzungsplan. @@ -18,17 +18,17 @@ const BEREICHE = [ { name: 'Dashboard', phase: 1, rollen: ['admin', 'creator', 'scout'] }, { name: 'Personen & Zugänge', phase: 1, rollen: ['admin'], ziel: 'personen.html' }, - { name: 'Creator-Profile', phase: 1, rollen: ['admin'], ziel: 'profil.html' }, + { name: 'Creator-Profile', phase: 1, rollen: ['admin', 'scout'], ziel: 'profil.html' }, { name: 'Mein Profil', phase: 1, rollen: ['creator'], ziel: 'profil.html' }, { name: 'Aufgaben', phase: 1, rollen: ['admin', 'creator', 'scout'], ziel: 'aufgaben.html' }, { name: 'Kalender', phase: 1, rollen: ['admin', 'creator', 'scout'], ziel: 'kalender.html' }, { name: 'Dateien', phase: 1, rollen: ['admin', 'creator', 'scout'], ziel: 'dateien.html' }, - { name: 'LIVE-Analyse', phase: 2, rollen: ['admin', 'creator'], ziel: 'bereich.html?b=live' }, - { name: 'Content-Planung', phase: 2, rollen: ['admin', 'creator'], ziel: 'bereich.html?b=content' }, - { name: 'Technik', phase: 2, rollen: ['admin', 'creator'], ziel: 'bereich.html?b=technik' }, - { name: 'Community', phase: 2, rollen: ['admin', 'creator'], ziel: 'bereich.html?b=community' }, - { name: 'Schutz & Regeln', phase: 2, rollen: ['admin', 'creator'], ziel: 'bereich.html?b=schutz' }, - { name: 'Reports & Review', phase: 2, rollen: ['admin', 'creator'], ziel: 'report.html' }, + { name: 'LIVE-Analyse', phase: 2, rollen: ['admin', 'creator', 'scout'], ziel: 'bereich.html?b=live' }, + { name: 'Content-Planung', phase: 2, rollen: ['admin', 'creator', 'scout'], ziel: 'bereich.html?b=content' }, + { name: 'Technik', phase: 2, rollen: ['admin', 'creator', 'scout'], ziel: 'bereich.html?b=technik' }, + { name: 'Community', phase: 2, rollen: ['admin', 'creator', 'scout'], ziel: 'bereich.html?b=community' }, + { name: 'Schutz & Regeln', phase: 2, rollen: ['admin', 'creator', 'scout'], ziel: 'bereich.html?b=schutz' }, + { name: 'Reports & Review', phase: 2, rollen: ['admin', 'creator', 'scout'], ziel: 'report.html' }, { name: 'Calls & Protokolle', phase: 3, rollen: ['admin', 'creator', 'scout'], ziel: 'calls.html' }, { name: 'Scout-Pipeline', phase: 3, rollen: ['admin', 'scout'], ziel: 'scouting.html' }, { name: 'Automationen', phase: 4, rollen: ['admin'] },