Files
DogFatherGit 521f0d5a80 Kompletten Ablauf einmal durchgespielt — echten Fehler dabei gefunden
Wunsch: "ich will dass du es abcheckst" — nicht Stück für Stück (das war
schon geprüft), sondern EINMAL DURCHGÄNGIG als ein zusammenhängender
Ablauf, so wie ein echter Auftrag tatsächlich läuft: Anfrage → Angebot
→ Zusage im Portal → Anzahlung → laufende Uhr → Benachrichtigung →
Arbeit → Restzahlung → Gegenprobe mit Abbruch.

test-kette.mjs bildet genau das ab, gegen die echten Funktionen aus
server-internal/, mit einer Wegwerf-Datenbank. 40 Prüfungen, alle grün.

DABEI GEFUNDEN: Der Liefertermin-Countdown im Portal prüfte nicht, ob
die Uhr überhaupt schon läuft. Ein frisch zugesagtes Projekt hat schon
ein termin_am (aus der Zusage vorgerechnet), aber uhr_start_am steht
noch auf null, solange die Anzahlung nicht da ist. Der Kunde hätte also
direkt nach der Zusage einen tickenden Countdown gesehen — und wenn die
Anzahlung eintrifft, wird der Termin ab dem Zahlungstag NEU berechnet
und springt dann sichtbar nach hinten. Das sieht aus wie ein Fehler,
auch wenn die Zahl rechnerisch stimmt: Kaum zu erklären, warum "noch 8
Tage" plötzlich wieder "noch 10 Tage" werden.

Jetzt zwei klar getrennte Zustände: Vor der Anzahlung steht "Startet,
sobald deine Anzahlung da ist — voraussichtlicher Liefertermin danach:
ca. {datum}" (kein Countdown, aber der Termin bleibt sichtbar, kein
Verstecken). Danach der echte Countdown. Fünf Sprachen ergänzt.

Nebenbei einen irreführenden Kommentar korrigiert: Ein neuer Kunde wird
beim Angebot-Senden SOFORT freigeschaltet (nicht erst bei Zusage, wie
der alte Kommentar behauptete) — sonst könnte er sich gar nicht
einloggen, um sein eigenes Angebot anzusehen. Der Code war richtig,
nur die Erklärung falsch, und mein erster Testentwurf ist genau darauf
hereingefallen.

Bei der Gelegenheit auch die bekannte better-sqlite3-Aussetzer-Eigenart
(zufälliger Absturz beim Prozessende, dokumentiert seit früheren
Commits) noch einmal eingegrenzt: Derselbe Import in test-angebote.mjs
brach mal nach "GEHEIM", mal nach "VORLAGEN" ab — der Absturzpunkt
verschiebt sich zwischen identischen Läufen. Das ist der endgültige
Beweis, dass es reine GC-Zeitfensterflakiness in der nativen
Bibliothek ist, kein Fehler im eigenen Code.

Geprüft: 40 (Kette) + 55 + 56 + 70 + 28 + 49 serverseitig (alle
mehrfach unabhängig grün), 261 im Browser (Portal, Angebot, Übersicht,
Verwaltung, Abbruch, Design). Ein Screenshot bestätigt den neuen
Wartehinweis visuell.
2026-08-24 16:54:35 +02:00

572 lines
24 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,
ipHash,
} from "../lib/webdesign-helfer.js";
import { werktageBis } from "../lib/webdesign-termine.js";
import { portalAngebote, portalAngebotZusagen as angebotZusagenKern,
portalAngebotAblehnen as angebotAblehnenKern } from "./webdesign-angebote.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, termin_am, uhr_start_am, angenommen_am, 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;
/* Die verbleibende Zeit wird HIER gerechnet, nicht im Browser.
Der Browser kennt die luxemburgischen Feiertage nicht. Rechnete er
selbst, saehe der Kunde eine andere Zahl als ich in der Verwaltung
-- bis zu drei Tage daneben. Zwei verschiedene Zahlen fuer denselben
Termin sind schlimmer als gar keine.
Bei uebergebenen Projekten wird nicht mehr gezaehlt: Ein Countdown
auf etwas Fertiges wuerde irgendwann "ueberfaellig" anzeigen,
obwohl alles erledigt ist.
GEFUNDEN beim Durchspielen der kompletten Kette am 24.08.2026: Die
Bedingung pruefte NICHT, ob die Uhr ueberhaupt schon laeuft. Ein
frisch zugesagtes Projekt hat schon ein termin_am (aus der Zusage
vorgerechnet), aber uhr_start_am steht noch auf null, solange die
Anzahlung nicht da ist. Ohne diese Pruefung saehe der Kunde direkt
nach der Zusage einen tickenden Countdown -- und wenn die Anzahlung
dann eintrifft, wird der Termin ab dem Zahlungstag NEU berechnet
und springt ploetzlich weiter nach hinten. Das sieht aus wie ein
Fehler, auch wenn es rechnerisch stimmt: Man kann einem Kunden
schwer erklaeren, warum "noch 8 Tage" ploetzlich wieder "noch 10
Tage" wurden. */
const uhrLaeuft = !!p.uhr_start_am;
const laeuftNoch = p.status !== "uebergeben" && p.status !== "abgebrochen";
p.verbleibende_werktage = (p.termin_am && laeuftNoch && uhrLaeuft)
? werktageBis(p.termin_am, new Date())
: null;
/* Damit die Oberflaeche unterscheiden kann: "noch kein Termin",
"Termin steht, aber die Uhr laeuft noch nicht" und "laeuft". */
p.wartet_auf_anzahlung = !!p.termin_am && laeuftNoch && !uhrLaeuft;
}
/* Offene Angebote GANZ nach vorn.
Ein Angebot ist das Einzige im Portal, das eine Entscheidung
verlangt -- alles andere ist Auskunft. Stünde es unter den
laufenden Projekten, sähe man es erst nach dem Scrollen. */
const angebote = portalAngebote(kunde.id);
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,
angebote,
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 };
/* ---------------------------------------------------------------------
ANGEBOTE — Hüllen um die Kernfunktionen
Die Logik steht in webdesign-angebote.js, weil sie dort mit dem
Erzeugen zusammengehört. Hier kommt nur dazu, was das Portal
beisteuern muss: WER gerade angemeldet ist und von welcher Verbindung
aus zugesagt wurde.
Der IP-Abdruck wird gehasht, nicht im Klartext gespeichert: Er dient
dem Nachweis, dass die Zusage von diesem Zugang kam -- dafür genügt
ein Vergleichswert, die Adresse selbst braucht niemand aufzubewahren.
--------------------------------------------------------------------- */
export async function portalAngebotZusagen(req, res) {
const kunde = brauchtKunde(req, res);
if (!kunde) return;
return angebotZusagenKern(req, res, kunde, ipHash(besucherIp(req)));
}
export async function portalAngebotAblehnen(req, res) {
const kunde = brauchtKunde(req, res);
if (!kunde) return;
return angebotAblehnenKern(req, res, kunde);
}