Kuma/DEPLOYMENT.md
2026-10-02 17:31:08 +02:00

90 lines
10 KiB
Markdown

# Deployment: Staging (Vercel)
Kumasy läuft als Staging-Umgebung auf Vercel, getriggert über den `dev`-Branch. Dieses Dokument beschreibt den aktuellen Aufbau, damit er reproduzierbar ist und man bei Fehlern schnell die Ursache findet.
Kein Docker: die App ist ein reines Next.js 16 (App Router) Full-Stack-Projekt ohne separaten Server/WebSockets — alles läuft als Vercel Serverless/Edge Functions. Ein eigenes Image würde hier nur Komplexität ohne Nutzen hinzufügen.
## Architektur
- **Hosting:** Vercel, Git-Integration mit `Since-3/Kuma`. Push auf `dev` → automatisches Preview-Deployment (= Staging). `main` ist als Production-Branch gesetzt, aber Production ist bislang **nicht** eingerichtet (kein eigenes Supabase-Projekt, keine Production-Env-Vars) — dieses Dokument behandelt nur Staging.
- **Staging-URL (fest, an `dev` gepinnt):** `https://kumasy-git-dev-kumasy.vercel.app`
- **Region:** Vercel Functions laufen in `fra1` (Frankfurt) — bewusst umgestellt, weil die Default-Region (Cleveland/US East) wegen der Entfernung zur Supabase-DB in `eu-central-1` spürbar langsam war.
- **Datenbank/Auth:** eigenes, separates Supabase-Projekt für Staging (Projekt-Ref `pyxjpsalchmnlyqfnzfw`), **nicht** dasselbe wie Produktion. Postgres + Supabase Auth (E-Mail/Passwort + Google OAuth).
- **Storage:** Supabase Storage, Bucket `avatars` (public) — wird für Profilbilder und Kursbilder verwendet.
- **Zahlungen:** Stripe, Test-Mode. Eigener Webhook-Endpoint für die Staging-URL (Webhook-Secrets sind pro Endpoint eindeutig, können nicht mit Produktion geteilt werden).
- **E-Mail:** Resend, gleicher API-Key wie lokal/Produktion (kein Test/Live-Split wie bei Stripe).
## Environment-Variablen
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"` |
| `CRON_SECRET` | eigener Zufallswert (`openssl rand -hex 32`), schützt `/api/cron/marketing`, siehe Marketing-Cron unten |
| `MARKETING_TOKEN_SECRET` | eigener Zufallswert, signiert die Abmeldelinks in Marketing-Mails - nie ändern, sonst gehen alte Links nicht mehr |
Lokales Pendant für einmalige DB-Operationen gegen Staging (z.B. neue Migrationen einspielen): `.env.staging` im Repo-Root, gitignored (`.env*` steht in `.gitignore`), wird nie committed.
## Marketing-Cron
Automatische Marketing-Mails (Geburtstag, Meilenstein, Reaktivierung) verschickt einmal täglich `GET /api/cron/marketing`. Die Route prüft nur den Header `Authorization: Bearer <CRON_SECRET>` - wer sie aufruft, ist egal:
- **Production:** Vercel Cron aus `vercel.json` (täglich 07:00 UTC = 9 Uhr Sommer / 8 Uhr Winter). Vercel setzt den Header selbst, sobald `CRON_SECRET` als Umgebungsvariable existiert. Vercel Cron läuft **nur auf Production-Deployments**.
- **Staging:** Job bei [cron-job.org](https://cron-job.org) auf `https://kumasy-git-dev-kumasy.vercel.app/api/cron/marketing`, täglich, mit dem Header `Authorization: Bearer <CRON_SECRET aus Vercel Preview>`. Ist die Vercel Deployment Protection für Previews an, zusätzlich `x-vercel-protection-bypass: <Bypass-Secret>` (Project Settings → Deployment Protection → Protection Bypass for Automation).
- **Lokal:** `npm run cron:marketing` (liest `CRON_SECRET` aus `.env`). Zum Testen: `-- --dry-run` (zeigt nur, wer was bekäme), `-- --date=2026-05-12` (tut so, als wäre heute dieser Tag), `-- --business=<id>`.
`date` ist in Production gesperrt. Doppelte Aufrufe sind harmlos: pro Studio, Kunde, Trigger-Art und Zeitraum gibt es höchstens eine Mail (Unique-Key in `MarketingSend`).
## Build Command (Vercel Project Settings → Build & Development Settings)
```
npx prisma generate && npx prisma migrate deploy && next build
```
`package.json` hat kein `postinstall` für Prisma — ohne diesen Override würde der Build mit fehlendem/veraltetem Prisma Client scheitern, und neue Migrationen würden nie automatisch angewendet.
## Bekannte Stolperfallen (aufgetreten während des Erstaufbaus)
1. **`prisma.config.ts` + Vercel = Crash beim Config-Laden.** Prisma 6.19's `env()`-Helper wirft synchron beim Laden der Config, wenn die Variable zu dem Zeitpunkt nicht sichtbar ist (bekannter Prisma-Bug, siehe [prisma/prisma#28607](https://github.com/prisma/prisma/issues/28607)). Fix: in `prisma.config.ts` `process.env.DATABASE_URL!` statt `env("DATABASE_URL")` verwenden.
2. **Supabase Direct Connection ist IPv6-only.** `db.<project-ref>.supabase.co:5432` (die klassische "Direct Connection") funktioniert nicht von Vercels Build-Containern aus (`P1001: Can't reach database server`), weil dort kein IPv6 verfügbar ist. Für `DIRECT_URL` stattdessen den **Session Pooler** verwenden: `aws-0-eu-central-1.pooler.supabase.com:5432` (gleicher Host wie der Transaction Pooler, nur Port 5432 statt 6543, ohne `pgbouncer=true`).
3. **`.env.example` hat einen Fehler:** zeigt `DIRECT_URL` mit Port 6543, obwohl der Kommentar "für Migrationen" sagt. Der Pooler/Direct-Unterschied ist der Port, nicht nur der Host — siehe Punkt 2 für den korrekten Wert.
4. **Schema-Drift zwischen `schema.prisma` und den committeten Migrationen.** Mehrere Felder (`Employee.permissions/roles/locations/onboardingToken`, `Business.stripeAccountId/slug`, `CourseBooking`-Zahlungsfelder, `GuestLead`-Tabelle) existierten im Schema, aber in keiner Migrationsdatei — offenbar wurden sie irgendwann per `prisma db push` direkt an einer DB angelegt, ohne die Migration zu committen. Behoben durch `prisma migrate diff --from-url $DIRECT_URL --to-schema-datamodel prisma/schema.prisma --script`, das Ergebnis als neue Migration (`prisma/migrations/20260729161133_sync_schema_drift/`) committed. **Wichtig für Produktion:** dort existieren dieselben Spalten vermutlich schon (per `db push`) — diese Migration darf dort nicht ausgeführt, sondern muss mit `prisma migrate resolve --applied 20260729161133_sync_schema_drift` als bereits angewendet markiert werden, sonst schlägt `migrate deploy` mit "column already exists" fehl.
5. **Next.js Image Optimization braucht die Storage-Domain explizit.** `next.config.ts` → `images.remotePatterns` muss den Hostnamen des jeweiligen Supabase-Projekts enthalten. Staging-Domain wurde ergänzt, Produktions-Domain bleibt zusätzlich bestehen (dieselbe Datei bedient später auch Production).
6. **Storage-Buckets werden nicht mitkopiert.** Ein neues Supabase-Projekt hat keine Buckets — `avatars` (public) musste manuell im Staging-Projekt angelegt werden, sonst schlagen Uploads mit "Bucket not found" fehl.
## Reihenfolge, falls das Setup je neu aufgebaut werden muss
1. Neues Supabase-Projekt anlegen, Connection-Strings/Keys notieren (Pooler für `DATABASE_URL`, **Session Pooler** für `DIRECT_URL`).
2. Migrationen einmalig lokal einspielen: `npx prisma migrate deploy` mit `.env.staging`.
3. Storage-Bucket `avatars` (public) im neuen Projekt anlegen.
4. Vercel-Projekt importieren, Branch-Domain auf `dev` pinnen (Project Settings → Domains).
5. Region auf `fra1` stellen (Project Settings → Functions).
6. Stripe-Test-Webhook auf die Staging-Domain anlegen, Signing Secret notieren.
7. Alle Env-Vars in Vercel eintragen (Scope: Preview), Build Command überschreiben.
8. Ersten Deploy auslösen, danach Google OAuth in Supabase (Site URL, Redirect URLs) auf die bestätigte Staging-Domain konfigurieren.
9. `next.config.ts` um die neue Storage-Domain ergänzen, committen.
## Verifikations-Checkliste
- [x] Homepage lädt, Bilder rendern (dicebear, Supabase Storage, Google-Avatare)
- [x] Öffentliche Listing-Seite zeigt echte Daten aus der Staging-DB
- [x] E-Mail/Passwort-Registrierung funktioniert
- [x] Google-Login funktioniert Ende-zu-Ende
- [x] Schreibvorgang (z.B. Kurs anlegen) funktioniert
- [x] Bild-Upload rendert korrekt
- [ ] Stripe-Checkout mit Testkarte `4242 4242 4242 4242` — **noch offen**, hängt an Stripe Connect Onboarding
- [ ] Stripe-Webhook zeigt "delivered", `CourseBooking.paymentStatus` wechselt auf `"paid"` — **noch offen**
- [ ] Buchungsbestätigungs-Mail kommt tatsächlich an (Resend) — **noch offen**
- [ ] Stripe Connect Onboarding — **noch offen**
- [x] Beide Sprachen (de/en) spot-gecheckt