Files
dogfather-universe/server/workspace-scouts.js
T
DogFatherGit 1c3e642f12 Workspace: Rolle "Management" heisst jetzt "DogFather"
Auf Wunsch von Filipe. Betrifft ausschliesslich die Anzeige.

Der Rollenschluessel bleibt "admin". Er steckt in der CHECK-Regel der
Datenbank, in jeder bestehenden Sitzung und in jeder Rechteabfrage --
ihn umzubenennen haette alle drei gebrochen, und bestehende Anmeldungen
waeren ungueltig geworden. Umbenannt wird nur, was man LIEST.

Der Anzeigename steht jetzt an EINER Stelle (ROLLEN_NAME in
workspace.js) und kommt ueber /workspace/api/ich als rolle_name mit.
Stand er in elf Dateien, waere er beim naechsten Mal in zehn davon
geaendert.

Dabei aufgefallen: In der Kopfleiste stand auf JEDER Seite der rohe
Rollenschluessel -- "Chef · admin". Das war schon vorher unschoen, faellt
aber jetzt erst richtig auf. Alle elf Seiten zeigen jetzt "Chef ·
DogFather". Geprueft: kein rohes "admin" mehr in der Oberflaeche.

Geaendert: Rollenkachel und Untertitel auf der Anmeldeseite, Kopfleiste
aller Seiten, Rollentext auf dem Dashboard, Rollenmarken und Auswahl in
der Personenverwaltung, die Betreuungs-Auswahl ("nur DogFather"), der
Hinweis auf der Dateienseite, die Erklaerung zur internen Notiz im
Profil, die Hinweise fuer Scouts ohne Zuteilung -- und vier
Fehlermeldungen vom Server.

Schreibweise "DogFather" wie von Filipe geschrieben; das ist auch auf
der oeffentlichen Website die haeufigste Form (717 von 1216).

In den Code-Kommentaren der Fachmodule heisst die Rolle weiterhin "das
Management". Das bleibt bewusst so -- eine Massenaenderung an vierzig
Kommentaren waere reines Risiko ohne sichtbaren Nutzen. Ein Hinweis an
der ROLLEN_NAME-Zuordnung erklaert den Zusammenhang.
2026-08-28 11:55:05 +02:00

316 lines
13 KiB
JavaScript

/* =====================================================================
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,
} 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 (person.rolle === "admin") 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 (req.person.rolle === "admin") {
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 (req.person.rolle !== "admin" && 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 (req.person.rolle !== "admin") 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 (req.person.rolle !== "admin") {
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" });
}
});