feat(crm): end_customers table, RLS, wizard customer selector, /my-customers CRUD, GDPR anonymization
This commit is contained in:
145
shop/lib/actions/end-customers.ts
Normal file
145
shop/lib/actions/end-customers.ts
Normal 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}`)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user