mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:41:16 +00:00
329 lines
16 KiB
Text
329 lines
16 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[]
|
|
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?
|
|
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[]
|
|
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?
|
|
business Business? @relation(fields: [businessId], references: [id], onDelete: SetNull)
|
|
bookings CourseBooking[]
|
|
waitlistEntries CourseWaitlistEntry[]
|
|
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
|
|
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
|
|
amountPaid Int? // tatsächlich gezahlter Betrag in Cents
|
|
platformFee Int? // unsere Service-Fee in Cents
|
|
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
|
|
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)
|
|
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])
|
|
}
|
|
|
|
// 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])
|
|
}
|
|
|
|
// 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
|
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
|
bookings CourseBooking[]
|
|
redemptions DiscountRedemption[]
|
|
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
|
|
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)
|
|
createdAt DateTime @default(now())
|
|
|
|
@@unique([discountCodeId, userId])
|
|
@@index([discountCodeId])
|
|
@@index([userId])
|
|
@@index([status])
|
|
}
|