Compare commits

..
Author SHA1 Message Date
msolarczekandClaude Opus 5 8a20c17444 Website-Beispiel ohne Demo-Kundennamen, Smoke-Test mit dynamischem Fremdkunden
Der Lotse-Beispielchat nannte „Wohnanlage Elbblick“ aus den Demo-Daten; die
Mandantentrennungs-Prüfung für demo2 schlug deshalb an. Dazu Grammatik korrigiert.
smoke-auth sucht den für den Monteur fremden Kunden jetzt über die Aufträge statt fest K-D004,
weil Aufträge beim Ausprobieren der Plantafel umgeplant werden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 10:42:55 +02:00
msolarczekandClaude Opus 5 3e26009543 Merge lane/pakete in feature/craftvia-mvp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 10:26:38 +02:00
msolarczekandClaude Opus 5 aca571b2e7 L17 Pakete: Smoke-Prüfungen, Audit-Labels, Betriebsdoku und Lane-Bericht
- smoke-auth: Admin-Karte, Teamleiterin ohne Chat-Platz, Monteur mit Platz,
  demo2 als Basis (Hinweis statt Profi-Seiten, API 403)
- Audit-Labels lotse_chat_seat / tenant_plan
- DEPLOY.md §7.5 (Pakete/Plätze setzen, Mehrverbrauch ablesen), API.md
- docs/craftvia/lanes/pakete.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 10:21:23 +02:00
msolarczekandClaude Opus 5 06d320908f L17 Pakete: Stufen Basis/Profi, Lotse-Chat-Plätze und Kontingent
- Datenmodell: Tenant.tier (Default PROFI), lotseChatSeats, lotseChatHardLimit;
  Tabelle lotse_chat_seats (RLS, TENANT_MODELS, pii-fields), Index für die
  Monatszählung der Chat-Nachrichten (Migration 20260921100000_pakete)
- src/lib/plans.ts: Stufenregeln, 150 Chats je Platz, Mehrverbrauch in 100er-Paketen
- src/server/plan.ts: effektive Freischaltung = Stufe UND TenantModule, genutzt von
  requireModule, assertModuleEnabled, API, Sync (Offline-Op → rejected mit Klartext),
  Navigation, isLotseEnabled und planningAccess (Planung nur in Profi)
- Lotse-Chat: Platzprüfung (no_seat), Testphase ohne Platz, Kontingent mit
  hartem Limit (quota_exhausted); Platzvergabe durch den Mandanten-Admin
- Betreiber: Stufe/Plätze/hartes Limit im Mandantendetail mit Bestätigung
  und Plattform-Audit, Verbrauch laufender Monat/Vormonat, Stufe als Badge
- Demo-Seed: demo = Profi mit 3 Plätzen, demo2 = Basis
- Tests: test-pakete-{rules,gates,seats}

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 10:13:27 +02:00
msolarczekandClaude Opus 5 4d5be72ebb Website und App auf getrennten Domains (craftvia.de / app.craftvia.de)
Der Proxy trennt nach Host, sobald MARKETING_HOST gesetzt ist: craftvia.de zeigt nur die
Produktseiten und leitet Login, Testphase, App und API auf die App-Domain aus AUTH_URL um;
www leitet auf die Hauptdomain; auf der App-Domain landet / ohne Sitzung beim Login und
Produktseiten gehen zurück zur Website. Interne Aufrufe (Healthcheck) bleiben unverändert.
Links bleiben relativ, damit keine Domain im Image steckt.

Dazu Test scripts/test-host-routing.ts, Env-Vorlagen mit MARKETING_HOST und Registry,
scripts/generate-coolify-env.sh für lokal erzeugte Geheimnisse und DEPLOY.md §2.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 09:08:47 +02:00
msolarczekandClaude Opus 5 f665999d81 Impressum mit den Anbieterdaten der GEFIM UG
Daten aus gefim.de/impressum als zentrale Konstante (src/lib/marketing/imprint.ts), Seite
/impressum öffentlich und in der Fußzeile verlinkt. Der dort abgeschnittene Satz zur
Verbraucherstreitbeilegung ist mit der Standardformulierung vervollständigt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 09:02:30 +02:00
msolarczekandClaude Opus 5 4815a957b0 Produktseite ausgebaut: Unterseiten und Bildschirmfotos aus der App
Neue öffentliche Routen unter src/app/(marketing): Übersicht /funktionen, sechs Detailseiten
(planung, baustelle, zeiterfassung, berichte, abrechnung, lotse), /sicherheit und /preise,
gemeinsames Gerüst mit Kopf- und Fußzeile.

scripts/marketing-shots.ts nimmt die Bilder direkt aus der laufenden App auf (Playwright über
den installierten Chrome, Session wie im Smoke-Test, ohne Passworteingabe); 14 Aufnahmen in
public/marketing ersetzen die gezeichneten Vorschauen.

Plantafel: „Vorschläge" hängt jetzt als Fußzeile in der Auftragskarte statt frei zwischen zwei
Karten zu stehen (neue Prop footer an OrderCard, Schlüssel unplanned.suggestionsFor).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:35:04 +02:00
msolarczekandClaude Opus 5 31343d329a Öffentliche Startseite: Produktseite unter /
Aufbau: Hero mit Plantafel-Vorschau, Leitgedanken, 12 Funktionen, vertiefte Abschnitte zu
Planung, Baustelle und Lotse, Ablauf, Gewerke, Sicherheit, Testphase, häufige Fragen und
Fußzeile. Texte auf Deutsch und Englisch, Vorschauen als Markup statt Screenshots.
Angemeldete Nutzer leitet die Route weiterhin auf Dashboard bzw. mobile Ansicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:40:19 +02:00
msolarczekandClaude Opus 5 a4a61dc31f Ereignisse erst nach dem Commit ausliefern
Benachrichtigungs- und Mailversand lief im Handler noch in der offenen Transaktion und
zählte gegen deren Zeitlimit; unter Volllast brachen dadurch wechselnde Tests ab
(test-einsatz-field 31 s, test-planung-recommend). withDeferredEvents puffert Ereignisse
innerhalb von inTransaction und stellt sie nach dem Commit zu, bei Rollback gar nicht —
analog zu withDeferredAudit und passend zum dokumentierten Vertrag in events.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 11:05:49 +02:00
msolarczekandClaude Opus 5 1e1c154a8a Plantafel: Beispielwoche relativ zu heute; Live-Lage mit Kachelansicht
Neues Skript scripts/planning-demo.ts plant über createWorkOrder + scheduleWorkOrder eine
Woche für beide Kolonnen (Auslastung, Überbuchung, Überschneidung, Mehrtagesauftrag,
ungeplante Aufträge); die Demo-Termine aus dem Seed liegen relativ zum Seed-Tag und wandern
sonst aus dem Standardzeitraum.

Live-Lage: Umschalter Karte · Kacheln · Liste, Kachelansicht mit Status, Auftrag und Ort;
LiveMap meldet nicht erreichbare Kartenkacheln und die Ansicht wechselt automatisch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 09:44:24 +02:00
msolarczekandClaude Opus 5 dd6a67d329 Offline: Fotos bei abgelaufener Testphase nicht mehr verwerfen
Upload-Antwort 422 trial_expired gilt als vorübergehend: Datei bleibt auf dem Gerät,
wartende Op bleibt in der Warteschlange, der Sync-Durchlauf stoppt mit Backoff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 19:23:14 +02:00
msolarczek a8fedc390f Merge lane/testphase in feature/craftvia-mvp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

# Conflicts:
#	prisma/schema.prisma
#	scripts/test-e2e-tenant-isolation.ts
#	src/server/backup/topology.ts
#	src/server/db.ts
#	src/server/dsgvo/pii-fields.ts
2026-09-15 19:16:19 +02:00
msolarczekandClaude Opus 5 c3712598c1 Transaktionen: Wartezeit 10 s und Zeitlimit 20 s statt Prisma-Standard
Unter paralleler Last scheiterten Tests sporadisch mit „Unable to start a transaction in the given time“.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 19:14:31 +02:00
msolarczekandClaude Opus 5 df18ff9077 L15 Testphase & Onboarding: Lane-Bericht, Smoke prüft mobilen Scope
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 19:13:49 +02:00
msolarczekandClaude Opus 5 273a4535e3 L15 Testphase & Onboarding: Lese-Modus am moduleGuard für Seitenkontexte, Sperre für Auftragsdokument-Upload
Der HTTP-Smoke zeigte 500 auf /m für abgelaufene Testmandanten: mobile Seitenkontexte (field,
emergency) und der Import-Datei-Download nutzen moduleGuard zum Lesen. moduleGuard(key, { read: true })
überspringt dort die Schreibsperre; der Guard-Check verbietet den Lese-Modus in Server-Actions.
POST /api/v1/work-orders/[id]/documents läuft nicht über withApi und prüft die Sperre jetzt explizit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 19:07:20 +02:00
msolarczekandClaude Opus 5 969bc4e12c Merge lane/lotse-chat in feature/craftvia-mvp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 19:04:40 +02:00
msolarczekandClaude Opus 5 eec23efa7e L16 Lotse-Chat für Monteure: optionaler Live-Test und Lane-Bericht
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 19:03:59 +02:00
msolarczekandClaude Opus 5 d9290a187c L15 Testphase & Onboarding: Selbstanmeldung mit Double-Opt-in, Plattform-Wizard, Nur-Lesen-Sperre, Export, Lebenszyklus-Job
- Datenmodell: Testphasen-Lebenszyklus am Mandanten (plan, trialEndsAt, readOnlySince, deletionDueAt,
  Versandmarker), TrialSignup (Plattform, Hashes statt Klartext), TenantExport (RLS), Onboarding-Status
- /testen: 5-Schritte-Wizard (Betrieb, Admin-Konto, Enddatum, Einrichtung, Zusammenfassung),
  Bestätigung per POST, direkte Anmeldung über login-ticket; Rate-Limit je IP/E-Mail, Honeypot,
  Enumeration-Schutz, Slug-Kollisionen
- Plattform: Wizard „Testmandant anlegen“ mit Einladung, Badges/Filter, Enddatum ändern,
  umwandeln, beenden, Löschung vormerken/abbrechen (Bestätigung + Audit)
- Schreibsperre nach Ablauf zentral in moduleGuard und requireApiContext (non-GET über withApi),
  Upload-Routen, Einstellungen/Nutzerverwaltung, Worker-Jobs; Banner Backoffice + mobil
- Datenexport (ZIP mit CSV/JSON + Dateien) als Worker-Job, auch im Nur-Lesen-Zustand
- Täglicher Job trial-lifecycle: Erinnerungen 7/3/1, Ablauf, Löschhinweis, Löschung über das Offboarding
- Erste-Schritte-Checkliste im Dashboard, Mail-Vorlagen de/en, Tests + Smoke, Betriebsdoku

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 19:01:47 +02:00
msolarczekandClaude Opus 5 2f46552970 L16 Lotse-Chat für Monteure: Tests für Chat-Schleife und Bestätigen, Isolation und Smoke
Fake-Provider mit geskripteten Tool-Aufrufen: Zuordnung, Vorschlag ohne Schreibzugriff,
Ausführung über Services, Blocker, Einmaligkeit/Ablauf/fremder Nutzer, Scope,
Mandantentrennung, Datenminimierung, Einstellung aus, Budget- und Rundengrenze.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:57:20 +02:00
msolarczekandClaude Opus 5 63baaf29df L16 Lotse-Chat für Monteure: mobile Chat-Seite, Aktionskarten, Mikrofon, Einstieg und Einstellung
/m/lotse mit Verlauf, Chips, Sprungzielen, Aktionskarten (Bestätigen/Bearbeiten/Verwerfen),
Spracheingabe mit editierbarem Transkript und Offline-Hinweis; Navigationseintrag, Button
„Lotse fragen“ im Auftragsdetail, Schalter und Datenfluss in /settings/lotse, Audit-Labels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:57:20 +02:00
msolarczekandClaude Opus 5 68f4eb32dc L16 Lotse-Chat für Monteure: Datenmodell, Provider mit Tool-Schleife, Vorschläge und Bestätigen über bestehende Services
Chatverlauf (LotseConversation/-Message) und Aktionskarten (LotseActionProposal) als
Mandantentabellen mit RLS; Schalter lotseChatEnabled. Tool-Use-Schleife mit Runden- und
Tokengrenze, Datenminimierung per Platzhalter, Auftragszuordnung im Sichtbarkeits-Scope,
Bestätigen/Verwerfen/Alle bestätigen mit Hash, Ablauf und Idempotenz, Transkriptions-API.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:57:20 +02:00
207 changed files with 13807 additions and 160 deletions
+15 -1
View File
@@ -22,6 +22,10 @@ RLS_ENFORCED=false
# und ist deshalb NICHT separat zu setzen (in Coolify sonst als „managed" gesperrt). # und ist deshalb NICHT separat zu setzen (in Coolify sonst als „managed" gesperrt).
REDIS_PASSWORD=CHANGE_ME_redis_password REDIS_PASSWORD=CHANGE_ME_redis_password
# --- Fertige Images (docker-compose.coolify.prebuilt.yml, DEPLOY.md §9) ---
REGISTRY=git.certvia.de/msolarczek
IMAGE_TAG=main
# --- Objektspeicher (Service "garage", S3-kompatibel) --- # --- Objektspeicher (Service "garage", S3-kompatibel) ---
# Garage ersetzt MinIO (Community EOL). Buckets/Keys legt NICHT die S3-API an, sondern # Garage ersetzt MinIO (Community EOL). Buckets/Keys legt NICHT die S3-API an, sondern
# der Init-Job "garage-provision" (Admin-API). S3_ACCESS_KEY/S3_SECRET_KEY sind der # der Init-Job "garage-provision" (Admin-API). S3_ACCESS_KEY/S3_SECRET_KEY sind der
@@ -60,7 +64,11 @@ AUTH_SECRET=CHANGE_ME_openssl_rand_base64_32
# PASSWORD_PEPPER (Härtung §1): openssl rand -hex 32 — frisch je Umgebung, NICHT rotierbar, nie ins Artefakt. # PASSWORD_PEPPER (Härtung §1): openssl rand -hex 32 — frisch je Umgebung, NICHT rotierbar, nie ins Artefakt.
PASSWORD_PEPPER=CHANGE_ME_openssl_rand_hex_32 PASSWORD_PEPPER=CHANGE_ME_openssl_rand_hex_32
# MFA_ENC_KEY= (leer = aus AUTH_SECRET abgeleitet; nach dem Setzen nicht mehr ändern) # MFA_ENC_KEY= (leer = aus AUTH_SECRET abgeleitet; nach dem Setzen nicht mehr ändern)
AUTH_URL=http://REPLACE-WITH-COOLIFY-SSLIP-DOMAIN AUTH_URL=https://app.craftvia.de
# Website und App auf getrennten Domains (src/lib/marketing/hosts.ts): craftvia.de zeigt nur die
# Produktseiten, AUTH_URL-Domain die App. Beide Domains in Coolify beim Service "app" eintragen.
# Leer = alles auf einer Domain.
MARKETING_HOST=craftvia.de
# Hinter Reverse-Proxy (Coolify/Traefik) für Auth.js v5 zwingend, sonst UntrustedHost: # Hinter Reverse-Proxy (Coolify/Traefik) für Auth.js v5 zwingend, sonst UntrustedHost:
AUTH_TRUST_HOST=true AUTH_TRUST_HOST=true
@@ -96,6 +104,12 @@ TRANSCRIPTION_MODEL=whisper-1
# --- KI: Kostenbremse & Aufbewahrung KI-Protokoll --- # --- KI: Kostenbremse & Aufbewahrung KI-Protokoll ---
# Tokens je Mandant je Kalendermonat (ein+aus), 0 = unbegrenzt. # Tokens je Mandant je Kalendermonat (ein+aus), 0 = unbegrenzt.
AI_MONTHLY_TOKEN_LIMIT=0 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. # Ein-/Ausgaben im KI-Protokoll (AiGeneration) nach N Tagen leeren/pseudonymisieren.
AI_GENERATION_RETENTION_DAYS=180 AI_GENERATION_RETENTION_DAYS=180
+6
View File
@@ -129,6 +129,12 @@ AI_GENERATION_RETENTION_DAYS=180
# Import-Extraktion ab. 0 = unbegrenzt. # Import-Extraktion ab. 0 = unbegrenzt.
AI_MONTHLY_TOKEN_LIMIT=0 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) --- # --- Craftvia: Planung – Karten & Geocoding (L13) ---
# Adresse → Koordinaten nur serverseitig im Worker (Job geocode-site, max. 1 Anfrage/s), # Adresse → Koordinaten nur serverseitig im Worker (Job geocode-site, max. 1 Anfrage/s),
# Ergebnis wird am Objekt gespeichert. nominatim | none (none = keine Verortung, Objekte # Ergebnis wird am Objekt gespeichert. nominatim | none (none = keine Verortung, Objekte
+10
View File
@@ -63,6 +63,10 @@ PASSWORD_PEPPER=CHANGE_ME_openssl_rand_hex_32
# MFA_ENC_KEY: TOTP-Secrets at-rest (leer = aus AUTH_SECRET). ⚠ Nach dem Setzen nicht mehr ändern. # MFA_ENC_KEY: TOTP-Secrets at-rest (leer = aus AUTH_SECRET). ⚠ Nach dem Setzen nicht mehr ändern.
MFA_ENC_KEY=CHANGE_ME_openssl_rand_hex_32 MFA_ENC_KEY=CHANGE_ME_openssl_rand_hex_32
AUTH_URL=https://app.craftvia.example AUTH_URL=https://app.craftvia.example
# Website und App auf getrennten Domains (src/lib/marketing/hosts.ts): craftvia.de zeigt nur die
# Produktseiten, AUTH_URL-Domain die App. Beide Domains in Coolify beim Service "app" eintragen.
# Leer = alles auf einer Domain.
MARKETING_HOST=craftvia.example
# AUTH_TRUST_HOST ist im Compose fest auf true (hinter dem Coolify-Proxy) – nicht nötig. # AUTH_TRUST_HOST ist im Compose fest auf true (hinter dem Coolify-Proxy) – nicht nötig.
# Passkeys/WebAuthn: leer = aus AUTH_URL abgeleitet. # Passkeys/WebAuthn: leer = aus AUTH_URL abgeleitet.
# WEBAUTHN_ORIGIN=https://app.craftvia.example # WEBAUTHN_ORIGIN=https://app.craftvia.example
@@ -99,6 +103,12 @@ TRANSCRIPTION_MODEL=whisper-1
# Tokens (ein + aus) je Mandant je Kalendermonat; darüber lehnen Lotse und # Tokens (ein + aus) je Mandant je Kalendermonat; darüber lehnen Lotse und
# Import-Extraktion ab. 0 = unbegrenzt. # Import-Extraktion ab. 0 = unbegrenzt.
AI_MONTHLY_TOKEN_LIMIT=0 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 # Nach N Tagen leert/pseudonymisiert ein Worker-Job Ein-/Ausgaben im KI-Protokoll
# (AiGeneration). Frist mit dem DSB abstimmen. # (AiGeneration). Frist mit dem DSB abstimmen.
AI_GENERATION_RETENTION_DAYS=180 AI_GENERATION_RETENTION_DAYS=180
+2
View File
@@ -79,6 +79,8 @@ für Lesepfade nach erneutem Login; Mutationen prüfen autoritativ gegen die DB
docker compose up -d postgres redis garage # Infrastruktur (+ --profile dev für mailhog) docker compose up -d postgres redis garage # Infrastruktur (+ --profile dev für mailhog)
GARAGE_ADMIN_URL=http://localhost:3903 npx tsx scripts/garage-provision.ts # Bucket/Key (einmalig) GARAGE_ADMIN_URL=http://localhost:3903 npx tsx scripts/garage-provision.ts # Bucket/Key (einmalig)
npx prisma migrate deploy && npx prisma db seed # Schema + Demo-Daten npx prisma migrate deploy && npx prisma db seed # Schema + Demo-Daten
npx tsx scripts/geocode-backfill.ts --tenant=demo # Koordinaten der Objekte (Karte, Empfehlungen)
npx tsx scripts/planning-demo.ts # Beispielbelegung der Plantafel (relativ zu heute)
npm run dev # App auf :3000 npm run dev # App auf :3000
npm run gate # vollständiges Qualitäts-Gate npm run gate # vollständiges Qualitäts-Gate
``` ```
+2
View File
@@ -27,6 +27,8 @@ docker compose up -d postgres redis garage
GARAGE_ADMIN_URL=http://localhost:3903 npx tsx scripts/garage-provision.ts GARAGE_ADMIN_URL=http://localhost:3903 npx tsx scripts/garage-provision.ts
npx prisma migrate deploy npx prisma migrate deploy
npx prisma db seed npx prisma db seed
npx tsx scripts/geocode-backfill.ts --tenant=demo # Koordinaten für Karte/Empfehlungen (1 Anfrage/s)
npx tsx scripts/planning-demo.ts # Beispielbelegung der Plantafel (heute + 4 Werktage)
npm run dev # http://localhost:3000 npm run dev # http://localhost:3000
``` ```
+8 -1
View File
@@ -20,7 +20,7 @@ Alle Routen antworten im Fehlerfall mit `Cache-Control: no-store` und
| Code | HTTP | Bedeutung / `details` | | Code | HTTP | Bedeutung / `details` |
|---|---|---| |---|---|---|
| `unauthorized` | 401 | nicht angemeldet, Konto inaktiv, Sitzung invalidiert | | `unauthorized` | 401 | nicht angemeldet, Konto inaktiv, Sitzung invalidiert |
| `forbidden` | 403 | Recht fehlt, Modul deaktiviert, Passwortwechsel nötig, Cross-Site-Request | | `forbidden` | 403 | Recht fehlt, Modul deaktiviert, Passwortwechsel nötig, Cross-Site-Request; Modul/Funktion nicht im Paket (L17: `details = { reason: "not_in_plan", module?, message }`) |
| `not_found` | 404 | unbekannt, fremder Mandant oder außerhalb des Scopes | | `not_found` | 404 | unbekannt, fremder Mandant oder außerhalb des Scopes |
| `conflict` | 409 | Versionskonflikt (`baseVersion`), Doppelbestätigung/unzulässiger Zustand, mögliche Dubletten (`details.reason = "possible_duplicates"`, `details.candidates`) | | `conflict` | 409 | Versionskonflikt (`baseVersion`), Doppelbestätigung/unzulässiger Zustand, mögliche Dubletten (`details.reason = "possible_duplicates"`, `details.candidates`) |
| `invalid` | 422 | Validierung (Zod: `details = [{ path, code }]`), fehlerhaftes JSON/Multipart | | `invalid` | 422 | Validierung (Zod: `details = [{ path, code }]`), fehlerhaftes JSON/Multipart |
@@ -40,6 +40,7 @@ Alle Routen antworten im Fehlerfall mit `Cache-Control: no-store` und
- **Idempotenz:** Eine wiederholte `clientOpId` (je Mandant) liefert `duplicate` mit dem gespeicherten Ergebnis. Ist die ID bereits durch einen anderen Nutzer belegt, wird die Operation `rejected`. - **Idempotenz:** Eine wiederholte `clientOpId` (je Mandant) liefert `duplicate` mit dem gespeicherten Ergebnis. Ist die ID bereits durch einen anderen Nutzer belegt, wird die Operation `rejected`.
- **Konflikte:** `work_order.transition` und `report.submit` verlangen `baseVersion`. Weicht sie von `WorkOrder.version` ab, lautet das Ergebnis `conflict`, `entityVersion` ist dann die aktuelle Version. Alle anderen Operationen sind additiv (Client-IDs in den Payloads, z. B. `clientId`, werden über `idMap` auf Server-IDs abgebildet). - **Konflikte:** `work_order.transition` und `report.submit` verlangen `baseVersion`. Weicht sie von `WorkOrder.version` ab, lautet das Ergebnis `conflict`, `entityVersion` ist dann die aktuelle Version. Alle anderen Operationen sind additiv (Client-IDs in den Payloads, z. B. `clientId`, werden über `idMap` auf Server-IDs abgebildet).
- Den opType-Katalog mit den Payload-Schemas enthält `src/lib/sync/ops.ts` (Spec: Komponenten `SyncPayload*`). - Den opType-Katalog mit den Payload-Schemas enthält `src/lib/sync/ops.ts` (Spec: Komponenten `SyncPayload*`).
- L17 Pakete: Operationen eines Moduls außerhalb des Pakets bzw. eines ausgeschalteten Moduls (`emergency.create` → Notdienst, `milestone.reach` → Abrechnung) werden gespeichert als `rejected` mit `errorCode = "forbidden"` und `message = "not_in_plan: <Klartext>"` bzw. `"module_disabled: <Klartext>"`.
- REST-Schreibrouten für Aufträge (`PATCH /work-orders/{id}`, `/assign`, `/transition`) akzeptieren optional `baseVersion` und antworten bei Abweichung mit `409`. - REST-Schreibrouten für Aufträge (`PATCH /work-orders/{id}`, `/assign`, `/transition`) akzeptieren optional `baseVersion` und antworten bei Abweichung mit `409`.
## Uploads ## Uploads
@@ -96,3 +97,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/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 | `/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 | | 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).
+65
View File
@@ -59,6 +59,25 @@ Entrypoints von postgres/redis), CPU-/RAM-Limits, gepinnte Image-Tags, non-root-
Proxies dürfen kein niedrigeres Body-Limit haben. Proxies dürfen kein niedrigeres Body-Limit haben.
- DNS: A/AAAA-Record `app.craftvia.example` → Server-IP; Firewall nur 80/443 + SSH. - DNS: A/AAAA-Record `app.craftvia.example` → Server-IP; Firewall nur 80/443 + SSH.
### 2.1 Website und App auf getrennten Domains
Ein `app`-Container bedient beide Domains; der Proxy trennt sie (`src/lib/marketing/hosts.ts`,
Test `scripts/test-host-routing.ts`):
| Domain | zeigt | leitet um |
|---|---|---|
| `craftvia.de` | `/`, `/funktionen/**`, `/sicherheit`, `/preise`, `/impressum` | alles andere (Login, `/testen`, App, API) → `app.craftvia.de` (308) |
| `www.craftvia.de` | – | → `craftvia.de` (308) |
| `app.craftvia.de` | die Anwendung; `/` ohne Sitzung → `/login` | Produktseiten → `craftvia.de` (308) |
- Env: `AUTH_URL=https://app.craftvia.de`, `MARKETING_HOST=craftvia.de` (leer = eine Domain für alles).
- Coolify, Service `app`, Feld *Domains*: `https://app.craftvia.de:3000,https://craftvia.de:3000,https://www.craftvia.de:3000`.
- DNS: `craftvia.de` und `app.craftvia.de` als A/AAAA auf den Server, `www.craftvia.de` als CNAME auf `craftvia.de`.
- Die Links der Produktseiten bleiben relativ (`/login`, `/testen`); die Umleitung übernimmt der Proxy.
So steckt keine Domain im Image, und dieselben Images laufen auf Test und Produktion.
- Sitzungs-Cookies gelten nur für `app.craftvia.de`. Interne Aufrufe (Healthcheck über `127.0.0.1`)
werden nicht umgeleitet.
## 3. Ersteinrichtung (Coolify) ## 3. Ersteinrichtung (Coolify)
1. **Ressource:** Git-Repo (Deploy-Key, nur lesend), Build Pack **Docker Compose**, Compose Location 1. **Ressource:** Git-Repo (Deploy-Key, nur lesend), Build Pack **Docker Compose**, Compose Location
@@ -208,6 +227,52 @@ 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. je Mandant wird ein Audit-Eintrag `ai_generation_retention` geschrieben. Die Frist mit dem DSB abstimmen.
Die Variable muss im `craftvia-worker` gesetzt sein. 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.
### 7.5 Pakete Basis/Profi und Lotse-Chat-Plätze (L17)
Die App rechnet nicht ab – sie setzt das Paket durch, verwaltet Chat-Plätze und zählt die Nutzung,
damit der Betreiber außerhalb der App die Rechnung stellt. Regeln: `src/lib/plans.ts`, Durchsetzung:
`src/server/plan.ts`.
| Paket | Inhalt |
|---|---|
| **Basis** | Aufträge, Kunden, Objekte, Teams, mobile App/Offline, Berichte + Unterschrift + PDF-Versand, Zeiterfassung inkl. Freigabe, Dokumente, Benachrichtigungen – **ohne KI und Planung** |
| **Profi** (Default) | zusätzlich Planung (Plantafel, Live-Lage, Kapazität, Empfehlungen, Verzugs-/Frühfertig-Hinweise), Abrechnungsübersicht, Notdienst, Auftragsimport, Lotse (Berichtsentwurf, Vollständigkeitsprüfung, Sprachnotiz-Zusammenfassung) und – je Platz – der Lotse-Chat |
**Paket, Plätze, hartes Limit setzen** (nur Plattform-Voll-Admins): Betreiber-Portal → Mandant
öffnen → Karte „Paket & Lotse-Chat“ → „Paket ändern …“ → Paket, Anzahl gebuchter Chat-Plätze
(0–1000) und „Hartes Limit“ wählen, Bestätigung ankreuzen, speichern. Jede Änderung steht im
Audit-Trail des Mandanten (`tenant_plan`, Scope Plattform, vorher/nachher). Die Mandantenliste zeigt
das Paket als Badge. Mandanten-Admins sehen das Paket nur lesend und vergeben unter
`/settings/lotse` die gebuchten Plätze an Nutzer mit Feldrolle (`lotse:use` + `field:execute`);
mehr als gebucht ist serverseitig ausgeschlossen. Senkt der Betreiber die Anzahl unter die vergebenen
Plätze, gelten nur die zuerst vergebenen; der Admin sieht „Über der Platzanzahl“ und entzieht Plätze.
**Wechsel Profi → Basis:** Daten bleiben vollständig erhalten (Einsätze, Abrechnungseinträge, Plätze,
Chatverläufe), nur der Zugriff endet: Seiten leiten auf das Dashboard mit dem Hinweis „Im Paket Profi
enthalten“, Actions/API antworten `403` (`details.reason = "not_in_plan"`), Offline-Operationen eines
gesperrten Moduls (Notdienst, Meilensteine) werden mit Klartext abgewiesen. Zurück auf Profi ist alles
wieder da. **Testphase** (`plan = TRIAL`) läuft immer als Profi ohne Platzprüfung.
**Kontingent und Mehrverbrauch ablesen:** Ein Chat = eine Nachricht eines Nutzers an den Lotsen. Je
vergebenem Platz 150 Chats pro Kalendermonat (Zeitzone des Mandanten), gemeinsam für den Betrieb
(Testphase: berechtigte Nutzer × 150). Darüber wird weitergezählt; abgerechnet wird der Mehrverbrauch in
Paketen à 100 (angefangene Pakete zählen). Die Karte im Betreiber-Portal zeigt für den **laufenden
Monat und den Vormonat** „x von y Chats“, den Mehrverbrauch und die Anzahl der 100er-Pakete – das ist
die Grundlage der Rechnung (Vormonat nach Monatsende ablesen). Mit „Hartes Limit“ wird der Chat beim
Kontingent gesperrt (Monteure sehen einen Hinweis statt des Eingabefelds, kein Mehrverbrauch). Die
Token-Obergrenze (`AI_MONTHLY_TOKEN_LIMIT`, `services/lotse/budget.ts`) bleibt als Sicherheitsnetz
unabhängig davon aktiv. Hinweis: Chatverläufe werden nach `AI_GENERATION_RETENTION_DAYS` gelöscht –
den Wert über ~62 Tagen lassen, sonst fehlen Vormonats-Chats in der Zählung.
## 8. Rate Limits ## 8. Rate Limits
| Bereich | Env | Default | Zählung | | Bereich | Env | Default | Zählung |
+67
View File
@@ -0,0 +1,67 @@
# Craftvia – Testphase & Onboarding (Betrieb)
Stand: 2026-09-16 · Lane L15 · Lane-Bericht: [lanes/testphase.md](lanes/testphase.md)
Craftvia lässt sich als zeitlich begrenzte Testversion anbieten – per öffentlicher Selbstanmeldung
(`/testen`) oder durch den Plattform-Admin (`/admin/trial`).
## 1. Lebenszyklus
| Phase | Zustand | Was gilt |
|---|---|---|
| Anmeldung | `TrialSignup.status = pending` | Nichts provisioniert. Bestätigungslink 24 h gültig, einmal verwendbar. |
| Testphase | `Tenant.plan = TRIAL`, `now < trialEndsAt` | Voll nutzbar. Banner „Testphase endet in X Tagen“ ab 7 Tagen vor Ende. |
| Abgelaufen | `now ≥ trialEndsAt` | **Nur lesen + Export.** Login, Lesen, Datei-Downloads, PDFs, Export bleiben möglich. |
| Löschung | `now ≥ deletionDueAt` (Standard: Ende + 30 Tage) | Täglicher Job löscht den Mandanten (Offboarding aller Mandanten-Tabellen + Speicher `<tenantId>/`), Mandant `ARCHIVED`. |
| Umgewandelt | `plan = FULL`, `convertedAt` | Nie Nur-Lesen, nie automatische Löschung. |
`trialEndsAt` ist das **exklusive** Ende: Beginn des Folgetags des gewählten Datums in Europe/Berlin.
Die Sperre hängt nur an diesem Zeitpunkt, nicht am Job – sie greift sekundengenau.
Plattform-Aktionen (Mandantendetail, Karte „Testphase“, jeweils mit Bestätigung und Plattform-Audit):
Enddatum ändern/verlängern (auch nach Ablauf → sofort wieder schreibbar, Erinnerungen neu geplant),
in Vollversion umwandeln, Testphase sofort beenden, Löschung vormerken (Ende + 30 Tage, frühestens in
7 Tagen) bzw. abbrechen. Nur Plattform-**Voll**-Admins; Mandanten-Admins haben keinen Weg dorthin.
## 2. Konfiguration
| Variable | Default | Bedeutung |
|---|---|---|
| `TRIAL_MAX_DAYS` | `30` | Längste selbst gewählte Testphase (1–365). Vorbelegung im Wizard: heute + 14 (höchstens `TRIAL_MAX_DAYS`). Der Plattform-Admin darf bis 365 Tage setzen. |
| `TRIAL_CONTACT_EMAIL` | leer | Kontakt in Banner und Testphasen-Mails („Vollversion oder Verlängerung: …“). Leer = allgemeiner Hinweis. |
Weitere Voraussetzungen: `APP_BASE_URL` (Links in Mails), funktionierender Mailversand (Double-Opt-in),
S3/Garage (Export-Dateien), `REDIS_URL` + `craftvia-worker` (Jobs). Rate-Limits der öffentlichen
Endpunkte sind fest (je App-Instanz, siehe `src/server/rate-limit.ts`): Anmeldung 5/h je IP und je
E-Mail, Bestätigung 20/15 min je IP, Schrittprüfung 120/15 min je IP.
## 3. Jobs (`npm run worker:craftvia`)
| Queue | Auslöser | Inhalt |
|---|---|---|
| `trial-lifecycle` | Job-Scheduler `trial-lifecycle-daily` (alle 24 h, beim Worker-Start angelegt) | Erinnerungen 7/3/1 Tage vorher, Ablaufmail, Löschhinweis 7 Tage vor Löschung, Löschung, Aufräumen alter Anmeldungen (7 Tage nach Link-Ablauf). Idempotent: jede Mail wird vor dem Versand über eine bedingte Aktualisierung „beansprucht“. |
| `tenant-export` | „Export erstellen“ unter `/settings/export` | ZIP (CSV + JSON + Dateien) nach `<tenantId>/uploads/…`, Download 7 Tage über `/settings/export/<id>` (Sitzung + `tenant:manage`). Ohne Redis läuft der Export inline. |
Manueller Lauf (z. B. nach Ausfall des Workers), im App- oder Worker-Container:
```bash
node --import tsx -e "import('./src/server/services/trial/lifecycle.ts').then(m => m.runTrialLifecycle()).then(console.log)"
```
Im Worker werden Jobs, die im Namen von Nutzern schreiben (`import-extraction`, `transcription`), für
abgelaufene Testmandanten übersprungen. PDFs/Vorschaubilder bereits gespeicherter Daten laufen weiter.
## 4. Schreibsperre – wo sie greift
- Server Actions der Module: `moduleGuard` (`src/server/action-guard.ts`) → `ServiceError("blocked", "trial_expired")`.
- `/api/v1`: `requireApiContext` sperrt jede nicht lesende Anfrage, die über `withApi` läuft (Sync, Uploads, alle Mutationen) → HTTP 422 `{ error: { code: "blocked", message: "trial_expired", details: { readOnly: true, message, deletionDueAt } } }`. Die Offline-Outbox behandelt 422 beim Sync-Batch als vorübergehend und wiederholt später.
- Routen außerhalb von `withApi`: Backoffice-Upload `/documents/upload`, `POST /api/v1/work-orders/[id]/documents` (explizit).
- Einstellungen, Nutzer-/Rollenverwaltung, Lotse-Einstellungen (EXEMPT-Actions, explizit).
- Nicht gesperrt: Login, Konto (eigenes Passwort, MFA, Sprache), Mandantenwechsel, Lesen, `/files/<id>`, Export, Plattform-Aktionen.
## 5. Datenschutz
- Anmeldung speichert Passwort nur als Argon2id-Hash (mit Pepper), Link-Token nur als SHA-256, IP nur als HMAC. Der Passwort-Hash wird nach Bestätigung/Ablauf aus der Anmeldung entfernt; Anmeldungen werden 7 Tage nach Link-Ablauf gelöscht.
- Enumeration-Schutz: Für bereits registrierte Adressen gleiche Antwort, aber Hinweis-Mail statt Link.
- Nutzungsbedingungen und Datenschutzhinweise unter `/testen/nutzungsbedingungen` bzw. `/testen/datenschutz` sind **Platzhalter** (Texte in `messages/<locale>/trial.json` → `legal.*`) und vor dem Go-live vom Betreiber zu ersetzen.
- Löschung über das bestehende DSGVO-Offboarding (Löschnachweis `DeletionCertificate`), zusätzlich Objektspeicher-Präfix `<tenantId>/`.
+103
View File
@@ -0,0 +1,103 @@
# Lane L16 – Lotse-Chat für Monteure (`lane/lotse-chat`)
Stand: 2026-09-15 · Basis `6b8cdf5` (`feature/craftvia-mvp`, L1–L14 integriert) · Spec §15, §27, §31 · Brandbook §4.3, §9, §11.4, §12 · ARCHITEKTUR §4.5, §4.8
Ziel: In der mobilen Web-App schreibt oder spricht der Monteur wie in einem Chat („Auftrag fertig, Speicher installiert, 2 Std., 1 Filter verbaut“). Der Lotse ordnet den Auftrag zu, fragt fehlende Angaben nach und schlägt konkrete Aktionen vor. **Er führt nichts selbst aus** – jede Änderung braucht den Tipp des Monteurs auf „Bestätigen“.
## 1. Umfang / erfüllte Auftragspunkte
| Auftrag | Umsetzung |
|---|---|
| Datenmodell | `LotseConversation` (Mandant, Nutzer, optionaler Auftragskontext, `lastMessageAt`), `LotseMessage` (Rolle `user`/`assistant`/`tool`, Text, `content` JSON mit Chips, Karten-IDs, Sprungzielen, Modell-Fassung), `LotseActionProposal` (Art, Nutzlast, HMAC-`payloadHash`, Status `proposed/confirmed/discarded/expired/failed`, `expiresAt` = +30 min, Ergebnis/Fehlercode, `confirmedAt/By`, `decidedAt`). `TenantSettings.lotseChatEnabled` (Default an). Alle Tabellen mit `tenant_id` + RLS, in beiden `TENANT_MODELS`, Personenfelder in `pii-fields.ts`. Verlauf nur für den eigenen Nutzer (jede Abfrage filtert `userId = ctx.userId`). |
| Aufbewahrung | `services/lotse/retention.ts` (bestehender Job `ai-retention`): Gespräche mit `lastMessageAt` älter als `AI_GENERATION_RETENTION_DAYS` werden samt Nachrichten und Karten gelöscht (Chatverlauf = Protokolldaten eines Nutzers), offene abgelaufene Karten → `expired`; Audit `lotse_chat_retention`. |
| Provider | `ai/lotse/chat-anthropic.ts`: ein Schritt der Tool-Use-Schleife über das Anthropic SDK (`beta.messages.create`, Modell `ANTHROPIC_MODEL`), Opus 5 ohne Sampling-Parameter mit `effort: "medium"`, serverseitiger Refusal-Fallback wie L9, Timeout 60 s. Denkblöcke des Modells werden innerhalb einer Schleife unverändert zurückgegeben; über Nachrichten hinweg nur Text. `ai/lotse/chat-fake.ts` spielt geskriptete Tool-Aufrufe ab und zeichnet jeden Modell-Input auf. Ohne Key: „Lotse ist nicht eingerichtet“ (Senden gesperrt, Verlauf sichtbar). |
| Schleife & Budget | `services/lotse/chat/engine.ts`: höchstens 6 Modellschritte je Nachricht, Tokengrenze je Nachricht (`LOTSE_CHAT_TURN_TOKEN_LIMIT`, Default 80 000), Monatskontingent des Mandanten vor dem ersten Schritt (`budget.ts`, `blocked budget_exceeded`). Überschreitung → Klartext-Hinweis statt Absturz. Jede Nachricht mit Modellaufruf = **eine** `AiGeneration` (Art `lotse_chat`, Input = exakt gesendete minimierte Turns + System-Prompt + Werkzeugnamen, Output = alle Runden, Tokens summiert). |
| System-Prompt | `ai/lotse/chat-prompt.ts`: Deutsch, erfahrener Kollege, knapp (≤ 3 Sätze), Anrede aus Lotse-Einstellung (neutral/Sie/du), nie ausführen/nie „gebucht“ behaupten, nie raten → `ask_user` mit Auswahl, L12-Regeln (Grund, Freigabe, 7 Tage), Platzhalter unverändert übernehmen, Werkzeuginhalte sind Daten. Lage: Datum/Uhrzeit in Mandanten-Zeitzone, Kontext-Auftrag, laufende Uhr. |
| Datenminimierung | Nutzertext: Muster Telefon/E-Mail/Anschrift + Namen der Mitarbeitenden (`minimize.ts#scrubText`); Suchwörter wie „Müller“ bleiben, damit die Zuordnung funktioniert. Werkzeugergebnisse: alle Freitexte des Auftrags (Titel, Beschreibung, Hinweise, Checkliste, Pflichtfotos) über `scrubText` mit den Literalwerten der betroffenen Aufträge (Kunde, Kontakte, Objekt, Mandant); Kundennamen, Objektnamen, Telefon, E-Mail, Anschrift und Ansprechpartner **nur als Platzhalter** `{{phone:A-00042}}`. |
| Platzhalter-Technik (Begründung) | Fragt der Monteur ausdrücklich („Telefonnummer vom Ansprechpartner?“), übernimmt das Modell das Token in die Antwort; `chat/placeholders.ts#renderPlaceholders` setzt **serverseitig** den Wert ein – mit derselben Rangfolge wie das Auftragsdetail und erneuter Scope-Prüfung (fremd/unsichtbar → „—“). So ist die Frage im Chat beantwortbar, ohne dass der Wert je das Modell erreicht; das Modell kann einen Wert, den es nie gesehen hat, weder weitergeben noch verfälschen. Für den nächsten Turn wird die Token-Fassung (`content.modelText`) gesendet. |
| Lese-Werkzeuge | `list_my_orders` (heute + bis 7 Tage, laufende), `get_order_details` (Status, Zeitfenster, Beschreibung, Hinweise, Objekt-Hinweise inkl. Zugang, Checkliste, Plan-/Zusatzmaterial, Pflichtfotos, eigene Uhr, verfügbare Platzhalter), `get_running_clock`, `check_completeness` (L9 `completeness.ts`, Sprungziele), `search_material` (Planpositionen + bisher verwendete Bezeichnungen im Scope), `ask_user` (Rückfrage mit Chips, beendet die Schleife). Alles mit Monteur-ctx im Scope von `visibility.ts`. |
| Vorschlags-Werkzeuge | `transition_work_order` (annehmen, Anfahrt, Arbeit starten, Pause, fortsetzen, technisch abschließen; Abschluss prüft Blocker vorab → keine Karte, Sprungziele), `book_time` (work/travel/return_travel/material_procurement, Dauer bis jetzt oder von–bis, Datum; Grund Pflicht; 7-Tage-/Zukunft-/16-h-/Überschneidungsprüfung vorab), `record_material` (Planposition per Name, Status aus Planmenge, Abweichung/Zusatzmaterial mit Grund; `validateMaterialUsage` von L4), `add_note` (Art + Text), `suggest_report_fields` (nur mit offenem Bericht). Fehlende Pflichtwerte → `missing_values`, **keine Karte**. |
| Auftragszuordnung | `chat/orders.ts#resolveOrder`: Suchbegriff (Nummer auch als Ziffern, Kunde, Objekt, Ort, Titel) > Kontext-Auftrag > Auftrag der laufenden Uhr > einziger heutiger Auftrag; mehrdeutig → `order_ambiguous` + Auswahl-Chips (Beschriftung mit Kunde nur für den Monteur, Wert „Auftrag A-00042“). |
| Bestätigen | `chat/confirm.ts`: Eigentümer (sonst `not_found`), Status `proposed`, Ablauf (→ `expired`), HMAC-Hash (→ `failed tampered` + Audit `denied`), Bearbeiten nur für freigegebene Felder (Auftrag/IDs fix, Hash neu). Ein atomares Row-Update beansprucht die Karte (Doppeltipp/parallel → `idempotent`), danach Ausführung **über die bestehenden Services** mit Monteur-ctx: `transitionWorkOrder`, `startSession/pauseSession/resumeSession/endSession` (inkl. L12-Auto-Wechsel, auf der Karte angekündigt), `addManualTimeEntry` (eigene Zeit → `pending`, Freigabepflicht), `upsertMaterialUsage`, `createNote`, Berichtsvorschlag in `content.lotse` (L9-Mechanismus: im Bericht übernehmen/verwerfen, Prüfbestätigung beim Absenden). Service-Fehler → Karte `failed` mit Klartext-Code + Lotse-Hinweis mit Sprungzielen (Pflichtfoto, Checkliste, Unterschrift, Meine Zeiten, Material, Bericht). Audit `lotse_action_proposal` before/after mit `source: "lotse_chat"`. „Alle bestätigen“: feste Reihenfolge Zeit → Material → Notiz → Bericht → Status, Stopp beim ersten Fehler mit Hinweis „N Karten noch offen“. |
| Keine äußere Transaktion (Entscheidung) | Die Services öffnen eigene Transaktionen und senden Benachrichtigungen (Mail). Eine umschließende Transaktion hätte die DB-Transaktion über den Mailversand offen gehalten (5-s-Timeout, im Gate beobachtet). Deshalb: Karte atomar beanspruchen, Abschluss-Blocker vor jeder Änderung prüfen, dann Services einzeln. |
| UI mobil | `/m/lotse` (Verlauf, Eingabe, Senden, „Neuer Chat“, Mikrofon-Taste: Aufnahme ≤ 2 min → `POST /api/v1/lotse/transcribe` → Transkript im Eingabefeld editierbar; ohne Transkriptionsanbieter deaktiviert mit Hinweis). Aktionskarten mit klaren Werten (z. B. „Arbeitszeit · Auftrag A-00042 · Di., 15.09. · 13:05–15:05 Uhr · 2:00 Std. · Grund … · Zählt nach Freigabe“), Bestätigen/Bearbeiten/Verwerfen, Status als Text + Icon, „Gültig bis“. Chips nur an der letzten Antwort, Sprungziele als Liste. Offline: Hinweis, Eingabe bleibt (auch `localStorage`), nichts wird gesendet/ausgeführt. Einstieg: Bottom-Navigation „Lotse“ (nur wenn nutzbar) und „Lotse fragen“ im Auftragsdetail (Kontext). Touch ≥ 48 px, Farben nur Tokens, Lotse-Mark in Signalorange, Signalorange sonst nur an Primärknöpfen/offenen Karten. |
| Einstellungen | `/settings/lotse`: Schalter „Lotse-Chat für Monteure“ (wirkt nur bei eingeschaltetem Lotse), Datenfluss um Chat und Spracheingabe ergänzt, KI-Protokoll zeigt Art „Lotse-Chat“ (Transkriptionen der Spracheingabe: Art `transcription`, `entityType lotse_chat`, ohne Inhalt). |
## 2. Routen / Screens
| Route | Rolle | Inhalt |
|---|---|---|
| `/m/lotse` | Monteur, Teamleiter (`lotse:use` + `field:execute`) | Chat ohne Auftragsbezug; ausgeschaltet → Hinweisseite |
| `/m/lotse?order=<id>` | dito | Chat mit Auftragskontext; fremder/unsichtbarer Auftrag → 404 |
| `/m/orders/[id]` | dito | Button „Lotse fragen“ unter der Vollständigkeits-Karte |
| Bottom-Navigation | dito | Eintrag „Lotse“ (6 Einträge, nur wenn Chat nutzbar) |
| `/settings/lotse` | Mandantenadministrator | Schalter + Datenfluss |
| `POST /api/v1/lotse/transcribe` | dito | multipart `file` → `{ text }` (Audio wird nicht gespeichert), OpenAPI ergänzt |
Server Actions (`actions/lotse/chat.ts`, `moduleGuard("lotse")` + `lotse:use`, `field:execute`): senden, bestätigen (optional mit Änderungen), verwerfen, alle bestätigen, neuer Chat – jeweils mit frischer Chat-Ansicht als Ergebnis.
## 3. Dateien
**Neu (Ownership L16 / Lotse-Pfade)**
- `prisma/migrations/20260916200000_lotse_chat/migration.sql`
- `src/lib/lotse/chat.ts` (client-safe: Kartenarten, Zod-Schemas, Ansichtstypen)
- `src/server/ai/lotse/{chat-types,chat-prompt,chat-fake,chat-anthropic}.ts`
- `src/server/services/lotse/chat/{access,orders,placeholders,proposals,tools,engine,conversations,confirm,transcribe}.ts`
- `src/server/actions/lotse/{chat,_chat-state}.ts`, `src/app/api/v1/lotse/transcribe/route.ts`
- `src/app/(field)/m/(core)/lotse/{layout,page}.tsx`
- `src/components/lotse/chat/{lotse-chat,proposal-card,mic-button,ask-button}.tsx`
- `scripts/test-lotse-chat-engine.ts`, `scripts/test-lotse-chat-confirm.ts`, `scripts/test-lotse-chat-live.ts`, `scripts/lib/lotse-chat-fixture.ts`
**Geändert in Lotse-Pfaden (erlaubt laut Auftrag)**
- `src/server/services/lotse/{settings,retention}.ts`, `src/server/actions/lotse-settings.ts`, `src/app/(app)/settings/lotse/page.tsx`, `messages/{de,en}/lotse.json`
**Fremdeingriffe (je klein, markiert „L16“)**
- `prisma/schema.prisma` (3 Modelle, 2 Enums, 1 Spalte) · `src/server/db.ts` + `src/server/backup/topology.ts` (TENANT_MODELS) · `src/server/dsgvo/pii-fields.ts` (3 Felder)
- `src/components/field/bottom-nav.tsx` (Eintrag + Prop `lotse`), `src/app/(field)/m/layout.tsx` (Flag `lotseChat`), `messages/{de,en}/field.json` (`nav.lotse`)
- `src/app/(field)/m/(core)/orders/[id]/page.tsx` (Import + 1 Zeile Button)
- `src/lib/api/openapi.ts` (Pfad `/lotse/transcribe` – `test-betrieb-api` verlangt jede Route im Dokument)
- `src/components/audit-trail.tsx` (Entity-Labels)
- `scripts/test-e2e-tenant-isolation.ts` (je eine Zeile für die 3 neuen Tenant-Modelle – der Test verlangt vollständige Abdeckung aller TENANT_MODELS)
- `scripts/smoke-auth.ts` (Monteur-/Admin-Prüfungen)
Keine Änderung an `rbac.ts` (bestehende Rechte `lotse:use` + `field:execute` + `field:record_own_time`/`report:write` genügen), keine neuen npm-Abhängigkeiten, keine Stubs.
## 4. Schema / Migration
`20260916200000_lotse_chat` (additiv): Enums `LotseMessageRole`, `LotseProposalStatus`; Tabellen `lotse_conversations`, `lotse_messages`, `lotse_action_proposals` (Kaskade von Gespräch zu Nachrichten/Karten), `tenant_settings.lotse_chat_enabled BOOLEAN DEFAULT true`; `SELECT enable_tenant_rls(...)` für alle drei Tabellen. **Begründung:** Chatverlauf und bestätigungspflichtige Vorschläge brauchen persistente, nutzerbezogene Datensätze mit Ablauf, Ergebnis und Audit-Bezug – weder `AiGeneration` (Protokoll, wird pseudonymisiert) noch Report-`content` passen. `workOrderId` ohne Fremdschlüssel (weiche Referenz, Auftrag kann soft-gelöscht werden; Sichtbarkeit wird bei jedem Lesen neu geprüft). Deploy: `prisma migrate deploy` (keine Rechte-Synchronisation nötig).
## 5. Tests
| Skript | Prüfungen | Ergebnis |
|---|---|---|
| `test-lotse-chat-engine.ts` | 75: System-Prompt, Suchwörter; Zuordnung Kontext > laufende Uhr, Suchbegriff Kunde/Objekt/Nummer, Mehrdeutigkeit → Chips (Beschriftung mit Kunde, Wert ohne Namen), `ask_user` beendet Schleife; vier Karten aus einer Nachricht ohne jede Änderung an Zeiten/Material/Notizen/Status; fehlende Pflichtwerte (Grund, Dauer, Zusatzmaterial-/Mindermengen-Grund) → keine Karte; Überschneidung mit laufender Uhr, Pflichtfoto-Blocker mit Sprungziel, kein offener Bericht → keine Karte; **Datenminimierung**: Modell-Input ohne Telefon/E-Mail/Anschrift/PLZ/Kunden-, Kontakt-, Firmen-, Mitarbeiternamen/Mandantenkontakt, Platzhalter-Token vorhanden, fachliche Hinweise erhalten, Wert serverseitig eingesetzt, AiGeneration minimiert; Scope (Monteur ohne Zuweisung → not_found / Auftrag nicht gefunden), fremder Verlauf im selben Mandanten und aus Mandant B → not_found, B findet A-Aufträge nicht, ohne `lotse:use` → forbidden; Chat-Schalter aus / Modul aus → `blocked`, kein Modellaufruf, Audit; ohne Anbieter → not_configured; Monatskontingent → `budget_exceeded`; Rundengrenze; Tokengrenze; Anbieterfehler → Hinweis; Protokoll zeigt `lotse_chat`; Aufbewahrung löscht Verläufe nur des Mandanten | grün |
| `test-lotse-chat-confirm.ts` | 52: Annehmen/Arbeit starten über Services (Statuswechsel, Session, Akteur), Doppeltipp idempotent, Audit mit Quelle; Zeitnachtrag `pending` mit Grund, Bearbeiten (nur erlaubte Felder, Hash aktualisiert, Audit `edited`), Überschneidung → `failed overlap`; Material, Notiz (paralleler Doppeltipp → genau eine Notiz); Abschluss mit fehlendem Pflichtfoto → `failed blocked`, Auftrag + Session unverändert, Sprungziel; erneutes Bestätigen → `already_decided`; Ablauf nach 30 min; fremder Monteur/Admin/Mandant B → not_found; manipulierte Nutzlast → `tampered`; Verwerfen (idempotent, danach nicht bestätigbar); Monteur ohne Zuweisung → Service verweigert; „Alle bestätigen“ Reihenfolge + Stopp + zweiter Lauf; Berichtsvorschlag in `content.lotse` und Übernahme per L9; Chat aus / Modul aus → blocked | grün |
| `test-lotse-chat-live.ts` | optional gegen Claude, nur mit `ANTHROPIC_API_KEY` | übersprungen (kein Key) |
Angepasst: `test-e2e-tenant-isolation.ts` (Fixture um die 3 Tenant-Modelle ergänzt) – grün.
**Gate:** `npm run gate` grün – prisma generate, tsc, lint (0 Fehler, 3 bestehende Warnungen außerhalb L16), build inkl. Modul-Guard-Check (37 Action-Dateien), **79/79 Testskripte**. Im ersten Lauf schlugen `test-e2e-tenant-isolation` (fehlende Fixture-Zeilen, behoben) und `test-einsatz-field` fehl (Transaktions-Timeout 5 s beim Mailversand innerhalb einer L12-Session-Transaktion unter Last paralleler Lanes; einzeln grün, im zweiten Gate grün – bestehendes Verhalten, siehe L12-Bericht §5.5).
**RLS-Lauf** `RLS_ENFORCED=true` (Lane-DB `craftvia_lotsechat`, Rolle `craftvia_app`): `test-lotse-chat-engine`, `test-lotse-chat-confirm`, `test-e2e-tenant-isolation`, `test-tenant-isolation`, `test-rls-enforcement` – 5/5 grün.
**HTTP-Smoke** (`npx next start -p 3116`, `scripts/smoke-auth.ts`, Demo-Seed): Monteur **37/37** grün (neu: `/m/lotse` mit „Lotse-Chat“, „Neuer Chat“, „Sprechen“; `/m/lotse?order=<DEMO-07>` mit Auftragsnummer; Auftragsdetail mit „Lotse fragen“; `/m/lotse?order=<fremder Auftrag DEMO-08>` → 404), Admin **12/12** grün (`/settings/lotse` mit „Lotse-Chat für Monteure“, Protokoll). Server danach beendet.
Visuelle Browserprüfung nicht durchgeführt (Anmeldung hätte Passwort- oder Token-Eingabe durch den Agenten erfordert).
## 6. Stubs & Abhängigkeiten
Keine Stubs. Genutzt: L2 `transitionWorkOrder`/`computeCompletionBlockers`/`workOrderScope`, L4 `requireFieldOrder`/`createNote`/`upsertMaterialUsage`/`validateMaterialUsage`/`sniffMime`, L12 `startSession`/`pauseSession`/`resumeSession`/`endSession`/`getMyActiveSession`/`addManualTimeEntry`/`earliestStart`, L5 `requireVisibleReport`/`contentOf`, L9 `checkCompleteness`/`scrubText`/`lotseVoice`/`isLotseEnabled`/Transkriptionsanbieter, L10b `assertTokenBudget`/`requireApiContext`/Aufbewahrungsjob.
## 7. Bekannte Lücken / Hinweise an den Architekten
1. **Live-Verhalten mit Claude ungeprüft** (kein Key): Tool-Definitionen, Prompt und Denkblock-Rückgabe sind typgeprüft und gegen den Fake getestet; `scripts/test-lotse-chat-live.ts` läuft, sobald ein Key gesetzt ist. Prompt-Feinschliff (Tonalität, Rückfragen) nach ersten echten Gesprächen empfohlen.
2. **Kein `/api/v1` für Senden/Bestätigen** – die mobile UI nutzt Server Actions (wie L9); nur die Transkription ist eine API-Route (Upload > Server-Action-Limit). Für native Clients ggf. nachziehen.
3. **Doppeltipp während der Ausführung:** Der zweite Tipp erhält sofort `confirmed (idempotent)`, auch wenn die Ausführung danach noch scheitert; die Karte zeigt nach dem Neuladen `failed`.
4. **Abschluss nicht vollständig atomar:** Blocker werden vorab geprüft; scheitert der Statuswechsel danach aus anderem Grund (z. B. parallele Änderung), bleibt die eigene Uhr beendet – wie beim bestehenden Zwei-Schritt-Flow der Primäraktion.
5. **Audit der Fachentitäten ohne Quelle:** Die Services schreiben ihre Audits unverändert; die Verbindung zur Chat-Karte steht im Audit `lotse_action_proposal` (Ergebnis-IDs, `source: "lotse_chat"`). Ein `source`-Feld im Fundament-Audit wäre sauberer.
6. **Gespeicherte Antworten enthalten eingesetzte Werte** (z. B. Telefonnummer), sichtbar nur für den Nutzer, gelöscht mit der Aufbewahrung. Das Modell sieht sie nie.
7. **Namen im Freitext des Monteurs** werden (außer Mitarbeitenden) nicht ersetzt, damit die Suche „Objekt Müller“ funktioniert; Telefon/E-Mail/Anschrift schon.
8. **Zeit „2 Std.“ über Mitternacht** → Rückfrage nach der Uhrzeit (kein Raten des Vortags).
9. **Verlauf ans Modell:** letzte 20 Nachrichten als Text + Kartenstatus; Werkzeugergebnisse früherer Nachrichten werden nicht erneut gesendet (Kosten, Datenminimierung).
10. **Bottom-Navigation mit 6 Einträgen** (~62 px je Eintrag bei 375 px) – bei weiteren Einträgen Profil/Sync zusammenlegen.
11. Offline gibt es bewusst keine Warteschlange für Chat-Nachrichten (Eingabe bleibt lokal erhalten).
+102
View File
@@ -0,0 +1,102 @@
# Lane L17 – Pakete (Basis/Profi) & Lotse-Chat pro Nutzer (`lane/pakete`)
Stand: 2026-09-21 · Basis `4d5be72` (`feature/craftvia-mvp`) · Betriebsdoku: [../DEPLOY.md §7.5](../DEPLOY.md)
Ziel: Zwei Pakete durchsetzen und den Lotse-Chat als Zusatzmodul pro Nutzer mit gemeinsamem Kontingent führen. **Keine Abrechnung in der App** – die App setzt Pakete durch, verwaltet Plätze und zählt Nutzung; der Betreiber stellt die Rechnung außerhalb.
## 1. Umfang / umgesetzte Entscheidungen
| Punkt | Umsetzung |
|---|---|
| **1 Datenmodell** | Migration `20260921100000_pakete` (additiv): `enum TenantTier { BASIS PROFI }`, am `Tenant` `tier` (Default `PROFI` → alle Bestandsmandanten und Testphasen unverändert), `lotseChatSeats Int @default(0)`, `lotseChatHardLimit Boolean @default(false)` (Plattform-Daten, keine RLS). Neue Mandanten-Tabelle `lotse_chat_seats` (`LotseChatSeat`: tenantId, userId, assignedById, createdAt; unique tenantId+userId) mit `enable_tenant_rls`, in beiden `TENANT_MODELS`, `userId`/`assignedById` in `pii-fields.ts`. Index `lotse_messages(tenant_id, role, created_at)` für die Monatszählung. |
| **2 Zentrale Regeln** `src/lib/plans.ts` (client-sicher, rein) | Stufen, `PROFI_ONLY_MODULES = billing, emergency, imports, lotse`, Features ohne eigenes Modul `planning`, `lotse_chat`; `effectiveTier` (Testphase → Profi), `isModuleInTier`, `isFeatureInTier`, `LOTSE_CHAT_CHATS_PER_SEAT = 150`, `LOTSE_CHAT_OVERAGE_PACK = 100`, `chatQuota`, `chatOverage`, `overagePacks` (angefangene Pakete zählen), `usageLevel` (ok / warn ab 80 % / exhausted / over), `usagePercent`, `isQuotaBlocked`, Monatsschlüssel. |
| **3 Durchsetzung an einer Stelle** `src/server/plan.ts` | `moduleState(tenantId, key)` = `not_in_plan` (Stufe) → `disabled` (TenantModule) → `enabled`; `effectiveModules` (Navigation/Übersichten), `isModuleActive`, `assertPlanFeature`/`requirePlanFeature` (Planung). Genutzt von **`requireModule`** (Layouts: nicht im Paket → `/dashboard?module=profi` mit ruhigem Hinweis „Im Paket Profi enthalten“), **`assertModuleEnabled`** (moduleGuard + `requireApiContext`; `ModuleDisabledError.reason = "not_in_plan"`, Audit `denied`, API → `403` mit `details.reason = "not_in_plan"` + Klartext), **Sync** (`services/sync/apply.ts#OP_MODULES`: `emergency.create` → emergency, `milestone.reach` → billing; gesperrt → gespeichert `rejected`/`forbidden` mit `not_in_plan: <Klartext>` bzw. `module_disabled: …`, Gerät zeigt eigenen Text `offline.problem.notInPlan|moduleDisabled`), **Navigation** (Backoffice-Sidebar über `effectiveModules` + `NavItem.feature`, mobile Bottom-Navigation ohne „Notdienst“/„Lotse“), **`isLotseEnabled`** (Berichtsentwurf, Vollständigkeitsprüfung, Sprachnotiz-Zusammenfassung, Transkription), **Planung** (`planningAccess` → Plantafel, Live-Lage, Kapazität, Empfehlungen, Frühfertig, Dashboard-Kacheln; zusätzlich `scheduleWorkOrder`, `updateTeamPlanningSettings`, `runPlanningWatch` überspringt Basis; Planungs-Layout `requirePlanFeature`). Abrechnungs-Schalter (`billingModuleActive`, Auftragsdetail) laufen ebenfalls über `plan.ts` – Basis verhält sich wie „Abrechnungsübersicht deaktiviert“ (direktes „abgerechnet“, Tab ausgeblendet). |
| **4 Lotse-Chat-Plätze** | `services/lotse/chat/access.ts` ist die eine Prüfstelle: Rechte → Stufe (`blocked not_in_plan`) → Lotse an (`disabled`) → Chat-Schalter (`chat_disabled`) → Platz (`blocked no_seat`, Testphase ohne Platzprüfung) → beim **Senden** Kontingent mit hartem Limit (`blocked quota_exhausted`). `canUseLotseChat` (Navigation, „Lotse fragen“) schließt den Platz ein. `seats.ts`: berechtigt = aktive Nutzer mit `lotse:use` + `field:execute` (DB-Rollen), Vergabe/Entzug `setChatSeat` (tenant:manage, Zod, Audit `lotse_chat_seat` before/after, idempotent, serialisiert über eine Zeilensperre der Mandanten-Einstellungen → parallele Vergaben überschreiten die Grenze nicht). Gültig sind die ältesten `lotseChatSeats` Zuweisungen (Überbelegung nach Senkung durch den Betreiber sichtbar). `usage.ts`: Chats im Monat gesamt und je Nutzer (Kalendermonat in der Mandanten-Zeitzone, DST-sicher), Kontingent = gezählte Plätze × 150 (Testphase: Berechtigte × 150, kein hartes Limit), Mehrverbrauch, 100er-Pakete, Stufe, Sperre. Die Token-Obergrenze (`budget.ts`) bleibt unverändert als Sicherheitsnetz. |
| **5 Mandanten-Admin** `/settings/lotse` | Neue Karte „Paket & Lotse-Chat“: Paket nur lesend (Badge, Testphase-Hinweis), „x von y Plätzen vergeben“, Liste aller Nutzer mit Feldrolle mit Schalter je Nutzer (Button `role="switch"`, ≥ 44 px, Status als Text + Icon, deaktiviert ohne freien Platz – serverseitig ohnehin geprüft), Monatsverbrauch als Balken (`role="meter"`) mit Text „212 von 450 Chats“, Hinweis ab 80 %, bei ausgeschöpftem Kontingent / Mehrverbrauch (inkl. Paketzahl) / hartem Limit, Aufschlüsselung je Nutzer. Basis: Hinweis, Plätze bleiben gespeichert. Action `actions/lotse/seats.ts` (`moduleGuard("lotse")` + `guard("tenant:manage")`). |
| **6 Mobil** | Ohne Platz kein Eintrag „Lotse“ in der Bottom-Navigation und kein „Lotse fragen“; `/m/lotse` zeigt „Der Lotse-Chat ist für dich noch nicht freigeschaltet – frag im Büro nach.“ Basis: Hinweis statt Weiterleitung (Lotse-Layout). Hartes Limit erreicht: Hinweis statt Eingabefeld (Verlauf bleibt lesbar, Chips deaktiviert); auch nach einer abgewiesenen Nachricht. |
| **7 Betreiber** `/admin/[id]` | Karte „Paket & Lotse-Chat“: Stufe, Plätze vergeben/gebucht, hartes Limit, Chats laufender Monat und Vormonat („x von y Chats“), Mehrverbrauch → 100er-Pakete (Grundlage der Rechnung). „Paket ändern …“ (nur Voll-Admins) → Popup mit Stufe, Platzanzahl (0–1000), hartem Limit und Pflicht-Bestätigung; `services/plans/platform.ts#updateTenantPlan` prüft den Akteur gegen den PlatformAdmin-Store, Zod, Plattform-Audit `tenant_plan` (before/after). Mandantenliste: Spalte „Paket“ als Badge; Modul-Popup markiert Module außerhalb der Stufe („Nur im Paket Profi“). |
| **8 Demo-Seed** | `demo` = Profi mit 3 Plätzen für Max, Nora, Paul (Monteure; Chat funktioniert weiter), `demo2` = Basis (zeigt die Sperren). Idempotent (`seedDemoPlan`). |
| **9 i18n/Doku** | Namespace `plans` (de/en), Ergänzungen in `lotse.json` (`chat.errors.not_in_plan/no_seat/quota_exhausted`, `chat.quotaExhausted`) und `offline.json` (`problem.notInPlan/moduleDisabled`); `DEPLOY.md §7.5` (wie der Betreiber Pakete/Plätze setzt, wie Mehrverbrauch abgelesen wird), `API.md` (403-Details, Sync-Abweisung). |
### Entscheidungen
- **Admin als Platzinhaber:** Berechtigung = Rechte `lotse:use` + `field:execute` (nicht der Rollenname). Der Mandantenadministrator hat alle Rechte und kann sich daher selbst einen Platz geben; Backoffice nicht.
- **Kontingent „je vergebenem Platz“:** zählt vergebene Plätze, höchstens die gebuchte Anzahl.
- **Sync-Abweisung ist endgültig** (gespeichert, wie andere deterministische Ablehnungen). Nach einem Upgrade muss der Monteur den Notdienst erneut erfassen.
- **Hartes Limit gilt nicht in der Testphase** (keine gebuchten Plätze; Token-Budget bleibt).
- **Basis sperrt auch die Platzvergabe** (moduleGuard `lotse`), Plätze bleiben gespeichert.
## 2. Dateien
**Neu**
- `prisma/migrations/20260921100000_pakete/migration.sql`
- `src/lib/plans.ts`, `src/server/plan.ts`
- `src/server/services/lotse/chat/{seats,usage}.ts`, `src/server/services/plans/platform.ts`
- `src/server/actions/lotse/seats.ts`, `src/server/actions/plans-platform.ts`
- `src/components/plans/{profi-hint,plan-admin-card,plan-form}.tsx`, `src/components/lotse/chat/{seat-admin,unavailable}.tsx`
- `messages/{de,en}/plans.json`, dieser Bericht
- Tests: `scripts/test-pakete-{rules,gates,seats}.ts`
**Eingriffe in Fundament-/Fremddateien (klein, markiert „L17“)**
| Datei | Eingriff | Grund |
|---|---|---|
| `prisma/schema.prisma` | 3 Tenant-Felder, Enum, Modell `LotseChatSeat`, Index an `LotseMessage` | Punkt 1 |
| `src/server/db.ts`, `src/server/backup/topology.ts`, `src/server/dsgvo/pii-fields.ts` | `LotseChatSeat` | Pflicht neue Tenant-Tabelle |
| `src/server/modules.ts` | `requireModule`/`assertModuleEnabled` über `moduleState`; `ModuleDisabledError.reason` | zentrale Durchsetzung |
| `src/server/api/respond.ts` | 403 mit `details.reason = not_in_plan` | Klartext für Clients |
| `src/lib/nav.ts`, `src/app/(app)/layout.tsx` | `NavItem.feature`, `lockedFeatures`; effektive Module | Navigation |
| `src/app/(app)/dashboard/page.tsx` (L2) | Hinweis `?module=profi`, Kacheln Abrechnung/Notdienst nur bei aktivem Modul | ruhiger Hinweis |
| `src/app/(app)/settings/page.tsx` | Modulübersicht über effektive Module | Anzeige |
| `src/app/(app)/work-orders/[id]/page.tsx`, `src/server/services/work-orders/transition.ts` (L2/L14) | `billingModuleActive` über `plan.ts`, Tab „Meilensteine & Abrechnung“ nur bei aktiver Abrechnung | Basis = Abrechnung aus |
| `src/server/services/planning/{access,schedule,team-settings,watch}.ts`, `src/app/(app)/planning/layout.tsx` (L13) | Feature-Prüfung `planning` | Planung nur Profi |
| `src/server/services/sync/apply.ts` (L4), `src/lib/offline/outbox-core.ts` (L7), `messages/{de,en}/offline.json` | `OP_MODULES` + Klartext; `problemKey` | Sync-Sperre |
| `src/app/(field)/m/layout.tsx`, `src/components/field/bottom-nav.tsx` (L4) | Notdienst-Eintrag nur bei aktivem Modul, Spaltenzahl dynamisch | mobile Navigation |
| `src/server/services/lotse/{settings.ts,chat/access.ts,chat/engine.ts,chat/transcribe.ts}`, `src/lib/lotse/chat.ts`, `src/server/actions/lotse/_chat-state.ts`, `src/components/lotse/chat/lotse-chat.tsx`, `src/app/(field)/m/(core)/lotse/{layout,page}.tsx`, `src/app/(app)/settings/lotse/page.tsx`, `messages/{de,en}/lotse.json` (L9/L16) | Stufe in `isLotseEnabled`, Platz/Kontingent im Chat-Gate, Hinweise, Admin-Karte | Punkte 4–6 |
| `src/app/(platform)/admin/page.tsx`, `admin/[id]/page.tsx` | Badge-Spalte, Karte, Modul-Popup-Markierung | Punkt 7 |
| `src/components/audit-trail.tsx` | Labels `lotse_chat_seat`, `tenant_plan` | Audit-Viewer |
| `scripts/check-module-guards.ts` | `plans-platform.ts` EXEMPT | Top-Level-Action |
| `scripts/lib/demo-seed.ts` | `seedDemoPlan`, demo Profi + 3 Plätze, demo2 Basis | Punkt 8 |
| `scripts/lib/e2e-fixture.ts`, `scripts/test-e2e-tenant-isolation.ts` | Cleanup + Fixture-Zeile `LotseChatSeat` | Test verlangt alle Tenant-Modelle |
| `scripts/lib/lotse-chat-fixture.ts` (L16) | 10 Plätze + Sitz je Testnutzer | bestehende Chat-Tests brauchen jetzt einen Platz |
| `scripts/smoke-auth.ts` | L17-Prüfungen (Admin-Karte, Teamleiterin ohne Platz, Monteur mit Platz, demo2 Basis) | Smoke |
| `docs/craftvia/{DEPLOY,API}.md` | §7.5, 403/Sync | Betrieb |
Keine neuen npm-Abhängigkeiten, keine Änderung an `rbac.ts`, keine neuen env-Variablen.
## 3. Tests
| Skript | Prüfungen | Inhalt |
|---|---|---|
| `test-pakete-rules.ts` | 51 | Stufen, Testphase = Profi, Module/Features je Stufe, Kontingent/Mehrverbrauch/100er-Pakete, Warnstufen, hartes Limit, Monatsschlüssel, Navigation Basis/Profi |
| `test-pakete-gates.ts` | 74 | Default Profi; Layout-Guard-Funktionen (`moduleState`, `requirePlanFeature` → Redirect `?module=profi`, Quelltextnachweis `requireModule`/Planungs-Layout); Action (`assertModuleEnabled` → `not_in_plan` + Audit) für billing/emergency/imports/lotse, Basis-Module frei; API 403 mit Klartext, Planungs-API 403; Planung (Plantafel, Live-Lage, Einplanen, Empfehlungen, Kapazität, Dashboard-Kachel, Watch) gesperrt / Profi erlaubt; `isLotseEnabled`, Abrechnungsschalter, Navigation; Sync: `emergency.create`/`milestone.reach` → rejected mit Klartext, gespeichert, kein Auftrag, Duplikat, Gerätetext; Profi → angewendet; TenantModule aus + Profi → gesperrt (Action, Sync); nur Betreiber ändert die Stufe (Mandanten-Admin, Nur-Lesen-Admin → forbidden, ungültige Werte → invalid, Mandanten-Einstellungen ändern keine Stufe); Wechsel Profi → Basis → Profi ohne Datenverlust mit Plattform-Audit; Mandantentrennung |
| `test-pakete-seats.ts` | 68 | berechtigte Nutzer; ohne Platz → `no_seat` (View, Senden, nichts gespeichert); Vergabe bis zur Grenze, darüber `invalid no_seats_left`, parallele Vergabe (genau eine), idempotent, Audit create/delete; mit Platz → ok/Senden; Entzug; Überbelegung nach Senkung; Feldrollen/Backoffice → forbidden (Vergabe, Übersicht, Verbrauch); ohne Feldrolle / deaktiviert → `not_eligible`; Mandantentrennung (B vergibt/entzieht nichts von A, sieht keine Plätze/Zählung); Monatsgrenze in Europe/Berlin inkl. Sommerzeit, Antworten zählen nicht, je Nutzer; Mehrverbrauch weiterzählen (305/300 → 1 Paket), hartes Limit → `quota_exhausted` ohne Speichern, Verlauf lesbar, B unberührt; Testphase ohne Platzprüfung (Kontingent Berechtigte × 150, kein hartes Limit); Basis sperrt Chat und Vergabe, Plätze bleiben; Betreiber-Übersicht laufender Monat/Vormonat |
**Gate (`npm run gate`) grün:** prisma generate, tsc, lint (0 Fehler; 3 bestehende Warnungen in fremden Dateien), build inkl. Guard-Check (42 Action-Dateien), **87/87 Testskripte** (vorher 84 + 3 neue). Bestehende Tests unverändert grün; angepasst wurde nur die L16-Chat-Fixture (Plätze).
**RLS-Lauf** `RLS_ENFORCED=true` (Lane-DB `craftvia_pakete`, Rolle `craftvia_app`): `test-pakete-gates`, `test-pakete-seats`, `test-e2e-tenant-isolation`, `test-rls-enforcement`, `test-lotse-chat-engine` – 5/5 grün.
**HTTP-Smoke** (`next build && next start -p 3117`, `scripts/smoke-auth.ts` ohne Passworteingabe): **152/155** – alle neuen L17-Prüfungen grün (Admin: Karte „Paket & Lotse-Chat“, „3 von 3 Plätzen vergeben“; Teamleiterin ohne Platz: kein Nav-Eintrag, Hinweis „frag im Büro nach“, kein „Lotse fragen“; Monteur mit Platz: Chat, Nav-Einträge Lotse/Notdienst; demo2 Basis: Planung/Abrechnung/Import/Notdienst-Prüfung → `/dashboard?module=profi` mit Hinweis und ohne gesperrte Links, `/api/v1/planning/board` und `/api/v1/billing` → 403). Die **3 Fehlschläge sind vorbestehend**: demo2-Seiten `/dashboard`, `/work-orders`, `/customers` enthalten „Elbblick“, weil `messages/*/marketing.json` (Produktseite, Commits `31343d3`/`4815a95`) den Beispieltext „Wohnanlage Elbblick“ enthält und die Messages an den Client serialisiert werden – kein Datenleck, aber die Isolationsprüfung schlägt an. Server danach beendet.
**Visuell:** nicht im Browser geprüft (Anmeldung hätte Passwort- oder Token-Eingabe erfordert). Bitte `/settings/lotse` (1024/768 px), `/admin/<id>` (Karte + Popup) und `/m/lotse` ohne Platz / bei hartem Limit (375 px) ansehen.
## 4. Offene Punkte
1. **Smoke-Isolationsprüfung „Elbblick“** (vorbestehend, s. o.): Beispieltext in `marketing.json` umbenennen oder die Prüfung auf Daten-Marker umstellen.
2. **Vormonat mit heutiger Platzzahl:** Die Betreiber-Karte rechnet das Kontingent des Vormonats mit den *aktuell* vergebenen Plätzen (keine Historie der Platzanzahl). Ändert sich die Platzzahl im Monat, ist der Vormonatswert eine Näherung → ggf. monatlichen Snapshot (Tabelle) nachziehen.
3. **Aufbewahrung:** Die Zählung basiert auf `LotseMessage`; `AI_GENERATION_RETENTION_DAYS` muss > ~62 Tage bleiben (Default 180), sonst fehlen Vormonats-Chats. Eine DSGVO-Löschung eines Nutzers entfernt auch seine Chats aus der Zählung.
4. **Hartes Limit unter Parallelität:** geprüft vor dem Speichern; gleichzeitige Nachrichten können das Kontingent um wenige Chats überschreiten.
5. **Worker:** `import-extraction` prüft die Stufe nicht (nur die Testphasen-Sperre). Nach einem Wechsel auf Basis laufen bereits eingereihte Importe noch durch; neue Importe sind gesperrt. Transkription ist über `isLotseEnabled` gesperrt.
6. **Abrechnungseinträge im Hintergrund:** Der L14-Event-Hook legt auch bei Basis weiter Abrechnungskandidaten an (unsichtbar, nach Upgrade vorhanden) – bewusst belassen („Daten bleiben erhalten“).
7. **Sync-Abweisung endgültig:** Eine im Basis-Paket offline erfasste Notdienst-Operation wird nach einem Upgrade nicht automatisch nachgeholt.
8. `services/emergency/sync-ops.ts` prüft den Modulschalter weiterhin selbst (redundant zur zentralen Sync-Prüfung, unverändert).
## 5. Screens / Routen
| Route | Zugriff | Inhalt |
|---|---|---|
| `/dashboard?module=profi` | Backoffice | Hinweis „Im Paket Profi enthalten“ (Ziel aller gesperrten Profi-Seiten) |
| `/settings/lotse` | `tenant:manage` | Karte „Paket & Lotse-Chat“: Paket, Plätze, Schalter je Nutzer, Monatsverbrauch |
| `/m/lotse` | Feldrollen | ohne Platz / Basis: Hinweis; hartes Limit: Hinweis statt Eingabefeld |
| `/admin` | Plattform | Spalte „Paket“ |
| `/admin/<id>?plan=edit` | Plattform-Voll-Admin | Karte „Paket & Lotse-Chat“ + Popup „Paket & Chat-Plätze ändern“ |
+1
View File
@@ -117,6 +117,7 @@ Nicht angefasst: `services/field/**`, `components/field/**`, `app/(field)/m/**`,
- **Smoke** `BASE=http://localhost:3114 npx tsx scripts/smoke-auth.ts` gegen den Produktions-Build (`next start -p 3114`): **OK — 111 Prüfungen** (alle Rollen). Neu: Backoffice `/planning` (Standard 5 Tage: `data-planning-view="days"`, `data-day-count="5"`, „Heute“, Team Nord + Team Süd, Seitenleiste), `?view=today` (Kolonnen als Spalten, „Ganztägig“), `?view=week` (7 Tage), Prefill `?schedule=`, `/planning/live` (Datenschutzhinweis, OSM-Namensnennung, keine Start-Koordinaten), `GET /api/v1/planning/{board,live,recommendations}` (live ohne `startLat/startLng/deviceInfo`), Dashboard „Planung heute“ + „Konflikte diese Woche“; Teamleiter `/planning` + `/planning/live` nur Team Nord, „Nur Lesezugriff“; Monteur `/planning*` → 404; Mandant demo2 ohne Demo-Teams/-Monteure, Empfehlungen für Demo-Auftrag → 404. Renderzeiten im Produktions-Build 30–170 ms. - **Smoke** `BASE=http://localhost:3114 npx tsx scripts/smoke-auth.ts` gegen den Produktions-Build (`next start -p 3114`): **OK — 111 Prüfungen** (alle Rollen). Neu: Backoffice `/planning` (Standard 5 Tage: `data-planning-view="days"`, `data-day-count="5"`, „Heute“, Team Nord + Team Süd, Seitenleiste), `?view=today` (Kolonnen als Spalten, „Ganztägig“), `?view=week` (7 Tage), Prefill `?schedule=`, `/planning/live` (Datenschutzhinweis, OSM-Namensnennung, keine Start-Koordinaten), `GET /api/v1/planning/{board,live,recommendations}` (live ohne `startLat/startLng/deviceInfo`), Dashboard „Planung heute“ + „Konflikte diese Woche“; Teamleiter `/planning` + `/planning/live` nur Team Nord, „Nur Lesezugriff“; Monteur `/planning*` → 404; Mandant demo2 ohne Demo-Teams/-Monteure, Empfehlungen für Demo-Auftrag → 404. Renderzeiten im Produktions-Build 30–170 ms.
- Hinweis Dev-Server: `next dev` im Worktree blieb beim zweiten Start beim Kompilieren von `/dashboard` hängen (Turbopack wählt wegen mehrerer Lockfiles das Hauptrepo inkl. aller Worktrees als Workspace-Root); der erste Dev-Lauf renderte alle Planungsseiten fehlerfrei, der finale Smoke lief daher gegen `next start`. - Hinweis Dev-Server: `next dev` im Worktree blieb beim zweiten Start beim Kompilieren von `/dashboard` hängen (Turbopack wählt wegen mehrerer Lockfiles das Hauptrepo inkl. aller Worktrees als Workspace-Root); der erste Dev-Lauf renderte alle Planungsseiten fehlerfrei, der finale Smoke lief daher gegen `next start`.
- **Beispieldaten für die Plantafel:** `npx tsx scripts/planning-demo.ts` plant relativ zu **heute** eine Woche für beide Kolonnen (hohe Auslastung, Überbuchung, Überschneidung, Mehrtagesauftrag, ungeplante Aufträge). Die Demo-Daten aus `demo-seed.ts` liegen relativ zum Seed-Tag und wandern sonst aus dem Standardzeitraum; `--reset` entfernt die Beispiele wieder.
- **Nicht durchgeführt:** visuelle Prüfung im Browser (Drag & Drop, Karte, Tagesansicht mit parallelen Kolonnen, 1024/768/375 px) – erfordert eine angemeldete Browsersitzung (Passwort-/Token-Eingabe durch den Agenten). Bitte manuell mit `backoffice@demo.example` prüfen. Demo-Objekte haben ohne Geocoding keine Koordinaten: `npx tsx scripts/geocode-backfill.ts --tenant=demo` (≈ 1 s je Objekt) oder Koordinaten am Objekt eintragen. - **Nicht durchgeführt:** visuelle Prüfung im Browser (Drag & Drop, Karte, Tagesansicht mit parallelen Kolonnen, 1024/768/375 px) – erfordert eine angemeldete Browsersitzung (Passwort-/Token-Eingabe durch den Agenten). Bitte manuell mit `backoffice@demo.example` prüfen. Demo-Objekte haben ohne Geocoding keine Koordinaten: `npx tsx scripts/geocode-backfill.ts --tenant=demo` (≈ 1 s je Objekt) oder Koordinaten am Objekt eintragen.
## 9. Bekannte Lücken / offene Punkte ## 9. Bekannte Lücken / offene Punkte
+108
View File
@@ -0,0 +1,108 @@
# Lane L15 – Testphase & Onboarding (`lane/testphase`)
Stand: 2026-09-16 · Basis `6b8cdf5` (`feature/craftvia-mvp`, L1–L14 integriert) · Betriebsdoku: [../TESTPHASE.md](../TESTPHASE.md)
## 1. Umfang / erfüllte Punkte
| Punkt | Umsetzung |
|---|---|
| **1 Datenmodell** | Migration `20260916100000_testphase`: `Tenant.plan` (`FULL`/`TRIAL`), `trialSource`, `trialStartedAt`, `trialEndsAt` (exklusives Ende = Beginn des Folgetags in Europe/Berlin), `convertedAt`, `readOnlySince`, `deletionDueAt`, `trialDeletedAt`, Versandmarker `trialReminder7/3/1At`, `trialExpiredNoticeAt`, `trialDeletionNoticeAt` (Plattform-Daten, keine RLS). `TrialSignup` (Plattform-Tabelle ohne `tenant_id`): Firmendaten, E-Mail, Argon2id-Hash (+ Pepper, wird nach Bestätigung/Ablauf geleert), Enddatum, Beispieldaten/Module, Token-**SHA-256**, Ablauf 24 h, IP-**HMAC**, Status. `TenantExport` (Mandanten-Tabelle, `enable_tenant_rls`, beide `TENANT_MODELS`, `requestedById` in `pii-fields.ts`). `TenantSettings.onboarding` (Checkliste). |
| **2 Öffentlicher Wizard** `/testen` | 5 Schritte (Betrieb · Admin-Konto · Testzeitraum · Einrichtung · Zusammenfassung), Fortschrittsanzeige, alle Werte in einem State (Zurück ohne Datenverlust), Validierung je Schritt im Browser **und** per Server-Action (`checkTrialStepAction`, gleiche Regeln aus `lib/trial/signup.ts`), finale Prüfung serverseitig. Branche: Auswahlliste + Freitext, Betriebsgröße optional, Passwort-Policy wie Identity, Enddatum per Datumsfeld (Vorbelegung heute + 14, erlaubt morgen … heute + `TRIAL_MAX_DAYS`, Anzeige „Testphase bis TT.MM.JJJJ (X Tage)“), Beispieldaten ja/nein, Module vorausgewählt (abwählbar), Pflicht-Checkboxen mit Platzhalter-Seiten `/testen/nutzungsbedingungen`, `/testen/datenschutz`. Double-Opt-in-Mail → `/testen/bestaetigen` zeigt die Anmeldung, **Einrichten erst per POST** (Mail-Scanner verbrauchen nichts) → `provisionTrialTenant` (tenant-admin, TRIAL, Ende des Tages Berlin, Löschung +30 Tage) → **direkt angemeldet** über den bestehenden `login-ticket`-Provider → `/dashboard?welcome=1` mit **„Erste Schritte“** (Firmendaten, Team, Monteur einladen, erster Auftrag, mobile App; automatisch erkannt oder abhakbar, ausblendbar). Missbrauchsschutz: Rate-Limit je IP und je E-Mail (neue Scopes `trialSignup`, `trialConfirm`, `trialStepCheck`), Honeypot, identische Antwort bei vorhandener E-Mail (+ Hinweis-Mail, gleicher Hash-Aufwand gegen Timing), Slug-Kollisionen (Reservierung per `create`, `-2 … -20`, Zufallssuffix), ältere Links derselben Adresse entwertet. |
| **3 Plattform-Admin** | `/admin/trial` „Testmandant anlegen“ (Firma, Branche, Admin, Enddatum bis 1 Jahr, Beispieldaten) → Einladung über `issueToken("invitation")` + `sendUserInvitationMail` (bestehende Person: nur verknüpft). Mandantenliste: Spalte „Version“ mit Badges „Test bis …“ / „abgelaufen – nur lesen“ / „Löschung am …“ / „gelöscht“ / „Vollversion“, Filter Alle/Test/Voll. Mandantendetail: Karte „Testphase“ mit Enddatum ändern/verlängern (auch nach Ablauf), umwandeln, sofort beenden, Löschung vormerken/abbrechen – jeweils Popup mit Bestätigungs-Checkbox, nur Voll-Admins, Audit (scope platform, am Mandanten) mit before/after. |
| **4 Nur-Lesen nach Ablauf** | `services/trial/state.ts#assertTenantWritable` → `ServiceError("blocked", "trial_expired", { readOnly, message, deletionDueAt })`, zeitgenau über `trialEndsAt`. Zentral in `moduleGuard` (alle Modul-Actions inkl. Lotse), `requireApiContext` für jede nicht lesende `/api/v1`-Anfrage (Methode aus `withApi`, AsyncLocalStorage → 422; Sync, Uploads eingeschlossen), explizit in `/documents/upload`, `POST /api/v1/work-orders/[id]/documents` (ohne `withApi`), Einstellungen, Nutzer-/Rollenverwaltung, Lotse-Einstellungen, Onboarding. Worker überspringt `import-extraction`/`transcription` abgelaufener Mandanten. Offen bleiben Login, Konto, Lesen, `/files`, PDFs, Export. Lesepfade, die `moduleGuard` nutzen (mobile Seitenkontexte, Import-Datei-Download), verwenden `moduleGuard(key, { read: true })`; der Guard-Check verbietet diesen Modus in Actions. Banner in Backoffice und mobiler App: „Testphase endet in X Tagen“ (ab 7 Tagen) bzw. „Testphase abgelaufen – nur Lesezugriff. Daten werden am … gelöscht.“ + Kontakt (`TRIAL_CONTACT_EMAIL`) + Export-Link (Admins). |
| **5 Export** | `/settings/export` (tenant:manage, auch im Nur-Lesen-Zustand): Worker-Job `tenant-export` baut ZIP mit `csv/` (Semikolon, BOM, Formel-Injektion entschärft) + `json/` für Kunden, Ansprechpartner, Objekte, Teams, Nutzer (ohne Secrets), Aufträge, Checklisten, Material (Vorgabe/Verbrauch), Einsätze, Zeiten, Notizen, Berichte, Unterschriften, Fotos, Dokumente, Meilensteine, Abrechnung + `dateien/` (alle gespeicherten Dateien inkl. Berichts-PDFs, Limit 500 MB) + `LIESMICH.txt`; Ablage über `storage.put` unter `<tenantId>/uploads/`, Download `/settings/export/<id>` (Session + DB-autoritatives `tenant:manage`, 7 Tage, Audit). Der DSGVO-/Backup-Export ist ein Betreiber-Werkzeug (alle Tabellen, JSON, Plattform-Portal) – wiederverwendet wurde sein ZIP-Writer (`backup/zip.ts`). |
| **6 Lebenszyklus-Job** | Queue `trial-lifecycle`, Job-Scheduler `trial-lifecycle-daily` (24 h) in `scheduleRecurringJobs`. Erinnerungen 7/3/1 Tage (verpasster Lauf → nur die nächstliegende, ältere als erledigt markiert), Ablaufmail, Löschhinweis 7 Tage vorher, Löschung an `deletionDueAt` über `dsgvo/deletion.ts#offboardTenant` (Topologie aller `TENANT_MODELS`, Identities ohne Rest-Mitgliedschaft, Löschnachweis) + Objektspeicher-Präfix `<tenantId>/` + Plattform-Audit. Jede Mail wird per bedingtem Update beansprucht (genau einmal, auch parallel). Doppelprüfung + Claim unmittelbar vor dem Löschen (TRIAL, nicht umgewandelt, abgelaufen, fällig → `SUSPENDED`, danach `ARCHIVED`). Anmeldungen werden 7 Tage nach Link-Ablauf gelöscht. |
| **7 i18n/Doku** | Namespace `trial` (de/en), `nav.dataExport`; Mail-Vorlagen `trial_confirm`, `trial_existing_account`, `trial_reminder`, `trial_expired`, `trial_deletion_notice` (de/en, `TRIAL_TEMPLATE_KEYS`). `docs/craftvia/TESTPHASE.md` (Lebenszyklus, env, Jobs, Sperre, Datenschutz), Abschnitte in `DEPLOY.md` (7.4) und `API.md`, env-Beispiele. |
### Entscheidungen
- **Beispieldaten:** `scripts/lib/demo-seed.ts` setzt sieben Nutzer mit festen Rollen sowie Foto-/PDF-/Import-Pipeline voraus. Ein Testmandant startet mit genau einem Admin → `services/trial/sample-data.ts` ist eine gekürzte Variante nach demselben Prinzip (ausschließlich echte Fachservices: 3 Kunden, 3 Objekte, „Beispielteam“, 4 Aufträge in Entwurf/Geplant/Zugewiesen/Prüfung; Kennung `BEISPIEL-`, zählt nicht für die Checkliste).
- **Provisionierung:** `provisionTenant` upsertet per Slug. Damit zwei gleichnamige Anmeldungen nie im selben Mandanten landen, reserviert `provisionTrialTenant` den Slug per `create` (inkl. Testphasen-Feldern) und ruft erst dann `provisionTenant` auf.
- **Sync im Nur-Lesen-Zustand:** Der Batch wird mit 422 abgewiesen, bevor eine Operation angewendet wird; die Outbox behandelt Nicht-OK beim Batch als vorübergehend und behält die Operationen (kein Datenverlust nach Verlängerung/Umwandlung). `services/sync/apply.ts` blieb unverändert.
- **Plattform-Wizard** als eine Seite mit vier nummerierten Abschnitten (Betreiber-Werkzeug, Einladung statt Passwort).
## 2. Dateien
**Neu**
- Migration `prisma/migrations/20260916100000_testphase/`
- `src/lib/trial/{dates,signup}.ts`
- `src/server/services/trial/{config,state,signup,abuse,provision,sample-data,admin,lifecycle,export,onboarding,mail,jobs,storage-purge}.ts`
- `src/server/actions/trial-{signup,platform,tenant}.ts`
- `src/server/jobs/processors/{trial-lifecycle,tenant-export}.ts`
- `src/app/testen/{layout,page}.tsx`, `src/app/testen/{bestaetigen,nutzungsbedingungen,datenschutz}/page.tsx`
- `src/app/(app)/settings/export/page.tsx`, `src/app/(app)/settings/export/[id]/route.ts`, `src/app/(platform)/admin/trial/page.tsx`
- `src/components/trial/{signup-wizard,confirm-form,legal-placeholder,trial-banner,getting-started,trial-badge,trial-admin-card,platform-forms}.tsx`
- `messages/{de,en}/trial.json`, `docs/craftvia/TESTPHASE.md`, dieser Bericht
- Tests/Smoke: `scripts/test-testphase-{signup,readonly,lifecycle}.ts`, `scripts/lib/testphase-fixture.ts`, `scripts/smoke-testphase.ts`
**Fundament-/Fremdeingriffe (laut Auftrag erlaubt, jeweils minimal)**
| Datei | Eingriff | Grund |
|---|---|---|
| `prisma/schema.prisma` | Tenant-Felder, Enum `TenantPlan`, `TenantSettings.onboarding`, Modelle `TrialSignup`, `TenantExport` | Punkt 1 |
| `src/server/provision.ts` | optional `admin.passwordHash`, `admin.mustChangePassword`, `modules`; Rückgabe um `adminUserId`, `identityId`, `identityCreated` ergänzt; Identity per `findUnique`+`create` statt `upsert` (Verhalten für Bestandsaufrufer gleich) | Hash aus der Anmeldung übernehmen, Modulauswahl, Einladung nur für neue Identity |
| `src/server/action-guard.ts` | `assertTenantWritable` nach Modul-Check; Option `{ read: true }` | zentrale Sperre; Lesepfade |
| `src/server/api/respond.ts` | `withApi` legt die Methode in AsyncLocalStorage ab (`currentApiMethod`, `isMutatingApiRequest`) | Sperre nur für nicht lesende Anfragen |
| `src/server/api/context.ts` | `assertApiWriteAllowed(tenantId)` in `requireApiContext` | zentrale API-Sperre |
| `src/app/(app)/documents/upload/route.ts`, `src/app/api/v1/work-orders/[id]/documents/route.ts` | je 1 Zeile `assertTenantWritable` | Upload-Routen ohne `withApi` |
| `src/server/actions/{tenant-settings,tenant-users,lotse-settings}.ts` | je 1 Zeile Sperre (+ Klartext in `tenant-users#actionError`) | Einstellungen/Nutzerverwaltung gesperrt |
| `src/server/services/field/page-context.ts`, `src/app/(field)/m/emergency/page.tsx`, `src/app/(app)/imports/[id]/file/route.ts` | `moduleGuard(key, { read: true })` | Lesepfade ohne Sperre (Smoke-Befund: sonst 500 auf `/m`) |
| `src/proxy.ts` | `/testen` in `PUBLIC_PATHS` | öffentliche Anmeldung |
| `src/server/rate-limit.ts` | Scopes `trialSignup`, `trialConfirm`, `trialStepCheck` | Missbrauchsschutz |
| `src/server/mail/templates.ts` | 5 Vorlagen de/en, `TRIAL_TEMPLATE_KEYS` | Mails |
| `src/server/jobs/{queues,processors/index}.ts`, `scripts/craftvia-worker.ts` | 2 Queues + Scheduler, 2 Registry-Zeilen, Sperrprüfung vor jedem Job | Punkt 5/6 |
| `src/app/(app)/layout.tsx`, `src/app/(field)/m/layout.tsx` | `<TrialBanner>` | Banner |
| `src/app/(app)/dashboard/page.tsx` (L2) | 1 Zeile `<GettingStarted>` | Checkliste im Dashboard |
| `src/app/(platform)/admin/page.tsx`, `admin/[id]/page.tsx` | Badge-Spalte, Filter, Button; Karte `TrialAdminCard` | Punkt 3 |
| `src/lib/nav.ts`, `messages/{de,en}/nav.json` | Eintrag „Datenexport“ | Einzeiler |
| `src/server/db.ts`, `src/server/backup/topology.ts`, `src/server/dsgvo/pii-fields.ts` | `TenantExport` | Pflicht bei neuer Tenant-Tabelle |
| `scripts/check-module-guards.ts` | 3 Einträge, Kategorie `PUBLIC` (jede Action braucht Rate-Limit), Verbot des Lese-Modus in Actions | Top-Level-Actions |
| `scripts/test-e2e-tenant-isolation.ts` (L10) | 1 Fixture-Zeile `TenantExport` | Test verlangt Abdeckung aller Tenant-Modelle |
| `.env.example`, `.env.prod.example`, `.env.coolify.example`, `docs/craftvia/{DEPLOY,API}.md` | Testphase-Abschnitte | Betrieb |
**Neue env-Variablen:** `TRIAL_MAX_DAYS` (Default 30), `TRIAL_CONTACT_EMAIL` (optional). Keine neuen npm-Abhängigkeiten.
## 3. Tests
| Skript | Prüfungen | Inhalt |
|---|---|---|
| `test-testphase-signup.ts` | 76 | Grenzen (morgen … heute + 30, Vorbelegung), Pflichtfelder, Passwort-Policy, Modul-/Checkbox-Pflicht, Normalisierung, `TRIAL_MAX_DAYS`, Ende des Tages Berlin (Winter/Sommer/Umstellung); ungültig/Honeypot ohne Seiteneffekt; Double-Opt-in (nur Token-Hash, Argon2id, IP-HMAC, 24 h, kein Mandant vor Bestätigung, ältere Links entwertet, Ablauf → expired + Hash geleert, Einmalverwendung); Provisionierung (TRIAL, Enddatum, Löschtermin, Slug, tenant-admin, Login-Passwort = Wizard-Passwort, Modulauswahl, Beispieldaten über Fachservices, Audit); Enumeration (identische Antwort, Hinweis-Mail ohne Link, keine offene Anmeldung); Rate-Limit je IP/E-Mail inkl. Retry-After, jede öffentliche Action limitiert, Proxy; ohne Beispieldaten; Slug-Kollision `-2`; Mandant B liest/ändert keine Kunden von A |
| `test-testphase-readonly.ts` | 59 | Checkliste (automatisch erkannt, Beispieldaten zählen nicht, manuell, Monteur forbidden/unsichtbar, Vollversion ohne Checkliste); Ablauf → `blocked trial_expired` mit Klartext; `withApi`+Sperre: POST/DELETE 422, GET 200, Mandant B 200; Sync-Batch 422 ohne angewendete Operation; statisch: moduleGuard, requireApiContext, **alle 24 mutierenden /api/v1-Routen**, Upload-Routen, Einstellungen, Nutzerverwaltung, Lotse, Worker, Lese-Modus nur in 3 Lesepfaden und nie in Actions; Jobs (Import/Transkription übersprungen, PDF/Export/Mandant B nicht); Lesen erlaubt; Export im Nur-Lesen-Zustand (ZIP, CSV mit BOM, JSON, Datei byte-identisch, keine Daten von B, keine Hashes, Formel-Injektion, paralleler Export conflict, Mandant B not_found, Monteur forbidden, Download-Ablauf, Audit); Verlängern hebt die Sperre auf; Mandant B unverändert |
| `test-testphase-lifecycle.ts` | 67 | Erinnerungen 7/3/1 genau einmal, verpasster Lauf; Ablaufmail einmal + readOnlySince; Löschhinweis einmal; Löschung nur fällig (Tabellen leer, Identity, Speicherobjekt, Nachweis, Plattform-Audit, keine Wiederholung); Vollversion B und nicht fälliger Test B2 unberührt; Umwandeln/Verlängern/Abbrechen verhindern Löschung; Vormerken ≥ 7 Tage, folgt neuem Ende; Audit before/after je Aktion; Plattform-Rechte (Mandanten-Admin, Read-only-Admin → forbidden, Action ohne Plattform-Session abgewiesen, Datumsgrenzen, Vollversion invalid, Mandanten-Actions ohne Testphasen-Änderung); Wizard (freies Enddatum, Einladung, Passwortzwang, Token, Beispieldaten, bestehende Person ohne neue Einladung, Audit); Vorlagen de/en; Processor + Scheduler |
**Gate (`npm run gate`) grün:** prisma generate, tsc, lint (0 Fehler; 3 vorbestehende Warnungen in fremden Dateien), build inkl. Guard-Check (39 Action-Dateien), **79/79 Testskripte**. Lane-DB `craftvia_testphase`, `RLS_DATABASE_URL` auf dieselbe DB. Ein Lauf mit `RLS_ENFORCED=true` wurde nicht durchgeführt.
**HTTP-Smoke** (`next build && next start -p 3115`, Session-Cookies ohne Passworteingabe): `scripts/smoke-testphase.ts` **32/32** – anonym `/testen` (Schritt 1 von 5), Bestätigung mit ungültigem Token, Rechtstexte, Redirects; abgelaufener Admin: Banner + Löschdatum + Export-Link, Lesen, Exportseite, `GET /api/v1/customers` 200, **422 `trial_expired`** für `POST /api/v1/customers`, `POST /api/v1/work-orders/[id]/documents`, `POST /documents/upload`; abgelaufener Monteur: `/m`, `/m/orders`, `/m/emergency` mit Banner, Auftrag ohne Zuweisung 404, Bundle 200, **422** für `/api/v1/sync` und `/api/v1/uploads`, keine Exportseite; laufender Test: „Testphase endet in 3 Tagen.“, „Erste Schritte“, Willkommen; Plattform: Liste mit Badges/Filtern, Wizard, Detail mit Karte und Popups. Zusätzlich `scripts/smoke-auth.ts` **137/137** (keine Regression). Der erste Smoke-Lauf fand den 500 auf `/m` (Lese-Kontext über `moduleGuard`) → behoben in `273a453`.
**Visuell:** nicht geprüft – die Navigation des Browser-Panels auf localhost wurde abgelehnt. Bitte `/testen` (Wizard, 375/768/1024 px), Banner und Plattform-Karte manuell ansehen.
## 4. Stubs / Abhängigkeiten
Keine Stubs. Genutzt: `provisionTenant`, `issueToken`/`sendUserInvitationMail`, `login-ticket`/`signIn`, `offboardTenant` + Topologie, `storage`/`readStoredBytes`, `buildZip`, `enqueueMail`, `enqueueJob`/Scheduler, Fachservices L1/L2 (Kunden, Objekte, Teams, Aufträge, Zuweisung).
## 5. Bekannte Lücken / offene Punkte
1. **Offline-Fotos nach Ablauf:** `/api/v1/uploads` antwortet 422; die Outbox (L7) wertet 422 beim Upload als endgültig ungültig und verwirft das Blob auf dem Gerät. Sync-Operationen bleiben erhalten. Vorschlag L7: `blocked`/`trial_expired` als vorübergehend behandeln.
2. **Fehlertexte in fremden Formularen:** Actions anderer Lanes zeigen bei der Sperre ihre eigene Fehlerdarstellung (Code `blocked`/`trial_expired`, teils generisch); der Banner erklärt den Zustand. Eine einheitliche Klartext-Zuordnung je Lane steht aus.
3. **Rate-Limits je App-Instanz** (In-Memory wie SEC2/L10b).
4. **Rechtstexte** sind Platzhalter (`messages/*/trial.json` → `legal.*`).
5. **Fallback nach Bestätigung:** Scheitert die direkte Anmeldung (z. B. künftige MFA-Pflicht), leitet die Action auf `/login?trial=ready`; die Login-Seite (Fundament) zeigt dazu keinen eigenen Hinweis.
6. **Plattform-Einladung an bestehende Personen:** wird nur verknüpft (wie `createTenantUser`), ohne Hinweis-Mail.
7. **Export** im Speicher gebaut (Dateien bis 500 MB, kein ZIP64/Streaming); alte Export-Dateien werden nicht automatisch aus dem Speicher entfernt (nur mit dem Mandanten).
8. **Worker-Sperre** nur für `import-extraction` und `transcription`; Benachrichtigungs-Mails aus Ereignissen vor dem Ablauf laufen weiter.
9. **Slug** eines gelöschten Testmandanten bleibt belegt (Zeile `ARCHIVED` mit Löschnachweis); neue Anmeldungen erhalten ein Suffix.
10. **Checkliste:** „Mobile App öffnen“ nur manuell abhakbar; Checkliste nur für Mandanten, die als Testphase gestartet sind.
11. **RLS-Modus** (`RLS_ENFORCED=true`) für die neuen Tests nicht gelaufen.
## 6. Screens / Routen
| Route | Zugriff | Inhalt |
|---|---|---|
| `/testen` | öffentlich | Wizard „Kostenlos testen“ |
| `/testen/bestaetigen?token=` | öffentlich | Anmeldung prüfen, „Jetzt einrichten“ (POST) → Dashboard |
| `/testen/nutzungsbedingungen`, `/testen/datenschutz` | öffentlich | Platzhalter-Rechtstexte |
| `/dashboard` | Backoffice | Karte „Erste Schritte“ (Admins, Testphasen-Mandanten) |
| alle `(app)`- und `/m`-Seiten | angemeldet | Testphasen-Banner |
| `/settings/export`, `/settings/export/<id>` | `tenant:manage` | Datenexport anfordern/herunterladen |
| `/admin` | Plattform | Spalte „Version“, Filter Alle/Test/Voll, Button „Testmandant anlegen“ |
| `/admin/trial` | Plattform (Voll-Admin für Aktion) | Wizard „Testmandant anlegen“ |
| `/admin/<id>?trial=extend|convert|end|schedule|cancel` | Plattform-Voll-Admin | Karte „Testphase“ + Bestätigungs-Popups |
+2 -1
View File
@@ -6,7 +6,8 @@
"emergency": "Notdienst", "emergency": "Notdienst",
"sync": "Sync", "sync": "Sync",
"profile": "Profil", "profile": "Profil",
"approvalsBadge": "{count} Zeiten zur Freigabe" "approvalsBadge": "{count} Zeiten zur Freigabe",
"lotse": "Lotse"
}, },
"connection": { "connection": {
"online": "Online", "online": "Online",
+175 -2
View File
@@ -89,6 +89,8 @@
"use": "Verwendung", "use": "Verwendung",
"enabled": "Lotse für diesen Betrieb verwenden", "enabled": "Lotse für diesen Betrieb verwenden",
"enabledHint": "Ausgeschaltet: keine Berichtsentwürfe, keine Zusammenfassungen, Sprachnotizen ohne Transkription.", "enabledHint": "Ausgeschaltet: keine Berichtsentwürfe, keine Zusammenfassungen, Sprachnotizen ohne Transkription.",
"chatEnabled": "Lotse-Chat für Monteure",
"chatEnabledHint": "Monteure schreiben oder sprechen mit dem Lotsen. Aktionen (Status, Zeiten, Material, Notizen) werden nur nach Bestätigung durch den Monteur ausgeführt.",
"addressForm": "Anrede des Lotsen", "addressForm": "Anrede des Lotsen",
"addressFormHint": "Gilt einheitlich für alle Texte des Lotsen in diesem Betrieb.", "addressFormHint": "Gilt einheitlich für alle Texte des Lotsen in diesem Betrieb.",
"address": { "address": {
@@ -114,7 +116,9 @@
"order": "Auftragstitel, Beschreibung und Leistungsumfang", "order": "Auftragstitel, Beschreibung und Leistungsumfang",
"notes": "Tätigkeitsnotizen und Texte aus Sprachnotizen", "notes": "Tätigkeitsnotizen und Texte aus Sprachnotizen",
"work": "Checkliste, Material mit Abweichungsgründen, Fotokommentare, Arbeitszeiten", "work": "Checkliste, Material mit Abweichungsgründen, Fotokommentare, Arbeitszeiten",
"audio": "Für die Transkription: die Audiodatei der Sprachnotiz" "audio": "Für die Transkription: die Audiodatei der Sprachnotiz",
"chat": "Lotse-Chat: Nachrichten des Monteurs (Telefonnummern, E-Mail-Adressen und Anschriften ersetzt) und die Auftragsdaten oben. Kundennamen und Kontaktdaten nur als Platzhalter – eingesetzt wird erst in der Antwort auf dem Gerät des Monteurs.",
"chatAudio": "Spracheingabe im Chat: die Aufnahme zur Transkription (wird nicht gespeichert)"
}, },
"notSentTitle": "Nicht gesendet wird", "notSentTitle": "Nicht gesendet wird",
"notSent": { "notSent": {
@@ -152,7 +156,176 @@
"voice_summary": "Zusammenfassung Sprachnotiz", "voice_summary": "Zusammenfassung Sprachnotiz",
"transcription": "Transkription", "transcription": "Transkription",
"import_extraction": "Auftragsimport", "import_extraction": "Auftragsimport",
"completeness_check": "Vollständigkeitsprüfung" "completeness_check": "Vollständigkeitsprüfung",
"lotse_chat": "Lotse-Chat"
} }
},
"chat": {
"title": "Lotse-Chat",
"intro": "Kurz schreiben oder sprechen, was erledigt ist – z. B. „Auftrag fertig, Speicher installiert, 2 Std., 1 Filter verbaut“. Der Lotse schlägt Aktionen vor. Gespeichert wird erst nach Bestätigung.",
"context": "Auftrag {number} · {title}",
"noContext": "Ohne Auftragsbezug",
"ask": "Lotse fragen",
"input": "Nachricht an den Lotsen",
"send": "Senden",
"sending": "Lotse denkt nach …",
"newChat": "Neuer Chat",
"userLabel": "Eigene Nachricht",
"lotseLabel": "Lotse",
"empty": "Noch keine Nachrichten.",
"notConfigured": "Lotse ist nicht eingerichtet. Das Büro kann den KI-Anbieter hinterlegen.",
"offline": "Keine Verbindung. Der Lotse braucht Internet – die Eingabe bleibt erhalten.",
"chipsLabel": "Antwortvorschläge",
"linksLabel": "Direkt ergänzen",
"confirmAll": "Alle bestätigen ({count})",
"mic": {
"record": "Sprechen",
"stop": "Aufnahme beenden",
"recording": "Aufnahme läuft · {time}",
"transcribing": "Wird in Text umgewandelt …",
"unavailable": "Spracheingabe ist nicht eingerichtet.",
"denied": "Kein Zugriff auf das Mikrofon.",
"unsupported": "Spracheingabe wird auf diesem Gerät nicht unterstützt.",
"failed": "Umwandlung in Text fehlgeschlagen. Bitte tippen.",
"hint": "Text vor dem Senden prüfen."
},
"card": {
"confirm": "Bestätigen",
"edit": "Bearbeiten",
"discard": "Verwerfen",
"saveConfirm": "Übernehmen und bestätigen",
"cancel": "Abbrechen",
"busy": "Wird ausgeführt …",
"validUntil": "Gültig bis {time} Uhr",
"status": {
"proposed": "Offen – bitte bestätigen",
"confirmed": "Ausgeführt",
"discarded": "Verworfen",
"expired": "Abgelaufen",
"failed": "Nicht ausgeführt"
},
"kind": {
"transition_work_order": "Status ändern",
"book_time": "Zeit buchen",
"record_material": "Material erfassen",
"add_note": "Notiz speichern",
"suggest_report_fields": "Berichtsvorschlag"
},
"action": {
"accept": "Auftrag {number} annehmen",
"start_travel": "Anfahrt zu {number} starten",
"start_work": "Arbeit an {number} starten",
"pause": "Pause bei {number}",
"resume": "Arbeit an {number} fortsetzen",
"complete": "Auftrag {number} technisch abschließen"
},
"switchFrom": "Die laufende Uhr von {number} wird pausiert.",
"completeHint": "Die eigene Uhr wird beendet. Fehlende Pflichtangaben verhindern den Abschluss.",
"order": "Auftrag {number}",
"timeLine": "{date} · {from}–{to} Uhr · {duration} Std.",
"timeType": {
"work": "Arbeitszeit",
"travel": "Anfahrt",
"return_travel": "Rückfahrt",
"material_procurement": "Material holen"
},
"type": "Art",
"date": "Datum",
"from": "Von",
"to": "Bis",
"reason": "Grund",
"note": "Notiz",
"approvalHint": "Zählt nach Freigabe durch Teamleiter oder Büro.",
"pendingApproval": "Zur Freigabe eingereicht",
"planned": "Geplante Position",
"additional": "Zusatzmaterial",
"name": "Bezeichnung",
"quantity": "Menge",
"unit": "Einheit",
"deviationReason": "Grund der Abweichung",
"noteKind": "Art der Notiz",
"text": "Text",
"reportFields": "Vorschläge für den {type}",
"reportType": {
"daily": "Tagesbericht",
"completion": "Abschlussbericht"
},
"reportHint": "Erscheint im Bericht als „Vorschlag vom Lotsen“ und wird dort übernommen oder verworfen.",
"openReport": "Bericht öffnen",
"field": {
"workPerformed": "Ausgeführte Leistungen",
"deviations": "Abweichungen",
"additionalWork": "Zusatzarbeiten",
"openItems": "Offene Punkte",
"nextSteps": "Nächste Schritte",
"hints": "Hinweise"
}
},
"noteKind": {
"work_done": "Erledigte Arbeit",
"deviation": "Abweichung",
"problem": "Problem",
"additional_work": "Zusatzarbeit",
"not_executable": "Nicht ausführbar",
"follow_up": "Nacharbeit",
"recommendation": "Empfehlung",
"customer_note": "Hinweis vom Kunden",
"general": "Allgemein"
},
"system": {
"failed": "Nicht ausgeführt ({number}): {reason}",
"stopped": "„Alle bestätigen“ gestoppt – {remaining, plural, one {# Karte ist} other {# Karten sind}} noch offen.",
"rounds_exceeded": "Das war zu umfangreich. Bitte kürzer oder in Schritten schreiben.",
"turn_budget": "Das war zu umfangreich. Bitte kürzer oder in Schritten schreiben.",
"refusal": "Dazu kann der Lotse nichts vorschlagen.",
"provider_failed": "Lotse ist gerade nicht erreichbar. Bitte später erneut versuchen.",
"proposals_ready": "Vorschläge liegen bereit. Bitte prüfen und bestätigen.",
"empty": "Keine Antwort erhalten. Bitte anders formulieren."
},
"links": {
"order": "Auftrag öffnen",
"report": "Bericht öffnen",
"myTime": "Meine Zeiten öffnen",
"materials": "Material öffnen",
"otherOrder": "Laufenden Auftrag öffnen",
"runningSession": "Uhr eines Kollegen läuft noch"
},
"errors": {
"generic": "Das hat nicht geklappt. Bitte erneut versuchen.",
"not_found": "Nicht gefunden.",
"forbidden": "Dafür fehlt die Berechtigung.",
"disabled": "Lotse ist für diesen Betrieb ausgeschaltet.",
"chat_disabled": "Der Lotse-Chat ist für diesen Betrieb ausgeschaltet.",
"not_configured": "Lotse ist nicht eingerichtet.",
"provider_failed": "Lotse ist gerade nicht erreichbar. Bitte später erneut versuchen.",
"budget_exceeded": "Das monatliche KI-Kontingent des Betriebs ist aufgebraucht.",
"invalid": "Bitte Eingaben prüfen.",
"conflict": "Inzwischen geändert. Bitte neu laden.",
"expired": "Vorschlag abgelaufen (nach 30 Minuten). Bitte neu anfragen.",
"already_decided": "Dieser Vorschlag ist schon erledigt.",
"tampered": "Der Vorschlag wurde verändert und nicht ausgeführt.",
"blocked": "Pflichtangaben fehlen.",
"offline": "Keine Verbindung – nichts gesendet.",
"overlap": "Der Zeitraum überschneidet sich mit einer erfassten Zeit.",
"too_old": "Nachträge sind nur 7 Tage rückwirkend möglich.",
"in_future": "Zeiten in der Zukunft können nicht gebucht werden.",
"too_long": "Ein Eintrag darf höchstens 16 Stunden lang sein.",
"end_before_start": "Das Ende liegt vor dem Beginn.",
"work_order_status": "Im aktuellen Auftragsstatus nicht möglich.",
"other_session_running": "Auf einem anderen Auftrag läuft noch die Uhr.",
"transition_not_allowed": "Dieser Statuswechsel ist gerade nicht möglich.",
"transition_forbidden": "Dafür fehlt die Berechtigung.",
"not_editable": "Der Bericht ist eingereicht und kann nicht mehr geändert werden.",
"reason_required": "Der Grund fehlt.",
"use_billing_overview": "Nur über die Abrechnungsübersicht möglich.",
"not_in_plan": "Der Lotse ist im Paket Profi enthalten und für diesen Betrieb nicht freigeschaltet.",
"no_seat": "Der Lotse-Chat ist für dich noch nicht freigeschaltet – frag im Büro nach.",
"quota_exhausted": "Das Chat-Kontingent des Betriebs ist für diesen Monat aufgebraucht."
},
"unavailable": {
"title": "Lotse-Chat nicht verfügbar",
"back": "Zur Übersicht"
},
"quotaExhausted": "Das Chat-Kontingent des Betriebs ist für diesen Monat aufgebraucht. Ab dem nächsten Monat kannst du wieder schreiben – bei Fragen hilft das Büro."
} }
} }
+419
View File
@@ -0,0 +1,419 @@
{
"meta": {
"title": "Craftvia — Einsatz-App für Handwerk und Montage",
"description": "Aufträge, Planung, Zeiterfassung, Berichte und Abrechnung in einem System. Mobil, offline-fähig und mandantensicher — für Handwerks- und Montagebetriebe."
},
"nav": {
"label": "Hauptnavigation",
"features": "Funktionen",
"planning": "Planung",
"mobile": "Baustelle",
"security": "Sicherheit",
"pricing": "Testphase",
"faq": "Fragen",
"login": "Anmelden",
"cta": "Kostenlos testen",
"menu": "Menü"
},
"hero": {
"eyebrow": "Für Handwerks- und Montagebetriebe",
"title": "Handwerk. Digital auf Kurs.",
"subtitle": "Craftvia führt Auftrag, Planung, Zeiten, Material und Bericht an einer Stelle zusammen — im Büro am Rechner, auf der Baustelle am Telefon. Auch ohne Netz.",
"ctaPrimary": "14 Tage kostenlos testen",
"ctaSecondary": "Funktionen ansehen",
"bullet1": "In wenigen Minuten eingerichtet, ohne Installation",
"bullet2": "Offline nutzbar — Eingaben gehen nie verloren",
"bullet3": "Keine Zahlungsdaten für den Test nötig"
},
"pillars": {
"title": "Was Craftvia zusammenhält",
"one": {
"title": "Ein Auftrag, eine Wahrheit",
"text": "Termin, Team, Zeiten, Material, Fotos und Bericht hängen am selben Auftrag — kein Zettel, keine Doppelerfassung."
},
"two": {
"title": "Büro und Baustelle im Takt",
"text": "Das Büro plant, der Monteur arbeitet ab. Beide sehen denselben Stand, ohne Anruf."
},
"three": {
"title": "Abschluss statt Nacharbeit",
"text": "Pflichtangaben, Fotos und Unterschrift werden vor Ort geprüft — der Auftrag ist fertig, wenn der Monteur vom Hof fährt."
}
},
"features": {
"title": "Alles, was ein Einsatz braucht",
"sub": "Module, die zusammenarbeiten. Sie schalten frei, was Sie brauchen.",
"orders": {
"title": "Aufträge",
"text": "Vom Anruf bis zur Abnahme: anlegen, einplanen, zuweisen, Status jederzeit sehen. Eigene Auftragsarten mit Checklisten und Pflichtfotos."
},
"board": {
"title": "Plantafel",
"text": "Aufträge per Maus auf Team und Tag ziehen. Auslastung, Überbuchung und Überschneidung sind sofort sichtbar."
},
"live": {
"title": "Live-Lage",
"text": "Wer ist unterwegs, wer arbeitet, wer macht Pause — am Einsatzort des laufenden Auftrags. Ohne GPS-Ortung der Person."
},
"time": {
"title": "Zeiterfassung",
"text": "Start, Pause, Ende mit einem Tipp. Fahrzeit und Arbeitszeit getrennt, Nachträge nur mit Begründung und Freigabe."
},
"offline": {
"title": "Mobil und offline",
"text": "Im Keller, in der Tiefgarage, im Funkloch: Eingaben werden lokal gespeichert und automatisch nachgesendet."
},
"reports": {
"title": "Bericht und Unterschrift",
"text": "Leistungsnachweis auf dem Gerät ausfüllen, Kunde unterschreibt direkt auf dem Display, das PDF geht an den Kunden."
},
"material": {
"title": "Material",
"text": "Geplantes und tatsächlich verbautes Material erfassen, Abweichungen mit Grund — sauber für die Nachkalkulation."
},
"billing": {
"title": "Abrechnungsübersicht",
"text": "Fertige Aufträge und erreichte Meilensteine laufen gesammelt auf: Zeiten, Material und Anfahrten als Grundlage für die Rechnung."
},
"emergency": {
"title": "Notdienst",
"text": "Einsatz in Minuten anlegen, Pflichtangaben später nachziehen, Prüfung im Büro."
},
"lotse": {
"title": "Lotse — der Assistent",
"text": "Der Monteur schreibt oder spricht wie im Chat. Der Lotse ordnet zu und fragt Fehlendes nach. Gebucht wird erst nach Bestätigung."
},
"documents": {
"title": "Dokumente",
"text": "Pläne, Fotos und Datenblätter am Auftrag und am Objekt — auch unterwegs verfügbar."
},
"imports": {
"title": "Auftragsimport",
"text": "Aufträge aus PDF einlesen: Daten werden vorgeschlagen und vor der Übernahme geprüft."
}
},
"planning": {
"eyebrow": "Planung",
"title": "Die Woche im Blick, bevor sie schiefgeht",
"text": "Die Plantafel zeigt jede Kolonne mit ihrer Tageskapazität. Wird ein Tag zu voll oder überschneiden sich zwei Termine, steht es als Meldung auf der Karte — nicht erst im Anruf des Kunden.",
"point1": "Auslastung je Team und Tag in Prozent",
"point2": "Warnung bei Überbuchung, Überschneidung und unvollständiger Kolonne",
"point3": "Hinweis, wenn ein Einsatz länger dauert als geplant — und der Folgeauftrag wackelt",
"point4": "Vorschläge für Aufträge in der Nähe, wenn ein Team früher fertig ist"
},
"mobile": {
"eyebrow": "Auf der Baustelle",
"title": "Vier Tipps, und der Einsatz ist dokumentiert",
"text": "Die mobile Ansicht ist für Handschuhe und helles Licht gebaut: große Flächen, klare Schritte, keine Suche im Menü.",
"step1": {
"title": "Anfahrt starten",
"text": "Fahrzeit läuft automatisch mit."
},
"step2": {
"title": "Vor Ort arbeiten",
"text": "Checkliste abhaken, Fotos aufnehmen, Material buchen."
},
"step3": {
"title": "Bericht ausfüllen",
"text": "Text diktieren oder tippen, Pflichtangaben werden geprüft."
},
"step4": {
"title": "Unterschrift holen",
"text": "Kunde unterschreibt auf dem Display, PDF geht raus."
},
"offlineNote": "Kein Netz? Alles läuft weiter und wird später automatisch übertragen."
},
"lotse": {
"eyebrow": "Lotse",
"title": "Sagen, was war — statt Felder suchen",
"text": "Der Lotse versteht ganze Sätze, ordnet den Auftrag zu und fragt nach, was fehlt. Er zeigt eine Karte mit dem, was er buchen würde. Erst nach Bestätigung wird etwas geschrieben.",
"chatUser": "Auftrag fertig, Speicher eingebaut, 2 Stunden, einen Filter verbraucht",
"chatAssistant": "Ich ordne das dem Auftrag A-00042 zu, Wohnanlage Lindenhof. Zwei Fragen: War die Anfahrt dabei, und soll ich den Auftrag technisch abschließen?",
"cardTitle": "Vorschlag",
"cardLine1": "2:00 Std. Arbeitszeit buchen",
"cardLine2": "1 × Filter als Material erfassen",
"cardLine3": "Auftrag technisch abschließen",
"cardConfirm": "Bestätigen",
"cardDiscard": "Verwerfen",
"note": "Rechte, Pflichtangaben und Freigaben gelten unverändert — der Assistent umgeht nichts."
},
"steps": {
"title": "In drei Schritten startklar",
"one": {
"title": "Konto anlegen",
"text": "Firma eintragen, Testzeitraum wählen, E-Mail bestätigen. Auf Wunsch mit Beispieldaten zum Ausprobieren."
},
"two": {
"title": "Team und Kunden anlegen",
"text": "Monteure einladen, Kolonnen bilden, Kunden und Objekte erfassen oder importieren."
},
"three": {
"title": "Ersten Auftrag fahren",
"text": "Auftrag einplanen, Monteur arbeitet ihn mobil ab, Bericht kommt zurück ins Büro."
}
},
"industries": {
"title": "Für Betriebe, die rausfahren",
"sub": "Gebaut für Montage und Service — unabhängig vom Gewerk.",
"shk": "Sanitär, Heizung, Klima",
"electric": "Elektrotechnik",
"cooling": "Kälte- und Lüftungstechnik",
"lifts": "Aufzüge, Tore, Antriebe",
"windows": "Fenster, Türen, Fassade",
"service": "Industrie- und Anlagenservice"
},
"security": {
"eyebrow": "Sicherheit und Datenschutz",
"title": "Ihre Daten bleiben Ihre Daten",
"text": "Craftvia ist mandantenfähig gebaut: Jeder Betrieb sieht ausschließlich seine eigenen Daten — abgesichert bis in die Datenbank, nicht nur in der Oberfläche.",
"item1": {
"title": "Strikte Mandantentrennung",
"text": "Zugriffsregeln direkt in der Datenbank verhindern, dass Daten den Betrieb verlassen."
},
"item2": {
"title": "Rollen und Rechte",
"text": "Admin, Backoffice, Teamleitung und Monteur sehen genau das, was sie brauchen. Serverseitig geprüft."
},
"item3": {
"title": "Zwei-Faktor und Passkeys",
"text": "Anmeldung mit zweitem Faktor oder Passkey, erzwingbar für den ganzen Betrieb."
},
"item4": {
"title": "Lückenloses Protokoll",
"text": "Jede schreibende Aktion wird revisionssicher protokolliert — wer, wann, was."
},
"item5": {
"title": "Export jederzeit",
"text": "Ihre Daten lassen sich als Datei herunterladen — auch am Ende der Testphase."
},
"item6": {
"title": "Sparsam mit Daten",
"text": "Standort heißt Einsatzort des laufenden Auftrags, keine Ortung von Personen."
}
},
"trial": {
"eyebrow": "Testphase",
"title": "14 Tage testen, in Ruhe entscheiden",
"text": "Sie legen den Testzeitraum selbst fest und starten mit Beispieldaten oder leer. Nach Ablauf bleiben Ihre Daten lesbar und lassen sich exportieren — nichts verschwindet über Nacht.",
"point1": "Voller Funktionsumfang, keine Zahlungsdaten nötig",
"point2": "Erinnerung vor Ablauf, Verlängerung jederzeit möglich",
"point3": "Übernahme in die Vollversion ohne Datenumzug",
"cta": "Testphase starten",
"secondary": "Angebot anfragen",
"note": "Preise der Vollversion auf Anfrage — wir melden uns mit einem Vorschlag, der zur Betriebsgröße passt."
},
"faq": {
"title": "Häufige Fragen",
"q1": {
"q": "Brauchen meine Monteure eine Schulung?",
"a": "Nein. Die mobile Ansicht führt durch den Einsatz: anfahren, arbeiten, dokumentieren, unterschreiben lassen. Die meisten Betriebe starten nach einer kurzen Einweisung."
},
"q2": {
"q": "Funktioniert die App ohne Internet?",
"a": "Ja. Eingaben, Fotos und Zeiten werden auf dem Gerät gespeichert und automatisch übertragen, sobald wieder Verbindung besteht."
},
"q3": {
"q": "Muss ich etwas installieren?",
"a": "Nein. Craftvia läuft im Browser und lässt sich auf dem Telefon wie eine App auf den Startbildschirm legen."
},
"q4": {
"q": "Was passiert nach der Testphase?",
"a": "Ihre Daten bleiben lesbar und exportierbar. Sie können verlängern oder in die Vollversion wechseln — ohne Umzug der Daten."
},
"q5": {
"q": "Kann ich bestehende Kunden und Aufträge übernehmen?",
"a": "Ja. Kunden und Objekte lassen sich importieren, Aufträge zusätzlich aus PDF einlesen und vor der Übernahme prüfen."
},
"q6": {
"q": "Werden meine Monteure per GPS überwacht?",
"a": "Nein. Angezeigt wird der Einsatzort des laufenden Auftrags, nicht die Position des Geräts oder der Person."
},
"q7": {
"q": "Ersetzt Craftvia meine Buchhaltung?",
"a": "Nein. Craftvia bereitet die Abrechnung auf — Zeiten, Material, Anfahrten und Meilensteine. Die Rechnung schreibt Ihre Buchhaltung."
},
"q8": {
"q": "Wie viele Nutzer sind möglich?",
"a": "Vom Ein-Mann-Betrieb bis zu mehreren Kolonnen. Teams, Rollen und Kapazitäten legen Sie selbst fest."
}
},
"closing": {
"title": "Bereit für den ersten Auftrag?",
"text": "Richten Sie Ihren Testzugang ein und planen Sie noch heute die erste Woche.",
"cta": "Kostenlos testen",
"login": "Ich habe schon ein Konto"
},
"footer": {
"tagline": "Die Einsatz-App für Handwerks- und Montagebetriebe.",
"product": "Produkt",
"company": "Rechtliches",
"features": "Funktionen",
"trial": "Testphase",
"login": "Anmelden",
"terms": "Nutzungsbedingungen",
"privacy": "Datenschutz",
"imprint": "Impressum",
"rights": "Alle Rechte vorbehalten."
},
"pages": {
"planung": {
"title": "Planung und Plantafel",
"lead": "Die Woche im Blick: Aufträge auf Kolonnen verteilen, Auslastung sehen, Konflikte erkennen, bevor der Kunde anruft.",
"p1": "Aufträge per Maus auf Team und Tag ziehen — oder über „Einplanen …“ ganz ohne Maus",
"p2": "Auslastung je Kolonne und Tag in Prozent, gemessen am Kolonnentag",
"p3": "Meldung bei Überbuchung, Überschneidung und unvollständiger Kolonne",
"p4": "Ungeplante Aufträge in einer eigenen Spalte, mit Vorschlägen direkt an der Karte",
"p5": "Live-Lage zeigt, wer unterwegs ist, arbeitet oder Pause macht",
"shot1": "Plantafel mit fünf Tagen, Auslastung je Kolonne und markierten Konflikten",
"shot2": "Live-Lage als Kachelansicht: Kolonnen mit Status, Auftrag und Ort",
"c1title": "Kolonnen statt Einzelpersonen",
"c1text": "Ein Team fährt zusammen. Die Kapazität ist der Kolonnentag, nicht die Summe der Personenstunden.",
"c2title": "Früher fertig, sinnvoll genutzt",
"c2text": "Endet ein Einsatz früher, schlägt Craftvia Aufträge in der Nähe vor — vorziehen entscheidet der Mensch.",
"c3title": "Verzug sichtbar machen",
"c3text": "Läuft ein Einsatz aus dem Ruder, markiert die Tafel den Folgeauftrag als gefährdet."
},
"baustelle": {
"title": "Auf der Baustelle",
"lead": "Die mobile Ansicht führt durch den Einsatz: anfahren, arbeiten, dokumentieren, unterschreiben lassen — auch ohne Netz.",
"p1": "Große Flächen und klare Schritte, bedienbar mit Arbeitshandschuhen",
"p2": "Checkliste, Pflichtfotos und Material direkt am Auftrag",
"p3": "Offline nutzbar: Eingaben werden gespeichert und automatisch nachgesendet",
"p4": "Unterschrift des Kunden auf dem Display, PDF geht anschließend raus",
"p5": "Notdiensteinsätze in Minuten anlegen und später vervollständigen",
"shot1": "Tagesansicht des Monteurs mit laufender Zeiterfassung",
"shot2": "Auftrag mobil: Status, Checkliste, Material und Fotos an einer Stelle",
"c1title": "Kein Zettel mehr",
"c1text": "Was auf der Baustelle erfasst wird, steht sofort im Büro zur Verfügung.",
"c2title": "Pflichtangaben vor dem Abschluss",
"c2text": "Fehlende Fotos oder Unterschriften fallen vor Ort auf, nicht Tage später.",
"c3title": "Sync ohne Nachdenken",
"c3text": "Die App überträgt selbstständig, sobald wieder Verbindung besteht."
},
"zeiterfassung": {
"title": "Zeiterfassung",
"lead": "Start, Pause, Ende mit einem Tipp. Fahrzeit und Arbeitszeit getrennt, Nachträge nachvollziehbar.",
"p1": "Laufende Uhr auf jeder Seite sichtbar, „Für heute beenden“ mit einem Tipp",
"p2": "Automatischer Wechsel, wenn auf einem anderen Auftrag gestartet wird",
"p3": "Zeiten nachtragen für die letzten Tage — mit Begründung",
"p4": "Freigabe durch Teamleitung oder Backoffice; offene Einträge zählen nicht für die Abrechnung",
"p5": "Backoffice kann Zeiten für Mitarbeitende erfassen, protokolliert im Audit",
"shot1": "Meine Zeiten: Tagesübersicht mit Fahrt- und Arbeitszeit",
"shot2": "Freigabe im Büro: offene Zeiteinträge prüfen und bestätigen",
"c1title": "Ehrliche Stunden",
"c1text": "Erfasst wird beim Arbeiten, nicht abends aus dem Gedächtnis.",
"c2title": "Korrekturen bleiben sichtbar",
"c2text": "Jeder Nachtrag trägt Grund und Freigabe — sauber für Lohn und Nachkalkulation.",
"c3title": "Fahrzeit sauber getrennt",
"c3text": "Anfahrten zählen eigenständig und landen als Position in der Abrechnung."
},
"berichte": {
"title": "Berichte und Unterschrift",
"lead": "Der Leistungsnachweis entsteht auf der Baustelle — geprüft, unterschrieben und als PDF beim Kunden.",
"p1": "Bericht direkt am Auftrag, mit Vorlage je Auftragsart",
"p2": "Pflichtangaben und Pflichtfotos werden vor dem Einreichen geprüft",
"p3": "Unterschrift auf dem Display, alternativ „Kunde nicht angetroffen“ mit Grund",
"p4": "Freigabe im Büro, danach automatischer Versand als PDF",
"p5": "Jede Version bleibt erhalten, ältere werden als ersetzt gekennzeichnet",
"shot1": "Berichte im Büro: Status von eingereicht bis freigegeben",
"shot2": "Auftragsdetail mit Zeiten, Material, Fotos und Bericht",
"c1title": "Ein Nachweis, keine Sammlung",
"c1text": "Bericht, Fotos, Zeiten und Material hängen am selben Auftrag.",
"c2title": "Nachvollziehbar",
"c2text": "Wer eingereicht, geprüft und freigegeben hat, steht im Protokoll.",
"c3title": "Direkt beim Kunden",
"c3text": "Nach der Freigabe geht das PDF automatisch per Mail raus."
},
"abrechnung": {
"title": "Abrechnungsübersicht",
"lead": "Fertige Aufträge und erreichte Meilensteine laufen gesammelt auf — mit Zeiten, Material und Anfahrten für die Buchhaltung.",
"p1": "Abgeschlossene Aufträge, Meilensteine und freigegebene Tagesberichte an einer Stelle",
"p2": "Nur freigegebene Zeiten zählen; offene Einträge werden getrennt ausgewiesen",
"p3": "Anfahrten aus der Zeiterfassung, je Kolonne und Tag gezählt",
"p4": "Abrechnungsblatt als PDF, „Abgerechnet“ mit optionaler Rechnungsnummer",
"p5": "Stornieren nur mit Begründung; abgerechnete Positionen bleiben eindeutig zugeordnet",
"shot1": "Abrechnungsübersicht mit offenen und abgerechneten Einträgen",
"c1title": "Keine doppelte Abrechnung",
"c1text": "Jede Zeit und jedes Material gehört zu genau einem abgerechneten Abschnitt.",
"c2title": "Buchhaltung bleibt Buchhaltung",
"c2text": "Craftvia liefert die Grundlage — Preise, Steuern und Rechnungen macht Ihr System.",
"c3title": "Meilensteine statt Endabrechnung",
"c3text": "Bei langen Projekten wird abgerechnet, was erreicht ist."
},
"lotse": {
"title": "Lotse — der Assistent",
"lead": "Sagen, was war: Der Lotse ordnet zu, fragt nach, was fehlt, und schlägt Buchungen vor. Gespeichert wird erst nach Bestätigung.",
"p1": "Schreiben oder sprechen wie im Chat, auch mitten im Einsatz",
"p2": "Ordnet den Auftrag selbst zu — aus dem Kontext, der laufenden Uhr oder dem Tagesplan",
"p3": "Fragt fehlende Angaben nach, statt zu raten",
"p4": "Zeigt Aktionskarten für Status, Zeiten, Material und Notizen",
"p5": "Rechte, Pflichtangaben und Freigaben gelten unverändert",
"shot1": "Lotse-Chat auf dem Telefon: Eingabe per Text oder Sprache",
"c1title": "Bestätigen statt tippen",
"c1text": "Der Monteur prüft eine Karte und tippt einmal — statt vier Formulare auszufüllen.",
"c2title": "Nichts passiert heimlich",
"c2text": "Ohne Bestätigung wird nichts gebucht. Jede Buchung landet im Protokoll.",
"c3title": "Auch als Nachschlagewerk",
"c3text": "Fragen wie „Wo ist der Zugangscode?“ beantwortet der Lotse aus dem Auftrag."
}
},
"overview": {
"title": "Funktionen im Überblick",
"lead": "Craftvia deckt den ganzen Einsatz ab — vom eingehenden Auftrag bis zur Grundlage für die Rechnung. Jedes Modul lässt sich einzeln freischalten.",
"more": "Mehr erfahren"
},
"securityPage": {
"title": "Sicherheit und Datenschutz",
"lead": "Craftvia ist mandantenfähig gebaut. Jeder Betrieb sieht ausschließlich seine eigenen Daten — abgesichert bis in die Datenbank, nicht nur in der Oberfläche.",
"shot1": "Audit-Protokoll: jede schreibende Aktion mit Zeitpunkt, Person und Änderung",
"shot2": "Teams und Rollen: wer welche Aufträge sieht, entscheidet die Zuweisung",
"detailTitle": "Was das im Betrieb bedeutet",
"d1title": "Feldrollen sehen nur ihre Einsätze",
"d1text": "Monteure sehen die Aufträge ihrer Kolonne, nicht die Kundenliste des Betriebs.",
"d2title": "Rechte gelten am Server",
"d2text": "Jede Änderung wird serverseitig geprüft. Ausgeblendete Schaltflächen sind Komfort, keine Absicherung.",
"d3title": "Nachvollziehbar statt vertrauensselig",
"d3text": "Das Protokoll hält fest, wer was wann geändert hat — auch für die Buchhaltung."
},
"pricingPage": {
"title": "Testphase und Preise",
"lead": "Starten Sie mit einer Testphase und entscheiden Sie danach in Ruhe. Der Preis der Vollversion richtet sich nach der Betriebsgröße.",
"trialTitle": "Testphase",
"trialText": "Voller Funktionsumfang, Zeitraum selbst gewählt, keine Zahlungsdaten nötig. Nach Ablauf bleiben die Daten lesbar und lassen sich exportieren.",
"t1": "Bis zu 30 Tage, Enddatum frei wählbar",
"t2": "Mit Beispieldaten oder leer starten",
"t3": "Erinnerung vor Ablauf, Verlängerung jederzeit",
"fullTitle": "Vollversion",
"fullText": "Übernahme ohne Datenumzug: Der Testmandant wird umgestellt, alles bleibt erhalten.",
"f1": "Alle Module, unbegrenzte Aufträge",
"f2": "Preis nach Anzahl der Nutzer und Kolonnen",
"f3": "Angebot nach einem kurzen Gespräch",
"note": "Konkrete Preise nennen wir erst, wenn sie feststehen — hier steht bewusst keine Zahl, die später nicht stimmt."
},
"cta": {
"title": "Craftvia im eigenen Betrieb ausprobieren",
"text": "Testzugang einrichten, Beispieldaten laden und die erste Woche planen — in wenigen Minuten.",
"primary": "Kostenlos testen",
"secondary": "Alle Funktionen ansehen"
},
"shotFrame": {
"label": "Bildschirmfoto aus Craftvia"
},
"imprint": {
"title": "Impressum",
"intro": "Angaben gemäß § 5 Digitale-Dienste-Gesetz (DDG).",
"provider": "Anbieter",
"represented": "Vertreten durch",
"contact": "Kontakt",
"phone": "Telefon",
"email": "E-Mail",
"register": "Registereintrag",
"registerCourt": "Registergericht",
"registerNumber": "Handelsregister",
"vat": "Umsatzsteuer-ID",
"vatText": "Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz:",
"editorial": "Redaktionell verantwortlich",
"dispute": "Verbraucherstreitbeilegung / Universalschlichtungsstelle",
"disputeText": "Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen."
}
}
+2 -1
View File
@@ -21,5 +21,6 @@
"openMenu": "Menü öffnen", "openMenu": "Menü öffnen",
"closeMenu": "Menü schließen", "closeMenu": "Menü schließen",
"timeApprovals": "Zeiten zur Freigabe", "timeApprovals": "Zeiten zur Freigabe",
"billing": "Abrechnung" "billing": "Abrechnung",
"dataExport": "Datenexport"
} }
+3 -1
View File
@@ -92,7 +92,9 @@
"internal": "Technischer Fehler – wird automatisch erneut versucht.", "internal": "Technischer Fehler – wird automatisch erneut versucht.",
"timeOverlap": "Der Zeitnachtrag überschneidet sich mit einer bereits erfassten Zeit und wurde nicht übernommen.", "timeOverlap": "Der Zeitnachtrag überschneidet sich mit einer bereits erfassten Zeit und wurde nicht übernommen.",
"timeWindow": "Der Zeitnachtrag liegt außerhalb der erlaubten 7 Tage, in der Zukunft oder ist zu lang und wurde nicht übernommen.", "timeWindow": "Der Zeitnachtrag liegt außerhalb der erlaubten 7 Tage, in der Zukunft oder ist zu lang und wurde nicht übernommen.",
"otherSession": "Auf einem anderen Auftrag lief noch eine Zeiterfassung – der Start wurde nicht übernommen. Bitte erneut starten und wechseln." "otherSession": "Auf einem anderen Auftrag lief noch eine Zeiterfassung – der Start wurde nicht übernommen. Bitte erneut starten und wechseln.",
"notInPlan": "Im Paket Profi enthalten – für diesen Betrieb nicht freigeschaltet. Die Eingabe wurde nicht übernommen, bitte im Büro melden.",
"moduleDisabled": "Diese Funktion ist für diesen Betrieb ausgeschaltet. Die Eingabe wurde nicht übernommen, bitte im Büro melden."
}, },
"view": { "view": {
"title": "Offline-Ansicht", "title": "Offline-Ansicht",
+5 -2
View File
@@ -89,7 +89,8 @@
"noTeam": "ohne Team", "noTeam": "ohne Team",
"noDate": "ohne Termin", "noDate": "ohne Termin",
"noLocation": "ohne Ortsangabe", "noLocation": "ohne Ortsangabe",
"suggestions": "Vorschläge" "suggestions": "Vorschläge",
"suggestionsFor": "Vorschläge für {number}"
}, },
"dialog": { "dialog": {
"title": "Auftrag einplanen", "title": "Auftrag einplanen",
@@ -150,7 +151,9 @@
"refreshing": "Aktualisiert …", "refreshing": "Aktualisiert …",
"updateFailed": "Aktualisierung fehlgeschlagen – der letzte Stand wird angezeigt.", "updateFailed": "Aktualisierung fehlgeschlagen – der letzte Stand wird angezeigt.",
"tabsLabel": "Darstellung", "tabsLabel": "Darstellung",
"tabs": { "map": "Karte", "list": "Liste" }, "tabs": { "map": "Karte", "tiles": "Kacheln", "list": "Liste" },
"tileMembers": "{count} Personen",
"mapUnavailable": "Kartenkacheln sind gerade nicht erreichbar – die Kachelansicht zeigt denselben Stand.",
"filterTeam": "Team", "filterTeam": "Team",
"filterStatus": "Status", "filterStatus": "Status",
"all": "Alle", "all": "Alle",
+93
View File
@@ -0,0 +1,93 @@
{
"tier": {
"BASIS": "Basis",
"PROFI": "Profi"
},
"colTier": "Paket",
"notInTier": "Nur im Paket Profi",
"profiHint": {
"title": "Im Paket Profi enthalten",
"text": "Diese Funktion gehört zum Paket Profi. Ihr Betrieb nutzt das Paket Basis – für ein Upgrade wenden Sie sich bitte an Ihren Craftvia-Ansprechpartner."
},
"admin": {
"title": "Paket & Lotse-Chat",
"sub": "Paket des Betriebs, Chat-Plätze für Monteure und Verbrauch im laufenden Monat",
"package": "Paket",
"trial": "Testphase (alle Funktionen)",
"packageReadOnly": "Paket und Anzahl der Chat-Plätze legt der Anbieter fest. Hier vergeben Sie die gekauften Plätze an Ihre Monteure und Teamleiter.",
"basisHint": "Lotse und Lotse-Chat sind im Paket Profi enthalten. Bereits vergebene Plätze und Chatverläufe bleiben gespeichert und sind nach einem Wechsel zu Profi wieder verfügbar.",
"seatsTitle": "Chat-Plätze",
"seatsCount": "{assigned} von {purchased} Plätzen vergeben",
"trialSeats": "Während der Testphase dürfen alle Monteure und Teamleiter den Lotse-Chat nutzen – Plätze müssen nicht vergeben werden (Kontingent: {perSeat} Chats je Person und Monat).",
"noSeatsBought": "Noch keine Chat-Plätze gebucht. Plätze bucht der Anbieter für Ihren Betrieb.",
"overbooked": "Mehr Plätze vergeben als gebucht ({purchased}). Nur die zuerst vergebenen Plätze gelten – bitte Plätze entziehen.",
"noFieldUsers": "Keine aktiven Nutzer mit Feldrolle (Monteur, Teamleiter).",
"seatAssigned": "Platz vergeben",
"seatOverbooked": "Über der Platzanzahl",
"seatNone": "Kein Platz",
"assign": "Platz vergeben",
"revoke": "Platz entziehen",
"assignLabel": "Chat-Platz an {name} vergeben",
"revokeLabel": "Chat-Platz von {name} entziehen",
"saved": "Chat-Platz gespeichert.",
"usageTitle": "Verbrauch {month}",
"usageText": "{used} von {quota} Chats",
"quotaRule": "Kontingent: {perSeat} Chats je vergebenem Platz und Monat, gemeinsam für den Betrieb (Basis: {basis}). Ein Chat ist eine Nachricht an den Lotsen.",
"warn80": "{percent} % des Monatskontingents sind genutzt.",
"exhausted": "Das Monatskontingent ist ausgeschöpft. Weitere Chats werden als Mehrverbrauch in Paketen à {pack} berechnet.",
"hardLimitReached": "Das Monatskontingent ist ausgeschöpft. Der Lotse-Chat ist bis zum Monatsende gesperrt (hartes Limit).",
"overage": "Mehrverbrauch: {overage} Chats – entspricht {packs, plural, one {# Paket} other {# Paketen}} à {pack} Chats.",
"perUserCaption": "Chats je Nutzer im laufenden Monat",
"perUserName": "Nutzer",
"perUserChats": "Chats",
"errors": {
"no_seats_left": "Alle gebuchten Plätze sind vergeben. Bitte zuerst einen Platz entziehen oder weitere Plätze beim Anbieter buchen.",
"not_eligible": "Diese Person kann keinen Chat-Platz erhalten (keine aktive Feldrolle).",
"not_in_plan": "Der Lotse-Chat ist im Paket Profi enthalten.",
"disabled": "Lotse ist für diesen Betrieb ausgeschaltet.",
"forbidden": "Dafür fehlt die Berechtigung.",
"trial_expired": "Testphase abgelaufen – nur Lesezugriff.",
"failed": "Speichern nicht möglich."
}
},
"platform": {
"title": "Paket & Lotse-Chat",
"tier": "Paket",
"trialProfi": "Testphase = Profi",
"seats": "Chat-Plätze",
"seatsValue": "{assigned} vergeben / {purchased} gebucht",
"hardLimit": "Hartes Limit",
"hardLimitOn": "an – Sperre beim Kontingent",
"hardLimitOff": "aus – Mehrverbrauch wird gezählt",
"rule": "{perSeat} Chats je vergebenem Platz und Monat (Zeitzone des Mandanten); Mehrverbrauch in Paketen à {pack}.",
"currentMonth": "Laufender Monat · {month}",
"previousMonth": "Vormonat · {month}",
"chats": "{used} von {quota} Chats",
"overage": "Mehrverbrauch {overage} Chats → {packs, plural, one {# Paket} other {# Pakete}} à {pack}",
"noOverage": "Kein Mehrverbrauch",
"edit": "Paket ändern …",
"editTitle": "Paket & Chat-Plätze ändern",
"editText": "Die Änderung wirkt sofort für alle Nutzer des Mandanten und wird im Plattform-Audit protokolliert.",
"tierHint": {
"BASIS": "Ohne KI und Planung: kein Lotse, keine Plantafel/Live-Lage, keine Abrechnungsübersicht, kein Notdienst, kein Auftragsimport.",
"PROFI": "Alle Funktionen inkl. Planung, Abrechnungsübersicht, Notdienst, Import und Lotse."
},
"downgradeHint": "Wechsel zu Basis: Daten bleiben erhalten, nur der Zugriff endet. Zurück zu Profi ist alles wieder da.",
"seatsInput": "Gebuchte Chat-Plätze",
"seatsHint": "Der Mandanten-Admin vergibt die Plätze an Nutzer mit Feldrolle.",
"hardLimitInput": "Hartes Limit",
"hardLimitHint": "Nach Ausschöpfen des Monatskontingents wird der Chat gesperrt statt Mehrverbrauch zu zählen.",
"confirm": "Ich habe die Auswirkungen geprüft und bestätige die Änderung.",
"submit": "Speichern",
"submitting": "Speichern …",
"close": "Schließen",
"saved": "Paket gespeichert.",
"errors": {
"confirm_required": "Bitte die Änderung bestätigen.",
"invalid_input": "Bitte Eingaben prüfen (Plätze 0–1000).",
"tenant_archived": "Der Mandant ist archiviert.",
"platform_admin_required": "Nur Voll-Administratoren dürfen das Paket ändern.",
"failed": "Speichern nicht möglich."
}
}
}
+269
View File
@@ -0,0 +1,269 @@
{
"meta": {
"title": "Kostenlos testen",
"confirmTitle": "Testphase bestätigen",
"termsTitle": "Nutzungsbedingungen",
"privacyTitle": "Datenschutz"
},
"public": {
"heading": "Craftvia kostenlos testen",
"intro": "In wenigen Minuten eingerichtet. Ohne Zahlungsdaten, ohne automatische Verlängerung.",
"benefit1": "Aufträge planen, Einsätze mobil erfassen, Berichte mit Unterschrift",
"benefit2": "Enddatum frei wählen – danach bleiben Ihre Daten lesbar und exportierbar",
"benefit3": "Auf Wunsch mit Beispieldaten zum sofortigen Ausprobieren",
"haveAccount": "Schon einen Zugang?",
"login": "Anmelden",
"terms": "Nutzungsbedingungen",
"privacy": "Datenschutz"
},
"steps": {
"company": "Betrieb",
"account": "Admin-Konto",
"period": "Testzeitraum",
"setup": "Einrichtung",
"summary": "Zusammenfassung"
},
"progress": "Schritt {current} von {total}",
"progressLabel": "Fortschritt",
"fields": {
"companyName": "Firmenname",
"sector": "Branche",
"sectorChoose": "Bitte wählen (optional)",
"sectorOther": "Branche (Freitext)",
"companySize": "Betriebsgröße",
"companySizeNone": "Keine Angabe",
"adminName": "Ihr Name",
"email": "E-Mail-Adresse",
"emailHint": "Die Adresse ist Ihr Anmeldename. Wir senden Ihnen einen Bestätigungslink.",
"password": "Passwort",
"passwordHint": "Anforderungen: {policy}",
"showPassword": "Passwort anzeigen",
"trialEndDate": "Testphase bis",
"sampleData": "Mit Beispieldaten starten",
"sampleDataHint": "Drei Kunden, Objekte, ein Team und vier Aufträge in verschiedenen Zuständen. Sie können sie jederzeit löschen.",
"modules": "Module",
"modulesHint": "Alle Module sind vorausgewählt. Nicht benötigte Module können Sie abwählen – später lässt sich das ändern.",
"acceptTerms": "Ich akzeptiere die {link}.",
"acceptPrivacy": "Ich habe die {link} gelesen.",
"required": "Pflichtfeld",
"website": "Website (bitte leer lassen)"
},
"sectors": {
"shk": "Sanitär, Heizung, Klima",
"electrical": "Elektro",
"roofing": "Dach und Fassade",
"carpentry": "Tischlerei und Innenausbau",
"painting": "Maler und Lackierer",
"facility": "Gebäudetechnik und Facility",
"construction": "Bau und Montage",
"metal": "Metallbau",
"other": "Andere Branche"
},
"sizes": {
"1_5": "1–5 Beschäftigte",
"6_20": "6–20 Beschäftigte",
"21_50": "21–50 Beschäftigte",
"51_200": "51–200 Beschäftigte",
"200_": "mehr als 200 Beschäftigte"
},
"period": {
"until": "Testphase bis {date} ({days, plural, one {# Tag} other {# Tage}})",
"range": "Möglich: {min} bis {max}",
"afterEnd": "Nach dem Enddatum bleiben Ihre Daten lesbar und exportierbar. Ohne Umwandlung in die Vollversion löschen wir sie 30 Tage später."
},
"summary": {
"intro": "Bitte prüfen Sie Ihre Angaben.",
"sampleYes": "mit Beispieldaten",
"sampleNo": "ohne Beispieldaten",
"modulesCount": "{count} von {total} Modulen",
"edit": "Ändern",
"noSector": "keine Branche angegeben"
},
"actions": {
"back": "Zurück",
"next": "Weiter",
"submit": "Testphase starten",
"submitting": "Wird gesendet …",
"checking": "Wird geprüft …"
},
"sent": {
"title": "Fast geschafft – bitte E-Mail bestätigen",
"body": "Wir haben Ihnen eine E-Mail geschickt. Klicken Sie auf den Link darin, um Ihre Testphase zu starten.",
"hint": "Der Link ist 24 Stunden gültig. Keine E-Mail erhalten? Prüfen Sie den Spam-Ordner oder starten Sie die Anmeldung erneut.",
"again": "Anmeldung neu starten"
},
"errors": {
"company_required": "Bitte den Firmennamen angeben (mindestens 2 Zeichen).",
"too_long": "Die Eingabe ist zu lang.",
"invalid_choice": "Bitte einen Eintrag aus der Liste wählen.",
"name_required": "Bitte Ihren Namen angeben.",
"email_invalid": "Bitte eine gültige E-Mail-Adresse angeben.",
"password_policy": "Das Passwort erfüllt die Anforderungen nicht.",
"date_invalid": "Bitte ein gültiges Datum wählen.",
"date_too_early": "Das Enddatum muss frühestens morgen sein.",
"date_too_late": "Das Enddatum liegt zu weit in der Zukunft.",
"modules_required": "Bitte mindestens ein Modul auswählen.",
"terms_required": "Bitte die Nutzungsbedingungen akzeptieren.",
"privacy_required": "Bitte die Datenschutzhinweise bestätigen.",
"invalid_request": "Die Anfrage war unvollständig. Bitte Seite neu laden.",
"rate_limited": "Zu viele Versuche. Bitte in einigen Minuten erneut versuchen.",
"failed": "Das hat nicht geklappt. Bitte erneut versuchen."
},
"confirm": {
"title": "Testphase starten",
"intro": "Bestätigen Sie Ihre Anmeldung. Danach richten wir Ihren Zugang ein und melden Sie direkt an.",
"company": "Betrieb",
"until": "Testphase bis",
"sampleData": "Beispieldaten",
"yes": "ja",
"no": "nein",
"button": "Jetzt einrichten",
"pending": "Wird eingerichtet …",
"invalid": "Der Link ist ungültig oder wurde bereits verwendet.",
"expired": "Der Link ist abgelaufen. Bitte starten Sie die Anmeldung erneut.",
"rate_limited": "Zu viele Versuche. Bitte in einigen Minuten erneut versuchen.",
"toSignup": "Zur Anmeldung „Kostenlos testen“",
"toLogin": "Zum Login"
},
"legal": {
"back": "Zurück zur Anmeldung",
"placeholder": "Platzhalter – der verbindliche Text wird vom Betreiber vor dem Go-live eingesetzt.",
"termsBody1": "Die Testphase ist kostenlos und endet automatisch zum gewählten Datum. Es entsteht kein Vertrag über eine kostenpflichtige Nutzung.",
"termsBody2": "Nach dem Ende sind die Daten 30 Tage lesbar und exportierbar. Ohne Umwandlung in die Vollversion werden sie danach gelöscht.",
"privacyBody1": "Wir verarbeiten Ihre Angaben (Firmenname, Name, E-Mail-Adresse) zur Einrichtung und Betreuung der Testphase. Passwörter speichern wir ausschließlich als sicheren Hash.",
"privacyBody2": "Nicht bestätigte Anmeldungen löschen wir nach wenigen Tagen. Daten der Testphase werden nach Ablauf gemäß den Nutzungsbedingungen gelöscht."
},
"banner": {
"endsIn": "{days, plural, =0 {Testphase endet heute.} =1 {Testphase endet morgen.} other {Testphase endet in # Tagen.}}",
"endsOn": "Letzter Tag: {date}.",
"expired": "Testphase abgelaufen – nur Lesezugriff.",
"deletion": "Daten werden am {date} gelöscht.",
"contact": "Vollversion oder Verlängerung: {email}",
"contactGeneric": "Vollversion oder Verlängerung? Wenden Sie sich an Ihren Ansprechpartner bei Craftvia.",
"export": "Daten exportieren"
},
"onboarding": {
"title": "Erste Schritte",
"welcome": "Willkommen! Ihre Testphase ist eingerichtet.",
"progress": "{done} von {total} erledigt",
"hide": "Ausblenden",
"markDone": "Als erledigt markieren",
"markOpen": "Wieder öffnen",
"open": "Öffnen",
"auto": "erkannt",
"doneLabel": "Erledigt",
"openLabel": "Offen",
"items": {
"company": { "title": "Firmendaten prüfen", "text": "Adresse, Telefon und E-Mail erscheinen auf Berichten." },
"team": { "title": "Team anlegen", "text": "Teams bündeln Monteure für Planung und Zuweisung." },
"technician": { "title": "Monteur einladen", "text": "Ein Einladungslink genügt – das Passwort vergibt die Person selbst." },
"first_order": { "title": "Ersten Auftrag anlegen oder importieren", "text": "Manuell erfassen oder ein Auftrags-PDF hochladen." },
"mobile": { "title": "Mobile App öffnen", "text": "Auf dem Smartphone /m aufrufen und zum Startbildschirm hinzufügen." }
}
},
"export": {
"crumb": "Organisation",
"title": "Datenexport",
"sub": "Alle Daten Ihres Betriebs als ZIP-Datei",
"contents": "Enthalten: Kunden, Ansprechpartner, Objekte, Teams, Aufträge, Zeiten, Material, Berichte, Meilensteine und Abrechnung als CSV und JSON sowie alle gespeicherten Dateien (Dokumente, Berichts-PDFs, Fotos).",
"readOnlyHint": "Der Export ist auch nach Ablauf der Testphase möglich.",
"request": "Export erstellen",
"requested": "Export angefordert. Die Datei steht in wenigen Minuten bereit – laden Sie die Seite dann neu.",
"listTitle": "Letzte Exporte",
"empty": "Noch kein Export erstellt.",
"created": "Angefordert",
"state": "Status",
"file": "Datei",
"status": { "queued": "Wartet", "running": "Wird erstellt", "done": "Bereit", "failed": "Fehlgeschlagen", "expired": "Abgelaufen" },
"download": "Herunterladen",
"expires": "bis {date}",
"errors": {
"export_running": "Es läuft bereits ein Export. Bitte warten Sie, bis er fertig ist.",
"failed": "Der Export konnte nicht angefordert werden."
}
},
"platform": {
"newTrial": "Testmandant anlegen",
"filter": "Filter",
"filterAll": "Alle",
"filterTrial": "Test",
"filterFull": "Voll",
"colPlan": "Version",
"badgeFull": "Vollversion",
"badgeUntil": "Test bis {date}",
"badgeExpired": "abgelaufen – nur lesen",
"badgeDeletion": "Löschung am {date}",
"badgeDeleted": "gelöscht",
"wizard": {
"crumb": "Plattform-Betrieb",
"title": "Testmandant anlegen",
"sub": "Mandant mit Enddatum anlegen; der Admin erhält eine Einladung zum Passwort-Setzen.",
"sectionCompany": "1. Betrieb",
"sectionAdmin": "2. Administrator",
"sectionPeriod": "3. Testzeitraum",
"sectionSetup": "4. Einrichtung",
"companyName": "Firmenname",
"sector": "Branche (optional)",
"adminName": "Name",
"adminEmail": "E-Mail-Adresse",
"adminHint": "Neue Adressen erhalten den bestehenden Einladungslink (7 Tage gültig). Bestehende Zugänge werden nur verknüpft.",
"endDate": "Testphase bis",
"endDateHint": "Frei wählbar bis ein Jahr im Voraus.",
"sampleData": "Mit Beispieldaten",
"submit": "Testmandant anlegen",
"submitting": "Wird angelegt …",
"cancel": "Abbrechen"
},
"card": {
"title": "Testphase",
"plan": "Version",
"until": "Letzter Tag",
"state": "Zustand",
"stateActive": "läuft",
"stateExpired": "abgelaufen – nur lesen",
"stateConverted": "in Vollversion umgewandelt",
"stateDeleted": "gelöscht",
"deletion": "Löschung",
"deletionNone": "nicht vorgemerkt",
"source": "Herkunft",
"sourceSelf": "Selbstanmeldung",
"sourcePlatform": "Plattform-Wizard",
"extend": "Enddatum ändern / verlängern",
"convert": "In Vollversion umwandeln",
"end": "Testphase sofort beenden",
"schedule": "Löschung vormerken",
"cancel": "Löschung abbrechen",
"created": "Testmandant angelegt.",
"invited": "Die Einladung an den Administrator wurde verschickt.",
"done": "Änderung gespeichert."
},
"ops": {
"extendTitle": "Enddatum ändern",
"extendText": "Neues letztes Testdatum. Liegt es in der Zukunft, ist der Mandant sofort wieder schreibbar; Erinnerungen werden neu geplant.",
"convertTitle": "In Vollversion umwandeln",
"convertText": "Der Mandant bleibt dauerhaft schreibbar und wird nie automatisch gelöscht.",
"endTitle": "Testphase sofort beenden",
"endText": "Der Mandant ist ab sofort nur noch lesbar. Eine vorgemerkte Löschung verschiebt sich auf 30 Tage ab heute.",
"scheduleTitle": "Löschung vormerken",
"scheduleText": "Löschung 30 Tage nach Ende, frühestens in 7 Tagen (Hinweismail an die Admins).",
"cancelTitle": "Löschung abbrechen",
"cancelText": "Der Mandant wird nicht automatisch gelöscht. Er bleibt nach Ablauf nur lesbar.",
"confirm": "Ich bestätige diese Änderung.",
"submit": "Ausführen",
"submitting": "Wird ausgeführt …",
"close": "Schließen"
},
"errors": {
"confirm_required": "Bitte die Bestätigung ankreuzen.",
"invalid_input": "Bitte die Eingaben prüfen.",
"date_invalid": "Bitte ein gültiges Datum wählen.",
"date_in_past": "Das Enddatum darf nicht in der Vergangenheit liegen.",
"date_too_late": "Das Enddatum liegt mehr als ein Jahr in der Zukunft.",
"not_a_trial": "Dieser Mandant ist keine Testversion.",
"tenant_deleted": "Der Mandant wurde bereits gelöscht.",
"platform_admin_required": "Nur Plattform-Voll-Administratoren dürfen das.",
"tenant not found": "Mandant nicht gefunden.",
"failed": "Die Aktion ist fehlgeschlagen."
}
}
}
+2 -1
View File
@@ -6,7 +6,8 @@
"emergency": "Emergency", "emergency": "Emergency",
"sync": "Sync", "sync": "Sync",
"profile": "Profile", "profile": "Profile",
"approvalsBadge": "{count} time entries to approve" "approvalsBadge": "{count} time entries to approve",
"lotse": "Lotse"
}, },
"connection": { "connection": {
"online": "Online", "online": "Online",
+175 -2
View File
@@ -89,6 +89,8 @@
"use": "Usage", "use": "Usage",
"enabled": "Use Lotse in this business", "enabled": "Use Lotse in this business",
"enabledHint": "Switched off: no report drafts, no summaries, voice notes without transcription.", "enabledHint": "Switched off: no report drafts, no summaries, voice notes without transcription.",
"chatEnabled": "Lotse chat for technicians",
"chatEnabledHint": "Technicians write or talk to the Lotse. Actions (status, times, material, notes) are only executed after the technician confirms them.",
"addressForm": "Lotse form of address", "addressForm": "Lotse form of address",
"addressFormHint": "Applies consistently to all Lotse texts in this business.", "addressFormHint": "Applies consistently to all Lotse texts in this business.",
"address": { "address": {
@@ -114,7 +116,9 @@
"order": "Work order title, description and scope", "order": "Work order title, description and scope",
"notes": "Activity notes and voice note texts", "notes": "Activity notes and voice note texts",
"work": "Checklist, material with deviation reasons, photo comments, working times", "work": "Checklist, material with deviation reasons, photo comments, working times",
"audio": "For transcription: the audio file of the voice note" "audio": "For transcription: the audio file of the voice note",
"chat": "Lotse chat: the technician's messages (phone numbers, e-mail and postal addresses replaced) and the order data above. Customer names and contact data only as placeholders – values are inserted only in the answer on the technician's device.",
"chatAudio": "Voice input in the chat: the recording for transcription (not stored)"
}, },
"notSentTitle": "Not sent", "notSentTitle": "Not sent",
"notSent": { "notSent": {
@@ -152,7 +156,176 @@
"voice_summary": "Voice note summary", "voice_summary": "Voice note summary",
"transcription": "Transcription", "transcription": "Transcription",
"import_extraction": "Order import", "import_extraction": "Order import",
"completeness_check": "Completeness check" "completeness_check": "Completeness check",
"lotse_chat": "Lotse chat"
} }
},
"chat": {
"title": "Lotse chat",
"intro": "Briefly write or say what is done – e.g. “Order finished, storage tank installed, 2 hrs, 1 filter fitted”. The Lotse proposes actions. Nothing is saved before confirmation.",
"context": "Order {number} · {title}",
"noContext": "No order context",
"ask": "Ask the Lotse",
"input": "Message to the Lotse",
"send": "Send",
"sending": "Lotse is thinking …",
"newChat": "New chat",
"userLabel": "Own message",
"lotseLabel": "Lotse",
"empty": "No messages yet.",
"notConfigured": "The Lotse is not set up. The office can configure the AI provider.",
"offline": "No connection. The Lotse needs internet – your input is kept.",
"chipsLabel": "Suggested answers",
"linksLabel": "Complete directly",
"confirmAll": "Confirm all ({count})",
"mic": {
"record": "Speak",
"stop": "Stop recording",
"recording": "Recording · {time}",
"transcribing": "Converting to text …",
"unavailable": "Voice input is not set up.",
"denied": "No access to the microphone.",
"unsupported": "Voice input is not supported on this device.",
"failed": "Conversion to text failed. Please type.",
"hint": "Check the text before sending."
},
"card": {
"confirm": "Confirm",
"edit": "Edit",
"discard": "Discard",
"saveConfirm": "Apply and confirm",
"cancel": "Cancel",
"busy": "Running …",
"validUntil": "Valid until {time}",
"status": {
"proposed": "Open – please confirm",
"confirmed": "Done",
"discarded": "Discarded",
"expired": "Expired",
"failed": "Not executed"
},
"kind": {
"transition_work_order": "Change status",
"book_time": "Book time",
"record_material": "Record material",
"add_note": "Save note",
"suggest_report_fields": "Report suggestion"
},
"action": {
"accept": "Accept order {number}",
"start_travel": "Start travel to {number}",
"start_work": "Start work on {number}",
"pause": "Pause {number}",
"resume": "Resume work on {number}",
"complete": "Technically complete order {number}"
},
"switchFrom": "The running clock of {number} will be paused.",
"completeHint": "Your own clock is stopped. Missing mandatory items prevent completion.",
"order": "Order {number}",
"timeLine": "{date} · {from}–{to} · {duration} hrs",
"timeType": {
"work": "Working time",
"travel": "Travel",
"return_travel": "Return travel",
"material_procurement": "Fetching material"
},
"type": "Type",
"date": "Date",
"from": "From",
"to": "To",
"reason": "Reason",
"note": "Note",
"approvalHint": "Counts after approval by the team lead or office.",
"pendingApproval": "Submitted for approval",
"planned": "Planned item",
"additional": "Additional material",
"name": "Name",
"quantity": "Quantity",
"unit": "Unit",
"deviationReason": "Reason for deviation",
"noteKind": "Note type",
"text": "Text",
"reportFields": "Suggestions for the {type}",
"reportType": {
"daily": "daily report",
"completion": "completion report"
},
"reportHint": "Appears in the report as “Suggestion from the Lotse” and is accepted or discarded there.",
"openReport": "Open report",
"field": {
"workPerformed": "Work performed",
"deviations": "Deviations",
"additionalWork": "Additional work",
"openItems": "Open items",
"nextSteps": "Next steps",
"hints": "Notes"
}
},
"noteKind": {
"work_done": "Work done",
"deviation": "Deviation",
"problem": "Problem",
"additional_work": "Additional work",
"not_executable": "Not executable",
"follow_up": "Follow-up",
"recommendation": "Recommendation",
"customer_note": "Customer note",
"general": "General"
},
"system": {
"failed": "Not executed ({number}): {reason}",
"stopped": "“Confirm all” stopped – {remaining, plural, one {# card is} other {# cards are}} still open.",
"rounds_exceeded": "That was too much at once. Please write shorter or in steps.",
"turn_budget": "That was too much at once. Please write shorter or in steps.",
"refusal": "The Lotse cannot propose anything for this.",
"provider_failed": "The Lotse is not reachable right now. Please try again later.",
"proposals_ready": "Proposals are ready. Please check and confirm.",
"empty": "No answer received. Please rephrase."
},
"links": {
"order": "Open order",
"report": "Open report",
"myTime": "Open my times",
"materials": "Open material",
"otherOrder": "Open running order",
"runningSession": "A colleague's clock is still running"
},
"errors": {
"generic": "That did not work. Please try again.",
"not_found": "Not found.",
"forbidden": "You lack the permission.",
"disabled": "The Lotse is switched off for this company.",
"chat_disabled": "The Lotse chat is switched off for this company.",
"not_configured": "The Lotse is not set up.",
"provider_failed": "The Lotse is not reachable right now. Please try again later.",
"budget_exceeded": "The company's monthly AI quota is used up.",
"invalid": "Please check your input.",
"conflict": "Changed in the meantime. Please reload.",
"expired": "Proposal expired (after 30 minutes). Please ask again.",
"already_decided": "This proposal has already been handled.",
"tampered": "The proposal was changed and not executed.",
"blocked": "Mandatory items are missing.",
"offline": "No connection – nothing sent.",
"overlap": "The period overlaps with recorded time.",
"too_old": "Entries can only be added up to 7 days back.",
"in_future": "Times in the future cannot be booked.",
"too_long": "An entry may be at most 16 hours long.",
"end_before_start": "The end is before the start.",
"work_order_status": "Not possible in the current order status.",
"other_session_running": "A clock is still running on another order.",
"transition_not_allowed": "This status change is not possible right now.",
"transition_forbidden": "You lack the permission.",
"not_editable": "The report has been submitted and can no longer be changed.",
"reason_required": "The reason is missing.",
"use_billing_overview": "Only possible via the billing overview.",
"not_in_plan": "The Lotse is included in the Profi package and not activated for this company.",
"no_seat": "The Lotse chat is not activated for you yet – please ask the office.",
"quota_exhausted": "The company's chat quota for this month is used up."
},
"unavailable": {
"title": "Lotse chat not available",
"back": "Back to overview"
},
"quotaExhausted": "The company's chat quota for this month is used up. You can write again from next month – the office can help with questions."
} }
} }
+419
View File
@@ -0,0 +1,419 @@
{
"meta": {
"title": "Craftvia — field service app for trades and installation",
"description": "Jobs, scheduling, time tracking, reports and billing in one system. Mobile, offline-capable and tenant-safe — for trade and installation businesses."
},
"nav": {
"label": "Main navigation",
"features": "Features",
"planning": "Scheduling",
"mobile": "On site",
"security": "Security",
"pricing": "Free trial",
"faq": "FAQ",
"login": "Sign in",
"cta": "Start free trial",
"menu": "Menu"
},
"hero": {
"eyebrow": "For trade and installation businesses",
"title": "Craftwork. Digitally on course.",
"subtitle": "Craftvia brings jobs, scheduling, times, material and reports together — at the desk and on site. Even without a connection.",
"ctaPrimary": "Try it free for 14 days",
"ctaSecondary": "See the features",
"bullet1": "Set up in minutes, nothing to install",
"bullet2": "Works offline — entries are never lost",
"bullet3": "No payment details for the trial"
},
"pillars": {
"title": "What holds Craftvia together",
"one": {
"title": "One job, one truth",
"text": "Appointment, crew, times, material, photos and report belong to the same job — no notes, no double entry."
},
"two": {
"title": "Office and site in sync",
"text": "The office plans, the technician works. Both see the same state, without a phone call."
},
"three": {
"title": "Finished, not reworked",
"text": "Mandatory fields, photos and the signature are checked on site — the job is done when the technician drives off."
}
},
"features": {
"title": "Everything a job needs",
"sub": "Modules that work together. Enable what you need.",
"orders": {
"title": "Jobs",
"text": "From the call to sign-off: create, schedule, assign, see the status. Custom job types with checklists and required photos."
},
"board": {
"title": "Scheduling board",
"text": "Drag jobs onto a crew and a day. Utilisation, overbooking and overlaps are visible at once."
},
"live": {
"title": "Live situation",
"text": "Who is en route, working or on a break — at the site of the running job. No GPS tracking of people."
},
"time": {
"title": "Time tracking",
"text": "Start, pause, stop with one tap. Travel and work time separated; later corrections need a reason and approval."
},
"offline": {
"title": "Mobile and offline",
"text": "In a basement or a dead spot: entries are stored on the device and sent automatically."
},
"reports": {
"title": "Report and signature",
"text": "Fill in the service report on the device, the customer signs on screen, the PDF goes out."
},
"material": {
"title": "Material",
"text": "Record planned and actually used material, deviations with a reason — clean for costing."
},
"billing": {
"title": "Billing overview",
"text": "Completed jobs and reached milestones collect in one place: times, material and trips as the basis for the invoice."
},
"emergency": {
"title": "Call-outs",
"text": "Create an emergency job in minutes, add the details later, review in the office."
},
"lotse": {
"title": "Lotse — the assistant",
"text": "Technicians write or speak like in a chat. Lotse maps it to the job and asks what is missing. Nothing is booked before they confirm."
},
"documents": {
"title": "Documents",
"text": "Plans, photos and datasheets on the job and the site — available on the road."
},
"imports": {
"title": "Job import",
"text": "Read jobs from PDF: data is proposed and checked before it is accepted."
}
},
"planning": {
"eyebrow": "Scheduling",
"title": "See the week before it goes wrong",
"text": "The board shows every crew with its daily capacity. If a day gets too full or two appointments overlap, it is written on the card — not in the customer's phone call.",
"point1": "Utilisation per crew and day in percent",
"point2": "Warnings for overbooking, overlap and incomplete crews",
"point3": "A hint when a job runs longer than planned — and the next one is at risk",
"point4": "Suggestions for nearby jobs when a crew finishes early"
},
"mobile": {
"eyebrow": "On site",
"title": "Four taps and the job is documented",
"text": "The mobile view is built for gloves and bright light: large targets, clear steps, no hunting through menus.",
"step1": {
"title": "Start the drive",
"text": "Travel time runs automatically."
},
"step2": {
"title": "Work on site",
"text": "Tick the checklist, take photos, record material."
},
"step3": {
"title": "Fill in the report",
"text": "Dictate or type; mandatory fields are checked."
},
"step4": {
"title": "Collect the signature",
"text": "The customer signs on screen, the PDF goes out."
},
"offlineNote": "No connection? Everything keeps working and is transferred later."
},
"lotse": {
"eyebrow": "Lotse",
"title": "Say what happened — instead of hunting for fields",
"text": "Lotse understands whole sentences, maps them to the job and asks for what is missing. It shows a card with what it would book. Nothing is written before you confirm.",
"chatUser": "Job done, installed the tank, two hours, used one filter",
"chatAssistant": "I'm mapping this to job A-00042, Lindenhof apartments. Two questions: was travel included, and should I mark the job technically complete?",
"cardTitle": "Proposal",
"cardLine1": "Book 2:00 h of work time",
"cardLine2": "Record 1 × filter as material",
"cardLine3": "Mark the job technically complete",
"cardConfirm": "Confirm",
"cardDiscard": "Discard",
"note": "Permissions, mandatory fields and approvals still apply — the assistant bypasses nothing."
},
"steps": {
"title": "Ready in three steps",
"one": {
"title": "Create the account",
"text": "Enter your company, pick the trial period, confirm your e-mail. With sample data if you like."
},
"two": {
"title": "Add crew and customers",
"text": "Invite technicians, form crews, add or import customers and sites."
},
"three": {
"title": "Run the first job",
"text": "Schedule it, the technician handles it on the phone, the report comes back to the office."
}
},
"industries": {
"title": "For businesses that drive out",
"sub": "Built for installation and service — whatever the trade.",
"shk": "Plumbing, heating, air conditioning",
"electric": "Electrical engineering",
"cooling": "Refrigeration and ventilation",
"lifts": "Lifts, gates, drives",
"windows": "Windows, doors, façades",
"service": "Industrial and plant service"
},
"security": {
"eyebrow": "Security and privacy",
"title": "Your data stays yours",
"text": "Craftvia is built multi-tenant: every business sees only its own data — enforced down in the database, not just in the interface.",
"item1": {
"title": "Strict tenant separation",
"text": "Access rules in the database itself keep data inside the business."
},
"item2": {
"title": "Roles and permissions",
"text": "Admin, office, crew lead and technician see exactly what they need. Checked on the server."
},
"item3": {
"title": "Two-factor and passkeys",
"text": "Sign in with a second factor or a passkey, enforceable for the whole business."
},
"item4": {
"title": "Complete audit trail",
"text": "Every write is recorded — who, when, what."
},
"item5": {
"title": "Export any time",
"text": "Download your data as a file — including at the end of the trial."
},
"item6": {
"title": "Data minimalism",
"text": "Location means the site of the running job, never tracking of a person."
}
},
"trial": {
"eyebrow": "Free trial",
"title": "Try it for 14 days, decide in peace",
"text": "You set the trial period yourself and start empty or with sample data. After it ends your data stays readable and exportable — nothing disappears overnight.",
"point1": "Full functionality, no payment details",
"point2": "Reminder before it ends, extension any time",
"point3": "Move to the full version without migrating data",
"cta": "Start the trial",
"secondary": "Request a quote",
"note": "Pricing for the full version on request — we come back with a proposal that fits your size."
},
"faq": {
"title": "Frequently asked questions",
"q1": {
"q": "Do my technicians need training?",
"a": "No. The mobile view guides them through the job: drive, work, document, collect the signature. Most teams start after a short briefing."
},
"q2": {
"q": "Does the app work without internet?",
"a": "Yes. Entries, photos and times are stored on the device and transferred automatically once there is a connection again."
},
"q3": {
"q": "Do I have to install anything?",
"a": "No. Craftvia runs in the browser and can be added to the home screen like an app."
},
"q4": {
"q": "What happens after the trial?",
"a": "Your data stays readable and exportable. You can extend or move to the full version — without migrating data."
},
"q5": {
"q": "Can I bring existing customers and jobs?",
"a": "Yes. Customers and sites can be imported, jobs additionally read from PDF and checked before they are accepted."
},
"q6": {
"q": "Are my technicians tracked by GPS?",
"a": "No. What is shown is the site of the running job, not the position of the device or the person."
},
"q7": {
"q": "Does Craftvia replace my accounting?",
"a": "No. Craftvia prepares billing — times, material, trips and milestones. Your accounting writes the invoice."
},
"q8": {
"q": "How many users are possible?",
"a": "From a one-person business to several crews. You define teams, roles and capacity yourself."
}
},
"closing": {
"title": "Ready for the first job?",
"text": "Set up your trial access and plan the first week today.",
"cta": "Start free trial",
"login": "I already have an account"
},
"footer": {
"tagline": "The field service app for trade and installation businesses.",
"product": "Product",
"company": "Legal",
"features": "Features",
"trial": "Free trial",
"login": "Sign in",
"terms": "Terms of use",
"privacy": "Privacy",
"imprint": "Imprint",
"rights": "All rights reserved."
},
"pages": {
"planung": {
"title": "Scheduling board",
"lead": "The week at a glance: distribute jobs across crews, see utilisation, spot conflicts before the customer calls.",
"p1": "Drag jobs onto a crew and a day — or schedule them entirely without a mouse",
"p2": "Utilisation per crew and day in percent, measured against the crew day",
"p3": "Warnings for overbooking, overlap and incomplete crews",
"p4": "Unplanned jobs in their own column, with suggestions attached to the card",
"p5": "The live view shows who is en route, working or on a break",
"shot1": "Scheduling board across five days with utilisation and marked conflicts",
"shot2": "Live situation as tiles: crews with status, job and place",
"c1title": "Crews, not individuals",
"c1text": "A crew travels together. Capacity is the crew day, not the sum of person-hours.",
"c2title": "Finished early, used well",
"c2text": "When a job ends early, Craftvia suggests nearby work — a person decides.",
"c3title": "Delays made visible",
"c3text": "If a job overruns, the board marks the following one as at risk."
},
"baustelle": {
"title": "On site",
"lead": "The mobile view guides through the job: drive, work, document, collect the signature — even without a connection.",
"p1": "Large targets and clear steps, usable with work gloves",
"p2": "Checklist, required photos and material right on the job",
"p3": "Works offline: entries are stored and sent automatically",
"p4": "The customer signs on screen, the PDF goes out afterwards",
"p5": "Create call-out jobs in minutes and complete them later",
"shot1": "The technician's day view with the running timer",
"shot2": "A job on the phone: status, checklist, material and photos in one place",
"c1title": "No more paper",
"c1text": "What is captured on site is immediately available in the office.",
"c2title": "Mandatory fields before sign-off",
"c2text": "Missing photos or signatures surface on site, not days later.",
"c3title": "Sync without thinking",
"c3text": "The app transfers on its own as soon as there is a connection."
},
"zeiterfassung": {
"title": "Time tracking",
"lead": "Start, pause, stop with one tap. Travel and work time separated, corrections traceable.",
"p1": "The running timer is visible on every page, with a one-tap \"finish for today\"",
"p2": "Automatic switch when work starts on another job",
"p3": "Add times for the last few days — with a reason",
"p4": "Approval by the crew lead or the office; pending entries never count for billing",
"p5": "The office can record times for staff, recorded in the audit trail",
"shot1": "My times: the day with travel and work time",
"shot2": "Approval in the office: review and confirm pending entries",
"c1title": "Honest hours",
"c1text": "Time is recorded while working, not from memory in the evening.",
"c2title": "Corrections stay visible",
"c2text": "Every correction carries a reason and an approval.",
"c3title": "Travel kept separate",
"c3text": "Trips count on their own and appear as a position in billing."
},
"berichte": {
"title": "Reports and signature",
"lead": "The service report is created on site — checked, signed and sent to the customer as a PDF.",
"p1": "The report sits on the job, with a template per job type",
"p2": "Mandatory fields and photos are checked before submission",
"p3": "Signature on screen, or \"customer not present\" with a reason",
"p4": "Approval in the office, then automatic dispatch as a PDF",
"p5": "Every version is kept; older ones are marked as superseded",
"shot1": "Reports in the office: from submitted to approved",
"shot2": "Job detail with times, material, photos and the report",
"c1title": "One record, not a collection",
"c1text": "Report, photos, times and material belong to the same job.",
"c2title": "Traceable",
"c2text": "Who submitted, checked and approved is in the audit trail.",
"c3title": "Straight to the customer",
"c3text": "After approval the PDF is e-mailed automatically."
},
"abrechnung": {
"title": "Billing overview",
"lead": "Completed jobs and reached milestones collect in one place — with times, material and trips for accounting.",
"p1": "Completed jobs, milestones and approved daily reports in one list",
"p2": "Only approved times count; pending entries are shown separately",
"p3": "Trips from time tracking, counted per crew and day",
"p4": "Billing sheet as a PDF, \"billed\" with an optional invoice number",
"p5": "Voiding needs a reason; billed positions stay clearly assigned",
"shot1": "Billing overview with open and billed records",
"c1title": "No double billing",
"c1text": "Every hour and every part belongs to exactly one billed section.",
"c2title": "Accounting stays accounting",
"c2text": "Craftvia provides the basis — prices, taxes and invoices stay in your system.",
"c3title": "Milestones instead of one final bill",
"c3text": "On long projects you bill what has been reached."
},
"lotse": {
"title": "Lotse — the assistant",
"lead": "Say what happened: Lotse maps it, asks what is missing and proposes bookings. Nothing is stored before you confirm.",
"p1": "Write or speak like in a chat, even mid-job",
"p2": "Maps the job itself — from context, the running timer or the day's plan",
"p3": "Asks for missing details instead of guessing",
"p4": "Shows action cards for status, times, material and notes",
"p5": "Permissions, mandatory fields and approvals still apply",
"shot1": "Lotse chat on the phone: input by text or voice",
"c1title": "Confirm instead of typing",
"c1text": "The technician checks a card and taps once — instead of filling four forms.",
"c2title": "Nothing happens quietly",
"c2text": "Without confirmation nothing is booked. Every booking lands in the audit trail.",
"c3title": "A reference too",
"c3text": "Questions like \"where is the access code?\" are answered from the job."
}
},
"overview": {
"title": "Features at a glance",
"lead": "Craftvia covers the whole job — from the incoming request to the basis for the invoice. Every module can be enabled on its own.",
"more": "Learn more"
},
"securityPage": {
"title": "Security and privacy",
"lead": "Craftvia is built multi-tenant. Every business sees only its own data — enforced down in the database, not just in the interface.",
"shot1": "Audit trail: every write with time, person and change",
"shot2": "Teams and roles: assignment decides who sees which jobs",
"detailTitle": "What that means day to day",
"d1title": "Field roles see only their jobs",
"d1text": "Technicians see their crew's jobs, not the company's customer list.",
"d2title": "Permissions are enforced on the server",
"d2text": "Every change is checked server-side. Hidden buttons are convenience, not security.",
"d3title": "Traceable, not trusting",
"d3text": "The trail records who changed what and when — useful for accounting too."
},
"pricingPage": {
"title": "Trial and pricing",
"lead": "Start with a trial and decide afterwards. The price of the full version depends on the size of your business.",
"trialTitle": "Trial",
"trialText": "Full functionality, period of your choice, no payment details. After it ends your data stays readable and exportable.",
"t1": "Up to 30 days, end date of your choice",
"t2": "Start with sample data or empty",
"t3": "Reminder before it ends, extension any time",
"fullTitle": "Full version",
"fullText": "Switch over without migrating data: the trial tenant is converted, everything is kept.",
"f1": "All modules, unlimited jobs",
"f2": "Priced by users and crews",
"f3": "A quote after a short conversation",
"note": "We name concrete prices once they are settled — deliberately no number here that would not hold later."
},
"cta": {
"title": "Try Craftvia in your own business",
"text": "Set up trial access, load sample data and plan the first week — in minutes.",
"primary": "Start free trial",
"secondary": "See all features"
},
"shotFrame": {
"label": "Screenshot from Craftvia"
},
"imprint": {
"title": "Imprint",
"intro": "Information pursuant to § 5 of the German Digital Services Act (DDG).",
"provider": "Provider",
"represented": "Represented by",
"contact": "Contact",
"phone": "Phone",
"email": "E-mail",
"register": "Commercial register",
"registerCourt": "Register court",
"registerNumber": "Register number",
"vat": "VAT ID",
"vatText": "VAT identification number pursuant to § 27 a of the German VAT Act:",
"editorial": "Editorially responsible",
"dispute": "Consumer dispute resolution",
"disputeText": "We are neither willing nor obliged to take part in dispute resolution proceedings before a consumer arbitration board."
}
}
+2 -1
View File
@@ -21,5 +21,6 @@
"openMenu": "Open menu", "openMenu": "Open menu",
"closeMenu": "Close menu", "closeMenu": "Close menu",
"timeApprovals": "Time approvals", "timeApprovals": "Time approvals",
"billing": "Billing" "billing": "Billing",
"dataExport": "Data export"
} }
+3 -1
View File
@@ -92,7 +92,9 @@
"internal": "Technical error – will retry automatically.", "internal": "Technical error – will retry automatically.",
"timeOverlap": "The added time overlaps with time already recorded and was not accepted.", "timeOverlap": "The added time overlaps with time already recorded and was not accepted.",
"timeWindow": "The added time is outside the allowed 7 days, in the future or too long and was not accepted.", "timeWindow": "The added time is outside the allowed 7 days, in the future or too long and was not accepted.",
"otherSession": "Time was still running on another order – the start was not accepted. Please start again and switch." "otherSession": "Time was still running on another order – the start was not accepted. Please start again and switch.",
"notInPlan": "Included in the Profi package – not activated for this company. The entry was not applied, please contact the office.",
"moduleDisabled": "This function is switched off for this company. The entry was not applied, please contact the office."
}, },
"view": { "view": {
"title": "Offline view", "title": "Offline view",
+5 -2
View File
@@ -89,7 +89,8 @@
"noTeam": "no team", "noTeam": "no team",
"noDate": "no date", "noDate": "no date",
"noLocation": "no location", "noLocation": "no location",
"suggestions": "Suggestions" "suggestions": "Suggestions",
"suggestionsFor": "Suggestions for {number}"
}, },
"dialog": { "dialog": {
"title": "Schedule order", "title": "Schedule order",
@@ -150,7 +151,9 @@
"refreshing": "Refreshing …", "refreshing": "Refreshing …",
"updateFailed": "Update failed – showing the last state.", "updateFailed": "Update failed – showing the last state.",
"tabsLabel": "Display", "tabsLabel": "Display",
"tabs": { "map": "Map", "list": "List" }, "tabs": { "map": "Map", "tiles": "Tiles", "list": "List" },
"tileMembers": "{count} people",
"mapUnavailable": "Map tiles are currently unreachable – the tile view shows the same data.",
"filterTeam": "Team", "filterTeam": "Team",
"filterStatus": "Status", "filterStatus": "Status",
"all": "All", "all": "All",
+93
View File
@@ -0,0 +1,93 @@
{
"tier": {
"BASIS": "Basic",
"PROFI": "Profi"
},
"colTier": "Package",
"notInTier": "Profi package only",
"profiHint": {
"title": "Included in the Profi package",
"text": "This function is part of the Profi package. Your company uses the Basic package – please contact your Craftvia representative to upgrade."
},
"admin": {
"title": "Package & Lotse chat",
"sub": "Package of the company, chat seats for technicians and usage in the current month",
"package": "Package",
"trial": "Trial (all functions)",
"packageReadOnly": "The provider sets the package and the number of chat seats. Here you assign the purchased seats to your technicians and team leads.",
"basisHint": "Lotse and the Lotse chat are included in the Profi package. Assigned seats and chat histories are kept and available again after switching to Profi.",
"seatsTitle": "Chat seats",
"seatsCount": "{assigned} of {purchased} seats assigned",
"trialSeats": "During the trial all technicians and team leads may use the Lotse chat – no seats need to be assigned (quota: {perSeat} chats per person and month).",
"noSeatsBought": "No chat seats purchased yet. The provider books seats for your company.",
"overbooked": "More seats assigned than purchased ({purchased}). Only the seats assigned first are valid – please revoke seats.",
"noFieldUsers": "No active users with a field role (technician, team lead).",
"seatAssigned": "Seat assigned",
"seatOverbooked": "Above the seat number",
"seatNone": "No seat",
"assign": "Assign seat",
"revoke": "Revoke seat",
"assignLabel": "Assign chat seat to {name}",
"revokeLabel": "Revoke chat seat from {name}",
"saved": "Chat seat saved.",
"usageTitle": "Usage {month}",
"usageText": "{used} of {quota} chats",
"quotaRule": "Quota: {perSeat} chats per assigned seat and month, shared by the company (basis: {basis}). One chat is one message to the Lotse.",
"warn80": "{percent} % of the monthly quota are used.",
"exhausted": "The monthly quota is used up. Further chats are billed as overage in packs of {pack}.",
"hardLimitReached": "The monthly quota is used up. The Lotse chat is blocked until the end of the month (hard limit).",
"overage": "Overage: {overage} chats – equals {packs, plural, one {# pack} other {# packs}} of {pack} chats.",
"perUserCaption": "Chats per user in the current month",
"perUserName": "User",
"perUserChats": "Chats",
"errors": {
"no_seats_left": "All purchased seats are assigned. Please revoke a seat first or book more seats with the provider.",
"not_eligible": "This person cannot get a chat seat (no active field role).",
"not_in_plan": "The Lotse chat is included in the Profi package.",
"disabled": "Lotse is switched off for this company.",
"forbidden": "You are not allowed to do this.",
"trial_expired": "Trial expired – read-only access.",
"failed": "Could not save."
}
},
"platform": {
"title": "Package & Lotse chat",
"tier": "Package",
"trialProfi": "Trial = Profi",
"seats": "Chat seats",
"seatsValue": "{assigned} assigned / {purchased} purchased",
"hardLimit": "Hard limit",
"hardLimitOn": "on – blocked at the quota",
"hardLimitOff": "off – overage is counted",
"rule": "{perSeat} chats per assigned seat and month (tenant timezone); overage in packs of {pack}.",
"currentMonth": "Current month · {month}",
"previousMonth": "Previous month · {month}",
"chats": "{used} of {quota} chats",
"overage": "Overage {overage} chats → {packs, plural, one {# pack} other {# packs}} of {pack}",
"noOverage": "No overage",
"edit": "Change package …",
"editTitle": "Change package & chat seats",
"editText": "The change applies immediately to all users of the tenant and is logged in the platform audit.",
"tierHint": {
"BASIS": "Without AI and planning: no Lotse, no planning board/live situation, no billing overview, no emergency service, no order import.",
"PROFI": "All functions incl. planning, billing overview, emergency service, import and Lotse."
},
"downgradeHint": "Switching to Basic keeps all data, only access ends. Back on Profi everything is available again.",
"seatsInput": "Purchased chat seats",
"seatsHint": "The tenant admin assigns the seats to users with a field role.",
"hardLimitInput": "Hard limit",
"hardLimitHint": "When the monthly quota is used up the chat is blocked instead of counting overage.",
"confirm": "I checked the impact and confirm the change.",
"submit": "Save",
"submitting": "Saving …",
"close": "Close",
"saved": "Package saved.",
"errors": {
"confirm_required": "Please confirm the change.",
"invalid_input": "Please check the input (seats 0–1000).",
"tenant_archived": "The tenant is archived.",
"platform_admin_required": "Only full administrators may change the package.",
"failed": "Could not save."
}
}
}
+269
View File
@@ -0,0 +1,269 @@
{
"meta": {
"title": "Try for free",
"confirmTitle": "Confirm trial",
"termsTitle": "Terms of use",
"privacyTitle": "Privacy"
},
"public": {
"heading": "Try Craftvia for free",
"intro": "Set up in minutes. No payment details, no automatic renewal.",
"benefit1": "Plan work orders, record jobs on mobile, reports with signature",
"benefit2": "Choose the end date – afterwards your data stays readable and exportable",
"benefit3": "Optional sample data to try everything right away",
"haveAccount": "Already have an account?",
"login": "Sign in",
"terms": "Terms of use",
"privacy": "Privacy"
},
"steps": {
"company": "Company",
"account": "Admin account",
"period": "Trial period",
"setup": "Setup",
"summary": "Summary"
},
"progress": "Step {current} of {total}",
"progressLabel": "Progress",
"fields": {
"companyName": "Company name",
"sector": "Trade",
"sectorChoose": "Please choose (optional)",
"sectorOther": "Trade (free text)",
"companySize": "Company size",
"companySizeNone": "Not specified",
"adminName": "Your name",
"email": "E-mail address",
"emailHint": "The address is your sign-in name. We will send you a confirmation link.",
"password": "Password",
"passwordHint": "Requirements: {policy}",
"showPassword": "Show password",
"trialEndDate": "Trial until",
"sampleData": "Start with sample data",
"sampleDataHint": "Three customers, sites, one team and four work orders in different states. You can delete them at any time.",
"modules": "Modules",
"modulesHint": "All modules are preselected. Deselect modules you do not need – you can change this later.",
"acceptTerms": "I accept the {link}.",
"acceptPrivacy": "I have read the {link}.",
"required": "Required",
"website": "Website (leave empty)"
},
"sectors": {
"shk": "Plumbing, heating, air conditioning",
"electrical": "Electrical",
"roofing": "Roofing and facades",
"carpentry": "Carpentry and interior fit-out",
"painting": "Painting and decorating",
"facility": "Building services and facility",
"construction": "Construction and installation",
"metal": "Metalwork",
"other": "Other trade"
},
"sizes": {
"1_5": "1–5 employees",
"6_20": "6–20 employees",
"21_50": "21–50 employees",
"51_200": "51–200 employees",
"200_": "more than 200 employees"
},
"period": {
"until": "Trial until {date} ({days, plural, one {# day} other {# days}})",
"range": "Possible: {min} to {max}",
"afterEnd": "After the end date your data stays readable and exportable. Unless converted to the full version, we delete it 30 days later."
},
"summary": {
"intro": "Please check your details.",
"sampleYes": "with sample data",
"sampleNo": "without sample data",
"modulesCount": "{count} of {total} modules",
"edit": "Edit",
"noSector": "no trade specified"
},
"actions": {
"back": "Back",
"next": "Next",
"submit": "Start trial",
"submitting": "Sending …",
"checking": "Checking …"
},
"sent": {
"title": "Almost done – please confirm your e-mail",
"body": "We have sent you an e-mail. Click the link in it to start your trial.",
"hint": "The link is valid for 24 hours. No e-mail? Check your spam folder or start the signup again.",
"again": "Start signup again"
},
"errors": {
"company_required": "Please enter the company name (at least 2 characters).",
"too_long": "The input is too long.",
"invalid_choice": "Please choose an entry from the list.",
"name_required": "Please enter your name.",
"email_invalid": "Please enter a valid e-mail address.",
"password_policy": "The password does not meet the requirements.",
"date_invalid": "Please choose a valid date.",
"date_too_early": "The end date must be tomorrow at the earliest.",
"date_too_late": "The end date is too far in the future.",
"modules_required": "Please select at least one module.",
"terms_required": "Please accept the terms of use.",
"privacy_required": "Please confirm the privacy notice.",
"invalid_request": "The request was incomplete. Please reload the page.",
"rate_limited": "Too many attempts. Please try again in a few minutes.",
"failed": "That did not work. Please try again."
},
"confirm": {
"title": "Start trial",
"intro": "Confirm your signup. We then set up your account and sign you in right away.",
"company": "Company",
"until": "Trial until",
"sampleData": "Sample data",
"yes": "yes",
"no": "no",
"button": "Set up now",
"pending": "Setting up …",
"invalid": "The link is invalid or has already been used.",
"expired": "The link has expired. Please start the signup again.",
"rate_limited": "Too many attempts. Please try again in a few minutes.",
"toSignup": "Go to “Try for free”",
"toLogin": "Go to sign-in"
},
"legal": {
"back": "Back to signup",
"placeholder": "Placeholder – the binding text will be provided by the operator before go-live.",
"termsBody1": "The trial is free of charge and ends automatically on the chosen date. No contract for paid use is concluded.",
"termsBody2": "After the end the data stays readable and exportable for 30 days. Unless converted to the full version, it is deleted afterwards.",
"privacyBody1": "We process your details (company name, name, e-mail address) to set up and support the trial. Passwords are stored as a secure hash only.",
"privacyBody2": "Unconfirmed signups are deleted after a few days. Trial data is deleted after expiry as described in the terms of use."
},
"banner": {
"endsIn": "{days, plural, =0 {Trial ends today.} =1 {Trial ends tomorrow.} other {Trial ends in # days.}}",
"endsOn": "Last day: {date}.",
"expired": "Trial expired – read-only access.",
"deletion": "Data will be deleted on {date}.",
"contact": "Full version or extension: {email}",
"contactGeneric": "Full version or extension? Contact your Craftvia representative.",
"export": "Export data"
},
"onboarding": {
"title": "Getting started",
"welcome": "Welcome! Your trial is ready.",
"progress": "{done} of {total} done",
"hide": "Hide",
"markDone": "Mark as done",
"markOpen": "Reopen",
"open": "Open",
"auto": "detected",
"doneLabel": "Done",
"openLabel": "Open",
"items": {
"company": { "title": "Check company details", "text": "Address, phone and e-mail appear on reports." },
"team": { "title": "Create a team", "text": "Teams group technicians for planning and assignment." },
"technician": { "title": "Invite a technician", "text": "An invitation link is enough – the person sets their own password." },
"first_order": { "title": "Create or import the first work order", "text": "Enter it manually or upload a work order PDF." },
"mobile": { "title": "Open the mobile app", "text": "Open /m on your smartphone and add it to the home screen." }
}
},
"export": {
"crumb": "Organisation",
"title": "Data export",
"sub": "All data of your company as a ZIP file",
"contents": "Included: customers, contacts, sites, teams, work orders, times, material, reports, milestones and billing as CSV and JSON plus all stored files (documents, report PDFs, photos).",
"readOnlyHint": "The export stays available after the trial has expired.",
"request": "Create export",
"requested": "Export requested. The file is ready in a few minutes – reload the page then.",
"listTitle": "Recent exports",
"empty": "No export created yet.",
"created": "Requested",
"state": "Status",
"file": "File",
"status": { "queued": "Waiting", "running": "In progress", "done": "Ready", "failed": "Failed", "expired": "Expired" },
"download": "Download",
"expires": "until {date}",
"errors": {
"export_running": "An export is already running. Please wait until it has finished.",
"failed": "The export could not be requested."
}
},
"platform": {
"newTrial": "Create trial tenant",
"filter": "Filter",
"filterAll": "All",
"filterTrial": "Trial",
"filterFull": "Full",
"colPlan": "Version",
"badgeFull": "Full version",
"badgeUntil": "Trial until {date}",
"badgeExpired": "expired – read-only",
"badgeDeletion": "Deletion on {date}",
"badgeDeleted": "deleted",
"wizard": {
"crumb": "Platform operations",
"title": "Create trial tenant",
"sub": "Create a tenant with an end date; the admin receives an invitation to set a password.",
"sectionCompany": "1. Company",
"sectionAdmin": "2. Administrator",
"sectionPeriod": "3. Trial period",
"sectionSetup": "4. Setup",
"companyName": "Company name",
"sector": "Trade (optional)",
"adminName": "Name",
"adminEmail": "E-mail address",
"adminHint": "New addresses receive the existing invitation link (valid for 7 days). Existing accounts are only linked.",
"endDate": "Trial until",
"endDateHint": "Any date up to one year ahead.",
"sampleData": "With sample data",
"submit": "Create trial tenant",
"submitting": "Creating …",
"cancel": "Cancel"
},
"card": {
"title": "Trial",
"plan": "Version",
"until": "Last day",
"state": "State",
"stateActive": "running",
"stateExpired": "expired – read-only",
"stateConverted": "converted to full version",
"stateDeleted": "deleted",
"deletion": "Deletion",
"deletionNone": "not scheduled",
"source": "Origin",
"sourceSelf": "Self-service signup",
"sourcePlatform": "Platform wizard",
"extend": "Change / extend end date",
"convert": "Convert to full version",
"end": "End trial now",
"schedule": "Schedule deletion",
"cancel": "Cancel deletion",
"created": "Trial tenant created.",
"invited": "The invitation was sent to the administrator.",
"done": "Change saved."
},
"ops": {
"extendTitle": "Change end date",
"extendText": "New last trial day. If it lies in the future, the tenant is writable again immediately; reminders are rescheduled.",
"convertTitle": "Convert to full version",
"convertText": "The tenant stays writable permanently and is never deleted automatically.",
"endTitle": "End trial now",
"endText": "The tenant is read-only from now on. A scheduled deletion moves to 30 days from today.",
"scheduleTitle": "Schedule deletion",
"scheduleText": "Deletion 30 days after the end, at the earliest in 7 days (notice mail to the admins).",
"cancelTitle": "Cancel deletion",
"cancelText": "The tenant will not be deleted automatically. It stays read-only after expiry.",
"confirm": "I confirm this change.",
"submit": "Execute",
"submitting": "Executing …",
"close": "Close"
},
"errors": {
"confirm_required": "Please tick the confirmation.",
"invalid_input": "Please check your input.",
"date_invalid": "Please choose a valid date.",
"date_in_past": "The end date must not lie in the past.",
"date_too_late": "The end date is more than one year ahead.",
"not_a_trial": "This tenant is not a trial.",
"tenant_deleted": "The tenant has already been deleted.",
"platform_admin_required": "Only platform full administrators may do this.",
"tenant not found": "Tenant not found.",
"failed": "The action failed."
}
}
}
@@ -0,0 +1,83 @@
-- L15 Testphase & Onboarding: Testphasen-Lebenszyklus am Mandanten, Onboarding-Checkliste,
-- Selbstanmeldungen (Plattform-Tabelle, ohne RLS) und Mandanten-Datenexporte (RLS).
-- CreateEnum
CREATE TYPE "TenantPlan" AS ENUM ('FULL', 'TRIAL');
-- AlterTable
ALTER TABLE "tenant_settings" ADD COLUMN "onboarding" JSONB NOT NULL DEFAULT '{}';
-- AlterTable
ALTER TABLE "tenants" ADD COLUMN "converted_at" TIMESTAMP(3),
ADD COLUMN "deletion_due_at" TIMESTAMP(3),
ADD COLUMN "plan" "TenantPlan" NOT NULL DEFAULT 'FULL',
ADD COLUMN "read_only_since" TIMESTAMP(3),
ADD COLUMN "trial_deleted_at" TIMESTAMP(3),
ADD COLUMN "trial_deletion_notice_at" TIMESTAMP(3),
ADD COLUMN "trial_ends_at" TIMESTAMP(3),
ADD COLUMN "trial_expired_notice_at" TIMESTAMP(3),
ADD COLUMN "trial_reminder_1_at" TIMESTAMP(3),
ADD COLUMN "trial_reminder_3_at" TIMESTAMP(3),
ADD COLUMN "trial_reminder_7_at" TIMESTAMP(3),
ADD COLUMN "trial_source" TEXT,
ADD COLUMN "trial_started_at" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "trial_signups" (
"id" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"company_name" TEXT NOT NULL,
"sector" TEXT,
"company_size" TEXT,
"admin_name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"trial_end_date" TEXT NOT NULL,
"sample_data" BOOLEAN NOT NULL DEFAULT true,
"modules" TEXT[] DEFAULT ARRAY[]::TEXT[],
"locale" TEXT NOT NULL DEFAULT 'de',
"token_hash" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"ip_hash" TEXT,
"accepted_terms_at" TIMESTAMP(3) NOT NULL,
"confirmed_at" TIMESTAMP(3),
"provisioned_tenant_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "trial_signups_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tenant_exports" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"requested_by_id" TEXT,
"status" TEXT NOT NULL DEFAULT 'queued',
"storage_key" TEXT,
"file_name" TEXT,
"bytes" INTEGER,
"summary" JSONB,
"error" TEXT,
"expires_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tenant_exports_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "trial_signups_token_hash_key" ON "trial_signups"("token_hash");
-- CreateIndex
CREATE INDEX "trial_signups_email_idx" ON "trial_signups"("email");
-- CreateIndex
CREATE INDEX "trial_signups_status_expires_at_idx" ON "trial_signups"("status", "expires_at");
-- CreateIndex
CREATE INDEX "tenant_exports_tenant_id_created_at_idx" ON "tenant_exports"("tenant_id", "created_at");
-- Mandanten-Tabelle: Row Level Security (docs/craftvia/MIGRATIONS.md)
SELECT enable_tenant_rls('tenant_exports');
@@ -0,0 +1,88 @@
-- L16 Lotse-Chat für Monteure: Chatverlauf, Aktionsvorschläge, Schalter je Mandant
-- CreateEnum
CREATE TYPE "LotseMessageRole" AS ENUM ('user', 'assistant', 'tool');
-- CreateEnum
CREATE TYPE "LotseProposalStatus" AS ENUM ('proposed', 'confirmed', 'discarded', 'expired', 'failed');
-- AlterTable
ALTER TABLE "tenant_settings" ADD COLUMN "lotse_chat_enabled" BOOLEAN NOT NULL DEFAULT true;
-- CreateTable
CREATE TABLE "lotse_conversations" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"work_order_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"last_message_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "lotse_conversations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lotse_messages" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"conversation_id" TEXT NOT NULL,
"role" "LotseMessageRole" NOT NULL,
"text" TEXT NOT NULL,
"content" JSONB,
"ai_generation_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "lotse_messages_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "lotse_action_proposals" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"conversation_id" TEXT NOT NULL,
"message_id" TEXT,
"user_id" TEXT NOT NULL,
"work_order_id" TEXT,
"kind" TEXT NOT NULL,
"payload" JSONB NOT NULL,
"payload_hash" TEXT NOT NULL,
"status" "LotseProposalStatus" NOT NULL DEFAULT 'proposed',
"expires_at" TIMESTAMP(3) NOT NULL,
"result" JSONB,
"error_code" TEXT,
"confirmed_at" TIMESTAMP(3),
"confirmed_by_id" TEXT,
"decided_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "lotse_action_proposals_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "lotse_conversations_tenant_id_user_id_last_message_at_idx" ON "lotse_conversations"("tenant_id", "user_id", "last_message_at");
-- CreateIndex
CREATE INDEX "lotse_conversations_tenant_id_last_message_at_idx" ON "lotse_conversations"("tenant_id", "last_message_at");
-- CreateIndex
CREATE INDEX "lotse_messages_tenant_id_conversation_id_created_at_idx" ON "lotse_messages"("tenant_id", "conversation_id", "created_at");
-- CreateIndex
CREATE INDEX "lotse_action_proposals_tenant_id_conversation_id_created_at_idx" ON "lotse_action_proposals"("tenant_id", "conversation_id", "created_at");
-- CreateIndex
CREATE INDEX "lotse_action_proposals_tenant_id_user_id_status_idx" ON "lotse_action_proposals"("tenant_id", "user_id", "status");
-- AddForeignKey
ALTER TABLE "lotse_messages" ADD CONSTRAINT "lotse_messages_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "lotse_conversations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "lotse_action_proposals" ADD CONSTRAINT "lotse_action_proposals_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "lotse_conversations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- L16: Mandantentrennung (RLS) für die neuen Tabellen
SELECT enable_tenant_rls('lotse_conversations');
SELECT enable_tenant_rls('lotse_messages');
SELECT enable_tenant_rls('lotse_action_proposals');
@@ -0,0 +1,36 @@
-- L17 Pakete (Basis/Profi) & Lotse-Chat-Plätze
-- Additiv: Stufe + Platzanzahl + hartes Limit am Mandanten (Default PROFI = bestehende Mandanten unverändert),
-- Tabelle lotse_chat_seats (Mandanten-RLS), Index für die Monatszählung der Chat-Nachrichten.
-- CreateEnum
CREATE TYPE "TenantTier" AS ENUM ('BASIS', 'PROFI');
-- AlterTable
ALTER TABLE "tenants" ADD COLUMN "lotse_chat_hard_limit" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "lotse_chat_seats" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "tier" "TenantTier" NOT NULL DEFAULT 'PROFI';
-- CreateTable
CREATE TABLE "lotse_chat_seats" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"assigned_by_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "lotse_chat_seats_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "lotse_chat_seats_tenant_id_created_at_idx" ON "lotse_chat_seats"("tenant_id", "created_at");
-- CreateIndex
CREATE UNIQUE INDEX "lotse_chat_seats_tenant_id_user_id_key" ON "lotse_chat_seats"("tenant_id", "user_id");
-- CreateIndex
CREATE INDEX "lotse_messages_tenant_id_role_created_at_idx" ON "lotse_messages"("tenant_id", "role", "created_at");
-- Mandantentrennung (docs/craftvia/MIGRATIONS.md)
SELECT enable_tenant_rls('lotse_chat_seats');
+191
View File
@@ -44,6 +44,30 @@ model Tenant {
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_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")
// L17 Pakete: Stufe (Basis ohne KI/Planung) + Lotse-Chat-Plätze (Plattform-Daten, nur Betreiber ändert)
tier TenantTier @default(PROFI)
/// Anzahl gekaufter Lotse-Chat-Plätze (Vergabe durch den Mandanten-Admin)
lotseChatSeats Int @default(0) @map("lotse_chat_seats")
/// true = nach Ausschöpfen des Monatskontingents gesperrt statt Mehrverbrauch
lotseChatHardLimit Boolean @default(false) @map("lotse_chat_hard_limit")
users User[] users User[]
roles Role[] roles Role[]
auditLogs AuditLog[] auditLogs AuditLog[]
@@ -79,6 +103,10 @@ model TenantSettings {
lotseAddressForm String? @map("lotse_address_form") 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 // 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") aiMonthlyTokenLimit Int? @map("ai_monthly_token_limit")
// L16 Lotse-Chat für Monteure: switch per tenant (effective only while the Lotse module is on)
lotseChatEnabled Boolean @default(true) @map("lotse_chat_enabled")
// L15: „Erste Schritte"-Checkliste ({ done: string[], hidden: boolean })
onboarding Json @default("{}")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@@ -1433,3 +1461,166 @@ model BillingRecord {
@@index([tenantId, workOrderId]) @@index([tenantId, workOrderId])
@@map("billing_records") @@map("billing_records")
} }
// ---------- L16 Lotse-Chat für Monteure ----------
enum LotseMessageRole {
user
assistant
tool
}
enum LotseProposalStatus {
proposed
confirmed
discarded
expired
failed
}
/// Chat of ONE user with the Lotse (only visible to that user). Optional work order context
/// (chat opened from the order detail). Deleted by the Lotse retention (services/lotse/retention.ts).
model LotseConversation {
id String @id @default(cuid())
tenantId String @map("tenant_id")
userId String @map("user_id")
workOrderId String? @map("work_order_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lastMessageAt DateTime @default(now()) @map("last_message_at")
messages LotseMessage[]
proposals LotseActionProposal[]
@@index([tenantId, userId, lastMessageAt])
@@index([tenantId, lastMessageAt])
@@map("lotse_conversations")
}
/// One chat message. `text` = what the user sees; `content` = structured parts (chips, proposal ids,
/// links, placeholder text sent to the model).
model LotseMessage {
id String @id @default(cuid())
tenantId String @map("tenant_id")
conversationId String @map("conversation_id")
role LotseMessageRole
text String
content Json?
aiGenerationId String? @map("ai_generation_id")
createdAt DateTime @default(now()) @map("created_at")
conversation LotseConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
@@index([tenantId, conversationId, createdAt])
// L17 Pakete: Monatszählung der Chats (role = user) je Mandant
@@index([tenantId, role, createdAt])
@@map("lotse_messages")
}
/// Action card proposed by the Lotse. Nothing is executed before the owner confirms it
/// (services/lotse/chat/confirm.ts runs the existing services with the owner's context).
model LotseActionProposal {
id String @id @default(cuid())
tenantId String @map("tenant_id")
conversationId String @map("conversation_id")
messageId String? @map("message_id")
userId String @map("user_id")
workOrderId String? @map("work_order_id")
kind String
payload Json
payloadHash String @map("payload_hash")
status LotseProposalStatus @default(proposed)
expiresAt DateTime @map("expires_at")
result Json?
errorCode String? @map("error_code")
confirmedAt DateTime? @map("confirmed_at")
confirmedById String? @map("confirmed_by_id")
decidedAt DateTime? @map("decided_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
conversation LotseConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
@@index([tenantId, conversationId, createdAt])
@@index([tenantId, userId, status])
@@map("lotse_action_proposals")
}
// ── L15 Testphase & Onboarding ─────────────────────────────────────────────────
enum TenantPlan {
FULL
TRIAL
}
/// Selbstanmeldung „Kostenlos testen" vor der E-Mail-Bestätigung (Plattform-Tabelle, kein tenant_id).
/// Keine Klartext-Passwörter/Token: Argon2id-Hash (+ Pepper) bzw. SHA-256 des Tokens; IP nur als HMAC.
model TrialSignup {
id String @id @default(cuid())
/// pending | confirmed | superseded | expired | existing_account | failed
status String @default("pending")
companyName String @map("company_name")
sector String?
companySize String? @map("company_size")
adminName String @map("admin_name")
email String
/// Wird nach der Bestätigung geleert.
passwordHash String @map("password_hash")
/// Gewähltes Enddatum YYYY-MM-DD (Europe/Berlin)
trialEndDate String @map("trial_end_date")
sampleData Boolean @default(true) @map("sample_data")
modules String[] @default([])
locale String @default("de")
tokenHash String @unique @map("token_hash")
expiresAt DateTime @map("expires_at")
ipHash String? @map("ip_hash")
acceptedTermsAt DateTime @map("accepted_terms_at")
confirmedAt DateTime? @map("confirmed_at")
provisionedTenantId String? @map("provisioned_tenant_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([email])
@@index([status, expiresAt])
@@map("trial_signups")
}
/// Daten-Export eines Mandanten (ZIP mit CSV/JSON + Dateien), auch im Nur-Lesen-Zustand.
model TenantExport {
id String @id @default(cuid())
tenantId String @map("tenant_id")
requestedById String? @map("requested_by_id")
/// queued | running | done | failed
status String @default("queued")
storageKey String? @map("storage_key")
fileName String? @map("file_name")
bytes Int?
summary Json?
error String?
expiresAt DateTime? @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([tenantId, createdAt])
@@map("tenant_exports")
}
// ── L17 Pakete (Basis/Profi) & Lotse-Chat-Plätze ───────────────────────────────
enum TenantTier {
BASIS
PROFI
}
/// Vergebener Lotse-Chat-Platz (Mandanten-Admin vergibt bis zur vom Betreiber gesetzten Anzahl).
model LotseChatSeat {
id String @id @default(cuid())
tenantId String @map("tenant_id")
userId String @map("user_id")
assignedById String? @map("assigned_by_id")
createdAt DateTime @default(now()) @map("created_at")
@@unique([tenantId, userId])
@@index([tenantId, createdAt])
@@map("lotse_chat_seats")
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

+26
View File
@@ -57,6 +57,14 @@ const ACTION_MODULE: Record<string, string> = {
"account.ts": "EXEMPT", "account.ts": "EXEMPT",
"tenant-switch.ts": "EXEMPT", "tenant-switch.ts": "EXEMPT",
"webauthn.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",
// L17 Pakete: Stufe/Chat-Plätze/hartes Limit durch den Betreiber (requirePlatformFullAdmin)
"plans-platform.ts": "EXEMPT",
// L15 Testphase: öffentliche Selbstanmeldung ohne Session — jede Action MUSS das Rate-Limit prüfen
"trial-signup.ts": "PUBLIC",
}; };
const errors: string[] = []; const errors: string[] = [];
@@ -75,6 +83,10 @@ function checkGatedFile(label: string, src: string, moduleKey: string) {
if (!src.includes(`moduleGuard("${moduleKey}")`)) { if (!src.includes(`moduleGuard("${moduleKey}")`)) {
errors.push(`${label}: erwartet moduleGuard("${moduleKey}") — Modul-Gating fehlt oder falscher Key.`); errors.push(`${label}: erwartet moduleGuard("${moduleKey}") — Modul-Gating fehlt oder falscher Key.`);
} }
// L15 Testphase: der Lese-Modus überspringt die Schreibsperre abgelaufener Testmandanten → in Actions verboten.
if (/moduleGuard\([^)]*read\s*:/.test(src)) {
errors.push(`${label}: moduleGuard(…, { read: true }) ist nur für Lesepfade erlaubt, nicht in Server-Actions.`);
}
for (let i = 0; i < positions.length; i++) { for (let i = 0; i < positions.length; i++) {
const start = positions[i].index; const start = positions[i].index;
const end = i + 1 < positions.length ? positions[i + 1].index : src.length; const end = i + 1 < positions.length ? positions[i + 1].index : src.length;
@@ -118,6 +130,20 @@ for (const entry of readdirSync(ACTIONS_DIR)) {
continue; continue;
} }
const src = readFileSync(full, "utf8"); 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") { if (mapped === "EXEMPT") {
// Auth-Nachweis: ein require*-Guard ODER ein direkter auth()-Aufruf. // Auth-Nachweis: ein require*-Guard ODER ein direkter auth()-Aufruf.
if (!/require(Session|Platform\w*|Permission)|\bauth\(\)/.test(src)) { if (!/require(Session|Platform\w*|Permission)|\bauth\(\)/.test(src)) {
+9 -1
View File
@@ -2,6 +2,7 @@ import "dotenv/config";
import { Worker } from "bullmq"; import { Worker } from "bullmq";
import { JOB_QUEUES, workerConnection, closeJobQueues, scheduleRecurringJobs, type JobPayload } from "../src/server/jobs/queues"; import { JOB_QUEUES, workerConnection, closeJobQueues, scheduleRecurringJobs, type JobPayload } from "../src/server/jobs/queues";
import { PROCESSORS } from "../src/server/jobs/processors"; 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. */ /** Craftvia background worker: `npm run worker:craftvia`. One BullMQ worker per registered queue. */
async function main() { async function main() {
@@ -20,7 +21,14 @@ async function main() {
const processor = await load(); const processor = await load();
const geocode = name === JOB_QUEUES.geocodeSite; // L13: OSM Nominatim policy — max. 1 request/s 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 concurrency = name === JOB_QUEUES.reportPdf ? 2 : geocode ? 1 : 4;
const w = new Worker<JobPayload>(name, async (job) => processor(job.data), { connection, concurrency, ...(geocode ? { limiter: { max: 1, duration: 1_000 } } : {}) }); const w = new Worker<JobPayload>(name, async (job) => {
// L15 Testphase: user-triggered jobs of an expired trial tenant (read-only) are skipped
if (await isJobBlockedByTrial(name, job.data)) {
console.warn(`[worker] ${name} job ${job.id} skipped: tenant is read-only (trial expired)`);
return;
}
return processor(job.data);
}, { connection, concurrency, ...(geocode ? { limiter: { max: 1, duration: 1_000 } } : {}) });
w.on("failed", (job, err) => console.error(`[worker] ${name} job ${job?.id} failed:`, err.message)); w.on("failed", (job, err) => console.error(`[worker] ${name} job ${job?.id} failed:`, err.message));
workers.push(w); workers.push(w);
console.info(`[worker] listening on ${name}`); console.info(`[worker] listening on ${name}`);
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Erzeugt die Env-Datei für den Coolify-Testserver aus .env.coolify.example — mit frischen
# Geheimnissen, die nur auf diesem Rechner entstehen. Die Datei ist per .gitignore (.env*)
# vom Repo ausgeschlossen; Inhalt in Coolify unter "Environment Variables" einfügen.
#
# ./scripts/generate-coolify-env.sh # schreibt .env.coolify.local
# APP_DOMAIN=app.craftvia.de MARKETING_HOST=craftvia.de ./scripts/generate-coolify-env.sh
set -euo pipefail
OUT="${1:-.env.coolify.local}"
APP_DOMAIN="${APP_DOMAIN:-app.craftvia.de}"
MARKETING_HOST="${MARKETING_HOST:-craftvia.de}"
if [ -e "$OUT" ]; then
echo "$OUT existiert bereits und wird nicht überschrieben (Geheimnisse wären sonst verloren)." >&2
exit 1
fi
hex() { openssl rand -hex "$1"; }
DB_PASSWORD="$(hex 24)"
sed \
-e "s|CHANGE_ME_db_password|${DB_PASSWORD}|g" \
-e "s|^REDIS_PASSWORD=.*|REDIS_PASSWORD=$(hex 24)|" \
-e "s|^S3_ACCESS_KEY=.*|S3_ACCESS_KEY=GK$(hex 12)|" \
-e "s|^S3_SECRET_KEY=.*|S3_SECRET_KEY=$(hex 32)|" \
-e "s|^GARAGE_RPC_SECRET=.*|GARAGE_RPC_SECRET=$(hex 32)|" \
-e "s|^GARAGE_ADMIN_TOKEN=.*|GARAGE_ADMIN_TOKEN=$(hex 32)|" \
-e "s|^AUTH_SECRET=.*|AUTH_SECRET=$(openssl rand -base64 32 | tr -d '\n')|" \
-e "s|^PASSWORD_PEPPER=.*|PASSWORD_PEPPER=$(hex 32)|" \
-e "s|^AUTH_URL=.*|AUTH_URL=https://${APP_DOMAIN}|" \
-e "s|^MARKETING_HOST=.*|MARKETING_HOST=${MARKETING_HOST}|" \
.env.coolify.example > "$OUT"
{
echo ""
echo "# --- erzeugt von scripts/generate-coolify-env.sh ($(date +%F)) ---"
echo "MFA_ENC_KEY=$(hex 32)"
echo "BACKUP_ENC_KEY=$(hex 32)"
} >> "$OUT"
chmod 600 "$OUT"
echo "Geschrieben: $OUT (nur lokal, Rechte 600)."
echo "Noch von Hand ergänzen: SMTP_HOST/PORT/SECURE/USER/PASSWORD/FROM, ANTHROPIC_API_KEY, TRIAL_CONTACT_EMAIL."
echo "Danach sicher ablegen (Passwortmanager) — PASSWORD_PEPPER und MFA_ENC_KEY sind nicht rotierbar."
+18
View File
@@ -422,10 +422,26 @@ export async function seedDemoTenant(tenantId: string, users: DemoUsers): Promis
}); });
} }
// ---------- L17 Pakete: Profi mit 3 Lotse-Chat-Plätzen für die Demo-Monteure (idempotent) ----------
await seedDemoPlan(tenantId, { tier: "PROFI", seats: 3, seatUserIds: [users.tech, users.tech2, users.tech3], assignedById: users.admin });
const orders = await bo.db.workOrder.count({ where: { OR: [{ externalOrderNumber: { startsWith: "DEMO-" } }, { isEmergency: true, emergencyReason: { startsWith: "Wasserrohrbruch im Keller" } }] } }); const orders = await bo.db.workOrder.count({ where: { OR: [{ externalOrderNumber: { startsWith: "DEMO-" } }, { isEmergency: true, emergencyReason: { startsWith: "Wasserrohrbruch im Keller" } }] } });
return { orders, created }; return { orders, created };
} }
/**
* L17 Pakete: package tier + chat seats of a demo tenant. Idempotent (seat rows per user are unique);
* existing seats of other users are left alone.
*/
export async function seedDemoPlan(tenantId: string, opts: { tier: "BASIS" | "PROFI"; seats: number; seatUserIds?: string[]; assignedById?: string }): Promise<void> {
await prisma.tenant.update({ where: { id: tenantId }, data: { tier: opts.tier, lotseChatSeats: opts.seats, lotseChatHardLimit: false } });
const db = dbForTenant(tenantId);
for (const userId of opts.seatUserIds ?? []) {
const existing = await db.lotseChatSeat.findFirst({ where: { userId }, select: { id: true } });
if (!existing) await db.lotseChatSeat.create({ data: { tenantId, userId, assignedById: opts.assignedById ?? null } });
}
}
/** Isolation fixture for the second tenant: one customer + one work order. */ /** Isolation fixture for the second tenant: one customer + one work order. */
export async function seedDemo2Tenant(tenantId: string, adminUserId: string): Promise<void> { export async function seedDemo2Tenant(tenantId: string, adminUserId: string): Promise<void> {
const admin = ctxOf(tenantId, adminUserId, "tenant-admin"); const admin = ctxOf(tenantId, adminUserId, "tenant-admin");
@@ -437,6 +453,8 @@ export async function seedDemo2Tenant(tenantId: string, adminUserId: string): Pr
const site = await createSite(admin, { customerId: customer.id, name: "Schaltanlage Halle 2", street: "Am Hafen", houseNumber: "3", postalCode: "24103", city: "Kiel", accessNotes: "Anmeldung beim Werkschutz." }); const site = await createSite(admin, { customerId: customer.id, name: "Schaltanlage Halle 2", street: "Am Hafen", houseNumber: "3", postalCode: "24103", city: "Kiel", accessNotes: "Anmeldung beim Werkschutz." });
await createWorkOrder(admin, { title: "Prüfung Unterverteilung nach DGUV V3", customerId: customer.id, siteId: site.id, externalOrderNumber: "DEMO2-01", status: "planned", plannedStart: day(3, 8), plannedEnd: day(3, 12) }); await createWorkOrder(admin, { title: "Prüfung Unterverteilung nach DGUV V3", customerId: customer.id, siteId: site.id, externalOrderNumber: "DEMO2-01", status: "planned", plannedStart: day(3, 8), plannedEnd: day(3, 12) });
} }
// L17 Pakete: demo2 = Basis (zeigt die Sperren: keine Planung, Abrechnung, Notdienst, Import, Lotse)
await seedDemoPlan(tenantId, { tier: "BASIS", seats: 0 });
} }
/** Close the owner client used by the services (the seed has its own client). */ /** Close the owner client used by the services (the seed has its own client). */
+1
View File
@@ -134,6 +134,7 @@ export async function cleanupTenants(slugs: string[]): Promise<void> {
await prisma.mailLog.deleteMany(w); await prisma.mailLog.deleteMany(w);
await prisma.authToken.deleteMany(w); await prisma.authToken.deleteMany(w);
await prisma.aiGeneration.deleteMany(w); await prisma.aiGeneration.deleteMany(w);
await prisma.lotseChatSeat.deleteMany(w); // L17 Pakete
await prisma.signature.deleteMany(w); await prisma.signature.deleteMany(w);
await prisma.report.deleteMany(w); await prisma.report.deleteMany(w);
await prisma.photo.deleteMany(w); await prisma.photo.deleteMany(w);
+149
View File
@@ -0,0 +1,149 @@
// Shared fixture of the lane L16 tests (scripts/test-lotse-chat-*.ts): two zz test tenants with
// technicians (assigned / not assigned), admin, tenant B, customers with contact data (for the data
// minimisation checks) and work orders in different statuses. Not a test itself.
import { prisma, dbForTenant } from "../../src/server/db";
import { ROLE_DEFS, type RoleKey } from "../../src/server/rbac";
import { ServiceError, type ServiceCtx } from "../../src/server/services/context";
export let failures = 0;
export const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
export async function expectErr(fn: () => Promise<unknown>, code: ServiceError["code"], msg: string, reason?: string) {
try {
await fn();
ok(false, `${msg} — kein Fehler (erwartet ${code}${reason ? `/${reason}` : ""})`);
} catch (err) {
if (!(err instanceof ServiceError)) return ok(false, `${msg} — ${(err as Error).message}`);
const r = (err.details as { reason?: string } | undefined)?.reason;
ok(err.code === code && (!reason || r === reason), `${msg} (${err.code}${r ? `/${r}` : ""})`);
}
}
export const ctxOf = (tenantId: string, userId: string, role: RoleKey): ServiceCtx => ({
db: dbForTenant(tenantId),
tenantId,
userId,
permissions: new Set<string>(ROLE_DEFS[role].permissions),
});
export async function cleanupChatTenants(prefix: string) {
const slugs = [`zz-${prefix}-a`, `zz-${prefix}-b`];
const tenants = await prisma.tenant.findMany({ where: { slug: { in: slugs } }, select: { id: true } });
const ids = tenants.map((t) => t.id);
if (ids.length) {
const w = { where: { tenantId: { in: ids } } };
await prisma.lotseChatSeat.deleteMany(w); // L17 Pakete
await prisma.lotseActionProposal.deleteMany(w);
await prisma.lotseMessage.deleteMany(w);
await prisma.lotseConversation.deleteMany(w);
await prisma.notification.deleteMany(w);
await prisma.mailLog.deleteMany(w);
await prisma.aiGeneration.deleteMany(w);
await prisma.signature.deleteMany(w);
await prisma.report.deleteMany(w);
await prisma.activityNote.deleteMany(w);
await prisma.voiceNote.deleteMany(w);
await prisma.materialUsage.deleteMany(w);
await prisma.materialPlan.deleteMany(w);
await prisma.timeEntry.deleteMany(w);
await prisma.workSession.deleteMany(w);
await prisma.photo.deleteMany(w);
await prisma.checklistItem.deleteMany(w);
await prisma.photoRequirement.deleteMany(w);
await prisma.workOrderStatusChange.deleteMany(w);
await prisma.workOrderAssignee.deleteMany(w);
await prisma.syncOperation.deleteMany(w);
await prisma.document.deleteMany(w);
await prisma.workOrder.deleteMany(w);
await prisma.site.deleteMany(w);
await prisma.contact.deleteMany(w);
await prisma.customer.deleteMany(w);
await prisma.numberSequence.deleteMany(w);
await prisma.auditLog.deleteMany(w);
await prisma.tenantModule.deleteMany(w);
await prisma.tenantSettings.deleteMany(w);
await prisma.user.deleteMany(w);
await prisma.tenant.deleteMany({ where: { id: { in: ids } } });
}
await prisma.identity.deleteMany({ where: { email: { endsWith: `@zz-${prefix}.test` } } });
}
export async function createChatFixture(prefix: string) {
await cleanupChatTenants(prefix);
const domain = `@zz-${prefix}.test`;
const tA = await prisma.tenant.create({ data: { name: `Lotse-Chat ${prefix} A`, slug: `zz-${prefix}-a` } });
const tB = await prisma.tenant.create({ data: { name: `Lotse-Chat ${prefix} B`, slug: `zz-${prefix}-b` } });
await prisma.tenantSettings.create({ data: { tenantId: tA.id, orgName: "Chat Test A GmbH", phone: "040 1234567", email: "buero@lotsechat-a.test", address: "Werftstraße 5, 20457 Hamburg" } });
await prisma.tenantSettings.create({ data: { tenantId: tB.id, orgName: "Chat Test B" } });
const mkUser = async (tenantId: string, key: string, name: string) => {
const identity = await prisma.identity.create({ data: { email: `${key}${domain}`, passwordHash: "x" } });
return prisma.user.create({ data: { tenantId, identityId: identity.id, email: identity.email, name } });
};
const tech = await mkUser(tA.id, "tech", "Max Monteur");
const tech2 = await mkUser(tA.id, "tech2", "Nora Nordmann");
const outsider = await mkUser(tA.id, "outsider", "Otto Fremd");
const admin = await mkUser(tA.id, "admin", "Anna Admin");
const techB = await mkUser(tB.id, "techb", "Tom Bader");
const now = Date.now();
const today = { plannedStart: new Date(now - 3_600_000), plannedEnd: new Date(now + 3 * 3_600_000) };
const tomorrow = { plannedStart: new Date(now + 26 * 3_600_000), plannedEnd: new Date(now + 30 * 3_600_000) };
const customer = await prisma.customer.create({
data: { tenantId: tA.id, firstName: "Erika", lastName: "Mustermann", phone: "0171 9876543", email: "erika@kunde.test", street: "Lindenallee", houseNumber: "7", postalCode: "22111", city: "Hamburg" },
});
const contact = await prisma.contact.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Klaus Kontakt", phone: "040 555666", email: "klaus@kunde.test" } });
const site = await prisma.site.create({
data: { tenantId: tA.id, customerId: customer.id, name: "Haus Mustermann", street: "Lindenallee", houseNumber: "7", postalCode: "22111", city: "Hamburg", accessNotes: "Schlüsselkasten Code 4711, Hausmeister 0170 1112223" },
});
const company = await prisma.customer.create({ data: { tenantId: tA.id, companyName: "Müller Haustechnik GmbH", city: "Buxtehude" } });
const site2 = await prisma.site.create({ data: { tenantId: tA.id, customerId: company.id, name: "Objekt Müller", city: "Buxtehude" } });
const order = async (number: string, data: Record<string, unknown>, assignees: string[]) => {
const wo = await prisma.workOrder.create({ data: { tenantId: tA.id, number, ...(data as object) } as never });
for (const userId of assignees) await prisma.workOrderAssignee.create({ data: { tenantId: tA.id, workOrderId: wo.id, userId } });
return wo as unknown as { id: string; number: string; status: string };
};
const wo1 = await order(
"A-LC-1",
{ customerId: customer.id, siteId: site.id, contactId: contact.id, title: "Speicher tauschen", description: "Kundin unter 0171 9876543 oder erika@kunde.test erreichbar, Lindenallee 7.", status: "in_progress", signatureRequired: false, ...today },
[tech.id, tech2.id],
);
const plan = await prisma.materialPlan.create({ data: { tenantId: tA.id, workOrderId: wo1.id, name: "Filter", plannedQuantity: 1, unit: "Stk" } });
const wo2 = await order("A-LC-2", { customerId: company.id, siteId: site2.id, title: "Wartung Heizung", status: "assigned", signatureRequired: false, ...today }, [tech.id, tech2.id]);
const wo3 = await order("A-LC-3", { customerId: company.id, siteId: site2.id, title: "Pumpe montieren", status: "in_progress", signatureRequired: false, ...tomorrow }, [tech.id]);
await prisma.photoRequirement.create({ data: { tenantId: tA.id, workOrderId: wo3.id, key: "fertige_montage", label: "Fertige Montage" } });
const wo4 = await order("A-LC-4", { customerId: company.id, siteId: site2.id, title: "Regler einstellen", status: "in_progress", signatureRequired: false, ...tomorrow }, [tech.id]);
// L17 Pakete: the chat needs a seat per user (Profi is the default tier)
await prisma.tenant.updateMany({ where: { id: { in: [tA.id, tB.id] } }, data: { lotseChatSeats: 10 } });
for (const u of [tech, tech2, outsider, admin]) await prisma.lotseChatSeat.create({ data: { tenantId: tA.id, userId: u.id } });
await prisma.lotseChatSeat.create({ data: { tenantId: tB.id, userId: techB.id } });
const customerB = await prisma.customer.create({ data: { tenantId: tB.id, companyName: "Kunde B" } });
const woB = await prisma.workOrder.create({ data: { tenantId: tB.id, number: "B-LC-1", customerId: customerB.id, title: "Auftrag B", status: "in_progress", ...today } });
await prisma.workOrderAssignee.create({ data: { tenantId: tB.id, workOrderId: woB.id, userId: techB.id } });
return {
tA,
tB,
users: { tech, tech2, outsider, admin, techB },
ctx: {
tech: ctxOf(tA.id, tech.id, "technician"),
tech2: ctxOf(tA.id, tech2.id, "technician"),
outsider: ctxOf(tA.id, outsider.id, "technician"),
admin: ctxOf(tA.id, admin.id, "tenant-admin"),
techB: ctxOf(tB.id, techB.id, "technician"),
},
customer,
contact,
site,
plan,
orders: { wo1, wo2, wo3, wo4, woB },
};
}
+109
View File
@@ -0,0 +1,109 @@
// Shared fixture of the L15 tests (scripts/test-testphase-*.ts): zz trial tenants, platform admins,
// mail capture, zip reader and a complete tenant purge. Not a test itself (runner only picks up
// scripts/test-*.ts at top level).
import { inflateRawSync } from "node:zlib";
import { prisma, dbForTenant } from "../../src/server/db";
import { ROLE_DEFS, type RoleKey } from "../../src/server/rbac";
import type { ServiceCtx } from "../../src/server/services/context";
import { offboardTenant } from "../../src/server/dsgvo/deletion";
import type { enqueueMail } from "../../src/server/mail/service";
import { provisionTrialTenant } from "../../src/server/services/trial/provision";
export const DOMAIN = "@zz-l15.test";
export const SLUG_PREFIX = "zz-l15";
export let failures = 0;
export const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
/** Expects `fn` to throw an error with the given `code` (ServiceError) — or any error when code is "*". */
export async function expectCode(fn: () => Promise<unknown>, code: string, msg: string) {
try {
await fn();
ok(false, `${msg} — kein Fehler (erwartet ${code})`);
} catch (err) {
const actual = (err as { code?: string }).code;
const pass = code === "*" || actual === code;
ok(pass, `${msg}${pass ? "" : ` — Code ${actual ?? (err as Error).message}`}`);
}
}
export function ctxFor(tenantId: string, userId: string, role: RoleKey): ServiceCtx {
return { db: dbForTenant(tenantId), tenantId, userId, permissions: new Set<string>(ROLE_DEFS[role].permissions) };
}
type MailInput = Parameters<typeof enqueueMail>[0];
export function captureMail() {
const sent: MailInput[] = [];
const fn = (async (input: MailInput) => {
sent.push(input);
return { status: "queued", mailLogId: `zz-${sent.length}` };
}) as typeof enqueueMail;
return { sent, fn };
}
export async function platformAdmin(role: "full" | "readonly" = "full") {
return prisma.platformAdmin.create({
data: { email: `platform-${role}-${Math.random().toString(36).slice(2, 8)}${DOMAIN}`, passwordHash: "x", name: `ZZ Platform ${role}`, role },
});
}
/** Trial tenant through the real provisioning (slug zz-l15-<name>). */
export async function trialTenant(name: string, endDateKey: string, opts: { sampleData?: boolean } = {}) {
const slugPart = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
return provisionTrialTenant({
companyName: `ZZ L15 ${name}`,
admin: { name: `Admin ${name}`, email: `admin-${slugPart}${DOMAIN}`, passwordHash: "x" },
endDateKey,
sampleData: opts.sampleData ?? false,
source: "platform",
});
}
export async function addMember(tenantId: string, local: string, role: RoleKey) {
const email = `${local}${DOMAIN}`;
const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } });
const roleRow = await prisma.role.findUniqueOrThrow({ where: { tenantId_key: { tenantId, key: role } } });
return prisma.user.create({ data: { tenantId, identityId: identity.id, email, name: local, userRoles: { create: [{ roleId: roleRow.id }] } } });
}
/** Removes a test tenant completely (offboarding of all tenant tables + rows around the tenant). */
export async function purgeTenant(tenantId: string) {
const exists = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { id: true } });
if (!exists) return;
await offboardTenant(tenantId, { reason: "zz-test-cleanup", purgeFiles: false });
await prisma.auditLog.deleteMany({ where: { tenantId } });
await prisma.mailLog.deleteMany({ where: { tenantId } });
await prisma.deletionCertificate.deleteMany({ where: { tenantId } });
await prisma.trialSignup.deleteMany({ where: { provisionedTenantId: tenantId } });
await prisma.tenant.delete({ where: { id: tenantId } });
}
export async function cleanupL15() {
const tenants = await prisma.tenant.findMany({ where: { slug: { startsWith: SLUG_PREFIX } }, select: { id: true } });
for (const t of tenants) await purgeTenant(t.id);
await prisma.trialSignup.deleteMany({ where: { email: { endsWith: DOMAIN } } });
await prisma.identity.deleteMany({ where: { email: { endsWith: DOMAIN }, memberships: { none: {} } } });
await prisma.platformAdmin.deleteMany({ where: { email: { endsWith: DOMAIN } } });
}
/** Minimal ZIP reader for the export tests (deflate entries written by src/server/backup/zip.ts). */
export function readZip(buf: Buffer): Map<string, Buffer> {
const out = new Map<string, Buffer>();
let offset = 0;
while (offset + 30 <= buf.length && buf.readUInt32LE(offset) === 0x04034b50) {
const method = buf.readUInt16LE(offset + 8);
const compressed = buf.readUInt32LE(offset + 18);
const nameLen = buf.readUInt16LE(offset + 26);
const extraLen = buf.readUInt16LE(offset + 28);
const name = buf.subarray(offset + 30, offset + 30 + nameLen).toString("utf8");
const start = offset + 30 + nameLen + extraLen;
const data = buf.subarray(start, start + compressed);
out.set(name, method === 8 ? inflateRawSync(data) : Buffer.from(data));
offset = start + compressed;
}
return out;
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Screenshots der laufenden App für die Produktseite (`public/marketing/*.png`).
*
* Meldet sich OHNE Passworteingabe an: die Session wird wie in `scripts/smoke-auth.ts` gebaut
* (finalizeIdentityLogin + next-auth/jwt) und als Cookie in den Browser gesetzt. Gezeigt werden
* ausschließlich Demo-Daten des Mandanten `demo`.
*
* npm run dev # Dev-Server muss laufen
* npx tsx scripts/marketing-shots.ts # BASE=http://localhost:3000
*/
import "dotenv/config";
import { mkdir } from "node:fs/promises";
import path from "node:path";
import { chromium, type BrowserContext } from "playwright-core";
import { encode } from "next-auth/jwt";
import { prisma } from "../src/server/db";
import { finalizeIdentityLogin } from "../src/server/auth";
const BASE = process.env.BASE ?? "http://localhost:3000";
const COOKIE = "authjs.session-token";
const OUT = path.join(process.cwd(), "public", "marketing");
async function sessionCookie(email: string, tenant: string) {
const identity = await prisma.identity.findUniqueOrThrow({ where: { email } });
const user = await finalizeIdentityLogin(identity.id, tenant);
if (!user) throw new Error(`keine Mitgliedschaft für ${email}`);
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 encode({ token, secret: process.env.AUTH_SECRET!, salt: COOKIE, maxAge: 60 * 60 });
}
type Shot = { name: string; path: string; wait?: string; click?: string; fullPage?: boolean };
async function shoot(ctx: BrowserContext, shots: Shot[], label: string) {
const page = await ctx.newPage();
for (const s of shots) {
await page.goto(`${BASE}${s.path}`, { waitUntil: "domcontentloaded", timeout: 120_000 });
if (s.wait) await page.waitForSelector(s.wait, { timeout: 60_000 }).catch(() => undefined);
if (s.click) await page.click(s.click, { timeout: 15_000 }).catch(() => undefined);
// Dev-Overlay von Next.js gehört nicht ins Produktbild
await page.addStyleTag({ content: "nextjs-portal, [data-nextjs-toast], #__next-build-watcher { display: none !important; }" });
await page.waitForTimeout(1200);
await page.screenshot({ path: path.join(OUT, `${s.name}.png`), fullPage: s.fullPage ?? false });
console.log(`✔ ${label}: ${s.name}.png (${s.path})`);
}
await page.close();
}
async function main() {
await mkdir(OUT, { recursive: true });
const tenant = await prisma.tenant.findUniqueOrThrow({ where: { slug: "demo" }, select: { id: true } });
const order = await prisma.workOrder.findFirstOrThrow({
where: { tenantId: tenant.id, status: "in_progress", deletedAt: null },
select: { id: true },
});
const browser = await chromium.launch({ channel: "chrome", headless: true });
const value = await sessionCookie("admin@demo.example", "demo");
const techValue = await sessionCookie("monteur@demo.example", "demo");
const cookie = { name: COOKIE, value, domain: "localhost", path: "/", httpOnly: true, secure: false, sameSite: "Lax" as const };
const desktop = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2, locale: "de-DE" });
await desktop.addCookies([cookie]);
await shoot(desktop, [
{ name: "plantafel", path: "/planning", wait: "text=Plantafel" },
{ name: "live-lage", path: "/planning/live", wait: "text=Live-Lage", click: "text=Kacheln" },
{ name: "auftrag", path: `/work-orders/${order.id}`, wait: "text=Status" },
{ name: "abrechnung", path: "/billing", wait: "text=Abrechnung" },
{ name: "dashboard", path: "/dashboard", wait: "text=Dashboard" },
{ name: "auftraege", path: "/work-orders", wait: "text=Aufträge" },
{ name: "berichte", path: "/reports", wait: "text=Berichte" },
{ name: "zeitfreigabe", path: "/work-orders/time-approvals", wait: "text=Zeiten" },
{ name: "audit", path: "/settings/audit", wait: "text=Audit" },
{ name: "teams", path: "/teams", wait: "text=Teams" },
], "Desktop");
await desktop.close();
const mobile = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, locale: "de-DE", isMobile: true, hasTouch: true });
await mobile.addCookies([{ ...cookie, value: techValue }]);
await shoot(mobile, [
{ name: "mobil-heute", path: "/m", wait: "text=Heute" },
{ name: "mobil-auftrag", path: `/m/orders/${order.id}`, wait: "text=Auftrag" },
{ name: "mobil-lotse", path: "/m/lotse", wait: "text=Lotse" },
{ name: "mobil-zeiten", path: "/m/time", wait: "text=Meine Zeiten" },
], "Mobil");
await mobile.close();
await browser.close();
console.log(`\nFertig — Dateien in public/marketing/`);
}
main()
.then(() => prisma.$disconnect())
.then(() => process.exit(0))
.catch(async (err) => {
console.error(err instanceof Error ? err.message : err);
await prisma.$disconnect().catch(() => undefined);
process.exit(1);
});
+185
View File
@@ -0,0 +1,185 @@
/**
* Beispielbelegung der Plantafel (L13) für die visuelle Prüfung — relativ zu HEUTE.
*
* Die Demo-Fachdaten aus `scripts/lib/demo-seed.ts` liegen relativ zum Tag des Seed-Laufs; einen Tag
* später ist die Plantafel im Standardzeitraum (heute + 4 Werktage) deshalb fast leer. Dieses Skript
* plant eine sprechende Woche über die echten Services (createWorkOrder + scheduleWorkOrder), sodass
* Auslastung, Konflikte, Mehrtagesaufträge und die Spalte „Ungeplante Aufträge" sichtbar sind.
*
* npx tsx scripts/planning-demo.ts # anlegen bzw. auf heute umplanen (idempotent)
* npx tsx scripts/planning-demo.ts --tenant=demo # anderer Mandant (Slug)
* npx tsx scripts/planning-demo.ts --reset # Beispiele wieder entfernen (Soft Delete)
*
* Kennzeichen aller Beispiele: externe Auftragsnummer `PLAN-…`.
*/
import "dotenv/config";
import { dbForTenant, prisma } from "../src/server/db";
import { ROLE_DEFS } from "../src/server/rbac";
import type { ServiceCtx } from "../src/server/services/context";
import { scheduleWorkOrder } from "../src/server/services/planning/schedule";
import { createWorkOrder } from "../src/server/services/work-orders/create";
const args = process.argv.slice(2);
const RESET = args.includes("--reset");
const SLUG = args.find((a) => a.startsWith("--tenant="))?.slice("--tenant=".length) ?? "demo";
const PREFIX = "PLAN-";
/** Lokaler Zeitpunkt am n-ten Werktag ab heute (0 = heute bzw. der nächste Werktag). */
function workingDays(count: number): Date[] {
const out: Date[] = [];
const d = new Date();
d.setHours(0, 0, 0, 0);
while (out.length < count) {
if (d.getDay() >= 1 && d.getDay() <= 5) out.push(new Date(d));
d.setDate(d.getDate() + 1);
}
return out;
}
function at(day: Date, h: number, m = 0): Date {
const d = new Date(day);
d.setHours(h, m, 0, 0);
return d;
}
type Example = {
ext: string;
title: string;
typeKey: string;
crew: "nord" | "sued";
/** Index im Werktagsfenster (0 = heute) */
dayIndex: number;
from: [number, number];
to: [number, number];
/** Mehrtägig: Ende an einem späteren Werktag */
untilDayIndex?: number;
priority?: "low" | "normal" | "high" | "urgent";
note?: string;
};
/**
* Bewusst gewähltes Bild: hohe Auslastung, eine Überbuchung, eine Überschneidung, ein
* Mehrtagesauftrag und ruhige Tage — damit alle Zustände der Plantafel sichtbar werden.
*/
const EXAMPLES: Example[] = [
// Heute: Nord gut ausgelastet (7,5 h / 8 h), Süd halber Tag
{ ext: `${PREFIX}01`, title: "Heizungswartung Mehrfamilienhaus", typeKey: "wartung", crew: "nord", dayIndex: 0, from: [8, 0], to: [12, 0] },
{ ext: `${PREFIX}02`, title: "Thermostatventile tauschen", typeKey: "reparatur", crew: "nord", dayIndex: 0, from: [13, 0], to: [16, 30] },
{ ext: `${PREFIX}03`, title: "Rückstauklappe prüfen", typeKey: "wartung", crew: "sued", dayIndex: 0, from: [9, 0], to: [12, 0] },
// Morgen: Nord überbucht (9 h > 8 h Kolonnentag), Süd zwei Termine hintereinander
{ ext: `${PREFIX}04`, title: "Badsanierung – Rohinstallation", typeKey: "montage", crew: "nord", dayIndex: 1, from: [7, 30], to: [16, 30], priority: "high" },
{ ext: `${PREFIX}05`, title: "Warmwasserspeicher entkalken", typeKey: "wartung", crew: "sued", dayIndex: 1, from: [8, 0], to: [11, 0] },
{ ext: `${PREFIX}06`, title: "Waschtisch-Armatur erneuern", typeKey: "reparatur", crew: "sued", dayIndex: 1, from: [11, 30], to: [14, 0] },
// Tag 3: Überschneidung bei Nord (zwei Aufträge zur selben Zeit)
{ ext: `${PREFIX}07`, title: "Gastherme Störung beheben", typeKey: "stoerung", crew: "nord", dayIndex: 2, from: [8, 0], to: [12, 0], priority: "urgent" },
{ ext: `${PREFIX}08`, title: "Sicherheitsventil wechseln", typeKey: "reparatur", crew: "nord", dayIndex: 2, from: [10, 0], to: [14, 0] },
// Tag 4/5: Mehrtagesauftrag Nord, Süd mit vollem Tag
{ ext: `${PREFIX}09`, title: "Strangsanierung Steigleitung (2 Tage)", typeKey: "montage", crew: "nord", dayIndex: 3, from: [7, 0], to: [16, 0], untilDayIndex: 4 },
{ ext: `${PREFIX}10`, title: "Lüftungsanlage Filterwechsel", typeKey: "wartung", crew: "sued", dayIndex: 3, from: [8, 0], to: [15, 0] },
{ ext: `${PREFIX}11`, title: "Abnahme Trinkwasserprobe", typeKey: "abnahme", crew: "sued", dayIndex: 4, from: [8, 0], to: [12, 0] },
];
/** Ungeplant: füllt die Spalte „Ungeplante Aufträge" (Drag-&-Drop-Material für die Prüfung). */
const UNPLANNED: { ext: string; title: string; typeKey: string; priority?: "low" | "normal" | "high" | "urgent" }[] = [
{ ext: `${PREFIX}20`, title: "Neue Duscharmatur montieren", typeKey: "montage" },
{ ext: `${PREFIX}21`, title: "Tropfender Außenhahn", typeKey: "reparatur", priority: "low" },
{ ext: `${PREFIX}22`, title: "Jahreswartung Wärmepumpe", typeKey: "wartung" },
{ ext: `${PREFIX}23`, title: "Heizkörper entlüften – Notdienstnachlauf", typeKey: "nacharbeit", priority: "high" },
];
async function main() {
const tenant = await prisma.tenant.findUnique({ where: { slug: SLUG }, select: { id: true, name: true } });
if (!tenant) throw new Error(`Mandant „${SLUG}" nicht gefunden.`);
const db = dbForTenant(tenant.id);
if (RESET) {
const res = await prisma.workOrder.updateMany({
where: { tenantId: tenant.id, externalOrderNumber: { startsWith: PREFIX }, deletedAt: null },
data: { deletedAt: new Date() },
});
console.log(`✔ ${res.count} Beispielauftrag/-aufträge entfernt (${tenant.name}).`);
return;
}
const backoffice = await db.user.findFirst({ where: { email: { contains: "backoffice" }, status: "ACTIVE" }, select: { id: true } });
if (!backoffice) throw new Error("Kein Backoffice-Nutzer im Mandanten gefunden.");
const ctx: ServiceCtx = { db, tenantId: tenant.id, userId: backoffice.id, permissions: new Set(ROLE_DEFS.backoffice.permissions) };
const teams = await db.team.findMany({ where: { deletedAt: null, status: "active" }, select: { id: true, name: true, members: { select: { userId: true, validTo: true } } }, orderBy: { name: "asc" } });
if (teams.length < 2) throw new Error("Mindestens zwei aktive Teams (Kolonnen) nötig — bitte zuerst `npx prisma db seed` ausführen.");
const crews = { nord: teams[0], sued: teams[1] };
const types = new Map((await db.orderType.findMany({ select: { id: true, key: true } })).map((t) => [t.key, t.id]));
// Objekte mit Koordinaten zuerst: so haben Karte und Empfehlungen (Luftlinie) etwas zu zeigen.
const sites = await db.site.findMany({
where: { deletedAt: null, customer: { deletedAt: null } },
select: { id: true, name: true, customerId: true, latitude: true },
orderBy: [{ latitude: "desc" }, { name: "asc" }],
});
if (!sites.length) throw new Error("Keine Objekte im Mandanten — bitte zuerst `npx prisma db seed` ausführen.");
const site = (i: number) => sites[i % sites.length];
const days = workingDays(6);
let created = 0;
let planned = 0;
/** Legt den Auftrag an (oder findet den vorhandenen) — immer ohne Termin, geplant wird danach. */
async function ensureOrder(ext: string, title: string, typeKey: string, index: number, priority?: Example["priority"], note?: string) {
const existing = await db.workOrder.findFirst({ where: { externalOrderNumber: ext, deletedAt: null }, select: { id: true, version: true, status: true } });
if (existing) return existing;
const s = site(index);
const wo = await createWorkOrder(ctx, {
title,
customerId: s.customerId,
siteId: s.id,
orderTypeId: types.get(typeKey) ?? null,
status: "planned",
priority: priority ?? "normal",
externalOrderNumber: ext,
technicianNotes: note,
applyTemplate: false,
});
created++;
return { id: wo.id, version: wo.version, status: wo.status };
}
for (const [i, ex] of EXAMPLES.entries()) {
const wo = await ensureOrder(ex.ext, ex.title, ex.typeKey, i, ex.priority, ex.note);
const startDay = days[ex.dayIndex];
const endDay = days[ex.untilDayIndex ?? ex.dayIndex];
const crew = crews[ex.crew];
const current = await db.workOrder.findFirstOrThrow({ where: { id: wo.id }, select: { version: true, status: true } });
// Laufende oder abgeschlossene Aufträge verschiebt die Plantafel nicht — solche Beispiele überspringen.
if (!["draft", "review_required", "planned", "assigned", "accepted"].includes(current.status)) continue;
await scheduleWorkOrder(ctx, {
workOrderId: wo.id,
teamId: crew.id,
plannedStart: at(startDay, ex.from[0], ex.from[1]),
plannedEnd: at(endDay, ex.to[0], ex.to[1]),
baseVersion: current.version,
});
planned++;
}
for (const [i, u] of UNPLANNED.entries()) {
await ensureOrder(u.ext, u.title, u.typeKey, EXAMPLES.length + i, u.priority);
}
const from = days[0].toLocaleDateString("de-DE");
const to = days[4].toLocaleDateString("de-DE");
console.log(`✔ ${tenant.name}: ${created} Auftrag/Aufträge neu angelegt, ${planned} eingeplant (${from} – ${to}).`);
console.log(` Kolonnen: ${crews.nord.name} · ${crews.sued.name} — Beispiele enthalten Überbuchung, Überschneidung und einen Mehrtagesauftrag.`);
console.log(` Ungeplant: ${UNPLANNED.length} Aufträge in der Spalte „Ungeplante Aufträge".`);
}
main()
.then(async () => {
await prisma.$disconnect();
// Ereignisse der Services halten Queue-Verbindungen offen → Prozess bewusst beenden.
process.exit(0);
})
.catch(async (err) => {
console.error(err instanceof Error ? err.message : err);
await prisma.$disconnect().catch(() => undefined);
process.exit(1);
});
+44 -10
View File
@@ -33,7 +33,18 @@ async function ids() {
if (!d07 || !d18) throw new Error("Demo-Aufträge fehlen — `npx prisma db seed` (SEED_DEMO) ausführen."); if (!d07 || !d18) throw new Error("Demo-Aufträge fehlen — `npx prisma db seed` (SEED_DEMO) ausführen.");
const emergency = await prisma.workOrder.findFirst({ where: { tenantId: demo.id, isEmergency: true, emergencyReason: { startsWith: "Wasserrohrbruch" } }, select: { id: true, number: true } }); const emergency = await prisma.workOrder.findFirst({ where: { tenantId: demo.id, isEmergency: true, emergencyReason: { startsWith: "Wasserrohrbruch" } }, select: { id: true, number: true } });
const customer = await prisma.customer.findFirstOrThrow({ where: { tenantId: demo.id, customerNumber: "K-D001" } }); const customer = await prisma.customer.findFirstOrThrow({ where: { tenantId: demo.id, customerNumber: "K-D001" } });
const foreignCustomer = await prisma.customer.findFirstOrThrow({ where: { tenantId: demo.id, customerNumber: "K-D004" } }); // "Fremder" Kunde für den Monteur: keiner seiner Aufträge (Team oder Einzelzuweisung) gehört dazu.
// Dynamisch statt fest K-D004, weil Aufträge beim Ausprobieren der Plantafel umgeplant werden.
const tech = await prisma.user.findFirstOrThrow({ where: { tenantId: demo.id, email: "monteur@demo.example" }, select: { id: true } });
const techTeams = (await prisma.teamMember.findMany({ where: { tenantId: demo.id, userId: tech.id }, select: { teamId: true } })).map((m) => m.teamId);
const foreignCustomer = await prisma.customer.findFirstOrThrow({
where: {
tenantId: demo.id,
deletedAt: null,
workOrders: { none: { OR: [{ assignedTeamId: { in: techTeams } }, { assignees: { some: { userId: tech.id } } }] } },
},
orderBy: { customerNumber: "asc" },
});
const site = await prisma.site.findFirstOrThrow({ where: { tenantId: demo.id, name: "Wohnanlage Elbblick – Haus A" } }); const site = await prisma.site.findFirstOrThrow({ where: { tenantId: demo.id, name: "Wohnanlage Elbblick – Haus A" } });
const approved = await prisma.report.findFirstOrThrow({ where: { workOrderId: d18.id, status: "approved" } }); const approved = await prisma.report.findFirstOrThrow({ where: { workOrderId: d18.id, status: "approved" } });
const inReview = d16 ? await prisma.report.findFirst({ where: { workOrderId: d16.id } }) : null; const inReview = d16 ? await prisma.report.findFirst({ where: { workOrderId: d16.id } }) : null;
@@ -75,7 +86,10 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
{ path: "/settings/users", mustContain: "backoffice@demo.example" }, { path: "/settings/users", mustContain: "backoffice@demo.example" },
{ path: "/settings/audit" }, { path: "/settings/audit" },
{ path: "/settings/email" }, { path: "/settings/email" },
{ path: "/settings/lotse" }, { path: "/settings/lotse", mustContain: "Lotse-Chat für Monteure" },
// L17 Pakete: Paket nur lesend, Plätze + Verbrauch
{ path: "/settings/lotse", mustContain: ["Paket &amp; Lotse-Chat", "Profi", "3 von 3 Plätzen vergeben", "Max Monteur", "Platz entziehen", "Chats"] },
{ path: "/settings", mustContain: "Freigeschaltete Module" },
{ path: "/settings/lotse/protocol" }, { path: "/settings/lotse/protocol" },
{ path: "/settings/order-types", mustContain: "Montage" }, { path: "/settings/order-types", mustContain: "Montage" },
{ path: "/settings/checklists", mustContain: "Montage Heizung/Sanitär" }, { path: "/settings/checklists", mustContain: "Montage Heizung/Sanitär" },
@@ -154,6 +168,10 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
// L13 Planung: team lead read-only, own team only // L13 Planung: team lead read-only, own team only
{ path: "/planning", mustContain: ["Plantafel", "Team Nord", "Nur Lesezugriff"], mustNotContain: ["Team Süd"] }, { path: "/planning", mustContain: ["Plantafel", "Team Nord", "Nur Lesezugriff"], mustNotContain: ["Team Süd"] },
{ path: "/planning/live", mustContain: "keine GPS-Ortung", mustNotContain: ["Team Süd"] }, { path: "/planning/live", mustContain: "keine GPS-Ortung", mustNotContain: ["Team Süd"] },
// L17 Pakete: Teamleiterin ohne Chat-Platz → kein Navigationseintrag, freundlicher Hinweis
{ path: "/m", mustNotContain: ['href="/m/lotse"'] },
{ path: "/m/lotse", mustContain: "frag im Büro nach", mustNotContain: ['id="lotse-chat-input"'] },
...(x.d14 ? [{ path: m(x.d14), mustContain: x.d14.number, mustNotContain: [`href="/m/lotse?order=${x.d14.id}"`] }] : []),
], ],
}, },
{ {
@@ -185,6 +203,12 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
{ path: "/m/time/new", mustContain: ["Zeit nachtragen", "Begründung"] }, { path: "/m/time/new", mustContain: ["Zeit nachtragen", "Begründung"] },
{ path: "/m/approvals", mustContain: "nicht freigeschaltet" }, { path: "/m/approvals", mustContain: "nicht freigeschaltet" },
{ path: m(x.d07, "/time"), mustContain: x.d07.number }, { path: m(x.d07, "/time"), mustContain: x.d07.number },
// L16 Lotse-Chat für Monteure
{ path: "/m/lotse", mustContain: ["Lotse-Chat", "Neuer Chat", "Sprechen"] },
{ path: `/m/lotse?order=${x.d07.id}`, mustContain: [x.d07.number, "Lotse-Chat"] },
{ path: m(x.d07), mustContain: "Lotse fragen" },
{ path: "/m", mustContain: ['href="/m/lotse"', 'href="/m/emergency"'] }, // L17: Platz vergeben, Profi
...(x.d08 ? [{ path: `/m/lotse?order=${x.d08.id}`, expect: [404] }] : []),
// L14 Abrechnungsübersicht: Meilensteine mobil, Backoffice-Übersicht ohne Recht // L14 Abrechnungsübersicht: Meilensteine mobil, Backoffice-Übersicht ohne Recht
{ path: m(x.d07), mustContain: ["Meilensteine", "Smoke-Meilenstein", "Erreicht melden"] }, { path: m(x.d07), mustContain: ["Meilensteine", "Smoke-Meilenstein", "Erreicht melden"] },
// order numbers appear in the monteur's own notifications and all UI texts in the serialized messages → check the record link // order numbers appear in the monteur's own notifications and all UI texts in the serialized messages → check the record link
@@ -219,15 +243,25 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
{ path: `/customers/${x.customer.id}`, expect: [404] }, { path: `/customers/${x.customer.id}`, expect: [404] },
{ path: `/sites/${x.site.id}`, expect: [404] }, { path: `/sites/${x.site.id}`, expect: [404] },
{ path: `/reports/${x.approved.id}`, expect: [404] }, { path: `/reports/${x.approved.id}`, expect: [404] },
...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, expect: [404] }] : []), // L17: demo2 is Basis → module layouts redirect before the (foreign) id is looked up
...(x.imp ? [{ path: `/imports/${x.imp.id}`, expect: [404] }] : []), ...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, expect: [307, 404] }] : []),
...(x.imp ? [{ path: `/imports/${x.imp.id}`, expect: [307, 404] }] : []),
...(x.photo ? [{ path: `/files/${x.photo.documentId}`, expect: [404] }] : []), ...(x.photo ? [{ path: `/files/${x.photo.documentId}`, expect: [404] }] : []),
{ path: "/planning", mustNotContain: ["Team Nord", x.d07.number] }, // L17 Pakete: demo2 = Basis → Planung, Abrechnung, Import, Notdienst, Lotse gesperrt (ruhiger Hinweis)
{ path: "/planning/live", mustNotContain: ["Team Nord", "Max Monteur"] }, { path: "/planning", expect: [307], redirectTo: "/dashboard" },
{ path: `/api/v1/planning/recommendations?workOrderId=${x.d07.id}`, expect: [404] }, { path: "/planning/live", expect: [307], redirectTo: "/dashboard" },
// L14 Abrechnungsübersicht: keine Einträge von demo { path: `/api/v1/planning/recommendations?workOrderId=${x.d07.id}`, expect: [403, 404] },
{ path: "/billing", mustContain: "Abrechnungsübersicht", mustNotContain: [x.d18.number, "Elbblick"] }, { path: "/api/v1/planning/board", expect: [403] },
...(x.billing ? [{ path: `/billing/${x.billing.id}`, expect: [404] }, { path: `/billing/print?ids=${x.billing.id}`, mustNotContain: [x.d18.number] }] : []), { path: "/billing", expect: [307], redirectTo: "/dashboard" },
{ path: "/imports", expect: [307], redirectTo: "/dashboard" },
{ path: "/work-orders/emergency-review", expect: [307], redirectTo: "/dashboard" },
{ path: "/api/v1/billing", expect: [403] },
{ path: "/dashboard?module=profi", mustContain: ["Im Paket Profi enthalten", "Offene Aufträge"], mustNotContain: ['href="/planning"', 'href="/billing"', 'href="/imports"', 'href="/work-orders/emergency-review"'] },
{ path: "/settings/lotse", mustContain: ["Paket &amp; Lotse-Chat", "Basis", "im Paket Profi enthalten"] },
{ path: "/work-orders", mustContain: "DGUV" },
{ path: "/customers" },
{ path: "/reports" },
...(x.billing ? [{ path: `/billing/${x.billing.id}`, expect: [307, 404] }] : []),
{ path: wo(x.d07, "?tab=billing"), expect: [404] }, { path: wo(x.d07, "?tab=billing"), expect: [404] },
], ],
}, },
+155
View File
@@ -0,0 +1,155 @@
/**
* L15 Testphase — HTTP smoke against a running server, WITHOUT typing passwords (session cookies are
* built like scripts/smoke-auth.ts). Creates zz trial tenants (expired + running) through the real
* services, checks the public wizard pages, banners, the "Erste Schritte" card, the export page, the
* central write lock on real /api/v1 routes (customers, sync, uploads, work-order documents,
* backoffice upload) and the platform pages; removes the zz tenants afterwards.
*
* Usage: BASE=http://localhost:3115 npx tsx scripts/smoke-testphase.ts
*/
import "dotenv/config";
import { encode } from "next-auth/jwt";
import { prisma } from "../src/server/db";
import { finalizeIdentityLogin } from "../src/server/auth";
import { addDaysToKey, todayKey } from "../src/lib/trial/dates";
import { endTrialNow } from "../src/server/services/trial/admin";
import { addMember, cleanupL15, platformAdmin, trialTenant } from "./lib/testphase-fixture";
const BASE = process.env.BASE ?? "http://localhost:3115";
const SECURE = BASE.startsWith("https");
const COOKIE = SECURE ? "__Secure-authjs.session-token" : "authjs.session-token";
const PLATFORM_COOKIE = `${SECURE ? "__Secure-" : ""}platform-authjs.session-token`;
type Check = { label?: string; path: string; method?: string; body?: BodyInit; headers?: Record<string, string>; expect?: number[]; mustContain?: string[]; mustNotContain?: string[]; redirectTo?: string };
async function tenantCookie(email: string, slug: string): Promise<string> {
const identity = await prisma.identity.findUniqueOrThrow({ where: { email } });
const user = await finalizeIdentityLogin(identity.id, slug);
if (!user) throw new Error(`no membership for ${email} in ${slug}`);
const token = {
sub: user.id, name: user.name, email: user.email, userId: user.id, identityId: user.identityId, tenantId: user.tenantId, tenantSlug: user.tenantSlug,
activeMembershipId: user.activeMembershipId, memberships: user.memberships, roles: user.roles, permissions: user.permissions, isPlatformAdmin: user.isPlatformAdmin, mfaEnrolled: user.mfaEnrolled,
};
return `${COOKIE}=${await encode({ token, secret: process.env.AUTH_SECRET!, salt: COOKIE, maxAge: 1800 })}`;
}
async function platformCookie(adminId: string): Promise<string> {
const token = { sub: adminId, userId: adminId, isPlatformAdmin: true, mfaEnrolled: false };
return `${PLATFORM_COOKIE}=${await encode({ token, secret: process.env.AUTH_SECRET!, salt: PLATFORM_COOKIE, maxAge: 1800 })}`;
}
function multipart(fields: Record<string, string>, file?: { name: string; type: string; bytes: Buffer }): FormData {
const fd = new FormData();
for (const [k, v] of Object.entries(fields)) fd.set(k, v);
if (file) fd.set("file", new Blob([new Uint8Array(file.bytes)], { type: file.type }), file.name);
return fd;
}
async function main() {
await cleanupL15();
const today = todayKey();
const expired = await trialTenant("Smoke Abgelaufen", addDaysToKey(today, 5), { sampleData: true });
const running = await trialTenant("Smoke Laufend", addDaysToKey(today, 3), { sampleData: true });
const tech = await addMember(expired.tenantId, "tech-smoke", "technician");
const admin = await platformAdmin("full");
await endTrialNow({ platformAdminId: admin.id }, expired.tenantId);
const expiredSlug = (await prisma.tenant.findUniqueOrThrow({ where: { id: expired.tenantId } })).slug;
const runningSlug = (await prisma.tenant.findUniqueOrThrow({ where: { id: running.tenantId } })).slug;
const order = await prisma.workOrder.findFirstOrThrow({ where: { tenantId: expired.tenantId, status: "assigned" } });
const pdf = Buffer.from("%PDF-1.4\n%%EOF\n");
const plans: { who: string; cookie: string; checks: Check[] }[] = [
{
who: "anonym",
cookie: "",
checks: [
{ path: "/testen", mustContain: ["Craftvia kostenlos testen", "Firmenname", "Schritt 1 von 5", "Nutzungsbedingungen"] },
{ path: "/testen/bestaetigen?token=ungueltig", mustContain: ["ungültig"] },
{ path: "/testen/nutzungsbedingungen", mustContain: ["Nutzungsbedingungen", "Platzhalter"] },
{ path: "/testen/datenschutz", mustContain: ["Datenschutz"] },
{ path: "/dashboard", expect: [307], redirectTo: "/login" },
{ path: "/settings/export", expect: [307], redirectTo: "/login" },
],
},
{
who: `Admin abgelaufen (${expiredSlug})`,
cookie: await tenantCookie(`admin-smoke-abgelaufen@zz-l15.test`, expiredSlug),
checks: [
{ path: "/dashboard", mustContain: ["Testphase abgelaufen – nur Lesezugriff.", "Daten werden am", "Daten exportieren", 'data-trial-banner="expired"'] },
{ path: "/customers", mustContain: ["Testphase abgelaufen", "Hausverwaltung Musterhof"] },
{ path: "/settings/export", mustContain: ["Datenexport", "Export erstellen", "auch nach Ablauf"] },
{ path: "/api/v1/customers", mustContain: ['"data"'] },
{ label: "POST /api/v1/customers → gesperrt", path: "/api/v1/customers", method: "POST", body: JSON.stringify({ companyName: "ZZ Smoke" }), headers: { "content-type": "application/json" }, expect: [422], mustContain: ["trial_expired", "nur Lesezugriff"] },
{ label: "POST /api/v1/work-orders/[id]/documents → gesperrt", path: `/api/v1/work-orders/${order.id}/documents`, method: "POST", body: multipart({ category: "other", visibility: "team" }, { name: "a.pdf", type: "application/pdf", bytes: pdf }), headers: { accept: "application/json" }, expect: [422], mustContain: ["trial_expired"] },
{ label: "POST /documents/upload → gesperrt", path: "/documents/upload", method: "POST", body: multipart({ category: "other", visibility: "team" }, { name: "a.pdf", type: "application/pdf", bytes: pdf }), headers: { accept: "application/json" }, expect: [422], mustContain: ["trial_expired"] },
{ path: "/settings/export/unbekannt", expect: [404] },
],
},
{
who: `Monteur abgelaufen (${expiredSlug})`,
cookie: await tenantCookie(tech.email, expiredSlug),
checks: [
{ path: "/m", mustContain: ["Testphase abgelaufen – nur Lesezugriff.", 'data-trial-banner="expired"'] },
{ path: "/m/orders", mustContain: ["Testphase abgelaufen – nur Lesezugriff."] },
{ label: "GET /m/orders/[id] ohne Zuweisung → 404 (Scope)", path: `/m/orders/${order.id}`, expect: [404] },
{ path: "/m/emergency", mustContain: ["Testphase abgelaufen – nur Lesezugriff."] },
{ path: "/api/v1/field/bundle", mustContain: ['"orders"'] },
{ label: "POST /api/v1/sync → gesperrt", path: "/api/v1/sync", method: "POST", body: JSON.stringify({ deviceId: "zz-smoke", operations: [] }), headers: { "content-type": "application/json" }, expect: [422], mustContain: ["trial_expired"] },
{ label: "POST /api/v1/uploads → gesperrt", path: "/api/v1/uploads", method: "POST", body: multipart({ clientId: "7c1d6a0e-3b1f-4c55-9d2a-00000000f016", workOrderId: order.id, kind: "photo" }, { name: "a.jpg", type: "image/jpeg", bytes: Buffer.from([0xff, 0xd8, 0xff, 0xd9]) }), expect: [422], mustContain: ["trial_expired"] },
{ path: "/settings/export", expect: [307], redirectTo: "/dashboard" },
],
},
{
who: `Admin laufend (${runningSlug})`,
cookie: await tenantCookie(`admin-smoke-laufend@zz-l15.test`, runningSlug),
checks: [
{ path: "/dashboard", mustContain: ["Testphase endet in 3 Tagen.", "Erste Schritte", "von 5 erledigt", "Team anlegen", "Monteur einladen"] },
{ path: "/dashboard?welcome=1", mustContain: ["Willkommen! Ihre Testphase ist eingerichtet."] },
{ path: "/settings/export", mustContain: ["Datenexport"] },
],
},
{
who: "Plattform-Admin",
cookie: await platformCookie(admin.id),
checks: [
{ path: "/admin", mustContain: ["Testmandant anlegen", "ZZ L15 Smoke Abgelaufen", "abgelaufen – nur lesen", "Löschung am", "Test bis"] },
{ path: "/admin?plan=trial", mustContain: ["ZZ L15 Smoke Laufend"], mustNotContain: ["Musterbau Haustechnik"] },
{ path: "/admin?plan=full", mustNotContain: ["ZZ L15 Smoke Laufend"] },
{ path: "/admin/trial", mustContain: ["Testmandant anlegen", "Testphase bis", "Mit Beispieldaten"] },
{ path: `/admin/${expired.tenantId}`, mustContain: ["Testphase", "abgelaufen – nur lesen", "In Vollversion umwandeln", "Enddatum ändern / verlängern", "Löschung abbrechen"] },
{ path: `/admin/${expired.tenantId}?trial=extend`, mustContain: ["Enddatum ändern", "Ich bestätige diese Änderung."] },
{ path: `/admin/${running.tenantId}?trial=end`, mustContain: ["Testphase sofort beenden"] },
],
},
];
let failures = 0;
let total = 0;
for (const plan of plans) {
console.log(`\n== ${plan.who}`);
for (const c of plan.checks) {
total++;
const res = await fetch(BASE + c.path, { method: c.method ?? "GET", body: c.body, headers: { ...(plan.cookie ? { cookie: plan.cookie } : {}), ...c.headers }, redirect: "manual", signal: AbortSignal.timeout(120_000) });
const body = res.status >= 300 && res.status < 400 ? "" : await res.text();
const expect = c.expect ?? [200];
const loc = res.headers.get("location");
const okRedirect = !c.redirectTo || (loc ? new URL(loc, BASE).pathname === c.redirectTo : false);
const errorPage = res.status === 200 && /Application error|Internal Server Error|Unhandled Runtime Error/i.test(body);
const missing = (c.mustContain ?? []).filter((s) => !body.includes(s));
const leaked = (c.mustNotContain ?? []).filter((s) => body.includes(s));
const pass = expect.includes(res.status) && okRedirect && !errorPage && missing.length === 0 && leaked.length === 0;
if (!pass) failures++;
console.log(`${pass ? "✓" : "✗"} ${String(res.status).padEnd(3)} ${c.label ?? `${c.method ?? "GET"} ${c.path}`}${loc ? ` → ${loc}` : ""}${missing.length ? ` [fehlt: ${missing.join(" | ")}]` : ""}${leaked.length ? ` [unerwartet: ${leaked.join(" | ")}]` : ""}`);
}
}
await cleanupL15();
await prisma.$disconnect();
console.log(failures ? `\n${failures} von ${total} Prüfungen fehlgeschlagen` : `\nOK — ${total} Prüfungen`);
process.exit(failures ? 1 : 0);
}
main().catch(async (err) => {
console.error(err);
await cleanupL15().catch(() => undefined);
process.exit(1);
});
+7
View File
@@ -136,6 +136,13 @@ async function createModelRows(A: TenantFixture): Promise<Rows> {
// L14 Abrechnungsübersicht // L14 Abrechnungsübersicht
put("WorkOrderMilestone", (await prisma.workOrderMilestone.create({ data: { tenantId: t, workOrderId: wo.id, title: "ZZ" } })).id, { title: MARK }); 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("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 });
// L16 Lotse-Chat für Monteure
const lotseConversation = await prisma.lotseConversation.create({ data: { tenantId: t, userId: uid, workOrderId: wo.id } });
put("LotseConversation", lotseConversation.id, { workOrderId: MARK });
put("LotseMessage", (await prisma.lotseMessage.create({ data: { tenantId: t, conversationId: lotseConversation.id, role: "user", text: "ZZ" } })).id, { text: MARK });
put("LotseActionProposal", (await prisma.lotseActionProposal.create({ data: { tenantId: t, conversationId: lotseConversation.id, userId: uid, kind: "add_note", payload: {}, payloadHash: "0".repeat(64), expiresAt: new Date(Date.now() + 1_800_000) } })).id, { kind: MARK });
put("TenantExport", (await prisma.tenantExport.create({ data: { tenantId: t, status: "done", fileName: "zz.zip" } })).id, { fileName: MARK }); // L15 Testphase
put("LotseChatSeat", (await prisma.lotseChatSeat.create({ data: { tenantId: t, userId: uid } })).id, { assignedById: MARK }); // L17 Pakete
return rows; return rows;
} }
+70
View File
@@ -0,0 +1,70 @@
// Ereignisse werden erst NACH dem Commit ausgeliefert (events.ts#withDeferredEvents, von
// services/context.ts#inTransaction gesetzt). Vorher lief der Benachrichtigungs- und Mailversand
// noch in der offenen Transaktion und zählte gegen deren Zeitlimit — unter Last brach der
// Einsatzstart deshalb sporadisch ab.
// (1) innerhalb der Transaktion: noch keine Benachrichtigung
// (2) nach dem Commit: Benachrichtigung vorhanden
// (3) Rollback: Ereignis wird verworfen, keine Benachrichtigung
// (4) ohne Transaktion: unverändert sofortige Auslieferung
//
// Lauf: npx tsx scripts/test-events-deferred.ts
import "dotenv/config";
import { randomUUID } from "node:crypto";
import type { DomainEvent } from "../src/lib/events";
import { prisma } from "../src/server/db";
import { emitEvent } from "../src/server/events";
import { inTransaction } from "../src/server/services/context";
import { createTenant, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture";
const SLUG = "zz-events-deferred";
const TYPE = "work_order.assigned";
async function order(A: TenantFixture) {
return prisma.workOrder.create({
data: {
tenantId: A.tenantId,
number: `EV-${randomUUID().slice(0, 6)}`,
title: "Heizung warten",
customerId: A.customerId,
siteId: A.siteId,
assignedTeamId: A.teamId,
status: "assigned",
},
});
}
const ev = (entityId: string): DomainEvent => ({ type: TYPE, entityType: "work_order", entityId });
const notifications = (tenantId: string, entityId: string) => prisma.notification.count({ where: { tenantId, type: TYPE, entityId } });
void runSuite("Ereignisse nach dem Commit", [SLUG], async () => {
const A = await createTenant(SLUG);
section("Transaktion mit Commit");
const wo1 = await order(A);
const duringTransaction = await inTransaction(A.ctx.backoffice, async (tx) => {
await emitEvent(tx, ev(wo1.id));
return notifications(A.tenantId, wo1.id);
});
ok(duringTransaction === 0, "(1) während der Transaktion wird noch nichts zugestellt");
ok((await notifications(A.tenantId, wo1.id)) > 0, "(2) nach dem Commit ist die Benachrichtigung da");
section("Rollback");
const wo2 = await order(A);
let rolledBack = false;
try {
await inTransaction(A.ctx.backoffice, async (tx) => {
await emitEvent(tx, ev(wo2.id));
throw new Error("rollback");
});
} catch {
rolledBack = true;
}
ok(rolledBack, "(3) Transaktion abgebrochen");
ok((await notifications(A.tenantId, wo2.id)) === 0, "(3) verworfenes Ereignis löst keine Benachrichtigung aus");
section("Ohne Transaktion");
const wo3 = await order(A);
await emitEvent(A.ctx.backoffice, ev(wo3.id));
ok((await notifications(A.tenantId, wo3.id)) > 0, "(4) ohne Transaktion unverändert sofort zugestellt");
});
+51
View File
@@ -0,0 +1,51 @@
// Domain-Trennung craftvia.de ↔ app.craftvia.de (src/lib/marketing/hosts.ts)
//
// Lauf: npx tsx scripts/test-host-routing.ts
import { hostSplit, routeByHost, type HostDecision } from "../src/lib/marketing/hosts";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
const env = { MARKETING_HOST: "craftvia.de", AUTH_URL: "https://app.craftvia.de" };
const split = hostSplit(env)!;
const route = (host: string, path: string, hasSession = false): HostDecision => {
const [pathname, query] = path.split("?");
return routeByHost({ host, pathname, search: query ? `?${query}` : "", hasSession }, split);
};
const to = (d: HostDecision) => (d.kind === "redirect" ? `${d.status} ${d.location}` : "next");
console.log("\n— Konfiguration —");
ok(hostSplit({ AUTH_URL: "https://app.craftvia.de" }) === null, "ohne MARKETING_HOST keine Trennung (lokal)");
ok(hostSplit({ MARKETING_HOST: "craftvia.de" }) === null, "ohne AUTH_URL keine Trennung");
ok(hostSplit({ MARKETING_HOST: "app.craftvia.de", AUTH_URL: "https://app.craftvia.de" }) === null, "gleiche Domain → keine Trennung");
ok(hostSplit({ MARKETING_HOST: "https://Craftvia.de/", AUTH_URL: "https://app.craftvia.de" })?.marketingHost === "craftvia.de", "MARKETING_HOST mit Schema/Slash/Großschreibung wird normalisiert");
console.log("\n— Website-Domain —");
ok(to(route("craftvia.de", "/")) === "next", "/ zeigt die Startseite");
ok(to(route("craftvia.de", "/funktionen/planung")) === "next", "Funktionsseiten bleiben auf der Website");
ok(to(route("craftvia.de", "/impressum")) === "next", "Impressum bleibt auf der Website");
ok(to(route("craftvia.de", "/login")) === "308 https://app.craftvia.de/login", "/login → App-Domain");
ok(to(route("craftvia.de", "/testen?ref=preise")) === "308 https://app.craftvia.de/testen?ref=preise", "/testen → App-Domain, Query bleibt erhalten");
ok(to(route("craftvia.de", "/dashboard")) === "308 https://app.craftvia.de/dashboard", "App-Seiten → App-Domain");
ok(to(route("craftvia.de", "/api/v1/sync")) === "308 https://app.craftvia.de/api/v1/sync", "API → App-Domain (308 behält die Methode)");
ok(to(route("craftvia.de:443", "/preise")) === "next", "Port im Host wird ignoriert");
ok(to(route("www.craftvia.de", "/preise")) === "308 https://craftvia.de/preise", "www → Hauptdomain");
console.log("\n— App-Domain —");
ok(to(route("app.craftvia.de", "/")) === "307 https://app.craftvia.de/login", "/ ohne Sitzung → Login");
ok(to(route("app.craftvia.de", "/", true)) === "next", "/ mit Sitzung → weiter (Seite leitet auf Dashboard bzw. /m)");
ok(to(route("app.craftvia.de", "/funktionen")) === "308 https://craftvia.de/funktionen", "Produktseiten → Website");
ok(to(route("app.craftvia.de", "/impressum")) === "308 https://craftvia.de/impressum", "Impressum → Website");
ok(to(route("app.craftvia.de", "/testen")) === "next", "Testphase bleibt in der App");
ok(to(route("app.craftvia.de", "/planning", true)) === "next", "App-Seiten bleiben in der App");
console.log("\n— Interne Aufrufe —");
ok(to(route("127.0.0.1:3000", "/")) === "next", "Healthcheck über 127.0.0.1 unverändert");
ok(to(route("craftvia-app:3000", "/login")) === "next", "Container-interne Aufrufe unverändert");
console.log(failures ? `\n✗ ${failures} Fehler` : "\n✓ alle Prüfungen grün");
process.exit(failures ? 1 : 0);
+186
View File
@@ -0,0 +1,186 @@
// L16 Lotse-Chat für Monteure – Bestätigen, Bearbeiten, Verwerfen, „Alle bestätigen“.
//
// Deckt ab: Ausführung ausschließlich über bestehende Services mit dem Monteur-ctx (Status, Session, Zeitnachtrag mit
// Freigabepflicht, Material, Notiz, Berichtsvorschlag im L9-Mechanismus), Blocker (Pflichtfoto) → fehlgeschlagen mit
// Sprungziel und ohne Teiländerung, Einmaligkeit (Doppeltipp, parallel), Ablauf, fremder Nutzer / Mandant B → not_found,
// Monteur ohne Zuweisung → Service verweigert, manipulierte Nutzlast, Bearbeiten (nur erlaubte Felder), Audit mit Quelle,
// Reihenfolge und Stopp beim ersten Fehler, Einstellung aus → blocked.
//
// Lauf: npx tsx scripts/test-lotse-chat-confirm.ts
import "dotenv/config";
import { prisma, dbForTenant } from "../src/server/db";
import type { ProposalKind } from "../src/lib/lotse/chat";
import { sessionStartPayload } from "../src/lib/sync/ops";
import { toWallTimeInput } from "../src/lib/work-orders/time";
import { parseReportContent } from "../src/lib/reports/content";
import type { ServiceCtx } from "../src/server/services/context";
import { startSession } from "../src/server/services/field/sessions";
import { createDailyReport } from "../src/server/services/reports/create";
import { decideLotseSuggestion } from "../src/server/services/lotse/suggestions";
import { updateLotseSettings } from "../src/server/services/lotse/settings";
import { confirmAllProposals, confirmProposal, discardProposal } from "../src/server/services/lotse/chat/confirm";
import { createProposal, hashMatches } from "../src/server/services/lotse/chat/proposals";
import { getChatView } from "../src/server/services/lotse/chat/conversations";
import { cleanupChatTenants, createChatFixture, expectErr, failures, ok } from "./lib/lotse-chat-fixture";
const PREFIX = "lotsechat-confirm";
const TZ = "Europe/Berlin";
async function main() {
const f = await createChatFixture(PREFIX);
const { wo1, wo2, wo3, wo4 } = f.orders;
const conversations = new Map<string, string>();
async function conversationOf(ctx: ServiceCtx) {
if (!conversations.has(ctx.userId)) {
const c = await prisma.lotseConversation.create({ data: { tenantId: ctx.tenantId, userId: ctx.userId } });
conversations.set(ctx.userId, c.id);
}
return conversations.get(ctx.userId)!;
}
async function card(ctx: ServiceCtx, kind: ProposalKind, workOrderId: string, payload: Record<string, unknown>, messageId?: string) {
const conversationId = await conversationOf(ctx);
const p = await createProposal(ctx, { conversationId, kind, payload: { workOrderId, ...payload }, workOrderId, now: new Date() });
if (messageId) await prisma.lotseActionProposal.update({ where: { id: p.id }, data: { messageId } });
return p;
}
const slot = (startMinutesAgo: number, endMinutesAgo: number) => {
const s = toWallTimeInput(new Date(Date.now() - startMinutesAgo * 60_000), TZ);
const e = toWallTimeInput(new Date(Date.now() - endMinutesAgo * 60_000), TZ);
return { date: s.slice(0, 10), from: s.slice(11, 16), to: e.slice(11, 16), durationMinutes: startMinutesAgo - endMinutesAgo };
};
console.log("\n— Status über transitionWorkOrder / Sessions —");
const accept = await card(f.ctx.tech2, "transition_work_order", wo2.id, { number: "A-LC-2", action: "accept" });
const r1 = await confirmProposal(f.ctx.tech2, { proposalId: accept.id });
ok(r1.status === "confirmed", "Annehmen bestätigt");
ok((await prisma.workOrder.findUnique({ where: { id: wo2.id } }))?.status === "accepted", "Auftrag → accepted (Service)");
ok((await prisma.workOrderStatusChange.count({ where: { workOrderId: wo2.id, toStatus: "accepted", actorId: f.users.tech2.id } })) === 1, "Statuswechsel mit Monteur als Akteur");
const again = await confirmProposal(f.ctx.tech2, { proposalId: accept.id });
ok(again.status === "confirmed" && again.idempotent === true, "Doppeltipp → idempotent");
const auditRow = await prisma.auditLog.findFirst({ where: { tenantId: f.tA.id, entity: "lotse_action_proposal", entityId: accept.id } });
ok((auditRow?.after as { source?: string; status?: string } | null)?.source === "lotse_chat" && (auditRow?.before as { status?: string } | null)?.status === "proposed", "Audit before/after mit Quelle lotse_chat");
const start = await card(f.ctx.tech2, "transition_work_order", wo2.id, { number: "A-LC-2", action: "start_work" });
ok((await confirmProposal(f.ctx.tech2, { proposalId: start.id })).status === "confirmed", "Arbeit starten bestätigt");
ok((await prisma.workSession.count({ where: { workOrderId: wo2.id, userId: f.users.tech2.id, status: "running" } })) === 1, "laufende Session angelegt");
ok((await prisma.workOrder.findUnique({ where: { id: wo2.id } }))?.status === "in_progress", "Auftrag → in_progress");
console.log("\n— Zeitnachtrag nach L12-Regeln —");
const time = await card(f.ctx.tech2, "book_time", wo2.id, { number: "A-LC-2", type: "work", reason: "Uhr vergessen", ...slot(95, 65) });
ok((await confirmProposal(f.ctx.tech2, { proposalId: time.id })).status === "confirmed", "Zeit bestätigt");
const entry = await prisma.timeEntry.findFirst({ where: { userId: f.users.tech2.id, source: "manual" }, orderBy: { createdAt: "desc" } });
ok(entry?.approvalStatus === "pending" && entry.correctionReason === "Uhr vergessen", "Nachtrag pending (Freigabepflicht) mit Grund");
ok(((await prisma.lotseActionProposal.findUnique({ where: { id: time.id } }))?.result as { approvalStatus?: string } | null)?.approvalStatus === "pending", "Ergebnis der Karte: zur Freigabe");
const edited = await card(f.ctx.tech2, "book_time", wo2.id, { number: "A-LC-2", type: "work", reason: "Uhr vergessen", ...slot(160, 130) });
ok((await confirmProposal(f.ctx.tech2, { proposalId: edited.id, edits: { reason: "Kein Netz", workOrderId: wo1.id, number: "X" } })).status === "confirmed", "bearbeitete Zeit bestätigt");
const editedEntry = await prisma.timeEntry.findFirst({ where: { userId: f.users.tech2.id, source: "manual" }, orderBy: { createdAt: "desc" }, include: { workSession: { select: { workOrderId: true } } } });
ok(editedEntry?.correctionReason === "Kein Netz" && editedEntry.workSession.workOrderId === wo2.id, "Änderung übernommen, Auftrag nicht änderbar");
const editedRow = await prisma.lotseActionProposal.findUniqueOrThrow({ where: { id: edited.id } });
ok((editedRow.payload as { reason?: string }).reason === "Kein Netz" && hashMatches(editedRow), "Nutzlast + Hash nach Bearbeitung aktualisiert");
const editAudit = await prisma.auditLog.findFirst({ where: { entity: "lotse_action_proposal", entityId: edited.id } });
ok((editAudit?.after as { edited?: boolean } | null)?.edited === true, "Audit: bearbeitet");
const overlapCard = await card(f.ctx.tech2, "book_time", wo2.id, { number: "A-LC-2", type: "work", reason: "Uhr vergessen", ...slot(90, 70) });
const overlap = await confirmProposal(f.ctx.tech2, { proposalId: overlapCard.id });
ok(overlap.status === "failed" && overlap.code === "overlap", `Überschneidung beim Bestätigen → fehlgeschlagen (${overlap.code})`);
console.log("\n— Material, Notiz, Einmaligkeit —");
const mat = await card(f.ctx.tech, "record_material", wo1.id, { number: "A-LC-1", materialPlanId: f.plan.id, name: "Filter", quantity: 1, unit: "Stk", usageStatus: "fully_used" });
ok((await confirmProposal(f.ctx.tech, { proposalId: mat.id })).status === "confirmed", "Material bestätigt");
const usage = await prisma.materialUsage.findFirst({ where: { workOrderId: wo1.id, materialPlanId: f.plan.id } });
ok(usage?.usageStatus === "fully_used" && Number(usage.actualQuantity) === 1 && usage.recordedById === f.users.tech.id, "MaterialUsage über Service erfasst");
const note = await card(f.ctx.tech, "add_note", wo1.id, { number: "A-LC-1", kind: "work_done", text: "Speicher installiert" });
const notesBefore = await prisma.activityNote.count({ where: { workOrderId: wo1.id } });
const parallel = await Promise.all([confirmProposal(f.ctx.tech, { proposalId: note.id }), confirmProposal(f.ctx.tech, { proposalId: note.id })]);
ok(parallel.every((r) => r.status === "confirmed") && parallel.some((r) => r.idempotent), "paralleler Doppeltipp: einmal ausgeführt, einmal idempotent");
ok((await prisma.activityNote.count({ where: { workOrderId: wo1.id } })) === notesBefore + 1, "genau eine Notiz angelegt");
console.log("\n— Blocker → Rückfrage mit Sprungziel —");
await startSession(f.ctx.tech, sessionStartPayload.parse({ workOrderId: wo3.id, mode: "work" }));
const complete = await card(f.ctx.tech, "transition_work_order", wo3.id, { number: "A-LC-3", action: "complete" });
const blocked = await confirmProposal(f.ctx.tech, { proposalId: complete.id });
ok(blocked.status === "failed" && blocked.code === "blocked", `Abschluss mit fehlendem Pflichtfoto → fehlgeschlagen (${blocked.code})`);
ok((await prisma.workOrder.findUnique({ where: { id: wo3.id } }))?.status === "in_progress", "Auftrag unverändert");
ok((await prisma.workSession.count({ where: { workOrderId: wo3.id, userId: f.users.tech.id, status: "running" } })) === 1, "Session nicht beendet (Blocker vor jeder Änderung geprüft)");
const view = await getChatView(f.ctx.tech, { conversationId: await conversationOf(f.ctx.tech) });
const failNotice = view.messages.find((m) => m.role === "tool" && m.content.noticeKey === "failed");
ok(Boolean(failNotice?.content.links?.some((l) => l.href === `/m/orders/${wo3.id}/photos` && l.label === "Fertige Montage")), "Lotse-Antwort mit Sprungziel zum Pflichtfoto");
await expectErr(() => confirmProposal(f.ctx.tech, { proposalId: complete.id }), "conflict", "fehlgeschlagene Karte nicht erneut ausführbar", "already_decided");
console.log("\n— Ablauf, fremde Nutzer, Manipulation, Verwerfen —");
const late = await card(f.ctx.tech, "add_note", wo1.id, { number: "A-LC-1", kind: "general", text: "zu spät" });
await expectErr(() => confirmProposal(f.ctx.tech, { proposalId: late.id }, new Date(Date.now() + 31 * 60_000)), "conflict", "nach 30 Minuten abgelaufen", "expired");
ok((await prisma.lotseActionProposal.findUnique({ where: { id: late.id } }))?.status === "expired", "Status expired gespeichert");
const foreign = await card(f.ctx.tech, "add_note", wo1.id, { number: "A-LC-1", kind: "general", text: "fremd" });
await expectErr(() => confirmProposal(f.ctx.tech2, { proposalId: foreign.id }), "not_found", "anderer Monteur (gleicher Auftrag) → not_found");
await expectErr(() => confirmProposal(f.ctx.admin, { proposalId: foreign.id }), "not_found", "Admin bestätigt keine fremde Karte");
await expectErr(() => confirmProposal(f.ctx.techB, { proposalId: foreign.id }), "not_found", "Mandant B → not_found");
await expectErr(() => discardProposal(f.ctx.techB, { proposalId: foreign.id }), "not_found", "Mandant B verwirft nicht");
ok((await dbForTenant(f.tB.id).lotseActionProposal.findFirst({ where: { id: foreign.id } })) === null, "B-Client sieht die Karte nicht");
await prisma.lotseActionProposal.update({ where: { id: foreign.id }, data: { payload: { ...(foreign.payload as object), text: "manipuliert" } } });
await expectErr(() => confirmProposal(f.ctx.tech, { proposalId: foreign.id }), "conflict", "veränderte Nutzlast → nicht ausgeführt", "tampered");
ok((await prisma.activityNote.count({ where: { text: "manipuliert" } })) === 0, "keine manipulierte Notiz");
const discard = await card(f.ctx.tech, "add_note", wo1.id, { number: "A-LC-1", kind: "general", text: "verwerfen" });
ok((await discardProposal(f.ctx.tech, { proposalId: discard.id })).status === "discarded", "verworfen");
ok((await discardProposal(f.ctx.tech, { proposalId: discard.id })).status === "discarded", "Verwerfen idempotent");
await expectErr(() => confirmProposal(f.ctx.tech, { proposalId: discard.id }), "conflict", "verworfene Karte nicht bestätigbar", "already_decided");
ok((await prisma.activityNote.count({ where: { text: "verwerfen" } })) === 0, "nichts ausgeführt");
const notAssigned = await card(f.ctx.outsider, "add_note", wo1.id, { number: "A-LC-1", kind: "general", text: "ohne Zuweisung" });
const outsiderResult = await confirmProposal(f.ctx.outsider, { proposalId: notAssigned.id });
ok(outsiderResult.status === "failed" && outsiderResult.code === "not_found", "Monteur ohne Zuweisung: Service verweigert (not_found)");
ok((await prisma.activityNote.count({ where: { text: "ohne Zuweisung" } })) === 0, "keine Notiz ohne Zuweisung");
console.log("\n— Alle bestätigen —");
const conv = await conversationOf(f.ctx.tech);
const msg = await prisma.lotseMessage.create({ data: { tenantId: f.tA.id, conversationId: conv, role: "assistant", text: "Drei Karten" } });
const pause = await card(f.ctx.tech, "transition_work_order", wo3.id, { number: "A-LC-3", action: "pause" }, msg.id);
const noteAll = await card(f.ctx.tech, "add_note", wo3.id, { number: "A-LC-3", kind: "work_done", text: "Pumpe gesetzt" }, msg.id);
const badMaterial = await card(f.ctx.tech, "record_material", wo3.id, { number: "A-LC-3", name: "Dichtung", quantity: 2, unit: "Stk", usageStatus: "additional" }, msg.id);
const all1 = await confirmAllProposals(f.ctx.tech, { messageId: msg.id });
ok(all1.stopped && all1.results.length === 1 && all1.results[0].proposalId === badMaterial.id && all1.remaining === 2, "Reihenfolge (Material vor Notiz vor Status), Stopp beim ersten Fehler");
ok((await prisma.lotseActionProposal.findUnique({ where: { id: noteAll.id } }))?.status === "proposed" && (await prisma.workOrder.findUnique({ where: { id: wo3.id } }))?.status === "in_progress", "nach Fehler nichts weiter ausgeführt");
const stoppedNotice = await prisma.lotseMessage.findFirst({ where: { conversationId: conv, role: "tool" }, orderBy: { createdAt: "desc" } });
ok((stoppedNotice?.content as { noticeKey?: string } | null)?.noticeKey === "stopped", "Klartext-Hinweis „gestoppt“");
const all2 = await confirmAllProposals(f.ctx.tech, { messageId: msg.id });
ok(!all2.stopped && all2.results.map((r) => r.proposalId).join() === [noteAll.id, pause.id].join(), "zweiter Lauf: Notiz, dann Pause");
ok((await prisma.workOrder.findUnique({ where: { id: wo3.id } }))?.status === "paused", "Auftrag pausiert");
await expectErr(() => confirmAllProposals(f.ctx.tech2, { messageId: msg.id }), "not_found", "„Alle bestätigen“ fremder Nachricht → not_found");
console.log("\n— Berichtsfelder als Lotse-Vorschlag (L9) —");
const { report } = await createDailyReport(f.ctx.tech, { workOrderId: wo4.id });
const fields = await card(f.ctx.tech, "suggest_report_fields", wo4.id, { number: "A-LC-4", reportId: report.id, reportType: "daily", fields: [{ field: "workPerformed", text: "Regler eingestellt und geprüft" }] });
ok((await confirmProposal(f.ctx.tech, { proposalId: fields.id })).status === "confirmed", "Berichtsvorschlag bestätigt");
const updated = await prisma.report.findUniqueOrThrow({ where: { id: report.id } });
const content = parseReportContent(updated.content);
ok(updated.aiDrafted && content.lotse?.suggestions.some((s) => s.field === "workPerformed" && s.state === "pending" && s.text === "Regler eingestellt und geprüft") === true, "Vorschlag in content.lotse (pending), aiDrafted");
ok(content.texts.workPerformed === "", "Berichtstext selbst unverändert");
await decideLotseSuggestion(f.ctx.tech, { reportId: report.id, field: "workPerformed", decision: "accept" });
ok(parseReportContent((await prisma.report.findUniqueOrThrow({ where: { id: report.id } })).content).texts.workPerformed === "Regler eingestellt und geprüft", "im Bericht über den L9-Mechanismus übernommen");
console.log("\n— Einstellung aus —");
const offCard = await card(f.ctx.tech, "add_note", wo1.id, { number: "A-LC-1", kind: "general", text: "aus" });
await updateLotseSettings(f.ctx.admin, { enabled: true, addressForm: "neutral", chatEnabled: false });
await expectErr(() => confirmProposal(f.ctx.tech, { proposalId: offCard.id }), "blocked", "Lotse-Chat aus → Bestätigen blocked", "chat_disabled");
await updateLotseSettings(f.ctx.admin, { enabled: false, addressForm: "neutral", chatEnabled: true });
await expectErr(() => discardProposal(f.ctx.tech, { proposalId: offCard.id }), "blocked", "Lotse-Modul aus → Verwerfen blocked", "disabled");
await updateLotseSettings(f.ctx.admin, { enabled: true, addressForm: "neutral", chatEnabled: true });
await cleanupChatTenants(PREFIX);
console.log(failures ? `\n${failures} Fehler` : "\nAlle Prüfungen grün.");
await prisma.$disconnect();
process.exit(failures ? 1 : 0);
}
main().catch(async (err) => {
console.error(err);
await cleanupChatTenants(PREFIX).catch(() => undefined);
await prisma.$disconnect();
process.exit(1);
});
+214
View File
@@ -0,0 +1,214 @@
// L16 Lotse-Chat für Monteure – Chat-Schleife mit Fake-Provider (geskriptete Tool-Aufrufe).
//
// Deckt ab: Auftragszuordnung (Kontext > laufende Uhr > heutige Aufträge, Suchbegriff, Mehrdeutigkeit → Chips),
// Rückfrage (ask_user beendet die Schleife), Vorschläge schreiben nichts vor der Bestätigung, fehlende Pflichtwerte →
// keine Karte, Datenminimierung (Modell-Input ohne Telefon/E-Mail/Adresse/Namen, Platzhalter serverseitig eingesetzt),
// Monteur ohne Zuweisung → not_found, Mandantentrennung + nur eigener Verlauf, Einstellung/Modul aus → blocked,
// Token-Kontingent, Runden- und Token-Grenze je Nachricht, Anbieterfehler, AiGeneration/Protokoll, Aufbewahrung.
//
// Lauf: npx tsx scripts/test-lotse-chat-engine.ts
import "dotenv/config";
import { prisma, dbForTenant } from "../src/server/db";
import { FakeLotseChatProvider, type FakeChatStep } from "../src/server/ai/lotse/chat-fake";
import { chatSystemPrompt } from "../src/server/ai/lotse/chat-prompt";
import type { ServiceCtx } from "../src/server/services/context";
import { startSession } from "../src/server/services/field/sessions";
import { sessionStartPayload } from "../src/lib/sync/ops";
import { sendLotseMessage } from "../src/server/services/lotse/chat/engine";
import { getChatView, startNewConversation } from "../src/server/services/lotse/chat/conversations";
import { canUseLotseChat } from "../src/server/services/lotse/chat/access";
import { renderPlaceholders } from "../src/server/services/lotse/chat/placeholders";
import { queryWords } from "../src/server/services/lotse/chat/orders";
import { updateLotseSettings } from "../src/server/services/lotse/settings";
import { listAiGenerations } from "../src/server/services/lotse/protocol";
import { purgeExpiredAiGenerations } from "../src/server/services/lotse/retention";
import { cleanupChatTenants, createChatFixture, expectErr, failures, ok } from "./lib/lotse-chat-fixture";
const PREFIX = "lotsechat-engine";
async function send(ctx: ServiceCtx, text: string, script: FakeChatStep[], opts: { conversationId?: string | null; workOrderId?: string | null; maxRounds?: number; turnTokenLimit?: number; fail?: Error } = {}) {
const provider = new FakeLotseChatProvider(script, opts.fail ? { fail: opts.fail } : {});
const view = await sendLotseMessage(ctx, { text, conversationId: opts.conversationId, workOrderId: opts.workOrderId }, { provider, maxRounds: opts.maxRounds, turnTokenLimit: opts.turnTokenLimit });
return { view, provider, last: view.messages[view.messages.length - 1] };
}
const lastToolResult = (p: FakeLotseChatProvider): string => {
const call = p.calls[p.calls.length - 1];
const turn = [...call.turns].reverse().find((t) => t.role === "tool_results");
return turn && turn.role === "tool_results" ? turn.results.map((r) => r.content).join("\n") : "";
};
async function main() {
const f = await createChatFixture(PREFIX);
const { wo1, wo2 } = f.orders;
console.log("\n— Prompt & Hilfsfunktionen —");
const prompt = chatSystemPrompt({ addressForm: "du", now: "Dienstag", today: "2026-09-15", contextOrder: "A-1", runningClockOrder: null });
ok(prompt.includes("du führst nie selbst etwas aus") && prompt.includes("„du“") && prompt.includes("ask_user"), "System-Prompt: nie ausführen, Anrede, Rückfrage");
ok(queryWords("Auftrag bei Objekt Müller").join(",") === "müller", "Suchbegriff ohne Füllwörter");
// tech: laufende Uhr auf A-LC-1
await startSession(f.ctx.tech, sessionStartPayload.parse({ workOrderId: wo1.id, mode: "work", at: new Date(Date.now() - 20 * 60_000).toISOString() }));
console.log("\n— Auftragszuordnung —");
const noteScript = (input: Record<string, unknown> = {}): FakeChatStep[] => [{ toolCalls: [{ name: "add_note", input: { text: "Speicher installiert", ...input } }] }, { text: "Bitte prüfen und bestätigen." }];
const ctxTurn = await send(f.ctx.tech, "Speicher installiert", noteScript(), { workOrderId: wo2.id });
ok(ctxTurn.last.proposals.length === 1 && ctxTurn.last.proposals[0].workOrderId === wo2.id, "Kontext-Auftrag hat Vorrang vor der laufenden Uhr");
ok(ctxTurn.view.workOrder?.id === wo2.id && ctxTurn.provider.calls[0].system.includes("Kontext-Auftrag (Chat aus dem Auftrag geöffnet): A-LC-2"), "Kontext steht im System-Prompt");
const clockTurn = await send(f.ctx.tech, "Speicher installiert", noteScript());
ok(clockTurn.last.proposals[0]?.workOrderId === wo1.id, "ohne Kontext: Auftrag der laufenden Uhr");
const generalConversation = clockTurn.view.conversationId!;
const ambiguous = await send(f.ctx.tech2, "Filter getauscht", [{ toolCalls: [{ name: "add_note", input: { text: "Filter getauscht" } }] }, { text: "Welcher Auftrag?" }]);
ok(ambiguous.last.proposals.length === 0, "mehrdeutig (zwei heutige Aufträge, keine Uhr) → keine Karte");
const chipValues = (ambiguous.last.content.chips ?? []).map((c) => c.value).sort();
ok(chipValues.join("|") === "Auftrag A-LC-1|Auftrag A-LC-2", `Auswahl-Chips mit beiden Aufträgen (${chipValues.join(", ")})`);
ok(lastToolResult(ambiguous.provider).includes("order_ambiguous"), "Modell erhält order_ambiguous");
ok(!(ambiguous.last.content.chips ?? []).some((c) => c.value.includes("Mustermann")) && ambiguous.last.content.chips!.some((c) => c.label.includes("Mustermann")), "Chip-Beschriftung für den Monteur mit Kunde, Wert ohne Namen");
const byQuery = await send(f.ctx.tech2, "Bei Müller Notiz", noteScript({ order: "Objekt Müller" }));
ok(byQuery.last.proposals[0]?.workOrderId === wo2.id, "Suchbegriff Kunde/Objekt → eindeutiger Auftrag");
const byNumber = await send(f.ctx.tech2, "Notiz zu 1", noteScript({ order: "A-LC-1" }));
ok(byNumber.last.proposals[0]?.workOrderId === wo1.id, "Suchbegriff Auftragsnummer");
const asked = await send(f.ctx.tech2, "2 Std. gebucht", [{ toolCalls: [{ name: "ask_user", input: { question: "Für welchen Auftrag?", options: ["A-LC-1", "A-LC-2"] } }] }, { text: "darf nicht kommen" }]);
ok(asked.provider.calls.length === 1 && asked.last.text.includes("Für welchen Auftrag?"), "ask_user beendet die Schleife, Frage als Antwort");
ok((asked.last.content.chips ?? []).length === 2, "Antwortoptionen als Chips");
console.log("\n— Vorschläge schreiben nichts —");
const counts = async () => ({
time: await prisma.timeEntry.count({ where: { tenantId: f.tA.id, source: "manual" } }),
material: await prisma.materialUsage.count({ where: { tenantId: f.tA.id } }),
notes: await prisma.activityNote.count({ where: { tenantId: f.tA.id } }),
status: (await prisma.workOrder.findMany({ where: { tenantId: f.tA.id }, orderBy: { number: "asc" }, select: { status: true } })).map((w) => w.status).join(","),
changes: await prisma.workOrderStatusChange.count({ where: { tenantId: f.tA.id } }),
});
const before = await counts();
const multi = await send(f.ctx.tech2, "Auftrag fertig, Speicher installiert, 30 Min., 1 Filter verbaut", [
{
toolCalls: [
{ name: "book_time", input: { order: "A-LC-2", duration_minutes: 30, reason: "Uhr vergessen" } },
{ name: "record_material", input: { order: "A-LC-1", name: "Filter", quantity: 1 } },
{ name: "add_note", input: { order: "A-LC-1", text: "Speicher installiert" } },
{ name: "transition_work_order", input: { order: "A-LC-2", action: "accept" } },
],
},
{ text: "Vier Karten liegen bereit. Die Zeit zählt nach Freigabe." },
]);
const kinds = multi.last.proposals.map((p) => p.kind).join(",");
ok(kinds === "book_time,record_material,add_note,transition_work_order", `vier Aktionskarten (${kinds})`);
ok(multi.last.proposals.every((p) => p.status === "proposed"), "alle Karten offen");
ok(JSON.stringify(await counts()) === JSON.stringify(before), "keine Zeit, kein Material, keine Notiz, kein Statuswechsel vor Bestätigung");
const timeCard = multi.last.proposals[0].payload;
ok(timeCard.durationMinutes === 30 && typeof timeCard.from === "string" && typeof timeCard.to === "string" && timeCard.type === "work", "Zeitkarte: Dauer 30 Min. bis jetzt, von–bis gesetzt");
const materialCard = multi.last.proposals[1].payload;
ok(materialCard.usageStatus === "fully_used" && materialCard.unit === "Stk" && materialCard.materialPlanId === f.plan.id, "Material der geplanten Position zugeordnet");
const proposalRows = await prisma.lotseActionProposal.findMany({ where: { id: { in: multi.last.proposals.map((p) => p.id) } } });
ok(proposalRows.every((p) => p.payloadHash.length === 64 && p.expiresAt.getTime() - p.createdAt.getTime() >= 29 * 60_000), "Hash + Ablauf nach 30 Minuten gespeichert");
const noReason = await send(f.ctx.tech2, "30 Min. auf Müller", [{ toolCalls: [{ name: "book_time", input: { order: "A-LC-2", duration_minutes: 30 } }] }, { text: "Grund?" }]);
ok(noReason.last.proposals.length === 0 && lastToolResult(noReason.provider).includes("missing_values") && lastToolResult(noReason.provider).includes("reason"), "Zeit ohne Grund → Rückfrage statt Karte");
const noDuration = await send(f.ctx.tech2, "Zeit buchen", [{ toolCalls: [{ name: "book_time", input: { order: "A-LC-2", reason: "Uhr vergessen" } }] }, { text: "Wie lange?" }]);
ok(noDuration.last.proposals.length === 0 && lastToolResult(noDuration.provider).includes("duration_minutes"), "Zeit ohne Dauer → Rückfrage");
const extra = await send(f.ctx.tech2, "2 Dichtungen", [{ toolCalls: [{ name: "record_material", input: { order: "A-LC-1", name: "Dichtung", quantity: 2, unit: "Stk" } }] }, { text: "Grund?" }]);
ok(extra.last.proposals.length === 0 && lastToolResult(extra.provider).includes("reason"), "Zusatzmaterial ohne Grund → Rückfrage");
const partial = await send(f.ctx.tech2, "halber Filter", [{ toolCalls: [{ name: "record_material", input: { order: "A-LC-1", name: "Filter", quantity: 0.5 } }] }, { text: "Grund?" }]);
ok(partial.last.proposals.length === 0 && lastToolResult(partial.provider).includes("missing_values"), "Mindermenge ohne Grund → Rückfrage");
const overlap = await send(f.ctx.tech, "30 Min. gearbeitet", [{ toolCalls: [{ name: "book_time", input: { order: "A-LC-1", duration_minutes: 30, reason: "Uhr vergessen" } }] }, { text: "Läuft schon." }]);
ok(overlap.last.proposals.length === 0 && lastToolResult(overlap.provider).includes("overlap"), "Überschneidung mit laufender Uhr → keine Karte");
const blocked = await send(f.ctx.tech, "Pumpe fertig", [{ toolCalls: [{ name: "transition_work_order", input: { order: "A-LC-3", action: "complete" } }] }, { text: "Pflichtfoto fehlt." }]);
ok(blocked.last.proposals.length === 0 && lastToolResult(blocked.provider).includes("blocked"), "Abschluss mit fehlendem Pflichtfoto → keine Karte");
ok((blocked.last.content.links ?? []).some((l) => l.href === `/m/orders/${f.orders.wo3.id}/photos`), "Sprungziel zum Pflichtfoto");
const noReport = await send(f.ctx.tech, "Bericht", [{ toolCalls: [{ name: "suggest_report_fields", input: { order: "A-LC-4", fields: { workPerformed: "Regler eingestellt" } } }] }, { text: "Kein Bericht." }]);
ok(noReport.last.proposals.length === 0 && lastToolResult(noReport.provider).includes("no_open_report"), "Berichtsfelder ohne offenen Bericht → keine Karte");
console.log("\n— Datenminimierung —");
const privacy = await send(f.ctx.tech, "Kundin ruft unter 0171 9876543 an, Mail erika@kunde.test. Telefonnummer vom Ansprechpartner?", [
{ toolCalls: [{ name: "get_order_details", input: { order: "A-LC-1" } }, { name: "list_my_orders", input: { days: 1 } }] },
{ text: "Telefon: {{phone:A-LC-1}}" },
]);
const sent = JSON.stringify(privacy.provider.calls);
for (const secret of ["9876543", "erika@kunde.test", "Mustermann", "Lindenallee", "22111", "555666", "klaus@kunde.test", "Klaus", "1112223", "Müller Haustechnik", "Max Monteur", "1234567", "buero@lotsechat-a.test"]) {
ok(!sent.includes(secret), `Modell-Input ohne „${secret}“`);
}
ok(sent.includes("[Telefon]") && sent.includes("[E-Mail]"), "Nutzertext: Telefon/E-Mail durch Platzhalter ersetzt");
ok(sent.includes("{{phone:A-LC-1}}") && sent.includes("{{customer:A-LC-1}}"), "Kontaktdaten nur als Platzhalter-Token");
ok(sent.includes("4711") && sent.includes("Speicher tauschen"), "Fachliche Hinweise (Zugangscode, Titel) bleiben erhalten");
ok(privacy.last.text === "Telefon: 040 555666", `Platzhalter serverseitig eingesetzt (${privacy.last.text})`);
const gen = await prisma.aiGeneration.findFirst({ where: { tenantId: f.tA.id, kind: "lotse_chat" }, orderBy: { createdAt: "desc" } });
ok(Boolean(gen) && !JSON.stringify(gen?.input).includes("555666") && JSON.stringify(gen?.output).includes("{{phone:A-LC-1}}"), "AiGeneration: Input minimiert, Output mit Token");
ok(gen?.entityType === "lotse_conversation" && gen?.createdById === f.users.tech.id && gen?.inputTokens === 1000, "AiGeneration: Gespräch, Nutzer, Tokens aller Runden");
ok((await renderPlaceholders(f.ctx.outsider, "{{phone:A-LC-1}}")) === "—", "Platzhalter für nicht sichtbaren Auftrag → „—“");
const userRow = await prisma.lotseMessage.findFirst({ where: { conversationId: privacy.view.conversationId!, role: "user" }, orderBy: { createdAt: "desc" } });
ok(JSON.stringify(userRow?.content).includes("[Telefon]"), "gesendete (minimierte) Fassung am Verlauf gespeichert");
console.log("\n— Rollen/Scope und Mandantentrennung —");
await expectErr(() => getChatView(f.ctx.outsider, { workOrderId: wo1.id }), "not_found", "Monteur ohne Zuweisung: Chat mit Auftragskontext → not_found");
const outsiderTurn = await send(f.ctx.outsider, "Notiz A-LC-1", noteScript({ order: "A-LC-1" }));
ok(outsiderTurn.last.proposals.length === 0 && lastToolResult(outsiderTurn.provider).includes("order_not_found"), "Monteur ohne Zuweisung findet den Auftrag nicht");
await expectErr(() => getChatView(f.ctx.admin, { conversationId: generalConversation }), "not_found", "fremder Verlauf im selben Mandanten → not_found");
await expectErr(() => getChatView(f.ctx.techB, { conversationId: generalConversation }), "not_found", "Mandant B liest Verlauf von A nicht");
const bProvider = new FakeLotseChatProvider([{ text: "x" }]);
await expectErr(() => sendLotseMessage(f.ctx.techB, { text: "hallo", conversationId: generalConversation }, { provider: bProvider }), "not_found", "Mandant B schreibt nicht in A-Verlauf");
await expectErr(() => sendLotseMessage(f.ctx.techB, { text: "hallo", workOrderId: wo1.id }, { provider: bProvider }), "not_found", "Mandant B: Auftrag von A als Kontext → not_found");
ok(bProvider.calls.length === 0, "Modell bei Fremdzugriff nie aufgerufen");
ok((await dbForTenant(f.tB.id).lotseMessage.count({ where: { conversationId: generalConversation } })) === 0, "B-Client sieht keine Nachrichten von A");
const bTurn = await send(f.ctx.techB, "Notiz", noteScript({ order: "A-LC-1" }));
ok(bTurn.last.proposals.length === 0 && lastToolResult(bTurn.provider).includes("order_not_found"), "Mandant B findet Aufträge von A nicht");
const withoutField = { ...f.ctx.tech, permissions: new Set([...f.ctx.tech.permissions].filter((p) => p !== "lotse:use")) };
await expectErr(() => getChatView(withoutField), "forbidden", "ohne lotse:use → forbidden");
const fresh = await startNewConversation(f.ctx.tech, { workOrderId: null });
ok(fresh.messages.length === 0 && fresh.conversationId !== generalConversation, "„Neuer Chat“ beginnt leer");
console.log("\n— Einstellung, Modul, Kontingent, Grenzen —");
await updateLotseSettings(f.ctx.admin, { enabled: true, addressForm: "neutral", chatEnabled: false });
const offProvider = new FakeLotseChatProvider([{ text: "x" }]);
await expectErr(() => sendLotseMessage(f.ctx.tech, { text: "hallo" }, { provider: offProvider }), "blocked", "Lotse-Chat ausgeschaltet → blocked", "chat_disabled");
ok(!(await canUseLotseChat(f.ctx.tech)) && offProvider.calls.length === 0, "ausgeschaltet: kein Einstieg, kein Modellaufruf");
const settingsAudit = await prisma.auditLog.findFirst({ where: { tenantId: f.tA.id, entity: "lotse_settings" }, orderBy: { createdAt: "desc" } });
ok((settingsAudit?.after as { chatEnabled?: boolean } | null)?.chatEnabled === false, "Audit der Einstellung enthält chatEnabled");
await updateLotseSettings(f.ctx.admin, { enabled: false, addressForm: "neutral", chatEnabled: true });
await expectErr(() => sendLotseMessage(f.ctx.tech, { text: "hallo" }, { provider: offProvider }), "blocked", "Lotse-Modul aus → blocked", "disabled");
await updateLotseSettings(f.ctx.admin, { enabled: true, addressForm: "du", chatEnabled: true });
ok(await canUseLotseChat(f.ctx.tech), "wieder eingeschaltet");
await expectErr(() => sendLotseMessage(f.ctx.tech, { text: "hallo" }, { provider: null }), "invalid", "ohne Anbieter → Lotse ist nicht eingerichtet", "not_configured");
await updateLotseSettings(f.ctx.admin, { enabled: true, addressForm: "du", monthlyTokenLimit: 100 });
await expectErr(() => sendLotseMessage(f.ctx.tech, { text: "hallo" }, { provider: offProvider }), "blocked", "Monatskontingent aufgebraucht → blocked", "budget_exceeded");
await updateLotseSettings(f.ctx.admin, { enabled: true, addressForm: "du", monthlyTokenLimit: null });
const loop: FakeChatStep[] = Array.from({ length: 10 }, () => ({ toolCalls: [{ name: "get_running_clock", input: {} }] }));
const rounds = await send(f.ctx.tech, "Uhr?", loop, { maxRounds: 3 });
ok(rounds.provider.calls.length === 3 && rounds.last.content.noticeKey === "rounds_exceeded", "Rundengrenze: nach 3 Schritten Schluss mit Hinweis");
const heavy: FakeChatStep[] = Array.from({ length: 10 }, () => ({ toolCalls: [{ name: "get_running_clock", input: {} }], tokens: { input: 9000, output: 1000 } }));
const tokens = await send(f.ctx.tech, "Uhr?", heavy, { turnTokenLimit: 15_000 });
ok(tokens.provider.calls.length === 2 && tokens.last.content.noticeKey === "turn_budget", "Token-Grenze je Nachricht greift");
ok(tokens.provider.calls[1].system.includes("„du“"), "Anrede aus der Lotse-Einstellung");
const failed = await send(f.ctx.tech, "hallo", [], { fail: new Error("503") });
ok(failed.last.role === "assistant" && failed.last.content.noticeKey === "provider_failed", "Anbieterfehler → Hinweis statt Absturz, Eingabe gespeichert");
const protocol = await listAiGenerations(f.ctx.admin);
ok(protocol.items.some((i) => i.kind === "lotse_chat"), "KI-Protokoll zeigt Chat-Generierungen");
const audit = await prisma.auditLog.count({ where: { tenantId: f.tA.id, entity: "lotse_chat_message" } });
ok(audit > 10, "Audit je Chat-Antwort");
console.log("\n— Aufbewahrung —");
const future = new Date(Date.now() + 400 * 86_400_000);
const purge = await purgeExpiredAiGenerations({ now: future, tenantIds: [f.tA.id] });
ok(purge.chatConversations > 0 && (await prisma.lotseConversation.count({ where: { tenantId: f.tA.id } })) === 0, `Chatverläufe nach Frist gelöscht (${purge.chatConversations})`);
ok((await prisma.lotseMessage.count({ where: { tenantId: f.tA.id } })) === 0 && (await prisma.lotseActionProposal.count({ where: { tenantId: f.tA.id } })) === 0, "Nachrichten und Karten mitgelöscht");
ok((await prisma.lotseConversation.count({ where: { tenantId: f.tB.id } })) === 1, "Mandant B unberührt");
await cleanupChatTenants(PREFIX);
console.log(failures ? `\n${failures} Fehler` : "\nAlle Prüfungen grün.");
await prisma.$disconnect();
process.exit(failures ? 1 : 0);
}
main().catch(async (err) => {
console.error(err);
await cleanupChatTenants(PREFIX).catch(() => undefined);
await prisma.$disconnect();
process.exit(1);
});
+42
View File
@@ -0,0 +1,42 @@
// L16 Lotse-Chat für Monteure – optionaler Live-Test gegen Claude.
//
// Läuft nur mit gesetztem ANTHROPIC_API_KEY (sonst Skip, Exit 0). Prüft, dass eine echte Tool-Use-Schleife eine
// Antwort liefert, als AiGeneration protokolliert wird und dabei nichts an Fachdaten ändert (nur Vorschläge).
//
// Lauf: npx tsx scripts/test-lotse-chat-live.ts
import "dotenv/config";
import { prisma } from "../src/server/db";
import { getLotseChatProvider } from "../src/server/ai/lotse/chat-anthropic";
import { sendLotseMessage } from "../src/server/services/lotse/chat/engine";
import { cleanupChatTenants, createChatFixture, failures, ok } from "./lib/lotse-chat-fixture";
const PREFIX = "lotsechat-live";
async function main() {
if (!process.env.ANTHROPIC_API_KEY?.trim()) {
console.log("übersprungen: ANTHROPIC_API_KEY nicht gesetzt");
await prisma.$disconnect();
return;
}
const f = await createChatFixture(PREFIX);
const provider = getLotseChatProvider();
ok(Boolean(provider), "Anbieter eingerichtet");
const notesBefore = await prisma.activityNote.count({ where: { tenantId: f.tA.id } });
const view = await sendLotseMessage(f.ctx.tech2, { text: "Was steht heute an? Und bei A-LC-1 habe ich den Filter getauscht." }, { provider });
const last = view.messages[view.messages.length - 1];
ok(last.role === "assistant" && (last.text.length > 0 || Boolean(last.content.noticeKey)), `Antwort erhalten: ${last.text.slice(0, 120)}`);
ok(!/\{\{(customer|contact|phone|email|address|site):/.test(last.text), "keine unaufgelösten Platzhalter in der Antwort");
ok((await prisma.aiGeneration.count({ where: { tenantId: f.tA.id, kind: "lotse_chat" } })) === 1, "AiGeneration protokolliert");
ok((await prisma.activityNote.count({ where: { tenantId: f.tA.id } })) === notesBefore, "keine Notiz ohne Bestätigung");
await cleanupChatTenants(PREFIX);
await prisma.$disconnect();
process.exit(failures ? 1 : 0);
}
main().catch(async (err) => {
console.error(err);
await cleanupChatTenants(PREFIX).catch(() => undefined);
await prisma.$disconnect();
process.exit(1);
});
+12 -1
View File
@@ -43,7 +43,7 @@ const at = (ms: number) => new Date(T0.getTime() + ms);
const fixedRandom = () => 1; // backoff = full step const fixedRandom = () => 1; // backoff = full step
/** Fake server: records every batch, answers per op via `decide`. */ /** Fake server: records every batch, answers per op via `decide`. */
function fakeTransport(decide: (op: SyncOperationInput) => SyncOpResult, opts: { failBatches?: number; uploadError?: "network" | "invalid" } = {}) { function fakeTransport(decide: (op: SyncOperationInput) => SyncOpResult, opts: { failBatches?: number; uploadError?: "network" | "invalid" | "suspended" } = {}) {
const batches: SyncOperationInput[][] = []; const batches: SyncOperationInput[][] = [];
const uploads: string[] = []; const uploads: string[] = [];
const log: string[] = []; const log: string[] = [];
@@ -230,6 +230,17 @@ async function main() {
const p = await runSyncPass({ store: store3, ctx: A, transport: offline.transport, deviceId: "d", now: () => at(10), random: fixedRandom }); const p = await runSyncPass({ store: store3, ctx: A, transport: offline.transport, deviceId: "d", now: () => at(10), random: fixedRandom });
const blob3 = await store3.getBlob(c3); const blob3 = await store3.getBlob(c3);
ok(p.stopped === "network" && blob3?.status === "failed" && blob3.nextAttemptAt === at(2010).toISOString() && offline.batches.length === 0, "Upload ohne Netz → Backoff, Op wartet"); ok(p.stopped === "network" && blob3?.status === "failed" && blob3.nextAttemptAt === at(2010).toISOString() && offline.batches.length === 0, "Upload ohne Netz → Backoff, Op wartet");
// L15: expired trial (read-only tenant) must not discard photos
const store5 = createMemoryStore();
const c5 = randomUUID();
const op5 = await enqueueOp(store5, A, { opType: "photo.attach", payload: { workOrderId: "wo-1", documentId: await enqueueBlob(store5, A, { clientId: c5, workOrderId: "wo-1", kind: "photo", blob: new Blob(["x"]), fileName: "f" }, at(0)) } }, randomUUID(), at(1));
const suspended = fakeTransport((o) => applied(o), { uploadError: "suspended" });
const p5 = await runSyncPass({ store: store5, ctx: A, transport: suspended.transport, deviceId: "d", now: () => at(10), random: fixedRandom });
const blob5 = await store5.getBlob(c5);
const op5After = (await store5.listOps(ctxKeyOf(A))).find((o) => o.clientOpId === op5.clientOpId);
ok(p5.stopped === "suspended" && p5.rejected === 0 && suspended.batches.length === 0, "Testphase abgelaufen → Pass stoppt, nichts verworfen");
ok(!!blob5 && blob5.nextAttemptAt !== null && blob5.lastError?.message === "trial_expired" && op5After?.status === "queued", "Foto bleibt auf dem Gerät, Op wartet (Backoff)");
} }
console.log("\n— idMap / verkettete baseVersion —"); console.log("\n— idMap / verkettete baseVersion —");
+213
View File
@@ -0,0 +1,213 @@
// L17 Pakete — Durchsetzung der Paketstufe an allen Mechanismen: Action (assertModuleEnabled im
// moduleGuard), API (requireApiContext/toErrorResponse), Layout-Guard-Funktionen (moduleState,
// requirePlanFeature), Sync (Offline-Op → rejected mit Klartext), Planung (planningAccess & Co.),
// isLotseEnabled, Navigation; TenantModule aus + Profi; Wechsel Profi → Basis → Profi ohne Datenverlust;
// nur der Betreiber ändert die Stufe.
// Lauf: npx tsx scripts/test-pakete-gates.ts (auch mit RLS_ENFORCED=true)
import "dotenv/config";
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { Session } from "next-auth";
import { prisma, dbForTenant } from "../src/server/db";
import { assertModuleEnabled, ModuleDisabledError } from "../src/server/modules";
import { effectiveModules, isModuleActive, isPlanFeatureAvailable, moduleState, PROFI_HINT_PATH, requirePlanFeature } from "../src/server/plan";
import { toErrorResponse } from "../src/server/api/respond";
import { applyOperations } from "../src/server/services/sync/apply";
import { getPlanningBoard } from "../src/server/services/planning/board";
import { getLiveSituation } from "../src/server/services/planning/live";
import { scheduleWorkOrder } from "../src/server/services/planning/schedule";
import { recommendAssignments } from "../src/server/services/planning/recommend";
import { updateTeamPlanningSettings } from "../src/server/services/planning/team-settings";
import { getPlanningToday } from "../src/server/services/planning/summary";
import { runPlanningWatch } from "../src/server/services/planning/watch";
import { isLotseEnabled, updateLotseSettings } from "../src/server/services/lotse/settings";
import { billingModuleActive } from "../src/server/services/work-orders/transition";
import { updateTenantPlan } from "../src/server/services/plans/platform";
import { problemKey } from "../src/lib/offline/outbox-core";
import type { SyncOperationInput } from "../src/lib/sync/envelope";
import { codeOf, createTenant, expectCode, ok, runSuite, section } from "./lib/e2e-fixture";
const SLUG_A = "zz-pak-gates-a";
const SLUG_B = "zz-pak-gates-b";
const PLATFORM_EMAIL = "platform-pakete@zz-pakete.test";
const READONLY_EMAIL = "readonly-pakete@zz-pakete.test";
const fakeSession = (tenantId: string, userId: string) => ({ user: { tenantId, id: userId } }) as unknown as Session;
const src = (p: string) => readFileSync(join(process.cwd(), p), "utf8");
function emergencyOp(): SyncOperationInput {
return {
clientOpId: randomUUID(),
opType: "emergency.create",
payload: {
clientIds: { workOrder: randomUUID(), session: randomUUID(), customer: randomUUID(), site: randomUUID() },
customer: { mode: "new", firstName: "Lutz", lastName: "Meier", phone: "0171 1234567", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" },
site: { mode: "new", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" },
onSiteContact: { name: "Lutz Meier", phone: "0171 1234567" },
reason: "Wasserrohrbruch im Keller",
},
clientCreatedAt: new Date().toISOString(),
};
}
async function extraCleanup(tenantIds: string[]) {
await prisma.lotseChatSeat.deleteMany({ where: { tenantId: { in: tenantIds } } });
await prisma.workOrderMilestone.deleteMany({ where: { tenantId: { in: tenantIds } } });
await prisma.platformAdmin.deleteMany({ where: { email: { in: [PLATFORM_EMAIL, READONLY_EMAIL] } } });
}
runSuite("L17 Pakete – Durchsetzung", [SLUG_A, SLUG_B], async () => {
await extraCleanup([]);
const A = await createTenant(SLUG_A);
const B = await createTenant(SLUG_B);
try {
await prisma.tenant.update({ where: { id: A.tenantId }, data: { tier: "BASIS" } });
const platform = await prisma.platformAdmin.create({ data: { email: PLATFORM_EMAIL, name: "ZZ Plattform", passwordHash: "x", role: "full" } });
const readonly = await prisma.platformAdmin.create({ data: { email: READONLY_EMAIL, name: "ZZ Nur-Lesen", passwordHash: "x", role: "readonly" } });
section("Stufe am Mandanten");
ok((await prisma.tenant.findUniqueOrThrow({ where: { id: B.tenantId } })).tier === "PROFI", "neuer Mandant: Default Profi");
section("Layout-Guard-Funktionen (moduleState / requirePlanFeature)");
for (const key of ["billing", "emergency", "imports", "lotse"]) ok((await moduleState(A.tenantId, key)) === "not_in_plan", `Basis: ${key} → not_in_plan`);
for (const key of ["customers", "sites", "teams", "work_orders", "field", "reports", "documents", "notifications"]) ok((await moduleState(A.tenantId, key)) === "enabled", `Basis: ${key} aktiv`);
for (const key of ["billing", "emergency", "imports", "lotse"]) ok((await moduleState(B.tenantId, key)) === "enabled", `Profi: ${key} aktiv`);
let redirected = "";
try {
await requirePlanFeature(A.tenantId, "planning");
} catch (err) {
redirected = String((err as { digest?: string }).digest ?? (err as Error).message);
}
ok(redirected.includes(PROFI_HINT_PATH), `Planungs-Layout (Basis) → Hinweis ${PROFI_HINT_PATH}`);
ok((await codeOf(requirePlanFeature(B.tenantId, "planning"))) === "ok", "Planungs-Layout (Profi) → kein Redirect");
const modulesSrc = src("src/server/modules.ts");
ok(/export async function requireModule[\s\S]*moduleState\(/.test(modulesSrc) && modulesSrc.includes("PROFI_HINT_PATH"), "requireModule nutzt moduleState (Stufe + Schalter) und leitet auf den Profi-Hinweis");
ok(src("src/app/(app)/planning/layout.tsx").includes('requirePlanFeature(session.user.tenantId, "planning")'), "Planungs-Layout prüft das Paket-Feature");
section("Action (moduleGuard → assertModuleEnabled)");
let actionErr: unknown = null;
try {
await assertModuleEnabled(fakeSession(A.tenantId, A.users.admin.id), "billing");
} catch (err) {
actionErr = err;
}
ok(actionErr instanceof ModuleDisabledError && actionErr.reason === "not_in_plan", "Basis: Abrechnungs-Action → ModuleDisabledError(not_in_plan)");
const denied = await prisma.auditLog.findFirst({ where: { tenantId: A.tenantId, action: "denied", entity: "module", entityId: "billing" } });
ok(!!denied, "verweigerter Zugriff wird auditiert");
for (const key of ["emergency", "imports", "lotse"]) {
ok((await codeOf(assertModuleEnabled(fakeSession(A.tenantId, A.users.admin.id), key))).startsWith("error:"), `Basis: ${key}-Action gesperrt`);
}
ok((await codeOf(assertModuleEnabled(fakeSession(A.tenantId, A.users.admin.id), "customers"))) === "ok", "Basis: Kunden-Action erlaubt");
ok((await codeOf(assertModuleEnabled(fakeSession(B.tenantId, B.users.admin.id), "billing"))) === "ok", "Profi: Abrechnungs-Action erlaubt");
ok(/await assertModuleEnabled\(session, moduleKey\)/.test(src("src/server/action-guard.ts")), "moduleGuard prüft über assertModuleEnabled");
section("API (requireApiContext → 403 mit Klartext)");
ok(/if \(moduleKey\) await assertModuleEnabled\(session, moduleKey\)/.test(src("src/server/api/context.ts")), "requireApiContext prüft über assertModuleEnabled");
const res = toErrorResponse(actionErr);
const body = (await res.json()) as { error: { code: string; details?: { reason?: string; message?: string } } };
ok(res.status === 403 && body.error.details?.reason === "not_in_plan" && /Paket Profi/.test(body.error.details?.message ?? ""), "API: 403 mit reason not_in_plan und Klartext");
const planningErr = await getPlanningBoard(A.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }).catch((e) => e);
const planningRes = toErrorResponse(planningErr);
ok(planningRes.status === 403, "Planungs-API (Basis) → 403");
section("Planung (Feature ohne eigenes Modul)");
ok(!(await isPlanFeatureAvailable(A.tenantId, "planning")) && (await isPlanFeatureAvailable(B.tenantId, "planning")), "Feature planning: Basis nein, Profi ja");
await expectCode(() => getPlanningBoard(A.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }), "forbidden", "Basis: Plantafel forbidden");
await expectCode(() => getLiveSituation(A.ctx.backoffice), "forbidden", "Basis: Live-Lage forbidden");
const woA = await prisma.workOrder.create({ data: { tenantId: A.tenantId, number: "ZZ-PAK-1", customerId: A.customerId, siteId: A.siteId, title: "Paket-Test", status: "draft" } });
await expectCode(() => scheduleWorkOrder(A.ctx.backoffice, { workOrderId: woA.id, teamId: A.teamId, plannedStart: new Date().toISOString(), baseVersion: woA.version } as never), "forbidden", "Basis: Einplanen forbidden");
await expectCode(() => recommendAssignments(A.ctx.backoffice, { workOrderId: woA.id }), "forbidden", "Basis: Empfehlungen forbidden");
await expectCode(() => updateTeamPlanningSettings(A.ctx.admin, A.teamId, { dailyCapacityMinutes: 400, workingDays: 31 }), "forbidden", "Basis: Kolonnenkapazität forbidden");
ok((await getPlanningToday(A.ctx.backoffice)) === null, "Basis: Dashboard-Kachel Planung ausgeblendet");
const watch = await runPlanningWatch(A.tenantId);
ok(watch.overrun === 0 && watch.followupAtRisk === 0 && watch.capacityFreed === 0, "Basis: keine Verzugs-/Frühfertig-Meldungen");
ok((await codeOf(getPlanningBoard(B.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }))) === "ok", "Profi: Plantafel erlaubt");
ok((await codeOf(updateTeamPlanningSettings(B.ctx.admin, B.teamId, { dailyCapacityMinutes: 420, workingDays: 31 }))) === "ok", "Profi: Kolonnenkapazität erlaubt");
section("Lotse, Abrechnung, Navigation");
ok(!(await isLotseEnabled(A.ctx.admin)) && (await isLotseEnabled(B.ctx.admin)), "isLotseEnabled: Basis aus, Profi an");
ok(!(await billingModuleActive(A.ctx.admin)) && (await billingModuleActive(B.ctx.admin)), "Abrechnungsübersicht: Basis aus (direktes „abgerechnet“ wie bei deaktiviertem Modul)");
const effA = await effectiveModules(A.tenantId);
ok(["billing", "emergency", "imports", "lotse"].every((k) => effA.inactive.has(k) && effA.notInPlan.has(k)) && effA.lockedFeatures.has("planning"), "Navigation Basis: gesperrte Module + Planung");
const effB = await effectiveModules(B.tenantId);
ok(effB.inactive.size === 0 && effB.lockedFeatures.size === 0, "Navigation Profi: alles sichtbar");
section("Sync (Offline-Op eines gesperrten Moduls → rejected mit Klartext)");
const opA = emergencyOp();
const syncA = await applyOperations(A.ctx.tech, { deviceId: "zz-dev", operations: [opA] });
const rA = syncA.results[0];
ok(rA.status === "rejected" && rA.errorCode === "forbidden" && (rA.message ?? "").startsWith("not_in_plan") && /Paket Profi/.test(rA.message ?? ""), `Basis: emergency.create → rejected mit Klartext (${rA.message?.slice(0, 60)}…)`);
const storedA = await prisma.syncOperation.findFirst({ where: { tenantId: A.tenantId, clientOpId: opA.clientOpId } });
ok(storedA?.status === "rejected", "abgewiesene Op gespeichert (nicht still verworfen)");
ok((await prisma.workOrder.count({ where: { tenantId: A.tenantId, isEmergency: true } })) === 0, "kein Notdienstauftrag angelegt");
const again = await applyOperations(A.ctx.tech, { deviceId: "zz-dev", operations: [opA] });
ok(again.results[0].status === "duplicate", "Wiederholung derselben Op → duplicate (Ergebnis bleibt)");
ok(problemKey({ status: "rejected", opType: "emergency.create", lastError: { code: "forbidden", message: rA.message } }) === "notInPlan", "Gerät zeigt Klartext „Im Paket Profi enthalten“ (problem.notInPlan)");
const msA = await prisma.workOrderMilestone.create({ data: { tenantId: A.tenantId, workOrderId: woA.id, title: "ZZ" } });
const mOp: SyncOperationInput = { clientOpId: randomUUID(), opType: "milestone.reach", payload: { workOrderId: woA.id, milestoneId: msA.id }, clientCreatedAt: new Date().toISOString() };
const mRes = (await applyOperations(A.ctx.tech, { deviceId: "zz-dev", operations: [mOp] })).results[0];
ok(mRes.status === "rejected" && (mRes.message ?? "").startsWith("not_in_plan"), "Basis: milestone.reach → rejected");
const opB = emergencyOp();
const rB = (await applyOperations(B.ctx.tech, { deviceId: "zz-dev", operations: [opB] })).results[0];
ok(rB.status === "applied", `Profi: emergency.create angewendet (${rB.status}${rB.message ? `: ${rB.message}` : ""})`);
section("TenantModule aus + Profi → gesperrt");
await prisma.tenantModule.upsert({ where: { tenantId_moduleKey: { tenantId: B.tenantId, moduleKey: "emergency" } }, update: { enabled: false }, create: { tenantId: B.tenantId, moduleKey: "emergency", enabled: false } });
ok((await moduleState(B.tenantId, "emergency")) === "disabled", "Profi + Schalter aus → disabled");
let offErr: unknown = null;
try {
await assertModuleEnabled(fakeSession(B.tenantId, B.users.admin.id), "emergency");
} catch (err) {
offErr = err;
}
ok(offErr instanceof ModuleDisabledError && offErr.reason === "disabled", "Action → ModuleDisabledError(disabled)");
const rOff = (await applyOperations(B.ctx.tech, { deviceId: "zz-dev", operations: [emergencyOp()] })).results[0];
ok(rOff.status === "rejected" && (rOff.message ?? "").startsWith("module_disabled"), "Sync → rejected (module_disabled)");
ok(problemKey({ status: "rejected", opType: "emergency.create", lastError: { code: "forbidden", message: rOff.message } }) === "moduleDisabled", "Gerät: problem.moduleDisabled");
await prisma.tenantModule.update({ where: { tenantId_moduleKey: { tenantId: B.tenantId, moduleKey: "emergency" } }, data: { enabled: true } });
ok(await isModuleActive(B.tenantId, "emergency"), "wieder eingeschaltet → aktiv");
section("Nur der Betreiber ändert die Stufe");
await expectCode(() => updateTenantPlan({ platformAdminId: B.users.admin.id }, B.tenantId, { tier: "BASIS", lotseChatSeats: 5, lotseChatHardLimit: false }), "forbidden", "Mandanten-Admin (kein Plattform-Konto) → forbidden");
await expectCode(() => updateTenantPlan({ platformAdminId: readonly.id }, B.tenantId, { tier: "BASIS", lotseChatSeats: 5, lotseChatHardLimit: false }), "forbidden", "Nur-Lesen-Plattform-Admin → forbidden");
await expectCode(() => updateTenantPlan({ platformAdminId: platform.id }, B.tenantId, { tier: "GOLD" as never, lotseChatSeats: 5, lotseChatHardLimit: false }), "invalid", "ungültige Stufe → invalid");
await expectCode(() => updateTenantPlan({ platformAdminId: platform.id }, B.tenantId, { tier: "PROFI", lotseChatSeats: -1, lotseChatHardLimit: false }), "invalid", "negative Platzanzahl → invalid");
await updateLotseSettings(B.ctx.admin, { enabled: true, addressForm: "neutral" });
const bAfterSettings = await prisma.tenant.findUniqueOrThrow({ where: { id: B.tenantId } });
ok(bAfterSettings.tier === "PROFI" && bAfterSettings.lotseChatSeats === 0, "Lotse-Einstellungen des Mandanten ändern weder Stufe noch Plätze");
ok(!/\btier:|lotseChatSeats|lotseChatHardLimit/.test(src("src/server/services/lotse/settings.ts") + src("src/server/actions/tenant-settings.ts") + src("src/server/actions/lotse-settings.ts")), "keine Mandanten-Action schreibt Stufe/Plätze");
section("Wechsel Profi → Basis → Profi ohne Datenverlust");
await prisma.lotseChatSeat.create({ data: { tenantId: B.tenantId, userId: B.users.tech.id } });
const conv = await prisma.lotseConversation.create({ data: { tenantId: B.tenantId, userId: B.users.tech.id } });
await prisma.lotseMessage.create({ data: { tenantId: B.tenantId, conversationId: conv.id, role: "user", text: "Hallo" } });
const snapshot = async () => ({
emergencies: await prisma.workOrder.count({ where: { tenantId: B.tenantId, isEmergency: true } }),
seats: await prisma.lotseChatSeat.count({ where: { tenantId: B.tenantId } }),
messages: await prisma.lotseMessage.count({ where: { tenantId: B.tenantId } }),
teams: await prisma.team.findMany({ where: { tenantId: B.tenantId }, select: { id: true, dailyCapacityMinutes: true }, orderBy: { id: "asc" } }),
});
const before = await snapshot();
const down = await updateTenantPlan({ platformAdminId: platform.id }, B.tenantId, { tier: "BASIS", lotseChatSeats: 3, lotseChatHardLimit: true });
ok(down.tier === "BASIS" && down.lotseChatSeats === 3 && down.lotseChatHardLimit, "Betreiber setzt Basis, 3 Plätze, hartes Limit");
const audit = await prisma.auditLog.findFirst({ where: { tenantId: B.tenantId, scope: "platform", entity: "tenant_plan" }, orderBy: { createdAt: "desc" } });
ok(!!audit && (audit.before as { tier?: string })?.tier === "PROFI" && (audit.after as { tier?: string })?.tier === "BASIS" && audit.actorId === platform.id, "Plattform-Audit mit before/after");
ok((await moduleState(B.tenantId, "emergency")) === "not_in_plan" && !(await isPlanFeatureAvailable(B.tenantId, "planning")), "Basis: Zugriff endet");
await expectCode(() => getPlanningBoard(B.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }), "forbidden", "Basis: Plantafel gesperrt");
ok(JSON.stringify(await snapshot()) === JSON.stringify(before), "Basis: Notdiensteinsätze, Plätze, Chats, Kolonnen unverändert");
await updateTenantPlan({ platformAdminId: platform.id }, B.tenantId, { tier: "PROFI", lotseChatSeats: 3, lotseChatHardLimit: false });
ok((await moduleState(B.tenantId, "emergency")) === "enabled" && (await isPlanFeatureAvailable(B.tenantId, "planning")), "zurück auf Profi: Zugriff wieder da");
ok(JSON.stringify(await snapshot()) === JSON.stringify(before), "zurück auf Profi: alle Daten vorhanden");
ok((await codeOf(getPlanningBoard(B.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }))) === "ok", "Plantafel wieder erlaubt");
section("Mandantentrennung der Stufe");
ok((await prisma.tenant.findUniqueOrThrow({ where: { id: A.tenantId } })).tier === "BASIS", "Änderung an B lässt A unberührt");
ok((await dbForTenant(B.tenantId).syncOperation.findMany({ where: { clientOpId: opA.clientOpId } })).length === 0, "B sieht die abgewiesene Op von A nicht");
} finally {
await extraCleanup([A.tenantId, B.tenantId]);
await prisma.lotseMessage.deleteMany({ where: { tenantId: { in: [A.tenantId, B.tenantId] } } });
await prisma.lotseConversation.deleteMany({ where: { tenantId: { in: [A.tenantId, B.tenantId] } } });
}
});
+79
View File
@@ -0,0 +1,79 @@
// L17 Pakete — reine Stufenregeln (src/lib/plans.ts), ohne Datenbank.
// Lauf: npx tsx scripts/test-pakete-rules.ts
import {
chatOverage,
chatQuota,
DEFAULT_TENANT_TIER,
effectiveTier,
isFeatureInTier,
isModuleInTier,
isQuotaBlocked,
isTenantTier,
LOTSE_CHAT_CHATS_PER_SEAT,
LOTSE_CHAT_OVERAGE_PACK,
modulesLockedByTier,
monthKeyOfDay,
nextMonthKey,
overagePacks,
previousMonthKey,
PROFI_ONLY_MODULES,
usageLevel,
usagePercent,
} from "../src/lib/plans";
import { MODULE_KEYS } from "../src/lib/modules";
import { NAV_ITEMS, visibleNavItems } from "../src/lib/nav";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
console.log("— Stufen —");
ok(DEFAULT_TENANT_TIER === "PROFI", "Default-Stufe ist Profi");
ok(isTenantTier("BASIS") && isTenantTier("PROFI") && !isTenantTier("GOLD") && !isTenantTier(null), "isTenantTier erkennt nur BASIS/PROFI");
ok(effectiveTier("BASIS", "TRIAL") === "PROFI", "Testphase läuft immer als Profi");
ok(effectiveTier("BASIS", "FULL") === "BASIS" && effectiveTier("PROFI", "FULL") === "PROFI", "Vollversion: gespeicherte Stufe gilt");
ok(effectiveTier(undefined, "FULL") === "PROFI", "unbekannte Stufe → Default Profi");
console.log("\n— Module je Stufe —");
const profiOnly = ["billing", "emergency", "imports", "lotse"];
ok(JSON.stringify([...PROFI_ONLY_MODULES].sort()) === JSON.stringify([...profiOnly].sort()), "Nur-Profi-Module: billing, emergency, imports, lotse");
for (const key of MODULE_KEYS) {
ok(isModuleInTier("PROFI", key), `Profi enthält ${key}`);
ok(isModuleInTier("BASIS", key) === !profiOnly.includes(key), `Basis ${profiOnly.includes(key) ? "sperrt" : "enthält"} ${key}`);
}
ok(modulesLockedByTier("PROFI").length === 0 && modulesLockedByTier("BASIS").length === 4, "modulesLockedByTier");
ok(!isFeatureInTier("BASIS", "planning") && !isFeatureInTier("BASIS", "lotse_chat"), "Basis: keine Planung, kein Lotse-Chat");
ok(isFeatureInTier("PROFI", "planning") && isFeatureInTier("PROFI", "lotse_chat"), "Profi: Planung und Lotse-Chat");
console.log("\n— Kontingent —");
ok(LOTSE_CHAT_CHATS_PER_SEAT === 150 && LOTSE_CHAT_OVERAGE_PACK === 100, "150 Chats je Platz, Mehrverbrauch in Paketen à 100");
ok(chatQuota(3) === 450 && chatQuota(0) === 0 && chatQuota(-2) === 0, "Kontingent = Plätze × 150");
ok(chatOverage(212, 450) === 0 && chatOverage(460, 450) === 10, "Mehrverbrauch = über dem Kontingent");
ok(overagePacks(0) === 0 && overagePacks(1) === 1 && overagePacks(100) === 1 && overagePacks(101) === 2 && overagePacks(250) === 3, "angefangene 100er-Pakete zählen");
ok(usageLevel(212, 450) === "ok", "212/450 → ok");
ok(usageLevel(360, 450) === "warn" && usageLevel(359, 450) === "ok", "ab 80 % → warn");
ok(usageLevel(450, 450) === "exhausted" && usageLevel(451, 450) === "over", "ausgeschöpft / Mehrverbrauch");
ok(usageLevel(0, 0) === "ok" && usageLevel(1, 0) === "over", "ohne Kontingent zählt jede Nutzung als Mehrverbrauch");
ok(usagePercent(212, 450) === 47 && usagePercent(900, 450) === 100 && usagePercent(0, 0) === 0, "Balken in Prozent (gedeckelt)");
ok(!isQuotaBlocked(500, 450, false), "ohne hartes Limit wird weitergezählt");
ok(isQuotaBlocked(450, 450, true) && !isQuotaBlocked(449, 450, true), "hartes Limit sperrt ab dem Kontingent");
console.log("\n— Monate —");
ok(monthKeyOfDay("2026-09-21") === "2026-09", "Monat eines Tags");
ok(previousMonthKey("2026-01") === "2025-12" && previousMonthKey("2026-09") === "2026-08", "Vormonat inkl. Jahreswechsel");
ok(nextMonthKey("2026-12") === "2027-01" && nextMonthKey("2026-09") === "2026-10", "Folgemonat inkl. Jahreswechsel");
console.log("\n— Navigation —");
const all = NAV_ITEMS.flatMap((i) => i.permissions ?? []);
const basisNav = visibleNavItems(NAV_ITEMS, { disabledModules: new Set(profiOnly), lockedFeatures: new Set(["planning", "lotse_chat"]), permissions: all });
const profiNav = visibleNavItems(NAV_ITEMS, { disabledModules: new Set(), lockedFeatures: new Set(), permissions: all });
ok(!basisNav.some((i) => i.href.startsWith("/planning")), "Basis: keine Planung in der Navigation");
ok(!basisNav.some((i) => ["/billing", "/imports", "/work-orders/emergency-review"].includes(i.href)), "Basis: keine Abrechnung/Import/Notdienst-Prüfung");
ok(basisNav.some((i) => i.href === "/work-orders") && basisNav.some((i) => i.href === "/work-orders/time-approvals") && basisNav.some((i) => i.href === "/reports"), "Basis: Aufträge, Zeitfreigabe, Berichte bleiben");
ok(profiNav.some((i) => i.href === "/planning") && profiNav.some((i) => i.href === "/billing"), "Profi: Planung und Abrechnung sichtbar");
console.log(failures === 0 ? "\nAlle Prüfungen grün." : `\n${failures} FEHLER.`);
process.exit(failures === 0 ? 0 : 1);
+191
View File
@@ -0,0 +1,191 @@
// L17 Pakete — Lotse-Chat pro Nutzer: Platzvergabe bis zur Grenze (darüber invalid), Chat ohne Platz →
// blocked no_seat, mit Platz → ok, Testphase ohne Platzprüfung, Kontingentzählung über die Monatsgrenze
// (Zeitzone), Mehrverbrauch weiterzählen, hartes Limit → blocked quota_exhausted, Feldrollen vergeben
// keine Plätze, Mandantentrennung (B sieht/ändert keine Plätze/Zählung von A), Betreiber-Übersicht.
// Lauf: npx tsx scripts/test-pakete-seats.ts (auch mit RLS_ENFORCED=true)
import "dotenv/config";
import { prisma, dbForTenant } from "../src/server/db";
import { FakeLotseChatProvider } from "../src/server/ai/lotse/chat-fake";
import { assertLotseChatUsable, canUseLotseChat, isChatQuotaBlocked } from "../src/server/services/lotse/chat/access";
import { getChatView } from "../src/server/services/lotse/chat/conversations";
import { sendLotseMessage } from "../src/server/services/lotse/chat/engine";
import { getSeatOverview, listEligibleUsers, setChatSeat } from "../src/server/services/lotse/chat/seats";
import { getChatUsage, getChatUsageForAdmin, monthBounds, monthKeyOf } from "../src/server/services/lotse/chat/usage";
import { getTenantPlanOverview, updateTenantPlan } from "../src/server/services/plans/platform";
import { ServiceError } from "../src/server/services/context";
import { codeOf, createTenant, expectCode, ok, runSuite, section } from "./lib/e2e-fixture";
const SLUG_A = "zz-pak-seats-a";
const SLUG_B = "zz-pak-seats-b";
const PLATFORM_EMAIL = "platform-seats@zz-pakete.test";
async function reasonOf(p: Promise<unknown>): Promise<string> {
try {
await p;
return "ok";
} catch (err) {
if (err instanceof ServiceError) return `${err.code}/${(err.details as { reason?: string } | undefined)?.reason ?? err.message}`;
return `error:${(err as Error).message}`;
}
}
async function extraCleanup(tenantIds: string[]) {
const w = { where: { tenantId: { in: tenantIds } } };
await prisma.lotseChatSeat.deleteMany(w);
await prisma.lotseActionProposal.deleteMany(w);
await prisma.lotseMessage.deleteMany(w);
await prisma.lotseConversation.deleteMany(w);
await prisma.platformAdmin.deleteMany({ where: { email: PLATFORM_EMAIL } });
}
runSuite("L17 Pakete – Lotse-Chat-Plätze & Kontingent", [SLUG_A, SLUG_B], async () => {
await extraCleanup([]);
const A = await createTenant(SLUG_A);
const B = await createTenant(SLUG_B);
try {
const platform = await prisma.platformAdmin.create({ data: { email: PLATFORM_EMAIL, name: "ZZ Plattform", passwordHash: "x", role: "full" } });
const actor = { platformAdminId: platform.id };
await updateTenantPlan(actor, A.tenantId, { tier: "PROFI", lotseChatSeats: 2, lotseChatHardLimit: false });
await updateTenantPlan(actor, B.tenantId, { tier: "PROFI", lotseChatSeats: 5, lotseChatHardLimit: false });
section("Berechtigte Nutzer (Feldrolle: lotse:use + field:execute)");
const eligible = (await listEligibleUsers(A.ctx.admin)).map((u) => u.id);
const u = A.users;
ok([u.tech.id, u.tech2.id, u.outsider.id, u.lead.id, u.lead2.id].every((id) => eligible.includes(id)), "Monteure und Teamleiter berechtigt");
ok(!eligible.includes(u.backoffice.id), "Backoffice ohne field:execute nicht berechtigt");
ok(eligible.includes(u.admin.id), "Mandantenadministrator hat beide Rechte → berechtigt (Regel = Rechte, nicht Rollenname)");
section("Chat ohne Platz → blocked no_seat");
ok(!(await canUseLotseChat(A.ctx.tech)), "canUseLotseChat ohne Platz → false (kein Navigationseintrag)");
ok((await reasonOf(assertLotseChatUsable(A.ctx.tech))) === "blocked/no_seat", "assertLotseChatUsable → blocked no_seat");
ok((await reasonOf(getChatView(A.ctx.tech))) === "blocked/no_seat", "direkter Aufruf /m/lotse (getChatView) → no_seat");
const provider = () => new FakeLotseChatProvider([{ text: "Alles klar." }]);
ok((await reasonOf(sendLotseMessage(A.ctx.tech, { text: "Hallo" }, { provider: provider() }))) === "blocked/no_seat", "Senden ohne Platz → no_seat");
ok((await prisma.lotseMessage.count({ where: { tenantId: A.tenantId } })) === 0, "ohne Platz wird nichts gespeichert");
section("Platzvergabe bis zur Grenze");
const r1 = await setChatSeat(A.ctx.admin, { userId: u.tech.id, assigned: true });
ok(r1.changed, "Platz 1 an Monteur vergeben");
await setChatSeat(A.ctx.admin, { userId: u.lead.id, assigned: true });
const overview = await getSeatOverview(A.ctx.admin);
ok(overview.purchased === 2 && overview.assigned === 2 && !overview.overbooked, "2 von 2 Plätzen vergeben");
ok((await reasonOf(setChatSeat(A.ctx.admin, { userId: u.tech2.id, assigned: true }))) === "invalid/no_seats_left", "dritter Platz → invalid no_seats_left");
ok((await prisma.lotseChatSeat.count({ where: { tenantId: A.tenantId } })) === 2, "Grenze serverseitig gehalten");
await setChatSeat(A.ctx.admin, { userId: u.lead.id, assigned: false });
const parallel = await Promise.allSettled([u.tech2.id, u.outsider.id].map((userId) => setChatSeat(A.ctx.admin, { userId, assigned: true })));
ok(parallel.filter((p) => p.status === "fulfilled").length === 1 && (await prisma.lotseChatSeat.count({ where: { tenantId: A.tenantId } })) === 2, "ein freier Platz, zwei parallele Vergaben → genau eine gelingt");
for (const userId of [u.tech2.id, u.outsider.id]) await setChatSeat(A.ctx.admin, { userId, assigned: false });
await setChatSeat(A.ctx.admin, { userId: u.lead.id, assigned: true });
ok(!(await setChatSeat(A.ctx.admin, { userId: u.tech.id, assigned: true })).changed, "erneutes Vergeben idempotent");
ok((await reasonOf(setChatSeat(A.ctx.admin, { userId: u.backoffice.id, assigned: false }))) === "ok", "Entzug ohne Platz idempotent");
const auditCreate = await prisma.auditLog.findFirst({ where: { tenantId: A.tenantId, entity: "lotse_chat_seat", entityId: u.tech.id, action: "create" } });
ok(!!auditCreate && (auditCreate.after as { userId?: string })?.userId === u.tech.id && auditCreate.actorId === u.admin.id, "Audit bei Vergabe (after)");
section("Chat mit Platz → ok");
ok(await canUseLotseChat(A.ctx.tech), "canUseLotseChat mit Platz → true");
ok((await codeOf(getChatView(A.ctx.tech))) === "ok", "Chat-Ansicht mit Platz");
const view = await sendLotseMessage(A.ctx.tech, { text: "Wie ist der Stand?" }, { provider: provider() });
ok(view.messages.some((m) => m.role === "user"), "Nachricht mit Platz gesendet");
ok((await canUseLotseChat(A.ctx.lead)) && !(await canUseLotseChat(A.ctx.tech2)), "Teamleiter mit Platz ja, Monteur ohne Platz nein");
section("Entzug + Überbelegung");
await setChatSeat(A.ctx.admin, { userId: u.lead.id, assigned: false });
ok((await reasonOf(assertLotseChatUsable(A.ctx.lead))) === "blocked/no_seat", "nach Entzug → no_seat");
const auditDelete = await prisma.auditLog.findFirst({ where: { tenantId: A.tenantId, entity: "lotse_chat_seat", entityId: u.lead.id, action: "delete" } });
ok(!!auditDelete && (auditDelete.before as { userId?: string })?.userId === u.lead.id, "Audit bei Entzug (before)");
await setChatSeat(A.ctx.admin, { userId: u.tech2.id, assigned: true });
await updateTenantPlan(actor, A.tenantId, { tier: "PROFI", lotseChatSeats: 1, lotseChatHardLimit: false });
const over = await getSeatOverview(A.ctx.admin);
ok(over.overbooked && over.users.find((x) => x.id === u.tech.id)?.valid === true && over.users.find((x) => x.id === u.tech2.id)?.valid === false, "Betreiber senkt auf 1: nur der zuerst vergebene Platz gilt");
ok((await reasonOf(assertLotseChatUsable(A.ctx.tech2))) === "blocked/no_seat", "überzähliger Platz → no_seat");
await updateTenantPlan(actor, A.tenantId, { tier: "PROFI", lotseChatSeats: 2, lotseChatHardLimit: false });
ok(await canUseLotseChat(A.ctx.tech2), "wieder 2 Plätze → beide nutzbar");
section("Feldrollen und Backoffice vergeben keine Plätze");
for (const p of ["tech", "lead", "backoffice"] as const) {
await expectCode(() => setChatSeat(A.ctx[p], { userId: u.outsider.id, assigned: true }), "forbidden", `${p} → forbidden`);
await expectCode(() => getSeatOverview(A.ctx[p]), "forbidden", `${p} sieht die Platzverwaltung nicht`);
await expectCode(() => getChatUsageForAdmin(A.ctx[p]), "forbidden", `${p} sieht den Verbrauch nicht`);
}
ok((await reasonOf(setChatSeat(A.ctx.admin, { userId: u.backoffice.id, assigned: true }))) === "invalid/not_eligible", "Platz an Nutzer ohne Feldrolle → invalid not_eligible");
await prisma.user.update({ where: { id: u.outsider.id }, data: { status: "DEACTIVATED" } });
await setChatSeat(A.ctx.admin, { userId: u.tech2.id, assigned: false });
ok((await reasonOf(setChatSeat(A.ctx.admin, { userId: u.outsider.id, assigned: true }))) === "invalid/not_eligible", "deaktivierter Nutzer → invalid not_eligible");
await prisma.user.update({ where: { id: u.outsider.id }, data: { status: "ACTIVE" } });
await setChatSeat(A.ctx.admin, { userId: u.tech2.id, assigned: true });
section("Mandantentrennung");
ok((await reasonOf(setChatSeat(B.ctx.admin, { userId: u.tech.id, assigned: true }))) === "invalid/not_eligible", "B vergibt keinen Platz an Nutzer aus A");
ok((await reasonOf(setChatSeat(B.ctx.admin, { userId: u.tech.id, assigned: false }))) === "ok" && (await prisma.lotseChatSeat.count({ where: { tenantId: A.tenantId, userId: u.tech.id } })) === 1, "B kann den Platz von A nicht entziehen");
const bOverview = await getSeatOverview(B.ctx.admin);
ok(!bOverview.users.some((x) => [u.tech.id, u.tech2.id].includes(x.id)) && bOverview.assigned === 0, "B sieht keine Plätze/Nutzer von A");
ok((await dbForTenant(B.tenantId).lotseChatSeat.findMany()).length === 0, "Tenant-Client von B findet keine Platz-Zeilen von A");
ok((await getChatUsageForAdmin(B.ctx.admin)).used === 0, "B zählt keine Chats von A");
ok((await reasonOf(assertLotseChatUsable(B.ctx.tech))) === "blocked/no_seat", "B-Monteur hat keinen Platz (Plätze gelten je Mandant)");
section("Kontingentzählung über die Monatsgrenze (Zeitzone Europe/Berlin)");
const tz = "Europe/Berlin";
ok(monthKeyOf(new Date("2026-02-28T23:30:00Z"), tz) === "2026-03" && monthKeyOf(new Date("2026-02-28T22:30:00Z"), tz) === "2026-02", "Monat in Mandanten-Zeitzone");
const march = monthBounds("2026-03", tz);
ok(march.from.toISOString() === "2026-02-28T23:00:00.000Z" && march.to.toISOString() === "2026-03-31T22:00:00.000Z", "Monatsgrenzen inkl. Sommerzeit");
const conv = await prisma.lotseConversation.create({ data: { tenantId: A.tenantId, userId: u.tech2.id } });
const msg = (createdAt: string, role: "user" | "assistant" = "user") => ({ tenantId: A.tenantId, conversationId: conv.id, role, text: "x", createdAt: new Date(createdAt) });
await prisma.lotseMessage.createMany({
data: [msg("2026-02-28T22:30:00Z"), msg("2026-02-28T23:30:00Z"), msg("2026-02-28T23:31:00Z", "assistant"), msg("2026-03-31T21:59:00Z"), msg("2026-03-31T22:00:00Z")],
});
const feb = await getChatUsage(A.ctx.admin, { monthKey: "2026-02" });
const mar = await getChatUsage(A.ctx.admin, { monthKey: "2026-03" });
const apr = await getChatUsage(A.ctx.admin, { monthKey: "2026-04" });
ok(feb.used === 1 && mar.used === 2 && apr.used === 1, `Zählung je Monat (Feb ${feb.used}, Mär ${mar.used}, Apr ${apr.used}); Lotse-Antworten zählen nicht`);
ok(mar.perUser.length === 1 && mar.perUser[0].userId === u.tech2.id && mar.perUser[0].count === 2, "Aufschlüsselung je Nutzer");
section("Mehrverbrauch weiterzählen, hartes Limit");
const now = new Date();
const current = await getChatUsage(A.ctx.admin, { now });
ok(current.quota === 300 && current.quotaBasis === 2, `Kontingent = 2 vergebene Plätze × 150 (${current.quota})`);
const fill = 300 - current.used;
const recent = new Date(Math.max(new Date(current.from).getTime() + 60_000, now.getTime() - 60_000));
await prisma.lotseMessage.createMany({ data: Array.from({ length: fill + 5 }, () => ({ tenantId: A.tenantId, conversationId: conv.id, role: "user" as const, text: "y", createdAt: recent })) });
const overUsage = await getChatUsage(A.ctx.admin, { now });
ok(overUsage.used === 305 && overUsage.overage === 5 && overUsage.overagePacks === 1 && overUsage.level === "over" && !overUsage.blocked, "305/300: Mehrverbrauch 5 → 1 Paket, nicht gesperrt");
ok((await reasonOf(assertLotseChatUsable(A.ctx.tech, { send: true, now }))) === "ok", "ohne hartes Limit: Senden weiter möglich");
const sent = await sendLotseMessage(A.ctx.tech, { text: "Noch eine Frage" }, { provider: provider(), now: () => now });
ok(sent.messages.length > 0 && (await getChatUsage(A.ctx.admin, { now })).used === 306, "Mehrverbrauch wird weitergezählt (306)");
await updateTenantPlan(actor, A.tenantId, { tier: "PROFI", lotseChatSeats: 2, lotseChatHardLimit: true });
ok(await isChatQuotaBlocked(A.ctx.tech, now), "hartes Limit → Kontingent gesperrt");
ok((await reasonOf(assertLotseChatUsable(A.ctx.tech, { send: true, now }))) === "blocked/quota_exhausted", "Senden → blocked quota_exhausted");
const beforeCount = await prisma.lotseMessage.count({ where: { tenantId: A.tenantId } });
ok((await reasonOf(sendLotseMessage(A.ctx.tech, { text: "Gesperrt?" }, { provider: provider(), now: () => now }))) === "blocked/quota_exhausted", "sendLotseMessage → quota_exhausted");
ok((await prisma.lotseMessage.count({ where: { tenantId: A.tenantId } })) === beforeCount, "gesperrte Nachricht wird nicht gespeichert/gezählt");
ok((await codeOf(getChatView(A.ctx.tech))) === "ok", "Verlauf bleibt lesbar (Hinweis statt Eingabefeld)");
ok(!(await isChatQuotaBlocked(B.ctx.tech, now)), "hartes Limit von A wirkt nicht auf B");
section("Testphase: ohne Platzprüfung, Kontingent = Berechtigte × 150");
await prisma.tenant.update({ where: { id: B.tenantId }, data: { plan: "TRIAL", trialStartedAt: new Date(), trialEndsAt: new Date(Date.now() + 10 * 86400_000), tier: "BASIS", lotseChatHardLimit: true } });
ok((await canUseLotseChat(B.ctx.tech)) && (await canUseLotseChat(B.ctx.outsider)), "Testphase: jeder berechtigte Nutzer darf chatten (auch bei gespeicherter Stufe Basis)");
const trialUsage = await getChatUsage(B.ctx.admin, { now });
const eligibleB = (await listEligibleUsers(B.ctx.admin)).length;
ok(trialUsage.isTrial && trialUsage.quota === eligibleB * 150 && !trialUsage.hardLimit, `Testphase: Kontingent ${eligibleB} × 150, kein hartes Limit`);
ok((await codeOf(sendLotseMessage(B.ctx.outsider, { text: "Test" }, { provider: provider() }))) === "ok", "Testphase: Senden ohne Platz");
ok((await codeOf(assertLotseChatUsable(B.ctx.backoffice))) === "forbidden", "Testphase: ohne Feldrolle weiterhin kein Chat");
await prisma.tenant.update({ where: { id: B.tenantId }, data: { plan: "FULL", trialStartedAt: null, trialEndsAt: null, tier: "PROFI", lotseChatHardLimit: false } });
section("Basis sperrt den Chat, Plätze bleiben");
await updateTenantPlan(actor, A.tenantId, { tier: "BASIS", lotseChatSeats: 2, lotseChatHardLimit: false });
ok((await reasonOf(assertLotseChatUsable(A.ctx.tech))) === "blocked/not_in_plan", "Basis → blocked not_in_plan");
ok(!(await canUseLotseChat(A.ctx.tech)), "Basis: kein „Lotse fragen“/Navigationseintrag");
ok((await prisma.lotseChatSeat.count({ where: { tenantId: A.tenantId } })) === 2, "Plätze bleiben gespeichert");
ok((await reasonOf(setChatSeat(A.ctx.admin, { userId: u.lead.id, assigned: true }))) === "blocked/not_in_plan", "Basis: keine neue Vergabe");
await updateTenantPlan(actor, A.tenantId, { tier: "PROFI", lotseChatSeats: 2, lotseChatHardLimit: false });
ok(await canUseLotseChat(A.ctx.tech), "zurück auf Profi: Chat mit bestehendem Platz wieder nutzbar");
section("Betreiber-Übersicht (Grundlage der Rechnung)");
const op = await getTenantPlanOverview(actor, A.tenantId, now);
ok(op.plan.lotseChatSeats === 2 && op.seatsAssigned === 2 && op.current.used === 306 && op.current.overage === 6 && op.current.overagePacks === 1, `laufender Monat: 306 Chats, Mehrverbrauch 6 → 1 Paket`);
ok(op.previous.monthKey !== op.current.monthKey && op.previous.used >= 0, `Vormonat ${op.previous.monthKey}: ${op.previous.used} Chats`);
await expectCode(() => getTenantPlanOverview({ platformAdminId: u.admin.id }, A.tenantId), "forbidden", "Mandanten-Admin hat keine Betreiber-Übersicht");
} finally {
await extraCleanup([A.tenantId, B.tenantId]);
}
});
+216
View File
@@ -0,0 +1,216 @@
// Lane L15 „Testphase & Onboarding" — Lebenszyklus-Job und Plattform-Aktionen:
// Erinnerungen 7/3/1 Tage genau einmal (auch nach verpasstem Lauf), Ablaufmail, Löschhinweis,
// Löschung nur fällig + TRIAL (über das bestehende Offboarding inkl. Speicher), umgewandelte,
// verlängerte oder abgebrochene Mandanten bleiben erhalten, Mandant B unberührt;
// Plattform-Rechte (Mandanten-Admin/Read-only-Admin können die Testphase nicht ändern),
// Plattform-Wizard mit Einladung, Mail-Vorlagen, Worker-Registrierung.
//
// Lauf: npx tsx scripts/test-testphase-lifecycle.ts (lokale Postgres-DB + Garage aus .env)
import "dotenv/config";
import { readFileSync } from "node:fs";
import { prisma, dbForTenant } from "../src/server/db";
import { PROCESSORS } from "../src/server/jobs/processors";
import { renderTemplate, TRIAL_TEMPLATE_KEYS, type TemplateVars } from "../src/server/mail/templates";
import { addDaysToKey, todayKey, trialEndInstant } from "../src/lib/trial/dates";
import {
cancelTrialDeletion,
changeTrialEndDate,
convertTenantToFull,
createPlatformTrialTenant,
endTrialNow,
scheduleTrialDeletion,
} from "../src/server/services/trial/admin";
import { deleteTrialTenant, runTrialLifecycle } from "../src/server/services/trial/lifecycle";
import { getTrialState } from "../src/server/services/trial/state";
import { storage } from "../src/server/storage/adapter";
import { readStoredBytes } from "../src/server/services/documents/read";
import { captureMail, cleanupL15, ctxFor, DOMAIN, expectCode, failures, ok, platformAdmin, trialTenant } from "./lib/testphase-fixture";
const HOUR = 3600_000;
const DAY = 24 * HOUR;
async function main() {
await cleanupL15();
const full = await platformAdmin("full");
const readonly = await platformAdmin("readonly");
const actor = { platformAdminId: full.id };
const today = todayKey();
console.log("\n— Erinnerungen 7/3/1 genau einmal —");
const R = await trialTenant("Lifecycle R", addDaysToKey(today, 7));
const endR = trialEndInstant(addDaysToKey(today, 7));
const at = (dayOffset: number) => new Date(endR.getTime() - DAY * 7 + dayOffset * DAY - 12 * HOUR); // noon-ish of "today + offset"
const mail = captureMail();
const run = (now: Date, ids: string[]) => runTrialLifecycle({ now, tenantIds: ids, sendMail: mail.fn });
const count = (tpl: string, tenantId: string) => mail.sent.filter((m) => m.template === tpl && m.tenantId === tenantId).length;
await run(at(0), [R.tenantId]);
ok(count("trial_reminder", R.tenantId) === 1 && (mail.sent[0].vars as { daysLeft: number }).daysLeft === 7, "7 Tage vorher: Erinnerung an den Admin");
ok(mail.sent[0].to === `admin-lifecycle-r${DOMAIN}`, "Empfänger = aktiver Mandanten-Admin");
await run(at(0), [R.tenantId]);
await run(at(1), [R.tenantId]);
ok(count("trial_reminder", R.tenantId) === 1, "zweiter Lauf / Folgetag: keine weitere Erinnerung");
await run(at(4), [R.tenantId]);
ok(count("trial_reminder", R.tenantId) === 2, "3 Tage vorher: zweite Erinnerung");
await run(at(4), [R.tenantId]);
await run(at(6), [R.tenantId]);
await run(at(6), [R.tenantId]);
ok(count("trial_reminder", R.tenantId) === 3, "1 Tag vorher: dritte Erinnerung, jeweils genau einmal");
const markers = await prisma.tenant.findUniqueOrThrow({ where: { id: R.tenantId } });
ok(!!markers.trialReminder7At && !!markers.trialReminder3At && !!markers.trialReminder1At, "Versandmarker gesetzt");
const R2 = await trialTenant("Lifecycle R2", addDaysToKey(today, 2));
await run(new Date(), [R2.tenantId]);
const r2 = await prisma.tenant.findUniqueOrThrow({ where: { id: R2.tenantId } });
ok(count("trial_reminder", R2.tenantId) === 1 && !!r2.trialReminder7At && !!r2.trialReminder3At && !r2.trialReminder1At, "verpasster Lauf: nur die nächstliegende Erinnerung, ältere als erledigt markiert");
console.log("\n— Ablauf, Löschhinweis, Löschung —");
const endExpired = new Date(endR.getTime() + HOUR);
await run(endExpired, [R.tenantId]);
await run(endExpired, [R.tenantId]);
ok(count("trial_expired", R.tenantId) === 1, "Ablaufmail am Endtag genau einmal");
const expiredRow = await prisma.tenant.findUniqueOrThrow({ where: { id: R.tenantId } });
ok(expiredRow.readOnlySince?.getTime() === endR.getTime(), "readOnlySince = Ende der Testphase");
const expiredMail = mail.sent.find((m) => m.template === "trial_expired" && m.tenantId === R.tenantId)!;
ok(!!(expiredMail.vars as { deletionDate?: string }).deletionDate && (expiredMail.vars as { exportUrl: string }).exportUrl.endsWith("/settings/export"), "Ablaufmail nennt Löschdatum und Export");
const due = expiredRow.deletionDueAt!;
ok(due.getTime() === endR.getTime() + 30 * DAY, "Löschung 30 Tage nach Ende fällig");
await run(new Date(due.getTime() - 8 * DAY), [R.tenantId]);
ok(count("trial_deletion_notice", R.tenantId) === 0, "8 Tage vor Löschung noch kein Hinweis");
await run(new Date(due.getTime() - 6 * DAY), [R.tenantId]);
await run(new Date(due.getTime() - 5 * DAY), [R.tenantId]);
ok(count("trial_deletion_notice", R.tenantId) === 1, "Löschhinweis 7 Tage vorher genau einmal");
// tenant data + stored object that must disappear
const dbR = dbForTenant(R.tenantId);
const customerR = await dbR.customer.create({ data: { tenantId: R.tenantId, companyName: "ZZ Löschkunde", city: "Bremen" } });
const stored = await storage.put({ tenantId: R.tenantId, filename: "zz-loeschen.txt", contentType: "text/plain", bytes: Buffer.from("zz") });
const identityR = await prisma.user.findFirstOrThrow({ where: { tenantId: R.tenantId }, select: { identityId: true } });
// untouched neighbours: FULL tenant B (via provisioning then converted), not due trial B2
const B = await trialTenant("Lifecycle B", addDaysToKey(today, 3));
await convertTenantToFull(actor, B.tenantId);
const customerB = await dbForTenant(B.tenantId).customer.create({ data: { tenantId: B.tenantId, companyName: "ZZ Kunde B", city: "Kiel" } });
const B2 = await trialTenant("Lifecycle B2", addDaysToKey(today, 20));
ok(!(await deleteTrialTenant(R.tenantId, { now: new Date(due.getTime() - HOUR) })).deleted, "vor Fälligkeit: keine Löschung");
const summary = await run(new Date(due.getTime() + HOUR), [R.tenantId, B.tenantId, B2.tenantId]);
ok(summary.deleted.length === 1 && summary.deleted[0] === R.tenantId, "fälliger Testmandant gelöscht, nur dieser");
const gone = await prisma.tenant.findUniqueOrThrow({ where: { id: R.tenantId } });
ok(gone.status === "ARCHIVED" && !!gone.trialDeletedAt && gone.deletionDueAt === null, "Mandant archiviert, Löschung protokolliert am Mandanten");
ok((await prisma.customer.count({ where: { tenantId: R.tenantId } })) === 0 && (await prisma.user.count({ where: { tenantId: R.tenantId } })) === 0 && (await prisma.role.count({ where: { tenantId: R.tenantId } })) === 0, "alle Mandanten-Tabellen geleert (Topologie)");
ok((await prisma.identity.findUnique({ where: { id: identityR.identityId } })) === null, "Identity ohne weitere Mitgliedschaft gelöscht");
ok((await readStoredBytes(stored.storageKey)) === null, "Speicherobjekte unter <tenantId>/ entfernt");
ok((await prisma.deletionCertificate.count({ where: { tenantId: R.tenantId, scope: "tenant" } })) === 1, "Löschnachweis erstellt");
ok((await prisma.auditLog.count({ where: { scope: "platform", entity: "trial_tenant", entityId: R.tenantId, action: "delete" } })) === 1, "Plattform-Audit der Löschung");
ok((await prisma.customer.findUnique({ where: { id: customerR.id } })) === null, "Kunde des gelöschten Mandanten entfernt");
ok((await dbForTenant(B.tenantId).customer.count({ where: { id: customerB.id } })) === 1 && (await prisma.tenant.findUniqueOrThrow({ where: { id: B.tenantId } })).status === "ACTIVE", "Mandant B (Vollversion) unberührt");
ok((await prisma.tenant.findUniqueOrThrow({ where: { id: B2.tenantId } })).trialDeletedAt === null, "nicht fälliger Testmandant unberührt");
await run(new Date(due.getTime() + 2 * HOUR), [R.tenantId]);
ok((await prisma.auditLog.count({ where: { scope: "platform", entity: "trial_tenant", entityId: R.tenantId } })) === 1, "gelöschter Mandant wird nicht erneut verarbeitet");
console.log("\n— Umwandeln, Verlängern, Abbrechen verhindern die Löschung —");
const X = await trialTenant("Lifecycle X", addDaysToKey(today, 1));
await endTrialNow(actor, X.tenantId);
const xDue = (await prisma.tenant.findUniqueOrThrow({ where: { id: X.tenantId } })).deletionDueAt!;
await convertTenantToFull(actor, X.tenantId);
const xRow = await prisma.tenant.findUniqueOrThrow({ where: { id: X.tenantId } });
ok(xRow.plan === "FULL" && !!xRow.convertedAt && xRow.deletionDueAt === null && !(await getTrialState(X.tenantId)).readOnly, "Umwandeln: Vollversion, schreibbar, keine Löschung vorgemerkt");
await run(new Date(xDue.getTime() + DAY), [X.tenantId]);
ok(!(await deleteTrialTenant(X.tenantId, { now: new Date(xDue.getTime() + DAY) })).deleted && (await prisma.tenant.findUniqueOrThrow({ where: { id: X.tenantId } })).status === "ACTIVE", "umgewandelter Mandant wird nie gelöscht (Doppelprüfung)");
const Y = await trialTenant("Lifecycle Y", addDaysToKey(today, 1));
await endTrialNow(actor, Y.tenantId);
const yOldDue = (await prisma.tenant.findUniqueOrThrow({ where: { id: Y.tenantId } })).deletionDueAt!;
await changeTrialEndDate(actor, Y.tenantId, addDaysToKey(today, 60));
const yRow = await prisma.tenant.findUniqueOrThrow({ where: { id: Y.tenantId } });
ok(yRow.trialEndsAt!.getTime() === trialEndInstant(addDaysToKey(today, 60)).getTime() && yRow.deletionDueAt!.getTime() > yOldDue.getTime() && yRow.readOnlySince === null && yRow.trialExpiredNoticeAt === null, "Verlängern: neues Ende, Löschtermin verschoben, Marker zurückgesetzt");
await run(new Date(yOldDue.getTime() + DAY), [Y.tenantId]);
ok((await prisma.tenant.findUniqueOrThrow({ where: { id: Y.tenantId } })).trialDeletedAt === null, "verlängerter Mandant nach altem Löschtermin nicht gelöscht");
const Z = await trialTenant("Lifecycle Z", addDaysToKey(today, 1));
await endTrialNow(actor, Z.tenantId);
const zDue = (await prisma.tenant.findUniqueOrThrow({ where: { id: Z.tenantId } })).deletionDueAt!;
await cancelTrialDeletion(actor, Z.tenantId);
await run(new Date(zDue.getTime() + 10 * DAY), [Z.tenantId]);
const zRow = await prisma.tenant.findUniqueOrThrow({ where: { id: Z.tenantId } });
ok(zRow.trialDeletedAt === null && (await getTrialState(Z.tenantId, new Date(zDue.getTime() + 10 * DAY))).readOnly, "Löschung abgebrochen: bleibt erhalten, weiter nur lesbar");
await scheduleTrialDeletion(actor, Z.tenantId);
const zScheduled = (await prisma.tenant.findUniqueOrThrow({ where: { id: Z.tenantId } })).deletionDueAt!;
ok(zScheduled.getTime() >= Date.now() + 7 * DAY - HOUR, "Löschung vormerken: frühestens in 7 Tagen (Hinweis möglich)");
await changeTrialEndDate(actor, Z.tenantId, addDaysToKey(today, 3));
ok((await prisma.tenant.findUniqueOrThrow({ where: { id: Z.tenantId } })).deletionDueAt!.getTime() === trialEndInstant(addDaysToKey(today, 3)).getTime() + 30 * DAY, "vorgemerkte Löschung folgt dem neuen Enddatum");
const auditZ = await prisma.auditLog.findMany({ where: { tenantId: Z.tenantId, scope: "platform", entity: { startsWith: "trial_" } }, orderBy: { createdAt: "asc" } });
ok(auditZ.map((a) => a.entity).join(",") === "trial_ended,trial_deletion_cancelled,trial_deletion_scheduled,trial_end_date" && auditZ.every((a) => a.actorId === full.id && a.before && a.after), "Plattform-Audit je Aktion mit before/after");
console.log("\n— Plattform-Rechte —");
const P = await trialTenant("Lifecycle P", addDaysToKey(today, 5));
const tenantAdminActor = { platformAdminId: P.adminUserId };
const endBefore = (await prisma.tenant.findUniqueOrThrow({ where: { id: P.tenantId } })).trialEndsAt!.getTime();
await expectCode(() => changeTrialEndDate(tenantAdminActor, P.tenantId, addDaysToKey(today, 30)), "forbidden", "Mandanten-Admin kann die Testphase nicht verlängern");
await expectCode(() => convertTenantToFull(tenantAdminActor, P.tenantId), "forbidden", "Mandanten-Admin kann nicht umwandeln");
await expectCode(() => cancelTrialDeletion(tenantAdminActor, P.tenantId), "forbidden", "Mandanten-Admin kann die Löschung nicht abbrechen");
await expectCode(() => changeTrialEndDate({ platformAdminId: readonly.id }, P.tenantId, addDaysToKey(today, 30)), "forbidden", "Read-only-Plattform-Admin kann nichts ändern");
await expectCode(() => createPlatformTrialTenant(tenantAdminActor, { companyName: "ZZ L15 Hack", adminName: "Hack", adminEmail: `hack${DOMAIN}`, endDate: addDaysToKey(today, 5), sampleData: false }), "forbidden", "Mandanten-Admin kann keinen Testmandanten anlegen");
await expectCode(() => changeTrialEndDate(actor, P.tenantId, addDaysToKey(today, -1)), "invalid", "Enddatum in der Vergangenheit → invalid");
await expectCode(() => changeTrialEndDate(actor, P.tenantId, addDaysToKey(today, 400)), "invalid", "Enddatum > 1 Jahr → invalid");
await expectCode(() => changeTrialEndDate(actor, X.tenantId, addDaysToKey(today, 10)), "invalid", "Vollversion: keine Testphasen-Aktion");
ok((await prisma.tenant.findUniqueOrThrow({ where: { id: P.tenantId } })).trialEndsAt!.getTime() === endBefore, "Testphase von P unverändert");
const { trialLifecycleAction } = await import("../src/server/actions/trial-platform");
const fd = new FormData();
fd.set("confirm", "on");
fd.set("endDate", addDaysToKey(today, 40));
await expectCode(() => trialLifecycleAction(P.tenantId, "extend", { status: "idle" }, fd), "*", "Action ohne Plattform-Session wird abgewiesen");
ok((await prisma.tenant.findUniqueOrThrow({ where: { id: P.tenantId } })).trialEndsAt!.getTime() === endBefore, "… und ändert nichts");
const platformSrc = readFileSync("src/server/actions/trial-platform.ts", "utf8");
const exportsP = [...platformSrc.matchAll(/export async function (\w+)/g)].map((m) => m[1]);
ok(exportsP.length === 2 && exportsP.every((n) => new RegExp(`export async function ${n}[\\s\\S]*?await requirePlatformFullAdmin\\(\\)`).test(platformSrc)), "jede Plattform-Action verlangt einen Plattform-Voll-Admin");
const tenantSrc = readFileSync("src/server/actions/trial-tenant.ts", "utf8");
ok(!/services\/trial\/admin/.test(tenantSrc) && !/trialEndsAt|deletionDueAt|convertedAt/.test(tenantSrc), "Mandanten-Actions enthalten keine Testphasen-Änderung");
console.log("\n— Plattform-Wizard mit Einladung —");
const invites: { to: string; tenantId: string }[] = [];
const created = await createPlatformTrialTenant(actor, { companyName: "ZZ L15 Wizard GmbH", sector: "Elektro", adminName: "Wanda Wizard", adminEmail: `wanda${DOMAIN}`, endDate: addDaysToKey(today, 45), sampleData: true }, { invite: async (i) => void invites.push({ to: i.to, tenantId: i.tenantId }) });
const w = await prisma.tenant.findUniqueOrThrow({ where: { id: created.tenantId } });
ok(w.plan === "TRIAL" && w.trialSource === "platform" && w.trialEndsAt!.getTime() === trialEndInstant(addDaysToKey(today, 45)).getTime(), "Wizard: Testmandant mit frei gesetztem Enddatum (> TRIAL_MAX_DAYS)");
ok(created.invited && invites.length === 1 && invites[0].to === `wanda${DOMAIN}` && invites[0].tenantId === w.id, "Einladung über den bestehenden Einladungsweg");
const wId = await prisma.identity.findUniqueOrThrow({ where: { email: `wanda${DOMAIN}` } });
ok(wId.mustChangePassword, "neue Identity: Passwort wird über die Einladung gesetzt");
ok((await prisma.authToken.count({ where: { principalId: wId.id, type: "invitation", usedAt: null } })) === 1, "Einladungs-Token ausgestellt (Hash)");
ok((await dbForTenant(w.id).workOrder.count()) === 4, "Wizard mit Beispieldaten");
const again = await createPlatformTrialTenant(actor, { companyName: "ZZ L15 Wizard Zwei", adminName: "Wanda Wizard", adminEmail: `wanda${DOMAIN}`, endDate: addDaysToKey(today, 10), sampleData: false }, { invite: async (i) => void invites.push({ to: i.to, tenantId: i.tenantId }) });
ok(!again.invited && invites.length === 1 && again.identityId === wId.id, "bestehende Person: nur verknüpft, keine neue Einladung");
ok((await prisma.auditLog.count({ where: { tenantId: w.id, entity: "trial", actorId: full.id } })) === 1, "Audit mit handelndem Plattform-Admin");
console.log("\n— Vorlagen und Worker —");
const sample: { [K in (typeof TRIAL_TEMPLATE_KEYS)[number]]: TemplateVars[K] } = {
trial_confirm: { name: "Paula", companyName: "Muster", trialEnd: "30.09.2026", actionUrl: "https://x.example/testen/bestaetigen?token=abc", expires: "16.09.2026, 10:00" },
trial_existing_account: { name: "Paula", loginUrl: "https://x.example/login", resetUrl: "https://x.example/forgot-password" },
trial_reminder: { name: "Paula", tenantName: "Muster", daysLeft: 3, endDate: "30.09.2026", actionUrl: "https://x.example/dashboard", contact: "vertrieb@craftvia.example" },
trial_expired: { name: "Paula", tenantName: "Muster", endDate: "30.09.2026", deletionDate: "30.10.2026", exportUrl: "https://x.example/settings/export" },
trial_deletion_notice: { name: "Paula", tenantName: "Muster", deletionDate: "30.10.2026", exportUrl: "https://x.example/settings/export" },
};
for (const key of TRIAL_TEMPLATE_KEYS) {
for (const locale of ["de", "en"] as const) {
const r = renderTemplate(key, locale, sample[key] as never);
ok(r.subject.length > 5 && !/undefined|\{/.test(r.subject + r.text) && r.html.includes("<"), `Vorlage ${key} (${locale}) rendert`);
}
}
ok(renderTemplate("trial_reminder", "de", { ...sample.trial_reminder, daysLeft: 1 }).subject.includes("morgen") && renderTemplate("trial_reminder", "de", { ...sample.trial_reminder, daysLeft: 0 }).subject.includes("heute"), "Erinnerung: „morgen“/„heute“");
ok(typeof PROCESSORS["trial-lifecycle"] === "function" && typeof PROCESSORS["tenant-export"] === "function", "Processor trial-lifecycle und tenant-export registriert");
ok(readFileSync("src/server/jobs/queues.ts", "utf8").includes('"trial-lifecycle-daily"'), "täglicher Job-Scheduler angelegt");
const ctxP = ctxFor(P.tenantId, P.adminUserId, "tenant-admin");
ok((await ctxP.db.tenantSettings.findFirst())?.orgName === "ZZ L15 Lifecycle P", "Mandanten-Einstellungen provisioniert");
await cleanupL15();
await prisma.$disconnect();
console.log(failures ? `\n${failures} Prüfung(en) fehlgeschlagen` : "\nAlle Prüfungen bestanden");
process.exit(failures ? 1 : 0);
}
main().catch(async (err) => {
console.error(err);
await cleanupL15().catch(() => undefined);
process.exit(1);
});
+189
View File
@@ -0,0 +1,189 @@
// Lane L15 „Testphase & Onboarding" — Nur-Lesen nach Ablauf + Export:
// zentrale Schreibsperre (moduleGuard, /api/v1-Mutationen inkl. Sync und Uploads, Backoffice-Upload,
// Einstellungen/Nutzerverwaltung, Worker-Jobs im Namen von Nutzern) bei gleichzeitig erlaubtem Lesen,
// Downloads und Export; Verlängern hebt die Sperre auf; Onboarding-Checkliste; Mandanten-Export
// (ZIP mit CSV/JSON + Dateien) inkl. Mandantentrennung und Rollen.
//
// Lauf: npx tsx scripts/test-testphase-readonly.ts (lokale Postgres-DB + Garage aus .env)
import "dotenv/config";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { prisma } from "../src/server/db";
import { json, withApi } from "../src/server/api/respond";
import { assertApiWriteAllowed } from "../src/server/api/context";
import { applyOperations } from "../src/server/services/sync/apply";
import { storeFile } from "../src/server/services/documents/store";
import { addDaysToKey, todayKey } from "../src/lib/trial/dates";
import { changeTrialEndDate, endTrialNow } from "../src/server/services/trial/admin";
import { buildTenantExport, listTenantExports, openTenantExport, requestTenantExport, toCsv } from "../src/server/services/trial/export";
import { isJobBlockedByTrial } from "../src/server/services/trial/jobs";
import { getOnboardingChecklist, setOnboardingHidden, setOnboardingItem } from "../src/server/services/trial/onboarding";
import { assertTenantWritable, getTrialState } from "../src/server/services/trial/state";
import { addMember, cleanupL15, ctxFor, expectCode, failures, ok, platformAdmin, readZip, trialTenant } from "./lib/testphase-fixture";
const post = (body: unknown = {}) => new Request("http://localhost/api/v1/zz", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
const get = () => new Request("http://localhost/api/v1/zz", { method: "GET" });
function walk(dir: string): string[] {
return readdirSync(dir).flatMap((n) => (statSync(join(dir, n)).isDirectory() ? walk(join(dir, n)) : [join(dir, n)]));
}
async function main() {
await cleanupL15();
const actor = { platformAdminId: (await platformAdmin("full")).id };
const today = todayKey();
const A = await trialTenant("Readonly A", addDaysToKey(today, 10), { sampleData: true });
const B = await trialTenant("Readonly B", addDaysToKey(today, 10));
const adminA = ctxFor(A.tenantId, A.adminUserId, "tenant-admin");
const adminB = ctxFor(B.tenantId, B.adminUserId, "tenant-admin");
const techA = await addMember(A.tenantId, "tech-readonly-a", "technician");
const ctxTechA = ctxFor(A.tenantId, techA.id, "technician");
await adminB.db.customer.create({ data: { tenantId: B.tenantId, companyName: "ZZ Kunde nur B", city: "Kiel" } });
console.log("\n— laufende Testphase: schreibbar, Onboarding —");
await assertTenantWritable(A.tenantId);
ok(true, "laufende Testphase ist schreibbar");
const checklist = await getOnboardingChecklist(adminA);
ok(checklist?.items.length === 5 && checklist.items.find((i) => i.key === "team")?.done === false, "Erste Schritte: 5 Punkte, Beispielteam zählt nicht als eigenes Team");
ok(checklist?.items.find((i) => i.key === "technician")?.done === true, "Erste Schritte: eingeladener Monteur automatisch erkannt");
ok(checklist?.items.find((i) => i.key === "first_order")?.done === false, "Erste Schritte: Beispielaufträge zählen nicht als erster Auftrag");
await setOnboardingItem(adminA, "mobile", true);
ok((await getOnboardingChecklist(adminA))?.items.find((i) => i.key === "mobile")?.done === true, "Punkt manuell abgehakt");
await expectCode(() => setOnboardingItem(ctxTechA, "mobile", false), "forbidden", "Monteur kann die Checkliste nicht ändern");
await expectCode(() => setOnboardingItem(adminA, "hacken", true), "invalid", "unbekannter Punkt → invalid");
ok((await getOnboardingChecklist(ctxTechA)) === null, "Monteur sieht die Checkliste nicht");
const fullTenant = await prisma.tenant.findUniqueOrThrow({ where: { slug: "demo" }, select: { id: true } }).catch(() => null);
if (fullTenant) {
const demoAdmin = await prisma.user.findFirst({ where: { tenantId: fullTenant.id, email: "admin@demo.example" } });
if (demoAdmin) ok((await getOnboardingChecklist(ctxFor(fullTenant.id, demoAdmin.id, "tenant-admin"))) === null, "Vollversions-Mandant ohne Testphase → keine Checkliste");
}
console.log("\n— Ablauf: zentrale Schreibsperre —");
await endTrialNow(actor, A.tenantId);
const stateA = await getTrialState(A.tenantId);
ok(stateA.readOnly && stateA.expired && !!stateA.deletionDueAt, "Testphase beendet → nur lesen, Löschtermin gesetzt");
try {
await assertTenantWritable(A.tenantId);
ok(false, "Schreibsperre greift nicht");
} catch (err) {
const e = err as { code?: string; message?: string; details?: { readOnly?: boolean; message?: string } };
ok(e.code === "blocked" && e.message === "trial_expired" && e.details?.readOnly === true && !!e.details.message, "ServiceError blocked/trial_expired mit Klartext");
}
await assertTenantWritable(B.tenantId);
ok(true, "Mandant B bleibt schreibbar");
// /api/v1: the same wrapper + check the real routes use (withApi → requireApiContext → assertApiWriteAllowed)
const handler = (tenantId: string) => withApi(async () => {
await assertApiWriteAllowed(tenantId);
return json({ ok: true });
});
const blocked = await handler(A.tenantId)(post());
const blockedBody = (await blocked.json()) as { error?: { code: string; message: string; details?: { readOnly?: boolean } } };
ok(blocked.status === 422 && blockedBody.error?.code === "blocked" && blockedBody.error.message === "trial_expired" && blockedBody.error.details?.readOnly === true, "API-Mutation (POST) → 422 blocked trial_expired");
ok((await handler(A.tenantId)(new Request("http://localhost/x", { method: "DELETE" }))).status === 422, "API-Mutation (DELETE) → 422");
ok((await handler(A.tenantId)(get())).status === 200, "API-Lesezugriff (GET) bleibt erlaubt");
ok((await handler(B.tenantId)(post())).status === 200, "API-Mutation von Mandant B unberührt");
await assertApiWriteAllowed(A.tenantId);
ok(true, "außerhalb von withApi (Downloads /files, Export) keine Sperre");
const syncBefore = await prisma.syncOperation.count({ where: { tenantId: A.tenantId } });
const order = await adminA.db.workOrder.findFirstOrThrow({ where: { status: "assigned" } });
const sync = withApi(async (req: Request) => {
await assertApiWriteAllowed(A.tenantId);
return json(await applyOperations(ctxTechA, (await req.json()) as never));
});
const syncRes = await sync(post({ deviceId: "zz", operations: [{ clientOpId: "7c1d6a0e-3b1f-4c55-9d2a-00000000f015", opType: "note.create", payload: { workOrderId: order.id, kind: "work_done", text: "zz" }, clientCreatedAt: new Date().toISOString() }] }));
ok(syncRes.status === 422 && (await prisma.syncOperation.count({ where: { tenantId: A.tenantId } })) === syncBefore, "Sync-Batch → 422, keine Operation angewendet (Outbox wiederholt später)");
// static coverage of every write entry point
const guard = readFileSync("src/server/action-guard.ts", "utf8");
ok(/assertModuleEnabled\(session, moduleKey\);[\s\S]*if \(!opts\.read\) await assertTenantWritable\(session\.user\.tenantId\);[\s\S]*return \{ session, db/.test(guard), "moduleGuard: Schreibsperre für alle Modul-Actions (inkl. Lotse, Uploads per Action)");
const actionFiles = walk("src/server/actions").filter((f) => f.endsWith(".ts"));
ok(actionFiles.every((f) => !/moduleGuard\([^)]*read\s*:/.test(readFileSync(f, "utf8"))), "keine Server-Action nutzt den Lese-Modus des Guards");
const readUsers = walk("src").filter((f) => /\.tsx?$/.test(f) && /moduleGuard\([^)]*read\s*:\s*true/.test(readFileSync(f, "utf8"))).sort();
ok(JSON.stringify(readUsers) === JSON.stringify(["src/app/(app)/imports/[id]/file/route.ts", "src/app/(field)/m/emergency/page.tsx", "src/server/services/field/page-context.ts"]), `Lese-Modus nur in Seitenkontexten/GET-Download (${readUsers.join(", ")})`);
ok(readFileSync("scripts/check-module-guards.ts", "utf8").includes("nur für Lesepfade erlaubt"), "Guard-Check verbietet den Lese-Modus in Actions");
const context = readFileSync("src/server/api/context.ts", "utf8");
ok(/enforceApiRateLimit\(session\.user\.id, moduleKey\);\s*await assertApiWriteAllowed\(tenantId\);/.test(context), "requireApiContext: Schreibsperre für jede /api/v1-Mutation");
const routes = walk("src/app/api/v1").filter((f) => f.endsWith("route.ts"));
const mutating = routes.filter((f) => /export const (POST|PUT|PATCH|DELETE)\b|export async function (POST|PUT|PATCH|DELETE)\b/.test(readFileSync(f, "utf8")));
// every mutating route: withApi (central lock in requireApiContext) OR an explicit assertTenantWritable
const unwrapped = mutating.filter((f) => {
const src = readFileSync(f, "utf8");
return !/export const (POST|PUT|PATCH|DELETE) = withApi\(/.test(src) && !/await assertTenantWritable\(ctx\.tenantId\)/.test(src);
});
ok(mutating.length > 10 && unwrapped.length === 0, `alle ${mutating.length} mutierenden /api/v1-Routen gesperrt (withApi bzw. explizit; Sync und Uploads eingeschlossen)${unwrapped.length ? ` — ohne: ${unwrapped.join(", ")}` : ""}`);
ok(readFileSync("src/app/(app)/documents/upload/route.ts", "utf8").includes("await assertTenantWritable(ctx.tenantId)"), "Backoffice-Upload-Route gesperrt");
ok(readFileSync("src/server/actions/tenant-settings.ts", "utf8").includes("await assertTenantWritable(tenantId)"), "Einstellungen gesperrt");
ok(readFileSync("src/server/actions/tenant-users.ts", "utf8").includes("await assertTenantWritable(session.user.tenantId)"), "Nutzer-/Rollenverwaltung gesperrt");
ok(readFileSync("src/server/actions/lotse-settings.ts", "utf8").includes("await assertTenantWritable(ctx.tenantId)"), "Lotse-Einstellungen gesperrt");
ok(readFileSync("scripts/craftvia-worker.ts", "utf8").includes("isJobBlockedByTrial(name, job.data)"), "Worker prüft die Sperre vor jedem Job");
ok(await isJobBlockedByTrial("import-extraction", { tenantId: A.tenantId }), "Job Import-Extraktion für abgelaufenen Mandanten übersprungen");
ok(await isJobBlockedByTrial("transcription", { tenantId: A.tenantId }), "Job Transkription (Lotse) übersprungen");
ok(!(await isJobBlockedByTrial("report-pdf", { tenantId: A.tenantId })), "PDF-Erzeugung bereits gespeicherter Berichte läuft weiter");
ok(!(await isJobBlockedByTrial("tenant-export", { tenantId: A.tenantId })), "Export-Job läuft auch im Nur-Lesen-Zustand");
ok(!(await isJobBlockedByTrial("import-extraction", { tenantId: B.tenantId })), "Jobs von Mandant B unberührt");
ok(!(await isJobBlockedByTrial("trial-lifecycle", { tenantId: "*" })), "plattformweite Jobs nicht betroffen");
await expectCode(() => setOnboardingItem(adminA, "team", true), "blocked", "Checkliste im Nur-Lesen-Zustand gesperrt");
await expectCode(() => setOnboardingHidden(adminA, true), "blocked", "Ausblenden im Nur-Lesen-Zustand gesperrt");
ok((await adminA.db.customer.count()) === 3 && (await adminA.db.workOrder.count()) === 4, "Lesen bleibt möglich");
console.log("\n— Export (auch im Nur-Lesen-Zustand) —");
// one stored file for the export
const pdf = Buffer.from("%PDF-1.4\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n");
const doc = await storeFile(ctxFor(A.tenantId, A.adminUserId, "tenant-admin"), { bytes: pdf, fileName: "zz-auftrag.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", links: { workOrderId: order.id } }).catch((err) => {
console.log(" (Hinweis: Dokument konnte nicht gespeichert werden:", (err as Error).message, ")");
return null;
});
await prisma.customer.updateMany({ where: { tenantId: A.tenantId, companyName: { startsWith: "Hausverwaltung" } }, data: { notes: "=HYPERLINK(\"http://evil\")" } });
await expectCode(() => requestTenantExport(ctxTechA, { dispatch: async () => undefined }), "forbidden", "Monteur darf keinen Export anfordern");
const exp = await requestTenantExport(adminA, { dispatch: (tid, id) => buildTenantExport(tid, id) });
const done = await prisma.tenantExport.findUniqueOrThrow({ where: { id: exp.id } });
ok(done.status === "done" && !!done.storageKey?.startsWith(`${A.tenantId}/`) && (done.bytes ?? 0) > 0, `Export im Nur-Lesen-Zustand erstellt (${done.status}${done.error ? `: ${done.error}` : ""})`);
await expectCode(() => requestTenantExport(adminA, { dispatch: async () => undefined }).then(() => requestTenantExport(adminA, { dispatch: async () => undefined })), "conflict", "paralleler Export → conflict");
const { bytes, fileName } = await openTenantExport(adminA, exp.id);
ok(bytes.subarray(0, 2).toString() === "PK" && fileName.endsWith(".zip"), "Download liefert ZIP");
const zip = readZip(bytes);
const kunden = zip.get("csv/kunden.csv")?.toString("utf8") ?? "";
ok(zip.has("LIESMICH.txt") && zip.has("json/auftraege.json") && zip.has("csv/zeiten.csv") && zip.has("csv/material.csv") && zip.has("csv/berichte.csv"), "ZIP enthält Stammdaten, Aufträge, Zeiten, Material, Berichte (CSV/JSON)");
ok(kunden.startsWith("") && kunden.includes("Hausverwaltung Musterhof GmbH"), "CSV mit BOM und Daten von Mandant A");
ok(!kunden.includes("ZZ Kunde nur B"), "Export enthält keine Daten von Mandant B");
ok(kunden.includes("'=HYPERLINK"), "CSV-Formel-Injektion entschärft");
ok((JSON.parse(zip.get("json/auftraege.json")?.toString("utf8") ?? "[]") as unknown[]).length === 4, "JSON enthält alle Aufträge");
if (doc) ok([...zip.keys()].some((k) => k.startsWith(`dateien/${doc.id}-`)) && zip.get([...zip.keys()].find((k) => k.startsWith(`dateien/${doc.id}-`))!)?.equals(pdf) === true, "gespeicherte Datei im ZIP (byte-identisch)");
const nutzer = zip.get("csv/nutzer.csv")?.toString("utf8") ?? "";
ok(!nutzer.includes("password") && !nutzer.includes("$argon2"), "keine Passwort-Hashes im Export");
ok((await listTenantExports(adminA)).some((e) => e.id === exp.id && !e.expired), "Exportliste zeigt den fertigen Export");
await expectCode(() => openTenantExport(adminB, exp.id), "not_found", "Mandant B kann den Export von A nicht laden");
await expectCode(() => openTenantExport(ctxTechA, exp.id), "forbidden", "Monteur kann den Export nicht laden");
await expectCode(() => openTenantExport(adminA, exp.id, { now: new Date(Date.now() + 8 * 86_400_000) }), "invalid", "Download nach 7 Tagen abgelaufen");
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "tenant_export_download" } })) >= 1, "Audit: Export-Download");
ok(toCsv([{ a: 'x;"y"', b: new Date("2026-01-01T00:00:00Z"), c: null }]) === 'a;b;c\r\n"x;""y""";2026-01-01T00:00:00.000Z;\r\n', "CSV-Quoting, Datum, leere Werte");
console.log("\n— Verlängern hebt die Sperre auf —");
const before = await prisma.tenant.findUniqueOrThrow({ where: { id: B.tenantId } });
await changeTrialEndDate(actor, A.tenantId, addDaysToKey(today, 5));
await assertTenantWritable(A.tenantId);
const stateAfter = await getTrialState(A.tenantId);
ok(!stateAfter.readOnly && stateAfter.daysLeft === 5, "nach Verlängerung wieder schreibbar (noch 5 Tage)");
ok((await handler(A.tenantId)(post())).status === 200, "API-Mutation nach Verlängerung erlaubt");
await setOnboardingItem(adminA, "team", true);
ok(true, "Checkliste nach Verlängerung wieder änderbar");
const after = await prisma.tenant.findUniqueOrThrow({ where: { id: B.tenantId } });
ok(before.trialEndsAt?.getTime() === after.trialEndsAt?.getTime() && after.readOnlySince === null, "Mandant B unverändert");
await cleanupL15();
await prisma.$disconnect();
console.log(failures ? `\n${failures} Prüfung(en) fehlgeschlagen` : "\nAlle Prüfungen bestanden");
process.exit(failures ? 1 : 0);
}
main().catch(async (err) => {
console.error(err);
await cleanupL15().catch(() => undefined);
process.exit(1);
});
+211
View File
@@ -0,0 +1,211 @@
// Lane L15 „Testphase & Onboarding" — öffentliche Selbstanmeldung:
// Wizard-Validierung (Pflichtfelder, Passwort-Policy, Enddatum-Grenzen, TRIAL_MAX_DAYS, Europe/Berlin),
// Double-Opt-in (Token nur als Hash, 24 h, Einmalverwendung, ältere Links entwertet),
// Honeypot, Enumeration-Schutz (gleiche Antwort, Hinweis-Mail), Rate-Limit je IP und je E-Mail,
// Provisionierung mit/ohne Beispieldaten + Modulauswahl, Slug-Kollisionen, Mandantentrennung.
//
// Lauf: npx tsx scripts/test-testphase-signup.ts (lokale Postgres-DB aus .env)
import "dotenv/config";
import { readFileSync } from "node:fs";
import { prisma, dbForTenant } from "../src/server/db";
import { verifyPassword } from "../src/server/password";
import { resetRateLimits } from "../src/server/rate-limit";
import { addDaysToKey, trialBounds, trialEndDateKey, trialEndInstant } from "../src/lib/trial/dates";
import { normalizeTrialValues, validateTrialSignup, validateTrialStep, type TrialSignupValues } from "../src/lib/trial/signup";
import { MODULE_KEYS } from "../src/lib/modules";
import { trialMaxDays } from "../src/server/services/trial/config";
import { enforceTrialRateLimit } from "../src/server/services/trial/abuse";
import { checkTrialStep, confirmTrialSignup, currentTrialBounds, hashSignupToken, peekTrialSignup, submitTrialSignup } from "../src/server/services/trial/signup";
import { SAMPLE_TEAM_NAME } from "../src/server/services/trial/sample-data";
import { captureMail, cleanupL15, DOMAIN, failures, ok } from "./lib/testphase-fixture";
const PASSWORD = "Testphase2026Sicher";
function values(over: Partial<TrialSignupValues> = {}): TrialSignupValues {
return {
companyName: "ZZ L15 Signup Sanitär",
sector: "Sanitär, Heizung, Klima",
companySize: "6-20",
adminName: "Paula Probe",
email: `paula${DOMAIN}`,
password: PASSWORD,
trialEndDate: currentTrialBounds().defaultEnd,
sampleData: true,
modules: [...MODULE_KEYS],
acceptTerms: true,
acceptPrivacy: true,
website: "",
...over,
};
}
const tokenOf = (url: string) => new URL(url).searchParams.get("token") ?? "";
async function main() {
await cleanupL15();
resetRateLimits();
console.log("\n— Wizard-Validierung —");
const now = new Date("2026-03-10T10:00:00Z");
const b = trialBounds(now, 30);
ok(b.today === "2026-03-10" && b.min === "2026-03-11" && b.max === "2026-04-09" && b.defaultEnd === "2026-03-24", "Grenzen: morgen … heute + 30, Vorbelegung heute + 14");
ok(trialBounds(now, 10).defaultEnd === "2026-03-20", "Vorbelegung höchstens TRIAL_MAX_DAYS");
const v = values();
ok(validateTrialStep("period", { ...v, trialEndDate: "2026-03-10" }, b).trialEndDate === "date_too_early", "Enddatum heute → zu früh");
ok(!validateTrialStep("period", { ...v, trialEndDate: "2026-03-11" }, b).trialEndDate, "Enddatum morgen → erlaubt");
ok(!validateTrialStep("period", { ...v, trialEndDate: "2026-04-09" }, b).trialEndDate, "Enddatum heute + 30 → erlaubt");
ok(validateTrialStep("period", { ...v, trialEndDate: "2026-04-10" }, b).trialEndDate === "date_too_late", "Enddatum heute + 31 → zu spät");
ok(validateTrialStep("period", { ...v, trialEndDate: "2026-02-30" }, b).trialEndDate === "date_invalid", "ungültiges Kalenderdatum");
ok(validateTrialStep("company", { ...v, companyName: " " }, b).companyName === "company_required", "Firmenname Pflicht");
ok(validateTrialStep("company", { ...v, companySize: "999" }, b).companySize === "invalid_choice", "Betriebsgröße nur aus der Liste");
const acc = validateTrialStep("account", { ...v, adminName: "", email: "kein-mail", password: "kurz" }, b);
ok(acc.adminName === "name_required" && acc.email === "email_invalid" && acc.password === "password_policy", "Admin-Konto: Name, E-Mail und Passwort-Policy");
ok(validateTrialStep("setup", { ...v, modules: [] }, b).modules === "modules_required", "mindestens ein Modul");
const sum = validateTrialStep("summary", { ...v, acceptTerms: false, acceptPrivacy: false }, b);
ok(sum.acceptTerms === "terms_required" && sum.acceptPrivacy === "privacy_required", "Pflicht-Checkboxen Nutzungsbedingungen/Datenschutz");
ok(Object.keys(validateTrialSignup({ ...v, trialEndDate: "2026-03-24" }, b)).length === 0, "vollständige gültige Anmeldung ohne Fehler");
ok(normalizeTrialValues({ ...v, email: " Paula@ZZ-L15.test ", modules: ["customers", "hacker"] })?.email === "paula@zz-l15.test", "E-Mail normalisiert");
ok(JSON.stringify(normalizeTrialValues({ ...v, modules: ["customers", "hacker"] })?.modules) === '["customers"]', "unbekannte Module verworfen");
ok(normalizeTrialValues({ modules: "kein-array" }) === null, "fehlerhafte Eingabe → null");
ok(checkTrialStep("hacken", v)._form === "invalid_request", "serverseitige Schrittprüfung: unbekannter Schritt");
ok(checkTrialStep("account", { ...v, password: "x" }).password === "password_policy", "serverseitige Schrittprüfung: Passwort-Policy");
const prevMax = process.env.TRIAL_MAX_DAYS;
process.env.TRIAL_MAX_DAYS = "10";
ok(trialMaxDays() === 10, "TRIAL_MAX_DAYS aus der Umgebung");
process.env.TRIAL_MAX_DAYS = "abc";
ok(trialMaxDays() === 30, "ungültiges TRIAL_MAX_DAYS → Default 30");
if (prevMax === undefined) delete process.env.TRIAL_MAX_DAYS;
else process.env.TRIAL_MAX_DAYS = prevMax;
ok(trialEndInstant("2026-03-24").toISOString() === "2026-03-24T23:00:00.000Z", "Ende des gewählten Tages in Europe/Berlin (Winterzeit)");
ok(trialEndInstant("2026-07-01").toISOString() === "2026-07-01T22:00:00.000Z", "Ende des gewählten Tages in Europe/Berlin (Sommerzeit)");
ok(trialEndInstant("2026-03-29").toISOString() === "2026-03-29T22:00:00.000Z", "Ende am Tag der Zeitumstellung");
ok(trialEndDateKey(trialEndInstant("2026-10-25")) === "2026-10-25", "letzter Testtag aus dem gespeicherten Ende zurückgerechnet");
console.log("\n— Absenden: ungültig / Honeypot —");
const mail = captureMail();
const invalid = await submitTrialSignup({ ...values(), acceptTerms: false }, { ip: "198.51.100.1", sendMail: mail.fn });
ok(invalid.status === "invalid" && (invalid as { errors: Record<string, string> }).errors.acceptTerms === "terms_required", "ungültig → Fehler je Feld");
ok((await prisma.trialSignup.count({ where: { email: { endsWith: DOMAIN } } })) === 0 && mail.sent.length === 0, "ungültig → keine Anmeldung, keine Mail");
const honey = await submitTrialSignup({ ...values(), website: "http://spam.example" }, { ip: "198.51.100.1", sendMail: mail.fn });
ok(honey.status === "sent", "Honeypot gefüllt → neutrale Erfolgsantwort");
ok((await prisma.trialSignup.count({ where: { email: { endsWith: DOMAIN } } })) === 0 && mail.sent.length === 0, "Honeypot → nichts gespeichert, keine Mail");
console.log("\n— Double-Opt-in —");
const first = await submitTrialSignup(values({ sampleData: true, modules: MODULE_KEYS.filter((m) => m !== "lotse") }), { ip: "198.51.100.7", sendMail: mail.fn });
ok(first.status === "sent" && mail.sent.length === 1 && mail.sent[0].template === "trial_confirm", "gültig → Bestätigungsmail");
const token1 = tokenOf((mail.sent[0].vars as { actionUrl: string }).actionUrl);
const row1 = await prisma.trialSignup.findFirstOrThrow({ where: { email: `paula${DOMAIN}` }, orderBy: { createdAt: "desc" } });
ok(row1.tokenHash === hashSignupToken(token1) && row1.tokenHash !== token1 && token1.length >= 40, "Token nur als SHA-256-Hash gespeichert");
ok(row1.passwordHash.startsWith("$argon2id$") && !row1.passwordHash.includes(PASSWORD), "Passwort als Argon2id-Hash (mit Pepper)");
ok(!!row1.ipHash && row1.ipHash !== "198.51.100.7", "IP nur als HMAC");
const ttl = row1.expiresAt.getTime() - row1.createdAt.getTime();
ok(Math.abs(ttl - 24 * 3600_000) < 60_000, "Link 24 Stunden gültig");
ok(row1.status === "pending" && (await prisma.tenant.count({ where: { slug: { startsWith: "zz-l15-signup" } } })) === 0, "vor der Bestätigung wird kein Mandant angelegt");
const rawDump = JSON.stringify(await prisma.trialSignup.findMany({ where: { email: { endsWith: DOMAIN } } }));
ok(!rawDump.includes(token1) && !rawDump.includes(PASSWORD), "weder Klartext-Token noch -Passwort in der Tabelle");
const second = await submitTrialSignup(values({ sampleData: true, modules: MODULE_KEYS.filter((m) => m !== "lotse") }), { ip: "198.51.100.7", sendMail: mail.fn });
const token2 = tokenOf((mail.sent[1].vars as { actionUrl: string }).actionUrl);
ok(second.status === "sent" && token2 !== token1, "erneute Anmeldung → neuer Link");
ok((await prisma.trialSignup.findUniqueOrThrow({ where: { id: row1.id } })).status === "superseded" && (await peekTrialSignup(token1)) === null, "älterer Link entwertet");
ok((await confirmTrialSignup(token1)).status === "invalid", "entwerteter Link lässt sich nicht einlösen");
const peek = await peekTrialSignup(token2);
ok(peek?.companyName === "ZZ L15 Signup Sanitär", "Bestätigungsseite zeigt die Anmeldung (ohne Einlösen)");
ok((await confirmTrialSignup("falsches-token")).status === "invalid", "falsches Token → ungültig");
// expiry on a separate signup
const mailExp = captureMail();
await submitTrialSignup(values({ email: `ablauf${DOMAIN}`, companyName: "ZZ L15 Ablauf" }), { ip: "198.51.100.8", sendMail: mailExp.fn });
const tokenExp = tokenOf((mailExp.sent[0].vars as { actionUrl: string }).actionUrl);
const expRow = await prisma.trialSignup.findFirstOrThrow({ where: { email: `ablauf${DOMAIN}` } });
ok((await confirmTrialSignup(tokenExp, { now: new Date(expRow.expiresAt.getTime() + 1000) })).status === "expired", "nach 24 h → abgelaufen");
const expAfter = await prisma.trialSignup.findUniqueOrThrow({ where: { id: expRow.id } });
ok(expAfter.status === "expired" && expAfter.passwordHash === "", "abgelaufene Anmeldung: Status expired, Passwort-Hash entfernt");
ok((await confirmTrialSignup(tokenExp)).status === "invalid", "abgelaufener Link bleibt ungültig");
console.log("\n— Bestätigung → Provisionierung (mit Beispieldaten, Modulauswahl) —");
const confirmed = await confirmTrialSignup(token2);
ok(confirmed.status === "ok", "Bestätigung → Mandant provisioniert");
if (confirmed.status !== "ok") throw new Error("confirm failed");
const tenantA = await prisma.tenant.findUniqueOrThrow({ where: { id: confirmed.tenantId } });
const endKey = values().trialEndDate;
ok(tenantA.plan === "TRIAL" && tenantA.trialSource === "self_signup" && !!tenantA.trialStartedAt, "Plan TRIAL, Herkunft Selbstanmeldung");
ok(tenantA.trialEndsAt?.getTime() === trialEndInstant(endKey).getTime(), "trialEndsAt = Ende des gewählten Tages (Europe/Berlin)");
ok(tenantA.deletionDueAt?.getTime() === trialEndInstant(endKey).getTime() + 30 * 86_400_000, "Löschung 30 Tage nach Ende vorgemerkt");
ok(tenantA.slug === "zz-l15-signup-sanitar", "Slug aus dem Firmennamen");
const admin = await prisma.user.findFirstOrThrow({ where: { tenantId: tenantA.id }, include: { userRoles: { include: { role: true } }, identity: true } });
ok(admin.userRoles.some((r) => r.role.key === "tenant-admin") && admin.email === `paula${DOMAIN}`, "Admin mit Rolle tenant-admin");
ok(await verifyPassword(admin.identity.passwordHash, PASSWORD), "Login-Passwort = Passwort aus dem Wizard");
ok(!admin.identity.mustChangePassword, "kein Passwortzwang für die Selbstanmeldung");
const signupAfter = await prisma.trialSignup.findFirstOrThrow({ where: { tokenHash: hashSignupToken(token2) } });
ok(signupAfter.status === "confirmed" && signupAfter.passwordHash === "" && signupAfter.provisionedTenantId === tenantA.id, "Anmeldung bestätigt, Passwort-Hash aus der Anmeldung entfernt");
const modules = await prisma.tenantModule.findMany({ where: { tenantId: tenantA.id } });
ok(modules.length === MODULE_KEYS.length && modules.find((m) => m.moduleKey === "lotse")?.enabled === false && modules.filter((m) => m.enabled).length === MODULE_KEYS.length - 1, "abgewähltes Modul deaktiviert, übrige aktiv");
const dbA = dbForTenant(tenantA.id);
ok((await dbA.customer.count()) === 3 && (await dbA.site.count()) === 3 && (await dbA.workOrder.count()) === 4, "Beispieldaten: 3 Kunden, 3 Objekte, 4 Aufträge");
ok((await dbA.team.count({ where: { name: SAMPLE_TEAM_NAME } })) === 1 && (await dbA.workOrder.count({ where: { status: "assigned" } })) === 1, "Beispieldaten: Team + zugewiesener Auftrag (über die Fachservices)");
ok((await prisma.auditLog.count({ where: { tenantId: tenantA.id, entity: "trial", action: "create" } })) === 1, "Audit: Testphase angelegt");
ok((await confirmTrialSignup(token2)).status === "invalid", "Einmalverwendung: zweiter Klick → ungültig");
console.log("\n— Enumeration-Schutz —");
const mailEnum = captureMail();
const known = await submitTrialSignup(values({ companyName: "ZZ L15 Doppelt" }), { ip: "198.51.100.9", sendMail: mailEnum.fn });
const unknown = await submitTrialSignup(values({ companyName: "ZZ L15 Neu", email: `neu${DOMAIN}` }), { ip: "198.51.100.9", sendMail: mailEnum.fn });
ok(JSON.stringify(known) === JSON.stringify(unknown), "bestehende und neue Adresse → identische Antwort");
ok(mailEnum.sent[0].template === "trial_existing_account" && mailEnum.sent[0].to === `paula${DOMAIN}` && mailEnum.sent[1].template === "trial_confirm", "bestehende Adresse → Hinweis-Mail statt Bestätigungslink");
ok(!JSON.stringify(mailEnum.sent[0].vars).includes("token="), "Hinweis-Mail enthält keinen Aktivierungslink");
ok((await prisma.trialSignup.count({ where: { email: `paula${DOMAIN}`, status: "pending" } })) === 0, "bestehende Adresse → keine offene Anmeldung");
console.log("\n— Rate-Limit je IP und je E-Mail —");
resetRateLimits();
const ipA = "203.0.113.10";
const results = Array.from({ length: 6 }, () => enforceTrialRateLimit("trialSignup", { ip: ipA, email: `rl${DOMAIN}` }).allowed);
ok(results.slice(0, 5).every(Boolean) && results[5] === false, "je IP/E-Mail: 5 Anmeldungen pro Stunde, die 6. wird abgewiesen");
ok(enforceTrialRateLimit("trialSignup", { ip: "203.0.113.11", email: `rl${DOMAIN}` }).allowed === false, "gleiche E-Mail von anderer IP → weiter gesperrt");
ok(enforceTrialRateLimit("trialSignup", { ip: ipA, email: `anders${DOMAIN}` }).allowed === false, "gleiche IP mit anderer E-Mail → weiter gesperrt");
ok(enforceTrialRateLimit("trialSignup", { ip: "203.0.113.12", email: `anders${DOMAIN}` }).allowed, "andere IP + andere E-Mail → erlaubt");
const retry = enforceTrialRateLimit("trialSignup", { ip: ipA, email: null });
ok(!retry.allowed && retry.retryAfterSeconds > 0, "Retry-After gesetzt");
resetRateLimits();
const actionsSrc = readFileSync("src/server/actions/trial-signup.ts", "utf8");
const exported = [...actionsSrc.matchAll(/export async function (\w+)/g)].map((m) => m[1]);
ok(exported.length === 3 && exported.every((name) => new RegExp(`export async function ${name}[\\s\\S]*?enforceTrialRateLimit\\(`).test(actionsSrc)), "jede öffentliche Action prüft das Rate-Limit");
ok(readFileSync("scripts/check-module-guards.ts", "utf8").includes('"trial-signup.ts": "PUBLIC"'), "Guard-Check erzwingt Rate-Limit für öffentliche Actions");
ok(readFileSync("src/proxy.ts", "utf8").includes('"/testen"'), "/testen ist ohne Login erreichbar (Proxy)");
console.log("\n— ohne Beispieldaten, Slug-Kollision, Mandantentrennung —");
const mailB = captureMail();
await submitTrialSignup(values({ email: `bernd${DOMAIN}`, adminName: "Bernd Probe", sampleData: false }), { ip: "198.51.100.20", sendMail: mailB.fn });
const confirmedB = await confirmTrialSignup(tokenOf((mailB.sent[0].vars as { actionUrl: string }).actionUrl));
ok(confirmedB.status === "ok", "zweite Anmeldung mit gleichem Firmennamen bestätigt");
if (confirmedB.status !== "ok") throw new Error("confirm B failed");
ok(confirmedB.tenantSlug === "zz-l15-signup-sanitar-2" && confirmedB.tenantId !== tenantA.id, "Slug-Kollision automatisch gelöst (-2), eigener Mandant");
const dbB = dbForTenant(confirmedB.tenantId);
ok((await dbB.customer.count()) === 0 && (await dbB.workOrder.count()) === 0, "ohne Beispieldaten → leerer Mandant");
ok((await dbB.tenantModule.count({ where: { enabled: true } })) === MODULE_KEYS.length, "Standard: alle Module aktiv");
const customerA = await dbA.customer.findFirstOrThrow();
ok((await dbB.customer.findFirst({ where: { id: customerA.id } })) === null, "Mandant B sieht die Kunden von A nicht");
let crossWrite = false;
try {
await dbB.customer.update({ where: { id: customerA.id }, data: { city: "Hack" } });
crossWrite = true;
} catch {
crossWrite = false;
}
ok(!crossWrite && (await dbA.customer.findFirstOrThrow({ where: { id: customerA.id } })).city !== "Hack", "Mandant B kann Kunden von A nicht ändern");
ok(addDaysToKey("2026-12-31", 1) === "2027-01-01", "Datumsrechnung über den Jahreswechsel");
await cleanupL15();
await prisma.$disconnect();
console.log(failures ? `\n${failures} Prüfung(en) fehlgeschlagen` : "\nAlle Prüfungen bestanden");
process.exit(failures ? 1 : 0);
}
main().catch(async (err) => {
console.error(err);
await cleanupL15().catch(() => undefined);
process.exit(1);
});
+9 -3
View File
@@ -26,6 +26,9 @@ import { WORK_ORDER_PRIORITIES } from "@/lib/work-orders/schemas";
import { getDashboardTiles } from "@/server/services/work-orders/dashboard"; import { getDashboardTiles } from "@/server/services/work-orders/dashboard";
import { customerDisplayName, customerFilterOptions, teamOptions, userOptions } from "@/server/services/work-orders/options"; import { customerDisplayName, customerFilterOptions, teamOptions, userOptions } from "@/server/services/work-orders/options";
import { listOrderTypes } from "@/server/services/work-orders/settings"; import { listOrderTypes } from "@/server/services/work-orders/settings";
import { GettingStarted } from "@/components/trial/getting-started";
import { ProfiHint } from "@/components/plans/profi-hint";
import { effectiveModules } from "@/server/plan";
type SP = Record<string, string | string[] | undefined>; type SP = Record<string, string | string[] | undefined>;
@@ -53,8 +56,9 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
const t = await getTranslations("dashboard"); const t = await getTranslations("dashboard");
const tw = await getTranslations("workOrders"); const tw = await getTranslations("workOrders");
const moduleRow = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "work_orders" }, select: { enabled: true } }); // L17 Pakete: effektive Module (Schalter + Paketstufe) zentral
const moduleEnabled = !moduleRow || moduleRow.enabled; const modules = await effectiveModules(ctx.tenantId);
const moduleEnabled = !modules.inactive.has("work_orders");
const f = parseListParams(sp); const f = parseListParams(sp);
const filter = { from: f.from, to: f.to, customerId: f.customerId, teamId: f.teamId, userId: f.userId, orderTypeId: f.orderTypeId, priority: f.priority }; const filter = { from: f.from, to: f.to, customerId: f.customerId, teamId: f.teamId, userId: f.userId, orderTypeId: f.orderTypeId, priority: f.priority };
@@ -65,11 +69,13 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
return ( return (
<main className="flex-1 p-4 md:p-6"> <main className="flex-1 p-4 md:p-6">
<PageHead crumb={t("crumb")} title={t("title")} sub={t("subtitle", { name: session.user.name ?? "", tenant: session.user.tenantSlug ?? "" })} /> <PageHead crumb={t("crumb")} title={t("title")} sub={t("subtitle", { name: session.user.name ?? "", tenant: session.user.tenantSlug ?? "" })} />
<GettingStarted ctx={ctx} welcome={sp.welcome === "1"} /> {/* L15 Testphase */}
{sp.module === "disabled" && ( {sp.module === "disabled" && (
<p role="status" className="mb-4 rounded-lg border border-[var(--warn)] bg-card px-4 py-3 text-sm text-[var(--warn)]"> <p role="status" className="mb-4 rounded-lg border border-[var(--warn)] bg-card px-4 py-3 text-sm text-[var(--warn)]">
{t("moduleDisabled")} {t("moduleDisabled")}
</p> </p>
)} )}
{sp.module === "profi" && <ProfiHint />} {/* L17 Pakete: Funktion nicht in der Paketstufe */}
{!tiles ? ( {!tiles ? (
<p className="rounded-lg border bg-card px-4 py-3 text-sm text-muted-foreground">{t("workOrdersDisabled")}</p> <p className="rounded-lg border bg-card px-4 py-3 text-sm text-muted-foreground">{t("workOrdersDisabled")}</p>
@@ -146,7 +152,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
</details> </details>
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{TILES.filter((tile) => (tile.key !== "sync_conflicts" || can("work_order:write")) && (tile.key !== "time_approvals" || can("time:approve")) && (tile.key !== "billing_ready" || can("billing:read"))).map(({ key, icon: Icon, tone }) => { {TILES.filter((tile) => (tile.key !== "sync_conflicts" || can("work_order:write")) && (tile.key !== "time_approvals" || can("time:approve")) && (tile.key !== "billing_ready" || (can("billing:read") && !modules.inactive.has("billing"))) && (tile.key !== "emergency_new" || !modules.inactive.has("emergency"))).map(({ key, icon: Icon, tone }) => {
const count = key === "billing_ready" ? tiles.billingReady : key === "sync_conflicts" ? tiles.syncConflicts : key === "time_approvals" ? tiles.timeApprovals : key === "reports_in_review" ? tiles.reportsToReview : tiles[key]; const count = key === "billing_ready" ? tiles.billingReady : key === "sync_conflicts" ? tiles.syncConflicts : key === "time_approvals" ? tiles.timeApprovals : key === "reports_in_review" ? tiles.reportsToReview : tiles[key];
const href = key === "billing_ready" ? "/billing" : key === "sync_conflicts" ? "/work-orders/conflicts" : key === "time_approvals" ? "/work-orders/time-approvals" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`; const href = key === "billing_ready" ? "/billing" : key === "sync_conflicts" ? "/work-orders/conflicts" : key === "time_approvals" ? "/work-orders/time-approvals" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`;
return ( return (
+2
View File
@@ -3,6 +3,7 @@ import { assertSameOrigin, requireApiContext } from "@/server/api/context";
import { ApiError, toErrorResponse } from "@/server/api/respond"; import { ApiError, toErrorResponse } from "@/server/api/respond";
import { ServiceError } from "@/server/services/context"; import { ServiceError } from "@/server/services/context";
import { storeFile } from "@/server/services/documents/store"; import { storeFile } from "@/server/services/documents/store";
import { assertTenantWritable } from "@/server/services/trial/state";
/** /**
* Multipart upload for the backoffice document tabs (customer, site, /documents). * Multipart upload for the backoffice document tabs (customer, site, /documents).
@@ -19,6 +20,7 @@ export async function POST(req: Request) {
try { try {
assertSameOrigin(req); assertSameOrigin(req);
const ctx = await requireApiContext("documents"); const ctx = await requireApiContext("documents");
await assertTenantWritable(ctx.tenantId); // L15: expired trial → read-only
let form: FormData; let form: FormData;
try { try {
+1 -1
View File
@@ -15,7 +15,7 @@ const INLINE = new Set(["application/pdf", "image/jpeg", "image/png"]);
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
let file: { storageKey: string; mimeType: string; fileName: string }; let file: { storageKey: string; mimeType: string; fileName: string };
try { try {
const ctx = ctxFromGuard(await moduleGuard("imports")("import:write")); const ctx = ctxFromGuard(await moduleGuard("imports", { read: true })("import:write")); // L15: GET download — no trial write lock
const { id } = await params; const { id } = await params;
file = await getImportFile(ctx, id); file = await getImportFile(ctx, id);
} catch { } catch {
+9 -4
View File
@@ -3,6 +3,7 @@ import { getTranslations } from "next-intl/server";
import { signOut } from "@/server/auth"; import { signOut } from "@/server/auth";
import { dbForTenant } from "@/server/db"; import { dbForTenant } from "@/server/db";
import { requireAppAccess } from "@/server/app-access"; import { requireAppAccess } from "@/server/app-access";
import { effectiveModules } from "@/server/plan";
import { NAV_ITEMS, visibleNavItems } from "@/lib/nav"; import { NAV_ITEMS, visibleNavItems } from "@/lib/nav";
import { resolveTenantBranding } from "@/lib/brand"; import { resolveTenantBranding } from "@/lib/brand";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -14,6 +15,7 @@ import { CraftviaLogo } from "@/components/brand/craftvia-logo";
import { NotificationBell } from "@/components/notifications/bell"; import { NotificationBell } from "@/components/notifications/bell";
import { AccountInactiveNotice } from "@/components/account-inactive-notice"; import { AccountInactiveNotice } from "@/components/account-inactive-notice";
import { BackofficeFrame } from "@/components/backoffice-frame"; import { BackofficeFrame } from "@/components/backoffice-frame";
import { TrialBanner } from "@/components/trial/trial-banner";
export default async function AppLayout({ export default async function AppLayout({
children, children,
@@ -29,17 +31,18 @@ export default async function AppLayout({
// Navigation aus src/lib/nav.ts, gefiltert nach aktiven Modulen + Rechten (Komfort; // Navigation aus src/lib/nav.ts, gefiltert nach aktiven Modulen + Rechten (Komfort;
// Seiten/Actions prüfen serverseitig selbst). // Seiten/Actions prüfen serverseitig selbst).
const [moduleRows, brandingSettings] = await Promise.all([ // L17 Pakete: effektive Module (TenantModule + Paketstufe) zentral aus src/server/plan.ts
dbForTenant(session.user.tenantId).tenantModule.findMany(), const [modules, brandingSettings] = await Promise.all([
effectiveModules(session.user.tenantId),
dbForTenant(session.user.tenantId).tenantSettings.findUnique({ dbForTenant(session.user.tenantId).tenantSettings.findUnique({
where: { tenantId: session.user.tenantId }, where: { tenantId: session.user.tenantId },
select: { accent: true }, select: { accent: true },
}), }),
]); ]);
const branding = resolveTenantBranding(brandingSettings); const branding = resolveTenantBranding(brandingSettings);
const disabledModules = new Set(moduleRows.filter((m) => !m.enabled).map((m) => m.moduleKey));
const navItems = visibleNavItems(NAV_ITEMS, { const navItems = visibleNavItems(NAV_ITEMS, {
disabledModules, disabledModules: modules.inactive,
lockedFeatures: modules.lockedFeatures,
permissions: session.user.permissions ?? [], permissions: session.user.permissions ?? [],
}); });
const mainNav = navItems.filter((i) => i.section === "main"); const mainNav = navItems.filter((i) => i.section === "main");
@@ -111,6 +114,8 @@ export default async function AppLayout({
</form> </form>
</> </>
}> }>
{/* L15 Testphase: Countdown ab 7 Tagen bzw. Nur-Lesen-Hinweis */}
<TrialBanner tenantId={session.user.tenantId} variant="backoffice" canExport={(session.user.permissions ?? []).includes("tenant:manage")} />
{children} {children}
</BackofficeFrame> </BackofficeFrame>
); );
+4 -2
View File
@@ -1,7 +1,9 @@
import { requireModule } from "@/server/modules"; import { requireModule } from "@/server/modules";
import { requirePlanFeature } from "@/server/plan";
/** Modul-Gate „work_orders" für Plantafel und Live-Lage (L13 Planung). */ /** Modul-Gate „work_orders" + Paket-Feature „planning" (L17) für Plantafel und Live-Lage (L13 Planung). */
export default async function PlanningLayout({ children }: Readonly<{ children: React.ReactNode }>) { export default async function PlanningLayout({ children }: Readonly<{ children: React.ReactNode }>) {
await requireModule("work_orders"); const session = await requireModule("work_orders");
await requirePlanFeature(session.user.tenantId, "planning"); // L17 Pakete: Planung nur in Profi
return <>{children}</>; return <>{children}</>;
} }
@@ -0,0 +1,27 @@
import { requireApiContext } from "@/server/api/context";
import { toErrorResponse } from "@/server/api/respond";
import { openTenantExport } from "@/server/services/trial/export";
/**
* GET /settings/export/<id> — download of a finished tenant data export (L15 Testphase).
* Session + DB-authoritative `tenant:manage`, tenant-bound row; a read, therefore also allowed for
* an expired (read-only) trial tenant.
*/
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const ctx = await requireApiContext(null, "tenant:manage");
const { id } = await params;
const { bytes, fileName } = await openTenantExport(ctx, id);
return new Response(new Uint8Array(bytes), {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${fileName.replace(/[^\w.\-]/g, "_")}"`,
"Content-Length": String(bytes.length),
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
},
});
} catch (err) {
return toErrorResponse(err);
}
}
+73
View File
@@ -0,0 +1,73 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { Download, PackageOpen } from "lucide-react";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission } from "@/server/rbac";
import { PageHead, Pill } from "@/components/mockup-ui";
import { Button } from "@/components/ui/button";
import { formatInstantDate } from "@/lib/trial/dates";
import { requestExportAction } from "@/server/actions/trial-tenant";
import { listTenantExports } from "@/server/services/trial/export";
const TONE = { queued: "warn", running: "warn", done: "ok", failed: "mut", expired: "mut" } as const;
/** L15 Testphase: data export for tenant administrators (also available in the read-only state). */
export default async function DataExportPage({ searchParams }: { searchParams: Promise<{ requested?: string; error?: string }> }) {
const session = await requireSession();
if (!hasPermission(session, "tenant:manage")) redirect("/dashboard");
const sp = await searchParams;
const [t, locale] = await Promise.all([getTranslations("trial.export"), getLocale()]);
const ctx = { db: dbForTenant(session.user.tenantId), tenantId: session.user.tenantId, userId: session.user.id, permissions: new Set(session.user.permissions ?? []) };
const exports = await listTenantExports(ctx);
const errorKey = sp.error === "export_running" ? "export_running" : "failed";
return (
<main className="flex-1 p-4 md:p-6">
<PageHead crumb={t("crumb")} title={t("title")} sub={t("sub")} />
<div className="shadow-card rounded-xl border bg-card p-5">
<div className="flex flex-wrap items-start gap-4">
<PackageOpen className="size-6 shrink-0 text-[var(--ui-primary)]" aria-hidden />
<div className="min-w-0 flex-1">
<p className="text-sm">{t("contents")}</p>
<p className="mt-1 text-[12.5px] text-muted-foreground">{t("readOnlyHint")}</p>
</div>
<form action={requestExportAction}>
<Button type="submit" className="h-11">{t("request")}</Button>
</form>
</div>
{sp.requested && <p role="status" className="mt-3 rounded-lg bg-[rgba(57,192,127,0.14)] px-3 py-2 text-sm text-[var(--ok)]">{t("requested")}</p>}
{sp.error && <p role="alert" className="mt-3 rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{t(`errors.${errorKey}`)}</p>}
</div>
<div className="shadow-card mt-4 rounded-xl border bg-card p-5">
<h2 className="font-heading text-[15px] font-semibold">{t("listTitle")}</h2>
{exports.length === 0 ? (
<p className="mt-2 text-sm text-muted-foreground">{t("empty")}</p>
) : (
<ul className="mt-2 divide-y">
{exports.map((e) => {
const status = e.expired ? "expired" : (e.status as keyof typeof TONE);
return (
<li key={e.id} className="flex flex-wrap items-center gap-3 py-2.5 text-sm">
<Pill tone={TONE[status] ?? "mut"}>{t(`status.${status}`)}</Pill>
<span className="text-muted-foreground">{t("created")}: {e.createdAt.toLocaleString(locale === "en" ? "en-GB" : "de-DE", { timeZone: "Europe/Berlin" })}</span>
{e.bytes != null && <span className="text-muted-foreground">{(e.bytes / 1024 / 1024).toFixed(1)} MB</span>}
{e.status === "failed" && e.error && <span className="text-[12.5px] text-[var(--risk)]">{e.error}</span>}
{e.status === "done" && !e.expired && (
<Link href={`/settings/export/${e.id}`} prefetch={false} className="ml-auto inline-flex min-h-11 items-center gap-1.5 rounded-md px-3 font-semibold text-[var(--ui-primary)] hover:bg-muted">
<Download className="size-4" aria-hidden /> {t("download")}
{e.expiresAt && <span className="font-normal text-muted-foreground">({t("expires", { date: formatInstantDate(e.expiresAt, locale) })})</span>}
</Link>
)}
</li>
);
})}
</ul>
)}
</div>
</main>
);
}
+15 -2
View File
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
import { ArrowLeft, CheckCircle2, ListChecks, MinusCircle, ShieldCheck, XCircle } from "lucide-react"; import { ArrowLeft, CheckCircle2, ListChecks, MinusCircle, ShieldCheck, XCircle } from "lucide-react";
import { LotseMark } from "@/components/lotse/lotse-mark"; import { LotseMark } from "@/components/lotse/lotse-mark";
import { LotseSeatAdmin } from "@/components/lotse/chat/seat-admin";
import { PageHead } from "@/components/mockup-ui"; import { PageHead } from "@/components/mockup-ui";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { saveLotseSettings } from "@/server/actions/lotse-settings"; import { saveLotseSettings } from "@/server/actions/lotse-settings";
@@ -24,7 +25,7 @@ function ProviderStatus({ ok, labels }: { ok: boolean; labels: { ok: string; off
* /settings/lotse (tenant:manage): Lotse on/off (module toggle `lotse`), address form, transparency on * /settings/lotse (tenant:manage): Lotse on/off (module toggle `lotse`), address form, transparency on
* which data goes to which provider, link to the AI log. Deliberately not module-gated (re-enabling). * which data goes to which provider, link to the AI log. Deliberately not module-gated (re-enabling).
*/ */
export default async function LotseSettingsPage({ searchParams }: { searchParams: Promise<{ saved?: string; error?: string }> }) { export default async function LotseSettingsPage({ searchParams }: { searchParams: Promise<{ saved?: string; error?: string; seat?: string; seatError?: string }> }) {
const ctx = await readCtx(); const ctx = await readCtx();
if (!can(ctx, "tenant:manage")) redirect("/dashboard"); if (!can(ctx, "tenant:manage")) redirect("/dashboard");
const [sp, s, t, locale] = await Promise.all([searchParams, getLotseSettings(ctx), getTranslations("lotse"), getLocale()]); const [sp, s, t, locale] = await Promise.all([searchParams, getLotseSettings(ctx), getTranslations("lotse"), getLocale()]);
@@ -61,6 +62,15 @@ export default async function LotseSettingsPage({ searchParams }: { searchParams
<span className="block text-[12px] text-muted-foreground">{t("settings.enabledHint")}</span> <span className="block text-[12px] text-muted-foreground">{t("settings.enabledHint")}</span>
</span> </span>
</label> </label>
{/* L16 Lotse-Chat für Monteure */}
<input type="hidden" name="chatEnabledPresent" value="1" />
<label className="flex min-h-11 cursor-pointer items-start gap-3">
<input type="checkbox" name="chatEnabled" defaultChecked={s.chatEnabled} className="mt-0.5 size-5 accent-[var(--ui-accent)]" />
<span>
<span className="block text-sm font-semibold">{t("settings.chatEnabled")}</span>
<span className="block text-[12px] text-muted-foreground">{t("settings.chatEnabledHint")}</span>
</span>
</label>
<fieldset> <fieldset>
<legend className="text-sm font-semibold">{t("settings.addressForm")}</legend> <legend className="text-sm font-semibold">{t("settings.addressForm")}</legend>
<p className="text-[12px] text-muted-foreground">{t("settings.addressFormHint")}</p> <p className="text-[12px] text-muted-foreground">{t("settings.addressFormHint")}</p>
@@ -127,7 +137,7 @@ export default async function LotseSettingsPage({ searchParams }: { searchParams
<div> <div>
<h3 className="font-semibold">{t("settings.sentTitle")}</h3> <h3 className="font-semibold">{t("settings.sentTitle")}</h3>
<ul className="mt-1 list-disc space-y-1 pl-5 text-muted-foreground"> <ul className="mt-1 list-disc space-y-1 pl-5 text-muted-foreground">
{(["order", "notes", "work", "audio"] as const).map((k) => ( {(["order", "notes", "work", "audio", "chat", "chatAudio"] as const).map((k) => (
<li key={k}>{t(`settings.sent.${k}`)}</li> <li key={k}>{t(`settings.sent.${k}`)}</li>
))} ))}
</ul> </ul>
@@ -150,6 +160,9 @@ export default async function LotseSettingsPage({ searchParams }: { searchParams
</Link> </Link>
</section> </section>
</div> </div>
{/* L17 Pakete: Paket (nur lesend), Lotse-Chat-Plätze, Monatsverbrauch */}
<LotseSeatAdmin ctx={ctx} seatSaved={sp.seat === "saved"} seatError={sp.seatError} />
</main> </main>
); );
} }
+4 -3
View File
@@ -12,6 +12,7 @@ import { BRAND } from "@/lib/brand";
import { updateTenantSettings } from "@/server/actions/tenant-settings"; import { updateTenantSettings } from "@/server/actions/tenant-settings";
import { ShieldCheck, History } from "lucide-react"; import { ShieldCheck, History } from "lucide-react";
import { AuditTrailModal, type AuditRow } from "@/components/audit-trail"; import { AuditTrailModal, type AuditRow } from "@/components/audit-trail";
import { effectiveModules } from "@/server/plan";
// TODO(i18n): Texte dieser Seite in messages/<locale>/settings.json überführen // TODO(i18n): Texte dieser Seite in messages/<locale>/settings.json überführen
// (aus dem Fundament übernommen; nicht Teil des Rückbaus). // (aus dem Fundament übernommen; nicht Teil des Rückbaus).
@@ -28,9 +29,9 @@ export default async function SettingsPage({
const db = dbForTenant(session.user.tenantId); const db = dbForTenant(session.user.tenantId);
const sp = await searchParams; const sp = await searchParams;
const [s, moduleRows] = await Promise.all([ const [s, modules] = await Promise.all([
db.tenantSettings.findUnique({ where: { tenantId: session.user.tenantId } }), db.tenantSettings.findUnique({ where: { tenantId: session.user.tenantId } }),
db.tenantModule.findMany(), effectiveModules(session.user.tenantId), // L17 Pakete: Schalter + Paketstufe
]); ]);
// Audit-Trail nur laden, wenn das Popup offen ist (?audit=1). Der Mandanten- // Audit-Trail nur laden, wenn das Popup offen ist (?audit=1). Der Mandanten-
@@ -50,7 +51,7 @@ export default async function SettingsPage({
} }
} }
// Fehlende TenantModule-Zeile ⇒ Modul gilt als aktiv (Default an). // Fehlende TenantModule-Zeile ⇒ Modul gilt als aktiv (Default an).
const disabled = new Set(moduleRows.filter((m) => !m.enabled).map((m) => m.moduleKey)); const disabled = modules.inactive;
const v = (x?: string | null) => x ?? ""; const v = (x?: string | null) => x ?? "";
return ( return (
+4 -5
View File
@@ -29,7 +29,7 @@ import { FINAL_STATUSES, PLANNING_LOCKED } from "@/server/services/work-orders/_
import { availableTransitions, getWorkOrderDetail } from "@/server/services/work-orders/detail"; import { availableTransitions, getWorkOrderDetail } from "@/server/services/work-orders/detail";
import { customerDisplayName, customerOption, teamOptions, userOptions } from "@/server/services/work-orders/options"; import { customerDisplayName, customerOption, teamOptions, userOptions } from "@/server/services/work-orders/options";
import { listOrderTypes } from "@/server/services/work-orders/settings"; import { listOrderTypes } from "@/server/services/work-orders/settings";
import { reasonRequired } from "@/server/services/work-orders/transition"; import { billingModuleActive, reasonRequired } from "@/server/services/work-orders/transition";
const TABS = ["overview", "checklist", "material", "times", "photos", "notes", "reports", "billing", "documents", "history"] as const; // L14: billing const TABS = ["overview", "checklist", "material", "times", "photos", "notes", "reports", "billing", "documents", "history"] as const; // L14: billing
type Tab = (typeof TABS)[number]; type Tab = (typeof TABS)[number];
@@ -103,8 +103,7 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa
]); ]);
// L14: with the billing overview active, "billed" is set only there (snapshot + position assignment) → no direct button // L14: with the billing overview active, "billed" is set only there (snapshot + position assignment) → no direct button
const billingModule = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "billing" }, select: { enabled: true } }); const billingOverviewActive = await billingModuleActive(ctx); // L17: incl. package tier
const billingOverviewActive = !billingModule || billingModule.enabled;
const billingTransitions = transitions.filter( const billingTransitions = transitions.filter(
(to) => (to) =>
(to === "released_for_billing" || (to === "billed" && !billingOverviewActive)) || (to === "released_for_billing" || (to === "billed" && !billingOverviewActive)) ||
@@ -188,7 +187,7 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa
<div className="mt-4"> <div className="mt-4">
<LinkTabs <LinkTabs
label={t("detail.tabs.overview")} label={t("detail.tabs.overview")}
items={TABS.map((k) => ({ href: `/work-orders/${wo.id}${k === "overview" ? "" : `?tab=${k}`}`, label: t(`detail.tabs.${k}`), active: tab === k }))} items={TABS.filter((k) => k !== "billing" || billingOverviewActive).map((k) => ({ href: `/work-orders/${wo.id}${k === "overview" ? "" : `?tab=${k}`}`, label: t(`detail.tabs.${k}`), active: tab === k }))}
/> />
</div> </div>
@@ -211,7 +210,7 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa
} }
/> />
)} )}
{tab === "billing" && <WorkOrderBillingTab ctx={ctx} wo={wo} locale={locale} tz={tz} />} {tab === "billing" && billingOverviewActive && <WorkOrderBillingTab ctx={ctx} wo={wo} locale={locale} tz={tz} />}
{tab === "documents" && <DocumentsTab {...tabProps} uploadError={one(sp.uploadError)} uploaded={one(sp.uploaded) === "1"} />} {tab === "documents" && <DocumentsTab {...tabProps} uploadError={one(sp.uploadError)} uploaded={one(sp.uploaded) === "1"} />}
{tab === "history" && <HistoryTab {...tabProps} />} {tab === "history" && <HistoryTab {...tabProps} />}
</div> </div>
+15
View File
@@ -0,0 +1,15 @@
import { requireSession } from "@/server/auth";
import { requireModule } from "@/server/modules";
import { moduleState } from "@/server/plan";
import { LotseChatUnavailable } from "@/components/lotse/chat/unavailable";
/**
* Modul-Gate „lotse" für den Lotse-Chat (L16); „field" gilt bereits über (core)/layout.tsx.
* L17 Pakete: nicht in der Paketstufe (Basis) → freundlicher Hinweis statt Weiterleitung aufs Dashboard.
*/
export default async function LotseChatLayout({ children }: Readonly<{ children: React.ReactNode }>) {
const session = await requireSession();
if ((await moduleState(session.user.tenantId, "lotse")) === "not_in_plan") return <LotseChatUnavailable reason="not_in_plan" />;
await requireModule("lotse");
return <>{children}</>;
}
+59
View File
@@ -0,0 +1,59 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { ChevronLeft } from "lucide-react";
import type { ChatView } from "@/lib/lotse/chat";
import { isAiConfigured } from "@/server/ai/client";
import { transcriptionConfig } from "@/server/ai/transcription/openai-compatible";
import { ServiceError } from "@/server/services/context";
import { fieldPageContext } from "@/server/services/field/page-context";
import { isChatQuotaBlocked } from "@/server/services/lotse/chat/access";
import { getChatView } from "@/server/services/lotse/chat/conversations";
import { tenantTimezone } from "@/server/services/work-orders/_shared";
import { LotseMark } from "@/components/lotse/lotse-mark";
import { LotseChat } from "@/components/lotse/chat/lotse-chat";
import { LotseChatUnavailable } from "@/components/lotse/chat/unavailable";
/**
* `/m/lotse` — Lotse chat for technicians (lane L16). `?order=<id>` opens the chat with order context
* (button „Lotse fragen" in the order detail). Nothing is executed without confirmation.
*/
export default async function LotseChatPage({ searchParams }: { searchParams: Promise<{ order?: string }> }) {
const [ctx, sp, t, locale] = await Promise.all([fieldPageContext(), searchParams, getTranslations("lotse"), getLocale()]);
let view: ChatView;
try {
view = await getChatView(ctx, { workOrderId: sp.order?.trim() || null });
} catch (err) {
if (!(err instanceof ServiceError)) throw err;
if (err.code === "not_found") notFound();
const reason = (err.details as { reason?: string } | undefined)?.reason;
const code = reason && ["disabled", "chat_disabled", "not_in_plan", "no_seat"].includes(reason) ? reason : err.code === "forbidden" ? "forbidden" : "generic";
return <LotseChatUnavailable reason={code} />;
}
const [timeZone, quotaExhausted] = await Promise.all([tenantTimezone(ctx), isChatQuotaBlocked(ctx)]); // L17: hard limit → hint
return (
<main className="space-y-3 p-4">
{view.workOrder && (
<Link href={`/m/orders/${view.workOrder.id}`} className="-ml-2 inline-flex min-h-12 items-center gap-1 rounded-xl px-2 text-[15px] font-semibold text-primary">
<ChevronLeft className="size-5" aria-hidden />
{t("chat.context", { number: view.workOrder.number, title: view.workOrder.title })}
</Link>
)}
<h1 className="flex items-center gap-2 text-[22px]">
<LotseMark label={t("chat.title")} />
</h1>
<LotseChat
key={view.conversationId ?? `new-${view.workOrder?.id ?? "general"}`}
initial={view}
workOrderId={view.workOrder?.id ?? null}
configured={isAiConfigured()}
transcription={transcriptionConfig().configured}
locale={locale}
timeZone={timeZone}
quotaExhausted={quotaExhausted}
/>
</main>
);
}
@@ -33,6 +33,7 @@ import { card, toneClasses } from "@/components/field/ui";
import { loadOrder } from "./load"; import { loadOrder } from "./load";
import { LotseCompletenessCard } from "@/components/lotse/completeness-card"; import { LotseCompletenessCard } from "@/components/lotse/completeness-card";
import { MilestonesSection } from "@/components/field/milestones"; import { MilestonesSection } from "@/components/field/milestones";
import { LotseAskButton } from "@/components/lotse/chat/ask-button";
function Section({ title, icon: Icon, children, href, summary }: { title: string; icon: LucideIcon; children?: React.ReactNode; href?: string; summary?: string }) { function Section({ title, icon: Icon, children, href, summary }: { title: string; icon: LucideIcon; children?: React.ReactNode; href?: string; summary?: string }) {
const head = ( const head = (
@@ -168,6 +169,7 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id
)} )}
{editable && <LotseCompletenessCard workOrderId={order.id} />} {editable && <LotseCompletenessCard workOrderId={order.id} />}
{editable && <LotseAskButton ctx={ctx} workOrderId={order.id} />}{/* L16 Lotse-Chat */}
<MilestonesSection ctx={ctx} workOrderId={order.id} editable={editable} />{/* L14 Abrechnungsübersicht */} <MilestonesSection ctx={ctx} workOrderId={order.id} editable={editable} />{/* L14 Abrechnungsübersicht */}
+1 -1
View File
@@ -6,7 +6,7 @@ import { EmergencyWizard } from "@/components/emergency/emergency-wizard";
/** `/m/emergency` — Notdienst erfassen (spec §19.2, US-010): max. 3 steps, then straight into the call-out. */ /** `/m/emergency` — Notdienst erfassen (spec §19.2, US-010): max. 3 steps, then straight into the call-out. */
export default async function EmergencyPage() { export default async function EmergencyPage() {
const ctx = ctxFromGuard(await moduleGuard("emergency")()); const ctx = ctxFromGuard(await moduleGuard("emergency", { read: true })()); // L15: read path — no trial write lock
const t = await getTranslations("emergency.capture"); const t = await getTranslations("emergency.capture");
if (!can(ctx, "emergency:create") || !can(ctx, "field:execute")) { if (!can(ctx, "emergency:create") || !can(ctx, "field:execute")) {
return <p className="p-4 text-[15px]">{t("noAccess")}</p>; return <p className="p-4 text-[15px]">{t("noAccess")}</p>;
+16 -4
View File
@@ -5,6 +5,8 @@ import { can } from "@/server/services/context";
import { fieldPageContext } from "@/server/services/field/page-context"; import { fieldPageContext } from "@/server/services/field/page-context";
import { getMyActiveSession } from "@/server/services/field/sessions"; import { getMyActiveSession } from "@/server/services/field/sessions";
import { countPendingTimeEntries } from "@/server/services/field/time-entries"; import { countPendingTimeEntries } from "@/server/services/field/time-entries";
import { canUseLotseChat } from "@/server/services/lotse/chat/access";
import { isModuleActive } from "@/server/plan";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CraftviaLogo } from "@/components/brand/craftvia-logo"; import { CraftviaLogo } from "@/components/brand/craftvia-logo";
import { AccountInactiveNotice } from "@/components/account-inactive-notice"; import { AccountInactiveNotice } from "@/components/account-inactive-notice";
@@ -12,21 +14,29 @@ import { BottomNav } from "@/components/field/bottom-nav";
import { OnlineBadge } from "@/components/field/online-badge"; import { OnlineBadge } from "@/components/field/online-badge";
import { RunningClockBar, type ClockSession } from "@/components/field/running-clock-bar"; import { RunningClockBar, type ClockSession } from "@/components/field/running-clock-bar";
import { OfflineRuntime } from "@/components/offline/offline-runtime"; 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). */ /** L12: own running/paused session + open approvals for the shell (never blocks the page). */
async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number }> { async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number; lotseChat: boolean; emergency: boolean }> {
try { try {
const ctx = await fieldPageContext(); const ctx = await fieldPageContext();
const [session, approvals] = await Promise.all([can(ctx, "field:execute") ? getMyActiveSession(ctx) : Promise.resolve(null), countPendingTimeEntries(ctx)]); const [session, approvals, lotseChat, emergency] = await Promise.all([
can(ctx, "field:execute") ? getMyActiveSession(ctx) : Promise.resolve(null),
countPendingTimeEntries(ctx),
canUseLotseChat(ctx),
isModuleActive(ctx.tenantId, "emergency"), // L17 Pakete: Notdienst nur in Profi
]);
return { return {
clock: session clock: session
? { status: session.status, workOrderId: session.workOrderId, number: session.number, title: session.title, segmentType: session.segmentType, segmentStartedAt: session.segmentStartedAt, closedSeconds: session.closedSeconds } ? { status: session.status, workOrderId: session.workOrderId, number: session.number, title: session.title, segmentType: session.segmentType, segmentStartedAt: session.segmentStartedAt, closedSeconds: session.closedSeconds }
: null, : null,
approvals, approvals,
lotseChat, // L16 (L17: inkl. Chat-Platz)
emergency,
}; };
} catch { } catch {
// module "field" disabled or no field permissions: shell without clock/badge // module "field" disabled or no field permissions: shell without clock/badge
return { clock: null, approvals: 0 }; return { clock: null, approvals: 0, lotseChat: false, emergency: true };
} }
} }
@@ -53,9 +63,11 @@ export default async function FieldShell({ children }: Readonly<{ children: Reac
<OnlineBadge /> <OnlineBadge />
</div> </div>
</header> </header>
{/* L15 Testphase: Countdown ab 7 Tagen bzw. Nur-Lesen-Hinweis */}
<TrialBanner tenantId={access.session.user.tenantId} variant="mobile" />
<div className={cn("mx-auto w-full max-w-xl flex-1", time.clock ? "pb-48" : "pb-28")}>{children}</div> <div className={cn("mx-auto w-full max-w-xl flex-1", time.clock ? "pb-48" : "pb-28")}>{children}</div>
<RunningClockBar initial={time.clock} /> <RunningClockBar initial={time.clock} />
<BottomNav approvals={time.approvals} /> <BottomNav approvals={time.approvals} lotse={time.lotseChat} emergency={time.emergency} />
</div> </div>
); );
} }
@@ -0,0 +1,70 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { CtaBand, PageHero, PointList, ShotFrame } from "@/components/marketing/blocks";
import { FEATURE_PAGES, FEATURE_SLUGS, isFeatureSlug } from "@/lib/marketing/pages";
/** Detailseite eines Moduls — Inhalt aus `marketing.pages.<slug>`, Bilder aus public/marketing. */
type Props = { params: Promise<{ slug: string }> };
export function generateStaticParams() {
return FEATURE_SLUGS.map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
if (!isFeatureSlug(slug)) return {};
const t = await getTranslations("marketing");
return { title: t(`pages.${slug}.title`), description: t(`pages.${slug}.lead`) };
}
export default async function FeatureDetailPage({ params }: Props) {
const { slug } = await params;
if (!isFeatureSlug(slug)) notFound();
const page = FEATURE_PAGES[slug];
const t = await getTranslations("marketing");
const points = Array.from({ length: page.points }, (_, i) => t(`pages.${slug}.p${i + 1}`));
const cards = Array.from({ length: page.cards }, (_, i) => ({
title: t(`pages.${slug}.c${i + 1}title`),
text: t(`pages.${slug}.c${i + 1}text`),
}));
const [first, second] = page.shots;
return (
<>
<PageHero eyebrow={t("nav.features")} title={t(`pages.${slug}.title`)} lead={t(`pages.${slug}.lead`)} />
<section aria-label={t(`pages.${slug}.title`)} className="mx-auto max-w-6xl px-4 pb-12 sm:px-6">
<div className={`grid gap-8 ${first.kind === "mobile" ? "lg:grid-cols-[1fr_0.6fr]" : "lg:grid-cols-[0.9fr_1.1fr]"} lg:items-center`}>
<PointList points={points} />
<ShotFrame shot={first} caption={t(`pages.${slug}.shot1`)} priority />
</div>
</section>
<section aria-labelledby="detail-cards" className="border-y bg-card">
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
<h2 id="detail-cards" className="sr-only">
{t(`pages.${slug}.title`)}
</h2>
<ul className="grid gap-4 md:grid-cols-3">
{cards.map((c) => (
<li key={c.title} className="rounded-xl border bg-background p-5">
<h3 className="font-heading text-[16px] font-semibold">{c.title}</h3>
<p className="mt-1.5 text-[14px] leading-relaxed text-muted-foreground">{c.text}</p>
</li>
))}
</ul>
</div>
</section>
{second && (
<section aria-label={t(`pages.${slug}.shot2`)} className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
<ShotFrame shot={second} caption={t(`pages.${slug}.shot2`)} />
</section>
)}
<CtaBand />
</>
);
}
+90
View File
@@ -0,0 +1,90 @@
import type { Metadata } from "next";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ArrowRight, Bot, CalendarRange, ClipboardList, FileInput, FileSignature, FolderOpen, MapPin, Package, Receipt, Siren, Timer, WifiOff, type LucideIcon } from "lucide-react";
import { CtaBand, PageHero } from "@/components/marketing/blocks";
import { FEATURE_SLUGS, type FeatureSlug } from "@/lib/marketing/pages";
/** Übersicht aller Funktionen mit Einstieg in die Detailseiten. */
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("marketing.overview");
return { title: t("title"), description: t("lead") };
}
/** Detailseite je Modul — die übrigen Module stehen als Kacheln ohne eigene Seite. */
const DETAIL_ICON: Record<FeatureSlug, LucideIcon> = {
planung: CalendarRange,
baustelle: WifiOff,
zeiterfassung: Timer,
berichte: FileSignature,
abrechnung: Receipt,
lotse: Bot,
};
const DETAIL_FEATURE: Record<FeatureSlug, string> = {
planung: "board",
baustelle: "offline",
zeiterfassung: "time",
berichte: "reports",
abrechnung: "billing",
lotse: "lotse",
};
const MORE: { key: string; icon: LucideIcon }[] = [
{ key: "orders", icon: ClipboardList },
{ key: "live", icon: MapPin },
{ key: "material", icon: Package },
{ key: "emergency", icon: Siren },
{ key: "documents", icon: FolderOpen },
{ key: "imports", icon: FileInput },
];
export default async function FeaturesOverviewPage() {
const t = await getTranslations("marketing");
return (
<>
<PageHero title={t("overview.title")} lead={t("overview.lead")} />
<section aria-label={t("overview.title")} className="mx-auto max-w-6xl px-4 pb-4 sm:px-6">
<ul className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{FEATURE_SLUGS.map((slug) => {
const Icon = DETAIL_ICON[slug];
return (
<li key={slug} className="shadow-card rounded-xl border bg-card p-5">
<span className="flex size-10 items-center justify-center rounded-lg bg-[var(--ui-primary-soft)] text-[var(--ui-primary)]">
<Icon className="size-5" aria-hidden />
</span>
<h2 className="mt-3 font-heading text-[17px] font-semibold">{t(`pages.${slug}.title`)}</h2>
<p className="mt-1.5 text-[14px] leading-relaxed text-muted-foreground">{t(`pages.${slug}.lead`)}</p>
<Link href={`/funktionen/${slug}`} className="mt-3 inline-flex min-h-11 items-center gap-1.5 text-[14px] font-semibold text-[var(--ui-primary)]">
{t("overview.more")}
<ArrowRight className="size-4" aria-hidden />
</Link>
</li>
);
})}
</ul>
</section>
<section aria-labelledby="more-title" className="mx-auto max-w-6xl px-4 py-10 sm:px-6">
<h2 id="more-title" className="font-heading text-[22px] font-semibold">{t("features.title")}</h2>
<ul className="mt-5 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{MORE.map(({ key, icon: Icon }) => (
<li key={key} className="rounded-xl border bg-card p-5">
<span className="flex size-10 items-center justify-center rounded-lg bg-[var(--ui-primary-soft)] text-[var(--ui-primary)]">
<Icon className="size-5" aria-hidden />
</span>
<h3 className="mt-3 font-heading text-[16px] font-semibold">{t(`features.${key}.title`)}</h3>
<p className="mt-1.5 text-[14px] leading-relaxed text-muted-foreground">{t(`features.${key}.text`)}</p>
</li>
))}
</ul>
<p className="mt-4 text-[13px] text-muted-foreground">
{FEATURE_SLUGS.map((slug) => t(`features.${DETAIL_FEATURE[slug]}.title`)).join(" · ")}
</p>
</section>
<CtaBand />
</>
);
}
+70
View File
@@ -0,0 +1,70 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { IMPRINT } from "@/lib/marketing/imprint";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("marketing.imprint");
return { title: t("title"), robots: { index: true, follow: true } };
}
/** Impressum der Produktseite — Daten aus src/lib/marketing/imprint.ts. */
export default async function ImprintPage() {
const t = await getTranslations("marketing.imprint");
const block = "mt-8 font-heading text-[17px] font-semibold";
return (
<article className="mx-auto max-w-3xl px-4 py-12 sm:px-6">
<h1 className="font-heading text-[30px] font-bold text-[var(--ui-primary)]">{t("title")}</h1>
<p className="mt-2 text-[14px] text-muted-foreground">{t("intro")}</p>
<h2 className={block}>{t("provider")}</h2>
<address className="mt-2 text-[15px] leading-relaxed not-italic">
{IMPRINT.company}
<br />
{IMPRINT.street}
<br />
{IMPRINT.city}
<br />
{IMPRINT.country}
</address>
<h2 className={block}>{t("represented")}</h2>
<p className="mt-2 text-[15px]">{IMPRINT.representatives.join(", ")}</p>
<h2 className={block}>{t("contact")}</h2>
<dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[15px]">
<dt className="text-muted-foreground">{t("phone")}</dt>
<dd>
<a href={IMPRINT.phoneHref} className="underline-offset-2 hover:underline">
{IMPRINT.phone}
</a>
</dd>
<dt className="text-muted-foreground">{t("email")}</dt>
<dd>
<a href={`mailto:${IMPRINT.email}`} className="underline-offset-2 hover:underline">
{IMPRINT.email}
</a>
</dd>
</dl>
<h2 className={block}>{t("register")}</h2>
<dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[15px]">
<dt className="text-muted-foreground">{t("registerCourt")}</dt>
<dd>{IMPRINT.registerCourt}</dd>
<dt className="text-muted-foreground">{t("registerNumber")}</dt>
<dd>{IMPRINT.registerNumber}</dd>
</dl>
<h2 className={block}>{t("vat")}</h2>
<p className="mt-2 text-[15px]">
{t("vatText")} {IMPRINT.vatId}
</p>
<h2 className={block}>{t("editorial")}</h2>
<p className="mt-2 text-[15px]">{IMPRINT.editorial}</p>
<h2 className={block}>{t("dispute")}</h2>
<p className="mt-2 text-[15px] leading-relaxed">{t("disputeText")}</p>
</article>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { MarketingShell } from "@/components/marketing/shell";
/** Öffentlicher Bereich (Produktseiten) — ohne Session, siehe PUBLIC_PATHS in src/proxy.ts. */
export default function MarketingLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return <MarketingShell>{children}</MarketingShell>;
}
+21
View File
@@ -0,0 +1,21 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { MarketingHome } from "@/components/marketing/home";
import { auth } from "@/server/auth";
import { landingPath } from "@/server/app-access";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("marketing.meta");
return { title: { absolute: t("title") }, description: t("description") };
}
/**
* Öffentliche Startseite. Angemeldete Nutzer sehen sie nicht: Feldrollen → /m,
* Backoffice → /dashboard (ARCHITEKTUR §5). Kopf- und Fußzeile liefert `(marketing)/layout.tsx`.
*/
export default async function Home() {
const session = await auth();
if (session?.user) redirect(landingPath(session));
return <MarketingHome contactEmail={process.env.TRIAL_CONTACT_EMAIL?.trim() || undefined} />;
}
+83
View File
@@ -0,0 +1,83 @@
import type { Metadata } from "next";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ArrowRight, CheckCircle2, Info } from "lucide-react";
import { PageHero } from "@/components/marketing/blocks";
/** Testphase und Preise — bewusst ohne erfundene Zahlen, solange das Preismodell offen ist. */
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("marketing.pricingPage");
return { title: t("title"), description: t("lead") };
}
export default async function PricingPage() {
const t = await getTranslations("marketing");
const trialPoints = (["t1", "t2", "t3"] as const).map((k) => t(`pricingPage.${k}`));
const fullPoints = (["f1", "f2", "f3"] as const).map((k) => t(`pricingPage.${k}`));
return (
<>
<PageHero eyebrow={t("trial.eyebrow")} title={t("pricingPage.title")} lead={t("pricingPage.lead")} />
<section aria-label={t("pricingPage.title")} className="mx-auto max-w-5xl px-4 pb-10 sm:px-6">
<div className="grid gap-5 md:grid-cols-2">
<article className="shadow-card rounded-2xl border border-l-4 border-l-[var(--ui-accent)] bg-card p-6">
<h2 className="font-heading text-[20px] font-semibold">{t("pricingPage.trialTitle")}</h2>
<p className="mt-2 text-[14.5px] leading-relaxed text-muted-foreground">{t("pricingPage.trialText")}</p>
<ul className="mt-4 grid gap-2.5">
{trialPoints.map((p) => (
<li key={p} className="flex items-start gap-2 text-[14.5px]">
<CheckCircle2 className="mt-0.5 size-[18px] shrink-0 text-[var(--ok)]" aria-hidden />
{p}
</li>
))}
</ul>
<Link
href="/testen"
className="mt-5 inline-flex min-h-12 items-center justify-center gap-2 rounded-lg bg-cta px-5 text-[15px] font-semibold text-cta-foreground hover:brightness-95"
>
{t("trial.cta")}
<ArrowRight className="size-4" aria-hidden />
</Link>
</article>
<article className="shadow-card rounded-2xl border bg-card p-6">
<h2 className="font-heading text-[20px] font-semibold">{t("pricingPage.fullTitle")}</h2>
<p className="mt-2 text-[14.5px] leading-relaxed text-muted-foreground">{t("pricingPage.fullText")}</p>
<ul className="mt-4 grid gap-2.5">
{fullPoints.map((p) => (
<li key={p} className="flex items-start gap-2 text-[14.5px]">
<CheckCircle2 className="mt-0.5 size-[18px] shrink-0 text-[var(--ui-primary)]" aria-hidden />
{p}
</li>
))}
</ul>
</article>
</div>
<p className="mt-5 flex items-start gap-2 rounded-lg border border-[var(--info)] bg-card px-4 py-3 text-[14px]">
<Info className="mt-0.5 size-[18px] shrink-0 text-[var(--info)]" aria-hidden />
{t("pricingPage.note")}
</p>
</section>
<section aria-labelledby="pricing-faq" className="border-t bg-card">
<div className="mx-auto max-w-4xl px-4 py-12 sm:px-6">
<h2 id="pricing-faq" className="font-heading text-[24px] font-semibold">{t("faq.title")}</h2>
<div className="mt-5 divide-y rounded-xl border bg-background">
{(["q1", "q4", "q5", "q7", "q8"] as const).map((key) => (
<details key={key} className="group px-4">
<summary className="flex min-h-14 cursor-pointer list-none items-center justify-between gap-3 text-[15px] font-semibold">
{t(`faq.${key}.q`)}
<span aria-hidden className="text-[var(--ui-primary)] transition-transform group-open:rotate-45">+</span>
</summary>
<p className="pb-4 text-[14.5px] leading-relaxed text-muted-foreground">{t(`faq.${key}.a`)}</p>
</details>
))}
</div>
</div>
</section>
</>
);
}
+67
View File
@@ -0,0 +1,67 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { Download, EyeOff, KeyRound, Layers, ScrollText, UsersRound, type LucideIcon } from "lucide-react";
import { CtaBand, PageHero, ShotFrame } from "@/components/marketing/blocks";
/** Sicherheit und Datenschutz — Mandantentrennung, Rollen, Protokoll, Export. */
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("marketing.securityPage");
return { title: t("title"), description: t("lead") };
}
const ITEMS: { key: string; icon: LucideIcon }[] = [
{ key: "item1", icon: Layers },
{ key: "item2", icon: UsersRound },
{ key: "item3", icon: KeyRound },
{ key: "item4", icon: ScrollText },
{ key: "item5", icon: Download },
{ key: "item6", icon: EyeOff },
];
export default async function SecurityPage() {
const t = await getTranslations("marketing");
return (
<>
<PageHero eyebrow={t("security.eyebrow")} title={t("securityPage.title")} lead={t("securityPage.lead")} />
<section aria-label={t("securityPage.title")} className="mx-auto max-w-6xl px-4 pb-12 sm:px-6">
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{ITEMS.map(({ key, icon: Icon }) => (
<li key={key} className="shadow-card rounded-xl border bg-card p-5">
<span className="flex size-10 items-center justify-center rounded-lg bg-[var(--ui-primary-soft)] text-[var(--ui-primary)]">
<Icon className="size-5" aria-hidden />
</span>
<h2 className="mt-3 font-heading text-[16px] font-semibold">{t(`security.${key}.title`)}</h2>
<p className="mt-1.5 text-[14px] leading-relaxed text-muted-foreground">{t(`security.${key}.text`)}</p>
</li>
))}
</ul>
</section>
<section aria-labelledby="security-detail" className="border-y bg-card">
<div className="mx-auto grid max-w-6xl gap-8 px-4 py-12 sm:px-6 lg:grid-cols-[0.9fr_1.1fr] lg:items-center">
<div>
<h2 id="security-detail" className="font-heading text-[24px] font-semibold">{t("securityPage.detailTitle")}</h2>
<dl className="mt-5 grid gap-4">
{(["d1", "d2", "d3"] as const).map((k) => (
<div key={k}>
<dt className="font-heading text-[16px] font-semibold">{t(`securityPage.${k}title`)}</dt>
<dd className="mt-1 text-[14px] leading-relaxed text-muted-foreground">{t(`securityPage.${k}text`)}</dd>
</div>
))}
</dl>
</div>
<ShotFrame shot={{ src: "audit", kind: "desktop" }} caption={t("securityPage.shot1")} />
</div>
</section>
<section aria-label={t("securityPage.shot2")} className="mx-auto max-w-5xl px-4 py-12 sm:px-6">
<ShotFrame shot={{ src: "teams", kind: "desktop" }} caption={t("securityPage.shot2")} />
</section>
<CtaBand />
</>
);
}
+22 -1
View File
@@ -7,6 +7,7 @@ import { platformAuth } from "@/server/platform-auth";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PageHead, Pill } from "@/components/mockup-ui"; import { PageHead, Pill } from "@/components/mockup-ui";
import { MODULES } from "@/lib/modules"; import { MODULES } from "@/lib/modules";
import { effectiveTier, isModuleInTier } from "@/lib/plans";
import { setTenantStatus, toggleTenantModule, setTenantMfaRequired, setTenantLocale } from "@/server/actions/admin"; import { setTenantStatus, toggleTenantModule, setTenantMfaRequired, setTenantLocale } from "@/server/actions/admin";
import { resolveMfaRequired } from "@/lib/mfa-policy"; import { resolveMfaRequired } from "@/lib/mfa-policy";
import { createTenantUser, updateTenantUser, setTenantUserRoles, setTenantUserStatus } from "@/server/actions/platform-users"; import { createTenantUser, updateTenantUser, setTenantUserRoles, setTenantUserStatus } from "@/server/actions/platform-users";
@@ -19,6 +20,8 @@ import { UserTable } from "@/components/user-table";
import { UserCreateForm, UserEditForm } from "@/components/user-forms"; import { UserCreateForm, UserEditForm } from "@/components/user-forms";
import { AuditTrailModal, type AuditRow } from "@/components/audit-trail"; import { AuditTrailModal, type AuditRow } from "@/components/audit-trail";
import { RestoreModalBody, ExportModalBody, DsgvoModalBody, type SnapshotOption, type SubjectOption } from "@/components/backup-admin-panel"; import { RestoreModalBody, ExportModalBody, DsgvoModalBody, type SnapshotOption, type SubjectOption } from "@/components/backup-admin-panel";
import { TrialAdminCard } from "@/components/trial/trial-admin-card";
import { PlanAdminCard } from "@/components/plans/plan-admin-card";
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" }; const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
@@ -27,7 +30,7 @@ export default async function AdminTenantPage({
searchParams, searchParams,
}: { }: {
params: Promise<{ id: string }>; 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; plan?: string; planDone?: string }>;
}) { }) {
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen. // Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
const { id } = await params; const { id } = await params;
@@ -57,6 +60,9 @@ export default async function AdminTenantPage({
const editUser = sp.edit ? tenant.users.find((u) => u.id === sp.edit) : null; const editUser = sp.edit ? tenant.users.find((u) => u.id === sp.edit) : null;
const base = `/admin/${tenant.id}`; const base = `/admin/${tenant.id}`;
// L17 Pakete: Module außerhalb der Paketstufe wirken trotz Schalter wie deaktiviert
const tierNow = effectiveTier(tenant.tier, tenant.plan);
const tp = await getTranslations("plans");
const moduleState = new Map(tenant.modules.map((m) => [m.moduleKey, m.enabled])); const moduleState = new Map(tenant.modules.map((m) => [m.moduleKey, m.enabled]));
const isOn = (key: string) => moduleState.get(key) ?? true; const isOn = (key: string) => moduleState.get(key) ?? true;
@@ -216,6 +222,20 @@ export default async function AdminTenantPage({
</div> </div>
<div className="space-y-5"> <div className="space-y-5">
{/* L15 Testphase: Enddatum, Umwandlung, Beenden, Löschung (Bestätigung + Plattform-Audit) */}
<TrialAdminCard
tenant={tenant}
isFullAdmin={isFullAdmin}
base={base}
op={sp.trial === "created" ? undefined : sp.trial}
notice={sp.trial === "created" ? (sp.invited ? "invited" : "created") : sp.trialDone ? "done" : null}
/>
{/* L17 Pakete: Stufe, Lotse-Chat-Plätze, hartes Limit, Verbrauch (Grundlage der Rechnung) */}
{platformSession?.user?.id && (
<PlanAdminCard tenantId={tenant.id} platformAdminId={platformSession.user.id} isFullAdmin={isFullAdmin} base={base} edit={sp.plan === "edit"} done={!!sp.planDone} />
)}
{/* Lebenszyklus */} {/* Lebenszyklus */}
<div className="shadow-card rounded-xl border bg-card p-5"> <div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-3 font-heading text-sm font-semibold">{t("lifecycleTitle")}</p> <p className="mb-3 font-heading text-sm font-semibold">{t("lifecycleTitle")}</p>
@@ -332,6 +352,7 @@ export default async function AdminTenantPage({
<p className="text-[11px] text-muted-foreground">{m.href ?? m.key}</p> <p className="text-[11px] text-muted-foreground">{m.href ?? m.key}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!isModuleInTier(tierNow, m.key) && <Pill tone="warn">{tp("notInTier")}</Pill>}
<Pill tone={on ? "ok" : "mut"}>{on ? t("moduleActive") : t("moduleInactive")}</Pill> <Pill tone={on ? "ok" : "mut"}>{on ? t("moduleActive") : t("moduleInactive")}</Pill>
<form action={toggleTenantModule.bind(null, tenant.id, m.key, !on)}> <form action={toggleTenantModule.bind(null, tenant.id, m.key, !on)}>
<Button type="submit" size="sm" variant="outline">{on ? t("moduleDeactivate") : t("moduleActivate")}</Button> <Button type="submit" size="sm" variant="outline">{on ? t("moduleDeactivate") : t("moduleActivate")}</Button>
+31 -1
View File
@@ -16,16 +16,23 @@ import {
import { createTenant } from "@/server/actions/admin"; import { createTenant } from "@/server/actions/admin";
import { getMailStatus } from "@/server/actions/mail"; import { getMailStatus } from "@/server/actions/mail";
import { MailStatusPanel } from "@/components/mail-status-panel"; import { MailStatusPanel } from "@/components/mail-status-panel";
import { getTranslations } from "next-intl/server";
import { TrialBadge } from "@/components/trial/trial-badge";
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" }; const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
const STATUS_LABEL: Record<string, string> = { ACTIVE: "Aktiv", SUSPENDED: "Gesperrt", ARCHIVED: "Archiviert" }; const STATUS_LABEL: Record<string, string> = { ACTIVE: "Aktiv", SUSPENDED: "Gesperrt", ARCHIVED: "Archiviert" };
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ new?: string }> }) { export default async function AdminPage({ searchParams }: { searchParams: Promise<{ new?: string; plan?: string }> }) {
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen. // Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
const params = await searchParams; const params = await searchParams;
// L15 Testphase: Filter Test/Voll
const tt = await getTranslations("trial.platform");
const tp = await getTranslations("plans");
const planFilter = params.plan === "trial" ? "TRIAL" : params.plan === "full" ? "FULL" : null;
const [tenants, mailStatus] = await Promise.all([ const [tenants, mailStatus] = await Promise.all([
prisma.tenant.findMany({ prisma.tenant.findMany({
where: planFilter ? { plan: planFilter } : undefined,
include: { _count: { select: { users: true } }, modules: true }, include: { _count: { select: { users: true } }, modules: true },
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
}), }),
@@ -42,6 +49,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
actions={ actions={
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/admins" />}>Administratoren</Button> <Button variant="outline" size="sm" nativeButton={false} render={<Link href="/admins" />}>Administratoren</Button>
<Button variant="outline" nativeButton={false} render={<Link href="/admin/trial" />}>{tt("newTrial")}</Button>
<Button nativeButton={false} render={<Link href={params.new ? "/admin" : "/admin?new=1"} />}> <Button nativeButton={false} render={<Link href={params.new ? "/admin" : "/admin?new=1"} />}>
<Plus className="size-4" /> Neuer Kunde <Plus className="size-4" /> Neuer Kunde
</Button> </Button>
@@ -92,6 +100,23 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
</div> </div>
)} )}
<nav aria-label={tt("filter")} className="mt-4 flex flex-wrap gap-2">
{[
{ key: null, href: "/admin", label: tt("filterAll") },
{ key: "TRIAL", href: "/admin?plan=trial", label: tt("filterTrial") },
{ key: "FULL", href: "/admin?plan=full", label: tt("filterFull") },
].map((f) => (
<Link
key={f.href}
href={f.href}
aria-current={planFilter === f.key ? "page" : undefined}
className={`inline-flex min-h-11 items-center rounded-full border px-4 text-[13px] font-semibold ${planFilter === f.key ? "border-[var(--ui-primary)] bg-[var(--ui-primary-soft)] text-[var(--ui-primary)]" : "text-muted-foreground hover:bg-muted"}`}
>
{f.label}
</Link>
))}
</nav>
<div className="shadow-card mt-4 rounded-xl border bg-card"> <div className="shadow-card mt-4 rounded-xl border bg-card">
<Table> <Table>
<TableHeader> <TableHeader>
@@ -99,6 +124,8 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
<TableHead>Kunde</TableHead> <TableHead>Kunde</TableHead>
<TableHead>Kürzel</TableHead> <TableHead>Kürzel</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>{tt("colPlan")}</TableHead>
<TableHead>{tp("colTier")}</TableHead>
<TableHead>Nutzer</TableHead> <TableHead>Nutzer</TableHead>
<TableHead>Aktive Module</TableHead> <TableHead>Aktive Module</TableHead>
</TableRow> </TableRow>
@@ -114,6 +141,9 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
</TableCell> </TableCell>
<TableCell className="text-muted-foreground">{t.slug}</TableCell> <TableCell className="text-muted-foreground">{t.slug}</TableCell>
<TableCell><Pill tone={STATUS_TONE[t.status]}>{STATUS_LABEL[t.status]}</Pill></TableCell> <TableCell><Pill tone={STATUS_TONE[t.status]}>{STATUS_LABEL[t.status]}</Pill></TableCell>
<TableCell><TrialBadge tenant={t} /></TableCell>
{/* L17 Pakete: Stufe als Badge (Testphase läuft immer als Profi) */}
<TableCell><Pill tone={t.tier === "PROFI" ? "ok" : "mut"}>{tp(`tier.${t.tier}`)}</Pill></TableCell>
<TableCell className="text-muted-foreground">{t._count.users}</TableCell> <TableCell className="text-muted-foreground">{t._count.users}</TableCell>
<TableCell className="text-muted-foreground">{active > 0 ? `${active} Module` : "—"}</TableCell> <TableCell className="text-muted-foreground">{active > 0 ? `${active} Module` : "—"}</TableCell>
</TableRow> </TableRow>
+27
View File
@@ -0,0 +1,27 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { ArrowLeft } from "lucide-react";
import { PageHead } from "@/components/mockup-ui";
import { PlatformTrialCreateForm } from "@/components/trial/platform-forms";
import { addDaysToKey, todayKey } from "@/lib/trial/dates";
import { PLATFORM_TRIAL_MAX_DAYS, TRIAL_DEFAULT_DAYS } from "@/server/services/trial/config";
export const dynamic = "force-dynamic";
/** L15 Testphase: platform wizard "Testmandant anlegen" (access: (platform)/layout.tsx; action: full admins). */
export default async function PlatformTrialWizardPage() {
const t = await getTranslations("trial.platform.wizard");
const ta = await getTranslations("admin");
const today = todayKey();
return (
<main className="flex-1 p-6">
<Link href="/admin" className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
<ArrowLeft className="size-4" /> {ta("backToOverview")}
</Link>
<div className="mt-2 max-w-3xl">
<PageHead crumb={t("crumb")} title={t("title")} sub={t("sub")} />
<PlatformTrialCreateForm defaultEnd={addDaysToKey(today, TRIAL_DEFAULT_DAYS)} min={today} max={addDaysToKey(today, PLATFORM_TRIAL_MAX_DAYS)} />
</div>
</main>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { requireApiContext } from "@/server/api/context";
import { ApiError, json, readFormData, withApi } from "@/server/api/respond";
import { CHAT_AUDIO_MAX_BYTES, transcribeChatAudio } from "@/server/services/lotse/chat/transcribe";
/**
* POST /api/v1/lotse/transcribe — multipart `file` (audio, max. 10 MB) → `{ text }` (lane L16).
* Microphone key of the Lotse chat: the transcript is put into the input field and can be edited
* before sending. The audio is not stored.
*/
export const POST = withApi(async (req: Request) => {
const ctx = await requireApiContext("lotse", "lotse:use", "field:execute");
const declared = Number(req.headers.get("content-length") ?? "0");
if (declared > CHAT_AUDIO_MAX_BYTES + 1024 * 1024) throw new ApiError("payload_too_large", "audio too large");
const form = await readFormData(req);
const file = form.get("file");
if (!(file instanceof File)) throw new ApiError("invalid", "file missing");
if (file.size > CHAT_AUDIO_MAX_BYTES) throw new ApiError("payload_too_large", "audio too large");
const result = await transcribeChatAudio(ctx, Buffer.from(await file.arrayBuffer()));
return json(result);
});
@@ -3,6 +3,7 @@ import { assertSameOrigin, requireApiContext } from "@/server/api/context";
import { ApiError, json, readFormData, toErrorResponse } from "@/server/api/respond"; import { ApiError, json, readFormData, toErrorResponse } from "@/server/api/respond";
import { ServiceError } from "@/server/services/context"; import { ServiceError } from "@/server/services/context";
import { uploadWorkOrderDocument } from "@/server/services/work-orders/documents"; 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?). * 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 { try {
assertSameOrigin(req); assertSameOrigin(req);
const ctx = await requireApiContext("work_orders", "document:write"); 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 form = await readFormData(req);
const file = form.get("file"); const file = form.get("file");
if (!(file instanceof File) || file.size === 0) throw new ServiceError("invalid", "file_missing"); if (!(file instanceof File) || file.size === 0) throw new ServiceError("invalid", "file_missing");
-8
View File
@@ -1,8 +0,0 @@
import { redirect } from "next/navigation";
import { auth } from "@/server/auth";
import { landingPath } from "@/server/app-access";
export default async function Home() {
// Feldrollen → /m, Backoffice → /dashboard (ARCHITEKTUR §5)
redirect(landingPath(await auth()));
}
+48
View File
@@ -0,0 +1,48 @@
import type { Metadata } from "next";
import Link from "next/link";
import { getLocale, getTranslations } from "next-intl/server";
import { TrialConfirmForm } from "@/components/trial/confirm-form";
import { formatDateKey } from "@/lib/trial/dates";
import { peekTrialSignup } from "@/server/services/trial/signup";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("trial.meta");
return { title: t("confirmTitle") };
}
export const dynamic = "force-dynamic";
/**
* L15 Testphase: landing page of the confirmation link. Shows the pending signup (link is only
* checked, not consumed); provisioning happens on the POST of the button (TrialConfirmForm).
*/
export default async function TrialConfirmPage({ searchParams }: { searchParams: Promise<{ token?: string }> }) {
const { token = "" } = await searchParams;
const [t, locale] = await Promise.all([getTranslations("trial.confirm"), getLocale()]);
const signup = await peekTrialSignup(token);
return (
<div className="shadow-card mt-4 w-full max-w-md self-start rounded-2xl border bg-card p-6 sm:p-8">
<h1 className="font-heading text-xl font-semibold">{t("title")}</h1>
{signup ? (
<>
<p className="mt-1 text-sm text-muted-foreground">{t("intro")}</p>
<dl className="mt-4 divide-y rounded-lg border text-sm">
<div className="flex justify-between gap-3 px-3 py-2"><dt className="text-muted-foreground">{t("company")}</dt><dd className="text-right font-medium">{signup.companyName}</dd></div>
<div className="flex justify-between gap-3 px-3 py-2"><dt className="text-muted-foreground">{t("until")}</dt><dd className="font-medium">{formatDateKey(signup.trialEndDate, locale)}</dd></div>
<div className="flex justify-between gap-3 px-3 py-2"><dt className="text-muted-foreground">{t("sampleData")}</dt><dd className="font-medium">{signup.sampleData ? t("yes") : t("no")}</dd></div>
</dl>
<TrialConfirmForm token={token} />
</>
) : (
<>
<p role="alert" className="mt-4 rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{t("invalid")}</p>
<div className="mt-4 flex flex-col gap-1 text-center">
<Link href="/testen" className="flex min-h-11 items-center justify-center text-sm font-semibold text-[var(--ui-primary)]">{t("toSignup")}</Link>
<Link href="/login" className="flex min-h-11 items-center justify-center text-sm text-muted-foreground">{t("toLogin")}</Link>
</div>
</>
)}
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { LegalPlaceholder } from "@/components/trial/legal-placeholder";
export async function generateMetadata(): Promise<Metadata> {
const t = await getTranslations("trial.meta");
return { title: t("privacyTitle") };
}
/** L15 Testphase: placeholder privacy notice (operator replaces the text before go-live). */
export default async function TrialPrivacyPage() {
const t = await getTranslations("trial");
return <LegalPlaceholder title={t("meta.privacyTitle")} paragraphs={[t("legal.privacyBody1"), t("legal.privacyBody2")]} placeholder={t("legal.placeholder")} back={t("legal.back")} />;
}

Some files were not shown because too many files have changed in this diff Show More