mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
added marketing-building-blog-v1
This commit is contained in:
parent
a9f248c91d
commit
dc4f08ab0c
71 changed files with 6307 additions and 183 deletions
|
|
@ -85,7 +85,7 @@ ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_productId_fkey" FOREIGN
|
|||
ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PassAdjustment" ADD CONSTRAINT "PassAdjustment_passId_fkey" FOREIGN KEY ("passId") REFERENCES "CustomerPass"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
-- Eine Karte überlebt das Löschen des Kundenkontos (Kauf, Rechnung und
|
||||
-- Erstattung müssen erhalten bleiben). Die Migration 20261004110000 hatte die
|
||||
-- Regel zuerst als CASCADE angelegt; sie wurde danach im Review auf SET NULL
|
||||
-- geändert, war da aber schon eingespielt. Die Änderung steht deshalb hier als
|
||||
-- eigene Migration, statt die alte Datei umzuschreiben.
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "CustomerPass" DROP CONSTRAINT IF EXISTS "CustomerPass_userId_fkey";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
152
prisma/migrations/20261005100000_marketing/migration.sql
Normal file
152
prisma/migrations/20261005100000_marketing/migration.sql
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "DiscountCode" ADD COLUMN "appliesTo" TEXT NOT NULL DEFAULT 'courses',
|
||||
ADD COLUMN "origin" TEXT NOT NULL DEFAULT 'manual',
|
||||
ADD COLUMN "restrictedToUserId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "DiscountRedemption" ADD COLUMN "passId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "CustomerPass" ADD COLUMN "discountAmount" INTEGER,
|
||||
ADD COLUMN "discountCodeId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MarketingConsent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"businessId" TEXT NOT NULL,
|
||||
"granted" BOOLEAN NOT NULL,
|
||||
"textVersion" TEXT NOT NULL,
|
||||
"source" TEXT NOT NULL,
|
||||
"grantedAt" TIMESTAMP(3),
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "MarketingConsent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MarketingNode" (
|
||||
"id" TEXT NOT NULL,
|
||||
"businessId" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"config" JSONB NOT NULL,
|
||||
"positionX" DOUBLE PRECISION NOT NULL,
|
||||
"positionY" DOUBLE PRECISION NOT NULL,
|
||||
"emailNodeId" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT false,
|
||||
"activatedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "MarketingNode_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MarketingSend" (
|
||||
"id" TEXT NOT NULL,
|
||||
"businessId" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"triggerKind" TEXT NOT NULL,
|
||||
"periodKey" TEXT NOT NULL,
|
||||
"triggerNodeId" TEXT,
|
||||
"emailNodeId" TEXT,
|
||||
"discountCodeId" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"attempts" INTEGER NOT NULL DEFAULT 1,
|
||||
"error" TEXT,
|
||||
"subject" TEXT NOT NULL,
|
||||
"sentAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "MarketingSend_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MarketingAuditLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"businessId" TEXT NOT NULL,
|
||||
"actorType" TEXT NOT NULL,
|
||||
"actorId" TEXT,
|
||||
"action" TEXT NOT NULL,
|
||||
"nodeId" TEXT,
|
||||
"userId" TEXT,
|
||||
"summary" TEXT NOT NULL,
|
||||
"before" JSONB,
|
||||
"after" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "MarketingAuditLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MarketingConsent_businessId_granted_idx" ON "MarketingConsent"("businessId", "granted");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "MarketingConsent_userId_businessId_key" ON "MarketingConsent"("userId", "businessId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MarketingNode_businessId_idx" ON "MarketingNode"("businessId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MarketingNode_emailNodeId_idx" ON "MarketingNode"("emailNodeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "MarketingSend_discountCodeId_key" ON "MarketingSend"("discountCodeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MarketingSend_businessId_createdAt_idx" ON "MarketingSend"("businessId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MarketingSend_triggerNodeId_idx" ON "MarketingSend"("triggerNodeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MarketingSend_emailNodeId_idx" ON "MarketingSend"("emailNodeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "MarketingSend_businessId_userId_triggerKind_periodKey_key" ON "MarketingSend"("businessId", "userId", "triggerKind", "periodKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MarketingAuditLog_businessId_createdAt_idx" ON "MarketingAuditLog"("businessId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MarketingAuditLog_userId_idx" ON "MarketingAuditLog"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "DiscountRedemption_passId_key" ON "DiscountRedemption"("passId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CustomerPass_discountCodeId_idx" ON "CustomerPass"("discountCodeId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DiscountRedemption" ADD CONSTRAINT "DiscountRedemption_passId_fkey" FOREIGN KEY ("passId") REFERENCES "CustomerPass"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_discountCodeId_fkey" FOREIGN KEY ("discountCodeId") REFERENCES "DiscountCode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MarketingConsent" ADD CONSTRAINT "MarketingConsent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MarketingConsent" ADD CONSTRAINT "MarketingConsent_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MarketingNode" ADD CONSTRAINT "MarketingNode_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MarketingNode" ADD CONSTRAINT "MarketingNode_emailNodeId_fkey" FOREIGN KEY ("emailNodeId") REFERENCES "MarketingNode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MarketingSend" ADD CONSTRAINT "MarketingSend_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MarketingSend" ADD CONSTRAINT "MarketingSend_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MarketingSend" ADD CONSTRAINT "MarketingSend_discountCodeId_fkey" FOREIGN KEY ("discountCodeId") REFERENCES "DiscountCode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MarketingAuditLog" ADD CONSTRAINT "MarketingAuditLog_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
|
|
@ -44,6 +44,8 @@ model User {
|
|||
courseWaitlistEntries CourseWaitlistEntry[]
|
||||
discountRedemptions DiscountRedemption[]
|
||||
passes CustomerPass[]
|
||||
marketingConsents MarketingConsent[]
|
||||
marketingSends MarketingSend[]
|
||||
createdAt DateTime? @default(now())
|
||||
updatedAt DateTime? @updatedAt
|
||||
}
|
||||
|
|
@ -138,13 +140,17 @@ model Business {
|
|||
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])
|
||||
manager Manager @relation(fields: [managerId], references: [id])
|
||||
courses Course[]
|
||||
discountCodes DiscountCode[]
|
||||
products Product[]
|
||||
customerPasses CustomerPass[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
marketingNodes MarketingNode[]
|
||||
marketingConsents MarketingConsent[]
|
||||
marketingSends MarketingSend[]
|
||||
marketingAuditLogs MarketingAuditLog[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([managerId])
|
||||
}
|
||||
|
|
@ -338,14 +344,14 @@ model Notification {
|
|||
// als natürlicher Unique-Key, um dasselbe Gerät bei erneutem Subscribe zu
|
||||
// aktualisieren statt zu duplizieren.
|
||||
model PushSubscription {
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid())
|
||||
recipientId String
|
||||
endpoint String @unique
|
||||
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
|
||||
createdAt DateTime @default(now())
|
||||
lastSeenAt DateTime @default(now()) // Bei jedem erneuten Subscribe aktualisiert
|
||||
|
||||
@@index([recipientId])
|
||||
}
|
||||
|
|
@ -359,23 +365,31 @@ model PushSubscription {
|
|||
// 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
|
||||
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])
|
||||
|
|
@ -400,10 +414,12 @@ model DiscountRedemption {
|
|||
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])
|
||||
|
|
@ -417,7 +433,7 @@ model DiscountRedemption {
|
|||
// 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())
|
||||
id String @id @default(uuid())
|
||||
businessId String
|
||||
name String
|
||||
description String?
|
||||
|
|
@ -429,8 +445,8 @@ model Product {
|
|||
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
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([businessId])
|
||||
}
|
||||
|
|
@ -443,7 +459,7 @@ model Product {
|
|||
// 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())
|
||||
id String @id @default(uuid())
|
||||
productId String
|
||||
businessId String
|
||||
// null = noch ohne Konto: vom Studio an eine E-Mail vergeben, oder das Konto
|
||||
|
|
@ -457,33 +473,38 @@ model CustomerPass {
|
|||
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"
|
||||
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
|
||||
stripeSessionId String? @unique
|
||||
stripePaymentIntentId String? @unique
|
||||
amountPaid Int? // inkl. Servicegebühr
|
||||
platformFee Int?
|
||||
stripeFee Int?
|
||||
billingAddress Json?
|
||||
invoiceNumber String? @unique
|
||||
invoiceNumber String? @unique
|
||||
invoiceIssuedAt DateTime?
|
||||
refundedAmount Int?
|
||||
refundedAt DateTime?
|
||||
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)
|
||||
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
|
||||
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
|
||||
|
|
@ -502,3 +523,114 @@ model PassAdjustment {
|
|||
|
||||
@@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])
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue