Files
Wabenhain/prisma/schema.prisma
T
Nick SzaunigandClaude Opus 5 9dd71ffb9a
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m8s
feat: Packstation ueber DHL-Standortsuche als Lieferziel waehlbar
Im Checkout laesst sich unter "Versand" jetzt zwischen der eigenen
Anschrift und einer DHL-Packstation waehlen. Postleitzahl eingeben,
suchen, Packstation aus der Trefferliste anklicken, DHL-Postnummer
eintragen — die Auswahl wird an der Bestellung gespeichert.

Aufbau:

- lib/dhl-locations.ts: Client fuer die DHL Location Finder Unified API
  (GET /find-by-address). Bewusst getrennt von lib/dhl.ts — das ist ein
  anderer DHL-Dienst mit anderer Authentifizierung: nur der API-Key im
  Header, kein OAuth und kein Geschaeftskundenvertrag. Die
  Packstation-Auswahl funktioniert damit auch, solange die
  Versandmarken-Erstellung noch nicht eingerichtet ist.
- api/shop/packstationen: serverseitiger Zwischenschritt, damit der
  DHL-API-Key nicht ins Browser-Bundle geraet. Streng rate-limitiert
  (15/Minute statt 60 wie die Produktsuche) und mit 15-Minuten-Cache:
  dahinter steht ein FREMDES Kontingent von 500 Aufrufen pro Tag, das ein
  ungebremstes Suchfeld an einem Nachmittag aufbrauchen koennte.
- Order/Address: packstationNumber + postNumber (Migration
  20260915222938_add_packstation_delivery, beide nullable).
- checkoutSchema: Postnummer als 6-10 Ziffern, plus zwei refine-Regeln —
  ohne Postnummer keine Packstation (DHL verlangt sie zwingend), und eine
  Packstation nur bei Versand, nicht bei Abholung.

Zwei Entscheidungen, die Folgearbeit ersparen:

- Bei Packstation-Auswahl tragen street/zip/city zusaetzlich die lesbare
  Anschrift ("Packstation 145", PLZ, Ort). Dadurch zeigen Rechnung,
  Bestaetigungsmail, Sendungsverfolgung und Admin-Ansicht ohne jede
  Aenderung etwas Sinnvolles; packstationNumber/postNumber tragen daneben
  das, was DHL technisch braucht.
- lib/dhl.ts baut daraus einen Locker-Empfaenger statt einer
  Hausanschrift. DHL kennt fuer den Empfaenger zwei sich ausschliessende
  Formen; eine Packstation als Strassentext zu schicken ergaebe eine Marke
  auf eine Anschrift, die es nicht gibt.

Die Option erscheint nur, wenn DHL_API_KEY gesetzt ist — sonst wuerde
Kundschaft in eine Suche laufen, die immer fehlschlaegt.

Geprueft: sechs Tests fuer das Umsetzen der DHL-Antwort (die
Packstation-Nummer steht in location.keywordId, NICHT in
location.ids[0].locationId — letzteres ist ein Systemcode, mit dem weder
Kundschaft noch DHL etwas anfangen kann). Dazu am laufenden
Produktions-Build: ohne Key liefert der Endpunkt 503 und der Checkout
zeigt die Option gar nicht; mit Key 400 bei ungueltiger PLZ und ein echter
Aufruf an api.dhl.com, der mit 401 quittiert wird — URL, Header und
Anfrageformat stimmen also. Im Browser durchgeklickt: Umschalten blendet
das Strassenfeld aus und Postnummer samt Suche ein, die Suche ruft die
Route und zeigt die Fehlermeldung an.

Feldnamen beider DHL-APIs vorab gegen developer.dhl.com geprueft. Ein
Lauf gegen einen echten Schluessel steht noch aus.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-16 00:37:00 +02:00

687 lines
23 KiB
Plaintext

