Compare commits

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 10:13:27 +02:00
67 changed files with 2258 additions and 103 deletions
+2 -1
View File
@@ -20,7 +20,7 @@ Alle Routen antworten im Fehlerfall mit `Cache-Control: no-store` und
| Code | HTTP | Bedeutung / `details` | | Code | HTTP | Bedeutung / `details` |
|---|---|---| |---|---|---|
| `unauthorized` | 401 | nicht angemeldet, Konto inaktiv, Sitzung invalidiert | | `unauthorized` | 401 | nicht angemeldet, Konto inaktiv, Sitzung invalidiert |
| `forbidden` | 403 | Recht fehlt, Modul deaktiviert, Passwortwechsel nötig, Cross-Site-Request | | `forbidden` | 403 | Recht fehlt, Modul deaktiviert, Passwortwechsel nötig, Cross-Site-Request; Modul/Funktion nicht im Paket (L17: `details = { reason: "not_in_plan", module?, message }`) |
| `not_found` | 404 | unbekannt, fremder Mandant oder außerhalb des Scopes | | `not_found` | 404 | unbekannt, fremder Mandant oder außerhalb des Scopes |
| `conflict` | 409 | Versionskonflikt (`baseVersion`), Doppelbestätigung/unzulässiger Zustand, mögliche Dubletten (`details.reason = "possible_duplicates"`, `details.candidates`) | | `conflict` | 409 | Versionskonflikt (`baseVersion`), Doppelbestätigung/unzulässiger Zustand, mögliche Dubletten (`details.reason = "possible_duplicates"`, `details.candidates`) |
| `invalid` | 422 | Validierung (Zod: `details = [{ path, code }]`), fehlerhaftes JSON/Multipart | | `invalid` | 422 | Validierung (Zod: `details = [{ path, code }]`), fehlerhaftes JSON/Multipart |
@@ -40,6 +40,7 @@ Alle Routen antworten im Fehlerfall mit `Cache-Control: no-store` und
- **Idempotenz:** Eine wiederholte `clientOpId` (je Mandant) liefert `duplicate` mit dem gespeicherten Ergebnis. Ist die ID bereits durch einen anderen Nutzer belegt, wird die Operation `rejected`. - **Idempotenz:** Eine wiederholte `clientOpId` (je Mandant) liefert `duplicate` mit dem gespeicherten Ergebnis. Ist die ID bereits durch einen anderen Nutzer belegt, wird die Operation `rejected`.
- **Konflikte:** `work_order.transition` und `report.submit` verlangen `baseVersion`. Weicht sie von `WorkOrder.version` ab, lautet das Ergebnis `conflict`, `entityVersion` ist dann die aktuelle Version. Alle anderen Operationen sind additiv (Client-IDs in den Payloads, z. B. `clientId`, werden über `idMap` auf Server-IDs abgebildet). - **Konflikte:** `work_order.transition` und `report.submit` verlangen `baseVersion`. Weicht sie von `WorkOrder.version` ab, lautet das Ergebnis `conflict`, `entityVersion` ist dann die aktuelle Version. Alle anderen Operationen sind additiv (Client-IDs in den Payloads, z. B. `clientId`, werden über `idMap` auf Server-IDs abgebildet).
- Den opType-Katalog mit den Payload-Schemas enthält `src/lib/sync/ops.ts` (Spec: Komponenten `SyncPayload*`). - Den opType-Katalog mit den Payload-Schemas enthält `src/lib/sync/ops.ts` (Spec: Komponenten `SyncPayload*`).
- L17 Pakete: Operationen eines Moduls außerhalb des Pakets bzw. eines ausgeschalteten Moduls (`emergency.create` → Notdienst, `milestone.reach` → Abrechnung) werden gespeichert als `rejected` mit `errorCode = "forbidden"` und `message = "not_in_plan: <Klartext>"` bzw. `"module_disabled: <Klartext>"`.
- REST-Schreibrouten für Aufträge (`PATCH /work-orders/{id}`, `/assign`, `/transition`) akzeptieren optional `baseVersion` und antworten bei Abweichung mit `409`. - REST-Schreibrouten für Aufträge (`PATCH /work-orders/{id}`, `/assign`, `/transition`) akzeptieren optional `baseVersion` und antworten bei Abweichung mit `409`.
## Uploads ## Uploads
+37
View File
@@ -236,6 +236,43 @@ verarbeitet `tenant-export` (Datenexport). Details, Schreibsperre und manueller
[TESTPHASE.md](TESTPHASE.md). Vor dem Go-live die Platzhaltertexte für Nutzungsbedingungen und [TESTPHASE.md](TESTPHASE.md). Vor dem Go-live die Platzhaltertexte für Nutzungsbedingungen und
Datenschutz (`messages/*/trial.json` → `legal.*`) ersetzen. Datenschutz (`messages/*/trial.json` → `legal.*`) ersetzen.
### 7.5 Pakete Basis/Profi und Lotse-Chat-Plätze (L17)
Die App rechnet nicht ab – sie setzt das Paket durch, verwaltet Chat-Plätze und zählt die Nutzung,
damit der Betreiber außerhalb der App die Rechnung stellt. Regeln: `src/lib/plans.ts`, Durchsetzung:
`src/server/plan.ts`.
| Paket | Inhalt |
|---|---|
| **Basis** | Aufträge, Kunden, Objekte, Teams, mobile App/Offline, Berichte + Unterschrift + PDF-Versand, Zeiterfassung inkl. Freigabe, Dokumente, Benachrichtigungen – **ohne KI und Planung** |
| **Profi** (Default) | zusätzlich Planung (Plantafel, Live-Lage, Kapazität, Empfehlungen, Verzugs-/Frühfertig-Hinweise), Abrechnungsübersicht, Notdienst, Auftragsimport, Lotse (Berichtsentwurf, Vollständigkeitsprüfung, Sprachnotiz-Zusammenfassung) und – je Platz – der Lotse-Chat |
**Paket, Plätze, hartes Limit setzen** (nur Plattform-Voll-Admins): Betreiber-Portal → Mandant
öffnen → Karte „Paket & Lotse-Chat“ → „Paket ändern …“ → Paket, Anzahl gebuchter Chat-Plätze
(0–1000) und „Hartes Limit“ wählen, Bestätigung ankreuzen, speichern. Jede Änderung steht im
Audit-Trail des Mandanten (`tenant_plan`, Scope Plattform, vorher/nachher). Die Mandantenliste zeigt
das Paket als Badge. Mandanten-Admins sehen das Paket nur lesend und vergeben unter
`/settings/lotse` die gebuchten Plätze an Nutzer mit Feldrolle (`lotse:use` + `field:execute`);
mehr als gebucht ist serverseitig ausgeschlossen. Senkt der Betreiber die Anzahl unter die vergebenen
Plätze, gelten nur die zuerst vergebenen; der Admin sieht „Über der Platzanzahl“ und entzieht Plätze.
**Wechsel Profi → Basis:** Daten bleiben vollständig erhalten (Einsätze, Abrechnungseinträge, Plätze,
Chatverläufe), nur der Zugriff endet: Seiten leiten auf das Dashboard mit dem Hinweis „Im Paket Profi
enthalten“, Actions/API antworten `403` (`details.reason = "not_in_plan"`), Offline-Operationen eines
gesperrten Moduls (Notdienst, Meilensteine) werden mit Klartext abgewiesen. Zurück auf Profi ist alles
wieder da. **Testphase** (`plan = TRIAL`) läuft immer als Profi ohne Platzprüfung.
**Kontingent und Mehrverbrauch ablesen:** Ein Chat = eine Nachricht eines Nutzers an den Lotsen. Je
vergebenem Platz 150 Chats pro Kalendermonat (Zeitzone des Mandanten), gemeinsam für den Betrieb
(Testphase: berechtigte Nutzer × 150). Darüber wird weitergezählt; abgerechnet wird der Mehrverbrauch in
Paketen à 100 (angefangene Pakete zählen). Die Karte im Betreiber-Portal zeigt für den **laufenden
Monat und den Vormonat** „x von y Chats“, den Mehrverbrauch und die Anzahl der 100er-Pakete – das ist
die Grundlage der Rechnung (Vormonat nach Monatsende ablesen). Mit „Hartes Limit“ wird der Chat beim
Kontingent gesperrt (Monteure sehen einen Hinweis statt des Eingabefelds, kein Mehrverbrauch). Die
Token-Obergrenze (`AI_MONTHLY_TOKEN_LIMIT`, `services/lotse/budget.ts`) bleibt als Sicherheitsnetz
unabhängig davon aktiv. Hinweis: Chatverläufe werden nach `AI_GENERATION_RETENTION_DAYS` gelöscht –
den Wert über ~62 Tagen lassen, sonst fehlen Vormonats-Chats in der Zählung.
## 8. Rate Limits ## 8. Rate Limits
| Bereich | Env | Default | Zählung | | Bereich | Env | Default | Zählung |
+102
View File
@@ -0,0 +1,102 @@
# Lane L17 – Pakete (Basis/Profi) & Lotse-Chat pro Nutzer (`lane/pakete`)
Stand: 2026-09-21 · Basis `4d5be72` (`feature/craftvia-mvp`) · Betriebsdoku: [../DEPLOY.md §7.5](../DEPLOY.md)
Ziel: Zwei Pakete durchsetzen und den Lotse-Chat als Zusatzmodul pro Nutzer mit gemeinsamem Kontingent führen. **Keine Abrechnung in der App** – die App setzt Pakete durch, verwaltet Plätze und zählt Nutzung; der Betreiber stellt die Rechnung außerhalb.
## 1. Umfang / umgesetzte Entscheidungen
| Punkt | Umsetzung |
|---|---|
| **1 Datenmodell** | Migration `20260921100000_pakete` (additiv): `enum TenantTier { BASIS PROFI }`, am `Tenant` `tier` (Default `PROFI` → alle Bestandsmandanten und Testphasen unverändert), `lotseChatSeats Int @default(0)`, `lotseChatHardLimit Boolean @default(false)` (Plattform-Daten, keine RLS). Neue Mandanten-Tabelle `lotse_chat_seats` (`LotseChatSeat`: tenantId, userId, assignedById, createdAt; unique tenantId+userId) mit `enable_tenant_rls`, in beiden `TENANT_MODELS`, `userId`/`assignedById` in `pii-fields.ts`. Index `lotse_messages(tenant_id, role, created_at)` für die Monatszählung. |
| **2 Zentrale Regeln** `src/lib/plans.ts` (client-sicher, rein) | Stufen, `PROFI_ONLY_MODULES = billing, emergency, imports, lotse`, Features ohne eigenes Modul `planning`, `lotse_chat`; `effectiveTier` (Testphase → Profi), `isModuleInTier`, `isFeatureInTier`, `LOTSE_CHAT_CHATS_PER_SEAT = 150`, `LOTSE_CHAT_OVERAGE_PACK = 100`, `chatQuota`, `chatOverage`, `overagePacks` (angefangene Pakete zählen), `usageLevel` (ok / warn ab 80 % / exhausted / over), `usagePercent`, `isQuotaBlocked`, Monatsschlüssel. |
| **3 Durchsetzung an einer Stelle** `src/server/plan.ts` | `moduleState(tenantId, key)` = `not_in_plan` (Stufe) → `disabled` (TenantModule) → `enabled`; `effectiveModules` (Navigation/Übersichten), `isModuleActive`, `assertPlanFeature`/`requirePlanFeature` (Planung). Genutzt von **`requireModule`** (Layouts: nicht im Paket → `/dashboard?module=profi` mit ruhigem Hinweis „Im Paket Profi enthalten“), **`assertModuleEnabled`** (moduleGuard + `requireApiContext`; `ModuleDisabledError.reason = "not_in_plan"`, Audit `denied`, API → `403` mit `details.reason = "not_in_plan"` + Klartext), **Sync** (`services/sync/apply.ts#OP_MODULES`: `emergency.create` → emergency, `milestone.reach` → billing; gesperrt → gespeichert `rejected`/`forbidden` mit `not_in_plan: <Klartext>` bzw. `module_disabled: …`, Gerät zeigt eigenen Text `offline.problem.notInPlan|moduleDisabled`), **Navigation** (Backoffice-Sidebar über `effectiveModules` + `NavItem.feature`, mobile Bottom-Navigation ohne „Notdienst“/„Lotse“), **`isLotseEnabled`** (Berichtsentwurf, Vollständigkeitsprüfung, Sprachnotiz-Zusammenfassung, Transkription), **Planung** (`planningAccess` → Plantafel, Live-Lage, Kapazität, Empfehlungen, Frühfertig, Dashboard-Kacheln; zusätzlich `scheduleWorkOrder`, `updateTeamPlanningSettings`, `runPlanningWatch` überspringt Basis; Planungs-Layout `requirePlanFeature`). Abrechnungs-Schalter (`billingModuleActive`, Auftragsdetail) laufen ebenfalls über `plan.ts` – Basis verhält sich wie „Abrechnungsübersicht deaktiviert“ (direktes „abgerechnet“, Tab ausgeblendet). |
| **4 Lotse-Chat-Plätze** | `services/lotse/chat/access.ts` ist die eine Prüfstelle: Rechte → Stufe (`blocked not_in_plan`) → Lotse an (`disabled`) → Chat-Schalter (`chat_disabled`) → Platz (`blocked no_seat`, Testphase ohne Platzprüfung) → beim **Senden** Kontingent mit hartem Limit (`blocked quota_exhausted`). `canUseLotseChat` (Navigation, „Lotse fragen“) schließt den Platz ein. `seats.ts`: berechtigt = aktive Nutzer mit `lotse:use` + `field:execute` (DB-Rollen), Vergabe/Entzug `setChatSeat` (tenant:manage, Zod, Audit `lotse_chat_seat` before/after, idempotent, serialisiert über eine Zeilensperre der Mandanten-Einstellungen → parallele Vergaben überschreiten die Grenze nicht). Gültig sind die ältesten `lotseChatSeats` Zuweisungen (Überbelegung nach Senkung durch den Betreiber sichtbar). `usage.ts`: Chats im Monat gesamt und je Nutzer (Kalendermonat in der Mandanten-Zeitzone, DST-sicher), Kontingent = gezählte Plätze × 150 (Testphase: Berechtigte × 150, kein hartes Limit), Mehrverbrauch, 100er-Pakete, Stufe, Sperre. Die Token-Obergrenze (`budget.ts`) bleibt unverändert als Sicherheitsnetz. |
| **5 Mandanten-Admin** `/settings/lotse` | Neue Karte „Paket & Lotse-Chat“: Paket nur lesend (Badge, Testphase-Hinweis), „x von y Plätzen vergeben“, Liste aller Nutzer mit Feldrolle mit Schalter je Nutzer (Button `role="switch"`, ≥ 44 px, Status als Text + Icon, deaktiviert ohne freien Platz – serverseitig ohnehin geprüft), Monatsverbrauch als Balken (`role="meter"`) mit Text „212 von 450 Chats“, Hinweis ab 80 %, bei ausgeschöpftem Kontingent / Mehrverbrauch (inkl. Paketzahl) / hartem Limit, Aufschlüsselung je Nutzer. Basis: Hinweis, Plätze bleiben gespeichert. Action `actions/lotse/seats.ts` (`moduleGuard("lotse")` + `guard("tenant:manage")`). |
| **6 Mobil** | Ohne Platz kein Eintrag „Lotse“ in der Bottom-Navigation und kein „Lotse fragen“; `/m/lotse` zeigt „Der Lotse-Chat ist für dich noch nicht freigeschaltet – frag im Büro nach.“ Basis: Hinweis statt Weiterleitung (Lotse-Layout). Hartes Limit erreicht: Hinweis statt Eingabefeld (Verlauf bleibt lesbar, Chips deaktiviert); auch nach einer abgewiesenen Nachricht. |
| **7 Betreiber** `/admin/[id]` | Karte „Paket & Lotse-Chat“: Stufe, Plätze vergeben/gebucht, hartes Limit, Chats laufender Monat und Vormonat („x von y Chats“), Mehrverbrauch → 100er-Pakete (Grundlage der Rechnung). „Paket ändern …“ (nur Voll-Admins) → Popup mit Stufe, Platzanzahl (0–1000), hartem Limit und Pflicht-Bestätigung; `services/plans/platform.ts#updateTenantPlan` prüft den Akteur gegen den PlatformAdmin-Store, Zod, Plattform-Audit `tenant_plan` (before/after). Mandantenliste: Spalte „Paket“ als Badge; Modul-Popup markiert Module außerhalb der Stufe („Nur im Paket Profi“). |
| **8 Demo-Seed** | `demo` = Profi mit 3 Plätzen für Max, Nora, Paul (Monteure; Chat funktioniert weiter), `demo2` = Basis (zeigt die Sperren). Idempotent (`seedDemoPlan`). |
| **9 i18n/Doku** | Namespace `plans` (de/en), Ergänzungen in `lotse.json` (`chat.errors.not_in_plan/no_seat/quota_exhausted`, `chat.quotaExhausted`) und `offline.json` (`problem.notInPlan/moduleDisabled`); `DEPLOY.md §7.5` (wie der Betreiber Pakete/Plätze setzt, wie Mehrverbrauch abgelesen wird), `API.md` (403-Details, Sync-Abweisung). |
### Entscheidungen
- **Admin als Platzinhaber:** Berechtigung = Rechte `lotse:use` + `field:execute` (nicht der Rollenname). Der Mandantenadministrator hat alle Rechte und kann sich daher selbst einen Platz geben; Backoffice nicht.
- **Kontingent „je vergebenem Platz“:** zählt vergebene Plätze, höchstens die gebuchte Anzahl.
- **Sync-Abweisung ist endgültig** (gespeichert, wie andere deterministische Ablehnungen). Nach einem Upgrade muss der Monteur den Notdienst erneut erfassen.
- **Hartes Limit gilt nicht in der Testphase** (keine gebuchten Plätze; Token-Budget bleibt).
- **Basis sperrt auch die Platzvergabe** (moduleGuard `lotse`), Plätze bleiben gespeichert.
## 2. Dateien
**Neu**
- `prisma/migrations/20260921100000_pakete/migration.sql`
- `src/lib/plans.ts`, `src/server/plan.ts`
- `src/server/services/lotse/chat/{seats,usage}.ts`, `src/server/services/plans/platform.ts`
- `src/server/actions/lotse/seats.ts`, `src/server/actions/plans-platform.ts`
- `src/components/plans/{profi-hint,plan-admin-card,plan-form}.tsx`, `src/components/lotse/chat/{seat-admin,unavailable}.tsx`
- `messages/{de,en}/plans.json`, dieser Bericht
- Tests: `scripts/test-pakete-{rules,gates,seats}.ts`
**Eingriffe in Fundament-/Fremddateien (klein, markiert „L17“)**
| Datei | Eingriff | Grund |
|---|---|---|
| `prisma/schema.prisma` | 3 Tenant-Felder, Enum, Modell `LotseChatSeat`, Index an `LotseMessage` | Punkt 1 |
| `src/server/db.ts`, `src/server/backup/topology.ts`, `src/server/dsgvo/pii-fields.ts` | `LotseChatSeat` | Pflicht neue Tenant-Tabelle |
| `src/server/modules.ts` | `requireModule`/`assertModuleEnabled` über `moduleState`; `ModuleDisabledError.reason` | zentrale Durchsetzung |
| `src/server/api/respond.ts` | 403 mit `details.reason = not_in_plan` | Klartext für Clients |
| `src/lib/nav.ts`, `src/app/(app)/layout.tsx` | `NavItem.feature`, `lockedFeatures`; effektive Module | Navigation |
| `src/app/(app)/dashboard/page.tsx` (L2) | Hinweis `?module=profi`, Kacheln Abrechnung/Notdienst nur bei aktivem Modul | ruhiger Hinweis |
| `src/app/(app)/settings/page.tsx` | Modulübersicht über effektive Module | Anzeige |
| `src/app/(app)/work-orders/[id]/page.tsx`, `src/server/services/work-orders/transition.ts` (L2/L14) | `billingModuleActive` über `plan.ts`, Tab „Meilensteine & Abrechnung“ nur bei aktiver Abrechnung | Basis = Abrechnung aus |
| `src/server/services/planning/{access,schedule,team-settings,watch}.ts`, `src/app/(app)/planning/layout.tsx` (L13) | Feature-Prüfung `planning` | Planung nur Profi |
| `src/server/services/sync/apply.ts` (L4), `src/lib/offline/outbox-core.ts` (L7), `messages/{de,en}/offline.json` | `OP_MODULES` + Klartext; `problemKey` | Sync-Sperre |
| `src/app/(field)/m/layout.tsx`, `src/components/field/bottom-nav.tsx` (L4) | Notdienst-Eintrag nur bei aktivem Modul, Spaltenzahl dynamisch | mobile Navigation |
| `src/server/services/lotse/{settings.ts,chat/access.ts,chat/engine.ts,chat/transcribe.ts}`, `src/lib/lotse/chat.ts`, `src/server/actions/lotse/_chat-state.ts`, `src/components/lotse/chat/lotse-chat.tsx`, `src/app/(field)/m/(core)/lotse/{layout,page}.tsx`, `src/app/(app)/settings/lotse/page.tsx`, `messages/{de,en}/lotse.json` (L9/L16) | Stufe in `isLotseEnabled`, Platz/Kontingent im Chat-Gate, Hinweise, Admin-Karte | Punkte 4–6 |
| `src/app/(platform)/admin/page.tsx`, `admin/[id]/page.tsx` | Badge-Spalte, Karte, Modul-Popup-Markierung | Punkt 7 |
| `src/components/audit-trail.tsx` | Labels `lotse_chat_seat`, `tenant_plan` | Audit-Viewer |
| `scripts/check-module-guards.ts` | `plans-platform.ts` EXEMPT | Top-Level-Action |
| `scripts/lib/demo-seed.ts` | `seedDemoPlan`, demo Profi + 3 Plätze, demo2 Basis | Punkt 8 |
| `scripts/lib/e2e-fixture.ts`, `scripts/test-e2e-tenant-isolation.ts` | Cleanup + Fixture-Zeile `LotseChatSeat` | Test verlangt alle Tenant-Modelle |
| `scripts/lib/lotse-chat-fixture.ts` (L16) | 10 Plätze + Sitz je Testnutzer | bestehende Chat-Tests brauchen jetzt einen Platz |
| `scripts/smoke-auth.ts` | L17-Prüfungen (Admin-Karte, Teamleiterin ohne Platz, Monteur mit Platz, demo2 Basis) | Smoke |
| `docs/craftvia/{DEPLOY,API}.md` | §7.5, 403/Sync | Betrieb |
Keine neuen npm-Abhängigkeiten, keine Änderung an `rbac.ts`, keine neuen env-Variablen.
## 3. Tests
| Skript | Prüfungen | Inhalt |
|---|---|---|
| `test-pakete-rules.ts` | 51 | Stufen, Testphase = Profi, Module/Features je Stufe, Kontingent/Mehrverbrauch/100er-Pakete, Warnstufen, hartes Limit, Monatsschlüssel, Navigation Basis/Profi |
| `test-pakete-gates.ts` | 74 | Default Profi; Layout-Guard-Funktionen (`moduleState`, `requirePlanFeature` → Redirect `?module=profi`, Quelltextnachweis `requireModule`/Planungs-Layout); Action (`assertModuleEnabled` → `not_in_plan` + Audit) für billing/emergency/imports/lotse, Basis-Module frei; API 403 mit Klartext, Planungs-API 403; Planung (Plantafel, Live-Lage, Einplanen, Empfehlungen, Kapazität, Dashboard-Kachel, Watch) gesperrt / Profi erlaubt; `isLotseEnabled`, Abrechnungsschalter, Navigation; Sync: `emergency.create`/`milestone.reach` → rejected mit Klartext, gespeichert, kein Auftrag, Duplikat, Gerätetext; Profi → angewendet; TenantModule aus + Profi → gesperrt (Action, Sync); nur Betreiber ändert die Stufe (Mandanten-Admin, Nur-Lesen-Admin → forbidden, ungültige Werte → invalid, Mandanten-Einstellungen ändern keine Stufe); Wechsel Profi → Basis → Profi ohne Datenverlust mit Plattform-Audit; Mandantentrennung |
| `test-pakete-seats.ts` | 68 | berechtigte Nutzer; ohne Platz → `no_seat` (View, Senden, nichts gespeichert); Vergabe bis zur Grenze, darüber `invalid no_seats_left`, parallele Vergabe (genau eine), idempotent, Audit create/delete; mit Platz → ok/Senden; Entzug; Überbelegung nach Senkung; Feldrollen/Backoffice → forbidden (Vergabe, Übersicht, Verbrauch); ohne Feldrolle / deaktiviert → `not_eligible`; Mandantentrennung (B vergibt/entzieht nichts von A, sieht keine Plätze/Zählung); Monatsgrenze in Europe/Berlin inkl. Sommerzeit, Antworten zählen nicht, je Nutzer; Mehrverbrauch weiterzählen (305/300 → 1 Paket), hartes Limit → `quota_exhausted` ohne Speichern, Verlauf lesbar, B unberührt; Testphase ohne Platzprüfung (Kontingent Berechtigte × 150, kein hartes Limit); Basis sperrt Chat und Vergabe, Plätze bleiben; Betreiber-Übersicht laufender Monat/Vormonat |
**Gate (`npm run gate`) grün:** prisma generate, tsc, lint (0 Fehler; 3 bestehende Warnungen in fremden Dateien), build inkl. Guard-Check (42 Action-Dateien), **87/87 Testskripte** (vorher 84 + 3 neue). Bestehende Tests unverändert grün; angepasst wurde nur die L16-Chat-Fixture (Plätze).
**RLS-Lauf** `RLS_ENFORCED=true` (Lane-DB `craftvia_pakete`, Rolle `craftvia_app`): `test-pakete-gates`, `test-pakete-seats`, `test-e2e-tenant-isolation`, `test-rls-enforcement`, `test-lotse-chat-engine` – 5/5 grün.
**HTTP-Smoke** (`next build && next start -p 3117`, `scripts/smoke-auth.ts` ohne Passworteingabe): **152/155** – alle neuen L17-Prüfungen grün (Admin: Karte „Paket & Lotse-Chat“, „3 von 3 Plätzen vergeben“; Teamleiterin ohne Platz: kein Nav-Eintrag, Hinweis „frag im Büro nach“, kein „Lotse fragen“; Monteur mit Platz: Chat, Nav-Einträge Lotse/Notdienst; demo2 Basis: Planung/Abrechnung/Import/Notdienst-Prüfung → `/dashboard?module=profi` mit Hinweis und ohne gesperrte Links, `/api/v1/planning/board` und `/api/v1/billing` → 403). Die **3 Fehlschläge sind vorbestehend**: demo2-Seiten `/dashboard`, `/work-orders`, `/customers` enthalten „Elbblick“, weil `messages/*/marketing.json` (Produktseite, Commits `31343d3`/`4815a95`) den Beispieltext „Wohnanlage Elbblick“ enthält und die Messages an den Client serialisiert werden – kein Datenleck, aber die Isolationsprüfung schlägt an. Server danach beendet.
**Visuell:** nicht im Browser geprüft (Anmeldung hätte Passwort- oder Token-Eingabe erfordert). Bitte `/settings/lotse` (1024/768 px), `/admin/<id>` (Karte + Popup) und `/m/lotse` ohne Platz / bei hartem Limit (375 px) ansehen.
## 4. Offene Punkte
1. **Smoke-Isolationsprüfung „Elbblick“** (vorbestehend, s. o.): Beispieltext in `marketing.json` umbenennen oder die Prüfung auf Daten-Marker umstellen.
2. **Vormonat mit heutiger Platzzahl:** Die Betreiber-Karte rechnet das Kontingent des Vormonats mit den *aktuell* vergebenen Plätzen (keine Historie der Platzanzahl). Ändert sich die Platzzahl im Monat, ist der Vormonatswert eine Näherung → ggf. monatlichen Snapshot (Tabelle) nachziehen.
3. **Aufbewahrung:** Die Zählung basiert auf `LotseMessage`; `AI_GENERATION_RETENTION_DAYS` muss > ~62 Tage bleiben (Default 180), sonst fehlen Vormonats-Chats. Eine DSGVO-Löschung eines Nutzers entfernt auch seine Chats aus der Zählung.
4. **Hartes Limit unter Parallelität:** geprüft vor dem Speichern; gleichzeitige Nachrichten können das Kontingent um wenige Chats überschreiten.
5. **Worker:** `import-extraction` prüft die Stufe nicht (nur die Testphasen-Sperre). Nach einem Wechsel auf Basis laufen bereits eingereihte Importe noch durch; neue Importe sind gesperrt. Transkription ist über `isLotseEnabled` gesperrt.
6. **Abrechnungseinträge im Hintergrund:** Der L14-Event-Hook legt auch bei Basis weiter Abrechnungskandidaten an (unsichtbar, nach Upgrade vorhanden) – bewusst belassen („Daten bleiben erhalten“).
7. **Sync-Abweisung endgültig:** Eine im Basis-Paket offline erfasste Notdienst-Operation wird nach einem Upgrade nicht automatisch nachgeholt.
8. `services/emergency/sync-ops.ts` prüft den Modulschalter weiterhin selbst (redundant zur zentralen Sync-Prüfung, unverändert).
## 5. Screens / Routen
| Route | Zugriff | Inhalt |
|---|---|---|
| `/dashboard?module=profi` | Backoffice | Hinweis „Im Paket Profi enthalten“ (Ziel aller gesperrten Profi-Seiten) |
| `/settings/lotse` | `tenant:manage` | Karte „Paket & Lotse-Chat“: Paket, Plätze, Schalter je Nutzer, Monatsverbrauch |
| `/m/lotse` | Feldrollen | ohne Platz / Basis: Hinweis; hartes Limit: Hinweis statt Eingabefeld |
| `/admin` | Plattform | Spalte „Paket“ |
| `/admin/<id>?plan=edit` | Plattform-Voll-Admin | Karte „Paket & Lotse-Chat“ + Popup „Paket & Chat-Plätze ändern“ |
+6 -2
View File
@@ -317,11 +317,15 @@
"transition_forbidden": "Dafür fehlt die Berechtigung.", "transition_forbidden": "Dafür fehlt die Berechtigung.",
"not_editable": "Der Bericht ist eingereicht und kann nicht mehr geändert werden.", "not_editable": "Der Bericht ist eingereicht und kann nicht mehr geändert werden.",
"reason_required": "Der Grund fehlt.", "reason_required": "Der Grund fehlt.",
"use_billing_overview": "Nur über die Abrechnungsübersicht möglich." "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": { "unavailable": {
"title": "Lotse-Chat nicht verfügbar", "title": "Lotse-Chat nicht verfügbar",
"back": "Zur Übersicht" "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."
} }
} }
+1 -1
View File
@@ -128,7 +128,7 @@
"title": "Sagen, was war — statt Felder suchen", "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.", "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", "chatUser": "Auftrag fertig, Speicher eingebaut, 2 Stunden, einen Filter verbraucht",
"chatAssistant": "Ich ordne das Auftrag A-00042 zu, Wohnanlage Elbblick. Zwei Fragen: War die Anfahrt dabei, und soll ich den Auftrag technisch abschließen?", "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", "cardTitle": "Vorschlag",
"cardLine1": "2:00 Std. Arbeitszeit buchen", "cardLine1": "2:00 Std. Arbeitszeit buchen",
"cardLine2": "1 × Filter als Material erfassen", "cardLine2": "1 × Filter als Material erfassen",
+3 -1
View File
@@ -92,7 +92,9 @@
"internal": "Technischer Fehler – wird automatisch erneut versucht.", "internal": "Technischer Fehler – wird automatisch erneut versucht.",
"timeOverlap": "Der Zeitnachtrag überschneidet sich mit einer bereits erfassten Zeit und wurde nicht übernommen.", "timeOverlap": "Der Zeitnachtrag überschneidet sich mit einer bereits erfassten Zeit und wurde nicht übernommen.",
"timeWindow": "Der Zeitnachtrag liegt außerhalb der erlaubten 7 Tage, in der Zukunft oder ist zu lang und wurde nicht übernommen.", "timeWindow": "Der Zeitnachtrag liegt außerhalb der erlaubten 7 Tage, in der Zukunft oder ist zu lang und wurde nicht übernommen.",
"otherSession": "Auf einem anderen Auftrag lief noch eine Zeiterfassung – der Start wurde nicht übernommen. Bitte erneut starten und wechseln." "otherSession": "Auf einem anderen Auftrag lief noch eine Zeiterfassung – der Start wurde nicht übernommen. Bitte erneut starten und wechseln.",
"notInPlan": "Im Paket Profi enthalten – für diesen Betrieb nicht freigeschaltet. Die Eingabe wurde nicht übernommen, bitte im Büro melden.",
"moduleDisabled": "Diese Funktion ist für diesen Betrieb ausgeschaltet. Die Eingabe wurde nicht übernommen, bitte im Büro melden."
}, },
"view": { "view": {
"title": "Offline-Ansicht", "title": "Offline-Ansicht",
+93
View File
@@ -0,0 +1,93 @@
{
"tier": {
"BASIS": "Basis",
"PROFI": "Profi"
},
"colTier": "Paket",
"notInTier": "Nur im Paket Profi",
"profiHint": {
"title": "Im Paket Profi enthalten",
"text": "Diese Funktion gehört zum Paket Profi. Ihr Betrieb nutzt das Paket Basis – für ein Upgrade wenden Sie sich bitte an Ihren Craftvia-Ansprechpartner."
},
"admin": {
"title": "Paket & Lotse-Chat",
"sub": "Paket des Betriebs, Chat-Plätze für Monteure und Verbrauch im laufenden Monat",
"package": "Paket",
"trial": "Testphase (alle Funktionen)",
"packageReadOnly": "Paket und Anzahl der Chat-Plätze legt der Anbieter fest. Hier vergeben Sie die gekauften Plätze an Ihre Monteure und Teamleiter.",
"basisHint": "Lotse und Lotse-Chat sind im Paket Profi enthalten. Bereits vergebene Plätze und Chatverläufe bleiben gespeichert und sind nach einem Wechsel zu Profi wieder verfügbar.",
"seatsTitle": "Chat-Plätze",
"seatsCount": "{assigned} von {purchased} Plätzen vergeben",
"trialSeats": "Während der Testphase dürfen alle Monteure und Teamleiter den Lotse-Chat nutzen – Plätze müssen nicht vergeben werden (Kontingent: {perSeat} Chats je Person und Monat).",
"noSeatsBought": "Noch keine Chat-Plätze gebucht. Plätze bucht der Anbieter für Ihren Betrieb.",
"overbooked": "Mehr Plätze vergeben als gebucht ({purchased}). Nur die zuerst vergebenen Plätze gelten – bitte Plätze entziehen.",
"noFieldUsers": "Keine aktiven Nutzer mit Feldrolle (Monteur, Teamleiter).",
"seatAssigned": "Platz vergeben",
"seatOverbooked": "Über der Platzanzahl",
"seatNone": "Kein Platz",
"assign": "Platz vergeben",
"revoke": "Platz entziehen",
"assignLabel": "Chat-Platz an {name} vergeben",
"revokeLabel": "Chat-Platz von {name} entziehen",
"saved": "Chat-Platz gespeichert.",
"usageTitle": "Verbrauch {month}",
"usageText": "{used} von {quota} Chats",
"quotaRule": "Kontingent: {perSeat} Chats je vergebenem Platz und Monat, gemeinsam für den Betrieb (Basis: {basis}). Ein Chat ist eine Nachricht an den Lotsen.",
"warn80": "{percent} % des Monatskontingents sind genutzt.",
"exhausted": "Das Monatskontingent ist ausgeschöpft. Weitere Chats werden als Mehrverbrauch in Paketen à {pack} berechnet.",
"hardLimitReached": "Das Monatskontingent ist ausgeschöpft. Der Lotse-Chat ist bis zum Monatsende gesperrt (hartes Limit).",
"overage": "Mehrverbrauch: {overage} Chats – entspricht {packs, plural, one {# Paket} other {# Paketen}} à {pack} Chats.",
"perUserCaption": "Chats je Nutzer im laufenden Monat",
"perUserName": "Nutzer",
"perUserChats": "Chats",
"errors": {
"no_seats_left": "Alle gebuchten Plätze sind vergeben. Bitte zuerst einen Platz entziehen oder weitere Plätze beim Anbieter buchen.",
"not_eligible": "Diese Person kann keinen Chat-Platz erhalten (keine aktive Feldrolle).",
"not_in_plan": "Der Lotse-Chat ist im Paket Profi enthalten.",
"disabled": "Lotse ist für diesen Betrieb ausgeschaltet.",
"forbidden": "Dafür fehlt die Berechtigung.",
"trial_expired": "Testphase abgelaufen – nur Lesezugriff.",
"failed": "Speichern nicht möglich."
}
},
"platform": {
"title": "Paket & Lotse-Chat",
"tier": "Paket",
"trialProfi": "Testphase = Profi",
"seats": "Chat-Plätze",
"seatsValue": "{assigned} vergeben / {purchased} gebucht",
"hardLimit": "Hartes Limit",
"hardLimitOn": "an – Sperre beim Kontingent",
"hardLimitOff": "aus – Mehrverbrauch wird gezählt",
"rule": "{perSeat} Chats je vergebenem Platz und Monat (Zeitzone des Mandanten); Mehrverbrauch in Paketen à {pack}.",
"currentMonth": "Laufender Monat · {month}",
"previousMonth": "Vormonat · {month}",
"chats": "{used} von {quota} Chats",
"overage": "Mehrverbrauch {overage} Chats → {packs, plural, one {# Paket} other {# Pakete}} à {pack}",
"noOverage": "Kein Mehrverbrauch",
"edit": "Paket ändern …",
"editTitle": "Paket & Chat-Plätze ändern",
"editText": "Die Änderung wirkt sofort für alle Nutzer des Mandanten und wird im Plattform-Audit protokolliert.",
"tierHint": {
"BASIS": "Ohne KI und Planung: kein Lotse, keine Plantafel/Live-Lage, keine Abrechnungsübersicht, kein Notdienst, kein Auftragsimport.",
"PROFI": "Alle Funktionen inkl. Planung, Abrechnungsübersicht, Notdienst, Import und Lotse."
},
"downgradeHint": "Wechsel zu Basis: Daten bleiben erhalten, nur der Zugriff endet. Zurück zu Profi ist alles wieder da.",
"seatsInput": "Gebuchte Chat-Plätze",
"seatsHint": "Der Mandanten-Admin vergibt die Plätze an Nutzer mit Feldrolle.",
"hardLimitInput": "Hartes Limit",
"hardLimitHint": "Nach Ausschöpfen des Monatskontingents wird der Chat gesperrt statt Mehrverbrauch zu zählen.",
"confirm": "Ich habe die Auswirkungen geprüft und bestätige die Änderung.",
"submit": "Speichern",
"submitting": "Speichern …",
"close": "Schließen",
"saved": "Paket gespeichert.",
"errors": {
"confirm_required": "Bitte die Änderung bestätigen.",
"invalid_input": "Bitte Eingaben prüfen (Plätze 0–1000).",
"tenant_archived": "Der Mandant ist archiviert.",
"platform_admin_required": "Nur Voll-Administratoren dürfen das Paket ändern.",
"failed": "Speichern nicht möglich."
}
}
}
+6 -2
View File
@@ -317,11 +317,15 @@
"transition_forbidden": "You lack the permission.", "transition_forbidden": "You lack the permission.",
"not_editable": "The report has been submitted and can no longer be changed.", "not_editable": "The report has been submitted and can no longer be changed.",
"reason_required": "The reason is missing.", "reason_required": "The reason is missing.",
"use_billing_overview": "Only possible via the billing overview." "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": { "unavailable": {
"title": "Lotse chat not available", "title": "Lotse chat not available",
"back": "Back to overview" "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."
} }
} }
+1 -1
View File
@@ -128,7 +128,7 @@
"title": "Say what happened — instead of hunting for fields", "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.", "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", "chatUser": "Job done, installed the tank, two hours, used one filter",
"chatAssistant": "I'm mapping this to job A-00042, Elbblick apartments. Two questions: was travel included, and should I mark the job technically complete?", "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", "cardTitle": "Proposal",
"cardLine1": "Book 2:00 h of work time", "cardLine1": "Book 2:00 h of work time",
"cardLine2": "Record 1 × filter as material", "cardLine2": "Record 1 × filter as material",
+3 -1
View File
@@ -92,7 +92,9 @@
"internal": "Technical error – will retry automatically.", "internal": "Technical error – will retry automatically.",
"timeOverlap": "The added time overlaps with time already recorded and was not accepted.", "timeOverlap": "The added time overlaps with time already recorded and was not accepted.",
"timeWindow": "The added time is outside the allowed 7 days, in the future or too long and was not accepted.", "timeWindow": "The added time is outside the allowed 7 days, in the future or too long and was not accepted.",
"otherSession": "Time was still running on another order – the start was not accepted. Please start again and switch." "otherSession": "Time was still running on another order – the start was not accepted. Please start again and switch.",
"notInPlan": "Included in the Profi package – not activated for this company. The entry was not applied, please contact the office.",
"moduleDisabled": "This function is switched off for this company. The entry was not applied, please contact the office."
}, },
"view": { "view": {
"title": "Offline view", "title": "Offline view",
+93
View File
@@ -0,0 +1,93 @@
{
"tier": {
"BASIS": "Basic",
"PROFI": "Profi"
},
"colTier": "Package",
"notInTier": "Profi package only",
"profiHint": {
"title": "Included in the Profi package",
"text": "This function is part of the Profi package. Your company uses the Basic package – please contact your Craftvia representative to upgrade."
},
"admin": {
"title": "Package & Lotse chat",
"sub": "Package of the company, chat seats for technicians and usage in the current month",
"package": "Package",
"trial": "Trial (all functions)",
"packageReadOnly": "The provider sets the package and the number of chat seats. Here you assign the purchased seats to your technicians and team leads.",
"basisHint": "Lotse and the Lotse chat are included in the Profi package. Assigned seats and chat histories are kept and available again after switching to Profi.",
"seatsTitle": "Chat seats",
"seatsCount": "{assigned} of {purchased} seats assigned",
"trialSeats": "During the trial all technicians and team leads may use the Lotse chat – no seats need to be assigned (quota: {perSeat} chats per person and month).",
"noSeatsBought": "No chat seats purchased yet. The provider books seats for your company.",
"overbooked": "More seats assigned than purchased ({purchased}). Only the seats assigned first are valid – please revoke seats.",
"noFieldUsers": "No active users with a field role (technician, team lead).",
"seatAssigned": "Seat assigned",
"seatOverbooked": "Above the seat number",
"seatNone": "No seat",
"assign": "Assign seat",
"revoke": "Revoke seat",
"assignLabel": "Assign chat seat to {name}",
"revokeLabel": "Revoke chat seat from {name}",
"saved": "Chat seat saved.",
"usageTitle": "Usage {month}",
"usageText": "{used} of {quota} chats",
"quotaRule": "Quota: {perSeat} chats per assigned seat and month, shared by the company (basis: {basis}). One chat is one message to the Lotse.",
"warn80": "{percent} % of the monthly quota are used.",
"exhausted": "The monthly quota is used up. Further chats are billed as overage in packs of {pack}.",
"hardLimitReached": "The monthly quota is used up. The Lotse chat is blocked until the end of the month (hard limit).",
"overage": "Overage: {overage} chats – equals {packs, plural, one {# pack} other {# packs}} of {pack} chats.",
"perUserCaption": "Chats per user in the current month",
"perUserName": "User",
"perUserChats": "Chats",
"errors": {
"no_seats_left": "All purchased seats are assigned. Please revoke a seat first or book more seats with the provider.",
"not_eligible": "This person cannot get a chat seat (no active field role).",
"not_in_plan": "The Lotse chat is included in the Profi package.",
"disabled": "Lotse is switched off for this company.",
"forbidden": "You are not allowed to do this.",
"trial_expired": "Trial expired – read-only access.",
"failed": "Could not save."
}
},
"platform": {
"title": "Package & Lotse chat",
"tier": "Package",
"trialProfi": "Trial = Profi",
"seats": "Chat seats",
"seatsValue": "{assigned} assigned / {purchased} purchased",
"hardLimit": "Hard limit",
"hardLimitOn": "on – blocked at the quota",
"hardLimitOff": "off – overage is counted",
"rule": "{perSeat} chats per assigned seat and month (tenant timezone); overage in packs of {pack}.",
"currentMonth": "Current month · {month}",
"previousMonth": "Previous month · {month}",
"chats": "{used} of {quota} chats",
"overage": "Overage {overage} chats → {packs, plural, one {# pack} other {# packs}} of {pack}",
"noOverage": "No overage",
"edit": "Change package …",
"editTitle": "Change package & chat seats",
"editText": "The change applies immediately to all users of the tenant and is logged in the platform audit.",
"tierHint": {
"BASIS": "Without AI and planning: no Lotse, no planning board/live situation, no billing overview, no emergency service, no order import.",
"PROFI": "All functions incl. planning, billing overview, emergency service, import and Lotse."
},
"downgradeHint": "Switching to Basic keeps all data, only access ends. Back on Profi everything is available again.",
"seatsInput": "Purchased chat seats",
"seatsHint": "The tenant admin assigns the seats to users with a field role.",
"hardLimitInput": "Hard limit",
"hardLimitHint": "When the monthly quota is used up the chat is blocked instead of counting overage.",
"confirm": "I checked the impact and confirm the change.",
"submit": "Save",
"submitting": "Saving …",
"close": "Close",
"saved": "Package saved.",
"errors": {
"confirm_required": "Please confirm the change.",
"invalid_input": "Please check the input (seats 0–1000).",
"tenant_archived": "The tenant is archived.",
"platform_admin_required": "Only full administrators may change the package.",
"failed": "Could not save."
}
}
}
@@ -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');
+29
View File
@@ -61,6 +61,13 @@ model Tenant {
trialExpiredNoticeAt DateTime? @map("trial_expired_notice_at") trialExpiredNoticeAt DateTime? @map("trial_expired_notice_at")
trialDeletionNoticeAt DateTime? @map("trial_deletion_notice_at") trialDeletionNoticeAt DateTime? @map("trial_deletion_notice_at")
// L17 Pakete: Stufe (Basis ohne KI/Planung) + Lotse-Chat-Plätze (Plattform-Daten, nur Betreiber ändert)
tier TenantTier @default(PROFI)
/// Anzahl gekaufter Lotse-Chat-Plätze (Vergabe durch den Mandanten-Admin)
lotseChatSeats Int @default(0) @map("lotse_chat_seats")
/// true = nach Ausschöpfen des Monatskontingents gesperrt statt Mehrverbrauch
lotseChatHardLimit Boolean @default(false) @map("lotse_chat_hard_limit")
users User[] users User[]
roles Role[] roles Role[]
auditLogs AuditLog[] auditLogs AuditLog[]
@@ -1505,6 +1512,8 @@ model LotseMessage {
conversation LotseConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) conversation LotseConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
@@index([tenantId, conversationId, createdAt]) @@index([tenantId, conversationId, createdAt])
// L17 Pakete: Monatszählung der Chats (role = user) je Mandant
@@index([tenantId, role, createdAt])
@@map("lotse_messages") @@map("lotse_messages")
} }
@@ -1595,3 +1604,23 @@ model TenantExport {
@@index([tenantId, createdAt]) @@index([tenantId, createdAt])
@@map("tenant_exports") @@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")
}
+2
View File
@@ -61,6 +61,8 @@ const ACTION_MODULE: Record<string, string> = {
// (requireSession + requirePermission + requireApiContext; Export bewusst auch im Nur-Lesen-Zustand) // (requireSession + requirePermission + requireApiContext; Export bewusst auch im Nur-Lesen-Zustand)
"trial-platform.ts": "EXEMPT", "trial-platform.ts": "EXEMPT",
"trial-tenant.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 // L15 Testphase: öffentliche Selbstanmeldung ohne Session — jede Action MUSS das Rate-Limit prüfen
"trial-signup.ts": "PUBLIC", "trial-signup.ts": "PUBLIC",
}; };
+18
View File
@@ -422,10 +422,26 @@ export async function seedDemoTenant(tenantId: string, users: DemoUsers): Promis
}); });
} }
// ---------- L17 Pakete: Profi mit 3 Lotse-Chat-Plätzen für die Demo-Monteure (idempotent) ----------
await seedDemoPlan(tenantId, { tier: "PROFI", seats: 3, seatUserIds: [users.tech, users.tech2, users.tech3], assignedById: users.admin });
const orders = await bo.db.workOrder.count({ where: { OR: [{ externalOrderNumber: { startsWith: "DEMO-" } }, { isEmergency: true, emergencyReason: { startsWith: "Wasserrohrbruch im Keller" } }] } }); const orders = await bo.db.workOrder.count({ where: { OR: [{ externalOrderNumber: { startsWith: "DEMO-" } }, { isEmergency: true, emergencyReason: { startsWith: "Wasserrohrbruch im Keller" } }] } });
return { orders, created }; return { orders, created };
} }
/**
* L17 Pakete: package tier + chat seats of a demo tenant. Idempotent (seat rows per user are unique);
* existing seats of other users are left alone.
*/
export async function seedDemoPlan(tenantId: string, opts: { tier: "BASIS" | "PROFI"; seats: number; seatUserIds?: string[]; assignedById?: string }): Promise<void> {
await prisma.tenant.update({ where: { id: tenantId }, data: { tier: opts.tier, lotseChatSeats: opts.seats, lotseChatHardLimit: false } });
const db = dbForTenant(tenantId);
for (const userId of opts.seatUserIds ?? []) {
const existing = await db.lotseChatSeat.findFirst({ where: { userId }, select: { id: true } });
if (!existing) await db.lotseChatSeat.create({ data: { tenantId, userId, assignedById: opts.assignedById ?? null } });
}
}
/** Isolation fixture for the second tenant: one customer + one work order. */ /** Isolation fixture for the second tenant: one customer + one work order. */
export async function seedDemo2Tenant(tenantId: string, adminUserId: string): Promise<void> { export async function seedDemo2Tenant(tenantId: string, adminUserId: string): Promise<void> {
const admin = ctxOf(tenantId, adminUserId, "tenant-admin"); const admin = ctxOf(tenantId, adminUserId, "tenant-admin");
@@ -437,6 +453,8 @@ export async function seedDemo2Tenant(tenantId: string, adminUserId: string): Pr
const site = await createSite(admin, { customerId: customer.id, name: "Schaltanlage Halle 2", street: "Am Hafen", houseNumber: "3", postalCode: "24103", city: "Kiel", accessNotes: "Anmeldung beim Werkschutz." }); const site = await createSite(admin, { customerId: customer.id, name: "Schaltanlage Halle 2", street: "Am Hafen", houseNumber: "3", postalCode: "24103", city: "Kiel", accessNotes: "Anmeldung beim Werkschutz." });
await createWorkOrder(admin, { title: "Prüfung Unterverteilung nach DGUV V3", customerId: customer.id, siteId: site.id, externalOrderNumber: "DEMO2-01", status: "planned", plannedStart: day(3, 8), plannedEnd: day(3, 12) }); await createWorkOrder(admin, { title: "Prüfung Unterverteilung nach DGUV V3", customerId: customer.id, siteId: site.id, externalOrderNumber: "DEMO2-01", status: "planned", plannedStart: day(3, 8), plannedEnd: day(3, 12) });
} }
// L17 Pakete: demo2 = Basis (zeigt die Sperren: keine Planung, Abrechnung, Notdienst, Import, Lotse)
await seedDemoPlan(tenantId, { tier: "BASIS", seats: 0 });
} }
/** Close the owner client used by the services (the seed has its own client). */ /** Close the owner client used by the services (the seed has its own client). */
+1
View File
@@ -134,6 +134,7 @@ export async function cleanupTenants(slugs: string[]): Promise<void> {
await prisma.mailLog.deleteMany(w); await prisma.mailLog.deleteMany(w);
await prisma.authToken.deleteMany(w); await prisma.authToken.deleteMany(w);
await prisma.aiGeneration.deleteMany(w); await prisma.aiGeneration.deleteMany(w);
await prisma.lotseChatSeat.deleteMany(w); // L17 Pakete
await prisma.signature.deleteMany(w); await prisma.signature.deleteMany(w);
await prisma.report.deleteMany(w); await prisma.report.deleteMany(w);
await prisma.photo.deleteMany(w); await prisma.photo.deleteMany(w);
+6
View File
@@ -36,6 +36,7 @@ export async function cleanupChatTenants(prefix: string) {
const ids = tenants.map((t) => t.id); const ids = tenants.map((t) => t.id);
if (ids.length) { if (ids.length) {
const w = { where: { tenantId: { in: ids } } }; const w = { where: { tenantId: { in: ids } } };
await prisma.lotseChatSeat.deleteMany(w); // L17 Pakete
await prisma.lotseActionProposal.deleteMany(w); await prisma.lotseActionProposal.deleteMany(w);
await prisma.lotseMessage.deleteMany(w); await prisma.lotseMessage.deleteMany(w);
await prisma.lotseConversation.deleteMany(w); await prisma.lotseConversation.deleteMany(w);
@@ -119,6 +120,11 @@ export async function createChatFixture(prefix: string) {
await prisma.photoRequirement.create({ data: { tenantId: tA.id, workOrderId: wo3.id, key: "fertige_montage", label: "Fertige Montage" } }); 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]); 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 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 } }); 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 } }); await prisma.workOrderAssignee.create({ data: { tenantId: tB.id, workOrderId: woB.id, userId: techB.id } });
+38 -9
View File
@@ -33,7 +33,18 @@ async function ids() {
if (!d07 || !d18) throw new Error("Demo-Aufträge fehlen — `npx prisma db seed` (SEED_DEMO) ausführen."); if (!d07 || !d18) throw new Error("Demo-Aufträge fehlen — `npx prisma db seed` (SEED_DEMO) ausführen.");
const emergency = await prisma.workOrder.findFirst({ where: { tenantId: demo.id, isEmergency: true, emergencyReason: { startsWith: "Wasserrohrbruch" } }, select: { id: true, number: true } }); const emergency = await prisma.workOrder.findFirst({ where: { tenantId: demo.id, isEmergency: true, emergencyReason: { startsWith: "Wasserrohrbruch" } }, select: { id: true, number: true } });
const customer = await prisma.customer.findFirstOrThrow({ where: { tenantId: demo.id, customerNumber: "K-D001" } }); const customer = await prisma.customer.findFirstOrThrow({ where: { tenantId: demo.id, customerNumber: "K-D001" } });
const foreignCustomer = await prisma.customer.findFirstOrThrow({ where: { tenantId: demo.id, customerNumber: "K-D004" } }); // "Fremder" Kunde für den Monteur: keiner seiner Aufträge (Team oder Einzelzuweisung) gehört dazu.
// Dynamisch statt fest K-D004, weil Aufträge beim Ausprobieren der Plantafel umgeplant werden.
const tech = await prisma.user.findFirstOrThrow({ where: { tenantId: demo.id, email: "monteur@demo.example" }, select: { id: true } });
const techTeams = (await prisma.teamMember.findMany({ where: { tenantId: demo.id, userId: tech.id }, select: { teamId: true } })).map((m) => m.teamId);
const foreignCustomer = await prisma.customer.findFirstOrThrow({
where: {
tenantId: demo.id,
deletedAt: null,
workOrders: { none: { OR: [{ assignedTeamId: { in: techTeams } }, { assignees: { some: { userId: tech.id } } }] } },
},
orderBy: { customerNumber: "asc" },
});
const site = await prisma.site.findFirstOrThrow({ where: { tenantId: demo.id, name: "Wohnanlage Elbblick – Haus A" } }); const site = await prisma.site.findFirstOrThrow({ where: { tenantId: demo.id, name: "Wohnanlage Elbblick – Haus A" } });
const approved = await prisma.report.findFirstOrThrow({ where: { workOrderId: d18.id, status: "approved" } }); const approved = await prisma.report.findFirstOrThrow({ where: { workOrderId: d18.id, status: "approved" } });
const inReview = d16 ? await prisma.report.findFirst({ where: { workOrderId: d16.id } }) : null; const inReview = d16 ? await prisma.report.findFirst({ where: { workOrderId: d16.id } }) : null;
@@ -76,6 +87,9 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
{ path: "/settings/audit" }, { path: "/settings/audit" },
{ path: "/settings/email" }, { path: "/settings/email" },
{ path: "/settings/lotse", mustContain: "Lotse-Chat für Monteure" }, { path: "/settings/lotse", mustContain: "Lotse-Chat für Monteure" },
// L17 Pakete: Paket nur lesend, Plätze + Verbrauch
{ path: "/settings/lotse", mustContain: ["Paket &amp; Lotse-Chat", "Profi", "3 von 3 Plätzen vergeben", "Max Monteur", "Platz entziehen", "Chats"] },
{ path: "/settings", mustContain: "Freigeschaltete Module" },
{ path: "/settings/lotse/protocol" }, { path: "/settings/lotse/protocol" },
{ path: "/settings/order-types", mustContain: "Montage" }, { path: "/settings/order-types", mustContain: "Montage" },
{ path: "/settings/checklists", mustContain: "Montage Heizung/Sanitär" }, { path: "/settings/checklists", mustContain: "Montage Heizung/Sanitär" },
@@ -154,6 +168,10 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
// L13 Planung: team lead read-only, own team only // L13 Planung: team lead read-only, own team only
{ path: "/planning", mustContain: ["Plantafel", "Team Nord", "Nur Lesezugriff"], mustNotContain: ["Team Süd"] }, { path: "/planning", mustContain: ["Plantafel", "Team Nord", "Nur Lesezugriff"], mustNotContain: ["Team Süd"] },
{ path: "/planning/live", mustContain: "keine GPS-Ortung", mustNotContain: ["Team Süd"] }, { path: "/planning/live", mustContain: "keine GPS-Ortung", mustNotContain: ["Team Süd"] },
// L17 Pakete: Teamleiterin ohne Chat-Platz → kein Navigationseintrag, freundlicher Hinweis
{ path: "/m", mustNotContain: ['href="/m/lotse"'] },
{ path: "/m/lotse", mustContain: "frag im Büro nach", mustNotContain: ['id="lotse-chat-input"'] },
...(x.d14 ? [{ path: m(x.d14), mustContain: x.d14.number, mustNotContain: [`href="/m/lotse?order=${x.d14.id}"`] }] : []),
], ],
}, },
{ {
@@ -189,6 +207,7 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
{ path: "/m/lotse", mustContain: ["Lotse-Chat", "Neuer Chat", "Sprechen"] }, { path: "/m/lotse", mustContain: ["Lotse-Chat", "Neuer Chat", "Sprechen"] },
{ path: `/m/lotse?order=${x.d07.id}`, mustContain: [x.d07.number, "Lotse-Chat"] }, { path: `/m/lotse?order=${x.d07.id}`, mustContain: [x.d07.number, "Lotse-Chat"] },
{ path: m(x.d07), mustContain: "Lotse fragen" }, { 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] }] : []), ...(x.d08 ? [{ path: `/m/lotse?order=${x.d08.id}`, expect: [404] }] : []),
// L14 Abrechnungsübersicht: Meilensteine mobil, Backoffice-Übersicht ohne Recht // L14 Abrechnungsübersicht: Meilensteine mobil, Backoffice-Übersicht ohne Recht
{ path: m(x.d07), mustContain: ["Meilensteine", "Smoke-Meilenstein", "Erreicht melden"] }, { path: m(x.d07), mustContain: ["Meilensteine", "Smoke-Meilenstein", "Erreicht melden"] },
@@ -224,15 +243,25 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
{ path: `/customers/${x.customer.id}`, expect: [404] }, { path: `/customers/${x.customer.id}`, expect: [404] },
{ path: `/sites/${x.site.id}`, expect: [404] }, { path: `/sites/${x.site.id}`, expect: [404] },
{ path: `/reports/${x.approved.id}`, expect: [404] }, { path: `/reports/${x.approved.id}`, expect: [404] },
...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, expect: [404] }] : []), // L17: demo2 is Basis → module layouts redirect before the (foreign) id is looked up
...(x.imp ? [{ path: `/imports/${x.imp.id}`, expect: [404] }] : []), ...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, expect: [307, 404] }] : []),
...(x.imp ? [{ path: `/imports/${x.imp.id}`, expect: [307, 404] }] : []),
...(x.photo ? [{ path: `/files/${x.photo.documentId}`, expect: [404] }] : []), ...(x.photo ? [{ path: `/files/${x.photo.documentId}`, expect: [404] }] : []),
{ path: "/planning", mustNotContain: ["Team Nord", x.d07.number] }, // L17 Pakete: demo2 = Basis → Planung, Abrechnung, Import, Notdienst, Lotse gesperrt (ruhiger Hinweis)
{ path: "/planning/live", mustNotContain: ["Team Nord", "Max Monteur"] }, { path: "/planning", expect: [307], redirectTo: "/dashboard" },
{ path: `/api/v1/planning/recommendations?workOrderId=${x.d07.id}`, expect: [404] }, { path: "/planning/live", expect: [307], redirectTo: "/dashboard" },
// L14 Abrechnungsübersicht: keine Einträge von demo { path: `/api/v1/planning/recommendations?workOrderId=${x.d07.id}`, expect: [403, 404] },
{ path: "/billing", mustContain: "Abrechnungsübersicht", mustNotContain: [x.d18.number, "Elbblick"] }, { path: "/api/v1/planning/board", expect: [403] },
...(x.billing ? [{ path: `/billing/${x.billing.id}`, expect: [404] }, { path: `/billing/print?ids=${x.billing.id}`, mustNotContain: [x.d18.number] }] : []), { path: "/billing", expect: [307], redirectTo: "/dashboard" },
{ path: "/imports", expect: [307], redirectTo: "/dashboard" },
{ path: "/work-orders/emergency-review", expect: [307], redirectTo: "/dashboard" },
{ path: "/api/v1/billing", expect: [403] },
{ path: "/dashboard?module=profi", mustContain: ["Im Paket Profi enthalten", "Offene Aufträge"], mustNotContain: ['href="/planning"', 'href="/billing"', 'href="/imports"', 'href="/work-orders/emergency-review"'] },
{ path: "/settings/lotse", mustContain: ["Paket &amp; Lotse-Chat", "Basis", "im Paket Profi enthalten"] },
{ path: "/work-orders", mustContain: "DGUV" },
{ path: "/customers" },
{ path: "/reports" },
...(x.billing ? [{ path: `/billing/${x.billing.id}`, expect: [307, 404] }] : []),
{ path: wo(x.d07, "?tab=billing"), expect: [404] }, { path: wo(x.d07, "?tab=billing"), expect: [404] },
], ],
}, },
+1
View File
@@ -142,6 +142,7 @@ async function createModelRows(A: TenantFixture): Promise<Rows> {
put("LotseMessage", (await prisma.lotseMessage.create({ data: { tenantId: t, conversationId: lotseConversation.id, role: "user", text: "ZZ" } })).id, { text: 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("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("TenantExport", (await prisma.tenantExport.create({ data: { tenantId: t, status: "done", fileName: "zz.zip" } })).id, { fileName: MARK }); // L15 Testphase
put("LotseChatSeat", (await prisma.lotseChatSeat.create({ data: { tenantId: t, userId: uid } })).id, { assignedById: MARK }); // L17 Pakete
return rows; return rows;
} }
+213
View File
@@ -0,0 +1,213 @@
// L17 Pakete — Durchsetzung der Paketstufe an allen Mechanismen: Action (assertModuleEnabled im
// moduleGuard), API (requireApiContext/toErrorResponse), Layout-Guard-Funktionen (moduleState,
// requirePlanFeature), Sync (Offline-Op → rejected mit Klartext), Planung (planningAccess & Co.),
// isLotseEnabled, Navigation; TenantModule aus + Profi; Wechsel Profi → Basis → Profi ohne Datenverlust;
// nur der Betreiber ändert die Stufe.
// Lauf: npx tsx scripts/test-pakete-gates.ts (auch mit RLS_ENFORCED=true)
import "dotenv/config";
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { Session } from "next-auth";
import { prisma, dbForTenant } from "../src/server/db";
import { assertModuleEnabled, ModuleDisabledError } from "../src/server/modules";
import { effectiveModules, isModuleActive, isPlanFeatureAvailable, moduleState, PROFI_HINT_PATH, requirePlanFeature } from "../src/server/plan";
import { toErrorResponse } from "../src/server/api/respond";
import { applyOperations } from "../src/server/services/sync/apply";
import { getPlanningBoard } from "../src/server/services/planning/board";
import { getLiveSituation } from "../src/server/services/planning/live";
import { scheduleWorkOrder } from "../src/server/services/planning/schedule";
import { recommendAssignments } from "../src/server/services/planning/recommend";
import { updateTeamPlanningSettings } from "../src/server/services/planning/team-settings";
import { getPlanningToday } from "../src/server/services/planning/summary";
import { runPlanningWatch } from "../src/server/services/planning/watch";
import { isLotseEnabled, updateLotseSettings } from "../src/server/services/lotse/settings";
import { billingModuleActive } from "../src/server/services/work-orders/transition";
import { updateTenantPlan } from "../src/server/services/plans/platform";
import { problemKey } from "../src/lib/offline/outbox-core";
import type { SyncOperationInput } from "../src/lib/sync/envelope";
import { codeOf, createTenant, expectCode, ok, runSuite, section } from "./lib/e2e-fixture";
const SLUG_A = "zz-pak-gates-a";
const SLUG_B = "zz-pak-gates-b";
const PLATFORM_EMAIL = "platform-pakete@zz-pakete.test";
const READONLY_EMAIL = "readonly-pakete@zz-pakete.test";
const fakeSession = (tenantId: string, userId: string) => ({ user: { tenantId, id: userId } }) as unknown as Session;
const src = (p: string) => readFileSync(join(process.cwd(), p), "utf8");
function emergencyOp(): SyncOperationInput {
return {
clientOpId: randomUUID(),
opType: "emergency.create",
payload: {
clientIds: { workOrder: randomUUID(), session: randomUUID(), customer: randomUUID(), site: randomUUID() },
customer: { mode: "new", firstName: "Lutz", lastName: "Meier", phone: "0171 1234567", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" },
site: { mode: "new", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" },
onSiteContact: { name: "Lutz Meier", phone: "0171 1234567" },
reason: "Wasserrohrbruch im Keller",
},
clientCreatedAt: new Date().toISOString(),
};
}
async function extraCleanup(tenantIds: string[]) {
await prisma.lotseChatSeat.deleteMany({ where: { tenantId: { in: tenantIds } } });
await prisma.workOrderMilestone.deleteMany({ where: { tenantId: { in: tenantIds } } });
await prisma.platformAdmin.deleteMany({ where: { email: { in: [PLATFORM_EMAIL, READONLY_EMAIL] } } });
}
runSuite("L17 Pakete – Durchsetzung", [SLUG_A, SLUG_B], async () => {
await extraCleanup([]);
const A = await createTenant(SLUG_A);
const B = await createTenant(SLUG_B);
try {
await prisma.tenant.update({ where: { id: A.tenantId }, data: { tier: "BASIS" } });
const platform = await prisma.platformAdmin.create({ data: { email: PLATFORM_EMAIL, name: "ZZ Plattform", passwordHash: "x", role: "full" } });
const readonly = await prisma.platformAdmin.create({ data: { email: READONLY_EMAIL, name: "ZZ Nur-Lesen", passwordHash: "x", role: "readonly" } });
section("Stufe am Mandanten");
ok((await prisma.tenant.findUniqueOrThrow({ where: { id: B.tenantId } })).tier === "PROFI", "neuer Mandant: Default Profi");
section("Layout-Guard-Funktionen (moduleState / requirePlanFeature)");
for (const key of ["billing", "emergency", "imports", "lotse"]) ok((await moduleState(A.tenantId, key)) === "not_in_plan", `Basis: ${key} → not_in_plan`);
for (const key of ["customers", "sites", "teams", "work_orders", "field", "reports", "documents", "notifications"]) ok((await moduleState(A.tenantId, key)) === "enabled", `Basis: ${key} aktiv`);
for (const key of ["billing", "emergency", "imports", "lotse"]) ok((await moduleState(B.tenantId, key)) === "enabled", `Profi: ${key} aktiv`);
let redirected = "";
try {
await requirePlanFeature(A.tenantId, "planning");
} catch (err) {
redirected = String((err as { digest?: string }).digest ?? (err as Error).message);
}
ok(redirected.includes(PROFI_HINT_PATH), `Planungs-Layout (Basis) → Hinweis ${PROFI_HINT_PATH}`);
ok((await codeOf(requirePlanFeature(B.tenantId, "planning"))) === "ok", "Planungs-Layout (Profi) → kein Redirect");
const modulesSrc = src("src/server/modules.ts");
ok(/export async function requireModule[\s\S]*moduleState\(/.test(modulesSrc) && modulesSrc.includes("PROFI_HINT_PATH"), "requireModule nutzt moduleState (Stufe + Schalter) und leitet auf den Profi-Hinweis");
ok(src("src/app/(app)/planning/layout.tsx").includes('requirePlanFeature(session.user.tenantId, "planning")'), "Planungs-Layout prüft das Paket-Feature");
section("Action (moduleGuard → assertModuleEnabled)");
let actionErr: unknown = null;
try {
await assertModuleEnabled(fakeSession(A.tenantId, A.users.admin.id), "billing");
} catch (err) {
actionErr = err;
}
ok(actionErr instanceof ModuleDisabledError && actionErr.reason === "not_in_plan", "Basis: Abrechnungs-Action → ModuleDisabledError(not_in_plan)");
const denied = await prisma.auditLog.findFirst({ where: { tenantId: A.tenantId, action: "denied", entity: "module", entityId: "billing" } });
ok(!!denied, "verweigerter Zugriff wird auditiert");
for (const key of ["emergency", "imports", "lotse"]) {
ok((await codeOf(assertModuleEnabled(fakeSession(A.tenantId, A.users.admin.id), key))).startsWith("error:"), `Basis: ${key}-Action gesperrt`);
}
ok((await codeOf(assertModuleEnabled(fakeSession(A.tenantId, A.users.admin.id), "customers"))) === "ok", "Basis: Kunden-Action erlaubt");
ok((await codeOf(assertModuleEnabled(fakeSession(B.tenantId, B.users.admin.id), "billing"))) === "ok", "Profi: Abrechnungs-Action erlaubt");
ok(/await assertModuleEnabled\(session, moduleKey\)/.test(src("src/server/action-guard.ts")), "moduleGuard prüft über assertModuleEnabled");
section("API (requireApiContext → 403 mit Klartext)");
ok(/if \(moduleKey\) await assertModuleEnabled\(session, moduleKey\)/.test(src("src/server/api/context.ts")), "requireApiContext prüft über assertModuleEnabled");
const res = toErrorResponse(actionErr);
const body = (await res.json()) as { error: { code: string; details?: { reason?: string; message?: string } } };
ok(res.status === 403 && body.error.details?.reason === "not_in_plan" && /Paket Profi/.test(body.error.details?.message ?? ""), "API: 403 mit reason not_in_plan und Klartext");
const planningErr = await getPlanningBoard(A.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }).catch((e) => e);
const planningRes = toErrorResponse(planningErr);
ok(planningRes.status === 403, "Planungs-API (Basis) → 403");
section("Planung (Feature ohne eigenes Modul)");
ok(!(await isPlanFeatureAvailable(A.tenantId, "planning")) && (await isPlanFeatureAvailable(B.tenantId, "planning")), "Feature planning: Basis nein, Profi ja");
await expectCode(() => getPlanningBoard(A.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }), "forbidden", "Basis: Plantafel forbidden");
await expectCode(() => getLiveSituation(A.ctx.backoffice), "forbidden", "Basis: Live-Lage forbidden");
const woA = await prisma.workOrder.create({ data: { tenantId: A.tenantId, number: "ZZ-PAK-1", customerId: A.customerId, siteId: A.siteId, title: "Paket-Test", status: "draft" } });
await expectCode(() => scheduleWorkOrder(A.ctx.backoffice, { workOrderId: woA.id, teamId: A.teamId, plannedStart: new Date().toISOString(), baseVersion: woA.version } as never), "forbidden", "Basis: Einplanen forbidden");
await expectCode(() => recommendAssignments(A.ctx.backoffice, { workOrderId: woA.id }), "forbidden", "Basis: Empfehlungen forbidden");
await expectCode(() => updateTeamPlanningSettings(A.ctx.admin, A.teamId, { dailyCapacityMinutes: 400, workingDays: 31 }), "forbidden", "Basis: Kolonnenkapazität forbidden");
ok((await getPlanningToday(A.ctx.backoffice)) === null, "Basis: Dashboard-Kachel Planung ausgeblendet");
const watch = await runPlanningWatch(A.tenantId);
ok(watch.overrun === 0 && watch.followupAtRisk === 0 && watch.capacityFreed === 0, "Basis: keine Verzugs-/Frühfertig-Meldungen");
ok((await codeOf(getPlanningBoard(B.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }))) === "ok", "Profi: Plantafel erlaubt");
ok((await codeOf(updateTeamPlanningSettings(B.ctx.admin, B.teamId, { dailyCapacityMinutes: 420, workingDays: 31 }))) === "ok", "Profi: Kolonnenkapazität erlaubt");
section("Lotse, Abrechnung, Navigation");
ok(!(await isLotseEnabled(A.ctx.admin)) && (await isLotseEnabled(B.ctx.admin)), "isLotseEnabled: Basis aus, Profi an");
ok(!(await billingModuleActive(A.ctx.admin)) && (await billingModuleActive(B.ctx.admin)), "Abrechnungsübersicht: Basis aus (direktes „abgerechnet“ wie bei deaktiviertem Modul)");
const effA = await effectiveModules(A.tenantId);
ok(["billing", "emergency", "imports", "lotse"].every((k) => effA.inactive.has(k) && effA.notInPlan.has(k)) && effA.lockedFeatures.has("planning"), "Navigation Basis: gesperrte Module + Planung");
const effB = await effectiveModules(B.tenantId);
ok(effB.inactive.size === 0 && effB.lockedFeatures.size === 0, "Navigation Profi: alles sichtbar");
section("Sync (Offline-Op eines gesperrten Moduls → rejected mit Klartext)");
const opA = emergencyOp();
const syncA = await applyOperations(A.ctx.tech, { deviceId: "zz-dev", operations: [opA] });
const rA = syncA.results[0];
ok(rA.status === "rejected" && rA.errorCode === "forbidden" && (rA.message ?? "").startsWith("not_in_plan") && /Paket Profi/.test(rA.message ?? ""), `Basis: emergency.create → rejected mit Klartext (${rA.message?.slice(0, 60)}…)`);
const storedA = await prisma.syncOperation.findFirst({ where: { tenantId: A.tenantId, clientOpId: opA.clientOpId } });
ok(storedA?.status === "rejected", "abgewiesene Op gespeichert (nicht still verworfen)");
ok((await prisma.workOrder.count({ where: { tenantId: A.tenantId, isEmergency: true } })) === 0, "kein Notdienstauftrag angelegt");
const again = await applyOperations(A.ctx.tech, { deviceId: "zz-dev", operations: [opA] });
ok(again.results[0].status === "duplicate", "Wiederholung derselben Op → duplicate (Ergebnis bleibt)");
ok(problemKey({ status: "rejected", opType: "emergency.create", lastError: { code: "forbidden", message: rA.message } }) === "notInPlan", "Gerät zeigt Klartext „Im Paket Profi enthalten“ (problem.notInPlan)");
const msA = await prisma.workOrderMilestone.create({ data: { tenantId: A.tenantId, workOrderId: woA.id, title: "ZZ" } });
const mOp: SyncOperationInput = { clientOpId: randomUUID(), opType: "milestone.reach", payload: { workOrderId: woA.id, milestoneId: msA.id }, clientCreatedAt: new Date().toISOString() };
const mRes = (await applyOperations(A.ctx.tech, { deviceId: "zz-dev", operations: [mOp] })).results[0];
ok(mRes.status === "rejected" && (mRes.message ?? "").startsWith("not_in_plan"), "Basis: milestone.reach → rejected");
const opB = emergencyOp();
const rB = (await applyOperations(B.ctx.tech, { deviceId: "zz-dev", operations: [opB] })).results[0];
ok(rB.status === "applied", `Profi: emergency.create angewendet (${rB.status}${rB.message ? `: ${rB.message}` : ""})`);
section("TenantModule aus + Profi → gesperrt");
await prisma.tenantModule.upsert({ where: { tenantId_moduleKey: { tenantId: B.tenantId, moduleKey: "emergency" } }, update: { enabled: false }, create: { tenantId: B.tenantId, moduleKey: "emergency", enabled: false } });
ok((await moduleState(B.tenantId, "emergency")) === "disabled", "Profi + Schalter aus → disabled");
let offErr: unknown = null;
try {
await assertModuleEnabled(fakeSession(B.tenantId, B.users.admin.id), "emergency");
} catch (err) {
offErr = err;
}
ok(offErr instanceof ModuleDisabledError && offErr.reason === "disabled", "Action → ModuleDisabledError(disabled)");
const rOff = (await applyOperations(B.ctx.tech, { deviceId: "zz-dev", operations: [emergencyOp()] })).results[0];
ok(rOff.status === "rejected" && (rOff.message ?? "").startsWith("module_disabled"), "Sync → rejected (module_disabled)");
ok(problemKey({ status: "rejected", opType: "emergency.create", lastError: { code: "forbidden", message: rOff.message } }) === "moduleDisabled", "Gerät: problem.moduleDisabled");
await prisma.tenantModule.update({ where: { tenantId_moduleKey: { tenantId: B.tenantId, moduleKey: "emergency" } }, data: { enabled: true } });
ok(await isModuleActive(B.tenantId, "emergency"), "wieder eingeschaltet → aktiv");
section("Nur der Betreiber ändert die Stufe");
await expectCode(() => updateTenantPlan({ platformAdminId: B.users.admin.id }, B.tenantId, { tier: "BASIS", lotseChatSeats: 5, lotseChatHardLimit: false }), "forbidden", "Mandanten-Admin (kein Plattform-Konto) → forbidden");
await expectCode(() => updateTenantPlan({ platformAdminId: readonly.id }, B.tenantId, { tier: "BASIS", lotseChatSeats: 5, lotseChatHardLimit: false }), "forbidden", "Nur-Lesen-Plattform-Admin → forbidden");
await expectCode(() => updateTenantPlan({ platformAdminId: platform.id }, B.tenantId, { tier: "GOLD" as never, lotseChatSeats: 5, lotseChatHardLimit: false }), "invalid", "ungültige Stufe → invalid");
await expectCode(() => updateTenantPlan({ platformAdminId: platform.id }, B.tenantId, { tier: "PROFI", lotseChatSeats: -1, lotseChatHardLimit: false }), "invalid", "negative Platzanzahl → invalid");
await updateLotseSettings(B.ctx.admin, { enabled: true, addressForm: "neutral" });
const bAfterSettings = await prisma.tenant.findUniqueOrThrow({ where: { id: B.tenantId } });
ok(bAfterSettings.tier === "PROFI" && bAfterSettings.lotseChatSeats === 0, "Lotse-Einstellungen des Mandanten ändern weder Stufe noch Plätze");
ok(!/\btier:|lotseChatSeats|lotseChatHardLimit/.test(src("src/server/services/lotse/settings.ts") + src("src/server/actions/tenant-settings.ts") + src("src/server/actions/lotse-settings.ts")), "keine Mandanten-Action schreibt Stufe/Plätze");
section("Wechsel Profi → Basis → Profi ohne Datenverlust");
await prisma.lotseChatSeat.create({ data: { tenantId: B.tenantId, userId: B.users.tech.id } });
const conv = await prisma.lotseConversation.create({ data: { tenantId: B.tenantId, userId: B.users.tech.id } });
await prisma.lotseMessage.create({ data: { tenantId: B.tenantId, conversationId: conv.id, role: "user", text: "Hallo" } });
const snapshot = async () => ({
emergencies: await prisma.workOrder.count({ where: { tenantId: B.tenantId, isEmergency: true } }),
seats: await prisma.lotseChatSeat.count({ where: { tenantId: B.tenantId } }),
messages: await prisma.lotseMessage.count({ where: { tenantId: B.tenantId } }),
teams: await prisma.team.findMany({ where: { tenantId: B.tenantId }, select: { id: true, dailyCapacityMinutes: true }, orderBy: { id: "asc" } }),
});
const before = await snapshot();
const down = await updateTenantPlan({ platformAdminId: platform.id }, B.tenantId, { tier: "BASIS", lotseChatSeats: 3, lotseChatHardLimit: true });
ok(down.tier === "BASIS" && down.lotseChatSeats === 3 && down.lotseChatHardLimit, "Betreiber setzt Basis, 3 Plätze, hartes Limit");
const audit = await prisma.auditLog.findFirst({ where: { tenantId: B.tenantId, scope: "platform", entity: "tenant_plan" }, orderBy: { createdAt: "desc" } });
ok(!!audit && (audit.before as { tier?: string })?.tier === "PROFI" && (audit.after as { tier?: string })?.tier === "BASIS" && audit.actorId === platform.id, "Plattform-Audit mit before/after");
ok((await moduleState(B.tenantId, "emergency")) === "not_in_plan" && !(await isPlanFeatureAvailable(B.tenantId, "planning")), "Basis: Zugriff endet");
await expectCode(() => getPlanningBoard(B.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }), "forbidden", "Basis: Plantafel gesperrt");
ok(JSON.stringify(await snapshot()) === JSON.stringify(before), "Basis: Notdiensteinsätze, Plätze, Chats, Kolonnen unverändert");
await updateTenantPlan({ platformAdminId: platform.id }, B.tenantId, { tier: "PROFI", lotseChatSeats: 3, lotseChatHardLimit: false });
ok((await moduleState(B.tenantId, "emergency")) === "enabled" && (await isPlanFeatureAvailable(B.tenantId, "planning")), "zurück auf Profi: Zugriff wieder da");
ok(JSON.stringify(await snapshot()) === JSON.stringify(before), "zurück auf Profi: alle Daten vorhanden");
ok((await codeOf(getPlanningBoard(B.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }))) === "ok", "Plantafel wieder erlaubt");
section("Mandantentrennung der Stufe");
ok((await prisma.tenant.findUniqueOrThrow({ where: { id: A.tenantId } })).tier === "BASIS", "Änderung an B lässt A unberührt");
ok((await dbForTenant(B.tenantId).syncOperation.findMany({ where: { clientOpId: opA.clientOpId } })).length === 0, "B sieht die abgewiesene Op von A nicht");
} finally {
await extraCleanup([A.tenantId, B.tenantId]);
await prisma.lotseMessage.deleteMany({ where: { tenantId: { in: [A.tenantId, B.tenantId] } } });
await prisma.lotseConversation.deleteMany({ where: { tenantId: { in: [A.tenantId, B.tenantId] } } });
}
});
+79
View File
@@ -0,0 +1,79 @@
// L17 Pakete — reine Stufenregeln (src/lib/plans.ts), ohne Datenbank.
// Lauf: npx tsx scripts/test-pakete-rules.ts
import {
chatOverage,
chatQuota,
DEFAULT_TENANT_TIER,
effectiveTier,
isFeatureInTier,
isModuleInTier,
isQuotaBlocked,
isTenantTier,
LOTSE_CHAT_CHATS_PER_SEAT,
LOTSE_CHAT_OVERAGE_PACK,
modulesLockedByTier,
monthKeyOfDay,
nextMonthKey,
overagePacks,
previousMonthKey,
PROFI_ONLY_MODULES,
usageLevel,
usagePercent,
} from "../src/lib/plans";
import { MODULE_KEYS } from "../src/lib/modules";
import { NAV_ITEMS, visibleNavItems } from "../src/lib/nav";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
console.log("— Stufen —");
ok(DEFAULT_TENANT_TIER === "PROFI", "Default-Stufe ist Profi");
ok(isTenantTier("BASIS") && isTenantTier("PROFI") && !isTenantTier("GOLD") && !isTenantTier(null), "isTenantTier erkennt nur BASIS/PROFI");
ok(effectiveTier("BASIS", "TRIAL") === "PROFI", "Testphase läuft immer als Profi");
ok(effectiveTier("BASIS", "FULL") === "BASIS" && effectiveTier("PROFI", "FULL") === "PROFI", "Vollversion: gespeicherte Stufe gilt");
ok(effectiveTier(undefined, "FULL") === "PROFI", "unbekannte Stufe → Default Profi");
console.log("\n— Module je Stufe —");
const profiOnly = ["billing", "emergency", "imports", "lotse"];
ok(JSON.stringify([...PROFI_ONLY_MODULES].sort()) === JSON.stringify([...profiOnly].sort()), "Nur-Profi-Module: billing, emergency, imports, lotse");
for (const key of MODULE_KEYS) {
ok(isModuleInTier("PROFI", key), `Profi enthält ${key}`);
ok(isModuleInTier("BASIS", key) === !profiOnly.includes(key), `Basis ${profiOnly.includes(key) ? "sperrt" : "enthält"} ${key}`);
}
ok(modulesLockedByTier("PROFI").length === 0 && modulesLockedByTier("BASIS").length === 4, "modulesLockedByTier");
ok(!isFeatureInTier("BASIS", "planning") && !isFeatureInTier("BASIS", "lotse_chat"), "Basis: keine Planung, kein Lotse-Chat");
ok(isFeatureInTier("PROFI", "planning") && isFeatureInTier("PROFI", "lotse_chat"), "Profi: Planung und Lotse-Chat");
console.log("\n— Kontingent —");
ok(LOTSE_CHAT_CHATS_PER_SEAT === 150 && LOTSE_CHAT_OVERAGE_PACK === 100, "150 Chats je Platz, Mehrverbrauch in Paketen à 100");
ok(chatQuota(3) === 450 && chatQuota(0) === 0 && chatQuota(-2) === 0, "Kontingent = Plätze × 150");
ok(chatOverage(212, 450) === 0 && chatOverage(460, 450) === 10, "Mehrverbrauch = über dem Kontingent");
ok(overagePacks(0) === 0 && overagePacks(1) === 1 && overagePacks(100) === 1 && overagePacks(101) === 2 && overagePacks(250) === 3, "angefangene 100er-Pakete zählen");
ok(usageLevel(212, 450) === "ok", "212/450 → ok");
ok(usageLevel(360, 450) === "warn" && usageLevel(359, 450) === "ok", "ab 80 % → warn");
ok(usageLevel(450, 450) === "exhausted" && usageLevel(451, 450) === "over", "ausgeschöpft / Mehrverbrauch");
ok(usageLevel(0, 0) === "ok" && usageLevel(1, 0) === "over", "ohne Kontingent zählt jede Nutzung als Mehrverbrauch");
ok(usagePercent(212, 450) === 47 && usagePercent(900, 450) === 100 && usagePercent(0, 0) === 0, "Balken in Prozent (gedeckelt)");
ok(!isQuotaBlocked(500, 450, false), "ohne hartes Limit wird weitergezählt");
ok(isQuotaBlocked(450, 450, true) && !isQuotaBlocked(449, 450, true), "hartes Limit sperrt ab dem Kontingent");
console.log("\n— Monate —");
ok(monthKeyOfDay("2026-09-21") === "2026-09", "Monat eines Tags");
ok(previousMonthKey("2026-01") === "2025-12" && previousMonthKey("2026-09") === "2026-08", "Vormonat inkl. Jahreswechsel");
ok(nextMonthKey("2026-12") === "2027-01" && nextMonthKey("2026-09") === "2026-10", "Folgemonat inkl. Jahreswechsel");
console.log("\n— Navigation —");
const all = NAV_ITEMS.flatMap((i) => i.permissions ?? []);
const basisNav = visibleNavItems(NAV_ITEMS, { disabledModules: new Set(profiOnly), lockedFeatures: new Set(["planning", "lotse_chat"]), permissions: all });
const profiNav = visibleNavItems(NAV_ITEMS, { disabledModules: new Set(), lockedFeatures: new Set(), permissions: all });
ok(!basisNav.some((i) => i.href.startsWith("/planning")), "Basis: keine Planung in der Navigation");
ok(!basisNav.some((i) => ["/billing", "/imports", "/work-orders/emergency-review"].includes(i.href)), "Basis: keine Abrechnung/Import/Notdienst-Prüfung");
ok(basisNav.some((i) => i.href === "/work-orders") && basisNav.some((i) => i.href === "/work-orders/time-approvals") && basisNav.some((i) => i.href === "/reports"), "Basis: Aufträge, Zeitfreigabe, Berichte bleiben");
ok(profiNav.some((i) => i.href === "/planning") && profiNav.some((i) => i.href === "/billing"), "Profi: Planung und Abrechnung sichtbar");
console.log(failures === 0 ? "\nAlle Prüfungen grün." : `\n${failures} FEHLER.`);
process.exit(failures === 0 ? 0 : 1);
+191
View File
@@ -0,0 +1,191 @@
// L17 Pakete — Lotse-Chat pro Nutzer: Platzvergabe bis zur Grenze (darüber invalid), Chat ohne Platz →
// blocked no_seat, mit Platz → ok, Testphase ohne Platzprüfung, Kontingentzählung über die Monatsgrenze
// (Zeitzone), Mehrverbrauch weiterzählen, hartes Limit → blocked quota_exhausted, Feldrollen vergeben
// keine Plätze, Mandantentrennung (B sieht/ändert keine Plätze/Zählung von A), Betreiber-Übersicht.
// Lauf: npx tsx scripts/test-pakete-seats.ts (auch mit RLS_ENFORCED=true)
import "dotenv/config";
import { prisma, dbForTenant } from "../src/server/db";
import { FakeLotseChatProvider } from "../src/server/ai/lotse/chat-fake";
import { assertLotseChatUsable, canUseLotseChat, isChatQuotaBlocked } from "../src/server/services/lotse/chat/access";
import { getChatView } from "../src/server/services/lotse/chat/conversations";
import { sendLotseMessage } from "../src/server/services/lotse/chat/engine";
import { getSeatOverview, listEligibleUsers, setChatSeat } from "../src/server/services/lotse/chat/seats";
import { getChatUsage, getChatUsageForAdmin, monthBounds, monthKeyOf } from "../src/server/services/lotse/chat/usage";
import { getTenantPlanOverview, updateTenantPlan } from "../src/server/services/plans/platform";
import { ServiceError } from "../src/server/services/context";
import { codeOf, createTenant, expectCode, ok, runSuite, section } from "./lib/e2e-fixture";
const SLUG_A = "zz-pak-seats-a";
const SLUG_B = "zz-pak-seats-b";
const PLATFORM_EMAIL = "platform-seats@zz-pakete.test";
async function reasonOf(p: Promise<unknown>): Promise<string> {
try {
await p;
return "ok";
} catch (err) {
if (err instanceof ServiceError) return `${err.code}/${(err.details as { reason?: string } | undefined)?.reason ?? err.message}`;
return `error:${(err as Error).message}`;
}
}
async function extraCleanup(tenantIds: string[]) {
const w = { where: { tenantId: { in: tenantIds } } };
await prisma.lotseChatSeat.deleteMany(w);
await prisma.lotseActionProposal.deleteMany(w);
await prisma.lotseMessage.deleteMany(w);
await prisma.lotseConversation.deleteMany(w);
await prisma.platformAdmin.deleteMany({ where: { email: PLATFORM_EMAIL } });
}
runSuite("L17 Pakete – Lotse-Chat-Plätze & Kontingent", [SLUG_A, SLUG_B], async () => {
await extraCleanup([]);
const A = await createTenant(SLUG_A);
const B = await createTenant(SLUG_B);
try {
const platform = await prisma.platformAdmin.create({ data: { email: PLATFORM_EMAIL, name: "ZZ Plattform", passwordHash: "x", role: "full" } });
const actor = { platformAdminId: platform.id };
await updateTenantPlan(actor, A.tenantId, { tier: "PROFI", lotseChatSeats: 2, lotseChatHardLimit: false });
await updateTenantPlan(actor, B.tenantId, { tier: "PROFI", lotseChatSeats: 5, lotseChatHardLimit: false });
section("Berechtigte Nutzer (Feldrolle: lotse:use + field:execute)");
const eligible = (await listEligibleUsers(A.ctx.admin)).map((u) => u.id);
const u = A.users;
ok([u.tech.id, u.tech2.id, u.outsider.id, u.lead.id, u.lead2.id].every((id) => eligible.includes(id)), "Monteure und Teamleiter berechtigt");
ok(!eligible.includes(u.backoffice.id), "Backoffice ohne field:execute nicht berechtigt");
ok(eligible.includes(u.admin.id), "Mandantenadministrator hat beide Rechte → berechtigt (Regel = Rechte, nicht Rollenname)");
section("Chat ohne Platz → blocked no_seat");
ok(!(await canUseLotseChat(A.ctx.tech)), "canUseLotseChat ohne Platz → false (kein Navigationseintrag)");
ok((await reasonOf(assertLotseChatUsable(A.ctx.tech))) === "blocked/no_seat", "assertLotseChatUsable → blocked no_seat");
ok((await reasonOf(getChatView(A.ctx.tech))) === "blocked/no_seat", "direkter Aufruf /m/lotse (getChatView) → no_seat");
const provider = () => new FakeLotseChatProvider([{ text: "Alles klar." }]);
ok((await reasonOf(sendLotseMessage(A.ctx.tech, { text: "Hallo" }, { provider: provider() }))) === "blocked/no_seat", "Senden ohne Platz → no_seat");
ok((await prisma.lotseMessage.count({ where: { tenantId: A.tenantId } })) === 0, "ohne Platz wird nichts gespeichert");
section("Platzvergabe bis zur Grenze");
const r1 = await setChatSeat(A.ctx.admin, { userId: u.tech.id, assigned: true });
ok(r1.changed, "Platz 1 an Monteur vergeben");
await setChatSeat(A.ctx.admin, { userId: u.lead.id, assigned: true });
const overview = await getSeatOverview(A.ctx.admin);
ok(overview.purchased === 2 && overview.assigned === 2 && !overview.overbooked, "2 von 2 Plätzen vergeben");
ok((await reasonOf(setChatSeat(A.ctx.admin, { userId: u.tech2.id, assigned: true }))) === "invalid/no_seats_left", "dritter Platz → invalid no_seats_left");
ok((await prisma.lotseChatSeat.count({ where: { tenantId: A.tenantId } })) === 2, "Grenze serverseitig gehalten");
await setChatSeat(A.ctx.admin, { userId: u.lead.id, assigned: false });
const parallel = await Promise.allSettled([u.tech2.id, u.outsider.id].map((userId) => setChatSeat(A.ctx.admin, { userId, assigned: true })));
ok(parallel.filter((p) => p.status === "fulfilled").length === 1 && (await prisma.lotseChatSeat.count({ where: { tenantId: A.tenantId } })) === 2, "ein freier Platz, zwei parallele Vergaben → genau eine gelingt");
for (const userId of [u.tech2.id, u.outsider.id]) await setChatSeat(A.ctx.admin, { userId, assigned: false });
await setChatSeat(A.ctx.admin, { userId: u.lead.id, assigned: true });
ok(!(await setChatSeat(A.ctx.admin, { userId: u.tech.id, assigned: true })).changed, "erneutes Vergeben idempotent");
ok((await reasonOf(setChatSeat(A.ctx.admin, { userId: u.backoffice.id, assigned: false }))) === "ok", "Entzug ohne Platz idempotent");
const auditCreate = await prisma.auditLog.findFirst({ where: { tenantId: A.tenantId, entity: "lotse_chat_seat", entityId: u.tech.id, action: "create" } });
ok(!!auditCreate && (auditCreate.after as { userId?: string })?.userId === u.tech.id && auditCreate.actorId === u.admin.id, "Audit bei Vergabe (after)");
section("Chat mit Platz → ok");
ok(await canUseLotseChat(A.ctx.tech), "canUseLotseChat mit Platz → true");
ok((await codeOf(getChatView(A.ctx.tech))) === "ok", "Chat-Ansicht mit Platz");
const view = await sendLotseMessage(A.ctx.tech, { text: "Wie ist der Stand?" }, { provider: provider() });
ok(view.messages.some((m) => m.role === "user"), "Nachricht mit Platz gesendet");
ok((await canUseLotseChat(A.ctx.lead)) && !(await canUseLotseChat(A.ctx.tech2)), "Teamleiter mit Platz ja, Monteur ohne Platz nein");
section("Entzug + Überbelegung");
await setChatSeat(A.ctx.admin, { userId: u.lead.id, assigned: false });
ok((await reasonOf(assertLotseChatUsable(A.ctx.lead))) === "blocked/no_seat", "nach Entzug → no_seat");
const auditDelete = await prisma.auditLog.findFirst({ where: { tenantId: A.tenantId, entity: "lotse_chat_seat", entityId: u.lead.id, action: "delete" } });
ok(!!auditDelete && (auditDelete.before as { userId?: string })?.userId === u.lead.id, "Audit bei Entzug (before)");
await setChatSeat(A.ctx.admin, { userId: u.tech2.id, assigned: true });
await updateTenantPlan(actor, A.tenantId, { tier: "PROFI", lotseChatSeats: 1, lotseChatHardLimit: false });
const over = await getSeatOverview(A.ctx.admin);
ok(over.overbooked && over.users.find((x) => x.id === u.tech.id)?.valid === true && over.users.find((x) => x.id === u.tech2.id)?.valid === false, "Betreiber senkt auf 1: nur der zuerst vergebene Platz gilt");
ok((await reasonOf(assertLotseChatUsable(A.ctx.tech2))) === "blocked/no_seat", "überzähliger Platz → no_seat");
await updateTenantPlan(actor, A.tenantId, { tier: "PROFI", lotseChatSeats: 2, lotseChatHardLimit: false });
ok(await canUseLotseChat(A.ctx.tech2), "wieder 2 Plätze → beide nutzbar");
section("Feldrollen und Backoffice vergeben keine Plätze");
for (const p of ["tech", "lead", "backoffice"] as const) {
await expectCode(() => setChatSeat(A.ctx[p], { userId: u.outsider.id, assigned: true }), "forbidden", `${p} → forbidden`);
await expectCode(() => getSeatOverview(A.ctx[p]), "forbidden", `${p} sieht die Platzverwaltung nicht`);
await expectCode(() => getChatUsageForAdmin(A.ctx[p]), "forbidden", `${p} sieht den Verbrauch nicht`);
}
ok((await reasonOf(setChatSeat(A.ctx.admin, { userId: u.backoffice.id, assigned: true }))) === "invalid/not_eligible", "Platz an Nutzer ohne Feldrolle → invalid not_eligible");
await prisma.user.update({ where: { id: u.outsider.id }, data: { status: "DEACTIVATED" } });
await setChatSeat(A.ctx.admin, { userId: u.tech2.id, assigned: false });
ok((await reasonOf(setChatSeat(A.ctx.admin, { userId: u.outsider.id, assigned: true }))) === "invalid/not_eligible", "deaktivierter Nutzer → invalid not_eligible");
await prisma.user.update({ where: { id: u.outsider.id }, data: { status: "ACTIVE" } });
await setChatSeat(A.ctx.admin, { userId: u.tech2.id, assigned: true });
section("Mandantentrennung");
ok((await reasonOf(setChatSeat(B.ctx.admin, { userId: u.tech.id, assigned: true }))) === "invalid/not_eligible", "B vergibt keinen Platz an Nutzer aus A");
ok((await reasonOf(setChatSeat(B.ctx.admin, { userId: u.tech.id, assigned: false }))) === "ok" && (await prisma.lotseChatSeat.count({ where: { tenantId: A.tenantId, userId: u.tech.id } })) === 1, "B kann den Platz von A nicht entziehen");
const bOverview = await getSeatOverview(B.ctx.admin);
ok(!bOverview.users.some((x) => [u.tech.id, u.tech2.id].includes(x.id)) && bOverview.assigned === 0, "B sieht keine Plätze/Nutzer von A");
ok((await dbForTenant(B.tenantId).lotseChatSeat.findMany()).length === 0, "Tenant-Client von B findet keine Platz-Zeilen von A");
ok((await getChatUsageForAdmin(B.ctx.admin)).used === 0, "B zählt keine Chats von A");
ok((await reasonOf(assertLotseChatUsable(B.ctx.tech))) === "blocked/no_seat", "B-Monteur hat keinen Platz (Plätze gelten je Mandant)");
section("Kontingentzählung über die Monatsgrenze (Zeitzone Europe/Berlin)");
const tz = "Europe/Berlin";
ok(monthKeyOf(new Date("2026-02-28T23:30:00Z"), tz) === "2026-03" && monthKeyOf(new Date("2026-02-28T22:30:00Z"), tz) === "2026-02", "Monat in Mandanten-Zeitzone");
const march = monthBounds("2026-03", tz);
ok(march.from.toISOString() === "2026-02-28T23:00:00.000Z" && march.to.toISOString() === "2026-03-31T22:00:00.000Z", "Monatsgrenzen inkl. Sommerzeit");
const conv = await prisma.lotseConversation.create({ data: { tenantId: A.tenantId, userId: u.tech2.id } });
const msg = (createdAt: string, role: "user" | "assistant" = "user") => ({ tenantId: A.tenantId, conversationId: conv.id, role, text: "x", createdAt: new Date(createdAt) });
await prisma.lotseMessage.createMany({
data: [msg("2026-02-28T22:30:00Z"), msg("2026-02-28T23:30:00Z"), msg("2026-02-28T23:31:00Z", "assistant"), msg("2026-03-31T21:59:00Z"), msg("2026-03-31T22:00:00Z")],
});
const feb = await getChatUsage(A.ctx.admin, { monthKey: "2026-02" });
const mar = await getChatUsage(A.ctx.admin, { monthKey: "2026-03" });
const apr = await getChatUsage(A.ctx.admin, { monthKey: "2026-04" });
ok(feb.used === 1 && mar.used === 2 && apr.used === 1, `Zählung je Monat (Feb ${feb.used}, Mär ${mar.used}, Apr ${apr.used}); Lotse-Antworten zählen nicht`);
ok(mar.perUser.length === 1 && mar.perUser[0].userId === u.tech2.id && mar.perUser[0].count === 2, "Aufschlüsselung je Nutzer");
section("Mehrverbrauch weiterzählen, hartes Limit");
const now = new Date();
const current = await getChatUsage(A.ctx.admin, { now });
ok(current.quota === 300 && current.quotaBasis === 2, `Kontingent = 2 vergebene Plätze × 150 (${current.quota})`);
const fill = 300 - current.used;
const recent = new Date(Math.max(new Date(current.from).getTime() + 60_000, now.getTime() - 60_000));
await prisma.lotseMessage.createMany({ data: Array.from({ length: fill + 5 }, () => ({ tenantId: A.tenantId, conversationId: conv.id, role: "user" as const, text: "y", createdAt: recent })) });
const overUsage = await getChatUsage(A.ctx.admin, { now });
ok(overUsage.used === 305 && overUsage.overage === 5 && overUsage.overagePacks === 1 && overUsage.level === "over" && !overUsage.blocked, "305/300: Mehrverbrauch 5 → 1 Paket, nicht gesperrt");
ok((await reasonOf(assertLotseChatUsable(A.ctx.tech, { send: true, now }))) === "ok", "ohne hartes Limit: Senden weiter möglich");
const sent = await sendLotseMessage(A.ctx.tech, { text: "Noch eine Frage" }, { provider: provider(), now: () => now });
ok(sent.messages.length > 0 && (await getChatUsage(A.ctx.admin, { now })).used === 306, "Mehrverbrauch wird weitergezählt (306)");
await updateTenantPlan(actor, A.tenantId, { tier: "PROFI", lotseChatSeats: 2, lotseChatHardLimit: true });
ok(await isChatQuotaBlocked(A.ctx.tech, now), "hartes Limit → Kontingent gesperrt");
ok((await reasonOf(assertLotseChatUsable(A.ctx.tech, { send: true, now }))) === "blocked/quota_exhausted", "Senden → blocked quota_exhausted");
const beforeCount = await prisma.lotseMessage.count({ where: { tenantId: A.tenantId } });
ok((await reasonOf(sendLotseMessage(A.ctx.tech, { text: "Gesperrt?" }, { provider: provider(), now: () => now }))) === "blocked/quota_exhausted", "sendLotseMessage → quota_exhausted");
ok((await prisma.lotseMessage.count({ where: { tenantId: A.tenantId } })) === beforeCount, "gesperrte Nachricht wird nicht gespeichert/gezählt");
ok((await codeOf(getChatView(A.ctx.tech))) === "ok", "Verlauf bleibt lesbar (Hinweis statt Eingabefeld)");
ok(!(await isChatQuotaBlocked(B.ctx.tech, now)), "hartes Limit von A wirkt nicht auf B");
section("Testphase: ohne Platzprüfung, Kontingent = Berechtigte × 150");
await prisma.tenant.update({ where: { id: B.tenantId }, data: { plan: "TRIAL", trialStartedAt: new Date(), trialEndsAt: new Date(Date.now() + 10 * 86400_000), tier: "BASIS", lotseChatHardLimit: true } });
ok((await canUseLotseChat(B.ctx.tech)) && (await canUseLotseChat(B.ctx.outsider)), "Testphase: jeder berechtigte Nutzer darf chatten (auch bei gespeicherter Stufe Basis)");
const trialUsage = await getChatUsage(B.ctx.admin, { now });
const eligibleB = (await listEligibleUsers(B.ctx.admin)).length;
ok(trialUsage.isTrial && trialUsage.quota === eligibleB * 150 && !trialUsage.hardLimit, `Testphase: Kontingent ${eligibleB} × 150, kein hartes Limit`);
ok((await codeOf(sendLotseMessage(B.ctx.outsider, { text: "Test" }, { provider: provider() }))) === "ok", "Testphase: Senden ohne Platz");
ok((await codeOf(assertLotseChatUsable(B.ctx.backoffice))) === "forbidden", "Testphase: ohne Feldrolle weiterhin kein Chat");
await prisma.tenant.update({ where: { id: B.tenantId }, data: { plan: "FULL", trialStartedAt: null, trialEndsAt: null, tier: "PROFI", lotseChatHardLimit: false } });
section("Basis sperrt den Chat, Plätze bleiben");
await updateTenantPlan(actor, A.tenantId, { tier: "BASIS", lotseChatSeats: 2, lotseChatHardLimit: false });
ok((await reasonOf(assertLotseChatUsable(A.ctx.tech))) === "blocked/not_in_plan", "Basis → blocked not_in_plan");
ok(!(await canUseLotseChat(A.ctx.tech)), "Basis: kein „Lotse fragen“/Navigationseintrag");
ok((await prisma.lotseChatSeat.count({ where: { tenantId: A.tenantId } })) === 2, "Plätze bleiben gespeichert");
ok((await reasonOf(setChatSeat(A.ctx.admin, { userId: u.lead.id, assigned: true }))) === "blocked/not_in_plan", "Basis: keine neue Vergabe");
await updateTenantPlan(actor, A.tenantId, { tier: "PROFI", lotseChatSeats: 2, lotseChatHardLimit: false });
ok(await canUseLotseChat(A.ctx.tech), "zurück auf Profi: Chat mit bestehendem Platz wieder nutzbar");
section("Betreiber-Übersicht (Grundlage der Rechnung)");
const op = await getTenantPlanOverview(actor, A.tenantId, now);
ok(op.plan.lotseChatSeats === 2 && op.seatsAssigned === 2 && op.current.used === 306 && op.current.overage === 6 && op.current.overagePacks === 1, `laufender Monat: 306 Chats, Mehrverbrauch 6 → 1 Paket`);
ok(op.previous.monthKey !== op.current.monthKey && op.previous.used >= 0, `Vormonat ${op.previous.monthKey}: ${op.previous.used} Chats`);
await expectCode(() => getTenantPlanOverview({ platformAdminId: u.admin.id }, A.tenantId), "forbidden", "Mandanten-Admin hat keine Betreiber-Übersicht");
} finally {
await extraCleanup([A.tenantId, B.tenantId]);
}
});
+7 -3
View File
@@ -27,6 +27,8 @@ import { getDashboardTiles } from "@/server/services/work-orders/dashboard";
import { customerDisplayName, customerFilterOptions, teamOptions, userOptions } from "@/server/services/work-orders/options"; import { customerDisplayName, customerFilterOptions, teamOptions, userOptions } from "@/server/services/work-orders/options";
import { listOrderTypes } from "@/server/services/work-orders/settings"; import { listOrderTypes } from "@/server/services/work-orders/settings";
import { GettingStarted } from "@/components/trial/getting-started"; import { GettingStarted } from "@/components/trial/getting-started";
import { ProfiHint } from "@/components/plans/profi-hint";
import { effectiveModules } from "@/server/plan";
type SP = Record<string, string | string[] | undefined>; type SP = Record<string, string | string[] | undefined>;
@@ -54,8 +56,9 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
const t = await getTranslations("dashboard"); const t = await getTranslations("dashboard");
const tw = await getTranslations("workOrders"); const tw = await getTranslations("workOrders");
const moduleRow = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "work_orders" }, select: { enabled: true } }); // L17 Pakete: effektive Module (Schalter + Paketstufe) zentral
const moduleEnabled = !moduleRow || moduleRow.enabled; const modules = await effectiveModules(ctx.tenantId);
const moduleEnabled = !modules.inactive.has("work_orders");
const f = parseListParams(sp); const f = parseListParams(sp);
const filter = { from: f.from, to: f.to, customerId: f.customerId, teamId: f.teamId, userId: f.userId, orderTypeId: f.orderTypeId, priority: f.priority }; const filter = { from: f.from, to: f.to, customerId: f.customerId, teamId: f.teamId, userId: f.userId, orderTypeId: f.orderTypeId, priority: f.priority };
@@ -72,6 +75,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
{t("moduleDisabled")} {t("moduleDisabled")}
</p> </p>
)} )}
{sp.module === "profi" && <ProfiHint />} {/* L17 Pakete: Funktion nicht in der Paketstufe */}
{!tiles ? ( {!tiles ? (
<p className="rounded-lg border bg-card px-4 py-3 text-sm text-muted-foreground">{t("workOrdersDisabled")}</p> <p className="rounded-lg border bg-card px-4 py-3 text-sm text-muted-foreground">{t("workOrdersDisabled")}</p>
@@ -148,7 +152,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
</details> </details>
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"> <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{TILES.filter((tile) => (tile.key !== "sync_conflicts" || can("work_order:write")) && (tile.key !== "time_approvals" || can("time:approve")) && (tile.key !== "billing_ready" || can("billing:read"))).map(({ key, icon: Icon, tone }) => { {TILES.filter((tile) => (tile.key !== "sync_conflicts" || can("work_order:write")) && (tile.key !== "time_approvals" || can("time:approve")) && (tile.key !== "billing_ready" || (can("billing:read") && !modules.inactive.has("billing"))) && (tile.key !== "emergency_new" || !modules.inactive.has("emergency"))).map(({ key, icon: Icon, tone }) => {
const count = key === "billing_ready" ? tiles.billingReady : key === "sync_conflicts" ? tiles.syncConflicts : key === "time_approvals" ? tiles.timeApprovals : key === "reports_in_review" ? tiles.reportsToReview : tiles[key]; const count = key === "billing_ready" ? tiles.billingReady : key === "sync_conflicts" ? tiles.syncConflicts : key === "time_approvals" ? tiles.timeApprovals : key === "reports_in_review" ? tiles.reportsToReview : tiles[key];
const href = key === "billing_ready" ? "/billing" : key === "sync_conflicts" ? "/work-orders/conflicts" : key === "time_approvals" ? "/work-orders/time-approvals" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`; const href = key === "billing_ready" ? "/billing" : key === "sync_conflicts" ? "/work-orders/conflicts" : key === "time_approvals" ? "/work-orders/time-approvals" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`;
return ( return (
+6 -4
View File
@@ -3,6 +3,7 @@ import { getTranslations } from "next-intl/server";
import { signOut } from "@/server/auth"; import { signOut } from "@/server/auth";
import { dbForTenant } from "@/server/db"; import { dbForTenant } from "@/server/db";
import { requireAppAccess } from "@/server/app-access"; import { requireAppAccess } from "@/server/app-access";
import { effectiveModules } from "@/server/plan";
import { NAV_ITEMS, visibleNavItems } from "@/lib/nav"; import { NAV_ITEMS, visibleNavItems } from "@/lib/nav";
import { resolveTenantBranding } from "@/lib/brand"; import { resolveTenantBranding } from "@/lib/brand";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -30,17 +31,18 @@ export default async function AppLayout({
// Navigation aus src/lib/nav.ts, gefiltert nach aktiven Modulen + Rechten (Komfort; // Navigation aus src/lib/nav.ts, gefiltert nach aktiven Modulen + Rechten (Komfort;
// Seiten/Actions prüfen serverseitig selbst). // Seiten/Actions prüfen serverseitig selbst).
const [moduleRows, brandingSettings] = await Promise.all([ // L17 Pakete: effektive Module (TenantModule + Paketstufe) zentral aus src/server/plan.ts
dbForTenant(session.user.tenantId).tenantModule.findMany(), const [modules, brandingSettings] = await Promise.all([
effectiveModules(session.user.tenantId),
dbForTenant(session.user.tenantId).tenantSettings.findUnique({ dbForTenant(session.user.tenantId).tenantSettings.findUnique({
where: { tenantId: session.user.tenantId }, where: { tenantId: session.user.tenantId },
select: { accent: true }, select: { accent: true },
}), }),
]); ]);
const branding = resolveTenantBranding(brandingSettings); const branding = resolveTenantBranding(brandingSettings);
const disabledModules = new Set(moduleRows.filter((m) => !m.enabled).map((m) => m.moduleKey));
const navItems = visibleNavItems(NAV_ITEMS, { const navItems = visibleNavItems(NAV_ITEMS, {
disabledModules, disabledModules: modules.inactive,
lockedFeatures: modules.lockedFeatures,
permissions: session.user.permissions ?? [], permissions: session.user.permissions ?? [],
}); });
const mainNav = navItems.filter((i) => i.section === "main"); const mainNav = navItems.filter((i) => i.section === "main");
+4 -2
View File
@@ -1,7 +1,9 @@
import { requireModule } from "@/server/modules"; import { requireModule } from "@/server/modules";
import { requirePlanFeature } from "@/server/plan";
/** Modul-Gate „work_orders" für Plantafel und Live-Lage (L13 Planung). */ /** Modul-Gate „work_orders" + Paket-Feature „planning" (L17) für Plantafel und Live-Lage (L13 Planung). */
export default async function PlanningLayout({ children }: Readonly<{ children: React.ReactNode }>) { export default async function PlanningLayout({ children }: Readonly<{ children: React.ReactNode }>) {
await requireModule("work_orders"); const session = await requireModule("work_orders");
await requirePlanFeature(session.user.tenantId, "planning"); // L17 Pakete: Planung nur in Profi
return <>{children}</>; return <>{children}</>;
} }
+5 -1
View File
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
import { ArrowLeft, CheckCircle2, ListChecks, MinusCircle, ShieldCheck, XCircle } from "lucide-react"; import { ArrowLeft, CheckCircle2, ListChecks, MinusCircle, ShieldCheck, XCircle } from "lucide-react";
import { LotseMark } from "@/components/lotse/lotse-mark"; import { LotseMark } from "@/components/lotse/lotse-mark";
import { LotseSeatAdmin } from "@/components/lotse/chat/seat-admin";
import { PageHead } from "@/components/mockup-ui"; import { PageHead } from "@/components/mockup-ui";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { saveLotseSettings } from "@/server/actions/lotse-settings"; import { saveLotseSettings } from "@/server/actions/lotse-settings";
@@ -24,7 +25,7 @@ function ProviderStatus({ ok, labels }: { ok: boolean; labels: { ok: string; off
* /settings/lotse (tenant:manage): Lotse on/off (module toggle `lotse`), address form, transparency on * /settings/lotse (tenant:manage): Lotse on/off (module toggle `lotse`), address form, transparency on
* which data goes to which provider, link to the AI log. Deliberately not module-gated (re-enabling). * which data goes to which provider, link to the AI log. Deliberately not module-gated (re-enabling).
*/ */
export default async function LotseSettingsPage({ searchParams }: { searchParams: Promise<{ saved?: string; error?: string }> }) { export default async function LotseSettingsPage({ searchParams }: { searchParams: Promise<{ saved?: string; error?: string; seat?: string; seatError?: string }> }) {
const ctx = await readCtx(); const ctx = await readCtx();
if (!can(ctx, "tenant:manage")) redirect("/dashboard"); if (!can(ctx, "tenant:manage")) redirect("/dashboard");
const [sp, s, t, locale] = await Promise.all([searchParams, getLotseSettings(ctx), getTranslations("lotse"), getLocale()]); const [sp, s, t, locale] = await Promise.all([searchParams, getLotseSettings(ctx), getTranslations("lotse"), getLocale()]);
@@ -159,6 +160,9 @@ export default async function LotseSettingsPage({ searchParams }: { searchParams
</Link> </Link>
</section> </section>
</div> </div>
{/* L17 Pakete: Paket (nur lesend), Lotse-Chat-Plätze, Monatsverbrauch */}
<LotseSeatAdmin ctx={ctx} seatSaved={sp.seat === "saved"} seatError={sp.seatError} />
</main> </main>
); );
} }
+4 -3
View File
@@ -12,6 +12,7 @@ import { BRAND } from "@/lib/brand";
import { updateTenantSettings } from "@/server/actions/tenant-settings"; import { updateTenantSettings } from "@/server/actions/tenant-settings";
import { ShieldCheck, History } from "lucide-react"; import { ShieldCheck, History } from "lucide-react";
import { AuditTrailModal, type AuditRow } from "@/components/audit-trail"; import { AuditTrailModal, type AuditRow } from "@/components/audit-trail";
import { effectiveModules } from "@/server/plan";
// TODO(i18n): Texte dieser Seite in messages/<locale>/settings.json überführen // TODO(i18n): Texte dieser Seite in messages/<locale>/settings.json überführen
// (aus dem Fundament übernommen; nicht Teil des Rückbaus). // (aus dem Fundament übernommen; nicht Teil des Rückbaus).
@@ -28,9 +29,9 @@ export default async function SettingsPage({
const db = dbForTenant(session.user.tenantId); const db = dbForTenant(session.user.tenantId);
const sp = await searchParams; const sp = await searchParams;
const [s, moduleRows] = await Promise.all([ const [s, modules] = await Promise.all([
db.tenantSettings.findUnique({ where: { tenantId: session.user.tenantId } }), db.tenantSettings.findUnique({ where: { tenantId: session.user.tenantId } }),
db.tenantModule.findMany(), effectiveModules(session.user.tenantId), // L17 Pakete: Schalter + Paketstufe
]); ]);
// Audit-Trail nur laden, wenn das Popup offen ist (?audit=1). Der Mandanten- // Audit-Trail nur laden, wenn das Popup offen ist (?audit=1). Der Mandanten-
@@ -50,7 +51,7 @@ export default async function SettingsPage({
} }
} }
// Fehlende TenantModule-Zeile ⇒ Modul gilt als aktiv (Default an). // Fehlende TenantModule-Zeile ⇒ Modul gilt als aktiv (Default an).
const disabled = new Set(moduleRows.filter((m) => !m.enabled).map((m) => m.moduleKey)); const disabled = modules.inactive;
const v = (x?: string | null) => x ?? ""; const v = (x?: string | null) => x ?? "";
return ( return (
+4 -5
View File
@@ -29,7 +29,7 @@ import { FINAL_STATUSES, PLANNING_LOCKED } from "@/server/services/work-orders/_
import { availableTransitions, getWorkOrderDetail } from "@/server/services/work-orders/detail"; import { availableTransitions, getWorkOrderDetail } from "@/server/services/work-orders/detail";
import { customerDisplayName, customerOption, teamOptions, userOptions } from "@/server/services/work-orders/options"; import { customerDisplayName, customerOption, teamOptions, userOptions } from "@/server/services/work-orders/options";
import { listOrderTypes } from "@/server/services/work-orders/settings"; import { listOrderTypes } from "@/server/services/work-orders/settings";
import { reasonRequired } from "@/server/services/work-orders/transition"; import { billingModuleActive, reasonRequired } from "@/server/services/work-orders/transition";
const TABS = ["overview", "checklist", "material", "times", "photos", "notes", "reports", "billing", "documents", "history"] as const; // L14: billing const TABS = ["overview", "checklist", "material", "times", "photos", "notes", "reports", "billing", "documents", "history"] as const; // L14: billing
type Tab = (typeof TABS)[number]; type Tab = (typeof TABS)[number];
@@ -103,8 +103,7 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa
]); ]);
// L14: with the billing overview active, "billed" is set only there (snapshot + position assignment) → no direct button // L14: with the billing overview active, "billed" is set only there (snapshot + position assignment) → no direct button
const billingModule = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "billing" }, select: { enabled: true } }); const billingOverviewActive = await billingModuleActive(ctx); // L17: incl. package tier
const billingOverviewActive = !billingModule || billingModule.enabled;
const billingTransitions = transitions.filter( const billingTransitions = transitions.filter(
(to) => (to) =>
(to === "released_for_billing" || (to === "billed" && !billingOverviewActive)) || (to === "released_for_billing" || (to === "billed" && !billingOverviewActive)) ||
@@ -188,7 +187,7 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa
<div className="mt-4"> <div className="mt-4">
<LinkTabs <LinkTabs
label={t("detail.tabs.overview")} label={t("detail.tabs.overview")}
items={TABS.map((k) => ({ href: `/work-orders/${wo.id}${k === "overview" ? "" : `?tab=${k}`}`, label: t(`detail.tabs.${k}`), active: tab === k }))} items={TABS.filter((k) => k !== "billing" || billingOverviewActive).map((k) => ({ href: `/work-orders/${wo.id}${k === "overview" ? "" : `?tab=${k}`}`, label: t(`detail.tabs.${k}`), active: tab === k }))}
/> />
</div> </div>
@@ -211,7 +210,7 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa
} }
/> />
)} )}
{tab === "billing" && <WorkOrderBillingTab ctx={ctx} wo={wo} locale={locale} tz={tz} />} {tab === "billing" && billingOverviewActive && <WorkOrderBillingTab ctx={ctx} wo={wo} locale={locale} tz={tz} />}
{tab === "documents" && <DocumentsTab {...tabProps} uploadError={one(sp.uploadError)} uploaded={one(sp.uploaded) === "1"} />} {tab === "documents" && <DocumentsTab {...tabProps} uploadError={one(sp.uploadError)} uploaded={one(sp.uploaded) === "1"} />}
{tab === "history" && <HistoryTab {...tabProps} />} {tab === "history" && <HistoryTab {...tabProps} />}
</div> </div>
+9 -1
View File
@@ -1,7 +1,15 @@
import { requireSession } from "@/server/auth";
import { requireModule } from "@/server/modules"; 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. */ /**
* 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 }>) { 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"); await requireModule("lotse");
return <>{children}</>; return <>{children}</>;
} }
+7 -18
View File
@@ -1,17 +1,18 @@
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
import { ChevronLeft, CircleSlash } from "lucide-react"; import { ChevronLeft } from "lucide-react";
import type { ChatView } from "@/lib/lotse/chat"; import type { ChatView } from "@/lib/lotse/chat";
import { isAiConfigured } from "@/server/ai/client"; import { isAiConfigured } from "@/server/ai/client";
import { transcriptionConfig } from "@/server/ai/transcription/openai-compatible"; import { transcriptionConfig } from "@/server/ai/transcription/openai-compatible";
import { ServiceError } from "@/server/services/context"; import { ServiceError } from "@/server/services/context";
import { fieldPageContext } from "@/server/services/field/page-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 { getChatView } from "@/server/services/lotse/chat/conversations";
import { tenantTimezone } from "@/server/services/work-orders/_shared"; import { tenantTimezone } from "@/server/services/work-orders/_shared";
import { LotseMark } from "@/components/lotse/lotse-mark"; import { LotseMark } from "@/components/lotse/lotse-mark";
import { LotseChat } from "@/components/lotse/chat/lotse-chat"; import { LotseChat } from "@/components/lotse/chat/lotse-chat";
import { card } from "@/components/field/ui"; import { LotseChatUnavailable } from "@/components/lotse/chat/unavailable";
/** /**
* `/m/lotse` — Lotse chat for technicians (lane L16). `?order=<id>` opens the chat with order context * `/m/lotse` — Lotse chat for technicians (lane L16). `?order=<id>` opens the chat with order context
@@ -27,24 +28,11 @@ export default async function LotseChatPage({ searchParams }: { searchParams: Pr
if (!(err instanceof ServiceError)) throw err; if (!(err instanceof ServiceError)) throw err;
if (err.code === "not_found") notFound(); if (err.code === "not_found") notFound();
const reason = (err.details as { reason?: string } | undefined)?.reason; const reason = (err.details as { reason?: string } | undefined)?.reason;
const code = reason === "disabled" || reason === "chat_disabled" ? reason : err.code === "forbidden" ? "forbidden" : "generic"; const code = reason && ["disabled", "chat_disabled", "not_in_plan", "no_seat"].includes(reason) ? reason : err.code === "forbidden" ? "forbidden" : "generic";
return ( return <LotseChatUnavailable reason={code} />;
<main className="space-y-3 p-4">
<section className={card}>
<h1 className="flex items-center gap-2 text-[20px]">
<CircleSlash className="size-5 text-muted-foreground" aria-hidden />
{t("chat.unavailable.title")}
</h1>
<p className="mt-2 text-[15px]">{t(`chat.errors.${code}`)}</p>
<Link href="/m" className="mt-3 inline-flex min-h-12 items-center font-semibold text-primary">
{t("chat.unavailable.back")}
</Link>
</section>
</main>
);
} }
const timeZone = await tenantTimezone(ctx); const [timeZone, quotaExhausted] = await Promise.all([tenantTimezone(ctx), isChatQuotaBlocked(ctx)]); // L17: hard limit → hint
return ( return (
<main className="space-y-3 p-4"> <main className="space-y-3 p-4">
{view.workOrder && ( {view.workOrder && (
@@ -64,6 +52,7 @@ export default async function LotseChatPage({ searchParams }: { searchParams: Pr
transcription={transcriptionConfig().configured} transcription={transcriptionConfig().configured}
locale={locale} locale={locale}
timeZone={timeZone} timeZone={timeZone}
quotaExhausted={quotaExhausted}
/> />
</main> </main>
); );
+12 -5
View File
@@ -6,6 +6,7 @@ import { fieldPageContext } from "@/server/services/field/page-context";
import { getMyActiveSession } from "@/server/services/field/sessions"; import { getMyActiveSession } from "@/server/services/field/sessions";
import { countPendingTimeEntries } from "@/server/services/field/time-entries"; import { countPendingTimeEntries } from "@/server/services/field/time-entries";
import { canUseLotseChat } from "@/server/services/lotse/chat/access"; import { canUseLotseChat } from "@/server/services/lotse/chat/access";
import { isModuleActive } from "@/server/plan";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CraftviaLogo } from "@/components/brand/craftvia-logo"; import { CraftviaLogo } from "@/components/brand/craftvia-logo";
import { AccountInactiveNotice } from "@/components/account-inactive-notice"; import { AccountInactiveNotice } from "@/components/account-inactive-notice";
@@ -16,20 +17,26 @@ import { OfflineRuntime } from "@/components/offline/offline-runtime";
import { TrialBanner } from "@/components/trial/trial-banner"; import { TrialBanner } from "@/components/trial/trial-banner";
/** L12: own running/paused session + open approvals for the shell (never blocks the page). */ /** L12: own running/paused session + open approvals for the shell (never blocks the page). */
async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number; lotseChat: boolean }> { async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number; lotseChat: boolean; emergency: boolean }> {
try { try {
const ctx = await fieldPageContext(); const ctx = await fieldPageContext();
const [session, approvals, lotseChat] = await Promise.all([can(ctx, "field:execute") ? getMyActiveSession(ctx) : Promise.resolve(null), countPendingTimeEntries(ctx), canUseLotseChat(ctx)]); const [session, approvals, lotseChat, emergency] = await Promise.all([
can(ctx, "field:execute") ? getMyActiveSession(ctx) : Promise.resolve(null),
countPendingTimeEntries(ctx),
canUseLotseChat(ctx),
isModuleActive(ctx.tenantId, "emergency"), // L17 Pakete: Notdienst nur in Profi
]);
return { return {
clock: session clock: session
? { status: session.status, workOrderId: session.workOrderId, number: session.number, title: session.title, segmentType: session.segmentType, segmentStartedAt: session.segmentStartedAt, closedSeconds: session.closedSeconds } ? { status: session.status, workOrderId: session.workOrderId, number: session.number, title: session.title, segmentType: session.segmentType, segmentStartedAt: session.segmentStartedAt, closedSeconds: session.closedSeconds }
: null, : null,
approvals, approvals,
lotseChat, // L16 lotseChat, // L16 (L17: inkl. Chat-Platz)
emergency,
}; };
} catch { } catch {
// module "field" disabled or no field permissions: shell without clock/badge // module "field" disabled or no field permissions: shell without clock/badge
return { clock: null, approvals: 0, lotseChat: false }; return { clock: null, approvals: 0, lotseChat: false, emergency: true };
} }
} }
@@ -60,7 +67,7 @@ export default async function FieldShell({ children }: Readonly<{ children: Reac
<TrialBanner tenantId={access.session.user.tenantId} variant="mobile" /> <TrialBanner tenantId={access.session.user.tenantId} variant="mobile" />
<div className={cn("mx-auto w-full max-w-xl flex-1", time.clock ? "pb-48" : "pb-28")}>{children}</div> <div className={cn("mx-auto w-full max-w-xl flex-1", time.clock ? "pb-48" : "pb-28")}>{children}</div>
<RunningClockBar initial={time.clock} /> <RunningClockBar initial={time.clock} />
<BottomNav approvals={time.approvals} lotse={time.lotseChat} /> <BottomNav approvals={time.approvals} lotse={time.lotseChat} emergency={time.emergency} />
</div> </div>
); );
} }
+12 -1
View File
@@ -7,6 +7,7 @@ import { platformAuth } from "@/server/platform-auth";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PageHead, Pill } from "@/components/mockup-ui"; import { PageHead, Pill } from "@/components/mockup-ui";
import { MODULES } from "@/lib/modules"; import { MODULES } from "@/lib/modules";
import { effectiveTier, isModuleInTier } from "@/lib/plans";
import { setTenantStatus, toggleTenantModule, setTenantMfaRequired, setTenantLocale } from "@/server/actions/admin"; import { setTenantStatus, toggleTenantModule, setTenantMfaRequired, setTenantLocale } from "@/server/actions/admin";
import { resolveMfaRequired } from "@/lib/mfa-policy"; import { resolveMfaRequired } from "@/lib/mfa-policy";
import { createTenantUser, updateTenantUser, setTenantUserRoles, setTenantUserStatus } from "@/server/actions/platform-users"; import { createTenantUser, updateTenantUser, setTenantUserRoles, setTenantUserStatus } from "@/server/actions/platform-users";
@@ -20,6 +21,7 @@ import { UserCreateForm, UserEditForm } from "@/components/user-forms";
import { AuditTrailModal, type AuditRow } from "@/components/audit-trail"; import { AuditTrailModal, type AuditRow } from "@/components/audit-trail";
import { RestoreModalBody, ExportModalBody, DsgvoModalBody, type SnapshotOption, type SubjectOption } from "@/components/backup-admin-panel"; import { RestoreModalBody, ExportModalBody, DsgvoModalBody, type SnapshotOption, type SubjectOption } from "@/components/backup-admin-panel";
import { TrialAdminCard } from "@/components/trial/trial-admin-card"; import { TrialAdminCard } from "@/components/trial/trial-admin-card";
import { PlanAdminCard } from "@/components/plans/plan-admin-card";
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" }; const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
@@ -28,7 +30,7 @@ export default async function AdminTenantPage({
searchParams, searchParams,
}: { }: {
params: Promise<{ id: string }>; params: Promise<{ id: string }>;
searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string; trial?: string; invited?: string; trialDone?: string }>; searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string; trial?: string; invited?: string; trialDone?: string; plan?: string; planDone?: string }>;
}) { }) {
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen. // Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
const { id } = await params; const { id } = await params;
@@ -58,6 +60,9 @@ export default async function AdminTenantPage({
const editUser = sp.edit ? tenant.users.find((u) => u.id === sp.edit) : null; const editUser = sp.edit ? tenant.users.find((u) => u.id === sp.edit) : null;
const base = `/admin/${tenant.id}`; const base = `/admin/${tenant.id}`;
// L17 Pakete: Module außerhalb der Paketstufe wirken trotz Schalter wie deaktiviert
const tierNow = effectiveTier(tenant.tier, tenant.plan);
const tp = await getTranslations("plans");
const moduleState = new Map(tenant.modules.map((m) => [m.moduleKey, m.enabled])); const moduleState = new Map(tenant.modules.map((m) => [m.moduleKey, m.enabled]));
const isOn = (key: string) => moduleState.get(key) ?? true; const isOn = (key: string) => moduleState.get(key) ?? true;
@@ -226,6 +231,11 @@ export default async function AdminTenantPage({
notice={sp.trial === "created" ? (sp.invited ? "invited" : "created") : sp.trialDone ? "done" : null} notice={sp.trial === "created" ? (sp.invited ? "invited" : "created") : sp.trialDone ? "done" : null}
/> />
{/* L17 Pakete: Stufe, Lotse-Chat-Plätze, hartes Limit, Verbrauch (Grundlage der Rechnung) */}
{platformSession?.user?.id && (
<PlanAdminCard tenantId={tenant.id} platformAdminId={platformSession.user.id} isFullAdmin={isFullAdmin} base={base} edit={sp.plan === "edit"} done={!!sp.planDone} />
)}
{/* Lebenszyklus */} {/* Lebenszyklus */}
<div className="shadow-card rounded-xl border bg-card p-5"> <div className="shadow-card rounded-xl border bg-card p-5">
<p className="mb-3 font-heading text-sm font-semibold">{t("lifecycleTitle")}</p> <p className="mb-3 font-heading text-sm font-semibold">{t("lifecycleTitle")}</p>
@@ -342,6 +352,7 @@ export default async function AdminTenantPage({
<p className="text-[11px] text-muted-foreground">{m.href ?? m.key}</p> <p className="text-[11px] text-muted-foreground">{m.href ?? m.key}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!isModuleInTier(tierNow, m.key) && <Pill tone="warn">{tp("notInTier")}</Pill>}
<Pill tone={on ? "ok" : "mut"}>{on ? t("moduleActive") : t("moduleInactive")}</Pill> <Pill tone={on ? "ok" : "mut"}>{on ? t("moduleActive") : t("moduleInactive")}</Pill>
<form action={toggleTenantModule.bind(null, tenant.id, m.key, !on)}> <form action={toggleTenantModule.bind(null, tenant.id, m.key, !on)}>
<Button type="submit" size="sm" variant="outline">{on ? t("moduleDeactivate") : t("moduleActivate")}</Button> <Button type="submit" size="sm" variant="outline">{on ? t("moduleDeactivate") : t("moduleActivate")}</Button>
+4
View File
@@ -27,6 +27,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
const params = await searchParams; const params = await searchParams;
// L15 Testphase: Filter Test/Voll // L15 Testphase: Filter Test/Voll
const tt = await getTranslations("trial.platform"); const tt = await getTranslations("trial.platform");
const tp = await getTranslations("plans");
const planFilter = params.plan === "trial" ? "TRIAL" : params.plan === "full" ? "FULL" : null; const planFilter = params.plan === "trial" ? "TRIAL" : params.plan === "full" ? "FULL" : null;
const [tenants, mailStatus] = await Promise.all([ const [tenants, mailStatus] = await Promise.all([
@@ -124,6 +125,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
<TableHead>Kürzel</TableHead> <TableHead>Kürzel</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>{tt("colPlan")}</TableHead> <TableHead>{tt("colPlan")}</TableHead>
<TableHead>{tp("colTier")}</TableHead>
<TableHead>Nutzer</TableHead> <TableHead>Nutzer</TableHead>
<TableHead>Aktive Module</TableHead> <TableHead>Aktive Module</TableHead>
</TableRow> </TableRow>
@@ -140,6 +142,8 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
<TableCell className="text-muted-foreground">{t.slug}</TableCell> <TableCell className="text-muted-foreground">{t.slug}</TableCell>
<TableCell><Pill tone={STATUS_TONE[t.status]}>{STATUS_LABEL[t.status]}</Pill></TableCell> <TableCell><Pill tone={STATUS_TONE[t.status]}>{STATUS_LABEL[t.status]}</Pill></TableCell>
<TableCell><TrialBadge tenant={t} /></TableCell> <TableCell><TrialBadge tenant={t} /></TableCell>
{/* L17 Pakete: Stufe als Badge (Testphase läuft immer als Profi) */}
<TableCell><Pill tone={t.tier === "PROFI" ? "ok" : "mut"}>{tp(`tier.${t.tier}`)}</Pill></TableCell>
<TableCell className="text-muted-foreground">{t._count.users}</TableCell> <TableCell className="text-muted-foreground">{t._count.users}</TableCell>
<TableCell className="text-muted-foreground">{active > 0 ? `${active} Module` : "—"}</TableCell> <TableCell className="text-muted-foreground">{active > 0 ? `${active} Module` : "—"}</TableCell>
</TableRow> </TableRow>
+1
View File
@@ -89,6 +89,7 @@ const ENTITY_LABEL: Record<string, string> = {
ai_generation: "Lotse (KI)", ai_generation: "Lotse (KI)",
lotse_settings: "Lotse-Einstellungen", lotse_settings: "Lotse-Einstellungen",
lotse_chat_message: "Lotse-Chat", lotse_action_proposal: "Lotse-Aktionskarte", lotse_chat_retention: "Lotse-Chat (Aufbewahrung)", // L16 lotse_chat_message: "Lotse-Chat", lotse_action_proposal: "Lotse-Aktionskarte", lotse_chat_retention: "Lotse-Chat (Aufbewahrung)", // L16
lotse_chat_seat: "Lotse-Chat-Platz", tenant_plan: "Paket (Basis/Profi)", // L17
}; };
const fmt = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" }); const fmt = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" });
+7 -3
View File
@@ -19,13 +19,17 @@ const ITEMS = [
* Bottom navigation of the mobile shell (Spec §22): Heute · Aufträge · Notdienst · Sync · Profil. * Bottom navigation of the mobile shell (Spec §22): Heute · Aufträge · Notdienst · Sync · Profil.
* L12: badge counter of open time approvals on „Profil" (number + accessible label). * L12: badge counter of open time approvals on „Profil" (number + accessible label).
*/ */
export function BottomNav({ approvals = 0, lotse = false }: { approvals?: number; lotse?: boolean }) { const GRID_COLS: Record<number, string> = { 4: "grid-cols-4", 5: "grid-cols-5", 6: "grid-cols-6" };
/** L17 Pakete: `emergency` = Notdienst im Paket enthalten und eingeschaltet; `lotse` = Chat nutzbar (inkl. Platz). */
export function BottomNav({ approvals = 0, lotse = false, emergency = true }: { approvals?: number; lotse?: boolean; emergency?: boolean }) {
const t = useTranslations("field.nav"); const t = useTranslations("field.nav");
const pathname = usePathname(); const pathname = usePathname();
const items = ITEMS.filter((item) => (lotse || item.key !== "lotse") && (emergency || item.key !== "emergency"));
return ( return (
<nav aria-label={t("label")} className="fixed inset-x-0 bottom-0 z-30 border-t bg-card pb-[env(safe-area-inset-bottom)]"> <nav aria-label={t("label")} className="fixed inset-x-0 bottom-0 z-30 border-t bg-card pb-[env(safe-area-inset-bottom)]">
<ul className={cn("mx-auto grid max-w-xl", lotse ? "grid-cols-6" : "grid-cols-5")}> <ul className={cn("mx-auto grid max-w-xl", GRID_COLS[items.length] ?? "grid-cols-5")}>
{ITEMS.filter((item) => lotse || item.key !== "lotse").map((item) => { {items.map((item) => {
const active = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`); const active = item.exact ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`);
const badge = item.key === "profile" && approvals > 0 ? approvals : 0; const badge = item.key === "profile" && approvals > 0 ? approvals : 0;
return ( return (
+13 -2
View File
@@ -41,6 +41,7 @@ export function LotseChat({
transcription, transcription,
locale, locale,
timeZone, timeZone,
quotaExhausted = false,
}: { }: {
initial: ChatView; initial: ChatView;
workOrderId: string | null; workOrderId: string | null;
@@ -48,6 +49,8 @@ export function LotseChat({
transcription: boolean; transcription: boolean;
locale: string; locale: string;
timeZone: string; timeZone: string;
/** L17 Pakete: monthly chat quota used up with hard limit → hint instead of the input */
quotaExhausted?: boolean;
}) { }) {
const t = useTranslations("lotse"); const t = useTranslations("lotse");
const router = useRouter(); const router = useRouter();
@@ -128,6 +131,7 @@ export function LotseChat({
} }
const lastAssistant = [...view.messages].reverse().find((m) => m.role !== "user"); const lastAssistant = [...view.messages].reverse().find((m) => m.role !== "user");
const sendLocked = quotaExhausted || error === "quota_exhausted";
const errorText = error ? (t.has(`chat.errors.${error}`) ? t(`chat.errors.${error}`) : t("chat.errors.generic")) : null; const errorText = error ? (t.has(`chat.errors.${error}`) ? t(`chat.errors.${error}`) : t("chat.errors.generic")) : null;
function notice(m: ChatMessageView): string | null { function notice(m: ChatMessageView): string | null {
@@ -235,7 +239,7 @@ export function LotseChat({
{m.id === lastAssistant?.id && m.content.chips && m.content.chips.length > 0 && ( {m.id === lastAssistant?.id && m.content.chips && m.content.chips.length > 0 && (
<div className="flex flex-wrap gap-2" role="group" aria-label={t("chat.chipsLabel")}> <div className="flex flex-wrap gap-2" role="group" aria-label={t("chat.chipsLabel")}>
{m.content.chips.map((c) => ( {m.content.chips.map((c) => (
<button key={c.value} type="button" className={chip(false)} disabled={pending !== null || !configured} onClick={() => send(c.value)}> <button key={c.value} type="button" className={chip(false)} disabled={pending !== null || !configured || sendLocked} onClick={() => send(c.value)}>
{c.label} {c.label}
</button> </button>
))} ))}
@@ -270,13 +274,19 @@ export function LotseChat({
{t("chat.offline")} {t("chat.offline")}
</p> </p>
)} )}
{errorText && ( {errorText && error !== "quota_exhausted" && (
<p className={noticeError} role="alert"> <p className={noticeError} role="alert">
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden /> <TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{errorText} {errorText}
</p> </p>
)} )}
{sendLocked ? (
<p className={noticeWarn} role="status">
<Info className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{t("chat.quotaExhausted")}
</p>
) : (
<form <form
className="space-y-2 rounded-xl border bg-card p-3 shadow-card" className="space-y-2 rounded-xl border bg-card p-3 shadow-card"
onSubmit={(e) => { onSubmit={(e) => {
@@ -315,6 +325,7 @@ export function LotseChat({
</div> </div>
{transcription && <p className="text-[12.5px] text-muted-foreground">{t("chat.mic.hint")}</p>} {transcription && <p className="text-[12.5px] text-muted-foreground">{t("chat.mic.hint")}</p>}
</form> </form>
)}
</div> </div>
); );
} }
+180
View File
@@ -0,0 +1,180 @@
import { getLocale, getTranslations } from "next-intl/server";
import { CheckCircle2, CircleDashed, Gauge, Info, Package, TriangleAlert, XCircle } from "lucide-react";
import { Pill } from "@/components/mockup-ui";
import { LOTSE_CHAT_CHATS_PER_SEAT, LOTSE_CHAT_OVERAGE_PACK } from "@/lib/plans";
import { cn } from "@/lib/utils";
import { setChatSeatAction } from "@/server/actions/lotse/seats";
import { getTenantPlan } from "@/server/plan";
import type { ServiceCtx } from "@/server/services/context";
import { getSeatOverview } from "@/server/services/lotse/chat/seats";
import { getChatUsageForAdmin } from "@/server/services/lotse/chat/usage";
const LEVEL_TONE = { ok: "var(--ok)", warn: "var(--warn)", exhausted: "var(--warn)", over: "var(--risk)" } as const;
/**
* L17 Pakete — /settings/lotse (tenant:manage): package (read-only), Lotse chat seats „x von y vergeben"
* with a switch per user with a field role, monthly usage bar with text, 80 % / overage hints and the
* per-user breakdown. Every change runs through setChatSeatAction (server-side limits + audit).
*/
export async function LotseSeatAdmin({ ctx, seatError, seatSaved }: { ctx: ServiceCtx; seatError?: string; seatSaved?: boolean }) {
const [t, locale, plan] = await Promise.all([getTranslations("plans"), getLocale(), getTenantPlan(ctx.tenantId)]);
const nf = new Intl.NumberFormat(locale);
const profi = plan.tier === "PROFI";
const [seats, usage] = profi ? await Promise.all([getSeatOverview(ctx), getChatUsageForAdmin(ctx)]) : [null, null];
const free = seats ? Math.max(0, seats.purchased - seats.assigned) : 0;
const monthLabel = usage ? new Date(`${usage.monthKey}-15T12:00:00Z`).toLocaleDateString(locale, { month: "long", year: "numeric", timeZone: "UTC" }) : "";
return (
<section id="seats" aria-labelledby="seats-title" className="shadow-card mt-5 max-w-5xl space-y-5 rounded-xl border bg-card p-5 text-sm">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 id="seats-title" className="flex items-center gap-2 font-heading text-sm font-semibold">
<Package className="size-4" aria-hidden /> {t("admin.title")}
</h2>
<p className="text-[12px] text-muted-foreground">{t("admin.sub")}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-[12.5px] text-muted-foreground">{t("admin.package")}</span>
<Pill tone={profi ? "ok" : "mut"}>{t(`tier.${plan.tier}`)}</Pill>
{plan.isTrial && <Pill tone="warn">{t("admin.trial")}</Pill>}
</div>
</div>
<p className="text-[12px] text-muted-foreground">{t("admin.packageReadOnly")}</p>
{seatSaved && (
<p role="status" className="flex items-center gap-2 rounded-lg border border-[var(--ok)] px-3 py-2 text-[var(--ok)]">
<CheckCircle2 className="size-4" aria-hidden /> {t("admin.saved")}
</p>
)}
{seatError && (
<p role="alert" className="flex items-center gap-2 rounded-lg border border-[var(--risk)] px-3 py-2 text-[var(--risk)]">
<XCircle className="size-4" aria-hidden /> {t.has(`admin.errors.${seatError}`) ? t(`admin.errors.${seatError}`) : t("admin.errors.failed")}
</p>
)}
{!profi || !seats || !usage ? (
<p className="flex items-start gap-2 rounded-lg bg-muted p-3">
<Info className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.basisHint")}
</p>
) : (
<>
{/* Plätze */}
<div className="space-y-2">
<h3 className="font-semibold">{t("admin.seatsTitle")}</h3>
{plan.isTrial ? (
<p className="flex items-start gap-2 text-muted-foreground">
<Info className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.trialSeats", { perSeat: LOTSE_CHAT_CHATS_PER_SEAT })}
</p>
) : (
<p className="text-[15px] font-semibold">{t("admin.seatsCount", { assigned: seats.assigned, purchased: seats.purchased })}</p>
)}
{seats.overbooked && (
<p className="flex items-start gap-2 text-[var(--warn)]">
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.overbooked", { purchased: seats.purchased })}
</p>
)}
{!plan.isTrial && seats.purchased === 0 && <p className="text-muted-foreground">{t("admin.noSeatsBought")}</p>}
{seats.users.length === 0 ? (
<p className="text-muted-foreground">{t("admin.noFieldUsers")}</p>
) : (
<ul className="divide-y rounded-lg border">
{seats.users.map((u) => {
const canAssign = !u.assigned && free > 0;
return (
<li key={u.id} className="flex flex-wrap items-center justify-between gap-3 px-3 py-2">
<div className="min-w-0">
<p className="font-medium">{u.name}</p>
<p className="truncate text-[12px] text-muted-foreground">{u.email}</p>
</div>
<div className="flex items-center gap-3">
<span className={cn("inline-flex items-center gap-1 text-[12.5px] font-semibold", !u.assigned && "text-muted-foreground")} style={u.assigned ? { color: u.valid ? "var(--ok)" : "var(--warn)" } : undefined}>
{u.assigned ? (u.valid ? <CheckCircle2 className="size-4" aria-hidden /> : <TriangleAlert className="size-4" aria-hidden />) : <CircleDashed className="size-4" aria-hidden />}
{u.assigned ? (u.valid ? t("admin.seatAssigned") : t("admin.seatOverbooked")) : t("admin.seatNone")}
</span>
<form action={setChatSeatAction}>
<input type="hidden" name="userId" value={u.id} />
<input type="hidden" name="assigned" value={u.assigned ? "0" : "1"} />
<button
type="submit"
role="switch"
aria-checked={u.assigned}
aria-label={u.assigned ? t("admin.revokeLabel", { name: u.name }) : t("admin.assignLabel", { name: u.name })}
disabled={!u.assigned && !canAssign}
className={cn(
"inline-flex min-h-11 min-w-28 items-center justify-center rounded-lg border px-3 text-[13px] font-semibold disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
u.assigned ? "border-[var(--ui-primary)] text-[var(--ui-primary)]" : "hover:bg-muted",
)}
>
{u.assigned ? t("admin.revoke") : t("admin.assign")}
</button>
</form>
</div>
</li>
);
})}
</ul>
)}
</div>
{/* Monatsverbrauch */}
<div className="space-y-2">
<h3 className="flex items-center gap-2 font-semibold">
<Gauge className="size-4" aria-hidden /> {t("admin.usageTitle", { month: monthLabel })}
</h3>
<div
role="meter"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={usage.percent}
aria-label={t("admin.usageText", { used: nf.format(usage.used), quota: nf.format(usage.quota) })}
className="h-3 w-full overflow-hidden rounded-full bg-muted"
>
<div className="h-full rounded-full" style={{ width: `${usage.percent}%`, background: LEVEL_TONE[usage.level] }} />
</div>
<p className="text-[15px] font-semibold">{t("admin.usageText", { used: nf.format(usage.used), quota: nf.format(usage.quota) })}</p>
<p className="text-[12px] text-muted-foreground">{t("admin.quotaRule", { perSeat: LOTSE_CHAT_CHATS_PER_SEAT, basis: usage.quotaBasis })}</p>
{usage.level === "warn" && (
<p className="flex items-start gap-2 text-[var(--warn)]">
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.warn80", { percent: usage.percent })}
</p>
)}
{(usage.level === "exhausted" || usage.level === "over") && usage.hardLimit && (
<p className="flex items-start gap-2 text-[var(--risk)]">
<XCircle className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.hardLimitReached")}
</p>
)}
{usage.level === "exhausted" && !usage.hardLimit && (
<p className="flex items-start gap-2 text-[var(--warn)]">
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.exhausted", { pack: LOTSE_CHAT_OVERAGE_PACK })}
</p>
)}
{usage.overage > 0 && (
<p className="flex items-start gap-2 text-[var(--risk)]">
<TriangleAlert className="mt-0.5 size-4 shrink-0" aria-hidden /> {t("admin.overage", { overage: nf.format(usage.overage), packs: usage.overagePacks, pack: LOTSE_CHAT_OVERAGE_PACK })}
</p>
)}
{usage.perUser.length > 0 && (
<table className="mt-2 w-full max-w-md text-left">
<caption className="sr-only">{t("admin.perUserCaption")}</caption>
<thead>
<tr className="text-[12px] text-muted-foreground">
<th scope="col" className="py-1 font-medium">{t("admin.perUserName")}</th>
<th scope="col" className="py-1 text-right font-medium">{t("admin.perUserChats")}</th>
</tr>
</thead>
<tbody className="divide-y">
{usage.perUser.map((u) => (
<tr key={u.userId}>
<td className="py-1.5">{u.name}</td>
<td className="py-1.5 text-right tabular-nums">{nf.format(u.count)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
)}
</section>
);
}
+23
View File
@@ -0,0 +1,23 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { CircleSlash } from "lucide-react";
import { card } from "@/components/field/ui";
/** „Lotse-Chat nicht verfügbar" (L16; L17: kein Platz / nicht im Paket) — text + icon, back to /m. */
export async function LotseChatUnavailable({ reason }: { reason: string }) {
const t = await getTranslations("lotse");
return (
<main className="space-y-3 p-4">
<section className={card}>
<h1 className="flex items-center gap-2 text-[20px]">
<CircleSlash className="size-5 text-muted-foreground" aria-hidden />
{t("chat.unavailable.title")}
</h1>
<p className="mt-2 text-[15px]">{t.has(`chat.errors.${reason}`) ? t(`chat.errors.${reason}`) : t("chat.errors.generic")}</p>
<Link href="/m" className="mt-3 inline-flex min-h-12 items-center font-semibold text-primary">
{t("chat.unavailable.back")}
</Link>
</section>
</main>
);
}
+82
View File
@@ -0,0 +1,82 @@
import Link from "next/link";
import { getLocale, getTranslations } from "next-intl/server";
import { Button } from "@/components/ui/button";
import { Modal } from "@/components/modal";
import { Pill } from "@/components/mockup-ui";
import { LOTSE_CHAT_CHATS_PER_SEAT, LOTSE_CHAT_OVERAGE_PACK } from "@/lib/plans";
import { updateTenantPlanAction } from "@/server/actions/plans-platform";
import type { ChatUsage } from "@/server/services/lotse/chat/usage";
import { getTenantPlanOverview, MAX_LOTSE_CHAT_SEATS } from "@/server/services/plans/platform";
import { PlanForm } from "./plan-form";
/**
* L17 Pakete — card "Paket & Lotse-Chat" on the platform tenant detail page: tier, seats assigned /
* purchased, chats of the current and previous month, overage and the resulting packs of 100 (basis for
* the invoice). Full admins change tier/seats/hard limit in a confirmation popup (?plan=edit).
*/
export async function PlanAdminCard({ tenantId, platformAdminId, isFullAdmin, base, edit, done }: { tenantId: string; platformAdminId: string; isFullAdmin: boolean; base: string; edit: boolean; done: boolean }) {
const [t, locale, overview] = await Promise.all([getTranslations("plans.platform"), getLocale(), getTenantPlanOverview({ platformAdminId }, tenantId)]);
const tt = await getTranslations("plans");
const nf = new Intl.NumberFormat(locale);
const { plan, seatsAssigned, current, previous } = overview;
const monthName = (u: ChatUsage) => new Date(`${u.monthKey}-15T12:00:00Z`).toLocaleDateString(locale, { month: "long", year: "numeric", timeZone: "UTC" });
const usageRows = [current, previous].map((u) => (
<div key={u.monthKey} className="rounded-lg border bg-muted/40 px-3 py-2" data-plan-usage={u.monthKey}>
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{u === current ? t("currentMonth", { month: monthName(u) }) : t("previousMonth", { month: monthName(u) })}</p>
<p className="text-[13.5px] font-semibold">{t("chats", { used: nf.format(u.used), quota: nf.format(u.quota) })}</p>
<p className="text-[12.5px]">
{u.overage > 0 ? t("overage", { overage: nf.format(u.overage), packs: u.overagePacks, pack: LOTSE_CHAT_OVERAGE_PACK }) : t("noOverage")}
</p>
</div>
));
return (
<div className="shadow-card rounded-xl border bg-card p-5" data-plan-card>
<p className="mb-2 font-heading text-sm font-semibold">{t("title")}</p>
{done && <p role="status" className="mb-3 rounded-lg border border-[var(--ok)] px-3 py-2 text-[12.5px] text-[var(--ok)]">{t("saved")}</p>}
<dl className="space-y-1.5 text-[13px]">
<div className="flex justify-between gap-2">
<dt className="text-muted-foreground">{t("tier")}</dt>
<dd className="flex flex-wrap justify-end gap-1.5">
<Pill tone={plan.storedTier === "PROFI" ? "ok" : "mut"}>{tt(`tier.${plan.storedTier}`)}</Pill>
{plan.isTrial && <Pill tone="warn">{t("trialProfi")}</Pill>}
</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-muted-foreground">{t("seats")}</dt>
<dd>{t("seatsValue", { assigned: seatsAssigned, purchased: plan.lotseChatSeats })}</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-muted-foreground">{t("hardLimit")}</dt>
<dd>{plan.lotseChatHardLimit ? t("hardLimitOn") : t("hardLimitOff")}</dd>
</div>
</dl>
<p className="mt-2 text-[11.5px] text-muted-foreground">{t("rule", { perSeat: LOTSE_CHAT_CHATS_PER_SEAT, pack: LOTSE_CHAT_OVERAGE_PACK })}</p>
<div className="mt-3 space-y-2">{usageRows}</div>
{isFullAdmin && (
<Link href={`${base}?plan=edit`} scroll={false} className="mt-3 block">
<Button variant="outline" size="sm" className="min-h-11 w-full justify-center">
{t("edit")}
</Button>
</Link>
)}
{isFullAdmin && edit && (
<Modal title={t("editTitle")} closeHref={base} closeLabel={t("close")}>
<div className="space-y-3 p-5">
<p className="text-sm">{t("editText")}</p>
<PlanForm
action={updateTenantPlanAction.bind(null, tenantId)}
tier={plan.storedTier}
seats={plan.lotseChatSeats}
hardLimit={plan.lotseChatHardLimit}
maxSeats={MAX_LOTSE_CHAT_SEATS}
closeHref={base}
/>
</div>
</Modal>
)}
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { useActionState } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { TENANT_TIERS, type TenantTier } from "@/lib/plans";
import type { PlatformPlanState } from "@/server/actions/plans-platform";
/** L17 Pakete: operator form (tier, seats, hard limit) with mandatory confirmation checkbox. */
export function PlanForm({
action,
tier,
seats,
hardLimit,
maxSeats,
closeHref,
}: {
action: (prev: PlatformPlanState, fd: FormData) => Promise<PlatformPlanState>;
tier: TenantTier;
seats: number;
hardLimit: boolean;
maxSeats: number;
closeHref: string;
}) {
const t = useTranslations("plans.platform");
const tt = useTranslations("plans");
const [state, formAction, pending] = useActionState<PlatformPlanState, FormData>(action, { status: "idle" });
return (
<form action={formAction} className="space-y-4">
<fieldset>
<legend className="text-sm font-semibold">{t("tier")}</legend>
<div className="mt-2 grid gap-2 sm:grid-cols-2">
{TENANT_TIERS.map((v) => (
<label key={v} className="flex min-h-11 cursor-pointer items-start gap-2 rounded-lg border p-3 text-sm has-[:checked]:border-[var(--ui-primary)] has-[:checked]:font-semibold">
<input type="radio" name="tier" value={v} defaultChecked={tier === v} className="mt-0.5 size-4" />
<span>
<span className="block">{tt(`tier.${v}`)}</span>
<span className="block text-[12px] font-normal text-muted-foreground">{t(`tierHint.${v}`)}</span>
</span>
</label>
))}
</div>
<p className="mt-2 text-[12px] text-muted-foreground">{t("downgradeHint")}</p>
</fieldset>
<div className="max-w-xs">
<Label htmlFor="plan-seats">{t("seatsInput")}</Label>
<Input id="plan-seats" name="lotseChatSeats" type="number" inputMode="numeric" min={0} max={maxSeats} step={1} required defaultValue={seats} className="mt-1 h-11" />
<p className="mt-1 text-[12px] text-muted-foreground">{t("seatsHint")}</p>
</div>
<label className="flex min-h-11 items-start gap-2 text-sm">
<input type="checkbox" name="lotseChatHardLimit" defaultChecked={hardLimit} className="mt-0.5 size-5" />
<span>
<span className="block font-semibold">{t("hardLimitInput")}</span>
<span className="block text-[12px] text-muted-foreground">{t("hardLimitHint")}</span>
</span>
</label>
<label className="flex min-h-11 items-center gap-2 text-sm">
<input type="checkbox" name="confirm" className="size-5" required /> {t("confirm")}
</label>
{state.status === "error" && (
<p role="alert" className="rounded-lg border border-[var(--risk)] px-3 py-2 text-sm text-[var(--risk)]">
{t.has(`errors.${state.code}`) ? t(`errors.${state.code}`) : t("errors.failed")}
</p>
)}
<div className="flex flex-wrap gap-2">
<Button type="submit" className="h-11" disabled={pending}>
{pending ? t("submitting") : t("submit")}
</Button>
<Button variant="outline" className="h-11" nativeButton={false} render={<Link href={closeHref} />}>
{t("close")}
</Button>
</div>
</form>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { getTranslations } from "next-intl/server";
import { Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* L17 Pakete: quiet hint "Im Paket Profi enthalten" instead of an error page — shown when a page of a
* module/feature outside the tenant's package tier was opened (redirect `/dashboard?module=profi`).
* Text + icon, colours only via tokens.
*/
export async function ProfiHint({ className, compact = false }: { className?: string; compact?: boolean }) {
const t = await getTranslations("plans");
return (
<div role="status" className={cn("mb-4 flex items-start gap-3 rounded-lg border bg-card px-4 py-3 text-sm", className)}>
<Sparkles className="mt-0.5 size-4.5 shrink-0 text-[var(--ui-primary)]" aria-hidden />
<div>
<p className="font-semibold">{t("profiHint.title")}</p>
{!compact && <p className="text-muted-foreground">{t("profiHint.text")}</p>}
</div>
</div>
);
}
+4
View File
@@ -175,6 +175,10 @@ export const LOTSE_CHAT_ERROR_CODES = [
"tampered", "tampered",
"blocked", "blocked",
"offline", "offline",
// L17 Pakete
"not_in_plan",
"no_seat",
"quota_exhausted",
] as const; ] as const;
export type LotseChatErrorCode = (typeof LOTSE_CHAT_ERROR_CODES)[number]; export type LotseChatErrorCode = (typeof LOTSE_CHAT_ERROR_CODES)[number];
+13 -5
View File
@@ -23,6 +23,7 @@ import {
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import type { ModuleKey } from "@/lib/modules"; import type { ModuleKey } from "@/lib/modules";
import type { PlanFeature } from "@/lib/plans";
import type { Permission } from "@/server/rbac"; import type { Permission } from "@/server/rbac";
/** /**
@@ -47,14 +48,16 @@ export interface NavItem {
sub?: boolean; sub?: boolean;
/** L13: active only on exactly this path (not on sub paths) */ /** L13: active only on exactly this path (not on sub paths) */
exact?: boolean; exact?: boolean;
/** L17 Pakete: feature without own module that the package tier must include (planning) */
feature?: PlanFeature;
} }
export const NAV_ITEMS: readonly NavItem[] = [ export const NAV_ITEMS: readonly NavItem[] = [
{ href: "/dashboard", label: "dashboard", icon: LayoutDashboard, section: "main" }, { href: "/dashboard", label: "dashboard", icon: LayoutDashboard, section: "main" },
// L13 Planung: top-level entry with sub entries; backoffice (read_all) + team leads (report:approve_team, own crews read-only) // L13 Planung: top-level entry with sub entries; backoffice (read_all) + team leads (report:approve_team, own crews read-only)
{ href: "/planning", label: "planning", icon: CalendarRange, module: "work_orders", permissions: ["work_order:read_all", "report:approve_team"], section: "main" }, { href: "/planning", label: "planning", icon: CalendarRange, module: "work_orders", feature: "planning", permissions: ["work_order:read_all", "report:approve_team"], section: "main" },
{ href: "/planning", label: "planningBoard", icon: LayoutGrid, module: "work_orders", permissions: ["work_order:read_all", "report:approve_team"], section: "main", sub: true, exact: true }, { href: "/planning", label: "planningBoard", icon: LayoutGrid, module: "work_orders", feature: "planning", permissions: ["work_order:read_all", "report:approve_team"], section: "main", sub: true, exact: true },
{ href: "/planning/live", label: "planningLive", icon: MapPinned, module: "work_orders", permissions: ["work_order:read_all", "report:approve_team"], section: "main", sub: true }, { href: "/planning/live", label: "planningLive", icon: MapPinned, module: "work_orders", feature: "planning", permissions: ["work_order:read_all", "report:approve_team"], section: "main", sub: true },
{ {
href: "/work-orders", href: "/work-orders",
label: "workOrders", label: "workOrders",
@@ -81,14 +84,19 @@ export const NAV_ITEMS: readonly NavItem[] = [
{ href: "/settings/export", label: "dataExport", icon: Download, permissions: ["tenant:manage"], section: "admin" }, // L15 { href: "/settings/export", label: "dataExport", icon: Download, permissions: ["tenant:manage"], section: "admin" }, // L15
]; ];
/** Filtert die Navigation nach aktiven Modulen und Rechten der Session. */ /**
* Filtert die Navigation nach aktiven Modulen und Rechten der Session. `disabledModules` = effektiv
* inaktive Module (L17: abgeschaltet ODER nicht in der Paketstufe), `lockedFeatures` = nicht enthaltene
* Paket-Features (Planung).
*/
export function visibleNavItems( export function visibleNavItems(
items: readonly NavItem[], items: readonly NavItem[],
opts: { disabledModules: ReadonlySet<string>; permissions: readonly string[] }, opts: { disabledModules: ReadonlySet<string>; permissions: readonly string[]; lockedFeatures?: ReadonlySet<string> },
): NavItem[] { ): NavItem[] {
return items.filter( return items.filter(
(item) => (item) =>
(!item.module || !opts.disabledModules.has(item.module)) && (!item.module || !opts.disabledModules.has(item.module)) &&
(!item.feature || !opts.lockedFeatures?.has(item.feature)) &&
(!item.permissions?.length || item.permissions.some((p) => opts.permissions.includes(p))), (!item.permissions?.length || item.permissions.some((p) => opts.permissions.includes(p))),
); );
} }
+3
View File
@@ -333,6 +333,9 @@ export function problemKey(e: Pick<OutboxEntry, "status" | "opType" | "lastError
case "not_found": case "not_found":
return "notFound"; return "notFound";
case "forbidden": case "forbidden":
// L17 Pakete: op of a module that is not part of the package / switched off (services/sync/apply.ts)
if (e.lastError?.message?.startsWith("not_in_plan")) return "notInPlan";
if (e.lastError?.message?.startsWith("module_disabled")) return "moduleDisabled";
return "forbidden"; return "forbidden";
case "blocked": case "blocked":
return "blocked"; return "blocked";
+122
View File
@@ -0,0 +1,122 @@
import type { ModuleKey } from "@/lib/modules";
/**
* L17 Pakete — central, client-safe rules of the two packages (pure data + functions, no I/O).
*
* - `BASIS`: everything without AI and without planning: work orders, customers, sites, teams, mobile
* app/offline, reports + signature + PDF mail, time tracking incl. approval, documents, notifications.
* - `PROFI`: additionally planning (board, live situation, capacity, recommendations, delay hints),
* billing overview (`billing`), emergency service (`emergency`), order import (`imports`) and the
* Lotse (`lotse`: report draft, completeness check, voice note summary, Lotse chat).
*
* The Lotse chat is additionally sold per user (seats): 150 chats per assigned seat and calendar month
* (tenant timezone), shared by the whole tenant. Beyond the quota usage keeps counting (overage, billed
* in packs of 100) unless the operator set a hard limit. A trial tenant is always PROFI without seat
* check; its quota is `eligible users × 150`.
*
* Server enforcement lives in src/server/plan.ts (reads Tenant.tier/plan) and is applied in
* requireModule / assertModuleEnabled / requireApiContext / sync / navigation / isLotseEnabled.
*/
export const TENANT_TIERS = ["BASIS", "PROFI"] as const;
export type TenantTier = (typeof TENANT_TIERS)[number];
/** Default tier of new and existing tenants (migration default). */
export const DEFAULT_TENANT_TIER: TenantTier = "PROFI";
/** Modules that exist only in PROFI. Every other module is part of both tiers. */
export const PROFI_ONLY_MODULES = ["billing", "emergency", "imports", "lotse"] as const satisfies readonly ModuleKey[];
/**
* Features that are no module of their own: `planning` lives inside the `work_orders` module (L13),
* `lotse_chat` inside `lotse` (L16, additionally needs a seat).
*/
export const PLAN_FEATURES = ["planning", "lotse_chat"] as const;
export type PlanFeature = (typeof PLAN_FEATURES)[number];
const PROFI_ONLY_FEATURES: readonly PlanFeature[] = ["planning", "lotse_chat"];
export const LOTSE_CHAT_CHATS_PER_SEAT = 150;
export const LOTSE_CHAT_OVERAGE_PACK = 100;
/** From this share of the quota the tenant admin sees a warning. */
export const LOTSE_CHAT_WARN_RATIO = 0.8;
export function isTenantTier(value: unknown): value is TenantTier {
return typeof value === "string" && (TENANT_TIERS as readonly string[]).includes(value);
}
/** Effective tier: a trial always runs as PROFI (full functionality during the test). */
export function effectiveTier(tier: TenantTier | string | null | undefined, plan?: string | null): TenantTier {
if (plan === "TRIAL") return "PROFI";
return isTenantTier(tier) ? tier : DEFAULT_TENANT_TIER;
}
export function isModuleInTier(tier: TenantTier, moduleKey: string): boolean {
if (tier === "PROFI") return true;
return !(PROFI_ONLY_MODULES as readonly string[]).includes(moduleKey);
}
export function isFeatureInTier(tier: TenantTier, feature: PlanFeature): boolean {
if (tier === "PROFI") return true;
return !PROFI_ONLY_FEATURES.includes(feature);
}
/** Modules that the tier does not include (used for navigation/UI). */
export function modulesLockedByTier(tier: TenantTier): string[] {
return tier === "PROFI" ? [] : [...PROFI_ONLY_MODULES];
}
/** Monthly quota of the tenant: assigned (and paid) seats × 150. */
export function chatQuota(seats: number): number {
return Math.max(0, Math.floor(seats)) * LOTSE_CHAT_CHATS_PER_SEAT;
}
/** Chats above the quota. */
export function chatOverage(used: number, quota: number): number {
return Math.max(0, used - quota);
}
/** Overage packs of 100 (started packs count). */
export function overagePacks(overage: number): number {
return overage <= 0 ? 0 : Math.ceil(overage / LOTSE_CHAT_OVERAGE_PACK);
}
export type UsageLevel = "ok" | "warn" | "exhausted" | "over";
/**
* Level of the monthly usage: `warn` from 80 %, `exhausted` = quota reached (not above),
* `over` = overage. Without quota: any use counts as overage.
*/
export function usageLevel(used: number, quota: number): UsageLevel {
if (quota <= 0) return used > 0 ? "over" : "ok";
if (used > quota) return "over";
if (used === quota) return "exhausted";
return used >= quota * LOTSE_CHAT_WARN_RATIO ? "warn" : "ok";
}
/** Share of the quota in percent (0–100 for the bar; overage caps at 100). */
export function usagePercent(used: number, quota: number): number {
if (quota <= 0) return used > 0 ? 100 : 0;
return Math.min(100, Math.round((used / quota) * 100));
}
/** Hard limit reached → no further chat messages. */
export function isQuotaBlocked(used: number, quota: number, hardLimit: boolean): boolean {
return hardLimit && used >= quota;
}
/** Month key `YYYY-MM` of a day key `YYYY-MM-DD`. */
export function monthKeyOfDay(dayKey: string): string {
return dayKey.slice(0, 7);
}
/** Previous month key of `YYYY-MM`. */
export function previousMonthKey(monthKey: string): string {
const [y, m] = monthKey.split("-").map(Number);
return m === 1 ? `${y - 1}-12` : `${y}-${String(m - 1).padStart(2, "0")}`;
}
/** Next month key of `YYYY-MM`. */
export function nextMonthKey(monthKey: string): string {
const [y, m] = monthKey.split("-").map(Number);
return m === 12 ? `${y + 1}-01` : `${y}-${String(m + 1).padStart(2, "0")}`;
}
+1 -1
View File
@@ -13,7 +13,7 @@ export function chatErrorState(err: unknown, view?: ChatView): LotseChatActionSt
const code = reason && (LOTSE_CHAT_ERROR_CODES as readonly string[]).includes(reason) ? (reason as LotseChatErrorCode) : err.code; const code = reason && (LOTSE_CHAT_ERROR_CODES as readonly string[]).includes(reason) ? (reason as LotseChatErrorCode) : err.code;
return { status: "error", code, at, ...withView }; return { status: "error", code, at, ...withView };
} }
if (err instanceof ModuleDisabledError) return { status: "error", code: "disabled", at, ...withView }; if (err instanceof ModuleDisabledError) return { status: "error", code: err.reason === "not_in_plan" ? "not_in_plan" : "disabled", at, ...withView };
if (err instanceof ZodError) return { status: "error", code: "invalid", at, ...withView }; if (err instanceof ZodError) return { status: "error", code: "invalid", at, ...withView };
if (err instanceof ForbiddenError) return { status: "error", code: "forbidden", at, ...withView }; if (err instanceof ForbiddenError) return { status: "error", code: "forbidden", at, ...withView };
console.error("[actions/lotse/chat]", err); console.error("[actions/lotse/chat]", err);
+40
View File
@@ -0,0 +1,40 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { moduleGuard } from "@/server/action-guard";
import { ModuleDisabledError } from "@/server/modules";
import { ForbiddenError } from "@/server/rbac";
import { ctxFromGuard, ServiceError } from "@/server/services/context";
import { setChatSeat } from "@/server/services/lotse/chat/seats";
/**
* L17 Pakete — /settings/lotse: assign / revoke a Lotse chat seat (tenant admin, `tenant:manage`).
* moduleGuard("lotse"): only while the Lotse is part of the package and switched on; the service checks
* eligibility, the purchased number (serialised) and writes the audit.
*/
const guard = moduleGuard("lotse");
function errorCode(err: unknown): string {
if (err instanceof ServiceError) return (err.details as { reason?: string } | undefined)?.reason ?? err.code;
if (err instanceof ModuleDisabledError) return err.reason;
if (err instanceof ForbiddenError) return "forbidden";
return "failed";
}
/** Form fields: userId, assigned ("1" = assign, otherwise revoke). */
export async function setChatSeatAction(fd: FormData): Promise<void> {
let target = "/settings/lotse?seat=saved#seats";
try {
const ctx = ctxFromGuard(await guard("tenant:manage"));
await setChatSeat(ctx, { userId: String(fd.get("userId") ?? ""), assigned: fd.get("assigned") === "1" });
revalidatePath("/settings/lotse");
revalidatePath("/m", "layout");
} catch (err) {
const code = errorCode(err);
if (code === "failed") console.error("[actions/lotse/seats]", err);
target = `/settings/lotse?seatError=${encodeURIComponent(code)}#seats`;
}
redirect(target);
}
+38
View File
@@ -0,0 +1,38 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { requirePlatformFullAdmin } from "@/server/platform-auth";
import { ServiceError } from "@/server/services/context";
import { updateTenantPlan } from "@/server/services/plans/platform";
/**
* L17 Pakete — operator action on the platform tenant detail page: tier, Lotse chat seats, hard limit.
* EXEMPT from module gating; authorisation via the separate platform session (full admins only). The
* service re-checks the actor against the PlatformAdmin store and writes the platform audit.
*/
export type PlatformPlanState = { status: "idle" } | { status: "error"; code: string };
export async function updateTenantPlanAction(tenantId: string, _prev: PlatformPlanState, fd: FormData): Promise<PlatformPlanState> {
const { admin } = await requirePlatformFullAdmin();
if (fd.get("confirm") !== "on") return { status: "error", code: "confirm_required" };
try {
await updateTenantPlan(
{ platformAdminId: admin.id },
tenantId,
{
tier: String(fd.get("tier") ?? "") as "BASIS" | "PROFI",
lotseChatSeats: String(fd.get("lotseChatSeats") ?? "").trim() || "0",
lotseChatHardLimit: fd.get("lotseChatHardLimit") === "on",
},
);
} catch (err) {
return { status: "error", code: err instanceof ServiceError ? err.message : "failed" };
}
revalidatePath("/admin");
revalidatePath(`/admin/${tenantId}`);
// the tenant shell renders navigation/module gates per request from the plan
revalidatePath("/", "layout");
redirect(`/admin/${tenantId}?planDone=1`);
}
+5 -1
View File
@@ -3,6 +3,7 @@ import { ZodError } from "zod";
import { ServiceError } from "@/server/services/context"; import { ServiceError } from "@/server/services/context";
import { ForbiddenError } from "@/server/rbac"; import { ForbiddenError } from "@/server/rbac";
import { ModuleDisabledError } from "@/server/modules"; import { ModuleDisabledError } from "@/server/modules";
import { PROFI_ONLY_MESSAGE } from "@/server/plan";
/** /**
* JSON response helpers for ALL /api/v1 route handlers (spec §29.2, L10b: single adapter — * JSON response helpers for ALL /api/v1 route handlers (spec §29.2, L10b: single adapter —
@@ -73,7 +74,10 @@ export function toErrorResponse(err: unknown): Response {
); );
} }
if (err instanceof ForbiddenError) return errorResponse("forbidden", "forbidden"); if (err instanceof ForbiddenError) return errorResponse("forbidden", "forbidden");
if (err instanceof ModuleDisabledError) return errorResponse("forbidden", "module disabled"); if (err instanceof ModuleDisabledError) {
// L17 Pakete: Modul nicht in der Paketstufe → Klartext für Clients
return err.reason === "not_in_plan" ? errorResponse("forbidden", "not included in plan", { reason: "not_in_plan", module: err.moduleKey, message: PROFI_ONLY_MESSAGE }) : errorResponse("forbidden", "module disabled");
}
if (err instanceof Error && /Tenant isolation violation/.test(err.message)) return errorResponse("not_found", "not found"); if (err instanceof Error && /Tenant isolation violation/.test(err.message)) return errorResponse("not_found", "not found");
console.error("[api] unhandled error", err); console.error("[api] unhandled error", err);
return errorResponse("internal", "internal error"); return errorResponse("internal", "internal error");
+2
View File
@@ -81,6 +81,8 @@ export const TENANT_MODELS: readonly string[] = [
"LotseActionProposal", "LotseActionProposal",
// L15 Testphase: Datenexport des Mandanten // L15 Testphase: Datenexport des Mandanten
"TenantExport", "TenantExport",
// L17 Pakete: vergebene Lotse-Chat-Plätze
"LotseChatSeat",
]; ];
/** /**
+2
View File
@@ -132,6 +132,8 @@ const TENANT_MODELS = new Set<string>([
"LotseActionProposal", "LotseActionProposal",
// L15 Testphase: Datenexport des Mandanten // L15 Testphase: Datenexport des Mandanten
"TenantExport", "TenantExport",
// L17 Pakete: vergebene Lotse-Chat-Plätze
"LotseChatSeat",
// WebAuthnCredential/Identity sind identitäts-global (kein tenant_id) → NICHT hier. // WebAuthnCredential/Identity sind identitäts-global (kein tenant_id) → NICHT hier.
// Craftvia-Fachmodelle hier ergänzen — UND in src/server/backup/topology.ts // Craftvia-Fachmodelle hier ergänzen — UND in src/server/backup/topology.ts
// (TENANT_MODELS) sowie per `SELECT enable_tenant_rls('<table>')` in der Migration // (TENANT_MODELS) sowie per `SELECT enable_tenant_rls('<table>')` in der Migration
+3
View File
@@ -61,6 +61,9 @@ export const PII_REFERENCE_FIELDS: readonly PiiReference[] = [
{ model: "LotseActionProposal", field: "confirmedById" }, { model: "LotseActionProposal", field: "confirmedById" },
// L15 Testphase: Datenexport // L15 Testphase: Datenexport
{ model: "TenantExport", field: "requestedById" }, { model: "TenantExport", field: "requestedById" },
// L17 Pakete: Lotse-Chat-Platz (Inhaber + vergebende Person)
{ model: "LotseChatSeat", field: "userId" },
{ model: "LotseChatSeat", field: "assignedById" },
// Free-text person data of END CUSTOMERS (Customer/Contact/Site/Signature.signerName) is // Free-text person data of END CUSTOMERS (Customer/Contact/Site/Signature.signerName) is
// tenant business data under data processing — not part of the employee subject export. // tenant business data under data processing — not part of the employee subject export.
]; ];
+16 -12
View File
@@ -1,8 +1,8 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import type { Session } from "next-auth"; import type { Session } from "next-auth";
import { requireSession } from "@/server/auth"; import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { writeAuditLog } from "@/server/audit"; import { writeAuditLog } from "@/server/audit";
import { moduleState, PROFI_HINT_PATH, PROFI_ONLY_MESSAGE } from "@/server/plan";
/** /**
* Serverseitige Modul-Durchsetzung (§3.4): blockiert den Zugriff auf ein für den * Serverseitige Modul-Durchsetzung (§3.4): blockiert den Zugriff auf ein für den
@@ -12,17 +12,21 @@ import { writeAuditLog } from "@/server/audit";
*/ */
export async function requireModule(moduleKey: string) { export async function requireModule(moduleKey: string) {
const session = await requireSession(); const session = await requireSession();
const row = await dbForTenant(session.user.tenantId).tenantModule.findUnique({ // L17 Pakete: Stufe + TenantModule zentral (src/server/plan.ts); nicht im Paket → ruhiger Hinweis
where: { tenantId_moduleKey: { tenantId: session.user.tenantId, moduleKey } }, const state = await moduleState(session.user.tenantId, moduleKey);
}); if (state === "not_in_plan") redirect(PROFI_HINT_PATH);
if (row && !row.enabled) redirect("/dashboard?module=disabled"); if (state === "disabled") redirect("/dashboard?module=disabled");
return session; return session;
} }
/** Wird geworfen, wenn eine Server-Action ein für den Mandanten deaktiviertes Modul betrifft. */ /** Wird geworfen, wenn eine Server-Action ein für den Mandanten deaktiviertes Modul betrifft. */
export class ModuleDisabledError extends Error { export class ModuleDisabledError extends Error {
constructor(public readonly moduleKey: string) { constructor(
super(`Modul „${moduleKey}" ist für diesen Mandanten deaktiviert.`); public readonly moduleKey: string,
/** L17 Pakete: `not_in_plan` = Modul gehört nicht zur Paketstufe des Mandanten (Basis). */
public readonly reason: "disabled" | "not_in_plan" = "disabled",
) {
super(reason === "not_in_plan" ? `Modul „${moduleKey}": ${PROFI_ONLY_MESSAGE}` : `Modul „${moduleKey}" ist für diesen Mandanten deaktiviert.`);
this.name = "ModuleDisabledError"; this.name = "ModuleDisabledError";
} }
} }
@@ -37,17 +41,17 @@ export class ModuleDisabledError extends Error {
*/ */
export async function assertModuleEnabled(session: Session, moduleKey: string) { export async function assertModuleEnabled(session: Session, moduleKey: string) {
const tenantId = session.user.tenantId; const tenantId = session.user.tenantId;
const row = await dbForTenant(tenantId).tenantModule.findUnique({ // L17 Pakete: effektive Freischaltung = Stufe erlaubt UND TenantModule aktiv (src/server/plan.ts)
where: { tenantId_moduleKey: { tenantId, moduleKey } }, const state = await moduleState(tenantId, moduleKey);
}); if (state !== "enabled") {
if (row && !row.enabled) {
await writeAuditLog({ await writeAuditLog({
tenantId, tenantId,
actorId: session.user.id, actorId: session.user.id,
action: "denied", action: "denied",
entity: "module", entity: "module",
entityId: moduleKey, entityId: moduleKey,
...(state === "not_in_plan" ? { after: { reason: "not_in_plan" } } : {}),
}); });
throw new ModuleDisabledError(moduleKey); throw new ModuleDisabledError(moduleKey, state === "not_in_plan" ? "not_in_plan" : "disabled");
} }
} }
+111
View File
@@ -0,0 +1,111 @@
import { redirect } from "next/navigation";
import { dbForTenant, prisma } from "@/server/db";
import { ServiceError } from "@/server/services/context";
import { MODULE_KEYS } from "@/lib/modules";
import { effectiveTier, isFeatureInTier, isModuleInTier, PLAN_FEATURES, type PlanFeature, type TenantTier } from "@/lib/plans";
/**
* L17 Pakete — the ONE server-side place that combines the package tier (Tenant.tier, trial = PROFI)
* with the tenant module switches (TenantModule). Effective module = tier includes it AND the module
* is switched on (missing TenantModule row = on). Every gate uses these functions:
* - requireModule / assertModuleEnabled (src/server/modules.ts) → layouts, moduleGuard, requireApiContext
* - sync apply (services/sync/apply.ts, ops of a locked module → rejected with plain text)
* - navigation (app layout, mobile shell), isLotseEnabled, planningAccess.
* `Tenant` is a platform table (no tenant_id/RLS) and is read with the owner client by id; only the
* operator changes tier/seats (services/plans/platform.ts).
*/
export type TenantPlanInfo = {
/** Effective tier (trial → PROFI). */
tier: TenantTier;
/** Stored tier (what the operator set). */
storedTier: TenantTier;
isTrial: boolean;
lotseChatSeats: number;
lotseChatHardLimit: boolean;
};
export const PLAN_SELECT = { tier: true, plan: true, lotseChatSeats: true, lotseChatHardLimit: true } as const;
export function toPlanInfo(row: { tier: TenantTier; plan: string; lotseChatSeats: number; lotseChatHardLimit: boolean } | null): TenantPlanInfo {
if (!row) return { tier: "PROFI", storedTier: "PROFI", isTrial: false, lotseChatSeats: 0, lotseChatHardLimit: false };
return {
tier: effectiveTier(row.tier, row.plan),
storedTier: row.tier,
isTrial: row.plan === "TRIAL",
lotseChatSeats: row.lotseChatSeats,
lotseChatHardLimit: row.lotseChatHardLimit,
};
}
export async function getTenantPlan(tenantId: string): Promise<TenantPlanInfo> {
return toPlanInfo(await prisma.tenant.findUnique({ where: { id: tenantId }, select: PLAN_SELECT }));
}
/** Plain-language hint for API clients / sync results (UI texts come from messages `plans.*`). */
export const PROFI_ONLY_MESSAGE = "Im Paket Profi enthalten – für diesen Betrieb nicht freigeschaltet.";
export type ModuleState = "enabled" | "disabled" | "not_in_plan";
/** Effective state of one module: tier first, then the tenant switch. */
export async function moduleState(tenantId: string, moduleKey: string): Promise<ModuleState> {
const plan = await getTenantPlan(tenantId);
if (!isModuleInTier(plan.tier, moduleKey)) return "not_in_plan";
const row = await dbForTenant(tenantId).tenantModule.findUnique({
where: { tenantId_moduleKey: { tenantId, moduleKey } },
select: { enabled: true },
});
return row && !row.enabled ? "disabled" : "enabled";
}
export async function isModuleActive(tenantId: string, moduleKey: string): Promise<boolean> {
return (await moduleState(tenantId, moduleKey)) === "enabled";
}
export type EffectiveModules = {
tier: TenantTier;
isTrial: boolean;
/** Modules switched off by the tenant/operator OR not included in the tier. */
inactive: Set<string>;
/** Subset of `inactive`: locked only by the tier ("Im Paket Profi enthalten"). */
notInPlan: Set<string>;
/** Features (planning, lotse_chat) not included in the tier. */
lockedFeatures: Set<PlanFeature>;
};
/** All module states in two queries (navigation, settings overview). */
export async function effectiveModules(tenantId: string): Promise<EffectiveModules> {
const [plan, rows] = await Promise.all([getTenantPlan(tenantId), dbForTenant(tenantId).tenantModule.findMany({ select: { moduleKey: true, enabled: true } })]);
const notInPlan = new Set(MODULE_KEYS.filter((k) => !isModuleInTier(plan.tier, k)));
const inactive = new Set<string>([...notInPlan, ...rows.filter((r) => !r.enabled).map((r) => r.moduleKey)]);
const lockedFeatures = new Set(PLAN_FEATURES.filter((f) => !isFeatureInTier(plan.tier, f)));
return { tier: plan.tier, isTrial: plan.isTrial, inactive, notInPlan, lockedFeatures };
}
export async function isPlanFeatureAvailable(tenantId: string, feature: PlanFeature): Promise<boolean> {
return isFeatureInTier((await getTenantPlan(tenantId)).tier, feature);
}
/** `forbidden` with reason `not_in_plan` — callers that hide sections on `forbidden` keep working. */
export class PlanFeatureError extends ServiceError {
constructor(public readonly feature: string) {
super("forbidden", "not_in_plan", { reason: "not_in_plan", feature, message: PROFI_ONLY_MESSAGE });
this.name = "PlanFeatureError";
}
}
export function isNotInPlanError(err: unknown): boolean {
return err instanceof ServiceError && err.message === "not_in_plan";
}
export async function assertPlanFeature(tenantId: string, feature: PlanFeature): Promise<void> {
if (!(await isPlanFeatureAvailable(tenantId, feature))) throw new PlanFeatureError(feature);
}
/** Dashboard hint target for pages of a locked module/feature (quiet hint instead of an error page). */
export const PROFI_HINT_PATH = "/dashboard?module=profi";
/** Layout gate for a feature without own module (planning). */
export async function requirePlanFeature(tenantId: string, feature: PlanFeature): Promise<void> {
if (!(await isPlanFeatureAvailable(tenantId, feature))) redirect(PROFI_HINT_PATH);
}
+45 -10
View File
@@ -1,27 +1,62 @@
import { isFeatureInTier } from "@/lib/plans";
import { getTenantPlan } from "@/server/plan";
import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context"; import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context";
import { isLotseEnabled } from "../settings"; import { isLotseEnabled } from "../settings";
import { hasValidChatSeat } from "./seats";
import { getChatUsage } from "./usage";
/** /**
* Gate of the Lotse chat for technicians (lane L16): `lotse:use` + `field:execute`, Lotse module on * Gate of the Lotse chat for technicians (lane L16, extended by L17 Pakete) — the ONE place for:
* (TenantModule `lotse`) and the tenant switch „Lotse-Chat für Monteure" (TenantSettings.lotseChatEnabled, * - permissions `lotse:use` + `field:execute`,
* default on). Switched off → `blocked` (reason disabled / chat_disabled). * - package tier PROFI (Basis → `blocked not_in_plan`; trial = PROFI),
* - Lotse module on (TenantModule `lotse`) and the tenant switch „Lotse-Chat für Monteure",
* - a chat seat of the user (`blocked no_seat`; trial: no seat check),
* - when SENDING: the monthly quota with hard limit (`blocked quota_exhausted`; without hard limit the
* usage keeps counting as overage). The token budget (budget.ts) stays as a separate safety net.
*/ */
export type LotseChatBlockReason = "not_in_plan" | "disabled" | "chat_disabled" | "no_seat";
export async function isLotseChatEnabled(ctx: Pick<ServiceCtx, "db" | "tenantId">): Promise<boolean> { export async function isLotseChatEnabled(ctx: Pick<ServiceCtx, "db" | "tenantId">): Promise<boolean> {
if (!(await isLotseEnabled(ctx))) return false; if (!(await isLotseEnabled(ctx))) return false;
const s = await ctx.db.tenantSettings.findFirst({ select: { lotseChatEnabled: true } }); const s = await ctx.db.tenantSettings.findFirst({ select: { lotseChatEnabled: true } });
return s?.lotseChatEnabled ?? true; return s?.lotseChatEnabled ?? true;
} }
/** UI convenience: may the user open the chat at all (permissions + switches)? Never a security check. */ /** Tenant- and user-level gate without permissions and quota; null = open. */
export async function canUseLotseChat(ctx: ServiceCtx): Promise<boolean> { export async function lotseChatBlockReason(ctx: ServiceCtx): Promise<LotseChatBlockReason | null> {
return can(ctx, "lotse:use") && can(ctx, "field:execute") && (await isLotseChatEnabled(ctx)); const plan = await getTenantPlan(ctx.tenantId);
if (!isFeatureInTier(plan.tier, "lotse_chat")) return "not_in_plan";
if (!(await isLotseEnabled(ctx))) return "disabled";
const s = await ctx.db.tenantSettings.findFirst({ select: { lotseChatEnabled: true } });
if (s && !s.lotseChatEnabled) return "chat_disabled";
if (!plan.isTrial && !(await hasValidChatSeat(ctx, ctx.userId, plan.lotseChatSeats))) return "no_seat";
return null;
} }
export async function assertLotseChatUsable(ctx: ServiceCtx): Promise<void> { /** UI convenience: may the user open the chat at all (permissions + switches + seat)? Never a security check. */
export async function canUseLotseChat(ctx: ServiceCtx): Promise<boolean> {
return can(ctx, "lotse:use") && can(ctx, "field:execute") && (await lotseChatBlockReason(ctx)) === null;
}
const BLOCK_MESSAGES: Record<LotseChatBlockReason, string> = {
not_in_plan: "lotse chat not included in plan",
disabled: "lotse disabled for tenant",
chat_disabled: "lotse chat disabled for tenant",
no_seat: "no lotse chat seat",
};
/** Hard limit reached for the current month (tenant timezone)? */
export async function isChatQuotaBlocked(ctx: ServiceCtx, now: Date = new Date()): Promise<boolean> {
return (await getChatUsage(ctx, { now, perUser: false })).blocked;
}
export async function assertLotseChatUsable(ctx: ServiceCtx, opts: { send?: boolean; now?: Date } = {}): Promise<void> {
assertCan(ctx, "lotse:use"); assertCan(ctx, "lotse:use");
assertCan(ctx, "field:execute"); assertCan(ctx, "field:execute");
if (!(await isLotseEnabled(ctx))) throw new ServiceError("blocked", "lotse disabled for tenant", { reason: "disabled" }); const reason = await lotseChatBlockReason(ctx);
const s = await ctx.db.tenantSettings.findFirst({ select: { lotseChatEnabled: true } }); if (reason) throw new ServiceError("blocked", BLOCK_MESSAGES[reason], { reason });
if (s && !s.lotseChatEnabled) throw new ServiceError("blocked", "lotse chat disabled for tenant", { reason: "chat_disabled" }); if (opts.send && (await isChatQuotaBlocked(ctx, opts.now))) {
throw new ServiceError("blocked", "lotse chat quota exhausted", { reason: "quota_exhausted" });
}
} }
+3 -2
View File
@@ -84,9 +84,10 @@ async function historyTurns(ctx: ServiceCtx, conversationId: string, env: Minimi
} }
export async function sendLotseMessage(ctx: ServiceCtx, raw: SendMessageInput, deps: ChatDeps = defaultChatDeps()): Promise<ChatView> { export async function sendLotseMessage(ctx: ServiceCtx, raw: SendMessageInput, deps: ChatDeps = defaultChatDeps()): Promise<ChatView> {
await assertLotseChatUsable(ctx);
const input = sendMessageSchema.parse(raw);
const now = (deps.now ?? (() => new Date()))(); const now = (deps.now ?? (() => new Date()))();
// L17 Pakete: seat + monthly quota (hard limit → blocked quota_exhausted) before anything is stored
await assertLotseChatUsable(ctx, { send: true, now });
const input = sendMessageSchema.parse(raw);
const conversation = (await openConversation(ctx, { conversationId: input.conversationId, workOrderId: input.workOrderId, create: true }))!; const conversation = (await openConversation(ctx, { conversationId: input.conversationId, workOrderId: input.workOrderId, create: true }))!;
if (!deps.provider) throw new ServiceError("invalid", "lotse provider not configured", { reason: "not_configured" }); if (!deps.provider) throw new ServiceError("invalid", "lotse provider not configured", { reason: "not_configured" });
await assertTokenBudget(ctx, now); await assertTokenBudget(ctx, now);
+130
View File
@@ -0,0 +1,130 @@
import { z } from "zod";
import { writeAuditLog } from "@/server/audit";
import { getTenantPlan } from "@/server/plan";
import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
/**
* L17 Pakete — Lotse chat seats. The operator sets the number of purchased seats per tenant
* (Tenant.lotseChatSeats, platform only); the tenant admin (`tenant:manage`) assigns them to users with
* a field role (`lotse:use` + `field:execute`). Assigning beyond the purchased number → `invalid`.
*
* If the operator reduces the number below the assigned seats, the rows stay (nothing is deleted),
* but only the OLDEST `lotseChatSeats` assignments are valid (createdAt, id) — the admin sees the
* overbooking and revokes seats. Switching the tenant to Basis keeps all rows.
*/
type SeatCtx = Pick<ServiceCtx, "db" | "tenantId">;
const ELIGIBLE_PERMISSIONS = ["lotse:use", "field:execute"] as const;
/** Where clause: active members whose roles grant BOTH chat permissions (DB-authoritative). */
export function eligibleUserWhere() {
return {
status: "ACTIVE" as const,
AND: ELIGIBLE_PERMISSIONS.map((key) => ({ userRoles: { some: { role: { rolePermissions: { some: { permission: { key } } } } } } })),
};
}
export async function listEligibleUsers(ctx: SeatCtx): Promise<{ id: string; name: string; email: string }[]> {
return ctx.db.user.findMany({ where: eligibleUserWhere(), select: { id: true, name: true, email: true }, orderBy: [{ name: "asc" }, { id: "asc" }] });
}
export async function countEligibleUsers(ctx: SeatCtx): Promise<number> {
return ctx.db.user.count({ where: eligibleUserWhere() });
}
/** User ids holding a VALID seat (the oldest `purchased` assignments). */
export async function validSeatUserIds(ctx: SeatCtx, purchased: number): Promise<Set<string>> {
if (purchased <= 0) return new Set();
const rows = await ctx.db.lotseChatSeat.findMany({ orderBy: [{ createdAt: "asc" }, { id: "asc" }], take: purchased, select: { userId: true } });
return new Set(rows.map((r) => r.userId));
}
export async function hasValidChatSeat(ctx: SeatCtx, userId: string, purchased: number): Promise<boolean> {
return (await validSeatUserIds(ctx, purchased)).has(userId);
}
/** Assigned seats that count (capped at the purchased number) — basis of the monthly quota. */
export async function countAssignedSeats(ctx: SeatCtx, purchased: number): Promise<{ assigned: number; counted: number }> {
const assigned = await ctx.db.lotseChatSeat.count();
return { assigned, counted: Math.min(assigned, Math.max(0, purchased)) };
}
export type SeatOverview = {
purchased: number;
assigned: number;
/** more rows than purchased seats (operator reduced the number) */
overbooked: boolean;
users: { id: string; name: string; email: string; assigned: boolean; valid: boolean; assignedAt: string | null }[];
};
/** Admin view: purchased/assigned seats + all users with a field role and their seat state. */
export async function getSeatOverview(ctx: ServiceCtx): Promise<SeatOverview> {
assertCan(ctx, "tenant:manage");
const plan = await getTenantPlan(ctx.tenantId);
const [eligible, rows, valid] = await Promise.all([
listEligibleUsers(ctx),
ctx.db.lotseChatSeat.findMany({ select: { userId: true, createdAt: true } }),
validSeatUserIds(ctx, plan.lotseChatSeats),
]);
const seatOf = new Map(rows.map((r) => [r.userId, r.createdAt]));
const users = eligible.map((u) => ({ ...u, assigned: seatOf.has(u.id), valid: valid.has(u.id), assignedAt: seatOf.get(u.id)?.toISOString() ?? null }));
// seats of users who lost the field role / were deactivated still occupy a seat → listed too
const eligibleIds = new Set(eligible.map((u) => u.id));
const orphanIds = rows.map((r) => r.userId).filter((id) => !eligibleIds.has(id));
if (orphanIds.length) {
const orphans = await ctx.db.user.findMany({ where: { id: { in: orphanIds } }, select: { id: true, name: true, email: true } });
for (const u of orphans) users.push({ ...u, assigned: true, valid: valid.has(u.id), assignedAt: seatOf.get(u.id)?.toISOString() ?? null });
}
return { purchased: plan.lotseChatSeats, assigned: rows.length, overbooked: rows.length > plan.lotseChatSeats, users };
}
export const seatAssignmentSchema = z.object({ userId: z.string().trim().min(1).max(64), assigned: z.boolean() });
export type SeatAssignmentInput = z.input<typeof seatAssignmentSchema>;
/**
* Assign or revoke a seat (`tenant:manage`). Assigning: the user must be an active member with a field
* role (else `invalid not_eligible`) and a purchased seat must be free (else `invalid no_seats_left`).
* Serialised per tenant by a row lock on the tenant settings (parallel admins cannot exceed the number).
* Idempotent: assigning an assigned / revoking a free seat changes nothing. Audit before/after.
*/
export async function setChatSeat(ctx: ServiceCtx, raw: SeatAssignmentInput): Promise<{ userId: string; assigned: boolean; changed: boolean }> {
assertCan(ctx, "tenant:manage");
const input = seatAssignmentSchema.parse(raw);
const plan = await getTenantPlan(ctx.tenantId);
if (input.assigned && plan.tier !== "PROFI") throw new ServiceError("blocked", "not_in_plan", { reason: "not_in_plan" });
const result = await inTransaction(ctx, async (tx) => {
// row lock: serialises seat changes of this tenant until commit
await tx.db.tenantSettings.updateMany({ data: { updatedAt: new Date() } });
const existing = await tx.db.lotseChatSeat.findFirst({ where: { userId: input.userId }, select: { id: true, userId: true, createdAt: true, assignedById: true } });
if (!input.assigned) {
if (!existing) return { changed: false, before: null, after: null };
await tx.db.lotseChatSeat.delete({ where: { id: existing.id } });
return { changed: true, before: { userId: existing.userId, assignedById: existing.assignedById }, after: null };
}
if (existing) return { changed: false, before: null, after: null };
const user = await tx.db.user.findFirst({ where: { AND: [{ id: input.userId }, eligibleUserWhere()] }, select: { id: true } });
if (!user) {
// unknown id, other tenant, inactive or without field role — never reveal which
throw new ServiceError("invalid", "not_eligible", { reason: "not_eligible" });
}
const assigned = await tx.db.lotseChatSeat.count();
if (assigned >= plan.lotseChatSeats) throw new ServiceError("invalid", "no_seats_left", { reason: "no_seats_left", purchased: plan.lotseChatSeats, assigned });
const row = await tx.db.lotseChatSeat.create({ data: { tenantId: tx.tenantId, userId: user.id, assignedById: ctx.userId }, select: { id: true, userId: true, assignedById: true } });
return { changed: true, before: null, after: { userId: row.userId, assignedById: row.assignedById } };
});
if (result.changed) {
await writeAuditLog({
tenantId: ctx.tenantId,
actorId: ctx.userId,
action: input.assigned ? "create" : "delete",
entity: "lotse_chat_seat",
entityId: input.userId,
before: result.before ?? undefined,
after: result.after ?? undefined,
});
}
return { userId: input.userId, assigned: input.assigned, changed: result.changed };
}
+1 -1
View File
@@ -18,7 +18,7 @@ export type ChatTranscriptionDeps = { provider: TranscriptionProvider | null };
export const defaultChatTranscriptionDeps = (): ChatTranscriptionDeps => ({ provider: getTranscriptionProvider() }); export const defaultChatTranscriptionDeps = (): ChatTranscriptionDeps => ({ provider: getTranscriptionProvider() });
export async function transcribeChatAudio(ctx: ServiceCtx, bytes: Buffer, deps: ChatTranscriptionDeps = defaultChatTranscriptionDeps()): Promise<{ text: string }> { export async function transcribeChatAudio(ctx: ServiceCtx, bytes: Buffer, deps: ChatTranscriptionDeps = defaultChatTranscriptionDeps()): Promise<{ text: string }> {
await assertLotseChatUsable(ctx); await assertLotseChatUsable(ctx, { send: true }); // L17: dictation only while sending is possible
if (!deps.provider) throw new ServiceError("invalid", "transcription not configured", { reason: "not_configured" }); if (!deps.provider) throw new ServiceError("invalid", "transcription not configured", { reason: "not_configured" });
if (bytes.byteLength === 0 || bytes.byteLength > CHAT_AUDIO_MAX_BYTES) throw new ServiceError("invalid", "audio size", { reason: "invalid" }); if (bytes.byteLength === 0 || bytes.byteLength > CHAT_AUDIO_MAX_BYTES) throw new ServiceError("invalid", "audio size", { reason: "invalid" });
const sniffed = sniffMime(bytes); const sniffed = sniffMime(bytes);
+144
View File
@@ -0,0 +1,144 @@
import { dayBounds, dayKeyOf } from "@/lib/planning/days";
import {
chatOverage,
chatQuota,
isQuotaBlocked,
monthKeyOfDay,
nextMonthKey,
overagePacks,
previousMonthKey,
usageLevel,
usagePercent,
type TenantTier,
type UsageLevel,
} from "@/lib/plans";
import { dbForTenant } from "@/server/db";
import { getTenantPlan } from "@/server/plan";
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
import { tenantTimezone } from "@/server/services/work-orders/_shared";
import { countAssignedSeats, countEligibleUsers } from "./seats";
/**
* L17 Pakete — usage of the Lotse chat: ONE chat = one message of a user to the Lotse
* (`LotseMessage.role = user`). Counted per calendar month in the tenant timezone, for the whole tenant
* and per user. Quota = counted seats × 150 (trial: eligible users × 150). Overage keeps counting and is
* billed by the operator in packs of 100; with the hard limit the chat is blocked at the quota.
*
* Note: the Lotse retention job deletes chat histories after `AI_GENERATION_RETENTION_DAYS` (default
* 180) — keep it above ~62 days so the previous month stays complete for billing.
*/
const MONTH_KEY_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
export function isMonthKey(v: unknown): v is string {
return typeof v === "string" && MONTH_KEY_RE.test(v);
}
/** [from, to) instants of a calendar month in the timezone (DST-safe via day bounds). */
export function monthBounds(monthKey: string, timeZone: string): { from: Date; to: Date } {
if (!isMonthKey(monthKey)) throw new ServiceError("invalid", "month_invalid");
return { from: dayBounds(`${monthKey}-01`, timeZone).start, to: dayBounds(`${nextMonthKey(monthKey)}-01`, timeZone).start };
}
export function monthKeyOf(now: Date, timeZone: string): string {
return monthKeyOfDay(dayKeyOf(now, timeZone));
}
export type ChatUsage = {
monthKey: string;
from: string;
to: string;
tier: TenantTier;
isTrial: boolean;
seatsPurchased: number;
seatsAssigned: number;
/** seats that count for the quota (trial: eligible users) */
quotaBasis: number;
used: number;
quota: number;
overage: number;
overagePacks: number;
level: UsageLevel;
percent: number;
hardLimit: boolean;
/** hard limit reached → sending is blocked */
blocked: boolean;
perUser: { userId: string; name: string; count: number }[];
};
type UsageCtx = Pick<ServiceCtx, "db" | "tenantId">;
async function countChats(ctx: UsageCtx, from: Date, to: Date): Promise<number> {
return ctx.db.lotseMessage.count({ where: { role: "user", createdAt: { gte: from, lt: to } } });
}
async function countChatsPerUser(ctx: UsageCtx, from: Date, to: Date): Promise<ChatUsage["perUser"]> {
const groups = await ctx.db.lotseMessage.groupBy({ by: ["conversationId"], where: { role: "user", createdAt: { gte: from, lt: to } }, _count: { _all: true } });
if (!groups.length) return [];
const conversations = await ctx.db.lotseConversation.findMany({ where: { id: { in: groups.map((g) => g.conversationId) } }, select: { id: true, userId: true } });
const userOf = new Map(conversations.map((c) => [c.id, c.userId]));
const perUser = new Map<string, number>();
for (const g of groups) {
const userId = userOf.get(g.conversationId);
if (userId) perUser.set(userId, (perUser.get(userId) ?? 0) + g._count._all);
}
const users = await ctx.db.user.findMany({ where: { id: { in: [...perUser.keys()] } }, select: { id: true, name: true } });
const nameOf = new Map(users.map((u) => [u.id, u.name]));
return [...perUser.entries()].map(([userId, count]) => ({ userId, name: nameOf.get(userId) ?? "—", count })).sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
}
/**
* Usage of one month (default: current month in the tenant timezone). No permission check — callers are
* the chat gate (own tenant), the tenant admin view (`getChatUsageForAdmin`) and the operator view.
*/
export async function getChatUsage(ctx: UsageCtx, opts: { monthKey?: string; now?: Date; perUser?: boolean } = {}): Promise<ChatUsage> {
const timeZone = await tenantTimezone(ctx as ServiceCtx);
const monthKey = opts.monthKey ?? monthKeyOf(opts.now ?? new Date(), timeZone);
const { from, to } = monthBounds(monthKey, timeZone);
const plan = await getTenantPlan(ctx.tenantId);
const [used, seats, eligible, perUser] = await Promise.all([
countChats(ctx, from, to),
countAssignedSeats(ctx, plan.lotseChatSeats),
plan.isTrial ? countEligibleUsers(ctx) : Promise.resolve(0),
opts.perUser === false ? Promise.resolve([]) : countChatsPerUser(ctx, from, to),
]);
const quotaBasis = plan.isTrial ? eligible : seats.counted;
const quota = chatQuota(quotaBasis);
const overage = chatOverage(used, quota);
// the hard limit does not apply to a trial (no purchased seats; the token budget stays the safety net)
const hardLimit = plan.lotseChatHardLimit && !plan.isTrial;
return {
monthKey,
from: from.toISOString(),
to: to.toISOString(),
tier: plan.tier,
isTrial: plan.isTrial,
seatsPurchased: plan.lotseChatSeats,
seatsAssigned: seats.assigned,
quotaBasis,
used,
quota,
overage,
overagePacks: overagePacks(overage),
level: usageLevel(used, quota),
percent: usagePercent(used, quota),
hardLimit,
blocked: isQuotaBlocked(used, quota, hardLimit),
perUser,
};
}
/** Tenant admin (`tenant:manage`): current month with per-user breakdown. */
export async function getChatUsageForAdmin(ctx: ServiceCtx, opts: { now?: Date } = {}): Promise<ChatUsage> {
assertCan(ctx, "tenant:manage");
return getChatUsage(ctx, { now: opts.now, perUser: true });
}
/** Operator view (platform admin, see services/plans/platform.ts): current and previous month. */
export async function getChatUsageForOperator(tenantId: string, now: Date = new Date()): Promise<{ current: ChatUsage; previous: ChatUsage }> {
const ctx: UsageCtx = { db: dbForTenant(tenantId), tenantId };
const current = await getChatUsage(ctx, { now, perUser: false });
const previous = await getChatUsage(ctx, { monthKey: previousMonthKey(current.monthKey), perUser: false });
return { current, previous };
}
+15 -2
View File
@@ -5,6 +5,8 @@ import { AI_MODEL, isAiConfigured } from "@/server/ai/client";
import { transcriptionConfig } from "@/server/ai/transcription/openai-compatible"; import { transcriptionConfig } from "@/server/ai/transcription/openai-compatible";
import { writeAuditLog } from "@/server/audit"; import { writeAuditLog } from "@/server/audit";
import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context"; import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
import { getTenantPlan } from "@/server/plan";
import { isModuleInTier } from "@/lib/plans";
import { envMonthlyTokenLimit, getTokenBudget } from "./budget"; import { envMonthlyTokenLimit, getTokenBudget } from "./budget";
/** /**
@@ -14,7 +16,14 @@ import { envMonthlyTokenLimit, getTokenBudget } from "./budget";
export const LOTSE_MODULE_KEY = "lotse"; export const LOTSE_MODULE_KEY = "lotse";
/** Lotse usable for the tenant: module switched on AND included in the package tier (L17: Basis has no AI). */
export async function isLotseEnabled(ctx: Pick<ServiceCtx, "db" | "tenantId">): Promise<boolean> { export async function isLotseEnabled(ctx: Pick<ServiceCtx, "db" | "tenantId">): Promise<boolean> {
if (!(await isModuleInPlan(ctx.tenantId, LOTSE_MODULE_KEY))) return false;
return isLotseSwitchedOn(ctx);
}
/** Only the tenant switch (settings form shows it independently of the tier). */
async function isLotseSwitchedOn(ctx: Pick<ServiceCtx, "db" | "tenantId">): Promise<boolean> {
const row = await ctx.db.tenantModule.findUnique({ const row = await ctx.db.tenantModule.findUnique({
where: { tenantId_moduleKey: { tenantId: ctx.tenantId, moduleKey: LOTSE_MODULE_KEY } }, where: { tenantId_moduleKey: { tenantId: ctx.tenantId, moduleKey: LOTSE_MODULE_KEY } },
select: { enabled: true }, select: { enabled: true },
@@ -22,6 +31,10 @@ export async function isLotseEnabled(ctx: Pick<ServiceCtx, "db" | "tenantId">):
return !row || row.enabled; return !row || row.enabled;
} }
async function isModuleInPlan(tenantId: string, moduleKey: string): Promise<boolean> {
return isModuleInTier((await getTenantPlan(tenantId)).tier, moduleKey);
}
/** Throws `forbidden` (details.reason = "disabled") when the tenant switched the Lotse off. */ /** Throws `forbidden` (details.reason = "disabled") when the tenant switched the Lotse off. */
export async function assertLotseEnabled(ctx: ServiceCtx): Promise<void> { export async function assertLotseEnabled(ctx: ServiceCtx): Promise<void> {
if (!(await isLotseEnabled(ctx))) throw new ServiceError("forbidden", "lotse disabled for tenant", { reason: "disabled" }); if (!(await isLotseEnabled(ctx))) throw new ServiceError("forbidden", "lotse disabled for tenant", { reason: "disabled" });
@@ -40,7 +53,7 @@ export async function lotseVoice(ctx: Pick<ServiceCtx, "db">): Promise<{ address
export async function getLotseSettings(ctx: ServiceCtx) { export async function getLotseSettings(ctx: ServiceCtx) {
assertCan(ctx, "tenant:manage"); assertCan(ctx, "tenant:manage");
const [enabled, s, budget] = await Promise.all([ const [enabled, s, budget] = await Promise.all([
isLotseEnabled(ctx), isLotseSwitchedOn(ctx),
ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true, lotseChatEnabled: true } }), ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true, lotseChatEnabled: true } }),
getTokenBudget(ctx), getTokenBudget(ctx),
]); ]);
@@ -73,7 +86,7 @@ export async function updateLotseSettings(ctx: ServiceCtx, raw: LotseSettingsInp
const stored = await ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true, aiMonthlyTokenLimit: true, lotseChatEnabled: true } }); const stored = await ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true, aiMonthlyTokenLimit: true, lotseChatEnabled: true } });
const before = { const before = {
enabled: await isLotseEnabled(ctx), enabled: await isLotseSwitchedOn(ctx),
addressForm: stored?.lotseAddressForm ?? null, addressForm: stored?.lotseAddressForm ?? null,
monthlyTokenLimit: stored?.aiMonthlyTokenLimit ?? null, monthlyTokenLimit: stored?.aiMonthlyTokenLimit ?? null,
chatEnabled: stored?.lotseChatEnabled ?? true, chatEnabled: stored?.lotseChatEnabled ?? true,
+4
View File
@@ -1,10 +1,13 @@
import { can, ServiceError, type ServiceCtx } from "@/server/services/context"; import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
import { assertPlanFeature } from "@/server/plan";
/** /**
* Who may use the planning views (L13): * Who may use the planning views (L13):
* - `work_order:read_all` (backoffice, admin): all teams; scheduling needs `work_order:assign` + `work_order:write`. * - `work_order:read_all` (backoffice, admin): all teams; scheduling needs `work_order:assign` + `work_order:write`.
* - `work_order:read_team` + leader of at least one active team (Teamleiter): read-only, own teams only. * - `work_order:read_team` + leader of at least one active team (Teamleiter): read-only, own teams only.
* - everyone else (Monteur): forbidden. * - everyone else (Monteur): forbidden.
* L17 Pakete: planning is a PROFI feature — Basis → `forbidden` (reason `not_in_plan`) for board, live
* situation, recommendations, freed capacity, dashboard tiles and the planning API.
*/ */
export type PlanningAccess = { export type PlanningAccess = {
all: boolean; all: boolean;
@@ -15,6 +18,7 @@ export type PlanningAccess = {
}; };
export async function planningAccess(ctx: ServiceCtx): Promise<PlanningAccess> { export async function planningAccess(ctx: ServiceCtx): Promise<PlanningAccess> {
await assertPlanFeature(ctx.tenantId, "planning");
if (can(ctx, "work_order:read_all")) { if (can(ctx, "work_order:read_all")) {
return { return {
all: true, all: true,
+2
View File
@@ -3,6 +3,7 @@ import { orderDays, SCHEDULABLE_STATUSES, type PlanningConflict } from "@/lib/pl
import type { WorkOrderStatus } from "@/lib/work-orders/status"; import type { WorkOrderStatus } from "@/lib/work-orders/status";
import { writeAuditLog } from "@/server/audit"; import { writeAuditLog } from "@/server/audit";
import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context"; import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
import { assertPlanFeature } from "@/server/plan";
import { getPlanningBoard, MAX_BOARD_DAYS } from "@/server/services/planning/board"; import { getPlanningBoard, MAX_BOARD_DAYS } from "@/server/services/planning/board";
import { assignWorkOrder } from "@/server/services/work-orders/assign"; import { assignWorkOrder } from "@/server/services/work-orders/assign";
import { parseInput, snapshot, tenantTimezone, writeWithVersion } from "@/server/services/work-orders/_shared"; import { parseInput, snapshot, tenantTimezone, writeWithVersion } from "@/server/services/work-orders/_shared";
@@ -44,6 +45,7 @@ export type ScheduleResult = {
export async function scheduleWorkOrder(ctx: ServiceCtx, raw: ScheduleInput): Promise<ScheduleResult> { export async function scheduleWorkOrder(ctx: ServiceCtx, raw: ScheduleInput): Promise<ScheduleResult> {
assertCan(ctx, "work_order:assign"); assertCan(ctx, "work_order:assign");
assertCan(ctx, "work_order:write"); assertCan(ctx, "work_order:write");
await assertPlanFeature(ctx.tenantId, "planning"); // L17 Pakete
const input = parseInput(scheduleSchema, raw); const input = parseInput(scheduleSchema, raw);
if (Number.isNaN(input.plannedStart.getTime())) throw new ServiceError("invalid", "validation_failed", [{ path: "plannedStart" }]); if (Number.isNaN(input.plannedStart.getTime())) throw new ServiceError("invalid", "validation_failed", [{ path: "plannedStart" }]);
@@ -1,6 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { writeAuditLog } from "@/server/audit"; import { writeAuditLog } from "@/server/audit";
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
import { assertPlanFeature } from "@/server/plan";
import { parseInput } from "@/server/services/work-orders/_shared"; import { parseInput } from "@/server/services/work-orders/_shared";
/** Planning settings of a team = crew (L13): crew working day in minutes + working days bit mask. */ /** Planning settings of a team = crew (L13): crew working day in minutes + working days bit mask. */
@@ -13,6 +14,7 @@ export type TeamPlanningSettingsInput = z.input<typeof settingsSchema>;
export async function updateTeamPlanningSettings(ctx: ServiceCtx, teamId: string, raw: TeamPlanningSettingsInput) { export async function updateTeamPlanningSettings(ctx: ServiceCtx, teamId: string, raw: TeamPlanningSettingsInput) {
assertCan(ctx, "team:manage"); assertCan(ctx, "team:manage");
await assertPlanFeature(ctx.tenantId, "planning"); // L17 Pakete: Kolonnenkapazität gehört zur Planung
const input = parseInput(settingsSchema, raw); const input = parseInput(settingsSchema, raw);
const before = await ctx.db.team.findFirst({ const before = await ctx.db.team.findFirst({
where: { id: teamId, deletedAt: null }, where: { id: teamId, deletedAt: null },
+3
View File
@@ -15,6 +15,7 @@ import { emitEvent } from "@/server/events";
import { PERMISSIONS } from "@/server/rbac"; import { PERMISSIONS } from "@/server/rbac";
import type { ServiceCtx } from "@/server/services/context"; import type { ServiceCtx } from "@/server/services/context";
import { planningAccess } from "@/server/services/planning/access"; import { planningAccess } from "@/server/services/planning/access";
import { isPlanFeatureAvailable } from "@/server/plan";
import { import {
loadPlannedOrders, loadPlannedOrders,
loadTeams, loadTeams,
@@ -339,6 +340,8 @@ export async function runPlanningWatch(
): Promise<WatchResult> { ): Promise<WatchResult> {
const ctx = watchContext(tenantId); const ctx = watchContext(tenantId);
const now = opts.now ?? new Date(); const now = opts.now ?? new Date();
// L17 Pakete: Basis enthält keine Planung → keine Verzugs-/Frühfertig-Meldungen
if (!(await isPlanFeatureAvailable(tenantId, "planning"))) return { overrun: 0, followupAtRisk: 0, capacityFreed: 0 };
const emit = opts.emit ?? emitEvent; const emit = opts.emit ?? emitEvent;
const ledger = opts.ledger ?? auditAlertLedger; const ledger = opts.ledger ?? auditAlertLedger;
const result: WatchResult = { overrun: 0, followupAtRisk: 0, capacityFreed: 0 }; const result: WatchResult = { overrun: 0, followupAtRisk: 0, capacityFreed: 0 };
+66
View File
@@ -0,0 +1,66 @@
import { z } from "zod";
import { dbForTenant, prisma } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
import { ServiceError } from "@/server/services/context";
import { TENANT_TIERS } from "@/lib/plans";
import { getChatUsageForOperator, type ChatUsage } from "@/server/services/lotse/chat/usage";
import { countAssignedSeats } from "@/server/services/lotse/chat/seats";
import { toPlanInfo, PLAN_SELECT, type TenantPlanInfo } from "@/server/plan";
/**
* L17 Pakete — operator (platform full admin) operations: package tier, number of purchased Lotse chat
* seats and the hard limit. Tenant administrators can NOT change these (they have no PlatformAdmin row →
* `forbidden`); the adapter additionally requires the platform session (src/server/actions/plans-platform.ts).
* Audit: scope platform, attached to the tenant (before/after), same pattern as the L15 trial actions.
* Switching PROFI → BASIS keeps all data (seats, billing records, chats); only access ends.
*/
export type PlatformActor = { platformAdminId: string };
async function assertPlatformFullAdmin(actor: PlatformActor): Promise<void> {
const admin = actor.platformAdminId
? await prisma.platformAdmin.findUnique({ where: { id: actor.platformAdminId }, select: { status: true, role: true } })
: null;
if (!admin || admin.status !== "ACTIVE" || admin.role !== "full") throw new ServiceError("forbidden", "platform_admin_required");
}
export const MAX_LOTSE_CHAT_SEATS = 1000;
export const tenantPlanSchema = z.object({
tier: z.enum(TENANT_TIERS),
lotseChatSeats: z.coerce.number().int().min(0).max(MAX_LOTSE_CHAT_SEATS),
lotseChatHardLimit: z.boolean(),
});
export type TenantPlanInput = z.input<typeof tenantPlanSchema>;
export async function updateTenantPlan(actor: PlatformActor, tenantId: string, raw: TenantPlanInput): Promise<TenantPlanInfo> {
await assertPlatformFullAdmin(actor);
const parsed = tenantPlanSchema.safeParse(raw);
if (!parsed.success) throw new ServiceError("invalid", "invalid_input", parsed.error.issues.map((i) => ({ path: i.path.join("."), code: i.code })));
const input = parsed.data;
const before = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { id: true, status: true, ...PLAN_SELECT } });
if (!before) throw new ServiceError("not_found", "tenant not found");
if (before.status === "ARCHIVED") throw new ServiceError("invalid", "tenant_archived");
const after = await prisma.tenant.update({ where: { id: tenantId }, data: input, select: PLAN_SELECT });
const snap = (r: { tier: string; lotseChatSeats: number; lotseChatHardLimit: boolean }) => ({ tier: r.tier, lotseChatSeats: r.lotseChatSeats, lotseChatHardLimit: r.lotseChatHardLimit });
await writeAuditLog({ tenantId, scope: "platform", actorId: actor.platformAdminId, action: "update", entity: "tenant_plan", entityId: tenantId, before: snap(before), after: snap(after) });
return toPlanInfo(after);
}
export type TenantPlanOverview = {
plan: TenantPlanInfo;
seatsAssigned: number;
current: ChatUsage;
previous: ChatUsage;
};
/** Operator view for the tenant detail page: plan, seats assigned/purchased, chats of the current and previous month. */
export async function getTenantPlanOverview(actor: PlatformActor, tenantId: string, now: Date = new Date()): Promise<TenantPlanOverview> {
const admin = actor.platformAdminId ? await prisma.platformAdmin.findUnique({ where: { id: actor.platformAdminId }, select: { status: true } }) : null;
if (!admin || admin.status !== "ACTIVE") throw new ServiceError("forbidden", "platform_admin_required");
const row = await prisma.tenant.findUnique({ where: { id: tenantId }, select: PLAN_SELECT });
if (!row) throw new ServiceError("not_found", "tenant not found");
const plan = toPlanInfo(row);
const [{ current, previous }, seats] = await Promise.all([getChatUsageForOperator(tenantId, now), countAssignedSeats({ db: dbForTenant(tenantId), tenantId }, plan.lotseChatSeats)]);
return { plan, seatsAssigned: seats.assigned, current, previous };
}
+40
View File
@@ -14,6 +14,7 @@ import { attachPhoto } from "@/server/services/field/photos";
import { attachVoiceNote } from "@/server/services/field/voice"; import { attachVoiceNote } from "@/server/services/field/voice";
// TODO(merge L2): replace with "@/server/services/work-orders/transition" // TODO(merge L2): replace with "@/server/services/work-orders/transition"
import { transitionWorkOrder } from "@/server/services/work-orders/transition"; import { transitionWorkOrder } from "@/server/services/work-orders/transition";
import { moduleState, PROFI_ONLY_MESSAGE } from "@/server/plan";
import { EXTERNAL_OP_OWNERS, EXTERNAL_OPS } from "./external-ops"; import { EXTERNAL_OP_OWNERS, EXTERNAL_OPS } from "./external-ops";
/** /**
@@ -78,6 +79,37 @@ function errorMessage(err: ServiceError): string {
class NotAvailable extends Error {} class NotAvailable extends Error {}
/**
* L17 Pakete: sync ops that belong to a module other than `field` (the sync route itself is gated by
* `field`). One place for all offline ops — the effective module state comes from src/server/plan.ts.
*/
export const OP_MODULES: Partial<Record<SyncOpType, string>> = {
"emergency.create": "emergency",
"milestone.reach": "billing",
};
const LOCKED_MODULE_MESSAGE: Record<string, { not_in_plan: string; disabled: string }> = {
emergency: {
not_in_plan: "Notdienst ist im Paket Profi enthalten und für diesen Betrieb nicht freigeschaltet – der Notdiensteinsatz wurde nicht übernommen. Bitte im Büro melden.",
disabled: "Notdienst ist für diesen Betrieb ausgeschaltet – der Notdiensteinsatz wurde nicht übernommen. Bitte im Büro melden.",
},
billing: {
not_in_plan: "Meilensteine gehören zur Abrechnungsübersicht (Paket Profi) – die Meldung wurde nicht übernommen.",
disabled: "Die Abrechnungsübersicht ist für diesen Betrieb ausgeschaltet – die Meldung wurde nicht übernommen.",
},
};
async function lockedModuleMessage(ctx: ServiceCtx, opType: SyncOpType): Promise<string | null> {
const moduleKey = OP_MODULES[opType];
if (!moduleKey) return null;
const state = await moduleState(ctx.tenantId, moduleKey);
if (state === "enabled") return null;
// `<code>: <plain text>` like `other_session_running:…` — the device maps the code to its own texts
// (lib/offline/outbox-core.ts#problemKey), other API clients show the plain text.
const text = LOCKED_MODULE_MESSAGE[moduleKey]?.[state] ?? (state === "not_in_plan" ? PROFI_ONLY_MESSAGE : "Modul ist für diesen Betrieb ausgeschaltet.");
return `${state === "not_in_plan" ? "not_in_plan" : "module_disabled"}: ${text}`;
}
/** Route a validated op to the field handler or the registered module of another lane. */ /** Route a validated op to the field handler or the registered module of another lane. */
async function dispatch(ctx: ServiceCtx, payload: unknown, op: SyncOperationInput): Promise<HandlerResult> { async function dispatch(ctx: ServiceCtx, payload: unknown, op: SyncOperationInput): Promise<HandlerResult> {
const handler = FIELD_HANDLERS[op.opType]; const handler = FIELD_HANDLERS[op.opType];
@@ -188,6 +220,14 @@ async function applyOne(ctx: ServiceCtx, deviceId: string, op: SyncOperationInpu
return rec === "duplicate" ? { ...base, status: "duplicate" } : { ...base, status: "rejected", errorCode: "invalid", message }; return rec === "duplicate" ? { ...base, status: "duplicate" } : { ...base, status: "rejected", errorCode: "invalid", message };
} }
// 2b. L17 Pakete: ops of a module that is switched off or not part of the package tier → rejected
// with plain text (stored, visible in the device's sync list — never silently dropped)
const locked = await lockedModuleMessage(ctx, op.opType);
if (locked) {
const rec = await record(ctx, op, deviceId, "rejected", { message: locked }, "forbidden");
return rec === "duplicate" ? { ...base, status: "duplicate" } : { ...base, status: "rejected", errorCode: "forbidden", message: locked };
}
try { try {
// 3. conflict check // 3. conflict check
if (CONFLICTING_OPS.includes(op.opType)) { if (CONFLICTING_OPS.includes(op.opType)) {
@@ -12,6 +12,7 @@ import {
type WorkOrderBase, type WorkOrderBase,
} from "@/server/services/work-orders/_shared"; } from "@/server/services/work-orders/_shared";
import { transitionBlockers } from "@/server/services/work-orders/completion"; import { transitionBlockers } from "@/server/services/work-orders/completion";
import { isModuleActive } from "@/server/plan";
/** Permission decision for a single transition (scope is checked separately by loading the order). */ /** Permission decision for a single transition (scope is checked separately by loading the order). */
export function mayTransition(ctx: ServiceCtx, from: WorkOrderStatus, to: WorkOrderStatus): boolean { export function mayTransition(ctx: ServiceCtx, from: WorkOrderStatus, to: WorkOrderStatus): boolean {
@@ -116,8 +117,7 @@ export async function applyTransition(
return { id: wo.id, status: to, version, from }; return { id: wo.id, status: to, version, from };
} }
/** L14: billing overview active for the tenant (missing TenantModule row = enabled, like requireModule). */ /** L14: billing overview active for the tenant (missing TenantModule row = enabled, like requireModule; L17: and part of the package tier). */
export async function billingModuleActive(ctx: ServiceCtx): Promise<boolean> { export async function billingModuleActive(ctx: ServiceCtx): Promise<boolean> {
const row = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "billing" }, select: { enabled: true } }); return isModuleActive(ctx.tenantId, "billing");
return !row || row.enabled;
} }