diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 311e96f..097623f 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -18,22 +18,34 @@ Kein Docker: die App ist ein reines Next.js 16 (App Router) Full-Stack-Projekt o Alle in Vercel unter **Project Settings → Environment Variables**, Scope **"Preview"** (niemals "Production" — das hält Staging- und Produktions-Secrets sauber getrennt). -| Variable | Quelle | -| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `DATABASE_URL` | Supabase Staging, Transaction Pooler, Port 6543, `?pgbouncer=true&connection_limit=1` | -| `DIRECT_URL` | Supabase Staging, **Session Pooler** (nicht Direct Connection!), Port 5432 — siehe Stolperfalle unten | -| `NEXT_PUBLIC_SUPABASE_URL` | Supabase Staging Projekt-URL | -| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase Staging anon key | -| `SUPABASE_SERVICE_ROLE_KEY` | Supabase Staging service_role key | -| `RESEND_API_KEY` / `RESEND_FROM` / `SMTP_TEST_MAIL` | wiederverwendet von lokal | -| `STRIPE_SECRET_KEY` / `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Test-Mode-Keys, wiederverwendet von lokal | -| `STRIPE_WEBHOOK_SECRET` | eigener Wert, siehe Stripe-Webhook unten | -| `STRIPE_PLATFORM_FEE_PERCENT` | gleicher Wert wie lokal | -| `NEXT_PUBLIC_SITE_URL` / `NEXT_PUBLIC_APP_URL` | `https://kumasy-git-dev-kumasy.vercel.app` | -| `COMPANY_NAME` | `"Kumasy"` | +| Variable | Quelle | +| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `DATABASE_URL` | Supabase Staging, Transaction Pooler, Port 6543, `?pgbouncer=true&connection_limit=1` | +| `DIRECT_URL` | Supabase Staging, **Session Pooler** (nicht Direct Connection!), Port 5432 — siehe Stolperfalle unten | +| `NEXT_PUBLIC_SUPABASE_URL` | Supabase Staging Projekt-URL | +| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase Staging anon key | +| `SUPABASE_SERVICE_ROLE_KEY` | Supabase Staging service_role key | +| `RESEND_API_KEY` / `RESEND_FROM` / `SMTP_TEST_MAIL` | wiederverwendet von lokal | +| `STRIPE_SECRET_KEY` / `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | Test-Mode-Keys, wiederverwendet von lokal | +| `STRIPE_WEBHOOK_SECRET` | eigener Wert, siehe Stripe-Webhook unten | +| `STRIPE_PLATFORM_FEE_PERCENT` | gleicher Wert wie lokal | +| `NEXT_PUBLIC_SITE_URL` / `NEXT_PUBLIC_APP_URL` | `https://kumasy-git-dev-kumasy.vercel.app` | +| `COMPANY_NAME` | `"Kumasy"` | +| `CRON_SECRET` | eigener Zufallswert (`openssl rand -hex 32`), schützt `/api/cron/marketing`, siehe Marketing-Cron unten | +| `MARKETING_TOKEN_SECRET` | eigener Zufallswert, signiert die Abmeldelinks in Marketing-Mails - nie ändern, sonst gehen alte Links nicht mehr | Lokales Pendant für einmalige DB-Operationen gegen Staging (z.B. neue Migrationen einspielen): `.env.staging` im Repo-Root, gitignored (`.env*` steht in `.gitignore`), wird nie committed. +## Marketing-Cron + +Automatische Marketing-Mails (Geburtstag, Meilenstein, Reaktivierung) verschickt einmal täglich `GET /api/cron/marketing`. Die Route prüft nur den Header `Authorization: Bearer ` - wer sie aufruft, ist egal: + +- **Production:** Vercel Cron aus `vercel.json` (täglich 07:00 UTC = 9 Uhr Sommer / 8 Uhr Winter). Vercel setzt den Header selbst, sobald `CRON_SECRET` als Umgebungsvariable existiert. Vercel Cron läuft **nur auf Production-Deployments**. +- **Staging:** Job bei [cron-job.org](https://cron-job.org) auf `https://kumasy-git-dev-kumasy.vercel.app/api/cron/marketing`, täglich, mit dem Header `Authorization: Bearer `. Ist die Vercel Deployment Protection für Previews an, zusätzlich `x-vercel-protection-bypass: ` (Project Settings → Deployment Protection → Protection Bypass for Automation). +- **Lokal:** `npm run cron:marketing` (liest `CRON_SECRET` aus `.env`). Zum Testen: `-- --dry-run` (zeigt nur, wer was bekäme), `-- --date=2026-05-12` (tut so, als wäre heute dieser Tag), `-- --business=`. + +`date` ist in Production gesperrt. Doppelte Aufrufe sind harmlos: pro Studio, Kunde, Trigger-Art und Zeitraum gibt es höchstens eine Mail (Unique-Key in `MarketingSend`). + ## Build Command (Vercel Project Settings → Build & Development Settings) ``` diff --git a/package-lock.json b/package-lock.json index 40af111..166d346 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "@tiptap/extension-underline": "^3.30.5", "@tiptap/react": "^3.30.5", "@tiptap/starter-kit": "^3.30.5", + "@xyflow/react": "^12.12.0", "archiver": "^8.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -7262,6 +7263,15 @@ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", "license": "MIT" }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", @@ -7292,6 +7302,12 @@ "@types/d3-time": "*" } }, + "node_modules/@types/d3-selection": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz", + "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==", + "license": "MIT" + }, "node_modules/@types/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", @@ -7313,6 +7329,25 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.9.tgz", + "integrity": "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -8345,6 +8380,76 @@ "license": "Apache-2.0", "peer": true }, + "node_modules/@xyflow/react": { + "version": "12.12.0", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.12.0.tgz", + "integrity": "sha512-74oXI0Rgm1Eu33cHQAd74J6oVgCH9mbFyTVQbsGgp2kmWwh0HdtBr2VP04z8mbPD+M/xO69nqG5dDEt7/EN2rQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.83", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.83", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.83.tgz", + "integrity": "sha512-a//eDjez3WZF47aaPTfVUDD0OINOq+WVwBZCRTqG+m0a5euX8EwlJ4jMY1Wxz0Ocj4xOeekJ70V22Pq0ll8RJw==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -9244,6 +9349,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -9644,6 +9755,28 @@ "node": ">=12" } }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -9699,6 +9832,15 @@ "node": ">=12" } }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -9744,6 +9886,41 @@ "node": ">=12" } }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", diff --git a/package.json b/package.json index 684c880..671ffa7 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "test": "vitest", "test:run": "vitest run", "test:coverage": "vitest run --coverage", - "stripe:listen": "stripe listen --forward-to localhost:3000/api/stripe/webhook --forward-connect-to localhost:3000/api/stripe/webhook" + "stripe:listen": "stripe listen --forward-to localhost:3000/api/stripe/webhook --forward-connect-to localhost:3000/api/stripe/webhook", + "cron:marketing": "node scripts/run-marketing-cron.mjs" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -44,6 +45,7 @@ "@tiptap/extension-underline": "^3.30.5", "@tiptap/react": "^3.30.5", "@tiptap/starter-kit": "^3.30.5", + "@xyflow/react": "^12.12.0", "archiver": "^8.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/prisma/migrations/20261004110000_customer_passes/migration.sql b/prisma/migrations/20261004110000_customer_passes/migration.sql index b900924..2a156b6 100644 --- a/prisma/migrations/20261004110000_customer_passes/migration.sql +++ b/prisma/migrations/20261004110000_customer_passes/migration.sql @@ -85,7 +85,7 @@ ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_productId_fkey" FOREIGN ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "PassAdjustment" ADD CONSTRAINT "PassAdjustment_passId_fkey" FOREIGN KEY ("passId") REFERENCES "CustomerPass"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20261004120000_customer_pass_user_set_null/migration.sql b/prisma/migrations/20261004120000_customer_pass_user_set_null/migration.sql new file mode 100644 index 0000000..68f8998 --- /dev/null +++ b/prisma/migrations/20261004120000_customer_pass_user_set_null/migration.sql @@ -0,0 +1,11 @@ +-- Eine Karte überlebt das Löschen des Kundenkontos (Kauf, Rechnung und +-- Erstattung müssen erhalten bleiben). Die Migration 20261004110000 hatte die +-- Regel zuerst als CASCADE angelegt; sie wurde danach im Review auf SET NULL +-- geändert, war da aber schon eingespielt. Die Änderung steht deshalb hier als +-- eigene Migration, statt die alte Datei umzuschreiben. + +-- DropForeignKey +ALTER TABLE "CustomerPass" DROP CONSTRAINT IF EXISTS "CustomerPass_userId_fkey"; + +-- AddForeignKey +ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20261005100000_marketing/migration.sql b/prisma/migrations/20261005100000_marketing/migration.sql new file mode 100644 index 0000000..1b6cd1e --- /dev/null +++ b/prisma/migrations/20261005100000_marketing/migration.sql @@ -0,0 +1,152 @@ +-- AlterTable +ALTER TABLE "DiscountCode" ADD COLUMN "appliesTo" TEXT NOT NULL DEFAULT 'courses', +ADD COLUMN "origin" TEXT NOT NULL DEFAULT 'manual', +ADD COLUMN "restrictedToUserId" TEXT; + +-- AlterTable +ALTER TABLE "DiscountRedemption" ADD COLUMN "passId" TEXT; + +-- AlterTable +ALTER TABLE "CustomerPass" ADD COLUMN "discountAmount" INTEGER, +ADD COLUMN "discountCodeId" TEXT; + +-- CreateTable +CREATE TABLE "MarketingConsent" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "businessId" TEXT NOT NULL, + "granted" BOOLEAN NOT NULL, + "textVersion" TEXT NOT NULL, + "source" TEXT NOT NULL, + "grantedAt" TIMESTAMP(3), + "revokedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MarketingConsent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MarketingNode" ( + "id" TEXT NOT NULL, + "businessId" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "config" JSONB NOT NULL, + "positionX" DOUBLE PRECISION NOT NULL, + "positionY" DOUBLE PRECISION NOT NULL, + "emailNodeId" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT false, + "activatedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MarketingNode_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MarketingSend" ( + "id" TEXT NOT NULL, + "businessId" TEXT NOT NULL, + "userId" TEXT, + "triggerKind" TEXT NOT NULL, + "periodKey" TEXT NOT NULL, + "triggerNodeId" TEXT, + "emailNodeId" TEXT, + "discountCodeId" TEXT, + "status" TEXT NOT NULL DEFAULT 'pending', + "attempts" INTEGER NOT NULL DEFAULT 1, + "error" TEXT, + "subject" TEXT NOT NULL, + "sentAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MarketingSend_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MarketingAuditLog" ( + "id" TEXT NOT NULL, + "businessId" TEXT NOT NULL, + "actorType" TEXT NOT NULL, + "actorId" TEXT, + "action" TEXT NOT NULL, + "nodeId" TEXT, + "userId" TEXT, + "summary" TEXT NOT NULL, + "before" JSONB, + "after" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "MarketingAuditLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "MarketingConsent_businessId_granted_idx" ON "MarketingConsent"("businessId", "granted"); + +-- CreateIndex +CREATE UNIQUE INDEX "MarketingConsent_userId_businessId_key" ON "MarketingConsent"("userId", "businessId"); + +-- CreateIndex +CREATE INDEX "MarketingNode_businessId_idx" ON "MarketingNode"("businessId"); + +-- CreateIndex +CREATE INDEX "MarketingNode_emailNodeId_idx" ON "MarketingNode"("emailNodeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "MarketingSend_discountCodeId_key" ON "MarketingSend"("discountCodeId"); + +-- CreateIndex +CREATE INDEX "MarketingSend_businessId_createdAt_idx" ON "MarketingSend"("businessId", "createdAt"); + +-- CreateIndex +CREATE INDEX "MarketingSend_triggerNodeId_idx" ON "MarketingSend"("triggerNodeId"); + +-- CreateIndex +CREATE INDEX "MarketingSend_emailNodeId_idx" ON "MarketingSend"("emailNodeId"); + +-- CreateIndex +CREATE UNIQUE INDEX "MarketingSend_businessId_userId_triggerKind_periodKey_key" ON "MarketingSend"("businessId", "userId", "triggerKind", "periodKey"); + +-- CreateIndex +CREATE INDEX "MarketingAuditLog_businessId_createdAt_idx" ON "MarketingAuditLog"("businessId", "createdAt"); + +-- CreateIndex +CREATE INDEX "MarketingAuditLog_userId_idx" ON "MarketingAuditLog"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "DiscountRedemption_passId_key" ON "DiscountRedemption"("passId"); + +-- CreateIndex +CREATE INDEX "CustomerPass_discountCodeId_idx" ON "CustomerPass"("discountCodeId"); + +-- AddForeignKey +ALTER TABLE "DiscountRedemption" ADD CONSTRAINT "DiscountRedemption_passId_fkey" FOREIGN KEY ("passId") REFERENCES "CustomerPass"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CustomerPass" ADD CONSTRAINT "CustomerPass_discountCodeId_fkey" FOREIGN KEY ("discountCodeId") REFERENCES "DiscountCode"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MarketingConsent" ADD CONSTRAINT "MarketingConsent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MarketingConsent" ADD CONSTRAINT "MarketingConsent_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MarketingNode" ADD CONSTRAINT "MarketingNode_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MarketingNode" ADD CONSTRAINT "MarketingNode_emailNodeId_fkey" FOREIGN KEY ("emailNodeId") REFERENCES "MarketingNode"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MarketingSend" ADD CONSTRAINT "MarketingSend_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MarketingSend" ADD CONSTRAINT "MarketingSend_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MarketingSend" ADD CONSTRAINT "MarketingSend_discountCodeId_fkey" FOREIGN KEY ("discountCodeId") REFERENCES "DiscountCode"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MarketingAuditLog" ADD CONSTRAINT "MarketingAuditLog_businessId_fkey" FOREIGN KEY ("businessId") REFERENCES "Business"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 180777c..be55263 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -44,6 +44,8 @@ model User { courseWaitlistEntries CourseWaitlistEntry[] discountRedemptions DiscountRedemption[] passes CustomerPass[] + marketingConsents MarketingConsent[] + marketingSends MarketingSend[] createdAt DateTime? @default(now()) updatedAt DateTime? @updatedAt } @@ -138,13 +140,17 @@ model Business { onboardingStartedAt DateTime? // Wizard erstmals geoeffnet, ab dann kein Redirect mehr onboardingDismissedAt DateTime? // Checkliste im Dashboard ausgeblendet onboardingCompletedAt DateTime? // Zeitpunkt, an dem erstmals alle Pflichtschritte erledigt waren - manager Manager @relation(fields: [managerId], references: [id]) + manager Manager @relation(fields: [managerId], references: [id]) courses Course[] discountCodes DiscountCode[] products Product[] customerPasses CustomerPass[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + marketingNodes MarketingNode[] + marketingConsents MarketingConsent[] + marketingSends MarketingSend[] + marketingAuditLogs MarketingAuditLog[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([managerId]) } @@ -338,14 +344,14 @@ model Notification { // als natürlicher Unique-Key, um dasselbe Gerät bei erneutem Subscribe zu // aktualisieren statt zu duplizieren. model PushSubscription { - id String @id @default(uuid()) + id String @id @default(uuid()) recipientId String - endpoint String @unique + endpoint String @unique p256dh String // Public Key des Browsers für die Payload-Verschlüsselung auth String // Auth-Secret des Browsers für die Payload-Verschlüsselung userAgent String? // Nur zur Anzeige in einer künftigen "Geräte verwalten"-UI - createdAt DateTime @default(now()) - lastSeenAt DateTime @default(now()) // Bei jedem erneuten Subscribe aktualisiert + createdAt DateTime @default(now()) + lastSeenAt DateTime @default(now()) // Bei jedem erneuten Subscribe aktualisiert @@index([recipientId]) } @@ -359,23 +365,31 @@ model PushSubscription { // Checkouts zu verhindern (siehe DiscountRedemption). Der Zähler wird wieder // dekrementiert, wenn eine Reservierung storniert/abgelaufen wird. model DiscountCode { - id String @id @default(uuid()) - businessId String - code String // Anzeigeform, z.B. "SOMMER25" — Eindeutigkeit nur pro Business (siehe @@unique) - type String // "percent" | "fixed" - value Int // percent: 1-100, fixed: Cents - scope String // "all_courses" | "specific_courses" - courseIds String[] // Nur relevant wenn scope = "specific_courses" - maxRedemptions Int? // null = unbegrenzt - redeemedCount Int @default(0) - startsAt DateTime? // null = sofort gültig - expiresAt DateTime? // null = kein Ablauf - isActive Boolean @default(true) // Manuelles An/Aus, unabhängig vom Zeitfenster - business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) - bookings CourseBooking[] - redemptions DiscountRedemption[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + businessId String + code String // Anzeigeform, z.B. "SOMMER25" — Eindeutigkeit nur pro Business (siehe @@unique) + type String // "percent" | "fixed" + value Int // percent: 1-100, fixed: Cents + scope String // "all_courses" | "specific_courses" + courseIds String[] // Nur relevant wenn scope = "specific_courses" + maxRedemptions Int? // null = unbegrenzt + redeemedCount Int @default(0) + startsAt DateTime? // null = sofort gültig + expiresAt DateTime? // null = kein Ablauf + isActive Boolean @default(true) // Manuelles An/Aus, unabhängig vom Zeitfenster + appliesTo String @default("courses") // "courses" | "passes" (Kartenkauf) | "all" + // Persönlicher Code: nur dieses Konto kann ihn einlösen. Bewusst ohne + // Fremdschlüssel - würde er beim Löschen des Kontos auf null gesetzt, könnte + // plötzlich jeder den Code einlösen. + restrictedToUserId String? + origin String @default("manual") // "manual" (Rabattcode-Seite) | "marketing" (automatisch pro Mail erzeugt, nicht in der Liste) + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) + bookings CourseBooking[] + passes CustomerPass[] + redemptions DiscountRedemption[] + marketingSend MarketingSend? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([businessId, code]) @@index([businessId]) @@ -400,10 +414,12 @@ model DiscountRedemption { userId String stripeSessionId String @unique bookingId String? @unique + passId String? @unique // Kartenkauf mit Rabattcode - gesetzt statt bookingId status String @default("reserved") // "reserved" | "confirmed" discountCode DiscountCode @relation(fields: [discountCodeId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) booking CourseBooking? @relation(fields: [bookingId], references: [id], onDelete: SetNull) + pass CustomerPass? @relation(fields: [passId], references: [id], onDelete: SetNull) createdAt DateTime @default(now()) @@unique([discountCodeId, userId]) @@ -417,7 +433,7 @@ model DiscountRedemption { // 1 Einheit = 1 Platz in einem Kurs. Zum Preis kommt die Servicegebühr pro // Einheit dazu (siehe src/modules/products/lib/card-pricing.ts). model Product { - id String @id @default(uuid()) + id String @id @default(uuid()) businessId String name String description String? @@ -429,8 +445,8 @@ model Product { isActive Boolean @default(true) // false = nicht mehr kaufbar business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) passes CustomerPass[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([businessId]) } @@ -443,7 +459,7 @@ model Product { // ist), Storno innerhalb der Frist und Kursabsage geben zurück, der Manager passt // ihn im Kunden-Detail an. Jede manuelle Änderung steht in PassAdjustment. model CustomerPass { - id String @id @default(uuid()) + id String @id @default(uuid()) productId String businessId String // null = noch ohne Konto: vom Studio an eine E-Mail vergeben, oder das Konto @@ -457,33 +473,38 @@ model CustomerPass { creditsTotal Int // alle Einheiten, die die Karte je bekommen hat (inkl. Gutschriften) creditsRemaining Int validUntil DateTime? // null = unbegrenzt gültig - status String @default("active") // "active" | "ended" (vom Studio beendet) | "refunding" (Erstattung angestoßen, Ausgang offen) | "refunded" + status String @default("active") // "active" | "ended" (vom Studio beendet) | "refunding" (Erstattung angestoßen, Ausgang offen) | "refunded" source String // "purchase" | "manual" (vom Studio vergeben, ohne Zahlung) // Zahlung - nur bei source = "purchase" priceCents Int? stripeAccountId String? - stripeSessionId String? @unique - stripePaymentIntentId String? @unique + stripeSessionId String? @unique + stripePaymentIntentId String? @unique amountPaid Int? // inkl. Servicegebühr platformFee Int? stripeFee Int? billingAddress Json? - invoiceNumber String? @unique + invoiceNumber String? @unique invoiceIssuedAt DateTime? refundedAmount Int? refundedAt DateTime? - product Product @relation(fields: [productId], references: [id], onDelete: NoAction) - business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) - user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + discountCodeId String? // Beim Kauf eingelöster Rabattcode + discountAmount Int? // Gewährter Rabatt in Cents (Snapshot) - priceCents ist schon der rabattierte Preis + discountCode DiscountCode? @relation(fields: [discountCodeId], references: [id], onDelete: SetNull) + discountRedemption DiscountRedemption? + product Product @relation(fields: [productId], references: [id], onDelete: NoAction) + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) bookings CourseBooking[] adjustments PassAdjustment[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([businessId]) @@index([userId]) @@index([email]) @@index([productId]) + @@index([discountCodeId]) } // Protokoll jeder Änderung durch das Studio: Karte vergeben, Einheiten @@ -502,3 +523,114 @@ model PassAdjustment { @@index([passId]) } + +// ─── Marketing ──────────────────────────────────────────────────────────────── +// +// Automatische E-Mails eines Studios an seine Kunden. Das Studio baut im +// Node-Editor Trigger (Geburtstag, Meilenstein, Reaktivierung), die jeweils in +// genau eine E-Mail führen. Ein täglicher Cron (/api/cron/marketing) prüft die +// aktiven Trigger und verschickt fällige Mails - nur an Kunden, die dem Studio +// ausdrücklich zugestimmt haben (MarketingConsent). Ablauf siehe +// src/modules/marketing/lib/run-marketing.ts. + +// Einwilligung eines Kunden in Werbe-Mails EINES Studios (Opt-in per Häkchen). +// Eine Zeile pro Kunde und Studio mit dem aktuellen Stand; jede Erteilung und +// jeder Widerruf steht zusätzlich im MarketingAuditLog (Nachweis). +model MarketingConsent { + id String @id @default(uuid()) + userId String + businessId String + granted Boolean + textVersion String // Version des Wortlauts, dem zugestimmt wurde (siehe consent-text.ts) + source String // "course_booking" | "pass_purchase" | "profile" | "unsubscribe_link" + grantedAt DateTime? // letzte Erteilung + revokedAt DateTime? // letzter Widerruf + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([userId, businessId]) + @@index([businessId, granted]) +} + +// Ein Baustein im Node-Editor. Trigger zeigen über emailNodeId auf die E-Mail, +// in die sie führen - ein Trigger hat damit höchstens eine Verbindung, eine +// E-Mail kann mehrere Trigger haben. +model MarketingNode { + id String @id @default(uuid()) + businessId String + kind String // "birthday" | "milestone" | "winback" | "email" + // Trigger: milestone { bookings }, winback { days }, birthday {}. + // E-Mail: { name, subject, body, reward } - Format siehe node-config.ts. + config Json + positionX Float + positionY Float + // Nur Trigger: verbundene E-Mail und Schalter. Wirksam ist ein Trigger, wenn + // er eingeschaltet UND verbunden ist; activatedAt ist der Zeitpunkt, ab dem + // er so läuft. Gezählt werden nur Ereignisse ab dann - sonst würde das + // Einschalten alle Bestandskunden auf einmal anschreiben. + emailNodeId String? + isActive Boolean @default(false) + activatedAt DateTime? + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) + emailNode MarketingNode? @relation("TriggerEmail", fields: [emailNodeId], references: [id], onDelete: SetNull) + triggers MarketingNode[] @relation("TriggerEmail") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([businessId]) + @@index([emailNodeId]) +} + +// Versandprotokoll. Der Unique-Key verhindert Doppelmails unabhängig vom +// Editor: pro Studio, Kunde, Trigger-Art und Zeitraum (Geburtstag: Jahr, +// Meilenstein: Schwelle, Reaktivierung: letzter Kurstag + Tage) gibt es +// höchstens eine Mail - auch wenn der Cron doppelt läuft oder ein Node +// gelöscht und neu angelegt wird. +model MarketingSend { + id String @id @default(uuid()) + businessId String + userId String? // null = Konto inzwischen gelöscht + triggerKind String + periodKey String + triggerNodeId String? // null = Node inzwischen gelöscht + emailNodeId String? + discountCodeId String? @unique + status String @default("pending") // "pending" | "sent" | "failed" + attempts Int @default(1) + error String? + subject String // Betreff als Momentaufnahme, für den Verlauf + sentAt DateTime? + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + discountCode DiscountCode? @relation(fields: [discountCodeId], references: [id], onDelete: SetNull) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([businessId, userId, triggerKind, periodKey]) + @@index([businessId, createdAt]) + @@index([triggerNodeId]) + @@index([emailNodeId]) +} + +// Änderungsprotokoll: wer hat im Editor was geändert, und wer hat wann +// eingewilligt oder widerrufen. Bewusst ohne Fremdschlüssel auf Nodes/Kunden - +// der Eintrag muss das Löschen überdauern. +model MarketingAuditLog { + id String @id @default(uuid()) + businessId String + actorType String // "manager" | "customer" | "system" + actorId String? + action String // siehe AUDIT_ACTIONS in audit-log.ts + nodeId String? + userId String? // betroffener Kunde (Einwilligung) + summary String // lesbare Momentaufnahme, z.B. "Meilenstein (10. Kurs) eingeschaltet" + before Json? + after Json? + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + + @@index([businessId, createdAt]) + @@index([userId]) +} diff --git a/scripts/run-marketing-cron.mjs b/scripts/run-marketing-cron.mjs new file mode 100644 index 0000000..4efa376 --- /dev/null +++ b/scripts/run-marketing-cron.mjs @@ -0,0 +1,40 @@ +/** + * Löst den Marketing-Lauf von Hand aus - lokal oder gegen eine andere Umgebung. + * Liest CRON_SECRET aus .env und ruft /api/cron/marketing mit dem richtigen + * Header auf (cron-job.org kommt nicht an localhost heran). + * + * Usage: + * npm run cron:marketing # echter Lauf gegen localhost:3000 + * npm run cron:marketing -- --dry-run # nur anzeigen, wer was bekäme + * npm run cron:marketing -- --date=2026-05-12 # so tun, als wäre der 12.05. + * npm run cron:marketing -- --business= # nur ein Studio + * npm run cron:marketing -- --url=https://… # andere Umgebung (CRON_SECRET muss passen) + */ +import "dotenv/config"; + +const args = Object.fromEntries( + process.argv.slice(2).map((arg) => { + const [key, value] = arg.replace(/^--/, "").split("="); + return [key, value ?? "1"]; + }) +); + +const secret = process.env.CRON_SECRET; +if (!secret) { + console.error("CRON_SECRET fehlt in .env"); + process.exit(1); +} + +const url = new URL("/api/cron/marketing", args.url ?? "http://localhost:3000"); +if (args["dry-run"]) url.searchParams.set("dryRun", "1"); +if (args.date) url.searchParams.set("date", args.date); +if (args.business) url.searchParams.set("businessId", args.business); + +const response = await fetch(url, { headers: { Authorization: `Bearer ${secret}` } }); +const body = await response.text(); +try { + console.log(JSON.stringify(JSON.parse(body), null, 2)); +} catch { + console.log(body); +} +process.exit(response.ok ? 0 : 1); diff --git a/src/app/(main)/marketing/page.tsx b/src/app/(main)/marketing/page.tsx new file mode 100644 index 0000000..480567e --- /dev/null +++ b/src/app/(main)/marketing/page.tsx @@ -0,0 +1,15 @@ +import { requireManager } from "@/src/lib/auth/getUser"; +import { getMarketingEditor } from "@/src/modules/marketing/actions/editor-actions"; +import MarketingView from "@/src/modules/marketing/ui/views/marketing-view"; + +const MarketingPage = async () => { + const manager = await requireManager(); + const businesses = manager.businesses.map((b) => ({ id: b.id, name: b.name })); + const result = businesses[0] ? await getMarketingEditor(businesses[0].id) : null; + + return ( + + ); +}; + +export default MarketingPage; diff --git a/src/app/(main)/settings/marketing/page.tsx b/src/app/(main)/settings/marketing/page.tsx new file mode 100644 index 0000000..a8fe37f --- /dev/null +++ b/src/app/(main)/settings/marketing/page.tsx @@ -0,0 +1,29 @@ +import { Suspense } from "react"; +import { redirect } from "next/navigation"; +import { AlertTriangle } from "lucide-react"; +import { isUser, requireAuthWithData } from "@/src/lib/auth/getUser"; +import { getMyStudioConsents } from "@/src/modules/marketing/actions/consent-actions"; +import StudioEmailsSettingsView from "@/src/modules/marketing/ui/views/studio-emails-settings-view"; +import SettingsLoading from "../loading"; + +/** Kunden: Werbe-Mails pro Studio ein- und ausschalten. */ +export default async function SettingsMarketingPage() { + const userData = await requireAuthWithData(); + if (!isUser(userData)) redirect("/settings"); + + const result = await getMyStudioConsents(); + if (!result.success) { + return ( +
+ +

