From 2246bdf2be7caa0169ab3e6d9ae751ddb48ae689 Mon Sep 17 00:00:00 2001 From: DanielS Date: Mon, 6 Jul 2026 23:15:11 +0200 Subject: [PATCH] refactor(shop): extract wizard validation and email templates into helpers --- shop/lib/actions/email-templates.ts | 87 ++++++++++ shop/lib/actions/orders.ts | 251 ++++------------------------ shop/lib/actions/validation.ts | 74 ++++++++ 3 files changed, 189 insertions(+), 223 deletions(-) create mode 100644 shop/lib/actions/email-templates.ts create mode 100644 shop/lib/actions/validation.ts diff --git a/shop/lib/actions/email-templates.ts b/shop/lib/actions/email-templates.ts new file mode 100644 index 0000000..e547ddd --- /dev/null +++ b/shop/lib/actions/email-templates.ts @@ -0,0 +1,87 @@ +interface EmailDetails { + orderNumber: string + formattedDate: string + customerCompanyName: string + totalDetailsText: string + totalDetailsHtml: string +} + +export function getOrderEmailTemplate( + details: EmailDetails, + siteUrl: string, + isUpdate: boolean = false +) { + const title = isUpdate ? 'Ihre Anfrage wurde aktualisiert!' : 'Vielen Dank für Ihre Anfrage!' + const intro = isUpdate + ? `Ihr Anfrage mit der Nummer ${details.orderNumber} wurde erfolgreich aktualisiert.` + : 'wir freuen uns sehr über Ihre Anfrage bei CASPOS Shop. Ihre Anfrage wurde erfolgreich entgegengenommen und wird nun verarbeitet.' + + const text = `Hallo,\n\n${isUpdate ? 'ihre Anfrage wurde erfolgreich aktualisiert!' : 'vielen Dank für Ihre Anfrage bei CASPOS Shop!'}\n\n${isUpdate ? 'Neue Anfragedetails' : 'Anfragedetails'}:\n- Anfrage-Nummer: ${details.orderNumber}\n- Datum: ${details.formattedDate}\n- Endkunde: ${details.customerCompanyName}\n${details.totalDetailsText}\n\nIm Anhang dieser E-Mail finden Sie Ihre ${isUpdate ? 'aktualisierte' : ''} Anfragebestätigung als PDF-Dokument.\n\nPDF herunterladen: ${siteUrl}/api/orders/${details.orderNumber}/download\n\nViele Grüße,\nIhr CASPOS Shop-Team` + + const html = ` +
+

${title}

+

Hallo,

+

${intro}

+
+

${isUpdate ? 'Neue Anfrage-Details' : 'Anfrage-Details'}

+ + + + + + + + + + + + + + ${details.totalDetailsHtml} +
Anfrage-Nummer:${details.orderNumber}
Datum:${details.formattedDate}
Endkunde:${details.customerCompanyName}
+
+

Im Anhang dieser E-Mail finden Sie Ihre ${isUpdate ? 'aktualisierte' : ''} Anfragebestätigung als PDF-Dokument.

+
+ 📄 PDF herunterladen +
+

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

+ ${!isUpdate ? '

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

' : ''} +
+ ` + + return { text, html } +} + +export function getStatusEmailTemplate(orderNumber: string, oldLabel: string, newLabel: string) { + const text = `Hallo,\n\nder Status Ihrer Bestellung mit der Nummer ${orderNumber} hat sich geändert.\n\nNeuer Status: ${newLabel} (vorher: ${oldLabel})\n\nViele Grüße,\nIhr CASPOS Shop-Team` + + const html = ` +
+

Status Ihrer Bestellung hat sich geändert

+

Hallo,

+

der Status Ihrer Bestellung mit der Nummer ${orderNumber} wurde aktualisiert.

+
+ + + + + + + + + + + + + +
Bestellnummer:${orderNumber}
Alter Status:${oldLabel}
Neuer Status:${newLabel}
+
+

Viele Grüße,
Ihr CASPOS Shop-Team

+
+

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

+
+ ` + + return { text, html } +} diff --git a/shop/lib/actions/orders.ts b/shop/lib/actions/orders.ts index 48fc5ac..f261343 100644 --- a/shop/lib/actions/orders.ts +++ b/shop/lib/actions/orders.ts @@ -11,6 +11,8 @@ 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' +import { validateWizardSelections } from '@/lib/actions/validation' +import { getOrderEmailTemplate, getStatusEmailTemplate } from '@/lib/actions/email-templates' // ─── Hilfsfunktionen ───────────────────────────────────────────────────────── @@ -78,72 +80,7 @@ export async function submitOrder(params: { 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 hasMetRequirement = mod.requirements.some(reqId => sel.moduleIds.includes(reqId)) - if (!hasMetRequirement) { - throw new Error(`Das Modul "${mod.name}" setzt die Aktivierung mindestens eines der folgenden Module voraus: ${mod.requirements.join(', ')}.`) - } - } - // 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 hasMetRequirement = prod.requirements.some(reqId => selectedProductIds.includes(reqId)) - if (!hasMetRequirement) { - const reqNames = prod.requirements - .map(reqId => dbProducts.find(p => p.id === reqId)?.name || reqId) - .join(' oder ') - throw new Error(`Das Produkt "${prod.name}" erfordert die Auswahl von mindestens einem dieser Produkte: ${reqNames}.`) - } - } - - // 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}.`) - } - } - } + validateWizardSelections(selections, dbProducts, dbCategories) // 1. Snapshots aufbauen // Wenn Endkunde vorhanden: dessen Daten einfrieren; sonst Partner-Profil (Fallback) @@ -319,42 +256,19 @@ export async function submitOrder(params: { ` } + const emailTemplate = getOrderEmailTemplate({ + orderNumber, + formattedDate, + customerCompanyName: customerSnapshot.company_name, + totalDetailsText, + totalDetailsHtml + }, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, false) + await sendMail({ to: user.email, subject: `Anfragebestätigung ${orderNumber}`, - text: `Hallo,\n\nvielen Dank für Ihre Anfrage bei CASPOS Shop!\n\nAnfragedetails:\n- Anfrage-Nummer: ${orderNumber}\n- Datum: ${formattedDate}\n- Endkunde: ${customerSnapshot.company_name}\n${totalDetailsText}\n\nIm Anhang dieser E-Mail finden Sie Ihre Anfragebestätigung als PDF-Dokument.\n\nPDF herunterladen: ${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}/api/orders/${order.id}/download\n\nViele Grüße,\nIhr CASPOS Shop-Team`, - html: ` -
-

