Files
dogfather-universe/server-internal/lib/webdesign-helfer.js
T
DogFatherGitandClaude Opus 5 cea265eb8c Angebote: der Kunde sagt mit einem Klick zu
DER LEITGEDANKE: SO WENIG TIPPEN WIE MOEGLICH
Niemand schreibt hier den Leistungsumfang. Er entsteht aus der
Aufgabenvorlage des Pakets -- denselben 92 Punkten, nach denen spaeter
gearbeitet wird. Der Nebeneffekt ist wichtiger als die gesparte
Tipparbeit: Was im Angebot steht, IST die Arbeitsliste. Ein von Hand
geschriebenes Angebot und eine getrennt gepflegte Aufgabenliste laufen
unweigerlich auseinander -- und dann steht im Angebot etwas, das niemand
abarbeitet.

Zu tun bleibt: Paket waehlen, Preis bestaetigen. Beides ist vorbelegt,
Laufzeit und Ablaufdatum werden gerechnet.

DER WORTLAUT WIRD BEIM ABSENDEN EINGEFROREN
Ein angenommenes Angebot ist ein Vertrag. Bei einem Streit zaehlt, WAS
dem Kunden gezeigt wurde, als er zusagte. Wuerde der Text bei jeder
Ansicht neu aus den Vorlagen erzeugt, staende nach der naechsten
Vorlagenaenderung etwas anderes da als damals. Die Zusage wird mit
Zeitpunkt und (gehashter) Herkunft belegt -- die Beweislast liegt beim
Unternehmer.

