diff --git a/server-internal/index.js b/server-internal/index.js index 9d1a72d0..56284c5b 100644 --- a/server-internal/index.js +++ b/server-internal/index.js @@ -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. diff --git a/server-internal/routes/webdesign-kunden.js b/server-internal/routes/webdesign-kunden.js new file mode 100644 index 00000000..bc74c80b --- /dev/null +++ b/server-internal/routes/webdesign-kunden.js @@ -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 }); +} diff --git a/webdesign/verwaltung.html b/webdesign/verwaltung.html index 9352f1bd..12098296 100644 --- a/webdesign/verwaltung.html +++ b/webdesign/verwaltung.html @@ -135,6 +135,15 @@ .vw-notiz-kopf { font-size: .76rem; color: var(--wd-text-soft); margin-bottom: .3rem; } .vw-notiz p { margin: 0; font-size: .93rem; white-space: pre-wrap; } + .vw-tabs { display: flex; gap: .4rem; } + .vw-link-anzeige { + padding: 1rem; border-radius: 14px; + background: rgba(232,200,119,.08); border: 1px solid rgba(232,200,119,.35); + } + .vw-link-anzeige code { + display: block; word-break: break-all; margin: .6rem 0; + font-size: .84rem; color: var(--wd-gold); line-height: 1.5; + } .vw-leer-hinweis { text-align: center; padding: 3rem 1rem; color: var(--wd-text-soft); } .vw-laedt { text-align: center; padding: 2.5rem 1rem; color: var(--wd-text-soft); } @@ -168,7 +177,11 @@