diff --git a/.env.coolify.example b/.env.coolify.example index 48c5ea7..3709b5d 100644 --- a/.env.coolify.example +++ b/.env.coolify.example @@ -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 diff --git a/.env.example b/.env.example index bf2fbe5..4948868 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.env.prod.example b/.env.prod.example index 4d8b616..f88524b 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -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 diff --git a/docs/craftvia/API.md b/docs/craftvia/API.md index 5ab762a..48ee815 100644 --- a/docs/craftvia/API.md +++ b/docs/craftvia/API.md @@ -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). diff --git a/docs/craftvia/DEPLOY.md b/docs/craftvia/DEPLOY.md index 90fa1d3..2dbf6fe 100644 --- a/docs/craftvia/DEPLOY.md +++ b/docs/craftvia/DEPLOY.md @@ -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 | diff --git a/docs/craftvia/TESTPHASE.md b/docs/craftvia/TESTPHASE.md new file mode 100644 index 0000000..0c1004b --- /dev/null +++ b/docs/craftvia/TESTPHASE.md @@ -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 `/`), 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 `/uploads/…`, Download 7 Tage über `/settings/export/` (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/`, 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//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 `/`. diff --git a/messages/de/nav.json b/messages/de/nav.json index c3251f5..3f1dbf6 100644 --- a/messages/de/nav.json +++ b/messages/de/nav.json @@ -21,5 +21,6 @@ "openMenu": "Menü öffnen", "closeMenu": "Menü schließen", "timeApprovals": "Zeiten zur Freigabe", - "billing": "Abrechnung" + "billing": "Abrechnung", + "dataExport": "Datenexport" } diff --git a/messages/de/trial.json b/messages/de/trial.json new file mode 100644 index 0000000..d8e5a78 --- /dev/null +++ b/messages/de/trial.json @@ -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." + } + } +} diff --git a/messages/en/nav.json b/messages/en/nav.json index 5c2e9dd..cbcb063 100644 --- a/messages/en/nav.json +++ b/messages/en/nav.json @@ -21,5 +21,6 @@ "openMenu": "Open menu", "closeMenu": "Close menu", "timeApprovals": "Time approvals", - "billing": "Billing" + "billing": "Billing", + "dataExport": "Data export" } diff --git a/messages/en/trial.json b/messages/en/trial.json new file mode 100644 index 0000000..9a56377 --- /dev/null +++ b/messages/en/trial.json @@ -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." + } + } +} diff --git a/prisma/migrations/20260916100000_testphase/migration.sql b/prisma/migrations/20260916100000_testphase/migration.sql new file mode 100644 index 0000000..8626077 --- /dev/null +++ b/prisma/migrations/20260916100000_testphase/migration.sql @@ -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'); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7a2ff19..a464fe0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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[] @@ -79,6 +96,8 @@ model TenantSettings { lotseAddressForm String? @map("lotse_address_form") // L10b (Spec §31): monthly AI token budget (input + output) per tenant; null = env AI_MONTHLY_TOKEN_LIMIT, 0 = unlimited aiMonthlyTokenLimit Int? @map("ai_monthly_token_limit") + // L15: „Erste Schritte"-Checkliste ({ done: string[], hidden: boolean }) + onboarding Json @default("{}") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -1433,3 +1452,62 @@ model BillingRecord { @@index([tenantId, workOrderId]) @@map("billing_records") } + +// ── 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") +} diff --git a/scripts/check-module-guards.ts b/scripts/check-module-guards.ts index 02ff65f..d4edb9d 100644 --- a/scripts/check-module-guards.ts +++ b/scripts/check-module-guards.ts @@ -57,6 +57,12 @@ const ACTION_MODULE: Record = { "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[] = []; @@ -118,6 +124,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)) { diff --git a/scripts/craftvia-worker.ts b/scripts/craftvia-worker.ts index f2a53de..77614f8 100644 --- a/scripts/craftvia-worker.ts +++ b/scripts/craftvia-worker.ts @@ -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(name, async (job) => processor(job.data), { connection, concurrency, ...(geocode ? { limiter: { max: 1, duration: 1_000 } } : {}) }); + const w = new Worker(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}`); diff --git a/scripts/lib/testphase-fixture.ts b/scripts/lib/testphase-fixture.ts new file mode 100644 index 0000000..4464e4a --- /dev/null +++ b/scripts/lib/testphase-fixture.ts @@ -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, 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(ROLE_DEFS[role].permissions) }; +} + +type MailInput = Parameters[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-). */ +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 { + const out = new Map(); + 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; +} diff --git a/scripts/smoke-testphase.ts b/scripts/smoke-testphase.ts new file mode 100644 index 0000000..5864f98 --- /dev/null +++ b/scripts/smoke-testphase.ts @@ -0,0 +1,152 @@ +/** + * 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; expect?: number[]; mustContain?: string[]; mustNotContain?: string[]; redirectTo?: string }; + +async function tenantCookie(email: string, slug: string): Promise { + 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 { + 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, 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."], mustNotContain: ["Daten exportieren"] }, + { 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); +}); diff --git a/scripts/test-e2e-tenant-isolation.ts b/scripts/test-e2e-tenant-isolation.ts index f201d8f..0d9da70 100644 --- a/scripts/test-e2e-tenant-isolation.ts +++ b/scripts/test-e2e-tenant-isolation.ts @@ -136,6 +136,7 @@ async function createModelRows(A: TenantFixture): Promise { // L14 Abrechnungsübersicht put("WorkOrderMilestone", (await prisma.workOrderMilestone.create({ data: { tenantId: t, workOrderId: wo.id, title: "ZZ" } })).id, { title: MARK }); put("BillingRecord", (await prisma.billingRecord.create({ data: { tenantId: t, workOrderId: wo.id, kind: "order_completion", periodFrom: new Date(Date.now() - 3600_000), periodTo: new Date() } })).id, { invoiceNumber: MARK }); + put("TenantExport", (await prisma.tenantExport.create({ data: { tenantId: t, status: "done", fileName: "zz.zip" } })).id, { fileName: MARK }); // L15 Testphase return rows; } diff --git a/scripts/test-testphase-lifecycle.ts b/scripts/test-testphase-lifecycle.ts new file mode 100644 index 0000000..5f53d87 --- /dev/null +++ b/scripts/test-testphase-lifecycle.ts @@ -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 / 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); +}); diff --git a/scripts/test-testphase-readonly.ts b/scripts/test-testphase-readonly.ts new file mode 100644 index 0000000..1de964d --- /dev/null +++ b/scripts/test-testphase-readonly.ts @@ -0,0 +1,184 @@ +// 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]*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 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); +}); diff --git a/scripts/test-testphase-signup.ts b/scripts/test-testphase-signup.ts new file mode 100644 index 0000000..28e13a1 --- /dev/null +++ b/scripts/test-testphase-signup.ts @@ -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 { + 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 }).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); +}); diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 1918df8..9e812f6 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -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; @@ -65,6 +66,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr return (
+ {/* L15 Testphase */} {sp.module === "disabled" && (

{t("moduleDisabled")} diff --git a/src/app/(app)/documents/upload/route.ts b/src/app/(app)/documents/upload/route.ts index 341eee7..5184b25 100644 --- a/src/app/(app)/documents/upload/route.ts +++ b/src/app/(app)/documents/upload/route.ts @@ -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 { diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 13ff9d7..b892683 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -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({ }> + {/* L15 Testphase: Countdown ab 7 Tagen bzw. Nur-Lesen-Hinweis */} + {children} ); diff --git a/src/app/(app)/settings/export/[id]/route.ts b/src/app/(app)/settings/export/[id]/route.ts new file mode 100644 index 0000000..58a1bcb --- /dev/null +++ b/src/app/(app)/settings/export/[id]/route.ts @@ -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/ — 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); + } +} diff --git a/src/app/(app)/settings/export/page.tsx b/src/app/(app)/settings/export/page.tsx new file mode 100644 index 0000000..3645eb9 --- /dev/null +++ b/src/app/(app)/settings/export/page.tsx @@ -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 ( +

+ + +
+
+ +
+

{t("contents")}

+

{t("readOnlyHint")}

+
+
+ +
+
+ {sp.requested &&

{t("requested")}

} + {sp.error &&

{t(`errors.${errorKey}`)}

} +
+ +
+

{t("listTitle")}

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

{t("empty")}

+ ) : ( +
    + {exports.map((e) => { + const status = e.expired ? "expired" : (e.status as keyof typeof TONE); + return ( +
  • + {t(`status.${status}`)} + {t("created")}: {e.createdAt.toLocaleString(locale === "en" ? "en-GB" : "de-DE", { timeZone: "Europe/Berlin" })} + {e.bytes != null && {(e.bytes / 1024 / 1024).toFixed(1)} MB} + {e.status === "failed" && e.error && {e.error}} + {e.status === "done" && !e.expired && ( + + {t("download")} + {e.expiresAt && ({t("expires", { date: formatInstantDate(e.expiresAt, locale) })})} + + )} +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/app/(field)/m/layout.tsx b/src/app/(field)/m/layout.tsx index ca6f601..2800f10 100644 --- a/src/app/(field)/m/layout.tsx +++ b/src/app/(field)/m/layout.tsx @@ -12,6 +12,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 }> { @@ -53,6 +54,8 @@ export default async function FieldShell({ children }: Readonly<{ children: Reac + {/* L15 Testphase: Countdown ab 7 Tagen bzw. Nur-Lesen-Hinweis */} +
{children}
diff --git a/src/app/(platform)/admin/[id]/page.tsx b/src/app/(platform)/admin/[id]/page.tsx index 6129386..f692b64 100644 --- a/src/app/(platform)/admin/[id]/page.tsx +++ b/src/app/(platform)/admin/[id]/page.tsx @@ -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 = { 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({
+ {/* L15 Testphase: Enddatum, Umwandlung, Beenden, Löschung (Bestätigung + Plattform-Audit) */} + + {/* Lebenszyklus */}

{t("lifecycleTitle")}

diff --git a/src/app/(platform)/admin/page.tsx b/src/app/(platform)/admin/page.tsx index 866f4d5..fd268db 100644 --- a/src/app/(platform)/admin/page.tsx +++ b/src/app/(platform)/admin/page.tsx @@ -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 = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" }; const STATUS_LABEL: Record = { 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={ + @@ -92,6 +99,23 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
)} + +
@@ -99,6 +123,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis Kunde Kürzel Status + {tt("colPlan")} Nutzer Aktive Module @@ -114,6 +139,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis {t.slug} {STATUS_LABEL[t.status]} + {t._count.users} {active > 0 ? `${active} Module` : "—"} diff --git a/src/app/(platform)/admin/trial/page.tsx b/src/app/(platform)/admin/trial/page.tsx new file mode 100644 index 0000000..3fa05ad --- /dev/null +++ b/src/app/(platform)/admin/trial/page.tsx @@ -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 ( +
+ + {ta("backToOverview")} + +
+ + +
+
+ ); +} diff --git a/src/app/api/v1/work-orders/[id]/documents/route.ts b/src/app/api/v1/work-orders/[id]/documents/route.ts index 94e10be..c4d6e15 100644 --- a/src/app/api/v1/work-orders/[id]/documents/route.ts +++ b/src/app/api/v1/work-orders/[id]/documents/route.ts @@ -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"); diff --git a/src/app/testen/bestaetigen/page.tsx b/src/app/testen/bestaetigen/page.tsx new file mode 100644 index 0000000..5456817 --- /dev/null +++ b/src/app/testen/bestaetigen/page.tsx @@ -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 { + 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 ( +
+

{t("title")}

+ {signup ? ( + <> +

{t("intro")}

+
+
{t("company")}
{signup.companyName}
+
{t("until")}
{formatDateKey(signup.trialEndDate, locale)}
+
{t("sampleData")}
{signup.sampleData ? t("yes") : t("no")}
+
+ + + ) : ( + <> +

{t("invalid")}

+
+ {t("toSignup")} + {t("toLogin")} +
+ + )} +
+ ); +} diff --git a/src/app/testen/datenschutz/page.tsx b/src/app/testen/datenschutz/page.tsx new file mode 100644 index 0000000..1ca1c24 --- /dev/null +++ b/src/app/testen/datenschutz/page.tsx @@ -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 { + 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 ; +} diff --git a/src/app/testen/layout.tsx b/src/app/testen/layout.tsx new file mode 100644 index 0000000..f7ea493 --- /dev/null +++ b/src/app/testen/layout.tsx @@ -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 ( +
+
+ + + +

+ {t("haveAccount")} + {t("login")} +

+
+
{children}
+
+ {t("terms")} + {t("privacy")} +
+
+ ); +} diff --git a/src/app/testen/nutzungsbedingungen/page.tsx b/src/app/testen/nutzungsbedingungen/page.tsx new file mode 100644 index 0000000..dd07aed --- /dev/null +++ b/src/app/testen/nutzungsbedingungen/page.tsx @@ -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 { + 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 ; +} diff --git a/src/app/testen/page.tsx b/src/app/testen/page.tsx new file mode 100644 index 0000000..81fa6f6 --- /dev/null +++ b/src/app/testen/page.tsx @@ -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 { + 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 ( +
+
+

{t("heading")}

+

{t("intro")}

+
    + {(["benefit1", "benefit2", "benefit3"] as const).map((k) => ( +
  • + + {t(k)} +
  • + ))} +
+
+
+ +
+
+ ); +} diff --git a/src/components/trial/confirm-form.tsx b/src/components/trial/confirm-form.tsx new file mode 100644 index 0000000..c666565 --- /dev/null +++ b/src/components/trial/confirm-form.tsx @@ -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(confirmTrialSignupAction, { status: "idle" }); + + if (state.status === "invalid" || state.status === "expired") { + return ( +
+

{t(state.status)}

+ {t("toSignup")} +
+ ); + } + + return ( +
+ + {state.status === "rate_limited" && ( +

{t("rate_limited")}

+ )} + + + ); +} diff --git a/src/components/trial/getting-started.tsx b/src/components/trial/getting-started.tsx new file mode 100644 index 0000000..10724d3 --- /dev/null +++ b/src/components/trial/getting-started.tsx @@ -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 ( +
+
+
+ {welcome &&

{t("welcome")}

} +

{t("title")}

+

{t("progress", { done: checklist.completed, total: checklist.total })}

+
+ {writable && ( +
+ + + )} +
+
+
+
+
    + {checklist.items.map((item) => ( +
  • + {item.done ? : } +
    +

    + {item.done ? t("doneLabel") : t("openLabel")}: + {t(`items.${item.key}.title`)} +

    +

    {t(`items.${item.key}.text`)}{item.auto ? ` · ${t("auto")}` : ""}

    +
    + {t("open")} + {writable && !item.auto && ( +
    + + + + + )} +
  • + ))} +
+
+ ); +} diff --git a/src/components/trial/legal-placeholder.tsx b/src/components/trial/legal-placeholder.tsx new file mode 100644 index 0000000..bba0aa3 --- /dev/null +++ b/src/components/trial/legal-placeholder.tsx @@ -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 ( +
+

{title}

+

{placeholder}

+ {paragraphs.map((p) => ( +

{p}

+ ))} + ← {back} +
+ ); +} diff --git a/src/components/trial/platform-forms.tsx b/src/components/trial/platform-forms.tsx new file mode 100644 index 0000000..5172cee --- /dev/null +++ b/src/components/trial/platform-forms.tsx @@ -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

{t.has(state.code) ? t(state.code) : t("failed")}

; +} + +/** 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(createTrialTenantAction, { status: "idle" }); + const section = "shadow-card rounded-xl border bg-card p-5"; + return ( +
+
+ {t("sectionCompany")} +
+
+ + +
+
+ + +
+
+
+
+ {t("sectionAdmin")} +
+
+ + +
+
+ + +
+
+

{t("adminHint")}

+
+
+ {t("sectionPeriod")} +
+ + +

{t("endDateHint")}

+
+
+
+ {t("sectionSetup")} + +
+ +
+ + +
+ + ); +} + +/** 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; + 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(action, { status: "idle" }); + return ( +
+ {withDate && ( +
+ + +
+ )} + + +
+ + +
+ + ); +} diff --git a/src/components/trial/signup-wizard.tsx b/src/components/trial/signup-wizard.tsx new file mode 100644 index 0000000..7f455e0 --- /dev/null +++ b/src/components/trial/signup-wizard.tsx @@ -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 = { + 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({ + 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({}); + const [sent, setSent] = useState(false); + const [pending, startTransition] = useTransition(); + + const current = TRIAL_STEPS[step]; + const set = (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 ( +
+ +

{t("sent.title")}

+

{t("sent.body")}

+

{t("sent.hint")}

+ +
+ ); + } + + return ( +
{ + e.preventDefault(); + if (current === "summary") submit(); + else next(); + }} + > + {/* Progress */} + + +

