Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a20c17444 | ||
|
|
3e26009543 | ||
|
|
aca571b2e7 | ||
|
|
06d320908f | ||
|
|
4d5be72ebb | ||
|
|
f665999d81 | ||
|
|
4815a957b0 | ||
|
|
31343d329a | ||
|
|
a4a61dc31f | ||
|
|
1e1c154a8a | ||
|
|
dd6a67d329 | ||
|
|
a8fedc390f | ||
|
|
c3712598c1 | ||
|
|
df18ff9077 | ||
|
|
273a4535e3 | ||
|
|
969bc4e12c | ||
|
|
eec23efa7e | ||
|
|
d9290a187c | ||
|
|
2f46552970 | ||
|
|
63baaf29df | ||
|
|
68f4eb32dc |
@@ -22,6 +22,10 @@ RLS_ENFORCED=false
|
||||
# und ist deshalb NICHT separat zu setzen (in Coolify sonst als „managed" gesperrt).
|
||||
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) ---
|
||||
# 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
|
||||
@@ -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=CHANGE_ME_openssl_rand_hex_32
|
||||
# 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:
|
||||
AUTH_TRUST_HOST=true
|
||||
|
||||
@@ -96,6 +104,12 @@ TRANSCRIPTION_MODEL=whisper-1
|
||||
# --- KI: Kostenbremse & Aufbewahrung KI-Protokoll ---
|
||||
# Tokens je Mandant je Kalendermonat (ein+aus), 0 = unbegrenzt.
|
||||
AI_MONTHLY_TOKEN_LIMIT=0
|
||||
|
||||
# --- Craftvia: Testphase (L15, docs/craftvia/TESTPHASE.md) ---
|
||||
# Längste selbst gewählte Testphase in Tagen (1–365); Vorbelegung im Wizard heute + 14.
|
||||
TRIAL_MAX_DAYS=30
|
||||
# Kontakt in Banner und Testphasen-Mails (Vollversion/Verlängerung); leer = allgemeiner Hinweis.
|
||||
TRIAL_CONTACT_EMAIL=
|
||||
# Ein-/Ausgaben im KI-Protokoll (AiGeneration) nach N Tagen leeren/pseudonymisieren.
|
||||
AI_GENERATION_RETENTION_DAYS=180
|
||||
|
||||
|
||||
@@ -129,6 +129,12 @@ AI_GENERATION_RETENTION_DAYS=180
|
||||
# Import-Extraktion ab. 0 = unbegrenzt.
|
||||
AI_MONTHLY_TOKEN_LIMIT=0
|
||||
|
||||
# --- Craftvia: Testphase (L15, docs/craftvia/TESTPHASE.md) ---
|
||||
# Längste selbst gewählte Testphase in Tagen (1–365); Vorbelegung im Wizard heute + 14.
|
||||
TRIAL_MAX_DAYS=30
|
||||
# Kontakt in Banner und Testphasen-Mails (Vollversion/Verlängerung); leer = allgemeiner Hinweis.
|
||||
TRIAL_CONTACT_EMAIL=
|
||||
|
||||
# --- Craftvia: Planung – Karten & Geocoding (L13) ---
|
||||
# Adresse → Koordinaten nur serverseitig im Worker (Job geocode-site, max. 1 Anfrage/s),
|
||||
# Ergebnis wird am Objekt gespeichert. nominatim | none (none = keine Verortung, Objekte
|
||||
|
||||
@@ -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=CHANGE_ME_openssl_rand_hex_32
|
||||
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.
|
||||
# Passkeys/WebAuthn: leer = aus AUTH_URL abgeleitet.
|
||||
# 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
|
||||
# Import-Extraktion ab. 0 = unbegrenzt.
|
||||
AI_MONTHLY_TOKEN_LIMIT=0
|
||||
|
||||
# --- Craftvia: Testphase (L15, docs/craftvia/TESTPHASE.md) ---
|
||||
# Längste selbst gewählte Testphase in Tagen (1–365); Vorbelegung im Wizard heute + 14.
|
||||
TRIAL_MAX_DAYS=30
|
||||
# Kontakt in Banner und Testphasen-Mails (Vollversion/Verlängerung); leer = allgemeiner Hinweis.
|
||||
TRIAL_CONTACT_EMAIL=
|
||||
# Nach N Tagen leert/pseudonymisiert ein Worker-Job Ein-/Ausgaben im KI-Protokoll
|
||||
# (AiGeneration). Frist mit dem DSB abstimmen.
|
||||
AI_GENERATION_RETENTION_DAYS=180
|
||||
|
||||
@@ -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)
|
||||
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 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 gate # vollständiges Qualitäts-Gate
|
||||
```
|
||||
|
||||
@@ -27,6 +27,8 @@ docker compose up -d postgres redis garage
|
||||
GARAGE_ADMIN_URL=http://localhost:3903 npx tsx scripts/garage-provision.ts
|
||||
npx prisma migrate deploy
|
||||
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
|
||||
```
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ Alle Routen antworten im Fehlerfall mit `Cache-Control: no-store` und
|
||||
| Code | HTTP | Bedeutung / `details` |
|
||||
|---|---|---|
|
||||
| `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 |
|
||||
| `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 |
|
||||
@@ -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`.
|
||||
- **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*`).
|
||||
- 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`.
|
||||
|
||||
## 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/live` | work_orders | Service: `work_order:read_all` oder Teamleiter | Live-Lage: Status aus aktiver WorkSession, Standort = Objekt des Auftrags, keine Geräte-Koordinaten |
|
||||
| GET | `/openapi.json` | – | angemeldet | OpenAPI-3.1-Dokument |
|
||||
|
||||
## Testphase: Nur-Lesen nach Ablauf (L15)
|
||||
|
||||
Für abgelaufene Testmandanten beantworten alle nicht lesenden Anfragen (POST/PUT/PATCH/DELETE, inkl.
|
||||
`/sync` und `/uploads`) mit **422** `{ "error": { "code": "blocked", "message": "trial_expired", "details": { "readOnly": true, "message": "…", "deletionDueAt": "…" } } }`.
|
||||
GET-Anfragen, Datei-Downloads und PDFs bleiben erlaubt. Siehe [TESTPHASE.md](TESTPHASE.md).
|
||||
|
||||
@@ -59,6 +59,25 @@ Entrypoints von postgres/redis), CPU-/RAM-Limits, gepinnte Image-Tags, non-root-
|
||||
Proxies dürfen kein niedrigeres Body-Limit haben.
|
||||
- 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)
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
| Bereich | Env | Default | Zählung |
|
||||
|
||||
@@ -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>/`.
|
||||
@@ -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).
|
||||
@@ -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“ |
|
||||
@@ -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.
|
||||
- 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.
|
||||
|
||||
## 9. Bekannte Lücken / offene Punkte
|
||||
|
||||
@@ -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 |
|
||||
@@ -6,7 +6,8 @@
|
||||
"emergency": "Notdienst",
|
||||
"sync": "Sync",
|
||||
"profile": "Profil",
|
||||
"approvalsBadge": "{count} Zeiten zur Freigabe"
|
||||
"approvalsBadge": "{count} Zeiten zur Freigabe",
|
||||
"lotse": "Lotse"
|
||||
},
|
||||
"connection": {
|
||||
"online": "Online",
|
||||
|
||||
@@ -89,6 +89,8 @@
|
||||
"use": "Verwendung",
|
||||
"enabled": "Lotse für diesen Betrieb verwenden",
|
||||
"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",
|
||||
"addressFormHint": "Gilt einheitlich für alle Texte des Lotsen in diesem Betrieb.",
|
||||
"address": {
|
||||
@@ -114,7 +116,9 @@
|
||||
"order": "Auftragstitel, Beschreibung und Leistungsumfang",
|
||||
"notes": "Tätigkeitsnotizen und Texte aus Sprachnotizen",
|
||||
"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",
|
||||
"notSent": {
|
||||
@@ -152,7 +156,176 @@
|
||||
"voice_summary": "Zusammenfassung Sprachnotiz",
|
||||
"transcription": "Transkription",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -21,5 +21,6 @@
|
||||
"openMenu": "Menü öffnen",
|
||||
"closeMenu": "Menü schließen",
|
||||
"timeApprovals": "Zeiten zur Freigabe",
|
||||
"billing": "Abrechnung"
|
||||
"billing": "Abrechnung",
|
||||
"dataExport": "Datenexport"
|
||||
}
|
||||
|
||||
@@ -92,7 +92,9 @@
|
||||
"internal": "Technischer Fehler – wird automatisch erneut versucht.",
|
||||
"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.",
|
||||
"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": {
|
||||
"title": "Offline-Ansicht",
|
||||
|
||||
@@ -89,7 +89,8 @@
|
||||
"noTeam": "ohne Team",
|
||||
"noDate": "ohne Termin",
|
||||
"noLocation": "ohne Ortsangabe",
|
||||
"suggestions": "Vorschläge"
|
||||
"suggestions": "Vorschläge",
|
||||
"suggestionsFor": "Vorschläge für {number}"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "Auftrag einplanen",
|
||||
@@ -150,7 +151,9 @@
|
||||
"refreshing": "Aktualisiert …",
|
||||
"updateFailed": "Aktualisierung fehlgeschlagen – der letzte Stand wird angezeigt.",
|
||||
"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",
|
||||
"filterStatus": "Status",
|
||||
"all": "Alle",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@
|
||||
"emergency": "Emergency",
|
||||
"sync": "Sync",
|
||||
"profile": "Profile",
|
||||
"approvalsBadge": "{count} time entries to approve"
|
||||
"approvalsBadge": "{count} time entries to approve",
|
||||
"lotse": "Lotse"
|
||||
},
|
||||
"connection": {
|
||||
"online": "Online",
|
||||
|
||||
@@ -89,6 +89,8 @@
|
||||
"use": "Usage",
|
||||
"enabled": "Use Lotse in this business",
|
||||
"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",
|
||||
"addressFormHint": "Applies consistently to all Lotse texts in this business.",
|
||||
"address": {
|
||||
@@ -114,7 +116,9 @@
|
||||
"order": "Work order title, description and scope",
|
||||
"notes": "Activity notes and voice note texts",
|
||||
"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",
|
||||
"notSent": {
|
||||
@@ -152,7 +156,176 @@
|
||||
"voice_summary": "Voice note summary",
|
||||
"transcription": "Transcription",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -21,5 +21,6 @@
|
||||
"openMenu": "Open menu",
|
||||
"closeMenu": "Close menu",
|
||||
"timeApprovals": "Time approvals",
|
||||
"billing": "Billing"
|
||||
"billing": "Billing",
|
||||
"dataExport": "Data export"
|
||||
}
|
||||
|
||||
@@ -92,7 +92,9 @@
|
||||
"internal": "Technical error – will retry automatically.",
|
||||
"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.",
|
||||
"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": {
|
||||
"title": "Offline view",
|
||||
|
||||
@@ -89,7 +89,8 @@
|
||||
"noTeam": "no team",
|
||||
"noDate": "no date",
|
||||
"noLocation": "no location",
|
||||
"suggestions": "Suggestions"
|
||||
"suggestions": "Suggestions",
|
||||
"suggestionsFor": "Suggestions for {number}"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "Schedule order",
|
||||
@@ -150,7 +151,9 @@
|
||||
"refreshing": "Refreshing …",
|
||||
"updateFailed": "Update failed – showing the last state.",
|
||||
"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",
|
||||
"filterStatus": "Status",
|
||||
"all": "All",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
@@ -44,6 +44,30 @@ model Tenant {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// L15 Testphase: Lebenszyklus (Plattform-Daten, keine Mandanten-RLS)
|
||||
plan TenantPlan @default(FULL)
|
||||
trialSource String? @map("trial_source") // self_signup | platform
|
||||
trialStartedAt DateTime? @map("trial_started_at")
|
||||
/// Exklusives Ende: Beginn des Folgetags des gewählten Enddatums (Europe/Berlin)
|
||||
trialEndsAt DateTime? @map("trial_ends_at")
|
||||
convertedAt DateTime? @map("converted_at")
|
||||
readOnlySince DateTime? @map("read_only_since")
|
||||
/// null = keine automatische Löschung vorgemerkt
|
||||
deletionDueAt DateTime? @map("deletion_due_at")
|
||||
trialDeletedAt DateTime? @map("trial_deleted_at")
|
||||
trialReminder7At DateTime? @map("trial_reminder_7_at")
|
||||
trialReminder3At DateTime? @map("trial_reminder_3_at")
|
||||
trialReminder1At DateTime? @map("trial_reminder_1_at")
|
||||
trialExpiredNoticeAt DateTime? @map("trial_expired_notice_at")
|
||||
trialDeletionNoticeAt DateTime? @map("trial_deletion_notice_at")
|
||||
|
||||
// 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[]
|
||||
roles Role[]
|
||||
auditLogs AuditLog[]
|
||||
@@ -79,6 +103,10 @@ model TenantSettings {
|
||||
lotseAddressForm String? @map("lotse_address_form")
|
||||
// L10b (Spec §31): monthly AI token budget (input + output) per tenant; null = env AI_MONTHLY_TOKEN_LIMIT, 0 = unlimited
|
||||
aiMonthlyTokenLimit Int? @map("ai_monthly_token_limit")
|
||||
// L16 Lotse-Chat für Monteure: switch per tenant (effective only while the Lotse module is on)
|
||||
lotseChatEnabled Boolean @default(true) @map("lotse_chat_enabled")
|
||||
// L15: „Erste Schritte"-Checkliste ({ done: string[], hidden: boolean })
|
||||
onboarding Json @default("{}")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -1433,3 +1461,166 @@ model BillingRecord {
|
||||
@@index([tenantId, workOrderId])
|
||||
@@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")
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 377 KiB |
|
After Width: | Height: | Size: 483 KiB |
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 355 KiB |
|
After Width: | Height: | Size: 314 KiB |
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 517 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 345 KiB |
@@ -57,6 +57,14 @@ const ACTION_MODULE: Record<string, string> = {
|
||||
"account.ts": "EXEMPT",
|
||||
"tenant-switch.ts": "EXEMPT",
|
||||
"webauthn.ts": "EXEMPT",
|
||||
// L15 Testphase: Plattform-Wizard/-Aktionen (requirePlatformFullAdmin) und Mandanten-Export/Onboarding
|
||||
// (requireSession + requirePermission + requireApiContext; Export bewusst auch im Nur-Lesen-Zustand)
|
||||
"trial-platform.ts": "EXEMPT",
|
||||
"trial-tenant.ts": "EXEMPT",
|
||||
// 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[] = [];
|
||||
@@ -75,6 +83,10 @@ function checkGatedFile(label: string, src: string, moduleKey: string) {
|
||||
if (!src.includes(`moduleGuard("${moduleKey}")`)) {
|
||||
errors.push(`${label}: erwartet moduleGuard("${moduleKey}") — Modul-Gating fehlt oder falscher Key.`);
|
||||
}
|
||||
// L15 Testphase: der Lese-Modus überspringt die Schreibsperre abgelaufener Testmandanten → in Actions verboten.
|
||||
if (/moduleGuard\([^)]*read\s*:/.test(src)) {
|
||||
errors.push(`${label}: moduleGuard(…, { read: true }) ist nur für Lesepfade erlaubt, nicht in Server-Actions.`);
|
||||
}
|
||||
for (let i = 0; i < positions.length; i++) {
|
||||
const start = positions[i].index;
|
||||
const end = i + 1 < positions.length ? positions[i + 1].index : src.length;
|
||||
@@ -118,6 +130,20 @@ for (const entry of readdirSync(ACTIONS_DIR)) {
|
||||
continue;
|
||||
}
|
||||
const src = readFileSync(full, "utf8");
|
||||
if (mapped === "PUBLIC") {
|
||||
// Öffentliche Actions (ohne Session): jede exportierte Action muss ein Rate-Limit prüfen.
|
||||
const exportRe = /export async function (\w+)\s*\(/g;
|
||||
const positions: { name: string; index: number }[] = [];
|
||||
let pm: RegExpExecArray | null;
|
||||
while ((pm = exportRe.exec(src))) positions.push({ name: pm[1], index: pm.index });
|
||||
positions.forEach((p, i) => {
|
||||
const body = src.slice(p.index, i + 1 < positions.length ? positions[i + 1].index : src.length);
|
||||
if (!/(enforceTrialRateLimit|checkRateLimit|consumeRateLimit)\(/.test(body)) {
|
||||
errors.push(`${entry}: öffentliche Action "${p.name}" ohne Rate-Limit-Prüfung.`);
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (mapped === "EXEMPT") {
|
||||
// Auth-Nachweis: ein require*-Guard ODER ein direkter auth()-Aufruf.
|
||||
if (!/require(Session|Platform\w*|Permission)|\bauth\(\)/.test(src)) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import "dotenv/config";
|
||||
import { Worker } from "bullmq";
|
||||
import { JOB_QUEUES, workerConnection, closeJobQueues, scheduleRecurringJobs, type JobPayload } from "../src/server/jobs/queues";
|
||||
import { PROCESSORS } from "../src/server/jobs/processors";
|
||||
import { isJobBlockedByTrial } from "../src/server/services/trial/jobs";
|
||||
|
||||
/** Craftvia background worker: `npm run worker:craftvia`. One BullMQ worker per registered queue. */
|
||||
async function main() {
|
||||
@@ -20,7 +21,14 @@ async function main() {
|
||||
const processor = await load();
|
||||
const geocode = name === JOB_QUEUES.geocodeSite; // L13: OSM Nominatim policy — max. 1 request/s
|
||||
const concurrency = name === JOB_QUEUES.reportPdf ? 2 : geocode ? 1 : 4;
|
||||
const w = new Worker<JobPayload>(name, async (job) => processor(job.data), { connection, concurrency, ...(geocode ? { limiter: { max: 1, duration: 1_000 } } : {}) });
|
||||
const w = new Worker<JobPayload>(name, async (job) => {
|
||||
// L15 Testphase: user-triggered jobs of an expired trial tenant (read-only) are skipped
|
||||
if (await isJobBlockedByTrial(name, job.data)) {
|
||||
console.warn(`[worker] ${name} job ${job.id} skipped: tenant is read-only (trial expired)`);
|
||||
return;
|
||||
}
|
||||
return processor(job.data);
|
||||
}, { connection, concurrency, ...(geocode ? { limiter: { max: 1, duration: 1_000 } } : {}) });
|
||||
w.on("failed", (job, err) => console.error(`[worker] ${name} job ${job?.id} failed:`, err.message));
|
||||
workers.push(w);
|
||||
console.info(`[worker] listening on ${name}`);
|
||||
|
||||
@@ -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."
|
||||
@@ -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" } }] } });
|
||||
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. */
|
||||
export async function seedDemo2Tenant(tenantId: string, adminUserId: string): Promise<void> {
|
||||
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." });
|
||||
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). */
|
||||
|
||||
@@ -134,6 +134,7 @@ export async function cleanupTenants(slugs: string[]): Promise<void> {
|
||||
await prisma.mailLog.deleteMany(w);
|
||||
await prisma.authToken.deleteMany(w);
|
||||
await prisma.aiGeneration.deleteMany(w);
|
||||
await prisma.lotseChatSeat.deleteMany(w); // L17 Pakete
|
||||
await prisma.signature.deleteMany(w);
|
||||
await prisma.report.deleteMany(w);
|
||||
await prisma.photo.deleteMany(w);
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.");
|
||||
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 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 approved = await prisma.report.findFirstOrThrow({ where: { workOrderId: d18.id, status: "approved" } });
|
||||
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/audit" },
|
||||
{ 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 & 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/order-types", mustContain: "Montage" },
|
||||
{ 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
|
||||
{ path: "/planning", mustContain: ["Plantafel", "Team Nord", "Nur Lesezugriff"], 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/approvals", mustContain: "nicht freigeschaltet" },
|
||||
{ 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
|
||||
{ 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
|
||||
@@ -219,15 +243,25 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
|
||||
{ path: `/customers/${x.customer.id}`, expect: [404] },
|
||||
{ path: `/sites/${x.site.id}`, expect: [404] },
|
||||
{ path: `/reports/${x.approved.id}`, expect: [404] },
|
||||
...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, expect: [404] }] : []),
|
||||
...(x.imp ? [{ path: `/imports/${x.imp.id}`, expect: [404] }] : []),
|
||||
// L17: demo2 is Basis → module layouts redirect before the (foreign) id is looked up
|
||||
...(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] }] : []),
|
||||
{ path: "/planning", mustNotContain: ["Team Nord", x.d07.number] },
|
||||
{ path: "/planning/live", mustNotContain: ["Team Nord", "Max Monteur"] },
|
||||
{ path: `/api/v1/planning/recommendations?workOrderId=${x.d07.id}`, expect: [404] },
|
||||
// L14 Abrechnungsübersicht: keine Einträge von demo
|
||||
{ path: "/billing", mustContain: "Abrechnungsübersicht", mustNotContain: [x.d18.number, "Elbblick"] },
|
||||
...(x.billing ? [{ path: `/billing/${x.billing.id}`, expect: [404] }, { path: `/billing/print?ids=${x.billing.id}`, mustNotContain: [x.d18.number] }] : []),
|
||||
// L17 Pakete: demo2 = Basis → Planung, Abrechnung, Import, Notdienst, Lotse gesperrt (ruhiger Hinweis)
|
||||
{ path: "/planning", expect: [307], redirectTo: "/dashboard" },
|
||||
{ path: "/planning/live", expect: [307], redirectTo: "/dashboard" },
|
||||
{ path: `/api/v1/planning/recommendations?workOrderId=${x.d07.id}`, expect: [403, 404] },
|
||||
{ path: "/api/v1/planning/board", expect: [403] },
|
||||
{ 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 & 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] },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -136,6 +136,13 @@ async function createModelRows(A: TenantFixture): Promise<Rows> {
|
||||
// L14 Abrechnungsübersicht
|
||||
put("WorkOrderMilestone", (await prisma.workOrderMilestone.create({ data: { tenantId: t, workOrderId: wo.id, title: "ZZ" } })).id, { title: MARK });
|
||||
put("BillingRecord", (await prisma.billingRecord.create({ data: { tenantId: t, workOrderId: wo.id, kind: "order_completion", periodFrom: new Date(Date.now() - 3600_000), periodTo: new Date() } })).id, { invoiceNumber: MARK });
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -43,7 +43,7 @@ const at = (ms: number) => new Date(T0.getTime() + ms);
|
||||
const fixedRandom = () => 1; // backoff = full step
|
||||
|
||||
/** 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 uploads: 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 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");
|
||||
|
||||
// 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 —");
|
||||
|
||||
@@ -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] } } });
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -26,6 +26,9 @@ import { WORK_ORDER_PRIORITIES } from "@/lib/work-orders/schemas";
|
||||
import { getDashboardTiles } from "@/server/services/work-orders/dashboard";
|
||||
import { customerDisplayName, customerFilterOptions, teamOptions, userOptions } from "@/server/services/work-orders/options";
|
||||
import { listOrderTypes } from "@/server/services/work-orders/settings";
|
||||
import { GettingStarted } from "@/components/trial/getting-started";
|
||||
import { ProfiHint } from "@/components/plans/profi-hint";
|
||||
import { effectiveModules } from "@/server/plan";
|
||||
|
||||
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 tw = await getTranslations("workOrders");
|
||||
|
||||
const moduleRow = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "work_orders" }, select: { enabled: true } });
|
||||
const moduleEnabled = !moduleRow || moduleRow.enabled;
|
||||
// L17 Pakete: effektive Module (Schalter + Paketstufe) zentral
|
||||
const modules = await effectiveModules(ctx.tenantId);
|
||||
const moduleEnabled = !modules.inactive.has("work_orders");
|
||||
|
||||
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 };
|
||||
@@ -65,11 +69,13 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
|
||||
return (
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
<PageHead crumb={t("crumb")} title={t("title")} sub={t("subtitle", { name: session.user.name ?? "", tenant: session.user.tenantSlug ?? "" })} />
|
||||
<GettingStarted ctx={ctx} welcome={sp.welcome === "1"} /> {/* L15 Testphase */}
|
||||
{sp.module === "disabled" && (
|
||||
<p role="status" className="mb-4 rounded-lg border border-[var(--warn)] bg-card px-4 py-3 text-sm text-[var(--warn)]">
|
||||
{t("moduleDisabled")}
|
||||
</p>
|
||||
)}
|
||||
{sp.module === "profi" && <ProfiHint />} {/* L17 Pakete: Funktion nicht in der Paketstufe */}
|
||||
|
||||
{!tiles ? (
|
||||
<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>
|
||||
|
||||
<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 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 (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { assertSameOrigin, requireApiContext } from "@/server/api/context";
|
||||
import { ApiError, toErrorResponse } from "@/server/api/respond";
|
||||
import { ServiceError } from "@/server/services/context";
|
||||
import { storeFile } from "@/server/services/documents/store";
|
||||
import { assertTenantWritable } from "@/server/services/trial/state";
|
||||
|
||||
/**
|
||||
* Multipart upload for the backoffice document tabs (customer, site, /documents).
|
||||
@@ -19,6 +20,7 @@ export async function POST(req: Request) {
|
||||
try {
|
||||
assertSameOrigin(req);
|
||||
const ctx = await requireApiContext("documents");
|
||||
await assertTenantWritable(ctx.tenantId); // L15: expired trial → read-only
|
||||
|
||||
let form: FormData;
|
||||
try {
|
||||
|
||||
@@ -15,7 +15,7 @@ const INLINE = new Set(["application/pdf", "image/jpeg", "image/png"]);
|
||||
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
let file: { storageKey: string; mimeType: string; fileName: string };
|
||||
try {
|
||||
const ctx = ctxFromGuard(await moduleGuard("imports")("import:write"));
|
||||
const ctx = ctxFromGuard(await moduleGuard("imports", { read: true })("import:write")); // L15: GET download — no trial write lock
|
||||
const { id } = await params;
|
||||
file = await getImportFile(ctx, id);
|
||||
} catch {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getTranslations } from "next-intl/server";
|
||||
import { signOut } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { requireAppAccess } from "@/server/app-access";
|
||||
import { effectiveModules } from "@/server/plan";
|
||||
import { NAV_ITEMS, visibleNavItems } from "@/lib/nav";
|
||||
import { resolveTenantBranding } from "@/lib/brand";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -14,6 +15,7 @@ import { CraftviaLogo } from "@/components/brand/craftvia-logo";
|
||||
import { NotificationBell } from "@/components/notifications/bell";
|
||||
import { AccountInactiveNotice } from "@/components/account-inactive-notice";
|
||||
import { BackofficeFrame } from "@/components/backoffice-frame";
|
||||
import { TrialBanner } from "@/components/trial/trial-banner";
|
||||
|
||||
export default async function AppLayout({
|
||||
children,
|
||||
@@ -29,17 +31,18 @@ export default async function AppLayout({
|
||||
|
||||
// Navigation aus src/lib/nav.ts, gefiltert nach aktiven Modulen + Rechten (Komfort;
|
||||
// Seiten/Actions prüfen serverseitig selbst).
|
||||
const [moduleRows, brandingSettings] = await Promise.all([
|
||||
dbForTenant(session.user.tenantId).tenantModule.findMany(),
|
||||
// L17 Pakete: effektive Module (TenantModule + Paketstufe) zentral aus src/server/plan.ts
|
||||
const [modules, brandingSettings] = await Promise.all([
|
||||
effectiveModules(session.user.tenantId),
|
||||
dbForTenant(session.user.tenantId).tenantSettings.findUnique({
|
||||
where: { tenantId: session.user.tenantId },
|
||||
select: { accent: true },
|
||||
}),
|
||||
]);
|
||||
const branding = resolveTenantBranding(brandingSettings);
|
||||
const disabledModules = new Set(moduleRows.filter((m) => !m.enabled).map((m) => m.moduleKey));
|
||||
const navItems = visibleNavItems(NAV_ITEMS, {
|
||||
disabledModules,
|
||||
disabledModules: modules.inactive,
|
||||
lockedFeatures: modules.lockedFeatures,
|
||||
permissions: session.user.permissions ?? [],
|
||||
});
|
||||
const mainNav = navItems.filter((i) => i.section === "main");
|
||||
@@ -111,6 +114,8 @@ export default async function AppLayout({
|
||||
</form>
|
||||
</>
|
||||
}>
|
||||
{/* L15 Testphase: Countdown ab 7 Tagen bzw. Nur-Lesen-Hinweis */}
|
||||
<TrialBanner tenantId={session.user.tenantId} variant="backoffice" canExport={(session.user.permissions ?? []).includes("tenant:manage")} />
|
||||
{children}
|
||||
</BackofficeFrame>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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 }>) {
|
||||
await requireModule("work_orders");
|
||||
const session = await requireModule("work_orders");
|
||||
await requirePlanFeature(session.user.tenantId, "planning"); // L17 Pakete: Planung nur in Profi
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
|
||||
import { getLocale, getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft, CheckCircle2, ListChecks, MinusCircle, ShieldCheck, XCircle } from "lucide-react";
|
||||
import { LotseMark } from "@/components/lotse/lotse-mark";
|
||||
import { LotseSeatAdmin } from "@/components/lotse/chat/seat-admin";
|
||||
import { PageHead } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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
|
||||
* 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();
|
||||
if (!can(ctx, "tenant:manage")) redirect("/dashboard");
|
||||
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>
|
||||
</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>
|
||||
<legend className="text-sm font-semibold">{t("settings.addressForm")}</legend>
|
||||
<p className="text-[12px] text-muted-foreground">{t("settings.addressFormHint")}</p>
|
||||
@@ -127,7 +137,7 @@ export default async function LotseSettingsPage({ searchParams }: { searchParams
|
||||
<div>
|
||||
<h3 className="font-semibold">{t("settings.sentTitle")}</h3>
|
||||
<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>
|
||||
))}
|
||||
</ul>
|
||||
@@ -150,6 +160,9 @@ export default async function LotseSettingsPage({ searchParams }: { searchParams
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* L17 Pakete: Paket (nur lesend), Lotse-Chat-Plätze, Monatsverbrauch */}
|
||||
<LotseSeatAdmin ctx={ctx} seatSaved={sp.seat === "saved"} seatError={sp.seatError} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { BRAND } from "@/lib/brand";
|
||||
import { updateTenantSettings } from "@/server/actions/tenant-settings";
|
||||
import { ShieldCheck, History } from "lucide-react";
|
||||
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
|
||||
// (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 sp = await searchParams;
|
||||
|
||||
const [s, moduleRows] = await Promise.all([
|
||||
const [s, modules] = await Promise.all([
|
||||
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-
|
||||
@@ -50,7 +51,7 @@ export default async function SettingsPage({
|
||||
}
|
||||
}
|
||||
// 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 ?? "";
|
||||
|
||||
return (
|
||||
|
||||
@@ -29,7 +29,7 @@ import { FINAL_STATUSES, PLANNING_LOCKED } from "@/server/services/work-orders/_
|
||||
import { availableTransitions, getWorkOrderDetail } from "@/server/services/work-orders/detail";
|
||||
import { customerDisplayName, customerOption, teamOptions, userOptions } from "@/server/services/work-orders/options";
|
||||
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
|
||||
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
|
||||
const billingModule = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "billing" }, select: { enabled: true } });
|
||||
const billingOverviewActive = !billingModule || billingModule.enabled;
|
||||
const billingOverviewActive = await billingModuleActive(ctx); // L17: incl. package tier
|
||||
const billingTransitions = transitions.filter(
|
||||
(to) =>
|
||||
(to === "released_for_billing" || (to === "billed" && !billingOverviewActive)) ||
|
||||
@@ -188,7 +187,7 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa
|
||||
<div className="mt-4">
|
||||
<LinkTabs
|
||||
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>
|
||||
|
||||
@@ -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 === "history" && <HistoryTab {...tabProps} />}
|
||||
</div>
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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 { LotseCompletenessCard } from "@/components/lotse/completeness-card";
|
||||
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 }) {
|
||||
const head = (
|
||||
@@ -168,6 +169,7 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ 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 */}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { EmergencyWizard } from "@/components/emergency/emergency-wizard";
|
||||
|
||||
/** `/m/emergency` — Notdienst erfassen (spec §19.2, US-010): max. 3 steps, then straight into the call-out. */
|
||||
export default async function EmergencyPage() {
|
||||
const ctx = ctxFromGuard(await moduleGuard("emergency")());
|
||||
const ctx = ctxFromGuard(await moduleGuard("emergency", { read: true })()); // L15: read path — no trial write lock
|
||||
const t = await getTranslations("emergency.capture");
|
||||
if (!can(ctx, "emergency:create") || !can(ctx, "field:execute")) {
|
||||
return <p className="p-4 text-[15px]">{t("noAccess")}</p>;
|
||||
|
||||
@@ -5,6 +5,8 @@ import { can } from "@/server/services/context";
|
||||
import { fieldPageContext } from "@/server/services/field/page-context";
|
||||
import { getMyActiveSession } from "@/server/services/field/sessions";
|
||||
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 { CraftviaLogo } from "@/components/brand/craftvia-logo";
|
||||
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 { RunningClockBar, type ClockSession } from "@/components/field/running-clock-bar";
|
||||
import { OfflineRuntime } from "@/components/offline/offline-runtime";
|
||||
import { TrialBanner } from "@/components/trial/trial-banner";
|
||||
|
||||
/** L12: own running/paused session + open approvals for the shell (never blocks the page). */
|
||||
async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number }> {
|
||||
async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number; lotseChat: boolean; emergency: boolean }> {
|
||||
try {
|
||||
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 {
|
||||
clock: session
|
||||
? { status: session.status, workOrderId: session.workOrderId, number: session.number, title: session.title, segmentType: session.segmentType, segmentStartedAt: session.segmentStartedAt, closedSeconds: session.closedSeconds }
|
||||
: null,
|
||||
approvals,
|
||||
lotseChat, // L16 (L17: inkl. Chat-Platz)
|
||||
emergency,
|
||||
};
|
||||
} catch {
|
||||
// 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 />
|
||||
</div>
|
||||
</header>
|
||||
{/* L15 Testphase: Countdown ab 7 Tagen bzw. Nur-Lesen-Hinweis */}
|
||||
<TrialBanner tenantId={access.session.user.tenantId} variant="mobile" />
|
||||
<div className={cn("mx-auto w-full max-w-xl flex-1", time.clock ? "pb-48" : "pb-28")}>{children}</div>
|
||||
<RunningClockBar initial={time.clock} />
|
||||
<BottomNav approvals={time.approvals} />
|
||||
<BottomNav approvals={time.approvals} lotse={time.lotseChat} emergency={time.emergency} />
|
||||
</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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { platformAuth } from "@/server/platform-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { MODULES } from "@/lib/modules";
|
||||
import { effectiveTier, isModuleInTier } from "@/lib/plans";
|
||||
import { setTenantStatus, toggleTenantModule, setTenantMfaRequired, setTenantLocale } from "@/server/actions/admin";
|
||||
import { resolveMfaRequired } from "@/lib/mfa-policy";
|
||||
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 { AuditTrailModal, type AuditRow } from "@/components/audit-trail";
|
||||
import { RestoreModalBody, ExportModalBody, DsgvoModalBody, type SnapshotOption, type SubjectOption } from "@/components/backup-admin-panel";
|
||||
import { TrialAdminCard } from "@/components/trial/trial-admin-card";
|
||||
import { PlanAdminCard } from "@/components/plans/plan-admin-card";
|
||||
|
||||
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
|
||||
|
||||
@@ -27,7 +30,7 @@ export default async function AdminTenantPage({
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string }>;
|
||||
searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string; trial?: string; invited?: string; trialDone?: string; plan?: string; planDone?: string }>;
|
||||
}) {
|
||||
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
|
||||
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 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 isOn = (key: string) => moduleState.get(key) ?? true;
|
||||
|
||||
@@ -216,6 +222,20 @@ export default async function AdminTenantPage({
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* L15 Testphase: Enddatum, Umwandlung, Beenden, Löschung (Bestätigung + Plattform-Audit) */}
|
||||
<TrialAdminCard
|
||||
tenant={tenant}
|
||||
isFullAdmin={isFullAdmin}
|
||||
base={base}
|
||||
op={sp.trial === "created" ? undefined : sp.trial}
|
||||
notice={sp.trial === "created" ? (sp.invited ? "invited" : "created") : sp.trialDone ? "done" : null}
|
||||
/>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
<form action={toggleTenantModule.bind(null, tenant.id, m.key, !on)}>
|
||||
<Button type="submit" size="sm" variant="outline">{on ? t("moduleDeactivate") : t("moduleActivate")}</Button>
|
||||
|
||||
@@ -16,16 +16,23 @@ import {
|
||||
import { createTenant } from "@/server/actions/admin";
|
||||
import { getMailStatus } from "@/server/actions/mail";
|
||||
import { MailStatusPanel } from "@/components/mail-status-panel";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { TrialBadge } from "@/components/trial/trial-badge";
|
||||
|
||||
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
|
||||
const STATUS_LABEL: Record<string, string> = { ACTIVE: "Aktiv", SUSPENDED: "Gesperrt", ARCHIVED: "Archiviert" };
|
||||
|
||||
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ new?: string }> }) {
|
||||
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ new?: string; plan?: string }> }) {
|
||||
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
|
||||
const params = await searchParams;
|
||||
// L15 Testphase: Filter Test/Voll
|
||||
const tt = await getTranslations("trial.platform");
|
||||
const tp = await getTranslations("plans");
|
||||
const planFilter = params.plan === "trial" ? "TRIAL" : params.plan === "full" ? "FULL" : null;
|
||||
|
||||
const [tenants, mailStatus] = await Promise.all([
|
||||
prisma.tenant.findMany({
|
||||
where: planFilter ? { plan: planFilter } : undefined,
|
||||
include: { _count: { select: { users: true } }, modules: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
}),
|
||||
@@ -42,6 +49,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
|
||||
actions={
|
||||
<span className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/admins" />}>Administratoren</Button>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/admin/trial" />}>{tt("newTrial")}</Button>
|
||||
<Button nativeButton={false} render={<Link href={params.new ? "/admin" : "/admin?new=1"} />}>
|
||||
<Plus className="size-4" /> Neuer Kunde
|
||||
</Button>
|
||||
@@ -92,6 +100,23 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav aria-label={tt("filter")} className="mt-4 flex flex-wrap gap-2">
|
||||
{[
|
||||
{ key: null, href: "/admin", label: tt("filterAll") },
|
||||
{ key: "TRIAL", href: "/admin?plan=trial", label: tt("filterTrial") },
|
||||
{ key: "FULL", href: "/admin?plan=full", label: tt("filterFull") },
|
||||
].map((f) => (
|
||||
<Link
|
||||
key={f.href}
|
||||
href={f.href}
|
||||
aria-current={planFilter === f.key ? "page" : undefined}
|
||||
className={`inline-flex min-h-11 items-center rounded-full border px-4 text-[13px] font-semibold ${planFilter === f.key ? "border-[var(--ui-primary)] bg-[var(--ui-primary-soft)] text-[var(--ui-primary)]" : "text-muted-foreground hover:bg-muted"}`}
|
||||
>
|
||||
{f.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -99,6 +124,8 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
|
||||
<TableHead>Kunde</TableHead>
|
||||
<TableHead>Kürzel</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>{tt("colPlan")}</TableHead>
|
||||
<TableHead>{tp("colTier")}</TableHead>
|
||||
<TableHead>Nutzer</TableHead>
|
||||
<TableHead>Aktive Module</TableHead>
|
||||
</TableRow>
|
||||
@@ -114,6 +141,9 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{t.slug}</TableCell>
|
||||
<TableCell><Pill tone={STATUS_TONE[t.status]}>{STATUS_LABEL[t.status]}</Pill></TableCell>
|
||||
<TableCell><TrialBadge tenant={t} /></TableCell>
|
||||
{/* 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">{active > 0 ? `${active} Module` : "—"}</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 { ServiceError } from "@/server/services/context";
|
||||
import { uploadWorkOrderDocument } from "@/server/services/work-orders/documents";
|
||||
import { assertTenantWritable } from "@/server/services/trial/state";
|
||||
|
||||
/**
|
||||
* POST /api/v1/work-orders/[id]/documents — multipart upload (file, category, visibility, title?).
|
||||
@@ -17,6 +18,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
|
||||
try {
|
||||
assertSameOrigin(req);
|
||||
const ctx = await requireApiContext("work_orders", "document:write");
|
||||
await assertTenantWritable(ctx.tenantId); // L15: not wrapped in withApi → explicit trial write lock
|
||||
const form = await readFormData(req);
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File) || file.size === 0) throw new ServiceError("invalid", "file_missing");
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")} />;
|
||||
}
|
||||