Files
Wabenhain/prisma/schema.prisma
T
Nick SzaunigandClaude Opus 5.5 93b589ff04
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 3m40s
feat(stock): keep shop stock in sync with ApiCore, write sales back
ApiCore now owns the remaining stock per batch; the shop sells from it
and reports every paid sale and every return back.

- Outbox apicore_stock_events, written in the same transaction as the
  status change: paid -> SALE, cancelled-after-payment / refunded return
  -> RETURN onto the batches of the sale, reopened paid order -> SALE.
  Whether a return is due is decided by the open sales in the outbox,
  so unpaid orders never book and nothing is booked twice.
- Delivery right after the response (next/server after()) plus CronJob
  wabenhain-apicore-stock every 10 minutes with 1/5/15/60 min backoff;
  ApiCore deduplicates by event id. Rejected bookings stop and show up
  on the dashboard.
- Reconcile: shop stock = ApiCore remaining - unconfirmed bookings -
  reservations of unpaid orders, in a serializable transaction like the
  checkout. Triggered after bookings, by the signed ApiCore webhook
  stock.changed (ids only, numbers are fetched) and by the sync button.
- Manufactured goods (candles, propolis products, ...): one shop product
  per ApiCore article via Product.apicoreProductId, drafts created by the
  sync, existing products linkable in the product form; new category
  SONSTIGES. Stock field is read-only for linked products.
- OrderItem.apicoreAllocations keeps which lots were sold (recalls).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-05 21:16:54 +02:00

918 lines
35 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". Bewusst NICHT eindeutig je Jahr:
/// dieselbe Sorte wird oft mehrmals im Jahr geerntet.
type String
/// Oeffentliche Adresse /ernten/<slug> — siehe lib/harvest-slug.ts.
slug String @unique
/// ID der Ernte in ApiCore; Wiedererkennungs-Schluessel des Syncs
/// (admin/actions/apicore-sync.ts). null fuer von Hand angelegte Ernten.
apicoreHarvestId 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[]
/// Nur noch fuer die Weiterleitung alter Adressen /ernten/<jahr>/<sorte>.
@@index([year, type])
@@map("harvests")
}
model Batch {
id String @id @default(cuid())
/// z.B. FH-26-01
code String @unique
/// ApiCore-ID der Abfuellung (BottlingBatch), aus der diese Charge stammt.
/// Stabiler Zuordnungsschluessel fuer den Sync: die Losnummer (code) kann in
/// ApiCore korrigiert werden, die ID nicht. Null bei manuell angelegten Chargen.
apicoreBottlingId 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
SONSTIGES
}
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
/// ApiCore-ID des ARTIKELS fuer hergestellte Waren (Kerzen, Propolis-
/// Produkte, ...): deren Bestand liegt in ApiCore je Herstellungscharge und
/// wird als Summe gefuehrt, ein Shop-Produkt je Artikel. Honig nutzt
/// stattdessen apicoreBottlingId. Hoechstens eines von beiden ist gesetzt;
/// ist eines gesetzt, kommt der Bestand aus ApiCore (lib/apicore-stock).
apicoreProductId 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[]
apicoreStockEvents ApiCoreStockEvent[]
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)
/// Vom Admin abgelehnt: bleibt im Archiv nachvollziehbar statt geloescht zu
/// werden. Offen ist eine Bewertung, solange weder isApproved noch
/// rejectedAt gesetzt ist (siehe lib/review-status.ts).
rejectedAt DateTime?
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?
/// Aus welchen ApiCore-Chargen die verkaufte Ware stammt, wie ApiCore sie
/// beim Verbuchen zugeordnet hat: [{ lotNumber, quantity }]. Fuer
/// Rueckverfolgung und Rueckruf; null, solange der Verkauf noch nicht
/// bestaetigt ist oder das Produkt nicht mit ApiCore verknuepft ist.
apicoreAllocations Json?
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)
apicoreStockEvents ApiCoreStockEvent[]
/// 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")
}
// ---------------------------------------------------------------------------
// Bestandsabgleich mit ApiCore
// ---------------------------------------------------------------------------
enum ApiCoreStockEventKind {
SALE
RETURN
}
/// Warteschlange der Bestandsbuchungen an ApiCore (lib/apicore-stock).
///
/// Geschrieben in DERSELBEN Transaktion wie die ausloesende Statusaenderung
/// (bezahlt, storniert, erstattet), gesendet danach — sofort und per
/// CronJob nachgereicht. So geht keine Buchung verloren, wenn ApiCore gerade
/// nicht erreichbar ist. Die id geht als externalId an ApiCore; ApiCore bucht
/// jede id nur einmal, Wiederholungen sind also gefahrlos.
model ApiCoreStockEvent {
id String @id @default(cuid())
kind ApiCoreStockEventKind
orderItemId String
/// Wabenhain-Produkt, dessen Shop-Bestand von dieser Buchung abhaengt.
productId String
/// Ziel in ApiCore: eine Abfuellung (Honig) ODER ein Artikel (FIFO ueber
/// dessen Herstellungschargen).
apicoreBottlingId String?
apicoreProductId String?
quantity Int
/// Bei RETURN: der Verkauf, der zurueckgebucht wird.
reversesEventId String?
createdAt DateTime @default(now())
/// Von ApiCore bestaetigt (neu gebucht oder schon bekannt).
sentAt DateTime?
/// Von ApiCore abgelehnt — wird nicht wiederholt, das Dashboard zeigt es.
rejectedAt DateTime?
attempts Int @default(0)
lastError String?
nextAttemptAt DateTime @default(now())
orderItem OrderItem @relation(fields: [orderItemId], references: [id], onDelete: Restrict)
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
reverses ApiCoreStockEvent? @relation("StockEventReversal", fields: [reversesEventId], references: [id], onDelete: Restrict)
reversedBy ApiCoreStockEvent[] @relation("StockEventReversal")
@@index([sentAt, rejectedAt, nextAttemptAt])
@@index([orderItemId])
@@index([productId])
@@map("apicore_stock_events")
}
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")
}