/* ===================================================================== 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 }); }