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:
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
+246
-1
@@ -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); }
|
||||
</style>
|
||||
@@ -168,7 +177,11 @@
|
||||
<!-- ============ ARBEITSBEREICH ============ -->
|
||||
<div id="vw-bereich" hidden>
|
||||
<div class="vw-leiste">
|
||||
<h1>Projektanfragen</h1>
|
||||
<h1 id="vw-titel">Projektanfragen</h1>
|
||||
<div class="vw-tabs">
|
||||
<button type="button" class="vw-filter-knopf vw-an" data-tab="anfragen">Anfragen</button>
|
||||
<button type="button" class="vw-filter-knopf" data-tab="kunden">Kunden</button>
|
||||
</div>
|
||||
<div class="vw-wer">
|
||||
<span id="vw-name">—</span>
|
||||
<button type="button" class="wd-btn wd-btn--still wd-btn--klein" id="vw-abmelden">Abmelden</button>
|
||||
@@ -186,6 +199,62 @@
|
||||
|
||||
<div class="wd-meldung" id="vw-meldung" hidden></div>
|
||||
<div id="vw-liste"><div class="vw-laedt">Wird geladen…</div></div>
|
||||
|
||||
<!-- ============ KUNDEN ============ -->
|
||||
<div id="vw-kunden-bereich" hidden>
|
||||
<div class="wd-karte" style="margin-bottom:1.2rem">
|
||||
<h2 style="font-size:1.15rem">Neuen Kundenzugang anlegen</h2>
|
||||
<p class="wd-klein wd-leise">
|
||||
Der Kunde bekommt einen Einladungslink und setzt sein Passwort selbst.
|
||||
Den Link siehst du <strong>nur einmal</strong> — danach steht in der
|
||||
Datenbank nur noch sein verschlüsselter Abdruck. Verloren? Einfach
|
||||
einen neuen erzeugen.
|
||||
</p>
|
||||
<form id="vw-kunde-form" novalidate>
|
||||
<div class="wd-grid wd-grid--2">
|
||||
<div class="wd-feld" id="vw-feld-kname">
|
||||
<label for="vw-kname">Name<span class="wd-pflicht">*</span></label>
|
||||
<input type="text" id="vw-kname" autocomplete="off" />
|
||||
<p class="wd-feld-fehler"></p>
|
||||
</div>
|
||||
<div class="wd-feld" id="vw-feld-kmail">
|
||||
<label for="vw-kmail">E-Mail<span class="wd-pflicht">*</span></label>
|
||||
<input type="email" id="vw-kmail" autocomplete="off" inputmode="email" />
|
||||
<p class="wd-feld-fehler"></p>
|
||||
</div>
|
||||
<div class="wd-feld">
|
||||
<label for="vw-kfirma">Firma <span class="wd-leise">optional</span></label>
|
||||
<input type="text" id="vw-kfirma" autocomplete="off" />
|
||||
</div>
|
||||
<div class="wd-feld">
|
||||
<label for="vw-ksprache">Sprache</label>
|
||||
<select id="vw-ksprache">
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="de-CH">Schwiizerdütsch</option>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="pt">Português</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<label class="wd-wahl-kachel" style="display:block;margin-bottom:.9rem">
|
||||
<input type="checkbox" id="vw-kfrei" checked />
|
||||
<span class="wd-wahl-inhalt">
|
||||
<span class="wd-wahl-titel">Sofort freischalten</span>
|
||||
<span class="wd-wahl-text">Ohne Häkchen wird der Zugang angelegt, kommt aber noch nicht hinein — für Kunden, deren Projekt erst später startet.</span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="wd-btn-reihe">
|
||||
<button type="submit" class="wd-btn wd-btn--haupt">Zugang anlegen</button>
|
||||
<button type="button" class="wd-btn wd-btn--zweit" id="vw-testzugang">Testzugang für mich</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="vw-link-box" hidden style="margin-top:1.2rem"></div>
|
||||
</div>
|
||||
|
||||
<h2 style="font-size:1.15rem">Bestehende Zugänge</h2>
|
||||
<div id="vw-kunden-liste"><div class="vw-laedt">Wird geladen…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -569,6 +638,182 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* =================================================================
|
||||
KUNDEN
|
||||
================================================================= */
|
||||
var aktiverTab = "anfragen";
|
||||
|
||||
document.querySelectorAll("[data-tab]").forEach(function (b) {
|
||||
b.addEventListener("click", function () {
|
||||
aktiverTab = b.dataset.tab;
|
||||
document.querySelectorAll("[data-tab]").forEach(function (x) {
|
||||
x.classList.toggle("vw-an", x.dataset.tab === aktiverTab);
|
||||
});
|
||||
var istAnfragen = aktiverTab === "anfragen";
|
||||
document.getElementById("vw-titel").textContent = istAnfragen ? "Projektanfragen" : "Kunden";
|
||||
document.getElementById("vw-zahlen").hidden = !istAnfragen;
|
||||
document.getElementById("vw-filter").hidden = !istAnfragen;
|
||||
document.getElementById("vw-suche").closest(".wd-feld").hidden = !istAnfragen;
|
||||
document.getElementById("vw-liste").hidden = !istAnfragen;
|
||||
document.getElementById("vw-kunden-bereich").hidden = istAnfragen;
|
||||
if (!istAnfragen) ladeKunden();
|
||||
});
|
||||
});
|
||||
|
||||
async function ladeKunden() {
|
||||
var ziel = document.getElementById("vw-kunden-liste");
|
||||
ziel.innerHTML = '<div class="vw-laedt">Wird geladen…</div>';
|
||||
try {
|
||||
var d = await api("/webdesign/admin/kunden");
|
||||
if (!d.kunden.length) {
|
||||
ziel.innerHTML = '<div class="vw-leer-hinweis">Noch keine Kundenzugaenge angelegt.</div>';
|
||||
return;
|
||||
}
|
||||
ziel.innerHTML = d.kunden.map(function (k) {
|
||||
/* Der Zustand eines Zugangs hat vier Stufen, und alle muessen auf
|
||||
einen Blick unterscheidbar sein. Besonders wichtig ist
|
||||
"freigeschaltet, aber noch kein Passwort gesetzt": das heisst,
|
||||
die Einladung liegt noch offen und der Kunde war noch nie drin. */
|
||||
var marke = k.gesperrt
|
||||
? '<span class="vw-status vw-status--abgelehnt">gesperrt</span>'
|
||||
: !k.freigeschaltet
|
||||
? '<span class="vw-status vw-status--neu">nicht freigeschaltet</span>'
|
||||
: !k.hat_passwort
|
||||
? '<span class="vw-status vw-status--rueckfrage">Einladung offen</span>'
|
||||
: '<span class="vw-status vw-status--angenommen">aktiv</span>';
|
||||
return '<div class="vw-karte" style="cursor:default">' +
|
||||
'<span class="vw-karte-nr">' + (k.projekte || 0) + ' Proj.</span>' +
|
||||
'<span class="vw-karte-mitte">' +
|
||||
'<span class="vw-karte-name">' + s(k.name) + (k.firma ? " · " + s(k.firma) : "") + "</span><br />" +
|
||||
'<span class="vw-karte-info">' + s(k.email) + "</span>" +
|
||||
"</span>" +
|
||||
'<span class="vw-karte-rechts">' + marke +
|
||||
'<span class="vw-karte-zeit">' +
|
||||
(k.letzter_login ? "zuletzt " + window.WD.datum(k.letzter_login) : "noch nie angemeldet") +
|
||||
"</span>" +
|
||||
'<span style="display:block;margin-top:.5rem">' +
|
||||
'<button type="button" class="wd-btn wd-btn--still wd-btn--klein" data-neuer-link="' + s(k.id) + '">Neuer Link</button> ' +
|
||||
'<button type="button" class="wd-btn wd-btn--still wd-btn--klein" data-schalten="' + s(k.id) +
|
||||
'" data-frei="' + (k.freigeschaltet ? "0" : "1") + '">' +
|
||||
(k.freigeschaltet ? "Sperren" : "Freischalten") + "</button>" +
|
||||
"</span>" +
|
||||
"</span></div>";
|
||||
}).join("");
|
||||
|
||||
ziel.querySelectorAll("[data-neuer-link]").forEach(function (b) {
|
||||
b.addEventListener("click", async function () {
|
||||
window.WD.knopfSperren(b, true, "…");
|
||||
try {
|
||||
var r = await api("/webdesign/admin/kunden/" + encodeURIComponent(b.dataset.neuerLink) + "/einladung",
|
||||
{ method: "POST", body: {} });
|
||||
zeigeLink(r.einladungsLink);
|
||||
} catch (f) { window.WD.meldung(meldung, f.message, "fehler"); }
|
||||
finally { window.WD.knopfSperren(b, false); }
|
||||
});
|
||||
});
|
||||
|
||||
ziel.querySelectorAll("[data-schalten]").forEach(function (b) {
|
||||
b.addEventListener("click", async function () {
|
||||
var frei = b.dataset.frei === "1";
|
||||
window.WD.knopfSperren(b, true, "…");
|
||||
try {
|
||||
await api("/webdesign/admin/kunden/" + encodeURIComponent(b.dataset.schalten) + "/zugang",
|
||||
{ method: "POST", body: { freigeschaltet: frei } });
|
||||
ladeKunden();
|
||||
} catch (f) {
|
||||
window.WD.knopfSperren(b, false);
|
||||
window.WD.meldung(meldung, f.message, "fehler");
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (fehler) {
|
||||
if (fehler.status === 401) { token = null; tokenSchreiben(null); zeigeAnmeldung(); return; }
|
||||
ziel.innerHTML = "";
|
||||
window.WD.meldung(meldung, fehler.message, "fehler");
|
||||
}
|
||||
}
|
||||
|
||||
/* Der Link wird EINMAL angezeigt. Deshalb gross, kopierbar und mit einer
|
||||
klaren Ansage, dass er nicht wiederkommt -- sonst klickt man ihn weg
|
||||
und wundert sich. */
|
||||
function zeigeLink(link) {
|
||||
var box = document.getElementById("vw-link-box");
|
||||
box.hidden = false;
|
||||
box.innerHTML = '<div class="vw-link-anzeige">' +
|
||||
'<p class="wd-hinweis-titel">Einladungslink — wird nur dieses eine Mal angezeigt</p>' +
|
||||
"<code>" + s(link) + "</code>" +
|
||||
'<div class="wd-btn-reihe">' +
|
||||
'<button type="button" class="wd-btn wd-btn--haupt wd-btn--klein" id="vw-link-kopieren">Link kopieren</button>' +
|
||||
'<a class="wd-btn wd-btn--zweit wd-btn--klein" href="' + s(link) + '" target="_blank" rel="noopener">Selbst oeffnen</a>' +
|
||||
"</div>" +
|
||||
'<p class="wd-klein wd-leise" style="margin:.6rem 0 0">Gueltig 14 Tage. Danach oder bei Verlust einfach einen neuen erzeugen.</p>' +
|
||||
"</div>";
|
||||
box.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
|
||||
document.getElementById("vw-link-kopieren").addEventListener("click", async function (e) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
e.target.textContent = "Kopiert ✓";
|
||||
} catch (f) {
|
||||
/* Die Zwischenablage kann blockiert sein (aelterer Browser, kein
|
||||
HTTPS, verweigerte Berechtigung). Dann den Text markieren statt
|
||||
einer Fehlermeldung, mit der niemand etwas anfangen kann. */
|
||||
var bereich = document.createRange();
|
||||
bereich.selectNodeContents(box.querySelector("code"));
|
||||
var auswahl = getSelection();
|
||||
auswahl.removeAllRanges();
|
||||
auswahl.addRange(bereich);
|
||||
e.target.textContent = "markiert — jetzt kopieren";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("vw-kunde-form").addEventListener("submit", async function (e) {
|
||||
e.preventDefault();
|
||||
await legeKundeAn({
|
||||
name: document.getElementById("vw-kname").value.trim(),
|
||||
email: document.getElementById("vw-kmail").value.trim(),
|
||||
firma: document.getElementById("vw-kfirma").value.trim(),
|
||||
sprache: document.getElementById("vw-ksprache").value,
|
||||
freischalten: document.getElementById("vw-kfrei").checked,
|
||||
}, e.target.querySelector('button[type="submit"]'));
|
||||
});
|
||||
|
||||
/* "Testzugang fuer mich": legt einen Zugang mit einer eindeutig als Test
|
||||
erkennbaren Adresse an. Bewusst NICHT die echte Geschaeftsadresse --
|
||||
sonst kollidiert der Test spaeter mit einem echten Kundenkonto, und
|
||||
die E-Mail-Adresse laesst sich aus gutem Grund nicht mehr aendern. */
|
||||
document.getElementById("vw-testzugang").addEventListener("click", async function (e) {
|
||||
var stempel = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
await legeKundeAn({
|
||||
name: "Testzugang (" + stempel + ")",
|
||||
email: "test+" + stempel + "@dogfather-universe.com",
|
||||
firma: "Eigener Test",
|
||||
sprache: window.WD.getSprache(),
|
||||
freischalten: true,
|
||||
}, e.target);
|
||||
});
|
||||
|
||||
async function legeKundeAn(daten, knopf) {
|
||||
["vw-feld-kname", "vw-feld-kmail"].forEach(function (id) {
|
||||
document.getElementById(id).classList.remove("wd-feld--fehler");
|
||||
});
|
||||
window.WD.meldungAus(meldung);
|
||||
window.WD.knopfSperren(knopf, true, "Wird angelegt…");
|
||||
try {
|
||||
var r = await api("/webdesign/admin/kunden", { method: "POST", body: daten });
|
||||
zeigeLink(r.einladungsLink);
|
||||
document.getElementById("vw-kunde-form").reset();
|
||||
document.getElementById("vw-kfrei").checked = true;
|
||||
ladeKunden();
|
||||
} catch (fehler) {
|
||||
window.WD.meldung(meldung, fehler.message, "fehler");
|
||||
} finally {
|
||||
window.WD.knopfSperren(knopf, false);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- Start ---------------- */
|
||||
function start() {
|
||||
token = tokenLesen();
|
||||
|
||||
Reference in New Issue
Block a user