Vielen Dank für Ihre Anfrage!

-

Hallo,

-

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

-
-

Anfrage-Details

- - - - - - - - - - - - - - ${totalDetailsHtml} -
Anfrage-Nummer:${orderNumber}
Datum:${formattedDate}
Endkunde:${customerSnapshot.company_name}
-
-

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

-
- 📄 PDF herunterladen -
-

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

-
-

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

-
- `, + text: emailTemplate.text, + html: emailTemplate.html, attachments: [ { filename: `Anfragebestaetigung_${orderNumber}.pdf`, @@ -427,36 +341,13 @@ export async function updateOrderStatus( const oldLabel = statusLabelMap[oldStatus] || oldStatus const newLabel = statusLabelMap[newStatus] || newStatus + const statusEmail = getStatusEmailTemplate(orderNumber, oldLabel, newLabel) + await sendMail({ to: user.email, subject: `Statusänderung Ihrer Bestellung ${orderNumber}`, - text: `Hallo,\n\nder Status Ihrer Bestellung mit der Nummer ${orderNumber} hat sich geändert.\n\nNeuer Status: ${newLabel} (vorher: ${oldLabel})\n\nViele Grüße,\nIhr CASPOS Shop-Team`, - html: ` -
-

Status Ihrer Bestellung hat sich geändert

-

Hallo,

-

der Status Ihrer Bestellung mit der Nummer ${orderNumber} wurde aktualisiert.

-
- - - - - - - - - - - - - -
Bestellnummer:${orderNumber}
Alter Status:${oldLabel}
Neuer Status:${newLabel}
-
-

