WIP: Aufgaben- und Postfach-Routen (Test folgt auf dem Server)
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user