/* ===================================================================== workspace-scouts.js — Scout-CRM (Konzept, Seite 15). Pipeline vom ersten Kontakt bis zur Uebergabe, Qualifizierung und Follow-ups. Der Merksatz der Seite ist hier die Sicherheitsregel: "Scouts sehen ihre Pipeline -- die Admin-Rolle die Gesamtuebersicht -- Creator keine Scout-internen Daten." Creator bekommen deshalb 404, nicht 403: Sie sollen nicht einmal erfahren, dass es diesen Bereich gibt. Besonderheit am Ende der Pipeline: Aus einem uebergebenen Lead kann das Management mit einem Klick einen Creator anlegen -- "Creator- Onboarding starten" aus dem Deck. Der Zugangscode wird dabei einmal angezeigt, wie in der Personenverwaltung. ===================================================================== */ import express from "express"; import { db, protokolliere, echteIp, sitzungLesen, personAnlegen, betreuungSetzen, istLeitung, } from "./workspace.js"; export const scoutRouter = express.Router(); /* Reihenfolge ist die Pipeline aus dem Konzept. */ export const STUFEN = { neu: "Neu entdeckt", angesprochen: "Angesprochen", gespraech: "Gespräch geplant", interessiert: "Interessiert", uebergeben: "Übergeben", abgelehnt: "Abgelehnt", }; const WEITER = { neu: "angesprochen", angesprochen: "gespraech", gespraech: "interessiert", interessiert: "uebergeben" }; const PRIORITAETEN = ["hoch", "mittel", "niedrig"]; const NAME_MAX = 120; const TEXT_MAX = 3000; const jetzt = () => new Date().toISOString(); function angemeldet(req, res, next) { const person = sitzungLesen(req); if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" }); if (person.rolle === "creator") return res.status(404).json({ fehler: "nicht_gefunden" }); 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(); } scoutRouter.use("/workspace/api/leads", angemeldet); function sichtbar(person) { if (istLeitung(person)) return { wo: "1=1", werte: [] }; return { wo: "l.scout_id = ?", werte: [person.id] }; } const SPALTEN = ` l.id, l.name, l.plattform, l.handle, l.status, l.prioritaet, l.aktivitaet, l.potenzial, l.notizen, l.letzte_nachricht, l.naechster_followup, l.scout_id, l.erstellt, l.geaendert, l.uebergeben_am, l.creator_id, ps.name AS scout_name, pc.name AS creator_name`; const VERBUND = ` FROM leads l LEFT JOIN personen ps ON ps.id = l.scout_id LEFT JOIN personen pc ON pc.id = l.creator_id`; /* ---------- Liste ------------------------------------------------------- */ scoutRouter.get("/workspace/api/leads", (req, res) => { try { const { wo, werte } = sichtbar(req.person); const heute = jetzt().slice(0, 10); res.json({ stufen: STUFEN, heute, leads: db().prepare(` SELECT ${SPALTEN} ${VERBUND} WHERE ${wo} ORDER BY CASE l.status WHEN 'neu' THEN 0 WHEN 'angesprochen' THEN 1 WHEN 'gespraech' THEN 2 WHEN 'interessiert' THEN 3 WHEN 'uebergeben' THEN 4 ELSE 5 END, CASE l.prioritaet WHEN 'hoch' THEN 0 WHEN 'mittel' THEN 1 ELSE 2 END, CASE WHEN l.naechster_followup IS NULL THEN 1 ELSE 0 END, l.naechster_followup, l.id DESC`).all(...werte), }); } catch (fehler) { console.error("[workspace] Leads lesen:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /* ---------- Prüfen ------------------------------------------------------- */ function pruefe(körper, { neu }) { const fehler = []; const aus = {}; const text = (feld, max, pflicht = false) => { if (!neu && körper[feld] === undefined) return; const t = String(körper[feld] ?? "").trim(); if (pflicht && t.length < 2) { fehler.push("Name fehlt."); return; } if (t.length > max) { fehler.push(`Feld ${feld} ist zu lang.`); return; } aus[feld] = t || null; }; text("name", NAME_MAX, neu); text("plattform", 60); text("handle", 120); text("aktivitaet", TEXT_MAX); text("potenzial", TEXT_MAX); text("notizen", TEXT_MAX); if (körper.status !== undefined) { if (!Object.hasOwn(STUFEN, körper.status)) fehler.push("Unbekannte Stufe."); else aus.status = körper.status; } if (körper.prioritaet !== undefined) { if (!PRIORITAETEN.includes(körper.prioritaet)) fehler.push("Unbekannte Priorität."); else aus.prioritaet = körper.prioritaet; } for (const feld of ["letzte_nachricht", "naechster_followup"]) { if (körper[feld] === undefined) continue; const d = String(körper[feld] ?? "").trim(); if (!d) { aus[feld] = null; continue; } if (!/^\d{4}-\d{2}-\d{2}$/.test(d) || Number.isNaN(Date.parse(d))) fehler.push("Datum ist ungültig."); else aus[feld] = d; } return { aus, fehler }; } /* ---------- Anlegen und Ändern ------------------------------------------- */ scoutRouter.post("/workspace/api/leads", gleicheHerkunft, (req, res) => { try { const { aus, fehler } = pruefe(req.body || {}, { neu: true }); if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") }); /* Ein Scout legt immer in die eigene Pipeline. Das Management darf einen Lead einem Scout zuordnen. */ let scoutId = req.person.id; if (istLeitung(req.person)) { const w = req.body?.scout_id; if (w) { const z = Number(w); if (!Number.isInteger(z) || z < 1) return res.status(400).json({ fehler: "Ungültige Zuordnung." }); if (!db().prepare("SELECT 1 FROM personen WHERE id = ? AND rolle = 'scout'").get(z)) { return res.status(400).json({ fehler: "Scout existiert nicht." }); } scoutId = z; } else scoutId = null; } const { lastInsertRowid } = db().prepare(` INSERT INTO leads (name, plattform, handle, status, prioritaet, aktivitaet, potenzial, notizen, letzte_nachricht, naechster_followup, scout_id, erstellt, erstellt_von) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`).run( aus.name, aus.plattform ?? null, aus.handle ?? null, aus.status ?? "neu", aus.prioritaet ?? "mittel", aus.aktivitaet ?? null, aus.potenzial ?? null, aus.notizen ?? null, aus.letzte_nachricht ?? null, aus.naechster_followup ?? null, scoutId, jetzt(), req.person.id); protokolliere("lead_angelegt", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), detail: `#${lastInsertRowid} ${aus.name}`.slice(0, 120), }); res.status(201).json({ id: Number(lastInsertRowid) }); } catch (fehler) { console.error("[workspace] Lead anlegen:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); function holen(req, id) { const { wo, werte } = sichtbar(req.person); return db().prepare(`SELECT l.* ${VERBUND} WHERE ${wo} AND l.id = ?`).get(...werte, id); } scoutRouter.patch("/workspace/api/leads/:id", gleicheHerkunft, (req, res) => { try { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); const lead = holen(req, id); if (!lead) return res.status(404).json({ fehler: "nicht_gefunden" }); const { aus, fehler } = pruefe(req.body || {}, { neu: false }); if (fehler.length) return res.status(400).json({ fehler: fehler.join(" ") }); const felder = Object.keys(aus); if (!felder.length) return res.status(400).json({ fehler: "nichts_zu_aendern" }); const setz = felder.map((f) => `${f} = ?`); const daten = felder.map((f) => aus[f]); /* Der Zeitpunkt der Uebergabe wird festgehalten -- er ist der Uebergang aus der Scout-Arbeit in die Betreuung. */ if (aus.status === "uebergeben" && lead.status !== "uebergeben") { setz.push("uebergeben_am = ?"); daten.push(jetzt()); } setz.push("geaendert = ?"); daten.push(jetzt()); db().prepare(`UPDATE leads SET ${setz.join(", ")} WHERE id = ?`).run(...daten, id); protokolliere("lead_geaendert", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), detail: `#${id} ${felder.join(",")}`.slice(0, 120), }); res.json({ ok: true }); } catch (fehler) { console.error("[workspace] Lead ändern:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); scoutRouter.delete("/workspace/api/leads/:id", gleicheHerkunft, (req, res) => { try { const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); const lead = holen(req, id); if (!lead) return res.status(404).json({ fehler: "nicht_gefunden" }); /* Ein übergebener Lead ist Teil der Betreuungsgeschichte -- den räumt nur das Management weg. */ if (!istLeitung(req.person) && lead.status === "uebergeben") { return res.status(403).json({ fehler: "Übergebene Leads löscht nur DogFather." }); } db().prepare("DELETE FROM leads WHERE id = ?").run(id); protokolliere("lead_geloescht", { personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), detail: `#${id} ${lead.name}`.slice(0, 120), }); res.json({ ok: true }); } catch (fehler) { console.error("[workspace] Lead löschen:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /* ---------- Creator-Onboarding starten ------------------------------------ "Übergabe: Infos vollständig, Gespräch dokumentiert, Termin setzen, Creator-Onboarding starten" (Deck, Seite 15). Aus einem übergebenen Lead entsteht hier eine echte Person mit Zugangscode -- der Code wird genau einmal zurückgegeben und danach nie wieder. */ scoutRouter.post("/workspace/api/leads/:id/onboarding", gleicheHerkunft, (req, res) => { try { if (!istLeitung(req.person)) return res.status(403).json({ fehler: "nicht_erlaubt" }); const id = Number(req.params.id); if (!Number.isInteger(id)) return res.status(400).json({ fehler: "ungueltig" }); const lead = holen(req, id); if (!lead) return res.status(404).json({ fehler: "nicht_gefunden" }); if (lead.creator_id) return res.status(409).json({ fehler: "Für diesen Lead gibt es schon einen Creator." }); if (lead.status !== "uebergeben") { return res.status(400).json({ fehler: "Erst übergeben, dann das Onboarding starten." }); } const name = String(req.body?.name ?? lead.name).trim().slice(0, 60); if (name.length < 2) return res.status(400).json({ fehler: "Name fehlt." }); if (db().prepare("SELECT 1 FROM personen WHERE lower(name) = lower(?) AND aktiv = 1").get(name)) { return res.status(409).json({ fehler: "Diesen Namen gibt es schon." }); } const neu = personAnlegen(name, "creator", { ...req.person, ip: echteIp(req) }); db().prepare("UPDATE leads SET creator_id = ?, geaendert = ? WHERE id = ?") .run(neu.id, jetzt(), id); /* Die Angaben des Scouts wandern ins Creator-Profil -- sonst müsste das Management sie abtippen, obwohl sie längst da sind. */ const handles = [lead.plattform, lead.handle].filter(Boolean).join(" · ") || null; if (handles || lead.potenzial || lead.aktivitaet) { db().prepare(` INSERT INTO profile (person_id, handles, nische, live_zeiten, geaendert, geaendert_von) VALUES (?,?,?,?,?,?)`).run( neu.id, handles, lead.potenzial ?? null, lead.aktivitaet ?? null, 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), }); res.status(201).json({ id: neu.id, name: neu.name, rolle: neu.rolle, code: neu.code }); } catch (fehler) { console.error("[workspace] Onboarding:", fehler?.message); res.status(503).json({ fehler: "nicht_verfuegbar" }); } }); /* Scout-Liste für die Zuordnung durch das Management. */ scoutRouter.get("/workspace/api/leads-scouts", angemeldet, (req, res) => { try { if (!istLeitung(req.person)) { return res.json({ scouts: [{ id: req.person.id, name: req.person.name }] }); } res.json({ scouts: db().prepare( "SELECT id, name FROM personen WHERE rolle = 'scout' AND aktiv = 1 ORDER BY name").all(), }); } catch { res.status(503).json({ fehler: "nicht_verfuegbar" }); } });