Files
craftvia/docs/craftvia/lanes/pakete.md
T
msolarczekandClaude Opus 5 aca571b2e7 L17 Pakete: Smoke-Prüfungen, Audit-Labels, Betriebsdoku und Lane-Bericht
- smoke-auth: Admin-Karte, Teamleiterin ohne Chat-Platz, Monteur mit Platz,
  demo2 als Basis (Hinweis statt Profi-Seiten, API 403)
- Audit-Labels lotse_chat_seat / tenant_plan
- DEPLOY.md §7.5 (Pakete/Plätze setzen, Mehrverbrauch ablesen), API.md
- docs/craftvia/lanes/pakete.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 10:21:23 +02:00

103 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Lane L17 – Pakete (Basis/Profi) & Lotse-Chat pro Nutzer (`lane/pakete`)
Stand: 2026-09-21 · Basis `4d5be72` (`feature/craftvia-mvp`) · Betriebsdoku: [../DEPLOY.md §7.5](../DEPLOY.md)
Ziel: Zwei Pakete durchsetzen und den Lotse-Chat als Zusatzmodul pro Nutzer mit gemeinsamem Kontingent führen. **Keine Abrechnung in der App** – die App setzt Pakete durch, verwaltet Plätze und zählt Nutzung; der Betreiber stellt die Rechnung außerhalb.
## 1. Umfang / umgesetzte Entscheidungen
| Punkt | Umsetzung |
|---|---|
| **1 Datenmodell** | Migration `20260921100000_pakete` (additiv): `enum TenantTier { BASIS PROFI }`, am `Tenant` `tier` (Default `PROFI` → alle Bestandsmandanten und Testphasen unverändert), `lotseChatSeats Int @default(0)`, `lotseChatHardLimit Boolean @default(false)` (Plattform-Daten, keine RLS). Neue Mandanten-Tabelle `lotse_chat_seats` (`LotseChatSeat`: tenantId, userId, assignedById, createdAt; unique tenantId+userId) mit `enable_tenant_rls`, in beiden `TENANT_MODELS`, `userId`/`assignedById` in `pii-fields.ts`. Index `lotse_messages(tenant_id, role, created_at)` für die Monatszählung. |
| **2 Zentrale Regeln** `src/lib/plans.ts` (client-sicher, rein) | Stufen, `PROFI_ONLY_MODULES = billing, emergency, imports, lotse`, Features ohne eigenes Modul `planning`, `lotse_chat`; `effectiveTier` (Testphase → Profi), `isModuleInTier`, `isFeatureInTier`, `LOTSE_CHAT_CHATS_PER_SEAT = 150`, `LOTSE_CHAT_OVERAGE_PACK = 100`, `chatQuota`, `chatOverage`, `overagePacks` (angefangene Pakete zählen), `usageLevel` (ok / warn ab 80 % / exhausted / over), `usagePercent`, `isQuotaBlocked`, Monatsschlüssel. |
| **3 Durchsetzung an einer Stelle** `src/server/plan.ts` | `moduleState(tenantId, key)` = `not_in_plan` (Stufe) → `disabled` (TenantModule) → `enabled`; `effectiveModules` (Navigation/Übersichten), `isModuleActive`, `assertPlanFeature`/`requirePlanFeature` (Planung). Genutzt von **`requireModule`** (Layouts: nicht im Paket → `/dashboard?module=profi` mit ruhigem Hinweis „Im Paket Profi enthalten“), **`assertModuleEnabled`** (moduleGuard + `requireApiContext`; `ModuleDisabledError.reason = "not_in_plan"`, Audit `denied`, API → `403` mit `details.reason = "not_in_plan"` + Klartext), **Sync** (`services/sync/apply.ts#OP_MODULES`: `emergency.create` → emergency, `milestone.reach` → billing; gesperrt → gespeichert `rejected`/`forbidden` mit `not_in_plan: <Klartext>` bzw. `module_disabled: …`, Gerät zeigt eigenen Text `offline.problem.notInPlan|moduleDisabled`), **Navigation** (Backoffice-Sidebar über `effectiveModules` + `NavItem.feature`, mobile Bottom-Navigation ohne „Notdienst“/„Lotse“), **`isLotseEnabled`** (Berichtsentwurf, Vollständigkeitsprüfung, Sprachnotiz-Zusammenfassung, Transkription), **Planung** (`planningAccess` → Plantafel, Live-Lage, Kapazität, Empfehlungen, Frühfertig, Dashboard-Kacheln; zusätzlich `scheduleWorkOrder`, `updateTeamPlanningSettings`, `runPlanningWatch` überspringt Basis; Planungs-Layout `requirePlanFeature`). Abrechnungs-Schalter (`billingModuleActive`, Auftragsdetail) laufen ebenfalls über `plan.ts` – Basis verhält sich wie „Abrechnungsübersicht deaktiviert“ (direktes „abgerechnet“, Tab ausgeblendet). |
| **4 Lotse-Chat-Plätze** | `services/lotse/chat/access.ts` ist die eine Prüfstelle: Rechte → Stufe (`blocked not_in_plan`) → Lotse an (`disabled`) → Chat-Schalter (`chat_disabled`) → Platz (`blocked no_seat`, Testphase ohne Platzprüfung) → beim **Senden** Kontingent mit hartem Limit (`blocked quota_exhausted`). `canUseLotseChat` (Navigation, „Lotse fragen“) schließt den Platz ein. `seats.ts`: berechtigt = aktive Nutzer mit `lotse:use` + `field:execute` (DB-Rollen), Vergabe/Entzug `setChatSeat` (tenant:manage, Zod, Audit `lotse_chat_seat` before/after, idempotent, serialisiert über eine Zeilensperre der Mandanten-Einstellungen → parallele Vergaben überschreiten die Grenze nicht). Gültig sind die ältesten `lotseChatSeats` Zuweisungen (Überbelegung nach Senkung durch den Betreiber sichtbar). `usage.ts`: Chats im Monat gesamt und je Nutzer (Kalendermonat in der Mandanten-Zeitzone, DST-sicher), Kontingent = gezählte Plätze × 150 (Testphase: Berechtigte × 150, kein hartes Limit), Mehrverbrauch, 100er-Pakete, Stufe, Sperre. Die Token-Obergrenze (`budget.ts`) bleibt unverändert als Sicherheitsnetz. |
| **5 Mandanten-Admin** `/settings/lotse` | Neue Karte „Paket & Lotse-Chat“: Paket nur lesend (Badge, Testphase-Hinweis), „x von y Plätzen vergeben“, Liste aller Nutzer mit Feldrolle mit Schalter je Nutzer (Button `role="switch"`, ≥ 44 px, Status als Text + Icon, deaktiviert ohne freien Platz – serverseitig ohnehin geprüft), Monatsverbrauch als Balken (`role="meter"`) mit Text „212 von 450 Chats“, Hinweis ab 80 %, bei ausgeschöpftem Kontingent / Mehrverbrauch (inkl. Paketzahl) / hartem Limit, Aufschlüsselung je Nutzer. Basis: Hinweis, Plätze bleiben gespeichert. Action `actions/lotse/seats.ts` (`moduleGuard("lotse")` + `guard("tenant:manage")`). |
| **6 Mobil** | Ohne Platz kein Eintrag „Lotse“ in der Bottom-Navigation und kein „Lotse fragen“; `/m/lotse` zeigt „Der Lotse-Chat ist für dich noch nicht freigeschaltet – frag im Büro nach.“ Basis: Hinweis statt Weiterleitung (Lotse-Layout). Hartes Limit erreicht: Hinweis statt Eingabefeld (Verlauf bleibt lesbar, Chips deaktiviert); auch nach einer abgewiesenen Nachricht. |
| **7 Betreiber** `/admin/[id]` | Karte „Paket & Lotse-Chat“: Stufe, Plätze vergeben/gebucht, hartes Limit, Chats laufender Monat und Vormonat („x von y Chats“), Mehrverbrauch → 100er-Pakete (Grundlage der Rechnung). „Paket ändern …“ (nur Voll-Admins) → Popup mit Stufe, Platzanzahl (0–1000), hartem Limit und Pflicht-Bestätigung; `services/plans/platform.ts#updateTenantPlan` prüft den Akteur gegen den PlatformAdmin-Store, Zod, Plattform-Audit `tenant_plan` (before/after). Mandantenliste: Spalte „Paket“ als Badge; Modul-Popup markiert Module außerhalb der Stufe („Nur im Paket Profi“). |
| **8 Demo-Seed** | `demo` = Profi mit 3 Plätzen für Max, Nora, Paul (Monteure; Chat funktioniert weiter), `demo2` = Basis (zeigt die Sperren). Idempotent (`seedDemoPlan`). |
| **9 i18n/Doku** | Namespace `plans` (de/en), Ergänzungen in `lotse.json` (`chat.errors.not_in_plan/no_seat/quota_exhausted`, `chat.quotaExhausted`) und `offline.json` (`problem.notInPlan/moduleDisabled`); `DEPLOY.md §7.5` (wie der Betreiber Pakete/Plätze setzt, wie Mehrverbrauch abgelesen wird), `API.md` (403-Details, Sync-Abweisung). |
### Entscheidungen
- **Admin als Platzinhaber:** Berechtigung = Rechte `lotse:use` + `field:execute` (nicht der Rollenname). Der Mandantenadministrator hat alle Rechte und kann sich daher selbst einen Platz geben; Backoffice nicht.
- **Kontingent „je vergebenem Platz“:** zählt vergebene Plätze, höchstens die gebuchte Anzahl.
- **Sync-Abweisung ist endgültig** (gespeichert, wie andere deterministische Ablehnungen). Nach einem Upgrade muss der Monteur den Notdienst erneut erfassen.
- **Hartes Limit gilt nicht in der Testphase** (keine gebuchten Plätze; Token-Budget bleibt).
- **Basis sperrt auch die Platzvergabe** (moduleGuard `lotse`), Plätze bleiben gespeichert.
## 2. Dateien
**Neu**
- `prisma/migrations/20260921100000_pakete/migration.sql`
- `src/lib/plans.ts`, `src/server/plan.ts`
- `src/server/services/lotse/chat/{seats,usage}.ts`, `src/server/services/plans/platform.ts`
- `src/server/actions/lotse/seats.ts`, `src/server/actions/plans-platform.ts`
- `src/components/plans/{profi-hint,plan-admin-card,plan-form}.tsx`, `src/components/lotse/chat/{seat-admin,unavailable}.tsx`
- `messages/{de,en}/plans.json`, dieser Bericht
- Tests: `scripts/test-pakete-{rules,gates,seats}.ts`
**Eingriffe in Fundament-/Fremddateien (klein, markiert „L17“)**
| Datei | Eingriff | Grund |
|---|---|---|
| `prisma/schema.prisma` | 3 Tenant-Felder, Enum, Modell `LotseChatSeat`, Index an `LotseMessage` | Punkt 1 |
| `src/server/db.ts`, `src/server/backup/topology.ts`, `src/server/dsgvo/pii-fields.ts` | `LotseChatSeat` | Pflicht neue Tenant-Tabelle |
| `src/server/modules.ts` | `requireModule`/`assertModuleEnabled` über `moduleState`; `ModuleDisabledError.reason` | zentrale Durchsetzung |
| `src/server/api/respond.ts` | 403 mit `details.reason = not_in_plan` | Klartext für Clients |
| `src/lib/nav.ts`, `src/app/(app)/layout.tsx` | `NavItem.feature`, `lockedFeatures`; effektive Module | Navigation |
| `src/app/(app)/dashboard/page.tsx` (L2) | Hinweis `?module=profi`, Kacheln Abrechnung/Notdienst nur bei aktivem Modul | ruhiger Hinweis |
| `src/app/(app)/settings/page.tsx` | Modulübersicht über effektive Module | Anzeige |
| `src/app/(app)/work-orders/[id]/page.tsx`, `src/server/services/work-orders/transition.ts` (L2/L14) | `billingModuleActive` über `plan.ts`, Tab „Meilensteine & Abrechnung“ nur bei aktiver Abrechnung | Basis = Abrechnung aus |
| `src/server/services/planning/{access,schedule,team-settings,watch}.ts`, `src/app/(app)/planning/layout.tsx` (L13) | Feature-Prüfung `planning` | Planung nur Profi |
| `src/server/services/sync/apply.ts` (L4), `src/lib/offline/outbox-core.ts` (L7), `messages/{de,en}/offline.json` | `OP_MODULES` + Klartext; `problemKey` | Sync-Sperre |
| `src/app/(field)/m/layout.tsx`, `src/components/field/bottom-nav.tsx` (L4) | Notdienst-Eintrag nur bei aktivem Modul, Spaltenzahl dynamisch | mobile Navigation |
| `src/server/services/lotse/{settings.ts,chat/access.ts,chat/engine.ts,chat/transcribe.ts}`, `src/lib/lotse/chat.ts`, `src/server/actions/lotse/_chat-state.ts`, `src/components/lotse/chat/lotse-chat.tsx`, `src/app/(field)/m/(core)/lotse/{layout,page}.tsx`, `src/app/(app)/settings/lotse/page.tsx`, `messages/{de,en}/lotse.json` (L9/L16) | Stufe in `isLotseEnabled`, Platz/Kontingent im Chat-Gate, Hinweise, Admin-Karte | Punkte 4–6 |
| `src/app/(platform)/admin/page.tsx`, `admin/[id]/page.tsx` | Badge-Spalte, Karte, Modul-Popup-Markierung | Punkt 7 |
| `src/components/audit-trail.tsx` | Labels `lotse_chat_seat`, `tenant_plan` | Audit-Viewer |
| `scripts/check-module-guards.ts` | `plans-platform.ts` EXEMPT | Top-Level-Action |
| `scripts/lib/demo-seed.ts` | `seedDemoPlan`, demo Profi + 3 Plätze, demo2 Basis | Punkt 8 |
| `scripts/lib/e2e-fixture.ts`, `scripts/test-e2e-tenant-isolation.ts` | Cleanup + Fixture-Zeile `LotseChatSeat` | Test verlangt alle Tenant-Modelle |
| `scripts/lib/lotse-chat-fixture.ts` (L16) | 10 Plätze + Sitz je Testnutzer | bestehende Chat-Tests brauchen jetzt einen Platz |
| `scripts/smoke-auth.ts` | L17-Prüfungen (Admin-Karte, Teamleiterin ohne Platz, Monteur mit Platz, demo2 Basis) | Smoke |
| `docs/craftvia/{DEPLOY,API}.md` | §7.5, 403/Sync | Betrieb |
Keine neuen npm-Abhängigkeiten, keine Änderung an `rbac.ts`, keine neuen env-Variablen.
## 3. Tests
| Skript | Prüfungen | Inhalt |
|---|---|---|
| `test-pakete-rules.ts` | 51 | Stufen, Testphase = Profi, Module/Features je Stufe, Kontingent/Mehrverbrauch/100er-Pakete, Warnstufen, hartes Limit, Monatsschlüssel, Navigation Basis/Profi |
| `test-pakete-gates.ts` | 74 | Default Profi; Layout-Guard-Funktionen (`moduleState`, `requirePlanFeature` → Redirect `?module=profi`, Quelltextnachweis `requireModule`/Planungs-Layout); Action (`assertModuleEnabled` → `not_in_plan` + Audit) für billing/emergency/imports/lotse, Basis-Module frei; API 403 mit Klartext, Planungs-API 403; Planung (Plantafel, Live-Lage, Einplanen, Empfehlungen, Kapazität, Dashboard-Kachel, Watch) gesperrt / Profi erlaubt; `isLotseEnabled`, Abrechnungsschalter, Navigation; Sync: `emergency.create`/`milestone.reach` → rejected mit Klartext, gespeichert, kein Auftrag, Duplikat, Gerätetext; Profi → angewendet; TenantModule aus + Profi → gesperrt (Action, Sync); nur Betreiber ändert die Stufe (Mandanten-Admin, Nur-Lesen-Admin → forbidden, ungültige Werte → invalid, Mandanten-Einstellungen ändern keine Stufe); Wechsel Profi → Basis → Profi ohne Datenverlust mit Plattform-Audit; Mandantentrennung |
| `test-pakete-seats.ts` | 68 | berechtigte Nutzer; ohne Platz → `no_seat` (View, Senden, nichts gespeichert); Vergabe bis zur Grenze, darüber `invalid no_seats_left`, parallele Vergabe (genau eine), idempotent, Audit create/delete; mit Platz → ok/Senden; Entzug; Überbelegung nach Senkung; Feldrollen/Backoffice → forbidden (Vergabe, Übersicht, Verbrauch); ohne Feldrolle / deaktiviert → `not_eligible`; Mandantentrennung (B vergibt/entzieht nichts von A, sieht keine Plätze/Zählung); Monatsgrenze in Europe/Berlin inkl. Sommerzeit, Antworten zählen nicht, je Nutzer; Mehrverbrauch weiterzählen (305/300 → 1 Paket), hartes Limit → `quota_exhausted` ohne Speichern, Verlauf lesbar, B unberührt; Testphase ohne Platzprüfung (Kontingent Berechtigte × 150, kein hartes Limit); Basis sperrt Chat und Vergabe, Plätze bleiben; Betreiber-Übersicht laufender Monat/Vormonat |
**Gate (`npm run gate`) grün:** prisma generate, tsc, lint (0 Fehler; 3 bestehende Warnungen in fremden Dateien), build inkl. Guard-Check (42 Action-Dateien), **87/87 Testskripte** (vorher 84 + 3 neue). Bestehende Tests unverändert grün; angepasst wurde nur die L16-Chat-Fixture (Plätze).
**RLS-Lauf** `RLS_ENFORCED=true` (Lane-DB `craftvia_pakete`, Rolle `craftvia_app`): `test-pakete-gates`, `test-pakete-seats`, `test-e2e-tenant-isolation`, `test-rls-enforcement`, `test-lotse-chat-engine` – 5/5 grün.
**HTTP-Smoke** (`next build && next start -p 3117`, `scripts/smoke-auth.ts` ohne Passworteingabe): **152/155** – alle neuen L17-Prüfungen grün (Admin: Karte „Paket & Lotse-Chat“, „3 von 3 Plätzen vergeben“; Teamleiterin ohne Platz: kein Nav-Eintrag, Hinweis „frag im Büro nach“, kein „Lotse fragen“; Monteur mit Platz: Chat, Nav-Einträge Lotse/Notdienst; demo2 Basis: Planung/Abrechnung/Import/Notdienst-Prüfung → `/dashboard?module=profi` mit Hinweis und ohne gesperrte Links, `/api/v1/planning/board` und `/api/v1/billing` → 403). Die **3 Fehlschläge sind vorbestehend**: demo2-Seiten `/dashboard`, `/work-orders`, `/customers` enthalten „Elbblick“, weil `messages/*/marketing.json` (Produktseite, Commits `31343d3`/`4815a95`) den Beispieltext „Wohnanlage Elbblick“ enthält und die Messages an den Client serialisiert werden – kein Datenleck, aber die Isolationsprüfung schlägt an. Server danach beendet.
**Visuell:** nicht im Browser geprüft (Anmeldung hätte Passwort- oder Token-Eingabe erfordert). Bitte `/settings/lotse` (1024/768 px), `/admin/<id>` (Karte + Popup) und `/m/lotse` ohne Platz / bei hartem Limit (375 px) ansehen.
## 4. Offene Punkte
1. **Smoke-Isolationsprüfung „Elbblick“** (vorbestehend, s. o.): Beispieltext in `marketing.json` umbenennen oder die Prüfung auf Daten-Marker umstellen.
2. **Vormonat mit heutiger Platzzahl:** Die Betreiber-Karte rechnet das Kontingent des Vormonats mit den *aktuell* vergebenen Plätzen (keine Historie der Platzanzahl). Ändert sich die Platzzahl im Monat, ist der Vormonatswert eine Näherung → ggf. monatlichen Snapshot (Tabelle) nachziehen.
3. **Aufbewahrung:** Die Zählung basiert auf `LotseMessage`; `AI_GENERATION_RETENTION_DAYS` muss > ~62 Tage bleiben (Default 180), sonst fehlen Vormonats-Chats. Eine DSGVO-Löschung eines Nutzers entfernt auch seine Chats aus der Zählung.
4. **Hartes Limit unter Parallelität:** geprüft vor dem Speichern; gleichzeitige Nachrichten können das Kontingent um wenige Chats überschreiten.
5. **Worker:** `import-extraction` prüft die Stufe nicht (nur die Testphasen-Sperre). Nach einem Wechsel auf Basis laufen bereits eingereihte Importe noch durch; neue Importe sind gesperrt. Transkription ist über `isLotseEnabled` gesperrt.
6. **Abrechnungseinträge im Hintergrund:** Der L14-Event-Hook legt auch bei Basis weiter Abrechnungskandidaten an (unsichtbar, nach Upgrade vorhanden) – bewusst belassen („Daten bleiben erhalten“).
7. **Sync-Abweisung endgültig:** Eine im Basis-Paket offline erfasste Notdienst-Operation wird nach einem Upgrade nicht automatisch nachgeholt.
8. `services/emergency/sync-ops.ts` prüft den Modulschalter weiterhin selbst (redundant zur zentralen Sync-Prüfung, unverändert).
## 5. Screens / Routen
| Route | Zugriff | Inhalt |
|---|---|---|
| `/dashboard?module=profi` | Backoffice | Hinweis „Im Paket Profi enthalten“ (Ziel aller gesperrten Profi-Seiten) |
| `/settings/lotse` | `tenant:manage` | Karte „Paket & Lotse-Chat“: Paket, Plätze, Schalter je Nutzer, Monatsverbrauch |
| `/m/lotse` | Feldrollen | ohne Platz / Basis: Hinweis; hartes Limit: Hinweis statt Eingabefeld |
| `/admin` | Plattform | Spalte „Paket“ |
| `/admin/<id>?plan=edit` | Plattform-Voll-Admin | Karte „Paket & Lotse-Chat“ + Popup „Paket & Chat-Plätze ändern“ |