feat(crm): end_customers table, RLS, wizard customer selector, /my-customers CRUD, GDPR anonymization

This commit is contained in:
DanielS
2026-06-17 01:57:17 +02:00
parent d96be1ace9
commit fb124b549c
12 changed files with 855 additions and 51 deletions

View File

@@ -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<EndCustomer[]> {
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<EndCustomer | null> {
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<EndCustomer> {
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<void> {
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<void> {
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}`)
}

View File

@@ -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<Profile>
endCustomerId?: string | null
endCustomer?: EndCustomer | null
}): Promise<Order> {
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,

View File

@@ -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<Profile>): CustomerSnapshot {
export function buildCustomerSnapshot(
profile: Partial<Profile>,
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 ?? '',

View File

@@ -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<string, CategorySelection>
*/
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