Files
dogfather-universe/server-internal/routes/webdesign-anfragen.js
T
DogFatherGitandClaude Opus 5 4b3ec450d5 Webdesign-Bereich: Fundament, oeffentliche Seiten, Zugangsschutz, PayPal
Umsetzung des "Website Masterplan" (16 Seiten) unter /webdesign.

Zugangsschutz mit EIGENER Schranke (server/webdesign-gate.js) statt gate.js:
gate.js laesst seit dem oeffentlichen Start am 21.08.2026 jeden durch, weil
die Pruefung auf SITE_PUBLIC_LAUNCH_AT als allererste Zeile steht. Haette man
/webdesign dahintergehaengt, waere der ausdruecklich nicht-oeffentliche Bereich
inklusive Preisen und spaeteren Kundendaten ab der ersten Sekunde fuer jeden
lesbar gewesen. Eigenes Sitzungs-Cookie, bereich="webdesign" im Token, damit
ein gueltiges Universe-Cookie hier NICHT gilt. 37/37 Tests.

Sieben oeffentliche Seiten in fuenf Sprachen (de, de-CH mit echtem Dialekt, en,
fr, pt). Preise, Zeitrahmen, Paketnamen und die 30-%-Regel stehen an genau
EINER Stelle in wd-core.js -- der Masterplan verlangt "ueberall
widerspruchsfrei", und vier Kopien laufen bei der ersten Preisaenderung
auseinander.

Als App installierbar auf Handy und PC. Der Service Worker speichert bewusst
KEINE HTML-Seite zwischen: nach dem Abmelden wuerden sonst geschuetzte Seiten
weiter ausgeliefert, ohne dass der Server je gefragt wird. 18/18 Tests.

Handy-Abnahme ueber alle Seiten in fuenf Breiten (320-1440) und fuenf Sprachen:
40/40. Der Test fand 35 echte Fehler (Touch-Ziele unter 44px), behoben im
Designsystem statt einzeln pro Seite.

PayPal (Wunsch 22.08.2026 "sofort auf meinem paypal"): Orders API mit
intent=CAPTURE, also sofortiger Einzug statt blosser Reservierung. Gebuehr und
Nettobetrag getrennt gespeichert. Betraege durchgehend als Ganzzahl in Cent.
Gefaelschte Webhooks werden abgewiesen. Fail closed solange Zugangsdaten
fehlen. 28/28 Tests gegen einen nachgebauten PayPal-Server.

Datenbank: 15 Tabellen mit Praefix wd_, fachlich vollstaendig vom Universe
getrennt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-22 18:06:52 +02:00

314 lines
13 KiB
JavaScript

