Compare commits
52 Commits
af78157536
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3562b154f0 | |||
| 2053dbac28 | |||
| f2c0a896e8 | |||
| af1878d5ee | |||
| 8ec116c75a | |||
| 53a49a109f | |||
| 19340fcebd | |||
| 0afcf237e8 | |||
| fffb7ffbec | |||
| 9829228726 | |||
| bbbafd11bc | |||
| 14674fced3 | |||
| 6c4bff6f6c | |||
| 11a70c9ee9 | |||
| faed0d760f | |||
| 6829c44464 | |||
| 87a7b7cbcd | |||
| 6698ef1a92 | |||
| 5ef7b0c6cc | |||
| 7188733f70 | |||
| 0e6e55ef65 | |||
| eaf2c4574a | |||
| c2ebc8daa0 | |||
| a222711ad9 | |||
| b94d267584 | |||
| 46d19da54a | |||
| 14933753e7 | |||
| 42b89f110b | |||
| 44c1d4351f | |||
| 1f85277a3b | |||
| 0edd6e9b1a | |||
| f7d4be0c7c | |||
| 2d6caec235 | |||
| 90e2157613 | |||
| f501ca6476 | |||
| c1b26541af | |||
| dfc4e9b781 | |||
| c07b0cfaf0 | |||
| 645c5e5887 | |||
| 2dd11b439d | |||
| 59b8cd43fc | |||
| 5919a55cbf | |||
| 7edaf8c00b | |||
| 61a971ccc0 | |||
| 471cfbf98b | |||
| a257bc3f86 | |||
| 714f731ac9 | |||
| e10da76259 | |||
| 31d514b7ca | |||
| 52f538c432 | |||
| 32441d77c7 | |||
| 0b34e82b21 |
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
context: default
|
||||||
|
phase: quick-auftraege-1.9.x (keine GSD-Phase)
|
||||||
|
task: 0
|
||||||
|
total_tasks: 0
|
||||||
|
status: paused
|
||||||
|
last_updated: 2026-10-02T08:00:00.000Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# BLOCKING CONSTRAINTS — Read Before Anything Else
|
||||||
|
|
||||||
|
- [ ] CONSTRAINT: live NICHT pushen/taggen, bis der User es verlangt.
|
||||||
|
- [ ] CONSTRAINT: Gebündelt pushen, nicht nach jeder Kleinigkeit.
|
||||||
|
- [ ] CONSTRAINT: Browser-Prüfungen im Dunkelmodus.
|
||||||
|
- [ ] CONSTRAINT: Echte Kundenzertifikate/Schlüssel nie ins Repo, lokale Kopien nach dem Test löschen.
|
||||||
|
|
||||||
|
<current_state>
|
||||||
|
main = live = v1.9.2 (2d6caec). Tag gepusht; CI-Läufe 481/482/483 grün, Release Tessera 1.9.2 mit Setup.exe + AppImage. Arbeitsbaum sauber.
|
||||||
|
</current_state>
|
||||||
|
|
||||||
|
<completed_work>
|
||||||
|
- 01.10.: Desktop-Favoriten öffnen im System-Browser (261001-cxo), Erinnerung-Cursor (261001-g68), Favoriten-Logo-Rückfall (261001-hbi); Freigabe 1.9.1, live gezogen.
|
||||||
|
- 01./02.10.: Zertifikat-Manager Reiter „Übersicht“ (261001-l4q): Paket/ZIP hochladen, Teile erkennen und zuordnen, jedes Teil in jedem Format; Desktop-Client speichert blob:/data:-Downloads selbst (VM gegen alpha nachgewiesen); geschützte PFX nur ruhiger Hinweis.
|
||||||
|
- Kalender-Test gehärtet (CI-Flake).
|
||||||
|
- 02.10.: Freigabe 1.9.2.
|
||||||
|
</completed_work>
|
||||||
|
|
||||||
|
<remaining_work>
|
||||||
|
- User: live auf 1.9.2 ziehen (df -h / vorher), alpha auf 2d6caec.
|
||||||
|
- Nächster Chat: NEUES MODUL (Auftrag kommt vom User).
|
||||||
|
</remaining_work>
|
||||||
|
|
||||||
|
<decisions_made>
|
||||||
|
- 1.9.2 statt 1.10.0 – ausdrücklicher Wunsch des Users.
|
||||||
|
- Logo-Rückfall DuckDuckGo nur für öffentliche Hosts.
|
||||||
|
- Aussteller-PFX-Passwort unbekannt und unnötig → ruhiger Hinweis.
|
||||||
|
</decisions_made>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
VM 8233: Client 1.9.1 Stand c1b2654 an alpha; Aussteller-ZIP auf dem VM-Desktop. alpha-Admin admin / admin1234.
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<next_action>
|
||||||
|
Neuen Chat abwarten: User bringt ein neues Modul. Fragen, ob live auf 1.9.2 gezogen ist.
|
||||||
|
</next_action>
|
||||||
+14
-5
@@ -6,7 +6,7 @@ current_phase_name: desktop-client-fertigstellen
|
|||||||
status: verified
|
status: verified
|
||||||
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
|
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
|
||||||
last_updated: "2026-09-23T15:30:00.000Z"
|
last_updated: "2026-09-23T15:30:00.000Z"
|
||||||
last_activity: 2026-09-30
|
last_activity: 2026-10-02
|
||||||
last_activity_desc: Quick 260928-ujj — Design Mosaik uebernommen, Hintergrund pro Benutzer in der DB; Freigabe 1.5.0
|
last_activity_desc: Quick 260928-ujj — Design Mosaik uebernommen, Hintergrund pro Benutzer in der DB; Freigabe 1.5.0
|
||||||
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
|
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
|
||||||
progress:
|
progress:
|
||||||
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
|
|||||||
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
|
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
|
||||||
Plan: 6 of 6
|
Plan: 6 of 6
|
||||||
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
|
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
|
||||||
Last activity: 2026-09-30 - Windows-Test (Tray-Update + Erinnerungs-Toast) bestanden; Review aller Aenderungen seit 26.09. mit 4 Fix-Commits (be1e003, c2e4467, 0710829, 12214a9)
|
Last activity: 2026-10-03 - Quick 261003-387 Kategorien bearbeitbar (lokal nachgewiesen, nicht gepusht)
|
||||||
|
|
||||||
Progress: [██████████] 99%
|
Progress: [██████████] 99%
|
||||||
|
|
||||||
@@ -483,6 +483,15 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
|
|||||||
| 260929-dzu | **Eigene Module fuer jeden Benutzer (persoenlich).** `CustomModule.ownerUserId` (null = gemeinsam), RLS-Muster SearchProvider, Einstellungen > Eigene Module (nur eigene), Verwaltung nur gemeinsame; Browser: Sichtbarkeit/Rechte wie verlangt. Nebenbei ohne eigenen Quick: Zentrierung entfernt (bc4c011), Desktop neue Fenster -> System-Browser (76a9234, Windows-VM bestaetigt), Single-Instance auf VM bestaetigt. | 2026-09-29 | c703d87,ee97b4e,8f41bd2 | [260929-dzu-eigene-module-fuer-jeden-benutzer-persoe](./quick/260929-dzu-eigene-module-fuer-jeden-benutzer-persoe/) |
|
| 260929-dzu | **Eigene Module fuer jeden Benutzer (persoenlich).** `CustomModule.ownerUserId` (null = gemeinsam), RLS-Muster SearchProvider, Einstellungen > Eigene Module (nur eigene), Verwaltung nur gemeinsame; Browser: Sichtbarkeit/Rechte wie verlangt. Nebenbei ohne eigenen Quick: Zentrierung entfernt (bc4c011), Desktop neue Fenster -> System-Browser (76a9234, Windows-VM bestaetigt), Single-Instance auf VM bestaetigt. | 2026-09-29 | c703d87,ee97b4e,8f41bd2 | [260929-dzu-eigene-module-fuer-jeden-benutzer-persoe](./quick/260929-dzu-eigene-module-fuer-jeden-benutzer-persoe/) |
|
||||||
| 260929-if2 | **Erinnerungen-Widget (Reminder).** Modell `Reminder` + RLS, API /reminders (anlegen/listen/bearbeiten/loeschen/erledigt/snooze, 409/404-Regeln), E-Mail-Scheduler alle 30 s mit Claim-once + max. 3 Versuche, globaler ReminderNotifier (Browser-Notification, Desktop via Tauri-Notification mit Laufzeit-Capability nur fuer die Server-Origin, Pattern escaped + vorab geprueft). Verifier human_needed (Windows-Toast offen); Browser dunkel bestanden inkl. echter Mail ueber MailHog. api 1570, web 1069, cargo 57. Nebenbei: eigene Module ohne Kopfzeile (cd1f8f6), Update-Klick prueft frisch (41d00a3). | 2026-09-29 | 325c5dd,709b41a,6879c75 | [260929-if2-reminder-widget-mit-benachrichtigung](./quick/260929-if2-reminder-widget-mit-benachrichtigung/) |
|
| 260929-if2 | **Erinnerungen-Widget (Reminder).** Modell `Reminder` + RLS, API /reminders (anlegen/listen/bearbeiten/loeschen/erledigt/snooze, 409/404-Regeln), E-Mail-Scheduler alle 30 s mit Claim-once + max. 3 Versuche, globaler ReminderNotifier (Browser-Notification, Desktop via Tauri-Notification mit Laufzeit-Capability nur fuer die Server-Origin, Pattern escaped + vorab geprueft). Verifier human_needed (Windows-Toast offen); Browser dunkel bestanden inkl. echter Mail ueber MailHog. api 1570, web 1069, cargo 57. Nebenbei: eigene Module ohne Kopfzeile (cd1f8f6), Update-Klick prueft frisch (41d00a3). | 2026-09-29 | 325c5dd,709b41a,6879c75 | [260929-if2-reminder-widget-mit-benachrichtigung](./quick/260929-if2-reminder-widget-mit-benachrichtigung/) |
|
||||||
| 260929-lh3 | **Favoriten: eigene Symbol-Adresse wirkt.** Neue iconUrl ersetzt Upload + bumpt iconVersion; iconUrl wird auch gespeichert, wenn nur der Browser sie laden kann (kein 422 mehr, nur Formpruefung); Kachel: Proxy -> iconUrl direkt -> origin/favicon -> Buchstabe; Discovery liest <link rel=icon> auch aus Nicht-2xx-Seiten (docuvita 400). | 2026-09-29 | 7188c5b,b15c746,0e72ad4 | [260929-lh3-favoriten-eigenes-symbol-wirkt-nicht](./quick/260929-lh3-favoriten-eigenes-symbol-wirkt-nicht/) |
|
| 260929-lh3 | **Favoriten: eigene Symbol-Adresse wirkt.** Neue iconUrl ersetzt Upload + bumpt iconVersion; iconUrl wird auch gespeichert, wenn nur der Browser sie laden kann (kein 422 mehr, nur Formpruefung); Kachel: Proxy -> iconUrl direkt -> origin/favicon -> Buchstabe; Discovery liest <link rel=icon> auch aus Nicht-2xx-Seiten (docuvita 400). | 2026-09-29 | 7188c5b,b15c746,0e72ad4 | [260929-lh3-favoriten-eigenes-symbol-wirkt-nicht](./quick/260929-lh3-favoriten-eigenes-symbol-wirkt-nicht/) |
|
||||||
|
| 261001-cxo | Desktop-Client: Links mit target=_blank (Favoriten) oeffnen jetzt im System-Browser (DesktopExternalLinks -> window.open) | 2026-10-01 | 61a971c | [261001-cxo](./quick/261001-cxo-desktop-client-links-mit-target-blank-oe/) |
|
||||||
|
| 261001-g68 | Erinnerung: Cursor sprang beim Schreiben der Beschreibung in den Titel (Fokus-Effekt hing an inline onClose, Kachel zeichnet alle 10 s neu) – Fokus nur beim Oeffnen | 2026-10-01 | siehe git log | [261001-g68](./quick/261001-g68-erinnerung-cursor-springt-aus-beschreibu/) |
|
||||||
|
| 261001-hbi | Favoriten: Logo fuer per JavaScript gesetzte Symbole (hosteurope.de) – Rueckfall auf DuckDuckGo-Symboldienst beim Ausliefern, nur oeffentliche Seiten | 2026-10-01 | siehe git log | [261001-hbi](./quick/261001-hbi-favoriten-logo-fuer-per-javascript-geset/) |
|
||||||
|
| 261001-l4q | Zertifikat-Manager: Reiter Übersicht (Paket/ZIP hochladen, Teile erkennen/zuordnen, jedes Teil in jedem Format) + Desktop speichert blob-Downloads selbst | 2026-10-01 | siehe git log | [261001-l4q](./quick/261001-l4q-zertifikatsmodul-paket-hochladen-uebersi/) |
|
||||||
|
| 261002-fm5 | Finanzbuchhaltung: Module Kantinenabrechnung und Handelsware (DATEV-Export), im Browser nachgewiesen | 2026-10-02 | 1f85277..HEAD | [261002-fm5-finanzbuchhaltung-module-kantinenabrechn](.planning/quick/261002-fm5-finanzbuchhaltung-module-kantinenabrechn/) |
|
||||||
|
| 261002-icv | Modul-Freigabe mit Stufe Verwalten (Modul-Einstellungen ohne Admin; Kantine, Handelsware, Proxmox, DKV), im Browser nachgewiesen | 2026-10-02 | a222711..HEAD | [261002-icv-modul-freigabe-mit-stufe-verwalten-modul](.planning/quick/261002-icv-modul-freigabe-mit-stufe-verwalten-modul/) |
|
||||||
|
| 261002-k67 | Modul Nextcloud-Status mit Ampel-Kacheln und Dashboard-Uebersicht | 2026-10-02 | 5ef7b0c..87a7b7c | [261002-k67-modul-nextcloud-status-mit-ampel-kacheln](.planning/quick/261002-k67-modul-nextcloud-status-mit-ampel-kacheln/) |
|
||||||
|
| 261002-kxc | Nextcloud-Status: Benachrichtigung bei Rot je Benutzer (Mail + Desktop-Hinweis), Klartext-Fehler | 2026-10-02 | faed0d7..6c4bff6 | [261002-kxc-nextcloud-status-benachrichtigung-bei-ro](.planning/quick/261002-kxc-nextcloud-status-benachrichtigung-bei-ro/) |
|
||||||
|
| 261003-387 | Kategorien durch Admins bearbeitbar (anlegen, umbenennen, sortieren, loeschen mit Verschieben, Module zuordnen) | 2026-10-03 | 8ec116c..f2c0a89 | [261003-387-kategorien-durch-admins-bearbeitbar-umbe](.planning/quick/261003-387-kategorien-durch-admins-bearbeitbar-umbe/) |
|
||||||
|
|
||||||
## Deferred Items
|
## Deferred Items
|
||||||
|
|
||||||
@@ -524,8 +533,8 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-09-22T13:40:00Z
|
Last session: 2026-10-02T09:10:00Z
|
||||||
Resumed: 2026-09-21 (abends) ueber /gsd-resume-work; seitdem Bilderrahmen, XFrame (inkl. Ausschnitt), Desktop-Korrekturen, Freigabe 1.3.0, Bilder in den Dateibereich.
|
Resumed: 2026-10-02 ueber /gsd-resume-work (HANDOFF nach Freigabe 1.9.2 eingelesen und entfernt).
|
||||||
Stopped at: hk4 fertig und nachgewiesen. Dem Nutzer vorgelegt: erst das Aufraeumen (Modul bringt seine Kachel selbst mit), dann Proxmox-Modul + Kachel — Antwort steht aus.
|
Stopped at: Session resumed — wartet auf Rueckmeldung live/alpha auf 1.9.2 und den Auftrag fuer das neue Modul.
|
||||||
Resume file: None
|
Resume file: None
|
||||||
Last activity: 2026-09-29 - Quick 260929-if2 Erinnerungen-Widget (lokal, nicht gepusht); v1.7.0 auf alpha+live
|
Last activity: 2026-09-29 - Quick 260929-if2 Erinnerungen-Widget (lokal, nicht gepusht); v1.7.0 auf alpha+live
|
||||||
|
|||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
quick_id: 261001-cxo
|
||||||
|
description: "Desktop-Client: Links mit target=_blank oeffnen"
|
||||||
|
date: 2026-10-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Desktop-Client: Links mit target=_blank oeffnen
|
||||||
|
|
||||||
|
**Befund (VM 8233, Client 1.9.0 gegen alpha):** Klick auf Favorit (`<a target="_blank">`) tut nichts; Such-Widget (`window.open`) oeffnet Edge ueber `on_new_window` (lib.rs). Der Rust-Weg funktioniert also, nur der Link-Klick erreicht ihn nicht.
|
||||||
|
|
||||||
|
## Task 1 — DesktopExternalLinks
|
||||||
|
- `apps/web/src/components/desktop/desktop-external-links.tsx`: im Desktop-Client (Cookie `tessera_desktop`) Links-/Mittelklick auf `a[href][target=_blank]` mit http/https per `window.open(href,'_blank','noopener,noreferrer')` oeffnen, `preventDefault`. Listener auf `window` (Bubble, nach React) -> von der Seite verhinderte Klicks (Favoriten im Bearbeiten-Modus) bleiben verhindert.
|
||||||
|
- In `apps/web/src/app/layout.tsx` neben `DesktopContextMenuGuard` einhaengen.
|
||||||
|
- Test `desktop-external-links.test.tsx`.
|
||||||
|
- CHANGELOG „Unveröffentlicht → Behoben“.
|
||||||
|
|
||||||
|
**Verify:** vitest gruen, tsc, biome; nach alpha-Pull auf VM 8233: Favorit oeffnet Edge.
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
quick_id: 261001-cxo
|
||||||
|
status: complete
|
||||||
|
date: 2026-10-01
|
||||||
|
commit: 61a971c
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary: Desktop-Client – Links mit target=_blank
|
||||||
|
|
||||||
|
- Nachgestellt auf VM 8233 (Client 1.9.0, alpha): Favorit-Klick ohne Wirkung, Such-Widget (`window.open`) oeffnet Edge.
|
||||||
|
- Neu `DesktopExternalLinks` (apps/web/src/components/desktop/desktop-external-links.tsx), in `app/layout.tsx` eingehaengt: im Client Links-/Mittelklick auf `a[target=_blank]` mit http/https -> `window.open(href,'_blank','noopener,noreferrer')`; verhinderte Klicks bleiben verhindert.
|
||||||
|
- 4 Tests (desktop-external-links.test.tsx), tsc + biome sauber. CHANGELOG „Unveröffentlicht → Behoben“.
|
||||||
|
- Reine Web-Aenderung: kein neuer Client noetig, wirkt nach Pull des web-Images.
|
||||||
|
- Offen: Nachweis auf VM nach alpha-Pull (User).
|
||||||
|
|
||||||
|
## Nachtrag (gleicher Tag): erste Fassung wirkte nicht
|
||||||
|
- Nach alpha-Pull weiter ohne Wirkung. Diagnose per temporaerem Klick-Protokoll (lokaler Stack, VM-Client per portproxy auf localhost:3000): `preventDefault` kam aus `<anonymous>:1:442` = Link-Skript von tauri-plugin-opener (init-iife.js, Listener auf `window`): faengt `target=_blank`-Klicks ab und ruft `plugin:opener|open_url` – von der Server-Seite nicht freigegeben, Klick verpufft. Unser Listener auf `window` lief danach und sah den Klick als verhindert.
|
||||||
|
- Fix: Listener auf `document` (Bubble) – nach React (Wurzel document), vor dem Opener-Skript. Auf VM nachgewiesen: Favorit oeffnet Edge, im Bearbeiten-Modus nichts (React-onClick verhindert).
|
||||||
|
- Commit siehe git log; Test „kommt dem Link-Skript des Clients auf window zuvor“.
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
quick_id: 261001-g68
|
||||||
|
description: "Erinnerung: Cursor springt aus Beschreibung in Titel"
|
||||||
|
date: 2026-10-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Erinnerung: Cursor springt aus Beschreibung in Titel
|
||||||
|
|
||||||
|
**Befund:** `ReminderFormModal` setzte den Fokus auf den Titel im selben Effekt wie den Escape-Listener, Abhaengigkeit `[onClose]`. `onClose` ist in der Kachel eine Inline-Funktion; die Kachel zeichnet alle 10 s neu (NOW_TICK_MS) und bei jedem Neuladen → Effekt laeuft erneut → Cursor springt in den Titel (User: beim Schreiben, und bei Loeschen-Taste in leerer Beschreibung).
|
||||||
|
|
||||||
|
## Task 1
|
||||||
|
- Fokus-Effekt nur beim Oeffnen (`[]`), Escape-Listener ueber `onCloseRef`.
|
||||||
|
- Test im Widget: Formular oeffnen, Beschreibung fokussieren, 30 s Takt → Fokus bleibt.
|
||||||
|
- CHANGELOG „Unveröffentlicht → Behoben“.
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
quick_id: 261001-g68
|
||||||
|
status: complete
|
||||||
|
date: 2026-10-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
- `reminder-form-modal.tsx`: Fokus auf Titel nur einmal beim Oeffnen; Escape ueber Ref statt `[onClose]`-Abhaengigkeit.
|
||||||
|
- Neuer Test in `reminder-widget.test.tsx` – schlaegt ohne Fix fehl, mit Fix gruen; 28/28 Erinnerungs-Tests, tsc, biome sauber.
|
||||||
|
- Andere Dialoge mit `[onClose]`-Fokus (Widget-Katalog, Bilderrahmen-Lightbox) fokussieren nur den Dialog ohne Eingabefelder – nicht betroffen.
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
quick_id: 261001-hbi
|
||||||
|
description: "Favoriten: Logo fuer per JavaScript gesetzte Symbole"
|
||||||
|
date: 2026-10-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Favoriten: Logo fuer per JavaScript gesetzte Symbole
|
||||||
|
|
||||||
|
**Befund:** https://www.hosteurope.de/ liefert im HTML nur `<link rel="icon" href="data:;base64,=">`; das echte Symbol (img1.wsimg.com/.../HostEurope.png) setzt erst JavaScript. `/favicon.ico`, `/apple-touch-icon.png`, `/favicon.svg` antworten 200 mit text/html. Die serverseitige Suche faellt auf `/favicon.ico` zurueck, der Abruf scheitert (kein Bild) → Buchstabe.
|
||||||
|
|
||||||
|
## Task 1
|
||||||
|
- `IconDiscoveryService.fetchPublicServiceIconBytes(pageUrl)`: DuckDuckGo-Symboldienst (`icons.duckduckgo.com/ip3/<host>.ico`), NUR wenn die Seite oeffentlich ist (isPublicHttpUrl) — interne Hostnamen verlassen das Haus nicht; 404 fuer Unbekanntes → wirft → Buchstabe bleibt.
|
||||||
|
- `FavoritesService.getIconBytes`: scheitert das gespeicherte Symbol, einmal den Dienst fragen, sonst 502 wie bisher. Repariert auch bestehende Favoriten ohne Neuanlage.
|
||||||
|
- Tests in beiden Specs; CHANGELOG.
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
quick_id: 261001-hbi
|
||||||
|
status: complete
|
||||||
|
date: 2026-10-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
- Rueckfall auf den oeffentlichen Symbol-Dienst beim Ausliefern (`getIconBytes`), nur fuer oeffentliche Seiten.
|
||||||
|
- 5 neue Tests (Dienst-URL, interne Seite fragt nicht, 404 wirft, Service nutzt Rueckfall / nicht bei Erfolg); 111/111 Favoriten-Tests, tsc, biome-Stand unveraendert.
|
||||||
|
- Lokal im Browser nachgewiesen: Favorit https://www.hosteurope.de/ zeigt das gruene H-Logo (32x32 ueber /api-proxy/favorites/<id>/icon).
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
quick_id: 261001-l4q
|
||||||
|
description: "Zertifikatsmodul: Paket hochladen, Uebersicht, Download in jedem Format"
|
||||||
|
date: 2026-10-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Zertifikatsmodul: Paket hochladen, Uebersicht, Download in jedem Format
|
||||||
|
|
||||||
|
**Auftrag (User):** Testdatei = ZIP vom Aussteller (pem mit Server+Zwischen, key, csr, pfx mit unbekanntem Passwort, .dnstxtrecord). Nach dem Hochladen soll angezeigt werden, welches Zertifikat was ist, darunter jedes Zertifikat in jedem Format herunterladbar.
|
||||||
|
|
||||||
|
**Befund vorher:** Modul nimmt nur EINE Datei; .key/.csr/.zip nicht waehlbar; keine Uebersicht; Labels teils englisch; Texte duzen.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
1. API `cert-bundle.ts`: `analyzeBundle` (Dateien + ZIP mit Grenzen vor dem Entpacken; PEM/DER/PFX/P7B; Duplikate per SHA-256/Modulus; Schluessel/CSR-Zuordnung; Kette) und `exportBundleItem` (crt, cer, fullchain, p7b, pfx inkl. Schluessel+Kette; key PKCS#8/PKCS#1/DER; csr PEM/DER). Endpunkte POST analyze / export.
|
||||||
|
2. Web: Reiter „Übersicht“ (Standard), Mehrfach-Ablage, Karten je Teil mit Erklaerung, Status, Zuordnung, Download-Knoepfen, PFX-Passwort; geschuetzte PFX entsperren. Texte de/en, Sie-Form.
|
||||||
|
3. Desktop: blob:/data:-Downloads in der App speichern (Downloads-Ordner) + Meldung; vorher landete der Klick als „blob-Link“ im System-Browser (VM gemessen).
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
quick_id: 261001-l4q
|
||||||
|
status: complete
|
||||||
|
date: 2026-10-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
- Echte Aussteller-ZIP (nur lokal, nicht im Repo): 4 Teile erkannt (Server, Zwischen, Schluessel→Server, CSR→Server), PFX als geschuetzt gemeldet, .dnstxtrecord als nicht verwendet; alle 15 Exporte mit OpenSSL gueltig (PFX mit 3 Bloecken, Schluessel-Modulus passt).
|
||||||
|
- Tests: API cert-bundle.spec 11 (selbst erzeugte PKI), Web OverviewTab.test 5 + Seitentest angepasst; gesamt Web 1189, API 1686, Rust 65 gruen; tsc/clippy/rustfmt sauber.
|
||||||
|
- Browser lokal: Uebersicht + Downloads (fullchain, pfx, rsa.key, csr.der) geprueft.
|
||||||
|
- Desktop-Client (VM, lokal): Upload/Anzeige ok; Download zeigte „blob-Link“-Fehler → Client-Fix (on_download speichert blob:/data: selbst, Meldung danach). Nachweis 02.10. auf VM gegen alpha mit Client 1.9.1 Stand c1b2654: crt + fullchain + pfx landen in Downloads, Meldung „Download gespeichert“; Windows certutil liest die PFX (2 Zertifikate, Schluesseltest fuer das Serverzertifikat bestanden). Testdateien danach geloescht.
|
||||||
+343
@@ -0,0 +1,343 @@
|
|||||||
|
---
|
||||||
|
phase: quick-261002-fm5
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
quick_id: 261002-fm5
|
||||||
|
description: "Finanzbuchhaltung: Module Kantinenabrechnung (kantine-datev) und Handelsware (handelsware-datev)"
|
||||||
|
date: 2026-10-02
|
||||||
|
files_modified:
|
||||||
|
# Task 1 — category + Kantinenabrechnung end-to-end (tracer)
|
||||||
|
- packages/shared/src/index.ts
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/prisma/migrations/20261002120000_kantine_datev_config/migration.sql
|
||||||
|
- apps/api/src/accounting/decode-csv-text.ts
|
||||||
|
- apps/api/src/accounting/decode-csv-text.spec.ts
|
||||||
|
- apps/api/src/kantine-datev/kantine-datev.types.ts
|
||||||
|
- apps/api/src/kantine-datev/kantine-csv.parser.ts
|
||||||
|
- apps/api/src/kantine-datev/kantine-csv.parser.spec.ts
|
||||||
|
- apps/api/src/kantine-datev/kantine-csv.validator.ts
|
||||||
|
- apps/api/src/kantine-datev/kantine-csv.validator.spec.ts
|
||||||
|
- apps/api/src/kantine-datev/kantine-datev.transformer.ts
|
||||||
|
- apps/api/src/kantine-datev/kantine-datev.transformer.spec.ts
|
||||||
|
- apps/api/src/kantine-datev/kantine-datev.pipeline.ts
|
||||||
|
- apps/api/src/kantine-datev/kantine-datev.pipeline.spec.ts
|
||||||
|
- apps/api/src/kantine-datev/dto/kantine-datev-settings.dto.ts
|
||||||
|
- apps/api/src/kantine-datev/kantine-datev.service.ts
|
||||||
|
- apps/api/src/kantine-datev/kantine-datev.service.spec.ts
|
||||||
|
- apps/api/src/kantine-datev/kantine-datev.controller.ts
|
||||||
|
- apps/api/src/kantine-datev/kantine-datev.controller.spec.ts
|
||||||
|
- apps/api/src/kantine-datev/kantine-datev.seed.ts
|
||||||
|
- apps/api/src/kantine-datev/kantine-datev.module.ts
|
||||||
|
- apps/api/src/app.module.ts
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
- apps/web/src/messages/de.json
|
||||||
|
- apps/web/src/messages/en.json
|
||||||
|
- apps/web/src/lib/download-base64.ts
|
||||||
|
- apps/web/src/lib/kantine-datev-api.ts
|
||||||
|
- apps/web/src/lib/module-loader.ts
|
||||||
|
- apps/web/src/lib/module-identity.ts
|
||||||
|
- apps/web/src/components/modules/module-tile.tsx
|
||||||
|
- apps/web/src/lib/stores/nav-store.ts
|
||||||
|
- apps/web/src/app/(portal)/modules/kantine-datev/layout.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/kantine-datev/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/kantine-datev/kantine-datev.test.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/module-layouts.test.tsx
|
||||||
|
# Task 2 — Handelsware API + Prisma
|
||||||
|
- apps/api/prisma/migrations/20261002130000_handelsware_datev/migration.sql
|
||||||
|
- apps/api/src/handelsware-datev/handelsware-datev.types.ts
|
||||||
|
- apps/api/src/handelsware-datev/handelsware-xlsx.ts
|
||||||
|
- apps/api/src/handelsware-datev/handelsware-xlsx.spec.ts
|
||||||
|
- apps/api/src/handelsware-datev/handelsware-transform.ts
|
||||||
|
- apps/api/src/handelsware-datev/handelsware-transform.spec.ts
|
||||||
|
- apps/api/src/handelsware-datev/handelsware-konten-csv.ts
|
||||||
|
- apps/api/src/handelsware-datev/handelsware-konten-csv.spec.ts
|
||||||
|
- apps/api/src/handelsware-datev/dto/handelsware-settings.dto.ts
|
||||||
|
- apps/api/src/handelsware-datev/dto/handelsware-account.dto.ts
|
||||||
|
- apps/api/src/handelsware-datev/handelsware-datev.service.ts
|
||||||
|
- apps/api/src/handelsware-datev/handelsware-datev.service.spec.ts
|
||||||
|
- apps/api/src/handelsware-datev/handelsware-datev.controller.ts
|
||||||
|
- apps/api/src/handelsware-datev/handelsware-datev.controller.spec.ts
|
||||||
|
- apps/api/src/handelsware-datev/handelsware-datev.seed.ts
|
||||||
|
- apps/api/src/handelsware-datev/handelsware-datev.module.ts
|
||||||
|
# Task 3 — Handelsware web + docs + local stack
|
||||||
|
- apps/web/src/lib/handelsware-datev-api.ts
|
||||||
|
- apps/web/src/app/(portal)/modules/handelsware-datev/layout.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/handelsware-datev/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/handelsware-datev/components/ImportTab.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/handelsware-datev/components/AccountsTab.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/handelsware-datev/components/SettingsTab.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/handelsware-datev/handelsware-datev.test.tsx
|
||||||
|
- CHANGELOG.md
|
||||||
|
- docs/anleitung-anwender.md
|
||||||
|
autonomous: true
|
||||||
|
requirements: [QUICK-261002-fm5]
|
||||||
|
|
||||||
|
estimate:
|
||||||
|
tokens: 420000
|
||||||
|
raw_tokens: 420000
|
||||||
|
tasks: 3
|
||||||
|
confidence: low
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "A user with a grant for kantine-datev sees a new sidebar group 'Finanzbuchhaltung' (en 'Financial accounting') with the entry 'Kantinenabrechnung'; same group holds 'Handelsware' when granted"
|
||||||
|
- "Uploading a canteen CSV (UTF-8, UTF-8 with BOM, or Windows-1252, CRLF or LF) shows row count, billing month MM/YYYY, total amount, row errors with line numbers and warnings; nothing from the upload is written to the database or logs"
|
||||||
|
- "Clicking download on a valid CSV yields a DATEV Lohn ASCII file: header Beraternr TAB Mandantennr TAB MM/YYYY + 8 empty columns, detail rows TAB PersonalNr TAB TAB Lohnart TAB -Betrag + 6 empty columns, exactly 11 columns per line, CRLF everywhere and at the end, quality check passed"
|
||||||
|
- "Beraternummer, Mandantennummer, Lohnart, Standard-Erloeskonto and Startwert Gegenkonto start empty per tenant; while empty the modules block processing with a clear German hint; only ADMIN/SUPER_ADMIN can change them; only digits are accepted"
|
||||||
|
- "Uploading a Handelsware XLSX shows a preview (Buchungstext, Umsatz abs dot 2 decimals, S/H, Gegenkonto, Datum TTMM, Erloeskonto); unknown products get the next free Gegenkonto and a visible 'neu' marker; the date is derived from the filename MMYY and is editable with TTMM validation"
|
||||||
|
- "New accounts are persisted only when the user downloads the TXT, in one tenant-bound transaction that re-checks for conflicts (409 when the account list changed since the preview)"
|
||||||
|
- "Tab 'Konten' lists, creates, edits, deletes accounts, imports CSV Name;Gegenkonto;Konto (replace-all after confirmation) and exports CSV"
|
||||||
|
- "Downloads work in the browser and in the Tauri desktop client (client-side blob download, same mechanism as cert-manager)"
|
||||||
|
artifacts:
|
||||||
|
- path: apps/api/src/kantine-datev/kantine-datev.transformer.ts
|
||||||
|
provides: "DATEV Lohn ASCII generation + quality check (ported from source transformer.ts, settings-driven header/Lohnart)"
|
||||||
|
- path: apps/api/src/kantine-datev/kantine-datev.controller.ts
|
||||||
|
provides: "GET/PUT settings, POST preview, POST export under modules/kantine-datev with @UseModule('kantine-datev')"
|
||||||
|
- path: apps/api/prisma/migrations/20261002120000_kantine_datev_config/migration.sql
|
||||||
|
provides: "KantineDatevConfig table with ENABLE+FORCE RLS and tenant_isolation_policy"
|
||||||
|
- path: apps/api/prisma/migrations/20261002130000_handelsware_datev/migration.sql
|
||||||
|
provides: "HandelswareDatevConfig + HandelswareKonto tables (unique tenantId+name) with RLS"
|
||||||
|
- path: apps/api/src/handelsware-datev/handelsware-datev.service.ts
|
||||||
|
provides: "Settings, account CRUD, CSV import/export, preview, export with withTenantTransaction"
|
||||||
|
- path: apps/web/src/app/(portal)/modules/kantine-datev/page.tsx
|
||||||
|
provides: "Kantinenabrechnung UI (upload, preview, download, admin settings)"
|
||||||
|
- path: apps/web/src/app/(portal)/modules/handelsware-datev/page.tsx
|
||||||
|
provides: "Handelsware UI with tabs Import / Konten / Einstellungen"
|
||||||
|
key_links:
|
||||||
|
- from: apps/api/src/kantine-datev/kantine-datev.seed.ts
|
||||||
|
to: "Module row slug kantine-datev, category accounting"
|
||||||
|
via: "ModuleRegistryService.seedModule in KantineDatevModule.onModuleInit"
|
||||||
|
pattern: "category: 'accounting'"
|
||||||
|
- from: apps/web/src/lib/module-loader.ts
|
||||||
|
to: apps/web/src/app/(portal)/modules/kantine-datev/page.tsx
|
||||||
|
via: "MODULE_REGISTRY entry rendered by [category]/[moduleSlug] ModuleShell"
|
||||||
|
pattern: "'kantine-datev'"
|
||||||
|
- from: apps/web/src/messages/de.json
|
||||||
|
to: "sidebar category label"
|
||||||
|
via: "moduleCategories.accounting read by useCategoryLabel"
|
||||||
|
pattern: "\"accounting\": \"Finanzbuchhaltung\""
|
||||||
|
- from: apps/api/src/handelsware-datev/handelsware-datev.service.ts
|
||||||
|
to: "HandelswareKonto rows"
|
||||||
|
via: "withTenantTransaction(this.prisma, tenantId, async (tx) => ...) on export and CSV replace"
|
||||||
|
pattern: "withTenantTransaction"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Port the two finance features from the colleague's Tauri app (`user-files/headflow/app/src/modules/datev/` = canteen billing, `user-files/headflow/app/src/modules/handelsware/` = merchandise; nothing else from that app) into Tessera as two modules, `kantine-datev` ("Kantinenabrechnung") and `handelsware-datev` ("Handelsware"), grouped under a new sidebar category `accounting` ("Finanzbuchhaltung" / "Financial accounting").
|
||||||
|
|
||||||
|
Purpose: the finance team uses Tessera instead of a separate desktop tool; access via the existing activation + ModuleGrants; no company-specific defaults (Tessera is a multi-tenant product).
|
||||||
|
Output: two API modules with pure, tested processing functions, two Prisma migrations with RLS, two web module pages, i18n de/en, docs + CHANGELOG, local stack rebuilt. No push.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@~/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/STATE.md
|
||||||
|
@./CLAUDE.md
|
||||||
|
|
||||||
|
Source of business rules (read, do not copy UI/Tauri code):
|
||||||
|
@user-files/headflow/app/src/modules/datev/parser.ts
|
||||||
|
@user-files/headflow/app/src/modules/datev/validator.ts
|
||||||
|
@user-files/headflow/app/src/modules/datev/transformer.ts
|
||||||
|
@user-files/headflow/app/src/modules/datev/types.ts
|
||||||
|
@user-files/headflow/app/src/modules/handelsware/handelswareService.ts
|
||||||
|
@user-files/headflow/app/src/modules/handelsware/handelswareTypes.ts
|
||||||
|
Download filename rules live in the source UI: `datev/ui/DatevPreview.tsx` (downloadFileName, lines ~33-35) and `handelsware/ui/HandelswarePage.tsx` (getExportFilename, lines ~30-34).
|
||||||
|
|
||||||
|
Tessera analogs (patterns to follow):
|
||||||
|
- Module seed + module class: `apps/api/src/cert-manager/cert-manager.seed.ts`, `apps/api/src/cert-manager/cert-manager.module.ts`
|
||||||
|
- Controller with `@UseModule`, `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`, `requireTenantId`: `apps/api/src/proxmox/proxmox.controller.ts`
|
||||||
|
- Multer upload (memory, 5 MB limit), `UploadedFileLike` (`buffer`, `originalname`, `size`): `apps/api/src/cert-manager/cert-manager.controller.ts`
|
||||||
|
- Tenant-bound Prisma: `forTenant` (assignment form `const tenantPrisma = forTenant(this.prisma, tenantId)`) and `withTenantTransaction(this.prisma, tenantId, async (tx) => ...)` in `apps/api/src/prisma/prisma-tenant.extension.ts` (header comment explains why never `$transaction` on a bound client)
|
||||||
|
- Singleton-per-tenant config model: `DkvModuleConfig` in `apps/api/prisma/schema.prisma`; RLS migration header + SQL form: `apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql`
|
||||||
|
- RLS gates: `apps/api/src/prisma/rls-coverage.spec.ts`, `apps/api/src/prisma/rls-access-inventory.spec.ts` (+ Fundstellentabelle in `docs/mandantentrennung-zugriffsklassifikation.md`)
|
||||||
|
- Route-order test: `apps/api/src/custom-modules/custom-modules.controller.spec.ts` (describe "Routen-Reihenfolge")
|
||||||
|
- Web: module page + PageHeader + tabs `apps/web/src/app/(portal)/modules/cert-manager/page.tsx`; layout gate `apps/web/src/app/(portal)/modules/cert-manager/layout.tsx`; drop area `apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx` (uses certManager texts, so build a module-local equivalent instead of importing it); blob download `downloadBase64` in `apps/web/src/app/(portal)/modules/cert-manager/actions.ts`; API client style `apps/web/src/lib/custom-modules-api.ts`; admin detection `useAuthStore` in `apps/web/src/app/(portal)/modules/proxmox/page.tsx`; page test with real `NextIntlClientProvider` + mocked api client + mocked auth store `apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx`
|
||||||
|
- Registries: `apps/web/src/lib/module-loader.ts` (MODULE_REGISTRY), `apps/web/src/lib/module-identity.ts` (ICONS + ModuleIconId), `apps/web/src/components/modules/module-tile.tsx` (GLYPHS), `apps/web/src/lib/stores/nav-store.ts` (MODULE_TITLE_KEYS), `packages/shared/src/index.ts` (MODULE_CATEGORIES), `apps/web/src/messages/{de,en}.json` (`moduleCategories`), `apps/web/src/app/(portal)/modules/module-layouts.test.tsx`
|
||||||
|
|
||||||
|
Project memory that applies: NestJS static routes before `:id` (unit tests do not catch shadowing); db container has no host port (use container IP); plain `docker compose up` does not rebuild; no customer-specific defaults; app texts use formal "Sie"; do not push.
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<coverage_audit>
|
||||||
|
Sources: the orchestrator task description (GOAL) only — no ROADMAP phase, REQUIREMENTS, RESEARCH.md or CONTEXT.md for this quick task.
|
||||||
|
|
||||||
|
| Source item | Covered by |
|
||||||
|
|---|---|
|
||||||
|
| New category `accounting`, de "Finanzbuchhaltung" / en "Financial accounting" | Task 1 |
|
||||||
|
| Kantine: CSV upload, UTF-8 / cp1252 detection | Task 1 |
|
||||||
|
| Kantine: validation exactly as parser.ts/validator.ts, month from "bis", multi-month warning | Task 1 |
|
||||||
|
| Kantine: preview (rows, month, total, row errors with lines, warnings) | Task 1 |
|
||||||
|
| Kantine: DATEV Lohn ASCII per transformer.ts + quality check | Task 1 |
|
||||||
|
| Kantine: Berater/Mandant/Lohnart as admin settings per tenant, empty default, blocked hint, numeric | Task 1 |
|
||||||
|
| Kantine: no persistence of uploaded data; pure functions + Vitest (cp1252 umlaut, CRLF/LF, multi-month, invalid rows) | Task 1 |
|
||||||
|
| Handelsware: XLSX B1 header, A/B rows, number or German string | Task 2 |
|
||||||
|
| Handelsware: Prisma model per tenant with RLS, unique name per tenant, migration | Task 2 |
|
||||||
|
| Handelsware: preview columns, auto Gegenkonto (max+1 / Startwert), "neu" marker | Task 2 (API) + Task 3 (UI) |
|
||||||
|
| Handelsware: persist new accounts only on export, one transaction, conflict re-check | Task 2 (API) + Task 3 (UI) |
|
||||||
|
| Handelsware: Buchungsdatum from filename MMYY, editable, TTMM validation | Task 2 (API) + Task 3 (UI) |
|
||||||
|
| Handelsware: TXT format, CRLF, filename `<Prefix>_<MMYY>.txt`, UTF-8 + open question in SUMMARY | Task 2 + Task 3 |
|
||||||
|
| Handelsware: Konten tab CRUD, CSV import replace-all with confirmation, CSV export | Task 2 (API) + Task 3 (UI) |
|
||||||
|
| Handelsware: settings Standard-Erloeskonto / Startwert Gegenkonto, empty, blocked hint | Task 2 + Task 3 |
|
||||||
|
| ModuleGrants access, de (Sie) + en texts, existing upload/download patterns, desktop-safe downloads | Tasks 1-3 |
|
||||||
|
| Static routes before `:id` + declaration-order test | Task 2 |
|
||||||
|
| Tests api + web, tsc, biome | Tasks 1-3 |
|
||||||
|
| Local migration via container IP, `docker compose up -d --build api web` | Tasks 1-3 |
|
||||||
|
| Browser check (Playwright, dark mode) or hand-off note in SUMMARY | Task 3 |
|
||||||
|
| Atomic commit per task, no push | Tasks 1-3 |
|
||||||
|
</coverage_audit>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="tracer">
|
||||||
|
<name>Task 1: Category "Finanzbuchhaltung" + Kantinenabrechnung end-to-end (API, migration, web)</name>
|
||||||
|
<files>packages/shared/src/index.ts, apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261002120000_kantine_datev_config/migration.sql, apps/api/src/accounting/decode-csv-text.ts, apps/api/src/accounting/decode-csv-text.spec.ts, apps/api/src/kantine-datev/kantine-datev.types.ts, apps/api/src/kantine-datev/kantine-csv.parser.ts, apps/api/src/kantine-datev/kantine-csv.parser.spec.ts, apps/api/src/kantine-datev/kantine-csv.validator.ts, apps/api/src/kantine-datev/kantine-csv.validator.spec.ts, apps/api/src/kantine-datev/kantine-datev.transformer.ts, apps/api/src/kantine-datev/kantine-datev.transformer.spec.ts, apps/api/src/kantine-datev/kantine-datev.pipeline.ts, apps/api/src/kantine-datev/kantine-datev.pipeline.spec.ts, apps/api/src/kantine-datev/dto/kantine-datev-settings.dto.ts, apps/api/src/kantine-datev/kantine-datev.service.ts, apps/api/src/kantine-datev/kantine-datev.service.spec.ts, apps/api/src/kantine-datev/kantine-datev.controller.ts, apps/api/src/kantine-datev/kantine-datev.controller.spec.ts, apps/api/src/kantine-datev/kantine-datev.seed.ts, apps/api/src/kantine-datev/kantine-datev.module.ts, apps/api/src/app.module.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/lib/download-base64.ts, apps/web/src/lib/kantine-datev-api.ts, apps/web/src/lib/module-loader.ts, apps/web/src/lib/module-identity.ts, apps/web/src/components/modules/module-tile.tsx, apps/web/src/lib/stores/nav-store.ts, apps/web/src/app/(portal)/modules/kantine-datev/layout.tsx, apps/web/src/app/(portal)/modules/kantine-datev/page.tsx, apps/web/src/app/(portal)/modules/kantine-datev/kantine-datev.test.tsx, apps/web/src/app/(portal)/modules/module-layouts.test.tsx</files>
|
||||||
|
<behavior>
|
||||||
|
- decodeCsvText: valid UTF-8 bytes (with or without BOM) decode unchanged and BOM is dropped; bytes that are invalid UTF-8 (e.g. "Müller" encoded Windows-1252, i.e. Buffer latin1) decode via windows-1252 to "Müller"; "€" byte 0x80 in cp1252 becomes "€"
|
||||||
|
- parseKantinenCsv (port of source parser.ts): CRLF and LF input give identical rows; empty lines skipped; header with fewer than 11 columns gives row-1 error "…Ist das Trennzeichen korrekt (Semikolon)?"; data line with fewer than 11 columns gives error with its 1-based line number and is skipped
|
||||||
|
- validateKantinenData (port of source validator.ts, same messages): non-numeric PersonalNr, missing/invalid Betrag (regex digits with optional comma decimals — "1.234,56" and "-5,00" are rejected exactly as in the source), date not TT.MM.JJJJ, von/bis in different months are errors with row = index + 2; billing month from "bis" as MM/YYYY; two different months produce the source warning text and keep the first month
|
||||||
|
- transformBetrag: "7,94" → "-7.94", "51,5" → "-51.50", "0,00" → "-0.00"
|
||||||
|
- generateDatevOutput(records, month, settings): header = beraterNr, mandantNr, month + 8 empty columns; each detail = empty, PersonalNr, empty, lohnart, betrag + 6 empty; every line 11 columns; CRLF joined plus trailing CRLF; qualityCheck passes on it and fails (with the source messages) on a LF-only string, on a 10-column line and on a positive Betrag
|
||||||
|
- processKantineCsv(buffer, settings | null): returns { rowCount, abrechnungsMonat, totalCents, errors[{row, field, code, message}], warnings[{code, message, params}], canExport, blockedReason }; zero data rows → error code noRows; settings missing → canExport false with blockedReason settingsMissing; buildExport refuses when errors exist or settings missing and runs qualityCheck before returning; export filename LuG_<beraterNr>_<mandantNr>_<MM>_<YYYY>.sic
|
||||||
|
- Settings DTO accepts only digit strings (1-10 digits) for all three fields; service returns { beraterNr, mandantNr, lohnart, configured } with nulls and configured=false when no row exists
|
||||||
|
- Controller: class path modules/kantine-datev, @UseModule('kantine-datev'), PUT settings carries @Roles(ADMIN, SUPER_ADMIN), GET settings / preview / export carry no role; export of a file with errors → 400; no settings → 400 with code settingsMissing
|
||||||
|
- Web page: shows the not-configured hint (admin sees the settings form, non-admin sees "Ein Administrator muss …"); after upload shows Zeilen / Abrechnungsmonat / Gesamtbetrag, warning list and error table with line numbers; download button disabled while errors exist; clicking download calls the export client and downloadBase64 with the returned filename
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**Category (shared + i18n).** In `packages/shared/src/index.ts` add `"accounting"` to `MODULE_CATEGORIES` (keeps the module-categories spec in step with seeds; custom modules may then also use the group — intended). In `apps/web/src/messages/de.json` add `moduleCategories.accounting = "Finanzbuchhaltung"`, in `en.json` `"Financial accounting"`.
|
||||||
|
|
||||||
|
**Prisma + migration.** Add model `KantineDatevConfig` to `apps/api/prisma/schema.prisma` following `DkvModuleConfig`: `id` uuid, `tenantId String @unique`, `beraterNr String?`, `mandantNr String?`, `lohnart String?`, `createdAt`, `updatedAt`, `@@index([tenantId])`. Strings (not Int) so leading zeros survive; no `@default` on the three fields — the colleague's hardcoded header/Lohnart constants from source `transformer.ts` (KOPF_SPALTE_1, KOPF_SPALTE_2, DETAIL_SPALTE_4) must not appear anywhere as defaults, examples, placeholders or test values (use neutral test values such as 1234567 / 12345 / 1111). Hand-write `apps/api/prisma/migrations/20261002120000_kantine_datev_config/migration.sql` in the form of `20260923140000_proxmox_server`: German header comment (purpose, RLS without user dimension, no system_read_policy because there is no scheduler, grants via ALTER DEFAULT PRIVILEGES, switch note), CREATE TABLE, unique index `KantineDatevConfig_tenantId_key`, index on tenantId, ENABLE + FORCE ROW LEVEL SECURITY, `CREATE POLICY tenant_isolation_policy ... USING ("tenantId" = current_tenant_id())`. Run `pnpm --filter @tessera/api exec prisma generate`. Apply locally: get the IP with `docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1`, then `DATABASE_URL="postgresql://tessera:tessera_dev@<IP>:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy` and confirm with `prisma migrate status` (same env).
|
||||||
|
|
||||||
|
**Shared decoder.** `apps/api/src/accounting/decode-csv-text.ts` exports `decodeCsvText(buffer: Buffer): string`: try `new TextDecoder('utf-8', { fatal: true })` (drops BOM by default); on TypeError fall back to `new TextDecoder('windows-1252')`. (Source also sniffed CP850 for the Konten CSV — not required here; Excel CSV is UTF-8 or cp1252.) Reused by Task 2.
|
||||||
|
|
||||||
|
**Pure functions (port, keep source German messages).** `kantine-datev.types.ts` ports source `types.ts` and adds a stable `code` to every error/warning (codes: headerMissing, headerColumns, columnCount, personalNrMissing, personalNrNotNumeric, betragMissing, betragFormat, vonMissing, vonFormat, bisMissing, bisFormat, multiMonthRange, noRows; warning multipleMonths with params.months) so the web can translate while `message` keeps the source text. `kantine-csv.parser.ts` = source `parser.ts` (input already decoded string). `kantine-csv.validator.ts` = source `validator.ts`, rules and regexes unchanged. `kantine-datev.transformer.ts` = source `transformer.ts` with the three constants replaced by a `KantineDatevSettings { beraterNr, mandantNr, lohnart }` argument to `generateDatevOutput`; `qualityCheck` unchanged; add `buildKantineExportFilename(settings, month)` per source DatevPreview rule. `kantine-datev.pipeline.ts`: `processKantineCsv(buffer, settings)` = decode → parse → validate → totals (sum Betrag in integer cents from the German string, only for rows that passed Betrag validation) → preview object; `buildKantineExport(buffer, settings)` = same steps, throws a typed error when errors exist / no rows / settings missing, generates output, runs `qualityCheck`, throws when it fails, returns `{ filename, content (base64 of the ASCII output), mimeType: 'text/plain' }` — same FileResponse shape as cert-manager. Never log row content or names.
|
||||||
|
|
||||||
|
**Service/DTO/controller.** `dto/kantine-datev-settings.dto.ts`: three required `@IsString() @Matches(/^\d{1,10}$/)` fields with German messages ("… darf nur Ziffern enthalten"). `kantine-datev.service.ts`: `getSettings(tenantId)` (findUnique by tenantId via `const tenantPrisma = forTenant(this.prisma, tenantId)`), `saveSettings(tenantId, dto)` (upsert on tenantId), `preview(tenantId, buffer)`, `export(tenantId, buffer)` (load settings, call pipeline, map typed errors to `BadRequestException({ code, message, errors })` / quality failure to `UnprocessableEntityException`). `kantine-datev.controller.ts`: `@Controller('modules/kantine-datev') @UseModule('kantine-datev')`, `requireTenantId` like ProxmoxController; `GET settings`, `PUT settings` with `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`, `POST preview` and `POST export` with `FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } })`, 400 when no file. No `:id` routes here. Uploaded buffers stay in memory (multer memory storage default) and are never persisted. `kantine-datev.seed.ts`: slug `kantine-datev`, name `Kantinenabrechnung`, version `1.0.0`, `category: 'accounting'`, description de "Kantinen-CSV prüfen und als DATEV-Lohndatei (ASCII) für die Gehaltsabrechnung exportieren" / en "Check canteen CSV files and export them as a DATEV payroll ASCII file", `isSystem: true`. `kantine-datev.module.ts` like CertManagerModule (imports ModuleRegistryModule, seeds in onModuleInit with try/catch + logger). Register `KantineDatevModule` in `apps/api/src/app.module.ts`.
|
||||||
|
|
||||||
|
**Access inventory.** Run `pnpm --filter @tessera/api exec vitest run rls-access-inventory rls-coverage`; add the Fundstellen row for `apps/api/src/kantine-datev/kantine-datev.service.ts | kantineDatevConfig | muss-mandantengebunden | gebunden | …` (German justification: Mandanten-Einstellung, no user dimension, migration 20261002120000) and a new area row `kantine-datev` plus an updated `Summe` row in `docs/mandantentrennung-zugriffsklassifikation.md`, measured with the gate loop like the previous entries (not copied).
|
||||||
|
|
||||||
|
**Web.** `apps/web/src/lib/download-base64.ts`: same body as cert-manager's `downloadBase64` (Blob + object URL + anchor with `download` + click + revoke) — this blob mechanism is what the desktop client saves since 1.9.2, so no Tauri-specific code. `apps/web/src/lib/kantine-datev-api.ts` in the style of `custom-modules-api.ts` (`credentials: 'include'`, `NEXT_PUBLIC_API_URL`, error class carrying status + code + message): `getKantineSettings`, `saveKantineSettings`, `previewKantineCsv(file)`, `exportKantineCsv(file)` (multipart field `file`). Module dir `apps/web/src/app/(portal)/modules/kantine-datev/`: `layout.tsx` = ModuleAccessGate with `moduleSlug="kantine-datev"`; `page.tsx` ('use client', default export) with `PageHeader moduleSlug="kantine-datev"`, tabs "Abrechnung" and (admins only, via `useAuthStore` role ADMIN/SUPER_ADMIN) "Einstellungen"; Abrechnung: module-local drop area (accept `.csv,text/csv`), note "Die hochgeladenen Daten werden nicht gespeichert.", summary (Zeilen, Abrechnungsmonat, Gesamtbetrag formatted de-DE EUR from totalCents), warnings, error table (Zeile, Feld, Meldung translated via `kantineDatev.errors.<code>`, falling back to `message`), download button "DATEV-Datei herunterladen" (disabled while errors or not configured; keeps the File in state and re-sends it to export). Not configured: admin sees hint + link to the settings tab, others see "Ein Administrator muss zuerst Beraternummer, Mandantennummer und Lohnart hinterlegen." Einstellungen: three numeric inputs (inputMode numeric, client-side digits check, empty by default, no placeholders with real numbers), save with success/error feedback. All texts under namespace `kantineDatev` in de.json (formal "Sie") and en.json. Registries: `module-loader.ts` entry `'kantine-datev'` (dynamic import, ssr false); `module-identity.ts` new `ModuleIconId` `'utensils'` mapped from `kantine-datev`; `module-tile.tsx` GLYPHS entry `utensils` with the Lucide "utensils" stroke paths; `nav-store.ts` MODULE_TITLE_KEYS `'kantine-datev': 'kantineDatev.title'`; `module-layouts.test.tsx` adds `['kantine-datev', KantineDatevLayout]`. Write `kantine-datev.test.tsx` per the behavior list (real NextIntlClientProvider with de.json, mocked `@/lib/kantine-datev-api`, mocked auth store, mocked `@/lib/download-base64`).
|
||||||
|
|
||||||
|
**Finish.** Biome lint the touched files (`pnpm exec biome lint <files>` from repo root, fix findings in new files), type-check both apps, run the verify command, commit atomically (German subject, e.g. `feat(kantine-datev): Kantinenabrechnung als Modul in neuer Gruppe Finanzbuchhaltung`, attribution line). Do not push.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api exec vitest run src/accounting src/kantine-datev rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run kantine-datev module-layouts module-categories && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test -z "$(grep -rnE '1387819|\b10001\b|\b9005\b' apps/api/src/kantine-datev 'apps/web/src/app/(portal)/modules/kantine-datev' apps/web/src/lib/kantine-datev-api.ts apps/api/prisma/migrations/20261002120000_kantine_datev_config)"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Migration applied locally (`prisma migrate status` up to date); all listed api and web tests green; both type-checks clean; biome clean on new files; no colleague-specific numbers in Kantine code/tests/migration; one commit on main, not pushed.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Handelsware API — Prisma models with RLS, pure XLSX/TXT/CSV functions, service, controller</name>
|
||||||
|
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261002130000_handelsware_datev/migration.sql, apps/api/src/handelsware-datev/handelsware-datev.types.ts, apps/api/src/handelsware-datev/handelsware-xlsx.ts, apps/api/src/handelsware-datev/handelsware-xlsx.spec.ts, apps/api/src/handelsware-datev/handelsware-transform.ts, apps/api/src/handelsware-datev/handelsware-transform.spec.ts, apps/api/src/handelsware-datev/handelsware-konten-csv.ts, apps/api/src/handelsware-datev/handelsware-konten-csv.spec.ts, apps/api/src/handelsware-datev/dto/handelsware-settings.dto.ts, apps/api/src/handelsware-datev/dto/handelsware-account.dto.ts, apps/api/src/handelsware-datev/handelsware-datev.service.ts, apps/api/src/handelsware-datev/handelsware-datev.service.spec.ts, apps/api/src/handelsware-datev/handelsware-datev.controller.ts, apps/api/src/handelsware-datev/handelsware-datev.controller.spec.ts, apps/api/src/handelsware-datev/handelsware-datev.seed.ts, apps/api/src/handelsware-datev/handelsware-datev.module.ts, apps/api/src/app.module.ts, docs/mandantentrennung-zugriffsklassifikation.md</files>
|
||||||
|
<behavior>
|
||||||
|
- parseHandelswareXlsx(buffer) on a workbook built in the test with XLSX.utils: B1 text/number → headerText string; rows from line 2 until A and B are both empty; numeric B used as is; German string "1.234,56" → 1234.56, "-12,5" → -12.5; non-numeric or empty B with text in A → rowError {line, code: umsatzInvalid}; row with empty A but value in B skipped (source behaviour); garbage bytes → typed invalidFile error; more than 10 000 data rows → tooManyRows
|
||||||
|
- calculateBuchungsdatum: "HWA 0326 Test.xlsx" → "3103", "HWA 0226.xlsx" → "2802", "x 0228.xlsx" → "2902" (leap year), month 00 or 13 or no 4-digit group → ""; isValidBuchungsdatum accepts "3103", rejects "3102", "0013", "abc", "310"
|
||||||
|
- formatAmount: 12.5 → {"12.50","S"}, 0 → {"0.00","S"}, -3.456 → {"3.46","H"}
|
||||||
|
- assignAccounts(rows, accounts, settings): known name → its gegenkonto + erloeskonto, isNew false; unknown names → max existing gegenkonto + 1, then + 2 …; empty list → first new = startGegenkonto exactly, next + 1; the same unknown name twice in one file reuses one new account; new accounts carry settings.erloeskonto; output lists newAccounts in first-seen order
|
||||||
|
- generateTxt: line 1 = TAB headerText TAB TAB TAB TAB; data = text TAB umsatz TAB S/H TAB gegenkonto TAB TTMM TAB erloeskonto; CRLF joined plus trailing CRLF; umlauts preserved (UTF-8)
|
||||||
|
- getExportFilename (source rule): "HWA 0326 Test.xlsx" → "HWA_0326.txt", "HWA0326.xlsx" → "HWA_0326.txt", "Liste.xlsx" → "Handelsware_Export.txt"
|
||||||
|
- parseKontenCsv: decodes via decodeCsvText, CRLF/LF, optional header line skipped when column 2 is not numeric, third column missing → settings erloeskonto (error missingErloeskonto when that setting is empty), invalid numbers or empty name → line errors, duplicate names → error duplicateName; generateKontenCsv writes Name;Gegenkonto;Konto lines with CRLF, prefixed with a UTF-8 BOM so Excel shows umlauts, and prefixes names starting with =, +, - or @ with an apostrophe (formula-injection guard); parseKontenCsv strips that apostrophe again so export → import round-trips
|
||||||
|
- Service: preview blocks with code settingsMissing while erloeskonto or startGegenkonto is null; export re-parses the uploaded file, recomputes inside withTenantTransaction and returns 409 code accountsChanged when the recomputed new accounts (name + gegenkonto) differ from the submitted list, otherwise creates them in the same transaction and returns FileResponse + createdCount; invalid buchungsdatum → 400; rowErrors → 400; CSV import replace runs deleteMany + createMany in one withTenantTransaction and changes nothing when any line is invalid; createAccount/updateAccount map Prisma P2002 to 409 nameTaken; update/delete of an unknown id → 404
|
||||||
|
- Controller: path modules/handelsware-datev, @UseModule('handelsware-datev'); PUT settings carries @Roles(ADMIN, SUPER_ADMIN), everything else no role; every static accounts route (GET accounts, POST accounts, GET accounts/export-csv, POST accounts/import-csv) is declared before PUT accounts/:id and DELETE accounts/:id (declaration-order test via Object.getOwnPropertyNames of the prototype)
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**Prisma + migration.** Add to `apps/api/prisma/schema.prisma`: `HandelswareDatevConfig` (singleton per tenant like `KantineDatevConfig`: `tenantId @unique`, `erloeskonto Int?`, `startGegenkonto Int?`, timestamps, no defaults on the two numbers — the colleague's hardcoded Erlöskonto from the source must not become a default, placeholder or test value) and `HandelswareKonto` (`id` uuid, `tenantId`, `name String`, `gegenkonto Int`, `erloeskonto Int`, `createdAt`, `updatedAt`, `@@unique([tenantId, name])`, `@@index([tenantId])`; no relation to Tenant, like ProxmoxServer; gegenkonto deliberately not unique — the source allows shared counter accounts). Hand-write `apps/api/prisma/migrations/20261002130000_handelsware_datev/migration.sql` with the German header comment and the same RLS form as Task 1 for both tables (unique indexes `HandelswareDatevConfig_tenantId_key` and `HandelswareKonto_tenantId_name_key`, ENABLE + FORCE, `tenant_isolation_policy`, no system_read_policy). `prisma generate`, then apply locally with the container-IP `prisma migrate deploy` exactly as in Task 1 and check `prisma migrate status`.
|
||||||
|
|
||||||
|
**Pure functions (port of source handelswareService.ts, write tests first).** `handelsware-datev.types.ts`: ImportRow {line, buchungstext, umsatz:number}, PreviewRow {line, buchungstext, umsatz:string, sollHaben, gegenkonto, erloeskonto, isNew}, NewAccount {name, gegenkonto, erloeskonto}, RowError {line, code, message}, settings type, FileResponse {filename, content, mimeType}. `handelsware-xlsx.ts`: `parseHandelswareXlsx(buffer)` with `XLSX.read(buffer, { type: 'buffer', cellFormula: false, cellHTML: false, cellStyles: false, sheetRows: MAX_ROWS + 1 })` (MAX_ROWS = 10 000 data rows; first sheet only, cells B1 and A/B from row 2 — source loop), plus `parseUmsatz(value)` (number → as is; string → trim, drop spaces and thousands dots, comma → dot, must match an optional minus + digits + optional decimals, else null). Improvement over the source (which silently used 0): invalid Umsatz becomes a row error. `handelsware-transform.ts`: `calculateBuchungsdatum(filename)` (source logic + month 1-12 guard), `isValidBuchungsdatum(ttmm)` (4 digits, month 1-12, day 1..days of that month, Feb up to 29), `formatAmount` (source), `assignAccounts(importRows, accounts, settings)` (exact, case-sensitive name match on the trimmed text as in the source; next free = max existing gegenkonto + 1, or settings.startGegenkonto when the list is empty — a documented choice: "Startwert" is the first number handed out), `generateTxt(headerText, rows, buchungsdatum)` (source format, rows take the edited date), `getExportFilename(importFilename)` (source regex and fallback). `handelsware-konten-csv.ts`: `parseKontenCsv(buffer, defaultErloeskonto)` using `decodeCsvText` from `apps/api/src/accounting/decode-csv-text.ts`, and `generateKontenCsv(accounts)`.
|
||||||
|
|
||||||
|
**DTOs, service, controller, seed, module.** `dto/handelsware-settings.dto.ts`: `erloeskonto`, `startGegenkonto` both `@IsInt() @Min(1) @Max(999999999)` with German messages. `dto/handelsware-account.dto.ts`: create/update with `name` (`@IsString`, trimmed, length 1-120), `gegenkonto`, `erloeskonto` (`@IsInt` 1-999999999). `handelsware-datev.service.ts`: settings get/upsert via `const tenantPrisma = forTenant(this.prisma, tenantId)`; `listAccounts` (orderBy name), `createAccount`, `updateAccount` (findFirst by id within tenant → 404), `deleteAccount`, `exportAccountsCsv` (FileResponse `Konten.csv`, `text/csv;charset=utf-8`), `importAccountsCsv(tenantId, buffer)` (parse all first, abort with 400 + line errors, else `withTenantTransaction(this.prisma, tenantId, async (tx) => …)` deleteMany + createMany, return count); `preview(tenantId, file)` → `{ headerText, suggestedBuchungsdatum, exportFilename, rows, newAccounts, rowErrors }`; `export(tenantId, file, buchungsdatum, submittedNewAccounts)` → validate TTMM, re-parse the file, then inside one `withTenantTransaction` load settings + accounts via `tx`, recompute with `assignAccounts`, compare to the submitted list (409 `accountsChanged`, German message "Die Kontenliste wurde inzwischen geändert. Bitte laden Sie die Datei erneut, um die Vorschau zu aktualisieren."), createMany the new accounts (P2002 → 409 too), build TXT, return `{ ...FileResponse (UTF-8 bytes base64, text/plain;charset=utf-8), createdCount }`. Design note (record in SUMMARY): the export re-sends the original XLSX as multipart plus `buchungsdatum` and `newAccounts` (JSON string of the preview's new accounts) instead of a JSON body with all rows — the server re-derives every row from the same file, so the TXT cannot diverge from the workbook, and Express's 100 kB JSON body default does not cap large lists. `handelsware-datev.controller.ts`: `@Controller('modules/handelsware-datev') @UseModule('handelsware-datev')`, `requireTenantId`; declare in this order: GET settings, PUT settings (`@Roles(Role.ADMIN, Role.SUPER_ADMIN)`), POST preview, POST export (both `FileInterceptor('file', 5 MB)`), GET accounts, POST accounts, GET accounts/export-csv, POST accounts/import-csv (`FileInterceptor`, 1 MB), then PUT accounts/:id and DELETE accounts/:id. Parse the `newAccounts` field defensively (JSON.parse in try/catch, array of objects with string name and integer gegenkonto, at most 10 000 entries, else 400). Multer decodes `originalname` as latin1 — convert with `Buffer.from(name, 'latin1').toString('utf8')` before deriving date/filename. `handelsware-datev.seed.ts`: slug `handelsware-datev`, name `Handelsware`, `category: 'accounting'`, description de "Handelswaren-Umsätze aus Excel den Erlöskonten zuordnen und als DATEV-Buchungsdatei exportieren" / en "Map merchandise sales from Excel to revenue accounts and export a DATEV booking file", `isSystem: true`; module class like Task 1; register in `apps/api/src/app.module.ts`.
|
||||||
|
|
||||||
|
**Access inventory.** Run the two RLS specs; add Fundstellen rows for `apps/api/src/handelsware-datev/handelsware-datev.service.ts` × `handelswareDatevConfig` and × `handelswareKonto` with the Stand the spec measures (bound client + `tx` of withTenantTransaction), plus area row `handelsware-datev` and updated `Summe`, measured with the gate loop.
|
||||||
|
|
||||||
|
**Tests.** Specs per the behavior list; service spec with a fake PrismaService/tx (pattern: `apps/api/src/favorites/favorites.service.spec.ts` for withTenantTransaction fakes) covering conflict 409, createMany only on export, preview not writing, replace-import atomicity; controller spec for roles metadata, path, file-missing 400 and the declaration-order describe "Routen-Reihenfolge (statisch vor :id)". Biome lint touched files, `tsc --noEmit`, commit atomically (e.g. `feat(handelsware-datev): API, Kontenliste mit Zeilenschutz und DATEV-Export`). Do not push.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api exec vitest run src/handelsware-datev src/accounting rls-coverage rls-access-inventory && pnpm --filter @tessera/api exec tsc --noEmit && test -z "$(grep -rn '8000' apps/api/src/handelsware-datev apps/api/prisma/migrations/20261002130000_handelsware_datev)"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Migration applied locally and `prisma migrate status` up to date; handelsware + accounting + RLS specs green (including the route declaration-order test); api type-check clean; no default or sample value equal to the colleague's Erlöskonto in API code/tests/migration; one commit, not pushed.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 3: Handelsware web (Import / Konten / Einstellungen), docs, CHANGELOG, local stack rebuild and smoke check</name>
|
||||||
|
<files>apps/web/src/lib/handelsware-datev-api.ts, apps/web/src/app/(portal)/modules/handelsware-datev/layout.tsx, apps/web/src/app/(portal)/modules/handelsware-datev/page.tsx, apps/web/src/app/(portal)/modules/handelsware-datev/components/ImportTab.tsx, apps/web/src/app/(portal)/modules/handelsware-datev/components/AccountsTab.tsx, apps/web/src/app/(portal)/modules/handelsware-datev/components/SettingsTab.tsx, apps/web/src/app/(portal)/modules/handelsware-datev/handelsware-datev.test.tsx, apps/web/src/app/(portal)/modules/module-layouts.test.tsx, apps/web/src/lib/module-loader.ts, apps/web/src/lib/module-identity.ts, apps/web/src/components/modules/module-tile.tsx, apps/web/src/lib/stores/nav-store.ts, apps/web/src/messages/de.json, apps/web/src/messages/en.json, CHANGELOG.md, docs/anleitung-anwender.md</files>
|
||||||
|
<behavior>
|
||||||
|
- Import tab: after upload the preview table shows Buchungstext, Umsatz, S/H, Gegenkonto, Datum, Erlöskonto; rows whose account is new show a visible "neu" badge and a summary line "N neue Konten werden beim Herunterladen gespeichert"
|
||||||
|
- The Buchungsdatum field is prefilled from suggestedBuchungsdatum, editable; an invalid TTMM shows an inline error and disables download; the Datum column follows the edited value
|
||||||
|
- Download calls the export client with the same File, the edited date and the preview's newAccounts, then downloadBase64(filename, content, mimeType) and a success message naming the count of saved accounts; a 409 accountsChanged shows the server hint and offers to reload the preview
|
||||||
|
- settingsMissing: admins see a hint pointing to the Einstellungen tab, other users see "Ein Administrator muss zuerst …"; rowErrors are listed with line numbers and block download
|
||||||
|
- Konten tab: list, add, edit, delete (with confirm); CSV import opens a confirmation "Alle N vorhandenen Konten werden ersetzt" before calling import; CSV export triggers downloadBase64
|
||||||
|
- Einstellungen tab visible only for ADMIN/SUPER_ADMIN, two numeric fields, empty by default
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**API client.** `apps/web/src/lib/handelsware-datev-api.ts` in the style of `custom-modules-api.ts` with an error class carrying status + code + message: `getHandelswareSettings`, `saveHandelswareSettings`, `previewHandelsware(file)`, `exportHandelsware(file, buchungsdatum, newAccounts)` (multipart: file, buchungsdatum, newAccounts as JSON string), `listAccounts`, `createAccount`, `updateAccount(id, …)`, `deleteAccount(id)`, `importAccountsCsv(file)`, `exportAccountsCsv()`; also export a client-side `isValidBuchungsdatum` mirroring the API rule (same cases as Task 2 tests).
|
||||||
|
|
||||||
|
**Module UI.** `layout.tsx` = ModuleAccessGate with `moduleSlug="handelsware-datev"`. `page.tsx` ('use client', default export): `PageHeader moduleSlug="handelsware-datev"`, tabs "Import", "Konten", and "Einstellungen" (admins only via `useAuthStore`), tab pattern from cert-manager. `components/ImportTab.tsx`: module-local drop area (accept `.xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`), header text display, Buchungsdatum input (maxLength 4, inputMode numeric, label "Buchungsdatum (TTMM)"), export filename display, preview table per behavior with "neu" badge (accent token, readable in dark mode), totals of S and H, row errors, download button "Buchungsdatei herunterladen"; after a successful export notify the Konten tab to reload (shared state in page or a reload key). `components/AccountsTab.tsx`: table Name / Gegenkonto / Erlöskonto, inline add row, edit (inline or small modal following existing modal patterns), delete with confirm, "CSV importieren" (file input → confirmation dialog naming the current count and the replace effect → import → show count or line errors), "CSV exportieren" via `downloadBase64` from `apps/web/src/lib/download-base64.ts`. `components/SettingsTab.tsx`: "Standard-Erlöskonto" and "Startwert Gegenkonto" numeric inputs, empty by default, short explanations ("wird neuen Konten zugeordnet" / "erste Nummer, wenn die Kontenliste leer ist"), save feedback; no placeholder showing a real account number. All texts under namespace `handelswareDatev` in `de.json` (formal "Sie") and `en.json`, error codes translated via `handelswareDatev.errors.<code>` with server message fallback.
|
||||||
|
|
||||||
|
**Registries.** `module-loader.ts` entry `'handelsware-datev'`; `module-identity.ts` new ModuleIconId `'shopping-bag'` mapped from `handelsware-datev`; `module-tile.tsx` GLYPHS entry with the Lucide "shopping-bag" stroke paths; `nav-store.ts` `'handelsware-datev': 'handelswareDatev.title'`; `module-layouts.test.tsx` adds `['handelsware-datev', HandelswareDatevLayout]`.
|
||||||
|
|
||||||
|
**Tests.** `handelsware-datev.test.tsx` per the behavior list (real NextIntlClientProvider with de.json, mocked api client, auth store and download helper).
|
||||||
|
|
||||||
|
**Docs.** `CHANGELOG.md` under "## Unveröffentlicht" add "### Neu" with two plain-language entries (Kantinenabrechnung: CSV der Kantine prüfen, Fehler mit Zeilennummer, DATEV-Lohndatei herunterladen, Nummern einmalig vom Administrator hinterlegen, hochgeladene Daten werden nicht gespeichert; Handelsware: Excel-Liste hochladen, Konten automatisch zuordnen, neue Konten markiert und erst beim Herunterladen gespeichert, Buchungsdatum aus dem Dateinamen, Kontenliste pflegen und als CSV ein- und auslesen; both under the new group „Finanzbuchhaltung“; activation via Marktplatz + Freigabe). `docs/anleitung-anwender.md`: add "### Kantinenabrechnung" and "### Handelsware" under "## Die Module" plus table-of-contents entries, same tone as the existing module sections.
|
||||||
|
|
||||||
|
**Local stack + smoke.** Rebuild with `docker compose up -d --build api web`; check `docker compose logs api --tail 80` for both seed log lines and no migration error. Generate fictitious test files into the scratch directory and copy them to `.planning/quick/261002-fm5-finanzbuchhaltung-module-kantinenabrechn/testdata/` for the browser check: a canteen CSV encoded Windows-1252 with CRLF, umlaut names, one invalid row and two billing months; a valid UTF-8 canteen CSV; an XLSX named like "HWA 0326 Test.xlsx" (B1 header, mixed numeric and German-string amounts, one negative, one unknown product), built with the api's `xlsx` package via `pnpm --filter @tessera/api exec node -e …`; a Konten CSV `Name;Gegenkonto;Konto`. If the Playwright MCP tool is available: in dark mode (theme button), activate both modules in the Marketplace, grant access, verify sidebar group "Finanzbuchhaltung", run each flow and confirm the downloaded files' content (11 columns + CRLF; TXT format; new accounts appear in Konten only after download). If Playwright is not available, state in the SUMMARY that the browser check is left to the orchestrator and list the testdata paths.
|
||||||
|
|
||||||
|
**SUMMARY must name, in plain words:** open question Handelsware TXT encoding (kept UTF-8 like the source; DATEV imports often expect Windows-1252/ANSI — umlauts in product names may need it); the export re-send design; the "Startwert" semantics; that only administrators change settings while all granted users maintain the Konten list; that the api's `xlsx` 0.18.5 is used for reading uploads (see threat model); browser check status.
|
||||||
|
|
||||||
|
Run the full api and web test suites, both type-checks, biome lint on touched files; commit atomically (e.g. `feat(handelsware-datev): Modulseite mit Import, Konten und Einstellungen`) and a separate docs commit if preferred. Do not push.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/web exec vitest run handelsware-datev kantine-datev module-layouts module-categories && pnpm --filter @tessera/web exec tsc --noEmit && pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && test -z "$(grep -rn '8000' 'apps/web/src/app/(portal)/modules/handelsware-datev' apps/web/src/lib/handelsware-datev-api.ts)" && docker compose logs api --tail 200 | grep -ciE 'kantine|handelsware'</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Web tests (new + full suite) and api full suite green; type-checks clean; local stack rebuilt and both modules seeded; testdata files exist; CHANGELOG and user guide updated; browser check done or explicitly handed off in SUMMARY; commits made, nothing pushed.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| browser/desktop → API | Authenticated, granted module users upload CSV/XLSX files and send account data |
|
||||||
|
| API → PostgreSQL | Tenant-bound access to config and account tables under RLS |
|
||||||
|
| uploaded file → parser | Untrusted file content parsed in memory (TextDecoder, `xlsx`) |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-FM5-01 | Information disclosure | Kantine upload (names, personnel numbers) | high | mitigate | Pure in-memory processing (multer memory storage, no DB write, no file write); no logging of row content; preview returns only counts, month, total, row errors (field + line, no names) |
|
||||||
|
| T-FM5-02 | Elevation of privilege | Settings endpoints of both modules | medium | mitigate | `PUT settings` carries `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`; all routes under `@UseModule(...)` (activation + grant); controller specs assert the metadata |
|
||||||
|
| T-FM5-03 | Information disclosure / Tampering | KantineDatevConfig, HandelswareDatevConfig, HandelswareKonto | high | mitigate | `tenantId` on every table, ENABLE + FORCE RLS + `tenant_isolation_policy`; all access via `forTenant` or `withTenantTransaction`; rls-coverage + rls-access-inventory specs updated and green |
|
||||||
|
| T-FM5-04 | Denial of service | Upload endpoints | medium | mitigate | Multer `fileSize` 5 MB (CSV import 1 MB), XLSX `sheetRows` cap (10 000 data rows), `newAccounts` array capped and parsed defensively |
|
||||||
|
| T-FM5-05 | Tampering | `xlsx` 0.18.5 reading crafted workbooks (known prototype-pollution/ReDoS advisories fixed in later SheetJS builds that are not on the npm registry) | medium | accept | Only authenticated, admin-granted internal users can upload; formulas/HTML/styles disabled, only first sheet cells A/B read, size and row caps; noted in SUMMARY. Upgrading the library is a separate decision outside this task |
|
||||||
|
| T-FM5-06 | Tampering | Handelsware export (client-submitted new accounts) | medium | mitigate | Server re-parses the uploaded file and recomputes the mapping inside one tenant-bound transaction; mismatch → 409; unique (tenantId, name) catches races (P2002 → 409) |
|
||||||
|
| T-FM5-07 | Tampering | CSV formula injection in Konten CSV export opened in Excel | low | mitigate | Account names starting with =, +, -, @ are prefixed with an apostrophe in `generateKontenCsv` (covered by a test) |
|
||||||
|
| T-FM5-SC | Tampering | npm/pip/cargo installs | low | accept | No package installs in this plan (`xlsx` already a dependency of apps/api) |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `pnpm --filter @tessera/api test` and `pnpm --filter @tessera/web test` green
|
||||||
|
- `pnpm --filter @tessera/api exec tsc --noEmit` and `pnpm --filter @tessera/web exec tsc --noEmit` clean
|
||||||
|
- Biome lint clean on all new files
|
||||||
|
- Local DB: both migrations applied (`prisma migrate status` up to date); stack rebuilt; api logs show both modules seeded
|
||||||
|
- Grep gates: no colleague-specific numbers in Kantine/Handelsware code, tests, migrations, web
|
||||||
|
- Browser check (Playwright, dark mode) done or explicitly handed to the orchestrator in SUMMARY
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Sidebar shows group "Finanzbuchhaltung" with "Kantinenabrechnung" and "Handelsware" for granted users
|
||||||
|
- Canteen CSV (UTF-8 or Windows-1252) → correct preview and a DATEV Lohn ASCII file that passes the source quality check, using tenant settings
|
||||||
|
- Handelsware XLSX → preview with "neu" markers and editable date → TXT in the source format; new accounts saved only on download, atomically, with conflict detection
|
||||||
|
- Konten tab fully usable incl. CSV import (replace with confirmation) and export
|
||||||
|
- No company-specific defaults; settings empty until an administrator sets them
|
||||||
|
- Three atomic commits (plus optional docs commit) on main, not pushed
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/quick/261002-fm5-finanzbuchhaltung-module-kantinenabrechn/261002-fm5-SUMMARY.md` when done (include the open questions listed in Task 3).
|
||||||
|
</output>
|
||||||
+154
@@ -0,0 +1,154 @@
|
|||||||
|
---
|
||||||
|
phase: quick-261002-fm5
|
||||||
|
plan: 01
|
||||||
|
subsystem: finanzbuchhaltung
|
||||||
|
tags: [kantine-datev, handelsware-datev, datev, prisma-rls, nestjs, next-intl, xlsx]
|
||||||
|
requires: []
|
||||||
|
provides:
|
||||||
|
- Seitenleisten-Kategorie accounting (Finanzbuchhaltung / Financial accounting)
|
||||||
|
- Modul kantine-datev (Kantinenabrechnung) mit DATEV-Lohn-ASCII-Export
|
||||||
|
- Modul handelsware-datev (Handelsware) mit Kontenliste und DATEV-Buchungsdatei
|
||||||
|
affects: [module-registry, marketplace, sidebar, rls-access-inventory]
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- reine, getestete Verarbeitungsfunktionen getrennt von Dienst und Controller
|
||||||
|
- Speicherung nur beim Export, in einer mandantengebundenen Transaktion mit erneuter Berechnung
|
||||||
|
- Blob-Download im Browser (wie der Zertifikat-Manager), keine Tauri-Sonderlogik
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- apps/api/src/accounting/decode-csv-text.ts
|
||||||
|
- apps/api/src/accounting/decode-upload-filename.ts
|
||||||
|
- apps/api/src/kantine-datev/ (Parser, Validator, Transformer, Pipeline, Dienst, Controller, Seed, Modul, Tests)
|
||||||
|
- apps/api/src/handelsware-datev/ (XLSX, Transformation, Konten-CSV, Dienst, Controller, Seed, Modul, Tests)
|
||||||
|
- apps/api/prisma/migrations/20261002120000_kantine_datev_config/migration.sql
|
||||||
|
- apps/api/prisma/migrations/20261002130000_handelsware_datev/migration.sql
|
||||||
|
- apps/web/src/app/(portal)/modules/kantine-datev/ (Seite, Layout, Test)
|
||||||
|
- apps/web/src/app/(portal)/modules/handelsware-datev/ (Seite, Layout, drei Reiter, Test)
|
||||||
|
- apps/web/src/lib/kantine-datev-api.ts
|
||||||
|
- apps/web/src/lib/handelsware-datev-api.ts
|
||||||
|
- apps/web/src/lib/accounting-request.ts
|
||||||
|
- apps/web/src/lib/download-base64.ts
|
||||||
|
- apps/web/src/components/accounting/file-drop-area.tsx
|
||||||
|
- apps/web/src/components/accounting/tab-bar.tsx
|
||||||
|
modified:
|
||||||
|
- packages/shared/src/index.ts
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/src/app.module.ts
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
- apps/web/src/messages/de.json
|
||||||
|
- apps/web/src/messages/en.json
|
||||||
|
- apps/web/src/messages/umlaut-dictionary.ts
|
||||||
|
- apps/web/src/lib/module-loader.ts
|
||||||
|
- apps/web/src/lib/module-identity.ts
|
||||||
|
- apps/web/src/components/modules/module-tile.tsx
|
||||||
|
- apps/web/src/lib/stores/nav-store.ts
|
||||||
|
- apps/web/src/app/(portal)/modules/module-layouts.test.tsx
|
||||||
|
- CHANGELOG.md
|
||||||
|
- docs/anleitung-anwender.md
|
||||||
|
decisions:
|
||||||
|
- Handelsware-Export schickt die Excel-Datei erneut mit (Multipart) plus buchungsdatum und newAccounts (JSON-Text), statt einer JSON-Liste aller Zeilen
|
||||||
|
- Startwert Gegenkonto ist die erste vergebene Nummer bei leerer Kontenliste, sonst hoechstes vorhandenes Gegenkonto + 1
|
||||||
|
- Einstellungen aendern nur Administratoren, die Kontenliste pflegen alle Benutzer mit Modulzugriff
|
||||||
|
- Handelsware-TXT bleibt UTF-8 (wie die Vorlage), offene Frage zur Kodierung siehe unten
|
||||||
|
metrics:
|
||||||
|
duration: ca. 1 h 20 min
|
||||||
|
completed: 2026-10-02
|
||||||
|
status: complete
|
||||||
|
plan_head_before: 0edd6e9b1a1e0e594663129fdc185187ef81636e
|
||||||
|
plan_head_after: 14933753e70e85bb7c318ac347dd02a702e11841
|
||||||
|
commits: 4
|
||||||
|
actuals:
|
||||||
|
tokens: 63000
|
||||||
|
tasks: 3
|
||||||
|
commits: 4
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase quick-261002-fm5 Plan 01: Finanzbuchhaltung, Kantinenabrechnung und Handelsware
|
||||||
|
|
||||||
|
Zwei neue Module in der neuen Seitenleisten-Gruppe „Finanzbuchhaltung“: **Kantinenabrechnung** (Kantinen-CSV prüfen, DATEV-Lohndatei im ASCII-Format erzeugen) und **Handelsware** (Excel-Umsätze Erlöskonten zuordnen, Kontenliste pflegen, DATEV-Buchungsdatei als TXT erzeugen). Beide laufen über Aktivierung im Marktplatz plus Freigabe. Beraternummer, Mandantennummer, Lohnart, Standard-Erlöskonto und Startwert Gegenkonto sind je Mandant leer, bis ein Administrator sie einträgt. Nichts wurde gepusht.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
| Aufgabe | Commit | Inhalt |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 (Tracer) | `1f85277` | Kategorie accounting, Kantinenabrechnung durchgängig (API, Migration, Web, Zugriffsinventar) |
|
||||||
|
| 2 | `44c1d43` | Handelsware API: Prisma-Modelle mit Zeilenschutz, XLSX/TXT/CSV-Funktionen, Dienst, Controller |
|
||||||
|
| 3 | `42b89f1` | Handelsware Modulseite (Import / Konten / Einstellungen), Registries, Umlaut-Allowlist |
|
||||||
|
| 3 (Doku) | `1493375` | CHANGELOG und Anwenderanleitung |
|
||||||
|
|
||||||
|
SUMMARY, STATE und PLAN sind wie verlangt nicht committet (Orchestrator).
|
||||||
|
|
||||||
|
## Prüfergebnisse (ehrlich)
|
||||||
|
|
||||||
|
- **API-Tests:** 109 Testdateien, **1857 Tests grün** (`pnpm --filter @tessera/api test`). Davon neu: accounting 8, kantine-datev 53, handelsware-datev 110 (Spec-Läufe zusammen 206 inkl. RLS-Gates).
|
||||||
|
- **Web-Tests:** 115 Testdateien, **1214 Tests grün** (`pnpm --filter @tessera/web test`). Davon neu: kantine-datev 7 + 1 Layout, handelsware-datev 15 + 1 Layout.
|
||||||
|
- **Type-Check:** `tsc --noEmit` für api und web **sauber**.
|
||||||
|
- **Biome:** `biome lint` auf allen neuen/geänderten Dateien ohne Befund. `biome check --write` (Format + Importsortierung) wurde auf die neuen Dateien angewandt. Bestehende, nicht von mir angefasste Dateien (z. B. `apps/api/src/proxmox`) sind schon vorher nicht formatrein; das habe ich nicht angefasst.
|
||||||
|
- **RLS-Gates:** `rls-coverage` und `rls-access-inventory` grün, Fundstellentabelle, Bereichszeilen, Summenzeile und Paarzählung (92 Paare) im Dokument nachgeführt.
|
||||||
|
- **Migrationen:** beide lokal über die Container-IP angewandt, `prisma migrate status` „Database schema is up to date“ (54 Migrationen), `prisma migrate diff` Schema gegen DB: „No difference detected“.
|
||||||
|
- **Grep-Gates:** keine Zahlen 1387819 / 10001 / 9005 in Kantine-Code, -Tests, -Web oder -Migration; keine 8000 in Handelsware-Code, -Tests, -Web, -Migration, auch nicht in den Testdateien.
|
||||||
|
- **Lokaler Stack:** `docker compose up -d --build api web` gebaut, API healthy. Logs: `Kantine-DATEV module seeded in registry`, `Handelsware-DATEV module seeded in registry`, alle Routen gemappt, kein Migrationsfehler. Zeilen in `Module`: `kantine-datev` und `handelsware-datev`, beide Kategorie `accounting`.
|
||||||
|
- **Browser-Prüfung:** nicht gemacht, liegt beim Orchestrator. Ich habe mich nicht angemeldet und keine Zugangsdaten gelesen. Das heißt: die Seiten laufen bisher nur gegen die Komponententests und gegen den gebauten Stack (Start, Routen, Seed), nicht gegen echte Klicks.
|
||||||
|
|
||||||
|
## Testdateien für die Browser-Prüfung
|
||||||
|
|
||||||
|
Alle in `/home/vicolab/projects/tessera-ctl/.planning/quick/261002-fm5-finanzbuchhaltung-module-kantinenabrechn/testdata/`, alles erfundene Daten, mit den Pipeline-Funktionen gegengeprüft:
|
||||||
|
|
||||||
|
| Datei | Zweck | Erwartung in der Vorschau |
|
||||||
|
|---|---|---|
|
||||||
|
| `kantine-cp1252-fehler.csv` | Windows-1252, CRLF, Umlaute, ein ungültiger Betrag, zwei Abrechnungsmonate | 4 Zeilen, Monat 03/2026, Gesamtbetrag 71,74 EUR, Fehler in Zeile 4 (Betrag), Warnung „03/2026, 04/2026“, Download gesperrt |
|
||||||
|
| `kantine-gueltig-utf8.csv` | gültig, UTF-8, LF | 4 Zeilen, 03/2026, 82,54 EUR, kein Fehler. Datei bei Einstellungen z. B. 1234567 / 12345 / 1111: `LuG_1234567_12345_03_2026.sic` |
|
||||||
|
| `HWA 0326 Test.xlsx` | B1 = 2026, gemischt Zahl und deutscher Text, ein negativer Wert, ein unbekanntes Produkt | Datumsvorschlag 3103, Dateiname `HWA_0326.txt` |
|
||||||
|
| `Konten.csv` | Windows-1252, `Name;Gegenkonto;Konto` | enthält 4 Konten, „Neues Produkt Saft“ fehlt absichtlich. Nach dem Import dieser Liste (Standard-Erlöskonto z. B. 4711, Startwert 2000) bekommt das Produkt Gegenkonto 2014 und die Markierung „neu“ |
|
||||||
|
|
||||||
|
Ablauf-Vorschlag: Kantine-Einstellungen leer lassen und Hinweis prüfen, dann Werte eintragen. Handelsware: erst Einstellungen setzen, dann `Konten.csv` im Reiter Konten importieren, danach die XLSX hochladen, „neu“ prüfen, Konten-Reiter vor und nach dem Download vergleichen.
|
||||||
|
|
||||||
|
## Abweichungen vom Plan
|
||||||
|
|
||||||
|
1. **[Regel 1 – Fehler] Zeilennummern aus der echten Datei.** Die Vorlage nutzt `Index + 2`; bei Leerzeilen oder übersprungenen Zeilen zeigt das eine falsche Zeile. Der Parser gibt jetzt die echte 1-basierte Dateizeile mit (`line`), der Validator nutzt sie und fällt ohne sie auf `Index + 2` zurück (Test deckt beides ab). Commit `1f85277`.
|
||||||
|
2. **[Regel 3 – blockierend] `sheetRows` = 10 002 statt `MAX_ROWS + 1`.** Mit `MAX_ROWS + 1` wäre die 10 001. Datenzeile nie sichtbar, „zu viele Zeilen“ also nicht erkennbar. Test prüft genau 10 000 (ok) und 10 001 (Fehler). Commit `44c1d43`.
|
||||||
|
3. **[Regel 2 – fehlende Absicherung] XLSX-Signaturprüfung.** SheetJS wirft bei Müll-Bytes nicht, es liest sie als Text. Ohne Prüfung des ZIP-/OLE-Kopfes wäre „garbage bytes → invalidFile“ nicht erfüllbar. Commit `44c1d43`.
|
||||||
|
4. **[Regel 2] Tabulator und Zeilenumbruch in Buchungstext und Kopftext werden durch ein Leerzeichen ersetzt**, sonst würden sie die Spalten der TXT-Datei zerreißen. Gleiches gilt für Kontennamen über das DTO (Tabulator abgelehnt). Commit `44c1d43`.
|
||||||
|
5. **[Regel 2] Dateinamen-Dekodierung** (`apps/api/src/accounting/decode-upload-filename.ts`): multer liefert UTF-8-Namen als latin1-gelesen; der Helfer kehrt das um, ohne einen schon richtigen Namen zu beschädigen. Der Plan nannte nur die Umwandlung; ein blindes `Buffer.from(name, 'latin1')` hätte Namen mit Zeichen über 255 zerstört. Commit `44c1d43`.
|
||||||
|
6. **Zusätzliche gemeinsame Web-Bausteine** (nicht in `files_modified`): `accounting-request.ts` (Anfrage, Fehlerklasse), `file-drop-area.tsx`, `tab-bar.tsx`. Sie ersetzen eine zweifach kopierte Ablagefläche, Reiterleiste und Fehlerbehandlung. Die Ablage importiert keine Texte aus dem Zertifikat-Manager, wie verlangt.
|
||||||
|
7. **Umlaut-Wächter:** das Wort „neues“ (korrektes Deutsch) stand nicht auf der Allowlist und ließ die volle Web-Suite rot werden; ergänzt in `umlaut-dictionary.ts` (nur die Teilmenge der Aufgabe 1 hatte das nicht gezeigt, die volle Suite schon).
|
||||||
|
8. **Doku:** Im Inhaltsverzeichnis der Anwenderanleitung fehlte Proxmox, ich habe es mit ergänzt; „fünf Module“ wurde zu „sieben“. Die alte Klassen-Tabelle im Zugriffsdokument (Zahl 83) war schon vorher veraltet (gezählt waren 89); ich habe sie nicht umgeschrieben, sondern wie bisher einen Nachtragsabsatz mit nachgezählten Werten (90, dann 92 Paare) ergänzt.
|
||||||
|
9. **TDD:** Aufgabe 2 und die Kantine-Funktionen sind mit den Tests zusammen entstanden und in je einem atomaren Commit gelandet; es gibt keine getrennten RED-Commits. Die Tests laufen gegen die finale Implementierung.
|
||||||
|
|
||||||
|
## Auth-Gates
|
||||||
|
|
||||||
|
Keine. Es wurde kein Login benötigt; ich habe keine Zugangsdaten gelesen oder verwendet (die Lesesperre für `.env` hat zudem gegriffen, als ich die Admin-Angaben nachsehen wollte, und ich habe es dabei belassen).
|
||||||
|
|
||||||
|
## Offene Fragen und Hinweise für Sie
|
||||||
|
|
||||||
|
- **Kodierung der Handelsware-TXT:** Ich habe UTF-8 beibehalten, wie in der Vorlage. DATEV-Importe erwarten oft Windows-1252 (ANSI). Enthalten Produktnamen Umlaute, kann DATEV sie falsch anzeigen. Das sollte die Kollegin am echten Import prüfen; die Umstellung wäre eine Zeile in `handelsware-datev.service.ts` (Ausgabe) plus `mimeType`.
|
||||||
|
- **Export schickt die Datei erneut:** Statt einer JSON-Liste aller Zeilen sendet der Export die Original-XLSX als Multipart plus `buchungsdatum` und `newAccounts` (JSON-Text). Der Server liest alle Zeilen selbst neu und berechnet die Zuordnung in derselben Transaktion; die TXT kann so nicht von der Arbeitsmappe abweichen, und die JSON-Grenze von Express (100 kB) kappt große Listen nicht.
|
||||||
|
- **„Startwert“-Bedeutung:** Er ist die erste Nummer, die vergeben wird, wenn die Kontenliste leer ist. Ist die Liste nicht leer, gilt höchstes vorhandenes Gegenkonto + 1 (wie die Vorlage mit festem 8000). Das ist eine Entscheidung von mir, bitte bestätigen.
|
||||||
|
- **Wer was ändert:** Nur Administratoren ändern die Einstellungen (beide Module). Die Kontenliste der Handelsware dürfen alle Benutzer mit Modulzugriff pflegen.
|
||||||
|
- **`xlsx` 0.18.5:** Das Lesen der hochgeladenen Excel-Dateien nutzt die bereits vorhandene `xlsx`-Abhängigkeit der API (0.18.5). Bekannte Hinweise (Prototype Pollution, ReDoS), die nur in Versionen behoben sind, die nicht in der npm-Registry stehen, sind laut Bedrohungsmodell T-FM5-05 bewusst akzeptiert: nur angemeldete, freigegebene interne Benutzer, Formeln/HTML/Formate abgeschaltet, nur erstes Blatt, Spalten A/B, 5 MB und 10 000 Zeilen Grenze. Ein Austausch der Bibliothek ist eine eigene Entscheidung.
|
||||||
|
- **Kantine: Betragsformat wie in der Vorlage:** „1.234,56“ und „-5,00“ werden als Fehler gemeldet (die Vorlage akzeptiert nur Ziffern mit optionalem Komma). Das ist so gewollt, könnte aber bei Kantinenexporten mit Tausenderpunkt hinderlich sein.
|
||||||
|
- **Desktop-Download:** Der Download läuft über denselben Blob-Mechanismus wie der Zertifikat-Manager (vom Desktop-Client seit 1.9.2 gespeichert). Im Desktop-Client selbst nicht getestet.
|
||||||
|
- **Icons:** `utensils` und `shopping-bag` habe ich den Lucide-Pfaden nach gezeichnet; optisch noch nicht gesehen.
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
Keine. Es gibt keine Platzhalter oder fest eingebauten leeren Werte, die in die Oberfläche fließen. Die Einstellungsfelder sind absichtlich leer (kein Standardwert), das ist Teil der Anforderung und kein Stub.
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
Keine neue Angriffsfläche außerhalb des Bedrohungsmodells. Umgesetzt: T-FM5-01 (keine Speicherung/Protokollierung der Kantinenzeilen; Test, dass die Vorschau keine Namen enthält), T-FM5-02 (Rollen-Metadaten per Test), T-FM5-03 (RLS plus Inventar), T-FM5-04 (5 MB, 1 MB für Konten-CSV, 10 000 Zeilen, `newAccounts` begrenzt), T-FM5-05 (akzeptiert, siehe oben), T-FM5-06 (Neuberechnung in der Transaktion, 409, P2002 → 409), T-FM5-07 (Apostroph vor `=+-@`, Rundlauf-Test).
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- Dateien vorhanden: Migrationen, Dienste, Controller, Seiten, Testdaten (4 Dateien) — geprüft per `ls` und Lauf der Funktionen gegen die Testdaten.
|
||||||
|
- Commits vorhanden: `1f85277`, `44c1d43`, `42b89f1`, `1493375` (`git log`), `commits: 4` gemessen mit `git rev-list --count 0edd6e9..HEAD`.
|
||||||
|
- Keine Löschungen in den Commits (`git diff --diff-filter=D` leer).
|
||||||
|
|
||||||
|
## Browser-Prüfung (Orchestrator, 02.10., lokal, dunkel)
|
||||||
|
|
||||||
|
- Marktplatz: beide Module unter „Finanzbuchhaltung“, aktiviert; Seitenleiste zeigt neue Kategorie.
|
||||||
|
- Kantine: Hinweis bei leeren Einstellungen; `kantine-cp1252-fehler.csv` → 4 Zeilen, 03/2026, 71,74 €, Warnung zwei Monate, Fehler Zeile 4, Download gesperrt. Einstellungen 1234567/12345/1111 gespeichert; `kantine-gueltig-utf8.csv` → `LuG_1234567_12345_03_2026.sic`, 11 Spalten, CRLF, Inhalt identisch zur Vorlage (Betrag 0 → `-0.00` wie in der Vorlage).
|
||||||
|
- Handelsware: Einstellungen 4711/2000; `Konten.csv` (cp1252) importiert, Umlaute korrekt; `HWA 0326 Test.xlsx` → Datum 3103, Kopftext 2026, „Neues Produkt Saft“ neu mit 2014/4711; Download `HWA_0326.txt` (UTF-8, CRLF); neues Konto erst danach in der Kontenliste.
|
||||||
|
- Korrektur: Einstellungstexte nannten „Ihres Mandanten“ → entfernt (de/en), Web-Suite 1214 grün.
|
||||||
|
- Desktop-Client-Download nicht geprüft.
|
||||||
+309
@@ -0,0 +1,309 @@
|
|||||||
|
---
|
||||||
|
phase: quick-261002-icv
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
quick_id: 261002-icv
|
||||||
|
description: "Modul-Freigabe mit Stufe: Benutzen (USE, Standard) und Verwalten (MANAGE)"
|
||||||
|
date: 2026-10-02
|
||||||
|
files_modified:
|
||||||
|
# Task 1 — tracer: DB level -> access resolution -> guard -> kantine settings -> /modules/active canManage -> web tab
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/prisma/migrations/20261002140000_module_grant_level/migration.sql
|
||||||
|
- apps/api/src/groups/migration-sql.spec.ts
|
||||||
|
- apps/api/src/module-registry/module-access.service.ts
|
||||||
|
- apps/api/src/module-registry/module-access.service.spec.ts
|
||||||
|
- apps/api/src/module-registry/module.guard.ts
|
||||||
|
- apps/api/src/module-registry/module.guard.spec.ts
|
||||||
|
- apps/api/src/groups/dto/create-module-grant.dto.ts
|
||||||
|
- apps/api/src/groups/dto/create-module-grant.dto.spec.ts
|
||||||
|
- apps/api/src/groups/module-grants.service.ts
|
||||||
|
- apps/api/src/groups/module-grants.service.spec.ts
|
||||||
|
- apps/api/src/kantine-datev/kantine-datev.controller.ts
|
||||||
|
- apps/api/src/kantine-datev/kantine-datev.controller.spec.ts
|
||||||
|
- apps/web/src/lib/api.ts
|
||||||
|
- apps/web/src/lib/use-module-capability.ts
|
||||||
|
- apps/web/src/app/(portal)/modules/kantine-datev/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/kantine-datev/kantine-datev.test.tsx
|
||||||
|
# Task 2 — remaining module conversions (API + their web pages), DKV gate
|
||||||
|
- apps/api/src/proxmox/proxmox.controller.ts
|
||||||
|
- apps/api/src/proxmox/proxmox-client.service.ts
|
||||||
|
- apps/api/src/handelsware-datev/handelsware-datev.controller.ts
|
||||||
|
- apps/api/src/handelsware-datev/handelsware-datev.controller.spec.ts
|
||||||
|
- apps/api/src/dkv/dkv.controller.ts
|
||||||
|
- apps/api/src/module-registry/module-manage-handlers.spec.ts
|
||||||
|
- apps/web/src/lib/module-access-actions.ts
|
||||||
|
- apps/web/src/components/modules/module-access-gate.tsx
|
||||||
|
- apps/web/src/components/modules/module-access-gate.test.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/handelsware-datev/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/handelsware-datev/components/ImportTab.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/handelsware-datev/handelsware-datev.test.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/proxmox/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/proxmox/settings/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx
|
||||||
|
- apps/web/src/messages/de.json
|
||||||
|
- apps/web/src/messages/en.json
|
||||||
|
# Task 3 — admin grant UI with level, docs, changelog, rebuild
|
||||||
|
- apps/web/src/app/(portal)/admin/modules/grants/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx
|
||||||
|
- apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx
|
||||||
|
- apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx
|
||||||
|
- docs/anleitung-administration.md
|
||||||
|
- docs/anleitung-anwender.md
|
||||||
|
- CHANGELOG.md
|
||||||
|
autonomous: true
|
||||||
|
requirements: [QUICK-261002-icv]
|
||||||
|
|
||||||
|
estimate:
|
||||||
|
tokens: 190000
|
||||||
|
raw_tokens: 190000
|
||||||
|
tasks: 3
|
||||||
|
confidence: low
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "An admin can set every module grant (group or single user) to Benutzen (USE) or Verwalten (MANAGE); grants that existed before the migration are USE"
|
||||||
|
- "A non-admin with MANAGE on a module can call that module's settings/administration endpoints (2xx) and sees its settings tab/controls; with only USE the same endpoints return 403 and the controls are hidden"
|
||||||
|
- "If a user has USE via one grant and MANAGE via another for the same module, the effective level is MANAGE"
|
||||||
|
- "MANAGE on module A grants nothing extra on module B, and a MANAGE grant on a deactivated module grants nothing"
|
||||||
|
- "Managers still cannot grant/revoke access, activate/deactivate modules, or reach users/groups/LDAP/SMTP/platform-wide tender settings (those stay @Roles(ADMIN, SUPER_ADMIN))"
|
||||||
|
- "ADMIN and SUPER_ADMIN keep full rights: they resolve to MANAGE on every active module"
|
||||||
|
- "DKV (dkv-fleet), admin-only for every handler today, becomes manager-level as a whole; USE-level DKV users get no API access (unchanged) and see an explanatory access page"
|
||||||
|
artifacts:
|
||||||
|
- path: "apps/api/prisma/migrations/20261002140000_module_grant_level/migration.sql"
|
||||||
|
provides: "ModuleGrantLevel enum + ModuleGrant.level NOT NULL DEFAULT 'USE'"
|
||||||
|
contains: "ModuleGrantLevel"
|
||||||
|
- path: "apps/api/src/module-registry/module.guard.ts"
|
||||||
|
provides: "ModuleManage(slug) decorator + MODULE_MANAGE_KEY enforced by ModuleGuard"
|
||||||
|
exports: ["ModuleGuard", "UseModule", "ModuleManage", "MODULE_SLUG_KEY", "MODULE_MANAGE_KEY"]
|
||||||
|
- path: "apps/api/src/module-registry/module-access.service.ts"
|
||||||
|
provides: "getModuleAccessLevels — single source of truth for access AND level"
|
||||||
|
- path: "apps/web/src/lib/use-module-capability.ts"
|
||||||
|
provides: "useCanManageModule(slug) display hook fed by GET /modules/active canManage"
|
||||||
|
- path: "apps/api/src/module-registry/module-manage-handlers.spec.ts"
|
||||||
|
provides: "metadata proof: converted handlers use ModuleManage, admin-only handlers keep @Roles"
|
||||||
|
key_links:
|
||||||
|
- from: "apps/api/src/module-registry/module.guard.ts"
|
||||||
|
to: "ModuleAccessService.getModuleAccessLevels"
|
||||||
|
via: "per-request memo request.moduleAccessLevels"
|
||||||
|
pattern: "getModuleAccessLevels"
|
||||||
|
- from: "apps/api/src/groups/dto/create-module-grant.dto.ts"
|
||||||
|
to: "ModuleGrantsService.grant -> ModuleGrant.level"
|
||||||
|
via: "POST /module-grants { level }"
|
||||||
|
pattern: "IsEnum\\(ModuleGrantLevel\\)"
|
||||||
|
- from: "GET /modules/active (canManage)"
|
||||||
|
to: "apps/web/src/lib/use-module-capability.ts -> module pages"
|
||||||
|
via: "useCanManageModule"
|
||||||
|
pattern: "useCanManageModule"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Add a second level to every module grant: "Benutzen" (USE, default, today's behavior) and "Verwalten" (MANAGE = use the module AND change that module's own settings/configuration). Backend is the source of truth via a reusable `@ModuleManage('<slug>')` decorator on `ModuleGuard`; the web learns the effective level per module from `GET /modules/active` (`canManage`) and shows settings tabs/controls to admins AND managers. Admins assign the level in the grant matrix (groups) and the user access dialog (single users).
|
||||||
|
|
||||||
|
Locked decisions from the request (cited below as L-xx):
|
||||||
|
- L-01 Only admins grant modules and choose the level; module activation and grant endpoints stay admin-only; managers get no users/groups/global settings.
|
||||||
|
- L-02 MANAGE grantable to single users AND groups like today; USE+MANAGE via different grants → MANAGE wins.
|
||||||
|
- L-03 Admins/super-admins implicitly manage every module.
|
||||||
|
- L-04 Applies to ALL module-scoped admin-only handlers; truly system-wide/cross-module ones stay admin-only and are listed in the SUMMARY with reason. DKV: admin-only usage today → convert whole module to manager-level, document, never widen USE.
|
||||||
|
- L-05 Reusable decorator/guard, no per-controller ad-hoc checks; web gets the effective level (`canManage`) from the module-list endpoint.
|
||||||
|
- L-06 Prisma migration: enum level column on ModuleGrant, default USE, hand-written SQL, RLS gates/tests checked.
|
||||||
|
- L-07 Admin grant UI: level choice per grant (Benutzen / Verwalten), formal German "Sie" + English, level shown in grant lists.
|
||||||
|
- L-08 Module pages: settings tabs/controls for admins AND managers (replace role checks with per-module capability).
|
||||||
|
- L-09 Tests: guard/access resolution (user grant, group grant, mixed, admin bypass, no grant), controller metadata, DTO validation, web grant UI + one module settings tab; full api + web suites, tsc, biome on touched files.
|
||||||
|
- L-10 CHANGELOG (Unveröffentlicht, user-facing German) + docs/ where grants are explained.
|
||||||
|
- L-11 Local migration via container IP + `docker compose up -d --build api web`; no push.
|
||||||
|
- L-12 NestJS static routes before `:id` (no new routes planned); never mention "Mandant"/tenant in new user-facing texts.
|
||||||
|
|
||||||
|
Output: migration, level-aware access service + guard + decorator, converted controllers (kantine-datev, handelsware-datev, proxmox, dkv-fleet), admin grant UI with level, capability-driven module pages, tests, docs, changelog, rebuilt local stack. Three atomic commits on main, NOT pushed.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@~/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/STATE.md
|
||||||
|
@./CLAUDE.md
|
||||||
|
|
||||||
|
Discovered facts the executor can rely on (verified during planning):
|
||||||
|
- `RolesGuard`, `JwtAuthGuard`, `TenantGuard` are global `APP_GUARD`s (apps/api/src/app.module.ts). Any handler still carrying `@Roles(ADMIN, SUPER_ADMIN)` blocks managers regardless of other guards — converted handlers MUST drop `@Roles`.
|
||||||
|
- `ModuleRegistryModule` exports `ModuleRegistryService`, `ModuleAccessService`, `ModuleGuard`; DkvModule, KantineDatevModule, HandelswareDatev, Proxmox modules already import it (no DI change needed).
|
||||||
|
- Module slugs: `kantine-datev`, `handelsware-datev`, `proxmox`, `dkv-fleet` (apps/api/src/dkv/dkv.seed.ts), `tender-radar`, `cert-manager`, `domaincheck`.
|
||||||
|
- `ModuleAccessService.getAccessibleModuleIds` is consumed by ModuleGuard, `getCatalogFlags`, `findAccessibleModules` and `apps/api/src/dashboard/dashboard.service.ts` — its signature must stay.
|
||||||
|
- `rls-access-inventory.spec.ts` keys on (file, model) pairs and bound/unbound state: every new Prisma access in module-access.service.ts / module-grants.service.ts MUST go through the existing `forTenant(...)` client of that method, and must not add `include:`/relation `select:` to new models.
|
||||||
|
- ValidationPipe is global with `whitelist: true, transform: true` (apps/api/src/main.ts).
|
||||||
|
- Web module pages are reachable via `/modules/<slug>` (own layout with `ModuleAccessGate`) AND via the sidebar link `/modules/<category>/<slug>` (generic `[category]/[moduleSlug]/page.tsx` → `ModuleAccessGate` → `ModuleShell`). Module page components are client components using `useAuthStore`.
|
||||||
|
- i18n namespaces: matrix uses `admin.groups.grants` (has `matrixCheckboxLabel`); UserAccessModal uses `admin.users.grants` (has `directCheckboxLabel`); matrix page texts `adminModules.grants`; gate texts `modules.accessDenied`; `proxmox.settings.accessDeniedText`; `kantineDatev.notConfigured.user`; `handelswareDatev.notConfigured.user`. `apps/web/src/messages/umlaut-guard.spec.ts` requires real umlauts in de.json.
|
||||||
|
- Migration convention: hand-written SQL with German header comment (model: apps/api/prisma/migrations/20261002120000_kantine_datev_config/migration.sql); latest migration is 20261002130000_handelsware_datev.
|
||||||
|
- Commits: German subject, conventional prefix, end with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`. Never push.
|
||||||
|
|
||||||
|
@apps/api/src/module-registry/module.guard.ts
|
||||||
|
@apps/api/src/module-registry/module-access.service.ts
|
||||||
|
@apps/api/src/groups/module-grants.service.ts
|
||||||
|
@apps/api/src/groups/dto/create-module-grant.dto.ts
|
||||||
|
@apps/api/src/kantine-datev/kantine-datev.controller.ts
|
||||||
|
@apps/web/src/components/modules/module-access-gate.tsx
|
||||||
|
@apps/web/src/lib/module-access-actions.ts
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="tracer">
|
||||||
|
<name>Task 1: Tracer — grant level end-to-end: DB column → access levels → ModuleManage guard → kantine settings → /modules/active canManage → kantine settings tab</name>
|
||||||
|
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261002140000_module_grant_level/migration.sql, apps/api/src/groups/migration-sql.spec.ts, apps/api/src/module-registry/module-access.service.ts, apps/api/src/module-registry/module-access.service.spec.ts, apps/api/src/module-registry/module.guard.ts, apps/api/src/module-registry/module.guard.spec.ts, apps/api/src/groups/dto/create-module-grant.dto.ts, apps/api/src/groups/dto/create-module-grant.dto.spec.ts, apps/api/src/groups/module-grants.service.ts, apps/api/src/groups/module-grants.service.spec.ts, apps/api/src/kantine-datev/kantine-datev.controller.ts, apps/api/src/kantine-datev/kantine-datev.controller.spec.ts, apps/web/src/lib/api.ts, apps/web/src/lib/use-module-capability.ts, apps/web/src/app/(portal)/modules/kantine-datev/page.tsx, apps/web/src/app/(portal)/modules/kantine-datev/kantine-datev.test.tsx</files>
|
||||||
|
<behavior>
|
||||||
|
- ModuleAccessService.getModuleAccessLevels: USER with only a direct USE grant → Map {m1: USE}; direct USE + group MANAGE on same module → MANAGE (L-02); MANAGE only via group → MANAGE; MANAGE grant on a module whose TenantModuleActivation is inactive → absent; ADMIN and SUPER_ADMIN → every active module = MANAGE without grant queries (L-03); no grants → empty Map; rows without a `level` field (old mocks) count as USE.
|
||||||
|
- getAccessibleModuleIds still returns exactly the key set (existing spec cases keep passing); findAccessibleModules rows carry `canManage` true/false.
|
||||||
|
- ModuleGuard: @UseModule route + USE → allowed; @ModuleManage route + USE → ForbiddenException; + MANAGE → allowed; admin → allowed; no grant → ForbiddenException; MANAGE on module "a" while the route is ModuleManage('b') → ForbiddenException; a second canActivate on the same request object reuses request.moduleAccessLevels and does not call the service again; ModuleManage(slug) sets MODULE_SLUG_KEY, MODULE_MANAGE_KEY=true and guards metadata containing ModuleGuard.
|
||||||
|
- CreateModuleGrantDto: level 'USE', 'MANAGE' or omitted → valid; 'ADMIN' and lowercase 'manage' → validation error on `level`.
|
||||||
|
- ModuleGrantsService.grant: no level → create with USE; level MANAGE → create with MANAGE; existing USE row + level MANAGE → update to MANAGE and log line; existing MANAGE row + no level → no update, existing returned (repeat click never downgrades); P2002 race + level given → same update rule.
|
||||||
|
- migration-sql.spec: the new migration contains the CREATE TYPE and ADD COLUMN statements below.
|
||||||
|
- Kantine controller metadata: saveSettings has MODULE_MANAGE_KEY true, slug 'kantine-datev', no ROLES_KEY; getSettings/preview/export have no MODULE_MANAGE_KEY.
|
||||||
|
- Kantine web page: USER whose /modules/active entry has canManage true sees the "Einstellungen" tab; USER with canManage false does not; ADMIN sees it without any /modules/active fetch.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**Schema + migration (L-06).** In `apps/api/prisma/schema.prisma` add `enum ModuleGrantLevel { USE MANAGE }` next to `enum MembershipSource`, and on `model ModuleGrant` add `level ModuleGrantLevel @default(USE)` with a German comment (261002-icv: Freigabestufe; USE = Benutzen, Standard und Bestand; MANAGE = Verwalten — Modul benutzen und dessen eigene Einstellungen ändern; Freigaben erteilen bleibt Administratoren vorbehalten). Hand-write `apps/api/prisma/migrations/20261002140000_module_grant_level/migration.sql`: German header comment in the style of 20261002120000_kantine_datev_config (purpose; existing rows become USE through the DEFAULT, so nobody gains rights; no new table, the existing ModuleGrant row policies filter rows not columns and stay unchanged, so rls-coverage needs nothing; PostgreSQL grants USAGE on new types to PUBLIC, so tessera_app can use the enum; switch-is-off note). Statements, exactly: `CREATE TYPE "ModuleGrantLevel" AS ENUM ('USE', 'MANAGE');` and `ALTER TABLE "ModuleGrant" ADD COLUMN "level" "ModuleGrantLevel" NOT NULL DEFAULT 'USE';`. Add a describe block to `apps/api/src/groups/migration-sql.spec.ts` using its `readMigrationSql('_module_grant_level')` helper asserting both statements. Run `pnpm --filter @tessera/api exec prisma generate`. Apply locally (L-11): IP via `docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1`, then `DATABASE_URL="postgresql://tessera:tessera_dev@<IP>:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy`, confirm with `prisma migrate status` and a drift check `prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --exit-code` (same env, run in apps/api via the filter) — exit code 0 means schema and SQL agree.
|
||||||
|
|
||||||
|
**Access resolution (L-02, L-03, L-05).** In `module-access.service.ts` add `getModuleAccessLevels(tenantId, userId, role): Promise<Map<string, ModuleGrantLevel>>` as the single resolution. Admin/SUPER_ADMIN branch: same activation query as today, every moduleId → MANAGE. Other roles: the same two grant queries as today (direct `userId`, group via `group: { memberships: { some: { userId } } }`), now selecting `{ moduleId: true, level: true }`; merge so MANAGE wins (any value other than 'MANAGE' counts as USE); intersect with the same active-activation query as today. Use the one `forTenant` client of the method for all accesses (rls-access-inventory). Rewrite `getAccessibleModuleIds` to return the key set of `getModuleAccessLevels` (signature unchanged). `findAccessibleModules` returns each catalog row spread plus `canManage: level === 'MANAGE'` (catalog query stays on `this.prisma` as today). Update the class/method doc comments (Freigabestufe, 261002-icv). Update the doc comment of `GET /modules/active` in module-registry.controller.ts only if you touch it — no code change there is needed.
|
||||||
|
|
||||||
|
**Guard + decorator (L-05).** In `module.guard.ts` export `MODULE_MANAGE_KEY = 'moduleManage'`. In `canActivate`, after the existing slug/tenant/user/findBySlug steps: read `requireManage` with `reflector.getAllAndOverride<boolean>(MODULE_MANAGE_KEY, [handler, class])`; take `request.moduleAccessLevels` if it is already a Map (class-level @UseModule plus handler-level @ModuleManage run this guard twice per request), else call `getModuleAccessLevels`; no entry for module.id → existing "not accessible" ForbiddenException; requireManage and level not MANAGE → ForbiddenException with message `Module '<slug>' requires manage permission`; store `request.moduleAccessLevels` and keep setting `request.moduleAccessIds` (Set of keys). Export `ModuleManage(slug)` = applyDecorators(SetMetadata(MODULE_SLUG_KEY, slug), SetMetadata(MODULE_MANAGE_KEY, true), UseGuards(ModuleGuard)) with a German JSDoc: replaces `@Roles(ADMIN, SUPER_ADMIN)` for module-scoped configuration; usable on a handler inside a @UseModule controller or on a whole controller; admins pass via the D-03 short-circuit; never combine with @Roles on the same handler (global RolesGuard would still block managers); tenant/user/role only from the JWT (T-15-10). Update `module.guard.spec.ts` mocks from `getAccessibleModuleIds` to `getModuleAccessLevels` and add the behavior cases.
|
||||||
|
|
||||||
|
**Grant write side (L-01, L-02).** `CreateModuleGrantDto`: add optional `level?: ModuleGrantLevel` with `@IsOptional()` and `@IsEnum(ModuleGrantLevel)` (import from @prisma/client); doc comment: level is ignored on DELETE. New `apps/api/src/groups/dto/create-module-grant.dto.spec.ts` following `apps/api/src/custom-modules/dto/custom-module.dto.spec.ts` (plainToInstance + validate). `ModuleGrantsService.grant` accepts `level?: ModuleGrantLevel`; keep the existing check order (XOR, tenant cross-check, activation). Then find the existing row for the exact target with `tenantPrisma.moduleGrant.findFirst` (same where as the current P2002 branch): if it exists and a level was given that differs → `tenantPrisma.moduleGrant.update({ where: { id: existing.id }, data: { level } })`, log `Grant-Stufe geändert: tenant=… module=… <target> level=<level>`, return it; if it exists otherwise → return it unchanged (no level given never changes the level). If not, create with `level: level ?? 'USE'` and add `level=` to the existing log line; the P2002 branch applies the same rule. Replace the outdated class comment sentence about the record carrying no level (old D-04) with: since 261002-icv the row carries `level`; only this admin-only service sets it. Controller unchanged (stays admin-only, L-01). Extend `module-grants.service.spec.ts` with the grant cases.
|
||||||
|
|
||||||
|
**First converted handler.** In `kantine-datev.controller.ts` replace `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` on `saveSettings` with `@ModuleManage('kantine-datev')`, remove now-unused `Role`/`Roles` imports, and update the header comment (settings: administrators and users with Freigabestufe Verwalten, 261002-icv). Update `kantine-datev.controller.spec.ts` (its ROLES_KEY expectation on saveSettings becomes the MODULE_MANAGE_KEY expectation).
|
||||||
|
|
||||||
|
**Web capability (L-05, L-08).** `apps/web/src/lib/api.ts`: add `canManage?: boolean` to `ApiModule`. New `apps/web/src/lib/use-module-capability.ts` exporting `useCanManageModule(moduleSlug: string): boolean | null`: null while `useAuthStore` user is null; true immediately for ADMIN/SUPER_ADMIN (mirrors the backend short-circuit, no fetch); otherwise one fetch of `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/modules/active` with `credentials: 'include'`, `cache: 'no-store'`, result true only if an entry has this slug AND `canManage === true`; non-ok or thrown → false; ignore results after unmount. German doc comment: display only, ModuleGuard is the binding check. In `kantine-datev/page.tsx` replace the `isAdmin` role check with `useCanManageModule('kantine-datev') === true` (settings tab + BillingTab hint), renaming the BillingTab prop `isAdmin` → `canManage`. Extend `kantine-datev.test.tsx`: stub global fetch (vi.stubGlobal, unstub in afterEach) answering `/modules/active` with `[{ slug: 'kantine-datev', canManage: true }]` or `canManage: false`, plus an ADMIN case asserting fetch was not called; existing ADMIN/USER cases must stay green.
|
||||||
|
|
||||||
|
Biome-lint the touched files (`pnpm exec biome lint <files>` from repo root), commit `feat(module-grants): Freigabestufe Verwalten – Datenbank, Zugriffsprüfung und Kantinen-Einstellungen` (attribution line). Do not push.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api exec vitest run src/module-registry src/groups src/kantine-datev rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run kantine-datev && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Migration applied locally (`prisma migrate status` up to date, drift check exit 0); listed api/web tests green incl. rls gates; both type-checks clean; a USER with a MANAGE grant passes `PUT /modules/kantine-datev/settings` guard logic (unit-proven) and sees the Einstellungen tab; one commit, not pushed.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Convert remaining module-scoped admin handlers (proxmox, handelsware-datev, dkv-fleet) + their web pages and DKV access page</name>
|
||||||
|
<files>apps/api/src/proxmox/proxmox.controller.ts, apps/api/src/proxmox/proxmox-client.service.ts, apps/api/src/handelsware-datev/handelsware-datev.controller.ts, apps/api/src/handelsware-datev/handelsware-datev.controller.spec.ts, apps/api/src/dkv/dkv.controller.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, apps/web/src/lib/module-access-actions.ts, apps/web/src/components/modules/module-access-gate.tsx, apps/web/src/components/modules/module-access-gate.test.tsx, apps/web/src/app/(portal)/modules/handelsware-datev/page.tsx, apps/web/src/app/(portal)/modules/handelsware-datev/components/ImportTab.tsx, apps/web/src/app/(portal)/modules/handelsware-datev/handelsware-datev.test.tsx, apps/web/src/app/(portal)/modules/proxmox/page.tsx, apps/web/src/app/(portal)/modules/proxmox/settings/page.tsx, apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx, apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx, apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx, apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx, apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
|
||||||
|
<behavior>
|
||||||
|
- module-manage-handlers.spec (metadata, L-04/L-09): DkvController class has MODULE_SLUG_KEY 'dkv-fleet', MODULE_MANAGE_KEY true, guards metadata (GUARDS_METADATA from @nestjs/common/constants) containing ModuleGuard, and none of its prototype methods has ROLES_KEY; ProxmoxController create/update/remove/poll/test/testDraft have MODULE_MANAGE_KEY true + slug 'proxmox' and no ROLES_KEY, `list` has neither; KantineDatevController.saveSettings and HandelswareDatevController.saveSettings are manage-level; STAY ADMIN-ONLY: TendersController getSourceConfig/saveSourceConfig/pollNow have ROLES_KEY [ADMIN, SUPER_ADMIN] and no MODULE_MANAGE_KEY; ModuleGrantsController matrix/userAccess/create/remove and ModuleRegistryController activate/deactivate have ROLES_KEY [ADMIN, SUPER_ADMIN].
|
||||||
|
- Gate: slug 'dkv-fleet' with level 'manage' → children; 'use' → denied page with the manage-required body text; 'none' or thrown → standard denied text; other slugs keep calling checkModuleAccess exactly once (existing tests unchanged).
|
||||||
|
- Handelsware page: USER with canManage true sees the settings tab; false does not.
|
||||||
|
- Proxmox: USER with canManage true sees the manager controls (poll button / settings link / enabled form); USER with canManage false keeps today's read-only view; ADMIN/SUPER_ADMIN unchanged.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**API conversions (L-04, L-05).** `proxmox.controller.ts`: replace `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` on create, update, remove, poll, test and testDraft with `@ModuleManage('proxmox')`; `GET servers` stays USE (class @UseModule); drop unused imports; update the header comment. `proxmox-client.service.ts`: comment only — the SSRF safeguard is now "administrator or a user the administrator explicitly granted Verwalten for the Proxmox module (`@ModuleManage('proxmox')`, 261002-icv)". `handelsware-datev.controller.ts`: `saveSettings` → `@ModuleManage('handelsware-datev')` (account routes are already USE-level, leave them), update header comment and `handelsware-datev.controller.spec.ts` (ROLES_KEY expectation → MODULE_MANAGE_KEY). `dkv.controller.ts`: today it has NO @UseModule and every one of its 11 handlers carries @Roles(ADMIN, SUPER_ADMIN) — i.e. DKV usage itself is admin-only. Per L-04 put `@ModuleManage('dkv-fleet')` on the class, remove all 11 per-handler @Roles plus the `Role`/`Roles` imports, and rewrite the header comment: whole module is Verwalten-level; USE-level users keep getting 403 exactly as before (not widened); the class guard additionally requires the dkv-fleet activation (the web page already required it). No route order changes anywhere (L-12).
|
||||||
|
|
||||||
|
**Leave admin-only (do not edit; list in SUMMARY with reason):** tenders `getSourceConfig`/`saveSourceConfig`/`pollNow` (platform-wide singleton poll config and upstream fetch for the whole installation, not module-per-company configuration); tenders `createRssFeed` scope 'platform' and `removeRssFeed` platform-feed branch (platform-wide feeds shown to every user of the installation); module-registry activate/deactivate and all module-grants routes (L-01); custom-modules shared entries (not a registry module, no @UseModule, sidebar entries for everyone); groups/user/ldap/settings (SMTP)/tenant/welcome-mail controllers (global administration). Confirm with `grep -rn "Roles(" apps/api/src --include=*.ts` that cert-manager, domaincheck and reminders have no admin-only handler; mention that in the SUMMARY.
|
||||||
|
|
||||||
|
New `apps/api/src/module-registry/module-manage-handlers.spec.ts` with the metadata assertions from behavior (if importing several controllers in one file proves problematic, split per controller next to it and adjust files list in the SUMMARY).
|
||||||
|
|
||||||
|
**Web (L-08).** `module-access-actions.ts`: add `getModuleAccessLevel(moduleSlug): Promise<'none' | 'use' | 'manage'>` (same cookie forwarding and fail-closed handling, reading `canManage` from /modules/active); make `checkModuleAccess` delegate (`!== 'none'`) with unchanged signature. `module-access-gate.tsx`: add `MANAGE_ONLY_MODULE_SLUGS = new Set(['dkv-fleet'])` with a German comment pointing at the class-level @ModuleManage on DkvController; for those slugs call getModuleAccessLevel ('manage' → children, 'use' → ModuleAccessDenied with body `t('accessDenied.manageRequiredBody')`, else the standard denied texts); all other slugs keep the current checkModuleAccess path. Extend `module-access-gate.test.tsx`. Handelsware: `useCanManageModule('handelsware-datev') === true` replaces isAdmin in page.tsx; rename ImportTab prop `isAdmin` → `canManage`. Proxmox: `useCanManageModule('proxmox')` in page.tsx and settings/page.tsx (settings page shows its existing loading placeholder while the value is null, the denied text when false); rename the `isAdmin` props of ServerCard and ServerForm to `canManage` and update their tests and code comments (e.g. the idle-text comment about the poll endpoint). Update `proxmox-page-roles.test.tsx` (stub fetch for USER cases: `[]` for read-only, `[{ slug: 'proxmox', canManage: true }]` for the new manager case) and add one handelsware manager case.
|
||||||
|
|
||||||
|
**Texts (de + en, formal "Sie", real umlauts, no "Mandant"/tenant in new wording, L-12):** `proxmox.settings.accessDeniedText` → „Diese Seite steht Administratoren und Benutzern zur Verfügung, die dieses Modul verwalten dürfen.“ / "This page is available to administrators and to users who may manage this module."; in `kantineDatev.notConfigured.user` and `handelswareDatev.notConfigured.user` replace only the leading „Ein Administrator muss“ with „Ein Administrator oder jemand, der dieses Modul verwalten darf, muss“ (rest verbatim; en: "An administrator or someone who may manage this module must …"); new `modules.accessDenied.manageRequiredBody` → „Dieses Modul steht nur Benutzern zur Verfügung, die es verwalten dürfen. Wenden Sie sich an Ihren Administrator.“ / "This module is only available to users who may manage it. Please contact your administrator.".
|
||||||
|
|
||||||
|
Biome-lint touched files, commit `feat(module-grants): Proxmox, Handelsware und DKV mit Freigabestufe Verwalten` (attribution line). Do not push.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api exec vitest run src/module-registry src/kantine-datev src/handelsware-datev src/proxmox src/dkv src/tenders src/groups && pnpm --filter @tessera/web exec vitest run proxmox handelsware-datev kantine-datev module-access umlaut && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test -z "$(grep -nE '^\s*@Roles\(' apps/api/src/dkv/dkv.controller.ts apps/api/src/proxmox/proxmox.controller.ts apps/api/src/kantine-datev/kantine-datev.controller.ts apps/api/src/handelsware-datev/handelsware-datev.controller.ts)" && grep -qE '^\s*@Roles\(' apps/api/src/tenders/tenders.controller.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<done>No decorator-level @Roles left in the four converted controllers, tenders keeps its @Roles on getSourceConfig/saveSourceConfig/pollNow (proven by the metadata spec); metadata spec proves converted vs. admin-only handlers; proxmox/handelsware/kantine pages and DKV gate follow canManage; tests and type-checks green; one commit, not pushed.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 3: Admin grant UI with level (matrix + user dialog), docs, changelog, full suites, local rebuild</name>
|
||||||
|
<files>apps/api/src/groups/module-grants.service.ts, apps/api/src/groups/module-grants.service.spec.ts, apps/web/src/app/(portal)/admin/modules/grants/page.tsx, apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx, apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx, apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, docs/anleitung-administration.md, docs/anleitung-anwender.md, CHANGELOG.md</files>
|
||||||
|
<behavior>
|
||||||
|
- getMatrix: each grant item is { moduleId, groupId, level }.
|
||||||
|
- getUserAccess: each module row keeps { module, viaGroups, direct } and adds `directLevel` (level of the direct grant or null) and `manageViaGroups` (display names of the groups granting MANAGE, subset of viaGroups).
|
||||||
|
- Matrix: a granted cell shows a level select with Benutzen/Verwalten reflecting the response; choosing Verwalten POSTs /module-grants with { moduleId, groupId, level: 'MANAGE' }; a failed POST rolls the select back; ticking an empty cell POSTs without level and shows Benutzen.
|
||||||
|
- User dialog: direct grant row shows a level select; changing it POSTs { moduleId, userId, level }; a group chip granting MANAGE shows the „Verwalten“ marker.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**API read side for the UI (L-07).** In `module-grants.service.ts` `getMatrix`: add `level: true` to the group-grant select and return `level` per grant item. `getUserAccess`: select `{ moduleId: true, level: true }` for direct grants; group grants already include the row (use its `level`); add `directLevel` and `manageViaGroups` per row as in behavior, leaving existing keys untouched. Keep every access on the method's `tenantPrisma` (rls-access-inventory). Extend the spec.
|
||||||
|
|
||||||
|
**Matrix page (`admin/modules/grants/page.tsx`).** State becomes a Map cellKey → 'USE' | 'MANAGE'. For a granted cell render, next to the checkbox, a compact native select (options from `admin.groups.grants.levelUse` / `levelManage`, aria-label `levelSelectLabel` with module + group) styled like the page inputs and disabled while that cell is saving; on change POST `/module-grants` with `{ moduleId, groupId, level }` using the same optimistic update + rollback + error banner as `toggleGrant`. Ticking an empty cell keeps POSTing without level (server default USE) and stores USE locally; revoke unchanged. Below the table render `adminModules.grants.levelExplanation` and the rewritten `adminNote`. Extend `grants-matrix.test.tsx` (its existing fetch-stub pattern).
|
||||||
|
|
||||||
|
**User dialog (`UserAccessModal.tsx`).** Extend the row type with `directLevel` and `manageViaGroups`. Group chips whose name is in `manageViaGroups` show the suffix marker `t('manageMarker')`. When `direct` is true, show a level select next to the checkbox (option labels from `admin.groups.grants`, aria-label `t('directLevelLabel', { module, user })`); change POSTs `{ moduleId, userId, level }` with the existing optimistic/rollback pattern; ticking the checkbox sets `directLevel` 'USE' locally. Extend `user-access-modal.test.tsx`.
|
||||||
|
|
||||||
|
**Texts (de/en, formal "Sie", no "Mandant"/tenant, real umlauts):** `admin.groups.grants.levelUse` „Benutzen“ / "Use"; `admin.groups.grants.levelManage` „Verwalten“ / "Manage"; `admin.groups.grants.levelSelectLabel` „Stufe für {module} in Gruppe {group}“ / "Level for {module} in group {group}"; `admin.users.grants.directLevelLabel` „Stufe der direkten Freigabe von {module} für {user}“ / "Level of the direct grant of {module} for {user}"; `admin.users.grants.manageMarker` „Verwalten“ / "Manage"; `adminModules.grants.levelExplanation` „„Benutzen“: Das Modul öffnen und damit arbeiten. „Verwalten“: zusätzlich die Einstellungen dieses Moduls ändern. Freigaben vergeben und Module aktivieren dürfen weiterhin nur Administratoren. Hat jemand über mehrere Wege Zugriff, gilt die höhere Stufe.“ (en equivalent); rewrite `adminModules.grants.adminNote` → „Administratoren haben immer Zugriff auf alle aktiven Module und dürfen deren Einstellungen ändern – diese Matrix betrifft nur Benutzer ohne Administratorrechte.“ / "Administrators always have access to all active modules and may change their settings — this matrix only affects users without administrator rights.".
|
||||||
|
|
||||||
|
**Docs (L-10, German, formal, new sentences without "Mandant").** `docs/anleitung-administration.md`: chapter 1 bullet on admin access (mention that the level Verwalten exists and admins implicitly have it); chapter 2 "Details zu Gruppen und Modulzugriff" (level select on the direct grant, Verwalten marker on group chips); chapter 5 new subsection "Freigabestufen: Benutzen und Verwalten" — what Verwalten unlocks per module (Kantinenabrechnung: Einstellungen; Handelsware: Einstellungen; Proxmox: Server anlegen, ändern, löschen, prüfen, sofort abrufen; DKV-Rechnung: das gesamte Modul, Benutzen allein reicht dort nicht), what stays admin-only (Freigaben erteilen und Stufe wählen, Module aktivieren, Benutzer, Gruppen, LDAP, SMTP, Willkommensmail, gemeinsame eigene Module, Ausschreibungs-Radar-Plattformeinstellungen: Abrufintervall, „Jetzt abrufen“, plattformweite RSS-Feeds), the higher level wins, existing grants are Benutzen; update the Freigaben-Matrix paragraph (level select per granted cell) and add a Fehlersuche row (user does not see the Einstellungen tab → grant is only Benutzen). `docs/anleitung-anwender.md`: after the Aktivieren/Freigeben paragraph a short paragraph on Verwalten; DKV-Rechnung section note (needs Verwalten); Proxmox server line and the Kantinenabrechnung/Handelsware "Einmalig einrichten" lines → "Administrator oder wer das Modul verwalten darf". `CHANGELOG.md` → under „## Unveröffentlicht“ / „### Neu“ one bullet in the existing user-facing style: two levels Benutzen (as before) and Verwalten; managers change the module's own settings (examples: Kantinenabrechnung, Handelsware, Proxmox-Server) without being administrators; admins choose the level per group in the Freigaben-Matrix or per user in the user details; existing grants stay Benutzen; the DKV-Rechnung module is available to administrators and to users with Verwalten.
|
||||||
|
|
||||||
|
**Finish.** Run full suites `pnpm --filter @tessera/api test` and `pnpm --filter @tessera/web test`, both type-checks, biome lint on every file touched in this plan. Rebuild the local stack from the repo root with `docker compose up -d --build api web`, then check `docker compose logs api --tail 120` for a clean start (no migration/Prisma error). No browser check (orchestrator does it), no push. Commit `feat(module-grants): Stufe Verwalten in Freigaben-Matrix und Benutzerdetails, Doku und Changelog` (attribution line). In the SUMMARY list: converted handlers per controller, the admin-only handlers with reasons (from Task 2), the DKV behavior change (now additionally requires activation; USE-level users see the explanatory page), and test counts.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && node -e 'const de=require("./apps/web/src/messages/de.json"),en=require("./apps/web/src/messages/en.json");const ks=["admin.groups.grants.levelUse","admin.groups.grants.levelManage","admin.groups.grants.levelSelectLabel","admin.users.grants.directLevelLabel","admin.users.grants.manageMarker","adminModules.grants.levelExplanation","adminModules.grants.adminNote","modules.accessDenied.manageRequiredBody","proxmox.settings.accessDeniedText"];const g=(o,k)=>k.split(".").reduce((a,p)=>a&&a[p],o);for(const k of ks)for(const m of [de,en]){const v=g(m,k);if(typeof v!=="string"||/mandant|tenant/i.test(v)){console.error("bad key",k);process.exit(1)}}' && grep -q "Verwalten" CHANGELOG.md && docker compose ps --status running --services | grep -qx api && docker compose ps --status running --services | grep -qx web</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Admins choose Benutzen/Verwalten per group cell and per direct user grant, lists show the level; full api + web suites, both type-checks and biome green; docs and changelog updated; api and web containers rebuilt and running with a clean api log; commit on main, not pushed; SUMMARY lists admin-only handlers with reasons.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| browser → API (module routes) | untrusted caller; identity/role/tenant only from the validated JWT |
|
||||||
|
| admin browser → POST /module-grants | the `level` field is client-supplied and decides future privileges |
|
||||||
|
| web UI capability display | `canManage` in the page is display only; ModuleGuard is binding |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-icv-01 | Elevation of Privilege | module-grants.controller.ts | high | mitigate | Grant/revoke routes keep @Roles(ADMIN, SUPER_ADMIN) (L-01); metadata spec in module-manage-handlers.spec.ts asserts it, so a manager cannot grant themselves or others |
|
||||||
|
| T-icv-02 | Elevation of Privilege | ModuleGuard / ModuleManage | high | mitigate | Level resolved server-side from ModuleGrant rows via getModuleAccessLevels using JWT userId/role/tenantId only; never from body/query; guard spec covers USE→403, MANAGE→ok, no grant→403 |
|
||||||
|
| T-icv-03 | Tampering | CreateModuleGrantDto.level | medium | mitigate | @IsOptional + @IsEnum(ModuleGrantLevel); DTO spec rejects 'ADMIN' and lowercase values; global whitelist ValidationPipe |
|
||||||
|
| T-icv-04 | Elevation of Privilege | cross-module scope | high | mitigate | MANAGE checked for the route's own slug's moduleId only; guard test: MANAGE on A → 403 on ModuleManage('b') |
|
||||||
|
| T-icv-05 | Elevation of Privilege | deactivated module | medium | mitigate | Levels intersected with active TenantModuleActivation (same as today); service test for inactive-module MANAGE grant |
|
||||||
|
| T-icv-06 | Elevation of Privilege | converted handlers still carrying @Roles or missing guard | high | mitigate | Verify gate: no decorator-level @Roles in the 4 converted controllers; metadata spec asserts MODULE_MANAGE_KEY + ModuleGuard on each converted handler/class |
|
||||||
|
| T-icv-07 | Elevation of Privilege | platform-wide tender settings, activation, users/groups/SMTP | high | mitigate | Left on @Roles(ADMIN, SUPER_ADMIN); metadata spec (tenders getSourceConfig/saveSourceConfig/pollNow, module-grants, activate/deactivate keep ROLES_KEY) plus the tenders @Roles presence gate prove nothing global was widened |
|
||||||
|
| T-icv-08 | Elevation of Privilege | DKV (dkv-fleet) | medium | mitigate | Whole controller @ModuleManage('dkv-fleet'): USE-level users stay at 403 as before (no silent widening); web gate shows explanatory page |
|
||||||
|
| T-icv-09 | Information Disclosure / SSRF | proxmox server addresses entered by managers | medium | accept | Proxmox targets are private by design (T-DHH-02, no address filter possible); the admin explicitly delegates via Verwalten; documented in proxmox-client.service.ts comment and admin docs |
|
||||||
|
| T-icv-10 | Repudiation | grant level changes | low | mitigate | Logger lines for create and level change include level=<level> (D-23 pattern, no audit table) |
|
||||||
|
| T-icv-11 | Tampering | repeated grant click downgrading MANAGE | low | mitigate | grant() never changes level when no level is sent; service test |
|
||||||
|
| T-icv-SC | Tampering | npm/pip/cargo installs | low | accept | No new packages in this plan; nothing to verify |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- Task verify commands above all pass; Task 3 runs the full api + web suites (includes rls-coverage, rls-access-inventory, umlaut-guard, migration-sql specs).
|
||||||
|
- `prisma migrate status` up to date locally; drift check exit 0.
|
||||||
|
- `docker compose ps` shows api and web running after rebuild; api log clean.
|
||||||
|
- Source coverage audit:
|
||||||
|
|
||||||
|
| Source item | Covered by |
|
||||||
|
|-------------|------------|
|
||||||
|
| GOAL: second grant level USE/MANAGE, managers change own module settings | Tasks 1-3 |
|
||||||
|
| L-01 admin-only grants/activation/global admin | Task 1 (controller untouched), Task 2 (metadata spec), T-icv-01/07 |
|
||||||
|
| L-02 users + groups, MANAGE wins | Task 1 (service + tests), Task 3 (UI both places) |
|
||||||
|
| L-03 admins implicit manage | Task 1 (short-circuit → MANAGE, hook short-circuit) |
|
||||||
|
| L-04 all module-scoped handlers, global ones listed, DKV converted | Task 1 (kantine), Task 2 (proxmox, handelsware, dkv, admin-only list) |
|
||||||
|
| L-05 reusable decorator/guard, canManage in /modules/active | Task 1 |
|
||||||
|
| L-06 migration default USE, RLS gates | Task 1 |
|
||||||
|
| L-07 admin grant UI level choice + display | Task 3 |
|
||||||
|
| L-08 module pages for admins AND managers | Task 1 (kantine), Task 2 (handelsware, proxmox, DKV gate) |
|
||||||
|
| L-09 tests + full suites + tsc + biome | Tasks 1-3 |
|
||||||
|
| L-10 CHANGELOG + docs | Task 3 |
|
||||||
|
| L-11 local migration + rebuild, no push | Task 1 (migrate), Task 3 (rebuild) |
|
||||||
|
| L-12 route order, no Mandant in texts | Task 2/3 text rules + node key check |
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- ModuleGrant has `level` (USE default); all existing grants are USE.
|
||||||
|
- `@ModuleManage(slug)` exists and is used by kantine-datev saveSettings, handelsware-datev saveSettings, six proxmox write handlers, and the whole DkvController; no @Roles remains on those handlers.
|
||||||
|
- GET /modules/active returns `canManage`; module pages show settings/controls for admins and managers only.
|
||||||
|
- Admin matrix and user dialog set and show the level.
|
||||||
|
- Full api + web test suites, both tsc runs and biome on touched files are green; local stack rebuilt; three commits on main, not pushed.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/quick/261002-icv-modul-freigabe-mit-stufe-verwalten-modul/261002-icv-SUMMARY.md` when done. It MUST contain a section "Bewusst nur für Administratoren" listing each handler kept admin-only with its reason, and a section on the DKV behavior change.
|
||||||
|
</output>
|
||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
---
|
||||||
|
phase: quick-261002-icv
|
||||||
|
plan: 01
|
||||||
|
quick_id: 261002-icv
|
||||||
|
subsystem: module-grants
|
||||||
|
tags: [berechtigungen, freigabestufe, module-guard, prisma, nestjs, nextjs]
|
||||||
|
status: complete
|
||||||
|
completed: 2026-10-02
|
||||||
|
commits: 3
|
||||||
|
plan_head_before: b94d267584398be7ccf714954dbd71ce577ef301
|
||||||
|
plan_head_after: eaf2c4574afb41f0787eb17874b709bd0faf1a01
|
||||||
|
actuals:
|
||||||
|
tasks: 3
|
||||||
|
commits: 3
|
||||||
|
requires: []
|
||||||
|
provides:
|
||||||
|
- "ModuleGrant.level (USE/MANAGE), Bestand = USE"
|
||||||
|
- "ModuleAccessService.getModuleAccessLevels als einzige Auflösung für Zugriff und Stufe"
|
||||||
|
- "@ModuleManage(slug) am ModuleGuard"
|
||||||
|
- "GET /modules/active liefert canManage je Modul"
|
||||||
|
- "Web-Hook useCanManageModule(slug)"
|
||||||
|
affects: [kantine-datev, handelsware-datev, proxmox, dkv-fleet, admin-freigaben]
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- apps/api/prisma/migrations/20261002140000_module_grant_level/migration.sql
|
||||||
|
- apps/api/src/groups/dto/create-module-grant.dto.spec.ts
|
||||||
|
- apps/api/src/module-registry/module-manage-handlers.spec.ts
|
||||||
|
- apps/web/src/lib/use-module-capability.ts
|
||||||
|
modified:
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/src/module-registry/module-access.service.ts
|
||||||
|
- apps/api/src/module-registry/module.guard.ts
|
||||||
|
- apps/api/src/groups/module-grants.service.ts
|
||||||
|
- apps/api/src/groups/dto/create-module-grant.dto.ts
|
||||||
|
- apps/api/src/kantine-datev/kantine-datev.controller.ts
|
||||||
|
- apps/api/src/handelsware-datev/handelsware-datev.controller.ts
|
||||||
|
- apps/api/src/proxmox/proxmox.controller.ts
|
||||||
|
- apps/api/src/dkv/dkv.controller.ts
|
||||||
|
- apps/web/src/lib/module-access-actions.ts
|
||||||
|
- apps/web/src/components/modules/module-access-gate.tsx
|
||||||
|
- "apps/web/src/app/(portal)/admin/modules/grants/page.tsx"
|
||||||
|
- "apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx"
|
||||||
|
decisions:
|
||||||
|
- "Stufe wird ausschließlich serverseitig aus ModuleGrant-Zeilen aufgelöst; MANAGE gewinnt bei mehreren Wegen."
|
||||||
|
- "Wiederholter Klick auf eine Matrix-Zelle (POST ohne level) ändert die Stufe nie."
|
||||||
|
- "DKV-Fleet wird als Ganzes Verwalten-Stufe, Benutzen-Stufe bekommt keinen API-Zugriff (wie bisher)."
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 261002-icv: Modul-Freigabe mit Stufe Benutzen und Verwalten
|
||||||
|
|
||||||
|
Jede Modul-Freigabe (Gruppe oder einzelner Benutzer) hat jetzt eine Stufe: **Benutzen** (USE, Standard und Bestand) oder **Verwalten** (MANAGE, zusätzlich die eigenen Einstellungen dieses einen Moduls ändern). Die Stufe wird im Backend über den neuen Dekorator `@ModuleManage('<slug>')` am `ModuleGuard` erzwungen. Die Weboberfläche erfährt die wirksame Stufe aus `GET /modules/active` (`canManage`) und zeigt Einstellungen nur Administratoren und Verwaltern. Administratoren vergeben die Stufe in der Freigaben-Matrix (je Gruppe) und im Benutzer-Detaildialog (je Benutzer).
|
||||||
|
|
||||||
|
## Was gebaut wurde
|
||||||
|
|
||||||
|
**Datenbank (Task 1).** Migration `20261002140000_module_grant_level` (von Hand geschrieben): `CREATE TYPE "ModuleGrantLevel"` und `ADD COLUMN "level" ... NOT NULL DEFAULT 'USE'`. Lokal angewendet über die Container-IP; `prisma migrate status` aktuell, Drift-Prüfung (`migrate diff --exit-code`) Rückgabewert 0. Die vorhandenen vier Freigaben stehen lokal alle auf USE. Keine neue Tabelle, daher keine Änderung an RLS-Regeln; `rls-coverage` und `rls-access-inventory` grün.
|
||||||
|
|
||||||
|
**Zugriffsauflösung und Wächter (Task 1).**
|
||||||
|
- `ModuleAccessService.getModuleAccessLevels` ist die einzige Auflösung: ADMIN/SUPER_ADMIN bekommen auf jedem aktiven Modul MANAGE ohne Grant-Abfragen; andere Rollen Direkt- plus Gruppen-Grants, MANAGE gewinnt, geschnitten mit aktiven Aktivierungen (MANAGE auf deaktiviertem Modul zählt nicht). Alles über denselben `forTenant`-Klienten. `getAccessibleModuleIds` ist nur noch die Schlüsselmenge (Signatur unverändert), `findAccessibleModules` liefert `canManage`.
|
||||||
|
- `ModuleGuard` liest `MODULE_MANAGE_KEY`, nutzt pro Request `request.moduleAccessLevels` (Klassen-`@UseModule` plus Handler-`@ModuleManage` fragen den Dienst nur einmal) und wirft bei Stufe USE `Module '<slug>' requires manage permission`. MANAGE gilt nur für das Modul der Route.
|
||||||
|
- `CreateModuleGrantDto.level` (optional, `@IsEnum`). `ModuleGrantsService.grant`: ohne Stufe USE; vorhandene Freigabe wird nur bei ausdrücklich anderer Stufe geändert (Logzeile `Grant-Stufe geändert … level=…`), ein Wiederholungsklick stuft nie herab; P2002-Wettlauf wendet dieselbe Regel an.
|
||||||
|
|
||||||
|
**Umgestellte Handler (Tasks 1 und 2).**
|
||||||
|
|
||||||
|
| Controller | Auf `@ModuleManage` umgestellt |
|
||||||
|
|---|---|
|
||||||
|
| `KantineDatevController` | `saveSettings` (`kantine-datev`) |
|
||||||
|
| `HandelswareDatevController` | `saveSettings` (`handelsware-datev`) |
|
||||||
|
| `ProxmoxController` | `create`, `update`, `remove`, `poll`, `test`, `testDraft` (`proxmox`); `list` bleibt Benutzen |
|
||||||
|
| `DkvController` | ganze Klasse (`dkv-fleet`), alle 11 früheren `@Roles` entfernt |
|
||||||
|
|
||||||
|
In den vier Controllern steht kein dekoratorseitiges `@Roles` mehr.
|
||||||
|
|
||||||
|
**Web (Tasks 1 bis 3).** `useCanManageModule(slug)` (Admins sofort `true` ohne Abfrage, sonst eine Abfrage von `/modules/active`, Fehler = `false`). Kantinenabrechnung, Handelsware, Proxmox-Seite, Proxmox-Einstellungen, `ServerCard`/`ServerForm` (Props `isAdmin` zu `canManage`) und das Proxmox-Dashboard-Widget (Einstellungs-Link) folgen `canManage`. Neue Server-Funktion `getModuleAccessLevel`; `checkModuleAccess` delegiert. `ModuleAccessGate` kennt `MANAGE_ONLY_MODULE_SLUGS = {'dkv-fleet'}`. Matrix und Benutzerdialog haben ein Stufen-Auswahlfeld (optimistisch, Rücksprung bei Fehler); Gruppen, die Verwalten gewähren, sind im Dialog mit „Verwalten“ markiert. Die API liefert dafür `level` je Matrix-Eintrag sowie `directLevel` und `manageViaGroups` je Modulzeile.
|
||||||
|
|
||||||
|
**Texte, Doku, Changelog.** Neue Schlüssel in `de.json`/`en.json` (formales „Sie“, echte Umlaute, kein „Mandant“/Tenant in den neuen Formulierungen; per Skript geprüft). `docs/anleitung-administration.md` (Kapitel 1, 2, 5 mit neuem Abschnitt „Freigabestufen: Benutzen und Verwalten“, Fehlersuche), `docs/anleitung-anwender.md`, `CHANGELOG.md` (Unveröffentlicht, Neu).
|
||||||
|
|
||||||
|
## Bewusst nur für Administratoren
|
||||||
|
|
||||||
|
Diese Handler blieben unverändert auf `@Roles(ADMIN, SUPER_ADMIN)`; die Metadaten-Spec `module-manage-handlers.spec.ts` beweist das:
|
||||||
|
|
||||||
|
| Handler | Grund |
|
||||||
|
|---|---|
|
||||||
|
| `TendersController.getSourceConfig` | plattformweiter Singleton der Abrufeinstellungen für die ganze Installation, keine Konfiguration eines einzelnen Moduls je Firma |
|
||||||
|
| `TendersController.saveSourceConfig` | wie oben; ändert Abrufintervall und Quelle für alle |
|
||||||
|
| `TendersController.pollNow` | stößt den plattformweiten Abruf beim Datenanbieter an (Lastschalter, T-lvg-01) |
|
||||||
|
| `TendersController.createRssFeed` (Bereich „platform“), `removeRssFeed` (plattformweiter Zweig) | prüfen die Rolle inline; plattformweite RSS-Feeds sehen alle Benutzer der Installation. Nicht angefasst |
|
||||||
|
| `ModuleRegistryController.activate` / `deactivate` | Modul-Aktivierung ist Sache der Administratoren (L-01) |
|
||||||
|
| `ModuleGrantsController.matrix` / `userAccess` / `create` / `remove` | Freigaben vergeben und Stufe wählen bleibt Administratoren vorbehalten (L-01); sonst könnte sich ein Verwalter selbst Rechte geben (T-icv-01) |
|
||||||
|
| Benutzer-, Gruppen-, LDAP-, SMTP-, Willkommensmail-, Mandanten-Controller | globale Administration, nicht modulgebunden |
|
||||||
|
| Gemeinsame eigene Module (`custom-modules`) | kein Registry-Modul und kein `@UseModule`; Seitenleisten-Einträge für alle, Rollenprüfung im Dienst |
|
||||||
|
|
||||||
|
Geprüft mit `grep -rn "Roles(" apps/api/src`: `cert-manager`, `domaincheck` und `reminders` haben keinen Administrator-Handler, also nichts umzustellen.
|
||||||
|
|
||||||
|
## DKV-Verhalten (Änderung)
|
||||||
|
|
||||||
|
Vorher trug jeder der 11 DKV-Handler `@Roles(ADMIN, SUPER_ADMIN)`, das Modul war faktisch nur für Administratoren benutzbar, und der Controller hatte kein `@UseModule`. Jetzt trägt die ganze Klasse `@ModuleManage('dkv-fleet')`:
|
||||||
|
- Zugriff haben Administratoren und Benutzer mit Stufe Verwalten.
|
||||||
|
- Benutzer mit nur Benutzen bekommen weiterhin 403 von der API (nichts wurde aufgeweitet).
|
||||||
|
- Neu ist, dass der Wächter zusätzlich die Aktivierung von `dkv-fleet` für den Mandanten verlangt (die Webseite verlangte sie schon). Ein Administrator ohne Aktivierung wird jetzt von der API ebenfalls abgewiesen.
|
||||||
|
- `ModuleAccessGate` zeigt Benutzern mit nur Benutzen eine erklärende Zugriffsseite („Dieses Modul steht nur Benutzern zur Verfügung, die es verwalten dürfen …“), sonst den Standardtext.
|
||||||
|
|
||||||
|
## Tests und Prüfungen (ehrlich)
|
||||||
|
|
||||||
|
- **API:** `pnpm --filter @tessera/api test`: 111 Dateien, 1911 Tests, alle grün (vorher in den Teilläufen u. a. `rls-coverage`, `rls-access-inventory`, `migration-sql`).
|
||||||
|
- **Web:** `pnpm --filter @tessera/web test`: 115 Dateien, 1234 Tests, alle grün (Vollauf vor einer reinen Umbenennung unbenutzter Testparameter; danach die betroffenen `admin`-Tests erneut grün, 7 Dateien, 96 Tests).
|
||||||
|
- **tsc:** `tsc --noEmit` in api und web ohne Fehler.
|
||||||
|
- **Biome:** `biome lint` auf allen berührten TS/TSX-Dateien ohne neue Meldungen. Zwei bereits vorhandene Warnungen bleiben bestehen und stammen nicht aus diesem Plan (`noAssignInExpressions` in `migration-sql.spec.ts`, `noArrayIndexKey` in `grants/page.tsx`).
|
||||||
|
- **Lokaler Stand:** `docker compose up -d --build api web` erfolgreich; api, web und db laufen; Log meldet `Nest application successfully started`, kein Migrations- oder Prisma-Fehler. Browserprüfung macht der Orchestrator.
|
||||||
|
|
||||||
|
## Abweichungen vom Plan
|
||||||
|
|
||||||
|
**1. [Rule 2 - Konsistenz] Proxmox-Dashboard-Widget auf `canManage` umgestellt**
|
||||||
|
- **Gefunden bei:** Task 2
|
||||||
|
- **Problem:** `proxmox-widget.tsx` blendete den Link „Zu den Einstellungen“ nur für Administratoren ein, obwohl Verwalter die Einstellungsseite jetzt nutzen dürfen. Die Datei stand nicht in der Dateiliste des Plans.
|
||||||
|
- **Fix:** `useCanManageModule('proxmox')` statt Rollenprüfung; bestehender Widget-Test blieb grün.
|
||||||
|
- **Commit:** c2ebc8d
|
||||||
|
|
||||||
|
**2. [Rule 1 - Typfehler] `applyToExisting` in `ModuleGrantsService.grant`** brauchte den Typ `ModuleGrant`, damit `tsc` die Spec akzeptiert. Behoben vor dem Commit von Task 1 (a222711).
|
||||||
|
|
||||||
|
Sonst: Plan wie geschrieben ausgeführt. Die Metadaten-Spec liegt wie geplant als eine Datei vor. Beim Handelsware-Test wurde eine Textprüfung (`/Ein Administrator muss zuerst/`) an den neuen Wortlaut angepasst, ebenso bei der Kantinenabrechnung.
|
||||||
|
|
||||||
|
## Bekannte Stubs
|
||||||
|
|
||||||
|
Keine.
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
Keine neuen Angriffsflächen außerhalb des Bedrohungsmodells des Plans. Hinweis zu T-icv-09: Wer für Proxmox „Verwalten“ erhält, darf Serveradressen eintragen (private Ziele per Design); das steht im Kommentar von `proxmox-client.service.ts` und im Administrationshandbuch.
|
||||||
|
|
||||||
|
## Commits (nicht gepusht)
|
||||||
|
|
||||||
|
- `a222711` feat(module-grants): Freigabestufe Verwalten – Datenbank, Zugriffsprüfung und Kantinen-Einstellungen
|
||||||
|
- `c2ebc8d` feat(module-grants): Proxmox, Handelsware und DKV mit Freigabestufe Verwalten
|
||||||
|
- `eaf2c45` feat(module-grants): Stufe Verwalten in Freigaben-Matrix und Benutzerdetails, Doku und Changelog
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- Migration, `module.guard.ts`, `use-module-capability.ts`, `module-manage-handlers.spec.ts`, `create-module-grant.dto.spec.ts` vorhanden.
|
||||||
|
- Alle drei Commit-Hashes existieren auf `main` (`git rev-list --count` über das Ledger: 3).
|
||||||
|
- Keine unbeabsichtigten Löschungen in den Commits.
|
||||||
|
|
||||||
|
## Browser-Prüfung (Orchestrator, 02.10., lokal, dunkel)
|
||||||
|
|
||||||
|
- Testgruppe „Buchhaltung Test“ mit Testbenutzer (USER) per API angelegt; in der Freigaben-Matrix Kantinenabrechnung = Verwalten, Handelsware = Benutzen gesetzt, bleibt nach Neuladen erhalten.
|
||||||
|
- Als Testbenutzer: Kantine zeigt Reiter Einstellungen, Speichern klappt; Handelsware ohne Einstellungs-Reiter; API: Handelsware-Einstellungen PUT → 403, DKV ohne Freigabe → 403.
|
||||||
|
- Korrektur: Matrix zeigte Kategorie-Kennungen („accounting“) → jetzt Anzeigenamen (Finanzbuchhaltung, Fuhrpark, …).
|
||||||
|
- Testbenutzer und Testgruppe wieder gelöscht.
|
||||||
+345
@@ -0,0 +1,345 @@
|
|||||||
|
---
|
||||||
|
phase: quick-261002-k67
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
quick_id: 261002-k67
|
||||||
|
description: "Neues Modul Nextcloud-Status: Clouds als Ampel-Kacheln mit Versionsbewertung, stündlicher Prüfung und Dashboard-Kachel"
|
||||||
|
date: 2026-10-02
|
||||||
|
files_modified:
|
||||||
|
# Task 1 — tracer: DB -> status.php check -> eol reference -> rating -> list API -> module page tiles
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-rating.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-rating.spec.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-status-fetch.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-status-fetch.spec.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-release.service.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-release.service.spec.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-status.service.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-status.controller.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts
|
||||||
|
- apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-status.seed.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-status.module.ts
|
||||||
|
- apps/api/src/app.module.ts
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
- apps/web/src/lib/nextcloud-status-api.ts
|
||||||
|
- apps/web/src/components/nextcloud-status/rating-display.ts
|
||||||
|
- apps/web/src/app/(portal)/modules/nextcloud-status/layout.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/module-layouts.test.tsx
|
||||||
|
- apps/web/src/lib/module-loader.ts
|
||||||
|
- apps/web/src/lib/module-identity.ts
|
||||||
|
- apps/web/src/lib/stores/nav-store.ts
|
||||||
|
- apps/web/src/components/modules/module-tile.tsx
|
||||||
|
- apps/web/src/messages/de.json
|
||||||
|
- apps/web/src/messages/en.json
|
||||||
|
- apps/web/src/messages/umlaut-dictionary.ts
|
||||||
|
# Task 2 — manager write paths, logo, check-all, hourly scheduler, sorting
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-logo-rules.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-logo-rules.spec.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.ts
|
||||||
|
- apps/api/src/module-registry/module-manage-handlers.spec.ts
|
||||||
|
- apps/api/src/prisma/rls-access-inventory.spec.ts
|
||||||
|
- apps/web/src/components/nextcloud-status/sort-clouds.ts
|
||||||
|
- apps/web/src/components/nextcloud-status/sort-clouds.test.ts
|
||||||
|
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.test.tsx
|
||||||
|
# Task 3 — dashboard widget, docs, changelog, full suites, rebuild
|
||||||
|
- packages/shared/src/index.ts
|
||||||
|
- apps/api/src/dashboard/widget-module-map.ts
|
||||||
|
- apps/api/src/dashboard/widget-module-map.spec.ts
|
||||||
|
- apps/web/src/components/dashboard/widget-registry.tsx
|
||||||
|
- apps/web/src/components/dashboard/widget-registry.test.tsx
|
||||||
|
- apps/web/src/components/dashboard/widget-catalog-modal.test.tsx
|
||||||
|
- apps/web/src/components/dashboard/widgets/widget-icon.tsx
|
||||||
|
- apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
|
||||||
|
- apps/web/src/components/dashboard/widgets/nextcloud-status-widget.tsx
|
||||||
|
- apps/web/src/components/dashboard/widgets/nextcloud-status-widget.test.tsx
|
||||||
|
- apps/web/src/app/(portal)/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/page.test.tsx
|
||||||
|
- CHANGELOG.md
|
||||||
|
- docs/anleitung-anwender.md
|
||||||
|
- docs/anleitung-administration.md
|
||||||
|
autonomous: true
|
||||||
|
requirements: [QUICK-261002-k67]
|
||||||
|
|
||||||
|
estimate:
|
||||||
|
tokens: 170000
|
||||||
|
raw_tokens: 170000
|
||||||
|
tasks: 3
|
||||||
|
confidence: low
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "A user with grant level Benutzen (USE) on nextcloud-status sees one tile per cloud: logo (or initials), Kundenname, URL link opening in a new tab, installed version, traffic-light color with a short plain-language reason, and the time of the last check; the page also names the newest overall Nextcloud version"
|
||||||
|
- "The traffic light follows the locked rules: GREEN = latest patch of its cycle and EOL more than 90 days away; YELLOW = patch update available in its cycle or EOL within 90 days; RED = EOL passed (or major older than every listed cycle), unreachable/invalid answer, maintenance mode, or needsDbUpgrade; without reference data the tile is grey with 'Bewertung nicht möglich' (red status conditions still red)"
|
||||||
|
- "Only administrators and users with Verwalten (MANAGE) can add/edit/delete clouds, upload/remove logos, and trigger 'Jetzt prüfen' or a per-tile check — API returns 403 for USE-level users and the controls are hidden for them"
|
||||||
|
- "Every cloud is checked automatically once per hour (also on a fresh database after the first start), each check fetches only <url>/status.php with a 10 s timeout, max 3 redirects and a size cap, and only parsed fields reach the browser"
|
||||||
|
- "Version reference data from endoflife.date is cached for 12 h; an outage keeps the last good data and never breaks the page"
|
||||||
|
- "The user can sort tiles by Kundenname, Status (red first), Version, or Support-Ende, and the choice survives a reload for the same user"
|
||||||
|
- "Users with module access can place a 'Nextcloud-Status' dashboard tile showing green/yellow/red counters and the red/yellow clouds with reason; clicking opens the module; users without access never see the tile in the catalog or on the dashboard"
|
||||||
|
artifacts:
|
||||||
|
- path: "apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql"
|
||||||
|
provides: "NextcloudInstance table with tenant_isolation_policy and system_read_policy"
|
||||||
|
contains: "NextcloudInstance"
|
||||||
|
- path: "apps/api/src/nextcloud-status/nextcloud-rating.ts"
|
||||||
|
provides: "pure rating function with injected date"
|
||||||
|
exports: ["rateNextcloud"]
|
||||||
|
- path: "apps/api/src/nextcloud-status/nextcloud-status-fetch.ts"
|
||||||
|
provides: "normalizeCloudUrl, parseNextcloudStatus, fetchNextcloudStatus (injectable fetch)"
|
||||||
|
exports: ["normalizeCloudUrl", "parseNextcloudStatus", "fetchNextcloudStatus"]
|
||||||
|
- path: "apps/api/src/nextcloud-status/nextcloud-release.service.ts"
|
||||||
|
provides: "endoflife.date cache (12 h, keep last good, backoff) + parseEndOfLife"
|
||||||
|
- path: "apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts"
|
||||||
|
provides: "hourly check job registered in onApplicationBootstrap"
|
||||||
|
- path: "apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx"
|
||||||
|
provides: "tile grid, sorting, manager controls"
|
||||||
|
- path: "apps/web/src/components/dashboard/widgets/nextcloud-status-widget.tsx"
|
||||||
|
provides: "dashboard overview tile"
|
||||||
|
key_links:
|
||||||
|
- from: "apps/api/src/nextcloud-status/nextcloud-status.service.ts"
|
||||||
|
to: "rateNextcloud + NextcloudReleaseService.getReference"
|
||||||
|
via: "listForTenant maps every row to a rating at read time"
|
||||||
|
pattern: "rateNextcloud\\("
|
||||||
|
- from: "apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts"
|
||||||
|
to: "NextcloudStatusService.loadAllInstancesForScheduler (forSystem) -> checkInstance (forTenant)"
|
||||||
|
via: "onApplicationBootstrap registers the cron job nextcloud-status-poll"
|
||||||
|
pattern: "onApplicationBootstrap"
|
||||||
|
- from: "packages/shared/src/index.ts WIDGET_MODULE_SLUGS"
|
||||||
|
to: "DashboardService fail-closed filter + web catalog visibleWidgetTypes"
|
||||||
|
via: "'nextcloud-status': 'nextcloud-status'"
|
||||||
|
pattern: "'nextcloud-status': 'nextcloud-status'"
|
||||||
|
- from: "apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx"
|
||||||
|
to: "useCanManageModule('nextcloud-status')"
|
||||||
|
via: "manager-only controls"
|
||||||
|
pattern: "useCanManageModule\\('nextcloud-status'\\)"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
New Tessera module "Nextcloud-Status" (slug `nextcloud-status`, category `infrastructure`, next to Proxmox). Managers register customer Nextcloud instances (URL + Kundenname + optional logo); Tessera checks each instance's public `status.php` every hour and on demand, compares the installed version with the endoflife.date reference data and shows a traffic-light tile per cloud plus a dashboard overview tile.
|
||||||
|
|
||||||
|
Locked decisions from the request (cited below as L-xx):
|
||||||
|
- L-01 Slug `nextcloud-status`, category `infrastructure`; follow the Proxmox module end to end (seed, NestJS module, ModuleGuard, Prisma model with RLS + hand-written migration, web route with ModuleAccessGate, sidebar/marketplace/icon registrations, module-layouts test, dashboard widget registration).
|
||||||
|
- L-02 Managers add a cloud with URL and Kundenname; optional logo either uploaded (PostgreSQL bytea, magic-byte type check, 1 MiB limit, served via an authenticated route) or an https URL the browser loads directly — the API never fetches the logo URL.
|
||||||
|
- L-03 Status: server-side GET `<url>/status.php`, ~10 s timeout, no credentials, http and https, at most a few redirects, capped response size; last result stored on the record (versionstring, maintenance, reachable, error text, checkedAt). URL policy like Proxmox (manager-entered, internal hosts allowed) with the SSRF consideration documented in a code comment; only parsed JSON fields, never raw bodies, reach the client.
|
||||||
|
- L-04 Reference data from https://endoflife.date/api/nextcloud.json, fetched server-side, cached ~12 h, outage-tolerant (keep last good); never fetched → tiles show the version without rating (grey "Bewertung nicht möglich").
|
||||||
|
- L-05 Traffic light (locked): GREEN = latest patch of its major cycle AND cycle still supported; YELLOW = update available within its cycle OR cycle EOL within the next 90 days; RED = cycle EOL passed (or major not in the list and older than all listed), unreachable, maintenance on, or needsDbUpgrade. Also show the newest overall Nextcloud version. Rating is a pure function with thorough Vitest tests and an injected date.
|
||||||
|
- L-06 Tile: logo, Kundenname, URL (new tab), installed version, status color + short German reason ("Aktuell", "Update auf 34.0.4 verfügbar", "Support endet am 30.06.2027", "Support abgelaufen seit …", "Nicht erreichbar", "Wartungsmodus"), last check time.
|
||||||
|
- L-07 Sorting selectable: Kundenname (A–Z), Status (rot zuerst), Version, Support-Ende; remembered per user in localStorage (try/catch).
|
||||||
|
- L-08 Polling hourly via scheduler (Proxmox/Tender pattern incl. the onApplicationBootstrap lesson), plus "Jetzt prüfen" (whole list) and per-tile refresh; background checks per tenant in system context like the other background jobs.
|
||||||
|
- L-09 Rights (locked): USE sees tiles; MANAGE (`@ModuleManage`) or admin adds/edits/deletes clouds, uploads logos, triggers checks. Web uses `useCanManageModule`.
|
||||||
|
- L-10 Dashboard widget (locked): counters (grün/gelb/rot) and the red/yellow clouds (name + reason); click opens the module; registered and sized like the Proxmox widget.
|
||||||
|
- L-11 UI texts German (formal "Sie") and English; UI texts never name the tenant concept; dark/light via existing tokens.
|
||||||
|
- L-12 Tests: api Vitest for rating, status.php parser (valid, maintenance, garbage, timeout), eol cache, service CRUD, controller guard metadata (static routes before `:id`); web tests for page (tiles, sorting, manager-only controls) and widget; full api + web suites, tsc, biome on touched files.
|
||||||
|
- L-13 CHANGELOG (Unveröffentlicht, user-facing German) + user/admin docs in `docs/`.
|
||||||
|
- L-14 Local migration via container IP, rebuild `docker compose up -d --build api web`; do NOT push; browser check is the orchestrator's job.
|
||||||
|
|
||||||
|
Claude's discretion (decided here, apply as written):
|
||||||
|
- D-A Logo bytes live in bytea columns on the instance row as L-02 says (note: dashboard images moved to the file area in 260922-hk4; for a handful of logos ≤ 1 MiB the DB is fine and needs no file cleanup). Every list/scheduler query uses an explicit `select` without the bytes. Accepted types PNG/JPEG/GIF/WebP via the existing `detectImageMime` — no SVG (script risk). Upload and logo URL are mutually exclusive: uploading clears `logoUrl`; saving a non-empty `logoUrl` clears the upload.
|
||||||
|
- D-B The rating is computed in the API at read time (`GET instances`), so page and widget show the same result; the API returns reason codes plus parameters, the web translates them (German + English).
|
||||||
|
- D-C One global hourly cron job `nextcloud-status-poll` (`0 * * * *`), registered unconditionally in `onApplicationBootstrap` without reading the database at registration time — a fresh database cannot end up without the job (Tender lesson). Each tick reads `(id, tenantId)` of all instances in system context (the single `forSystem` call), then checks each instance tenant-bound with concurrency 4 and an overlap guard.
|
||||||
|
- D-D Reference cache in memory (no table): TTL 12 h; stale data is returned immediately and refreshed in the background; only an empty cache is awaited; after a failure no new attempt for 15 min; concurrent refreshes share one request; bootstrap warms it up without blocking.
|
||||||
|
- D-E A major newer than every listed cycle (fresh release not yet on endoflife.date) rates GREEN "Aktuell"; a major inside the listed range but missing from it rates grey.
|
||||||
|
- D-F An answer that is not a valid Nextcloud status JSON (or `installed` not true) is RED with its own reason "Keine gültige Nextcloud-Antwort" (variant of "unreachable").
|
||||||
|
- D-G Status sort order red, yellow, grey, green (then name); version sort oldest first, unknown last; Support-Ende earliest first, unknown last; ties by name.
|
||||||
|
- D-H TLS certificates of the clouds are verified (no opt-out); a certificate error shows as "Nicht erreichbar" with the error code as detail.
|
||||||
|
|
||||||
|
Output: migration + model, API module (pure functions, release cache, service, controller, scheduler, seed), module page with tiles/sorting/manager form, dashboard widget, tests, docs, changelog, rebuilt local stack. Three atomic commits on main, NOT pushed.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@~/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/STATE.md
|
||||||
|
@./CLAUDE.md
|
||||||
|
|
||||||
|
Discovered facts the executor can rely on (verified during planning):
|
||||||
|
- Proxmox touchpoints (grep `proxmox` across apps/ and packages/) are the template: `apps/api/src/proxmox/{proxmox.module.ts, proxmox.seed.ts, proxmox.controller.ts, proxmox.service.ts, proxmox-scheduler.service.ts}`, `apps/api/src/app.module.ts`, migration `20260923140000_proxmox_server`, web `apps/web/src/app/(portal)/modules/proxmox/{layout.tsx,page.tsx}`, `apps/web/src/lib/{proxmox-api.ts,module-loader.ts,module-identity.ts,stores/nav-store.ts}`, `apps/web/src/components/modules/module-tile.tsx` (ICONS map keyed by `ModuleIconId`), widget files under `apps/web/src/components/dashboard/`.
|
||||||
|
- `PrismaService` is injected without importing a Prisma module (global); `forTenant`/`forSystem` come from `apps/api/src/prisma/prisma-tenant.extension.ts`. `ModuleRegistryModule` must be imported for `ModuleRegistryService` (seed) and `ModuleGuard`. `ScheduleModule` is already global in app.module.ts.
|
||||||
|
- `@UseModule(slug)` (class) and `@ModuleManage(slug)` (handler) live in `apps/api/src/module-registry/module.guard.ts` (quick 261002-icv). Never put a role decorator on a manage handler — the global RolesGuard would block managers. `GET /modules/active` already carries `canManage`; `apps/web/src/lib/use-module-capability.ts` exports `useCanManageModule`.
|
||||||
|
- Cron: `ProxmoxSchedulerService` resolves `CronJob` via `require('cron').CronJob` (pnpm strict isolation) and registers with `SchedulerRegistry.addCronJob` — reuse that workaround verbatim.
|
||||||
|
- HTTP: `apps/api/src/favorites/icon-discovery.service.ts` uses `fetch as undiciFetch` from `undici` (dependency 7.28.0) with `redirect: 'manual'`, AbortController timeouts and a capped body reader (`readTextCapped`) — same approach here, but WITHOUT the private-IP filter (L-03: internal hosts allowed).
|
||||||
|
- Magic bytes: `detectImageMime(buffer)` in `apps/api/src/dashboard/dashboard-image-rules.ts` (PNG/JPEG/GIF/WebP). Serving pattern for uploaded images: `apps/api/src/favorites/favorites.controller.ts` `getIcon` (Content-Type from detected mime, `Cache-Control: private, max-age=86400`, `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; sandbox`) and `FileInterceptor(field, { limits: { fileSize, files: 1 } })`. The web loads authenticated images through the same-origin proxy `/api-proxy/<api path>` with a `?v=<version>` cache buster (favorites-widget.tsx).
|
||||||
|
- RLS gates: `rls-coverage.spec.ts` needs the policies in the migration; `rls-access-inventory.spec.ts` compares every (file, model) Prisma access against the Fundstellentabelle in `docs/mandantentrennung-zugriffsklassifikation.md` (plus Bereichszeile, Summenzeile, Paarzählung — follow the quick-261002-fm5 and proxmox rows) and allows `forSystem(` only at the call sites listed in `FORSYSTEM_ALLOWED_CALL_SITES`. A file with tenant-bound AND one system read on the same model has Stand `system-gebunden` (precedent `proxmox.service.ts`/`proxmoxServer`). Never use `include:` or relation `select:` in this module.
|
||||||
|
- Status colors: tokens `bg-status-ok|warn|down|idle` and `text-status-*-fg`, pill form `bg-status-ok/12 text-status-ok-fg` (see `apps/web/src/components/proxmox/status-styles.ts`); class strings must be literal (Tailwind scanning).
|
||||||
|
- Module routes in the web: `/modules/nextcloud-status` (own layout with `ModuleAccessGate`) AND the sidebar route `/modules/infrastructure/nextcloud-status` (generic `[category]/[moduleSlug]` page → `module-loader.ts`).
|
||||||
|
- i18n: widget catalog names live under `widgets.<key>.name/description` in de.json/en.json; module texts get a new top-level namespace `nextcloudStatus`. `apps/web/src/messages/umlaut-guard.spec.ts` rejects ae/oe/ue/ss tokens in de.json that are not in `UMLAUT_ALLOWLIST` (e.g. "aktuell", "Neueste" may need allowlisting — run the test).
|
||||||
|
- Widget tests enumerate all widget types (currently eleven): `widget-registry.test.tsx`, `widget-catalog-modal.test.tsx`, `apps/api/src/dashboard/widget-module-map.spec.ts`; `(portal)/page.test.tsx` mocks each widget module.
|
||||||
|
- endoflife.date sample (2026-10-02): `[{"cycle":"35","releaseDate":"2026-09-16","eol":"2027-09-30","latest":"35.0.1",...},{"cycle":"34","eol":"2027-06-30","latest":"34.0.4"},{"cycle":"33","eol":"2027-02-28","latest":"33.0.9"},{"cycle":"32","eol":"2026-09-30","latest":"32.0.15"},...]`; `eol` may also be a boolean. Nextcloud `status.php` returns `installed, maintenance, needsDbUpgrade, version ("31.0.5.1"), versionstring ("31.0.5"), edition, productname, extendedSupport`.
|
||||||
|
- Migration convention: hand-written SQL with German header comment (model: `20260923140000_proxmox_server`); latest existing migration is `20261002140000_module_grant_level`. Local DB: no host port — IP via `docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1`, then `DATABASE_URL="postgresql://tessera:tessera_dev@<IP>:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy`.
|
||||||
|
- Commits: German subject, conventional prefix, end with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`. Never push. PLAN/SUMMARY/STATE are committed by the orchestrator, not by the executor.
|
||||||
|
|
||||||
|
@apps/api/src/proxmox/proxmox.controller.ts
|
||||||
|
@apps/api/src/proxmox/proxmox-scheduler.service.ts
|
||||||
|
@apps/api/src/proxmox/proxmox.seed.ts
|
||||||
|
@apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql
|
||||||
|
@apps/web/src/app/(portal)/modules/proxmox/page.tsx
|
||||||
|
@apps/web/src/components/dashboard/widgets/proxmox-widget.tsx
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="tracer">
|
||||||
|
<name>Task 1: Tracer — a registered cloud is checked, rated and shown as a tile (DB → status.php → endoflife reference → rating → GET instances → module page)</name>
|
||||||
|
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql, apps/api/src/nextcloud-status/nextcloud-rating.ts, apps/api/src/nextcloud-status/nextcloud-rating.spec.ts, apps/api/src/nextcloud-status/nextcloud-status-fetch.ts, apps/api/src/nextcloud-status/nextcloud-status-fetch.spec.ts, apps/api/src/nextcloud-status/nextcloud-release.service.ts, apps/api/src/nextcloud-status/nextcloud-release.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.service.ts, apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts, apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts, apps/api/src/nextcloud-status/nextcloud-status.seed.ts, apps/api/src/nextcloud-status/nextcloud-status.module.ts, apps/api/src/app.module.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/nextcloud-status-api.ts, apps/web/src/components/nextcloud-status/rating-display.ts, apps/web/src/app/(portal)/modules/nextcloud-status/layout.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx, apps/web/src/app/(portal)/modules/module-layouts.test.tsx, apps/web/src/lib/module-loader.ts, apps/web/src/lib/module-identity.ts, apps/web/src/lib/stores/nav-store.ts, apps/web/src/components/modules/module-tile.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts</files>
|
||||||
|
<behavior>
|
||||||
|
- rateNextcloud (reference = 35/2027-09-30/35.0.1, 34/2027-06-30/34.0.4, 33/2027-02-28/33.0.9, 32/2026-09-30/32.0.15, 31/2026-02-28/31.0.14; now = 2026-10-02 unless stated): 35.0.1 → green/current; 35.0.2 (newer than listed latest) → green/current; 34.0.3 → yellow/update-available, updateTo 34.0.4; 32.0.15 → red/eol-passed with eolDate 2026-09-30; 32.0.15 at now 2026-08-01 → yellow/eol-soon; 33.0.9 at 2026-12-15 → yellow/eol-soon eolDate 2027-02-28; boundary: EOL exactly 90 days ahead → yellow, 91 days → green; EOL day itself → yellow, the day after → red; 33.0.5 at 2026-12-15 → yellow, reason eol-soon AND updateTo 33.0.9; major 20 (older than all listed) → red/eol-passed without date; major 36 (newer than all) → green/current; cycle with eol false → supported, green when latest; eol true → red/eol-passed without date; reachable false → red/unreachable even with reference null; errorKind not-nextcloud → red/invalid-response; maintenance true → red/maintenance; needsDbUpgrade true → red/needs-db-upgrade; red priority unreachable > invalid-response > maintenance > needs-db-upgrade > eol-passed; reference null + reachable → unknown/no-reference; reachable but no parseable version → unknown/version-unknown; never checked → unknown/not-checked; newestVersion(reference) = latest of the highest cycle ('35.0.1').
|
||||||
|
- normalizeCloudUrl: ' https://cloud.kunde.de/ ' → 'https://cloud.kunde.de'; '.../status.php' and '.../index.php' suffix stripped; subpath 'https://host/nextcloud/' → 'https://host/nextcloud'; query/hash dropped; 'http://intern:8080' kept; ftp:, javascript:, URL with user:pass@, empty, longer than 2048 → null.
|
||||||
|
- parseNextcloudStatus: valid JSON → fields; maintenance true kept; needsDbUpgrade missing → false; versionstring missing → first three parts of version; HTML, empty, array, installed false, non-object → null; overlong edition/productname cut to 64 chars.
|
||||||
|
- fetchNextcloudStatus (injected fetch): 200 valid → reachable true + fields; 200 maintenance → reachable true, maintenance true; 200 garbage → reachable false, errorKind not-nextcloud; 503 → errorKind http-status, errorDetail 'HTTP 503'; never-resolving fetch → errorKind timeout after the injected timeout (fake timers or a 20 ms timeout); rejected fetch with cause.code ENOTFOUND → errorKind network, detail 'ENOTFOUND'; cert error code (e.g. CERT_HAS_EXPIRED) → errorKind tls; 301 with relative Location → followed once and succeeds; redirect to ftp: → errorKind redirect; 4 consecutive redirects → errorKind redirect; body over 64 KiB → errorKind too-large; request carries no Authorization/Cookie header and targets exactly '<base>/status.php'.
|
||||||
|
- NextcloudReleaseService (injected fetch + clock): first getReference fetches and parses; second call within 12 h does not fetch; after 12 h returns the stale data immediately and refreshes in the background; failure with existing data keeps it; failure without data → null; no new attempt within 15 min after a failure; garbage/empty array keeps last good; parallel calls share one request; parseEndOfLife skips entries without numeric cycle or valid latest.
|
||||||
|
- NextcloudStatusService (mocked prisma via forTenant, mocked fetch + release): createInstance normalizes the URL (invalid → BadRequestException with German message), creates the row and runs the first check; checkInstance writes reachable/maintenance/needsDbUpgrade/versionString/edition/errorKind/errorDetail/lastCheckedAt; checkInstance for an unknown id → NotFoundException; listForTenant selects no logo bytes, returns { instances (with rating), reference: { newestVersion, fetchedAt } }.
|
||||||
|
- Controller metadata: class has MODULE_SLUG_KEY 'nextcloud-status' + ModuleGuard; `list` has no MODULE_MANAGE_KEY; `create` and `checkOne` have MODULE_MANAGE_KEY true and no ROLES_KEY.
|
||||||
|
- Web page test: with a mocked list (one green, one yellow with updateTo, one red unreachable, one grey) it renders four tiles with Kundenname, version, the translated reasons ('Aktuell', 'Update auf 34.0.4 verfügbar', 'Nicht erreichbar', 'Bewertung nicht möglich'), the URL as link with target _blank and rel noopener noreferrer, and the line with the newest Nextcloud version; an empty list shows the empty state.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**Schema + migration (L-01, L-02, L-03, D-A).** In `apps/api/prisma/schema.prisma` add `model NextcloudInstance` after `ProxmoxServerStatus` with a German comment (quick-261002-k67; status lives on the row, L-03; logo bytes per D-A; no relation to Tenant, pattern ProxmoxServer): `id String @id @default(uuid())`, `tenantId String`, `customerName String`, `baseUrl String`, `logoUrl String?`, `logoData Bytes?`, `logoMime String?`, `logoVersion Int @default(0)`, `lastCheckedAt DateTime?`, `reachable Boolean?` (null = never checked), `maintenance Boolean?`, `needsDbUpgrade Boolean?`, `versionString String?`, `edition String?`, `productName String?`, `errorKind String?`, `errorDetail String?`, `createdAt`, `updatedAt @updatedAt`, `@@index([tenantId])`. Hand-write `apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql` in the style of 20260923140000_proxmox_server: German header (purpose; tenant_isolation_policy WITHOUT user dimension because clouds are shared data of the organisation; `system_read_policy ... FOR SELECT USING (is_system_context())` because the hourly job of Task 2 reads id/tenantId of all rows; rights via ALTER DEFAULT PRIVILEGES; switch-is-off note), CREATE TABLE with `"logoData" BYTEA`, index, ENABLE + FORCE ROW LEVEL SECURITY, both policies. Run `pnpm --filter @tessera/api exec prisma generate`; apply locally (L-14) with the container-IP command from the context and confirm `prisma migrate status` is up to date and `prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --exit-code` exits 0.
|
||||||
|
|
||||||
|
**Pure functions (L-03, L-05, D-E, D-F).** `nextcloud-rating.ts` (no Nest, no Prisma): types `NextcloudCycle { cycle: number; eol: string | boolean; latest: string }`, `NextcloudReference { cycles: NextcloudCycle[]; fetchedAt: string }`, `RatingLevel = 'green'|'yellow'|'red'|'unknown'`, `RatingReason = 'current'|'update-available'|'eol-soon'|'eol-passed'|'unreachable'|'invalid-response'|'maintenance'|'needs-db-upgrade'|'no-reference'|'version-unknown'|'not-checked'`, `NextcloudRating { level; reason; updateTo: string|null; eolDate: string|null; cycle: number|null }`. Export `rateNextcloud(status, reference, now: Date)` and `newestVersion(reference)`. Compare dates as UTC calendar days ('YYYY-MM-DD' of `now`), EOL-soon window `EOL_WARNING_DAYS = 90` inclusive, version compare numerically on three parts parsed with an anchored regex from versionString (fallback: first three parts of `version`). Implement exactly the cases in `<behavior>`; German JSDoc explaining each rule and citing L-05. `nextcloud-status-fetch.ts`: `normalizeCloudUrl(raw)`, `parseNextcloudStatus(text)` (JSON.parse in try, whitelist fields, version strings limited to digits/dots and 32 chars), and `fetchNextcloudStatus(baseUrl, opts?: { fetchImpl?, timeoutMs? })` → `{ reachable, maintenance, needsDbUpgrade, versionString, edition, productName, errorKind, errorDetail }`. Use `undiciFetch` by default, `redirect: 'manual'`, at most `MAX_REDIRECTS = 3` hops (each Location resolved against the current URL, only http/https), one AbortController for the whole request with `STATUS_TIMEOUT_MS = 10_000`, headers only `Accept: application/json` and a `User-Agent: Tessera-Nextcloud-Status`, body read through a capped reader with `MAX_STATUS_BYTES = 64 * 1024`, discard bodies of redirect/error responses. errorKind values: timeout, network, tls, http-status, not-nextcloud, too-large, redirect; errorDetail is only our own short code ('HTTP 503', the `cause.code` such as ENOTFOUND/ECONNREFUSED/CERT_HAS_EXPIRED, max 120 chars) — never a response body or exception message. Put a German SSRF comment block above the function (L-03): manager-entered URLs incl. internal hosts are allowed on purpose like Proxmox (T-DHH-02); limits applied (only `/status.php`, GET, no credentials, 3 redirects, 10 s, 64 KiB, only whitelisted fields leave the server); a person with Verwalten could thereby learn whether an internal address answers — accepted.
|
||||||
|
|
||||||
|
**Reference cache (L-04, D-D).** `nextcloud-release.service.ts`: `@Injectable() NextcloudReleaseService` without constructor parameters; test seams are two instance fields `fetchImpl` (default `undiciFetch`) and `now` (default `() => Date.now()`) that specs overwrite on the instance. Export pure `parseEndOfLife(json): NextcloudCycle[]` (cycle must be /^\d+$/, latest a dotted version, eol a 'YYYY-MM-DD' string or boolean; invalid entries skipped). `getReference(): Promise<NextcloudReference | null>` and `refresh(): Promise<void>` per D-D: source URL constant `ENDOFLIFE_URL = 'https://endoflife.date/api/nextcloud.json'`, `CACHE_TTL_MS = 12 h`, `RETRY_BACKOFF_MS = 15 min`, 10 s timeout, 512 KiB cap, empty parse result counts as failure, failures logged once per attempt with Logger.warn.
|
||||||
|
|
||||||
|
**Service + DTO + controller + seed + module (L-01, L-09).** `dto/nextcloud-instance.dto.ts`: `CreateNextcloudInstanceDto { customerName (IsString, IsNotEmpty, MaxLength 120); baseUrl (IsString, IsNotEmpty, MaxLength 2048); logoUrl? (IsOptional, ValidateIf non-empty, IsUrl https only with require_protocol, MaxLength 2048) }` and `UpdateNextcloudInstanceDto` with all three optional (same validators; empty logoUrl = remove). `nextcloud-status.service.ts`: inject PrismaService and NextcloudReleaseService; a module-level `PUBLIC_SELECT` constant without `logoData`; every method uses its own `const tenantPrisma = forTenant(this.prisma, tenantId)`; `listForTenant(tenantId)` (findMany ordered by customerName, maps to a view `{ id, customerName, baseUrl, logoUrl, hasUploadedLogo, logoVersion, status: { checkedAt, reachable, maintenance, needsDbUpgrade, versionString, edition, errorKind, errorDetail }, rating }` using `rateNextcloud(..., reference, new Date())`, returns `{ instances, reference: { newestVersion, fetchedAt } }`); `createInstance(tenantId, dto)` (normalizeCloudUrl → BadRequestException 'Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.'; create; then `checkInstance`); `checkInstance(tenantId, id)` (findFirst by id+tenantId → NotFoundException; fetchNextcloudStatus; update status columns + lastCheckedAt; return the view). `nextcloud-status.controller.ts`: `@Controller('modules/nextcloud-status')`, class `@UseModule('nextcloud-status')`, `requireTenantId` as in ProxmoxController; `@Get('instances') list`; `@Post('instances') @ModuleManage('nextcloud-status') create`; `@Post('instances/:id/check') @ModuleManage('nextcloud-status') checkOne`. Header comment: rights per L-09, route order rule (static routes before `:id`, see Task 2). `nextcloud-status.seed.ts` like proxmox.seed.ts: slug 'nextcloud-status', name 'Nextcloud-Status', version '1.0.0', category 'infrastructure', description de 'Versionen und Erreichbarkeit Ihrer Nextcloud-Clouds im Blick' / en 'Keep track of versions and availability of your Nextcloud clouds', isSystem true. `nextcloud-status.module.ts` like ProxmoxModule (imports ModuleRegistryModule, providers NextcloudStatusService + NextcloudReleaseService, OnModuleInit seed with log line 'Nextcloud-Status module seeded in registry'). Register `NextcloudStatusModule` in `app.module.ts` right after ProxmoxModule. Specs per `<behavior>` (controller spec pattern: Reflect.getMetadata on prototype methods, like module-manage-handlers.spec.ts).
|
||||||
|
|
||||||
|
**RLS inventory doc.** Run `pnpm --filter @tessera/api exec vitest run rls-coverage rls-access-inventory`; add the Bereichszeile `nextcloud-status`, update the Summenzeile and Paarzählung, and add the Fundstellentabelle row(s) for `apps/api/src/nextcloud-status/nextcloud-status.service.ts` / `nextcloudInstance` (Stand `gebunden` for now; Task 2 turns it into `system-gebunden`) in `docs/mandantentrennung-zugriffsklassifikation.md`, counted with the Gate-Schleife exactly as the fm5 rows describe; both specs green.
|
||||||
|
|
||||||
|
**Web tracer (L-06, L-11).** `apps/web/src/lib/nextcloud-status-api.ts` (pattern proxmox-api.ts, `credentials: 'include'`): exported types mirroring the API view and `listInstances()`; plus `logoSrc(instance)` returning `/api-proxy/modules/nextcloud-status/instances/<id>/logo?v=<logoVersion>` when `hasUploadedLogo`, else `logoUrl`, else null. `apps/web/src/components/nextcloud-status/rating-display.ts`: literal class map `RATING_STYLE` per level (green→status-ok, yellow→status-warn, red→status-down, unknown→status-idle; fill + pill + text), `ratingReasonText(t, rating, locale)` mapping reason → key under `nextcloudStatus.reason.*` with params (version, date formatted with Intl.DateTimeFormat for the locale in UTC, e.g. 30.06.2027), shared later by the widget. `layout.tsx` = ModuleAccessGate moduleSlug "nextcloud-status" (copy proxmox/layout.tsx). `page.tsx` ('use client'): PageHeader with title, a muted line "Neueste Nextcloud-Version: {version}" (or "Versionsdaten derzeit nicht verfügbar"), loading skeleton, empty state, responsive tile grid (`grid gap-4 sm:grid-cols-2 xl:grid-cols-3`) of `CloudTile`. `CloudTile.tsx`: colored left strip + status pill with reason, logo (img with `referrerPolicy="no-referrer"`, alt = Kundenname, fallback initials on null or onError), Kundenname, URL link (`target="_blank" rel="noopener noreferrer"`), installed version (or "—"), last check as relative time ("vor 12 Min.", "noch nie"), errorDetail in small muted text only when red/unreachable. Use existing tokens (bg-card, text-muted-foreground, dark: variants as in proxmox ServerCard) — no new colors. Registrations: module-loader.ts entry 'nextcloud-status' (dynamic import of the page, ssr false); module-identity.ts new ModuleIconId 'cloud' mapped from 'nextcloud-status'; module-tile.tsx ICONS 'cloud' (lucide cloud path `M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z`); nav-store.ts MODULE_TITLE_KEYS 'nextcloud-status' → 'nextcloudStatus.title'; module-layouts.test.tsx add ['nextcloud-status', NextcloudStatusLayout] to the it.each. Messages: new top-level `nextcloudStatus` namespace in de.json (formal Sie, real umlauts) and en.json with identical keys: title, newestVersion, referenceUnavailable, empty, lastCheck/never, version labels, and `reason.{current: 'Aktuell', updateAvailable: 'Update auf {version} verfügbar', eolSoon: 'Support endet am {date}', eolPassed: 'Support abgelaufen seit {date}', eolPassedNoDate: 'Support abgelaufen', unreachable: 'Nicht erreichbar', invalidResponse: 'Keine gültige Nextcloud-Antwort', maintenance: 'Wartungsmodus', needsDbUpgrade: 'Datenbank-Aktualisierung ausstehend', noReference: 'Bewertung nicht möglich', versionUnknown: 'Version unbekannt', notChecked: 'Noch nicht geprüft'}` plus English equivalents ('Up to date', 'Update to {version} available', 'Support ends on {date}', …). UI texts never name the tenant concept (L-11). Run the umlaut guard and add legitimately correct tokens to `UMLAUT_ALLOWLIST` only if it fails. Page test per `<behavior>` (mock `@/lib/nextcloud-status-api` and next-intl like the proxmox page tests).
|
||||||
|
|
||||||
|
Biome-lint the touched files (`pnpm exec biome lint <files>` from repo root, `biome check --write` on new files only), commit `feat(nextcloud-status): Modul mit Statusabruf, Versionsbewertung und Kachelansicht` (attribution line). Do not push.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api exec vitest run src/nextcloud-status rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run nextcloud-status module-layouts umlaut && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit</automated>
|
||||||
|
</verify>
|
||||||
|
<done>NextcloudInstance migration applied locally without drift; rating, parser, fetch, release cache, service and controller specs green; GET /modules/nextcloud-status/instances returns rated instances plus newest version; the module page renders the tiles with translated reasons; module registered in loader, identity, tile icon, nav titles and layouts test; RLS gates green; commit on main, not pushed.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Managers maintain clouds (edit, delete, logo), trigger checks, hourly job runs; everyone can sort the tiles</name>
|
||||||
|
<files>apps/api/src/nextcloud-status/nextcloud-logo-rules.ts, apps/api/src/nextcloud-status/nextcloud-logo-rules.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.service.ts, apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts, apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts, apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.module.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, apps/api/src/prisma/rls-access-inventory.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/nextcloud-status-api.ts, apps/web/src/components/nextcloud-status/sort-clouds.ts, apps/web/src/components/nextcloud-status/sort-clouds.test.ts, apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.test.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts</files>
|
||||||
|
<behavior>
|
||||||
|
- checkLogoUpload (nextcloud-logo-rules): PNG/JPEG/GIF/WebP bytes ≤ 1 MiB → detected mime; SVG text, PDF, empty, renamed HTML → rejected; 1 MiB + 1 byte → rejected (second net behind multer).
|
||||||
|
- Service: updateInstance changes name/URL (new URL normalized and re-checked; unchanged URL not re-checked); non-empty logoUrl clears logoData/logoMime and bumps logoVersion; empty logoUrl sets null; uploadLogo stores bytes + detected mime, clears logoUrl, bumps logoVersion; removeLogo clears bytes/mime and bumps logoVersion; getLogo returns { data, mime } or NotFoundException when none; deleteInstance removes the row; every write/read of a foreign id → NotFoundException (where id + tenantId); checkAllForTenant checks all instances of the tenant with at most 4 in parallel and returns the refreshed list; loadAllInstancesForScheduler selects only id and tenantId through forSystem.
|
||||||
|
- Scheduler: onApplicationBootstrap registers exactly one cron job 'nextcloud-status-poll' with '0 * * * *' without any database read, starts it, and fires one non-blocking release refresh; a thrown error during bootstrap is logged and does not propagate; tick groups instances by tenant and calls checkInstance(tenantId, id) for each; one failing instance does not stop the others; a tick started while the previous one runs is skipped.
|
||||||
|
- Controller metadata + order: list and logo (GET) have no MODULE_MANAGE_KEY; create, update, remove, checkAll, checkOne, uploadLogo, removeLogo have MODULE_MANAGE_KEY true and no ROLES_KEY; the static handler for POST 'instances/check' is declared before every handler whose path contains ':id' (index check on Object.getOwnPropertyNames of the prototype).
|
||||||
|
- sortClouds: 'name' → A–Z with German collation (Ä next to A, case-insensitive); 'status' → red, yellow, unknown, green, ties by name; 'version' → oldest first, missing version last; 'eol' → earliest eolDate first, missing last; input array not mutated. readSortPreference/writeSortPreference: key 'tessera:nextcloud-status:sort:<userId>', unknown stored value → 'name', throwing localStorage → default and no throw.
|
||||||
|
- Page: USE user (useCanManageModule false) sees tiles and the sort selector but no "Cloud hinzufügen", "Jetzt prüfen", per-tile refresh/edit buttons; manager (true) sees all of them; choosing 'Status' reorders tiles red first and writes the preference; a stored preference is applied on load; "Jetzt prüfen" calls checkAll and replaces the list.
|
||||||
|
- CloudForm: required Kundenname and URL; logo choice "Keins / Bild hochladen / Bildadresse"; https-only hint on logo URL; submit calls create (or update) and, when a file is chosen, uploadLogo afterwards; delete asks for confirmation before calling remove; API error message is shown in the form.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**API write paths (L-02, L-09, D-A).** `nextcloud-logo-rules.ts`: `NEXTCLOUD_LOGO_MAX_BYTES = 1024 * 1024`, `checkLogoUpload(buffer)` → mime or null, built on `detectImageMime` from `../dashboard/dashboard-image-rules` (German comment: no SVG because inline script; magic bytes instead of client mimetype, pattern T-PI9-01). Extend `nextcloud-status.service.ts` with `updateInstance`, `deleteInstance`, `uploadLogo(tenantId, id, file)` (BadRequestException 'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.'), `getLogo`, `removeLogo`, `checkAllForTenant` (small promise pool of 4, each instance isolated with try/catch + Logger.warn), `listInstanceIdsForTenant`, and `loadAllInstancesForScheduler()` — the single `const systemPrisma = forSystem(this.prisma);` access, `select: { id: true, tenantId: true }`, German comment naming FORSYSTEM_ALLOWED_CALL_SITES and the system_read_policy of migration 20261002150000. All per-row writes stay `forTenant` with `where: { id, tenantId }`.
|
||||||
|
|
||||||
|
**Controller routes (L-08, L-09, L-12).** Final handler order in `nextcloud-status.controller.ts`: `@Get('instances') list`; `@Post('instances') create`; `@Post('instances/check') checkAll` (static, BEFORE any `:id` route); `@Put('instances/:id') update`; `@Delete('instances/:id') remove`; `@Post('instances/:id/check') checkOne`; `@Get('instances/:id/logo') logo` (USE level; sets Content-Type from the stored mime, `Cache-Control: private, max-age=86400`, `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; sandbox`, sends the bytes — pattern favorites getIcon); `@Post('instances/:id/logo')` with `FileInterceptor('logo', { limits: { fileSize: NEXTCLOUD_LOGO_MAX_BYTES, files: 1 } })` uploadLogo; `@Delete('instances/:id/logo') removeLogo`. Every write/check handler gets `@ModuleManage('nextcloud-status')` and no role decorator. Extend the controller spec with the metadata and declaration-order assertions, and add a `NextcloudStatusController` block to `apps/api/src/module-registry/module-manage-handlers.spec.ts` (manage handlers listed via it.each, list/logo stay USE level).
|
||||||
|
|
||||||
|
**Scheduler (L-08, D-C).** `nextcloud-status-scheduler.service.ts` implementing `OnApplicationBootstrap` (German header: why not OnModuleInit — Tender bootstrap lesson; why a single global job instead of per-tenant jobs — fixed hourly interval, no per-row setting, no DB read at registration). Reuse the `require('cron').CronJob` workaround and the SchedulerRegistry calls from ProxmoxSchedulerService; job name `nextcloud-status-poll`, cron `0 * * * *`; `running` flag as overlap guard; tick = `loadAllInstancesForScheduler()` → group by tenantId → all instances of the tick run through one pool of at most 4 concurrent `checkInstance(tenantId, id)` calls (each bound to its own tenantId), every error logged with tenant and id. Bootstrap also calls `void this.release.refresh()` (caught). Register the scheduler in `nextcloud-status.module.ts` providers. Spec per `<behavior>` with mocked SchedulerRegistry (pattern proxmox-scheduler.service.spec.ts).
|
||||||
|
|
||||||
|
**RLS gates.** Add `['apps/api/src/nextcloud-status/nextcloud-status.service.ts', 1]` to `FORSYSTEM_ALLOWED_CALL_SITES` in `rls-access-inventory.spec.ts`, extending its history comment (quick-261002-k67: hourly job reads id/tenantId of all instances, writes per row tenant-bound; new totals). Update `docs/mandantentrennung-zugriffsklassifikation.md`: Bereichszeile counts (bound/system), Summenzeile, Fundstellentabelle row for `nextcloud-status.service.ts`/`nextcloudInstance` now `system-gebunden` with the explanation (precedent proxmox row); recount with the Gate-Schleife, never copy numbers.
|
||||||
|
|
||||||
|
**Web (L-02, L-07, L-09, D-G).** `nextcloud-status-api.ts`: add `createInstance`, `updateInstance`, `deleteInstance`, `checkAll`, `checkOne`, `uploadLogo` (FormData field 'logo'), `removeLogo`; non-ok responses throw an Error carrying the API `message` (pattern proxmox-api.ts). `sort-clouds.ts`: `SortKey = 'name'|'status'|'version'|'eol'`, `sortClouds(items, key)` per D-G (pure, returns a new array, `localeCompare(…, 'de', { sensitivity: 'base' })`), `readSortPreference(userId)` / `writeSortPreference(userId, key)` with key `tessera:nextcloud-status:sort:<userId>`, everything in try/catch, unknown values fall back to 'name'. Page: sort `<select>` with the four options (label "Sortieren nach"), applied via useMemo; user id from `useAuthStore`; `const canManage = useCanManageModule('nextcloud-status') === true` gates "Cloud hinzufügen", "Jetzt prüfen" (spinner while running, then replace list with the response) and, on each tile, refresh (checkOne, replaces that tile) and edit (opens CloudForm). `CloudForm.tsx`: dialog/panel (follow the proxmox ServerForm look) for add/edit with Kundenname, URL (placeholder https://cloud.example.com), logo choice radio "Kein Logo / Bild hochladen / Bildadresse (https)", file input accept="image/png,image/jpeg,image/gif,image/webp", client-side size hint 1 MB, preview of the current logo, "Logo entfernen", delete button with confirmation dialog ("Möchten Sie die Cloud „{name}“ wirklich entfernen?"), saving state "Wird geprüft …" (create awaits the first check), API errors shown inline. All new texts in `nextcloudStatus.*` de + en, formal Sie, real umlauts, no tenant wording; umlaut guard green. Tests per `<behavior>`: `sort-clouds.test.ts`, `CloudForm.test.tsx`, and new manager/USE/sorting cases in `nextcloud-status-page.test.tsx` (mock `@/lib/use-module-capability`).
|
||||||
|
|
||||||
|
Biome-lint touched files, commit `feat(nextcloud-status): Clouds verwalten, Logos, stündliche Prüfung und Sortierung` (attribution line). Do not push.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api exec vitest run src/nextcloud-status src/module-registry rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run nextcloud-status umlaut && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test -z "$(grep -nE '^\s*@Roles\(' apps/api/src/nextcloud-status/nextcloud-status.controller.ts)"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>All write, logo and check routes exist behind ModuleManage (metadata + route-order specs green); the hourly job is registered in onApplicationBootstrap without a DB read and checks every instance tenant-bound; forSystem allowlist and RLS doc updated; the page offers sorting for everyone (remembered per user) and add/edit/delete/logo/check controls only to managers; commit on main, not pushed.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 3: Dashboard tile "Nextcloud-Status", docs and changelog, full suites, local rebuild</name>
|
||||||
|
<files>packages/shared/src/index.ts, apps/api/src/dashboard/widget-module-map.ts, apps/api/src/dashboard/widget-module-map.spec.ts, apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/dashboard/widget-registry.test.tsx, apps/web/src/components/dashboard/widget-catalog-modal.test.tsx, apps/web/src/components/dashboard/widgets/widget-icon.tsx, apps/web/src/components/dashboard/widgets/widget-wrapper.tsx, apps/web/src/components/dashboard/widgets/nextcloud-status-widget.tsx, apps/web/src/components/dashboard/widgets/nextcloud-status-widget.test.tsx, apps/web/src/app/(portal)/page.tsx, apps/web/src/app/(portal)/page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md</files>
|
||||||
|
<behavior>
|
||||||
|
- WIDGET_TYPES ends with 'nextcloud-status' (twelve types, existing order unchanged); WIDGET_MODULE_SLUGS maps proxmox → proxmox and 'nextcloud-status' → 'nextcloud-status'; getModuleSlugForWidgetType('nextcloud-status') = 'nextcloud-status'; all other types stay platform tiles.
|
||||||
|
- Catalog: without module access neither Proxmox nor Nextcloud-Status appears; with access to 'nextcloud-status' only that module tile is added.
|
||||||
|
- Widget: list with 2 green, 1 yellow (updateTo 34.0.4), 1 red (maintenance) → counters 2/1/1, list shows the red cloud first, then the yellow one, each with name + translated reason; all green → a calm "Alle Clouds sind aktuell" line and no list; empty list → hint "Noch keine Cloud eingetragen"; view mode rows link to /modules/nextcloud-status; edit mode rows are not links; the widget never calls a check endpoint (only listInstances); unknown-rated clouds counted separately only when > 0.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**Widget registration (L-10).** `packages/shared/src/index.ts`: append `'nextcloud-status'` to WIDGET_TYPES and add `'nextcloud-status': 'nextcloud-status'` to WIDGET_MODULE_SLUGS (extend the comment: second module tile, quick-261002-k67; slug equals the seed and the class-level UseModule). Update the comment in `apps/api/src/dashboard/widget-module-map.ts` (no longer exactly one entry) and `widget-module-map.spec.ts` (module tiles = proxmox and nextcloud-status). `widget-registry.tsx`: WIDGET_CONSTRAINTS `'nextcloud-status': { minW: 6, minH: 4, defaultW: 12, defaultH: 8 }` with a comment, an inline `NextcloudStatusIcon` (cloud path from Task 1), and the registry entry (nameKey 'nextcloudStatus.name', descriptionKey 'nextcloudStatus.description', moduleSlug from WIDGET_MODULE_SLUGS, PlaceholderWidget). `widget-icon.tsx`: 'nextcloud-status' cloud glyph. `widget-wrapper.tsx`: add 'nextcloud-status' to FRAME_HEADER_TYPES (frame header = icon + name, hide-title toggle via TITLE_TYPES) and update its comment. `(portal)/page.tsx`: import and `registerWidget('nextcloud-status', NextcloudStatusWidget)`; add the matching vi.mock in `page.test.tsx`. Update the enumerations in `widget-registry.test.tsx` (twelve types, constraints table, moduleSlug assertion for both module tiles, visibleWidgetTypes cases) and `widget-catalog-modal.test.tsx` (counts and module-access cases).
|
||||||
|
|
||||||
|
**Widget component (L-10, L-11).** `nextcloud-status-widget.tsx` ('use client', WidgetProps): reads only `listInstances()` from `@/lib/nextcloud-status-api` (German comment: never triggers checks — pattern T-I8V-02), refresh every 5 min with visibility pause and immediate reload on return (pattern proxmox-widget.tsx); three counter chips using `RATING_STYLE` from `rating-display.ts` (grün/gelb/rot labels plus "ohne Bewertung" only when > 0); below, red then yellow clouds sorted by name (`sortClouds(items, 'status')` from Task 2) with status dot, Kundenname and `ratingReasonText`; container-query compact mode like the proxmox widget (narrow: only counters); in view mode each row and the counter area are Next `Link`s to `/modules/nextcloud-status`, in edit mode plain rows without tabstop (links are in the drag-cancel selector, see proxmox comment); loading, error ("Status konnte nicht geladen werden") and empty states. Messages: `widgets.nextcloudStatus.{name: 'Nextcloud-Status', description: 'Ampelübersicht Ihrer Nextcloud-Clouds'}` and `nextcloudStatus.widget.*` texts in de + en. Widget test per `<behavior>`.
|
||||||
|
|
||||||
|
**Docs + changelog (L-13).** `CHANGELOG.md` under "## Unveröffentlicht" → "### Neu": one user-facing German bullet (module in Infrastruktur, Aktivierung im Marktplatz + Freigabe, tiles with Ampel and what the colors mean, newest version shown, hourly check plus "Jetzt prüfen", sorting remembered, logo upload or address, who may maintain clouds = Verwalten, dashboard tile). `docs/anleitung-anwender.md`: new section "### Nextcloud-Status" after "### Proxmox" (what the tile shows, the exact traffic-light rules in plain words incl. 3-month window, grey state, sorting, the dashboard tile, what Verwalten users can do). `docs/anleitung-administration.md`: new subsection after "### Proxmox-Server anbinden…" — "### Nextcloud-Status: Clouds eintragen" (who may maintain, Tessera reads only the public status.php, no login data needed, the API container needs outbound access to the clouds and to endoflife.date, internal addresses allowed, certificate must be valid, logo rules 1 MB PNG/JPEG/GIF/WebP or https address loaded by the browser), plus its entry in the table of contents if the section list there names subsections. No tenant wording in user-facing docs/changelog.
|
||||||
|
|
||||||
|
**Final gates (L-12, L-14).** Run full `pnpm --filter @tessera/api test` and `pnpm --filter @tessera/web test`, both tsc, biome lint on all touched files of the three tasks; fix any enumeration test that still expects eleven widget types. Rebuild `docker compose up -d --build api web`, wait for healthy, check `docker compose logs api` for 'Nextcloud-Status module seeded in registry', the mapped routes `/modules/nextcloud-status/instances`, the scheduler log line, and no migration errors; confirm with psql in the db container that the `Module` row `nextcloud-status` has category `infrastructure`. Commit `feat(nextcloud-status): Dashboard-Kachel, Anleitung und Changelog` (attribution line). Do not push; leave the browser check to the orchestrator and list in the SUMMARY what to click (add a public cloud such as a real customer URL, manager vs USE view, sorting, widget).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && node -e 'const de=require("./apps/web/src/messages/de.json"),en=require("./apps/web/src/messages/en.json");const w=(o,p,r)=>{for(const[k,v]of Object.entries(o||{})){const q=p+"."+k;if(v&&typeof v==="object")w(v,q,r);else r[q]=v}return r};const pick=(m)=>({...w(m.nextcloudStatus,"nextcloudStatus",{}),...w(m.widgets&&m.widgets.nextcloudStatus,"widgets.nextcloudStatus",{})});const a=pick(de),b=pick(en);if(Object.keys(a).length<10||Object.keys(a).sort().join()!==Object.keys(b).sort().join()){console.error("key mismatch");process.exit(1)}for(const v of [...Object.values(a),...Object.values(b)])if(/mandant|tenant/i.test(String(v))){console.error("bad text",v);process.exit(1)}' && grep -q "Nextcloud" CHANGELOG.md && grep -q "### Nextcloud-Status" docs/anleitung-anwender.md && docker compose ps --status running --services | grep -qx api && docker compose ps --status running --services | grep -qx web && docker compose logs api 2>&1 | grep -q "Nextcloud-Status module seeded in registry"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Dashboard tile registered (shared types, module map, registry, icon, wrapper, page) and visible only with module access; widget shows counters and red/yellow clouds and links to the module; CHANGELOG, user and admin docs updated; full api + web suites, tsc and biome green; api and web rebuilt and running with the module seeded; commit on main, not pushed.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| browser → API (`/modules/nextcloud-status/*`) | untrusted caller; tenant/user/role only from the validated JWT |
|
||||||
|
| API → customer cloud (`<url>/status.php`) | outbound request to a manager-entered address, untrusted response |
|
||||||
|
| API → endoflife.date | outbound request to a public service, untrusted response |
|
||||||
|
| browser → logo URL host | the viewer's browser loads an external image |
|
||||||
|
| manager upload → DB → other users' browsers | uploaded bytes are served back to every module user |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-k67-01 | Information Disclosure (SSRF) | fetchNextcloudStatus | medium | accept | Internal targets allowed by design like Proxmox (L-03); limits: only GET `<url>/status.php`, no credentials, http/https, 3 redirects, 10 s, 64 KiB, whitelisted parsed fields only, errorDetail only own codes; write access requires Verwalten; documented in the code comment and admin docs |
|
||||||
|
| T-k67-02 | Tampering / Spoofing | logo upload + GET logo | high | mitigate | multer fileSize 1 MiB + service re-check; type from magic bytes (PNG/JPEG/GIF/WebP, no SVG); served with detected mime, nosniff, `default-src 'none'; sandbox`, private cache; logo-rules spec |
|
||||||
|
| T-k67-03 | Elevation of Privilege | controller write/check/logo routes | high | mitigate | `@ModuleManage('nextcloud-status')` on every write/check handler, no role decorator; controller spec + module-manage-handlers.spec metadata; verify gate greps for role decorators |
|
||||||
|
| T-k67-04 | Information Disclosure | cross-tenant rows | high | mitigate | forTenant on every request path, `where: { id, tenantId }` → 404, tenant_isolation_policy; system read limited to `select { id, tenantId }` in one allowlisted call site (rls-access-inventory) |
|
||||||
|
| T-k67-05 | Information Disclosure | external logo URL | low | mitigate | https only (DTO), `referrerPolicy="no-referrer"` on the img, API never fetches it (L-02) |
|
||||||
|
| T-k67-06 | Denial of Service | hourly job / reference fetch | medium | mitigate | concurrency 4, overlap guard, per-instance try/catch, 10 s timeouts, size caps; reference cache 12 h with 15 min failure backoff and shared in-flight request |
|
||||||
|
| T-k67-07 | Information Disclosure | list response | medium | mitigate | `PUBLIC_SELECT` without logo bytes; raw bodies never stored or returned; service spec asserts the select |
|
||||||
|
| T-k67-08 | Elevation of Privilege | route shadowing (`instances/check` vs `:id`) | medium | mitigate | static route declared before `:id` routes; declaration-order assertion in the controller spec |
|
||||||
|
| T-k67-09 | Tampering | stored URL | low | mitigate | normalizeCloudUrl rejects non-http(s), embedded credentials, overlong input; link rendered with `rel="noopener noreferrer"` |
|
||||||
|
| T-k67-SC | Tampering | npm/pip/cargo installs | low | accept | No new packages (undici, class-validator, multer, cron already present); nothing to verify |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- Task verify commands all pass; Task 3 runs the full api + web suites (includes rls-coverage, rls-access-inventory, umlaut-guard, module-layouts, widget enumerations).
|
||||||
|
- `prisma migrate status` up to date locally; drift check exit 0.
|
||||||
|
- `docker compose ps` shows api and web running after the rebuild; api log shows the seed line and the routes.
|
||||||
|
- Source coverage audit:
|
||||||
|
|
||||||
|
| Source item | Covered by |
|
||||||
|
|-------------|------------|
|
||||||
|
| GOAL: Nextcloud-Status module with traffic-light tiles | Tasks 1-3 |
|
||||||
|
| L-01 slug/category, Proxmox touchpoints end to end | Task 1 (API, migration, web route, registrations, layouts test), Task 3 (widget registration) |
|
||||||
|
| L-02 URL + Kundenname, logo upload (bytea, magic bytes, 1 MiB, auth route) or https URL | Task 1 (model, create), Task 2 (logo routes, form) |
|
||||||
|
| L-03 status.php fetch limits, stored result, SSRF comment, no raw bodies | Task 1 |
|
||||||
|
| L-04 endoflife.date cache 12 h, outage-tolerant, grey without data | Task 1 |
|
||||||
|
| L-05 traffic-light rules + newest version, pure function with date injection | Task 1 |
|
||||||
|
| L-06 tile content and reason texts | Task 1 |
|
||||||
|
| L-07 four sort options remembered per user | Task 2 |
|
||||||
|
| L-08 hourly job (onApplicationBootstrap), "Jetzt prüfen", per-tile refresh, system context | Task 1 (checkOne), Task 2 (checkAll, scheduler) |
|
||||||
|
| L-09 rights USE vs MANAGE/admin, useCanManageModule | Task 1 (create/checkOne), Task 2 (all write routes, UI gating) |
|
||||||
|
| L-10 dashboard widget counters + red/yellow list | Task 3 |
|
||||||
|
| L-11 German Sie + English, no tenant wording, tokens | Tasks 1-3 + node key check |
|
||||||
|
| L-12 tests, full suites, tsc, biome | Tasks 1-3 |
|
||||||
|
| L-13 CHANGELOG + docs | Task 3 |
|
||||||
|
| L-14 local migration, rebuild, no push | Task 1 (migrate), Task 3 (rebuild) |
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- `NextcloudInstance` exists with RLS policies; migration applied locally without drift.
|
||||||
|
- Rating, parser, fetch, release cache, service, controller, scheduler, logo rules, sort, page, form and widget tests pass; full api + web suites, both tsc runs and biome on touched files are green.
|
||||||
|
- USE users see rated tiles and can sort; managers and admins can additionally add/edit/delete clouds, manage logos and trigger checks; the API enforces this with ModuleManage.
|
||||||
|
- The hourly job is registered on every start, independent of existing rows.
|
||||||
|
- The dashboard tile appears in the catalog only with module access and links to the module.
|
||||||
|
- CHANGELOG and both guides describe the module; three commits on main, nothing pushed.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/quick/261002-k67-modul-nextcloud-status-mit-ampel-kacheln/261002-k67-SUMMARY.md` when done (not committed by the executor).
|
||||||
|
</output>
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
---
|
||||||
|
phase: quick-261002-k67
|
||||||
|
plan: 01
|
||||||
|
subsystem: nextcloud-status
|
||||||
|
tags: [nextcloud, ampel, modul, dashboard-kachel, scheduler, rls]
|
||||||
|
status: complete
|
||||||
|
requires:
|
||||||
|
- module-registry (ModuleGuard, ModuleManage, seedModule)
|
||||||
|
- Freigabestufe Verwalten (quick-261002-icv)
|
||||||
|
provides:
|
||||||
|
- Modul "nextcloud-status" (Kategorie infrastructure) mit Ampel-Kacheln je Cloud
|
||||||
|
- Tabelle NextcloudInstance (RLS, system_read_policy)
|
||||||
|
- stündlicher Prüfauftrag nextcloud-status-poll
|
||||||
|
- Dashboard-Kachel "nextcloud-status" (zweite modulgebundene Kachel)
|
||||||
|
affects:
|
||||||
|
- WIDGET_TYPES / WIDGET_MODULE_SLUGS (@tessera/shared)
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md, FORSYSTEM_ALLOWED_CALL_SITES
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- reine Bewertungsfunktion mit eingereichtem Datum
|
||||||
|
- Stale-while-revalidate-Zwischenspeicher im Speicher (12 h, 15 min Pause nach Fehlschlag)
|
||||||
|
- ein globaler Cron-Auftrag in onApplicationBootstrap ohne Datenbankzugriff bei der Registrierung
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql
|
||||||
|
- apps/api/src/nextcloud-status/ (Rating, Fetch, Release, Service, Controller, Scheduler, Logo-Regeln, DTO, Seed, Modul, je mit Spec)
|
||||||
|
- apps/web/src/lib/nextcloud-status-api.ts
|
||||||
|
- apps/web/src/components/nextcloud-status/ (rating-display, sort-clouds + Test)
|
||||||
|
- apps/web/src/app/(portal)/modules/nextcloud-status/ (layout, page, CloudTile, CloudForm + Tests)
|
||||||
|
- apps/web/src/components/dashboard/widgets/nextcloud-status-widget.tsx (+ Test)
|
||||||
|
modified:
|
||||||
|
- apps/api/prisma/schema.prisma, apps/api/src/app.module.ts
|
||||||
|
- apps/api/src/prisma/rls-access-inventory.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
- apps/api/src/module-registry/module-manage-handlers.spec.ts
|
||||||
|
- packages/shared/src/index.ts, apps/api/src/dashboard/widget-module-map(.spec).ts
|
||||||
|
- Web-Registrierungen (module-loader, module-identity, nav-store, module-tile, module-layouts.test, widget-registry(+Tests), widget-icon, widget-wrapper, (portal)/page(+Test)), de.json, en.json, umlaut-dictionary.ts
|
||||||
|
- CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md
|
||||||
|
decisions:
|
||||||
|
- "Logo-Bytes als bytea an der Zeile; Listen-/Planerabfragen wählen sie nie aus, nur getLogo (D-A)"
|
||||||
|
- "Bewertung beim Lesen in der API; Grundkennung plus Parameter, Übersetzung im Web (D-B)"
|
||||||
|
- "Ein globaler Cron-Auftrag, beim Start ohne Datenbankzugriff registriert (D-C)"
|
||||||
|
- "Zertifikate der Clouds werden geprüft, Fehler erscheint als Nicht erreichbar mit Fehlercode (D-H)"
|
||||||
|
metrics:
|
||||||
|
duration: etwa 1 h 15 min
|
||||||
|
completed: 2026-10-02
|
||||||
|
actuals:
|
||||||
|
tokens: 52000
|
||||||
|
tasks: 3
|
||||||
|
commits: 3
|
||||||
|
plan_head_before: 7188733f70f88ce7a61c53f76a614c476864bbfd
|
||||||
|
plan_head_after: 87a7b7cbcd86756b1892c14abd77f66744edc969
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase quick-261002-k67 Plan 01: Modul Nextcloud-Status mit Ampel-Kacheln
|
||||||
|
|
||||||
|
Neues Modul „Nextcloud-Status“ (Gruppe Infrastruktur): Verwalter tragen Nextcloud-Clouds ihrer Kunden ein, Tessera ruft stündlich `<Adresse>/status.php` ab, bewertet die Version gegen die Daten von endoflife.date (Ampel grün/gelb/rot/grau) und zeigt je Cloud eine Kachel sowie eine Übersichtskachel auf dem Dashboard.
|
||||||
|
|
||||||
|
## Was gebaut wurde
|
||||||
|
|
||||||
|
**Aufgabe 1 (Tracer), Commit 5ef7b0c:** Tabelle `NextcloudInstance` samt Zeilenschutz (Migration 20261002150000, lokal angewendet, `migrate status` aktuell, Drift-Prüfung Exit 0). Reine Funktion `rateNextcloud` (alle Regeln aus L-05 einschließlich der Grenzen 90/91 Tage und Support-Ende-Tag gelb, Tag danach rot), `normalizeCloudUrl`, `parseNextcloudStatus`, `fetchNextcloudStatus` (nur GET `<Adresse>/status.php`, 10 s, 3 Weiterleitungen, 64 KiB, keine Zugangsdaten, nur eigene Fehlerkürzel). `NextcloudReleaseService` als Zwischenspeicher (12 h, veraltete Daten sofort, Erneuerung im Hintergrund, 15 min Pause nach Fehlschlag, geteilte Anfrage). Dienst, Controller (`GET instances`, `POST instances`, `POST instances/:id/check`), Seed, Modul, RLS-Inventar. Web: Modulseite mit Kacheln (Ampelleiste, Pille mit Klartext, Logo oder Initialen, Version, letzte Prüfung), Registrierungen in Loader, Identität (Wolkensymbol), Seitenleisten-Titel, Layout-Test, Texte de/en.
|
||||||
|
|
||||||
|
**Aufgabe 2, Commit 6698ef1:** Ändern, Entfernen, Logo hochladen/abrufen/entfernen, „Jetzt prüfen“ (`POST instances/check`, statisch vor allen `:id`-Routen) und Einzelprüfung, alle mit `@ModuleManage('nextcloud-status')` und ohne Rollen-Decorator; Logo per Magic Bytes (PNG/JPEG/GIF/WebP, kein SVG, 1 MiB, multer plus Zweitprüfung). Stündlicher Auftrag `nextcloud-status-poll` (`0 * * * *`) in `onApplicationBootstrap` ohne Datenbankzugriff; je Durchlauf ein `forSystem`-Aufruf (nur `id`, `tenantId`), danach jede Cloud an ihren Mandanten gebunden, höchstens vier gleichzeitig, Überlappungsschutz. Web: Sortierung (Kundenname, Status, Version, Support-Ende; je Benutzer in localStorage), Formular (Anlegen/Bearbeiten/Löschen mit Bestätigung/Logo), Verwalten-Knöpfe nur mit `useCanManageModule`. `FORSYSTEM_ALLOWED_CALL_SITES` und die Zugriffsklassifikation nachgezogen.
|
||||||
|
|
||||||
|
**Aufgabe 3, Commit 87a7b7c:** Dashboard-Kachel `nextcloud-status` (geteilte Typliste, Modulzuordnung `nextcloud-status` → `nextcloud-status`, Registry, Symbol, Rahmenkopf, Katalog nur mit Modulzugriff): Zähler grün/gelb/rot (und „ohne Bewertung“ nur wenn > 0), darunter rote, dann gelbe Clouds mit Grund; Klick öffnet das Modul, im Bearbeitungsmodus keine Links, ruft nie eine Prüfung auf. CHANGELOG, Anwender- und Administrationsanleitung.
|
||||||
|
|
||||||
|
## Testergebnisse (ehrlich)
|
||||||
|
|
||||||
|
| Prüfung | Ergebnis |
|
||||||
|
|---|---|
|
||||||
|
| API-Tests gesamt (`pnpm --filter @tessera/api test`) | 118 Dateien, **2030 Tests grün** |
|
||||||
|
| Web-Tests gesamt (`pnpm --filter @tessera/web test`) | 119 Dateien, **1276 Tests grün** |
|
||||||
|
| `tsc --noEmit` api | grün |
|
||||||
|
| `tsc --noEmit` web | grün |
|
||||||
|
| Biome lint auf den neuen/geänderten Dateien | keine Meldungen auf neuen Dateien; die zehn verbleibenden Warnungen der Gesamtläufe stammen aus bestehenden Widget-Dateien (z. B. Stoppuhr) und wurden nicht angefasst |
|
||||||
|
| Biome check --write | nur auf neuen Dateien angewendet |
|
||||||
|
| Schlüsselabgleich de/en (`nextcloudStatus`, `widgets.nextcloudStatus`) und Prüfung auf Mandanten-Wörter | 74 Schlüssel je Sprache, deckungsgleich, kein Treffer |
|
||||||
|
| Umlaut-Wächter | grün (Allowlist: Neueste, ausstehend, Statusseite, Bildadresse, aktuell) |
|
||||||
|
| rls-coverage, rls-access-inventory | grün (93 Paare, Bereichszeile 0/14/1, Summe 61/264/8, 7 Dateien/8 `forSystem`-Aufrufe) |
|
||||||
|
|
||||||
|
Neue Tests: Rating (22), Fetch/Parser/URL (35), Release-Cache (10), Service (18), Controller (11), Scheduler (5), Logo-Regeln (6), module-manage-handlers (+9), Web: Seite (13), CloudForm (9), sort-clouds (8), Widget (8) sowie angepasste Aufzählungstests der Widget-Typen.
|
||||||
|
|
||||||
|
## Lokaler Stand
|
||||||
|
|
||||||
|
- Migration lokal angewendet (Container-IP 172.19.0.2), `docker compose up -d --build api web` gebaut, api healthy, web läuft.
|
||||||
|
- API-Log: „Nextcloud-Status module seeded in registry“, „Nextcloud-Status cron job registered: 0 * * * *“, alle neun Routen gemappt (`instances/check` vor `instances/:id`), „No pending migrations to apply“.
|
||||||
|
- Datenbank: `Module`-Zeile `nextcloud-status`, Kategorie `infrastructure`, `isSystem` true; Tabelle `NextcloudInstance` vorhanden.
|
||||||
|
- Nicht gepusht. SUMMARY/STATE/PLAN nicht committiert (macht der Orchestrator).
|
||||||
|
|
||||||
|
## Abweichungen vom Plan
|
||||||
|
|
||||||
|
Keine funktionalen Abweichungen. Drei kleine Anmerkungen:
|
||||||
|
|
||||||
|
1. **[Hinweis] Zugriffsklassifikation in zwei Schritten:** Task 1 trug `nextcloud-status.service.ts`/`nextcloudInstance` als `gebunden` ein (0/4/0), Task 2 stellte es wie geplant auf `system-gebunden` (0/14/1) um. Die Zahlen stammen aus der Gate-Schleife (`grep -cE 'tenantPrisma\.[a-zA-Z]*\.'` über die nicht-Spec-Dateien), nicht aus Annahmen.
|
||||||
|
2. **[Rule 1 - Fehler] Zählerstand im Registry-Test:** Die Erwartung `counted` (44) im bestehenden Registry-Test musste auf 48 steigen (zwölf Typen mal vier Felder); dazu mussten die Aufzählungen aus dem Plan angepasst werden (Katalog-, Registry-, Modulkarten-Tests). Beim ersten Voll-Lauf war zusätzlich `widget-module-map.spec.ts` noch auf einen Modulbezug eingestellt; im selben Task behoben, danach alle Tests grün.
|
||||||
|
3. **[Hinweis] Ledger für `commits:`:** Der Ledger-Eintrag wurde erst nach dem ersten Commit angelegt und aus dessen Elternkommit (7188733) gebildet; die Zählung (3) entspricht `git rev-list --count 7188733..HEAD`.
|
||||||
|
|
||||||
|
## Bekannte Stubs
|
||||||
|
|
||||||
|
Keine.
|
||||||
|
|
||||||
|
## Bedrohungen (Threat Model)
|
||||||
|
|
||||||
|
Alle als `mitigate` eingestuften Punkte sind umgesetzt und getestet: T-k67-02 (Logo: Magic Bytes, nosniff, Sandbox-CSP, 1 MiB), -03 (`@ModuleManage` ohne Rollen-Decorator, Metadaten-Specs), -04 (`forTenant`, `where { id, tenantId }`, ein einziger `forSystem`-Aufruf mit `select { id, tenantId }`), -05 (nur https, `referrerPolicy="no-referrer"`, API ruft die Logo-Adresse nie ab), -06 (Nebenläufigkeit 4, Überlappungsschutz, Zeitlimits), -07 (`PUBLIC_SELECT` ohne `logoData`, Spec prüft), -08 (statische Route vor `:id`, Reihenfolge-Test), -09 (`normalizeCloudUrl`). T-k67-01 (SSRF auf manuell eingegebene, auch interne Adressen) ist wie geplant akzeptiert und im Code und in der Administrationsanleitung dokumentiert.
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
Keine neuen Angriffsflächen außerhalb des Plans.
|
||||||
|
|
||||||
|
## Für die Browser-Prüfung (Orchestrator)
|
||||||
|
|
||||||
|
1. Als Administrator im Marktplatz „Nextcloud-Status“ aktivieren (falls noch nicht), Freigabe erteilen; die Seite unter `/modules/infrastructure/nextcloud-status` und `/modules/nextcloud-status` öffnen (Seitenleiste, Gruppe Infrastruktur).
|
||||||
|
2. „Cloud hinzufügen“: eine öffentlich erreichbare Nextcloud eintragen (Kundenname + Adresse, z. B. eine echte Kunden-Cloud). Erwartung: Kachel mit Version, Ampel und Klartext, „Zuletzt geprüft vor …“; Kopfzeile nennt die neueste Nextcloud-Version. Außerdem eine nicht erreichbare Adresse (rot „Nicht erreichbar“) und eine Adresse ohne Nextcloud (rot „Keine gültige Nextcloud-Antwort“) probieren.
|
||||||
|
3. Logo: einmal PNG hochladen (Kachel zeigt es), einmal https-Bildadresse, „Logo entfernen“ (Initialen); eine Nicht-Bilddatei und eine Datei über 1 MB (Fehlermeldung im Formular).
|
||||||
|
4. Sortierung: alle vier Optionen, danach neu laden (Auswahl bleibt, im Dunkelmodus ebenfalls prüfen).
|
||||||
|
5. Verwalten-Stufe: Benutzer nur mit „Benutzen“ anmelden (sieht Kacheln und Sortierung, aber weder „Cloud hinzufügen“, „Jetzt prüfen“, Kachel-Knöpfe noch Bearbeiten), Benutzer mit „Verwalten“ (sieht alles). „Jetzt prüfen“ und Kachel-Prüfung ausprobieren.
|
||||||
|
6. Dashboard: im Bearbeitungsmodus „Widget hinzufügen“ → „Nextcloud-Status“ (nur mit Modulzugriff im Katalog). Zähler, rote/gelbe Liste mit Grund, Klick öffnet das Modul; kleine Kachel zeigt nur die Zähler.
|
||||||
|
7. Optional: `docker compose logs api` nach der vollen Stunde — Prüfläufe der Clouds laufen ohne Fehler.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- Dateien vorhanden: Migration, `apps/api/src/nextcloud-status/*`, `apps/web/src/app/(portal)/modules/nextcloud-status/*`, Widget und Tests (alle per Commit nachgewiesen).
|
||||||
|
- Commits vorhanden: 5ef7b0c, 6698ef1, 87a7b7c (`git log`), 3 Commits seit 7188733.
|
||||||
|
- Laufender Stand: api healthy, Seed- und Planer-Logzeilen vorhanden, Modul-Zeile in der Datenbank.
|
||||||
+306
@@ -0,0 +1,306 @@
|
|||||||
|
---
|
||||||
|
phase: quick-261002-kxc
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
quick_id: 261002-kxc
|
||||||
|
description: "Nextcloud-Status: persönliche Benachrichtigung (Glocke je Kachel) bei Störung und Wiederherstellung, per E-Mail und in Tessera"
|
||||||
|
date: 2026-10-02
|
||||||
|
files_modified:
|
||||||
|
# Task 1 — tracer: DB -> Ausfall-Regeln -> Pruefung -> Anspruch -> Mail; Glocke API + Kachel
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/prisma/migrations/20261002170000_nextcloud_alerts/migration.sql
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-alert-rules.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-alert-rules.spec.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-alert-mail.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-alert-mail.spec.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-alert.service.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-alert.service.spec.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-status.service.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-status.controller.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-status.module.ts
|
||||||
|
- apps/api/src/mail/mail.service.ts
|
||||||
|
- apps/api/src/mail/mail.service.spec.ts
|
||||||
|
- apps/api/src/module-registry/module-manage-handlers.spec.ts
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
- apps/web/src/lib/nextcloud-status-api.ts
|
||||||
|
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx
|
||||||
|
- apps/web/src/messages/de.json
|
||||||
|
- apps/web/src/messages/en.json
|
||||||
|
# Task 2 — Wiederholung nach 5 min, Adresswechsel, Hinweis auf der Kachel
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.ts
|
||||||
|
# Task 3 — In-App-Meldung, Anleitung, Changelog, Abschluss
|
||||||
|
- apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx
|
||||||
|
- apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.test.tsx
|
||||||
|
- apps/web/src/components/layout/app-shell.tsx
|
||||||
|
- CHANGELOG.md
|
||||||
|
- docs/anleitung-anwender.md
|
||||||
|
- docs/anleitung-administration.md
|
||||||
|
autonomous: true
|
||||||
|
requirements: [QUICK-261002-kxc]
|
||||||
|
|
||||||
|
estimate:
|
||||||
|
tokens: 150000
|
||||||
|
raw_tokens: 150000
|
||||||
|
tasks: 3
|
||||||
|
confidence: low
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Every user who can open Nextcloud-Status (grant Benutzen or Verwalten, or administrator) sees a bell on each cloud tile, can switch it on and off, and the state survives a reload; it is personal per user and per cloud (L-01)"
|
||||||
|
- "When a subscribed cloud turns red (unreachable, invalid answer, maintenance, database upgrade pending, support expired) every subscriber with current module access and an active account gets exactly one e-mail; while it stays red nothing more is sent; when it turns yellow or green again exactly one 'wieder in Ordnung' e-mail follows (L-02, L-05, L-06)"
|
||||||
|
- "A cloud that fails one check keeps showing its last known state with the hint 'Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt'; it turns red and triggers the notification only after a second failed check, and Tessera repeats the check about 5 minutes after the first failure instead of waiting for the next hour; maintenance, database upgrade and expired support count immediately (L-03)"
|
||||||
|
- "Two concurrent checks, several API instances or a restart never send the same notification twice — the transition is claimed on the cloud row before any mail is sent (L-02)"
|
||||||
|
- "Without SMTP setup, without an e-mail address, with a deactivated account or without module access the mail is skipped and only logged; a failing SMTP transport is tried at most three times (L-04, L-06)"
|
||||||
|
- "While Tessera is open (browser or desktop app) a subscriber also gets an on-screen notification for each transition — in the desktop app as a Windows notification — at most once per transition and client (L-04)"
|
||||||
|
- "No user-facing text (mail, tile, notification, docs, changelog) contains a word for tenant (L-10)"
|
||||||
|
artifacts:
|
||||||
|
- path: "apps/api/prisma/migrations/20261002170000_nextcloud_alerts/migration.sql"
|
||||||
|
provides: "NextcloudAlertSubscription table (RLS with user dimension, cascades) + alert/failure columns on NextcloudInstance"
|
||||||
|
contains: "NextcloudAlertSubscription"
|
||||||
|
- path: "apps/api/src/nextcloud-status/nextcloud-alert-rules.ts"
|
||||||
|
provides: "pure decideAlert + planStatusWrite + retry constants"
|
||||||
|
exports: ["decideAlert", "planStatusWrite", "FAILURES_FOR_RED", "RETRY_DELAY_MS"]
|
||||||
|
- path: "apps/api/src/nextcloud-status/nextcloud-alert-mail.ts"
|
||||||
|
provides: "pure German mail builder (subject + plain text)"
|
||||||
|
exports: ["buildNextcloudAlertMail"]
|
||||||
|
- path: "apps/api/src/nextcloud-status/nextcloud-alert.service.ts"
|
||||||
|
provides: "subscriptions, claim-before-send, recipient re-check, mail delivery with 3 attempts, recent alerts per user"
|
||||||
|
- path: "apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx"
|
||||||
|
provides: "global in-app notifier mounted in AppShell"
|
||||||
|
key_links:
|
||||||
|
- from: "NextcloudStatusService.checkInstance"
|
||||||
|
to: "planStatusWrite -> rateNextcloud -> NextcloudAlertService.evaluateAfterCheck"
|
||||||
|
via: "every check (hourly, retry, manual, create, URL change) runs the guard and the transition claim"
|
||||||
|
pattern: "evaluateAfterCheck\\("
|
||||||
|
- from: "NextcloudAlertService.evaluateAfterCheck"
|
||||||
|
to: "nextcloudInstance.updateMany where alertState = previous state"
|
||||||
|
via: "claim-before-send: only count === 1 notifies"
|
||||||
|
pattern: "alertState: prev"
|
||||||
|
- from: "NextcloudStatusSchedulerService retry job"
|
||||||
|
to: "NextcloudStatusService.loadAllInstancesForScheduler({ retryDueBefore })"
|
||||||
|
via: "same single forSystem call site, filtered to consecutiveFailures = 1"
|
||||||
|
pattern: "retryDueBefore"
|
||||||
|
- from: "apps/web/src/components/layout/app-shell.tsx"
|
||||||
|
to: "NextcloudAlertNotifier -> GET /modules/nextcloud-status/alerts -> showReminderNotification"
|
||||||
|
via: "global mount next to ReminderNotifier"
|
||||||
|
pattern: "<NextcloudAlertNotifier />"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Extend the module `nextcloud-status` (quick 261002-k67) with personal notifications: a bell per tile, e-mail plus in-app notification when a subscribed cloud goes red and when it recovers, with a two-strike guard against flapping and a quick re-check after the first failure.
|
||||||
|
|
||||||
|
Locked decisions from the request (cited below as L-xx):
|
||||||
|
- L-01 Every user who can see the module (grant USE or MANAGE, or admin) gets a bell toggle "Benachrichtigen" on each tile, personal per user per cloud. New Prisma table user x instance, tenant RLS like the other tables, cascade on instance/user delete. Toggle endpoints need only module access (USE), not manage. Tile shows the bell state; the list endpoint returns `subscribed` per instance for the current user.
|
||||||
|
- L-02 Trigger: transition INTO red (unreachable, maintenance, needsDbUpgrade, EOL passed, invalid response) notifies subscribers once; staying red = no repeat; red back to yellow/green = one "wieder in Ordnung" notification. Last notified state stored on the instance so restarts/multiple polls never duplicate; claim-before-send with an `updateMany` guard like `reminder-mail.scheduler.ts`.
|
||||||
|
- L-03 Flapping guard: "unreachable" only counts as red after 2 consecutive failed checks (counter on the instance); the tile keeps showing the last good state plus a hint until the second failure; other red reasons (maintenance, EOL) count immediately; after a first failure the cloud is re-checked after about 5 minutes.
|
||||||
|
- L-04 Channels: e-mail via the existing MailService/SMTP config exactly like reminder mails (skip silently + log if SMTP missing, user has no e-mail or is inactive; max 3 attempts) plus an in-app notification while Tessera is open, reusing the reminder mechanism (`reminder-notifier.tsx`, `reminder-notify.ts`; desktop app = Windows notification) with a small "recent alerts" endpoint.
|
||||||
|
- L-05 Mail text German, plain and short: subject "Nextcloud <Kundenname>: nicht erreichbar" / "Nextcloud <Kundenname>: wieder in Ordnung", body with reason, URL, time and link to the module. Reminder mails are German-only (`MailService.sendReminderEmail`), so these mails are German-only too.
|
||||||
|
- L-06 Only users who still have module access at send time (grant re-checked) and only active users are notified.
|
||||||
|
- L-07 Tests: transition logic as pure function (green->red, red->red, red->green, unreachable once vs twice, maintenance immediate), claim/no-duplicate, subscription endpoints + guard metadata, web bell toggle + notifier; full api + web suites, tsc, biome on touched files.
|
||||||
|
- L-08 CHANGELOG (Unveröffentlicht, German, user-facing) + docs update.
|
||||||
|
- L-09 Local migration via db container IP + `prisma migrate deploy`; rebuild `docker compose up -d --build api web`.
|
||||||
|
- L-10 No word for tenant in user texts. Do not push. SUMMARY documents how to force a red transition locally and whether local SMTP is configured (it is: one `SmtpConfig` row with a host exists in the local db).
|
||||||
|
|
||||||
|
Claude's discretion, decided here (cited as D-Kx):
|
||||||
|
- D-K1 The two-strike guard applies to every failed fetch (`reachable === false`, all error kinds incl. `not-nextcloud`): each comes from one HTTP call and can be transient (a proxy error page during a restart is an "invalid answer"). Maintenance and DB upgrade come from a successful answer, EOL from the date — both immediate.
|
||||||
|
- D-K2 First failure writes ONLY `consecutiveFailures = 1` and `firstFailureAt`; all status fields and `lastCheckedAt` stay untouched, so the rating (computed from stored fields) keeps the last good state. A never-checked cloud therefore stays grey "Noch nicht geprüft" plus the hint. The second failure writes the failure fields as today.
|
||||||
|
- D-K3 Quick retry = a second cron job `nextcloud-status-retry` every minute that checks only clouds with exactly one failure whose `firstFailureAt` is at least 5 minutes old. It reuses the ONE existing `forSystem` call site (`loadAllInstancesForScheduler` gets an optional filter) — no new system read, no new policy. State lives on the row, so it survives restarts.
|
||||||
|
- D-K4 Transition state on the instance: `alertState` ('ok' | 'red', default 'ok'), `alertReason`, `alertChangedAt`. Grey ("unknown") changes nothing. Existing rows start as 'ok'.
|
||||||
|
- D-K5 Mail delivery runs in the background after a won claim (never blocks "Jetzt prüfen"); per recipient up to 3 attempts, 60 s apart, in-process. A restart between attempts drops the remaining ones — accepted: a late status mail after a restart has little value, and tile plus in-app notification show the state anyway. Skips (no SMTP / no address / inactive / no access) are logged and never retried.
|
||||||
|
- D-K6 Subject per red reason: unreachable uses the locked wording "nicht erreichbar"; the other red reasons get their own short wording ("keine gültige Antwort", "im Wartungsmodus", "Datenbank-Aktualisierung ausstehend", "Support abgelaufen") so a subject is never factually wrong; recovery uses the locked "wieder in Ordnung".
|
||||||
|
- D-K7 In-app: `GET modules/nextcloud-status/alerts` returns, for the caller's subscribed clouds, the latest transition of the last 24 h that happened after the caller subscribed. The client dedupes per (cloud, changedAt) with the existing reminder helpers and only polls when the user has module access.
|
||||||
|
- D-K8 Changing a cloud's address resets the status fields and the failure counter but KEEPS `alertState` — fixing a broken address therefore sends "wieder in Ordnung" to subscribers.
|
||||||
|
- D-K9 Subscription RLS with user dimension like "Reminder" (`current_user_id() IS NULL OR "userId" = current_user_id()`), no `system_read_policy` (never read in system context).
|
||||||
|
- D-K10 List responses (`GET instances`, `POST instances/check`) carry `subscribed`; single-cloud responses do not — the page keeps the tile's bell state when it swaps in a single checked tile. Switching a bell on for the first time asks the browser for notification permission once (`requestBrowserPermissionOnce`).
|
||||||
|
|
||||||
|
Purpose: subscribers learn about an outage within minutes instead of noticing it on the next visit, without false alarms from a single hiccup.
|
||||||
|
Output: one migration, alert rules/mail/service in `apps/api/src/nextcloud-status/`, bell + hint on the tile, global notifier, docs and changelog.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@~/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/STATE.md
|
||||||
|
@./CLAUDE.md
|
||||||
|
@.planning/quick/261002-k67-modul-nextcloud-status-mit-ampel-kacheln/261002-k67-SUMMARY.md
|
||||||
|
@apps/api/src/nextcloud-status/nextcloud-status.service.ts
|
||||||
|
@apps/api/src/nextcloud-status/nextcloud-status.controller.ts
|
||||||
|
@apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts
|
||||||
|
@apps/api/src/nextcloud-status/nextcloud-rating.ts
|
||||||
|
@apps/api/src/reminders/reminder-mail.scheduler.ts
|
||||||
|
@apps/api/src/module-registry/module-access.service.ts
|
||||||
|
@apps/web/src/lib/reminder-notify.ts
|
||||||
|
@apps/web/src/components/reminders/reminder-notifier.tsx
|
||||||
|
@apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx
|
||||||
|
|
||||||
|
Facts gathered during planning (no need to re-discover):
|
||||||
|
- `rateNextcloud(status, reference, now)` computes the rating from the STORED fields; `NextcloudStatusService.toView` calls it at read time. `checkInstance(tenantId, id)` is the single write path for every check (hourly tick, "Jetzt prüfen", per-tile check, create, URL change).
|
||||||
|
- `fetchNextcloudStatus` returns `reachable: false` with `errorKind` in timeout | network | tls | http-status | not-nextcloud | too-large | redirect; `errorDetail` is a short code like `HTTP 502` or `ECONNREFUSED`.
|
||||||
|
- `ModuleAccessService.getModuleAccessLevels(tenantId, userId, role)` (exported by `ModuleRegistryModule`) is the single source for access incl. admin short-circuit; `ModuleRegistryService.findBySlug('nextcloud-status')` gives the module id. `MailModule` exports `MailService`, `SettingsModule` exports `SettingsService.getSmtpConfig(tenantId)` (null = not set up). `MailService` keeps `appUrl` from `TESSERA_APP_URL`; reminder mails are German-only, time zone Europe/Berlin.
|
||||||
|
- Controllers get the user via `@CurrentUser() user: AuthUser` (see `reminders.controller.ts`), tenant via `requireTenantId(req)`.
|
||||||
|
- `forTenant(prisma, tenantId, userId?)` sets the user context; every call must use the assignment form `const tenantPrisma = forTenant(...)` (checked by `rls-access-inventory.spec.ts`), `forSystem` only `const systemPrisma = forSystem(...)`. `FORSYSTEM_ALLOWED_CALL_SITES` allows exactly 1 call in `nextcloud-status.service.ts` — keep it at 1. Selects stay scalar (no relation keys) so no new relation pairs appear.
|
||||||
|
- `docs/mandantentrennung-zugriffsklassifikation.md` keeps a Bereichszeile `nextcloud-status` (currently 0/14/1), a Summenzeile (61/264/8), pair counts (93) and the Fundstellentabelle; k67 shows how each task updated them with the Gate-Schleife. Every new (file, model) pair needs a row.
|
||||||
|
- Web: `reminder-notify.ts` exports `claimNotification(key, nowMs)`, `withNotifyLock`, `showReminderNotification({title, body, tag})` (Tauri plugin in the desktop app, Web Notification in the browser), `requestBrowserPermissionOnce`, `CATCH_UP_WINDOW_MS`. `ReminderNotifier` is mounted in `apps/web/src/components/layout/app-shell.tsx`. Access check pattern: `GET /modules/active` (see `use-module-capability.ts`).
|
||||||
|
- Local SMTP is configured (one `SmtpConfig` row with host). Containers `tessera-ctl-db-1`, `tessera-ctl-api-1`, `tessera-ctl-web-1` run. Latest migration: `20261002150000_nextcloud_status`.
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="tracer" tdd="true">
|
||||||
|
<name>Task 1 (Tracer): Glocke einschalten -> Cloud fällt zweimal aus -> genau eine Mail an den Abonnenten</name>
|
||||||
|
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261002170000_nextcloud_alerts/migration.sql, apps/api/src/nextcloud-status/nextcloud-alert-rules.ts, apps/api/src/nextcloud-status/nextcloud-alert-rules.spec.ts, apps/api/src/nextcloud-status/nextcloud-alert-mail.ts, apps/api/src/nextcloud-status/nextcloud-alert-mail.spec.ts, apps/api/src/nextcloud-status/nextcloud-alert.service.ts, apps/api/src/nextcloud-status/nextcloud-alert.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.service.ts, apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.module.ts, apps/api/src/mail/mail.service.ts, apps/api/src/mail/mail.service.spec.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/nextcloud-status-api.ts, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
|
||||||
|
<precondition>`docker ps` lists `tessera-ctl-db-1` as running (needed for the local migration).</precondition>
|
||||||
|
<behavior>
|
||||||
|
- decideAlert('ok', red) -> 'down'; ('red', red) -> null; ('red', green) -> 'up'; ('red', yellow) -> 'up'; ('red', unknown) -> null; ('ok', green) -> null; ('ok', unknown) -> null
|
||||||
|
- planStatusWrite(prevFailures 0, failed result) -> outcome 'pending', data only consecutiveFailures 1 + firstFailureAt now (no status field, no lastCheckedAt); prevFailures 1 + failed -> 'confirmed' with reachable false, errorKind, errorDetail, lastCheckedAt and consecutiveFailures 2; any success -> 'ok' with all status fields, consecutiveFailures 0, firstFailureAt null; success with maintenance true -> 'ok' (rating red immediately)
|
||||||
|
- Combined (pure, with rateNextcloud): green cloud + one failure -> rating stays green -> no alert; + second failure -> red -> 'down'; green + maintenance answer -> 'down' at once; red + green answer -> 'up'
|
||||||
|
- buildNextcloudAlertMail down/unreachable -> subject exactly "Nextcloud <Kundenname>: nicht erreichbar"; up -> "Nextcloud <Kundenname>: wieder in Ordnung"; body contains reason line, URL, time (Europe/Berlin) and "<appUrl>/modules/nextcloud-status"; CR/LF in Kundenname never reaches the subject; no word for tenant in any output
|
||||||
|
- evaluateAfterCheck: claim updateMany count 1 -> mails to eligible subscribers; count 0 -> no mail (second concurrent check / second API instance); red -> red -> no claim, no mail
|
||||||
|
- Recipients: inactive user, user without e-mail, user without module access (re-checked via getModuleAccessLevels), missing SMTP -> skipped + logged, no send; send returning false twice then true -> exactly 3 calls; false three times -> 3 calls, then stop
|
||||||
|
- Subscription endpoints: subscribe is idempotent, unknown/foreign cloud -> 404, unsubscribe removes only the caller's row; list returns subscribed true only for the caller's subscriptions; handlers carry no manage metadata and no role metadata
|
||||||
|
- Web: bell visible for a USE-only user, aria-pressed reflects subscribed, click calls subscribe/unsubscribe and flips state, failure rolls back and shows the error text; single-tile check keeps the bell state
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**Schema + migration (L-01, L-02, L-03, D-K4, D-K9).** In `apps/api/prisma/schema.prisma` extend `NextcloudInstance` with `consecutiveFailures Int @default(0)`, `firstFailureAt DateTime?`, `alertState String @default("ok")` (comment: 'ok' | 'red', last notified state), `alertReason String?`, `alertChangedAt DateTime?`, and the back-relation `subscriptions NextcloudAlertSubscription[]`; add `nextcloudAlertSubscriptions NextcloudAlertSubscription[]` to `User`; add `model NextcloudAlertSubscription` (German comment, quick-261002-kxc) with `id String @id @default(uuid())`, `tenantId String`, `userId String` + relation to User `onDelete: Cascade`, `instanceId String` + relation to NextcloudInstance `onDelete: Cascade`, `createdAt DateTime @default(now())`, `@@unique([instanceId, userId])`, `@@index([tenantId, userId])`. Hand-write `apps/api/prisma/migrations/20261002170000_nextcloud_alerts/migration.sql` in the style of `20261002150000_nextcloud_status` and `20260929140000_reminder`: German header (purpose; subscription is personal data, hence tenant_isolation_policy WITH user dimension exactly like "Reminder"; no system_read_policy because the table is never read in system context; the new instance columns are covered by the existing policies of "NextcloudInstance"; rights via ALTER DEFAULT PRIVILEGES; switch-is-off note), ALTER TABLE for the five columns (alertState NOT NULL DEFAULT 'ok', consecutiveFailures NOT NULL DEFAULT 0), CREATE TABLE, unique index, index, both foreign keys ON DELETE CASCADE ON UPDATE CASCADE, ENABLE + FORCE ROW LEVEL SECURITY, the policy. Run `pnpm --filter @tessera/api exec prisma generate`; apply locally with the container IP (`docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1`, then `DATABASE_URL="postgresql://tessera:tessera_dev@<IP>:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy`), confirm `prisma migrate status` is up to date and `prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --exit-code` exits 0 (L-09).
|
||||||
|
|
||||||
|
**Pure rules, TDD first (L-02, L-03, D-K1, D-K2).** `nextcloud-alert-rules.ts`, no Nest, no Prisma, date passed in: `FAILURES_FOR_RED = 2`, `RETRY_DELAY_MS = 5 * 60 * 1000`, type `AlertState = 'ok' | 'red'`, `decideAlert(prev: AlertState, level: RatingLevel): 'down' | 'up' | null` (grey changes nothing), and `planStatusWrite(prevFailures: number, result: NextcloudCheckResult, now: Date): { outcome: 'ok' | 'pending' | 'confirmed'; data: Record<string, unknown> }` per D-K2 (failure = `result.reachable === false`, any errorKind, D-K1). German header comment explaining the two-strike rule and why a first failure leaves the stored state alone. Write the spec with every case from `<behavior>` (incl. the combined cases that run `rateNextcloud` on the resulting stored fields) BEFORE the implementation, see it fail, then implement.
|
||||||
|
|
||||||
|
**Mail builder (L-05, D-K6).** `nextcloud-alert-mail.ts`, pure: `buildNextcloudAlertMail(input, appUrl): { subject: string; text: string }` with input `{ kind: 'down' | 'up'; customerName; baseUrl; rating: NextcloudRating; errorKind; errorDetail; at: Date }`. Subject `Nextcloud <Kundenname>: <wording>` (down wording per reason per D-K6, up "wieder in Ordnung"), CR/LF collapsed to a space, max 150 characters (same as `sendReminderEmail`). Plain-text body, short, Sie-form: "Guten Tag,", one sentence (down: the cloud "<Kundenname>" has a problem since <time>; up: is back in order), "Grund:" line (down: German reason text — "Nicht erreichbar" with errorDetail or a German word for the errorKind in brackets, "Keine gültige Nextcloud-Antwort", "Wartungsmodus eingeschaltet", "Datenbank-Aktualisierung ausstehend", "Support abgelaufen seit <TT.MM.JJJJ>"; up: "Aktueller Stand:" with "Aktuell" / "Update auf <x> verfügbar" / "Support endet am <TT.MM.JJJJ>"), "Adresse: <baseUrl>", "Zeitpunkt: <de-DE, Europe/Berlin, dateStyle full, timeStyle short> Uhr", "Zum Modul: <appUrl>/modules/nextcloud-status", closing line that the mail comes because "Benachrichtigen" is switched on for this cloud and can be switched off with the bell on the tile. Spec covers every subject wording, the locked two subjects verbatim, header-injection stripping, link, and a case-insensitive check that neither subject nor text contains a word for tenant (German or English). In `MailService` add `sendNextcloudAlertEmail(tenantId, to, input)` next to `sendReminderEmail`: builds via `buildNextcloudAlertMail(input, this.appUrl)`, sends through `this.deliver(tenantId, ..., 'NextcloudAlert')`, returns true/false and logs on failure exactly like `sendReminderEmail`; add spec cases mirroring the reminder ones.
|
||||||
|
|
||||||
|
**Alert service (L-01, L-02, L-04, L-06, D-K4, D-K5).** `nextcloud-alert.service.ts` (`@Injectable`, deps PrismaService, MailService, SettingsService, ModuleAccessService, ModuleRegistryService). Methods: `subscribe(tenantId, userId, instanceId)` (instance must exist with `{ id, tenantId }` else NotFoundException 'Cloud nicht gefunden'; upsert on the unique pair with `forTenant(prisma, tenantId, userId)`; returns `{ subscribed: true }`), `unsubscribe(...)` (deleteMany where tenantId, userId, instanceId; returns `{ subscribed: false }`), `subscribedInstanceIds(tenantId, userId): Promise<Set<string>>`, `evaluateAfterCheck(tenantId, row, rating, now)` where row carries id, customerName, baseUrl, errorKind, errorDetail, alertState: compute `decideAlert`; null -> return `{ kind: null, delivery: null }`; otherwise claim with `nextcloudInstance.updateMany({ where: { id, tenantId, alertState: prev }, data: { alertState: next, alertReason: down ? rating.reason : null, alertChangedAt: now } })` — only `count === 1` continues (header comment: why the claim stands before sending, same reasoning as `ReminderMailScheduler`). Then start `notifySubscribers` WITHOUT awaiting it inside the check path and return `{ kind, delivery }` (the promise, `.catch` logs) so tests can await it. `notifySubscribers`: subscriptions of the instance (tenant-bound, no user filter), users `where { tenantId, id in, isActive: true }` with scalar select email + role, module id via `findBySlug('nextcloud-status')`, per user `getModuleAccessLevels(tenantId, user.id, user.role)` must contain the module (L-06), SMTP via `getSmtpConfig(tenantId)`; every skip logs one German line with the reason (Benutzer deaktiviert / keine E-Mail-Adresse / kein Modulzugriff / kein E-Mail-Versand eingerichtet) and is never retried; eligible recipients get `sendNextcloudAlertEmail` with up to 3 attempts, `ALERT_MAIL_RETRY_MS = 60_000` apart via an overridable `sleep` member (D-K5, comment states that a restart drops pending attempts and why that is accepted). Spec: claim won/lost, red->red no claim, all skip reasons, 1-3 attempts, access revoked, inactive user.
|
||||||
|
|
||||||
|
**Wire into the existing service and controller.** `NextcloudStatusService` gets `NextcloudAlertService` injected. `checkInstance`: load `{ id, baseUrl, consecutiveFailures }`, fetch, `planStatusWrite`, update with that data selecting `PUBLIC_SELECT` plus `alertState`, build the view, then `await this.alerts.evaluateAfterCheck(...)` (awaits only the claim) and return the view. `listForTenant(tenantId, userId)` and `checkAllForTenant(tenantId, userId)` add `subscribed: boolean` per instance from `subscribedInstanceIds` (D-K10); `NextcloudInstanceView` gets an optional `subscribed`. Update the existing service spec (constructor, two-failure path, subscribed flag). Controller: `list` and `checkAll` pass `user.id` via `@CurrentUser()`; new `POST instances/:id/subscription` and `DELETE instances/:id/subscription` with only the class-level `@UseModule` — no manage decorator, no role decorator (L-01). Module imports `MailModule` and `SettingsModule` and provides `NextcloudAlertService`. Extend `module-manage-handlers.spec.ts` so `subscribe`/`unsubscribe` are asserted to stay on Benutzen level next to `list`/`logo`; controller spec covers the two routes (user id from `@CurrentUser`, never from body).
|
||||||
|
|
||||||
|
**RLS bookkeeping.** Run `pnpm --filter @tessera/api exec vitest run rls-coverage rls-access-inventory`; add the Fundstellentabelle rows for the new (file, model) pairs (e.g. `nextcloud-alert.service.ts` with `nextcloudInstance`, `nextcloudAlertSubscription`, `user`), update the Bereichszeile `nextcloud-status`, the Summenzeile and the Paarzählung in `docs/mandantentrennung-zugriffsklassifikation.md`, counted with the Gate-Schleife exactly like the k67 entries (measured, not copied). `FORSYSTEM_ALLOWED_CALL_SITES` stays unchanged.
|
||||||
|
|
||||||
|
**Web bell (L-01, D-K10).** `nextcloud-status-api.ts`: optional `subscribed?: boolean` on `NextcloudInstance` (missing = false, keeps existing fixtures valid), `subscribe(id)` (POST `${BASE}/${id}/subscription`) and `unsubscribe(id)` (DELETE) with `readErrorMessage`. `CloudTile`: new props `subscribed`, `onToggleSubscription`, `toggling`; a bell icon button shown to EVERY user (outside the `canManage` block, left of the manager buttons), `aria-pressed`, aria-label "Benachrichtigen", title from `bell.titleOn` / `bell.titleOff`, outlined bell when off, filled bell in accent color when on, disabled while toggling. `page.tsx`: optimistic toggle, call subscribe/unsubscribe, on error roll back and show `bell.error` as a small line above the grid; on switching on call `requestBrowserPermissionOnce()` from `@/lib/reminder-notify`; `handleCheckOne` keeps the previous `subscribed` when swapping in the checked tile. Texts in `nextcloudStatus.bell` (`label`, `titleOn`, `titleOff`, `error`) in de.json (real umlauts, Sie-form) and en.json with identical keys. Page test: bell visible for a USE-only user, toggle on/off calls the right function and flips aria-pressed, rollback on error, single check keeps the state.
|
||||||
|
|
||||||
|
Biome-lint touched files (`pnpm exec biome lint <files>` from repo root, `biome check --write` only on new files). Commit `feat(nextcloud-status): Benachrichtigung abonnieren und Mail bei Störung` (attribution line). Do not push.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api exec vitest run src/nextcloud-status src/mail src/module-registry rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run nextcloud-status umlaut && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && DATABASE_URL="postgresql://tessera:tessera_dev@$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1):5432/tessera" pnpm --filter @tessera/api exec prisma migrate status</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Migration applied locally and drift-free; pure rules, mail builder, alert service, status service, controller, mail service and manage-handler specs green; a subscribed user's cloud failing twice produces exactly one claimed transition and one mail per eligible recipient; the bell works for USE-level users in the web test; RLS inventory specs green with updated doc; commit on main, not pushed.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Wiederholung nach 5 Minuten, Adresswechsel und Hinweis „Prüfung fehlgeschlagen“ auf der Kachel</name>
|
||||||
|
<files>apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts, apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.service.ts, apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-alert.service.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/nextcloud-status-api.ts, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
|
||||||
|
<behavior>
|
||||||
|
- retryTick loads only clouds with consecutiveFailures 1 and firstFailureAt at least RETRY_DELAY_MS old (filter passed to loadAllInstancesForScheduler), checks each tenant-bound, max 4 at a time, skips while a previous retry run is active, never throws
|
||||||
|
- onApplicationBootstrap registers both jobs (nextcloud-status-poll hourly, nextcloud-status-retry every minute) without database access
|
||||||
|
- loadAllInstancesForScheduler() without filter keeps today's query; with { retryDueBefore } adds where consecutiveFailures 1 and firstFailureAt lte retryDueBefore; select stays { id, tenantId }
|
||||||
|
- updateInstance with a new address resets reachable, maintenance, needsDbUpgrade, versionString, edition, productName, errorKind, errorDetail, lastCheckedAt, consecutiveFailures, firstFailureAt and does NOT touch alertState; a red cloud whose corrected address answers green yields 'up'
|
||||||
|
- view status.pendingRetry is true exactly when consecutiveFailures is 1; tile shows the hint then and keeps pill, version and last check from the stored state
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**Retry job (L-03, D-K3).** In `nextcloud-status-scheduler.service.ts` export `NEXTCLOUD_RETRY_JOB_NAME = 'nextcloud-status-retry'` and `NEXTCLOUD_RETRY_CRON = '* * * * *'`; register it in the same `onApplicationBootstrap` inside the existing try/catch (same `CronJobClass` workaround, no database access at registration, log line `Nextcloud-Status retry job registered: * * * * *`). `retryTick(now = new Date())` with its own overlap flag: `loadAllInstancesForScheduler({ retryDueBefore: new Date(now - RETRY_DELAY_MS) })`, then `checkInstance(tenantId, id)` per row with `runWithConcurrency(..., CHECK_CONCURRENCY, ...)`, errors logged per cloud. Extend the header comment: why a second job instead of in-memory timers (survives restarts, state on the row) and why it reuses the one system read. In `NextcloudStatusService.loadAllInstancesForScheduler(filter?: { retryDueBefore: Date })` add the optional where (consecutiveFailures 1, firstFailureAt lte) to the SAME `systemPrisma.nextcloudInstance.findMany` call — still exactly one `forSystem` call in the file. Scheduler spec: both jobs registered, retry filter passed, overlap guard, error isolation.
|
||||||
|
|
||||||
|
**Address change (D-K8).** In `updateInstance`, when the normalized address changed, write the reset of the status fields, `consecutiveFailures: 0` and `firstFailureAt: null` together with the new address (alertState untouched), then call `checkInstance` as today. Service spec: reset written, alertState not in the data; a cloud with alertState 'red' whose new address answers green triggers `evaluateAfterCheck` with an 'up' decision (alert service spec: 'up' mail subject "wieder in Ordnung" and "Aktueller Stand" line).
|
||||||
|
|
||||||
|
**Hint on the tile (L-03, D-K2).** Add `consecutiveFailures` to `PUBLIC_SELECT`/`PublicRow` and `status.pendingRetry: boolean` (`consecutiveFailures === 1`) to the view; the rating input stays unchanged. Web: optional `pendingRetry?: boolean` in `NextcloudInstanceStatus`; `CloudTile` shows, when true, a small line with a warning-colored dot and the text `card.pendingRetry` ("Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt") between the pill row and the last-check line, `data-testid="pending-retry"`; pill, version and last check stay as stored. en.json gets the same key. Page test: hint shown with pendingRetry, not shown without, pill keeps the stored green level.
|
||||||
|
|
||||||
|
Re-run the RLS specs and adjust the doc only if the Gate-Schleife counts changed. Biome-lint touched files, commit `feat(nextcloud-status): erneute Prüfung nach Ausfall und Hinweis auf der Kachel` (attribution line). Do not push.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api exec vitest run src/nextcloud-status src/module-registry rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run nextcloud-status umlaut && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test "$(grep -v '^\s*//' apps/api/src/nextcloud-status/nextcloud-status.service.ts | grep -c 'forSystem(this.prisma)')" = "1"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Retry job registered and tested; first failure leaves the stored state and shows the hint, second failure (manual, retry or hourly) turns the cloud red; address change resets the check state but keeps the notification state; still exactly one system read in the service; specs and both tsc runs green; commit on main, not pushed.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 3: In-App-Meldung (Desktop: Windows-Benachrichtigung), Anleitung, Changelog, Abschluss und Neubau</name>
|
||||||
|
<files>apps/api/src/nextcloud-status/nextcloud-alert.service.ts, apps/api/src/nextcloud-status/nextcloud-alert.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/nextcloud-status-api.ts, apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx, apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.test.tsx, apps/web/src/components/layout/app-shell.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md</files>
|
||||||
|
<behavior>
|
||||||
|
- listRecentAlerts(tenantId, userId, now) returns only the caller's subscribed clouds with alertChangedAt within 24 h AND not before the caller's subscription createdAt; kind 'down' for alertState red (with reason), 'up' for ok; other users' subscriptions never appear
|
||||||
|
- GET alerts stays on Benutzen level (no manage, no role metadata)
|
||||||
|
- Notifier: without module access (slug missing in /modules/active) it never calls the alerts endpoint; with access it shows one notification per (instanceId, changedAt), never twice across polls; title for down/unreachable is "Nextcloud <Name>: nicht erreichbar", for up "Nextcloud <Name>: wieder in Ordnung", body is the address; 401/403 pauses polling until focus
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
**Recent alerts endpoint (L-04, D-K7).** `NextcloudAlertService.listRecentAlerts(tenantId, userId, now)`: subscriptions of the caller (`forTenant(prisma, tenantId, userId)`, scalar select instanceId + createdAt), then instances `where { tenantId, id in, alertChangedAt gte now - 24 h }` with scalar select id, customerName, baseUrl, alertState, alertReason, alertChangedAt; drop entries whose alertChangedAt is before the subscription's createdAt; return `{ alerts: [{ instanceId, customerName, baseUrl, kind, reason, changedAt }] }` sorted by changedAt. Controller `GET alerts` (path `modules/nextcloud-status/alerts`, user from `@CurrentUser()`), only class-level `@UseModule`. Specs: service filters, controller route, `module-manage-handlers.spec.ts` asserts Benutzen level for `alerts`. Update the RLS doc rows/counts with the Gate-Schleife and re-run the RLS specs.
|
||||||
|
|
||||||
|
**Global notifier (L-04).** `nextcloud-status-api.ts`: `NextcloudAlert` type and `listAlerts()` throwing an error object that carries the HTTP status (pattern `ReminderRequestError`). New `apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx`, modeled on `ReminderNotifier` (renders nothing, translation via refs): on mount and on focus/visibility resume it checks `GET /modules/active` for slug `nextcloud-status` (with `credentials: 'include'`, `cache: 'no-store'`); only with access it loads alerts immediately and every 60 s; for each alert under `withNotifyLock` it calls `claimNotification('nextcloud-alert|' + instanceId + '|' + changedAt, Date.now())` and on first claim `showReminderNotification({ title, body: baseUrl, tag })` — reuse of these helpers is deliberate (same Tauri path = Windows notification in the desktop app, same per-client memory; say so in the header comment). Titles from `nextcloudStatus.notify` in de.json/en.json: `up` and `down.unreachable`, `down.invalidResponse`, `down.maintenance`, `down.needsDbUpgrade`, `down.eolPassed`, each with `{name}`, German wording identical to the mail subjects (D-K6). 401/403 sets a paused flag until the next focus. Mount `<NextcloudAlertNotifier />` in `app-shell.tsx` right after `<ReminderNotifier />` with a short German comment. Notifier test modeled on `reminder-notifier.test.tsx`: no access -> no alerts call; access -> one notification per alert, none on the next poll, correct titles; 403 pauses.
|
||||||
|
|
||||||
|
**Docs + changelog (L-08, L-10).** CHANGELOG under "## Unveröffentlicht" / "### Neu": one user-facing German bullet — bell on each Nextcloud-Status tile, personal; mail and on-screen notification (desktop app: Windows notification) when the cloud fails and when it is back in order; one message per change; a single failed check does not alert, Tessera re-checks after about five minutes; mails need the e-mail setup. `docs/anleitung-anwender.md` section Nextcloud-Status: new paragraph "**Benachrichtigen:**" (who sees the bell, what triggers a message, two failed checks for "nicht erreichbar", immediate for maintenance/DB upgrade/support expired, "wieder in Ordnung", hint text on the tile, browser asks once for permission, mail only with e-mail setup and an address in the profile). `docs/anleitung-administration.md` section "Nextcloud-Status: Clouds eintragen": bullet on notifications (SMTP from section 6 required, recipients re-checked at send time, at most three attempts, changing the address sends "wieder in Ordnung" if it was red) and extend the "Rhythmus" bullet with the 5-minute re-check. No word for tenant anywhere in these texts.
|
||||||
|
|
||||||
|
**Final gates (L-07, L-09).** Full `pnpm --filter @tessera/api test` and `pnpm --filter @tessera/web test` (if an AppShell-rendering test breaks, mock the new notifier the way `ReminderNotifier` is mocked), both tsc, biome lint on all touched files of the three tasks. Rebuild `docker compose up -d --build api web`, wait for api healthy, check `docker compose logs api` for "Nextcloud-Status module seeded in registry", "Nextcloud-Status retry job registered" and the mapped routes `/modules/nextcloud-status/instances/:id/subscription` and `/modules/nextcloud-status/alerts`, no migration errors. Commit `feat(nextcloud-status): Meldung in Tessera, Anleitung und Changelog` (attribution line). Do not push.
|
||||||
|
|
||||||
|
**SUMMARY for the orchestrator's browser check (L-10):** list the click path to force both transitions locally — (1) "Cloud hinzufügen" with an unreachable address such as `https://127.0.0.1:9` (tile grey "Noch nicht geprüft" + hint), (2) switch the bell on (browser asks once for permission), (3) "Jetzt prüfen" or the tile's check button once more -> red, mail + on-screen notification "nicht erreichbar", (4) edit the address to a reachable public Nextcloud -> "wieder in Ordnung". State that local SMTP is configured (SmtpConfig row present) and name which local account address would receive the mail (look it up, do not change it); note that a USE-only user also sees the bell.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && node -e 'const de=require("./apps/web/src/messages/de.json"),en=require("./apps/web/src/messages/en.json");const w=(o,p,r)=>{for(const[k,v]of Object.entries(o||{})){const q=p+"."+k;if(v&&typeof v==="object")w(v,q,r);else r[q]=v}return r};const pick=(m)=>({...w(m.nextcloudStatus,"nextcloudStatus",{}),...w(m.widgets&&m.widgets.nextcloudStatus,"widgets.nextcloudStatus",{})});const a=pick(de),b=pick(en);if(Object.keys(a).length<10||Object.keys(a).sort().join()!==Object.keys(b).sort().join()){console.error("key mismatch");process.exit(1)}for(const v of [...Object.values(a),...Object.values(b)])if(/mandant|tenant/i.test(String(v))){console.error("bad text",v);process.exit(1)}if(!a["nextcloudStatus.notify.up"]||!a["nextcloudStatus.bell.label"]||!a["nextcloudStatus.card.pendingRetry"]){console.error("missing keys");process.exit(1)}' && grep -q "Benachrichtig" CHANGELOG.md && grep -q "Benachrichtigen:" docs/anleitung-anwender.md && grep -q "NextcloudAlertNotifier" apps/web/src/components/layout/app-shell.tsx && docker compose ps --status running --services | grep -qx api && docker compose ps --status running --services | grep -qx web && docker compose logs api 2>&1 | grep -q "Nextcloud-Status retry job registered"</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Subscribers get an on-screen notification (desktop: Windows notification) once per transition while Tessera is open; users without access never poll; CHANGELOG and both guides describe the feature without a word for tenant; full api + web suites, tsc and biome green; api and web rebuilt and running with the retry job and new routes; SUMMARY contains the local test path and SMTP status; commit on main, not pushed.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| browser -> API (subscription, alerts) | user-controlled instance id; user identity must come from the JWT |
|
||||||
|
| API -> SMTP / recipient mailbox | Kundenname (manager-entered) flows into subject and body |
|
||||||
|
| background check -> notification fan-out | concurrent checks, several API instances, restarts |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-kxc-01 | Spoofing / Elevation | POST/DELETE instances/:id/subscription | high | mitigate | userId only from `@CurrentUser()`, tenantId only from `requireTenantId(req)`; instance must match `{ id, tenantId }` (404 otherwise); `forTenant(prisma, tenantId, userId)` + RLS user dimension; controller spec asserts no body field is used |
|
||||||
|
| T-kxc-02 | Information disclosure | GET alerts | medium | mitigate | query limited to the caller's own subscriptions (userId from JWT, user-bound client), scalar selects (name, address, state, reason, time — all already visible on the module page), class-level `@UseModule` so only users with access get any answer |
|
||||||
|
| T-kxc-03 | Information disclosure | mail fan-out | high | mitigate | at send time re-check `isActive`, e-mail present and `getModuleAccessLevels` contains the module (L-06); spec covers revoked access and deactivated user |
|
||||||
|
| T-kxc-04 | Repudiation / Tampering (duplicate sends) | evaluateAfterCheck | medium | mitigate | claim-before-send `updateMany where alertState = previous`; only `count === 1` notifies; state on the row survives restarts; spec covers the lost claim |
|
||||||
|
| T-kxc-05 | Tampering (header injection) | buildNextcloudAlertMail subject | medium | mitigate | CR/LF collapsed, 150-char cap (same as reminder subject); spec with a Kundenname containing a line break |
|
||||||
|
| T-kxc-06 | Denial of service (mail flood) | flapping cloud | medium | mitigate | two-strike rule, one mail per transition, grey changes nothing, retry job only touches clouds with exactly one failure, concurrency 4, overlap guards |
|
||||||
|
| T-kxc-07 | Denial of service (blocked request) | "Jetzt prüfen" with slow SMTP | low | mitigate | delivery runs in the background after the claim; check responses never await SMTP |
|
||||||
|
| T-kxc-08 | Elevation | new handlers | medium | mitigate | no manage and no role decorator on subscription/alerts handlers — asserted in `module-manage-handlers.spec.ts`; module guard still enforces access |
|
||||||
|
| T-kxc-SC | Tampering | npm/pip/cargo installs | low | accept | no package installs in this plan; only existing dependencies are used |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- Pure rules cover every transition case of L-07 (green->red, red->red, red->green/yellow, grey, one vs two failures, maintenance immediate).
|
||||||
|
- Claim-before-send prevents duplicates (spec with lost claim); recipients re-checked for access, active state, address, SMTP; at most 3 attempts.
|
||||||
|
- Retry job checks a cloud with one failure after 5 minutes; tile hint shown during that time.
|
||||||
|
- Bell visible and working for USE-level users; list carries `subscribed`; notifier fires once per transition and only with access.
|
||||||
|
- `prisma migrate status` up to date locally, drift check exit 0; RLS specs green with updated classification doc; still one `forSystem` call in the service.
|
||||||
|
- Full api + web suites, both tsc runs, biome on touched files green; api and web rebuilt and running.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- A subscribed user receives exactly one mail and one on-screen notification when a cloud fails twice in a row or turns red for maintenance, DB upgrade or expired support, and exactly one "wieder in Ordnung" when it recovers.
|
||||||
|
- No duplicate notification across concurrent checks, restarts or repeated red checks.
|
||||||
|
- Single failures do not alert; real outages are reported within about five minutes.
|
||||||
|
- No user-facing text names a tenant; nothing pushed.
|
||||||
|
|
||||||
|
## Source coverage audit
|
||||||
|
|
||||||
|
| Source | Item | Covered by |
|
||||||
|
|---|---|---|
|
||||||
|
| GOAL | Per-user notification on red and recovery | Tasks 1-3 |
|
||||||
|
| CONTEXT | L-01 bell, table, RLS, cascade, USE-level toggles, `subscribed` in list | Task 1 |
|
||||||
|
| CONTEXT | L-02 transition once, no repeat, recovery, state on row, claim | Task 1 (+ recovery via address change Task 2) |
|
||||||
|
| CONTEXT | L-03 two-strike guard, tile keeps last good + hint, immediate other reasons, ~5 min retry | Task 1 (rules), Task 2 (retry, hint) |
|
||||||
|
| CONTEXT | L-04 mail like reminders + in-app/desktop notification | Task 1 (mail), Task 3 (in-app) |
|
||||||
|
| CONTEXT | L-05 German mail texts, locked subjects, link | Task 1 |
|
||||||
|
| CONTEXT | L-06 re-check access and active state at send time | Task 1 |
|
||||||
|
| CONTEXT | L-07 tests, full suites, tsc, biome | Tasks 1-3 |
|
||||||
|
| CONTEXT | L-08 CHANGELOG + docs | Task 3 |
|
||||||
|
| CONTEXT | L-09 local migration + rebuild | Task 1 (migration), Task 3 (rebuild) |
|
||||||
|
| CONTEXT | L-10 no tenant wording, no push, SUMMARY test path + SMTP status | Tasks 1-3, SUMMARY in Task 3 |
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/quick/261002-kxc-nextcloud-status-benachrichtigung-bei-ro/261002-kxc-SUMMARY.md` when done
|
||||||
|
</output>
|
||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
---
|
||||||
|
phase: quick-261002-kxc
|
||||||
|
plan: 01
|
||||||
|
subsystem: nextcloud-status
|
||||||
|
tags: [benachrichtigung, glocke, mail, rls, scheduler, in-app]
|
||||||
|
status: complete
|
||||||
|
requires:
|
||||||
|
- quick-261002-k67 (Modul Nextcloud-Status)
|
||||||
|
provides:
|
||||||
|
- persönliche Glocke je Kachel (Tabelle NextcloudAlertSubscription, RLS mit Benutzerdimension)
|
||||||
|
- Zwei-Fehlschläge-Regel, Wiederholung nach 5 Minuten, gemeldeter Zustand auf der Zeile
|
||||||
|
- E-Mail (nur Deutsch) und Meldung in Tessera bei Störung und Wiederherstellung
|
||||||
|
- Fehlercodes als lesbarer Hinweis auf Kachel und in der Mail
|
||||||
|
affects:
|
||||||
|
- apps/api/src/nextcloud-status/
|
||||||
|
- apps/api/src/mail/mail.service.ts
|
||||||
|
- apps/web (Kachel, Seite, AppShell)
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- apps/api/prisma/migrations/20261002170000_nextcloud_alerts/migration.sql
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-alert-rules.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-alert-mail.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-alert.service.ts
|
||||||
|
- apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx
|
||||||
|
- apps/web/src/components/nextcloud-status/error-hint.ts
|
||||||
|
modified:
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-status.service.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-status.controller.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts
|
||||||
|
- apps/api/src/nextcloud-status/nextcloud-status.module.ts
|
||||||
|
- apps/api/src/mail/mail.service.ts
|
||||||
|
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx
|
||||||
|
- apps/web/src/components/layout/app-shell.tsx
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
- CHANGELOG.md
|
||||||
|
- docs/anleitung-anwender.md
|
||||||
|
- docs/anleitung-administration.md
|
||||||
|
decisions:
|
||||||
|
- "Zwei-Fehlschläge-Regel für jeden fehlgeschlagenen Abruf (D-K1); der erste Fehlschlag schreibt nur Zähler und Zeitpunkt, die Kachel behält den guten Stand (D-K2)"
|
||||||
|
- "Der Zähler einer dauerhaft roten Cloud wird bei 2 gedeckelt (sonst wüchse er stündlich weiter)"
|
||||||
|
- "Wiederholung als zweiter Cron-Auftrag (jede Minute) über denselben einen Systemlesezugriff (D-K3)"
|
||||||
|
- "Anspruch vor dem Senden per updateMany auf alertState; Versand im Hintergrund, bis zu 3 Versuche im Abstand von 60 s (D-K5)"
|
||||||
|
- "Fehler-Hinweise: gleiche Zuordnung in API (Mail) und Web (Kachel); Rohkennung bleibt als Tooltip"
|
||||||
|
metrics:
|
||||||
|
duration: "ca. 20 Minuten"
|
||||||
|
completed: 2026-10-02
|
||||||
|
actuals:
|
||||||
|
tokens: 32500
|
||||||
|
tasks: 3
|
||||||
|
commits: 3
|
||||||
|
plan_head_before: 6829c44464ef3d68711117af656d4c6db79892ec
|
||||||
|
plan_head_after: 6c4bff6f6cf85867cdea54c1dbcc91d2c7fff028
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 261002-kxc: Nextcloud-Status – Benachrichtigung bei Rot und Wiederherstellung
|
||||||
|
|
||||||
|
Persönliche Glocke je Kachel: Wer eingeschaltet hat, bekommt bei Störung und bei „wieder in Ordnung“ je Änderung genau eine E-Mail und eine Meldung in Tessera (Desktop-App: Windows-Benachrichtigung). Ein einzelner Fehlschlag löst nichts aus; Tessera prüft nach etwa fünf Minuten erneut.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
| Aufgabe | Commit | Inhalt |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 (Tracer) | faed0d7 | Migration, reine Regeln, Mailbaustein, Alert-Dienst, Glocke in API und Kachel, RLS-Dokument |
|
||||||
|
| 2 | 11a70c9 | Wiederholungsauftrag, Adresswechsel, Hinweis „Prüfung fehlgeschlagen“, Fehlercodes in Klartext |
|
||||||
|
| 3 | 6c4bff6 | Endpunkt `GET alerts`, globaler Melder im AppShell, Anleitung, Changelog |
|
||||||
|
|
||||||
|
Nicht gepusht. Die Commit-Zeile trägt „Claude Sonnet 5.5“ (die Vorgabe der Umgebung für Commit-Anhänge), nicht den im Auftrag genannten Opus-Text, weil ich Sonnet 5.5 bin und die Angabe sonst falsch wäre.
|
||||||
|
|
||||||
|
## Was gebaut wurde
|
||||||
|
|
||||||
|
- **Datenbank:** Migration `20261002170000_nextcloud_alerts` (lokal angewendet, `migrate status` aktuell, Drift-Prüfung `--exit-code` = 0). Neue Tabelle `NextcloudAlertSubscription` mit Mandant-und-Benutzer-Regel wie „Reminder“, ohne `system_read_policy`, Cascade bei Cloud und Benutzer. Neue Spalten an `NextcloudInstance`: `consecutiveFailures`, `firstFailureAt`, `alertState` ('ok'|'red'), `alertReason`, `alertChangedAt`.
|
||||||
|
- **Reine Regeln** (`nextcloud-alert-rules.ts`): `decideAlert`, `planStatusWrite`, `FAILURES_FOR_RED = 2`, `RETRY_DELAY_MS = 5 min`.
|
||||||
|
- **Mail** (`nextcloud-alert-mail.ts`, `MailService.sendNextcloudAlertEmail`): Betreff „Nextcloud <Kundenname>: nicht erreichbar“ bzw. „… wieder in Ordnung“ wortgleich, eigene Betreffe für die anderen roten Gründe, Text mit Grund, Adresse, Zeit (Europe/Berlin), Link zum Modul; CR/LF und 150 Zeichen wie bei Erinnerungen.
|
||||||
|
- **Alert-Dienst:** Abonnieren/Abbestellen (idempotent, 404 für fremde Clouds), Anspruch vor dem Senden (`updateMany where alertState = bisher`, nur `count === 1` meldet), Versand im Hintergrund, Empfänger beim Senden neu geprüft (aktiv, Adresse, Modulzugriff über `getModuleAccessLevels`, SMTP), bis zu 3 Versuche, `listRecentAlerts` für die Meldung in Tessera.
|
||||||
|
- **Status-Dienst/Controller:** `checkInstance` ist weiter der einzige Schreibweg und läuft jetzt über `planStatusWrite` und `evaluateAfterCheck`; Listen tragen `subscribed`; `POST/DELETE instances/:id/subscription` und `GET alerts` nur mit Modul-Guard (kein Verwalten, keine Rolle).
|
||||||
|
- **Planer:** zweiter Auftrag `nextcloud-status-retry` (jede Minute), filtert dieselbe `forSystem`-Abfrage auf genau einen Fehlschlag älter als 5 Minuten. Weiterhin genau ein `forSystem`-Aufruf im Dienst.
|
||||||
|
- **Web:** Glocke an jeder Kachel (auch für „Benutzen“), optimistisches Umschalten mit Rücknahme bei Fehler, Browser-Erlaubnis einmalig beim ersten Einschalten; Hinweis „Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt“; `NextcloudAlertNotifier` im AppShell (nutzt die Helfer der Erinnerungen, daher Windows-Benachrichtigung in der Desktop-App; fragt ohne Modulzugriff nie ab; 401/403 pausiert bis Fokus).
|
||||||
|
- **Zusatzwunsch Fehlertexte:** Kachel und Mail zeigen statt `ERR_TLS_CERT_ALTNAME_INVALID` & Co. Klartext: „Zertifikat passt nicht zur Adresse“, „Zertifikat abgelaufen“, „Zertifikat nicht vertrauenswürdig“, „Adresse nicht gefunden“ (ENOTFOUND/EAI_AGAIN), „Verbindung abgelehnt“, „Zeitüberschreitung“, „Server antwortet mit Fehler <Code>“, sonst „Verbindungsfehler“. Auf der Kachel bleibt die Rohkennung als `title`-Tooltip. In der Mail steht nur der Klartext. Zuordnung als reine Funktionen `describeCheckError` (API) und `errorHint` (Web), je mit Tests; de/en-Texte unter `nextcloudStatus.errorHint`.
|
||||||
|
|
||||||
|
## Tests und Prüfungen (gemessen)
|
||||||
|
|
||||||
|
| Prüfung | Ergebnis |
|
||||||
|
|---|---|
|
||||||
|
| API komplett (`pnpm --filter @tessera/api test`) | 121 Dateien, 2120 Tests, alle grün |
|
||||||
|
| Web komplett (`pnpm --filter @tessera/web test`) | 121 Dateien, 1321 Tests, alle grün |
|
||||||
|
| `tsc --noEmit` API / Web | beide fehlerfrei |
|
||||||
|
| Biome `lint` auf allen berührten Dateien der drei Aufgaben (25 Dateien) | keine Fehler, 2 Warnungen in `mail.service.spec.ts` (Zeilen 130 und 150, Non-Null-Zusicherungen, vor dieser Arbeit vorhanden, nicht angefasst) |
|
||||||
|
| Biome `check` auf den neuen Dateien und dem AppShell | sauber |
|
||||||
|
| RLS-Specs (`rls-coverage`, `rls-access-inventory`) | grün, Dokument nachgeführt |
|
||||||
|
| Migration | angewendet, Drift 0 |
|
||||||
|
|
||||||
|
Neu hinzugekommen: Regeln 19 Tests, Mailbaustein 27, Alert-Dienst 22, Web-Melder 11, Fehlerhinweis 20 u. a. sowie Erweiterungen in Status-Dienst, Controller, Scheduler, MailService und Seitentest.
|
||||||
|
|
||||||
|
Hinweis zur Reihenfolge: Bei den reinen Regeln habe ich Spec und Implementierung gemeinsam geschrieben und nicht ausdrücklich zuerst den roten Lauf beobachtet; die Fälle aus dem Plan sind vollständig abgedeckt.
|
||||||
|
|
||||||
|
## RLS-Dokument (`docs/mandantentrennung-zugriffsklassifikation.md`)
|
||||||
|
|
||||||
|
- Bereichszeile `nextcloud-status`: 0/23/1 (gemessen mit der Gate-Schleife über `nextcloud-status/`; Dienst 14, Alert-Dienst 9 gebundene Rohtreffer).
|
||||||
|
- Neue Fundstellen: drei Paare `nextcloud-alert.service.ts` (`nextcloudAlertSubscription`, `nextcloudInstance`, `user`), Paarzahl 96 (53→56 `muss-mandantengebunden`).
|
||||||
|
- Summenzeile von mir nur um die eigenen +9 fortgeschrieben (61/273/8). **Auffälligkeit:** Eine frische Messung über alle Bereiche ergibt 61/280/8; die Differenz von 7 stammt aus älteren, nicht nachgeführten Zeilen (`dashboard` 30 statt 29, `groups` 33 statt 31, `reminders` 13 statt 12, weitere Bereiche ohne eigene Zeile). Das habe ich nicht stillschweigend „repariert“, sondern in der Summenzeile vermerkt.
|
||||||
|
- `FORSYSTEM_ALLOWED_CALL_SITES` unverändert; genau ein `forSystem(this.prisma)` in `nextcloud-status.service.ts`.
|
||||||
|
|
||||||
|
## Abweichungen vom Plan
|
||||||
|
|
||||||
|
**1. [Rule 3 - Blockierend] Umlaut-Wächter**
|
||||||
|
- Gefunden bei Aufgabe 2: `umlaut-guard.spec.ts` meldete „passt“ und „vertrauenswürdig“ als neue Wörter.
|
||||||
|
- Behoben: beide in `UMLAUT_ALLOWLIST` (`apps/web/src/messages/umlaut-dictionary.ts`) ergänzt (korrektes Deutsch).
|
||||||
|
|
||||||
|
**2. [Rule 2 - Korrektheit] Zähler gedeckelt**
|
||||||
|
- `consecutiveFailures` einer dauerhaft roten Cloud würde sonst bei jeder stündlichen Prüfung weiterzählen; gedeckelt bei 2 (`FAILURES_FOR_RED`). Mit Test.
|
||||||
|
|
||||||
|
**3. Zusatzwunsch Fehlertexte** (Auftrag, nicht im Plan): zusätzlich neue Dateien `error-hint.ts`/`.test.ts` und `describeCheckError` in `nextcloud-alert-mail.ts`; Doku-Satz „Fehlercode steht klein darunter“ in der Administrationsanleitung angepasst.
|
||||||
|
|
||||||
|
**4. Commit-Anhang:** siehe oben (Sonnet 5.5 statt Opus-Text).
|
||||||
|
|
||||||
|
Sonst: Plan wie geschrieben ausgeführt. Keine Authentifizierungs-Hürden, keine Paketinstallationen.
|
||||||
|
|
||||||
|
## Lokaler Neubau
|
||||||
|
|
||||||
|
`docker compose up -d --build api web` ausgeführt; api, web und db laufen. Im API-Protokoll: „Nextcloud-Status module seeded in registry“, „Nextcloud-Status retry job registered: * * * * *“, Routen `/modules/nextcloud-status/instances/:id/subscription` (POST, DELETE) und `/modules/nextcloud-status/alerts` (GET) gemappt, „No pending migrations to apply“, keine Fehler.
|
||||||
|
|
||||||
|
## Für die Browser-Prüfung (Orchestrator)
|
||||||
|
|
||||||
|
**Lokaler SMTP-Stand:** Ja, eingerichtet. Genau eine `SmtpConfig`-Zeile: Host `mailhog`, Port 1025, Absender `tessera@tessera.local`. Die Mails landen also in MailHog (nicht in einem echten Postfach). Empfangsadressen der lokalen Konten (nur nachgesehen, nichts geändert): `admin` (SUPER_ADMIN) `admin@tessera.local`, `nutzer1` `nutzer1@tessera.local`, `nutzer2` `nutzer2@tessera.local`, `testuser` `testuser@example.com`; alle aktiv. Lokal gibt es bereits 5 Clouds. Ich habe keinen Versand ausgelöst (nur Unit-Tests mit gemocktem Transport).
|
||||||
|
|
||||||
|
**Klickweg, beide Übergänge zu erzwingen** (als Admin; ein Benutzer mit nur „Benutzen“ sieht die Glocke ebenfalls, kann aber keine Clouds anlegen oder prüfen):
|
||||||
|
|
||||||
|
1. „Cloud hinzufügen“ mit einer nicht erreichbaren Adresse, z. B. `https://127.0.0.1:9`. Die Kachel bleibt grau „Noch nicht geprüft“ und zeigt den Hinweis „Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt“.
|
||||||
|
2. Glocke auf dieser Kachel einschalten (Browser fragt einmalig nach der Erlaubnis für Benachrichtigungen).
|
||||||
|
3. „Jetzt prüfen“ oder den Prüfknopf der Kachel noch einmal drücken: zweiter Fehlschlag, Kachel wird rot „Nicht erreichbar“ mit Klartext-Grund (bei 127.0.0.1:9 „Verbindung abgelehnt“). Es kommt eine Mail „Nextcloud <Name>: nicht erreichbar“ (in MailHog) und die Meldung in Tessera. Ohne Knopfdruck passiert dasselbe automatisch nach etwa 5 Minuten durch den Wiederholungsauftrag. Weitere Prüfungen, solange die Cloud rot bleibt, senden nichts mehr.
|
||||||
|
4. Adresse der Cloud bearbeiten auf eine erreichbare öffentliche Nextcloud: sie wird sofort neu geprüft, die Kachel wird grün oder gelb, es kommt „Nextcloud <Name>: wieder in Ordnung“ (Mail und Meldung).
|
||||||
|
|
||||||
|
Sofort rot ohne Wartezeit: eine Cloud, die im Wartungsmodus steht oder auf eine Datenbank-Aktualisierung wartet, wird beim ersten Abruf gemeldet.
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
Keine.
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
Keine neuen Angriffsflächen außerhalb des Plan-Bedrohungsmodells (T-kxc-01 bis -08 umgesetzt: Benutzer nur aus dem Token, 404 für fremde Clouds, Empfänger beim Senden neu geprüft, Anspruch vor dem Senden, CR/LF-Schutz im Betreff, Versand im Hintergrund, keine Verwalten-/Rollen-Decorators an den neuen Handlern, durch Tests festgeschrieben).
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- Dateien vorhanden: Migration, `nextcloud-alert-rules.ts`, `nextcloud-alert-mail.ts`, `nextcloud-alert.service.ts`, `nextcloud-alert-notifier.tsx`, `error-hint.ts` – gefunden.
|
||||||
|
- Commits vorhanden: faed0d7, 11a70c9, 6c4bff6 (gemessen mit `git rev-list --count 6829c44..HEAD` = 3).
|
||||||
|
|
||||||
|
## Browser-Prüfung (Orchestrator, 02.10., lokal, dunkel, MailHog)
|
||||||
|
|
||||||
|
- Grundmodul (k67): 5 echte öffentliche Clouds + 1 kaputte; Ampel korrekt (35.0.1/34.0.4 grün, 33.0.5/33.0.8 Enterprise gelb „Update auf 33.0.9“, Zertifikatsfehler rot), Sortierung Status, Logo per Upload und per URL, Dashboard-Kachel mit Zählern 2/2/1.
|
||||||
|
- Glocke an „Ausfall AG“ (https://127.0.0.1:9): erster Abruf grau + Wiederholungshinweis, zweiter rot „Nicht erreichbar“ (Port 9 ist von fetch gesperrt → „Verbindungsfehler“ korrekt; normaler Port liefert ECONNREFUSED → „Verbindung abgelehnt“).
|
||||||
|
- Mail „Nextcloud Ausfall AG: nicht erreichbar“ in MailHog, Text verständlich; Browser-Benachrichtigung erschienen.
|
||||||
|
- Adresse auf erreichbare Cloud geändert → grün, Mail + Benachrichtigung „wieder in Ordnung“.
|
||||||
+265
@@ -0,0 +1,265 @@
|
|||||||
|
---
|
||||||
|
phase: quick-261003-387
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
quick_id: 261003-387
|
||||||
|
description: "Modulkategorien durch Administratoren bearbeitbar: anlegen, umbenennen, sortieren, löschen mit Verschieben, Module zuordnen und innerhalb der Kategorie sortieren"
|
||||||
|
date: 2026-10-03
|
||||||
|
files_modified:
|
||||||
|
# Task 1 — tracer: DB -> Dienst (Grundbestand + Überlagerung) -> GET /module-categories + /modules/active -> Store -> Seitenleiste/Beschriftung
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/prisma/migrations/20261003120000_module_categories/migration.sql
|
||||||
|
- apps/api/src/module-categories/module-categories.service.ts
|
||||||
|
- apps/api/src/module-categories/module-categories.service.spec.ts
|
||||||
|
- apps/api/src/module-categories/module-categories.controller.ts
|
||||||
|
- apps/api/src/module-categories/module-categories.controller.spec.ts
|
||||||
|
- apps/api/src/module-categories/module-categories.module.ts
|
||||||
|
- apps/api/src/module-categories/dto/module-category.dto.ts
|
||||||
|
- apps/api/src/module-registry/module-registry.module.ts
|
||||||
|
- apps/api/src/module-registry/module-registry.controller.ts
|
||||||
|
- apps/api/src/app.module.ts
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
- apps/web/src/lib/module-categories-api.ts
|
||||||
|
- apps/web/src/lib/stores/module-category-store.ts
|
||||||
|
- apps/web/src/lib/module-category-order.ts
|
||||||
|
- apps/web/src/lib/module-category-order.test.ts
|
||||||
|
- apps/web/src/lib/use-category-label.ts
|
||||||
|
- apps/web/src/components/layout/sidebar.tsx
|
||||||
|
- apps/web/src/components/layout/sidebar.test.tsx
|
||||||
|
# Task 2 — API: Verwaltungs-Endpunkte, Löschen mit Verschieben, Überlagerung überall, eigene Module
|
||||||
|
- apps/api/src/groups/groups.module.ts
|
||||||
|
- apps/api/src/groups/module-grants.controller.ts
|
||||||
|
- apps/api/src/custom-modules/custom-modules.module.ts
|
||||||
|
- apps/api/src/custom-modules/custom-modules.service.ts
|
||||||
|
- apps/api/src/custom-modules/custom-modules.service.spec.ts
|
||||||
|
- apps/api/src/custom-modules/custom-modules.controller.spec.ts
|
||||||
|
- apps/api/src/custom-modules/dto/custom-module.dto.ts
|
||||||
|
# Task 3 — Web: Verwaltungsseite, Marktplatz, Formular, Texte, Doku
|
||||||
|
- apps/web/src/app/(portal)/admin/modules/categories/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/admin/modules/categories/categories-page.test.tsx
|
||||||
|
- apps/web/src/app/(portal)/admin/modules/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/marketplace/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/[category]/page.tsx
|
||||||
|
- apps/web/src/components/custom-modules/custom-module-form-modal.tsx
|
||||||
|
- apps/web/src/lib/custom-modules-api.ts
|
||||||
|
- apps/web/src/messages/de.json
|
||||||
|
- apps/web/src/messages/en.json
|
||||||
|
- apps/web/src/messages/umlaut-dictionary.ts
|
||||||
|
- CHANGELOG.md
|
||||||
|
- docs/anleitung-administration.md
|
||||||
|
- docs/anleitung-anwender.md
|
||||||
|
autonomous: true
|
||||||
|
requirements: [QUICK-261003-387]
|
||||||
|
|
||||||
|
estimate:
|
||||||
|
tokens: 240000
|
||||||
|
raw_tokens: 240000
|
||||||
|
tasks: 3
|
||||||
|
confidence: low
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Ein Administrator sieht unter Administrator → Module neben „Freigaben-Matrix“ einen Knopf „Kategorien“, der /admin/modules/categories öffnet; Nicht-Administratoren bekommen dort den Zugriffshinweis und von der API 403."
|
||||||
|
- "Ein Administrator kann eine Kategorie anlegen, umbenennen, mit Pfeilen nach oben/unten verschieben und löschen; „Eigene Module“ lässt sich umbenennen und verschieben, aber nicht löschen (Knopf gesperrt, API 400)."
|
||||||
|
- "Löschen einer nicht leeren Kategorie fragt nach einer Zielkategorie; alle Module, gemeinsame UND persönliche eigene Module, landen dort — kein Eintrag geht verloren; ohne Ziel antwortet die API 409 und löscht nichts."
|
||||||
|
- "Ein Administrator ordnet jedes Marktplatz-Modul und jedes gemeinsame eigene Modul per Auswahlfeld einer Kategorie zu und sortiert die Einträge innerhalb einer Kategorie mit Pfeilen; die Spalte Module.category (für alle gleich) bleibt unverändert."
|
||||||
|
- "Seitenleiste, Marktplatz (Filterchips und Kartenreihenfolge), Kategorieseite /modules/<kategorie> und Freigaben-Matrix zeigen die Kategorie aus der Zuordnung in der eingestellten Kategorie- und Modulreihenfolge; nicht umbenannte Standardkategorien bleiben übersetzt (de/en), umbenannte zeigen den gespeicherten Namen."
|
||||||
|
- "Im Formular für eigene Module stehen alle vorhandenen Kategorien zur Wahl; bestehende Werte bleiben gültig; eine unbekannte Kategorie lehnt die API mit 400 ab."
|
||||||
|
- "Alte Modul-Adressen /modules/<alte-kategorie>/<slug> öffnen das Modul weiterhin, weil die Seite nur über den Slug auflöst."
|
||||||
|
artifacts:
|
||||||
|
- path: "apps/api/prisma/migrations/20261003120000_module_categories/migration.sql"
|
||||||
|
provides: "Tabellen ModuleCategory + ModuleCategoryPlacement mit RLS, Spalte CustomModule.sortOrder"
|
||||||
|
contains: "tenant_isolation_policy"
|
||||||
|
- path: "apps/api/src/module-categories/module-categories.service.ts"
|
||||||
|
provides: "Grundbestand je Organisation, CRUD, Löschen mit Verschieben, Überlagerung applyToModules, assertCategoryKey"
|
||||||
|
- path: "apps/api/src/module-categories/module-categories.controller.ts"
|
||||||
|
provides: "GET /module-categories (alle), Verwaltungs-Endpunkte nur ADMIN/SUPER_ADMIN, statische Routen vor :key"
|
||||||
|
- path: "apps/web/src/app/(portal)/admin/modules/categories/page.tsx"
|
||||||
|
provides: "Verwaltungsseite Kategorien"
|
||||||
|
- path: "apps/web/src/lib/stores/module-category-store.ts"
|
||||||
|
provides: "Geteilter Kategorienstand für Beschriftung, Reihenfolge und Auswahlfelder"
|
||||||
|
key_links:
|
||||||
|
- from: "apps/api/src/module-registry/module-registry.controller.ts"
|
||||||
|
to: "ModuleCategoriesService.applyToModules"
|
||||||
|
via: "GET /modules, /modules/active, /modules/catalog liefern die zugeordnete Kategorie + sortOrder"
|
||||||
|
pattern: "applyToModules"
|
||||||
|
- from: "apps/api/src/groups/module-grants.controller.ts"
|
||||||
|
to: "ModuleCategoriesService.applyToModules"
|
||||||
|
via: "GET /module-grants/matrix sortiert Module nach Kategorie- und Modulreihenfolge"
|
||||||
|
pattern: "applyToModules"
|
||||||
|
- from: "apps/web/src/lib/use-category-label.ts"
|
||||||
|
to: "apps/web/src/lib/stores/module-category-store.ts"
|
||||||
|
via: "gespeicherter Name vor Übersetzung, Übersetzung vor Kennung"
|
||||||
|
pattern: "useModuleCategoryStore"
|
||||||
|
- from: "apps/web/src/components/layout/sidebar.tsx"
|
||||||
|
to: "apps/web/src/lib/module-category-order.ts"
|
||||||
|
via: "Gruppenreihenfolge und Reihenfolge innerhalb der Gruppe"
|
||||||
|
pattern: "module-category-order"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Modulkategorien werden pro Organisation durch Administratoren pflegbar: anlegen, umbenennen, sortieren, löschen (mit Verschieben der Inhalte), Module und gemeinsame eigene Module zuordnen und innerhalb der Kategorie sortieren. Die eingestellte Kategorie und Reihenfolge gilt in Seitenleiste, Marktplatz, Kategorieseite, Freigaben-Matrix und im Formular für eigene Module.
|
||||||
|
|
||||||
|
Purpose: Heute legt jedes Modul seine Kategorie fest (Module.category aus dem Manifest, für alle Organisationen gleich); Administratoren können die Seitenleiste nicht nach ihren Abläufen ordnen.
|
||||||
|
Output: Zwei neue Tabellen mit Zeilenschutz, ein Kategorien-Dienst mit Endpunkten, eine Verwaltungsseite, angepasste Anzeigen, CHANGELOG und Handbuch.
|
||||||
|
|
||||||
|
Festgelegte Entwurfsentscheidungen (aus dem Auftrag, Planer-Ermessen hier dokumentiert):
|
||||||
|
- E-01 Datenform: ModuleCategory {id, tenantId, key, name (null = Übersetzung moduleCategories.<key>), sortOrder, isSystem}, eindeutig (tenantId, key). key ist UNVERÄNDERLICH und bleibt URL-Segment /modules/<key>/<slug>; Umbenennen ändert nur name. isSystem=true nur für „custom-modules“ (Eigene Module): umbenennbar, verschiebbar, nicht löschbar.
|
||||||
|
- E-02 Zuordnung Marktplatz-Module: ModuleCategoryPlacement {id, tenantId, moduleId → Module (onDelete Cascade), categoryKey, sortOrder}, eindeutig (tenantId, moduleId). Wirksame Kategorie = Zuordnung, sonst Module.category. Module.category wird NIE geändert.
|
||||||
|
- E-03 Gemeinsame eigene Module: CustomModule ist schon eine Zeile je Organisation; Zuordnung schreibt direkt CustomModule.category, Reihenfolge in der neuen Spalte CustomModule.sortOrder (Int, null erlaubt). Persönliche eigene Module wählen ihre Kategorie selbst (jede vorhandene) und bekommen nie eine sortOrder.
|
||||||
|
- E-04 Grundbestand ohne SQL-Rückfüllung: der Dienst legt beim ersten Lesen je Organisation die Standardkategorien an (Reihenfolge von CUSTOM_MODULE_CATEGORIES, also die sechs Modulkategorien und „Eigene Module“ zuletzt, name null) und legt fehlende Zeilen für jede wirksam benutzte Kennung nach (Kategorie eines später ausgelieferten Moduls, vorhandene Werte eigener Module), jeweils hinten angehängt. Eine gelöschte Standardkategorie kommt nur wieder, wenn ein Modul sie wirksam benutzt (z. B. ein neu ausgeliefertes Modul mit diesem Manifest-Wert).
|
||||||
|
- E-05 Löschen: alle Inhalte wandern in die gewählte Zielkategorie — Marktplatz-Module (Zuordnung umgeschrieben bzw. neu angelegt, damit die Manifest-Kategorie sie nicht zurückholt), gemeinsame UND persönliche eigene Module (persönliche Einträge gehen mit den anderen mit, nicht nach „Eigene Module“). Verschobene Einträge werden hinten angehängt.
|
||||||
|
- E-06 Neue Kennung: aus dem Namen gebildet (klein, ä→ae, ö→oe, ü→ue, ß→ss, sonst nur a-z0-9 und Bindestrich, höchstens 40 Zeichen, leer → „kategorie“); kollidiert sie mit einer Kennung der Organisation, einem Modul-Slug (eigene Routenordner unter /modules) oder „custom“, wird „-2“, „-3“ … angehängt.
|
||||||
|
- E-07 Wirksame Kategorie wird SERVERSEITIG über die Modullisten gelegt (/modules, /modules/active, /modules/catalog, /module-grants/matrix): jedes Modul bekommt category = wirksame Kennung und sortOrder (Zahl oder null), Liste sortiert nach Kategorie-Reihenfolge, dann sortOrder (null zuletzt), dann Name. Dadurch gruppieren Kategorieseite, Marktplatz und Matrix ohne eigene Logik richtig.
|
||||||
|
- E-08 Reihenfolge innerhalb einer Kategorie (Seitenleiste): sortOrder aufsteigend, null zuletzt; bei Gleichstand eingebaute Module vor eigenen, dann Name. Persönliche eigene Module stehen damit immer hinter den vom Administrator sortierten Einträgen.
|
||||||
|
- E-09 Nicht im Umfang: der Benutzer-Zugriffsdialog (Benutzerdetails) behält seine bisherige Sortierung; keine „Auf Standardnamen zurücksetzen“-Funktion.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@~/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/STATE.md
|
||||||
|
@./CLAUDE.md
|
||||||
|
|
||||||
|
Bestehende Muster (einmal lesen, dann nachbauen):
|
||||||
|
- Migration mit Kopfkommentar + RLS ohne Benutzerdimension: apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql
|
||||||
|
- RLS-Regeln eigener Module (Klient ohne Benutzer darf alle Zeilen der Organisation ändern): apps/api/prisma/migrations/20260929130000_custom_module_owner/migration.sql
|
||||||
|
- forTenant / withTenantTransaction: apps/api/src/prisma/prisma-tenant.extension.ts
|
||||||
|
- Rollen je Methode: apps/api/src/module-registry/module-registry.controller.ts (@UseGuards(RolesGuard) + @Roles(Role.ADMIN, Role.SUPER_ADMIN), tenantId = req.tenantId ?? req.user?.tenantId)
|
||||||
|
- Zugriffsinventar: apps/api/src/prisma/rls-access-inventory.spec.ts gegen docs/mandantentrennung-zugriffsklassifikation.md (Zeilenformat wie Eintrag nextcloud-status.service.ts)
|
||||||
|
- Löschdialog mit Rückfrage: apps/web/src/app/(portal)/admin/groups/components/DeleteGroupDialog.tsx
|
||||||
|
- Admin-Seite mit Rollenprüfung und Kopf-Link: apps/web/src/app/(portal)/admin/modules/page.tsx, apps/web/src/app/(portal)/admin/modules/grants/page.tsx
|
||||||
|
- Seitenleisten-Test zählt fetch-Aufrufe: apps/web/src/components/layout/sidebar.test.tsx (API-Helfer werden als Modul gemockt, z. B. @/lib/custom-modules-api)
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="tracer">
|
||||||
|
<name>Task 1: Tracer — Kategorietabellen, Grundbestand, Lese-Endpunkt und wirksame Kategorie bis in die Seitenleiste</name>
|
||||||
|
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261003120000_module_categories/migration.sql, apps/api/src/module-categories/module-categories.service.ts, apps/api/src/module-categories/module-categories.service.spec.ts, apps/api/src/module-categories/module-categories.controller.ts, apps/api/src/module-categories/module-categories.controller.spec.ts, apps/api/src/module-categories/module-categories.module.ts, apps/api/src/module-categories/dto/module-category.dto.ts, apps/api/src/module-registry/module-registry.module.ts, apps/api/src/module-registry/module-registry.controller.ts, apps/api/src/app.module.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/module-categories-api.ts, apps/web/src/lib/stores/module-category-store.ts, apps/web/src/lib/module-category-order.ts, apps/web/src/lib/module-category-order.test.ts, apps/web/src/lib/use-category-label.ts, apps/web/src/components/layout/sidebar.tsx, apps/web/src/components/layout/sidebar.test.tsx</files>
|
||||||
|
<read_first>apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql, apps/api/src/custom-modules/custom-modules.service.ts, apps/api/src/module-registry/module-registry.controller.ts, apps/web/src/components/layout/sidebar.tsx, apps/web/src/components/layout/sidebar.test.tsx, apps/web/src/lib/use-category-label.ts, packages/shared/src/index.ts (Zeilen 270-300)</read_first>
|
||||||
|
<action>
|
||||||
|
Schema (E-01, E-02, E-03): in schema.prisma die Modelle ModuleCategory und ModuleCategoryPlacement wie in E-01/E-02 beschrieben anlegen (beide mit tenantId String, createdAt/updatedAt, @@index([tenantId]); ModuleCategory @@unique([tenantId, key]), sortOrder Int @default(0), isSystem Boolean @default(false), name String?; Placement @@unique([tenantId, moduleId]), sortOrder Int, Relation zu Module mit onDelete: Cascade und Gegenfeld categoryPlacements an Module). An CustomModule die Spalte sortOrder Int? ergänzen und den Kommentar an category auf „Kennung einer ModuleCategory der Organisation“ ändern. Migration 20261003120000_module_categories: DDL mit `pnpm --filter @tessera/api exec prisma migrate diff --from-migrations prisma/migrations --to-schema-datamodel prisma/schema.prisma --script` erzeugen (Shadow-DB per --shadow-database-url über die Container-IP, falls nötig) oder von Hand nach Vorbild schreiben; dann von Hand den Pflicht-Kopfkommentar (Zweck, quick-261003-387, Zeilenschutz ohne Benutzerdimension weil gemeinsame Daten der Organisation, Rechte über ALTER DEFAULT PRIVILEGES, Schalter-Hinweis) und für BEIDE Tabellen ENABLE + FORCE ROW LEVEL SECURITY und CREATE POLICY tenant_isolation_policy … USING ("tenantId" = current_tenant_id()) ergänzen. Keine system_read_policy (kein Hintergrunddienst). Migration lokal anwenden: IP mit `docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1`, dann `DATABASE_URL="postgresql://tessera:tessera_dev@<IP>:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy` und `pnpm --filter @tessera/api exec prisma generate`.
|
||||||
|
|
||||||
|
Dienst apps/api/src/module-categories/module-categories.service.ts (injiziert nur PrismaService; je Methode eigener Klient `const tenantPrisma = forTenant(this.prisma, tenantId)`; Lesen des globalen Modulkatalogs über this.prisma.module wie in module-access.service.ts, mit gleichem Begründungskommentar). In diesem Task: (a) private ensure(tenantId) nach E-04 — keine Zeile vorhanden → createMany mit skipDuplicates für CUSTOM_MODULE_CATEGORIES in dieser Reihenfolge (sortOrder = Index, isSystem nur für CUSTOM_MODULE_CATEGORY); danach fehlende Kennungen aus wirksamer Modulkategorie (Zuordnung sonst Module.category) und distinct CustomModule.category der Organisation mit sortOrder = bisheriges Maximum + 1 nachlegen (skipDuplicates); liefert die Zeilen sortiert nach sortOrder, dann key. (b) listCategories(tenantId) → [{id, key, name, sortOrder, isSystem}]. (c) applyToModules(tenantId, modules) generisch über {id, category, name} nach E-07 (gibt category und sortOrder: number | null zurück, unbekannte Kategorie sortiert zuletzt). (d) rename(tenantId, key, name) — Name getrimmt 1–60 Zeichen, unbekannte Kennung 404, „Eigene Module“ erlaubt (D-Auftrag: umbenennbar).
|
||||||
|
|
||||||
|
Controller apps/api/src/module-categories/module-categories.controller.ts mit @Controller('module-categories'): GET '' für alle angemeldeten Benutzer → listCategories; PATCH ':key' mit @UseGuards(RolesGuard) @Roles(Role.ADMIN, Role.SUPER_ADMIN) → rename (DTO RenameModuleCategoryDto in dto/module-category.dto.ts mit Transform-Trim, IsString, IsNotEmpty, MaxLength(60)). Alle späteren statischen Routen kommen VOR ':key' (NestJS-Route-Order). ModuleCategoriesModule (providers + exports ModuleCategoriesService, controllers) anlegen, in app.module.ts registrieren und von ModuleRegistryModule importieren. In ModuleRegistryController ModuleCategoriesService injizieren und findActive über applyToModules leiten (findAll/findCatalog folgen in Task 2).
|
||||||
|
|
||||||
|
Inventar: rls-access-inventory.spec.ts laufen lassen und für die neuen Paare (Datei module-categories.service.ts × moduleCategory, moduleCategoryPlacement, customModule, module) Zeilen in docs/mandantentrennung-zugriffsklassifikation.md im vorhandenen Format ergänzen (Stand gebunden bzw. für module der dokumentierte ungebundene Katalogzugriff); in Task 2 kommen weitere Treffer hinzu — die Rohzahlen dann nachziehen.
|
||||||
|
|
||||||
|
Web: apps/web/src/lib/module-categories-api.ts mit Typ ModuleCategoryInfo {id, key, name: string | null, sortOrder, isSystem} und listModuleCategories() (GET, credentials include, wirft bei !ok). apps/web/src/lib/stores/module-category-store.ts (zustand wie marketplace-store): categories, loaded, load() ruft listModuleCategories und schluckt Fehler still (Seitenleisten-Muster), ensureLoaded() lädt nur wenn !loaded. apps/web/src/lib/module-category-order.ts als reine Funktionen: categoryRank(categories, key) und compareSidebarEntries nach E-08; Tests in module-category-order.test.ts. use-category-label.ts: liest den Store; gespeicherter name (nicht null) vor Übersetzung, Übersetzung vor Kennung; abonniert den Store, damit Beschriftungen nach dem Laden neu rendern. Sidebar: SidebarModule und SidebarEntry um sortOrder (number | null) und custom-Kennzeichen erweitern (eigene Module aus der CustomModule-Antwort übernehmen sortOrder, siehe Task 2 für das API-Feld; bis dahin null); fetchActiveModules lädt zusätzlich den Store (load()) im selben Auffrisch-Takt; orderedCategories sortiert Gruppen nach categoryRank (unbekannte Kennungen zuletzt in Fundreihenfolge) und Einträge je Gruppe nach compareSidebarEntries — der bisherige feste Sonderfall „Eigene Module immer zuletzt“ entfällt, weil die Reihenfolge jetzt aus den Kategoriezeilen kommt (Standard: zuletzt). sidebar.test.tsx: @/lib/module-categories-api als Modul mocken (fetch-Zähler bleiben unverändert) und Tests ergänzen: Gruppen folgen der Store-Reihenfolge; umbenannte Kategorie zeigt den Namen; Einträge innerhalb einer Gruppe folgen sortOrder, null zuletzt, eingebaut vor eigenem.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api exec vitest run src/module-categories src/module-registry rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run sidebar module-category-order && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && docker exec tessera-ctl-db-1 psql -U tessera -d tessera -tAc "select count(*) from pg_class where relname in ('ModuleCategory','ModuleCategoryPlacement') and relrowsecurity and relforcerowsecurity" | grep -qx 2</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Migration lokal angewendet, beide Tabellen mit FORCE RLS; GET /module-categories liefert für eine frische Organisation sieben Standardkategorien in Standardreihenfolge mit name null; PATCH benennt um (nur Administratoren); /modules/active liefert wirksame Kategorie + sortOrder; die Seitenleiste ordnet Gruppen und Einträge nach Store und zeigt umbenannte Namen; RLS-Gates grün.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: API — Anlegen, Sortieren, Zuordnen, Löschen mit Verschieben, Überlagerung in allen Modullisten, eigene Module gegen vorhandene Kategorien prüfen</name>
|
||||||
|
<files>apps/api/src/module-categories/module-categories.service.ts, apps/api/src/module-categories/module-categories.service.spec.ts, apps/api/src/module-categories/module-categories.controller.ts, apps/api/src/module-categories/module-categories.controller.spec.ts, apps/api/src/module-categories/dto/module-category.dto.ts, apps/api/src/module-registry/module-registry.controller.ts, apps/api/src/groups/groups.module.ts, apps/api/src/groups/module-grants.controller.ts, apps/api/src/custom-modules/custom-modules.module.ts, apps/api/src/custom-modules/custom-modules.service.ts, apps/api/src/custom-modules/custom-modules.service.spec.ts, apps/api/src/custom-modules/custom-modules.controller.spec.ts, apps/api/src/custom-modules/dto/custom-module.dto.ts, docs/mandantentrennung-zugriffsklassifikation.md</files>
|
||||||
|
<read_first>apps/api/src/module-categories/module-categories.service.ts (aus Task 1), apps/api/src/groups/module-grants.controller.ts, apps/api/src/custom-modules/dto/custom-module.dto.ts, apps/api/src/custom-modules/custom-modules.service.spec.ts, apps/api/src/prisma/prisma-tenant.extension.ts (withTenantTransaction)</read_first>
|
||||||
|
<behavior>
|
||||||
|
- create: Name „Werkzeuge & Tools“ ergibt Kennung „werkzeuge-tools“, sortOrder = Maximum + 1, name gespeichert; Name, dessen Kennung einem Modul-Slug (z. B. „proxmox“), „custom“ oder einer vorhandenen Kennung entspricht, bekommt „-2“; leerer Name 400.
|
||||||
|
- reorderCategories: keys muss genau eine Umstellung aller Kennungen der Organisation sein, sonst 400; danach sortOrder = Index.
|
||||||
|
- assign module: legt Zuordnung an oder schreibt sie um (categoryKey, sortOrder = Maximum der Zielkategorie + 1); Module.category bleibt unverändert; unbekannte Kategorie 400, unbekanntes Modul 404.
|
||||||
|
- assign custom: nur gemeinsame eigene Module (ownerUserId null), persönliches oder fremdes 404; schreibt CustomModule.category und sortOrder.
|
||||||
|
- reorderItems: items muss genau die Menge der nicht persönlichen Einträge der Kategorie sein (Marktplatz-Module mit wirksamer Kategorie + gemeinsame eigene Module), sonst 400; schreibt sortOrder = Index (Module per Upsert der Zuordnung).
|
||||||
|
- remove: isSystem → 400; unbekannte Kennung → 404; nicht leer (wirksame Module, gemeinsame oder persönliche eigene Module) ohne moveTo → 409 und nichts gelöscht; moveTo gleich key oder unbekannt → 400; mit Ziel: Zuordnungen umgeschrieben, Module mit Manifest-Kategorie ohne Zuordnung bekommen eine Zuordnung zum Ziel, alle CustomModule-Zeilen (auch persönliche) bekommen das Ziel, alles in einer Transaktion, dann Zeile gelöscht; leere Kategorie ohne moveTo wird gelöscht; nach dem Löschen legt ensure die Kategorie nicht wieder an.
|
||||||
|
- applyToModules: Zuordnung schlägt Manifest; Sortierung Kategorie-Reihenfolge, dann sortOrder (null zuletzt), dann Name.
|
||||||
|
- getOverview: je Kategorie in Reihenfolge {key, name, sortOrder, isSystem, items: [{type: 'module'|'custom', id, name, slug?}] in Reihenfolge, personalCount}.
|
||||||
|
- Custom modules: create/update mit Kategorie, die die Organisation nicht hat → 400; vorhandene Kennung (auch neu angelegte) → ok.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Dienst ergänzen (E-02, E-03, E-05, E-06): create(tenantId, name), reorderCategories(tenantId, keys), assign(tenantId, {type, id, categoryKey}), reorderItems(tenantId, key, items), remove(tenantId, key, moveTo?), getOverview(tenantId), assertCategoryKey(tenantId, key) (ruft ensure, wirft BadRequestException mit deutscher Meldung „Unbekannte Kategorie“). Mehrschrittige Schreibvorgänge (reorderCategories, reorderItems, remove) über withTenantTransaction(this.prisma, tenantId, async (tx) => …), damit das Inventar sie als gebunden erkennt. Eigene Module werden über den Organisations-Klienten OHNE Benutzer gelesen/geschrieben (die Regel aus 20260929130000 lässt dann alle Zeilen der Organisation zu); die Administrator-Prüfung sitzt im Controller. Zusätzlich jede Abfrage mit tenantId im where (Anwendungsprüfung, solange der RLS-Schalter aus ist). Fehlermeldungen deutsch, ohne das Wort Mandant.
|
||||||
|
|
||||||
|
Controller (statische Routen VOR ':key', alle schreibenden und overview nur ADMIN/SUPER_ADMIN): GET 'overview', POST '' (CreateModuleCategoryDto {name}), PUT 'order' ({keys: string[]}, ArrayMinSize 1, jedes Element passend zu /^[a-z0-9][a-z0-9-]{0,59}$/), PUT 'assignment' ({type: IsIn ['module','custom'], id: IsString, categoryKey}), dann PATCH ':key' (aus Task 1), PUT ':key/items' ({items: [{type, id}]} mit ValidateNested + Type), DELETE ':key' mit optionalem Query moveTo. Controller-Spec: Rollen-Metadaten je Verwaltungsmethode (Muster expectAdminOnly aus module-manage-handlers.spec.ts), GET '' ohne @Roles, und Reihenfolge der Routen (Index von 'overview', 'order', 'assignment' im Quelltext vor dem ersten ':key').
|
||||||
|
|
||||||
|
Überlagerung (E-07): ModuleRegistryController.findAll (mit @Req; ohne tenantId unverändert zurückgeben) und findCatalog über applyToModules leiten; ModuleGrantsController.matrix: Ergebnis von getMatrix nehmen und modules durch applyToModules ersetzen (GroupsModule importiert ModuleCategoriesModule; getMatrix im Dienst bleibt unverändert, damit bestehende Specs halten). Benutzer-Zugriffsdialog unverändert (E-09).
|
||||||
|
|
||||||
|
Eigene Module: CUSTOM_MODULE_SELECT um sortOrder erweitern (Antwortfeld für die Seitenleiste). DTO: @IsIn([...CUSTOM_MODULE_CATEGORIES]) durch IsString + Matches(/^[a-z0-9][a-z0-9-]{0,59}$/) ersetzen; Typ string. CustomModulesService injiziert ModuleCategoriesService (CustomModulesModule importiert ModuleCategoriesModule) und ruft assertCategoryKey in create und in update (nur wenn category gesetzt). Bestehende Specs auf den neuen Konstruktor-Parameter anpassen (Stub mit assertCategoryKey), neuen Fall „unbekannte Kategorie 400“ ergänzen. Danach rls-access-inventory.spec.ts laufen lassen und die Rohzahlen/Methodenliste der Zeilen für module-categories.service.ts nachziehen.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api exec vitest run src/module-categories src/module-registry src/groups src/custom-modules rls-coverage rls-access-inventory && pnpm --filter @tessera/api exec tsc --noEmit && node -e 'const s=require("fs").readFileSync("apps/api/src/module-categories/module-categories.controller.ts","utf8");const k=s.indexOf("\x27:key");for(const r of ["\x27overview\x27","\x27order\x27","\x27assignment\x27"]){const i=s.indexOf(r);if(i<0||k<0||i>k){console.error("route order",r);process.exit(1)}}' && grep -q "applyToModules" apps/api/src/groups/module-grants.controller.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Alle Verwaltungsendpunkte vorhanden, nur für Administratoren, statische Routen vor :key; Löschen verschiebt alle Einträge einschließlich persönlicher eigener Module in einer Transaktion und verweigert ohne Ziel mit 409; /modules, /modules/catalog und /module-grants/matrix liefern wirksame Kategorie und Reihenfolge; eigene Module akzeptieren jede vorhandene Kategorie und lehnen unbekannte mit 400 ab; API-Gates grün.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 3: Web — Verwaltungsseite „Kategorien“, Marktplatz, Formular, Texte, CHANGELOG, Handbuch, Gesamtprüfung und Neubau</name>
|
||||||
|
<files>apps/web/src/lib/module-categories-api.ts, apps/web/src/app/(portal)/admin/modules/categories/page.tsx, apps/web/src/app/(portal)/admin/modules/categories/categories-page.test.tsx, apps/web/src/app/(portal)/admin/modules/page.tsx, apps/web/src/app/(portal)/marketplace/page.tsx, apps/web/src/app/(portal)/modules/[category]/page.tsx, apps/web/src/components/custom-modules/custom-module-form-modal.tsx, apps/web/src/lib/custom-modules-api.ts, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, CHANGELOG.md, docs/anleitung-administration.md, docs/anleitung-anwender.md</files>
|
||||||
|
<read_first>apps/web/src/app/(portal)/admin/modules/page.tsx, apps/web/src/app/(portal)/admin/modules/grants/page.tsx (Kopf, Zurück-Link), apps/web/src/app/(portal)/admin/groups/components/DeleteGroupDialog.tsx, apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx (Mock-Muster), apps/web/src/components/custom-modules/custom-module-form-modal.tsx, apps/web/src/app/(portal)/marketplace/page.tsx</read_first>
|
||||||
|
<behavior>
|
||||||
|
- Seite listet Kategorien in Reihenfolge mit Beschriftung aus useCategoryLabel und darunter ihre Einträge; Pfeil nach oben bei der ersten bzw. nach unten bei der letzten Kategorie/Eintrag gesperrt.
|
||||||
|
- „Kategorie anlegen“ sendet POST mit dem Namen; Umbenennen sendet PATCH; Pfeile senden PUT order bzw. PUT :key/items mit der vollständigen neuen Reihenfolge.
|
||||||
|
- Auswahlfeld je Eintrag sendet PUT assignment.
|
||||||
|
- Löschen-Knopf bei „Eigene Module“ gesperrt; leere Kategorie → einfache Rückfrage → DELETE ohne moveTo; nicht leere (items oder personalCount > 0) → Dialog mit Zielauswahl (ohne die zu löschende) → DELETE mit moveTo.
|
||||||
|
- Nach jeder Änderung: Übersicht neu laden, Kategorienstand neu laden, Seitenleiste auffrischen (bumpSidebarRefresh).
|
||||||
|
- Nicht-Administrator sieht den Zugriffshinweis und es wird nichts geladen.
|
||||||
|
- Formular für eigene Module bietet alle Kategorien aus dem Store in Reihenfolge an; ein vorhandener Wert, der (noch) nicht im Store steht, bleibt als Option erhalten.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
module-categories-api.ts um getModuleCategoryOverview, createModuleCategory, renameModuleCategory, reorderModuleCategories, reorderModuleCategoryItems, assignModuleCategory, deleteModuleCategory(key, moveTo?) erweitern (Fehlerklasse mit status wie CustomModuleRequestError; Fehlermeldung der API anzeigen). CustomModule-Typ in custom-modules-api.ts um sortOrder: number | null ergänzen.
|
||||||
|
|
||||||
|
Neue Seite apps/web/src/app/(portal)/admin/modules/categories/page.tsx (Client-Komponente, Rollenprüfung und Layout wie admin/modules/page.tsx, Zurück-Link „Module“ wie in grants/page.tsx): Kopf „Kategorien“ mit Erklärung; Eingabe + Knopf „Kategorie anlegen“; je Kategorie eine Karte mit Name, Pfeilen nach oben/unten (aria-label „Kategorie nach oben/unten verschieben“), „Umbenennen“ (Eingabe an Ort und Stelle, Speichern/Abbrechen), „Löschen“ (bei isSystem gesperrt mit Hinweis „Diese Kategorie kann nicht gelöscht werden“); darin die Einträge (Marktplatz-Module und gemeinsame eigene Module, letztere mit kleinem Hinweis „Eigenes Modul“) mit Pfeilen und einem Auswahlfeld „Kategorie“ (alle Kategorien); bei personalCount > 0 der Satz „Außerdem N persönliche Einträge von Benutzern“; leere Kategorie zeigt „Keine Module in dieser Kategorie“. Löschdialog nach Vorbild DeleteGroupDialog nach E-05 (Text: die Module werden in die gewählte Kategorie verschoben, auch persönliche Einträge von Benutzern; nichts geht verloren). Pfeile verschieben durch Tauschen in der lokalen Liste und senden die vollständige neue Reihenfolge. admin/modules/page.tsx: neben dem Link „Freigaben-Matrix“ einen zweiten Link „Kategorien“ auf /admin/modules/categories; die Kategorie-Plakette zeigt categoryLabel(mod.category) statt der Kennung.
|
||||||
|
|
||||||
|
Marktplatz: Filterchips nach Store-Reihenfolge (categoryRank) statt alphabetisch, Store per ensureLoaded laden; Karten behalten die vom Server gelieferte Reihenfolge. Kategorieseite /modules/[category]/page.tsx: Titel über useCategoryLabel statt formatCategoryName (Filter auf mod.category bleibt, die API liefert jetzt die wirksame Kategorie). Prüfen und im SUMMARY festhalten, dass /modules/[category]/[moduleSlug] nur über den Slug auflöst (ModuleAccessGate + ModuleShell); der Zurück-Link nutzt das URL-Segment und darf bleiben. Formular custom-module-form-modal.tsx: Optionen aus dem Store (ensureLoaded beim Öffnen) statt CUSTOM_MODULE_CATEGORIES, Rückfall auf CUSTOM_MODULE_CATEGORIES nur solange der Store leer ist; Vorbelegung bleibt CUSTOM_MODULE_CATEGORY. Freigaben-Matrix braucht keine Änderung (Server sortiert, Beschriftung über useCategoryLabel); Matrix-Test muss weiter grün sein.
|
||||||
|
|
||||||
|
Texte: neue Schlüssel unter adminModules (categoriesLink sowie Bereich categories mit allen Seiten- und Dialogtexten) in de.json UND en.json mit identischer Schlüsselmenge; Deutsch mit echten Umlauten und „Sie“, Englisch sachlich; keines der Wörter „Mandant“ oder „tenant“ in Texten. Meldet der Umlaut-Wächter ein korrektes Wort, es in die Erlaubnisliste von umlaut-dictionary.ts aufnehmen. Tests in categories-page.test.tsx für die Fälle aus behavior (API-Helfer und Stores als Modul mocken wie in grants-matrix.test.tsx).
|
||||||
|
|
||||||
|
CHANGELOG.md unter „Unveröffentlicht → Neu“ ein Absatz in Alltagssprache (wo die Seite liegt, was Administratoren tun können, dass beim Löschen alle Einträge in eine gewählte Kategorie wandern und „Eigene Module“ nicht löschbar ist, dass Seitenleiste und Marktplatz der Reihenfolge folgen, dass Benutzer für ihre eigenen Einträge jede Kategorie wählen können). docs/anleitung-administration.md: neuer Abschnitt „### Kategorien“ in Kapitel 5 nach „Freigaben-Matrix“ (Anlegen, Umbenennen, Reihenfolge, Zuordnen, Sortieren, Löschen mit Ziel inkl. persönlicher Einträge, „Eigene Module“ nicht löschbar, alte Lesezeichen funktionieren weiter); docs/anleitung-anwender.md: Satz zu „Eigene Module … ganz unten“ anpassen (Reihenfolge legt der Administrator fest, standardmäßig unten; Auswahl umfasst alle Kategorien).
|
||||||
|
|
||||||
|
Abschluss: komplette Suites, tsc beider Apps, `biome check` auf alle NEUEN Dateien und `biome lint` auf die berührten bestehenden Dateien (diese haben schon heute Format-/Import-Abweichungen; nicht ganze Altdateien umformatieren, damit der Diff klein bleibt), dann `docker compose up -d --build api web` und prüfen, dass beide Dienste laufen und die API ohne Migrationsfehler startet. Nicht pushen; Browserprüfung macht der Orchestrator.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && pnpm exec biome check apps/api/src/module-categories "apps/web/src/app/(portal)/admin/modules/categories" apps/web/src/lib/module-categories-api.ts apps/web/src/lib/module-category-order.ts apps/web/src/lib/module-category-order.test.ts apps/web/src/lib/stores/module-category-store.ts && pnpm exec biome lint apps/api/src/custom-modules apps/api/src/module-registry/module-registry.controller.ts apps/api/src/groups/module-grants.controller.ts "apps/web/src/app/(portal)/admin/modules/page.tsx" "apps/web/src/app/(portal)/marketplace/page.tsx" "apps/web/src/app/(portal)/modules/[category]/page.tsx" apps/web/src/components/layout/sidebar.tsx apps/web/src/components/custom-modules/custom-module-form-modal.tsx apps/web/src/lib/use-category-label.ts && node -e 'const de=require("./apps/web/src/messages/de.json"),en=require("./apps/web/src/messages/en.json");const w=(o,p,r)=>{for(const[k,v]of Object.entries(o||{})){const q=p+"."+k;if(v&&typeof v==="object")w(v,q,r);else r[q]=v}return r};const a=w(de.adminModules&&de.adminModules.categories,"c",{}),b=w(en.adminModules&&en.adminModules.categories,"c",{});if(Object.keys(a).length<10||Object.keys(a).sort().join()!==Object.keys(b).sort().join()){console.error("key mismatch");process.exit(1)}for(const v of [...Object.values(a),...Object.values(b)])if(/mandant|tenant/i.test(String(v))){console.error("bad text",v);process.exit(1)}' && grep -q "Kategorien" CHANGELOG.md && grep -q "### Kategorien" docs/anleitung-administration.md && docker compose ps --status running --services | grep -qx api && docker compose ps --status running --services | grep -qx web</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Administrator → Module → „Kategorien“ ist erreichbar und deckt Anlegen, Umbenennen, Sortieren, Löschen mit Zielauswahl, Zuordnen und Sortieren der Einträge ab; Marktplatz, Kategorieseite, Matrix und Formular folgen den eingestellten Kategorien; Texte de/en vollständig ohne „Mandant/tenant“; CHANGELOG und Handbuch ergänzt; beide Suites, tsc und biome grün; api und web neu gebaut und laufend; nichts gepusht.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Browser → API /module-categories | Eingaben (Name, Kennungen, Modul-IDs, moveTo) sind unvertraut |
|
||||||
|
| Organisation A ↔ Organisation B | Kategorien und Zuordnungen sind je Organisation getrennt (tenantId + RLS) |
|
||||||
|
| Benutzer ↔ Administrator | Nur Administratoren ändern Kategorien; persönliche eigene Module bleiben für andere unsichtbar |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-387-01 | Elevation of Privilege | module-categories.controller.ts Schreibrouten + overview | high | mitigate | @UseGuards(RolesGuard) + @Roles(ADMIN, SUPER_ADMIN) je Methode; Controller-Spec prüft die Metadaten; GET '' liefert nur Kennung/Name/Reihenfolge |
|
||||||
|
| T-387-02 | Information Disclosure | ModuleCategory, ModuleCategoryPlacement | high | mitigate | tenant_isolation_policy mit FORCE RLS in Migration 20261003120000; forTenant/withTenantTransaction je Methode; tenantId zusätzlich im where; rls-coverage + rls-access-inventory grün |
|
||||||
|
| T-387-03 | Information Disclosure | getOverview / Löschen mit Verschieben | medium | mitigate | Übersicht nennt für persönliche eigene Module nur eine Anzahl (personalCount), nie Name oder Adresse; assign/reorderItems akzeptieren nur gemeinsame eigene Module (persönliche 404) |
|
||||||
|
| T-387-04 | Tampering | assign/reorderItems mit fremden IDs | medium | mitigate | Modul-ID gegen Katalog, eigene Module mit where {id, tenantId, ownerUserId: null}; reorderItems verlangt exakt die Menge der Einträge der Kategorie, sonst 400 |
|
||||||
|
| T-387-05 | Denial of Service | remove ohne Ziel / Datenverlust | medium | mitigate | Nicht leere Kategorie ohne moveTo → 409, nichts gelöscht; Verschieben und Löschen in einer Transaktion; „Eigene Module“ (isSystem) nicht löschbar |
|
||||||
|
| T-387-06 | Tampering | Kennung als URL-Segment | low | mitigate | Kennung serverseitig aus dem Namen gebildet (a-z0-9-), kollisionsfrei gegen Modul-Slugs und „custom“; Kennungen in DTOs per Regex geprüft |
|
||||||
|
| T-387-SC | Tampering | npm/pip/cargo installs | high | accept | Keine neuen Pakete in diesem Auftrag (nur vorhandene Abhängigkeiten) |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- Alle drei automatisierten Prüfungen grün; vollständige API- und Web-Suite grün.
|
||||||
|
- Migration lokal angewendet, FORCE RLS auf beiden neuen Tabellen.
|
||||||
|
- docker compose: api und web neu gebaut und laufend.
|
||||||
|
- Browserprüfung (Orchestrator, dunkel): Kategorie anlegen, Modul hineinschieben, umbenennen, sortieren, nicht leere Kategorie löschen mit Ziel; Seitenleiste und Marktplatz folgen; alte Modul-Adresse öffnet weiter.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Administratoren pflegen Kategorien vollständig über die neue Seite; kein Modul geht beim Löschen verloren.
|
||||||
|
- Wirksame Kategorie und Reihenfolge gelten in Seitenleiste, Marktplatz, Kategorieseite, Freigaben-Matrix und Formular für eigene Module.
|
||||||
|
- Module.category bleibt unangetastet; Standardkategorien bleiben übersetzt, solange sie nicht umbenannt sind.
|
||||||
|
- RLS-Gates, Inventar-Doku, CHANGELOG und Handbuch aktuell; nichts gepusht.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/quick/261003-387-kategorien-durch-admins-bearbeitbar-umbe/261003-387-SUMMARY.md` when done (inkl. der Entscheidungen E-01 bis E-09 und des Befunds zur Slug-Auflösung von /modules/[category]/[moduleSlug]).
|
||||||
|
</output>
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
---
|
||||||
|
phase: quick-261003-387
|
||||||
|
plan: 01
|
||||||
|
subsystem: modules
|
||||||
|
tags: [module-categories, admin, sidebar, marketplace, rls, prisma, nestjs, nextjs]
|
||||||
|
status: complete
|
||||||
|
requirements: [QUICK-261003-387]
|
||||||
|
completed: 2026-10-03
|
||||||
|
duration: 23 min
|
||||||
|
commits: 3
|
||||||
|
plan_head_before: 53a49a109fc70dab6d5acf43d41649939cb1251d
|
||||||
|
plan_head_after: f2c0a896e840493d56305726ecaf98eb92a86889
|
||||||
|
actuals:
|
||||||
|
tokens: 215000
|
||||||
|
tasks: 3
|
||||||
|
commits: 3
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- apps/api/prisma/migrations/20261003120000_module_categories/migration.sql
|
||||||
|
- apps/api/src/module-categories/module-categories.service.ts
|
||||||
|
- apps/api/src/module-categories/module-categories.controller.ts
|
||||||
|
- apps/api/src/module-categories/module-categories.module.ts
|
||||||
|
- apps/api/src/module-categories/dto/module-category.dto.ts
|
||||||
|
- apps/api/src/module-categories/module-categories.service.spec.ts
|
||||||
|
- apps/api/src/module-categories/module-categories.controller.spec.ts
|
||||||
|
- apps/api/src/module-categories/module-categories.fake-prisma.ts
|
||||||
|
- apps/api/src/module-registry/module-registry.controller.categories.spec.ts
|
||||||
|
- apps/web/src/app/(portal)/admin/modules/categories/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/admin/modules/categories/categories-page.test.tsx
|
||||||
|
- apps/web/src/lib/module-category-order.ts
|
||||||
|
- apps/web/src/lib/stores/module-category-store.ts
|
||||||
|
modified:
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/src/module-registry/module-registry.controller.ts
|
||||||
|
- apps/api/src/groups/module-grants.controller.ts
|
||||||
|
- apps/api/src/custom-modules/custom-modules.service.ts
|
||||||
|
- apps/web/src/components/layout/sidebar.tsx
|
||||||
|
- apps/web/src/lib/use-category-label.ts
|
||||||
|
- apps/web/src/lib/module-categories-api.ts
|
||||||
|
- apps/web/src/app/(portal)/marketplace/page.tsx
|
||||||
|
- apps/web/src/components/custom-modules/custom-module-form-modal.tsx
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
- CHANGELOG.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 261003-387: Modulkategorien durch Administratoren bearbeitbar
|
||||||
|
|
||||||
|
Administratoren pflegen die Modulkategorien ihrer Organisation jetzt selbst (Administrator → Module → „Kategorien“): anlegen, umbenennen, sortieren, Module und gemeinsame eigene Module zuordnen und sortieren, löschen mit Zielkategorie. Seitenleiste, Marktplatz, Kategorieseite, Freigaben-Matrix und das Formular für eigene Module folgen der Einstellung.
|
||||||
|
|
||||||
|
## Was gebaut wurde
|
||||||
|
|
||||||
|
**Task 1 (Tracer, 8ec116c):** Tabellen `ModuleCategory` und `ModuleCategoryPlacement` mit `FORCE ROW LEVEL SECURITY` und `tenant_isolation_policy`, Spalte `CustomModule.sortOrder`; Migration lokal angewendet (`prisma migrate diff` gegen die Datenbank ist leer). `ModuleCategoriesService` mit Grundbestand je Organisation (`ensure`), `listCategories`, `applyToModules`, `rename`. `GET /module-categories` (jeder Angemeldete), `PATCH :key` (nur Administratoren). `/modules/active` liefert die wirksame Kategorie samt `sortOrder`. Web: API-Client, Kategorienspeicher, `module-category-order.ts`, `useCategoryLabel` (gespeicherter Name vor Übersetzung vor Kennung), Seitenleiste ordnet Gruppen und Einträge nach dem Speicher.
|
||||||
|
|
||||||
|
**Task 2 (API, af1878d):** `create`, `reorderCategories`, `assign`, `reorderItems`, `remove`, `getOverview`, `assertCategoryKey`; Controller mit Routen `overview`, `POST`, `order`, `assignment` vor `:key`, dazu `:key/items` und `DELETE :key?moveTo=`. Überlagerung in `GET /modules`, `/modules/catalog` und `/module-grants/matrix`. Eigene Module prüfen die Kategorie gegen die Organisation (400), das DTO prüft nur das Format der Kennung. Zugriffsinventar (Zeilen, Bereichszeile, Summe, Paarzahl) nachgezogen.
|
||||||
|
|
||||||
|
**Task 3 (Web, f2c0a89):** Verwaltungsseite `/admin/modules/categories` mit Löschdialog (Zielauswahl), Knopf „Kategorien“ neben „Freigaben-Matrix“, Kategorie-Plakette mit Anzeigenamen, Marktplatz-Chips in Kategorienreihenfolge, Kategorieseite mit gespeichertem Namen, Formular mit allen Kategorien der Organisation (vorhandener Wert bleibt Option), Texte de/en, CHANGELOG, Handbuch Administration und Anwender.
|
||||||
|
|
||||||
|
## Entscheidungen E-01 bis E-09 (wie im Plan umgesetzt)
|
||||||
|
|
||||||
|
- **E-01** `ModuleCategory {id, tenantId, key, name?, sortOrder, isSystem}`, eindeutig `(tenantId, key)`; Kennung unveränderlich, `isSystem` nur für `custom-modules`.
|
||||||
|
- **E-02** `ModuleCategoryPlacement` je `(tenantId, moduleId)`; wirksam ist die Zuordnung, sonst `Module.category`. `Module.category` wird nie geschrieben (durch Test belegt).
|
||||||
|
- **E-03** Gemeinsame eigene Module: `CustomModule.category` und neue Spalte `sortOrder` direkt; persönliche bekommen nie eine `sortOrder`.
|
||||||
|
- **E-04** Grundbestand beim ersten Lesen, ohne SQL-Rückfüllung; fehlende benutzte Kennungen werden hinten nachgelegt; eine gelöschte Standardkategorie kommt nur zurück, wenn ein Modul sie wirksam benutzt.
|
||||||
|
- **E-05** Löschen verschiebt Marktplatz-Module (Zuordnung umgeschrieben bzw. neu angelegt), gemeinsame UND persönliche eigene Module in einer Transaktion; ohne Ziel 409.
|
||||||
|
- **E-06** Kennung aus dem Namen (ä→ae …, höchstens 40 Zeichen, leer → `kategorie`), bei Kollision mit Kennung, Modul-Slug oder `custom` `-2`, `-3` …
|
||||||
|
- **E-07** Überlagerung serverseitig in allen Modullisten (Kategorie-Reihenfolge, dann `sortOrder` mit null zuletzt, dann Name).
|
||||||
|
- **E-08** Seitenleiste: `sortOrder` aufsteigend, null zuletzt, eingebaut vor eigenem, dann Name.
|
||||||
|
- **E-09** Benutzer-Zugriffsdialog unverändert; keine „Auf Standardnamen zurücksetzen“-Funktion.
|
||||||
|
|
||||||
|
Zusätzlich festgelegt (Planer-Ermessen): Die letzte verbleibende Kategorie kann nie gelöscht werden, weil „Eigene Module“ nicht löschbar ist; dadurch fällt `ensure` nie auf den Standardbestand zurück.
|
||||||
|
|
||||||
|
## Befund zur Slug-Auflösung von /modules/[category]/[moduleSlug]
|
||||||
|
|
||||||
|
Die Seite löst das Modul ausschließlich über `moduleSlug` auf (`ModuleShell` → `ModuleAccessGate`); `category` wird nur für den „Zurück“-Link verwendet. Alte Adressen `/modules/<alte-kategorie>/<slug>` öffnen das Modul deshalb weiter. Einzige Folge: Der „Zurück“-Link einer solchen alten Adresse führt auf `/modules/<alte-kategorie>`, und diese Kategorieseite zeigt dann „Keine aktiven Module in dieser Kategorie“ (kein 404, nur leer). Der Plan lässt den Link bewusst bestehen; nicht geändert.
|
||||||
|
|
||||||
|
## Abweichungen vom Plan
|
||||||
|
|
||||||
|
### Automatisch behoben
|
||||||
|
|
||||||
|
**1. [Rule 3 - Blockierend] Kategorienspeicher übernahm eine unerwartete Antwort**
|
||||||
|
- **Gefunden bei:** Task 3, `tenant-selector.test.tsx` (zwei Tests rot: `categories.find is not a function`)
|
||||||
|
- **Problem:** Der Test-Fetch liefert für jede Adresse dieselben Daten; der Speicher übernahm sie als Kategorienliste.
|
||||||
|
- **Lösung:** `load()` übernimmt nur ein Feld (`Array.isArray`), sonst bleibt der bisherige Stand.
|
||||||
|
- **Dateien:** `apps/web/src/lib/stores/module-category-store.ts`
|
||||||
|
|
||||||
|
**2. [Rule 1 - Bug] Bestehende Tests an das neue Verhalten angepasst**
|
||||||
|
- `custom-module.dto.spec.ts`: „lehnt eine unbekannte Kategorie ab“ galt nur für die feste Liste; das DTO prüft jetzt das Format, die Existenz prüft der Dienst (neuer Dienst-Test, 400).
|
||||||
|
- `sidebar.test.tsx`: Kategorienspeicher als Fixture (Gruppenreihenfolge kommt nicht mehr aus dem festen Sonderfall „Eigene Module zuletzt“).
|
||||||
|
- `umlaut-dictionary.ts`: „Neuer“ in die Erlaubnisliste (korrektes Deutsch).
|
||||||
|
|
||||||
|
### Ergänzungen über den Plan hinaus
|
||||||
|
|
||||||
|
- `module-categories.fake-prisma.ts` (Speicher-Attrappe für den Dienst-Test) und `module-registry.controller.categories.spec.ts` (belegt die Überlagerung in `/modules`, `/active`, `/catalog` und Matrix).
|
||||||
|
- Zusätzliche Tests in `custom-modules-page.test.tsx` (Formular-Optionen) und `marketplace-filters.test.tsx` (Chip-Reihenfolge).
|
||||||
|
|
||||||
|
## Prüfergebnisse (ehrlich)
|
||||||
|
|
||||||
|
| Prüfung | Ergebnis |
|
||||||
|
|---|---|
|
||||||
|
| API-Tests (`pnpm --filter @tessera/api test`) | 125 Dateien, **2205 Tests, alle grün** |
|
||||||
|
| Web-Tests (`pnpm --filter @tessera/web test`) | 126 Dateien, **1361 Tests, alle grün** |
|
||||||
|
| `tsc --noEmit` api / web | beide fehlerfrei |
|
||||||
|
| `biome check` auf alle neuen Dateien | sauber (0 Fehler, 0 Warnungen) |
|
||||||
|
| `biome lint` auf berührte bestehende Dateien | 0 Fehler; 1 bereits vorhandene Warnung (`sidebar.tsx`, a11y `role="group"`), nicht von dieser Änderung |
|
||||||
|
| `rls-coverage` + `rls-access-inventory` | grün (35 Tests) |
|
||||||
|
| Routenreihenfolge (statisch vor `:key`) | Skript aus dem Plan grün, zusätzlich Controller-Test |
|
||||||
|
| Migration lokal | angewendet, `prisma migrate diff` leer, beide Tabellen `relrowsecurity` + `relforcerowsecurity` |
|
||||||
|
| `docker compose up -d --build api web` | api (healthy) und web laufen, API startet ohne Migrationsfehler, 9 Logzeilen zu `/module-categories`-Routen, `GET /module-categories` ohne Anmeldung → 401, Startseite antwortet |
|
||||||
|
|
||||||
|
Nicht geprüft: Anmeldung und Browserablauf (macht der Orchestrator). Keine Prüfung gegen die laufende API mit Administrator-Anmeldung durchgeführt.
|
||||||
|
|
||||||
|
## Zugriffsinventar
|
||||||
|
|
||||||
|
Vier neue Zeilen für `module-categories.service.ts` (`moduleCategory`, `moduleCategoryPlacement`, `customModule` gebunden, `module` ungebunden). Bereichszeile `module-categories` 4/22/0 (mit der Gate-Schleife nachgemessen), Summe 61/273/8 → 65/295/8, Paarzahl 96 → 100 (59 muss-mandantengebunden, 23 keine-mandantengebundene-tabelle, 16 beides, 2 bewusst-uebergreifend).
|
||||||
|
|
||||||
|
## Bekannte Stubs
|
||||||
|
|
||||||
|
Keine.
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
Keine neue Angriffsfläche außerhalb des Plan-Bedrohungsmodells. T-387-01 bis T-387-06 sind umgesetzt: `@Roles(ADMIN, SUPER_ADMIN)` je Verwaltungsmethode (Controller-Test, auch „keine Methode ohne Rolle außer `list`“), FORCE RLS plus `tenantId` in jedem `where`, persönliche Einträge nur als Anzahl, `assign`/`reorderItems` nur für gemeinsame Einträge (persönliche und fremde: 404/400), 409 ohne Ziel und Transaktion beim Löschen, Kennungen per Regex im DTO.
|
||||||
|
|
||||||
|
## Offene Hinweise
|
||||||
|
|
||||||
|
- Das Ändern der Kategorie eines gemeinsamen eigenen Moduls über dessen Formular setzt `sortOrder` nicht zurück; die alte Position kann in der neuen Kategorie also mitten in der Reihenfolge landen, bis ein Administrator neu sortiert. Zuordnen über die Kategorienseite hängt dagegen hinten an.
|
||||||
|
- Gepusht wurde nichts; `STATE.md`, `PLAN.md` und diese Datei sind nicht committet (Vorgabe).
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- Neue Dateien vorhanden (Migration, Dienst, Controller, DTO, Seite, Tests): bestätigt über `git diff --stat` (43 Dateien).
|
||||||
|
- Commits vorhanden: 8ec116c, af1878d, f2c0a89 (`git rev-list --count 53a49a1..HEAD` = 3).
|
||||||
|
|
||||||
|
## Browser-Prüfung (Orchestrator, 03.10., lokal)
|
||||||
|
|
||||||
|
- Administrator → Module → Kategorien: alle Bereiche mit Modulen, „Eigene Module“ nicht löschbar.
|
||||||
|
- Neue Kategorie „Server“ angelegt, Proxmox per Auswahl hinein, nach ganz oben sortiert → Seitenleiste folgt.
|
||||||
|
- Umbenannt in „Serverraum“ (Kennung bleibt `server`).
|
||||||
|
- „Infrastruktur“ gelöscht mit Ziel „Serverraum“ → Nextcloud-Status umgezogen.
|
||||||
|
- Marktplatz und Freigaben-Matrix zeigen dieselbe Einteilung und Reihenfolge.
|
||||||
|
- Alte Adressen /modules/infrastructure/proxmox und …/nextcloud-status öffnen weiter das Modul.
|
||||||
@@ -4,6 +4,54 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
|||||||
|
|
||||||
## Unveröffentlicht
|
## Unveröffentlicht
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- Kategorien der Module lassen sich jetzt von Administratoren selbst gestalten, unter Administrator → Module → „Kategorien“. Sie können neue Kategorien anlegen, bestehende umbenennen und mit Pfeilen in eine andere Reihenfolge bringen, jedes Modul (auch gemeinsame eigene Module) einer Kategorie zuordnen und die Module innerhalb einer Kategorie sortieren. Seitenleiste, Marktplatz und Freigaben-Matrix folgen dieser Reihenfolge und den neuen Namen. Löschen Sie eine Kategorie, in der noch Module liegen, wählen Sie eine Zielkategorie: Alle Module wandern dorthin, auch die persönlichen Einträge der Benutzer, es geht nichts verloren. „Eigene Module“ lässt sich umbenennen und verschieben, aber nicht löschen. Benutzer können für ihre eigenen Einträge jetzt jede vorhandene Kategorie wählen. Alte Modul-Adressen aus Lesezeichen funktionieren weiter.
|
||||||
|
- Neues Modul „Nextcloud-Status“ in der Gruppe „Infrastruktur“. Es zeigt für jede eingetragene Nextcloud-Cloud Ihrer Kunden eine Kachel mit Logo (oder Initialen), Kundenname, Adresse (öffnet in einem neuen Tab), installierter Version, Ampelfarbe mit kurzer Begründung und dem Zeitpunkt der letzten Prüfung; oben steht die neueste Nextcloud-Version. Grün heißt: neuester Stand seiner Version und der Support läuft noch mehr als drei Monate. Gelb heißt: ein Update steht an oder der Support endet in den nächsten drei Monaten. Rot heißt: der Support ist abgelaufen, die Cloud ist nicht erreichbar, im Wartungsmodus oder wartet auf eine Datenbank-Aktualisierung. Grau heißt: Bewertung nicht möglich, zum Beispiel wenn die Versionsdaten gerade nicht abrufbar sind. Tessera prüft jede Cloud automatisch einmal pro Stunde; „Jetzt prüfen“ und ein Knopf je Kachel prüfen sofort. Die Kacheln lassen sich nach Kundenname, Status (Rot zuerst), Version oder Support-Ende sortieren, die Wahl merkt sich Tessera für jeden Benutzer. Clouds eintragen, ändern, entfernen und Logos hinterlegen (Bild hochladen bis 1 MB oder eine https-Bildadresse) dürfen Administratoren und Benutzer mit der Freigabestufe „Verwalten“; alle anderen mit Freigabe sehen die Kacheln. Aktivieren Sie das Modul als Administrator im Marktplatz und erteilen Sie die Freigabe.
|
||||||
|
- Nextcloud-Status: Auf jeder Kachel gibt es jetzt eine Glocke „Benachrichtigen“, die jeder Benutzer mit Zugriff auf das Modul für sich ein- und ausschalten kann. Ist sie an, meldet Tessera per E-Mail und, solange Tessera geöffnet ist, als Benachrichtigung auf dem Bildschirm (in der Desktop-App als Windows-Benachrichtigung), wenn die Cloud eine Störung hat – nicht erreichbar, keine gültige Antwort, Wartungsmodus, ausstehende Datenbank-Aktualisierung oder abgelaufener Support – und wenn sie wieder in Ordnung ist. Pro Änderung kommt genau eine Nachricht, solange die Störung anhält, nicht jede Stunde neu. Ein einzelner fehlgeschlagener Abruf löst keine Meldung aus: Die Kachel zeigt weiter den letzten guten Stand mit dem Hinweis „Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt“, und Tessera prüft nach etwa fünf Minuten erneut. Für die E-Mails muss der Mailversand eingerichtet sein und im Benutzerprofil eine E-Mail-Adresse stehen. Außerdem nennt die Kachel bei „Nicht erreichbar“ jetzt den Grund in Klartext, zum Beispiel „Zertifikat passt nicht zur Adresse“, „Adresse nicht gefunden“ oder „Zeitüberschreitung“, statt eines technischen Fehlercodes.
|
||||||
|
- Neue Dashboard-Kachel „Nextcloud-Status“: drei Zähler für Grün, Gelb und Rot und darunter die roten und gelben Clouds mit Kundenname und Grund. Ein Klick öffnet das Modul. Die Kachel erscheint nur für Benutzer, die das Modul nutzen dürfen.
|
||||||
|
- Neue Gruppe „Finanzbuchhaltung“ in der Seitenleiste mit zwei Modulen. Beide aktiviert ein Administrator im Marktplatz; wer sie nutzen soll, bekommt zusätzlich die Freigabe.
|
||||||
|
- Kantinenabrechnung: Die CSV-Datei der Kantine hochladen (Excel-Export mit UTF-8 oder Windows-1252 ist beides in Ordnung). Tessera zeigt Zeilenzahl, Abrechnungsmonat und Gesamtbetrag, nennt fehlerhafte Zeilen mit Zeilennummer und weist auf unterschiedliche Abrechnungsmonate hin. Ist alles in Ordnung, laden Sie mit einem Klick die DATEV-Lohndatei herunter. Beraternummer, Mandantennummer und Lohnart trägt ein Administrator einmalig ein; bis dahin ist der Download gesperrt. Die hochgeladenen Daten werden nicht gespeichert.
|
||||||
|
- Handelsware: Eine Excel-Liste mit Handelswaren-Umsätzen hochladen. Tessera ordnet jedem Produkt sein Konto zu, markiert neue Produkte mit „neu“ und vergibt ihnen das nächste freie Gegenkonto. Das Buchungsdatum wird aus dem Dateinamen abgeleitet (letzter Tag des Monats) und lässt sich ändern. Neue Konten werden erst beim Herunterladen der Buchungsdatei gespeichert. Im Reiter „Konten“ pflegen Sie die Kontenliste, lesen sie aus einer CSV-Datei ein (ersetzt alle vorhandenen Konten, nach Rückfrage) und exportieren sie als CSV. Standard-Erlöskonto und Startwert für das Gegenkonto trägt ein Administrator einmalig ein.
|
||||||
|
- Modul-Freigaben haben jetzt zwei Stufen: „Benutzen“ (wie bisher) und „Verwalten“. Wer ein Modul verwalten darf, ändert dessen Einstellungen selbst, ohne Administrator zu sein, zum Beispiel in der Kantinenabrechnung, bei Handelsware und bei den Proxmox-Servern. Ein Administrator wählt die Stufe je Gruppe in der Freigaben-Matrix oder je Benutzer in den Benutzerdetails; bestehende Freigaben bleiben „Benutzen“. Freigaben vergeben und Module aktivieren dürfen weiterhin nur Administratoren. Das Modul DKV-Rechnung steht Administratoren und Benutzern mit „Verwalten“ zur Verfügung.
|
||||||
|
|
||||||
|
## 1.9.2 – 2026-10-02
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- Zertifikat-Manager: Neuer Reiter „Übersicht“. Ziehen Sie alle Dateien, die Sie vom Zertifikatsaussteller bekommen haben, auf einmal hinein – gern auch direkt die ZIP-Datei. Tessera zeigt, was jede Datei ist (Serverzertifikat, Zwischenzertifikat, Stammzertifikat, privater Schlüssel, Zertifikatsanfrage), wofür sie gebraucht wird, wie lange sie gültig ist und was zusammengehört. Unter jedem Teil können Sie es in jedem passenden Format herunterladen: Zertifikate als PEM (.crt), DER (.cer), mit Kette, PKCS#7 (.p7b) oder als PFX mit Schlüssel und Kette; den Schlüssel als PEM, RSA-PEM oder DER; die Anfrage als PEM oder DER. Passwortgeschützte PFX-Dateien lassen sich mit dem Passwort entsperren.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Desktop-App: Downloads, die Tessera erst im Fenster erstellt (zum Beispiel im Zertifikat-Manager), werden jetzt im Ordner „Downloads“ gespeichert; eine Meldung nennt den Dateinamen. Bisher öffnete Windows nur den Hinweis „Holen Sie sich eine App, um diesen ‚blob‘-Link zu öffnen“. Dafür ist die neue Version der Desktop-App nötig.
|
||||||
|
- Zertifikat-Manager: Die Texte sprechen Sie jetzt durchgehend mit „Sie“ an.
|
||||||
|
|
||||||
|
## 1.9.1 – 2026-10-01
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Desktop-App: Favoriten und andere Links, die sich in einem neuen Fenster öffnen (etwa „In neuem Tab öffnen“ oder Quellen im Ausschreibungs-Radar), öffnen sich jetzt in Ihrem normalen Browser. Bisher passierte beim Klick in der Desktop-App nichts.
|
||||||
|
- Erinnerungen: Beim Schreiben der Beschreibung springt der Cursor nicht mehr in die Titelzeile zurück. Bisher passierte das alle paar Sekunden, weil sich die Kachel regelmäßig neu aufbaut.
|
||||||
|
- Favoriten: Auch Seiten, die ihr Logo erst beim Laden im Browser setzen (etwa Host Europe), zeigen jetzt ihr Logo statt nur des Anfangsbuchstabens. Findet Tessera auf der Seite selbst kein Logo, fragt es bei öffentlichen Adressen einen Logo-Dienst; interne Adressen werden dabei nie weitergegeben. Das gilt auch für bereits angelegte Favoriten.
|
||||||
|
|
||||||
|
## 1.9.0 – 2026-09-30
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- Verwaltung: Eigene Vorlage für die Willkommensmail unter Administrator → Willkommensmail. Betreff, Überschrift, Einleitung, Abschluss und der Anmeldehinweis (getrennt für Verzeichnis- und lokale Konten) lassen sich anpassen, mit Platzhaltern wie {{vorname}}, {{benutzername}}, {{adresse}} oder {{firma}} (eine Tabelle auf der Seite erklärt sie). Die Seite zeigt eine Live-Vorschau der echten Mail und schickt auf Wunsch eine Testmail an Ihre Adresse; „Auf Standard zurücksetzen“ stellt die mitgelieferten Texte wieder her. Logo, Zugangsdaten und Knöpfe fügt Tessera immer selbst ein.
|
||||||
|
- Benutzerverwaltung: Willkommensmail. Über das Briefsymbol in der Benutzerliste schicken Sie einem Benutzer eine gestaltete Willkommensmail mit Tessera-Logo, Adresse, Benutzername und einem Knopf „Zu Tessera“. Konten aus dem Verzeichnis erhalten den Hinweis auf ihr Windows-Passwort, lokale Konten einen Link „Passwort festlegen“ (7 Tage gültig) – ein Passwort steht nie in der Mail. Die Liste zeigt, wann die Mail zuletzt ging.
|
||||||
|
- Benutzerverwaltung: Neue Spalte „Letzte Anmeldung“.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Benutzerverwaltung: Die Aktionen je Zeile sind jetzt Symbole (Willkommensmail, Details, Bearbeiten, Löschen), damit die Liste ohne seitliches Scrollen passt.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Anmeldung: Wer schon angemeldet ist und die Anmeldeseite aufruft, landet jetzt direkt auf dem Dashboard.
|
||||||
|
- Willkommensmail: Logo und Schriftzug erscheinen jetzt in jedem Mailprogramm. Bisher steckten sie in einem Bild; zeigte Outlook es nicht an, blieb nur ein großer schwarzer Kasten. Die Welle darunter ist nur noch ein schmaler Streifen; zeigt ein Mailprogramm sie nicht an (etwa Outlook im Browser), bleibt keine weiße Lücke mehr.
|
||||||
|
- Anmeldung: Eine geänderte Rolle, eine Deaktivierung oder das Löschen eines Kontos wirkt jetzt sofort. Bisher galt bis zu 30 Tage die Rolle vom Zeitpunkt der Anmeldung weiter – ein herabgestufter Administrator behielt seine Rechte, ein deaktiviertes Konto konnte mit seiner Sitzung weiterarbeiten, und die Benutzerliste ließ sich nach einer Rollenänderung nicht laden.
|
||||||
|
|
||||||
## 1.8.0 – 2026-09-30
|
## 1.8.0 – 2026-09-30
|
||||||
|
|
||||||
### Neu
|
### Neu
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ COPY --from=builder /app/node_modules/.pnpm/@prisma+client@6.19.3_prisma@6.19.3_
|
|||||||
COPY --from=builder /app/apps/api/prisma ./apps/api/prisma
|
COPY --from=builder /app/apps/api/prisma ./apps/api/prisma
|
||||||
COPY --from=builder /app/packages/shared/src ./packages/shared/src
|
COPY --from=builder /app/packages/shared/src ./packages/shared/src
|
||||||
COPY apps/api/scripts ./apps/api/scripts
|
COPY apps/api/scripts ./apps/api/scripts
|
||||||
|
# Kopfbild der Willkommensmail (MailService.loadWelcomeHeaderPng liest
|
||||||
|
# apps/api/assets/mail/welcome-header.png relativ zu dist/mail/).
|
||||||
|
COPY apps/api/assets ./apps/api/assets
|
||||||
# Desktop-Pakete (Phase 18, D-08): im CI legt desktop-collect.sh Pakete +
|
# Desktop-Pakete (Phase 18, D-08): im CI legt desktop-collect.sh Pakete +
|
||||||
# manifest.json in diesen Ordner, lokal liegt nur der Platzhalter. Nur
|
# manifest.json in diesen Ordner, lokal liegt nur der Platzhalter. Nur
|
||||||
# lesend zur Laufzeit -- kein chown noetig.
|
# lesend zur Laufzeit -- kein chown noetig.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="80" viewBox="0 0 1200 80">
|
||||||
|
<!--
|
||||||
|
Wellenstreifen unter dem Kopf der Tessera-Systemmails (Willkommensmail).
|
||||||
|
Quelle des PNG daneben (welcome-header.png, 1200x80, angezeigt 600x40);
|
||||||
|
erzeugt mit `node apps/api/scripts/render-mail-header.mjs`.
|
||||||
|
|
||||||
|
Seit quick-260930 (Rueckmeldung des Nutzers: in Outlook "ein riesiger
|
||||||
|
schwarzer Fleck, kein Logo") steckt KEIN Logo und KEIN Text mehr im Bild:
|
||||||
|
Bildmarke und Schriftzug stehen als HTML im Mailkopf und erscheinen immer.
|
||||||
|
Dieses Bild ist nur noch Schmuck: oben die Kopffarbe, darunter die
|
||||||
|
Duenen-Wellen des Dashboard-Hintergrunds (dunkle Fassung,
|
||||||
|
apps/web/src/lib/dashboard-background.ts) mit der feinen gelben Linie,
|
||||||
|
unten laeuft es ins Weiss der Karte aus. Zeigt ein Mailprogramm das Bild
|
||||||
|
nicht (Outlook mit gesperrten Bildern), bleibt dort die dunkle Kopffarbe
|
||||||
|
der Zelle stehen — der Kopf wirkt nur etwas hoeher, keine weisse Luecke.
|
||||||
|
Seit quick-260930 (zweite Rueckmeldung) 80 statt 112 px hoch (y-Werte
|
||||||
|
x 80/112), in der Mail 40 statt 56 px.
|
||||||
|
-->
|
||||||
|
<rect width="1200" height="80" fill="#ffffff"/>
|
||||||
|
<!-- Flaechen von unten nach oben uebereinander, jede von oben bis zu ihrer
|
||||||
|
Wellenlinie: so teilen sich benachbarte Baender dieselbe Kante, ohne
|
||||||
|
Luecken dazwischen. -->
|
||||||
|
<path d="M0 0 V65.7 C220 55.7 420 72.9 600 68.6 S1000 54.3 1200 61.4 V0 Z" fill="#d9dce0"/>
|
||||||
|
<path d="M0 0 V50 C250 35.7 420 61.4 640 54.3 S1040 35.7 1200 45.7 V0 Z" fill="#2c3036"/>
|
||||||
|
<path d="M0 0 V31.4 C330 12.9 520 44.3 700 37.1 S1060 15.7 1200 28.6 V0 Z" fill="#1a1c20"/>
|
||||||
|
<path d="M0 31.4 C330 12.9 520 44.3 700 37.1 S1060 15.7 1200 28.6" fill="none" stroke="#ffed00" stroke-opacity="0.75" stroke-width="3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
-- Willkommensmail aus der Benutzerverwaltung (Administrator → Benutzer).
|
||||||
|
--
|
||||||
|
-- Merkt pro Benutzer, wann zuletzt eine Willkommensmail verschickt wurde,
|
||||||
|
-- damit die Liste "Willkommensmail gesendet am …" zeigen kann. Gesetzt nur
|
||||||
|
-- ueber POST /users/:id/welcome-mail nach erfolgreichem Versand; erneutes
|
||||||
|
-- Senden ueberschreibt den Wert. NULL = nie gesendet. Kein Standardwert,
|
||||||
|
-- kein Backfill.
|
||||||
|
--
|
||||||
|
-- Keine neue Regel noetig: die Spalte liegt in "User", dessen
|
||||||
|
-- tenant_isolation_policy die ganze Zeile schuetzt. Die Anmelde-Funktionen
|
||||||
|
-- auth_lookup_* liefern eine feste Spaltenliste (RETURNS TABLE) und bleiben
|
||||||
|
-- unberuehrt.
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "welcomeMailSentAt" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
-- Willkommensmail: eigene Vorlage je Mandant (Administrator → Willkommensmail).
|
||||||
|
--
|
||||||
|
-- Zweck: neue Tabelle "WelcomeMailTemplate". Ein Administrator kann Betreff,
|
||||||
|
-- Ueberschrift, Einleitung und Abschlusstext der Willkommensmail anpassen
|
||||||
|
-- (mit Platzhaltern wie {{name}}). Hoechstens EINE Zeile je Mandant
|
||||||
|
-- ("tenantId" eindeutig); "Auf Standard zuruecksetzen" loescht die Zeile, dann
|
||||||
|
-- gelten wieder die Standardtexte aus dem Code. Die festen Bausteine der Mail
|
||||||
|
-- (Kopf, Zugangsdaten, Anmeldehinweis, Knoepfe, Fusszeile) stehen NICHT in
|
||||||
|
-- der Tabelle. "updatedBy" haelt den Benutzernamen des letzten Bearbeiters
|
||||||
|
-- als reinen Anzeigetext (keine Relation).
|
||||||
|
--
|
||||||
|
-- Von Hand geschrieben (Vorbild 20260929120000_custom_module).
|
||||||
|
--
|
||||||
|
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die
|
||||||
|
-- Tabelle traegt `tenantId` und `tenant_isolation_policy` OHNE
|
||||||
|
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — die
|
||||||
|
-- Vorlage ist Verwaltungsdatum des Mandanten, nicht persoenliches Datum eines
|
||||||
|
-- einzelnen Benutzers.
|
||||||
|
--
|
||||||
|
-- BEWUSST KEINE `system_read_policy`: gelesen wird nur beim Versand einer
|
||||||
|
-- Willkommensmail, und zwar gebunden an den Mandanten des Zielbenutzers
|
||||||
|
-- (`forTenant(prisma, tenantId)`); es gibt keinen Hintergrunddienst, der die
|
||||||
|
-- Vorlagen ueber alle Mandanten liest.
|
||||||
|
--
|
||||||
|
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
|
||||||
|
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
|
||||||
|
-- tun.
|
||||||
|
--
|
||||||
|
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
|
||||||
|
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
|
||||||
|
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
|
||||||
|
|
||||||
|
CREATE TABLE "WelcomeMailTemplate" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"subject" TEXT NOT NULL,
|
||||||
|
"heading" TEXT NOT NULL,
|
||||||
|
"intro" TEXT NOT NULL,
|
||||||
|
"closing" TEXT NOT NULL,
|
||||||
|
"updatedBy" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "WelcomeMailTemplate_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "WelcomeMailTemplate_tenantId_key" ON "WelcomeMailTemplate"("tenantId");
|
||||||
|
|
||||||
|
ALTER TABLE "WelcomeMailTemplate" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "WelcomeMailTemplate" FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_policy ON "WelcomeMailTemplate"
|
||||||
|
USING ("tenantId" = current_tenant_id());
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Willkommensmail: Anmeldehinweis je Kontoart als Teil der eigenen Vorlage.
|
||||||
|
--
|
||||||
|
-- Zweck: zwei neue Spalten in "WelcomeMailTemplate" —
|
||||||
|
-- "loginHintDirectory" (Hinweis fuer verzeichnisgefuehrte Konten, Standard
|
||||||
|
-- "Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten
|
||||||
|
-- Windows-Passwort an.") und "loginHintLocal" (Hinweis vor dem Knopf
|
||||||
|
-- "Passwort festlegen" fuer lokale Konten).
|
||||||
|
--
|
||||||
|
-- Bewusst NULLABLE ohne Default: bestehende Vorlagen behalten NULL, und
|
||||||
|
-- WelcomeMailTemplateService setzt dafuer den Standardtext aus
|
||||||
|
-- @tessera/shared ein. So steht der Standardtext nur an EINER Stelle im
|
||||||
|
-- Code und nicht zusaetzlich in der Datenbank.
|
||||||
|
--
|
||||||
|
-- Zeilenschutz: unveraendert (tenant_isolation_policy der Tabelle gilt fuer
|
||||||
|
-- die neuen Spalten mit).
|
||||||
|
|
||||||
|
ALTER TABLE "WelcomeMailTemplate" ADD COLUMN "loginHintDirectory" TEXT;
|
||||||
|
ALTER TABLE "WelcomeMailTemplate" ADD COLUMN "loginHintLocal" TEXT;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
-- 261002-fm5 — Finanzbuchhaltung: Modul "Kantinenabrechnung" (kantine-datev).
|
||||||
|
--
|
||||||
|
-- Zweck: eine neue Tabelle `KantineDatevConfig` mit den drei Nummern, die der
|
||||||
|
-- Administrator einmalig je Mandant hinterlegt (Beraternummer, Mandantennummer,
|
||||||
|
-- Lohnart). Eine Zeile je Mandant (Singleton, Vorbild `DkvModuleConfig`). Die
|
||||||
|
-- Felder sind Text, damit fuehrende Nullen erhalten bleiben, und haben
|
||||||
|
-- ABSICHTLICH keinen Standardwert: solange sie leer sind, sperrt das Modul die
|
||||||
|
-- Verarbeitung. Die hochgeladene Kantinen-CSV wird nicht gespeichert.
|
||||||
|
--
|
||||||
|
-- Von Hand geschrieben (Vorbild 20260923140000_proxmox_server), von Hand
|
||||||
|
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
|
||||||
|
--
|
||||||
|
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die Tabelle
|
||||||
|
-- traegt `tenantId` und `tenant_isolation_policy` OHNE Benutzerdimension
|
||||||
|
-- (`USING ("tenantId" = current_tenant_id())`, Form aus `DkvModuleConfig`) —
|
||||||
|
-- Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers.
|
||||||
|
-- Keine `system_read_policy`: es gibt keinen Hintergrunddienst, der diese
|
||||||
|
-- Einstellungen ueber alle Mandanten liest.
|
||||||
|
--
|
||||||
|
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
|
||||||
|
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
|
||||||
|
-- tun.
|
||||||
|
--
|
||||||
|
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
|
||||||
|
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
|
||||||
|
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
|
||||||
|
|
||||||
|
CREATE TABLE "KantineDatevConfig" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"beraterNr" TEXT,
|
||||||
|
"mandantNr" TEXT,
|
||||||
|
"lohnart" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "KantineDatevConfig_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "KantineDatevConfig_tenantId_key" ON "KantineDatevConfig"("tenantId");
|
||||||
|
CREATE INDEX "KantineDatevConfig_tenantId_idx" ON "KantineDatevConfig"("tenantId");
|
||||||
|
|
||||||
|
ALTER TABLE "KantineDatevConfig" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "KantineDatevConfig" FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_policy ON "KantineDatevConfig"
|
||||||
|
USING ("tenantId" = current_tenant_id());
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
-- 261002-fm5 — Finanzbuchhaltung: Modul "Handelsware" (handelsware-datev).
|
||||||
|
--
|
||||||
|
-- Zweck: zwei neue Tabellen. `HandelswareDatevConfig` traegt die Einstellungen
|
||||||
|
-- des Mandanten (Standard-Erloeskonto fuer neue Konten, Startwert fuer die
|
||||||
|
-- Gegenkonto-Vergabe bei leerer Kontenliste) — eine Zeile je Mandant
|
||||||
|
-- (Singleton, Vorbild `DkvModuleConfig`/`KantineDatevConfig`). Beide Zahlen
|
||||||
|
-- haben ABSICHTLICH keinen Standardwert: solange sie leer sind, sperrt das
|
||||||
|
-- Modul die Verarbeitung. `HandelswareKonto` ist die Kontenliste (Produktname
|
||||||
|
-- -> Gegenkonto, Erloeskonto) — mehrere Zeilen je Mandant, der Name ist je
|
||||||
|
-- Mandant eindeutig, das Gegenkonto bewusst nicht (mehrere Produkte duerfen
|
||||||
|
-- auf dasselbe Gegenkonto laufen).
|
||||||
|
--
|
||||||
|
-- Von Hand geschrieben (Vorbild 20260923140000_proxmox_server), von Hand
|
||||||
|
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
|
||||||
|
--
|
||||||
|
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): beide
|
||||||
|
-- Tabellen tragen `tenantId` und `tenant_isolation_policy` OHNE
|
||||||
|
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`, Form aus
|
||||||
|
-- `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten
|
||||||
|
-- eines Benutzers. Keine `system_read_policy`: es gibt keinen Hintergrunddienst,
|
||||||
|
-- der diese Tabellen ueber alle Mandanten liest.
|
||||||
|
--
|
||||||
|
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
|
||||||
|
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
|
||||||
|
-- tun.
|
||||||
|
--
|
||||||
|
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
|
||||||
|
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
|
||||||
|
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
|
||||||
|
|
||||||
|
-- 1) HandelswareDatevConfig
|
||||||
|
CREATE TABLE "HandelswareDatevConfig" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"erloeskonto" INTEGER,
|
||||||
|
"startGegenkonto" INTEGER,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "HandelswareDatevConfig_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "HandelswareDatevConfig_tenantId_key" ON "HandelswareDatevConfig"("tenantId");
|
||||||
|
CREATE INDEX "HandelswareDatevConfig_tenantId_idx" ON "HandelswareDatevConfig"("tenantId");
|
||||||
|
|
||||||
|
ALTER TABLE "HandelswareDatevConfig" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "HandelswareDatevConfig" FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_policy ON "HandelswareDatevConfig"
|
||||||
|
USING ("tenantId" = current_tenant_id());
|
||||||
|
|
||||||
|
-- 2) HandelswareKonto
|
||||||
|
CREATE TABLE "HandelswareKonto" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"gegenkonto" INTEGER NOT NULL,
|
||||||
|
"erloeskonto" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "HandelswareKonto_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "HandelswareKonto_tenantId_name_key" ON "HandelswareKonto"("tenantId", "name");
|
||||||
|
CREATE INDEX "HandelswareKonto_tenantId_idx" ON "HandelswareKonto"("tenantId");
|
||||||
|
|
||||||
|
ALTER TABLE "HandelswareKonto" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "HandelswareKonto" FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_policy ON "HandelswareKonto"
|
||||||
|
USING ("tenantId" = current_tenant_id());
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- 261002-icv — Freigabestufe fuer Modul-Freigaben: Benutzen (USE) und
|
||||||
|
-- Verwalten (MANAGE).
|
||||||
|
--
|
||||||
|
-- Zweck: jede Zeile in "ModuleGrant" bekommt eine Stufe. USE ist der Bestand
|
||||||
|
-- und der Standard (Modul oeffnen und benutzen). MANAGE erlaubt zusaetzlich,
|
||||||
|
-- die eigenen Einstellungen dieses einen Moduls zu aendern. Freigaben
|
||||||
|
-- erteilen, Module aktivieren und die uebrige Verwaltung bleiben
|
||||||
|
-- Administratoren vorbehalten (das erzwingt die Anwendung, nicht diese
|
||||||
|
-- Migration).
|
||||||
|
--
|
||||||
|
-- Bestandsdaten: durch den DEFAULT 'USE' werden alle vorhandenen Freigaben zu
|
||||||
|
-- USE — niemand gewinnt durch die Migration Rechte.
|
||||||
|
--
|
||||||
|
-- Von Hand geschrieben (Vorbild 20261002120000_kantine_datev_config).
|
||||||
|
--
|
||||||
|
-- Zeilenschutz: keine neue Tabelle. Die vorhandenen Regeln auf "ModuleGrant"
|
||||||
|
-- filtern Zeilen, nicht Spalten, und bleiben unveraendert — rls-coverage
|
||||||
|
-- braucht nichts. PostgreSQL gewaehrt USAGE auf neue Typen automatisch an
|
||||||
|
-- PUBLIC, die Anwendungsrolle tessera_app kann den Aufzaehlungstyp also
|
||||||
|
-- verwenden.
|
||||||
|
--
|
||||||
|
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken die Zeilenregeln erst,
|
||||||
|
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
|
||||||
|
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
|
||||||
|
|
||||||
|
CREATE TYPE "ModuleGrantLevel" AS ENUM ('USE', 'MANAGE');
|
||||||
|
|
||||||
|
ALTER TABLE "ModuleGrant" ADD COLUMN "level" "ModuleGrantLevel" NOT NULL DEFAULT 'USE';
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
-- quick-261002-k67 — Modul Nextcloud-Status.
|
||||||
|
--
|
||||||
|
-- Zweck: neue Tabelle "NextcloudInstance" fuer die vom Verwalter
|
||||||
|
-- eingetragenen Nextcloud-Clouds der Kunden (Kundenname, Adresse, optionales
|
||||||
|
-- Logo) samt zuletzt ermitteltem Zustand (Erreichbarkeit, Wartungsmodus,
|
||||||
|
-- Versionstext, Fehlerart). Der Zustand liegt direkt auf der Zeile, es gibt
|
||||||
|
-- kein Zwischenlager. Logo-Bytes liegen als BYTEA an der Zeile (hoechstens
|
||||||
|
-- 1 MiB, Pruefung im Dienst); Abfragen ausser dem Logo-Abruf waehlen sie
|
||||||
|
-- nie mit aus.
|
||||||
|
--
|
||||||
|
-- Von Hand geschrieben (Vorbild 20260923140000_proxmox_server), von Hand
|
||||||
|
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
|
||||||
|
--
|
||||||
|
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die
|
||||||
|
-- Tabelle traegt `tenantId` und `tenant_isolation_policy` OHNE
|
||||||
|
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — die
|
||||||
|
-- Clouds sind gemeinsame Daten der Organisation, nicht persoenliche Daten
|
||||||
|
-- eines einzelnen Benutzers.
|
||||||
|
--
|
||||||
|
-- Zusaetzlich eine `system_read_policy` (Form aus
|
||||||
|
-- 20260914120000_rls_system_context_read): der stuendliche Hintergrunddienst
|
||||||
|
-- liest ueber `forSystem()` genau einmal je Durchlauf Kennung und Mandant
|
||||||
|
-- aller Clouds und prueft danach jede Cloud an ihren eigenen Mandanten
|
||||||
|
-- gebunden. Geschrieben wird nie im Systemkontext.
|
||||||
|
--
|
||||||
|
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
|
||||||
|
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
|
||||||
|
-- tun.
|
||||||
|
--
|
||||||
|
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
|
||||||
|
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
|
||||||
|
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
|
||||||
|
|
||||||
|
CREATE TABLE "NextcloudInstance" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"customerName" TEXT NOT NULL,
|
||||||
|
"baseUrl" TEXT NOT NULL,
|
||||||
|
"logoUrl" TEXT,
|
||||||
|
"logoData" BYTEA,
|
||||||
|
"logoMime" TEXT,
|
||||||
|
"logoVersion" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"lastCheckedAt" TIMESTAMP(3),
|
||||||
|
"reachable" BOOLEAN,
|
||||||
|
"maintenance" BOOLEAN,
|
||||||
|
"needsDbUpgrade" BOOLEAN,
|
||||||
|
"versionString" TEXT,
|
||||||
|
"edition" TEXT,
|
||||||
|
"productName" TEXT,
|
||||||
|
"errorKind" TEXT,
|
||||||
|
"errorDetail" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "NextcloudInstance_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "NextcloudInstance_tenantId_idx" ON "NextcloudInstance"("tenantId");
|
||||||
|
|
||||||
|
ALTER TABLE "NextcloudInstance" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "NextcloudInstance" FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_policy ON "NextcloudInstance"
|
||||||
|
USING ("tenantId" = current_tenant_id());
|
||||||
|
CREATE POLICY system_read_policy ON "NextcloudInstance"
|
||||||
|
FOR SELECT USING (is_system_context());
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
-- quick-261002-kxc — Nextcloud-Status: persoenliche Benachrichtigung.
|
||||||
|
--
|
||||||
|
-- Zweck: (1) neue Tabelle "NextcloudAlertSubscription" — wer fuer welche
|
||||||
|
-- Cloud die Glocke eingeschaltet hat (je Benutzer und Cloud hoechstens eine
|
||||||
|
-- Zeile); (2) fuenf neue Spalten an "NextcloudInstance": Zaehler und Zeitpunkt
|
||||||
|
-- der aufeinanderfolgenden Fehlschlaege (Zwei-Fehlschlaege-Regel) und der
|
||||||
|
-- zuletzt gemeldete Zustand ('ok' | 'red') samt Grund und Zeitpunkt. Der
|
||||||
|
-- gemeldete Zustand wird VOR dem Mailversand per bedingtem Update beansprucht,
|
||||||
|
-- damit mehrere API-Instanzen oder ein Neustart nie doppelt melden.
|
||||||
|
-- Bestehende Zeilen starten als 'ok' ohne Fehlschlaege.
|
||||||
|
--
|
||||||
|
-- Von Hand geschrieben (Vorbild 20261002150000_nextcloud_status und
|
||||||
|
-- 20260929140000_reminder).
|
||||||
|
--
|
||||||
|
-- Zeilenschutz: das Abonnement ist ein persoenliches Datum, deshalb
|
||||||
|
-- `tenant_isolation_policy` MIT Benutzerdimension — exakt wie "Reminder"
|
||||||
|
-- (ohne gesetzten Benutzer gilt nur der Mandant, mit Benutzer zusaetzlich
|
||||||
|
-- "userId"). Keine `system_read_policy`: die Tabelle wird nie im
|
||||||
|
-- Systemkontext gelesen, jede Abfrage laeuft an den Mandanten gebunden. Die
|
||||||
|
-- neuen Spalten von "NextcloudInstance" fallen unter deren bestehende Regeln.
|
||||||
|
--
|
||||||
|
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
|
||||||
|
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
|
||||||
|
-- tun.
|
||||||
|
--
|
||||||
|
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
|
||||||
|
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
|
||||||
|
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "NextcloudInstance"
|
||||||
|
ADD COLUMN "consecutiveFailures" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN "firstFailureAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "alertState" TEXT NOT NULL DEFAULT 'ok',
|
||||||
|
ADD COLUMN "alertReason" TEXT,
|
||||||
|
ADD COLUMN "alertChangedAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "NextcloudAlertSubscription" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"instanceId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "NextcloudAlertSubscription_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "NextcloudAlertSubscription_instanceId_userId_key" ON "NextcloudAlertSubscription"("instanceId", "userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "NextcloudAlertSubscription_tenantId_userId_idx" ON "NextcloudAlertSubscription"("tenantId", "userId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NextcloudAlertSubscription" ADD CONSTRAINT "NextcloudAlertSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "NextcloudAlertSubscription" ADD CONSTRAINT "NextcloudAlertSubscription_instanceId_fkey" FOREIGN KEY ("instanceId") REFERENCES "NextcloudInstance"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Zeilenschutz: Mandant UND Benutzer (Muster "Reminder")
|
||||||
|
ALTER TABLE "NextcloudAlertSubscription" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "NextcloudAlertSubscription" FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_policy ON "NextcloudAlertSubscription"
|
||||||
|
USING (
|
||||||
|
"tenantId" = current_tenant_id()
|
||||||
|
AND (current_user_id() IS NULL OR "userId" = current_user_id())
|
||||||
|
);
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
-- quick-261003-387 — Modulkategorien durch Administratoren bearbeitbar.
|
||||||
|
--
|
||||||
|
-- Zweck: zwei neue Tabellen und eine neue Spalte.
|
||||||
|
-- * "ModuleCategory": die Kategorien einer Organisation (Kennung, optionaler
|
||||||
|
-- eigener Name, Reihenfolge, Systemkennzeichen fuer "Eigene Module").
|
||||||
|
-- Die Kennung ist unveraenderlich, sie steht als URL-Segment in
|
||||||
|
-- /modules/<kennung>/<slug>.
|
||||||
|
-- * "ModuleCategoryPlacement": Zuordnung eines Marktplatz-Moduls zu einer
|
||||||
|
-- Kategorie der Organisation samt Reihenfolge. Die Spalte "Module"."category"
|
||||||
|
-- (fuer alle Organisationen gleich) bleibt unveraendert; die wirksame
|
||||||
|
-- Kategorie ist die Zuordnung, sonst diese Spalte.
|
||||||
|
-- * "CustomModule"."sortOrder": Reihenfolge gemeinsamer eigener Module
|
||||||
|
-- innerhalb ihrer Kategorie (NULL = noch nicht sortiert).
|
||||||
|
-- Es gibt keine Rueckfuellung in SQL: der Dienst legt den Grundbestand je
|
||||||
|
-- Organisation beim ersten Lesen an.
|
||||||
|
--
|
||||||
|
-- Von Hand geschrieben (Vorbild 20261002150000_nextcloud_status), von Hand
|
||||||
|
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
|
||||||
|
--
|
||||||
|
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): beide
|
||||||
|
-- Tabellen tragen `tenantId` und `tenant_isolation_policy` OHNE
|
||||||
|
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — die
|
||||||
|
-- Kategorien sind gemeinsame Einstellungen der Organisation, nicht
|
||||||
|
-- persoenliche Daten eines Benutzers. Keine `system_read_policy`: es gibt
|
||||||
|
-- keinen Hintergrunddienst, der darueber liest.
|
||||||
|
--
|
||||||
|
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
|
||||||
|
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
|
||||||
|
-- tun.
|
||||||
|
--
|
||||||
|
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
|
||||||
|
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
|
||||||
|
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "CustomModule" ADD COLUMN "sortOrder" INTEGER;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ModuleCategory" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"isSystem" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ModuleCategory_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ModuleCategoryPlacement" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"moduleId" TEXT NOT NULL,
|
||||||
|
"categoryKey" TEXT NOT NULL,
|
||||||
|
"sortOrder" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ModuleCategoryPlacement_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ModuleCategory_tenantId_idx" ON "ModuleCategory"("tenantId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ModuleCategory_tenantId_key_key" ON "ModuleCategory"("tenantId", "key");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ModuleCategoryPlacement_tenantId_idx" ON "ModuleCategoryPlacement"("tenantId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "ModuleCategoryPlacement_tenantId_moduleId_key" ON "ModuleCategoryPlacement"("tenantId", "moduleId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ModuleCategoryPlacement" ADD CONSTRAINT "ModuleCategoryPlacement_moduleId_fkey" FOREIGN KEY ("moduleId") REFERENCES "Module"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Zeilenschutz
|
||||||
|
ALTER TABLE "ModuleCategory" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "ModuleCategory" FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_policy ON "ModuleCategory"
|
||||||
|
USING ("tenantId" = current_tenant_id());
|
||||||
|
|
||||||
|
ALTER TABLE "ModuleCategoryPlacement" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "ModuleCategoryPlacement" FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_policy ON "ModuleCategoryPlacement"
|
||||||
|
USING ("tenantId" = current_tenant_id());
|
||||||
@@ -50,11 +50,15 @@ model User {
|
|||||||
// sonst das durch parseDashboardBackground (@tessera/shared) normalisierte
|
// sonst das durch parseDashboardBackground (@tessera/shared) normalisierte
|
||||||
// Objekt, auch { kind: 'none' } fuer bewusst "kein Hintergrund"
|
// Objekt, auch { kind: 'none' } fuer bewusst "kein Hintergrund"
|
||||||
dashboardBackground Json?
|
dashboardBackground Json?
|
||||||
|
// Willkommensmail aus der Benutzerverwaltung: Zeitpunkt des letzten
|
||||||
|
// Versands; null = nie gesendet
|
||||||
|
welcomeMailSentAt DateTime?
|
||||||
passwordResetTokens PasswordResetToken[]
|
passwordResetTokens PasswordResetToken[]
|
||||||
groupMemberships GroupMembership[]
|
groupMemberships GroupMembership[]
|
||||||
moduleGrants ModuleGrant[]
|
moduleGrants ModuleGrant[]
|
||||||
customModules CustomModule[]
|
customModules CustomModule[]
|
||||||
reminders Reminder[]
|
reminders Reminder[]
|
||||||
|
nextcloudAlertSubscriptions NextcloudAlertSubscription[]
|
||||||
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@index([username])
|
@@index([username])
|
||||||
@@ -116,6 +120,7 @@ model Module {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
activations TenantModuleActivation[]
|
activations TenantModuleActivation[]
|
||||||
grants ModuleGrant[]
|
grants ModuleGrant[]
|
||||||
|
categoryPlacements ModuleCategoryPlacement[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model TenantModuleActivation {
|
model TenantModuleActivation {
|
||||||
@@ -137,12 +142,20 @@ model TenantModuleActivation {
|
|||||||
// darf höchstens eine Gruppe die Standard-Markierung tragen, DB-erzwungen
|
// darf höchstens eine Gruppe die Standard-Markierung tragen, DB-erzwungen
|
||||||
// über einen partiellen Unique-Index in der Hand-SQL-Ergänzung dieser
|
// über einen partiellen Unique-Index in der Hand-SQL-Ergänzung dieser
|
||||||
// Migration (Prisma 6.19 kennt keine partiellen Indizes ohne Preview-Flag).
|
// Migration (Prisma 6.19 kennt keine partiellen Indizes ohne Preview-Flag).
|
||||||
// D-04: ModuleGrant trägt bewusst KEIN Rechtestufen-Feld — nur Zugriff an/aus.
|
// D-04 (überholt durch 261002-icv): ModuleGrant trägt seit 261002-icv die Freigabestufe `level`.
|
||||||
enum MembershipSource {
|
enum MembershipSource {
|
||||||
MANUAL
|
MANUAL
|
||||||
LDAP
|
LDAP
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 261002-icv: Freigabestufe einer Modul-Freigabe. USE = Benutzen (Standard und
|
||||||
|
// Bestand), MANAGE = Verwalten (Modul benutzen UND dessen eigene Einstellungen
|
||||||
|
// ändern). Freigaben erteilen bleibt Administratoren vorbehalten.
|
||||||
|
enum ModuleGrantLevel {
|
||||||
|
USE
|
||||||
|
MANAGE
|
||||||
|
}
|
||||||
|
|
||||||
model Group {
|
model Group {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
tenantId String
|
tenantId String
|
||||||
@@ -188,6 +201,10 @@ model ModuleGrant {
|
|||||||
userId String?
|
userId String?
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
// 261002-icv: Freigabestufe; USE = Benutzen (Standard und Bestand),
|
||||||
|
// MANAGE = Verwalten — Modul benutzen und dessen eigene Einstellungen
|
||||||
|
// ändern; Freigaben erteilen bleibt Administratoren vorbehalten.
|
||||||
|
level ModuleGrantLevel @default(USE)
|
||||||
|
|
||||||
// Entweder-oder (Gruppe XOR Benutzer, D-04) + Duplikat-Schutz je Variante
|
// Entweder-oder (Gruppe XOR Benutzer, D-04) + Duplikat-Schutz je Variante
|
||||||
// werden per hand-editierter migration.sql ergänzt — Prisma 6.19 hat kein
|
// werden per hand-editierter migration.sql ergänzt — Prisma 6.19 hat kein
|
||||||
@@ -341,6 +358,56 @@ model DkvModuleConfig {
|
|||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// quick-261002-fm5: Kantinenabrechnung (Modul kantine-datev). Eine Zeile je
|
||||||
|
// Mandant (Singleton wie DkvModuleConfig). Die drei Nummern stehen als Text,
|
||||||
|
// damit fuehrende Nullen erhalten bleiben; sie haben bewusst KEINEN
|
||||||
|
// Standardwert — der Administrator hinterlegt sie einmalig, bis dahin ist die
|
||||||
|
// Verarbeitung gesperrt. Die hochgeladene CSV selbst wird nie gespeichert.
|
||||||
|
model KantineDatevConfig {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String @unique
|
||||||
|
beraterNr String?
|
||||||
|
mandantNr String?
|
||||||
|
lohnart String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// quick-261002-fm5: Handelsware (Modul handelsware-datev). Einstellungen je
|
||||||
|
// Mandant (Singleton wie KantineDatevConfig): Standard-Erloeskonto fuer neue
|
||||||
|
// Konten und Startwert fuer die Gegenkonto-Vergabe bei leerer Kontenliste.
|
||||||
|
// Beide Zahlen haben bewusst KEINEN Standardwert — der Administrator hinterlegt
|
||||||
|
// sie einmalig, bis dahin ist die Verarbeitung gesperrt.
|
||||||
|
model HandelswareDatevConfig {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String @unique
|
||||||
|
erloeskonto Int?
|
||||||
|
startGegenkonto Int?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// quick-261002-fm5: Kontenliste der Handelsware (Produktname -> Gegenkonto,
|
||||||
|
// Erloeskonto). Der Name ist je Mandant eindeutig; das Gegenkonto bewusst
|
||||||
|
// NICHT (mehrere Produkte duerfen auf dasselbe Gegenkonto laufen, wie in der
|
||||||
|
// Desktop-Vorlage). Keine Relation zu Tenant, Zeilenschutz nach ProxmoxServer.
|
||||||
|
model HandelswareKonto {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
name String
|
||||||
|
gegenkonto Int
|
||||||
|
erloeskonto Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([tenantId, name])
|
||||||
|
@@index([tenantId])
|
||||||
|
}
|
||||||
|
|
||||||
// Phase 14, Plan 03 (INGEST-05, CONFIG-02, D-06/D-07) — per-tenant portal-
|
// Phase 14, Plan 03 (INGEST-05, CONFIG-02, D-06/D-07) — per-tenant portal-
|
||||||
// alert mailbox config, mirroring DkvModuleConfig's shape/pattern exactly
|
// alert mailbox config, mirroring DkvModuleConfig's shape/pattern exactly
|
||||||
// (own tenantId @unique row, own encrypted creds — D-03: each module keeps
|
// (own tenantId @unique row, own encrypted creds — D-03: each module keeps
|
||||||
@@ -719,6 +786,61 @@ model ProxmoxServerStatus {
|
|||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nextcloud-Status (quick-261002-k67): vom Verwalter eingetragene Clouds der
|
||||||
|
// Kunden. Der zuletzt ermittelte Zustand liegt direkt auf der Zeile (L-03),
|
||||||
|
// es gibt kein Zwischenlager. Logo-Bytes liegen als bytea an der Zeile (D-A,
|
||||||
|
// hoechstens 1 MiB); Listen- und Planerabfragen waehlen sie nie mit aus.
|
||||||
|
// Zeilenschutz nach Muster ProxmoxServer (tenantId, keine Relation zu Tenant).
|
||||||
|
model NextcloudInstance {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
customerName String
|
||||||
|
baseUrl String
|
||||||
|
logoUrl String?
|
||||||
|
logoData Bytes?
|
||||||
|
logoMime String?
|
||||||
|
logoVersion Int @default(0)
|
||||||
|
lastCheckedAt DateTime?
|
||||||
|
reachable Boolean? // null = noch nie geprueft
|
||||||
|
maintenance Boolean?
|
||||||
|
needsDbUpgrade Boolean?
|
||||||
|
versionString String?
|
||||||
|
edition String?
|
||||||
|
productName String?
|
||||||
|
errorKind String?
|
||||||
|
errorDetail String?
|
||||||
|
// quick-261002-kxc: Zwei-Fehlschlaege-Regel und zuletzt gemeldeter Zustand.
|
||||||
|
// consecutiveFailures/firstFailureAt: aufeinanderfolgende fehlgeschlagene
|
||||||
|
// Abrufe (der erste aendert den gespeicherten Zustand nicht).
|
||||||
|
consecutiveFailures Int @default(0)
|
||||||
|
firstFailureAt DateTime?
|
||||||
|
// zuletzt gemeldeter Zustand: 'ok' | 'red' (Anspruch vor dem Mailversand)
|
||||||
|
alertState String @default("ok")
|
||||||
|
alertReason String?
|
||||||
|
alertChangedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
subscriptions NextcloudAlertSubscription[]
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// quick-261002-kxc: persoenliche Benachrichtigung (Glocke) je Benutzer und
|
||||||
|
// Nextcloud-Cloud. Zeilenschutz MIT Benutzerdimension wie "Reminder"; faellt
|
||||||
|
// Benutzer oder Cloud weg, faellt das Abonnement mit.
|
||||||
|
model NextcloudAlertSubscription {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
instanceId String
|
||||||
|
instance NextcloudInstance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([instanceId, userId])
|
||||||
|
@@index([tenantId, userId])
|
||||||
|
}
|
||||||
|
|
||||||
// Eigene Module (quick-260929-9wc): vom Administrator angelegte Seitenleisten-
|
// Eigene Module (quick-260929-9wc): vom Administrator angelegte Seitenleisten-
|
||||||
// Eintraege, die eine externe https-Seite im Rahmen zeigen. Sichtbar fuer alle
|
// Eintraege, die eine externe https-Seite im Rahmen zeigen. Sichtbar fuer alle
|
||||||
// Benutzer des Mandanten. Zeilenschutz nach Muster ProxmoxServer (tenantId,
|
// Benutzer des Mandanten. Zeilenschutz nach Muster ProxmoxServer (tenantId,
|
||||||
@@ -728,7 +850,11 @@ model CustomModule {
|
|||||||
tenantId String
|
tenantId String
|
||||||
name String
|
name String
|
||||||
url String
|
url String
|
||||||
category String // eine der MODULE_CATEGORIES aus @tessera/shared
|
category String // Kennung einer ModuleCategory der Organisation
|
||||||
|
// quick-261003-387: Reihenfolge innerhalb der Kategorie (nur gemeinsame
|
||||||
|
// Eintraege; null = noch nicht sortiert, steht hinten). Persoenliche
|
||||||
|
// Eintraege bekommen nie eine sortOrder.
|
||||||
|
sortOrder Int?
|
||||||
// quick-260929-dzu: null = gemeinsamer Eintrag (vom Administrator, fuer alle
|
// quick-260929-dzu: null = gemeinsamer Eintrag (vom Administrator, fuer alle
|
||||||
// sichtbar); gesetzt = persoenlicher Eintrag, nur fuer diesen Benutzer
|
// sichtbar); gesetzt = persoenlicher Eintrag, nur fuer diesen Benutzer
|
||||||
// sichtbar. Faellt der Benutzer weg, fallen seine Eintraege mit.
|
// sichtbar. Faellt der Benutzer weg, fallen seine Eintraege mit.
|
||||||
@@ -764,3 +890,60 @@ model Reminder {
|
|||||||
@@index([tenantId, userId, dueAt])
|
@@index([tenantId, userId, dueAt])
|
||||||
@@index([dueAt])
|
@@index([dueAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Eigene Vorlage der Willkommensmail (Administrator → Willkommensmail):
|
||||||
|
// hoechstens eine je Mandant; fehlt sie, gelten die Standardtexte aus
|
||||||
|
// @tessera/shared (DEFAULT_WELCOME_MAIL_TEXTS). Nur die sechs Texte —
|
||||||
|
// Kopf, Zugangsdaten und Knoepfe bleiben fest im Code.
|
||||||
|
model WelcomeMailTemplate {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String @unique
|
||||||
|
subject String
|
||||||
|
heading String
|
||||||
|
intro String
|
||||||
|
// Anmeldehinweise je Kontoart (Migration 20260930170000); NULL in einer
|
||||||
|
// aelteren Vorlage = Standardtext aus @tessera/shared
|
||||||
|
loginHintDirectory String?
|
||||||
|
loginHintLocal String?
|
||||||
|
closing String
|
||||||
|
// Benutzername des Administrators, der zuletzt gespeichert hat (Anzeige)
|
||||||
|
updatedBy String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// quick-261003-387: Modulkategorien je Organisation, durch Administratoren
|
||||||
|
// pflegbar. `key` ist unveraenderlich (URL-Segment /modules/<key>/<slug>),
|
||||||
|
// `name` null = Uebersetzung moduleCategories.<key>. isSystem nur fuer
|
||||||
|
// "custom-modules" (Eigene Module): umbenennbar und verschiebbar, nicht
|
||||||
|
// loeschbar.
|
||||||
|
model ModuleCategory {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
key String
|
||||||
|
name String?
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
isSystem Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([tenantId, key])
|
||||||
|
@@index([tenantId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// quick-261003-387: Zuordnung eines Marktplatz-Moduls zu einer Kategorie der
|
||||||
|
// Organisation samt Reihenfolge. Wirksame Kategorie = Zuordnung, sonst
|
||||||
|
// Module.category (die Spalte selbst bleibt fuer alle gleich).
|
||||||
|
model ModuleCategoryPlacement {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
moduleId String
|
||||||
|
module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade)
|
||||||
|
categoryKey String
|
||||||
|
sortOrder Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([tenantId, moduleId])
|
||||||
|
@@index([tenantId])
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* render-mail-header.mjs — erzeugt das Kopfbild der Willkommensmail
|
||||||
|
* (apps/api/assets/mail/welcome-header.png, 1200x80 fuer hochaufloesende
|
||||||
|
* Bildschirme, in der Mail 600x40 angezeigt) aus der daneben liegenden
|
||||||
|
* Quelle welcome-header.svg.
|
||||||
|
*
|
||||||
|
* Warum ein PNG statt Inline-SVG oder CSS-Hintergrund: Outlook (Word-
|
||||||
|
* Darstellung) und viele Webmailer zeigen weder SVG noch Hintergrundbilder
|
||||||
|
* zuverlaessig an. Das PNG wird als CID-Anhang eingebettet (MailService),
|
||||||
|
* die Mail laedt also nichts von aussen nach.
|
||||||
|
*
|
||||||
|
* Das PNG liegt fertig im Repo; dieses Skript ist nur noetig, wenn die SVG
|
||||||
|
* geaendert wird. Kein neues Paket: `sharp` ist ueber Next.js (apps/web)
|
||||||
|
* bereits installiert und wird von dort aufgeloest. Der Schriftzug wird
|
||||||
|
* mit den Systemschriften des erzeugenden Rechners gesetzt (fontconfig:
|
||||||
|
* Segoe UI, Inter, Noto Sans, DejaVu Sans — die erste vorhandene gewinnt).
|
||||||
|
*
|
||||||
|
* Aufruf (Repo-Wurzel): node apps/api/scripts/render-mail-header.mjs
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const assetDir = join(here, '..', 'assets', 'mail');
|
||||||
|
const webDir = join(here, '..', '..', 'web');
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const nextPkg = require.resolve('next/package.json', { paths: [webDir] });
|
||||||
|
const sharp = createRequire(nextPkg)('sharp');
|
||||||
|
|
||||||
|
const svg = readFileSync(join(assetDir, 'welcome-header.svg'));
|
||||||
|
const png = await sharp(svg, { density: 72 })
|
||||||
|
.resize(1200, 80)
|
||||||
|
.png({ compressionLevel: 9, palette: false })
|
||||||
|
.toBuffer();
|
||||||
|
writeFileSync(join(assetDir, 'welcome-header.png'), png);
|
||||||
|
console.log(`welcome-header.png geschrieben (${png.length} Bytes)`);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { decodeCsvText } from './decode-csv-text';
|
||||||
|
|
||||||
|
describe('decodeCsvText', () => {
|
||||||
|
it('liest gueltiges UTF-8 unveraendert', () => {
|
||||||
|
expect(decodeCsvText(Buffer.from('Müller;Straße', 'utf8'))).toBe('Müller;Straße');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entfernt ein UTF-8-BOM', () => {
|
||||||
|
const buf = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('Name;Wert', 'utf8')]);
|
||||||
|
expect(decodeCsvText(buf)).toBe('Name;Wert');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('faellt bei ungueltigem UTF-8 auf Windows-1252 zurueck (Umlaut)', () => {
|
||||||
|
expect(decodeCsvText(Buffer.from('Müller', 'latin1'))).toBe('Müller');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liest das Euro-Zeichen (0x80) in Windows-1252', () => {
|
||||||
|
expect(decodeCsvText(Buffer.from([0x31, 0x30, 0x80]))).toBe('10€');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Dekodiert hochgeladene CSV-Bytes zu Text (quick-261002-fm5).
|
||||||
|
*
|
||||||
|
* Excel und Warenwirtschaftssysteme liefern CSV entweder als UTF-8 (mit oder
|
||||||
|
* ohne Byte-Order-Mark) oder als Windows-1252. Zuerst wird streng als UTF-8
|
||||||
|
* gelesen: sind die Bytes kein gueltiges UTF-8 (typisch bei Umlauten in
|
||||||
|
* Windows-1252), faellt die Funktion auf Windows-1252 zurueck. `TextDecoder`
|
||||||
|
* verwirft ein fuehrendes BOM standardmaessig.
|
||||||
|
*
|
||||||
|
* Gemeinsam genutzt von Kantinenabrechnung und Handelsware.
|
||||||
|
*/
|
||||||
|
export function decodeCsvText(buffer: Buffer): string {
|
||||||
|
try {
|
||||||
|
return new TextDecoder('utf-8', { fatal: true }).decode(buffer);
|
||||||
|
} catch {
|
||||||
|
return new TextDecoder('windows-1252').decode(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { decodeUploadFilename } from './decode-upload-filename';
|
||||||
|
|
||||||
|
describe('decodeUploadFilename', () => {
|
||||||
|
it('laesst ASCII-Namen unveraendert', () => {
|
||||||
|
expect(decodeUploadFilename('HWA 0326 Test.xlsx')).toBe('HWA 0326 Test.xlsx');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kehrt latin1-gelesenes UTF-8 um', () => {
|
||||||
|
const mojibake = Buffer.from('Käse 0326.xlsx', 'utf8').toString('latin1');
|
||||||
|
expect(decodeUploadFilename(mojibake)).toBe('Käse 0326.xlsx');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('laesst einen schon richtigen Namen mit Umlaut stehen', () => {
|
||||||
|
expect(decodeUploadFilename('Käse.xlsx')).toBe('Käse.xlsx');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('laesst Namen mit Zeichen ueber 255 stehen', () => {
|
||||||
|
expect(decodeUploadFilename('Preis €.xlsx')).toBe('Preis €.xlsx');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Multer liefert `originalname` je nach Version als latin1-gelesene Bytes: ein
|
||||||
|
* UTF-8-Dateiname wie "Käse.xlsx" kommt als "Käse.xlsx" an. Diese Funktion
|
||||||
|
* kehrt das um, ohne einen schon richtigen Namen zu zerstoeren: ist der Name
|
||||||
|
* nicht aus latin1-Zeichen zusammengesetzt (Zeichen > 255) oder ergibt die
|
||||||
|
* Umkehrung kein gueltiges UTF-8, bleibt er unveraendert.
|
||||||
|
*/
|
||||||
|
export function decodeUploadFilename(name: string): string {
|
||||||
|
for (let i = 0; i < name.length; i++) {
|
||||||
|
if (name.charCodeAt(i) > 255) return name;
|
||||||
|
}
|
||||||
|
const converted = Buffer.from(name, 'latin1').toString('utf8');
|
||||||
|
return converted.includes('�') ? name : converted;
|
||||||
|
}
|
||||||
@@ -26,8 +26,12 @@ import { TenantGuard } from './tenant/tenant.guard';
|
|||||||
import { TenantModule } from './tenant/tenant.module';
|
import { TenantModule } from './tenant/tenant.module';
|
||||||
import { TendersModule } from './tenders/tenders.module';
|
import { TendersModule } from './tenders/tenders.module';
|
||||||
import { UserModule } from './user/user.module';
|
import { UserModule } from './user/user.module';
|
||||||
|
import { HandelswareDatevModule } from './handelsware-datev/handelsware-datev.module';
|
||||||
|
import { KantineDatevModule } from './kantine-datev/kantine-datev.module';
|
||||||
|
import { NextcloudStatusModule } from './nextcloud-status/nextcloud-status.module';
|
||||||
import { ProxmoxModule } from './proxmox/proxmox.module';
|
import { ProxmoxModule } from './proxmox/proxmox.module';
|
||||||
import { CustomModulesModule } from './custom-modules/custom-modules.module';
|
import { CustomModulesModule } from './custom-modules/custom-modules.module';
|
||||||
|
import { ModuleCategoriesModule } from './module-categories/module-categories.module';
|
||||||
import { RemindersModule } from './reminders/reminders.module';
|
import { RemindersModule } from './reminders/reminders.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -55,7 +59,11 @@ import { RemindersModule } from './reminders/reminders.module';
|
|||||||
TendersModule,
|
TendersModule,
|
||||||
BugReportsModule,
|
BugReportsModule,
|
||||||
ProxmoxModule,
|
ProxmoxModule,
|
||||||
|
NextcloudStatusModule,
|
||||||
|
KantineDatevModule,
|
||||||
|
HandelswareDatevModule,
|
||||||
CustomModulesModule,
|
CustomModulesModule,
|
||||||
|
ModuleCategoriesModule,
|
||||||
RemindersModule,
|
RemindersModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { LdapService } from '../ldap/ldap.service';
|
|||||||
import { MailService } from '../mail/mail.service';
|
import { MailService } from '../mail/mail.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
import { PASSWORD_RESET_TOKEN_TTL_MS } from './password-reset-token';
|
||||||
import type { JwtPayload, LoginUser } from './types/auth-user';
|
import type { JwtPayload, LoginUser } from './types/auth-user';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -239,7 +240,7 @@ export class AuthService {
|
|||||||
|
|
||||||
// Generate a unique reset token
|
// Generate a unique reset token
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
const expiresAt = new Date(Date.now() + PASSWORD_RESET_TOKEN_TTL_MS); // 1 hour
|
||||||
|
|
||||||
// Create the reset token record — mandantengebunden, sobald der
|
// Create the reset token record — mandantengebunden, sobald der
|
||||||
// Benutzer und damit sein Mandant bekannt sind (WINDOWS #20, Aufgabe 1).
|
// Benutzer und damit sein Mandant bekannt sind (WINDOWS #20, Aufgabe 1).
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { ForbiddenException } from '@nestjs/common';
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { JwtStrategy } from '../strategies/jwt.strategy';
|
import { JwtStrategy } from '../strategies/jwt.strategy';
|
||||||
import { ForcePasswordChangeInterceptor } from './force-password-change.interceptor';
|
import { ForcePasswordChangeInterceptor } from './force-password-change.interceptor';
|
||||||
|
|
||||||
|
vi.mock('../../prisma/prisma-tenant.extension', () => ({
|
||||||
|
forTenant: vi.fn((p: unknown) => p),
|
||||||
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ForcePasswordChangeInterceptor.intercept — pinnt Sperre, Erlaubnisliste
|
* ForcePasswordChangeInterceptor.intercept — pinnt Sperre, Erlaubnisliste
|
||||||
* und die Teilstring-Falle (260921-fi3, Aufgabe 1, Befund 1/D-01/D-02/D-03).
|
* und die Teilstring-Falle (260921-fi3, Aufgabe 1, Befund 1/D-01/D-02/D-03).
|
||||||
@@ -30,7 +34,19 @@ const nextHandle = { handle: () => of('ok') } as any;
|
|||||||
|
|
||||||
describe('ForcePasswordChangeInterceptor.intercept', () => {
|
describe('ForcePasswordChangeInterceptor.intercept', () => {
|
||||||
it('Nahttest (D-03): JwtStrategy.validate() -> request.user -> GET /users wirft ForbiddenException — scheitert gegen den heutigen Quelltext, weil das Feld auf dem Weg verloren geht', async () => {
|
it('Nahttest (D-03): JwtStrategy.validate() -> request.user -> GET /users wirft ForbiddenException — scheitert gegen den heutigen Quelltext, weil das Feld auf dem Weg verloren geht', async () => {
|
||||||
const strategy = new JwtStrategy({ get: () => 'test-secret' } as any);
|
const prisma = {
|
||||||
|
user: {
|
||||||
|
findUnique: async () => ({
|
||||||
|
id: 'u1',
|
||||||
|
username: 'admin',
|
||||||
|
role: 'ADMIN',
|
||||||
|
tenantId: 't1',
|
||||||
|
isActive: true,
|
||||||
|
mustChangePassword: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
const strategy = new JwtStrategy({ get: () => 'test-secret' } as any, prisma);
|
||||||
const user = await strategy.validate({
|
const user = await strategy.validate({
|
||||||
sub: 'u1',
|
sub: 'u1',
|
||||||
username: 'admin',
|
username: 'admin',
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Gueltigkeit eines Kennwort-Tokens (`PasswordResetToken`, T-02-13): eine
|
||||||
|
* Stunde, einmal verwendbar. Gemeinsam genutzt vom Weg "Passwort
|
||||||
|
* vergessen" (`AuthService.requestPasswordReset`) und vom Link "Passwort
|
||||||
|
* festlegen" der Willkommensmail (`WelcomeMailService`) — beide legen
|
||||||
|
* dieselbe Art Token an und fuehren auf dieselbe Seite
|
||||||
|
* `/reset-password/<token>`, deshalb gilt dieselbe Frist.
|
||||||
|
*/
|
||||||
|
export const PASSWORD_RESET_TOKEN_TTL_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gueltigkeit des Links "Passwort festlegen" in der Willkommensmail: 7 Tage.
|
||||||
|
* Neue Mitarbeiter lesen die Mail oft erst Tage spaeter; eine Stunde wie bei
|
||||||
|
* "Passwort vergessen" (dort fordert der Benutzer den Link selbst an und
|
||||||
|
* nutzt ihn sofort) waere hier fast immer abgelaufen. Einmal verwendbar
|
||||||
|
* bleibt der Link trotzdem, und jede neue Willkommensmail legt einen neuen an.
|
||||||
|
*/
|
||||||
|
export const WELCOME_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { forTenant } from '../../prisma/prisma-tenant.extension';
|
||||||
import { JwtStrategy } from './jwt.strategy';
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
|
|
||||||
|
vi.mock('../../prisma/prisma-tenant.extension', () => ({
|
||||||
|
forTenant: vi.fn((p: unknown) => p),
|
||||||
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JwtStrategy.validate — pinnt die Durchreichung von mustChangePassword
|
* JwtStrategy.validate — seit quick-260930 kommen Rolle, Aktiv-Status und
|
||||||
* (260921-fi3, Aufgabe 1, Befund 1/D-01). Direkte Konstruktion ohne
|
* Kennwort-Pflicht bei jeder Anfrage aus der Datenbank, nicht aus dem Token
|
||||||
|
* (Rollenaenderung/Deaktivierung wirkt sofort). Direkte Konstruktion ohne
|
||||||
* Nest-Testmodul, Muster aus `../../tenant/tenant.guard.spec.ts`.
|
* Nest-Testmodul, Muster aus `../../tenant/tenant.guard.spec.ts`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -11,51 +18,77 @@ function makeConfigService() {
|
|||||||
return { get: () => 'test-secret' } as any;
|
return { get: () => 'test-secret' } as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('JwtStrategy.validate', () => {
|
type Row = {
|
||||||
it('Anspruch mustChangePassword=true im Token: liefert request.user.mustChangePassword === true', async () => {
|
id: string;
|
||||||
const strategy = new JwtStrategy(makeConfigService());
|
username: string;
|
||||||
|
role: string;
|
||||||
|
tenantId: string;
|
||||||
|
isActive: boolean;
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
} | null;
|
||||||
|
|
||||||
const result = await strategy.validate({
|
function makePrisma(row: Row) {
|
||||||
|
return { user: { findUnique: vi.fn(async () => row) } } as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
sub: 'u1',
|
sub: 'u1',
|
||||||
username: 'admin',
|
username: 'kschaller',
|
||||||
role: 'ADMIN',
|
role: 'SUPER_ADMIN' as const,
|
||||||
tenantId: 't1',
|
tenantId: 't1',
|
||||||
mustChangePassword: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.mustChangePassword).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Anspruch fehlt im Token (Alt-Sitzung, vor dieser Aenderung ausgestellt): liefert false statt undefined', async () => {
|
|
||||||
const strategy = new JwtStrategy(makeConfigService());
|
|
||||||
|
|
||||||
const result = await strategy.validate({
|
|
||||||
sub: 'u1',
|
|
||||||
username: 'admin',
|
|
||||||
role: 'ADMIN',
|
|
||||||
tenantId: 't1',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.mustChangePassword).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('id, username, role und tenantId werden unveraendert wie bisher durchgereicht', async () => {
|
|
||||||
const strategy = new JwtStrategy(makeConfigService());
|
|
||||||
|
|
||||||
const result = await strategy.validate({
|
|
||||||
sub: 'u1',
|
|
||||||
username: 'nutzer1',
|
|
||||||
role: 'USER',
|
|
||||||
tenantId: 't2',
|
|
||||||
mustChangePassword: false,
|
mustChangePassword: false,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const dbRow = {
|
||||||
|
id: 'u1',
|
||||||
|
username: 'kschaller',
|
||||||
|
role: 'ADMIN',
|
||||||
|
tenantId: 't1',
|
||||||
|
isActive: true,
|
||||||
|
mustChangePassword: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('JwtStrategy.validate', () => {
|
||||||
|
it('Rolle kommt aus der Datenbank, nicht aus dem Token (herabgestufter Super-Admin ist sofort Admin)', async () => {
|
||||||
|
const prisma = makePrisma(dbRow);
|
||||||
|
const strategy = new JwtStrategy(makeConfigService(), prisma);
|
||||||
|
|
||||||
|
const result = await strategy.validate(payload);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
id: 'u1',
|
id: 'u1',
|
||||||
username: 'nutzer1',
|
username: 'kschaller',
|
||||||
role: 'USER',
|
role: 'ADMIN',
|
||||||
tenantId: 't2',
|
tenantId: 't1',
|
||||||
mustChangePassword: false,
|
mustChangePassword: false,
|
||||||
});
|
});
|
||||||
|
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
|
||||||
|
expect(prisma.user.findUnique).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { id: 'u1' } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deaktiviertes Konto: 401, auch mit gueltigem Token', async () => {
|
||||||
|
const strategy = new JwtStrategy(makeConfigService(), makePrisma({ ...dbRow, isActive: false }));
|
||||||
|
await expect(strategy.validate(payload)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('geloeschtes Konto: 401', async () => {
|
||||||
|
const strategy = new JwtStrategy(makeConfigService(), makePrisma(null));
|
||||||
|
await expect(strategy.validate(payload)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Konto gehoert nicht (mehr) zum Mandanten aus dem Token: 401', async () => {
|
||||||
|
const strategy = new JwtStrategy(makeConfigService(), makePrisma({ ...dbRow, tenantId: 't2' }));
|
||||||
|
await expect(strategy.validate(payload)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Kennwort-Pflicht kommt aus der Datenbank (vom Administrator nachtraeglich gesetzt)', async () => {
|
||||||
|
const strategy = new JwtStrategy(
|
||||||
|
makeConfigService(),
|
||||||
|
makePrisma({ ...dbRow, mustChangePassword: true }),
|
||||||
|
);
|
||||||
|
const result = await strategy.validate(payload);
|
||||||
|
expect(result.mustChangePassword).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { Strategy } from 'passport-jwt';
|
import { Strategy } from 'passport-jwt';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { forTenant } from '../../prisma/prisma-tenant.extension';
|
||||||
import type { AuthUser, JwtPayload } from '../types/auth-user';
|
import type { AuthUser, JwtPayload } from '../types/auth-user';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,7 +19,10 @@ function cookieExtractor(req: Request): string | null {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor(configService: ConfigService) {
|
constructor(
|
||||||
|
configService: ConfigService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: cookieExtractor,
|
jwtFromRequest: cookieExtractor,
|
||||||
ignoreExpiration: false,
|
ignoreExpiration: false,
|
||||||
@@ -25,16 +30,46 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Token beweist nur, WER angemeldet ist — Rolle, Aktiv-Status und
|
||||||
|
* Kennwort-Pflicht kommen bei JEDER Anfrage frisch aus der Datenbank
|
||||||
|
* (quick-260930, Befund des Nutzers): vorher galt die Rolle aus dem
|
||||||
|
* 30-Tage-Token. Ein herabgestufter Administrator behielt bis zum Ablauf
|
||||||
|
* seine alten Rechte, ein deaktiviertes oder geloeschtes Konto (etwa per
|
||||||
|
* LDAP-Abgleich beim Austritt) arbeitete mit seiner Sitzung weiter, und
|
||||||
|
* Oberflaeche (liest die Rolle ueber /auth/me aus der Datenbank) und API
|
||||||
|
* (las sie aus dem Token) sahen verschiedene Rollen — die Benutzerliste
|
||||||
|
* scheiterte dann im Client.
|
||||||
|
*
|
||||||
|
* Ein Primaerschluessel-Lesezugriff je Anfrage, gebunden an den Mandanten
|
||||||
|
* aus dem Token (`forTenant`); gehoert das Konto nicht (mehr) zu diesem
|
||||||
|
* Mandanten, fehlt es oder ist es deaktiviert, gilt die Sitzung als
|
||||||
|
* ungueltig (401) — die Web-Oberflaeche leitet dann zur Anmeldung.
|
||||||
|
*/
|
||||||
async validate(payload: JwtPayload): Promise<AuthUser> {
|
async validate(payload: JwtPayload): Promise<AuthUser> {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, payload.tenantId);
|
||||||
|
const user = await tenantPrisma.user.findUnique({
|
||||||
|
where: { id: payload.sub },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
role: true,
|
||||||
|
tenantId: true,
|
||||||
|
isActive: true,
|
||||||
|
mustChangePassword: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.isActive || user.tenantId !== payload.tenantId) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: payload.sub,
|
id: user.id,
|
||||||
username: payload.username,
|
username: user.username,
|
||||||
role: payload.role,
|
role: user.role as AuthUser['role'],
|
||||||
tenantId: payload.tenantId,
|
tenantId: user.tenantId,
|
||||||
// Ein vor dieser Aenderung ausgestelltes Token traegt diesen Anspruch
|
mustChangePassword: user.mustChangePassword === true,
|
||||||
// nicht; der strenge Vergleich ergibt dann false, laufende Sitzungen
|
|
||||||
// verhalten sich unveraendert (260921-fi3, D-01 — keine Aussperrwelle).
|
|
||||||
mustChangePassword: payload.mustChangePassword === true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import AdmZip from 'adm-zip';
|
||||||
|
import * as forge from 'node-forge';
|
||||||
|
import { beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { analyzeBundle, exportBundleItem, safeBaseName } from './cert-bundle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* cert-bundle.spec (quick-261001-l4q) — Zertifikatspaket wie vom Aussteller:
|
||||||
|
* Stamm -> Zwischen -> Server, dazu Schluessel, CSR und PFX, als ZIP.
|
||||||
|
* Alles hier erzeugt (keine echten Kundendaten im Repo).
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Pki {
|
||||||
|
rootPem: string;
|
||||||
|
interPem: string;
|
||||||
|
leafPem: string;
|
||||||
|
keyPem: string;
|
||||||
|
csrPem: string;
|
||||||
|
pfx: Buffer;
|
||||||
|
leafModulus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pki: Pki;
|
||||||
|
|
||||||
|
function makeCert(
|
||||||
|
subjectCn: string,
|
||||||
|
pub: forge.pki.PublicKey,
|
||||||
|
signer: forge.pki.PrivateKey,
|
||||||
|
issuer: forge.pki.CertificateField[] | null,
|
||||||
|
ca: boolean,
|
||||||
|
serial: string,
|
||||||
|
): forge.pki.Certificate {
|
||||||
|
const cert = forge.pki.createCertificate();
|
||||||
|
cert.publicKey = pub;
|
||||||
|
cert.serialNumber = serial;
|
||||||
|
cert.validity.notBefore = new Date(Date.now() - 86_400_000);
|
||||||
|
cert.validity.notAfter = new Date(Date.now() + 90 * 86_400_000);
|
||||||
|
const subject = [{ name: 'commonName', value: subjectCn }];
|
||||||
|
cert.setSubject(subject);
|
||||||
|
cert.setIssuer(issuer ?? subject);
|
||||||
|
const ext: object[] = [{ name: 'basicConstraints', cA: ca }];
|
||||||
|
if (!ca) ext.push({ name: 'subjectAltName', altNames: [{ type: 2, value: subjectCn }] });
|
||||||
|
cert.setExtensions(ext);
|
||||||
|
cert.sign(signer as forge.pki.rsa.PrivateKey, forge.md.sha256.create());
|
||||||
|
return cert;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
const rootKeys = forge.pki.rsa.generateKeyPair(1024);
|
||||||
|
const interKeys = forge.pki.rsa.generateKeyPair(1024);
|
||||||
|
const leafKeys = forge.pki.rsa.generateKeyPair(1024);
|
||||||
|
const root = makeCert('Test Root CA', rootKeys.publicKey, rootKeys.privateKey, null, true, '01');
|
||||||
|
const inter = makeCert(
|
||||||
|
'Test Intermediate CA',
|
||||||
|
interKeys.publicKey,
|
||||||
|
rootKeys.privateKey,
|
||||||
|
root.subject.attributes,
|
||||||
|
true,
|
||||||
|
'02',
|
||||||
|
);
|
||||||
|
const leaf = makeCert(
|
||||||
|
'www.example.test',
|
||||||
|
leafKeys.publicKey,
|
||||||
|
interKeys.privateKey,
|
||||||
|
inter.subject.attributes,
|
||||||
|
false,
|
||||||
|
'03',
|
||||||
|
);
|
||||||
|
|
||||||
|
const csr = forge.pki.createCertificationRequest();
|
||||||
|
csr.publicKey = leafKeys.publicKey;
|
||||||
|
csr.setSubject([{ name: 'commonName', value: 'www.example.test' }]);
|
||||||
|
csr.sign(leafKeys.privateKey, forge.md.sha256.create());
|
||||||
|
|
||||||
|
const p12 = forge.pkcs12.toPkcs12Asn1(leafKeys.privateKey, [leaf, inter], 'geheim', {
|
||||||
|
algorithm: '3des',
|
||||||
|
});
|
||||||
|
|
||||||
|
pki = {
|
||||||
|
rootPem: forge.pki.certificateToPem(root),
|
||||||
|
interPem: forge.pki.certificateToPem(inter),
|
||||||
|
leafPem: forge.pki.certificateToPem(leaf),
|
||||||
|
keyPem: forge.pki.privateKeyInfoToPem(
|
||||||
|
forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(leafKeys.privateKey)),
|
||||||
|
),
|
||||||
|
csrPem: forge.pki.certificationRequestToPem(csr),
|
||||||
|
pfx: Buffer.from(forge.asn1.toDer(p12).getBytes(), 'binary'),
|
||||||
|
leafModulus: leafKeys.publicKey.n.toString(16),
|
||||||
|
};
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
function issuerZip(): Buffer {
|
||||||
|
const zip = new AdmZip();
|
||||||
|
zip.addFile('www.example.test/www.example.test.pem', Buffer.from(pki.leafPem + pki.interPem));
|
||||||
|
zip.addFile('www.example.test/www.example.test.key', Buffer.from(pki.keyPem));
|
||||||
|
zip.addFile('www.example.test/www.example.test.csr', Buffer.from(pki.csrPem));
|
||||||
|
zip.addFile('www.example.test/www.example.test.pfx', pki.pfx);
|
||||||
|
zip.addFile('www.example.test/.dnstxtrecord', Buffer.from('_dnsauth abc123'));
|
||||||
|
return zip.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('analyzeBundle', () => {
|
||||||
|
it('ZIP vom Aussteller: erkennt jedes Teil, fasst PEM/PFX zusammen, ordnet Schluessel und Kette zu', () => {
|
||||||
|
const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'geheim');
|
||||||
|
|
||||||
|
const kinds = r.items.map((i) => (i.kind === 'certificate' ? i.role : i.kind));
|
||||||
|
expect(kinds).toEqual(['end-entity', 'intermediate', 'privateKey', 'csr']);
|
||||||
|
|
||||||
|
const [leaf, inter, key, csr] = r.items;
|
||||||
|
expect(leaf.cn).toBe('www.example.test');
|
||||||
|
expect(leaf.san).toEqual(['www.example.test']);
|
||||||
|
// in PEM UND PFX enthalten -> ein Eintrag mit beiden Quellen
|
||||||
|
expect(leaf.sources.sort()).toEqual(['www.example.test.pem', 'www.example.test.pfx']);
|
||||||
|
expect(leaf.chainIds).toEqual([inter.id]);
|
||||||
|
expect(leaf.matchId).toBe(key.id);
|
||||||
|
expect(key.matchId).toBe(leaf.id);
|
||||||
|
expect(key.sources.sort()).toEqual(['www.example.test.key', 'www.example.test.pfx']);
|
||||||
|
expect(csr.matchId).toBe(leaf.id);
|
||||||
|
expect(csr.cn).toBe('www.example.test');
|
||||||
|
expect(leaf.baseName).toBe('www.example.test');
|
||||||
|
|
||||||
|
expect(r.locked).toEqual([]);
|
||||||
|
expect(r.ignored).toEqual(['.dnstxtrecord']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PFX ohne passendes Passwort wird als gesperrt gemeldet, der Rest trotzdem erkannt', () => {
|
||||||
|
const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'falsch');
|
||||||
|
expect(r.locked).toEqual(['www.example.test.pfx']);
|
||||||
|
expect(r.items.filter((i) => i.kind === 'certificate')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Stammzertifikat wird als root erkannt und an die Kette gehaengt', () => {
|
||||||
|
const r = analyzeBundle(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
originalname: 'chain.pem',
|
||||||
|
buffer: Buffer.from(pki.leafPem + pki.interPem + pki.rootPem),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
expect(r.items.map((i) => i.role)).toEqual(['end-entity', 'intermediate', 'root']);
|
||||||
|
expect(r.items[0].chainIds).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Dateien -> 400', () => {
|
||||||
|
expect(() => analyzeBundle([], '')).toThrow(/No files/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ZIP mit zu vielen Dateien -> 400', () => {
|
||||||
|
const zip = new AdmZip();
|
||||||
|
for (let i = 0; i < 101; i++) zip.addFile(`f${i}.txt`, Buffer.from('x'));
|
||||||
|
expect(() =>
|
||||||
|
analyzeBundle([{ originalname: 'gross.zip', buffer: zip.toBuffer() }], ''),
|
||||||
|
).toThrow(/too many files/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exportBundleItem', () => {
|
||||||
|
function bundle() {
|
||||||
|
const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'geheim');
|
||||||
|
const byId = Object.fromEntries(r.items.map((i) => [i.id, i]));
|
||||||
|
return { items: r.items, byId };
|
||||||
|
}
|
||||||
|
const decode = (b64: string) => Buffer.from(b64, 'base64');
|
||||||
|
|
||||||
|
it('Zertifikat in jedem Format liest sich wieder ein', () => {
|
||||||
|
const { items, byId } = bundle();
|
||||||
|
const leaf = items[0];
|
||||||
|
const chain = leaf.chainIds.map((id) => byId[id].pem);
|
||||||
|
const keyPem = byId[leaf.matchId!].pem;
|
||||||
|
const base = { kind: leaf.kind, pem: leaf.pem, baseName: leaf.baseName, chain, keyPem };
|
||||||
|
|
||||||
|
const crt = exportBundleItem({ ...base, format: 'crt' });
|
||||||
|
expect(crt.filename).toBe('www.example.test.crt');
|
||||||
|
expect(
|
||||||
|
forge.pki.certificateFromPem(decode(crt.content).toString()).subject.getField('CN').value,
|
||||||
|
).toBe('www.example.test');
|
||||||
|
|
||||||
|
const cer = exportBundleItem({ ...base, format: 'cer' });
|
||||||
|
expect(cer.filename).toBe('www.example.test.cer');
|
||||||
|
forge.pki.certificateFromAsn1(forge.asn1.fromDer(decode(cer.content).toString('binary')));
|
||||||
|
|
||||||
|
const full = exportBundleItem({ ...base, format: 'fullchain' });
|
||||||
|
expect(
|
||||||
|
decode(full.content)
|
||||||
|
.toString()
|
||||||
|
.match(/BEGIN CERTIFICATE/g),
|
||||||
|
).toHaveLength(2);
|
||||||
|
|
||||||
|
const p7b = exportBundleItem({ ...base, format: 'p7b' });
|
||||||
|
const p7 = forge.pkcs7.messageFromPem(decode(p7b.content).toString());
|
||||||
|
expect('certificates' in p7 ? p7.certificates : []).toHaveLength(2);
|
||||||
|
|
||||||
|
const pfx = exportBundleItem({ ...base, format: 'pfx', password: 'neu' });
|
||||||
|
expect(pfx.filename).toBe('www.example.test.pfx');
|
||||||
|
const p12 = forge.pkcs12.pkcs12FromAsn1(
|
||||||
|
forge.asn1.fromDer(decode(pfx.content).toString('binary')),
|
||||||
|
'neu',
|
||||||
|
);
|
||||||
|
expect(p12.getBags({ bagType: forge.pki.oids.certBag })[forge.pki.oids.certBag]).toHaveLength(
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
const keyBag = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag })[
|
||||||
|
forge.pki.oids.pkcs8ShroudedKeyBag
|
||||||
|
]![0];
|
||||||
|
expect((keyBag.key as forge.pki.rsa.PrivateKey).n.toString(16)).toBe(pki.leafModulus);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PFX ohne Passwort -> 400', () => {
|
||||||
|
const { items } = bundle();
|
||||||
|
expect(() =>
|
||||||
|
exportBundleItem({ kind: 'certificate', pem: items[0].pem, format: 'pfx' }),
|
||||||
|
).toThrow(/password is required/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Schluessel als PKCS#8, PKCS#1 und DER', () => {
|
||||||
|
const { items } = bundle();
|
||||||
|
const key = items.find((i) => i.kind === 'privateKey')!;
|
||||||
|
const base = { kind: key.kind, pem: key.pem, baseName: key.baseName };
|
||||||
|
expect(decode(exportBundleItem({ ...base, format: 'key' }).content).toString()).toContain(
|
||||||
|
'BEGIN PRIVATE KEY',
|
||||||
|
);
|
||||||
|
const rsa = exportBundleItem({ ...base, format: 'key-rsa' });
|
||||||
|
expect(rsa.filename).toBe('www.example.test.rsa.key');
|
||||||
|
expect(decode(rsa.content).toString()).toContain('BEGIN RSA PRIVATE KEY');
|
||||||
|
const der = exportBundleItem({ ...base, format: 'key-der' });
|
||||||
|
const info = forge.asn1.fromDer(decode(der.content).toString('binary'));
|
||||||
|
expect((forge.pki.privateKeyFromAsn1(info) as forge.pki.rsa.PrivateKey).n.toString(16)).toBe(
|
||||||
|
pki.leafModulus,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CSR als PEM und DER', () => {
|
||||||
|
const { items } = bundle();
|
||||||
|
const csr = items.find((i) => i.kind === 'csr')!;
|
||||||
|
const der = exportBundleItem({
|
||||||
|
kind: 'csr',
|
||||||
|
pem: csr.pem,
|
||||||
|
baseName: csr.baseName,
|
||||||
|
format: 'csr-der',
|
||||||
|
});
|
||||||
|
expect(der.filename).toBe('www.example.test.csr.der');
|
||||||
|
forge.pki.certificationRequestFromAsn1(
|
||||||
|
forge.asn1.fromDer(decode(der.content).toString('binary')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unpassendes Format -> 400', () => {
|
||||||
|
const { items } = bundle();
|
||||||
|
expect(() =>
|
||||||
|
exportBundleItem({ kind: 'csr', pem: items[3].pem, format: 'pfx', password: 'x' }),
|
||||||
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
exportBundleItem({ kind: 'privateKey', pem: 'kein pem', format: 'key-rsa' }),
|
||||||
|
).toThrow(/Failed to export/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('safeBaseName', () => {
|
||||||
|
it('Platzhalter, Leerzeichen und Pfadteile werden entschaerft', () => {
|
||||||
|
expect(safeBaseName('*.example.de', 'x')).toBe('wildcard.example.de');
|
||||||
|
expect(safeBaseName('Encryption Everywhere DV TLS CA - G1', 'x')).toBe(
|
||||||
|
'Encryption_Everywhere_DV_TLS_CA_-_G1',
|
||||||
|
);
|
||||||
|
expect(safeBaseName('../../etc/passwd', 'x')).toBe('etc_passwd');
|
||||||
|
expect(safeBaseName('', 'fallback')).toBe('fallback');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,655 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import AdmZip from 'adm-zip';
|
||||||
|
import * as forge from 'node-forge';
|
||||||
|
import type { UploadedFileLike } from '../auth/types/auth-user';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zertifikatspaket (quick-261001-l4q): alles, was ein Aussteller liefert —
|
||||||
|
* Zertifikat mit Kette (.pem/.crt/.cer/.p7b), privater Schluessel (.key),
|
||||||
|
* Zertifikatsanfrage (.csr), PFX/P12, gern als ZIP — auf einmal hochladen,
|
||||||
|
* erkennen, was was ist, und jedes Teil in jedem passenden Format
|
||||||
|
* herunterladen.
|
||||||
|
*
|
||||||
|
* Zustandslos wie der Rest des Moduls: `analyzeBundle` gibt je Teil den
|
||||||
|
* kanonischen PEM-Text zurueck, `exportBundleItem` baut daraus die Datei.
|
||||||
|
* Nichts wird gespeichert, Passwoerter werden nie protokolliert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type BundleFile = Pick<UploadedFileLike, 'buffer' | 'originalname'>;
|
||||||
|
|
||||||
|
export type BundleCertRole = 'end-entity' | 'intermediate' | 'root';
|
||||||
|
export type BundleItemKind = 'certificate' | 'privateKey' | 'csr';
|
||||||
|
|
||||||
|
export interface BundleItem {
|
||||||
|
id: string;
|
||||||
|
kind: BundleItemKind;
|
||||||
|
/** Nur bei Zertifikaten. */
|
||||||
|
role?: BundleCertRole;
|
||||||
|
/** Dateien, in denen dieses Teil gefunden wurde (Duplikate zusammengefasst). */
|
||||||
|
sources: string[];
|
||||||
|
/** Kanonischer PEM-Text — Grundlage fuer jeden Export. */
|
||||||
|
pem: string;
|
||||||
|
/** Vorschlag fuer den Dateinamen ohne Endung, aus dem CN abgeleitet. */
|
||||||
|
baseName: string;
|
||||||
|
cn: string;
|
||||||
|
organization: string;
|
||||||
|
issuerCn: string;
|
||||||
|
notBefore: string | null;
|
||||||
|
notAfter: string | null;
|
||||||
|
isExpired: boolean | null;
|
||||||
|
daysLeft: number | null;
|
||||||
|
san: string[];
|
||||||
|
keyType: string;
|
||||||
|
keyBits: number;
|
||||||
|
serialNumber: string;
|
||||||
|
sha256: string;
|
||||||
|
/** Zertifikat: id des passenden Schluessels; Schluessel/CSR: id des passenden Zertifikats. */
|
||||||
|
matchId: string | null;
|
||||||
|
/** Zertifikat: ids der Kette darueber (Aussteller, dessen Aussteller ...). */
|
||||||
|
chainIds: string[];
|
||||||
|
/** Formate, die `exportBundleItem` fuer dieses Teil liefern kann. */
|
||||||
|
formats: BundleExportFormat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BundleAnalysis {
|
||||||
|
items: BundleItem[];
|
||||||
|
/** PFX/P12 oder verschluesselte Schluessel, die ohne (richtiges) Passwort nicht lesbar sind. */
|
||||||
|
locked: string[];
|
||||||
|
/** Dateien ohne erkennbares Zertifikat, Schluessel oder CSR. */
|
||||||
|
ignored: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BundleExportFormat =
|
||||||
|
| 'crt'
|
||||||
|
| 'cer'
|
||||||
|
| 'fullchain'
|
||||||
|
| 'p7b'
|
||||||
|
| 'pfx'
|
||||||
|
| 'key'
|
||||||
|
| 'key-rsa'
|
||||||
|
| 'key-der'
|
||||||
|
| 'csr'
|
||||||
|
| 'csr-der';
|
||||||
|
|
||||||
|
export interface BundleExportInput {
|
||||||
|
kind: BundleItemKind;
|
||||||
|
pem: string;
|
||||||
|
format: BundleExportFormat;
|
||||||
|
baseName?: string;
|
||||||
|
/** Zertifikat: PEMs der Kette darueber (fuer Fullchain/P7B/PFX). */
|
||||||
|
chain?: string[];
|
||||||
|
/** Zertifikat: PEM des passenden privaten Schluessels (fuer PFX). */
|
||||||
|
keyPem?: string;
|
||||||
|
/** PFX: Passwort fuer die neue Datei. */
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BundleExportFile {
|
||||||
|
filename: string;
|
||||||
|
/** Base64 */
|
||||||
|
content: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ZIP_ENTRIES = 100;
|
||||||
|
const MAX_ENTRY_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
const PEM_BLOCK = /-----BEGIN ([A-Z0-9 ]+)-----[\s\S]+?-----END \1-----/g;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hilfen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function binary(buffer: Buffer): forge.util.ByteStringBuffer {
|
||||||
|
return forge.util.createBuffer(buffer.toString('binary'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase64(bytes: string): string {
|
||||||
|
return Buffer.from(forge.util.bytesToHex(bytes), 'hex').toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
function textToBase64(text: string): string {
|
||||||
|
return Buffer.from(text, 'utf-8').toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256Of(cert: forge.pki.Certificate): string {
|
||||||
|
const md = forge.md.sha256.create();
|
||||||
|
md.update(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes());
|
||||||
|
return (md.digest().toHex().match(/.{2}/g) ?? []).join(':').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function field(name: forge.pki.Certificate['subject'], short: string): string {
|
||||||
|
return (name.getField(short)?.value as string | undefined) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dateiname ohne Pfad und ohne gefaehrliche Zeichen, z. B. „*.example.de“ -> „wildcard.example.de“. */
|
||||||
|
export function safeBaseName(raw: string, fallback: string): string {
|
||||||
|
const cleaned = raw
|
||||||
|
.replace(/^\*\./, 'wildcard.')
|
||||||
|
.replace(/[^A-Za-z0-9._-]+/g, '_')
|
||||||
|
.replace(/^[._]+/, '')
|
||||||
|
.slice(0, 80);
|
||||||
|
return cleaned || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function certRole(cert: forge.pki.Certificate): BundleCertRole {
|
||||||
|
const bc = cert.getExtension('basicConstraints') as { cA?: boolean } | null;
|
||||||
|
if (!bc?.cA) return 'end-entity';
|
||||||
|
return cert.subject.hash === cert.issuer.hash ? 'root' : 'intermediate';
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicKeyInfo(key: unknown): { keyType: string; keyBits: number; modulus: string } {
|
||||||
|
// node-forge liefert RSA-Schluessel mit `n`; EC-Schluessel kennt es nur
|
||||||
|
// eingeschraenkt (siehe Kommentar in CertManagerService.parseCert).
|
||||||
|
const k = key as { n?: forge.jsbn.BigInteger };
|
||||||
|
if (k?.n) return { keyType: 'RSA', keyBits: k.n.bitLength(), modulus: k.n.toString(16) };
|
||||||
|
return { keyType: 'EC', keyBits: 0, modulus: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanOf(extensions: unknown[] | undefined): string[] {
|
||||||
|
const ext = (extensions ?? []).find((e) => (e as { name?: string }).name === 'subjectAltName') as
|
||||||
|
| { altNames?: { type: number; value?: string; ip?: string }[] }
|
||||||
|
| undefined;
|
||||||
|
return (ext?.altNames ?? []).map((n) =>
|
||||||
|
n.type === 2 ? (n.value ?? '') : `IP:${n.ip ?? n.value ?? ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Einsammeln: Dateien (inkl. ZIP) -> rohe Teile
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface RawKey {
|
||||||
|
source: string;
|
||||||
|
pem: string;
|
||||||
|
modulus: string;
|
||||||
|
keyType: string;
|
||||||
|
keyBits: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawCsr {
|
||||||
|
source: string;
|
||||||
|
pem: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Collected {
|
||||||
|
certs: { source: string; cert: forge.pki.Certificate }[];
|
||||||
|
keys: RawKey[];
|
||||||
|
csrs: RawCsr[];
|
||||||
|
locked: string[];
|
||||||
|
ignored: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandZips(files: BundleFile[]): { name: string; buffer: Buffer }[] {
|
||||||
|
const out: { name: string; buffer: Buffer }[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.originalname.toLowerCase().endsWith('.zip')) {
|
||||||
|
out.push({ name: file.originalname, buffer: file.buffer });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let zip: AdmZip;
|
||||||
|
try {
|
||||||
|
zip = new AdmZip(file.buffer);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException(`"${file.originalname}" is not a readable ZIP archive`);
|
||||||
|
}
|
||||||
|
const entries = zip
|
||||||
|
.getEntries()
|
||||||
|
.filter((e) => !e.isDirectory && !e.entryName.startsWith('__MACOSX/'));
|
||||||
|
if (entries.length > MAX_ZIP_ENTRIES) {
|
||||||
|
throw new BadRequestException(`"${file.originalname}" contains too many files`);
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
// Groesse aus dem Kopf pruefen, BEVOR entpackt wird (Zip-Bombe).
|
||||||
|
if (entry.header.size > MAX_ENTRY_BYTES) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`"${entry.entryName}" in "${file.originalname}" is too large`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const name = entry.entryName.split('/').pop() ?? entry.entryName;
|
||||||
|
out.push({ name, buffer: entry.getData() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addKey(c: Collected, source: string, privateKey: forge.pki.rsa.PrivateKey): void {
|
||||||
|
const info = publicKeyInfo(privateKey);
|
||||||
|
const pem = forge.pki.privateKeyInfoToPem(
|
||||||
|
forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(privateKey)),
|
||||||
|
);
|
||||||
|
c.keys.push({ source, pem, ...info });
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectPemText(c: Collected, source: string, text: string, password: string): boolean {
|
||||||
|
let found = false;
|
||||||
|
for (const match of text.matchAll(PEM_BLOCK)) {
|
||||||
|
const [block, type] = match;
|
||||||
|
try {
|
||||||
|
if (type === 'CERTIFICATE' || type === 'TRUSTED CERTIFICATE') {
|
||||||
|
c.certs.push({ source, cert: forge.pki.certificateFromPem(block) });
|
||||||
|
found = true;
|
||||||
|
} else if (type === 'PRIVATE KEY' || type === 'RSA PRIVATE KEY') {
|
||||||
|
const key = forge.pki.privateKeyFromPem(block) as forge.pki.rsa.PrivateKey;
|
||||||
|
addKey(c, source, key);
|
||||||
|
found = true;
|
||||||
|
} else if (type === 'ENCRYPTED PRIVATE KEY') {
|
||||||
|
const key = password ? forge.pki.decryptRsaPrivateKey(block, password) : null;
|
||||||
|
if (key) addKey(c, source, key as forge.pki.rsa.PrivateKey);
|
||||||
|
else c.locked.push(source);
|
||||||
|
found = true;
|
||||||
|
} else if (type === 'EC PRIVATE KEY') {
|
||||||
|
// node-forge kann EC nicht umrechnen — Teil bleibt im Original erhalten.
|
||||||
|
c.keys.push({ source, pem: block, modulus: '', keyType: 'EC', keyBits: 0 });
|
||||||
|
found = true;
|
||||||
|
} else if (type === 'CERTIFICATE REQUEST' || type === 'NEW CERTIFICATE REQUEST') {
|
||||||
|
c.csrs.push({
|
||||||
|
source,
|
||||||
|
pem: block.replace(/NEW CERTIFICATE REQUEST/g, 'CERTIFICATE REQUEST'),
|
||||||
|
});
|
||||||
|
found = true;
|
||||||
|
} else if (type === 'PKCS7') {
|
||||||
|
const p7 = forge.pkcs7.messageFromPem(block);
|
||||||
|
for (const cert of 'certificates' in p7 ? p7.certificates : [])
|
||||||
|
c.certs.push({ source, cert });
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// PKCS#8 mit EC-Schluessel o. ae.: node-forge kann ihn nicht lesen —
|
||||||
|
// im Original behalten statt zu verwerfen.
|
||||||
|
if (type === 'PRIVATE KEY') {
|
||||||
|
c.keys.push({ source, pem: block, modulus: '', keyType: 'EC', keyBits: 0 });
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectPfx(c: Collected, source: string, buffer: Buffer, password: string): void {
|
||||||
|
let p12: forge.pkcs12.Pkcs12Pfx | null = null;
|
||||||
|
for (const candidate of password ? [password, ''] : ['']) {
|
||||||
|
try {
|
||||||
|
p12 = forge.pkcs12.pkcs12FromAsn1(forge.asn1.fromDer(binary(buffer)), candidate);
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
// naechstes Passwort versuchen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!p12) {
|
||||||
|
c.locked.push(source);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const bag of p12.getBags({ bagType: forge.pki.oids.certBag })[forge.pki.oids.certBag] ??
|
||||||
|
[]) {
|
||||||
|
if (bag.cert) c.certs.push({ source, cert: bag.cert });
|
||||||
|
}
|
||||||
|
for (const oid of [forge.pki.oids.pkcs8ShroudedKeyBag, forge.pki.oids.keyBag]) {
|
||||||
|
for (const bag of p12.getBags({ bagType: oid })[oid] ?? []) {
|
||||||
|
if (bag.key) addKey(c, source, bag.key as forge.pki.rsa.PrivateKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDer(c: Collected, source: string, buffer: Buffer): boolean {
|
||||||
|
try {
|
||||||
|
const asn1 = forge.asn1.fromDer(binary(buffer));
|
||||||
|
try {
|
||||||
|
c.certs.push({ source, cert: forge.pki.certificateFromAsn1(asn1) });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
/* kein einzelnes Zertifikat */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const p7 = forge.pkcs7.messageFromAsn1(asn1);
|
||||||
|
const certs = 'certificates' in p7 ? p7.certificates : [];
|
||||||
|
for (const cert of certs) c.certs.push({ source, cert });
|
||||||
|
if (certs.length > 0) return true;
|
||||||
|
} catch {
|
||||||
|
/* kein PKCS#7 */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
forge.pki.certificationRequestFromAsn1(asn1);
|
||||||
|
const body = forge.asn1.toDer(asn1).getBytes();
|
||||||
|
c.csrs.push({ source, pem: forge.pem.encode({ type: 'CERTIFICATE REQUEST', body }) });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
/* keine CSR */
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* kein DER */
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collect(files: BundleFile[], password: string): Collected {
|
||||||
|
const c: Collected = { certs: [], keys: [], csrs: [], locked: [], ignored: [] };
|
||||||
|
for (const { name, buffer } of expandZips(files)) {
|
||||||
|
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
if (ext === 'pfx' || ext === 'p12') {
|
||||||
|
collectPfx(c, name, buffer, password);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const head = buffer.subarray(0, 4096).toString('latin1');
|
||||||
|
const found = head.includes('-----BEGIN')
|
||||||
|
? collectPemText(c, name, buffer.toString('utf-8'), password)
|
||||||
|
: collectDer(c, name, buffer);
|
||||||
|
if (!found) c.ignored.push(name);
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// analyzeBundle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CERT_FORMATS: BundleExportFormat[] = ['crt', 'cer', 'fullchain', 'p7b', 'pfx'];
|
||||||
|
|
||||||
|
export function analyzeBundle(files: BundleFile[], password = ''): BundleAnalysis {
|
||||||
|
if (files.length === 0) throw new BadRequestException('No files provided');
|
||||||
|
const c = collect(files, password);
|
||||||
|
|
||||||
|
// Zertifikate nach Fingerabdruck zusammenfassen (PEM und PFX enthalten oft dieselben).
|
||||||
|
const certMap = new Map<string, { cert: forge.pki.Certificate; sources: Set<string> }>();
|
||||||
|
for (const { source, cert } of c.certs) {
|
||||||
|
const fp = sha256Of(cert);
|
||||||
|
const entry = certMap.get(fp) ?? { cert, sources: new Set<string>() };
|
||||||
|
entry.sources.add(source);
|
||||||
|
certMap.set(fp, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const certItems: BundleItem[] = [...certMap.entries()].map(([fp, { cert, sources }]) => {
|
||||||
|
const info = publicKeyInfo(cert.publicKey);
|
||||||
|
const cn = field(cert.subject, 'CN');
|
||||||
|
const notAfter = cert.validity.notAfter;
|
||||||
|
const role = certRole(cert);
|
||||||
|
return {
|
||||||
|
id: `cert-${fp.replace(/:/g, '').slice(0, 16).toLowerCase()}`,
|
||||||
|
kind: 'certificate',
|
||||||
|
role,
|
||||||
|
sources: [...sources],
|
||||||
|
pem: forge.pki.certificateToPem(cert),
|
||||||
|
baseName: safeBaseName(cn, role === 'end-entity' ? 'zertifikat' : 'ca'),
|
||||||
|
cn,
|
||||||
|
organization: field(cert.subject, 'O'),
|
||||||
|
issuerCn: field(cert.issuer, 'CN'),
|
||||||
|
notBefore: cert.validity.notBefore.toISOString(),
|
||||||
|
notAfter: notAfter.toISOString(),
|
||||||
|
isExpired: notAfter.getTime() < now,
|
||||||
|
daysLeft: Math.ceil((notAfter.getTime() - now) / 86_400_000),
|
||||||
|
san: sanOf(cert.extensions),
|
||||||
|
keyType: info.keyType,
|
||||||
|
keyBits: info.keyBits,
|
||||||
|
serialNumber: cert.serialNumber,
|
||||||
|
sha256: fp,
|
||||||
|
matchId: null,
|
||||||
|
chainIds: [],
|
||||||
|
formats: CERT_FORMATS,
|
||||||
|
// nur intern fuer Kette/Zuordnung, wird unten entfernt
|
||||||
|
_cert: cert,
|
||||||
|
_modulus: info.modulus,
|
||||||
|
} as BundleItem & { _cert: forge.pki.Certificate; _modulus: string };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Kette: zu jedem Zertifikat den Aussteller im Paket suchen.
|
||||||
|
type Internal = BundleItem & { _cert: forge.pki.Certificate; _modulus: string };
|
||||||
|
const internals = certItems as Internal[];
|
||||||
|
for (const item of internals) {
|
||||||
|
let current = item._cert;
|
||||||
|
const seen = new Set<string>([item.id]);
|
||||||
|
for (let depth = 0; depth < 10; depth++) {
|
||||||
|
if (current.subject.hash === current.issuer.hash) break;
|
||||||
|
const issuer = internals.find(
|
||||||
|
(o) => !seen.has(o.id) && o._cert.subject.hash === current.issuer.hash,
|
||||||
|
);
|
||||||
|
if (!issuer) break;
|
||||||
|
item.chainIds.push(issuer.id);
|
||||||
|
seen.add(issuer.id);
|
||||||
|
current = issuer._cert;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schluessel: Duplikate zusammenfassen, dem Zertifikat zuordnen.
|
||||||
|
const keyMap = new Map<string, { key: RawKey; sources: Set<string> }>();
|
||||||
|
for (const key of c.keys) {
|
||||||
|
const id = key.modulus || key.pem;
|
||||||
|
const entry = keyMap.get(id) ?? { key, sources: new Set<string>() };
|
||||||
|
entry.sources.add(key.source);
|
||||||
|
keyMap.set(id, entry);
|
||||||
|
}
|
||||||
|
const keyItems: BundleItem[] = [...keyMap.values()].map(({ key, sources }, i) => {
|
||||||
|
const cert = key.modulus ? internals.find((o) => o._modulus === key.modulus) : undefined;
|
||||||
|
const id = `key-${i + 1}`;
|
||||||
|
if (cert) cert.matchId = id;
|
||||||
|
const isRsa = key.keyType === 'RSA';
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
kind: 'privateKey',
|
||||||
|
sources: [...sources],
|
||||||
|
pem: key.pem,
|
||||||
|
baseName:
|
||||||
|
cert?.baseName ??
|
||||||
|
safeBaseName(
|
||||||
|
sources
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.value?.replace(/\.[^.]+$/, '') ?? '',
|
||||||
|
'schluessel',
|
||||||
|
),
|
||||||
|
cn: cert?.cn ?? '',
|
||||||
|
organization: '',
|
||||||
|
issuerCn: '',
|
||||||
|
notBefore: null,
|
||||||
|
notAfter: null,
|
||||||
|
isExpired: null,
|
||||||
|
daysLeft: null,
|
||||||
|
san: [],
|
||||||
|
keyType: key.keyType,
|
||||||
|
keyBits: key.keyBits,
|
||||||
|
serialNumber: '',
|
||||||
|
sha256: '',
|
||||||
|
matchId: cert?.id ?? null,
|
||||||
|
chainIds: [],
|
||||||
|
formats: isRsa ? ['key', 'key-rsa', 'key-der'] : ['key'],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// CSRs: Duplikate zusammenfassen, Details lesen, dem Zertifikat zuordnen.
|
||||||
|
const csrMap = new Map<string, { pem: string; sources: Set<string> }>();
|
||||||
|
for (const csr of c.csrs) {
|
||||||
|
const norm = csr.pem.replace(/\s+/g, '');
|
||||||
|
const entry = csrMap.get(norm) ?? { pem: csr.pem, sources: new Set<string>() };
|
||||||
|
entry.sources.add(csr.source);
|
||||||
|
csrMap.set(norm, entry);
|
||||||
|
}
|
||||||
|
const csrItems: BundleItem[] = [...csrMap.values()].map(({ pem, sources }, i) => {
|
||||||
|
let cn = '';
|
||||||
|
let organization = '';
|
||||||
|
let info = { keyType: '', keyBits: 0, modulus: '' };
|
||||||
|
let san: string[] = [];
|
||||||
|
try {
|
||||||
|
const csr = forge.pki.certificationRequestFromPem(pem);
|
||||||
|
cn = field(csr.subject as forge.pki.Certificate['subject'], 'CN');
|
||||||
|
organization = field(csr.subject as forge.pki.Certificate['subject'], 'O');
|
||||||
|
info = publicKeyInfo(csr.publicKey);
|
||||||
|
const ext = csr.getAttribute({ name: 'extensionRequest' }) as {
|
||||||
|
extensions?: unknown[];
|
||||||
|
} | null;
|
||||||
|
san = sanOf(ext?.extensions);
|
||||||
|
} catch {
|
||||||
|
// EC-CSR: node-forge liest sie nicht — Teil bleibt trotzdem herunterladbar.
|
||||||
|
}
|
||||||
|
const cert = info.modulus ? internals.find((o) => o._modulus === info.modulus) : undefined;
|
||||||
|
return {
|
||||||
|
id: `csr-${i + 1}`,
|
||||||
|
kind: 'csr',
|
||||||
|
sources: [...sources],
|
||||||
|
pem,
|
||||||
|
baseName: cert?.baseName ?? safeBaseName(cn, 'anfrage'),
|
||||||
|
cn,
|
||||||
|
organization,
|
||||||
|
issuerCn: '',
|
||||||
|
notBefore: null,
|
||||||
|
notAfter: null,
|
||||||
|
isExpired: null,
|
||||||
|
daysLeft: null,
|
||||||
|
san,
|
||||||
|
keyType: info.keyType,
|
||||||
|
keyBits: info.keyBits,
|
||||||
|
serialNumber: '',
|
||||||
|
sha256: '',
|
||||||
|
matchId: cert?.id ?? null,
|
||||||
|
chainIds: [],
|
||||||
|
formats: ['csr', 'csr-der'],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reihenfolge: Serverzertifikat(e), Zwischen-, Stammzertifikate, Schluessel, CSR.
|
||||||
|
const roleOrder: Record<BundleCertRole, number> = { 'end-entity': 0, intermediate: 1, root: 2 };
|
||||||
|
internals.sort(
|
||||||
|
(a, b) =>
|
||||||
|
roleOrder[a.role ?? 'end-entity'] - roleOrder[b.role ?? 'end-entity'] ||
|
||||||
|
b.chainIds.length - a.chainIds.length,
|
||||||
|
);
|
||||||
|
const certsClean: BundleItem[] = internals.map(({ _cert, _modulus, ...rest }) => rest);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: [...certsClean, ...keyItems, ...csrItems],
|
||||||
|
locked: [...new Set(c.locked)],
|
||||||
|
ignored: [...new Set(c.ignored)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// exportBundleItem
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
pem: 'application/x-pem-file',
|
||||||
|
der: 'application/x-x509-ca-cert',
|
||||||
|
p7b: 'application/x-pkcs7-certificates',
|
||||||
|
pfx: 'application/x-pkcs12',
|
||||||
|
key: 'application/x-pem-file',
|
||||||
|
octet: 'application/octet-stream',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function pemBody(pem: string): string {
|
||||||
|
const [msg] = forge.pem.decode(pem);
|
||||||
|
if (!msg) throw new Error('no PEM block');
|
||||||
|
return msg.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportBundleItem(input: BundleExportInput): BundleExportFile {
|
||||||
|
const { kind, pem, format, chain = [], keyPem, password } = input;
|
||||||
|
const base = safeBaseName(
|
||||||
|
input.baseName ?? '',
|
||||||
|
kind === 'csr' ? 'anfrage' : kind === 'privateKey' ? 'schluessel' : 'zertifikat',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!pem || typeof pem !== 'string') throw new BadRequestException('No PEM provided');
|
||||||
|
if (format === 'pfx' && (!password || password.trim() === '')) {
|
||||||
|
throw new BadRequestException('A password is required for PFX output');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (kind === 'certificate') {
|
||||||
|
const cert = forge.pki.certificateFromPem(pem);
|
||||||
|
const chainCerts = chain.map((p) => forge.pki.certificateFromPem(p));
|
||||||
|
switch (format) {
|
||||||
|
case 'crt':
|
||||||
|
return {
|
||||||
|
filename: `${base}.crt`,
|
||||||
|
content: textToBase64(forge.pki.certificateToPem(cert)),
|
||||||
|
mimeType: MIME.pem,
|
||||||
|
};
|
||||||
|
case 'cer':
|
||||||
|
return {
|
||||||
|
filename: `${base}.cer`,
|
||||||
|
content: bytesToBase64(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes()),
|
||||||
|
mimeType: MIME.der,
|
||||||
|
};
|
||||||
|
case 'fullchain': {
|
||||||
|
const text = [cert, ...chainCerts].map((x) => forge.pki.certificateToPem(x)).join('');
|
||||||
|
return {
|
||||||
|
filename: `${base}-fullchain.pem`,
|
||||||
|
content: textToBase64(text),
|
||||||
|
mimeType: MIME.pem,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'p7b': {
|
||||||
|
const p7 = forge.pkcs7.createSignedData();
|
||||||
|
for (const x of [cert, ...chainCerts]) p7.addCertificate(x);
|
||||||
|
const text = forge.pem.encode({
|
||||||
|
type: 'PKCS7',
|
||||||
|
body: forge.asn1.toDer(p7.toAsn1()).getBytes(),
|
||||||
|
});
|
||||||
|
return { filename: `${base}.p7b`, content: textToBase64(text), mimeType: MIME.p7b };
|
||||||
|
}
|
||||||
|
case 'pfx': {
|
||||||
|
const key = keyPem
|
||||||
|
? (forge.pki.privateKeyFromPem(keyPem) as forge.pki.rsa.PrivateKey)
|
||||||
|
: null;
|
||||||
|
const p12 = forge.pkcs12.toPkcs12Asn1(
|
||||||
|
// null = reines Zertifikatsbuendel ohne Schluessel (siehe
|
||||||
|
// CertManagerService.mergeCerts).
|
||||||
|
key,
|
||||||
|
[cert, ...chainCerts],
|
||||||
|
password as string, // oben geprueft: PFX verlangt ein Passwort
|
||||||
|
{ algorithm: '3des', friendlyName: input.baseName || undefined },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
filename: `${base}.pfx`,
|
||||||
|
content: bytesToBase64(forge.asn1.toDer(p12).getBytes()),
|
||||||
|
mimeType: MIME.pfx,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (kind === 'privateKey') {
|
||||||
|
switch (format) {
|
||||||
|
case 'key':
|
||||||
|
return {
|
||||||
|
filename: `${base}.key`,
|
||||||
|
content: textToBase64(`${pem.trim()}\n`),
|
||||||
|
mimeType: MIME.key,
|
||||||
|
};
|
||||||
|
case 'key-rsa': {
|
||||||
|
const key = forge.pki.privateKeyFromPem(pem);
|
||||||
|
return {
|
||||||
|
filename: `${base}.rsa.key`,
|
||||||
|
content: textToBase64(forge.pki.privateKeyToPem(key)),
|
||||||
|
mimeType: MIME.key,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'key-der': {
|
||||||
|
const key = forge.pki.privateKeyFromPem(pem);
|
||||||
|
const info = forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(key));
|
||||||
|
return {
|
||||||
|
filename: `${base}.key.der`,
|
||||||
|
content: bytesToBase64(forge.asn1.toDer(info).getBytes()),
|
||||||
|
mimeType: MIME.octet,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (kind === 'csr') {
|
||||||
|
switch (format) {
|
||||||
|
case 'csr':
|
||||||
|
return {
|
||||||
|
filename: `${base}.csr`,
|
||||||
|
content: textToBase64(`${pem.trim()}\n`),
|
||||||
|
mimeType: MIME.pem,
|
||||||
|
};
|
||||||
|
case 'csr-der':
|
||||||
|
return {
|
||||||
|
filename: `${base}.csr.der`,
|
||||||
|
content: bytesToBase64(pemBody(pem)),
|
||||||
|
mimeType: MIME.octet,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Passwort und Schluessel nie protokollieren oder zurueckgeben.
|
||||||
|
throw new BadRequestException(`Failed to export ${kind} as ${format}`);
|
||||||
|
}
|
||||||
|
throw new BadRequestException(`Unsupported format "${format}" for ${kind}`);
|
||||||
|
}
|
||||||
@@ -10,6 +10,12 @@ import {
|
|||||||
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
|
||||||
import { UseModule } from '../module-registry/module.guard';
|
import { UseModule } from '../module-registry/module.guard';
|
||||||
import type { UploadedFileLike } from '../auth/types/auth-user';
|
import type { UploadedFileLike } from '../auth/types/auth-user';
|
||||||
|
import {
|
||||||
|
analyzeBundle,
|
||||||
|
type BundleExportFormat,
|
||||||
|
type BundleItemKind,
|
||||||
|
exportBundleItem,
|
||||||
|
} from './cert-bundle';
|
||||||
import { CertManagerService } from './cert-manager.service';
|
import { CertManagerService } from './cert-manager.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,4 +124,50 @@ export class CertManagerController {
|
|||||||
}
|
}
|
||||||
return this.certManagerService.convertCert({ file, pemText, targetFormat, password });
|
return this.certManagerService.convertCert({ file, pemText, targetFormat, password });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /modules/cert-manager/analyze (quick-261001-l4q)
|
||||||
|
* Zertifikatspaket: mehrere Dateien oder ZIP hochladen, jedes Teil erkennen
|
||||||
|
* (Server-/Zwischen-/Stammzertifikat, privater Schluessel, CSR), Duplikate
|
||||||
|
* zusammenfassen, Schluessel und Kette zuordnen.
|
||||||
|
*
|
||||||
|
* T-09-03: 20 Dateien, je 5 MB; ZIP-Inhalt zusaetzlich begrenzt (cert-bundle.ts).
|
||||||
|
* T-09-02: password is never passed to the logger
|
||||||
|
*/
|
||||||
|
@Post('analyze')
|
||||||
|
@UseInterceptors(
|
||||||
|
FilesInterceptor('files', 20, {
|
||||||
|
limits: { fileSize: 5 * 1024 * 1024 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
async analyze(
|
||||||
|
@UploadedFiles() files: UploadedFileLike[] | undefined,
|
||||||
|
@Body('password') password?: string,
|
||||||
|
) {
|
||||||
|
return analyzeBundle(files ?? [], password ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /modules/cert-manager/export (quick-261001-l4q)
|
||||||
|
* Ein Teil aus `analyze` (PEM) in das gewuenschte Format bringen.
|
||||||
|
* JSON-Body; PFX verlangt ein Passwort fuer die neue Datei.
|
||||||
|
*/
|
||||||
|
@Post('export')
|
||||||
|
async export(
|
||||||
|
@Body('kind') kind: BundleItemKind,
|
||||||
|
@Body('pem') pem: string,
|
||||||
|
@Body('format') format: BundleExportFormat,
|
||||||
|
@Body('baseName') baseName?: string,
|
||||||
|
@Body('chain') chain?: string[],
|
||||||
|
@Body('keyPem') keyPem?: string,
|
||||||
|
@Body('password') password?: string,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
chain !== undefined &&
|
||||||
|
(!Array.isArray(chain) || chain.some((c) => typeof c !== 'string'))
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('chain must be a list of PEM strings');
|
||||||
|
}
|
||||||
|
return exportBundleItem({ kind, pem, format, baseName, chain, keyPem, password });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { formatRequestLine } from './request-log';
|
||||||
|
|
||||||
|
describe('formatRequestLine', () => {
|
||||||
|
it('schreibt Methode, Pfad, Status, Dauer und Benutzer', () => {
|
||||||
|
expect(
|
||||||
|
formatRequestLine({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/modules/x',
|
||||||
|
status: 200,
|
||||||
|
ms: 12,
|
||||||
|
username: 'admin',
|
||||||
|
}),
|
||||||
|
).toBe('GET /modules/x 200 12 ms user=admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('laesst den Abfrageteil weg', () => {
|
||||||
|
expect(
|
||||||
|
formatRequestLine({ method: 'GET', url: '/auth/reset?token=geheim', status: 200, ms: 1 }),
|
||||||
|
).toBe('GET /auth/reset 200 1 ms user=-');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('markiert langsame Anfragen', () => {
|
||||||
|
expect(formatRequestLine({ method: 'POST', url: '/a', status: 201, ms: 4500 })).toContain(
|
||||||
|
'LANGSAM',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Protokollzeile je API-Anfrage im Docker-Log (quick-261002):
|
||||||
|
* `GET /modules/nextcloud-status/instances 200 34 ms user=admin`.
|
||||||
|
*
|
||||||
|
* Nur Methode, Pfad OHNE Abfrageteil (dort koennten Kennungen oder Token
|
||||||
|
* stehen), Status, Dauer und Benutzername — nie Koerper, Cookies oder
|
||||||
|
* Kopfzeilen. `/health` wird ausgelassen, der Docker-Healthcheck fragt es
|
||||||
|
* alle paar Sekunden ab. Fehler (>= 500) als `error`, Abweisungen (>= 400)
|
||||||
|
* als `warn`, langsame Anfragen (>= 3 s) mit Vermerk.
|
||||||
|
*/
|
||||||
|
const logger = new Logger('HTTP');
|
||||||
|
export const SLOW_REQUEST_MS = 3000;
|
||||||
|
|
||||||
|
export function formatRequestLine(input: {
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
status: number;
|
||||||
|
ms: number;
|
||||||
|
username?: string | null;
|
||||||
|
}): string {
|
||||||
|
const path = input.url.split('?')[0] ?? input.url;
|
||||||
|
const slow = input.ms >= SLOW_REQUEST_MS ? ' LANGSAM' : '';
|
||||||
|
return `${input.method} ${path} ${input.status} ${input.ms} ms user=${input.username ?? '-'}${slow}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestLogMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
const url = req.originalUrl ?? req.url;
|
||||||
|
if (url === '/health' || url.startsWith('/health?') || url.startsWith('/health/')) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const started = process.hrtime.bigint();
|
||||||
|
res.on('finish', () => {
|
||||||
|
const ms = Number((process.hrtime.bigint() - started) / 1_000_000n);
|
||||||
|
const username = (req as Request & { user?: { username?: string } }).user?.username;
|
||||||
|
const line = formatRequestLine({
|
||||||
|
method: req.method,
|
||||||
|
url,
|
||||||
|
status: res.statusCode,
|
||||||
|
ms,
|
||||||
|
username,
|
||||||
|
});
|
||||||
|
if (res.statusCode >= 500) logger.error(line);
|
||||||
|
else if (res.statusCode >= 400 || ms >= SLOW_REQUEST_MS) logger.warn(line);
|
||||||
|
else logger.log(line);
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ModuleCategoriesModule } from '../module-categories/module-categories.module';
|
||||||
import { CustomModulesController } from './custom-modules.controller';
|
import { CustomModulesController } from './custom-modules.controller';
|
||||||
import { CustomModulesService } from './custom-modules.service';
|
import { CustomModulesService } from './custom-modules.service';
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ import { CustomModulesService } from './custom-modules.service';
|
|||||||
* `ProxmoxModule`, das PrismaService ebenfalls ohne eigenen Import erhaelt).
|
* `ProxmoxModule`, das PrismaService ebenfalls ohne eigenen Import erhaelt).
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [ModuleCategoriesModule],
|
||||||
controllers: [CustomModulesController],
|
controllers: [CustomModulesController],
|
||||||
providers: [CustomModulesService],
|
providers: [CustomModulesService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
@@ -51,9 +51,18 @@ const admin = { id: 'admin1', role: Role.ADMIN };
|
|||||||
const userA = { id: 'ua', role: Role.USER };
|
const userA = { id: 'ua', role: Role.USER };
|
||||||
const userB = { id: 'ub', role: Role.USER };
|
const userB = { id: 'ub', role: Role.USER };
|
||||||
|
|
||||||
|
// Kategorien-Dienst (quick-261003-387): die Organisation fuehrt eine feste
|
||||||
|
// Menge von Kennungen; eine andere lehnt assertCategoryKey mit 400 ab.
|
||||||
|
const KNOWN_CATEGORIES = new Set(['fleet', 'infrastructure', 'custom-modules', 'neu-angelegt']);
|
||||||
|
|
||||||
function setup() {
|
function setup() {
|
||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
return { prisma, service: new CustomModulesService(prisma as any) };
|
const categories = {
|
||||||
|
assertCategoryKey: vi.fn(async (_tenantId: string, key: string) => {
|
||||||
|
if (!KNOWN_CATEGORIES.has(key)) throw new BadRequestException('Unbekannte Kategorie');
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return { prisma, categories, service: new CustomModulesService(prisma as any, categories as any) };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('CustomModulesService — anlegen', () => {
|
describe('CustomModulesService — anlegen', () => {
|
||||||
@@ -106,6 +115,40 @@ describe('CustomModulesService — anlegen', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('CustomModulesService — Kategorie gegen die Organisation pruefen', () => {
|
||||||
|
it('create: eine vorhandene Kennung (auch eine neu angelegte) ist erlaubt', async () => {
|
||||||
|
const { categories, service } = setup();
|
||||||
|
const res: any = await service.create('t1', userA, { ...dto, category: 'neu-angelegt' });
|
||||||
|
expect(res.category).toBe('neu-angelegt');
|
||||||
|
expect(categories.assertCategoryKey).toHaveBeenCalledWith('t1', 'neu-angelegt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('create: eine unbekannte Kategorie -> 400, nichts gespeichert', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
await expect(
|
||||||
|
service.create('t1', userA, { ...dto, category: 'gibtsnicht' }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(prisma.customModule.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update: eine unbekannte Kategorie -> 400, der Eintrag bleibt unveraendert', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
const created: any = await service.create('t1', userA, dto);
|
||||||
|
await expect(
|
||||||
|
service.update('t1', userA, created.id, { category: 'gibtsnicht' }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(prisma.customModule.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update ohne Kategorie prueft nichts', async () => {
|
||||||
|
const { categories, service } = setup();
|
||||||
|
const created: any = await service.create('t1', userA, dto);
|
||||||
|
categories.assertCategoryKey.mockClear();
|
||||||
|
await service.update('t1', userA, created.id, { name: 'Neuer Name' });
|
||||||
|
expect(categories.assertCategoryKey).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('CustomModulesService — lesen', () => {
|
describe('CustomModulesService — lesen', () => {
|
||||||
it('list liefert gemeinsame plus eigene Eintraege, nie die eines anderen Benutzers', async () => {
|
it('list liefert gemeinsame plus eigene Eintraege, nie die eines anderen Benutzers', async () => {
|
||||||
const { service } = setup();
|
const { service } = setup();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
|
import { ModuleCategoriesService } from '../module-categories/module-categories.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import type { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
|
import type { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
|
||||||
@@ -10,6 +11,7 @@ const CUSTOM_MODULE_SELECT = {
|
|||||||
name: true,
|
name: true,
|
||||||
url: true,
|
url: true,
|
||||||
category: true,
|
category: true,
|
||||||
|
sortOrder: true,
|
||||||
ownerUserId: true,
|
ownerUserId: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
@@ -56,7 +58,10 @@ function toResponse<T extends { ownerUserId: string | null }>(row: T) {
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomModulesService {
|
export class CustomModulesService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly categories: ModuleCategoriesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/** Gemeinsame Eintraege plus die eigenen des Aufrufers. */
|
/** Gemeinsame Eintraege plus die eigenen des Aufrufers. */
|
||||||
async list(tenantId: string, caller: CustomModuleCaller) {
|
async list(tenantId: string, caller: CustomModuleCaller) {
|
||||||
@@ -81,6 +86,9 @@ export class CustomModulesService {
|
|||||||
if (shared && !isAdmin(caller)) {
|
if (shared && !isAdmin(caller)) {
|
||||||
throw new ForbiddenException('Gemeinsame Einträge dürfen nur Administratoren anlegen');
|
throw new ForbiddenException('Gemeinsame Einträge dürfen nur Administratoren anlegen');
|
||||||
}
|
}
|
||||||
|
// quick-261003-387: jede Kategorie der Organisation ist erlaubt, eine
|
||||||
|
// unbekannte lehnt der Dienst mit 400 ab (statt fester Liste im DTO).
|
||||||
|
await this.categories.assertCategoryKey(tenantId, dto.category);
|
||||||
const data = {
|
const data = {
|
||||||
tenantId,
|
tenantId,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
@@ -106,6 +114,9 @@ export class CustomModulesService {
|
|||||||
dto: UpdateCustomModuleDto,
|
dto: UpdateCustomModuleDto,
|
||||||
) {
|
) {
|
||||||
const tenantPrisma = await this.writableClient(tenantId, caller, id);
|
const tenantPrisma = await this.writableClient(tenantId, caller, id);
|
||||||
|
if (dto.category !== undefined) {
|
||||||
|
await this.categories.assertCategoryKey(tenantId, dto.category);
|
||||||
|
}
|
||||||
const data: { name?: string; url?: string; category?: string } = {};
|
const data: { name?: string; url?: string; category?: string } = {};
|
||||||
if (dto.name !== undefined) data.name = dto.name;
|
if (dto.name !== undefined) data.name = dto.name;
|
||||||
if (dto.url !== undefined) data.url = dto.url;
|
if (dto.url !== undefined) data.url = dto.url;
|
||||||
|
|||||||
@@ -29,11 +29,23 @@ describe('CreateCustomModuleDto', () => {
|
|||||||
expect(await errorsFor(CreateCustomModuleDto, { ...valid, url })).toContain('url');
|
expect(await errorsFor(CreateCustomModuleDto, { ...valid, url })).toContain('url');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lehnt eine unbekannte Kategorie ab', async () => {
|
// quick-261003-387: das DTO prueft nur das Format der Kennung; ob die
|
||||||
expect(await errorsFor(CreateCustomModuleDto, { ...valid, category: 'other' })).toContain(
|
// Organisation sie fuehrt, entscheidet der Dienst (400, siehe Dienst-Spec).
|
||||||
|
it.each(['', 'Gross', 'mit leerzeichen', '-fuehrend', 'x'.repeat(61)])(
|
||||||
|
'lehnt die Kategorie-Kennung %j ab',
|
||||||
|
async (category) => {
|
||||||
|
expect(await errorsFor(CreateCustomModuleDto, { ...valid, category })).toContain('category');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['fleet', 'custom-modules', 'werkzeuge-tools'])(
|
||||||
|
'akzeptiert die Kategorie-Kennung %s',
|
||||||
|
async (category) => {
|
||||||
|
expect(await errorsFor(CreateCustomModuleDto, { ...valid, category })).not.toContain(
|
||||||
'category',
|
'category',
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it.each(['', ' '])('lehnt den Namen %j ab', async (name) => {
|
it.each(['', ' '])('lehnt den Namen %j ab', async (name) => {
|
||||||
expect(await errorsFor(CreateCustomModuleDto, { ...valid, name })).toContain('name');
|
expect(await errorsFor(CreateCustomModuleDto, { ...valid, name })).toContain('name');
|
||||||
@@ -61,7 +73,7 @@ describe('UpdateCustomModuleDto', () => {
|
|||||||
|
|
||||||
it('prueft jedes gesetzte Feld gleich', async () => {
|
it('prueft jedes gesetzte Feld gleich', async () => {
|
||||||
expect(await errorsFor(UpdateCustomModuleDto, { url: 'http://example.com' })).toContain('url');
|
expect(await errorsFor(UpdateCustomModuleDto, { url: 'http://example.com' })).toContain('url');
|
||||||
expect(await errorsFor(UpdateCustomModuleDto, { category: 'other' })).toContain('category');
|
expect(await errorsFor(UpdateCustomModuleDto, { category: 'Nicht Gueltig' })).toContain('category');
|
||||||
expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name');
|
expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { OmitType, PartialType } from '@nestjs/mapped-types';
|
import { OmitType, PartialType } from '@nestjs/mapped-types';
|
||||||
import { CUSTOM_MODULE_CATEGORIES } from '@tessera/shared';
|
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsIn,
|
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
Matches,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
Validate,
|
Validate,
|
||||||
ValidatorConstraint,
|
ValidatorConstraint,
|
||||||
@@ -60,8 +59,13 @@ export class CreateCustomModuleDto {
|
|||||||
@Validate(NurHttpsOhneZugangsdatenConstraint)
|
@Validate(NurHttpsOhneZugangsdatenConstraint)
|
||||||
url!: string;
|
url!: string;
|
||||||
|
|
||||||
@IsIn([...CUSTOM_MODULE_CATEGORIES])
|
/**
|
||||||
category!: (typeof CUSTOM_MODULE_CATEGORIES)[number];
|
* Kennung einer Kategorie der Organisation (quick-261003-387). Das Format
|
||||||
|
* prueft das DTO, ob die Kategorie existiert der Dienst (sonst 400).
|
||||||
|
*/
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[a-z0-9][a-z0-9-]{0,59}$/)
|
||||||
|
category!: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* quick-260929-dzu: `true` legt einen gemeinsamen Eintrag fuer alle Benutzer
|
* quick-260929-dzu: `true` legt einen gemeinsamen Eintrag fuer alle Benutzer
|
||||||
|
|||||||
@@ -21,14 +21,26 @@ describe('widget-module-map (quick-260922-m1h)', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// quick-260924-i8v: Proxmox ist die erste modulgebundene Kachel; alle
|
// quick-260924-i8v: Proxmox ist die erste modulgebundene Kachel,
|
||||||
// uebrigen bleiben Plattform-Kacheln ohne Modulbezug.
|
// quick-261002-k67 ergaenzt Nextcloud-Status; alle uebrigen bleiben
|
||||||
it('nur proxmox traegt einen Modulbezug, alle uebrigen Kacheln sind Plattform-Kacheln', () => {
|
// Plattform-Kacheln ohne Modulbezug.
|
||||||
for (const type of WIDGET_TYPES.filter((t) => t !== 'proxmox')) {
|
it('nur proxmox und nextcloud-status tragen einen Modulbezug, alle uebrigen Kacheln sind Plattform-Kacheln', () => {
|
||||||
|
expect(Object.keys(WIDGET_MODULE_MAP).sort()).toEqual(['nextcloud-status', 'proxmox']);
|
||||||
|
for (const type of WIDGET_TYPES.filter((t) => t !== 'proxmox' && t !== 'nextcloud-status')) {
|
||||||
expect(getModuleSlugForWidgetType(type)).toBeUndefined();
|
expect(getModuleSlugForWidgetType(type)).toBeUndefined();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("die Nextcloud-Status-Kachel gehoert zum Modul 'nextcloud-status' (T-k67-04)", () => {
|
||||||
|
expect(getModuleSlugForWidgetType('nextcloud-status')).toBe('nextcloud-status');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('WIDGET_TYPES endet mit nextcloud-status (zwoelf Typen, Reihenfolge unveraendert)', () => {
|
||||||
|
expect(WIDGET_TYPES).toHaveLength(12);
|
||||||
|
expect(WIDGET_TYPES.at(-1)).toBe('nextcloud-status');
|
||||||
|
expect(WIDGET_TYPES.at(-2)).toBe('reminder');
|
||||||
|
});
|
||||||
|
|
||||||
it("die Proxmox-Kachel gehoert zum Modul 'proxmox' (T-I8V-01)", () => {
|
it("die Proxmox-Kachel gehoert zum Modul 'proxmox' (T-I8V-01)", () => {
|
||||||
expect(getModuleSlugForWidgetType('proxmox')).toBe('proxmox');
|
expect(getModuleSlugForWidgetType('proxmox')).toBe('proxmox');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,16 +23,17 @@ import { WIDGET_MODULE_SLUGS } from '@tessera/shared';
|
|||||||
* für ein Feld, das derzeit für jede Zeile leer wäre, wiegt schwerer als
|
* für ein Feld, das derzeit für jede Zeile leer wäre, wiegt schwerer als
|
||||||
* diese Konstante mit identischer Aussagekraft (15-RESEARCH.md Pitfall 5).
|
* diese Konstante mit identischer Aussagekraft (15-RESEARCH.md Pitfall 5).
|
||||||
*
|
*
|
||||||
* Seit quick-260924-i8v steht dort genau ein Eintrag: `proxmox` →
|
* Seit quick-261002-k67 stehen dort zwei Einträge: `proxmox` → `proxmox`
|
||||||
* `proxmox`. Die übrigen neun Widget-Typen (clock/search/calendar/note/
|
* (quick-260924-i8v) und `nextcloud-status` → `nextcloud-status`. Die
|
||||||
* calculator/favorites/stopwatch/picture-frame/xframe) sind
|
* übrigen Widget-Typen (clock/search/calendar/note/calculator/favorites/
|
||||||
* Plattform-Widgets ohne Modulbezug.
|
* stopwatch/picture-frame/xframe/reminder) sind Plattform-Widgets ohne
|
||||||
|
* Modulbezug.
|
||||||
*/
|
*/
|
||||||
export const WIDGET_MODULE_MAP: Readonly<Record<string, string>> = WIDGET_MODULE_SLUGS;
|
export const WIDGET_MODULE_MAP: Readonly<Record<string, string>> = WIDGET_MODULE_SLUGS;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liefert den Modul-Slug für einen Widget-Typ, oder `undefined`, wenn
|
* Liefert den Modul-Slug für einen Widget-Typ, oder `undefined`, wenn
|
||||||
* der Typ kein Modul-Widget ist (alle Typen außer `proxmox`). Einziger Lesezugriff auf die Zuordnungstabelle,
|
* der Typ kein Modul-Widget ist (alle Typen außer `proxmox` und `nextcloud-status`). Einziger Lesezugriff auf die Zuordnungstabelle,
|
||||||
* damit Tests sie gezielt mocken können.
|
* damit Tests sie gezielt mocken können.
|
||||||
*/
|
*/
|
||||||
export function getModuleSlugForWidgetType(widgetType: string): string | undefined {
|
export function getModuleSlugForWidgetType(widgetType: string): string | undefined {
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ import {
|
|||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { Role } from '@prisma/client';
|
import { ModuleManage } from '../module-registry/module.guard';
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
|
||||||
import type {
|
import type {
|
||||||
AuthenticatedRequest,
|
AuthenticatedRequest,
|
||||||
UploadedFileLike,
|
UploadedFileLike,
|
||||||
@@ -29,11 +28,17 @@ import { DkvHistoryQueryDto } from './dto/dkv-history.dto';
|
|||||||
import { CreateVehicleDto, UpdateVehicleDto } from './dto/dkv-vehicle.dto';
|
import { CreateVehicleDto, UpdateVehicleDto } from './dto/dkv-vehicle.dto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DkvController — all /dkv/* routes, ADMIN-only (V4).
|
* DkvController — all /dkv/* routes, manager level (V4, 261002-icv).
|
||||||
*
|
*
|
||||||
* Every handler carries @Roles(Role.ADMIN, Role.SUPER_ADMIN).
|
* The whole module is Verwalten-level: `@ModuleManage('dkv-fleet')` on the
|
||||||
* Global JwtAuthGuard enforces JWT authentication; RolesGuard enforces the
|
* class replaces the former per-handler @Roles(ADMIN, SUPER_ADMIN). Access is
|
||||||
* @Roles decorator. No route is publicly accessible.
|
* therefore limited to administrators and to users with the grant level
|
||||||
|
* "Verwalten" (MANAGE) on the dkv-fleet module. Users with only "Benutzen"
|
||||||
|
* (USE) keep getting 403 exactly as before — nothing was widened. The class
|
||||||
|
* guard additionally requires the dkv-fleet module to be active for the
|
||||||
|
* tenant (the web page already required that).
|
||||||
|
* Global JwtAuthGuard enforces JWT authentication; ModuleGuard enforces the
|
||||||
|
* grant level. No route is publicly accessible.
|
||||||
*
|
*
|
||||||
* Tenant extraction: `req.tenantId` set by TenantGuard (runs after auth guards).
|
* Tenant extraction: `req.tenantId` set by TenantGuard (runs after auth guards).
|
||||||
* All operations are scoped to the authenticated tenant's data.
|
* All operations are scoped to the authenticated tenant's data.
|
||||||
@@ -52,6 +57,7 @@ import { CreateVehicleDto, UpdateVehicleDto } from './dto/dkv-vehicle.dto';
|
|||||||
* POST /dkv/vehicles/import — bulk-import from CSV upload
|
* POST /dkv/vehicles/import — bulk-import from CSV upload
|
||||||
*/
|
*/
|
||||||
@Controller('dkv')
|
@Controller('dkv')
|
||||||
|
@ModuleManage('dkv-fleet')
|
||||||
export class DkvController {
|
export class DkvController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly dkvService: DkvService,
|
private readonly dkvService: DkvService,
|
||||||
@@ -62,7 +68,6 @@ export class DkvController {
|
|||||||
|
|
||||||
/** GET /dkv/config — returns module config with username + hasPassword. 404 when not yet configured. */
|
/** GET /dkv/config — returns module config with username + hasPassword. 404 when not yet configured. */
|
||||||
@Get('config')
|
@Get('config')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
|
||||||
async getConfig(@Req() req: AuthenticatedRequest) {
|
async getConfig(@Req() req: AuthenticatedRequest) {
|
||||||
const tenantId = this._requireTenant(req);
|
const tenantId = this._requireTenant(req);
|
||||||
const config = await this.dkvService.getConfigForApi(tenantId);
|
const config = await this.dkvService.getConfigForApi(tenantId);
|
||||||
@@ -79,7 +84,6 @@ export class DkvController {
|
|||||||
* or stops the cron job if isActive is false.
|
* or stops the cron job if isActive is false.
|
||||||
*/
|
*/
|
||||||
@Put('config')
|
@Put('config')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
|
||||||
async saveConfig(@Req() req: AuthenticatedRequest, @Body() dto: DkvConfigDto) {
|
async saveConfig(@Req() req: AuthenticatedRequest, @Body() dto: DkvConfigDto) {
|
||||||
const tenantId = this._requireTenant(req);
|
const tenantId = this._requireTenant(req);
|
||||||
const result = await this.dkvService.saveConfig(tenantId, dto);
|
const result = await this.dkvService.saveConfig(tenantId, dto);
|
||||||
@@ -98,7 +102,6 @@ export class DkvController {
|
|||||||
|
|
||||||
/** POST /dkv/check-now — immediately run the inbox processing pipeline. */
|
/** POST /dkv/check-now — immediately run the inbox processing pipeline. */
|
||||||
@Post('check-now')
|
@Post('check-now')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
|
||||||
async checkNow(@Req() req: AuthenticatedRequest) {
|
async checkNow(@Req() req: AuthenticatedRequest) {
|
||||||
const tenantId = this._requireTenant(req);
|
const tenantId = this._requireTenant(req);
|
||||||
return this.dkvService.checkNow(tenantId);
|
return this.dkvService.checkNow(tenantId);
|
||||||
@@ -109,7 +112,6 @@ export class DkvController {
|
|||||||
* Used by the InboxConfigForm "Verbindung testen" button before saving.
|
* Used by the InboxConfigForm "Verbindung testen" button before saving.
|
||||||
*/
|
*/
|
||||||
@Post('test-connection')
|
@Post('test-connection')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
|
||||||
async testConnection(@Req() req: AuthenticatedRequest, @Body() dto: DkvConfigDto) {
|
async testConnection(@Req() req: AuthenticatedRequest, @Body() dto: DkvConfigDto) {
|
||||||
const tenantId = this._requireTenant(req);
|
const tenantId = this._requireTenant(req);
|
||||||
return this.dkvService.testConnection(tenantId, dto);
|
return this.dkvService.testConnection(tenantId, dto);
|
||||||
@@ -122,7 +124,6 @@ export class DkvController {
|
|||||||
* T-07-06: pagination parameters validated by DkvHistoryQueryDto.
|
* T-07-06: pagination parameters validated by DkvHistoryQueryDto.
|
||||||
*/
|
*/
|
||||||
@Get('history')
|
@Get('history')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
|
||||||
async getHistory(@Req() req: AuthenticatedRequest, @Query() query: DkvHistoryQueryDto) {
|
async getHistory(@Req() req: AuthenticatedRequest, @Query() query: DkvHistoryQueryDto) {
|
||||||
const tenantId = this._requireTenant(req);
|
const tenantId = this._requireTenant(req);
|
||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
@@ -140,7 +141,6 @@ export class DkvController {
|
|||||||
* containing path separators or non-whitelisted characters is rejected.
|
* containing path separators or non-whitelisted characters is rejected.
|
||||||
*/
|
*/
|
||||||
@Get('exports/:filename')
|
@Get('exports/:filename')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
|
||||||
async downloadExport(
|
async downloadExport(
|
||||||
@Req() req: AuthenticatedRequest,
|
@Req() req: AuthenticatedRequest,
|
||||||
@Param('filename') filename: string,
|
@Param('filename') filename: string,
|
||||||
@@ -168,7 +168,6 @@ export class DkvController {
|
|||||||
|
|
||||||
/** GET /dkv/vehicles — list all vehicle master records for this tenant. */
|
/** GET /dkv/vehicles — list all vehicle master records for this tenant. */
|
||||||
@Get('vehicles')
|
@Get('vehicles')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
|
||||||
async listVehicles(@Req() req: AuthenticatedRequest) {
|
async listVehicles(@Req() req: AuthenticatedRequest) {
|
||||||
const tenantId = this._requireTenant(req);
|
const tenantId = this._requireTenant(req);
|
||||||
return this.dkvService.listVehicles(tenantId);
|
return this.dkvService.listVehicles(tenantId);
|
||||||
@@ -176,7 +175,6 @@ export class DkvController {
|
|||||||
|
|
||||||
/** POST /dkv/vehicles — create a new vehicle master record. */
|
/** POST /dkv/vehicles — create a new vehicle master record. */
|
||||||
@Post('vehicles')
|
@Post('vehicles')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
|
||||||
async createVehicle(@Req() req: AuthenticatedRequest, @Body() dto: CreateVehicleDto) {
|
async createVehicle(@Req() req: AuthenticatedRequest, @Body() dto: CreateVehicleDto) {
|
||||||
const tenantId = this._requireTenant(req);
|
const tenantId = this._requireTenant(req);
|
||||||
return this.dkvService.createVehicle(tenantId, dto);
|
return this.dkvService.createVehicle(tenantId, dto);
|
||||||
@@ -184,7 +182,6 @@ export class DkvController {
|
|||||||
|
|
||||||
/** PUT /dkv/vehicles/:id — update an existing vehicle master record. */
|
/** PUT /dkv/vehicles/:id — update an existing vehicle master record. */
|
||||||
@Put('vehicles/:id')
|
@Put('vehicles/:id')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
|
||||||
async updateVehicle(
|
async updateVehicle(
|
||||||
@Req() req: AuthenticatedRequest,
|
@Req() req: AuthenticatedRequest,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -196,7 +193,6 @@ export class DkvController {
|
|||||||
|
|
||||||
/** DELETE /dkv/vehicles/:id — delete a vehicle master record. */
|
/** DELETE /dkv/vehicles/:id — delete a vehicle master record. */
|
||||||
@Delete('vehicles/:id')
|
@Delete('vehicles/:id')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
|
||||||
async deleteVehicle(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
async deleteVehicle(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||||
const tenantId = this._requireTenant(req);
|
const tenantId = this._requireTenant(req);
|
||||||
return this.dkvService.deleteVehicle(tenantId, id);
|
return this.dkvService.deleteVehicle(tenantId, id);
|
||||||
@@ -213,7 +209,6 @@ export class DkvController {
|
|||||||
* The controller reads `file.buffer.toString('utf-8')` and passes to DkvService.
|
* The controller reads `file.buffer.toString('utf-8')` and passes to DkvService.
|
||||||
*/
|
*/
|
||||||
@Post('vehicles/import')
|
@Post('vehicles/import')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
|
||||||
@UseInterceptors(FileInterceptor('file', {
|
@UseInterceptors(FileInterceptor('file', {
|
||||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB — generous for any realistic vehicle list (WR-05)
|
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB — generous for any realistic vehicle list (WR-05)
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -220,7 +220,11 @@ function expectBoundCall(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeIconDiscovery(
|
function makeIconDiscovery(
|
||||||
overrides: Partial<{ discoverFavoriteIconUrl: any; fetchIconBytes: any }> = {},
|
overrides: Partial<{
|
||||||
|
discoverFavoriteIconUrl: any;
|
||||||
|
fetchIconBytes: any;
|
||||||
|
fetchPublicServiceIconBytes: any;
|
||||||
|
}> = {},
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
discoverFavoriteIconUrl:
|
discoverFavoriteIconUrl:
|
||||||
@@ -229,6 +233,11 @@ function makeIconDiscovery(
|
|||||||
fetchIconBytes:
|
fetchIconBytes:
|
||||||
overrides.fetchIconBytes ??
|
overrides.fetchIconBytes ??
|
||||||
vi.fn(async () => ({ contentType: 'image/png', body: Buffer.from('png') })),
|
vi.fn(async () => ({ contentType: 'image/png', body: Buffer.from('png') })),
|
||||||
|
fetchPublicServiceIconBytes:
|
||||||
|
overrides.fetchPublicServiceIconBytes ??
|
||||||
|
vi.fn(async () => {
|
||||||
|
throw new Error('icon service: unknown');
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,6 +520,35 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
|||||||
await expect(service.getIconBytes('t2', 'f1', 'user-a1')).rejects.toThrow(NotFoundException);
|
await expect(service.getIconBytes('t2', 'f1', 'user-a1')).rejects.toThrow(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('quick-261001-hbi: gespeichertes Symbol scheitert -> Symbol-Dienst mit der Seiten-URL', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const iconDiscovery = makeIconDiscovery({
|
||||||
|
fetchIconBytes: vi.fn(async () => {
|
||||||
|
throw new Error('not an image');
|
||||||
|
}),
|
||||||
|
fetchPublicServiceIconBytes: vi.fn(async () => ({
|
||||||
|
contentType: 'image/png',
|
||||||
|
body: Buffer.from('ddg'),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
|
||||||
|
const result = await service.getIconBytes('t1', 'f1', 'user-a1');
|
||||||
|
|
||||||
|
expect(iconDiscovery.fetchPublicServiceIconBytes).toHaveBeenCalledWith(baseRow.url);
|
||||||
|
expect(result.body).toEqual(Buffer.from('ddg'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quick-261001-hbi: gespeichertes Symbol klappt -> Symbol-Dienst wird nicht gefragt', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const iconDiscovery = makeIconDiscovery();
|
||||||
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
|
||||||
|
await service.getIconBytes('t1', 'f1', 'user-a1');
|
||||||
|
|
||||||
|
expect(iconDiscovery.fetchPublicServiceIconBytes).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('fetchIconBytes wirft -> HttpException mit Status 502', async () => {
|
it('fetchIconBytes wirft -> HttpException mit Status 502', async () => {
|
||||||
const prisma = makeFakePrisma([baseRow]);
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
const iconDiscovery = makeIconDiscovery({
|
const iconDiscovery = makeIconDiscovery({
|
||||||
|
|||||||
@@ -497,7 +497,9 @@ export class FavoritesService {
|
|||||||
* Throws NotFoundException (404) if the row doesn't exist, isn't owned
|
* Throws NotFoundException (404) if the row doesn't exist, isn't owned
|
||||||
* by the caller, or has neither an uploaded icon nor a stored iconUrl.
|
* by the caller, or has neither an uploaded icon nor a stored iconUrl.
|
||||||
* Throws a 502 HttpException if the upstream fetch fails (unreachable,
|
* Throws a 502 HttpException if the upstream fetch fails (unreachable,
|
||||||
* timeout, non-image, or SSRF-blocked) -- never returns a placeholder image.
|
* timeout, non-image, or SSRF-blocked) AND the public icon service fallback
|
||||||
|
* (quick-261001-hbi, public pages only) has no icon either -- never returns
|
||||||
|
* a placeholder image.
|
||||||
*/
|
*/
|
||||||
async getIconBytes(
|
async getIconBytes(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
@@ -535,8 +537,15 @@ export class FavoritesService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
return await this.iconDiscovery.fetchIconBytes(link.iconUrl);
|
return await this.iconDiscovery.fetchIconBytes(link.iconUrl);
|
||||||
|
} catch {
|
||||||
|
// quick-261001-hbi: Seite liefert kein abrufbares Symbol (z. B. per
|
||||||
|
// JavaScript gesetzt) -- einmal beim oeffentlichen Symbol-Dienst fragen,
|
||||||
|
// nur fuer oeffentlich erreichbare Seiten.
|
||||||
|
try {
|
||||||
|
return await this.iconDiscovery.fetchPublicServiceIconBytes(link.url);
|
||||||
} catch {
|
} catch {
|
||||||
throw new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY);
|
throw new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -238,6 +238,57 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('IconDiscoveryService.fetchPublicServiceIconBytes (quick-261001-hbi)', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fragt fuer eine oeffentliche Seite den Symbol-Dienst mit dem Hostnamen', async () => {
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValue(mockResponse({ contentType: 'image/png' }));
|
||||||
|
vi.stubGlobal('fetch', fetchSpy);
|
||||||
|
|
||||||
|
const service = new IconDiscoveryService();
|
||||||
|
const result = await service.fetchPublicServiceIconBytes('http://8.8.8.8/start');
|
||||||
|
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchSpy.mock.calls[0][0]).toBe('https://icons.duckduckgo.com/ip3/8.8.8.8.ico');
|
||||||
|
expect(result.contentType).toBe('image/png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fragt fuer eine interne Seite NICHT (Hostname verlaesst das Haus nicht)', async () => {
|
||||||
|
const fetchSpy = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchSpy);
|
||||||
|
|
||||||
|
const service = new IconDiscoveryService();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.fetchPublicServiceIconBytes('https://docuvita.ctl.local/x'),
|
||||||
|
).rejects.toThrow(/not public/);
|
||||||
|
await expect(service.fetchPublicServiceIconBytes('http://192.168.1.5/')).rejects.toThrow(
|
||||||
|
/not public/,
|
||||||
|
);
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Dienst kennt kein Symbol (404) -> wirft', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
...mockResponse({ contentType: 'image/png' }),
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new IconDiscoveryService();
|
||||||
|
|
||||||
|
await expect(service.fetchPublicServiceIconBytes('http://8.8.8.8/')).rejects.toThrow(
|
||||||
|
/blocked or failed/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', () => {
|
describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ const MAX_REDIRECTS = 2;
|
|||||||
const MAX_HTML_CHARS = 200000;
|
const MAX_HTML_CHARS = 200000;
|
||||||
const MAX_ICON_BYTES = 1_000_000;
|
const MAX_ICON_BYTES = 1_000_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* quick-261001-hbi — oeffentlicher Symbol-Dienst als letzter Rueckfall. Manche
|
||||||
|
* Seiten setzen ihr Symbol erst per JavaScript (hosteurope.de: im HTML nur
|
||||||
|
* `<link rel="icon" href="data:;base64,=">`, `/favicon.ico` liefert eine
|
||||||
|
* HTML-Seite) — ohne Browser findet die Suche dort nichts. DuckDuckGo kennt
|
||||||
|
* das gerenderte Symbol und antwortet fuer Unbekanntes mit 404 (dann bleibt
|
||||||
|
* der Buchstabe). Gefragt wird NUR fuer oeffentlich erreichbare Adressen,
|
||||||
|
* damit interne Hostnamen (docuvita.ctl.local, private IPs) das Haus nie
|
||||||
|
* verlassen; der Dienst erfaehrt nur den Hostnamen.
|
||||||
|
*/
|
||||||
|
const PUBLIC_ICON_SERVICE = 'https://icons.duckduckgo.com/ip3/';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 260917-jdd — Ziel ist ein Bildchen, kein Geheimnis: selbstsignierte,
|
* 260917-jdd — Ziel ist ein Bildchen, kein Geheimnis: selbstsignierte,
|
||||||
* abgelaufene oder falsch benannte Zertifikate sollen das Symbol eines
|
* abgelaufene oder falsch benannte Zertifikate sollen das Symbol eines
|
||||||
@@ -426,6 +438,22 @@ export class IconDiscoveryService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* quick-261001-hbi: Symbol fuer die Seite `pageUrl` beim oeffentlichen
|
||||||
|
* Symbol-Dienst holen (siehe PUBLIC_ICON_SERVICE). Wirft, wenn die Seite
|
||||||
|
* nicht oeffentlich erreichbar ist (dann wird der Dienst NICHT gefragt) oder
|
||||||
|
* der Dienst kein Symbol kennt (404) — wie `fetchIconBytes`.
|
||||||
|
*/
|
||||||
|
async fetchPublicServiceIconBytes(
|
||||||
|
pageUrl: string,
|
||||||
|
): Promise<{ contentType: string; body: Buffer }> {
|
||||||
|
const page = new URL(normalizeUrl(pageUrl));
|
||||||
|
if (!(await isPublicHttpUrl(page))) {
|
||||||
|
throw new Error('Page is not public, icon service not asked');
|
||||||
|
}
|
||||||
|
return this.fetchIconBytes(`${PUBLIC_ICON_SERVICE}${page.hostname}.ico`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the raw bytes of a stored icon URL, SSRF-guarded, for streaming
|
* Fetch the raw bytes of a stored icon URL, SSRF-guarded, for streaming
|
||||||
* back to the browser from Tessera's own origin (avoids Cross-Origin-
|
* back to the browser from Tessera's own origin (avoids Cross-Origin-
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { plainToInstance } from 'class-transformer';
|
||||||
|
import { validate } from 'class-validator';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CreateModuleGrantDto } from './create-module-grant.dto';
|
||||||
|
|
||||||
|
async function errorsFor(plain: Record<string, unknown>) {
|
||||||
|
const dto = plainToInstance(CreateModuleGrantDto, plain);
|
||||||
|
const errors = await validate(dto as object);
|
||||||
|
return errors.map((e) => e.property);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CreateModuleGrantDto — Freigabestufe (261002-icv)', () => {
|
||||||
|
it('ohne level ist gültig', async () => {
|
||||||
|
expect(await errorsFor({ moduleId: 'm1', groupId: 'g1' })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['USE', 'MANAGE'])('level %s ist gültig', async (level) => {
|
||||||
|
expect(await errorsFor({ moduleId: 'm1', userId: 'u1', level })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['ADMIN', 'manage', 'use', '', 1])('level %j wird abgelehnt', async (level) => {
|
||||||
|
expect(await errorsFor({ moduleId: 'm1', userId: 'u1', level })).toContain('level');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
import { ModuleGrantLevel } from '@prisma/client';
|
||||||
|
import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO für Grant-Erstellung und -Entzug (PERM-03).
|
* DTO für Grant-Erstellung und -Entzug (PERM-03).
|
||||||
@@ -21,4 +22,12 @@ export class CreateModuleGrantDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freigabestufe (261002-icv): 'USE' (Benutzen, Standard) oder 'MANAGE'
|
||||||
|
* (Verwalten). Beim Entzug (DELETE) wird das Feld ignoriert.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ModuleGrantLevel)
|
||||||
|
level?: ModuleGrantLevel;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ModuleCategoriesModule } from '../module-categories/module-categories.module';
|
||||||
import { GroupsController } from './groups.controller';
|
import { GroupsController } from './groups.controller';
|
||||||
import { GroupsService } from './groups.service';
|
import { GroupsService } from './groups.service';
|
||||||
import { ModuleGrantsController } from './module-grants.controller';
|
import { ModuleGrantsController } from './module-grants.controller';
|
||||||
@@ -17,6 +18,7 @@ import { ModuleGrantsService } from './module-grants.service';
|
|||||||
* ModuleAccessService (15-01/15-05), das hier nicht verwendet wird.
|
* ModuleAccessService (15-01/15-05), das hier nicht verwendet wird.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [ModuleCategoriesModule],
|
||||||
controllers: [GroupsController, ModuleGrantsController],
|
controllers: [GroupsController, ModuleGrantsController],
|
||||||
providers: [GroupsService, ModuleGrantsService],
|
providers: [GroupsService, ModuleGrantsService],
|
||||||
exports: [GroupsService, ModuleGrantsService],
|
exports: [GroupsService, ModuleGrantsService],
|
||||||
|
|||||||
@@ -335,3 +335,17 @@ describe('add_group_internal_name_and_object_guid migration.sql (D-04)', () => {
|
|||||||
expect(sql).not.toMatch(/ALTER TABLE .* (ENABLE|FORCE) ROW LEVEL SECURITY/);
|
expect(sql).not.toMatch(/ALTER TABLE .* (ENABLE|FORCE) ROW LEVEL SECURITY/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('module_grant_level migration.sql (261002-icv)', () => {
|
||||||
|
const sql = readMigrationSql('_module_grant_level');
|
||||||
|
|
||||||
|
it('legt den Aufzählungstyp ModuleGrantLevel mit USE und MANAGE an', () => {
|
||||||
|
expect(sql).toContain(`CREATE TYPE "ModuleGrantLevel" AS ENUM ('USE', 'MANAGE');`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fügt die Spalte level mit Standard USE hinzu (Bestand wird USE)', () => {
|
||||||
|
expect(sql).toContain(
|
||||||
|
`ALTER TABLE "ModuleGrant" ADD COLUMN "level" "ModuleGrantLevel" NOT NULL DEFAULT 'USE';`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Role } from '@prisma/client';
|
|||||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
|
import { ModuleCategoriesService } from '../module-categories/module-categories.service';
|
||||||
import { CreateModuleGrantDto } from './dto/create-module-grant.dto';
|
import { CreateModuleGrantDto } from './dto/create-module-grant.dto';
|
||||||
import { ModuleGrantsService } from './module-grants.service';
|
import { ModuleGrantsService } from './module-grants.service';
|
||||||
|
|
||||||
@@ -29,7 +30,10 @@ import { ModuleGrantsService } from './module-grants.service';
|
|||||||
*/
|
*/
|
||||||
@Controller('module-grants')
|
@Controller('module-grants')
|
||||||
export class ModuleGrantsController {
|
export class ModuleGrantsController {
|
||||||
constructor(private readonly moduleGrantsService: ModuleGrantsService) {}
|
constructor(
|
||||||
|
private readonly moduleGrantsService: ModuleGrantsService,
|
||||||
|
private readonly moduleCategoriesService: ModuleCategoriesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
private getTenantId(req: AuthenticatedRequest): string {
|
private getTenantId(req: AuthenticatedRequest): string {
|
||||||
const tenantId = req.tenantId ?? req.user?.tenantId;
|
const tenantId = req.tenantId ?? req.user?.tenantId;
|
||||||
@@ -41,13 +45,20 @@ export class ModuleGrantsController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /module-grants/matrix
|
* GET /module-grants/matrix
|
||||||
* Module × Gruppen mit den bestehenden Gruppen-Grants (D-15).
|
* Module × Gruppen mit den bestehenden Gruppen-Grants (D-15); die Module
|
||||||
|
* stehen in der eingestellten Kategorie- und Modulreihenfolge.
|
||||||
*/
|
*/
|
||||||
@Get('matrix')
|
@Get('matrix')
|
||||||
@UseGuards(RolesGuard)
|
@UseGuards(RolesGuard)
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
async matrix(@Req() req: AuthenticatedRequest) {
|
async matrix(@Req() req: AuthenticatedRequest) {
|
||||||
return this.moduleGrantsService.getMatrix(this.getTenantId(req));
|
const tenantId = this.getTenantId(req);
|
||||||
|
const result = await this.moduleGrantsService.getMatrix(tenantId);
|
||||||
|
// quick-261003-387: Module in der Kategorie- und Modulreihenfolge der
|
||||||
|
// Organisation, jedes mit seiner wirksamen Kategorie. getMatrix selbst
|
||||||
|
// bleibt unveraendert.
|
||||||
|
const modules = await this.moduleCategoriesService.applyToModules(tenantId, result.modules);
|
||||||
|
return { ...result, modules };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -124,6 +124,12 @@ function makeFakePrisma() {
|
|||||||
findFirst: async ({ where }: any) => {
|
findFirst: async ({ where }: any) => {
|
||||||
return findGrant(where.tenantId, where.moduleId, where.groupId, where.userId) ?? null;
|
return findGrant(where.tenantId, where.moduleId, where.groupId, where.userId) ?? null;
|
||||||
},
|
},
|
||||||
|
update: async ({ where, data }: any) => {
|
||||||
|
const record = grants.get(where.id);
|
||||||
|
if (!record) throw new Error('not found');
|
||||||
|
Object.assign(record, data);
|
||||||
|
return record;
|
||||||
|
},
|
||||||
findMany: async ({ where }: any) => {
|
findMany: async ({ where }: any) => {
|
||||||
let rows = Array.from(grants.values()).filter((g) => g.tenantId === where.tenantId);
|
let rows = Array.from(grants.values()).filter((g) => g.tenantId === where.tenantId);
|
||||||
|
|
||||||
@@ -351,6 +357,83 @@ describe('ModuleGrantsService.grant', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ModuleGrantsService.grant — Freigabestufe (261002-icv)', () => {
|
||||||
|
it('ohne Stufe wird mit USE angelegt', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
seedBase(prisma);
|
||||||
|
const service = new ModuleGrantsService(prisma as any);
|
||||||
|
|
||||||
|
const result = await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
|
||||||
|
|
||||||
|
expect(result.level).toBe('USE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mit Stufe MANAGE wird mit MANAGE angelegt', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
seedBase(prisma);
|
||||||
|
const service = new ModuleGrantsService(prisma as any);
|
||||||
|
|
||||||
|
const result = await service.grant('t1', { moduleId: 'mod-1', userId: 'u1', level: 'MANAGE' });
|
||||||
|
|
||||||
|
expect(result.level).toBe('MANAGE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bestehende USE-Freigabe plus Stufe MANAGE wird auf MANAGE angehoben und protokolliert', async () => {
|
||||||
|
const logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
seedBase(prisma);
|
||||||
|
const service = new ModuleGrantsService(prisma as any);
|
||||||
|
const first = await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
|
||||||
|
|
||||||
|
const second = await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1', level: 'MANAGE' });
|
||||||
|
|
||||||
|
expect(second.id).toBe(first.id);
|
||||||
|
expect(second.level).toBe('MANAGE');
|
||||||
|
expect(prisma.__grantCount()).toBe(1);
|
||||||
|
expect(logSpy.mock.calls.map((c) => String(c[0])).join('\n')).toContain(
|
||||||
|
'Grant-Stufe geändert: tenant=t1 module=mod-1 group=g1 level=MANAGE',
|
||||||
|
);
|
||||||
|
logSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bestehende MANAGE-Freigabe ohne Stufenangabe bleibt MANAGE (Wiederholungsklick stuft nie herab)', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
seedBase(prisma);
|
||||||
|
const service = new ModuleGrantsService(prisma as any);
|
||||||
|
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1', level: 'MANAGE' });
|
||||||
|
|
||||||
|
const again = await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
|
||||||
|
|
||||||
|
expect(again.level).toBe('MANAGE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bestehende MANAGE-Freigabe kann ausdrücklich auf USE gesetzt werden', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
seedBase(prisma);
|
||||||
|
const service = new ModuleGrantsService(prisma as any);
|
||||||
|
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1', level: 'MANAGE' });
|
||||||
|
|
||||||
|
const down = await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1', level: 'USE' });
|
||||||
|
|
||||||
|
expect(down.level).toBe('USE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('P2002-Wettlauf mit Stufe: die Stufe wird angewendet, es bleibt eine Zeile', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
seedBase(prisma);
|
||||||
|
const service = new ModuleGrantsService(prisma as any);
|
||||||
|
|
||||||
|
const [a, b] = await Promise.all([
|
||||||
|
service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }),
|
||||||
|
service.grant('t1', { moduleId: 'mod-1', groupId: 'g1', level: 'MANAGE' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(a.groupId).toBe('g1');
|
||||||
|
expect(b.level).toBe('MANAGE');
|
||||||
|
expect(prisma.__grantCount()).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('ModuleGrantsService.revoke', () => {
|
describe('ModuleGrantsService.revoke', () => {
|
||||||
it('entfernt einen bestehenden Grant', async () => {
|
it('entfernt einen bestehenden Grant', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
@@ -412,7 +495,18 @@ describe('ModuleGrantsService.getMatrix', () => {
|
|||||||
|
|
||||||
expect(matrix.modules.map((m: any) => m.id)).toEqual(['mod-a', 'mod-b']);
|
expect(matrix.modules.map((m: any) => m.id)).toEqual(['mod-a', 'mod-b']);
|
||||||
expect(matrix.groups.map((g: any) => g.name)).toEqual(['Alpha', 'Zeta']);
|
expect(matrix.groups.map((g: any) => g.name)).toEqual(['Alpha', 'Zeta']);
|
||||||
expect(matrix.grants).toEqual([{ moduleId: 'mod-a', groupId: 'g1' }]);
|
expect(matrix.grants).toEqual([{ moduleId: 'mod-a', groupId: 'g1', level: 'USE' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('261002-icv: jedes Grant-Element trägt die Stufe', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
seedBase(prisma);
|
||||||
|
const service = new ModuleGrantsService(prisma as any);
|
||||||
|
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1', level: 'MANAGE' });
|
||||||
|
|
||||||
|
const matrix = await service.getMatrix('t1');
|
||||||
|
|
||||||
|
expect(matrix.grants).toEqual([{ moduleId: 'mod-1', groupId: 'g1', level: 'MANAGE' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('empty: ein Mandant ohne Gruppen liefert eine leere Gruppenliste und wirft nicht', async () => {
|
it('empty: ein Mandant ohne Gruppen liefert eine leere Gruppenliste und wirft nicht', async () => {
|
||||||
@@ -469,11 +563,32 @@ describe('ModuleGrantsService.getUserAccess', () => {
|
|||||||
module: { id: 'mod-1', category: 'ops', name: 'Modul Eins' },
|
module: { id: 'mod-1', category: 'ops', name: 'Modul Eins' },
|
||||||
viaGroups: ['Gruppe A'],
|
viaGroups: ['Gruppe A'],
|
||||||
direct: false,
|
direct: false,
|
||||||
|
directLevel: null,
|
||||||
|
manageViaGroups: [],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(result.groups).toEqual([{ id: 'g1', name: 'Gruppe A', source: 'MANUAL' }]);
|
expect(result.groups).toEqual([{ id: 'g1', name: 'Gruppe A', source: 'MANUAL' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('261002-icv: directLevel zeigt die Stufe der Direkt-Freigabe, manageViaGroups nennt Gruppen mit Verwalten', async () => {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
seedBase(prisma);
|
||||||
|
prisma.__seedGroup({ id: 'g2', tenantId: 't1', name: 'Gruppe B' });
|
||||||
|
prisma.__seedMembership('g1', 'u1');
|
||||||
|
prisma.__seedMembership('g2', 'u1');
|
||||||
|
const service = new ModuleGrantsService(prisma as any);
|
||||||
|
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
|
||||||
|
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g2', level: 'MANAGE' });
|
||||||
|
await service.grant('t1', { moduleId: 'mod-1', userId: 'u1', level: 'MANAGE' });
|
||||||
|
|
||||||
|
const row = (await service.getUserAccess('t1', 'u1')).modules[0];
|
||||||
|
|
||||||
|
expect(row.direct).toBe(true);
|
||||||
|
expect(row.directLevel).toBe('MANAGE');
|
||||||
|
expect([...row.viaGroups].sort()).toEqual(['Gruppe A', 'Gruppe B']);
|
||||||
|
expect(row.manageViaGroups).toEqual(['Gruppe B']);
|
||||||
|
});
|
||||||
|
|
||||||
it('adjacency: ein Direkt-Grant UND ein Gruppen-Grant auf dasselbe Modul erscheinen gleichzeitig, keiner verdrängt den anderen', async () => {
|
it('adjacency: ein Direkt-Grant UND ein Gruppen-Grant auf dasselbe Modul erscheinen gleichzeitig, keiner verdrängt den anderen', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
seedBase(prisma);
|
seedBase(prisma);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { type ModuleGrant, ModuleGrantLevel } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import { prismaErrorCode } from '../prisma/prisma-error';
|
import { prismaErrorCode } from '../prisma/prisma-error';
|
||||||
@@ -21,8 +22,9 @@ import { prismaErrorCode } from '../prisma/prisma-error';
|
|||||||
* über `this.logger`. Es entsteht bewusst keine Audit-Tabelle und keine
|
* über `this.logger`. Es entsteht bewusst keine Audit-Tabelle und keine
|
||||||
* Ansicht im Admin-UI.
|
* Ansicht im Admin-UI.
|
||||||
*
|
*
|
||||||
* D-04: der Datensatz trägt keine Rechtestufe, und dieser Service bietet
|
* Seit 261002-icv trägt der Datensatz eine Freigabestufe `level` (Benutzen /
|
||||||
* keine Methode, die eine solche setzen könnte.
|
* Verwalten); nur dieser ausschließlich Administratoren zugängliche Service
|
||||||
|
* setzt sie.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ModuleGrantsService {
|
export class ModuleGrantsService {
|
||||||
@@ -82,9 +84,9 @@ export class ModuleGrantsService {
|
|||||||
*/
|
*/
|
||||||
async grant(
|
async grant(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
data: { moduleId: string; groupId?: string; userId?: string },
|
data: { moduleId: string; groupId?: string; userId?: string; level?: ModuleGrantLevel },
|
||||||
) {
|
) {
|
||||||
const { moduleId, groupId, userId } = data;
|
const { moduleId, groupId, userId, level } = data;
|
||||||
if ((groupId && userId) || (!groupId && !userId)) {
|
if ((groupId && userId) || (!groupId && !userId)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Ein Grant muss entweder eine groupId oder eine userId tragen, nicht beides und nicht keines',
|
'Ein Grant muss entweder eine groupId oder eine userId tragen, nicht beides und nicht keines',
|
||||||
@@ -119,6 +121,37 @@ export class ModuleGrantsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const target = groupId ? `group=${groupId}` : `user=${userId}`;
|
const target = groupId ? `group=${groupId}` : `user=${userId}`;
|
||||||
|
const targetWhere = {
|
||||||
|
tenantId,
|
||||||
|
moduleId,
|
||||||
|
groupId: groupId ?? null,
|
||||||
|
userId: userId ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Besteht der Grant schon: nur eine ausdruecklich andere Stufe aendert
|
||||||
|
// ihn. Ohne Stufenangabe (erneuter Klick auf die Zelle) bleibt die
|
||||||
|
// vorhandene Stufe — ein Wiederholungsklick stuft nie herab (T-icv-11).
|
||||||
|
const applyToExisting = async (existing: ModuleGrant): Promise<ModuleGrant> => {
|
||||||
|
if (level && existing.level !== level) {
|
||||||
|
const updated = await tenantPrisma.moduleGrant.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { level },
|
||||||
|
});
|
||||||
|
this.logger.log(
|
||||||
|
`Grant-Stufe geändert: tenant=${tenantId} module=${moduleId} ${target} level=${level}`,
|
||||||
|
);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
this.logger.log(
|
||||||
|
`Grant bereits vorhanden (Doppelklick abgefangen): tenant=${tenantId} module=${moduleId} ${target}`,
|
||||||
|
);
|
||||||
|
return existing;
|
||||||
|
};
|
||||||
|
|
||||||
|
const found = await tenantPrisma.moduleGrant.findFirst({ where: targetWhere });
|
||||||
|
if (found) {
|
||||||
|
return applyToExisting(found);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const created = await tenantPrisma.moduleGrant.create({
|
const created = await tenantPrisma.moduleGrant.create({
|
||||||
@@ -127,27 +160,18 @@ export class ModuleGrantsService {
|
|||||||
moduleId,
|
moduleId,
|
||||||
groupId: groupId ?? null,
|
groupId: groupId ?? null,
|
||||||
userId: userId ?? null,
|
userId: userId ?? null,
|
||||||
|
level: level ?? ModuleGrantLevel.USE,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Grant erteilt: tenant=${tenantId} module=${moduleId} ${target}`,
|
`Grant erteilt: tenant=${tenantId} module=${moduleId} ${target} level=${created.level ?? level ?? ModuleGrantLevel.USE}`,
|
||||||
);
|
);
|
||||||
return created;
|
return created;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (prismaErrorCode(err) === 'P2002') {
|
if (prismaErrorCode(err) === 'P2002') {
|
||||||
const existing = await tenantPrisma.moduleGrant.findFirst({
|
const existing = await tenantPrisma.moduleGrant.findFirst({ where: targetWhere });
|
||||||
where: {
|
|
||||||
tenantId,
|
|
||||||
moduleId,
|
|
||||||
groupId: groupId ?? null,
|
|
||||||
userId: userId ?? null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
this.logger.log(
|
return applyToExisting(existing);
|
||||||
`Grant bereits vorhanden (Doppelklick abgefangen): tenant=${tenantId} module=${moduleId} ${target}`,
|
|
||||||
);
|
|
||||||
return existing;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
@@ -202,7 +226,7 @@ export class ModuleGrantsService {
|
|||||||
}),
|
}),
|
||||||
tenantPrisma.moduleGrant.findMany({
|
tenantPrisma.moduleGrant.findMany({
|
||||||
where: { tenantId, groupId: { not: null } },
|
where: { tenantId, groupId: { not: null } },
|
||||||
select: { moduleId: true, groupId: true },
|
select: { moduleId: true, groupId: true, level: true },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -218,6 +242,7 @@ export class ModuleGrantsService {
|
|||||||
grants: groupGrants.map((g) => ({
|
grants: groupGrants.map((g) => ({
|
||||||
moduleId: g.moduleId,
|
moduleId: g.moduleId,
|
||||||
groupId: g.groupId,
|
groupId: g.groupId,
|
||||||
|
level: g.level ?? ModuleGrantLevel.USE,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -231,7 +256,8 @@ export class ModuleGrantsService {
|
|||||||
* dadurch sichtbar. `modules` beantwortet je aktivem Modul die andere
|
* dadurch sichtbar. `modules` beantwortet je aktivem Modul die andere
|
||||||
* Frage (welche Gruppe gewährt dieses Modul, und besteht zusätzlich ein
|
* Frage (welche Gruppe gewährt dieses Modul, und besteht zusätzlich ein
|
||||||
* Direkt-Grant) und behält dafür je Eintrag exakt die Form
|
* Direkt-Grant) und behält dafür je Eintrag exakt die Form
|
||||||
* { module, viaGroups, direct }.
|
* { module, viaGroups, direct }; seit 261002-icv kommen `directLevel` und
|
||||||
|
* `manageViaGroups` hinzu (Anzeige der Freigabestufe).
|
||||||
*
|
*
|
||||||
* Anzeigename mit Fallback (D-04, UI-SPEC Surface Contract 6): beide
|
* Anzeigename mit Fallback (D-04, UI-SPEC Surface Contract 6): beide
|
||||||
* Projektionsstellen (viaGroups-Namen, groups[].name) liefern
|
* Projektionsstellen (viaGroups-Namen, groups[].name) liefern
|
||||||
@@ -258,7 +284,7 @@ export class ModuleGrantsService {
|
|||||||
}),
|
}),
|
||||||
tenantPrisma.moduleGrant.findMany({
|
tenantPrisma.moduleGrant.findMany({
|
||||||
where: { tenantId, userId },
|
where: { tenantId, userId },
|
||||||
select: { moduleId: true },
|
select: { moduleId: true, level: true },
|
||||||
}),
|
}),
|
||||||
// Mandantengebunden seit 260909-jts (Aufgabe 3): der Kontext wird
|
// Mandantengebunden seit 260909-jts (Aufgabe 3): der Kontext wird
|
||||||
// über denselben tenantPrisma wie die drei Abfragen oben gesetzt —
|
// über denselben tenantPrisma wie die drei Abfragen oben gesetzt —
|
||||||
@@ -275,13 +301,23 @@ export class ModuleGrantsService {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const directModuleIds = new Set(directGrants.map((g) => g.moduleId));
|
const directLevelByModule = new Map<string, ModuleGrantLevel>();
|
||||||
|
for (const g of directGrants) {
|
||||||
|
directLevelByModule.set(g.moduleId, g.level ?? ModuleGrantLevel.USE);
|
||||||
|
}
|
||||||
const groupNamesByModule = new Map<string, string[]>();
|
const groupNamesByModule = new Map<string, string[]>();
|
||||||
|
const manageGroupNamesByModule = new Map<string, string[]>();
|
||||||
for (const g of groupGrants) {
|
for (const g of groupGrants) {
|
||||||
if (!g.group) continue;
|
if (!g.group) continue;
|
||||||
|
const displayName = g.group.internalName ?? g.group.name;
|
||||||
const names = groupNamesByModule.get(g.moduleId) ?? [];
|
const names = groupNamesByModule.get(g.moduleId) ?? [];
|
||||||
names.push(g.group.internalName ?? g.group.name);
|
names.push(displayName);
|
||||||
groupNamesByModule.set(g.moduleId, names);
|
groupNamesByModule.set(g.moduleId, names);
|
||||||
|
if (g.level === ModuleGrantLevel.MANAGE) {
|
||||||
|
const manageNames = manageGroupNamesByModule.get(g.moduleId) ?? [];
|
||||||
|
manageNames.push(displayName);
|
||||||
|
manageGroupNamesByModule.set(g.moduleId, manageNames);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const modules = activations
|
const modules = activations
|
||||||
@@ -304,7 +340,11 @@ export class ModuleGrantsService {
|
|||||||
modules: modules.map((module) => ({
|
modules: modules.map((module) => ({
|
||||||
module,
|
module,
|
||||||
viaGroups: groupNamesByModule.get(module.id) ?? [],
|
viaGroups: groupNamesByModule.get(module.id) ?? [],
|
||||||
direct: directModuleIds.has(module.id),
|
direct: directLevelByModule.has(module.id),
|
||||||
|
// 261002-icv: Stufe der Direkt-Freigabe (null ohne Direkt-Grant) und
|
||||||
|
// die Gruppen, die Verwalten gewähren (Teilmenge von viaGroups).
|
||||||
|
directLevel: directLevelByModule.get(module.id) ?? null,
|
||||||
|
manageViaGroups: manageGroupNamesByModule.get(module.id) ?? [],
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import { IsInt, IsString, Length, Matches, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
const trim = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value);
|
||||||
|
|
||||||
|
/** Anlegen und Aendern eines Kontos der Kontenliste (quick-261002-fm5). */
|
||||||
|
export class HandelswareAccountDto {
|
||||||
|
@Transform(trim)
|
||||||
|
@IsString({ message: 'Der Name muss angegeben werden' })
|
||||||
|
@Length(1, 120, { message: 'Der Name muss 1 bis 120 Zeichen lang sein' })
|
||||||
|
@Matches(/^[^\t\r\n]*$/, {
|
||||||
|
message: 'Der Name darf keine Tabulatoren oder Zeilenumbrüche enthalten',
|
||||||
|
})
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsInt({ message: 'Das Gegenkonto muss eine ganze Zahl sein' })
|
||||||
|
@Min(1, { message: 'Das Gegenkonto muss mindestens 1 sein' })
|
||||||
|
@Max(999999999, { message: 'Das Gegenkonto darf höchstens 999999999 sein' })
|
||||||
|
gegenkonto!: number;
|
||||||
|
|
||||||
|
@IsInt({ message: 'Das Erlöskonto muss eine ganze Zahl sein' })
|
||||||
|
@Min(1, { message: 'Das Erlöskonto muss mindestens 1 sein' })
|
||||||
|
@Max(999999999, { message: 'Das Erlöskonto darf höchstens 999999999 sein' })
|
||||||
|
erloeskonto!: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { IsInt, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einstellungen der Handelsware (quick-261002-fm5): Standard-Erloeskonto fuer
|
||||||
|
* neue Konten und Startwert fuer die Gegenkonto-Vergabe. Ganze Zahlen,
|
||||||
|
* bewusst ohne Standardwert — der Administrator hinterlegt sie einmalig.
|
||||||
|
*/
|
||||||
|
export class HandelswareSettingsDto {
|
||||||
|
@IsInt({ message: 'Das Standard-Erlöskonto muss eine ganze Zahl sein' })
|
||||||
|
@Min(1, { message: 'Das Standard-Erlöskonto muss mindestens 1 sein' })
|
||||||
|
@Max(999999999, { message: 'Das Standard-Erlöskonto darf höchstens 999999999 sein' })
|
||||||
|
erloeskonto!: number;
|
||||||
|
|
||||||
|
@IsInt({ message: 'Der Startwert Gegenkonto muss eine ganze Zahl sein' })
|
||||||
|
@Min(1, { message: 'Der Startwert Gegenkonto muss mindestens 1 sein' })
|
||||||
|
@Max(999999999, { message: 'Der Startwert Gegenkonto darf höchstens 999999999 sein' })
|
||||||
|
startGegenkonto!: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||||
|
import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY } from '../module-registry/module.guard';
|
||||||
|
import { HandelswareAccountDto } from './dto/handelsware-account.dto';
|
||||||
|
import { HandelswareSettingsDto } from './dto/handelsware-settings.dto';
|
||||||
|
import { HandelswareDatevController, parseNewAccountsField } from './handelsware-datev.controller';
|
||||||
|
|
||||||
|
const proto = HandelswareDatevController.prototype as any;
|
||||||
|
const req = (tenantId?: string) => ({ tenantId }) as any;
|
||||||
|
|
||||||
|
function makeService() {
|
||||||
|
return {
|
||||||
|
getSettings: vi.fn(async (..._a: unknown[]) => ({})),
|
||||||
|
saveSettings: vi.fn(async (..._a: unknown[]) => ({})),
|
||||||
|
preview: vi.fn(async (..._a: unknown[]) => ({})),
|
||||||
|
export: vi.fn(async (..._a: unknown[]) => ({})),
|
||||||
|
listAccounts: vi.fn(async (..._a: unknown[]) => []),
|
||||||
|
createAccount: vi.fn(async (..._a: unknown[]) => ({})),
|
||||||
|
updateAccount: vi.fn(async (..._a: unknown[]) => ({})),
|
||||||
|
deleteAccount: vi.fn(async (..._a: unknown[]) => ({})),
|
||||||
|
exportAccountsCsv: vi.fn(async (..._a: unknown[]) => ({})),
|
||||||
|
importAccountsCsv: vi.fn(async (..._a: unknown[]) => ({})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HandelswareDatevController — Metadaten', () => {
|
||||||
|
it('haengt an modules/handelsware-datev und traegt @UseModule', () => {
|
||||||
|
expect(Reflect.getMetadata('path', HandelswareDatevController)).toBe(
|
||||||
|
'modules/handelsware-datev',
|
||||||
|
);
|
||||||
|
expect(Reflect.getMetadata(MODULE_SLUG_KEY, HandelswareDatevController)).toBe(
|
||||||
|
'handelsware-datev',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT settings verlangt die Freigabestufe Verwalten, alles andere keine Routen-Rolle und kein Verwalten (261002-icv)', () => {
|
||||||
|
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto.saveSettings)).toBe(true);
|
||||||
|
expect(Reflect.getMetadata(MODULE_SLUG_KEY, proto.saveSettings)).toBe('handelsware-datev');
|
||||||
|
expect(Reflect.getMetadata(ROLES_KEY, proto.saveSettings)).toBeUndefined();
|
||||||
|
for (const name of [
|
||||||
|
'getSettings',
|
||||||
|
'preview',
|
||||||
|
'export',
|
||||||
|
'listAccounts',
|
||||||
|
'createAccount',
|
||||||
|
'exportAccountsCsv',
|
||||||
|
'importAccountsCsv',
|
||||||
|
'updateAccount',
|
||||||
|
'deleteAccount',
|
||||||
|
]) {
|
||||||
|
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined();
|
||||||
|
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Pfade und Methoden', () => {
|
||||||
|
const route = (name: string) => [
|
||||||
|
Reflect.getMetadata('method', proto[name]),
|
||||||
|
Reflect.getMetadata('path', proto[name]),
|
||||||
|
];
|
||||||
|
// RequestMethod: GET 0, POST 1, PUT 2, DELETE 3
|
||||||
|
expect(route('getSettings')).toEqual([0, 'settings']);
|
||||||
|
expect(route('saveSettings')).toEqual([2, 'settings']);
|
||||||
|
expect(route('preview')).toEqual([1, 'preview']);
|
||||||
|
expect(route('export')).toEqual([1, 'export']);
|
||||||
|
expect(route('listAccounts')).toEqual([0, 'accounts']);
|
||||||
|
expect(route('createAccount')).toEqual([1, 'accounts']);
|
||||||
|
expect(route('exportAccountsCsv')).toEqual([0, 'accounts/export-csv']);
|
||||||
|
expect(route('importAccountsCsv')).toEqual([1, 'accounts/import-csv']);
|
||||||
|
expect(route('updateAccount')).toEqual([2, 'accounts/:id']);
|
||||||
|
expect(route('deleteAccount')).toEqual([3, 'accounts/:id']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HandelswareDatevController — Routen-Reihenfolge (statisch vor :id)', () => {
|
||||||
|
it('deklariert alle statischen Konten-Routen vor accounts/:id', () => {
|
||||||
|
const methods = Object.getOwnPropertyNames(HandelswareDatevController.prototype);
|
||||||
|
const idx = (name: string) => {
|
||||||
|
const i = methods.indexOf(name);
|
||||||
|
expect(i, `${name} fehlt`).toBeGreaterThanOrEqual(0);
|
||||||
|
return i;
|
||||||
|
};
|
||||||
|
const firstIdRoute = Math.min(idx('updateAccount'), idx('deleteAccount'));
|
||||||
|
for (const staticRoute of [
|
||||||
|
'listAccounts',
|
||||||
|
'createAccount',
|
||||||
|
'exportAccountsCsv',
|
||||||
|
'importAccountsCsv',
|
||||||
|
]) {
|
||||||
|
expect(idx(staticRoute), `${staticRoute} muss vor :id stehen`).toBeLessThan(firstIdRoute);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HandelswareDatevController — Verhalten', () => {
|
||||||
|
it('reicht req.tenantId weiter und decodiert den Dateinamen', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
const c = new HandelswareDatevController(service as any);
|
||||||
|
const mojibake = Buffer.from('Käse 0326.xlsx', 'utf8').toString('latin1');
|
||||||
|
const buffer = Buffer.from('x');
|
||||||
|
await c.preview(req('t1'), { buffer, originalname: mojibake } as any);
|
||||||
|
expect(service.preview).toHaveBeenCalledWith('t1', { buffer, originalname: 'Käse 0326.xlsx' });
|
||||||
|
await c.export(
|
||||||
|
req('t1'),
|
||||||
|
{ buffer, originalname: 'a.xlsx' } as any,
|
||||||
|
' 3103 ',
|
||||||
|
'[{"name":"A","gegenkonto":5}]',
|
||||||
|
);
|
||||||
|
expect(service.export).toHaveBeenCalledWith('t1', { buffer, originalname: 'a.xlsx' }, '3103', [
|
||||||
|
{ name: 'A', gegenkonto: 5 },
|
||||||
|
]);
|
||||||
|
await c.listAccounts(req('t1'));
|
||||||
|
await c.deleteAccount(req('t1'), 'x');
|
||||||
|
expect(service.listAccounts).toHaveBeenCalledWith('t1');
|
||||||
|
expect(service.deleteAccount).toHaveBeenCalledWith('t1', 'x');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('antwortet ohne Datei mit 400', async () => {
|
||||||
|
const c = new HandelswareDatevController(makeService() as any);
|
||||||
|
await expect(c.preview(req('t1'), undefined)).rejects.toThrow(BadRequestException);
|
||||||
|
await expect(c.export(req('t1'), undefined)).rejects.toThrow(BadRequestException);
|
||||||
|
await expect(c.importAccountsCsv(req('t1'), undefined)).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('antwortet ohne Mandantenkontext mit 403', async () => {
|
||||||
|
const c = new HandelswareDatevController(makeService() as any);
|
||||||
|
await expect(c.listAccounts(req(undefined))).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseNewAccountsField', () => {
|
||||||
|
it('akzeptiert eine Liste und leere Werte', () => {
|
||||||
|
expect(parseNewAccountsField('[{"name":"A","gegenkonto":1,"erloeskonto":2}]')).toEqual([
|
||||||
|
{ name: 'A', gegenkonto: 1 },
|
||||||
|
]);
|
||||||
|
expect(parseNewAccountsField(undefined)).toEqual([]);
|
||||||
|
expect(parseNewAccountsField('[]')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'kein json',
|
||||||
|
'{"a":1}',
|
||||||
|
'[1]',
|
||||||
|
'[{"name":1,"gegenkonto":1}]',
|
||||||
|
'[{"name":"A","gegenkonto":"1"}]',
|
||||||
|
'[{"name":"A","gegenkonto":1.5}]',
|
||||||
|
'[null]',
|
||||||
|
])('lehnt %s ab', (raw) => {
|
||||||
|
expect(() => parseNewAccountsField(raw)).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt mehr als 10 000 Eintraege ab', () => {
|
||||||
|
const big = JSON.stringify(
|
||||||
|
Array.from({ length: 10_001 }, (_, i) => ({ name: `n${i}`, gegenkonto: i })),
|
||||||
|
);
|
||||||
|
expect(() => parseNewAccountsField(big)).toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DTOs', () => {
|
||||||
|
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||||
|
|
||||||
|
it('Einstellungen: nur ganze Zahlen 1 bis 999999999', async () => {
|
||||||
|
const run = (value: unknown) =>
|
||||||
|
pipe.transform(value, { type: 'body', metatype: HandelswareSettingsDto });
|
||||||
|
await expect(run({ erloeskonto: 5, startGegenkonto: 6 })).resolves.toBeDefined();
|
||||||
|
for (const bad of [
|
||||||
|
{ erloeskonto: 0, startGegenkonto: 6 },
|
||||||
|
{ erloeskonto: 5, startGegenkonto: 1000000000 },
|
||||||
|
{ erloeskonto: 1.5, startGegenkonto: 6 },
|
||||||
|
{ erloeskonto: '5', startGegenkonto: 6 },
|
||||||
|
{ erloeskonto: 5 },
|
||||||
|
]) {
|
||||||
|
await expect(run(bad)).rejects.toThrow(BadRequestException);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Konto: Name wird getrimmt, Tabulator im Namen und leerer Name werden abgelehnt', async () => {
|
||||||
|
const run = (value: unknown) =>
|
||||||
|
pipe.transform(value, { type: 'body', metatype: HandelswareAccountDto });
|
||||||
|
const ok: any = await run({ name: ' Kaffee ', gegenkonto: 1, erloeskonto: 2 });
|
||||||
|
expect(ok.name).toBe('Kaffee');
|
||||||
|
await expect(run({ name: 'a\tb', gegenkonto: 1, erloeskonto: 2 })).rejects.toThrow(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
await expect(run({ name: ' ', gegenkonto: 1, erloeskonto: 2 })).rejects.toThrow(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
await expect(run({ name: 'x'.repeat(121), gegenkonto: 1, erloeskonto: 2 })).rejects.toThrow(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Req,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { decodeUploadFilename } from '../accounting/decode-upload-filename';
|
||||||
|
import type { AuthenticatedRequest, UploadedFileLike } from '../auth/types/auth-user';
|
||||||
|
import { ModuleManage, UseModule } from '../module-registry/module.guard';
|
||||||
|
import { HandelswareAccountDto } from './dto/handelsware-account.dto';
|
||||||
|
import { HandelswareSettingsDto } from './dto/handelsware-settings.dto';
|
||||||
|
import { HandelswareDatevService } from './handelsware-datev.service';
|
||||||
|
|
||||||
|
const MAX_NEW_ACCOUNTS = 10_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Formularfeld `newAccounts` ist ein JSON-Text (Liste der von der Vorschau
|
||||||
|
* gemeldeten neuen Konten). Defensiv gelesen: gueltiges JSON, ein Feld, hoechstens
|
||||||
|
* 10 000 Eintraege, jeder mit Text-`name` und ganzzahligem `gegenkonto`.
|
||||||
|
*/
|
||||||
|
export function parseNewAccountsField(raw: unknown): { name: string; gegenkonto: number }[] {
|
||||||
|
const bad = () =>
|
||||||
|
new BadRequestException({
|
||||||
|
code: 'newAccountsInvalid',
|
||||||
|
message: 'Die Angaben zu den neuen Konten sind ungültig.',
|
||||||
|
});
|
||||||
|
if (raw === undefined || raw === null || raw === '') return [];
|
||||||
|
if (typeof raw !== 'string') throw bad();
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
throw bad();
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed) || parsed.length > MAX_NEW_ACCOUNTS) throw bad();
|
||||||
|
return parsed.map((entry) => {
|
||||||
|
if (
|
||||||
|
typeof entry !== 'object' ||
|
||||||
|
entry === null ||
|
||||||
|
typeof (entry as { name?: unknown }).name !== 'string' ||
|
||||||
|
!Number.isInteger((entry as { gegenkonto?: unknown }).gegenkonto)
|
||||||
|
) {
|
||||||
|
throw bad();
|
||||||
|
}
|
||||||
|
const { name, gegenkonto } = entry as { name: string; gegenkonto: number };
|
||||||
|
return { name, gegenkonto };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `@UseModule('handelsware-datev')` auf Klassenebene — Aktivierung UND Freigabe.
|
||||||
|
* `tenantId` kommt ausschliesslich aus `req.tenantId`. Die Einstellungen aendern
|
||||||
|
* Administratoren und Benutzer mit der Freigabestufe Verwalten
|
||||||
|
* (`@ModuleManage`, 261002-icv; T-FM5-02); die Kontenliste pflegen alle Benutzer mit
|
||||||
|
* Modulzugriff.
|
||||||
|
*
|
||||||
|
* REIHENFOLGE: alle statischen Routen (`accounts`, `accounts/export-csv`,
|
||||||
|
* `accounts/import-csv`) stehen VOR `accounts/:id` — sonst faengt `:id` sie ab
|
||||||
|
* (Unit-Tests sehen das nicht, `handelsware-datev.controller.spec.ts` prueft die
|
||||||
|
* Deklarationsreihenfolge).
|
||||||
|
*/
|
||||||
|
@Controller('modules/handelsware-datev')
|
||||||
|
@UseModule('handelsware-datev')
|
||||||
|
export class HandelswareDatevController {
|
||||||
|
constructor(private readonly service: HandelswareDatevService) {}
|
||||||
|
|
||||||
|
private requireTenantId(req: AuthenticatedRequest): string {
|
||||||
|
const tenantId = req.tenantId;
|
||||||
|
if (!tenantId) {
|
||||||
|
throw new ForbiddenException('Kein Mandantenkontext');
|
||||||
|
}
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('settings')
|
||||||
|
async getSettings(@Req() req: AuthenticatedRequest) {
|
||||||
|
return this.service.getSettings(this.requireTenantId(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('settings')
|
||||||
|
@ModuleManage('handelsware-datev')
|
||||||
|
async saveSettings(@Req() req: AuthenticatedRequest, @Body() dto: HandelswareSettingsDto) {
|
||||||
|
return this.service.saveSettings(this.requireTenantId(req), dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('preview')
|
||||||
|
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
|
||||||
|
async preview(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@UploadedFile() file: UploadedFileLike | undefined,
|
||||||
|
) {
|
||||||
|
const tenantId = this.requireTenantId(req);
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException('Keine Datei hochgeladen');
|
||||||
|
}
|
||||||
|
return this.service.preview(tenantId, {
|
||||||
|
buffer: file.buffer,
|
||||||
|
originalname: decodeUploadFilename(file.originalname),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('export')
|
||||||
|
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
|
||||||
|
async export(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@UploadedFile() file: UploadedFileLike | undefined,
|
||||||
|
@Body('buchungsdatum') buchungsdatum?: string,
|
||||||
|
@Body('newAccounts') newAccounts?: string,
|
||||||
|
) {
|
||||||
|
const tenantId = this.requireTenantId(req);
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException('Keine Datei hochgeladen');
|
||||||
|
}
|
||||||
|
return this.service.export(
|
||||||
|
tenantId,
|
||||||
|
{ buffer: file.buffer, originalname: decodeUploadFilename(file.originalname) },
|
||||||
|
typeof buchungsdatum === 'string' ? buchungsdatum.trim() : '',
|
||||||
|
parseNewAccountsField(newAccounts),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('accounts')
|
||||||
|
async listAccounts(@Req() req: AuthenticatedRequest) {
|
||||||
|
return this.service.listAccounts(this.requireTenantId(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('accounts')
|
||||||
|
async createAccount(@Req() req: AuthenticatedRequest, @Body() dto: HandelswareAccountDto) {
|
||||||
|
return this.service.createAccount(this.requireTenantId(req), dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('accounts/export-csv')
|
||||||
|
async exportAccountsCsv(@Req() req: AuthenticatedRequest) {
|
||||||
|
return this.service.exportAccountsCsv(this.requireTenantId(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('accounts/import-csv')
|
||||||
|
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 1024 * 1024 } }))
|
||||||
|
async importAccountsCsv(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@UploadedFile() file: UploadedFileLike | undefined,
|
||||||
|
) {
|
||||||
|
const tenantId = this.requireTenantId(req);
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException('Keine Datei hochgeladen');
|
||||||
|
}
|
||||||
|
return this.service.importAccountsCsv(tenantId, file.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('accounts/:id')
|
||||||
|
async updateAccount(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: HandelswareAccountDto,
|
||||||
|
) {
|
||||||
|
return this.service.updateAccount(this.requireTenantId(req), id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('accounts/:id')
|
||||||
|
async deleteAccount(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||||
|
return this.service.deleteAccount(this.requireTenantId(req), id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
|
||||||
|
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||||
|
import { HandelswareDatevController } from './handelsware-datev.controller';
|
||||||
|
import { seedHandelswareDatevModule } from './handelsware-datev.seed';
|
||||||
|
import { HandelswareDatevService } from './handelsware-datev.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handelsware (quick-261002-fm5): Excel-Umsaetze Erloeskonten zuordnen und als
|
||||||
|
* DATEV-Buchungsdatei exportieren. Traegt sich beim Start in die
|
||||||
|
* Modulverwaltung ein; aktiviert wird per Marktplatz.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [ModuleRegistryModule],
|
||||||
|
controllers: [HandelswareDatevController],
|
||||||
|
providers: [HandelswareDatevService],
|
||||||
|
})
|
||||||
|
export class HandelswareDatevModule implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(HandelswareDatevModule.name);
|
||||||
|
|
||||||
|
constructor(private readonly moduleRegistryService: ModuleRegistryService) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await seedHandelswareDatevModule(this.moduleRegistryService);
|
||||||
|
this.logger.log('Handelsware-DATEV module seeded in registry');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to seed handelsware-datev module', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traegt das Modul "Handelsware" in die Modulverwaltung ein (quick-261002-fm5).
|
||||||
|
* `isSystem: true` legt den Eintrag an, aktiviert ihn aber NICHT je Mandant —
|
||||||
|
* der Administrator aktiviert ueber den Marktplatz und erteilt die Freigabe.
|
||||||
|
*/
|
||||||
|
export async function seedHandelswareDatevModule(
|
||||||
|
moduleRegistryService: ModuleRegistryService,
|
||||||
|
): Promise<void> {
|
||||||
|
await moduleRegistryService.seedModule({
|
||||||
|
slug: 'handelsware-datev',
|
||||||
|
name: 'Handelsware',
|
||||||
|
version: '1.0.0',
|
||||||
|
category: 'accounting',
|
||||||
|
description: {
|
||||||
|
de: 'Handelswaren-Umsätze aus Excel den Erlöskonten zuordnen und als DATEV-Buchungsdatei exportieren',
|
||||||
|
en: 'Map merchandise sales from Excel to revenue accounts and export a DATEV booking file',
|
||||||
|
},
|
||||||
|
isSystem: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zwei Klienten wie in favorites.service.spec.ts: `forTenant` und
|
||||||
|
* `withTenantTransaction` werden auf den Nachbau umgeleitet. Die Transaktion
|
||||||
|
* arbeitet auf einer KOPIE des Bestands und uebernimmt sie nur, wenn die
|
||||||
|
* Funktion ohne Fehler endet — so ist Alles-oder-nichts pruefbar.
|
||||||
|
*/
|
||||||
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
|
forTenant: vi.fn((db: any, tenantId: string) => db.__bound(tenantId)),
|
||||||
|
withTenantTransaction: vi.fn((db: any, tenantId: string, fn: (tx: any) => any) =>
|
||||||
|
db.__transaction(tenantId, fn),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { HandelswareDatevService } from './handelsware-datev.service';
|
||||||
|
|
||||||
|
interface Konto {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
name: string;
|
||||||
|
gegenkonto: number;
|
||||||
|
erloeskonto: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueError() {
|
||||||
|
return Object.assign(new Error('Unique constraint failed'), { code: 'P2002' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDb(opts: {
|
||||||
|
config?: { erloeskonto: number | null; startGegenkonto: number | null } | null;
|
||||||
|
konten?: Konto[];
|
||||||
|
}) {
|
||||||
|
const state = {
|
||||||
|
config: opts.config === undefined ? { erloeskonto: 4711, startGegenkonto: 2000 } : opts.config,
|
||||||
|
konten: [...(opts.konten ?? [])],
|
||||||
|
writes: [] as string[],
|
||||||
|
seq: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
function client(tenantId: string, s: { konten: Konto[] }, record: (w: string) => void) {
|
||||||
|
const own = () => s.konten.filter((k) => k.tenantId === tenantId);
|
||||||
|
return {
|
||||||
|
handelswareDatevConfig: {
|
||||||
|
findUnique: vi.fn(async () => state.config),
|
||||||
|
upsert: vi.fn(async ({ create, update }: any) => {
|
||||||
|
record('config.upsert');
|
||||||
|
state.config = { ...(state.config ?? {}), ...update, ...create } as any;
|
||||||
|
return state.config;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
handelswareKonto: {
|
||||||
|
findMany: vi.fn(async () => [...own()].sort((a, b) => a.name.localeCompare(b.name))),
|
||||||
|
findFirst: vi.fn(async ({ where }: any) => own().find((k) => k.id === where.id) ?? null),
|
||||||
|
create: vi.fn(async ({ data }: any) => {
|
||||||
|
record('konto.create');
|
||||||
|
if (own().some((k) => k.name === data.name)) throw uniqueError();
|
||||||
|
const row = { id: `k${++state.seq}`, ...data };
|
||||||
|
s.konten.push(row);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
update: vi.fn(async ({ where, data }: any) => {
|
||||||
|
record('konto.update');
|
||||||
|
const row = s.konten.find((k) => k.id === where.id) as Konto;
|
||||||
|
if (data.name !== row.name && own().some((k) => k.name === data.name))
|
||||||
|
throw uniqueError();
|
||||||
|
Object.assign(row, data);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
delete: vi.fn(async ({ where }: any) => {
|
||||||
|
record('konto.delete');
|
||||||
|
s.konten.splice(
|
||||||
|
s.konten.findIndex((k) => k.id === where.id),
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
deleteMany: vi.fn(async () => {
|
||||||
|
record('konto.deleteMany');
|
||||||
|
const keep = s.konten.filter((k) => k.tenantId !== tenantId);
|
||||||
|
s.konten.length = 0;
|
||||||
|
s.konten.push(...keep);
|
||||||
|
}),
|
||||||
|
createMany: vi.fn(async ({ data }: any) => {
|
||||||
|
record('konto.createMany');
|
||||||
|
for (const d of data) {
|
||||||
|
if (own().some((k) => k.name === d.name)) throw uniqueError();
|
||||||
|
s.konten.push({ id: `k${++state.seq}`, ...d });
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const db: any = {
|
||||||
|
__state: state,
|
||||||
|
__bound: (tenantId: string) => client(tenantId, state, (w) => state.writes.push(w)),
|
||||||
|
__transaction: async (tenantId: string, fn: (tx: any) => any) => {
|
||||||
|
const copy = { konten: state.konten.map((k) => ({ ...k })) };
|
||||||
|
const txWrites: string[] = [];
|
||||||
|
const result = await fn(client(tenantId, copy, (w) => txWrites.push(w)));
|
||||||
|
state.konten = copy.konten;
|
||||||
|
state.writes.push(...txWrites.map((w) => `tx:${w}`));
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function workbook(aoa: unknown[][]): Buffer {
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), 'Blatt1');
|
||||||
|
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) as Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE = {
|
||||||
|
buffer: workbook([
|
||||||
|
['', '2026'],
|
||||||
|
['Kaffee', 12.5],
|
||||||
|
['Kakao', -3],
|
||||||
|
['Kakao', 1],
|
||||||
|
]),
|
||||||
|
originalname: 'HWA 0326 Test.xlsx',
|
||||||
|
};
|
||||||
|
|
||||||
|
const konto = (name: string, gegenkonto: number, erloeskonto = 4000): Konto => ({
|
||||||
|
id: `id-${name}`,
|
||||||
|
tenantId: 't1',
|
||||||
|
name,
|
||||||
|
gegenkonto,
|
||||||
|
erloeskonto,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HandelswareDatevService — Vorschau', () => {
|
||||||
|
it('sperrt mit settingsMissing, solange Erloeskonto oder Startwert fehlen', async () => {
|
||||||
|
for (const config of [
|
||||||
|
null,
|
||||||
|
{ erloeskonto: 1, startGegenkonto: null },
|
||||||
|
{ erloeskonto: null, startGegenkonto: 1 },
|
||||||
|
]) {
|
||||||
|
const service = new HandelswareDatevService(makeDb({ config }));
|
||||||
|
const err: any = await service.preview('t1', FILE).catch((e) => e);
|
||||||
|
expect(err).toBeInstanceOf(BadRequestException);
|
||||||
|
expect(err.getResponse().code).toBe('settingsMissing');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liefert Zeilen, neue Konten, Datumsvorschlag und Dateinamen — und schreibt nichts', async () => {
|
||||||
|
const db = makeDb({ konten: [konto('Kaffee', 2010)] });
|
||||||
|
const res = await new HandelswareDatevService(db).preview('t1', FILE);
|
||||||
|
expect(res.headerText).toBe('2026');
|
||||||
|
expect(res.suggestedBuchungsdatum).toBe('3103');
|
||||||
|
expect(res.exportFilename).toBe('HWA_0326.txt');
|
||||||
|
expect(res.rows.map((r) => [r.buchungstext, r.gegenkonto, r.isNew])).toEqual([
|
||||||
|
['Kaffee', 2010, false],
|
||||||
|
['Kakao', 2011, true],
|
||||||
|
['Kakao', 2011, true],
|
||||||
|
]);
|
||||||
|
expect(res.newAccounts).toEqual([{ name: 'Kakao', gegenkonto: 2011, erloeskonto: 4711 }]);
|
||||||
|
expect(db.__state.writes).toEqual([]);
|
||||||
|
expect(db.__state.konten).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet eine kaputte Datei als 400 invalidFile', async () => {
|
||||||
|
const service = new HandelswareDatevService(makeDb({}));
|
||||||
|
const err: any = await service
|
||||||
|
.preview('t1', { buffer: Buffer.from('xx'), originalname: 'a.xlsx' })
|
||||||
|
.catch((e) => e);
|
||||||
|
expect(err.getResponse().code).toBe('invalidFile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gibt Zeilenfehler zurueck statt zu werfen', async () => {
|
||||||
|
const buffer = workbook([
|
||||||
|
['', 'X'],
|
||||||
|
['Kaffee', 'viel'],
|
||||||
|
]);
|
||||||
|
const res = await new HandelswareDatevService(makeDb({})).preview('t1', {
|
||||||
|
buffer,
|
||||||
|
originalname: 'a.xlsx',
|
||||||
|
});
|
||||||
|
expect(res.rowErrors).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HandelswareDatevService — Export', () => {
|
||||||
|
const submitted = [{ name: 'Kakao', gegenkonto: 2011 }];
|
||||||
|
|
||||||
|
it('speichert die neuen Konten erst beim Export, in der Transaktion, und liefert die TXT', async () => {
|
||||||
|
const db = makeDb({ konten: [konto('Kaffee', 2010)] });
|
||||||
|
const res = await new HandelswareDatevService(db).export('t1', FILE, '3103', submitted);
|
||||||
|
expect(res.createdCount).toBe(1);
|
||||||
|
expect(res.filename).toBe('HWA_0326.txt');
|
||||||
|
expect(res.mimeType).toBe('text/plain;charset=utf-8');
|
||||||
|
expect(Buffer.from(res.content, 'base64').toString('utf8')).toBe(
|
||||||
|
'\t2026\t\t\t\t\r\nKaffee\t12.50\tS\t2010\t3103\t4000\r\nKakao\t3.00\tH\t2011\t3103\t4711\r\nKakao\t1.00\tS\t2011\t3103\t4711\r\n',
|
||||||
|
);
|
||||||
|
expect(db.__state.konten.map((k: Konto) => k.name).sort()).toEqual(['Kaffee', 'Kakao']);
|
||||||
|
expect(db.__state.writes).toEqual(['tx:konto.createMany']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('409 accountsChanged, wenn sich die Liste seit der Vorschau geaendert hat — nichts gespeichert', async () => {
|
||||||
|
// Inzwischen gibt es schon ein Konto mit Gegenkonto 2011 -> neues Konto waere 2012.
|
||||||
|
const db = makeDb({ konten: [konto('Kaffee', 2010), konto('Saft', 2011)] });
|
||||||
|
const err: any = await new HandelswareDatevService(db)
|
||||||
|
.export('t1', FILE, '3103', submitted)
|
||||||
|
.catch((e) => e);
|
||||||
|
expect(err).toBeInstanceOf(ConflictException);
|
||||||
|
expect(err.getResponse().code).toBe('accountsChanged');
|
||||||
|
expect(err.getResponse().message).toBe(
|
||||||
|
'Die Kontenliste wurde inzwischen geändert. Bitte laden Sie die Datei erneut, um die Vorschau zu aktualisieren.',
|
||||||
|
);
|
||||||
|
expect(db.__state.konten).toHaveLength(2);
|
||||||
|
expect(db.__state.writes).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('409, wenn der Client ein neues Konto verschweigt oder erfindet', async () => {
|
||||||
|
const db = makeDb({ konten: [konto('Kaffee', 2010)] });
|
||||||
|
const service = new HandelswareDatevService(db);
|
||||||
|
await expect(service.export('t1', FILE, '3103', [])).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
await expect(
|
||||||
|
service.export('t1', FILE, '3103', [...submitted, { name: 'Erfunden', gegenkonto: 9 }]),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
expect(db.__state.konten).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Wettlauf: Eindeutigkeit (P2002) beim Anlegen wird zu 409', async () => {
|
||||||
|
const db = makeDb({ konten: [konto('Kaffee', 2010)] });
|
||||||
|
const original = db.__transaction;
|
||||||
|
// Ein zweiter Export hat "Kakao" zwischen Berechnung und Speichern angelegt.
|
||||||
|
db.__transaction = (tenantId: string, fn: (tx: any) => any) =>
|
||||||
|
original(tenantId, (tx: any) => {
|
||||||
|
tx.handelswareKonto.createMany = async () => {
|
||||||
|
throw uniqueError();
|
||||||
|
};
|
||||||
|
return fn(tx);
|
||||||
|
});
|
||||||
|
const err: any = await new HandelswareDatevService(db)
|
||||||
|
.export('t1', FILE, '3103', submitted)
|
||||||
|
.catch((e) => e);
|
||||||
|
expect(err).toBeInstanceOf(ConflictException);
|
||||||
|
expect(err.getResponse().code).toBe('accountsChanged');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400 bei ungueltigem Buchungsdatum', async () => {
|
||||||
|
const err: any = await new HandelswareDatevService(makeDb({}))
|
||||||
|
.export('t1', FILE, '3102', submitted)
|
||||||
|
.catch((e) => e);
|
||||||
|
expect(err.getResponse().code).toBe('buchungsdatumInvalid');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400 bei Zeilenfehlern', async () => {
|
||||||
|
const buffer = workbook([
|
||||||
|
['', 'X'],
|
||||||
|
['Kaffee', 'viel'],
|
||||||
|
]);
|
||||||
|
const err: any = await new HandelswareDatevService(makeDb({}))
|
||||||
|
.export('t1', { buffer, originalname: 'a 0326.xlsx' }, '3103', [])
|
||||||
|
.catch((e) => e);
|
||||||
|
expect(err).toBeInstanceOf(BadRequestException);
|
||||||
|
expect(err.getResponse().code).toBe('rowErrors');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400 settingsMissing beim Export ohne Einstellungen', async () => {
|
||||||
|
const err: any = await new HandelswareDatevService(makeDb({ config: null }))
|
||||||
|
.export('t1', FILE, '3103', submitted)
|
||||||
|
.catch((e) => e);
|
||||||
|
expect(err.getResponse().code).toBe('settingsMissing');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HandelswareDatevService — Kontenliste', () => {
|
||||||
|
it('legt an, sortiert nach Name und meldet doppelte Namen als 409 nameTaken', async () => {
|
||||||
|
const db = makeDb({});
|
||||||
|
const service = new HandelswareDatevService(db);
|
||||||
|
await service.createAccount('t1', { name: 'Tee', gegenkonto: 2, erloeskonto: 3 });
|
||||||
|
await service.createAccount('t1', { name: 'Kaffee', gegenkonto: 4, erloeskonto: 5 });
|
||||||
|
expect((await service.listAccounts('t1')).map((a) => a.name)).toEqual(['Kaffee', 'Tee']);
|
||||||
|
const err: any = await service
|
||||||
|
.createAccount('t1', { name: 'Tee', gegenkonto: 9, erloeskonto: 9 })
|
||||||
|
.catch((e) => e);
|
||||||
|
expect(err).toBeInstanceOf(ConflictException);
|
||||||
|
expect(err.getResponse().code).toBe('nameTaken');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aendert ein Konto; Namensklau ist 409; unbekannte id ist 404', async () => {
|
||||||
|
const db = makeDb({ konten: [konto('A', 1), konto('B', 2)] });
|
||||||
|
const service = new HandelswareDatevService(db);
|
||||||
|
const updated = await service.updateAccount('t1', 'id-A', {
|
||||||
|
name: 'A2',
|
||||||
|
gegenkonto: 7,
|
||||||
|
erloeskonto: 8,
|
||||||
|
});
|
||||||
|
expect(updated).toMatchObject({ name: 'A2', gegenkonto: 7 });
|
||||||
|
await expect(
|
||||||
|
service.updateAccount('t1', 'id-A', { name: 'B', gegenkonto: 1, erloeskonto: 1 }),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
await expect(
|
||||||
|
service.updateAccount('t1', 'nope', { name: 'X', gegenkonto: 1, erloeskonto: 1 }),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loescht ein Konto; unbekannte id ist 404', async () => {
|
||||||
|
const db = makeDb({ konten: [konto('A', 1)] });
|
||||||
|
const service = new HandelswareDatevService(db);
|
||||||
|
await expect(service.deleteAccount('t1', 'nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
await expect(service.deleteAccount('t1', 'id-A')).resolves.toEqual({ deleted: true });
|
||||||
|
expect(db.__state.konten).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CSV-Import ersetzt die Liste in EINER Transaktion (deleteMany + createMany)', async () => {
|
||||||
|
const db = makeDb({ konten: [konto('Alt', 1)] });
|
||||||
|
const res = await new HandelswareDatevService(db).importAccountsCsv(
|
||||||
|
't1',
|
||||||
|
Buffer.from('Name;Gegenkonto;Konto\nNeu1;10;20\nNeu2;11'),
|
||||||
|
);
|
||||||
|
expect(res).toEqual({ count: 2 });
|
||||||
|
expect(db.__state.konten.map((k: Konto) => k.name)).toEqual(['Neu1', 'Neu2']);
|
||||||
|
expect(db.__state.konten[1].erloeskonto).toBe(4711);
|
||||||
|
expect(db.__state.writes).toEqual(['tx:konto.deleteMany', 'tx:konto.createMany']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CSV-Import mit einer ungueltigen Zeile aendert nichts', async () => {
|
||||||
|
const db = makeDb({ konten: [konto('Alt', 1)] });
|
||||||
|
const err: any = await new HandelswareDatevService(db)
|
||||||
|
.importAccountsCsv('t1', Buffer.from('Neu1;10;20\nNeu2;abc;20'))
|
||||||
|
.catch((e) => e);
|
||||||
|
expect(err).toBeInstanceOf(BadRequestException);
|
||||||
|
expect(err.getResponse().code).toBe('csvErrors');
|
||||||
|
expect(err.getResponse().errors).toHaveLength(1);
|
||||||
|
expect(db.__state.writes).toEqual([]);
|
||||||
|
expect(db.__state.konten.map((k: Konto) => k.name)).toEqual(['Alt']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CSV-Import: scheitert das Schreiben mittendrin, bleibt die alte Liste', async () => {
|
||||||
|
const db = makeDb({ konten: [konto('Alt', 1)] });
|
||||||
|
const original = db.__transaction;
|
||||||
|
db.__transaction = (tenantId: string, fn: (tx: any) => any) =>
|
||||||
|
original(tenantId, (tx: any) => {
|
||||||
|
tx.handelswareKonto.createMany = async () => {
|
||||||
|
throw new Error('Datenbank weg');
|
||||||
|
};
|
||||||
|
return fn(tx);
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
new HandelswareDatevService(db).importAccountsCsv('t1', Buffer.from('Neu;1;2')),
|
||||||
|
).rejects.toThrow('Datenbank weg');
|
||||||
|
expect(db.__state.konten.map((k: Konto) => k.name)).toEqual(['Alt']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CSV-Export liefert BOM-CSV als Base64', async () => {
|
||||||
|
const db = makeDb({ konten: [konto('Käse', 1, 2)] });
|
||||||
|
const res = await new HandelswareDatevService(db).exportAccountsCsv('t1');
|
||||||
|
expect(res.filename).toBe('Konten.csv');
|
||||||
|
expect(Buffer.from(res.content, 'base64').toString('utf8')).toBe('Käse;1;2\r\n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HandelswareDatevService — Einstellungen', () => {
|
||||||
|
it('configured nur, wenn beide Zahlen gesetzt sind', async () => {
|
||||||
|
expect(await new HandelswareDatevService(makeDb({ config: null })).getSettings('t1')).toEqual({
|
||||||
|
erloeskonto: null,
|
||||||
|
startGegenkonto: null,
|
||||||
|
configured: false,
|
||||||
|
});
|
||||||
|
expect((await new HandelswareDatevService(makeDb({})).getSettings('t1')).configured).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('speichert per upsert', async () => {
|
||||||
|
const db = makeDb({ config: null });
|
||||||
|
const res = await new HandelswareDatevService(db).saveSettings('t1', {
|
||||||
|
erloeskonto: 5,
|
||||||
|
startGegenkonto: 6,
|
||||||
|
});
|
||||||
|
expect(res).toEqual({ erloeskonto: 5, startGegenkonto: 6, configured: true });
|
||||||
|
expect(db.__state.writes).toEqual(['config.upsert']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
||||||
|
import type { HandelswareAccountDto } from './dto/handelsware-account.dto';
|
||||||
|
import type { HandelswareSettingsDto } from './dto/handelsware-settings.dto';
|
||||||
|
import type {
|
||||||
|
AccountEntry,
|
||||||
|
FileResponse,
|
||||||
|
HandelswareSettings,
|
||||||
|
HandelswareSettingsReady,
|
||||||
|
NewAccount,
|
||||||
|
PreviewResult,
|
||||||
|
} from './handelsware-datev.types';
|
||||||
|
import { generateKontenCsv, parseKontenCsv } from './handelsware-konten-csv';
|
||||||
|
import {
|
||||||
|
assignAccounts,
|
||||||
|
calculateBuchungsdatum,
|
||||||
|
generateTxt,
|
||||||
|
getExportFilename,
|
||||||
|
isValidBuchungsdatum,
|
||||||
|
} from './handelsware-transform';
|
||||||
|
import { HandelswareFileError, parseHandelswareXlsx } from './handelsware-xlsx';
|
||||||
|
|
||||||
|
export interface UploadedWorkbook {
|
||||||
|
buffer: Buffer;
|
||||||
|
/** Bereits als UTF-8 dekodierter Dateiname. */
|
||||||
|
originalname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HandelswareSettingsResponse extends HandelswareSettings {
|
||||||
|
configured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountResponse extends AccountEntry {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ACCOUNTS_IMPORT = 10_000;
|
||||||
|
|
||||||
|
const SETTINGS_MISSING = {
|
||||||
|
code: 'settingsMissing',
|
||||||
|
message: 'Standard-Erlöskonto und Startwert Gegenkonto sind noch nicht hinterlegt.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACCOUNTS_CHANGED = {
|
||||||
|
code: 'accountsChanged',
|
||||||
|
message:
|
||||||
|
'Die Kontenliste wurde inzwischen geändert. Bitte laden Sie die Datei erneut, um die Vorschau zu aktualisieren.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const NAME_TAKEN = {
|
||||||
|
code: 'nameTaken',
|
||||||
|
message: 'Ein Konto mit diesem Namen gibt es bereits.',
|
||||||
|
};
|
||||||
|
|
||||||
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'P2002'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isReady(settings: HandelswareSettings | null): settings is HandelswareSettingsReady {
|
||||||
|
return Boolean(settings && settings.erloeskonto !== null && settings.startGegenkonto !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gleichheit der berechneten und der von der Vorschau gemeldeten neuen Konten (Name + Gegenkonto). */
|
||||||
|
function sameNewAccounts(
|
||||||
|
computed: NewAccount[],
|
||||||
|
submitted: { name: string; gegenkonto: number }[],
|
||||||
|
): boolean {
|
||||||
|
if (computed.length !== submitted.length) return false;
|
||||||
|
const byName = new Map(submitted.map((a) => [a.name, a.gegenkonto]));
|
||||||
|
if (byName.size !== submitted.length) return false;
|
||||||
|
return computed.every((a) => byName.get(a.name) === a.gegenkonto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handelsware (quick-261002-fm5): Excel-Umsaetze den Erloeskonten zuordnen,
|
||||||
|
* Kontenliste je Mandant pflegen, TXT fuer DATEV erzeugen. Alle
|
||||||
|
* Datenbankzugriffe mandantengebunden (`forTenant` bzw. eine gemeinsame
|
||||||
|
* `withTenantTransaction`); neue Konten werden NUR beim Export gespeichert,
|
||||||
|
* die Vorschau schreibt nie.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class HandelswareDatevService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
// --- Einstellungen -------------------------------------------------------
|
||||||
|
|
||||||
|
async getSettings(tenantId: string): Promise<HandelswareSettingsResponse> {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
const row = await tenantPrisma.handelswareDatevConfig.findUnique({ where: { tenantId } });
|
||||||
|
const settings: HandelswareSettings = {
|
||||||
|
erloeskonto: row?.erloeskonto ?? null,
|
||||||
|
startGegenkonto: row?.startGegenkonto ?? null,
|
||||||
|
};
|
||||||
|
return { ...settings, configured: isReady(settings) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSettings(
|
||||||
|
tenantId: string,
|
||||||
|
dto: HandelswareSettingsDto,
|
||||||
|
): Promise<HandelswareSettingsResponse> {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
const data = { erloeskonto: dto.erloeskonto, startGegenkonto: dto.startGegenkonto };
|
||||||
|
await tenantPrisma.handelswareDatevConfig.upsert({
|
||||||
|
where: { tenantId },
|
||||||
|
create: { tenantId, ...data },
|
||||||
|
update: data,
|
||||||
|
});
|
||||||
|
return { ...data, configured: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Kontenliste ---------------------------------------------------------
|
||||||
|
|
||||||
|
async listAccounts(tenantId: string): Promise<AccountResponse[]> {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
const rows = await tenantPrisma.handelswareKonto.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
return rows.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
gegenkonto: r.gegenkonto,
|
||||||
|
erloeskonto: r.erloeskonto,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAccount(tenantId: string, dto: HandelswareAccountDto): Promise<AccountResponse> {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
try {
|
||||||
|
const row = await tenantPrisma.handelswareKonto.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
name: dto.name,
|
||||||
|
gegenkonto: dto.gegenkonto,
|
||||||
|
erloeskonto: dto.erloeskonto,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
gegenkonto: row.gegenkonto,
|
||||||
|
erloeskonto: row.erloeskonto,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) throw new ConflictException(NAME_TAKEN);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateAccount(
|
||||||
|
tenantId: string,
|
||||||
|
id: string,
|
||||||
|
dto: HandelswareAccountDto,
|
||||||
|
): Promise<AccountResponse> {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
const existing = await tenantPrisma.handelswareKonto.findFirst({ where: { id, tenantId } });
|
||||||
|
if (!existing) throw new NotFoundException('Konto nicht gefunden');
|
||||||
|
try {
|
||||||
|
const row = await tenantPrisma.handelswareKonto.update({
|
||||||
|
where: { id },
|
||||||
|
data: { name: dto.name, gegenkonto: dto.gegenkonto, erloeskonto: dto.erloeskonto },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
gegenkonto: row.gegenkonto,
|
||||||
|
erloeskonto: row.erloeskonto,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) throw new ConflictException(NAME_TAKEN);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteAccount(tenantId: string, id: string): Promise<{ deleted: true }> {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
const existing = await tenantPrisma.handelswareKonto.findFirst({ where: { id, tenantId } });
|
||||||
|
if (!existing) throw new NotFoundException('Konto nicht gefunden');
|
||||||
|
await tenantPrisma.handelswareKonto.delete({ where: { id } });
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportAccountsCsv(tenantId: string): Promise<FileResponse> {
|
||||||
|
const accounts = await this.listAccounts(tenantId);
|
||||||
|
return {
|
||||||
|
filename: 'Konten.csv',
|
||||||
|
content: Buffer.from(generateKontenCsv(accounts), 'utf8').toString('base64'),
|
||||||
|
mimeType: 'text/csv;charset=utf-8',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ersetzt die gesamte Kontenliste durch den CSV-Inhalt — alles oder nichts. */
|
||||||
|
async importAccountsCsv(tenantId: string, buffer: Buffer): Promise<{ count: number }> {
|
||||||
|
const settings = await this.getSettings(tenantId);
|
||||||
|
const { accounts, errors } = parseKontenCsv(buffer, settings.erloeskonto);
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'csvErrors',
|
||||||
|
message: 'Die CSV-Datei enthält Fehler. Es wurde nichts geändert.',
|
||||||
|
errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (accounts.length > MAX_ACCOUNTS_IMPORT) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'tooManyRows',
|
||||||
|
message: `Die Datei enthält mehr als ${MAX_ACCOUNTS_IMPORT} Konten.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await withTenantTransaction(this.prisma, tenantId, async (tx) => {
|
||||||
|
await tx.handelswareKonto.deleteMany({ where: { tenantId } });
|
||||||
|
if (accounts.length > 0) {
|
||||||
|
await tx.handelswareKonto.createMany({
|
||||||
|
data: accounts.map((a) => ({ tenantId, ...a })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { count: accounts.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Import / Export -----------------------------------------------------
|
||||||
|
|
||||||
|
private parseWorkbook(buffer: Buffer) {
|
||||||
|
try {
|
||||||
|
return parseHandelswareXlsx(buffer);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HandelswareFileError) {
|
||||||
|
throw new BadRequestException({ code: error.code, message: error.message });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorschau: liest die Datei, ordnet Konten zu — schreibt NICHTS in die Datenbank. */
|
||||||
|
async preview(tenantId: string, file: UploadedWorkbook): Promise<PreviewResult> {
|
||||||
|
const settings = await this.getSettings(tenantId);
|
||||||
|
if (!isReady(settings)) throw new BadRequestException(SETTINGS_MISSING);
|
||||||
|
|
||||||
|
const { headerText, rows: importRows, rowErrors } = this.parseWorkbook(file.buffer);
|
||||||
|
const accounts = await this.listAccounts(tenantId);
|
||||||
|
const { rows, newAccounts } = assignAccounts(importRows, accounts, settings);
|
||||||
|
|
||||||
|
return {
|
||||||
|
headerText,
|
||||||
|
suggestedBuchungsdatum: calculateBuchungsdatum(file.originalname),
|
||||||
|
exportFilename: getExportFilename(file.originalname),
|
||||||
|
rows,
|
||||||
|
newAccounts,
|
||||||
|
rowErrors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export: berechnet die Zuordnung INNERHALB einer mandantengebundenen
|
||||||
|
* Transaktion neu und vergleicht mit den neuen Konten, die die Vorschau
|
||||||
|
* gemeldet hat (409 `accountsChanged`, wenn die Liste sich inzwischen
|
||||||
|
* geaendert hat). Nur dann werden die neuen Konten gespeichert — in derselben
|
||||||
|
* Transaktion, in der die Datei erzeugt wird.
|
||||||
|
*/
|
||||||
|
async export(
|
||||||
|
tenantId: string,
|
||||||
|
file: UploadedWorkbook,
|
||||||
|
buchungsdatum: string,
|
||||||
|
submittedNewAccounts: { name: string; gegenkonto: number }[],
|
||||||
|
): Promise<FileResponse & { createdCount: number }> {
|
||||||
|
if (!isValidBuchungsdatum(buchungsdatum)) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'buchungsdatumInvalid',
|
||||||
|
message: 'Das Buchungsdatum muss als TTMM angegeben werden, zum Beispiel 3103.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { headerText, rows: importRows, rowErrors } = this.parseWorkbook(file.buffer);
|
||||||
|
if (rowErrors.length > 0) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'rowErrors',
|
||||||
|
message: 'Die Datei enthält fehlerhafte Zeilen und kann nicht exportiert werden.',
|
||||||
|
errors: rowErrors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (importRows.length === 0) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: 'noRows',
|
||||||
|
message: 'Die Datei enthält keine Datenzeilen.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await withTenantTransaction(this.prisma, tenantId, async (tx) => {
|
||||||
|
const config = await tx.handelswareDatevConfig.findUnique({ where: { tenantId } });
|
||||||
|
const settings: HandelswareSettings = {
|
||||||
|
erloeskonto: config?.erloeskonto ?? null,
|
||||||
|
startGegenkonto: config?.startGegenkonto ?? null,
|
||||||
|
};
|
||||||
|
if (!isReady(settings)) throw new BadRequestException(SETTINGS_MISSING);
|
||||||
|
|
||||||
|
const stored: AccountEntry[] = await tx.handelswareKonto.findMany({
|
||||||
|
where: { tenantId },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
const { rows, newAccounts } = assignAccounts(importRows, stored, settings);
|
||||||
|
|
||||||
|
if (!sameNewAccounts(newAccounts, submittedNewAccounts)) {
|
||||||
|
throw new ConflictException(ACCOUNTS_CHANGED);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newAccounts.length > 0) {
|
||||||
|
await tx.handelswareKonto.createMany({
|
||||||
|
data: newAccounts.map((a) => ({
|
||||||
|
tenantId,
|
||||||
|
name: a.name,
|
||||||
|
gegenkonto: a.gegenkonto,
|
||||||
|
erloeskonto: a.erloeskonto,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const txt = generateTxt(headerText, rows, buchungsdatum);
|
||||||
|
return {
|
||||||
|
filename: getExportFilename(file.originalname),
|
||||||
|
content: Buffer.from(txt, 'utf8').toString('base64'),
|
||||||
|
mimeType: 'text/plain;charset=utf-8',
|
||||||
|
createdCount: newAccounts.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Zwei Exporte gleichzeitig: die Eindeutigkeit (Mandant, Name) faengt den Wettlauf.
|
||||||
|
if (isUniqueViolation(error)) throw new ConflictException(ACCOUNTS_CHANGED);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Typen des Moduls Handelsware (quick-261002-fm5): Excel-Umsaetze den
|
||||||
|
* Erloeskonten zuordnen und als DATEV-Buchungsdatei (TXT) exportieren.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Eine Zeile aus der hochgeladenen Excel-Datei. */
|
||||||
|
export interface ImportRow {
|
||||||
|
/** Zeile in der Excel-Datei (1-basiert) */
|
||||||
|
line: number;
|
||||||
|
buchungstext: string;
|
||||||
|
umsatz: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RowErrorCode = 'umsatzInvalid';
|
||||||
|
|
||||||
|
export interface RowError {
|
||||||
|
line: number;
|
||||||
|
code: RowErrorCode;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorschauzeile (ohne Buchungsdatum — das tragen Vorschau und Export einmal fuer alle). */
|
||||||
|
export interface PreviewRow {
|
||||||
|
line: number;
|
||||||
|
buchungstext: string;
|
||||||
|
/** Betrag als Text, Punkt, genau 2 Nachkommastellen, ohne Vorzeichen */
|
||||||
|
umsatz: string;
|
||||||
|
sollHaben: 'S' | 'H';
|
||||||
|
gegenkonto: number;
|
||||||
|
erloeskonto: number;
|
||||||
|
/** true, wenn das Konto fuer dieses Produkt neu vergeben wurde */
|
||||||
|
isNew: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountEntry {
|
||||||
|
name: string;
|
||||||
|
gegenkonto: number;
|
||||||
|
erloeskonto: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NewAccount = AccountEntry;
|
||||||
|
|
||||||
|
/** Einstellungen des Mandanten, wie in der Datenbank (leer = noch nicht hinterlegt). */
|
||||||
|
export interface HandelswareSettings {
|
||||||
|
erloeskonto: number | null;
|
||||||
|
startGegenkonto: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vollstaendige Einstellungen — Voraussetzung fuer jede Verarbeitung. */
|
||||||
|
export interface HandelswareSettingsReady {
|
||||||
|
erloeskonto: number;
|
||||||
|
startGegenkonto: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileResponse {
|
||||||
|
filename: string;
|
||||||
|
/** Base64 */
|
||||||
|
content: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewResult {
|
||||||
|
headerText: string;
|
||||||
|
suggestedBuchungsdatum: string;
|
||||||
|
exportFilename: string;
|
||||||
|
rows: PreviewRow[];
|
||||||
|
newAccounts: NewAccount[];
|
||||||
|
rowErrors: RowError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KontenCsvErrorCode =
|
||||||
|
| 'nameEmpty'
|
||||||
|
| 'nameTooLong'
|
||||||
|
| 'gegenkontoInvalid'
|
||||||
|
| 'erloeskontoInvalid'
|
||||||
|
| 'missingErloeskonto'
|
||||||
|
| 'duplicateName';
|
||||||
|
|
||||||
|
export interface KontenCsvError {
|
||||||
|
line: number;
|
||||||
|
code: KontenCsvErrorCode;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { generateKontenCsv, parseKontenCsv } from './handelsware-konten-csv';
|
||||||
|
|
||||||
|
const csv = (text: string, enc: BufferEncoding = 'utf8') => Buffer.from(text, enc);
|
||||||
|
|
||||||
|
describe('parseKontenCsv', () => {
|
||||||
|
it('liest CRLF und LF gleich', () => {
|
||||||
|
const a = parseKontenCsv(csv('Kaffee;2010;4000\r\nTee;2011;4001\r\n'), 4711);
|
||||||
|
const b = parseKontenCsv(csv('Kaffee;2010;4000\nTee;2011;4001'), 4711);
|
||||||
|
expect(a.accounts).toEqual(b.accounts);
|
||||||
|
expect(a.accounts).toHaveLength(2);
|
||||||
|
expect(a.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dekodiert Windows-1252 und UTF-8 mit BOM', () => {
|
||||||
|
expect(parseKontenCsv(csv('Käse;2010;4000', 'latin1'), null).accounts[0].name).toBe('Käse');
|
||||||
|
const bom = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), csv('Käse;2010;4000')]);
|
||||||
|
expect(parseKontenCsv(bom, null).accounts[0].name).toBe('Käse');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ueberspringt eine Kopfzeile, wenn die zweite Spalte keine Zahl ist', () => {
|
||||||
|
const r = parseKontenCsv(csv('Name;Gegenkonto;Konto\nKaffee;2010;4000'), null);
|
||||||
|
expect(r.accounts).toEqual([{ name: 'Kaffee', gegenkonto: 2010, erloeskonto: 4000 }]);
|
||||||
|
expect(r.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt fuer eine fehlende dritte Spalte das Standard-Erloeskonto', () => {
|
||||||
|
const r = parseKontenCsv(csv('Kaffee;2010'), 4711);
|
||||||
|
expect(r.accounts[0].erloeskonto).toBe(4711);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet missingErloeskonto, wenn das Standard-Erloeskonto leer ist', () => {
|
||||||
|
const r = parseKontenCsv(csv('Kaffee;2010'), null);
|
||||||
|
expect(r.errors).toEqual([expect.objectContaining({ line: 1, code: 'missingErloeskonto' })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet ungueltige Zahlen und leere Namen mit Zeilennummer', () => {
|
||||||
|
const r = parseKontenCsv(csv('ok;1;2\n;5;6\nx;abc;6\ny;5;-1\nz;0;6'), null);
|
||||||
|
expect(r.errors.map((e) => [e.line, e.code])).toEqual([
|
||||||
|
[2, 'nameEmpty'],
|
||||||
|
[3, 'gegenkontoInvalid'],
|
||||||
|
[4, 'erloeskontoInvalid'],
|
||||||
|
[5, 'gegenkontoInvalid'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet doppelte Namen', () => {
|
||||||
|
const r = parseKontenCsv(csv('Kaffee;1;2\nKaffee;3;4'), null);
|
||||||
|
expect(r.errors).toEqual([expect.objectContaining({ line: 2, code: 'duplicateName' })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet zu lange Namen', () => {
|
||||||
|
const r = parseKontenCsv(csv(`${'x'.repeat(121)};1;2`), null);
|
||||||
|
expect(r.errors[0].code).toBe('nameTooLong');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erlaubt Semikolons im Namen', () => {
|
||||||
|
const r = parseKontenCsv(csv('Tee; gruen;2010;4000'), null);
|
||||||
|
expect(r.accounts[0]).toEqual({ name: 'Tee; gruen', gegenkonto: 2010, erloeskonto: 4000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateKontenCsv', () => {
|
||||||
|
it('beginnt mit BOM, nutzt Semikolon und CRLF', () => {
|
||||||
|
const out = generateKontenCsv([
|
||||||
|
{ name: 'Käse', gegenkonto: 2010, erloeskonto: 4000 },
|
||||||
|
{ name: 'Tee', gegenkonto: 2011, erloeskonto: 4001 },
|
||||||
|
]);
|
||||||
|
expect(out.startsWith('')).toBe(true);
|
||||||
|
expect(out.slice(1)).toBe('Käse;2010;4000\r\nTee;2011;4001\r\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'=SUMME(A1)',
|
||||||
|
'+1',
|
||||||
|
'-5 % Aktion',
|
||||||
|
'@cmd',
|
||||||
|
])('schuetzt %j mit einem Apostroph', (name) => {
|
||||||
|
const out = generateKontenCsv([{ name, gegenkonto: 1, erloeskonto: 2 }]);
|
||||||
|
expect(out.slice(1).startsWith(`'${name};`)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Export und Import ergeben dieselbe Liste (Rundlauf)', () => {
|
||||||
|
const accounts = [
|
||||||
|
{ name: '=1+1', gegenkonto: 2010, erloeskonto: 4000 },
|
||||||
|
{ name: '-5 % Aktion', gegenkonto: 2011, erloeskonto: 4000 },
|
||||||
|
{ name: 'Käse', gegenkonto: 2012, erloeskonto: 4001 },
|
||||||
|
];
|
||||||
|
const back = parseKontenCsv(Buffer.from(generateKontenCsv(accounts), 'utf8'), null);
|
||||||
|
expect(back.errors).toEqual([]);
|
||||||
|
expect(back.accounts).toEqual(accounts);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leere Liste ergibt nur das BOM', () => {
|
||||||
|
expect(generateKontenCsv([])).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { decodeCsvText } from '../accounting/decode-csv-text';
|
||||||
|
import type { AccountEntry, KontenCsvError } from './handelsware-datev.types';
|
||||||
|
|
||||||
|
export const MAX_NAME_LENGTH = 120;
|
||||||
|
const MAX_ACCOUNT_NUMBER = 999_999_999;
|
||||||
|
/** Zeichen, mit denen Excel einen Zelltext als Formel liest. */
|
||||||
|
const FORMULA_TRIGGERS = ['=', '+', '-', '@'];
|
||||||
|
|
||||||
|
function parseAccountNumber(value: string): number | null {
|
||||||
|
if (!/^\d{1,9}$/.test(value)) return null;
|
||||||
|
const num = Number.parseInt(value, 10);
|
||||||
|
return num >= 1 && num <= MAX_ACCOUNT_NUMBER ? num : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entfernt den Schutz-Apostroph, den `generateKontenCsv` vor Formelzeichen setzt. */
|
||||||
|
function stripFormulaGuard(name: string): string {
|
||||||
|
if (name.length > 1 && name[0] === "'" && FORMULA_TRIGGERS.includes(name[1])) {
|
||||||
|
return name.slice(1);
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest eine Konten-CSV (Semikolon): Name;Gegenkonto;Konto. UTF-8 oder
|
||||||
|
* Windows-1252, CRLF oder LF. Eine Kopfzeile (zweite Spalte keine Zahl) wird
|
||||||
|
* uebersprungen. Fehlt die dritte Spalte, gilt das Standard-Erloeskonto. Der
|
||||||
|
* Name steht vor den letzten beiden Semikolons, darf also selbst Semikolons
|
||||||
|
* enthalten. Es wird alles geprueft; bei Fehlern ist `accounts` unbrauchbar.
|
||||||
|
*/
|
||||||
|
export function parseKontenCsv(
|
||||||
|
buffer: Buffer,
|
||||||
|
defaultErloeskonto: number | null,
|
||||||
|
): { accounts: AccountEntry[]; errors: KontenCsvError[] } {
|
||||||
|
const accounts: AccountEntry[] = [];
|
||||||
|
const errors: KontenCsvError[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
const lines = decodeCsvText(buffer).split(/\r?\n/);
|
||||||
|
let firstContentLine = true;
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const raw = lines[i];
|
||||||
|
if (raw.trim() === '') continue;
|
||||||
|
const line = i + 1;
|
||||||
|
const parts = raw.split(';');
|
||||||
|
|
||||||
|
let name: string;
|
||||||
|
let gegenText: string;
|
||||||
|
let kontoText: string;
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
kontoText = parts[parts.length - 1].trim();
|
||||||
|
gegenText = parts[parts.length - 2].trim();
|
||||||
|
name = parts.slice(0, -2).join(';').trim();
|
||||||
|
} else {
|
||||||
|
name = (parts[0] ?? '').trim();
|
||||||
|
gegenText = (parts[1] ?? '').trim();
|
||||||
|
kontoText = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kopfzeile: nur als allererste Inhaltszeile, wenn die zweite Spalte keine Zahl ist.
|
||||||
|
if (firstContentLine) {
|
||||||
|
firstContentLine = false;
|
||||||
|
if (!/^\d+$/.test(gegenText)) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
name = stripFormulaGuard(name);
|
||||||
|
|
||||||
|
if (name === '') {
|
||||||
|
errors.push({ line, code: 'nameEmpty', message: 'Der Name fehlt.' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (name.length > MAX_NAME_LENGTH) {
|
||||||
|
errors.push({
|
||||||
|
line,
|
||||||
|
code: 'nameTooLong',
|
||||||
|
message: `Der Name ist länger als ${MAX_NAME_LENGTH} Zeichen.`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gegenkonto = parseAccountNumber(gegenText);
|
||||||
|
if (gegenkonto === null) {
|
||||||
|
errors.push({
|
||||||
|
line,
|
||||||
|
code: 'gegenkontoInvalid',
|
||||||
|
message: 'Das Gegenkonto muss eine ganze Zahl von 1 bis 999999999 sein.',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let erloeskonto: number | null;
|
||||||
|
if (kontoText === '') {
|
||||||
|
if (defaultErloeskonto === null) {
|
||||||
|
errors.push({
|
||||||
|
line,
|
||||||
|
code: 'missingErloeskonto',
|
||||||
|
message: 'Das Erlöskonto fehlt und es ist kein Standard-Erlöskonto hinterlegt.',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
erloeskonto = defaultErloeskonto;
|
||||||
|
} else {
|
||||||
|
erloeskonto = parseAccountNumber(kontoText);
|
||||||
|
if (erloeskonto === null) {
|
||||||
|
errors.push({
|
||||||
|
line,
|
||||||
|
code: 'erloeskontoInvalid',
|
||||||
|
message: 'Das Erlöskonto muss eine ganze Zahl von 1 bis 999999999 sein.',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seen.has(name)) {
|
||||||
|
errors.push({
|
||||||
|
line,
|
||||||
|
code: 'duplicateName',
|
||||||
|
message: 'Der Name kommt in der Datei mehrfach vor.',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(name);
|
||||||
|
accounts.push({ name, gegenkonto, erloeskonto });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { accounts, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Konten-CSV fuer Excel: UTF-8 mit BOM (damit Umlaute stimmen), Semikolon,
|
||||||
|
* CRLF. Namen, die mit Formelzeichen beginnen, bekommen einen Apostroph
|
||||||
|
* davor (Schutz vor Formeleinschleusung, T-FM5-07); `parseKontenCsv` nimmt ihn
|
||||||
|
* wieder weg.
|
||||||
|
*/
|
||||||
|
export function generateKontenCsv(accounts: AccountEntry[]): string {
|
||||||
|
const lines = accounts.map((a) => {
|
||||||
|
const name = FORMULA_TRIGGERS.includes(a.name[0] ?? '') ? `'${a.name}` : a.name;
|
||||||
|
return `${name};${a.gegenkonto};${a.erloeskonto}`;
|
||||||
|
});
|
||||||
|
return `${lines.join('\r\n')}${lines.length > 0 ? '\r\n' : ''}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { ImportRow } from './handelsware-datev.types';
|
||||||
|
import {
|
||||||
|
assignAccounts,
|
||||||
|
calculateBuchungsdatum,
|
||||||
|
formatAmount,
|
||||||
|
generateTxt,
|
||||||
|
getExportFilename,
|
||||||
|
isValidBuchungsdatum,
|
||||||
|
} from './handelsware-transform';
|
||||||
|
|
||||||
|
// Neutrale Testwerte, keine Zahlen aus einem echten Kontenrahmen.
|
||||||
|
const SETTINGS = { erloeskonto: 4711, startGegenkonto: 2000 };
|
||||||
|
|
||||||
|
const row = (buchungstext: string, umsatz: number, line = 2): ImportRow => ({
|
||||||
|
line,
|
||||||
|
buchungstext,
|
||||||
|
umsatz,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('calculateBuchungsdatum', () => {
|
||||||
|
it.each([
|
||||||
|
['HWA 0326 Test.xlsx', '3103'],
|
||||||
|
['HWA 0226.xlsx', '2802'],
|
||||||
|
['x 0228.xlsx', '2902'],
|
||||||
|
['HWA 0426.xlsx', '3004'],
|
||||||
|
['HWA 0026.xlsx', ''],
|
||||||
|
['HWA 1326.xlsx', ''],
|
||||||
|
['HWA.xlsx', ''],
|
||||||
|
['HWA 12.xlsx', ''],
|
||||||
|
])('%s -> %j', (name, expected) => {
|
||||||
|
expect(calculateBuchungsdatum(name)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isValidBuchungsdatum', () => {
|
||||||
|
it.each(['3103', '0101', '2902', '3012'])('akzeptiert %s', (v) => {
|
||||||
|
expect(isValidBuchungsdatum(v)).toBe(true);
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
'3102',
|
||||||
|
'0013',
|
||||||
|
'0000',
|
||||||
|
'3204',
|
||||||
|
'abc',
|
||||||
|
'310',
|
||||||
|
'31033',
|
||||||
|
'3104',
|
||||||
|
'',
|
||||||
|
])('lehnt %j ab', (v) => {
|
||||||
|
expect(isValidBuchungsdatum(v)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatAmount', () => {
|
||||||
|
it('Soll fuer positive Werte und Null, Haben fuer negative', () => {
|
||||||
|
expect(formatAmount(12.5)).toEqual({ formatted: '12.50', sollHaben: 'S' });
|
||||||
|
expect(formatAmount(0)).toEqual({ formatted: '0.00', sollHaben: 'S' });
|
||||||
|
expect(formatAmount(-3.456)).toEqual({ formatted: '3.46', sollHaben: 'H' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assignAccounts', () => {
|
||||||
|
const accounts = [
|
||||||
|
{ name: 'Kaffee', gegenkonto: 2010, erloeskonto: 4000 },
|
||||||
|
{ name: 'Tee', gegenkonto: 2005, erloeskonto: 4001 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('bekannter Name bekommt sein Gegenkonto und Erloeskonto, isNew false', () => {
|
||||||
|
const r = assignAccounts([row('Kaffee', 5)], accounts, SETTINGS);
|
||||||
|
expect(r.rows[0]).toMatchObject({ gegenkonto: 2010, erloeskonto: 4000, isNew: false });
|
||||||
|
expect(r.newAccounts).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unbekannte Namen: hoechstes Gegenkonto + 1, dann + 2, mit Standard-Erloeskonto', () => {
|
||||||
|
const r = assignAccounts([row('Kakao', 1), row('Saft', 2)], accounts, SETTINGS);
|
||||||
|
expect(r.newAccounts).toEqual([
|
||||||
|
{ name: 'Kakao', gegenkonto: 2011, erloeskonto: 4711 },
|
||||||
|
{ name: 'Saft', gegenkonto: 2012, erloeskonto: 4711 },
|
||||||
|
]);
|
||||||
|
expect(r.rows.map((x) => x.isNew)).toEqual([true, true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leere Liste: erstes neues Konto ist genau der Startwert, dann + 1', () => {
|
||||||
|
const r = assignAccounts([row('A', 1), row('B', 1)], [], SETTINGS);
|
||||||
|
expect(r.newAccounts.map((a) => a.gegenkonto)).toEqual([2000, 2001]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derselbe unbekannte Name zweimal: ein neues Konto, beide Zeilen als neu', () => {
|
||||||
|
const r = assignAccounts(
|
||||||
|
[row('Kakao', 1, 2), row('Saft', 1, 3), row('Kakao', 2, 4)],
|
||||||
|
accounts,
|
||||||
|
SETTINGS,
|
||||||
|
);
|
||||||
|
expect(r.newAccounts.map((a) => a.name)).toEqual(['Kakao', 'Saft']);
|
||||||
|
expect(r.rows[2]).toMatchObject({ gegenkonto: 2011, isNew: true, line: 4 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('vergleicht Namen genau (Gross-/Kleinschreibung zaehlt)', () => {
|
||||||
|
const r = assignAccounts([row('kaffee', 1)], accounts, SETTINGS);
|
||||||
|
expect(r.rows[0].isNew).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formatiert Betrag und Soll/Haben je Zeile', () => {
|
||||||
|
const r = assignAccounts([row('Kaffee', -2.5)], accounts, SETTINGS);
|
||||||
|
expect(r.rows[0]).toMatchObject({ umsatz: '2.50', sollHaben: 'H' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateTxt', () => {
|
||||||
|
const rows = assignAccounts([row('Müller Käse', 12.5), row('Tee', -3)], [], SETTINGS).rows;
|
||||||
|
const txt = generateTxt('2026', rows, '3103');
|
||||||
|
|
||||||
|
it('Kopfzeile: TAB Kopftext und vier weitere Tabs', () => {
|
||||||
|
expect(txt.split('\r\n')[0]).toBe('\t2026\t\t\t\t');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Datenzeilen: Text, Umsatz, S/H, Gegenkonto, TTMM, Erloeskonto', () => {
|
||||||
|
const lines = txt.split('\r\n');
|
||||||
|
expect(lines[1]).toBe('Müller Käse\t12.50\tS\t2000\t3103\t4711');
|
||||||
|
expect(lines[2]).toBe('Tee\t3.00\tH\t2001\t3103\t4711');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('endet mit CRLF und enthaelt kein einzelnes LF', () => {
|
||||||
|
expect(txt.endsWith('\r\n')).toBe(true);
|
||||||
|
expect(txt.replace(/\r\n/g, '')).not.toContain('\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('behaelt Umlaute bei UTF-8 bei', () => {
|
||||||
|
expect(Buffer.from(txt, 'utf8').toString('utf8')).toContain('Müller Käse');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getExportFilename', () => {
|
||||||
|
it.each([
|
||||||
|
['HWA 0326 Test.xlsx', 'HWA_0326.txt'],
|
||||||
|
['HWA0326.xlsx', 'HWA_0326.txt'],
|
||||||
|
['Liste.xlsx', 'Handelsware_Export.txt'],
|
||||||
|
])('%s -> %s', (name, expected) => {
|
||||||
|
expect(getExportFilename(name)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import type {
|
||||||
|
AccountEntry,
|
||||||
|
HandelswareSettingsReady,
|
||||||
|
ImportRow,
|
||||||
|
NewAccount,
|
||||||
|
PreviewRow,
|
||||||
|
} from './handelsware-datev.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Buchungsdatum (TTMM) aus dem Dateinamen: die erste vierstellige Ziffernfolge
|
||||||
|
* ist MMYY, ergibt den letzten Tag dieses Monats.
|
||||||
|
* "HWA 0326 Test.xlsx" -> "3103". Ohne Treffer oder mit Monat ausserhalb 1-12: "".
|
||||||
|
*/
|
||||||
|
export function calculateBuchungsdatum(filename: string): string {
|
||||||
|
const match = filename.match(/(\d{2})(\d{2})/);
|
||||||
|
if (!match) return '';
|
||||||
|
const month = Number.parseInt(match[1], 10);
|
||||||
|
if (month < 1 || month > 12) return '';
|
||||||
|
const year = 2000 + Number.parseInt(match[2], 10);
|
||||||
|
const lastDay = new Date(year, month, 0).getDate();
|
||||||
|
return `${String(lastDay).padStart(2, '0')}${String(month).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAYS_PER_MONTH = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||||
|
|
||||||
|
/** TTMM: vier Ziffern, Monat 1-12, Tag passend zum Monat (Februar bis 29). */
|
||||||
|
export function isValidBuchungsdatum(ttmm: string): boolean {
|
||||||
|
if (!/^\d{4}$/.test(ttmm)) return false;
|
||||||
|
const day = Number.parseInt(ttmm.slice(0, 2), 10);
|
||||||
|
const month = Number.parseInt(ttmm.slice(2, 4), 10);
|
||||||
|
if (month < 1 || month > 12) return false;
|
||||||
|
return day >= 1 && day <= DAYS_PER_MONTH[month - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Betrag ohne Vorzeichen, Punkt, genau 2 Nachkommastellen; Soll fuer >= 0, Haben fuer < 0. */
|
||||||
|
export function formatAmount(value: number): { formatted: string; sollHaben: 'S' | 'H' } {
|
||||||
|
const sollHaben = value < 0 ? 'H' : 'S';
|
||||||
|
return { formatted: Math.abs(value).toFixed(2), sollHaben };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ordnet jeder Zeile ihr Konto zu. Bekannte Produkte (genauer, gross-/
|
||||||
|
* kleinschreibungsabhaengiger Name) bekommen ihr Gegenkonto und Erloeskonto;
|
||||||
|
* unbekannte bekommen das naechste freie Gegenkonto (hoechstes vorhandenes + 1,
|
||||||
|
* bei leerer Liste genau der Startwert aus den Einstellungen) und das
|
||||||
|
* Standard-Erloeskonto. Dasselbe unbekannte Produkt mehrfach in einer Datei
|
||||||
|
* bekommt EIN neues Konto. `newAccounts` steht in der Reihenfolge des ersten
|
||||||
|
* Auftretens.
|
||||||
|
*/
|
||||||
|
export function assignAccounts(
|
||||||
|
importRows: ImportRow[],
|
||||||
|
accounts: AccountEntry[],
|
||||||
|
settings: HandelswareSettingsReady,
|
||||||
|
): { rows: PreviewRow[]; newAccounts: NewAccount[] } {
|
||||||
|
const known = new Map<string, AccountEntry>();
|
||||||
|
for (const account of accounts) known.set(account.name, account);
|
||||||
|
|
||||||
|
const created = new Map<string, NewAccount>();
|
||||||
|
const newAccounts: NewAccount[] = [];
|
||||||
|
let next =
|
||||||
|
accounts.length > 0
|
||||||
|
? Math.max(...accounts.map((a) => a.gegenkonto)) + 1
|
||||||
|
: settings.startGegenkonto;
|
||||||
|
|
||||||
|
const rows: PreviewRow[] = [];
|
||||||
|
for (const row of importRows) {
|
||||||
|
const { formatted, sollHaben } = formatAmount(row.umsatz);
|
||||||
|
|
||||||
|
let account = known.get(row.buchungstext);
|
||||||
|
let isNew = false;
|
||||||
|
if (!account) {
|
||||||
|
isNew = true;
|
||||||
|
account = created.get(row.buchungstext);
|
||||||
|
if (!account) {
|
||||||
|
account = { name: row.buchungstext, gegenkonto: next++, erloeskonto: settings.erloeskonto };
|
||||||
|
created.set(row.buchungstext, account);
|
||||||
|
newAccounts.push(account);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
line: row.line,
|
||||||
|
buchungstext: row.buchungstext,
|
||||||
|
umsatz: formatted,
|
||||||
|
sollHaben,
|
||||||
|
gegenkonto: account.gegenkonto,
|
||||||
|
erloeskonto: account.erloeskonto,
|
||||||
|
isNew,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rows, newAccounts };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TXT-Datei fuer DATEV: Kopfzeile TAB Kopftext + 4 Tabs, dann je Zeile
|
||||||
|
* Text, Umsatz, S/H, Gegenkonto, Datum (TTMM), Erloeskonto — tabgetrennt, CRLF,
|
||||||
|
* die Datei endet mit CRLF. UTF-8 (offene Frage: DATEV erwartet oft ANSI).
|
||||||
|
*/
|
||||||
|
export function generateTxt(headerText: string, rows: PreviewRow[], buchungsdatum: string): string {
|
||||||
|
const lines: string[] = [`\t${headerText}\t\t\t\t`];
|
||||||
|
for (const row of rows) {
|
||||||
|
lines.push(
|
||||||
|
`${row.buchungstext}\t${row.umsatz}\t${row.sollHaben}\t${row.gegenkonto}\t${buchungsdatum}\t${row.erloeskonto}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return `${lines.join('\r\n')}\r\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dateiname des Exports: "HWA 0326 Test.xlsx" -> "HWA_0326.txt", sonst "Handelsware_Export.txt". */
|
||||||
|
export function getExportFilename(importFilename: string): string {
|
||||||
|
const match = importFilename.match(/(\w+)\s*(\d{4})/);
|
||||||
|
if (match) return `${match[1]}_${match[2]}.txt`;
|
||||||
|
return 'Handelsware_Export.txt';
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import {
|
||||||
|
HandelswareFileError,
|
||||||
|
MAX_DATA_ROWS,
|
||||||
|
parseHandelswareXlsx,
|
||||||
|
parseUmsatz,
|
||||||
|
} from './handelsware-xlsx';
|
||||||
|
|
||||||
|
/** Baut eine Arbeitsmappe aus einer Matrix (Zeile 1 = Kopf). */
|
||||||
|
function workbook(aoa: unknown[][]): Buffer {
|
||||||
|
const wb = XLSX.utils.book_new();
|
||||||
|
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), 'Blatt1');
|
||||||
|
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) as Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseUmsatz', () => {
|
||||||
|
it('uebernimmt Zahlen unveraendert', () => {
|
||||||
|
expect(parseUmsatz(12.5)).toBe(12.5);
|
||||||
|
expect(parseUmsatz(-3)).toBe(-3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liest deutsche Texte', () => {
|
||||||
|
expect(parseUmsatz('1.234,56')).toBe(1234.56);
|
||||||
|
expect(parseUmsatz('-12,5')).toBe(-12.5);
|
||||||
|
expect(parseUmsatz(' 7,00 ')).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liest Text mit Punkt als Dezimalzeichen', () => {
|
||||||
|
expect(parseUmsatz('12.5')).toBe(12.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['', 'abc', '1,2,3', '12,5x', '--1', null, undefined, true, NaN])('lehnt %j ab', (v) => {
|
||||||
|
expect(parseUmsatz(v)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseHandelswareXlsx', () => {
|
||||||
|
it('liest Kopftext aus B1 (Text oder Zahl) und die Zeilen ab Zeile 2', () => {
|
||||||
|
const textHeader = parseHandelswareXlsx(
|
||||||
|
workbook([
|
||||||
|
['', 'Marz'],
|
||||||
|
['Kaffee', 12.5],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(textHeader.headerText).toBe('Marz');
|
||||||
|
const numberHeader = parseHandelswareXlsx(
|
||||||
|
workbook([
|
||||||
|
['', 2025],
|
||||||
|
['Kaffee', 1],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(numberHeader.headerText).toBe('2025');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liest Zahlen und deutsche Texte als Umsatz und merkt sich die Zeilennummer', () => {
|
||||||
|
const r = parseHandelswareXlsx(
|
||||||
|
workbook([
|
||||||
|
['', 'X'],
|
||||||
|
['Kaffee', 12.5],
|
||||||
|
['Tee', '1.234,56'],
|
||||||
|
['Kakao', '-12,5'],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(r.rows).toEqual([
|
||||||
|
{ line: 2, buchungstext: 'Kaffee', umsatz: 12.5 },
|
||||||
|
{ line: 3, buchungstext: 'Tee', umsatz: 1234.56 },
|
||||||
|
{ line: 4, buchungstext: 'Kakao', umsatz: -12.5 },
|
||||||
|
]);
|
||||||
|
expect(r.rowErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('endet an der ersten Zeile, in der A und B leer sind', () => {
|
||||||
|
const r = parseHandelswareXlsx(workbook([['', 'X'], ['Kaffee', 1], [], ['Tee', 2]]));
|
||||||
|
expect(r.rows.map((x) => x.buchungstext)).toEqual(['Kaffee']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet nicht numerischen oder leeren Umsatz als Zeilenfehler', () => {
|
||||||
|
const r = parseHandelswareXlsx(
|
||||||
|
workbook([
|
||||||
|
['', 'X'],
|
||||||
|
['Kaffee', 'viel'],
|
||||||
|
['Tee', null],
|
||||||
|
['Kakao', 3],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(r.rowErrors).toEqual([
|
||||||
|
expect.objectContaining({ line: 2, code: 'umsatzInvalid' }),
|
||||||
|
expect.objectContaining({ line: 3, code: 'umsatzInvalid' }),
|
||||||
|
]);
|
||||||
|
expect(r.rows).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ueberspringt eine Zeile ohne Buchungstext, aber mit Wert (wie die Vorlage)', () => {
|
||||||
|
const r = parseHandelswareXlsx(
|
||||||
|
workbook([
|
||||||
|
['', 'X'],
|
||||||
|
['', 5],
|
||||||
|
['Kaffee', 1],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(r.rows.map((x) => x.buchungstext)).toEqual(['Kaffee']);
|
||||||
|
expect(r.rowErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ersetzt Tabulatoren und Zeilenumbrueche im Text durch Leerzeichen', () => {
|
||||||
|
const r = parseHandelswareXlsx(
|
||||||
|
workbook([
|
||||||
|
['', 'Kopf\tText'],
|
||||||
|
['Kaf\tfee\nneu', 1],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(r.headerText).toBe('Kopf Text');
|
||||||
|
expect(r.rows[0].buchungstext).toBe('Kaf fee neu');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wirft invalidFile bei Muelldaten', () => {
|
||||||
|
expect(() => parseHandelswareXlsx(Buffer.from('das ist keine Excel-Datei;1;2'))).toThrow(
|
||||||
|
HandelswareFileError,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
parseHandelswareXlsx(Buffer.from([1, 2, 3, 4, 5, 6]));
|
||||||
|
expect.unreachable();
|
||||||
|
} catch (e) {
|
||||||
|
expect((e as HandelswareFileError).code).toBe('invalidFile');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wirft invalidFile bei kaputtem ZIP', () => {
|
||||||
|
const broken = Buffer.concat([Buffer.from([0x50, 0x4b, 0x03, 0x04]), Buffer.from('kaputt')]);
|
||||||
|
expect(() => parseHandelswareXlsx(broken)).toThrow(HandelswareFileError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wirft tooManyRows ab mehr als 10 000 Datenzeilen, nicht davor', () => {
|
||||||
|
const header = ['', 'X'];
|
||||||
|
const make = (n: number) =>
|
||||||
|
workbook([header, ...Array.from({ length: n }, (_, i) => [`P${i}`, 1])]);
|
||||||
|
expect(parseHandelswareXlsx(make(MAX_DATA_ROWS)).rows).toHaveLength(MAX_DATA_ROWS);
|
||||||
|
try {
|
||||||
|
parseHandelswareXlsx(make(MAX_DATA_ROWS + 1));
|
||||||
|
expect.unreachable();
|
||||||
|
} catch (e) {
|
||||||
|
expect((e as HandelswareFileError).code).toBe('tooManyRows');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import type { ImportRow, RowError } from './handelsware-datev.types';
|
||||||
|
|
||||||
|
/** Obergrenze der Datenzeilen je Datei (T-FM5-04). */
|
||||||
|
export const MAX_DATA_ROWS = 10_000;
|
||||||
|
|
||||||
|
export type HandelswareFileErrorCode = 'invalidFile' | 'tooManyRows';
|
||||||
|
|
||||||
|
export class HandelswareFileError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: HandelswareFileErrorCode,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'HandelswareFileError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tabulator und Zeilenumbrueche wuerden die Spalten der TXT-Datei zerreissen. */
|
||||||
|
export function sanitizeText(value: string): string {
|
||||||
|
return value.replace(/[\t\r\n]+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wandelt einen Umsatzwert in eine Zahl: Zahl unveraendert, Text im deutschen
|
||||||
|
* Format ("1.234,56", "-12,5") oder mit Punkt ("12.5"). `null` bei allem, was
|
||||||
|
* keine Zahl ist.
|
||||||
|
*/
|
||||||
|
export function parseUmsatz(value: unknown): number | null {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
let text = value.replace(/\s/g, '');
|
||||||
|
if (text === '') return null;
|
||||||
|
if (text.includes(',')) {
|
||||||
|
// Deutsches Format: Punkte sind Tausendertrenner, das Komma ist das Dezimalzeichen.
|
||||||
|
text = text.replace(/\./g, '').replace(',', '.');
|
||||||
|
}
|
||||||
|
if (!/^-?\d+(\.\d+)?$/.test(text)) return null;
|
||||||
|
const num = Number(text);
|
||||||
|
return Number.isFinite(num) ? num : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Signatur einer xlsx-Datei (ZIP) oder einer alten xls-Datei (OLE2). */
|
||||||
|
function looksLikeWorkbook(buffer: Buffer): boolean {
|
||||||
|
if (buffer.length < 4) return false;
|
||||||
|
const zip = buffer[0] === 0x50 && buffer[1] === 0x4b;
|
||||||
|
const ole = buffer[0] === 0xd0 && buffer[1] === 0xcf && buffer[2] === 0x11 && buffer[3] === 0xe0;
|
||||||
|
return zip || ole;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellText(cell: XLSX.CellObject | undefined): string {
|
||||||
|
if (!cell || cell.v === undefined || cell.v === null) return '';
|
||||||
|
return String(cell.v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die Handelsware-Excel-Datei: Zelle B1 = Kopftext, ab Zeile 2 Spalte A =
|
||||||
|
* Buchungstext und Spalte B = Umsatz, bis A und B beide leer sind. Nur das erste
|
||||||
|
* Blatt, keine Formeln/HTML/Formatvorlagen (T-FM5-05), hoechstens
|
||||||
|
* `MAX_DATA_ROWS` Datenzeilen (T-FM5-04).
|
||||||
|
*
|
||||||
|
* Abweichung von der Vorlage: ein Umsatz, der keine Zahl ist, wurde dort still
|
||||||
|
* als 0 gebucht — hier wird er ein Zeilenfehler.
|
||||||
|
*/
|
||||||
|
export function parseHandelswareXlsx(buffer: Buffer): {
|
||||||
|
headerText: string;
|
||||||
|
rows: ImportRow[];
|
||||||
|
rowErrors: RowError[];
|
||||||
|
} {
|
||||||
|
if (!looksLikeWorkbook(buffer)) {
|
||||||
|
throw new HandelswareFileError('invalidFile', 'Die Datei ist keine gültige Excel-Datei.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let sheet: XLSX.WorkSheet | undefined;
|
||||||
|
try {
|
||||||
|
const workbook = XLSX.read(buffer, {
|
||||||
|
type: 'buffer',
|
||||||
|
cellFormula: false,
|
||||||
|
cellHTML: false,
|
||||||
|
cellStyles: false,
|
||||||
|
// Zeile 1 (Kopf) + MAX_DATA_ROWS Datenzeilen + 1 Zeile, um "zu viele" zu erkennen.
|
||||||
|
sheetRows: MAX_DATA_ROWS + 2,
|
||||||
|
});
|
||||||
|
sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||||
|
} catch {
|
||||||
|
throw new HandelswareFileError(
|
||||||
|
'invalidFile',
|
||||||
|
'Die Datei konnte nicht als Excel-Datei gelesen werden.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!sheet) {
|
||||||
|
throw new HandelswareFileError('invalidFile', 'Die Excel-Datei enthält kein Tabellenblatt.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerText = sanitizeText(cellText(sheet.B1));
|
||||||
|
|
||||||
|
const rows: ImportRow[] = [];
|
||||||
|
const rowErrors: RowError[] = [];
|
||||||
|
for (let line = 2; ; line++) {
|
||||||
|
const textA = sanitizeText(cellText(sheet[`A${line}`]));
|
||||||
|
const rawB = sheet[`B${line}`]?.v;
|
||||||
|
const emptyB = rawB === undefined || rawB === null || String(rawB).trim() === '';
|
||||||
|
if (textA === '' && emptyB) break;
|
||||||
|
|
||||||
|
if (line - 1 > MAX_DATA_ROWS) {
|
||||||
|
throw new HandelswareFileError(
|
||||||
|
'tooManyRows',
|
||||||
|
`Die Datei enthält mehr als ${MAX_DATA_ROWS} Datenzeilen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zeile ohne Buchungstext, aber mit Wert: uebersprungen (Verhalten der Vorlage).
|
||||||
|
if (textA === '') continue;
|
||||||
|
|
||||||
|
const umsatz = parseUmsatz(rawB);
|
||||||
|
if (umsatz === null) {
|
||||||
|
rowErrors.push({
|
||||||
|
line,
|
||||||
|
code: 'umsatzInvalid',
|
||||||
|
message: 'Der Umsatz ist keine gültige Zahl.',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
rows.push({ line, buchungstext: textA, umsatz });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { headerText, rows, rowErrors };
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { IsString, Matches } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einstellungen der Kantinenabrechnung (quick-261002-fm5): drei Nummern, die
|
||||||
|
* der Administrator einmalig je Mandant hinterlegt. Nur Ziffern (1 bis 10),
|
||||||
|
* Text statt Zahl, damit fuehrende Nullen erhalten bleiben.
|
||||||
|
*/
|
||||||
|
export class KantineDatevSettingsDto {
|
||||||
|
@IsString({ message: 'Die Beraternummer muss angegeben werden' })
|
||||||
|
@Matches(/^\d{1,10}$/, {
|
||||||
|
message: 'Die Beraternummer darf nur Ziffern enthalten (1 bis 10 Stellen)',
|
||||||
|
})
|
||||||
|
beraterNr!: string;
|
||||||
|
|
||||||
|
@IsString({ message: 'Die Mandantennummer muss angegeben werden' })
|
||||||
|
@Matches(/^\d{1,10}$/, {
|
||||||
|
message: 'Die Mandantennummer darf nur Ziffern enthalten (1 bis 10 Stellen)',
|
||||||
|
})
|
||||||
|
mandantNr!: string;
|
||||||
|
|
||||||
|
@IsString({ message: 'Die Lohnart muss angegeben werden' })
|
||||||
|
@Matches(/^\d{1,10}$/, { message: 'Die Lohnart darf nur Ziffern enthalten (1 bis 10 Stellen)' })
|
||||||
|
lohnart!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { parseKantinenCsv } from './kantine-csv.parser';
|
||||||
|
|
||||||
|
const HEADER = 'PersNr;Name;Menge;EK;Netto;ZuAb;MwSt;Zuschuss;Betrag;Von;Bis';
|
||||||
|
const ROW = '100;Muster, Max;1;1,00;1,00;0;0;0;7,94;01.03.2026;31.03.2026';
|
||||||
|
|
||||||
|
describe('parseKantinenCsv', () => {
|
||||||
|
it('liefert bei CRLF und LF dieselben Zeilen', () => {
|
||||||
|
const lf = parseKantinenCsv([HEADER, ROW, ROW].join('\n'));
|
||||||
|
const crlf = parseKantinenCsv([HEADER, ROW, ROW].join('\r\n'));
|
||||||
|
expect(crlf.rows).toEqual(lf.rows);
|
||||||
|
expect(lf.rows).toHaveLength(2);
|
||||||
|
expect(lf.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ueberspringt leere Zeilen und merkt sich die echte Zeilennummer', () => {
|
||||||
|
const { rows } = parseKantinenCsv([HEADER, '', ROW, '', ROW, ''].join('\r\n'));
|
||||||
|
expect(rows.map((r) => r.line)).toEqual([3, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet einen Kopf mit zu wenigen Spalten mit Hinweis auf das Trennzeichen', () => {
|
||||||
|
const { rows, errors } = parseKantinenCsv('a,b,c\n1,2,3');
|
||||||
|
expect(rows).toEqual([]);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0]).toMatchObject({ row: 1, code: 'headerColumns' });
|
||||||
|
expect(errors[0].message).toContain('Ist das Trennzeichen korrekt (Semikolon)?');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet eine leere Datei als fehlenden Kopf', () => {
|
||||||
|
expect(parseKantinenCsv('').errors[0]).toMatchObject({ row: 1, code: 'headerMissing' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet eine Datenzeile mit zu wenigen Spalten mit Zeilennummer und ueberspringt sie', () => {
|
||||||
|
const { rows, errors } = parseKantinenCsv([HEADER, ROW, '1;2;3', ROW].join('\n'));
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(errors).toEqual([
|
||||||
|
expect.objectContaining({ row: 3, code: 'columnCount', field: 'zeile' }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trimmt Whitespace', () => {
|
||||||
|
const { rows } = parseKantinenCsv(
|
||||||
|
[HEADER, ` 100 ; Max ;1;1;1;0;0;0; 7,94 ;01.03.2026;31.03.2026`].join('\n'),
|
||||||
|
);
|
||||||
|
expect(rows[0].personalNr).toBe('100');
|
||||||
|
expect(rows[0].betrag).toBe('7,94');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { KantinenRawRow, ValidationError } from './kantine-datev.types';
|
||||||
|
|
||||||
|
/** Erwartete Anzahl der Spalten pro CSV-Zeile. */
|
||||||
|
const ERWARTETE_SPALTENANZAHL = 11;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parst eine Kantinen-CSV (Semikolon-getrennt, bereits als Text dekodiert).
|
||||||
|
*
|
||||||
|
* - Erste Zeile ist der Kopf und wird uebersprungen
|
||||||
|
* - Leere Zeilen werden ignoriert
|
||||||
|
* - Whitespace wird getrimmt
|
||||||
|
* - Falsche Spaltenanzahl erzeugt einen Fehler mit Zeilennummer
|
||||||
|
*/
|
||||||
|
export function parseKantinenCsv(content: string): {
|
||||||
|
rows: KantinenRawRow[];
|
||||||
|
errors: ValidationError[];
|
||||||
|
} {
|
||||||
|
const rows: KantinenRawRow[] = [];
|
||||||
|
const errors: ValidationError[] = [];
|
||||||
|
|
||||||
|
// Unterstuetzt CRLF und LF.
|
||||||
|
const zeilen = content.split(/\r?\n/);
|
||||||
|
|
||||||
|
const headerZeile = zeilen[0]?.trim();
|
||||||
|
if (!headerZeile) {
|
||||||
|
errors.push({
|
||||||
|
row: 1,
|
||||||
|
field: 'header',
|
||||||
|
code: 'headerMissing',
|
||||||
|
message: 'Die Datei enthält keine Header-Zeile.',
|
||||||
|
});
|
||||||
|
return { rows, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerSpalten = headerZeile.split(';').map((s) => s.trim());
|
||||||
|
if (headerSpalten.length < ERWARTETE_SPALTENANZAHL) {
|
||||||
|
errors.push({
|
||||||
|
row: 1,
|
||||||
|
field: 'header',
|
||||||
|
code: 'headerColumns',
|
||||||
|
message: `Header enthält nur ${headerSpalten.length} Spalten, erwartet werden ${ERWARTETE_SPALTENANZAHL}. Ist das Trennzeichen korrekt (Semikolon)?`,
|
||||||
|
});
|
||||||
|
return { rows, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 1; i < zeilen.length; i++) {
|
||||||
|
const zeile = zeilen[i]?.trim();
|
||||||
|
const zeilenNummer = i + 1;
|
||||||
|
|
||||||
|
if (!zeile) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spalten = zeile.split(';').map((s) => s.trim());
|
||||||
|
|
||||||
|
if (spalten.length < ERWARTETE_SPALTENANZAHL) {
|
||||||
|
errors.push({
|
||||||
|
row: zeilenNummer,
|
||||||
|
field: 'zeile',
|
||||||
|
code: 'columnCount',
|
||||||
|
message: `Zeile hat nur ${spalten.length} Spalten, erwartet werden ${ERWARTETE_SPALTENANZAHL}.`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
personalNr: spalten[0],
|
||||||
|
name: spalten[1],
|
||||||
|
menge: spalten[2],
|
||||||
|
ekPreis: spalten[3],
|
||||||
|
netto: spalten[4],
|
||||||
|
zuAbschlag: spalten[5],
|
||||||
|
mwst: spalten[6],
|
||||||
|
zuschuss: spalten[7],
|
||||||
|
betrag: spalten[8],
|
||||||
|
abrechnungVon: spalten[9],
|
||||||
|
abrechnungBis: spalten[10],
|
||||||
|
line: zeilenNummer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rows, errors };
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { validateKantinenData } from './kantine-csv.validator';
|
||||||
|
import type { KantinenRawRow } from './kantine-datev.types';
|
||||||
|
|
||||||
|
function row(over: Partial<KantinenRawRow> = {}): KantinenRawRow {
|
||||||
|
return {
|
||||||
|
personalNr: '100',
|
||||||
|
name: 'Max Muster',
|
||||||
|
menge: '1',
|
||||||
|
ekPreis: '1,00',
|
||||||
|
netto: '1,00',
|
||||||
|
zuAbschlag: '0',
|
||||||
|
mwst: '0',
|
||||||
|
zuschuss: '0',
|
||||||
|
betrag: '7,94',
|
||||||
|
abrechnungVon: '01.03.2026',
|
||||||
|
abrechnungBis: '31.03.2026',
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('validateKantinenData', () => {
|
||||||
|
it('akzeptiert eine gueltige Zeile und bestimmt den Monat aus "bis"', () => {
|
||||||
|
const result = validateKantinenData([row()]);
|
||||||
|
expect(result.isValid).toBe(true);
|
||||||
|
expect(result.abrechnungsMonat).toBe('03/2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet nicht numerische und fehlende Personalnummern', () => {
|
||||||
|
const r = validateKantinenData([row({ personalNr: 'A12' }), row({ personalNr: '' })]);
|
||||||
|
expect(r.errors.map((e) => e.code)).toEqual(['personalNrNotNumeric', 'personalNrMissing']);
|
||||||
|
expect(r.errors[0].message).toBe('Personalnummer muss numerisch sein');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt Betraege mit Tausenderpunkt oder Minus ab (wie die Vorlage)', () => {
|
||||||
|
const r = validateKantinenData([
|
||||||
|
row({ betrag: '1.234,56' }),
|
||||||
|
row({ betrag: '-5,00' }),
|
||||||
|
row({ betrag: '' }),
|
||||||
|
]);
|
||||||
|
expect(r.errors.map((e) => e.code)).toEqual(['betragFormat', 'betragFormat', 'betragMissing']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prueft das Datumsformat', () => {
|
||||||
|
const r = validateKantinenData([row({ abrechnungVon: '2026-03-01', abrechnungBis: '' })]);
|
||||||
|
expect(r.errors.map((e) => e.code)).toEqual(['vonFormat', 'bisMissing']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet von/bis in verschiedenen Monaten mit Zeile = Index + 2', () => {
|
||||||
|
const r = validateKantinenData([row(), row({ abrechnungVon: '28.02.2026' })]);
|
||||||
|
expect(r.errors).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
row: 3,
|
||||||
|
code: 'multiMonthRange',
|
||||||
|
field: 'abrechnungVon/abrechnungBis',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nimmt die echte Dateizeile, wenn der Parser sie mitliefert', () => {
|
||||||
|
const r = validateKantinenData([row({ personalNr: 'x', line: 9 })]);
|
||||||
|
expect(r.errors[0].row).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warnt bei zwei Abrechnungsmonaten und behaelt den ersten', () => {
|
||||||
|
const r = validateKantinenData([
|
||||||
|
row(),
|
||||||
|
row({ abrechnungVon: '01.04.2026', abrechnungBis: '30.04.2026' }),
|
||||||
|
]);
|
||||||
|
expect(r.isValid).toBe(true);
|
||||||
|
expect(r.abrechnungsMonat).toBe('03/2026');
|
||||||
|
expect(r.warnings).toHaveLength(1);
|
||||||
|
expect(r.warnings[0]).toMatchObject({
|
||||||
|
code: 'multipleMonths',
|
||||||
|
params: { months: ['03/2026', '04/2026'] },
|
||||||
|
});
|
||||||
|
expect(r.warnings[0].message).toBe(
|
||||||
|
'Verschiedene Abrechnungsmonate erkannt: 03/2026, 04/2026. Alle Zeilen sollten im selben Abrechnungsmonat liegen.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import type {
|
||||||
|
KantinenRawRow,
|
||||||
|
ValidationError,
|
||||||
|
ValidationResult,
|
||||||
|
ValidationWarning,
|
||||||
|
} from './kantine-datev.types';
|
||||||
|
|
||||||
|
/** Zahl im deutschen Format (Komma als Dezimaltrenner), wie in der Vorlage. */
|
||||||
|
export function isValidGermanNumber(value: string): boolean {
|
||||||
|
return /^\d+([,]\d+)?$/.test(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Datum im Format TT.MM.JJJJ. */
|
||||||
|
function isValidDate(value: string): boolean {
|
||||||
|
return /^\d{2}\.\d{2}\.\d{4}$/.test(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMonthYear(dateStr: string): { month: string; year: string } | null {
|
||||||
|
const match = dateStr.trim().match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
|
||||||
|
if (!match) return null;
|
||||||
|
return { month: match[2], year: match[3] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validiert die geparsten Kantinen-Zeilen (Regeln und Meldungen wie in der
|
||||||
|
* Desktop-Vorlage):
|
||||||
|
* 1. Personalnummer: vorhanden und numerisch
|
||||||
|
* 2. Betrag: vorhanden, deutsches Zahlenformat
|
||||||
|
* 3. Abrechnung von/bis: Format TT.MM.JJJJ
|
||||||
|
* 4. von und bis muessen im selben Monat liegen
|
||||||
|
* 5. Abrechnungsmonat kommt aus "Abrechnung bis" -> MM/YYYY
|
||||||
|
*/
|
||||||
|
export function validateKantinenData(rows: KantinenRawRow[]): ValidationResult {
|
||||||
|
const errors: ValidationError[] = [];
|
||||||
|
const warnings: ValidationWarning[] = [];
|
||||||
|
const detectedMonths = new Set<string>();
|
||||||
|
let abrechnungsMonat: string | null = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
const row = rows[i];
|
||||||
|
// Echte Dateizeile, falls bekannt; sonst 1-basiert + 1 fuer den Kopf.
|
||||||
|
const rowNum = row.line ?? i + 2;
|
||||||
|
|
||||||
|
if (!row.personalNr || row.personalNr.trim() === '') {
|
||||||
|
errors.push({
|
||||||
|
row: rowNum,
|
||||||
|
field: 'personalNr',
|
||||||
|
code: 'personalNrMissing',
|
||||||
|
message: 'Personalnummer fehlt',
|
||||||
|
});
|
||||||
|
} else if (!/^\d+$/.test(row.personalNr.trim())) {
|
||||||
|
errors.push({
|
||||||
|
row: rowNum,
|
||||||
|
field: 'personalNr',
|
||||||
|
code: 'personalNrNotNumeric',
|
||||||
|
message: 'Personalnummer muss numerisch sein',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row.betrag || row.betrag.trim() === '') {
|
||||||
|
errors.push({ row: rowNum, field: 'betrag', code: 'betragMissing', message: 'Betrag fehlt' });
|
||||||
|
} else if (!isValidGermanNumber(row.betrag)) {
|
||||||
|
errors.push({
|
||||||
|
row: rowNum,
|
||||||
|
field: 'betrag',
|
||||||
|
code: 'betragFormat',
|
||||||
|
message: 'Betrag muss im deutschen Zahlenformat vorliegen (Komma als Dezimaltrenner)',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row.abrechnungVon || row.abrechnungVon.trim() === '') {
|
||||||
|
errors.push({
|
||||||
|
row: rowNum,
|
||||||
|
field: 'abrechnungVon',
|
||||||
|
code: 'vonMissing',
|
||||||
|
message: 'Abrechnung von fehlt',
|
||||||
|
});
|
||||||
|
} else if (!isValidDate(row.abrechnungVon)) {
|
||||||
|
errors.push({
|
||||||
|
row: rowNum,
|
||||||
|
field: 'abrechnungVon',
|
||||||
|
code: 'vonFormat',
|
||||||
|
message: 'Abrechnung von muss im Format TT.MM.JJJJ vorliegen',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row.abrechnungBis || row.abrechnungBis.trim() === '') {
|
||||||
|
errors.push({
|
||||||
|
row: rowNum,
|
||||||
|
field: 'abrechnungBis',
|
||||||
|
code: 'bisMissing',
|
||||||
|
message: 'Abrechnung bis fehlt',
|
||||||
|
});
|
||||||
|
} else if (!isValidDate(row.abrechnungBis)) {
|
||||||
|
errors.push({
|
||||||
|
row: rowNum,
|
||||||
|
field: 'abrechnungBis',
|
||||||
|
code: 'bisFormat',
|
||||||
|
message: 'Abrechnung bis muss im Format TT.MM.JJJJ vorliegen',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const vonParsed = extractMonthYear(row.abrechnungVon);
|
||||||
|
const bisParsed = extractMonthYear(row.abrechnungBis);
|
||||||
|
|
||||||
|
if (vonParsed && bisParsed) {
|
||||||
|
if (vonParsed.month !== bisParsed.month || vonParsed.year !== bisParsed.year) {
|
||||||
|
errors.push({
|
||||||
|
row: rowNum,
|
||||||
|
field: 'abrechnungVon/abrechnungBis',
|
||||||
|
code: 'multiMonthRange',
|
||||||
|
message: 'Abrechnungszeitraum erstreckt sich über mehrere Monate',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bisParsed) {
|
||||||
|
detectedMonths.add(`${bisParsed.month}/${bisParsed.year}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detectedMonths.size === 1) {
|
||||||
|
abrechnungsMonat = [...detectedMonths][0];
|
||||||
|
} else if (detectedMonths.size > 1) {
|
||||||
|
const months = [...detectedMonths];
|
||||||
|
warnings.push({
|
||||||
|
code: 'multipleMonths',
|
||||||
|
message:
|
||||||
|
`Verschiedene Abrechnungsmonate erkannt: ${months.join(', ')}. ` +
|
||||||
|
'Alle Zeilen sollten im selben Abrechnungsmonat liegen.',
|
||||||
|
params: { months },
|
||||||
|
});
|
||||||
|
// Fallback wie in der Vorlage: der erste erkannte Monat.
|
||||||
|
abrechnungsMonat = months[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: errors.length === 0, errors, warnings, abrechnungsMonat };
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||||
|
import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY } from '../module-registry/module.guard';
|
||||||
|
import { KantineDatevSettingsDto } from './dto/kantine-datev-settings.dto';
|
||||||
|
import { KantineDatevController } from './kantine-datev.controller';
|
||||||
|
|
||||||
|
const proto = KantineDatevController.prototype as any;
|
||||||
|
const req = (tenantId?: string) => ({ tenantId }) as any;
|
||||||
|
|
||||||
|
function makeService() {
|
||||||
|
return {
|
||||||
|
getSettings: vi.fn(async (..._a: unknown[]) => ({})),
|
||||||
|
saveSettings: vi.fn(async (..._a: unknown[]) => ({})),
|
||||||
|
preview: vi.fn(async (..._a: unknown[]) => ({})),
|
||||||
|
export: vi.fn(async (..._a: unknown[]) => ({})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('KantineDatevController — Metadaten', () => {
|
||||||
|
it('haengt an modules/kantine-datev und traegt @UseModule', () => {
|
||||||
|
expect(Reflect.getMetadata('path', KantineDatevController)).toBe('modules/kantine-datev');
|
||||||
|
expect(Reflect.getMetadata(MODULE_SLUG_KEY, KantineDatevController)).toBe('kantine-datev');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PUT settings verlangt die Freigabestufe Verwalten und trägt keine Routen-Rolle (261002-icv)', () => {
|
||||||
|
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto.saveSettings)).toBe(true);
|
||||||
|
expect(Reflect.getMetadata(MODULE_SLUG_KEY, proto.saveSettings)).toBe('kantine-datev');
|
||||||
|
expect(Reflect.getMetadata(ROLES_KEY, proto.saveSettings)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['getSettings', 'preview', 'export'])(
|
||||||
|
'%s traegt weder Routen-Rolle noch Verwalten-Pflicht',
|
||||||
|
(name) => {
|
||||||
|
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
|
||||||
|
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name])).toBeUndefined();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('KantineDatevController — Verhalten', () => {
|
||||||
|
it('reicht req.tenantId und den Dateipuffer an den Dienst', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
const c = new KantineDatevController(service as any);
|
||||||
|
const buffer = Buffer.from('x');
|
||||||
|
await c.getSettings(req('t1'));
|
||||||
|
await c.preview(req('t1'), { buffer } as any);
|
||||||
|
await c.export(req('t1'), { buffer } as any);
|
||||||
|
expect(service.getSettings).toHaveBeenCalledWith('t1');
|
||||||
|
expect(service.preview).toHaveBeenCalledWith('t1', buffer);
|
||||||
|
expect(service.export).toHaveBeenCalledWith('t1', buffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('antwortet ohne Datei mit 400', async () => {
|
||||||
|
const c = new KantineDatevController(makeService() as any);
|
||||||
|
await expect(c.preview(req('t1'), undefined)).rejects.toThrow(BadRequestException);
|
||||||
|
await expect(c.export(req('t1'), undefined)).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('antwortet ohne Mandantenkontext mit 403', async () => {
|
||||||
|
const c = new KantineDatevController(makeService() as any);
|
||||||
|
await expect(c.getSettings(req(undefined))).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('KantineDatevSettingsDto', () => {
|
||||||
|
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||||
|
const run = (value: unknown) =>
|
||||||
|
pipe.transform(value, { type: 'body', metatype: KantineDatevSettingsDto });
|
||||||
|
|
||||||
|
it('akzeptiert Ziffernketten (auch mit fuehrender Null)', async () => {
|
||||||
|
await expect(
|
||||||
|
run({ beraterNr: '0123456', mandantNr: '12345', lohnart: '1111' }),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ beraterNr: '12a', mandantNr: '1', lohnart: '1' }],
|
||||||
|
[{ beraterNr: '1', mandantNr: '', lohnart: '1' }],
|
||||||
|
[{ beraterNr: '1', mandantNr: '1', lohnart: '12345678901' }],
|
||||||
|
[{ beraterNr: '1', mandantNr: '1' }],
|
||||||
|
[{ beraterNr: 1, mandantNr: '1', lohnart: '1' }],
|
||||||
|
])('lehnt %j ab', async (body) => {
|
||||||
|
await expect(run(body)).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Req,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
|
import type { AuthenticatedRequest, UploadedFileLike } from '../auth/types/auth-user';
|
||||||
|
import { ModuleManage, UseModule } from '../module-registry/module.guard';
|
||||||
|
import { KantineDatevSettingsDto } from './dto/kantine-datev-settings.dto';
|
||||||
|
import { KantineDatevService } from './kantine-datev.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `@UseModule('kantine-datev')` auf Klassenebene — Aktivierung UND Freigabe.
|
||||||
|
* `tenantId` kommt ausschliesslich aus `req.tenantId` (TenantGuard). Lesen,
|
||||||
|
* Vorschau und Export stehen jedem Benutzer mit Modulzugriff offen; die
|
||||||
|
* Einstellungen aendern Administratoren und Benutzer mit der Freigabestufe
|
||||||
|
* Verwalten (`@ModuleManage`, 261002-icv; T-FM5-02). Hochgeladene Dateien
|
||||||
|
* bleiben im Arbeitsspeicher (multer-Standard), 5 MB Grenze (T-FM5-04).
|
||||||
|
* Keine `:id`-Routen in diesem Controller.
|
||||||
|
*/
|
||||||
|
@Controller('modules/kantine-datev')
|
||||||
|
@UseModule('kantine-datev')
|
||||||
|
export class KantineDatevController {
|
||||||
|
constructor(private readonly service: KantineDatevService) {}
|
||||||
|
|
||||||
|
private requireTenantId(req: AuthenticatedRequest): string {
|
||||||
|
const tenantId = req.tenantId;
|
||||||
|
if (!tenantId) {
|
||||||
|
throw new ForbiddenException('Kein Mandantenkontext');
|
||||||
|
}
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('settings')
|
||||||
|
async getSettings(@Req() req: AuthenticatedRequest) {
|
||||||
|
return this.service.getSettings(this.requireTenantId(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('settings')
|
||||||
|
@ModuleManage('kantine-datev')
|
||||||
|
async saveSettings(@Req() req: AuthenticatedRequest, @Body() dto: KantineDatevSettingsDto) {
|
||||||
|
return this.service.saveSettings(this.requireTenantId(req), dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('preview')
|
||||||
|
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
|
||||||
|
async preview(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@UploadedFile() file: UploadedFileLike | undefined,
|
||||||
|
) {
|
||||||
|
const tenantId = this.requireTenantId(req);
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException('Keine Datei hochgeladen');
|
||||||
|
}
|
||||||
|
return this.service.preview(tenantId, file.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('export')
|
||||||
|
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
|
||||||
|
async export(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@UploadedFile() file: UploadedFileLike | undefined,
|
||||||
|
) {
|
||||||
|
const tenantId = this.requireTenantId(req);
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException('Keine Datei hochgeladen');
|
||||||
|
}
|
||||||
|
return this.service.export(tenantId, file.buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Logger, Module, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
|
||||||
|
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||||
|
import { KantineDatevController } from './kantine-datev.controller';
|
||||||
|
import { seedKantineDatevModule } from './kantine-datev.seed';
|
||||||
|
import { KantineDatevService } from './kantine-datev.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kantinenabrechnung (quick-261002-fm5): Kantinen-CSV pruefen und als
|
||||||
|
* DATEV-Lohn-ASCII-Datei exportieren. Traegt sich beim Start in die
|
||||||
|
* Modulverwaltung ein; aktiviert wird per Marktplatz.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [ModuleRegistryModule],
|
||||||
|
controllers: [KantineDatevController],
|
||||||
|
providers: [KantineDatevService],
|
||||||
|
})
|
||||||
|
export class KantineDatevModule implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(KantineDatevModule.name);
|
||||||
|
|
||||||
|
constructor(private readonly moduleRegistryService: ModuleRegistryService) {}
|
||||||
|
|
||||||
|
async onModuleInit(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await seedKantineDatevModule(this.moduleRegistryService);
|
||||||
|
this.logger.log('Kantine-DATEV module seeded in registry');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error('Failed to seed kantine-datev module', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
buildKantineExport,
|
||||||
|
KantineExportError,
|
||||||
|
processKantineCsv,
|
||||||
|
} from './kantine-datev.pipeline';
|
||||||
|
|
||||||
|
const SETTINGS = { beraterNr: '1234567', mandantNr: '12345', lohnart: '1111' };
|
||||||
|
const HEADER = 'PersNr;Name;Menge;EK;Netto;ZuAb;MwSt;Zuschuss;Betrag;Von;Bis';
|
||||||
|
const ok = (nr: string, name: string, betrag: string, bis = '31.03.2026') =>
|
||||||
|
`${nr};${name};1;1,00;1,00;0;0;0;${betrag};01.03.2026;${bis}`;
|
||||||
|
|
||||||
|
function csv(lines: string[], eol = '\r\n'): string {
|
||||||
|
return [HEADER, ...lines].join(eol);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('processKantineCsv', () => {
|
||||||
|
it('liest Windows-1252 mit Umlauten und CRLF', () => {
|
||||||
|
const buf = Buffer.from(
|
||||||
|
csv([ok('100', 'Müller, Jürgen', '7,94'), ok('200', 'Köhler', '51,5')]),
|
||||||
|
'latin1',
|
||||||
|
);
|
||||||
|
const p = processKantineCsv(buf, SETTINGS);
|
||||||
|
expect(p.rowCount).toBe(2);
|
||||||
|
expect(p.abrechnungsMonat).toBe('03/2026');
|
||||||
|
expect(p.totalCents).toBe(794 + 5150);
|
||||||
|
expect(p.errors).toEqual([]);
|
||||||
|
expect(p.canExport).toBe(true);
|
||||||
|
expect(p.blockedReason).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liest UTF-8 mit BOM und LF gleich', () => {
|
||||||
|
const body = csv([ok('100', 'Müller', '7,94')], '\n');
|
||||||
|
const buf = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(body, 'utf8')]);
|
||||||
|
const p = processKantineCsv(buf, SETTINGS);
|
||||||
|
expect(p.rowCount).toBe(1);
|
||||||
|
expect(p.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zaehlt in der Summe nur Zeilen mit gueltigem Betrag', () => {
|
||||||
|
const p = processKantineCsv(
|
||||||
|
Buffer.from(csv([ok('100', 'A', '7,94'), ok('101', 'B', 'abc')])),
|
||||||
|
SETTINGS,
|
||||||
|
);
|
||||||
|
expect(p.totalCents).toBe(794);
|
||||||
|
expect(p.errors).toEqual([expect.objectContaining({ row: 3, code: 'betragFormat' })]);
|
||||||
|
expect(p.canExport).toBe(false);
|
||||||
|
expect(p.blockedReason).toBe('errors');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet zwei Monate als Warnung, nicht als Fehler', () => {
|
||||||
|
const p = processKantineCsv(
|
||||||
|
Buffer.from(csv([ok('100', 'A', '1,00'), `101;B;1;1;1;0;0;0;1,00;01.04.2026;30.04.2026`])),
|
||||||
|
SETTINGS,
|
||||||
|
);
|
||||||
|
expect(p.warnings).toHaveLength(1);
|
||||||
|
expect(p.errors).toEqual([]);
|
||||||
|
expect(p.abrechnungsMonat).toBe('03/2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet eine Datei ohne Datenzeilen mit noRows', () => {
|
||||||
|
const p = processKantineCsv(Buffer.from(csv([])), SETTINGS);
|
||||||
|
expect(p.rowCount).toBe(0);
|
||||||
|
expect(p.errors.map((e) => e.code)).toEqual(['noRows']);
|
||||||
|
expect(p.canExport).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sperrt ohne Einstellungen mit settingsMissing', () => {
|
||||||
|
const p = processKantineCsv(Buffer.from(csv([ok('100', 'A', '1,00')])), null);
|
||||||
|
expect(p.canExport).toBe(false);
|
||||||
|
expect(p.blockedReason).toBe('settingsMissing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gibt keine Zeileninhalte in der Vorschau zurueck', () => {
|
||||||
|
const p = processKantineCsv(Buffer.from(csv([ok('100', 'Geheimname', 'x')])), SETTINGS);
|
||||||
|
expect(JSON.stringify(p)).not.toContain('Geheimname');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildKantineExport', () => {
|
||||||
|
it('erzeugt Dateiname und Base64-Inhalt', () => {
|
||||||
|
const r = buildKantineExport(Buffer.from(csv([ok('100', 'A', '7,94')])), SETTINGS);
|
||||||
|
expect(r.filename).toBe('LuG_1234567_12345_03_2026.sic');
|
||||||
|
expect(r.mimeType).toBe('text/plain');
|
||||||
|
expect(Buffer.from(r.content, 'base64').toString('utf8')).toBe(
|
||||||
|
'1234567\t12345\t03/2026\t\t\t\t\t\t\t\t\r\n\t100\t\t1111\t-7.94\t\t\t\t\t\t\r\n',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verweigert den Export bei Fehlern', () => {
|
||||||
|
expect(() => buildKantineExport(Buffer.from(csv([ok('x', 'A', '7,94')])), SETTINGS)).toThrow(
|
||||||
|
KantineExportError,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
buildKantineExport(Buffer.from(csv([ok('x', 'A', '7,94')])), SETTINGS);
|
||||||
|
} catch (e) {
|
||||||
|
expect((e as KantineExportError).code).toBe('hasErrors');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verweigert den Export ohne Einstellungen', () => {
|
||||||
|
try {
|
||||||
|
buildKantineExport(Buffer.from(csv([ok('100', 'A', '7,94')])), null);
|
||||||
|
expect.unreachable();
|
||||||
|
} catch (e) {
|
||||||
|
expect((e as KantineExportError).code).toBe('settingsMissing');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { decodeCsvText } from '../accounting/decode-csv-text';
|
||||||
|
import { parseKantinenCsv } from './kantine-csv.parser';
|
||||||
|
import { isValidGermanNumber, validateKantinenData } from './kantine-csv.validator';
|
||||||
|
import {
|
||||||
|
buildKantineExportFilename,
|
||||||
|
generateDatevOutput,
|
||||||
|
qualityCheck,
|
||||||
|
transformToDatevRecords,
|
||||||
|
} from './kantine-datev.transformer';
|
||||||
|
import type {
|
||||||
|
FileResponse,
|
||||||
|
KantineDatevSettings,
|
||||||
|
KantinenRawRow,
|
||||||
|
KantinePreview,
|
||||||
|
ValidationError,
|
||||||
|
ValidationResult,
|
||||||
|
} from './kantine-datev.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verarbeitungskette der Kantinenabrechnung (quick-261002-fm5):
|
||||||
|
* dekodieren -> parsen -> validieren -> Summe -> Vorschau bzw. Export.
|
||||||
|
*
|
||||||
|
* Reine Funktionen ohne Datenbank, Datei oder Protokollausgabe: hochgeladene
|
||||||
|
* Zeilen (Namen, Personalnummern) verlassen den Arbeitsspeicher nie.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Grund, warum ein Export abgelehnt wurde. */
|
||||||
|
export type KantineExportErrorCode = 'settingsMissing' | 'hasErrors' | 'qualityCheckFailed';
|
||||||
|
|
||||||
|
export class KantineExportError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: KantineExportErrorCode,
|
||||||
|
message: string,
|
||||||
|
readonly errors: ValidationError[] = [],
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'KantineExportError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Analysis {
|
||||||
|
rows: KantinenRawRow[];
|
||||||
|
errors: ValidationError[];
|
||||||
|
validation: ValidationResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyze(buffer: Buffer): Analysis {
|
||||||
|
const text = decodeCsvText(buffer);
|
||||||
|
const parsed = parseKantinenCsv(text);
|
||||||
|
const validation = validateKantinenData(parsed.rows);
|
||||||
|
const errors = [...parsed.errors, ...validation.errors].sort((a, b) => a.row - b.row);
|
||||||
|
|
||||||
|
if (parsed.rows.length === 0 && parsed.errors.length === 0) {
|
||||||
|
errors.push({
|
||||||
|
row: 1,
|
||||||
|
field: 'datei',
|
||||||
|
code: 'noRows',
|
||||||
|
message: 'Die Datei enthält keine Datenzeilen.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { rows: parsed.rows, errors, validation };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Summe der Betraege in Cent; nur Zeilen, deren Betrag das Format besteht. */
|
||||||
|
function sumCents(rows: KantinenRawRow[]): number {
|
||||||
|
let total = 0;
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!isValidGermanNumber(row.betrag)) continue;
|
||||||
|
total += Math.round(Number(row.betrag.trim().replace(',', '.')) * 100);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function processKantineCsv(
|
||||||
|
buffer: Buffer,
|
||||||
|
settings: KantineDatevSettings | null,
|
||||||
|
): KantinePreview {
|
||||||
|
const { rows, errors, validation } = analyze(buffer);
|
||||||
|
|
||||||
|
let blockedReason: KantinePreview['blockedReason'] = null;
|
||||||
|
if (!settings) {
|
||||||
|
blockedReason = 'settingsMissing';
|
||||||
|
} else if (errors.length > 0) {
|
||||||
|
blockedReason = 'errors';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rowCount: rows.length,
|
||||||
|
abrechnungsMonat: validation.abrechnungsMonat,
|
||||||
|
totalCents: sumCents(rows),
|
||||||
|
errors,
|
||||||
|
warnings: validation.warnings,
|
||||||
|
canExport: blockedReason === null,
|
||||||
|
blockedReason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildKantineExport(
|
||||||
|
buffer: Buffer,
|
||||||
|
settings: KantineDatevSettings | null,
|
||||||
|
): FileResponse {
|
||||||
|
if (!settings) {
|
||||||
|
throw new KantineExportError(
|
||||||
|
'settingsMissing',
|
||||||
|
'Beraternummer, Mandantennummer und Lohnart sind noch nicht hinterlegt.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows, errors, validation } = analyze(buffer);
|
||||||
|
if (errors.length > 0 || !validation.abrechnungsMonat) {
|
||||||
|
throw new KantineExportError(
|
||||||
|
'hasErrors',
|
||||||
|
'Die Datei enthält Fehler und kann nicht exportiert werden.',
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = transformToDatevRecords(rows);
|
||||||
|
const output = generateDatevOutput(records, validation.abrechnungsMonat, settings);
|
||||||
|
|
||||||
|
const check = qualityCheck(output);
|
||||||
|
if (!check.passed) {
|
||||||
|
throw new KantineExportError(
|
||||||
|
'qualityCheckFailed',
|
||||||
|
`Qualitätsprüfung fehlgeschlagen: ${check.errors.join('; ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
filename: buildKantineExportFilename(settings, validation.abrechnungsMonat),
|
||||||
|
content: Buffer.from(output, 'utf8').toString('base64'),
|
||||||
|
mimeType: 'text/plain',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traegt das Modul "Kantinenabrechnung" in die Modulverwaltung ein
|
||||||
|
* (quick-261002-fm5). `isSystem: true` legt den Eintrag an, aktiviert ihn aber
|
||||||
|
* NICHT je Mandant — der Administrator aktiviert ueber den Marktplatz und
|
||||||
|
* erteilt die Freigabe.
|
||||||
|
*/
|
||||||
|
export async function seedKantineDatevModule(
|
||||||
|
moduleRegistryService: ModuleRegistryService,
|
||||||
|
): Promise<void> {
|
||||||
|
await moduleRegistryService.seedModule({
|
||||||
|
slug: 'kantine-datev',
|
||||||
|
name: 'Kantinenabrechnung',
|
||||||
|
version: '1.0.0',
|
||||||
|
category: 'accounting',
|
||||||
|
description: {
|
||||||
|
de: 'Kantinen-CSV prüfen und als DATEV-Lohndatei (ASCII) für die Gehaltsabrechnung exportieren',
|
||||||
|
en: 'Check canteen CSV files and export them as a DATEV payroll ASCII file',
|
||||||
|
},
|
||||||
|
isSystem: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
|
forTenant: vi.fn((p: unknown) => p),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
import { KantineDatevService } from './kantine-datev.service';
|
||||||
|
|
||||||
|
const HEADER = 'PersNr;Name;Menge;EK;Netto;ZuAb;MwSt;Zuschuss;Betrag;Von;Bis';
|
||||||
|
const GOOD = Buffer.from(
|
||||||
|
[HEADER, '100;Max;1;1,00;1,00;0;0;0;7,94;01.03.2026;31.03.2026'].join('\r\n'),
|
||||||
|
);
|
||||||
|
|
||||||
|
function setup(row: Record<string, string | null> | null = null) {
|
||||||
|
const kantineDatevConfig = {
|
||||||
|
findUnique: vi.fn(async () => row),
|
||||||
|
upsert: vi.fn(async ({ create }: any) => create),
|
||||||
|
};
|
||||||
|
return { prisma: { kantineDatevConfig }, kantineDatevConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('KantineDatevService — Einstellungen', () => {
|
||||||
|
it('liefert ohne Zeile leere Werte und configured=false', async () => {
|
||||||
|
const { prisma } = setup(null);
|
||||||
|
const res = await new KantineDatevService(prisma as any).getSettings('t1');
|
||||||
|
expect(res).toEqual({ beraterNr: null, mandantNr: null, lohnart: null, configured: false });
|
||||||
|
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('configured=false, solange ein Feld fehlt', async () => {
|
||||||
|
const { prisma } = setup({ beraterNr: '1', mandantNr: null, lohnart: '3' });
|
||||||
|
expect((await new KantineDatevService(prisma as any).getSettings('t1')).configured).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('speichert per upsert auf tenantId (aus dem Argument)', async () => {
|
||||||
|
const { prisma, kantineDatevConfig } = setup();
|
||||||
|
const res = await new KantineDatevService(prisma as any).saveSettings('t1', {
|
||||||
|
beraterNr: '1234567',
|
||||||
|
mandantNr: '12345',
|
||||||
|
lohnart: '1111',
|
||||||
|
});
|
||||||
|
expect(kantineDatevConfig.upsert).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { tenantId: 't1' } }),
|
||||||
|
);
|
||||||
|
expect(res.configured).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('KantineDatevService — Vorschau und Export', () => {
|
||||||
|
it('Vorschau ohne Einstellungen sperrt mit settingsMissing', async () => {
|
||||||
|
const { prisma } = setup(null);
|
||||||
|
const p = await new KantineDatevService(prisma as any).preview('t1', GOOD);
|
||||||
|
expect(p.blockedReason).toBe('settingsMissing');
|
||||||
|
expect(p.rowCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Export ohne Einstellungen -> 400 mit code settingsMissing', async () => {
|
||||||
|
const { prisma } = setup(null);
|
||||||
|
const err: any = await new KantineDatevService(prisma as any)
|
||||||
|
.export('t1', GOOD)
|
||||||
|
.catch((e) => e);
|
||||||
|
expect(err).toBeInstanceOf(BadRequestException);
|
||||||
|
expect(err.getResponse().code).toBe('settingsMissing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Export mit Fehlern -> 400 mit code hasErrors und Fehlerliste', async () => {
|
||||||
|
const { prisma } = setup({ beraterNr: '1', mandantNr: '2', lohnart: '3' });
|
||||||
|
const bad = Buffer.from(
|
||||||
|
[HEADER, 'x;Max;1;1,00;1,00;0;0;0;7,94;01.03.2026;31.03.2026'].join('\n'),
|
||||||
|
);
|
||||||
|
const err: any = await new KantineDatevService(prisma as any).export('t1', bad).catch((e) => e);
|
||||||
|
expect(err.getResponse().code).toBe('hasErrors');
|
||||||
|
expect(err.getResponse().errors).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Export mit Einstellungen liefert Datei', async () => {
|
||||||
|
const { prisma } = setup({ beraterNr: '1234567', mandantNr: '12345', lohnart: '1111' });
|
||||||
|
const res = await new KantineDatevService(prisma as any).export('t1', GOOD);
|
||||||
|
expect(res.filename).toBe('LuG_1234567_12345_03_2026.sic');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { BadRequestException, Injectable, UnprocessableEntityException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
import type { KantineDatevSettingsDto } from './dto/kantine-datev-settings.dto';
|
||||||
|
import {
|
||||||
|
buildKantineExport,
|
||||||
|
KantineExportError,
|
||||||
|
processKantineCsv,
|
||||||
|
} from './kantine-datev.pipeline';
|
||||||
|
import type { FileResponse, KantineDatevSettings, KantinePreview } from './kantine-datev.types';
|
||||||
|
|
||||||
|
export interface KantineSettingsResponse {
|
||||||
|
beraterNr: string | null;
|
||||||
|
mandantNr: string | null;
|
||||||
|
lohnart: string | null;
|
||||||
|
configured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kantinenabrechnung (quick-261002-fm5). Die Einstellungen liegen je Mandant
|
||||||
|
* in `KantineDatevConfig` (mandantengebunden); die hochgeladene CSV wird nur
|
||||||
|
* im Arbeitsspeicher verarbeitet und weder gespeichert noch protokolliert.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class KantineDatevService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getSettings(tenantId: string): Promise<KantineSettingsResponse> {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
const row = await tenantPrisma.kantineDatevConfig.findUnique({ where: { tenantId } });
|
||||||
|
const beraterNr = row?.beraterNr ?? null;
|
||||||
|
const mandantNr = row?.mandantNr ?? null;
|
||||||
|
const lohnart = row?.lohnart ?? null;
|
||||||
|
return {
|
||||||
|
beraterNr,
|
||||||
|
mandantNr,
|
||||||
|
lohnart,
|
||||||
|
configured: Boolean(beraterNr && mandantNr && lohnart),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSettings(
|
||||||
|
tenantId: string,
|
||||||
|
dto: KantineDatevSettingsDto,
|
||||||
|
): Promise<KantineSettingsResponse> {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
const data = { beraterNr: dto.beraterNr, mandantNr: dto.mandantNr, lohnart: dto.lohnart };
|
||||||
|
await tenantPrisma.kantineDatevConfig.upsert({
|
||||||
|
where: { tenantId },
|
||||||
|
create: { tenantId, ...data },
|
||||||
|
update: data,
|
||||||
|
});
|
||||||
|
return { ...data, configured: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadConfigured(tenantId: string): Promise<KantineDatevSettings | null> {
|
||||||
|
const s = await this.getSettings(tenantId);
|
||||||
|
if (!s.configured || !s.beraterNr || !s.mandantNr || !s.lohnart) return null;
|
||||||
|
return { beraterNr: s.beraterNr, mandantNr: s.mandantNr, lohnart: s.lohnart };
|
||||||
|
}
|
||||||
|
|
||||||
|
async preview(tenantId: string, buffer: Buffer): Promise<KantinePreview> {
|
||||||
|
return processKantineCsv(buffer, await this.loadConfigured(tenantId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async export(tenantId: string, buffer: Buffer): Promise<FileResponse> {
|
||||||
|
const settings = await this.loadConfigured(tenantId);
|
||||||
|
try {
|
||||||
|
return buildKantineExport(buffer, settings);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof KantineExportError) {
|
||||||
|
if (error.code === 'qualityCheckFailed') {
|
||||||
|
throw new UnprocessableEntityException({ code: error.code, message: error.message });
|
||||||
|
}
|
||||||
|
throw new BadRequestException({
|
||||||
|
code: error.code,
|
||||||
|
message: error.message,
|
||||||
|
errors: error.errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
buildKantineExportFilename,
|
||||||
|
generateDatevOutput,
|
||||||
|
qualityCheck,
|
||||||
|
transformBetrag,
|
||||||
|
} from './kantine-datev.transformer';
|
||||||
|
|
||||||
|
const SETTINGS = { beraterNr: '1234567', mandantNr: '12345', lohnart: '1111' };
|
||||||
|
|
||||||
|
describe('transformBetrag', () => {
|
||||||
|
it('macht den Betrag negativ mit Punkt und zwei Nachkommastellen', () => {
|
||||||
|
expect(transformBetrag('7,94')).toBe('-7.94');
|
||||||
|
expect(transformBetrag('51,5')).toBe('-51.50');
|
||||||
|
expect(transformBetrag('0,00')).toBe('-0.00');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateDatevOutput', () => {
|
||||||
|
const out = generateDatevOutput(
|
||||||
|
[
|
||||||
|
{ personalNr: '100', betrag: '-7.94' },
|
||||||
|
{ personalNr: '200', betrag: '-51.50' },
|
||||||
|
],
|
||||||
|
'03/2026',
|
||||||
|
SETTINGS,
|
||||||
|
);
|
||||||
|
|
||||||
|
it('schreibt den Kopf aus den Einstellungen mit 8 leeren Spalten', () => {
|
||||||
|
const lines = out.split('\r\n');
|
||||||
|
expect(lines[0]).toBe('1234567\t12345\t03/2026\t\t\t\t\t\t\t\t');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schreibt Detailzeilen mit Lohnart und Betrag', () => {
|
||||||
|
const lines = out.split('\r\n');
|
||||||
|
expect(lines[1]).toBe('\t100\t\t1111\t-7.94\t\t\t\t\t\t');
|
||||||
|
expect(lines[2]).toBe('\t200\t\t1111\t-51.50\t\t\t\t\t\t');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hat in jeder Zeile 11 Spalten, CRLF und ein abschliessendes CRLF', () => {
|
||||||
|
expect(out.endsWith('\r\n')).toBe(true);
|
||||||
|
expect(out.replace(/\r\n/g, '')).not.toContain('\n');
|
||||||
|
for (const line of out.split('\r\n').slice(0, -1)) {
|
||||||
|
expect(line.split('\t')).toHaveLength(11);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('besteht die Qualitaetspruefung', () => {
|
||||||
|
expect(qualityCheck(out)).toEqual({ passed: true, errors: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('qualityCheck', () => {
|
||||||
|
it('lehnt eine Datei nur mit LF ab', () => {
|
||||||
|
const r = qualityCheck('a\tb\t\t\t\t\t\t\t\t\t\n');
|
||||||
|
expect(r.passed).toBe(false);
|
||||||
|
expect(r.errors).toContain('Datei endet nicht mit CRLF');
|
||||||
|
expect(r.errors).toContain('Datei enthaelt einzelne LF-Zeilenenden (nur CRLF erlaubt)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt eine Zeile mit 10 Spalten ab', () => {
|
||||||
|
const r = qualityCheck('a\t\t\t\t\t\t\t\t\t\r\n');
|
||||||
|
expect(r.passed).toBe(false);
|
||||||
|
expect(r.errors[0]).toBe('Zeile 1: 10 Spalten gefunden, 11 erwartet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt einen positiven Betrag ab', () => {
|
||||||
|
const r = qualityCheck(`k\t\t\t\t\t\t\t\t\t\t\r\n\t1\t\t1111\t7.94\t\t\t\t\t\t\r\n`);
|
||||||
|
expect(r.passed).toBe(false);
|
||||||
|
expect(r.errors[0]).toContain('Betrag "7.94" ist nicht im Format -X.XX');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildKantineExportFilename', () => {
|
||||||
|
it('folgt LuG_<Berater>_<Mandant>_<MM>_<YYYY>.sic', () => {
|
||||||
|
expect(buildKantineExportFilename(SETTINGS, '03/2026')).toBe('LuG_1234567_12345_03_2026.sic');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import type { DatevRecord, KantineDatevSettings, KantinenRawRow } from './kantine-datev.types';
|
||||||
|
|
||||||
|
const SPALTEN_ANZAHL = 11;
|
||||||
|
const TAB = '\t';
|
||||||
|
const CRLF = '\r\n';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transformiert einen Betrag gemaess DATEV-Regel: Komma -> Punkt, immer
|
||||||
|
* negativ, exakt 2 Nachkommastellen. "7,94" -> "-7.94", "51,5" -> "-51.50".
|
||||||
|
*/
|
||||||
|
export function transformBetrag(betrag: string): string {
|
||||||
|
const cleaned = betrag.trim().replace(',', '.');
|
||||||
|
const num = parseFloat(cleaned);
|
||||||
|
const absValue = Math.abs(num);
|
||||||
|
return `-${absValue.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transformToDatevRecords(rows: KantinenRawRow[]): DatevRecord[] {
|
||||||
|
return rows.map((row) => ({
|
||||||
|
personalNr: row.personalNr.trim(),
|
||||||
|
betrag: transformBetrag(row.betrag),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generiert die komplette DATEV-Lohn-ASCII-Datei.
|
||||||
|
*
|
||||||
|
* Kopf: Beraternr TAB Mandantennr TAB MM/YYYY + 8 leere Spalten
|
||||||
|
* Detail: TAB PersonalNr TAB TAB Lohnart TAB -Betrag + 6 leere Spalten
|
||||||
|
*
|
||||||
|
* Exakt 11 Spalten je Zeile, CRLF-Zeilenenden, die Datei endet mit CRLF.
|
||||||
|
* Berater-, Mandantennummer und Lohnart kommen aus den Einstellungen des
|
||||||
|
* Mandanten, nicht aus festen Werten.
|
||||||
|
*/
|
||||||
|
export function generateDatevOutput(
|
||||||
|
records: DatevRecord[],
|
||||||
|
abrechnungsMonat: string,
|
||||||
|
settings: KantineDatevSettings,
|
||||||
|
): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
[settings.beraterNr, settings.mandantNr, abrechnungsMonat, '', '', '', '', '', '', '', ''].join(
|
||||||
|
TAB,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
lines.push(
|
||||||
|
['', record.personalNr, '', settings.lohnart, record.betrag, '', '', '', '', '', ''].join(
|
||||||
|
TAB,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join(CRLF) + CRLF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Qualitaetspruefung der erzeugten Ausgabe: 11 Spalten je Zeile, nur CRLF,
|
||||||
|
* Datei endet mit CRLF, alle Betraege negativ mit 2 Nachkommastellen.
|
||||||
|
*/
|
||||||
|
export function qualityCheck(output: string): { passed: boolean; errors: string[] } {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (!output.endsWith(CRLF)) {
|
||||||
|
errors.push('Datei endet nicht mit CRLF');
|
||||||
|
}
|
||||||
|
|
||||||
|
const ohneCarriageReturn = output.replace(/\r\n/g, '');
|
||||||
|
if (ohneCarriageReturn.includes('\n')) {
|
||||||
|
errors.push('Datei enthaelt einzelne LF-Zeilenenden (nur CRLF erlaubt)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const zeilen = output.split(CRLF);
|
||||||
|
const inhaltZeilen = zeilen.slice(0, -1);
|
||||||
|
|
||||||
|
if (inhaltZeilen.length === 0) {
|
||||||
|
errors.push('Datei enthaelt keine Zeilen');
|
||||||
|
return { passed: false, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < inhaltZeilen.length; i++) {
|
||||||
|
const spalten = inhaltZeilen[i].split(TAB);
|
||||||
|
if (spalten.length !== SPALTEN_ANZAHL) {
|
||||||
|
errors.push(`Zeile ${i + 1}: ${spalten.length} Spalten gefunden, ${SPALTEN_ANZAHL} erwartet`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const betragRegex = /^-\d+\.\d{2}$/;
|
||||||
|
for (let i = 1; i < inhaltZeilen.length; i++) {
|
||||||
|
const spalten = inhaltZeilen[i].split(TAB);
|
||||||
|
const betrag = spalten[4];
|
||||||
|
if (betrag && !betragRegex.test(betrag)) {
|
||||||
|
errors.push(
|
||||||
|
`Zeile ${i + 1}: Betrag "${betrag}" ist nicht im Format -X.XX (negativ, 2 Nachkommastellen)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { passed: errors.length === 0, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dateiname des Downloads: LuG_<Beraternr>_<Mandantennr>_<MM>_<YYYY>.sic */
|
||||||
|
export function buildKantineExportFilename(
|
||||||
|
settings: KantineDatevSettings,
|
||||||
|
abrechnungsMonat: string,
|
||||||
|
): string {
|
||||||
|
const [monat, jahr] = abrechnungsMonat.split('/');
|
||||||
|
return `LuG_${settings.beraterNr}_${settings.mandantNr}_${monat}_${jahr}.sic`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Typen der Kantinenabrechnung (quick-261002-fm5). Portiert aus der
|
||||||
|
* Desktop-Vorlage; jeder Fehler und jede Warnung traegt zusaetzlich eine
|
||||||
|
* stabile Kennung (`code`), damit die Oberflaeche den Text uebersetzen kann,
|
||||||
|
* waehrend `message` den deutschen Originaltext behaelt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Rohe CSV-Zeile nach dem Parsen. */
|
||||||
|
export interface KantinenRawRow {
|
||||||
|
personalNr: string;
|
||||||
|
name: string;
|
||||||
|
menge: string;
|
||||||
|
ekPreis: string;
|
||||||
|
netto: string;
|
||||||
|
zuAbschlag: string;
|
||||||
|
mwst: string;
|
||||||
|
zuschuss: string;
|
||||||
|
betrag: string;
|
||||||
|
abrechnungVon: string;
|
||||||
|
abrechnungBis: string;
|
||||||
|
/** 1-basierte Zeilennummer in der Datei (fuer Fehlermeldungen). */
|
||||||
|
line?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validierter und transformierter Datensatz. */
|
||||||
|
export interface DatevRecord {
|
||||||
|
personalNr: string;
|
||||||
|
/** z. B. "-51.50" (immer negativ, Punkt, 2 Nachkommastellen) */
|
||||||
|
betrag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KantineErrorCode =
|
||||||
|
| 'headerMissing'
|
||||||
|
| 'headerColumns'
|
||||||
|
| 'columnCount'
|
||||||
|
| 'personalNrMissing'
|
||||||
|
| 'personalNrNotNumeric'
|
||||||
|
| 'betragMissing'
|
||||||
|
| 'betragFormat'
|
||||||
|
| 'vonMissing'
|
||||||
|
| 'vonFormat'
|
||||||
|
| 'bisMissing'
|
||||||
|
| 'bisFormat'
|
||||||
|
| 'multiMonthRange'
|
||||||
|
| 'noRows';
|
||||||
|
|
||||||
|
export interface ValidationError {
|
||||||
|
row: number;
|
||||||
|
field: string;
|
||||||
|
code: KantineErrorCode;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationWarning {
|
||||||
|
code: 'multipleMonths';
|
||||||
|
message: string;
|
||||||
|
params: { months: string[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
errors: ValidationError[];
|
||||||
|
warnings: ValidationWarning[];
|
||||||
|
/** Format "MM/YYYY" */
|
||||||
|
abrechnungsMonat: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nummern, die der Administrator je Mandant hinterlegt. */
|
||||||
|
export interface KantineDatevSettings {
|
||||||
|
beraterNr: string;
|
||||||
|
mandantNr: string;
|
||||||
|
lohnart: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KantineBlockedReason = 'settingsMissing' | 'errors';
|
||||||
|
|
||||||
|
export interface KantinePreview {
|
||||||
|
rowCount: number;
|
||||||
|
abrechnungsMonat: string | null;
|
||||||
|
/** Summe der gueltigen Betraege in Cent. */
|
||||||
|
totalCents: number;
|
||||||
|
errors: ValidationError[];
|
||||||
|
warnings: ValidationWarning[];
|
||||||
|
canExport: boolean;
|
||||||
|
blockedReason: KantineBlockedReason | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileResponse {
|
||||||
|
filename: string;
|
||||||
|
/** Base64 */
|
||||||
|
content: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user