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.
316 lines
13 KiB
JavaScript
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" });
|
|
}
|
|
});
|