/* ===================================================================== routes/webdesign-widerruf.js — Elektronische Widerrufsfunktion Umsetzung von § 356a BGB (in Kraft seit 19.06.2026). Begründung, warum die Vorschrift hier gilt, steht ausführlich im Kopf der Migration 0014 — kurz: Im Portal werden Zusatzangebote per Klick verbindlich angenommen, das ist ein Vertragsschluss über eine Online-Benutzer- oberfläche. Drei Dinge, die diese Datei anders macht als jedes andere Formular im Projekt — jedes davon folgt unmittelbar aus dem Gesetz: 1. KEINE ANMELDUNG. Der Widerruf muss "leicht zugänglich" sein. Jemanden, der einen Vertrag loswerden will, erst durch eine Anmeldung zu schicken, ist das Gegenteil davon. Wer sein Passwort vergessen hat, könnte sonst die Frist verpassen. 2. KEINE PFLICHTFELDER ÜBER DAS GESETZLICHE MASS HINAUS. Ein Widerruf braucht KEINE Begründung. Das Feld existiert, ist aber freiwillig und auch so beschriftet. 3. DIE MENGENBEGRENZUNG DARF NIEMANDEN AUSSPERREN. Bei jedem anderen Formular ist eine Sperre nach zu vielen Versuchen richtig. Hier wäre sie ein Rechtsverlust: Wer in der letzten Stunde seiner Frist dreimal auf "Absenden" klickt, weil das Netz hakt, darf nicht abgewiesen werden. Deshalb wird oberhalb der Grenze verlangsamt und protokolliert, aber der Widerruf immer angenommen. ===================================================================== */ import { db } from "../db.js"; import { json } from "../lib/http.js"; import { requireSession } from "../lib/auth.js"; import { hasPermission } from "../lib/permissions.js"; import { ausweisPruefen } from "../lib/webdesign-ausweis.js"; import { jetzt, neueId, naechsteNummer, text, istEmail, emailNormal, verlauf, besucherIp, ipHash, spracheOderDe, } from "../lib/webdesign-helfer.js"; async function darf(req, recht) { const ausweis = ausweisPruefen(req); if (ausweis) return { erlaubt: true, sitzung: { actor: ausweis.rolle, isOwner: true } }; 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 }; } /* --------------------------------------------------------------------- WIDERRUF ENTGEGENNEHMEN — öffentlich, ohne Anmeldung POST /webdesign/widerruf --------------------------------------------------------------------- */ export function widerrufAbsenden(req, res) { const b = req.body || {}; const name = text(b.name, 200); const kontakt = emailNormal(b.kontakt); const vertrag = text(b.vertrag, 300); /* Nur die drei Angaben, die § 356a nennt, sind Pflicht — Name, Vertragskennzeichnung und ein Kommunikationsmittel für die Eingangsbestätigung. Mehr zu verlangen wäre eine Hürde, die das Gesetz gerade abbauen will. */ const fehlt = []; if (!name) fehlt.push("name"); if (!vertrag) fehlt.push("vertrag"); if (!istEmail(kontakt)) fehlt.push("kontakt"); if (fehlt.length) { return json(res, { ok: false, fehlt, error: "Bitte Name, Vertrag und eine E-Mail-Adresse angeben — mehr brauchen wir nicht.", }, 400); } const id = neueId(); const nummer = naechsteNummer("wd_widerrufe", "W"); const zeit = jetzt(); /* Zuordnung wird VERSUCHT, ist aber nie Bedingung. Findet sich kein passender Kunde, wird der Widerruf trotzdem gespeichert — er ist wirksam, sobald er zugeht, unabhängig davon, ob unser System ihn einem Datensatz zuordnen kann. */ let kundeId = null; let projektId = null; try { const kunde = db.prepare(`SELECT id FROM wd_kunden WHERE email = ?`).get(kontakt); if (kunde) kundeId = kunde.id; const projekt = db .prepare(`SELECT id FROM wd_projekte WHERE nummer = ?`) .get(vertrag.trim().toUpperCase()); if (projekt) projektId = projekt.id; } catch (e) { /* Zuordnung ist Komfort, kein Muss. */ } db.prepare( `INSERT INTO wd_widerrufe (id, nummer, name, kontakt, vertrag, betrifft, begruendung, kunde_id, projekt_id, eingegangen_am, ip_hash, sprache, bestaetigt_am, bestaetigung_weg, status) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,'anzeige','eingegangen')` ).run( id, nummer, name, kontakt, vertrag, text(b.betrifft, 300) || null, text(b.begruendung, 2000) || null, kundeId, projektId, zeit, ipHash(besucherIp(req)), spracheOderDe(b.sprache), zeit ); verlauf("widerruf", id, "eingegangen", "kunde", { nummer, vertrag }); /* Die Eingangsbestätigung. § 356a verlangt sie "unverzüglich" und auf einem dauerhaften Datenträger, mit dem Inhalt der Erklärung und dem Zeitpunkt. Sie wird hier VOLLSTÄNDIG zurückgegeben, damit die Seite sie sofort anzeigen und zum Ausdrucken bzw. Speichern anbieten kann. Auf einen E-Mail-Versand als EINZIGEN Weg zu setzen wäre fahrlässig: Mails landen im Spam oder werden vom Anbieter verzögert, und dann steht der Verbraucher ohne Nachweis da. Die Anzeige ist sofort und zuverlässig; die Datenbank ist der dauerhafte Nachweis auf unserer Seite. */ return json(res, { ok: true, nummer, eingegangenAm: zeit, bestaetigung: { nummer, name, vertrag, kontakt, eingegangenAm: zeit, empfaenger: "Filipe Pereira Queiroz, 3 rue Arthur Thinnes, L-3919 Mondercange, Luxemburg", }, }); } /* --------------------------------------------------------------------- VERWALTUNG --------------------------------------------------------------------- */ /* GET /webdesign/admin/widerrufe */ export async function widerrufeListe(req, res) { const p = await darf(req, "WD_KUNDEN_MANAGE"); if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund); const zeilen = db .prepare( `SELECT w.*, k.name AS kunde_name, pr.titel AS projekt_titel FROM wd_widerrufe w LEFT JOIN wd_kunden k ON k.id = w.kunde_id LEFT JOIN wd_projekte pr ON pr.id = w.projekt_id ORDER BY w.eingegangen_am DESC LIMIT 300` ) .all(); const offen = zeilen.filter((z) => z.status === "eingegangen").length; return json(res, { ok: true, widerrufe: zeilen, offen }); } /* POST /webdesign/admin/widerrufe/:id Nur Notiz und Bearbeitungsstand. Ein Widerruf lässt sich NICHT löschen und nicht ablehnen — er ist wirksam, sobald er zugeht, und die Zeile ist der Nachweis dafür. Ein System, in dem man den unangenehmen Fall wegklicken kann, verliert genau dann seinen Wert, wenn man ihn braucht. */ export async function widerrufBearbeiten(req, res) { const p = await darf(req, "WD_KUNDEN_MANAGE"); if (!p.erlaubt) return json(res, { ok: false, error: "Kein Zugriff." }, p.grund); const w = db.prepare(`SELECT * FROM wd_widerrufe WHERE id = ?`).get(String(req.params.id || "")); if (!w) return json(res, { ok: false, error: "Nicht gefunden." }, 404); const erledigt = req.body?.erledigt === true; db.prepare( `UPDATE wd_widerrufe SET status = ?, notiz = ?, erledigt_am = ? WHERE id = ?` ).run( erledigt ? "bearbeitet" : "eingegangen", text(req.body?.notiz, 2000) || w.notiz, erledigt ? jetzt() : null, w.id ); verlauf("widerruf", w.id, erledigt ? "bearbeitet" : "wieder_offen", "team:" + (p.sitzung?.actor || "?"), null); return json(res, { ok: true }); } /* --------------------------------------------------------------------- ZUSTIMMUNG ZUM SOFORTIGEN LEISTUNGSBEGINN FESTHALTEN § 356 Abs. 4 BGB. Die Beweislast liegt beim Unternehmer, deshalb wird nicht nur der Haken gespeichert, sondern der WORTLAUT, den der Kunde gesehen hat. Nur "hat zugestimmt" hilft im Streit wenig — es zählt, WAS er bestätigt hat, und Texte ändern sich über die Jahre. --------------------------------------------------------------------- */ export function zustimmungSpeichern({ kundeId, projektId, anfrageId, art, wortlaut, fassung, sprache, ip }) { if (!wortlaut) return null; const id = neueId(); db.prepare( `INSERT INTO wd_zustimmungen (id, kunde_id, projekt_id, anfrage_id, art, wortlaut, fassung, sprache, erteilt_am, ip_hash) VALUES (?,?,?,?,?,?,?,?,?,?)` ).run( id, kundeId || null, projektId || null, anfrageId || null, art, wortlaut, fassung || null, spracheOderDe(sprache), jetzt(), ip ? ipHash(ip) : null ); return id; }