feat(orders): idempotency guard, order numbers, button lock, success page, my-orders, admin polling
This commit is contained in:
@@ -8,11 +8,43 @@ import React from 'react'
|
||||
import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform'
|
||||
import type { Category, Order, Product, Profile, WizardSelections } from '@/lib/types'
|
||||
|
||||
// ─── Hilfsfunktionen ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Erzeugt eine menschenlesbare, nicht-sequenzielle Bestellnummer.
|
||||
* Format: RE-YYYY-NNNNN (z.B. RE-2026-84731)
|
||||
* Kein Rückschluss auf Gesamtanzahl der Bestellungen möglich.
|
||||
*/
|
||||
function generateOrderNumber(): string {
|
||||
const year = new Date().getFullYear()
|
||||
const rand = Math.floor(10000 + Math.random() * 90000) // 5-stellig, niemals < 10000
|
||||
return `RE-${year}-${rand}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen einfachen deterministischen Hash aus dem Bestell-Snapshot.
|
||||
* Wird für den Idempotenz-Guard verwendet (verhindert Doppelbestellungen).
|
||||
*/
|
||||
function hashOrderSnapshot(snapshot: object): string {
|
||||
const str = JSON.stringify(snapshot)
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i)
|
||||
hash = ((hash << 5) - hash) + char
|
||||
hash = hash & hash // 32-bit integer
|
||||
}
|
||||
return Math.abs(hash).toString(36)
|
||||
}
|
||||
|
||||
// ─── submitOrder ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Speichert eine abgeschlossene Bestellung als versionierten Snapshot in der DB,
|
||||
* generiert die PDF-Rechnung und lädt sie in den Supabase Storage hoch.
|
||||
*
|
||||
* Enthält Idempotenz-Guard: Wenn innerhalb von 30 Sekunden eine identische
|
||||
* Bestellung vom gleichen User existiert, wird diese zurückgegeben statt
|
||||
* einer neuen erstellt (Schutz gegen Doppelklick / Netzwerk-Retry).
|
||||
*/
|
||||
export async function submitOrder(params: {
|
||||
selections: WizardSelections
|
||||
@@ -30,11 +62,46 @@ export async function submitOrder(params: {
|
||||
const customerSnapshot = buildCustomerSnapshot(customerProfile)
|
||||
const orderSnapshot = buildOrderSnapshot(selections, products, categories)
|
||||
|
||||
// 2. Bestellung in DB schreiben
|
||||
// 2. Idempotenz-Guard: Prüfen ob identische Bestellung in den letzten 30s existiert
|
||||
const orderHash = hashOrderSnapshot(orderSnapshot)
|
||||
const thirtySecondsAgo = new Date(Date.now() - 30_000).toISOString()
|
||||
|
||||
const { data: existingOrder } = await supabase
|
||||
.from('orders')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.eq('order_hash', orderHash)
|
||||
.gte('created_at', thirtySecondsAgo)
|
||||
.maybeSingle()
|
||||
|
||||
if (existingOrder) {
|
||||
console.warn('[submitOrder] Duplicate order detected, returning existing order:', existingOrder.order_number)
|
||||
return existingOrder as Order
|
||||
}
|
||||
|
||||
// 3. Eindeutige Bestellnummer generieren (Retry bei Kollision)
|
||||
let orderNumber: string = ''
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const candidate = generateOrderNumber()
|
||||
const { data: collision } = await supabase
|
||||
.from('orders')
|
||||
.select('id')
|
||||
.eq('order_number', candidate)
|
||||
.maybeSingle()
|
||||
if (!collision) {
|
||||
orderNumber = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!orderNumber) throw new Error('Konnte keine eindeutige Bestellnummer generieren.')
|
||||
|
||||
// 4. Bestellung in DB schreiben
|
||||
const { data: order, error: orderError } = await supabase
|
||||
.from('orders')
|
||||
.insert([{
|
||||
user_id: user.id,
|
||||
order_number: orderNumber,
|
||||
order_hash: orderHash,
|
||||
total_price: orderSnapshot.total,
|
||||
customer_data: customerSnapshot,
|
||||
order_data: orderSnapshot,
|
||||
@@ -45,7 +112,7 @@ export async function submitOrder(params: {
|
||||
|
||||
if (orderError) throw orderError
|
||||
|
||||
// 3. PDF generieren (greift auf Snapshot-Daten zu, nicht auf Live-Katalog)
|
||||
// 5. PDF generieren (greift auf Snapshot-Daten zu, nicht auf Live-Katalog)
|
||||
try {
|
||||
const buffer = await renderToBuffer(
|
||||
React.createElement(InvoicePDF, {
|
||||
@@ -55,7 +122,7 @@ export async function submitOrder(params: {
|
||||
})
|
||||
)
|
||||
|
||||
// 4. PDF in Supabase Storage hochladen
|
||||
// 6. PDF in Supabase Storage hochladen
|
||||
const fileName = `invoice_${order.id}.pdf`
|
||||
const { error: uploadError } = await supabase
|
||||
.storage
|
||||
@@ -67,12 +134,13 @@ export async function submitOrder(params: {
|
||||
} else {
|
||||
const { data: publicUrlData } = supabase.storage.from('invoices').getPublicUrl(fileName)
|
||||
await supabase.from('orders').update({ pdf_url: publicUrlData.publicUrl }).eq('id', order.id)
|
||||
order.pdf_url = publicUrlData.publicUrl
|
||||
}
|
||||
} catch (pdfError) {
|
||||
console.error('PDF Generation Error:', pdfError)
|
||||
}
|
||||
|
||||
revalidatePath('/protected')
|
||||
revalidatePath('/my-orders')
|
||||
return order as Order
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user