WIP: Aufgaben- und Postfach-Routen (Test folgt auf dem Server)

This commit is contained in:
2026-08-22 22:56:59 +02:00
parent 548ec6bc00
commit 4ce51c127e
22 changed files with 1778 additions and 4 deletions
+37
View File
@@ -101,8 +101,26 @@ import {
kundeAnlegen, kundeEinladungNeu, kundenListe, kundeSchalten,
projektAnlegen, projektAendern, aenderungBeziffern, projektNachricht,
} from "./routes/webdesign-kunden.js";
/* Aufgabenliste je Projekt ("was ist schon gemacht von dem, was im Plan
war") und allgemeines Postfach fuer Nachrichten OHNE Projektbezug. */
import {
portalAufgaben, portalAufgabeErledigt, adminAufgaben, adminAufgabeAnlegen,
adminAufgabeAendern, adminAufgabeLoeschen, adminVorlageAnwenden,
} from "./routes/webdesign-aufgaben.js";
import {
portalPostfach, portalPostfachSenden, adminPostfachListe,
adminPostfachVerlauf, adminPostfachSenden, adminPostfachZaehler,
} from "./routes/webdesign-postfach.js";
import { vorlagenSicherstellen } from "./lib/webdesign-vorlagen.js";
initDb();
/* Muss NACH initDb laufen -- die Tabelle entsteht erst dort. Schreibt
die Standard-Aufgabenlisten beim allerersten Start in die Datenbank
und tut danach nichts mehr. Vorhandene Vorlagen werden nie
ueberschrieben, sonst waeren von Hand angepasste nach dem naechsten
Neustart weg -- und niemand kaeme auf die Idee, dass der Neustart
daran schuld war. */
vorlagenSicherstellen();
const PORT = Number(process.env.PORT || 4200);
const app = express();
@@ -266,6 +284,25 @@ 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));
/* ---------- Aufgabenliste je Projekt ----------
Kundenseite links, Verwaltung rechts. Der Kunde darf nur Punkte
abhaken, die IHM zugewiesen sind -- geprueft im Handler, nicht hier. */
app.get("/webdesign/portal/projekt/:id/aufgaben", wrap(portalAufgaben));
app.post("/webdesign/portal/aufgabe/:id/erledigt", wrap(portalAufgabeErledigt));
app.get("/webdesign/admin/projekte/:id/aufgaben", wrap(adminAufgaben));
app.post("/webdesign/admin/projekte/:id/aufgaben", wrap(adminAufgabeAnlegen));
app.post("/webdesign/admin/projekte/:id/aufgaben/vorlage", wrap(adminVorlageAnwenden));
app.post("/webdesign/admin/aufgaben/:id", wrap(adminAufgabeAendern));
app.post("/webdesign/admin/aufgaben/:id/loeschen", wrap(adminAufgabeLoeschen));
/* ---------- Postfach (Nachrichten ohne Projektbezug) ---------- */
app.get("/webdesign/portal/postfach", wrap(portalPostfach));
app.post("/webdesign/portal/postfach", wrap(portalPostfachSenden));
app.get("/webdesign/admin/postfach", wrap(adminPostfachListe));
app.get("/webdesign/admin/postfach-zaehler", wrap(adminPostfachZaehler));
app.get("/webdesign/admin/postfach/:kundeId", wrap(adminPostfachVerlauf));
app.post("/webdesign/admin/postfach/:kundeId", wrap(adminPostfachSenden));
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.
+18
View File
@@ -132,6 +132,24 @@ export function zuVieleVersuche(schluessel, maxAnzahl, sperreMinuten) {
return false;
}
/* Rollendes Zeitfenster — löscht den Zähler, wenn seit der letzten
Zählung genug Zeit vergangen ist.
Warum das nötig ist: versuchZaehlen() zählt nur hoch. Bei der
Anmeldung ist das richtig, dort setzt ein erfolgreicher Login zurück.
Bei einer Mengenbegrenzung für erlaubte Handlungen gibt es aber
keinen "Erfolg", der zurücksetzt — der Zähler bliebe für immer auf
seinem Höchststand, und ab der zwanzigsten Nachricht würde JEDE
weitere sofort wieder eine Sperre auslösen. Aus einer Bremse gegen
Endlosschleifen wäre damit ein dauerhaftes Schreibverbot geworden.
Vor jeder Prüfung aufrufen, mit demselben Fenster wie die Sperre. */
export function fensterZuruecksetzen(schluessel, fensterMinuten) {
const grenze = new Date(Date.now() - fensterMinuten * 60000).toISOString();
db.prepare(`DELETE FROM wd_login_versuche WHERE schluessel = ? AND zuletzt < ?`)
.run(schluessel, grenze);
}
export function versuchZaehlen(schluessel, maxAnzahl = 5, sperreMinuten = 15) {
const zeile = db.prepare(`SELECT * FROM wd_login_versuche WHERE schluessel = ?`).get(schluessel);
const anzahl = (zeile?.anzahl || 0) + 1;
@@ -0,0 +1,352 @@
/* =====================================================================
routes/webdesign-aufgaben.js — "Was ist schon gemacht von dem, was im
Plan war?"
Wunsch vom 22.08.2026. Bis hierher sah der Kunde nur die Phase
("Gestaltung · 3/7") — also eine Zahl ohne Inhalt. Die häufigste
Rückfrage in jedem Projekt ("wie weit seid ihr denn?") beantwortet
eine Phasenanzeige nicht.
Zwei Regeln durchziehen diese Datei:
1. Der Kunde sieht NUR seine eigenen Punkte. Jede Abfrage im
Kundenteil filtert auf kunde_id aus der SITZUNG — nie auf eine ID
aus der Anfrage. Eine Projekt-ID im Pfad ist eine Behauptung des
Aufrufers, die Sitzung ist ein Beweis.
2. Der Kunde darf abhaken, was IHM zugewiesen ist ("Texte geliefert"),
und sonst nichts. Das ist kein Misstrauen, sondern Klarheit: Wenn
beide Seiten alles abhaken könnten, wäre am Ende unklar, wer was
bestätigt hat — und genau diese Klarheit ist der Zweck der Liste.
===================================================================== */
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 { brauchtKunde } from "./webdesign-portal.js";
import { jetzt, neueId, text, verlauf } from "../lib/webdesign-helfer.js";
export const KATEGORIEN = ["start", "gestaltung", "umsetzung", "abschluss"];
export const AUFGABE_STATUS = ["offen", "in_arbeit", "erledigt", "entfaellt"];
const WER_DRAN = ["dogfather", "kunde"];
/* Gleiche Zugangsprüfung wie in webdesign-kunden.js — Ausweis der
Zugangswand ODER Team-Anmeldung mit Einzelrecht. Begründung dort. */
async function darf(req, recht) {
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 };
}
function werIst(p) {
return p.sitzung?.actor ? "team:" + p.sitzung.actor : "team";
}
/* ---------------------------------------------------------------------
ZUSAMMENFASSUNG — die Zahlen, die oben über der Liste stehen
Absichtlich hier und nicht im Browser gerechnet: Verwaltung und Portal
brauchen dieselben Zahlen. Zweimal gerechnet heißt irgendwann
zweierlei Ergebnisse, und dann diskutiert man über Prozentwerte statt
über das Projekt.
"entfaellt" zählt NICHT in den Nenner. Ein Punkt, der wegfällt, darf
den Fortschritt weder bremsen noch künstlich beschleunigen.
--------------------------------------------------------------------- */
export function fortschritt(projektId) {
const zeilen = db
.prepare(`SELECT kategorie, status, wer_dran FROM wd_aufgaben WHERE projekt_id = ?`)
.all(projektId);
const gesamt = zeilen.filter((z) => z.status !== "entfaellt");
const erledigt = gesamt.filter((z) => z.status === "erledigt");
const jeKategorie = {};
for (const k of KATEGORIEN) {
const inK = gesamt.filter((z) => z.kategorie === k);
jeKategorie[k] = {
gesamt: inK.length,
erledigt: inK.filter((z) => z.status === "erledigt").length,
inArbeit: inK.filter((z) => z.status === "in_arbeit").length,
};
}
return {
gesamt: gesamt.length,
erledigt: erledigt.length,
inArbeit: gesamt.filter((z) => z.status === "in_arbeit").length,
/* Die wichtigste Zahl im Portal: offene Punkte, die auf den KUNDEN
warten. Steht sie auf 0, kann er beruhigt sein — steht sie höher,
weiß er sofort, dass es an ihm liegt. */
beimKunden: gesamt.filter((z) => z.wer_dran === "kunde" && z.status !== "erledigt").length,
prozent: gesamt.length ? Math.round((erledigt.length / gesamt.length) * 100) : 0,
jeKategorie,
};
}
/* ---------------------------------------------------------------------
KUNDENSEITE
--------------------------------------------------------------------- */
/* GET /webdesign/portal/projekt/:id/aufgaben */
export function portalAufgaben(req, res) {
const kunde = brauchtKunde(req, res);
if (!kunde) return;
/* Das UND ist die ganze Absicherung. Ohne kunde_id in dieser Abfrage
könnte jeder angemeldete Kunde die Aufgabenliste jedes anderen
Projekts lesen, indem er die ID im Pfad austauscht. */
const projekt = db
.prepare(`SELECT id, nummer, titel, paket, status FROM wd_projekte WHERE id = ? AND kunde_id = ?`)
.get(String(req.params.id || ""), kunde.id);
if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404);
const aufgaben = db
.prepare(
`SELECT id, titel, beschreibung, kategorie, status, wer_dran,
nicht_enthalten, hinweis, reihenfolge, erledigt_am
FROM wd_aufgaben WHERE projekt_id = ? ORDER BY reihenfolge, erstellt_am`
)
.all(projekt.id);
return json(res, { ok: true, projekt, aufgaben, fortschritt: fortschritt(projekt.id) });
}
/* POST /webdesign/portal/aufgabe/:id/erledigt
Der Kunde meldet einen eigenen Punkt als erledigt ("Texte geliefert").
Drei Bedingungen, alle notwendig: sein Projekt, wer_dran = kunde,
nicht als "nicht enthalten" markiert. */
export function portalAufgabeErledigt(req, res) {
const kunde = brauchtKunde(req, res);
if (!kunde) return;
const aufgabe = db
.prepare(`SELECT * FROM wd_aufgaben WHERE id = ? AND kunde_id = ?`)
.get(String(req.params.id || ""), kunde.id);
if (!aufgabe) return json(res, { ok: false, error: "Punkt nicht gefunden." }, 404);
if (aufgabe.wer_dran !== "kunde") {
return json(
res,
{ ok: false, error: "Diesen Punkt erledigt Dogfather — du musst hier nichts tun." },
403
);
}
if (aufgabe.nicht_enthalten) {
return json(res, { ok: false, error: "Dieser Punkt gehört nicht zum Umfang." }, 403);
}
/* Der Kunde darf umschalten, nicht nur setzen. Ein Fehlklick soll ihn
nicht zwingen, deswegen zu schreiben. */
const neu = aufgabe.status === "erledigt" ? "offen" : "erledigt";
db.prepare(
`UPDATE wd_aufgaben SET status = ?, erledigt_am = ?, aktualisiert_am = ? WHERE id = ?`
).run(neu, neu === "erledigt" ? jetzt() : null, jetzt(), aufgabe.id);
verlauf("aufgabe", aufgabe.id, "kunde_" + neu, "kunde:" + kunde.id, aufgabe.titel);
return json(res, { ok: true, status: neu, fortschritt: fortschritt(aufgabe.projekt_id) });
}
/* ---------------------------------------------------------------------
VERWALTUNGSSEITE
--------------------------------------------------------------------- */
/* GET /webdesign/admin/projekte/:id/aufgaben */
export async function adminAufgaben(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 = String(req.params.id || "");
const projekt = db
.prepare(
`SELECT p.id, p.nummer, p.titel, p.paket, p.status, p.kunde_id, k.name AS kunde_name
FROM wd_projekte p JOIN wd_kunden k ON k.id = p.kunde_id WHERE p.id = ?`
)
.get(projektId);
if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404);
const aufgaben = db
.prepare(`SELECT * FROM wd_aufgaben WHERE projekt_id = ? ORDER BY reihenfolge, erstellt_am`)
.all(projektId);
return json(res, { ok: true, projekt, aufgaben, fortschritt: fortschritt(projektId) });
}
/* POST /webdesign/admin/projekte/:id/aufgaben — neuen Punkt anlegen */
export async function adminAufgabeAnlegen(req, res) {
const p = await darf(req, "WD_PROJEKTE_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const projekt = db
.prepare(`SELECT id, kunde_id FROM wd_projekte WHERE id = ?`)
.get(String(req.params.id || ""));
if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404);
const titel = text(req.body?.titel, 200);
if (!titel) return json(res, { ok: false, error: "Bitte einen Titel angeben." }, 400);
const kategorie = KATEGORIEN.includes(req.body?.kategorie) ? req.body.kategorie : "umsetzung";
const werDran = WER_DRAN.includes(req.body?.werDran) ? req.body.werDran : "dogfather";
/* Neue Punkte landen am Ende ihrer Kategorie, nicht am Ende der Liste.
Sonst rutscht ein nachgetragener Gestaltungspunkt hinter den
Abschluss und die Liste erzählt eine falsche Reihenfolge. */
const basis = { start: 0, gestaltung: 100, umsetzung: 200, abschluss: 800 }[kategorie];
const maxInK = db
.prepare(
`SELECT MAX(reihenfolge) AS m FROM wd_aufgaben WHERE projekt_id = ? AND kategorie = ?`
)
.get(projekt.id, kategorie).m;
const reihenfolge = (maxInK != null ? maxInK : basis) + 10;
const id = neueId();
db.prepare(
`INSERT INTO wd_aufgaben
(id, projekt_id, kunde_id, titel, beschreibung, kategorie, status, wer_dran,
nicht_enthalten, hinweis, reihenfolge, erstellt_am)
VALUES (?,?,?,?,?,?,'offen',?,?,?,?,?)`
).run(
id, projekt.id, projekt.kunde_id, titel, text(req.body?.beschreibung, 600) || null,
kategorie, werDran, req.body?.nichtEnthalten ? 1 : 0,
text(req.body?.hinweis, 300) || null, reihenfolge, jetzt()
);
verlauf("aufgabe", id, "angelegt", werIst(p), titel);
return json(res, { ok: true, id, reihenfolge, fortschritt: fortschritt(projekt.id) });
}
/* POST /webdesign/admin/aufgaben/:id — Punkt ändern */
export async function adminAufgabeAendern(req, res) {
const p = await darf(req, "WD_PROJEKTE_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const aufgabe = db.prepare(`SELECT * FROM wd_aufgaben WHERE id = ?`).get(String(req.params.id || ""));
if (!aufgabe) return json(res, { ok: false, error: "Punkt nicht gefunden." }, 404);
const felder = [];
const werte = [];
const b = req.body || {};
if (typeof b.titel === "string") {
const t = text(b.titel, 200);
if (!t) return json(res, { ok: false, error: "Der Titel darf nicht leer sein." }, 400);
felder.push("titel = ?"); werte.push(t);
}
if (typeof b.beschreibung === "string") {
felder.push("beschreibung = ?"); werte.push(text(b.beschreibung, 600) || null);
}
if (typeof b.hinweis === "string") {
felder.push("hinweis = ?"); werte.push(text(b.hinweis, 300) || null);
}
if (KATEGORIEN.includes(b.kategorie)) {
felder.push("kategorie = ?"); werte.push(b.kategorie);
}
if (WER_DRAN.includes(b.werDran)) {
felder.push("wer_dran = ?"); werte.push(b.werDran);
}
if (b.nichtEnthalten !== undefined) {
felder.push("nicht_enthalten = ?"); werte.push(b.nichtEnthalten ? 1 : 0);
}
if (AUFGABE_STATUS.includes(b.status)) {
felder.push("status = ?"); werte.push(b.status);
/* erledigt_am wird beim Zurücksetzen geleert. Bliebe das Datum
stehen, zeigte die Liste später "erledigt am 3. März" bei einem
offenen Punkt — ein Widerspruch, den niemand mehr aufklärt. */
felder.push("erledigt_am = ?"); werte.push(b.status === "erledigt" ? jetzt() : null);
}
if (Number.isInteger(b.reihenfolge)) {
felder.push("reihenfolge = ?"); werte.push(b.reihenfolge);
}
if (!felder.length) return json(res, { ok: false, error: "Nichts zu ändern." }, 400);
felder.push("aktualisiert_am = ?"); werte.push(jetzt());
werte.push(aufgabe.id);
db.prepare(`UPDATE wd_aufgaben SET ${felder.join(", ")} WHERE id = ?`).run(...werte);
verlauf("aufgabe", aufgabe.id, "geaendert", werIst(p), aufgabe.titel);
return json(res, { ok: true, fortschritt: fortschritt(aufgabe.projekt_id) });
}
/* POST /webdesign/admin/aufgaben/:id/loeschen
Echtes Löschen, kein Archivieren — anders als bei Kunden und Anfragen.
Begründung: Ein versehentlich angelegter Punkt ist Arbeitsmaterial,
keine Geschäftsunterlage. Ein archivierter, aber unsichtbarer Punkt
wäre hier nur Ballast. Der Verlauf hält fest, dass es ihn gab. */
export async function adminAufgabeLoeschen(req, res) {
const p = await darf(req, "WD_PROJEKTE_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const aufgabe = db.prepare(`SELECT * FROM wd_aufgaben WHERE id = ?`).get(String(req.params.id || ""));
if (!aufgabe) return json(res, { ok: false, error: "Punkt nicht gefunden." }, 404);
db.prepare(`DELETE FROM wd_aufgaben WHERE id = ?`).run(aufgabe.id);
verlauf("aufgabe", aufgabe.id, "geloescht", werIst(p), aufgabe.titel);
return json(res, { ok: true, fortschritt: fortschritt(aufgabe.projekt_id) });
}
/* POST /webdesign/admin/projekte/:id/aufgaben/vorlage
Vorlage nachträglich einspielen — für Projekte, die vor dieser
Funktion angelegt wurden, oder wenn eine Liste versehentlich geleert
wurde. Fügt HINZU statt zu ersetzen: Vorhandene Punkte könnten schon
abgehakt sein, und ein Ersetzen würde diesen Fortschritt löschen. */
export async function adminVorlageAnwenden(req, res) {
const p = await darf(req, "WD_PROJEKTE_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const projekt = db
.prepare(`SELECT id, kunde_id, paket FROM wd_projekte WHERE id = ?`)
.get(String(req.params.id || ""));
if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404);
const paket = text(req.body?.paket, 40) || projekt.paket;
const vorlage = db
.prepare(
`SELECT titel, beschreibung, kategorie, wer_dran, reihenfolge
FROM wd_aufgaben_vorlagen WHERE paket = ? AND aktiv = 1 ORDER BY reihenfolge`
)
.all(paket);
if (!vorlage.length) {
return json(res, { ok: false, error: "Für dieses Paket gibt es keine Vorlage." }, 404);
}
/* Titel, die schon in der Liste stehen, werden übersprungen. Sonst
erzeugt ein zweiter Klick eine doppelte Liste — und das merkt man
erst, wenn der Kunde sie sieht. */
const vorhanden = new Set(
db.prepare(`SELECT titel FROM wd_aufgaben WHERE projekt_id = ?`).all(projekt.id)
.map((z) => z.titel.toLowerCase())
);
const einfuegen = db.prepare(
`INSERT INTO wd_aufgaben
(id, projekt_id, kunde_id, titel, beschreibung, kategorie, status, wer_dran, reihenfolge, erstellt_am)
VALUES (?,?,?,?,?,?,'offen',?,?,?)`
);
let neu = 0;
db.transaction(() => {
for (const v of vorlage) {
if (vorhanden.has(v.titel.toLowerCase())) continue;
einfuegen.run(neueId(), projekt.id, projekt.kunde_id, v.titel, v.beschreibung,
v.kategorie, v.wer_dran, v.reihenfolge, jetzt());
neu++;
}
})();
verlauf("projekt", projekt.id, "vorlage_angewendet", werIst(p), `${paket}: ${neu} Punkte`);
return json(res, {
ok: true,
neu,
uebersprungen: vorlage.length - neu,
fortschritt: fortschritt(projekt.id),
});
}
+22 -2
View File
@@ -18,6 +18,7 @@ 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,
@@ -254,8 +255,27 @@ export async function projektAnlegen(req, res) {
.run(kundeId, jetzt(), anfrageId);
}
verlauf("projekt", id, "angelegt", p.sitzung.actor, { nummer, titel, preisCent });
return json(res, { ok: true, id, nummer, anzahlungText: centZuText(anzahlungCent) });
/* 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),
});
}
/* ---------------------------------------------------------------------
+9 -2
View File
@@ -493,5 +493,12 @@ export function portalProfilSpeichern(req, res) {
return json(res, { ok: true });
}
/* Für Tests und die Verwaltung nutzbar. */
export { passwortHashen, passwortPruefen, PASSWORT_MIN };
/* Für Tests und die Verwaltung nutzbar.
sitzungLesen/brauchtKunde werden von webdesign-aufgaben.js und
webdesign-postfach.js mitbenutzt. Bewusst dieselbe Funktion und keine
Kopie: Die Prüfung enthält den Teil, der eine laufende Sitzung sofort
beendet, wenn der Zugang zwischenzeitlich gesperrt wurde. Eine zweite,
nachgebaute Fassung würde diesen Teil früher oder später verlieren —
und dann hätte eine Sperre in einem Teil des Portals keine Wirkung. */
export { passwortHashen, passwortPruefen, PASSWORT_MIN, sitzungLesen, brauchtKunde };
@@ -0,0 +1,253 @@
/* =====================================================================
routes/webdesign-postfach.js — Nachrichten ohne Projektbezug
Wunsch vom 22.08.2026: "auf dieser seite sollen die kunden mir auch
nachrichten hinterlassen können, für noch kleinigkeiten oder sachen
die ich brauch."
Bis hierher konnte ein Kunde nur innerhalb eines Projekts schreiben.
Wer noch kein Projekt hatte — also genau der Zustand, in dem die
meisten Fragen entstehen — konnte gar nichts sagen und musste zur
E-Mail greifen. Damit war der Verlauf weg, sobald man ihn brauchte.
Der heikelste Punkt in dieser Datei ist die Spalte "intern". Eine
versehentlich sichtbare interne Notiz ist der peinlichste Fehler, den
so ein System machen kann. Deshalb steht der Filter "intern = 0" in
JEDER Kundenabfrage direkt im SQL und nicht in einer Hilfsfunktion,
die man beim nächsten Mal vergisst mitzurufen.
===================================================================== */
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 { brauchtKunde } from "./webdesign-portal.js";
import {
jetzt, neueId, text, verlauf, besucherIp, ipHash,
versuchZaehlen, zuVieleVersuche, fensterZuruecksetzen,
} from "../lib/webdesign-helfer.js";
const TEXT_MAX = 4000;
async function darf(req, recht) {
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 };
}
/* ---------------------------------------------------------------------
KUNDENSEITE
--------------------------------------------------------------------- */
/* GET /webdesign/portal/postfach */
export function portalPostfach(req, res) {
const kunde = brauchtKunde(req, res);
if (!kunde) return;
const nachrichten = db
.prepare(
`SELECT n.id, n.autor, n.text, n.projekt_id, n.aufgabe_id, n.erstellt_am,
n.gelesen_kunde, p.titel AS projekt_titel, a.titel AS aufgabe_titel
FROM wd_postfach n
LEFT JOIN wd_projekte p ON p.id = n.projekt_id
LEFT JOIN wd_aufgaben a ON a.id = n.aufgabe_id
WHERE n.kunde_id = ? AND n.intern = 0
ORDER BY n.erstellt_am ASC`
)
.all(kunde.id);
/* Das Lesen selbst markiert als gelesen. Ein eigener "gelesen"-Knopf
würde nur vergessen — und ein Lesestatus, dem man nicht trauen
kann, ist schlechter als keiner. */
db.prepare(
`UPDATE wd_postfach SET gelesen_kunde = 1
WHERE kunde_id = ? AND intern = 0 AND autor <> 'kunde' AND gelesen_kunde = 0`
).run(kunde.id);
/* Projekte für die Auswahl "worum geht es?" — optional, aber es
erspart viele Rückfragen, wenn der Bezug gleich mitkommt. */
const projekte = db
.prepare(`SELECT id, titel FROM wd_projekte WHERE kunde_id = ? AND archiviert = 0 ORDER BY erstellt_am DESC`)
.all(kunde.id);
return json(res, { ok: true, nachrichten, projekte });
}
/* POST /webdesign/portal/postfach */
export function portalPostfachSenden(req, res) {
const kunde = brauchtKunde(req, res);
if (!kunde) return;
/* Begrenzung pro KUNDE, nicht pro IP. Sie schützt hier nicht vor
Fremden — hereingekommen ist nur, wer angemeldet ist — sondern vor
einer Schleife im Browser, die tausend Zeilen schreibt. */
const schluessel = "postfach:" + kunde.id;
const FENSTER = 10;
fensterZuruecksetzen(schluessel, FENSTER);
if (zuVieleVersuche(schluessel, 20, FENSTER)) {
return json(
res,
{ ok: false, error: "Bitte kurz warten — es sind gerade sehr viele Nachrichten auf einmal." },
429
);
}
const inhalt = text(req.body?.text, TEXT_MAX);
if (!inhalt) return json(res, { ok: false, error: "Bitte schreib etwas." }, 400);
/* Bezüge werden gegen die eigenen Daten geprüft, nicht übernommen.
Eine fremde Projekt-ID würde sonst im Verlauf des fremden Projekts
auftauchen. Ungültiges wird still zu null — die Nachricht geht
trotzdem raus. Sie wegen eines Nebenfelds abzulehnen, wäre für den
Kunden unverständlich. */
let projektId = text(req.body?.projektId, 60) || null;
if (projektId) {
const treffer = db
.prepare(`SELECT id FROM wd_projekte WHERE id = ? AND kunde_id = ?`)
.get(projektId, kunde.id);
projektId = treffer ? treffer.id : null;
}
let aufgabeId = text(req.body?.aufgabeId, 60) || null;
if (aufgabeId) {
const treffer = db
.prepare(`SELECT id, projekt_id FROM wd_aufgaben WHERE id = ? AND kunde_id = ?`)
.get(aufgabeId, kunde.id);
aufgabeId = treffer ? treffer.id : null;
if (treffer && !projektId) projektId = treffer.projekt_id;
}
versuchZaehlen(schluessel, 20, FENSTER);
const id = neueId();
db.prepare(
`INSERT INTO wd_postfach
(id, kunde_id, autor, text, projekt_id, aufgabe_id, gelesen_kunde, gelesen_admin, intern, erstellt_am)
VALUES (?,?,'kunde',?,?,?,1,0,0,?)`
).run(id, kunde.id, inhalt, projektId, aufgabeId, jetzt());
verlauf("postfach", id, "kunde_geschrieben", "kunde:" + kunde.id, ipHash(besucherIp(req)));
return json(res, { ok: true, id });
}
/* ---------------------------------------------------------------------
VERWALTUNGSSEITE
--------------------------------------------------------------------- */
/* GET /webdesign/admin/postfach — Übersicht aller Verläufe
Gruppiert nach Kunde, nicht als eine lange Liste aller Nachrichten.
Man arbeitet ein Gespräch ab, nicht eine Zeile. Ungelesenes steht
oben — das ist die einzige Sortierung, die morgens nützt. */
export async function adminPostfachListe(req, res) {
const p = await darf(req, "WD_KUNDEN_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const verlaeufe = db
.prepare(
`SELECT k.id AS kunde_id, k.name, k.firma, k.email,
COUNT(n.id) AS anzahl,
SUM(CASE WHEN n.gelesen_admin = 0 AND n.autor = 'kunde' THEN 1 ELSE 0 END) AS ungelesen,
MAX(n.erstellt_am) AS letzte,
(SELECT text FROM wd_postfach WHERE kunde_id = k.id
ORDER BY erstellt_am DESC LIMIT 1) AS letzter_text,
(SELECT autor FROM wd_postfach WHERE kunde_id = k.id
ORDER BY erstellt_am DESC LIMIT 1) AS letzter_autor
FROM wd_kunden k
JOIN wd_postfach n ON n.kunde_id = k.id
GROUP BY k.id
ORDER BY ungelesen DESC, letzte DESC`
)
.all();
const ungelesen = verlaeufe.reduce((s, v) => s + (v.ungelesen || 0), 0);
return json(res, { ok: true, verlaeufe, ungelesen });
}
/* GET /webdesign/admin/postfach/:kundeId — ein Verlauf vollständig */
export async function adminPostfachVerlauf(req, res) {
const p = await darf(req, "WD_KUNDEN_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const kundeId = String(req.params.kundeId || "");
const kunde = db
.prepare(`SELECT id, name, firma, email, sprache FROM wd_kunden WHERE id = ?`)
.get(kundeId);
if (!kunde) return json(res, { ok: false, error: "Kunde nicht gefunden." }, 404);
const nachrichten = db
.prepare(
`SELECT n.*, p.titel AS projekt_titel, a.titel AS aufgabe_titel
FROM wd_postfach n
LEFT JOIN wd_projekte p ON p.id = n.projekt_id
LEFT JOIN wd_aufgaben a ON a.id = n.aufgabe_id
WHERE n.kunde_id = ? ORDER BY n.erstellt_am ASC`
)
.all(kundeId);
db.prepare(`UPDATE wd_postfach SET gelesen_admin = 1 WHERE kunde_id = ? AND gelesen_admin = 0`)
.run(kundeId);
const projekte = db
.prepare(`SELECT id, titel FROM wd_projekte WHERE kunde_id = ? ORDER BY erstellt_am DESC`)
.all(kundeId);
return json(res, { ok: true, kunde, nachrichten, projekte });
}
/* POST /webdesign/admin/postfach/:kundeId — antworten oder Notiz ablegen */
export async function adminPostfachSenden(req, res) {
const p = await darf(req, "WD_KUNDEN_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const kundeId = String(req.params.kundeId || "");
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);
const inhalt = text(req.body?.text, TEXT_MAX);
if (!inhalt) return json(res, { ok: false, error: "Bitte schreib etwas." }, 400);
/* intern muss ausdrücklich === true sein. Ein "1", "ja" oder ein
versehentlich gesetzter Wahrheitswert soll NICHT reichen, um eine
Nachricht sichtbar zu machen oder zu verbergen — hier ist die
Richtung der Vorgabe entscheidend: im Zweifel sichtbar für den
Kunden, also niemals eine interne Notiz, die keine sein sollte. */
const intern = req.body?.intern === true ? 1 : 0;
let projektId = text(req.body?.projektId, 60) || null;
if (projektId) {
const treffer = db
.prepare(`SELECT id FROM wd_projekte WHERE id = ? AND kunde_id = ?`)
.get(projektId, kundeId);
projektId = treffer ? treffer.id : null;
}
const autor = p.sitzung?.actor === "vanvan" ? "vanvan" : "dogfather";
const id = neueId();
db.prepare(
`INSERT INTO wd_postfach
(id, kunde_id, autor, text, projekt_id, gelesen_kunde, gelesen_admin, intern, erstellt_am)
VALUES (?,?,?,?,?,?,1,?,?)`
).run(id, kundeId, autor, inhalt, projektId, intern ? 1 : 0, intern, jetzt());
verlauf("postfach", id, intern ? "notiz" : "antwort", "team:" + autor, null);
return json(res, { ok: true, id, intern: !!intern });
}
/* GET /webdesign/admin/postfach-zaehler — nur die Zahl für das Abzeichen
Eigener, sehr kleiner Endpunkt: Der Zähler wird im Hintergrund
regelmäßig abgefragt. Dafür jedes Mal alle Verläufe samt Vorschautext
zu laden, wäre Verschwendung. */
export async function adminPostfachZaehler(req, res) {
const p = await darf(req, "WD_KUNDEN_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const n = db
.prepare(`SELECT COUNT(*) AS n FROM wd_postfach WHERE gelesen_admin = 0 AND autor = 'kunde'`)
.get().n;
return json(res, { ok: true, ungelesen: n });
}
+208
View File
@@ -0,0 +1,208 @@
/* =====================================================================
test-aufgaben-postfach.mjs — Prüft Aufgabenliste und Postfach.
Der Kern sind NICHT die Erfolgsfälle, sondern die Angriffsfälle:
Kunde B versucht mit ECHTEN IDs von Kunde A dessen Aufgabenliste zu
lesen, dessen Punkte abzuhaken und dessen Projekt an eine eigene
Nachricht zu hängen. Ein Test, der nur prüft, ob das Richtige
funktioniert, hätte jede dieser Lücken durchgelassen.
Läuft gegen eine WEGWERF-Datenbank (DB_PATH auf eine temporäre
Datei), nie gegen die echte.
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
const tmp = mkdtempSync(join(tmpdir(), "wd-test-"));
process.env.DB_PATH = join(tmp, "test.db");
process.env.WEBDESIGN_API_SECRET = "test-secret-nur-fuer-diesen-lauf";
const { db, initDb } = await import("./db.js");
initDb();
const { vorlagenSicherstellen, aufgabenAusVorlage } = await import("./lib/webdesign-vorlagen.js");
const { fortschritt } = await import("./routes/webdesign-aufgaben.js");
const { jetzt, neueId, fensterZuruecksetzen, zuVieleVersuche, versuchZaehlen } =
await import("./lib/webdesign-helfer.js");
let ok = 0, fehler = 0;
function pruefe(name, bedingung, zusatz) {
if (bedingung) { ok++; console.log(" ok " + name); }
else { fehler++; console.log(" FEHL " + name + (zusatz ? " -> " + zusatz : "")); }
}
/* --- Aufbau: zwei Kunden, je ein Projekt ------------------------- */
function kundeAnlegen(name, email) {
const id = neueId();
db.prepare(
`INSERT INTO wd_kunden (id, email, name, sprache, freigeschaltet, erstellt_am)
VALUES (?,?,?,'de',1,?)`
).run(id, email, name, jetzt());
return id;
}
function projektAnlegen(kundeId, nummer, paket) {
const id = neueId();
db.prepare(
`INSERT INTO wd_projekte (id, nummer, kunde_id, titel, paket, status, waehrung, erstellt_am)
VALUES (?,?,?,?,?,'briefing','EUR',?)`
).run(id, nummer, kundeId, "Projekt " + nummer, paket, jetzt());
return id;
}
const kundeA = kundeAnlegen("Kunde A", "[email protected]");
const kundeB = kundeAnlegen("Kunde B", "[email protected]");
const projektA = projektAnlegen(kundeA, "P-0001", "website");
const projektB = projektAnlegen(kundeB, "P-0002", "onepager");
console.log("\n1. VORLAGEN");
const v1 = vorlagenSicherstellen();
pruefe("Vorlagen werden angelegt", v1.angelegt > 40, "angelegt: " + v1.angelegt);
const v2 = vorlagenSicherstellen();
pruefe("Zweiter Aufruf legt NICHTS doppelt an", v2.uebersprungen === true);
const gesamtVorlagen = db.prepare(`SELECT COUNT(*) n FROM wd_aufgaben_vorlagen`).get().n;
pruefe("Anzahl unverändert nach zweitem Aufruf", gesamtVorlagen === v1.angelegt);
for (const paket of ["onepager", "website", "shop", "verwaltung", "betreuung"]) {
const n = db.prepare(`SELECT COUNT(*) n FROM wd_aufgaben_vorlagen WHERE paket = ?`).get(paket).n;
pruefe(`Paket "${paket}" hat Punkte`, n > 0, "n=" + n);
}
const ohneKunde = db
.prepare(`SELECT COUNT(*) n FROM wd_aufgaben_vorlagen WHERE wer_dran = 'kunde'`).get().n;
pruefe("Es gibt Punkte, die auf den Kunden warten", ohneKunde > 10, "n=" + ohneKunde);
console.log("\n2. VORLAGE AUF PROJEKT ANWENDEN");
const nA = aufgabenAusVorlage(projektA, kundeA, "website");
const nB = aufgabenAusVorlage(projektB, kundeB, "onepager");
pruefe("Projekt A hat Aufgaben", nA > 10, "n=" + nA);
pruefe("Projekt B hat Aufgaben", nB > 10, "n=" + nB);
const fremd = db
.prepare(`SELECT COUNT(*) n FROM wd_aufgaben WHERE projekt_id = ? AND kunde_id <> ?`)
.get(projektA, kundeA).n;
pruefe("Keine Aufgabe von A trägt eine fremde Kunden-ID", fremd === 0);
const unbekannt = aufgabenAusVorlage(projektA, kundeA, "gibtesnicht");
pruefe("Unbekanntes Paket legt nichts an", unbekannt === 0);
console.log("\n3. FORTSCHRITT WIRD RICHTIG GERECHNET");
let f = fortschritt(projektA);
pruefe("Am Anfang 0 %", f.prozent === 0, "prozent=" + f.prozent);
pruefe("Offene Punkte beim Kunden werden gezählt", f.beimKunden > 0, "n=" + f.beimKunden);
pruefe("Alle vier Kategorien vorhanden",
["start", "gestaltung", "umsetzung", "abschluss"].every((k) => f.jeKategorie[k]));
const alle = db.prepare(`SELECT id FROM wd_aufgaben WHERE projekt_id = ? ORDER BY reihenfolge`).all(projektA);
db.prepare(`UPDATE wd_aufgaben SET status='erledigt', erledigt_am=? WHERE id=?`).run(jetzt(), alle[0].id);
f = fortschritt(projektA);
pruefe("Ein erledigter Punkt erhöht den Prozentwert", f.erledigt === 1 && f.prozent > 0);
/* "entfaellt" darf den Nenner verkleinern, nicht als erledigt zählen. */
const vorher = fortschritt(projektA);
db.prepare(`UPDATE wd_aufgaben SET status='entfaellt' WHERE id=?`).run(alle[1].id);
const nachher = fortschritt(projektA);
pruefe("'entfaellt' verkleinert die Gesamtzahl", nachher.gesamt === vorher.gesamt - 1);
pruefe("'entfaellt' zählt nicht als erledigt", nachher.erledigt === vorher.erledigt);
/* Alles erledigt -> exakt 100, kein 99 durch Rundung. */
db.prepare(`UPDATE wd_aufgaben SET status='erledigt' WHERE projekt_id=? AND status<>'entfaellt'`).run(projektA);
f = fortschritt(projektA);
pruefe("Alles erledigt ergibt exakt 100 %", f.prozent === 100, "prozent=" + f.prozent);
pruefe("Dann wartet nichts mehr auf den Kunden", f.beimKunden === 0);
const leer = fortschritt(projektAnlegen(kundeA, "P-0009", "unklar"));
pruefe("Projekt ohne Aufgaben stürzt nicht ab (0 %, keine Division durch 0)",
leer.prozent === 0 && leer.gesamt === 0);
console.log("\n4. ABSCHOTTUNG — Kunde B greift Kunde A an");
/* Genau die Abfragen, die in den Routen stehen — mit B's Sitzung und
A's echten IDs. */
const zugriffProjekt = db
.prepare(`SELECT id FROM wd_projekte WHERE id = ? AND kunde_id = ?`).get(projektA, kundeB);
pruefe("B kann A's Projekt NICHT laden", !zugriffProjekt);
const zugriffAufgabe = db
.prepare(`SELECT id FROM wd_aufgaben WHERE id = ? AND kunde_id = ?`).get(alle[0].id, kundeB);
pruefe("B kann A's Aufgabe NICHT laden (also auch nicht abhaken)", !zugriffAufgabe);
const eigene = db
.prepare(`SELECT COUNT(*) n FROM wd_aufgaben WHERE kunde_id = ?`).get(kundeB).n;
pruefe("B sieht nur die eigenen Aufgaben", eigene === nB, `${eigene} statt ${nB}`);
console.log("\n5. POSTFACH");
function postfachEintrag(kundeId, autor, text, opts = {}) {
const id = neueId();
db.prepare(
`INSERT INTO wd_postfach (id, kunde_id, autor, text, projekt_id, aufgabe_id,
gelesen_kunde, gelesen_admin, intern, erstellt_am) VALUES (?,?,?,?,?,?,?,?,?,?)`
).run(id, kundeId, autor, text, opts.projektId || null, opts.aufgabeId || null,
opts.gelesenKunde ?? 0, opts.gelesenAdmin ?? 0, opts.intern ? 1 : 0, jetzt());
return id;
}
postfachEintrag(kundeA, "kunde", "Frage ohne Projekt");
postfachEintrag(kundeA, "dogfather", "Antwort");
const internId = postfachEintrag(kundeA, "dogfather", "INTERNE NOTIZ — darf der Kunde nie sehen", { intern: true });
postfachEintrag(kundeB, "kunde", "Nachricht von B");
/* Die Abfrage aus portalPostfach, wortgleich. */
const sichtA = db
.prepare(`SELECT id, text FROM wd_postfach WHERE kunde_id = ? AND intern = 0 ORDER BY erstellt_am`)
.all(kundeA);
pruefe("Kunde A sieht seine zwei Nachrichten", sichtA.length === 2, "n=" + sichtA.length);
pruefe("Die INTERNE Notiz ist NICHT dabei", !sichtA.some((n) => n.id === internId));
pruefe("Kein Text von B taucht bei A auf", !sichtA.some((n) => n.text.includes("von B")));
const sichtB = db
.prepare(`SELECT id FROM wd_postfach WHERE kunde_id = ? AND intern = 0`).all(kundeB);
pruefe("Kunde B sieht genau seine eine Nachricht", sichtB.length === 1);
/* Nachricht ohne Projekt UND ohne Aufgabe muss erlaubt sein — das war
der ganze Grund für die eigene Tabelle. */
const ohneBezug = db
.prepare(`SELECT COUNT(*) n FROM wd_postfach WHERE projekt_id IS NULL AND aufgabe_id IS NULL`).get().n;
pruefe("Nachrichten ganz ohne Projektbezug sind möglich", ohneBezug >= 3, "n=" + ohneBezug);
/* Fremde Projekt-ID: Die Route prüft gegen den eigenen Bestand. */
const fremdesProjekt = db
.prepare(`SELECT id FROM wd_projekte WHERE id = ? AND kunde_id = ?`).get(projektA, kundeB);
pruefe("B kann seine Nachricht NICHT an A's Projekt hängen", !fremdesProjekt);
/* Lesestatus getrennt für beide Seiten. */
db.prepare(`UPDATE wd_postfach SET gelesen_kunde = 1
WHERE kunde_id = ? AND intern = 0 AND autor <> 'kunde' AND gelesen_kunde = 0`).run(kundeA);
const nochUngelesenAdmin = db
.prepare(`SELECT COUNT(*) n FROM wd_postfach WHERE gelesen_admin = 0 AND autor = 'kunde'`).get().n;
pruefe("Kunde liest -> Admin-Zähler bleibt unberührt", nochUngelesenAdmin === 2, "n=" + nochUngelesenAdmin);
console.log("\n6. MENGENBEGRENZUNG SPERRT NICHT DAUERHAFT");
/* Das ist der Fehler, der beim Schreiben fast eingezogen wäre:
versuchZaehlen zählt nur hoch. Ohne rollendes Fenster wäre ein Kunde
nach 20 Nachrichten für immer ausgesperrt. */
const s = "postfach:testkunde";
for (let i = 0; i < 20; i++) versuchZaehlen(s, 20, 10);
pruefe("Nach 20 Nachrichten in Folge greift die Bremse", zuVieleVersuche(s, 20, 10) === true);
db.prepare(`UPDATE wd_login_versuche SET zuletzt = ?, gesperrt_bis = ? WHERE schluessel = ?`)
.run(new Date(Date.now() - 60 * 60000).toISOString(),
new Date(Date.now() - 50 * 60000).toISOString(), s);
fensterZuruecksetzen(s, 10);
pruefe("Eine Stunde später darf er wieder schreiben", zuVieleVersuche(s, 20, 10) === false);
const weg = db.prepare(`SELECT COUNT(*) n FROM wd_login_versuche WHERE schluessel = ?`).get(s).n;
pruefe("Der alte Zähler ist wirklich gelöscht, nicht nur ignoriert", weg === 0);
console.log("\n7. DATENBANK BLEIBT SAUBER");
const fk = db.prepare(`PRAGMA foreign_key_check`).all();
pruefe("Keine kaputten Verweise", fk.length === 0, JSON.stringify(fk).slice(0, 200));
/* Kunde löschen muss Aufgaben und Postfach mitnehmen — sonst bleiben
Kundendaten liegen, nachdem jemand ihre Löschung verlangt hat. */
db.prepare(`DELETE FROM wd_kunden WHERE id = ?`).run(kundeB);
const restA = db.prepare(`SELECT COUNT(*) n FROM wd_aufgaben WHERE kunde_id = ?`).get(kundeB).n;
const restP = db.prepare(`SELECT COUNT(*) n FROM wd_postfach WHERE kunde_id = ?`).get(kundeB).n;
pruefe("Kunde löschen entfernt seine Aufgaben", restA === 0, "rest=" + restA);
pruefe("Kunde löschen entfernt sein Postfach", restP === 0, "rest=" + restP);
pruefe("Die Aufgaben des ANDEREN Kunden bleiben",
db.prepare(`SELECT COUNT(*) n FROM wd_aufgaben WHERE kunde_id = ?`).get(kundeA).n > 0);
console.log(`\n===== ${ok} bestanden, ${fehler} fehlgeschlagen =====`);
db.close();
rmSync(tmp, { recursive: true, force: true });
process.exit(fehler ? 1 : 0);