'use server' import { createClient } from '@/lib/supabase/server' import { revalidatePath } from 'next/cache' import { sendMail } from '@/utils/mail' 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: BE-YYYY-NNNNN (z.B. BE-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 `BE-${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 moduleQuantities?: Record products?: Product[] categories?: Category[] customerProfile: Partial endCustomerId?: string | null endCustomer?: EndCustomer | null billingInterval?: 'one_time' | 'monthly' lastLicenseDate?: string | null }): Promise { const { selections, moduleQuantities, customerProfile, endCustomerId, endCustomer, billingInterval, lastLicenseDate } = 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.`) } } } } } // Product-level constraints validation const selectedProductIds = Object.values(selections) .map(s => s.productId) .filter((id): id is string => !!id) for (const prodId of selectedProductIds) { const prod = dbProducts.find(p => p.id === prodId) if (!prod) continue // Product requirements check if (prod.requirements && prod.requirements.length > 0) { const missing = prod.requirements.filter(reqId => !selectedProductIds.includes(reqId)) if (missing.length > 0) { const missingNames = missing .map(reqId => dbProducts.find(p => p.id === reqId)?.name || reqId) .join(', ') throw new Error(`Das Produkt "${prod.name}" erfordert die Auswahl von: ${missingNames}.`) } } // Product exclusions check if (prod.exclusions && prod.exclusions.length > 0) { const conflicting = prod.exclusions.filter(exId => selectedProductIds.includes(exId)) if (conflicting.length > 0) { const conflictingNames = conflicting .map(exId => dbProducts.find(p => p.id === exId)?.name || exId) .join(', ') throw new Error(`Das Produkt "${prod.name}" schließt die gleichzeitige Auswahl von folgenden Produkten aus: ${conflictingNames}.`) } } } // 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, billingInterval, moduleQuantities, lastLicenseDate) // 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 = `bestellbestaetigung_${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 } // 7. E-Mail mit PDF-Anhang an den Besteller senden if (user.email) { try { const formattedDate = new Date(order.created_at).toLocaleDateString('de-DE') const formattedTotal = order.total_price.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }) await sendMail({ to: user.email, subject: `Bestellbestätigung ${orderNumber}`, text: `Hallo,\n\nvielen Dank für Ihre Bestellung bei CASPOS Shop!\n\nBestelldetails:\n- Bestellnummer: ${orderNumber}\n- Datum: ${formattedDate}\n- Endkunde: ${customerSnapshot.company_name}\n- Gesamtsumme: ${formattedTotal}\n\nIm Anhang dieser E-Mail finden Sie Ihre Bestellbestätigung als PDF-Dokument.\n\nViele Grüße,\nIhr CASPOS Shop-Team`, html: `

Vielen Dank für Ihre Bestellung!

Hallo,

wir freuen uns sehr über Ihre Bestellung bei CASPOS Shop. Ihre Bestellung wurde erfolgreich entgegengenommen und wird nun verarbeitet.

Bestelldetails

Bestellnummer: ${orderNumber}
Datum: ${formattedDate}
Endkunde: ${customerSnapshot.company_name}
Gesamtsumme: ${formattedTotal}

Im Anhang dieser E-Mail finden Sie Ihre Bestellbestätigung als PDF-Dokument.

Mit freundlichen Grüßen,
Ihr CASPOS Shop-Team


Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht darauf.

`, attachments: [ { filename: `Bestellbestaetigung_${orderNumber}.pdf`, content: buffer, contentType: 'application/pdf', } ] }) } catch (mailError) { console.error('Failed to send order confirmation mail:', mailError) } } } catch (pdfError) { console.error('PDF Generation Error:', pdfError) } revalidatePath('/my-customers') revalidatePath('/my-orders') return order as Order }