mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 19:01:16 +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).
|
||||
|
||||
| Variable | Quelle |
|
||||
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| `DATABASE_URL` | Supabase Staging, Transaction Pooler, Port 6543, `?pgbouncer=true&connection_limit=1` |
|
||||
| `DIRECT_URL` | Supabase Staging, **Session Pooler** (nicht Direct Connection!), Port 5432 — siehe Stolperfalle unten |
|
||||
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase Staging Projekt-URL |
|
||||
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase Staging anon key |
|
||||
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase Staging service_role key |
|
||||
| `RESEND_API_KEY` / `RESEND_FROM` / `SMTP_TEST_MAIL` | wiederverwendet von lokal |
|
||||
| `STRIPE_SECRET_KEY` / `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Test-Mode-Keys, wiederverwendet von lokal |
|
||||
| `STRIPE_WEBHOOK_SECRET` | eigener Wert, siehe Stripe-Webhook unten |
|
||||
| `STRIPE_PLATFORM_FEE_PERCENT` | gleicher Wert wie lokal |
|
||||
| `NEXT_PUBLIC_SITE_URL` / `NEXT_PUBLIC_APP_URL` | `https://kumasy-git-dev-kumasy.vercel.app` |
|
||||
| `COMPANY_NAME` | `"Kumasy"` |
|
||||
| Variable | Quelle |
|
||||
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| `DATABASE_URL` | Supabase Staging, Transaction Pooler, Port 6543, `?pgbouncer=true&connection_limit=1` |
|
||||
| `DIRECT_URL` | Supabase Staging, **Session Pooler** (nicht Direct Connection!), Port 5432 — siehe Stolperfalle unten |
|
||||
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase Staging Projekt-URL |
|
||||
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase Staging anon key |
|
||||
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase Staging service_role key |
|
||||
| `RESEND_API_KEY` / `RESEND_FROM` / `SMTP_TEST_MAIL` | wiederverwendet von lokal |
|
||||
| `STRIPE_SECRET_KEY` / `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Test-Mode-Keys, wiederverwendet von lokal |
|
||||
| `STRIPE_WEBHOOK_SECRET` | eigener Wert, siehe Stripe-Webhook unten |
|
||||
| `STRIPE_PLATFORM_FEE_PERCENT` | gleicher Wert wie lokal |
|
||||
| `NEXT_PUBLIC_SITE_URL` / `NEXT_PUBLIC_APP_URL` | `https://kumasy-git-dev-kumasy.vercel.app` |
|
||||
| `COMPANY_NAME` | `"Kumasy"` |
|
||||
| `CRON_SECRET` | eigener Zufallswert (`openssl rand -hex 32`), schützt `/api/cron/marketing`, siehe Marketing-Cron unten |
|
||||
| `MARKETING_TOKEN_SECRET` | eigener Zufallswert, signiert die Abmeldelinks in Marketing-Mails - nie ändern, sonst gehen alte Links nicht mehr |
|
||||
|
||||
Lokales Pendant für einmalige DB-Operationen gegen Staging (z.B. neue Migrationen einspielen): `.env.staging` im Repo-Root, gitignored (`.env*` steht in `.gitignore`), wird nie committed.
|
||||
|
||||
## Marketing-Cron
|
||||
|
||||
Automatische Marketing-Mails (Geburtstag, Meilenstein, Reaktivierung) verschickt einmal täglich `GET /api/cron/marketing`. Die Route prüft nur den Header `Authorization: Bearer <CRON_SECRET>` - wer sie aufruft, ist egal:
|
||||
|
||||
- **Production:** Vercel Cron aus `vercel.json` (täglich 07:00 UTC = 9 Uhr Sommer / 8 Uhr Winter). Vercel setzt den Header selbst, sobald `CRON_SECRET` als Umgebungsvariable existiert. Vercel Cron läuft **nur auf Production-Deployments**.
|
||||
- **Staging:** Job bei [cron-job.org](https://cron-job.org) auf `https://kumasy-git-dev-kumasy.vercel.app/api/cron/marketing`, täglich, mit dem Header `Authorization: Bearer <CRON_SECRET aus Vercel Preview>`. Ist die Vercel Deployment Protection für Previews an, zusätzlich `x-vercel-protection-bypass: <Bypass-Secret>` (Project Settings → Deployment Protection → Protection Bypass for Automation).
|
||||
- **Lokal:** `npm run cron:marketing` (liest `CRON_SECRET` aus `.env`). Zum Testen: `-- --dry-run` (zeigt nur, wer was bekäme), `-- --date=2026-05-12` (tut so, als wäre heute dieser Tag), `-- --business=<id>`.
|
||||
|
||||
`date` ist in Production gesperrt. Doppelte Aufrufe sind harmlos: pro Studio, Kunde, Trigger-Art und Zeitraum gibt es höchstens eine Mail (Unique-Key in `MarketingSend`).
|
||||
|
||||
## Build Command (Vercel Project Settings → Build & Development Settings)
|
||||
|
||||
```
|
||||
|
|
|
|||
177
package-lock.json
generated
177
package-lock.json
generated
|
|
@ -36,6 +36,7 @@
|
|||
"@tiptap/extension-underline": "^3.30.5",
|
||||
"@tiptap/react": "^3.30.5",
|
||||
"@tiptap/starter-kit": "^3.30.5",
|
||||
"@xyflow/react": "^12.12.0",
|
||||
"archiver": "^8.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
@ -7262,6 +7263,15 @@
|
|||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-drag": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
|
|
@ -7292,6 +7302,12 @@
|
|||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz",
|
||||
"integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
|
|
@ -7313,6 +7329,25 @@
|
|||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-transition": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-zoom": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.9.tgz",
|
||||
"integrity": "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
|
|
@ -8345,6 +8380,76 @@
|
|||
"license": "Apache-2.0",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.12.0.tgz",
|
||||
"integrity": "sha512-74oXI0Rgm1Eu33cHQAd74J6oVgCH9mbFyTVQbsGgp2kmWwh0HdtBr2VP04z8mbPD+M/xO69nqG5dDEt7/EN2rQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xyflow/system": "0.0.83",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=17",
|
||||
"@types/react-dom": ">=17",
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/react/node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/system": {
|
||||
"version": "0.0.83",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.83.tgz",
|
||||
"integrity": "sha512-a//eDjez3WZF47aaPTfVUDD0OINOq+WVwBZCRTqG+m0a5euX8EwlJ4jMY1Wxz0Ocj4xOeekJ70V22Pq0ll8RJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-drag": "^3.0.7",
|
||||
"@types/d3-interpolate": "^3.0.4",
|
||||
"@types/d3-selection": "^3.0.10",
|
||||
"@types/d3-transition": "^3.0.8",
|
||||
"@types/d3-zoom": "^3.0.8",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
|
|
@ -9244,6 +9349,12 @@
|
|||
"url": "https://polar.sh/cva"
|
||||
}
|
||||
},
|
||||
"node_modules/classcat": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
|
|
@ -9644,6 +9755,28 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
|
|
@ -9699,6 +9832,15 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
|
|
@ -9744,6 +9886,41 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@
|
|||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"stripe:listen": "stripe listen --forward-to localhost:3000/api/stripe/webhook --forward-connect-to localhost:3000/api/stripe/webhook"
|
||||
"stripe:listen": "stripe listen --forward-to localhost:3000/api/stripe/webhook --forward-connect-to localhost:3000/api/stripe/webhook",
|
||||
"cron:marketing": "node scripts/run-marketing-cron.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
|
@ -44,6 +45,7 @@
|
|||
"@tiptap/extension-underline": "^3.30.5",
|
||||
"@tiptap/react": "^3.30.5",
|
||||
"@tiptap/starter-kit": "^3.30.5",
|
||||
"@xyflow/react": "^12.12.0",
|
||||
"archiver": "^8.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_productId_fkey" FOREIGN
|
|||
ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PassAdjustment" ADD CONSTRAINT "PassAdjustment_passId_fkey" FOREIGN KEY ("passId") REFERENCES "CustomerPass"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
-- Eine Karte überlebt das Löschen des Kundenkontos (Kauf, Rechnung und
|
||||
-- Erstattung müssen erhalten bleiben). Die Migration 20261004110000 hatte die
|
||||
-- Regel zuerst als CASCADE angelegt; sie wurde danach im Review auf SET NULL
|
||||
-- geändert, war da aber schon eingespielt. Die Änderung steht deshalb hier als
|
||||
-- eigene Migration, statt die alte Datei umzuschreiben.
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "CustomerPass" DROP CONSTRAINT IF EXISTS "CustomerPass_userId_fkey";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
152
prisma/migrations/20261005100000_marketing/migration.sql
Normal file
152
prisma/migrations/20261005100000_marketing/migration.sql
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "DiscountCode" ADD COLUMN "appliesTo" TEXT NOT NULL DEFAULT 'courses',
|
||||
ADD COLUMN "origin" TEXT NOT NULL DEFAULT 'manual',
|
||||
ADD COLUMN "restrictedToUserId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "DiscountRedemption" ADD COLUMN "passId" TEXT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "CustomerPass" ADD COLUMN "discountAmount" INTEGER,
|
||||
ADD COLUMN "discountCodeId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MarketingConsent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"businessId" TEXT NOT NULL,
|
||||
"granted" BOOLEAN NOT NULL,
|
||||
"textVersion" TEXT NOT NULL,
|
||||
"source" TEXT NOT NULL,
|
||||
"grantedAt" TIMESTAMP(3),
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "MarketingConsent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MarketingNode" (
|
||||
"id" TEXT NOT NULL,
|
||||
"businessId" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"config" JSONB NOT NULL,
|
||||
"positionX" DOUBLE PRECISION NOT NULL,
|
||||
"positionY" DOUBLE PRECISION NOT NULL,
|
||||
"emailNodeId" TEXT,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT false,
|
||||
"activatedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "MarketingNode_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MarketingSend" (
|
||||
"id" TEXT NOT NULL,
|
||||
"businessId" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"triggerKind" TEXT NOT NULL,
|
||||
"periodKey" TEXT NOT NULL,
|
||||
"triggerNodeId" TEXT,
|
||||
"emailNodeId" TEXT,
|
||||
"discountCodeId" TEXT,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"attempts" INTEGER NOT NULL DEFAULT 1,
|
||||
"error" TEXT,
|
||||
"subject" TEXT NOT NULL,
|
||||
"sentAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "MarketingSend_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MarketingAuditLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"businessId" TEXT NOT NULL,
|
||||
"actorType" TEXT NOT NULL,
|
||||
"actorId" TEXT,
|
||||
"action" TEXT NOT NULL,
|
||||
"nodeId" TEXT,
|
||||
"userId" TEXT,
|
||||
"summary" TEXT NOT NULL,
|
||||
"before" JSONB,
|
||||
"after" JSONB,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "MarketingAuditLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MarketingConsent_businessId_granted_idx" ON "MarketingConsent"("businessId", "granted");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "MarketingConsent_userId_businessId_key" ON "MarketingConsent"("userId", "businessId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MarketingNode_businessId_idx" ON "MarketingNode"("businessId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MarketingNode_emailNodeId_idx" ON "MarketingNode"("emailNodeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "MarketingSend_discountCodeId_key" ON "MarketingSend"("discountCodeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MarketingSend_businessId_createdAt_idx" ON "MarketingSend"("businessId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MarketingSend_triggerNodeId_idx" ON "MarketingSend"("triggerNodeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MarketingSend_emailNodeId_idx" ON "MarketingSend"("emailNodeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "MarketingSend_businessId_userId_triggerKind_periodKey_key" ON "MarketingSend"("businessId", "userId", "triggerKind", "periodKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MarketingAuditLog_businessId_createdAt_idx" ON "MarketingAuditLog"("businessId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MarketingAuditLog_userId_idx" ON "MarketingAuditLog"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "DiscountRedemption_passId_key" ON "DiscountRedemption"("passId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "CustomerPass_discountCodeId_idx" ON "CustomerPass"("discountCodeId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DiscountRedemption" ADD CONSTRAINT "DiscountRedemption_passId_fkey" FOREIGN KEY ("passId") REFERENCES "CustomerPass"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_discountCodeId_fkey" FOREIGN KEY ("discountCodeId") REFERENCES "DiscountCode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MarketingConsent" ADD CONSTRAINT "MarketingConsent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MarketingConsent" ADD CONSTRAINT "MarketingConsent_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MarketingNode" ADD CONSTRAINT "MarketingNode_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MarketingNode" ADD CONSTRAINT "MarketingNode_emailNodeId_fkey" FOREIGN KEY ("emailNodeId") REFERENCES "MarketingNode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MarketingSend" ADD CONSTRAINT "MarketingSend_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MarketingSend" ADD CONSTRAINT "MarketingSend_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MarketingSend" ADD CONSTRAINT "MarketingSend_discountCodeId_fkey" FOREIGN KEY ("discountCodeId") REFERENCES "DiscountCode"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MarketingAuditLog" ADD CONSTRAINT "MarketingAuditLog_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
|
|
@ -44,6 +44,8 @@ model User {
|
|||
courseWaitlistEntries CourseWaitlistEntry[]
|
||||
discountRedemptions DiscountRedemption[]
|
||||
passes CustomerPass[]
|
||||
marketingConsents MarketingConsent[]
|
||||
marketingSends MarketingSend[]
|
||||
createdAt DateTime? @default(now())
|
||||
updatedAt DateTime? @updatedAt
|
||||
}
|
||||
|
|
@ -138,13 +140,17 @@ model Business {
|
|||
onboardingStartedAt DateTime? // Wizard erstmals geoeffnet, ab dann kein Redirect mehr
|
||||
onboardingDismissedAt DateTime? // Checkliste im Dashboard ausgeblendet
|
||||
onboardingCompletedAt DateTime? // Zeitpunkt, an dem erstmals alle Pflichtschritte erledigt waren
|
||||
manager Manager @relation(fields: [managerId], references: [id])
|
||||
manager Manager @relation(fields: [managerId], references: [id])
|
||||
courses Course[]
|
||||
discountCodes DiscountCode[]
|
||||
products Product[]
|
||||
customerPasses CustomerPass[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
marketingNodes MarketingNode[]
|
||||
marketingConsents MarketingConsent[]
|
||||
marketingSends MarketingSend[]
|
||||
marketingAuditLogs MarketingAuditLog[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([managerId])
|
||||
}
|
||||
|
|
@ -338,14 +344,14 @@ model Notification {
|
|||
// als natürlicher Unique-Key, um dasselbe Gerät bei erneutem Subscribe zu
|
||||
// aktualisieren statt zu duplizieren.
|
||||
model PushSubscription {
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid())
|
||||
recipientId String
|
||||
endpoint String @unique
|
||||
endpoint String @unique
|
||||
p256dh String // Public Key des Browsers für die Payload-Verschlüsselung
|
||||
auth String // Auth-Secret des Browsers für die Payload-Verschlüsselung
|
||||
userAgent String? // Nur zur Anzeige in einer künftigen "Geräte verwalten"-UI
|
||||
createdAt DateTime @default(now())
|
||||
lastSeenAt DateTime @default(now()) // Bei jedem erneuten Subscribe aktualisiert
|
||||
createdAt DateTime @default(now())
|
||||
lastSeenAt DateTime @default(now()) // Bei jedem erneuten Subscribe aktualisiert
|
||||
|
||||
@@index([recipientId])
|
||||
}
|
||||
|
|
@ -359,23 +365,31 @@ model PushSubscription {
|
|||
// Checkouts zu verhindern (siehe DiscountRedemption). Der Zähler wird wieder
|
||||
// dekrementiert, wenn eine Reservierung storniert/abgelaufen wird.
|
||||
model DiscountCode {
|
||||
id String @id @default(uuid())
|
||||
businessId String
|
||||
code String // Anzeigeform, z.B. "SOMMER25" — Eindeutigkeit nur pro Business (siehe @@unique)
|
||||
type String // "percent" | "fixed"
|
||||
value Int // percent: 1-100, fixed: Cents
|
||||
scope String // "all_courses" | "specific_courses"
|
||||
courseIds String[] // Nur relevant wenn scope = "specific_courses"
|
||||
maxRedemptions Int? // null = unbegrenzt
|
||||
redeemedCount Int @default(0)
|
||||
startsAt DateTime? // null = sofort gültig
|
||||
expiresAt DateTime? // null = kein Ablauf
|
||||
isActive Boolean @default(true) // Manuelles An/Aus, unabhängig vom Zeitfenster
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||
bookings CourseBooking[]
|
||||
redemptions DiscountRedemption[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(uuid())
|
||||
businessId String
|
||||
code String // Anzeigeform, z.B. "SOMMER25" — Eindeutigkeit nur pro Business (siehe @@unique)
|
||||
type String // "percent" | "fixed"
|
||||
value Int // percent: 1-100, fixed: Cents
|
||||
scope String // "all_courses" | "specific_courses"
|
||||
courseIds String[] // Nur relevant wenn scope = "specific_courses"
|
||||
maxRedemptions Int? // null = unbegrenzt
|
||||
redeemedCount Int @default(0)
|
||||
startsAt DateTime? // null = sofort gültig
|
||||
expiresAt DateTime? // null = kein Ablauf
|
||||
isActive Boolean @default(true) // Manuelles An/Aus, unabhängig vom Zeitfenster
|
||||
appliesTo String @default("courses") // "courses" | "passes" (Kartenkauf) | "all"
|
||||
// Persönlicher Code: nur dieses Konto kann ihn einlösen. Bewusst ohne
|
||||
// Fremdschlüssel - würde er beim Löschen des Kontos auf null gesetzt, könnte
|
||||
// plötzlich jeder den Code einlösen.
|
||||
restrictedToUserId String?
|
||||
origin String @default("manual") // "manual" (Rabattcode-Seite) | "marketing" (automatisch pro Mail erzeugt, nicht in der Liste)
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||
bookings CourseBooking[]
|
||||
passes CustomerPass[]
|
||||
redemptions DiscountRedemption[]
|
||||
marketingSend MarketingSend?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([businessId, code])
|
||||
@@index([businessId])
|
||||
|
|
@ -400,10 +414,12 @@ model DiscountRedemption {
|
|||
userId String
|
||||
stripeSessionId String @unique
|
||||
bookingId String? @unique
|
||||
passId String? @unique // Kartenkauf mit Rabattcode - gesetzt statt bookingId
|
||||
status String @default("reserved") // "reserved" | "confirmed"
|
||||
discountCode DiscountCode @relation(fields: [discountCodeId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
booking CourseBooking? @relation(fields: [bookingId], references: [id], onDelete: SetNull)
|
||||
pass CustomerPass? @relation(fields: [passId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([discountCodeId, userId])
|
||||
|
|
@ -417,7 +433,7 @@ model DiscountRedemption {
|
|||
// 1 Einheit = 1 Platz in einem Kurs. Zum Preis kommt die Servicegebühr pro
|
||||
// Einheit dazu (siehe src/modules/products/lib/card-pricing.ts).
|
||||
model Product {
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid())
|
||||
businessId String
|
||||
name String
|
||||
description String?
|
||||
|
|
@ -429,8 +445,8 @@ model Product {
|
|||
isActive Boolean @default(true) // false = nicht mehr kaufbar
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||
passes CustomerPass[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([businessId])
|
||||
}
|
||||
|
|
@ -443,7 +459,7 @@ model Product {
|
|||
// ist), Storno innerhalb der Frist und Kursabsage geben zurück, der Manager passt
|
||||
// ihn im Kunden-Detail an. Jede manuelle Änderung steht in PassAdjustment.
|
||||
model CustomerPass {
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid())
|
||||
productId String
|
||||
businessId String
|
||||
// null = noch ohne Konto: vom Studio an eine E-Mail vergeben, oder das Konto
|
||||
|
|
@ -457,33 +473,38 @@ model CustomerPass {
|
|||
creditsTotal Int // alle Einheiten, die die Karte je bekommen hat (inkl. Gutschriften)
|
||||
creditsRemaining Int
|
||||
validUntil DateTime? // null = unbegrenzt gültig
|
||||
status String @default("active") // "active" | "ended" (vom Studio beendet) | "refunding" (Erstattung angestoßen, Ausgang offen) | "refunded"
|
||||
status String @default("active") // "active" | "ended" (vom Studio beendet) | "refunding" (Erstattung angestoßen, Ausgang offen) | "refunded"
|
||||
source String // "purchase" | "manual" (vom Studio vergeben, ohne Zahlung)
|
||||
// Zahlung - nur bei source = "purchase"
|
||||
priceCents Int?
|
||||
stripeAccountId String?
|
||||
stripeSessionId String? @unique
|
||||
stripePaymentIntentId String? @unique
|
||||
stripeSessionId String? @unique
|
||||
stripePaymentIntentId String? @unique
|
||||
amountPaid Int? // inkl. Servicegebühr
|
||||
platformFee Int?
|
||||
stripeFee Int?
|
||||
billingAddress Json?
|
||||
invoiceNumber String? @unique
|
||||
invoiceNumber String? @unique
|
||||
invoiceIssuedAt DateTime?
|
||||
refundedAmount Int?
|
||||
refundedAt DateTime?
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: NoAction)
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
discountCodeId String? // Beim Kauf eingelöster Rabattcode
|
||||
discountAmount Int? // Gewährter Rabatt in Cents (Snapshot) - priceCents ist schon der rabattierte Preis
|
||||
discountCode DiscountCode? @relation(fields: [discountCodeId], references: [id], onDelete: SetNull)
|
||||
discountRedemption DiscountRedemption?
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: NoAction)
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
bookings CourseBooking[]
|
||||
adjustments PassAdjustment[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([businessId])
|
||||
@@index([userId])
|
||||
@@index([email])
|
||||
@@index([productId])
|
||||
@@index([discountCodeId])
|
||||
}
|
||||
|
||||
// Protokoll jeder Änderung durch das Studio: Karte vergeben, Einheiten
|
||||
|
|
@ -502,3 +523,114 @@ model PassAdjustment {
|
|||
|
||||
@@index([passId])
|
||||
}
|
||||
|
||||
// ─── Marketing ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Automatische E-Mails eines Studios an seine Kunden. Das Studio baut im
|
||||
// Node-Editor Trigger (Geburtstag, Meilenstein, Reaktivierung), die jeweils in
|
||||
// genau eine E-Mail führen. Ein täglicher Cron (/api/cron/marketing) prüft die
|
||||
// aktiven Trigger und verschickt fällige Mails - nur an Kunden, die dem Studio
|
||||
// ausdrücklich zugestimmt haben (MarketingConsent). Ablauf siehe
|
||||
// src/modules/marketing/lib/run-marketing.ts.
|
||||
|
||||
// Einwilligung eines Kunden in Werbe-Mails EINES Studios (Opt-in per Häkchen).
|
||||
// Eine Zeile pro Kunde und Studio mit dem aktuellen Stand; jede Erteilung und
|
||||
// jeder Widerruf steht zusätzlich im MarketingAuditLog (Nachweis).
|
||||
model MarketingConsent {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
businessId String
|
||||
granted Boolean
|
||||
textVersion String // Version des Wortlauts, dem zugestimmt wurde (siehe consent-text.ts)
|
||||
source String // "course_booking" | "pass_purchase" | "profile" | "unsubscribe_link"
|
||||
grantedAt DateTime? // letzte Erteilung
|
||||
revokedAt DateTime? // letzter Widerruf
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([userId, businessId])
|
||||
@@index([businessId, granted])
|
||||
}
|
||||
|
||||
// Ein Baustein im Node-Editor. Trigger zeigen über emailNodeId auf die E-Mail,
|
||||
// in die sie führen - ein Trigger hat damit höchstens eine Verbindung, eine
|
||||
// E-Mail kann mehrere Trigger haben.
|
||||
model MarketingNode {
|
||||
id String @id @default(uuid())
|
||||
businessId String
|
||||
kind String // "birthday" | "milestone" | "winback" | "email"
|
||||
// Trigger: milestone { bookings }, winback { days }, birthday {}.
|
||||
// E-Mail: { name, subject, body, reward } - Format siehe node-config.ts.
|
||||
config Json
|
||||
positionX Float
|
||||
positionY Float
|
||||
// Nur Trigger: verbundene E-Mail und Schalter. Wirksam ist ein Trigger, wenn
|
||||
// er eingeschaltet UND verbunden ist; activatedAt ist der Zeitpunkt, ab dem
|
||||
// er so läuft. Gezählt werden nur Ereignisse ab dann - sonst würde das
|
||||
// Einschalten alle Bestandskunden auf einmal anschreiben.
|
||||
emailNodeId String?
|
||||
isActive Boolean @default(false)
|
||||
activatedAt DateTime?
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||
emailNode MarketingNode? @relation("TriggerEmail", fields: [emailNodeId], references: [id], onDelete: SetNull)
|
||||
triggers MarketingNode[] @relation("TriggerEmail")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([businessId])
|
||||
@@index([emailNodeId])
|
||||
}
|
||||
|
||||
// Versandprotokoll. Der Unique-Key verhindert Doppelmails unabhängig vom
|
||||
// Editor: pro Studio, Kunde, Trigger-Art und Zeitraum (Geburtstag: Jahr,
|
||||
// Meilenstein: Schwelle, Reaktivierung: letzter Kurstag + Tage) gibt es
|
||||
// höchstens eine Mail - auch wenn der Cron doppelt läuft oder ein Node
|
||||
// gelöscht und neu angelegt wird.
|
||||
model MarketingSend {
|
||||
id String @id @default(uuid())
|
||||
businessId String
|
||||
userId String? // null = Konto inzwischen gelöscht
|
||||
triggerKind String
|
||||
periodKey String
|
||||
triggerNodeId String? // null = Node inzwischen gelöscht
|
||||
emailNodeId String?
|
||||
discountCodeId String? @unique
|
||||
status String @default("pending") // "pending" | "sent" | "failed"
|
||||
attempts Int @default(1)
|
||||
error String?
|
||||
subject String // Betreff als Momentaufnahme, für den Verlauf
|
||||
sentAt DateTime?
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
discountCode DiscountCode? @relation(fields: [discountCodeId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([businessId, userId, triggerKind, periodKey])
|
||||
@@index([businessId, createdAt])
|
||||
@@index([triggerNodeId])
|
||||
@@index([emailNodeId])
|
||||
}
|
||||
|
||||
// Änderungsprotokoll: wer hat im Editor was geändert, und wer hat wann
|
||||
// eingewilligt oder widerrufen. Bewusst ohne Fremdschlüssel auf Nodes/Kunden -
|
||||
// der Eintrag muss das Löschen überdauern.
|
||||
model MarketingAuditLog {
|
||||
id String @id @default(uuid())
|
||||
businessId String
|
||||
actorType String // "manager" | "customer" | "system"
|
||||
actorId String?
|
||||
action String // siehe AUDIT_ACTIONS in audit-log.ts
|
||||
nodeId String?
|
||||
userId String? // betroffener Kunde (Einwilligung)
|
||||
summary String // lesbare Momentaufnahme, z.B. "Meilenstein (10. Kurs) eingeschaltet"
|
||||
before Json?
|
||||
after Json?
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([businessId, createdAt])
|
||||
@@index([userId])
|
||||
}
|
||||
|
|
|
|||
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";
|
||||
import { MAX_SEATS_PER_BOOKING, paidSeatCount } from "@/src/modules/courses/lib/seat-counts";
|
||||
import { spotDisplayName, type FloorPlanItem } from "@/src/modules/rooms/lib/floor-plan";
|
||||
import { grantConsentQuietly } from "@/src/modules/marketing/lib/consent";
|
||||
|
||||
/**
|
||||
* Pro Person und Kurs läuft höchstens ein offener Checkout mit Platz. Startet
|
||||
|
|
@ -134,6 +135,8 @@ export async function POST(request: NextRequest) {
|
|||
spotIds?: string[];
|
||||
/** Alter Client vor der Mehrfachbuchung: ein einzelner Platz. */
|
||||
spotId?: string;
|
||||
/** Häkchen für Werbe-Mails des Studios (Opt-in), nur mit Konto. */
|
||||
marketingConsent?: boolean;
|
||||
};
|
||||
const { courseId, guestEmail, guestName, discountCode } = body;
|
||||
const seats = body.seats ?? 1;
|
||||
|
|
@ -532,6 +535,16 @@ export async function POST(request: NextRequest) {
|
|||
throw error;
|
||||
}
|
||||
|
||||
// Das Häkchen hat der Kunde beim Klick auf "Buchen" gesetzt - es gilt
|
||||
// unabhängig davon, ob er die Zahlung danach abschließt.
|
||||
if (!isGuest && body.marketingConsent === true) {
|
||||
await grantConsentQuietly({
|
||||
userId: userData!.id,
|
||||
businessId: course.business.id,
|
||||
source: "course_booking",
|
||||
});
|
||||
}
|
||||
|
||||
// Damit ein späterer Checkout derselben Person diese Session beenden kann
|
||||
// (siehe endOpenCheckouts). Scheitert das, bleibt der Platz trotzdem bis
|
||||
// zum Ablauf gehalten - kein Grund, die fertige Session zu verwerfen.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ import { getUserData, isUser } from "@/src/lib/auth/getUser";
|
|||
import { cardServiceFeeCents } from "@/src/modules/products/lib/card-pricing";
|
||||
import { formatValidity } from "@/src/modules/products/lib/card-format";
|
||||
import { PASS_CHECKOUT_KIND } from "@/src/modules/products/lib/pass-webhook";
|
||||
import {
|
||||
calculateDiscountedPriceCents,
|
||||
releaseDiscountReservation,
|
||||
reserveDiscountCode,
|
||||
} from "@/src/modules/settings/lib/discount-validation";
|
||||
import { grantConsentQuietly } from "@/src/modules/marketing/lib/consent";
|
||||
|
||||
/**
|
||||
* Erstellt eine Stripe Checkout Session für den Kauf einer Karte.
|
||||
|
|
@ -18,7 +24,11 @@ import { PASS_CHECKOUT_KIND } from "@/src/modules/products/lib/pass-webhook";
|
|||
* Nur für eingeloggte Kunden: eine Karte hängt an einem Konto, mit dem später
|
||||
* gebucht wird.
|
||||
*
|
||||
* Body: { productId: string }
|
||||
* Ein Rabattcode (DiscountCode.appliesTo "passes" oder "all") mindert nur den
|
||||
* Kartenpreis, nie die Servicegebühr. Er wird wie bei Kursen schon hier
|
||||
* reserviert und erst im Webhook bestätigt.
|
||||
*
|
||||
* Body: { productId: string; discountCode?: string; marketingConsent?: boolean }
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
|
|
@ -38,7 +48,11 @@ export async function POST(request: NextRequest) {
|
|||
);
|
||||
}
|
||||
|
||||
const { productId } = (await request.json()) as { productId?: string };
|
||||
const { productId, discountCode, marketingConsent } = (await request.json()) as {
|
||||
productId?: string;
|
||||
discountCode?: string;
|
||||
marketingConsent?: boolean;
|
||||
};
|
||||
if (!productId) {
|
||||
return NextResponse.json({ error: "productId fehlt" }, { status: 400 });
|
||||
}
|
||||
|
|
@ -82,6 +96,38 @@ export async function POST(request: NextRequest) {
|
|||
const feeCents = cardServiceFeeCents(product.credits, feePerUnitCents);
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
||||
|
||||
let cardPriceCents = product.priceCents;
|
||||
let discountAmountCents = 0;
|
||||
let appliedDiscountCodeId: string | null = null;
|
||||
let reservationToken: string | null = null;
|
||||
if (discountCode?.trim()) {
|
||||
const reservation = await reserveDiscountCode(prisma, {
|
||||
code: discountCode,
|
||||
businessId: business.id,
|
||||
productId: product.id,
|
||||
userId: userData.id,
|
||||
});
|
||||
if (!reservation.reserved) {
|
||||
return NextResponse.json({ error: reservation.reason }, { status: 400 });
|
||||
}
|
||||
const result = calculateDiscountedPriceCents(product.priceCents, reservation.discountCode);
|
||||
// Wie bei Kursen: eine 0-€-Karte hätte keinen PaymentIntent, der Webhook
|
||||
// könnte den Kauf nicht bestätigen.
|
||||
if (result.discountedPriceCents <= 0) {
|
||||
await releaseDiscountReservation(prisma, reservation.reservationToken);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Dieser Rabattcode reduziert den Preis auf 0 € und kann nicht verwendet werden.",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
cardPriceCents = result.discountedPriceCents;
|
||||
discountAmountCents = result.discountAmountCents;
|
||||
appliedDiscountCodeId = reservation.discountCode.id;
|
||||
reservationToken = reservation.reservationToken;
|
||||
}
|
||||
|
||||
// Alles, was der Webhook braucht, um die Karte anzulegen. Liegt an Session
|
||||
// UND PaymentIntent, damit es auch im Stripe-Dashboard an der Zahlung steht.
|
||||
const metadata = {
|
||||
|
|
@ -90,57 +136,83 @@ export async function POST(request: NextRequest) {
|
|||
userId: userData.id,
|
||||
businessId: business.id,
|
||||
platformFeeModel: "on_top",
|
||||
discountCodeId: appliedDiscountCodeId ?? "",
|
||||
discountAmountCents: String(discountAmountCents),
|
||||
discountReservationToken: reservationToken ?? "",
|
||||
};
|
||||
|
||||
const session = await createBrandedCheckoutSession(
|
||||
{
|
||||
mode: "payment",
|
||||
// Rechnungsadresse ist Pflicht - sie steht auf der Rechnung zum Kartenkauf.
|
||||
billing_address_collection: "required",
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: "eur",
|
||||
product_data: {
|
||||
name: product.name,
|
||||
description: `${business.name} - ${product.credits} ${
|
||||
product.credits === 1 ? "Einheit" : "Einheiten"
|
||||
} · ${formatValidity(product.validityMonths)}`,
|
||||
},
|
||||
unit_amount: product.priceCents,
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
// Servicegebühr pro Einheit - eine 10er-Karte kostet so viel Gebühr wie
|
||||
// zehn Einzelbuchungen. Bei 0 entfällt die Position ganz.
|
||||
...(feeCents > 0
|
||||
? [
|
||||
{
|
||||
price_data: {
|
||||
currency: "eur" as const,
|
||||
product_data: {
|
||||
name: "Servicegebühr",
|
||||
description: "Gebühr für die Online-Buchung, pro Einheit",
|
||||
},
|
||||
unit_amount: feePerUnitCents,
|
||||
},
|
||||
quantity: product.credits,
|
||||
let session;
|
||||
try {
|
||||
session = await createBrandedCheckoutSession(
|
||||
{
|
||||
mode: "payment",
|
||||
// Rechnungsadresse ist Pflicht - sie steht auf der Rechnung zum Kartenkauf.
|
||||
billing_address_collection: "required",
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: "eur",
|
||||
product_data: {
|
||||
name: product.name,
|
||||
description: `${business.name} - ${product.credits} ${
|
||||
product.credits === 1 ? "Einheit" : "Einheiten"
|
||||
} · ${formatValidity(product.validityMonths)}`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
payment_intent_data: {
|
||||
...(feeCents > 0 ? { application_fee_amount: feeCents } : {}),
|
||||
unit_amount: cardPriceCents,
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
// Servicegebühr pro Einheit - eine 10er-Karte kostet so viel Gebühr wie
|
||||
// zehn Einzelbuchungen. Bei 0 entfällt die Position ganz.
|
||||
...(feeCents > 0
|
||||
? [
|
||||
{
|
||||
price_data: {
|
||||
currency: "eur" as const,
|
||||
product_data: {
|
||||
name: "Servicegebühr",
|
||||
description: "Gebühr für die Online-Buchung, pro Einheit",
|
||||
},
|
||||
unit_amount: feePerUnitCents,
|
||||
},
|
||||
quantity: product.credits,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
payment_intent_data: {
|
||||
...(feeCents > 0 ? { application_fee_amount: feeCents } : {}),
|
||||
metadata,
|
||||
},
|
||||
metadata,
|
||||
customer_email: userData.email,
|
||||
success_url: `${baseUrl}/courses/myCards?purchased=1`,
|
||||
cancel_url: business.slug ? `${baseUrl}/business/${business.slug}` : baseUrl,
|
||||
},
|
||||
metadata,
|
||||
customer_email: userData.email,
|
||||
success_url: `${baseUrl}/courses/myCards?purchased=1`,
|
||||
cancel_url: business.slug ? `${baseUrl}/business/${business.slug}` : baseUrl,
|
||||
},
|
||||
studioCheckoutBranding(business),
|
||||
business.stripeAccountId
|
||||
);
|
||||
studioCheckoutBranding(business),
|
||||
business.stripeAccountId
|
||||
);
|
||||
} catch (error) {
|
||||
// Nie angelegte Session meldet auch kein checkout.session.expired - die
|
||||
// Reservierung muss hier zurück.
|
||||
if (reservationToken) {
|
||||
await releaseDiscountReservation(prisma, reservationToken).catch((releaseError) =>
|
||||
console.error(
|
||||
`Rabattcode-Reservierung ${reservationToken} nach fehlgeschlagenem Kartenkauf nicht freigegeben:`,
|
||||
releaseError
|
||||
)
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (marketingConsent === true) {
|
||||
await grantConsentQuietly({
|
||||
userId: userData.id,
|
||||
businessId: business.id,
|
||||
source: "pass_purchase",
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ url: session.url });
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
import { MAX_SEATS_PER_BOOKING, paidSeatCount } from "@/src/modules/courses/lib/seat-counts";
|
||||
import {
|
||||
handlePassCheckoutCompleted,
|
||||
handlePassCheckoutUnpaid,
|
||||
isPassCheckout,
|
||||
} from "@/src/modules/products/lib/pass-webhook";
|
||||
import { reconcilePassRefund } from "@/src/modules/products/lib/pass-refund";
|
||||
|
|
@ -108,9 +109,11 @@ export async function POST(request: NextRequest) {
|
|||
}
|
||||
case "checkout.session.expired":
|
||||
case "checkout.session.async_payment_failed": {
|
||||
// Ein Kartenkauf reserviert nichts (keine Plätze, kein Rabattcode) - nie
|
||||
// bezahlt heißt einfach: keine Karte.
|
||||
if (!isPassCheckout(event.data.object)) {
|
||||
// Ein Kartenkauf hält keine Plätze - nie bezahlt heißt: keine Karte,
|
||||
// nur ein reservierter Rabattcode wird wieder frei.
|
||||
if (isPassCheckout(event.data.object)) {
|
||||
await handlePassCheckoutUnpaid(event.data.object);
|
||||
} else {
|
||||
await handleCheckoutUnpaid(event.data.object);
|
||||
}
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ export function getResend(): Resend {
|
|||
* @param subject - E-Mail Betreff
|
||||
* @param html - HTML-Inhalt der E-Mail
|
||||
* @param text - Plain-Text Alternative (optional)
|
||||
* @param fromName - Anzeigename statt dem aus RESEND_FROM, die Adresse bleibt (optional)
|
||||
* @param replyTo - Antwortadresse, z.B. die des Studios (optional)
|
||||
* @param headers - Zusätzliche Header wie List-Unsubscribe (optional)
|
||||
* @param idempotencyKey - Resend verschickt dieselbe Mail innerhalb von 24 h nur einmal (optional)
|
||||
* @returns Promise mit dem Ergebnis des Versands
|
||||
*/
|
||||
export async function sendMail({
|
||||
|
|
@ -33,20 +37,34 @@ export async function sendMail({
|
|||
subject,
|
||||
html,
|
||||
text,
|
||||
fromName,
|
||||
replyTo,
|
||||
headers,
|
||||
idempotencyKey,
|
||||
}: {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
text?: string;
|
||||
fromName?: string;
|
||||
replyTo?: string;
|
||||
headers?: Record<string, string>;
|
||||
idempotencyKey?: string;
|
||||
}) {
|
||||
try {
|
||||
const { data, error } = await getResend().emails.send({
|
||||
from: process.env.RESEND_FROM || "",
|
||||
const defaultFrom = process.env.RESEND_FROM || "";
|
||||
const payload = {
|
||||
from: fromName ? withDisplayName(defaultFrom, fromName) : defaultFrom,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text: text || stripHtml(html), // Fallback: HTML zu Text konvertieren
|
||||
});
|
||||
...(replyTo ? { replyTo } : {}),
|
||||
...(headers ? { headers } : {}),
|
||||
};
|
||||
const { data, error } = idempotencyKey
|
||||
? await getResend().emails.send(payload, { idempotencyKey })
|
||||
: await getResend().emails.send(payload);
|
||||
|
||||
if (error) {
|
||||
console.error("Fehler beim E-Mail-Versand:", error);
|
||||
|
|
@ -61,6 +79,17 @@ export async function sendMail({
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt einen anderen Anzeigenamen vor die Absenderadresse aus RESEND_FROM
|
||||
* ("Kumasy <noreply@…>" → "Studio X über Kumasy <noreply@…>"). Zeichen, die
|
||||
* den Header aufbrechen könnten, fliegen raus.
|
||||
*/
|
||||
export function withDisplayName(from: string, displayName: string): string {
|
||||
const address = from.match(/<([^>]+)>/)?.[1] ?? from.trim();
|
||||
const safeName = displayName.replace(/[\r\n"<>\\]/g, "").trim();
|
||||
return safeName ? `"${safeName}" <${address}>` : address;
|
||||
}
|
||||
|
||||
/**
|
||||
* Einfache Funktion zum Entfernen von HTML-Tags (für Text-Alternative)
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@ export interface RenderEmailLayoutParams {
|
|||
/** Fertiger Inner-Content, wird unverändert in die Content-Zelle eingesetzt. */
|
||||
bodyHtml: string;
|
||||
branding?: EmailBranding;
|
||||
/**
|
||||
* Ersetzt den Hinweis "automatisch generiert, bitte nicht antworten" im
|
||||
* Footer - z.B. bei Werbe-Mails, auf die Kunden antworten dürfen und die
|
||||
* einen Abmeldelink brauchen. Vom Aufrufer bereits escaped.
|
||||
*/
|
||||
footerNoteHtml?: string;
|
||||
}
|
||||
|
||||
export function renderEmailLayout({
|
||||
|
|
@ -25,6 +31,7 @@ export function renderEmailLayout({
|
|||
bannerHtml,
|
||||
bodyHtml,
|
||||
branding = KUMASY_BRANDING,
|
||||
footerNoteHtml,
|
||||
}: RenderEmailLayoutParams): string {
|
||||
const safeTitle = escapeHtml(title);
|
||||
const safeCompanyName = escapeHtml(branding.companyName);
|
||||
|
|
@ -75,7 +82,7 @@ export function renderEmailLayout({
|
|||
${safeCompanyName}
|
||||
</p>
|
||||
<p style="margin: 0 0 10px; color: ${EMAIL_TOKENS.colors.textMuted}; font-size: 12px;">
|
||||
Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht auf diese E-Mail.
|
||||
${footerNoteHtml ?? "Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht auf diese E-Mail."}
|
||||
</p>
|
||||
<p style="margin: 0; color: ${EMAIL_TOKENS.colors.textMuted}; font-size: 11px;">
|
||||
Powered by Kumasy
|
||||
|
|
|
|||
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,
|
||||
type CustomerPassView,
|
||||
} from "@/src/modules/products/lib/pass-queries";
|
||||
import { hasMarketingConsent } from "@/src/modules/marketing/lib/consent";
|
||||
import { consentLabel } from "@/src/modules/marketing/lib/consent-text";
|
||||
|
||||
export type UserBooking = {
|
||||
bookingId: string;
|
||||
|
|
@ -266,7 +268,14 @@ export async function checkUserBookingStatus(courseId: string) {
|
|||
|
||||
// Alle Checks im selben Aufruf statt separater Wartelisten-/Karten-Calls,
|
||||
// damit der Dialog weiterhin nur einen Roundtrip beim Öffnen braucht.
|
||||
const [booking, waitlistEntry, passes] = await Promise.all([
|
||||
const customerCourse = isUser(userData)
|
||||
? await prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: { businessId: true, sport: true, business: { select: { name: true } } },
|
||||
})
|
||||
: null;
|
||||
|
||||
const [booking, waitlistEntry, passes, consentGranted] = await Promise.all([
|
||||
prisma.courseBooking.findUnique({
|
||||
where: { courseId_userId: { courseId, userId: userData.id } },
|
||||
}),
|
||||
|
|
@ -274,11 +283,12 @@ export async function checkUserBookingStatus(courseId: string) {
|
|||
where: { courseId_userId: { courseId, userId: userData.id } },
|
||||
}),
|
||||
// Karten, mit denen der Kunde diesen Kurs ohne Bezahlen buchen kann.
|
||||
isUser(userData)
|
||||
? prisma.course
|
||||
.findUnique({ where: { id: courseId }, select: { businessId: true, sport: true } })
|
||||
.then((course) => (course ? findUsablePassesForCourse(userData, course) : []))
|
||||
customerCourse && isUser(userData)
|
||||
? findUsablePassesForCourse(userData, customerCourse)
|
||||
: Promise.resolve([] as CustomerPassView[]),
|
||||
customerCourse?.businessId
|
||||
? hasMarketingConsent(userData.id, customerCourse.businessId)
|
||||
: Promise.resolve(true),
|
||||
]);
|
||||
|
||||
return {
|
||||
|
|
@ -290,6 +300,12 @@ export async function checkUserBookingStatus(courseId: string) {
|
|||
bookedSeats: booking?.paymentStatus === "paid" ? booking.seats : 0,
|
||||
isOnWaitlist: waitlistEntry !== null,
|
||||
passes,
|
||||
// Häkchen für Werbe-Mails des Studios - nur solange der Kunde noch nicht
|
||||
// zugestimmt hat (Opt-in, standardmäßig leer).
|
||||
marketingConsentLabel:
|
||||
customerCourse?.business && !consentGranted
|
||||
? consentLabel(customerCourse.business.name)
|
||||
: null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error checking booking status:", error);
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import { spotDisplayName } from "@/src/modules/rooms/lib/floor-plan";
|
|||
import { MAX_SEATS_PER_BOOKING } from "../../lib/seat-counts";
|
||||
import { bookCourseWithPass } from "@/src/modules/products/actions/pass-booking-actions";
|
||||
import type { CustomerPassView } from "@/src/modules/products/lib/pass-queries";
|
||||
import MarketingConsentCheckbox from "@/src/modules/marketing/ui/components/MarketingConsentCheckbox";
|
||||
|
||||
// useSyncExternalStore: liefert `false` auf dem Server (server snapshot) und
|
||||
// `true` im Browser. Damit haben Server- und initialer Client-Render denselben
|
||||
|
|
@ -215,6 +216,10 @@ export const BookingDialog = ({
|
|||
const [passes, setPasses] = useState<CustomerPassView[]>([]);
|
||||
const [selectedPassId, setSelectedPassId] = useState<string | null>(null);
|
||||
const [highlightPicker, setHighlightPicker] = useState(false);
|
||||
// Werbe-Mails des Studios: Text des Häkchens, solange der Kunde noch nicht
|
||||
// zugestimmt hat (null = schon zugestimmt oder kein Kundenkonto).
|
||||
const [consentText, setConsentText] = useState<string | null>(null);
|
||||
const [consentChecked, setConsentChecked] = useState(false);
|
||||
const spotPickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isAdminView = paymentStatus !== undefined;
|
||||
|
|
@ -249,6 +254,9 @@ export const BookingDialog = ({
|
|||
const usablePasses = "passes" in result ? (result.passes ?? []) : [];
|
||||
setPasses(usablePasses);
|
||||
setSelectedPassId(usablePasses[0]?.id ?? null);
|
||||
setConsentText(
|
||||
"marketingConsentLabel" in result ? (result.marketingConsentLabel ?? null) : null
|
||||
);
|
||||
// Gäste sehen das Gast-Formular sofort, statt erst nach einem
|
||||
// gescheiterten Buchungsversuch.
|
||||
if (result.isLoggedIn === false) setShowGuestForm(true);
|
||||
|
|
@ -424,6 +432,7 @@ export const BookingDialog = ({
|
|||
passId,
|
||||
seats,
|
||||
spotIds: spotMode ? selectedSpots.map((s) => s.id) : undefined,
|
||||
marketingConsent: consentText !== null && consentChecked,
|
||||
});
|
||||
if (!result.success) {
|
||||
toast.error(result.error);
|
||||
|
|
@ -456,8 +465,12 @@ export const BookingDialog = ({
|
|||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const body: Record<string, string | number | string[]> = { courseId: course.id, seats };
|
||||
const body: Record<string, string | number | string[] | boolean> = {
|
||||
courseId: course.id,
|
||||
seats,
|
||||
};
|
||||
if (appliedDiscount) body.discountCode = appliedDiscount.code;
|
||||
if (!opts?.asGuest && consentText !== null && consentChecked) body.marketingConsent = true;
|
||||
if (spotMode) body.spotIds = selectedSpots.map((s) => s.id);
|
||||
if (opts?.asGuest) {
|
||||
const normalizedEmail = guestEmail.trim().toLowerCase();
|
||||
|
|
@ -864,6 +877,18 @@ export const BookingDialog = ({
|
|||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!isAdminView &&
|
||||
!isCheckingStatus &&
|
||||
!isFull &&
|
||||
!isBooked &&
|
||||
!showGuestForm &&
|
||||
consentText !== null && (
|
||||
<MarketingConsentCheckbox
|
||||
label={consentText}
|
||||
checked={consentChecked}
|
||||
onCheckedChange={setConsentChecked}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="flex flex-col">
|
||||
{payWithPass && !isAdminView && !isBooked ? (
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
Dumbbell,
|
||||
IdCardLanyard,
|
||||
LayoutDashboard,
|
||||
Megaphone,
|
||||
User,
|
||||
WalletCards,
|
||||
} from "lucide-react";
|
||||
|
|
@ -94,6 +95,12 @@ const allMenuItems: MenuItem[] = [
|
|||
href: "/zahlungen",
|
||||
roles: ["manager"],
|
||||
},
|
||||
{
|
||||
icon: Megaphone,
|
||||
label: "Marketing",
|
||||
href: "/marketing",
|
||||
roles: ["manager"],
|
||||
},
|
||||
{
|
||||
icon: DoorOpen,
|
||||
label: "Räume",
|
||||
|
|
|
|||
|
|
@ -89,6 +89,16 @@ export type KundeDetail = {
|
|||
createdAt: Date | null;
|
||||
bookings: KundeBooking[];
|
||||
passes: KundePass[];
|
||||
/** Einwilligung in Werbe-Mails, pro Studio des Managers mit einem Eintrag. */
|
||||
marketingConsents: KundeMarketingConsent[];
|
||||
};
|
||||
|
||||
export type KundeMarketingConsent = {
|
||||
studioName: string;
|
||||
granted: boolean;
|
||||
/** Zeitpunkt der letzten Erteilung bzw. des letzten Widerrufs */
|
||||
changedAt: string | null;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export async function getKundeDetail(
|
||||
|
|
@ -151,6 +161,16 @@ export async function getKundeDetail(
|
|||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
},
|
||||
marketingConsents: {
|
||||
where: { businessId: { in: businessIds } },
|
||||
select: {
|
||||
granted: true,
|
||||
grantedAt: true,
|
||||
revokedAt: true,
|
||||
source: true,
|
||||
business: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
passes: {
|
||||
where: { businessId: { in: businessIds } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
|
|
@ -289,6 +309,13 @@ export async function getKundeDetail(
|
|||
pbSrc: userWithBookings.pbSrc,
|
||||
createdAt: userWithBookings.createdAt,
|
||||
bookings,
|
||||
marketingConsents: userWithBookings.marketingConsents.map((consent) => ({
|
||||
studioName: consent.business.name,
|
||||
granted: consent.granted,
|
||||
changedAt:
|
||||
(consent.granted ? consent.grantedAt : consent.revokedAt)?.toISOString() ?? null,
|
||||
source: consent.source,
|
||||
})),
|
||||
passes: userWithBookings.passes.map((pass) => {
|
||||
const branding = businessBrandingSchema.safeParse(pass.business.branding);
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -16,8 +16,9 @@ import {
|
|||
Bell,
|
||||
Scale,
|
||||
CheckCircle2,
|
||||
Megaphone,
|
||||
} from "lucide-react";
|
||||
import type { KundeDetail } from "../../actions/kunde-detail-actions";
|
||||
import type { KundeDetail, KundeMarketingConsent } from "../../actions/kunde-detail-actions";
|
||||
import KundeBookingCard from "../components/kunde-booking-card";
|
||||
import KundePassesSection from "@/src/modules/products/ui/components/KundePassesSection";
|
||||
import type { PassIssueOption } from "@/src/modules/products/actions/pass-admin-actions";
|
||||
|
|
@ -27,6 +28,28 @@ import type { PassIssueOption } from "@/src/modules/products/actions/pass-admin-
|
|||
const getDiceBearUrl = (seed: string) =>
|
||||
`https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent(`u-${seed.slice(0, 6)}`)}`;
|
||||
|
||||
// ─── Einwilligung in Werbe-Mails ─────────────────────────────────────────────
|
||||
// Ohne Eintrag hat der Kunde nie zugestimmt. Bei mehreren Studios steht der
|
||||
// Name davor.
|
||||
const formatMarketingConsent = (consents: KundeMarketingConsent[]) => {
|
||||
if (consents.length === 0) return "Nicht zugestimmt";
|
||||
return consents
|
||||
.map((consent) => {
|
||||
const date = consent.changedAt
|
||||
? new Date(consent.changedAt).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
})
|
||||
: null;
|
||||
const state = consent.granted
|
||||
? `Zugestimmt${date ? ` am ${date}` : ""}`
|
||||
: `Abgemeldet${date ? ` am ${date}` : ""}`;
|
||||
return consents.length > 1 ? `${consent.studioName}: ${state}` : state;
|
||||
})
|
||||
.join(" · ");
|
||||
};
|
||||
|
||||
// ─── ID kürzen: erste 8 Zeichen + … ──────────────────────────────────────────
|
||||
const shortenId = (id: string) => `${id.slice(0, 8)}…${id.slice(-4)}`;
|
||||
|
||||
|
|
@ -162,6 +185,11 @@ const KundeDetailView = ({ kunde, issueOptions }: KundeDetailViewProps) => {
|
|||
value={kunde.gender ? (genderLabel[kunde.gender] ?? kunde.gender) : "–"}
|
||||
/>
|
||||
<InfoRow icon={<Calendar size={15} />} label="Geburtsdatum" value={birthdayFormatted} />
|
||||
<InfoRow
|
||||
icon={<Megaphone size={15} />}
|
||||
label="Werbe-Mails"
|
||||
value={formatMarketingConsent(kunde.marketingConsents)}
|
||||
/>
|
||||
{/* Kunden-ID über volle Breite */}
|
||||
<div className="sm:col-span-2 flex items-start gap-2.5">
|
||||
<span className="mt-0.5 text-blue shrink-0">
|
||||
|
|
|
|||
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 { spotDisplayName } from "@/src/modules/rooms/lib/floor-plan";
|
||||
import { canRedeemPass, passCoversCourse } from "../lib/pass-rules";
|
||||
import { grantConsentQuietly } from "@/src/modules/marketing/lib/consent";
|
||||
|
||||
export type BookWithPassResult =
|
||||
| { success: true; seats: number; creditsRemaining: number }
|
||||
|
|
@ -55,6 +56,8 @@ export async function bookCourseWithPass(input: {
|
|||
passId: string;
|
||||
seats: number;
|
||||
spotIds?: string[];
|
||||
/** Häkchen für Werbe-Mails des Studios (Opt-in) */
|
||||
marketingConsent?: boolean;
|
||||
}): Promise<BookWithPassResult> {
|
||||
const userData = await getUserData();
|
||||
if (!userData || !isUser(userData)) {
|
||||
|
|
@ -242,6 +245,14 @@ export async function bookCourseWithPass(input: {
|
|||
.deleteMany({ where: { courseId, userId: userData.id } })
|
||||
.catch((err) => console.error("Warteliste-Aufräumen fehlgeschlagen:", err));
|
||||
|
||||
if (input.marketingConsent && course.businessId) {
|
||||
await grantConsentQuietly({
|
||||
userId: userData.id,
|
||||
businessId: course.businessId,
|
||||
source: "course_booking",
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
|
||||
const mail = generateBookingConfirmationEmail({
|
||||
courseName: course.name,
|
||||
|
|
|
|||
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 { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
|
||||
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
|
||||
import {
|
||||
confirmDiscountReservation,
|
||||
releaseDiscountReservation,
|
||||
} from "@/src/modules/settings/lib/discount-validation";
|
||||
import { validUntilFrom } from "./pass-rules";
|
||||
|
||||
/** Markiert Checkout-Sessions für einen Kartenkauf (siehe api/stripe/checkout/pass). */
|
||||
|
|
@ -25,6 +29,22 @@ async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | null
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Kartenkauf, der nie bezahlt wurde (Session abgelaufen, SEPA gescheitert):
|
||||
* Eine Karte gibt es nicht - nur ein reservierter Rabattcode muss wieder frei
|
||||
* werden.
|
||||
*/
|
||||
export async function handlePassCheckoutUnpaid(session: Stripe.Checkout.Session): Promise<void> {
|
||||
const token = session.metadata?.discountReservationToken;
|
||||
if (!token) return;
|
||||
await releaseDiscountReservation(prisma, token).catch((error) =>
|
||||
console.error(
|
||||
`Rabattcode-Reservierung ${token} nach nicht bezahltem Kartenkauf nicht freigegeben:`,
|
||||
error
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt die gekaufte Karte an, sobald Stripe die Zahlung meldet - analog zur
|
||||
* Kursbuchung nie auf der Success-Page. Idempotent über stripeSessionId: eine
|
||||
|
|
@ -112,29 +132,46 @@ export async function handlePassCheckoutCompleted(
|
|||
const now = new Date();
|
||||
const validUntil = validUntilFrom(now, product.validityMonths);
|
||||
|
||||
// Rabatt als Momentaufnahme vom Checkout-Start (wie bei Kursbuchungen). Die
|
||||
// Reservierung des Codes wird mit der neuen Karte bestätigt.
|
||||
const discountCodeId = session.metadata?.discountCodeId || null;
|
||||
const discountAmountCents = Number(session.metadata?.discountAmountCents ?? "0") || 0;
|
||||
const discountReservationToken = session.metadata?.discountReservationToken || null;
|
||||
|
||||
let passId: string;
|
||||
try {
|
||||
const pass = await prisma.customerPass.create({
|
||||
data: {
|
||||
productId: product.id,
|
||||
businessId: product.businessId,
|
||||
userId: user.id,
|
||||
email: user.email.toLowerCase(),
|
||||
name: product.name,
|
||||
sports: product.sports,
|
||||
creditsTotal: product.credits,
|
||||
creditsRemaining: product.credits,
|
||||
validUntil,
|
||||
source: "purchase",
|
||||
priceCents: amountPaidCents - platformFeeCents,
|
||||
stripeAccountId,
|
||||
stripeSessionId: session.id,
|
||||
stripePaymentIntentId: paymentIntentId,
|
||||
amountPaid: amountPaidCents,
|
||||
platformFee: platformFeeCents,
|
||||
...(billingAddress ? { billingAddress } : {}),
|
||||
},
|
||||
select: { id: true },
|
||||
const pass = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.customerPass.create({
|
||||
data: {
|
||||
productId: product.id,
|
||||
businessId: product.businessId,
|
||||
userId: user.id,
|
||||
email: user.email.toLowerCase(),
|
||||
name: product.name,
|
||||
sports: product.sports,
|
||||
creditsTotal: product.credits,
|
||||
creditsRemaining: product.credits,
|
||||
validUntil,
|
||||
source: "purchase",
|
||||
priceCents: amountPaidCents - platformFeeCents,
|
||||
stripeAccountId,
|
||||
stripeSessionId: session.id,
|
||||
stripePaymentIntentId: paymentIntentId,
|
||||
amountPaid: amountPaidCents,
|
||||
platformFee: platformFeeCents,
|
||||
discountCodeId,
|
||||
discountAmount: discountCodeId ? discountAmountCents : null,
|
||||
...(billingAddress ? { billingAddress } : {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (discountReservationToken) {
|
||||
await confirmDiscountReservation(tx, {
|
||||
reservationToken: discountReservationToken,
|
||||
passId: created.id,
|
||||
});
|
||||
}
|
||||
return created;
|
||||
});
|
||||
passId = pass.id;
|
||||
} catch (error) {
|
||||
|
|
|
|||
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 { formatSports, formatValidity } from "../../lib/card-format";
|
||||
import type { CardDesign } from "../../lib/card-design";
|
||||
import { getPassPurchaseContext } from "../../actions/pass-purchase-actions";
|
||||
import PassCard from "./PassCard";
|
||||
import PassPurchaseDialog from "./PassPurchaseDialog";
|
||||
|
||||
export type PublicProduct = {
|
||||
id: string;
|
||||
|
|
@ -34,7 +36,8 @@ interface PublicPassShopProps {
|
|||
/**
|
||||
* Karten zum Kaufen auf der öffentlichen Studio-Seite. Der Preis steht wie bei
|
||||
* den Kursen als Endpreis inklusive Servicegebühr da. Kaufen geht nur mit
|
||||
* Konto - ohne Anmeldung geht es zum Login und danach zurück hierher.
|
||||
* Konto - ohne Anmeldung geht es zum Login und danach zurück hierher. Mit Konto
|
||||
* öffnet sich vor dem Checkout ein Dialog für Rabattcode und Werbe-Häkchen.
|
||||
*/
|
||||
const PublicPassShop = ({
|
||||
products,
|
||||
|
|
@ -47,14 +50,49 @@ const PublicPassShop = ({
|
|||
}: PublicPassShopProps) => {
|
||||
const router = useRouter();
|
||||
const [buyingId, setBuyingId] = useState<string | null>(null);
|
||||
const [purchase, setPurchase] = useState<{
|
||||
product: PublicProduct;
|
||||
consentLabel: string | null;
|
||||
} | null>(null);
|
||||
|
||||
const buy = async (productId: string) => {
|
||||
setBuyingId(productId);
|
||||
const goToLogin = () => {
|
||||
toast.info("Bitte melden Sie sich an, um eine Karte zu kaufen.");
|
||||
router.push(`/login?returnTo=${encodeURIComponent(`/business/${studioSlug}`)}`);
|
||||
};
|
||||
|
||||
const openPurchase = async (product: PublicProduct) => {
|
||||
setBuyingId(product.id);
|
||||
try {
|
||||
const context = await getPassPurchaseContext(product.id);
|
||||
if (!context.loggedIn) {
|
||||
goToLogin();
|
||||
return;
|
||||
}
|
||||
if (!context.isCustomer) {
|
||||
toast.error("Nur Kunden-Accounts können Karten kaufen.");
|
||||
return;
|
||||
}
|
||||
setPurchase({ product, consentLabel: context.marketingConsentLabel });
|
||||
} catch {
|
||||
toast.error("Ein unerwarteter Fehler ist aufgetreten.");
|
||||
} finally {
|
||||
setBuyingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const checkout = async (
|
||||
productId: string,
|
||||
options: { discountCode: string | null; marketingConsent: boolean }
|
||||
) => {
|
||||
try {
|
||||
const response = await fetch("/api/stripe/checkout/pass", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ productId }),
|
||||
body: JSON.stringify({
|
||||
productId,
|
||||
...(options.discountCode ? { discountCode: options.discountCode } : {}),
|
||||
...(options.marketingConsent ? { marketingConsent: true } : {}),
|
||||
}),
|
||||
});
|
||||
const data = (await response.json().catch(() => ({}))) as {
|
||||
url?: string;
|
||||
|
|
@ -63,19 +101,16 @@ const PublicPassShop = ({
|
|||
};
|
||||
|
||||
if (data.loginRequired) {
|
||||
toast.info("Bitte melden Sie sich an, um eine Karte zu kaufen.");
|
||||
router.push(`/login?returnTo=${encodeURIComponent(`/business/${studioSlug}`)}`);
|
||||
goToLogin();
|
||||
return;
|
||||
}
|
||||
if (!response.ok || !data.url) {
|
||||
toast.error(data.error ?? "Der Kauf konnte nicht gestartet werden.");
|
||||
setBuyingId(null);
|
||||
return;
|
||||
}
|
||||
window.location.assign(data.url);
|
||||
} catch {
|
||||
toast.error("Ein unerwarteter Fehler ist aufgetreten.");
|
||||
setBuyingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -126,18 +161,31 @@ const PublicPassShop = ({
|
|||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void buy(product.id)}
|
||||
onClick={() => void openPurchase(product)}
|
||||
disabled={buyingId !== null}
|
||||
className="mt-auto rounded-full px-5 py-2.5 text-sm font-semibold shadow-sm transition-[scale,opacity] duration-150 active:scale-95 disabled:opacity-60"
|
||||
style={{ backgroundColor: accentColor, color: accentTextColor }}
|
||||
>
|
||||
{buyingId === product.id ? "Wird geöffnet…" : "Kaufen"}
|
||||
{buyingId === product.id ? "Einen Moment…" : "Kaufen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{purchase && (
|
||||
<PassPurchaseDialog
|
||||
key={purchase.product.id}
|
||||
product={purchase.product}
|
||||
feeCents={cardServiceFeeCents(purchase.product.credits, feePerUnitCents)}
|
||||
consentLabel={purchase.consentLabel}
|
||||
accentColor={accentColor}
|
||||
accentTextColor={accentTextColor}
|
||||
onClose={() => setPurchase(null)}
|
||||
onCheckout={(options) => checkout(purchase.product.id, options)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export type DiscountCodeDTO = {
|
|||
code: string;
|
||||
type: "percent" | "fixed";
|
||||
value: number;
|
||||
appliesTo: "courses" | "passes" | "all";
|
||||
scope: "all_courses" | "specific_courses";
|
||||
courseIds: string[];
|
||||
maxRedemptions: number | null;
|
||||
|
|
@ -40,8 +41,10 @@ export async function getDiscountCodes(
|
|||
return { success: false, error: "Keine Berechtigung für dieses Business." };
|
||||
}
|
||||
|
||||
// Persönliche Codes aus Marketing-Mails stehen im Verlauf des Marketings,
|
||||
// nicht hier - sonst liefe die Liste mit Einmalcodes voll.
|
||||
const codes = await prisma.discountCode.findMany({
|
||||
where: { businessId },
|
||||
where: { businessId, origin: "manual" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
|
|
@ -100,6 +103,7 @@ export async function createDiscountCode(
|
|||
code: normalizedCode,
|
||||
type: parsed.data.type,
|
||||
value: parsed.data.value,
|
||||
appliesTo: parsed.data.appliesTo,
|
||||
scope: parsed.data.scope,
|
||||
courseIds: parsed.data.scope === "specific_courses" ? courseIds : [],
|
||||
maxRedemptions: parsed.data.maxRedemptions,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ type PrismaOrTx =
|
|||
| typeof prismaClient
|
||||
| Parameters<Parameters<typeof prismaClient.$transaction>[0]>[0];
|
||||
|
||||
/**
|
||||
* Wofür ein Code eingelöst wird: eine Kursbuchung (courseId) oder ein
|
||||
* Kartenkauf (productId). DiscountCode.appliesTo legt fest, was erlaubt ist.
|
||||
*/
|
||||
export type DiscountTarget = { courseId: string } | { productId: string };
|
||||
|
||||
export type DiscountValidationResult =
|
||||
| { valid: true; discountCode: { id: string; type: string; value: number } }
|
||||
| { valid: false; reason: string };
|
||||
|
|
@ -19,7 +25,7 @@ export type DiscountValidationResult =
|
|||
// reserveDiscountCode() gleich danach, nicht hier.
|
||||
export async function validateDiscountCode(
|
||||
db: PrismaOrTx,
|
||||
params: { code: string; businessId: string; courseId: string; userId: string | null }
|
||||
params: { code: string; businessId: string; userId: string | null } & DiscountTarget
|
||||
): Promise<DiscountValidationResult> {
|
||||
const normalizedCode = params.code.trim().toUpperCase();
|
||||
if (!normalizedCode) {
|
||||
|
|
@ -42,11 +48,20 @@ export async function validateDiscountCode(
|
|||
return { valid: false, reason: "Dieser Rabattcode ist abgelaufen." };
|
||||
}
|
||||
|
||||
if (
|
||||
discountCode.scope === "specific_courses" &&
|
||||
!discountCode.courseIds.includes(params.courseId)
|
||||
) {
|
||||
return { valid: false, reason: "Dieser Rabattcode gilt nicht für diesen Kurs." };
|
||||
if ("productId" in params) {
|
||||
if (discountCode.appliesTo !== "passes" && discountCode.appliesTo !== "all") {
|
||||
return { valid: false, reason: "Dieser Rabattcode gilt nicht für Karten." };
|
||||
}
|
||||
} else {
|
||||
if (discountCode.appliesTo === "passes") {
|
||||
return { valid: false, reason: "Dieser Rabattcode gilt nur für Karten." };
|
||||
}
|
||||
if (
|
||||
discountCode.scope === "specific_courses" &&
|
||||
!discountCode.courseIds.includes(params.courseId)
|
||||
) {
|
||||
return { valid: false, reason: "Dieser Rabattcode gilt nicht für diesen Kurs." };
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
|
|
@ -64,6 +79,12 @@ export async function validateDiscountCode(
|
|||
return { valid: false, reason: "Rabattcodes sind nur für angemeldete Kunden verfügbar." };
|
||||
}
|
||||
|
||||
// Persönliche Codes (z.B. aus einer Geburtstagsmail) gelten nur für das
|
||||
// Konto, für das sie erzeugt wurden.
|
||||
if (discountCode.restrictedToUserId && discountCode.restrictedToUserId !== params.userId) {
|
||||
return { valid: false, reason: "Dieser Rabattcode gehört zu einem anderen Kundenkonto." };
|
||||
}
|
||||
|
||||
const existingRedemption = await db.discountRedemption.findUnique({
|
||||
where: { discountCodeId_userId: { discountCodeId: discountCode.id, userId: params.userId } },
|
||||
});
|
||||
|
|
@ -126,7 +147,7 @@ export type ReserveDiscountResult =
|
|||
// "bereits reserviert" abfängt, statt ihn nach oben durchzureichen.
|
||||
export async function reserveDiscountCode(
|
||||
db: PrismaOrTx,
|
||||
params: { code: string; businessId: string; courseId: string; userId: string }
|
||||
params: { code: string; businessId: string; userId: string } & DiscountTarget
|
||||
): Promise<ReserveDiscountResult> {
|
||||
const validation = await validateDiscountCode(db, params);
|
||||
if (!validation.valid) {
|
||||
|
|
@ -230,13 +251,17 @@ export async function releaseDiscountReservation(
|
|||
// tut diese Funktion nichts: die Buchung wurde vom Aufrufer bereits
|
||||
// unabhängig davon zum schon belasteten (rabattierten) Preis bestätigt, nur
|
||||
// ohne verbuchte Redemption. Das ist bewusstes Verhalten, kein Fehlerfall.
|
||||
// Beim Kartenkauf gilt dasselbe, nur mit der neuen Karte statt einer Buchung.
|
||||
export async function confirmDiscountReservation(
|
||||
tx: PrismaOrTx,
|
||||
params: { reservationToken: string; bookingId: string }
|
||||
params: { reservationToken: string } & ({ bookingId: string } | { passId: string })
|
||||
): Promise<boolean> {
|
||||
const result = await tx.discountRedemption.updateMany({
|
||||
where: { stripeSessionId: params.reservationToken, status: "reserved" },
|
||||
data: { status: "confirmed", bookingId: params.bookingId },
|
||||
data: {
|
||||
status: "confirmed",
|
||||
...("passId" in params ? { passId: params.passId } : { bookingId: params.bookingId }),
|
||||
},
|
||||
});
|
||||
return result.count > 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export const discountCodeSchema = z
|
|||
.regex(/^[A-Z0-9_-]+$/, "Nur Großbuchstaben, Zahlen, - und _ erlaubt"),
|
||||
type: z.enum(["percent", "fixed"]),
|
||||
value: z.number().int().positive(),
|
||||
// Kurse, Kartenkauf oder beides. Die Kursauswahl (scope) wirkt nur auf Kurse.
|
||||
appliesTo: z.enum(["courses", "passes", "all"]).default("courses"),
|
||||
scope: z.enum(["all_courses", "specific_courses"]),
|
||||
courseIds: z.array(z.string()).default([]),
|
||||
maxRedemptions: z.number().int().positive().nullable(),
|
||||
|
|
@ -28,6 +30,13 @@ export const discountCodeSchema = z
|
|||
message: "Prozentwert darf maximal 100 sein",
|
||||
});
|
||||
}
|
||||
if (data.appliesTo === "passes" && data.scope === "specific_courses") {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["scope"],
|
||||
message: "Eine Kursauswahl gibt es nur für Codes, die auch für Kurse gelten",
|
||||
});
|
||||
}
|
||||
if (data.scope === "specific_courses" && data.courseIds.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ interface DiscountCodeListProps {
|
|||
code: string;
|
||||
type: "percent" | "fixed";
|
||||
value: number;
|
||||
appliesTo: "courses" | "passes" | "all";
|
||||
scope: "all_courses" | "specific_courses";
|
||||
courseIds: string[];
|
||||
maxRedemptions: number | null;
|
||||
|
|
@ -57,6 +58,7 @@ const emptyForm = {
|
|||
code: "",
|
||||
type: "percent" as "percent" | "fixed",
|
||||
value: 10,
|
||||
appliesTo: "courses" as "courses" | "passes" | "all",
|
||||
scope: "all_courses" as "all_courses" | "specific_courses",
|
||||
courseIds: [] as string[],
|
||||
maxRedemptions: "" as string, // leer = unbegrenzt, siehe toInput()
|
||||
|
|
@ -166,8 +168,10 @@ const DiscountCodeList = ({
|
|||
code: form.code,
|
||||
type: form.type,
|
||||
value: form.value,
|
||||
scope: form.scope,
|
||||
courseIds: form.courseIds,
|
||||
appliesTo: form.appliesTo,
|
||||
// Die Kursauswahl gibt es nur, wenn der Code auch für Kurse gilt.
|
||||
scope: form.appliesTo === "passes" ? "all_courses" : form.scope,
|
||||
courseIds: form.appliesTo === "passes" ? [] : form.courseIds,
|
||||
maxRedemptions: form.maxRedemptions.trim() ? Number(form.maxRedemptions) : null,
|
||||
startsAt: form.startsAt ? parseLocalStartOfDay(form.startsAt) : null,
|
||||
expiresAt: form.expiresAt ? parseLocalEndOfDay(form.expiresAt) : null,
|
||||
|
|
@ -325,30 +329,56 @@ const DiscountCodeList = ({
|
|||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-gray-600">Gültigkeitsbereich</label>
|
||||
<label className="text-xs font-medium text-gray-600">Gilt für</label>
|
||||
<div className="flex gap-2 border border-white/60 bg-white/40 rounded-xl p-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant={form.scope === "all_courses" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => setForm((f) => ({ ...f, scope: "all_courses" }))}
|
||||
>
|
||||
Alle Kurse
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={form.scope === "specific_courses" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => setForm((f) => ({ ...f, scope: "specific_courses" }))}
|
||||
>
|
||||
Bestimmte Kurse
|
||||
</Button>
|
||||
{(
|
||||
[
|
||||
["courses", "Kurse"],
|
||||
["passes", "Karten"],
|
||||
["all", "Kurse & Karten"],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
variant={form.appliesTo === value ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => setForm((f) => ({ ...f, appliesTo: value }))}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.scope === "specific_courses" && (
|
||||
{form.appliesTo !== "passes" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium text-gray-600">Gültigkeitsbereich</label>
|
||||
<div className="flex gap-2 border border-white/60 bg-white/40 rounded-xl p-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant={form.scope === "all_courses" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => setForm((f) => ({ ...f, scope: "all_courses" }))}
|
||||
>
|
||||
Alle Kurse
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={form.scope === "specific_courses" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => setForm((f) => ({ ...f, scope: "specific_courses" }))}
|
||||
>
|
||||
Bestimmte Kurse
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.appliesTo !== "passes" && form.scope === "specific_courses" && (
|
||||
<MultiSelectDropdown
|
||||
label="Kurse"
|
||||
selected={form.courseIds}
|
||||
|
|
@ -614,12 +644,19 @@ const DiscountCodeList = ({
|
|||
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<Layers size={13} className="shrink-0 text-gray-400" />
|
||||
{c.scope === "all_courses" ? (
|
||||
<span>Gilt für alle Kurse</span>
|
||||
{c.appliesTo === "passes" ? (
|
||||
<span>Gilt für den Kartenkauf</span>
|
||||
) : c.scope === "all_courses" ? (
|
||||
<span>
|
||||
{c.appliesTo === "all"
|
||||
? "Gilt für alle Kurse und Karten"
|
||||
: "Gilt für alle Kurse"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="truncate">
|
||||
Gilt für:{" "}
|
||||
{c.courseIds.map((id) => courseNameById.get(id) ?? "Unbekannt").join(", ")}
|
||||
{c.appliesTo === "all" && " und Karten"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
Building2,
|
||||
CreditCard,
|
||||
LucideIcon,
|
||||
Mail,
|
||||
Megaphone,
|
||||
PanelTop,
|
||||
Settings,
|
||||
|
|
@ -103,6 +104,14 @@ const SettingsOverviewView = async () => {
|
|||
icon={Bell}
|
||||
href="/settings/notifications"
|
||||
/>
|
||||
{isUser(userData) && (
|
||||
<SettingsMenuItem
|
||||
name="E-Mails von Studios"
|
||||
description="Angebote und Glückwünsche pro Studio ein- oder ausschalten"
|
||||
icon={Mail}
|
||||
href="/settings/marketing"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
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(),
|
||||
studioCheckoutBranding: () => ({}),
|
||||
}));
|
||||
vi.mock("@/src/modules/settings/lib/discount-validation", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/src/modules/settings/lib/discount-validation")>();
|
||||
return {
|
||||
reserveDiscountCode: vi.fn(),
|
||||
releaseDiscountReservation: vi.fn().mockResolvedValue(undefined),
|
||||
calculateDiscountedPriceCents: actual.calculateDiscountedPriceCents,
|
||||
};
|
||||
});
|
||||
vi.mock("@/src/modules/marketing/lib/consent", () => ({ grantConsentQuietly: vi.fn() }));
|
||||
|
||||
import { POST } from "@/src/app/api/stripe/checkout/pass/route";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { getUserData } from "@/src/lib/auth/getUser";
|
||||
import { createBrandedCheckoutSession } from "@/src/lib/stripe-checkout";
|
||||
import {
|
||||
releaseDiscountReservation,
|
||||
reserveDiscountCode,
|
||||
} from "@/src/modules/settings/lib/discount-validation";
|
||||
import { grantConsentQuietly } from "@/src/modules/marketing/lib/consent";
|
||||
|
||||
const product = {
|
||||
id: "prod-1",
|
||||
|
|
@ -93,4 +108,67 @@ describe("POST /api/stripe/checkout/pass", () => {
|
|||
]);
|
||||
expect(params.metadata).toMatchObject({ kind: "pass", productId: "prod-1", userId: "user-1" });
|
||||
});
|
||||
|
||||
describe("mit Rabattcode", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(reserveDiscountCode).mockResolvedValue({
|
||||
reserved: true,
|
||||
reservationToken: "tok-1",
|
||||
discountCode: { id: "code-1", type: "percent", value: 25 },
|
||||
});
|
||||
});
|
||||
|
||||
it("mindert nur den Kartenpreis, nie die Servicegebühr", async () => {
|
||||
const res = await POST(request({ productId: "prod-1", discountCode: "KARTE25" }));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
expect(reserveDiscountCode).toHaveBeenCalledWith(expect.anything(), {
|
||||
code: "KARTE25",
|
||||
businessId: "biz-1",
|
||||
productId: "prod-1",
|
||||
userId: "user-1",
|
||||
});
|
||||
const params = vi.mocked(createBrandedCheckoutSession).mock.calls[0][0];
|
||||
expect(params.line_items?.[0].price_data?.unit_amount).toBe(9000);
|
||||
expect(params.payment_intent_data?.application_fee_amount).toBe(500);
|
||||
expect(params.metadata).toMatchObject({
|
||||
discountCodeId: "code-1",
|
||||
discountAmountCents: "3000",
|
||||
discountReservationToken: "tok-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("lehnt einen ungültigen Code ab, statt zum vollen Preis weiterzumachen", async () => {
|
||||
vi.mocked(reserveDiscountCode).mockResolvedValue({
|
||||
reserved: false,
|
||||
reason: "Dieser Rabattcode gilt nicht für Karten.",
|
||||
});
|
||||
const res = await POST(request({ productId: "prod-1", discountCode: "KURS10" }));
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).error).toBe("Dieser Rabattcode gilt nicht für Karten.");
|
||||
expect(createBrandedCheckoutSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gibt die Reservierung frei, wenn Stripe die Session nicht anlegt", async () => {
|
||||
vi.mocked(createBrandedCheckoutSession).mockRejectedValue(new Error("Stripe weg"));
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const res = await POST(request({ productId: "prod-1", discountCode: "KARTE25" }));
|
||||
expect(res.status).toBe(500);
|
||||
expect(releaseDiscountReservation).toHaveBeenCalledWith(expect.anything(), "tok-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("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";
|
||||
|
||||
vi.mock("@/src/lib/prisma", () => ({
|
||||
prisma: {
|
||||
vi.mock("@/src/lib/prisma", () => {
|
||||
const prisma = {
|
||||
customerPass: { findUnique: vi.fn(), create: vi.fn() },
|
||||
product: { findUnique: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
discountRedemption: { updateMany: vi.fn(), findUnique: vi.fn(), delete: vi.fn() },
|
||||
discountCode: { update: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
};
|
||||
// Callback-Transaktion: läuft mit demselben Mock; Array-Transaktion: alle Promises.
|
||||
prisma.$transaction.mockImplementation((arg: unknown) =>
|
||||
typeof arg === "function" ? arg(prisma) : Promise.all(arg as Promise<unknown>[])
|
||||
);
|
||||
return { prisma };
|
||||
});
|
||||
vi.mock("@/src/lib/stripe", () => ({
|
||||
stripe: { paymentIntents: { retrieve: vi.fn() } },
|
||||
onAccount: (id: string | null) => (id ? { stripeAccount: id } : undefined),
|
||||
|
|
@ -20,6 +28,7 @@ vi.mock("@/src/modules/notifications/lib/create-notification", () => ({
|
|||
|
||||
import {
|
||||
handlePassCheckoutCompleted,
|
||||
handlePassCheckoutUnpaid,
|
||||
isPassCheckout,
|
||||
} from "@/src/modules/products/lib/pass-webhook";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
|
|
@ -125,3 +134,55 @@ describe("handlePassCheckoutCompleted", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kartenkauf mit Rabattcode", () => {
|
||||
const discounted = {
|
||||
...session,
|
||||
amount_total: 10500,
|
||||
metadata: {
|
||||
...session.metadata,
|
||||
discountCodeId: "code-1",
|
||||
discountAmountCents: "2000",
|
||||
discountReservationToken: "tok-1",
|
||||
},
|
||||
};
|
||||
|
||||
it("speichert den Rabatt an der Karte und bestätigt die Reservierung", async () => {
|
||||
vi.mocked(prisma.discountRedemption.updateMany).mockResolvedValue({ count: 1 } as never);
|
||||
|
||||
await handlePassCheckoutCompleted(discounted as never, "acct_studio");
|
||||
|
||||
const { data } = vi.mocked(prisma.customerPass.create).mock.calls[0][0];
|
||||
expect(data).toMatchObject({
|
||||
discountCodeId: "code-1",
|
||||
discountAmount: 2000,
|
||||
// Der Kartenpreis ist schon rabattiert: gezahlt minus Servicegebühr.
|
||||
priceCents: 10000,
|
||||
});
|
||||
expect(prisma.discountRedemption.updateMany).toHaveBeenCalledWith({
|
||||
where: { stripeSessionId: "tok-1", status: "reserved" },
|
||||
data: { status: "confirmed", passId: "pass-1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("gibt die Reservierung frei, wenn der Kauf nie bezahlt wurde", async () => {
|
||||
vi.mocked(prisma.discountRedemption.findUnique).mockResolvedValue({
|
||||
id: "red-1",
|
||||
discountCodeId: "code-1",
|
||||
status: "reserved",
|
||||
} as never);
|
||||
|
||||
await handlePassCheckoutUnpaid(discounted as never);
|
||||
|
||||
expect(prisma.discountRedemption.delete).toHaveBeenCalledWith({ where: { id: "red-1" } });
|
||||
expect(prisma.discountCode.update).toHaveBeenCalledWith({
|
||||
where: { id: "code-1" },
|
||||
data: { redeemedCount: { decrement: 1 } },
|
||||
});
|
||||
});
|
||||
|
||||
it("ohne Rabattcode bleibt nichts freizugeben", async () => {
|
||||
await handlePassCheckoutUnpaid(session as never);
|
||||
expect(prisma.discountRedemption.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
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