Merge pull request #174 from Since-3/feature/marketing

added marketing-building-blog-v1
This commit is contained in:
Rajpreet0 2026-10-02 18:04:21 +02:00 • committed by GitHub
commit e5b8705951
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 6575 additions and 185 deletions

View file

@ -18,22 +18,34 @@ Kein Docker: die App ist ein reines Next.js 16 (App Router) Full-Stack-Projekt o
Alle in Vercel unter **Project Settings → Environment Variables**, Scope **"Preview"** (niemals "Production" — das hält Staging- und Produktions-Secrets sauber getrennt).
| Variable | Quelle |
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `DATABASE_URL` | Supabase Staging, Transaction Pooler, Port 6543, `?pgbouncer=true&connection_limit=1` |
| `DIRECT_URL` | Supabase Staging, **Session Pooler** (nicht Direct Connection!), Port 5432 — siehe Stolperfalle unten |
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase Staging Projekt-URL |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase Staging anon key |
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase Staging service_role key |
| `RESEND_API_KEY` / `RESEND_FROM` / `SMTP_TEST_MAIL` | wiederverwendet von lokal |
| `STRIPE_SECRET_KEY` / `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Test-Mode-Keys, wiederverwendet von lokal |
| `STRIPE_WEBHOOK_SECRET` | eigener Wert, siehe Stripe-Webhook unten |
| `STRIPE_PLATFORM_FEE_PERCENT` | gleicher Wert wie lokal |
| `NEXT_PUBLIC_SITE_URL` / `NEXT_PUBLIC_APP_URL` | `https://kumasy-git-dev-kumasy.vercel.app` |
| `COMPANY_NAME` | `"Kumasy"` |
| Variable | Quelle |
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `DATABASE_URL` | Supabase Staging, Transaction Pooler, Port 6543, `?pgbouncer=true&connection_limit=1` |
| `DIRECT_URL` | Supabase Staging, **Session Pooler** (nicht Direct Connection!), Port 5432 — siehe Stolperfalle unten |
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase Staging Projekt-URL |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase Staging anon key |
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase Staging service_role key |
| `RESEND_API_KEY` / `RESEND_FROM` / `SMTP_TEST_MAIL` | wiederverwendet von lokal |
| `STRIPE_SECRET_KEY` / `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Test-Mode-Keys, wiederverwendet von lokal |
| `STRIPE_WEBHOOK_SECRET` | eigener Wert, siehe Stripe-Webhook unten |
| `STRIPE_PLATFORM_FEE_PERCENT` | gleicher Wert wie lokal |
| `NEXT_PUBLIC_SITE_URL` / `NEXT_PUBLIC_APP_URL` | `https://kumasy-git-dev-kumasy.vercel.app` |
| `COMPANY_NAME` | `"Kumasy"` |
| `CRON_SECRET` | eigener Zufallswert (`openssl rand -hex 32`), schützt `/api/cron/marketing`, siehe Marketing-Cron unten |
| `MARKETING_TOKEN_SECRET` | eigener Zufallswert, signiert die Abmeldelinks in Marketing-Mails - nie ändern, sonst gehen alte Links nicht mehr |
Lokales Pendant für einmalige DB-Operationen gegen Staging (z.B. neue Migrationen einspielen): `.env.staging` im Repo-Root, gitignored (`.env*` steht in `.gitignore`), wird nie committed.
## Marketing-Cron
Automatische Marketing-Mails (Geburtstag, Meilenstein, Reaktivierung) verschickt einmal täglich `GET /api/cron/marketing`. Die Route prüft nur den Header `Authorization: Bearer <CRON_SECRET>` - wer sie aufruft, ist egal:
- **Production:** Vercel Cron aus `vercel.json` (täglich 07:00 UTC = 9 Uhr Sommer / 8 Uhr Winter). Vercel setzt den Header selbst, sobald `CRON_SECRET` als Umgebungsvariable existiert. Vercel Cron läuft **nur auf Production-Deployments**.
- **Staging:** Job bei [cron-job.org](https://cron-job.org) auf `https://kumasy-git-dev-kumasy.vercel.app/api/cron/marketing`, täglich, mit dem Header `Authorization: Bearer <CRON_SECRET aus Vercel Preview>`. Ist die Vercel Deployment Protection für Previews an, zusätzlich `x-vercel-protection-bypass: <Bypass-Secret>` (Project Settings → Deployment Protection → Protection Bypass for Automation).
- **Lokal:** `npm run cron:marketing` (liest `CRON_SECRET` aus `.env`). Zum Testen: `-- --dry-run` (zeigt nur, wer was bekäme), `-- --date=2026-05-12` (tut so, als wäre heute dieser Tag), `-- --business=<id>`.
`date` ist in Production gesperrt. Doppelte Aufrufe sind harmlos: pro Studio, Kunde, Trigger-Art und Zeitraum gibt es höchstens eine Mail (Unique-Key in `MarketingSend`).
## Build Command (Vercel Project Settings → Build & Development Settings)
```

177
package-lock.json generated
View file

@ -36,6 +36,7 @@
"@tiptap/extension-underline": "^3.30.5",
"@tiptap/react": "^3.30.5",
"@tiptap/starter-kit": "^3.30.5",
"@xyflow/react": "^12.12.0",
"archiver": "^8.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -7262,6 +7263,15 @@
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
@ -7292,6 +7302,12 @@
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.12",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz",
"integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==",
"license": "MIT"
},
"node_modules/@types/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
@ -7313,6 +7329,25 @@
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.9.tgz",
"integrity": "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@ -8345,6 +8380,76 @@
"license": "Apache-2.0",
"peer": true
},
"node_modules/@xyflow/react": {
"version": "12.12.0",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.12.0.tgz",
"integrity": "sha512-74oXI0Rgm1Eu33cHQAd74J6oVgCH9mbFyTVQbsGgp2kmWwh0HdtBr2VP04z8mbPD+M/xO69nqG5dDEt7/EN2rQ==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.83",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"@types/react": ">=17",
"@types/react-dom": ">=17",
"react": ">=17",
"react-dom": ">=17"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@xyflow/react/node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/@xyflow/system": {
"version": "0.0.83",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.83.tgz",
"integrity": "sha512-a//eDjez3WZF47aaPTfVUDD0OINOq+WVwBZCRTqG+m0a5euX8EwlJ4jMY1Wxz0Ocj4xOeekJ70V22Pq0ll8RJw==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@ -9244,6 +9349,12 @@
"url": "https://polar.sh/cva"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
@ -9644,6 +9755,28 @@
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
@ -9699,6 +9832,15 @@
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
@ -9744,6 +9886,41 @@
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",

View file

@ -13,7 +13,8 @@
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"stripe:listen": "stripe listen --forward-to localhost:3000/api/stripe/webhook --forward-connect-to localhost:3000/api/stripe/webhook"
"stripe:listen": "stripe listen --forward-to localhost:3000/api/stripe/webhook --forward-connect-to localhost:3000/api/stripe/webhook",
"cron:marketing": "node scripts/run-marketing-cron.mjs"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@ -44,6 +45,7 @@
"@tiptap/extension-underline": "^3.30.5",
"@tiptap/react": "^3.30.5",
"@tiptap/starter-kit": "^3.30.5",
"@xyflow/react": "^12.12.0",
"archiver": "^8.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

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

View file

@ -0,0 +1,40 @@
/**
* Löst den Marketing-Lauf von Hand aus - lokal oder gegen eine andere Umgebung.
* Liest CRON_SECRET aus .env und ruft /api/cron/marketing mit dem richtigen
* Header auf (cron-job.org kommt nicht an localhost heran).
*
* Usage:
* npm run cron:marketing # echter Lauf gegen localhost:3000
* npm run cron:marketing -- --dry-run # nur anzeigen, wer was bekäme
* npm run cron:marketing -- --date=2026-05-12 # so tun, als wäre der 12.05.
* npm run cron:marketing -- --business=<id> # nur ein Studio
* npm run cron:marketing -- --url=https://… # andere Umgebung (CRON_SECRET muss passen)
*/
import "dotenv/config";
const args = Object.fromEntries(
process.argv.slice(2).map((arg) => {
const [key, value] = arg.replace(/^--/, "").split("=");
return [key, value ?? "1"];
})
);
const secret = process.env.CRON_SECRET;
if (!secret) {
console.error("CRON_SECRET fehlt in .env");
process.exit(1);
}
const url = new URL("/api/cron/marketing", args.url ?? "http://localhost:3000");
if (args["dry-run"]) url.searchParams.set("dryRun", "1");
if (args.date) url.searchParams.set("date", args.date);
if (args.business) url.searchParams.set("businessId", args.business);
const response = await fetch(url, { headers: { Authorization: `Bearer ${secret}` } });
const body = await response.text();
try {
console.log(JSON.stringify(JSON.parse(body), null, 2));
} catch {
console.log(body);
}
process.exit(response.ok ? 0 : 1);

View file

@ -0,0 +1,15 @@
import { requireManager } from "@/src/lib/auth/getUser";
import { getMarketingEditor } from "@/src/modules/marketing/actions/editor-actions";
import MarketingView from "@/src/modules/marketing/ui/views/marketing-view";
const MarketingPage = async () => {
const manager = await requireManager();
const businesses = manager.businesses.map((b) => ({ id: b.id, name: b.name }));
const result = businesses[0] ? await getMarketingEditor(businesses[0].id) : null;
return (
<MarketingView businesses={businesses} initialData={result?.success ? result.data : null} />
);
};
export default MarketingPage;

View file

@ -0,0 +1,29 @@
import { Suspense } from "react";
import { redirect } from "next/navigation";
import { AlertTriangle } from "lucide-react";
import { isUser, requireAuthWithData } from "@/src/lib/auth/getUser";
import { getMyStudioConsents } from "@/src/modules/marketing/actions/consent-actions";
import StudioEmailsSettingsView from "@/src/modules/marketing/ui/views/studio-emails-settings-view";
import SettingsLoading from "../loading";
/** Kunden: Werbe-Mails pro Studio ein- und ausschalten. */
export default async function SettingsMarketingPage() {
const userData = await requireAuthWithData();
if (!isUser(userData)) redirect("/settings");
const result = await getMyStudioConsents();
if (!result.success) {
return (
<div className="flex gap-2 text-sm bg-red-50 border border-red-200 rounded-lg p-4 text-red-700">
<AlertTriangle size={16} className="shrink-0 mt-0.5" />
<p>{result.error}</p>
</div>
);
}
return (
<Suspense fallback={<SettingsLoading />}>
<StudioEmailsSettingsView initialStudios={result.studios} />
</Suspense>
);
}

33
src/app/abmelden/page.tsx Normal file
View file

@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { prisma } from "@/src/lib/prisma";
import { verifyUnsubscribeToken } from "@/src/modules/marketing/lib/unsubscribe-token";
import UnsubscribeView from "@/src/modules/marketing/ui/views/unsubscribe-view";
export const metadata: Metadata = {
title: "E-Mails abbestellen · Kumasy",
robots: { index: false },
};
/**
* Ziel des Abmeldelinks in jeder Werbe-Mail. Abgemeldet wird erst per Klick auf
* der Seite, nicht schon beim Aufruf - Link-Scanner in Mailprogrammen öffnen
* Links vorab und würden sonst Kunden ungewollt abmelden.
*/
export default async function UnsubscribePage({
searchParams,
}: {
searchParams: Promise<{ t?: string }>;
}) {
const { t } = await searchParams;
const target = verifyUnsubscribeToken(t);
const business = target
? await prisma.business.findUnique({
where: { id: target.businessId },
select: { name: true },
})
: null;
return (
<UnsubscribeView token={target && business ? t! : null} studioName={business?.name ?? null} />
);
}

View file

@ -0,0 +1,65 @@
import { timingSafeEqual } from "crypto";
import { NextRequest, NextResponse } from "next/server";
import { runMarketing } from "@/src/modules/marketing/lib/run-marketing";
// Vercel bricht Funktionen nach maxDuration ab. Der Lauf hört 50 Sekunden
// vorher auf, neue Mails anzufangen - der Rest kommt beim nächsten Lauf.
export const maxDuration = 300;
const RUN_BUDGET_MS = 250_000;
function isAuthorized(header: string | null, secret: string): boolean {
const expected = Buffer.from(`Bearer ${secret}`);
const actual = Buffer.from(header ?? "");
return expected.length === actual.length && timingSafeEqual(expected, actual);
}
/**
* Täglicher Marketing-Lauf (siehe run-marketing.ts).
*
* Aufrufer ist egal, es zählt nur der Header `Authorization: Bearer CRON_SECRET`:
* - Production: Vercel Cron (vercel.json) - Vercel setzt den Header selbst,
* sobald CRON_SECRET als Umgebungsvariable existiert.
* - Staging: ein Job bei cron-job.org mit demselben Header.
* - Lokal: `npm run cron:marketing` (scripts/run-marketing-cron.mjs).
*
* Query-Parameter zum Testen:
* - dryRun=1: zeigt, wer welche Mail bekäme, verschickt nichts
* - businessId=…: nur dieses Studio
* - date=YYYY-MM-DD: tut so, als wäre heute dieser Tag (nicht in Production)
*/
export async function GET(request: NextRequest) {
const secret = process.env.CRON_SECRET;
if (!secret) {
return NextResponse.json({ error: "CRON_SECRET ist nicht gesetzt" }, { status: 500 });
}
if (!isAuthorized(request.headers.get("authorization"), secret)) {
return NextResponse.json({ error: "Nicht autorisiert" }, { status: 401 });
}
const params = request.nextUrl.searchParams;
const date = params.get("date");
if (date) {
if (process.env.VERCEL_ENV === "production") {
return NextResponse.json({ error: "date ist in Production nicht erlaubt" }, { status: 400 });
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || Number.isNaN(Date.parse(date))) {
return NextResponse.json({ error: "date muss YYYY-MM-DD sein" }, { status: 400 });
}
}
try {
const report = await runMarketing({
today: date ?? undefined,
dryRun: params.get("dryRun") === "1",
businessId: params.get("businessId") ?? undefined,
deadline: Date.now() + RUN_BUDGET_MS,
});
return NextResponse.json(report);
} catch (error) {
console.error("Marketing-Lauf fehlgeschlagen:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Marketing-Lauf fehlgeschlagen" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server";
import { setMarketingConsent } from "@/src/modules/marketing/lib/consent";
import { verifyUnsubscribeToken } from "@/src/modules/marketing/lib/unsubscribe-token";
/**
* Ein-Klick-Abmeldung aus dem Mailprogramm (RFC 8058). Gmail, Apple Mail & Co.
* schicken einen POST an die Adresse aus dem List-Unsubscribe-Header - ohne
* Cookies, ohne Rückfrage. Das signierte Token reicht als Berechtigung.
*/
export async function POST(request: NextRequest) {
const target = verifyUnsubscribeToken(request.nextUrl.searchParams.get("t"));
if (!target) {
return NextResponse.json({ error: "Ungültiger Abmeldelink" }, { status: 400 });
}
try {
await setMarketingConsent({ ...target, granted: false, source: "unsubscribe_link" });
return NextResponse.json({ unsubscribed: true });
} catch (error) {
console.error("Ein-Klick-Abmeldung fehlgeschlagen:", error);
return NextResponse.json({ error: "Abmeldung fehlgeschlagen" }, { status: 500 });
}
}
/**
* Wer die Adresse direkt öffnet, landet auf der Abmelde-Seite. Ein GET meldet
* bewusst nicht ab: Link-Scanner in Mailprogrammen rufen Links vorab auf.
*/
export function GET(request: NextRequest) {
const url = request.nextUrl.clone();
url.pathname = "/abmelden";
return NextResponse.redirect(url);
}

View file

@ -20,6 +20,7 @@ import {
} from "@/src/modules/courses/lib/course-spots";
import { MAX_SEATS_PER_BOOKING, paidSeatCount } from "@/src/modules/courses/lib/seat-counts";
import { spotDisplayName, type FloorPlanItem } from "@/src/modules/rooms/lib/floor-plan";
import { grantConsentQuietly } from "@/src/modules/marketing/lib/consent";
/**
* Pro Person und Kurs läuft höchstens ein offener Checkout mit Platz. Startet
@ -134,6 +135,8 @@ export async function POST(request: NextRequest) {
spotIds?: string[];
/** Alter Client vor der Mehrfachbuchung: ein einzelner Platz. */
spotId?: string;
/** Häkchen für Werbe-Mails des Studios (Opt-in), nur mit Konto. */
marketingConsent?: boolean;
};
const { courseId, guestEmail, guestName, discountCode } = body;
const seats = body.seats ?? 1;
@ -532,6 +535,16 @@ export async function POST(request: NextRequest) {
throw error;
}
// Das Häkchen hat der Kunde beim Klick auf "Buchen" gesetzt - es gilt
// unabhängig davon, ob er die Zahlung danach abschließt.
if (!isGuest && body.marketingConsent === true) {
await grantConsentQuietly({
userId: userData!.id,
businessId: course.business.id,
source: "course_booking",
});
}
// Damit ein späterer Checkout derselben Person diese Session beenden kann
// (siehe endOpenCheckouts). Scheitert das, bleibt der Platz trotzdem bis
// zum Ablauf gehalten - kein Grund, die fertige Session zu verwerfen.

View file

@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { prisma } from "@/src/lib/prisma";
import { getPlatformFeeCents } from "@/src/lib/stripe";
import { createBrandedCheckoutSession, studioCheckoutBranding } from "@/src/lib/stripe-checkout";
@ -6,6 +7,12 @@ import { getUserData, isUser } from "@/src/lib/auth/getUser";
import { cardServiceFeeCents } from "@/src/modules/products/lib/card-pricing";
import { formatValidity } from "@/src/modules/products/lib/card-format";
import { PASS_CHECKOUT_KIND } from "@/src/modules/products/lib/pass-webhook";
import {
calculateDiscountedPriceCents,
releaseDiscountReservation,
reserveDiscountCode,
} from "@/src/modules/settings/lib/discount-validation";
import { grantConsentQuietly } from "@/src/modules/marketing/lib/consent";
/**
* Erstellt eine Stripe Checkout Session für den Kauf einer Karte.
@ -18,8 +25,18 @@ import { PASS_CHECKOUT_KIND } from "@/src/modules/products/lib/pass-webhook";
* Nur für eingeloggte Kunden: eine Karte hängt an einem Konto, mit dem später
* gebucht wird.
*
* Body: { productId: string }
* Ein Rabattcode (DiscountCode.appliesTo "passes" oder "all") mindert nur den
* Kartenpreis, nie die Servicegebühr. Er wird wie bei Kursen schon hier
* reserviert und erst im Webhook bestätigt.
*
* Body: { productId: string; discountCode?: string; marketingConsent?: boolean }
*/
const bodySchema = z.object({
productId: z.string().min(1),
discountCode: z.string().optional(),
marketingConsent: z.boolean().optional(),
});
export async function POST(request: NextRequest) {
try {
const userData = await getUserData();
@ -38,10 +55,11 @@ export async function POST(request: NextRequest) {
);
}
const { productId } = (await request.json()) as { productId?: string };
if (!productId) {
return NextResponse.json({ error: "productId fehlt" }, { status: 400 });
const body = bodySchema.safeParse(await request.json().catch(() => null));
if (!body.success) {
return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 });
}
const { productId, discountCode, marketingConsent } = body.data;
const product = await prisma.product.findUnique({
where: { id: productId },
@ -82,6 +100,38 @@ export async function POST(request: NextRequest) {
const feeCents = cardServiceFeeCents(product.credits, feePerUnitCents);
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
let cardPriceCents = product.priceCents;
let discountAmountCents = 0;
let appliedDiscountCodeId: string | null = null;
let reservationToken: string | null = null;
if (discountCode?.trim()) {
const reservation = await reserveDiscountCode(prisma, {
code: discountCode,
businessId: business.id,
productId: product.id,
userId: userData.id,
});
if (!reservation.reserved) {
return NextResponse.json({ error: reservation.reason }, { status: 400 });
}
const result = calculateDiscountedPriceCents(product.priceCents, reservation.discountCode);
// Wie bei Kursen: eine 0-€-Karte hätte keinen PaymentIntent, der Webhook
// könnte den Kauf nicht bestätigen.
if (result.discountedPriceCents <= 0) {
await releaseDiscountReservation(prisma, reservation.reservationToken);
return NextResponse.json(
{
error: "Dieser Rabattcode reduziert den Preis auf 0 € und kann nicht verwendet werden.",
},
{ status: 400 }
);
}
cardPriceCents = result.discountedPriceCents;
discountAmountCents = result.discountAmountCents;
appliedDiscountCodeId = reservation.discountCode.id;
reservationToken = reservation.reservationToken;
}
// Alles, was der Webhook braucht, um die Karte anzulegen. Liegt an Session
// UND PaymentIntent, damit es auch im Stripe-Dashboard an der Zahlung steht.
const metadata = {
@ -90,57 +140,83 @@ export async function POST(request: NextRequest) {
userId: userData.id,
businessId: business.id,
platformFeeModel: "on_top",
discountCodeId: appliedDiscountCodeId ?? "",
discountAmountCents: String(discountAmountCents),
discountReservationToken: reservationToken ?? "",
};
const session = await createBrandedCheckoutSession(
{
mode: "payment",
// Rechnungsadresse ist Pflicht - sie steht auf der Rechnung zum Kartenkauf.
billing_address_collection: "required",
line_items: [
{
price_data: {
currency: "eur",
product_data: {
name: product.name,
description: `${business.name} - ${product.credits} ${
product.credits === 1 ? "Einheit" : "Einheiten"
} · ${formatValidity(product.validityMonths)}`,
},
unit_amount: product.priceCents,
},
quantity: 1,
},
// Servicegebühr pro Einheit - eine 10er-Karte kostet so viel Gebühr wie
// zehn Einzelbuchungen. Bei 0 entfällt die Position ganz.
...(feeCents > 0
? [
{
price_data: {
currency: "eur" as const,
product_data: {
name: "Servicegebühr",
description: "Gebühr für die Online-Buchung, pro Einheit",
},
unit_amount: feePerUnitCents,
},
quantity: product.credits,
let session;
try {
session = await createBrandedCheckoutSession(
{
mode: "payment",
// Rechnungsadresse ist Pflicht - sie steht auf der Rechnung zum Kartenkauf.
billing_address_collection: "required",
line_items: [
{
price_data: {
currency: "eur",
product_data: {
name: product.name,
description: `${business.name} - ${product.credits} ${
product.credits === 1 ? "Einheit" : "Einheiten"
} · ${formatValidity(product.validityMonths)}`,
},
]
: []),
],
payment_intent_data: {
...(feeCents > 0 ? { application_fee_amount: feeCents } : {}),
unit_amount: cardPriceCents,
},
quantity: 1,
},
// Servicegebühr pro Einheit - eine 10er-Karte kostet so viel Gebühr wie
// zehn Einzelbuchungen. Bei 0 entfällt die Position ganz.
...(feeCents > 0
? [
{
price_data: {
currency: "eur" as const,
product_data: {
name: "Servicegebühr",
description: "Gebühr für die Online-Buchung, pro Einheit",
},
unit_amount: feePerUnitCents,
},
quantity: product.credits,
},
]
: []),
],
payment_intent_data: {
...(feeCents > 0 ? { application_fee_amount: feeCents } : {}),
metadata,
},
metadata,
customer_email: userData.email,
success_url: `${baseUrl}/courses/myCards?purchased=1`,
cancel_url: business.slug ? `${baseUrl}/business/${business.slug}` : baseUrl,
},
metadata,
customer_email: userData.email,
success_url: `${baseUrl}/courses/myCards?purchased=1`,
cancel_url: business.slug ? `${baseUrl}/business/${business.slug}` : baseUrl,
},
studioCheckoutBranding(business),
business.stripeAccountId
);
studioCheckoutBranding(business),
business.stripeAccountId
);
} catch (error) {
// Nie angelegte Session meldet auch kein checkout.session.expired - die
// Reservierung muss hier zurück.
if (reservationToken) {
await releaseDiscountReservation(prisma, reservationToken).catch((releaseError) =>
console.error(
`Rabattcode-Reservierung ${reservationToken} nach fehlgeschlagenem Kartenkauf nicht freigegeben:`,
releaseError
)
);
}
throw error;
}
if (marketingConsent === true) {
await grantConsentQuietly({
userId: userData.id,
businessId: business.id,
source: "pass_purchase",
});
}
return NextResponse.json({ url: session.url });
} catch (error) {

View file

@ -30,6 +30,7 @@ import {
import { MAX_SEATS_PER_BOOKING, paidSeatCount } from "@/src/modules/courses/lib/seat-counts";
import {
handlePassCheckoutCompleted,
handlePassCheckoutUnpaid,
isPassCheckout,
} from "@/src/modules/products/lib/pass-webhook";
import { reconcilePassRefund } from "@/src/modules/products/lib/pass-refund";
@ -108,9 +109,11 @@ export async function POST(request: NextRequest) {
}
case "checkout.session.expired":
case "checkout.session.async_payment_failed": {
// Ein Kartenkauf reserviert nichts (keine Plätze, kein Rabattcode) - nie
// bezahlt heißt einfach: keine Karte.
if (!isPassCheckout(event.data.object)) {
// Ein Kartenkauf hält keine Plätze - nie bezahlt heißt: keine Karte,
// nur ein reservierter Rabattcode wird wieder frei.
if (isPassCheckout(event.data.object)) {
await handlePassCheckoutUnpaid(event.data.object);
} else {
await handleCheckoutUnpaid(event.data.object);
}
break;

View file

@ -26,6 +26,10 @@ export function getResend(): Resend {
* @param subject - E-Mail Betreff
* @param html - HTML-Inhalt der E-Mail
* @param text - Plain-Text Alternative (optional)
* @param fromName - Anzeigename statt dem aus RESEND_FROM, die Adresse bleibt (optional)
* @param replyTo - Antwortadresse, z.B. die des Studios (optional)
* @param headers - Zusätzliche Header wie List-Unsubscribe (optional)
* @param idempotencyKey - Resend verschickt dieselbe Mail innerhalb von 24 h nur einmal (optional)
* @returns Promise mit dem Ergebnis des Versands
*/
export async function sendMail({
@ -33,20 +37,34 @@ export async function sendMail({
subject,
html,
text,
fromName,
replyTo,
headers,
idempotencyKey,
}: {
to: string;
subject: string;
html: string;
text?: string;
fromName?: string;
replyTo?: string;
headers?: Record<string, string>;
idempotencyKey?: string;
}) {
try {
const { data, error } = await getResend().emails.send({
from: process.env.RESEND_FROM || "",
const defaultFrom = process.env.RESEND_FROM || "";
const payload = {
from: fromName ? withDisplayName(defaultFrom, fromName) : defaultFrom,
to,
subject,
html,
text: text || stripHtml(html), // Fallback: HTML zu Text konvertieren
});
...(replyTo ? { replyTo } : {}),
...(headers ? { headers } : {}),
};
const { data, error } = idempotencyKey
? await getResend().emails.send(payload, { idempotencyKey })
: await getResend().emails.send(payload);
if (error) {
console.error("Fehler beim E-Mail-Versand:", error);
@ -61,6 +79,17 @@ export async function sendMail({
}
}
/**
* Setzt einen anderen Anzeigenamen vor die Absenderadresse aus RESEND_FROM
* ("Kumasy <noreply@…>" → "Studio X über Kumasy <noreply@…>"). Zeichen, die
* den Header aufbrechen könnten, fliegen raus.
*/
export function withDisplayName(from: string, displayName: string): string {
const address = from.match(/<([^>]+)>/)?.[1] ?? from.trim();
const safeName = displayName.replace(/[\r\n"<>\\]/g, "").trim();
return safeName ? `"${safeName}" <${address}>` : address;
}
/**
* Einfache Funktion zum Entfernen von HTML-Tags (für Text-Alternative)
*/

View file

@ -17,6 +17,12 @@ export interface RenderEmailLayoutParams {
/** Fertiger Inner-Content, wird unverändert in die Content-Zelle eingesetzt. */
bodyHtml: string;
branding?: EmailBranding;
/**
* Ersetzt den Hinweis "automatisch generiert, bitte nicht antworten" im
* Footer - z.B. bei Werbe-Mails, auf die Kunden antworten dürfen und die
* einen Abmeldelink brauchen. Vom Aufrufer bereits escaped.
*/
footerNoteHtml?: string;
}
export function renderEmailLayout({
@ -25,6 +31,7 @@ export function renderEmailLayout({
bannerHtml,
bodyHtml,
branding = KUMASY_BRANDING,
footerNoteHtml,
}: RenderEmailLayoutParams): string {
const safeTitle = escapeHtml(title);
const safeCompanyName = escapeHtml(branding.companyName);
@ -75,7 +82,7 @@ export function renderEmailLayout({
${safeCompanyName}
</p>
<p style="margin: 0 0 10px; color: ${EMAIL_TOKENS.colors.textMuted}; font-size: 12px;">
Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht auf diese E-Mail.
${footerNoteHtml ?? "Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht auf diese E-Mail."}
</p>
<p style="margin: 0; color: ${EMAIL_TOKENS.colors.textMuted}; font-size: 11px;">
Powered by Kumasy

View file

@ -0,0 +1,114 @@
/**
* E-Mail-Template für automatische Marketing-Mails eines Studios (Geburtstag,
* Meilenstein, Reaktivierung).
*
* Betreff und Text schreibt das Studio selbst (Platzhalter sind hier schon
* ersetzt). Das Template setzt den Text in Absätze, hängt einen eventuellen
* persönlichen Rabattcode als Box an und ersetzt den "Bitte nicht antworten"-
* Hinweis durch den Abmeldelink - Antworten gehen per Reply-To an das Studio.
*/
import { escapeHtml, sanitizeUrl, singleLine } from "./utils";
import { renderButton, renderInfoBox } from "./components";
import { renderEmailLayout } from "./layout";
import { EMAIL_TOKENS, KUMASY_BRANDING, type EmailBranding } from "./tokens";
export interface MarketingEmailParams {
subject: string;
body: string;
studioName: string;
reward: {
code: string;
/** "15 %" oder "5,00 €" */
valueLabel: string;
/** "Kurse", "Karten" oder "Kurse und Karten" */
appliesToLabel: string;
/** "31.10.2026" */
validUntilLabel: string;
} | null;
/** Öffentliche Studio-Seite für den Button, null = kein Button */
bookingUrl: string | null;
unsubscribeUrl: string;
branding?: EmailBranding;
}
function paragraphsToHtml(text: string): string {
return text
.split(/\n{2,}/)
.map((paragraph) => paragraph.trim())
.filter(Boolean)
.map(
(paragraph) =>
`<p style="margin: 0 0 20px; color: ${EMAIL_TOKENS.colors.textPrimary}; font-size: 16px; line-height: 1.6;">${escapeHtml(
paragraph
).replace(/\n/g, "<br>")}</p>`
)
.join("\n");
}
export function generateMarketingEmail({
subject,
body,
studioName,
reward,
bookingUrl,
unsubscribeUrl,
branding = KUMASY_BRANDING,
}: MarketingEmailParams): { subject: string; html: string; text: string } {
const safeSubject = singleLine(subject);
const safeStudioName = escapeHtml(studioName);
const safeUnsubscribeUrl = sanitizeUrl(unsubscribeUrl);
const rewardHtml = reward
? renderInfoBox({
rows: [
{
label: "Ihr persönlicher Code",
valueHtml: `<span style="font-size: 24px; font-weight: 700; letter-spacing: 1px; font-family: 'SFMono-Regular', Menlo, Consolas, monospace;">${escapeHtml(
reward.code
)}</span>`,
},
{
label: "Rabatt",
valueHtml: `${escapeHtml(reward.valueLabel)} auf ${escapeHtml(reward.appliesToLabel)}`,
},
{ label: "Gültig bis", valueHtml: escapeHtml(reward.validUntilLabel) },
],
}) +
`<p style="margin: -16px 0 30px; color: ${EMAIL_TOKENS.colors.textSecondary}; font-size: 13px;">Einmal einlösbar, nur mit Ihrem Kundenkonto. Geben Sie den Code beim Buchen ein.</p>`
: "";
const bodyHtml = `
${paragraphsToHtml(body)}
${rewardHtml}
${bookingUrl ? renderButton({ label: "Jetzt buchen", href: bookingUrl, branding }) : ""}
`;
const footerNoteHtml =
`Sie erhalten diese E-Mail, weil Sie Angebote von ${safeStudioName} abonniert haben. ` +
`<a href="${safeUnsubscribeUrl}" style="color: ${EMAIL_TOKENS.colors.textMuted}; text-decoration: underline;">Abmelden</a>`;
const html = renderEmailLayout({
title: safeSubject,
headerHeading: escapeHtml(safeSubject),
bodyHtml,
branding,
footerNoteHtml,
});
const rewardText = reward
? `\nIhr persönlicher Code: ${reward.code}\nRabatt: ${reward.valueLabel} auf ${reward.appliesToLabel}\nGültig bis: ${reward.validUntilLabel}\nEinmal einlösbar, nur mit Ihrem Kundenkonto.\n`
: "";
const text = `${body.trim()}
${rewardText}${bookingUrl ? `\nJetzt buchen: ${sanitizeUrl(bookingUrl)}\n` : ""}
--
${studioName}
Sie erhalten diese E-Mail, weil Sie Angebote von ${studioName} abonniert haben.
Abmelden: ${safeUnsubscribeUrl}
Powered by Kumasy
`;
return { subject: safeSubject, html, text };
}

View file

@ -27,6 +27,8 @@ import {
findUsablePassesForCourse,
type CustomerPassView,
} from "@/src/modules/products/lib/pass-queries";
import { hasMarketingConsent } from "@/src/modules/marketing/lib/consent";
import { consentLabel } from "@/src/modules/marketing/lib/consent-text";
export type UserBooking = {
bookingId: string;
@ -266,7 +268,14 @@ export async function checkUserBookingStatus(courseId: string) {
// Alle Checks im selben Aufruf statt separater Wartelisten-/Karten-Calls,
// damit der Dialog weiterhin nur einen Roundtrip beim Öffnen braucht.
const [booking, waitlistEntry, passes] = await Promise.all([
const customerCourse = isUser(userData)
? await prisma.course.findUnique({
where: { id: courseId },
select: { businessId: true, sport: true, business: { select: { name: true } } },
})
: null;
const [booking, waitlistEntry, passes, consentGranted] = await Promise.all([
prisma.courseBooking.findUnique({
where: { courseId_userId: { courseId, userId: userData.id } },
}),
@ -274,11 +283,12 @@ export async function checkUserBookingStatus(courseId: string) {
where: { courseId_userId: { courseId, userId: userData.id } },
}),
// Karten, mit denen der Kunde diesen Kurs ohne Bezahlen buchen kann.
isUser(userData)
? prisma.course
.findUnique({ where: { id: courseId }, select: { businessId: true, sport: true } })
.then((course) => (course ? findUsablePassesForCourse(userData, course) : []))
customerCourse && isUser(userData)
? findUsablePassesForCourse(userData, customerCourse)
: Promise.resolve([] as CustomerPassView[]),
customerCourse?.businessId
? hasMarketingConsent(userData.id, customerCourse.businessId)
: Promise.resolve(true),
]);
return {
@ -290,6 +300,12 @@ export async function checkUserBookingStatus(courseId: string) {
bookedSeats: booking?.paymentStatus === "paid" ? booking.seats : 0,
isOnWaitlist: waitlistEntry !== null,
passes,
// Häkchen für Werbe-Mails des Studios - nur solange der Kunde noch nicht
// zugestimmt hat (Opt-in, standardmäßig leer).
marketingConsentLabel:
customerCourse?.business && !consentGranted
? consentLabel(customerCourse.business.name)
: null,
};
} catch (error) {
console.error("Error checking booking status:", error);

View file

@ -38,6 +38,7 @@ import { spotDisplayName } from "@/src/modules/rooms/lib/floor-plan";
import { MAX_SEATS_PER_BOOKING } from "../../lib/seat-counts";
import { bookCourseWithPass } from "@/src/modules/products/actions/pass-booking-actions";
import type { CustomerPassView } from "@/src/modules/products/lib/pass-queries";
import MarketingConsentCheckbox from "@/src/modules/marketing/ui/components/MarketingConsentCheckbox";
// useSyncExternalStore: liefert `false` auf dem Server (server snapshot) und
// `true` im Browser. Damit haben Server- und initialer Client-Render denselben
@ -215,6 +216,10 @@ export const BookingDialog = ({
const [passes, setPasses] = useState<CustomerPassView[]>([]);
const [selectedPassId, setSelectedPassId] = useState<string | null>(null);
const [highlightPicker, setHighlightPicker] = useState(false);
// Werbe-Mails des Studios: Text des Häkchens, solange der Kunde noch nicht
// zugestimmt hat (null = schon zugestimmt oder kein Kundenkonto).
const [consentText, setConsentText] = useState<string | null>(null);
const [consentChecked, setConsentChecked] = useState(false);
const spotPickerRef = useRef<HTMLDivElement>(null);
const isAdminView = paymentStatus !== undefined;
@ -249,6 +254,9 @@ export const BookingDialog = ({
const usablePasses = "passes" in result ? (result.passes ?? []) : [];
setPasses(usablePasses);
setSelectedPassId(usablePasses[0]?.id ?? null);
setConsentText(
"marketingConsentLabel" in result ? (result.marketingConsentLabel ?? null) : null
);
// Gäste sehen das Gast-Formular sofort, statt erst nach einem
// gescheiterten Buchungsversuch.
if (result.isLoggedIn === false) setShowGuestForm(true);
@ -424,6 +432,7 @@ export const BookingDialog = ({
passId,
seats,
spotIds: spotMode ? selectedSpots.map((s) => s.id) : undefined,
marketingConsent: consentText !== null && consentChecked,
});
if (!result.success) {
toast.error(result.error);
@ -456,8 +465,12 @@ export const BookingDialog = ({
}
setIsSubmitting(true);
try {
const body: Record<string, string | number | string[]> = { courseId: course.id, seats };
const body: Record<string, string | number | string[] | boolean> = {
courseId: course.id,
seats,
};
if (appliedDiscount) body.discountCode = appliedDiscount.code;
if (!opts?.asGuest && consentText !== null && consentChecked) body.marketingConsent = true;
if (spotMode) body.spotIds = selectedSpots.map((s) => s.id);
if (opts?.asGuest) {
const normalizedEmail = guestEmail.trim().toLowerCase();
@ -864,6 +877,18 @@ export const BookingDialog = ({
</Button>
</div>
)}
{!isAdminView &&
!isCheckingStatus &&
!isFull &&
!isBooked &&
!showGuestForm &&
consentText !== null && (
<MarketingConsentCheckbox
label={consentText}
checked={consentChecked}
onCheckedChange={setConsentChecked}
/>
)}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="flex flex-col">
{payWithPass && !isAdminView && !isBooked ? (

View file

@ -16,6 +16,7 @@ import {
Dumbbell,
IdCardLanyard,
LayoutDashboard,
Megaphone,
User,
WalletCards,
} from "lucide-react";
@ -94,6 +95,12 @@ const allMenuItems: MenuItem[] = [
href: "/zahlungen",
roles: ["manager"],
},
{
icon: Megaphone,
label: "Marketing",
href: "/marketing",
roles: ["manager"],
},
{
icon: DoorOpen,
label: "Räume",

View file

@ -89,6 +89,16 @@ export type KundeDetail = {
createdAt: Date | null;
bookings: KundeBooking[];
passes: KundePass[];
/** Einwilligung in Werbe-Mails, pro Studio des Managers mit einem Eintrag. */
marketingConsents: KundeMarketingConsent[];
};
export type KundeMarketingConsent = {
studioName: string;
granted: boolean;
/** Zeitpunkt der letzten Erteilung bzw. des letzten Widerrufs */
changedAt: string | null;
source: string;
};
export async function getKundeDetail(
@ -151,6 +161,16 @@ export async function getKundeDetail(
},
orderBy: { createdAt: "desc" },
},
marketingConsents: {
where: { businessId: { in: businessIds } },
select: {
granted: true,
grantedAt: true,
revokedAt: true,
source: true,
business: { select: { name: true } },
},
},
passes: {
where: { businessId: { in: businessIds } },
orderBy: { createdAt: "desc" },
@ -289,6 +309,13 @@ export async function getKundeDetail(
pbSrc: userWithBookings.pbSrc,
createdAt: userWithBookings.createdAt,
bookings,
marketingConsents: userWithBookings.marketingConsents.map((consent) => ({
studioName: consent.business.name,
granted: consent.granted,
changedAt:
(consent.granted ? consent.grantedAt : consent.revokedAt)?.toISOString() ?? null,
source: consent.source,
})),
passes: userWithBookings.passes.map((pass) => {
const branding = businessBrandingSchema.safeParse(pass.business.branding);
return {

View file

@ -16,8 +16,9 @@ import {
Bell,
Scale,
CheckCircle2,
Megaphone,
} from "lucide-react";
import type { KundeDetail } from "../../actions/kunde-detail-actions";
import type { KundeDetail, KundeMarketingConsent } from "../../actions/kunde-detail-actions";
import KundeBookingCard from "../components/kunde-booking-card";
import KundePassesSection from "@/src/modules/products/ui/components/KundePassesSection";
import type { PassIssueOption } from "@/src/modules/products/actions/pass-admin-actions";
@ -27,6 +28,28 @@ import type { PassIssueOption } from "@/src/modules/products/actions/pass-admin-
const getDiceBearUrl = (seed: string) =>
`https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent(`u-${seed.slice(0, 6)}`)}`;
// ─── Einwilligung in Werbe-Mails ─────────────────────────────────────────────
// Ohne Eintrag hat der Kunde nie zugestimmt. Bei mehreren Studios steht der
// Name davor.
const formatMarketingConsent = (consents: KundeMarketingConsent[]) => {
if (consents.length === 0) return "Nicht zugestimmt";
return consents
.map((consent) => {
const date = consent.changedAt
? new Date(consent.changedAt).toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
})
: null;
const state = consent.granted
? `Zugestimmt${date ? ` am ${date}` : ""}`
: `Abgemeldet${date ? ` am ${date}` : ""}`;
return consents.length > 1 ? `${consent.studioName}: ${state}` : state;
})
.join(" · ");
};
// ─── ID kürzen: erste 8 Zeichen + … ──────────────────────────────────────────
const shortenId = (id: string) => `${id.slice(0, 8)}…${id.slice(-4)}`;
@ -162,6 +185,11 @@ const KundeDetailView = ({ kunde, issueOptions }: KundeDetailViewProps) => {
value={kunde.gender ? (genderLabel[kunde.gender] ?? kunde.gender) : "–"}
/>
<InfoRow icon={<Calendar size={15} />} label="Geburtsdatum" value={birthdayFormatted} />
<InfoRow
icon={<Megaphone size={15} />}
label="Werbe-Mails"
value={formatMarketingConsent(kunde.marketingConsents)}
/>
{/* Kunden-ID über volle Breite */}
<div className="sm:col-span-2 flex items-start gap-2.5">
<span className="mt-0.5 text-blue shrink-0">

View file

@ -0,0 +1,114 @@
"use server";
import { prisma } from "@/src/lib/prisma";
import { getUserData, isUser } from "@/src/lib/auth/getUser";
import { setMarketingConsent } from "../lib/consent";
import { consentLabel } from "../lib/consent-text";
import { verifyUnsubscribeToken } from "../lib/unsubscribe-token";
/** Abmelde-Seite: widerruft die Einwilligung, die zum signierten Link gehört. */
export async function unsubscribeWithToken(
token: string
): Promise<{ success: true; studioName: string } | { success: false; error: string }> {
const target = verifyUnsubscribeToken(token);
if (!target) return { success: false, error: "Dieser Abmeldelink ist ungültig." };
try {
const business = await prisma.business.findUnique({
where: { id: target.businessId },
select: { name: true },
});
if (!business) return { success: false, error: "Dieses Studio gibt es nicht mehr." };
await setMarketingConsent({ ...target, granted: false, source: "unsubscribe_link" });
return { success: true, studioName: business.name };
} catch (error) {
console.error("Abmeldung fehlgeschlagen:", error);
return { success: false, error: "Die Abmeldung hat nicht geklappt. Bitte erneut versuchen." };
}
}
export type StudioConsentRow = {
businessId: string;
studioName: string;
granted: boolean;
label: string;
};
/**
* Studios, bei denen der Kunde gebucht oder eine Karte hat, mit dem Stand
* seiner Einwilligung. Grundlage für die Einstellungsseite "E-Mails von Studios".
*/
export async function getMyStudioConsents(): Promise<
{ success: true; studios: StudioConsentRow[] } | { success: false; error: string }
> {
const userData = await getUserData();
if (!userData || !isUser(userData)) {
return { success: false, error: "Nur für Kundenkonten." };
}
try {
const businesses = await prisma.business.findMany({
where: {
OR: [
{ courses: { some: { bookings: { some: { userId: userData.id } } } } },
{ customerPasses: { some: { userId: userData.id } } },
{ marketingConsents: { some: { userId: userData.id } } },
],
},
select: {
id: true,
name: true,
marketingConsents: { where: { userId: userData.id }, select: { granted: true } },
},
orderBy: { name: "asc" },
});
return {
success: true,
studios: businesses.map((b) => ({
businessId: b.id,
studioName: b.name,
granted: b.marketingConsents[0]?.granted ?? false,
label: consentLabel(b.name),
})),
};
} catch (error) {
console.error("Einwilligungen konnten nicht geladen werden:", error);
return { success: false, error: "Die Einstellungen konnten nicht geladen werden." };
}
}
export async function setMyStudioConsent(
businessId: string,
granted: boolean
): Promise<{ success: true } | { success: false; error: string }> {
const userData = await getUserData();
if (!userData || !isUser(userData)) {
return { success: false, error: "Nur für Kundenkonten." };
}
try {
// Einwilligen geht nur bei Studios, mit denen der Kunde schon zu tun hatte.
// Widerrufen geht immer.
if (granted) {
const isCustomer = await prisma.business.count({
where: {
id: businessId,
OR: [
{ courses: { some: { bookings: { some: { userId: userData.id } } } } },
{ customerPasses: { some: { userId: userData.id } } },
{ marketingConsents: { some: { userId: userData.id } } },
],
},
});
if (isCustomer === 0) return { success: false, error: "Studio nicht gefunden." };
}
await setMarketingConsent({ userId: userData.id, businessId, granted, source: "profile" });
return { success: true };
} catch (error) {
console.error("Einwilligung konnte nicht gespeichert werden:", error);
return { success: false, error: "Die Einstellung konnte nicht gespeichert werden." };
}
}

View file

@ -0,0 +1,455 @@
"use server";
import { revalidatePath } from "next/cache";
import type { Prisma } from "@/src/generated/prisma/client";
import { prisma } from "@/src/lib/prisma";
import { requireManager, type ManagerData } from "@/src/lib/auth/getUser";
import { sendMail } from "@/src/lib/mail/resend";
import { addDaysToKey, berlinDayEnd, dayKey } from "@/src/modules/courses/lib/course-days";
import { writeAuditLog } from "../lib/audit-log";
import { appBaseUrl, composeMarketingEmail } from "../lib/compose-email";
import {
diffGraph,
isEffective,
normalizeGraph,
validateGraph,
type GraphNode,
} from "../lib/graph";
import { emailConfigSchema, isTriggerKind, type NodeKind } from "../lib/node-config";
export type EditorNode = GraphNode & {
/** Verschickte Mails über diesen Baustein */
sent: number;
/** Nur E-Mails: eingelöste Codes */
redeemed: number;
/** Nur Trigger: ab wann er zählt (ISO), null = nicht wirksam */
activatedAt: string | null;
};
export type EditorData = {
nodes: EditorNode[];
/** Kunden, die Werbe-Mails dieses Studios zugestimmt haben */
consentingCustomers: number;
};
type ActionResult<T> = ({ success: true } & T) | { success: false; error: string };
function ownsBusiness(manager: ManagerData, businessId: string): boolean {
return manager.businesses.some((b) => b.id === businessId);
}
function toGraphNode(row: {
id: string;
kind: string;
config: unknown;
positionX: number;
positionY: number;
emailNodeId: string | null;
isActive: boolean;
}): GraphNode {
return {
id: row.id,
kind: row.kind as NodeKind,
config: (row.config ?? {}) as Record<string, unknown>,
position: { x: row.positionX, y: row.positionY },
emailNodeId: row.emailNodeId,
isActive: row.isActive,
};
}
async function loadEditor(businessId: string): Promise<EditorData> {
const [rows, consentingCustomers, sentByEmail, sentByTrigger, redeemedByEmail] =
await Promise.all([
prisma.marketingNode.findMany({
where: { businessId },
orderBy: { createdAt: "asc" },
}),
prisma.marketingConsent.count({ where: { businessId, granted: true } }),
prisma.marketingSend.groupBy({
by: ["emailNodeId"],
where: { businessId, status: "sent" },
_count: { _all: true },
}),
prisma.marketingSend.groupBy({
by: ["triggerNodeId"],
where: { businessId, status: "sent" },
_count: { _all: true },
}),
prisma.marketingSend.groupBy({
by: ["emailNodeId"],
where: {
businessId,
status: "sent",
discountCode: { redemptions: { some: { status: "confirmed" } } },
},
_count: { _all: true },
}),
]);
const sentPerEmail = new Map(sentByEmail.map((r) => [r.emailNodeId, r._count._all]));
const sentPerTrigger = new Map(sentByTrigger.map((r) => [r.triggerNodeId, r._count._all]));
const redeemedPerEmail = new Map(redeemedByEmail.map((r) => [r.emailNodeId, r._count._all]));
return {
consentingCustomers,
nodes: rows.map((row) => ({
...toGraphNode(row),
activatedAt: row.activatedAt?.toISOString() ?? null,
sent: (row.kind === "email" ? sentPerEmail.get(row.id) : sentPerTrigger.get(row.id)) ?? 0,
redeemed: row.kind === "email" ? (redeemedPerEmail.get(row.id) ?? 0) : 0,
})),
};
}
export async function getMarketingEditor(
businessId: string
): Promise<ActionResult<{ data: EditorData }>> {
const manager = await requireManager();
if (!ownsBusiness(manager, businessId)) {
return { success: false, error: "Keine Berechtigung für dieses Business." };
}
try {
return { success: true, data: await loadEditor(businessId) };
} catch (error) {
console.error("Marketing-Editor konnte nicht geladen werden:", error);
return { success: false, error: "Das Marketing konnte nicht geladen werden." };
}
}
/**
* Speichert den ganzen Editor auf einmal. Prüft alles, schreibt die Änderungen
* in einer Transaktion und protokolliert jede inhaltliche Änderung. Ein
* Trigger, der hier wirksam wird (eingeschaltet + verbunden), zählt ab jetzt.
*/
export async function saveMarketingGraph(
businessId: string,
input: GraphNode[]
): Promise<ActionResult<{ data: EditorData }>> {
const manager = await requireManager();
if (!ownsBusiness(manager, businessId)) {
return { success: false, error: "Keine Berechtigung für dieses Business." };
}
const invalid = validateGraph(input);
if (invalid) return { success: false, error: invalid.message };
const nodes = normalizeGraph(input);
try {
// IDs eines anderen Studios dürfen nicht übernommen werden.
const foreign = await prisma.marketingNode.count({
where: { id: { in: nodes.map((n) => n.id) }, businessId: { not: businessId } },
});
if (foreign > 0) return { success: false, error: "Ungültiger Baustein." };
await prisma.$transaction(async (tx) => {
const existingRows = await tx.marketingNode.findMany({ where: { businessId } });
const existing = new Map(existingRows.map((row) => [row.id, row]));
const before = existingRows.map(toGraphNode);
const now = new Date();
const incomingIds = new Set(nodes.map((n) => n.id));
const removed = existingRows.filter((row) => !incomingIds.has(row.id)).map((row) => row.id);
if (removed.length > 0) {
await tx.marketingNode.deleteMany({ where: { id: { in: removed }, businessId } });
}
// E-Mails zuerst, damit Trigger auf sie zeigen können.
const ordered = [...nodes].sort(
(a, b) => Number(a.kind !== "email") - Number(b.kind !== "email")
);
for (const node of ordered) {
const old = existing.get(node.id);
const isTrigger = node.kind !== "email";
const effective = isTrigger && isEffective(node);
const wasEffective = old ? isEffective(old) : false;
const activatedAt = !effective
? null
: wasEffective && old?.activatedAt
? old.activatedAt
: now;
const data = {
kind: node.kind,
config: node.config as Prisma.InputJsonValue,
positionX: node.position.x,
positionY: node.position.y,
emailNodeId: isTrigger ? node.emailNodeId : null,
isActive: isTrigger ? node.isActive : false,
activatedAt,
};
if (old) {
await tx.marketingNode.update({ where: { id: node.id }, data });
} else {
await tx.marketingNode.create({ data: { id: node.id, businessId, ...data } });
}
}
const changes = diffGraph(before, nodes);
await writeAuditLog(
tx,
changes.map((change) => ({
businessId,
actorType: "manager" as const,
actorId: manager.id,
action: change.action,
nodeId: change.nodeId,
summary: change.summary,
before: (change.before as Prisma.InputJsonValue | undefined) ?? null,
after: (change.after as Prisma.InputJsonValue | undefined) ?? null,
}))
);
});
revalidatePath("/marketing");
return { success: true, data: await loadEditor(businessId) };
} catch (error) {
console.error("Marketing konnte nicht gespeichert werden:", error);
return { success: false, error: "Die Änderungen konnten nicht gespeichert werden." };
}
}
/** Schalter in der Handy-Ansicht: wirkt sofort, ohne den Rest zu speichern. */
export async function setTriggerActive(
businessId: string,
nodeId: string,
isActive: boolean
): Promise<ActionResult<{ data: EditorData }>> {
const manager = await requireManager();
if (!ownsBusiness(manager, businessId)) {
return { success: false, error: "Keine Berechtigung für dieses Business." };
}
try {
const rows = await prisma.marketingNode.findMany({ where: { businessId } });
const nodes = rows.map(toGraphNode);
const target = nodes.find((n) => n.id === nodeId);
if (!target || !isTriggerKind(target.kind)) {
return { success: false, error: "Trigger nicht gefunden." };
}
if (isActive && target.emailNodeId === null) {
return {
success: false,
error: "Dieser Trigger ist mit keiner E-Mail verbunden. Verbinde ihn am Computer.",
};
}
return saveMarketingGraph(
businessId,
nodes.map((n) => (n.id === nodeId ? { ...n, isActive } : n))
);
} catch (error) {
console.error("Trigger konnte nicht geschaltet werden:", error);
return { success: false, error: "Der Trigger konnte nicht geschaltet werden." };
}
}
/**
* Schickt die E-Mail so, wie sie gerade im Editor steht, an den Manager - mit
* Beispielwerten statt echtem Kunden und einem Beispielcode, der nirgends
* einlösbar ist.
*/
export async function sendMarketingTestEmail(
businessId: string,
config: unknown
): Promise<{ success: true; to: string } | { success: false; error: string }> {
const manager = await requireManager();
if (!ownsBusiness(manager, businessId)) {
return { success: false, error: "Keine Berechtigung für dieses Business." };
}
const parsed = emailConfigSchema.safeParse(config);
if (!parsed.success) {
return { success: false, error: parsed.error.issues[0]?.message ?? "E-Mail unvollständig." };
}
try {
const business = await prisma.business.findUnique({
where: { id: businessId },
select: { name: true, email: true, slug: true, isPublic: true, branding: true },
});
if (!business) return { success: false, error: "Business nicht gefunden." };
const reward = parsed.data.reward;
const mail = composeMarketingEmail({
business,
config: parsed.data,
customerName: manager.firstName,
milestoneCount: 10,
code: reward
? {
code: "BEISPIEL-ABC234",
expiresAt: berlinDayEnd(addDaysToKey(dayKey(new Date()), reward.validDays)),
}
: null,
unsubscribeUrl: `${appBaseUrl()}/abmelden`,
});
const result = await sendMail({
to: manager.email,
...mail,
subject: `[Test] ${mail.subject}`,
fromName: `${business.name} über Kumasy`,
replyTo: business.email || undefined,
});
if (!result.success) {
return { success: false, error: "Die Testmail konnte nicht verschickt werden." };
}
return { success: true, to: manager.email };
} catch (error) {
console.error("Testmail fehlgeschlagen:", error);
return { success: false, error: "Die Testmail konnte nicht verschickt werden." };
}
}
// ─── Verlauf ─────────────────────────────────────────────────────────────────
const HISTORY_PAGE_SIZE = 50;
export type SendHistoryRow = {
id: string;
createdAt: string;
sentAt: string | null;
status: string;
error: string | null;
customerName: string | null;
customerEmail: string | null;
customerId: string | null;
triggerKind: string;
subject: string;
code: string | null;
redeemed: boolean;
};
export type ChangeHistoryRow = {
id: string;
createdAt: string;
action: string;
actor: string;
summary: string;
};
export async function getSendHistory(
businessId: string,
cursor?: string
): Promise<ActionResult<{ rows: SendHistoryRow[]; nextCursor: string | null }>> {
const manager = await requireManager();
if (!ownsBusiness(manager, businessId)) {
return { success: false, error: "Keine Berechtigung für dieses Business." };
}
try {
const rows = await prisma.marketingSend.findMany({
where: { businessId },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: HISTORY_PAGE_SIZE + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
select: {
id: true,
createdAt: true,
sentAt: true,
status: true,
error: true,
triggerKind: true,
subject: true,
user: { select: { id: true, name: true, email: true } },
discountCode: {
select: {
code: true,
redemptions: { where: { status: "confirmed" }, select: { id: true }, take: 1 },
},
},
},
});
const page = rows.slice(0, HISTORY_PAGE_SIZE);
return {
success: true,
nextCursor: rows.length > HISTORY_PAGE_SIZE ? page[page.length - 1].id : null,
rows: page.map((row) => ({
id: row.id,
createdAt: row.createdAt.toISOString(),
sentAt: row.sentAt?.toISOString() ?? null,
status: row.status,
error: row.error,
customerName: row.user?.name ?? null,
customerEmail: row.user?.email ?? null,
customerId: row.user?.id ?? null,
triggerKind: row.triggerKind,
subject: row.subject,
code: row.discountCode?.code ?? null,
redeemed: (row.discountCode?.redemptions.length ?? 0) > 0,
})),
};
} catch (error) {
console.error("Versandverlauf konnte nicht geladen werden:", error);
return { success: false, error: "Der Verlauf konnte nicht geladen werden." };
}
}
export async function getChangeHistory(
businessId: string,
cursor?: string
): Promise<ActionResult<{ rows: ChangeHistoryRow[]; nextCursor: string | null }>> {
const manager = await requireManager();
if (!ownsBusiness(manager, businessId)) {
return { success: false, error: "Keine Berechtigung für dieses Business." };
}
try {
const rows = await prisma.marketingAuditLog.findMany({
where: { businessId },
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: HISTORY_PAGE_SIZE + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
select: {
id: true,
createdAt: true,
action: true,
actorType: true,
actorId: true,
userId: true,
summary: true,
},
});
const page = rows.slice(0, HISTORY_PAGE_SIZE);
const managerIds = [
...new Set(page.filter((r) => r.actorType === "manager" && r.actorId).map((r) => r.actorId!)),
];
const customerIds = [
...new Set(page.filter((r) => r.actorType === "customer" && r.userId).map((r) => r.userId!)),
];
const [managers, customers] = await Promise.all([
prisma.manager.findMany({
where: { id: { in: managerIds } },
select: { id: true, firstName: true, lastName: true },
}),
prisma.user.findMany({
where: { id: { in: customerIds } },
select: { id: true, name: true, email: true },
}),
]);
const managerNames = new Map(
managers.map((m) => [m.id, `${m.firstName} ${m.lastName}`.trim()])
);
const customerNames = new Map(customers.map((c) => [c.id, c.name?.trim() || c.email]));
return {
success: true,
nextCursor: rows.length > HISTORY_PAGE_SIZE ? page[page.length - 1].id : null,
rows: page.map((row) => ({
id: row.id,
createdAt: row.createdAt.toISOString(),
action: row.action,
summary: row.summary,
actor:
row.actorType === "manager"
? (managerNames.get(row.actorId ?? "") ?? "Manager")
: row.actorType === "customer"
? (customerNames.get(row.userId ?? "") ?? "Gelöschtes Konto")
: "System",
})),
};
} catch (error) {
console.error("Änderungsverlauf konnte nicht geladen werden:", error);
return { success: false, error: "Der Verlauf konnte nicht geladen werden." };
}
}

View file

@ -0,0 +1,55 @@
import type { prisma as prismaClient } from "@/src/lib/prisma";
import type { Prisma } from "@/src/generated/prisma/client";
type PrismaOrTx =
| typeof prismaClient
| Parameters<Parameters<typeof prismaClient.$transaction>[0]>[0];
/**
* Was im Änderungsprotokoll des Marketings stehen kann. Der Verlauf im Editor
* gruppiert danach: "consent.*" sind Einwilligungen von Kunden, alles andere
* Änderungen im Editor.
*/
export const AUDIT_ACTIONS = [
"node.created",
"node.deleted",
"node.updated",
"trigger.connected",
"trigger.disconnected",
"trigger.activated",
"trigger.deactivated",
"consent.granted",
"consent.revoked",
] as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
export type AuditEntry = {
businessId: string;
actorType: "manager" | "customer" | "system";
actorId: string | null;
action: AuditAction;
summary: string;
nodeId?: string | null;
userId?: string | null;
before?: Prisma.InputJsonValue | null;
after?: Prisma.InputJsonValue | null;
};
export async function writeAuditLog(db: PrismaOrTx, entries: AuditEntry | AuditEntry[]) {
const list = Array.isArray(entries) ? entries : [entries];
if (list.length === 0) return;
await db.marketingAuditLog.createMany({
data: list.map((entry) => ({
businessId: entry.businessId,
actorType: entry.actorType,
actorId: entry.actorId,
action: entry.action,
summary: entry.summary,
nodeId: entry.nodeId ?? null,
userId: entry.userId ?? null,
...(entry.before != null ? { before: entry.before } : {}),
...(entry.after != null ? { after: entry.after } : {}),
})),
});
}

View file

@ -0,0 +1,72 @@
import { generateMarketingEmail } from "@/src/lib/mail/templates/marketing-email";
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
import { BERLIN } from "@/src/modules/courses/lib/course-days";
import { REWARD_APPLIES_TO_LABELS, formatRewardValue, type EmailConfig } from "./node-config";
import { firstName, renderPlaceholders } from "./placeholders";
export type ComposeBusiness = {
name: string;
slug: string | null;
isPublic: boolean;
branding: unknown;
};
export function appBaseUrl(): string {
return process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
}
export function studioBookingUrl(business: ComposeBusiness): string | null {
return business.isPublic && business.slug ? `${appBaseUrl()}/business/${business.slug}` : null;
}
export function formatBerlinDate(date: Date): string {
return date.toLocaleDateString("de-DE", {
timeZone: BERLIN,
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
/**
* Baut eine Marketing-Mail aus der E-Mail-Node: Platzhalter ersetzen,
* Rabattcode einsetzen, Studio-Branding und Abmeldelink. Gemeinsam für den
* echten Versand und die Testmail an den Manager.
*/
export function composeMarketingEmail(params: {
business: ComposeBusiness;
config: EmailConfig;
customerName: string | null;
milestoneCount?: number;
code: { code: string; expiresAt: Date } | null;
unsubscribeUrl: string;
}) {
const { business, config, code } = params;
const reward = config.reward && code ? config.reward : null;
const values = {
vorname: firstName(params.customerName),
studio: business.name,
anzahl: params.milestoneCount,
code: code?.code,
rabatt: reward ? formatRewardValue(reward) : undefined,
gueltig_bis: code ? formatBerlinDate(code.expiresAt) : undefined,
};
return generateMarketingEmail({
subject: renderPlaceholders(config.subject, values),
body: renderPlaceholders(config.body, values),
studioName: business.name,
reward:
reward && code
? {
code: code.code,
valueLabel: formatRewardValue(reward),
appliesToLabel: REWARD_APPLIES_TO_LABELS[reward.appliesTo],
validUntilLabel: formatBerlinDate(code.expiresAt),
}
: null,
bookingUrl: studioBookingUrl(business),
unsubscribeUrl: params.unsubscribeUrl,
branding: toEmailBranding(business),
});
}

View file

@ -0,0 +1,34 @@
/**
* Wortlaut der Einwilligung in Werbe-Mails eines Studios.
*
* Die Einwilligung muss nachweisbar sein - gespeichert wird deshalb, welcher
* Version des Wortlauts zugestimmt wurde. Wer den Text inhaltlich ändert, setzt
* eine neue Version; bestehende Einwilligungen behalten ihre alte.
*
* Der Geburtstag steht bewusst im Text: Die Einwilligung deckt damit auch ab,
* dass das Studio das Geburtsdatum für Glückwunsch-Mails nutzt.
*/
export const CONSENT_TEXT_VERSION = "2026-10-05";
export function consentLabel(studioName: string): string {
return (
`Ich möchte von ${studioName} Angebote und Glückwünsche, z. B. zum Geburtstag, ` +
`per E-Mail erhalten. Abmelden geht jederzeit über den Link in jeder E-Mail oder in den Einstellungen.`
);
}
export const CONSENT_SOURCES = [
"course_booking",
"pass_purchase",
"profile",
"unsubscribe_link",
] as const;
export type ConsentSource = (typeof CONSENT_SOURCES)[number];
export const CONSENT_SOURCE_LABELS: Record<ConsentSource, string> = {
course_booking: "bei einer Kursbuchung",
pass_purchase: "beim Kartenkauf",
profile: "in den Einstellungen",
unsubscribe_link: "über den Abmeldelink",
};

View file

@ -0,0 +1,121 @@
import { revalidateTag } from "next/cache";
import { Prisma } from "@/src/generated/prisma/client";
import { prisma } from "@/src/lib/prisma";
import { writeAuditLog } from "./audit-log";
import { CONSENT_SOURCE_LABELS, CONSENT_TEXT_VERSION, type ConsentSource } from "./consent-text";
/**
* Setzt die Einwilligung eines Kunden in Werbe-Mails eines Studios.
*
* Jede tatsächliche Änderung landet im Änderungsprotokoll - das ist der
* Nachweis, wann, wo und zu welchem Wortlaut eingewilligt oder widerrufen
* wurde. Ein erneutes Setzen desselben Zustands ändert nichts und schreibt
* auch nichts ins Protokoll.
*
* @returns true, wenn sich etwas geändert hat
*/
export async function setMarketingConsent(params: {
userId: string;
businessId: string;
granted: boolean;
source: ConsentSource;
}): Promise<boolean> {
const { userId } = params;
// Zwei gleichzeitige Anfragen für ein neues Kunde-Studio-Paar (Doppelklick,
// Buchung und Einstellungen zugleich) lesen beide "keine Zeile" und wollen
// beide anlegen - eine scheitert am Unique-Key. Ein zweiter Durchlauf sieht
// dann die Zeile der anderen und gleicht nur noch ab.
let changed: boolean;
try {
changed = await applyConsent(params);
} catch (error) {
if (!(error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002")) {
throw error;
}
changed = await applyConsent(params);
}
// Das Kunden-Detail des Managers zeigt den Stand der Einwilligung.
if (changed) {
try {
revalidateTag(`kunde-detail-${userId}`, { expire: 0 });
} catch {
// Außerhalb eines Requests (Skripte, Tests) gibt es keinen Cache.
}
}
return changed;
}
async function applyConsent(params: {
userId: string;
businessId: string;
granted: boolean;
source: ConsentSource;
}): Promise<boolean> {
const { userId, businessId, granted, source } = params;
return prisma.$transaction(async (tx) => {
const current = await tx.marketingConsent.findUnique({
where: { userId_businessId: { userId, businessId } },
select: { granted: true },
});
if ((current?.granted ?? false) === granted) return false;
const now = new Date();
await tx.marketingConsent.upsert({
where: { userId_businessId: { userId, businessId } },
create: {
userId,
businessId,
granted,
textVersion: CONSENT_TEXT_VERSION,
source,
grantedAt: granted ? now : null,
revokedAt: granted ? null : now,
},
update: {
granted,
source,
...(granted ? { textVersion: CONSENT_TEXT_VERSION, grantedAt: now } : { revokedAt: now }),
},
});
await writeAuditLog(tx, {
businessId,
actorType: "customer",
actorId: userId,
userId,
action: granted ? "consent.granted" : "consent.revoked",
summary: `${granted ? "Einwilligung erteilt" : "Einwilligung widerrufen"} (${CONSENT_SOURCE_LABELS[source]})`,
after: { granted, source, textVersion: CONSENT_TEXT_VERSION },
});
return true;
});
}
/**
* Für Buchung und Kartenkauf: das Häkchen darf den eigentlichen Vorgang nie
* scheitern lassen. Ein Fehler wird nur protokolliert.
*/
export async function grantConsentQuietly(params: {
userId: string;
businessId: string;
source: ConsentSource;
}): Promise<void> {
await setMarketingConsent({ ...params, granted: true }).catch((error) =>
console.error(
`Einwilligung von ${params.userId} für Studio ${params.businessId} nicht gespeichert:`,
error
)
);
}
export async function hasMarketingConsent(userId: string, businessId: string): Promise<boolean> {
const consent = await prisma.marketingConsent.findUnique({
where: { userId_businessId: { userId, businessId } },
select: { granted: true },
});
return consent?.granted ?? false;
}

View file

@ -0,0 +1,232 @@
import {
SINGLE_INSTANCE_TRIGGERS,
TRIGGER_LABELS,
describeTrigger,
emailConfigSchema,
isTriggerKind,
triggerConfigSchema,
type NodeKind,
} from "./node-config";
/**
* Der Inhalt des Editors, so wie er zwischen Browser und Server hin- und
* hergeht. Ohne Prisma-Abhängigkeit, damit Client (Prüfen vor dem Speichern)
* und Server (verbindliche Prüfung, Änderungsprotokoll) dieselbe Logik nutzen.
*/
export type GraphNode = {
id: string;
kind: NodeKind;
config: Record<string, unknown>;
position: { x: number; y: number };
/** Nur Trigger: verbundene E-Mail */
emailNodeId: string | null;
/** Nur Trigger: Schalter */
isActive: boolean;
};
export const MAX_NODES = 50;
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** Gibt die erste Fehlermeldung zurück, oder null, wenn alles passt. */
export function validateGraph(
nodes: GraphNode[]
): { nodeId: string | null; message: string } | null {
if (nodes.length > MAX_NODES) {
return { nodeId: null, message: `Höchstens ${MAX_NODES} Bausteine` };
}
const ids = new Set<string>();
for (const node of nodes) {
if (!UUID_RE.test(node.id) || ids.has(node.id)) {
return { nodeId: node.id, message: "Ungültiger Baustein" };
}
ids.add(node.id);
if (!Number.isFinite(node.position.x) || !Number.isFinite(node.position.y)) {
return { nodeId: node.id, message: "Ungültige Position" };
}
}
for (const single of SINGLE_INSTANCE_TRIGGERS) {
if (nodes.filter((node) => node.kind === single).length > 1) {
return { nodeId: null, message: `${TRIGGER_LABELS[single]} gibt es nur einmal` };
}
}
const emailIds = new Set(nodes.filter((n) => n.kind === "email").map((n) => n.id));
for (const node of nodes) {
if (node.kind === "email") {
const parsed = emailConfigSchema.safeParse(node.config);
if (!parsed.success) {
const name = typeof node.config.name === "string" && node.config.name.trim();
return {
nodeId: node.id,
message: `E-Mail${name ? ` „${name}“` : ""}: ${parsed.error.issues[0]?.message ?? "unvollständig"}`,
};
}
continue;
}
if (!isTriggerKind(node.kind)) {
return { nodeId: node.id, message: "Unbekannter Baustein" };
}
const parsed = triggerConfigSchema(node.kind).safeParse(node.config);
if (!parsed.success) {
return {
nodeId: node.id,
message: `${TRIGGER_LABELS[node.kind]}: ${parsed.error.issues[0]?.message ?? "ungültig"}`,
};
}
if (node.emailNodeId !== null && !emailIds.has(node.emailNodeId)) {
return { nodeId: node.id, message: `${TRIGGER_LABELS[node.kind]}: E-Mail nicht gefunden` };
}
}
return null;
}
/**
* Gibt die Bausteine mit geprüfter Konfiguration zurück (getrimmte Texte,
* unbekannte Felder entfernt). Nur nach erfolgreichem validateGraph aufrufen.
*/
export function normalizeGraph(nodes: GraphNode[]): GraphNode[] {
return nodes.map((node) => {
const isTrigger = node.kind !== "email";
const config =
node.kind === "email"
? emailConfigSchema.parse(node.config)
: isTriggerKind(node.kind)
? triggerConfigSchema(node.kind).parse(node.config)
: node.config;
return {
...node,
config: config as Record<string, unknown>,
emailNodeId: isTrigger ? node.emailNodeId : null,
isActive: isTrigger ? node.isActive : false,
};
});
}
/** Wirksam = eingeschaltet und mit einer E-Mail verbunden. */
export function isEffective(node: Pick<GraphNode, "isActive" | "emailNodeId">): boolean {
return node.isActive && node.emailNodeId !== null;
}
export function nodeLabel(node: Pick<GraphNode, "kind" | "config">): string {
if (node.kind === "email") {
const name = typeof node.config.name === "string" ? node.config.name.trim() : "";
return `E-Mail „${name || "Ohne Namen"}“`;
}
return isTriggerKind(node.kind) ? describeTrigger(node.kind, node.config) : node.kind;
}
const EMAIL_FIELD_LABELS: Record<string, string> = {
name: "Name",
subject: "Betreff",
body: "Text",
reward: "Rabatt",
};
function sameJson(a: unknown, b: unknown): boolean {
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
}
export type GraphChange = {
action:
| "node.created"
| "node.deleted"
| "node.updated"
| "trigger.connected"
| "trigger.disconnected"
| "trigger.activated"
| "trigger.deactivated";
nodeId: string;
summary: string;
before?: Record<string, unknown> | null;
after?: Record<string, unknown> | null;
};
/**
* Was sich zwischen zwei Ständen geändert hat - für das Änderungsprotokoll.
* Verschieben auf der Fläche ist keine Änderung.
*/
export function diffGraph(before: GraphNode[], after: GraphNode[]): GraphChange[] {
const changes: GraphChange[] = [];
const beforeById = new Map(before.map((n) => [n.id, n]));
const afterById = new Map(after.map((n) => [n.id, n]));
const label = (id: string | null) => {
if (!id) return "";
const node = afterById.get(id) ?? beforeById.get(id);
return node ? nodeLabel(node) : "gelöschte E-Mail";
};
for (const old of before) {
if (!afterById.has(old.id)) {
changes.push({
action: "node.deleted",
nodeId: old.id,
summary: `${nodeLabel(old)} gelöscht`,
before: { kind: old.kind, config: old.config },
});
}
}
for (const node of after) {
const old = beforeById.get(node.id);
if (!old) {
changes.push({
action: "node.created",
nodeId: node.id,
summary: `${nodeLabel(node)} hinzugefügt`,
after: { kind: node.kind, config: node.config },
});
} else if (!sameJson(old.config, node.config)) {
const fields =
node.kind === "email"
? Object.keys(EMAIL_FIELD_LABELS)
.filter((key) => !sameJson(old.config[key], node.config[key]))
.map((key) => EMAIL_FIELD_LABELS[key])
: [];
changes.push({
action: "node.updated",
nodeId: node.id,
summary:
node.kind === "email"
? `${nodeLabel(node)} geändert${fields.length ? `: ${fields.join(", ")}` : ""}`
: `${nodeLabel(old)} geändert zu ${nodeLabel(node)}`,
before: { config: old.config },
after: { config: node.config },
});
}
if (node.kind === "email") continue;
const oldEmail = old?.emailNodeId ?? null;
if (oldEmail !== node.emailNodeId) {
if (oldEmail && afterById.has(oldEmail)) {
changes.push({
action: "trigger.disconnected",
nodeId: node.id,
summary: `${nodeLabel(node)} von ${label(oldEmail)} getrennt`,
});
}
if (node.emailNodeId) {
changes.push({
action: "trigger.connected",
nodeId: node.id,
summary: `${nodeLabel(node)} mit ${label(node.emailNodeId)} verbunden`,
});
}
}
const wasActive = old?.isActive ?? false;
if (wasActive !== node.isActive) {
changes.push({
action: node.isActive ? "trigger.activated" : "trigger.deactivated",
nodeId: node.id,
summary: `${nodeLabel(node)} ${node.isActive ? "eingeschaltet" : "ausgeschaltet"}`,
});
}
}
return changes;
}

View file

@ -0,0 +1,210 @@
import { z } from "zod";
/**
* Bausteine des Marketing-Editors und ihr gespeichertes `config`-Format
* (MarketingNode.config).
*
* Trigger lösen pro Kunde aus und führen in genau eine E-Mail. Was "fällig"
* heißt, steht in triggers.ts.
*/
export const TRIGGER_KINDS = ["birthday", "milestone", "winback"] as const;
export type TriggerKind = (typeof TRIGGER_KINDS)[number];
export type NodeKind = TriggerKind | "email";
export function isTriggerKind(kind: string): kind is TriggerKind {
return (TRIGGER_KINDS as readonly string[]).includes(kind);
}
export const TRIGGER_LABELS: Record<TriggerKind, string> = {
birthday: "Geburtstag",
milestone: "Meilenstein",
winback: "Reaktivierung",
};
export const TRIGGER_DESCRIPTIONS: Record<TriggerKind, string> = {
birthday: "Am Geburtstag des Kunden",
milestone: "Wenn ein Kunde seinen X. Kurs besucht hat",
winback: "Wenn ein Kunde seit X Tagen keinen Kurs mehr hatte und nichts gebucht hat",
};
// Geburtstag kann nur einmal auf der Fläche stehen - mehr als eine
// Geburtstagsmail pro Jahr ergibt keinen Sinn.
export const SINGLE_INSTANCE_TRIGGERS: readonly TriggerKind[] = ["birthday"];
export const MILESTONE_LIMITS = { min: 1, max: 1000 } as const;
export const WINBACK_LIMITS = { min: 7, max: 730 } as const;
export const birthdayConfigSchema = z.object({}).strip();
export const milestoneConfigSchema = z.object({
bookings: z
.number()
.int()
.min(MILESTONE_LIMITS.min, "Mindestens 1 Kurs")
.max(MILESTONE_LIMITS.max, `Höchstens ${MILESTONE_LIMITS.max} Kurse`),
});
export const winbackConfigSchema = z.object({
days: z
.number()
.int()
.min(WINBACK_LIMITS.min, `Mindestens ${WINBACK_LIMITS.min} Tage`)
.max(WINBACK_LIMITS.max, `Höchstens ${WINBACK_LIMITS.max} Tage`),
});
export type MilestoneConfig = z.infer<typeof milestoneConfigSchema>;
export type WinbackConfig = z.infer<typeof winbackConfigSchema>;
export const REWARD_LIMITS = {
percentMax: 100,
fixedMaxCents: 100_000,
validDaysMin: 1,
validDaysMax: 365,
} as const;
export const rewardSchema = z
.object({
type: z.enum(["percent", "fixed"]),
/** percent: 1-100, fixed: Cents */
value: z.number().int().positive("Der Rabatt muss größer als 0 sein"),
validDays: z
.number()
.int()
.min(REWARD_LIMITS.validDaysMin, "Mindestens 1 Tag gültig")
.max(REWARD_LIMITS.validDaysMax, "Höchstens 365 Tage gültig"),
appliesTo: z.enum(["courses", "passes", "all"]),
})
.superRefine((reward, ctx) => {
if (reward.type === "percent" && reward.value > REWARD_LIMITS.percentMax) {
ctx.addIssue({ code: "custom", path: ["value"], message: "Höchstens 100 %" });
}
if (reward.type === "fixed" && reward.value > REWARD_LIMITS.fixedMaxCents) {
ctx.addIssue({ code: "custom", path: ["value"], message: "Höchstens 1.000 €" });
}
});
export type Reward = z.infer<typeof rewardSchema>;
/** Platzhalter, die im Betreff und Text der E-Mail ersetzt werden. */
export const PLACEHOLDERS = [
{ key: "{vorname}", description: "Vorname des Kunden" },
{ key: "{studio}", description: "Name des Studios" },
{ key: "{anzahl}", description: "Anzahl besuchter Kurse (nur Meilenstein)" },
{ key: "{code}", description: "Persönlicher Rabattcode" },
{ key: "{rabatt}", description: "Rabatt, z.B. 10 %" },
{ key: "{gueltig_bis}", description: "Letzter Tag, an dem der Code gilt" },
] as const;
const REWARD_PLACEHOLDERS = ["{code}", "{rabatt}", "{gueltig_bis}"];
export const emailConfigSchema = z
.object({
name: z.string().trim().min(1, "Bitte einen Namen angeben").max(60, "Höchstens 60 Zeichen"),
subject: z
.string()
.trim()
.min(1, "Bitte einen Betreff angeben")
.max(150, "Höchstens 150 Zeichen"),
body: z.string().trim().min(1, "Bitte einen Text angeben").max(5000, "Höchstens 5000 Zeichen"),
reward: rewardSchema.nullable(),
})
.superRefine((config, ctx) => {
if (config.reward) return;
const used = REWARD_PLACEHOLDERS.filter(
(key) => config.subject.includes(key) || config.body.includes(key)
);
if (used.length > 0) {
ctx.addIssue({
code: "custom",
path: ["body"],
message: `${used.join(", ")} gibt es nur mit Rabatt`,
});
}
});
export type EmailConfig = z.infer<typeof emailConfigSchema>;
export function triggerConfigSchema(kind: TriggerKind) {
switch (kind) {
case "birthday":
return birthdayConfigSchema;
case "milestone":
return milestoneConfigSchema;
case "winback":
return winbackConfigSchema;
}
}
/** Kurzbeschreibung eines Triggers mit seinen Werten, z.B. "Meilenstein: 10. Kurs". */
export function describeTrigger(kind: TriggerKind, config: unknown): string {
if (kind === "milestone") {
const parsed = milestoneConfigSchema.safeParse(config);
return parsed.success ? `Meilenstein: ${parsed.data.bookings}. Kurs` : "Meilenstein";
}
if (kind === "winback") {
const parsed = winbackConfigSchema.safeParse(config);
return parsed.success ? `Reaktivierung: ${parsed.data.days} Tage weg` : "Reaktivierung";
}
return "Geburtstag";
}
export function parseEmailConfig(config: unknown): EmailConfig | null {
const parsed = emailConfigSchema.safeParse(config);
return parsed.success ? parsed.data : null;
}
export const DEFAULT_TRIGGER_CONFIG: Record<TriggerKind, Record<string, number>> = {
birthday: {},
milestone: { bookings: 10 },
winback: { days: 60 },
};
/** Vorlagen für neue E-Mails - das Studio kann alles anpassen. */
export const EMAIL_TEMPLATES: Record<TriggerKind, EmailConfig> = {
birthday: {
name: "Geburtstagsmail",
subject: "Alles Gute zum Geburtstag, {vorname}!",
body:
"Hallo {vorname},\n\n" +
"alles Gute zum Geburtstag! Wir freuen uns, dass Sie bei uns trainieren, und wünschen " +
"Ihnen ein wunderbares neues Lebensjahr.\n\n" +
"Als kleines Geschenk haben wir Ihnen einen persönlichen Rabattcode erstellt.\n\n" +
"Ihr Team von {studio}",
reward: { type: "percent", value: 15, validDays: 30, appliesTo: "all" },
},
milestone: {
name: "Danke-Mail",
subject: "Schon {anzahl} Kurse - danke, {vorname}!",
body:
"Hallo {vorname},\n\n" +
"Sie waren jetzt schon {anzahl}-mal bei uns. Danke für Ihre Treue!\n\n" +
"Als Dankeschön haben wir Ihnen einen persönlichen Rabattcode erstellt.\n\n" +
"Ihr Team von {studio}",
reward: { type: "percent", value: 10, validDays: 30, appliesTo: "all" },
},
winback: {
name: "Wir vermissen Sie",
subject: "Wir vermissen Sie, {vorname}",
body:
"Hallo {vorname},\n\n" +
"wir haben Sie schon eine Weile nicht mehr bei uns gesehen. Schauen Sie doch mal wieder " +
"vorbei - unser aktueller Kursplan wartet auf Sie.\n\n" +
"Für Ihren nächsten Besuch haben wir Ihnen einen persönlichen Rabattcode erstellt.\n\n" +
"Ihr Team von {studio}",
reward: { type: "percent", value: 15, validDays: 21, appliesTo: "courses" },
},
};
export const REWARD_APPLIES_TO_LABELS: Record<Reward["appliesTo"], string> = {
courses: "Kurse",
passes: "Karten",
all: "Kurse und Karten",
};
/** "15 %" oder "5,00 €" */
export function formatRewardValue(reward: Pick<Reward, "type" | "value">): string {
return reward.type === "percent"
? `${reward.value} %`
: new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(
reward.value / 100
);
}

View file

@ -0,0 +1,65 @@
import { randomInt } from "crypto";
import { Prisma } from "@/src/generated/prisma/client";
import { prisma } from "@/src/lib/prisma";
import { berlinDayEnd } from "@/src/modules/courses/lib/course-days";
import type { Reward, TriggerKind } from "./node-config";
// Ohne 0/O und 1/I, damit niemand den Code beim Abtippen verwechselt.
const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const PREFIX: Record<TriggerKind, string> = {
birthday: "GEBURTSTAG",
milestone: "DANKE",
winback: "COMEBACK",
};
export function generateCodeString(kind: TriggerKind): string {
let suffix = "";
for (let i = 0; i < 6; i++) suffix += ALPHABET[randomInt(ALPHABET.length)];
return `${PREFIX[kind]}-${suffix}`;
}
/**
* Legt den persönlichen Rabattcode für eine Marketing-Mail an: einmal
* einlösbar, nur für dieses Konto, gültig bis einschließlich `validUntilDay`
* (Berliner Tag). Er erscheint nicht auf der Rabattcode-Seite (origin
* "marketing"), sondern im Verlauf des Marketings.
*/
export async function createPersonalDiscountCode(params: {
businessId: string;
userId: string;
triggerKind: TriggerKind;
reward: Reward;
validUntilDay: string;
}): Promise<{ id: string; code: string; expiresAt: Date }> {
const expiresAt = berlinDayEnd(params.validUntilDay);
// Kollisionen sind bei 32^6 Möglichkeiten pro Präfix selten - ein paar
// Versuche reichen.
for (let attempt = 1; ; attempt++) {
try {
return await prisma.discountCode
.create({
data: {
businessId: params.businessId,
code: generateCodeString(params.triggerKind),
type: params.reward.type,
value: params.reward.value,
appliesTo: params.reward.appliesTo,
scope: "all_courses",
courseIds: [],
maxRedemptions: 1,
expiresAt,
restrictedToUserId: params.userId,
origin: "marketing",
},
select: { id: true, code: true, expiresAt: true },
})
.then((code) => ({ ...code, expiresAt: code.expiresAt ?? expiresAt }));
} catch (error) {
const isCollision =
error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002";
if (!isCollision || attempt >= 5) throw error;
}
}
}

View file

@ -0,0 +1,34 @@
export type PlaceholderValues = {
vorname: string;
studio: string;
anzahl?: number;
code?: string;
rabatt?: string;
gueltig_bis?: string;
};
/** Erster Teil des Namens, leer ohne Namen. */
export function firstName(name: string | null | undefined): string {
return name?.trim().split(/\s+/)[0] ?? "";
}
/**
* Ersetzt {vorname}, {studio} usw. in Betreff und Text. Fehlt ein Wert (z.B.
* kein Name hinterlegt), fällt der Platzhalter weg; übrig gebliebene Leerzeichen
* vor Satzzeichen werden aufgeräumt ("Hallo ," → "Hallo,").
*/
export function renderPlaceholders(text: string, values: PlaceholderValues): string {
const map: Record<string, string> = {
"{vorname}": values.vorname,
"{studio}": values.studio,
"{anzahl}": values.anzahl !== undefined ? String(values.anzahl) : "",
"{code}": values.code ?? "",
"{rabatt}": values.rabatt ?? "",
"{gueltig_bis}": values.gueltig_bis ?? "",
};
return text
.replace(/\{(vorname|studio|anzahl|code|rabatt|gueltig_bis)\}/g, (match) => map[match] ?? "")
.replace(/,\s*([!?.])/g, "$1")
.replace(/[ \t]+([,!?.])/g, "$1")
.replace(/[ \t]{2,}/g, " ");
}

View file

@ -0,0 +1,424 @@
import { Prisma } from "@/src/generated/prisma/client";
import { prisma } from "@/src/lib/prisma";
import { sendMail } from "@/src/lib/mail/resend";
import { addDaysToKey, berlinDayStart, dayKey } from "@/src/modules/courses/lib/course-days";
import { appBaseUrl, composeMarketingEmail, type ComposeBusiness } from "./compose-email";
import { TRIGGER_KINDS, isTriggerKind, parseEmailConfig, type EmailConfig } from "./node-config";
import { createPersonalDiscountCode } from "./personal-code";
import { findDueEvents, type ActiveTrigger, type DueEvent } from "./triggers";
import { createUnsubscribeToken, unsubscribeApiUrl, unsubscribePageUrl } from "./unsubscribe-token";
/**
* Täglicher Lauf des Marketings (aufgerufen von /api/cron/marketing).
*
* Pro Studio mit eingeschalteten, verbundenen Triggern:
* 1. Kunden mit Einwilligung und ihre Buchungstage laden.
* 2. Fällige Ereignisse bestimmen (triggers.ts).
* 3. Pro Ereignis eine Versand-Zeile beanspruchen. Der Unique-Key aus Studio,
* Kunde, Trigger-Art und Zeitraum macht doppelten Versand unmöglich - auch
* bei zwei gleichzeitigen Läufen.
* 4. Persönlichen Rabattcode anlegen, Mail bauen und verschicken.
*
* Pro Kunde und Studio geht höchstens eine Mail am Tag raus; was liegen bleibt,
* holt der nächste Lauf nach (siehe Nachholzeiträume in triggers.ts).
* Fehlgeschlagene Mails werden bis zu MAX_ATTEMPTS-mal in späteren Läufen
* erneut versucht.
*/
/** Buchungsstatus, die als "war im Kurs" zählen (Kartenbuchungen sind auch "paid"). */
const ATTENDED_STATUSES = ["paid", "partially_refunded"];
export const MAX_ATTEMPTS = 3;
/** Eine "pending"-Zeile, die so lange hängt, gilt als abgebrochen. */
const STALE_PENDING_MS = 15 * 60_000;
/** Abstand zwischen zwei Mails - bleibt unter dem Rate-Limit von Resend. */
const SEND_INTERVAL_MS = 550;
export type RunOptions = {
/** Berliner Tag "YYYY-MM-DD", Standard: heute */
today?: string;
/** Nur ermitteln, wer was bekäme - nichts anlegen, nichts verschicken */
dryRun?: boolean;
/** Nur dieses Studio */
businessId?: string;
/** Zeitpunkt (ms), ab dem keine neuen Mails mehr angefangen werden */
deadline?: number;
};
export type DryRunItem = {
trigger: string;
customer: string;
periodKey: string;
subject: string;
withCode: boolean;
};
export type BusinessReport = {
businessId: string;
studioName: string;
consentingCustomers: number;
due: number;
sent: number;
failed: number;
skipped: number;
items?: DryRunItem[];
};
export type RunReport = {
today: string;
dryRun: boolean;
/** true = Zeit lief ab, der nächste Lauf macht weiter */
incomplete: boolean;
businesses: BusinessReport[];
};
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
function sendKey(userId: string, triggerKind: string, periodKey: string) {
return `${userId}|${triggerKind}|${periodKey}`;
}
export async function runMarketing(options: RunOptions = {}): Promise<RunReport> {
const today = options.today ?? dayKey(new Date());
const dryRun = options.dryRun ?? false;
const report: RunReport = { today, dryRun, incomplete: false, businesses: [] };
if (!dryRun && !process.env.MARKETING_TOKEN_SECRET) {
throw new Error("MARKETING_TOKEN_SECRET ist nicht gesetzt - ohne Abmeldelink kein Versand");
}
const triggerRows = await prisma.marketingNode.findMany({
where: {
kind: { in: [...TRIGGER_KINDS] },
isActive: true,
activatedAt: { not: null },
emailNodeId: { not: null },
...(options.businessId ? { businessId: options.businessId } : {}),
},
select: {
id: true,
businessId: true,
kind: true,
config: true,
activatedAt: true,
emailNodeId: true,
},
});
const byBusiness = new Map<string, ActiveTrigger[]>();
for (const row of triggerRows) {
if (!isTriggerKind(row.kind) || !row.activatedAt || !row.emailNodeId) continue;
const list = byBusiness.get(row.businessId) ?? [];
list.push({
id: row.id,
kind: row.kind,
config: row.config,
activatedAt: row.activatedAt,
emailNodeId: row.emailNodeId,
});
byBusiness.set(row.businessId, list);
}
for (const [businessId, triggers] of byBusiness) {
if (options.deadline && Date.now() > options.deadline) {
report.incomplete = true;
break;
}
const result = await runForBusiness({ businessId, triggers, today, dryRun, options });
if (result.report) report.businesses.push(result.report);
if (result.incomplete) {
report.incomplete = true;
break;
}
}
return report;
}
async function runForBusiness(params: {
businessId: string;
triggers: ActiveTrigger[];
today: string;
dryRun: boolean;
options: RunOptions;
}): Promise<{ report: BusinessReport | null; incomplete: boolean }> {
const { businessId, triggers, today, dryRun, options } = params;
const business = await prisma.business.findUnique({
where: { id: businessId },
select: { id: true, name: true, email: true, slug: true, isPublic: true, branding: true },
});
if (!business) return { report: null, incomplete: false };
const emailRows = await prisma.marketingNode.findMany({
where: { businessId, kind: "email", id: { in: triggers.map((t) => t.emailNodeId) } },
select: { id: true, config: true },
});
const emailConfigs = new Map<string, EmailConfig | null>(
emailRows.map((row) => [row.id, parseEmailConfig(row.config)])
);
const consents = await prisma.marketingConsent.findMany({
where: { businessId, granted: true },
select: { user: { select: { id: true, email: true, name: true, birthday: true } } },
});
const customers = new Map(consents.map((c) => [c.user.id, c.user]));
const report: BusinessReport = {
businessId,
studioName: business.name,
consentingCustomers: customers.size,
due: 0,
sent: 0,
failed: 0,
skipped: 0,
...(dryRun ? { items: [] } : {}),
};
if (customers.size === 0) return { report, incomplete: false };
const bookings = await prisma.courseBooking.findMany({
where: {
userId: { in: [...customers.keys()] },
paymentStatus: { in: ATTENDED_STATUSES },
course: { businessId, status: { not: "cancelled" } },
},
select: { userId: true, course: { select: { date: true } } },
});
const bookingDays = new Map<string, string[]>();
for (const booking of bookings) {
if (!booking.userId) continue;
const days = bookingDays.get(booking.userId) ?? [];
days.push(dayKey(booking.course.date));
bookingDays.set(booking.userId, days);
}
const events = findDueEvents({
today,
triggers,
customers: [...customers.values()].map((user) => ({
userId: user.id,
birthday: user.birthday,
bookingDays: bookingDays.get(user.id) ?? [],
})),
});
// Was schon verschickt (oder endgültig gescheitert) ist, gar nicht erst
// anfassen - der Unique-Key würde es ohnehin abweisen.
const existingSends = await prisma.marketingSend.findMany({
where: {
businessId,
userId: { in: [...new Set(events.map((e) => e.userId))] },
triggerKind: { in: [...new Set(events.map((e) => e.triggerKind))] },
},
select: { userId: true, triggerKind: true, periodKey: true, status: true, attempts: true },
});
const settled = new Set(
existingSends
.filter((s) => s.status === "sent" || s.attempts >= MAX_ATTEMPTS)
.map((s) => sendKey(s.userId ?? "", s.triggerKind, s.periodKey))
);
const mailedToday = new Set(
(
await prisma.marketingSend.findMany({
where: { businessId, status: "sent", sentAt: { gte: berlinDayStart(dayKey(new Date())) } },
select: { userId: true },
})
).map((s) => s.userId)
);
const openEvents = events.filter(
(e) => !settled.has(sendKey(e.userId, e.triggerKind, e.periodKey))
);
report.due = openEvents.length;
for (const event of openEvents) {
if (options.deadline && Date.now() > options.deadline) {
return { report, incomplete: true };
}
const customer = customers.get(event.userId)!;
const config = emailConfigs.get(event.emailNodeId);
if (!config || mailedToday.has(event.userId)) {
report.skipped++;
continue;
}
mailedToday.add(event.userId);
if (dryRun) {
report.items!.push({
trigger: event.triggerKind,
customer: customer.email,
periodKey: event.periodKey,
subject: config.subject,
withCode: config.reward !== null,
});
continue;
}
const outcome = await deliver({ business, event, customer, config, today });
report[outcome]++;
if (outcome !== "skipped") await sleep(SEND_INTERVAL_MS);
}
return { report, incomplete: false };
}
type BusinessRow = ComposeBusiness & { id: string; email: string };
type CustomerRow = { id: string; email: string; name: string | null };
/**
* Beansprucht die Versand-Zeile. Gibt null zurück, wenn sie schon jemand
* anderes hat (paralleler Lauf) oder sie nicht mehr erneut versucht wird.
*/
async function claimSend(
business: BusinessRow,
event: DueEvent,
subject: string
): Promise<{ id: string; discountCodeId: string | null } | null> {
const data = {
businessId: business.id,
userId: event.userId,
triggerKind: event.triggerKind,
periodKey: event.periodKey,
triggerNodeId: event.triggerId,
emailNodeId: event.emailNodeId,
subject,
};
try {
return await prisma.marketingSend.create({
data: { ...data, status: "pending", attempts: 1 },
select: { id: true, discountCodeId: true },
});
} catch (error) {
if (!(error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002")) {
throw error;
}
}
const existing = await prisma.marketingSend.findUnique({
where: {
businessId_userId_triggerKind_periodKey: {
businessId: business.id,
userId: event.userId,
triggerKind: event.triggerKind,
periodKey: event.periodKey,
},
},
select: { id: true, discountCodeId: true },
});
if (!existing) return null;
// Erneuter Versuch nur für gescheiterte oder hängengebliebene Zeilen.
const retried = await prisma.marketingSend.updateMany({
where: {
id: existing.id,
attempts: { lt: MAX_ATTEMPTS },
OR: [
{ status: "failed" },
{ status: "pending", updatedAt: { lt: new Date(Date.now() - STALE_PENDING_MS) } },
],
},
data: {
status: "pending",
attempts: { increment: 1 },
error: null,
triggerNodeId: event.triggerId,
emailNodeId: event.emailNodeId,
subject,
},
});
return retried.count === 1 ? existing : null;
}
async function deliver(params: {
business: BusinessRow;
event: DueEvent;
customer: CustomerRow;
config: EmailConfig;
today: string;
}): Promise<"sent" | "failed" | "skipped"> {
const { business, event, customer, config, today } = params;
const claim = await claimSend(business, event, config.subject);
if (!claim) return "skipped";
try {
// Bei einem erneuten Versuch gilt der schon angelegte Code weiter.
let code: { code: string; expiresAt: Date } | null = null;
if (config.reward) {
if (claim.discountCodeId) {
const existing = await prisma.discountCode.findUnique({
where: { id: claim.discountCodeId },
select: { code: true, expiresAt: true },
});
if (existing?.expiresAt) code = { code: existing.code, expiresAt: existing.expiresAt };
}
if (!code) {
const created = await createPersonalDiscountCode({
businessId: business.id,
userId: customer.id,
triggerKind: event.triggerKind,
reward: config.reward,
validUntilDay: addDaysToKey(today, config.reward.validDays),
});
await prisma.marketingSend.update({
where: { id: claim.id },
data: { discountCodeId: created.id },
});
code = created;
}
}
const token = createUnsubscribeToken(customer.id, business.id);
const mail = composeMarketingEmail({
business,
config,
customerName: customer.name,
milestoneCount: event.milestoneCount,
code,
unsubscribeUrl: unsubscribePageUrl(appBaseUrl(), token),
});
const result = await sendMail({
to: customer.email,
...mail,
fromName: `${business.name} über Kumasy`,
replyTo: business.email || undefined,
headers: {
"List-Unsubscribe": `<${unsubscribeApiUrl(appBaseUrl(), token)}>`,
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
},
// Resend verschickt mit demselben Schlüssel innerhalb von 24 h nur
// einmal - schützt, falls der Lauf zwischen Versand und Statusupdate abbricht.
idempotencyKey: `marketing-${claim.id}`,
});
if (!result.success) {
throw new Error(
result.error instanceof Error
? result.error.message
: JSON.stringify(result.error ?? "Versand fehlgeschlagen")
);
}
await prisma.marketingSend.update({
where: { id: claim.id },
data: { status: "sent", sentAt: new Date(), subject: mail.subject, error: null },
});
return "sent";
} catch (error) {
console.error(`Marketing-Mail ${claim.id} fehlgeschlagen:`, error);
await prisma.marketingSend
.update({
where: { id: claim.id },
data: {
status: "failed",
error: (error instanceof Error ? error.message : String(error)).slice(0, 500),
},
})
.catch((updateError) =>
console.error(`Status von Marketing-Mail ${claim.id} nicht gespeichert:`, updateError)
);
return "failed";
}
}

View file

@ -0,0 +1,151 @@
import { addDaysToKey, dayKey } from "@/src/modules/courses/lib/course-days";
import { milestoneConfigSchema, winbackConfigSchema, type TriggerKind } from "./node-config";
/**
* Wann ein Trigger für einen Kunden fällig ist - reine Logik ohne Datenbank.
*
* Alle Tage sind Berliner Kalendertage als "YYYY-MM-DD" (siehe course-days.ts).
* Der Cron läuft einmal täglich; jeder Trigger zählt nur Ereignisse ab dem Tag,
* an dem er eingeschaltet wurde. Ein kurzer Nachholzeitraum fängt Tage ab, an
* denen der Cron nicht lief - doppelt verschickt wird trotzdem nichts, dafür
* sorgt der Unique-Key in MarketingSend (Trigger-Art + periodKey).
*/
/** Wie viele Tage ein verpasster Geburtstag nachgeholt wird. */
export const BIRTHDAY_CATCH_UP_DAYS = 2;
/** Wie viele Tage ein erreichter Meilenstein nachgeholt wird. */
export const MILESTONE_CATCH_UP_DAYS = 7;
export type ActiveTrigger = {
id: string;
kind: TriggerKind;
config: unknown;
activatedAt: Date;
emailNodeId: string;
};
export type CustomerFacts = {
userId: string;
birthday: Date | null;
/** Berliner Kurstage aller bezahlten Buchungen in nicht abgesagten Kursen */
bookingDays: string[];
};
export type DueEvent = {
triggerId: string;
triggerKind: TriggerKind;
emailNodeId: string;
userId: string;
/** Zeitraum für die Sperre: Jahr, Schwelle oder letzter Kurstag + Tage */
periodKey: string;
/** Für {anzahl}: Zahl der besuchten Kurse beim Meilenstein */
milestoneCount?: number;
};
/** Reihenfolge, wenn ein Kunde am selben Tag mehrere Mails bekäme. */
export const TRIGGER_PRIORITY: Record<TriggerKind, number> = {
birthday: 0,
milestone: 1,
winback: 2,
};
function isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
/**
* Geburtstag in einem bestimmten Jahr. Das Geburtsdatum liegt als UTC-
* Mitternacht in der DB ("1990-05-12" → 1990-05-12T00:00Z), Monat und Tag
* kommen deshalb aus UTC. Wer am 29.2. Geburtstag hat, feiert in anderen
* Jahren am 28.2.
*/
export function birthdayInYear(birthday: Date, year: number): string {
const month = birthday.getUTCMonth() + 1;
let day = birthday.getUTCDate();
if (month === 2 && day === 29 && !isLeapYear(year)) {
day = 28;
}
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function maxKey(a: string, b: string): string {
return a > b ? a : b;
}
export function findDueEvents(params: {
today: string;
triggers: ActiveTrigger[];
customers: CustomerFacts[];
}): DueEvent[] {
const { today, triggers, customers } = params;
const events: DueEvent[] = [];
for (const trigger of triggers) {
const activatedDay = dayKey(trigger.activatedAt);
const base = {
triggerId: trigger.id,
triggerKind: trigger.kind,
emailNodeId: trigger.emailNodeId,
};
for (const customer of customers) {
const completed = customer.bookingDays.filter((day) => day < today).sort();
const hasUpcoming = customer.bookingDays.some((day) => day >= today);
if (trigger.kind === "birthday") {
if (!customer.birthday) continue;
const windowStart = maxKey(activatedDay, addDaysToKey(today, -BIRTHDAY_CATCH_UP_DAYS));
const year = Number(today.slice(0, 4));
// Vorjahr mitprüfen: am 1.1. kann der 31.12. noch nachgeholt werden.
for (const candidateYear of [year, year - 1]) {
const occurrence = birthdayInYear(customer.birthday, candidateYear);
if (occurrence <= today && occurrence >= windowStart) {
events.push({ ...base, userId: customer.userId, periodKey: String(candidateYear) });
break;
}
}
}
if (trigger.kind === "milestone") {
const config = milestoneConfigSchema.safeParse(trigger.config);
if (!config.success) continue;
const threshold = config.data.bookings;
if (completed.length < threshold) continue;
// Der Kurs, mit dem die Schwelle erreicht wurde.
const reachedOn = completed[threshold - 1];
const windowStart = maxKey(activatedDay, addDaysToKey(today, -MILESTONE_CATCH_UP_DAYS));
if (reachedOn >= windowStart) {
events.push({
...base,
userId: customer.userId,
periodKey: String(threshold),
milestoneCount: threshold,
});
}
}
if (trigger.kind === "winback") {
const config = winbackConfigSchema.safeParse(trigger.config);
if (!config.success) continue;
if (hasUpcoming || completed.length === 0) continue;
const lastDay = completed[completed.length - 1];
const dueDay = addDaysToKey(lastDay, config.data.days);
// Fällig ab dueDay - aber nur, wenn dieser Tag nach dem Einschalten
// liegt. Wer schon vorher so lange weg war, wird nicht angeschrieben.
if (dueDay <= today && dueDay >= activatedDay) {
events.push({
...base,
userId: customer.userId,
periodKey: `${lastDay}:${config.data.days}`,
});
}
}
}
}
return events.sort(
(a, b) =>
TRIGGER_PRIORITY[a.triggerKind] - TRIGGER_PRIORITY[b.triggerKind] ||
a.userId.localeCompare(b.userId)
);
}

View file

@ -0,0 +1,49 @@
import { createHmac, timingSafeEqual } from "crypto";
/**
* Signierter Abmeldelink: Wer eine Werbe-Mail bekommt, soll sich ohne Anmeldung
* abmelden können. Das Token enthält Kunde und Studio und ist mit
* MARKETING_TOKEN_SECRET signiert, damit niemand fremde Einwilligungen
* widerrufen kann. Es läuft bewusst nicht ab - ein Abmeldelink aus einer alten
* Mail muss weiter funktionieren.
*/
function secret(): string {
const value = process.env.MARKETING_TOKEN_SECRET;
if (!value) throw new Error("MARKETING_TOKEN_SECRET ist nicht gesetzt");
return value;
}
function sign(payload: string): string {
return createHmac("sha256", secret()).update(payload).digest("base64url");
}
export function createUnsubscribeToken(userId: string, businessId: string): string {
const payload = `${userId}.${businessId}`;
return `${payload}.${sign(payload)}`;
}
export function verifyUnsubscribeToken(
token: string | null | undefined
): { userId: string; businessId: string } | null {
if (!token) return null;
const parts = token.split(".");
if (parts.length !== 3) return null;
const [userId, businessId, signature] = parts;
if (!userId || !businessId || !signature) return null;
const expected = Buffer.from(sign(`${userId}.${businessId}`));
const actual = Buffer.from(signature);
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return null;
return { userId, businessId };
}
export function unsubscribePageUrl(baseUrl: string, token: string): string {
return `${baseUrl}/abmelden?t=${encodeURIComponent(token)}`;
}
/** Ziel für den List-Unsubscribe-Header (Ein-Klick-Abmeldung im Mailprogramm). */
export function unsubscribeApiUrl(baseUrl: string, token: string): string {
return `${baseUrl}/api/marketing/unsubscribe?t=${encodeURIComponent(token)}`;
}

View file

@ -0,0 +1,35 @@
"use client";
import { useId } from "react";
import { Checkbox } from "@/src/components/ui/checkbox";
/**
* Häkchen für Werbe-Mails eines Studios beim Buchen und beim Kartenkauf.
* Opt-in: standardmäßig leer, der Kunde setzt es selbst.
*/
const MarketingConsentCheckbox = ({
label,
checked,
onCheckedChange,
}: {
label: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
}) => {
const id = useId();
return (
<div className="flex items-start gap-2.5">
<Checkbox
id={id}
checked={checked}
onCheckedChange={(value) => onCheckedChange(value === true)}
className="mt-0.5"
/>
<label htmlFor={id} className="text-xs leading-relaxed text-gray-600">
{label}
</label>
</div>
);
};
export default MarketingConsentCheckbox;

View file

@ -0,0 +1,194 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import Link from "next/link";
import { CheckCircle2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/src/components/ui/button";
import { cn } from "@/src/lib/utils";
import {
getChangeHistory,
getSendHistory,
type ChangeHistoryRow,
type SendHistoryRow,
} from "../../actions/editor-actions";
import { isTriggerKind, TRIGGER_LABELS } from "../../lib/node-config";
type Tab = "sends" | "changes";
const STATUS_STYLES: Record<string, { label: string; className: string }> = {
sent: { label: "Verschickt", className: "bg-green-100 text-green-700" },
failed: { label: "Fehlgeschlagen", className: "bg-red-100 text-red-700" },
pending: { label: "Wird verschickt", className: "bg-yellow-100 text-yellow-700" },
};
function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
/** Verlauf: verschickte Mails und Änderungen (Editor + Einwilligungen). */
const MarketingHistory = ({ businessId }: { businessId: string }) => {
const [tab, setTab] = useState<Tab>("sends");
const [sends, setSends] = useState<SendHistoryRow[] | null>(null);
const [changes, setChanges] = useState<ChangeHistoryRow[] | null>(null);
const [cursor, setCursor] = useState<{ sends: string | null; changes: string | null }>({
sends: null,
changes: null,
});
const [isLoading, startLoading] = useTransition();
const load = (which: Tab, after?: string) => {
startLoading(async () => {
if (which === "sends") {
const result = await getSendHistory(businessId, after);
if (!result.success) return void toast.error(result.error);
setSends((rows) => (after ? [...(rows ?? []), ...result.rows] : result.rows));
setCursor((c) => ({ ...c, sends: result.nextCursor }));
} else {
const result = await getChangeHistory(businessId, after);
if (!result.success) return void toast.error(result.error);
setChanges((rows) => (after ? [...(rows ?? []), ...result.rows] : result.rows));
setCursor((c) => ({ ...c, changes: result.nextCursor }));
}
});
};
// Erst laden, wenn der jeweilige Reiter zum ersten Mal offen ist.
useEffect(() => {
if (tab === "sends" && sends === null) load("sends");
if (tab === "changes" && changes === null) load("changes");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab]);
const rows = tab === "sends" ? sends : changes;
const nextCursor = tab === "sends" ? cursor.sends : cursor.changes;
return (
<div className="flex flex-col gap-3">
<div className="flex w-fit gap-1 rounded-xl border border-white/60 bg-white/40 p-1">
{(
[
["sends", "Versand"],
["changes", "Änderungen"],
] as const
).map(([value, label]) => (
<Button
key={value}
size="sm"
variant={tab === value ? "default" : "ghost"}
onClick={() => setTab(value)}
>
{label}
</Button>
))}
</div>
<div
className={cn(
"rounded-xl border border-white/60 bg-white/55 backdrop-blur-xl",
isLoading && "opacity-70"
)}
>
{rows === null ? (
<p className="p-6 text-center text-sm text-gray-500">Lade…</p>
) : rows.length === 0 ? (
<p className="p-6 text-center text-sm text-gray-500">
{tab === "sends" ? "Noch keine Mails verschickt." : "Noch keine Änderungen."}
</p>
) : tab === "sends" ? (
<ul className="divide-y divide-white/50">
{(rows as SendHistoryRow[]).map((row) => (
<SendRow key={row.id} row={row} />
))}
</ul>
) : (
<ul className="divide-y divide-white/50">
{(rows as ChangeHistoryRow[]).map((row) => (
<li
key={row.id}
className="flex flex-col gap-0.5 p-4 sm:flex-row sm:items-center sm:gap-4"
>
<span className="w-36 shrink-0 text-xs text-gray-500">
{formatDateTime(row.createdAt)}
</span>
<span className="min-w-0 flex-1 text-sm">{row.summary}</span>
<span className="shrink-0 text-xs text-gray-500">{row.actor}</span>
</li>
))}
</ul>
)}
</div>
{nextCursor && (
<Button
variant="outline"
className="self-center bg-white/60"
disabled={isLoading}
onClick={() => load(tab, nextCursor)}
>
Mehr laden
</Button>
)}
</div>
);
};
const SendRow = ({ row }: { row: SendHistoryRow }) => {
const status = STATUS_STYLES[row.status] ?? STATUS_STYLES.pending;
return (
<li className="flex flex-col gap-2 p-4 sm:flex-row sm:items-center sm:gap-4">
<div className="min-w-0 sm:w-56 sm:shrink-0">
{row.customerId ? (
<Link
href={`/kunden/${row.customerId}`}
className="block truncate text-sm font-medium hover:underline"
>
{row.customerName || row.customerEmail}
</Link>
) : (
<span className="text-sm text-gray-500">Gelöschtes Konto</span>
)}
{row.customerName && row.customerEmail && (
<p className="truncate text-xs text-gray-500">{row.customerEmail}</p>
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{row.subject}</p>
<p className="text-xs text-gray-500">
{isTriggerKind(row.triggerKind) ? TRIGGER_LABELS[row.triggerKind] : row.triggerKind} ·{" "}
{formatDateTime(row.sentAt ?? row.createdAt)}
</p>
{row.status === "failed" && row.error && (
<p className="mt-0.5 truncate text-xs text-red-600" title={row.error}>
{row.error}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{row.code && (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-mono text-[11px]",
row.redeemed ? "bg-green-50 text-green-700" : "bg-gray-100 text-gray-600"
)}
title={row.redeemed ? "Eingelöst" : "Noch nicht eingelöst"}
>
{row.redeemed && <CheckCircle2 className="size-3" />}
{row.code}
</span>
)}
<span className={cn("rounded-full px-2 py-0.5 text-xs font-medium", status.className)}>
{status.label}
</span>
</div>
</li>
);
};
export default MarketingHistory;

View file

@ -0,0 +1,85 @@
"use client";
import { useTransition } from "react";
import { ArrowRight, Monitor } from "lucide-react";
import { toast } from "sonner";
import { Switch } from "@/src/components/ui/switch";
import { setTriggerActive, type EditorData } from "../../actions/editor-actions";
import { isTriggerKind, TRIGGER_LABELS, describeTrigger } from "../../lib/node-config";
import { nodeLabel } from "../../lib/graph";
import { NODE_ICONS } from "./flow/trigger-icons";
interface MobileFlowListProps {
businessId: string;
data: EditorData;
onSaved: (data: EditorData) => void;
}
/**
* Handy-Ansicht: keine Fläche, nur die Trigger mit ihrer E-Mail und dem
* Schalter. Ein Schalter wirkt sofort. Bearbeitet wird am Computer.
*/
const MobileFlowList = ({ businessId, data, onSaved }: MobileFlowListProps) => {
const [isPending, startTransition] = useTransition();
const byId = new Map(data.nodes.map((node) => [node.id, node]));
const triggers = data.nodes.filter((node) => isTriggerKind(node.kind));
const toggle = (nodeId: string, isActive: boolean) => {
startTransition(async () => {
const result = await setTriggerActive(businessId, nodeId, isActive);
if (!result.success) {
toast.error(result.error);
return;
}
onSaved(result.data);
});
};
return (
<div className="flex flex-col gap-3">
<p className="flex items-center gap-2 rounded-xl border border-white/60 bg-white/55 p-3 text-sm text-gray-600 backdrop-blur-xl">
<Monitor className="size-4 shrink-0" />
Trigger und E-Mails bearbeitest du am Computer. Hier kannst du sie ein- und ausschalten.
</p>
{triggers.length === 0 ? (
<p className="rounded-xl border border-white/60 bg-white/55 p-6 text-center text-sm text-gray-500 backdrop-blur-xl">
Noch keine Automationen angelegt.
</p>
) : (
<ul className="divide-y divide-white/50 rounded-xl border border-white/60 bg-white/55 backdrop-blur-xl">
{triggers.map((trigger) => {
if (!isTriggerKind(trigger.kind)) return null;
const Icon = NODE_ICONS[trigger.kind];
const email = trigger.emailNodeId ? byId.get(trigger.emailNodeId) : undefined;
return (
<li key={trigger.id} className="flex items-center gap-3 p-4">
<Icon className="size-5 shrink-0 text-gray-500" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{trigger.kind === "birthday"
? TRIGGER_LABELS.birthday
: describeTrigger(trigger.kind, trigger.config)}
</p>
<p className="flex items-center gap-1 truncate text-xs text-gray-500">
<ArrowRight className="size-3 shrink-0" />
{email ? nodeLabel(email) : "Nicht verbunden"}
{trigger.sent > 0 && ` · ${trigger.sent} verschickt`}
</p>
</div>
<Switch
checked={trigger.isActive}
disabled={isPending || (!email && !trigger.isActive)}
onCheckedChange={(checked) => toggle(trigger.id, checked)}
aria-label={`${TRIGGER_LABELS[trigger.kind]} ein- oder ausschalten`}
/>
</li>
);
})}
</ul>
)}
</div>
);
};
export default MobileFlowList;

View file

@ -0,0 +1,423 @@
"use client";
import { useRef, useTransition } from "react";
import { Send, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/src/components/ui/button";
import { Input } from "@/src/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/src/components/ui/select";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/src/components/ui/sheet";
import { Switch } from "@/src/components/ui/switch";
import { cn } from "@/src/lib/utils";
import { sendMarketingTestEmail } from "../../actions/editor-actions";
import {
EMAIL_TEMPLATES,
MILESTONE_LIMITS,
PLACEHOLDERS,
REWARD_APPLIES_TO_LABELS,
TRIGGER_DESCRIPTIONS,
TRIGGER_KINDS,
TRIGGER_LABELS,
WINBACK_LIMITS,
emailConfigSchema,
type Reward,
} from "../../lib/node-config";
import {
isTriggerNode,
type EmailFlowNode,
type FlowNode,
type TriggerFlowNode,
} from "./flow/flow-model";
interface NodeSettingsSheetProps {
node: FlowNode | null;
businessId: string;
emailOptions: { id: string; name: string }[];
/** Nur Trigger: die verbundene E-Mail */
connectedEmailId: string | null;
onClose: () => void;
onConfigChange: (nodeId: string, config: Record<string, unknown>) => void;
onActiveChange: (nodeId: string, isActive: boolean) => void;
onConnect: (triggerId: string, emailId: string | null) => void;
onDelete: (nodeId: string) => void;
}
const fieldClass = "bg-white md:text-sm";
/** Einstellungen des angeklickten Bausteins, rechts als Panel. */
const NodeSettingsSheet = (props: NodeSettingsSheetProps) => {
const { node, onClose } = props;
return (
<Sheet open={node !== null} onOpenChange={(open) => !open && onClose()}>
<SheetContent className="w-full overflow-y-auto sm:max-w-md">
{node && isTriggerNode(node) && <TriggerSettings {...props} node={node} />}
{node && !isTriggerNode(node) && <EmailSettings {...props} node={node} />}
</SheetContent>
</Sheet>
);
};
// ─── Trigger ─────────────────────────────────────────────────────────────────
const NONE = "__none__";
const TriggerSettings = ({
node,
emailOptions,
connectedEmailId,
onConfigChange,
onActiveChange,
onConnect,
onDelete,
}: NodeSettingsSheetProps & { node: TriggerFlowNode }) => {
const { kind, config, isActive, sent, activatedAt } = node.data;
const numberField = (key: "bookings" | "days", limits: { min: number; max: number }) => {
const value = typeof config[key] === "number" ? (config[key] as number) : "";
const invalid = typeof value !== "number" || value < limits.min || value > limits.max;
return (
<Input
type="number"
min={limits.min}
max={limits.max}
value={value}
aria-invalid={invalid}
onChange={(e) =>
onConfigChange(node.id, {
...config,
[key]: e.target.value === "" ? "" : Math.round(Number(e.target.value)),
})
}
className={cn(fieldClass, "w-24")}
/>
);
};
return (
<>
<SheetHeader>
<SheetTitle>{TRIGGER_LABELS[kind]}</SheetTitle>
<SheetDescription>{TRIGGER_DESCRIPTIONS[kind]}</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-5 px-4">
{kind === "milestone" && (
<label className="flex flex-wrap items-center gap-2 text-sm">
Nach dem {numberField("bookings", MILESTONE_LIMITS)}. besuchten Kurs
</label>
)}
{kind === "winback" && (
<label className="flex flex-wrap items-center gap-2 text-sm">
Seit {numberField("days", WINBACK_LIMITS)} Tagen kein Kurs und nichts gebucht
</label>
)}
{kind === "birthday" && (
<p className="text-sm text-gray-600">
Wer am 29. Februar Geburtstag hat, bekommt die Mail in anderen Jahren am 28. Februar.
</p>
)}
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-gray-600">Führt zu</span>
<Select
value={connectedEmailId ?? NONE}
onValueChange={(value) => onConnect(node.id, value === NONE ? null : value)}
>
<SelectTrigger className="w-full bg-white">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>Nicht verbunden</SelectItem>
{emailOptions.map((email) => (
<SelectItem key={email.id} value={email.id}>
E-Mail „{email.name}“
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between gap-4 rounded-lg border p-3">
<div>
<p className="text-sm font-medium">Eingeschaltet</p>
<p className="text-xs text-gray-500">
{activatedAt
? `Läuft seit ${new Date(activatedAt).toLocaleDateString("de-DE")}`
: "Wirkt nach dem Speichern"}
</p>
</div>
<Switch
checked={isActive}
onCheckedChange={(checked) => onActiveChange(node.id, checked)}
/>
</div>
<p className="rounded-lg bg-gray-50 p-3 text-xs leading-relaxed text-gray-600">
Gezählt wird erst ab dem Einschalten. Wer vorher schon so weit war, bekommt keine Mail -
so werden beim Einschalten nicht alle Bestandskunden auf einmal angeschrieben. Mails gehen
nur an Kunden, die zugestimmt haben, und höchstens eine pro Tag.
</p>
<p className="text-sm text-gray-600">
{sent} {sent === 1 ? "Mail" : "Mails"} über diesen Trigger verschickt
</p>
</div>
<SheetFooter>
<Button variant="outline" className="text-red-600" onClick={() => onDelete(node.id)}>
<Trash2 className="size-4" /> Trigger löschen
</Button>
</SheetFooter>
</>
);
};
// ─── E-Mail ──────────────────────────────────────────────────────────────────
const DEFAULT_REWARD: Reward = { type: "percent", value: 10, validDays: 30, appliesTo: "all" };
const EmailSettings = ({
node,
businessId,
onConfigChange,
onDelete,
}: NodeSettingsSheetProps & { node: EmailFlowNode }) => {
const config = node.data.config;
const reward = (config.reward ?? null) as Reward | null;
const bodyRef = useRef<HTMLTextAreaElement>(null);
const [isSending, startSending] = useTransition();
const parsed = emailConfigSchema.safeParse(config);
const errors = new Map<string, string>();
if (!parsed.success) {
for (const issue of parsed.error.issues) {
const key = issue.path.join(".");
if (!errors.has(key)) errors.set(key, issue.message);
}
}
const set = (patch: Record<string, unknown>) => onConfigChange(node.id, { ...config, ...patch });
const setReward = (patch: Partial<Reward>) =>
set({ reward: { ...(reward ?? DEFAULT_REWARD), ...patch } });
const insertPlaceholder = (key: string) => {
const body = typeof config.body === "string" ? config.body : "";
const textarea = bodyRef.current;
const start = textarea?.selectionStart ?? body.length;
const end = textarea?.selectionEnd ?? body.length;
set({ body: body.slice(0, start) + key + body.slice(end) });
requestAnimationFrame(() => {
textarea?.focus();
textarea?.setSelectionRange(start + key.length, start + key.length);
});
};
const sendTest = () => {
startSending(async () => {
const result = await sendMarketingTestEmail(businessId, config);
if (result.success) toast.success(`Testmail an ${result.to} verschickt.`);
else toast.error(result.error);
});
};
const error = (key: string) =>
errors.get(key) ? <p className="text-xs text-red-600">{errors.get(key)}</p> : null;
return (
<>
<SheetHeader>
<SheetTitle>E-Mail</SheetTitle>
<SheetDescription>
Absender ist dein Studio, Antworten landen bei deiner Studio-E-Mail.
</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-4 px-4">
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-gray-600">Vorlage einsetzen</span>
<Select
value=""
onValueChange={(kind) =>
set({ ...EMAIL_TEMPLATES[kind as keyof typeof EMAIL_TEMPLATES] })
}
>
<SelectTrigger className="w-full bg-white">
<SelectValue placeholder="Vorlage wählen …" />
</SelectTrigger>
<SelectContent>
{TRIGGER_KINDS.map((kind) => (
<SelectItem key={kind} value={kind}>
{TRIGGER_LABELS[kind]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-gray-600">Name (nur für dich)</span>
<Input
value={typeof config.name === "string" ? config.name : ""}
onChange={(e) => set({ name: e.target.value })}
aria-invalid={errors.has("name")}
className={fieldClass}
/>
{error("name")}
</label>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-gray-600">Betreff</span>
<Input
value={typeof config.subject === "string" ? config.subject : ""}
onChange={(e) => set({ subject: e.target.value })}
aria-invalid={errors.has("subject")}
className={fieldClass}
/>
{error("subject")}
</label>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-gray-600">Text</span>
<textarea
ref={bodyRef}
rows={9}
value={typeof config.body === "string" ? config.body : ""}
onChange={(e) => set({ body: e.target.value })}
aria-invalid={errors.has("body")}
className="w-full rounded-md border border-input bg-white px-3 py-2 text-sm shadow-xs outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive"
/>
{error("body")}
</label>
<div className="flex flex-wrap gap-1.5">
{PLACEHOLDERS.map((placeholder) => (
<button
key={placeholder.key}
type="button"
title={placeholder.description}
onClick={() => insertPlaceholder(placeholder.key)}
className="rounded-full border bg-white px-2 py-0.5 font-mono text-[11px] text-gray-600 hover:bg-gray-50"
>
{placeholder.key}
</button>
))}
</div>
<div className="flex flex-col gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">Persönlicher Rabattcode</p>
<p className="text-xs text-gray-500">
Jeder Kunde bekommt einen eigenen Code, einmal einlösbar.
</p>
</div>
<Switch
checked={reward !== null}
onCheckedChange={(checked) => set({ reward: checked ? DEFAULT_REWARD : null })}
/>
</div>
{reward && (
<>
<div className="flex items-end gap-2">
<label className="flex flex-1 flex-col gap-1.5">
<span className="text-xs font-medium text-gray-600">Rabatt</span>
<Input
type="number"
min={reward.type === "percent" ? 1 : 0.01}
step={reward.type === "percent" ? 1 : 0.01}
value={reward.type === "percent" ? reward.value : reward.value / 100}
onChange={(e) => {
const raw = Number(e.target.value);
setReward({
value: reward.type === "percent" ? Math.round(raw) : Math.round(raw * 100),
});
}}
aria-invalid={errors.has("reward.value")}
className={fieldClass}
/>
</label>
<div className="flex gap-1 rounded-lg border bg-white p-1">
{(["percent", "fixed"] as const).map((type) => (
<Button
key={type}
type="button"
size="sm"
variant={reward.type === type ? "default" : "ghost"}
onClick={() => setReward({ type, value: type === "percent" ? 10 : 500 })}
>
{type === "percent" ? "%" : "€"}
</Button>
))}
</div>
</div>
{error("reward.value")}
<label className="flex flex-wrap items-center gap-2 text-sm">
Gültig für
<Input
type="number"
min={1}
max={365}
value={reward.validDays}
onChange={(e) => setReward({ validDays: Math.round(Number(e.target.value)) })}
aria-invalid={errors.has("reward.validDays")}
className={cn(fieldClass, "w-20")}
/>
Tage ab Versand
</label>
{error("reward.validDays")}
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-gray-600">Gilt für</span>
<div className="flex gap-1 rounded-lg border bg-white p-1">
{(Object.keys(REWARD_APPLIES_TO_LABELS) as Reward["appliesTo"][]).map((value) => (
<Button
key={value}
type="button"
size="sm"
className="flex-1"
variant={reward.appliesTo === value ? "default" : "ghost"}
onClick={() => setReward({ appliesTo: value })}
>
{REWARD_APPLIES_TO_LABELS[value]}
</Button>
))}
</div>
</div>
</>
)}
</div>
<p className="text-sm text-gray-600">
{node.data.sent} verschickt
{reward ? ` · ${node.data.redeemed} Codes eingelöst` : ""}
</p>
</div>
<SheetFooter>
<Button variant="outline" onClick={sendTest} disabled={isSending || !parsed.success}>
<Send className="size-4" /> {isSending ? "Wird verschickt…" : "Testmail an mich"}
</Button>
<Button variant="outline" className="text-red-600" onClick={() => onDelete(node.id)}>
<Trash2 className="size-4" /> E-Mail löschen
</Button>
</SheetFooter>
</>
);
};
export default NodeSettingsSheet;

View file

@ -0,0 +1,63 @@
"use client";
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { Gift } from "lucide-react";
import { cn } from "@/src/lib/utils";
import { emailConfigSchema, formatRewardValue } from "../../../lib/node-config";
import type { EmailFlowNode } from "./flow-model";
import { NODE_ICONS } from "./trigger-icons";
const EmailNode = ({ data, selected }: NodeProps<EmailFlowNode>) => {
const parsed = emailConfigSchema.safeParse(data.config);
const config = parsed.success ? parsed.data : null;
const name = typeof data.config.name === "string" ? data.config.name : "";
const subject = typeof data.config.subject === "string" ? data.config.subject : "";
const Icon = NODE_ICONS.email;
return (
<div
className={cn(
"w-64 rounded-xl border bg-white p-3 shadow-sm",
selected
? "border-gray-900 ring-2 ring-gray-900/10"
: config
? "border-gray-200"
: "border-red-300"
)}
>
<Handle
type="target"
position={Position.Left}
className="size-3! border-2! border-white! bg-gray-900!"
/>
<div className="flex items-start gap-2.5">
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-sky-100 text-sky-700">
<Icon className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="text-[11px] font-medium uppercase tracking-wide text-gray-400">E-Mail</p>
<p className="truncate text-sm font-semibold text-gray-900">{name || "Ohne Namen"}</p>
<p className="truncate text-xs text-gray-500">{subject || "Kein Betreff"}</p>
</div>
</div>
<div className="mt-2 flex items-center justify-between gap-2 text-xs">
{config?.reward ? (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2 py-0.5 text-amber-800">
<Gift className="size-3" />
{formatRewardValue(config.reward)} · {config.reward.validDays} Tage
</span>
) : (
<span className={config ? "text-gray-400" : "text-red-600"}>
{config ? "Ohne Rabatt" : "Unvollständig"}
</span>
)}
<span className="text-gray-500">
{data.sent} verschickt
{config?.reward ? ` · ${data.redeemed} eingelöst` : ""}
</span>
</div>
</div>
);
};
export default EmailNode;

View file

@ -0,0 +1,374 @@
"use client";
import "@xyflow/react/dist/style.css";
import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
import {
Background,
Controls,
ReactFlow,
ReactFlowProvider,
applyEdgeChanges,
applyNodeChanges,
useReactFlow,
type Connection,
type Edge,
type EdgeChange,
type IsValidConnection,
type NodeChange,
} from "@xyflow/react";
import { toast } from "sonner";
import { Button } from "@/src/components/ui/button";
import { cn } from "@/src/lib/utils";
import { saveMarketingGraph, type EditorData } from "../../../actions/editor-actions";
import { validateGraph } from "../../../lib/graph";
import {
SINGLE_INSTANCE_TRIGGERS,
TRIGGER_KINDS,
TRIGGER_LABELS,
type NodeKind,
} from "../../../lib/node-config";
import NodeSettingsSheet from "../NodeSettingsSheet";
import EmailNode from "./EmailNode";
import { EditorActionsContext, type EditorActions } from "./editor-context";
import {
edgeId,
isTriggerNode,
newNode,
snapshot,
starterFlow,
toFlow,
toGraph,
type FlowNode,
} from "./flow-model";
import TriggerNode from "./TriggerNode";
import { NODE_ICONS } from "./trigger-icons";
const nodeTypes = { trigger: TriggerNode, email: EmailNode };
const DRAG_TYPE = "application/x-kumasy-node";
interface FlowEditorProps {
businessId: string;
data: EditorData;
onSaved: (data: EditorData) => void;
}
/**
* Node-Editor: Trigger links in die Fläche ziehen, mit einer E-Mail verbinden,
* einschalten, speichern. Änderungen sind ein Entwurf, bis "Speichern" sie
* übernimmt - erst dann landen sie im Änderungsprotokoll und wirken im Versand.
*/
const FlowEditor = (props: FlowEditorProps) => (
<ReactFlowProvider>
<FlowEditorInner {...props} />
</ReactFlowProvider>
);
function initialFlow(data: EditorData) {
return data.nodes.length > 0 ? toFlow(data.nodes) : starterFlow();
}
const FlowEditorInner = ({ businessId, data, onSaved }: FlowEditorProps) => {
const { screenToFlowPosition, fitView } = useReactFlow();
const isStarter = data.nodes.length === 0;
const [initial] = useState(() => initialFlow(data));
const [nodes, setNodes] = useState<FlowNode[]>(initial.nodes);
const [edges, setEdges] = useState<Edge[]>(initial.edges);
// Gespeicherter Stand; die Startvorlage gilt als ungespeichert.
const [savedSnapshot, setSavedSnapshot] = useState(() =>
isStarter ? "" : snapshot(initial.nodes, initial.edges)
);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isSaving, startSaving] = useTransition();
const wrapperRef = useRef<HTMLDivElement>(null);
const isDirty = snapshot(nodes, edges) !== savedSnapshot;
// Ungespeicherte Änderungen nicht stillschweigend verlieren.
useEffect(() => {
if (!isDirty) return;
const warn = (event: BeforeUnloadEvent) => event.preventDefault();
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [isDirty]);
const loadFrom = useCallback((next: EditorData) => {
const flow = toFlow(next.nodes);
setNodes(flow.nodes);
setEdges(flow.edges);
setSavedSnapshot(snapshot(flow.nodes, flow.edges));
}, []);
const onNodesChange = useCallback(
(changes: NodeChange<FlowNode>[]) => setNodes((current) => applyNodeChanges(changes, current)),
[]
);
const onEdgesChange = useCallback(
(changes: EdgeChange[]) => setEdges((current) => applyEdgeChanges(changes, current)),
[]
);
const isValidConnection: IsValidConnection = useCallback(
(connection) => {
const source = nodes.find((n) => n.id === connection.source);
const target = nodes.find((n) => n.id === connection.target);
return !!source && !!target && isTriggerNode(source) && !isTriggerNode(target);
},
[nodes]
);
const connect = useCallback((triggerId: string, emailId: string | null) => {
setEdges((current) => [
// Ein Trigger führt in genau eine E-Mail - eine neue Verbindung ersetzt die alte.
...current.filter((edge) => edge.source !== triggerId),
...(emailId ? [{ id: edgeId(triggerId), source: triggerId, target: emailId }] : []),
]);
}, []);
const onConnect = useCallback(
(connection: Connection) => connect(connection.source, connection.target),
[connect]
);
const setTriggerActive = useCallback((nodeId: string, isActive: boolean) => {
setNodes((current) =>
current.map((node) =>
node.id === nodeId && isTriggerNode(node)
? { ...node, data: { ...node.data, isActive } }
: node
)
);
}, []);
const setConfig = useCallback((nodeId: string, config: Record<string, unknown>) => {
setNodes((current) =>
current.map((node) =>
node.id === nodeId ? ({ ...node, data: { ...node.data, config } } as FlowNode) : node
)
);
}, []);
const deleteNode = useCallback((nodeId: string) => {
setNodes((current) => current.filter((node) => node.id !== nodeId));
setEdges((current) => current.filter((e) => e.source !== nodeId && e.target !== nodeId));
setSelectedId(null);
}, []);
// Bausteine, die es nur einmal geben darf und die schon auf der Fläche sind.
const placedSingles = new Set<NodeKind>(
nodes
.map((n) => n.data.kind)
.filter((kind) => (SINGLE_INSTANCE_TRIGGERS as readonly NodeKind[]).includes(kind))
);
const addNode = (kind: NodeKind, position?: { x: number; y: number }) => {
if (placedSingles.has(kind)) {
toast.info(`${TRIGGER_LABELS[kind as keyof typeof TRIGGER_LABELS]} gibt es nur einmal.`);
return;
}
// Ohne Position (Klick statt Ziehen): in die Mitte der sichtbaren Fläche.
const rect = wrapperRef.current?.getBoundingClientRect();
const at =
position ??
screenToFlowPosition({
x: (rect?.left ?? 0) + (rect?.width ?? 600) / 2 - 120,
y: (rect?.top ?? 0) + (rect?.height ?? 400) / 2 - 40,
});
const node = newNode(kind, at);
setNodes((current) => [...current, node]);
setSelectedId(node.id);
};
const onDrop = (event: React.DragEvent) => {
event.preventDefault();
const kind = event.dataTransfer.getData(DRAG_TYPE) as NodeKind;
if (!kind) return;
addNode(kind, screenToFlowPosition({ x: event.clientX - 120, y: event.clientY - 30 }));
};
const save = () => {
const graph = toGraph(nodes, edges);
const invalid = validateGraph(graph);
if (invalid) {
toast.error(invalid.message);
if (invalid.nodeId) setSelectedId(invalid.nodeId);
return;
}
startSaving(async () => {
const result = await saveMarketingGraph(businessId, graph);
if (!result.success) {
toast.error(result.error);
return;
}
loadFrom(result.data);
onSaved(result.data);
toast.success("Gespeichert.");
});
};
const discard = () => {
const flow = initialFlow(data);
setNodes(flow.nodes);
setEdges(flow.edges);
setSelectedId(null);
requestAnimationFrame(() => fitView({ padding: 0.2, maxZoom: 1 }));
};
// Verbindungen eingeschalteter Trigger laufen sichtbar.
const activeTriggerIds = new Set(
nodes.filter((n) => isTriggerNode(n) && n.data.isActive).map((n) => n.id)
);
const styledEdges = edges.map((edge) => ({
...edge,
animated: activeTriggerIds.has(edge.source),
style: activeTriggerIds.has(edge.source)
? { stroke: "#111827", strokeWidth: 2 }
: { stroke: "#9ca3af", strokeWidth: 2, strokeDasharray: "6 4" },
}));
const actions = useMemo<EditorActions>(() => ({ setTriggerActive }), [setTriggerActive]);
const selectedNode = nodes.find((n) => n.id === selectedId) ?? null;
const emailOptions = nodes
.filter((n) => !isTriggerNode(n))
.map((n) => ({
id: n.id,
name:
typeof n.data.config.name === "string" && n.data.config.name
? n.data.config.name
: "Ohne Namen",
}));
return (
<EditorActionsContext.Provider value={actions}>
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-gray-500">
{isStarter && !savedSnapshot
? "Drei Vorschläge sind vorbereitet - schalte ein, was du nutzen willst, und speichere."
: "Trigger aus der Leiste auf die Fläche ziehen und mit einer E-Mail verbinden."}
</p>
<div className="flex items-center gap-2">
{isDirty && <span className="text-xs text-amber-700">Ungespeicherte Änderungen</span>}
<Button variant="outline" size="sm" onClick={discard} disabled={!isDirty || isSaving}>
Verwerfen
</Button>
<Button size="sm" onClick={save} disabled={!isDirty || isSaving}>
{isSaving ? "Wird gespeichert…" : "Speichern"}
</Button>
</div>
</div>
<div className="flex h-[calc(100dvh-17rem)] min-h-[520px] overflow-hidden rounded-xl border border-white/60 bg-white/55 backdrop-blur-xl">
<aside className="flex w-52 shrink-0 flex-col gap-4 border-r border-white/60 bg-white/40 p-3">
<PaletteGroup title="Trigger">
{TRIGGER_KINDS.map((kind) => (
<PaletteItem
key={kind}
kind={kind}
label={TRIGGER_LABELS[kind]}
disabled={placedSingles.has(kind)}
onAdd={() => addNode(kind)}
/>
))}
</PaletteGroup>
<PaletteGroup title="Aktion">
<PaletteItem kind="email" label="E-Mail senden" onAdd={() => addNode("email")} />
</PaletteGroup>
<p className="mt-auto text-[11px] leading-relaxed text-gray-500">
Ziehen oder anklicken. Zum Verbinden vom Punkt rechts am Trigger zur E-Mail ziehen.
Entfernen mit der Löschtaste.
</p>
</aside>
<div
ref={wrapperRef}
className="relative flex-1"
onDragOver={(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}}
onDrop={onDrop}
>
<ReactFlow
nodes={nodes}
edges={styledEdges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
isValidConnection={isValidConnection}
onNodeClick={(_, node) => setSelectedId(node.id)}
onPaneClick={() => setSelectedId(null)}
deleteKeyCode={["Backspace", "Delete"]}
fitView
fitViewOptions={{ padding: 0.2, maxZoom: 1 }}
minZoom={0.3}
maxZoom={1.5}
>
<Background gap={20} size={1} />
<Controls showInteractive={false} />
</ReactFlow>
</div>
</div>
</div>
<NodeSettingsSheet
node={selectedNode}
businessId={businessId}
emailOptions={emailOptions}
connectedEmailId={
selectedNode ? (edges.find((e) => e.source === selectedNode.id)?.target ?? null) : null
}
onClose={() => setSelectedId(null)}
onConfigChange={setConfig}
onActiveChange={setTriggerActive}
onConnect={connect}
onDelete={deleteNode}
/>
</EditorActionsContext.Provider>
);
};
const PaletteGroup = ({ title, children }: { title: string; children: React.ReactNode }) => (
<div className="flex flex-col gap-1.5">
<p className="px-1 text-[11px] font-medium uppercase tracking-wide text-gray-400">{title}</p>
{children}
</div>
);
const PaletteItem = ({
kind,
label,
disabled = false,
onAdd,
}: {
kind: NodeKind;
label: string;
disabled?: boolean;
onAdd: () => void;
}) => {
const Icon = NODE_ICONS[kind];
return (
<button
type="button"
draggable={!disabled}
disabled={disabled}
onDragStart={(event) => {
event.dataTransfer.setData(DRAG_TYPE, kind);
event.dataTransfer.effectAllowed = "move";
}}
onClick={onAdd}
title={disabled ? "Schon auf der Fläche" : undefined}
className={cn(
"flex items-center gap-2 rounded-lg border bg-white px-2.5 py-2 text-left text-sm shadow-xs transition-colors",
disabled
? "cursor-not-allowed opacity-50"
: "cursor-grab hover:bg-gray-50 active:cursor-grabbing"
)}
>
<Icon className="size-4 shrink-0 text-gray-500" />
{label}
</button>
);
};
export default FlowEditor;

View file

@ -0,0 +1,70 @@
"use client";
import { Handle, Position, useNodeConnections, type NodeProps } from "@xyflow/react";
import { Switch } from "@/src/components/ui/switch";
import { cn } from "@/src/lib/utils";
import { TRIGGER_LABELS, describeTrigger } from "../../../lib/node-config";
import { useEditorActions } from "./editor-context";
import type { TriggerFlowNode } from "./flow-model";
import { NODE_ICONS } from "./trigger-icons";
const TriggerNode = ({ id, data, selected }: NodeProps<TriggerFlowNode>) => {
const { setTriggerActive } = useEditorActions();
const connected = useNodeConnections({ handleType: "source" }).length > 0;
const Icon = NODE_ICONS[data.kind];
const running = data.isActive && connected;
return (
<div
className={cn(
"w-60 rounded-xl border bg-white p-3 shadow-sm transition-[box-shadow,opacity]",
selected ? "border-gray-900 ring-2 ring-gray-900/10" : "border-gray-200",
!running && "opacity-80"
)}
>
<div className="flex items-start gap-2.5">
<span
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-lg",
running ? "bg-amber-100 text-amber-700" : "bg-gray-100 text-gray-500"
)}
>
<Icon className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="text-[11px] font-medium uppercase tracking-wide text-gray-400">Trigger</p>
<p className="truncate text-sm font-semibold text-gray-900">
{data.kind === "birthday"
? TRIGGER_LABELS.birthday
: describeTrigger(data.kind, data.config).replace(/^[^:]+: /, "")}
</p>
{data.kind !== "birthday" && (
<p className="text-xs text-gray-500">{TRIGGER_LABELS[data.kind]}</p>
)}
</div>
{/* nodrag: der Schalter darf den Node nicht verschieben oder öffnen */}
<div className="nodrag" onClick={(event) => event.stopPropagation()}>
<Switch
checked={data.isActive}
onCheckedChange={(checked) => setTriggerActive(id, checked)}
aria-label={`${TRIGGER_LABELS[data.kind]} ein- oder ausschalten`}
/>
</div>
</div>
<p className="mt-2 text-xs text-gray-500">
{!connected
? "Mit einer E-Mail verbinden"
: !data.isActive
? "Ausgeschaltet"
: `${data.sent} ${data.sent === 1 ? "Mail" : "Mails"} verschickt`}
</p>
<Handle
type="source"
position={Position.Right}
className="size-3! border-2! border-white! bg-gray-900!"
/>
</div>
);
};
export default TriggerNode;

View file

@ -0,0 +1,16 @@
"use client";
import { createContext, useContext } from "react";
/** Was die Nodes auf der Fläche am Editor auslösen dürfen. */
export type EditorActions = {
setTriggerActive: (nodeId: string, isActive: boolean) => void;
};
export const EditorActionsContext = createContext<EditorActions | null>(null);
export function useEditorActions(): EditorActions {
const actions = useContext(EditorActionsContext);
if (!actions) throw new Error("useEditorActions außerhalb des Editors");
return actions;
}

View file

@ -0,0 +1,138 @@
import type { Edge, Node } from "@xyflow/react";
import type { EditorNode } from "../../../actions/editor-actions";
import type { GraphNode } from "../../../lib/graph";
import {
DEFAULT_TRIGGER_CONFIG,
EMAIL_TEMPLATES,
TRIGGER_KINDS,
type TriggerKind,
} from "../../../lib/node-config";
/**
* Umrechnung zwischen dem gespeicherten Graph (GraphNode, Trigger zeigen per
* emailNodeId auf ihre E-Mail) und dem Zustand von React Flow (Nodes + Edges).
*/
export type TriggerNodeData = {
kind: TriggerKind;
config: Record<string, unknown>;
isActive: boolean;
sent: number;
activatedAt: string | null;
};
export type EmailNodeData = {
kind: "email";
config: Record<string, unknown>;
sent: number;
redeemed: number;
};
export type TriggerFlowNode = Node<TriggerNodeData, "trigger">;
export type EmailFlowNode = Node<EmailNodeData, "email">;
export type FlowNode = TriggerFlowNode | EmailFlowNode;
export function isTriggerNode(node: FlowNode): node is TriggerFlowNode {
return node.type === "trigger";
}
export function edgeId(triggerId: string): string {
return `edge-${triggerId}`;
}
export function toFlow(nodes: EditorNode[]): { nodes: FlowNode[]; edges: Edge[] } {
const flowNodes: FlowNode[] = nodes.map((node) =>
node.kind === "email"
? {
id: node.id,
type: "email",
position: node.position,
data: { kind: "email", config: node.config, sent: node.sent, redeemed: node.redeemed },
}
: {
id: node.id,
type: "trigger",
position: node.position,
data: {
kind: node.kind,
config: node.config,
isActive: node.isActive,
sent: node.sent,
activatedAt: node.activatedAt,
},
}
);
const edges: Edge[] = nodes
.filter((node) => node.kind !== "email" && node.emailNodeId)
.map((node) => ({ id: edgeId(node.id), source: node.id, target: node.emailNodeId! }));
return { nodes: flowNodes, edges };
}
export function toGraph(nodes: FlowNode[], edges: Edge[]): GraphNode[] {
return nodes.map((node) => ({
id: node.id,
kind: node.data.kind,
config: node.data.config,
position: { x: Math.round(node.position.x), y: Math.round(node.position.y) },
emailNodeId: isTriggerNode(node)
? (edges.find((edge) => edge.source === node.id)?.target ?? null)
: null,
isActive: isTriggerNode(node) ? node.data.isActive : false,
}));
}
/** Vergleichbarer Stand für "ungespeicherte Änderungen". */
export function snapshot(nodes: FlowNode[], edges: Edge[]): string {
return JSON.stringify(toGraph(nodes, edges));
}
export function newNode(kind: TriggerKind | "email", position: { x: number; y: number }): FlowNode {
const id = crypto.randomUUID();
if (kind === "email") {
return {
id,
type: "email",
position,
data: {
kind: "email",
config: {
name: "Neue E-Mail",
subject: "Hallo {vorname}",
body: "Hallo {vorname},\n\n\n\nIhr Team von {studio}",
reward: null,
},
sent: 0,
redeemed: 0,
},
};
}
return {
id,
type: "trigger",
position,
data: {
kind,
config: { ...DEFAULT_TRIGGER_CONFIG[kind] },
isActive: false,
sent: 0,
activatedAt: null,
},
};
}
/**
* Vorschlag für Studios ohne Automationen: jeder Trigger mit seiner passenden
* Vorlage verbunden, alles ausgeschaltet. Erst "Speichern" übernimmt ihn.
*/
export function starterFlow(): { nodes: FlowNode[]; edges: Edge[] } {
const nodes: FlowNode[] = [];
const edges: Edge[] = [];
TRIGGER_KINDS.forEach((kind, index) => {
const trigger = newNode(kind, { x: 0, y: index * 190 });
const email = newNode("email", { x: 380, y: index * 190 });
email.data.config = { ...EMAIL_TEMPLATES[kind] };
nodes.push(trigger, email);
edges.push({ id: edgeId(trigger.id), source: trigger.id, target: email.id });
});
return { nodes, edges };
}

View file

@ -0,0 +1,9 @@
import { Cake, Mail, Trophy, UserRoundX, type LucideIcon } from "lucide-react";
import type { NodeKind } from "../../../lib/node-config";
export const NODE_ICONS: Record<NodeKind, LucideIcon> = {
birthday: Cake,
milestone: Trophy,
winback: UserRoundX,
email: Mail,
};

View file

@ -0,0 +1,114 @@
"use client";
import { useRef, useState, useTransition } from "react";
import { Users } from "lucide-react";
import { toast } from "sonner";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/src/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/src/components/ui/tabs";
import { useIsMobile } from "@/src/hooks/use-mobile";
import { getMarketingEditor, type EditorData } from "../../actions/editor-actions";
import FlowEditor from "../components/flow/FlowEditor";
import MarketingHistory from "../components/MarketingHistory";
import MobileFlowList from "../components/MobileFlowList";
interface MarketingViewProps {
businesses: { id: string; name: string }[];
initialData: EditorData | null;
}
const MarketingView = ({ businesses, initialData }: MarketingViewProps) => {
const [businessId, setBusinessId] = useState(businesses[0]?.id ?? "");
const [data, setData] = useState<EditorData | null>(initialData);
const [isLoading, startLoading] = useTransition();
const isMobile = useIsMobile();
const requestSeq = useRef(0);
// Studio und Daten wechseln zusammen, erst wenn das neue Studio geladen ist.
// Sonst würde der Editor (key={businessId}) mit den Bausteinen des alten
// Studios neu starten und sie beim Speichern dem neuen zuordnen.
const changeBusiness = (next: string) => {
const seq = ++requestSeq.current;
startLoading(async () => {
const result = await getMarketingEditor(next);
if (seq !== requestSeq.current) return;
if (!result.success) {
toast.error(result.error);
return;
}
setBusinessId(next);
setData(result.data);
});
};
if (businesses.length === 0) {
return (
<div className="p-2">
<h1 className="text-4xl font-bold mb-6">Marketing</h1>
<p className="text-sm text-gray-500">Lege zuerst ein Business an.</p>
</div>
);
}
return (
<div className="p-2">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-4xl font-bold">Marketing</h1>
<p className="mt-1 text-sm text-gray-500">
Automatische E-Mails an Kunden, die dem zugestimmt haben - täglich am Morgen.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
{data && (
<span className="inline-flex w-fit items-center gap-1.5 rounded-full border border-white/60 bg-white/60 px-3 py-1 text-xs text-gray-600 backdrop-blur-xl">
<Users className="size-3.5" />
{data.consentingCustomers}{" "}
{data.consentingCustomers === 1 ? "Kunde hat" : "Kunden haben"} zugestimmt
</span>
)}
{businesses.length > 1 && (
<Select value={businessId} onValueChange={changeBusiness}>
<SelectTrigger className="w-full bg-white/60 backdrop-blur-xl border-white/40 sm:w-56">
<SelectValue placeholder="Business" />
</SelectTrigger>
<SelectContent>
{businesses.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
<Tabs defaultValue="automations" className={isLoading ? "opacity-60" : undefined}>
<TabsList className="bg-white/60 backdrop-blur-xl">
<TabsTrigger value="automations">Automationen</TabsTrigger>
<TabsTrigger value="history">Verlauf</TabsTrigger>
</TabsList>
<TabsContent value="automations">
{!data ? (
<p className="text-sm text-red-600">Das Marketing konnte nicht geladen werden.</p>
) : isMobile === undefined ? null : isMobile ? (
<MobileFlowList businessId={businessId} data={data} onSaved={setData} />
) : (
<FlowEditor key={businessId} businessId={businessId} data={data} onSaved={setData} />
)}
</TabsContent>
<TabsContent value="history">
<MarketingHistory key={businessId} businessId={businessId} />
</TabsContent>
</Tabs>
</div>
);
};
export default MarketingView;

View file

@ -0,0 +1,72 @@
"use client";
import { useState, useTransition } from "react";
import { Mail } from "lucide-react";
import { toast } from "sonner";
import { Switch } from "@/src/components/ui/switch";
import { setMyStudioConsent, type StudioConsentRow } from "../../actions/consent-actions";
interface StudioEmailsSettingsViewProps {
initialStudios: StudioConsentRow[];
}
const StudioEmailsSettingsView = ({ initialStudios }: StudioEmailsSettingsViewProps) => {
const [studios, setStudios] = useState(initialStudios);
const [, startSaving] = useTransition();
const toggle = (businessId: string, granted: boolean) => {
const update = (value: boolean) =>
setStudios((rows) =>
rows.map((row) => (row.businessId === businessId ? { ...row, granted: value } : row))
);
update(granted);
startSaving(async () => {
const result = await setMyStudioConsent(businessId, granted);
if (!result.success) {
update(!granted);
toast.error(result.error);
}
});
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl flex items-center gap-2 font-bold">
<Mail /> E-Mails von Studios
</h1>
<p className="text-sm text-gray-500 mt-1">
Legen Sie fest, welche Studios Ihnen Angebote und Glückwünsche schicken dürfen. Buchungs-
und Zahlungsbestätigungen bekommen Sie immer.
</p>
</div>
{studios.length === 0 ? (
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-6 text-center">
<p className="text-sm text-gray-500">
Sobald Sie bei einem Studio gebucht haben, erscheint es hier.
</p>
</div>
) : (
<ul className="divide-y divide-white/50 rounded-xl border border-white/60 bg-white/55 backdrop-blur-xl">
{studios.map((studio) => (
<li key={studio.businessId} className="flex items-start gap-4 p-4">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{studio.studioName}</p>
<p className="mt-0.5 text-sm text-gray-500">{studio.label}</p>
</div>
<Switch
checked={studio.granted}
onCheckedChange={(next) => toggle(studio.businessId, next)}
aria-label={`E-Mails von ${studio.studioName}`}
className="mt-1"
/>
</li>
))}
</ul>
)}
</div>
);
};
export default StudioEmailsSettingsView;

View file

@ -0,0 +1,74 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import { MailX, CheckCircle2 } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import { unsubscribeWithToken } from "../../actions/consent-actions";
interface UnsubscribeViewProps {
/** null = Link ungültig oder Studio gelöscht */
token: string | null;
studioName: string | null;
}
const UnsubscribeView = ({ token, studioName }: UnsubscribeViewProps) => {
const [done, setDone] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const unsubscribe = () => {
if (!token) return;
setError(null);
startTransition(async () => {
const result = await unsubscribeWithToken(token);
if (result.success) setDone(true);
else setError(result.error);
});
};
return (
<main className="flex min-h-dvh items-center justify-center px-4 py-10">
<div className="w-full max-w-md rounded-2xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 sm:p-8">
{!token ? (
<>
<MailX className="size-8 text-gray-400" />
<h1 className="mt-4 text-xl font-semibold text-gray-900">Link ungültig</h1>
<p className="mt-2 text-sm text-gray-600">
Dieser Abmeldelink ist ungültig oder abgelaufen. Wenn Sie ein Kundenkonto haben,
können Sie E-Mails von Studios auch in Ihren{" "}
<Link href="/settings/marketing" className="underline">
Einstellungen
</Link>{" "}
abbestellen.
</p>
</>
) : done ? (
<>
<CheckCircle2 className="size-8 text-green-600" />
<h1 className="mt-4 text-xl font-semibold text-gray-900">Sie sind abgemeldet</h1>
<p className="mt-2 text-sm text-gray-600">
Sie erhalten keine Angebote und Glückwünsche mehr von {studioName}. Buchungs- und
Zahlungsbestätigungen bekommen Sie weiterhin.
</p>
</>
) : (
<>
<MailX className="size-8 text-gray-400" />
<h1 className="mt-4 text-xl font-semibold text-gray-900">E-Mails abbestellen</h1>
<p className="mt-2 text-sm text-gray-600">
Möchten Sie keine Angebote und Glückwünsche mehr von <b>{studioName}</b> per E-Mail
erhalten? Buchungs- und Zahlungsbestätigungen bekommen Sie weiterhin.
</p>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
<Button onClick={unsubscribe} disabled={isPending} className="mt-6 w-full">
{isPending ? "Wird abgemeldet…" : "Abmelden"}
</Button>
</>
)}
</div>
</main>
);
};
export default UnsubscribeView;

View file

@ -18,6 +18,7 @@ import {
import { notifyManagerOfBooking } from "@/src/modules/courses/lib/notify-booking";
import { spotDisplayName } from "@/src/modules/rooms/lib/floor-plan";
import { canRedeemPass, passCoversCourse } from "../lib/pass-rules";
import { grantConsentQuietly } from "@/src/modules/marketing/lib/consent";
export type BookWithPassResult =
| { success: true; seats: number; creditsRemaining: number }
@ -55,6 +56,8 @@ export async function bookCourseWithPass(input: {
passId: string;
seats: number;
spotIds?: string[];
/** Häkchen für Werbe-Mails des Studios (Opt-in) */
marketingConsent?: boolean;
}): Promise<BookWithPassResult> {
const userData = await getUserData();
if (!userData || !isUser(userData)) {
@ -242,6 +245,14 @@ export async function bookCourseWithPass(input: {
.deleteMany({ where: { courseId, userId: userData.id } })
.catch((err) => console.error("Warteliste-Aufräumen fehlgeschlagen:", err));
if (input.marketingConsent && course.businessId) {
await grantConsentQuietly({
userId: userData.id,
businessId: course.businessId,
source: "course_booking",
});
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
const mail = generateBookingConfirmationEmail({
courseName: course.name,

View file

@ -0,0 +1,97 @@
"use server";
import { prisma } from "@/src/lib/prisma";
import { getUserData, isUser } from "@/src/lib/auth/getUser";
import {
calculateDiscountedPriceCents,
validateDiscountCode,
} from "@/src/modules/settings/lib/discount-validation";
import { hasMarketingConsent } from "@/src/modules/marketing/lib/consent";
import { consentLabel } from "@/src/modules/marketing/lib/consent-text";
/**
* Was der Kaufdialog einer Karte vor dem Checkout wissen muss: ob jemand mit
* Kundenkonto angemeldet ist und ob das Häkchen für Werbe-Mails noch fehlt.
*/
export async function getPassPurchaseContext(
productId: string
): Promise<
| { loggedIn: false }
| { loggedIn: true; isCustomer: boolean; marketingConsentLabel: string | null }
> {
const userData = await getUserData();
if (!userData) return { loggedIn: false };
if (!isUser(userData)) {
return { loggedIn: true, isCustomer: false, marketingConsentLabel: null };
}
const product = await prisma.product.findUnique({
where: { id: productId },
select: { businessId: true, business: { select: { name: true } } },
});
const granted = product ? await hasMarketingConsent(userData.id, product.businessId) : true;
return {
loggedIn: true,
isCustomer: true,
marketingConsentLabel: product && !granted ? consentLabel(product.business.name) : null,
};
}
/**
* Rabattcode-Vorschau im Kaufdialog. Reserviert nichts - das passiert erst beim
* Start des Checkouts (siehe api/stripe/checkout/pass).
*/
export async function previewPassDiscountCode(
productId: string,
code: string
): Promise<
| { valid: true; discountedPriceCents: number; discountAmountCents: number }
| {
valid: false;
error: string;
}
> {
try {
const userData = await getUserData();
if (!userData || !isUser(userData)) {
return { valid: false, error: "Rabattcodes sind nur für angemeldete Kunden verfügbar." };
}
const product = await prisma.product.findUnique({
where: { id: productId },
select: {
id: true,
businessId: true,
priceCents: true,
isActive: true,
business: { select: { isPublic: true } },
},
});
// Dieselben Bedingungen wie im Checkout - sonst zeigt die Vorschau einen
// Preis, den der Checkout dann ablehnt.
if (!product || !product.isActive || !product.business.isPublic) {
return { valid: false, error: "Diese Karte ist nicht verfügbar." };
}
const validation = await validateDiscountCode(prisma, {
code,
businessId: product.businessId,
productId: product.id,
userId: userData.id,
});
if (!validation.valid) return { valid: false, error: validation.reason };
const result = calculateDiscountedPriceCents(product.priceCents, validation.discountCode);
if (result.discountedPriceCents <= 0) {
return {
valid: false,
error: "Dieser Rabattcode reduziert den Preis auf 0 € und kann nicht verwendet werden.",
};
}
return { valid: true, ...result };
} catch (error) {
console.error("Rabattcode-Vorschau für Karte fehlgeschlagen:", error);
return { valid: false, error: "Rabattcode konnte nicht geprüft werden." };
}
}

View file

@ -9,6 +9,10 @@ import { sendMail } from "@/src/lib/mail/resend";
import { generatePassConfirmationEmail } from "@/src/lib/mail/templates/pass-confirmation";
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
import {
confirmDiscountReservation,
releaseDiscountReservation,
} from "@/src/modules/settings/lib/discount-validation";
import { validUntilFrom } from "./pass-rules";
/** Markiert Checkout-Sessions für einen Kartenkauf (siehe api/stripe/checkout/pass). */
@ -25,6 +29,22 @@ async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | null
]);
}
/**
* Ein Kartenkauf, der nie bezahlt wurde (Session abgelaufen, SEPA gescheitert):
* Eine Karte gibt es nicht - nur ein reservierter Rabattcode muss wieder frei
* werden.
*/
export async function handlePassCheckoutUnpaid(session: Stripe.Checkout.Session): Promise<void> {
const token = session.metadata?.discountReservationToken;
if (!token) return;
await releaseDiscountReservation(prisma, token).catch((error) =>
console.error(
`Rabattcode-Reservierung ${token} nach nicht bezahltem Kartenkauf nicht freigegeben:`,
error
)
);
}
/**
* Legt die gekaufte Karte an, sobald Stripe die Zahlung meldet - analog zur
* Kursbuchung nie auf der Success-Page. Idempotent über stripeSessionId: eine
@ -112,29 +132,46 @@ export async function handlePassCheckoutCompleted(
const now = new Date();
const validUntil = validUntilFrom(now, product.validityMonths);
// Rabatt als Momentaufnahme vom Checkout-Start (wie bei Kursbuchungen). Die
// Reservierung des Codes wird mit der neuen Karte bestätigt.
const discountCodeId = session.metadata?.discountCodeId || null;
const discountAmountCents = Number(session.metadata?.discountAmountCents ?? "0") || 0;
const discountReservationToken = session.metadata?.discountReservationToken || null;
let passId: string;
try {
const pass = await prisma.customerPass.create({
data: {
productId: product.id,
businessId: product.businessId,
userId: user.id,
email: user.email.toLowerCase(),
name: product.name,
sports: product.sports,
creditsTotal: product.credits,
creditsRemaining: product.credits,
validUntil,
source: "purchase",
priceCents: amountPaidCents - platformFeeCents,
stripeAccountId,
stripeSessionId: session.id,
stripePaymentIntentId: paymentIntentId,
amountPaid: amountPaidCents,
platformFee: platformFeeCents,
...(billingAddress ? { billingAddress } : {}),
},
select: { id: true },
const pass = await prisma.$transaction(async (tx) => {
const created = await tx.customerPass.create({
data: {
productId: product.id,
businessId: product.businessId,
userId: user.id,
email: user.email.toLowerCase(),
name: product.name,
sports: product.sports,
creditsTotal: product.credits,
creditsRemaining: product.credits,
validUntil,
source: "purchase",
priceCents: amountPaidCents - platformFeeCents,
stripeAccountId,
stripeSessionId: session.id,
stripePaymentIntentId: paymentIntentId,
amountPaid: amountPaidCents,
platformFee: platformFeeCents,
discountCodeId,
discountAmount: discountCodeId ? discountAmountCents : null,
...(billingAddress ? { billingAddress } : {}),
},
select: { id: true },
});
if (discountReservationToken) {
await confirmDiscountReservation(tx, {
reservationToken: discountReservationToken,
passId: created.id,
});
}
return created;
});
passId = pass.id;
} catch (error) {

View file

@ -0,0 +1,165 @@
"use client";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/src/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/src/components/ui/dialog";
import { formatPrice } from "@/src/lib/currency";
import MarketingConsentCheckbox from "@/src/modules/marketing/ui/components/MarketingConsentCheckbox";
import { previewPassDiscountCode } from "../../actions/pass-purchase-actions";
import { formatValidity } from "../../lib/card-format";
import type { PublicProduct } from "./PublicPassShop";
interface PassPurchaseDialogProps {
product: PublicProduct;
feeCents: number;
/** Text des Häkchens für Werbe-Mails, null = schon zugestimmt */
consentLabel: string | null;
accentColor: string;
accentTextColor: string;
onClose: () => void;
/** Startet den Checkout - leitet bei Erfolg zu Stripe weiter. */
onCheckout: (input: { discountCode: string | null; marketingConsent: boolean }) => Promise<void>;
}
/**
* Letzter Schritt vor dem Stripe-Checkout einer Karte: Endpreis, Rabattcode und
* das Häkchen für Werbe-Mails des Studios.
*/
const PassPurchaseDialog = ({
product,
feeCents,
consentLabel,
accentColor,
accentTextColor,
onClose,
onCheckout,
}: PassPurchaseDialogProps) => {
const [codeInput, setCodeInput] = useState("");
const [isChecking, setIsChecking] = useState(false);
const [applied, setApplied] = useState<{ code: string; priceCents: number } | null>(null);
const [consentChecked, setConsentChecked] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const cardPriceCents = applied?.priceCents ?? product.priceCents;
const applyCode = async () => {
const code = codeInput.trim();
if (!code) return;
setIsChecking(true);
try {
const result = await previewPassDiscountCode(product.id, code);
if (!result.valid) {
toast.error(result.error);
setApplied(null);
return;
}
setApplied({ code, priceCents: result.discountedPriceCents });
toast.success("Rabattcode angewendet.");
} catch {
toast.error("Rabattcode konnte nicht geprüft werden.");
setApplied(null);
} finally {
setIsChecking(false);
}
};
const checkout = async () => {
setIsSubmitting(true);
await onCheckout({
discountCode: applied?.code ?? null,
marketingConsent: consentLabel !== null && consentChecked,
});
setIsSubmitting(false);
};
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{product.name}</DialogTitle>
<DialogDescription>
{product.credits} {product.credits === 1 ? "Einheit" : "Einheiten"} ·{" "}
{formatValidity(product.validityMonths)}
{product.validityMonths !== null && " ab Kauf"}
</DialogDescription>
</DialogHeader>
<div className="space-y-1.5 text-sm">
<div className="flex justify-between text-gray-600">
<span>Karte</span>
<span>
{applied && (
<span className="mr-2 text-gray-400 line-through">
{formatPrice(product.priceCents)}
</span>
)}
{formatPrice(cardPriceCents)}
</span>
</div>
{feeCents > 0 && (
<div className="flex justify-between text-gray-600">
<span>Servicegebühr</span>
<span>{formatPrice(feeCents)}</span>
</div>
)}
<div className="flex justify-between border-t pt-1.5 font-semibold text-gray-900">
<span>Gesamt</span>
<span>{formatPrice(cardPriceCents + feeCents)}</span>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="text"
placeholder="Rabattcode"
value={codeInput}
onChange={(e) => {
setCodeInput(e.target.value.toUpperCase());
if (applied) setApplied(null);
}}
className="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-gray-400"
/>
<Button
variant="outline"
size="sm"
onClick={() => void applyCode()}
disabled={isChecking || !codeInput.trim()}
>
{isChecking ? "Prüfe…" : "Anwenden"}
</Button>
</div>
{consentLabel !== null && (
<MarketingConsentCheckbox
label={consentLabel}
checked={consentChecked}
onCheckedChange={setConsentChecked}
/>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Abbrechen
</Button>
<Button
onClick={() => void checkout()}
disabled={isSubmitting || isChecking}
style={{ backgroundColor: accentColor, color: accentTextColor }}
>
{isSubmitting ? "Wird geöffnet…" : "Weiter zur Zahlung"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default PassPurchaseDialog;

View file

@ -8,7 +8,9 @@ import { formatPrice } from "@/src/lib/currency";
import { cardServiceFeeCents, pricePerUnitCents } from "../../lib/card-pricing";
import { formatSports, formatValidity } from "../../lib/card-format";
import type { CardDesign } from "../../lib/card-design";
import { getPassPurchaseContext } from "../../actions/pass-purchase-actions";
import PassCard from "./PassCard";
import PassPurchaseDialog from "./PassPurchaseDialog";
export type PublicProduct = {
id: string;
@ -34,7 +36,8 @@ interface PublicPassShopProps {
/**
* Karten zum Kaufen auf der öffentlichen Studio-Seite. Der Preis steht wie bei
* den Kursen als Endpreis inklusive Servicegebühr da. Kaufen geht nur mit
* Konto - ohne Anmeldung geht es zum Login und danach zurück hierher.
* Konto - ohne Anmeldung geht es zum Login und danach zurück hierher. Mit Konto
* öffnet sich vor dem Checkout ein Dialog für Rabattcode und Werbe-Häkchen.
*/
const PublicPassShop = ({
products,
@ -47,14 +50,49 @@ const PublicPassShop = ({
}: PublicPassShopProps) => {
const router = useRouter();
const [buyingId, setBuyingId] = useState<string | null>(null);
const [purchase, setPurchase] = useState<{
product: PublicProduct;
consentLabel: string | null;
} | null>(null);
const buy = async (productId: string) => {
setBuyingId(productId);
const goToLogin = () => {
toast.info("Bitte melden Sie sich an, um eine Karte zu kaufen.");
router.push(`/login?returnTo=${encodeURIComponent(`/business/${studioSlug}`)}`);
};
const openPurchase = async (product: PublicProduct) => {
setBuyingId(product.id);
try {
const context = await getPassPurchaseContext(product.id);
if (!context.loggedIn) {
goToLogin();
return;
}
if (!context.isCustomer) {
toast.error("Nur Kunden-Accounts können Karten kaufen.");
return;
}
setPurchase({ product, consentLabel: context.marketingConsentLabel });
} catch {
toast.error("Ein unerwarteter Fehler ist aufgetreten.");
} finally {
setBuyingId(null);
}
};
const checkout = async (
productId: string,
options: { discountCode: string | null; marketingConsent: boolean }
) => {
try {
const response = await fetch("/api/stripe/checkout/pass", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ productId }),
body: JSON.stringify({
productId,
...(options.discountCode ? { discountCode: options.discountCode } : {}),
...(options.marketingConsent ? { marketingConsent: true } : {}),
}),
});
const data = (await response.json().catch(() => ({}))) as {
url?: string;
@ -63,19 +101,16 @@ const PublicPassShop = ({
};
if (data.loginRequired) {
toast.info("Bitte melden Sie sich an, um eine Karte zu kaufen.");
router.push(`/login?returnTo=${encodeURIComponent(`/business/${studioSlug}`)}`);
goToLogin();
return;
}
if (!response.ok || !data.url) {
toast.error(data.error ?? "Der Kauf konnte nicht gestartet werden.");
setBuyingId(null);
return;
}
window.location.assign(data.url);
} catch {
toast.error("Ein unerwarteter Fehler ist aufgetreten.");
setBuyingId(null);
}
};
@ -126,18 +161,31 @@ const PublicPassShop = ({
)}
<button
type="button"
onClick={() => void buy(product.id)}
onClick={() => void openPurchase(product)}
disabled={buyingId !== null}
className="mt-auto rounded-full px-5 py-2.5 text-sm font-semibold shadow-sm transition-[scale,opacity] duration-150 active:scale-95 disabled:opacity-60"
style={{ backgroundColor: accentColor, color: accentTextColor }}
>
{buyingId === product.id ? "Wird geöffnet…" : "Kaufen"}
{buyingId === product.id ? "Einen Moment…" : "Kaufen"}
</button>
</div>
</div>
);
})}
</div>
{purchase && (
<PassPurchaseDialog
key={purchase.product.id}
product={purchase.product}
feeCents={cardServiceFeeCents(purchase.product.credits, feePerUnitCents)}
consentLabel={purchase.consentLabel}
accentColor={accentColor}
accentTextColor={accentTextColor}
onClose={() => setPurchase(null)}
onCheckout={(options) => checkout(purchase.product.id, options)}
/>
)}
</section>
);
};

View file

@ -10,6 +10,7 @@ export type DiscountCodeDTO = {
code: string;
type: "percent" | "fixed";
value: number;
appliesTo: "courses" | "passes" | "all";
scope: "all_courses" | "specific_courses";
courseIds: string[];
maxRedemptions: number | null;
@ -40,8 +41,10 @@ export async function getDiscountCodes(
return { success: false, error: "Keine Berechtigung für dieses Business." };
}
// Persönliche Codes aus Marketing-Mails stehen im Verlauf des Marketings,
// nicht hier - sonst liefe die Liste mit Einmalcodes voll.
const codes = await prisma.discountCode.findMany({
where: { businessId },
where: { businessId, origin: "manual" },
orderBy: { createdAt: "desc" },
});
@ -100,6 +103,7 @@ export async function createDiscountCode(
code: normalizedCode,
type: parsed.data.type,
value: parsed.data.value,
appliesTo: parsed.data.appliesTo,
scope: parsed.data.scope,
courseIds: parsed.data.scope === "specific_courses" ? courseIds : [],
maxRedemptions: parsed.data.maxRedemptions,

View file

@ -9,6 +9,12 @@ type PrismaOrTx =
| typeof prismaClient
| Parameters<Parameters<typeof prismaClient.$transaction>[0]>[0];
/**
* Wofür ein Code eingelöst wird: eine Kursbuchung (courseId) oder ein
* Kartenkauf (productId). DiscountCode.appliesTo legt fest, was erlaubt ist.
*/
export type DiscountTarget = { courseId: string } | { productId: string };
export type DiscountValidationResult =
| { valid: true; discountCode: { id: string; type: string; value: number } }
| { valid: false; reason: string };
@ -19,7 +25,7 @@ export type DiscountValidationResult =
// reserveDiscountCode() gleich danach, nicht hier.
export async function validateDiscountCode(
db: PrismaOrTx,
params: { code: string; businessId: string; courseId: string; userId: string | null }
params: { code: string; businessId: string; userId: string | null } & DiscountTarget
): Promise<DiscountValidationResult> {
const normalizedCode = params.code.trim().toUpperCase();
if (!normalizedCode) {
@ -42,11 +48,20 @@ export async function validateDiscountCode(
return { valid: false, reason: "Dieser Rabattcode ist abgelaufen." };
}
if (
discountCode.scope === "specific_courses" &&
!discountCode.courseIds.includes(params.courseId)
) {
return { valid: false, reason: "Dieser Rabattcode gilt nicht für diesen Kurs." };
if ("productId" in params) {
if (discountCode.appliesTo !== "passes" && discountCode.appliesTo !== "all") {
return { valid: false, reason: "Dieser Rabattcode gilt nicht für Karten." };
}
} else {
if (discountCode.appliesTo === "passes") {
return { valid: false, reason: "Dieser Rabattcode gilt nur für Karten." };
}
if (
discountCode.scope === "specific_courses" &&
!discountCode.courseIds.includes(params.courseId)
) {
return { valid: false, reason: "Dieser Rabattcode gilt nicht für diesen Kurs." };
}
}
if (
@ -64,6 +79,12 @@ export async function validateDiscountCode(
return { valid: false, reason: "Rabattcodes sind nur für angemeldete Kunden verfügbar." };
}
// Persönliche Codes (z.B. aus einer Geburtstagsmail) gelten nur für das
// Konto, für das sie erzeugt wurden.
if (discountCode.restrictedToUserId && discountCode.restrictedToUserId !== params.userId) {
return { valid: false, reason: "Dieser Rabattcode gehört zu einem anderen Kundenkonto." };
}
const existingRedemption = await db.discountRedemption.findUnique({
where: { discountCodeId_userId: { discountCodeId: discountCode.id, userId: params.userId } },
});
@ -126,7 +147,7 @@ export type ReserveDiscountResult =
// "bereits reserviert" abfängt, statt ihn nach oben durchzureichen.
export async function reserveDiscountCode(
db: PrismaOrTx,
params: { code: string; businessId: string; courseId: string; userId: string }
params: { code: string; businessId: string; userId: string } & DiscountTarget
): Promise<ReserveDiscountResult> {
const validation = await validateDiscountCode(db, params);
if (!validation.valid) {
@ -230,13 +251,17 @@ export async function releaseDiscountReservation(
// tut diese Funktion nichts: die Buchung wurde vom Aufrufer bereits
// unabhängig davon zum schon belasteten (rabattierten) Preis bestätigt, nur
// ohne verbuchte Redemption. Das ist bewusstes Verhalten, kein Fehlerfall.
// Beim Kartenkauf gilt dasselbe, nur mit der neuen Karte statt einer Buchung.
export async function confirmDiscountReservation(
tx: PrismaOrTx,
params: { reservationToken: string; bookingId: string }
params: { reservationToken: string } & ({ bookingId: string } | { passId: string })
): Promise<boolean> {
const result = await tx.discountRedemption.updateMany({
where: { stripeSessionId: params.reservationToken, status: "reserved" },
data: { status: "confirmed", bookingId: params.bookingId },
data: {
status: "confirmed",
...("passId" in params ? { passId: params.passId } : { bookingId: params.bookingId }),
},
});
return result.count > 0;
}

View file

@ -13,6 +13,8 @@ export const discountCodeSchema = z
.regex(/^[A-Z0-9_-]+$/, "Nur Großbuchstaben, Zahlen, - und _ erlaubt"),
type: z.enum(["percent", "fixed"]),
value: z.number().int().positive(),
// Kurse, Kartenkauf oder beides. Die Kursauswahl (scope) wirkt nur auf Kurse.
appliesTo: z.enum(["courses", "passes", "all"]).default("courses"),
scope: z.enum(["all_courses", "specific_courses"]),
courseIds: z.array(z.string()).default([]),
maxRedemptions: z.number().int().positive().nullable(),
@ -28,6 +30,13 @@ export const discountCodeSchema = z
message: "Prozentwert darf maximal 100 sein",
});
}
if (data.appliesTo === "passes" && data.scope === "specific_courses") {
ctx.addIssue({
code: "custom",
path: ["scope"],
message: "Eine Kursauswahl gibt es nur für Codes, die auch für Kurse gelten",
});
}
if (data.scope === "specific_courses" && data.courseIds.length === 0) {
ctx.addIssue({
code: "custom",

View file

@ -33,6 +33,7 @@ interface DiscountCodeListProps {
code: string;
type: "percent" | "fixed";
value: number;
appliesTo: "courses" | "passes" | "all";
scope: "all_courses" | "specific_courses";
courseIds: string[];
maxRedemptions: number | null;
@ -57,6 +58,7 @@ const emptyForm = {
code: "",
type: "percent" as "percent" | "fixed",
value: 10,
appliesTo: "courses" as "courses" | "passes" | "all",
scope: "all_courses" as "all_courses" | "specific_courses",
courseIds: [] as string[],
maxRedemptions: "" as string, // leer = unbegrenzt, siehe toInput()
@ -166,8 +168,10 @@ const DiscountCodeList = ({
code: form.code,
type: form.type,
value: form.value,
scope: form.scope,
courseIds: form.courseIds,
appliesTo: form.appliesTo,
// Die Kursauswahl gibt es nur, wenn der Code auch für Kurse gilt.
scope: form.appliesTo === "passes" ? "all_courses" : form.scope,
courseIds: form.appliesTo === "passes" ? [] : form.courseIds,
maxRedemptions: form.maxRedemptions.trim() ? Number(form.maxRedemptions) : null,
startsAt: form.startsAt ? parseLocalStartOfDay(form.startsAt) : null,
expiresAt: form.expiresAt ? parseLocalEndOfDay(form.expiresAt) : null,
@ -325,30 +329,56 @@ const DiscountCodeList = ({
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-gray-600">Gültigkeitsbereich</label>
<label className="text-xs font-medium text-gray-600">Gilt für</label>
<div className="flex gap-2 border border-white/60 bg-white/40 rounded-xl p-1">
<Button
type="button"
variant={form.scope === "all_courses" ? "default" : "ghost"}
size="sm"
className="flex-1"
onClick={() => setForm((f) => ({ ...f, scope: "all_courses" }))}
>
Alle Kurse
</Button>
<Button
type="button"
variant={form.scope === "specific_courses" ? "default" : "ghost"}
size="sm"
className="flex-1"
onClick={() => setForm((f) => ({ ...f, scope: "specific_courses" }))}
>
Bestimmte Kurse
</Button>
{(
[
["courses", "Kurse"],
["passes", "Karten"],
["all", "Kurse & Karten"],
] as const
).map(([value, label]) => (
<Button
key={value}
type="button"
variant={form.appliesTo === value ? "default" : "ghost"}
size="sm"
className="flex-1"
onClick={() => setForm((f) => ({ ...f, appliesTo: value }))}
>
{label}
</Button>
))}
</div>
</div>
{form.scope === "specific_courses" && (
{form.appliesTo !== "passes" && (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-gray-600">Gültigkeitsbereich</label>
<div className="flex gap-2 border border-white/60 bg-white/40 rounded-xl p-1">
<Button
type="button"
variant={form.scope === "all_courses" ? "default" : "ghost"}
size="sm"
className="flex-1"
onClick={() => setForm((f) => ({ ...f, scope: "all_courses" }))}
>
Alle Kurse
</Button>
<Button
type="button"
variant={form.scope === "specific_courses" ? "default" : "ghost"}
size="sm"
className="flex-1"
onClick={() => setForm((f) => ({ ...f, scope: "specific_courses" }))}
>
Bestimmte Kurse
</Button>
</div>
</div>
)}
{form.appliesTo !== "passes" && form.scope === "specific_courses" && (
<MultiSelectDropdown
label="Kurse"
selected={form.courseIds}
@ -614,12 +644,19 @@ const DiscountCodeList = ({
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<Layers size={13} className="shrink-0 text-gray-400" />
{c.scope === "all_courses" ? (
<span>Gilt für alle Kurse</span>
{c.appliesTo === "passes" ? (
<span>Gilt für den Kartenkauf</span>
) : c.scope === "all_courses" ? (
<span>
{c.appliesTo === "all"
? "Gilt für alle Kurse und Karten"
: "Gilt für alle Kurse"}
</span>
) : (
<span className="truncate">
Gilt für:{" "}
{c.courseIds.map((id) => courseNameById.get(id) ?? "Unbekannt").join(", ")}
{c.appliesTo === "all" && " und Karten"}
</span>
)}
</div>

View file

@ -5,6 +5,7 @@ import {
Building2,
CreditCard,
LucideIcon,
Mail,
Megaphone,
PanelTop,
Settings,
@ -103,6 +104,14 @@ const SettingsOverviewView = async () => {
icon={Bell}
href="/settings/notifications"
/>
{isUser(userData) && (
<SettingsMenuItem
name="E-Mails von Studios"
description="Angebote und Glückwünsche pro Studio ein- oder ausschalten"
icon={Mail}
href="/settings/marketing"
/>
)}
</div>
</div>
);

View file

@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/src/modules/marketing/lib/run-marketing", () => ({ runMarketing: vi.fn() }));
import { GET } from "@/src/app/api/cron/marketing/route";
import { runMarketing } from "@/src/modules/marketing/lib/run-marketing";
function request(query = "", auth: string | null = "Bearer geheim") {
return new NextRequest(`http://localhost/api/cron/marketing${query}`, {
headers: auth ? { authorization: auth } : {},
});
}
beforeEach(() => {
vi.clearAllMocks();
process.env.CRON_SECRET = "geheim";
delete process.env.VERCEL_ENV;
vi.mocked(runMarketing).mockResolvedValue({
today: "2026-10-05",
dryRun: false,
incomplete: false,
businesses: [],
});
});
afterEach(() => {
delete process.env.VERCEL_ENV;
});
describe("GET /api/cron/marketing", () => {
it("lässt nur Aufrufe mit dem Secret durch", async () => {
expect((await GET(request("", null))).status).toBe(401);
expect((await GET(request("", "Bearer falsch"))).status).toBe(401);
expect(runMarketing).not.toHaveBeenCalled();
});
it("startet den Lauf mit den Test-Parametern", async () => {
const res = await GET(request("?dryRun=1&date=2026-05-12&businessId=biz-1"));
expect(res.status).toBe(200);
expect(runMarketing).toHaveBeenCalledWith(
expect.objectContaining({ dryRun: true, today: "2026-05-12", businessId: "biz-1" })
);
});
it("erlaubt kein Datum in Production", async () => {
process.env.VERCEL_ENV = "production";
expect((await GET(request("?date=2026-05-12"))).status).toBe(400);
expect(runMarketing).not.toHaveBeenCalled();
});
it("lehnt ein kaputtes Datum ab", async () => {
expect((await GET(request("?date=12.05.2026"))).status).toBe(400);
});
it("verweigert ohne CRON_SECRET", async () => {
delete process.env.CRON_SECRET;
expect((await GET(request())).status).toBe(500);
});
});

