diff --git a/shop/components/admin/create-product-dialog.tsx b/shop/components/admin/create-product-dialog.tsx index abae9e2..23cbd35 100644 --- a/shop/components/admin/create-product-dialog.tsx +++ b/shop/components/admin/create-product-dialog.tsx @@ -40,12 +40,13 @@ import { } from "@/components/ui/select" const moduleSchema = z.object({ + id: z.string().optional(), name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'), description: z.string().optional(), price: z.coerce.number().min(0, 'Preis darf nicht negativ sein'), is_required: z.boolean().default(false), - requirements: z.string().optional().default(''), // Will be converted to array - exclusions: z.string().optional().default(''), // Will be converted to array + requirements: z.array(z.string()).default([]), + exclusions: z.array(z.string()).default([]), }) const productSchema = z.object({ @@ -72,12 +73,13 @@ export function CreateProductDialog({ children, categories, product }: { childre category_id: product?.category_id || '', billing_interval: product?.billing_interval || 'monthly', modules: product?.modules?.map(m => ({ + id: m.id, name: m.name, description: m.description || '', price: m.price, is_required: false, - requirements: m.requirements?.join(', ') || '', - exclusions: m.exclusions?.join(', ') || '', + requirements: m.requirements || [], + exclusions: m.exclusions || [], })) || [], }, }) @@ -92,8 +94,7 @@ export function CreateProductDialog({ children, categories, product }: { childre const { modules, ...productData } = values const formattedModules = modules.map(m => ({ ...m, - requirements: m.requirements ? m.requirements.split(',').map(s => s.trim()).filter(Boolean) : [], - exclusions: m.exclusions ? m.exclusions.split(',').map(s => s.trim()).filter(Boolean) : [], + id: m.id || (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2, 15)), })) if (product) { @@ -243,103 +244,184 @@ export function CreateProductDialog({ children, categories, product }: { childre variant="outline" size="sm" className="border-primary/50 text-primary hover:bg-primary/10" - onClick={() => append({ name: '', price: 0, is_required: false, description: '', requirements: '', exclusions: '' })} + onClick={() => append({ + id: typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(2, 15), + name: '', + price: 0, + is_required: false, + description: '', + requirements: [], + exclusions: [] + })} > Modul hinzufügen - {fields.map((field, index) => ( -
- + {fields.map((field, index) => { + const allModules = form.watch('modules') || [] + const otherModules = allModules + .map((m, idx) => ({ + id: m.id || field.id, + name: m.name || `Modul ${idx + 1}`, + })) + .filter((_, idx) => idx !== index) -
- ( - - Modulname - - - - - - )} - /> - ( - - Aufpreis (€) - - - - - - )} - /> -
+ return ( +
+ -
- ( - - Benötigt (IDs, kommagetrennt) - - - - - - )} - /> - ( - - Schließt aus (IDs, kommagetrennt) - - - - - - )} - /> -
+
+ ( + + Modulname + + + + + + )} + /> + ( + + Aufpreis (€) + + + + + + )} + /> +
-
- ( - - - - -
- Erforderlich +
+ {/* Requirements Selection Matrix */} + ( +
+ Benötigt Module + + {otherModules.length === 0 ? ( +

Keine anderen Module vorhanden.

+ ) : ( +
+ {otherModules.map(other => { + const checked = (reqField.value || []).includes(other.id) + return ( +
+ { + const currentVal = reqField.value || [] + if (checkedState) { + reqField.onChange([...currentVal, other.id]) + } else { + reqField.onChange(currentVal.filter(id => id !== other.id)) + } + }} + /> + +
+ ) + })} +
+ )} +
- - )} - /> + )} + /> + + {/* Exclusions Selection Matrix */} + ( +
+ Schließt aus + + {otherModules.length === 0 ? ( +

Keine anderen Module vorhanden.

+ ) : ( +
+ {otherModules.map(other => { + const checked = (exclField.value || []).includes(other.id) + return ( +
+ { + const currentVal = exclField.value || [] + if (checkedState) { + exclField.onChange([...currentVal, other.id]) + } else { + exclField.onChange(currentVal.filter(id => id !== other.id)) + } + }} + /> + +
+ ) + })} +
+ )} +
+
+ )} + /> +
+ +
+ ( + + + + +
+ Erforderlich +
+
+ )} + /> +
-
- ))} + ) + })} {fields.length === 0 && (
diff --git a/shop/components/order-wizard.tsx b/shop/components/order-wizard.tsx index 1b7180f..7604ea5 100644 --- a/shop/components/order-wizard.tsx +++ b/shop/components/order-wizard.tsx @@ -48,12 +48,18 @@ function toggleModuleInList( ): string[] { const isSelected = currentIds.includes(toggleId) if (isSelected) { - const next = currentIds.filter(id => id !== toggleId) - // cascade: remove modules that required this one - return next.filter(mId => { - const m = modules.find(mod => mod.id === mId) - return !m?.requirements || m.requirements.every(reqId => next.includes(reqId) || reqId === toggleId) - }).filter(id => id !== toggleId) + // Recursive removal: repeatedly filter until no more dependent modules are removed + let next = currentIds.filter(id => id !== toggleId) + let changed = true + while (changed) { + const beforeLength = next.length + next = next.filter(mId => { + const m = modules.find(mod => mod.id === mId) + return !m?.requirements || m.requirements.every(reqId => next.includes(reqId)) + }) + changed = next.length !== beforeLength + } + return next } else { const module = modules.find(m => m.id === toggleId) if (module?.exclusions?.some(exId => currentIds.includes(exId))) return currentIds diff --git a/shop/lib/actions/orders.ts b/shop/lib/actions/orders.ts index c4d72b4..690565a 100644 --- a/shop/lib/actions/orders.ts +++ b/shop/lib/actions/orders.ts @@ -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 endCustomerId?: string | null endCustomer?: EndCustomer | null }): Promise { - 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) diff --git a/shop/lib/actions/products.ts b/shop/lib/actions/products.ts index bf4f218..f8bad36 100644 --- a/shop/lib/actions/products.ts +++ b/shop/lib/actions/products.ts @@ -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, modules: Omit[]) { +export async function createProduct( + product: Omit, + modules: Omit[] +) { 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 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, 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, modul if (modulesError) throw modulesError } + revalidatePath('/admin/products') revalidatePath('/order') } diff --git a/shop/utils/mail.ts b/shop/utils/mail.ts index e294e8b..7c97d77 100644 --- a/shop/utils/mail.ts +++ b/shop/utils/mail.ts @@ -1,22 +1,9 @@ // utils/mail.ts – simple wrapper around nodemailer import nodemailer from 'nodemailer'; - -// Load SMTP configuration from environment variables -const smtpConfig = { - host: process.env.SMTP_HOST, - port: Number(process.env.SMTP_PORT), - secure: process.env.SMTP_SECURE === 'true' || process.env.SMTP_SECURE === '1', // true for 465, false for other ports - auth: { - user: process.env.SMTP_USER, - pass: process.env.SMTP_PASS, - }, -}; - -// Create a reusable transporter. If any required var is missing, nodemailer will throw on send. -const transporter = nodemailer.createTransport(smtpConfig); +import { createAdminClient } from '@/lib/supabase/admin'; /** - * Send an email. + * Send an email using database-configured SMTP settings (or environment variable fallback). * @param to Recipient address * @param subject Subject line * @param text Plain‑text body @@ -33,12 +20,46 @@ export async function sendMail({ text: string; html?: string; }) { + // Query custom SMTP settings from database + const supabase = createAdminClient(); + const { data: dbSettings } = await supabase + .from('settings') + .select('*') + .eq('id', 'smtp') + .maybeSingle(); + + // Resolve config from DB or environment variables + const host = dbSettings?.host || process.env.SMTP_HOST; + const port = dbSettings?.port ? Number(dbSettings.port) : Number(process.env.SMTP_PORT); + const secure = dbSettings + ? !!dbSettings.secure + : (process.env.SMTP_SECURE === 'true' || process.env.SMTP_SECURE === '1'); + const user = dbSettings?.user || process.env.SMTP_USER; + const pass = dbSettings?.pass || process.env.SMTP_PASS; + + if (!host || !user) { + throw new Error('SMTP host and user must be configured (either in settings database table or environment variables).'); + } + + // Create transporter dynamically on send request + const transporter = nodemailer.createTransport({ + host, + port, + secure, + auth: { + user, + pass, + }, + }); + const info = await transporter.sendMail({ - from: smtpConfig.auth.user, + from: user, to, subject, text, html, }); + return info; } +