Viele Grüße,
Ihr CASPOS Shop-Team

-
-

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

-
- ` + text: statusEmail.text, + html: statusEmail.html }) } } catch (mailError) { @@ -538,72 +429,7 @@ export async function updateOrder( 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 hasMetRequirement = mod.requirements.some(reqId => sel.moduleIds.includes(reqId)) - if (!hasMetRequirement) { - throw new Error(`Das Modul "${mod.name}" setzt die Aktivierung mindestens eines der folgenden Module voraus: ${mod.requirements.join(', ')}.`) - } - } - // 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 hasMetRequirement = prod.requirements.some(reqId => selectedProductIds.includes(reqId)) - if (!hasMetRequirement) { - const reqNames = prod.requirements - .map(reqId => dbProducts.find(p => p.id === reqId)?.name || reqId) - .join(' oder ') - throw new Error(`Das Produkt "${prod.name}" erfordert die Auswahl von mindestens einem dieser Produkte: ${reqNames}.`) - } - } - - // 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}.`) - } - } - } + validateWizardSelections(selections, dbProducts, dbCategories) // 1. Snapshots aufbauen const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null) @@ -750,40 +576,19 @@ export async function updateOrder( ` } + const emailTemplate = getOrderEmailTemplate({ + orderNumber: order.order_number, + formattedDate, + customerCompanyName: customerSnapshot.company_name, + totalDetailsText, + totalDetailsHtml + }, `${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}`, true) + await sendMail({ to: orderUserEmail, subject: `Anfrageänderung ${order.order_number}`, - text: `Hallo,\n\nihre Anfrage wurde erfolgreich aktualisiert!\n\nNeue Anfragedetails:\n- Anfrage-Nummer: ${order.order_number}\n- Datum: ${formattedDate}\n- Endkunde: ${customerSnapshot.company_name}\n${totalDetailsText}\n\nIm Anhang dieser E-Mail finden Sie Ihre aktualisierte Anfrage als PDF-Dokument.\n\nPDF herunterladen: ${process.env.NEXT_PUBLIC_SITE_URL || 'https://staging.hephex.de'}/api/orders/${order.id}/download\n\nViele Grüße,\nIhr CASPOS Shop-Team`, - html: ` -
-

Ihre Anfrage wurde aktualisiert!

-

Hallo,

-

Ihr Anfrage mit der Nummer ${order.order_number} wurde erfolgreich aktualisiert.

-
-

Neue Anfrage-Details

- - - - - - - - - - - - - - ${totalDetailsHtml} -
Anfrage-Nummer:${order.order_number}
Datum:${formattedDate}
Endkunde:${customerSnapshot.company_name}
-
-

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

-
- 📄 PDF herunterladen -
-

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

-
- `, + text: emailTemplate.text, + html: emailTemplate.html, attachments: [ { filename: `Anfragebestaetigung_${order.order_number}.pdf`, diff --git a/shop/lib/actions/validation.ts b/shop/lib/actions/validation.ts new file mode 100644 index 0000000..884648c --- /dev/null +++ b/shop/lib/actions/validation.ts @@ -0,0 +1,74 @@ +import { WizardSelections, Product, Category } from '@/lib/types' + +export function validateWizardSelections( + selections: WizardSelections, + dbProducts: Product[], + dbCategories: Category[] +): void { + 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 hasMetRequirement = mod.requirements.some(reqId => sel.moduleIds.includes(reqId)) + if (!hasMetRequirement) { + throw new Error(`Das Modul "${mod.name}" setzt die Aktivierung mindestens eines der folgenden Module voraus: ${mod.requirements.join(', ')}.`) + } + } + // 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 hasMetRequirement = prod.requirements.some(reqId => selectedProductIds.includes(reqId)) + if (!hasMetRequirement) { + const reqNames = prod.requirements + .map(reqId => dbProducts.find(p => p.id === reqId)?.name || reqId) + .join(' oder ') + throw new Error(`Das Produkt "${prod.name}" erfordert die Auswahl von mindestens einem dieser Produkte: ${reqNames}.`) + } + } + + // 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}.`) + } + } + } +}