/* =====================================================================
routes/webdesign-anfragen.js — Projektanfragen (Masterplan S.10)
Zwei Welten in einer Datei:
- EIN öffentlicher Endpunkt (Formular absenden). Der ist von außen
erreichbar und deshalb der einzige, der wirklich gehärtet sein muss.
- Mehrere Verwaltungs-Endpunkte, die eine Team-Anmeldung verlangen.
Absichtliche Entscheidung: Das Formular erzeugt NIE einen Kunden und NIE
eine Preiszusage (Masterplan S.10: "Das Formular zeigt Richtwerte,
erstellt aber keinen automatisch verbindlichen Vertrag"). Es legt genau
eine Anfrage an, mehr nicht.
===================================================================== */
import { db } from "../db.js";
import { json } from "../lib/http.js";
import { requireSession } from "../lib/auth.js";
import { hasPermission } from "../lib/permissions.js";
import {
jetzt, neueId, naechsteNummer, text, istEmail, emailNormal,
ipHash, besucherIp, spracheOderDe, verlauf,
PAKETE, ANFRAGE_STATUS,
} from "../lib/webdesign-helfer.js";
/* ---------------------------------------------------------------------
Rechteprüfung. Der Eigentümer darf immer alles — genau wie im
restlichen internen Bereich.
--------------------------------------------------------------------- */
async function darf(req, recht) {
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 };
}
/* ---------------------------------------------------------------------
Missbrauchsschutz für den öffentlichen Endpunkt.
Masterplan S.10 verlangt "Spam- und Missbrauchsschutz ohne unnötige
Hürden" — also ausdrücklich KEIN Captcha. Stattdessen drei Maßnahmen,
die für echte Menschen unsichtbar sind:
1. Honigtopf: ein Feld, das im Browser versteckt ist. Menschen füllen es
nie aus, viele automatische Skripte schon.
2. Mindest-Ausfüllzeit: das Formular schickt mit, wann es geöffnet
wurde. Wer in unter drei Sekunden durch ein mehrstufiges Formular
kommt, hat es nicht gelesen.
3. Mengenbegrenzung pro Absender-IP.
Wichtig: Die ersten beiden sind höflich — sie melden Erfolg, speichern
aber nichts. Ein Skript, dem man sagt "erkannt", probiert es anders.
Eines, das "gespeichert" liest, hört auf.
--------------------------------------------------------------------- */
const ANFRAGE_MAX_PRO_STUNDE = 5;
const MINDEST_SEKUNDEN = 3;
function zuVieleAnfragen(hash) {
if (!hash) return false;
const grenze = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const zeile = db
.prepare(`SELECT COUNT(*) AS anzahl FROM wd_anfragen WHERE ip_hash = ? AND erstellt_am > ?`)
.get(hash, grenze);
return (zeile?.anzahl || 0) >= ANFRAGE_MAX_PRO_STUNDE;
}
/* ---------------------------------------------------------------------
ÖFFENTLICH: Anfrage absenden
--------------------------------------------------------------------- */
export async function anfrageAbsenden(req, res) {
const koerper = req.body || {};
// --- 1. Honigtopf ---
if (text(koerper.website, 200)) {
// Absichtlich "ok" melden, siehe Begründung oben.
return json(res, { ok: true, nummer: null, hinweis: "angenommen" });
}
// --- 2. Ausfüllzeit ---
const geoeffnet = Number(koerper.geoeffnetAm);
if (Number.isFinite(geoeffnet) && geoeffnet > 0) {
const sekunden = (Date.now() - geoeffnet) / 1000;
if (sekunden < MINDEST_SEKUNDEN) {
return json(res, { ok: true, nummer: null, hinweis: "angenommen" });
}
}
// --- 3. Mengenbegrenzung ---
const hash = ipHash(besucherIp(req));
if (zuVieleAnfragen(hash)) {
return json(res, {
ok: false,
error: "Es sind schon mehrere Anfragen von dir eingegangen. Bitte melde dich direkt per E-Mail, dann klären wir das persönlich.",
}, 429);
}
// --- Pflichtangaben (Masterplan S.10) ---
const name = text(koerper.name, 120);
const email = emailNormal(koerper.email);
const paket = text(koerper.paket, 30);
const datenschutz = koerper.datenschutz === true || koerper.datenschutz === "ja";
const fehler = {};
if (!name) fehler.name = "Bitte gib deinen Namen an.";
if (!email) fehler.email = "Bitte gib deine E-Mail-Adresse an.";
else if (!istEmail(email)) fehler.email = "Diese E-Mail-Adresse sieht nicht gültig aus.";
if (!paket || !PAKETE.includes(paket)) fehler.paket = "Bitte wähle aus, worum es ungefähr geht.";
if (!text(koerper.ziel, 4000)) fehler.ziel = "Bitte beschreib kurz, was die Seite erreichen soll.";
if (!datenschutz) fehler.datenschutz = "Ohne diese Zustimmung dürfen wir deine Angaben nicht speichern.";
if (Object.keys(fehler).length) {
/* Alle Fehler auf einmal zurückgeben, nicht einen nach dem anderen —
sonst schickt jemand das Formular fünfmal ab und findet jedes Mal
einen neuen Fehler. */
return json(res, { ok: false, error: "Bitte prüfe die markierten Felder.", felder: fehler }, 400);
}
const id = neueId();
const nummer = naechsteNummer("wd_anfragen", "A");
const zeit = jetzt();
/* Funktionen und kategoriespezifische Antworten als JSON. Begrenzt
gespeichert, damit niemand über ein Array die Datenbank vollschreibt. */
const funktionen = Array.isArray(koerper.funktionen)
? koerper.funktionen.slice(0, 40).map((f) => text(f, 80)).filter(Boolean)
: [];
let details = {};
if (koerper.details && typeof koerper.details === "object" && !Array.isArray(koerper.details)) {
for (const [k, v] of Object.entries(koerper.details).slice(0, 40)) {
details[text(k, 60)] = text(typeof v === "boolean" ? (v ? "ja" : "nein") : v, 1000);
}
}
db.prepare(
`INSERT INTO wd_anfragen (
id, nummer, name, email, telefon, kontaktweg,
paket, ziel, zielgruppe, wirkung, seiten, funktionen, details,
hat_texte, hat_bilder, hat_logo, hat_domain,
budget, termin, bemerkung,
status, sprache, erstellt_am, ip_hash
) VALUES (?,?,?,?,?,?, ?,?,?,?,?,?,?, ?,?,?,?, ?,?,?, ?,?,?,?)`
).run(
id, nummer, name, email,
text(koerper.telefon, 60) || null,
["email", "telefon"].includes(text(koerper.kontaktweg, 20)) ? text(koerper.kontaktweg, 20) : "email",
paket,
text(koerper.ziel, 4000),
text(koerper.zielgruppe, 1000) || null,
text(koerper.wirkung, 1000) || null,
text(koerper.seiten, 2000) || null,
JSON.stringify(funktionen),
JSON.stringify(details),
text(koerper.hatTexte, 20) || null,
text(koerper.hatBilder, 20) || null,
text(koerper.hatLogo, 20) || null,
text(koerper.hatDomain, 20) || null,
text(koerper.budget, 60) || null,
text(koerper.termin, 60) || null,
text(koerper.bemerkung, 4000) || null,
"neu",
spracheOderDe(koerper.sprache),
zeit,
hash
);
verlauf("anfrage", id, "angelegt", "system", { nummer, paket });
/* Benachrichtigung an Dogfather. Läuft bewusst NACH dem Speichern und
ohne await auf den Erfolg: die Anfrage ist bereits sicher in der
Datenbank, und ein Ausfall des Mail-Versands darf den Kunden nicht
eine Fehlermeldung sehen lassen für etwas, das geklappt hat. */
benachrichtige(nummer, name, email, paket).catch((e) =>
console.error("[webdesign] Benachrichtigung fehlgeschlagen:", e?.message || e)
);
return json(res, {
ok: true,
nummer,
/* Die Zusammenfassung kommt vom Server zurück, damit der Kunde
schwarz auf weiß sieht, was tatsächlich gespeichert wurde — nicht
das, was sein Browser glaubt abgeschickt zu haben. */
zusammenfassung: { nummer, name, email, paket, eingegangen: zeit },
});
}
/* Discord-Benachrichtigung über den bereits vorhandenen Webhook-Weg.
Bewusst OHNE Kundendaten im Text: die Meldung sagt nur, dass etwas da
ist. Wer die Anfrage lesen will, meldet sich in der Verwaltung an —
ein Discord-Kanal ist kein sicherer Ort für Kontaktdaten. */
async function benachrichtige(nummer, name, email, paket) {
const url = process.env.DISCORD_WEBHOOK_WEBDESIGN;
if (!url) return;
await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content: `🖥️ Neue Webdesign-Anfrage **${nummer}** (${paket}). Details in der Verwaltung.`,
}),
});
}
/* ---------------------------------------------------------------------
VERWALTUNG
--------------------------------------------------------------------- */
export async function anfragenListe(req, res) {
const p = await darf(req, "WD_ANFRAGEN_VIEW");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const status = text(req.query.status, 30);
const paket = text(req.query.paket, 30);
const archiviert = req.query.archiviert === "1" ? 1 : 0;
const suche = text(req.query.suche, 100);
const bedingungen = ["archiviert = ?"];
const werte = [archiviert];
if (status && ANFRAGE_STATUS.includes(status)) { bedingungen.push("status = ?"); werte.push(status); }
if (paket && PAKETE.includes(paket)) { bedingungen.push("paket = ?"); werte.push(paket); }
if (suche) {
bedingungen.push("(name LIKE ? OR email LIKE ? OR nummer LIKE ?)");
werte.push(`%${suche}%`, `%${suche}%`, `%${suche}%`);
}
const zeilen = db
.prepare(
`SELECT id, nummer, name, email, paket, status, budget, termin, sprache, erstellt_am, kunde_id
FROM wd_anfragen WHERE ${bedingungen.join(" AND ")}
ORDER BY erstellt_am DESC LIMIT 300`
)
.all(...werte);
/* Zähler je Status für die Filterleiste — in einer Abfrage statt sieben. */
const zaehler = {};
for (const z of db.prepare(`SELECT status, COUNT(*) AS n FROM wd_anfragen WHERE archiviert = 0 GROUP BY status`).all()) {
zaehler[z.status] = z.n;
}
return json(res, { ok: true, anfragen: zeilen, zaehler });
}
export async function anfrageDetail(req, res) {
const p = await darf(req, "WD_ANFRAGEN_VIEW");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const zeile = db.prepare(`SELECT * FROM wd_anfragen WHERE id = ?`).get(text(req.params.id, 64));
if (!zeile) return json(res, { ok: false, error: "Anfrage nicht gefunden." }, 404);
/* JSON-Felder aufgelöst zurückgeben, damit die Oberfläche nichts
nachparsen muss (und ein kaputter Eintrag dort keinen Absturz auslöst). */
const sicherParsen = (s, standard) => { try { return JSON.parse(s); } catch { return standard; } };
zeile.funktionen = sicherParsen(zeile.funktionen, []);
zeile.details = sicherParsen(zeile.details, {});
const notizen = db
.prepare(`SELECT * FROM wd_anfrage_notizen WHERE anfrage_id = ? ORDER BY erstellt_am ASC`)
.all(zeile.id);
return json(res, { ok: true, anfrage: zeile, notizen });
}
export async function anfrageStatus(req, res) {
const p = await darf(req, "WD_ANFRAGEN_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const id = text(req.params.id, 64);
const neu = text(req.body?.status, 30);
if (!ANFRAGE_STATUS.includes(neu)) {
return json(res, { ok: false, error: "Unbekannter Status." }, 400);
}
const alt = db.prepare(`SELECT status FROM wd_anfragen WHERE id = ?`).get(id);
if (!alt) return json(res, { ok: false, error: "Anfrage nicht gefunden." }, 404);
db.prepare(`UPDATE wd_anfragen SET status = ?, aktualisiert_am = ? WHERE id = ?`).run(neu, jetzt(), id);
verlauf("anfrage", id, "status_geaendert", p.sitzung.actor, { von: alt.status, nach: neu });
return json(res, { ok: true, status: neu });
}
export async function anfrageArchivieren(req, res) {
const p = await darf(req, "WD_ANFRAGEN_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const id = text(req.params.id, 64);
/* Archivieren statt löschen (Masterplan S.13). Der Datensatz bleibt
vollständig erhalten und lässt sich jederzeit zurückholen. */
const zurueck = req.body?.zurueck === true;
const treffer = db
.prepare(`UPDATE wd_anfragen SET archiviert = ?, aktualisiert_am = ? WHERE id = ?`)
.run(zurueck ? 0 : 1, jetzt(), id);
if (!treffer.changes) return json(res, { ok: false, error: "Anfrage nicht gefunden." }, 404);
verlauf("anfrage", id, zurueck ? "wiederhergestellt" : "archiviert", p.sitzung.actor, null);
return json(res, { ok: true, archiviert: !zurueck });
}
export async function anfrageNotiz(req, res) {
const p = await darf(req, "WD_ANFRAGEN_MANAGE");
if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund);
const anfrageId = text(req.params.id, 64);
const inhalt = text(req.body?.text, 4000);
if (!inhalt) return json(res, { ok: false, error: "Die Notiz ist leer." }, 400);
const da = db.prepare(`SELECT 1 FROM wd_anfragen WHERE id = ?`).get(anfrageId);
if (!da) return json(res, { ok: false, error: "Anfrage nicht gefunden." }, 404);
const id = neueId();
db.prepare(
`INSERT INTO wd_anfrage_notizen (id, anfrage_id, autor, text, intern, erstellt_am)
VALUES (?,?,?,?,?,?)`
).run(id, anfrageId, p.sitzung.actor, inhalt, 1, jetzt());
verlauf("anfrage", anfrageId, "notiz_ergaenzt", p.sitzung.actor, null);
return json(res, { ok: true, id });
}