// 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[] /// Stripe-Kunde fuer Abos (Stripe Billing). Erst beim ersten Abo-Abschluss /// angelegt; Einmalkaeufe laufen weiter ohne Stripe-Kunde. stripeCustomerId String? @unique /// Gesetzt nach einer SEPA-Rueckbuchung: neue Abos dieses Kontos nur noch /// mit Karte oder Wallet (siehe lib/sponsorship-subscription). Der Admin /// kann die Sperre aufheben. sepaBlockedAt DateTime? sponsorships Sponsorship[] subscriptions SponsorshipSubscription[] @@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/ — 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//. @@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 /// Traegerprodukt einer Einmal-Patenschaft (siehe SponsorshipOffer). Nicht /// im Shop gelistet; es haelt Preis, Steuersatz und Rechnungsposition, damit /// Warenkorb, Checkout und Rechnung keinen Sonderweg brauchen. PATENSCHAFT } 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[] /// Einmal-Patenschaft, deren Traegerprodukt dieses Produkt ist. sponsorshipOffer SponsorshipOffer? @relation("SponsorshipOfferProduct") /// Patenschafts-Angebote, deren Honig aus diesem Produkt kommt. sponsorshipHoneyFor SponsorshipOffer[] @relation("SponsorshipOfferHoney") @@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 /// Nur digitale Leistungen (Patenschaft ohne Paket, Abo-Monatsrechnung): /// nichts zu versenden, nichts abzuholen. Die Rechnungsanschrift bleibt. DIGITAL } 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=; 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[] /// Gesetzt, wenn diese Bestellung eine bezahlte Abo-Monatsrechnung abbildet. subscriptionPayment SubscriptionPayment? /// Gesetzt, wenn diese Bestellung eine Abo-Honiglieferung ist (0 €). subscriptionDelivery SubscriptionDelivery? /// 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? /// Personalisierung einer Patenschaft, wie sie beim Kauf bestaetigt wurde /// (lib/certificate/schema.ts), samt Vorlagenversion. Nur an der /// Hauptposition (sponsorshipRole "MAIN"); daraus entsteht nach der Zahlung /// die erste Revision der Urkunde. personalization Json? /// Angebot, zu dem diese Position gehoert — an Haupt- UND Honigposition, /// damit Warenkorb, Rechnung und Admin sie als ein Paket zeigen koennen. sponsorshipOfferId String? /// "MAIN" (Patenschaft, Regelsteuersatz) oder "HONEY" (Honiganteil des /// Pakets zum Honig-Satz). Null bei gewoehnlichen Positionen. sponsorshipRole String? order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) sponsorship Sponsorship? 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) cancellation CancellationInvoice? @@map("invoices") } /// Eigener fortlaufender Nummernkreis der Stornorechnungen (ST-JJJJ-NNNN), /// je Kalenderjahr — aus demselben Grund eine Zeile mit Sperre wie /// InvoiceCounter. model CancellationCounter { year Int @id lastNumber Int @default(0) @@map("cancellation_counters") } /// Stornorechnung: hebt eine Rechnung vollstaendig auf. Eine Rechnung wird /// nie geloescht oder geaendert (§ 14 UStG, GoBD); wird die Bestellung /// storniert oder voll erstattet, entsteht stattdessen dieser Gegenbeleg mit /// eigener Nummer und Bezug auf die Rechnung (lib/cancellation-invoice.ts). /// Hoechstens einer je Rechnung. ERPNext uebernimmt ihn als /// Rechnungskorrektur, die Ausgangsrechnung steht dort dann auf /// "Credit Note Issued". model CancellationInvoice { id String @id @default(cuid()) invoiceId String @unique number String @unique issuedAt DateTime @default(now()) /// Betrag der aufgehobenen Rechnung (brutto, positiv). totalCents Int /// STORNIERUNG (Admin), RETOURE (Retoure erstattet) oder ERSTATTUNG (volle Erstattung bei Stripe). reason String /// Wie Invoice.pdfPath, relativ zu INVOICE_DIR. pdfPath String? invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade) @@index([issuedAt]) @@map("cancellation_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") } // --------------------------------------------------------------------------- // Bienenpatenschaft (docs/superpowers/specs/2026-10-16-bienenpatenschaft-design.md) // --------------------------------------------------------------------------- enum SponsorshipOfferKind { EINMALIG ABO } enum SponsorshipCycle { /// Lieferanspruch nach jeweils drei bezahlten Monaten. VIERTELJAEHRLICH /// Lieferung in den Erntefenstern, frueheste nach sechs bezahlten Monaten /// seit der letzten Lieferung. ERNTE } /// Praegung der Patenschafts-Medaille (lib/medals). enum MedalTier { BRONZE SILBER GOLD ABO } enum SponsorshipStatus { /// Bezahlt, PDF noch nicht erzeugt oder Mail noch nicht versendet. AUSSTEHEND /// Alle automatischen Versuche aufgebraucht — braucht den Admin. FEHLER /// PDF liegt vor, Mail ist raus. BEREIT /// Nur physische Pakete: Urkunde gedruckt. GEDRUCKT } enum SubscriptionStatus { /// Checkout gestartet, bei Stripe noch nicht abgeschlossen. AUSSTEHEND AKTIV /// Zahlung offen oder zurueckgebucht — keine Lieferungen, Stufe ruht. IM_VERZUG /// Gekuendigt, laeuft bis zum Ende des bezahlten Monats. GEKUENDIGT BEENDET } enum DeliveryStatus { /// Bestellung fuer den Versand angelegt. ANGELEGT /// Faellig, aber ohne Bestand oder Anspruch zurueckgestellt. ZURUECKGESTELLT } enum LoyaltyKind { STAMMKUNDE ABO } /// Ein im Admin pflegbares Patenschafts-Angebot (Einmalpaket oder Abo). model SponsorshipOffer { id String @id @default(cuid()) slug String @unique kind SponsorshipOfferKind name String nameEn String? description String @db.Text descriptionEn String? @db.Text /// Leistungsliste als JSON-Array von Strings (lib/json-array.ts). features Json @default("[]") featuresEn Json @default("[]") /// EINMALIG: Bruttopreis des ganzen Pakets. ABO: Monatspreis brutto. priceCents Int isActive Boolean @default(true) sortOrder Int @default(0) /// Mit gedruckter Urkunde, Honig, Grusskarte und Versand. isPhysical Boolean @default(false) /// Nur EINMALIG: Traegerprodukt (Kategorie PATENSCHAFT) fuer Warenkorb, /// Checkout und Rechnung. Sein Preis ist Paketpreis minus Honiganteil. productId String? @unique /// Honigprodukt, dessen Bestand das Paket bzw. Abo verbraucht. honeyProductId String? /// EINMALIG: Glaeser im Paket. ABO: Glaeser je Lieferung. honeyJars Int @default(0) /// EINMALIG: Honiganteil des Paketpreises je Glas (zum Honig-Steuersatz). honeyShareCentsPerJar Int @default(0) /// ABO: Honiganteil des Monatspreises (zum Honig-Steuersatz). honeyShareCentsPerMonth Int @default(0) /// Nur ABO. cycle SponsorshipCycle? /// Nur ABO: Hoechstzahl aktiver Abos (Kontingent); null = unbegrenzt. maxActiveSubscriptions Int? /// Nur ABO: Stripe-Produkt und der Preis, der priceCents abbildet. Bei einer /// Preisaenderung entsteht ein neuer Stripe-Preis; laufende Abos behalten /// ihren alten. stripeProductId String? stripePriceId String? stripePriceAmountCents Int? medalTier MedalTier @default(BRONZE) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt product Product? @relation("SponsorshipOfferProduct", fields: [productId], references: [id], onDelete: Restrict) honeyProduct Product? @relation("SponsorshipOfferHoney", fields: [honeyProductId], references: [id], onDelete: SetNull) sponsorships Sponsorship[] subscriptions SponsorshipSubscription[] harvestWindows HarvestWindow[] @@index([kind, isActive, sortOrder]) @@index([honeyProductId]) @@map("sponsorship_offers") } /// Eine gekaufte Patenschaft (eine Biene, ein Name, eine Urkunde). model Sponsorship { id String @id @default(cuid()) /// WH-P-JJJJ-NNNNN, fortlaufend ueber SponsorshipCounter. number String @unique ownerId String offerId String /// Einmalkauf: die Hauptposition der Bestellung. Eindeutig — das ist der /// Idempotenzschluessel gegen doppelt zugestellte Webhooks. orderItemId String? @unique /// Abo: das Abo, aus dem die Patenschaft entstand. subscriptionId String? @unique locale String @default("de") startDate DateTime @db.Date /// Leer bei laufendem Abo. endDate DateTime? @db.Date status SponsorshipStatus @default(AUSSTEHEND) /// Nummer der aktuellen Revision; 0 = noch keine Urkunde erzeugt. currentRevision Int @default(0) pdfAttempts Int @default(0) nextAttemptAt DateTime? lastError String? @db.Text mailSentAt DateTime? printedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt owner Customer @relation(fields: [ownerId], references: [id], onDelete: Cascade) offer SponsorshipOffer @relation(fields: [offerId], references: [id], onDelete: Restrict) orderItem OrderItem? @relation(fields: [orderItemId], references: [id], onDelete: SetNull) subscription SponsorshipSubscription? @relation(fields: [subscriptionId], references: [id], onDelete: SetNull) revisions SponsorshipRevision[] transfers SponsorshipTransfer[] @@index([ownerId]) @@index([status, nextAttemptAt]) @@index([offerId]) @@map("sponsorships") } /// Unveraenderlicher Stand einer Urkunde. Korrekturen und das Abo-Ende /// erzeugen neue Revisionen; alte bleiben samt PDF erhalten. model SponsorshipRevision { id String @id @default(cuid()) sponsorshipId String revision Int /// Personalisierung wie bestaetigt (lib/certificate/schema.ts). personalization Json /// Laufzeitform (FIXED/OPEN/ENDED) mit Daten. term Json /// Aufgeloeste Textbausteine — Beleg dessen, was auf der Urkunde steht. text Json templateVersion String /// Relativ zu CERTIFICATE_DIR. pdfPath String pdfSha256 String /// Admin-Konto bei Korrekturen, null bei System-Revisionen. createdById String? /// Lesbarer Ersteller ("System" oder die Admin-Adresse) — bleibt lesbar, /// auch wenn das Admin-Konto spaeter geloescht wird. createdByLabel String reason String? createdAt DateTime @default(now()) sponsorship Sponsorship @relation(fields: [sponsorshipId], references: [id], onDelete: Cascade) @@unique([sponsorshipId, revision]) @@map("sponsorship_revisions") } /// Jahreszaehler der Patenschaftsnummern, Muster wie InvoiceCounter. model SponsorshipCounter { year Int @id lastNumber Int @default(0) @@map("sponsorship_counters") } /// Befristeter, einmal nutzbarer Link zur Uebertragung einer /// Einmal-Patenschaft an ein anderes Konto. Gespeichert wird nur der Hash. model SponsorshipTransfer { id String @id @default(cuid()) sponsorshipId String tokenHash String @unique expiresAt DateTime createdById String claimedById String? claimedAt DateTime? revokedAt DateTime? createdAt DateTime @default(now()) sponsorship Sponsorship @relation(fields: [sponsorshipId], references: [id], onDelete: Cascade) @@index([sponsorshipId]) @@map("sponsorship_transfers") } /// Abo-Patenschaft ueber Stripe Billing. model SponsorshipSubscription { id String @id @default(cuid()) customerId String offerId String status SubscriptionStatus @default(AUSSTEHEND) /// Personalisierung beim Abschluss bestaetigt; daraus entsteht mit der /// ersten bezahlten Rechnung die Patenschaft. personalization Json locale String @default("de") stripeCheckoutSessionId String? @unique stripeSubscriptionId String? @unique startedAt DateTime? currentPeriodEnd DateTime? /// Gesetzt nach Kuendigung: Ende des bezahlten Zeitraums. cancelAt DateTime? endedAt DateTime? /// Beginn des Verzugs (fehlgeschlagene Zahlung oder Rueckbuchung). delinquentSince DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade) offer SponsorshipOffer @relation(fields: [offerId], references: [id], onDelete: Restrict) payments SubscriptionPayment[] deliveries SubscriptionDelivery[] sponsorship Sponsorship? @@index([customerId]) @@index([offerId, status]) @@map("sponsorship_subscriptions") } /// Eine bezahlte Abo-Monatsrechnung von Stripe. model SubscriptionPayment { id String @id @default(cuid()) subscriptionId String /// Idempotenzschluessel gegen doppelt zugestellte invoice.paid-Ereignisse. stripeInvoiceId String @unique stripePaymentIntentId String? paymentMethodType String? amountCents Int paidAt DateTime periodStart DateTime periodEnd DateTime /// Ab wann diese Zahlung fuer den Lieferanspruch zaehlt (SEPA-Neukunden- /// Frist, siehe lib/sponsorship-subscription/entitlement.ts). countsFrom DateTime /// Zurueckgebucht — zaehlt nie mehr fuer eine Lieferung. reversedAt DateTime? /// Lieferung, die diese Zahlung verbraucht hat. deliveryId String? /// Wabenhain-Bestellung samt Rechnung zu dieser Zahlung. orderId String? @unique createdAt DateTime @default(now()) subscription SponsorshipSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade) delivery SubscriptionDelivery? @relation(fields: [deliveryId], references: [id], onDelete: SetNull) order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull) @@index([subscriptionId, paidAt]) @@map("subscription_payments") } /// Eine Abo-Honiglieferung (0-€-Bestellung mit Honigpositionen). model SubscriptionDelivery { id String @id @default(cuid()) subscriptionId String dueAt DateTime jars Int status DeliveryStatus orderId String? @unique note String? createdAt DateTime @default(now()) subscription SponsorshipSubscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade) order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull) payments SubscriptionPayment[] @@index([subscriptionId, dueAt]) @@map("subscription_deliveries") } /// Erntefenster eines Abo-Angebots mit Zyklus ERNTE. model HarvestWindow { id String @id @default(cuid()) offerId String startsOn DateTime @db.Date endsOn DateTime @db.Date /// Fuer Abos geplante Glaeser — werden reserviert, bevor sie in den /// Freiverkauf gehen. plannedJars Int @default(0) createdAt DateTime @default(now()) offer SponsorshipOffer @relation(fields: [offerId], references: [id], onDelete: Cascade) @@index([offerId, startsOn]) @@map("harvest_windows") } /// Treuestufe (Stammkunde nach bezahlten Bestellungen, Abo nach bezahlten /// Monaten). threshold null = VIP (nur ABO, nach der hoechsten Zahl). model LoyaltyLevel { id String @id @default(cuid()) kind LoyaltyKind threshold Int? name String nameEn String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@unique([kind, threshold]) @@map("loyalty_levels") }