see comments

This commit is contained in:
Rajpreet0 2026-09-13 19:58:17 +02:00
parent db514248a2
commit f6f1973086
11 changed files with 512 additions and 184 deletions

View file

@ -55,6 +55,16 @@ CREATE UNIQUE INDEX "DiscountRedemption_discountCodeId_userId_key" ON "DiscountR
CREATE INDEX "CourseBooking_discountCodeId_idx" ON "CourseBooking"("discountCodeId");
-- AddForeignKey
-- Bewusst OHNE CONCURRENTLY/NOT VALID: Postgres erlaubt CREATE INDEX
-- CONCURRENTLY nie innerhalb einer Transaktion, `prisma migrate dev` führt
-- aber jede Migration transaktional gegen eine Shadow-Database aus - dort
-- würde CONCURRENTLY hart fehlschlagen und dauerhaft JEDE künftige
-- `migrate dev`-Ausführung blockieren (nicht nur diese eine Migration), weil
-- die Shadow-DB alle Migrationen der Reihe nach durchspielt. Der Trade-off
-- ist hier vertretbar: CourseBooking existiert zu diesem Zeitpunkt zwar
-- schon, aber discountCodeId ist eine brandneue Spalte ohne bestehende
-- Nicht-NULL-Werte - Index-Build und FK-Validierung laufen daher in
-- Sekundenbruchteilen, unabhängig von der Zeilenzahl der Tabelle.
ALTER TABLE "CourseBooking" ADD CONSTRAINT "CourseBooking_discountCodeId_fkey" FOREIGN KEY ("discountCodeId") REFERENCES "DiscountCode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey

View file

@ -0,0 +1,30 @@
-- DropForeignKey
ALTER TABLE "DiscountRedemption" DROP CONSTRAINT "DiscountRedemption_bookingId_fkey";
-- DropIndex
DROP INDEX "DiscountRedemption_bookingId_key";
-- AlterTable
ALTER TABLE "DiscountRedemption"
ADD COLUMN "stripeSessionId" TEXT,
ADD COLUMN "status" TEXT NOT NULL DEFAULT 'reserved',
ALTER COLUMN "bookingId" DROP NOT NULL;
-- Tabelle ist zu diesem Zeitpunkt leer (siehe vorherige Migration), daher
-- ist ein Backfill für die neue NOT NULL-Spalte stripeSessionId nicht nötig -
-- UPDATE würde ohnehin keine Zeile treffen. Erst danach kann die Spalte auf
-- NOT NULL gesetzt werden, weil ALTER COLUMN ... SET NOT NULL sonst bereits
-- vorhandene NULL-Werte prüfen würde.
ALTER TABLE "DiscountRedemption" ALTER COLUMN "stripeSessionId" SET NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "DiscountRedemption_stripeSessionId_key" ON "DiscountRedemption"("stripeSessionId");
-- CreateIndex
CREATE UNIQUE INDEX "DiscountRedemption_bookingId_key" ON "DiscountRedemption"("bookingId");
-- CreateIndex
CREATE INDEX "DiscountRedemption_status_idx" ON "DiscountRedemption"("status");
-- AddForeignKey
ALTER TABLE "DiscountRedemption" ADD CONSTRAINT "DiscountRedemption_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "CourseBooking"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -269,8 +269,11 @@ model Notification {
// 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 wird ausschließlich im Stripe-Webhook per {increment: 1} erhöht,
// niemals beim Checkout-Start (siehe DiscountRedemption).
// 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
@ -294,20 +297,33 @@ model DiscountCode {
@@index([businessId])
}
// Ein Eintrag pro tatsächlich eingelöstem Rabattcode (nur bei bezahlter Buchung,
// siehe Webhook handleCheckoutCompleted). Der @@unique-Constraint erzwingt
// "1x pro User" race-condition-sicher auf DB-Ebene, unabhängig von Anwendungslogik.
// 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
bookingId String @unique
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: Cascade)
createdAt DateTime @default(now())
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])
}