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;
}
+