Merge lane/testphase in feature/craftvia-mvp

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	prisma/schema.prisma
#	scripts/test-e2e-tenant-isolation.ts
#	src/server/backup/topology.ts
#	src/server/db.ts
#	src/server/dsgvo/pii-fields.ts
This commit is contained in:
2026-09-15 19:16:19 +02:00
83 changed files with 4703 additions and 23 deletions
+6
View File
@@ -96,6 +96,12 @@ TRANSCRIPTION_MODEL=whisper-1
# --- KI: Kostenbremse & Aufbewahrung KI-Protokoll ---
# Tokens je Mandant je Kalendermonat (ein+aus), 0 = unbegrenzt.
AI_MONTHLY_TOKEN_LIMIT=0
# --- Craftvia: Testphase (L15, docs/craftvia/TESTPHASE.md) ---
# Längste selbst gewählte Testphase in Tagen (1–365); Vorbelegung im Wizard heute + 14.
TRIAL_MAX_DAYS=30
# Kontakt in Banner und Testphasen-Mails (Vollversion/Verlängerung); leer = allgemeiner Hinweis.
TRIAL_CONTACT_EMAIL=
# Ein-/Ausgaben im KI-Protokoll (AiGeneration) nach N Tagen leeren/pseudonymisieren.
AI_GENERATION_RETENTION_DAYS=180
+6
View File
@@ -129,6 +129,12 @@ AI_GENERATION_RETENTION_DAYS=180
# Import-Extraktion ab. 0 = unbegrenzt.
AI_MONTHLY_TOKEN_LIMIT=0
# --- Craftvia: Testphase (L15, docs/craftvia/TESTPHASE.md) ---
# Längste selbst gewählte Testphase in Tagen (1–365); Vorbelegung im Wizard heute + 14.
TRIAL_MAX_DAYS=30
# Kontakt in Banner und Testphasen-Mails (Vollversion/Verlängerung); leer = allgemeiner Hinweis.
TRIAL_CONTACT_EMAIL=
# --- Craftvia: Planung – Karten & Geocoding (L13) ---
# Adresse → Koordinaten nur serverseitig im Worker (Job geocode-site, max. 1 Anfrage/s),
# Ergebnis wird am Objekt gespeichert. nominatim | none (none = keine Verortung, Objekte
+6
View File
@@ -99,6 +99,12 @@ TRANSCRIPTION_MODEL=whisper-1
# Tokens (ein + aus) je Mandant je Kalendermonat; darüber lehnen Lotse und
# Import-Extraktion ab. 0 = unbegrenzt.
AI_MONTHLY_TOKEN_LIMIT=0
# --- Craftvia: Testphase (L15, docs/craftvia/TESTPHASE.md) ---
# Längste selbst gewählte Testphase in Tagen (1–365); Vorbelegung im Wizard heute + 14.
TRIAL_MAX_DAYS=30
# Kontakt in Banner und Testphasen-Mails (Vollversion/Verlängerung); leer = allgemeiner Hinweis.
TRIAL_CONTACT_EMAIL=
# Nach N Tagen leert/pseudonymisiert ein Worker-Job Ein-/Ausgaben im KI-Protokoll
# (AiGeneration). Frist mit dem DSB abstimmen.
AI_GENERATION_RETENTION_DAYS=180
+6
View File
@@ -96,3 +96,9 @@ Die Pfade sind relativ zu `/api/v1`. „Recht“ nennt das Gate der Route. Mit
| GET | `/planning/recommendations` | work_orders | `work_order:assign` | Einsatz-Empfehlungen (Luftlinie + freie Kapazität, Top 5) + nahe ungeplante Aufträge; nur Vorschläge |
| GET | `/planning/live` | work_orders | Service: `work_order:read_all` oder Teamleiter | Live-Lage: Status aus aktiver WorkSession, Standort = Objekt des Auftrags, keine Geräte-Koordinaten |
| GET | `/openapi.json` | – | angemeldet | OpenAPI-3.1-Dokument |
## Testphase: Nur-Lesen nach Ablauf (L15)
Für abgelaufene Testmandanten beantworten alle nicht lesenden Anfragen (POST/PUT/PATCH/DELETE, inkl.
`/sync` und `/uploads`) mit **422** `{ "error": { "code": "blocked", "message": "trial_expired", "details": { "readOnly": true, "message": "…", "deletionDueAt": "…" } } }`.
GET-Anfragen, Datei-Downloads und PDFs bleiben erlaubt. Siehe [TESTPHASE.md](TESTPHASE.md).
+9
View File
@@ -208,6 +208,15 @@ Metadaten (Art, Modell, Tokens, Zeitpunkt, Bezug) bleiben für Kosten- und Nachv
je Mandant wird ein Audit-Eintrag `ai_generation_retention` geschrieben. Die Frist mit dem DSB abstimmen.
Die Variable muss im `craftvia-worker` gesetzt sein.
### 7.4 Testphase (L15)
Öffentliche Selbstanmeldung unter `/testen`, Plattform-Wizard unter `/admin/trial`. Variablen
`TRIAL_MAX_DAYS` (Default 30) und `TRIAL_CONTACT_EMAIL`. Der `craftvia-worker` legt beim Start den
täglichen Job-Scheduler `trial-lifecycle-daily` an (Erinnerungen, Ablauf, Löschung nach 30 Tagen) und
verarbeitet `tenant-export` (Datenexport). Details, Schreibsperre und manueller Lauf:
[TESTPHASE.md](TESTPHASE.md). Vor dem Go-live die Platzhaltertexte für Nutzungsbedingungen und
Datenschutz (`messages/*/trial.json` → `legal.*`) ersetzen.
## 8. Rate Limits
| Bereich | Env | Default | Zählung |
+67
View File
@@ -0,0 +1,67 @@
# Craftvia – Testphase & Onboarding (Betrieb)
Stand: 2026-09-16 · Lane L15 · Lane-Bericht: [lanes/testphase.md](lanes/testphase.md)
Craftvia lässt sich als zeitlich begrenzte Testversion anbieten – per öffentlicher Selbstanmeldung
(`/testen`) oder durch den Plattform-Admin (`/admin/trial`).
## 1. Lebenszyklus
| Phase | Zustand | Was gilt |
|---|---|---|
| Anmeldung | `TrialSignup.status = pending` | Nichts provisioniert. Bestätigungslink 24 h gültig, einmal verwendbar. |
| Testphase | `Tenant.plan = TRIAL`, `now < trialEndsAt` | Voll nutzbar. Banner „Testphase endet in X Tagen“ ab 7 Tagen vor Ende. |
| Abgelaufen | `now ≥ trialEndsAt` | **Nur lesen + Export.** Login, Lesen, Datei-Downloads, PDFs, Export bleiben möglich. |
| Löschung | `now ≥ deletionDueAt` (Standard: Ende + 30 Tage) | Täglicher Job löscht den Mandanten (Offboarding aller Mandanten-Tabellen + Speicher `<tenantId>/`), Mandant `ARCHIVED`. |
| Umgewandelt | `plan = FULL`, `convertedAt` | Nie Nur-Lesen, nie automatische Löschung. |
`trialEndsAt` ist das **exklusive** Ende: Beginn des Folgetags des gewählten Datums in Europe/Berlin.
Die Sperre hängt nur an diesem Zeitpunkt, nicht am Job – sie greift sekundengenau.
Plattform-Aktionen (Mandantendetail, Karte „Testphase“, jeweils mit Bestätigung und Plattform-Audit):
Enddatum ändern/verlängern (auch nach Ablauf → sofort wieder schreibbar, Erinnerungen neu geplant),
in Vollversion umwandeln, Testphase sofort beenden, Löschung vormerken (Ende + 30 Tage, frühestens in
7 Tagen) bzw. abbrechen. Nur Plattform-**Voll**-Admins; Mandanten-Admins haben keinen Weg dorthin.
## 2. Konfiguration
| Variable | Default | Bedeutung |
|---|---|---|
| `TRIAL_MAX_DAYS` | `30` | Längste selbst gewählte Testphase (1–365). Vorbelegung im Wizard: heute + 14 (höchstens `TRIAL_MAX_DAYS`). Der Plattform-Admin darf bis 365 Tage setzen. |
| `TRIAL_CONTACT_EMAIL` | leer | Kontakt in Banner und Testphasen-Mails („Vollversion oder Verlängerung: …“). Leer = allgemeiner Hinweis. |
Weitere Voraussetzungen: `APP_BASE_URL` (Links in Mails), funktionierender Mailversand (Double-Opt-in),
S3/Garage (Export-Dateien), `REDIS_URL` + `craftvia-worker` (Jobs). Rate-Limits der öffentlichen
Endpunkte sind fest (je App-Instanz, siehe `src/server/rate-limit.ts`): Anmeldung 5/h je IP und je
E-Mail, Bestätigung 20/15 min je IP, Schrittprüfung 120/15 min je IP.
## 3. Jobs (`npm run worker:craftvia`)
| Queue | Auslöser | Inhalt |
|---|---|---|
| `trial-lifecycle` | Job-Scheduler `trial-lifecycle-daily` (alle 24 h, beim Worker-Start angelegt) | Erinnerungen 7/3/1 Tage vorher, Ablaufmail, Löschhinweis 7 Tage vor Löschung, Löschung, Aufräumen alter Anmeldungen (7 Tage nach Link-Ablauf). Idempotent: jede Mail wird vor dem Versand über eine bedingte Aktualisierung „beansprucht“. |
| `tenant-export` | „Export erstellen“ unter `/settings/export` | ZIP (CSV + JSON + Dateien) nach `<tenantId>/uploads/…`, Download 7 Tage über `/settings/export/<id>` (Sitzung + `tenant:manage`). Ohne Redis läuft der Export inline. |
Manueller Lauf (z. B. nach Ausfall des Workers), im App- oder Worker-Container:
```bash
node --import tsx -e "import('./src/server/services/trial/lifecycle.ts').then(m => m.runTrialLifecycle()).then(console.log)"
```
Im Worker werden Jobs, die im Namen von Nutzern schreiben (`import-extraction`, `transcription`), für
abgelaufene Testmandanten übersprungen. PDFs/Vorschaubilder bereits gespeicherter Daten laufen weiter.
## 4. Schreibsperre – wo sie greift
- Server Actions der Module: `moduleGuard` (`src/server/action-guard.ts`) → `ServiceError("blocked", "trial_expired")`.
- `/api/v1`: `requireApiContext` sperrt jede nicht lesende Anfrage, die über `withApi` läuft (Sync, Uploads, alle Mutationen) → HTTP 422 `{ error: { code: "blocked", message: "trial_expired", details: { readOnly: true, message, deletionDueAt } } }`. Die Offline-Outbox behandelt 422 beim Sync-Batch als vorübergehend und wiederholt später.
- Routen außerhalb von `withApi`: Backoffice-Upload `/documents/upload`, `POST /api/v1/work-orders/[id]/documents` (explizit).
- Einstellungen, Nutzer-/Rollenverwaltung, Lotse-Einstellungen (EXEMPT-Actions, explizit).
- Nicht gesperrt: Login, Konto (eigenes Passwort, MFA, Sprache), Mandantenwechsel, Lesen, `/files/<id>`, Export, Plattform-Aktionen.
## 5. Datenschutz
- Anmeldung speichert Passwort nur als Argon2id-Hash (mit Pepper), Link-Token nur als SHA-256, IP nur als HMAC. Der Passwort-Hash wird nach Bestätigung/Ablauf aus der Anmeldung entfernt; Anmeldungen werden 7 Tage nach Link-Ablauf gelöscht.
- Enumeration-Schutz: Für bereits registrierte Adressen gleiche Antwort, aber Hinweis-Mail statt Link.
- Nutzungsbedingungen und Datenschutzhinweise unter `/testen/nutzungsbedingungen` bzw. `/testen/datenschutz` sind **Platzhalter** (Texte in `messages/<locale>/trial.json` → `legal.*`) und vor dem Go-live vom Betreiber zu ersetzen.
- Löschung über das bestehende DSGVO-Offboarding (Löschnachweis `DeletionCertificate`), zusätzlich Objektspeicher-Präfix `<tenantId>/`.
+108
View File
@@ -0,0 +1,108 @@
# Lane L15 – Testphase & Onboarding (`lane/testphase`)
Stand: 2026-09-16 · Basis `6b8cdf5` (`feature/craftvia-mvp`, L1–L14 integriert) · Betriebsdoku: [../TESTPHASE.md](../TESTPHASE.md)
## 1. Umfang / erfüllte Punkte
| Punkt | Umsetzung |
|---|---|
| **1 Datenmodell** | Migration `20260916100000_testphase`: `Tenant.plan` (`FULL`/`TRIAL`), `trialSource`, `trialStartedAt`, `trialEndsAt` (exklusives Ende = Beginn des Folgetags in Europe/Berlin), `convertedAt`, `readOnlySince`, `deletionDueAt`, `trialDeletedAt`, Versandmarker `trialReminder7/3/1At`, `trialExpiredNoticeAt`, `trialDeletionNoticeAt` (Plattform-Daten, keine RLS). `TrialSignup` (Plattform-Tabelle ohne `tenant_id`): Firmendaten, E-Mail, Argon2id-Hash (+ Pepper, wird nach Bestätigung/Ablauf geleert), Enddatum, Beispieldaten/Module, Token-**SHA-256**, Ablauf 24 h, IP-**HMAC**, Status. `TenantExport` (Mandanten-Tabelle, `enable_tenant_rls`, beide `TENANT_MODELS`, `requestedById` in `pii-fields.ts`). `TenantSettings.onboarding` (Checkliste). |
| **2 Öffentlicher Wizard** `/testen` | 5 Schritte (Betrieb · Admin-Konto · Testzeitraum · Einrichtung · Zusammenfassung), Fortschrittsanzeige, alle Werte in einem State (Zurück ohne Datenverlust), Validierung je Schritt im Browser **und** per Server-Action (`checkTrialStepAction`, gleiche Regeln aus `lib/trial/signup.ts`), finale Prüfung serverseitig. Branche: Auswahlliste + Freitext, Betriebsgröße optional, Passwort-Policy wie Identity, Enddatum per Datumsfeld (Vorbelegung heute + 14, erlaubt morgen … heute + `TRIAL_MAX_DAYS`, Anzeige „Testphase bis TT.MM.JJJJ (X Tage)“), Beispieldaten ja/nein, Module vorausgewählt (abwählbar), Pflicht-Checkboxen mit Platzhalter-Seiten `/testen/nutzungsbedingungen`, `/testen/datenschutz`. Double-Opt-in-Mail → `/testen/bestaetigen` zeigt die Anmeldung, **Einrichten erst per POST** (Mail-Scanner verbrauchen nichts) → `provisionTrialTenant` (tenant-admin, TRIAL, Ende des Tages Berlin, Löschung +30 Tage) → **direkt angemeldet** über den bestehenden `login-ticket`-Provider → `/dashboard?welcome=1` mit **„Erste Schritte“** (Firmendaten, Team, Monteur einladen, erster Auftrag, mobile App; automatisch erkannt oder abhakbar, ausblendbar). Missbrauchsschutz: Rate-Limit je IP und je E-Mail (neue Scopes `trialSignup`, `trialConfirm`, `trialStepCheck`), Honeypot, identische Antwort bei vorhandener E-Mail (+ Hinweis-Mail, gleicher Hash-Aufwand gegen Timing), Slug-Kollisionen (Reservierung per `create`, `-2 … -20`, Zufallssuffix), ältere Links derselben Adresse entwertet. |
| **3 Plattform-Admin** | `/admin/trial` „Testmandant anlegen“ (Firma, Branche, Admin, Enddatum bis 1 Jahr, Beispieldaten) → Einladung über `issueToken("invitation")` + `sendUserInvitationMail` (bestehende Person: nur verknüpft). Mandantenliste: Spalte „Version“ mit Badges „Test bis …“ / „abgelaufen – nur lesen“ / „Löschung am …“ / „gelöscht“ / „Vollversion“, Filter Alle/Test/Voll. Mandantendetail: Karte „Testphase“ mit Enddatum ändern/verlängern (auch nach Ablauf), umwandeln, sofort beenden, Löschung vormerken/abbrechen – jeweils Popup mit Bestätigungs-Checkbox, nur Voll-Admins, Audit (scope platform, am Mandanten) mit before/after. |
| **4 Nur-Lesen nach Ablauf** | `services/trial/state.ts#assertTenantWritable` → `ServiceError("blocked", "trial_expired", { readOnly, message, deletionDueAt })`, zeitgenau über `trialEndsAt`. Zentral in `moduleGuard` (alle Modul-Actions inkl. Lotse), `requireApiContext` für jede nicht lesende `/api/v1`-Anfrage (Methode aus `withApi`, AsyncLocalStorage → 422; Sync, Uploads eingeschlossen), explizit in `/documents/upload`, `POST /api/v1/work-orders/[id]/documents` (ohne `withApi`), Einstellungen, Nutzer-/Rollenverwaltung, Lotse-Einstellungen, Onboarding. Worker überspringt `import-extraction`/`transcription` abgelaufener Mandanten. Offen bleiben Login, Konto, Lesen, `/files`, PDFs, Export. Lesepfade, die `moduleGuard` nutzen (mobile Seitenkontexte, Import-Datei-Download), verwenden `moduleGuard(key, { read: true })`; der Guard-Check verbietet diesen Modus in Actions. Banner in Backoffice und mobiler App: „Testphase endet in X Tagen“ (ab 7 Tagen) bzw. „Testphase abgelaufen – nur Lesezugriff. Daten werden am … gelöscht.“ + Kontakt (`TRIAL_CONTACT_EMAIL`) + Export-Link (Admins). |
| **5 Export** | `/settings/export` (tenant:manage, auch im Nur-Lesen-Zustand): Worker-Job `tenant-export` baut ZIP mit `csv/` (Semikolon, BOM, Formel-Injektion entschärft) + `json/` für Kunden, Ansprechpartner, Objekte, Teams, Nutzer (ohne Secrets), Aufträge, Checklisten, Material (Vorgabe/Verbrauch), Einsätze, Zeiten, Notizen, Berichte, Unterschriften, Fotos, Dokumente, Meilensteine, Abrechnung + `dateien/` (alle gespeicherten Dateien inkl. Berichts-PDFs, Limit 500 MB) + `LIESMICH.txt`; Ablage über `storage.put` unter `<tenantId>/uploads/`, Download `/settings/export/<id>` (Session + DB-autoritatives `tenant:manage`, 7 Tage, Audit). Der DSGVO-/Backup-Export ist ein Betreiber-Werkzeug (alle Tabellen, JSON, Plattform-Portal) – wiederverwendet wurde sein ZIP-Writer (`backup/zip.ts`). |
| **6 Lebenszyklus-Job** | Queue `trial-lifecycle`, Job-Scheduler `trial-lifecycle-daily` (24 h) in `scheduleRecurringJobs`. Erinnerungen 7/3/1 Tage (verpasster Lauf → nur die nächstliegende, ältere als erledigt markiert), Ablaufmail, Löschhinweis 7 Tage vorher, Löschung an `deletionDueAt` über `dsgvo/deletion.ts#offboardTenant` (Topologie aller `TENANT_MODELS`, Identities ohne Rest-Mitgliedschaft, Löschnachweis) + Objektspeicher-Präfix `<tenantId>/` + Plattform-Audit. Jede Mail wird per bedingtem Update beansprucht (genau einmal, auch parallel). Doppelprüfung + Claim unmittelbar vor dem Löschen (TRIAL, nicht umgewandelt, abgelaufen, fällig → `SUSPENDED`, danach `ARCHIVED`). Anmeldungen werden 7 Tage nach Link-Ablauf gelöscht. |
| **7 i18n/Doku** | Namespace `trial` (de/en), `nav.dataExport`; Mail-Vorlagen `trial_confirm`, `trial_existing_account`, `trial_reminder`, `trial_expired`, `trial_deletion_notice` (de/en, `TRIAL_TEMPLATE_KEYS`). `docs/craftvia/TESTPHASE.md` (Lebenszyklus, env, Jobs, Sperre, Datenschutz), Abschnitte in `DEPLOY.md` (7.4) und `API.md`, env-Beispiele. |
### Entscheidungen
- **Beispieldaten:** `scripts/lib/demo-seed.ts` setzt sieben Nutzer mit festen Rollen sowie Foto-/PDF-/Import-Pipeline voraus. Ein Testmandant startet mit genau einem Admin → `services/trial/sample-data.ts` ist eine gekürzte Variante nach demselben Prinzip (ausschließlich echte Fachservices: 3 Kunden, 3 Objekte, „Beispielteam“, 4 Aufträge in Entwurf/Geplant/Zugewiesen/Prüfung; Kennung `BEISPIEL-`, zählt nicht für die Checkliste).
- **Provisionierung:** `provisionTenant` upsertet per Slug. Damit zwei gleichnamige Anmeldungen nie im selben Mandanten landen, reserviert `provisionTrialTenant` den Slug per `create` (inkl. Testphasen-Feldern) und ruft erst dann `provisionTenant` auf.
- **Sync im Nur-Lesen-Zustand:** Der Batch wird mit 422 abgewiesen, bevor eine Operation angewendet wird; die Outbox behandelt Nicht-OK beim Batch als vorübergehend und behält die Operationen (kein Datenverlust nach Verlängerung/Umwandlung). `services/sync/apply.ts` blieb unverändert.
- **Plattform-Wizard** als eine Seite mit vier nummerierten Abschnitten (Betreiber-Werkzeug, Einladung statt Passwort).
## 2. Dateien
**Neu**
- Migration `prisma/migrations/20260916100000_testphase/`
- `src/lib/trial/{dates,signup}.ts`
- `src/server/services/trial/{config,state,signup,abuse,provision,sample-data,admin,lifecycle,export,onboarding,mail,jobs,storage-purge}.ts`
- `src/server/actions/trial-{signup,platform,tenant}.ts`
- `src/server/jobs/processors/{trial-lifecycle,tenant-export}.ts`
- `src/app/testen/{layout,page}.tsx`, `src/app/testen/{bestaetigen,nutzungsbedingungen,datenschutz}/page.tsx`
- `src/app/(app)/settings/export/page.tsx`, `src/app/(app)/settings/export/[id]/route.ts`, `src/app/(platform)/admin/trial/page.tsx`
- `src/components/trial/{signup-wizard,confirm-form,legal-placeholder,trial-banner,getting-started,trial-badge,trial-admin-card,platform-forms}.tsx`
- `messages/{de,en}/trial.json`, `docs/craftvia/TESTPHASE.md`, dieser Bericht
- Tests/Smoke: `scripts/test-testphase-{signup,readonly,lifecycle}.ts`, `scripts/lib/testphase-fixture.ts`, `scripts/smoke-testphase.ts`
**Fundament-/Fremdeingriffe (laut Auftrag erlaubt, jeweils minimal)**
| Datei | Eingriff | Grund |
|---|---|---|
| `prisma/schema.prisma` | Tenant-Felder, Enum `TenantPlan`, `TenantSettings.onboarding`, Modelle `TrialSignup`, `TenantExport` | Punkt 1 |
| `src/server/provision.ts` | optional `admin.passwordHash`, `admin.mustChangePassword`, `modules`; Rückgabe um `adminUserId`, `identityId`, `identityCreated` ergänzt; Identity per `findUnique`+`create` statt `upsert` (Verhalten für Bestandsaufrufer gleich) | Hash aus der Anmeldung übernehmen, Modulauswahl, Einladung nur für neue Identity |
| `src/server/action-guard.ts` | `assertTenantWritable` nach Modul-Check; Option `{ read: true }` | zentrale Sperre; Lesepfade |
| `src/server/api/respond.ts` | `withApi` legt die Methode in AsyncLocalStorage ab (`currentApiMethod`, `isMutatingApiRequest`) | Sperre nur für nicht lesende Anfragen |
| `src/server/api/context.ts` | `assertApiWriteAllowed(tenantId)` in `requireApiContext` | zentrale API-Sperre |
| `src/app/(app)/documents/upload/route.ts`, `src/app/api/v1/work-orders/[id]/documents/route.ts` | je 1 Zeile `assertTenantWritable` | Upload-Routen ohne `withApi` |
| `src/server/actions/{tenant-settings,tenant-users,lotse-settings}.ts` | je 1 Zeile Sperre (+ Klartext in `tenant-users#actionError`) | Einstellungen/Nutzerverwaltung gesperrt |
| `src/server/services/field/page-context.ts`, `src/app/(field)/m/emergency/page.tsx`, `src/app/(app)/imports/[id]/file/route.ts` | `moduleGuard(key, { read: true })` | Lesepfade ohne Sperre (Smoke-Befund: sonst 500 auf `/m`) |
| `src/proxy.ts` | `/testen` in `PUBLIC_PATHS` | öffentliche Anmeldung |
| `src/server/rate-limit.ts` | Scopes `trialSignup`, `trialConfirm`, `trialStepCheck` | Missbrauchsschutz |
| `src/server/mail/templates.ts` | 5 Vorlagen de/en, `TRIAL_TEMPLATE_KEYS` | Mails |
| `src/server/jobs/{queues,processors/index}.ts`, `scripts/craftvia-worker.ts` | 2 Queues + Scheduler, 2 Registry-Zeilen, Sperrprüfung vor jedem Job | Punkt 5/6 |
| `src/app/(app)/layout.tsx`, `src/app/(field)/m/layout.tsx` | `<TrialBanner>` | Banner |
| `src/app/(app)/dashboard/page.tsx` (L2) | 1 Zeile `<GettingStarted>` | Checkliste im Dashboard |
| `src/app/(platform)/admin/page.tsx`, `admin/[id]/page.tsx` | Badge-Spalte, Filter, Button; Karte `TrialAdminCard` | Punkt 3 |
| `src/lib/nav.ts`, `messages/{de,en}/nav.json` | Eintrag „Datenexport“ | Einzeiler |
| `src/server/db.ts`, `src/server/backup/topology.ts`, `src/server/dsgvo/pii-fields.ts` | `TenantExport` | Pflicht bei neuer Tenant-Tabelle |
| `scripts/check-module-guards.ts` | 3 Einträge, Kategorie `PUBLIC` (jede Action braucht Rate-Limit), Verbot des Lese-Modus in Actions | Top-Level-Actions |
| `scripts/test-e2e-tenant-isolation.ts` (L10) | 1 Fixture-Zeile `TenantExport` | Test verlangt Abdeckung aller Tenant-Modelle |
| `.env.example`, `.env.prod.example`, `.env.coolify.example`, `docs/craftvia/{DEPLOY,API}.md` | Testphase-Abschnitte | Betrieb |
**Neue env-Variablen:** `TRIAL_MAX_DAYS` (Default 30), `TRIAL_CONTACT_EMAIL` (optional). Keine neuen npm-Abhängigkeiten.
## 3. Tests
| Skript | Prüfungen | Inhalt |
|---|---|---|
| `test-testphase-signup.ts` | 76 | Grenzen (morgen … heute + 30, Vorbelegung), Pflichtfelder, Passwort-Policy, Modul-/Checkbox-Pflicht, Normalisierung, `TRIAL_MAX_DAYS`, Ende des Tages Berlin (Winter/Sommer/Umstellung); ungültig/Honeypot ohne Seiteneffekt; Double-Opt-in (nur Token-Hash, Argon2id, IP-HMAC, 24 h, kein Mandant vor Bestätigung, ältere Links entwertet, Ablauf → expired + Hash geleert, Einmalverwendung); Provisionierung (TRIAL, Enddatum, Löschtermin, Slug, tenant-admin, Login-Passwort = Wizard-Passwort, Modulauswahl, Beispieldaten über Fachservices, Audit); Enumeration (identische Antwort, Hinweis-Mail ohne Link, keine offene Anmeldung); Rate-Limit je IP/E-Mail inkl. Retry-After, jede öffentliche Action limitiert, Proxy; ohne Beispieldaten; Slug-Kollision `-2`; Mandant B liest/ändert keine Kunden von A |
| `test-testphase-readonly.ts` | 59 | Checkliste (automatisch erkannt, Beispieldaten zählen nicht, manuell, Monteur forbidden/unsichtbar, Vollversion ohne Checkliste); Ablauf → `blocked trial_expired` mit Klartext; `withApi`+Sperre: POST/DELETE 422, GET 200, Mandant B 200; Sync-Batch 422 ohne angewendete Operation; statisch: moduleGuard, requireApiContext, **alle 24 mutierenden /api/v1-Routen**, Upload-Routen, Einstellungen, Nutzerverwaltung, Lotse, Worker, Lese-Modus nur in 3 Lesepfaden und nie in Actions; Jobs (Import/Transkription übersprungen, PDF/Export/Mandant B nicht); Lesen erlaubt; Export im Nur-Lesen-Zustand (ZIP, CSV mit BOM, JSON, Datei byte-identisch, keine Daten von B, keine Hashes, Formel-Injektion, paralleler Export conflict, Mandant B not_found, Monteur forbidden, Download-Ablauf, Audit); Verlängern hebt die Sperre auf; Mandant B unverändert |
| `test-testphase-lifecycle.ts` | 67 | Erinnerungen 7/3/1 genau einmal, verpasster Lauf; Ablaufmail einmal + readOnlySince; Löschhinweis einmal; Löschung nur fällig (Tabellen leer, Identity, Speicherobjekt, Nachweis, Plattform-Audit, keine Wiederholung); Vollversion B und nicht fälliger Test B2 unberührt; Umwandeln/Verlängern/Abbrechen verhindern Löschung; Vormerken ≥ 7 Tage, folgt neuem Ende; Audit before/after je Aktion; Plattform-Rechte (Mandanten-Admin, Read-only-Admin → forbidden, Action ohne Plattform-Session abgewiesen, Datumsgrenzen, Vollversion invalid, Mandanten-Actions ohne Testphasen-Änderung); Wizard (freies Enddatum, Einladung, Passwortzwang, Token, Beispieldaten, bestehende Person ohne neue Einladung, Audit); Vorlagen de/en; Processor + Scheduler |
**Gate (`npm run gate`) grün:** prisma generate, tsc, lint (0 Fehler; 3 vorbestehende Warnungen in fremden Dateien), build inkl. Guard-Check (39 Action-Dateien), **79/79 Testskripte**. Lane-DB `craftvia_testphase`, `RLS_DATABASE_URL` auf dieselbe DB. Ein Lauf mit `RLS_ENFORCED=true` wurde nicht durchgeführt.
**HTTP-Smoke** (`next build && next start -p 3115`, Session-Cookies ohne Passworteingabe): `scripts/smoke-testphase.ts` **32/32** – anonym `/testen` (Schritt 1 von 5), Bestätigung mit ungültigem Token, Rechtstexte, Redirects; abgelaufener Admin: Banner + Löschdatum + Export-Link, Lesen, Exportseite, `GET /api/v1/customers` 200, **422 `trial_expired`** für `POST /api/v1/customers`, `POST /api/v1/work-orders/[id]/documents`, `POST /documents/upload`; abgelaufener Monteur: `/m`, `/m/orders`, `/m/emergency` mit Banner, Auftrag ohne Zuweisung 404, Bundle 200, **422** für `/api/v1/sync` und `/api/v1/uploads`, keine Exportseite; laufender Test: „Testphase endet in 3 Tagen.“, „Erste Schritte“, Willkommen; Plattform: Liste mit Badges/Filtern, Wizard, Detail mit Karte und Popups. Zusätzlich `scripts/smoke-auth.ts` **137/137** (keine Regression). Der erste Smoke-Lauf fand den 500 auf `/m` (Lese-Kontext über `moduleGuard`) → behoben in `273a453`.
**Visuell:** nicht geprüft – die Navigation des Browser-Panels auf localhost wurde abgelehnt. Bitte `/testen` (Wizard, 375/768/1024 px), Banner und Plattform-Karte manuell ansehen.
## 4. Stubs / Abhängigkeiten
Keine Stubs. Genutzt: `provisionTenant`, `issueToken`/`sendUserInvitationMail`, `login-ticket`/`signIn`, `offboardTenant` + Topologie, `storage`/`readStoredBytes`, `buildZip`, `enqueueMail`, `enqueueJob`/Scheduler, Fachservices L1/L2 (Kunden, Objekte, Teams, Aufträge, Zuweisung).
## 5. Bekannte Lücken / offene Punkte
1. **Offline-Fotos nach Ablauf:** `/api/v1/uploads` antwortet 422; die Outbox (L7) wertet 422 beim Upload als endgültig ungültig und verwirft das Blob auf dem Gerät. Sync-Operationen bleiben erhalten. Vorschlag L7: `blocked`/`trial_expired` als vorübergehend behandeln.
2. **Fehlertexte in fremden Formularen:** Actions anderer Lanes zeigen bei der Sperre ihre eigene Fehlerdarstellung (Code `blocked`/`trial_expired`, teils generisch); der Banner erklärt den Zustand. Eine einheitliche Klartext-Zuordnung je Lane steht aus.
3. **Rate-Limits je App-Instanz** (In-Memory wie SEC2/L10b).
4. **Rechtstexte** sind Platzhalter (`messages/*/trial.json` → `legal.*`).
5. **Fallback nach Bestätigung:** Scheitert die direkte Anmeldung (z. B. künftige MFA-Pflicht), leitet die Action auf `/login?trial=ready`; die Login-Seite (Fundament) zeigt dazu keinen eigenen Hinweis.
6. **Plattform-Einladung an bestehende Personen:** wird nur verknüpft (wie `createTenantUser`), ohne Hinweis-Mail.
7. **Export** im Speicher gebaut (Dateien bis 500 MB, kein ZIP64/Streaming); alte Export-Dateien werden nicht automatisch aus dem Speicher entfernt (nur mit dem Mandanten).
8. **Worker-Sperre** nur für `import-extraction` und `transcription`; Benachrichtigungs-Mails aus Ereignissen vor dem Ablauf laufen weiter.
9. **Slug** eines gelöschten Testmandanten bleibt belegt (Zeile `ARCHIVED` mit Löschnachweis); neue Anmeldungen erhalten ein Suffix.
10. **Checkliste:** „Mobile App öffnen“ nur manuell abhakbar; Checkliste nur für Mandanten, die als Testphase gestartet sind.
11. **RLS-Modus** (`RLS_ENFORCED=true`) für die neuen Tests nicht gelaufen.
## 6. Screens / Routen
| Route | Zugriff | Inhalt |
|---|---|---|
| `/testen` | öffentlich | Wizard „Kostenlos testen“ |
| `/testen/bestaetigen?token=` | öffentlich | Anmeldung prüfen, „Jetzt einrichten“ (POST) → Dashboard |
| `/testen/nutzungsbedingungen`, `/testen/datenschutz` | öffentlich | Platzhalter-Rechtstexte |
| `/dashboard` | Backoffice | Karte „Erste Schritte“ (Admins, Testphasen-Mandanten) |
| alle `(app)`- und `/m`-Seiten | angemeldet | Testphasen-Banner |
| `/settings/export`, `/settings/export/<id>` | `tenant:manage` | Datenexport anfordern/herunterladen |
| `/admin` | Plattform | Spalte „Version“, Filter Alle/Test/Voll, Button „Testmandant anlegen“ |
| `/admin/trial` | Plattform (Voll-Admin für Aktion) | Wizard „Testmandant anlegen“ |
| `/admin/<id>?trial=extend|convert|end|schedule|cancel` | Plattform-Voll-Admin | Karte „Testphase“ + Bestätigungs-Popups |
+2 -1
View File
@@ -21,5 +21,6 @@
"openMenu": "Menü öffnen",
"closeMenu": "Menü schließen",
"timeApprovals": "Zeiten zur Freigabe",
"billing": "Abrechnung"
"billing": "Abrechnung",
"dataExport": "Datenexport"
}
+269
View File
@@ -0,0 +1,269 @@
{
"meta": {
"title": "Kostenlos testen",
"confirmTitle": "Testphase bestätigen",
"termsTitle": "Nutzungsbedingungen",
"privacyTitle": "Datenschutz"
},
"public": {
"heading": "Craftvia kostenlos testen",
"intro": "In wenigen Minuten eingerichtet. Ohne Zahlungsdaten, ohne automatische Verlängerung.",
"benefit1": "Aufträge planen, Einsätze mobil erfassen, Berichte mit Unterschrift",
"benefit2": "Enddatum frei wählen – danach bleiben Ihre Daten lesbar und exportierbar",
"benefit3": "Auf Wunsch mit Beispieldaten zum sofortigen Ausprobieren",
"haveAccount": "Schon einen Zugang?",
"login": "Anmelden",
"terms": "Nutzungsbedingungen",
"privacy": "Datenschutz"
},
"steps": {
"company": "Betrieb",
"account": "Admin-Konto",
"period": "Testzeitraum",
"setup": "Einrichtung",
"summary": "Zusammenfassung"
},
"progress": "Schritt {current} von {total}",
"progressLabel": "Fortschritt",
"fields": {
"companyName": "Firmenname",
"sector": "Branche",
"sectorChoose": "Bitte wählen (optional)",
"sectorOther": "Branche (Freitext)",
"companySize": "Betriebsgröße",
"companySizeNone": "Keine Angabe",
"adminName": "Ihr Name",
"email": "E-Mail-Adresse",
"emailHint": "Die Adresse ist Ihr Anmeldename. Wir senden Ihnen einen Bestätigungslink.",
"password": "Passwort",
"passwordHint": "Anforderungen: {policy}",
"showPassword": "Passwort anzeigen",
"trialEndDate": "Testphase bis",
"sampleData": "Mit Beispieldaten starten",
"sampleDataHint": "Drei Kunden, Objekte, ein Team und vier Aufträge in verschiedenen Zuständen. Sie können sie jederzeit löschen.",
"modules": "Module",
"modulesHint": "Alle Module sind vorausgewählt. Nicht benötigte Module können Sie abwählen – später lässt sich das ändern.",
"acceptTerms": "Ich akzeptiere die {link}.",
"acceptPrivacy": "Ich habe die {link} gelesen.",
"required": "Pflichtfeld",
"website": "Website (bitte leer lassen)"
},
"sectors": {
"shk": "Sanitär, Heizung, Klima",
"electrical": "Elektro",
"roofing": "Dach und Fassade",
"carpentry": "Tischlerei und Innenausbau",
"painting": "Maler und Lackierer",
"facility": "Gebäudetechnik und Facility",
"construction": "Bau und Montage",
"metal": "Metallbau",
"other": "Andere Branche"
},
"sizes": {
"1_5": "1–5 Beschäftigte",
"6_20": "6–20 Beschäftigte",
"21_50": "21–50 Beschäftigte",
"51_200": "51–200 Beschäftigte",
"200_": "mehr als 200 Beschäftigte"
},
"period": {
"until": "Testphase bis {date} ({days, plural, one {# Tag} other {# Tage}})",
"range": "Möglich: {min} bis {max}",
"afterEnd": "Nach dem Enddatum bleiben Ihre Daten lesbar und exportierbar. Ohne Umwandlung in die Vollversion löschen wir sie 30 Tage später."
},
"summary": {
"intro": "Bitte prüfen Sie Ihre Angaben.",
"sampleYes": "mit Beispieldaten",
"sampleNo": "ohne Beispieldaten",
"modulesCount": "{count} von {total} Modulen",
"edit": "Ändern",
"noSector": "keine Branche angegeben"
},
"actions": {
"back": "Zurück",
"next": "Weiter",
"submit": "Testphase starten",
"submitting": "Wird gesendet …",
"checking": "Wird geprüft …"
},
"sent": {
"title": "Fast geschafft – bitte E-Mail bestätigen",
"body": "Wir haben Ihnen eine E-Mail geschickt. Klicken Sie auf den Link darin, um Ihre Testphase zu starten.",
"hint": "Der Link ist 24 Stunden gültig. Keine E-Mail erhalten? Prüfen Sie den Spam-Ordner oder starten Sie die Anmeldung erneut.",
"again": "Anmeldung neu starten"
},
"errors": {
"company_required": "Bitte den Firmennamen angeben (mindestens 2 Zeichen).",
"too_long": "Die Eingabe ist zu lang.",
"invalid_choice": "Bitte einen Eintrag aus der Liste wählen.",
"name_required": "Bitte Ihren Namen angeben.",
"email_invalid": "Bitte eine gültige E-Mail-Adresse angeben.",
"password_policy": "Das Passwort erfüllt die Anforderungen nicht.",
"date_invalid": "Bitte ein gültiges Datum wählen.",
"date_too_early": "Das Enddatum muss frühestens morgen sein.",
"date_too_late": "Das Enddatum liegt zu weit in der Zukunft.",
"modules_required": "Bitte mindestens ein Modul auswählen.",
"terms_required": "Bitte die Nutzungsbedingungen akzeptieren.",
"privacy_required": "Bitte die Datenschutzhinweise bestätigen.",
"invalid_request": "Die Anfrage war unvollständig. Bitte Seite neu laden.",
"rate_limited": "Zu viele Versuche. Bitte in einigen Minuten erneut versuchen.",
"failed": "Das hat nicht geklappt. Bitte erneut versuchen."
},
"confirm": {
"title": "Testphase starten",
"intro": "Bestätigen Sie Ihre Anmeldung. Danach richten wir Ihren Zugang ein und melden Sie direkt an.",
"company": "Betrieb",
"until": "Testphase bis",
"sampleData": "Beispieldaten",
"yes": "ja",
"no": "nein",
"button": "Jetzt einrichten",
"pending": "Wird eingerichtet …",
"invalid": "Der Link ist ungültig oder wurde bereits verwendet.",
"expired": "Der Link ist abgelaufen. Bitte starten Sie die Anmeldung erneut.",
"rate_limited": "Zu viele Versuche. Bitte in einigen Minuten erneut versuchen.",
"toSignup": "Zur Anmeldung „Kostenlos testen“",
"toLogin": "Zum Login"
},
"legal": {
"back": "Zurück zur Anmeldung",
"placeholder": "Platzhalter – der verbindliche Text wird vom Betreiber vor dem Go-live eingesetzt.",
"termsBody1": "Die Testphase ist kostenlos und endet automatisch zum gewählten Datum. Es entsteht kein Vertrag über eine kostenpflichtige Nutzung.",
"termsBody2": "Nach dem Ende sind die Daten 30 Tage lesbar und exportierbar. Ohne Umwandlung in die Vollversion werden sie danach gelöscht.",
"privacyBody1": "Wir verarbeiten Ihre Angaben (Firmenname, Name, E-Mail-Adresse) zur Einrichtung und Betreuung der Testphase. Passwörter speichern wir ausschließlich als sicheren Hash.",
"privacyBody2": "Nicht bestätigte Anmeldungen löschen wir nach wenigen Tagen. Daten der Testphase werden nach Ablauf gemäß den Nutzungsbedingungen gelöscht."
},
"banner": {
"endsIn": "{days, plural, =0 {Testphase endet heute.} =1 {Testphase endet morgen.} other {Testphase endet in # Tagen.}}",
"endsOn": "Letzter Tag: {date}.",
"expired": "Testphase abgelaufen – nur Lesezugriff.",
"deletion": "Daten werden am {date} gelöscht.",
"contact": "Vollversion oder Verlängerung: {email}",
"contactGeneric": "Vollversion oder Verlängerung? Wenden Sie sich an Ihren Ansprechpartner bei Craftvia.",
"export": "Daten exportieren"
},
"onboarding": {
"title": "Erste Schritte",
"welcome": "Willkommen! Ihre Testphase ist eingerichtet.",
"progress": "{done} von {total} erledigt",
"hide": "Ausblenden",
"markDone": "Als erledigt markieren",
"markOpen": "Wieder öffnen",
"open": "Öffnen",
"auto": "erkannt",
"doneLabel": "Erledigt",
"openLabel": "Offen",
"items": {
"company": { "title": "Firmendaten prüfen", "text": "Adresse, Telefon und E-Mail erscheinen auf Berichten." },
"team": { "title": "Team anlegen", "text": "Teams bündeln Monteure für Planung und Zuweisung." },
"technician": { "title": "Monteur einladen", "text": "Ein Einladungslink genügt – das Passwort vergibt die Person selbst." },
"first_order": { "title": "Ersten Auftrag anlegen oder importieren", "text": "Manuell erfassen oder ein Auftrags-PDF hochladen." },
"mobile": { "title": "Mobile App öffnen", "text": "Auf dem Smartphone /m aufrufen und zum Startbildschirm hinzufügen." }
}
},
"export": {
"crumb": "Organisation",
"title": "Datenexport",
"sub": "Alle Daten Ihres Betriebs als ZIP-Datei",
"contents": "Enthalten: Kunden, Ansprechpartner, Objekte, Teams, Aufträge, Zeiten, Material, Berichte, Meilensteine und Abrechnung als CSV und JSON sowie alle gespeicherten Dateien (Dokumente, Berichts-PDFs, Fotos).",
"readOnlyHint": "Der Export ist auch nach Ablauf der Testphase möglich.",
"request": "Export erstellen",
"requested": "Export angefordert. Die Datei steht in wenigen Minuten bereit – laden Sie die Seite dann neu.",
"listTitle": "Letzte Exporte",
"empty": "Noch kein Export erstellt.",
"created": "Angefordert",
"state": "Status",
"file": "Datei",
"status": { "queued": "Wartet", "running": "Wird erstellt", "done": "Bereit", "failed": "Fehlgeschlagen", "expired": "Abgelaufen" },
"download": "Herunterladen",
"expires": "bis {date}",
"errors": {
"export_running": "Es läuft bereits ein Export. Bitte warten Sie, bis er fertig ist.",
"failed": "Der Export konnte nicht angefordert werden."
}
},
"platform": {
"newTrial": "Testmandant anlegen",
"filter": "Filter",
"filterAll": "Alle",
"filterTrial": "Test",
"filterFull": "Voll",
"colPlan": "Version",
"badgeFull": "Vollversion",
"badgeUntil": "Test bis {date}",
"badgeExpired": "abgelaufen – nur lesen",
"badgeDeletion": "Löschung am {date}",
"badgeDeleted": "gelöscht",
"wizard": {
"crumb": "Plattform-Betrieb",
"title": "Testmandant anlegen",
"sub": "Mandant mit Enddatum anlegen; der Admin erhält eine Einladung zum Passwort-Setzen.",
"sectionCompany": "1. Betrieb",
"sectionAdmin": "2. Administrator",
"sectionPeriod": "3. Testzeitraum",
"sectionSetup": "4. Einrichtung",
"companyName": "Firmenname",
"sector": "Branche (optional)",
"adminName": "Name",
"adminEmail": "E-Mail-Adresse",
"adminHint": "Neue Adressen erhalten den bestehenden Einladungslink (7 Tage gültig). Bestehende Zugänge werden nur verknüpft.",
"endDate": "Testphase bis",
"endDateHint": "Frei wählbar bis ein Jahr im Voraus.",
"sampleData": "Mit Beispieldaten",
"submit": "Testmandant anlegen",
"submitting": "Wird angelegt …",
"cancel": "Abbrechen"
},
"card": {
"title": "Testphase",
"plan": "Version",
"until": "Letzter Tag",
"state": "Zustand",
"stateActive": "läuft",
"stateExpired": "abgelaufen – nur lesen",
"stateConverted": "in Vollversion umgewandelt",
"stateDeleted": "gelöscht",
"deletion": "Löschung",
"deletionNone": "nicht vorgemerkt",
"source": "Herkunft",
"sourceSelf": "Selbstanmeldung",
"sourcePlatform": "Plattform-Wizard",
"extend": "Enddatum ändern / verlängern",
"convert": "In Vollversion umwandeln",
"end": "Testphase sofort beenden",
"schedule": "Löschung vormerken",
"cancel": "Löschung abbrechen",
"created": "Testmandant angelegt.",
"invited": "Die Einladung an den Administrator wurde verschickt.",
"done": "Änderung gespeichert."
},
"ops": {
"extendTitle": "Enddatum ändern",
"extendText": "Neues letztes Testdatum. Liegt es in der Zukunft, ist der Mandant sofort wieder schreibbar; Erinnerungen werden neu geplant.",
"convertTitle": "In Vollversion umwandeln",
"convertText": "Der Mandant bleibt dauerhaft schreibbar und wird nie automatisch gelöscht.",
"endTitle": "Testphase sofort beenden",
"endText": "Der Mandant ist ab sofort nur noch lesbar. Eine vorgemerkte Löschung verschiebt sich auf 30 Tage ab heute.",
"scheduleTitle": "Löschung vormerken",
"scheduleText": "Löschung 30 Tage nach Ende, frühestens in 7 Tagen (Hinweismail an die Admins).",
"cancelTitle": "Löschung abbrechen",
"cancelText": "Der Mandant wird nicht automatisch gelöscht. Er bleibt nach Ablauf nur lesbar.",
"confirm": "Ich bestätige diese Änderung.",
"submit": "Ausführen",
"submitting": "Wird ausgeführt …",
"close": "Schließen"
},
"errors": {
"confirm_required": "Bitte die Bestätigung ankreuzen.",
"invalid_input": "Bitte die Eingaben prüfen.",
"date_invalid": "Bitte ein gültiges Datum wählen.",
"date_in_past": "Das Enddatum darf nicht in der Vergangenheit liegen.",
"date_too_late": "Das Enddatum liegt mehr als ein Jahr in der Zukunft.",
"not_a_trial": "Dieser Mandant ist keine Testversion.",
"tenant_deleted": "Der Mandant wurde bereits gelöscht.",
"platform_admin_required": "Nur Plattform-Voll-Administratoren dürfen das.",
"tenant not found": "Mandant nicht gefunden.",
"failed": "Die Aktion ist fehlgeschlagen."
}
}
}
+2 -1
View File
@@ -21,5 +21,6 @@
"openMenu": "Open menu",
"closeMenu": "Close menu",
"timeApprovals": "Time approvals",
"billing": "Billing"
"billing": "Billing",
"dataExport": "Data export"
}
+269
View File
@@ -0,0 +1,269 @@
{
"meta": {
"title": "Try for free",
"confirmTitle": "Confirm trial",
"termsTitle": "Terms of use",
"privacyTitle": "Privacy"
},
"public": {
"heading": "Try Craftvia for free",
"intro": "Set up in minutes. No payment details, no automatic renewal.",
"benefit1": "Plan work orders, record jobs on mobile, reports with signature",
"benefit2": "Choose the end date – afterwards your data stays readable and exportable",
"benefit3": "Optional sample data to try everything right away",
"haveAccount": "Already have an account?",
"login": "Sign in",
"terms": "Terms of use",
"privacy": "Privacy"
},
"steps": {
"company": "Company",
"account": "Admin account",
"period": "Trial period",
"setup": "Setup",
"summary": "Summary"
},
"progress": "Step {current} of {total}",
"progressLabel": "Progress",
"fields": {
"companyName": "Company name",
"sector": "Trade",
"sectorChoose": "Please choose (optional)",
"sectorOther": "Trade (free text)",
"companySize": "Company size",
"companySizeNone": "Not specified",
"adminName": "Your name",
"email": "E-mail address",
"emailHint": "The address is your sign-in name. We will send you a confirmation link.",
"password": "Password",
"passwordHint": "Requirements: {policy}",
"showPassword": "Show password",
"trialEndDate": "Trial until",
"sampleData": "Start with sample data",
"sampleDataHint": "Three customers, sites, one team and four work orders in different states. You can delete them at any time.",
"modules": "Modules",
"modulesHint": "All modules are preselected. Deselect modules you do not need – you can change this later.",
"acceptTerms": "I accept the {link}.",
"acceptPrivacy": "I have read the {link}.",
"required": "Required",
"website": "Website (leave empty)"
},
"sectors": {
"shk": "Plumbing, heating, air conditioning",
"electrical": "Electrical",
"roofing": "Roofing and facades",
"carpentry": "Carpentry and interior fit-out",
"painting": "Painting and decorating",
"facility": "Building services and facility",
"construction": "Construction and installation",
"metal": "Metalwork",
"other": "Other trade"
},
"sizes": {
"1_5": "1–5 employees",
"6_20": "6–20 employees",
"21_50": "21–50 employees",
"51_200": "51–200 employees",
"200_": "more than 200 employees"
},
"period": {
"until": "Trial until {date} ({days, plural, one {# day} other {# days}})",
"range": "Possible: {min} to {max}",
"afterEnd": "After the end date your data stays readable and exportable. Unless converted to the full version, we delete it 30 days later."
},
"summary": {
"intro": "Please check your details.",
"sampleYes": "with sample data",
"sampleNo": "without sample data",
"modulesCount": "{count} of {total} modules",
"edit": "Edit",
"noSector": "no trade specified"
},
"actions": {
"back": "Back",
"next": "Next",
"submit": "Start trial",
"submitting": "Sending …",
"checking": "Checking …"
},
"sent": {
"title": "Almost done – please confirm your e-mail",
"body": "We have sent you an e-mail. Click the link in it to start your trial.",
"hint": "The link is valid for 24 hours. No e-mail? Check your spam folder or start the signup again.",
"again": "Start signup again"
},
"errors": {
"company_required": "Please enter the company name (at least 2 characters).",
"too_long": "The input is too long.",
"invalid_choice": "Please choose an entry from the list.",
"name_required": "Please enter your name.",
"email_invalid": "Please enter a valid e-mail address.",
"password_policy": "The password does not meet the requirements.",
"date_invalid": "Please choose a valid date.",
"date_too_early": "The end date must be tomorrow at the earliest.",
"date_too_late": "The end date is too far in the future.",
"modules_required": "Please select at least one module.",
"terms_required": "Please accept the terms of use.",
"privacy_required": "Please confirm the privacy notice.",
"invalid_request": "The request was incomplete. Please reload the page.",
"rate_limited": "Too many attempts. Please try again in a few minutes.",
"failed": "That did not work. Please try again."
},
"confirm": {
"title": "Start trial",
"intro": "Confirm your signup. We then set up your account and sign you in right away.",
"company": "Company",
"until": "Trial until",
"sampleData": "Sample data",
"yes": "yes",
"no": "no",
"button": "Set up now",
"pending": "Setting up …",
"invalid": "The link is invalid or has already been used.",
"expired": "The link has expired. Please start the signup again.",
"rate_limited": "Too many attempts. Please try again in a few minutes.",
"toSignup": "Go to “Try for free”",
"toLogin": "Go to sign-in"
},
"legal": {
"back": "Back to signup",
"placeholder": "Placeholder – the binding text will be provided by the operator before go-live.",
"termsBody1": "The trial is free of charge and ends automatically on the chosen date. No contract for paid use is concluded.",
"termsBody2": "After the end the data stays readable and exportable for 30 days. Unless converted to the full version, it is deleted afterwards.",
"privacyBody1": "We process your details (company name, name, e-mail address) to set up and support the trial. Passwords are stored as a secure hash only.",
"privacyBody2": "Unconfirmed signups are deleted after a few days. Trial data is deleted after expiry as described in the terms of use."
},
"banner": {
"endsIn": "{days, plural, =0 {Trial ends today.} =1 {Trial ends tomorrow.} other {Trial ends in # days.}}",
"endsOn": "Last day: {date}.",
"expired": "Trial expired – read-only access.",
"deletion": "Data will be deleted on {date}.",
"contact": "Full version or extension: {email}",
"contactGeneric": "Full version or extension? Contact your Craftvia representative.",
"export": "Export data"
},
"onboarding": {
"title": "Getting started",
"welcome": "Welcome! Your trial is ready.",
"progress": "{done} of {total} done",
"hide": "Hide",
"markDone": "Mark as done",
"markOpen": "Reopen",
"open": "Open",
"auto": "detected",
"doneLabel": "Done",
"openLabel": "Open",
"items": {
"company": { "title": "Check company details", "text": "Address, phone and e-mail appear on reports." },
"team": { "title": "Create a team", "text": "Teams group technicians for planning and assignment." },
"technician": { "title": "Invite a technician", "text": "An invitation link is enough – the person sets their own password." },
"first_order": { "title": "Create or import the first work order", "text": "Enter it manually or upload a work order PDF." },
"mobile": { "title": "Open the mobile app", "text": "Open /m on your smartphone and add it to the home screen." }
}
},
"export": {
"crumb": "Organisation",
"title": "Data export",
"sub": "All data of your company as a ZIP file",
"contents": "Included: customers, contacts, sites, teams, work orders, times, material, reports, milestones and billing as CSV and JSON plus all stored files (documents, report PDFs, photos).",
"readOnlyHint": "The export stays available after the trial has expired.",
"request": "Create export",
"requested": "Export requested. The file is ready in a few minutes – reload the page then.",
"listTitle": "Recent exports",
"empty": "No export created yet.",
"created": "Requested",
"state": "Status",
"file": "File",
"status": { "queued": "Waiting", "running": "In progress", "done": "Ready", "failed": "Failed", "expired": "Expired" },
"download": "Download",
"expires": "until {date}",
"errors": {
"export_running": "An export is already running. Please wait until it has finished.",
"failed": "The export could not be requested."
}
},
"platform": {
"newTrial": "Create trial tenant",
"filter": "Filter",
"filterAll": "All",
"filterTrial": "Trial",
"filterFull": "Full",
"colPlan": "Version",
"badgeFull": "Full version",
"badgeUntil": "Trial until {date}",
"badgeExpired": "expired – read-only",
"badgeDeletion": "Deletion on {date}",
"badgeDeleted": "deleted",
"wizard": {
"crumb": "Platform operations",
"title": "Create trial tenant",
"sub": "Create a tenant with an end date; the admin receives an invitation to set a password.",
"sectionCompany": "1. Company",
"sectionAdmin": "2. Administrator",
"sectionPeriod": "3. Trial period",
"sectionSetup": "4. Setup",
"companyName": "Company name",
"sector": "Trade (optional)",
"adminName": "Name",
"adminEmail": "E-mail address",
"adminHint": "New addresses receive the existing invitation link (valid for 7 days). Existing accounts are only linked.",
"endDate": "Trial until",
"endDateHint": "Any date up to one year ahead.",
"sampleData": "With sample data",
"submit": "Create trial tenant",
"submitting": "Creating …",
"cancel": "Cancel"
},
"card": {
"title": "Trial",
"plan": "Version",
"until": "Last day",
"state": "State",
"stateActive": "running",
"stateExpired": "expired – read-only",
"stateConverted": "converted to full version",
"stateDeleted": "deleted",
"deletion": "Deletion",
"deletionNone": "not scheduled",
"source": "Origin",
"sourceSelf": "Self-service signup",
"sourcePlatform": "Platform wizard",
"extend": "Change / extend end date",
"convert": "Convert to full version",
"end": "End trial now",
"schedule": "Schedule deletion",
"cancel": "Cancel deletion",
"created": "Trial tenant created.",
"invited": "The invitation was sent to the administrator.",
"done": "Change saved."
},
"ops": {
"extendTitle": "Change end date",
"extendText": "New last trial day. If it lies in the future, the tenant is writable again immediately; reminders are rescheduled.",
"convertTitle": "Convert to full version",
"convertText": "The tenant stays writable permanently and is never deleted automatically.",
"endTitle": "End trial now",
"endText": "The tenant is read-only from now on. A scheduled deletion moves to 30 days from today.",
"scheduleTitle": "Schedule deletion",
"scheduleText": "Deletion 30 days after the end, at the earliest in 7 days (notice mail to the admins).",
"cancelTitle": "Cancel deletion",
"cancelText": "The tenant will not be deleted automatically. It stays read-only after expiry.",
"confirm": "I confirm this change.",
"submit": "Execute",
"submitting": "Executing …",
"close": "Close"
},
"errors": {
"confirm_required": "Please tick the confirmation.",
"invalid_input": "Please check your input.",
"date_invalid": "Please choose a valid date.",
"date_in_past": "The end date must not lie in the past.",
"date_too_late": "The end date is more than one year ahead.",
"not_a_trial": "This tenant is not a trial.",
"tenant_deleted": "The tenant has already been deleted.",
"platform_admin_required": "Only platform full administrators may do this.",
"tenant not found": "Tenant not found.",
"failed": "The action failed."
}
}
}
@@ -0,0 +1,83 @@
-- L15 Testphase & Onboarding: Testphasen-Lebenszyklus am Mandanten, Onboarding-Checkliste,
-- Selbstanmeldungen (Plattform-Tabelle, ohne RLS) und Mandanten-Datenexporte (RLS).
-- CreateEnum
CREATE TYPE "TenantPlan" AS ENUM ('FULL', 'TRIAL');
-- AlterTable
ALTER TABLE "tenant_settings" ADD COLUMN "onboarding" JSONB NOT NULL DEFAULT '{}';
-- AlterTable
ALTER TABLE "tenants" ADD COLUMN "converted_at" TIMESTAMP(3),
ADD COLUMN "deletion_due_at" TIMESTAMP(3),
ADD COLUMN "plan" "TenantPlan" NOT NULL DEFAULT 'FULL',
ADD COLUMN "read_only_since" TIMESTAMP(3),
ADD COLUMN "trial_deleted_at" TIMESTAMP(3),
ADD COLUMN "trial_deletion_notice_at" TIMESTAMP(3),
ADD COLUMN "trial_ends_at" TIMESTAMP(3),
ADD COLUMN "trial_expired_notice_at" TIMESTAMP(3),
ADD COLUMN "trial_reminder_1_at" TIMESTAMP(3),
ADD COLUMN "trial_reminder_3_at" TIMESTAMP(3),
ADD COLUMN "trial_reminder_7_at" TIMESTAMP(3),
ADD COLUMN "trial_source" TEXT,
ADD COLUMN "trial_started_at" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "trial_signups" (
"id" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"company_name" TEXT NOT NULL,
"sector" TEXT,
"company_size" TEXT,
"admin_name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"trial_end_date" TEXT NOT NULL,
"sample_data" BOOLEAN NOT NULL DEFAULT true,
"modules" TEXT[] DEFAULT ARRAY[]::TEXT[],
"locale" TEXT NOT NULL DEFAULT 'de',
"token_hash" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"ip_hash" TEXT,
"accepted_terms_at" TIMESTAMP(3) NOT NULL,
"confirmed_at" TIMESTAMP(3),
"provisioned_tenant_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "trial_signups_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tenant_exports" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"requested_by_id" TEXT,
"status" TEXT NOT NULL DEFAULT 'queued',
"storage_key" TEXT,
"file_name" TEXT,
"bytes" INTEGER,
"summary" JSONB,
"error" TEXT,
"expires_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tenant_exports_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "trial_signups_token_hash_key" ON "trial_signups"("token_hash");
-- CreateIndex
CREATE INDEX "trial_signups_email_idx" ON "trial_signups"("email");
-- CreateIndex
CREATE INDEX "trial_signups_status_expires_at_idx" ON "trial_signups"("status", "expires_at");
-- CreateIndex
CREATE INDEX "tenant_exports_tenant_id_created_at_idx" ON "tenant_exports"("tenant_id", "created_at");
-- Mandanten-Tabelle: Row Level Security (docs/craftvia/MIGRATIONS.md)
SELECT enable_tenant_rls('tenant_exports');
+78
View File
@@ -44,6 +44,23 @@ model Tenant {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// L15 Testphase: Lebenszyklus (Plattform-Daten, keine Mandanten-RLS)
plan TenantPlan @default(FULL)
trialSource String? @map("trial_source") // self_signup | platform
trialStartedAt DateTime? @map("trial_started_at")
/// Exklusives Ende: Beginn des Folgetags des gewählten Enddatums (Europe/Berlin)
trialEndsAt DateTime? @map("trial_ends_at")
convertedAt DateTime? @map("converted_at")
readOnlySince DateTime? @map("read_only_since")
/// null = keine automatische Löschung vorgemerkt
deletionDueAt DateTime? @map("deletion_due_at")
trialDeletedAt DateTime? @map("trial_deleted_at")
trialReminder7At DateTime? @map("trial_reminder_7_at")
trialReminder3At DateTime? @map("trial_reminder_3_at")
trialReminder1At DateTime? @map("trial_reminder_1_at")
trialExpiredNoticeAt DateTime? @map("trial_expired_notice_at")
trialDeletionNoticeAt DateTime? @map("trial_deletion_notice_at")
users User[]
roles Role[]
auditLogs AuditLog[]
@@ -81,6 +98,8 @@ model TenantSettings {
aiMonthlyTokenLimit Int? @map("ai_monthly_token_limit")
// L16 Lotse-Chat für Monteure: switch per tenant (effective only while the Lotse module is on)
lotseChatEnabled Boolean @default(true) @map("lotse_chat_enabled")
// L15: „Erste Schritte"-Checkliste ({ done: string[], hidden: boolean })
onboarding Json @default("{}")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -1517,3 +1536,62 @@ model LotseActionProposal {
@@index([tenantId, userId, status])
@@map("lotse_action_proposals")
}
// ── L15 Testphase & Onboarding ─────────────────────────────────────────────────
enum TenantPlan {
FULL
TRIAL
}
/// Selbstanmeldung „Kostenlos testen" vor der E-Mail-Bestätigung (Plattform-Tabelle, kein tenant_id).
/// Keine Klartext-Passwörter/Token: Argon2id-Hash (+ Pepper) bzw. SHA-256 des Tokens; IP nur als HMAC.
model TrialSignup {
id String @id @default(cuid())
/// pending | confirmed | superseded | expired | existing_account | failed
status String @default("pending")
companyName String @map("company_name")
sector String?
companySize String? @map("company_size")
adminName String @map("admin_name")
email String
/// Wird nach der Bestätigung geleert.
passwordHash String @map("password_hash")
/// Gewähltes Enddatum YYYY-MM-DD (Europe/Berlin)
trialEndDate String @map("trial_end_date")
sampleData Boolean @default(true) @map("sample_data")
modules String[] @default([])
locale String @default("de")
tokenHash String @unique @map("token_hash")
expiresAt DateTime @map("expires_at")
ipHash String? @map("ip_hash")
acceptedTermsAt DateTime @map("accepted_terms_at")
confirmedAt DateTime? @map("confirmed_at")
provisionedTenantId String? @map("provisioned_tenant_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([email])
@@index([status, expiresAt])
@@map("trial_signups")
}
/// Daten-Export eines Mandanten (ZIP mit CSV/JSON + Dateien), auch im Nur-Lesen-Zustand.
model TenantExport {
id String @id @default(cuid())
tenantId String @map("tenant_id")
requestedById String? @map("requested_by_id")
/// queued | running | done | failed
status String @default("queued")
storageKey String? @map("storage_key")
fileName String? @map("file_name")
bytes Int?
summary Json?
error String?
expiresAt DateTime? @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([tenantId, createdAt])
@@map("tenant_exports")
}
+24
View File
@@ -57,6 +57,12 @@ const ACTION_MODULE: Record<string, string> = {
"account.ts": "EXEMPT",
"tenant-switch.ts": "EXEMPT",
"webauthn.ts": "EXEMPT",
// L15 Testphase: Plattform-Wizard/-Aktionen (requirePlatformFullAdmin) und Mandanten-Export/Onboarding
// (requireSession + requirePermission + requireApiContext; Export bewusst auch im Nur-Lesen-Zustand)
"trial-platform.ts": "EXEMPT",
"trial-tenant.ts": "EXEMPT",
// L15 Testphase: öffentliche Selbstanmeldung ohne Session — jede Action MUSS das Rate-Limit prüfen
"trial-signup.ts": "PUBLIC",
};
const errors: string[] = [];
@@ -75,6 +81,10 @@ function checkGatedFile(label: string, src: string, moduleKey: string) {
if (!src.includes(`moduleGuard("${moduleKey}")`)) {
errors.push(`${label}: erwartet moduleGuard("${moduleKey}") — Modul-Gating fehlt oder falscher Key.`);
}
// L15 Testphase: der Lese-Modus überspringt die Schreibsperre abgelaufener Testmandanten → in Actions verboten.
if (/moduleGuard\([^)]*read\s*:/.test(src)) {
errors.push(`${label}: moduleGuard(…, { read: true }) ist nur für Lesepfade erlaubt, nicht in Server-Actions.`);
}
for (let i = 0; i < positions.length; i++) {
const start = positions[i].index;
const end = i + 1 < positions.length ? positions[i + 1].index : src.length;
@@ -118,6 +128,20 @@ for (const entry of readdirSync(ACTIONS_DIR)) {
continue;
}
const src = readFileSync(full, "utf8");
if (mapped === "PUBLIC") {
// Öffentliche Actions (ohne Session): jede exportierte Action muss ein Rate-Limit prüfen.
const exportRe = /export async function (\w+)\s*\(/g;
const positions: { name: string; index: number }[] = [];
let pm: RegExpExecArray | null;
while ((pm = exportRe.exec(src))) positions.push({ name: pm[1], index: pm.index });
positions.forEach((p, i) => {
const body = src.slice(p.index, i + 1 < positions.length ? positions[i + 1].index : src.length);
if (!/(enforceTrialRateLimit|checkRateLimit|consumeRateLimit)\(/.test(body)) {
errors.push(`${entry}: öffentliche Action "${p.name}" ohne Rate-Limit-Prüfung.`);
}
});
continue;
}
if (mapped === "EXEMPT") {
// Auth-Nachweis: ein require*-Guard ODER ein direkter auth()-Aufruf.
if (!/require(Session|Platform\w*|Permission)|\bauth\(\)/.test(src)) {
+9 -1
View File
@@ -2,6 +2,7 @@ import "dotenv/config";
import { Worker } from "bullmq";
import { JOB_QUEUES, workerConnection, closeJobQueues, scheduleRecurringJobs, type JobPayload } from "../src/server/jobs/queues";
import { PROCESSORS } from "../src/server/jobs/processors";
import { isJobBlockedByTrial } from "../src/server/services/trial/jobs";
/** Craftvia background worker: `npm run worker:craftvia`. One BullMQ worker per registered queue. */
async function main() {
@@ -20,7 +21,14 @@ async function main() {
const processor = await load();
const geocode = name === JOB_QUEUES.geocodeSite; // L13: OSM Nominatim policy — max. 1 request/s
const concurrency = name === JOB_QUEUES.reportPdf ? 2 : geocode ? 1 : 4;
const w = new Worker<JobPayload>(name, async (job) => processor(job.data), { connection, concurrency, ...(geocode ? { limiter: { max: 1, duration: 1_000 } } : {}) });
const w = new Worker<JobPayload>(name, async (job) => {
// L15 Testphase: user-triggered jobs of an expired trial tenant (read-only) are skipped
if (await isJobBlockedByTrial(name, job.data)) {
console.warn(`[worker] ${name} job ${job.id} skipped: tenant is read-only (trial expired)`);
return;
}
return processor(job.data);
}, { connection, concurrency, ...(geocode ? { limiter: { max: 1, duration: 1_000 } } : {}) });
w.on("failed", (job, err) => console.error(`[worker] ${name} job ${job?.id} failed:`, err.message));
workers.push(w);
console.info(`[worker] listening on ${name}`);
+109
View File
@@ -0,0 +1,109 @@
// Shared fixture of the L15 tests (scripts/test-testphase-*.ts): zz trial tenants, platform admins,
// mail capture, zip reader and a complete tenant purge. Not a test itself (runner only picks up
// scripts/test-*.ts at top level).
import { inflateRawSync } from "node:zlib";
import { prisma, dbForTenant } from "../../src/server/db";
import { ROLE_DEFS, type RoleKey } from "../../src/server/rbac";
import type { ServiceCtx } from "../../src/server/services/context";
import { offboardTenant } from "../../src/server/dsgvo/deletion";
import type { enqueueMail } from "../../src/server/mail/service";
import { provisionTrialTenant } from "../../src/server/services/trial/provision";
export const DOMAIN = "@zz-l15.test";
export const SLUG_PREFIX = "zz-l15";
export let failures = 0;
export const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
/** Expects `fn` to throw an error with the given `code` (ServiceError) — or any error when code is "*". */
export async function expectCode(fn: () => Promise<unknown>, code: string, msg: string) {
try {
await fn();
ok(false, `${msg} — kein Fehler (erwartet ${code})`);
} catch (err) {
const actual = (err as { code?: string }).code;
const pass = code === "*" || actual === code;
ok(pass, `${msg}${pass ? "" : ` — Code ${actual ?? (err as Error).message}`}`);
}
}
export function ctxFor(tenantId: string, userId: string, role: RoleKey): ServiceCtx {
return { db: dbForTenant(tenantId), tenantId, userId, permissions: new Set<string>(ROLE_DEFS[role].permissions) };
}
type MailInput = Parameters<typeof enqueueMail>[0];
export function captureMail() {
const sent: MailInput[] = [];
const fn = (async (input: MailInput) => {
sent.push(input);
return { status: "queued", mailLogId: `zz-${sent.length}` };
}) as typeof enqueueMail;
return { sent, fn };
}
export async function platformAdmin(role: "full" | "readonly" = "full") {
return prisma.platformAdmin.create({
data: { email: `platform-${role}-${Math.random().toString(36).slice(2, 8)}${DOMAIN}`, passwordHash: "x", name: `ZZ Platform ${role}`, role },
});
}
/** Trial tenant through the real provisioning (slug zz-l15-<name>). */
export async function trialTenant(name: string, endDateKey: string, opts: { sampleData?: boolean } = {}) {
const slugPart = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
return provisionTrialTenant({
companyName: `ZZ L15 ${name}`,
admin: { name: `Admin ${name}`, email: `admin-${slugPart}${DOMAIN}`, passwordHash: "x" },
endDateKey,
sampleData: opts.sampleData ?? false,
source: "platform",
});
}
export async function addMember(tenantId: string, local: string, role: RoleKey) {
const email = `${local}${DOMAIN}`;
const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } });
const roleRow = await prisma.role.findUniqueOrThrow({ where: { tenantId_key: { tenantId, key: role } } });
return prisma.user.create({ data: { tenantId, identityId: identity.id, email, name: local, userRoles: { create: [{ roleId: roleRow.id }] } } });
}
/** Removes a test tenant completely (offboarding of all tenant tables + rows around the tenant). */
export async function purgeTenant(tenantId: string) {
const exists = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { id: true } });
if (!exists) return;
await offboardTenant(tenantId, { reason: "zz-test-cleanup", purgeFiles: false });
await prisma.auditLog.deleteMany({ where: { tenantId } });
await prisma.mailLog.deleteMany({ where: { tenantId } });
await prisma.deletionCertificate.deleteMany({ where: { tenantId } });
await prisma.trialSignup.deleteMany({ where: { provisionedTenantId: tenantId } });
await prisma.tenant.delete({ where: { id: tenantId } });
}
export async function cleanupL15() {
const tenants = await prisma.tenant.findMany({ where: { slug: { startsWith: SLUG_PREFIX } }, select: { id: true } });
for (const t of tenants) await purgeTenant(t.id);
await prisma.trialSignup.deleteMany({ where: { email: { endsWith: DOMAIN } } });
await prisma.identity.deleteMany({ where: { email: { endsWith: DOMAIN }, memberships: { none: {} } } });
await prisma.platformAdmin.deleteMany({ where: { email: { endsWith: DOMAIN } } });
}
/** Minimal ZIP reader for the export tests (deflate entries written by src/server/backup/zip.ts). */
export function readZip(buf: Buffer): Map<string, Buffer> {
const out = new Map<string, Buffer>();
let offset = 0;
while (offset + 30 <= buf.length && buf.readUInt32LE(offset) === 0x04034b50) {
const method = buf.readUInt16LE(offset + 8);
const compressed = buf.readUInt32LE(offset + 18);
const nameLen = buf.readUInt16LE(offset + 26);
const extraLen = buf.readUInt16LE(offset + 28);
const name = buf.subarray(offset + 30, offset + 30 + nameLen).toString("utf8");
const start = offset + 30 + nameLen + extraLen;
const data = buf.subarray(start, start + compressed);
out.set(name, method === 8 ? inflateRawSync(data) : Buffer.from(data));
offset = start + compressed;
}
return out;
}
+155
View File
@@ -0,0 +1,155 @@
/**
* L15 Testphase — HTTP smoke against a running server, WITHOUT typing passwords (session cookies are
* built like scripts/smoke-auth.ts). Creates zz trial tenants (expired + running) through the real
* services, checks the public wizard pages, banners, the "Erste Schritte" card, the export page, the
* central write lock on real /api/v1 routes (customers, sync, uploads, work-order documents,
* backoffice upload) and the platform pages; removes the zz tenants afterwards.
*
* Usage: BASE=http://localhost:3115 npx tsx scripts/smoke-testphase.ts
*/
import "dotenv/config";
import { encode } from "next-auth/jwt";
import { prisma } from "../src/server/db";
import { finalizeIdentityLogin } from "../src/server/auth";
import { addDaysToKey, todayKey } from "../src/lib/trial/dates";
import { endTrialNow } from "../src/server/services/trial/admin";
import { addMember, cleanupL15, platformAdmin, trialTenant } from "./lib/testphase-fixture";
const BASE = process.env.BASE ?? "http://localhost:3115";
const SECURE = BASE.startsWith("https");
const COOKIE = SECURE ? "__Secure-authjs.session-token" : "authjs.session-token";
const PLATFORM_COOKIE = `${SECURE ? "__Secure-" : ""}platform-authjs.session-token`;
type Check = { label?: string; path: string; method?: string; body?: BodyInit; headers?: Record<string, string>; expect?: number[]; mustContain?: string[]; mustNotContain?: string[]; redirectTo?: string };
async function tenantCookie(email: string, slug: string): Promise<string> {
const identity = await prisma.identity.findUniqueOrThrow({ where: { email } });
const user = await finalizeIdentityLogin(identity.id, slug);
if (!user) throw new Error(`no membership for ${email} in ${slug}`);
const token = {
sub: user.id, name: user.name, email: user.email, userId: user.id, identityId: user.identityId, tenantId: user.tenantId, tenantSlug: user.tenantSlug,
activeMembershipId: user.activeMembershipId, memberships: user.memberships, roles: user.roles, permissions: user.permissions, isPlatformAdmin: user.isPlatformAdmin, mfaEnrolled: user.mfaEnrolled,
};
return `${COOKIE}=${await encode({ token, secret: process.env.AUTH_SECRET!, salt: COOKIE, maxAge: 1800 })}`;
}
async function platformCookie(adminId: string): Promise<string> {
const token = { sub: adminId, userId: adminId, isPlatformAdmin: true, mfaEnrolled: false };
return `${PLATFORM_COOKIE}=${await encode({ token, secret: process.env.AUTH_SECRET!, salt: PLATFORM_COOKIE, maxAge: 1800 })}`;
}
function multipart(fields: Record<string, string>, file?: { name: string; type: string; bytes: Buffer }): FormData {
const fd = new FormData();
for (const [k, v] of Object.entries(fields)) fd.set(k, v);
if (file) fd.set("file", new Blob([new Uint8Array(file.bytes)], { type: file.type }), file.name);
return fd;
}
async function main() {
await cleanupL15();
const today = todayKey();
const expired = await trialTenant("Smoke Abgelaufen", addDaysToKey(today, 5), { sampleData: true });
const running = await trialTenant("Smoke Laufend", addDaysToKey(today, 3), { sampleData: true });
const tech = await addMember(expired.tenantId, "tech-smoke", "technician");
const admin = await platformAdmin("full");
await endTrialNow({ platformAdminId: admin.id }, expired.tenantId);
const expiredSlug = (await prisma.tenant.findUniqueOrThrow({ where: { id: expired.tenantId } })).slug;
const runningSlug = (await prisma.tenant.findUniqueOrThrow({ where: { id: running.tenantId } })).slug;
const order = await prisma.workOrder.findFirstOrThrow({ where: { tenantId: expired.tenantId, status: "assigned" } });
const pdf = Buffer.from("%PDF-1.4\n%%EOF\n");
const plans: { who: string; cookie: string; checks: Check[] }[] = [
{
who: "anonym",
cookie: "",
checks: [
{ path: "/testen", mustContain: ["Craftvia kostenlos testen", "Firmenname", "Schritt 1 von 5", "Nutzungsbedingungen"] },
{ path: "/testen/bestaetigen?token=ungueltig", mustContain: ["ungültig"] },
{ path: "/testen/nutzungsbedingungen", mustContain: ["Nutzungsbedingungen", "Platzhalter"] },
{ path: "/testen/datenschutz", mustContain: ["Datenschutz"] },
{ path: "/dashboard", expect: [307], redirectTo: "/login" },
{ path: "/settings/export", expect: [307], redirectTo: "/login" },
],
},
{
who: `Admin abgelaufen (${expiredSlug})`,
cookie: await tenantCookie(`admin-smoke-abgelaufen@zz-l15.test`, expiredSlug),
checks: [
{ path: "/dashboard", mustContain: ["Testphase abgelaufen – nur Lesezugriff.", "Daten werden am", "Daten exportieren", 'data-trial-banner="expired"'] },
{ path: "/customers", mustContain: ["Testphase abgelaufen", "Hausverwaltung Musterhof"] },
{ path: "/settings/export", mustContain: ["Datenexport", "Export erstellen", "auch nach Ablauf"] },
{ path: "/api/v1/customers", mustContain: ['"data"'] },
{ label: "POST /api/v1/customers → gesperrt", path: "/api/v1/customers", method: "POST", body: JSON.stringify({ companyName: "ZZ Smoke" }), headers: { "content-type": "application/json" }, expect: [422], mustContain: ["trial_expired", "nur Lesezugriff"] },
{ label: "POST /api/v1/work-orders/[id]/documents → gesperrt", path: `/api/v1/work-orders/${order.id}/documents`, method: "POST", body: multipart({ category: "other", visibility: "team" }, { name: "a.pdf", type: "application/pdf", bytes: pdf }), headers: { accept: "application/json" }, expect: [422], mustContain: ["trial_expired"] },
{ label: "POST /documents/upload → gesperrt", path: "/documents/upload", method: "POST", body: multipart({ category: "other", visibility: "team" }, { name: "a.pdf", type: "application/pdf", bytes: pdf }), headers: { accept: "application/json" }, expect: [422], mustContain: ["trial_expired"] },
{ path: "/settings/export/unbekannt", expect: [404] },
],
},
{
who: `Monteur abgelaufen (${expiredSlug})`,
cookie: await tenantCookie(tech.email, expiredSlug),
checks: [
{ path: "/m", mustContain: ["Testphase abgelaufen – nur Lesezugriff.", 'data-trial-banner="expired"'] },
{ path: "/m/orders", mustContain: ["Testphase abgelaufen – nur Lesezugriff."] },
{ label: "GET /m/orders/[id] ohne Zuweisung → 404 (Scope)", path: `/m/orders/${order.id}`, expect: [404] },
{ path: "/m/emergency", mustContain: ["Testphase abgelaufen – nur Lesezugriff."] },
{ path: "/api/v1/field/bundle", mustContain: ['"orders"'] },
{ label: "POST /api/v1/sync → gesperrt", path: "/api/v1/sync", method: "POST", body: JSON.stringify({ deviceId: "zz-smoke", operations: [] }), headers: { "content-type": "application/json" }, expect: [422], mustContain: ["trial_expired"] },
{ label: "POST /api/v1/uploads → gesperrt", path: "/api/v1/uploads", method: "POST", body: multipart({ clientId: "7c1d6a0e-3b1f-4c55-9d2a-00000000f016", workOrderId: order.id, kind: "photo" }, { name: "a.jpg", type: "image/jpeg", bytes: Buffer.from([0xff, 0xd8, 0xff, 0xd9]) }), expect: [422], mustContain: ["trial_expired"] },
{ path: "/settings/export", expect: [307], redirectTo: "/dashboard" },
],
},
{
who: `Admin laufend (${runningSlug})`,
cookie: await tenantCookie(`admin-smoke-laufend@zz-l15.test`, runningSlug),
checks: [
{ path: "/dashboard", mustContain: ["Testphase endet in 3 Tagen.", "Erste Schritte", "von 5 erledigt", "Team anlegen", "Monteur einladen"] },
{ path: "/dashboard?welcome=1", mustContain: ["Willkommen! Ihre Testphase ist eingerichtet."] },
{ path: "/settings/export", mustContain: ["Datenexport"] },
],
},
{
who: "Plattform-Admin",
cookie: await platformCookie(admin.id),
checks: [
{ path: "/admin", mustContain: ["Testmandant anlegen", "ZZ L15 Smoke Abgelaufen", "abgelaufen – nur lesen", "Löschung am", "Test bis"] },
{ path: "/admin?plan=trial", mustContain: ["ZZ L15 Smoke Laufend"], mustNotContain: ["Musterbau Haustechnik"] },
{ path: "/admin?plan=full", mustNotContain: ["ZZ L15 Smoke Laufend"] },
{ path: "/admin/trial", mustContain: ["Testmandant anlegen", "Testphase bis", "Mit Beispieldaten"] },
{ path: `/admin/${expired.tenantId}`, mustContain: ["Testphase", "abgelaufen – nur lesen", "In Vollversion umwandeln", "Enddatum ändern / verlängern", "Löschung abbrechen"] },
{ path: `/admin/${expired.tenantId}?trial=extend`, mustContain: ["Enddatum ändern", "Ich bestätige diese Änderung."] },
{ path: `/admin/${running.tenantId}?trial=end`, mustContain: ["Testphase sofort beenden"] },
],
},
];
let failures = 0;
let total = 0;
for (const plan of plans) {
console.log(`\n== ${plan.who}`);
for (const c of plan.checks) {
total++;
const res = await fetch(BASE + c.path, { method: c.method ?? "GET", body: c.body, headers: { ...(plan.cookie ? { cookie: plan.cookie } : {}), ...c.headers }, redirect: "manual", signal: AbortSignal.timeout(120_000) });
const body = res.status >= 300 && res.status < 400 ? "" : await res.text();
const expect = c.expect ?? [200];
const loc = res.headers.get("location");
const okRedirect = !c.redirectTo || (loc ? new URL(loc, BASE).pathname === c.redirectTo : false);
const errorPage = res.status === 200 && /Application error|Internal Server Error|Unhandled Runtime Error/i.test(body);
const missing = (c.mustContain ?? []).filter((s) => !body.includes(s));
const leaked = (c.mustNotContain ?? []).filter((s) => body.includes(s));
const pass = expect.includes(res.status) && okRedirect && !errorPage && missing.length === 0 && leaked.length === 0;
if (!pass) failures++;
console.log(`${pass ? "✓" : "✗"} ${String(res.status).padEnd(3)} ${c.label ?? `${c.method ?? "GET"} ${c.path}`}${loc ? ` → ${loc}` : ""}${missing.length ? ` [fehlt: ${missing.join(" | ")}]` : ""}${leaked.length ? ` [unerwartet: ${leaked.join(" | ")}]` : ""}`);
}
}
await cleanupL15();
await prisma.$disconnect();
console.log(failures ? `\n${failures} von ${total} Prüfungen fehlgeschlagen` : `\nOK — ${total} Prüfungen`);
process.exit(failures ? 1 : 0);
}
main().catch(async (err) => {
console.error(err);
await cleanupL15().catch(() => undefined);
process.exit(1);
});
+1
View File
@@ -141,6 +141,7 @@ async function createModelRows(A: TenantFixture): Promise<Rows> {
put("LotseConversation", lotseConversation.id, { workOrderId: MARK });
put("LotseMessage", (await prisma.lotseMessage.create({ data: { tenantId: t, conversationId: lotseConversation.id, role: "user", text: "ZZ" } })).id, { text: MARK });
put("LotseActionProposal", (await prisma.lotseActionProposal.create({ data: { tenantId: t, conversationId: lotseConversation.id, userId: uid, kind: "add_note", payload: {}, payloadHash: "0".repeat(64), expiresAt: new Date(Date.now() + 1_800_000) } })).id, { kind: MARK });
put("TenantExport", (await prisma.tenantExport.create({ data: { tenantId: t, status: "done", fileName: "zz.zip" } })).id, { fileName: MARK }); // L15 Testphase
return rows;
}
+216
View File
@@ -0,0 +1,216 @@
// Lane L15 „Testphase & Onboarding" — Lebenszyklus-Job und Plattform-Aktionen:
// Erinnerungen 7/3/1 Tage genau einmal (auch nach verpasstem Lauf), Ablaufmail, Löschhinweis,
// Löschung nur fällig + TRIAL (über das bestehende Offboarding inkl. Speicher), umgewandelte,
// verlängerte oder abgebrochene Mandanten bleiben erhalten, Mandant B unberührt;
// Plattform-Rechte (Mandanten-Admin/Read-only-Admin können die Testphase nicht ändern),
// Plattform-Wizard mit Einladung, Mail-Vorlagen, Worker-Registrierung.
//
// Lauf: npx tsx scripts/test-testphase-lifecycle.ts (lokale Postgres-DB + Garage aus .env)
import "dotenv/config";
import { readFileSync } from "node:fs";
import { prisma, dbForTenant } from "../src/server/db";
import { PROCESSORS } from "../src/server/jobs/processors";
import { renderTemplate, TRIAL_TEMPLATE_KEYS, type TemplateVars } from "../src/server/mail/templates";
import { addDaysToKey, todayKey, trialEndInstant } from "../src/lib/trial/dates";
import {
cancelTrialDeletion,
changeTrialEndDate,
convertTenantToFull,
createPlatformTrialTenant,
endTrialNow,
scheduleTrialDeletion,
} from "../src/server/services/trial/admin";
import { deleteTrialTenant, runTrialLifecycle } from "../src/server/services/trial/lifecycle";
import { getTrialState } from "../src/server/services/trial/state";
import { storage } from "../src/server/storage/adapter";
import { readStoredBytes } from "../src/server/services/documents/read";
import { captureMail, cleanupL15, ctxFor, DOMAIN, expectCode, failures, ok, platformAdmin, trialTenant } from "./lib/testphase-fixture";
const HOUR = 3600_000;
const DAY = 24 * HOUR;
async function main() {
await cleanupL15();
const full = await platformAdmin("full");
const readonly = await platformAdmin("readonly");
const actor = { platformAdminId: full.id };
const today = todayKey();
console.log("\n— Erinnerungen 7/3/1 genau einmal —");
const R = await trialTenant("Lifecycle R", addDaysToKey(today, 7));
const endR = trialEndInstant(addDaysToKey(today, 7));
const at = (dayOffset: number) => new Date(endR.getTime() - DAY * 7 + dayOffset * DAY - 12 * HOUR); // noon-ish of "today + offset"
const mail = captureMail();
const run = (now: Date, ids: string[]) => runTrialLifecycle({ now, tenantIds: ids, sendMail: mail.fn });
const count = (tpl: string, tenantId: string) => mail.sent.filter((m) => m.template === tpl && m.tenantId === tenantId).length;
await run(at(0), [R.tenantId]);
ok(count("trial_reminder", R.tenantId) === 1 && (mail.sent[0].vars as { daysLeft: number }).daysLeft === 7, "7 Tage vorher: Erinnerung an den Admin");
ok(mail.sent[0].to === `admin-lifecycle-r${DOMAIN}`, "Empfänger = aktiver Mandanten-Admin");
await run(at(0), [R.tenantId]);
await run(at(1), [R.tenantId]);
ok(count("trial_reminder", R.tenantId) === 1, "zweiter Lauf / Folgetag: keine weitere Erinnerung");
await run(at(4), [R.tenantId]);
ok(count("trial_reminder", R.tenantId) === 2, "3 Tage vorher: zweite Erinnerung");
await run(at(4), [R.tenantId]);
await run(at(6), [R.tenantId]);
await run(at(6), [R.tenantId]);
ok(count("trial_reminder", R.tenantId) === 3, "1 Tag vorher: dritte Erinnerung, jeweils genau einmal");
const markers = await prisma.tenant.findUniqueOrThrow({ where: { id: R.tenantId } });
ok(!!markers.trialReminder7At && !!markers.trialReminder3At && !!markers.trialReminder1At, "Versandmarker gesetzt");
const R2 = await trialTenant("Lifecycle R2", addDaysToKey(today, 2));
await run(new Date(), [R2.tenantId]);
const r2 = await prisma.tenant.findUniqueOrThrow({ where: { id: R2.tenantId } });
ok(count("trial_reminder", R2.tenantId) === 1 && !!r2.trialReminder7At && !!r2.trialReminder3At && !r2.trialReminder1At, "verpasster Lauf: nur die nächstliegende Erinnerung, ältere als erledigt markiert");
console.log("\n— Ablauf, Löschhinweis, Löschung —");
const endExpired = new Date(endR.getTime() + HOUR);
await run(endExpired, [R.tenantId]);
await run(endExpired, [R.tenantId]);
ok(count("trial_expired", R.tenantId) === 1, "Ablaufmail am Endtag genau einmal");
const expiredRow = await prisma.tenant.findUniqueOrThrow({ where: { id: R.tenantId } });
ok(expiredRow.readOnlySince?.getTime() === endR.getTime(), "readOnlySince = Ende der Testphase");
const expiredMail = mail.sent.find((m) => m.template === "trial_expired" && m.tenantId === R.tenantId)!;
ok(!!(expiredMail.vars as { deletionDate?: string }).deletionDate && (expiredMail.vars as { exportUrl: string }).exportUrl.endsWith("/settings/export"), "Ablaufmail nennt Löschdatum und Export");
const due = expiredRow.deletionDueAt!;
ok(due.getTime() === endR.getTime() + 30 * DAY, "Löschung 30 Tage nach Ende fällig");
await run(new Date(due.getTime() - 8 * DAY), [R.tenantId]);
ok(count("trial_deletion_notice", R.tenantId) === 0, "8 Tage vor Löschung noch kein Hinweis");
await run(new Date(due.getTime() - 6 * DAY), [R.tenantId]);
await run(new Date(due.getTime() - 5 * DAY), [R.tenantId]);
ok(count("trial_deletion_notice", R.tenantId) === 1, "Löschhinweis 7 Tage vorher genau einmal");
// tenant data + stored object that must disappear
const dbR = dbForTenant(R.tenantId);
const customerR = await dbR.customer.create({ data: { tenantId: R.tenantId, companyName: "ZZ Löschkunde", city: "Bremen" } });
const stored = await storage.put({ tenantId: R.tenantId, filename: "zz-loeschen.txt", contentType: "text/plain", bytes: Buffer.from("zz") });
const identityR = await prisma.user.findFirstOrThrow({ where: { tenantId: R.tenantId }, select: { identityId: true } });
// untouched neighbours: FULL tenant B (via provisioning then converted), not due trial B2
const B = await trialTenant("Lifecycle B", addDaysToKey(today, 3));
await convertTenantToFull(actor, B.tenantId);
const customerB = await dbForTenant(B.tenantId).customer.create({ data: { tenantId: B.tenantId, companyName: "ZZ Kunde B", city: "Kiel" } });
const B2 = await trialTenant("Lifecycle B2", addDaysToKey(today, 20));
ok(!(await deleteTrialTenant(R.tenantId, { now: new Date(due.getTime() - HOUR) })).deleted, "vor Fälligkeit: keine Löschung");
const summary = await run(new Date(due.getTime() + HOUR), [R.tenantId, B.tenantId, B2.tenantId]);
ok(summary.deleted.length === 1 && summary.deleted[0] === R.tenantId, "fälliger Testmandant gelöscht, nur dieser");
const gone = await prisma.tenant.findUniqueOrThrow({ where: { id: R.tenantId } });
ok(gone.status === "ARCHIVED" && !!gone.trialDeletedAt && gone.deletionDueAt === null, "Mandant archiviert, Löschung protokolliert am Mandanten");
ok((await prisma.customer.count({ where: { tenantId: R.tenantId } })) === 0 && (await prisma.user.count({ where: { tenantId: R.tenantId } })) === 0 && (await prisma.role.count({ where: { tenantId: R.tenantId } })) === 0, "alle Mandanten-Tabellen geleert (Topologie)");
ok((await prisma.identity.findUnique({ where: { id: identityR.identityId } })) === null, "Identity ohne weitere Mitgliedschaft gelöscht");
ok((await readStoredBytes(stored.storageKey)) === null, "Speicherobjekte unter <tenantId>/ entfernt");
ok((await prisma.deletionCertificate.count({ where: { tenantId: R.tenantId, scope: "tenant" } })) === 1, "Löschnachweis erstellt");
ok((await prisma.auditLog.count({ where: { scope: "platform", entity: "trial_tenant", entityId: R.tenantId, action: "delete" } })) === 1, "Plattform-Audit der Löschung");
ok((await prisma.customer.findUnique({ where: { id: customerR.id } })) === null, "Kunde des gelöschten Mandanten entfernt");
ok((await dbForTenant(B.tenantId).customer.count({ where: { id: customerB.id } })) === 1 && (await prisma.tenant.findUniqueOrThrow({ where: { id: B.tenantId } })).status === "ACTIVE", "Mandant B (Vollversion) unberührt");
ok((await prisma.tenant.findUniqueOrThrow({ where: { id: B2.tenantId } })).trialDeletedAt === null, "nicht fälliger Testmandant unberührt");
await run(new Date(due.getTime() + 2 * HOUR), [R.tenantId]);
ok((await prisma.auditLog.count({ where: { scope: "platform", entity: "trial_tenant", entityId: R.tenantId } })) === 1, "gelöschter Mandant wird nicht erneut verarbeitet");
console.log("\n— Umwandeln, Verlängern, Abbrechen verhindern die Löschung —");
const X = await trialTenant("Lifecycle X", addDaysToKey(today, 1));
await endTrialNow(actor, X.tenantId);
const xDue = (await prisma.tenant.findUniqueOrThrow({ where: { id: X.tenantId } })).deletionDueAt!;
await convertTenantToFull(actor, X.tenantId);
const xRow = await prisma.tenant.findUniqueOrThrow({ where: { id: X.tenantId } });
ok(xRow.plan === "FULL" && !!xRow.convertedAt && xRow.deletionDueAt === null && !(await getTrialState(X.tenantId)).readOnly, "Umwandeln: Vollversion, schreibbar, keine Löschung vorgemerkt");
await run(new Date(xDue.getTime() + DAY), [X.tenantId]);
ok(!(await deleteTrialTenant(X.tenantId, { now: new Date(xDue.getTime() + DAY) })).deleted && (await prisma.tenant.findUniqueOrThrow({ where: { id: X.tenantId } })).status === "ACTIVE", "umgewandelter Mandant wird nie gelöscht (Doppelprüfung)");
const Y = await trialTenant("Lifecycle Y", addDaysToKey(today, 1));
await endTrialNow(actor, Y.tenantId);
const yOldDue = (await prisma.tenant.findUniqueOrThrow({ where: { id: Y.tenantId } })).deletionDueAt!;
await changeTrialEndDate(actor, Y.tenantId, addDaysToKey(today, 60));
const yRow = await prisma.tenant.findUniqueOrThrow({ where: { id: Y.tenantId } });
ok(yRow.trialEndsAt!.getTime() === trialEndInstant(addDaysToKey(today, 60)).getTime() && yRow.deletionDueAt!.getTime() > yOldDue.getTime() && yRow.readOnlySince === null && yRow.trialExpiredNoticeAt === null, "Verlängern: neues Ende, Löschtermin verschoben, Marker zurückgesetzt");
await run(new Date(yOldDue.getTime() + DAY), [Y.tenantId]);
ok((await prisma.tenant.findUniqueOrThrow({ where: { id: Y.tenantId } })).trialDeletedAt === null, "verlängerter Mandant nach altem Löschtermin nicht gelöscht");
const Z = await trialTenant("Lifecycle Z", addDaysToKey(today, 1));
await endTrialNow(actor, Z.tenantId);
const zDue = (await prisma.tenant.findUniqueOrThrow({ where: { id: Z.tenantId } })).deletionDueAt!;
await cancelTrialDeletion(actor, Z.tenantId);
await run(new Date(zDue.getTime() + 10 * DAY), [Z.tenantId]);
const zRow = await prisma.tenant.findUniqueOrThrow({ where: { id: Z.tenantId } });
ok(zRow.trialDeletedAt === null && (await getTrialState(Z.tenantId, new Date(zDue.getTime() + 10 * DAY))).readOnly, "Löschung abgebrochen: bleibt erhalten, weiter nur lesbar");
await scheduleTrialDeletion(actor, Z.tenantId);
const zScheduled = (await prisma.tenant.findUniqueOrThrow({ where: { id: Z.tenantId } })).deletionDueAt!;
ok(zScheduled.getTime() >= Date.now() + 7 * DAY - HOUR, "Löschung vormerken: frühestens in 7 Tagen (Hinweis möglich)");
await changeTrialEndDate(actor, Z.tenantId, addDaysToKey(today, 3));
ok((await prisma.tenant.findUniqueOrThrow({ where: { id: Z.tenantId } })).deletionDueAt!.getTime() === trialEndInstant(addDaysToKey(today, 3)).getTime() + 30 * DAY, "vorgemerkte Löschung folgt dem neuen Enddatum");
const auditZ = await prisma.auditLog.findMany({ where: { tenantId: Z.tenantId, scope: "platform", entity: { startsWith: "trial_" } }, orderBy: { createdAt: "asc" } });
ok(auditZ.map((a) => a.entity).join(",") === "trial_ended,trial_deletion_cancelled,trial_deletion_scheduled,trial_end_date" && auditZ.every((a) => a.actorId === full.id && a.before && a.after), "Plattform-Audit je Aktion mit before/after");
console.log("\n— Plattform-Rechte —");
const P = await trialTenant("Lifecycle P", addDaysToKey(today, 5));
const tenantAdminActor = { platformAdminId: P.adminUserId };
const endBefore = (await prisma.tenant.findUniqueOrThrow({ where: { id: P.tenantId } })).trialEndsAt!.getTime();
await expectCode(() => changeTrialEndDate(tenantAdminActor, P.tenantId, addDaysToKey(today, 30)), "forbidden", "Mandanten-Admin kann die Testphase nicht verlängern");
await expectCode(() => convertTenantToFull(tenantAdminActor, P.tenantId), "forbidden", "Mandanten-Admin kann nicht umwandeln");
await expectCode(() => cancelTrialDeletion(tenantAdminActor, P.tenantId), "forbidden", "Mandanten-Admin kann die Löschung nicht abbrechen");
await expectCode(() => changeTrialEndDate({ platformAdminId: readonly.id }, P.tenantId, addDaysToKey(today, 30)), "forbidden", "Read-only-Plattform-Admin kann nichts ändern");
await expectCode(() => createPlatformTrialTenant(tenantAdminActor, { companyName: "ZZ L15 Hack", adminName: "Hack", adminEmail: `hack${DOMAIN}`, endDate: addDaysToKey(today, 5), sampleData: false }), "forbidden", "Mandanten-Admin kann keinen Testmandanten anlegen");
await expectCode(() => changeTrialEndDate(actor, P.tenantId, addDaysToKey(today, -1)), "invalid", "Enddatum in der Vergangenheit → invalid");
await expectCode(() => changeTrialEndDate(actor, P.tenantId, addDaysToKey(today, 400)), "invalid", "Enddatum > 1 Jahr → invalid");
await expectCode(() => changeTrialEndDate(actor, X.tenantId, addDaysToKey(today, 10)), "invalid", "Vollversion: keine Testphasen-Aktion");
ok((await prisma.tenant.findUniqueOrThrow({ where: { id: P.tenantId } })).trialEndsAt!.getTime() === endBefore, "Testphase von P unverändert");
const { trialLifecycleAction } = await import("../src/server/actions/trial-platform");
const fd = new FormData();
fd.set("confirm", "on");
fd.set("endDate", addDaysToKey(today, 40));
await expectCode(() => trialLifecycleAction(P.tenantId, "extend", { status: "idle" }, fd), "*", "Action ohne Plattform-Session wird abgewiesen");
ok((await prisma.tenant.findUniqueOrThrow({ where: { id: P.tenantId } })).trialEndsAt!.getTime() === endBefore, "… und ändert nichts");
const platformSrc = readFileSync("src/server/actions/trial-platform.ts", "utf8");
const exportsP = [...platformSrc.matchAll(/export async function (\w+)/g)].map((m) => m[1]);
ok(exportsP.length === 2 && exportsP.every((n) => new RegExp(`export async function ${n}[\\s\\S]*?await requirePlatformFullAdmin\\(\\)`).test(platformSrc)), "jede Plattform-Action verlangt einen Plattform-Voll-Admin");
const tenantSrc = readFileSync("src/server/actions/trial-tenant.ts", "utf8");
ok(!/services\/trial\/admin/.test(tenantSrc) && !/trialEndsAt|deletionDueAt|convertedAt/.test(tenantSrc), "Mandanten-Actions enthalten keine Testphasen-Änderung");
console.log("\n— Plattform-Wizard mit Einladung —");
const invites: { to: string; tenantId: string }[] = [];
const created = await createPlatformTrialTenant(actor, { companyName: "ZZ L15 Wizard GmbH", sector: "Elektro", adminName: "Wanda Wizard", adminEmail: `wanda${DOMAIN}`, endDate: addDaysToKey(today, 45), sampleData: true }, { invite: async (i) => void invites.push({ to: i.to, tenantId: i.tenantId }) });
const w = await prisma.tenant.findUniqueOrThrow({ where: { id: created.tenantId } });
ok(w.plan === "TRIAL" && w.trialSource === "platform" && w.trialEndsAt!.getTime() === trialEndInstant(addDaysToKey(today, 45)).getTime(), "Wizard: Testmandant mit frei gesetztem Enddatum (> TRIAL_MAX_DAYS)");
ok(created.invited && invites.length === 1 && invites[0].to === `wanda${DOMAIN}` && invites[0].tenantId === w.id, "Einladung über den bestehenden Einladungsweg");
const wId = await prisma.identity.findUniqueOrThrow({ where: { email: `wanda${DOMAIN}` } });
ok(wId.mustChangePassword, "neue Identity: Passwort wird über die Einladung gesetzt");
ok((await prisma.authToken.count({ where: { principalId: wId.id, type: "invitation", usedAt: null } })) === 1, "Einladungs-Token ausgestellt (Hash)");
ok((await dbForTenant(w.id).workOrder.count()) === 4, "Wizard mit Beispieldaten");
const again = await createPlatformTrialTenant(actor, { companyName: "ZZ L15 Wizard Zwei", adminName: "Wanda Wizard", adminEmail: `wanda${DOMAIN}`, endDate: addDaysToKey(today, 10), sampleData: false }, { invite: async (i) => void invites.push({ to: i.to, tenantId: i.tenantId }) });
ok(!again.invited && invites.length === 1 && again.identityId === wId.id, "bestehende Person: nur verknüpft, keine neue Einladung");
ok((await prisma.auditLog.count({ where: { tenantId: w.id, entity: "trial", actorId: full.id } })) === 1, "Audit mit handelndem Plattform-Admin");
console.log("\n— Vorlagen und Worker —");
const sample: { [K in (typeof TRIAL_TEMPLATE_KEYS)[number]]: TemplateVars[K] } = {
trial_confirm: { name: "Paula", companyName: "Muster", trialEnd: "30.09.2026", actionUrl: "https://x.example/testen/bestaetigen?token=abc", expires: "16.09.2026, 10:00" },
trial_existing_account: { name: "Paula", loginUrl: "https://x.example/login", resetUrl: "https://x.example/forgot-password" },
trial_reminder: { name: "Paula", tenantName: "Muster", daysLeft: 3, endDate: "30.09.2026", actionUrl: "https://x.example/dashboard", contact: "vertrieb@craftvia.example" },
trial_expired: { name: "Paula", tenantName: "Muster", endDate: "30.09.2026", deletionDate: "30.10.2026", exportUrl: "https://x.example/settings/export" },
trial_deletion_notice: { name: "Paula", tenantName: "Muster", deletionDate: "30.10.2026", exportUrl: "https://x.example/settings/export" },
};
for (const key of TRIAL_TEMPLATE_KEYS) {
for (const locale of ["de", "en"] as const) {
const r = renderTemplate(key, locale, sample[key] as never);
ok(r.subject.length > 5 && !/undefined|\{/.test(r.subject + r.text) && r.html.includes("<"), `Vorlage ${key} (${locale}) rendert`);
}
}
ok(renderTemplate("trial_reminder", "de", { ...sample.trial_reminder, daysLeft: 1 }).subject.includes("morgen") && renderTemplate("trial_reminder", "de", { ...sample.trial_reminder, daysLeft: 0 }).subject.includes("heute"), "Erinnerung: „morgen“/„heute“");
ok(typeof PROCESSORS["trial-lifecycle"] === "function" && typeof PROCESSORS["tenant-export"] === "function", "Processor trial-lifecycle und tenant-export registriert");
ok(readFileSync("src/server/jobs/queues.ts", "utf8").includes('"trial-lifecycle-daily"'), "täglicher Job-Scheduler angelegt");
const ctxP = ctxFor(P.tenantId, P.adminUserId, "tenant-admin");
ok((await ctxP.db.tenantSettings.findFirst())?.orgName === "ZZ L15 Lifecycle P", "Mandanten-Einstellungen provisioniert");
await cleanupL15();
await prisma.$disconnect();
console.log(failures ? `\n${failures} Prüfung(en) fehlgeschlagen` : "\nAlle Prüfungen bestanden");
process.exit(failures ? 1 : 0);
}
main().catch(async (err) => {
console.error(err);
await cleanupL15().catch(() => undefined);
process.exit(1);
});
+189
View File
@@ -0,0 +1,189 @@
// Lane L15 „Testphase & Onboarding" — Nur-Lesen nach Ablauf + Export:
// zentrale Schreibsperre (moduleGuard, /api/v1-Mutationen inkl. Sync und Uploads, Backoffice-Upload,
// Einstellungen/Nutzerverwaltung, Worker-Jobs im Namen von Nutzern) bei gleichzeitig erlaubtem Lesen,
// Downloads und Export; Verlängern hebt die Sperre auf; Onboarding-Checkliste; Mandanten-Export
// (ZIP mit CSV/JSON + Dateien) inkl. Mandantentrennung und Rollen.
//
// Lauf: npx tsx scripts/test-testphase-readonly.ts (lokale Postgres-DB + Garage aus .env)
import "dotenv/config";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { prisma } from "../src/server/db";
import { json, withApi } from "../src/server/api/respond";
import { assertApiWriteAllowed } from "../src/server/api/context";
import { applyOperations } from "../src/server/services/sync/apply";
import { storeFile } from "../src/server/services/documents/store";
import { addDaysToKey, todayKey } from "../src/lib/trial/dates";
import { changeTrialEndDate, endTrialNow } from "../src/server/services/trial/admin";
import { buildTenantExport, listTenantExports, openTenantExport, requestTenantExport, toCsv } from "../src/server/services/trial/export";
import { isJobBlockedByTrial } from "../src/server/services/trial/jobs";
import { getOnboardingChecklist, setOnboardingHidden, setOnboardingItem } from "../src/server/services/trial/onboarding";
import { assertTenantWritable, getTrialState } from "../src/server/services/trial/state";
import { addMember, cleanupL15, ctxFor, expectCode, failures, ok, platformAdmin, readZip, trialTenant } from "./lib/testphase-fixture";
const post = (body: unknown = {}) => new Request("http://localhost/api/v1/zz", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
const get = () => new Request("http://localhost/api/v1/zz", { method: "GET" });
function walk(dir: string): string[] {
return readdirSync(dir).flatMap((n) => (statSync(join(dir, n)).isDirectory() ? walk(join(dir, n)) : [join(dir, n)]));
}
async function main() {
await cleanupL15();
const actor = { platformAdminId: (await platformAdmin("full")).id };
const today = todayKey();
const A = await trialTenant("Readonly A", addDaysToKey(today, 10), { sampleData: true });
const B = await trialTenant("Readonly B", addDaysToKey(today, 10));
const adminA = ctxFor(A.tenantId, A.adminUserId, "tenant-admin");
const adminB = ctxFor(B.tenantId, B.adminUserId, "tenant-admin");
const techA = await addMember(A.tenantId, "tech-readonly-a", "technician");
const ctxTechA = ctxFor(A.tenantId, techA.id, "technician");
await adminB.db.customer.create({ data: { tenantId: B.tenantId, companyName: "ZZ Kunde nur B", city: "Kiel" } });
console.log("\n— laufende Testphase: schreibbar, Onboarding —");
await assertTenantWritable(A.tenantId);
ok(true, "laufende Testphase ist schreibbar");
const checklist = await getOnboardingChecklist(adminA);
ok(checklist?.items.length === 5 && checklist.items.find((i) => i.key === "team")?.done === false, "Erste Schritte: 5 Punkte, Beispielteam zählt nicht als eigenes Team");
ok(checklist?.items.find((i) => i.key === "technician")?.done === true, "Erste Schritte: eingeladener Monteur automatisch erkannt");
ok(checklist?.items.find((i) => i.key === "first_order")?.done === false, "Erste Schritte: Beispielaufträge zählen nicht als erster Auftrag");
await setOnboardingItem(adminA, "mobile", true);
ok((await getOnboardingChecklist(adminA))?.items.find((i) => i.key === "mobile")?.done === true, "Punkt manuell abgehakt");
await expectCode(() => setOnboardingItem(ctxTechA, "mobile", false), "forbidden", "Monteur kann die Checkliste nicht ändern");
await expectCode(() => setOnboardingItem(adminA, "hacken", true), "invalid", "unbekannter Punkt → invalid");
ok((await getOnboardingChecklist(ctxTechA)) === null, "Monteur sieht die Checkliste nicht");
const fullTenant = await prisma.tenant.findUniqueOrThrow({ where: { slug: "demo" }, select: { id: true } }).catch(() => null);
if (fullTenant) {
const demoAdmin = await prisma.user.findFirst({ where: { tenantId: fullTenant.id, email: "admin@demo.example" } });
if (demoAdmin) ok((await getOnboardingChecklist(ctxFor(fullTenant.id, demoAdmin.id, "tenant-admin"))) === null, "Vollversions-Mandant ohne Testphase → keine Checkliste");
}
console.log("\n— Ablauf: zentrale Schreibsperre —");
await endTrialNow(actor, A.tenantId);
const stateA = await getTrialState(A.tenantId);
ok(stateA.readOnly && stateA.expired && !!stateA.deletionDueAt, "Testphase beendet → nur lesen, Löschtermin gesetzt");
try {
await assertTenantWritable(A.tenantId);
ok(false, "Schreibsperre greift nicht");
} catch (err) {
const e = err as { code?: string; message?: string; details?: { readOnly?: boolean; message?: string } };
ok(e.code === "blocked" && e.message === "trial_expired" && e.details?.readOnly === true && !!e.details.message, "ServiceError blocked/trial_expired mit Klartext");
}
await assertTenantWritable(B.tenantId);
ok(true, "Mandant B bleibt schreibbar");
// /api/v1: the same wrapper + check the real routes use (withApi → requireApiContext → assertApiWriteAllowed)
const handler = (tenantId: string) => withApi(async () => {
await assertApiWriteAllowed(tenantId);
return json({ ok: true });
});
const blocked = await handler(A.tenantId)(post());
const blockedBody = (await blocked.json()) as { error?: { code: string; message: string; details?: { readOnly?: boolean } } };
ok(blocked.status === 422 && blockedBody.error?.code === "blocked" && blockedBody.error.message === "trial_expired" && blockedBody.error.details?.readOnly === true, "API-Mutation (POST) → 422 blocked trial_expired");
ok((await handler(A.tenantId)(new Request("http://localhost/x", { method: "DELETE" }))).status === 422, "API-Mutation (DELETE) → 422");
ok((await handler(A.tenantId)(get())).status === 200, "API-Lesezugriff (GET) bleibt erlaubt");
ok((await handler(B.tenantId)(post())).status === 200, "API-Mutation von Mandant B unberührt");
await assertApiWriteAllowed(A.tenantId);
ok(true, "außerhalb von withApi (Downloads /files, Export) keine Sperre");
const syncBefore = await prisma.syncOperation.count({ where: { tenantId: A.tenantId } });
const order = await adminA.db.workOrder.findFirstOrThrow({ where: { status: "assigned" } });
const sync = withApi(async (req: Request) => {
await assertApiWriteAllowed(A.tenantId);
return json(await applyOperations(ctxTechA, (await req.json()) as never));
});
const syncRes = await sync(post({ deviceId: "zz", operations: [{ clientOpId: "7c1d6a0e-3b1f-4c55-9d2a-00000000f015", opType: "note.create", payload: { workOrderId: order.id, kind: "work_done", text: "zz" }, clientCreatedAt: new Date().toISOString() }] }));
ok(syncRes.status === 422 && (await prisma.syncOperation.count({ where: { tenantId: A.tenantId } })) === syncBefore, "Sync-Batch → 422, keine Operation angewendet (Outbox wiederholt später)");
// static coverage of every write entry point
const guard = readFileSync("src/server/action-guard.ts", "utf8");
ok(/assertModuleEnabled\(session, moduleKey\);[\s\S]*if \(!opts\.read\) await assertTenantWritable\(session\.user\.tenantId\);[\s\S]*return \{ session, db/.test(guard), "moduleGuard: Schreibsperre für alle Modul-Actions (inkl. Lotse, Uploads per Action)");
const actionFiles = walk("src/server/actions").filter((f) => f.endsWith(".ts"));
ok(actionFiles.every((f) => !/moduleGuard\([^)]*read\s*:/.test(readFileSync(f, "utf8"))), "keine Server-Action nutzt den Lese-Modus des Guards");
const readUsers = walk("src").filter((f) => /\.tsx?$/.test(f) && /moduleGuard\([^)]*read\s*:\s*true/.test(readFileSync(f, "utf8"))).sort();
ok(JSON.stringify(readUsers) === JSON.stringify(["src/app/(app)/imports/[id]/file/route.ts", "src/app/(field)/m/emergency/page.tsx", "src/server/services/field/page-context.ts"]), `Lese-Modus nur in Seitenkontexten/GET-Download (${readUsers.join(", ")})`);
ok(readFileSync("scripts/check-module-guards.ts", "utf8").includes("nur für Lesepfade erlaubt"), "Guard-Check verbietet den Lese-Modus in Actions");
const context = readFileSync("src/server/api/context.ts", "utf8");
ok(/enforceApiRateLimit\(session\.user\.id, moduleKey\);\s*await assertApiWriteAllowed\(tenantId\);/.test(context), "requireApiContext: Schreibsperre für jede /api/v1-Mutation");
const routes = walk("src/app/api/v1").filter((f) => f.endsWith("route.ts"));
const mutating = routes.filter((f) => /export const (POST|PUT|PATCH|DELETE)\b|export async function (POST|PUT|PATCH|DELETE)\b/.test(readFileSync(f, "utf8")));
// every mutating route: withApi (central lock in requireApiContext) OR an explicit assertTenantWritable
const unwrapped = mutating.filter((f) => {
const src = readFileSync(f, "utf8");
return !/export const (POST|PUT|PATCH|DELETE) = withApi\(/.test(src) && !/await assertTenantWritable\(ctx\.tenantId\)/.test(src);
});
ok(mutating.length > 10 && unwrapped.length === 0, `alle ${mutating.length} mutierenden /api/v1-Routen gesperrt (withApi bzw. explizit; Sync und Uploads eingeschlossen)${unwrapped.length ? ` — ohne: ${unwrapped.join(", ")}` : ""}`);
ok(readFileSync("src/app/(app)/documents/upload/route.ts", "utf8").includes("await assertTenantWritable(ctx.tenantId)"), "Backoffice-Upload-Route gesperrt");
ok(readFileSync("src/server/actions/tenant-settings.ts", "utf8").includes("await assertTenantWritable(tenantId)"), "Einstellungen gesperrt");
ok(readFileSync("src/server/actions/tenant-users.ts", "utf8").includes("await assertTenantWritable(session.user.tenantId)"), "Nutzer-/Rollenverwaltung gesperrt");
ok(readFileSync("src/server/actions/lotse-settings.ts", "utf8").includes("await assertTenantWritable(ctx.tenantId)"), "Lotse-Einstellungen gesperrt");
ok(readFileSync("scripts/craftvia-worker.ts", "utf8").includes("isJobBlockedByTrial(name, job.data)"), "Worker prüft die Sperre vor jedem Job");
ok(await isJobBlockedByTrial("import-extraction", { tenantId: A.tenantId }), "Job Import-Extraktion für abgelaufenen Mandanten übersprungen");
ok(await isJobBlockedByTrial("transcription", { tenantId: A.tenantId }), "Job Transkription (Lotse) übersprungen");
ok(!(await isJobBlockedByTrial("report-pdf", { tenantId: A.tenantId })), "PDF-Erzeugung bereits gespeicherter Berichte läuft weiter");
ok(!(await isJobBlockedByTrial("tenant-export", { tenantId: A.tenantId })), "Export-Job läuft auch im Nur-Lesen-Zustand");
ok(!(await isJobBlockedByTrial("import-extraction", { tenantId: B.tenantId })), "Jobs von Mandant B unberührt");
ok(!(await isJobBlockedByTrial("trial-lifecycle", { tenantId: "*" })), "plattformweite Jobs nicht betroffen");
await expectCode(() => setOnboardingItem(adminA, "team", true), "blocked", "Checkliste im Nur-Lesen-Zustand gesperrt");
await expectCode(() => setOnboardingHidden(adminA, true), "blocked", "Ausblenden im Nur-Lesen-Zustand gesperrt");
ok((await adminA.db.customer.count()) === 3 && (await adminA.db.workOrder.count()) === 4, "Lesen bleibt möglich");
console.log("\n— Export (auch im Nur-Lesen-Zustand) —");
// one stored file for the export
const pdf = Buffer.from("%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n");
const doc = await storeFile(ctxFor(A.tenantId, A.adminUserId, "tenant-admin"), { bytes: pdf, fileName: "zz-auftrag.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", links: { workOrderId: order.id } }).catch((err) => {
console.log(" (Hinweis: Dokument konnte nicht gespeichert werden:", (err as Error).message, ")");
return null;
});
await prisma.customer.updateMany({ where: { tenantId: A.tenantId, companyName: { startsWith: "Hausverwaltung" } }, data: { notes: "=HYPERLINK(\"http://evil\")" } });
await expectCode(() => requestTenantExport(ctxTechA, { dispatch: async () => undefined }), "forbidden", "Monteur darf keinen Export anfordern");
const exp = await requestTenantExport(adminA, { dispatch: (tid, id) => buildTenantExport(tid, id) });
const done = await prisma.tenantExport.findUniqueOrThrow({ where: { id: exp.id } });
ok(done.status === "done" && !!done.storageKey?.startsWith(`${A.tenantId}/`) && (done.bytes ?? 0) > 0, `Export im Nur-Lesen-Zustand erstellt (${done.status}${done.error ? `: ${done.error}` : ""})`);
await expectCode(() => requestTenantExport(adminA, { dispatch: async () => undefined }).then(() => requestTenantExport(adminA, { dispatch: async () => undefined })), "conflict", "paralleler Export → conflict");
const { bytes, fileName } = await openTenantExport(adminA, exp.id);
ok(bytes.subarray(0, 2).toString() === "PK" && fileName.endsWith(".zip"), "Download liefert ZIP");
const zip = readZip(bytes);
const kunden = zip.get("csv/kunden.csv")?.toString("utf8") ?? "";
ok(zip.has("LIESMICH.txt") && zip.has("json/auftraege.json") && zip.has("csv/zeiten.csv") && zip.has("csv/material.csv") && zip.has("csv/berichte.csv"), "ZIP enthält Stammdaten, Aufträge, Zeiten, Material, Berichte (CSV/JSON)");
ok(kunden.startsWith("") && kunden.includes("Hausverwaltung Musterhof GmbH"), "CSV mit BOM und Daten von Mandant A");
ok(!kunden.includes("ZZ Kunde nur B"), "Export enthält keine Daten von Mandant B");
ok(kunden.includes("'=HYPERLINK"), "CSV-Formel-Injektion entschärft");
ok((JSON.parse(zip.get("json/auftraege.json")?.toString("utf8") ?? "[]") as unknown[]).length === 4, "JSON enthält alle Aufträge");
if (doc) ok([...zip.keys()].some((k) => k.startsWith(`dateien/${doc.id}-`)) && zip.get([...zip.keys()].find((k) => k.startsWith(`dateien/${doc.id}-`))!)?.equals(pdf) === true, "gespeicherte Datei im ZIP (byte-identisch)");
const nutzer = zip.get("csv/nutzer.csv")?.toString("utf8") ?? "";
ok(!nutzer.includes("password") && !nutzer.includes("$argon2"), "keine Passwort-Hashes im Export");
ok((await listTenantExports(adminA)).some((e) => e.id === exp.id && !e.expired), "Exportliste zeigt den fertigen Export");
await expectCode(() => openTenantExport(adminB, exp.id), "not_found", "Mandant B kann den Export von A nicht laden");
await expectCode(() => openTenantExport(ctxTechA, exp.id), "forbidden", "Monteur kann den Export nicht laden");
await expectCode(() => openTenantExport(adminA, exp.id, { now: new Date(Date.now() + 8 * 86_400_000) }), "invalid", "Download nach 7 Tagen abgelaufen");
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "tenant_export_download" } })) >= 1, "Audit: Export-Download");
ok(toCsv([{ a: 'x;"y"', b: new Date("2026-01-01T00:00:00Z"), c: null }]) === 'a;b;c\r\n"x;""y""";2026-01-01T00:00:00.000Z;\r\n', "CSV-Quoting, Datum, leere Werte");
console.log("\n— Verlängern hebt die Sperre auf —");
const before = await prisma.tenant.findUniqueOrThrow({ where: { id: B.tenantId } });
await changeTrialEndDate(actor, A.tenantId, addDaysToKey(today, 5));
await assertTenantWritable(A.tenantId);
const stateAfter = await getTrialState(A.tenantId);
ok(!stateAfter.readOnly && stateAfter.daysLeft === 5, "nach Verlängerung wieder schreibbar (noch 5 Tage)");
ok((await handler(A.tenantId)(post())).status === 200, "API-Mutation nach Verlängerung erlaubt");
await setOnboardingItem(adminA, "team", true);
ok(true, "Checkliste nach Verlängerung wieder änderbar");
const after = await prisma.tenant.findUniqueOrThrow({ where: { id: B.tenantId } });
ok(before.trialEndsAt?.getTime() === after.trialEndsAt?.getTime() && after.readOnlySince === null, "Mandant B unverändert");
await cleanupL15();
await prisma.$disconnect();
console.log(failures ? `\n${failures} Prüfung(en) fehlgeschlagen` : "\nAlle Prüfungen bestanden");
process.exit(failures ? 1 : 0);
}
main().catch(async (err) => {
console.error(err);
await cleanupL15().catch(() => undefined);
process.exit(1);
});
+211
View File
@@ -0,0 +1,211 @@
// Lane L15 „Testphase & Onboarding" — öffentliche Selbstanmeldung:
// Wizard-Validierung (Pflichtfelder, Passwort-Policy, Enddatum-Grenzen, TRIAL_MAX_DAYS, Europe/Berlin),
// Double-Opt-in (Token nur als Hash, 24 h, Einmalverwendung, ältere Links entwertet),
// Honeypot, Enumeration-Schutz (gleiche Antwort, Hinweis-Mail), Rate-Limit je IP und je E-Mail,
// Provisionierung mit/ohne Beispieldaten + Modulauswahl, Slug-Kollisionen, Mandantentrennung.
//
// Lauf: npx tsx scripts/test-testphase-signup.ts (lokale Postgres-DB aus .env)
import "dotenv/config";
import { readFileSync } from "node:fs";
import { prisma, dbForTenant } from "../src/server/db";
import { verifyPassword } from "../src/server/password";
import { resetRateLimits } from "../src/server/rate-limit";
import { addDaysToKey, trialBounds, trialEndDateKey, trialEndInstant } from "../src/lib/trial/dates";
import { normalizeTrialValues, validateTrialSignup, validateTrialStep, type TrialSignupValues } from "../src/lib/trial/signup";
import { MODULE_KEYS } from "../src/lib/modules";
import { trialMaxDays } from "../src/server/services/trial/config";
import { enforceTrialRateLimit } from "../src/server/services/trial/abuse";
import { checkTrialStep, confirmTrialSignup, currentTrialBounds, hashSignupToken, peekTrialSignup, submitTrialSignup } from "../src/server/services/trial/signup";
import { SAMPLE_TEAM_NAME } from "../src/server/services/trial/sample-data";
import { captureMail, cleanupL15, DOMAIN, failures, ok } from "./lib/testphase-fixture";
const PASSWORD = "Testphase2026Sicher";
function values(over: Partial<TrialSignupValues> = {}): TrialSignupValues {
return {
companyName: "ZZ L15 Signup Sanitär",
sector: "Sanitär, Heizung, Klima",
companySize: "6-20",
adminName: "Paula Probe",
email: `paula${DOMAIN}`,
password: PASSWORD,
trialEndDate: currentTrialBounds().defaultEnd,
sampleData: true,
modules: [...MODULE_KEYS],
acceptTerms: true,
acceptPrivacy: true,
website: "",
...over,
};
}
const tokenOf = (url: string) => new URL(url).searchParams.get("token") ?? "";
async function main() {
await cleanupL15();
resetRateLimits();
console.log("\n— Wizard-Validierung —");
const now = new Date("2026-03-10T10:00:00Z");
const b = trialBounds(now, 30);
ok(b.today === "2026-03-10" && b.min === "2026-03-11" && b.max === "2026-04-09" && b.defaultEnd === "2026-03-24", "Grenzen: morgen … heute + 30, Vorbelegung heute + 14");
ok(trialBounds(now, 10).defaultEnd === "2026-03-20", "Vorbelegung höchstens TRIAL_MAX_DAYS");
const v = values();
ok(validateTrialStep("period", { ...v, trialEndDate: "2026-03-10" }, b).trialEndDate === "date_too_early", "Enddatum heute → zu früh");
ok(!validateTrialStep("period", { ...v, trialEndDate: "2026-03-11" }, b).trialEndDate, "Enddatum morgen → erlaubt");
ok(!validateTrialStep("period", { ...v, trialEndDate: "2026-04-09" }, b).trialEndDate, "Enddatum heute + 30 → erlaubt");
ok(validateTrialStep("period", { ...v, trialEndDate: "2026-04-10" }, b).trialEndDate === "date_too_late", "Enddatum heute + 31 → zu spät");
ok(validateTrialStep("period", { ...v, trialEndDate: "2026-02-30" }, b).trialEndDate === "date_invalid", "ungültiges Kalenderdatum");
ok(validateTrialStep("company", { ...v, companyName: " " }, b).companyName === "company_required", "Firmenname Pflicht");
ok(validateTrialStep("company", { ...v, companySize: "999" }, b).companySize === "invalid_choice", "Betriebsgröße nur aus der Liste");
const acc = validateTrialStep("account", { ...v, adminName: "", email: "kein-mail", password: "kurz" }, b);
ok(acc.adminName === "name_required" && acc.email === "email_invalid" && acc.password === "password_policy", "Admin-Konto: Name, E-Mail und Passwort-Policy");
ok(validateTrialStep("setup", { ...v, modules: [] }, b).modules === "modules_required", "mindestens ein Modul");
const sum = validateTrialStep("summary", { ...v, acceptTerms: false, acceptPrivacy: false }, b);
ok(sum.acceptTerms === "terms_required" && sum.acceptPrivacy === "privacy_required", "Pflicht-Checkboxen Nutzungsbedingungen/Datenschutz");
ok(Object.keys(validateTrialSignup({ ...v, trialEndDate: "2026-03-24" }, b)).length === 0, "vollständige gültige Anmeldung ohne Fehler");
ok(normalizeTrialValues({ ...v, email: " Paula@ZZ-L15.test ", modules: ["customers", "hacker"] })?.email === "paula@zz-l15.test", "E-Mail normalisiert");
ok(JSON.stringify(normalizeTrialValues({ ...v, modules: ["customers", "hacker"] })?.modules) === '["customers"]', "unbekannte Module verworfen");
ok(normalizeTrialValues({ modules: "kein-array" }) === null, "fehlerhafte Eingabe → null");
ok(checkTrialStep("hacken", v)._form === "invalid_request", "serverseitige Schrittprüfung: unbekannter Schritt");
ok(checkTrialStep("account", { ...v, password: "x" }).password === "password_policy", "serverseitige Schrittprüfung: Passwort-Policy");
const prevMax = process.env.TRIAL_MAX_DAYS;
process.env.TRIAL_MAX_DAYS = "10";
ok(trialMaxDays() === 10, "TRIAL_MAX_DAYS aus der Umgebung");
process.env.TRIAL_MAX_DAYS = "abc";
ok(trialMaxDays() === 30, "ungültiges TRIAL_MAX_DAYS → Default 30");
if (prevMax === undefined) delete process.env.TRIAL_MAX_DAYS;
else process.env.TRIAL_MAX_DAYS = prevMax;
ok(trialEndInstant("2026-03-24").toISOString() === "2026-03-24T23:00:00.000Z", "Ende des gewählten Tages in Europe/Berlin (Winterzeit)");
ok(trialEndInstant("2026-07-01").toISOString() === "2026-07-01T22:00:00.000Z", "Ende des gewählten Tages in Europe/Berlin (Sommerzeit)");
ok(trialEndInstant("2026-03-29").toISOString() === "2026-03-29T22:00:00.000Z", "Ende am Tag der Zeitumstellung");
ok(trialEndDateKey(trialEndInstant("2026-10-25")) === "2026-10-25", "letzter Testtag aus dem gespeicherten Ende zurückgerechnet");
console.log("\n— Absenden: ungültig / Honeypot —");
const mail = captureMail();
const invalid = await submitTrialSignup({ ...values(), acceptTerms: false }, { ip: "198.51.100.1", sendMail: mail.fn });
ok(invalid.status === "invalid" && (invalid as { errors: Record<string, string> }).errors.acceptTerms === "terms_required", "ungültig → Fehler je Feld");
ok((await prisma.trialSignup.count({ where: { email: { endsWith: DOMAIN } } })) === 0 && mail.sent.length === 0, "ungültig → keine Anmeldung, keine Mail");
const honey = await submitTrialSignup({ ...values(), website: "http://spam.example" }, { ip: "198.51.100.1", sendMail: mail.fn });
ok(honey.status === "sent", "Honeypot gefüllt → neutrale Erfolgsantwort");
ok((await prisma.trialSignup.count({ where: { email: { endsWith: DOMAIN } } })) === 0 && mail.sent.length === 0, "Honeypot → nichts gespeichert, keine Mail");
console.log("\n— Double-Opt-in —");
const first = await submitTrialSignup(values({ sampleData: true, modules: MODULE_KEYS.filter((m) => m !== "lotse") }), { ip: "198.51.100.7", sendMail: mail.fn });
ok(first.status === "sent" && mail.sent.length === 1 && mail.sent[0].template === "trial_confirm", "gültig → Bestätigungsmail");
const token1 = tokenOf((mail.sent[0].vars as { actionUrl: string }).actionUrl);
const row1 = await prisma.trialSignup.findFirstOrThrow({ where: { email: `paula${DOMAIN}` }, orderBy: { createdAt: "desc" } });
ok(row1.tokenHash === hashSignupToken(token1) && row1.tokenHash !== token1 && token1.length >= 40, "Token nur als SHA-256-Hash gespeichert");
ok(row1.passwordHash.startsWith("$argon2id$") && !row1.passwordHash.includes(PASSWORD), "Passwort als Argon2id-Hash (mit Pepper)");
ok(!!row1.ipHash && row1.ipHash !== "198.51.100.7", "IP nur als HMAC");
const ttl = row1.expiresAt.getTime() - row1.createdAt.getTime();
ok(Math.abs(ttl - 24 * 3600_000) < 60_000, "Link 24 Stunden gültig");
ok(row1.status === "pending" && (await prisma.tenant.count({ where: { slug: { startsWith: "zz-l15-signup" } } })) === 0, "vor der Bestätigung wird kein Mandant angelegt");
const rawDump = JSON.stringify(await prisma.trialSignup.findMany({ where: { email: { endsWith: DOMAIN } } }));
ok(!rawDump.includes(token1) && !rawDump.includes(PASSWORD), "weder Klartext-Token noch -Passwort in der Tabelle");
const second = await submitTrialSignup(values({ sampleData: true, modules: MODULE_KEYS.filter((m) => m !== "lotse") }), { ip: "198.51.100.7", sendMail: mail.fn });
const token2 = tokenOf((mail.sent[1].vars as { actionUrl: string }).actionUrl);
ok(second.status === "sent" && token2 !== token1, "erneute Anmeldung → neuer Link");
ok((await prisma.trialSignup.findUniqueOrThrow({ where: { id: row1.id } })).status === "superseded" && (await peekTrialSignup(token1)) === null, "älterer Link entwertet");
ok((await confirmTrialSignup(token1)).status === "invalid", "entwerteter Link lässt sich nicht einlösen");
const peek = await peekTrialSignup(token2);
ok(peek?.companyName === "ZZ L15 Signup Sanitär", "Bestätigungsseite zeigt die Anmeldung (ohne Einlösen)");
ok((await confirmTrialSignup("falsches-token")).status === "invalid", "falsches Token → ungültig");
// expiry on a separate signup
const mailExp = captureMail();
await submitTrialSignup(values({ email: `ablauf${DOMAIN}`, companyName: "ZZ L15 Ablauf" }), { ip: "198.51.100.8", sendMail: mailExp.fn });
const tokenExp = tokenOf((mailExp.sent[0].vars as { actionUrl: string }).actionUrl);
const expRow = await prisma.trialSignup.findFirstOrThrow({ where: { email: `ablauf${DOMAIN}` } });
ok((await confirmTrialSignup(tokenExp, { now: new Date(expRow.expiresAt.getTime() + 1000) })).status === "expired", "nach 24 h → abgelaufen");
const expAfter = await prisma.trialSignup.findUniqueOrThrow({ where: { id: expRow.id } });
ok(expAfter.status === "expired" && expAfter.passwordHash === "", "abgelaufene Anmeldung: Status expired, Passwort-Hash entfernt");
ok((await confirmTrialSignup(tokenExp)).status === "invalid", "abgelaufener Link bleibt ungültig");
console.log("\n— Bestätigung → Provisionierung (mit Beispieldaten, Modulauswahl) —");
const confirmed = await confirmTrialSignup(token2);
ok(confirmed.status === "ok", "Bestätigung → Mandant provisioniert");
if (confirmed.status !== "ok") throw new Error("confirm failed");
const tenantA = await prisma.tenant.findUniqueOrThrow({ where: { id: confirmed.tenantId } });
const endKey = values().trialEndDate;
ok(tenantA.plan === "TRIAL" && tenantA.trialSource === "self_signup" && !!tenantA.trialStartedAt, "Plan TRIAL, Herkunft Selbstanmeldung");
ok(tenantA.trialEndsAt?.getTime() === trialEndInstant(endKey).getTime(), "trialEndsAt = Ende des gewählten Tages (Europe/Berlin)");
ok(tenantA.deletionDueAt?.getTime() === trialEndInstant(endKey).getTime() + 30 * 86_400_000, "Löschung 30 Tage nach Ende vorgemerkt");
ok(tenantA.slug === "zz-l15-signup-sanitar", "Slug aus dem Firmennamen");
const admin = await prisma.user.findFirstOrThrow({ where: { tenantId: tenantA.id }, include: { userRoles: { include: { role: true } }, identity: true } });
ok(admin.userRoles.some((r) => r.role.key === "tenant-admin") && admin.email === `paula${DOMAIN}`, "Admin mit Rolle tenant-admin");
ok(await verifyPassword(admin.identity.passwordHash, PASSWORD), "Login-Passwort = Passwort aus dem Wizard");
ok(!admin.identity.mustChangePassword, "kein Passwortzwang für die Selbstanmeldung");
const signupAfter = await prisma.trialSignup.findFirstOrThrow({ where: { tokenHash: hashSignupToken(token2) } });
ok(signupAfter.status === "confirmed" && signupAfter.passwordHash === "" && signupAfter.provisionedTenantId === tenantA.id, "Anmeldung bestätigt, Passwort-Hash aus der Anmeldung entfernt");
const modules = await prisma.tenantModule.findMany({ where: { tenantId: tenantA.id } });
ok(modules.length === MODULE_KEYS.length && modules.find((m) => m.moduleKey === "lotse")?.enabled === false && modules.filter((m) => m.enabled).length === MODULE_KEYS.length - 1, "abgewähltes Modul deaktiviert, übrige aktiv");
const dbA = dbForTenant(tenantA.id);
ok((await dbA.customer.count()) === 3 && (await dbA.site.count()) === 3 && (await dbA.workOrder.count()) === 4, "Beispieldaten: 3 Kunden, 3 Objekte, 4 Aufträge");
ok((await dbA.team.count({ where: { name: SAMPLE_TEAM_NAME } })) === 1 && (await dbA.workOrder.count({ where: { status: "assigned" } })) === 1, "Beispieldaten: Team + zugewiesener Auftrag (über die Fachservices)");
ok((await prisma.auditLog.count({ where: { tenantId: tenantA.id, entity: "trial", action: "create" } })) === 1, "Audit: Testphase angelegt");
ok((await confirmTrialSignup(token2)).status === "invalid", "Einmalverwendung: zweiter Klick → ungültig");
console.log("\n— Enumeration-Schutz —");
const mailEnum = captureMail();
const known = await submitTrialSignup(values({ companyName: "ZZ L15 Doppelt" }), { ip: "198.51.100.9", sendMail: mailEnum.fn });
const unknown = await submitTrialSignup(values({ companyName: "ZZ L15 Neu", email: `neu${DOMAIN}` }), { ip: "198.51.100.9", sendMail: mailEnum.fn });
ok(JSON.stringify(known) === JSON.stringify(unknown), "bestehende und neue Adresse → identische Antwort");
ok(mailEnum.sent[0].template === "trial_existing_account" && mailEnum.sent[0].to === `paula${DOMAIN}` && mailEnum.sent[1].template === "trial_confirm", "bestehende Adresse → Hinweis-Mail statt Bestätigungslink");
ok(!JSON.stringify(mailEnum.sent[0].vars).includes("token="), "Hinweis-Mail enthält keinen Aktivierungslink");
ok((await prisma.trialSignup.count({ where: { email: `paula${DOMAIN}`, status: "pending" } })) === 0, "bestehende Adresse → keine offene Anmeldung");
console.log("\n— Rate-Limit je IP und je E-Mail —");
resetRateLimits();
const ipA = "203.0.113.10";
const results = Array.from({ length: 6 }, () => enforceTrialRateLimit("trialSignup", { ip: ipA, email: `rl${DOMAIN}` }).allowed);
ok(results.slice(0, 5).every(Boolean) && results[5] === false, "je IP/E-Mail: 5 Anmeldungen pro Stunde, die 6. wird abgewiesen");
ok(enforceTrialRateLimit("trialSignup", { ip: "203.0.113.11", email: `rl${DOMAIN}` }).allowed === false, "gleiche E-Mail von anderer IP → weiter gesperrt");
ok(enforceTrialRateLimit("trialSignup", { ip: ipA, email: `anders${DOMAIN}` }).allowed === false, "gleiche IP mit anderer E-Mail → weiter gesperrt");
ok(enforceTrialRateLimit("trialSignup", { ip: "203.0.113.12", email: `anders${DOMAIN}` }).allowed, "andere IP + andere E-Mail → erlaubt");
const retry = enforceTrialRateLimit("trialSignup", { ip: ipA, email: null });
ok(!retry.allowed && retry.retryAfterSeconds > 0, "Retry-After gesetzt");
resetRateLimits();
const actionsSrc = readFileSync("src/server/actions/trial-signup.ts", "utf8");
const exported = [...actionsSrc.matchAll(/export async function (\w+)/g)].map((m) => m[1]);
ok(exported.length === 3 && exported.every((name) => new RegExp(`export async function ${name}[\\s\\S]*?enforceTrialRateLimit\\(`).test(actionsSrc)), "jede öffentliche Action prüft das Rate-Limit");
ok(readFileSync("scripts/check-module-guards.ts", "utf8").includes('"trial-signup.ts": "PUBLIC"'), "Guard-Check erzwingt Rate-Limit für öffentliche Actions");
ok(readFileSync("src/proxy.ts", "utf8").includes('"/testen"'), "/testen ist ohne Login erreichbar (Proxy)");
console.log("\n— ohne Beispieldaten, Slug-Kollision, Mandantentrennung —");
const mailB = captureMail();
await submitTrialSignup(values({ email: `bernd${DOMAIN}`, adminName: "Bernd Probe", sampleData: false }), { ip: "198.51.100.20", sendMail: mailB.fn });
const confirmedB = await confirmTrialSignup(tokenOf((mailB.sent[0].vars as { actionUrl: string }).actionUrl));
ok(confirmedB.status === "ok", "zweite Anmeldung mit gleichem Firmennamen bestätigt");
if (confirmedB.status !== "ok") throw new Error("confirm B failed");
ok(confirmedB.tenantSlug === "zz-l15-signup-sanitar-2" && confirmedB.tenantId !== tenantA.id, "Slug-Kollision automatisch gelöst (-2), eigener Mandant");
const dbB = dbForTenant(confirmedB.tenantId);
ok((await dbB.customer.count()) === 0 && (await dbB.workOrder.count()) === 0, "ohne Beispieldaten → leerer Mandant");
ok((await dbB.tenantModule.count({ where: { enabled: true } })) === MODULE_KEYS.length, "Standard: alle Module aktiv");
const customerA = await dbA.customer.findFirstOrThrow();
ok((await dbB.customer.findFirst({ where: { id: customerA.id } })) === null, "Mandant B sieht die Kunden von A nicht");
let crossWrite = false;
try {
await dbB.customer.update({ where: { id: customerA.id }, data: { city: "Hack" } });
crossWrite = true;
} catch {
crossWrite = false;
}
ok(!crossWrite && (await dbA.customer.findFirstOrThrow({ where: { id: customerA.id } })).city !== "Hack", "Mandant B kann Kunden von A nicht ändern");
ok(addDaysToKey("2026-12-31", 1) === "2027-01-01", "Datumsrechnung über den Jahreswechsel");
await cleanupL15();
await prisma.$disconnect();
console.log(failures ? `\n${failures} Prüfung(en) fehlgeschlagen` : "\nAlle Prüfungen bestanden");
process.exit(failures ? 1 : 0);
}
main().catch(async (err) => {
console.error(err);
await cleanupL15().catch(() => undefined);
process.exit(1);
});
+2
View File
@@ -26,6 +26,7 @@ import { WORK_ORDER_PRIORITIES } from "@/lib/work-orders/schemas";
import { getDashboardTiles } from "@/server/services/work-orders/dashboard";
import { customerDisplayName, customerFilterOptions, teamOptions, userOptions } from "@/server/services/work-orders/options";
import { listOrderTypes } from "@/server/services/work-orders/settings";
import { GettingStarted } from "@/components/trial/getting-started";
type SP = Record<string, string | string[] | undefined>;
@@ -65,6 +66,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
return (
<main className="flex-1 p-4 md:p-6">
<PageHead crumb={t("crumb")} title={t("title")} sub={t("subtitle", { name: session.user.name ?? "", tenant: session.user.tenantSlug ?? "" })} />
<GettingStarted ctx={ctx} welcome={sp.welcome === "1"} /> {/* L15 Testphase */}
{sp.module === "disabled" && (
<p role="status" className="mb-4 rounded-lg border border-[var(--warn)] bg-card px-4 py-3 text-sm text-[var(--warn)]">
{t("moduleDisabled")}
+2
View File
@@ -3,6 +3,7 @@ import { assertSameOrigin, requireApiContext } from "@/server/api/context";
import { ApiError, toErrorResponse } from "@/server/api/respond";
import { ServiceError } from "@/server/services/context";
import { storeFile } from "@/server/services/documents/store";
import { assertTenantWritable } from "@/server/services/trial/state";
/**
* Multipart upload for the backoffice document tabs (customer, site, /documents).
@@ -19,6 +20,7 @@ export async function POST(req: Request) {
try {
assertSameOrigin(req);
const ctx = await requireApiContext("documents");
await assertTenantWritable(ctx.tenantId); // L15: expired trial → read-only
let form: FormData;
try {
+1 -1
View File
@@ -15,7 +15,7 @@ const INLINE = new Set(["application/pdf", "image/jpeg", "image/png"]);
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
let file: { storageKey: string; mimeType: string; fileName: string };
try {
const ctx = ctxFromGuard(await moduleGuard("imports")("import:write"));
const ctx = ctxFromGuard(await moduleGuard("imports", { read: true })("import:write")); // L15: GET download — no trial write lock
const { id } = await params;
file = await getImportFile(ctx, id);
} catch {
+3
View File
@@ -14,6 +14,7 @@ import { CraftviaLogo } from "@/components/brand/craftvia-logo";
import { NotificationBell } from "@/components/notifications/bell";
import { AccountInactiveNotice } from "@/components/account-inactive-notice";
import { BackofficeFrame } from "@/components/backoffice-frame";
import { TrialBanner } from "@/components/trial/trial-banner";
export default async function AppLayout({
children,
@@ -111,6 +112,8 @@ export default async function AppLayout({
</form>
</>
}>
{/* L15 Testphase: Countdown ab 7 Tagen bzw. Nur-Lesen-Hinweis */}
<TrialBanner tenantId={session.user.tenantId} variant="backoffice" canExport={(session.user.permissions ?? []).includes("tenant:manage")} />
{children}
</BackofficeFrame>
);
@@ -0,0 +1,27 @@
import { requireApiContext } from "@/server/api/context";
import { toErrorResponse } from "@/server/api/respond";
import { openTenantExport } from "@/server/services/trial/export";
/**
* GET /settings/export/<id> — download of a finished tenant data export (L15 Testphase).
* Session + DB-authoritative `tenant:manage`, tenant-bound row; a read, therefore also allowed for
* an expired (read-only) trial tenant.
*/
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const ctx = await requireApiContext(null, "tenant:manage");
const { id } = await params;
const { bytes, fileName } = await openTenantExport(ctx, id);
return new Response(new Uint8Array(bytes), {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${fileName.replace(/[^\w.\-]/g, "_")}"`,
"Content-Length": String(bytes.length),
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
},
});
} catch (err) {
return toErrorResponse(err);
}
}
+73
View File
@@ -0,0 +1,73 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { Download, PackageOpen } from "lucide-react";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission } from "@/server/rbac";
import { PageHead, Pill } from "@/components/mockup-ui";
import { Button } from "@/components/ui/button";
import { formatInstantDate } from "@/lib/trial/dates";
import { requestExportAction } from "@/server/actions/trial-tenant";
import { listTenantExports } from "@/server/services/trial/export";
const TONE = { queued: "warn", running: "warn", done: "ok", failed: "mut", expired: "mut" } as const;
/** L15 Testphase: data export for tenant administrators (also available in the read-only state). */
export default async function DataExportPage({ searchParams }: { searchParams: Promise<{ requested?: string; error?: string }> }) {
const session = await requireSession();
if (!hasPermission(session, "tenant:manage")) redirect("/dashboard");
const sp = await searchParams;
const [t, locale] = await Promise.all([getTranslations("trial.export"), getLocale()]);
const ctx = { db: dbForTenant(session.user.tenantId), tenantId: session.user.tenantId, userId: session.user.id, permissions: new Set(session.user.permissions ?? []) };
const exports = await listTenantExports(ctx);
const errorKey = sp.error === "export_running" ? "export_running" : "failed";
return (
<main className="flex-1 p-4 md:p-6">
<PageHead crumb={t("crumb")} title={t("title")} sub={t("sub")} />
<div className="shadow-card rounded-xl border bg-card p-5">
<div className="flex flex-wrap items-start gap-4">
<PackageOpen className="size-6 shrink-0 text-[var(--ui-primary)]" aria-hidden />
<div className="min-w-0 flex-1">
<p className="text-sm">{t("contents")}</p>
<p className="mt-1 text-[12.5px] text-muted-foreground">{t("readOnlyHint")}</p>
</div>
<form action={requestExportAction}>
<Button type="submit" className="h-11">{t("request")}</Button>
</form>
</div>
{sp.requested && <p role="status" className="mt-3 rounded-lg bg-[rgba(57,192,127,0.14)] px-3 py-2 text-sm text-[var(--ok)]">{t("requested")}</p>}
{sp.error && <p role="alert" className="mt-3 rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{t(`errors.${errorKey}`)}</p>}
</div>
<div className="shadow-card mt-4 rounded-xl border bg-card p-5">
<h2 className="font-heading text-[15px] font-semibold">{t("listTitle")}</h2>
{exports.length === 0 ? (
<p className="mt-2 text-sm text-muted-foreground">{t("empty")}</p>
) : (
<ul className="mt-2 divide-y">
{exports.map((e) => {
const status = e.expired ? "expired" : (e.status as keyof typeof TONE);
return (
<li key={e.id} className="flex flex-wrap items-center gap-3 py-2.5 text-sm">
<Pill tone={TONE[status] ?? "mut"}>{t(`status.${status}`)}</Pill>
<span className="text-muted-foreground">{t("created")}: {e.createdAt.toLocaleString(locale === "en" ? "en-GB" : "de-DE", { timeZone: "Europe/Berlin" })}</span>
{e.bytes != null && <span className="text-muted-foreground">{(e.bytes / 1024 / 1024).toFixed(1)} MB</span>}
{e.status === "failed" && e.error && <span className="text-[12.5px] text-[var(--risk)]">{e.error}</span>}
{e.status === "done" && !e.expired && (
<Link href={`/settings/export/${e.id}`} prefetch={false} className="ml-auto inline-flex min-h-11 items-center gap-1.5 rounded-md px-3 font-semibold text-[var(--ui-primary)] hover:bg-muted">
<Download className="size-4" aria-hidden /> {t("download")}
{e.expiresAt && <span className="font-normal text-muted-foreground">({t("expires", { date: formatInstantDate(e.expiresAt, locale) })})</span>}
</Link>
)}
</li>
);
})}
</ul>
)}
</div>
</main>
);
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { EmergencyWizard } from "@/components/emergency/emergency-wizard";
/** `/m/emergency` — Notdienst erfassen (spec §19.2, US-010): max. 3 steps, then straight into the call-out. */
export default async function EmergencyPage() {
const ctx = ctxFromGuard(await moduleGuard("emergency")());
const ctx = ctxFromGuard(await moduleGuard("emergency", { read: true })()); // L15: read path — no trial write lock
const t = await getTranslations("emergency.capture");
if (!can(ctx, "emergency:create") || !can(ctx, "field:execute")) {
return <p className="p-4 text-[15px]">{t("noAccess")}</p>;
+3
View File
@@ -13,6 +13,7 @@ import { BottomNav } from "@/components/field/bottom-nav";
import { OnlineBadge } from "@/components/field/online-badge";
import { RunningClockBar, type ClockSession } from "@/components/field/running-clock-bar";
import { OfflineRuntime } from "@/components/offline/offline-runtime";
import { TrialBanner } from "@/components/trial/trial-banner";
/** L12: own running/paused session + open approvals for the shell (never blocks the page). */
async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number; lotseChat: boolean }> {
@@ -55,6 +56,8 @@ export default async function FieldShell({ children }: Readonly<{ children: Reac
<OnlineBadge />
</div>
</header>
{/* L15 Testphase: Countdown ab 7 Tagen bzw. Nur-Lesen-Hinweis */}
<TrialBanner tenantId={access.session.user.tenantId} variant="mobile" />
<div className={cn("mx-auto w-full max-w-xl flex-1", time.clock ? "pb-48" : "pb-28")}>{children}</div>
<RunningClockBar initial={time.clock} />
<BottomNav approvals={time.approvals} lotse={time.lotseChat} />
+11 -1
View File
@@ -19,6 +19,7 @@ import { UserTable } from "@/components/user-table";
import { UserCreateForm, UserEditForm } from "@/components/user-forms";
import { AuditTrailModal, type AuditRow } from "@/components/audit-trail";
import { RestoreModalBody, ExportModalBody, DsgvoModalBody, type SnapshotOption, type SubjectOption } from "@/components/backup-admin-panel";
import { TrialAdminCard } from "@/components/trial/trial-admin-card";
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
@@ -27,7 +28,7 @@ export default async function AdminTenantPage({
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string }>;
searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string; trial?: string; invited?: string; trialDone?: string }>;
}) {
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
const { id } = await params;
@@ -216,6 +217,15 @@ export default async function AdminTenantPage({
</div>
<div className="space-y-5">
{/* L15 Testphase: Enddatum, Umwandlung, Beenden, Löschung (Bestätigung + Plattform-Audit) */}
<TrialAdminCard
tenant={tenant}
isFullAdmin={isFullAdmin}
base={base}
op={sp.trial === "created" ? undefined : sp.trial}
notice={sp.trial === "created" ? (sp.invited ? "invited" : "created") : sp.trialDone ? "done" : null}
/>
{/* Lebenszyklus */}
<div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-3 font-heading text-sm font-semibold">{t("lifecycleTitle")}</p>
+27 -1
View File
@@ -16,16 +16,22 @@ import {
import { createTenant } from "@/server/actions/admin";
import { getMailStatus } from "@/server/actions/mail";
import { MailStatusPanel } from "@/components/mail-status-panel";
import { getTranslations } from "next-intl/server";
import { TrialBadge } from "@/components/trial/trial-badge";
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
const STATUS_LABEL: Record<string, string> = { ACTIVE: "Aktiv", SUSPENDED: "Gesperrt", ARCHIVED: "Archiviert" };
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ new?: string }> }) {
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ new?: string; plan?: string }> }) {
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
const params = await searchParams;
// L15 Testphase: Filter Test/Voll
const tt = await getTranslations("trial.platform");
const planFilter = params.plan === "trial" ? "TRIAL" : params.plan === "full" ? "FULL" : null;
const [tenants, mailStatus] = await Promise.all([
prisma.tenant.findMany({
where: planFilter ? { plan: planFilter } : undefined,
include: { _count: { select: { users: true } }, modules: true },
orderBy: { createdAt: "asc" },
}),
@@ -42,6 +48,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
actions={
<span className="flex items-center gap-2">
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/admins" />}>Administratoren</Button>
<Button variant="outline" nativeButton={false} render={<Link href="/admin/trial" />}>{tt("newTrial")}</Button>
<Button nativeButton={false} render={<Link href={params.new ? "/admin" : "/admin?new=1"} />}>
<Plus className="size-4" /> Neuer Kunde
</Button>
@@ -92,6 +99,23 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
</div>
)}
<nav aria-label={tt("filter")} className="mt-4 flex flex-wrap gap-2">
{[
{ key: null, href: "/admin", label: tt("filterAll") },
{ key: "TRIAL", href: "/admin?plan=trial", label: tt("filterTrial") },
{ key: "FULL", href: "/admin?plan=full", label: tt("filterFull") },
].map((f) => (
<Link
key={f.href}
href={f.href}
aria-current={planFilter === f.key ? "page" : undefined}
className={`inline-flex min-h-11 items-center rounded-full border px-4 text-[13px] font-semibold ${planFilter === f.key ? "border-[var(--ui-primary)] bg-[var(--ui-primary-soft)] text-[var(--ui-primary)]" : "text-muted-foreground hover:bg-muted"}`}
>
{f.label}
</Link>
))}
</nav>
<div className="shadow-card mt-4 rounded-xl border bg-card">
<Table>
<TableHeader>
@@ -99,6 +123,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
<TableHead>Kunde</TableHead>
<TableHead>Kürzel</TableHead>
<TableHead>Status</TableHead>
<TableHead>{tt("colPlan")}</TableHead>
<TableHead>Nutzer</TableHead>
<TableHead>Aktive Module</TableHead>
</TableRow>
@@ -114,6 +139,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
</TableCell>
<TableCell className="text-muted-foreground">{t.slug}</TableCell>
<TableCell><Pill tone={STATUS_TONE[t.status]}>{STATUS_LABEL[t.status]}</Pill></TableCell>
<TableCell><TrialBadge tenant={t} /></TableCell>
<TableCell className="text-muted-foreground">{t._count.users}</TableCell>
<TableCell className="text-muted-foreground">{active > 0 ? `${active} Module` : "—"}</TableCell>
</TableRow>
+27
View File
@@ -0,0 +1,27 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ArrowLeft } from "lucide-react";
import { PageHead } from "@/components/mockup-ui";
import { PlatformTrialCreateForm } from "@/components/trial/platform-forms";
import { addDaysToKey, todayKey } from "@/lib/trial/dates";
import { PLATFORM_TRIAL_MAX_DAYS, TRIAL_DEFAULT_DAYS } from "@/server/services/trial/config";
export const dynamic = "force-dynamic";
/** L15 Testphase: platform wizard "Testmandant anlegen" (access: (platform)/layout.tsx; action: full admins). */
export default async function PlatformTrialWizardPage() {
const t = await getTranslations("trial.platform.wizard");
const ta = await getTranslations("admin");
const today = todayKey();
return (
<main className="flex-1 p-6">
<Link href="/admin" className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
<ArrowLeft className="size-4" /> {ta("backToOverview")}
</Link>
<div className="mt-2 max-w-3xl">
<PageHead crumb={t("crumb")} title={t("title")} sub={t("sub")} />
<PlatformTrialCreateForm defaultEnd={addDaysToKey(today, TRIAL_DEFAULT_DAYS)} min={today} max={addDaysToKey(today, PLATFORM_TRIAL_MAX_DAYS)} />
</div>
</main>
);
}
@@ -3,6 +3,7 @@ import { assertSameOrigin, requireApiContext } from "@/server/api/context";
import { ApiError, json, readFormData, toErrorResponse } from "@/server/api/respond";
import { ServiceError } from "@/server/services/context";
import { uploadWorkOrderDocument } from "@/server/services/work-orders/documents";
import { assertTenantWritable } from "@/server/services/trial/state";
/**
* POST /api/v1/work-orders/[id]/documents — multipart upload (file, category, visibility, title?).
@@ -17,6 +18,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
try {
assertSameOrigin(req);
const ctx = await requireApiContext("work_orders", "document:write");
await assertTenantWritable(ctx.tenantId); // L15: not wrapped in withApi → explicit trial write lock
const form = await readFormData(req);
const file = form.get("file");
if (!(file instanceof File) || file.size === 0) throw new ServiceError("invalid", "file_missing");
+48
View File
@@ -0,0 +1,48 @@
import type { Metadata } from "next";
import Link from "next/link";
import { getLocale, getTranslations } from "next-intl/server";
import { TrialConfirmForm } from "@/components/trial/confirm-form";
import { formatDateKey } from "@/lib/trial/dates";
import { peekTrialSignup } from "@/server/services/trial/signup";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("trial.meta");
return { title: t("confirmTitle") };
}
export const dynamic = "force-dynamic";
/**
* L15 Testphase: landing page of the confirmation link. Shows the pending signup (link is only
* checked, not consumed); provisioning happens on the POST of the button (TrialConfirmForm).
*/
export default async function TrialConfirmPage({ searchParams }: { searchParams: Promise<{ token?: string }> }) {
const { token = "" } = await searchParams;
const [t, locale] = await Promise.all([getTranslations("trial.confirm"), getLocale()]);
const signup = await peekTrialSignup(token);
return (
<div className="shadow-card mt-4 w-full max-w-md self-start rounded-2xl border bg-card p-6 sm:p-8">
<h1 className="font-heading text-xl font-semibold">{t("title")}</h1>
{signup ? (
<>
<p className="mt-1 text-sm text-muted-foreground">{t("intro")}</p>
<dl className="mt-4 divide-y rounded-lg border text-sm">
<div className="flex justify-between gap-3 px-3 py-2"><dt className="text-muted-foreground">{t("company")}</dt><dd className="text-right font-medium">{signup.companyName}</dd></div>
<div className="flex justify-between gap-3 px-3 py-2"><dt className="text-muted-foreground">{t("until")}</dt><dd className="font-medium">{formatDateKey(signup.trialEndDate, locale)}</dd></div>
<div className="flex justify-between gap-3 px-3 py-2"><dt className="text-muted-foreground">{t("sampleData")}</dt><dd className="font-medium">{signup.sampleData ? t("yes") : t("no")}</dd></div>
</dl>
<TrialConfirmForm token={token} />
</>
) : (
<>
<p role="alert" className="mt-4 rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{t("invalid")}</p>
<div className="mt-4 flex flex-col gap-1 text-center">
<Link href="/testen" className="flex min-h-11 items-center justify-center text-sm font-semibold text-[var(--ui-primary)]">{t("toSignup")}</Link>
<Link href="/login" className="flex min-h-11 items-center justify-center text-sm text-muted-foreground">{t("toLogin")}</Link>
</div>
</>
)}
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { LegalPlaceholder } from "@/components/trial/legal-placeholder";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("trial.meta");
return { title: t("privacyTitle") };
}
/** L15 Testphase: placeholder privacy notice (operator replaces the text before go-live). */
export default async function TrialPrivacyPage() {
const t = await getTranslations("trial");
return <LegalPlaceholder title={t("meta.privacyTitle")} paragraphs={[t("legal.privacyBody1"), t("legal.privacyBody2")]} placeholder={t("legal.placeholder")} back={t("legal.back")} />;
}
+26
View File
@@ -0,0 +1,26 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
/** L15 Testphase: public shell of /testen (no session; see PUBLIC_PATHS in src/proxy.ts). */
export default async function TrialPublicLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const t = await getTranslations("trial.public");
return (
<div className="flex flex-1 flex-col bg-background">
<header className="flex items-center justify-between gap-3 px-4 py-4 sm:px-8">
<Link href="/testen" className="flex min-h-11 items-center" aria-label="Craftvia">
<CraftviaLogo variant="horizontal" height={32} />
</Link>
<p className="text-[13px] text-muted-foreground">
<span className="hidden sm:inline">{t("haveAccount")} </span>
<Link href="/login" className="inline-flex min-h-11 items-center font-semibold text-[var(--ui-primary)]">{t("login")}</Link>
</p>
</header>
<main className="flex flex-1 justify-center px-4 pb-10 sm:px-8">{children}</main>
<footer className="flex flex-wrap justify-center gap-4 border-t px-4 py-3 text-[12.5px] text-muted-foreground">
<Link href="/testen/nutzungsbedingungen" className="inline-flex min-h-11 items-center hover:text-foreground">{t("terms")}</Link>
<Link href="/testen/datenschutz" className="inline-flex min-h-11 items-center hover:text-foreground">{t("privacy")}</Link>
</footer>
</div>
);
}
@@ -0,0 +1,14 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { LegalPlaceholder } from "@/components/trial/legal-placeholder";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("trial.meta");
return { title: t("termsTitle") };
}
/** L15 Testphase: placeholder terms of use (operator replaces the text before go-live). */
export default async function TrialTermsPage() {
const t = await getTranslations("trial");
return <LegalPlaceholder title={t("meta.termsTitle")} paragraphs={[t("legal.termsBody1"), t("legal.termsBody2")]} placeholder={t("legal.placeholder")} back={t("legal.back")} />;
}
+41
View File
@@ -0,0 +1,41 @@
import type { Metadata } from "next";
import { getLocale, getTranslations } from "next-intl/server";
import { CheckCircle2 } from "lucide-react";
import { TrialSignupWizard } from "@/components/trial/signup-wizard";
import { MODULES } from "@/lib/modules";
import { DEFAULT_PASSWORD_POLICY, describePasswordPolicy } from "@/lib/password-policy";
import { currentTrialBounds } from "@/server/services/trial/signup";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("trial.meta");
return { title: t("title") };
}
// the date bounds depend on "today" — never prerender
export const dynamic = "force-dynamic";
/** L15 Testphase: public wizard "Kostenlos testen". */
export default async function TrialSignupPage() {
const [t, tm, locale] = await Promise.all([getTranslations("trial.public"), getTranslations("modules"), getLocale()]);
const modules = MODULES.map((m) => ({ key: m.key, label: tm(m.nav) }));
return (
<div className="grid w-full max-w-5xl gap-8 lg:grid-cols-[1fr_1.2fr] lg:items-start">
<section className="pt-2 lg:pt-10">
<h1 className="font-heading text-[28px] leading-tight font-semibold sm:text-[34px]">{t("heading")}</h1>
<p className="mt-3 text-[15px] text-muted-foreground">{t("intro")}</p>
<ul className="mt-6 space-y-3">
{(["benefit1", "benefit2", "benefit3"] as const).map((k) => (
<li key={k} className="flex items-start gap-2.5 text-[14.5px]">
<CheckCircle2 className="mt-0.5 size-5 shrink-0 text-[var(--ok)]" aria-hidden />
{t(k)}
</li>
))}
</ul>
</section>
<section className="shadow-card relative rounded-2xl border bg-card p-5 sm:p-8">
<TrialSignupWizard bounds={currentTrialBounds()} modules={modules} passwordPolicy={describePasswordPolicy(DEFAULT_PASSWORD_POLICY)} locale={locale} />
</section>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
"use client";
import { useActionState } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { confirmTrialSignupAction, type TrialConfirmState } from "@/server/actions/trial-signup";
/** L15 Testphase: confirmation button (POST) — the GET link alone never provisions anything. */
export function TrialConfirmForm({ token }: { token: string }) {
const t = useTranslations("trial.confirm");
const [state, action, pending] = useActionState<TrialConfirmState, FormData>(confirmTrialSignupAction, { status: "idle" });
if (state.status === "invalid" || state.status === "expired") {
return (
<div className="mt-4 space-y-3">
<p role="alert" className="rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{t(state.status)}</p>
<Link href="/testen" className="flex min-h-11 items-center justify-center text-sm font-semibold text-[var(--ui-primary)]">{t("toSignup")}</Link>
</div>
);
}
return (
<form action={action} className="mt-5 space-y-3">
<input type="hidden" name="token" value={token} />
{state.status === "rate_limited" && (
<p role="alert" className="rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{t("rate_limited")}</p>
)}
<Button type="submit" className="h-11 w-full" disabled={pending}>
{pending ? t("pending") : t("button")}
</Button>
</form>
);
}
+58
View File
@@ -0,0 +1,58 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { CheckCircle2, Circle } from "lucide-react";
import type { ServiceCtx } from "@/server/services/context";
import { getOnboardingChecklist } from "@/server/services/trial/onboarding";
import { getTrialState } from "@/server/services/trial/state";
import { hideOnboardingAction, toggleOnboardingItemAction } from "@/server/actions/trial-tenant";
/** L15 Testphase: "Erste Schritte" card on the dashboard (tenant admins of trial-origin tenants). */
export async function GettingStarted({ ctx, welcome = false }: { ctx: ServiceCtx; welcome?: boolean }) {
const checklist = await getOnboardingChecklist(ctx);
if (!checklist) return null;
const [t, trial] = await Promise.all([getTranslations("trial.onboarding"), getTrialState(ctx.tenantId)]);
const writable = !trial.readOnly;
const percent = Math.round((checklist.completed / checklist.total) * 100);
return (
<section aria-labelledby="getting-started-title" className="shadow-card mb-4 rounded-xl border bg-card p-4 md:p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
{welcome && <p className="mb-1 text-sm font-semibold text-[var(--ok)]">{t("welcome")}</p>}
<h2 id="getting-started-title" className="font-heading text-[15px] font-semibold">{t("title")}</h2>
<p className="text-[12.5px] text-muted-foreground">{t("progress", { done: checklist.completed, total: checklist.total })}</p>
</div>
{writable && (
<form action={hideOnboardingAction}>
<button type="submit" className="min-h-11 rounded-md px-3 text-[13px] font-semibold text-muted-foreground hover:bg-muted hover:text-foreground">{t("hide")}</button>
</form>
)}
</div>
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-muted" role="progressbar" aria-valuemin={0} aria-valuemax={checklist.total} aria-valuenow={checklist.completed} aria-label={t("progress", { done: checklist.completed, total: checklist.total })}>
<div className="h-full rounded-full bg-[var(--ok)]" style={{ width: `${percent}%` }} />
</div>
<ul className="mt-3 divide-y">
{checklist.items.map((item) => (
<li key={item.key} className="flex flex-wrap items-center gap-3 py-2.5">
{item.done ? <CheckCircle2 className="size-5 shrink-0 text-[var(--ok)]" aria-hidden /> : <Circle className="size-5 shrink-0 text-muted-foreground" aria-hidden />}
<div className="min-w-0 flex-1">
<p className={item.done ? "text-sm font-semibold text-muted-foreground line-through" : "text-sm font-semibold"}>
<span className="sr-only">{item.done ? t("doneLabel") : t("openLabel")}: </span>
{t(`items.${item.key}.title`)}
</p>
<p className="text-[12.5px] text-muted-foreground">{t(`items.${item.key}.text`)}{item.auto ? ` · ${t("auto")}` : ""}</p>
</div>
<Link href={item.href} className="inline-flex min-h-11 items-center rounded-md px-3 text-[13px] font-semibold text-[var(--ui-primary)] hover:bg-muted">{t("open")}</Link>
{writable && !item.auto && (
<form action={toggleOnboardingItemAction}>
<input type="hidden" name="key" value={item.key} />
<input type="hidden" name="done" value={item.done ? "0" : "1"} />
<button type="submit" className="min-h-11 rounded-md border px-3 text-[13px] hover:bg-muted">{item.done ? t("markOpen") : t("markDone")}</button>
</form>
)}
</li>
))}
</ul>
</section>
);
}
@@ -0,0 +1,15 @@
import Link from "next/link";
/** L15 Testphase: simple legal text page (terms/privacy placeholders under /testen). */
export function LegalPlaceholder({ title, paragraphs, placeholder, back }: { title: string; paragraphs: string[]; placeholder: string; back: string }) {
return (
<article className="shadow-card mt-4 w-full max-w-2xl self-start rounded-2xl border bg-card p-6 sm:p-8">
<h1 className="font-heading text-xl font-semibold">{title}</h1>
<p className="mt-2 rounded-lg bg-muted px-3 py-2 text-[13px] text-muted-foreground">{placeholder}</p>
{paragraphs.map((p) => (
<p key={p} className="mt-3 text-sm leading-relaxed">{p}</p>
))}
<Link href="/testen" className="mt-4 inline-flex min-h-11 items-center text-sm font-semibold text-[var(--ui-primary)]">← {back}</Link>
</article>
);
}
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { useActionState } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { createTrialTenantAction, type PlatformTrialState } from "@/server/actions/trial-platform";
const ERR = "rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]";
function ErrorText({ state }: { state: PlatformTrialState }) {
const t = useTranslations("trial.platform.errors");
if (state.status !== "error") return null;
return <p role="alert" className={ERR}>{t.has(state.code) ? t(state.code) : t("failed")}</p>;
}
/** L15 Testphase: platform wizard "Testmandant anlegen" (one page, four sections). */
export function PlatformTrialCreateForm({ defaultEnd, min, max }: { defaultEnd: string; min: string; max: string }) {
const t = useTranslations("trial.platform.wizard");
const [state, action, pending] = useActionState<PlatformTrialState, FormData>(createTrialTenantAction, { status: "idle" });
const section = "shadow-card rounded-xl border bg-card p-5";
return (
<form action={action} className="grid gap-4">
<fieldset className={section}>
<legend className="font-heading text-sm font-semibold">{t("sectionCompany")}</legend>
<div className="mt-3 grid gap-4 md:grid-cols-2">
<div>
<Label htmlFor="companyName">{t("companyName")} *</Label>
<Input id="companyName" name="companyName" required minLength={2} maxLength={120} className="mt-1 h-11" />
</div>
<div>
<Label htmlFor="sector">{t("sector")}</Label>
<Input id="sector" name="sector" maxLength={80} className="mt-1 h-11" />
</div>
</div>
</fieldset>
<fieldset className={section}>
<legend className="font-heading text-sm font-semibold">{t("sectionAdmin")}</legend>
<div className="mt-3 grid gap-4 md:grid-cols-2">
<div>
<Label htmlFor="adminName">{t("adminName")} *</Label>
<Input id="adminName" name="adminName" required minLength={2} maxLength={120} className="mt-1 h-11" />
</div>
<div>
<Label htmlFor="adminEmail">{t("adminEmail")} *</Label>
<Input id="adminEmail" name="adminEmail" type="email" required maxLength={200} className="mt-1 h-11" />
</div>
</div>
<p className="mt-2 text-[12px] text-muted-foreground">{t("adminHint")}</p>
</fieldset>
<fieldset className={section}>
<legend className="font-heading text-sm font-semibold">{t("sectionPeriod")}</legend>
<div className="mt-3 max-w-xs">
<Label htmlFor="endDate">{t("endDate")} *</Label>
<Input id="endDate" name="endDate" type="date" required min={min} max={max} defaultValue={defaultEnd} className="mt-1 h-11" />
<p className="mt-1 text-[12px] text-muted-foreground">{t("endDateHint")}</p>
</div>
</fieldset>
<fieldset className={section}>
<legend className="font-heading text-sm font-semibold">{t("sectionSetup")}</legend>
<label className="mt-3 flex min-h-11 items-center gap-2 text-sm">
<input type="checkbox" name="sampleData" defaultChecked className="size-5" /> {t("sampleData")}
</label>
</fieldset>
<ErrorText state={state} />
<div className="flex flex-wrap gap-2">
<Button type="submit" className="h-11" disabled={pending}>{pending ? t("submitting") : t("submit")}</Button>
<Button variant="outline" className="h-11" nativeButton={false} render={<Link href="/admin" />}>{t("cancel")}</Button>
</div>
</form>
);
}
/** L15 Testphase: confirmation form of one lifecycle operation (bound server action). */
export function TrialLifecycleForm({
action,
withDate,
defaultEnd,
min,
max,
closeHref,
destructive,
}: {
action: (prev: PlatformTrialState, fd: FormData) => Promise<PlatformTrialState>;
withDate: boolean;
defaultEnd?: string;
min?: string;
max?: string;
closeHref: string;
destructive?: boolean;
}) {
const t = useTranslations("trial.platform.ops");
const tw = useTranslations("trial.platform.wizard");
const [state, formAction, pending] = useActionState<PlatformTrialState, FormData>(action, { status: "idle" });
return (
<form action={formAction} className="space-y-3">
{withDate && (
<div className="max-w-xs">
<Label htmlFor="op-endDate">{tw("endDate")} *</Label>
<Input id="op-endDate" name="endDate" type="date" required min={min} max={max} defaultValue={defaultEnd} className="mt-1 h-11" />
</div>
)}
<label className="flex min-h-11 items-center gap-2 text-sm">
<input type="checkbox" name="confirm" className="size-5" required /> {t("confirm")}
</label>
<ErrorText state={state} />
<div className="flex flex-wrap gap-2">
<Button type="submit" className="h-11" variant={destructive ? "destructive" : "default"} disabled={pending}>{pending ? t("submitting") : t("submit")}</Button>
<Button variant="outline" className="h-11" nativeButton={false} render={<Link href={closeHref} />}>{t("close")}</Button>
</div>
</form>
);
}
+341
View File
@@ -0,0 +1,341 @@
"use client";
import { useMemo, useState, useTransition } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Check, MailCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { diffDayKeys, formatDateKey, type TrialBounds } from "@/lib/trial/dates";
import {
TRIAL_COMPANY_SIZES,
TRIAL_SECTOR_KEYS,
TRIAL_STEPS,
validateTrialSignup,
validateTrialStep,
type FieldErrors,
type TrialSignupValues,
type TrialStep,
} from "@/lib/trial/signup";
import { checkTrialStepAction, submitTrialSignupAction } from "@/server/actions/trial-signup";
/**
* L15 Testphase: public 5-step wizard. All values live in one state object, so "Zurück" never loses
* input. Each step is validated in the browser (same rules as the server) and then by the server
* (`checkTrialStepAction`); the final submit validates everything again.
*/
type Props = {
bounds: TrialBounds;
modules: { key: string; label: string }[];
passwordPolicy: string;
locale: string;
};
const STEP_FIELDS: Record<TrialStep, (keyof TrialSignupValues)[]> = {
company: ["companyName", "sector", "companySize"],
account: ["adminName", "email", "password"],
period: ["trialEndDate"],
setup: ["sampleData", "modules"],
summary: ["acceptTerms", "acceptPrivacy"],
};
const fieldCls = "mt-1 h-11 text-base sm:text-sm";
const selectCls = "mt-1 h-11 w-full rounded-md border border-input bg-background px-3 text-base sm:text-sm";
const errorCls = "mt-1 text-[13px] text-[var(--risk)]";
export function TrialSignupWizard({ bounds, modules, passwordPolicy, locale }: Props) {
const t = useTranslations("trial");
const [step, setStep] = useState(0);
const [sectorKey, setSectorKey] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [values, setValues] = useState<TrialSignupValues>({
companyName: "",
sector: "",
companySize: "",
adminName: "",
email: "",
password: "",
trialEndDate: bounds.defaultEnd,
sampleData: true,
modules: modules.map((m) => m.key),
acceptTerms: false,
acceptPrivacy: false,
website: "",
});
const [errors, setErrors] = useState<FieldErrors>({});
const [sent, setSent] = useState(false);
const [pending, startTransition] = useTransition();
const current = TRIAL_STEPS[step];
const set = <K extends keyof TrialSignupValues>(key: K, value: TrialSignupValues[K]) => {
setValues((v) => ({ ...v, [key]: value }));
setErrors((e) => ({ ...e, [key]: undefined, _form: undefined }));
};
const days = useMemo(() => (/^\d{4}-\d{2}-\d{2}$/.test(values.trialEndDate) ? diffDayKeys(bounds.today, values.trialEndDate) : null), [values.trialEndDate, bounds.today]);
const err = (key: keyof FieldErrors) => (errors[key] ? t(`errors.${errors[key]}`) : null);
function firstStepWithError(e: FieldErrors): number {
const idx = TRIAL_STEPS.findIndex((s) => STEP_FIELDS[s].some((f) => e[f]));
return idx === -1 ? step : idx;
}
function next() {
const local = validateTrialStep(current, values, bounds);
if (Object.keys(local).length) return setErrors(local);
startTransition(async () => {
const server = await checkTrialStepAction(current, values);
if (Object.keys(server).length) return setErrors(server);
setErrors({});
setStep((s) => Math.min(s + 1, TRIAL_STEPS.length - 1));
});
}
function submit() {
const local = validateTrialSignup(values, bounds);
if (Object.keys(local).length) {
setErrors(local);
return setStep(firstStepWithError(local));
}
startTransition(async () => {
const res = await submitTrialSignupAction(values);
if (res.status === "sent") return setSent(true);
setErrors(res.errors);
setStep(firstStepWithError(res.errors));
});
}
if (sent) {
return (
<div role="status" className="space-y-3 text-center">
<MailCheck className="mx-auto size-10 text-[var(--ok)]" aria-hidden />
<h2 className="font-heading text-xl font-semibold">{t("sent.title")}</h2>
<p className="text-sm">{t("sent.body")}</p>
<p className="text-[13px] text-muted-foreground">{t("sent.hint")}</p>
<Button variant="outline" className="h-11" onClick={() => { setSent(false); setStep(0); }}>
{t("sent.again")}
</Button>
</div>
);
}
return (
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
if (current === "summary") submit();
else next();
}}
>
{/* Progress */}
<nav aria-label={t("progressLabel")}>
<p className="text-[13px] font-medium text-muted-foreground">{t("progress", { current: step + 1, total: TRIAL_STEPS.length })}</p>
<ol className="mt-2 grid grid-cols-5 gap-1.5">
{TRIAL_STEPS.map((s, i) => (
<li key={s} aria-current={i === step ? "step" : undefined}>
<button
type="button"
disabled={i > step || pending}
onClick={() => setStep(i)}
className="flex w-full flex-col items-start gap-1 text-left disabled:cursor-default"
>
<span className={cn("h-1.5 w-full rounded-full", i <= step ? "bg-[var(--ui-primary)]" : "bg-muted")} />
<span className={cn("hidden text-[11.5px] sm:inline", i === step ? "font-semibold text-foreground" : "text-muted-foreground")}>
{i < step && <Check className="mr-0.5 inline size-3" aria-hidden />}
{t(`steps.${s}`)}
</span>
</button>
</li>
))}
</ol>
</nav>
<h2 className="mt-5 font-heading text-lg font-semibold">{t(`steps.${current}`)}</h2>
{/* Honeypot: invisible for people, not announced to screen readers */}
<div aria-hidden className="absolute -left-[10000px] top-auto size-px overflow-hidden">
<label htmlFor="website">{t("fields.website")}</label>
<input id="website" name="website" tabIndex={-1} autoComplete="off" value={values.website} onChange={(e) => set("website", e.target.value)} />
</div>
<div className="mt-4 space-y-4">
{current === "company" && (
<>
<div>
<Label htmlFor="companyName">{t("fields.companyName")} *</Label>
<Input id="companyName" className={fieldCls} autoComplete="organization" value={values.companyName} onChange={(e) => set("companyName", e.target.value)} aria-invalid={!!errors.companyName} aria-describedby={errors.companyName ? "companyName-error" : undefined} />
{err("companyName") && <p id="companyName-error" className={errorCls}>{err("companyName")}</p>}
</div>
<div>
<Label htmlFor="sector">{t("fields.sector")}</Label>
<select
id="sector"
className={selectCls}
value={sectorKey}
onChange={(e) => {
const key = e.target.value;
setSectorKey(key);
set("sector", key && key !== "other" ? t(`sectors.${key}`) : "");
}}
>
<option value="">{t("fields.sectorChoose")}</option>
{TRIAL_SECTOR_KEYS.map((k) => (
<option key={k} value={k}>{t(`sectors.${k}`)}</option>
))}
</select>
{sectorKey === "other" && (
<>
<Label htmlFor="sectorOther" className="mt-3 block">{t("fields.sectorOther")}</Label>
<Input id="sectorOther" className={fieldCls} value={values.sector} onChange={(e) => set("sector", e.target.value)} />
</>
)}
{err("sector") && <p className={errorCls}>{err("sector")}</p>}
</div>
<div>
<Label htmlFor="companySize">{t("fields.companySize")}</Label>
<select id="companySize" className={selectCls} value={values.companySize} onChange={(e) => set("companySize", e.target.value)}>
<option value="">{t("fields.companySizeNone")}</option>
{TRIAL_COMPANY_SIZES.map((s) => (
<option key={s} value={s}>{t(`sizes.${s.replace(/[^0-9]/g, "_")}`)}</option>
))}
</select>
{err("companySize") && <p className={errorCls}>{err("companySize")}</p>}
</div>
</>
)}
{current === "account" && (
<>
<div>
<Label htmlFor="adminName">{t("fields.adminName")} *</Label>
<Input id="adminName" className={fieldCls} autoComplete="name" value={values.adminName} onChange={(e) => set("adminName", e.target.value)} aria-invalid={!!errors.adminName} />
{err("adminName") && <p className={errorCls}>{err("adminName")}</p>}
</div>
<div>
<Label htmlFor="email">{t("fields.email")} *</Label>
<Input id="email" type="email" inputMode="email" className={fieldCls} autoComplete="email" value={values.email} onChange={(e) => set("email", e.target.value)} aria-invalid={!!errors.email} aria-describedby="email-hint" />
<p id="email-hint" className="mt-1 text-[12.5px] text-muted-foreground">{t("fields.emailHint")}</p>
{err("email") && <p className={errorCls}>{err("email")}</p>}
</div>
<div>
<Label htmlFor="password">{t("fields.password")} *</Label>
<Input id="password" type={showPassword ? "text" : "password"} className={fieldCls} autoComplete="new-password" value={values.password} onChange={(e) => set("password", e.target.value)} aria-invalid={!!errors.password} aria-describedby="password-hint" />
<p id="password-hint" className="mt-1 text-[12.5px] text-muted-foreground">{t("fields.passwordHint", { policy: passwordPolicy })}</p>
<label className="mt-2 flex min-h-11 items-center gap-2 text-sm">
<input type="checkbox" className="size-5" checked={showPassword} onChange={(e) => setShowPassword(e.target.checked)} />
{t("fields.showPassword")}
</label>
{err("password") && <p className={errorCls}>{err("password")}</p>}
</div>
</>
)}
{current === "period" && (
<div>
<Label htmlFor="trialEndDate">{t("fields.trialEndDate")} *</Label>
<Input id="trialEndDate" type="date" className={fieldCls} min={bounds.min} max={bounds.max} value={values.trialEndDate} onChange={(e) => set("trialEndDate", e.target.value)} aria-invalid={!!errors.trialEndDate} aria-describedby="period-range" />
<p id="period-range" className="mt-1 text-[12.5px] text-muted-foreground">
{t("period.range", { min: formatDateKey(bounds.min, locale), max: formatDateKey(bounds.max, locale) })}
</p>
{err("trialEndDate") ? (
<p className={errorCls}>{err("trialEndDate")}</p>
) : (
days !== null && days > 0 && (
<p className="mt-3 rounded-lg bg-[var(--ui-primary-soft)] px-3 py-2 text-sm font-semibold" aria-live="polite">
{t("period.until", { date: formatDateKey(values.trialEndDate, locale), days })}
</p>
)
)}
<p className="mt-3 text-[13px] text-muted-foreground">{t("period.afterEnd")}</p>
</div>
)}
{current === "setup" && (
<>
<label className="flex min-h-11 items-start gap-3 rounded-lg border p-3">
<input type="checkbox" className="mt-0.5 size-5" checked={values.sampleData} onChange={(e) => set("sampleData", e.target.checked)} />
<span>
<span className="block text-sm font-semibold">{t("fields.sampleData")}</span>
<span className="block text-[12.5px] text-muted-foreground">{t("fields.sampleDataHint")}</span>
</span>
</label>
<fieldset>
<legend className="text-sm font-semibold">{t("fields.modules")}</legend>
<p className="text-[12.5px] text-muted-foreground">{t("fields.modulesHint")}</p>
<div className="mt-2 grid gap-1 sm:grid-cols-2">
{modules.map((m) => (
<label key={m.key} className="flex min-h-11 items-center gap-2 rounded-md px-2 text-sm hover:bg-muted">
<input
type="checkbox"
className="size-5"
checked={values.modules.includes(m.key)}
onChange={(e) => set("modules", e.target.checked ? [...values.modules, m.key] : values.modules.filter((k) => k !== m.key))}
/>
{m.label}
</label>
))}
</div>
{err("modules") && <p className={errorCls}>{err("modules")}</p>}
</fieldset>
</>
)}
{current === "summary" && (
<>
<p className="text-sm text-muted-foreground">{t("summary.intro")}</p>
<dl className="divide-y rounded-lg border text-sm">
{[
{ step: 0, label: t("steps.company"), value: `${values.companyName} · ${values.sector || t("summary.noSector")}` },
{ step: 1, label: t("steps.account"), value: `${values.adminName} · ${values.email}` },
{ step: 2, label: t("steps.period"), value: days !== null ? t("period.until", { date: formatDateKey(values.trialEndDate, locale), days }) : values.trialEndDate },
{ step: 3, label: t("steps.setup"), value: `${values.sampleData ? t("summary.sampleYes") : t("summary.sampleNo")} · ${t("summary.modulesCount", { count: values.modules.length, total: modules.length })}` },
].map((row) => (
<div key={row.step} className="flex items-start justify-between gap-3 px-3 py-2.5">
<div className="min-w-0">
<dt className="text-[12px] text-muted-foreground">{row.label}</dt>
<dd className="break-words">{row.value}</dd>
</div>
<button type="button" className="min-h-11 shrink-0 px-2 text-[13px] font-semibold text-[var(--ui-primary)]" onClick={() => setStep(row.step)}>
{t("summary.edit")}
</button>
</div>
))}
</dl>
<label className="flex min-h-11 items-start gap-3">
<input type="checkbox" className="mt-0.5 size-5" checked={values.acceptTerms} onChange={(e) => set("acceptTerms", e.target.checked)} aria-invalid={!!errors.acceptTerms} />
<span className="text-sm">
{t.rich("fields.acceptTerms", { link: () => <Link href="/testen/nutzungsbedingungen" target="_blank" className="font-semibold text-[var(--ui-primary)] underline">{t("public.terms")}</Link> })}
</span>
</label>
{err("acceptTerms") && <p className={errorCls}>{err("acceptTerms")}</p>}
<label className="flex min-h-11 items-start gap-3">
<input type="checkbox" className="mt-0.5 size-5" checked={values.acceptPrivacy} onChange={(e) => set("acceptPrivacy", e.target.checked)} aria-invalid={!!errors.acceptPrivacy} />
<span className="text-sm">
{t.rich("fields.acceptPrivacy", { link: () => <Link href="/testen/datenschutz" target="_blank" className="font-semibold text-[var(--ui-primary)] underline">{t("public.privacy")}</Link> })}
</span>
</label>
{err("acceptPrivacy") && <p className={errorCls}>{err("acceptPrivacy")}</p>}
</>
)}
</div>
{err("_form") && (
<p role="alert" className="mt-4 rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">
{err("_form")}
</p>
)}
<div className="mt-6 flex items-center justify-between gap-3">
<Button type="button" variant="outline" className="h-11 min-w-24" disabled={step === 0 || pending} onClick={() => { setErrors({}); setStep((s) => Math.max(0, s - 1)); }}>
{t("actions.back")}
</Button>
<Button type="submit" className="h-11 min-w-32" disabled={pending}>
{current === "summary" ? (pending ? t("actions.submitting") : t("actions.submit")) : pending ? t("actions.checking") : t("actions.next")}
</Button>
</div>
</form>
);
}
+89
View File
@@ -0,0 +1,89 @@
import Link from "next/link";
import { getLocale, getTranslations } from "next-intl/server";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/modal";
import { Pill } from "@/components/mockup-ui";
import { addDaysToKey, formatDateKey, formatInstantDate, todayKey } from "@/lib/trial/dates";
import { trialLifecycleAction } from "@/server/actions/trial-platform";
import { PLATFORM_TRIAL_MAX_DAYS } from "@/server/services/trial/config";
import { computeTrialState, type TenantTrialRow } from "@/server/services/trial/state";
import { TrialLifecycleForm } from "./platform-forms";
const OPS = ["extend", "convert", "end", "schedule", "cancel"] as const;
type Op = (typeof OPS)[number];
/**
* L15 Testphase: trial card on the platform tenant detail page with lifecycle actions (full admins).
* Each action opens a confirmation popup (?trial=<op>, existing Modal pattern).
*/
export async function TrialAdminCard({
tenant,
isFullAdmin,
base,
op,
notice,
}: {
tenant: TenantTrialRow & { id: string; trialSource: string | null; status: string };
isFullAdmin: boolean;
base: string;
op?: string;
notice?: "created" | "invited" | "done" | null;
}) {
if (!tenant.trialStartedAt) return null;
const [t, locale] = await Promise.all([getTranslations("trial.platform"), getLocale()]);
const state = computeTrialState(tenant);
const deleted = !!tenant.trialDeletedAt;
const isTrial = tenant.plan === "TRIAL" && !deleted;
const today = todayKey();
const activeOp = isFullAdmin && isTrial && (OPS as readonly string[]).includes(op ?? "") ? (op as Op) : null;
const stateText = deleted ? t("card.stateDeleted") : tenant.plan === "FULL" ? t("card.stateConverted") : state.expired ? t("card.stateExpired") : t("card.stateActive");
const actions: { op: Op; show: boolean }[] = [
{ op: "extend", show: true },
{ op: "convert", show: true },
{ op: "end", show: !state.expired },
{ op: "schedule", show: !tenant.deletionDueAt },
{ op: "cancel", show: !!tenant.deletionDueAt },
];
return (
<div className="shadow-card rounded-xl border bg-card p-5" data-trial-card>
<p className="mb-2 font-heading text-sm font-semibold">{t("card.title")}</p>
{notice && <p role="status" className="mb-3 rounded-lg bg-[rgba(57,192,127,0.14)] px-3 py-2 text-[12.5px] text-[var(--ok)]">{t(`card.${notice}`)}</p>}
<dl className="space-y-1.5 text-[13px]">
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">{t("card.state")}</dt><dd><Pill tone={deleted ? "mut" : tenant.plan === "FULL" ? "ok" : "warn"}>{stateText}</Pill></dd></div>
{state.endDateKey && <div className="flex justify-between gap-2"><dt className="text-muted-foreground">{t("card.until")}</dt><dd>{formatDateKey(state.endDateKey, locale)}</dd></div>}
{isTrial && <div className="flex justify-between gap-2"><dt className="text-muted-foreground">{t("card.deletion")}</dt><dd>{tenant.deletionDueAt ? formatInstantDate(tenant.deletionDueAt, locale) : t("card.deletionNone")}</dd></div>}
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">{t("card.source")}</dt><dd>{tenant.trialSource === "platform" ? t("card.sourcePlatform") : t("card.sourceSelf")}</dd></div>
</dl>
{isFullAdmin && isTrial && (
<div className="mt-3 space-y-2">
{actions.filter((a) => a.show).map((a) => (
<Link key={a.op} href={`${base}?trial=${a.op}`} scroll={false} className="block">
<Button variant="outline" size="sm" className={a.op === "end" || a.op === "schedule" ? "min-h-11 w-full justify-center border-destructive/40 text-destructive" : "min-h-11 w-full justify-center"}>
{t(`card.${a.op}`)}
</Button>
</Link>
))}
</div>
)}
{activeOp && (
<Modal title={t(`ops.${activeOp}Title`)} closeHref={base} closeLabel={t("ops.close")}>
<div className="space-y-3 p-5">
<p className="text-sm">{t(`ops.${activeOp}Text`)}</p>
<TrialLifecycleForm
action={trialLifecycleAction.bind(null, tenant.id, activeOp)}
withDate={activeOp === "extend"}
defaultEnd={state.endDateKey && state.endDateKey >= today ? addDaysToKey(state.endDateKey, 14) : addDaysToKey(today, 14)}
min={today}
max={addDaysToKey(today, PLATFORM_TRIAL_MAX_DAYS)}
closeHref={base}
destructive={activeOp === "end" || activeOp === "schedule"}
/>
</div>
</Modal>
)}
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { getLocale, getTranslations } from "next-intl/server";
import { Pill } from "@/components/mockup-ui";
import { formatDateKey, formatInstantDate } from "@/lib/trial/dates";
import { computeTrialState, type TenantTrialRow } from "@/server/services/trial/state";
/** L15 Testphase: plan badge(s) in the platform tenant list ("Test bis …", "abgelaufen – nur lesen", "Löschung am …"). */
export async function TrialBadge({ tenant }: { tenant: TenantTrialRow }) {
const [t, locale] = await Promise.all([getTranslations("trial.platform"), getLocale()]);
if (tenant.trialDeletedAt) return <Pill tone="mut">{t("badgeDeleted")}</Pill>;
const state = computeTrialState(tenant);
if (!state.isTrial) return <Pill tone="ok">{t("badgeFull")}</Pill>;
return (
<span className="flex flex-wrap gap-1.5">
{state.expired ? <Pill tone="warn">{t("badgeExpired")}</Pill> : <Pill tone="warn">{t("badgeUntil", { date: formatDateKey(state.endDateKey!, locale) })}</Pill>}
{state.deletionDueAt && <Pill tone="mut">{t("badgeDeletion", { date: formatInstantDate(state.deletionDueAt, locale) })}</Pill>}
</span>
);
}
+54
View File
@@ -0,0 +1,54 @@
import Link from "next/link";
import { getLocale, getTranslations } from "next-intl/server";
import { Hourglass, Lock } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatDateKey, formatInstantDate } from "@/lib/trial/dates";
import { trialContactEmail } from "@/server/services/trial/config";
import { getTrialState } from "@/server/services/trial/state";
/**
* L15 Testphase: banner in the backoffice and mobile shell — countdown from 7 days before the end,
* read-only notice (+ deletion date, contact, export link) after expiry. Status is carried by text
* and icon, colour only supports it.
*/
export async function TrialBanner({ tenantId, variant, canExport = false }: { tenantId: string; variant: "backoffice" | "mobile"; canExport?: boolean }) {
const state = await getTrialState(tenantId);
if (!state.isTrial || (!state.expired && !state.showCountdown)) return null;
const [t, locale] = await Promise.all([getTranslations("trial.banner"), getLocale()]);
const contact = trialContactEmail();
const Icon = state.expired ? Lock : Hourglass;
return (
<div
role="status"
data-trial-banner={state.expired ? "expired" : "countdown"}
className={cn(
"flex flex-wrap items-center gap-x-3 gap-y-1 border-b px-4 py-2.5 text-[13px]",
state.expired ? "bg-[rgba(198,66,66,0.08)] text-foreground" : "bg-[rgba(200,121,18,0.10)] text-foreground",
variant === "mobile" && "text-[14px]",
)}
>
<Icon className={cn("size-4 shrink-0", state.expired ? "text-[var(--risk)]" : "text-[var(--warn)]")} aria-hidden />
<span className="font-semibold">
{state.expired ? t("expired") : t("endsIn", { days: Math.max(0, state.daysLeft ?? 0) })}
</span>
{!state.expired && state.endDateKey && <span>{t("endsOn", { date: formatDateKey(state.endDateKey, locale) })}</span>}
{state.expired && state.deletionDueAt && <span>{t("deletion", { date: formatInstantDate(state.deletionDueAt, locale) })}</span>}
<span className="text-muted-foreground">
{contact ? (
<>
{t("contact", { email: "" })}
<a href={`mailto:${contact}`} className="font-semibold text-[var(--ui-primary)] underline">{contact}</a>
</>
) : (
t("contactGeneric")
)}
</span>
{state.expired && canExport && (
<Link href="/settings/export" className="ml-auto inline-flex min-h-11 items-center font-semibold text-[var(--ui-primary)] underline">
{t("export")}
</Link>
)}
</div>
);
}
+2
View File
@@ -19,6 +19,7 @@ import {
LayoutGrid,
MapPinned,
Receipt,
Download,
type LucideIcon,
} from "lucide-react";
import type { ModuleKey } from "@/lib/modules";
@@ -77,6 +78,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
{ href: "/settings/email", label: "email", icon: Mail, module: "notifications", permissions: ["tenant:manage"], section: "admin" },
{ href: "/settings/audit", label: "audit", icon: History, permissions: ["audit:read"], section: "admin" },
{ href: "/settings/lotse", label: "lotse", icon: Compass, permissions: ["tenant:manage"], section: "admin" },
{ href: "/settings/export", label: "dataExport", icon: Download, permissions: ["tenant:manage"], section: "admin" }, // L15
];
/** Filtert die Navigation nach aktiven Modulen und Rechten der Session. */
+68
View File
@@ -0,0 +1,68 @@
/**
* L15 Testphase: calendar-day helpers in Europe/Berlin (client-safe, no dependencies).
* Date keys are `YYYY-MM-DD`. A trial "until 30.09." ends at the START of 01.10. in Berlin
* (exclusive end instant, stored in `Tenant.trialEndsAt`).
*/
import { dayWindow, localDateKey } from "@/lib/reports/dates";
export const TRIAL_TZ = "Europe/Berlin";
const DATE_KEY = /^\d{4}-\d{2}-\d{2}$/;
export function isDateKey(value: unknown): value is string {
if (typeof value !== "string" || !DATE_KEY.test(value)) return false;
const [y, m, d] = value.split("-").map(Number);
const dt = new Date(Date.UTC(y, m - 1, d));
return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
}
/** Today's date key in Berlin. */
export function todayKey(now: Date = new Date()): string {
return localDateKey(now, TRIAL_TZ);
}
export function addDaysToKey(key: string, days: number): string {
const [y, m, d] = key.split("-").map(Number);
return new Date(Date.UTC(y, m - 1, d + days)).toISOString().slice(0, 10);
}
/** Whole calendar days from `from` to `to` (negative when `to` lies before `from`). */
export function diffDayKeys(from: string, to: string): number {
const [y1, m1, d1] = from.split("-").map(Number);
const [y2, m2, d2] = to.split("-").map(Number);
return Math.round((Date.UTC(y2, m2 - 1, d2) - Date.UTC(y1, m1 - 1, d1)) / 86_400_000);
}
/** Exclusive end instant of the chosen last trial day (start of the next day in Berlin). */
export function trialEndInstant(endDateKey: string): Date {
return dayWindow(endDateKey, TRIAL_TZ).end;
}
/** Last trial day (date key) of a stored exclusive end instant. */
export function trialEndDateKey(endsAt: Date): string {
return localDateKey(new Date(endsAt.getTime() - 1), TRIAL_TZ);
}
/** `TT.MM.JJJJ` (de) or `DD/MM/YYYY` (en) of a date key. */
export function formatDateKey(key: string, locale: string = "de"): string {
const [y, m, d] = key.split("-");
return locale === "en" ? `${d}/${m}/${y}` : `${d}.${m}.${y}`;
}
/** Formatted Berlin calendar date of an instant. */
export function formatInstantDate(instant: Date, locale: string = "de"): string {
return formatDateKey(localDateKey(instant, TRIAL_TZ), locale);
}
export type TrialBounds = { today: string; min: string; max: string; defaultEnd: string; maxDays: number };
/** Allowed end dates of the self-service wizard: tomorrow … today + maxDays (default today + 14). */
export function trialBounds(now: Date, maxDays: number, defaultDays = 14): TrialBounds {
const today = todayKey(now);
return {
today,
min: addDaysToKey(today, 1),
max: addDaysToKey(today, maxDays),
defaultEnd: addDaysToKey(today, Math.min(defaultDays, maxDays)),
maxDays,
};
}
+107
View File
@@ -0,0 +1,107 @@
/**
* L15 Testphase: wizard data model + validation (client-safe — used by the wizard for instant
* feedback AND by the server actions/services as the authoritative check).
* Errors are stable codes; the UI resolves them via messages `trial.errors.<code>`.
*/
import { z } from "zod";
import { DEFAULT_PASSWORD_POLICY, validatePassword } from "@/lib/password-policy";
import { MODULE_KEYS } from "@/lib/modules";
import { isDateKey, type TrialBounds } from "@/lib/trial/dates";
/** Sector presets (labels in messages `trial.sectors.<key>`); "other" = free text. */
export const TRIAL_SECTOR_KEYS = ["shk", "electrical", "roofing", "carpentry", "painting", "facility", "construction", "metal", "other"] as const;
export const TRIAL_COMPANY_SIZES = ["1-5", "6-20", "21-50", "51-200", "200+"] as const;
export const TRIAL_STEPS = ["company", "account", "period", "setup", "summary"] as const;
export type TrialStep = (typeof TRIAL_STEPS)[number];
export type TrialSignupValues = {
companyName: string;
/** Stored sector text (preset label or free text). */
sector: string;
companySize: string;
adminName: string;
email: string;
password: string;
trialEndDate: string;
sampleData: boolean;
modules: string[];
acceptTerms: boolean;
acceptPrivacy: boolean;
/** Honeypot — must stay empty (hidden from people, filled by naive bots). */
website: string;
};
/** `_form` = error not tied to one field (malformed request, rate limit). */
export type FieldErrors = Partial<Record<keyof TrialSignupValues | "_form", string>>;
/** Shape/coercion of untrusted input (server side). Content rules follow in `validateTrialStep`. */
export const trialSignupInputSchema = z.object({
companyName: z.string().max(500).default(""),
sector: z.string().max(500).default(""),
companySize: z.string().max(20).default(""),
adminName: z.string().max(500).default(""),
email: z.string().max(500).default(""),
password: z.string().max(500).default(""),
trialEndDate: z.string().max(20).default(""),
sampleData: z.coerce.boolean().default(true),
modules: z.array(z.string().max(40)).max(50).default([...MODULE_KEYS]),
acceptTerms: z.coerce.boolean().default(false),
acceptPrivacy: z.coerce.boolean().default(false),
website: z.string().max(500).default(""),
});
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
/** Normalised values (trimmed, lower-case e-mail, known modules only); null for malformed input. */
export function normalizeTrialValues(input: unknown): TrialSignupValues | null {
const parsed = trialSignupInputSchema.safeParse(input ?? {});
if (!parsed.success) return null;
const v = parsed.data;
return {
...v,
companyName: v.companyName.trim(),
sector: v.sector.trim(),
companySize: v.companySize.trim(),
adminName: v.adminName.trim(),
email: v.email.trim().toLowerCase(),
trialEndDate: v.trialEndDate.trim(),
modules: [...new Set(v.modules.filter((m) => (MODULE_KEYS as readonly string[]).includes(m)))],
};
}
export function validateTrialStep(step: TrialStep, v: TrialSignupValues, bounds: TrialBounds): FieldErrors {
const e: FieldErrors = {};
switch (step) {
case "company":
if (v.companyName.length < 2) e.companyName = "company_required";
else if (v.companyName.length > 120) e.companyName = "too_long";
if (v.sector.length > 80) e.sector = "too_long";
if (v.companySize && !(TRIAL_COMPANY_SIZES as readonly string[]).includes(v.companySize)) e.companySize = "invalid_choice";
break;
case "account":
if (v.adminName.length < 2) e.adminName = "name_required";
else if (v.adminName.length > 120) e.adminName = "too_long";
if (!EMAIL.test(v.email) || v.email.length > 200) e.email = "email_invalid";
if (validatePassword(v.password, DEFAULT_PASSWORD_POLICY).length > 0 || v.password.length > 200) e.password = "password_policy";
break;
case "period":
if (!isDateKey(v.trialEndDate)) e.trialEndDate = "date_invalid";
else if (v.trialEndDate < bounds.min) e.trialEndDate = "date_too_early";
else if (v.trialEndDate > bounds.max) e.trialEndDate = "date_too_late";
break;
case "setup":
if (v.modules.length === 0) e.modules = "modules_required";
break;
case "summary":
if (!v.acceptTerms) e.acceptTerms = "terms_required";
if (!v.acceptPrivacy) e.acceptPrivacy = "privacy_required";
break;
}
return e;
}
/** All steps at once (final submit). */
export function validateTrialSignup(v: TrialSignupValues, bounds: TrialBounds): FieldErrors {
return TRIAL_STEPS.reduce<FieldErrors>((acc, step) => ({ ...acc, ...validateTrialStep(step, v, bounds) }), {});
}
+3 -1
View File
@@ -9,7 +9,9 @@ import { NextResponse, type NextRequest } from "next/server";
// SEC2: die Wiederherstellungs-Abläufe müssen ohne Session erreichbar sein —
// der Nutzer ist gerade ausgesperrt. Ihre Absicherung sind Rate-Limit,
// Enumeration-Neutralität und single-use-Tokens, nicht dieses Gate.
const PUBLIC_PATHS = ["/login", "/api/auth", "/forgot-password", "/reset", "/invite", "/verify-email", "/platform/login", "/api/platform-auth"];
// L15 Testphase: `/testen` (Wizard, Bestätigung, Nutzungsbedingungen, Datenschutz) ist öffentlich —
// abgesichert über Rate-Limit je IP/E-Mail, Honeypot, Double-Opt-in und Enumeration-Neutralität.
const PUBLIC_PATHS = ["/login", "/api/auth", "/forgot-password", "/reset", "/invite", "/verify-email", "/platform/login", "/api/platform-auth", "/testen"];
// Plattform-Bereich (getrennte Session/Login): diese Routen werden über das
// Plattform-Cookie gegatet und leiten anonyme Besucher auf /platform/login —
+8 -1
View File
@@ -4,6 +4,7 @@ import { ForbiddenError, type Permission } from "@/server/rbac";
import { assertModuleEnabled } from "@/server/modules";
import { writeAuditLog } from "@/server/audit";
import { isTokenStillValid } from "@/server/sessions";
import { assertTenantWritable } from "@/server/services/trial/state";
/**
* Einheitlicher Einstieg für mutierende Server-Actions eines gegateten Moduls (§3.4).
@@ -26,7 +27,10 @@ import { isTokenStillValid } from "@/server/sessions";
* Der Vollständigkeitscheck (`scripts/check-module-guards.ts`) verlässt sich darauf,
* dass jede Action eines gegateten Moduls über einen so erzeugten `guard(...)` läuft.
*/
export function moduleGuard(moduleKey: string) {
export function moduleGuard(moduleKey: string, opts: { read?: boolean } = {}) {
// L15 Testphase: `{ read: true }` nur für Lesepfade (Seitenkontexte, GET-Downloads), die denselben
// DB-autoritativen Guard nutzen — dort greift die Schreibsperre abgelaufener Testmandanten nicht.
// scripts/check-module-guards.ts verbietet den Lese-Modus in Action-Dateien.
return async function guard(...permissions: Permission[]) {
const session = await requireSession();
const db = dbForTenant(session.user.tenantId);
@@ -83,6 +87,9 @@ export function moduleGuard(moduleKey: string) {
}
await assertModuleEnabled(session, moduleKey);
// L15 Testphase: abgelaufene Testmandanten sind nur lesbar — zentrale Schreibsperre
// (wirft ServiceError "blocked"/"trial_expired").
if (!opts.read) await assertTenantWritable(session.user.tenantId);
// `permissions` = DB-authoritative effective set; domain services derive their
// scope decisions from it (src/server/services/context.ts#ctxFromGuard).
return { session, db, permissions: effective as ReadonlySet<string> };
+2
View File
@@ -6,6 +6,7 @@ import { requireApiContext } from "@/server/api/context";
import { requireSession } from "@/server/auth";
import { requirePermission } from "@/server/rbac";
import { updateLotseSettings } from "@/server/services/lotse/settings";
import { assertTenantWritable } from "@/server/services/trial/state";
/**
* /settings/lotse: switch the Lotse module on/off and set the address form (lane L9).
@@ -19,6 +20,7 @@ export async function saveLotseSettings(fd: FormData): Promise<void> {
try {
requirePermission(session, "tenant:manage"); // fast JWT check; requireApiContext re-checks against the DB
const ctx = await requireApiContext(null, "tenant:manage");
await assertTenantWritable(ctx.tenantId); // L15: expired trial → settings read-only
// L10b: empty = platform default (null); invalid numbers are rejected by the service schema
const rawLimit = String(fd.get("monthlyTokenLimit") ?? "").trim();
await updateLotseSettings(ctx, {
+2
View File
@@ -6,6 +6,7 @@ import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { requirePermission } from "@/server/rbac";
import { writeAuditLog } from "@/server/audit";
import { assertTenantWritable } from "@/server/services/trial/state";
const str = (v: FormDataEntryValue | null) => (v ? String(v).trim() : "");
@@ -14,6 +15,7 @@ export async function updateTenantSettings(formData: FormData) {
const session = await requireSession();
requirePermission(session, "tenant:manage");
const tenantId = session.user.tenantId;
await assertTenantWritable(tenantId); // L15: expired trial → read-only
const db = dbForTenant(tenantId);
const orgName = z.string().trim().min(1).parse(formData.get("orgName"));
+3
View File
@@ -11,6 +11,7 @@ import { resolvePasswordPolicy } from "@/lib/password-policy";
import { issueToken } from "@/server/auth-token";
import { sendUserInvitationMail } from "@/server/auth-selfservice";
import { writeAuditLog } from "@/server/audit";
import { assertTenantWritable, isTrialExpiredError, TRIAL_READ_ONLY_MESSAGE } from "@/server/services/trial/state";
/**
* Benutzer- & Rollenverwaltung durch den Mandanten-Admin (Paket B). EXEMPT vom
@@ -38,6 +39,7 @@ export type EditUserState =
async function ctx(permission: "user:manage" | "role:manage") {
const session = await requireSession();
requirePermission(session, permission);
await assertTenantWritable(session.user.tenantId); // L15: expired trial → user administration read-only
return { session, tenantId: session.user.tenantId, db: dbForTenant(session.user.tenantId) };
}
@@ -48,6 +50,7 @@ async function ctx(permission: "user:manage" | "role:manage") {
* ("Aktion konnte nicht abgeschlossen werden" + Formularverlust).
*/
function actionError(err: unknown): string {
if (isTrialExpiredError(err)) return TRIAL_READ_ONLY_MESSAGE;
const msg = err instanceof Error ? err.message : String(err);
return msg || "Die Aktion konnte nicht ausgeführt werden.";
}
+73
View File
@@ -0,0 +1,73 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePlatformFullAdmin } from "@/server/platform-auth";
import { ServiceError } from "@/server/services/context";
import {
cancelTrialDeletion,
changeTrialEndDate,
convertTenantToFull,
createPlatformTrialTenant,
endTrialNow,
scheduleTrialDeletion,
} from "@/server/services/trial/admin";
/**
* L15 Testphase — platform admin: wizard "Testmandant anlegen" and lifecycle actions.
* EXEMPT from module gating; authorisation via the separate platform session (full admins only).
* The services re-check the actor against the PlatformAdmin store and write the audit.
*/
export type PlatformTrialState = { status: "idle" } | { status: "error"; code: string };
function errorCode(err: unknown): string {
if (err instanceof ServiceError) return err.message;
if (err && typeof err === "object" && "issues" in err) return "invalid_input";
return "failed";
}
export async function createTrialTenantAction(_prev: PlatformTrialState, fd: FormData): Promise<PlatformTrialState> {
const { admin } = await requirePlatformFullAdmin();
let target: string;
try {
const result = await createPlatformTrialTenant(
{ platformAdminId: admin.id },
{
companyName: String(fd.get("companyName") ?? ""),
sector: String(fd.get("sector") ?? ""),
adminName: String(fd.get("adminName") ?? ""),
adminEmail: String(fd.get("adminEmail") ?? ""),
endDate: String(fd.get("endDate") ?? ""),
sampleData: fd.get("sampleData") === "on",
},
);
target = `/admin/${result.tenantId}?trial=created${result.invited ? "&invited=1" : ""}`;
} catch (err) {
return { status: "error", code: errorCode(err) };
}
revalidatePath("/admin");
redirect(target);
}
const OPS = ["extend", "convert", "end", "schedule", "cancel"] as const;
/** Lifecycle actions; every destructive/relevant change requires the confirmation checkbox. */
export async function trialLifecycleAction(tenantId: string, op: string, _prev: PlatformTrialState, fd: FormData): Promise<PlatformTrialState> {
const { admin } = await requirePlatformFullAdmin();
const actor = { platformAdminId: admin.id };
if (!(OPS as readonly string[]).includes(op)) return { status: "error", code: "invalid_input" };
if (fd.get("confirm") !== "on") return { status: "error", code: "confirm_required" };
try {
if (op === "extend") await changeTrialEndDate(actor, tenantId, String(fd.get("endDate") ?? ""));
else if (op === "convert") await convertTenantToFull(actor, tenantId);
else if (op === "end") await endTrialNow(actor, tenantId);
else if (op === "schedule") await scheduleTrialDeletion(actor, tenantId);
else await cancelTrialDeletion(actor, tenantId);
} catch (err) {
return { status: "error", code: errorCode(err) };
}
revalidatePath("/admin");
revalidatePath(`/admin/${tenantId}`);
redirect(`/admin/${tenantId}?trialDone=${op}`);
}
+63
View File
@@ -0,0 +1,63 @@
"use server";
import { redirect } from "next/navigation";
import { AuthError } from "next-auth";
import { getLocale } from "next-intl/server";
import { signIn } from "@/server/auth";
import { clientIp } from "@/server/auth-selfservice";
import { signLoginTicket } from "@/server/login-ticket";
import { enforceTrialRateLimit } from "@/server/services/trial/abuse";
import { checkTrialStep, confirmTrialSignup, submitTrialSignup } from "@/server/services/trial/signup";
import type { FieldErrors } from "@/lib/trial/signup";
/**
* L15 Testphase — public self-service signup (no session). Registered as PUBLIC in
* scripts/check-module-guards.ts: every action checks the rate limit (per IP, per e-mail).
* Business logic in src/server/services/trial/signup.ts.
*/
export type TrialSubmitState = { status: "sent" } | { status: "invalid"; errors: FieldErrors };
export type TrialConfirmState = { status: "idle" } | { status: "invalid" } | { status: "expired" } | { status: "rate_limited" };
async function localeOrDefault(): Promise<string> {
try {
return await getLocale();
} catch {
return "de";
}
}
/** Server-side validation of one wizard step (Weiter). Writes nothing. */
export async function checkTrialStepAction(step: string, values: unknown): Promise<FieldErrors> {
const limit = enforceTrialRateLimit("trialStepCheck", { ip: await clientIp() });
if (!limit.allowed) return { _form: "rate_limited" };
return checkTrialStep(step, values);
}
/** Final submit: validates everything again, creates the pending signup and sends the confirmation mail. */
export async function submitTrialSignupAction(values: unknown): Promise<TrialSubmitState> {
const ip = await clientIp();
const email = values && typeof values === "object" && typeof (values as { email?: unknown }).email === "string" ? (values as { email: string }).email : null;
const limit = enforceTrialRateLimit("trialSignup", { ip, email });
if (!limit.allowed) return { status: "invalid", errors: { _form: "rate_limited" } };
return submitTrialSignup(values, { ip, locale: await localeOrDefault() });
}
/** Confirmation (POST from /testen/bestaetigen — mail scanners opening the GET link consume nothing). */
export async function confirmTrialSignupAction(_prev: TrialConfirmState, fd: FormData): Promise<TrialConfirmState> {
const limit = enforceTrialRateLimit("trialConfirm", { ip: await clientIp() });
if (!limit.allowed) return { status: "rate_limited" };
const token = String(fd.get("token") ?? "");
const result = await confirmTrialSignup(token);
if (result.status !== "ok") return { status: result.status };
// Directly signed in via the existing login finalisation (login-ticket provider). If that is not
// possible (e.g. MFA policy), the regular login page takes over.
try {
await signIn("login-ticket", { ticket: signLoginTicket(result.identityId, result.tenantSlug), redirectTo: "/dashboard?welcome=1" });
} catch (err) {
if (err instanceof AuthError) redirect("/login?trial=ready");
throw err; // NEXT_REDIRECT of a successful sign-in
}
return { status: "idle" };
}
+47
View File
@@ -0,0 +1,47 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requireApiContext } from "@/server/api/context";
import { requireSession } from "@/server/auth";
import { requirePermission } from "@/server/rbac";
import { ServiceError } from "@/server/services/context";
import { requestTenantExport } from "@/server/services/trial/export";
import { setOnboardingHidden, setOnboardingItem } from "@/server/services/trial/onboarding";
/**
* L15 Testphase — tenant side: data export (allowed in the read-only state, therefore NOT behind
* moduleGuard) and the "Erste Schritte" checklist (writes → trial write lock in the service).
* Auth: requireSession + requirePermission (JWT, fast) + requireApiContext (DB-authoritative).
* Registered as EXEMPT in scripts/check-module-guards.ts.
*/
async function adminCtx() {
const session = await requireSession();
requirePermission(session, "tenant:manage");
return requireApiContext(null, "tenant:manage");
}
export async function requestExportAction(): Promise<void> {
let target = "/settings/export?requested=1";
try {
await requestTenantExport(await adminCtx());
} catch (err) {
console.error("[actions/trial-tenant] export:", (err as Error).message);
target = `/settings/export?error=${err instanceof ServiceError ? encodeURIComponent(err.message) : "failed"}`;
}
revalidatePath("/settings/export");
redirect(target);
}
export async function toggleOnboardingItemAction(fd: FormData): Promise<void> {
const ctx = await adminCtx();
await setOnboardingItem(ctx, String(fd.get("key") ?? ""), fd.get("done") === "1");
revalidatePath("/dashboard");
}
export async function hideOnboardingAction(): Promise<void> {
const ctx = await adminCtx();
await setOnboardingHidden(ctx, true);
revalidatePath("/dashboard");
}
+12 -1
View File
@@ -6,7 +6,8 @@ import { assertModuleEnabled, requireModule } from "@/server/modules";
import type { Permission } from "@/server/rbac";
import type { ModuleKey } from "@/lib/modules";
import type { ServiceCtx } from "@/server/services/context";
import { ApiError } from "@/server/api/respond";
import { ApiError, isMutatingApiRequest } from "@/server/api/respond";
import { assertTenantWritable } from "@/server/services/trial/state";
import { consumeRateLimit } from "@/server/rate-limit";
// assertSameOrigin lives in respond.ts (withApi applies it to every mutation); re-exported for
@@ -63,10 +64,20 @@ export async function requireApiContext(moduleKey: ModuleKey | null, ...permissi
}
if (moduleKey) await assertModuleEnabled(session, moduleKey); // throws ModuleDisabledError → 403
if (moduleKey) enforceApiRateLimit(session.user.id, moduleKey);
await assertApiWriteAllowed(tenantId);
return { db, tenantId, userId: session.user.id, permissions: effective };
}
/**
* L15 Testphase: non-GET /api/v1 requests (withApi) of an expired trial tenant → `blocked
* trial_expired` (422). GET requests (reads, PDFs, downloads, export) are never blocked. Route
* handlers outside `withApi` that write (backoffice upload) call `assertTenantWritable` themselves.
*/
export async function assertApiWriteAllowed(tenantId: string): Promise<void> {
if (isMutatingApiRequest()) await assertTenantWritable(tenantId);
}
/**
* Per-user request budget for /api/v1 (in-memory, per app instance — see rate-limit.ts).
* Field endpoints (sync outbox, uploads, offline pre-download, document cache) get the generous
+20 -2
View File
@@ -1,3 +1,4 @@
import { AsyncLocalStorage } from "node:async_hooks";
import { ZodError } from "zod";
import { ServiceError } from "@/server/services/context";
import { ForbiddenError } from "@/server/rbac";
@@ -96,6 +97,22 @@ export function parsePagination(url: URL | string, defaults = { pageSize: 25 }):
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
/**
* L15 Testphase: HTTP method of the /api/v1 request currently handled by `withApi` (async context).
* `requireApiContext` uses it to apply the trial write lock to every non-safe request centrally;
* outside `withApi` (server actions, /files downloads) the method is unknown → `null`.
*/
const apiRequest = new AsyncLocalStorage<{ method: string }>();
export function currentApiMethod(): string | null {
return apiRequest.getStore()?.method ?? null;
}
export function isMutatingApiRequest(): boolean {
const method = currentApiMethod();
return method !== null && !SAFE_METHODS.has(method);
}
/**
* CSRF defense for cookie-authenticated, state-changing route handlers: reject requests whose
* Origin (or Sec-Fetch-Site) shows a foreign site. Server actions have this built in.
@@ -125,8 +142,9 @@ export function assertSameOrigin(req: Request): void {
export function withApi<A extends [Request, ...unknown[]]>(handler: (...args: A) => Promise<Response>) {
return async (...args: A): Promise<Response> => {
try {
if (!SAFE_METHODS.has(args[0].method.toUpperCase())) assertSameOrigin(args[0]);
return await handler(...args);
const method = args[0].method.toUpperCase();
if (!SAFE_METHODS.has(method)) assertSameOrigin(args[0]);
return await apiRequest.run({ method }, () => handler(...args));
} catch (err) {
return toErrorResponse(err);
}
+2
View File
@@ -79,6 +79,8 @@ export const TENANT_MODELS: readonly string[] = [
"LotseConversation",
"LotseMessage",
"LotseActionProposal",
// L15 Testphase: Datenexport des Mandanten
"TenantExport",
];
/**
+2
View File
@@ -130,6 +130,8 @@ const TENANT_MODELS = new Set<string>([
"LotseConversation",
"LotseMessage",
"LotseActionProposal",
// L15 Testphase: Datenexport des Mandanten
"TenantExport",
// WebAuthnCredential/Identity sind identitäts-global (kein tenant_id) → NICHT hier.
// Craftvia-Fachmodelle hier ergänzen — UND in src/server/backup/topology.ts
// (TENANT_MODELS) sowie per `SELECT enable_tenant_rls('<table>')` in der Migration
+2
View File
@@ -59,6 +59,8 @@ export const PII_REFERENCE_FIELDS: readonly PiiReference[] = [
{ model: "LotseConversation", field: "userId" },
{ model: "LotseActionProposal", field: "userId" },
{ model: "LotseActionProposal", field: "confirmedById" },
// L15 Testphase: Datenexport
{ model: "TenantExport", field: "requestedById" },
// Free-text person data of END CUSTOMERS (Customer/Contact/Site/Signature.signerName) is
// tenant business data under data processing — not part of the employee subject export.
];
+2
View File
@@ -16,6 +16,8 @@ export const PROCESSORS: Partial<Record<JobQueueName, () => Promise<JobProcessor
"geocode-site": () => import("./geocode-site").then((m) => m.process), // L13: geocoding of sites (OSM Nominatim)
"planning-watch": () => import("./planning-watch").then((m) => m.process), // L13: delay/overrun/freed-capacity alerts (every 5 min)
"billing-pdf": () => import(/* turbopackIgnore: true */ "./billing-pdf").then((m) => m.process), // L14: worker-only billing sheet PDF (react-dom/server + Chromium)
"trial-lifecycle": () => import("./trial-lifecycle").then((m) => m.process), // L15: daily trial reminders/expiry/deletion
"tenant-export": () => import("./tenant-export").then((m) => m.process), // L15: tenant data export ZIP
};
/** Inline fallback when no Redis is available (dev/demo). */
@@ -0,0 +1,7 @@
import type { JobPayload } from "../queues";
import { buildTenantExport } from "@/server/services/trial/export";
/** L15 Testphase: builds the data export ZIP of one tenant (entityId = TenantExport.id). */
export async function process(payload: JobPayload): Promise<void> {
await buildTenantExport(payload.tenantId, payload.entityId);
}
@@ -0,0 +1,11 @@
import type { JobPayload } from "../queues";
import { runTrialLifecycle } from "@/server/services/trial/lifecycle";
/** L15 Testphase: daily lifecycle run over all trial tenants (payload tenantId "*"). */
export async function process(_payload: JobPayload): Promise<void> {
void _payload;
const s = await runTrialLifecycle();
console.info(
`[trial-lifecycle] checked ${s.checked}, reminders ${s.reminders}, expired ${s.expiredNotices}, deletion notices ${s.deletionNotices}, deleted ${s.deleted.length}, purged signups ${s.purgedSignups}`,
);
}
+13
View File
@@ -16,6 +16,8 @@ export const JOB_QUEUES = {
geocodeSite: "geocode-site", // L13 Planung: site address → coordinates (OSM Nominatim, 1 req/s)
planningWatch: "planning-watch", // L13 Planung: delay/overrun/freed-capacity alerts every 5 min
billingPdf: "billing-pdf", // L14 Abrechnungsblatt
trialLifecycle: "trial-lifecycle", // L15 Testphase: daily reminders/expiry/deletion
tenantExport: "tenant-export", // L15 Testphase: tenant data export (ZIP)
} as const;
export type JobQueueName = (typeof JOB_QUEUES)[keyof typeof JOB_QUEUES];
@@ -111,6 +113,17 @@ export async function scheduleRecurringJobs(connection: Redis): Promise<void> {
} finally {
await watch.close();
}
// L15 Testphase: trial lifecycle once a day (all trial tenants; idempotent per tenant/notice)
const trial = new Queue<JobPayload>(JOB_QUEUES.trialLifecycle, { connection });
try {
await trial.upsertJobScheduler(
"trial-lifecycle-daily",
{ every: 24 * 60 * 60 * 1000 },
{ name: JOB_QUEUES.trialLifecycle, data: { tenantId: "*", entityId: "lifecycle" }, opts: { removeOnComplete: { count: 30 }, removeOnFail: { count: 30 } } },
);
} finally {
await trial.close();
}
}
export async function closeJobQueues(): Promise<void> {
+135
View File
@@ -66,6 +66,12 @@ export type TemplateVars = {
craftvia_report_customer: {
customerName: string; tenantName: string; reportTitle: string; reportDate: string; message?: string;
};
// ---- L15 Testphase (Transaktions-/Pflichtmails, nicht abbestellbar)
trial_confirm: { name: string; companyName: string; trialEnd: string; actionUrl: string; expires: string };
trial_existing_account: { name: string; loginUrl: string; resetUrl: string };
trial_reminder: { name: string; tenantName: string; daysLeft: number; endDate: string; actionUrl: string; contact?: string };
trial_expired: { name: string; tenantName: string; endDate: string; deletionDate?: string; exportUrl: string; contact?: string };
trial_deletion_notice: { name: string; tenantName: string; deletionDate: string; exportUrl: string; contact?: string };
};
/** Why the recipient gets a Craftvia notification — controls the footer line. */
@@ -97,6 +103,9 @@ export const CRAFTVIA_TEMPLATE_KEYS = [
/** Customer-facing Craftvia mails (lane L11) — separate list, recipients are external customers. */
export const CUSTOMER_TEMPLATE_KEYS = ["craftvia_report_customer"] as const satisfies readonly TemplateKey[];
/** L15 Testphase: Anmeldung (Double-Opt-in), Hinweis bei bestehendem Konto, Erinnerungen, Ablauf, Löschung. */
export const TRIAL_TEMPLATE_KEYS = ["trial_confirm", "trial_existing_account", "trial_reminder", "trial_expired", "trial_deletion_notice"] as const satisfies readonly TemplateKey[];
/** Abmelde-/Präferenzhinweis — nur für Benachrichtigungen, nie für Transaktionsmails. */
const FOOTER_NOTE: Record<Locale, string> = {
de: "Sie erhalten diese Benachrichtigung aufgrund Ihrer Rolle in Ihrem Betrieb. Die Einstellungen dazu finden Sie in Ihrem Profil.",
@@ -307,6 +316,130 @@ const customerEn: { [K in CustomerKey]: Builder<K> } = {
}),
};
// ---- L15 Testphase ----
type TrialKey = (typeof TRIAL_TEMPLATE_KEYS)[number];
const contactDe = (c?: string) => (c ? `Fragen oder Wunsch nach der Vollversion? Schreiben Sie uns: ${c}` : "Fragen oder Wunsch nach der Vollversion? Antworten Sie einfach auf diese E-Mail.");
const contactEn = (c?: string) => (c ? `Questions or ready for the full version? Write to us: ${c}` : "Questions or ready for the full version? Simply reply to this e-mail.");
const daysDe = (n: number) => (n <= 0 ? "heute" : n === 1 ? "morgen" : `in ${n} Tagen`);
const daysEn = (n: number) => (n <= 0 ? "today" : n === 1 ? "tomorrow" : `in ${n} days`);
const trialDe: { [K in TrialKey]: Builder<K> } = {
trial_confirm: (v) => ({
subject: `${BRAND.name}: Testphase bestätigen`,
heading: "Testphase bestätigen",
paragraphs: [
greetDe(v.name),
`Sie möchten ${BRAND.name} für „${oneLine(v.companyName)}" testen. Ihre Testphase läuft bis ${v.trialEnd}.`,
"Bestätigen Sie Ihre E-Mail-Adresse – danach richten wir Ihren Zugang sofort ein.",
"Haben Sie das nicht angefordert, ignorieren Sie diese E-Mail. Es wird dann nichts angelegt.",
],
action: { label: "Testphase starten", url: v.actionUrl },
note: `Der Link ist bis ${v.expires} gültig und kann nur einmal verwendet werden.`,
}),
trial_existing_account: (v) => ({
subject: `${BRAND.name}: Sie haben bereits einen Zugang`,
heading: "Sie haben bereits einen Zugang",
paragraphs: [
greetDe(v.name),
`für diese E-Mail-Adresse besteht bereits ein Zugang zu ${BRAND.name}. Eine weitere Testphase mit derselben Adresse ist nicht möglich.`,
"Melden Sie sich mit Ihrem bestehenden Zugang an. Passwort vergessen? Dann setzen Sie es über den zweiten Link zurück.",
"Haben Sie das nicht angefordert, ignorieren Sie diese E-Mail.",
],
action: { label: "Zur Anmeldung", url: v.loginUrl },
note: `Passwort zurücksetzen: ${v.resetUrl}`,
}),
trial_reminder: (v) => ({
subject: `${BRAND.name}: Testphase endet ${daysDe(v.daysLeft)}`,
heading: `Ihre Testphase endet ${daysDe(v.daysLeft)}`,
paragraphs: [
greetDe(v.name),
`die Testphase von „${oneLine(v.tenantName)}" läuft bis ${v.endDate}. Danach sind Ihre Daten nur noch lesbar und exportierbar.`,
contactDe(v.contact),
],
action: { label: `${BRAND.name} öffnen`, url: v.actionUrl },
}),
trial_expired: (v) => ({
subject: `${BRAND.name}: Testphase abgelaufen`,
heading: "Testphase abgelaufen – nur Lesezugriff",
paragraphs: [
greetDe(v.name),
`die Testphase von „${oneLine(v.tenantName)}" ist am ${v.endDate} abgelaufen. Sie können sich weiter anmelden, Daten ansehen und exportieren – Änderungen sind nicht mehr möglich.`,
...(v.deletionDate ? [`Ohne Umwandlung in die Vollversion werden die Daten am ${v.deletionDate} gelöscht.`] : []),
contactDe(v.contact),
],
action: { label: "Daten exportieren", url: v.exportUrl },
}),
trial_deletion_notice: (v) => ({
subject: `${BRAND.name}: Daten werden am ${v.deletionDate} gelöscht`,
heading: "Löschung Ihrer Testdaten",
paragraphs: [
greetDe(v.name),
`die Daten von „${oneLine(v.tenantName)}" werden am ${v.deletionDate} endgültig gelöscht. Exportieren Sie vorher alles, was Sie behalten möchten.`,
contactDe(v.contact),
],
action: { label: "Daten exportieren", url: v.exportUrl },
}),
};
const trialEn: { [K in TrialKey]: Builder<K> } = {
trial_confirm: (v) => ({
subject: `${BRAND.name}: confirm your trial`,
heading: "Confirm your trial",
paragraphs: [
greetEn(v.name),
`you would like to try ${BRAND.name} for "${oneLine(v.companyName)}". Your trial runs until ${v.trialEnd}.`,
"Confirm your e-mail address – we set up your account right away.",
"If you did not request this, ignore this e-mail. Nothing will be created.",
],
action: { label: "Start trial", url: v.actionUrl },
note: `The link is valid until ${v.expires} and can only be used once.`,
}),
trial_existing_account: (v) => ({
subject: `${BRAND.name}: you already have an account`,
heading: "You already have an account",
paragraphs: [
greetEn(v.name),
`there already is a ${BRAND.name} account for this e-mail address. Another trial with the same address is not possible.`,
"Sign in with your existing account. Forgot your password? Reset it with the second link.",
"If you did not request this, ignore this e-mail.",
],
action: { label: "Sign in", url: v.loginUrl },
note: `Reset password: ${v.resetUrl}`,
}),
trial_reminder: (v) => ({
subject: `${BRAND.name}: trial ends ${daysEn(v.daysLeft)}`,
heading: `Your trial ends ${daysEn(v.daysLeft)}`,
paragraphs: [
greetEn(v.name),
`the trial of "${oneLine(v.tenantName)}" runs until ${v.endDate}. Afterwards your data can only be viewed and exported.`,
contactEn(v.contact),
],
action: { label: `Open ${BRAND.name}`, url: v.actionUrl },
}),
trial_expired: (v) => ({
subject: `${BRAND.name}: trial expired`,
heading: "Trial expired – read-only access",
paragraphs: [
greetEn(v.name),
`the trial of "${oneLine(v.tenantName)}" expired on ${v.endDate}. You can still sign in, view and export your data – changes are no longer possible.`,
...(v.deletionDate ? [`Unless converted to the full version, the data will be deleted on ${v.deletionDate}.`] : []),
contactEn(v.contact),
],
action: { label: "Export data", url: v.exportUrl },
}),
trial_deletion_notice: (v) => ({
subject: `${BRAND.name}: data will be deleted on ${v.deletionDate}`,
heading: "Deletion of your trial data",
paragraphs: [
greetEn(v.name),
`the data of "${oneLine(v.tenantName)}" will be deleted permanently on ${v.deletionDate}. Export everything you want to keep beforehand.`,
contactEn(v.contact),
],
action: { label: "Export data", url: v.exportUrl },
}),
};
const de: { [K in TemplateKey]: Builder<K> } = {
invitation: (v) => ({
subject: `Ihr Zugang zu ${BRAND.name}`,
@@ -385,6 +518,7 @@ const de: { [K in TemplateKey]: Builder<K> } = {
}),
...craftviaDe,
...customerDe,
...trialDe,
};
const en: { [K in TemplateKey]: Builder<K> } = {
@@ -465,6 +599,7 @@ const en: { [K in TemplateKey]: Builder<K> } = {
}),
...craftviaEn,
...customerEn,
...trialEn,
};
const CATALOG: Record<Locale, { [K in TemplateKey]: Builder<K> }> = { de, en };
+22 -10
View File
@@ -8,8 +8,15 @@ export interface ProvisionOpts {
slug: string;
short?: string;
sector?: string;
admin: { email: string; name: string; password: string };
/**
* Erster Administrator. `password` (Klartext, wird gehasht) ODER `passwordHash` (bereits
* Argon2id + Pepper, z. B. aus der bestätigten Testphasen-Anmeldung — L15).
* `mustChangePassword` gilt nur für eine NEU angelegte Identity (Einladungsweg).
*/
admin: { email: string; name: string; password?: string; passwordHash?: string; mustChangePassword?: boolean };
actorId?: string | null;
/** L15: nur diese Module aktivieren (neue Mandanten); ohne Angabe alle. */
modules?: readonly string[];
}
/**
@@ -52,12 +59,17 @@ export async function provisionTenant(prisma: PrismaClient, opts: ProvisionOpts)
// 4. Erster Mandantenadministrator: globale Identity (Anmeldung) + Mitgliedschaft.
// Idempotent: eine bestehende Identity/ihr Passwort wird NICHT überschrieben.
const passwordHash = await hashPassword(opts.admin.password);
const identity = await prisma.identity.upsert({
where: { email: opts.admin.email },
update: {},
create: { email: opts.admin.email, passwordHash },
});
const existingIdentity = await prisma.identity.findUnique({ where: { email: opts.admin.email }, select: { id: true } });
const identity =
existingIdentity ??
(await prisma.identity.create({
data: {
email: opts.admin.email,
passwordHash: opts.admin.passwordHash ?? (await hashPassword(opts.admin.password ?? "")),
mustChangePassword: opts.admin.mustChangePassword ?? false,
},
select: { id: true },
}));
const admin = await prisma.user.upsert({
where: { tenantId_email: { tenantId: tenant.id, email: opts.admin.email } },
update: { name: opts.admin.name, identityId: identity.id },
@@ -84,12 +96,12 @@ export async function provisionTenant(prisma: PrismaClient, opts: ProvisionOpts)
},
});
// 6. Alle Module aktivieren
// 6. Module aktivieren (L15: optional nur die gewählten; bestehende Zeilen bleiben unverändert)
for (const m of MODULES) {
await prisma.tenantModule.upsert({
where: { tenantId_moduleKey: { tenantId: tenant.id, moduleKey: m.key } },
update: {},
create: { tenantId: tenant.id, moduleKey: m.key, enabled: true },
create: { tenantId: tenant.id, moduleKey: m.key, enabled: opts.modules ? opts.modules.includes(m.key) : true },
});
}
@@ -97,5 +109,5 @@ export async function provisionTenant(prisma: PrismaClient, opts: ProvisionOpts)
data: { tenantId: tenant.id, scope: "platform", actorId: opts.actorId ?? null, action: "provision", entity: "tenant", entityId: tenant.id, after: { name: opts.name } },
});
return tenant;
return Object.assign(tenant, { adminUserId: admin.id, identityId: identity.id, identityCreated: !existingIdentity });
}
+6
View File
@@ -62,6 +62,12 @@ export const RATE_LIMITS = {
api: { limit: perMinute("API_RATE_LIMIT_PER_MINUTE", 300), windowMs: 60_000 },
/** L10b: Einsatz-/Sync-Endpunkte (/sync, /uploads, /field/**) je Nutzer — großzügig (Outbox, Vorab-Download). */
apiField: { limit: perMinute("API_FIELD_RATE_LIMIT_PER_MINUTE", 1200), windowMs: 60_000 },
/** L15: öffentliche Testphasen-Anmeldung — je IP und je E-Mail-Adresse. */
trialSignup: { limit: 5, windowMs: 60 * 60_000 },
/** L15: Einlösen des Bestätigungslinks je IP. */
trialConfirm: { limit: 20, windowMs: 15 * 60_000 },
/** L15: serverseitige Schrittprüfung des Wizards (schreibt nichts) je IP. */
trialStepCheck: { limit: 120, windowMs: 15 * 60_000 },
} as const satisfies Record<string, RateLimitRule>;
export type RateLimitScope = keyof typeof RATE_LIMITS;
+1 -1
View File
@@ -7,7 +7,7 @@ import { can, ctxFromGuard, type ServiceCtx } from "@/server/services/context";
* the visibility scopes of the services.
*/
export async function fieldPageContext(): Promise<ServiceCtx> {
return ctxFromGuard(await moduleGuard("field")());
return ctxFromGuard(await moduleGuard("field", { read: true })()); // L15: read path — no trial write lock
}
export function canUseFieldApp(ctx: ServiceCtx): boolean {
+13
View File
@@ -0,0 +1,13 @@
import { checkRateLimit, type RateLimitResult } from "@/server/rate-limit";
/**
* L15 Testphase: abuse protection of the public endpoints (no external CAPTCHA).
* Counts per IP AND per e-mail address (both counters are always increased, see rate-limit.ts).
* The key material is hashed inside rate-limit.ts — no clear-text IPs/addresses in memory.
*/
export function enforceTrialRateLimit(
scope: "trialSignup" | "trialConfirm" | "trialStepCheck",
parts: { ip?: string | null; email?: string | null },
): RateLimitResult {
return checkRateLimit(scope, { ip: parts.ip ?? null, account: parts.email ? parts.email.trim().toLowerCase() : null });
}
+174
View File
@@ -0,0 +1,174 @@
import { z } from "zod";
import { prisma } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
import { issueToken } from "@/server/auth-token";
import { sendUserInvitationMail } from "@/server/auth-selfservice";
import { ServiceError } from "@/server/services/context";
import { addDaysToKey, isDateKey, todayKey, trialEndInstant } from "@/lib/trial/dates";
import { daysMs, PLATFORM_TRIAL_MAX_DAYS, TRIAL_DELETION_GRACE_DAYS, TRIAL_DELETION_NOTICE_DAYS } from "./config";
import { provisionTrialTenant, type ProvisionTrialResult } from "./provision";
/**
* L15 Testphase: platform-admin operations on trial tenants. ONLY for active platform full admins —
* every function re-checks the actor against the PlatformAdmin store (tenant administrators have no
* PlatformAdmin row and are rejected with `forbidden`). The adapter additionally requires the
* platform session (src/server/actions/trial-platform.ts). Every change → audit (scope platform,
* attached to the tenant so it shows in the tenant's audit trail) with before/after.
*/
export type PlatformActor = { platformAdminId: string };
async function assertPlatformFullAdmin(actor: PlatformActor): Promise<void> {
const admin = actor.platformAdminId
? await prisma.platformAdmin.findUnique({ where: { id: actor.platformAdminId }, select: { status: true, role: true } })
: null;
if (!admin || admin.status !== "ACTIVE" || admin.role !== "full") throw new ServiceError("forbidden", "platform_admin_required");
}
const LIFECYCLE_SELECT = {
id: true,
name: true,
status: true,
plan: true,
trialEndsAt: true,
convertedAt: true,
readOnlySince: true,
deletionDueAt: true,
trialDeletedAt: true,
} as const;
async function loadTrialTenant(tenantId: string) {
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: LIFECYCLE_SELECT });
if (!tenant) throw new ServiceError("not_found", "tenant not found");
if (tenant.trialDeletedAt || tenant.status === "ARCHIVED") throw new ServiceError("invalid", "tenant_deleted");
if (tenant.plan !== "TRIAL" || !tenant.trialEndsAt) throw new ServiceError("invalid", "not_a_trial");
return tenant;
}
type Snapshot = { plan: string; trialEndsAt: Date | null; readOnlySince: Date | null; deletionDueAt: Date | null; convertedAt: Date | null };
const snap = (t: Snapshot) => ({ plan: t.plan, trialEndsAt: t.trialEndsAt, readOnlySince: t.readOnlySince, deletionDueAt: t.deletionDueAt, convertedAt: t.convertedAt });
async function audit(actor: PlatformActor, tenantId: string, entity: string, before: Snapshot, after: Snapshot) {
await writeAuditLog({ tenantId, scope: "platform", actorId: actor.platformAdminId, action: "update", entity, entityId: tenantId, before: snap(before), after: snap(after) });
}
const RESET_NOTICES = { trialReminder7At: null, trialReminder3At: null, trialReminder1At: null, trialExpiredNoticeAt: null, trialDeletionNoticeAt: null } as const;
export const platformTrialSchema = z.object({
companyName: z.string().trim().min(2).max(120),
sector: z.string().trim().max(80).default(""),
adminName: z.string().trim().min(2).max(120),
adminEmail: z.string().trim().toLowerCase().max(200).regex(/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/),
endDate: z.string().refine(isDateKey, "date_invalid"),
sampleData: z.boolean().default(true),
});
function assertEndDateRange(endDateKey: string, now: Date) {
if (!isDateKey(endDateKey)) throw new ServiceError("invalid", "date_invalid", { field: "endDate" });
const today = todayKey(now);
if (endDateKey < today) throw new ServiceError("invalid", "date_in_past", { field: "endDate" });
if (endDateKey > addDaysToKey(today, PLATFORM_TRIAL_MAX_DAYS)) throw new ServiceError("invalid", "date_too_late", { field: "endDate" });
}
/** Wizard "Testmandant anlegen": provisioning + invitation of a NEW admin identity (existing people keep their access). */
export async function createPlatformTrialTenant(
actor: PlatformActor,
raw: z.input<typeof platformTrialSchema>,
opts: { now?: Date; invite?: (input: Parameters<typeof sendUserInvitationMail>[0]) => Promise<unknown> } = {},
): Promise<ProvisionTrialResult & { invited: boolean }> {
await assertPlatformFullAdmin(actor);
const now = opts.now ?? new Date();
const input = platformTrialSchema.parse(raw);
assertEndDateRange(input.endDate, now);
const result = await provisionTrialTenant({
companyName: input.companyName,
sector: input.sector,
admin: { name: input.adminName, email: input.adminEmail },
endDateKey: input.endDate,
sampleData: input.sampleData,
source: "platform",
actorId: actor.platformAdminId,
now,
});
let invited = false;
if (result.identityCreated) {
const { raw: token, expiresAt } = await issueToken({ principalType: "identity", principalId: result.identityId, tenantId: result.tenantId, type: "invitation" });
await (opts.invite ?? sendUserInvitationMail)({ to: input.adminEmail, name: input.adminName, tenantId: result.tenantId, tenantName: input.companyName, rawToken: token, expiresAt });
invited = true;
}
return { ...result, invited };
}
/** Change/extend the end date — also after expiry (the tenant becomes writable again). */
export async function changeTrialEndDate(actor: PlatformActor, tenantId: string, endDateKey: string, opts: { now?: Date } = {}) {
await assertPlatformFullAdmin(actor);
const now = opts.now ?? new Date();
assertEndDateRange(endDateKey, now);
const before = await loadTrialTenant(tenantId);
const endsAt = trialEndInstant(endDateKey);
const after = await prisma.tenant.update({
where: { id: tenantId },
data: {
trialEndsAt: endsAt,
readOnlySince: null,
// a cancelled deletion stays cancelled; otherwise the grace period follows the new end
deletionDueAt: before.deletionDueAt ? new Date(endsAt.getTime() + daysMs(TRIAL_DELETION_GRACE_DAYS)) : null,
...RESET_NOTICES,
},
select: LIFECYCLE_SELECT,
});
await audit(actor, tenantId, "trial_end_date", before, after);
return after;
}
/** Convert to the full version: never deleted, never read-only again. */
export async function convertTenantToFull(actor: PlatformActor, tenantId: string, opts: { now?: Date } = {}) {
await assertPlatformFullAdmin(actor);
const before = await loadTrialTenant(tenantId);
const after = await prisma.tenant.update({
where: { id: tenantId },
data: { plan: "FULL", convertedAt: opts.now ?? new Date(), readOnlySince: null, deletionDueAt: null, trialDeletionNoticeAt: null },
select: LIFECYCLE_SELECT,
});
await audit(actor, tenantId, "trial_converted", before, after);
return after;
}
/** End the trial immediately (read-only from now on). */
export async function endTrialNow(actor: PlatformActor, tenantId: string, opts: { now?: Date } = {}) {
await assertPlatformFullAdmin(actor);
const now = opts.now ?? new Date();
const before = await loadTrialTenant(tenantId);
const after = await prisma.tenant.update({
where: { id: tenantId },
data: {
trialEndsAt: now,
readOnlySince: now,
deletionDueAt: before.deletionDueAt ? new Date(now.getTime() + daysMs(TRIAL_DELETION_GRACE_DAYS)) : null,
trialExpiredNoticeAt: null,
trialDeletionNoticeAt: null,
},
select: LIFECYCLE_SELECT,
});
await audit(actor, tenantId, "trial_ended", before, after);
return after;
}
/** Schedule the automatic deletion (end + 30 days, at least TRIAL_DELETION_NOTICE_DAYS from now so the notice can go out). */
export async function scheduleTrialDeletion(actor: PlatformActor, tenantId: string, opts: { now?: Date } = {}) {
await assertPlatformFullAdmin(actor);
const now = opts.now ?? new Date();
const before = await loadTrialTenant(tenantId);
const due = Math.max(before.trialEndsAt!.getTime() + daysMs(TRIAL_DELETION_GRACE_DAYS), now.getTime() + daysMs(TRIAL_DELETION_NOTICE_DAYS));
const after = await prisma.tenant.update({ where: { id: tenantId }, data: { deletionDueAt: new Date(due), trialDeletionNoticeAt: null }, select: LIFECYCLE_SELECT });
await audit(actor, tenantId, "trial_deletion_scheduled", before, after);
return after;
}
export async function cancelTrialDeletion(actor: PlatformActor, tenantId: string) {
await assertPlatformFullAdmin(actor);
const before = await loadTrialTenant(tenantId);
const after = await prisma.tenant.update({ where: { id: tenantId }, data: { deletionDueAt: null, trialDeletionNoticeAt: null }, select: LIFECYCLE_SELECT });
await audit(actor, tenantId, "trial_deletion_cancelled", before, after);
return after;
}
+34
View File
@@ -0,0 +1,34 @@
/**
* L15 Testphase: operating parameters (env, read at call time so tests can override them).
* TRIAL_MAX_DAYS longest self-service trial in days (1–365, default 30)
* TRIAL_CONTACT_EMAIL contact shown in banners and trial mails (optional)
*/
export const TRIAL_DEFAULT_DAYS = 14;
/** Automatic deletion of an unconverted trial tenant after its end. */
export const TRIAL_DELETION_GRACE_DAYS = 30;
/** Deletion notice before the deletion date. */
export const TRIAL_DELETION_NOTICE_DAYS = 7;
/** Reminder mails before the end (calendar days in Berlin). */
export const TRIAL_REMINDER_DAYS = [7, 3, 1] as const;
/** Banner countdown from this many days before the end. */
export const TRIAL_BANNER_DAYS = 7;
/** Double opt-in link validity. */
export const TRIAL_SIGNUP_TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
/** Unconfirmed/finished signups are purged this long after their link expired (data minimisation). */
export const TRIAL_SIGNUP_RETENTION_DAYS = 7;
/** Platform admins may set end dates up to one year ahead. */
export const PLATFORM_TRIAL_MAX_DAYS = 365;
const DAY_MS = 24 * 60 * 60 * 1000;
export const daysMs = (days: number) => days * DAY_MS;
export function trialMaxDays(): number {
const v = Number(process.env.TRIAL_MAX_DAYS);
return Number.isInteger(v) && v >= 1 && v <= 365 ? v : 30;
}
export function trialContactEmail(): string | null {
const v = process.env.TRIAL_CONTACT_EMAIL?.trim();
return v && /^[^\s@]+@[^\s@]+$/.test(v) ? v : null;
}
+194
View File
@@ -0,0 +1,194 @@
import { prisma, dbForTenant } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
import { buildZip, type ZipEntry } from "@/server/backup/zip";
import { enqueueJob, JOB_QUEUES } from "@/server/jobs/queues";
import { storage } from "@/server/storage/adapter";
import { readStoredBytes } from "@/server/services/documents/read";
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
import { todayKey } from "@/lib/trial/dates";
import { daysMs } from "./config";
/**
* L15 Testphase: data export for tenant administrators — ZIP with CSV (semicolon, UTF-8 BOM, Excel)
* and JSON of master data, work orders, times, material, reports, plus the stored files (documents,
* report PDFs, photos). Runs as worker job `tenant-export`; the result is stored under
* `<tenantId>/uploads/…` in the object storage and downloaded via /settings/export/<id> (session +
* tenant:manage, never a public link). Allowed in the read-only state (not a business mutation).
*
* The existing backup/DSGVO export (src/server/backup/dsgvo-zip.ts) is an operator tool (platform
* portal, all tables incl. internal ones, JSON only); its ZIP writer is reused here.
*/
export const EXPORT_DOWNLOAD_DAYS = 7;
/** Upper bound for embedded files; beyond that the README lists what was left out. */
const MAX_FILE_BYTES = 500 * 1024 * 1024;
type Dispatch = (tenantId: string, exportId: string, actorId: string) => Promise<void>;
const defaultDispatch: Dispatch = async (tenantId, exportId, actorId) => {
if (!(await enqueueJob(JOB_QUEUES.tenantExport, { tenantId, entityId: exportId, actorId }))) {
await buildTenantExport(tenantId, exportId); // no Redis (dev/demo): inline
}
};
export async function requestTenantExport(ctx: ServiceCtx, deps: { dispatch?: Dispatch; now?: Date } = {}) {
assertCan(ctx, "tenant:manage");
const now = deps.now ?? new Date();
const open = await ctx.db.tenantExport.findFirst({ where: { status: { in: ["queued", "running"] }, createdAt: { gt: new Date(now.getTime() - 30 * 60_000) } }, select: { id: true } });
if (open) throw new ServiceError("conflict", "export_running");
const row = await ctx.db.tenantExport.create({ data: { tenantId: ctx.tenantId, requestedById: ctx.userId } });
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "export", entity: "tenant_export", entityId: row.id, after: { status: "queued" } });
await (deps.dispatch ?? defaultDispatch)(ctx.tenantId, row.id, ctx.userId);
return row;
}
export async function listTenantExports(ctx: ServiceCtx, opts: { now?: Date } = {}) {
assertCan(ctx, "tenant:manage");
const now = (opts.now ?? new Date()).getTime();
const rows = await ctx.db.tenantExport.findMany({
orderBy: { createdAt: "desc" },
take: 10,
select: { id: true, status: true, fileName: true, bytes: true, error: true, expiresAt: true, createdAt: true },
});
return rows.map((r) => ({ ...r, expired: r.status === "done" && !!r.expiresAt && r.expiresAt.getTime() <= now }));
}
// ---------------------------------------------------------------- serialisation
function scalar(v: unknown): string {
if (v === null || v === undefined) return "";
if (v instanceof Date) return v.toISOString();
if (typeof v === "bigint") return v.toString();
if (typeof v === "object") {
// Prisma.Decimal
if (typeof (v as { toFixed?: unknown }).toFixed === "function") return String(v);
return JSON.stringify(v, (_k, x) => (typeof x === "bigint" ? x.toString() : x));
}
return String(v);
}
function csvCell(v: unknown): string {
let s = scalar(v);
// CSV/formula injection: never let a cell start with a formula character
if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
return /[";\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
export function toCsv(rows: Record<string, unknown>[]): string {
const headers: string[] = [];
for (const row of rows) for (const key of Object.keys(row)) if (!headers.includes(key)) headers.push(key);
const lines = [headers.map(csvCell).join(";"), ...rows.map((row) => headers.map((h) => csvCell(row[h])).join(";"))];
return `${lines.join("\r\n")}\r\n`;
}
function toJson(rows: unknown): string {
return JSON.stringify(rows, (_k, v) => (typeof v === "bigint" ? v.toString() : v && typeof v === "object" && typeof (v as { toFixed?: unknown }).toFixed === "function" && !(v instanceof Date) ? String(v) : v), 2);
}
const safeName = (s: string) => s.replace(/[^\w.\-]+/g, "_").slice(0, 100) || "datei";
/** Builds the ZIP for one export row (worker). Idempotent: only a `queued` row is processed. */
export async function buildTenantExport(tenantId: string, exportId: string, opts: { now?: Date } = {}): Promise<void> {
const now = opts.now ?? new Date();
const db = dbForTenant(tenantId);
const claim = await db.tenantExport.updateMany({ where: { id: exportId, status: "queued" }, data: { status: "running" } });
if (claim.count !== 1) return;
const row = await db.tenantExport.findFirst({ where: { id: exportId }, select: { requestedById: true } });
try {
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { name: true, slug: true } });
if (!tenant) throw new Error("tenant not found");
const datasets: [string, Promise<Record<string, unknown>[]>][] = [
["kunden", db.customer.findMany({ orderBy: { createdAt: "asc" } })],
["ansprechpartner", db.contact.findMany({ orderBy: { createdAt: "asc" } })],
["objekte", db.site.findMany({ orderBy: { createdAt: "asc" } })],
["teams", db.team.findMany({ orderBy: { createdAt: "asc" } })],
["team-mitglieder", db.teamMember.findMany()],
["nutzer", db.user.findMany({ select: { id: true, name: true, email: true, status: true, createdAt: true } })],
["auftraege", db.workOrder.findMany({ orderBy: { createdAt: "asc" } })],
["checklisten", db.checklistItem.findMany()],
["material-vorgabe", db.materialPlan.findMany()],
["material", db.materialUsage.findMany({ orderBy: { createdAt: "asc" } })],
["einsaetze", db.workSession.findMany()],
["zeiten", db.timeEntry.findMany({ orderBy: { startedAt: "asc" } })],
["notizen", db.activityNote.findMany({ orderBy: { createdAt: "asc" } })],
["berichte", db.report.findMany({ orderBy: { createdAt: "asc" } })],
["unterschriften", db.signature.findMany()],
["fotos", db.photo.findMany()],
["dokumente", db.document.findMany({ where: { deletedAt: null }, orderBy: { createdAt: "asc" } })],
["meilensteine", db.workOrderMilestone.findMany()],
["abrechnung", db.billingRecord.findMany()],
];
const entries: ZipEntry[] = [];
const counts: Record<string, number> = {};
let documents: { id: string; fileName: string; storageKey: string; fileSize: number }[] = [];
for (const [name, query] of datasets) {
const rows = await query;
counts[name] = rows.length;
entries.push({ name: `csv/${name}.csv`, data: toCsv(rows) });
entries.push({ name: `json/${name}.json`, data: toJson(rows) });
if (name === "dokumente") documents = rows as unknown as typeof documents;
}
let fileBytes = 0;
let files = 0;
const skipped: string[] = [];
for (const doc of documents) {
if (fileBytes + doc.fileSize > MAX_FILE_BYTES) {
skipped.push(`${doc.id} ${doc.fileName} (Größenlimit)`);
continue;
}
const bytes = await readStoredBytes(doc.storageKey).catch(() => null);
if (!bytes) {
skipped.push(`${doc.id} ${doc.fileName} (nicht im Speicher)`);
continue;
}
entries.push({ name: `dateien/${doc.id}-${safeName(doc.fileName)}`, data: bytes });
fileBytes += bytes.length;
files++;
}
entries.unshift({
name: "LIESMICH.txt",
data:
`Craftvia – Datenexport\r\nBetrieb: ${tenant.name} (${tenant.slug})\r\nErstellt: ${now.toISOString()}\r\n\r\n` +
`csv/ Tabellen im CSV-Format (Semikolon, UTF-8) – öffnen z. B. mit Excel oder LibreOffice\r\n` +
`json/ dieselben Daten als JSON (maschinenlesbar)\r\n` +
`dateien/ gespeicherte Dateien (Dokumente, Berichts-PDFs, Fotos, Unterschriften); Dateiname beginnt mit der Dokument-ID aus dokumente.csv\r\n\r\n` +
`Datensätze: ${Object.entries(counts).map(([k, v]) => `${k} ${v}`).join(", ")}\r\n` +
`Dateien: ${files}${skipped.length ? `\r\nNicht enthalten:\r\n${skipped.join("\r\n")}` : ""}\r\n`,
});
const zip = buildZip(entries);
const fileName = `craftvia-export-${tenant.slug}-${todayKey(now)}.zip`;
const stored = await storage.put({ tenantId, filename: fileName, contentType: "application/zip", bytes: zip });
if (stored.storageKey.startsWith("stub://")) throw new Error("object storage not configured");
const summary = { counts, files, skipped: skipped.length };
await db.tenantExport.update({
where: { id: exportId },
data: { status: "done", storageKey: stored.storageKey, fileName, bytes: zip.length, summary, expiresAt: new Date(now.getTime() + daysMs(EXPORT_DOWNLOAD_DAYS)), error: null },
});
await writeAuditLog({ tenantId, actorId: row?.requestedById ?? undefined, action: "export", entity: "tenant_export", entityId: exportId, after: { status: "done", bytes: zip.length, ...summary } });
} catch (err) {
console.error(`[tenant-export] ${exportId} failed:`, (err as Error).message);
await db.tenantExport.update({ where: { id: exportId }, data: { status: "failed", error: (err as Error).message.slice(0, 300) } });
}
}
/** Download of a finished export (tenant:manage; expired links → invalid). */
export async function openTenantExport(ctx: ServiceCtx, exportId: string, opts: { now?: Date } = {}): Promise<{ bytes: Buffer; fileName: string }> {
assertCan(ctx, "tenant:manage");
const now = opts.now ?? new Date();
const row = await ctx.db.tenantExport.findFirst({ where: { id: exportId, status: "done" }, select: { id: true, storageKey: true, fileName: true, expiresAt: true } });
if (!row || !row.storageKey) throw new ServiceError("not_found", "export not found");
if (row.expiresAt && row.expiresAt.getTime() <= now.getTime()) throw new ServiceError("invalid", "export_expired");
// defence in depth: the key must belong to this tenant
if (!row.storageKey.startsWith(`${ctx.tenantId}/`)) throw new ServiceError("not_found", "export not found");
const bytes = await readStoredBytes(row.storageKey);
if (!bytes) throw new ServiceError("not_found", "export file missing");
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "export", entity: "tenant_export_download", entityId: row.id });
return { bytes, fileName: row.fileName ?? "craftvia-export.zip" };
}
+15
View File
@@ -0,0 +1,15 @@
import { getTrialState } from "./state";
/**
* L15 Testphase: background jobs that write ON BEHALF OF A USER must not run for an expired trial
* tenant (e.g. an import extraction or transcription queued shortly before the end).
* System derivations of data committed before the end (report/billing PDFs, thumbnails, geocoding)
* and platform maintenance jobs keep running; the trial jobs themselves are never blocked.
*/
export const TRIAL_BLOCKED_QUEUES: readonly string[] = ["import-extraction", "transcription"];
export async function isJobBlockedByTrial(queue: string, payload: { tenantId?: string | null }): Promise<boolean> {
if (!TRIAL_BLOCKED_QUEUES.includes(queue)) return false;
if (!payload.tenantId || payload.tenantId === "*") return false;
return (await getTrialState(payload.tenantId)).readOnly;
}
+180
View File
@@ -0,0 +1,180 @@
import { prisma } from "@/server/db";
import { writePlatformAudit } from "@/server/audit";
import { offboardTenant } from "@/server/dsgvo/deletion";
import { absoluteUrl } from "@/server/mail/config";
import { enqueueMail } from "@/server/mail/service";
import { normalizeLocale } from "@/server/mail/templates";
import { formatDateKey, formatInstantDate } from "@/lib/trial/dates";
import { daysMs, trialContactEmail, TRIAL_DELETION_NOTICE_DAYS, TRIAL_REMINDER_DAYS, TRIAL_SIGNUP_RETENTION_DAYS } from "./config";
import { computeTrialState, TRIAL_SELECT } from "./state";
import { tenantAdminRecipients } from "./mail";
import { purgeTenantObjects } from "./storage-purge";
/**
* L15 Testphase: daily lifecycle job (BullMQ job scheduler `trial-lifecycle-daily`, idempotent).
* - reminders 7/3/1 days before the end (each at most once; a missed run sends only the closest)
* - expiry mail on the end day (+ readOnlySince)
* - deletion notice TRIAL_DELETION_NOTICE_DAYS before `deletionDueAt`
* - deletion at `deletionDueAt` through the existing tenant offboarding (topology of all
* TENANT_MODELS + storage prefix `<tenantId>/`), platform audit
* - purge of old signup rows (data minimisation)
* Every mail is "claimed" with a conditional update (column still NULL) before sending, so parallel
* workers or re-runs never send twice. Converted or extended tenants are never deleted: the
* deletion re-checks and claims the row immediately before offboarding.
*/
export type LifecycleSummary = {
checked: number;
reminders: number;
expiredNotices: number;
deletionNotices: number;
deleted: string[];
purgedSignups: number;
};
type Deps = { now?: Date; tenantIds?: string[]; sendMail?: typeof enqueueMail };
const REMINDER_COLUMN = { 7: "trialReminder7At", 3: "trialReminder3At", 1: "trialReminder1At" } as const;
async function mailAdmins(
tenantId: string,
send: typeof enqueueMail,
build: (r: { name: string; email: string; locale: "de" | "en" }) => Parameters<typeof enqueueMail>[0],
): Promise<number> {
let n = 0;
for (const r of await tenantAdminRecipients(tenantId)) {
const res = await send(build({ name: r.name, email: r.email, locale: normalizeLocale(r.locale) }));
if (res.status !== "duplicate") n++;
}
return n;
}
export async function runTrialLifecycle(deps: Deps = {}): Promise<LifecycleSummary> {
const now = deps.now ?? new Date();
const send = deps.sendMail ?? enqueueMail;
const contact = trialContactEmail() ?? undefined;
const summary: LifecycleSummary = { checked: 0, reminders: 0, expiredNotices: 0, deletionNotices: 0, deleted: [], purgedSignups: 0 };
const tenants = await prisma.tenant.findMany({
where: { plan: "TRIAL", trialDeletedAt: null, status: { not: "ARCHIVED" }, trialEndsAt: { not: null }, ...(deps.tenantIds ? { id: { in: deps.tenantIds } } : {}) },
select: { id: true, name: true, ...TRIAL_SELECT, trialReminder7At: true, trialReminder3At: true, trialReminder1At: true, trialExpiredNoticeAt: true, trialDeletionNoticeAt: true },
orderBy: { createdAt: "asc" },
});
for (const t of tenants) {
summary.checked++;
try {
const state = computeTrialState(t, now);
const endDate = (locale: string) => formatDateKey(state.endDateKey!, locale);
if (!state.expired) {
const threshold = [...TRIAL_REMINDER_DAYS].sort((a, b) => a - b).find((d) => state.daysLeft! <= d);
if (threshold !== undefined && !t[REMINDER_COLUMN[threshold]]) {
// claim this threshold and every larger one (a missed run must not send an older reminder later)
const columns = TRIAL_REMINDER_DAYS.filter((d) => d >= threshold).map((d) => REMINDER_COLUMN[d]);
const claim = await prisma.tenant.updateMany({
where: { id: t.id, plan: "TRIAL", [REMINDER_COLUMN[threshold]]: null },
data: Object.fromEntries(columns.map((c) => [c, now])),
});
if (claim.count === 1) {
await mailAdmins(t.id, send, (r) => ({
template: "trial_reminder",
to: r.email,
tenantId: t.id,
locale: r.locale,
vars: { name: r.name, tenantName: t.name, daysLeft: state.daysLeft!, endDate: endDate(r.locale), actionUrl: absoluteUrl("/dashboard"), contact },
}));
summary.reminders++;
}
}
continue;
}
if (!t.trialExpiredNoticeAt) {
const claim = await prisma.tenant.updateMany({
where: { id: t.id, plan: "TRIAL", trialExpiredNoticeAt: null },
data: { trialExpiredNoticeAt: now, readOnlySince: t.readOnlySince ?? t.trialEndsAt },
});
if (claim.count === 1) {
await mailAdmins(t.id, send, (r) => ({
template: "trial_expired",
to: r.email,
tenantId: t.id,
locale: r.locale,
vars: { name: r.name, tenantName: t.name, endDate: endDate(r.locale), deletionDate: t.deletionDueAt ? formatInstantDate(t.deletionDueAt, r.locale) : undefined, exportUrl: absoluteUrl("/settings/export"), contact },
}));
summary.expiredNotices++;
}
}
if (!t.deletionDueAt) continue;
if (now.getTime() >= t.deletionDueAt.getTime()) {
const res = await deleteTrialTenant(t.id, { now });
if (res.deleted) summary.deleted.push(t.id);
} else if (now.getTime() >= t.deletionDueAt.getTime() - daysMs(TRIAL_DELETION_NOTICE_DAYS) && !t.trialDeletionNoticeAt) {
const claim = await prisma.tenant.updateMany({ where: { id: t.id, plan: "TRIAL", trialDeletionNoticeAt: null, deletionDueAt: t.deletionDueAt }, data: { trialDeletionNoticeAt: now } });
if (claim.count === 1) {
await mailAdmins(t.id, send, (r) => ({
template: "trial_deletion_notice",
to: r.email,
tenantId: t.id,
locale: r.locale,
vars: { name: r.name, tenantName: t.name, deletionDate: formatInstantDate(t.deletionDueAt!, r.locale), exportUrl: absoluteUrl("/settings/export"), contact },
}));
summary.deletionNotices++;
}
}
} catch (err) {
// one broken tenant must not stop the others; the next run retries
console.error(`[trial-lifecycle] tenant ${t.id} failed:`, (err as Error).message);
}
}
if (!deps.tenantIds) {
const { count } = await prisma.trialSignup.deleteMany({ where: { expiresAt: { lt: new Date(now.getTime() - daysMs(TRIAL_SIGNUP_RETENTION_DAYS)) } } });
summary.purgedSignups = count;
}
return summary;
}
/**
* Deletes an expired, unconverted trial tenant. Double check + claim right before the deletion:
* the conditional update only matches while the tenant is still TRIAL, unconverted, expired and due —
* a conversion/extension a moment earlier makes it a no-op. The tenant is suspended during deletion
* (no logins) and archived by the offboarding afterwards.
*/
export async function deleteTrialTenant(tenantId: string, opts: { now?: Date } = {}): Promise<{ deleted: boolean }> {
const now = opts.now ?? new Date();
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { id: true, slug: true } });
if (!tenant) return { deleted: false };
const claim = await prisma.tenant.updateMany({
where: {
id: tenantId,
plan: "TRIAL",
convertedAt: null,
trialDeletedAt: null,
status: { not: "ARCHIVED" },
trialEndsAt: { lte: now },
deletionDueAt: { lte: now },
},
data: { status: "SUSPENDED" },
});
if (claim.count !== 1) return { deleted: false };
const result = await offboardTenant(tenantId, { reason: "trial_expired", purgeFiles: true, portabilitySnapshot: false });
let files = 0;
try {
files = await purgeTenantObjects(tenantId);
} catch (err) {
console.error(`[trial-lifecycle] storage purge for ${tenantId} failed:`, (err as Error).message);
}
await prisma.tenant.update({ where: { id: tenantId }, data: { trialDeletedAt: now, deletionDueAt: null } });
await prisma.trialSignup.deleteMany({ where: { provisionedTenantId: tenantId } });
await writePlatformAudit({
action: "delete",
entity: "trial_tenant",
entityId: tenantId,
after: { slug: tenant.slug, deletedRows: result.deletedRows, deletedIdentities: result.deletedIdentities, files, certificateId: result.certificateId },
});
return { deleted: true };
}
+11
View File
@@ -0,0 +1,11 @@
import { dbForTenant } from "@/server/db";
/** L15 Testphase: active tenant administrators (recipients of reminder/expiry/deletion mails). */
export async function tenantAdminRecipients(tenantId: string): Promise<{ userId: string; email: string; name: string; locale: string }[]> {
const users = await dbForTenant(tenantId).user.findMany({
where: { status: "ACTIVE", userRoles: { some: { role: { key: "tenant-admin" } } } },
select: { id: true, email: true, name: true, identity: { select: { status: true, uiLocale: true } } },
orderBy: { createdAt: "asc" },
});
return users.filter((u) => u.identity.status === "ACTIVE").map((u) => ({ userId: u.id, email: u.email, name: u.name, locale: u.identity.uiLocale }));
}
+84
View File
@@ -0,0 +1,84 @@
import { prisma } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context";
import { SAMPLE_ORDER_PREFIX, SAMPLE_TEAM_NAME } from "./sample-data";
import { assertTenantWritable } from "./state";
/**
* L15 Testphase: "Erste Schritte" checklist on the dashboard of tenants that started as a trial.
* Items are detected automatically where possible (sample data does not count) and can be ticked
* manually; the whole card can be hidden. State in `TenantSettings.onboarding`.
*/
export const ONBOARDING_ITEMS = [
{ key: "company", href: "/settings" },
{ key: "team", href: "/teams" },
{ key: "technician", href: "/settings/users" },
{ key: "first_order", href: "/work-orders/new" },
{ key: "mobile", href: "/m" },
] as const;
export type OnboardingKey = (typeof ONBOARDING_ITEMS)[number]["key"];
type OnboardingJson = { done: string[]; hidden: boolean };
function parse(value: unknown): OnboardingJson {
const v = value && typeof value === "object" ? (value as Record<string, unknown>) : {};
return { done: Array.isArray(v.done) ? v.done.filter((x): x is string => typeof x === "string") : [], hidden: v.hidden === true };
}
export function isOnboardingKey(key: string): key is OnboardingKey {
return ONBOARDING_ITEMS.some((i) => i.key === key);
}
export async function getOnboardingChecklist(ctx: ServiceCtx) {
if (!can(ctx, "tenant:manage")) return null;
const tenant = await prisma.tenant.findUnique({ where: { id: ctx.tenantId }, select: { trialStartedAt: true } });
if (!tenant?.trialStartedAt) return null;
const settings = await ctx.db.tenantSettings.findFirst({ select: { onboarding: true, address: true, phone: true, email: true } });
const state = parse(settings?.onboarding);
if (state.hidden) return null;
const [teams, fieldUsers, orders] = await Promise.all([
ctx.db.team.count({ where: { deletedAt: null, name: { not: SAMPLE_TEAM_NAME } } }),
ctx.db.user.count({ where: { userRoles: { some: { role: { key: { in: ["technician", "team-lead"] } } } } } }),
ctx.db.workOrder.count({ where: { OR: [{ externalOrderNumber: null }, { NOT: { externalOrderNumber: { startsWith: SAMPLE_ORDER_PREFIX } } }] } }),
]);
const auto: Record<OnboardingKey, boolean> = {
company: !!(settings?.address || settings?.phone || settings?.email),
team: teams > 0,
technician: fieldUsers > 0,
first_order: orders > 0,
mobile: false,
};
const items = ONBOARDING_ITEMS.map((i) => ({ key: i.key, href: i.href, auto: auto[i.key], done: auto[i.key] || state.done.includes(i.key) }));
return { items, completed: items.filter((i) => i.done).length, total: items.length };
}
async function write(ctx: ServiceCtx, next: OnboardingJson, before: OnboardingJson) {
await ctx.db.tenantSettings.update({ where: { tenantId: ctx.tenantId }, data: { onboarding: next } });
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "onboarding", entityId: ctx.tenantId, before, after: next });
}
async function load(ctx: ServiceCtx): Promise<OnboardingJson> {
const settings = await ctx.db.tenantSettings.findFirst({ select: { onboarding: true } });
if (!settings) throw new ServiceError("not_found", "tenant settings not found");
return parse(settings.onboarding);
}
export async function setOnboardingItem(ctx: ServiceCtx, key: string, done: boolean) {
assertCan(ctx, "tenant:manage");
if (!isOnboardingKey(key)) throw new ServiceError("invalid", "unknown_item", { field: "key" });
await assertTenantWritable(ctx.tenantId);
const before = await load(ctx);
const set = new Set(before.done);
if (done) set.add(key);
else set.delete(key);
await write(ctx, { ...before, done: [...set] }, before);
}
export async function setOnboardingHidden(ctx: ServiceCtx, hidden: boolean) {
assertCan(ctx, "tenant:manage");
await assertTenantWritable(ctx.tenantId);
const before = await load(ctx);
await write(ctx, { ...before, hidden }, before);
}
+132
View File
@@ -0,0 +1,132 @@
import { Prisma } from "@prisma/client";
import { randomBytes } from "node:crypto";
import { prisma } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
import { generateCompliantPassword, hashPassword } from "@/server/password";
import { provisionTenant } from "@/server/provision";
import { MODULE_KEYS } from "@/lib/modules";
import { trialEndInstant, todayKey } from "@/lib/trial/dates";
import { daysMs, TRIAL_DELETION_GRACE_DAYS } from "./config";
import { seedTrialSampleData } from "./sample-data";
/**
* L15 Testphase: create a trial tenant (self-service signup after double opt-in, or platform wizard).
*
* The tenant row is CREATED here first (unique slug, trial fields) and only then completed by the
* idempotent `provisionTenant` (roles, admin, settings, modules). `provisionTenant` upserts by slug —
* reserving the slug with a plain `create` first guarantees that two concurrent signups with the same
* company name never end up in the same tenant.
*/
export function slugifyCompany(name: string): string {
const base = name
.toLowerCase()
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40)
.replace(/-+$/g, "");
return base.length >= 3 ? base : "betrieb";
}
export type ProvisionTrialInput = {
companyName: string;
sector?: string | null;
companySize?: string | null;
admin: { name: string; email: string; passwordHash?: string };
/** Last trial day YYYY-MM-DD (Berlin). */
endDateKey: string;
sampleData: boolean;
modules?: readonly string[];
source: "self_signup" | "platform";
actorId?: string | null;
now?: Date;
};
export type ProvisionTrialResult = {
tenantId: string;
tenantSlug: string;
adminUserId: string;
identityId: string;
identityCreated: boolean;
sampleData: "created" | "skipped" | "failed";
};
async function reserveTenant(input: ProvisionTrialInput, now: Date) {
const base = slugifyCompany(input.companyName);
const endsAt = trialEndInstant(input.endDateKey < todayKey(now) ? todayKey(now) : input.endDateKey);
const candidates = [base, ...Array.from({ length: 19 }, (_, i) => `${base}-${i + 2}`), `${base}-${randomBytes(3).toString("hex")}`, `${base}-${randomBytes(4).toString("hex")}`];
for (const slug of candidates) {
try {
return await prisma.tenant.create({
data: {
name: input.companyName,
slug,
sector: input.sector || null,
plan: "TRIAL",
trialSource: input.source,
trialStartedAt: now,
trialEndsAt: endsAt,
deletionDueAt: new Date(endsAt.getTime() + daysMs(TRIAL_DELETION_GRACE_DAYS)),
config: { trial: { companySize: input.companySize || null, source: input.source } },
},
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002") continue; // slug taken → next candidate
throw err;
}
}
throw new Error("no free tenant slug");
}
export async function provisionTrialTenant(input: ProvisionTrialInput): Promise<ProvisionTrialResult> {
const now = input.now ?? new Date();
const email = input.admin.email.trim().toLowerCase();
const tenant = await reserveTenant(input, now);
const modules = input.modules?.length ? input.modules.filter((m) => (MODULE_KEYS as readonly string[]).includes(m)) : [...MODULE_KEYS];
// Without a password (platform wizard): unusable random password + forced change; the admin sets
// their own password through the existing invitation link (actions/platform-users.ts pattern).
const passwordHash = input.admin.passwordHash ?? (await hashPassword(generateCompliantPassword()));
const provisioned = await provisionTenant(prisma, {
name: input.companyName,
slug: tenant.slug,
sector: input.sector || undefined,
admin: { email, name: input.admin.name, passwordHash, mustChangePassword: !input.admin.passwordHash },
modules,
actorId: input.actorId ?? null,
});
let sampleData: ProvisionTrialResult["sampleData"] = "skipped";
if (input.sampleData) {
try {
await seedTrialSampleData(tenant.id, provisioned.adminUserId);
sampleData = "created";
} catch (err) {
// sample data is a convenience — the trial itself must not fail because of it
console.error("[trial] sample data failed:", (err as Error).message);
sampleData = "failed";
}
}
await writeAuditLog({
tenantId: tenant.id,
scope: "platform",
actorId: input.actorId ?? undefined,
action: "create",
entity: "trial",
entityId: tenant.id,
after: { source: input.source, endDate: input.endDateKey, trialEndsAt: tenant.trialEndsAt, deletionDueAt: tenant.deletionDueAt, modules, sampleData },
});
return {
tenantId: tenant.id,
tenantSlug: tenant.slug,
adminUserId: provisioned.adminUserId,
identityId: provisioned.identityId,
identityCreated: provisioned.identityCreated,
sampleData,
};
}
+65
View File
@@ -0,0 +1,65 @@
import { dbForTenant } from "@/server/db";
import { ROLE_DEFS } from "@/server/rbac";
import type { ServiceCtx } from "@/server/services/context";
import { createCustomer } from "@/server/services/customers/customers";
import { createSite } from "@/server/services/sites/sites";
import { createTeam } from "@/server/services/teams/teams";
import { ensureDefaultOrderTypes } from "@/server/services/work-orders/settings";
import { createWorkOrder } from "@/server/services/work-orders/create";
import { assignWorkOrder } from "@/server/services/work-orders/assign";
/**
* L15 Testphase: compact sample data for a new trial tenant (customers, sites, one team, work
* orders in several states). Same approach as the demo seed (scripts/lib/demo-seed.ts): every
* record is created through the REAL domain services (numbering, audit, status history). The demo
* seed itself needs seven users with fixed roles and a PDF/photo pipeline; a trial tenant starts
* with ONE administrator, so this is a trimmed variant for exactly that situation.
* All names and addresses are fictitious.
*/
export const SAMPLE_ORDER_PREFIX = "BEISPIEL-";
export const SAMPLE_TEAM_NAME = "Beispielteam";
const SAMPLE_NOTE = "Beispieldaten der Testphase – können jederzeit gelöscht werden.";
function inDays(days: number, hour: number): Date {
const d = new Date();
d.setDate(d.getDate() + days);
d.setHours(hour, 0, 0, 0);
return d;
}
export async function seedTrialSampleData(tenantId: string, adminUserId: string): Promise<{ customers: number; sites: number; teams: number; orders: number }> {
const ctx: ServiceCtx = { db: dbForTenant(tenantId), tenantId, userId: adminUserId, permissions: new Set<string>(ROLE_DEFS["tenant-admin"].permissions) };
await ensureDefaultOrderTypes(ctx.db, tenantId);
const types = new Map((await ctx.db.orderType.findMany({ select: { id: true, key: true } })).map((t) => [t.key, t.id]));
const type = (key: string) => types.get(key) ?? null;
const customers = [
{ companyName: "Hausverwaltung Musterhof GmbH", street: "Lindenstraße", houseNumber: "12", postalCode: "10969", city: "Berlin", phone: "030 555 0100", email: "service@musterhof.example" },
{ companyName: "Bäckerei Sonnenschein", street: "Marktplatz", houseNumber: "3", postalCode: "04109", city: "Leipzig", phone: "0341 555 0200" },
{ firstName: "Erika", lastName: "Beispiel", street: "Am Wiesengrund", houseNumber: "7", postalCode: "30159", city: "Hannover", phone: "0511 555 0300", email: "erika.beispiel@example.org" },
];
const customerIds: string[] = [];
for (const c of customers) {
customerIds.push((await createCustomer(ctx, { ...c, notes: SAMPLE_NOTE }, { acknowledgeDuplicates: true })).id);
}
const sites = [
{ customerId: customerIds[0], name: "Wohnanlage Musterhof – Haus 1", street: "Lindenstraße", houseNumber: "12a", postalCode: "10969", city: "Berlin", accessNotes: "Schlüssel beim Hausmeister (Erdgeschoss links)." },
{ customerId: customerIds[1], name: "Backstube", street: "Marktplatz", houseNumber: "3", postalCode: "04109", city: "Leipzig", accessNotes: "Lieferanteneingang hinten, ab 5 Uhr besetzt." },
{ customerId: customerIds[2], name: "Einfamilienhaus Am Wiesengrund", street: "Am Wiesengrund", houseNumber: "7", postalCode: "30159", city: "Hannover", accessNotes: "Kundin ist vormittags zu Hause." },
];
const siteIds: string[] = [];
for (const s of sites) siteIds.push((await createSite(ctx, s)).id);
const team = await createTeam(ctx, { name: SAMPLE_TEAM_NAME, leaderUserId: adminUserId, members: [{ userId: adminUserId }], notes: SAMPLE_NOTE });
await createWorkOrder(ctx, { title: "Heizungswartung vor der Heizperiode", customerId: customerIds[0], siteId: siteIds[0], orderTypeId: type("wartung"), status: "draft", externalOrderNumber: `${SAMPLE_ORDER_PREFIX}01`, plannedStart: inDays(5, 8), plannedEnd: inDays(5, 12), description: SAMPLE_NOTE });
await createWorkOrder(ctx, { title: "Wasserenthärtung prüfen", customerId: customerIds[1], siteId: siteIds[1], orderTypeId: type("wartung"), status: "planned", externalOrderNumber: `${SAMPLE_ORDER_PREFIX}02`, plannedStart: inDays(2, 7), plannedEnd: inDays(2, 9), materials: [{ name: "Filterkartusche", plannedQuantity: 1, unit: "Stk" }] });
const assigned = await createWorkOrder(ctx, { title: "Heizkörper im Wohnzimmer tauschen", customerId: customerIds[2], siteId: siteIds[2], orderTypeId: type("montage"), status: "planned", externalOrderNumber: `${SAMPLE_ORDER_PREFIX}03`, plannedStart: inDays(1, 8), plannedEnd: inDays(1, 15), materials: [{ name: "Flachheizkörper 22/600/1000", plannedQuantity: 1, unit: "Stk" }] });
await assignWorkOrder(ctx, { workOrderId: assigned.id, teamId: team.id, userIds: [adminUserId] });
await createWorkOrder(ctx, { title: "Undichte Leitung im Keller", customerId: customerIds[0], siteId: siteIds[0], orderTypeId: type("reparatur"), priority: "high", status: "review_required", externalOrderNumber: `${SAMPLE_ORDER_PREFIX}04`, description: "Aus einer Kundenmail übernommen – bitte prüfen." });
return { customers: customerIds.length, sites: siteIds.length, teams: 1, orders: 4 };
}
+184
View File
@@ -0,0 +1,184 @@
import { createHash, createHmac, randomBytes } from "node:crypto";
import { prisma } from "@/server/db";
import { writePlatformAudit } from "@/server/audit";
import { hashPassword } from "@/server/password";
import { absoluteUrl } from "@/server/mail/config";
import { enqueueMail } from "@/server/mail/service";
import { formatWhen, normalizeLocale } from "@/server/mail/templates";
import { formatDateKey, trialBounds, type TrialBounds } from "@/lib/trial/dates";
import { normalizeTrialValues, TRIAL_STEPS, validateTrialSignup, validateTrialStep, type FieldErrors, type TrialStep } from "@/lib/trial/signup";
import { trialMaxDays, TRIAL_DEFAULT_DAYS, TRIAL_SIGNUP_TOKEN_TTL_MS } from "./config";
import { provisionTrialTenant } from "./provision";
/**
* L15 Testphase: public self-service signup with double opt-in.
*
* - Nothing is provisioned before the e-mail address is confirmed; the pending signup lives in
* `TrialSignup` (platform table) with the Argon2id(+pepper) password hash and the SHA-256 of the
* link token — never the raw password/token. The IP is stored only as HMAC.
* - Enumeration protection: an address that already has an account gets exactly the same response
* (and the same work: the password is hashed in both branches), but a hint mail instead of a
* confirmation link.
* - Rate limits and the honeypot check live in the action adapter (src/server/actions/trial-signup.ts
* via ./abuse.ts), so this service stays testable without a request.
*/
export type SendMail = typeof enqueueMail;
export type SignupResult = { status: "sent" } | { status: "invalid"; errors: FieldErrors };
export type ConfirmResult =
| { status: "ok"; tenantId: string; tenantSlug: string; identityId: string }
| { status: "invalid" }
| { status: "expired" };
export function hashSignupToken(raw: string): string {
return createHash("sha256").update(raw).digest("hex");
}
export function hashIp(ip: string | null | undefined): string | null {
if (!ip) return null;
return createHmac("sha256", process.env.AUTH_SECRET ?? "craftvia-trial").update(ip).digest("hex");
}
export function currentTrialBounds(now: Date = new Date()): TrialBounds {
return trialBounds(now, trialMaxDays(), TRIAL_DEFAULT_DAYS);
}
/** Server-side validation of one wizard step (no writes). Unknown step → form error. */
export function checkTrialStep(step: string, raw: unknown, now: Date = new Date()): FieldErrors {
if (!(TRIAL_STEPS as readonly string[]).includes(step)) return { _form: "invalid_request" };
const values = normalizeTrialValues(raw);
if (!values) return { _form: "invalid_request" };
return validateTrialStep(step as TrialStep, values, currentTrialBounds(now));
}
export async function submitTrialSignup(
raw: unknown,
opts: { ip?: string | null; now?: Date; locale?: string | null; sendMail?: SendMail } = {},
): Promise<SignupResult> {
const now = opts.now ?? new Date();
const send = opts.sendMail ?? enqueueMail;
const locale = normalizeLocale(opts.locale);
const values = normalizeTrialValues(raw);
if (!values) return { status: "invalid", errors: { _form: "invalid_request" } };
// Honeypot filled → pretend success, do nothing.
if (values.website.trim() !== "") return { status: "sent" };
const errors = validateTrialSignup(values, currentTrialBounds(now));
if (Object.keys(errors).length > 0) return { status: "invalid", errors };
// Hash in BOTH branches — equal work, no timing oracle for existing addresses.
const passwordHash = await hashPassword(values.password);
const existing = await prisma.identity.findUnique({ where: { email: values.email }, select: { id: true } });
if (existing) {
const hourBucket = Math.floor(now.getTime() / 3_600_000);
await send({
template: "trial_existing_account",
to: values.email,
tenantId: null,
locale,
vars: { name: values.adminName, loginUrl: absoluteUrl("/login"), resetUrl: absoluteUrl("/forgot-password") },
// at most one hint mail per address per hour
dedupeKey: `trial_existing:${hashSignupToken(values.email)}:${hourBucket}`,
});
await writePlatformAudit({ action: "denied", entity: "trial_signup", after: { reason: "existing_account" } });
return { status: "sent" };
}
const rawToken = randomBytes(32).toString("base64url");
const expiresAt = new Date(now.getTime() + TRIAL_SIGNUP_TOKEN_TTL_MS);
// A new request makes older open links of the same address useless.
await prisma.trialSignup.updateMany({ where: { email: values.email, status: "pending" }, data: { status: "superseded", passwordHash: "" } });
const signup = await prisma.trialSignup.create({
data: {
companyName: values.companyName,
sector: values.sector || null,
companySize: values.companySize || null,
adminName: values.adminName,
email: values.email,
passwordHash,
trialEndDate: values.trialEndDate,
sampleData: values.sampleData,
modules: values.modules,
locale,
tokenHash: hashSignupToken(rawToken),
expiresAt,
ipHash: hashIp(opts.ip),
acceptedTermsAt: now,
},
select: { id: true },
});
await send({
template: "trial_confirm",
to: values.email,
tenantId: null,
locale,
vars: {
name: values.adminName,
companyName: values.companyName,
trialEnd: formatDateKey(values.trialEndDate, locale),
actionUrl: absoluteUrl(`/testen/bestaetigen?token=${encodeURIComponent(rawToken)}`),
expires: formatWhen(expiresAt, locale),
},
});
await writePlatformAudit({ action: "create", entity: "trial_signup", entityId: signup.id, after: { trialEndDate: values.trialEndDate, sampleData: values.sampleData, modules: values.modules.length } });
return { status: "sent" };
}
/** Shows the pending signup behind a link (without consuming it). */
export async function peekTrialSignup(rawToken: string, now: Date = new Date()) {
if (!rawToken || rawToken.length > 200) return null;
const row = await prisma.trialSignup.findUnique({
where: { tokenHash: hashSignupToken(rawToken) },
select: { status: true, expiresAt: true, companyName: true, trialEndDate: true, sampleData: true },
});
if (!row || row.status !== "pending" || row.expiresAt <= now) return null;
return { companyName: row.companyName, trialEndDate: row.trialEndDate, sampleData: row.sampleData };
}
/** Consumes the link (single use, 24 h) and provisions the trial tenant. */
export async function confirmTrialSignup(rawToken: string, opts: { now?: Date } = {}): Promise<ConfirmResult> {
const now = opts.now ?? new Date();
if (!rawToken || rawToken.length > 200) return { status: "invalid" };
const row = await prisma.trialSignup.findUnique({ where: { tokenHash: hashSignupToken(rawToken) } });
if (!row || row.status !== "pending") return { status: "invalid" };
if (row.expiresAt <= now) {
await prisma.trialSignup.updateMany({ where: { id: row.id, status: "pending" }, data: { status: "expired", passwordHash: "" } });
return { status: "expired" };
}
// Claim: only one of several parallel clicks wins.
const claim = await prisma.trialSignup.updateMany({ where: { id: row.id, status: "pending" }, data: { status: "confirming" } });
if (claim.count !== 1) return { status: "invalid" };
// The address may have got an account in the meantime (invitation, other signup).
if (await prisma.identity.findUnique({ where: { email: row.email }, select: { id: true } })) {
await prisma.trialSignup.update({ where: { id: row.id }, data: { status: "existing_account", passwordHash: "" } });
await writePlatformAudit({ action: "denied", entity: "trial_signup", entityId: row.id, after: { reason: "existing_account" } });
return { status: "invalid" };
}
try {
const result = await provisionTrialTenant({
companyName: row.companyName,
sector: row.sector,
companySize: row.companySize,
admin: { name: row.adminName, email: row.email, passwordHash: row.passwordHash },
endDateKey: row.trialEndDate,
sampleData: row.sampleData,
modules: row.modules,
source: "self_signup",
now,
});
await prisma.trialSignup.update({
where: { id: row.id },
data: { status: "confirmed", confirmedAt: now, provisionedTenantId: result.tenantId, passwordHash: "" },
});
await writePlatformAudit({ action: "update", entity: "trial_signup", entityId: row.id, after: { status: "confirmed", tenantId: result.tenantId } });
return { status: "ok", tenantId: result.tenantId, tenantSlug: result.tenantSlug, identityId: result.identityId };
} catch (err) {
await prisma.trialSignup.update({ where: { id: row.id }, data: { status: "failed", passwordHash: "" } });
throw err;
}
}
+100
View File
@@ -0,0 +1,100 @@
import { prisma } from "@/server/db";
import { ServiceError } from "@/server/services/context";
import { diffDayKeys, todayKey, trialEndDateKey } from "@/lib/trial/dates";
import { TRIAL_BANNER_DAYS } from "./config";
/**
* L15 Testphase: lifecycle state of a tenant and the CENTRAL write lock.
*
* After the chosen end the trial tenant is read-only: every mutation path calls
* `assertTenantWritable(tenantId)` (moduleGuard, requireApiContext for non-GET /api/v1 requests,
* backoffice upload route, settings/user administration). Reads, file downloads, PDFs, the data
* export and the login stay possible. The lock depends only on `trialEndsAt` — not on the daily
* job — so it applies to the second.
*
* `Tenant` is a platform table (no tenant_id / RLS); it is read via the owner client by id.
*/
export type TenantTrialRow = {
plan: "FULL" | "TRIAL";
trialStartedAt: Date | null;
trialEndsAt: Date | null;
convertedAt: Date | null;
readOnlySince: Date | null;
deletionDueAt: Date | null;
trialDeletedAt?: Date | null;
};
export type TrialState = {
isTrial: boolean;
endsAt: Date | null;
/** Last trial day (YYYY-MM-DD, Berlin). */
endDateKey: string | null;
/** Calendar days until the last trial day (0 = ends today); null without trial. */
daysLeft: number | null;
expired: boolean;
readOnly: boolean;
deletionDueAt: Date | null;
/** Show "trial ends in X days" (from TRIAL_BANNER_DAYS on). */
showCountdown: boolean;
};
export const TRIAL_SELECT = {
plan: true,
trialStartedAt: true,
trialEndsAt: true,
convertedAt: true,
readOnlySince: true,
deletionDueAt: true,
trialDeletedAt: true,
} as const;
export function computeTrialState(row: TenantTrialRow | null, now: Date = new Date()): TrialState {
if (!row || row.plan !== "TRIAL" || !row.trialEndsAt) {
return { isTrial: false, endsAt: null, endDateKey: null, daysLeft: null, expired: false, readOnly: false, deletionDueAt: null, showCountdown: false };
}
const endDateKey = trialEndDateKey(row.trialEndsAt);
const expired = now.getTime() >= row.trialEndsAt.getTime();
const daysLeft = diffDayKeys(todayKey(now), endDateKey);
return {
isTrial: true,
endsAt: row.trialEndsAt,
endDateKey,
daysLeft,
expired,
readOnly: expired,
deletionDueAt: row.deletionDueAt,
showCountdown: !expired && daysLeft <= TRIAL_BANNER_DAYS,
};
}
export async function getTrialState(tenantId: string, now: Date = new Date()): Promise<TrialState> {
const row = await prisma.tenant.findUnique({ where: { id: tenantId }, select: TRIAL_SELECT });
return computeTrialState(row, now);
}
/** Plain-text reason for API clients and legacy action forms that show `err.message`-style texts. */
export const TRIAL_READ_ONLY_MESSAGE = "Testphase abgelaufen – nur Lesezugriff. Export und Downloads bleiben möglich.";
/** `blocked trial_expired` — mapped to HTTP 422 by respond.ts, shown as plain text by the UI. */
export class TrialExpiredError extends ServiceError {
constructor(deletionDueAt: Date | null) {
super("blocked", "trial_expired", {
reason: "trial_expired",
readOnly: true,
deletionDueAt: deletionDueAt?.toISOString() ?? null,
message: TRIAL_READ_ONLY_MESSAGE,
});
this.name = "TrialExpiredError";
}
}
export function isTrialExpiredError(err: unknown): boolean {
return err instanceof ServiceError && err.code === "blocked" && err.message === "trial_expired";
}
/** Throws `TrialExpiredError` when the tenant is an expired, unconverted trial. */
export async function assertTenantWritable(tenantId: string, now: Date = new Date()): Promise<void> {
const state = await getTrialState(tenantId, now);
if (state.readOnly) throw new TrialExpiredError(state.deletionDueAt);
}
@@ -0,0 +1,33 @@
import { DeleteObjectsCommand, ListObjectsV2Command, S3Client } from "@aws-sdk/client-s3";
/**
* L15 Testphase: remove ALL objects of a tenant from the document/upload bucket (`<tenantId>/…`).
*
* The upload adapter (src/server/storage/adapter.ts, foundation) only offers put/get; the DSGVO
* offboarding removes `<tenantId>/uploads/` from the BACKUP store, which is not necessarily the
* upload bucket (backup target can be local or a different bucket). Same S3_* configuration as the
* upload adapter; without S3 (stub storage) there are no bytes to delete.
*/
export async function purgeTenantObjects(tenantId: string): Promise<number> {
if (!/^[A-Za-z0-9_-]{8,}$/.test(tenantId)) throw new Error("purgeTenantObjects: invalid tenant id");
const endpoint = process.env.S3_ENDPOINT?.trim();
const accessKeyId = process.env.S3_ACCESS_KEY?.trim();
const secretAccessKey = process.env.S3_SECRET_KEY?.trim();
const bucket = process.env.S3_BUCKET?.trim();
if (!endpoint || !accessKeyId || !secretAccessKey || !bucket) return 0;
const client = new S3Client({ endpoint, region: process.env.S3_REGION?.trim() || "us-east-1", forcePathStyle: true, credentials: { accessKeyId, secretAccessKey } });
const prefix = `${tenantId}/`;
let removed = 0;
let token: string | undefined;
do {
const page = await client.send(new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix, ContinuationToken: token }));
const keys = (page.Contents ?? []).map((o) => o.Key).filter((k): k is string => !!k && k.startsWith(prefix));
if (keys.length) {
await client.send(new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: keys.map((Key) => ({ Key })), Quiet: true } }));
removed += keys.length;
}
token = page.IsTruncated ? page.NextContinuationToken : undefined;
} while (token);
return removed;
}