Files
dogfather-universe/server/workspace-profil.js
T
DogFatherGit 8bfd2ae166 Workspace: Creator-Profile (Onboarding aus dem Konzept)
/workspace/profil.html -- Stammdaten, Ziele und 90-Tage-Plan, genau nach
Seite 5 des Konzepts. Management waehlt oben den Creator aus, ein Creator
sieht nur sein eigenes Profil.

Sicherheitskern ist das Feld admin_notiz. Das Konzept fordert "private
Admin-Notizen separat". Die Notiz wird deshalb nicht im Browser
ausgeblendet, sondern gar nicht erst gesendet: Die Spaltenliste der
Abfrage haengt an der Rolle (FELDER_OFFEN / FELDER_ADMIN). Dasselbe gilt
fuer Plan-Start und Review-Termin.

Geprueft:
- In der kompletten Rohantwort an den Creator kommt der Inhalt der
  internen Notiz 0-mal vor
- Creator auf fremdes Profil: 404 (lesend wie schreibend)
- Scout auf ein Profil: 404, profil.html leitet ihn weg
- Creator setzt admin_notiz selbst: wird stillschweigend ignoriert,
  der Inhalt bleibt unveraendert
- Profil einer Nicht-Creator-Person: 404

Dabei ist ein aelterer Fehler aufgefallen: /api/ich lieferte nur Name und
Rolle, nicht die eigene Nummer. Dadurch rief die Profilseite eines
Creators /api/profil/undefined auf und blieb leer. Derselbe Fehler machte
in der Personenverwaltung den Selbstvergleich unwirksam -- beim eigenen
Eintrag erschien ein "Sperren"-Knopf, den der Server dann ablehnte.
/api/ich liefert jetzt zusaetzlich die id.

Im Protokoll landen nur die Feldnamen, nie die Inhalte: Im Profil stehen
persoenliche Angaben, die nicht zusaetzlich im Audit-Log auftauchen
sollen.
2026-08-28 00:17:53 +02:00

178 lines
7.3 KiB
JavaScript

/* =====================================================================
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 { db, protokolliere, echteIp, sitzungLesen } 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? Scouts gar keins -- im Konzept haben sie mit
der Creator-Betreuung nichts zu tun. */
function darf(person, zielId) {
if (person.rolle === "admin") return "voll";
if (person.rolle === "creator" && person.id === zielId) return "eigen";
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 });
}
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,
(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'
ORDER BY p.aktiv DESC, p.name`).all(),
eigen: false,
});
} 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" });
}
});