- 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>
16 KiB
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
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 |
| 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.sqlsrc/lib/plans.ts,src/server/plan.tssrc/server/services/lotse/chat/{seats,usage}.ts,src/server/services/plans/platform.tssrc/server/actions/lotse/seats.ts,src/server/actions/plans-platform.tssrc/components/plans/{profi-hint,plan-admin-card,plan-form}.tsx,src/components/lotse/chat/{seat-admin,unavailable}.tsxmessages/{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
- Smoke-Isolationsprüfung „Elbblick“ (vorbestehend, s. o.): Beispieltext in
marketing.jsonumbenennen oder die Prüfung auf Daten-Marker umstellen. - 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.
- Aufbewahrung: Die Zählung basiert auf
LotseMessage;AI_GENERATION_RETENTION_DAYSmuss > ~62 Tage bleiben (Default 180), sonst fehlen Vormonats-Chats. Eine DSGVO-Löschung eines Nutzers entfernt auch seine Chats aus der Zählung. - Hartes Limit unter Parallelität: geprüft vor dem Speichern; gleichzeitige Nachrichten können das Kontingent um wenige Chats überschreiten.
- Worker:
import-extractionprü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 überisLotseEnabledgesperrt. - Abrechnungseinträge im Hintergrund: Der L14-Event-Hook legt auch bei Basis weiter Abrechnungskandidaten an (unsichtbar, nach Upgrade vorhanden) – bewusst belassen („Daten bleiben erhalten“).
- Sync-Abweisung endgültig: Eine im Basis-Paket offline erfasste Notdienst-Operation wird nach einem Upgrade nicht automatisch nachgeholt.
services/emergency/sync-ops.tsprü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“ |