Kunden und Projekte in der Verwaltung anlegen, inklusive Testzugang

Wunsch 22.08.2026: "ich will einen provisorischen zugang fuer mich".
Der richtige Weg dahin war ohnehin ueberfaellig -- Kundenzugaenge
entstehen ausschliesslich hier, es gibt bewusst keine Selbstregistrierung.

Neu:
* Kundenzugang anlegen (Name, E-Mail, Firma, Sprache, sofort freischalten
  ja/nein) mit Einladungslink
* "Testzugang fuer mich" -- ein Klick, legt einen als Test erkennbaren
  Zugang mit Datumsstempel an. Bewusst NICHT die echte Geschaeftsadresse:
  die E-Mail ist gleichzeitig der Anmeldename und laesst sich aus gutem
  Grund nicht mehr aendern, ein Test wuerde also spaeter mit einem echten
  Kundenkonto kollidieren.
* Zugaenge auflisten, freischalten, sperren, neuen Einladungslink erzeugen
* Projekte anlegen und aendern, Aenderungswuensche beziffern, Nachrichten

Entscheidungen:
* Der Einladungslink geht EINMAL im Klartext raus, direkt beim Anlegen.
  In der Datenbank liegt nur sein Hash. Wer ihn verliert, bekommt einen
  neuen -- das ist sicherer, als ihn dauerhaft abrufbar zu halten. Die
  Oberflaeche sagt das auch klar dazu, sonst klickt man ihn weg und
  wundert sich.
* Ein neuer Link entwertet alle offenen alten. Sonst sammeln sich mehrere
  gueltige Generalschluessel fuer dasselbe Konto an.
* "Sofort freischalten" ist eine bewusste Handlung. Ohne Haekchen wird der
  Zugang angelegt, kommt aber noch nicht hinein -- Masterplan S.12
  verlangt, dass Projektkauf oder Betreuung VOR der Freischaltung geprueft
  werden.
* Sperren beendet laufende Sitzungen sofort, nicht erst nach Ablauf.
* Die 30 % Anzahlung werden aus dem Preis BERECHNET, nicht eingetippt.
  Ein Tippfehler in der Anzahlung faellt sonst erst beim Geldeingang auf.
* Preise kommen als Euro herein und werden sofort in Cent umgerechnet
  (Math.round, damit 49.99 nicht zu 4998 wird). Ab da nie wieder Komma.
* Vier klar unterscheidbare Zustaende in der Liste. Wichtig vor allem
  "Einladung offen": freigeschaltet, aber noch kein Passwort gesetzt --
  der Kunde war also noch nie drin.
* Kopieren faellt auf Markieren zurueck, wenn die Zwischenablage
  blockiert ist. Eine Fehlermeldung waere dort nutzlos.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-22 21:23:15 +02:00
co-authored by Claude Opus 5
parent 1b432f456f
commit 2542a2598e
3 changed files with 609 additions and 1 deletions
+16
View File
@@ -95,6 +95,12 @@ import {
portalUebersicht, portalProjekt, portalAenderungEinreichen,
portalAenderungEntscheiden, portalNachricht, portalProfilSpeichern,
} from "./routes/webdesign-portal.js";
/* Kunden und Projekte verwalten (Masterplan S.13). Hier entstehen
Kundenzugaenge -- es gibt bewusst keine Selbstregistrierung. */
import {
kundeAnlegen, kundeEinladungNeu, kundenListe, kundeSchalten,
projektAnlegen, projektAendern, aenderungBeziffern, projektNachricht,
} from "./routes/webdesign-kunden.js";
initDb();
@@ -250,6 +256,16 @@ app.post("/webdesign/portal/aenderung/:id/entscheiden", wrap(portalAenderungEnts
app.post("/webdesign/portal/nachricht", wrap(portalNachricht));
app.post("/webdesign/portal/profil", wrap(portalProfilSpeichern));
/* ---------- Kunden und Projekte (Verwaltung) ---------- */
app.get("/webdesign/admin/kunden", wrap(kundenListe));
app.post("/webdesign/admin/kunden", wrap(kundeAnlegen));
app.post("/webdesign/admin/kunden/:id/einladung", wrap(kundeEinladungNeu));
app.post("/webdesign/admin/kunden/:id/zugang", wrap(kundeSchalten));
app.post("/webdesign/admin/projekte", wrap(projektAnlegen));
app.post("/webdesign/admin/projekte/:id", wrap(projektAendern));
app.post("/webdesign/admin/aenderungen/:id/beziffern", wrap(aenderungBeziffern));
app.post("/webdesign/admin/projekt-nachricht", wrap(projektNachricht));
app.post("/supporter/register", wrap(registerSupporter));
app.post("/supporter/verify-email", wrap(verifySupporterEmail));
// Normaler Login seit 21.08.2026: E-Mail + selbst gewählter Zugangscode.
+347
View File
@@ -0,0 +1,347 @@
/* =====================================================================
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 {
jetzt, neueId, naechsteNummer, text, istEmail, emailNormal,
tokenHash, verlauf, spracheOderDe, centZuText, PAKETE, PROJEKT_STATUS,
} from "../lib/webdesign-helfer.js";
async function darf(req, recht) {
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);
}
verlauf("projekt", id, "angelegt", p.sitzung.actor, { nummer, titel, preisCent });
return json(res, { ok: true, id, nummer, 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 });
}