/* ===================================================================== workspace-profil.js — Creator-Profile (Onboarding-Teil des Konzepts): Stammdaten, Ziele, 90-Tage-Plan. Sicherheitskern dieses Moduls ist das Feld `admin_notiz`. Das Konzept fordert "private Admin-Notizen separat" -- diese Notiz darf ein Creator unter keinen Umständen sehen, auch nicht als Nebeneffekt einer Sammelabfrage. Deshalb wird sie nicht nur im Browser ausgeblendet, sondern gar nicht erst mitgeschickt: Die Spaltenliste der Abfrage haengt an der Rolle (siehe FELDER_OFFEN / FELDER_ADMIN). ===================================================================== */ import express from "express"; import { sichtbareCreatorIds, db, protokolliere, echteIp, sitzungLesen, darfCreator, betreuteIds, istLeitung, istDogFather, } from "./workspace.js"; export const profilRouter = express.Router(); /* Felder, die auch der Creator selbst sehen und ändern darf. */ const FELDER_OFFEN = [ "handles", "nische", "live_zeiten", "technik", "ziel_live", "ziel_content", "ziel_community", "ziel_technik", "plan_prio1", "plan_prio2", "plan_prio3", ]; /* Nur fürs Management. `admin_notiz` ist der eigentliche Grund für die Trennung, die Termine stehen hier, weil sie zur Steuerung gehören. */ const FELDER_ADMIN = ["plan_start", "naechster_review", "admin_notiz"]; const DATUMSFELDER = new Set(["plan_start", "naechster_review"]); const TEXT_MAX = 2000; const jetzt = () => new Date().toISOString(); function angemeldet(req, res, next) { const person = sitzungLesen(req); if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); req.person = person; next(); } function gleicheHerkunft(req, res, next) { const herkunft = req.get("origin"); if (!herkunft) return next(); let erlaubt; try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; } if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" }); next(); } profilRouter.use("/workspace/api/profil", angemeldet); /* 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) { /* "voll" bekommt allein DogFather (03.09.2026). Vorher stand hier istLeitung -- und damit kam jede Managerin an JEDES Creator-Profil, auch an das einer fremden Managerin, samt der internen Notiz. Es reichte, die Nummer in die Adresse zu schreiben. Ein Manager behaelt volle Rechte an SEINEN Creators: darfCreator prueft die Zuteilung, und "voll" bleibt es dort auch -- er soll die Verwaltungsfelder seiner eigenen Leute pflegen koennen. */ if (istDogFather(person)) return "voll"; if (person.rolle === "creator") return person.id === zielId ? "eigen" : null; if (person.rolle === "manager") return darfCreator(person, zielId) ? "voll" : null; if (person.rolle === "scout") return darfCreator(person, zielId) ? "betreuend" : null; return null; } /* ---------- Lesen ------------------------------------------------------- */ profilRouter.get("/workspace/api/profil/:id", (req, res) => { try { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); const recht = darf(req.person, id); /* 404 statt 403: Wer kein Recht hat, soll nicht erfahren, ob es die Person überhaupt gibt. */ if (!recht) return res.status(404).json({ fehler: "nicht_gefunden" }); const person = db().prepare( "SELECT id, name, rolle, aktiv, erstellt, letzter_login FROM personen WHERE id = ?").get(id); if (!person || person.rolle !== "creator") { return res.status(404).json({ fehler: "nicht_gefunden" }); } const felder = recht === "voll" ? [...FELDER_OFFEN, ...FELDER_ADMIN] : FELDER_OFFEN; const reihe = db().prepare( `SELECT ${felder.join(", ")}, geaendert FROM profile WHERE person_id = ?`).get(id); res.json({ person, /* Kein Profil angelegt = leeres Objekt, kein Fehler. */ profil: reihe || Object.fromEntries(felder.map((f) => [f, null])), darf_alles: recht === "voll", }); } catch (fehler) { console.error("[workspace] Profil lesen:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /* Liste aller Creator -- fürs Management, zur Auswahl. */ profilRouter.get("/workspace/api/profil", (req, res) => { try { if (req.person.rolle === "creator") { return res.json({ creator: [{ id: req.person.id, name: req.person.name }], eigen: true }); } /* Ein Scout bekommt genau die Creator, die er betreut -- und ohne Zuteilung eine leere Liste statt einer Fehlermeldung. */ /* Zuteilung statt Rolle (03.09.2026): Eine Managerin sah hier die Profile ALLER Creator. Jetzt bekommt sie genau ihre -- eigene und die ihrer Scouts. */ let nur = ""; let werte = []; const erlaubt = sichtbareCreatorIds(req.person); if (erlaubt !== null) { if (!erlaubt.length) { return res.json({ creator: [], eigen: false, betreuend: req.person.rolle !== "creator" }); } nur = ` AND p.id IN (${erlaubt.map(() => "?").join(",")})`; werte = erlaubt; } else if (false) { return res.status(404).json({ fehler: "nicht_gefunden" }); } res.json({ creator: db().prepare(` SELECT p.id, p.name, p.aktiv, p.letzter_login, (SELECT COUNT(*) FROM aufgaben a 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'${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); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /* ---------- Speichern ---------------------------------------------------- */ profilRouter.put("/workspace/api/profil/:id", gleicheHerkunft, (req, res) => { try { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); const recht = darf(req.person, id); if (!recht) return res.status(404).json({ fehler: "nicht_gefunden" }); const person = db().prepare("SELECT id, rolle FROM personen WHERE id = ?").get(id); if (!person || person.rolle !== "creator") return res.status(404).json({ fehler: "nicht_gefunden" }); /* Ein Creator kann die Verwaltungsfelder nicht setzen -- sie stehen gar nicht erst in der Liste der zulässigen Felder. Was er trotzdem mitschickt, wird stillschweigend ignoriert. */ const erlaubt = recht === "voll" ? [...FELDER_OFFEN, ...FELDER_ADMIN] : FELDER_OFFEN; const werte = {}; for (const feld of erlaubt) { if (req.body?.[feld] === undefined) continue; let wert = req.body[feld]; if (wert === null) { werte[feld] = null; continue; } wert = String(wert).trim(); if (!wert) { werte[feld] = null; continue; } if (DATUMSFELDER.has(feld)) { if (!/^\d{4}-\d{2}-\d{2}$/.test(wert) || Number.isNaN(Date.parse(wert))) { return res.status(400).json({ fehler: `Kein gültiges Datum: ${feld}` }); } } else if (wert.length > TEXT_MAX) { return res.status(400).json({ fehler: "Ein Feld ist zu lang." }); } werte[feld] = wert; } if (!Object.keys(werte).length) return res.status(400).json({ fehler: "nichts_zu_speichern" }); const spalten = Object.keys(werte); /* UPSERT: Beim ersten Speichern entsteht die Zeile, danach wird sie aktualisiert -- ohne vorher prüfen zu müssen, ob es sie gibt. */ db().prepare(` INSERT INTO profile (person_id, ${spalten.join(", ")}, geaendert, geaendert_von) VALUES (?, ${spalten.map(() => "?").join(", ")}, ?, ?) ON CONFLICT(person_id) DO UPDATE SET ${spalten.map((s) => `${s} = excluded.${s}`).join(", ")}, geaendert = excluded.geaendert, geaendert_von = excluded.geaendert_von `).run(id, ...spalten.map((s) => werte[s]), jetzt(), req.person.id); protokolliere("profil_gespeichert", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), /* Nur die Feldnamen, nie die Inhalte -- im Profil stehen persönliche Angaben, die nicht zusätzlich im Protokoll landen sollen. */ detail: `Creator #${id}: ${spalten.join(",")}`.slice(0, 120), }); res.json({ ok: true }); } catch (fehler) { console.error("[workspace] Profil speichern:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } });