Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m12s
Die Rechnung entstand bisher nur als Datenbankzeile; das PDF wurde bei jedem Abruf neu gerendert und tauchte in keiner E-Mail auf. Das hatte drei Nachteile: Gastbestellungen haben kein Konto, in dem sie den Beleg finden koennten; eine Sicherung der Datenbank sicherte den Beleg nicht mit; und ein spaeter erneut gerendertes PDF konnte nach einer Aenderung an Firmendaten oder Layout anders aussehen als das, was die Kundschaft in Haenden hielt. Ablage jetzt unter INVOICE_DIR, nach Jahr und Kunde sortiert: invoices/2026/mustermann-erika/RE-2026-A3F9C1B2.pdf Das Jahr zuerst, weil sich die Aufbewahrungsfrist danach richtet — ein abgelaufener Jahrgang laesst sich als ganzes Verzeichnis archivieren. Der relative Pfad steht als Invoice.pdfPath in der Datenbank, damit eine spaetere Aenderung an der Pfadbildung keine bereits abgelegten Belege unauffindbar macht. Dafuer wurde die nie gelesene Spalte "pdfUrl" umbenannt; eine URL war ohnehin das falsche Konzept. INVOICE_DIR ist bewusst ein ANDERES Verzeichnis als UPLOAD_DIR: app/uploads/[...path] liefert jede Datei unterhalb von UPLOAD_DIR ohne Anmeldung aus. Laegen Rechnungen dort, koennte jede Person, die den Pfad erraet, fremde Rechnungen samt Anschrift und Bestellinhalt herunterladen. Auf das Rechnungsverzeichnis zeigt keine oeffentliche Route; der Abruf laeuft weiter ueber /rechnung/<nummer>/pdf mit Besitzpruefung. Der Abruf liefert jetzt die abgelegte Datei aus und rendert nur neu, wenn keine da ist — also bei Rechnungen aus der Zeit davor. Versand: das PDF haengt an der Bestellbestaetigung. sendMail nimmt dafuer optionale Anhaenge entgegen, alle zehn bestehenden Aufrufe bleiben unveraendert. Schlaegt das Erzeugen oder Ablegen fehl, geht die Bestaetigung trotzdem raus und die Rechnung bleibt abrufbar: ein 500er an Stripe waere die falsche Antwort auf eine volle Platte, denn die Zahlung ist erfolgt und ein Wiederholungslauf aenderte daran nichts. Sicherung: der taegliche CronJob packt das Verzeichnis als rechnungen-<Zeitstempel>.tar.gz neben den Datenbank-Dump und haengt das Volume nur lesend ein. Vermerkt ist dort auch die Einschraenkung, dass wabenhain-invoices als ReadWriteOnce nur von Pods auf demselben Knoten einhaengbar ist — bei mehreren Knoten braucht es ReadWriteMany, sonst bleibt der Sicherungs-Pod lautlos in "Pending". Weiterhin offen und im README vermerkt: die Rechnungsnummer ist eindeutig, aber nicht fortlaufend (§ 14 Abs. 4 Nr. 4 UStG), und das PDF weist keine Umsatzsteuer aus. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
714 lines
24 KiB
Plaintext
714 lines
24 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
|
|
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
|
|
/// Pfad des abgelegten PDFs, RELATIV zu INVOICE_DIR — z.B.
|
|
/// "2026/mustermann-erika/RE-2026-A3F9C1B2.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())
|
|
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")
|
|
}
|