Implement dynamic SMTP setting loader, admin product dependency matrix UI, recursive deselect cascade, and backend catalog validation
All checks were successful
Staging Build / build (push) Successful in 2m19s

This commit is contained in:
DanielS
2026-06-23 04:42:50 +02:00
parent de1d228fb2
commit 0729dd21be
5 changed files with 286 additions and 122 deletions

View File

@@ -7,6 +7,7 @@ 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 ─────────────────────────────────────────────────────────
@@ -48,22 +49,62 @@ function hashOrderSnapshot(snapshot: object): string {
*/
export async function submitOrder(params: {
selections: WizardSelections
products: Product[]
categories: Category[]
products?: Product[]
categories?: Category[]
customerProfile: Partial<Profile>
endCustomerId?: string | null
endCustomer?: EndCustomer | null
}): Promise<Order> {
const { selections, products, categories, customerProfile, endCustomerId, endCustomer } = params
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, products, categories)
const orderSnapshot = buildOrderSnapshot(selections, dbProducts, dbCategories)
// 2. Idempotenz-Guard: Prüfen ob identische Bestellung in den letzten 30s existiert
const orderHash = hashOrderSnapshot(orderSnapshot)

View File

@@ -3,6 +3,7 @@
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
import { Category, Product, ProductModule } from '../types'
import { randomUUID } from 'crypto'
export async function getProducts() {
const supabase = await createClient()
@@ -64,7 +65,10 @@ export async function deleteCategory(id: string) {
revalidatePath('/order')
}
export async function createProduct(product: Omit<Product, 'id' | 'created_at' | 'updated_at'>, modules: Omit<ProductModule, 'id' | 'product_id' | 'created_at'>[]) {
export async function createProduct(
product: Omit<Product, 'id' | 'created_at' | 'updated_at'>,
modules: Omit<ProductModule, 'product_id' | 'created_at'>[]
) {
const supabase = await createClient()
// Start a transaction-like approach (Supabase doesn't have cross-table transactions easily in one call via JS client without RPC)
@@ -77,7 +81,15 @@ export async function createProduct(product: Omit<Product, 'id' | 'created_at' |
if (productError) throw productError
if (modules.length > 0) {
const modulesWithId = modules.map(m => ({ ...m, product_id: newProduct.id }))
const modulesWithId = modules.map(m => ({
id: m.id || randomUUID(),
product_id: newProduct.id,
name: m.name,
description: m.description || null,
price: m.price,
requirements: m.requirements || [],
exclusions: m.exclusions || [],
}))
const { error: modulesError } = await supabase
.from('product_modules')
.insert(modulesWithId)
@@ -106,12 +118,13 @@ export async function updateProduct(id: string, product: Partial<Product>, modul
if (modules.length > 0) {
const modulesWithId = modules.map(m => ({
id: m.id || randomUUID(),
product_id: id,
name: m.name,
description: m.description || null,
price: m.price,
requirements: m.requirements || [],
exclusions: m.exclusions || [],
product_id: id
}))
const { error: modulesError } = await supabase
.from('product_modules')
@@ -120,6 +133,7 @@ export async function updateProduct(id: string, product: Partial<Product>, modul
if (modulesError) throw modulesError
}
revalidatePath('/admin/products')
revalidatePath('/order')
}