From fb124b549c406a8c737eab4621135e5c97973758 Mon Sep 17 00:00:00 2001 From: DanielS Date: Wed, 17 Jun 2026 01:57:17 +0200 Subject: [PATCH] feat(crm): end_customers table, RLS, wizard customer selector, /my-customers CRUD, GDPR anonymization --- shop/app/layout.tsx | 2 +- shop/app/my-customers/[id]/page.tsx | 213 ++++++++++++++++++ shop/app/my-customers/new/page.tsx | 94 ++++++++ shop/app/my-customers/page.tsx | 151 +++++++++++++ shop/app/order/page.tsx | 6 +- shop/app/page.tsx | 3 + shop/components/order-wizard.tsx | 211 +++++++++++++---- shop/lib/actions/end-customers.ts | 145 ++++++++++++ shop/lib/actions/orders.ts | 10 +- shop/lib/license-transform.ts | 25 +- shop/lib/types.ts | 19 ++ .../migrations/20260617_end_customers.sql | 27 +++ 12 files changed, 855 insertions(+), 51 deletions(-) create mode 100644 shop/app/my-customers/[id]/page.tsx create mode 100644 shop/app/my-customers/new/page.tsx create mode 100644 shop/app/my-customers/page.tsx create mode 100644 shop/lib/actions/end-customers.ts create mode 100644 shop/supabase/migrations/20260617_end_customers.sql diff --git a/shop/app/layout.tsx b/shop/app/layout.tsx index 62a36f3..9e03b7b 100644 --- a/shop/app/layout.tsx +++ b/shop/app/layout.tsx @@ -9,7 +9,7 @@ const defaultUrl = process.env.VERCEL_URL export const metadata: Metadata = { metadataBase: new URL(defaultUrl), - title: "CASPOSShop | Modulare Software-Lösungen", + title: "CASPOS-Shop | Die Kasse", description: "Konfigurieren und bestellen Sie Ihre maßgeschneiderte Business-Software in Minuten.", }; diff --git a/shop/app/my-customers/[id]/page.tsx b/shop/app/my-customers/[id]/page.tsx new file mode 100644 index 0000000..b93eae9 --- /dev/null +++ b/shop/app/my-customers/[id]/page.tsx @@ -0,0 +1,213 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import { ArrowLeft, Save, AlertTriangle, Loader2, Building2, ShoppingBag, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' +import { Separator } from '@/components/ui/separator' +import { getEndCustomer, updateEndCustomer, anonymizeEndCustomer } from '@/lib/actions/end-customers' +import type { EndCustomer } from '@/lib/types' + +export default function CustomerDetailPage({ params }: { params: Promise<{ id: string }> }) { + const router = useRouter() + const [customer, setCustomer] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [anonymizing, setAnonymizing] = useState(false) + const [showGdprConfirm, setShowGdprConfirm] = useState(false) + const [form, setForm] = useState({ + company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '', + }) + + useEffect(() => { + params.then(({ id }) => { + getEndCustomer(id).then(c => { + if (!c) { router.push('/my-customers'); return } + setCustomer(c) + setForm({ + company_name: c.company_name ?? '', + vat_id: c.vat_id ?? '', + first_name: c.first_name ?? '', + last_name: c.last_name ?? '', + street: c.street ?? '', + zip: c.zip ?? '', + city: c.city ?? '', + }) + setLoading(false) + }) + }) + }, [params, router]) + + const handleSave = async () => { + if (!customer || !form.company_name.trim()) return + setSaving(true) + try { + await updateEndCustomer(customer.id, form) + setCustomer(prev => prev ? { ...prev, ...form } : prev) + alert('Kundendaten gespeichert.') + } catch (e) { + alert('Fehler beim Speichern.') + } finally { + setSaving(false) + } + } + + const handleAnonymize = async () => { + if (!customer) return + setAnonymizing(true) + try { + await anonymizeEndCustomer(customer.id) + router.push('/my-customers') + } catch (e) { + alert('Fehler bei der Anonymisierung.') + setAnonymizing(false) + } + } + + if (loading) { + return ( +
+ +
+ ) + } + + if (!customer) return null + + return ( +
+
+ {/* Header */} +
+ + + +
+

+ + {customer.is_anonymized ? '[Anonymisiert]' : customer.company_name} +

+

+ Kunden-ID: {customer.id.slice(0, 8)}… +

+
+ {customer.is_anonymized && ( + + Anonymisiert + + )} +
+ + {/* Bearbeitungsformular */} + {!customer.is_anonymized && ( + + + Stammdaten bearbeiten + + Änderungen wirken sich nur auf zukünftige Bestellungen aus. Bestehende Snapshots bleiben unverändert. + + + +
+ {([ + { label: 'Firmenname *', key: 'company_name', span: true }, + { label: 'USt-IdNr.', key: 'vat_id', span: false }, + { label: 'Vorname', key: 'first_name', span: false }, + { label: 'Nachname', key: 'last_name', span: false }, + { label: 'Straße & Hausnummer', key: 'street', span: true }, + { label: 'PLZ', key: 'zip', span: false }, + { label: 'Ort', key: 'city', span: false }, + ] as const).map(({ label, key, span }) => ( +
+ + setForm(prev => ({ ...prev, [key]: e.target.value }))} + className="bg-white/5 border-white/10 text-white" + /> +
+ ))} +
+
+ + + + +
+
+
+ )} + + {/* DSGVO Anonymisierung */} + {!customer.is_anonymized && ( + + + + + Recht auf Vergessenwerden (DSGVO) + + + Auf Wunsch des Endkunden können Sie dessen personenbezogene Daten unwiderruflich anonymisieren. + Bestehende Bestelldaten (PDF-Rechnungen, Snapshots) werden aus steuerrechtlichen Gründen + nicht gelöscht. + + + + {!showGdprConfirm ? ( + + ) : ( +
+

+ ⚠️ Diese Aktion ist unwiderruflich. Alle personenbezogenen Daten werden überschrieben. +

+
+ + +
+
+ )} +
+
+ )} + + {/* Bereits anonymisiert */} + {customer.is_anonymized && ( + + + +

Dieser Endkunde wurde bereits anonymisiert.

+

Die Daten können nicht wiederhergestellt werden.

+
+
+ )} +
+
+ ) +} diff --git a/shop/app/my-customers/new/page.tsx b/shop/app/my-customers/new/page.tsx new file mode 100644 index 0000000..abd6c9d --- /dev/null +++ b/shop/app/my-customers/new/page.tsx @@ -0,0 +1,94 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import Link from 'next/link' +import { ArrowLeft, UserPlus, Loader2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { createEndCustomer } from '@/lib/actions/end-customers' + +export default function NewCustomerPage() { + const router = useRouter() + const [saving, setSaving] = useState(false) + const [form, setForm] = useState({ + company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '', + }) + + const handleSave = async () => { + if (!form.company_name.trim()) return + setSaving(true) + try { + await createEndCustomer(form) + router.push('/my-customers') + } catch (e) { + alert('Fehler beim Anlegen des Kunden.') + setSaving(false) + } + } + + return ( +
+
+
+ + + +

+ + Neuen Endkunden anlegen +

+
+ + + + Stammdaten + + Legen Sie einen neuen Endkunden an. Diese Daten stehen sofort im Bestellwizard zur Auswahl. + + + +
+ {([ + { label: 'Firmenname *', key: 'company_name', span: true, placeholder: 'GmbH / KG / Einzelunternehmen' }, + { label: 'USt-IdNr.', key: 'vat_id', span: false, placeholder: 'DE123456789' }, + { label: 'Vorname', key: 'first_name', span: false, placeholder: '' }, + { label: 'Nachname', key: 'last_name', span: false, placeholder: '' }, + { label: 'Straße & Hausnummer', key: 'street', span: true, placeholder: 'Musterstraße 1' }, + { label: 'PLZ', key: 'zip', span: false, placeholder: '12345' }, + { label: 'Ort', key: 'city', span: false, placeholder: 'Musterstadt' }, + ] as const).map(({ label, key, span, placeholder }) => ( +
+ + setForm(prev => ({ ...prev, [key]: e.target.value }))} + placeholder={placeholder} + className="bg-white/5 border-white/10 text-white placeholder:text-slate-500" + /> +
+ ))} +
+
+ + + + +
+
+
+
+
+ ) +} diff --git a/shop/app/my-customers/page.tsx b/shop/app/my-customers/page.tsx new file mode 100644 index 0000000..81af19f --- /dev/null +++ b/shop/app/my-customers/page.tsx @@ -0,0 +1,151 @@ +import { createClient } from '@/lib/supabase/server' +import { redirect } from 'next/navigation' +import Link from 'next/link' +import { getEndCustomers } from '@/lib/actions/end-customers' +import { ArrowLeft, Building2, Plus, Edit2, AlertTriangle } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' + +export default async function MyCustomersPage() { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + if (!user) redirect('/auth/login') + + const customers = await getEndCustomers() + + // Anzahl Bestellungen pro Endkunde + const { data: orderCounts } = await supabase + .from('orders') + .select('end_customer_id') + .not('end_customer_id', 'is', null) + + const countMap: Record = {} + ;(orderCounts ?? []).forEach(o => { + if (o.end_customer_id) { + countMap[o.end_customer_id] = (countMap[o.end_customer_id] ?? 0) + 1 + } + }) + + return ( +
+
+ {/* Header */} +
+
+ + + +
+