// Wabenhain — Datenmodell
// Alle Geldbetraege werden als Cent-Integer gespeichert, um Rundungsfehler zu vermeiden.
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
// "provider" wird NICHT hier per Hand gepflegt: Prisma erlaubt kein env() an
// dieser Stelle, daher schreibt scripts/prisma-provider.mjs diese Zeile vor
// jedem generate/db push/migrate anhand von DATABASE_PROVIDER ("postgresql"
// [Standard] oder "mysql") neu. Siehe README, Abschnitt "Datenbank-Provider
// wechseln".
datasource db {
provider = "postgresql"
}
// ---------------------------------------------------------------------------
// Kunden
// ---------------------------------------------------------------------------
enum CustomerRole {
KUNDE
ADMIN
}
model Customer {
id String @id @default(cuid())
email String @unique
passwordHash String
firstName String
lastName String
phone String?
role CustomerRole @default(KUNDE)
/// Wird bei sicherheitsrelevanten Ereignissen (Passwort-Reset, "alle
/// Geraete abmelden", 2FA an/aus) hochgezaehlt. Im JWT eingebettet — bei
/// Abweichung von diesem Wert gilt das Token als widerrufen, obwohl es
/// technisch noch nicht abgelaufen ist.
sessionVersion Int @default(0)
/// Fuer die Konto-Sperre nach wiederholten Fehlversuchen (zusaetzlich zum
/// IP-basierten Rate-Limit — persistent und ueberlebt einen Neustart/
/// mehrere Server-Instanzen, anders als der In-Memory-Rate-Limiter).
failedLoginAttempts Int @default(0)
lockedUntil DateTime?
/// Base32-TOTP-Secret, erst gesetzt wenn 2FA aktiv bestaetigt wurde.
twoFactorSecret String?
twoFactorEnabled Boolean @default(false)
/// Zeitschritt (Unix-Zeit / 30s) des zuletzt beim Login akzeptierten
/// TOTP-Codes — verhindert, dass ein abgefangener/mitgelesener Code im
/// selben oder einem frueheren Zeitfenster ein zweites Mal funktioniert.
lastTotpStep Int?
/// Von einem Admin gesperrtes Konto (Benutzerverwaltung) — unabhaengig von
/// der automatischen Fehlversuchs-Sperre in lockedUntil. Verhindert den
/// Login bis ein Admin das Konto wieder entsperrt.
blockedAt DateTime?
/// Gesetzt, sobald die E-Mail-Adresse per Bestaetigungslink oder durch
/// erfolgreiches Setzen eines Passworts ueber einen zugesandten Reset-Link
/// nachweislich unter der Kontrolle des Kontoinhabers ist. Registrierte
/// Kund:innen benoetigen dies fuer den Checkout (siehe /api/checkout).
emailVerifiedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
orders Order[]
addresses Address[]
newsletterSubscriber NewsletterSubscriber?
passwordResetTokens PasswordResetToken[]
emailVerificationTokens EmailVerificationToken[]
twoFactorBackupCodes TwoFactorBackupCode[]
reviews Review[]
wishlistItems WishlistItem[]
auditLogs AuditLog[]
@@map("customers")
}
model EmailVerificationToken {
id String @id @default(cuid())
customerId String
/// SHA-256-Hash des per E-Mail zugesandten Tokens — wie beim
/// Passwort-Reset wird nie der Klartext gespeichert.
tokenHash String @unique
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
@@map("email_verification_tokens")
}
model TwoFactorBackupCode {
id String @id @default(cuid())
customerId String
/// SHA-256-Hash des Codes — der Klartext wird dem Kunden nur einmalig bei
/// der Einrichtung angezeigt und nie wieder gespeichert.
codeHash String @unique
usedAt DateTime?
createdAt DateTime @default(now())
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
@@map("two_factor_backup_codes")
}
model Address {
id String @id @default(cuid())
customerId String
label String @default("Zuhause")
firstName String
lastName String
street String
zip String
city String
country String @default("DE")
/// Lieferung an eine DHL-Packstation statt an eine Hausanschrift. Enthaelt
/// die Packstation-Nummer, wie sie auf dem Geraet steht (z.B. "145") —
/// gefunden ueber die DHL-Standortsuche (lib/dhl-locations.ts).
/// Ist das Feld gesetzt, tragen street/zip/city zusaetzlich die lesbare
/// Anschrift der Packstation, damit Rechnung, E-Mails und Admin-Ansicht
/// unveraendert etwas Sinnvolles anzeigen.
packstationNumber String?
/// DHL-Postnummer der empfangenden Person (6-10 Ziffern). Bei Lieferung an
/// eine Packstation von DHL zwingend verlangt — ohne sie laesst sich keine
/// Versandmarke erstellen (siehe lib/dhl.ts).
postNumber String?
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
/// Adressliste und Standardadresse-Update im Kundenkonto filtern nach customerId.
@@index([customerId])
@@map("addresses")
}
// ---------------------------------------------------------------------------
// Ernten, Chargen, Lagerbestand, Produkte
// ---------------------------------------------------------------------------
model Harvest {
id String @id @default(cuid())
year Int
/// z.B. "Fruehtracht", "Sommertracht"
type String
slug String @unique
harvestDate DateTime
location String @default("Altenmuenster")
description String
/// Liste von Bild-URLs als JSON-Array (String[] ist nicht Provider-uebergreifend:
/// MySQL kennt keine nativen Skalar-Arrays) — siehe lib/json-array.ts fuer den
/// Normalisierungs-Helfer beim Lesen.
images Json @default("[]")
/// Steuert Sichtbarkeit auf den oeffentlichen Shop-/Ernten-Seiten.
/// Default true, damit alle VOR dieser Spalte angelegten Ernten sofort
/// sichtbar bleiben. Neu aus ApiCore synchronisierte Ernten werden explizit
/// mit false angelegt (siehe admin/actions/apicore-sync.ts) — vorbefuellt,
/// aber erst nach Admin-Review ueber "Veroeffentlichen" oeffentlich sichtbar.
isPublished Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
batches Batch[]
products Product[]
@@unique([year, type])
@@map("harvests")
}
model Batch {
id String @id @default(cuid())
/// z.B. FH-26-01
code String @unique
harvestId String
fillDate DateTime
bestBeforeDate DateTime
glassCount Int
remainingGlasses Int
/// Liste von Bild-URLs als JSON-Array (String[] ist nicht Provider-uebergreifend:
/// MySQL kennt keine nativen Skalar-Arrays) — siehe lib/json-array.ts fuer den
/// Normalisierungs-Helfer beim Lesen. Eigene Fotos dieser konkreten Charge
/// (z.B. Etikett/Abfuellung), unabhaengig von Harvest.images.
images Json @default("[]")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
harvest Harvest @relation(fields: [harvestId], references: [id], onDelete: Restrict)
products Product[]
orderItems OrderItem[]
@@map("batches")
}
enum ProductCategory {
HONIG
KERZEN
PROPOLIS
}
model Product {
id String @id @default(cuid())
slug String @unique
name String
category ProductCategory @default(HONIG)
/// Nur fuer Honig-Produkte gesetzt (Kerzen/Propolis sind keiner Ernte/Charge zugeordnet)
harvestId String?
batchId String?
description String
/// Geschmacksprofil-Tags als JSON-Array, z.B. ["mild", "floral", "feincremig"]
/// — String[] ist nicht Provider-uebergreifend (MySQL kennt keine nativen
/// Skalar-Arrays), siehe lib/json-array.ts fuer den Normalisierungs-Helfer.
tasteTags Json @default("[]")
/// Intensitaet 1-5 fuer die visuelle Geschmacksskala
intensityMild Int @default(3)
intensityFloral Int @default(3)
intensityKraeftig Int @default(3)
consistency String @default("feincremig")
weightGrams Int
priceCents Int
pricePerKgCents Int
currency String @default("EUR")
/// Liste von Bild-URLs als JSON-Array, siehe tasteTags-Kommentar oben.
images Json @default("[]")
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
harvest Harvest? @relation(fields: [harvestId], references: [id], onDelete: Restrict)
batch Batch? @relation(fields: [batchId], references: [id], onDelete: Restrict)
inventory Inventory?
orderItems OrderItem[]
reviews Review[]
wishlistItems WishlistItem[]
stockNotifications StockNotification[]
@@map("products")
}
model StockNotification {
id String @id @default(cuid())
productId String
email String
/// Spracheinstellung bei der Anmeldung — die Benachrichtigungs-E-Mail wird
/// spaeter asynchron (beim naechsten Bestandsupdate) versendet, ohne
/// Cookie-Kontext der urspruenglichen Anfrage.
locale String @default("de")
createdAt DateTime @default(now())
/// Gesetzt, sobald die Benachrichtigungs-E-Mail versendet wurde — verhindert
/// Mehrfachversand, falls der Bestand mehrfach zwischen 0 und >0 wechselt,
/// ohne dass sich die Kundin/der Kunde neu angemeldet hat.
notifiedAt DateTime?
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
/// Eine Anmeldung pro E-Mail-Adresse und Produkt.
@@unique([productId, email])
@@map("stock_notifications")
}
model WishlistItem {
id String @id @default(cuid())
customerId String
productId String
createdAt DateTime @default(now())
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
/// Ein Produkt kann pro Kunde nur einmal auf der Wunschliste stehen.
@@unique([customerId, productId])
@@map("wishlist_items")
}
model Review {
id String @id @default(cuid())
productId String
customerId String
/// 1-5
rating Int
comment String?
/// Nur freigeschaltete Bewertungen werden auf der Produktseite angezeigt —
/// einfache Missbrauchskontrolle durch den Admin vor Veroeffentlichung.
isApproved Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
/// Ein Kunde kann ein Produkt nur einmal bewerten.
@@unique([productId, customerId])
@@map("reviews")
}
model Inventory {
id String @id @default(cuid())
productId String @unique
quantity Int @default(0)
updatedAt DateTime @updatedAt
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@map("inventory")
}
// ---------------------------------------------------------------------------
// Bestellungen
// ---------------------------------------------------------------------------
enum OrderStatus {
NEU
BEZAHLT
IN_VORBEREITUNG
VERSANDBEREIT
VERSENDET
ABHOLBEREIT
ABGEHOLT
STORNIERT
}
enum FulfillmentMethod {
VERSAND
ABHOLUNG
}
model Order {
id String @id @default(cuid())
orderNumber String @unique
customerId String?
status OrderStatus @default(NEU)
fulfillmentMethod FulfillmentMethod @default(VERSAND)
/// Spracheinstellung beim Checkout ("de"/"en") — wird fuer spaeter
/// asynchron versendete E-Mails (Stripe-Webhook) benoetigt, da dort kein
/// Cookie-/Request-Kontext mehr vorhanden ist, um die Sprache erneut zu bestimmen.
locale String @default("de")
// Kontakt- und Versanddaten werden am Bestellzeitpunkt gespeichert,
// damit Gastbestellungen unabhaengig von einem Customer-Datensatz funktionieren.
email String
firstName String
lastName String
phone String?
street String
zip String
city String
country String @default("DE")
/// Lieferung an eine DHL-Packstation statt an eine Hausanschrift. Enthaelt
/// die Packstation-Nummer, wie sie auf dem Geraet steht (z.B. "145") —
/// gefunden ueber die DHL-Standortsuche (lib/dhl-locations.ts).
/// Ist das Feld gesetzt, tragen street/zip/city zusaetzlich die lesbare
/// Anschrift der Packstation, damit Rechnung, E-Mails und Admin-Ansicht
/// unveraendert etwas Sinnvolles anzeigen.
packstationNumber String?
/// DHL-Postnummer der empfangenden Person (6-10 Ziffern). Bei Lieferung an
/// eine Packstation von DHL zwingend verlangt — ohne sie laesst sich keine
/// Versandmarke erstellen (siehe lib/dhl.ts).
postNumber String?
subtotalCents Int
shippingCents Int @default(0)
discountCents Int @default(0)
totalCents Int
currency String @default("EUR")
discountCodeId String?
stripeCheckoutSessionId String?
notes String?
/// Optionale persoenliche Nachricht bei Geschenkbestellungen — wird beim
/// Verpacken von Hand beigelegt (siehe Admin-Bestelldetail).
giftMessage String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
discountCode DiscountCode? @relation(fields: [discountCodeId], references: [id], onDelete: SetNull)
items OrderItem[]
payment Payment?
shipment Shipment?
pickup Pickup?
invoice Invoice?
returnRequest ReturnRequest?
/// Bedient sowohl "Meine Bestellungen" (Filter nach customerId, sortiert nach
/// createdAt) als auch den reinen customerId-Filter beim Datenexport.
@@index([customerId, createdAt])
/// Statusfilter im Admin-Bestellungen-Bereich (exakte Gleichheit).
@@index([status])
/// Datumsbereichs-Filter (Tages-/Monatsumsatz, Umsatztrend im Admin-Dashboard)
/// sowie Standard-Sortierung in Bestelllisten ohne weiteren Filter.
@@index([createdAt])
/// Nutzungszaehlung eines Rabattcodes beim Checkout (Order.count nach discountCodeId).
@@index([discountCodeId])
@@map("orders")
}
enum ReturnStatus {
ANGEFRAGT
ANGENOMMEN
ABGELEHNT
ERSTATTET
}
/// Eine Rueckgabeanfrage pro Bestellung (orderId @unique) — deckt den Fall
/// "gesamte Bestellung zurueckschicken" ab, keine positionsweise Teil-Retoure.
model ReturnRequest {
id String @id @default(cuid())
orderId String @unique
reason String
status ReturnStatus @default(ANGEFRAGT)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@map("return_requests")
}
model OrderItem {
id String @id @default(cuid())
orderId String
productId String
/// Nur gesetzt, wenn das Produkt einer Charge zugeordnet ist (Honig)
batchId String?
// Snapshot der Produktdaten zum Bestellzeitpunkt (Preisaenderungen duerfen
// bestehende Bestellungen nicht rueckwirkend veraendern).
productName String
unitPriceCents Int
quantity Int
weightGrams Int
totalCents Int
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
batch Batch? @relation(fields: [batchId], references: [id], onDelete: Restrict)
/// Wird bei jedem Laden der Positionen einer Bestellung (Konto, Admin,
/// Rechnung) ueber die order.items-Relation abgefragt.
@@index([orderId])
@@map("order_items")
}
enum PaymentStatus {
PENDING
SUCCEEDED
FAILED
REFUNDED
}
model Payment {
id String @id @default(cuid())
orderId String @unique
provider String @default("stripe")
stripePaymentIntentId String?
status PaymentStatus @default(PENDING)
amountCents Int
currency String @default("EUR")
paidAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@map("payments")
}
model Shipment {
id String @id @default(cuid())
orderId String @unique
carrier String?
trackingNumber String?
shippedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@map("shipments")
}
model Pickup {
id String @id @default(cuid())
orderId String @unique
location String @default("Altenmuenster")
readyAt DateTime?
pickedUpAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@map("pickups")
}
model Invoice {
id String @id @default(cuid())
orderId String @unique
invoiceNumber String @unique
issuedAt DateTime @default(now())
totalCents Int
pdfUrl String?
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@map("invoices")
}
// ---------------------------------------------------------------------------
// Marketing / Kontakt
// ---------------------------------------------------------------------------
model NewsletterSubscriber {
id String @id @default(cuid())
email String @unique
customerId String? @unique
confirmed Boolean @default(false)
/// Dient sowohl der Bestaetigung (Double-Opt-In) als auch als dauerhafter
/// Abmelde-Link fuer Abonnenten ohne Kundenkonto.
confirmationToken String? @unique
subscribedAt DateTime @default(now())
unsubscribedAt DateTime?
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
@@map("newsletter_subscribers")
}
model PasswordResetToken {
id String @id @default(cuid())
customerId String
/// SHA-256-Hash des dem Kunden per E-Mail zugesandten Tokens — wie bei
/// Passwoertern wird nie der Klartext gespeichert.
tokenHash String @unique
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
@@map("password_reset_tokens")
}
enum BusinessPackage {
KLASSIK
GESCHENK
INDIVIDUELL
}
enum InquiryStatus {
NEU
IN_BEARBEITUNG
ABGESCHLOSSEN
ABGELEHNT
}
// ---------------------------------------------------------------------------
// Versand
// ---------------------------------------------------------------------------
model ShippingTier {
id String @id @default(cuid())
/// Menge (Stueck ueber alle Artikel im Warenkorb zusammengezaehlt) ab der
/// diese Staffel gilt.
minQuantity Int
/// Bis einschliesslich dieser Menge — null bedeutet "und mehr" (offene,
/// oberste Staffel).
maxQuantity Int?
priceCents Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("shipping_tiers")
}
// ---------------------------------------------------------------------------
// Rabattcodes
// ---------------------------------------------------------------------------
enum DiscountType {
PERCENT
FIXED
}
model DiscountCode {
id String @id @default(cuid())
code String @unique
type DiscountType
/// PERCENT: Prozentpunkte (1-100). FIXED: Cent-Betrag.
value Int
/// Mindestbestellwert (Cent) fuer die Anwendung, 0 = keine Mindestsumme.
minOrderCents Int @default(0)
/// Mindestmenge (Summe aller Artikel im Warenkorb) fuer die Anwendung —
/// null = keine Mengenschwelle. Gesetzt macht aus dem Code einen
/// Mengenrabatt: Checkout wendet ihn automatisch an, sobald die Schwelle
/// erreicht ist (siehe api/checkout/route.ts), ohne dass der Code
/// eingegeben werden muss.
minQuantity Int?
/// Nutzungslimit insgesamt ueber alle Kunden — null = unbegrenzt.
maxUses Int?
usedCount Int @default(0)
isActive Boolean @default(true)
validFrom DateTime?
validUntil DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
orders Order[]
@@map("discount_codes")
}
// ---------------------------------------------------------------------------
// Admin-Audit-Log
// ---------------------------------------------------------------------------
/// Protokolliert sicherheitsrelevante Admin-Mutationen (wer hat wann was
/// geaendert) — wichtig bei mehreren Admin-Konten, um Aenderungen
/// nachvollziehen zu koennen. adminEmail ist ein Snapshot, damit der
/// Eintrag auch nach Loeschung des Admin-Kontos lesbar bleibt.
model AuditLog {
id String @id @default(cuid())
adminId String?
adminEmail String
/// z.B. "product.update", "order.status_change", "discount_code.delete"
action String
entityType String
entityId String?
detail String?
createdAt DateTime @default(now())
admin Customer? @relation(fields: [adminId], references: [id], onDelete: SetNull)
/// Admin-Audit-Log-Seite sortiert unbegrenzt wachsende Tabelle nach
/// createdAt absteigend (ORDER BY + LIMIT ohne weiteren Filter).
@@index([createdAt])
@@map("audit_logs")
}
/// Admin-editierbare Website-Inhalte (Firmendaten, Homepage-Texte,
/// Rechtstexte) als generisches Key-Value-Overlay ueber den statischen
/// i18n-Dictionaries — siehe lib/content-registry.ts fuer die Liste aller
/// editierbaren Schluessel und deren Fallback-Werte. Fehlt eine Zeile (kein
/// Admin-Override gepflegt), liefert getContentValue() den urspruenglichen
/// Dictionary-/site-config-Text — die Seite bricht also nie, auch bevor die
/// erste Bearbeitung stattgefunden hat.
model SiteContent {
/// z.B. "company.email", "home.philosophy.items", "legal.impressum"
key String @id
/// Bei nicht lokalisierten Feldern (Firmendaten) einziger Speicherort;
/// bei lokalisierten Feldern (Homepage/Rechtstexte) die deutsche Fassung.
valueDe String @db.Text
/// Nur bei lokalisierten Feldern gesetzt — null bei Firmendaten.
valueEn String? @db.Text
updatedAt DateTime @updatedAt
@@map("site_content")
}
model BusinessInquiry {
id String @id @default(cuid())
companyName String
contactPerson String
email String
phone String?
desiredQuantity Int
desiredDeliveryDate DateTime?
package BusinessPackage @default(KLASSIK)
message String?
status InquiryStatus @default(NEU)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("business_inquiries")
}