Kuma/prisma/schema.prisma
2026-09-11 12:23:43 +02:00

260 lines
12 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[]
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[]
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?
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)
createdAt DateTime @default(now())
@@unique([courseId, userId]) // Ein User kann sich nur einmal pro Kurs anmelden
@@index([courseId])
@@index([userId])
@@index([guestLeadId])
}
// 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])
}