Gutscheine "nur einmal pro Person" + Bestell-E-Mail-Benachrichtigung

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 [email protected]: 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 <[email protected]>
This commit is contained in:
qcigano
2026-08-03 22:16:38 +02:00
co-authored by Claude Sonnet 5
parent 0c1c8f075f
commit af729ba571
10 changed files with 168 additions and 3 deletions
+84 -3
View File
@@ -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 = "[email protected]";
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) => `<li>${a.menge}× ${escapeHtmlFuerEmail(a.name)}${formatPreis(a.preis * a.menge)}</li>`)
.join("");
return `
<div style="font-family: sans-serif; color: #1a1a1a; line-height: 1.6;">
<h2 style="margin: 0 0 0.5em;">🩵 Neue Bestellung ${escapeHtmlFuerEmail(bestellnummer)}</h2>
<p><strong>Von:</strong> ${escapeHtmlFuerEmail(name)} (${escapeHtmlFuerEmail(email)})<br />
<strong>Land:</strong> ${escapeHtmlFuerEmail(LAND_NAME[land] || land)}</p>
<p><strong>Artikel:</strong></p>
<ul>${artikelZeilen}</ul>
<p><strong>Summe: ${formatPreis(summe)}</strong></p>
<p><a href="https://vans-diy-bastelbedarf.pages.dev/verwaltung/">→ Zur Bestellverwaltung</a></p>
</div>
`;
}
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." });