{t(`steps.${current}`)}

+ + {/* Honeypot: invisible for people, not announced to screen readers */} +
+ + set("website", e.target.value)} /> +
+ +
+ {current === "company" && ( + <> +
+ + set("companyName", e.target.value)} aria-invalid={!!errors.companyName} aria-describedby={errors.companyName ? "companyName-error" : undefined} /> + {err("companyName") &&

{err("companyName")}

} +
+
+ + + {sectorKey === "other" && ( + <> + + set("sector", e.target.value)} /> + + )} + {err("sector") &&

{err("sector")}

} +
+
+ + + {err("companySize") &&

{err("companySize")}

} +
+ + )} + + {current === "account" && ( + <> +
+ + set("adminName", e.target.value)} aria-invalid={!!errors.adminName} /> + {err("adminName") &&

{err("adminName")}

} +
+
+ + set("email", e.target.value)} aria-invalid={!!errors.email} aria-describedby="email-hint" /> +

{t("fields.emailHint")}

+ {err("email") &&

{err("email")}

} +
+
+ + set("password", e.target.value)} aria-invalid={!!errors.password} aria-describedby="password-hint" /> +

{t("fields.passwordHint", { policy: passwordPolicy })}

+ + {err("password") &&

{err("password")}

} +
+ + )} + + {current === "period" && ( +
+ + set("trialEndDate", e.target.value)} aria-invalid={!!errors.trialEndDate} aria-describedby="period-range" /> +

+ {t("period.range", { min: formatDateKey(bounds.min, locale), max: formatDateKey(bounds.max, locale) })} +

+ {err("trialEndDate") ? ( +

{err("trialEndDate")}

+ ) : ( + days !== null && days > 0 && ( +

+ {t("period.until", { date: formatDateKey(values.trialEndDate, locale), days })} +

+ ) + )} +

{t("period.afterEnd")}

+
+ )} + + {current === "setup" && ( + <> + +
+ {t("fields.modules")} +

{t("fields.modulesHint")}

+
+ {modules.map((m) => ( + + ))} +
+ {err("modules") &&

{err("modules")}

} +
+ + )} + + {current === "summary" && ( + <> +

{t("summary.intro")}

+
+ {[ + { 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) => ( +
+
+
{row.label}
+
{row.value}
+
+ +
+ ))} +
+ + {err("acceptTerms") &&

{err("acceptTerms")}

} + + {err("acceptPrivacy") &&

{err("acceptPrivacy")}

} + + )} +
+ + {err("_form") && ( +

+ {err("_form")} +

+ )} + +
+ + +
+ + ); +} diff --git a/src/components/trial/trial-admin-card.tsx b/src/components/trial/trial-admin-card.tsx new file mode 100644 index 0000000..54df08b --- /dev/null +++ b/src/components/trial/trial-admin-card.tsx @@ -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=, 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 ( +
+

{t("card.title")}

+ {notice &&

{t(`card.${notice}`)}

} +
+
{t("card.state")}
{stateText}
+ {state.endDateKey &&
{t("card.until")}
{formatDateKey(state.endDateKey, locale)}
} + {isTrial &&
{t("card.deletion")}
{tenant.deletionDueAt ? formatInstantDate(tenant.deletionDueAt, locale) : t("card.deletionNone")}
} +
{t("card.source")}
{tenant.trialSource === "platform" ? t("card.sourcePlatform") : t("card.sourceSelf")}
+
+ {isFullAdmin && isTrial && ( +
+ {actions.filter((a) => a.show).map((a) => ( + + + + ))} +
+ )} + + {activeOp && ( + +
+

{t(`ops.${activeOp}Text`)}

+ = today ? addDaysToKey(state.endDateKey, 14) : addDaysToKey(today, 14)} + min={today} + max={addDaysToKey(today, PLATFORM_TRIAL_MAX_DAYS)} + closeHref={base} + destructive={activeOp === "end" || activeOp === "schedule"} + /> +
+
+ )} +
+ ); +} diff --git a/src/components/trial/trial-badge.tsx b/src/components/trial/trial-badge.tsx new file mode 100644 index 0000000..f96920c --- /dev/null +++ b/src/components/trial/trial-badge.tsx @@ -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 {t("badgeDeleted")}; + const state = computeTrialState(tenant); + if (!state.isTrial) return {t("badgeFull")}; + return ( + + {state.expired ? {t("badgeExpired")} : {t("badgeUntil", { date: formatDateKey(state.endDateKey!, locale) })}} + {state.deletionDueAt && {t("badgeDeletion", { date: formatInstantDate(state.deletionDueAt, locale) })}} + + ); +} diff --git a/src/components/trial/trial-banner.tsx b/src/components/trial/trial-banner.tsx new file mode 100644 index 0000000..b1df9a7 --- /dev/null +++ b/src/components/trial/trial-banner.tsx @@ -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 ( +
+ + + {state.expired ? t("expired") : t("endsIn", { days: Math.max(0, state.daysLeft ?? 0) })} + + {!state.expired && state.endDateKey && {t("endsOn", { date: formatDateKey(state.endDateKey, locale) })}} + {state.expired && state.deletionDueAt && {t("deletion", { date: formatInstantDate(state.deletionDueAt, locale) })}} + + {contact ? ( + <> + {t("contact", { email: "" })} + {contact} + + ) : ( + t("contactGeneric") + )} + + {state.expired && canExport && ( + + {t("export")} + + )} +
+ ); +} diff --git a/src/lib/nav.ts b/src/lib/nav.ts index 59a1dd8..59607cb 100644 --- a/src/lib/nav.ts +++ b/src/lib/nav.ts @@ -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. */ diff --git a/src/lib/trial/dates.ts b/src/lib/trial/dates.ts new file mode 100644 index 0000000..84e1012 --- /dev/null +++ b/src/lib/trial/dates.ts @@ -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, + }; +} diff --git a/src/lib/trial/signup.ts b/src/lib/trial/signup.ts new file mode 100644 index 0000000..0270170 --- /dev/null +++ b/src/lib/trial/signup.ts @@ -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.`. + */ +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.`); "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>; + +/** 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((acc, step) => ({ ...acc, ...validateTrialStep(step, v, bounds) }), {}); +} diff --git a/src/proxy.ts b/src/proxy.ts index 4fbe6f6..7b061ea 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -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 — diff --git a/src/server/action-guard.ts b/src/server/action-guard.ts index 1f48cd4..b0797fb 100644 --- a/src/server/action-guard.ts +++ b/src/server/action-guard.ts @@ -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). @@ -83,6 +84,9 @@ export function moduleGuard(moduleKey: string) { } await assertModuleEnabled(session, moduleKey); + // L15 Testphase: abgelaufene Testmandanten sind nur lesbar — zentrale Schreibsperre + // (wirft ServiceError "blocked"/"trial_expired"). + 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 }; diff --git a/src/server/actions/lotse-settings.ts b/src/server/actions/lotse-settings.ts index 896f8e1..3f82250 100644 --- a/src/server/actions/lotse-settings.ts +++ b/src/server/actions/lotse-settings.ts @@ -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 { 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, { diff --git a/src/server/actions/tenant-settings.ts b/src/server/actions/tenant-settings.ts index f452479..169bc24 100644 --- a/src/server/actions/tenant-settings.ts +++ b/src/server/actions/tenant-settings.ts @@ -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")); diff --git a/src/server/actions/tenant-users.ts b/src/server/actions/tenant-users.ts index b2faff1..d3f27c2 100644 --- a/src/server/actions/tenant-users.ts +++ b/src/server/actions/tenant-users.ts @@ -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."; } diff --git a/src/server/actions/trial-platform.ts b/src/server/actions/trial-platform.ts new file mode 100644 index 0000000..f5784ab --- /dev/null +++ b/src/server/actions/trial-platform.ts @@ -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 { + 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 { + 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}`); +} diff --git a/src/server/actions/trial-signup.ts b/src/server/actions/trial-signup.ts new file mode 100644 index 0000000..ed42d7f --- /dev/null +++ b/src/server/actions/trial-signup.ts @@ -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 { + 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 { + 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 { + 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 { + 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" }; +} diff --git a/src/server/actions/trial-tenant.ts b/src/server/actions/trial-tenant.ts new file mode 100644 index 0000000..1ab0fa1 --- /dev/null +++ b/src/server/actions/trial-tenant.ts @@ -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 { + 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 { + const ctx = await adminCtx(); + await setOnboardingItem(ctx, String(fd.get("key") ?? ""), fd.get("done") === "1"); + revalidatePath("/dashboard"); +} + +export async function hideOnboardingAction(): Promise { + const ctx = await adminCtx(); + await setOnboardingHidden(ctx, true); + revalidatePath("/dashboard"); +} diff --git a/src/server/api/context.ts b/src/server/api/context.ts index ae852fd..d087524 100644 --- a/src/server/api/context.ts +++ b/src/server/api/context.ts @@ -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 { + 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 diff --git a/src/server/api/respond.ts b/src/server/api/respond.ts index e65f69e..49a2ff7 100644 --- a/src/server/api/respond.ts +++ b/src/server/api/respond.ts @@ -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(handler: (...args: A) => Promise) { return async (...args: A): Promise => { 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); } diff --git a/src/server/backup/topology.ts b/src/server/backup/topology.ts index 382cba3..6cc01f0 100644 --- a/src/server/backup/topology.ts +++ b/src/server/backup/topology.ts @@ -75,6 +75,8 @@ export const TENANT_MODELS: readonly string[] = [ // L14 Abrechnungsübersicht "WorkOrderMilestone", "BillingRecord", + // L15 Testphase: Datenexport des Mandanten + "TenantExport", ]; /** diff --git a/src/server/db.ts b/src/server/db.ts index 111ecaf..22cb66f 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -126,6 +126,8 @@ const TENANT_MODELS = new Set([ // L14 Abrechnungsübersicht "WorkOrderMilestone", "BillingRecord", + // 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('
')` in der Migration diff --git a/src/server/dsgvo/pii-fields.ts b/src/server/dsgvo/pii-fields.ts index 1483356..2b90dc9 100644 --- a/src/server/dsgvo/pii-fields.ts +++ b/src/server/dsgvo/pii-fields.ts @@ -55,6 +55,8 @@ export const PII_REFERENCE_FIELDS: readonly PiiReference[] = [ { model: "WorkOrderMilestone", field: "rejectedById" }, { model: "BillingRecord", field: "billedById" }, { model: "BillingRecord", field: "voidedById" }, + // 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. ]; diff --git a/src/server/jobs/processors/index.ts b/src/server/jobs/processors/index.ts index e9eb334..6317ec6 100644 --- a/src/server/jobs/processors/index.ts +++ b/src/server/jobs/processors/index.ts @@ -16,6 +16,8 @@ export const PROCESSORS: Partial Promise 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). */ diff --git a/src/server/jobs/processors/tenant-export.ts b/src/server/jobs/processors/tenant-export.ts new file mode 100644 index 0000000..9507637 --- /dev/null +++ b/src/server/jobs/processors/tenant-export.ts @@ -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 { + await buildTenantExport(payload.tenantId, payload.entityId); +} diff --git a/src/server/jobs/processors/trial-lifecycle.ts b/src/server/jobs/processors/trial-lifecycle.ts new file mode 100644 index 0000000..16ae64b --- /dev/null +++ b/src/server/jobs/processors/trial-lifecycle.ts @@ -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 _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}`, + ); +} diff --git a/src/server/jobs/queues.ts b/src/server/jobs/queues.ts index 97d9493..8194c87 100644 --- a/src/server/jobs/queues.ts +++ b/src/server/jobs/queues.ts @@ -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 { } finally { await watch.close(); } + // L15 Testphase: trial lifecycle once a day (all trial tenants; idempotent per tenant/notice) + const trial = new Queue(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 { diff --git a/src/server/mail/templates.ts b/src/server/mail/templates.ts index 28f7383..72922f0 100644 --- a/src/server/mail/templates.ts +++ b/src/server/mail/templates.ts @@ -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 = { 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 } = { }), }; +// ---- 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 } = { + 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 } = { + 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 } = { invitation: (v) => ({ subject: `Ihr Zugang zu ${BRAND.name}`, @@ -385,6 +518,7 @@ const de: { [K in TemplateKey]: Builder } = { }), ...craftviaDe, ...customerDe, + ...trialDe, }; const en: { [K in TemplateKey]: Builder } = { @@ -465,6 +599,7 @@ const en: { [K in TemplateKey]: Builder } = { }), ...craftviaEn, ...customerEn, + ...trialEn, }; const CATALOG: Record }> = { de, en }; diff --git a/src/server/provision.ts b/src/server/provision.ts index 7412f6c..a1e401d 100644 --- a/src/server/provision.ts +++ b/src/server/provision.ts @@ -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 }); } diff --git a/src/server/rate-limit.ts b/src/server/rate-limit.ts index b81b3c6..762885d 100644 --- a/src/server/rate-limit.ts +++ b/src/server/rate-limit.ts @@ -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; export type RateLimitScope = keyof typeof RATE_LIMITS; diff --git a/src/server/services/trial/abuse.ts b/src/server/services/trial/abuse.ts new file mode 100644 index 0000000..bcd020d --- /dev/null +++ b/src/server/services/trial/abuse.ts @@ -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 }); +} diff --git a/src/server/services/trial/admin.ts b/src/server/services/trial/admin.ts new file mode 100644 index 0000000..fd11c06 --- /dev/null +++ b/src/server/services/trial/admin.ts @@ -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 { + 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, + opts: { now?: Date; invite?: (input: Parameters[0]) => Promise } = {}, +): Promise { + 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; +} diff --git a/src/server/services/trial/config.ts b/src/server/services/trial/config.ts new file mode 100644 index 0000000..db265d8 --- /dev/null +++ b/src/server/services/trial/config.ts @@ -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; +} diff --git a/src/server/services/trial/export.ts b/src/server/services/trial/export.ts new file mode 100644 index 0000000..ad0965e --- /dev/null +++ b/src/server/services/trial/export.ts @@ -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 + * `/uploads/…` in the object storage and downloaded via /settings/export/ (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; + +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 { + 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 { + 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[]>][] = [ + ["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 = {}; + 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" }; +} diff --git a/src/server/services/trial/jobs.ts b/src/server/services/trial/jobs.ts new file mode 100644 index 0000000..75ef4e7 --- /dev/null +++ b/src/server/services/trial/jobs.ts @@ -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 { + if (!TRIAL_BLOCKED_QUEUES.includes(queue)) return false; + if (!payload.tenantId || payload.tenantId === "*") return false; + return (await getTrialState(payload.tenantId)).readOnly; +} diff --git a/src/server/services/trial/lifecycle.ts b/src/server/services/trial/lifecycle.ts new file mode 100644 index 0000000..929618a --- /dev/null +++ b/src/server/services/trial/lifecycle.ts @@ -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 `/`), 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[0], +): Promise { + 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 { + 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 }; +} diff --git a/src/server/services/trial/mail.ts b/src/server/services/trial/mail.ts new file mode 100644 index 0000000..ddcb66a --- /dev/null +++ b/src/server/services/trial/mail.ts @@ -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 })); +} diff --git a/src/server/services/trial/onboarding.ts b/src/server/services/trial/onboarding.ts new file mode 100644 index 0000000..a515a04 --- /dev/null +++ b/src/server/services/trial/onboarding.ts @@ -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) : {}; + 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 = { + 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 { + 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); +} diff --git a/src/server/services/trial/provision.ts b/src/server/services/trial/provision.ts new file mode 100644 index 0000000..e28e4ad --- /dev/null +++ b/src/server/services/trial/provision.ts @@ -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 { + 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, + }; +} diff --git a/src/server/services/trial/sample-data.ts b/src/server/services/trial/sample-data.ts new file mode 100644 index 0000000..dccbd89 --- /dev/null +++ b/src/server/services/trial/sample-data.ts @@ -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(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 }; +} diff --git a/src/server/services/trial/signup.ts b/src/server/services/trial/signup.ts new file mode 100644 index 0000000..1e82142 --- /dev/null +++ b/src/server/services/trial/signup.ts @@ -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 { + 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 { + 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; + } +} diff --git a/src/server/services/trial/state.ts b/src/server/services/trial/state.ts new file mode 100644 index 0000000..5af125c --- /dev/null +++ b/src/server/services/trial/state.ts @@ -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 { + 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 { + const state = await getTrialState(tenantId, now); + if (state.readOnly) throw new TrialExpiredError(state.deletionDueAt); +} diff --git a/src/server/services/trial/storage-purge.ts b/src/server/services/trial/storage-purge.ts new file mode 100644 index 0000000..21b87f7 --- /dev/null +++ b/src/server/services/trial/storage-purge.ts @@ -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 (`/…`). + * + * The upload adapter (src/server/storage/adapter.ts, foundation) only offers put/get; the DSGVO + * offboarding removes `/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 { + 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; +}