// 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:", 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:" oder "guest:": 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/ 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]) }