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/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/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" });