505 lines
20 KiB
JavaScript
505 lines
20 KiB
JavaScript
/* =====================================================================
|
|
routes/webdesign-portal.js — Kundenportal (Masterplan S.12)
|
|
|
|
⚠️ DIE WICHTIGSTE REGEL DIESER DATEI
|
|
|
|
"Jeder Kunde sieht ausschließlich die eigenen Projekte, Dateien,
|
|
Nachrichten, Kosten, Freigaben und Betreuungsdaten." (Masterplan S.12)
|
|
|
|
Umgesetzt wird das NICHT dadurch, dass die Oberfläche nur die eigenen
|
|
Sachen anzeigt — die Oberfläche liegt im Browser des Kunden und ist
|
|
damit beliebig manipulierbar. Umgesetzt wird es dadurch, dass JEDE
|
|
Abfrage hier die kunde_id aus der SITZUNG in die WHERE-Bedingung
|
|
nimmt, nie aus der Anfrage.
|
|
|
|
Konkret: `WHERE id = ? AND kunde_id = ?` statt `WHERE id = ?`.
|
|
Wer eine fremde Projekt-Kennung errät, bekommt dadurch "nicht
|
|
gefunden" — nicht die Daten. Das ist der Unterschied zwischen einer
|
|
Zugriffsbeschränkung und einem Datenleck.
|
|
|
|
REGISTRIERUNG: bewusst KEINE Selbstregistrierung.
|
|
Masterplan S.12: "Projektkauf oder laufende Betreuung wird vor
|
|
Freischaltung geprüft". Ein Portal, in das sich jeder selbst eintragen
|
|
kann, wäre genau das Gegenteil. Kunden werden in der Verwaltung
|
|
angelegt und bekommen einen Einladungslink; erst darüber setzen sie
|
|
ihr Passwort.
|
|
===================================================================== */
|
|
|
|
import { randomBytes, scrypt as scryptRoh, timingSafeEqual } from "node:crypto";
|
|
import { promisify } from "node:util";
|
|
import { db } from "../db.js";
|
|
import { json } from "../lib/http.js";
|
|
import {
|
|
jetzt, neueId, text, istEmail, emailNormal, tokenHash,
|
|
besucherIp, zuVieleVersuche, versuchZaehlen, versucheZuruecksetzen,
|
|
verlauf, spracheOderDe, centZuText,
|
|
} from "../lib/webdesign-helfer.js";
|
|
|
|
const scrypt = promisify(scryptRoh);
|
|
|
|
/* ---------------------------------------------------------------------
|
|
Passwörter
|
|
|
|
scrypt statt eines einfachen Hashes: Ein SHA-256 über ein Passwort ist
|
|
auf heutiger Hardware milliardenfach pro Sekunde durchprobierbar.
|
|
scrypt ist absichtlich langsam UND speicherhungrig — das macht auch
|
|
Grafikkarten-Angriffe teuer. Node bringt es eingebaut mit, es braucht
|
|
also kein zusätzliches Paket.
|
|
|
|
Der Zufallswert (salt) steht mit im gespeicherten Text: zwei Kunden mit
|
|
demselben Passwort bekommen dadurch verschiedene Hashes, und eine
|
|
fertige Regenbogentabelle nützt nichts.
|
|
--------------------------------------------------------------------- */
|
|
const SCRYPT_LAENGE = 64;
|
|
const SCRYPT_KOSTEN = { N: 16384, r: 8, p: 1 };
|
|
|
|
async function passwortHashen(passwort) {
|
|
const salz = randomBytes(16).toString("hex");
|
|
const abgeleitet = await scrypt(passwort, salz, SCRYPT_LAENGE, SCRYPT_KOSTEN);
|
|
return `scrypt$${salz}$${abgeleitet.toString("hex")}`;
|
|
}
|
|
|
|
async function passwortPruefen(passwort, gespeichert) {
|
|
if (!gespeichert || !gespeichert.startsWith("scrypt$")) return false;
|
|
const [, salz, hex] = gespeichert.split("$");
|
|
if (!salz || !hex) return false;
|
|
const abgeleitet = await scrypt(passwort, salz, SCRYPT_LAENGE, SCRYPT_KOSTEN);
|
|
const erwartet = Buffer.from(hex, "hex");
|
|
if (erwartet.length !== abgeleitet.length) return false;
|
|
return timingSafeEqual(erwartet, abgeleitet);
|
|
}
|
|
|
|
/* Mindestanforderung bewusst LÄNGE statt Zeichenklassen.
|
|
"Hund1234!" erfüllt jede Zeichenklassen-Regel und ist trotzdem schlecht;
|
|
"mein blauer stuhl steht krumm" erfüllt keine und ist ausgezeichnet.
|
|
Länge ist der einzige Faktor, der zuverlässig hilft. */
|
|
const PASSWORT_MIN = 10;
|
|
|
|
/* ---------------------------------------------------------------------
|
|
Sitzungen
|
|
Serverseitig gespeichert, damit sie sich in der Verwaltung gezielt
|
|
beenden lassen (Masterplan S.13 "Aktive Sitzungen abmelden").
|
|
--------------------------------------------------------------------- */
|
|
const SITZUNG_STUNDEN = 12;
|
|
|
|
function sitzungAnlegen(kundeId) {
|
|
const token = randomBytes(32).toString("base64url");
|
|
db.prepare(
|
|
`INSERT INTO wd_kunden_sitzungen (token_hash, kunde_id, erstellt_am, laeuft_ab, letzte_aktion)
|
|
VALUES (?, ?, ?, ?, ?)`
|
|
).run(
|
|
tokenHash(token),
|
|
kundeId,
|
|
jetzt(),
|
|
new Date(Date.now() + SITZUNG_STUNDEN * 3600 * 1000).toISOString(),
|
|
jetzt()
|
|
);
|
|
return token;
|
|
}
|
|
|
|
function sitzungLesen(req) {
|
|
const kopf = req.headers["authorization"] || "";
|
|
const token = kopf.startsWith("Bearer ") ? kopf.slice(7) : null;
|
|
if (!token) return null;
|
|
|
|
const zeile = db
|
|
.prepare(`SELECT * FROM wd_kunden_sitzungen WHERE token_hash = ?`)
|
|
.get(tokenHash(token));
|
|
if (!zeile) return null;
|
|
|
|
if (zeile.laeuft_ab < jetzt()) {
|
|
db.prepare(`DELETE FROM wd_kunden_sitzungen WHERE token_hash = ?`).run(zeile.token_hash);
|
|
return null;
|
|
}
|
|
|
|
const kunde = db.prepare(`SELECT * FROM wd_kunden WHERE id = ?`).get(zeile.kunde_id);
|
|
/* Auch eine gültige Sitzung endet sofort, wenn der Zugang inzwischen
|
|
gesperrt oder entzogen wurde — sonst wäre eine Sperre erst nach
|
|
zwölf Stunden wirksam. */
|
|
if (!kunde || kunde.gesperrt || !kunde.freigeschaltet || kunde.archiviert) return null;
|
|
|
|
db.prepare(`UPDATE wd_kunden_sitzungen SET letzte_aktion = ? WHERE token_hash = ?`)
|
|
.run(jetzt(), zeile.token_hash);
|
|
return kunde;
|
|
}
|
|
|
|
function brauchtKunde(req, res) {
|
|
const kunde = sitzungLesen(req);
|
|
if (!kunde) {
|
|
json(res, { ok: false, error: "Bitte melde dich erneut an." }, 401);
|
|
return null;
|
|
}
|
|
return kunde;
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
ANMELDUNG
|
|
--------------------------------------------------------------------- */
|
|
export async function portalAnmelden(req, res) {
|
|
const email = emailNormal(req.body?.email);
|
|
const passwort = String(req.body?.passwort || "");
|
|
|
|
/* Die Sperre hängt an der E-Mail, nicht an der IP. Grund: Ein Angreifer
|
|
wechselt IPs mühelos, aber nicht das Zielkonto. Eine IP-Sperre würde
|
|
zudem mehrere Kunden hinter demselben Firmenanschluss gemeinsam
|
|
aussperren — genau der Fehler, der im Universe am 19.08.2026 auftrat. */
|
|
const schluessel = "portal:" + email;
|
|
if (email && zuVieleVersuche(schluessel)) {
|
|
return json(res, {
|
|
ok: false,
|
|
error: "Zu viele Fehlversuche. Bitte in 15 Minuten erneut versuchen oder das Passwort zurücksetzen.",
|
|
}, 429);
|
|
}
|
|
|
|
if (!email || !istEmail(email) || !passwort) {
|
|
return json(res, { ok: false, error: "Bitte E-Mail und Passwort eingeben." }, 400);
|
|
}
|
|
|
|
const kunde = db.prepare(`SELECT * FROM wd_kunden WHERE email = ?`).get(email);
|
|
|
|
/* Immer dieselbe Meldung, egal ob die Adresse unbekannt ist oder nur
|
|
das Passwort falsch war. Unterschiedliche Meldungen verraten sonst,
|
|
welche E-Mail-Adressen Kunden sind. */
|
|
const abweisen = () => {
|
|
versuchZaehlen(schluessel);
|
|
return json(res, { ok: false, error: "E-Mail oder Passwort stimmt nicht." }, 401);
|
|
};
|
|
|
|
if (!kunde || !kunde.passwort_hash) return abweisen();
|
|
if (!(await passwortPruefen(passwort, kunde.passwort_hash))) return abweisen();
|
|
|
|
if (kunde.gesperrt) {
|
|
return json(res, {
|
|
ok: false,
|
|
error: "Dieser Zugang ist gesperrt. Bitte melde dich direkt bei Dogfather.",
|
|
}, 403);
|
|
}
|
|
if (!kunde.freigeschaltet) {
|
|
return json(res, {
|
|
ok: false,
|
|
error: "Dein Zugang ist noch nicht freigeschaltet. Sobald dein Projekt startet, bekommst du Bescheid.",
|
|
}, 403);
|
|
}
|
|
|
|
versucheZuruecksetzen(schluessel);
|
|
const token = sitzungAnlegen(kunde.id);
|
|
db.prepare(`UPDATE wd_kunden SET letzter_login = ? WHERE id = ?`).run(jetzt(), kunde.id);
|
|
verlauf("kunde", kunde.id, "angemeldet", "kunde:" + kunde.id, { ip: besucherIp(req) ? "erfasst" : null });
|
|
|
|
return json(res, {
|
|
ok: true,
|
|
token,
|
|
kunde: { name: kunde.name, firma: kunde.firma, email: kunde.email, sprache: kunde.sprache },
|
|
});
|
|
}
|
|
|
|
export function portalAbmelden(req, res) {
|
|
const kopf = req.headers["authorization"] || "";
|
|
const token = kopf.startsWith("Bearer ") ? kopf.slice(7) : null;
|
|
if (token) db.prepare(`DELETE FROM wd_kunden_sitzungen WHERE token_hash = ?`).run(tokenHash(token));
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
EINLADUNG EINLÖSEN — hier setzt der Kunde sein Passwort
|
|
--------------------------------------------------------------------- */
|
|
export async function portalEinladungPruefen(req, res) {
|
|
const token = text(req.query.token, 200);
|
|
const zeile = db
|
|
.prepare(`SELECT * FROM wd_kunden_token WHERE token_hash = ? AND zweck IN ('einladung','passwort')`)
|
|
.get(tokenHash(token));
|
|
|
|
if (!zeile || zeile.benutzt_am || zeile.laeuft_ab < jetzt()) {
|
|
return json(res, { ok: false, error: "Dieser Link ist abgelaufen oder wurde schon benutzt." }, 400);
|
|
}
|
|
const kunde = db.prepare(`SELECT name, email FROM wd_kunden WHERE id = ?`).get(zeile.kunde_id);
|
|
if (!kunde) return json(res, { ok: false, error: "Zugang nicht gefunden." }, 404);
|
|
|
|
return json(res, { ok: true, name: kunde.name, email: kunde.email, zweck: zeile.zweck });
|
|
}
|
|
|
|
export async function portalPasswortSetzen(req, res) {
|
|
const token = text(req.body?.token, 200);
|
|
const passwort = String(req.body?.passwort || "");
|
|
|
|
if (passwort.length < PASSWORT_MIN) {
|
|
return json(res, {
|
|
ok: false,
|
|
error: `Bitte mindestens ${PASSWORT_MIN} Zeichen. Ein kurzer Satz ist sicherer und leichter zu merken als ein kryptisches Wort.`,
|
|
}, 400);
|
|
}
|
|
|
|
const zeile = db
|
|
.prepare(`SELECT * FROM wd_kunden_token WHERE token_hash = ? AND zweck IN ('einladung','passwort')`)
|
|
.get(tokenHash(token));
|
|
if (!zeile || zeile.benutzt_am || zeile.laeuft_ab < jetzt()) {
|
|
return json(res, { ok: false, error: "Dieser Link ist abgelaufen oder wurde schon benutzt." }, 400);
|
|
}
|
|
|
|
const hash = await passwortHashen(passwort);
|
|
|
|
/* Alles in EINER Transaktion. Bräche der Vorgang zwischen "Passwort
|
|
gesetzt" und "Einladung verbraucht" ab, bliebe ein gültiger
|
|
Einladungslink zurück, mit dem sich das Passwort erneut ändern ließe. */
|
|
db.transaction(() => {
|
|
db.prepare(
|
|
`UPDATE wd_kunden SET passwort_hash = ?, email_bestaetigt = 1, aktualisiert_am = ? WHERE id = ?`
|
|
).run(hash, jetzt(), zeile.kunde_id);
|
|
db.prepare(`UPDATE wd_kunden_token SET benutzt_am = ? WHERE token_hash = ?`)
|
|
.run(jetzt(), zeile.token_hash);
|
|
/* Alle bestehenden Sitzungen beenden: Wer das Passwort ändert, will
|
|
damit oft genau erreichen, dass jemand anders rausfliegt. */
|
|
db.prepare(`DELETE FROM wd_kunden_sitzungen WHERE kunde_id = ?`).run(zeile.kunde_id);
|
|
})();
|
|
|
|
verlauf("kunde", zeile.kunde_id, "passwort_gesetzt", "kunde:" + zeile.kunde_id, null);
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
DASHBOARD — alles, was der Kunde auf einen Blick braucht
|
|
--------------------------------------------------------------------- */
|
|
export function portalUebersicht(req, res) {
|
|
const kunde = brauchtKunde(req, res);
|
|
if (!kunde) return;
|
|
|
|
const projekte = db
|
|
.prepare(
|
|
`SELECT id, nummer, titel, paket, status, naechster_schritt, wartet_auf,
|
|
preis_cent, anzahlung_cent, anzahlung_bezahlt, rest_bezahlt, waehrung,
|
|
start_am, richttermin, uebergabe_am, portfolio_freigabe
|
|
FROM wd_projekte WHERE kunde_id = ? AND archiviert = 0
|
|
ORDER BY erstellt_am DESC`
|
|
)
|
|
.all(kunde.id);
|
|
|
|
/* Beträge zusätzlich als fertigen Text mitgeben. Sonst muss die
|
|
Oberfläche das Formatieren nachbauen — und zwei Formatierungen
|
|
driften garantiert irgendwann auseinander. */
|
|
for (const p of projekte) {
|
|
p.preis_text = centZuText(p.preis_cent, p.waehrung);
|
|
p.anzahlung_text = centZuText(p.anzahlung_cent, p.waehrung);
|
|
p.offene_aenderungen = db
|
|
.prepare(`SELECT COUNT(*) AS n FROM wd_aenderungen WHERE projekt_id = ? AND status IN ('eingereicht','geprueft','angeboten')`)
|
|
.get(p.id).n;
|
|
p.ungelesene_nachrichten = db
|
|
.prepare(`SELECT COUNT(*) AS n FROM wd_nachrichten WHERE projekt_id = ? AND intern = 0 AND autor != 'kunde' AND gelesen_kunde = 0`)
|
|
.get(p.id).n;
|
|
}
|
|
|
|
const abos = db
|
|
.prepare(`SELECT id, paket, preis_cent, status, start_am, naechste_zahlung, laeuft_bis
|
|
FROM wd_betreuung_abos WHERE kunde_id = ? AND status != 'abgelaufen'`)
|
|
.all(kunde.id);
|
|
for (const a of abos) a.preis_text = centZuText(a.preis_cent);
|
|
|
|
const offeneZahlungen = db
|
|
.prepare(
|
|
`SELECT id, nummer, art, zweck_text, betrag_cent, waehrung, status, erstellt_am
|
|
FROM wd_zahlungen WHERE kunde_id = ? AND status IN ('offen','freigegeben')
|
|
ORDER BY erstellt_am ASC`
|
|
)
|
|
.all(kunde.id);
|
|
for (const z of offeneZahlungen) z.betrag_text = centZuText(z.betrag_cent, z.waehrung);
|
|
|
|
return json(res, {
|
|
ok: true,
|
|
kunde: {
|
|
name: kunde.name, firma: kunde.firma, email: kunde.email,
|
|
sprache: kunde.sprache, betreuung_aktiv: !!kunde.betreuung_aktiv,
|
|
},
|
|
projekte,
|
|
abos,
|
|
offeneZahlungen,
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
EIN PROJEKT IM DETAIL
|
|
--------------------------------------------------------------------- */
|
|
export function portalProjekt(req, res) {
|
|
const kunde = brauchtKunde(req, res);
|
|
if (!kunde) return;
|
|
|
|
const id = text(req.params.id, 64);
|
|
|
|
/* HIER steht die eigentliche Absicherung: "AND kunde_id = ?".
|
|
Ohne diesen Zusatz könnte jeder angemeldete Kunde durch Raten oder
|
|
Ausprobieren einer fremden Kennung ein fremdes Projekt öffnen. */
|
|
const projekt = db
|
|
.prepare(`SELECT * FROM wd_projekte WHERE id = ? AND kunde_id = ? AND archiviert = 0`)
|
|
.get(id, kunde.id);
|
|
if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404);
|
|
|
|
projekt.preis_text = centZuText(projekt.preis_cent, projekt.waehrung);
|
|
projekt.anzahlung_text = centZuText(projekt.anzahlung_cent, projekt.waehrung);
|
|
|
|
/* fuer_kunde = 1: interne Arbeitsdateien bleiben unsichtbar. */
|
|
const dateien = db
|
|
.prepare(
|
|
`SELECT id, anzeigename, typ, groesse, kategorie, version, hochgeladen_von, erstellt_am
|
|
FROM wd_dateien WHERE projekt_id = ? AND kunde_id = ? AND geloescht = 0 AND fuer_kunde = 1
|
|
ORDER BY erstellt_am DESC`
|
|
)
|
|
.all(id, kunde.id);
|
|
|
|
// intern = 0: interne Notizen des Teams sieht der Kunde nicht.
|
|
const nachrichten = db
|
|
.prepare(
|
|
`SELECT id, autor, text, erstellt_am FROM wd_nachrichten
|
|
WHERE projekt_id = ? AND kunde_id = ? AND intern = 0 ORDER BY erstellt_am ASC`
|
|
)
|
|
.all(id, kunde.id);
|
|
|
|
const aenderungen = db
|
|
.prepare(
|
|
`SELECT id, titel, beschreibung, status, aufwand_text, zusatzpreis_cent,
|
|
zusatzdauer_text, antwort, erstellt_am, entschieden_am
|
|
FROM wd_aenderungen WHERE projekt_id = ? AND kunde_id = ? ORDER BY erstellt_am DESC`
|
|
)
|
|
.all(id, kunde.id);
|
|
for (const a of aenderungen) a.zusatzpreis_text = centZuText(a.zusatzpreis_cent);
|
|
|
|
const zahlungen = db
|
|
.prepare(
|
|
`SELECT id, nummer, art, zweck_text, betrag_cent, waehrung, status, erstellt_am, bezahlt_am
|
|
FROM wd_zahlungen WHERE projekt_id = ? AND kunde_id = ? ORDER BY erstellt_am DESC`
|
|
)
|
|
.all(id, kunde.id);
|
|
for (const z of zahlungen) z.betrag_text = centZuText(z.betrag_cent, z.waehrung);
|
|
|
|
// Als gelesen markieren — der Kunde hat sie ja gerade geöffnet.
|
|
db.prepare(
|
|
`UPDATE wd_nachrichten SET gelesen_kunde = 1 WHERE projekt_id = ? AND kunde_id = ? AND intern = 0`
|
|
).run(id, kunde.id);
|
|
|
|
return json(res, { ok: true, projekt, dateien, nachrichten, aenderungen, zahlungen });
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
ÄNDERUNGSWUNSCH EINREICHEN (Masterplan S.12)
|
|
--------------------------------------------------------------------- */
|
|
export function portalAenderungEinreichen(req, res) {
|
|
const kunde = brauchtKunde(req, res);
|
|
if (!kunde) return;
|
|
|
|
const projektId = text(req.body?.projektId, 64);
|
|
const titel = text(req.body?.titel, 160);
|
|
const beschreibung = text(req.body?.beschreibung, 4000);
|
|
|
|
if (!titel || !beschreibung) {
|
|
return json(res, { ok: false, error: "Bitte gib der Idee einen Titel und beschreib sie kurz." }, 400);
|
|
}
|
|
|
|
const projekt = db
|
|
.prepare(`SELECT id FROM wd_projekte WHERE id = ? AND kunde_id = ? AND archiviert = 0`)
|
|
.get(projektId, kunde.id);
|
|
if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404);
|
|
|
|
const id = neueId();
|
|
db.prepare(
|
|
`INSERT INTO wd_aenderungen (id, projekt_id, kunde_id, titel, beschreibung, status, erstellt_am)
|
|
VALUES (?,?,?,?,?,'eingereicht',?)`
|
|
).run(id, projektId, kunde.id, titel, beschreibung, jetzt());
|
|
|
|
verlauf("projekt", projektId, "aenderung_eingereicht", "kunde:" + kunde.id, { titel });
|
|
return json(res, { ok: true, id });
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
ZUSATZANGEBOT ANNEHMEN ODER ABLEHNEN
|
|
--------------------------------------------------------------------- */
|
|
export function portalAenderungEntscheiden(req, res) {
|
|
const kunde = brauchtKunde(req, res);
|
|
if (!kunde) return;
|
|
|
|
const id = text(req.params.id, 64);
|
|
const annehmen = req.body?.annehmen === true;
|
|
|
|
const aenderung = db
|
|
.prepare(`SELECT * FROM wd_aenderungen WHERE id = ? AND kunde_id = ?`)
|
|
.get(id, kunde.id);
|
|
if (!aenderung) return json(res, { ok: false, error: "Nicht gefunden." }, 404);
|
|
|
|
/* Nur ein Vorgang, zu dem tatsächlich ein Angebot vorliegt, lässt sich
|
|
entscheiden. Ohne diese Prüfung könnte jemand eine gerade erst
|
|
eingereichte Idee als "angenommen" markieren — und damit eine
|
|
Zahlungspflicht behaupten, die nie beziffert wurde. */
|
|
if (aenderung.status !== "angeboten") {
|
|
return json(res, {
|
|
ok: false,
|
|
error: "Zu dieser Idee liegt noch kein Angebot vor. Sobald der Aufwand feststeht, kannst du entscheiden.",
|
|
}, 409);
|
|
}
|
|
|
|
db.prepare(
|
|
`UPDATE wd_aenderungen SET status = ?, entschieden_am = ?, aktualisiert_am = ? WHERE id = ?`
|
|
).run(annehmen ? "angenommen" : "abgelehnt", jetzt(), jetzt(), id);
|
|
|
|
verlauf("projekt", aenderung.projekt_id, annehmen ? "zusatz_angenommen" : "zusatz_abgelehnt",
|
|
"kunde:" + kunde.id, { titel: aenderung.titel, preisCent: aenderung.zusatzpreis_cent });
|
|
|
|
return json(res, { ok: true, status: annehmen ? "angenommen" : "abgelehnt" });
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
NACHRICHT ZUM PROJEKT
|
|
--------------------------------------------------------------------- */
|
|
export function portalNachricht(req, res) {
|
|
const kunde = brauchtKunde(req, res);
|
|
if (!kunde) return;
|
|
|
|
const projektId = text(req.body?.projektId, 64);
|
|
const inhalt = text(req.body?.text, 4000);
|
|
if (!inhalt) return json(res, { ok: false, error: "Die Nachricht ist leer." }, 400);
|
|
|
|
const projekt = db
|
|
.prepare(`SELECT id FROM wd_projekte WHERE id = ? AND kunde_id = ? AND archiviert = 0`)
|
|
.get(projektId, kunde.id);
|
|
if (!projekt) return json(res, { ok: false, error: "Projekt nicht gefunden." }, 404);
|
|
|
|
const id = neueId();
|
|
db.prepare(
|
|
`INSERT INTO wd_nachrichten (id, projekt_id, kunde_id, autor, text, intern, gelesen_kunde, erstellt_am)
|
|
VALUES (?,?,?,'kunde',?,0,1,?)`
|
|
).run(id, projektId, kunde.id, inhalt, jetzt());
|
|
|
|
return json(res, { ok: true, id });
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
EIGENES PROFIL
|
|
--------------------------------------------------------------------- */
|
|
export function portalProfilSpeichern(req, res) {
|
|
const kunde = brauchtKunde(req, res);
|
|
if (!kunde) return;
|
|
|
|
const name = text(req.body?.name, 120) || kunde.name;
|
|
const firma = text(req.body?.firma, 160);
|
|
const telefon = text(req.body?.telefon, 60);
|
|
const sprache = spracheOderDe(req.body?.sprache || kunde.sprache);
|
|
const benachrichtigungen = req.body?.benachrichtigungen === false ? 0 : 1;
|
|
|
|
/* Die E-Mail-Adresse ist bewusst NICHT änderbar: sie ist gleichzeitig
|
|
der Anmeldename. Eine Änderung braucht eine Bestätigung über beide
|
|
Adressen, sonst sperrt ein Tippfehler den eigenen Zugang aus. Das
|
|
läuft über die Verwaltung. */
|
|
db.prepare(
|
|
`UPDATE wd_kunden SET name = ?, firma = ?, telefon = ?, sprache = ?,
|
|
benachrichtigungen = ?, aktualisiert_am = ? WHERE id = ?`
|
|
).run(name, firma || null, telefon || null, sprache, benachrichtigungen, jetzt(), kunde.id);
|
|
|
|
verlauf("kunde", kunde.id, "profil_geaendert", "kunde:" + kunde.id, null);
|
|
return json(res, { ok: true });
|
|
}
|
|
|
|
/* 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 };
|