+ + Meine Kunden +

+

+ Verwalten Sie Ihre Endkunden – deren Daten werden für Bestellungen verwendet. +

+
+
+ + + +
+ + {/* Keine Kunden */} + {customers.length === 0 && ( + + + +

Noch keine Endkunden angelegt.

+ + + +
+
+ )} + + {/* Kundenliste */} + {customers.length > 0 && ( +
+ + + + + + + + + + + + {customers.map(customer => ( + + + + + + + + ))} + +
UnternehmenAdresseBestellungenStatusAktionen
+

{customer.company_name}

+ {(customer.first_name || customer.last_name) && ( +

+ {[customer.first_name, customer.last_name].filter(Boolean).join(' ')} +

+ )} + {customer.vat_id && ( +

{customer.vat_id}

+ )} +
+

+ {customer.street ?? '–'} +

+

+ {[customer.zip, customer.city].filter(Boolean).join(' ') || '–'} +

+
+ + + {countMap[customer.id] ?? 0} + + Bestellung(en) + + + {customer.is_anonymized ? ( + + Anonymisiert + + ) : ( + Aktiv + )} + + {!customer.is_anonymized && ( + + + + )} +
+
+ )} + + {/* DSGVO-Hinweis */} +
+

DSGVO-Hinweis

+

