@@ -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 |
| 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:
- 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>/`.
| **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. |
- **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).
| `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/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 |
| `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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.