From dc4f08ab0c5335f835c4decac665f102e96d7a2e Mon Sep 17 00:00:00 2001
From: Rajpreet0
Date: Fri, 2 Oct 2026 17:31:08 +0200
Subject: [PATCH] added marketing-building-blog-v1
---
DEPLOYMENT.md | 38 +-
package-lock.json | 177 +++++++
package.json | 4 +-
.../migration.sql | 2 +-
.../migration.sql | 11 +
.../20261005100000_marketing/migration.sql | 152 ++++++
prisma/schema.prisma | 206 ++++++--
scripts/run-marketing-cron.mjs | 40 ++
src/app/(main)/marketing/page.tsx | 15 +
src/app/(main)/settings/marketing/page.tsx | 29 ++
src/app/abmelden/page.tsx | 33 ++
src/app/api/cron/marketing/route.ts | 65 +++
src/app/api/marketing/unsubscribe/route.ts | 33 ++
src/app/api/stripe/checkout/course/route.ts | 13 +
src/app/api/stripe/checkout/pass/route.ts | 168 +++++--
src/app/api/stripe/webhook/route.ts | 9 +-
src/lib/mail/resend.ts | 35 +-
src/lib/mail/templates/layout.ts | 9 +-
src/lib/mail/templates/marketing-email.ts | 114 +++++
.../courses/actions/booking-actions.ts | 26 +-
.../courses/ui/components/BookingDialog.tsx | 27 +-
.../ui/components/DashboardSidebar.tsx | 7 +
.../kunden/actions/kunde-detail-actions.ts | 27 ++
.../kunden/ui/views/kunde-detail-view.tsx | 30 +-
.../marketing/actions/consent-actions.ts | 114 +++++
.../marketing/actions/editor-actions.ts | 455 ++++++++++++++++++
src/modules/marketing/lib/audit-log.ts | 55 +++
src/modules/marketing/lib/compose-email.ts | 72 +++
src/modules/marketing/lib/consent-text.ts | 34 ++
src/modules/marketing/lib/consent.ts | 97 ++++
src/modules/marketing/lib/graph.ts | 232 +++++++++
src/modules/marketing/lib/node-config.ts | 210 ++++++++
src/modules/marketing/lib/personal-code.ts | 65 +++
src/modules/marketing/lib/placeholders.ts | 34 ++
src/modules/marketing/lib/run-marketing.ts | 424 ++++++++++++++++
src/modules/marketing/lib/triggers.ts | 151 ++++++
.../marketing/lib/unsubscribe-token.ts | 49 ++
.../components/MarketingConsentCheckbox.tsx | 35 ++
.../ui/components/MarketingHistory.tsx | 194 ++++++++
.../ui/components/MobileFlowList.tsx | 85 ++++
.../ui/components/NodeSettingsSheet.tsx | 423 ++++++++++++++++
.../ui/components/flow/EmailNode.tsx | 63 +++
.../ui/components/flow/FlowEditor.tsx | 374 ++++++++++++++
.../ui/components/flow/TriggerNode.tsx | 70 +++
.../ui/components/flow/editor-context.ts | 16 +
.../ui/components/flow/flow-model.ts | 138 ++++++
.../ui/components/flow/trigger-icons.tsx | 9 +
.../marketing/ui/views/marketing-view.tsx | 113 +++++
.../ui/views/studio-emails-settings-view.tsx | 72 +++
.../marketing/ui/views/unsubscribe-view.tsx | 74 +++
.../products/actions/pass-booking-actions.ts | 11 +
.../products/actions/pass-purchase-actions.ts | 89 ++++
src/modules/products/lib/pass-webhook.ts | 79 ++-
.../ui/components/PassPurchaseDialog.tsx | 165 +++++++
.../products/ui/components/PublicPassShop.tsx | 68 ++-
.../settings/actions/discount-actions.ts | 6 +-
.../settings/lib/discount-validation.ts | 43 +-
.../settings/schemas/discount-code-schema.ts | 9 +
.../ui/components/DiscountCodeList.tsx | 85 +++-
.../ui/views/settings-overview-view.tsx | 9 +
tests/api/cron/marketing.test.ts | 60 +++
tests/api/stripe/checkout-pass.test.ts | 78 +++
tests/marketing/consent.test.ts | 86 ++++
tests/marketing/graph.test.ts | 122 +++++
tests/marketing/placeholders.test.ts | 40 ++
tests/marketing/run-marketing.test.ts | 247 ++++++++++
tests/marketing/triggers.test.ts | 187 +++++++
tests/marketing/unsubscribe-token.test.ts | 30 ++
tests/products/pass-webhook.test.ts | 69 ++-
tests/settings/discount-validation.test.ts | 70 +++
vercel.json | 9 +
71 files changed, 6307 insertions(+), 183 deletions(-)
create mode 100644 prisma/migrations/20261004120000_customer_pass_user_set_null/migration.sql
create mode 100644 prisma/migrations/20261005100000_marketing/migration.sql
create mode 100644 scripts/run-marketing-cron.mjs
create mode 100644 src/app/(main)/marketing/page.tsx
create mode 100644 src/app/(main)/settings/marketing/page.tsx
create mode 100644 src/app/abmelden/page.tsx
create mode 100644 src/app/api/cron/marketing/route.ts
create mode 100644 src/app/api/marketing/unsubscribe/route.ts
create mode 100644 src/lib/mail/templates/marketing-email.ts
create mode 100644 src/modules/marketing/actions/consent-actions.ts
create mode 100644 src/modules/marketing/actions/editor-actions.ts
create mode 100644 src/modules/marketing/lib/audit-log.ts
create mode 100644 src/modules/marketing/lib/compose-email.ts
create mode 100644 src/modules/marketing/lib/consent-text.ts
create mode 100644 src/modules/marketing/lib/consent.ts
create mode 100644 src/modules/marketing/lib/graph.ts
create mode 100644 src/modules/marketing/lib/node-config.ts
create mode 100644 src/modules/marketing/lib/personal-code.ts
create mode 100644 src/modules/marketing/lib/placeholders.ts
create mode 100644 src/modules/marketing/lib/run-marketing.ts
create mode 100644 src/modules/marketing/lib/triggers.ts
create mode 100644 src/modules/marketing/lib/unsubscribe-token.ts
create mode 100644 src/modules/marketing/ui/components/MarketingConsentCheckbox.tsx
create mode 100644 src/modules/marketing/ui/components/MarketingHistory.tsx
create mode 100644 src/modules/marketing/ui/components/MobileFlowList.tsx
create mode 100644 src/modules/marketing/ui/components/NodeSettingsSheet.tsx
create mode 100644 src/modules/marketing/ui/components/flow/EmailNode.tsx
create mode 100644 src/modules/marketing/ui/components/flow/FlowEditor.tsx
create mode 100644 src/modules/marketing/ui/components/flow/TriggerNode.tsx
create mode 100644 src/modules/marketing/ui/components/flow/editor-context.ts
create mode 100644 src/modules/marketing/ui/components/flow/flow-model.ts
create mode 100644 src/modules/marketing/ui/components/flow/trigger-icons.tsx
create mode 100644 src/modules/marketing/ui/views/marketing-view.tsx
create mode 100644 src/modules/marketing/ui/views/studio-emails-settings-view.tsx
create mode 100644 src/modules/marketing/ui/views/unsubscribe-view.tsx
create mode 100644 src/modules/products/actions/pass-purchase-actions.ts
create mode 100644 src/modules/products/ui/components/PassPurchaseDialog.tsx
create mode 100644 tests/api/cron/marketing.test.ts
create mode 100644 tests/marketing/consent.test.ts
create mode 100644 tests/marketing/graph.test.ts
create mode 100644 tests/marketing/placeholders.test.ts
create mode 100644 tests/marketing/run-marketing.test.ts
create mode 100644 tests/marketing/triggers.test.ts
create mode 100644 tests/marketing/unsubscribe-token.test.ts
create mode 100644 tests/settings/discount-validation.test.ts
create mode 100644 vercel.json
diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
index 311e96f..097623f 100644
--- a/DEPLOYMENT.md
+++ b/DEPLOYMENT.md
@@ -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 ` - 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 `. Ist die Vercel Deployment Protection für Previews an, zusätzlich `x-vercel-protection-bypass: ` (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=`.
+
+`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)
```
diff --git a/package-lock.json b/package-lock.json
index 40af111..166d346 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 684c880..671ffa7 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/prisma/migrations/20261004110000_customer_passes/migration.sql b/prisma/migrations/20261004110000_customer_passes/migration.sql
index b900924..2a156b6 100644
--- a/prisma/migrations/20261004110000_customer_passes/migration.sql
+++ b/prisma/migrations/20261004110000_customer_passes/migration.sql
@@ -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;
diff --git a/prisma/migrations/20261004120000_customer_pass_user_set_null/migration.sql b/prisma/migrations/20261004120000_customer_pass_user_set_null/migration.sql
new file mode 100644
index 0000000..68f8998
--- /dev/null
+++ b/prisma/migrations/20261004120000_customer_pass_user_set_null/migration.sql
@@ -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;
diff --git a/prisma/migrations/20261005100000_marketing/migration.sql b/prisma/migrations/20261005100000_marketing/migration.sql
new file mode 100644
index 0000000..1b6cd1e
--- /dev/null
+++ b/prisma/migrations/20261005100000_marketing/migration.sql
@@ -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;
+
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 180777c..be55263 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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])
+}
diff --git a/scripts/run-marketing-cron.mjs b/scripts/run-marketing-cron.mjs
new file mode 100644
index 0000000..4efa376
--- /dev/null
+++ b/scripts/run-marketing-cron.mjs
@@ -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= # 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);
diff --git a/src/app/(main)/marketing/page.tsx b/src/app/(main)/marketing/page.tsx
new file mode 100644
index 0000000..480567e
--- /dev/null
+++ b/src/app/(main)/marketing/page.tsx
@@ -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 (
+
+ );
+};
+
+export default MarketingPage;
diff --git a/src/app/(main)/settings/marketing/page.tsx b/src/app/(main)/settings/marketing/page.tsx
new file mode 100644
index 0000000..a8fe37f
--- /dev/null
+++ b/src/app/(main)/settings/marketing/page.tsx
@@ -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 (
+
+
+
{result.error}
+
+ );
+ }
+
+ return (
+ }>
+
+
+ );
+}
diff --git a/src/app/abmelden/page.tsx b/src/app/abmelden/page.tsx
new file mode 100644
index 0000000..1eb1646
--- /dev/null
+++ b/src/app/abmelden/page.tsx
@@ -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 (
+
+ );
+}
diff --git a/src/app/api/cron/marketing/route.ts b/src/app/api/cron/marketing/route.ts
new file mode 100644
index 0000000..a4941d2
--- /dev/null
+++ b/src/app/api/cron/marketing/route.ts
@@ -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 }
+ );
+ }
+}
diff --git a/src/app/api/marketing/unsubscribe/route.ts b/src/app/api/marketing/unsubscribe/route.ts
new file mode 100644
index 0000000..ff55fc0
--- /dev/null
+++ b/src/app/api/marketing/unsubscribe/route.ts
@@ -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);
+}
diff --git a/src/app/api/stripe/checkout/course/route.ts b/src/app/api/stripe/checkout/course/route.ts
index f27e79c..35e6156 100644
--- a/src/app/api/stripe/checkout/course/route.ts
+++ b/src/app/api/stripe/checkout/course/route.ts
@@ -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.
diff --git a/src/app/api/stripe/checkout/pass/route.ts b/src/app/api/stripe/checkout/pass/route.ts
index aec1f3e..5d1c2b5 100644
--- a/src/app/api/stripe/checkout/pass/route.ts
+++ b/src/app/api/stripe/checkout/pass/route.ts
@@ -6,6 +6,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,7 +24,11 @@ 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 }
*/
export async function POST(request: NextRequest) {
try {
@@ -38,7 +48,11 @@ export async function POST(request: NextRequest) {
);
}
- const { productId } = (await request.json()) as { productId?: string };
+ const { productId, discountCode, marketingConsent } = (await request.json()) as {
+ productId?: string;
+ discountCode?: string;
+ marketingConsent?: boolean;
+ };
if (!productId) {
return NextResponse.json({ error: "productId fehlt" }, { status: 400 });
}
@@ -82,6 +96,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 +136,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) {
diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts
index 4f80cc3..c271065 100644
--- a/src/app/api/stripe/webhook/route.ts
+++ b/src/app/api/stripe/webhook/route.ts
@@ -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;
diff --git a/src/lib/mail/resend.ts b/src/lib/mail/resend.ts
index 035a8f0..f8b7cf7 100644
--- a/src/lib/mail/resend.ts
+++ b/src/lib/mail/resend.ts
@@ -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;
+ 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 " → "Studio X über Kumasy "). 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)
*/
diff --git a/src/lib/mail/templates/layout.ts b/src/lib/mail/templates/layout.ts
index 9afd073..dadf662 100644
--- a/src/lib/mail/templates/layout.ts
+++ b/src/lib/mail/templates/layout.ts
@@ -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}
- 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."}
Powered by Kumasy
diff --git a/src/lib/mail/templates/marketing-email.ts b/src/lib/mail/templates/marketing-email.ts
new file mode 100644
index 0000000..586a2ec
--- /dev/null
+++ b/src/lib/mail/templates/marketing-email.ts
@@ -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) =>
+ `
+ Wer am 29. Februar Geburtstag hat, bekommt die Mail in anderen Jahren am 28. Februar.
+
+ )}
+
+
+ Führt zu
+
+
+
+
+
+
Eingeschaltet
+
+ {activatedAt
+ ? `Läuft seit ${new Date(activatedAt).toLocaleDateString("de-DE")}`
+ : "Wirkt nach dem Speichern"}
+
+
+ onActiveChange(node.id, checked)}
+ />
+
+
+
+ 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.
+
+
+
+ {sent} {sent === 1 ? "Mail" : "Mails"} über diesen Trigger verschickt
+
+ {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."}
+
+ Dieser Abmeldelink ist ungültig oder abgelaufen. Wenn Sie ein Kundenkonto haben,
+ können Sie E-Mails von Studios auch in Ihren{" "}
+
+ Einstellungen
+ {" "}
+ abbestellen.
+
+ >
+ ) : done ? (
+ <>
+
+
Sie sind abgemeldet
+
+ Sie erhalten keine Angebote und Glückwünsche mehr von {studioName}. Buchungs- und
+ Zahlungsbestätigungen bekommen Sie weiterhin.
+
+ >
+ ) : (
+ <>
+
+
E-Mails abbestellen
+
+ Möchten Sie keine Angebote und Glückwünsche mehr von {studioName} per E-Mail
+ erhalten? Buchungs- und Zahlungsbestätigungen bekommen Sie weiterhin.
+
+ {error &&
{error}
}
+
+ >
+ )}
+
+
+ );
+};
+
+export default UnsubscribeView;
diff --git a/src/modules/products/actions/pass-booking-actions.ts b/src/modules/products/actions/pass-booking-actions.ts
index d56b27d..bd6dcfd 100644
--- a/src/modules/products/actions/pass-booking-actions.ts
+++ b/src/modules/products/actions/pass-booking-actions.ts
@@ -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 {
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,
diff --git a/src/modules/products/actions/pass-purchase-actions.ts b/src/modules/products/actions/pass-purchase-actions.ts
new file mode 100644
index 0000000..a6a1b81
--- /dev/null
+++ b/src/modules/products/actions/pass-purchase-actions.ts
@@ -0,0 +1,89 @@
+"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 },
+ });
+ if (!product || !product.isActive) {
+ 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." };
+ }
+}
diff --git a/src/modules/products/lib/pass-webhook.ts b/src/modules/products/lib/pass-webhook.ts
index 3e0a185..6325c4c 100644
--- a/src/modules/products/lib/pass-webhook.ts
+++ b/src/modules/products/lib/pass-webhook.ts
@@ -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(promise: Promise, ms: number): Promise {
+ 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) {
diff --git a/src/modules/products/ui/components/PassPurchaseDialog.tsx b/src/modules/products/ui/components/PassPurchaseDialog.tsx
new file mode 100644
index 0000000..7d05eb1
--- /dev/null
+++ b/src/modules/products/ui/components/PassPurchaseDialog.tsx
@@ -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;
+}
+
+/**
+ * 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 (
+
+ );
+};
+
+export default PassPurchaseDialog;
diff --git a/src/modules/products/ui/components/PublicPassShop.tsx b/src/modules/products/ui/components/PublicPassShop.tsx
index 32f57d4..da0c529 100644
--- a/src/modules/products/ui/components/PublicPassShop.tsx
+++ b/src/modules/products/ui/components/PublicPassShop.tsx
@@ -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(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 = ({
)}
);
})}
+
+ {purchase && (
+ setPurchase(null)}
+ onCheckout={(options) => checkout(purchase.product.id, options)}
+ />
+ )}
);
};
diff --git a/src/modules/settings/actions/discount-actions.ts b/src/modules/settings/actions/discount-actions.ts
index e46be3c..1213a35 100644
--- a/src/modules/settings/actions/discount-actions.ts
+++ b/src/modules/settings/actions/discount-actions.ts
@@ -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,
diff --git a/src/modules/settings/lib/discount-validation.ts b/src/modules/settings/lib/discount-validation.ts
index ca7b959..1ccc127 100644
--- a/src/modules/settings/lib/discount-validation.ts
+++ b/src/modules/settings/lib/discount-validation.ts
@@ -9,6 +9,12 @@ type PrismaOrTx =
| typeof prismaClient
| Parameters[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 {
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 {
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 {
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;
}
diff --git a/src/modules/settings/schemas/discount-code-schema.ts b/src/modules/settings/schemas/discount-code-schema.ts
index 904b9dd..5982f61 100644
--- a/src/modules/settings/schemas/discount-code-schema.ts
+++ b/src/modules/settings/schemas/discount-code-schema.ts
@@ -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",
diff --git a/src/modules/settings/ui/components/DiscountCodeList.tsx b/src/modules/settings/ui/components/DiscountCodeList.tsx
index 89583ae..4e24881 100644
--- a/src/modules/settings/ui/components/DiscountCodeList.tsx
+++ b/src/modules/settings/ui/components/DiscountCodeList.tsx
@@ -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 = ({