Files
Wabenhain/prisma/schema.prisma
T
Nick SzaunigandClaude Opus 5.5 dcfad4e011 feat(legal): electronic withdrawal function and remaining compliance fixes
Withdrawal function (§ 356a BGB, mandatory since 19 June 2026):
- /vertrag-widerrufen: two-step flow "Vertrag widerrufen" ->
  "Widerruf bestätigen", no login, only name, order number and e-mail
  required. Prominent footer button on every page, prefilled link per
  order in the customer account.
- /api/widerruf stores every declaration (matched to the order only if
  order number and e-mail fit), mails a receipt with the declaration and
  the exact time of receipt, notifies ADMIN_EMAIL. Same response for
  matching and non-matching orders (no enumeration), origin check and
  rate limit instead of a CAPTCHA that could block a statutory right.
  User input is HTML-escaped and stripped of line breaks in headers.
- /admin/widerrufe lists declarations, open ones first, with an
  open-count badge and "mark as done".
- Withdrawal notice gains the statutory sentence on the online function.

Other fixes:
- Newsletter stores confirmedAt as proof of consent (Art. 7(1) GDPR).
- "Recently viewed" is only persisted with consent (§ 25(1) TDDDG);
  choosing "essential only" or withdrawing consent deletes it. The
  banner no longer promises audience-measurement cookies that do not
  exist.
- Admin rejects an invalid company e-mail; an obfuscated stored value
  falls back to the configured address so the legal notice stays valid.
- Privacy policy: Microsoft 365 as mail processor, log retention of at
  most 14 days, withdrawal-function processing, consent timestamps.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-26 11:14:02 +02:00