{result.error}

+
+ ); + } + + return ( + }> + + + ); +} diff --git a/src/app/abmelden/page.tsx b/src/app/abmelden/page.tsx new file mode 100644 index 0000000..1eb1646 --- /dev/null +++ b/src/app/abmelden/page.tsx @@ -0,0 +1,33 @@ +import type { Metadata } from "next"; +import { prisma } from "@/src/lib/prisma"; +import { verifyUnsubscribeToken } from "@/src/modules/marketing/lib/unsubscribe-token"; +import UnsubscribeView from "@/src/modules/marketing/ui/views/unsubscribe-view"; + +export const metadata: Metadata = { + title: "E-Mails abbestellen · Kumasy", + robots: { index: false }, +}; + +/** + * Ziel des Abmeldelinks in jeder Werbe-Mail. Abgemeldet wird erst per Klick auf + * der Seite, nicht schon beim Aufruf - Link-Scanner in Mailprogrammen öffnen + * Links vorab und würden sonst Kunden ungewollt abmelden. + */ +export default async function UnsubscribePage({ + searchParams, +}: { + searchParams: Promise<{ t?: string }>; +}) { + const { t } = await searchParams; + const target = verifyUnsubscribeToken(t); + const business = target + ? await prisma.business.findUnique({ + where: { id: target.businessId }, + select: { name: true }, + }) + : null; + + return ( + + ); +} diff --git a/src/app/api/cron/marketing/route.ts b/src/app/api/cron/marketing/route.ts new file mode 100644 index 0000000..a4941d2 --- /dev/null +++ b/src/app/api/cron/marketing/route.ts @@ -0,0 +1,65 @@ +import { timingSafeEqual } from "crypto"; +import { NextRequest, NextResponse } from "next/server"; +import { runMarketing } from "@/src/modules/marketing/lib/run-marketing"; + +// Vercel bricht Funktionen nach maxDuration ab. Der Lauf hört 50 Sekunden +// vorher auf, neue Mails anzufangen - der Rest kommt beim nächsten Lauf. +export const maxDuration = 300; +const RUN_BUDGET_MS = 250_000; + +function isAuthorized(header: string | null, secret: string): boolean { + const expected = Buffer.from(`Bearer ${secret}`); + const actual = Buffer.from(header ?? ""); + return expected.length === actual.length && timingSafeEqual(expected, actual); +} + +/** + * Täglicher Marketing-Lauf (siehe run-marketing.ts). + * + * Aufrufer ist egal, es zählt nur der Header `Authorization: Bearer CRON_SECRET`: + * - Production: Vercel Cron (vercel.json) - Vercel setzt den Header selbst, + * sobald CRON_SECRET als Umgebungsvariable existiert. + * - Staging: ein Job bei cron-job.org mit demselben Header. + * - Lokal: `npm run cron:marketing` (scripts/run-marketing-cron.mjs). + * + * Query-Parameter zum Testen: + * - dryRun=1: zeigt, wer welche Mail bekäme, verschickt nichts + * - businessId=…: nur dieses Studio + * - date=YYYY-MM-DD: tut so, als wäre heute dieser Tag (nicht in Production) + */ +export async function GET(request: NextRequest) { + const secret = process.env.CRON_SECRET; + if (!secret) { + return NextResponse.json({ error: "CRON_SECRET ist nicht gesetzt" }, { status: 500 }); + } + if (!isAuthorized(request.headers.get("authorization"), secret)) { + return NextResponse.json({ error: "Nicht autorisiert" }, { status: 401 }); + } + + const params = request.nextUrl.searchParams; + const date = params.get("date"); + if (date) { + if (process.env.VERCEL_ENV === "production") { + return NextResponse.json({ error: "date ist in Production nicht erlaubt" }, { status: 400 }); + } + if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || Number.isNaN(Date.parse(date))) { + return NextResponse.json({ error: "date muss YYYY-MM-DD sein" }, { status: 400 }); + } + } + + try { + const report = await runMarketing({ + today: date ?? undefined, + dryRun: params.get("dryRun") === "1", + businessId: params.get("businessId") ?? undefined, + deadline: Date.now() + RUN_BUDGET_MS, + }); + return NextResponse.json(report); + } catch (error) { + console.error("Marketing-Lauf fehlgeschlagen:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Marketing-Lauf fehlgeschlagen" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/marketing/unsubscribe/route.ts b/src/app/api/marketing/unsubscribe/route.ts new file mode 100644 index 0000000..ff55fc0 --- /dev/null +++ b/src/app/api/marketing/unsubscribe/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { setMarketingConsent } from "@/src/modules/marketing/lib/consent"; +import { verifyUnsubscribeToken } from "@/src/modules/marketing/lib/unsubscribe-token"; + +/** + * Ein-Klick-Abmeldung aus dem Mailprogramm (RFC 8058). Gmail, Apple Mail & Co. + * schicken einen POST an die Adresse aus dem List-Unsubscribe-Header - ohne + * Cookies, ohne Rückfrage. Das signierte Token reicht als Berechtigung. + */ +export async function POST(request: NextRequest) { + const target = verifyUnsubscribeToken(request.nextUrl.searchParams.get("t")); + if (!target) { + return NextResponse.json({ error: "Ungültiger Abmeldelink" }, { status: 400 }); + } + + try { + await setMarketingConsent({ ...target, granted: false, source: "unsubscribe_link" }); + return NextResponse.json({ unsubscribed: true }); + } catch (error) { + console.error("Ein-Klick-Abmeldung fehlgeschlagen:", error); + return NextResponse.json({ error: "Abmeldung fehlgeschlagen" }, { status: 500 }); + } +} + +/** + * Wer die Adresse direkt öffnet, landet auf der Abmelde-Seite. Ein GET meldet + * bewusst nicht ab: Link-Scanner in Mailprogrammen rufen Links vorab auf. + */ +export function GET(request: NextRequest) { + const url = request.nextUrl.clone(); + url.pathname = "/abmelden"; + return NextResponse.redirect(url); +} diff --git a/src/app/api/stripe/checkout/course/route.ts b/src/app/api/stripe/checkout/course/route.ts index f27e79c..35e6156 100644 --- a/src/app/api/stripe/checkout/course/route.ts +++ b/src/app/api/stripe/checkout/course/route.ts @@ -20,6 +20,7 @@ import { } from "@/src/modules/courses/lib/course-spots"; import { MAX_SEATS_PER_BOOKING, paidSeatCount } from "@/src/modules/courses/lib/seat-counts"; import { spotDisplayName, type FloorPlanItem } from "@/src/modules/rooms/lib/floor-plan"; +import { grantConsentQuietly } from "@/src/modules/marketing/lib/consent"; /** * Pro Person und Kurs läuft höchstens ein offener Checkout mit Platz. Startet @@ -134,6 +135,8 @@ export async function POST(request: NextRequest) { spotIds?: string[]; /** Alter Client vor der Mehrfachbuchung: ein einzelner Platz. */ spotId?: string; + /** Häkchen für Werbe-Mails des Studios (Opt-in), nur mit Konto. */ + marketingConsent?: boolean; }; const { courseId, guestEmail, guestName, discountCode } = body; const seats = body.seats ?? 1; @@ -532,6 +535,16 @@ export async function POST(request: NextRequest) { throw error; } + // Das Häkchen hat der Kunde beim Klick auf "Buchen" gesetzt - es gilt + // unabhängig davon, ob er die Zahlung danach abschließt. + if (!isGuest && body.marketingConsent === true) { + await grantConsentQuietly({ + userId: userData!.id, + businessId: course.business.id, + source: "course_booking", + }); + } + // Damit ein späterer Checkout derselben Person diese Session beenden kann // (siehe endOpenCheckouts). Scheitert das, bleibt der Platz trotzdem bis // zum Ablauf gehalten - kein Grund, die fertige Session zu verwerfen. diff --git a/src/app/api/stripe/checkout/pass/route.ts b/src/app/api/stripe/checkout/pass/route.ts index aec1f3e..7b39997 100644 --- a/src/app/api/stripe/checkout/pass/route.ts +++ b/src/app/api/stripe/checkout/pass/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; import { prisma } from "@/src/lib/prisma"; import { getPlatformFeeCents } from "@/src/lib/stripe"; import { createBrandedCheckoutSession, studioCheckoutBranding } from "@/src/lib/stripe-checkout"; @@ -6,6 +7,12 @@ import { getUserData, isUser } from "@/src/lib/auth/getUser"; import { cardServiceFeeCents } from "@/src/modules/products/lib/card-pricing"; import { formatValidity } from "@/src/modules/products/lib/card-format"; import { PASS_CHECKOUT_KIND } from "@/src/modules/products/lib/pass-webhook"; +import { + calculateDiscountedPriceCents, + releaseDiscountReservation, + reserveDiscountCode, +} from "@/src/modules/settings/lib/discount-validation"; +import { grantConsentQuietly } from "@/src/modules/marketing/lib/consent"; /** * Erstellt eine Stripe Checkout Session für den Kauf einer Karte. @@ -18,8 +25,18 @@ import { PASS_CHECKOUT_KIND } from "@/src/modules/products/lib/pass-webhook"; * Nur für eingeloggte Kunden: eine Karte hängt an einem Konto, mit dem später * gebucht wird. * - * Body: { productId: string } + * Ein Rabattcode (DiscountCode.appliesTo "passes" oder "all") mindert nur den + * Kartenpreis, nie die Servicegebühr. Er wird wie bei Kursen schon hier + * reserviert und erst im Webhook bestätigt. + * + * Body: { productId: string; discountCode?: string; marketingConsent?: boolean } */ +const bodySchema = z.object({ + productId: z.string().min(1), + discountCode: z.string().optional(), + marketingConsent: z.boolean().optional(), +}); + export async function POST(request: NextRequest) { try { const userData = await getUserData(); @@ -38,10 +55,11 @@ export async function POST(request: NextRequest) { ); } - const { productId } = (await request.json()) as { productId?: string }; - if (!productId) { - return NextResponse.json({ error: "productId fehlt" }, { status: 400 }); + const body = bodySchema.safeParse(await request.json().catch(() => null)); + if (!body.success) { + return NextResponse.json({ error: "Ungültige Anfrage." }, { status: 400 }); } + const { productId, discountCode, marketingConsent } = body.data; const product = await prisma.product.findUnique({ where: { id: productId }, @@ -82,6 +100,38 @@ export async function POST(request: NextRequest) { const feeCents = cardServiceFeeCents(product.credits, feePerUnitCents); const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"; + let cardPriceCents = product.priceCents; + let discountAmountCents = 0; + let appliedDiscountCodeId: string | null = null; + let reservationToken: string | null = null; + if (discountCode?.trim()) { + const reservation = await reserveDiscountCode(prisma, { + code: discountCode, + businessId: business.id, + productId: product.id, + userId: userData.id, + }); + if (!reservation.reserved) { + return NextResponse.json({ error: reservation.reason }, { status: 400 }); + } + const result = calculateDiscountedPriceCents(product.priceCents, reservation.discountCode); + // Wie bei Kursen: eine 0-€-Karte hätte keinen PaymentIntent, der Webhook + // könnte den Kauf nicht bestätigen. + if (result.discountedPriceCents <= 0) { + await releaseDiscountReservation(prisma, reservation.reservationToken); + return NextResponse.json( + { + error: "Dieser Rabattcode reduziert den Preis auf 0 € und kann nicht verwendet werden.", + }, + { status: 400 } + ); + } + cardPriceCents = result.discountedPriceCents; + discountAmountCents = result.discountAmountCents; + appliedDiscountCodeId = reservation.discountCode.id; + reservationToken = reservation.reservationToken; + } + // Alles, was der Webhook braucht, um die Karte anzulegen. Liegt an Session // UND PaymentIntent, damit es auch im Stripe-Dashboard an der Zahlung steht. const metadata = { @@ -90,57 +140,83 @@ export async function POST(request: NextRequest) { userId: userData.id, businessId: business.id, platformFeeModel: "on_top", + discountCodeId: appliedDiscountCodeId ?? "", + discountAmountCents: String(discountAmountCents), + discountReservationToken: reservationToken ?? "", }; - const session = await createBrandedCheckoutSession( - { - mode: "payment", - // Rechnungsadresse ist Pflicht - sie steht auf der Rechnung zum Kartenkauf. - billing_address_collection: "required", - line_items: [ - { - price_data: { - currency: "eur", - product_data: { - name: product.name, - description: `${business.name} - ${product.credits} ${ - product.credits === 1 ? "Einheit" : "Einheiten" - } · ${formatValidity(product.validityMonths)}`, - }, - unit_amount: product.priceCents, - }, - quantity: 1, - }, - // Servicegebühr pro Einheit - eine 10er-Karte kostet so viel Gebühr wie - // zehn Einzelbuchungen. Bei 0 entfällt die Position ganz. - ...(feeCents > 0 - ? [ - { - price_data: { - currency: "eur" as const, - product_data: { - name: "Servicegebühr", - description: "Gebühr für die Online-Buchung, pro Einheit", - }, - unit_amount: feePerUnitCents, - }, - quantity: product.credits, + let session; + try { + session = await createBrandedCheckoutSession( + { + mode: "payment", + // Rechnungsadresse ist Pflicht - sie steht auf der Rechnung zum Kartenkauf. + billing_address_collection: "required", + line_items: [ + { + price_data: { + currency: "eur", + product_data: { + name: product.name, + description: `${business.name} - ${product.credits} ${ + product.credits === 1 ? "Einheit" : "Einheiten" + } · ${formatValidity(product.validityMonths)}`, }, - ] - : []), - ], - payment_intent_data: { - ...(feeCents > 0 ? { application_fee_amount: feeCents } : {}), + unit_amount: cardPriceCents, + }, + quantity: 1, + }, + // Servicegebühr pro Einheit - eine 10er-Karte kostet so viel Gebühr wie + // zehn Einzelbuchungen. Bei 0 entfällt die Position ganz. + ...(feeCents > 0 + ? [ + { + price_data: { + currency: "eur" as const, + product_data: { + name: "Servicegebühr", + description: "Gebühr für die Online-Buchung, pro Einheit", + }, + unit_amount: feePerUnitCents, + }, + quantity: product.credits, + }, + ] + : []), + ], + payment_intent_data: { + ...(feeCents > 0 ? { application_fee_amount: feeCents } : {}), + metadata, + }, metadata, + customer_email: userData.email, + success_url: `${baseUrl}/courses/myCards?purchased=1`, + cancel_url: business.slug ? `${baseUrl}/business/${business.slug}` : baseUrl, }, - metadata, - customer_email: userData.email, - success_url: `${baseUrl}/courses/myCards?purchased=1`, - cancel_url: business.slug ? `${baseUrl}/business/${business.slug}` : baseUrl, - }, - studioCheckoutBranding(business), - business.stripeAccountId - ); + studioCheckoutBranding(business), + business.stripeAccountId + ); + } catch (error) { + // Nie angelegte Session meldet auch kein checkout.session.expired - die + // Reservierung muss hier zurück. + if (reservationToken) { + await releaseDiscountReservation(prisma, reservationToken).catch((releaseError) => + console.error( + `Rabattcode-Reservierung ${reservationToken} nach fehlgeschlagenem Kartenkauf nicht freigegeben:`, + releaseError + ) + ); + } + throw error; + } + + if (marketingConsent === true) { + await grantConsentQuietly({ + userId: userData.id, + businessId: business.id, + source: "pass_purchase", + }); + } return NextResponse.json({ url: session.url }); } catch (error) { diff --git a/src/app/api/stripe/webhook/route.ts b/src/app/api/stripe/webhook/route.ts index 4f80cc3..c271065 100644 --- a/src/app/api/stripe/webhook/route.ts +++ b/src/app/api/stripe/webhook/route.ts @@ -30,6 +30,7 @@ import { import { MAX_SEATS_PER_BOOKING, paidSeatCount } from "@/src/modules/courses/lib/seat-counts"; import { handlePassCheckoutCompleted, + handlePassCheckoutUnpaid, isPassCheckout, } from "@/src/modules/products/lib/pass-webhook"; import { reconcilePassRefund } from "@/src/modules/products/lib/pass-refund"; @@ -108,9 +109,11 @@ export async function POST(request: NextRequest) { } case "checkout.session.expired": case "checkout.session.async_payment_failed": { - // Ein Kartenkauf reserviert nichts (keine Plätze, kein Rabattcode) - nie - // bezahlt heißt einfach: keine Karte. - if (!isPassCheckout(event.data.object)) { + // Ein Kartenkauf hält keine Plätze - nie bezahlt heißt: keine Karte, + // nur ein reservierter Rabattcode wird wieder frei. + if (isPassCheckout(event.data.object)) { + await handlePassCheckoutUnpaid(event.data.object); + } else { await handleCheckoutUnpaid(event.data.object); } break; diff --git a/src/lib/mail/resend.ts b/src/lib/mail/resend.ts index 035a8f0..f8b7cf7 100644 --- a/src/lib/mail/resend.ts +++ b/src/lib/mail/resend.ts @@ -26,6 +26,10 @@ export function getResend(): Resend { * @param subject - E-Mail Betreff * @param html - HTML-Inhalt der E-Mail * @param text - Plain-Text Alternative (optional) + * @param fromName - Anzeigename statt dem aus RESEND_FROM, die Adresse bleibt (optional) + * @param replyTo - Antwortadresse, z.B. die des Studios (optional) + * @param headers - Zusätzliche Header wie List-Unsubscribe (optional) + * @param idempotencyKey - Resend verschickt dieselbe Mail innerhalb von 24 h nur einmal (optional) * @returns Promise mit dem Ergebnis des Versands */ export async function sendMail({ @@ -33,20 +37,34 @@ export async function sendMail({ subject, html, text, + fromName, + replyTo, + headers, + idempotencyKey, }: { to: string; subject: string; html: string; text?: string; + fromName?: string; + replyTo?: string; + headers?: Record; + idempotencyKey?: string; }) { try { - const { data, error } = await getResend().emails.send({ - from: process.env.RESEND_FROM || "", + const defaultFrom = process.env.RESEND_FROM || ""; + const payload = { + from: fromName ? withDisplayName(defaultFrom, fromName) : defaultFrom, to, subject, html, text: text || stripHtml(html), // Fallback: HTML zu Text konvertieren - }); + ...(replyTo ? { replyTo } : {}), + ...(headers ? { headers } : {}), + }; + const { data, error } = idempotencyKey + ? await getResend().emails.send(payload, { idempotencyKey }) + : await getResend().emails.send(payload); if (error) { console.error("Fehler beim E-Mail-Versand:", error); @@ -61,6 +79,17 @@ export async function sendMail({ } } +/** + * Setzt einen anderen Anzeigenamen vor die Absenderadresse aus RESEND_FROM + * ("Kumasy " → "Studio X über Kumasy "). Zeichen, die + * den Header aufbrechen könnten, fliegen raus. + */ +export function withDisplayName(from: string, displayName: string): string { + const address = from.match(/<([^>]+)>/)?.[1] ?? from.trim(); + const safeName = displayName.replace(/[\r\n"<>\\]/g, "").trim(); + return safeName ? `"${safeName}" <${address}>` : address; +} + /** * Einfache Funktion zum Entfernen von HTML-Tags (für Text-Alternative) */ diff --git a/src/lib/mail/templates/layout.ts b/src/lib/mail/templates/layout.ts index 9afd073..dadf662 100644 --- a/src/lib/mail/templates/layout.ts +++ b/src/lib/mail/templates/layout.ts @@ -17,6 +17,12 @@ export interface RenderEmailLayoutParams { /** Fertiger Inner-Content, wird unverändert in die Content-Zelle eingesetzt. */ bodyHtml: string; branding?: EmailBranding; + /** + * Ersetzt den Hinweis "automatisch generiert, bitte nicht antworten" im + * Footer - z.B. bei Werbe-Mails, auf die Kunden antworten dürfen und die + * einen Abmeldelink brauchen. Vom Aufrufer bereits escaped. + */ + footerNoteHtml?: string; } export function renderEmailLayout({ @@ -25,6 +31,7 @@ export function renderEmailLayout({ bannerHtml, bodyHtml, branding = KUMASY_BRANDING, + footerNoteHtml, }: RenderEmailLayoutParams): string { const safeTitle = escapeHtml(title); const safeCompanyName = escapeHtml(branding.companyName); @@ -75,7 +82,7 @@ export function renderEmailLayout({ ${safeCompanyName}

- Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht auf diese E-Mail. + ${footerNoteHtml ?? "Diese E-Mail wurde automatisch generiert. Bitte antworten Sie nicht auf diese E-Mail."}

Powered by Kumasy diff --git a/src/lib/mail/templates/marketing-email.ts b/src/lib/mail/templates/marketing-email.ts new file mode 100644 index 0000000..586a2ec --- /dev/null +++ b/src/lib/mail/templates/marketing-email.ts @@ -0,0 +1,114 @@ +/** + * E-Mail-Template für automatische Marketing-Mails eines Studios (Geburtstag, + * Meilenstein, Reaktivierung). + * + * Betreff und Text schreibt das Studio selbst (Platzhalter sind hier schon + * ersetzt). Das Template setzt den Text in Absätze, hängt einen eventuellen + * persönlichen Rabattcode als Box an und ersetzt den "Bitte nicht antworten"- + * Hinweis durch den Abmeldelink - Antworten gehen per Reply-To an das Studio. + */ + +import { escapeHtml, sanitizeUrl, singleLine } from "./utils"; +import { renderButton, renderInfoBox } from "./components"; +import { renderEmailLayout } from "./layout"; +import { EMAIL_TOKENS, KUMASY_BRANDING, type EmailBranding } from "./tokens"; + +export interface MarketingEmailParams { + subject: string; + body: string; + studioName: string; + reward: { + code: string; + /** "15 %" oder "5,00 €" */ + valueLabel: string; + /** "Kurse", "Karten" oder "Kurse und Karten" */ + appliesToLabel: string; + /** "31.10.2026" */ + validUntilLabel: string; + } | null; + /** Öffentliche Studio-Seite für den Button, null = kein Button */ + bookingUrl: string | null; + unsubscribeUrl: string; + branding?: EmailBranding; +} + +function paragraphsToHtml(text: string): string { + return text + .split(/\n{2,}/) + .map((paragraph) => paragraph.trim()) + .filter(Boolean) + .map( + (paragraph) => + `

${escapeHtml( + paragraph + ).replace(/\n/g, "
")}

` + ) + .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: `${escapeHtml( + reward.code + )}`, + }, + { + label: "Rabatt", + valueHtml: `${escapeHtml(reward.valueLabel)} auf ${escapeHtml(reward.appliesToLabel)}`, + }, + { label: "Gültig bis", valueHtml: escapeHtml(reward.validUntilLabel) }, + ], + }) + + `

Einmal einlösbar, nur mit Ihrem Kundenkonto. Geben Sie den Code beim Buchen ein.

` + : ""; + + 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. ` + + `Abmelden`; + + 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 }; +} diff --git a/src/modules/courses/actions/booking-actions.ts b/src/modules/courses/actions/booking-actions.ts index 30cdb4c..1b65ca5 100644 --- a/src/modules/courses/actions/booking-actions.ts +++ b/src/modules/courses/actions/booking-actions.ts @@ -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); diff --git a/src/modules/courses/ui/components/BookingDialog.tsx b/src/modules/courses/ui/components/BookingDialog.tsx index 3398884..1fd1fb1 100644 --- a/src/modules/courses/ui/components/BookingDialog.tsx +++ b/src/modules/courses/ui/components/BookingDialog.tsx @@ -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([]); const [selectedPassId, setSelectedPassId] = useState(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(null); + const [consentChecked, setConsentChecked] = useState(false); const spotPickerRef = useRef(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 = { courseId: course.id, seats }; + const body: Record = { + 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 = ({ )} + {!isAdminView && + !isCheckingStatus && + !isFull && + !isBooked && + !showGuestForm && + consentText !== null && ( + + )}
{payWithPass && !isAdminView && !isBooked ? ( diff --git a/src/modules/dashboard/ui/components/DashboardSidebar.tsx b/src/modules/dashboard/ui/components/DashboardSidebar.tsx index e849f59..36f94c4 100644 --- a/src/modules/dashboard/ui/components/DashboardSidebar.tsx +++ b/src/modules/dashboard/ui/components/DashboardSidebar.tsx @@ -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", diff --git a/src/modules/kunden/actions/kunde-detail-actions.ts b/src/modules/kunden/actions/kunde-detail-actions.ts index 90cba70..bcafd4b 100644 --- a/src/modules/kunden/actions/kunde-detail-actions.ts +++ b/src/modules/kunden/actions/kunde-detail-actions.ts @@ -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 { diff --git a/src/modules/kunden/ui/views/kunde-detail-view.tsx b/src/modules/kunden/ui/views/kunde-detail-view.tsx index c437cee..49af105 100644 --- a/src/modules/kunden/ui/views/kunde-detail-view.tsx +++ b/src/modules/kunden/ui/views/kunde-detail-view.tsx @@ -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) : "–"} /> } label="Geburtsdatum" value={birthdayFormatted} /> + } + label="Werbe-Mails" + value={formatMarketingConsent(kunde.marketingConsents)} + /> {/* Kunden-ID über volle Breite */}
diff --git a/src/modules/marketing/actions/consent-actions.ts b/src/modules/marketing/actions/consent-actions.ts new file mode 100644 index 0000000..7d9274b --- /dev/null +++ b/src/modules/marketing/actions/consent-actions.ts @@ -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." }; + } +} diff --git a/src/modules/marketing/actions/editor-actions.ts b/src/modules/marketing/actions/editor-actions.ts new file mode 100644 index 0000000..59cc894 --- /dev/null +++ b/src/modules/marketing/actions/editor-actions.ts @@ -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 = ({ 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, + position: { x: row.positionX, y: row.positionY }, + emailNodeId: row.emailNodeId, + isActive: row.isActive, + }; +} + +async function loadEditor(businessId: string): Promise { + 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> { + 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> { + 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> { + 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> { + 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> { + 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." }; + } +} diff --git a/src/modules/marketing/lib/audit-log.ts b/src/modules/marketing/lib/audit-log.ts new file mode 100644 index 0000000..7d3de09 --- /dev/null +++ b/src/modules/marketing/lib/audit-log.ts @@ -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[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 } : {}), + })), + }); +} diff --git a/src/modules/marketing/lib/compose-email.ts b/src/modules/marketing/lib/compose-email.ts new file mode 100644 index 0000000..829242a --- /dev/null +++ b/src/modules/marketing/lib/compose-email.ts @@ -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), + }); +} diff --git a/src/modules/marketing/lib/consent-text.ts b/src/modules/marketing/lib/consent-text.ts new file mode 100644 index 0000000..c1a1814 --- /dev/null +++ b/src/modules/marketing/lib/consent-text.ts @@ -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 = { + course_booking: "bei einer Kursbuchung", + pass_purchase: "beim Kartenkauf", + profile: "in den Einstellungen", + unsubscribe_link: "über den Abmeldelink", +}; diff --git a/src/modules/marketing/lib/consent.ts b/src/modules/marketing/lib/consent.ts new file mode 100644 index 0000000..6f522cf --- /dev/null +++ b/src/modules/marketing/lib/consent.ts @@ -0,0 +1,121 @@ +import { revalidateTag } from "next/cache"; +import { Prisma } from "@/src/generated/prisma/client"; +import { prisma } from "@/src/lib/prisma"; +import { writeAuditLog } from "./audit-log"; +import { CONSENT_SOURCE_LABELS, CONSENT_TEXT_VERSION, type ConsentSource } from "./consent-text"; + +/** + * Setzt die Einwilligung eines Kunden in Werbe-Mails eines Studios. + * + * Jede tatsächliche Änderung landet im Änderungsprotokoll - das ist der + * Nachweis, wann, wo und zu welchem Wortlaut eingewilligt oder widerrufen + * wurde. Ein erneutes Setzen desselben Zustands ändert nichts und schreibt + * auch nichts ins Protokoll. + * + * @returns true, wenn sich etwas geändert hat + */ +export async function setMarketingConsent(params: { + userId: string; + businessId: string; + granted: boolean; + source: ConsentSource; +}): Promise { + const { userId } = params; + + // Zwei gleichzeitige Anfragen für ein neues Kunde-Studio-Paar (Doppelklick, + // Buchung und Einstellungen zugleich) lesen beide "keine Zeile" und wollen + // beide anlegen - eine scheitert am Unique-Key. Ein zweiter Durchlauf sieht + // dann die Zeile der anderen und gleicht nur noch ab. + let changed: boolean; + try { + changed = await applyConsent(params); + } catch (error) { + if (!(error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002")) { + throw error; + } + changed = await applyConsent(params); + } + + // Das Kunden-Detail des Managers zeigt den Stand der Einwilligung. + if (changed) { + try { + revalidateTag(`kunde-detail-${userId}`, { expire: 0 }); + } catch { + // Außerhalb eines Requests (Skripte, Tests) gibt es keinen Cache. + } + } + return changed; +} + +async function applyConsent(params: { + userId: string; + businessId: string; + granted: boolean; + source: ConsentSource; +}): Promise { + const { userId, businessId, granted, source } = params; + + return prisma.$transaction(async (tx) => { + const current = await tx.marketingConsent.findUnique({ + where: { userId_businessId: { userId, businessId } }, + select: { granted: true }, + }); + if ((current?.granted ?? false) === granted) return false; + + const now = new Date(); + await tx.marketingConsent.upsert({ + where: { userId_businessId: { userId, businessId } }, + create: { + userId, + businessId, + granted, + textVersion: CONSENT_TEXT_VERSION, + source, + grantedAt: granted ? now : null, + revokedAt: granted ? null : now, + }, + update: { + granted, + source, + ...(granted ? { textVersion: CONSENT_TEXT_VERSION, grantedAt: now } : { revokedAt: now }), + }, + }); + + await writeAuditLog(tx, { + businessId, + actorType: "customer", + actorId: userId, + userId, + action: granted ? "consent.granted" : "consent.revoked", + summary: `${granted ? "Einwilligung erteilt" : "Einwilligung widerrufen"} (${CONSENT_SOURCE_LABELS[source]})`, + after: { granted, source, textVersion: CONSENT_TEXT_VERSION }, + }); + + return true; + }); +} + +/** + * Für Buchung und Kartenkauf: das Häkchen darf den eigentlichen Vorgang nie + * scheitern lassen. Ein Fehler wird nur protokolliert. + */ +export async function grantConsentQuietly(params: { + userId: string; + businessId: string; + source: ConsentSource; +}): Promise { + 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 { + const consent = await prisma.marketingConsent.findUnique({ + where: { userId_businessId: { userId, businessId } }, + select: { granted: true }, + }); + return consent?.granted ?? false; +} diff --git a/src/modules/marketing/lib/graph.ts b/src/modules/marketing/lib/graph.ts new file mode 100644 index 0000000..8adae66 --- /dev/null +++ b/src/modules/marketing/lib/graph.ts @@ -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; + 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(); + 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, + emailNodeId: isTrigger ? node.emailNodeId : null, + isActive: isTrigger ? node.isActive : false, + }; + }); +} + +/** Wirksam = eingeschaltet und mit einer E-Mail verbunden. */ +export function isEffective(node: Pick): boolean { + return node.isActive && node.emailNodeId !== null; +} + +export function nodeLabel(node: Pick): 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 = { + 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 | null; + after?: Record | 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; +} diff --git a/src/modules/marketing/lib/node-config.ts b/src/modules/marketing/lib/node-config.ts new file mode 100644 index 0000000..007b3b3 --- /dev/null +++ b/src/modules/marketing/lib/node-config.ts @@ -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 = { + birthday: "Geburtstag", + milestone: "Meilenstein", + winback: "Reaktivierung", +}; + +export const TRIGGER_DESCRIPTIONS: Record = { + 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; +export type WinbackConfig = z.infer; + +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; + +/** 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; + +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> = { + birthday: {}, + milestone: { bookings: 10 }, + winback: { days: 60 }, +}; + +/** Vorlagen für neue E-Mails - das Studio kann alles anpassen. */ +export const EMAIL_TEMPLATES: Record = { + 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 = { + courses: "Kurse", + passes: "Karten", + all: "Kurse und Karten", +}; + +/** "15 %" oder "5,00 €" */ +export function formatRewardValue(reward: Pick): string { + return reward.type === "percent" + ? `${reward.value} %` + : new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format( + reward.value / 100 + ); +} diff --git a/src/modules/marketing/lib/personal-code.ts b/src/modules/marketing/lib/personal-code.ts new file mode 100644 index 0000000..1df57f6 --- /dev/null +++ b/src/modules/marketing/lib/personal-code.ts @@ -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 = { + 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; + } + } +} diff --git a/src/modules/marketing/lib/placeholders.ts b/src/modules/marketing/lib/placeholders.ts new file mode 100644 index 0000000..f7a05dd --- /dev/null +++ b/src/modules/marketing/lib/placeholders.ts @@ -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 = { + "{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, " "); +} diff --git a/src/modules/marketing/lib/run-marketing.ts b/src/modules/marketing/lib/run-marketing.ts new file mode 100644 index 0000000..5c33c15 --- /dev/null +++ b/src/modules/marketing/lib/run-marketing.ts @@ -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 { + 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(); + 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( + 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(); + 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"; + } +} diff --git a/src/modules/marketing/lib/triggers.ts b/src/modules/marketing/lib/triggers.ts new file mode 100644 index 0000000..4104185 --- /dev/null +++ b/src/modules/marketing/lib/triggers.ts @@ -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 = { + 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) + ); +} diff --git a/src/modules/marketing/lib/unsubscribe-token.ts b/src/modules/marketing/lib/unsubscribe-token.ts new file mode 100644 index 0000000..0c4b06c --- /dev/null +++ b/src/modules/marketing/lib/unsubscribe-token.ts @@ -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)}`; +} diff --git a/src/modules/marketing/ui/components/MarketingConsentCheckbox.tsx b/src/modules/marketing/ui/components/MarketingConsentCheckbox.tsx new file mode 100644 index 0000000..c02dd9b --- /dev/null +++ b/src/modules/marketing/ui/components/MarketingConsentCheckbox.tsx @@ -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 ( +
+ onCheckedChange(value === true)} + className="mt-0.5" + /> + +
+ ); +}; + +export default MarketingConsentCheckbox; diff --git a/src/modules/marketing/ui/components/MarketingHistory.tsx b/src/modules/marketing/ui/components/MarketingHistory.tsx new file mode 100644 index 0000000..b106e06 --- /dev/null +++ b/src/modules/marketing/ui/components/MarketingHistory.tsx @@ -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 = { + 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("sends"); + const [sends, setSends] = useState(null); + const [changes, setChanges] = useState(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 ( +
+
+ {( + [ + ["sends", "Versand"], + ["changes", "Änderungen"], + ] as const + ).map(([value, label]) => ( + + ))} +
+ +
+ {rows === null ? ( +

Lade…

+ ) : rows.length === 0 ? ( +

+ {tab === "sends" ? "Noch keine Mails verschickt." : "Noch keine Änderungen."} +

+ ) : tab === "sends" ? ( +
    + {(rows as SendHistoryRow[]).map((row) => ( + + ))} +
+ ) : ( +
    + {(rows as ChangeHistoryRow[]).map((row) => ( +
  • + + {formatDateTime(row.createdAt)} + + {row.summary} + {row.actor} +
  • + ))} +
+ )} +
+ + {nextCursor && ( + + )} +
+ ); +}; + +const SendRow = ({ row }: { row: SendHistoryRow }) => { + const status = STATUS_STYLES[row.status] ?? STATUS_STYLES.pending; + return ( +
  • +
    + {row.customerId ? ( + + {row.customerName || row.customerEmail} + + ) : ( + Gelöschtes Konto + )} + {row.customerName && row.customerEmail && ( +

    {row.customerEmail}

    + )} +
    +
    +

    {row.subject}

    +

    + {isTriggerKind(row.triggerKind) ? TRIGGER_LABELS[row.triggerKind] : row.triggerKind} ·{" "} + {formatDateTime(row.sentAt ?? row.createdAt)} +

    + {row.status === "failed" && row.error && ( +

    + {row.error} +

    + )} +
    +
    + {row.code && ( + + {row.redeemed && } + {row.code} + + )} + + {status.label} + +
    +
  • + ); +}; + +export default MarketingHistory; diff --git a/src/modules/marketing/ui/components/MobileFlowList.tsx b/src/modules/marketing/ui/components/MobileFlowList.tsx new file mode 100644 index 0000000..e6db8c8 --- /dev/null +++ b/src/modules/marketing/ui/components/MobileFlowList.tsx @@ -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 ( +
    +

    + + Trigger und E-Mails bearbeitest du am Computer. Hier kannst du sie ein- und ausschalten. +

    + + {triggers.length === 0 ? ( +

    + Noch keine Automationen angelegt. +

    + ) : ( +
      + {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 ( +
    • + +
      +

      + {trigger.kind === "birthday" + ? TRIGGER_LABELS.birthday + : describeTrigger(trigger.kind, trigger.config)} +

      +

      + + {email ? nodeLabel(email) : "Nicht verbunden"} + {trigger.sent > 0 && ` · ${trigger.sent} verschickt`} +

      +
      + toggle(trigger.id, checked)} + aria-label={`${TRIGGER_LABELS[trigger.kind]} ein- oder ausschalten`} + /> +
    • + ); + })} +
    + )} +
    + ); +}; + +export default MobileFlowList; diff --git a/src/modules/marketing/ui/components/NodeSettingsSheet.tsx b/src/modules/marketing/ui/components/NodeSettingsSheet.tsx new file mode 100644 index 0000000..90473bc --- /dev/null +++ b/src/modules/marketing/ui/components/NodeSettingsSheet.tsx @@ -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) => 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 ( + !open && onClose()}> + + {node && isTriggerNode(node) && } + {node && !isTriggerNode(node) && } + + + ); +}; + +// ─── 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 ( + + onConfigChange(node.id, { + ...config, + [key]: e.target.value === "" ? "" : Math.round(Number(e.target.value)), + }) + } + className={cn(fieldClass, "w-24")} + /> + ); + }; + + return ( + <> + + {TRIGGER_LABELS[kind]} + {TRIGGER_DESCRIPTIONS[kind]} + + +
    + {kind === "milestone" && ( + + )} + {kind === "winback" && ( + + )} + {kind === "birthday" && ( +

    + Wer am 29. Februar Geburtstag hat, bekommt die Mail in anderen Jahren am 28. Februar. +

    + )} + +
    + Führt zu + +
    + +
    +
    +

    Eingeschaltet

    +

    + {activatedAt + ? `Läuft seit ${new Date(activatedAt).toLocaleDateString("de-DE")}` + : "Wirkt nach dem Speichern"} +

    +
    + onActiveChange(node.id, checked)} + /> +
    + +

    + Gezählt wird erst ab dem Einschalten. Wer vorher schon so weit war, bekommt keine Mail - + so werden beim Einschalten nicht alle Bestandskunden auf einmal angeschrieben. Mails gehen + nur an Kunden, die zugestimmt haben, und höchstens eine pro Tag. +

    + +

    + {sent} {sent === 1 ? "Mail" : "Mails"} über diesen Trigger verschickt +

    +
    + + + + + + ); +}; + +// ─── 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(null); + const [isSending, startSending] = useTransition(); + + const parsed = emailConfigSchema.safeParse(config); + const errors = new Map(); + 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) => onConfigChange(node.id, { ...config, ...patch }); + const setReward = (patch: Partial) => + 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) ?

    {errors.get(key)}

    : null; + + return ( + <> + + E-Mail + + Absender ist dein Studio, Antworten landen bei deiner Studio-E-Mail. + + + +
    +
    + Vorlage einsetzen + +
    + + + + + +