WAS AUTOMATISCH PASSIERT
Angebot raus -> Anfrage steht auf 'angebot' (der Wunsch: 'wenn ich ein
angebot rausschicke soll der automatisch das erkennen'). Zusage ->
Projekt angelegt, Aufgabenliste eingesetzt, Anzahlung als offene
Rechnung erzeugt, Anfrage auf 'angenommen', Benachrichtigung an mich.

Die UHR startet dabei NICHT. Sie startet erst mit dem Zahlungseingang --
das ist die Regel aus dem vorigen Schritt, und sie gilt auch dann, wenn
der Kunde selbst zugesagt hat. Genau dieser Punkt wird ausdruecklich
geprueft: Es fuehlt sich richtig an, mit der Zusage loszulegen.

ZWEI FEHLER IN DER GELDANZEIGE GEFUNDEN
Beide fielen erst auf, als das erste Angebot ueber tausend Euro entstand
-- alle frueheren Testbetraege lagen darunter:
- Kein Tausenderpunkt: 1490 Euro erschienen als '1490,00 €'.
- Das Minuszeichen ging verloren: Math.trunc(-0.5) ergibt -0, und -0
  schreibt sich als '0'. Eine ERSTATTUNG von 50 Cent erschien damit als
  '0,50 €' -- also wie eine Forderung. Ausgerechnet beim Abbruch mit
  Rueckzahlung waere das der falsche Ort fuer einen Anzeigefehler.
Bewusst von Hand statt ueber Intl.NumberFormat: Die Ausgabe muss auf dem
Server und im Browser zeichengleich sein -- ein Betrag, der in der
Verwaltung anders aussieht als im Portal, saet Zweifel an der Zahl.

Geprueft: 56 zu den Angeboten, 53 zur Automatik (inkl. der neun
Geldpruefungen), 70 zur Annahme, 28 zur Suche -- alle gegen eine echte
Datenbank. Darunter: zweiter Klick legt nichts doppelt an, abgelaufene
Angebote lassen sich nicht mehr annehmen (ein Angebot, das man sieht
aber nicht annehmen kann, waere eine Falle), ein fremder Kunde erfaehrt
nicht einmal, dass ein Angebot existiert.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-24 12:14:27 +02:00

221 lines
9.3 KiB
JavaScript

/* =====================================================================
webdesign-helfer.js — geteilte Bausteine für den Webdesign-Bereich.
Bewusst klein gehalten und ohne Abhängigkeit zu den Universe-Modulen,
damit eine Änderung dort diesen Bereich nicht mitreißt.
===================================================================== */
import { randomUUID, createHash, timingSafeEqual } from "node:crypto";
import { db } from "../db.js";
export const SPRACHEN = ["de", "de-CH", "en", "fr", "pt"];
export const PAKETE = ["onepager", "website", "shop", "verwaltung", "betreuung", "unklar"];
export const ANFRAGE_STATUS = [
"neu", "pruefung", "rueckfrage", "angebot", "angenommen", "abgeschlossen", "abgelehnt",
];
export const PROJEKT_STATUS = [
"briefing", "angebot", "design", "entwicklung", "tests", "abnahme", "uebergeben", "pausiert", "abgebrochen",
];
export function jetzt() {
return new Date().toISOString();
}
export function neueId() {
return randomUUID();
}
/* Fortlaufende, gut aussprechbare Nummer: "A-2608-0001".
Der Zähler läuft pro Präfix, damit die Nummer im Gespräch kurz bleibt
("die A-2608-7") statt eine 36-stellige Kennung vorlesen zu müssen. */
export function naechsteNummer(tabelle, praefix) {
const d = new Date();
const monat = String(d.getUTCMonth() + 1).padStart(2, "0");
const jahr = String(d.getUTCFullYear()).slice(2);
const stamm = `${praefix}-${jahr}${monat}-`;
const zeile = db
.prepare(`SELECT nummer FROM ${tabelle} WHERE nummer LIKE ? ORDER BY nummer DESC LIMIT 1`)
.get(stamm + "%");
const letzte = zeile ? parseInt(String(zeile.nummer).slice(stamm.length), 10) : 0;
return stamm + String((Number.isFinite(letzte) ? letzte : 0) + 1).padStart(4, "0");
}
/* Zeichenketten aus Formularen einheitlich säubern:
- Rand-Leerzeichen weg
- Länge begrenzt (sonst kann jemand mit einem 10-MB-Feld die Datenbank
aufblähen; die Begrenzung im Body-Parser allein reicht nicht, weil
ein einzelnes Feld auch innerhalb eines gültigen Bodys riesig sein kann)
- Steuerzeichen entfernen, die in Logs und E-Mails Unfug anrichten */
export function text(wert, maxLaenge = 2000) {
if (wert == null) return "";
return String(wert)
/* Steuerzeichen raus, aber Zeilenumbruch und Tabulator behalten --
die braucht ein mehrzeiliges Textfeld.
Geschrieben ueber Unicode-Eigenschaften statt ueber einen Bereich
aus echten Steuerzeichen: solche Zeichen im Quelltext ueberleben
weder Copy-and-paste noch jeden Editor zuverlaessig, und ein
stillschweigend kaputter Filter faellt erst auf, wenn er gebraucht
wird. \P{Cc} bedeutet "kein Steuerzeichen", die Verneinung der
Klasse trifft also genau die Steuerzeichen ausser \n und \t. */
.replace(/[^\P{Cc}\n\t]/gu, "")
.trim()
.slice(0, maxLaenge);
}
/* Sehr bewusst KEINE komplizierte E-Mail-Regex. Die einzige zuverlässige
Prüfung ist der Bestätigungslink; alles andere lehnt entweder gültige
Adressen ab (z.B. neue Endungen, Umlaut-Domains) oder lässt Unsinn durch.
Geprüft wird deshalb nur das strukturell Notwendige. */
export function istEmail(wert) {
const s = text(wert, 254).toLowerCase();
if (!s || s.length < 6) return false;
const teile = s.split("@");
if (teile.length !== 2) return false;
const [lokal, domain] = teile;
if (!lokal || !domain) return false;
if (!domain.includes(".")) return false;
if (/\s/.test(s)) return false;
return true;
}
export function emailNormal(wert) {
return text(wert, 254).toLowerCase();
}
/* IP nur als Hash speichern (Datensparsamkeit). Der Hash reicht, um
wiederholte Absendungen derselben Quelle zu erkennen, lässt sich aber
nicht ohne Weiteres zurückrechnen. Der feste Zusatz verhindert, dass
jemand mit einer fertigen Regenbogentabelle alle IPv4-Adressen durchprobiert. */
export function ipHash(ip) {
if (!ip) return null;
const zusatz = process.env.WD_IP_SALT || "dogfather-webdesign";
return createHash("sha256").update(zusatz + "|" + String(ip)).digest("hex").slice(0, 32);
}
export function tokenHash(token) {
return createHash("sha256").update(String(token)).digest("hex");
}
export function gleichZeitsicher(a, b) {
const pa = Buffer.from(String(a ?? ""), "utf8");
const pb = Buffer.from(String(b ?? ""), "utf8");
if (pa.length !== pb.length) return false; // timingSafeEqual wirft sonst
return timingSafeEqual(pa, pb);
}
/* Änderungsverlauf schreiben (Masterplan S.13). Absichtlich als eigene
Funktion und nicht inline: so ist garantiert, dass jeder Eintrag
dieselben Felder hat und keiner vergessen wird. */
export function verlauf(bereich, objektId, aktion, wer, details) {
db.prepare(
`INSERT INTO wd_verlauf (id, bereich, objekt_id, aktion, wer, details, erstellt_am)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).run(
neueId(),
bereich,
objektId || null,
aktion,
wer || "system",
details ? JSON.stringify(details) : null,
jetzt()
);
}
/* Einfache Mengenbegrenzung, in der Datenbank statt im Arbeitsspeicher —
dieser Dienst kann neu starten, ohne dass ein Absender dadurch seine
Sperre verliert. */
export function zuVieleVersuche(schluessel, maxAnzahl, sperreMinuten) {
const zeile = db.prepare(`SELECT * FROM wd_login_versuche WHERE schluessel = ?`).get(schluessel);
if (!zeile) return false;
if (zeile.gesperrt_bis && zeile.gesperrt_bis > jetzt()) return true;
return false;
}
/* Rollendes Zeitfenster — löscht den Zähler, wenn seit der letzten
Zählung genug Zeit vergangen ist.
Warum das nötig ist: versuchZaehlen() zählt nur hoch. Bei der
Anmeldung ist das richtig, dort setzt ein erfolgreicher Login zurück.
Bei einer Mengenbegrenzung für erlaubte Handlungen gibt es aber
keinen "Erfolg", der zurücksetzt — der Zähler bliebe für immer auf
seinem Höchststand, und ab der zwanzigsten Nachricht würde JEDE
weitere sofort wieder eine Sperre auslösen. Aus einer Bremse gegen
Endlosschleifen wäre damit ein dauerhaftes Schreibverbot geworden.
Vor jeder Prüfung aufrufen, mit demselben Fenster wie die Sperre. */
export function fensterZuruecksetzen(schluessel, fensterMinuten) {
const grenze = new Date(Date.now() - fensterMinuten * 60000).toISOString();
db.prepare(`DELETE FROM wd_login_versuche WHERE schluessel = ? AND zuletzt < ?`)
.run(schluessel, grenze);
}
export function versuchZaehlen(schluessel, maxAnzahl = 5, sperreMinuten = 15) {
const zeile = db.prepare(`SELECT * FROM wd_login_versuche WHERE schluessel = ?`).get(schluessel);
const anzahl = (zeile?.anzahl || 0) + 1;
const gesperrtBis = anzahl >= maxAnzahl
? new Date(Date.now() + sperreMinuten * 60000).toISOString()
: null;
db.prepare(
`INSERT INTO wd_login_versuche (schluessel, anzahl, gesperrt_bis, zuletzt)
VALUES (?, ?, ?, ?)
ON CONFLICT(schluessel) DO UPDATE SET anzahl = excluded.anzahl,
gesperrt_bis = excluded.gesperrt_bis, zuletzt = excluded.zuletzt`
).run(schluessel, anzahl, gesperrtBis, jetzt());
return { anzahl, gesperrtBis };
}
export function versucheZuruecksetzen(schluessel) {
db.prepare(`DELETE FROM wd_login_versuche WHERE schluessel = ?`).run(schluessel);
}
/* Die echte Besucher-IP. Gleiche Begründung und gleiche Abhängigkeit wie
in server/gate.js: Cloudflare setzt diesen Header und die Firewall lässt
nur Cloudflare an die Ports 80/443. Ändert sich das, muss diese Stelle
zurückgebaut werden. */
export function besucherIp(req) {
const cf = req.headers["cf-connecting-ip"];
if (typeof cf === "string" && cf.trim() && cf.length <= 45) return cf.trim();
return req.ip || req.socket?.remoteAddress || null;
}
/* Sprache aus der Anfrage übernehmen, damit die Antwort-E-Mail in
derselben Sprache kommt, in der gefragt wurde. */
export function spracheOderDe(wert) {
const s = text(wert, 10);
return SPRACHEN.includes(s) ? s : "de";
}
/* Betrag in Cent -> lesbarer Text. Nie mit Gleitkomma rechnen. */
export function centZuText(cent, waehrung = "EUR") {
if (cent == null) return null;
/* ZWEI FEHLER, DIE HIER LANGE STECKTEN (gefunden 24.08.2026)
1. KEIN TAUSENDERPUNKT. Ein Angebot ueber 1490 Euro erschien als
"1490,00 €". Bei einem Betrag, den jemand bezahlen soll, ist das
nicht nur unschoen -- man verliest sich. Aufgefallen erst, als
das erste Angebot ueber tausend Euro entstand; alle frueheren
Testbetraege lagen darunter.
2. DAS MINUSZEICHEN GING VERLOREN. Math.trunc(-0.5) ergibt -0, und
-0 schreibt sich als "0". Eine Erstattung von 50 Cent erschien
damit als "0,50 €" -- also wie eine Forderung. Deshalb wird das
Vorzeichen jetzt getrennt vom Betrag behandelt, statt sich auf
die Ganzzahl zu verlassen.
Bewusst von Hand statt ueber Intl.NumberFormat: Die Ausgabe soll auf
dem Server und im Browser Zeichen fuer Zeichen gleich sein. Intl
haengt an der eingestellten Sprache -- und ein Betrag, der in der
Verwaltung anders aussieht als im Portal, saet Zweifel an der Zahl. */
const negativ = cent < 0;
const absolut = Math.abs(cent);
const ganz = Math.floor(absolut / 100);
const rest = String(absolut % 100).padStart(2, "0");
/* Dreiergruppen von rechts. */
const gruppiert = String(ganz).replace(/\B(?=(\d{3})+(?!\d))/g, ".");
return `${negativ ? "-" : ""}${gruppiert},${rest} ${waehrung === "EUR" ? "€" : waehrung}`;
}