Files
webshop/shop/lib/actions/orders.ts
2026-06-23 04:42:50 +02:00

192 lines
7.1 KiB
TypeScript

'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
import { InvoicePDF } from '@/components/invoice-pdf'
import { renderToBuffer } from '@react-pdf/renderer'
import React from 'react'
import { buildCustomerSnapshot, buildOrderSnapshot } from '@/lib/license-transform'
import type { Category, EndCustomer, Order, Product, Profile, WizardSelections } from '@/lib/types'
import { getProducts, getCategories } from '@/lib/actions/products'
// ─── 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
products?: Product[]
categories?: Category[]
customerProfile: Partial<Profile>
endCustomerId?: string | null
endCustomer?: EndCustomer | null
}): Promise<Order> {
const { selections, customerProfile, endCustomerId, endCustomer } = params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Not authenticated')
// Fetch catalog directly from DB to prevent client tampering
const dbProducts = await getProducts()
const dbCategories = await getCategories()
// ─── Constraint Validation ──────────────────────────────────────────────────
for (const cat of dbCategories) {
const sel = selections[cat.id]
if (cat.is_required && (!sel || !sel.productId)) {
throw new Error(`Die Kategorie "${cat.name}" ist ein Pflichtfeld, wurde aber nicht ausgewählt.`)
}
if (sel?.productId) {
const prod = dbProducts.find(p => p.id === sel.productId)
if (!prod) {
throw new Error(`Das ausgewählte Produkt für Kategorie "${cat.name}" wurde nicht gefunden.`)
}
// Validate selected modules
for (const mId of sel.moduleIds) {
const mod = prod.modules?.find(m => m.id === mId)
if (!mod) {
throw new Error(`Das Modul mit ID "${mId}" gehört nicht zum Produkt "${prod.name}".`)
}
// Requirements check
if (mod.requirements && mod.requirements.length > 0) {
const missing = mod.requirements.filter(reqId => !sel.moduleIds.includes(reqId))
if (missing.length > 0) {
throw new Error(`Das Modul "${mod.name}" setzt die Aktivierung anderer Module voraus.`)
}
}
// Exclusions check
if (mod.exclusions && mod.exclusions.length > 0) {
const conflicting = mod.exclusions.filter(exId => sel.moduleIds.includes(exId))
if (conflicting.length > 0) {
throw new Error(`Das Modul "${mod.name}" schließt die Kombination mit anderen gewählten Modulen aus.`)
}
}
}
}
}
// 1. Snapshots aufbauen
// Wenn Endkunde vorhanden: dessen Daten einfrieren; sonst Partner-Profil (Fallback)
const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null)
const orderSnapshot = buildOrderSnapshot(selections, dbProducts, dbCategories)
// 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,
end_customer_id: endCustomerId ?? null,
total_price: orderSnapshot.total,
customer_data: customerSnapshot,
order_data: orderSnapshot,
status: 'pending',
}])
.select()
.single()
if (orderError) throw orderError
// 5. PDF generieren (greift auf Snapshot-Daten zu, nicht auf Live-Katalog)
try {
const buffer = await renderToBuffer(
React.createElement(InvoicePDF, {
order,
customer: customerSnapshot,
orderSnapshot,
})
)
// 6. PDF in Supabase Storage hochladen
const fileName = `invoice_${order.id}.pdf`
const { error: uploadError } = await supabase
.storage
.from('invoices')
.upload(fileName, buffer, { contentType: 'application/pdf', upsert: true })
if (uploadError) {
console.error('PDF Upload Error:', uploadError)
} 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
}