mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 19:51:17 +00:00
added marketing-building-blog-v1
This commit is contained in:
parent
a9f248c91d
commit
dc4f08ab0c
71 changed files with 6307 additions and 183 deletions
|
|
@ -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).
|
Alle in Vercel unter **Project Settings → Environment Variables**, Scope **"Preview"** (niemals "Production" — das hält Staging- und Produktions-Secrets sauber getrennt).
|
||||||
|
|
||||||
| Variable | Quelle |
|
| Variable | Quelle |
|
||||||
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||||
| `DATABASE_URL` | Supabase Staging, Transaction Pooler, Port 6543, `?pgbouncer=true&connection_limit=1` |
|
| `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 |
|
| `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_URL` | Supabase Staging Projekt-URL |
|
||||||
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase Staging anon key |
|
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase Staging anon key |
|
||||||
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase Staging service_role key |
|
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase Staging service_role key |
|
||||||
| `RESEND_API_KEY` / `RESEND_FROM` / `SMTP_TEST_MAIL` | wiederverwendet von lokal |
|
| `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_SECRET_KEY` / `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Test-Mode-Keys, wiederverwendet von lokal |
|
||||||
| `STRIPE_WEBHOOK_SECRET` | eigener Wert, siehe Stripe-Webhook unten |
|
| `STRIPE_WEBHOOK_SECRET` | eigener Wert, siehe Stripe-Webhook unten |
|
||||||
| `STRIPE_PLATFORM_FEE_PERCENT` | gleicher Wert wie lokal |
|
| `STRIPE_PLATFORM_FEE_PERCENT` | gleicher Wert wie lokal |
|
||||||
| `NEXT_PUBLIC_SITE_URL` / `NEXT_PUBLIC_APP_URL` | `https://kumasy-git-dev-kumasy.vercel.app` |
|
| `NEXT_PUBLIC_SITE_URL` / `NEXT_PUBLIC_APP_URL` | `https://kumasy-git-dev-kumasy.vercel.app` |
|
||||||
| `COMPANY_NAME` | `"Kumasy"` |
|
| `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.
|
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)
|
## Build Command (Vercel Project Settings → Build & Development Settings)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
177
package-lock.json
generated
177
package-lock.json
generated
|
|
@ -36,6 +36,7 @@
|
||||||
"@tiptap/extension-underline": "^3.30.5",
|
"@tiptap/extension-underline": "^3.30.5",
|
||||||
"@tiptap/react": "^3.30.5",
|
"@tiptap/react": "^3.30.5",
|
||||||
"@tiptap/starter-kit": "^3.30.5",
|
"@tiptap/starter-kit": "^3.30.5",
|
||||||
|
"@xyflow/react": "^12.12.0",
|
||||||
"archiver": "^8.0.0",
|
"archiver": "^8.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
@ -7262,6 +7263,15 @@
|
||||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/d3-ease": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
|
@ -7292,6 +7302,12 @@
|
||||||
"@types/d3-time": "*"
|
"@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": {
|
"node_modules/@types/d3-shape": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
|
@ -7313,6 +7329,25 @@
|
||||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
|
|
@ -8345,6 +8380,76 @@
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true
|
"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": {
|
"node_modules/abort-controller": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||||
|
|
@ -9244,6 +9349,12 @@
|
||||||
"url": "https://polar.sh/cva"
|
"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": {
|
"node_modules/classnames": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||||
|
|
@ -9644,6 +9755,28 @@
|
||||||
"node": ">=12"
|
"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": {
|
"node_modules/d3-ease": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
|
@ -9699,6 +9832,15 @@
|
||||||
"node": ">=12"
|
"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": {
|
"node_modules/d3-shape": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
|
@ -9744,6 +9886,41 @@
|
||||||
"node": ">=12"
|
"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": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:run": "vitest run",
|
"test:run": "vitest run",
|
||||||
"test:coverage": "vitest run --coverage",
|
"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": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|
@ -44,6 +45,7 @@
|
||||||
"@tiptap/extension-underline": "^3.30.5",
|
"@tiptap/extension-underline": "^3.30.5",
|
||||||
"@tiptap/react": "^3.30.5",
|
"@tiptap/react": "^3.30.5",
|
||||||
"@tiptap/starter-kit": "^3.30.5",
|
"@tiptap/starter-kit": "^3.30.5",
|
||||||
|
"@xyflow/react": "^12.12.0",
|
||||||
"archiver": "^8.0.0",
|
"archiver": "^8.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
-- AddForeignKey
|
-- 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
|
-- AddForeignKey
|
||||||
ALTER TABLE "PassAdjustment" ADD CONSTRAINT "PassAdjustment_passId_fkey" FOREIGN KEY ("passId") REFERENCES "CustomerPass"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
ALTER TABLE "PassAdjustment" ADD CONSTRAINT "PassAdjustment_passId_fkey" FOREIGN KEY ("passId") REFERENCES "CustomerPass"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
-- Eine Karte überlebt das Löschen des Kundenkontos (Kauf, Rechnung und
|
||||||
|
-- Erstattung müssen erhalten bleiben). Die Migration 20261004110000 hatte die
|
||||||
|
-- Regel zuerst als CASCADE angelegt; sie wurde danach im Review auf SET NULL
|
||||||
|
-- geändert, war da aber schon eingespielt. Die Änderung steht deshalb hier als
|
||||||
|
-- eigene Migration, statt die alte Datei umzuschreiben.
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "CustomerPass" DROP CONSTRAINT IF EXISTS "CustomerPass_userId_fkey";
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
152
prisma/migrations/20261005100000_marketing/migration.sql
Normal file
152
prisma/migrations/20261005100000_marketing/migration.sql
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DiscountCode" ADD COLUMN "appliesTo" TEXT NOT NULL DEFAULT 'courses',
|
||||||
|
ADD COLUMN "origin" TEXT NOT NULL DEFAULT 'manual',
|
||||||
|
ADD COLUMN "restrictedToUserId" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "DiscountRedemption" ADD COLUMN "passId" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "CustomerPass" ADD COLUMN "discountAmount" INTEGER,
|
||||||
|
ADD COLUMN "discountCodeId" TEXT;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MarketingConsent" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"businessId" TEXT NOT NULL,
|
||||||
|
"granted" BOOLEAN NOT NULL,
|
||||||
|
"textVersion" TEXT NOT NULL,
|
||||||
|
"source" TEXT NOT NULL,
|
||||||
|
"grantedAt" TIMESTAMP(3),
|
||||||
|
"revokedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "MarketingConsent_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MarketingNode" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"businessId" TEXT NOT NULL,
|
||||||
|
"kind" TEXT NOT NULL,
|
||||||
|
"config" JSONB NOT NULL,
|
||||||
|
"positionX" DOUBLE PRECISION NOT NULL,
|
||||||
|
"positionY" DOUBLE PRECISION NOT NULL,
|
||||||
|
"emailNodeId" TEXT,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"activatedAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "MarketingNode_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MarketingSend" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"businessId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT,
|
||||||
|
"triggerKind" TEXT NOT NULL,
|
||||||
|
"periodKey" TEXT NOT NULL,
|
||||||
|
"triggerNodeId" TEXT,
|
||||||
|
"emailNodeId" TEXT,
|
||||||
|
"discountCodeId" TEXT,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"attempts" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"error" TEXT,
|
||||||
|
"subject" TEXT NOT NULL,
|
||||||
|
"sentAt" TIMESTAMP(3),
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "MarketingSend_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MarketingAuditLog" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"businessId" TEXT NOT NULL,
|
||||||
|
"actorType" TEXT NOT NULL,
|
||||||
|
"actorId" TEXT,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"nodeId" TEXT,
|
||||||
|
"userId" TEXT,
|
||||||
|
"summary" TEXT NOT NULL,
|
||||||
|
"before" JSONB,
|
||||||
|
"after" JSONB,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "MarketingAuditLog_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MarketingConsent_businessId_granted_idx" ON "MarketingConsent"("businessId", "granted");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "MarketingConsent_userId_businessId_key" ON "MarketingConsent"("userId", "businessId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MarketingNode_businessId_idx" ON "MarketingNode"("businessId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MarketingNode_emailNodeId_idx" ON "MarketingNode"("emailNodeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "MarketingSend_discountCodeId_key" ON "MarketingSend"("discountCodeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MarketingSend_businessId_createdAt_idx" ON "MarketingSend"("businessId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MarketingSend_triggerNodeId_idx" ON "MarketingSend"("triggerNodeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MarketingSend_emailNodeId_idx" ON "MarketingSend"("emailNodeId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "MarketingSend_businessId_userId_triggerKind_periodKey_key" ON "MarketingSend"("businessId", "userId", "triggerKind", "periodKey");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MarketingAuditLog_businessId_createdAt_idx" ON "MarketingAuditLog"("businessId", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MarketingAuditLog_userId_idx" ON "MarketingAuditLog"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "DiscountRedemption_passId_key" ON "DiscountRedemption"("passId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "CustomerPass_discountCodeId_idx" ON "CustomerPass"("discountCodeId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "DiscountRedemption" ADD CONSTRAINT "DiscountRedemption_passId_fkey" FOREIGN KEY ("passId") REFERENCES "CustomerPass"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_discountCodeId_fkey" FOREIGN KEY ("discountCodeId") REFERENCES "DiscountCode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MarketingConsent" ADD CONSTRAINT "MarketingConsent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MarketingConsent" ADD CONSTRAINT "MarketingConsent_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MarketingNode" ADD CONSTRAINT "MarketingNode_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MarketingNode" ADD CONSTRAINT "MarketingNode_emailNodeId_fkey" FOREIGN KEY ("emailNodeId") REFERENCES "MarketingNode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MarketingSend" ADD CONSTRAINT "MarketingSend_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MarketingSend" ADD CONSTRAINT "MarketingSend_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MarketingSend" ADD CONSTRAINT "MarketingSend_discountCodeId_fkey" FOREIGN KEY ("discountCodeId") REFERENCES "DiscountCode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MarketingAuditLog" ADD CONSTRAINT "MarketingAuditLog_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
|
@ -44,6 +44,8 @@ model User {
|
||||||
courseWaitlistEntries CourseWaitlistEntry[]
|
courseWaitlistEntries CourseWaitlistEntry[]
|
||||||
discountRedemptions DiscountRedemption[]
|
discountRedemptions DiscountRedemption[]
|
||||||
passes CustomerPass[]
|
passes CustomerPass[]
|
||||||
|
marketingConsents MarketingConsent[]
|
||||||
|
marketingSends MarketingSend[]
|
||||||
createdAt DateTime? @default(now())
|
createdAt DateTime? @default(now())
|
||||||
updatedAt DateTime? @updatedAt
|
updatedAt DateTime? @updatedAt
|
||||||
}
|
}
|
||||||
|
|
@ -138,13 +140,17 @@ model Business {
|
||||||
onboardingStartedAt DateTime? // Wizard erstmals geoeffnet, ab dann kein Redirect mehr
|
onboardingStartedAt DateTime? // Wizard erstmals geoeffnet, ab dann kein Redirect mehr
|
||||||
onboardingDismissedAt DateTime? // Checkliste im Dashboard ausgeblendet
|
onboardingDismissedAt DateTime? // Checkliste im Dashboard ausgeblendet
|
||||||
onboardingCompletedAt DateTime? // Zeitpunkt, an dem erstmals alle Pflichtschritte erledigt waren
|
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[]
|
courses Course[]
|
||||||
discountCodes DiscountCode[]
|
discountCodes DiscountCode[]
|
||||||
products Product[]
|
products Product[]
|
||||||
customerPasses CustomerPass[]
|
customerPasses CustomerPass[]
|
||||||
createdAt DateTime @default(now())
|
marketingNodes MarketingNode[]
|
||||||
updatedAt DateTime @updatedAt
|
marketingConsents MarketingConsent[]
|
||||||
|
marketingSends MarketingSend[]
|
||||||
|
marketingAuditLogs MarketingAuditLog[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([managerId])
|
@@index([managerId])
|
||||||
}
|
}
|
||||||
|
|
@ -338,14 +344,14 @@ model Notification {
|
||||||
// als natürlicher Unique-Key, um dasselbe Gerät bei erneutem Subscribe zu
|
// als natürlicher Unique-Key, um dasselbe Gerät bei erneutem Subscribe zu
|
||||||
// aktualisieren statt zu duplizieren.
|
// aktualisieren statt zu duplizieren.
|
||||||
model PushSubscription {
|
model PushSubscription {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
recipientId String
|
recipientId String
|
||||||
endpoint String @unique
|
endpoint String @unique
|
||||||
p256dh String // Public Key des Browsers für die Payload-Verschlüsselung
|
p256dh String // Public Key des Browsers für die Payload-Verschlüsselung
|
||||||
auth String // Auth-Secret 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
|
userAgent String? // Nur zur Anzeige in einer künftigen "Geräte verwalten"-UI
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
lastSeenAt DateTime @default(now()) // Bei jedem erneuten Subscribe aktualisiert
|
lastSeenAt DateTime @default(now()) // Bei jedem erneuten Subscribe aktualisiert
|
||||||
|
|
||||||
@@index([recipientId])
|
@@index([recipientId])
|
||||||
}
|
}
|
||||||
|
|
@ -359,23 +365,31 @@ model PushSubscription {
|
||||||
// Checkouts zu verhindern (siehe DiscountRedemption). Der Zähler wird wieder
|
// Checkouts zu verhindern (siehe DiscountRedemption). Der Zähler wird wieder
|
||||||
// dekrementiert, wenn eine Reservierung storniert/abgelaufen wird.
|
// dekrementiert, wenn eine Reservierung storniert/abgelaufen wird.
|
||||||
model DiscountCode {
|
model DiscountCode {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
businessId String
|
businessId String
|
||||||
code String // Anzeigeform, z.B. "SOMMER25" — Eindeutigkeit nur pro Business (siehe @@unique)
|
code String // Anzeigeform, z.B. "SOMMER25" — Eindeutigkeit nur pro Business (siehe @@unique)
|
||||||
type String // "percent" | "fixed"
|
type String // "percent" | "fixed"
|
||||||
value Int // percent: 1-100, fixed: Cents
|
value Int // percent: 1-100, fixed: Cents
|
||||||
scope String // "all_courses" | "specific_courses"
|
scope String // "all_courses" | "specific_courses"
|
||||||
courseIds String[] // Nur relevant wenn scope = "specific_courses"
|
courseIds String[] // Nur relevant wenn scope = "specific_courses"
|
||||||
maxRedemptions Int? // null = unbegrenzt
|
maxRedemptions Int? // null = unbegrenzt
|
||||||
redeemedCount Int @default(0)
|
redeemedCount Int @default(0)
|
||||||
startsAt DateTime? // null = sofort gültig
|
startsAt DateTime? // null = sofort gültig
|
||||||
expiresAt DateTime? // null = kein Ablauf
|
expiresAt DateTime? // null = kein Ablauf
|
||||||
isActive Boolean @default(true) // Manuelles An/Aus, unabhängig vom Zeitfenster
|
isActive Boolean @default(true) // Manuelles An/Aus, unabhängig vom Zeitfenster
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
appliesTo String @default("courses") // "courses" | "passes" (Kartenkauf) | "all"
|
||||||
bookings CourseBooking[]
|
// Persönlicher Code: nur dieses Konto kann ihn einlösen. Bewusst ohne
|
||||||
redemptions DiscountRedemption[]
|
// Fremdschlüssel - würde er beim Löschen des Kontos auf null gesetzt, könnte
|
||||||
createdAt DateTime @default(now())
|
// plötzlich jeder den Code einlösen.
|
||||||
updatedAt DateTime @updatedAt
|
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])
|
@@unique([businessId, code])
|
||||||
@@index([businessId])
|
@@index([businessId])
|
||||||
|
|
@ -400,10 +414,12 @@ model DiscountRedemption {
|
||||||
userId String
|
userId String
|
||||||
stripeSessionId String @unique
|
stripeSessionId String @unique
|
||||||
bookingId String? @unique
|
bookingId String? @unique
|
||||||
|
passId String? @unique // Kartenkauf mit Rabattcode - gesetzt statt bookingId
|
||||||
status String @default("reserved") // "reserved" | "confirmed"
|
status String @default("reserved") // "reserved" | "confirmed"
|
||||||
discountCode DiscountCode @relation(fields: [discountCodeId], references: [id], onDelete: Cascade)
|
discountCode DiscountCode @relation(fields: [discountCodeId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
booking CourseBooking? @relation(fields: [bookingId], references: [id], onDelete: SetNull)
|
booking CourseBooking? @relation(fields: [bookingId], references: [id], onDelete: SetNull)
|
||||||
|
pass CustomerPass? @relation(fields: [passId], references: [id], onDelete: SetNull)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@unique([discountCodeId, userId])
|
@@unique([discountCodeId, userId])
|
||||||
|
|
@ -417,7 +433,7 @@ model DiscountRedemption {
|
||||||
// 1 Einheit = 1 Platz in einem Kurs. Zum Preis kommt die Servicegebühr pro
|
// 1 Einheit = 1 Platz in einem Kurs. Zum Preis kommt die Servicegebühr pro
|
||||||
// Einheit dazu (siehe src/modules/products/lib/card-pricing.ts).
|
// Einheit dazu (siehe src/modules/products/lib/card-pricing.ts).
|
||||||
model Product {
|
model Product {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
businessId String
|
businessId String
|
||||||
name String
|
name String
|
||||||
description String?
|
description String?
|
||||||
|
|
@ -429,8 +445,8 @@ model Product {
|
||||||
isActive Boolean @default(true) // false = nicht mehr kaufbar
|
isActive Boolean @default(true) // false = nicht mehr kaufbar
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||||
passes CustomerPass[]
|
passes CustomerPass[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([businessId])
|
@@index([businessId])
|
||||||
}
|
}
|
||||||
|
|
@ -443,7 +459,7 @@ model Product {
|
||||||
// ist), Storno innerhalb der Frist und Kursabsage geben zurück, der Manager passt
|
// ist), Storno innerhalb der Frist und Kursabsage geben zurück, der Manager passt
|
||||||
// ihn im Kunden-Detail an. Jede manuelle Änderung steht in PassAdjustment.
|
// ihn im Kunden-Detail an. Jede manuelle Änderung steht in PassAdjustment.
|
||||||
model CustomerPass {
|
model CustomerPass {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
productId String
|
productId String
|
||||||
businessId String
|
businessId String
|
||||||
// null = noch ohne Konto: vom Studio an eine E-Mail vergeben, oder das Konto
|
// 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)
|
creditsTotal Int // alle Einheiten, die die Karte je bekommen hat (inkl. Gutschriften)
|
||||||
creditsRemaining Int
|
creditsRemaining Int
|
||||||
validUntil DateTime? // null = unbegrenzt gültig
|
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)
|
source String // "purchase" | "manual" (vom Studio vergeben, ohne Zahlung)
|
||||||
// Zahlung - nur bei source = "purchase"
|
// Zahlung - nur bei source = "purchase"
|
||||||
priceCents Int?
|
priceCents Int?
|
||||||
stripeAccountId String?
|
stripeAccountId String?
|
||||||
stripeSessionId String? @unique
|
stripeSessionId String? @unique
|
||||||
stripePaymentIntentId String? @unique
|
stripePaymentIntentId String? @unique
|
||||||
amountPaid Int? // inkl. Servicegebühr
|
amountPaid Int? // inkl. Servicegebühr
|
||||||
platformFee Int?
|
platformFee Int?
|
||||||
stripeFee Int?
|
stripeFee Int?
|
||||||
billingAddress Json?
|
billingAddress Json?
|
||||||
invoiceNumber String? @unique
|
invoiceNumber String? @unique
|
||||||
invoiceIssuedAt DateTime?
|
invoiceIssuedAt DateTime?
|
||||||
refundedAmount Int?
|
refundedAmount Int?
|
||||||
refundedAt DateTime?
|
refundedAt DateTime?
|
||||||
product Product @relation(fields: [productId], references: [id], onDelete: NoAction)
|
discountCodeId String? // Beim Kauf eingelöster Rabattcode
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
discountAmount Int? // Gewährter Rabatt in Cents (Snapshot) - priceCents ist schon der rabattierte Preis
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
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[]
|
bookings CourseBooking[]
|
||||||
adjustments PassAdjustment[]
|
adjustments PassAdjustment[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([businessId])
|
@@index([businessId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([email])
|
@@index([email])
|
||||||
@@index([productId])
|
@@index([productId])
|
||||||
|
@@index([discountCodeId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protokoll jeder Änderung durch das Studio: Karte vergeben, Einheiten
|
// Protokoll jeder Änderung durch das Studio: Karte vergeben, Einheiten
|
||||||
|
|
@ -502,3 +523,114 @@ model PassAdjustment {
|
||||||
|
|
||||||
@@index([passId])
|
@@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])
|
||||||
|
}
|
||||||
|
|
|
||||||
40
scripts/run-marketing-cron.mjs
Normal file
40
scripts/run-marketing-cron.mjs
Normal 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);
|
||||||
15
src/app/(main)/marketing/page.tsx
Normal file
15
src/app/(main)/marketing/page.tsx
Normal 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;
|
||||||
29
src/app/(main)/settings/marketing/page.tsx
Normal file
29
src/app/(main)/settings/marketing/page.tsx
Normal 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
33
src/app/abmelden/page.tsx
Normal 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} />
|
||||||
|
);
|
||||||
|
}
|
||||||
65
src/app/api/cron/marketing/route.ts
Normal file
65
src/app/api/cron/marketing/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/app/api/marketing/unsubscribe/route.ts
Normal file
33
src/app/api/marketing/unsubscribe/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
} from "@/src/modules/courses/lib/course-spots";
|
} from "@/src/modules/courses/lib/course-spots";
|
||||||
import { MAX_SEATS_PER_BOOKING, paidSeatCount } from "@/src/modules/courses/lib/seat-counts";
|
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 { 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
|
* 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[];
|
spotIds?: string[];
|
||||||
/** Alter Client vor der Mehrfachbuchung: ein einzelner Platz. */
|
/** Alter Client vor der Mehrfachbuchung: ein einzelner Platz. */
|
||||||
spotId?: string;
|
spotId?: string;
|
||||||
|
/** Häkchen für Werbe-Mails des Studios (Opt-in), nur mit Konto. */
|
||||||
|
marketingConsent?: boolean;
|
||||||
};
|
};
|
||||||
const { courseId, guestEmail, guestName, discountCode } = body;
|
const { courseId, guestEmail, guestName, discountCode } = body;
|
||||||
const seats = body.seats ?? 1;
|
const seats = body.seats ?? 1;
|
||||||
|
|
@ -532,6 +535,16 @@ export async function POST(request: NextRequest) {
|
||||||
throw error;
|
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
|
// Damit ein späterer Checkout derselben Person diese Session beenden kann
|
||||||
// (siehe endOpenCheckouts). Scheitert das, bleibt der Platz trotzdem bis
|
// (siehe endOpenCheckouts). Scheitert das, bleibt der Platz trotzdem bis
|
||||||
// zum Ablauf gehalten - kein Grund, die fertige Session zu verwerfen.
|
// zum Ablauf gehalten - kein Grund, die fertige Session zu verwerfen.
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,12 @@ import { getUserData, isUser } from "@/src/lib/auth/getUser";
|
||||||
import { cardServiceFeeCents } from "@/src/modules/products/lib/card-pricing";
|
import { cardServiceFeeCents } from "@/src/modules/products/lib/card-pricing";
|
||||||
import { formatValidity } from "@/src/modules/products/lib/card-format";
|
import { formatValidity } from "@/src/modules/products/lib/card-format";
|
||||||
import { PASS_CHECKOUT_KIND } from "@/src/modules/products/lib/pass-webhook";
|
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.
|
* 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
|
* Nur für eingeloggte Kunden: eine Karte hängt an einem Konto, mit dem später
|
||||||
* gebucht wird.
|
* 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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
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) {
|
if (!productId) {
|
||||||
return NextResponse.json({ error: "productId fehlt" }, { status: 400 });
|
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 feeCents = cardServiceFeeCents(product.credits, feePerUnitCents);
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
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
|
// Alles, was der Webhook braucht, um die Karte anzulegen. Liegt an Session
|
||||||
// UND PaymentIntent, damit es auch im Stripe-Dashboard an der Zahlung steht.
|
// UND PaymentIntent, damit es auch im Stripe-Dashboard an der Zahlung steht.
|
||||||
const metadata = {
|
const metadata = {
|
||||||
|
|
@ -90,57 +136,83 @@ export async function POST(request: NextRequest) {
|
||||||
userId: userData.id,
|
userId: userData.id,
|
||||||
businessId: business.id,
|
businessId: business.id,
|
||||||
platformFeeModel: "on_top",
|
platformFeeModel: "on_top",
|
||||||
|
discountCodeId: appliedDiscountCodeId ?? "",
|
||||||
|
discountAmountCents: String(discountAmountCents),
|
||||||
|
discountReservationToken: reservationToken ?? "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const session = await createBrandedCheckoutSession(
|
let session;
|
||||||
{
|
try {
|
||||||
mode: "payment",
|
session = await createBrandedCheckoutSession(
|
||||||
// Rechnungsadresse ist Pflicht - sie steht auf der Rechnung zum Kartenkauf.
|
{
|
||||||
billing_address_collection: "required",
|
mode: "payment",
|
||||||
line_items: [
|
// Rechnungsadresse ist Pflicht - sie steht auf der Rechnung zum Kartenkauf.
|
||||||
{
|
billing_address_collection: "required",
|
||||||
price_data: {
|
line_items: [
|
||||||
currency: "eur",
|
{
|
||||||
product_data: {
|
price_data: {
|
||||||
name: product.name,
|
currency: "eur",
|
||||||
description: `${business.name} - ${product.credits} ${
|
product_data: {
|
||||||
product.credits === 1 ? "Einheit" : "Einheiten"
|
name: product.name,
|
||||||
} · ${formatValidity(product.validityMonths)}`,
|
description: `${business.name} - ${product.credits} ${
|
||||||
},
|
product.credits === 1 ? "Einheit" : "Einheiten"
|
||||||
unit_amount: product.priceCents,
|
} · ${formatValidity(product.validityMonths)}`,
|
||||||
},
|
|
||||||
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,
|
|
||||||
},
|
},
|
||||||
]
|
unit_amount: cardPriceCents,
|
||||||
: []),
|
},
|
||||||
],
|
quantity: 1,
|
||||||
payment_intent_data: {
|
},
|
||||||
...(feeCents > 0 ? { application_fee_amount: feeCents } : {}),
|
// 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,
|
metadata,
|
||||||
|
customer_email: userData.email,
|
||||||
|
success_url: `${baseUrl}/courses/myCards?purchased=1`,
|
||||||
|
cancel_url: business.slug ? `${baseUrl}/business/${business.slug}` : baseUrl,
|
||||||
},
|
},
|
||||||
metadata,
|
studioCheckoutBranding(business),
|
||||||
customer_email: userData.email,
|
business.stripeAccountId
|
||||||
success_url: `${baseUrl}/courses/myCards?purchased=1`,
|
);
|
||||||
cancel_url: business.slug ? `${baseUrl}/business/${business.slug}` : baseUrl,
|
} catch (error) {
|
||||||
},
|
// Nie angelegte Session meldet auch kein checkout.session.expired - die
|
||||||
studioCheckoutBranding(business),
|
// Reservierung muss hier zurück.
|
||||||
business.stripeAccountId
|
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 });
|
return NextResponse.json({ url: session.url });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import {
|
||||||
import { MAX_SEATS_PER_BOOKING, paidSeatCount } from "@/src/modules/courses/lib/seat-counts";
|
import { MAX_SEATS_PER_BOOKING, paidSeatCount } from "@/src/modules/courses/lib/seat-counts";
|
||||||
import {
|
import {
|
||||||
handlePassCheckoutCompleted,
|
handlePassCheckoutCompleted,
|
||||||
|
handlePassCheckoutUnpaid,
|
||||||
isPassCheckout,
|
isPassCheckout,
|
||||||
} from "@/src/modules/products/lib/pass-webhook";
|
} from "@/src/modules/products/lib/pass-webhook";
|
||||||
import { reconcilePassRefund } from "@/src/modules/products/lib/pass-refund";
|
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.expired":
|
||||||
case "checkout.session.async_payment_failed": {
|
case "checkout.session.async_payment_failed": {
|
||||||
// Ein Kartenkauf reserviert nichts (keine Plätze, kein Rabattcode) - nie
|
// Ein Kartenkauf hält keine Plätze - nie bezahlt heißt: keine Karte,
|
||||||
// bezahlt heißt einfach: keine Karte.
|
// nur ein reservierter Rabattcode wird wieder frei.
|
||||||
if (!isPassCheckout(event.data.object)) {
|
if (isPassCheckout(event.data.object)) {
|
||||||
|
await handlePassCheckoutUnpaid(event.data.object);
|
||||||
|
} else {
|
||||||
await handleCheckoutUnpaid(event.data.object);
|
await handleCheckoutUnpaid(event.data.object);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ export function getResend(): Resend {
|
||||||
* @param subject - E-Mail Betreff
|
* @param subject - E-Mail Betreff
|
||||||
* @param html - HTML-Inhalt der E-Mail
|
* @param html - HTML-Inhalt der E-Mail
|
||||||
* @param text - Plain-Text Alternative (optional)
|
* @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
|
* @returns Promise mit dem Ergebnis des Versands
|
||||||
*/
|
*/
|
||||||
export async function sendMail({
|
export async function sendMail({
|
||||||
|
|
@ -33,20 +37,34 @@ export async function sendMail({
|
||||||
subject,
|
subject,
|
||||||
html,
|
html,
|
||||||
text,
|
text,
|
||||||
|
fromName,
|
||||||
|
replyTo,
|
||||||
|
headers,
|
||||||
|
idempotencyKey,
|
||||||
}: {
|
}: {
|
||||||
to: string;
|
to: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
html: string;
|
html: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
|
fromName?: string;
|
||||||
|
replyTo?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
idempotencyKey?: string;
|
||||||
}) {
|
}) {
|
||||||
try {
|
try {
|
||||||
const { data, error } = await getResend().emails.send({
|
const defaultFrom = process.env.RESEND_FROM || "";
|
||||||
from: process.env.RESEND_FROM || "",
|
const payload = {
|
||||||
|
from: fromName ? withDisplayName(defaultFrom, fromName) : defaultFrom,
|
||||||
to,
|
to,
|
||||||
subject,
|
subject,
|
||||||
html,
|
html,
|
||||||
text: text || stripHtml(html), // Fallback: HTML zu Text konvertieren
|
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) {
|
if (error) {
|
||||||
console.error("Fehler beim E-Mail-Versand:", 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)
|
* Einfache Funktion zum Entfernen von HTML-Tags (für Text-Alternative)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,12 @@ export interface RenderEmailLayoutParams {
|
||||||
/** Fertiger Inner-Content, wird unverändert in die Content-Zelle eingesetzt. */
|
/** Fertiger Inner-Content, wird unverändert in die Content-Zelle eingesetzt. */
|
||||||
bodyHtml: string;
|
bodyHtml: string;
|
||||||
branding?: EmailBranding;
|
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({
|
export function renderEmailLayout({
|
||||||
|
|
@ -25,6 +31,7 @@ export function renderEmailLayout({
|
||||||
bannerHtml,
|
bannerHtml,
|
||||||
bodyHtml,
|
bodyHtml,
|
||||||
branding = KUMASY_BRANDING,
|
branding = KUMASY_BRANDING,
|
||||||
|
footerNoteHtml,
|
||||||
}: RenderEmailLayoutParams): string {
|
}: RenderEmailLayoutParams): string {
|
||||||
const safeTitle = escapeHtml(title);
|
const safeTitle = escapeHtml(title);
|
||||||
const safeCompanyName = escapeHtml(branding.companyName);
|
const safeCompanyName = escapeHtml(branding.companyName);
|
||||||
|
|
@ -75,7 +82,7 @@ export function renderEmailLayout({
|
||||||
${safeCompanyName}
|
${safeCompanyName}
|
||||||
</p>
|
</p>
|
||||||
<p style="margin: 0 0 10px; color: ${EMAIL_TOKENS.colors.textMuted}; font-size: 12px;">
|
<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>
|
||||||
<p style="margin: 0; color: ${EMAIL_TOKENS.colors.textMuted}; font-size: 11px;">
|
<p style="margin: 0; color: ${EMAIL_TOKENS.colors.textMuted}; font-size: 11px;">
|
||||||
Powered by Kumasy
|
Powered by Kumasy
|
||||||
|
|
|
||||||
114
src/lib/mail/templates/marketing-email.ts
Normal file
114
src/lib/mail/templates/marketing-email.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,8 @@ import {
|
||||||
findUsablePassesForCourse,
|
findUsablePassesForCourse,
|
||||||
type CustomerPassView,
|
type CustomerPassView,
|
||||||
} from "@/src/modules/products/lib/pass-queries";
|
} 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 = {
|
export type UserBooking = {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
|
|
@ -266,7 +268,14 @@ export async function checkUserBookingStatus(courseId: string) {
|
||||||
|
|
||||||
// Alle Checks im selben Aufruf statt separater Wartelisten-/Karten-Calls,
|
// Alle Checks im selben Aufruf statt separater Wartelisten-/Karten-Calls,
|
||||||
// damit der Dialog weiterhin nur einen Roundtrip beim Öffnen braucht.
|
// 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({
|
prisma.courseBooking.findUnique({
|
||||||
where: { courseId_userId: { courseId, userId: userData.id } },
|
where: { courseId_userId: { courseId, userId: userData.id } },
|
||||||
}),
|
}),
|
||||||
|
|
@ -274,11 +283,12 @@ export async function checkUserBookingStatus(courseId: string) {
|
||||||
where: { courseId_userId: { courseId, userId: userData.id } },
|
where: { courseId_userId: { courseId, userId: userData.id } },
|
||||||
}),
|
}),
|
||||||
// Karten, mit denen der Kunde diesen Kurs ohne Bezahlen buchen kann.
|
// Karten, mit denen der Kunde diesen Kurs ohne Bezahlen buchen kann.
|
||||||
isUser(userData)
|
customerCourse && isUser(userData)
|
||||||
? prisma.course
|
? findUsablePassesForCourse(userData, customerCourse)
|
||||||
.findUnique({ where: { id: courseId }, select: { businessId: true, sport: true } })
|
|
||||||
.then((course) => (course ? findUsablePassesForCourse(userData, course) : []))
|
|
||||||
: Promise.resolve([] as CustomerPassView[]),
|
: Promise.resolve([] as CustomerPassView[]),
|
||||||
|
customerCourse?.businessId
|
||||||
|
? hasMarketingConsent(userData.id, customerCourse.businessId)
|
||||||
|
: Promise.resolve(true),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -290,6 +300,12 @@ export async function checkUserBookingStatus(courseId: string) {
|
||||||
bookedSeats: booking?.paymentStatus === "paid" ? booking.seats : 0,
|
bookedSeats: booking?.paymentStatus === "paid" ? booking.seats : 0,
|
||||||
isOnWaitlist: waitlistEntry !== null,
|
isOnWaitlist: waitlistEntry !== null,
|
||||||
passes,
|
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) {
|
} catch (error) {
|
||||||
console.error("Error checking booking status:", error);
|
console.error("Error checking booking status:", error);
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import { spotDisplayName } from "@/src/modules/rooms/lib/floor-plan";
|
||||||
import { MAX_SEATS_PER_BOOKING } from "../../lib/seat-counts";
|
import { MAX_SEATS_PER_BOOKING } from "../../lib/seat-counts";
|
||||||
import { bookCourseWithPass } from "@/src/modules/products/actions/pass-booking-actions";
|
import { bookCourseWithPass } from "@/src/modules/products/actions/pass-booking-actions";
|
||||||
import type { CustomerPassView } from "@/src/modules/products/lib/pass-queries";
|
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
|
// useSyncExternalStore: liefert `false` auf dem Server (server snapshot) und
|
||||||
// `true` im Browser. Damit haben Server- und initialer Client-Render denselben
|
// `true` im Browser. Damit haben Server- und initialer Client-Render denselben
|
||||||
|
|
@ -215,6 +216,10 @@ export const BookingDialog = ({
|
||||||
const [passes, setPasses] = useState<CustomerPassView[]>([]);
|
const [passes, setPasses] = useState<CustomerPassView[]>([]);
|
||||||
const [selectedPassId, setSelectedPassId] = useState<string | null>(null);
|
const [selectedPassId, setSelectedPassId] = useState<string | null>(null);
|
||||||
const [highlightPicker, setHighlightPicker] = useState(false);
|
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 spotPickerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const isAdminView = paymentStatus !== undefined;
|
const isAdminView = paymentStatus !== undefined;
|
||||||
|
|
@ -249,6 +254,9 @@ export const BookingDialog = ({
|
||||||
const usablePasses = "passes" in result ? (result.passes ?? []) : [];
|
const usablePasses = "passes" in result ? (result.passes ?? []) : [];
|
||||||
setPasses(usablePasses);
|
setPasses(usablePasses);
|
||||||
setSelectedPassId(usablePasses[0]?.id ?? null);
|
setSelectedPassId(usablePasses[0]?.id ?? null);
|
||||||
|
setConsentText(
|
||||||
|
"marketingConsentLabel" in result ? (result.marketingConsentLabel ?? null) : null
|
||||||
|
);
|
||||||
// Gäste sehen das Gast-Formular sofort, statt erst nach einem
|
// Gäste sehen das Gast-Formular sofort, statt erst nach einem
|
||||||
// gescheiterten Buchungsversuch.
|
// gescheiterten Buchungsversuch.
|
||||||
if (result.isLoggedIn === false) setShowGuestForm(true);
|
if (result.isLoggedIn === false) setShowGuestForm(true);
|
||||||
|
|
@ -424,6 +432,7 @@ export const BookingDialog = ({
|
||||||
passId,
|
passId,
|
||||||
seats,
|
seats,
|
||||||
spotIds: spotMode ? selectedSpots.map((s) => s.id) : undefined,
|
spotIds: spotMode ? selectedSpots.map((s) => s.id) : undefined,
|
||||||
|
marketingConsent: consentText !== null && consentChecked,
|
||||||
});
|
});
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
|
|
@ -456,8 +465,12 @@ export const BookingDialog = ({
|
||||||
}
|
}
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
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 (appliedDiscount) body.discountCode = appliedDiscount.code;
|
||||||
|
if (!opts?.asGuest && consentText !== null && consentChecked) body.marketingConsent = true;
|
||||||
if (spotMode) body.spotIds = selectedSpots.map((s) => s.id);
|
if (spotMode) body.spotIds = selectedSpots.map((s) => s.id);
|
||||||
if (opts?.asGuest) {
|
if (opts?.asGuest) {
|
||||||
const normalizedEmail = guestEmail.trim().toLowerCase();
|
const normalizedEmail = guestEmail.trim().toLowerCase();
|
||||||
|
|
@ -864,6 +877,18 @@ export const BookingDialog = ({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{payWithPass && !isAdminView && !isBooked ? (
|
{payWithPass && !isAdminView && !isBooked ? (
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
Dumbbell,
|
Dumbbell,
|
||||||
IdCardLanyard,
|
IdCardLanyard,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
Megaphone,
|
||||||
User,
|
User,
|
||||||
WalletCards,
|
WalletCards,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
@ -94,6 +95,12 @@ const allMenuItems: MenuItem[] = [
|
||||||
href: "/zahlungen",
|
href: "/zahlungen",
|
||||||
roles: ["manager"],
|
roles: ["manager"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: Megaphone,
|
||||||
|
label: "Marketing",
|
||||||
|
href: "/marketing",
|
||||||
|
roles: ["manager"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: DoorOpen,
|
icon: DoorOpen,
|
||||||
label: "Räume",
|
label: "Räume",
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,16 @@ export type KundeDetail = {
|
||||||
createdAt: Date | null;
|
createdAt: Date | null;
|
||||||
bookings: KundeBooking[];
|
bookings: KundeBooking[];
|
||||||
passes: KundePass[];
|
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(
|
export async function getKundeDetail(
|
||||||
|
|
@ -151,6 +161,16 @@ export async function getKundeDetail(
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
},
|
},
|
||||||
|
marketingConsents: {
|
||||||
|
where: { businessId: { in: businessIds } },
|
||||||
|
select: {
|
||||||
|
granted: true,
|
||||||
|
grantedAt: true,
|
||||||
|
revokedAt: true,
|
||||||
|
source: true,
|
||||||
|
business: { select: { name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
passes: {
|
passes: {
|
||||||
where: { businessId: { in: businessIds } },
|
where: { businessId: { in: businessIds } },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
|
|
@ -289,6 +309,13 @@ export async function getKundeDetail(
|
||||||
pbSrc: userWithBookings.pbSrc,
|
pbSrc: userWithBookings.pbSrc,
|
||||||
createdAt: userWithBookings.createdAt,
|
createdAt: userWithBookings.createdAt,
|
||||||
bookings,
|
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) => {
|
passes: userWithBookings.passes.map((pass) => {
|
||||||
const branding = businessBrandingSchema.safeParse(pass.business.branding);
|
const branding = businessBrandingSchema.safeParse(pass.business.branding);
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,9 @@ import {
|
||||||
Bell,
|
Bell,
|
||||||
Scale,
|
Scale,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
|
Megaphone,
|
||||||
} from "lucide-react";
|
} 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 KundeBookingCard from "../components/kunde-booking-card";
|
||||||
import KundePassesSection from "@/src/modules/products/ui/components/KundePassesSection";
|
import KundePassesSection from "@/src/modules/products/ui/components/KundePassesSection";
|
||||||
import type { PassIssueOption } from "@/src/modules/products/actions/pass-admin-actions";
|
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) =>
|
const getDiceBearUrl = (seed: string) =>
|
||||||
`https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent(`u-${seed.slice(0, 6)}`)}`;
|
`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 + … ──────────────────────────────────────────
|
// ─── ID kürzen: erste 8 Zeichen + … ──────────────────────────────────────────
|
||||||
const shortenId = (id: string) => `${id.slice(0, 8)}…${id.slice(-4)}`;
|
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) : "–"}
|
value={kunde.gender ? (genderLabel[kunde.gender] ?? kunde.gender) : "–"}
|
||||||
/>
|
/>
|
||||||
<InfoRow icon={<Calendar size={15} />} label="Geburtsdatum" value={birthdayFormatted} />
|
<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 */}
|
{/* Kunden-ID über volle Breite */}
|
||||||
<div className="sm:col-span-2 flex items-start gap-2.5">
|
<div className="sm:col-span-2 flex items-start gap-2.5">
|
||||||
<span className="mt-0.5 text-blue shrink-0">
|
<span className="mt-0.5 text-blue shrink-0">
|
||||||
|
|
|
||||||
114
src/modules/marketing/actions/consent-actions.ts
Normal file
114
src/modules/marketing/actions/consent-actions.ts
Normal 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." };
|
||||||
|
}
|
||||||
|
}
|
||||||
455
src/modules/marketing/actions/editor-actions.ts
Normal file
455
src/modules/marketing/actions/editor-actions.ts
Normal 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." };
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/modules/marketing/lib/audit-log.ts
Normal file
55
src/modules/marketing/lib/audit-log.ts
Normal 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 } : {}),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
72
src/modules/marketing/lib/compose-email.ts
Normal file
72
src/modules/marketing/lib/compose-email.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
34
src/modules/marketing/lib/consent-text.ts
Normal file
34
src/modules/marketing/lib/consent-text.ts
Normal 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",
|
||||||
|
};
|
||||||
97
src/modules/marketing/lib/consent.ts
Normal file
97
src/modules/marketing/lib/consent.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { revalidateTag } from "next/cache";
|
||||||
|
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, businessId, granted, source } = params;
|
||||||
|
|
||||||
|
const changed = await 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;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
232
src/modules/marketing/lib/graph.ts
Normal file
232
src/modules/marketing/lib/graph.ts
Normal 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;
|
||||||
|
}
|
||||||
210
src/modules/marketing/lib/node-config.ts
Normal file
210
src/modules/marketing/lib/node-config.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
65
src/modules/marketing/lib/personal-code.ts
Normal file
65
src/modules/marketing/lib/personal-code.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/modules/marketing/lib/placeholders.ts
Normal file
34
src/modules/marketing/lib/placeholders.ts
Normal 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, " ");
|
||||||
|
}
|
||||||
424
src/modules/marketing/lib/run-marketing.ts
Normal file
424
src/modules/marketing/lib/run-marketing.ts
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
151
src/modules/marketing/lib/triggers.ts
Normal file
151
src/modules/marketing/lib/triggers.ts
Normal 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/modules/marketing/lib/unsubscribe-token.ts
Normal file
49
src/modules/marketing/lib/unsubscribe-token.ts
Normal 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)}`;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
194
src/modules/marketing/ui/components/MarketingHistory.tsx
Normal file
194
src/modules/marketing/ui/components/MarketingHistory.tsx
Normal 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;
|
||||||
85
src/modules/marketing/ui/components/MobileFlowList.tsx
Normal file
85
src/modules/marketing/ui/components/MobileFlowList.tsx
Normal 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;
|
||||||
423
src/modules/marketing/ui/components/NodeSettingsSheet.tsx
Normal file
423
src/modules/marketing/ui/components/NodeSettingsSheet.tsx
Normal 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;
|
||||||
63
src/modules/marketing/ui/components/flow/EmailNode.tsx
Normal file
63
src/modules/marketing/ui/components/flow/EmailNode.tsx
Normal 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;
|
||||||
374
src/modules/marketing/ui/components/flow/FlowEditor.tsx
Normal file
374
src/modules/marketing/ui/components/flow/FlowEditor.tsx
Normal 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;
|
||||||
70
src/modules/marketing/ui/components/flow/TriggerNode.tsx
Normal file
70
src/modules/marketing/ui/components/flow/TriggerNode.tsx
Normal 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;
|
||||||
16
src/modules/marketing/ui/components/flow/editor-context.ts
Normal file
16
src/modules/marketing/ui/components/flow/editor-context.ts
Normal 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;
|
||||||
|
}
|
||||||
138
src/modules/marketing/ui/components/flow/flow-model.ts
Normal file
138
src/modules/marketing/ui/components/flow/flow-model.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
113
src/modules/marketing/ui/views/marketing-view.tsx
Normal file
113
src/modules/marketing/ui/views/marketing-view.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
"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);
|
||||||
|
|
||||||
|
const changeBusiness = (next: string) => {
|
||||||
|
const seq = ++requestSeq.current;
|
||||||
|
const previous = businessId;
|
||||||
|
setBusinessId(next);
|
||||||
|
startLoading(async () => {
|
||||||
|
const result = await getMarketingEditor(next);
|
||||||
|
if (seq !== requestSeq.current) return;
|
||||||
|
if (!result.success) {
|
||||||
|
setBusinessId(previous);
|
||||||
|
toast.error(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
|
@ -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;
|
||||||
74
src/modules/marketing/ui/views/unsubscribe-view.tsx
Normal file
74
src/modules/marketing/ui/views/unsubscribe-view.tsx
Normal 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;
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
import { notifyManagerOfBooking } from "@/src/modules/courses/lib/notify-booking";
|
import { notifyManagerOfBooking } from "@/src/modules/courses/lib/notify-booking";
|
||||||
import { spotDisplayName } from "@/src/modules/rooms/lib/floor-plan";
|
import { spotDisplayName } from "@/src/modules/rooms/lib/floor-plan";
|
||||||
import { canRedeemPass, passCoversCourse } from "../lib/pass-rules";
|
import { canRedeemPass, passCoversCourse } from "../lib/pass-rules";
|
||||||
|
import { grantConsentQuietly } from "@/src/modules/marketing/lib/consent";
|
||||||
|
|
||||||
export type BookWithPassResult =
|
export type BookWithPassResult =
|
||||||
| { success: true; seats: number; creditsRemaining: number }
|
| { success: true; seats: number; creditsRemaining: number }
|
||||||
|
|
@ -55,6 +56,8 @@ export async function bookCourseWithPass(input: {
|
||||||
passId: string;
|
passId: string;
|
||||||
seats: number;
|
seats: number;
|
||||||
spotIds?: string[];
|
spotIds?: string[];
|
||||||
|
/** Häkchen für Werbe-Mails des Studios (Opt-in) */
|
||||||
|
marketingConsent?: boolean;
|
||||||
}): Promise<BookWithPassResult> {
|
}): Promise<BookWithPassResult> {
|
||||||
const userData = await getUserData();
|
const userData = await getUserData();
|
||||||
if (!userData || !isUser(userData)) {
|
if (!userData || !isUser(userData)) {
|
||||||
|
|
@ -242,6 +245,14 @@ export async function bookCourseWithPass(input: {
|
||||||
.deleteMany({ where: { courseId, userId: userData.id } })
|
.deleteMany({ where: { courseId, userId: userData.id } })
|
||||||
.catch((err) => console.error("Warteliste-Aufräumen fehlgeschlagen:", err));
|
.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 baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
||||||
const mail = generateBookingConfirmationEmail({
|
const mail = generateBookingConfirmationEmail({
|
||||||
courseName: course.name,
|
courseName: course.name,
|
||||||
|
|
|
||||||
89
src/modules/products/actions/pass-purchase-actions.ts
Normal file
89
src/modules/products/actions/pass-purchase-actions.ts
Normal file
|
|
@ -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." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,10 @@ import { sendMail } from "@/src/lib/mail/resend";
|
||||||
import { generatePassConfirmationEmail } from "@/src/lib/mail/templates/pass-confirmation";
|
import { generatePassConfirmationEmail } from "@/src/lib/mail/templates/pass-confirmation";
|
||||||
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
||||||
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
|
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
|
||||||
|
import {
|
||||||
|
confirmDiscountReservation,
|
||||||
|
releaseDiscountReservation,
|
||||||
|
} from "@/src/modules/settings/lib/discount-validation";
|
||||||
import { validUntilFrom } from "./pass-rules";
|
import { validUntilFrom } from "./pass-rules";
|
||||||
|
|
||||||
/** Markiert Checkout-Sessions für einen Kartenkauf (siehe api/stripe/checkout/pass). */
|
/** 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
|
* Legt die gekaufte Karte an, sobald Stripe die Zahlung meldet - analog zur
|
||||||
* Kursbuchung nie auf der Success-Page. Idempotent über stripeSessionId: eine
|
* Kursbuchung nie auf der Success-Page. Idempotent über stripeSessionId: eine
|
||||||
|
|
@ -112,29 +132,46 @@ export async function handlePassCheckoutCompleted(
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const validUntil = validUntilFrom(now, product.validityMonths);
|
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;
|
let passId: string;
|
||||||
try {
|
try {
|
||||||
const pass = await prisma.customerPass.create({
|
const pass = await prisma.$transaction(async (tx) => {
|
||||||
data: {
|
const created = await tx.customerPass.create({
|
||||||
productId: product.id,
|
data: {
|
||||||
businessId: product.businessId,
|
productId: product.id,
|
||||||
userId: user.id,
|
businessId: product.businessId,
|
||||||
email: user.email.toLowerCase(),
|
userId: user.id,
|
||||||
name: product.name,
|
email: user.email.toLowerCase(),
|
||||||
sports: product.sports,
|
name: product.name,
|
||||||
creditsTotal: product.credits,
|
sports: product.sports,
|
||||||
creditsRemaining: product.credits,
|
creditsTotal: product.credits,
|
||||||
validUntil,
|
creditsRemaining: product.credits,
|
||||||
source: "purchase",
|
validUntil,
|
||||||
priceCents: amountPaidCents - platformFeeCents,
|
source: "purchase",
|
||||||
stripeAccountId,
|
priceCents: amountPaidCents - platformFeeCents,
|
||||||
stripeSessionId: session.id,
|
stripeAccountId,
|
||||||
stripePaymentIntentId: paymentIntentId,
|
stripeSessionId: session.id,
|
||||||
amountPaid: amountPaidCents,
|
stripePaymentIntentId: paymentIntentId,
|
||||||
platformFee: platformFeeCents,
|
amountPaid: amountPaidCents,
|
||||||
...(billingAddress ? { billingAddress } : {}),
|
platformFee: platformFeeCents,
|
||||||
},
|
discountCodeId,
|
||||||
select: { id: true },
|
discountAmount: discountCodeId ? discountAmountCents : null,
|
||||||
|
...(billingAddress ? { billingAddress } : {}),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (discountReservationToken) {
|
||||||
|
await confirmDiscountReservation(tx, {
|
||||||
|
reservationToken: discountReservationToken,
|
||||||
|
passId: created.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return created;
|
||||||
});
|
});
|
||||||
passId = pass.id;
|
passId = pass.id;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
165
src/modules/products/ui/components/PassPurchaseDialog.tsx
Normal file
165
src/modules/products/ui/components/PassPurchaseDialog.tsx
Normal 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;
|
||||||
|
|
@ -8,7 +8,9 @@ import { formatPrice } from "@/src/lib/currency";
|
||||||
import { cardServiceFeeCents, pricePerUnitCents } from "../../lib/card-pricing";
|
import { cardServiceFeeCents, pricePerUnitCents } from "../../lib/card-pricing";
|
||||||
import { formatSports, formatValidity } from "../../lib/card-format";
|
import { formatSports, formatValidity } from "../../lib/card-format";
|
||||||
import type { CardDesign } from "../../lib/card-design";
|
import type { CardDesign } from "../../lib/card-design";
|
||||||
|
import { getPassPurchaseContext } from "../../actions/pass-purchase-actions";
|
||||||
import PassCard from "./PassCard";
|
import PassCard from "./PassCard";
|
||||||
|
import PassPurchaseDialog from "./PassPurchaseDialog";
|
||||||
|
|
||||||
export type PublicProduct = {
|
export type PublicProduct = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -34,7 +36,8 @@ interface PublicPassShopProps {
|
||||||
/**
|
/**
|
||||||
* Karten zum Kaufen auf der öffentlichen Studio-Seite. Der Preis steht wie bei
|
* 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
|
* 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 = ({
|
const PublicPassShop = ({
|
||||||
products,
|
products,
|
||||||
|
|
@ -47,14 +50,49 @@ const PublicPassShop = ({
|
||||||
}: PublicPassShopProps) => {
|
}: PublicPassShopProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [buyingId, setBuyingId] = useState<string | null>(null);
|
const [buyingId, setBuyingId] = useState<string | null>(null);
|
||||||
|
const [purchase, setPurchase] = useState<{
|
||||||
|
product: PublicProduct;
|
||||||
|
consentLabel: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const buy = async (productId: string) => {
|
const goToLogin = () => {
|
||||||
setBuyingId(productId);
|
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 {
|
try {
|
||||||
const response = await fetch("/api/stripe/checkout/pass", {
|
const response = await fetch("/api/stripe/checkout/pass", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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 {
|
const data = (await response.json().catch(() => ({}))) as {
|
||||||
url?: string;
|
url?: string;
|
||||||
|
|
@ -63,19 +101,16 @@ const PublicPassShop = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
if (data.loginRequired) {
|
if (data.loginRequired) {
|
||||||
toast.info("Bitte melden Sie sich an, um eine Karte zu kaufen.");
|
goToLogin();
|
||||||
router.push(`/login?returnTo=${encodeURIComponent(`/business/${studioSlug}`)}`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!response.ok || !data.url) {
|
if (!response.ok || !data.url) {
|
||||||
toast.error(data.error ?? "Der Kauf konnte nicht gestartet werden.");
|
toast.error(data.error ?? "Der Kauf konnte nicht gestartet werden.");
|
||||||
setBuyingId(null);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.location.assign(data.url);
|
window.location.assign(data.url);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Ein unerwarteter Fehler ist aufgetreten.");
|
toast.error("Ein unerwarteter Fehler ist aufgetreten.");
|
||||||
setBuyingId(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -126,18 +161,31 @@ const PublicPassShop = ({
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void buy(product.id)}
|
onClick={() => void openPurchase(product)}
|
||||||
disabled={buyingId !== null}
|
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"
|
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 }}
|
style={{ backgroundColor: accentColor, color: accentTextColor }}
|
||||||
>
|
>
|
||||||
{buyingId === product.id ? "Wird geöffnet…" : "Kaufen"}
|
{buyingId === product.id ? "Einen Moment…" : "Kaufen"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export type DiscountCodeDTO = {
|
||||||
code: string;
|
code: string;
|
||||||
type: "percent" | "fixed";
|
type: "percent" | "fixed";
|
||||||
value: number;
|
value: number;
|
||||||
|
appliesTo: "courses" | "passes" | "all";
|
||||||
scope: "all_courses" | "specific_courses";
|
scope: "all_courses" | "specific_courses";
|
||||||
courseIds: string[];
|
courseIds: string[];
|
||||||
maxRedemptions: number | null;
|
maxRedemptions: number | null;
|
||||||
|
|
@ -40,8 +41,10 @@ export async function getDiscountCodes(
|
||||||
return { success: false, error: "Keine Berechtigung für dieses Business." };
|
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({
|
const codes = await prisma.discountCode.findMany({
|
||||||
where: { businessId },
|
where: { businessId, origin: "manual" },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -100,6 +103,7 @@ export async function createDiscountCode(
|
||||||
code: normalizedCode,
|
code: normalizedCode,
|
||||||
type: parsed.data.type,
|
type: parsed.data.type,
|
||||||
value: parsed.data.value,
|
value: parsed.data.value,
|
||||||
|
appliesTo: parsed.data.appliesTo,
|
||||||
scope: parsed.data.scope,
|
scope: parsed.data.scope,
|
||||||
courseIds: parsed.data.scope === "specific_courses" ? courseIds : [],
|
courseIds: parsed.data.scope === "specific_courses" ? courseIds : [],
|
||||||
maxRedemptions: parsed.data.maxRedemptions,
|
maxRedemptions: parsed.data.maxRedemptions,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,12 @@ type PrismaOrTx =
|
||||||
| typeof prismaClient
|
| typeof prismaClient
|
||||||
| Parameters<Parameters<typeof prismaClient.$transaction>[0]>[0];
|
| 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 =
|
export type DiscountValidationResult =
|
||||||
| { valid: true; discountCode: { id: string; type: string; value: number } }
|
| { valid: true; discountCode: { id: string; type: string; value: number } }
|
||||||
| { valid: false; reason: string };
|
| { valid: false; reason: string };
|
||||||
|
|
@ -19,7 +25,7 @@ export type DiscountValidationResult =
|
||||||
// reserveDiscountCode() gleich danach, nicht hier.
|
// reserveDiscountCode() gleich danach, nicht hier.
|
||||||
export async function validateDiscountCode(
|
export async function validateDiscountCode(
|
||||||
db: PrismaOrTx,
|
db: PrismaOrTx,
|
||||||
params: { code: string; businessId: string; courseId: string; userId: string | null }
|
params: { code: string; businessId: string; userId: string | null } & DiscountTarget
|
||||||
): Promise<DiscountValidationResult> {
|
): Promise<DiscountValidationResult> {
|
||||||
const normalizedCode = params.code.trim().toUpperCase();
|
const normalizedCode = params.code.trim().toUpperCase();
|
||||||
if (!normalizedCode) {
|
if (!normalizedCode) {
|
||||||
|
|
@ -42,11 +48,20 @@ export async function validateDiscountCode(
|
||||||
return { valid: false, reason: "Dieser Rabattcode ist abgelaufen." };
|
return { valid: false, reason: "Dieser Rabattcode ist abgelaufen." };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if ("productId" in params) {
|
||||||
discountCode.scope === "specific_courses" &&
|
if (discountCode.appliesTo !== "passes" && discountCode.appliesTo !== "all") {
|
||||||
!discountCode.courseIds.includes(params.courseId)
|
return { valid: false, reason: "Dieser Rabattcode gilt nicht für Karten." };
|
||||||
) {
|
}
|
||||||
return { valid: false, reason: "Dieser Rabattcode gilt nicht für diesen Kurs." };
|
} 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 (
|
if (
|
||||||
|
|
@ -64,6 +79,12 @@ export async function validateDiscountCode(
|
||||||
return { valid: false, reason: "Rabattcodes sind nur für angemeldete Kunden verfügbar." };
|
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({
|
const existingRedemption = await db.discountRedemption.findUnique({
|
||||||
where: { discountCodeId_userId: { discountCodeId: discountCode.id, userId: params.userId } },
|
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.
|
// "bereits reserviert" abfängt, statt ihn nach oben durchzureichen.
|
||||||
export async function reserveDiscountCode(
|
export async function reserveDiscountCode(
|
||||||
db: PrismaOrTx,
|
db: PrismaOrTx,
|
||||||
params: { code: string; businessId: string; courseId: string; userId: string }
|
params: { code: string; businessId: string; userId: string } & DiscountTarget
|
||||||
): Promise<ReserveDiscountResult> {
|
): Promise<ReserveDiscountResult> {
|
||||||
const validation = await validateDiscountCode(db, params);
|
const validation = await validateDiscountCode(db, params);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
|
|
@ -230,13 +251,17 @@ export async function releaseDiscountReservation(
|
||||||
// tut diese Funktion nichts: die Buchung wurde vom Aufrufer bereits
|
// tut diese Funktion nichts: die Buchung wurde vom Aufrufer bereits
|
||||||
// unabhängig davon zum schon belasteten (rabattierten) Preis bestätigt, nur
|
// unabhängig davon zum schon belasteten (rabattierten) Preis bestätigt, nur
|
||||||
// ohne verbuchte Redemption. Das ist bewusstes Verhalten, kein Fehlerfall.
|
// 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(
|
export async function confirmDiscountReservation(
|
||||||
tx: PrismaOrTx,
|
tx: PrismaOrTx,
|
||||||
params: { reservationToken: string; bookingId: string }
|
params: { reservationToken: string } & ({ bookingId: string } | { passId: string })
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const result = await tx.discountRedemption.updateMany({
|
const result = await tx.discountRedemption.updateMany({
|
||||||
where: { stripeSessionId: params.reservationToken, status: "reserved" },
|
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;
|
return result.count > 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ export const discountCodeSchema = z
|
||||||
.regex(/^[A-Z0-9_-]+$/, "Nur Großbuchstaben, Zahlen, - und _ erlaubt"),
|
.regex(/^[A-Z0-9_-]+$/, "Nur Großbuchstaben, Zahlen, - und _ erlaubt"),
|
||||||
type: z.enum(["percent", "fixed"]),
|
type: z.enum(["percent", "fixed"]),
|
||||||
value: z.number().int().positive(),
|
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"]),
|
scope: z.enum(["all_courses", "specific_courses"]),
|
||||||
courseIds: z.array(z.string()).default([]),
|
courseIds: z.array(z.string()).default([]),
|
||||||
maxRedemptions: z.number().int().positive().nullable(),
|
maxRedemptions: z.number().int().positive().nullable(),
|
||||||
|
|
@ -28,6 +30,13 @@ export const discountCodeSchema = z
|
||||||
message: "Prozentwert darf maximal 100 sein",
|
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) {
|
if (data.scope === "specific_courses" && data.courseIds.length === 0) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: "custom",
|
code: "custom",
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ interface DiscountCodeListProps {
|
||||||
code: string;
|
code: string;
|
||||||
type: "percent" | "fixed";
|
type: "percent" | "fixed";
|
||||||
value: number;
|
value: number;
|
||||||
|
appliesTo: "courses" | "passes" | "all";
|
||||||
scope: "all_courses" | "specific_courses";
|
scope: "all_courses" | "specific_courses";
|
||||||
courseIds: string[];
|
courseIds: string[];
|
||||||
maxRedemptions: number | null;
|
maxRedemptions: number | null;
|
||||||
|
|
@ -57,6 +58,7 @@ const emptyForm = {
|
||||||
code: "",
|
code: "",
|
||||||
type: "percent" as "percent" | "fixed",
|
type: "percent" as "percent" | "fixed",
|
||||||
value: 10,
|
value: 10,
|
||||||
|
appliesTo: "courses" as "courses" | "passes" | "all",
|
||||||
scope: "all_courses" as "all_courses" | "specific_courses",
|
scope: "all_courses" as "all_courses" | "specific_courses",
|
||||||
courseIds: [] as string[],
|
courseIds: [] as string[],
|
||||||
maxRedemptions: "" as string, // leer = unbegrenzt, siehe toInput()
|
maxRedemptions: "" as string, // leer = unbegrenzt, siehe toInput()
|
||||||
|
|
@ -166,8 +168,10 @@ const DiscountCodeList = ({
|
||||||
code: form.code,
|
code: form.code,
|
||||||
type: form.type,
|
type: form.type,
|
||||||
value: form.value,
|
value: form.value,
|
||||||
scope: form.scope,
|
appliesTo: form.appliesTo,
|
||||||
courseIds: form.courseIds,
|
// 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,
|
maxRedemptions: form.maxRedemptions.trim() ? Number(form.maxRedemptions) : null,
|
||||||
startsAt: form.startsAt ? parseLocalStartOfDay(form.startsAt) : null,
|
startsAt: form.startsAt ? parseLocalStartOfDay(form.startsAt) : null,
|
||||||
expiresAt: form.expiresAt ? parseLocalEndOfDay(form.expiresAt) : null,
|
expiresAt: form.expiresAt ? parseLocalEndOfDay(form.expiresAt) : null,
|
||||||
|
|
@ -325,30 +329,56 @@ const DiscountCodeList = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<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">
|
<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"}
|
["courses", "Kurse"],
|
||||||
size="sm"
|
["passes", "Karten"],
|
||||||
className="flex-1"
|
["all", "Kurse & Karten"],
|
||||||
onClick={() => setForm((f) => ({ ...f, scope: "all_courses" }))}
|
] as const
|
||||||
>
|
).map(([value, label]) => (
|
||||||
Alle Kurse
|
<Button
|
||||||
</Button>
|
key={value}
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
variant={form.appliesTo === value ? "default" : "ghost"}
|
||||||
variant={form.scope === "specific_courses" ? "default" : "ghost"}
|
size="sm"
|
||||||
size="sm"
|
className="flex-1"
|
||||||
className="flex-1"
|
onClick={() => setForm((f) => ({ ...f, appliesTo: value }))}
|
||||||
onClick={() => setForm((f) => ({ ...f, scope: "specific_courses" }))}
|
>
|
||||||
>
|
{label}
|
||||||
Bestimmte Kurse
|
</Button>
|
||||||
</Button>
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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
|
<MultiSelectDropdown
|
||||||
label="Kurse"
|
label="Kurse"
|
||||||
selected={form.courseIds}
|
selected={form.courseIds}
|
||||||
|
|
@ -614,12 +644,19 @@ const DiscountCodeList = ({
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||||
<Layers size={13} className="shrink-0 text-gray-400" />
|
<Layers size={13} className="shrink-0 text-gray-400" />
|
||||||
{c.scope === "all_courses" ? (
|
{c.appliesTo === "passes" ? (
|
||||||
<span>Gilt für alle Kurse</span>
|
<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">
|
<span className="truncate">
|
||||||
Gilt für:{" "}
|
Gilt für:{" "}
|
||||||
{c.courseIds.map((id) => courseNameById.get(id) ?? "Unbekannt").join(", ")}
|
{c.courseIds.map((id) => courseNameById.get(id) ?? "Unbekannt").join(", ")}
|
||||||
|
{c.appliesTo === "all" && " und Karten"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
Building2,
|
Building2,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
LucideIcon,
|
LucideIcon,
|
||||||
|
Mail,
|
||||||
Megaphone,
|
Megaphone,
|
||||||
PanelTop,
|
PanelTop,
|
||||||
Settings,
|
Settings,
|
||||||
|
|
@ -103,6 +104,14 @@ const SettingsOverviewView = async () => {
|
||||||
icon={Bell}
|
icon={Bell}
|
||||||
href="/settings/notifications"
|
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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
60
tests/api/cron/marketing.test.ts
Normal file
60
tests/api/cron/marketing.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -10,11 +10,26 @@ vi.mock("@/src/lib/stripe-checkout", () => ({
|
||||||
createBrandedCheckoutSession: vi.fn(),
|
createBrandedCheckoutSession: vi.fn(),
|
||||||
studioCheckoutBranding: () => ({}),
|
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 { POST } from "@/src/app/api/stripe/checkout/pass/route";
|
||||||
import { prisma } from "@/src/lib/prisma";
|
import { prisma } from "@/src/lib/prisma";
|
||||||
import { getUserData } from "@/src/lib/auth/getUser";
|
import { getUserData } from "@/src/lib/auth/getUser";
|
||||||
import { createBrandedCheckoutSession } from "@/src/lib/stripe-checkout";
|
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 = {
|
const product = {
|
||||||
id: "prod-1",
|
id: "prod-1",
|
||||||
|
|
@ -93,4 +108,67 @@ describe("POST /api/stripe/checkout/pass", () => {
|
||||||
]);
|
]);
|
||||||
expect(params.metadata).toMatchObject({ kind: "pass", productId: "prod-1", userId: "user-1" });
|
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("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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
86
tests/marketing/consent.test.ts
Normal file
86
tests/marketing/consent.test.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
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/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();
|
||||||
|
});
|
||||||
|
});
|
||||||
122
tests/marketing/graph.test.ts
Normal file
122
tests/marketing/graph.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
tests/marketing/placeholders.test.ts
Normal file
40
tests/marketing/placeholders.test.ts
Normal 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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
247
tests/marketing/run-marketing.test.ts
Normal file
247
tests/marketing/run-marketing.test.ts
Normal 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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
187
tests/marketing/triggers.test.ts
Normal file
187
tests/marketing/triggers.test.ts
Normal 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"]);
|
||||||
|
});
|
||||||
30
tests/marketing/unsubscribe-token.test.ts
Normal file
30
tests/marketing/unsubscribe-token.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
vi.mock("@/src/lib/prisma", () => ({
|
vi.mock("@/src/lib/prisma", () => {
|
||||||
prisma: {
|
const prisma = {
|
||||||
customerPass: { findUnique: vi.fn(), create: vi.fn() },
|
customerPass: { findUnique: vi.fn(), create: vi.fn() },
|
||||||
product: { findUnique: vi.fn() },
|
product: { findUnique: vi.fn() },
|
||||||
user: { 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", () => ({
|
vi.mock("@/src/lib/stripe", () => ({
|
||||||
stripe: { paymentIntents: { retrieve: vi.fn() } },
|
stripe: { paymentIntents: { retrieve: vi.fn() } },
|
||||||
onAccount: (id: string | null) => (id ? { stripeAccount: id } : undefined),
|
onAccount: (id: string | null) => (id ? { stripeAccount: id } : undefined),
|
||||||
|
|
@ -20,6 +28,7 @@ vi.mock("@/src/modules/notifications/lib/create-notification", () => ({
|
||||||
|
|
||||||
import {
|
import {
|
||||||
handlePassCheckoutCompleted,
|
handlePassCheckoutCompleted,
|
||||||
|
handlePassCheckoutUnpaid,
|
||||||
isPassCheckout,
|
isPassCheckout,
|
||||||
} from "@/src/modules/products/lib/pass-webhook";
|
} from "@/src/modules/products/lib/pass-webhook";
|
||||||
import { prisma } from "@/src/lib/prisma";
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
70
tests/settings/discount-validation.test.ts
Normal file
70
tests/settings/discount-validation.test.ts
Normal 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
9
vercel.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||||
|
"crons": [
|
||||||
|
{
|
||||||
|
"path": "/api/cron/marketing",
|
||||||
|
"schedule": "0 7 * * *"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue