Bild Carousel V3

This commit is contained in:
2026-07-12 12:32:30 +02:00
parent d542f849aa
commit c352cb75b5

View File

@@ -0,0 +1,368 @@
# Plan: Public API, Webhooks, Enterprise-Plan, White-Label & Custom Domains
Stand: 2026-07-11 · Autor: Claude (Analyse der Codebase auf Commit `d542f84`)
**Feature-Gating-Übersicht:**
| Feature | FREE | PRO | BUSINESS | ENTERPRISE |
|---|---|---|---|---|
| Webhooks | | ✅ (3 Endpoints) | ✅ (10) | ✅ (unbegrenzt) |
| Public REST API | | | ✅ (1.000 req/h) | ✅ (10.000 req/h) |
| White-Label (Branding aus) | | | | ✅ |
| Custom Domain (CNAME) | | | | ✅ (bis 3 Domains) |
| Dynamische QRs | 3 | 50 | 500 | unbegrenzt |
| Preis | 0 € | 9 €/M | 29 €/M | 149 €/M · 1.490 €/J |
Empfohlene Reihenfolge: **Phase 1 → 2 → 3** ist launchbar als „Enterprise light"
(CNAME als „coming soon"), Phase 4 + 5 danach.
---
## Phase 1 — Enterprise-Plan (≈ 12 Tage)
### 1.1 Datenbank (manuell per SQL, gemäß DB-Policy — kein Migrate)
```sql
ALTER TYPE "Plan" ADD VALUE 'ENTERPRISE';
```
Danach: `prisma/schema.prisma``enum Plan { FREE PRO BUSINESS ENTERPRISE }`,
dann `npx prisma generate`.
### 1.2 Stripe
- Im Stripe-Dashboard zwei Prices anlegen: **149 €/Monat**, **1.490 €/Jahr**
(Produkt „QR Master Enterprise").
- Neue Env-Vars: `STRIPE_PRICE_ID_ENTERPRISE_MONTHLY`, `STRIPE_PRICE_ID_ENTERPRISE_YEARLY`
(auch in `env.example` ergänzen).
### 1.3 Code-Änderungen
| Datei | Änderung |
|---|---|
| `src/lib/plans.ts` | `ENTERPRISE_DYNAMIC_QR_LIMIT` sauber definieren (Wert `-1` = unbegrenzt statt 99999, oder 99999 belassen — Konsistenz mit `staticQRCodes: -1` prüfen) |
| `src/lib/stripe.ts` | `STRIPE_PLANS.ENTERPRISE` mit Features-Liste + beiden priceIds; `getPlanFromStripePriceId()` um ENTERPRISE-Zweig erweitern (VOR dem BUSINESS-Check einfügen) |
| Pricing-Page (`(marketing)`) | 4. Spalte Enterprise; Features: „Alles aus Business", „Public API 10k req/h", „Unbegrenzte Webhooks", „White-Label", „Custom Domain (CNAME)", „Priority Support & SLA", „AV-Vertrag (DPA)" |
| Checkout/Portal-Routen (`api/stripe/*`) | Prüfen, ob Plan-Namen irgendwo hart kodiert sind (`grep -r "BUSINESS" src/app/(main)/api/stripe`) |
| Webhook-Handler `api/stripe/webhook/route.ts` | Sollte über `getPlanFromStripePriceId()` automatisch funktionieren — verifizieren |
### 1.4 Zentrale Gating-Helper (Grundlage für alle weiteren Phasen)
Neue Datei `src/lib/entitlements.ts`:
```ts
export const ENTITLEMENTS = {
FREE: { webhooks: 0, api: false, apiRatePerHour: 0, whiteLabel: false, customDomains: 0 },
PRO: { webhooks: 3, api: false, apiRatePerHour: 0, whiteLabel: false, customDomains: 0 },
BUSINESS: { webhooks: 10, api: true, apiRatePerHour: 1000, whiteLabel: false, customDomains: 0 },
ENTERPRISE: { webhooks: -1, api: true, apiRatePerHour: 10000, whiteLabel: true, customDomains: 3 },
} as const;
```
Alle Feature-Checks laufen NUR über diese Datei — nie `plan === 'PRO'` inline.
### 1.5 Verifikation
- Test-Checkout mit Stripe-Testkarte (4242…) auf Enterprise monatlich + jährlich.
- Downgrade/Upgrade über Customer Portal → `plan`-Feld in DB prüfen.
---
## Phase 2 — Webhooks (≈ 34 Tage) · ab PRO
### 2.1 Datenbank (raw SQL)
```sql
CREATE TABLE "WebhookEndpoint" (
"id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
"userId" TEXT NOT NULL REFERENCES "User"("id") ON DELETE CASCADE,
"url" TEXT NOT NULL,
"secret" TEXT NOT NULL, -- whsec_..., wird nur 1x angezeigt
"events" TEXT[] NOT NULL DEFAULT '{qr.scanned}',
"active" BOOLEAN NOT NULL DEFAULT true,
"failCount" INTEGER NOT NULL DEFAULT 0, -- für Auto-Disable
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX "WebhookEndpoint_userId_idx" ON "WebhookEndpoint"("userId");
CREATE TABLE "WebhookDelivery" (
"id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
"endpointId" TEXT NOT NULL REFERENCES "WebhookEndpoint"("id") ON DELETE CASCADE,
"event" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"statusCode" INTEGER,
"success" BOOLEAN NOT NULL DEFAULT false,
"attempts" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX "WebhookDelivery_endpointId_createdAt_idx"
ON "WebhookDelivery"("endpointId", "createdAt");
```
Schema.prisma nachziehen, `prisma generate`. Deliveries nach 30 Tagen löschen
(einfacher Cleanup beim Dispatch oder Cron).
### 2.2 Dispatch-Modul `src/lib/webhooks.ts`
- `dispatchEvent(userId, event, data)`:
1. Aktive Endpoints des Users laden, die `event` abonniert haben.
2. Payload bauen (s. u.), HMAC-SHA256 über den Raw-Body mit `secret`.
3. POST mit Headers `X-QRMaster-Signature: sha256=<hex>`,
`X-QRMaster-Event`, `X-QRMaster-Delivery-Id`; Timeout 5 s.
4. Retry inline: max. 3 Versuche (0 s / 10 s / 60 s Backoff) — bewusst ohne
Job-Queue, fire-and-forget wie `trackScan`.
5. `WebhookDelivery` loggen; bei Erfolg `failCount = 0`, bei Fehlschlag
`failCount++`; ab 20 Fehlschlägen in Folge `active = false` + E-Mail an
User (via `src/lib/email.ts`).
- **SSRF-Schutz (wichtig!)**: URL-Validierung beim Anlegen UND vor jedem
Request — nur `https://`, DNS auflösen und private/interne IP-Ranges
blocken (127.0.0.0/8, 10/8, 172.16/12, 192.168/16, 169.254/16, ::1).
Payload-Format (stabil halten, das ist API-Vertrag):
```json
{
"id": "evt_...",
"event": "qr.scanned",
"createdAt": "2026-07-11T12:00:00Z",
"data": {
"qrId": "...", "slug": "...", "name": "...",
"scan": { "ts": "...", "country": "DE", "city": "Berlin",
"device": "mobile", "os": "iOS", "isUnique": true }
}
}
```
Kein `ipHash` im Payload (Datenschutz).
### 2.3 Event-Quellen einhängen
| Event | Ort |
|---|---|
| `qr.scanned` | `src/app/(main)/r/[slug]/route.ts` → am Ende von `trackScan()` (dort liegen bereits alle Geo/Device-Daten vor) |
| `qr.created` / `qr.updated` / `qr.deleted` | `api/qrs/route.ts` + `api/qrs/[id]/route.ts` nach erfolgreichem DB-Write |
DNT-Verhalten spiegeln: wenn `trackScan` wegen DNT keinen Scan schreibt,
auch kein `qr.scanned`-Event feuern.
### 2.4 Management-API (Session-Auth, CSRF wie bestehende Routen)
- `GET/POST /api/webhooks` — Liste / Anlegen (Zod-Schema in `validationSchemas.ts`;
Limit aus `ENTITLEMENTS[plan].webhooks` prüfen; Secret `whsec_` + 32 random
Bytes generieren, im Response einmalig zurückgeben)
- `PATCH/DELETE /api/webhooks/[id]` — aktivieren/deaktivieren/URL/Events ändern, löschen
- `POST /api/webhooks/[id]/test` — Test-Event `webhook.test` senden, Response-Status zurückgeben
- `GET /api/webhooks/[id]/deliveries` — letzte 50 Deliveries
### 2.5 UI
Neuer Tab/Bereich in `src/app/(main)/(app)/settings/page.tsx` (oder eigene
Seite `settings/webhooks`): Endpoint-Liste mit Status-Badge, Anlegen-Dialog
(URL + Event-Checkboxen), Secret-einmal-anzeigen-Modal (Copy-Button),
„Test senden"-Button, Delivery-Log aufklappbar. Für FREE: Upsell-Karte.
### 2.6 Doku + Verifikation
- Doku-Seite `/docs/webhooks` (Signatur-Verifikations-Beispiel in Node/PHP).
- Test end-to-end mit webhook.site + lokalem Scan; Signatur nachrechnen;
Retry testen (Endpoint der 500 liefert); SSRF-Check (`http://localhost` muss abgelehnt werden).
---
## Phase 3 — Public REST API (≈ 46 Tage) · ab BUSINESS
### 3.1 Datenbank (raw SQL)
```sql
CREATE TABLE "ApiKey" (
"id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
"userId" TEXT NOT NULL REFERENCES "User"("id") ON DELETE CASCADE,
"name" TEXT NOT NULL,
"keyHash" TEXT NOT NULL UNIQUE, -- SHA-256 des vollen Keys
"keyPrefix" TEXT NOT NULL, -- "qrm_live_a1b2c3" für UI-Anzeige
"lastUsedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX "ApiKey_userId_idx" ON "ApiKey"("userId");
```
Key-Format: `qrm_live_` + 32 random Bytes base62. Lookup über SHA-256-Hash
(deterministisch → `keyHash` direkt per `findUnique`, kein bcrypt nötig,
Keys haben genug Entropie).
### 3.2 Auth- & Rate-Limit-Middleware `src/lib/apiAuth.ts`
- `authenticateApiKey(request)`: `Authorization: Bearer qrm_live_...`
Hash → Key + User laden → revoked prüfen → `ENTITLEMENTS[plan].api`
prüfen → `lastUsedAt` throttled updaten (max. 1×/min) →
`{ userId, plan }` zurückgeben oder 401/403.
- Rate-Limiting **Redis-basiert** (nicht das in-memory `rateLimit.ts`):
neuer `src/lib/redisRateLimit.ts` mit Sliding Window
(`INCR` + `EXPIRE` pro `apikey:{id}:{hourBucket}` reicht).
Limits aus `ENTITLEMENTS`. Fallback ohne Redis: in-memory mit Warnung.
Standard-Header `X-RateLimit-*` + `Retry-After` bei 429.
### 3.3 Service-Layer-Refactoring (der eigentliche Aufwand)
Kernlogik aus den Session-Routen in `src/lib/services/qrService.ts`
extrahieren, damit Session-Routen und v1-Routen dieselbe Logik nutzen:
`listQrs(userId)`, `createQr(userId, plan, input)` (inkl. Plan-Limit-Check),
`updateQr`, `deleteQr`, `getAnalyticsSummary(userId, qrId, range)`.
Bestehende Routen auf den Service umstellen — **keine Logik duplizieren**.
### 3.4 Endpoints unter `src/app/(main)/api/v1/`
Kein CSRF (Bearer-Auth), JSON-Fehlerformat `{ "error": { "code", "message" } }`:
| Endpoint | Beschreibung |
|---|---|
| `GET /api/v1/qr-codes` | Liste, Pagination `?limit=&cursor=` |
| `POST /api/v1/qr-codes` | Anlegen (gleiche Zod-Schemas wie intern) |
| `GET/PATCH/DELETE /api/v1/qr-codes/{id}` | Einzeloperationen |
| `GET /api/v1/qr-codes/{id}/analytics?from=&to=` | Scans, Unique, Country/Device-Breakdown |
| `GET /api/v1/qr-codes/{id}/image?format=png\|svg&size=` | QR-Bild (nutzt `src/lib/qr.ts`) |
| `GET /api/v1/me` | Plan, Limits, Verbrauch (guter Smoke-Test-Endpoint) |
Versionierung im Pfad (`/v1/`) — Breaking Changes später nur via `/v2/`.
### 3.5 Key-Verwaltung
- Session-Routen `GET/POST /api/api-keys`, `DELETE /api/api-keys/[id]` (= revoke).
- UI in Settings: Key-Liste (Prefix, lastUsed), „Create key" mit
Einmal-Anzeige-Modal, Revoke mit Confirm. Max. 5 Keys/User.
- Gating: FREE/PRO sehen Upsell auf Business.
### 3.6 Doku (Pflicht, sonst wertlos)
Marketing-Seite `/docs/api`: Auth, Rate Limits, alle Endpoints mit
curl-Beispielen, Fehlercodes, Webhook-Sektion verlinken. OpenAPI-Spec
(`public/openapi.json`) optional als Follow-up.
### 3.7 Verifikation
curl-Suite gegen lokalen Dev-Server: 401 ohne Key, 403 mit PRO-Key,
CRUD-Roundtrip mit Business-Key, 429 nach Limit, revoked Key → 401.
---
## Phase 4 — White-Label (≈ 12 Tage) · ENTERPRISE
### 4.1 Scope
„Powered by QR Master"-Branding entfernen auf allen Seiten, die Endkunden
beim Scannen sehen: vCard-Display, Text-Display, Coupon-, Feedback-Seiten
(`grep -ri "qr master\|qrmaster" src/app` über die Display-Seiten, um alle
Stellen zu finden).
### 4.2 Umsetzung
- User-Felder (raw SQL): `whiteLabelEnabled BOOLEAN DEFAULT false`,
optional `brandLogoUrl TEXT`, `brandColor TEXT`.
- Display-Seiten laden den QR-Owner sowieso → Branding-Block konditional
auf `ENTITLEMENTS[plan].whiteLabel && whiteLabelEnabled`.
- Settings-Toggle (nur Enterprise sichtbar), optional Logo-Upload via
bestehendem S3-Setup.
- Wichtig: Beim Downgrade (Stripe-Webhook) `whiteLabelEnabled` NICHT löschen,
aber Anzeige-Check läuft immer über den aktuellen Plan → verhält sich korrekt.
---
## Phase 5 — Custom Domains / CNAME (≈ 58 Tage) · ENTERPRISE
Der technisch riskanteste Teil — TLS für fremde Domains ist Infra-Arbeit.
### 5.1 Datenbank (raw SQL)
```sql
CREATE TABLE "CustomDomain" (
"id" TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
"userId" TEXT NOT NULL REFERENCES "User"("id") ON DELETE CASCADE,
"domain" TEXT NOT NULL UNIQUE, -- z. B. qr.kunde.de (lowercase)
"verifyToken" TEXT NOT NULL, -- für TXT-Record
"verifiedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
### 5.2 Verifizierungs-Flow
1. User trägt Domain ein → App zeigt zwei DNS-Records:
`TXT _qrmaster-verify.qr.kunde.de = <verifyToken>` und
`CNAME qr.kunde.de → qrmaster.net`.
2. `POST /api/domains/[id]/verify` prüft beides via Node `dns.promises.resolveTxt/resolveCname`.
3. Erst nach Verifizierung wird die Domain aktiv (verhindert Domain-Hijacking).
### 5.3 Request-Routing in Next.js
- `src/middleware.ts` (bzw. erweitern, falls vorhanden): wenn `Host`
qrmaster.net/localhost → rewrite auf `/r/...` Namespace; auf
Fremd-Domains NUR `/r/[slug]` bedienen, alles andere → 404
(keine Login-/Marketing-Seiten unter Kundendomains, SEO/Sicherheit).
- `/r/[slug]`-Route: bei Fremd-Host prüfen, dass der Slug-Owner die
verifizierte Domain besitzt — sonst 404 (verhindert, dass fremde QRs
über eine Kundendomain laufen).
- Redis-Cache für Domain-Lookups (Hostname → userId, TTL 5 min).
### 5.4 TLS — Caddy als Reverse Proxy (Docker-Deployment)
`docker-compose.yml`: Caddy-Service vor `web`, Ports 80/443, Volume für Zertifikate.
```caddyfile
{
on_demand_tls {
ask http://web:3050/api/domains/check # gibt 200 nur für verifizierte Domains
}
}
https:// {
tls { on_demand }
reverse_proxy web:3050
}
qrmaster.net, www.qrmaster.net {
reverse_proxy web:3050
}
```
- Neuer interner Endpoint `GET /api/domains/check?domain=` → 200 wenn
verifiziert, sonst 403 (schützt vor Zertifikats-Missbrauch/Let's-Encrypt-Rate-Limits).
- Falls qrmaster.net aktuell hinter Cloudflare/anderem Proxy läuft:
vorher klären, wie Port 443 heute terminiert wird — Caddy ersetzt bzw.
ergänzt das bestehende Setup. **Vor Umbau: aktuelles docker-compose.yml
und DNS-Setup prüfen** (größte Unbekannte in diesem Plan).
### 5.5 UI
Settings-Bereich „Custom Domains" (nur Enterprise): Domain hinzufügen,
DNS-Anleitung mit Copy-Buttons, Verify-Button mit Status, Limit 3 Domains.
QR-Detail/Generator: Kurz-URL-Anzeige nutzt die Custom Domain, wenn vorhanden.
### 5.6 Verifikation
Lokal mit `/etc/hosts`-Eintrag + Caddy testen; auf dem Server mit einer
eigenen Test-Domain (z. B. `qr.timo-test.de`) den kompletten Flow durchspielen,
bevor es an Kunden geht.
---
## Querschnittsthemen
- **Downgrade-Verhalten**: Bei Plan-Downgrade (Stripe-Webhook) Webhooks über
Limit deaktivieren (nicht löschen), API-Keys behalten aber `api: false`
greift beim Auth-Check, Custom Domains deaktivieren. Ein zentraler
`enforceEntitlements(userId)` im Stripe-Webhook-Handler.
- **Abuse**: API-Key-Erstellung rate-limiten; Webhook-URLs SSRF-geprüft;
Delivery-Payloads klein halten.
- **Doku/SEO**: `/docs/api` + `/docs/webhooks` sind auch Marketing-Seiten
(„QR Code API") — lohnt eigene SEO-Behandlung wie die Growth-Pages.
## Zeitplan gesamt
| Phase | Aufwand | Abhängig von |
|---|---|---|
| 1 Enterprise-Plan + Entitlements | 12 Tage | — |
| 2 Webhooks | 34 Tage | Phase 1 (Gating) |
| 3 Public API | 46 Tage | Phase 1; Payload-Format aus Phase 2 wiederverwenden |
| 4 White-Label | 12 Tage | Phase 1 |
| 5 Custom Domains | 58 Tage | Phase 1; Infra-Klärung (Proxy/DNS) vorab |
| **Gesamt** | **≈ 1422 Arbeitstage** | Launch von „Enterprise light" nach Phase 13 möglich |