// 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") isDefault Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade) @@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("[]") 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 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") 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? @@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) @@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) @@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") }