View file

@ -10,11 +10,26 @@ vi.mock("@/src/lib/stripe-checkout", () => ({
createBrandedCheckoutSession: vi.fn(),
studioCheckoutBranding: () => ({}),
}));
vi.mock("@/src/modules/settings/lib/discount-validation", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/src/modules/settings/lib/discount-validation")>();
return {
reserveDiscountCode: vi.fn(),
releaseDiscountReservation: vi.fn().mockResolvedValue(undefined),
calculateDiscountedPriceCents: actual.calculateDiscountedPriceCents,
};
});
vi.mock("@/src/modules/marketing/lib/consent", () => ({ grantConsentQuietly: vi.fn() }));
import { POST } from "@/src/app/api/stripe/checkout/pass/route";
import { prisma } from "@/src/lib/prisma";
import { getUserData } from "@/src/lib/auth/getUser";
import { createBrandedCheckoutSession } from "@/src/lib/stripe-checkout";
import {
releaseDiscountReservation,
reserveDiscountCode,
} from "@/src/modules/settings/lib/discount-validation";
import { grantConsentQuietly } from "@/src/modules/marketing/lib/consent";
const product = {
id: "prod-1",
@ -93,4 +108,84 @@ describe("POST /api/stripe/checkout/pass", () => {
]);
expect(params.metadata).toMatchObject({ kind: "pass", productId: "prod-1", userId: "user-1" });
});
describe("mit Rabattcode", () => {
beforeEach(() => {
vi.mocked(reserveDiscountCode).mockResolvedValue({
reserved: true,
reservationToken: "tok-1",
discountCode: { id: "code-1", type: "percent", value: 25 },
});
});
it("mindert nur den Kartenpreis, nie die Servicegebühr", async () => {
const res = await POST(request({ productId: "prod-1", discountCode: "KARTE25" }));
expect(res.status).toBe(200);
expect(reserveDiscountCode).toHaveBeenCalledWith(expect.anything(), {
code: "KARTE25",
businessId: "biz-1",
productId: "prod-1",
userId: "user-1",
});
const params = vi.mocked(createBrandedCheckoutSession).mock.calls[0][0];
expect(params.line_items?.[0].price_data?.unit_amount).toBe(9000);
expect(params.payment_intent_data?.application_fee_amount).toBe(500);
expect(params.metadata).toMatchObject({
discountCodeId: "code-1",
discountAmountCents: "3000",
discountReservationToken: "tok-1",
});
});
it("lehnt einen ungültigen Code ab, statt zum vollen Preis weiterzumachen", async () => {
vi.mocked(reserveDiscountCode).mockResolvedValue({
reserved: false,
reason: "Dieser Rabattcode gilt nicht für Karten.",
});
const res = await POST(request({ productId: "prod-1", discountCode: "KURS10" }));
expect(res.status).toBe(400);
expect((await res.json()).error).toBe("Dieser Rabattcode gilt nicht für Karten.");
expect(createBrandedCheckoutSession).not.toHaveBeenCalled();
});
it("gibt die Reservierung frei, wenn Stripe die Session nicht anlegt", async () => {
vi.mocked(createBrandedCheckoutSession).mockRejectedValue(new Error("Stripe weg"));
vi.spyOn(console, "error").mockImplementation(() => {});
const res = await POST(request({ productId: "prod-1", discountCode: "KARTE25" }));
expect(res.status).toBe(500);
expect(releaseDiscountReservation).toHaveBeenCalledWith(expect.anything(), "tok-1");
});
});
it("antwortet bei falschen Feldtypen mit 400 statt 500", async () => {
const res = await POST(request({ productId: "prod-1", discountCode: 5 }));
expect(res.status).toBe(400);
expect(reserveDiscountCode).not.toHaveBeenCalled();
expect((await POST(request({ productId: "" }))).status).toBe(400);
});
it("antwortet bei kaputtem JSON mit 400", async () => {
const res = await POST(
new Request("http://localhost/api/stripe/checkout/pass", {
method: "POST",
body: "{kein json",
}) as never
);
expect(res.status).toBe(400);
});
it("speichert das Häkchen für Werbe-Mails", async () => {
await POST(request({ productId: "prod-1", marketingConsent: true }));
expect(grantConsentQuietly).toHaveBeenCalledWith({
userId: "user-1",
businessId: "biz-1",
source: "pass_purchase",
});
});
it("ohne Häkchen keine Einwilligung", async () => {
await POST(request({ productId: "prod-1" }));
expect(grantConsentQuietly).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("next/cache", () => ({ revalidateTag: vi.fn() }));
vi.mock("@/src/lib/prisma", () => {
const prisma = {
marketingConsent: { findUnique: vi.fn(), upsert: vi.fn() },
marketingAuditLog: { createMany: vi.fn() },
$transaction: vi.fn(),
};
prisma.$transaction.mockImplementation((fn: (tx: unknown) => unknown) => fn(prisma));
return { prisma };
});
import { Prisma } from "@/src/generated/prisma/client";
import { prisma } from "@/src/lib/prisma";
import { setMarketingConsent } from "@/src/modules/marketing/lib/consent";
import { CONSENT_TEXT_VERSION } from "@/src/modules/marketing/lib/consent-text";
beforeEach(() => {
vi.clearAllMocks();
});
describe("setMarketingConsent", () => {
it("speichert die Einwilligung mit Wortlaut-Version und protokolliert sie", async () => {
vi.mocked(prisma.marketingConsent.findUnique).mockResolvedValue(null);
const changed = await setMarketingConsent({
userId: "user-1",
businessId: "biz-1",
granted: true,
source: "course_booking",
});
expect(changed).toBe(true);
const upsert = vi.mocked(prisma.marketingConsent.upsert).mock.calls[0][0];
expect(upsert.create).toMatchObject({
granted: true,
textVersion: CONSENT_TEXT_VERSION,
source: "course_booking",
});
expect(upsert.create?.grantedAt).toBeInstanceOf(Date);
const [entry] = vi.mocked(prisma.marketingAuditLog.createMany).mock.calls[0][0]!.data as {
action: string;
actorType: string;
userId: string;
}[];
expect(entry).toMatchObject({
action: "consent.granted",
actorType: "customer",
userId: "user-1",
});
});
it("protokolliert den Widerruf", async () => {
vi.mocked(prisma.marketingConsent.findUnique).mockResolvedValue({ granted: true } as never);
await setMarketingConsent({
userId: "user-1",
businessId: "biz-1",
granted: false,
source: "unsubscribe_link",
});
const upsert = vi.mocked(prisma.marketingConsent.upsert).mock.calls[0][0];
expect(upsert.update).toMatchObject({ granted: false });
expect(upsert.update?.revokedAt).toBeInstanceOf(Date);
const [entry] = vi.mocked(prisma.marketingAuditLog.createMany).mock.calls[0][0]!.data as {
action: string;
}[];
expect(entry.action).toBe("consent.revoked");
});
it("ändert nichts, wenn der Stand schon stimmt", async () => {
vi.mocked(prisma.marketingConsent.findUnique).mockResolvedValue({ granted: true } as never);
const changed = await setMarketingConsent({
userId: "user-1",
businessId: "biz-1",
granted: true,
source: "profile",
});
expect(changed).toBe(false);
expect(prisma.marketingConsent.upsert).not.toHaveBeenCalled();
expect(prisma.marketingAuditLog.createMany).not.toHaveBeenCalled();
});
it("versucht es einmal neu, wenn eine parallele Anfrage die Zeile zuerst anlegt", async () => {
// Beide Anfragen lesen "keine Zeile"; unsere scheitert beim Anlegen am Unique-Key.
// Im zweiten Durchlauf ist die Zeile der anderen Anfrage da.
vi.mocked(prisma.marketingConsent.findUnique)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ granted: true } as never);
vi.mocked(prisma.marketingConsent.upsert).mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
})
);
const changed = await setMarketingConsent({
userId: "user-1",
businessId: "biz-1",
granted: true,
source: "profile",
});
expect(changed).toBe(false);
expect(prisma.marketingConsent.findUnique).toHaveBeenCalledTimes(2);
});
it("gibt andere Fehler weiter", async () => {
vi.mocked(prisma.marketingConsent.findUnique).mockRejectedValue(new Error("DB weg"));
await expect(
setMarketingConsent({ userId: "u", businessId: "b", granted: true, source: "profile" })
).rejects.toThrow("DB weg");
});
});

View file

@ -0,0 +1,122 @@
import { describe, it, expect } from "vitest";
import {
diffGraph,
normalizeGraph,
validateGraph,
type GraphNode,
} from "@/src/modules/marketing/lib/graph";
import { EMAIL_TEMPLATES } from "@/src/modules/marketing/lib/node-config";
const ids = {
birthday: "11111111-1111-4111-8111-111111111111",
milestone: "22222222-2222-4222-8222-222222222222",
mail: "33333333-3333-4333-8333-333333333333",
mail2: "44444444-4444-4444-8444-444444444444",
};
const mail = (
id = ids.mail,
config: Record<string, unknown> = { ...EMAIL_TEMPLATES.birthday }
): GraphNode => ({
id,
kind: "email",
config,
position: { x: 300, y: 0 },
emailNodeId: null,
isActive: false,
});
const birthday = (overrides: Partial<GraphNode> = {}): GraphNode => ({
id: ids.birthday,
kind: "birthday",
config: {},
position: { x: 0, y: 0 },
emailNodeId: ids.mail,
isActive: true,
...overrides,
});
describe("validateGraph", () => {
it("akzeptiert einen verbundenen Trigger mit gültiger E-Mail", () => {
expect(validateGraph([birthday(), mail()])).toBeNull();
});
it("erlaubt Geburtstag nur einmal", () => {
const second = birthday({ id: ids.milestone });
expect(validateGraph([birthday(), second, mail()])?.message).toMatch(/nur einmal/);
});
it("lehnt Verbindungen zu fehlenden E-Mails ab", () => {
expect(validateGraph([birthday({ emailNodeId: ids.mail2 }), mail()])?.message).toMatch(
/nicht gefunden/
);
});
it("prüft die Werte der Trigger", () => {
const milestone: GraphNode = {
...birthday({ id: ids.milestone }),
kind: "milestone",
config: { bookings: 0 },
};
expect(validateGraph([milestone, mail()])?.nodeId).toBe(ids.milestone);
});
it("verlangt einen Rabatt für {code}", () => {
const invalid = mail(ids.mail, {
...EMAIL_TEMPLATES.birthday,
reward: null,
body: "Ihr Code: {code}",
});
expect(validateGraph([invalid])?.message).toMatch(/\{code\}/);
});
it("lehnt Bausteine ohne gültige ID ab", () => {
expect(validateGraph([mail("abc")])?.message).toMatch(/Ungültig/);
});
});
describe("diffGraph", () => {
it("protokolliert Anlegen, Verbinden und Einschalten", () => {
const actions = diffGraph([], [birthday(), mail()]).map((c) => c.action);
expect(actions).toEqual(
expect.arrayContaining(["node.created", "trigger.connected", "trigger.activated"])
);
});
it("ignoriert reines Verschieben", () => {
const before = [birthday(), mail()];
const after = [birthday({ position: { x: 99, y: 99 } }), mail()];
expect(diffGraph(before, after)).toEqual([]);
});
it("nennt geänderte Felder einer E-Mail", () => {
const before = [mail()];
const after = [mail(ids.mail, { ...EMAIL_TEMPLATES.birthday, subject: "Neu" })];
const [change] = diffGraph(before, after);
expect(change.action).toBe("node.updated");
expect(change.summary).toContain("Betreff");
});
it("protokolliert Ausschalten und Löschen", () => {
const before = [birthday(), mail(), mail(ids.mail2)];
const after = [birthday({ isActive: false }), mail()];
const actions = diffGraph(before, after).map((c) => c.action);
expect(actions).toEqual(expect.arrayContaining(["node.deleted", "trigger.deactivated"]));
});
});
describe("normalizeGraph", () => {
it("speichert nur geprüfte Werte", () => {
const [trigger, email] = normalizeGraph([
birthday({ config: { fremd: "x" } }),
{
...mail(ids.mail, { ...EMAIL_TEMPLATES.birthday, subject: " Hallo ", extra: 1 }),
isActive: true,
},
]);
expect(trigger.config).toEqual({});
expect(email.config).not.toHaveProperty("extra");
expect(email.config.subject).toBe("Hallo");
expect(email.isActive).toBe(false);
});
});

View file

@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
vi.mock("@/src/hooks/use-mobile", () => ({ useIsMobile: () => true }));
vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
vi.mock("@/src/modules/marketing/actions/editor-actions", () => ({
getMarketingEditor: vi.fn(),
setTriggerActive: vi.fn(),
getSendHistory: vi.fn(),
getChangeHistory: vi.fn(),
}));
// Radix-Select lässt sich in jsdom kaum bedienen - ein natives <select> reicht hier.
vi.mock("@/src/components/ui/select", () => ({
Select: ({
value,
onValueChange,
children,
}: {
value: string;
onValueChange: (v: string) => void;
children: ReactNode;
}) => (
<select aria-label="Business" value={value} onChange={(e) => onValueChange(e.target.value)}>
{children}
</select>
),
SelectTrigger: () => null,
SelectValue: () => null,
SelectContent: ({ children }: { children: ReactNode }) => <>{children}</>,
SelectItem: ({ value, children }: { value: string; children: ReactNode }) => (
<option value={value}>{children}</option>
),
}));
import { toast } from "sonner";
import MarketingView from "@/src/modules/marketing/ui/views/marketing-view";
import {
getMarketingEditor,
setTriggerActive,
type EditorData,
} from "@/src/modules/marketing/actions/editor-actions";
import { EMAIL_TEMPLATES } from "@/src/modules/marketing/lib/node-config";
function editorData(prefix: string): EditorData {
return {
consentingCustomers: 3,
nodes: [
{
id: `${prefix}-trigger`,
kind: "birthday",
config: {},
position: { x: 0, y: 0 },
emailNodeId: `${prefix}-mail`,
isActive: false,
sent: 0,
redeemed: 0,
activatedAt: null,
},
{
id: `${prefix}-mail`,
kind: "email",
config: { ...EMAIL_TEMPLATES.birthday, name: `Mail ${prefix}` },
position: { x: 300, y: 0 },
emailNodeId: null,
isActive: false,
sent: 0,
redeemed: 0,
activatedAt: null,
},
],
};
}
const businesses = [
{ id: "biz-a", name: "Studio A" },
{ id: "biz-b", name: "Studio B" },
];
beforeEach(() => {
vi.clearAllMocks();
});
describe("MarketingView - Studio wechseln", () => {
it("wechselt Studio und Daten erst zusammen, wenn das neue Studio geladen ist", async () => {
let resolve!: (value: Awaited<ReturnType<typeof getMarketingEditor>>) => void;
vi.mocked(getMarketingEditor).mockReturnValue(new Promise((r) => (resolve = r)));
vi.mocked(setTriggerActive).mockResolvedValue({ success: true, data: editorData("b") });
render(<MarketingView businesses={businesses} initialData={editorData("a")} />);
expect(screen.getByText(/Mail a/)).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Business"), { target: { value: "biz-b" } });
// Solange B lädt, bleibt alles bei A - auch die Auswahl.
expect(screen.getByText(/Mail a/)).toBeInTheDocument();
expect(screen.getByLabelText("Business")).toHaveValue("biz-a");
await act(async () => resolve({ success: true, data: editorData("b") }));
expect(screen.getByText(/Mail b/)).toBeInTheDocument();
expect(screen.getByLabelText("Business")).toHaveValue("biz-b");
await act(async () => {
fireEvent.click(screen.getByRole("switch"));
});
expect(setTriggerActive).toHaveBeenCalledWith("biz-b", "b-trigger", true);
});
it("bleibt beim alten Studio, wenn das neue nicht lädt", async () => {
vi.mocked(getMarketingEditor).mockResolvedValue({ success: false, error: "Kaputt" });
render(<MarketingView businesses={businesses} initialData={editorData("a")} />);
await act(async () => {
fireEvent.change(screen.getByLabelText("Business"), { target: { value: "biz-b" } });
});
expect(toast.error).toHaveBeenCalledWith("Kaputt");
expect(screen.getByLabelText("Business")).toHaveValue("biz-a");
expect(screen.getByText(/Mail a/)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,40 @@
import { describe, it, expect } from "vitest";
import { firstName, renderPlaceholders } from "@/src/modules/marketing/lib/placeholders";
describe("renderPlaceholders", () => {
it("ersetzt alle Platzhalter", () => {
expect(
renderPlaceholders(
"Hallo {vorname}, {anzahl} Kurse bei {studio}: {code} ({rabatt}, bis {gueltig_bis})",
{
vorname: "Anna",
studio: "Pilates Loft",
anzahl: 10,
code: "DANKE-ABC234",
rabatt: "10 %",
gueltig_bis: "31.10.2026",
}
)
).toBe("Hallo Anna, 10 Kurse bei Pilates Loft: DANKE-ABC234 (10 %, bis 31.10.2026)");
});
it("räumt auf, wenn der Name fehlt", () => {
expect(
renderPlaceholders("Alles Gute zum Geburtstag, {vorname}!", { vorname: "", studio: "X" })
).toBe("Alles Gute zum Geburtstag!");
expect(renderPlaceholders("Hallo {vorname},\n\nschön", { vorname: "", studio: "X" })).toBe(
"Hallo,\n\nschön"
);
});
it("lässt unbekannte geschweifte Klammern stehen", () => {
expect(renderPlaceholders("{foo} {studio}", { vorname: "", studio: "X" })).toBe("{foo} X");
});
});
describe("firstName", () => {
it("nimmt den ersten Teil des Namens", () => {
expect(firstName(" Anna Maria Schmidt ")).toBe("Anna");
expect(firstName(null)).toBe("");
});
});

View file

@ -0,0 +1,247 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("@/src/lib/prisma", () => ({
prisma: {
marketingNode: { findMany: vi.fn() },
business: { findUnique: vi.fn() },
marketingConsent: { findMany: vi.fn() },
courseBooking: { findMany: vi.fn() },
marketingSend: {
findMany: vi.fn(),
create: vi.fn(),
findUnique: vi.fn(),
updateMany: vi.fn(),
update: vi.fn(),
},
discountCode: { create: vi.fn(), findUnique: vi.fn() },
},
}));
vi.mock("@/src/lib/mail/resend", () => ({ sendMail: vi.fn() }));
import { Prisma } from "@/src/generated/prisma/client";
import { prisma } from "@/src/lib/prisma";
import { sendMail } from "@/src/lib/mail/resend";
import { runMarketing } from "@/src/modules/marketing/lib/run-marketing";
import { EMAIL_TEMPLATES } from "@/src/modules/marketing/lib/node-config";
const TODAY = "2026-05-12";
const birthdayTrigger = {
id: "trigger-1",
businessId: "biz-1",
kind: "birthday",
config: {},
activatedAt: new Date("2026-01-01T10:00:00Z"),
emailNodeId: "mail-1",
};
function setup({ withReward = true } = {}) {
vi.mocked(prisma.marketingNode.findMany).mockImplementation(((args: {
where: { kind: unknown };
}) =>
Promise.resolve(
args.where.kind === "email"
? [
{
id: "mail-1",
config: withReward
? EMAIL_TEMPLATES.birthday
: { ...EMAIL_TEMPLATES.birthday, reward: null },
},
]
: [birthdayTrigger]
)) as never);
vi.mocked(prisma.business.findUnique).mockResolvedValue({
id: "biz-1",
name: "Pilates Loft",
email: "hallo@loft.de",
slug: "loft",
isPublic: true,
branding: null,
} as never);
vi.mocked(prisma.marketingConsent.findMany).mockResolvedValue([
{
user: {
id: "user-1",
email: "anna@example.com",
name: "Anna Schmidt",
birthday: new Date("1990-05-12"),
},
},
] as never);
vi.mocked(prisma.courseBooking.findMany).mockResolvedValue([]);
vi.mocked(prisma.marketingSend.findMany).mockResolvedValue([]);
vi.mocked(prisma.marketingSend.create).mockResolvedValue({
id: "send-1",
discountCodeId: null,
} as never);
vi.mocked(prisma.marketingSend.update).mockResolvedValue({} as never);
vi.mocked(prisma.discountCode.create).mockImplementation(((args: {
data: { code: string; expiresAt: Date };
}) =>
Promise.resolve({
id: "code-1",
code: args.data.code,
expiresAt: args.data.expiresAt,
})) as never);
vi.mocked(sendMail).mockResolvedValue({ success: true, messageId: "m1" } as never);
}
async function run(options: Parameters<typeof runMarketing>[0]) {
const promise = runMarketing(options);
await vi.runAllTimersAsync();
return promise;
}
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
process.env.MARKETING_TOKEN_SECRET = "test-secret";
process.env.NEXT_PUBLIC_APP_URL = "https://app.kumasy.de";
setup();
});
afterEach(() => {
vi.useRealTimers();
});
describe("runMarketing", () => {
it("verschickt die Geburtstagsmail mit persönlichem Code", async () => {
const report = await run({ today: TODAY });
expect(report.businesses[0]).toMatchObject({ due: 1, sent: 1, failed: 0 });
expect(prisma.marketingSend.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
businessId: "biz-1",
userId: "user-1",
triggerKind: "birthday",
periodKey: "2026",
status: "pending",
}),
})
);
const codeData = vi.mocked(prisma.discountCode.create).mock.calls[0][0].data;
expect(codeData).toMatchObject({
businessId: "biz-1",
restrictedToUserId: "user-1",
origin: "marketing",
maxRedemptions: 1,
appliesTo: "all",
type: "percent",
value: 15,
});
expect(codeData.code).toMatch(/^GEBURTSTAG-[A-Z2-9]{6}$/);
const mail = vi.mocked(sendMail).mock.calls[0][0];
expect(mail).toMatchObject({
to: "anna@example.com",
subject: "Alles Gute zum Geburtstag, Anna!",
fromName: "Pilates Loft über Kumasy",
replyTo: "hallo@loft.de",
idempotencyKey: "marketing-send-1",
});
expect(mail.headers?.["List-Unsubscribe"]).toMatch(
/^<https:\/\/app\.kumasy\.de\/api\/marketing\/unsubscribe\?t=/
);
expect(mail.headers?.["List-Unsubscribe-Post"]).toBe("List-Unsubscribe=One-Click");
expect(mail.html).toContain(codeData.code);
expect(mail.html).toContain("https://app.kumasy.de/abmelden?t=");
expect(prisma.marketingSend.update).toHaveBeenLastCalledWith(
expect.objectContaining({
where: { id: "send-1" },
data: expect.objectContaining({ status: "sent" }),
})
);
});
it("legt ohne Rabatt keinen Code an", async () => {
setup({ withReward: false });
await run({ today: TODAY });
expect(prisma.discountCode.create).not.toHaveBeenCalled();
expect(sendMail).toHaveBeenCalledTimes(1);
});
it("zeigt im Probelauf nur, wer was bekäme", async () => {
const report = await run({ today: TODAY, dryRun: true });
expect(report.businesses[0].items).toEqual([
expect.objectContaining({
trigger: "birthday",
customer: "anna@example.com",
withCode: true,
}),
]);
expect(prisma.marketingSend.create).not.toHaveBeenCalled();
expect(sendMail).not.toHaveBeenCalled();
});
it("verschickt nichts zweimal", async () => {
vi.mocked(prisma.marketingSend.findMany).mockImplementation(((args: {
where: { status?: string };
}) =>
Promise.resolve(
args.where.status
? []
: [
{
userId: "user-1",
triggerKind: "birthday",
periodKey: "2026",
status: "sent",
attempts: 1,
},
]
)) as never);
const report = await run({ today: TODAY });
expect(report.businesses[0].due).toBe(0);
expect(sendMail).not.toHaveBeenCalled();
});
it("überlässt eine Zeile, die ein paralleler Lauf hält, diesem Lauf", async () => {
vi.mocked(prisma.marketingSend.create).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
code: "P2002",
clientVersion: "test",
})
);
vi.mocked(prisma.marketingSend.findUnique).mockResolvedValue({
id: "send-1",
discountCodeId: null,
} as never);
vi.mocked(prisma.marketingSend.updateMany).mockResolvedValue({ count: 0 });
const report = await run({ today: TODAY });
expect(report.businesses[0]).toMatchObject({ sent: 0, skipped: 1 });
expect(sendMail).not.toHaveBeenCalled();
});
it("vermerkt einen gescheiterten Versand", async () => {
vi.mocked(sendMail).mockResolvedValue({
success: false,
error: new Error("rate_limit_exceeded"),
} as never);
vi.spyOn(console, "error").mockImplementation(() => {});
const report = await run({ today: TODAY });
expect(report.businesses[0]).toMatchObject({ sent: 0, failed: 1 });
expect(prisma.marketingSend.update).toHaveBeenLastCalledWith({
where: { id: "send-1" },
data: { status: "failed", error: "rate_limit_exceeded" },
});
});
it("schreibt ohne Einwilligungen niemanden an", async () => {
vi.mocked(prisma.marketingConsent.findMany).mockResolvedValue([]);
const report = await run({ today: TODAY });
expect(report.businesses[0]).toMatchObject({ consentingCustomers: 0, due: 0 });
expect(prisma.courseBooking.findMany).not.toHaveBeenCalled();
});
it("verschickt ohne Secret für den Abmeldelink nichts", async () => {
delete process.env.MARKETING_TOKEN_SECRET;
await expect(runMarketing({ today: TODAY })).rejects.toThrow(/MARKETING_TOKEN_SECRET/);
});
});

View file

@ -0,0 +1,187 @@
import { describe, it, expect } from "vitest";
import {
birthdayInYear,
findDueEvents,
type ActiveTrigger,
type CustomerFacts,
} from "@/src/modules/marketing/lib/triggers";
// activatedAt als Berliner Mittag, damit der Tag eindeutig ist.
const activatedOn = (day: string) => new Date(`${day}T10:00:00Z`);
function trigger(overrides: Partial<ActiveTrigger> & Pick<ActiveTrigger, "kind">): ActiveTrigger {
return {
id: `t-${overrides.kind}`,
config: {},
activatedAt: activatedOn("2026-01-01"),
emailNodeId: "mail-1",
...overrides,
};
}
function customer(overrides: Partial<CustomerFacts> = {}): CustomerFacts {
return { userId: "u1", birthday: null, bookingDays: [], ...overrides };
}
describe("birthdayInYear", () => {
it("nimmt Monat und Tag aus UTC", () => {
expect(birthdayInYear(new Date("1990-05-12"), 2026)).toBe("2026-05-12");
});
it("feiert den 29.2. in anderen Jahren am 28.2.", () => {
expect(birthdayInYear(new Date("1996-02-29"), 2027)).toBe("2027-02-28");
expect(birthdayInYear(new Date("1996-02-29"), 2028)).toBe("2028-02-29");
});
});
describe("Geburtstag", () => {
const birthday = trigger({ kind: "birthday" });
it("ist am Geburtstag fällig, Zeitraum ist das Jahr", () => {
const events = findDueEvents({
today: "2026-05-12",
triggers: [birthday],
customers: [customer({ birthday: new Date("1990-05-12") })],
});
expect(events).toEqual([
expect.objectContaining({ triggerKind: "birthday", userId: "u1", periodKey: "2026" }),
]);
});
it("holt bis zu zwei Tage nach, danach nicht mehr", () => {
const facts = [customer({ birthday: new Date("1990-05-12") })];
expect(
findDueEvents({ today: "2026-05-14", triggers: [birthday], customers: facts })
).toHaveLength(1);
expect(
findDueEvents({ today: "2026-05-15", triggers: [birthday], customers: facts })
).toHaveLength(0);
});
it("holt am 1.1. den 31.12. des Vorjahres nach", () => {
const events = findDueEvents({
today: "2027-01-01",
triggers: [birthday],
customers: [customer({ birthday: new Date("1990-12-31") })],
});
expect(events[0]?.periodKey).toBe("2026");
});
it("zählt keinen Geburtstag vor dem Einschalten", () => {
const events = findDueEvents({
today: "2026-05-13",
triggers: [trigger({ kind: "birthday", activatedAt: activatedOn("2026-05-13") })],
customers: [customer({ birthday: new Date("1990-05-12") })],
});
expect(events).toHaveLength(0);
});
it("überspringt Kunden ohne Geburtsdatum", () => {
expect(
findDueEvents({ today: "2026-05-12", triggers: [birthday], customers: [customer()] })
).toHaveLength(0);
});
});
describe("Meilenstein", () => {
const milestone = trigger({ kind: "milestone", config: { bookings: 3 } });
it("ist fällig, sobald der 3. Kurs vorbei ist", () => {
const events = findDueEvents({
today: "2026-03-11",
triggers: [milestone],
customers: [customer({ bookingDays: ["2026-03-01", "2026-03-05", "2026-03-10"] })],
});
expect(events).toEqual([
expect.objectContaining({ periodKey: "3", milestoneCount: 3, triggerKind: "milestone" }),
]);
});
it("zählt kommende Kurse nicht mit", () => {
const events = findDueEvents({
today: "2026-03-11",
triggers: [milestone],
customers: [customer({ bookingDays: ["2026-03-01", "2026-03-05", "2026-03-12"] })],
});
expect(events).toHaveLength(0);
});
it("schreibt niemanden an, der die Schwelle vor dem Einschalten erreicht hat", () => {
const events = findDueEvents({
today: "2026-03-11",
triggers: [
trigger({
kind: "milestone",
config: { bookings: 3 },
activatedAt: activatedOn("2026-03-08"),
}),
],
customers: [
customer({ bookingDays: ["2026-03-01", "2026-03-02", "2026-03-05", "2026-03-10"] }),
],
});
expect(events).toHaveLength(0);
});
it("holt höchstens sieben Tage nach", () => {
const facts = [customer({ bookingDays: ["2026-03-01", "2026-03-02", "2026-03-03"] })];
expect(
findDueEvents({ today: "2026-03-10", triggers: [milestone], customers: facts })
).toHaveLength(1);
expect(
findDueEvents({ today: "2026-03-11", triggers: [milestone], customers: facts })
).toHaveLength(0);
});
});
describe("Reaktivierung", () => {
const winback = trigger({ kind: "winback", config: { days: 30 } });
it("ist fällig, wenn der letzte Kurs 30 Tage her ist", () => {
const events = findDueEvents({
today: "2026-04-30",
triggers: [winback],
customers: [customer({ bookingDays: ["2026-03-01", "2026-03-31"] })],
});
expect(events).toEqual([expect.objectContaining({ periodKey: "2026-03-31:30" })]);
});
it("schreibt niemanden an, der schon wieder gebucht hat", () => {
const events = findDueEvents({
today: "2026-04-30",
triggers: [winback],
customers: [customer({ bookingDays: ["2026-03-31", "2026-05-02"] })],
});
expect(events).toHaveLength(0);
});
it("schreibt niemanden an, der schon vor dem Einschalten so lange weg war", () => {
const events = findDueEvents({
today: "2026-06-01",
triggers: [
trigger({ kind: "winback", config: { days: 30 }, activatedAt: activatedOn("2026-05-15") }),
],
customers: [customer({ bookingDays: ["2026-03-31"] })],
});
expect(events).toHaveLength(0);
});
it("braucht mindestens einen besuchten Kurs", () => {
expect(
findDueEvents({ today: "2026-04-30", triggers: [winback], customers: [customer()] })
).toHaveLength(0);
});
});
it("sortiert Geburtstag vor Meilenstein vor Reaktivierung", () => {
const events = findDueEvents({
today: "2026-05-12",
triggers: [
trigger({ kind: "winback", config: { days: 30 } }),
trigger({ kind: "milestone", config: { bookings: 1 } }),
trigger({ kind: "birthday" }),
],
customers: [customer({ birthday: new Date("1990-05-12"), bookingDays: ["2026-04-11"] })],
});
expect(events.map((e) => e.triggerKind)).toEqual(["birthday", "winback"]);
});

