Kuma/prisma/schema.prisma
2026-10-02 17:31:08 +02:00

636 lines
33 KiB
Text

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
engineType = "client"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
model User {
id String @id @default(uuid())
name String?
complaints String?
birthday DateTime?
plz String?
city String?
street String?
houseNumber String?
// @unique is Prisma-managed and stays case-sensitive at the schema level —
// Prisma has no functional-index syntax. The actual enforced constraint is
// a case-insensitive unique index on lower(email), added via raw SQL in
// migration 20260817000000_case_insensitive_user_email. Do not remove
// @unique here; it keeps Prisma's drift detection from trying to recreate
// a duplicate case-sensitive constraint.
email String @unique
tel String?
gender String?
is2FA Boolean @default(false)
isSendingMail Boolean @default(false)
isNewsletter Boolean @default(false)
pbSrc String?
isValid Boolean @default(false)
trainings UserTraining[]
courseBookings CourseBooking[]
courseWaitlistEntries CourseWaitlistEntry[]
discountRedemptions DiscountRedemption[]
passes CustomerPass[]
marketingConsents MarketingConsent[]
marketingSends MarketingSend[]
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
}
model Employee {
id String @id @default(uuid())
firstName String?
lastName String?
email String @unique
is2FA Boolean @default(false)
isSendingMail Boolean @default(false)
pbSrc String?
status String?
roles String[] @default([])
locations String[] @default([])
permissions Json?
affiliation String?
label String?
qualification String?
authorization Json?
onboardingToken String? @unique
onboardingTokenExpiry DateTime?
isOnboarded Boolean @default(false)
firstStepsDismissedAt DateTime? // "Erste Schritte"-Karte im Dashboard weggeklickt
createdBy String? // Manager ID who created the employee
trainings UserTraining[]
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
@@index([createdBy])
}
model UserTraining {
userId String
employeeId String
employee Employee @relation(fields: [employeeId], references: [id])
user User @relation(fields: [userId], references: [id])
@@id([userId, employeeId])
}
model Manager {
id String @id @default(uuid())
firstName String
lastName String
tel String?
email String @unique
is2FA Boolean @default(false)
isSendingMail Boolean @default(false)
pbSrc String?
businesses Business[]
createdAt DateTime? @default(now())
updatedAt DateTime? @updatedAt
}
model Business {
id String @id @default(uuid())
managerId String
name String
address String
email String
title String
ustId String?
// Steuerliche Angaben für die Rechnungen an die Kunden des Studios
// (§ 14 UStG, § 33 UStDV). Bewusst ohne Standardwert: ob Umsatzsteuer
// ausgewiesen werden muss, lässt sich aus keinem anderen Feld ableiten.
// Solange taxStatus null ist, erstellt Kumasy keine Rechnungen.
taxStatus String? // "regular" | "small_business" (§ 19 UStG) | "exempt"
taxNumber String? // Steuernummer vom Finanzamt, z.B. 12/345/67890
vatRate Int? // Umsatzsteuersatz für Kurse in Prozent (19 oder 7), nur bei "regular"
taxExemptionNote String? // Grund der Steuerbefreiung für die Rechnung, nur bei "exempt"
banking String?
plan String?
subscription Json?
slug String? @unique
isPublic Boolean @default(false)
stripeAccountId String? @unique // Stripe Connect Account ID
stripeAccountStatus String? // "pending" | "active" | "restricted"
invoiceCounter Int @default(0) // Fortlaufender Zähler für Rechnungsnummern
cancellationPolicy Json? // { tiers: [{ hoursBeforeStart: number, refundPercent: number }, ...] }, absteigend sortiert. null = immer 100% Rückerstattung.
branding Json? // { companyName, logoUrl, primaryColor, accentColor }. null = Kumasy-Standardbranding (siehe KUMASY_BRANDING).
notificationSettings Json? // { "booking.created": true, ... } Opt-out pro In-App-Benachrichtigungstyp. null / fehlender Key = Registry-Default (siehe NOTIFICATION_TYPES).
// Weggeklickte Dashboard-Empfehlungen: [{ id: "add-session:<courseId>", until: ISO-Datum }].
// until = Schlummer-Ende, danach taucht die Empfehlung wieder auf. Die zugehoerige
// Migration (20260828164044) war nur in der Datenbank, nicht im Repo - das Feld wird
// aktuell von keinem Code auf dev benutzt, enthaelt aber echte Daten.
dashboardDismissedRecommendations Json?
// Onboarding-Status des Studios. Bewusst nur Meta-Zustand: der Fortschritt der
// einzelnen Schritte wird aus den vorhandenen Daten abgeleitet (siehe
// src/modules/onboarding/lib/onboarding-steps.ts), damit er nicht driften kann.
onboardingStartedAt DateTime? // Wizard erstmals geoeffnet, ab dann kein Redirect mehr
onboardingDismissedAt DateTime? // Checkliste im Dashboard ausgeblendet
onboardingCompletedAt DateTime? // Zeitpunkt, an dem erstmals alle Pflichtschritte erledigt waren
manager Manager @relation(fields: [managerId], references: [id])
courses Course[]
discountCodes DiscountCode[]
products Product[]
customerPasses CustomerPass[]
marketingNodes MarketingNode[]
marketingConsents MarketingConsent[]
marketingSends MarketingSend[]
marketingAuditLogs MarketingAuditLog[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([managerId])
}
model Course {
id String @id @default(uuid())
name String
sport String[]
level String @default("any")
date DateTime
timeFrom String // Anfangszeit
timeTo String // Endzeit
trainers String[] // Array of trainer IDs
room String
description String // Rich text HTML content
maxParticipants Int // Maximum number of participants
price Float @default(10.0) // Preis in Euro
isStandingOrder Boolean @default(false)
frequency String? // daily, weekly, biweekly, monthly, custom
weekdays String[] // For custom frequency
weekdayTimings Json? // { "monday": [{ "timeFrom": "08:00", "timeTo": "09:00" }, ...], ... } — mehrere Zeitfenster pro Tag. Legacy: { "monday": { "timeFrom", "timeTo" } }
endDate DateTime? // Required when isStandingOrder = true
parentCourseId String? // Null on template; set on every child instance
parentCourse Course? @relation("StandingOrderInstances", fields: [parentCourseId], references: [id], onDelete: Cascade)
children Course[] @relation("StandingOrderInstances")
status String @default("draft") // draft, published, cancelled
cancelledAt DateTime? // Zeitpunkt der Kursabsage (nur gesetzt wenn status = "cancelled")
createdBy String // Manager ID who created the course
businessId String?
coverImage String?
// Kunden wählen beim Buchen ein Gerät aus dem Grundriss des Raums. Wirkt nur,
// solange der Raum buchbare Geräte hat - sonst wird ganz normal ohne Platz gebucht.
spotBooking Boolean @default(false)
business Business? @relation(fields: [businessId], references: [id], onDelete: SetNull)
bookings CourseBooking[]
waitlistEntries CourseWaitlistEntry[]
spotHolds CourseSpotHold[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([createdBy])
@@index([date])
@@index([status])
@@index([businessId])
@@index([parentCourseId])
@@index([parentCourseId, date])
}
model Room {
id String @id @default(uuid())
name String
createdBy String // Manager ID who created the room
// Grundriss mit Geräten: { width, depth, items: [{ id, type, x, y, rotation, label }] }.
// Format und Gerätekatalog siehe src/modules/rooms/lib/floor-plan.ts. null = kein Grundriss.
// Die item-IDs sind stabil und stehen als CourseBooking.spotId an Buchungen.
floorPlan Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([createdBy])
}
model GuestLead {
id String @id @default(uuid())
email String @unique
name String?
bookings CourseBooking[]
createdAt DateTime @default(now())
}
model CourseBooking {
id String @id @default(uuid())
courseId String
userId String?
guestLeadId String?
paymentStatus String @default("pending") // "pending" | "paid" | "failed" | "refunded" | "partially_refunded" | "refunding" | "cancelled" (cancelled = storniert ohne Geldbewegung, 0% Erstattung)
stripeSessionId String? @unique
stripePaymentIntentId String? @unique
billingAddress Json? // Rechnungsadresse, die der Kunde im Stripe-Checkout angegeben hat - als Momentaufnahme pro Buchung, damit eine Rechnung sich nicht ändert, wenn jemand später umzieht. Siehe src/lib/billing-address.ts.
stripeAccountId String? // Express-Account des Studios, auf dem die Zahlung liegt (Direct Charge). null = Altbuchung als Destination Charge auf dem Plattformkonto - Erstattungen laufen dann mit reverse_transfer.
amountPaid Int? // tatsächlich gezahlter Betrag in Cents
platformFee Int? // unsere Service-Fee in Cents
stripeFee Int? // Stripe-Gebühr in Cents, die Stripe bei der Zahlung vom Studio abgezogen hat (Direct Charge). null = noch nicht abgerechnet, bzw. bei Altbuchungen: Gebühr trug die Plattform.
platformFeeOnTop Boolean @default(false) // true = Gebühr wurde dem Kunden ZUSÄTZLICH berechnet, amountPaid enthält sie. false = Altbuchung, bei der die prozentuale Fee aus dem Kurspreis einbehalten wurde.
invoiceNumber String? @unique // Rechnungsnummer, wird lazy beim ersten PDF-Download vergeben
invoiceIssuedAt DateTime? // Zeitpunkt der Rechnungsnummer-Vergabe, damit das Rechnungsdatum bei jedem Download gleich bleibt
refundedAmount Int? // tatsächlich erstatteter Betrag in Cents, kumuliert über alle Teilstornos. Bei "paid" > 0 = einzelne Plätze wurden storniert.
refundedFeeAmount Int? // Anteil von refundedAmount, der Servicegebühr war (nur bei Kursabsage). Kursanteil der Erstattung = refundedAmount - refundedFeeAmount.
refundedAt DateTime? // Zeitpunkt der erfolgreichen Stripe-Rückerstattung
cancelledBy String? // "user" | "manager" | "system" (system = automatischer Überbuchungs-Refund)
cancellationReason String?
discountCodeId String? // Gesetzt, sobald bei der Buchung ein gültiger Rabattcode eingelöst wurde
discountAmount Int? // Tatsächlich gewährter Rabatt in Cents (Snapshot, unabhängig von späteren Code-Änderungen)
// Mehrfachbuchung: eine Buchung = eine Zahlung, kann aber mehrere Plätze umfassen
// (eingeloggte Kunden buchen für Begleitpersonen mit). Belegte Plätze eines Kurses
// = Summe von seats über alle "paid"-Buchungen (siehe seat-counts.ts), nicht die
// Anzahl der Buchungen.
seats Int @default(1) // aktuell gehaltene Plätze - sinkt, wenn einzelne Plätze storniert werden
seatsPurchased Int @default(1) // ursprünglich bezahlte Plätze - Bezugsgröße für den Betrag pro Platz
spotIds String[] @default([]) // Geräte aus dem Raum-Grundriss (Room.floorPlan.items[].id), nur bei Kursen mit Platzwahl, ein Eintrag pro Platz. Belegt, solange die Buchung "paid" ist.
spotLabels String[] @default([]) // Anzeigenamen als Momentaufnahme, z.B. "Reformer 3", parallel zu spotIds - bleiben lesbar, auch wenn der Grundriss sich ändert
// Mit einer Karte gebucht: kein Geld, keine Stripe-Felder, keine Rechnung (die
// gehört zum Kartenkauf). Pro Platz ist eine Einheit von der Karte abgegangen.
// Status wie bei bezahlten Buchungen ("paid" = gebucht), damit Auslastung und
// Platzwahl unverändert über seat-counts.ts laufen. Storno: "refunded" = Einheit
// zurück auf der Karte, "cancelled" = Einheit verfallen.
passId String?
pass CustomerPass? @relation(fields: [passId], references: [id], onDelete: SetNull)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
guestLead GuestLead? @relation(fields: [guestLeadId], references: [id], onDelete: SetNull)
discountCode DiscountCode? @relation(fields: [discountCodeId], references: [id], onDelete: SetNull)
discountRedemption DiscountRedemption?
createdAt DateTime @default(now())
@@unique([courseId, userId]) // Ein User kann sich nur einmal pro Kurs anmelden
@@index([courseId])
@@index([userId])
@@index([guestLeadId])
@@index([discountCodeId])
@@index([spotIds], type: Gin)
@@index([passId])
}
// Hält einen Platz frei, während der Kunde im Stripe-Checkout bezahlt - analog
// zur DiscountRedemption-Reservierung. Ohne sie könnten zwei Kunden gleichzeitig
// mit demselben Reformer zur Kasse gehen. Entsteht beim Checkout-Start, wird im
// Webhook nach der Zahlung gelöscht (dann belegt die Buchung den Platz) oder bei
// abgelaufener Session freigegeben. Abgelaufene Holds (expiresAt < jetzt) zählen
// nicht und werden beim nächsten Reservieren aufgeräumt.
// holderKey = "user:<id>" oder "guest:<email>": pro Person und Kurs läuft höchstens
// ein offener Checkout - wer zurück geht und neu wählt, beendet den alten (siehe
// Checkout-Route). Holds abgeschlossener Checkouts bleiben dabei unangetastet.
model CourseSpotHold {
id String @id @default(uuid())
courseId String
spotId String
holderKey String
expiresAt DateTime
stripeSessionId String? // Checkout-Session, für die der Platz gehalten wird. null, solange sie angelegt wird.
status String @default("checkout") // "checkout" (Session offen) | "awaiting_payment" (Checkout abgeschlossen, SEPA-Zahlung unterwegs)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([courseId, spotId])
@@index([courseId])
}
// Bewusst nur für registrierte User (kein guestEmail-Feld): ein
// unauthentifiziertes Gast-Formular würde eine beliebige, nicht verifizierte
// E-Mail-Adresse als Benachrichtigungsempfänger akzeptieren, ohne dass deren
// Kontrolle bestätigt wurde. Der Login-Zwang schließt das und wirkt nebenbei
// als Anreiz zur Registrierung (Lead-Generierung).
model CourseWaitlistEntry {
id String @id @default(uuid())
courseId String
userId String
notifiedAt DateTime? // Zeitpunkt der letzten "Platz frei"-Benachrichtigung, null = noch nie benachrichtigt
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([courseId, userId]) // Ein User kann sich nur einmal pro Kurs eintragen
@@index([courseId])
@@index([userId])
}
// In-App-Benachrichtigungen für die Studio-Seite (Manager/Employee).
// recipientId ist bewusst polymorph ohne Relation (wie Employee.createdBy /
// CourseBooking.cancelledBy): Empfänger kann je nach Typ Manager oder Employee
// sein, perspektivisch auch User. Keine FK, damit kein Modell-Join nötig ist
// und ein späterer Kunden-Empfänger keine Migration braucht.
model Notification {
id String @id @default(uuid())
recipientId String // Manager- oder Employee-ID
type String // Schlüssel aus NOTIFICATION_TYPES (src/modules/notifications/lib/notification-types.ts)
title String
body String
linkUrl String? // Ziel beim Klick, z.B. /courses/edit/<id>
readAt DateTime? // null = ungelesen
createdAt DateTime @default(now())
@@index([recipientId, readAt])
@@index([recipientId, createdAt])
}
// Web-Push-Subscriptions (ein Datensatz pro Browser/Gerät). recipientId ist
// polymorph wie bei Notification (Manager-, Employee- oder User-ID) - Push ist
// ein zweiter Zustellkanal für createNotification(), kein eigenes System.
// endpoint ist pro Push-Dienst (FCM/Mozilla/...) global eindeutig und dient
// als natürlicher Unique-Key, um dasselbe Gerät bei erneutem Subscribe zu
// aktualisieren statt zu duplizieren.
model PushSubscription {
id String @id @default(uuid())
recipientId String
endpoint String @unique
p256dh String // Public Key des Browsers für die Payload-Verschlüsselung
auth String // Auth-Secret des Browsers für die Payload-Verschlüsselung
userAgent String? // Nur zur Anzeige in einer künftigen "Geräte verwalten"-UI
createdAt DateTime @default(now())
lastSeenAt DateTime @default(now()) // Bei jedem erneuten Subscribe aktualisiert
@@index([recipientId])
}
// Rabattcode eines Business. scope = "specific_courses" schränkt die Gültigkeit
// über courseIds ein (Array statt Join-Tabelle, analog Course.trainers/sport —
// hier reicht eine Handvoll IDs pro Code, kein eigenes Modell nötig).
// redeemedCount zählt RESERVIERTE Slots (status "reserved" ODER "confirmed"),
// nicht nur bestätigte Zahlungen - die Reservierung passiert bereits beim
// Checkout-Start, um Race Conditions zwischen mehreren gleichzeitigen
// Checkouts zu verhindern (siehe DiscountRedemption). Der Zähler wird wieder
// dekrementiert, wenn eine Reservierung storniert/abgelaufen wird.
model DiscountCode {
id String @id @default(uuid())
businessId String
code String // Anzeigeform, z.B. "SOMMER25" — Eindeutigkeit nur pro Business (siehe @@unique)
type String // "percent" | "fixed"
value Int // percent: 1-100, fixed: Cents
scope String // "all_courses" | "specific_courses"
courseIds String[] // Nur relevant wenn scope = "specific_courses"
maxRedemptions Int? // null = unbegrenzt
redeemedCount Int @default(0)
startsAt DateTime? // null = sofort gültig
expiresAt DateTime? // null = kein Ablauf
isActive Boolean @default(true) // Manuelles An/Aus, unabhängig vom Zeitfenster
appliesTo String @default("courses") // "courses" | "passes" (Kartenkauf) | "all"
// Persönlicher Code: nur dieses Konto kann ihn einlösen. Bewusst ohne
// Fremdschlüssel - würde er beim Löschen des Kontos auf null gesetzt, könnte
// plötzlich jeder den Code einlösen.
restrictedToUserId String?
origin String @default("manual") // "manual" (Rabattcode-Seite) | "marketing" (automatisch pro Mail erzeugt, nicht in der Liste)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
bookings CourseBooking[]
passes CustomerPass[]
redemptions DiscountRedemption[]
marketingSend MarketingSend?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([businessId, code])
@@index([businessId])
}
// Reserviert einen Rabattcode-Slot BEIM CHECKOUT-START, nicht erst nach
// Zahlungsbestätigung - sonst könnten mehrere gleichzeitige Checkouts (versch.
// User bei knappem Kontingent, oder ein User mit mehreren offenen Sessions)
// alle den rabattierten Preis bekommen, obwohl nur ein Slot frei ist. status:
// "reserved" (Checkout-Session erstellt, Zahlung ausstehend) → "confirmed"
// (Webhook hat die Zahlung bestätigt, bookingId gesetzt) oder gelöscht (Session
// abgelaufen/storniert, siehe handleCheckoutExpired - gibt den Slot wieder frei).
// bookingId ist erst ab "confirmed" gesetzt, da beim Reservieren noch keine
// CourseBooking-Zeile existiert. Der @@unique([discountCodeId, userId])
// Constraint erzwingt "1x pro User" bereits ab der Reservierung, nicht erst
// ab Zahlungsbestätigung - das schließt die zweite Race Condition
// (ein User bucht mehrere Kurse mit demselben Code, bevor eine Zahlung
// durchläuft) auf DB-Ebene.
model DiscountRedemption {
id String @id @default(uuid())
discountCodeId String
userId String
stripeSessionId String @unique
bookingId String? @unique
passId String? @unique // Kartenkauf mit Rabattcode - gesetzt statt bookingId
status String @default("reserved") // "reserved" | "confirmed"
discountCode DiscountCode @relation(fields: [discountCodeId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
booking CourseBooking? @relation(fields: [bookingId], references: [id], onDelete: SetNull)
pass CustomerPass? @relation(fields: [passId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
@@unique([discountCodeId, userId])
@@index([discountCodeId])
@@index([userId])
@@index([status])
}
// Karte, die ein Studio verkauft, z.B. eine 10er-Karte. Eingeloggte Kunden
// kaufen sie auf der öffentlichen Seite und lösen die Einheiten beim Buchen ein:
// 1 Einheit = 1 Platz in einem Kurs. Zum Preis kommt die Servicegebühr pro
// Einheit dazu (siehe src/modules/products/lib/card-pricing.ts).
model Product {
id String @id @default(uuid())
businessId String
name String
description String?
credits Int // Einheiten pro Karte
priceCents Int // Preis der Karte in Cents, ohne Servicegebühr
validityMonths Int? // Gültigkeit ab Kauf in Monaten, null = unbegrenzt gültig
sports String[] // Kursarten (Course.sport), für die die Karte gilt. Leer = alle Kurse.
design Json // Kartendesign, Format siehe src/modules/products/lib/card-design.ts
isActive Boolean @default(true) // false = nicht mehr kaufbar
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
passes CustomerPass[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([businessId])
}
// Eine Karte, die ein Kunde gekauft oder vom Studio bekommen hat. Name, Kursarten,
// Einheiten und Ablaufdatum sind Momentaufnahmen beim Kauf - spätere Änderungen am
// Produkt gelten nur für neue Käufe. Das Design kommt live vom Produkt.
//
// creditsRemaining ist ein Zähler: Buchen zieht ab (atomar, nur wenn genug übrig
// ist), Storno innerhalb der Frist und Kursabsage geben zurück, der Manager passt
// ihn im Kunden-Detail an. Jede manuelle Änderung steht in PassAdjustment.
model CustomerPass {
id String @id @default(uuid())
productId String
businessId String
// null = noch ohne Konto: vom Studio an eine E-Mail vergeben, oder das Konto
// wurde gelöscht. Die Karte hängt sich an das Konto, sobald sich jemand mit
// dieser Adresse anmeldet. Ein gelöschtes Konto nimmt die Karte bewusst nicht
// mit: Kauf, Rechnung und Erstattung müssen erhalten bleiben.
userId String?
email String // Empfänger, immer lowercase
name String
sports String[] // leer = alle Kurse
creditsTotal Int // alle Einheiten, die die Karte je bekommen hat (inkl. Gutschriften)
creditsRemaining Int
validUntil DateTime? // null = unbegrenzt gültig
status String @default("active") // "active" | "ended" (vom Studio beendet) | "refunding" (Erstattung angestoßen, Ausgang offen) | "refunded"
source String // "purchase" | "manual" (vom Studio vergeben, ohne Zahlung)
// Zahlung - nur bei source = "purchase"
priceCents Int?
stripeAccountId String?
stripeSessionId String? @unique
stripePaymentIntentId String? @unique
amountPaid Int? // inkl. Servicegebühr
platformFee Int?
stripeFee Int?
billingAddress Json?
invoiceNumber String? @unique
invoiceIssuedAt DateTime?
refundedAmount Int?
refundedAt DateTime?
discountCodeId String? // Beim Kauf eingelöster Rabattcode
discountAmount Int? // Gewährter Rabatt in Cents (Snapshot) - priceCents ist schon der rabattierte Preis
discountCode DiscountCode? @relation(fields: [discountCodeId], references: [id], onDelete: SetNull)
discountRedemption DiscountRedemption?
product Product @relation(fields: [productId], references: [id], onDelete: NoAction)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
bookings CourseBooking[]
adjustments PassAdjustment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([businessId])
@@index([userId])
@@index([email])
@@index([productId])
@@index([discountCodeId])
}
// Protokoll jeder Änderung durch das Studio: Karte vergeben, Einheiten
// gutgeschrieben oder abgezogen, verlängert, beendet, erstattet.
model PassAdjustment {
id String @id @default(uuid())
passId String
managerId String
type String // "issue" | "credits" | "extend" | "end" | "refund" | "refund_failed"
creditsDelta Int?
previousValidUntil DateTime?
newValidUntil DateTime?
reason String?
pass CustomerPass @relation(fields: [passId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([passId])
}
// ─── Marketing ────────────────────────────────────────────────────────────────
//
// Automatische E-Mails eines Studios an seine Kunden. Das Studio baut im
// Node-Editor Trigger (Geburtstag, Meilenstein, Reaktivierung), die jeweils in
// genau eine E-Mail führen. Ein täglicher Cron (/api/cron/marketing) prüft die
// aktiven Trigger und verschickt fällige Mails - nur an Kunden, die dem Studio
// ausdrücklich zugestimmt haben (MarketingConsent). Ablauf siehe
// src/modules/marketing/lib/run-marketing.ts.
// Einwilligung eines Kunden in Werbe-Mails EINES Studios (Opt-in per Häkchen).
// Eine Zeile pro Kunde und Studio mit dem aktuellen Stand; jede Erteilung und
// jeder Widerruf steht zusätzlich im MarketingAuditLog (Nachweis).
model MarketingConsent {
id String @id @default(uuid())
userId String
businessId String
granted Boolean
textVersion String // Version des Wortlauts, dem zugestimmt wurde (siehe consent-text.ts)
source String // "course_booking" | "pass_purchase" | "profile" | "unsubscribe_link"
grantedAt DateTime? // letzte Erteilung
revokedAt DateTime? // letzter Widerruf
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, businessId])
@@index([businessId, granted])
}
// Ein Baustein im Node-Editor. Trigger zeigen über emailNodeId auf die E-Mail,
// in die sie führen - ein Trigger hat damit höchstens eine Verbindung, eine
// E-Mail kann mehrere Trigger haben.
model MarketingNode {
id String @id @default(uuid())
businessId String
kind String // "birthday" | "milestone" | "winback" | "email"
// Trigger: milestone { bookings }, winback { days }, birthday {}.
// E-Mail: { name, subject, body, reward } - Format siehe node-config.ts.
config Json
positionX Float
positionY Float
// Nur Trigger: verbundene E-Mail und Schalter. Wirksam ist ein Trigger, wenn
// er eingeschaltet UND verbunden ist; activatedAt ist der Zeitpunkt, ab dem
// er so läuft. Gezählt werden nur Ereignisse ab dann - sonst würde das
// Einschalten alle Bestandskunden auf einmal anschreiben.
emailNodeId String?
isActive Boolean @default(false)
activatedAt DateTime?
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
emailNode MarketingNode? @relation("TriggerEmail", fields: [emailNodeId], references: [id], onDelete: SetNull)
triggers MarketingNode[] @relation("TriggerEmail")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([businessId])
@@index([emailNodeId])
}
// Versandprotokoll. Der Unique-Key verhindert Doppelmails unabhängig vom
// Editor: pro Studio, Kunde, Trigger-Art und Zeitraum (Geburtstag: Jahr,
// Meilenstein: Schwelle, Reaktivierung: letzter Kurstag + Tage) gibt es
// höchstens eine Mail - auch wenn der Cron doppelt läuft oder ein Node
// gelöscht und neu angelegt wird.
model MarketingSend {
id String @id @default(uuid())
businessId String
userId String? // null = Konto inzwischen gelöscht
triggerKind String
periodKey String
triggerNodeId String? // null = Node inzwischen gelöscht
emailNodeId String?
discountCodeId String? @unique
status String @default("pending") // "pending" | "sent" | "failed"
attempts Int @default(1)
error String?
subject String // Betreff als Momentaufnahme, für den Verlauf
sentAt DateTime?
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
discountCode DiscountCode? @relation(fields: [discountCodeId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([businessId, userId, triggerKind, periodKey])
@@index([businessId, createdAt])
@@index([triggerNodeId])
@@index([emailNodeId])
}
// Änderungsprotokoll: wer hat im Editor was geändert, und wer hat wann
// eingewilligt oder widerrufen. Bewusst ohne Fremdschlüssel auf Nodes/Kunden -
// der Eintrag muss das Löschen überdauern.
model MarketingAuditLog {
id String @id @default(uuid())
businessId String
actorType String // "manager" | "customer" | "system"
actorId String?
action String // siehe AUDIT_ACTIONS in audit-log.ts
nodeId String?
userId String? // betroffener Kunde (Einwilligung)
summary String // lesbare Momentaufnahme, z.B. "Meilenstein (10. Kurs) eingeschaltet"
before Json?
after Json?
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([businessId, createdAt])
@@index([userId])
}