diff --git a/docs/craftvia/API.md b/docs/craftvia/API.md index 48ee815..9576f7c 100644 --- a/docs/craftvia/API.md +++ b/docs/craftvia/API.md @@ -20,7 +20,7 @@ Alle Routen antworten im Fehlerfall mit `Cache-Control: no-store` und | Code | HTTP | Bedeutung / `details` | |---|---|---| | `unauthorized` | 401 | nicht angemeldet, Konto inaktiv, Sitzung invalidiert | -| `forbidden` | 403 | Recht fehlt, Modul deaktiviert, Passwortwechsel nötig, Cross-Site-Request | +| `forbidden` | 403 | Recht fehlt, Modul deaktiviert, Passwortwechsel nötig, Cross-Site-Request; Modul/Funktion nicht im Paket (L17: `details = { reason: "not_in_plan", module?, message }`) | | `not_found` | 404 | unbekannt, fremder Mandant oder außerhalb des Scopes | | `conflict` | 409 | Versionskonflikt (`baseVersion`), Doppelbestätigung/unzulässiger Zustand, mögliche Dubletten (`details.reason = "possible_duplicates"`, `details.candidates`) | | `invalid` | 422 | Validierung (Zod: `details = [{ path, code }]`), fehlerhaftes JSON/Multipart | @@ -40,6 +40,7 @@ Alle Routen antworten im Fehlerfall mit `Cache-Control: no-store` und - **Idempotenz:** Eine wiederholte `clientOpId` (je Mandant) liefert `duplicate` mit dem gespeicherten Ergebnis. Ist die ID bereits durch einen anderen Nutzer belegt, wird die Operation `rejected`. - **Konflikte:** `work_order.transition` und `report.submit` verlangen `baseVersion`. Weicht sie von `WorkOrder.version` ab, lautet das Ergebnis `conflict`, `entityVersion` ist dann die aktuelle Version. Alle anderen Operationen sind additiv (Client-IDs in den Payloads, z. B. `clientId`, werden über `idMap` auf Server-IDs abgebildet). - Den opType-Katalog mit den Payload-Schemas enthält `src/lib/sync/ops.ts` (Spec: Komponenten `SyncPayload*`). +- L17 Pakete: Operationen eines Moduls außerhalb des Pakets bzw. eines ausgeschalteten Moduls (`emergency.create` → Notdienst, `milestone.reach` → Abrechnung) werden gespeichert als `rejected` mit `errorCode = "forbidden"` und `message = "not_in_plan: "` bzw. `"module_disabled: "`. - REST-Schreibrouten für Aufträge (`PATCH /work-orders/{id}`, `/assign`, `/transition`) akzeptieren optional `baseVersion` und antworten bei Abweichung mit `409`. ## Uploads diff --git a/docs/craftvia/DEPLOY.md b/docs/craftvia/DEPLOY.md index 86cb870..9b5f73c 100644 --- a/docs/craftvia/DEPLOY.md +++ b/docs/craftvia/DEPLOY.md @@ -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 Datenschutz (`messages/*/trial.json` → `legal.*`) ersetzen. +### 7.5 Pakete Basis/Profi und Lotse-Chat-Plätze (L17) + +Die App rechnet nicht ab – sie setzt das Paket durch, verwaltet Chat-Plätze und zählt die Nutzung, +damit der Betreiber außerhalb der App die Rechnung stellt. Regeln: `src/lib/plans.ts`, Durchsetzung: +`src/server/plan.ts`. + +| Paket | Inhalt | +|---|---| +| **Basis** | Aufträge, Kunden, Objekte, Teams, mobile App/Offline, Berichte + Unterschrift + PDF-Versand, Zeiterfassung inkl. Freigabe, Dokumente, Benachrichtigungen – **ohne KI und Planung** | +| **Profi** (Default) | zusätzlich Planung (Plantafel, Live-Lage, Kapazität, Empfehlungen, Verzugs-/Frühfertig-Hinweise), Abrechnungsübersicht, Notdienst, Auftragsimport, Lotse (Berichtsentwurf, Vollständigkeitsprüfung, Sprachnotiz-Zusammenfassung) und – je Platz – der Lotse-Chat | + +**Paket, Plätze, hartes Limit setzen** (nur Plattform-Voll-Admins): Betreiber-Portal → Mandant +öffnen → Karte „Paket & Lotse-Chat“ → „Paket ändern …“ → Paket, Anzahl gebuchter Chat-Plätze +(0–1000) und „Hartes Limit“ wählen, Bestätigung ankreuzen, speichern. Jede Änderung steht im +Audit-Trail des Mandanten (`tenant_plan`, Scope Plattform, vorher/nachher). Die Mandantenliste zeigt +das Paket als Badge. Mandanten-Admins sehen das Paket nur lesend und vergeben unter +`/settings/lotse` die gebuchten Plätze an Nutzer mit Feldrolle (`lotse:use` + `field:execute`); +mehr als gebucht ist serverseitig ausgeschlossen. Senkt der Betreiber die Anzahl unter die vergebenen +Plätze, gelten nur die zuerst vergebenen; der Admin sieht „Über der Platzanzahl“ und entzieht Plätze. + +**Wechsel Profi → Basis:** Daten bleiben vollständig erhalten (Einsätze, Abrechnungseinträge, Plätze, +Chatverläufe), nur der Zugriff endet: Seiten leiten auf das Dashboard mit dem Hinweis „Im Paket Profi +enthalten“, Actions/API antworten `403` (`details.reason = "not_in_plan"`), Offline-Operationen eines +gesperrten Moduls (Notdienst, Meilensteine) werden mit Klartext abgewiesen. Zurück auf Profi ist alles +wieder da. **Testphase** (`plan = TRIAL`) läuft immer als Profi ohne Platzprüfung. + +**Kontingent und Mehrverbrauch ablesen:** Ein Chat = eine Nachricht eines Nutzers an den Lotsen. Je +vergebenem Platz 150 Chats pro Kalendermonat (Zeitzone des Mandanten), gemeinsam für den Betrieb +(Testphase: berechtigte Nutzer × 150). Darüber wird weitergezählt; abgerechnet wird der Mehrverbrauch in +Paketen à 100 (angefangene Pakete zählen). Die Karte im Betreiber-Portal zeigt für den **laufenden +Monat und den Vormonat** „x von y Chats“, den Mehrverbrauch und die Anzahl der 100er-Pakete – das ist +die Grundlage der Rechnung (Vormonat nach Monatsende ablesen). Mit „Hartes Limit“ wird der Chat beim +Kontingent gesperrt (Monteure sehen einen Hinweis statt des Eingabefelds, kein Mehrverbrauch). Die +Token-Obergrenze (`AI_MONTHLY_TOKEN_LIMIT`, `services/lotse/budget.ts`) bleibt als Sicherheitsnetz +unabhängig davon aktiv. Hinweis: Chatverläufe werden nach `AI_GENERATION_RETENTION_DAYS` gelöscht – +den Wert über ~62 Tagen lassen, sonst fehlen Vormonats-Chats in der Zählung. + ## 8. Rate Limits | Bereich | Env | Default | Zählung | diff --git a/docs/craftvia/lanes/pakete.md b/docs/craftvia/lanes/pakete.md new file mode 100644 index 0000000..04190a1 --- /dev/null +++ b/docs/craftvia/lanes/pakete.md @@ -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: ` 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/` (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/?plan=edit` | Plattform-Voll-Admin | Karte „Paket & Lotse-Chat“ + Popup „Paket & Chat-Plätze ändern“ | diff --git a/messages/de/lotse.json b/messages/de/lotse.json index be3cd64..06dad6c 100644 --- a/messages/de/lotse.json +++ b/messages/de/lotse.json @@ -317,11 +317,15 @@ "transition_forbidden": "Dafür fehlt die Berechtigung.", "not_editable": "Der Bericht ist eingereicht und kann nicht mehr geändert werden.", "reason_required": "Der Grund fehlt.", - "use_billing_overview": "Nur über die Abrechnungsübersicht möglich." + "use_billing_overview": "Nur über die Abrechnungsübersicht möglich.", + "not_in_plan": "Der Lotse ist im Paket Profi enthalten und für diesen Betrieb nicht freigeschaltet.", + "no_seat": "Der Lotse-Chat ist für dich noch nicht freigeschaltet – frag im Büro nach.", + "quota_exhausted": "Das Chat-Kontingent des Betriebs ist für diesen Monat aufgebraucht." }, "unavailable": { "title": "Lotse-Chat nicht verfügbar", "back": "Zur Übersicht" - } + }, + "quotaExhausted": "Das Chat-Kontingent des Betriebs ist für diesen Monat aufgebraucht. Ab dem nächsten Monat kannst du wieder schreiben – bei Fragen hilft das Büro." } } diff --git a/messages/de/offline.json b/messages/de/offline.json index 89336f8..edca747 100644 --- a/messages/de/offline.json +++ b/messages/de/offline.json @@ -92,7 +92,9 @@ "internal": "Technischer Fehler – wird automatisch erneut versucht.", "timeOverlap": "Der Zeitnachtrag überschneidet sich mit einer bereits erfassten Zeit und wurde nicht übernommen.", "timeWindow": "Der Zeitnachtrag liegt außerhalb der erlaubten 7 Tage, in der Zukunft oder ist zu lang und wurde nicht übernommen.", - "otherSession": "Auf einem anderen Auftrag lief noch eine Zeiterfassung – der Start wurde nicht übernommen. Bitte erneut starten und wechseln." + "otherSession": "Auf einem anderen Auftrag lief noch eine Zeiterfassung – der Start wurde nicht übernommen. Bitte erneut starten und wechseln.", + "notInPlan": "Im Paket Profi enthalten – für diesen Betrieb nicht freigeschaltet. Die Eingabe wurde nicht übernommen, bitte im Büro melden.", + "moduleDisabled": "Diese Funktion ist für diesen Betrieb ausgeschaltet. Die Eingabe wurde nicht übernommen, bitte im Büro melden." }, "view": { "title": "Offline-Ansicht", diff --git a/messages/de/plans.json b/messages/de/plans.json new file mode 100644 index 0000000..8de00bf --- /dev/null +++ b/messages/de/plans.json @@ -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." + } + } +} diff --git a/messages/en/lotse.json b/messages/en/lotse.json index aa89269..69ceb32 100644 --- a/messages/en/lotse.json +++ b/messages/en/lotse.json @@ -317,11 +317,15 @@ "transition_forbidden": "You lack the permission.", "not_editable": "The report has been submitted and can no longer be changed.", "reason_required": "The reason is missing.", - "use_billing_overview": "Only possible via the billing overview." + "use_billing_overview": "Only possible via the billing overview.", + "not_in_plan": "The Lotse is included in the Profi package and not activated for this company.", + "no_seat": "The Lotse chat is not activated for you yet – please ask the office.", + "quota_exhausted": "The company's chat quota for this month is used up." }, "unavailable": { "title": "Lotse chat not available", "back": "Back to overview" - } + }, + "quotaExhausted": "The company's chat quota for this month is used up. You can write again from next month – the office can help with questions." } } diff --git a/messages/en/offline.json b/messages/en/offline.json index b2ec4a9..889c76e 100644 --- a/messages/en/offline.json +++ b/messages/en/offline.json @@ -92,7 +92,9 @@ "internal": "Technical error – will retry automatically.", "timeOverlap": "The added time overlaps with time already recorded and was not accepted.", "timeWindow": "The added time is outside the allowed 7 days, in the future or too long and was not accepted.", - "otherSession": "Time was still running on another order – the start was not accepted. Please start again and switch." + "otherSession": "Time was still running on another order – the start was not accepted. Please start again and switch.", + "notInPlan": "Included in the Profi package – not activated for this company. The entry was not applied, please contact the office.", + "moduleDisabled": "This function is switched off for this company. The entry was not applied, please contact the office." }, "view": { "title": "Offline view", diff --git a/messages/en/plans.json b/messages/en/plans.json new file mode 100644 index 0000000..561249f --- /dev/null +++ b/messages/en/plans.json @@ -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." + } + } +} diff --git a/prisma/migrations/20260921100000_pakete/migration.sql b/prisma/migrations/20260921100000_pakete/migration.sql new file mode 100644 index 0000000..b84a893 --- /dev/null +++ b/prisma/migrations/20260921100000_pakete/migration.sql @@ -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'); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9761c17..3051b84 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -61,6 +61,13 @@ model Tenant { trialExpiredNoticeAt DateTime? @map("trial_expired_notice_at") trialDeletionNoticeAt DateTime? @map("trial_deletion_notice_at") + // L17 Pakete: Stufe (Basis ohne KI/Planung) + Lotse-Chat-Plätze (Plattform-Daten, nur Betreiber ändert) + tier TenantTier @default(PROFI) + /// Anzahl gekaufter Lotse-Chat-Plätze (Vergabe durch den Mandanten-Admin) + lotseChatSeats Int @default(0) @map("lotse_chat_seats") + /// true = nach Ausschöpfen des Monatskontingents gesperrt statt Mehrverbrauch + lotseChatHardLimit Boolean @default(false) @map("lotse_chat_hard_limit") + users User[] roles Role[] auditLogs AuditLog[] @@ -1505,6 +1512,8 @@ model LotseMessage { conversation LotseConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) @@index([tenantId, conversationId, createdAt]) + // L17 Pakete: Monatszählung der Chats (role = user) je Mandant + @@index([tenantId, role, createdAt]) @@map("lotse_messages") } @@ -1595,3 +1604,23 @@ model TenantExport { @@index([tenantId, createdAt]) @@map("tenant_exports") } + +// ── L17 Pakete (Basis/Profi) & Lotse-Chat-Plätze ─────────────────────────────── + +enum TenantTier { + BASIS + PROFI +} + +/// Vergebener Lotse-Chat-Platz (Mandanten-Admin vergibt bis zur vom Betreiber gesetzten Anzahl). +model LotseChatSeat { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + userId String @map("user_id") + assignedById String? @map("assigned_by_id") + createdAt DateTime @default(now()) @map("created_at") + + @@unique([tenantId, userId]) + @@index([tenantId, createdAt]) + @@map("lotse_chat_seats") +} diff --git a/scripts/check-module-guards.ts b/scripts/check-module-guards.ts index 4ee9524..e636da2 100644 --- a/scripts/check-module-guards.ts +++ b/scripts/check-module-guards.ts @@ -61,6 +61,8 @@ const ACTION_MODULE: Record = { // (requireSession + requirePermission + requireApiContext; Export bewusst auch im Nur-Lesen-Zustand) "trial-platform.ts": "EXEMPT", "trial-tenant.ts": "EXEMPT", + // L17 Pakete: Stufe/Chat-Plätze/hartes Limit durch den Betreiber (requirePlatformFullAdmin) + "plans-platform.ts": "EXEMPT", // L15 Testphase: öffentliche Selbstanmeldung ohne Session — jede Action MUSS das Rate-Limit prüfen "trial-signup.ts": "PUBLIC", }; diff --git a/scripts/lib/demo-seed.ts b/scripts/lib/demo-seed.ts index 911b1d5..bbb143a 100644 --- a/scripts/lib/demo-seed.ts +++ b/scripts/lib/demo-seed.ts @@ -422,10 +422,26 @@ export async function seedDemoTenant(tenantId: string, users: DemoUsers): Promis }); } + // ---------- L17 Pakete: Profi mit 3 Lotse-Chat-Plätzen für die Demo-Monteure (idempotent) ---------- + await seedDemoPlan(tenantId, { tier: "PROFI", seats: 3, seatUserIds: [users.tech, users.tech2, users.tech3], assignedById: users.admin }); + const orders = await bo.db.workOrder.count({ where: { OR: [{ externalOrderNumber: { startsWith: "DEMO-" } }, { isEmergency: true, emergencyReason: { startsWith: "Wasserrohrbruch im Keller" } }] } }); return { orders, created }; } +/** + * L17 Pakete: package tier + chat seats of a demo tenant. Idempotent (seat rows per user are unique); + * existing seats of other users are left alone. + */ +export async function seedDemoPlan(tenantId: string, opts: { tier: "BASIS" | "PROFI"; seats: number; seatUserIds?: string[]; assignedById?: string }): Promise { + await prisma.tenant.update({ where: { id: tenantId }, data: { tier: opts.tier, lotseChatSeats: opts.seats, lotseChatHardLimit: false } }); + const db = dbForTenant(tenantId); + for (const userId of opts.seatUserIds ?? []) { + const existing = await db.lotseChatSeat.findFirst({ where: { userId }, select: { id: true } }); + if (!existing) await db.lotseChatSeat.create({ data: { tenantId, userId, assignedById: opts.assignedById ?? null } }); + } +} + /** Isolation fixture for the second tenant: one customer + one work order. */ export async function seedDemo2Tenant(tenantId: string, adminUserId: string): Promise { const admin = ctxOf(tenantId, adminUserId, "tenant-admin"); @@ -437,6 +453,8 @@ export async function seedDemo2Tenant(tenantId: string, adminUserId: string): Pr const site = await createSite(admin, { customerId: customer.id, name: "Schaltanlage Halle 2", street: "Am Hafen", houseNumber: "3", postalCode: "24103", city: "Kiel", accessNotes: "Anmeldung beim Werkschutz." }); await createWorkOrder(admin, { title: "Prüfung Unterverteilung nach DGUV V3", customerId: customer.id, siteId: site.id, externalOrderNumber: "DEMO2-01", status: "planned", plannedStart: day(3, 8), plannedEnd: day(3, 12) }); } + // L17 Pakete: demo2 = Basis (zeigt die Sperren: keine Planung, Abrechnung, Notdienst, Import, Lotse) + await seedDemoPlan(tenantId, { tier: "BASIS", seats: 0 }); } /** Close the owner client used by the services (the seed has its own client). */ diff --git a/scripts/lib/e2e-fixture.ts b/scripts/lib/e2e-fixture.ts index ee3f108..9dcc769 100644 --- a/scripts/lib/e2e-fixture.ts +++ b/scripts/lib/e2e-fixture.ts @@ -134,6 +134,7 @@ export async function cleanupTenants(slugs: string[]): Promise { await prisma.mailLog.deleteMany(w); await prisma.authToken.deleteMany(w); await prisma.aiGeneration.deleteMany(w); + await prisma.lotseChatSeat.deleteMany(w); // L17 Pakete await prisma.signature.deleteMany(w); await prisma.report.deleteMany(w); await prisma.photo.deleteMany(w); diff --git a/scripts/lib/lotse-chat-fixture.ts b/scripts/lib/lotse-chat-fixture.ts index e570c26..21ee926 100644 --- a/scripts/lib/lotse-chat-fixture.ts +++ b/scripts/lib/lotse-chat-fixture.ts @@ -36,6 +36,7 @@ export async function cleanupChatTenants(prefix: string) { const ids = tenants.map((t) => t.id); if (ids.length) { const w = { where: { tenantId: { in: ids } } }; + await prisma.lotseChatSeat.deleteMany(w); // L17 Pakete await prisma.lotseActionProposal.deleteMany(w); await prisma.lotseMessage.deleteMany(w); await prisma.lotseConversation.deleteMany(w); @@ -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" } }); const wo4 = await order("A-LC-4", { customerId: company.id, siteId: site2.id, title: "Regler einstellen", status: "in_progress", signatureRequired: false, ...tomorrow }, [tech.id]); + // L17 Pakete: the chat needs a seat per user (Profi is the default tier) + await prisma.tenant.updateMany({ where: { id: { in: [tA.id, tB.id] } }, data: { lotseChatSeats: 10 } }); + for (const u of [tech, tech2, outsider, admin]) await prisma.lotseChatSeat.create({ data: { tenantId: tA.id, userId: u.id } }); + await prisma.lotseChatSeat.create({ data: { tenantId: tB.id, userId: techB.id } }); + const customerB = await prisma.customer.create({ data: { tenantId: tB.id, companyName: "Kunde B" } }); const woB = await prisma.workOrder.create({ data: { tenantId: tB.id, number: "B-LC-1", customerId: customerB.id, title: "Auftrag B", status: "in_progress", ...today } }); await prisma.workOrderAssignee.create({ data: { tenantId: tB.id, workOrderId: woB.id, userId: techB.id } }); diff --git a/scripts/smoke-auth.ts b/scripts/smoke-auth.ts index e97a54b..f55b133 100644 --- a/scripts/smoke-auth.ts +++ b/scripts/smoke-auth.ts @@ -76,6 +76,9 @@ function plans(x: Awaited>): Plan[] { { path: "/settings/audit" }, { path: "/settings/email" }, { path: "/settings/lotse", mustContain: "Lotse-Chat für Monteure" }, + // L17 Pakete: Paket nur lesend, Plätze + Verbrauch + { path: "/settings/lotse", mustContain: ["Paket & Lotse-Chat", "Profi", "3 von 3 Plätzen vergeben", "Max Monteur", "Platz entziehen", "Chats"] }, + { path: "/settings", mustContain: "Freigeschaltete Module" }, { path: "/settings/lotse/protocol" }, { path: "/settings/order-types", mustContain: "Montage" }, { path: "/settings/checklists", mustContain: "Montage Heizung/Sanitär" }, @@ -154,6 +157,10 @@ function plans(x: Awaited>): Plan[] { // L13 Planung: team lead read-only, own team only { path: "/planning", mustContain: ["Plantafel", "Team Nord", "Nur Lesezugriff"], mustNotContain: ["Team Süd"] }, { path: "/planning/live", mustContain: "keine GPS-Ortung", mustNotContain: ["Team Süd"] }, + // L17 Pakete: Teamleiterin ohne Chat-Platz → kein Navigationseintrag, freundlicher Hinweis + { path: "/m", mustNotContain: ['href="/m/lotse"'] }, + { path: "/m/lotse", mustContain: "frag im Büro nach", mustNotContain: ['id="lotse-chat-input"'] }, + ...(x.d14 ? [{ path: m(x.d14), mustContain: x.d14.number, mustNotContain: [`href="/m/lotse?order=${x.d14.id}"`] }] : []), ], }, { @@ -189,6 +196,7 @@ function plans(x: Awaited>): Plan[] { { path: "/m/lotse", mustContain: ["Lotse-Chat", "Neuer Chat", "Sprechen"] }, { path: `/m/lotse?order=${x.d07.id}`, mustContain: [x.d07.number, "Lotse-Chat"] }, { path: m(x.d07), mustContain: "Lotse fragen" }, + { path: "/m", mustContain: ['href="/m/lotse"', 'href="/m/emergency"'] }, // L17: Platz vergeben, Profi ...(x.d08 ? [{ path: `/m/lotse?order=${x.d08.id}`, expect: [404] }] : []), // L14 Abrechnungsübersicht: Meilensteine mobil, Backoffice-Übersicht ohne Recht { path: m(x.d07), mustContain: ["Meilensteine", "Smoke-Meilenstein", "Erreicht melden"] }, @@ -224,15 +232,25 @@ function plans(x: Awaited>): Plan[] { { path: `/customers/${x.customer.id}`, expect: [404] }, { path: `/sites/${x.site.id}`, expect: [404] }, { path: `/reports/${x.approved.id}`, expect: [404] }, - ...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, expect: [404] }] : []), - ...(x.imp ? [{ path: `/imports/${x.imp.id}`, expect: [404] }] : []), + // L17: demo2 is Basis → module layouts redirect before the (foreign) id is looked up + ...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, expect: [307, 404] }] : []), + ...(x.imp ? [{ path: `/imports/${x.imp.id}`, expect: [307, 404] }] : []), ...(x.photo ? [{ path: `/files/${x.photo.documentId}`, expect: [404] }] : []), - { path: "/planning", mustNotContain: ["Team Nord", x.d07.number] }, - { path: "/planning/live", mustNotContain: ["Team Nord", "Max Monteur"] }, - { path: `/api/v1/planning/recommendations?workOrderId=${x.d07.id}`, expect: [404] }, - // L14 Abrechnungsübersicht: keine Einträge von demo - { path: "/billing", mustContain: "Abrechnungsübersicht", mustNotContain: [x.d18.number, "Elbblick"] }, - ...(x.billing ? [{ path: `/billing/${x.billing.id}`, expect: [404] }, { path: `/billing/print?ids=${x.billing.id}`, mustNotContain: [x.d18.number] }] : []), + // L17 Pakete: demo2 = Basis → Planung, Abrechnung, Import, Notdienst, Lotse gesperrt (ruhiger Hinweis) + { path: "/planning", expect: [307], redirectTo: "/dashboard" }, + { path: "/planning/live", expect: [307], redirectTo: "/dashboard" }, + { path: `/api/v1/planning/recommendations?workOrderId=${x.d07.id}`, expect: [403, 404] }, + { path: "/api/v1/planning/board", expect: [403] }, + { path: "/billing", expect: [307], redirectTo: "/dashboard" }, + { path: "/imports", expect: [307], redirectTo: "/dashboard" }, + { path: "/work-orders/emergency-review", expect: [307], redirectTo: "/dashboard" }, + { path: "/api/v1/billing", expect: [403] }, + { path: "/dashboard?module=profi", mustContain: ["Im Paket Profi enthalten", "Offene Aufträge"], mustNotContain: ['href="/planning"', 'href="/billing"', 'href="/imports"', 'href="/work-orders/emergency-review"'] }, + { path: "/settings/lotse", mustContain: ["Paket & Lotse-Chat", "Basis", "im Paket Profi enthalten"] }, + { path: "/work-orders", mustContain: "DGUV" }, + { path: "/customers" }, + { path: "/reports" }, + ...(x.billing ? [{ path: `/billing/${x.billing.id}`, expect: [307, 404] }] : []), { path: wo(x.d07, "?tab=billing"), expect: [404] }, ], }, diff --git a/scripts/test-e2e-tenant-isolation.ts b/scripts/test-e2e-tenant-isolation.ts index bb6de7c..1171457 100644 --- a/scripts/test-e2e-tenant-isolation.ts +++ b/scripts/test-e2e-tenant-isolation.ts @@ -142,6 +142,7 @@ async function createModelRows(A: TenantFixture): Promise { put("LotseMessage", (await prisma.lotseMessage.create({ data: { tenantId: t, conversationId: lotseConversation.id, role: "user", text: "ZZ" } })).id, { text: MARK }); put("LotseActionProposal", (await prisma.lotseActionProposal.create({ data: { tenantId: t, conversationId: lotseConversation.id, userId: uid, kind: "add_note", payload: {}, payloadHash: "0".repeat(64), expiresAt: new Date(Date.now() + 1_800_000) } })).id, { kind: MARK }); put("TenantExport", (await prisma.tenantExport.create({ data: { tenantId: t, status: "done", fileName: "zz.zip" } })).id, { fileName: MARK }); // L15 Testphase + put("LotseChatSeat", (await prisma.lotseChatSeat.create({ data: { tenantId: t, userId: uid } })).id, { assignedById: MARK }); // L17 Pakete return rows; } diff --git a/scripts/test-pakete-gates.ts b/scripts/test-pakete-gates.ts new file mode 100644 index 0000000..03a924f --- /dev/null +++ b/scripts/test-pakete-gates.ts @@ -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] } } }); + } +}); diff --git a/scripts/test-pakete-rules.ts b/scripts/test-pakete-rules.ts new file mode 100644 index 0000000..069b013 --- /dev/null +++ b/scripts/test-pakete-rules.ts @@ -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); diff --git a/scripts/test-pakete-seats.ts b/scripts/test-pakete-seats.ts new file mode 100644 index 0000000..d5f4c48 --- /dev/null +++ b/scripts/test-pakete-seats.ts @@ -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): Promise { + 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]); + } +}); diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 9e812f6..b93b132 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -27,6 +27,8 @@ import { getDashboardTiles } from "@/server/services/work-orders/dashboard"; import { customerDisplayName, customerFilterOptions, teamOptions, userOptions } from "@/server/services/work-orders/options"; import { listOrderTypes } from "@/server/services/work-orders/settings"; import { GettingStarted } from "@/components/trial/getting-started"; +import { ProfiHint } from "@/components/plans/profi-hint"; +import { effectiveModules } from "@/server/plan"; type SP = Record; @@ -54,8 +56,9 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr const t = await getTranslations("dashboard"); const tw = await getTranslations("workOrders"); - const moduleRow = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "work_orders" }, select: { enabled: true } }); - const moduleEnabled = !moduleRow || moduleRow.enabled; + // L17 Pakete: effektive Module (Schalter + Paketstufe) zentral + const modules = await effectiveModules(ctx.tenantId); + const moduleEnabled = !modules.inactive.has("work_orders"); const f = parseListParams(sp); const filter = { from: f.from, to: f.to, customerId: f.customerId, teamId: f.teamId, userId: f.userId, orderTypeId: f.orderTypeId, priority: f.priority }; @@ -72,6 +75,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr {t("moduleDisabled")}

)} + {sp.module === "profi" && } {/* L17 Pakete: Funktion nicht in der Paketstufe */} {!tiles ? (

{t("workOrdersDisabled")}

@@ -148,7 +152,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
    - {TILES.filter((tile) => (tile.key !== "sync_conflicts" || can("work_order:write")) && (tile.key !== "time_approvals" || can("time:approve")) && (tile.key !== "billing_ready" || can("billing:read"))).map(({ key, icon: Icon, tone }) => { + {TILES.filter((tile) => (tile.key !== "sync_conflicts" || can("work_order:write")) && (tile.key !== "time_approvals" || can("time:approve")) && (tile.key !== "billing_ready" || (can("billing:read") && !modules.inactive.has("billing"))) && (tile.key !== "emergency_new" || !modules.inactive.has("emergency"))).map(({ key, icon: Icon, tone }) => { const count = key === "billing_ready" ? tiles.billingReady : key === "sync_conflicts" ? tiles.syncConflicts : key === "time_approvals" ? tiles.timeApprovals : key === "reports_in_review" ? tiles.reportsToReview : tiles[key]; const href = key === "billing_ready" ? "/billing" : key === "sync_conflicts" ? "/work-orders/conflicts" : key === "time_approvals" ? "/work-orders/time-approvals" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`; return ( diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index b892683..ba8618b 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -3,6 +3,7 @@ import { getTranslations } from "next-intl/server"; import { signOut } from "@/server/auth"; import { dbForTenant } from "@/server/db"; import { requireAppAccess } from "@/server/app-access"; +import { effectiveModules } from "@/server/plan"; import { NAV_ITEMS, visibleNavItems } from "@/lib/nav"; import { resolveTenantBranding } from "@/lib/brand"; import { Button } from "@/components/ui/button"; @@ -30,17 +31,18 @@ export default async function AppLayout({ // Navigation aus src/lib/nav.ts, gefiltert nach aktiven Modulen + Rechten (Komfort; // Seiten/Actions prüfen serverseitig selbst). - const [moduleRows, brandingSettings] = await Promise.all([ - dbForTenant(session.user.tenantId).tenantModule.findMany(), + // L17 Pakete: effektive Module (TenantModule + Paketstufe) zentral aus src/server/plan.ts + const [modules, brandingSettings] = await Promise.all([ + effectiveModules(session.user.tenantId), dbForTenant(session.user.tenantId).tenantSettings.findUnique({ where: { tenantId: session.user.tenantId }, select: { accent: true }, }), ]); const branding = resolveTenantBranding(brandingSettings); - const disabledModules = new Set(moduleRows.filter((m) => !m.enabled).map((m) => m.moduleKey)); const navItems = visibleNavItems(NAV_ITEMS, { - disabledModules, + disabledModules: modules.inactive, + lockedFeatures: modules.lockedFeatures, permissions: session.user.permissions ?? [], }); const mainNav = navItems.filter((i) => i.section === "main"); diff --git a/src/app/(app)/planning/layout.tsx b/src/app/(app)/planning/layout.tsx index 0ddf0c6..96485f3 100644 --- a/src/app/(app)/planning/layout.tsx +++ b/src/app/(app)/planning/layout.tsx @@ -1,7 +1,9 @@ import { requireModule } from "@/server/modules"; +import { requirePlanFeature } from "@/server/plan"; -/** Modul-Gate „work_orders" für Plantafel und Live-Lage (L13 Planung). */ +/** Modul-Gate „work_orders" + Paket-Feature „planning" (L17) für Plantafel und Live-Lage (L13 Planung). */ export default async function PlanningLayout({ children }: Readonly<{ children: React.ReactNode }>) { - await requireModule("work_orders"); + const session = await requireModule("work_orders"); + await requirePlanFeature(session.user.tenantId, "planning"); // L17 Pakete: Planung nur in Profi return <>{children}; } diff --git a/src/app/(app)/settings/lotse/page.tsx b/src/app/(app)/settings/lotse/page.tsx index a74f5e9..344a76a 100644 --- a/src/app/(app)/settings/lotse/page.tsx +++ b/src/app/(app)/settings/lotse/page.tsx @@ -3,6 +3,7 @@ import { redirect } from "next/navigation"; import { getLocale, getTranslations } from "next-intl/server"; import { ArrowLeft, CheckCircle2, ListChecks, MinusCircle, ShieldCheck, XCircle } from "lucide-react"; import { LotseMark } from "@/components/lotse/lotse-mark"; +import { LotseSeatAdmin } from "@/components/lotse/chat/seat-admin"; import { PageHead } from "@/components/mockup-ui"; import { Button } from "@/components/ui/button"; import { saveLotseSettings } from "@/server/actions/lotse-settings"; @@ -24,7 +25,7 @@ function ProviderStatus({ ok, labels }: { ok: boolean; labels: { ok: string; off * /settings/lotse (tenant:manage): Lotse on/off (module toggle `lotse`), address form, transparency on * which data goes to which provider, link to the AI log. Deliberately not module-gated (re-enabling). */ -export default async function LotseSettingsPage({ searchParams }: { searchParams: Promise<{ saved?: string; error?: string }> }) { +export default async function LotseSettingsPage({ searchParams }: { searchParams: Promise<{ saved?: string; error?: string; seat?: string; seatError?: string }> }) { const ctx = await readCtx(); if (!can(ctx, "tenant:manage")) redirect("/dashboard"); const [sp, s, t, locale] = await Promise.all([searchParams, getLotseSettings(ctx), getTranslations("lotse"), getLocale()]); @@ -159,6 +160,9 @@ export default async function LotseSettingsPage({ searchParams }: { searchParams + + {/* L17 Pakete: Paket (nur lesend), Lotse-Chat-Plätze, Monatsverbrauch */} + ); } diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index ffc63b0..a899783 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -12,6 +12,7 @@ import { BRAND } from "@/lib/brand"; import { updateTenantSettings } from "@/server/actions/tenant-settings"; import { ShieldCheck, History } from "lucide-react"; import { AuditTrailModal, type AuditRow } from "@/components/audit-trail"; +import { effectiveModules } from "@/server/plan"; // TODO(i18n): Texte dieser Seite in messages//settings.json überführen // (aus dem Fundament übernommen; nicht Teil des Rückbaus). @@ -28,9 +29,9 @@ export default async function SettingsPage({ const db = dbForTenant(session.user.tenantId); const sp = await searchParams; - const [s, moduleRows] = await Promise.all([ + const [s, modules] = await Promise.all([ db.tenantSettings.findUnique({ where: { tenantId: session.user.tenantId } }), - db.tenantModule.findMany(), + effectiveModules(session.user.tenantId), // L17 Pakete: Schalter + Paketstufe ]); // Audit-Trail nur laden, wenn das Popup offen ist (?audit=1). Der Mandanten- @@ -50,7 +51,7 @@ export default async function SettingsPage({ } } // Fehlende TenantModule-Zeile ⇒ Modul gilt als aktiv (Default an). - const disabled = new Set(moduleRows.filter((m) => !m.enabled).map((m) => m.moduleKey)); + const disabled = modules.inactive; const v = (x?: string | null) => x ?? ""; return ( diff --git a/src/app/(app)/work-orders/[id]/page.tsx b/src/app/(app)/work-orders/[id]/page.tsx index 551480b..772256d 100644 --- a/src/app/(app)/work-orders/[id]/page.tsx +++ b/src/app/(app)/work-orders/[id]/page.tsx @@ -29,7 +29,7 @@ import { FINAL_STATUSES, PLANNING_LOCKED } from "@/server/services/work-orders/_ import { availableTransitions, getWorkOrderDetail } from "@/server/services/work-orders/detail"; import { customerDisplayName, customerOption, teamOptions, userOptions } from "@/server/services/work-orders/options"; import { listOrderTypes } from "@/server/services/work-orders/settings"; -import { reasonRequired } from "@/server/services/work-orders/transition"; +import { billingModuleActive, reasonRequired } from "@/server/services/work-orders/transition"; const TABS = ["overview", "checklist", "material", "times", "photos", "notes", "reports", "billing", "documents", "history"] as const; // L14: billing type Tab = (typeof TABS)[number]; @@ -103,8 +103,7 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa ]); // L14: with the billing overview active, "billed" is set only there (snapshot + position assignment) → no direct button - const billingModule = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "billing" }, select: { enabled: true } }); - const billingOverviewActive = !billingModule || billingModule.enabled; + const billingOverviewActive = await billingModuleActive(ctx); // L17: incl. package tier const billingTransitions = transitions.filter( (to) => (to === "released_for_billing" || (to === "billed" && !billingOverviewActive)) || @@ -188,7 +187,7 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa
    ({ 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 }))} />
    @@ -211,7 +210,7 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa } /> )} - {tab === "billing" && } + {tab === "billing" && billingOverviewActive && } {tab === "documents" && } {tab === "history" && } diff --git a/src/app/(field)/m/(core)/lotse/layout.tsx b/src/app/(field)/m/(core)/lotse/layout.tsx index c4faabe..3794bfe 100644 --- a/src/app/(field)/m/(core)/lotse/layout.tsx +++ b/src/app/(field)/m/(core)/lotse/layout.tsx @@ -1,7 +1,15 @@ +import { requireSession } from "@/server/auth"; import { requireModule } from "@/server/modules"; +import { moduleState } from "@/server/plan"; +import { LotseChatUnavailable } from "@/components/lotse/chat/unavailable"; -/** Modul-Gate „lotse" für den Lotse-Chat (L16); „field" gilt bereits über (core)/layout.tsx. */ +/** + * Modul-Gate „lotse" für den Lotse-Chat (L16); „field" gilt bereits über (core)/layout.tsx. + * L17 Pakete: nicht in der Paketstufe (Basis) → freundlicher Hinweis statt Weiterleitung aufs Dashboard. + */ export default async function LotseChatLayout({ children }: Readonly<{ children: React.ReactNode }>) { + const session = await requireSession(); + if ((await moduleState(session.user.tenantId, "lotse")) === "not_in_plan") return ; await requireModule("lotse"); return <>{children}; } diff --git a/src/app/(field)/m/(core)/lotse/page.tsx b/src/app/(field)/m/(core)/lotse/page.tsx index 0f67906..d59376f 100644 --- a/src/app/(field)/m/(core)/lotse/page.tsx +++ b/src/app/(field)/m/(core)/lotse/page.tsx @@ -1,17 +1,18 @@ import Link from "next/link"; import { notFound } from "next/navigation"; 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 { isAiConfigured } from "@/server/ai/client"; import { transcriptionConfig } from "@/server/ai/transcription/openai-compatible"; import { ServiceError } from "@/server/services/context"; import { fieldPageContext } from "@/server/services/field/page-context"; +import { isChatQuotaBlocked } from "@/server/services/lotse/chat/access"; import { getChatView } from "@/server/services/lotse/chat/conversations"; import { tenantTimezone } from "@/server/services/work-orders/_shared"; import { LotseMark } from "@/components/lotse/lotse-mark"; import { LotseChat } from "@/components/lotse/chat/lotse-chat"; -import { card } from "@/components/field/ui"; +import { LotseChatUnavailable } from "@/components/lotse/chat/unavailable"; /** * `/m/lotse` — Lotse chat for technicians (lane L16). `?order=` 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.code === "not_found") notFound(); const reason = (err.details as { reason?: string } | undefined)?.reason; - const code = reason === "disabled" || reason === "chat_disabled" ? reason : err.code === "forbidden" ? "forbidden" : "generic"; - return ( -
    -
    -

    - - {t("chat.unavailable.title")} -

    -

    {t(`chat.errors.${code}`)}

    - - {t("chat.unavailable.back")} - -
    -
    - ); + const code = reason && ["disabled", "chat_disabled", "not_in_plan", "no_seat"].includes(reason) ? reason : err.code === "forbidden" ? "forbidden" : "generic"; + return ; } - const timeZone = await tenantTimezone(ctx); + const [timeZone, quotaExhausted] = await Promise.all([tenantTimezone(ctx), isChatQuotaBlocked(ctx)]); // L17: hard limit → hint return (
    {view.workOrder && ( @@ -64,6 +52,7 @@ export default async function LotseChatPage({ searchParams }: { searchParams: Pr transcription={transcriptionConfig().configured} locale={locale} timeZone={timeZone} + quotaExhausted={quotaExhausted} />
    ); diff --git a/src/app/(field)/m/layout.tsx b/src/app/(field)/m/layout.tsx index 937d222..1ed8517 100644 --- a/src/app/(field)/m/layout.tsx +++ b/src/app/(field)/m/layout.tsx @@ -6,6 +6,7 @@ import { fieldPageContext } from "@/server/services/field/page-context"; import { getMyActiveSession } from "@/server/services/field/sessions"; import { countPendingTimeEntries } from "@/server/services/field/time-entries"; import { canUseLotseChat } from "@/server/services/lotse/chat/access"; +import { isModuleActive } from "@/server/plan"; import { cn } from "@/lib/utils"; import { CraftviaLogo } from "@/components/brand/craftvia-logo"; import { AccountInactiveNotice } from "@/components/account-inactive-notice"; @@ -16,20 +17,26 @@ import { OfflineRuntime } from "@/components/offline/offline-runtime"; import { TrialBanner } from "@/components/trial/trial-banner"; /** L12: own running/paused session + open approvals for the shell (never blocks the page). */ -async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number; lotseChat: boolean }> { +async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number; lotseChat: boolean; emergency: boolean }> { try { 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 { clock: session ? { status: session.status, workOrderId: session.workOrderId, number: session.number, title: session.title, segmentType: session.segmentType, segmentStartedAt: session.segmentStartedAt, closedSeconds: session.closedSeconds } : null, approvals, - lotseChat, // L16 + lotseChat, // L16 (L17: inkl. Chat-Platz) + emergency, }; } catch { // 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
    {children}
    - + ); } diff --git a/src/app/(platform)/admin/[id]/page.tsx b/src/app/(platform)/admin/[id]/page.tsx index f692b64..9ffb8cf 100644 --- a/src/app/(platform)/admin/[id]/page.tsx +++ b/src/app/(platform)/admin/[id]/page.tsx @@ -7,6 +7,7 @@ import { platformAuth } from "@/server/platform-auth"; import { Button } from "@/components/ui/button"; import { PageHead, Pill } from "@/components/mockup-ui"; import { MODULES } from "@/lib/modules"; +import { effectiveTier, isModuleInTier } from "@/lib/plans"; import { setTenantStatus, toggleTenantModule, setTenantMfaRequired, setTenantLocale } from "@/server/actions/admin"; import { resolveMfaRequired } from "@/lib/mfa-policy"; import { createTenantUser, updateTenantUser, setTenantUserRoles, setTenantUserStatus } from "@/server/actions/platform-users"; @@ -20,6 +21,7 @@ import { UserCreateForm, UserEditForm } from "@/components/user-forms"; import { AuditTrailModal, type AuditRow } from "@/components/audit-trail"; import { RestoreModalBody, ExportModalBody, DsgvoModalBody, type SnapshotOption, type SubjectOption } from "@/components/backup-admin-panel"; import { TrialAdminCard } from "@/components/trial/trial-admin-card"; +import { PlanAdminCard } from "@/components/plans/plan-admin-card"; const STATUS_TONE: Record = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" }; @@ -28,7 +30,7 @@ export default async function AdminTenantPage({ searchParams, }: { params: Promise<{ id: string }>; - searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string; 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. 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 base = `/admin/${tenant.id}`; + // L17 Pakete: Module außerhalb der Paketstufe wirken trotz Schalter wie deaktiviert + const tierNow = effectiveTier(tenant.tier, tenant.plan); + const tp = await getTranslations("plans"); const moduleState = new Map(tenant.modules.map((m) => [m.moduleKey, m.enabled])); const isOn = (key: string) => moduleState.get(key) ?? true; @@ -226,6 +231,11 @@ export default async function AdminTenantPage({ 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 && ( + + )} + {/* Lebenszyklus */}

    {t("lifecycleTitle")}

    @@ -342,6 +352,7 @@ export default async function AdminTenantPage({

    {m.href ?? m.key}

    + {!isModuleInTier(tierNow, m.key) && {tp("notInTier")}} {on ? t("moduleActive") : t("moduleInactive")}
    diff --git a/src/app/(platform)/admin/page.tsx b/src/app/(platform)/admin/page.tsx index fd268db..5392a13 100644 --- a/src/app/(platform)/admin/page.tsx +++ b/src/app/(platform)/admin/page.tsx @@ -27,6 +27,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis const params = await searchParams; // L15 Testphase: Filter Test/Voll const tt = await getTranslations("trial.platform"); + const tp = await getTranslations("plans"); const planFilter = params.plan === "trial" ? "TRIAL" : params.plan === "full" ? "FULL" : null; const [tenants, mailStatus] = await Promise.all([ @@ -124,6 +125,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis Kürzel Status {tt("colPlan")} + {tp("colTier")} Nutzer Aktive Module @@ -140,6 +142,8 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis {t.slug} {STATUS_LABEL[t.status]} + {/* L17 Pakete: Stufe als Badge (Testphase läuft immer als Profi) */} + {tp(`tier.${t.tier}`)} {t._count.users} {active > 0 ? `${active} Module` : "—"} diff --git a/src/components/audit-trail.tsx b/src/components/audit-trail.tsx index 1a88009..0edd763 100644 --- a/src/components/audit-trail.tsx +++ b/src/components/audit-trail.tsx @@ -89,6 +89,7 @@ const ENTITY_LABEL: Record = { ai_generation: "Lotse (KI)", lotse_settings: "Lotse-Einstellungen", 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" }); diff --git a/src/components/field/bottom-nav.tsx b/src/components/field/bottom-nav.tsx index bdd31a1..aafae11 100644 --- a/src/components/field/bottom-nav.tsx +++ b/src/components/field/bottom-nav.tsx @@ -19,13 +19,17 @@ const ITEMS = [ * 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). */ -export function BottomNav({ approvals = 0, lotse = false }: { approvals?: number; lotse?: boolean }) { +const GRID_COLS: Record = { 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 pathname = usePathname(); + const items = ITEMS.filter((item) => (lotse || item.key !== "lotse") && (emergency || item.key !== "emergency")); return (
    ); } diff --git a/src/components/lotse/chat/seat-admin.tsx b/src/components/lotse/chat/seat-admin.tsx new file mode 100644 index 0000000..53cd4d3 --- /dev/null +++ b/src/components/lotse/chat/seat-admin.tsx @@ -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 ( +
    +
    +
    +

    + {t("admin.title")} +

    +

    {t("admin.sub")}

    +
    +
    + {t("admin.package")} + {t(`tier.${plan.tier}`)} + {plan.isTrial && {t("admin.trial")}} +
    +
    +

    {t("admin.packageReadOnly")}

    + + {seatSaved && ( +

    + {t("admin.saved")} +

    + )} + {seatError && ( +

    + {t.has(`admin.errors.${seatError}`) ? t(`admin.errors.${seatError}`) : t("admin.errors.failed")} +

    + )} + + {!profi || !seats || !usage ? ( +

    + {t("admin.basisHint")} +

    + ) : ( + <> + {/* Plätze */} +
    +

    {t("admin.seatsTitle")}

    + {plan.isTrial ? ( +

    + {t("admin.trialSeats", { perSeat: LOTSE_CHAT_CHATS_PER_SEAT })} +

    + ) : ( +

    {t("admin.seatsCount", { assigned: seats.assigned, purchased: seats.purchased })}

    + )} + {seats.overbooked && ( +

    + {t("admin.overbooked", { purchased: seats.purchased })} +

    + )} + {!plan.isTrial && seats.purchased === 0 &&

    {t("admin.noSeatsBought")}

    } + {seats.users.length === 0 ? ( +

    {t("admin.noFieldUsers")}

    + ) : ( +
      + {seats.users.map((u) => { + const canAssign = !u.assigned && free > 0; + return ( +
    • +
      +

      {u.name}

      +

      {u.email}

      +
      +
      + + {u.assigned ? (u.valid ? : ) : } + {u.assigned ? (u.valid ? t("admin.seatAssigned") : t("admin.seatOverbooked")) : t("admin.seatNone")} + +
      + + + +
      +
      +
    • + ); + })} +
    + )} +
    + + {/* Monatsverbrauch */} +
    +

    + {t("admin.usageTitle", { month: monthLabel })} +

    +
    +
    +
    +

    {t("admin.usageText", { used: nf.format(usage.used), quota: nf.format(usage.quota) })}

    +

    {t("admin.quotaRule", { perSeat: LOTSE_CHAT_CHATS_PER_SEAT, basis: usage.quotaBasis })}

    + {usage.level === "warn" && ( +

    + {t("admin.warn80", { percent: usage.percent })} +

    + )} + {(usage.level === "exhausted" || usage.level === "over") && usage.hardLimit && ( +

    + {t("admin.hardLimitReached")} +

    + )} + {usage.level === "exhausted" && !usage.hardLimit && ( +

    + {t("admin.exhausted", { pack: LOTSE_CHAT_OVERAGE_PACK })} +

    + )} + {usage.overage > 0 && ( +

    + {t("admin.overage", { overage: nf.format(usage.overage), packs: usage.overagePacks, pack: LOTSE_CHAT_OVERAGE_PACK })} +

    + )} + {usage.perUser.length > 0 && ( + + + + + + + + + + {usage.perUser.map((u) => ( + + + + + ))} + +
    {t("admin.perUserCaption")}
    {t("admin.perUserName")}{t("admin.perUserChats")}
    {u.name}{nf.format(u.count)}
    + )} +
    + + )} +
    + ); +} diff --git a/src/components/lotse/chat/unavailable.tsx b/src/components/lotse/chat/unavailable.tsx new file mode 100644 index 0000000..3e74743 --- /dev/null +++ b/src/components/lotse/chat/unavailable.tsx @@ -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 ( +
    +
    +

    + + {t("chat.unavailable.title")} +

    +

    {t.has(`chat.errors.${reason}`) ? t(`chat.errors.${reason}`) : t("chat.errors.generic")}

    + + {t("chat.unavailable.back")} + +
    +
    + ); +} diff --git a/src/components/plans/plan-admin-card.tsx b/src/components/plans/plan-admin-card.tsx new file mode 100644 index 0000000..61a6ff5 --- /dev/null +++ b/src/components/plans/plan-admin-card.tsx @@ -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) => ( +
    +

    {u === current ? t("currentMonth", { month: monthName(u) }) : t("previousMonth", { month: monthName(u) })}

    +

    {t("chats", { used: nf.format(u.used), quota: nf.format(u.quota) })}

    +

    + {u.overage > 0 ? t("overage", { overage: nf.format(u.overage), packs: u.overagePacks, pack: LOTSE_CHAT_OVERAGE_PACK }) : t("noOverage")} +

    +
    + )); + + return ( +
    +

    {t("title")}

    + {done &&

    {t("saved")}

    } +
    +
    +
    {t("tier")}
    +
    + {tt(`tier.${plan.storedTier}`)} + {plan.isTrial && {t("trialProfi")}} +
    +
    +
    +
    {t("seats")}
    +
    {t("seatsValue", { assigned: seatsAssigned, purchased: plan.lotseChatSeats })}
    +
    +
    +
    {t("hardLimit")}
    +
    {plan.lotseChatHardLimit ? t("hardLimitOn") : t("hardLimitOff")}
    +
    +
    +

    {t("rule", { perSeat: LOTSE_CHAT_CHATS_PER_SEAT, pack: LOTSE_CHAT_OVERAGE_PACK })}

    +
    {usageRows}
    + {isFullAdmin && ( + + + + )} + + {isFullAdmin && edit && ( + +
    +

    {t("editText")}

    + +
    +
    + )} +
    + ); +} diff --git a/src/components/plans/plan-form.tsx b/src/components/plans/plan-form.tsx new file mode 100644 index 0000000..76f7d1e --- /dev/null +++ b/src/components/plans/plan-form.tsx @@ -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; + tier: TenantTier; + seats: number; + hardLimit: boolean; + maxSeats: number; + closeHref: string; +}) { + const t = useTranslations("plans.platform"); + const tt = useTranslations("plans"); + const [state, formAction, pending] = useActionState(action, { status: "idle" }); + return ( +
    +
    + {t("tier")} +
    + {TENANT_TIERS.map((v) => ( + + ))} +
    +

    {t("downgradeHint")}

    +
    +
    + + +

    {t("seatsHint")}

    +
    + + + {state.status === "error" && ( +

    + {t.has(`errors.${state.code}`) ? t(`errors.${state.code}`) : t("errors.failed")} +

    + )} +
    + + +
    +
    + ); +} diff --git a/src/components/plans/profi-hint.tsx b/src/components/plans/profi-hint.tsx new file mode 100644 index 0000000..d60cf8c --- /dev/null +++ b/src/components/plans/profi-hint.tsx @@ -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 ( +
    + +
    +

    {t("profiHint.title")}

    + {!compact &&

    {t("profiHint.text")}

    } +
    +
    + ); +} diff --git a/src/lib/lotse/chat.ts b/src/lib/lotse/chat.ts index 1a646e4..34fe4aa 100644 --- a/src/lib/lotse/chat.ts +++ b/src/lib/lotse/chat.ts @@ -175,6 +175,10 @@ export const LOTSE_CHAT_ERROR_CODES = [ "tampered", "blocked", "offline", + // L17 Pakete + "not_in_plan", + "no_seat", + "quota_exhausted", ] as const; export type LotseChatErrorCode = (typeof LOTSE_CHAT_ERROR_CODES)[number]; diff --git a/src/lib/nav.ts b/src/lib/nav.ts index 59607cb..2c086cc 100644 --- a/src/lib/nav.ts +++ b/src/lib/nav.ts @@ -23,6 +23,7 @@ import { type LucideIcon, } from "lucide-react"; import type { ModuleKey } from "@/lib/modules"; +import type { PlanFeature } from "@/lib/plans"; import type { Permission } from "@/server/rbac"; /** @@ -47,14 +48,16 @@ export interface NavItem { sub?: boolean; /** L13: active only on exactly this path (not on sub paths) */ exact?: boolean; + /** L17 Pakete: feature without own module that the package tier must include (planning) */ + feature?: PlanFeature; } export const NAV_ITEMS: readonly NavItem[] = [ { 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) - { href: "/planning", label: "planning", icon: CalendarRange, module: "work_orders", 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/live", label: "planningLive", icon: MapPinned, module: "work_orders", permissions: ["work_order:read_all", "report:approve_team"], section: "main", sub: true }, + { 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", 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", feature: "planning", permissions: ["work_order:read_all", "report:approve_team"], section: "main", sub: true }, { href: "/work-orders", 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 ]; -/** 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( items: readonly NavItem[], - opts: { disabledModules: ReadonlySet; permissions: readonly string[] }, + opts: { disabledModules: ReadonlySet; permissions: readonly string[]; lockedFeatures?: ReadonlySet }, ): NavItem[] { return items.filter( (item) => (!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))), ); } diff --git a/src/lib/offline/outbox-core.ts b/src/lib/offline/outbox-core.ts index 168a45e..1e78843 100644 --- a/src/lib/offline/outbox-core.ts +++ b/src/lib/offline/outbox-core.ts @@ -333,6 +333,9 @@ export function problemKey(e: Pick 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")}`; +} diff --git a/src/server/actions/lotse/_chat-state.ts b/src/server/actions/lotse/_chat-state.ts index 0c58763..bcdfe14 100644 --- a/src/server/actions/lotse/_chat-state.ts +++ b/src/server/actions/lotse/_chat-state.ts @@ -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; 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 ForbiddenError) return { status: "error", code: "forbidden", at, ...withView }; console.error("[actions/lotse/chat]", err); diff --git a/src/server/actions/lotse/seats.ts b/src/server/actions/lotse/seats.ts new file mode 100644 index 0000000..52f44f3 --- /dev/null +++ b/src/server/actions/lotse/seats.ts @@ -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 { + 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); +} diff --git a/src/server/actions/plans-platform.ts b/src/server/actions/plans-platform.ts new file mode 100644 index 0000000..6ae311f --- /dev/null +++ b/src/server/actions/plans-platform.ts @@ -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 { + 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`); +} diff --git a/src/server/api/respond.ts b/src/server/api/respond.ts index 49a2ff7..316ccad 100644 --- a/src/server/api/respond.ts +++ b/src/server/api/respond.ts @@ -3,6 +3,7 @@ import { ZodError } from "zod"; import { ServiceError } from "@/server/services/context"; import { ForbiddenError } from "@/server/rbac"; 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 — @@ -73,7 +74,10 @@ export function toErrorResponse(err: unknown): Response { ); } 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"); console.error("[api] unhandled error", err); return errorResponse("internal", "internal error"); diff --git a/src/server/backup/topology.ts b/src/server/backup/topology.ts index 8099168..2b4616e 100644 --- a/src/server/backup/topology.ts +++ b/src/server/backup/topology.ts @@ -81,6 +81,8 @@ export const TENANT_MODELS: readonly string[] = [ "LotseActionProposal", // L15 Testphase: Datenexport des Mandanten "TenantExport", + // L17 Pakete: vergebene Lotse-Chat-Plätze + "LotseChatSeat", ]; /** diff --git a/src/server/db.ts b/src/server/db.ts index c15afa3..3abf9bd 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -132,6 +132,8 @@ const TENANT_MODELS = new Set([ "LotseActionProposal", // L15 Testphase: Datenexport des Mandanten "TenantExport", + // L17 Pakete: vergebene Lotse-Chat-Plätze + "LotseChatSeat", // WebAuthnCredential/Identity sind identitäts-global (kein tenant_id) → NICHT hier. // Craftvia-Fachmodelle hier ergänzen — UND in src/server/backup/topology.ts // (TENANT_MODELS) sowie per `SELECT enable_tenant_rls('')` in der Migration diff --git a/src/server/dsgvo/pii-fields.ts b/src/server/dsgvo/pii-fields.ts index 2027236..c29e21f 100644 --- a/src/server/dsgvo/pii-fields.ts +++ b/src/server/dsgvo/pii-fields.ts @@ -61,6 +61,9 @@ export const PII_REFERENCE_FIELDS: readonly PiiReference[] = [ { model: "LotseActionProposal", field: "confirmedById" }, // L15 Testphase: Datenexport { 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 // tenant business data under data processing — not part of the employee subject export. ]; diff --git a/src/server/modules.ts b/src/server/modules.ts index 79af632..f650aa1 100644 --- a/src/server/modules.ts +++ b/src/server/modules.ts @@ -1,8 +1,8 @@ import { redirect } from "next/navigation"; import type { Session } from "next-auth"; import { requireSession } from "@/server/auth"; -import { dbForTenant } from "@/server/db"; 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 @@ -12,17 +12,21 @@ import { writeAuditLog } from "@/server/audit"; */ export async function requireModule(moduleKey: string) { const session = await requireSession(); - const row = await dbForTenant(session.user.tenantId).tenantModule.findUnique({ - where: { tenantId_moduleKey: { tenantId: session.user.tenantId, moduleKey } }, - }); - if (row && !row.enabled) redirect("/dashboard?module=disabled"); + // L17 Pakete: Stufe + TenantModule zentral (src/server/plan.ts); nicht im Paket → ruhiger Hinweis + const state = await moduleState(session.user.tenantId, moduleKey); + if (state === "not_in_plan") redirect(PROFI_HINT_PATH); + if (state === "disabled") redirect("/dashboard?module=disabled"); return session; } /** Wird geworfen, wenn eine Server-Action ein für den Mandanten deaktiviertes Modul betrifft. */ export class ModuleDisabledError extends Error { - constructor(public readonly moduleKey: string) { - super(`Modul „${moduleKey}" ist für diesen Mandanten deaktiviert.`); + constructor( + 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"; } } @@ -37,17 +41,17 @@ export class ModuleDisabledError extends Error { */ export async function assertModuleEnabled(session: Session, moduleKey: string) { const tenantId = session.user.tenantId; - const row = await dbForTenant(tenantId).tenantModule.findUnique({ - where: { tenantId_moduleKey: { tenantId, moduleKey } }, - }); - if (row && !row.enabled) { + // L17 Pakete: effektive Freischaltung = Stufe erlaubt UND TenantModule aktiv (src/server/plan.ts) + const state = await moduleState(tenantId, moduleKey); + if (state !== "enabled") { await writeAuditLog({ tenantId, actorId: session.user.id, action: "denied", entity: "module", 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"); } } diff --git a/src/server/plan.ts b/src/server/plan.ts new file mode 100644 index 0000000..8c23bd7 --- /dev/null +++ b/src/server/plan.ts @@ -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 { + 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 { + 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 { + 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; + /** Subset of `inactive`: locked only by the tier ("Im Paket Profi enthalten"). */ + notInPlan: Set; + /** Features (planning, lotse_chat) not included in the tier. */ + lockedFeatures: Set; +}; + +/** All module states in two queries (navigation, settings overview). */ +export async function effectiveModules(tenantId: string): Promise { + 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([...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 { + 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 { + 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 { + if (!(await isPlanFeatureAvailable(tenantId, feature))) redirect(PROFI_HINT_PATH); +} diff --git a/src/server/services/lotse/chat/access.ts b/src/server/services/lotse/chat/access.ts index 25909c1..01641ac 100644 --- a/src/server/services/lotse/chat/access.ts +++ b/src/server/services/lotse/chat/access.ts @@ -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 { 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 - * (TenantModule `lotse`) and the tenant switch „Lotse-Chat für Monteure" (TenantSettings.lotseChatEnabled, - * default on). Switched off → `blocked` (reason disabled / chat_disabled). + * Gate of the Lotse chat for technicians (lane L16, extended by L17 Pakete) — the ONE place for: + * - permissions `lotse:use` + `field:execute`, + * - 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): Promise { if (!(await isLotseEnabled(ctx))) return false; const s = await ctx.db.tenantSettings.findFirst({ select: { lotseChatEnabled: true } }); return s?.lotseChatEnabled ?? true; } -/** UI convenience: may the user open the chat at all (permissions + switches)? Never a security check. */ -export async function canUseLotseChat(ctx: ServiceCtx): Promise { - return can(ctx, "lotse:use") && can(ctx, "field:execute") && (await isLotseChatEnabled(ctx)); +/** Tenant- and user-level gate without permissions and quota; null = open. */ +export async function lotseChatBlockReason(ctx: ServiceCtx): Promise { + 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 { +/** UI convenience: may the user open the chat at all (permissions + switches + seat)? Never a security check. */ +export async function canUseLotseChat(ctx: ServiceCtx): Promise { + return can(ctx, "lotse:use") && can(ctx, "field:execute") && (await lotseChatBlockReason(ctx)) === null; +} + +const BLOCK_MESSAGES: Record = { + 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 { + return (await getChatUsage(ctx, { now, perUser: false })).blocked; +} + +export async function assertLotseChatUsable(ctx: ServiceCtx, opts: { send?: boolean; now?: Date } = {}): Promise { assertCan(ctx, "lotse:use"); assertCan(ctx, "field:execute"); - if (!(await isLotseEnabled(ctx))) throw new ServiceError("blocked", "lotse disabled for tenant", { reason: "disabled" }); - const s = await ctx.db.tenantSettings.findFirst({ select: { lotseChatEnabled: true } }); - if (s && !s.lotseChatEnabled) throw new ServiceError("blocked", "lotse chat disabled for tenant", { reason: "chat_disabled" }); + const reason = await lotseChatBlockReason(ctx); + if (reason) throw new ServiceError("blocked", BLOCK_MESSAGES[reason], { reason }); + if (opts.send && (await isChatQuotaBlocked(ctx, opts.now))) { + throw new ServiceError("blocked", "lotse chat quota exhausted", { reason: "quota_exhausted" }); + } } diff --git a/src/server/services/lotse/chat/engine.ts b/src/server/services/lotse/chat/engine.ts index 27986f2..94b4924 100644 --- a/src/server/services/lotse/chat/engine.ts +++ b/src/server/services/lotse/chat/engine.ts @@ -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 { - await assertLotseChatUsable(ctx); - const input = sendMessageSchema.parse(raw); 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 }))!; if (!deps.provider) throw new ServiceError("invalid", "lotse provider not configured", { reason: "not_configured" }); await assertTokenBudget(ctx, now); diff --git a/src/server/services/lotse/chat/seats.ts b/src/server/services/lotse/chat/seats.ts new file mode 100644 index 0000000..112a8c8 --- /dev/null +++ b/src/server/services/lotse/chat/seats.ts @@ -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; + +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 { + 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> { + 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 { + 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 { + 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; + +/** + * 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 }; +} diff --git a/src/server/services/lotse/chat/transcribe.ts b/src/server/services/lotse/chat/transcribe.ts index 3d8f213..b08ebd7 100644 --- a/src/server/services/lotse/chat/transcribe.ts +++ b/src/server/services/lotse/chat/transcribe.ts @@ -18,7 +18,7 @@ export type ChatTranscriptionDeps = { provider: TranscriptionProvider | null }; export const defaultChatTranscriptionDeps = (): ChatTranscriptionDeps => ({ provider: getTranscriptionProvider() }); 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 (bytes.byteLength === 0 || bytes.byteLength > CHAT_AUDIO_MAX_BYTES) throw new ServiceError("invalid", "audio size", { reason: "invalid" }); const sniffed = sniffMime(bytes); diff --git a/src/server/services/lotse/chat/usage.ts b/src/server/services/lotse/chat/usage.ts new file mode 100644 index 0000000..f150586 --- /dev/null +++ b/src/server/services/lotse/chat/usage.ts @@ -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; + +async function countChats(ctx: UsageCtx, from: Date, to: Date): Promise { + return ctx.db.lotseMessage.count({ where: { role: "user", createdAt: { gte: from, lt: to } } }); +} + +async function countChatsPerUser(ctx: UsageCtx, from: Date, to: Date): Promise { + 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(); + 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 { + 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 { + 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 }; +} + diff --git a/src/server/services/lotse/settings.ts b/src/server/services/lotse/settings.ts index 2e8980e..2654344 100644 --- a/src/server/services/lotse/settings.ts +++ b/src/server/services/lotse/settings.ts @@ -5,6 +5,8 @@ import { AI_MODEL, isAiConfigured } from "@/server/ai/client"; import { transcriptionConfig } from "@/server/ai/transcription/openai-compatible"; import { writeAuditLog } from "@/server/audit"; 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"; /** @@ -14,7 +16,14 @@ import { envMonthlyTokenLimit, getTokenBudget } from "./budget"; 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): Promise { + 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): Promise { const row = await ctx.db.tenantModule.findUnique({ where: { tenantId_moduleKey: { tenantId: ctx.tenantId, moduleKey: LOTSE_MODULE_KEY } }, select: { enabled: true }, @@ -22,6 +31,10 @@ export async function isLotseEnabled(ctx: Pick): return !row || row.enabled; } +async function isModuleInPlan(tenantId: string, moduleKey: string): Promise { + return isModuleInTier((await getTenantPlan(tenantId)).tier, moduleKey); +} + /** Throws `forbidden` (details.reason = "disabled") when the tenant switched the Lotse off. */ export async function assertLotseEnabled(ctx: ServiceCtx): Promise { if (!(await isLotseEnabled(ctx))) throw new ServiceError("forbidden", "lotse disabled for tenant", { reason: "disabled" }); @@ -40,7 +53,7 @@ export async function lotseVoice(ctx: Pick): Promise<{ address export async function getLotseSettings(ctx: ServiceCtx) { assertCan(ctx, "tenant:manage"); const [enabled, s, budget] = await Promise.all([ - isLotseEnabled(ctx), + isLotseSwitchedOn(ctx), ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true, lotseChatEnabled: true } }), 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 before = { - enabled: await isLotseEnabled(ctx), + enabled: await isLotseSwitchedOn(ctx), addressForm: stored?.lotseAddressForm ?? null, monthlyTokenLimit: stored?.aiMonthlyTokenLimit ?? null, chatEnabled: stored?.lotseChatEnabled ?? true, diff --git a/src/server/services/planning/access.ts b/src/server/services/planning/access.ts index 75b2cf8..cb990c3 100644 --- a/src/server/services/planning/access.ts +++ b/src/server/services/planning/access.ts @@ -1,10 +1,13 @@ import { can, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { assertPlanFeature } from "@/server/plan"; /** * 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_team` + leader of at least one active team (Teamleiter): read-only, own teams only. * - 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 = { all: boolean; @@ -15,6 +18,7 @@ export type PlanningAccess = { }; export async function planningAccess(ctx: ServiceCtx): Promise { + await assertPlanFeature(ctx.tenantId, "planning"); if (can(ctx, "work_order:read_all")) { return { all: true, diff --git a/src/server/services/planning/schedule.ts b/src/server/services/planning/schedule.ts index 998d21d..4a3ae46 100644 --- a/src/server/services/planning/schedule.ts +++ b/src/server/services/planning/schedule.ts @@ -3,6 +3,7 @@ import { orderDays, SCHEDULABLE_STATUSES, type PlanningConflict } from "@/lib/pl import type { WorkOrderStatus } from "@/lib/work-orders/status"; import { writeAuditLog } from "@/server/audit"; 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 { assignWorkOrder } from "@/server/services/work-orders/assign"; 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 { assertCan(ctx, "work_order:assign"); assertCan(ctx, "work_order:write"); + await assertPlanFeature(ctx.tenantId, "planning"); // L17 Pakete const input = parseInput(scheduleSchema, raw); if (Number.isNaN(input.plannedStart.getTime())) throw new ServiceError("invalid", "validation_failed", [{ path: "plannedStart" }]); diff --git a/src/server/services/planning/team-settings.ts b/src/server/services/planning/team-settings.ts index 4d9a35f..5bd5802 100644 --- a/src/server/services/planning/team-settings.ts +++ b/src/server/services/planning/team-settings.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { writeAuditLog } from "@/server/audit"; import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { assertPlanFeature } from "@/server/plan"; import { parseInput } from "@/server/services/work-orders/_shared"; /** 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; export async function updateTeamPlanningSettings(ctx: ServiceCtx, teamId: string, raw: TeamPlanningSettingsInput) { assertCan(ctx, "team:manage"); + await assertPlanFeature(ctx.tenantId, "planning"); // L17 Pakete: Kolonnenkapazität gehört zur Planung const input = parseInput(settingsSchema, raw); const before = await ctx.db.team.findFirst({ where: { id: teamId, deletedAt: null }, diff --git a/src/server/services/planning/watch.ts b/src/server/services/planning/watch.ts index 9221c38..92dcc29 100644 --- a/src/server/services/planning/watch.ts +++ b/src/server/services/planning/watch.ts @@ -15,6 +15,7 @@ import { emitEvent } from "@/server/events"; import { PERMISSIONS } from "@/server/rbac"; import type { ServiceCtx } from "@/server/services/context"; import { planningAccess } from "@/server/services/planning/access"; +import { isPlanFeatureAvailable } from "@/server/plan"; import { loadPlannedOrders, loadTeams, @@ -339,6 +340,8 @@ export async function runPlanningWatch( ): Promise { const ctx = watchContext(tenantId); 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 ledger = opts.ledger ?? auditAlertLedger; const result: WatchResult = { overrun: 0, followupAtRisk: 0, capacityFreed: 0 }; diff --git a/src/server/services/plans/platform.ts b/src/server/services/plans/platform.ts new file mode 100644 index 0000000..6c32ec6 --- /dev/null +++ b/src/server/services/plans/platform.ts @@ -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 { + 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; + +export async function updateTenantPlan(actor: PlatformActor, tenantId: string, raw: TenantPlanInput): Promise { + 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 { + 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 }; +} diff --git a/src/server/services/sync/apply.ts b/src/server/services/sync/apply.ts index 0f122a4..55ff958 100644 --- a/src/server/services/sync/apply.ts +++ b/src/server/services/sync/apply.ts @@ -14,6 +14,7 @@ import { attachPhoto } from "@/server/services/field/photos"; import { attachVoiceNote } from "@/server/services/field/voice"; // TODO(merge L2): replace with "@/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"; /** @@ -78,6 +79,37 @@ function errorMessage(err: ServiceError): string { 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> = { + "emergency.create": "emergency", + "milestone.reach": "billing", +}; + +const LOCKED_MODULE_MESSAGE: Record = { + 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 { + const moduleKey = OP_MODULES[opType]; + if (!moduleKey) return null; + const state = await moduleState(ctx.tenantId, moduleKey); + if (state === "enabled") return null; + // `: ` 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. */ async function dispatch(ctx: ServiceCtx, payload: unknown, op: SyncOperationInput): Promise { 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 }; } + // 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 { // 3. conflict check if (CONFLICTING_OPS.includes(op.opType)) { diff --git a/src/server/services/work-orders/transition.ts b/src/server/services/work-orders/transition.ts index 22e664c..23cb903 100644 --- a/src/server/services/work-orders/transition.ts +++ b/src/server/services/work-orders/transition.ts @@ -12,6 +12,7 @@ import { type WorkOrderBase, } from "@/server/services/work-orders/_shared"; 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). */ 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 }; } -/** 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 { - const row = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "billing" }, select: { enabled: true } }); - return !row || row.enabled; + return isModuleActive(ctx.tenantId, "billing"); }