+ Die hier gespeicherten Kundendaten unterliegen der DSGVO. Auf Wunsch Ihrer Endkunden können Sie deren Daten + über die Bearbeitungsmaske anonymisieren (Recht auf Vergessenwerden). Bestehende Bestellungen bleiben aus + steuerrechtlichen Gründen unverändert. +

+
+
+
+ ) +} diff --git a/shop/app/order/page.tsx b/shop/app/order/page.tsx index 9033f25..69d1ef1 100644 --- a/shop/app/order/page.tsx +++ b/shop/app/order/page.tsx @@ -1,5 +1,6 @@ import { createClient } from '@/lib/supabase/server' import { getProducts, getCategories } from '@/lib/actions/products' +import { getEndCustomers } from '@/lib/actions/end-customers' import { OrderWizard } from '@/components/order-wizard' import { redirect } from 'next/navigation' import { Suspense } from 'react' @@ -35,7 +36,8 @@ async function OrderDataWrapper() { const products = await getProducts() const categories = await getCategories() - + const endCustomers = await getEndCustomers() + // Fetch profile if exists const { data: profile } = await supabase .from('profiles') @@ -43,5 +45,5 @@ async function OrderDataWrapper() { .eq('id', user.id) .single() - return + return } diff --git a/shop/app/page.tsx b/shop/app/page.tsx index fc3288b..96c0ade 100644 --- a/shop/app/page.tsx +++ b/shop/app/page.tsx @@ -22,6 +22,9 @@ export default function Home() { Bestellen + + Meine Kunden + Meine Bestellungen diff --git a/shop/components/order-wizard.tsx b/shop/components/order-wizard.tsx index 808fac6..1f7210f 100644 --- a/shop/components/order-wizard.tsx +++ b/shop/components/order-wizard.tsx @@ -3,7 +3,7 @@ import { useState, useMemo } from 'react' import { useRouter } from 'next/navigation' import { motion, AnimatePresence } from 'framer-motion' -import { Product, ProductModule, Profile } from '@/lib/types' +import { Product, ProductModule, Profile, EndCustomer } from '@/lib/types' import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' @@ -11,8 +11,9 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Separator } from '@/components/ui/separator' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' -import { Check, ChevronRight, ChevronLeft, ShoppingCart, User, ShieldCheck, Cloud, Utensils, HardDrive, Package, AlertCircle, Loader2 } from 'lucide-react' +import { Check, ChevronRight, ChevronLeft, ShoppingCart, User, ShieldCheck, Cloud, Utensils, HardDrive, Package, AlertCircle, Loader2, UserPlus, Building2 } from 'lucide-react' import { submitOrder } from '@/lib/actions/orders' +import { createEndCustomer } from '@/lib/actions/end-customers' import { Category } from '@/lib/types' import { Badge } from '@/components/ui/badge' @@ -72,25 +73,33 @@ export function OrderWizard({ products, categories, initialProfile, + initialEndCustomers, }: { products: Product[] categories: Category[] initialProfile: Profile | null + initialEndCustomers: EndCustomer[] }) { const router = useRouter() const [step, setStep] = useState(1) const [isSubmitting, setIsSubmitting] = useState(false) - const [customerData, setCustomerData] = useState>( - initialProfile || { - company_name: '', - vat_id: '', - first_name: '', - last_name: '', - address: '', - city: '', - zip_code: '', - } + + // Partner-Profil (Fallback) + const [customerData] = useState>(initialProfile || {}) + + // Endkunden-State + const [endCustomers, setEndCustomers] = useState(initialEndCustomers) + const [selectedEndCustomerId, setSelectedEndCustomerId] = useState( + initialEndCustomers.length > 0 ? initialEndCustomers[0].id : null ) + const selectedEndCustomer = endCustomers.find(c => c.id === selectedEndCustomerId) ?? null + + // Modus: 'select' = Bestandskunde wählen | 'create' = Neuen anlegen + const [customerMode, setCustomerMode] = useState<'select' | 'create'>('select') + const [isCreatingCustomer, setIsCreatingCustomer] = useState(false) + const [newCustomerForm, setNewCustomerForm] = useState({ + company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '', + }) // Per-category selection map: categoryId -> { productId, moduleIds } const [selections, setSelections] = useState>(() => { @@ -147,6 +156,23 @@ export function OrderWizard({ const nextStep = () => setStep(s => s + 1) const prevStep = () => setStep(s => s - 1) + // Neuen Endkunden inline anlegen + const handleCreateCustomer = async () => { + if (!newCustomerForm.company_name.trim()) return + setIsCreatingCustomer(true) + try { + const created = await createEndCustomer(newCustomerForm) + setEndCustomers(prev => [...prev, created]) + setSelectedEndCustomerId(created.id) + setCustomerMode('select') + setNewCustomerForm({ company_name: '', vat_id: '', first_name: '', last_name: '', street: '', zip: '', city: '' }) + } catch (e) { + alert('Fehler beim Anlegen des Kunden.') + } finally { + setIsCreatingCustomer(false) + } + } + const handleSubmit = async () => { if (isSubmitting) return // Doppelklick-Guard setIsSubmitting(true) @@ -156,6 +182,8 @@ export function OrderWizard({ products, categories, customerProfile: customerData, + endCustomerId: selectedEndCustomerId, + endCustomer: selectedEndCustomer, }) router.push(`/order/success?id=${order.id}`) } catch (error) { @@ -425,7 +453,7 @@ export function OrderWizard({ )} - {/* ───── Step 2: Customer Data ───── */} + {/* ───── Step 2: Endkunde wählen / anlegen ───── */} {step === 2 && ( - Kundendaten + Für wen bestellen Sie? - Wir benötigen Ihre Daten für die Rechnungsstellung. + Wählen Sie einen Endkunden aus Ihrem CRM oder legen Sie einen neuen an. - - {[ - { label: 'Firmenname', key: 'company_name', placeholder: 'GmbH / Einzelunternehmen', span: false }, - { label: 'USt-IdNr.', key: 'vat_id', placeholder: 'DE123456789', span: false }, - { label: 'Vorname', key: 'first_name', placeholder: '', span: false }, - { label: 'Nachname', key: 'last_name', placeholder: '', span: false }, - { label: 'Straße & Hausnummer', key: 'address', placeholder: '', span: true }, - { label: 'Postleitzahl', key: 'zip_code', placeholder: '', span: false }, - { label: 'Ort', key: 'city', placeholder: '', span: false }, - ].map(({ label, key, placeholder, span }) => ( -
- - setCustomerData({ ...customerData, [key]: e.target.value })} - placeholder={placeholder} - className="bg-white/5 border-white/10 text-white placeholder:text-slate-500" - /> + + {/* Modus-Toggle */} +
+ + +
+ + {/* Modus A: Bestandskunde wählen */} + {customerMode === 'select' && ( +
+ {endCustomers.length === 0 ? ( +
+ +

Noch keine Endkunden angelegt.

+ +
+ ) : ( +
+ {endCustomers.filter(c => !c.is_anonymized).map(customer => ( + + ))} +
+ )}
- ))} + )} + + {/* Modus B: Neuen Kunden anlegen */} + {customerMode === 'create' && ( +
+ {([ + { label: 'Firmenname *', key: 'company_name', span: true, placeholder: 'GmbH / Einzelunternehmen' }, + { label: 'USt-IdNr.', key: 'vat_id', span: false, placeholder: 'DE123456789' }, + { label: 'Vorname', key: 'first_name', span: false, placeholder: '' }, + { label: 'Nachname', key: 'last_name', span: false, placeholder: '' }, + { label: 'Straße & Hausnummer', key: 'street', span: true, placeholder: '' }, + { label: 'PLZ', key: 'zip', span: false, placeholder: '' }, + { label: 'Ort', key: 'city', span: false, placeholder: '' }, + ] as const).map(({ label, key, span, placeholder }) => ( +
+ + setNewCustomerForm(prev => ({ ...prev, [key]: e.target.value }))} + placeholder={placeholder} + className="bg-white/5 border-white/10 text-white placeholder:text-slate-500" + /> +
+ ))} +
+ + +
+
+ )}
- @@ -533,13 +650,23 @@ export function OrderWizard({ })}
-

