/* ===================================================================== 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 }); }