Files
vans-diy-bastelbedarf/functions/api/orders.js
T
qciganoandClaude Sonnet 5 af729ba571 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]>
2026-08-03 22:16:38 +02:00

202 lines
9.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
POST /api/orders — legt eine echte Bestellung in D1 an (orders + order_items).
Wird vom Checkout aufgerufen (siehe src/pages/checkout/index.astro), sobald jemand die
Bestellung abschickt. BEWUSST nicht dauerhaft hinter dem Zugangscode-Gate (siehe
functions/_middleware.js) — sobald der Shop öffentlich ist (SITE_PUBLIC=true), müssen auch
echte Kund:innen hier bestellen können, genau wie sie den Checkout selbst erreichen können.
Vor dem Live-Gang ist ohnehin die ganze Seite inkl. Checkout hinter dem Gate, d.h. nur
qciga/VanVan können bis hierhin überhaupt kommen.
In Phase 1 gibt es kein echtes Zahlungs-Backend — jede vollständig abgeschickte Bestellung
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;
if (!env.DB) {
return json(500, { ok: false, error: "Datenbank nicht verbunden." });
}
let body;
try {
body = await request.json();
} catch {
return json(400, { ok: false, error: "Ungültige Anfrage." });
}
// ── Validierung: lieber eine klare Fehlermeldung im Checkout als eine kaputte/leere
// Bestellung in der Datenbank. ──
const land = String(body.land || "").toLowerCase();
if (!ERLAUBTE_LAENDER.includes(land)) {
return json(400, { ok: false, error: "Ungültiges Land." });
}
const kunde = body.kunde && typeof body.kunde === "object" ? body.kunde : {};
const name = String(kunde.name || "").trim();
const email = String(kunde.email || "").trim();
if (!name || !email || !email.includes("@")) {
return json(400, { ok: false, error: "Ungültige Kundendaten." });
}
const artikel = Array.isArray(body.artikel) ? body.artikel : [];
if (artikel.length === 0) {
return json(400, { ok: false, error: "Die Bestellung enthält keine Artikel." });
}
for (const a of artikel) {
if (!a || typeof a.slug !== "string" || !a.slug.trim() || typeof a.name !== "string" || !a.name.trim()) {
return json(400, { ok: false, error: "Ungültige Artikeldaten." });
}
if (!Number.isFinite(a.menge) || a.menge <= 0) {
return json(400, { ok: false, error: "Ungültige Artikelmenge." });
}
if (!Number.isFinite(a.preis) || a.preis < 0) {
return json(400, { ok: false, error: "Ungültiger Artikelpreis." });
}
}
const summe = Number(body.summe);
if (!Number.isFinite(summe) || summe < 0) {
return json(400, { ok: false, error: "Ungültige Bestellsumme." });
}
const zwischensumme = Number.isFinite(Number(body.zwischensumme)) ? Number(body.zwischensumme) : 0;
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 {
const insertOrder = await env.DB.prepare(
`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, gutschein_code)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
)
.bind(
now,
now,
"bezahlt",
name,
email,
land,
String(kunde.strasse || ""),
String(kunde.plz || ""),
String(kunde.ort || ""),
String(body.zahlungsart || ""),
zwischensumme,
versandkosten,
rabattGesamt,
summe,
body.treuebonusVerwendet ? 1 : 0,
body.aboRabattVerwendet ? 1 : 0,
gutscheinCodeNormalisiert
)
.run();
const orderId = insertOrder.meta.last_row_id;
// Menschenlesbare Bestellnummer wird ERST NACH dem Insert aus der autoinkrementierten,
// garantiert eindeutigen ID abgeleitet — keine Race Condition bei zwei gleichzeitigen
// Bestellungen (anders als z.B. "COUNT(*) + 1" vorher zu berechnen).
const jahr = new Date().getFullYear();
const bestellnummer = `VDB-${jahr}-${String(orderId).padStart(4, "0")}`;
await env.DB.prepare(`UPDATE orders SET bestellnummer = ? WHERE id = ?`).bind(bestellnummer, orderId).run();
const itemStmts = artikel.map((a) =>
env.DB.prepare(
`INSERT INTO order_items (order_id, produkt_slug, name, kategorie_slug, menge, preis, gratis)
VALUES (?,?,?,?,?,?,?)`
).bind(orderId, a.slug, a.name, a.kategorie || null, Math.round(a.menge), a.preis, a.gratis ? 1 : 0)
);
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." });
}
}