/* ===================================================================== workspace-startcheck.js — Erstanalyse (Konzept, Seite 5, Block 03). "Die Betreuung startet strukturiert: Ausgangslage verstehen, Ziele festlegen und daraus konkrete Arbeitspakete bauen." Der letzte Satz ist der entscheidende. Ein Check, der mit einer Note endet, ist eine Beurteilung. Ein Check, der mit Aufgaben endet, ist Betreuung. Deshalb laesst sich jeder Punkt mit Handlungsbedarf direkt in eine Aufgabe verwandeln -- dieselbe Linie wie beim Review ("endet mit einer Entscheidung") und beim Call ("endet mit To-dos"). Die Punkte stehen fest im Code, nicht in der Datenbank. Eine Erstanalyse, bei der jeder seine eigenen Punkte anlegt, ist keine Erstanalyse mehr -- man koennte zwei Creator nicht mehr vergleichen, und genau das ist ihr Zweck. ===================================================================== */ import express from "express"; import { sichtbareCreatorIds, db, protokolliere, echteIp, sitzungLesen, darfCreator, betreuteIds } from "./workspace.js"; export const startcheckRouter = express.Router(); /* Die vier Felder aus dem Deck, mit je vier Punkten. Bewusst knapp gehalten: Eine Liste, die man in einem Gespraech durchgehen kann. */ export const FELDER = [ { schluessel: "profil", name: "Profil & Auftritt", punkte: [ { schluessel: "bild", name: "Profilbild und Name", hinweis: "Erkennt man auf Anhieb, wer das ist?" }, { schluessel: "bio", name: "Beschreibung / Bio", hinweis: "Steht dort, was einen erwartet?" }, { schluessel: "kanaele", name: "Verlinkte Kanäle", hinweis: "Führen die Verweise irgendwohin – und stimmen sie noch?" }, { schluessel: "wiedererkennung", name: "Wiedererkennbarkeit", hinweis: "Farben, Bilder, Sprache – passt das zusammen?" }, ], }, { schluessel: "live", name: "LIVE-Struktur", punkte: [ { schluessel: "zeiten", name: "Feste Sendezeiten", hinweis: "Weiß die Community, wann sie kommen kann?" }, { schluessel: "ablauf", name: "Roter Faden im Stream", hinweis: "Gibt es einen erkennbaren Ablauf, oder passiert es einfach?" }, { schluessel: "begruessung", name: "Umgang mit neuen Zuschauern", hinweis: "Werden Neue angesprochen oder gehen sie unter?" }, { schluessel: "laenge", name: "Länge und Rhythmus", hinweis: "Passt die Dauer zur Energie – und wird sie durchgehalten?" }, ], }, { schluessel: "content", name: "Content-Muster", punkte: [ { schluessel: "formate", name: "Wiederkehrende Formate", hinweis: "Gibt es etwas, worauf man sich freuen kann?" }, { schluessel: "rhythmus", name: "Veröffentlichungsrhythmus", hinweis: "Regelmäßig oder in Schüben?" }, { schluessel: "einstiege", name: "Einstiege der ersten Sekunden", hinweis: "Bleibt man hängen oder scrollt man weiter?" }, { schluessel: "verwertung", name: "LIVE wird zu Content", hinweis: "Werden gute Momente aus dem Stream weiterverwendet?" }, ], }, { schluessel: "community", name: "Community & Modis", punkte: [ { schluessel: "stamm", name: "Stammzuschauer", hinweis: "Gibt es Namen, die immer wieder auftauchen?" }, { schluessel: "moderation", name: "Moderation besetzt", hinweis: "Ist jemand da, wenn es voll oder ungemütlich wird?" }, { schluessel: "regeln", name: "Regeln sichtbar", hinweis: "Weiß man, was gilt, bevor etwas passiert?" }, { schluessel: "stoerer", name: "Umgang mit Störern", hinweis: "Gibt es ein geübtes Vorgehen – oder wird improvisiert?" }, ], }, ]; const BEWERTUNGEN = new Set(["ok", "mittel", "handlung"]); const NOTIZ_MAX = 2000; /* Nachschlagewerk, damit ein Aufruf nicht irgendeinen Punkt erfinden kann. */ const GUELTIG = new Map(); for (const f of FELDER) { for (const p of f.punkte) GUELTIG.set(`${f.schluessel}:${p.schluessel}`, { feld: f, punkt: p }); } const PUNKTE_GESAMT = GUELTIG.size; 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(); } startcheckRouter.use("/workspace/api/startcheck", angemeldet); /* Lesen darf: das Management, der zustaendige Scout -- und der Creator selbst. Der Check ist Teil SEINES Entwicklungsplans ("jeder Creator bekommt einen eigenen, lebenden Entwicklungsplan"), nicht ein Urteil hinter seinem Ruecken. Wer etwas festhalten will, das der Creator nicht sehen soll, hat dafuer die interne Notiz im Profil. Aendern duerfen ihn nur Management und Scout: Es ist eine Fremdeinschaetzung, sonst waere es eine Selbsteinschaetzung. */ function darfLesen(person, creatorId) { if (person.rolle === "creator") return person.id === creatorId; return darfCreator(person, creatorId); } const darfAendern = (person, creatorId) => person.rolle !== "creator" && darfCreator(person, creatorId); /* ---------- Auswahl der Creator ----------------------------------------- */ startcheckRouter.get("/workspace/api/startcheck/creator", (req, res) => { try { /* Die Auswahl folgt der SICHT, nicht dem Angemeldeten -- dieselbe Regel wie bei den Daten dieses Moduls. Zwei Antworten auf eine Frage waren der Grund, warum eine fremde Sicht nur auf der halben Seite ankam (11.09.2026). */ const person = req.sicht || req.person; if (person.rolle === "creator") { return res.json({ creator: [{ id: person.id, name: person.name }], eigen: true }); } /* Nicht die ROLLE, sondern die ZUTEILUNG entscheidet (03.09.2026). Hier stand eine Abfrage ueber ALLE -- eine Managerin bekam damit auch Namen und Daten von Creators, die ihr nie zugeteilt waren. sichtbarePersonenIds/sichtbareCreatorIds liefern null fuer DogFather (= keine Einschraenkung) und sonst genau die erlaubten Nummern. */ let nur = ""; let werte = []; const erlaubt = sichtbareCreatorIds(person); if (erlaubt !== null) { if (!erlaubt.length) return res.json({ creator: [], eigen: false }); nur = ` AND id IN (${erlaubt.map(() => "?").join(",")})`; werte = erlaubt; } res.json({ creator: db().prepare( `SELECT id, name FROM personen WHERE rolle = 'creator' AND aktiv = 1${nur} ORDER BY name`).all(...werte), eigen: false, }); } catch (fehler) { console.error("[workspace] Start-Check Creator:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /* ---------- Lesen -------------------------------------------------------- */ startcheckRouter.get("/workspace/api/startcheck/:id", (req, res) => { try { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); if (!darfLesen(req.person, id)) return res.status(404).json({ fehler: "nicht_gefunden" }); const person = db().prepare( "SELECT id, name, rolle FROM personen WHERE id = ?").get(id); if (!person || person.rolle !== "creator") { return res.status(404).json({ fehler: "nicht_gefunden" }); } const zeilen = db().prepare(` SELECT s.feld, s.punkt, s.bewertung, s.notiz, s.geaendert, p.name AS von FROM startcheck s LEFT JOIN personen p ON p.id = s.geaendert_von WHERE s.creator_id = ?`).all(id); const karte = new Map(zeilen.map((z) => [`${z.feld}:${z.punkt}`, z])); const felder = FELDER.map((f) => ({ schluessel: f.schluessel, name: f.name, punkte: f.punkte.map((p) => { const z = karte.get(`${f.schluessel}:${p.schluessel}`); return { schluessel: p.schluessel, name: p.name, hinweis: p.hinweis, bewertung: z?.bewertung ?? null, notiz: z?.notiz ?? null, geaendert: z?.geaendert ?? null, von: z?.von ?? null, }; }), })); const geprueft = zeilen.filter((z) => BEWERTUNGEN.has(z.bewertung)).length; res.json({ creator: { id: person.id, name: person.name }, felder, geprueft, gesamt: PUNKTE_GESAMT, handlungsbedarf: zeilen.filter((z) => z.bewertung === "handlung").length, darf_aendern: darfAendern(req.person, id), }); } catch (fehler) { console.error("[workspace] Start-Check lesen:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /* ---------- Einen Punkt setzen ------------------------------------------- */ startcheckRouter.put("/workspace/api/startcheck/:id", gleicheHerkunft, (req, res) => { try { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); /* 404 statt 403 auch hier: Wer nicht lesen darf, soll nicht ueber die Fehlermeldung erfahren, dass es die Person gibt. */ if (!darfLesen(req.person, id)) return res.status(404).json({ fehler: "nicht_gefunden" }); if (!darfAendern(req.person, id)) { return res.status(403).json({ fehler: "Den Start-Check füllt die Betreuung aus." }); } const feld = String(req.body?.feld ?? ""); const punkt = String(req.body?.punkt ?? ""); if (!GUELTIG.has(`${feld}:${punkt}`)) { return res.status(400).json({ fehler: "Diesen Punkt gibt es nicht." }); } const b = req.body?.bewertung; const bewertung = b === null || b === "" ? null : String(b); if (bewertung !== null && !BEWERTUNGEN.has(bewertung)) { return res.status(400).json({ fehler: "Unbekannte Bewertung." }); } const notiz = String(req.body?.notiz ?? "").trim().slice(0, NOTIZ_MAX) || null; /* Ein Punkt ohne Bewertung und ohne Notiz ist kein Eintrag, sondern ein zurueckgenommener -- der verschwindet, statt als leere Zeile stehen zu bleiben. */ if (bewertung === null && !notiz) { db().prepare("DELETE FROM startcheck WHERE creator_id = ? AND feld = ? AND punkt = ?") .run(id, feld, punkt); } else { db().prepare(` INSERT INTO startcheck (creator_id, feld, punkt, bewertung, notiz, geaendert, geaendert_von) VALUES (?,?,?,?,?,?,?) ON CONFLICT(creator_id, feld, punkt) DO UPDATE SET bewertung = excluded.bewertung, notiz = excluded.notiz, geaendert = excluded.geaendert, geaendert_von = excluded.geaendert_von`).run( id, feld, punkt, bewertung, notiz, jetzt(), req.person.id); } protokolliere("startcheck_gesetzt", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), detail: `Creator #${id} ${feld}/${punkt} = ${bewertung ?? "offen"}`.slice(0, 120), }); res.json({ ok: true }); } catch (fehler) { console.error("[workspace] Start-Check setzen:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /* ---------- Aus einem Befund eine Aufgabe machen ------------------------- "... und daraus konkrete Arbeitspakete bauen." Ohne diesen Schritt waere der Check eine Beurteilung statt Betreuung. */ startcheckRouter.post("/workspace/api/startcheck/:id/aufgabe", gleicheHerkunft, (req, res) => { try { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); if (!darfLesen(req.person, id)) return res.status(404).json({ fehler: "nicht_gefunden" }); if (!darfAendern(req.person, id)) { return res.status(403).json({ fehler: "nicht_erlaubt" }); } const feld = String(req.body?.feld ?? ""); const punkt = String(req.body?.punkt ?? ""); const eintrag = GUELTIG.get(`${feld}:${punkt}`); if (!eintrag) return res.status(400).json({ fehler: "Diesen Punkt gibt es nicht." }); const titel = String(req.body?.titel ?? "").trim().slice(0, 160); if (titel.length < 3) return res.status(400).json({ fehler: "Was genau ist zu tun?" }); const frist = String(req.body?.frist ?? "").trim(); if (frist && (!/^\d{4}-\d{2}-\d{2}$/.test(frist) || Number.isNaN(Date.parse(frist)))) { return res.status(400).json({ fehler: "Frist ist kein gültiges Datum." }); } const gespeichert = db().prepare( "SELECT bewertung FROM startcheck WHERE creator_id = ? AND feld = ? AND punkt = ?") .get(id, feld, punkt); const prio = gespeichert?.bewertung === "handlung" ? "hoch" : "mittel"; const { lastInsertRowid } = db().prepare(` INSERT INTO aufgaben (titel, beschreibung, status, prioritaet, creator_id, verantwortlich_id, frist, erstellt, erstellt_von) VALUES (?,?,'offen',?,?,?,?,?,?)`).run( titel, `Aus dem Start-Check: ${eintrag.feld.name} – ${eintrag.punkt.name}`, prio, id, id, frist || null, jetzt(), req.person.id); protokolliere("startcheck_aufgabe", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), detail: `#${lastInsertRowid} ${titel}`.slice(0, 120), }); res.status(201).json({ id: Number(lastInsertRowid), prioritaet: prio }); } catch (fehler) { console.error("[workspace] Start-Check Aufgabe:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } });