254 lines
10 KiB
JavaScript
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 });
|
|
}
|