From af729ba57189a1bf3c6418d33268f3a3686e01ad Mon Sep 17 00:00:00 2001 From: qcigano Date: Mon, 3 Aug 2026 22:16:38 +0200 Subject: [PATCH] Gutscheine "nur einmal pro Person" + Bestell-E-Mail-Benachrichtigung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) Gutscheincodes können jetzt im Adminbereich auf "nur einmal pro Person einlösbar" gestellt werden. Wird serverseitig durchgesetzt (neue Spalte orders.gutschein_code + Prüfung in functions/api/orders.js gegen die echte Bestelldatenbank, case-insensitiv nach E-Mail-Adresse) — kann also NICHT durch Löschen von localStorage/einen anderen Browser umgangen werden, anders als die bisherige rein clientseitige Rabattlogik. Stornierte Bestellungen zählen nicht als "schon benutzt". Ausführlich getestet: echter Browser-Checkout mit Einmal-Code funktioniert beim ersten Versuch, wird beim zweiten Versuch (gleiche E-Mail) korrekt mit klarer Fehlermeldung abgelehnt, mit anderer E-Mail weiterhin nutzbar. 2) Bestell-E-Mail-Benachrichtigung an vansdiyundbastelbedarf@gmail.com: recherchiert und vorbereitet (existierte vorher nirgends im Code). Läuft über Resend (kostenlos bis 3.000 E-Mails/Monat), im Hintergrund per context.waitUntil() nach jeder neuen Bestellung — verzögert nie die Antwort an die Kundschaft und kann eine Bestellung niemals zum Scheitern bringen, selbst wenn der Versand fehlschlägt (mit ungültigem Testschlüssel verifiziert: Bestellung bleibt trotzdem erfolgreich gespeichert). Wird automatisch aktiv, sobald ein RESEND_API_KEY als Secret hinterlegt wird. Co-Authored-By: Claude Sonnet 5 --- d1/migration-0002-gutschein-code.sql | 5 ++ d1/schema.sql | 6 ++ functions/_shared/email.js | 58 +++++++++++++++++++ functions/api/orders.js | 87 +++++++++++++++++++++++++++- public/admin/config.yml | 5 ++ src/data/gutscheine.ts | 6 ++ src/pages/ch/checkout/index.astro | 1 + src/pages/checkout/index.astro | 1 + src/pages/en/checkout/index.astro | 1 + src/pages/fr/checkout/index.astro | 1 + 10 files changed, 168 insertions(+), 3 deletions(-) create mode 100644 d1/migration-0002-gutschein-code.sql create mode 100644 functions/_shared/email.js diff --git a/d1/migration-0002-gutschein-code.sql b/d1/migration-0002-gutschein-code.sql new file mode 100644 index 0000000..c76776a --- /dev/null +++ b/d1/migration-0002-gutschein-code.sql @@ -0,0 +1,5 @@ +-- Nachträgliche Erweiterung für "Gutschein nur einmal pro Person" (siehe functions/api/orders.js). +-- Einmalig auf die schon bestehende Datenbank anzuwenden (lokal UND remote) — für neue +-- Datenbanken ist die Spalte schon direkt in d1/schema.sql enthalten. +ALTER TABLE orders ADD COLUMN gutschein_code TEXT; +CREATE INDEX IF NOT EXISTS idx_orders_gutschein_email ON orders(gutschein_code, kunde_email); diff --git a/d1/schema.sql b/d1/schema.sql index e1991de..b30c30f 100644 --- a/d1/schema.sql +++ b/d1/schema.sql @@ -27,6 +27,11 @@ CREATE TABLE IF NOT EXISTS orders ( ort TEXT, zahlungsart TEXT, tracking TEXT, + -- Eingelöster Gutscheincode (normalisiert, Großbuchstaben) — NULL, wenn keiner verwendet wurde. + -- Wird gebraucht, um "nur einmal pro Person"-Gutscheine serverseitig durchzusetzen (siehe + -- functions/api/orders.js): vor dem Anlegen einer neuen Bestellung wird geprüft, ob dieselbe + -- E-Mail-Adresse denselben Code schon einmal (in einer nicht-stornierten Bestellung) benutzt hat. + gutschein_code TEXT, zwischensumme REAL NOT NULL DEFAULT 0, versandkosten REAL NOT NULL DEFAULT 0, rabatt_gesamt REAL NOT NULL DEFAULT 0, @@ -50,3 +55,4 @@ CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id); CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status); CREATE INDEX IF NOT EXISTS idx_orders_land ON orders(land); CREATE INDEX IF NOT EXISTS idx_orders_created_at ON orders(created_at); +CREATE INDEX IF NOT EXISTS idx_orders_gutschein_email ON orders(gutschein_code, kunde_email); diff --git a/functions/_shared/email.js b/functions/_shared/email.js new file mode 100644 index 0000000..977a778 --- /dev/null +++ b/functions/_shared/email.js @@ -0,0 +1,58 @@ +/* ===================================================================== + functions/_shared/email.js — E-Mail-Versand über Resend (kostenlos bis 3.000 E-Mails/Monat, + 100/Tag — für einen kleinen Handmade-Shop mehr als ausreichend, siehe Recherche im Vault- + Tagesprotokoll). Bewusst NICHT über Cloudflare Email Routing/Workers Send-Email-Binding + gelöst, weil das einen bereits verifizierten, eigenen Domain-Versand voraussetzt — die Seite + läuft aktuell noch auf *.pages.dev ohne eigene Domain. Resend funktioniert dagegen sofort mit + der kostenlosen "onboarding@resend.dev"-Absenderadresse, ganz ohne eigene Domain-Verifizierung. + + WICHTIG: E-Mail-Versand darf NIE eine Bestellung zum Scheitern bringen — die Bestellung ist + in der Datenbank (D1) längst sicher gespeichert, bevor diese Funktion überhaupt aufgerufen + wird. Deshalb wirft sendResendEmail() nie einen Fehler nach außen, sondern gibt nur + { ok: true/false, error? } zurück — der Aufrufer entscheidet selbst, ob/wie er einen + Fehlschlag protokolliert (siehe functions/api/orders.js). ===================================================================== */ + +const RESEND_API_URL = "https://api.resend.com/emails"; +const STANDARD_ABSENDER = "Van's DIY & Bastelbedarf "; + +/** + * Verschickt eine E-Mail über die Resend-API. Gibt IMMER ein Ergebnisobjekt zurück (nie eine + * geworfene Exception), damit der Aufrufer den Versand einfach "fire and forget" behandeln kann. + * `env.RESEND_API_KEY` fehlt einfach so lange, bis VanVan/qciga einen kostenlosen Resend-Account + * eingerichtet haben — bis dahin liefert diese Funktion sauber `{ ok: false, error: "..." }` + * zurück, ohne irgendetwas kaputtzumachen. + */ +export async function sendeEmail(env, { to, subject, html, text, from }) { + if (!env.RESEND_API_KEY) { + return { ok: false, error: "RESEND_API_KEY nicht gesetzt — E-Mail-Versand noch nicht eingerichtet." }; + } + try { + const res = await fetch(RESEND_API_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${env.RESEND_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from: from || env.RESEND_FROM || STANDARD_ABSENDER, + to: Array.isArray(to) ? to : [to], + subject, + html, + text, + }), + }); + if (!res.ok) { + const fehlertext = await res.text().catch(() => ""); + return { ok: false, error: `Resend antwortete mit Status ${res.status}: ${fehlertext}` }; + } + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +/** Escaped Text sicher fürs Einbetten in eine HTML-E-Mail (verhindert kaputtes HTML bei z.B. + * Sonderzeichen im Kundennamen). */ +export function escapeHtmlFuerEmail(str) { + return String(str ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); +} diff --git a/functions/api/orders.js b/functions/api/orders.js index 850ad27..c0ea0e9 100644 --- a/functions/api/orders.js +++ b/functions/api/orders.js @@ -12,8 +12,50 @@ gilt sofort als "bezahlt" (siehe scripts/account.ts, derselbe Grundsatz). ===================================================================== */ import { json } from "../_shared/http.js"; +import { sendeEmail, escapeHtmlFuerEmail } from "../_shared/email.js"; +// Gleiche Gutschein-Daten wie im Frontend (src/data/gutscheine.ts) — hier server-seitig erneut +// eingebunden, um das "nur einmal pro Person"-Flag unabhängig vom Browser durchzusetzen (siehe +// unten). Bewusst derselbe JSON-Rohdaten-Import, keine Code-Duplizierung der Inhalte. +// @ts-ignore +import gutscheineRaw from "../../src/content/gutscheine.json"; const ERLAUBTE_LAENDER = ["de", "at", "ch", "lu"]; +const gutscheine = (gutscheineRaw && gutscheineRaw.codes) || []; +// VanVans Wunsch-Adresse für Bestellbenachrichtigungen (siehe Anfrage vom 03.08.2026). Fest +// hinterlegt statt als Secret, da es keine geheime Information ist (nur ein Empfänger). +const BENACHRICHTIGUNGS_EMAIL = "vansdiyundbastelbedarf@gmail.com"; + +const LAND_NAME = { de: "Deutschland", at: "Österreich", ch: "Schweiz", lu: "Luxemburg" }; + +function formatPreis(n) { + return `${Number(n).toFixed(2).replace(".", ",")} €`; +} + +/** Baut die interne "neue Bestellung eingegangen"-E-Mail an VanVan/qciga — bewusst schlicht + * gehalten (kein Marketing-Layout nötig, nur die wichtigsten Infos auf einen Blick + Link zur + * Verwaltung). */ +function bestellBenachrichtigungHtml({ bestellnummer, name, email, land, artikel, summe }) { + const artikelZeilen = artikel + .map((a) => `
  • ${a.menge}× ${escapeHtmlFuerEmail(a.name)} — ${formatPreis(a.preis * a.menge)}
  • `) + .join(""); + return ` +
    +

    🩵 Neue Bestellung ${escapeHtmlFuerEmail(bestellnummer)}

    +

    Von: ${escapeHtmlFuerEmail(name)} (${escapeHtmlFuerEmail(email)})
    + Land: ${escapeHtmlFuerEmail(LAND_NAME[land] || land)}

    +

    Artikel:

    +
      ${artikelZeilen}
    +

    Summe: ${formatPreis(summe)}

    +

    → Zur Bestellverwaltung

    +
    + `; +} + +function findeGutschein(code) { + const normalisiert = String(code || "").trim().toUpperCase(); + if (!normalisiert) return null; + return gutscheine.find((g) => String(g.code || "").toUpperCase() === normalisiert) || null; +} export async function onRequestPost(context) { const { request, env } = context; @@ -67,6 +109,30 @@ export async function onRequestPost(context) { const versandkosten = Number.isFinite(Number(body.versandkosten)) ? Number(body.versandkosten) : 0; const rabattGesamt = Number.isFinite(Number(body.rabattGesamt)) ? Number(body.rabattGesamt) : 0; + // ── "Nur einmal pro Person" durchsetzen — unabhängig vom Browser/Gerät, da hier gegen die + // echten, gespeicherten Bestellungen geprüft wird (siehe d1/schema.sql, Spalte + // "gutschein_code"). Stornierte Bestellungen zählen NICHT als "schon benutzt" — wurde ja + // letztlich nicht wirksam eingelöst. ── + const gutscheinCodeRoh = typeof body.gutscheinCode === "string" ? body.gutscheinCode.trim() : ""; + let gutscheinCodeNormalisiert = null; + if (gutscheinCodeRoh) { + const gutschein = findeGutschein(gutscheinCodeRoh); + gutscheinCodeNormalisiert = gutscheinCodeRoh.toUpperCase(); + if (gutschein && gutschein.einmaligProPerson) { + const { results } = await env.DB.prepare( + `SELECT COUNT(*) as anzahl FROM orders WHERE gutschein_code = ? AND LOWER(kunde_email) = LOWER(?) AND status != 'storniert'` + ) + .bind(gutscheinCodeNormalisiert, email) + .all(); + if ((results?.[0]?.anzahl ?? 0) > 0) { + return json(400, { + ok: false, + error: "Dieser Gutscheincode wurde bereits mit dieser E-Mail-Adresse eingelöst und gilt nur einmal pro Person.", + }); + } + } + } + const now = new Date().toISOString(); try { @@ -74,8 +140,8 @@ export async function onRequestPost(context) { `INSERT INTO orders (created_at, updated_at, status, kunde_name, kunde_email, land, strasse, plz, ort, zahlungsart, zwischensumme, versandkosten, rabatt_gesamt, summe, - treuebonus_verwendet, abo_rabatt_verwendet) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)` + treuebonus_verwendet, abo_rabatt_verwendet, gutschein_code) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)` ) .bind( now, @@ -93,7 +159,8 @@ export async function onRequestPost(context) { rabattGesamt, summe, body.treuebonusVerwendet ? 1 : 0, - body.aboRabattVerwendet ? 1 : 0 + body.aboRabattVerwendet ? 1 : 0, + gutscheinCodeNormalisiert ) .run(); @@ -113,6 +180,20 @@ export async function onRequestPost(context) { ); await env.DB.batch(itemStmts); + // E-Mail-Benachrichtigung an VanVan/qciga — bewusst NICHT abgewartet (context.waitUntil + // lässt den Versand im Hintergrund fertig laufen, ohne die Antwort an die Kundschaft zu + // verzögern) und bewusst NICHT fehlerkritisch: schlägt der Versand fehl (z.B. weil noch kein + // RESEND_API_KEY hinterlegt ist), bleibt die Bestellung trotzdem ganz normal gespeichert und + // in /verwaltung/ sichtbar — nur die Benachrichtigung fehlt dann eben. + context.waitUntil( + sendeEmail(env, { + to: BENACHRICHTIGUNGS_EMAIL, + subject: `🩵 Neue Bestellung ${bestellnummer}`, + html: bestellBenachrichtigungHtml({ bestellnummer, name, email, land, artikel, summe }), + text: `Neue Bestellung ${bestellnummer} von ${name} (${email}), ${artikel.length} Artikel, Summe ${formatPreis(summe)}. Details: https://vans-diy-bastelbedarf.pages.dev/verwaltung/`, + }) + ); + return json(201, { ok: true, id: orderId, bestellnummer }); } catch (err) { return json(500, { ok: false, error: "Bestellung konnte nicht gespeichert werden." }); diff --git a/public/admin/config.yml b/public/admin/config.yml index 73a3c68..f80510f 100644 --- a/public/admin/config.yml +++ b/public/admin/config.yml @@ -307,6 +307,11 @@ collections: - { label: "Rabatt in Prozent (z.B. 10 für 10%)", name: prozent, widget: number, value_type: int, required: false } - { label: "ODER: Rabatt als fester Betrag (€)", name: betrag, widget: number, value_type: float, step: 0.01, required: false } - { label: "Gültig bis (leer = unbegrenzt)", name: bis, widget: datetime, format: "YYYY-MM-DD", date_format: "YYYY-MM-DD", time_format: false, required: false } + - label: "Nur einmal pro Person einlösbar?" + name: einmaligProPerson + widget: boolean + default: false + hint: "Ja = derselbe Code kann nicht zweimal mit derselben E-Mail-Adresse bestellt werden (wird beim Bestellabschluss automatisch geprüft). Nein (Standard) = beliebig oft nutzbar, wie bisher." - name: partnercodes label: "🤝 Partner-Rabattcodes" diff --git a/src/data/gutscheine.ts b/src/data/gutscheine.ts index bbdb625..34d3537 100644 --- a/src/data/gutscheine.ts +++ b/src/data/gutscheine.ts @@ -9,6 +9,12 @@ export interface Gutschein { prozent?: number; betrag?: number; bis?: string; // ISO-Datum, optional + /** true = der Code gilt pro Person (genauer: pro E-Mail-Adresse) nur einmal — wird beim + * Abschicken der Bestellung serverseitig geprüft (siehe functions/api/orders.js, Spalte + * "gutschein_code" in der orders-Tabelle), kann also NICHT durch Löschen von localStorage/ + * einen anderen Browser umgangen werden. Ohne dieses Flag (Standard) bleibt ein Code + * unbegrenzt mehrfach nutzbar, wie bisher. */ + einmaligProPerson?: boolean; } const gutscheine = (gutscheineRaw as { codes: Gutschein[] }).codes ?? []; diff --git a/src/pages/ch/checkout/index.astro b/src/pages/ch/checkout/index.astro index e422f5a..1d59919 100644 --- a/src/pages/ch/checkout/index.astro +++ b/src/pages/ch/checkout/index.astro @@ -377,6 +377,7 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../ summe: currentTotal, treuebonusVerwendet: treuebonusDiscount() > 0, aboRabattVerwendet: aboDiscount(subtotal) > 0, + gutscheinCode: appliedCoupon()?.code ?? null, }; try { diff --git a/src/pages/checkout/index.astro b/src/pages/checkout/index.astro index 624858a..eec48e4 100644 --- a/src/pages/checkout/index.astro +++ b/src/pages/checkout/index.astro @@ -387,6 +387,7 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../ summe: currentTotal, treuebonusVerwendet: treuebonusDiscount() > 0, aboRabattVerwendet: aboDiscount(subtotal) > 0, + gutscheinCode: appliedCoupon()?.code ?? null, }; try { diff --git a/src/pages/en/checkout/index.astro b/src/pages/en/checkout/index.astro index f4dbefd..65e2e19 100644 --- a/src/pages/en/checkout/index.astro +++ b/src/pages/en/checkout/index.astro @@ -377,6 +377,7 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../ summe: currentTotal, treuebonusVerwendet: treuebonusDiscount() > 0, aboRabattVerwendet: aboDiscount(subtotal) > 0, + gutscheinCode: appliedCoupon()?.code ?? null, }; try { diff --git a/src/pages/fr/checkout/index.astro b/src/pages/fr/checkout/index.astro index 42ecf5d..5ffedcd 100644 --- a/src/pages/fr/checkout/index.astro +++ b/src/pages/fr/checkout/index.astro @@ -377,6 +377,7 @@ import { registriereAbgeschlosseneBestellung, treuebonusEinloesen } from "../../ summe: currentTotal, treuebonusVerwendet: treuebonusDiscount() > 0, aboRabattVerwendet: aboDiscount(subtotal) > 0, + gutscheinCode: appliedCoupon()?.code ?? null, }; try {