386 lines
17 KiB
JavaScript
386 lines
17 KiB
JavaScript
/* =====================================================================
|
|
routes/webdesign-kunden.js — Kunden und Projekte verwalten
|
|
(Masterplan S.13 "KUNDEN, KÄUFE & BETREUUNG")
|
|
|
|
Hier entstehen Kundenzugänge. Bewusst NUR hier — es gibt keine
|
|
Selbstregistrierung (Begründung im Kopf von webdesign-portal.js).
|
|
|
|
Der Einladungslink ist der einzige Weg, wie ein Kunde an ein Passwort
|
|
kommt. Er wird EINMAL im Klartext zurückgegeben, direkt beim Anlegen,
|
|
und danach nie wieder: in der Datenbank liegt nur sein Hash. Wer den
|
|
Link verliert, bekommt einen neuen — das ist sicherer, als ihn
|
|
dauerhaft abrufbar zu halten.
|
|
===================================================================== */
|
|
|
|
import { randomBytes } from "node:crypto";
|
|
import { db } from "../db.js";
|
|
import { json } from "../lib/http.js";
|
|
import { requireSession } from "../lib/auth.js";
|
|
import { hasPermission } from "../lib/permissions.js";
|
|
import { ausweisPruefen } from "../lib/webdesign-ausweis.js";
|
|
import { aufgabenAusVorlage } from "../lib/webdesign-vorlagen.js";
|
|
import {
|
|
jetzt, neueId, naechsteNummer, text, istEmail, emailNormal,
|
|
tokenHash, verlauf, spracheOderDe, centZuText, PAKETE, PROJEKT_STATUS,
|
|
} from "../lib/webdesign-helfer.js";
|
|
|
|
async function darf(req, recht) {
|
|
/* Zwei Wege fuehren hinein.
|
|
|
|
1. Der Ausweis der Zugangswand. Wer dort mit dem persoenlichen Code
|
|
durchgekommen ist, ist Dogfather oder VanVan -- laut Masterplan S.4
|
|
beide gleichberechtigte Volladministratoren. Ein zweites Passwort
|
|
danach bringt keinen zusaetzlichen Schutz und kostet nur Zeit
|
|
(Rueckmeldung 22.08.2026: "ich will es nur einmal eingeben muessen").
|
|
|
|
2. Eine normale Team-Anmeldung mit dem passenden Einzelrecht -- fuer
|
|
den Fall, dass spaeter jemand aus dem Team nur Anfragen ansehen,
|
|
aber keine Kundendaten bearbeiten koennen soll.
|
|
|
|
Der Ausweis gilt AUSSCHLIESSLICH hier. Postfach, Bewerbungen,
|
|
Supporter und Teamverwaltung bleiben davon unberuehrt. */
|
|
const ausweis = ausweisPruefen(req);
|
|
if (ausweis) return { erlaubt: true, sitzung: { actor: ausweis.rolle, isOwner: true } };
|
|
|
|
const sitzung = await requireSession(req);
|
|
if (!sitzung) return { erlaubt: false, grund: 401, sitzung: null };
|
|
if (sitzung.isOwner) return { erlaubt: true, sitzung };
|
|
const ok = hasPermission(sitzung.permissions, sitzung.overrides, recht);
|
|
return { erlaubt: ok, grund: ok ? null : 403, sitzung };
|
|
}
|
|
|
|
/* Einladung gilt 14 Tage. Lang genug, dass ein Urlaub dazwischenpasst,
|
|
kurz genug, dass ein vergessener Link nicht ewig gültig bleibt. */
|
|
const EINLADUNG_TAGE = 14;
|
|
|
|
function einladungAnlegen(kundeId, zweck) {
|
|
const roh = randomBytes(32).toString("base64url");
|
|
db.prepare(
|
|
`INSERT INTO wd_kunden_token (token_hash, kunde_id, zweck, laeuft_ab, erstellt_am)
|
|
VALUES (?,?,?,?,?)`
|
|
).run(
|
|
tokenHash(roh),
|
|
kundeId,
|
|
zweck,
|
|
new Date(Date.now() + EINLADUNG_TAGE * 86400000).toISOString(),
|
|
jetzt()
|
|
);
|
|
return roh;
|
|
}
|
|
|
|
function einladungsLink(roh) {
|
|
const basis = process.env.WD_BASIS_URL || "https://dogfather-universe.com";
|
|
return `${basis}/webdesign/portal.html?einladung=${encodeURIComponent(roh)}`;
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
KUNDE ANLEGEN
|
|
--------------------------------------------------------------------- */
|
|
export async function kundeAnlegen(req, res) {
|
|
const p = await darf(req, "WD_KUNDEN_MANAGE");
|
|
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
|
|
|
const email = emailNormal(req.body?.email);
|
|
const name = text(req.body?.name, 120);
|
|
const firma = text(req.body?.firma, 160);
|
|
const telefon = text(req.body?.telefon, 60);
|
|
const sprache = spracheOderDe(req.body?.sprache);
|
|
/* Freischalten ist eine bewusste Handlung, keine Voreinstellung
|
|
(Masterplan S.12: "Projektkauf oder laufende Betreuung wird vor
|
|
Freischaltung geprüft"). Wer hier nichts angibt, legt einen Zugang
|
|
an, der noch NICHT hineinkommt. */
|
|
const freischalten = req.body?.freischalten === true;
|
|
|
|
if (!name) return json(res, { ok: false, error: "Bitte einen Namen angeben." }, 400);
|
|
if (!istEmail(email)) return json(res, { ok: false, error: "Bitte eine gültige E-Mail-Adresse angeben." }, 400);
|
|
|
|
const schonDa = db.prepare(`SELECT id, name FROM wd_kunden WHERE email = ?`).get(email);
|
|
if (schonDa) {
|
|
return json(res, {
|
|
ok: false,
|
|
error: `Diese Adresse gehört bereits zu „${schonDa.name}". Dort kannst du eine neue Einladung erzeugen.`,
|
|
}, 409);
|
|
}
|
|
|
|
const id = neueId();
|
|
db.prepare(
|
|
`INSERT INTO wd_kunden (id, email, name, firma, telefon, sprache,
|
|
email_bestaetigt, freigeschaltet, gesperrt, erstellt_am)
|
|
VALUES (?,?,?,?,?,?,0,?,0,?)`
|
|
).run(id, email, name, firma || null, telefon || null, sprache, freischalten ? 1 : 0, jetzt());
|
|
|
|
const roh = einladungAnlegen(id, "einladung");
|
|
verlauf("kunde", id, "angelegt", p.sitzung.actor, { email, freigeschaltet: freischalten });
|
|
|
|
return json(res, {
|
|
ok: true,
|
|
id,
|
|
/* Der Link geht EINMAL raus. Danach steht in der Datenbank nur noch
|
|
sein Hash — er lässt sich nicht wieder anzeigen. */
|
|
einladungsLink: einladungsLink(roh),
|
|
laeuftAb: new Date(Date.now() + EINLADUNG_TAGE * 86400000).toISOString(),
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
NEUE EINLADUNG (Link verloren, abgelaufen, oder Passwort vergessen)
|
|
--------------------------------------------------------------------- */
|
|
export async function kundeEinladungNeu(req, res) {
|
|
const p = await darf(req, "WD_KUNDEN_MANAGE");
|
|
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
|
|
|
const id = text(req.params.id, 64);
|
|
const kunde = db.prepare(`SELECT id, name FROM wd_kunden WHERE id = ?`).get(id);
|
|
if (!kunde) return json(res, { ok: false, error: "Kunde nicht gefunden." }, 404);
|
|
|
|
/* Alte, noch offene Einladungen entwerten. Sonst sammeln sich mehrere
|
|
gültige Generalschlüssel für dasselbe Konto an. */
|
|
db.prepare(
|
|
`UPDATE wd_kunden_token SET benutzt_am = ?
|
|
WHERE kunde_id = ? AND zweck IN ('einladung','passwort') AND benutzt_am IS NULL`
|
|
).run(jetzt(), id);
|
|
|
|
const roh = einladungAnlegen(id, "passwort");
|
|
verlauf("kunde", id, "einladung_erneuert", p.sitzung.actor, null);
|
|
|
|
return json(res, { ok: true, einladungsLink: einladungsLink(roh) });
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
LISTE
|
|
--------------------------------------------------------------------- */
|
|
export async function kundenListe(req, res) {
|
|
const p = await darf(req, "WD_KUNDEN_MANAGE");
|
|
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
|
|
|
const zeilen = db
|
|
.prepare(
|
|
`SELECT k.id, k.email, k.name, k.firma, k.sprache, k.freigeschaltet, k.gesperrt,
|
|
k.betreuung_aktiv, k.erstellt_am, k.letzter_login,
|
|
(k.passwort_hash IS NOT NULL) AS hat_passwort,
|
|
(SELECT COUNT(*) FROM wd_projekte WHERE kunde_id = k.id AND archiviert = 0) AS projekte
|
|
FROM wd_kunden k WHERE k.archiviert = 0 ORDER BY k.erstellt_am DESC LIMIT 300`
|
|
)
|
|
.all();
|
|
|
|
return json(res, { ok: true, kunden: zeilen });
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
FREISCHALTEN / SPERREN
|
|
--------------------------------------------------------------------- */
|
|
export async function kundeSchalten(req, res) {
|
|
const p = await darf(req, "WD_KUNDEN_MANAGE");
|
|
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
|
|
|
const id = text(req.params.id, 64);
|
|
const kunde = db.prepare(`SELECT * FROM wd_kunden WHERE id = ?`).get(id);
|
|
if (!kunde) return json(res, { ok: false, error: "Kunde nicht gefunden." }, 404);
|
|
|
|
const felder = [];
|
|
const werte = [];
|
|
if (typeof req.body?.freigeschaltet === "boolean") {
|
|
felder.push("freigeschaltet = ?"); werte.push(req.body.freigeschaltet ? 1 : 0);
|
|
}
|
|
if (typeof req.body?.gesperrt === "boolean") {
|
|
felder.push("gesperrt = ?"); werte.push(req.body.gesperrt ? 1 : 0);
|
|
felder.push("gesperrt_grund = ?"); werte.push(text(req.body?.grund, 300) || null);
|
|
}
|
|
if (typeof req.body?.betreuungAktiv === "boolean") {
|
|
felder.push("betreuung_aktiv = ?"); werte.push(req.body.betreuungAktiv ? 1 : 0);
|
|
}
|
|
if (!felder.length) return json(res, { ok: false, error: "Nichts zu ändern." }, 400);
|
|
|
|
felder.push("aktualisiert_am = ?"); werte.push(jetzt());
|
|
werte.push(id);
|
|
db.prepare(`UPDATE wd_kunden SET ${felder.join(", ")} WHERE id = ?`).run(...werte);
|
|
|
|
/* Eine Sperre muss SOFORT wirken, nicht erst wenn die Sitzung abläuft.
|
|
Deshalb hier alle laufenden Sitzungen des Kunden beenden. */
|
|
if (req.body?.gesperrt === true || req.body?.freigeschaltet === false) {
|
|
db.prepare(`DELETE FROM wd_kunden_sitzungen WHERE kunde_id = ?`).run(id);
|
|
}
|
|
|
|
verlauf("kunde", id, "zugang_geaendert", p.sitzung.actor, {
|
|
freigeschaltet: req.body?.freigeschaltet, gesperrt: req.body?.gesperrt,
|
|
});
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
PROJEKT ANLEGEN
|
|
--------------------------------------------------------------------- */
|
|
export async function projektAnlegen(req, res) {
|
|
const p = await darf(req, "WD_PROJEKTE_MANAGE");
|
|
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
|
|
|
const kundeId = text(req.body?.kundeId, 64);
|
|
const titel = text(req.body?.titel, 160);
|
|
const paket = text(req.body?.paket, 30);
|
|
const anfrageId = text(req.body?.anfrageId, 64) || null;
|
|
|
|
if (!titel) return json(res, { ok: false, error: "Bitte einen Projekttitel angeben." }, 400);
|
|
if (!PAKETE.includes(paket)) return json(res, { ok: false, error: "Unbekanntes Paket." }, 400);
|
|
|
|
const kunde = db.prepare(`SELECT id FROM wd_kunden WHERE id = ?`).get(kundeId);
|
|
if (!kunde) return json(res, { ok: false, error: "Kunde nicht gefunden." }, 404);
|
|
|
|
/* Preis kommt als Euro-Zahl herein und wird SOFORT in Cent umgerechnet.
|
|
Ab hier wird nie wieder mit Kommazahlen gerechnet. Math.round statt
|
|
Abschneiden, damit 49.99 nicht zu 4998 wird. */
|
|
const preisEuro = Number(req.body?.preisEuro);
|
|
const preisCent = Number.isFinite(preisEuro) && preisEuro >= 0 ? Math.round(preisEuro * 100) : null;
|
|
/* Die 30 % werden aus dem Preis BERECHNET, nicht eingetippt. Ein
|
|
Tippfehler in der Anzahlung fällt sonst erst beim Geldeingang auf. */
|
|
const anzahlungCent = preisCent != null ? Math.round(preisCent * 0.3) : null;
|
|
|
|
const id = neueId();
|
|
const nummer = naechsteNummer("wd_projekte", "P");
|
|
db.prepare(
|
|
`INSERT INTO wd_projekte (id, nummer, kunde_id, anfrage_id, titel, paket, beschreibung,
|
|
status, naechster_schritt, wartet_auf, preis_cent, anzahlung_cent, waehrung, richttermin, erstellt_am)
|
|
VALUES (?,?,?,?,?,?,?,'briefing',?,'dogfather',?,?,'EUR',?,?)`
|
|
).run(
|
|
id, nummer, kundeId, anfrageId, titel, paket,
|
|
text(req.body?.beschreibung, 4000) || null,
|
|
text(req.body?.naechsterSchritt, 300) || null,
|
|
preisCent, anzahlungCent,
|
|
text(req.body?.richttermin, 40) || null,
|
|
jetzt()
|
|
);
|
|
|
|
// Anfrage mit dem Kunden verknüpfen, damit die Herkunft sichtbar bleibt.
|
|
if (anfrageId) {
|
|
db.prepare(`UPDATE wd_anfragen SET kunde_id = ?, status = 'angenommen', aktualisiert_am = ? WHERE id = ?`)
|
|
.run(kundeId, jetzt(), anfrageId);
|
|
}
|
|
|
|
/* Aufgabenliste aus der Paketvorlage übernehmen. Bewusst hier und
|
|
nicht als eigener Knopf: Eine Liste, die man erst noch anlegen
|
|
muss, wird genau dann vergessen, wenn der Kunde zum ersten Mal ins
|
|
Portal schaut — und dann sieht er wieder die leere Seite, die der
|
|
Auslöser für die ganze Funktion war.
|
|
|
|
Schlägt es fehl, wird das Projekt trotzdem angelegt. Ein Projekt
|
|
ohne Liste ist ärgerlich, ein abgebrochenes Anlegen wäre schlimmer:
|
|
Kundenzugang und Einladungslink hängen daran. */
|
|
let aufgabenAngelegt = 0;
|
|
try {
|
|
aufgabenAngelegt = aufgabenAusVorlage(id, kundeId, paket);
|
|
} catch (e) {
|
|
console.error("[webdesign] Aufgabenvorlage fehlgeschlagen:", e?.message);
|
|
}
|
|
|
|
verlauf("projekt", id, "angelegt", p.sitzung.actor, { nummer, titel, preisCent, aufgabenAngelegt });
|
|
return json(res, {
|
|
ok: true, id, nummer, aufgabenAngelegt,
|
|
anzahlungText: centZuText(anzahlungCent),
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
PROJEKT ÄNDERN
|
|
--------------------------------------------------------------------- */
|
|
export async function projektAendern(req, res) {
|
|
const p = await darf(req, "WD_PROJEKTE_MANAGE");
|
|
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
|
|
|
const id = text(req.params.id, 64);
|
|
const projekt = db.prepare(`SELECT * FROM wd_projekte WHERE id = ?`).get(id);
|
|
if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404);
|
|
|
|
const felder = [];
|
|
const werte = [];
|
|
const b = req.body || {};
|
|
|
|
if (b.status && PROJEKT_STATUS.includes(text(b.status, 30))) {
|
|
felder.push("status = ?"); werte.push(text(b.status, 30));
|
|
}
|
|
if (typeof b.naechsterSchritt === "string") {
|
|
felder.push("naechster_schritt = ?"); werte.push(text(b.naechsterSchritt, 300) || null);
|
|
}
|
|
if (b.wartetAuf === "kunde" || b.wartetAuf === "dogfather") {
|
|
felder.push("wartet_auf = ?"); werte.push(b.wartetAuf);
|
|
}
|
|
if (typeof b.richttermin === "string") {
|
|
felder.push("richttermin = ?"); werte.push(text(b.richttermin, 40) || null);
|
|
}
|
|
if (typeof b.portfolioFreigabe === "boolean") {
|
|
/* Portfoliofreigabe ist ausdrücklich getrennt von allem anderen
|
|
(Masterplan S.13). Sie wird mit Zeitstempel festgehalten, damit im
|
|
Zweifel belegbar ist, wann sie erteilt wurde. */
|
|
felder.push("portfolio_freigabe = ?"); werte.push(b.portfolioFreigabe ? 1 : 0);
|
|
felder.push("portfolio_freigabe_am = ?"); werte.push(b.portfolioFreigabe ? jetzt() : null);
|
|
}
|
|
if (typeof b.anzahlungBezahlt === "boolean") {
|
|
felder.push("anzahlung_bezahlt = ?"); werte.push(b.anzahlungBezahlt ? 1 : 0);
|
|
}
|
|
if (typeof b.restBezahlt === "boolean") {
|
|
felder.push("rest_bezahlt = ?"); werte.push(b.restBezahlt ? 1 : 0);
|
|
}
|
|
|
|
if (!felder.length) return json(res, { ok: false, error: "Nichts zu ändern." }, 400);
|
|
felder.push("aktualisiert_am = ?"); werte.push(jetzt());
|
|
werte.push(id);
|
|
db.prepare(`UPDATE wd_projekte SET ${felder.join(", ")} WHERE id = ?`).run(...werte);
|
|
|
|
verlauf("projekt", id, "geaendert", p.sitzung.actor, b);
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
ÄNDERUNGSWUNSCH BEZIFFERN (Masterplan S.12)
|
|
--------------------------------------------------------------------- */
|
|
export async function aenderungBeziffern(req, res) {
|
|
const p = await darf(req, "WD_PROJEKTE_MANAGE");
|
|
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
|
|
|
const id = text(req.params.id, 64);
|
|
const aenderung = db.prepare(`SELECT * FROM wd_aenderungen WHERE id = ?`).get(id);
|
|
if (!aenderung) return json(res, { ok: false, error: "Nicht gefunden." }, 404);
|
|
|
|
const preisEuro = Number(req.body?.preisEuro);
|
|
const preisCent = Number.isFinite(preisEuro) && preisEuro >= 0 ? Math.round(preisEuro * 100) : null;
|
|
if (preisCent == null) {
|
|
return json(res, { ok: false, error: "Bitte einen Preis angeben — ohne Preis kann der Kunde nicht entscheiden." }, 400);
|
|
}
|
|
|
|
db.prepare(
|
|
`UPDATE wd_aenderungen SET status = 'angeboten', aufwand_text = ?, zusatzpreis_cent = ?,
|
|
zusatzdauer_text = ?, antwort = ?, aktualisiert_am = ? WHERE id = ?`
|
|
).run(
|
|
text(req.body?.aufwand, 500) || null,
|
|
preisCent,
|
|
text(req.body?.dauer, 200) || null,
|
|
text(req.body?.antwort, 2000) || null,
|
|
jetzt(), id
|
|
);
|
|
|
|
verlauf("projekt", aenderung.projekt_id, "zusatz_beziffert", p.sitzung.actor, { preisCent });
|
|
return json(res, { ok: true, preisText: centZuText(preisCent) });
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
NACHRICHT AN DEN KUNDEN
|
|
--------------------------------------------------------------------- */
|
|
export async function projektNachricht(req, res) {
|
|
const p = await darf(req, "WD_PROJEKTE_MANAGE");
|
|
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
|
|
|
|
const projektId = text(req.body?.projektId, 64);
|
|
const inhalt = text(req.body?.text, 4000);
|
|
const intern = req.body?.intern === true;
|
|
if (!inhalt) return json(res, { ok: false, error: "Die Nachricht ist leer." }, 400);
|
|
|
|
const projekt = db.prepare(`SELECT id, kunde_id FROM wd_projekte WHERE id = ?`).get(projektId);
|
|
if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404);
|
|
|
|
const id = neueId();
|
|
db.prepare(
|
|
`INSERT INTO wd_nachrichten (id, projekt_id, kunde_id, autor, text, intern, gelesen_admin, erstellt_am)
|
|
VALUES (?,?,?,?,?,?,1,?)`
|
|
).run(id, projektId, projekt.kunde_id, p.sitzung.actor, inhalt, intern ? 1 : 0, jetzt());
|
|
|
|
return json(res, { ok: true, id });
|
|
}
|