840 lines
31 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)
/// ApiCore-ID der ABFUELLUNG (BottlingBatch), aus der dieses Produkt
/// synchronisiert wurde; null bei manuell angelegten Produkten.
///
/// Warum die Abfuellung und nicht das ApiCore-Produkt: dort ist "Waldhonig
/// 500 g" EIN Katalogartikel, an dem jedes Jahr eine neue Abfuellung haengt.
/// In Wabenhain gehoert ein Produkt dagegen zu genau einer Charge, weil hier
/// jeder Jahrgang eigenen Preis, eigenen Verkaufstext, eigenes
/// Geschmacksprofil und eigene Bewertungen hat. Ein ApiCore-Artikel wird
/// deshalb zu MEHREREN Wabenhain-Produkten, eines je Jahrgang.
///
/// Dient dem Sync als Idempotenz-Schluessel: daran erkennt ein erneuter
/// Lauf, dass diese Abfuellung schon importiert wurde. Bewusst NICHT ueber
/// den Slug geloest — der ist die oeffentliche Adresse und soll lesbar
/// bleiben ("waldhonig-2026"), darf sich also nicht nach einer fremden ID
/// richten.
apicoreBottlingId String? @unique
/// 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
/// Umsatzsteuersatz dieses Produkts in ganzen Prozent — uebersteuert den
/// Satz, der sich sonst aus der Kategorie ergibt (siehe lib/vat.ts).
///
/// Leer lassen ist der Normalfall: dann gilt der Satz der Kategorie, und
/// eine spaetere Aenderung der gesetzlichen Saetze wirkt ueber die
/// Konfiguration auf alle Produkte zugleich. Ein Wert hier ist fuer die
/// Ausnahmen gedacht, die sich nicht aus der Kategorie ergeben — etwa ein
/// Geschenkset, das anders einzuordnen ist als seine Kategorie vermuten
/// liesse.
vatRatePercent Int?
currency String @default("EUR")
/// Liste von Bild-URLs als JSON-Array, siehe tasteTags-Kommentar oben.
images Json @default("[]")
isActive Boolean @default(true)
/// Archiv (siehe lib/archive.ts): gesetzt = im Admin-Tab "Archiv" statt in
/// der aktiven Liste. Wird beim Oeffnen der Admin-Liste automatisch nach
/// den Regeln dort gesetzt und wieder geloescht; archivedManually
/// kennzeichnet einen Klick auf "Archivieren", den die Automatik nicht
/// rueckgaengig machen darf.
archivedAt DateTime?
archivedManually Boolean @default(false)
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[]
@@index([archivedAt])
@@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?
/// Nicht erratbares Kennzeichen, das ausschliesslich in der cancel_url der
/// Stripe-Sitzung steht. Bricht die Kundschaft dort ab, landet sie auf
/// /checkout/cancel?ref=<token>; erst dieser Token weist nach, dass der
/// Abbruch zu DIESER Bestellung gehoert, und gibt den reservierten Bestand
/// sofort wieder frei. Die Bestellnummer taugt dafuer nicht: sie steht in
/// jeder Bestaetigungsmail und auf jeder Rechnung, mit ihr koennte also
/// jemand die noch offene Zahlung einer fremden Person abbrechen.
cancelToken String? @unique
notes String?
/// Optionale persoenliche Nachricht bei Geschenkbestellungen — wird beim
/// Verpacken von Hand beigelegt (siehe Admin-Bestelldetail).
giftMessage String?
/// Archiv (siehe lib/archive.ts): gesetzt = im Admin-Tab "Archiv" statt in
/// der aktiven Liste. Wird beim Oeffnen der Admin-Liste automatisch nach
/// den Regeln dort gesetzt und wieder geloescht; archivedManually
/// kennzeichnet einen Klick auf "Archivieren", den die Automatik nicht
/// rueckgaengig machen darf.
archivedAt DateTime?
archivedManually Boolean @default(false)
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?
withdrawalDeclarations WithdrawalDeclaration[]
/// 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])
/// Tab-Filter Aktiv/Archiv im Admin-Bereich.
@@index([archivedAt])
@@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)
/// Archiv (siehe lib/archive.ts): gesetzt = im Admin-Tab "Archiv" statt in
/// der aktiven Liste. Wird beim Oeffnen der Admin-Liste automatisch nach
/// den Regeln dort gesetzt und wieder geloescht; archivedManually
/// kennzeichnet einen Klick auf "Archivieren", den die Automatik nicht
/// rueckgaengig machen darf.
archivedAt DateTime?
archivedManually Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@index([archivedAt])
@@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
/// Umsatzsteuersatz dieser Position in ganzen Prozent, abgeleitet aus der
/// Produktkategorie zum Bestellzeitpunkt (Honig ermaessigt, Kerzen voll —
/// siehe lib/vat.ts). Teil des Snapshots aus demselben Grund wie der Preis:
/// wird ein Produkt spaeter umkategorisiert oder aendert sich der Satz,
/// darf das bestehende Rechnungen nicht rueckwirkend veraendern.
///
/// Null bei Bestellungen aus der Zeit vor der Steuerausweisung — deren
/// Rechnungen zeigen weiterhin keine Steuerzeilen, weil rueckwirkend nicht
/// feststellbar ist, welcher Satz gegolten haette.
vatRatePercent 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])
/// PostgreSQL legt fuer eine Fremdschluessel-Spalte KEINEN Index an (anders
/// als MySQL/InnoDB) — ohne diesen hier laeuft jede Abfrage nach productId
/// als vollstaendiger Scan ueber die am schnellsten wachsende Tabelle des
/// Shops. Betroffen sind zwei Stellen, die das nicht vertragen:
///
/// 1. hasCustomerPurchased (lib/reviews.ts) — der Nachweis "verifizierter
/// Kauf", der bei JEDEM Aufruf einer Produktseite durch eine
/// angemeldete Kundschaft ausgefuehrt wird.
/// 2. onDelete: Restrict auf der product-Relation — die Datenbank muss
/// beim Loeschen eines Produkts pruefen, ob noch Positionen daran
/// haengen.
@@index([productId])
/// Aus demselben Grund wie productId: die batch-Relation ist ebenfalls
/// "Restrict", das Loeschen einer Charge im Admin-Bereich prueft also gegen
/// diese Tabelle.
@@index([batchId])
@@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")
}
/// Laufende Nummer je Kalenderjahr fuer die Rechnungsstellung.
///
/// § 14 Abs. 4 Nr. 4 UStG verlangt eine FORTLAUFENDE Nummer. Eine
/// Zufallsnummer waere zwar eindeutig, aber nicht fortlaufend — bei einer
/// Betriebspruefung liesse sich damit nicht zeigen, dass keine Rechnung
/// fehlt.
///
/// Eigene Tabelle statt "hoechste bestehende Nummer + 1": beim Hochzaehlen
/// sperrt die Datenbank genau diese eine Zeile, zwei gleichzeitig bezahlte
/// Bestellungen bekommen dadurch zwangslaeufig verschiedene Nummern. Ein
/// Maximum-Abfragen waere dagegen ein klassisches Wettrennen — beide lesen
/// dieselbe hoechste Nummer, beide zaehlen auf denselben Wert hoch.
model InvoiceCounter {
/// Kalenderjahr; je Jahr beginnt die Zaehlung neu bei 1.
year Int @id
lastNumber Int @default(0)
@@map("invoice_counters")
}
model Invoice {
id String @id @default(cuid())
orderId String @unique
invoiceNumber String @unique
issuedAt DateTime @default(now())
totalCents Int
// Kein Steuersatz an der Rechnung: eine Bestellung kann Waren mit
// VERSCHIEDENEN Saetzen enthalten (Honig 7%, Bienenwachskerze 19%). Der
// Satz haengt deshalb an der einzelnen Position (OrderItem.vatRatePercent),
// und die Aufstellung je Satz rechnet lib/vat.ts daraus aus.
/// Pfad des abgelegten PDFs, RELATIV zu INVOICE_DIR — z.B.
/// "2026/mustermann-erika/RE-2026-0001.pdf". Siehe lib/invoice-storage.ts.
///
/// Null bei Rechnungen aus der Zeit vor dieser Ablage: deren PDF wird beim
/// Abruf weiterhin neu erzeugt. Der Pfad wird gespeichert statt jedes Mal
/// aus Jahr und Namen neu berechnet, damit eine spaetere Aenderung an der
/// Pfadbildung keine bereits abgelegten Belege unauffindbar macht.
///
/// Hiess vorher "pdfUrl" und wurde nirgends gelesen — eine URL waere auch
/// das falsche Konzept gewesen: das Dokument liegt bewusst NICHT unter
/// einem oeffentlich abrufbaren Pfad.
pdfPath 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())
/// Zeitpunkt des Klicks auf den Double-Opt-in-Link — Nachweis der
/// Einwilligung (Art. 7 Abs. 1 DSGVO). Null bei Altbestand vor Einfuehrung.
confirmedAt DateTime?
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")
}
/// Widerrufserklaerung ueber die elektronische Widerrufsfunktion
/// (§ 356a BGB, Seite /vertrag-widerrufen). Wird IMMER gespeichert, auch wenn
/// Bestellnummer und E-Mail zu keiner Bestellung passen: der Widerruf ist mit
/// dem Zugang erklaert, die Zuordnung ist unsere Sache. orderId ist nur die
/// automatische Zuordnung, wenn beides passt.
model WithdrawalDeclaration {
id String @id @default(cuid())
name String
/// Wortlaut wie eingegeben, nicht normalisiert — Beleg dessen, was erklaert wurde.
orderNumber String
email String
message String?
locale String @default("de")
orderId String?
/// Zugangszeitpunkt, steht so in der Eingangsbestaetigung (§ 356a Abs. 4 BGB).
receivedAt DateTime @default(now())
/// Gesetzt, sobald der Mailserver die Eingangsbestaetigung angenommen hat.
confirmationSentAt DateTime?
/// Im Admin als erledigt markiert (Ware zurueck, Erstattung veranlasst).
processedAt DateTime?
order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull)
@@index([receivedAt])
@@index([processedAt])
@@map("withdrawal_declarations")
}