View file

@ -0,0 +1,30 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
createUnsubscribeToken,
verifyUnsubscribeToken,
} from "@/src/modules/marketing/lib/unsubscribe-token";
beforeEach(() => {
process.env.MARKETING_TOKEN_SECRET = "test-secret";
});
describe("Abmelde-Token", () => {
it("liefert Kunde und Studio zurück", () => {
const token = createUnsubscribeToken("user-1", "biz-1");
expect(verifyUnsubscribeToken(token)).toEqual({ userId: "user-1", businessId: "biz-1" });
});
it("lehnt veränderte Tokens ab", () => {
const token = createUnsubscribeToken("user-1", "biz-1");
const forged = token.replace("user-1", "user-2");
expect(verifyUnsubscribeToken(forged)).toBeNull();
expect(verifyUnsubscribeToken("kaputt")).toBeNull();
expect(verifyUnsubscribeToken(null)).toBeNull();
});
it("lehnt Tokens mit anderem Secret ab", () => {
const token = createUnsubscribeToken("user-1", "biz-1");
process.env.MARKETING_TOKEN_SECRET = "anderes-secret";
expect(verifyUnsubscribeToken(token)).toBeNull();
});
});

View file

@ -0,0 +1,58 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/src/lib/prisma", () => ({ prisma: { product: { findUnique: vi.fn() } } }));
vi.mock("@/src/lib/auth/getUser", () => ({
getUserData: vi.fn(),
isUser: (user: { role: string }) => user.role === "user",
}));
vi.mock("@/src/modules/settings/lib/discount-validation", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/src/modules/settings/lib/discount-validation")>();
return { ...actual, validateDiscountCode: vi.fn() };
});
vi.mock("@/src/modules/marketing/lib/consent", () => ({ hasMarketingConsent: vi.fn() }));
import { prisma } from "@/src/lib/prisma";
import { getUserData } from "@/src/lib/auth/getUser";
import { validateDiscountCode } from "@/src/modules/settings/lib/discount-validation";
import { previewPassDiscountCode } from "@/src/modules/products/actions/pass-purchase-actions";
const product = {
id: "prod-1",
businessId: "biz-1",
priceCents: 12000,
isActive: true,
business: { isPublic: true },
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getUserData).mockResolvedValue({ id: "user-1", role: "user" } as never);
vi.mocked(prisma.product.findUnique).mockResolvedValue(product as never);
vi.mocked(validateDiscountCode).mockResolvedValue({
valid: true,
discountCode: { id: "code-1", type: "percent", value: 25 },
});
});
describe("previewPassDiscountCode", () => {
it("rechnet den Kartenpreis mit Rabatt", async () => {
expect(await previewPassDiscountCode("prod-1", "KARTE25")).toEqual({
valid: true,
discountedPriceCents: 9000,
discountAmountCents: 3000,
});
});
it("lehnt Karten nicht öffentlicher Studios ab - wie der Checkout", async () => {
vi.mocked(prisma.product.findUnique).mockResolvedValue({
...product,
business: { isPublic: false },
} as never);
expect(await previewPassDiscountCode("prod-1", "KARTE25")).toEqual({
valid: false,
error: "Diese Karte ist nicht verfügbar.",
});
expect(validateDiscountCode).not.toHaveBeenCalled();
});
});

