Files
dogfather-universe/server-internal/routes/webdesign-postfach.js
T

254 lines
10 KiB
JavaScript

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