added marketing-building-blog-v1

This commit is contained in:
Rajpreet0 2026-10-02 17:31:08 +02:00
parent a9f248c91d
commit dc4f08ab0c
71 changed files with 6307 additions and 183 deletions

View file

@ -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;

View file

@ -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;

View 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;

View file

@ -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])
}