View file

@ -1,12 +1,20 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/src/lib/prisma", () => ({
prisma: {
vi.mock("@/src/lib/prisma", () => {
const prisma = {
customerPass: { findUnique: vi.fn(), create: vi.fn() },
product: { findUnique: vi.fn() },
user: { findUnique: vi.fn() },
},
}));
discountRedemption: { updateMany: vi.fn(), findUnique: vi.fn(), delete: vi.fn() },
discountCode: { update: vi.fn() },
$transaction: vi.fn(),
};
// Callback-Transaktion: läuft mit demselben Mock; Array-Transaktion: alle Promises.
prisma.$transaction.mockImplementation((arg: unknown) =>
typeof arg === "function" ? arg(prisma) : Promise.all(arg as Promise<unknown>[])
);
return { prisma };
});
vi.mock("@/src/lib/stripe", () => ({
stripe: { paymentIntents: { retrieve: vi.fn() } },
onAccount: (id: string | null) => (id ? { stripeAccount: id } : undefined),
@ -20,6 +28,7 @@ vi.mock("@/src/modules/notifications/lib/create-notification", () => ({
import {
handlePassCheckoutCompleted,
handlePassCheckoutUnpaid,
isPassCheckout,
} from "@/src/modules/products/lib/pass-webhook";
import { prisma } from "@/src/lib/prisma";
@ -125,3 +134,55 @@ describe("handlePassCheckoutCompleted", () => {
);
});
});
describe("Kartenkauf mit Rabattcode", () => {
const discounted = {
...session,
amount_total: 10500,
metadata: {
...session.metadata,
discountCodeId: "code-1",
discountAmountCents: "2000",
discountReservationToken: "tok-1",
},
};
it("speichert den Rabatt an der Karte und bestätigt die Reservierung", async () => {
vi.mocked(prisma.discountRedemption.updateMany).mockResolvedValue({ count: 1 } as never);
await handlePassCheckoutCompleted(discounted as never, "acct_studio");
const { data } = vi.mocked(prisma.customerPass.create).mock.calls[0][0];
expect(data).toMatchObject({
discountCodeId: "code-1",
discountAmount: 2000,
// Der Kartenpreis ist schon rabattiert: gezahlt minus Servicegebühr.
priceCents: 10000,
});
expect(prisma.discountRedemption.updateMany).toHaveBeenCalledWith({
where: { stripeSessionId: "tok-1", status: "reserved" },
data: { status: "confirmed", passId: "pass-1" },
});
});
it("gibt die Reservierung frei, wenn der Kauf nie bezahlt wurde", async () => {
vi.mocked(prisma.discountRedemption.findUnique).mockResolvedValue({
id: "red-1",
discountCodeId: "code-1",
status: "reserved",
} as never);
await handlePassCheckoutUnpaid(discounted as never);
expect(prisma.discountRedemption.delete).toHaveBeenCalledWith({ where: { id: "red-1" } });
expect(prisma.discountCode.update).toHaveBeenCalledWith({
where: { id: "code-1" },
data: { redeemedCount: { decrement: 1 } },
});
});
it("ohne Rabattcode bleibt nichts freizugeben", async () => {
await handlePassCheckoutUnpaid(session as never);
expect(prisma.discountRedemption.findUnique).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,70 @@
import { describe, it, expect, vi } from "vitest";
import { validateDiscountCode } from "@/src/modules/settings/lib/discount-validation";
function dbWith(code: Record<string, unknown> | null) {
return {
discountCode: { findUnique: vi.fn().mockResolvedValue(code) },
discountRedemption: { findUnique: vi.fn().mockResolvedValue(null) },
} as never;
}
const baseCode = {
id: "code-1",
code: "SOMMER",
type: "percent",
value: 10,
scope: "all_courses",
courseIds: [],
appliesTo: "courses",
restrictedToUserId: null,
maxRedemptions: null,
redeemedCount: 0,
startsAt: null,
expiresAt: null,
isActive: true,
};
const base = { code: "sommer", businessId: "biz-1", userId: "user-1" };
describe("validateDiscountCode - wofür ein Code gilt", () => {
it("Kurs-Codes gelten nicht beim Kartenkauf", async () => {
const result = await validateDiscountCode(dbWith(baseCode), { ...base, productId: "prod-1" });
expect(result).toEqual({ valid: false, reason: "Dieser Rabattcode gilt nicht für Karten." });
});
it("Karten-Codes gelten nicht für Kurse", async () => {
const result = await validateDiscountCode(dbWith({ ...baseCode, appliesTo: "passes" }), {
...base,
courseId: "course-1",
});
expect(result).toEqual({ valid: false, reason: "Dieser Rabattcode gilt nur für Karten." });
});
it("Codes für beides gelten bei Kurs und Karte", async () => {
const db = dbWith({ ...baseCode, appliesTo: "all" });
expect((await validateDiscountCode(db, { ...base, courseId: "c" })).valid).toBe(true);
expect((await validateDiscountCode(db, { ...base, productId: "p" })).valid).toBe(true);
});
it("die Kursauswahl wirkt beim Kartenkauf nicht", async () => {
const db = dbWith({
...baseCode,
appliesTo: "all",
scope: "specific_courses",
courseIds: ["c1"],
});
expect((await validateDiscountCode(db, { ...base, productId: "p" })).valid).toBe(true);
expect((await validateDiscountCode(db, { ...base, courseId: "c2" })).valid).toBe(false);
});
});
describe("validateDiscountCode - persönliche Codes", () => {
it("gilt nur für das eigene Konto", async () => {
const db = dbWith({ ...baseCode, restrictedToUserId: "user-1" });
expect((await validateDiscountCode(db, { ...base, courseId: "c" })).valid).toBe(true);
expect(await validateDiscountCode(db, { ...base, userId: "user-2", courseId: "c" })).toEqual({
valid: false,
reason: "Dieser Rabattcode gehört zu einem anderen Kundenkonto.",
});
});
});

9
vercel.json Normal file
View file

@ -0,0 +1,9 @@
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"crons": [
{
"path": "/api/cron/marketing",
"schedule": "0 7 * * *"
}
]
}