{customerData.company_name}

-

- {customerData.first_name} {customerData.last_name} -

-

- {customerData.address}, {customerData.zip_code} {customerData.city} -

+ {selectedEndCustomer ? ( + <> +

+ + {selectedEndCustomer.company_name} +

+

{[selectedEndCustomer.first_name, selectedEndCustomer.last_name].filter(Boolean).join(' ')}

+

{[selectedEndCustomer.street, selectedEndCustomer.zip, selectedEndCustomer.city].filter(Boolean).join(', ')}

+

Endkunde Ihres Partners

+ + ) : ( + <> +

{customerData.company_name}

+

{customerData.first_name} {customerData.last_name}

+

{customerData.address}, {customerData.zip_code} {customerData.city}

+ + )}
diff --git a/shop/lib/actions/end-customers.ts b/shop/lib/actions/end-customers.ts new file mode 100644 index 0000000..9fcd740 --- /dev/null +++ b/shop/lib/actions/end-customers.ts @@ -0,0 +1,145 @@ +'use server' + +import { createClient } from '@/lib/supabase/server' +import { revalidatePath } from 'next/cache' +import type { EndCustomer } from '@/lib/types' + +// ─── Typen ─────────────────────────────────────────────────────────────────── + +export type EndCustomerFormData = { + company_name: string + vat_id?: string + first_name?: string + last_name?: string + street?: string + zip?: string + city?: string +} + +// ─── Lesen ─────────────────────────────────────────────────────────────────── + +/** + * Holt alle (nicht anonymisierten) Endkunden des eingeloggten Partners. + * RLS stellt sicher, dass nur eigene Kunden zurückgegeben werden. + */ +export async function getEndCustomers(): Promise { + const supabase = await createClient() + const { data, error } = await supabase + .from('end_customers') + .select('*') + .order('company_name', { ascending: true }) + + if (error) throw error + return data as EndCustomer[] +} + +/** + * Holt einen einzelnen Endkunden (Eigentümerprüfung via RLS). + */ +export async function getEndCustomer(id: string): Promise { + const supabase = await createClient() + const { data, error } = await supabase + .from('end_customers') + .select('*') + .eq('id', id) + .single() + + if (error) return null + return data as EndCustomer +} + +// ─── Erstellen ──────────────────────────────────────────────────────────────── + +/** + * Legt einen neuen Endkunden für den eingeloggten Partner an. + * partner_id wird serverseitig aus der Session gesetzt (kein Client-Trust). + */ +export async function createEndCustomer(formData: EndCustomerFormData): Promise { + const supabase = await createClient() + + const { data: { user } } = await supabase.auth.getUser() + if (!user) throw new Error('Not authenticated') + + const { data, error } = await supabase + .from('end_customers') + .insert([{ + partner_id: user.id, + company_name: formData.company_name, + vat_id: formData.vat_id || null, + first_name: formData.first_name || null, + last_name: formData.last_name || null, + street: formData.street || null, + zip: formData.zip || null, + city: formData.city || null, + }]) + .select() + .single() + + if (error) throw error + + revalidatePath('/my-customers') + return data as EndCustomer +} + +// ─── Aktualisieren ──────────────────────────────────────────────────────────── + +/** + * Aktualisiert Adressdaten eines Endkunden. + * RLS verhindert Zugriff auf fremde Kunden. + */ +export async function updateEndCustomer(id: string, formData: EndCustomerFormData): Promise { + const supabase = await createClient() + + const { error } = await supabase + .from('end_customers') + .update({ + company_name: formData.company_name, + vat_id: formData.vat_id || null, + first_name: formData.first_name || null, + last_name: formData.last_name || null, + street: formData.street || null, + zip: formData.zip || null, + city: formData.city || null, + }) + .eq('id', id) + + if (error) throw error + + revalidatePath('/my-customers') + revalidatePath(`/my-customers/${id}`) +} + +// ─── DSGVO: Anonymisieren ───────────────────────────────────────────────────── + +/** + * Anonymisiert einen Endkunden gemäß DSGVO „Recht auf Vergessenwerden". + * + * - Überschreibt alle personenbezogenen Felder mit [GELÖSCHT] + * - Setzt is_anonymized = true (verhindert weitere Bearbeitung) + * - LÖSCHT NICHT die Zeile (FK in orders bleibt erhalten) + * - Bestell-Snapshots in orders.customer_data werden NICHT verändert + * (steuerrechtliche Aufbewahrungspflicht) + */ +export async function anonymizeEndCustomer(id: string): Promise { + const supabase = await createClient() + + const { error } = await supabase + .from('end_customers') + .update({ + company_name: '[GELÖSCHT]', + vat_id: null, + first_name: '[GELÖSCHT]', + last_name: null, + street: null, + zip: null, + city: null, + is_anonymized: true, + }) + .eq('id', id) + .eq('is_anonymized', false) // Keine doppelte Anonymisierung + + if (error) throw error + + revalidatePath('/my-customers') + revalidatePath(`/my-customers/${id}`) +} diff --git a/shop/lib/actions/orders.ts b/shop/lib/actions/orders.ts index a86b174..c4d72b4 100644 --- a/shop/lib/actions/orders.ts +++ b/shop/lib/actions/orders.ts @@ -6,7 +6,7 @@ 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, Order, Product, Profile, WizardSelections } from '@/lib/types' +import type { Category, EndCustomer, Order, Product, Profile, WizardSelections } from '@/lib/types' // ─── Hilfsfunktionen ───────────────────────────────────────────────────────── @@ -51,15 +51,18 @@ export async function submitOrder(params: { products: Product[] categories: Category[] customerProfile: Partial + endCustomerId?: string | null + endCustomer?: EndCustomer | null }): Promise { - const { selections, products, categories, customerProfile } = params + const { selections, products, categories, customerProfile, endCustomerId, endCustomer } = params const supabase = await createClient() const { data: { user } } = await supabase.auth.getUser() if (!user) throw new Error('Not authenticated') // 1. Snapshots aufbauen - const customerSnapshot = buildCustomerSnapshot(customerProfile) + // Wenn Endkunde vorhanden: dessen Daten einfrieren; sonst Partner-Profil (Fallback) + const customerSnapshot = buildCustomerSnapshot(customerProfile, endCustomer ?? null) const orderSnapshot = buildOrderSnapshot(selections, products, categories) // 2. Idempotenz-Guard: Prüfen ob identische Bestellung in den letzten 30s existiert @@ -102,6 +105,7 @@ export async function submitOrder(params: { 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, diff --git a/shop/lib/license-transform.ts b/shop/lib/license-transform.ts index bd3106a..3205246 100644 --- a/shop/lib/license-transform.ts +++ b/shop/lib/license-transform.ts @@ -9,6 +9,7 @@ import type { Category, CategorySelection, CustomerSnapshot, + EndCustomer, LicenseBundle, LicenseOption, OrderItem, @@ -37,10 +38,28 @@ export function slugify(name: string): string { /** * Baut einen validen CustomerSnapshot aus einem Profil oder Formulardaten. - * Fehlende Pflichtfelder werden mit leerem String aufgefüllt (kein throw – - * Validierung findet im Wizard-UI statt). + * + * Wenn ein Endkunde übergeben wird, werden DESSEN Daten eingefroren (für die Rechnung). + * Ohne Endkunden-Übergabe: Partner-Profil als Fallback (Abwärtskompatibilität). + * Fehlende Pflichtfelder werden mit leerem String aufgefüllt (Validierung im Wizard). */ -export function buildCustomerSnapshot(profile: Partial): CustomerSnapshot { +export function buildCustomerSnapshot( + profile: Partial, + endCustomer?: EndCustomer | null +): CustomerSnapshot { + if (endCustomer) { + return { + schema_version: 1, + end_customer_id: endCustomer.id, + company_name: endCustomer.company_name ?? '', + vat_id: endCustomer.vat_id ?? '', + first_name: endCustomer.first_name ?? '', + last_name: endCustomer.last_name ?? '', + address: endCustomer.street ?? '', + zip_code: endCustomer.zip ?? '', + city: endCustomer.city ?? '', + } + } return { schema_version: 1, company_name: profile.company_name ?? '', diff --git a/shop/lib/types.ts b/shop/lib/types.ts index 892b9ee..cc1f7db 100644 --- a/shop/lib/types.ts +++ b/shop/lib/types.ts @@ -50,6 +50,22 @@ export type Profile = { updated_at: string } +// ─── Endkunde (verwaltet vom Partner) ──────────────────────────────────────── + +export type EndCustomer = { + id: string + partner_id: string + company_name: string + vat_id: string | null + first_name: string | null + last_name: string | null + street: string | null + zip: string | null + city: string | null + is_anonymized: boolean + created_at: string +} + // ─── Wizard-Zustand (flüchtig, im RAM) ─────────────────────────────────────── export type CategorySelection = { @@ -68,6 +84,8 @@ export type WizardSelections = Record */ export type CustomerSnapshot = { schema_version: 1 + /** ID des Endkunden zum Zeitpunkt der Bestellung (für Audit-Trail) */ + end_customer_id?: string company_name: string vat_id: string first_name: string @@ -115,6 +133,7 @@ export type Order = { user_id: string | null order_number: string order_hash: string | null + end_customer_id: string | null customer_data: CustomerSnapshot order_data: OrderSnapshot total_price: number diff --git a/shop/supabase/migrations/20260617_end_customers.sql b/shop/supabase/migrations/20260617_end_customers.sql new file mode 100644 index 0000000..9c959c1 --- /dev/null +++ b/shop/supabase/migrations/20260617_end_customers.sql @@ -0,0 +1,27 @@ +-- Migration: end_customers table + RLS + orders.end_customer_id + +CREATE TABLE IF NOT EXISTS end_customers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + partner_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + company_name TEXT NOT NULL, + vat_id TEXT, + first_name TEXT, + last_name TEXT, + street TEXT, + zip TEXT, + city TEXT, + is_anonymized BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +ALTER TABLE end_customers ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS "Partner sehen nur eigene Endkunden" ON end_customers; + +CREATE POLICY "Partner sehen nur eigene Endkunden" +ON end_customers FOR ALL +USING (auth.uid() = partner_id) +WITH CHECK (auth.uid() = partner_id); + +ALTER TABLE orders + ADD COLUMN IF NOT EXISTS end_customer_id UUID REFERENCES end_customers(id) ON DELETE SET NULL;