Compare commits
66 Commits
76a923450f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| af78157536 | |||
| 7e70fc4d32 | |||
| f78b422abf | |||
| c846eb49cb | |||
| af87c2c112 | |||
| bd73fa5e74 | |||
| 86ad95f74e | |||
| 12214a948e | |||
| 071082983b | |||
| c2e4467dd8 | |||
| be1e0035e0 | |||
| f7213f5e45 | |||
| e72814abd9 | |||
| 0e72ad45f8 | |||
| b15c74632b | |||
| 7188c5b958 | |||
| 8c644de5da | |||
| 6879c756f2 | |||
| 709b41a007 | |||
| 325c5ddbf2 | |||
| cd1f8f6cda | |||
| 41d00a3623 | |||
| d0e649baa1 | |||
| cbc89d9810 | |||
| 3cb43d6cc0 | |||
| 4ff9a239fd | |||
| 00c2cfe2c9 | |||
| cd4b5b56ee | |||
| b15a43f3d3 | |||
| 8f41bd26bd | |||
| ee97b4ed9f | |||
| bc4c0119de | |||
| c703d87a1c |
@@ -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>
|
||||||
+15
-6
@@ -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-23
|
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-29 - Quick 260929-9wc (Eigene Module) + 260929-d37 (Desktop-App nur einmal starten); main auf beta gepusht, live unveraendert auf 1.5.2
|
Last activity: 2026-10-02 - Quick 261002-icv: Freigabestufe Verwalten (lokal, nicht gepusht); 261002-fm5 Finanzbuchhaltung gepusht (CI 484 gruen)
|
||||||
|
|
||||||
Progress: [██████████] 99%
|
Progress: [██████████] 99%
|
||||||
|
|
||||||
@@ -480,6 +480,15 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
|
|||||||
| 260929-9wc | **Eigene Module (nur lokal, nicht gepusht).** Modell `CustomModule` + Migration 20260929120000 mit RLS (Muster ProxmoxServer), `/custom-modules` (GET alle Angemeldeten, POST/PATCH/DELETE Admin, nur https ohne Zugangsdaten), `MODULE_CATEGORIES` in packages/shared, Seitenleisten-Eintrag unter gewaehlter Kategorie, Rahmen-Seite `/modules/custom/[id]` mit XFRAME_SANDBOX + no-referrer + „In neuem Tab öffnen“, Verwaltung `/admin/custom-modules`, Zugriffsklassifikation 61/224/6. Gruppen-Beschraenkung zurueckgestellt (ModuleGrant haengt an Module). api 1495, web 992 gruen; Browser dunkel 9 Schritte bestanden. | 2026-09-29 | b9d87be,e7fc4de,e48c0de | [260929-9wc-eigene-module-admin-legt-seitenleisten-e](./quick/260929-9wc-eigene-module-admin-legt-seitenleisten-e/) |
|
| 260929-9wc | **Eigene Module (nur lokal, nicht gepusht).** Modell `CustomModule` + Migration 20260929120000 mit RLS (Muster ProxmoxServer), `/custom-modules` (GET alle Angemeldeten, POST/PATCH/DELETE Admin, nur https ohne Zugangsdaten), `MODULE_CATEGORIES` in packages/shared, Seitenleisten-Eintrag unter gewaehlter Kategorie, Rahmen-Seite `/modules/custom/[id]` mit XFRAME_SANDBOX + no-referrer + „In neuem Tab öffnen“, Verwaltung `/admin/custom-modules`, Zugriffsklassifikation 61/224/6. Gruppen-Beschraenkung zurueckgestellt (ModuleGrant haengt an Module). api 1495, web 992 gruen; Browser dunkel 9 Schritte bestanden. | 2026-09-29 | b9d87be,e7fc4de,e48c0de | [260929-9wc-eigene-module-admin-legt-seitenleisten-e](./quick/260929-9wc-eigene-module-admin-legt-seitenleisten-e/) |
|
||||||
| 260929-d37 | **Desktop-App nur einmal starten.** User-Meldung Windows 11: beim Systemstart zwei Instanzen/zwei Tray-Symbole. `tauri-plugin-single-instance` 2.4.5 als erstes Plugin, zweiter Start ruft `show_main_window` (neuer Helper, ersetzt 3 Kopien) und beendet sich. cargo build/test (44)/clippy gruen. Windows-Pruefung offen (VM 8233 oder User-PC nach naechster Desktop-Version). | 2026-09-29 | c0b145a,0751198 | [260929-d37-desktop-client-nur-einmal-starten-single](./quick/260929-d37-desktop-client-nur-einmal-starten-single/) |
|
| 260929-d37 | **Desktop-App nur einmal starten.** User-Meldung Windows 11: beim Systemstart zwei Instanzen/zwei Tray-Symbole. `tauri-plugin-single-instance` 2.4.5 als erstes Plugin, zweiter Start ruft `show_main_window` (neuer Helper, ersetzt 3 Kopien) und beendet sich. cargo build/test (44)/clippy gruen. Windows-Pruefung offen (VM 8233 oder User-PC nach naechster Desktop-Version). | 2026-09-29 | c0b145a,0751198 | [260929-d37-desktop-client-nur-einmal-starten-single](./quick/260929-d37-desktop-client-nur-einmal-starten-single/) |
|
||||||
| 260929-dmx | **Widget-Raster horizontal feiner + Kalender schmaler.** COLS lg 48/md 40/sm 24/xs 16/xxs 4, GRID_VERSION 3 (v2->v3 nur x/w/minW/maxW x2), alle minW/defaultW x2, Kalender minW 8 (~250 px). Browser: Anordnung pixelgleich, Kalender bis 252 px, Schritt 33 px. Auch: Hover-Anheben der Widgets entfernt (acd3c7a, Nutzerwunsch). | 2026-09-29 | 97744b5,9c9e142,46ebb4e | [260929-dmx-widget-raster-horizontal-feiner-48-spalt](./quick/260929-dmx-widget-raster-horizontal-feiner-48-spalt/) |
|
| 260929-dmx | **Widget-Raster horizontal feiner + Kalender schmaler.** COLS lg 48/md 40/sm 24/xs 16/xxs 4, GRID_VERSION 3 (v2->v3 nur x/w/minW/maxW x2), alle minW/defaultW x2, Kalender minW 8 (~250 px). Browser: Anordnung pixelgleich, Kalender bis 252 px, Schritt 33 px. Auch: Hover-Anheben der Widgets entfernt (acd3c7a, Nutzerwunsch). | 2026-09-29 | 97744b5,9c9e142,46ebb4e | [260929-dmx-widget-raster-horizontal-feiner-48-spalt](./quick/260929-dmx-widget-raster-horizontal-feiner-48-spalt/) |
|
||||||
|
| 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-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/) |
|
||||||
|
|
||||||
## Deferred Items
|
## Deferred Items
|
||||||
|
|
||||||
@@ -521,8 +530,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-9wc (Eigene Module) + 260929-d37 (Desktop-App nur einmal starten); main auf beta gepusht, live unveraendert auf 1.5.2
|
Last activity: 2026-09-29 - Quick 260929-if2 Erinnerungen-Widget (lokal, nicht gepusht); v1.7.0 auf alpha+live
|
||||||
|
|||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
quick_id: 260929-dzu
|
||||||
|
type: quick
|
||||||
|
wave: 1
|
||||||
|
autonomous: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260929-dzu: Eigene Module für jeden Benutzer (persönlich)
|
||||||
|
|
||||||
|
## User request (29.09.2026)
|
||||||
|
|
||||||
|
"Jeder User soll eigene Module anlegen können. nicht nur admins."
|
||||||
|
Decision (AskUserQuestion, locked): **"Nur er selbst"** — a normal user's entries are visible ONLY to that user.
|
||||||
|
Admins keep creating shared entries (visible to everyone) on /admin/custom-modules as today.
|
||||||
|
No user can put anything into another user's sidebar.
|
||||||
|
|
||||||
|
## Existing state (quick 260929-9wc, commits b9d87be, e7fc4de)
|
||||||
|
|
||||||
|
- Prisma `CustomModule { id, tenantId, name, url, category, createdAt, updatedAt }`, migration
|
||||||
|
`20260929120000_custom_module` with RLS (tenant only, pattern ProxmoxServer).
|
||||||
|
- API `apps/api/src/custom-modules/*`: GET list/one for any authenticated user; POST/PATCH/DELETE admin only;
|
||||||
|
https-only, no credentials in URL.
|
||||||
|
- Web: sidebar loads `listCustomModules()`, frame page `/modules/custom/[id]`, admin page `/admin/custom-modules`
|
||||||
|
with `CustomModuleFormModal` + `DeleteCustomModuleDialog`, `bumpSidebarRefresh` after changes.
|
||||||
|
|
||||||
|
## Task 1: Model + API (tests first)
|
||||||
|
|
||||||
|
- Add nullable `ownerUserId String?` (+ relation to User with onDelete: Cascade, index `[tenantId, ownerUserId]`)
|
||||||
|
via NEW migration (e.g. `20260929130000_custom_module_owner`). `null` = shared (admin-made), set = personal.
|
||||||
|
- RLS: extend the existing policy the way user-scoped tables already do it (find the pattern used by e.g.
|
||||||
|
DashboardImage / Favorite / other tables with a user dimension). Personal rows must only be readable/writable by
|
||||||
|
their owner; shared rows readable by the whole tenant. If the project's RLS pattern handles the user dimension
|
||||||
|
in the service layer instead, follow that pattern and document it. Update the RLS inventory test and
|
||||||
|
`docs/mandantentrennung-zugriffsklassifikation.md` (re-measure totals as last time).
|
||||||
|
- Service/controller:
|
||||||
|
- `GET /custom-modules` → shared rows + rows owned by the caller. Response carries `personal: boolean` (or `ownerUserId === me`).
|
||||||
|
- `GET /custom-modules/:id` → 404 unless shared or owned by caller.
|
||||||
|
- `POST /custom-modules` → any authenticated user; body flag `shared?: boolean`. `shared: true` only allowed for admins
|
||||||
|
(403 otherwise); default personal (ownerUserId = caller). The admin page sends `shared: true`.
|
||||||
|
- `PATCH` / `DELETE` → personal rows: only the owner (404 for others, do not leak existence); shared rows: admin only (403 for non-admin).
|
||||||
|
Ownership/shared-ness cannot be changed via PATCH.
|
||||||
|
- Keep URL validation. Keep static routes before `:id`.
|
||||||
|
- Admin page list: `GET /custom-modules?scope=shared` (admin) or filter client-side — pick the simplest; the admin page shows only shared entries; the settings page only the caller's personal ones.
|
||||||
|
- Tests: service + controller specs for all permission cases (user A cannot see/edit/delete user B's entry; non-admin cannot create/edit/delete shared; admin personal vs shared).
|
||||||
|
- verify: `pnpm --filter @tessera/api exec vitest run src/custom-modules` + RLS inventory test green; migrate local DB (db container IP 172.19.x, tessera/tessera_dev), rebuild api, curl check.
|
||||||
|
|
||||||
|
## Task 2: Web — settings section
|
||||||
|
|
||||||
|
- Settings: new section/page "Eigene Module" in the user settings (`apps/web/src/app/(portal)/settings/`, follow how
|
||||||
|
`general` / `dashboard` sub-pages and their nav are built). Reuse `CustomModuleFormModal` and
|
||||||
|
`DeleteCustomModuleDialog` (move to a shared location if needed, e.g. `components/custom-modules/`) — one form, two callers.
|
||||||
|
Intro text (Sie-Form): e.g. "Nehmen Sie Webseiten, die Sie oft brauchen, als eigene Einträge in Ihre Seitenleiste auf. Diese Einträge sehen nur Sie."
|
||||||
|
- Admin page: shows only shared entries; intro text states they are visible for all users.
|
||||||
|
- Sidebar: unchanged behavior, shows shared + own personal entries (API already filters). `bumpSidebarRefresh` after changes on the settings page too.
|
||||||
|
- de + en texts; umlaut dictionary if needed.
|
||||||
|
- Tests: component tests for the settings page (create/edit/delete, list only personal), admin page still passes `shared: true`.
|
||||||
|
- verify: `pnpm --filter @tessera/web exec vitest run` green; `pnpm turbo run type-check lint` green; biome web ≤ 55, api ≤ 82.
|
||||||
|
|
||||||
|
## Task 3: CHANGELOG + rebuild
|
||||||
|
|
||||||
|
- CHANGELOG `## Unveröffentlicht` → adjust the existing "Eigene Module" bullet under "Neu" (not released yet, so rewrite it):
|
||||||
|
every user can add own entries under "Einstellungen → Eigene Module", visible only to them; administrators can additionally add entries for everyone under "Verwaltung → Eigene Module". Plain German, Sie-Form.
|
||||||
|
- Update `docs/anleitung-anwender.md` (and admin guide if it mentions custom modules) accordingly.
|
||||||
|
- `docker compose up -d --build web api`.
|
||||||
|
- Commits per task, end with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`. NEVER git push.
|
||||||
|
- Browser check is done by the orchestrator (normal user + admin, dark mode).
|
||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260929-dzu
|
||||||
|
plan: 01
|
||||||
|
quick_id: 260929-dzu
|
||||||
|
subsystem: api, web, prisma
|
||||||
|
tags: [custom-modules, personal, rls, settings]
|
||||||
|
status: complete
|
||||||
|
requires: [260929-9wc]
|
||||||
|
provides:
|
||||||
|
- Spalte CustomModule.ownerUserId (NULL = gemeinsam, gesetzt = persoenlich), Migration 20260929130000
|
||||||
|
- Zeilenschutz mit Benutzerdimension nach Muster SearchProvider
|
||||||
|
- API /custom-modules mit persoenlichen und gemeinsamen Eintraegen (Antwortfeld personal)
|
||||||
|
- Einstellungen > Eigene Module (/settings/custom-modules) fuer jeden Benutzer
|
||||||
|
- gemeinsame Oberflaeche CustomModuleManager (Formular, Loeschdialog, Liste) fuer Verwaltung und Einstellungen
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- apps/api/prisma/migrations/20260929130000_custom_module_owner/migration.sql
|
||||||
|
- apps/web/src/components/custom-modules/custom-module-manager.tsx
|
||||||
|
- apps/web/src/app/(portal)/settings/custom-modules/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/settings/custom-modules/custom-modules-settings.test.tsx
|
||||||
|
modified:
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/src/custom-modules/ (Dienst, Controller, DTO, Specs)
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
- apps/web/src/components/custom-modules/custom-module-form-modal.tsx (verschoben aus admin/custom-modules/components)
|
||||||
|
- apps/web/src/components/custom-modules/delete-custom-module-dialog.tsx (verschoben)
|
||||||
|
- apps/web/src/app/(portal)/admin/custom-modules/page.tsx (+ Test)
|
||||||
|
- apps/web/src/components/settings/settings-sidebar.tsx
|
||||||
|
- apps/web/src/lib/custom-modules-api.ts
|
||||||
|
- apps/web/src/messages/de.json, en.json
|
||||||
|
- CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md
|
||||||
|
decisions:
|
||||||
|
- "RLS: Muster SearchProvider (nullable Besitzerspalte, vier Regeln je Befehl), nicht die einfache Muster DashboardImage (Pflicht-userId)"
|
||||||
|
- "Gemeinsame Eintraege werden ohne Benutzerkontext geschrieben (forTenant ohne userId), persoenliche mit Benutzer"
|
||||||
|
- "Rollenpruefung fuer gemeinsame Eintraege im Dienst statt per @Roles, weil sie vom Eintrag abhaengt"
|
||||||
|
- "Filter fuer Verwaltung/Einstellungen im Web ueber personal, kein scope-Parameter in der API"
|
||||||
|
- "Texte von Formular und Loeschdialog in eigenen Namensraum customModules.form, Umzug aus admin.customModules"
|
||||||
|
completed: 2026-09-29
|
||||||
|
commits: 3
|
||||||
|
plan_head_before: 76a923450fd2f492ec046d5945a6965c8e94b707
|
||||||
|
plan_head_after: 8f41bd26bd3eddee1979485281cb375ade52949d
|
||||||
|
actuals:
|
||||||
|
tokens: 42000
|
||||||
|
tasks: 3
|
||||||
|
commits: 3
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase quick-260929-dzu Plan 01: Eigene Module fuer jeden Benutzer Summary
|
||||||
|
|
||||||
|
Jeder angemeldete Benutzer legt unter Einstellungen > Eigene Module persoenliche Seitenleisten-Eintraege an, die nur er sieht; Administratoren pflegen weiter gemeinsame Eintraege unter Verwaltung > Eigene Module (Senden von `shared: true`). Niemand kann etwas in die Seitenleiste eines anderen Benutzers legen.
|
||||||
|
|
||||||
|
## Was gebaut wurde
|
||||||
|
|
||||||
|
**Aufgabe 1, Commit c703d87 (Modell + API, Tests zuerst angepasst)**
|
||||||
|
- Schema: `ownerUserId String?` mit Relation zu `User` (`onDelete: Cascade`), Index `[tenantId, ownerUserId]`; Gegenfeld `customModules` am `User`.
|
||||||
|
- Migration `20260929130000_custom_module_owner` (von Hand, mit Kopfkommentar): Spalte, Index, Fremdschluessel, alte Regel ersetzt durch vier Regeln.
|
||||||
|
- Dienst/Controller: `GET /custom-modules` liefert gemeinsame plus eigene Zeilen mit `personal: boolean` (ownerUserId wird nicht ausgeliefert); `GET :id` 404 bei fremdem persoenlichem Eintrag (auch fuer Administratoren); `POST` fuer jeden Angemeldeten, `shared: true` nur fuer ADMIN/SUPER_ADMIN (sonst 403), Standard persoenlich; `PATCH`/`DELETE`: persoenlich nur Besitzer (fremd: 404), gemeinsam nur Administrator (sonst 403). `shared`/`ownerUserId` sind per PATCH nicht aenderbar (`OmitType` im DTO plus `whitelist`).
|
||||||
|
- Routen: `list` steht weiter vor `getOne`; kein `@Roles` mehr an den Schreibrouten, die Rollenpruefung sitzt im Dienst.
|
||||||
|
- Specs: Dienst (22 Faelle: A sieht/aendert/loescht B nicht, Nicht-Admin nicht shared, Admin persoenlich vs. gemeinsam, RLS-Bindung mit/ohne Benutzer), Controller, DTO-Pipe-Faelle.
|
||||||
|
- Zugriffsklassifikation nachgemessen (siehe unten).
|
||||||
|
|
||||||
|
**Aufgabe 2, Commit ee97b4e (Web)**
|
||||||
|
- Neue Seite `/settings/custom-modules` und Nav-Eintrag „Eigene Module“ unter „Allgemein“.
|
||||||
|
- Gemeinsame Komponenten unter `components/custom-modules/`: `CustomModuleFormModal` und `DeleteCustomModuleDialog` (verschoben, Parameter `shared`) plus neu `CustomModuleManager` (Liste, Anlegen/Bearbeiten/Loeschen, `bumpSidebarRefresh`), aufgerufen mit `scope="shared"` (Verwaltung) oder `scope="personal"` (Einstellungen). Filter ueber `personal` im Web.
|
||||||
|
- Verwaltung sendet beim Anlegen `shared: true`, zeigt nur gemeinsame Eintraege, Einleitung nennt „alle Benutzer“; Einstellungen senden kein `shared`, Einleitung: „Diese Einträge sehen nur Sie.“
|
||||||
|
- Texte de/en (Namensraeume `customModules.form`, `customModules.manage`, `settings.customModules`), Umlaut-Waechter gruen.
|
||||||
|
- Tests: neuer Settings-Test (7), Admin-Test angepasst (`shared: true`, Filter; 14).
|
||||||
|
|
||||||
|
**Aufgabe 3, Commit 8f41bd2 (Doku) + Neubau**
|
||||||
|
- CHANGELOG-Punkt „Eigene Module“ umgeschrieben (Einstellungen fuer jeden, Verwaltung zusaetzlich fuer alle), `docs/anleitung-anwender.md` (Abschnitt „Allgemein > Eigene Module“) und `docs/anleitung-administration.md` (Unterabschnitt bei 5.).
|
||||||
|
- `docker compose up -d --build web api`: web :3000/login 200, api /health ok, `GET /custom-modules` anonym 401, `/settings/custom-modules` ohne Anmeldung 307 (Umleitung auf Login).
|
||||||
|
|
||||||
|
## RLS-Muster und Begruendung
|
||||||
|
|
||||||
|
Gefolgt bin ich dem Muster **SearchProvider** aus `20260911120000_rls_user_dimension_personal_tables`: nullable Besitzerspalte, vier nach Befehl getrennte Regeln.
|
||||||
|
- SELECT: Mandant UND (kein Benutzer gesetzt ODER `ownerUserId IS NULL` ODER `ownerUserId = current_user_id()`).
|
||||||
|
- INSERT/UPDATE/DELETE: Mandant UND (kein Benutzer gesetzt ODER `ownerUserId = current_user_id()`).
|
||||||
|
|
||||||
|
Warum nicht das einfachere Muster DashboardImage/Favorite (Pflicht-`userId`, eine Regel): eigene Module haben gemeinsame Zeilen (`NULL`), die jeder lesen, aber nur ein Administrator schreiben darf. Eine einzelne Regel, die die gemeinsame Zeile zum Lesen freigibt, wuerde sie auch zum Aendern/Loeschen freigeben (Praezedenz 260910-jab (3)), deshalb getrennte Befehle. Folge: ein Benutzerkontext kann gemeinsame Zeilen nicht schreiben; der Dienst bindet Schreibzugriffe auf gemeinsame Eintraege deshalb bewusst OHNE Benutzer (`forTenant(prisma, tenantId)`), nachdem er die Administrator-Rolle geprueft hat. Persoenliche Zugriffe binden mit Benutzer. Wie bei allen RLS-Regeln wirkt der Schutz erst mit dem Datenbankrollen-Schalter (heute AUS); bis dahin tragen die Anwendungspruefungen (`row.tenantId`, `ownerUserId`) den Schutz.
|
||||||
|
|
||||||
|
## Curl-Pruefung (lokal, echte API :3001)
|
||||||
|
|
||||||
|
Benutzer: admin (SUPER_ADMIN), testuser (USER), curltmp (USER, nur fuer die Pruefung angelegt und danach geloescht).
|
||||||
|
|
||||||
|
| Fall | Ergebnis |
|
||||||
|
|------|----------|
|
||||||
|
| USER legt Eintrag ohne shared an | 200, `personal: true` |
|
||||||
|
| USER `shared: true` | 403 „Gemeinsame Einträge dürfen nur Administratoren anlegen“ |
|
||||||
|
| Admin `shared: true` | 200, `personal: false` |
|
||||||
|
| Admin ohne shared | 200, `personal: true` |
|
||||||
|
| Liste USER | gemeinsam + eigener |
|
||||||
|
| Liste zweiter USER | nur gemeinsam |
|
||||||
|
| Liste Admin | nur gemeinsam (persoenliche Eintraege anderer nicht) |
|
||||||
|
| zweiter USER: GET / PATCH / DELETE auf fremden persoenlichen Eintrag | 404 / 404 / 404 |
|
||||||
|
| Admin: GET / DELETE auf persoenlichen Eintrag eines Benutzers | 404 / 404 |
|
||||||
|
| USER GET gemeinsam | 200 |
|
||||||
|
| USER PATCH / DELETE gemeinsam | 403 / 403 |
|
||||||
|
| Admin PATCH gemeinsam (mit eingeschmuggeltem `shared:false`) | 200, bleibt gemeinsam |
|
||||||
|
| USER PATCH eigenen mit `shared:true`, `ownerUserId:null` | 200, bleibt persoenlich |
|
||||||
|
| http-Adresse | 400 |
|
||||||
|
| anonym | 401 |
|
||||||
|
| Benutzer loeschen -> seine persoenlichen Eintraege | Cascade, 0 Zeilen |
|
||||||
|
|
||||||
|
Alle Testeintraege sind geloescht, `CustomModule` ist leer.
|
||||||
|
|
||||||
|
## Tore (gemessen)
|
||||||
|
|
||||||
|
| Tor | Ergebnis |
|
||||||
|
|-----|----------|
|
||||||
|
| API-Tests vollstaendig | 88 Dateien, 1511 Tests gruen |
|
||||||
|
| Web-Tests vollstaendig | 103 Dateien, 1003 Tests gruen |
|
||||||
|
| `pnpm turbo run type-check lint --force` | 9/9 erfolgreich |
|
||||||
|
| Biome-Warnungen Web / API | 55 (Grundlinie 55) / 82 (Grundlinie 82) |
|
||||||
|
| rls-coverage / rls-access-inventory | gruen |
|
||||||
|
| `prisma migrate deploy` lokal (Container-IP 172.19.0.2) | Migration angewendet, `migrate diff` danach leer |
|
||||||
|
| Zugriffsklassifikation | Gate-Schleife 61/223/6 (vorher 61/224/6); `custom-modules` 0/6/0 |
|
||||||
|
|
||||||
|
## Testbenutzer fuer die Browser-Pruefung des Orchestrators
|
||||||
|
|
||||||
|
Es gab lokal schon die Nicht-Admin-Konten `nutzer1` und `nutzer2`, deren Passwoerter aber nicht bekannt sind. Deshalb habe ich per Admin-API angelegt: Login **testuser**, Passwort **Test1234!test** (Rolle USER, `mustChangePassword` auf false gesetzt, damit die Anmeldung nicht auf die Passwort-Seite umleitet). Der Administrator ist wie gehabt admin / admin123.
|
||||||
|
|
||||||
|
Vorschlag fuer die Browser-Pruefung (dunkel): als testuser unter Einstellungen > Allgemein > Eigene Module einen Eintrag anlegen (Seitenleiste zieht ohne Neuladen nach), als admin unter Verwaltung > Eigene Module einen gemeinsamen Eintrag anlegen (testuser sieht ihn in der Seitenleiste, kann ihn unter Einstellungen aber nicht bearbeiten), als admin pruefen, dass der persoenliche Eintrag von testuser weder in Seitenleiste noch Verwaltung erscheint. Danach die Testeintraege loeschen.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 3 - Blocking] Festplatte voll (0 Byte frei) mitten in der Arbeit**
|
||||||
|
- **Found during:** Aufgabe 2 (Biome meldete „No space left on device“)
|
||||||
|
- **Issue:** die Docker-Build-Cache-Ablagen der Neubauten fuellten die Platte.
|
||||||
|
- **Fix:** `docker builder prune -f` (nur Build-Cache, 17,97 GB, keine Images, Container oder Volumes); danach type-check/lint/Tests frisch und vollstaendig wiederholt, alle gruen.
|
||||||
|
- **Commit:** kein Code betroffen.
|
||||||
|
|
||||||
|
**2. [Rule 1 - Bug] Detektor-Vorgaben fuer `forTenant`**
|
||||||
|
- **Found during:** Aufgabe 1 (rls-access-inventory schlug zweimal fehl)
|
||||||
|
- **Issue:** eine Ternary-Bindung (`shared ? forTenant(..) : forTenant(..)`) und ein `client.customModule.create` in einer Hilfsfunktion werden vom Detektor nicht als Zuweisungsform/Modellaufruf erkannt.
|
||||||
|
- **Fix:** je Zweig `const tenantPrisma = forTenant(...)` mit direktem Modellaufruf; Ausnahmeliste unveraendert leer.
|
||||||
|
- **Files modified:** `apps/api/src/custom-modules/custom-modules.service.ts`
|
||||||
|
- **Commit:** c703d87
|
||||||
|
|
||||||
|
**3. Plan-Feinheit:** Kein API-Parameter `scope`; die Verwaltung filtert im Web ueber `personal` (Plan liess beides zu, „das Einfachste“). Nebenwirkung: die Verwaltungsseite laedt auch die eigenen persoenlichen Eintraege des Administrators und blendet sie aus.
|
||||||
|
|
||||||
|
**4. Plan-Feinheit:** Formular-/Dialog-Texte aus `admin.customModules` in den neuen Namensraum `customModules.form` umgezogen (beide Aufrufer teilen sie); Admin-Test entsprechend angepasst. Die Anleitung des Anwenders hatte den Punkt „Eigene Module“ vorher nicht, er ist jetzt neu beschrieben (der Plan sprach von „aktualisieren“).
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
|
||||||
|
- Zwischen c703d87 und ee97b4e liegt ein fremder Commit `bc4c011` (fix(web) Widgets nicht mehr zur Mitte versetzen), nicht von diesem Plan; er beruehrt CHANGELOG.md und `docs/anleitung-anwender.md` an anderen Stellen. Die 3 Commits dieses Plans sind c703d87, ee97b4e, 8f41bd2 (`git rev-list` ab dem Vorgaenger von c703d87 zaehlt 4 inklusive des fremden). Der Ledger nach Protokoll 0c wurde nicht vor dem ersten Commit angelegt, `plan_head_before` ist deshalb der Vorgaenger von c703d87.
|
||||||
|
- Die Verwaltungs-Nav zeigt weiterhin „Eigene Module“; sie fuehrt jetzt auf die gemeinsamen Eintraege, die Einleitung nennt das.
|
||||||
|
- Nichts gepusht.
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
Keine.
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
Keine neue Angriffsflaeche ausserhalb des bestehenden Modells: `ownerUserId` kommt nie aus dem Body (Whitelist, im Test belegt), `shared` ist per PATCH nicht setzbar, fremde persoenliche Eintraege sind ununterscheidbar 404.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- Dateien vorhanden: Migration `20260929130000_custom_module_owner`, `custom-module-manager.tsx`, `settings/custom-modules/page.tsx`, Settings-Test.
|
||||||
|
- Commits vorhanden: c703d87, ee97b4e, 8f41bd2 (`git log`); nichts gepusht (`git branch -r --contains HEAD` leer).
|
||||||
|
|
||||||
|
## Browser-Pruefung (Orchestrator, 29.09.)
|
||||||
|
|
||||||
|
- testuser: Einstellungen > Eigene Module vorhanden; „Meine Seite“ angelegt -> sofort in eigener Seitenleiste (Infrastruktur).
|
||||||
|
- admin: sieht „Meine Seite“ weder in Seitenleiste noch Verwaltung; Direktlink zeigt „Dieses Modul gibt es nicht mehr.“; Verwaltung heisst „Gemeinsamen Eintrag anlegen“.
|
||||||
|
- admin legt „Firmenseite“ (Sicherheit) an -> testuser sieht sie in der Seitenleiste, nicht in seinen Einstellungen; DELETE als testuser -> 403.
|
||||||
|
- Nebenbei: Widgets nicht mehr zentriert (bc4c011) — alle linken Kanten am Raster (272 px bei Rasterbeginn 260 + 12 Rand).
|
||||||
|
- Testeintraege geloescht, CustomModule leer.
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260929-if2
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/prisma/migrations/20260929140000_reminder/migration.sql
|
||||||
|
- apps/api/src/app.module.ts
|
||||||
|
- apps/api/src/reminders/reminders.module.ts
|
||||||
|
- apps/api/src/reminders/reminders.controller.ts
|
||||||
|
- apps/api/src/reminders/reminders.controller.spec.ts
|
||||||
|
- apps/api/src/reminders/reminders.service.ts
|
||||||
|
- apps/api/src/reminders/reminders.service.spec.ts
|
||||||
|
- apps/api/src/reminders/dto/reminder.dto.ts
|
||||||
|
- apps/api/src/reminders/reminder-mail.scheduler.ts
|
||||||
|
- apps/api/src/reminders/reminder-mail.scheduler.spec.ts
|
||||||
|
- apps/api/src/mail/mail.service.ts
|
||||||
|
- apps/api/src/mail/mail.service.spec.ts
|
||||||
|
- apps/api/src/prisma/rls-access-inventory.spec.ts
|
||||||
|
- packages/shared/src/index.ts
|
||||||
|
- apps/web/src/lib/reminders-api.ts
|
||||||
|
- apps/web/src/lib/reminders-api.test.ts
|
||||||
|
- apps/web/src/lib/reminder-notify.ts
|
||||||
|
- apps/web/src/lib/reminder-notify.test.ts
|
||||||
|
- apps/web/src/lib/reminder-time.ts
|
||||||
|
- apps/web/src/lib/reminder-time.test.ts
|
||||||
|
- apps/web/src/components/reminders/reminder-notifier.tsx
|
||||||
|
- apps/web/src/components/reminders/reminder-notifier.test.tsx
|
||||||
|
- apps/web/src/components/layout/app-shell.tsx
|
||||||
|
- apps/web/src/components/dashboard/widgets/reminder-widget.tsx
|
||||||
|
- apps/web/src/components/dashboard/widgets/reminder-widget.test.tsx
|
||||||
|
- apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx
|
||||||
|
- apps/web/src/components/dashboard/widget-registry.tsx
|
||||||
|
- apps/web/src/components/dashboard/widgets/widget-icon.tsx
|
||||||
|
- apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
|
||||||
|
- apps/web/src/app/(portal)/page.tsx
|
||||||
|
- apps/web/src/messages/de.json
|
||||||
|
- apps/web/src/messages/en.json
|
||||||
|
- apps/web/src/messages/umlaut-dictionary.ts
|
||||||
|
- apps/desktop/src-tauri/src/lib.rs
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
- docs/anleitung-anwender.md
|
||||||
|
- CHANGELOG.md
|
||||||
|
autonomous: true
|
||||||
|
requirements: [QUICK-260929-if2]
|
||||||
|
|
||||||
|
estimate:
|
||||||
|
tokens: 260000
|
||||||
|
raw_tokens: 260000
|
||||||
|
tasks: 3
|
||||||
|
confidence: low
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "A user adds the dashboard widget 'Erinnerungen', creates a reminder with date, time, title and description in local time, and sees only their own open reminders, sorted by due time (D-05)"
|
||||||
|
- "At the due time (D-02, no advance warning) an open Tessera browser tab shows a Web Notification once permission was granted. Permission is asked only from the widget on the first creation, never on page load (D-04)"
|
||||||
|
- "At the due time the desktop app shows a native OS notification, also while the main window is hidden in the tray, through the notification plugin, which the page may call only from the stored server origin"
|
||||||
|
- "Every client shows each (reminder id, dueAt) at most once: tabs of one browser share the local claim, and the desktop app and the browser each notify once"
|
||||||
|
- "A due reminder stays in the widget, highlighted, with 'Erledigt' (removes it) and 'Später erinnern' (+10 min, +1 h, tomorrow at the same time). Snoozing sets a new dueAt, so notifications fire again and the e-mail is sent again when enabled (D-01, D-03)"
|
||||||
|
- "Upcoming reminders can be edited and deleted. Editing a due reminder is rejected with 409, snoozing a reminder that is not due yet is rejected with 409"
|
||||||
|
- "With 'zusätzlich per E-Mail' on, the server sends exactly one e-mail per due occurrence through the tenant SMTP config (time shown in Europe/Berlin), without any open client and also with several API instances (atomic claim). The toggle is disabled with an explanation when SMTP is not configured or the user has no e-mail"
|
||||||
|
- "A foreign reminder id always returns 404 (never 403). The Reminder table has a tenant+user RLS policy and a system read policy. rls-coverage and rls-access-inventory stay green, and the classification doc is re-measured"
|
||||||
|
- "All API and web tests are green, type-check and lint are green, Biome warnings stay at web <= 55 and api <= 82, and cargo test/fmt/clippy are green"
|
||||||
|
artifacts:
|
||||||
|
- path: "apps/api/prisma/migrations/20260929140000_reminder/migration.sql"
|
||||||
|
provides: "Reminder table, FK to User with cascade, indexes, RLS ENABLE+FORCE, tenant_isolation_policy with user dimension, system_read_policy FOR SELECT"
|
||||||
|
contains: "system_read_policy"
|
||||||
|
- path: "apps/api/src/reminders/reminders.service.ts"
|
||||||
|
provides: "Owner-scoped CRUD, snooze, email availability, all via forTenant(prisma, tenantId, userId)"
|
||||||
|
- path: "apps/api/src/reminders/reminders.controller.ts"
|
||||||
|
provides: "GET /reminders, GET /reminders/email-status, POST /reminders, PATCH /reminders/:id, POST /reminders/:id/snooze, DELETE /reminders/:id"
|
||||||
|
- path: "apps/api/src/reminders/reminder-mail.scheduler.ts"
|
||||||
|
provides: "30-second global tick, reads candidates through the system context, then claims and sends each one tenant-bound"
|
||||||
|
- path: "apps/web/src/lib/reminder-notify.ts"
|
||||||
|
provides: "Tauri-vs-browser notification helper, one-time permission request, local dedup claim with Web Locks"
|
||||||
|
- path: "apps/web/src/components/reminders/reminder-notifier.tsx"
|
||||||
|
provides: "Global notifier mounted in AppShell, polls and fires on due"
|
||||||
|
- path: "apps/web/src/components/dashboard/widgets/reminder-widget.tsx"
|
||||||
|
provides: "Erinnerungen widget: list, due highlight, create/edit modal, Erledigt, Später erinnern, e-mail toggle"
|
||||||
|
- path: "apps/desktop/src-tauri/src/lib.rs"
|
||||||
|
provides: "server_origin_pattern() (host escaped for URLPattern, self-checked with tauri::utils::acl::RemoteUrlPattern, None when it does not parse or match) + grant_server_notifications() runtime remote capability; server_origin_* unit tests pin the exact 3-permission set, the IPv6 and wildcard-host patterns and the match/no-match behavior"
|
||||||
|
key_links:
|
||||||
|
- from: "apps/web/src/components/layout/app-shell.tsx"
|
||||||
|
to: "apps/web/src/components/reminders/reminder-notifier.tsx"
|
||||||
|
via: "<ReminderNotifier /> next to <ReleaseNoticeHost />, so notifications fire on every portal page and not only when the widget is visible"
|
||||||
|
pattern: "ReminderNotifier"
|
||||||
|
- from: "apps/web/src/lib/reminder-notify.ts"
|
||||||
|
to: "tauri-plugin-notification"
|
||||||
|
via: "window.__TAURI_INTERNALS__.invoke('plugin:notification|notify', { options: { title, body } })"
|
||||||
|
pattern: "plugin:notification\\|notify"
|
||||||
|
- from: "apps/desktop/src-tauri/src/lib.rs"
|
||||||
|
to: "tauri runtime authority"
|
||||||
|
via: "app.add_capability(CapabilityBuilder::new(..).remote(<stored origin>).window(\"main\").permission(notification:*)) in setup() and save_server_url()"
|
||||||
|
pattern: "add_capability"
|
||||||
|
- from: "apps/api/src/reminders/reminder-mail.scheduler.ts"
|
||||||
|
to: "apps/api/src/mail/mail.service.ts"
|
||||||
|
via: "claim via tenant-bound updateMany(where emailSentAt null, same dueAt) -> sendReminderEmail -> release claim only on transport failure"
|
||||||
|
pattern: "sendReminderEmail"
|
||||||
|
- from: "apps/api/src/reminders/reminders.service.ts (snooze)"
|
||||||
|
to: "Reminder.emailSentAt / emailAttempts"
|
||||||
|
via: "snooze writes new dueAt AND resets emailSentAt=null, emailAttempts=0"
|
||||||
|
pattern: "emailAttempts: 0"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260929-if2: Reminder widget "Erinnerungen" with notifications (desktop, browser, optional e-mail)
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Build a new dashboard widget called "Erinnerungen" (widget type key `reminder`). A user sets personal one-time reminders (date + time + title + description). At the due time Tessera notifies them: a native OS notification in the desktop app (also while the window is hidden in the tray), a Web Notification in the browser, and optionally one e-mail sent by the server. After the due time the reminder stays in the widget, highlighted, until the user clicks "Erledigt" or snoozes it with "Später erinnern".
|
||||||
|
|
||||||
|
Purpose: this is the first time-driven feature that reaches the user outside the dashboard, and it reuses the SMTP setup, the desktop notification plugin and the RLS pattern that already exist.
|
||||||
|
|
||||||
|
Output: Prisma model + migration with RLS, NestJS module `reminders` (CRUD, snooze, email status, mail scheduler), web widget + global notifier + helpers, a Tauri runtime capability, tests, re-measured classification doc, CHANGELOG entry and user guide entry.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@~/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/STATE.md
|
||||||
|
@./CLAUDE.md
|
||||||
|
|
||||||
|
Pattern sources, read before the task that uses them:
|
||||||
|
@apps/api/src/custom-modules/custom-modules.service.ts
|
||||||
|
@apps/api/src/custom-modules/custom-modules.controller.ts
|
||||||
|
@apps/api/src/custom-modules/custom-modules.controller.spec.ts
|
||||||
|
@apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql
|
||||||
|
@apps/api/prisma/migrations/20260914120000_rls_system_context_read/migration.sql
|
||||||
|
@apps/api/src/tenders/tender-digest.scheduler.ts
|
||||||
|
@apps/api/src/mail/mail.service.ts
|
||||||
|
@apps/api/src/prisma/prisma-tenant.extension.ts
|
||||||
|
@apps/web/src/components/dashboard/widget-registry.tsx
|
||||||
|
@apps/web/src/components/layout/app-shell.tsx
|
||||||
|
@apps/web/src/lib/favorites-api.ts
|
||||||
|
@apps/web/src/components/custom-modules/custom-module-form-modal.tsx
|
||||||
|
@apps/web/src/components/dashboard/widgets/picture-frame-lightbox.tsx
|
||||||
|
@apps/desktop/src-tauri/src/lib.rs
|
||||||
|
</context>
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
Locked (from the user, must be implemented exactly; cited as D-NN in the tasks):
|
||||||
|
|
||||||
|
- **D-01** One-time reminders only. No recurrence field and no recurrence UI.
|
||||||
|
- **D-02** No advance warning. Notifications and the e-mail fire exactly at `dueAt`, never before.
|
||||||
|
- **D-03** After the due time the reminder stays in the widget, marked as due, with two actions. "Erledigt" removes it from the list. "Später erinnern" offers +10 min, +1 h and "morgen zur gleichen Uhrzeit"; each option sets a new `dueAt`, the notifications fire again, and the e-mail is sent again if it is enabled.
|
||||||
|
- **D-04** Browser notifications: yes. The permission is requested once, from the widget, on the first reminder creation (a user gesture), never on page load.
|
||||||
|
- **D-05** Reminders are personal. Only the owner sees and edits them, and a foreign id returns 404.
|
||||||
|
|
||||||
|
Chosen by the planner (Claude's discretion). Each choice is documented in code comments where it applies:
|
||||||
|
|
||||||
|
- **E-01 Desktop mechanism: the page-side notifier plus a runtime remote capability.** The global web notifier (it runs in the Tauri webview as well) calls the notification plugin through `window.__TAURI_INTERNALS__.invoke('plugin:notification|notify', …)`. The static `capabilities/default.json` has no `remote` block on purpose (T-JN2-01, see the doc comment on `get_server_url`), so Tauri rejects plugin calls from the server page. The Rust side therefore adds, at runtime, one capability bound to exactly the stored server origin (`scheme://host[:port]`), limited to window `main`, and granting only `notification:allow-notify`, `notification:allow-is-permission-granted` and `notification:allow-request-permission`. App commands such as `save_server_url` stay local-only. Tauri 2.11.3 has `dynamic-acl` in its default features, and `tauri::ipc::CapabilityBuilder::remote()` plus `Manager::add_capability()` exist. Two alternatives were rejected. A Rust-side poll of the API fails because Basic-Auth in front of alpha returns 401 to reqwest (the same problem the updater has) and because the session cookie lives only in the webview. The Web Notification API inside the webview is not an option either: the plugin's init script replaces `window.Notification` with a polyfill that makes the same IPC call. On Windows that polyfill also reports `permission = "denied"` on every page load until `requestPermission()` runs. So the helper never uses `Notification.permission` inside Tauri and calls invoke directly. Timers in a hidden webview are throttled by Chromium (at most once per minute after 5 minutes hidden), so a notification from the tray can arrive up to about 1 minute late. That is accepted.
|
||||||
|
- **E-02 "Erledigt" deletes the row.** No history UI was requested, and deleting avoids any retention question. The same `DELETE /reminders/:id` backs both "Löschen" (offered before due) and "Erledigt" (offered after due).
|
||||||
|
- **E-03 Catch-up window of 24 h.** When a client opens late, it still notifies once for reminders that became due within the last 24 h. Older due reminders are only shown, highlighted, in the widget. The e-mail scheduler also only picks reminders due within the last 24 h. That covers API restarts and downtime, and it keeps a late SMTP setup from sending mails about old reminders.
|
||||||
|
- **E-04 E-mail delivery semantics.** The scheduler claims before sending (`emailSentAt = now`, `emailAttempts + 1`, only where `emailSentAt IS NULL`, `dueAt` unchanged and `emailAttempts < 3`). It releases the claim (`emailSentAt = null`) only when the transport throws, so a failed send retries at most 3 times. When the tenant has no SmtpConfig or the user has no e-mail address at send time, the claim is kept: the occurrence counts as handled and is logged, with no send and no retry loop. A snooze resets both fields.
|
||||||
|
- **E-05 "Morgen zur gleichen Uhrzeit".** Computed on the client in local time: take the original `dueAt`, add one calendar day (`setDate(+1)`, which is DST-safe), and repeat until the result is in the future. Typical case: due today 14:00, snoozed at 14:05, new time tomorrow 14:00. +10 min and +1 h count from *now*, not from the old dueAt. The client sends the computed ISO `dueAt`, and the server only validates it.
|
||||||
|
- **E-06 Limits.** At most 100 reminders per user (create returns 409 above that). Title 1–200 characters, description 0–2000 characters. `dueAt` must be after *now* and at most 5 years ahead (both return 400).
|
||||||
|
- **E-07 E-mail language and time zone.** The mail is in German with the time formatted in `Europe/Berlin` (`de-DE`, `dateStyle: 'full'`, `timeStyle: 'short'`, followed by " Uhr"). No per-user locale is stored in `User`. The mail is text-only (no HTML), and CR/LF are stripped from the subject.
|
||||||
|
- **E-08 Scheduler tick.** One global 30-second interval registered through `SchedulerRegistry.addInterval` in `onApplicationBootstrap` (lifecycle choice as in `TenderSchedulerService`). It does not use the `require('cron')` + cast workaround, which would add a Biome warning. An in-process `running` flag skips overlapping ticks.
|
||||||
|
- **E-09 SMTP "configured"** means the tenant has a `SmtpConfig` row (`SettingsService.getSmtpConfig(tenantId) !== null`), the same rule as `TenderMailService`. The environment fallback of `MailService` does not count.
|
||||||
|
|
||||||
|
## Interfaces (contract the three tasks share)
|
||||||
|
|
||||||
|
API (all routes need authentication; `tenantId` comes from `req.tenantId` and the user from `@CurrentUser()`; never from the body):
|
||||||
|
|
||||||
|
| Route | Body | Result | Errors |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `GET /reminders` | – | `Reminder[]` of the caller, `dueAt` ascending | – |
|
||||||
|
| `GET /reminders/email-status` (Task 3) | – | `{ smtpConfigured: boolean, hasEmail: boolean }` | – |
|
||||||
|
| `POST /reminders` | `{ title, description?, dueAt (ISO 8601), emailEnabled? (Task 3) }` | `Reminder` | 400 invalid/past/>5 y, 400 emailEnabled while unavailable, 409 limit |
|
||||||
|
| `PATCH /reminders/:id` (Task 2) | partial create body | `Reminder` | 404 foreign/unknown, 409 already due, 400 as above |
|
||||||
|
| `POST /reminders/:id/snooze` (Task 2) | `{ dueAt }` | `Reminder` | 404, 409 not due yet, 400 past/>5 y |
|
||||||
|
| `DELETE /reminders/:id` (Task 2) | – | `{ deleted: true }` | 404 |
|
||||||
|
|
||||||
|
`Reminder` response = exactly `{ id, title, description, dueAt, emailEnabled, createdAt, updatedAt }` through a `REMINDER_SELECT` constant (pattern `CUSTOM_MODULE_SELECT`). `tenantId`, `userId`, `emailSentAt` and `emailAttempts` never leave the service.
|
||||||
|
|
||||||
|
Prisma model `Reminder`: `id String @id @default(uuid())`, `tenantId String`, `userId String`, `user User @relation(fields: [userId], references: [id], onDelete: Cascade)`, `title String`, `description String @default("")`, `dueAt DateTime`, `emailEnabled Boolean @default(false)`, `emailSentAt DateTime?`, `emailAttempts Int @default(0)`, `createdAt DateTime @default(now())`, `updatedAt DateTime @updatedAt`, `@@index([tenantId, userId, dueAt])`, `@@index([dueAt])`. `User` gets `reminders Reminder[]`. There is no `doneAt` column (E-02) and no recurrence column (D-01).
|
||||||
|
|
||||||
|
Web: `apps/web/src/lib/reminders-api.ts` exports the type `Reminder` (dates as ISO strings), `ReminderRequestError` (carries `status: number`), `listReminders()`, `createReminder(input)`, `updateReminder(id, patch)`, `snoozeReminder(id, dueAt)`, `deleteReminder(id)`, `getReminderEmailStatus()`. It follows the `favorites-api.ts` pattern: `NEXT_PUBLIC_API_URL`, `credentials: 'include'`, and a non-2xx status throws `ReminderRequestError(status)`. After every successful mutation the widget dispatches `window.dispatchEvent(new Event('tessera:reminders-changed'))` (constant `REMINDERS_CHANGED_EVENT`, exported from `reminders-api.ts`).
|
||||||
|
|
||||||
|
## Execution segments (context budget)
|
||||||
|
|
||||||
|
The plan-level estimate (260k tokens raw, calibration factor 1 with 0 samples, confidence low) is above `workflow.smart_zone_tokens` (100k, measured with `config-get`). Quick mode runs exactly one `260929-if2-PLAN.md` per task directory, so this plan is not split into separate plan files. The three task commits are the cut points instead, and each segment is sized on its own:
|
||||||
|
|
||||||
|
| Segment | Ends with commit subject | Raw projection |
|
||||||
|
|---|---|---|
|
||||||
|
| Task 1 (tracer) | `feat(260929-if2): Erinnerungen anlegen und zur Faelligkeit benachrichtigen (Tracer)` | ~100k |
|
||||||
|
| Task 2 | `feat(260929-if2): faellige Erinnerungen erledigen, spaeter erinnern, bearbeiten und loeschen` | ~65k |
|
||||||
|
| Task 3 | `feat(260929-if2): Erinnerung zusaetzlich per E-Mail, Doku und Aenderungsliste` | ~95k |
|
||||||
|
|
||||||
|
Rules for the executor:
|
||||||
|
|
||||||
|
- **Resume rule.** Before the first task, run `git log --format=%s -n 50 --grep='^feat(260929-if2): '` on the current branch. Start at the first task whose commit subject is missing. For every task that is already committed, re-run only its vitest and cargo `<automated>` commands (not the curl end-to-end command, which creates data) before continuing. Nothing is carried over from an earlier conversation: each task's `<read_first>` names everything it needs, and the committed code is the handoff.
|
||||||
|
- **Stop rule.** Stop only directly after a task commit, never in the middle of a task. When the context is past roughly half of the budget after a commit, do not start the next task: write `260929-if2-SUMMARY.md` with `status: halted`, the commit hashes and the measured gate results so far, plus the line "Fortsetzen bei Task N", and return. A new dispatch of the same plan continues through the resume rule and finally rewrites the SUMMARY with `status: complete`.
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="tracer">
|
||||||
|
<name>Task 1 (tracer): create a reminder, see it in the widget, get notified at due time (browser and desktop)</name>
|
||||||
|
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260929140000_reminder/migration.sql, apps/api/src/reminders/reminders.module.ts, apps/api/src/reminders/reminders.controller.ts, apps/api/src/reminders/reminders.controller.spec.ts, apps/api/src/reminders/reminders.service.ts, apps/api/src/reminders/reminders.service.spec.ts, apps/api/src/reminders/dto/reminder.dto.ts, apps/api/src/app.module.ts, packages/shared/src/index.ts, apps/web/src/lib/reminders-api.ts, apps/web/src/lib/reminders-api.test.ts, apps/web/src/lib/reminder-notify.ts, apps/web/src/lib/reminder-notify.test.ts, apps/web/src/components/reminders/reminder-notifier.tsx, apps/web/src/components/reminders/reminder-notifier.test.tsx, apps/web/src/components/layout/app-shell.tsx, apps/web/src/components/dashboard/widgets/reminder-widget.tsx, apps/web/src/components/dashboard/widgets/reminder-widget.test.tsx, apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx, apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/dashboard/widgets/widget-icon.tsx, apps/web/src/components/dashboard/widgets/widget-wrapper.tsx, apps/web/src/app/(portal)/page.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/desktop/src-tauri/src/lib.rs, docs/mandantentrennung-zugriffsklassifikation.md</files>
|
||||||
|
<read_first>apps/api/src/custom-modules/custom-modules.service.ts, apps/api/src/custom-modules/custom-modules.controller.spec.ts, apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql, apps/api/prisma/migrations/20260929130000_custom_module_owner/migration.sql (header comment style), apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx (mock style for next-intl and the api module), apps/web/src/components/custom-modules/custom-module-form-modal.tsx, apps/web/src/components/dashboard/widgets/picture-frame-lightbox.tsx (createPortal into document.body), apps/desktop/src-tauri/src/lib.rs lines 630-810 (commands, setup, window builder), docs/mandantentrennung-zugriffsklassifikation.md sections "Übersicht je Bereich" and "Bestandsaufnahme"</read_first>
|
||||||
|
<action>
|
||||||
|
Build ONE thin path through every layer: DB, API, web widget, global notifier, desktop Rust. Only create and list are in this task. Due highlight, edit, delete, snooze and e-mail come later, but the schema and the migration are final now because an applied migration can no longer be changed.
|
||||||
|
|
||||||
|
1. DB (D-05). Add the `Reminder` model exactly as in "Interfaces" and `reminders Reminder[]` on `User` in `apps/api/prisma/schema.prisma`, with a German comment above the model ("quick-260929-if2: persoenliche Erinnerungen, einmalig (D-01)…"). Write the migration `apps/api/prisma/migrations/20260929140000_reminder/migration.sql` by hand:
|
||||||
|
- Start with the mandatory German header comment in the style of `20260929130000_custom_module_owner`: purpose, owner semantics, both policies, the note that app-role grants come through ALTER DEFAULT PRIVILEGES, and the "switch is OFF" note.
|
||||||
|
- Generate the CREATE TABLE, index and FK statements with `prisma migrate diff --from-url <local db url> --to-schema-datamodel prisma/schema.prisma --script`, so that the names match Prisma (`Reminder_pkey`, `Reminder_tenantId_userId_dueAt_idx`, `Reminder_dueAt_idx`, `Reminder_userId_fkey` with ON DELETE CASCADE).
|
||||||
|
- Then add `ENABLE` and `FORCE ROW LEVEL SECURITY`, plus `tenant_isolation_policy` in the user-dimension form of DashboardImage (`"tenantId" = current_tenant_id() AND (current_user_id() IS NULL OR "userId" = current_user_id())`).
|
||||||
|
- Also add `CREATE POLICY system_read_policy ON "Reminder" FOR SELECT USING (is_system_context());`. The comment must say it serves the e-mail scheduler's candidate query (Task 3) and that it is read-only, as in migration 20260914120000.
|
||||||
|
- Run `pnpm --filter @tessera/api exec prisma generate`.
|
||||||
|
|
||||||
|
2. API module `apps/api/src/reminders/`, registered in `apps/api/src/app.module.ts`:
|
||||||
|
- `dto/reminder.dto.ts`: `CreateReminderDto` with `title` (`@IsString @IsNotEmpty @MaxLength(200)`, trimmed via `@Transform`), `description` (`@IsOptional @IsString @MaxLength(2000)`) and `dueAt` (`@IsISO8601({ strict: true })`). Do not add `emailEnabled` yet (Task 3).
|
||||||
|
- `reminders.service.ts`: `list(tenantId, userId)` and `create(tenantId, userId, dto)`.
|
||||||
|
- Every method uses its own `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`. Always use that assignment form and always the name `tenantPrisma`, because `rls-access-inventory.spec.ts` and the doc's gate loop detect it by exactly that form.
|
||||||
|
- Every `where` also carries `tenantId` and `userId`, as an app-level check while the RLS switch is off.
|
||||||
|
- `list` returns the caller's rows ordered by `dueAt` ascending through `REMINDER_SELECT`, with `dueAt` serialized as ISO.
|
||||||
|
- `create` rejects a `dueAt` that is not after now or more than 5 years ahead with `BadRequestException`, and returns 409 `ConflictException` once the user already has 100 rows (E-06). It sets `tenantId` and `userId` from the arguments only.
|
||||||
|
- Add a German class comment explaining ownership (404, never 403, D-05) and the RLS binding.
|
||||||
|
- `reminders.controller.ts` at path `reminders`. Build `requireTenantId` as in `CustomModulesController`, with `@Get()` list and `@Post()` create. Put a German ROUTE-ORDER comment at the top: every static GET route (Task 3 adds `email-status`) must stand above any `:id` route.
|
||||||
|
- `reminders.module.ts`: controller + service (PrismaModule is global).
|
||||||
|
- Specs:
|
||||||
|
- `reminders.service.spec.ts`: list is scoped to tenant+user and sorted; create stores the ids from the arguments, not from the body; past dueAt gives 400; more than 5 years gives 400; the 101st reminder gives 409.
|
||||||
|
- `reminders.controller.spec.ts`: tenantId is passed through; ForbiddenException without tenantId; the global ValidationPipe (whitelist) strips `tenantId`/`userId` from the body; there is a route-order assertion like in the custom-modules spec.
|
||||||
|
|
||||||
|
3. Shared type: append `'reminder'` at the end of `WIDGET_TYPES` in `packages/shared/src/index.ts`. It is a platform widget, so there is no entry in `WIDGET_MODULE_SLUGS`.
|
||||||
|
|
||||||
|
4. Web data and notify helpers.
|
||||||
|
- `apps/web/src/lib/reminders-api.ts`: the type `Reminder`, `ReminderRequestError`, `REMINDERS_CHANGED_EVENT`, `listReminders` and `createReminder`, as specified in "Interfaces". Test file `reminders-api.test.ts`: URLs, `credentials: 'include'`, a non-2xx status throws with that status.
|
||||||
|
- `apps/web/src/lib/reminder-notify.ts`, pure functions without React:
|
||||||
|
- `isTauriWebview()`: true when `window.__TAURI_INTERNALS__` has an `invoke` function. Narrow through `unknown`; no `any` and no non-null assertions, because of the Biome baseline.
|
||||||
|
- `requestBrowserPermissionOnce()`: does nothing inside Tauri, when `Notification` is missing, when the permission is not `'default'`, or when the localStorage flag `tessera.reminders.permissionAsked` is already set. Otherwise it sets the flag and calls `Notification.requestPermission()` (D-04).
|
||||||
|
- `browserPermissionState()`: returns `'desktop' | 'granted' | 'default' | 'denied' | 'unsupported'`.
|
||||||
|
- `showReminderNotification({ title, body, tag })`: inside Tauri it calls invoke `plugin:notification|notify` with `{ options: { title, body } }` and catches errors with a single `console.warn('[reminders] …')`. Outside Tauri it creates `new Notification(title, { body, tag })` only when the permission is `'granted'`, inside try/catch.
|
||||||
|
- `claimNotification(key, nowMs)`: a localStorage record `tessera.reminders.notified` mapping key to ms. It returns true only on the first claim of a key and prunes entries older than 7 days.
|
||||||
|
- `withNotifyLock(fn)`: runs `fn` under `navigator.locks.request('tessera-reminder-notify', …)` when available, otherwise calls it directly.
|
||||||
|
- `remindersToNotify(reminders, nowMs)`: returns the reminders with `dueAt <= now` and `dueAt > now - 24 h` (E-03, D-02: never before dueAt).
|
||||||
|
- Comment in German why Tauri never uses `Notification.permission` (the polyfill reports "denied" on Windows until `requestPermission`, see E-01).
|
||||||
|
- `reminder-notify.test.ts` covers: dedup (same key twice gives true, then false; the key `${id}|${dueAt}` changes after a snooze), pruning, the Tauri branch calls invoke with the exact command and payload, the browser branch only with `'granted'`, the permission is asked at most once and never in Tauri, and the 24 h window.
|
||||||
|
|
||||||
|
5. Global notifier `apps/web/src/components/reminders/reminder-notifier.tsx` (`'use client'`, renders null). It is mounted in `apps/web/src/components/layout/app-shell.tsx` right after `<ReleaseNoticeHost />`, with a comment that it is global so notifications fire on every portal page, not only when the widget is visible.
|
||||||
|
- It loads `listReminders()` on mount, every 60 s, on the `REMINDERS_CHANGED_EVENT`, on `visibilitychange` to visible, and on window `focus`.
|
||||||
|
- A local 10-second tick against the cached list runs `withNotifyLock` → `claimNotification('${id}|${dueAt}')` → `showReminderNotification`.
|
||||||
|
- Notification title: `widgets.reminder.notificationTitle` ("Erinnerung: {title}"). Body: the description, cut to 200 characters, or the due time formatted locally when the description is empty. Tag: `reminder-${id}-${dueAt}`.
|
||||||
|
- On `ReminderRequestError` with status 401 it stops polling until the next focus. Other errors are ignored silently until the next tick.
|
||||||
|
- `reminder-notifier.test.tsx` uses fake timers and a mocked api module: a due reminder gives exactly one notification across several ticks; a reminder that is not due yet gives none; after `dueAt` changes it notifies again; the change event triggers a refetch.
|
||||||
|
|
||||||
|
6. Widget, minimal:
|
||||||
|
- `apps/web/src/components/dashboard/widgets/reminder-widget.tsx` (`WidgetProps`) lists the user's reminders (title, due time via `Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' })`, description clamped to 2 lines) and shows an empty state. A button "Neue Erinnerung" opens `reminder-form-modal.tsx`.
|
||||||
|
- The modal is rendered with `createPortal` into `document.body`, because react-grid-layout transforms would break `position: fixed` (precedent: picture-frame-lightbox). It follows the dialog markup of custom-module-form-modal (`role="dialog"`, `aria-modal`, Escape closes). Fields: date (`type="date"`), time (`type="time"`), title, description. The defaults are today and the next full hour.
|
||||||
|
- Local inputs become ISO via `new Date(`${date}T${time}`)` → `toISOString()` in a small exported function (Task 2 moves it into `reminder-time.ts`). The client check "must be in the future" mirrors the server rule.
|
||||||
|
- On submit, call `requestBrowserPermissionOnce()` synchronously first (a user gesture, D-04), then `createReminder`, then refetch and dispatch `REMINDERS_CHANGED_EVENT`.
|
||||||
|
- Registration:
|
||||||
|
- `apps/web/src/components/dashboard/widget-registry.tsx`: `WIDGET_CONSTRAINTS.reminder = { minW: 8, minH: 4, defaultW: 12, defaultH: 10 }` with a German comment giving the reason in 48-column units (like the note/favorites widgets: list plus button row; 8 columns ≈ 230 px is the smallest usable width). Add a `ReminderIcon` (bell) and the registry entry `nameKey: 'reminder.name'` / `descriptionKey: 'reminder.description'`.
|
||||||
|
- `apps/web/src/components/dashboard/widgets/widget-icon.tsx`: bell path under `reminder`.
|
||||||
|
- `apps/web/src/components/dashboard/widgets/widget-wrapper.tsx`: add `'reminder'` to `FRAME_HEADER_TYPES` (the unified header supplies icon + name and the hide-title toggle).
|
||||||
|
- `apps/web/src/app/(portal)/page.tsx`: `registerWidget('reminder', ReminderWidget)`.
|
||||||
|
- Texts under `widgets.reminder` in `apps/web/src/messages/de.json` and `en.json`: German uses "Sie" and real umlauts; English mirrors the keys. Keys for this task: name "Erinnerungen", description "Termine und Aufgaben mit Benachrichtigung zur gewünschten Zeit", add, empty, dateLabel, timeLabel, titleLabel, descriptionLabel, save, cancel, pastError, saveError, loadError, limitReached, notificationTitle.
|
||||||
|
- `reminder-widget.test.tsx`: the list renders sorted; create calls `createReminder` with the ISO built from the local inputs; rendering does NOT call `Notification.requestPermission`; the first create calls it once; a second create does not call it again.
|
||||||
|
|
||||||
|
7. Desktop (E-01) in `apps/desktop/src-tauri/src/lib.rs`. Facts measured during planning, which the code must respect: in tauri 2.11.3 `add_capability` runs `Resolved::resolve(..).unwrap()` while it holds the runtime-authority mutex (`src/ipc/authority.rs`, `src/lib.rs`), and tauri-utils 2.9.3 `resolve_command` panics with "invalid URL pattern for remote URL" on a pattern it cannot parse. So an unparsable pattern or an unknown permission does NOT come back as `Err`; it crashes `setup()`, and a `catch_unwind` around it would leave a poisoned mutex that breaks every later IPC call. The only safe guard is to validate the inputs before the call. Do not use `catch_unwind`.
|
||||||
|
- Add `fn server_origin_pattern(url: &str) -> Option<String>`:
|
||||||
|
- Reuse `parse_server_url` (http and https only) and take `host_str()`.
|
||||||
|
- Put a backslash in front of every host character outside ASCII `A-Z`, `a-z`, `0-9`, `.` and `-` (URLPattern escaping). Why: the url crate accepts `http://*.example.com/` and returns the host `*.example.com`, which unescaped would become a wildcard pattern for every subdomain. IPv6 hosts come back in brackets (`[::1]`), and the URLPattern tokenizer (urlpattern 0.3.0) rejects `http://[::1]:8080` with `Tokenizer(InvalidName, 1)`, while the escaped form `http://\[\:\:1\]:8080` parses and matches only `[::1]:8080`. Both were measured.
|
||||||
|
- Append `:port` only when the port is explicit and not the default. Path, query and fragment are dropped.
|
||||||
|
- Self-check before returning: parse the pattern with `tauri::utils::acl::RemoteUrlPattern` (its `FromStr` is the parser Tauri uses for `remote.urls`) and require `.test(&parsed_url)` to be true. Return `None` otherwise.
|
||||||
|
- Add `const SERVER_NOTIFICATION_PERMISSIONS: [&str; 3]` with exactly `notification:allow-notify`, `notification:allow-is-permission-granted` and `notification:allow-request-permission`. These identifiers exist in tauri-plugin-notification 2.3.3 (`permissions/autogenerated/commands/notify.toml`, `is_permission_granted.toml`, `request_permission.toml`). An unknown identifier would panic inside `add_capability` as well, which is one reason the exact-set test below exists.
|
||||||
|
- Add `fn grant_server_notifications(app: &AppHandle, url: &str)`:
|
||||||
|
- When `server_origin_pattern` returns `None`, write one `eprintln!` and add no capability. Desktop toasts are then off for that address, while the browser notifications and the e-mail keep working.
|
||||||
|
- Otherwise build `tauri::ipc::CapabilityBuilder::new("server-notifications").remote(pattern).local(false).window("main")` plus each permission, call `app.add_capability(...)`, and on `Err` write one `eprintln!`. It must never fail startup.
|
||||||
|
- Call it in `setup()` once the stored server URL is known, before the first `navigate`, and in `save_server_url` right after the store is saved, before `navigate`.
|
||||||
|
- Doc comment in German: why a runtime capability and not the static `default.json`; exactly the stored origin, escaped and self-checked, never a wildcard; why the self-check is required (panic inside `add_capability`, see above); only notification permissions, no app commands; T-JN2-01 remains in force for `get_server_url` and the other commands; after a server change the old origin keeps notification rights until the app restarts (accepted, T-IF2-03). Also explain the rejected alternatives: the Rust poll (Basic-Auth 401 as with the updater, the session cookie lives in the webview) and the native Web Notification (plugin polyfill).
|
||||||
|
- Unit tests in `mod tests`. Every new test name starts with `server_origin_` followed by a German description, like the existing `server_host_*` tests, so that the verify command can count them. There are at least 10:
|
||||||
|
- `https://alpha.tessera.ctl.de/dashboard?x=1#h` gives exactly `https://alpha.tessera.ctl.de` (path, query and fragment removed).
|
||||||
|
- `http://192.168.13.12:8080/` gives exactly `http://192.168.13.12:8080`.
|
||||||
|
- `https://alpha.tessera.ctl.de:443/` gives exactly `https://alpha.tessera.ctl.de` (the default port is omitted).
|
||||||
|
- With and without a trailing slash, the result is the same.
|
||||||
|
- `ftp://…` gives `None`, and unparsable input gives `None`.
|
||||||
|
- IPv6: `http://[::1]:8080/` gives exactly the raw string `r"http://\[\:\:1\]:8080"`.
|
||||||
|
- Wildcard host: `http://*.example.com/` gives exactly `r"http://\*.example.com"`, with no unescaped `*`.
|
||||||
|
- Match behavior through `tauri::utils::acl::RemoteUrlPattern`: every pattern above parses. It matches its own origin with a different path and query. It does NOT match the other scheme, another port, the subdomain `x.alpha.tessera.ctl.de`, or a different host. The wildcard pattern does not match `http://a.example.com/`. The IPv6 pattern matches `http://[::1]:8080/dashboard` but not `http://[::2]:8080/` and not `http://[::1]/`.
|
||||||
|
- Exact permission set: `assert_eq!` of `SERVER_NOTIFICATION_PERMISSIONS` against the three identifiers above, in that order. This pins the set (T-IF2-03), so an added `notification:default` or a wildcard permission fails the test.
|
||||||
|
- Run `cargo fmt`.
|
||||||
|
|
||||||
|
8. RLS docs (the inventory spec enforces this now): add rows to the "Bestandsaufnahme" table of `docs/mandantentrennung-zugriffsklassifikation.md` for `apps/api/src/reminders/reminders.service.ts` / `reminder` (class `muss-mandantengebunden`, stand `gebunden`), with a reason that names quick-260929-if2, the user dimension and 404. Add an area row `reminders` in "Übersicht je Bereich", update the "Summe" row and the pair count in "Klassen-Verteilung". Re-measure these with the gate loop the doc describes (per area: `this.prisma.` / `tenantPrisma.` / `systemPrisma.` raw hits, .ts without spec). Write down measured numbers, not copied ones.
|
||||||
|
|
||||||
|
9. Migrate locally and rebuild:
|
||||||
|
- Read the DB container IP with `docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1` (currently 172.19.0.2).
|
||||||
|
- Run `DATABASE_URL=postgresql://tessera:tessera_dev@$DB_IP:5432/tessera pnpm --filter @tessera/api exec prisma migrate deploy`, then `migrate diff --exit-code` must be empty.
|
||||||
|
- Run `docker compose up -d --build web api`. If the disk fills up, run `docker builder prune -f` (only the build cache).
|
||||||
|
- The curl end-to-end command in `<verify>` creates one "Tracer-Test" reminder for testuser each time it runs and leaves it in place. Task 2 deletes every row with that title.
|
||||||
|
- Commit locally: `feat(260929-if2): Erinnerungen anlegen und zur Faelligkeit benachrichtigen (Tracer)`, message ending with the Co-Authored-By line. NEVER push.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api exec vitest run src/reminders src/prisma/rls-coverage.spec.ts src/prisma/rls-access-inventory.spec.ts src/dashboard/widget-module-map.spec.ts</automated>
|
||||||
|
<fails_when>non-zero exit, a non-zero "failed" count in the "Test Files" or "Tests" summary line, or "No test files found"</fails_when>
|
||||||
|
<automated>pnpm --filter @tessera/web exec vitest run src/lib/reminder-notify.test.ts src/lib/reminders-api.test.ts src/components/reminders src/components/dashboard src/messages</automated>
|
||||||
|
<fails_when>non-zero exit, a non-zero "failed" count in the "Test Files" or "Tests" summary line, or "No test files found"</fails_when>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib && cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib server_origin_ 2>&1 | grep -E 'test result: ok\. [1-9][0-9]+ passed' && cargo fmt --manifest-path apps/desktop/src-tauri/Cargo.toml --check && cargo clippy --manifest-path apps/desktop/src-tauri/Cargo.toml -- -D warnings</automated>
|
||||||
|
<fails_when>non-zero exit: "test result: FAILED" in the full run, no "test result: ok. N passed" line with N of at least 10 under the server_origin_ filter (grep prints nothing), a diff printed by cargo fmt --check, or an "error:" line from clippy</fails_when>
|
||||||
|
<automated>DB_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1) && cd /home/vicolab/projects/tessera-ctl/apps/api && DATABASE_URL=postgresql://tessera:tessera_dev@$DB_IP:5432/tessera pnpm exec prisma migrate diff --from-url postgresql://tessera:tessera_dev@$DB_IP:5432/tessera --to-schema-datamodel prisma/schema.prisma --exit-code</automated>
|
||||||
|
<fails_when>non-zero exit (2 when the database and schema.prisma differ, printing diff statements instead of "No difference detected."; 1 on a Prisma error such as an unreachable database)</fails_when>
|
||||||
|
<automated>T=$(mktemp) && A=$(mktemp) && curl -sf -c "$T" -H 'Content-Type: application/json' -d '{"username":"testuser","password":"Test1234!test"}' http://localhost:3001/auth/login >/dev/null && curl -sf -c "$A" -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}' http://localhost:3001/auth/login >/dev/null && DUE=$(date -u -d '+2 minutes' +%Y-%m-%dT%H:%M:00.000Z) && curl -sf -b "$T" -H 'Content-Type: application/json' -d "{\"title\":\"Tracer-Test\",\"dueAt\":\"$DUE\"}" http://localhost:3001/reminders | grep -q '"id"' && curl -sf -b "$T" http://localhost:3001/reminders | grep -q 'Tracer-Test' && ADM=$(curl -sf -b "$A" http://localhost:3001/reminders) && echo "$ADM" | grep -q '^\[' && ! echo "$ADM" | grep -q 'Tracer-Test' && echo "tracer e2e ok"</automated>
|
||||||
|
<fails_when>non-zero exit and no "tracer e2e ok" line: a login, the POST or a GET answered with a non-2xx status (curl -f), the POST response has no "id", testuser's list lacks "Tracer-Test", admin's answer is not a JSON array, or admin's list contains "Tracer-Test"</fails_when>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- The migration is applied locally and `migrate diff` is empty.
|
||||||
|
- POST+GET work for testuser, and admin does not see testuser's reminder (D-05).
|
||||||
|
- The widget is in the catalog and lists and creates reminders; the permission is asked only on the first create (D-04).
|
||||||
|
- The notifier fires once per (id, dueAt) at or after dueAt, never before (D-02).
|
||||||
|
- The Tauri branch invokes `plugin:notification|notify`; lib.rs grants the runtime capability for the stored origin only (E-01). The origin pattern is escaped and self-checked, so an IPv6 or wildcard-looking host can neither crash startup nor widen the grant. At least 10 `server_origin_*` tests pass, including the exact 3-permission set, and cargo test/fmt/clippy are green.
|
||||||
|
- rls-coverage and rls-access-inventory are green, and the doc is re-measured.
|
||||||
|
- Committed locally, not pushed.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: due state, "Erledigt", "Später erinnern", edit and delete before due</name>
|
||||||
|
<files>apps/api/src/reminders/reminders.controller.ts, apps/api/src/reminders/reminders.controller.spec.ts, apps/api/src/reminders/reminders.service.ts, apps/api/src/reminders/reminders.service.spec.ts, apps/api/src/reminders/dto/reminder.dto.ts, apps/web/src/lib/reminders-api.ts, apps/web/src/lib/reminders-api.test.ts, apps/web/src/lib/reminder-time.ts, apps/web/src/lib/reminder-time.test.ts, apps/web/src/components/dashboard/widgets/reminder-widget.tsx, apps/web/src/components/dashboard/widgets/reminder-widget.test.tsx, apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, docs/mandantentrennung-zugriffsklassifikation.md</files>
|
||||||
|
<read_first>apps/api/src/reminders/reminders.service.ts (from Task 1), apps/api/src/custom-modules/custom-modules.service.ts (loadVisible → 404 pattern), apps/web/src/components/dashboard/widgets/reminder-widget.tsx (from Task 1), apps/web/src/app/globals.css (tokens --color-status-warn / --color-status-warn-fg), apps/web/src/messages/umlaut-guard.spec.ts</read_first>
|
||||||
|
<behavior>
|
||||||
|
- Service: PATCH on a foreign or unknown id gives 404; PATCH on a due reminder (dueAt <= now) gives 409; PATCH with a past dueAt gives 400; a valid PATCH changes only the given fields.
|
||||||
|
- Service: snooze on a reminder that is not due yet gives 409; snooze with a past dueAt gives 400; a valid snooze writes the new dueAt AND emailSentAt=null AND emailAttempts=0; snooze on a foreign id gives 404.
|
||||||
|
- Service: DELETE on a foreign id gives 404, on the own id it deletes and returns { deleted: true } (serves both "Löschen" and "Erledigt", E-02).
|
||||||
|
- reminder-time: snoozeTarget('10m') = now+10 min, '1h' = now+1 h, 'tomorrow' = original local time on the next calendar day, repeated until it is in the future (E-05); localInputsToIso/isoToLocalInputs round-trip.
|
||||||
|
- Widget: a due row gets a highlight + "Fällig" badge + "Erledigt" + "Später erinnern" (three options), without edit/delete; an upcoming row gets edit + delete, without Erledigt/Später; "Erledigt" calls deleteReminder and removes the row; each snooze option calls snoozeReminder with the dueAt from snoozeTarget; a row becomes due through the local 10 s tick without reloading.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Expand the tracer so the full lifecycle of D-01/D-03 works end to end.
|
||||||
|
|
||||||
|
1. API in `apps/api/src/reminders/`:
|
||||||
|
- `UpdateReminderDto = PartialType(CreateReminderDto)` and `SnoozeReminderDto { dueAt: IsISO8601 strict }` in `dto/reminder.dto.ts`.
|
||||||
|
- In the service, a private `loadOwn(tenantPrisma, tenantId, userId, id)` returns the row or throws `NotFoundException` for unknown, foreign-tenant and foreign-user rows alike (D-05, never 403).
|
||||||
|
- `update`: 409 `ConflictException` when `row.dueAt <= now` ("Die Erinnerung ist bereits fällig"). A new `dueAt` passes the same future/5-year check as `create`, via a shared private `assertValidDueAt`.
|
||||||
|
- `snooze`: 409 when `row.dueAt > now` (not due yet); validates `dueAt`; writes `{ dueAt, emailSentAt: null, emailAttempts: 0 }` (D-03, so the e-mail fires again).
|
||||||
|
- `remove`: deletes the row (E-02).
|
||||||
|
- All of them use the `const tenantPrisma = forTenant(this.prisma, tenantId, userId)` assignment form, and the `where` clauses carry `tenantId` and `userId`.
|
||||||
|
- Controller: `@Patch(':id')`, `@Post(':id/snooze')`, `@Delete(':id')`, all below the static routes.
|
||||||
|
- Extend both specs with the behaviors above, and extend the route-order spec.
|
||||||
|
|
||||||
|
2. Web helpers:
|
||||||
|
- `apps/web/src/lib/reminder-time.ts` holds pure functions: `snoozeTarget(preset: '10m' | '1h' | 'tomorrow', originalDueAt: Date, now: Date): Date` per E-05, `localInputsToIso(date, time): string | null` (moved here from the Task 1 widget), `isoToLocalInputs(iso): { date, time }`, `defaultNewReminderInputs(now)` (the next full hour).
|
||||||
|
- `apps/web/src/lib/reminder-time.test.ts` has the cases from `<behavior>`, including one across the end of a month.
|
||||||
|
- Extend `apps/web/src/lib/reminders-api.ts` with `updateReminder`, `snoozeReminder` and `deleteReminder`, plus tests.
|
||||||
|
|
||||||
|
3. Widget `apps/web/src/components/dashboard/widgets/reminder-widget.tsx`:
|
||||||
|
- `now` state refreshed every 10 s decides due vs. upcoming. The sort stays `dueAt` ascending.
|
||||||
|
- Due rows (D-03): border/background from the status-warn token (`border-status-warn`, `bg-status-warn/10`, readable in dark mode) and a "Fällig" badge (`bg-status-warn text-status-warn-fg`). Buttons "Erledigt" (calls `deleteReminder`) and "Später erinnern", which toggles an inline option row: "In 10 Minuten", "In 1 Stunde", "Morgen um {time}", where `{time}` is the original local time. It calls `snoozeReminder(id, snoozeTarget(...).toISOString())`.
|
||||||
|
- Upcoming rows: edit (pencil) opens `reminder-form-modal.tsx` prefilled through `isoToLocalInputs` and saves with `updateReminder`. Delete (trash) uses an inline two-step confirm ("Löschen?" Ja/Nein).
|
||||||
|
- On 409 the widget shows the matching text (edit → alreadyDue, snooze → notDue, create → limitReached) and refetches.
|
||||||
|
- After every successful mutation: refetch + dispatch `REMINDERS_CHANGED_EVENT`, so the global notifier picks up a new dueAt immediately.
|
||||||
|
- Buttons are compact and allowed to wrap at minW 8.
|
||||||
|
- Browser hint: when `browserPermissionState()` is `'denied'`, show a muted line saying that the browser blocks notifications and that due reminders then only appear here. Show nothing in Tauri.
|
||||||
|
|
||||||
|
4. New texts under `widgets.reminder` in de/en: due, done, snooze, snooze10m, snooze1h, snoozeTomorrow (with `{time}`), edit, delete, deleteConfirm, yes, no, alreadyDue, notDue, permissionDenied. Run `umlaut-guard.spec.ts`. Extend `apps/web/src/messages/umlaut-dictionary.ts` only if the guard flags a correct German token.
|
||||||
|
|
||||||
|
5. Re-measure the `reminders` area row and the "Summe" row in `docs/mandantentrennung-zugriffsklassifikation.md` with the gate loop, since the service has new bound raw hits.
|
||||||
|
|
||||||
|
6. Rebuild with `docker compose up -d --build web api`. Then, as testuser: DELETE every "Tracer-Test" row from Task 1 (the tracer verify may have run more than once), so GET no longer lists that title. Check that admin gets 404 for PATCH, snooze and DELETE on a testuser id. Commit `feat(260929-if2): faellige Erinnerungen erledigen, spaeter erinnern, bearbeiten und loeschen` (Co-Authored-By line; NEVER push).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api exec vitest run src/reminders src/prisma/rls-access-inventory.spec.ts</automated>
|
||||||
|
<fails_when>non-zero exit, a non-zero "failed" count in the "Test Files" or "Tests" summary line, or "No test files found"</fails_when>
|
||||||
|
<automated>pnpm --filter @tessera/web exec vitest run src/lib/reminder-time.test.ts src/lib/reminders-api.test.ts src/components/dashboard/widgets/reminder-widget.test.tsx src/components/reminders src/messages</automated>
|
||||||
|
<fails_when>non-zero exit, a non-zero "failed" count in the "Test Files" or "Tests" summary line, or "No test files found"</fails_when>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- API: foreign ids give 404 on PATCH, snooze and DELETE; editing a due reminder gives 409; snoozing a reminder that is not due gives 409; snooze resets emailSentAt and emailAttempts.
|
||||||
|
- Widget: due rows are highlighted with Erledigt and the three snooze options (D-03); upcoming rows are editable and deletable.
|
||||||
|
- The Tracer-Test row is removed; the doc is re-measured; committed locally.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 3: optional e-mail at due time (exactly once), toggle in the widget, docs, full gates, manual check list</name>
|
||||||
|
<files>apps/api/src/reminders/reminder-mail.scheduler.ts, apps/api/src/reminders/reminder-mail.scheduler.spec.ts, apps/api/src/reminders/reminders.service.ts, apps/api/src/reminders/reminders.service.spec.ts, apps/api/src/reminders/reminders.controller.ts, apps/api/src/reminders/reminders.controller.spec.ts, apps/api/src/reminders/reminders.module.ts, apps/api/src/reminders/dto/reminder.dto.ts, apps/api/src/mail/mail.service.ts, apps/api/src/mail/mail.service.spec.ts, apps/api/src/prisma/rls-access-inventory.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/reminders-api.ts, apps/web/src/lib/reminders-api.test.ts, apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx, apps/web/src/components/dashboard/widgets/reminder-widget.tsx, apps/web/src/components/dashboard/widgets/reminder-widget.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, CHANGELOG.md, docs/anleitung-anwender.md</files>
|
||||||
|
<read_first>apps/api/src/tenders/tender-digest.scheduler.ts (the forSystem candidates → forTenant loop, per-candidate try/catch), apps/api/src/tenders/tender-digest.scheduler.spec.ts (how forSystem/forTenant are mocked), apps/api/src/tenders/tender-scheduler.service.ts (onApplicationBootstrap), apps/api/src/mail/mail.service.ts (deliver, sendBugReport), apps/api/src/settings/settings.service.ts (getSmtpConfig), apps/api/src/prisma/rls-access-inventory.spec.ts lines 130-192 (FORSYSTEM_ALLOWED_CALL_SITES with its history comment), docs/mandantentrennung-zugriffsklassifikation.md section "Der Hintergrunddienst als Falle", CHANGELOG.md head, docs/anleitung-anwender.md section "Dashboard" (table "Verfügbare Widgets") and "Fenster, Infobereich und Beenden"</read_first>
|
||||||
|
<behavior>
|
||||||
|
- Claim-once: two scheduler instances (or two overlapping ticks) against the same fake store, where updateMany returns count 1 for the first claim and 0 afterwards, lead to exactly one sendReminderEmail call.
|
||||||
|
- Transport failure (sendReminderEmail returns false) releases the claim (emailSentAt=null only where emailSentAt equals the claimed timestamp), so the next tick retries; after 3 attempts (emailAttempts >= 3) the reminder is no longer a candidate.
|
||||||
|
- No SmtpConfig or no user e-mail at send time: the claim is kept, nothing is sent, one log line appears, and nothing is retried (E-04).
|
||||||
|
- The candidate query selects only emailEnabled=true, emailSentAt=null, emailAttempts<3, dueAt <= now AND dueAt >= now-24h (E-03), and only scalar fields (no relation include on the system client).
|
||||||
|
- One failing candidate does not stop the others; an overlapping tick is skipped while `running` is true.
|
||||||
|
- After a snooze (Task 2 reset) the same reminder is a candidate again and gets exactly one more e-mail (D-03).
|
||||||
|
- MailService.sendReminderEmail: subject "Erinnerung: <title>" without CR/LF, text contains the Europe/Berlin time ("… um HH:MM Uhr"), title, description and the app URL; returns true on success and false when deliver throws.
|
||||||
|
- Service/API: GET /reminders/email-status returns { smtpConfigured, hasEmail }; create/update with emailEnabled=true while unavailable gives 400.
|
||||||
|
- Widget: the e-mail checkbox is disabled with the explanation text when smtpConfigured=false, and disabled with the no-address text when hasEmail=false; otherwise it is enabled and sent as emailEnabled; rows with emailEnabled show a small mail icon.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Add the server-side e-mail path (E-04, E-07, E-08, E-09), the toggle, the documentation, and run the final gates.
|
||||||
|
|
||||||
|
1. `apps/api/src/mail/mail.service.ts`: add `sendReminderEmail(tenantId, to, reminder: { title: string; description: string; dueAt: Date }): Promise<boolean>`.
|
||||||
|
- Subject: `Erinnerung: ${title}`, with CR/LF replaced by spaces and cut to 150 characters.
|
||||||
|
- The text body is German in the Sie form: salutation, "Sie haben in Tessera eine Erinnerung für {Zeit} gesetzt:", title, description (if present), then the link `this.appUrl`. `{Zeit}` = `Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'full', timeStyle: 'short' })` + " Uhr".
|
||||||
|
- Call `this.deliver(tenantId, …, 'Reminder')` in try/catch; return true on success, log and return false on failure. No HTML.
|
||||||
|
- Add spec cases to `mail.service.spec.ts`.
|
||||||
|
|
||||||
|
2. `RemindersService`:
|
||||||
|
- `getEmailAvailability(tenantId, userId)` returns `{ smtpConfigured: (await settingsService.getSmtpConfig(tenantId)) !== null, hasEmail: Boolean(user.email) }`, where the user is read through the tenant-bound client.
|
||||||
|
- `create`/`update` accept `emailEnabled` (`@IsOptional @IsBoolean` in the DTO) and throw 400 when it is true while either flag is false.
|
||||||
|
- Controller: `@Get('email-status')` placed directly under `@Get()` and above every `:id` route (NestJS route-order rule); extend the route-order spec.
|
||||||
|
- `reminders.module.ts` imports `MailModule` and `SettingsModule` and provides `ReminderMailScheduler`.
|
||||||
|
|
||||||
|
3. `apps/api/src/reminders/reminder-mail.scheduler.ts`, class `ReminderMailScheduler implements OnApplicationBootstrap`:
|
||||||
|
- `onApplicationBootstrap` registers `this.schedulerRegistry.addInterval('reminder-email', setInterval(() => void this.runTick(), 30_000))`. It first removes an existing entry (try/catch as in the tender schedulers) and logs one line. Errors are only logged, never thrown.
|
||||||
|
- `runTick(now = new Date())` returns immediately while `this.running` is set; otherwise it sets the flag and clears it in `finally`.
|
||||||
|
- Candidates come from EXACTLY ONE `const systemPrisma = forSystem(this.prisma)` and `systemPrisma.reminder.findMany`, with the where clause from `<behavior>`, `select { id, tenantId, userId, dueAt }`, `orderBy dueAt asc`, `take 200`.
|
||||||
|
- Keep the select scalar. A relation include/select on the system client would make `User` a system-read model that needs its own `system_read_policy` (the WINDOWS #27 form).
|
||||||
|
- For each candidate, inside try/catch:
|
||||||
|
1. `const tenantPrisma = forTenant(this.prisma, c.tenantId)` (bound, without a user, as in the digest).
|
||||||
|
2. Claim: `tenantPrisma.reminder.updateMany({ where: { id, tenantId, dueAt: c.dueAt, emailEnabled: true, emailSentAt: null, emailAttempts: { lt: 3 } }, data: { emailSentAt: now, emailAttempts: { increment: 1 } } })`. Continue unless the count is 1.
|
||||||
|
3. Load the title/description/dueAt of the row and the user's e-mail through `tenantPrisma`, and check SMTP availability via `SettingsService.getSmtpConfig`.
|
||||||
|
4. When SMTP is missing or there is no address, log "übersprungen" and keep the claim.
|
||||||
|
5. Otherwise call `sendReminderEmail`. On false, release the claim with `updateMany({ where: { id, emailSentAt: now }, data: { emailSentAt: null } })`.
|
||||||
|
- German class comment: why the claim comes before sending (no duplicate mails with several instances and restarts, at-most-3 attempts), why there is a 24 h window, and why it runs every 30 s.
|
||||||
|
- `reminder-mail.scheduler.spec.ts` covers every scheduler behavior above, mocked the way `tender-digest.scheduler.spec.ts` does it.
|
||||||
|
|
||||||
|
4. RLS inventory:
|
||||||
|
- Add `['apps/api/src/reminders/reminder-mail.scheduler.ts', 1]` to `FORSYSTEM_ALLOWED_CALL_SITES` in `apps/api/src/prisma/rls-access-inventory.spec.ts`, and extend the history comment with a quick-260929-if2 paragraph (candidate query only, all writes bound per row, policy `system_read_policy` from migration 20260929140000; new total "6 Dateien, 7 Aufrufe").
|
||||||
|
- In `docs/mandantentrennung-zugriffsklassifikation.md`:
|
||||||
|
- Add the scheduler rows (`reminder` → class `beides`, stand `system-gebunden`; `user` → `beides`, `gebunden` if read directly) and the `reminders.service.ts` / `user` row if the service reads the user.
|
||||||
|
- Add a paragraph to "Der Hintergrunddienst als Falle" for the new case.
|
||||||
|
- Re-measure the area row, the "Summe" row and "Klassen-Verteilung" with the gate loop.
|
||||||
|
- Run both RLS specs.
|
||||||
|
|
||||||
|
5. Web:
|
||||||
|
- `getReminderEmailStatus()` in `reminders-api.ts`, plus a test.
|
||||||
|
- `reminder-form-modal.tsx` gets the checkbox "Zusätzlich per E-Mail erinnern". Its disabled state and explanation come from the status. It is loaded once per widget mount and treated as unavailable while unknown or failed.
|
||||||
|
- The mail icon appears on rows where `emailEnabled` is set.
|
||||||
|
- New texts: emailLabel, emailNoSmtp ("E-Mail-Erinnerungen sind nicht möglich, weil kein E-Mail-Versand eingerichtet ist. Bitte wenden Sie sich an Ihren Administrator."), emailNoAddress ("In Ihrem Konto ist keine E-Mail-Adresse hinterlegt."), emailOn (for the icon's aria-label). de and en.
|
||||||
|
- Extend the widget tests.
|
||||||
|
|
||||||
|
6. Documentation in plain German for non-programmers, Sie form, real umlauts:
|
||||||
|
- `CHANGELOG.md`: under "## Unveröffentlicht" add a new "### Neu" section ABOVE the existing "### Behoben". Bullet: "Dashboard: Neues Widget „Erinnerungen“ …". It covers date/time/title/description, the notification at exactly the chosen time in the browser (after a one-time permission) and in the desktop app (also from the notification area), the optional e-mail (also when Tessera is not open anywhere), and a due reminder staying highlighted until "Erledigt" or "Später erinnern" (in 10 minutes, in 1 hour, tomorrow at the same time). Add the note that the desktop app needs its new version for this, delivered through the update in the Tessera icon's menu.
|
||||||
|
- `docs/anleitung-anwender.md`:
|
||||||
|
- A row "Erinnerungen" in the table "Verfügbare Widgets", plus a short paragraph below it: the browser asks once, blocked notifications only show in the widget, the e-mail option and why it can be greyed out, snooze options, editing/deleting only before the due time, reminders are personal.
|
||||||
|
- One sentence in "Fenster, Infobereich und Beenden": reminders also appear while the window is in the notification area.
|
||||||
|
|
||||||
|
7. Final gates, in this order. All must pass:
|
||||||
|
- Full API and web test suites.
|
||||||
|
- `pnpm turbo run type-check lint --force`.
|
||||||
|
- Biome counts web <= 55 and api <= 82, measured with the Biome command in `<verify>` (it prints both counts and exits non-zero above the limit).
|
||||||
|
- cargo test/fmt/clippy.
|
||||||
|
- `docker compose up -d --build web api`, then GET /health on the API.
|
||||||
|
- curl as testuser: GET /reminders/email-status answers; POST with `emailEnabled: true` behaves as the status says (201 or 400).
|
||||||
|
- Delete all test reminders again.
|
||||||
|
- Commit `feat(260929-if2): Erinnerung zusaetzlich per E-Mail, Doku und Aenderungsliste` (Co-Authored-By line). NEVER push.
|
||||||
|
- Copy the section "Manuelle Prüfschritte für den Orchestrator" of this plan into the SUMMARY, adjusted to the actual state (for example whether SMTP is configured locally).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api exec vitest run src/reminders src/mail src/prisma</automated>
|
||||||
|
<fails_when>non-zero exit, a non-zero "failed" count in the "Test Files" or "Tests" summary line, or "No test files found"</fails_when>
|
||||||
|
<automated>pnpm --filter @tessera/api exec vitest run && pnpm --filter @tessera/web exec vitest run && pnpm turbo run type-check lint --force</automated>
|
||||||
|
<fails_when>non-zero exit: a failed test in either suite, or a turbo task reported as failed (a type error or a Biome lint error)</fails_when>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && ok=1; for p in web:55 api:82; do n=${p%%:*}; max=${p#*:}; out=$(pnpm --filter @tessera/$n exec biome lint . 2>&1) && echo "$out" | grep -qE 'Checked [0-9]+ files' || { echo "$n: biome did not run"; ok=0; continue; }; c=$(echo "$out" | grep -oE 'Found [0-9]+ warnings?' | grep -oE '[0-9]+'); echo "$n: ${c:-0} warnings (max $max)"; [ "${c:-0}" -le "$max" ] || ok=0; done; [ "$ok" = 1 ]</automated>
|
||||||
|
<fails_when>non-zero exit, together with a "biome did not run" line or a "web: N warnings (max 55)" / "api: N warnings (max 82)" line whose N is above its max (baseline measured during planning: exactly 55 and 82)</fails_when>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib && cargo fmt --manifest-path apps/desktop/src-tauri/Cargo.toml --check && cargo clippy --manifest-path apps/desktop/src-tauri/Cargo.toml -- -D warnings</automated>
|
||||||
|
<fails_when>non-zero exit: "test result: FAILED", a diff printed by cargo fmt --check, or an "error:" line from clippy</fails_when>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q "reminder-mail.scheduler.ts', 1" apps/api/src/prisma/rls-access-inventory.spec.ts && grep -q "Erinnerungen" CHANGELOG.md && grep -q "Erinnerungen" docs/anleitung-anwender.md</automated>
|
||||||
|
<fails_when>non-zero exit: one of the three strings is missing from its file</fails_when>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- The e-mail is claimed atomically and sent exactly once per due occurrence (tests prove this with concurrent claims), and it is sent again after a snooze.
|
||||||
|
- A transport failure is retried at most 3 times; a missing SMTP config or address is skipped without a loop.
|
||||||
|
- The toggle is disabled with an explanation when e-mail is unavailable.
|
||||||
|
- The inventory allowlist and the doc are updated and re-measured.
|
||||||
|
- CHANGELOG and user guide are written.
|
||||||
|
- All gates are green, and the Biome counts do not exceed the baseline.
|
||||||
|
- The containers are rebuilt, the test data removed, and the work committed locally and never pushed.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| browser/webview → API `/reminders*` | untrusted body and ids; identity from the session cookie only |
|
||||||
|
| server page (remote origin) → Tauri IPC | web content from the configured server calls the native notification plugin |
|
||||||
|
| API scheduler → SMTP | user-provided title/description go into a mail |
|
||||||
|
| scheduler (all tenants) → DB | the system context reads across tenants |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-IF2-01 | Information disclosure | `RemindersService` loadOwn/list | high | mitigate | Every query uses `forTenant(prisma, tenantId, userId)` plus `where { tenantId, userId }`; foreign/unknown ids give 404 (never 403); RLS `tenant_isolation_policy` with a user dimension; service specs assert the 404 cases (D-05) |
|
||||||
|
| T-IF2-02 | Tampering | DTOs / controller | high | mitigate | Global ValidationPipe `whitelist` strips `tenantId`/`userId`/`emailSentAt`/`emailAttempts`; the service sets the ids from the token; the controller spec proves the stripping |
|
||||||
|
| T-IF2-03 | Elevation of privilege | `grant_server_notifications` (lib.rs) | medium | mitigate | The runtime capability binds exactly the stored `scheme://host[:port]`, window `main`, and only the three `notification:` permissions; no app commands (save_server_url etc. stay local-only, T-JN2-01). Host characters outside `A-Za-z0-9.-` are escaped, so a host such as `*.example.com` cannot become a wildcard, and the pattern is self-checked with `RemoteUrlPattern` (parse + match of the stored URL), so an IPv6 or otherwise unparsable pattern yields no grant instead of a panic inside `add_capability`. `server_origin_*` unit tests pin the exact 3-permission set with `assert_eq!` and the match/no-match behavior (other scheme, port, subdomain, host; IPv6; wildcard host). Accepted residual risk: after a server change the old origin keeps the notify right until the app restarts |
|
||||||
|
| T-IF2-04 | Denial of service | create/list, notifier polling | medium | mitigate | At most 100 reminders per user (409); title ≤ 200, description ≤ 2000; the notifier polls every 60 s (local ticks without network); the scheduler uses `take 200` and a reentrancy guard |
|
||||||
|
| T-IF2-05 | Tampering (header injection) | `MailService.sendReminderEmail` | medium | mitigate | CR/LF stripped from the subject, text-only body (no HTML, so no HTML injection); recipient only the owner's stored address |
|
||||||
|
| T-IF2-06 | Repudiation / integrity | e-mail duplicates across instances | medium | mitigate | Atomic claim `updateMany … emailSentAt: null, dueAt: <read value>` with a count check before sending; release only on transport failure; at most 3 attempts; the spec proves a single send with concurrent claims |
|
||||||
|
| T-IF2-07 | Information disclosure | system context read | medium | mitigate | `system_read_policy` is FOR SELECT only; the candidate select is scalar-only; every write is tenant-bound per row; `FORSYSTEM_ALLOWED_CALL_SITES` pins exactly 1 call in `reminder-mail.scheduler.ts` |
|
||||||
|
| T-IF2-08 | Information disclosure | OS notification / e-mail content | low | accept | Title/description are the user's own text, shown to that user on their own device and mailbox; lock-screen visibility is the user's OS setting |
|
||||||
|
| T-IF2-09 | Information disclosure | `GET /reminders/email-status` | low | accept | Reveals only two booleans (tenant SMTP present, own address present) to an authenticated user of that tenant |
|
||||||
|
| T-IF2-10 | Spoofing / XSS | widget rendering, notification body | low | mitigate | React escapes the text; Notification/plugin bodies are plain text; nothing is rendered as HTML |
|
||||||
|
| T-IF2-SC | Tampering | npm/pip/cargo installs | high | mitigate | No new packages in this plan (nodemailer, @nestjs/schedule, the Tauri plugins and tauri-plugin-notification are already installed); the executor must not add dependencies, so no package-legitimacy gate is triggered |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `pnpm --filter @tessera/api exec vitest run` and `pnpm --filter @tessera/web exec vitest run` are fully green.
|
||||||
|
- `pnpm turbo run type-check lint --force` is green. Biome: web ≤ 55, api ≤ 82 warnings.
|
||||||
|
- `cargo test --lib`, `cargo fmt --check` and `cargo clippy -- -D warnings` on `apps/desktop/src-tauri/Cargo.toml` are green.
|
||||||
|
- `rls-coverage.spec.ts` and `rls-access-inventory.spec.ts` are green, and the doc numbers are re-measured with the gate loop.
|
||||||
|
- The local migration is applied, and `prisma migrate diff --exit-code` shows no difference.
|
||||||
|
- curl end to end: create/list as testuser, 404 for admin on testuser's id, email-status answers, and the test data is removed.
|
||||||
|
- Local commits only. `git log origin/main..HEAD` shows the new commits and nothing was pushed.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- A user can create personal one-time reminders in the new "Erinnerungen" widget and see them sorted, with due ones highlighted (D-01, D-05).
|
||||||
|
- A browser tab and the desktop app (including tray mode) each notify exactly once per due occurrence at the due time (D-02, D-04, E-01).
|
||||||
|
- "Erledigt" removes a reminder; "Später erinnern" (+10 min / +1 h / tomorrow same time) re-arms the notifications and the e-mail (D-03).
|
||||||
|
- With e-mail enabled, exactly one mail per due occurrence is sent even with no client open (E-04); the toggle explains why it is disabled when unavailable.
|
||||||
|
- RLS, the inventory and the docs are consistent; CHANGELOG and user guide describe the feature in plain German.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
## Manuelle Prüfschritte für den Orchestrator
|
||||||
|
|
||||||
|
These are carried out by the orchestrator after execution; the executor copies them into the SUMMARY.
|
||||||
|
|
||||||
|
**Browser (Playwright MCP, dark mode through the theme button; never measure via fetch from the page):**
|
||||||
|
1. Log in as `testuser` / `Test1234!test`. Allow notifications for the origin in the Playwright context (`grantPermissions(['notifications'])`); otherwise the prompt stays "default". Also check once with a new context without that grant: the prompt must appear only after clicking "Speichern" on the first reminder, not on page load.
|
||||||
|
2. "Bearbeiten" → "Widget hinzufügen" → catalog shows "Erinnerungen" with the bell icon → add it → "Fertig". Screenshot in dark mode: header with the icon chip, empty state, button "Neue Erinnerung".
|
||||||
|
3. Create a reminder due in 2 minutes (title + description). The list shows it with the local time.
|
||||||
|
4. Before the due time, install a spy in the page (`page.evaluate`, wrap `window.Notification` and count the calls). Switch to another portal page (e.g. Marktplatz) and wait past the due time. Expect exactly one notification call with the title "Erinnerung: …" even though the dashboard is not visible (the notifier is global). Back on the dashboard, the row is highlighted as "Fällig" with "Erledigt" and "Später erinnern". Take a dark-mode screenshot of the highlighted row.
|
||||||
|
5. Open a second tab of the same session and wait 20 s: no second notification for the same reminder.
|
||||||
|
6. "Später erinnern" → "In 10 Minuten": the row is upcoming again with the new time (edit/delete visible). "Bearbeiten": change the title and save. Delete with the confirm step. Create another one, let it become due, then "Erledigt": it disappears.
|
||||||
|
7. The e-mail checkbox in the form: with no tenant SMTP configured locally it is disabled with the explanation text. If SMTP (e.g. mailhog from `docker-compose.dev.yml`) is configured under Einstellungen → SMTP, enable it, let a reminder become due, and verify that exactly one mail arrives with the time in Europe/Berlin. Snooze by 10 minutes, and after that a second mail arrives.
|
||||||
|
8. As `admin` (admin123): the widget does not show testuser's reminders.
|
||||||
|
9. Remove all test reminders afterwards.
|
||||||
|
|
||||||
|
**Windows VM (Proxmox VM 8233, per the stored VM notes):**
|
||||||
|
- The Rust change only takes effect in a desktop client built from this commit. Since nothing is pushed, CI does not build one. The Windows check therefore needs either the user's push + CI build, or a locally built NSIS package. An older installed client shows the widget and sends e-mails, but shows no toast; this is expected and is mentioned in the CHANGELOG.
|
||||||
|
10. With the new client connected to the server that has this code: create a reminder due in 3 minutes, close the window with X (the app goes to the tray), and wait. A Windows toast from "Tessera" with "Erinnerung: …" appears within about 1 minute of the due time (WebView2 throttles hidden timers, E-01). After the toast, open the window: the reminder shows "Fällig".
|
||||||
|
11. With the desktop app and a browser open at the same time, each shows the notification exactly once.
|
||||||
|
12. After "Server-Adresse ändern…" to the same server, notifications still work (capability re-granted in `save_server_url`).
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/quick/260929-if2-reminder-widget-mit-benachrichtigung/260929-if2-SUMMARY.md` when done. It must contain: the measured gate table (test counts, Biome counts, cargo, RLS specs, migrate diff, gate-loop numbers), the curl results, any deviations, and the manual check steps above, adjusted to the actual state.
|
||||||
|
</output>
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260929-if2
|
||||||
|
plan: 01
|
||||||
|
subsystem: dashboard-widgets, api-reminders, desktop-notifications
|
||||||
|
tags: [reminders, notifications, tauri, rls, scheduler, smtp]
|
||||||
|
status: complete
|
||||||
|
requires: []
|
||||||
|
provides:
|
||||||
|
- "Widget 'Erinnerungen' (type reminder) with create/edit/delete, due highlight, Erledigt, Spaeter erinnern"
|
||||||
|
- "API /reminders (list, email-status, create, patch, snooze, delete) with owner scoping (404 for foreign ids)"
|
||||||
|
- "Reminder table with tenant+user RLS policy and system_read_policy"
|
||||||
|
- "Global ReminderNotifier in AppShell (browser Web Notification, Tauri plugin notification)"
|
||||||
|
- "ReminderMailScheduler: atomic-claim e-mail once per due occurrence"
|
||||||
|
- "Tauri runtime remote capability for notification permissions on the stored server origin only"
|
||||||
|
affects: [dashboard, desktop, mail, rls-inventory]
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "claim-before-send scheduler (updateMany with count check), forSystem candidate query + forTenant per row"
|
||||||
|
- "runtime Tauri capability with escaped and self-checked URL pattern (no catch_unwind)"
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- apps/api/prisma/migrations/20260929140000_reminder/migration.sql
|
||||||
|
- apps/api/src/reminders/ (module, controller, service, scheduler, dto, 4 specs)
|
||||||
|
- apps/web/src/lib/reminders-api.ts
|
||||||
|
- apps/web/src/lib/reminder-notify.ts
|
||||||
|
- apps/web/src/lib/reminder-time.ts
|
||||||
|
- apps/web/src/components/reminders/reminder-notifier.tsx
|
||||||
|
- apps/web/src/components/dashboard/widgets/reminder-widget.tsx
|
||||||
|
- apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx
|
||||||
|
modified:
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/src/app.module.ts
|
||||||
|
- apps/api/src/mail/mail.service.ts
|
||||||
|
- apps/api/src/prisma/rls-access-inventory.spec.ts
|
||||||
|
- apps/desktop/src-tauri/src/lib.rs
|
||||||
|
- packages/shared/src/index.ts
|
||||||
|
- apps/web (registry, widget-icon, widget-wrapper, app-shell, page.tsx, messages de/en, umlaut-dictionary)
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md, docs/anleitung-anwender.md, CHANGELOG.md
|
||||||
|
key-decisions:
|
||||||
|
- "E-01 runtime remote capability for exactly the stored origin (escaped, self-checked with RemoteUrlPattern), window main, three notification permissions"
|
||||||
|
- "E-02 Erledigt deletes the row; E-04 claim before send, release only on transport failure, max 3 attempts"
|
||||||
|
- "Snooze resets emailSentAt/emailAttempts so the mail fires again (D-03)"
|
||||||
|
duration: about 1 h 15 min
|
||||||
|
completed: 2026-09-29
|
||||||
|
commits: 3
|
||||||
|
plan_head_before: cd1f8f6cda7d3b1b6fa22ab8ec9274201d9c2089
|
||||||
|
plan_head_after: 8027c4857080790bf9994553fbda8036d81f8eb3
|
||||||
|
actuals:
|
||||||
|
tokens: 43000
|
||||||
|
tasks: 3
|
||||||
|
commits: 3
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase quick-260929-if2 Plan 01: Erinnerungen-Widget mit Benachrichtigung Summary
|
||||||
|
|
||||||
|
Persoenliche einmalige Erinnerungen als Dashboard-Widget: Benachrichtigung zur Faelligkeit im Browser und als native Windows-Meldung in der Desktop-App (auch im Infobereich), optional eine E-Mail, die der Server genau einmal je Faelligkeit ueber einen atomaren Anspruch versendet.
|
||||||
|
|
||||||
|
## Commits (lokal, nicht gepusht)
|
||||||
|
|
||||||
|
| Task | Hash | Betreff |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 (Tracer) | 26f8f0f | feat(260929-if2): Erinnerungen anlegen und zur Faelligkeit benachrichtigen (Tracer) |
|
||||||
|
| 2 | 580c31c | feat(260929-if2): faellige Erinnerungen erledigen, spaeter erinnern, bearbeiten und loeschen |
|
||||||
|
| 3 | 8027c48 | feat(260929-if2): Erinnerung zusaetzlich per E-Mail, Doku und Aenderungsliste |
|
||||||
|
|
||||||
|
`commits:` gemessen mit `git rev-list --count cd1f8f6..HEAD` = 3. Der Tracer-Feedback-Gate (Auto-Modus: `<verify>` erneut ausfuehren) lief vor Task 2: API-, Web-, cargo-Tests, `migrate diff` und die curl-End-to-End-Kette waren gruen, also wurde erweitert.
|
||||||
|
|
||||||
|
## Gemessene Gates (nach Task 3)
|
||||||
|
|
||||||
|
| Gate | Ergebnis |
|
||||||
|
|---|---|
|
||||||
|
| API vitest komplett | 91 Dateien, 1570 Tests, alle gruen |
|
||||||
|
| Web vitest komplett | 108 Dateien, 1069 Tests, alle gruen |
|
||||||
|
| `pnpm turbo run type-check lint --force` | 9/9 Tasks erfolgreich |
|
||||||
|
| Biome-Warnungen | web 55 (max 55), api 82 (max 82), also exakt die Grundlinie |
|
||||||
|
| cargo test --lib | 57 gruen, davon 12 `server_origin_*` (Plan verlangte mindestens 10) |
|
||||||
|
| cargo fmt --check / clippy -D warnings | sauber |
|
||||||
|
| rls-coverage + rls-access-inventory | gruen (30 Zusicherungen im Inventar) |
|
||||||
|
| `prisma migrate diff --exit-code` | "No difference detected" (Exit 0), Migration lokal angewendet |
|
||||||
|
| Gate-Schleife (Rohtreffer, ohne spec) | Summe 61 / 235 / 7 (ungebunden / gebunden / System); Bereich `reminders` 0 / 12 / 1 |
|
||||||
|
| Bestandsaufnahme-Doku | 83 Paare (44 muss-mandantengebunden, 21 keine-mandantengebundene-tabelle, 16 beides, 2 bewusst-uebergreifend), mit `grep -cE '^\| apps/api/src/'` nachgezaehlt |
|
||||||
|
| FORSYSTEM_ALLOWED_CALL_SITES | neu `reminder-mail.scheduler.ts` = 1, Summe 6 Dateien / 7 Aufrufe |
|
||||||
|
|
||||||
|
## curl-Ergebnisse (lokal, gegen die neu gebauten Container)
|
||||||
|
|
||||||
|
- Tracer: testuser POST + GET ok, admin sieht das Tracer-Test nicht (`tracer e2e ok`); Vergangenheit ergibt 400.
|
||||||
|
- Task 2: admin bekommt fuer PATCH, snooze und DELETE auf eine testuser-id je 404; testuser: snooze auf nicht faellige Erinnerung 409, PATCH 200. Alle Test-Zeilen geloescht.
|
||||||
|
- Task 3: `GET /reminders/email-status` liefert `{"smtpConfigured":true,"hasEmail":true}` (lokal ist SmtpConfig auf `mailhog:1025` gesetzt, testuser hat `testuser@example.com`); POST mit `emailEnabled:true` ergibt 201. `/health` ok.
|
||||||
|
- Planerlauf gegen die echte Datenbank: eine Erinnerung mit E-Mail wurde 1 Minute nach Anlage faellig; der Planer versuchte den Versand im 30-s-Takt genau dreimal (mailhog-Container laeuft lokal nicht, also Transportfehler und Freigabe), danach `emailAttempts = 3` und keine weiteren Versuche. Damit ist Anspruch, Freigabe und die Grenze von 3 Versuchen live belegt. Alle Test-Zeilen danach geloescht (`count(*) = 0`).
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
1. **[Rule 3 - Blocking] Bestehende Tests an die neue Kachel angepasst.** `widget-registry.test.tsx` (Typliste, Groessentabelle, Zaehler 40 auf 44) und `widget-catalog-modal.test.tsx` (letzte Kachel nun `reminder`) pruefen die exakte Kachelliste. Ohne Anpassung waere die Suite rot. Commit 26f8f0f.
|
||||||
|
2. **[Rule 3] `reminder-time.ts` schon in Task 1.** Der Plan legte `localInputsToIso` zuerst in die Kachel und verschob sie in Task 2; ich habe sie gleich in `reminder-time.ts` angelegt und in Task 2 nur erweitert (`isoToLocalInputs`, `snoozeTarget`). Kein Verhalten anders.
|
||||||
|
3. **[Rule 2 - Lesbarkeit] "Faellig"-Abzeichen mit 20 % Flaeche.** Der Plan nannte `bg-status-warn text-status-warn-fg`; die Schrift-Variante erreicht auf voller Warnflaeche nur rund 2,6:1 (globals.css-Kommentar). Verwendet wird `bg-status-warn/20 text-status-warn-fg` (die dokumentierte Pillen-Form). Bitte im Dunkelmodus mitpruefen.
|
||||||
|
4. **[Rule 2] `lässt` auf die Umlaut-Allowlist** (`umlaut-dictionary.ts`), weil der Text `alreadyDue` "lässt sich nicht mehr bearbeiten" korrektes Deutsch ist, das der Guard sonst meldet (im Plan vorgesehen, "nur wenn der Guard warnt").
|
||||||
|
5. **Formular-Details:** `emailEnabled` wird beim Bearbeiten nur gesendet, wenn es sich aendert (sonst kippt eine unveraenderte Alt-Einstellung das Speichern mit 400, wenn SMTP inzwischen fehlt); ein bereits angehaktes Feld bleibt bedienbar, damit man es abwaehlen kann. Beim Bearbeiten wird nie die Browser-Erlaubnis abgefragt (D-04: nur beim ersten Anlegen). Zusaetzlich bekam `ReminderFormModal` die Props `emailStatus` und `onStale` (409 beim Bearbeiten laedt neu).
|
||||||
|
6. **Commit-Zeile:** Die Co-Authored-By-Zeile ist `Claude Sonnet 5.5 <noreply@anthropic.com>` (das laufende Modell, wie die Umgebung sie vorgibt), nicht "Claude Opus 5.5 (1M context)" wie in der Aufgabenbeschreibung. Bei Bedarf per Umschreiben anzupassen, bevor gepusht wird.
|
||||||
|
|
||||||
|
Stop-Regel des Plans: nach jedem Commit lag der Kontext weit unter der Haelfte, daher alle drei Segmente in einem Lauf.
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
Keine. Alle Daten der Kachel kommen aus der API; keine Platzhalter.
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
Keine neue Flaeche ausserhalb des Plan-Threat-Models. Zur Beachtung (T-IF2-03, im Plan akzeptiert): nach einem Serverwechsel behaelt der alte Ursprung sein Benachrichtigungsrecht bis zum App-Neustart.
|
||||||
|
|
||||||
|
## Nicht messbar ohne GUI (bitte pruefen)
|
||||||
|
|
||||||
|
- Das Rust-Laufzeitrecht (`add_capability` mit `remote`) ist per Unit-Tests der Musterbildung abgesichert (Escaping, Selbstpruefung, exakte 3er-Menge), aber der echte Toast in der Desktop-App ist nur auf der Windows-VM pruefbar (siehe Schritte 10 bis 12 unten).
|
||||||
|
|
||||||
|
## Manuelle Pruefschritte fuer den Orchestrator
|
||||||
|
|
||||||
|
Angepasst an den Ist-Zustand: lokal ist SMTP auf `mailhog:1025` gesetzt, der mailhog-Container laeuft aber NICHT (`docker ps` zeigt keinen). Die E-Mail-Checkbox ist also aktiv (nicht ausgegraut), Mails scheitern lokal beim Transport, bis mailhog laeuft (z. B. aus `docker-compose.dev.yml` starten).
|
||||||
|
|
||||||
|
**Browser (Playwright MCP, dunkel per Theme-Knopf; nie per fetch aus der Seite messen):**
|
||||||
|
1. Als `testuser` / `Test1234!test` anmelden. Benachrichtigungen fuer den Ursprung im Playwright-Kontext erlauben (`grantPermissions(['notifications'])`), sonst bleibt die Abfrage "default". Einmal auch mit neuem Kontext ohne Erlaubnis: die Abfrage erscheint erst nach Klick auf "Speichern" beim ersten Anlegen, nicht beim Laden der Seite.
|
||||||
|
2. "Bearbeiten" -> "Widget hinzufuegen" -> Katalog zeigt "Erinnerungen" mit Glocken-Symbol -> hinzufuegen -> "Fertig". Dunkel-Screenshot: Kopfzeile mit Symbol-Chip, Leerzustand, Knopf "Neue Erinnerung".
|
||||||
|
3. Erinnerung mit Faelligkeit in 2 Minuten anlegen (Titel + Beschreibung). Die Liste zeigt sie mit lokaler Zeit; der E-Mail-Haken ist bedienbar.
|
||||||
|
4. Vor der Faelligkeit einen Spion in die Seite legen (`page.evaluate`, `window.Notification` umhuellen und Aufrufe zaehlen). Auf eine andere Portalseite (z. B. Marktplatz) wechseln und die Faelligkeit abwarten. Erwartet: genau ein Aufruf mit Titel "Erinnerung: ...", obwohl das Dashboard nicht sichtbar ist (der Melder ist global). Zurueck auf dem Dashboard: Zeile hervorgehoben, Abzeichen "Faellig", Knoepfe "Erledigt" und "Spaeter erinnern". Dunkel-Screenshot der hervorgehobenen Zeile (Lesbarkeit des Abzeichens beurteilen).
|
||||||
|
5. Zweiten Tab derselben Sitzung oeffnen und 20 s warten: keine zweite Benachrichtigung fuer dieselbe Erinnerung.
|
||||||
|
6. "Spaeter erinnern" -> "In 10 Minuten": Zeile ist wieder kuenftig mit neuer Zeit (Bearbeiten/Loeschen sichtbar). "Bearbeiten": Titel aendern, speichern. Loeschen mit Rueckfrage (Ja/Nein). Eine weitere Erinnerung faellig werden lassen, dann "Erledigt": sie verschwindet.
|
||||||
|
7. E-Mail: lokal ist SMTP gesetzt (mailhog:1025), Checkbox ist bedienbar. Fuer den echten Mailfluss zuerst mailhog starten, dann eine Erinnerung mit Haken faellig werden lassen: genau eine Mail mit Zeit in Europe/Berlin. Nach "In 10 Minuten" kommt nach Ablauf eine zweite. (Den ausgegrauten Zustand mit Erklaerungstext kann man pruefen, indem man in Einstellungen -> SMTP die Einrichtung entfernt; danach wiederherstellen.)
|
||||||
|
8. Als `admin` (admin123): das Widget zeigt keine Erinnerungen von testuser.
|
||||||
|
9. Am Ende alle Test-Erinnerungen entfernen.
|
||||||
|
|
||||||
|
**Windows-VM (Proxmox 8233, laut VM-Notizen):**
|
||||||
|
Die Rust-Aenderung wirkt nur in einem Desktop-Client, der aus diesem Stand gebaut ist. Da nichts gepusht wurde, baut CI keinen; der Windows-Test braucht entweder Push + CI-Paket oder ein lokal gebautes NSIS-Paket. Ein aelterer Client zeigt das Widget und bekommt E-Mails, aber keine Toasts (im CHANGELOG vermerkt).
|
||||||
|
10. Mit dem neuen Client, verbunden mit dem Server mit diesem Code: Erinnerung in 3 Minuten anlegen, Fenster mit X schliessen (Infobereich), warten. Ein Windows-Toast von "Tessera" mit "Erinnerung: ..." erscheint innerhalb rund einer Minute nach der Faelligkeit (WebView2 drosselt versteckte Timer, E-01). Danach das Fenster oeffnen: die Erinnerung zeigt "Faellig".
|
||||||
|
11. Desktop-App und Browser gleichzeitig offen: jede zeigt die Benachrichtigung genau einmal.
|
||||||
|
12. Nach "Server-Adresse aendern..." auf denselben Server funktionieren die Benachrichtigungen weiter (Berechtigung wird in `save_server_url` erneut erteilt).
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- Erstellte Dateien vorhanden: Migration, `apps/api/src/reminders/*` (inkl. `reminder-mail.scheduler.ts`), `reminders-api.ts`, `reminder-notify.ts`, `reminder-time.ts`, `reminder-notifier.tsx`, `reminder-widget.tsx`, `reminder-form-modal.tsx` (alle in den Commits enthalten).
|
||||||
|
- Commits 26f8f0f, 580c31c, 8027c48 liegen auf `main` (`git log cd1f8f6..HEAD`); `git status` zeigt ausser dem `.planning`-Verzeichnis nichts Uncommittetes.
|
||||||
|
- Nichts gepusht.
|
||||||
|
|
||||||
|
## Browser-Pruefung (Orchestrator, 29.09., dunkel, testuser)
|
||||||
|
|
||||||
|
- Katalog zeigt „Erinnerungen“; Widget hinzugefuegt, Leerzustand + „Neue Erinnerung“.
|
||||||
|
- „Kaffee holen“ faellig 14:14 mit E-Mail-Haken, danach auf den Marktplatz gewechselt: genau eine Browser-Benachrichtigung „Erinnerung: Kaffee holen“ um 14:14:08 (global, nicht nur auf dem Dashboard).
|
||||||
|
- MailHog (lokal gestartet, Alias mailhog im backend-net): genau eine Mail an testuser@example.com um 14:14:15, Text „Dienstag, 29. September 2026 um 14:14 Uhr“ (Berlin).
|
||||||
|
- Zweiter Tab derselben Sitzung, 20 s: keine zweite Benachrichtigung.
|
||||||
|
- Faellig-Zustand dunkel gut lesbar (Rahmen + Abzeichen „Fällig“, Erledigt / Später erinnern).
|
||||||
|
- Später erinnern: Menue In 10 Minuten / In 1 Stunde / Morgen um 14:14; „In 10 Minuten“ -> 14:26. Bearbeiten (Titel) ok. Löschen mit Rueckfrage Ja/Nein ok.
|
||||||
|
- „Wasser trinken“ (ohne E-Mail) faellig -> Erledigt -> verschwindet; keine zweite Mail.
|
||||||
|
- Aufgeraeumt: Reminder-Tabelle leer, MailHog-Container entfernt.
|
||||||
|
- Offen: Windows-Toast der Desktop-App (braucht CI-Paket nach Push), Erlaubnisabfrage-erst-nach-Speichern nur per Komponententest belegt (Playwright-Kontext hatte die Erlaubnis vorab).
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260929-if2
|
||||||
|
verified: 2026-09-29T12:20:00Z
|
||||||
|
status: human_needed
|
||||||
|
score: 8/9 must-haves verified
|
||||||
|
behavior_unverified: 1
|
||||||
|
overrides_applied: 0
|
||||||
|
behavior_unverified_items:
|
||||||
|
- truth: "At the due time the desktop app shows a native OS notification, also while the main window is hidden in the tray, through the notification plugin, which the page may call only from the stored server origin"
|
||||||
|
test: "Windows VM with a desktop client built from commit 6879c75 (or CI package): create a reminder due in 3 minutes, close the window with X, wait; then open the window"
|
||||||
|
expected: "A Windows toast 'Erinnerung: ...' appears within about 1 minute of the due time; the reminder shows 'Faellig' afterwards. Also: after 'Server-Adresse aendern...' to the same server the toast still works"
|
||||||
|
why_human: "Unit tests only pin the URL pattern (escape, self-check, match/no-match) and the exact 3-permission set. Whether Tauri accepts the runtime capability (remote + notification:allow-* identifiers) and delivers the toast cannot be seen by grep or cargo test; add_capability panics rather than returning Err on a bad pattern/identifier"
|
||||||
|
human_verification:
|
||||||
|
- test: "Browser (Playwright MCP, dark): create a reminder due in 2 min, switch to another portal page, wait past due time, spy on window.Notification"
|
||||||
|
expected: "Exactly one Notification call titled 'Erinnerung: ...' although the dashboard is not visible; on return the row is highlighted with 'Faellig', 'Erledigt' and 'Spaeter erinnern'; a second tab of the same session gives no second notification"
|
||||||
|
why_human: "Real Notification permission flow and multi-tab Web Locks/localStorage dedup need a live browser (orchestrator runs these)"
|
||||||
|
- test: "Browser, fresh context without notification grant"
|
||||||
|
expected: "Permission prompt appears only after clicking 'Speichern' on the first reminder, never on page load"
|
||||||
|
why_human: "Browser permission UI"
|
||||||
|
- test: "Dark-mode look of the 'Faellig' badge (bg-status-warn/20 text-status-warn-fg, deviation 3) and highlighted row"
|
||||||
|
expected: "Readable contrast"
|
||||||
|
why_human: "Visual"
|
||||||
|
- test: "E-mail flow with a reachable SMTP (start mailhog or real SMTP), reminder with the e-mail tick due, then snooze +10 min"
|
||||||
|
expected: "Exactly one mail with time in Europe/Berlin; after the snooze a second one; e-mail checkbox greyed out with explanation when SMTP is removed"
|
||||||
|
why_human: "Real transport; locally the mailhog container is not running (summary: 3 failed attempts then stop, as designed)"
|
||||||
|
- test: "Windows VM: desktop app and browser open simultaneously"
|
||||||
|
expected: "Each shows the notification exactly once"
|
||||||
|
why_human: "Needs Windows GUI"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260929-if2: Reminder widget "Erinnerungen" Verification Report
|
||||||
|
|
||||||
|
**Phase Goal:** Reminder widget with notification in desktop app, browser and optionally by e-mail; one-time, no advance warning, after due "Erledigt"/"Spaeter erinnern" (10 min / 1 h / tomorrow); personal with RLS; e-mail exactly once per due occurrence.
|
||||||
|
**Verified:** 2026-09-29
|
||||||
|
**Status:** human_needed
|
||||||
|
**Re-verification:** No, initial verification
|
||||||
|
|
||||||
|
Note on commits: verified against the re-created commits 325c5dd, 709b41a, 6879c75 (HEAD, three commits above cd1f8f6; nothing pushed, `git log origin/main..HEAD` shows exactly these). No source files were modified by this verification. The only untracked path is the task directory. During verification an unrelated test reminder ("Kaffee holen") appeared in the live DB, presumably from the orchestrator's browser check; it was not touched.
|
||||||
|
|
||||||
|
## Goal Achievement
|
||||||
|
|
||||||
|
### Observable Truths
|
||||||
|
|
||||||
|
| # | Truth | Status | Evidence |
|
||||||
|
|---|-------|--------|----------|
|
||||||
|
| 1 | User adds widget, creates reminder (date/time/title/description, local time), sees only own open reminders sorted by due time (D-05) | VERIFIED | `reminder-widget.tsx` sorts by dueAt, lists via `listReminders`; `reminders.service.ts list()` uses `forTenant(prisma, tenantId, userId)` + `where {tenantId,userId}`, `orderBy dueAt asc`; registered in registry, `page.tsx` (`registerWidget('reminder', ReminderWidget)`), `WIDGET_TYPES` in shared; widget test green; live GET as testuser returns 200 array |
|
||||||
|
| 2 | At due time an open tab shows a Web Notification once granted; permission asked only from widget at first creation, never on page load (D-04) | VERIFIED (unit-level; live browser in human items) | `reminder-notify.ts`: `requestBrowserPermissionOnce` (flag in localStorage, no-op in Tauri/when not 'default'); called first in submit handler only when `!reminder` (create); `ReminderNotifier` mounted in `app-shell.tsx:48`; `remindersToNotify` only `dueAt <= now` (D-02) within 24 h; widget/notifier/notify tests green (410 web tests in scope, 1069 full) |
|
||||||
|
| 3 | Desktop app shows native OS notification also while window hidden in tray, via plugin, callable only from stored server origin | PRESENT_BEHAVIOR_UNVERIFIED | Code present and wired: `showReminderNotification` invokes `plugin:notification\|notify` with `{options:{title,body}}`; `grant_server_notifications` called in `setup()` before first `navigate` and in `save_server_url`; `server_origin_pattern` escapes host, self-checks with `RemoteUrlPattern`; `SERVER_NOTIFICATION_PERMISSIONS` pinned to 3 ids; capability `.local(false).window("main")`; plugin registered (`lib.rs:867`). cargo: 57 passed, 12 `server_origin_*`; fmt ok. `add_capability` in tauri 2.11.3 appends (checked source), so repeated calls do not overwrite. Actual toast delivery / runtime acceptance is not exercised by any test, so routed to human (Windows) |
|
||||||
|
| 4 | Every client shows each (id, dueAt) at most once: tabs share local claim, desktop and browser each notify once | VERIFIED (unit-level) | `claimNotification` (localStorage record, key `${id}\|${dueAt}`, 7-day prune) under `withNotifyLock` (Web Locks); key changes after snooze; notifier test: one notification across several ticks, again after dueAt change. Separate webview storage means desktop and browser each notify once by design. Multi-tab live check in human items |
|
||||||
|
| 5 | Due reminder stays highlighted with "Erledigt" (removes) and "Spaeter erinnern" (+10 min, +1 h, tomorrow same time); snooze sets new dueAt so notifications and e-mail fire again (D-01, D-03) | VERIFIED | Widget: due rows (`dueAt <= now`, 10 s tick) get `border-status-warn`, badge, Erledigt (`deleteReminder`), snooze options via `snoozeTarget`; `reminder-time.ts snoozeTarget` (now+10m, now+1h, original time + calendar days until future); service `snooze` writes `{dueAt, emailSentAt: null, emailAttempts: 0}`; no recurrence field/UI anywhere in schema/DTO; time/widget/service tests green |
|
||||||
|
| 6 | Upcoming reminders editable/deletable; editing a due one is 409; snoozing a not-due one is 409 | VERIFIED | `service.update` 409 when `dueAt <= now`; `snooze` 409 when `dueAt > now`; controller has PATCH/POST snooze/DELETE below static `email-status`; route-order spec in controller spec (14 tests green); widget shows edit/delete only on non-due rows |
|
||||||
|
| 7 | With e-mail on, server sends exactly one mail per due occurrence via tenant SMTP (Europe/Berlin), no client needed, several instances safe (atomic claim); toggle disabled with explanation when SMTP missing / no e-mail | VERIFIED (unit-level; real transport in human items) | `reminder-mail.scheduler.ts`: single `forSystem` call, scalar select, candidate filter (emailEnabled, emailSentAt null, attempts<3, due within 24 h), claim `updateMany` with `dueAt` equality and `emailSentAt: null`, count===1 check before send, release only on transport failure with own timestamp, skip keeps claim, `running` reentrancy guard, 30 s `addInterval`. `MailService.sendReminderEmail`: CR/LF stripped subject, `Europe/Berlin` `de-DE` + " Uhr", text only, returns bool. Scheduler spec: 16 tests incl. two instances one mail, 3-attempt cap, snooze re-arm. Toggle: `emailAvailable`/`emailHint` in form modal; email-status endpoint live: `{"smtpConfigured":true,"hasEmail":true}`. Summary reports a live DB run with 3 attempts then stop |
|
||||||
|
| 8 | Foreign reminder id always 404 (never 403); Reminder has tenant+user RLS policy and system read policy; rls-coverage and rls-access-inventory green; classification doc re-measured | VERIFIED | `loadOwn` throws `NotFoundException` for unknown/foreign; live DELETE on unknown id returns 404; DB: `relrowsecurity` and `relforcerowsecurity` both true; policies `tenant_isolation_policy` (ALL, tenant AND user dim) and `system_read_policy` (SELECT, `is_system_context()`); `migrate diff --exit-code` "No difference detected"; rls-coverage and rls-access-inventory pass; `FORSYSTEM_ALLOWED_CALL_SITES` has `reminder-mail.scheduler.ts, 1`; doc updated in all three commits (numbers not independently re-counted) |
|
||||||
|
| 9 | All API/web tests green, type-check and lint green, Biome warnings web <= 55 and api <= 82, cargo test/fmt/clippy green | VERIFIED (clippy not re-run) | API full: 91 files / 1570 tests pass; web full: 108 files / 1069 tests pass; `turbo run type-check lint --force`: 9/9 successful; Biome web 55, api 82 (exactly baseline); cargo test 57 pass, fmt ok. Clippy `-D warnings` not re-run by me (summary claims clean) |
|
||||||
|
|
||||||
|
**Score:** 8/9 truths verified (1 present, behavior-unverified)
|
||||||
|
|
||||||
|
### Required Artifacts
|
||||||
|
|
||||||
|
| Artifact | Status | Details |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `apps/api/prisma/migrations/20260929140000_reminder/migration.sql` | VERIFIED | Table, indexes, FK cascade, ENABLE+FORCE RLS, both policies; applied locally, no schema drift |
|
||||||
|
| `apps/api/src/reminders/reminders.service.ts` / `.controller.ts` | VERIFIED | Owner-scoped CRUD/snooze/email-status, all through `forTenant(..., tenantId, userId)`; `REMINDER_SELECT` excludes tenantId/userId/emailSentAt/emailAttempts |
|
||||||
|
| `apps/api/src/reminders/reminder-mail.scheduler.ts` | VERIFIED | Registered in `RemindersModule` providers; module registered in `app.module.ts` |
|
||||||
|
| `apps/web/src/lib/reminder-notify.ts` | VERIFIED | Tauri vs browser branch, one-time permission, dedup with Web Locks |
|
||||||
|
| `apps/web/src/components/reminders/reminder-notifier.tsx` | VERIFIED | Mounted in `app-shell.tsx` |
|
||||||
|
| `apps/web/src/components/dashboard/widgets/reminder-widget.tsx` (+ form modal) | VERIFIED | Wired via registry, `page.tsx`, `widget-wrapper` FRAME_HEADER_TYPES, icon |
|
||||||
|
| `apps/desktop/src-tauri/src/lib.rs` | VERIFIED (code), see truth 3 | `server_origin_pattern`, `grant_server_notifications`, 12 tests |
|
||||||
|
|
||||||
|
### Key Link Verification
|
||||||
|
|
||||||
|
| From | To | Status | Details |
|
||||||
|
|------|----|--------|---------|
|
||||||
|
| `app-shell.tsx` | `ReminderNotifier` | WIRED | line 48 |
|
||||||
|
| `reminder-notify.ts` | plugin notification | WIRED | `invoke('plugin:notification\|notify', { options })` |
|
||||||
|
| `lib.rs` | Tauri runtime authority | WIRED | `add_capability` in `grant_server_notifications`, called in `setup()` and `save_server_url` |
|
||||||
|
| scheduler | `MailService.sendReminderEmail` | WIRED | claim then send, release on false |
|
||||||
|
| `snooze` | `emailSentAt`/`emailAttempts` reset | WIRED | `data: { dueAt, emailSentAt: null, emailAttempts: 0 }` |
|
||||||
|
|
||||||
|
### Data-Flow Trace (Level 4)
|
||||||
|
|
||||||
|
Widget and notifier data come from `listReminders()` -> `GET /reminders` -> Prisma query (live check returned real rows). FLOWING. No hardcoded/static fallbacks.
|
||||||
|
|
||||||
|
### Behavioral Spot-Checks
|
||||||
|
|
||||||
|
| Behavior | Command | Result | Status |
|
||||||
|
|----------|---------|--------|--------|
|
||||||
|
| API reminders/mail/prisma/dashboard specs | `vitest run src/reminders src/mail src/prisma src/dashboard` | 17 files, 267 passed | PASS |
|
||||||
|
| Web reminder specs | `vitest run src/lib/reminder src/components/reminders src/components/dashboard src/messages` | 30 files, 410 passed | PASS |
|
||||||
|
| Full suites | api / web `vitest run` | 1570 / 1069 passed | PASS |
|
||||||
|
| cargo | `cargo test --lib`; `server_origin_` filter | 57 passed; 12 passed | PASS |
|
||||||
|
| Schema drift | `prisma migrate diff --exit-code` | exit 0 | PASS |
|
||||||
|
| RLS in DB | `pg_policies`, `pg_class` | both policies present, RLS+FORCE on | PASS |
|
||||||
|
| Live API | login, GET /reminders, GET /reminders/email-status, DELETE unknown id | 200, 200, 200, 404 | PASS |
|
||||||
|
| Type-check + lint, Biome | `turbo run type-check lint --force`; biome lint | 9/9; 55 / 82 warnings | PASS |
|
||||||
|
|
||||||
|
### Probe Execution
|
||||||
|
|
||||||
|
No probes declared. SKIPPED.
|
||||||
|
|
||||||
|
### Requirements Coverage
|
||||||
|
|
||||||
|
QUICK-260929-if2 (all decisions D-01..D-05, E-01..E-09) implemented as described; no REQUIREMENTS.md mapping.
|
||||||
|
|
||||||
|
### Anti-Patterns Found
|
||||||
|
|
||||||
|
None. No TBD/FIXME/XXX/TODO in the new files; no stubs; no debt markers. Working tree clean apart from the task directory.
|
||||||
|
|
||||||
|
### Notes / minor observations (non-blocking)
|
||||||
|
|
||||||
|
- Deviation 3 (badge uses `bg-status-warn/20` instead of the plan's solid fill) is documented and justified by contrast; flagged for a dark-mode visual check.
|
||||||
|
- Summary deviation 6 mentions the trailer as "Claude Sonnet 5.5"; the re-created commits carry "Claude Opus 5.5 (1M context)". Immaterial to the goal.
|
||||||
|
- `claimNotification` claims before showing: a browser whose permission is still 'default' at due time loses that occurrence's notification (still visible in the widget, highlighted). Consistent with the plan ("blocked notifications only show in the widget").
|
||||||
|
- Fingerprint fields (`covered_files`/`covered_digest`) were not generated because the fingerprint verb was not run in this environment.
|
||||||
|
|
||||||
|
## Human Verification Required
|
||||||
|
|
||||||
|
See frontmatter `human_verification` and `behavior_unverified_items`. Summary: (1) Windows toast in the tray, runtime capability accepted by Tauri, plus desktop+browser once-each; (2) live browser notification, permission prompt timing and two-tab dedup; (3) dark-mode look of the "Faellig" badge; (4) real SMTP flow (mailhog is not running locally).
|
||||||
|
|
||||||
|
## Gaps Summary
|
||||||
|
|
||||||
|
No gaps. All code-verifiable must-haves hold in the codebase and the automated gates reproduce the summary's numbers (tests, type-check, lint, Biome baseline, migrate diff, RLS state in the live DB). The single behavior-dependent truth that cannot be proven without a GUI is the actual desktop toast through the runtime Tauri capability; it is left PRESENT_BEHAVIOR_UNVERIFIED and routed to the Windows check, so the status is `human_needed`, not `passed`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Verified: 2026-09-29_
|
||||||
|
_Verifier: Claude (gsd-verifier)_
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
quick_id: 260929-lh3
|
||||||
|
type: quick
|
||||||
|
wave: 1
|
||||||
|
autonomous: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260929-lh3: Favoriten — eigene Symbol-Adresse wirkt nicht
|
||||||
|
|
||||||
|
## User reports (29.09.2026, alpha 8c644de)
|
||||||
|
|
||||||
|
1. Favorite with URL https://docuvita.ctl.local/server/services/web/ shows a black circle with a white "V"
|
||||||
|
instead of the page's favicon (visible in the browser tab).
|
||||||
|
2. Setting an explicit icon URL ("Symbol-Adresse", field `iconUrl`) to
|
||||||
|
https://nextcloud.com/c/uploads/2025/10/Nextcloud_01-standard-logo.png on a favorite does not change the shown icon.
|
||||||
|
|
||||||
|
## Measured facts (orchestrator, from inside the alpha api container)
|
||||||
|
|
||||||
|
- docuvita.ctl.local resolves (172.16.0.46). Server-side GET of the page returns **400** but the HTML contains
|
||||||
|
`<link rel="SHORTCUT ICON" type="image/png" href="/webclient/docuvita/resources/brandimage/favicon.ico" />`.
|
||||||
|
Server-side GET of that icon returns **404 text/html** (also with a Chrome User-Agent). Root /favicon.ico → 404.
|
||||||
|
→ docuvita refuses the files to the server; the browser can load them (user sees the icon in the tab).
|
||||||
|
- Discovery (`apps/api/src/favorites/icon-discovery.service.ts` `discoverFavoriteIconUrl`) ignores non-2xx HTML
|
||||||
|
(`fetchHtml` returns null) → falls back to `{origin}/favicon.ico`; proxy fails → browser direct
|
||||||
|
`{origin}/favicon.ico` shows the "V" (a real icon served to browsers at the root).
|
||||||
|
- Explicit `iconUrl` that the server cannot fetch → API answers 422 `iconUrlUnreachable` (quick 260923-lrr),
|
||||||
|
so the user cannot set the docuvita icon URL at all.
|
||||||
|
|
||||||
|
## Task 1: Explicit icon URL change must show immediately (bug 2)
|
||||||
|
|
||||||
|
- files: apps/api/src/favorites/*, apps/web/src/components/dashboard/widgets/favorites-widget.tsx, apps/web/src/lib/favorites-api.ts (+ tests)
|
||||||
|
- action: Reproduce locally (admin/admin123, favorites widget): set/change `iconUrl` to a reachable PNG (e.g. the Nextcloud URL,
|
||||||
|
and a second different one). Find why the tile keeps the old image — likely the icon proxy URL
|
||||||
|
(`/favorites/:id/icon?...`) does not change when `iconUrl` changes (browser/HTTP cache, Cache-Control on the proxy
|
||||||
|
response, `iconVersion` only bumped on upload, or the server returns a cached/discovered icon instead of the explicit one).
|
||||||
|
Fix at the root: the explicit `iconUrl` wins over discovery, and any change of `iconUrl` changes the image URL
|
||||||
|
(e.g. cache-buster from `iconVersion` bumped on every iconUrl change, or a hash of iconUrl). Add regression tests
|
||||||
|
(API: PATCH iconUrl bumps version / proxy serves new bytes; web: tile src changes when iconUrl changes).
|
||||||
|
- verify: api + web tests for favorites green.
|
||||||
|
- done: commit `fix(favorites): geaenderte Symbol-Adresse wird sofort angezeigt`.
|
||||||
|
|
||||||
|
## Task 2: Accept icon URLs the server cannot fetch; browser loads them directly (bug 1)
|
||||||
|
|
||||||
|
- action:
|
||||||
|
- API: an explicit `iconUrl` that is a valid http/https URL is stored even if the server cannot fetch it
|
||||||
|
(no more 422 for "unreachable"; keep validation of scheme/length and keep rejecting non-image responses only
|
||||||
|
when the server DID get a response with a non-image content type — decide and document). Keep SSRF guard for
|
||||||
|
server-side fetches unchanged.
|
||||||
|
- Web tile: chain for an explicit `iconUrl`: proxy image → on error the browser loads `iconUrl` directly
|
||||||
|
(`<img>` with referrerPolicy="no-referrer", only http/https) → letter fallback. Existing chain for discovered icons unchanged.
|
||||||
|
- Discovery improvement (small, safe): if the page answers non-2xx but returns HTML with a `<link rel=icon>`,
|
||||||
|
still use that icon URL (so docuvita-like servers yield `/webclient/.../favicon.ico`, which the browser can then load directly).
|
||||||
|
- Remove/adjust the now-unused `iconUrlUnreachable` error text (de/en) if no longer reachable.
|
||||||
|
- verify: api + web favorites tests green; type-check/lint green; biome web ≤ 55, api ≤ 82.
|
||||||
|
- done: commit `fix(favorites): Symbol-Adresse auch speichern, wenn nur der Browser sie laden kann`.
|
||||||
|
|
||||||
|
## Task 3: CHANGELOG + rebuild
|
||||||
|
|
||||||
|
- CHANGELOG `## Unveröffentlicht` → `### Behoben`: two plain-German bullets (Sie-Form) for both fixes.
|
||||||
|
- `docker compose up -d --build web api`.
|
||||||
|
- Commit `docs(changelog): Favoriten-Symbole`.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Commit locally only, NEVER git push. Commits end with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`.
|
||||||
|
- Commit with explicit paths only.
|
||||||
|
- Browser check is done by the orchestrator.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
quick_id: 260929-lh3
|
||||||
|
phase: quick
|
||||||
|
plan: 260929-lh3
|
||||||
|
subsystem: favorites
|
||||||
|
tags: [favorites, icons, icon-discovery, browser-fallback]
|
||||||
|
status: complete
|
||||||
|
commits: 3
|
||||||
|
plan_head_before: 8c644de5dad56a0394a14a00180a125919565246
|
||||||
|
plan_head_after: 0e72ad45f8833cb93ae9a8c0afa1f6d4e948e3e0
|
||||||
|
actuals:
|
||||||
|
tasks: 3
|
||||||
|
commits: 3
|
||||||
|
key-files:
|
||||||
|
modified:
|
||||||
|
- apps/api/src/favorites/favorites.service.ts
|
||||||
|
- apps/api/src/favorites/icon-discovery.service.ts
|
||||||
|
- apps/api/src/favorites/dto/create-favorite.dto.ts
|
||||||
|
- apps/api/src/favorites/dto/update-favorite.dto.ts
|
||||||
|
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||||
|
- apps/web/src/lib/favorites-api.ts
|
||||||
|
- apps/web/src/messages/de.json
|
||||||
|
- apps/web/src/messages/en.json
|
||||||
|
- CHANGELOG.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260929-lh3: Favoriten-Symbol-Adresse Summary
|
||||||
|
|
||||||
|
Explicit icon URLs are now stored even when the server cannot fetch them, the tile loads them directly in the browser, discovery uses `<link rel=icon>` from non-2xx HTML pages, and a newly entered icon URL replaces a previously uploaded icon.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
- 7188c5b `fix(favorites): geaenderte Symbol-Adresse wird sofort angezeigt` (Task 1)
|
||||||
|
- b15c746 `fix(favorites): Symbol-Adresse auch speichern, wenn nur der Browser sie laden kann` (Task 2)
|
||||||
|
- 0e72ad4 `docs(changelog): Favoriten-Symbole` (Task 3)
|
||||||
|
|
||||||
|
## Root cause, bug 2 ("Symbol-Adresse wirkt nicht")
|
||||||
|
|
||||||
|
Measured, not assumed:
|
||||||
|
|
||||||
|
- Local reproduction (API with curl, and the real widget in headless Chromium via CDP): changing `iconUrl` on a favorite WITHOUT an uploaded icon works. `iconVersion` is bumped, the tile is remounted, the `<img>` src changes (`?v=1` -> `?v=2`), and the bytes change (naturalWidth 626 -> 48). The proxy, `Cache-Control` and Next rewrite are not the cause.
|
||||||
|
- The alpha database (read-only psql) shows the save path works there too: favorite "Medon" holds the Nextcloud URL with `iconVersion = 1`. No favorite on alpha has `uploadedIconMime` set, and the alpha web image contains the `?v=` code.
|
||||||
|
- The real defect found in the code: `getIconBytes` always serves the uploaded file when `uploadedIconMime` is set, and `update()` never cleared it. A newly entered `iconUrl` was therefore saved but invisible for any favorite with an uploaded icon (the form even said "Ein hochgeladenes Symbol hat Vorrang"). Fixed: a new, different, non-empty `iconUrl` now clears `uploadedIconMime`, removes the file, and bumps `iconVersion`. An unchanged `iconUrl` (the form resends it on every save) leaves the upload alone.
|
||||||
|
- Caveat for the orchestrator: for the exact alpha "Medon" case (no upload) I could not reproduce a stale display; the alpha row and local browser behavior are both correct. The browser check on alpha (https, NPM, basic auth) remains the only place this can still show. If it still fails there with the new build, capture the network request of `/api-proxy/favorites/<id>/icon?v=1` in the alpha browser.
|
||||||
|
|
||||||
|
## Root cause, bug 1 (black circle with "V")
|
||||||
|
|
||||||
|
`fetchHtml` dropped every non-2xx response, so docuvita (answers the server with 400 but ships `<link rel="SHORTCUT ICON" href="/webclient/.../favicon.ico">`) fell back to `{origin}/favicon.ico`; the proxy failed and the browser showed the root favicon (the "V"). And an explicit `iconUrl` was rejected by the 422 fetch probe because docuvita returns 404 HTML to the server.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- API: `assertIconUrlLoadable` (422) replaced by `assertIconUrlWellFormed` (http/https, <= 2048 chars, else 400); DTO `@MaxLength(2048)` on both DTOs. Decision, documented in code: even a response the server DID receive with a non-image type does not reject, because "server gets no image" does not mean "browser gets none". SSRF guard for server-side fetches is unchanged.
|
||||||
|
- Discovery: `fetchWithRedirectGuard` got `allowErrorStatus` (used only by the HTML search; `fetchIconBytes` stays strict). On a non-2xx page only `<link rel=...icon>` counts, not `og:image`.
|
||||||
|
- Web tile: proxy -> `iconUrl` direct (`referrerPolicy="no-referrer"`, http/https only) -> `{origin}/favicon.ico` (skipped when identical) -> letter. Existing chain for discovered icons behaves as before.
|
||||||
|
- Removed the `iconUrlUnreachable` reason, 422 handling and de/en texts; adjusted the upload hint (a newly entered address replaces an upload).
|
||||||
|
- CHANGELOG: two plain-German bullets under Unveröffentlicht / Behoben.
|
||||||
|
- Rebuilt `web` and `api` (`docker compose up -d --build`, healthy). Smoke test against the rebuilt API: URL the server cannot fetch is saved (proxy answers 502, tile falls back to browser), `javascript:` is rejected with 400. Test favorite deleted afterwards.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- API favorites specs: 99 tests green. Web favorites widget, favorites-api and messages tests: 47 green. `tsc --noEmit` clean for web and api.
|
||||||
|
- Biome: web 55 warnings, api 82 warnings (at the limits, not above).
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
- [Rule 2 - Missing validation] Plan said "keep validation of scheme/length", but none existed for `iconUrl`; added form validation (service + DTO length) so the SSRF-relevant direct browser load never receives non-http(s) schemes.
|
||||||
|
- Task 1 needed no web change: the existing test "Speichern mit neuer Logo-Adresse ... ?v=1" already covers the src change; API regression tests were added.
|
||||||
|
- Commits were made on `main` as instructed (no worktree).
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
Commits 7188c5b, b15c746, 0e72ad4 exist; SUMMARY location correct; PLAN/SUMMARY/STATE not committed.
|
||||||
|
|
||||||
|
## Browser-Pruefung (Orchestrator, 29.09.)
|
||||||
|
|
||||||
|
- Favorit „Claude“: iconUrl -> Nextcloud-PNG (PATCH 200), nach Neuladen Proxy-Bild ?v=5 mit 626 px Breite geladen.
|
||||||
|
- iconUrl -> docuvita-Brand-Icon (vom Dev-Host nicht aufloesbar): PATCH 200 (frueher 422), Kachel faellt sauber zurueck. Im Firmennetz laedt der Browser direkt.
|
||||||
|
- Zurueckgesetzt auf das urspruengliche Symbol.
|
||||||
+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.
|
||||||
+88
-1
@@ -6,11 +6,98 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
|||||||
|
|
||||||
### Neu
|
### Neu
|
||||||
|
|
||||||
- Eigene Module: Als Administrator können Sie unter „Verwaltung“ im Punkt „Eigene Module“ andere Webseiten in die Seitenleiste aufnehmen – mit Name, Adresse (nur https) und Kategorie, etwa „Infrastruktur“. Alle Benutzer sehen die Einträge unter der gewählten Kategorie; ein Klick zeigt die Seite direkt in Tessera. Manche Seiten verbieten das Einbetten – dafür gibt es immer den Knopf „In neuem Tab öffnen“.
|
- 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
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- Dashboard: Neues Widget „Erinnerungen“. Sie legen eine Erinnerung mit Datum, Uhrzeit, Titel und Beschreibung an, und Tessera meldet sich genau zur gewählten Zeit: im Browser mit einer Benachrichtigung (der Browser fragt dafür einmal um Erlaubnis, und zwar beim ersten Anlegen), in der Desktop-App mit einer Windows-Benachrichtigung – auch wenn das Fenster im Infobereich liegt. Wenn Sie möchten, schickt Tessera zusätzlich eine E-Mail an Ihre Adresse, auch dann, wenn Tessera gerade nirgends geöffnet ist. Eine fällige Erinnerung bleibt im Widget hervorgehoben stehen, bis Sie „Erledigt“ wählen oder mit „Später erinnern“ verschieben – auf in 10 Minuten, in 1 Stunde oder morgen zur gleichen Uhrzeit; dann meldet sich Tessera (und bei Bedarf die E-Mail) noch einmal. Erinnerungen sind persönlich: nur Sie sehen und ändern Ihre. Für die Desktop-Benachrichtigungen braucht die Desktop-App ihre neue Version, die Sie über „Auf Version … aktualisieren“ im Menü des Tessera-Symbols erhalten; Widget und E-Mail funktionieren auch mit der bisherigen Version.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Eigene Module: Einmal geöffnete Seiten bleiben im Hintergrund offen. Wechseln Sie zurück, ist die Seite sofort da – im selben Zustand, ohne neu zu laden. Tessera hält die fünf zuletzt benutzten offen; beim Abmelden werden sie geschlossen.
|
||||||
|
- Dashboard, Favoriten: In der Kachelansicht stehen die Symbole enger beieinander; der Abstand zwischen ihnen ist etwa halb so groß, in eine Zeile passen mehr Favoriten. Passt ein Name nicht in eine Zeile, wird er kleiner geschrieben und auf zwei Zeilen umbrochen.
|
||||||
|
- Dashboard: Es sieht jetzt auf jedem Bildschirm gleich aus. Tessera merkt sich die Fläche des Bildschirms, an dem Sie ein Dashboard zuerst öffnen, und zeigt es auf anderen Bildschirmen maßstäblich verkleinert oder vergrößert – samt Schrift und ohne Scrollen. Ist ein Dashboard länger als der Bildschirm, wird es so weit verkleinert, dass es ganz hineinpasst. Auf dem Handy bleibt es bei der bisherigen Anordnung untereinander.
|
||||||
|
- Eigene Module: Neue Einträge sind mit der Kategorie „Eigene Module“ vorbelegt.
|
||||||
|
- Dashboard: Der Kalender lässt sich nicht mehr so schmal ziehen, dass seine Überschrift abgeschnitten wird.
|
||||||
|
- Eigene Module: Die Seite füllt jetzt den ganzen Inhaltsbereich. Name und Hinweiszeile darüber sind weggefallen – der Name steht ohnehin oben in der Leiste, und „In neuem Tab öffnen“ sitzt jetzt dort rechts.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Eigene Module: Lässt sich ein Eintrag nicht laden, sagt Tessera das jetzt, statt „nicht gefunden“ zu melden. Fehlende Berechtigung und ungültige Angaben werden beim Speichern und Löschen eigens genannt.
|
||||||
|
- Seitenleiste: Eingeklappt stehen eigene Module jetzt bei ihrer Kategorie, in derselben Reihenfolge wie ausgeklappt.
|
||||||
|
- Erinnerungen: Ohne Browser-Speicher (etwa im privaten Fenster) kam dieselbe Benachrichtigung alle 10 Sekunden – jetzt nur einmal.
|
||||||
|
- Erinnerungen: Nach einer Änderung von Datum oder Uhrzeit kommt die E-Mail zuverlässig zur neuen Zeit. Deaktivierte Benutzer bekommen keine Erinnerungs-E-Mails mehr.
|
||||||
|
- Erinnerungen: „Später erinnern“ zeigt „Heute um …“, wenn die Uhrzeit heute noch kommt, statt fälschlich „Morgen um …“. Speichern ohne Zeitänderung verschiebt die Fälligkeit nicht mehr um Sekunden.
|
||||||
|
- Dashboard: Wird ein Bild gelöscht, das als Hintergrund gewählt war, gilt wieder „kein Hintergrund“.
|
||||||
|
- Dashboard: Ein noch offener Tab mit älterer Tessera-Version kann die Anordnung nicht mehr verziehen; er bittet stattdessen, die Seite neu zu laden.
|
||||||
|
- Desktop-App: Während ein Update installiert wird, bietet das Menü kein zweites mehr an. Nach einer fehlgeschlagenen Update-Prüfung genügt wieder ein Klick zum Installieren.
|
||||||
|
- Desktop-App: „Auf Version … aktualisieren“ im Menü des Tessera-Symbols scheiterte mit „Signaturprüfung fehlgeschlagen“ und öffnete stattdessen die Download-Seite, wenn der Server seit der letzten Update-Prüfung der App eine neuere Version bekommen hatte. Die App fragt jetzt beim Klick zuerst frisch nach und installiert genau die Version, die der Server in diesem Moment anbietet.
|
||||||
|
- Dashboard, Favoriten: Eine neu eingetragene Logo-Adresse wird jetzt sofort angezeigt. Bisher blieb ein früher hochgeladenes eigenes Symbol stehen und verdeckte die neue Adresse; jetzt ersetzt die neue Adresse es.
|
||||||
|
- Dashboard, Favoriten: Eine Logo-Adresse lässt sich jetzt auch speichern, wenn Tessera das Bild selbst nicht laden kann – etwa bei Seiten im internen Netz, die nur Ihrem Browser das Symbol geben. Die Kachel lädt das Bild dann direkt in Ihrem Browser. Auch die automatische Erkennung findet das Symbol solcher Seiten jetzt eher, statt auf ein Ersatzsymbol zurückzufallen.
|
||||||
|
|
||||||
|
## 1.7.0 – 2026-09-29
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- Eigene Module: Als Kategorie steht jetzt auch „Eigene Module“ zur Auswahl. Einträge dort erscheinen gesammelt in einer eigenen Gruppe ganz unten in der Seitenleiste; die Gruppe ist nur zu sehen, solange ein Eintrag darin liegt. Bestehende Einträge verschieben Sie über „Bearbeiten“ dorthin.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Seitenleiste: Ist sie eingeklappt, sind die Symbole etwas größer und stehen etwas enger beieinander.
|
||||||
|
- Dashboard: Im Such-Widget haben die Auswahl der Suchmaschine und das Suchfeld keine helle Linie an der Unterkante mehr.
|
||||||
|
|
||||||
|
## 1.6.0 – 2026-09-29
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- Eigene Module: Jeder Benutzer kann unter „Einstellungen → Eigene Module“ Webseiten, die er oft braucht, als eigene Einträge in seine Seitenleiste aufnehmen – mit Name, Adresse (nur https) und Kategorie, etwa „Infrastruktur“. Diese Einträge sieht nur der Benutzer selbst. Ein Klick zeigt die Seite direkt in Tessera. Manche Seiten verbieten das Einbetten – dafür gibt es immer den Knopf „In neuem Tab öffnen“. Administratoren können zusätzlich unter „Verwaltung → Eigene Module“ Einträge für alle Benutzer anlegen; die sehen dann alle unter der gewählten Kategorie.
|
||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
|
|
||||||
- Dashboard: Die Widgets bleiben beim Darüberfahren mit der Maus ruhig stehen, sie heben sich nicht mehr an.
|
- Dashboard: Die Widgets bleiben beim Darüberfahren mit der Maus ruhig stehen, sie heben sich nicht mehr an.
|
||||||
|
- Dashboard: Die Widgets stehen in der Ansicht genau dort, wo Sie sie beim Bearbeiten platziert haben. Bisher rückte Tessera sie nach dem Bearbeiten zur Seitenmitte, sodass etwa ein einzelnes Widget oben links plötzlich in die Mitte sprang.
|
||||||
- Dashboard: Das Raster ist in der Breite doppelt so fein – Widgets lassen sich in kleineren Schritten breiter oder schmaler ziehen und genauer platzieren. Bestehende Anordnungen bleiben unverändert.
|
- Dashboard: Das Raster ist in der Breite doppelt so fein – Widgets lassen sich in kleineren Schritten breiter oder schmaler ziehen und genauer platzieren. Bestehende Anordnungen bleiben unverändert.
|
||||||
- Dashboard: Das Kalender-Widget lässt sich deutlich schmaler ziehen als bisher.
|
- Dashboard: Das Kalender-Widget lässt sich deutlich schmaler ziehen als bisher.
|
||||||
|
|
||||||
|
|||||||
@@ -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,77 @@
|
|||||||
|
-- 260929-dzu — Eigene Module fuer jeden Benutzer: persoenliche Eintraege.
|
||||||
|
--
|
||||||
|
-- Zweck: jeder Benutzer darf eigene Seitenleisten-Eintraege anlegen, die nur
|
||||||
|
-- er selbst sieht. Die Spalte "ownerUserId" unterscheidet: NULL = gemeinsamer
|
||||||
|
-- Eintrag (vom Administrator, fuer alle sichtbar, bisheriges Verhalten),
|
||||||
|
-- gesetzt = persoenlicher Eintrag dieses Benutzers. Faellt der Benutzer weg,
|
||||||
|
-- fallen seine Eintraege mit (ON DELETE CASCADE). Bestehende Zeilen bleiben
|
||||||
|
-- gemeinsam (NULL).
|
||||||
|
--
|
||||||
|
-- Zeilenschutz: Muster "SearchProvider" (20260911120000_rls_user_dimension_
|
||||||
|
-- personal_tables) — Spalte mit NULL = gemeinsame Zeile. Die eine Regel
|
||||||
|
-- "tenant_isolation_policy" (aus 20260929120000, ohne Benutzerdimension) wird
|
||||||
|
-- durch vier nach Befehl getrennte Regeln ersetzt (Praezedenz 260910-jab (3)):
|
||||||
|
-- ein einzelner USING-Ausdruck, der die gemeinsame Zeile zum Lesen einschliesst,
|
||||||
|
-- wuerde sie sonst auch zum Aendern/Entfernen freigeben.
|
||||||
|
-- SELECT: Mandant UND (kein Benutzer gesetzt ODER gemeinsame Zeile ODER
|
||||||
|
-- eigene Zeile).
|
||||||
|
-- INSERT/UPDATE/DELETE: Mandant UND (kein Benutzer gesetzt ODER eigene
|
||||||
|
-- Zeile). Ein Benutzerkontext kann gemeinsame Zeilen also NICHT
|
||||||
|
-- schreiben; der Administrator-Weg fuer gemeinsame Eintraege bindet
|
||||||
|
-- deshalb ohne Benutzer (`forTenant(prisma, tenantId)`), die
|
||||||
|
-- Rollenpruefung liegt im Controller/Dienst.
|
||||||
|
-- Die Regelnamen sind neu (vier statt eine), rls-coverage.spec.ts fordert nur
|
||||||
|
-- mindestens eine Regel je Tabelle mit eingeschaltetem RLS.
|
||||||
|
--
|
||||||
|
-- Rechte fuer tessera_app kommen ueber ALTER DEFAULT PRIVILEGES aus
|
||||||
|
-- 20260909130000_rls_app_role — hier nichts zu tun.
|
||||||
|
--
|
||||||
|
-- WICHTIG: wie alle RLS-Regeln dieses Schemas wirken diese erst, wenn die
|
||||||
|
-- Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter heute AUS, siehe
|
||||||
|
-- docs/mandantentrennung-datenbankrolle.md). Bis dahin tragen die
|
||||||
|
-- Anwendungspruefungen im Dienst den Schutz allein.
|
||||||
|
|
||||||
|
ALTER TABLE "CustomModule" ADD COLUMN "ownerUserId" TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX "CustomModule_tenantId_ownerUserId_idx" ON "CustomModule"("tenantId", "ownerUserId");
|
||||||
|
|
||||||
|
ALTER TABLE "CustomModule" ADD CONSTRAINT "CustomModule_ownerUserId_fkey"
|
||||||
|
FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
DROP POLICY tenant_isolation_policy ON "CustomModule";
|
||||||
|
|
||||||
|
CREATE POLICY tenant_user_read_policy ON "CustomModule"
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
"tenantId" = current_tenant_id()
|
||||||
|
AND (
|
||||||
|
current_user_id() IS NULL
|
||||||
|
OR "ownerUserId" IS NULL
|
||||||
|
OR "ownerUserId" = current_user_id()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY tenant_user_insert_policy ON "CustomModule"
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
"tenantId" = current_tenant_id()
|
||||||
|
AND (current_user_id() IS NULL OR "ownerUserId" = current_user_id())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY tenant_user_update_policy ON "CustomModule"
|
||||||
|
FOR UPDATE
|
||||||
|
USING (
|
||||||
|
"tenantId" = current_tenant_id()
|
||||||
|
AND (current_user_id() IS NULL OR "ownerUserId" = current_user_id())
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
"tenantId" = current_tenant_id()
|
||||||
|
AND (current_user_id() IS NULL OR "ownerUserId" = current_user_id())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY tenant_user_delete_policy ON "CustomModule"
|
||||||
|
FOR DELETE
|
||||||
|
USING (
|
||||||
|
"tenantId" = current_tenant_id()
|
||||||
|
AND (current_user_id() IS NULL OR "ownerUserId" = current_user_id())
|
||||||
|
);
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
-- 260929-if2 — Erinnerungen: persoenliche, einmalige Erinnerungen je Benutzer.
|
||||||
|
--
|
||||||
|
-- Zweck: die Tabelle "Reminder" traegt die Erinnerungen des Dashboard-Widgets
|
||||||
|
-- „Erinnerungen“ (Titel, Beschreibung, Faelligkeit, optional E-Mail). Es gibt
|
||||||
|
-- keine Wiederholung (D-01) und keine Historie: „Erledigt“ loescht die Zeile.
|
||||||
|
--
|
||||||
|
-- Besitz: eine Erinnerung gehoert genau einem Benutzer (gleicher Mandant UND
|
||||||
|
-- gleicher Benutzer, D-05). Faellt der Benutzer weg, fallen seine Erinnerungen
|
||||||
|
-- mit (ON DELETE CASCADE). Die Anwendung antwortet fuer fremde Kennungen mit
|
||||||
|
-- 404 (nie 403).
|
||||||
|
--
|
||||||
|
-- Spuren des E-Mail-Planers: "emailSentAt" ist der ANSPRUCH auf den Versand
|
||||||
|
-- (wird vor dem Senden gesetzt, damit mehrere API-Instanzen nicht doppelt
|
||||||
|
-- senden), "emailAttempts" zaehlt die Versuche (hoechstens 3). Ein Verschieben
|
||||||
|
-- der Faelligkeit setzt beide zurueck.
|
||||||
|
--
|
||||||
|
-- Zeilenschutz, zwei Regeln:
|
||||||
|
-- tenant_isolation_policy — Mandant UND Benutzer (Form aus DashboardImage,
|
||||||
|
-- 20260921120000_dashboard_image): ohne gesetzten Benutzer (Hintergrund-
|
||||||
|
-- dienst, der je Mandant gebunden schreibt) gilt nur der Mandant, mit
|
||||||
|
-- Benutzer zusaetzlich "userId".
|
||||||
|
-- system_read_policy — NUR FOR SELECT, Form aus 20260914120000_rls_system_
|
||||||
|
-- context_read. Sie bedient allein die Kandidatenabfrage des E-Mail-
|
||||||
|
-- Planers (reminder-mail.scheduler.ts), der einmal ueber ALLE Mandanten
|
||||||
|
-- liest und dann je Zeile gebunden anspricht. Schreiben bleibt der
|
||||||
|
-- Mandantenregel vorbehalten.
|
||||||
|
--
|
||||||
|
-- Rechte fuer die Anwendungsrolle tessera_app: kommen ueber ALTER DEFAULT
|
||||||
|
-- PRIVILEGES aus 20260909130000_rls_app_role automatisch — hier nichts zu tun.
|
||||||
|
--
|
||||||
|
-- WICHTIG: wie alle RLS-Regeln dieses Schemas wirken diese erst, wenn die
|
||||||
|
-- Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter heute AUS, siehe
|
||||||
|
-- docs/mandantentrennung-datenbankrolle.md). Bis dahin tragen die
|
||||||
|
-- Anwendungspruefungen im Dienst den Schutz allein.
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Reminder" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL DEFAULT '',
|
||||||
|
"dueAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"emailEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"emailSentAt" TIMESTAMP(3),
|
||||||
|
"emailAttempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Reminder_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Reminder_tenantId_userId_dueAt_idx" ON "Reminder"("tenantId", "userId", "dueAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Reminder_dueAt_idx" ON "Reminder"("dueAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Reminder" ADD CONSTRAINT "Reminder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Zeilenschutz: Mandant UND Benutzer (Muster 20260921120000)
|
||||||
|
ALTER TABLE "Reminder" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "Reminder" FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_policy ON "Reminder"
|
||||||
|
USING (
|
||||||
|
"tenantId" = current_tenant_id()
|
||||||
|
AND (current_user_id() IS NULL OR "userId" = current_user_id())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Systemkontext: nur Lesen, fuer die Kandidatenabfrage des E-Mail-Planers
|
||||||
|
CREATE POLICY system_read_policy ON "Reminder"
|
||||||
|
FOR SELECT USING (is_system_context());
|
||||||
@@ -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';
|
||||||
@@ -50,9 +50,14 @@ 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[]
|
||||||
|
reminders Reminder[]
|
||||||
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@index([username])
|
@@index([username])
|
||||||
@@ -135,12 +140,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
|
||||||
@@ -186,6 +199,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
|
||||||
@@ -339,6 +356,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
|
||||||
@@ -727,8 +794,59 @@ model CustomModule {
|
|||||||
name String
|
name String
|
||||||
url String
|
url String
|
||||||
category String // eine der MODULE_CATEGORIES aus @tessera/shared
|
category String // eine der MODULE_CATEGORIES aus @tessera/shared
|
||||||
|
// quick-260929-dzu: null = gemeinsamer Eintrag (vom Administrator, fuer alle
|
||||||
|
// sichtbar); gesetzt = persoenlicher Eintrag, nur fuer diesen Benutzer
|
||||||
|
// sichtbar. Faellt der Benutzer weg, fallen seine Eintraege mit.
|
||||||
|
ownerUserId String?
|
||||||
|
owner User? @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
|
@@index([tenantId, ownerUserId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// quick-260929-if2: persoenliche Erinnerungen, einmalig (D-01). Eine Zeile
|
||||||
|
// gehoert genau einem Benutzer (D-05); fuer fremde Kennungen antwortet die API
|
||||||
|
// mit 404. "Erledigt" loescht die Zeile (E-02), es gibt keine Historie.
|
||||||
|
// emailSentAt/emailAttempts sind die Rechenspur des E-Mail-Planers (Anspruch
|
||||||
|
// vor dem Senden, hoechstens 3 Versuche); ein Verschieben (dueAt) setzt beide
|
||||||
|
// zurueck, damit die E-Mail erneut verschickt wird (D-03).
|
||||||
|
model Reminder {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
title String
|
||||||
|
description String @default("")
|
||||||
|
dueAt DateTime
|
||||||
|
emailEnabled Boolean @default(false)
|
||||||
|
emailSentAt DateTime?
|
||||||
|
emailAttempts Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([tenantId, userId, 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,11 @@ 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 { 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 { RemindersModule } from './reminders/reminders.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -54,7 +57,10 @@ import { CustomModulesModule } from './custom-modules/custom-modules.module';
|
|||||||
TendersModule,
|
TendersModule,
|
||||||
BugReportsModule,
|
BugReportsModule,
|
||||||
ProxmoxModule,
|
ProxmoxModule,
|
||||||
|
KantineDatevModule,
|
||||||
|
HandelswareDatevModule,
|
||||||
CustomModulesModule,
|
CustomModulesModule,
|
||||||
|
RemindersModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global JWT guard: all routes require auth unless @Public()
|
// Global JWT guard: all routes require auth unless @Public()
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
import { ForbiddenException, ValidationPipe } from '@nestjs/common';
|
import { ForbiddenException, ValidationPipe } from '@nestjs/common';
|
||||||
import { Role } from '@prisma/client';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||||
import { CustomModulesController } from './custom-modules.controller';
|
import { CustomModulesController } from './custom-modules.controller';
|
||||||
import { CreateCustomModuleDto } from './dto/custom-module.dto';
|
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
|
||||||
|
|
||||||
function makeService() {
|
function makeService() {
|
||||||
return {
|
return {
|
||||||
@@ -17,14 +16,20 @@ function makeService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const req = (tenantId?: string) => ({ tenantId }) as any;
|
const req = (tenantId?: string) => ({ tenantId }) as any;
|
||||||
|
const user = { id: 'u1', username: 'u', role: 'USER', tenantId: 't1' } as any;
|
||||||
const proto = CustomModulesController.prototype as any;
|
const proto = CustomModulesController.prototype as any;
|
||||||
|
|
||||||
describe('CustomModulesController — Rollen (T-9WC-01)', () => {
|
describe('CustomModulesController — Rollen (quick-260929-dzu)', () => {
|
||||||
it.each(['create', 'update', 'remove'])('%s ist nur fuer ADMIN und SUPER_ADMIN offen', (name) => {
|
// Jeder Angemeldete darf persoenliche Eintraege anlegen/aendern/loeschen; die
|
||||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toEqual([Role.ADMIN, Role.SUPER_ADMIN]);
|
// Administrator-Pflicht fuer gemeinsame Eintraege prueft der Dienst (hangt
|
||||||
});
|
// vom Eintrag ab, nicht von der Route) — siehe custom-modules.service.spec.ts.
|
||||||
|
it.each([
|
||||||
it.each(['list', 'getOne'])('%s traegt keine Rollen (jeder Angemeldete)', (name) => {
|
'list',
|
||||||
|
'getOne',
|
||||||
|
'create',
|
||||||
|
'update',
|
||||||
|
'remove',
|
||||||
|
])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => {
|
||||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
|
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -37,26 +42,26 @@ describe('CustomModulesController — Mandant', () => {
|
|||||||
it('reicht req.tenantId an den Dienst weiter', async () => {
|
it('reicht req.tenantId an den Dienst weiter', async () => {
|
||||||
const service = makeService();
|
const service = makeService();
|
||||||
const controller = new CustomModulesController(service as any);
|
const controller = new CustomModulesController(service as any);
|
||||||
await controller.list(req('t1'));
|
await controller.list(req('t1'), user);
|
||||||
await controller.getOne(req('t1'), 'x');
|
await controller.getOne(req('t1'), user, 'x');
|
||||||
await controller.create(req('t1'), { name: 'a', url: 'https://a.de', category: 'fleet' });
|
await controller.create(req('t1'), user, { name: 'a', url: 'https://a.de', category: 'fleet' });
|
||||||
await controller.update(req('t1'), 'x', { name: 'b' });
|
await controller.update(req('t1'), user, 'x', { name: 'b' });
|
||||||
await controller.remove(req('t1'), 'x');
|
await controller.remove(req('t1'), user, 'x');
|
||||||
expect(service.list).toHaveBeenCalledWith('t1');
|
expect(service.list).toHaveBeenCalledWith('t1', user);
|
||||||
expect(service.getOne).toHaveBeenCalledWith('t1', 'x');
|
expect(service.getOne).toHaveBeenCalledWith('t1', user, 'x');
|
||||||
expect(service.create.mock.calls[0][0]).toBe('t1');
|
expect(service.create.mock.calls[0].slice(0, 2)).toEqual(['t1', user]);
|
||||||
expect(service.update.mock.calls[0].slice(0, 2)).toEqual(['t1', 'x']);
|
expect(service.update.mock.calls[0].slice(0, 3)).toEqual(['t1', user, 'x']);
|
||||||
expect(service.remove).toHaveBeenCalledWith('t1', 'x');
|
expect(service.remove).toHaveBeenCalledWith('t1', user, 'x');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('wirft ForbiddenException ohne req.tenantId', async () => {
|
it('wirft ForbiddenException ohne req.tenantId', async () => {
|
||||||
const controller = new CustomModulesController(makeService() as any);
|
const controller = new CustomModulesController(makeService() as any);
|
||||||
await expect(controller.list(req())).rejects.toBeInstanceOf(ForbiddenException);
|
await expect(controller.list(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
await expect(controller.getOne(req(), 'x')).rejects.toBeInstanceOf(ForbiddenException);
|
await expect(controller.getOne(req(), user, 'x')).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
await expect(
|
await expect(
|
||||||
controller.create(req(), { name: 'a', url: 'https://a.de', category: 'fleet' }),
|
controller.create(req(), user, { name: 'a', url: 'https://a.de', category: 'fleet' }),
|
||||||
).rejects.toBeInstanceOf(ForbiddenException);
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
await expect(controller.remove(req(), 'x')).rejects.toBeInstanceOf(ForbiddenException);
|
await expect(controller.remove(req(), user, 'x')).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('die globale Pipe verwirft ein untergeschobenes tenantId (T-9WC-07)', async () => {
|
it('die globale Pipe verwirft ein untergeschobenes tenantId (T-9WC-07)', async () => {
|
||||||
@@ -67,6 +72,27 @@ describe('CustomModulesController — Mandant', () => {
|
|||||||
);
|
);
|
||||||
expect(out).not.toHaveProperty('tenantId');
|
expect(out).not.toHaveProperty('tenantId');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('die globale Pipe verwirft ownerUserId, laesst shared beim Anlegen durch', async () => {
|
||||||
|
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||||
|
const out: any = await pipe.transform(
|
||||||
|
{ name: 'a', url: 'https://a.de', category: 'fleet', ownerUserId: 'evil', shared: true },
|
||||||
|
{ type: 'body', metatype: CreateCustomModuleDto },
|
||||||
|
);
|
||||||
|
expect(out).not.toHaveProperty('ownerUserId');
|
||||||
|
expect(out.shared).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die globale Pipe verwirft shared und ownerUserId beim Aendern', async () => {
|
||||||
|
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||||
|
const out: any = await pipe.transform(
|
||||||
|
{ name: 'b', shared: true, ownerUserId: 'evil' },
|
||||||
|
{ type: 'body', metatype: UpdateCustomModuleDto },
|
||||||
|
);
|
||||||
|
expect(out).not.toHaveProperty('shared');
|
||||||
|
expect(out).not.toHaveProperty('ownerUserId');
|
||||||
|
expect(out.name).toBe('b');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('CustomModulesController — Routen-Reihenfolge (statisch vor :id)', () => {
|
describe('CustomModulesController — Routen-Reihenfolge (statisch vor :id)', () => {
|
||||||
|
|||||||
@@ -9,18 +9,21 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Req,
|
Req,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Role } from '@prisma/client';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import { Roles } from '../auth/decorators/roles.decorator';
|
import type { AuthenticatedRequest, AuthUser } from '../auth/types/auth-user';
|
||||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
|
||||||
import { CustomModulesService } from './custom-modules.service';
|
import { CustomModulesService } from './custom-modules.service';
|
||||||
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
|
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Eigene Module (quick-260929-9wc). Lesen (`GET`, `GET :id`) steht jedem
|
* Eigene Module (quick-260929-9wc, erweitert in quick-260929-dzu). Jeder
|
||||||
* angemeldeten Benutzer offen — die Eintraege sind fuer alle sichtbar (D-01);
|
* angemeldete Benutzer darf lesen und eigene (persoenliche) Eintraege anlegen,
|
||||||
* Schreiben nur `@Roles(ADMIN, SUPER_ADMIN)` (T-9WC-01). Kein `@UseModule`:
|
* aendern und loeschen; gemeinsame Eintraege (`shared: true`) darf nur ein
|
||||||
|
* Administrator anlegen, aendern und loeschen — diese Rollenentscheidung trifft
|
||||||
|
* der Dienst, weil sie vom Eintrag abhaengt (gemeinsam oder persoenlich), nicht
|
||||||
|
* von der Route. Deshalb tragen die Routen kein `@Roles`. Kein `@UseModule`:
|
||||||
* eigene Module haengen an keiner Modul-Aktivierung. `tenantId` kommt
|
* eigene Module haengen an keiner Modul-Aktivierung. `tenantId` kommt
|
||||||
* ausschliesslich aus `req.tenantId` (gesetzt vom `TenantGuard`).
|
* ausschliesslich aus `req.tenantId` (gesetzt vom `TenantGuard`), der Benutzer
|
||||||
|
* aus dem Token.
|
||||||
*
|
*
|
||||||
* ROUTEN-REIHENFOLGE: NestJS bildet Routen in Deklarationsreihenfolge ab.
|
* ROUTEN-REIHENFOLGE: NestJS bildet Routen in Deklarationsreihenfolge ab.
|
||||||
* Jede kuenftige statische GET-Route MUSS ueber `getOne` (`@Get(':id')`)
|
* Jede kuenftige statische GET-Route MUSS ueber `getOne` (`@Get(':id')`)
|
||||||
@@ -40,34 +43,44 @@ export class CustomModulesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
async list(@Req() req: AuthenticatedRequest) {
|
async list(@Req() req: AuthenticatedRequest, @CurrentUser() user: AuthUser) {
|
||||||
return this.service.list(this.requireTenantId(req));
|
return this.service.list(this.requireTenantId(req), user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async getOne(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
async getOne(
|
||||||
return this.service.getOne(this.requireTenantId(req), id);
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
return this.service.getOne(this.requireTenantId(req), user, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
async create(
|
||||||
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateCustomModuleDto) {
|
@Req() req: AuthenticatedRequest,
|
||||||
return this.service.create(this.requireTenantId(req), dto);
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Body() dto: CreateCustomModuleDto,
|
||||||
|
) {
|
||||||
|
return this.service.create(this.requireTenantId(req), user, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
|
||||||
async update(
|
async update(
|
||||||
@Req() req: AuthenticatedRequest,
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: UpdateCustomModuleDto,
|
@Body() dto: UpdateCustomModuleDto,
|
||||||
) {
|
) {
|
||||||
return this.service.update(this.requireTenantId(req), id, dto);
|
return this.service.update(this.requireTenantId(req), user, id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
async remove(
|
||||||
async remove(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
@Req() req: AuthenticatedRequest,
|
||||||
return this.service.remove(this.requireTenantId(req), id);
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
return this.service.remove(this.requireTenantId(req), user, id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { NotFoundException } from '@nestjs/common';
|
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
// `forTenant` reicht den Klienten durch — Mandantenbindung selbst prueft
|
// `forTenant` reicht den Klienten durch — Mandantenbindung selbst prueft
|
||||||
// rls-access-inventory.spec.ts; hier zaehlt, dass je Methode (prisma, tenantId)
|
// rls-access-inventory.spec.ts; hier zaehlt, mit welchen Argumenten je Methode
|
||||||
// uebergeben wird.
|
// gebunden wird (mit oder ohne Benutzer).
|
||||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
forTenant: vi.fn((p: unknown) => p),
|
forTenant: vi.fn((p: unknown) => p),
|
||||||
}));
|
}));
|
||||||
@@ -24,6 +25,11 @@ function makeFakePrisma() {
|
|||||||
findMany: vi.fn(async ({ where, orderBy }: { where?: any; orderBy?: any } = {}) => {
|
findMany: vi.fn(async ({ where, orderBy }: { where?: any; orderBy?: any } = {}) => {
|
||||||
let list = [...rows.values()];
|
let list = [...rows.values()];
|
||||||
if (where?.tenantId) list = list.filter((r) => r.tenantId === where.tenantId);
|
if (where?.tenantId) list = list.filter((r) => r.tenantId === where.tenantId);
|
||||||
|
if (where?.OR) {
|
||||||
|
list = list.filter((r) =>
|
||||||
|
where.OR.some((c: { ownerUserId: string | null }) => r.ownerUserId === c.ownerUserId),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (orderBy?.name === 'asc') list.sort((a, b) => a.name.localeCompare(b.name));
|
if (orderBy?.name === 'asc') list.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
return list;
|
return list;
|
||||||
}),
|
}),
|
||||||
@@ -41,86 +47,233 @@ function makeFakePrisma() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dto = { name: 'Wiki', url: 'https://example.com', category: 'infrastructure' as const };
|
const dto = { name: 'Wiki', url: 'https://example.com', category: 'infrastructure' as const };
|
||||||
|
const admin = { id: 'admin1', role: Role.ADMIN };
|
||||||
|
const userA = { id: 'ua', role: Role.USER };
|
||||||
|
const userB = { id: 'ub', role: Role.USER };
|
||||||
|
|
||||||
describe('CustomModulesService', () => {
|
function setup() {
|
||||||
it('create speichert tenantId aus dem Argument, nie aus dem DTO', async () => {
|
|
||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
const service = new CustomModulesService(prisma as any);
|
return { prisma, service: new CustomModulesService(prisma as any) };
|
||||||
await service.create('t1', { ...dto, tenantId: 'evil' } as any);
|
}
|
||||||
expect(prisma.customModule.create).toHaveBeenCalledTimes(1);
|
|
||||||
|
describe('CustomModulesService — anlegen', () => {
|
||||||
|
it('speichert tenantId aus dem Argument, nie aus dem DTO', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
await service.create('t1', userA, { ...dto, tenantId: 'evil' } as any);
|
||||||
expect(prisma.customModule.create.mock.calls[0][0].data.tenantId).toBe('t1');
|
expect(prisma.customModule.create.mock.calls[0][0].data.tenantId).toBe('t1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('list liefert nur Zeilen des Mandanten, nach Name sortiert', async () => {
|
it('ohne shared-Angabe ist der Eintrag persoenlich (ownerUserId = Aufrufer)', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const { prisma, service } = setup();
|
||||||
const service = new CustomModulesService(prisma as any);
|
const res: any = await service.create('t1', userA, dto);
|
||||||
await service.create('t1', { ...dto, name: 'Zebra' });
|
expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBe('ua');
|
||||||
await service.create('t1', { ...dto, name: 'Anker' });
|
expect(res.personal).toBe(true);
|
||||||
await service.create('t2', { ...dto, name: 'Fremd' });
|
expect(res).not.toHaveProperty('ownerUserId');
|
||||||
const result = await service.list('t1');
|
|
||||||
expect(result.map((r: any) => r.name)).toEqual(['Anker', 'Zebra']);
|
|
||||||
expect(prisma.customModule.findMany.mock.calls[0]?.[0]?.where).toEqual({ tenantId: 't1' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getOne liefert die Zeile ohne tenantId', async () => {
|
it('auch ein Administrator legt ohne shared persoenlich an', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const { prisma, service } = setup();
|
||||||
const service = new CustomModulesService(prisma as any);
|
const res: any = await service.create('t1', admin, dto);
|
||||||
const created: any = await service.create('t1', dto);
|
expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBe('admin1');
|
||||||
const row: any = await service.getOne('t1', created.id);
|
expect(res.personal).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shared: true durch einen Administrator legt einen gemeinsamen Eintrag an', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
const res: any = await service.create('t1', admin, { ...dto, shared: true });
|
||||||
|
expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBeNull();
|
||||||
|
expect(res.personal).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shared: true durch einen normalen Benutzer -> ForbiddenException, nichts gespeichert', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
await expect(service.create('t1', userA, { ...dto, shared: true })).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
expect(prisma.customModule.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shared: false durch einen normalen Benutzer bleibt persoenlich', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
await service.create('t1', userA, { ...dto, shared: false });
|
||||||
|
expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBe('ua');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('das Feld shared landet nie in den gespeicherten Daten', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
await service.create('t1', admin, { ...dto, shared: true });
|
||||||
|
expect(prisma.customModule.create.mock.calls[0][0].data).not.toHaveProperty('shared');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CustomModulesService — lesen', () => {
|
||||||
|
it('list liefert gemeinsame plus eigene Eintraege, nie die eines anderen Benutzers', async () => {
|
||||||
|
const { service } = setup();
|
||||||
|
await service.create('t1', admin, { ...dto, name: 'Gemeinsam', shared: true });
|
||||||
|
await service.create('t1', userA, { ...dto, name: 'A-privat' });
|
||||||
|
await service.create('t1', userB, { ...dto, name: 'B-privat' });
|
||||||
|
const resA: any[] = await service.list('t1', userA);
|
||||||
|
expect(resA.map((r) => [r.name, r.personal])).toEqual([
|
||||||
|
['A-privat', true],
|
||||||
|
['Gemeinsam', false],
|
||||||
|
]);
|
||||||
|
const resB: any[] = await service.list('t1', userB);
|
||||||
|
expect(resB.map((r) => r.name)).toEqual(['B-privat', 'Gemeinsam']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('list sieht auch als Administrator keine persoenlichen Eintraege anderer', async () => {
|
||||||
|
const { service } = setup();
|
||||||
|
await service.create('t1', userA, { ...dto, name: 'A-privat' });
|
||||||
|
await service.create('t1', admin, { ...dto, name: 'Gemeinsam', shared: true });
|
||||||
|
const res: any[] = await service.list('t1', admin);
|
||||||
|
expect(res.map((r) => r.name)).toEqual(['Gemeinsam']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('list liefert nur Zeilen des Mandanten, nach Name sortiert', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
await service.create('t1', admin, { ...dto, name: 'Zebra', shared: true });
|
||||||
|
await service.create('t1', admin, { ...dto, name: 'Anker', shared: true });
|
||||||
|
await service.create('t2', admin, { ...dto, name: 'Fremd', shared: true });
|
||||||
|
const result: any[] = await service.list('t1', userA);
|
||||||
|
expect(result.map((r) => r.name)).toEqual(['Anker', 'Zebra']);
|
||||||
|
expect(prisma.customModule.findMany.mock.calls[0]?.[0]?.where).toEqual({
|
||||||
|
tenantId: 't1',
|
||||||
|
OR: [{ ownerUserId: null }, { ownerUserId: 'ua' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getOne liefert einen gemeinsamen Eintrag jedem, ohne tenantId und ownerUserId', async () => {
|
||||||
|
const { service } = setup();
|
||||||
|
const created: any = await service.create('t1', admin, { ...dto, shared: true });
|
||||||
|
const row: any = await service.getOne('t1', userA, created.id);
|
||||||
expect(row.name).toBe('Wiki');
|
expect(row.name).toBe('Wiki');
|
||||||
|
expect(row.personal).toBe(false);
|
||||||
expect(row).not.toHaveProperty('tenantId');
|
expect(row).not.toHaveProperty('tenantId');
|
||||||
|
expect(row).not.toHaveProperty('ownerUserId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getOne liefert einen eigenen persoenlichen Eintrag', async () => {
|
||||||
|
const { service } = setup();
|
||||||
|
const created: any = await service.create('t1', userA, dto);
|
||||||
|
const row: any = await service.getOne('t1', userA, created.id);
|
||||||
|
expect(row.personal).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getOne auf den persoenlichen Eintrag eines anderen -> NotFoundException (auch fuer Administratoren)', async () => {
|
||||||
|
const { service } = setup();
|
||||||
|
const created: any = await service.create('t1', userA, dto);
|
||||||
|
await expect(service.getOne('t1', userB, created.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
await expect(service.getOne('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getOne/update/remove mit unbekannter id -> NotFoundException', async () => {
|
it('getOne/update/remove mit unbekannter id -> NotFoundException', async () => {
|
||||||
const service = new CustomModulesService(makeFakePrisma() as any);
|
const { service } = setup();
|
||||||
await expect(service.getOne('t1', 'nope')).rejects.toBeInstanceOf(NotFoundException);
|
await expect(service.getOne('t1', userA, 'nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
await expect(service.update('t1', 'nope', { name: 'x' })).rejects.toBeInstanceOf(
|
await expect(service.update('t1', userA, 'nope', { name: 'x' })).rejects.toBeInstanceOf(
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
);
|
);
|
||||||
await expect(service.remove('t1', 'nope')).rejects.toBeInstanceOf(NotFoundException);
|
await expect(service.remove('t1', userA, 'nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getOne/update/remove mit Zeile eines anderen Mandanten -> NotFoundException', async () => {
|
it('getOne/update/remove mit Zeile eines anderen Mandanten -> NotFoundException', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const { prisma, service } = setup();
|
||||||
const service = new CustomModulesService(prisma as any);
|
const created: any = await service.create('t2', admin, { ...dto, shared: true });
|
||||||
const created: any = await service.create('t2', dto);
|
await expect(service.getOne('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
await expect(service.getOne('t1', created.id)).rejects.toBeInstanceOf(NotFoundException);
|
await expect(service.update('t1', admin, created.id, { name: 'x' })).rejects.toBeInstanceOf(
|
||||||
await expect(service.update('t1', created.id, { name: 'x' })).rejects.toBeInstanceOf(
|
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
);
|
);
|
||||||
await expect(service.remove('t1', created.id)).rejects.toBeInstanceOf(NotFoundException);
|
await expect(service.remove('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
expect(prisma.customModule.update).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.customModule.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CustomModulesService — aendern und loeschen', () => {
|
||||||
|
it('der Besitzer aendert und loescht seinen persoenlichen Eintrag', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
const created: any = await service.create('t1', userA, dto);
|
||||||
|
const updated: any = await service.update('t1', userA, created.id, { name: 'Neu' });
|
||||||
|
expect(updated.name).toBe('Neu');
|
||||||
|
expect(updated.personal).toBe(true);
|
||||||
|
await expect(service.remove('t1', userA, created.id)).resolves.toEqual({ deleted: true });
|
||||||
|
expect(prisma.rows.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein anderer Benutzer kann den persoenlichen Eintrag weder aendern noch loeschen (404)', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
const created: any = await service.create('t1', userA, dto);
|
||||||
|
await expect(service.update('t1', userB, created.id, { name: 'x' })).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
await expect(service.remove('t1', userB, created.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
expect(prisma.customModule.update).not.toHaveBeenCalled();
|
expect(prisma.customModule.update).not.toHaveBeenCalled();
|
||||||
expect(prisma.customModule.delete).not.toHaveBeenCalled();
|
expect(prisma.customModule.delete).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('update aendert nur gesetzte Felder', async () => {
|
it('auch ein Administrator kann den persoenlichen Eintrag eines Benutzers nicht aendern (404)', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const { prisma, service } = setup();
|
||||||
const service = new CustomModulesService(prisma as any);
|
const created: any = await service.create('t1', userA, dto);
|
||||||
const created: any = await service.create('t1', dto);
|
await expect(service.update('t1', admin, created.id, { name: 'x' })).rejects.toBeInstanceOf(
|
||||||
await service.update('t1', created.id, { name: 'Neu' });
|
NotFoundException,
|
||||||
expect(prisma.customModule.update.mock.calls[0][0].data).toEqual({ name: 'Neu' });
|
);
|
||||||
|
await expect(service.remove('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
expect(prisma.rows.size).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('remove loescht und liefert { deleted: true }', async () => {
|
it('ein normaler Benutzer kann einen gemeinsamen Eintrag weder aendern noch loeschen (403)', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const { prisma, service } = setup();
|
||||||
const service = new CustomModulesService(prisma as any);
|
const created: any = await service.create('t1', admin, { ...dto, shared: true });
|
||||||
const created: any = await service.create('t1', dto);
|
await expect(service.update('t1', userA, created.id, { name: 'x' })).rejects.toBeInstanceOf(
|
||||||
await expect(service.remove('t1', created.id)).resolves.toEqual({ deleted: true });
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
await expect(service.remove('t1', userA, created.id)).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
expect(prisma.customModule.update).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.customModule.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Administrator aendert und loescht einen gemeinsamen Eintrag', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
const created: any = await service.create('t1', admin, { ...dto, shared: true });
|
||||||
|
const updated: any = await service.update('t1', admin, created.id, { name: 'Neu' });
|
||||||
|
expect(updated.name).toBe('Neu');
|
||||||
|
expect(updated.personal).toBe(false);
|
||||||
|
await expect(service.remove('t1', admin, created.id)).resolves.toEqual({ deleted: true });
|
||||||
expect(prisma.rows.size).toBe(0);
|
expect(prisma.rows.size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ruft forTenant je Methode mit (prisma, tenantId) auf', async () => {
|
it('update aendert nur gesetzte Felder und nie Besitz oder Gemeinsamkeit', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const { prisma, service } = setup();
|
||||||
const service = new CustomModulesService(prisma as any);
|
const created: any = await service.create('t1', userA, dto);
|
||||||
vi.mocked(forTenant).mockClear();
|
await service.update('t1', userA, created.id, {
|
||||||
const created: any = await service.create('t1', dto);
|
name: 'Neu',
|
||||||
await service.list('t1');
|
shared: true,
|
||||||
await service.getOne('t1', created.id);
|
ownerUserId: 'ub',
|
||||||
await service.update('t1', created.id, { name: 'a' });
|
} as any);
|
||||||
await service.remove('t1', created.id);
|
expect(prisma.customModule.update.mock.calls[0][0].data).toEqual({ name: 'Neu' });
|
||||||
expect(forTenant).toHaveBeenCalledTimes(5);
|
});
|
||||||
for (const call of vi.mocked(forTenant).mock.calls) {
|
});
|
||||||
expect(call).toEqual([prisma, 't1']);
|
|
||||||
}
|
describe('CustomModulesService — RLS-Bindung', () => {
|
||||||
|
it('bindet persoenliche Zugriffe mit Benutzer, gemeinsame Schreibzugriffe ohne', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
vi.mocked(forTenant).mockClear();
|
||||||
|
const shared: any = await service.create('t1', admin, { ...dto, shared: true });
|
||||||
|
const mine: any = await service.create('t1', userA, dto);
|
||||||
|
await service.list('t1', userA);
|
||||||
|
await service.getOne('t1', userA, mine.id);
|
||||||
|
await service.update('t1', userA, mine.id, { name: 'a' });
|
||||||
|
await service.update('t1', admin, shared.id, { name: 'b' });
|
||||||
|
const calls = vi.mocked(forTenant).mock.calls;
|
||||||
|
// create shared: ohne Benutzer
|
||||||
|
expect(calls[0]).toEqual([prisma, 't1']);
|
||||||
|
// create personal + list + getOne + (update personal: Laden + Schreiben)
|
||||||
|
expect(calls[1]).toEqual([prisma, 't1', 'ua']);
|
||||||
|
expect(calls[2]).toEqual([prisma, 't1', 'ua']);
|
||||||
|
expect(calls[3]).toEqual([prisma, 't1', 'ua']);
|
||||||
|
// update shared als Admin: Laden mit Benutzer, Schreiben ohne
|
||||||
|
expect(calls[calls.length - 2]).toEqual([prisma, 't1', 'admin1']);
|
||||||
|
expect(calls[calls.length - 1]).toEqual([prisma, 't1']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Role } 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 type { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
|
import type { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
|
||||||
@@ -9,34 +10,132 @@ const CUSTOM_MODULE_SELECT = {
|
|||||||
name: true,
|
name: true,
|
||||||
url: true,
|
url: true,
|
||||||
category: true,
|
category: true,
|
||||||
|
ownerUserId: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Der Aufrufer, wie ihn der Controller aus dem Anmelde-Token liest. */
|
||||||
|
export interface CustomModuleCaller {
|
||||||
|
id: string;
|
||||||
|
role: Role;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdmin(caller: CustomModuleCaller): boolean {
|
||||||
|
return caller.role === Role.ADMIN || caller.role === Role.SUPER_ADMIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aus der Zeile die Antwort machen: `personal` statt der Besitzer-Kennung. */
|
||||||
|
function toResponse<T extends { ownerUserId: string | null }>(row: T) {
|
||||||
|
const { ownerUserId, ...rest } = row;
|
||||||
|
return { ...rest, personal: ownerUserId !== null };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Eigene Module (quick-260929-9wc): vom Administrator angelegte
|
* Eigene Module (quick-260929-9wc, erweitert in quick-260929-dzu): Seitenleisten-
|
||||||
* Seitenleisten-Eintraege mit externer https-Adresse. `tenantId` kommt
|
* Eintraege mit externer https-Adresse. Zwei Arten:
|
||||||
* ausschliesslich als Argument (aus `req.tenantId`), nie aus dem DTO. Je
|
*
|
||||||
* Methode ein eigener `forTenant`-Klient; zusaetzlich pruefen
|
* - gemeinsam (`ownerUserId` null): vom Administrator angelegt, fuer alle
|
||||||
* getOne/update/remove `row.tenantId` — zweites Netz, solange der
|
* Benutzer des Mandanten sichtbar; Schreiben nur fuer Administratoren.
|
||||||
* RLS-Schalter aus ist (Muster DashboardImage). Eine fremde oder unbekannte
|
* - persoenlich (`ownerUserId` = Benutzer): nur der Besitzer sieht, aendert und
|
||||||
* id ergibt immer `NotFoundException`, nie einen Hinweis auf die Existenz.
|
* loescht ihn. Ein anderer Benutzer bekommt fuer die id immer 404 — nie einen
|
||||||
|
* Hinweis, dass es sie gibt.
|
||||||
|
*
|
||||||
|
* `tenantId` kommt ausschliesslich als Argument (aus `req.tenantId`), nie aus
|
||||||
|
* dem DTO. Je Methode ein eigener `forTenant`-Klient.
|
||||||
|
*
|
||||||
|
* RLS-BINDUNG (Muster SearchProvider, siehe Migration 20260929130000): Lesen
|
||||||
|
* und Schreiben PERSOENLICHER Eintraege laeuft mit dem Benutzer als drittem
|
||||||
|
* Argument (`forTenant(prisma, tenantId, user.id)`); die Regel laesst dann nur
|
||||||
|
* gemeinsame und eigene Zeilen zu. Schreiben GEMEINSAMER Eintraege laeuft
|
||||||
|
* bewusst OHNE Benutzer (`forTenant(prisma, tenantId)`), weil die Regel einem
|
||||||
|
* Benutzerkontext das Schreiben gemeinsamer Zeilen verwehrt — die
|
||||||
|
* Rollenpruefung (Administrator) sitzt vorher im Dienst. Zusaetzlich pruefen
|
||||||
|
* alle Methoden `row.tenantId` und `row.ownerUserId` in der Anwendung, solange
|
||||||
|
* der RLS-Schalter aus ist.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomModulesService {
|
export class CustomModulesService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async list(tenantId: string) {
|
/** Gemeinsame Eintraege plus die eigenen des Aufrufers. */
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
async list(tenantId: string, caller: CustomModuleCaller) {
|
||||||
return tenantPrisma.customModule.findMany({
|
const tenantPrisma = forTenant(this.prisma, tenantId, caller.id);
|
||||||
where: { tenantId },
|
const rows = await tenantPrisma.customModule.findMany({
|
||||||
|
where: { tenantId, OR: [{ ownerUserId: null }, { ownerUserId: caller.id }] },
|
||||||
orderBy: { name: 'asc' },
|
orderBy: { name: 'asc' },
|
||||||
select: CUSTOM_MODULE_SELECT,
|
select: CUSTOM_MODULE_SELECT,
|
||||||
});
|
});
|
||||||
|
return rows.map(toResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOne(tenantId: string, id: string) {
|
async getOne(tenantId: string, caller: CustomModuleCaller, id: string) {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId, caller.id);
|
||||||
|
const row = await this.loadVisible(tenantPrisma, tenantId, caller, id);
|
||||||
|
const { tenantId: _omit, ...result } = row;
|
||||||
|
return toResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(tenantId: string, caller: CustomModuleCaller, dto: CreateCustomModuleDto) {
|
||||||
|
const shared = dto.shared === true;
|
||||||
|
if (shared && !isAdmin(caller)) {
|
||||||
|
throw new ForbiddenException('Gemeinsame Einträge dürfen nur Administratoren anlegen');
|
||||||
|
}
|
||||||
|
const data = {
|
||||||
|
tenantId,
|
||||||
|
name: dto.name,
|
||||||
|
url: dto.url,
|
||||||
|
category: dto.category,
|
||||||
|
ownerUserId: shared ? null : caller.id,
|
||||||
|
};
|
||||||
|
if (shared) {
|
||||||
|
// Gemeinsam: ohne Benutzerkontext (die Regel verwehrt ihn dort).
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
const row = await tenantPrisma.customModule.create({ data, select: CUSTOM_MODULE_SELECT });
|
||||||
|
return toResponse(row);
|
||||||
|
}
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId, caller.id);
|
||||||
|
const row = await tenantPrisma.customModule.create({ data, select: CUSTOM_MODULE_SELECT });
|
||||||
|
return toResponse(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
tenantId: string,
|
||||||
|
caller: CustomModuleCaller,
|
||||||
|
id: string,
|
||||||
|
dto: UpdateCustomModuleDto,
|
||||||
|
) {
|
||||||
|
const tenantPrisma = await this.writableClient(tenantId, caller, id);
|
||||||
|
const data: { name?: string; url?: string; category?: string } = {};
|
||||||
|
if (dto.name !== undefined) data.name = dto.name;
|
||||||
|
if (dto.url !== undefined) data.url = dto.url;
|
||||||
|
if (dto.category !== undefined) data.category = dto.category;
|
||||||
|
// Besitz und Gemeinsamkeit stehen nie in `data` — sie aendern sich nicht.
|
||||||
|
const row = await tenantPrisma.customModule.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
select: CUSTOM_MODULE_SELECT,
|
||||||
|
});
|
||||||
|
return toResponse(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(tenantId: string, caller: CustomModuleCaller, id: string) {
|
||||||
|
const tenantPrisma = await this.writableClient(tenantId, caller, id);
|
||||||
|
await tenantPrisma.customModule.delete({ where: { id } });
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeile laden, die der Aufrufer sehen darf: gleicher Mandant UND (gemeinsam
|
||||||
|
* ODER eigene). Alles andere — unbekannt, fremder Mandant, fremder
|
||||||
|
* persoenlicher Eintrag — ist ununterscheidbar 404.
|
||||||
|
*/
|
||||||
|
private async loadVisible(
|
||||||
|
tenantPrisma: ReturnType<typeof forTenant>,
|
||||||
|
tenantId: string,
|
||||||
|
caller: CustomModuleCaller,
|
||||||
|
id: string,
|
||||||
|
) {
|
||||||
const row = await tenantPrisma.customModule.findUnique({
|
const row = await tenantPrisma.customModule.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
select: { ...CUSTOM_MODULE_SELECT, tenantId: true },
|
select: { ...CUSTOM_MODULE_SELECT, tenantId: true },
|
||||||
@@ -44,42 +143,28 @@ export class CustomModulesService {
|
|||||||
if (!row || row.tenantId !== tenantId) {
|
if (!row || row.tenantId !== tenantId) {
|
||||||
throw new NotFoundException('Eigenes Modul nicht gefunden');
|
throw new NotFoundException('Eigenes Modul nicht gefunden');
|
||||||
}
|
}
|
||||||
const { tenantId: _omit, ...result } = row;
|
if (row.ownerUserId !== null && row.ownerUserId !== caller.id) {
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async create(tenantId: string, dto: CreateCustomModuleDto) {
|
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
|
||||||
return tenantPrisma.customModule.create({
|
|
||||||
data: { tenantId, name: dto.name, url: dto.url, category: dto.category },
|
|
||||||
select: CUSTOM_MODULE_SELECT,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(tenantId: string, id: string, dto: UpdateCustomModuleDto) {
|
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
|
||||||
const existing = await tenantPrisma.customModule.findUnique({ where: { id } });
|
|
||||||
if (!existing || existing.tenantId !== tenantId) {
|
|
||||||
throw new NotFoundException('Eigenes Modul nicht gefunden');
|
throw new NotFoundException('Eigenes Modul nicht gefunden');
|
||||||
}
|
}
|
||||||
const data: { name?: string; url?: string; category?: string } = {};
|
return row;
|
||||||
if (dto.name !== undefined) data.name = dto.name;
|
|
||||||
if (dto.url !== undefined) data.url = dto.url;
|
|
||||||
if (dto.category !== undefined) data.category = dto.category;
|
|
||||||
return tenantPrisma.customModule.update({
|
|
||||||
where: { id },
|
|
||||||
data,
|
|
||||||
select: CUSTOM_MODULE_SELECT,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(tenantId: string, id: string) {
|
/**
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
* Klient fuer Aendern/Loeschen: persoenlicher Eintrag -> mit Benutzer (nur der
|
||||||
const existing = await tenantPrisma.customModule.findUnique({ where: { id } });
|
* Besitzer kommt bis hierher); gemeinsamer Eintrag -> nur Administrator (403
|
||||||
if (!existing || existing.tenantId !== tenantId) {
|
* sonst, der Eintrag ist fuer alle sichtbar, sein Bestehen ist kein
|
||||||
throw new NotFoundException('Eigenes Modul nicht gefunden');
|
* Geheimnis), dann ohne Benutzerkontext.
|
||||||
|
*/
|
||||||
|
private async writableClient(tenantId: string, caller: CustomModuleCaller, id: string) {
|
||||||
|
const userClient = forTenant(this.prisma, tenantId, caller.id);
|
||||||
|
const row = await this.loadVisible(userClient, tenantId, caller, id);
|
||||||
|
if (row.ownerUserId === caller.id) {
|
||||||
|
return userClient;
|
||||||
}
|
}
|
||||||
await tenantPrisma.customModule.delete({ where: { id } });
|
if (!isAdmin(caller)) {
|
||||||
return { deleted: true };
|
throw new ForbiddenException('Gemeinsame Einträge dürfen nur Administratoren ändern');
|
||||||
|
}
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
return tenantPrisma;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,4 +64,12 @@ describe('UpdateCustomModuleDto', () => {
|
|||||||
expect(await errorsFor(UpdateCustomModuleDto, { category: 'other' })).toContain('category');
|
expect(await errorsFor(UpdateCustomModuleDto, { category: 'other' })).toContain('category');
|
||||||
expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name');
|
expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'name',
|
||||||
|
'url',
|
||||||
|
'category',
|
||||||
|
])('lehnt %s: null ab statt es durchzulassen', async (field) => {
|
||||||
|
expect(await errorsFor(UpdateCustomModuleDto, { [field]: null })).toContain(field);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { PartialType } from '@nestjs/mapped-types';
|
import { OmitType, PartialType } from '@nestjs/mapped-types';
|
||||||
import { MODULE_CATEGORIES } from '@tessera/shared';
|
import { CUSTOM_MODULE_CATEGORIES } from '@tessera/shared';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
|
IsBoolean,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
Validate,
|
Validate,
|
||||||
@@ -58,9 +60,29 @@ export class CreateCustomModuleDto {
|
|||||||
@Validate(NurHttpsOhneZugangsdatenConstraint)
|
@Validate(NurHttpsOhneZugangsdatenConstraint)
|
||||||
url!: string;
|
url!: string;
|
||||||
|
|
||||||
@IsIn([...MODULE_CATEGORIES])
|
@IsIn([...CUSTOM_MODULE_CATEGORIES])
|
||||||
category!: (typeof MODULE_CATEGORIES)[number];
|
category!: (typeof CUSTOM_MODULE_CATEGORIES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* quick-260929-dzu: `true` legt einen gemeinsamen Eintrag fuer alle Benutzer
|
||||||
|
* an (nur Administratoren, sonst 403 im Dienst). Fehlt das Feld oder ist es
|
||||||
|
* `false`, ist der Eintrag persoenlich und gehoert dem Aufrufer.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
shared?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Teil-Update: jedes gesetzte Feld wird genauso geprueft wie beim Anlegen. */
|
/**
|
||||||
export class UpdateCustomModuleDto extends PartialType(CreateCustomModuleDto) {}
|
* Teil-Update: jedes gesetzte Feld wird genauso geprueft wie beim Anlegen.
|
||||||
|
* `shared` ist ausgenommen — ob ein Eintrag gemeinsam oder persoenlich ist,
|
||||||
|
* aendert sich nach dem Anlegen nicht (die globale Pipe verwirft das Feld
|
||||||
|
* dank `whitelist: true`).
|
||||||
|
* `skipNullProperties: false`: fehlende Felder bleiben unveraendert, ein
|
||||||
|
* ausdrueckliches `null` wird aber geprueft und damit abgelehnt (400) — sonst
|
||||||
|
* liefe `{"name": null}` bis in die Datenbank und endete als 500.
|
||||||
|
*/
|
||||||
|
export class UpdateCustomModuleDto extends PartialType(
|
||||||
|
OmitType(CreateCustomModuleDto, ['shared'] as const),
|
||||||
|
{ skipNullProperties: false },
|
||||||
|
) {}
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
InternalServerErrorException,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import { DashboardImagesService } from './dashboard-images.service';
|
import { DashboardImagesService } from './dashboard-images.service';
|
||||||
@@ -73,11 +77,21 @@ interface BoundCall {
|
|||||||
|
|
||||||
type ModelMethods = Record<string, (...args: unknown[]) => Promise<unknown>>;
|
type ModelMethods = Record<string, (...args: unknown[]) => Promise<unknown>>;
|
||||||
|
|
||||||
|
interface UserRow {
|
||||||
|
id: string;
|
||||||
|
dashboardBackground: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
interface FakePrisma {
|
interface FakePrisma {
|
||||||
dashboardImage: ModelMethods;
|
dashboardImage: ModelMethods;
|
||||||
|
user: ModelMethods;
|
||||||
__rows: ImageRow[];
|
__rows: ImageRow[];
|
||||||
|
__users: UserRow[];
|
||||||
__boundCallLog: BoundCall[];
|
__boundCallLog: BoundCall[];
|
||||||
__makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods };
|
__makeBoundClient(
|
||||||
|
tenantId: string,
|
||||||
|
userId?: string,
|
||||||
|
): { dashboardImage: ModelMethods; user: ModelMethods };
|
||||||
}
|
}
|
||||||
|
|
||||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
||||||
@@ -147,8 +161,28 @@ function pick(row: ImageRow, select: Record<string, boolean> | undefined) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
function makeFakePrisma(rows: ImageRow[] = [], users: UserRow[] = []): FakePrisma {
|
||||||
const boundCallLog: BoundCall[] = [];
|
const boundCallLog: BoundCall[] = [];
|
||||||
|
// quick-260930: Hintergrund-Wahl (`User.dashboardBackground`) — bedingtes
|
||||||
|
// updateMany ueber den JSON-Pfad `imageId`, wie Prisma es auf PostgreSQL filtert.
|
||||||
|
const user: ModelMethods = {
|
||||||
|
updateMany: vi.fn(async (raw: unknown) => {
|
||||||
|
const args = raw as {
|
||||||
|
where: { id: string; dashboardBackground: { path: string[]; equals: unknown } };
|
||||||
|
data: { dashboardBackground: unknown };
|
||||||
|
};
|
||||||
|
const [key] = args.where.dashboardBackground.path;
|
||||||
|
let count = 0;
|
||||||
|
for (const u of users) {
|
||||||
|
const bg = u.dashboardBackground as Record<string, unknown> | null;
|
||||||
|
if (u.id !== args.where.id || !bg || bg[key] !== args.where.dashboardBackground.equals)
|
||||||
|
continue;
|
||||||
|
u.dashboardBackground = args.data.dashboardBackground;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return { count };
|
||||||
|
}),
|
||||||
|
};
|
||||||
const dashboardImage: ModelMethods = {
|
const dashboardImage: ModelMethods = {
|
||||||
findMany: vi.fn(async (raw: unknown) => {
|
findMany: vi.fn(async (raw: unknown) => {
|
||||||
const args = raw as {
|
const args = raw as {
|
||||||
@@ -168,11 +202,17 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
|||||||
}),
|
}),
|
||||||
count: vi.fn(async (raw: unknown) => {
|
count: vi.fn(async (raw: unknown) => {
|
||||||
const args = raw as { where: { tenantId: string; userId: string } };
|
const args = raw as { where: { tenantId: string; userId: string } };
|
||||||
return rows.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId).length;
|
return rows.filter(
|
||||||
|
(r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId,
|
||||||
|
).length;
|
||||||
}),
|
}),
|
||||||
create: vi.fn(async (raw: unknown) => {
|
create: vi.fn(async (raw: unknown) => {
|
||||||
const args = raw as { data: Partial<ImageRow>; select?: Record<string, boolean> };
|
const args = raw as { data: Partial<ImageRow>; select?: Record<string, boolean> };
|
||||||
const created = makeRow({ id: `new-${rows.length + 1}`, ...args.data, createdAt: new Date('2026-02-02') });
|
const created = makeRow({
|
||||||
|
id: `new-${rows.length + 1}`,
|
||||||
|
...args.data,
|
||||||
|
createdAt: new Date('2026-02-02'),
|
||||||
|
});
|
||||||
rows.push(created);
|
rows.push(created);
|
||||||
return pick(created, args.select);
|
return pick(created, args.select);
|
||||||
}),
|
}),
|
||||||
@@ -196,20 +236,29 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
function wrap(tenantId: string, userId?: string) {
|
function wrapModel(model: string, methods: ModelMethods, tenantId: string, userId?: string) {
|
||||||
const wrapped: ModelMethods = {};
|
const wrapped: ModelMethods = {};
|
||||||
for (const method of Object.keys(dashboardImage)) {
|
for (const method of Object.keys(methods)) {
|
||||||
wrapped[method] = async (...args: unknown[]) => {
|
wrapped[method] = async (...args: unknown[]) => {
|
||||||
boundCallLog.push({ tenantId, userId, model: 'dashboardImage', method });
|
boundCallLog.push({ tenantId, userId, model, method });
|
||||||
return dashboardImage[method](...args);
|
return methods[method](...args);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { dashboardImage: wrapped };
|
return wrapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrap(tenantId: string, userId?: string) {
|
||||||
|
return {
|
||||||
|
dashboardImage: wrapModel('dashboardImage', dashboardImage, tenantId, userId),
|
||||||
|
user: wrapModel('user', user, tenantId, userId),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const fake: FakePrisma = {
|
const fake: FakePrisma = {
|
||||||
dashboardImage,
|
dashboardImage,
|
||||||
|
user,
|
||||||
__rows: rows,
|
__rows: rows,
|
||||||
|
__users: users,
|
||||||
__boundCallLog: boundCallLog,
|
__boundCallLog: boundCallLog,
|
||||||
__makeBoundClient(tenantId: string, userId?: string) {
|
__makeBoundClient(tenantId: string, userId?: string) {
|
||||||
return wrap(tenantId, userId);
|
return wrap(tenantId, userId);
|
||||||
@@ -253,9 +302,17 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
|||||||
const result = await makeService(prisma).list('user-1', 'tenant-1');
|
const result = await makeService(prisma).list('user-1', 'tenant-1');
|
||||||
expect(result.map((r) => r.id)).toEqual(['a', 'b']);
|
expect(result.map((r) => r.id)).toEqual(['a', 'b']);
|
||||||
for (const r of result) {
|
for (const r of result) {
|
||||||
expect(Object.keys(r).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']);
|
expect(Object.keys(r).sort()).toEqual([
|
||||||
|
'createdAt',
|
||||||
|
'id',
|
||||||
|
'mimeType',
|
||||||
|
'originalName',
|
||||||
|
'size',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as { select: Record<string, boolean> };
|
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as {
|
||||||
|
select: Record<string, boolean>;
|
||||||
|
};
|
||||||
expect(call.select.data).toBeUndefined();
|
expect(call.select.data).toBeUndefined();
|
||||||
expect(call.select.storagePath).toBeUndefined();
|
expect(call.select.storagePath).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -274,14 +331,22 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
|||||||
expect(result.mimeType).toBe('image/png');
|
expect(result.mimeType).toBe('image/png');
|
||||||
expect(result.originalName).toBe('irgendwas.txt');
|
expect(result.originalName).toBe('irgendwas.txt');
|
||||||
expect(result.size).toBe(PNG.length);
|
expect(result.size).toBe(PNG.length);
|
||||||
expect(Object.keys(result).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']);
|
expect(Object.keys(result).sort()).toEqual([
|
||||||
|
'createdAt',
|
||||||
|
'id',
|
||||||
|
'mimeType',
|
||||||
|
'originalName',
|
||||||
|
'size',
|
||||||
|
]);
|
||||||
expect(prisma.__rows[0].userId).toBe('user-1');
|
expect(prisma.__rows[0].userId).toBe('user-1');
|
||||||
expect(prisma.__rows[0].tenantId).toBe('tenant-1');
|
expect(prisma.__rows[0].tenantId).toBe('tenant-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Test 4: Textdatei mit behauptetem image/png scheitert mit deutscher Meldung, nichts wird angelegt', async () => {
|
it('Test 4: Textdatei mit behauptetem image/png scheitert mit deutscher Meldung, nichts wird angelegt', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
await expect(makeService(prisma).upload(user, file(TEXT, 'image/png', 'bild.png'))).rejects.toThrow(
|
await expect(
|
||||||
|
makeService(prisma).upload(user, file(TEXT, 'image/png', 'bild.png')),
|
||||||
|
).rejects.toThrow(
|
||||||
new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'),
|
new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'),
|
||||||
);
|
);
|
||||||
expect(prisma.dashboardImage.create).not.toHaveBeenCalled();
|
expect(prisma.dashboardImage.create).not.toHaveBeenCalled();
|
||||||
@@ -304,7 +369,9 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Test 6: Zaehler zaehlt nur den eigenen Benutzer im eigenen Mandanten (fremde Zeilen zaehlen nicht)', async () => {
|
it('Test 6: Zaehler zaehlt nur den eigenen Benutzer im eigenen Mandanten (fremde Zeilen zaehlen nicht)', async () => {
|
||||||
const foreign = Array.from({ length: 30 }, (_, i) => makeRow({ id: `f${i}`, userId: 'user-2' }));
|
const foreign = Array.from({ length: 30 }, (_, i) =>
|
||||||
|
makeRow({ id: `f${i}`, userId: 'user-2' }),
|
||||||
|
);
|
||||||
const prisma = makeFakePrisma(foreign);
|
const prisma = makeFakePrisma(foreign);
|
||||||
await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({
|
await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({
|
||||||
mimeType: 'image/png',
|
mimeType: 'image/png',
|
||||||
@@ -321,14 +388,20 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
|||||||
|
|
||||||
it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => {
|
it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => {
|
||||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', userId: 'user-2' })]);
|
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', userId: 'user-2' })]);
|
||||||
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => {
|
it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => {
|
||||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', tenantId: 'tenant-2' })]);
|
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', tenantId: 'tenant-2' })]);
|
||||||
const service = makeService(prisma);
|
const service = makeService(prisma);
|
||||||
await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||||
await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
NotFoundException,
|
||||||
|
);
|
||||||
|
await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Test 10: getBytes — eigenes Bild liefert mimeType und die gespeicherten Bytes', async () => {
|
it('Test 10: getBytes — eigenes Bild liefert mimeType und die gespeicherten Bytes', async () => {
|
||||||
@@ -347,11 +420,52 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
|||||||
const service = makeService(prisma);
|
const service = makeService(prisma);
|
||||||
await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' });
|
await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' });
|
||||||
expect(prisma.__rows.map((r) => r.id)).toEqual(['fremd-user', 'fremd-tenant']);
|
expect(prisma.__rows.map((r) => r.id)).toEqual(['fremd-user', 'fremd-tenant']);
|
||||||
await expect(service.remove('fremd-user', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
await expect(service.remove('fremd-user', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||||
await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
NotFoundException,
|
||||||
|
);
|
||||||
|
await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
expect(prisma.__rows).toHaveLength(2);
|
expect(prisma.__rows).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Test 11b (quick-260930): war das geloeschte Bild der Dashboard-Hintergrund, steht die Wahl danach auf „kein Hintergrund“ — andere Wahlen und andere Benutzer bleiben', async () => {
|
||||||
|
const users: UserRow[] = [
|
||||||
|
{ id: 'user-1', dashboardBackground: { kind: 'image', imageId: 'eigen' } },
|
||||||
|
{ id: 'user-2', dashboardBackground: { kind: 'image', imageId: 'eigen' } },
|
||||||
|
];
|
||||||
|
const prisma = makeFakePrisma(
|
||||||
|
[makeStoredRow({ id: 'eigen' }), makeStoredRow({ id: 'zweites' })],
|
||||||
|
users,
|
||||||
|
);
|
||||||
|
const service = makeService(prisma);
|
||||||
|
|
||||||
|
await service.remove('eigen', 'user-1', 'tenant-1');
|
||||||
|
|
||||||
|
expect(users[0].dashboardBackground).toEqual({ kind: 'none' });
|
||||||
|
// nur die eigene Zeile
|
||||||
|
expect(users[1].dashboardBackground).toEqual({ kind: 'image', imageId: 'eigen' });
|
||||||
|
const call = vi.mocked(prisma.user.updateMany).mock.calls[0][0];
|
||||||
|
expect(call).toEqual({
|
||||||
|
where: { id: 'user-1', dashboardBackground: { path: ['imageId'], equals: 'eigen' } },
|
||||||
|
data: { dashboardBackground: { kind: 'none' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ein anderes Bild loeschen laesst eine andere Wahl stehen.
|
||||||
|
users[0].dashboardBackground = { kind: 'preset', id: 'mist' };
|
||||||
|
await service.remove('zweites', 'user-1', 'tenant-1');
|
||||||
|
expect(users[0].dashboardBackground).toEqual({ kind: 'preset', id: 'mist' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 11c (quick-260930): scheitert das Zuruecksetzen der Wahl, ist das Bild trotzdem geloescht (kein Fehler nach aussen)', async () => {
|
||||||
|
const prisma = makeFakePrisma([makeStoredRow({ id: 'eigen' })]);
|
||||||
|
vi.mocked(prisma.user.updateMany).mockRejectedValueOnce(new Error('db weg'));
|
||||||
|
await expect(makeService(prisma).remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({
|
||||||
|
id: 'eigen',
|
||||||
|
});
|
||||||
|
expect(prisma.__rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => {
|
it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => {
|
||||||
const prisma = makeFakePrisma([]);
|
const prisma = makeFakePrisma([]);
|
||||||
const service = makeService(prisma);
|
const service = makeService(prisma);
|
||||||
@@ -370,7 +484,17 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
|||||||
// Stufe 2 vergibt der Dienst die UUID selbst und legt die Zeile gleich
|
// Stufe 2 vergibt der Dienst die UUID selbst und legt die Zeile gleich
|
||||||
// MIT Pfad an — kein nachtraegliches `update` mehr (m4n).
|
// MIT Pfad an — kein nachtraegliches `update` mehr (m4n).
|
||||||
const methods = prisma.__boundCallLog.map((c) => c.method);
|
const methods = prisma.__boundCallLog.map((c) => c.method);
|
||||||
expect(methods).toEqual(['findMany', 'count', 'create', 'findUnique', 'findUnique', 'delete']);
|
// quick-260930: `remove` setzt zusaetzlich die Hintergrund-Wahl zurueck (user.updateMany).
|
||||||
|
expect(methods).toEqual([
|
||||||
|
'findMany',
|
||||||
|
'count',
|
||||||
|
'create',
|
||||||
|
'findUnique',
|
||||||
|
'findUnique',
|
||||||
|
'delete',
|
||||||
|
'updateMany',
|
||||||
|
]);
|
||||||
|
expect(prisma.__boundCallLog.at(-1)?.model).toBe('user');
|
||||||
expect(vi.mocked(forSystem)).not.toHaveBeenCalled();
|
expect(vi.mocked(forSystem)).not.toHaveBeenCalled();
|
||||||
for (const c of prisma.__boundCallLog) {
|
for (const c of prisma.__boundCallLog) {
|
||||||
expect(c.tenantId).toBe('tenant-1');
|
expect(c.tenantId).toBe('tenant-1');
|
||||||
@@ -387,14 +511,20 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
|||||||
const onDisk = storedFile('user-1', result.id);
|
const onDisk = storedFile('user-1', result.id);
|
||||||
expect(fs.existsSync(onDisk)).toBe(true);
|
expect(fs.existsSync(onDisk)).toBe(true);
|
||||||
expect(fs.readFileSync(onDisk).equals(PNG)).toBe(true);
|
expect(fs.readFileSync(onDisk).equals(PNG)).toBe(true);
|
||||||
expect(prisma.__rows[0].storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
|
expect(prisma.__rows[0].storagePath).toBe(
|
||||||
|
`user-files/dashboard-images/user-1/${result.id}.png`,
|
||||||
|
);
|
||||||
// Die Zeile traegt den Pfad schon beim Anlegen (Pflichtfeld seit Stufe 2),
|
// Die Zeile traegt den Pfad schon beim Anlegen (Pflichtfeld seit Stufe 2),
|
||||||
// die Kennung ist eine vom Dienst vergebene UUID, und Bytes gehen nie in
|
// die Kennung ist eine vom Dienst vergebene UUID, und Bytes gehen nie in
|
||||||
// die Zeile.
|
// die Zeile.
|
||||||
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as { data: Record<string, unknown> };
|
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as {
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
};
|
||||||
expect(createArgs.data.storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
|
expect(createArgs.data.storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
|
||||||
expect(createArgs.data.id).toBe(result.id);
|
expect(createArgs.data.id).toBe(result.id);
|
||||||
expect(result.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
expect(result.id).toMatch(
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||||
|
);
|
||||||
expect(createArgs.data).not.toHaveProperty('data');
|
expect(createArgs.data).not.toHaveProperty('data');
|
||||||
expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
|
expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -444,7 +574,10 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
|||||||
// Eigene Kennung: das Verzeichnis ist ueber alle Tests dieser Datei
|
// Eigene Kennung: das Verzeichnis ist ueber alle Tests dieser Datei
|
||||||
// dasselbe, eine von Test 8/10 angelegte `img-1.png` waere sonst da.
|
// dasselbe, eine von Test 8/10 angelegte `img-1.png` waere sonst da.
|
||||||
const prisma = makeFakePrisma([
|
const prisma = makeFakePrisma([
|
||||||
makeRow({ id: 'datei-fehlt', storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png' }),
|
makeRow({
|
||||||
|
id: 'datei-fehlt',
|
||||||
|
storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png',
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
await expect(makeService(prisma).getBytes('datei-fehlt', 'user-1', 'tenant-1')).rejects.toThrow(
|
await expect(makeService(prisma).getBytes('datei-fehlt', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -456,7 +589,9 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
|||||||
const onDisk = storedFile('user-1', 'weg');
|
const onDisk = storedFile('user-1', 'weg');
|
||||||
expect(fs.existsSync(onDisk)).toBe(true);
|
expect(fs.existsSync(onDisk)).toBe(true);
|
||||||
|
|
||||||
await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'weg' });
|
await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({
|
||||||
|
id: 'weg',
|
||||||
|
});
|
||||||
expect(prisma.__rows).toHaveLength(0);
|
expect(prisma.__rows).toHaveLength(0);
|
||||||
expect(fs.existsSync(onDisk)).toBe(false);
|
expect(fs.existsSync(onDisk)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
import * as path from 'node:path';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Injectable,
|
Injectable,
|
||||||
@@ -5,12 +8,9 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import * as fs from 'node:fs/promises';
|
|
||||||
import * as path from 'node:path';
|
|
||||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import {
|
import {
|
||||||
DASHBOARD_IMAGE_MAX_COUNT,
|
DASHBOARD_IMAGE_MAX_COUNT,
|
||||||
type DashboardImageMime,
|
type DashboardImageMime,
|
||||||
@@ -314,6 +314,15 @@ export class DashboardImagesService {
|
|||||||
* Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht.
|
* Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht.
|
||||||
* Zeile zuerst, Datei danach: ein Fehler beim Entfernen der Datei wird
|
* Zeile zuerst, Datei danach: ein Fehler beim Entfernen der Datei wird
|
||||||
* protokolliert und geschluckt (T-HK4-04).
|
* protokolliert und geschluckt (T-HK4-04).
|
||||||
|
*
|
||||||
|
* quick-260930: War das Bild der Dashboard-Hintergrund des Benutzers
|
||||||
|
* (`User.dashboardBackground` = `{ kind: 'image', imageId: <diese UUID> }`),
|
||||||
|
* wird die Wahl im selben Vorgang auf „kein Hintergrund“ gesetzt — sonst
|
||||||
|
* zeigte sie auf ein Bild, das es nicht mehr gibt. Bedingtes `updateMany`
|
||||||
|
* (JSON-Pfad `imageId`), damit jede andere Wahl unberuehrt bleibt; nur die
|
||||||
|
* eigene Zeile (`id: userId`). Ein Fehler dabei wird wie beim Entfernen der
|
||||||
|
* Datei protokolliert und geschluckt: das Bild ist schon weg, und das Web
|
||||||
|
* zeigt eine Wahl mit nicht ladbarem Bild ohnehin als „kein Hintergrund“.
|
||||||
*/
|
*/
|
||||||
async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> {
|
async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> {
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
@@ -323,6 +332,19 @@ export class DashboardImagesService {
|
|||||||
}
|
}
|
||||||
await tenantPrisma.dashboardImage.delete({ where: { id } });
|
await tenantPrisma.dashboardImage.delete({ where: { id } });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await tenantPrisma.user.updateMany({
|
||||||
|
where: { id: userId, dashboardBackground: { path: ['imageId'], equals: id } },
|
||||||
|
data: { dashboardBackground: { kind: 'none' } },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Hintergrund-Wahl zum geloeschten Bilderrahmen-Bild ${id} konnte nicht zurueckgesetzt werden: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const absolute = absoluteImagePath(row.storagePath);
|
const absolute = absoluteImagePath(row.storagePath);
|
||||||
if (absolute !== null) {
|
if (absolute !== null) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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)
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
IsString,
|
IsString,
|
||||||
IsUrl,
|
IsUrl,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
|
MaxLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,6 +25,7 @@ export class CreateFavoriteDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@MaxLength(2048)
|
||||||
iconUrl?: string;
|
iconUrl?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsInt, IsOptional, IsString, IsUrl } from 'class-validator';
|
import { IsInt, IsOptional, IsString, IsUrl, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO for updating an existing FavoriteLink.
|
* DTO for updating an existing FavoriteLink.
|
||||||
@@ -19,6 +19,8 @@ export class UpdateFavoriteDto {
|
|||||||
* No strict type validation so null passes through to Prisma.
|
* No strict type validation so null passes through to Prisma.
|
||||||
*/
|
*/
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2048)
|
||||||
iconUrl?: string | null;
|
iconUrl?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
HttpException,
|
HttpException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
PayloadTooLargeException,
|
PayloadTooLargeException,
|
||||||
UnprocessableEntityException,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { FavoritesService } from './favorites.service';
|
import { FavoritesService } from './favorites.service';
|
||||||
@@ -221,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:
|
||||||
@@ -230,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');
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,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({
|
||||||
@@ -859,6 +896,40 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('update — ausdrueckliche Logo-Adresse verdraengt ein hochgeladenes Symbol (260929-lh3)', () => {
|
||||||
|
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||||
|
|
||||||
|
it('neue, abweichende iconUrl bei vorhandenem Upload: Upload-Typ null, Datei weg, iconVersion erneut +1 — die neue Adresse wird angezeigt', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||||
|
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(true);
|
||||||
|
|
||||||
|
const updated = await service.update('t1', 'f1', 'user-a1', {
|
||||||
|
iconUrl: 'https://neu.invalid/logo.png',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(updated.iconUrl).toBe('https://neu.invalid/logo.png');
|
||||||
|
expect(updated.uploadedIconMime).toBeNull();
|
||||||
|
expect(updated.iconVersion).toBe(2);
|
||||||
|
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UNVERAENDERTE iconUrl bei vorhandenem Upload (das Formular schickt sie bei jedem Speichern mit): Upload bleibt', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||||
|
|
||||||
|
const updated = await service.update('t1', 'f1', 'user-a1', {
|
||||||
|
iconUrl: baseRow.iconUrl,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(updated.uploadedIconMime).toBe('image/png');
|
||||||
|
expect(updated.iconVersion).toBe(1);
|
||||||
|
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('remove() mit hochgeladenem Symbol', () => {
|
describe('remove() mit hochgeladenem Symbol', () => {
|
||||||
it('Zeile und Datei weg', async () => {
|
it('Zeile und Datei weg', async () => {
|
||||||
const prisma = makeFakePrisma([baseRow]);
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
@@ -886,26 +957,50 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('create/update — Abrufprobe fuer eine explizite iconUrl (260923-lrr)', () => {
|
describe('create/update — ausdrueckliche iconUrl: nur Formpruefung, kein Abruf (260929-lh3)', () => {
|
||||||
it('create mit expliziter iconUrl: Probe genau einmal; wirft -> UnprocessableEntityException, favoriteLink.create NICHT aufgerufen', async () => {
|
const widgets = [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }];
|
||||||
const prisma = makeFakePrisma([], [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]);
|
const failingFetch = () =>
|
||||||
const iconDiscovery = makeIconDiscovery({
|
vi.fn(async () => {
|
||||||
fetchIconBytes: vi.fn(async () => {
|
throw new Error('server bekommt 404/HTML');
|
||||||
throw new Error('blocked');
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('create mit einer Adresse, die der SERVER nicht abrufen kann: wird gespeichert, KEIN Abruf, KEINE Erkennung', async () => {
|
||||||
|
const prisma = makeFakePrisma([], widgets);
|
||||||
|
const iconDiscovery = makeIconDiscovery({ fetchIconBytes: failingFetch() });
|
||||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
|
||||||
|
const created = await service.create('t1', 'user-a1', {
|
||||||
|
widgetId: 'widget-a1',
|
||||||
|
title: 'Docuvita',
|
||||||
|
url: 'https://docuvita.ctl.local/server/services/web/',
|
||||||
|
iconUrl: 'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(created.iconUrl).toBe(
|
||||||
|
'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico',
|
||||||
|
);
|
||||||
|
expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled();
|
||||||
|
expect(iconDiscovery.discoverFavoriteIconUrl).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.__favorites.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['kein http/https', 'ftp://x.invalid/icon.png'],
|
||||||
|
['javascript-Schema', 'javascript:alert(1)'],
|
||||||
|
['keine Adresse', 'kein url'],
|
||||||
|
['laenger als 2048 Zeichen', `https://x.invalid/${'a'.repeat(2050)}`],
|
||||||
|
])('create mit ungueltiger iconUrl (%s) -> BadRequestException, nichts geschrieben', async (_label, iconUrl) => {
|
||||||
|
const prisma = makeFakePrisma([], widgets);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.create('t1', 'user-a1', {
|
service.create('t1', 'user-a1', {
|
||||||
widgetId: 'widget-a1',
|
widgetId: 'widget-a1',
|
||||||
title: 'X',
|
title: 'X',
|
||||||
url: 'https://x.invalid',
|
url: 'https://x.invalid',
|
||||||
iconUrl: 'https://x.invalid/logo.png',
|
iconUrl,
|
||||||
} as any),
|
} as any),
|
||||||
).rejects.toThrow(UnprocessableEntityException);
|
).rejects.toThrow(BadRequestException);
|
||||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledTimes(1);
|
|
||||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://x.invalid/logo.png');
|
|
||||||
expect(prisma.__favorites.size).toBe(0);
|
expect(prisma.__favorites.size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -922,24 +1017,31 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
|||||||
iconVersion: 0,
|
iconVersion: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
it('update mit neuer, abweichender iconUrl: fetchIconBytes genau einmal mit dieser Adresse; wirft -> UnprocessableEntityException, favoriteLink.update NICHT aufgerufen', async () => {
|
it('update mit neuer iconUrl, die der Server nicht abrufen kann: gespeichert, iconVersion +1, KEIN Abruf', async () => {
|
||||||
const prisma = makeFakePrisma([baseRow]);
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
const iconDiscovery = makeIconDiscovery({
|
const iconDiscovery = makeIconDiscovery({ fetchIconBytes: failingFetch() });
|
||||||
fetchIconBytes: vi.fn(async () => {
|
|
||||||
throw new Error('blocked');
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
|
||||||
|
const updated = await service.update('t1', 'f1', 'user-a1', {
|
||||||
|
iconUrl: 'https://neu.invalid/icon.png',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(updated.iconUrl).toBe('https://neu.invalid/icon.png');
|
||||||
|
expect(updated.iconVersion).toBe(1);
|
||||||
|
expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update mit ungueltiger neuer iconUrl -> BadRequestException, Zeile unveraendert', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.update('t1', 'f1', 'user-a1', { iconUrl: 'https://neu.invalid/icon.png' } as any),
|
service.update('t1', 'f1', 'user-a1', { iconUrl: 'file:///etc/passwd' } as any),
|
||||||
).rejects.toThrow(UnprocessableEntityException);
|
).rejects.toThrow(BadRequestException);
|
||||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledTimes(1);
|
|
||||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://neu.invalid/icon.png');
|
|
||||||
expect(prisma.__favorites.get('f1').iconUrl).toBe(baseRow.iconUrl);
|
expect(prisma.__favorites.get('f1').iconUrl).toBe(baseRow.iconUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('update mit UNVERAENDERTER iconUrl: keine Probe, keine Erhoehung', async () => {
|
it('update mit UNVERAENDERTER iconUrl: keine Pruefung, keine Erhoehung', async () => {
|
||||||
const prisma = makeFakePrisma([baseRow]);
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
const iconDiscovery = makeIconDiscovery();
|
const iconDiscovery = makeIconDiscovery();
|
||||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
@@ -958,18 +1060,5 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
|||||||
|
|
||||||
expect(updated.iconVersion).toBe(0);
|
expect(updated.iconVersion).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('update mit neuer, erreichbarer iconUrl: iconVersion +1', async () => {
|
|
||||||
const prisma = makeFakePrisma([baseRow]);
|
|
||||||
const iconDiscovery = makeIconDiscovery();
|
|
||||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
|
||||||
|
|
||||||
const updated = await service.update('t1', 'f1', 'user-a1', {
|
|
||||||
iconUrl: 'https://neu.invalid/icon.png',
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://neu.invalid/icon.png');
|
|
||||||
expect(updated.iconVersion).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
PayloadTooLargeException,
|
PayloadTooLargeException,
|
||||||
UnprocessableEntityException,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { UploadedFileLike } from '../auth/types/auth-user';
|
import type { UploadedFileLike } from '../auth/types/auth-user';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@@ -77,12 +76,16 @@ import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
|
|||||||
* Datei, dann die Zeile; scheitert die Zeile, wird die neue Datei wieder
|
* Datei, dann die Zeile; scheitert die Zeile, wird die neue Datei wieder
|
||||||
* entfernt. Entfernen/Loeschen aktualisiert zuerst die Zeile, ein
|
* entfernt. Entfernen/Loeschen aktualisiert zuerst die Zeile, ein
|
||||||
* Dateifehler wird protokolliert und geschluckt.
|
* Dateifehler wird protokolliert und geschluckt.
|
||||||
* - Abrufprobe: `assertIconUrlLoadable()` ruft `fetchIconBytes` einmal ab,
|
* - 260929-lh3 (loest die Abrufprobe von 260923-lrr ab): eine ausdrueckliche
|
||||||
* um eine im Formular NICHT abrufbare Logo-Adresse (z. B. hinter einer
|
* Logo-Adresse wird nur auf Form (http/https, <= 2048 Zeichen) geprueft und
|
||||||
* Cloudflare-Pruefung) mit `UnprocessableEntityException` (422) statt
|
* auch gespeichert, wenn der Server sie nicht abrufen kann — der Browser der
|
||||||
* stiller Speicherung abzuweisen — keine Umgehung von Bot-Sperren, nur
|
* Kachel laedt sie dann direkt. Ein hochgeladenes Symbol wird von einer
|
||||||
* derselbe Abruf, den `GET /favorites/:id/icon` ohnehin ausloest.
|
* neuen, abweichenden Adresse verdraengt (Vorrang der Datei sonst: Adresse
|
||||||
|
* gespeichert, aber unsichtbar).
|
||||||
*/
|
*/
|
||||||
|
/** Hoechstlaenge einer ausdruecklichen Logo-Adresse (260929-lh3). */
|
||||||
|
const ICON_URL_MAX_LENGTH = 2048;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FavoritesService {
|
export class FavoritesService {
|
||||||
private readonly logger = new Logger(FavoritesService.name);
|
private readonly logger = new Logger(FavoritesService.name);
|
||||||
@@ -107,19 +110,31 @@ export class FavoritesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prueft, ob sich das Bild unter `iconUrl` serverseitig abrufen laesst
|
* Prueft eine ausdruecklich eingetragene Logo-Adresse NUR auf Form (260929-lh3):
|
||||||
* (260923-lrr) — derselbe `fetchIconBytes`-Aufruf, den `getIconBytes`
|
* gueltige http/https-Adresse, hoechstens 2048 Zeichen. Bewusst KEIN
|
||||||
* ohnehin ausloest, hier nur zur Speicherzeit als Probe. Jeder Fehler
|
* serverseitiger Abruf mehr — Server wie docuvita liefern dem Server ein
|
||||||
* (SSRF-Ablehnung, Zeitgrenze, kein `image/*`, Cloudflare-Pruefung o. ae.)
|
* 404/HTML, dem Browser aber das Bild; die fruehere Abrufprobe (422,
|
||||||
* wird zu derselben deutschen 422-Meldung — keine Unterscheidung, aus der
|
* 260923-lrr) machte genau diese Adressen unspeicherbar. Entscheidung: auch
|
||||||
* sich etwas ueber die gepruefte Adresse ablesen liesse.
|
* eine Antwort, die der Server sieht und die kein Bild ist, weist NICHT ab —
|
||||||
|
* "Server bekommt kein Bild" heisst nicht "Browser bekommt keins", und der
|
||||||
|
* Server kann beides nicht unterscheiden. Der SSRF-Schutz bleibt unveraendert
|
||||||
|
* dort, wo der Server tatsaechlich abruft (`getIconBytes`/Erkennung); scheitert
|
||||||
|
* der Proxy, laedt die Kachel die Adresse direkt im Browser.
|
||||||
*/
|
*/
|
||||||
private async assertIconUrlLoadable(iconUrl: string): Promise<void> {
|
private assertIconUrlWellFormed(iconUrl: string): void {
|
||||||
|
let parsed: URL | null = null;
|
||||||
try {
|
try {
|
||||||
await this.iconDiscovery.fetchIconBytes(iconUrl);
|
parsed = new URL(iconUrl);
|
||||||
} catch {
|
} catch {
|
||||||
throw new UnprocessableEntityException(
|
parsed = null;
|
||||||
'Das Bild unter dieser Adresse konnte nicht geladen werden. Die Seite blockiert vermutlich automatische Abrufe (zum Beispiel durch eine Cloudflare-Prüfung) oder ist nicht erreichbar. Bitte laden Sie das Symbol stattdessen hoch.',
|
}
|
||||||
|
if (
|
||||||
|
parsed === null ||
|
||||||
|
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
|
||||||
|
iconUrl.length > ICON_URL_MAX_LENGTH
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Die Logo-Adresse muss eine gültige http- oder https-Adresse sein (höchstens 2048 Zeichen).',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,8 +144,8 @@ export class FavoritesService {
|
|||||||
* Verifies the target widget belongs to the caller BEFORE any icon
|
* Verifies the target widget belongs to the caller BEFORE any icon
|
||||||
* discovery network call (T-GWH-05).
|
* discovery network call (T-GWH-05).
|
||||||
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection.
|
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection.
|
||||||
* If iconUrl IS provided (260923-lrr), it must load successfully or the
|
* If iconUrl IS provided it is stored as given after a form check only
|
||||||
* create is rejected with 422 — nothing is written on a failed probe.
|
* (260929-lh3, see assertIconUrlWellFormed) — no server-side fetch.
|
||||||
*/
|
*/
|
||||||
async create(tenantId: string, userId: string, dto: CreateFavoriteDto) {
|
async create(tenantId: string, userId: string, dto: CreateFavoriteDto) {
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
@@ -154,8 +169,8 @@ export class FavoritesService {
|
|||||||
let iconUrl = dto.iconUrl ?? null;
|
let iconUrl = dto.iconUrl ?? null;
|
||||||
|
|
||||||
if (iconUrl) {
|
if (iconUrl) {
|
||||||
// 260923-lrr: explizit uebergebene Adresse wird einmal probiert.
|
// 260929-lh3: nur Formpruefung, kein serverseitiger Abruf.
|
||||||
await this.assertIconUrlLoadable(iconUrl);
|
this.assertIconUrlWellFormed(iconUrl);
|
||||||
} else {
|
} else {
|
||||||
// Server-side icon discovery (D-05) — only when caller did not supply an icon
|
// Server-side icon discovery (D-05) — only when caller did not supply an icon
|
||||||
iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url);
|
iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url);
|
||||||
@@ -179,10 +194,11 @@ export class FavoritesService {
|
|||||||
* Verifies userId ownership before applying changes (T-08-06).
|
* Verifies userId ownership before applying changes (T-08-06).
|
||||||
* Accepts null as an explicit value for iconUrl (clears stored icon).
|
* Accepts null as an explicit value for iconUrl (clears stored icon).
|
||||||
*
|
*
|
||||||
* 260923-lrr: eine neue, vom gespeicherten Wert ABWEICHENDE `iconUrl`
|
* 260929-lh3: eine neue, vom gespeicherten Wert ABWEICHENDE `iconUrl`
|
||||||
* durchlaeuft die Abrufprobe (`assertIconUrlLoadable`), bevor irgendetwas
|
* durchlaeuft nur die Formpruefung (`assertIconUrlWellFormed`), bevor
|
||||||
* geschrieben wird; misslingt sie, bleibt die Zeile unveraendert. Jede
|
* irgendetwas geschrieben wird; sie wird auch gespeichert, wenn der Server
|
||||||
* tatsaechliche Aenderung der Symbolquelle erhoeht `iconVersion`.
|
* sie nicht abrufen kann. Jede tatsaechliche Aenderung der Symbolquelle
|
||||||
|
* erhoeht `iconVersion`.
|
||||||
*/
|
*/
|
||||||
async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) {
|
async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) {
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
@@ -205,8 +221,8 @@ export class FavoritesService {
|
|||||||
if ('iconUrl' in dto) {
|
if ('iconUrl' in dto) {
|
||||||
if (dto.iconUrl) {
|
if (dto.iconUrl) {
|
||||||
if (dto.iconUrl !== link.iconUrl) {
|
if (dto.iconUrl !== link.iconUrl) {
|
||||||
// 260923-lrr: nur eine NEUE, abweichende Adresse wird probiert.
|
// 260929-lh3: nur eine NEUE, abweichende Adresse wird geprueft (Form).
|
||||||
await this.assertIconUrlLoadable(dto.iconUrl);
|
this.assertIconUrlWellFormed(dto.iconUrl);
|
||||||
}
|
}
|
||||||
// Explicit icon URL supplied — respect it as-is.
|
// Explicit icon URL supplied — respect it as-is.
|
||||||
data.iconUrl = dto.iconUrl;
|
data.iconUrl = dto.iconUrl;
|
||||||
@@ -219,14 +235,34 @@ export class FavoritesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.iconUrl !== undefined && data.iconUrl !== link.iconUrl) {
|
// 260929-lh3: eine NEUE, ausdruecklich eingetragene Logo-Adresse muss
|
||||||
|
// Vorrang vor einem frueher hochgeladenen Symbol haben. `getIconBytes`
|
||||||
|
// liefert bei gesetztem `uploadedIconMime` IMMER die Datei — ohne diesen
|
||||||
|
// Schritt blieb die neue Adresse gespeichert, aber unsichtbar (die Kachel
|
||||||
|
// zeigte weiter das alte hochgeladene Bild). Nur bei einer tatsaechlichen
|
||||||
|
// Aenderung: das Formular schickt die unveraenderte Adresse bei jedem
|
||||||
|
// Speichern mit, das darf ein hochgeladenes Symbol nicht verdraengen.
|
||||||
|
const iconUrlChanged = data.iconUrl !== undefined && data.iconUrl !== link.iconUrl;
|
||||||
|
const explicitUrlReplacesUpload =
|
||||||
|
iconUrlChanged && Boolean(dto.iconUrl) && link.uploadedIconMime !== null;
|
||||||
|
if (explicitUrlReplacesUpload) {
|
||||||
|
data.uploadedIconMime = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iconUrlChanged) {
|
||||||
data.iconVersion = { increment: 1 };
|
data.iconVersion = { increment: 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
return tenantPrisma.favoriteLink.update({
|
const updated = await tenantPrisma.favoriteLink.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (explicitUrlReplacesUpload && link.uploadedIconMime !== null) {
|
||||||
|
await this.removeIconFile(id, link.userId, link.uploadedIconMime, 'ersetzte');
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -461,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,
|
||||||
@@ -499,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,24 +18,21 @@ vi.mock('undici', () => ({
|
|||||||
|
|
||||||
import { Agent } from 'undici';
|
import { Agent } from 'undici';
|
||||||
import {
|
import {
|
||||||
|
discardBody,
|
||||||
IconDiscoveryService,
|
IconDiscoveryService,
|
||||||
isPublicHttpUrl,
|
isPublicHttpUrl,
|
||||||
normalizeUrl,
|
normalizeUrl,
|
||||||
|
readTextCapped,
|
||||||
} from './icon-discovery.service';
|
} from './icon-discovery.service';
|
||||||
|
|
||||||
function mockResponse(options: {
|
function mockResponse(options: { contentType?: string; body?: ArrayBuffer }): Response {
|
||||||
contentType?: string;
|
|
||||||
body?: ArrayBuffer;
|
|
||||||
}): Response {
|
|
||||||
const body = options.body ?? new ArrayBuffer(10);
|
const body = options.body ?? new ArrayBuffer(10);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
get: (name: string) =>
|
get: (name: string) =>
|
||||||
name.toLowerCase() === 'content-type'
|
name.toLowerCase() === 'content-type' ? (options.contentType ?? 'image/png') : null,
|
||||||
? (options.contentType ?? 'image/png')
|
|
||||||
: null,
|
|
||||||
},
|
},
|
||||||
arrayBuffer: async () => body,
|
arrayBuffer: async () => body,
|
||||||
} as unknown as Response;
|
} as unknown as Response;
|
||||||
@@ -106,9 +103,7 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl', () => {
|
|||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
get: (n: string) =>
|
get: (n: string) =>
|
||||||
n.toLowerCase() === 'content-type'
|
n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null,
|
||||||
? 'text/html; charset=utf-8'
|
|
||||||
: null,
|
|
||||||
},
|
},
|
||||||
text: async () => html,
|
text: async () => html,
|
||||||
}),
|
}),
|
||||||
@@ -133,6 +128,58 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('IconDiscoveryService.discoverFavoriteIconUrl — Seite mit Fehlerstatus (260929-lh3)', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function htmlResponse(status: number, html: string) {
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
get: (n: string) =>
|
||||||
|
n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null,
|
||||||
|
},
|
||||||
|
text: async () => html,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('Seite antwortet 400, traegt aber <link rel="SHORTCUT ICON"> (docuvita) -> dieser Verweis wird genutzt', async () => {
|
||||||
|
const html =
|
||||||
|
'<html><head><link rel="SHORTCUT ICON" type="image/png" href="/webclient/docuvita/resources/brandimage/favicon.ico" /></head></html>';
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(htmlResponse(400, html)));
|
||||||
|
|
||||||
|
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl(
|
||||||
|
'http://8.8.8.8/server/services/web/',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(icon).toBe('http://8.8.8.8/webclient/docuvita/resources/brandimage/favicon.ico');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehlerseite ohne Symbol-Verweis, nur og:image -> Rueckfall <origin>/favicon.ico (og:image einer Fehlerseite zaehlt nicht)', async () => {
|
||||||
|
const html =
|
||||||
|
'<html><head><meta property="og:image" content="https://cdn.invalid/x.png"></head></html>';
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(htmlResponse(404, html)));
|
||||||
|
|
||||||
|
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/x');
|
||||||
|
|
||||||
|
expect(icon).toBe('http://8.8.8.8/favicon.ico');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchIconBytes bleibt streng: Fehlerstatus -> wirft (kein allowErrorStatus fuer Bilder)', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({ ...htmlResponse(404, ''), headers: { get: () => 'text/html' } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
new IconDiscoveryService().fetchIconBytes('http://8.8.8.8/favicon.ico'),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('IconDiscoveryService.fetchIconBytes', () => {
|
describe('IconDiscoveryService.fetchIconBytes', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@@ -155,16 +202,13 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects when Content-Type is not an image', async () => {
|
it('rejects when Content-Type is not an image', async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })));
|
||||||
'fetch',
|
|
||||||
vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })),
|
|
||||||
);
|
|
||||||
|
|
||||||
const service = new IconDiscoveryService();
|
const service = new IconDiscoveryService();
|
||||||
|
|
||||||
await expect(
|
await expect(service.fetchIconBytes('http://8.8.8.8/favicon.ico')).rejects.toThrow(
|
||||||
service.fetchIconBytes('http://8.8.8.8/favicon.ico'),
|
/not an image/,
|
||||||
).rejects.toThrow(/not an image/);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects when the SSRF guard blocks the target', async () => {
|
it('rejects when the SSRF guard blocks the target', async () => {
|
||||||
@@ -173,9 +217,9 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
|
|||||||
|
|
||||||
const service = new IconDiscoveryService();
|
const service = new IconDiscoveryService();
|
||||||
|
|
||||||
await expect(
|
await expect(service.fetchIconBytes('http://127.0.0.1/favicon.ico')).rejects.toThrow(
|
||||||
service.fetchIconBytes('http://127.0.0.1/favicon.ico'),
|
/blocked or failed/,
|
||||||
).rejects.toThrow(/blocked or failed/);
|
);
|
||||||
expect(fetchSpy).not.toHaveBeenCalled();
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,9 +232,60 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
|
|||||||
|
|
||||||
const service = new IconDiscoveryService();
|
const service = new IconDiscoveryService();
|
||||||
|
|
||||||
|
await expect(service.fetchIconBytes('http://8.8.8.8/favicon.ico')).rejects.toThrow(
|
||||||
|
/size limit/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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(
|
await expect(
|
||||||
service.fetchIconBytes('http://8.8.8.8/favicon.ico'),
|
service.fetchPublicServiceIconBytes('https://docuvita.ctl.local/x'),
|
||||||
).rejects.toThrow(/size limit/);
|
).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/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -210,10 +305,7 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', (
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('still returns a URL string', async () => {
|
it('still returns a URL string', async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })));
|
||||||
'fetch',
|
|
||||||
vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })),
|
|
||||||
);
|
|
||||||
|
|
||||||
const service = new IconDiscoveryService();
|
const service = new IconDiscoveryService();
|
||||||
const result = await service.discoverFavoriteIconUrl('http://8.8.8.8/page');
|
const result = await service.discoverFavoriteIconUrl('http://8.8.8.8/page');
|
||||||
@@ -287,3 +379,143 @@ describe('IconDiscoveryService — Dispatcher (260917-jdd)', () => {
|
|||||||
expect(calls[0][1].dispatcher).toBe(calls[1][1].dispatcher);
|
expect(calls[0][1].dispatcher).toBe(calls[1][1].dispatcher);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('readTextCapped / discardBody — Groessendeckel beim Lesen (T-08-09)', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Stream aus `chunks` Stuecken je `chunkChars` ASCII-Zeichen; zaehlt gelesene Stuecke und Abbruch. */
|
||||||
|
function countingStream(chunks: number, chunkChars: number) {
|
||||||
|
const state = { pulled: 0, cancelled: false };
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
pull(controller) {
|
||||||
|
if (state.pulled >= chunks) {
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.pulled += 1;
|
||||||
|
controller.enqueue(encoder.encode('a'.repeat(chunkChars)));
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
state.cancelled = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { body, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('bricht den Stream nach der Grenze ab statt alles zu lesen', async () => {
|
||||||
|
const { body, state } = countingStream(1000, 1000);
|
||||||
|
const text = await readTextCapped({ body, text: async () => 'unbenutzt' } as never, 2500);
|
||||||
|
|
||||||
|
expect(text).toHaveLength(2500);
|
||||||
|
expect(state.pulled).toBeLessThan(10);
|
||||||
|
expect(state.cancelled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gibt nach der Zeitgrenze zurueck, was bis dahin da ist (tropfender Server)', async () => {
|
||||||
|
let cancelled = false;
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new TextEncoder().encode('<link rel="icon">'));
|
||||||
|
// danach kommt nichts mehr, der Stream bleibt offen
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
cancelled = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await readTextCapped({ body, text: async () => '' } as never, 200000, 50);
|
||||||
|
|
||||||
|
expect(text).toBe('<link rel="icon">');
|
||||||
|
expect(cancelled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liest kurze Seiten vollstaendig, auch Mehrbyte-Zeichen ueber Chunk-Grenzen', async () => {
|
||||||
|
const bytes = new TextEncoder().encode('<p>Grüße</p>');
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
// Das "ü" (2 Bytes) wird absichtlich zerteilt.
|
||||||
|
controller.enqueue(bytes.slice(0, 5));
|
||||||
|
controller.enqueue(bytes.slice(5));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await readTextCapped({ body, text: async () => '' } as never, 200000);
|
||||||
|
|
||||||
|
expect(text).toBe('<p>Grüße</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Stream: Rueckfall auf text() mit Deckel', async () => {
|
||||||
|
const text = await readTextCapped(
|
||||||
|
{ body: null, text: async () => 'x'.repeat(50) } as never,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(text).toBe('x'.repeat(10));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discardBody bricht einen offenen Body ab und vertraegt fehlenden Body', () => {
|
||||||
|
const { body, state } = countingStream(5, 10);
|
||||||
|
discardBody({ body } as never);
|
||||||
|
expect(state.cancelled).toBe(true);
|
||||||
|
expect(() => discardBody({ body: null } as never)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Discovery: Fehlerstatus ohne HTML-Typ -> Body wird verworfen, Rueckfall favicon.ico', async () => {
|
||||||
|
const { body, state } = countingStream(5, 10);
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
get: (n: string) => (n.toLowerCase() === 'content-type' ? 'application/json' : null),
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/x');
|
||||||
|
|
||||||
|
expect(icon).toBe('http://8.8.8.8/favicon.ico');
|
||||||
|
expect(state.cancelled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Discovery: riesige HTML-Seite wird nur bis zur Grenze gelesen, Symbol am Anfang gefunden', async () => {
|
||||||
|
const head = '<html><head><link rel="icon" href="/klein.png" /></head><body>';
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const state = { pulled: 0, cancelled: false };
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
pull(controller) {
|
||||||
|
state.pulled += 1;
|
||||||
|
controller.enqueue(encoder.encode(state.pulled === 1 ? head : 'a'.repeat(64 * 1024)));
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
state.cancelled = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: { get: (n: string) => (n.toLowerCase() === 'content-type' ? 'text/html' : null) },
|
||||||
|
body,
|
||||||
|
text: async () => {
|
||||||
|
throw new Error('text() darf bei vorhandenem Stream nicht laufen');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/');
|
||||||
|
|
||||||
|
expect(icon).toBe('http://8.8.8.8/klein.png');
|
||||||
|
expect(state.cancelled).toBe(true);
|
||||||
|
// 200 000 Zeichen bei 64-KiB-Stuecken: hoechstens eine Handvoll gelesen.
|
||||||
|
expect(state.pulled).toBeLessThan(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { lookup } from 'node:dns/promises';
|
import { lookup } from 'node:dns/promises';
|
||||||
import { isIP } from 'node:net';
|
import { isIP } from 'node:net';
|
||||||
import { Agent, fetch as undiciFetch, type Response as UndiciResponse } from 'undici';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Agent, type Response as UndiciResponse, fetch as undiciFetch } from 'undici';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side favicon / icon discovery with SSRF protection (T-08-05).
|
* Server-side favicon / icon discovery with SSRF protection (T-08-05).
|
||||||
@@ -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
|
||||||
@@ -49,6 +61,8 @@ const LENIENT_TLS_AGENT = new Agent({ connect: { rejectUnauthorized: false } });
|
|||||||
type FetchHtmlResult = {
|
type FetchHtmlResult = {
|
||||||
html: string;
|
html: string;
|
||||||
finalUrl: string;
|
finalUrl: string;
|
||||||
|
/** false = die Seite antwortete mit einem Fehlerstatus (z. B. 400/404), lieferte aber HTML (260929-lh3). */
|
||||||
|
ok: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isPrivateIpv4(address: string): boolean {
|
function isPrivateIpv4(address: string): boolean {
|
||||||
@@ -56,9 +70,7 @@ function isPrivateIpv4(address: string): boolean {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
parts.length !== 4 ||
|
parts.length !== 4 ||
|
||||||
parts.some(
|
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
|
||||||
(part) => !Number.isInteger(part) || part < 0 || part > 255,
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -115,12 +127,7 @@ function isPrivateIpAddress(address: string): boolean {
|
|||||||
function isBlockedHostname(hostname: string): boolean {
|
function isBlockedHostname(hostname: string): boolean {
|
||||||
const h = hostname.trim().toLowerCase();
|
const h = hostname.trim().toLowerCase();
|
||||||
|
|
||||||
return (
|
return h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h === '0.0.0.0';
|
||||||
h === 'localhost' ||
|
|
||||||
h.endsWith('.localhost') ||
|
|
||||||
h.endsWith('.local') ||
|
|
||||||
h === '0.0.0.0'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isPublicHttpUrl(url: URL): Promise<boolean> {
|
export async function isPublicHttpUrl(url: URL): Promise<boolean> {
|
||||||
@@ -202,7 +209,7 @@ function toAbsoluteUrl(value: string | undefined, base: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractIconFromHtml(html: string, baseUrl: string): string | null {
|
function extractIconFromHtml(html: string, baseUrl: string, linkTagsOnly = false): string | null {
|
||||||
const linkTags = html.match(/<link\b[^>]*>/gi) ?? [];
|
const linkTags = html.match(/<link\b[^>]*>/gi) ?? [];
|
||||||
const metaTags = html.match(/<meta\b[^>]*>/gi) ?? [];
|
const metaTags = html.match(/<meta\b[^>]*>/gi) ?? [];
|
||||||
|
|
||||||
@@ -214,30 +221,26 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null {
|
|||||||
}))
|
}))
|
||||||
.filter((c) => c.href);
|
.filter((c) => c.href);
|
||||||
|
|
||||||
const appleTouchIcon = linkCandidates.find((c) =>
|
const appleTouchIcon = linkCandidates.find((c) => c.rel.includes('apple-touch-icon'))?.href;
|
||||||
c.rel.includes('apple-touch-icon'),
|
|
||||||
)?.href;
|
|
||||||
|
|
||||||
if (appleTouchIcon) return appleTouchIcon;
|
if (appleTouchIcon) return appleTouchIcon;
|
||||||
|
|
||||||
const icon = linkCandidates.find((c) =>
|
const icon = linkCandidates.find((c) => c.rel.split(/\s+/).includes('icon'))?.href;
|
||||||
c.rel.split(/\s+/).includes('icon'),
|
|
||||||
)?.href;
|
|
||||||
|
|
||||||
if (icon) return icon;
|
if (icon) return icon;
|
||||||
|
|
||||||
const shortcutIcon = linkCandidates.find((c) =>
|
const shortcutIcon = linkCandidates.find((c) => c.rel.includes('shortcut icon'))?.href;
|
||||||
c.rel.includes('shortcut icon'),
|
|
||||||
)?.href;
|
|
||||||
|
|
||||||
if (shortcutIcon) return shortcutIcon;
|
if (shortcutIcon) return shortcutIcon;
|
||||||
|
|
||||||
const imageSrc = linkCandidates.find((c) =>
|
const imageSrc = linkCandidates.find((c) => c.rel.includes('image_src'))?.href;
|
||||||
c.rel.includes('image_src'),
|
|
||||||
)?.href;
|
|
||||||
|
|
||||||
if (imageSrc) return imageSrc;
|
if (imageSrc) return imageSrc;
|
||||||
|
|
||||||
|
// 260929-lh3: eine Fehlerseite (Status != 2xx) traegt kein Vorschaubild der
|
||||||
|
// Seite — nur die ausdruecklichen Symbol-Verweise (<link rel=...icon>) zaehlen.
|
||||||
|
if (linkTagsOnly) return null;
|
||||||
|
|
||||||
const metaImage = metaTags
|
const metaImage = metaTags
|
||||||
.map((tag) => parseAttributes(tag))
|
.map((tag) => parseAttributes(tag))
|
||||||
.map((a) => ({
|
.map((a) => ({
|
||||||
@@ -247,9 +250,7 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null {
|
|||||||
.find(
|
.find(
|
||||||
(c) =>
|
(c) =>
|
||||||
c.content &&
|
c.content &&
|
||||||
(c.property === 'og:image' ||
|
(c.property === 'og:image' || c.property === 'og:logo' || c.property === 'twitter:image'),
|
||||||
c.property === 'og:logo' ||
|
|
||||||
c.property === 'twitter:image'),
|
|
||||||
)?.content;
|
)?.content;
|
||||||
|
|
||||||
return metaImage ?? null;
|
return metaImage ?? null;
|
||||||
@@ -266,7 +267,13 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null {
|
|||||||
*/
|
*/
|
||||||
async function fetchWithRedirectGuard(
|
async function fetchWithRedirectGuard(
|
||||||
pageUrl: URL,
|
pageUrl: URL,
|
||||||
options: { accept: string; timeoutMs: number; userAgent?: string },
|
options: {
|
||||||
|
accept: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
userAgent?: string;
|
||||||
|
/** 260929-lh3: auch eine 4xx/5xx-Antwort zurueckgeben (nur fuer die HTML-Suche). */
|
||||||
|
allowErrorStatus?: boolean;
|
||||||
|
},
|
||||||
): Promise<{ response: UndiciResponse; finalUrl: URL } | null> {
|
): Promise<{ response: UndiciResponse; finalUrl: URL } | null> {
|
||||||
let currentUrl = pageUrl;
|
let currentUrl = pageUrl;
|
||||||
|
|
||||||
@@ -298,7 +305,7 @@ async function fetchWithRedirectGuard(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) return null;
|
if (!response.ok && !options.allowErrorStatus) return null;
|
||||||
|
|
||||||
return { response, finalUrl: currentUrl };
|
return { response, finalUrl: currentUrl };
|
||||||
} catch {
|
} catch {
|
||||||
@@ -311,23 +318,98 @@ async function fetchWithRedirectGuard(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Minimaler Ausschnitt einer Antwort, den die beiden Helfer brauchen. */
|
||||||
|
type BodyResponse = Pick<UndiciResponse, 'body' | 'text'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwirft den Body einer nicht gebrauchten Antwort. Fehler (bereits
|
||||||
|
* gelesen/abgebrochen) sind egal.
|
||||||
|
*/
|
||||||
|
export function discardBody(response: Pick<UndiciResponse, 'body'>): void {
|
||||||
|
try {
|
||||||
|
response.body?.cancel().catch(() => {});
|
||||||
|
} catch {
|
||||||
|
// Body gesperrt oder schon verbraucht — nichts zu tun.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest den Antworttext hoechstens bis `maxChars` Zeichen und bricht den
|
||||||
|
* Stream danach ab (T-08-09). Vorher wurde der komplette Body gelesen und
|
||||||
|
* erst danach abgeschnitten — eine riesige Seite landete ganz im Speicher.
|
||||||
|
* Dekodiert wird UTF-8 wie bei `Response.text()`; da jedes Zeichen aus
|
||||||
|
* mindestens einem Byte entsteht, bleibt der Speicher bei ~maxChars plus
|
||||||
|
* einem Chunk. Ohne Stream (`body === null`) wie bisher ueber `text()`.
|
||||||
|
* `timeoutMs` begrenzt zusaetzlich die Lesedauer: die Zeitgrenze von
|
||||||
|
* `fetchWithRedirectGuard` endet mit den Kopfzeilen, ein Server, der den
|
||||||
|
* Body tropfenweise liefert, hielte die Anfrage sonst beliebig lange auf.
|
||||||
|
* Nach Ablauf zaehlt, was bis dahin gelesen ist.
|
||||||
|
*/
|
||||||
|
export async function readTextCapped(
|
||||||
|
response: BodyResponse,
|
||||||
|
maxChars: number,
|
||||||
|
timeoutMs = HTML_FETCH_TIMEOUT_MS,
|
||||||
|
): Promise<string> {
|
||||||
|
if (!response.body) {
|
||||||
|
return (await response.text()).slice(0, maxChars);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let text = '';
|
||||||
|
// cancel() beendet ein haengendes read() mit done: true.
|
||||||
|
const deadline = setTimeout(() => void reader.cancel().catch(() => {}), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
text += decoder.decode();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
text += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
if (text.length >= maxChars) {
|
||||||
|
await reader.cancel().catch(() => {});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(deadline);
|
||||||
|
}
|
||||||
|
|
||||||
|
return text.slice(0, maxChars);
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
|
async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
|
||||||
const result = await fetchWithRedirectGuard(pageUrl, {
|
const result = await fetchWithRedirectGuard(pageUrl, {
|
||||||
accept: 'text/html,application/xhtml+xml,*/*',
|
accept: 'text/html,application/xhtml+xml,*/*',
|
||||||
timeoutMs: HTML_FETCH_TIMEOUT_MS,
|
timeoutMs: HTML_FETCH_TIMEOUT_MS,
|
||||||
|
// 260929-lh3: Server wie docuvita antworten dem Server mit 400, tragen im
|
||||||
|
// HTML aber trotzdem den <link rel=icon> — den Verweis wollen wir haben.
|
||||||
|
allowErrorStatus: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result) return null;
|
if (!result) return null;
|
||||||
|
|
||||||
const contentType = result.response.headers.get('content-type') ?? '';
|
const contentType = result.response.headers.get('content-type') ?? '';
|
||||||
|
|
||||||
if (!contentType.toLowerCase().includes('text/html')) return null;
|
if (!contentType.toLowerCase().includes('text/html')) {
|
||||||
|
// Kein HTML (auch bei Fehlerstatus dank allowErrorStatus hier moeglich):
|
||||||
|
// Body verwerfen, sonst haelt undici die Verbindung bis zum Timeout offen.
|
||||||
|
discardBody(result.response);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const html = await result.response.text();
|
// T-08-09: HTML cap — schon beim Lesen, nicht erst nach dem kompletten Body.
|
||||||
|
const html = await readTextCapped(result.response, MAX_HTML_CHARS);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: html.slice(0, MAX_HTML_CHARS), // T-08-09: HTML cap
|
html,
|
||||||
finalUrl: result.finalUrl.toString(),
|
finalUrl: result.finalUrl.toString(),
|
||||||
|
ok: result.response.ok,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,12 +432,28 @@ export class IconDiscoveryService {
|
|||||||
|
|
||||||
if (!htmlResult) return fallback;
|
if (!htmlResult) return fallback;
|
||||||
|
|
||||||
return extractIconFromHtml(htmlResult.html, htmlResult.finalUrl) ?? fallback;
|
return extractIconFromHtml(htmlResult.html, htmlResult.finalUrl, !htmlResult.ok) ?? fallback;
|
||||||
} catch {
|
} catch {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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-
|
||||||
@@ -365,9 +463,7 @@ export class IconDiscoveryService {
|
|||||||
* or an oversized body. Callers must not return a placeholder image; let
|
* or an oversized body. Callers must not return a placeholder image; let
|
||||||
* the caller map the failure to an HTTP error status instead.
|
* the caller map the failure to an HTTP error status instead.
|
||||||
*/
|
*/
|
||||||
async fetchIconBytes(
|
async fetchIconBytes(iconUrl: string): Promise<{ contentType: string; body: Buffer }> {
|
||||||
iconUrl: string,
|
|
||||||
): Promise<{ contentType: string; body: Buffer }> {
|
|
||||||
const url = new URL(iconUrl);
|
const url = new URL(iconUrl);
|
||||||
|
|
||||||
const result = await fetchWithRedirectGuard(url, {
|
const result = await fetchWithRedirectGuard(url, {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import * as nodemailer from 'nodemailer';
|
import * as nodemailer from 'nodemailer';
|
||||||
import { MailService } from './mail.service';
|
import { MailService, WELCOME_HEADER_CID } from './mail.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MailService.spec — NEU (260914-eym, Etappe 3c, WINDOWS #30). Der Bereich
|
* MailService.spec — NEU (260914-eym, Etappe 3c, WINDOWS #30). Der Bereich
|
||||||
@@ -166,7 +166,11 @@ describe('MailService — Transport je Versand nach Mandant des Empfaengers (260
|
|||||||
const service = new MailService(settings as any, makeFakeConfig({}) as any);
|
const service = new MailService(settings as any, makeFakeConfig({}) as any);
|
||||||
|
|
||||||
await service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-a', 't1');
|
await service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-a', 't1');
|
||||||
await service.sendWelcomeEmail('bob@b.example.invalid', 'bob', 't2');
|
await service.sendWelcomeMail('t2', 'bob@b.example.invalid', {
|
||||||
|
subject: 'Willkommen bei Tessera',
|
||||||
|
text: 'Guten Tag bob,',
|
||||||
|
html: '<p>bob</p>',
|
||||||
|
});
|
||||||
|
|
||||||
expect(settings.getDecryptedSmtpConfig.mock.calls.map((c) => c[0])).toEqual(['t1', 't2']);
|
expect(settings.getDecryptedSmtpConfig.mock.calls.map((c) => c[0])).toEqual(['t1', 't2']);
|
||||||
const transports = vi.mocked(nodemailer.createTransport).mock.calls.map((c) => c[0] as any);
|
const transports = vi.mocked(nodemailer.createTransport).mock.calls.map((c) => c[0] as any);
|
||||||
@@ -248,3 +252,113 @@ describe('MailService — Transport je Versand nach Mandant des Empfaengers (260
|
|||||||
expect(errorSpy).toHaveBeenCalled();
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('MailService.sendReminderEmail (quick-260929-if2, E-04/E-07, T-IF2-05)', () => {
|
||||||
|
const dueAt = new Date('2026-10-05T12:30:00.000Z'); // 14:30 in Europe/Berlin (Sommerzeit)
|
||||||
|
|
||||||
|
function make() {
|
||||||
|
return new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('sendet Betreff "Erinnerung: <Titel>" mit Berliner Zeit, Titel, Beschreibung und App-Adresse; true bei Erfolg', async () => {
|
||||||
|
const ok = await make().sendReminderEmail('t1', 'alice@a.example.invalid', {
|
||||||
|
title: 'Zahnarzt',
|
||||||
|
description: 'Kartenlesegeraet mitnehmen',
|
||||||
|
dueAt,
|
||||||
|
});
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
const sent = mockSendMail.mock.calls[0][0] as any;
|
||||||
|
expect(sent.to).toBe('alice@a.example.invalid');
|
||||||
|
expect(sent.from).toBe('noreply@a.example.invalid');
|
||||||
|
expect(sent.subject).toBe('Erinnerung: Zahnarzt');
|
||||||
|
expect(sent.html).toBeUndefined();
|
||||||
|
expect(sent.text).toContain('14:30 Uhr');
|
||||||
|
expect(sent.text).toContain('Zahnarzt');
|
||||||
|
expect(sent.text).toContain('Kartenlesegeraet mitnehmen');
|
||||||
|
expect(sent.text).toContain('http://localhost:3000');
|
||||||
|
expect(mockClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entfernt CR/LF aus dem Betreff und kuerzt auf 150 Zeichen', async () => {
|
||||||
|
await make().sendReminderEmail('t1', 'a@a.example.invalid', {
|
||||||
|
title: `Zeile1\r\nBcc: boese@example.invalid ${'x'.repeat(300)}`,
|
||||||
|
description: '',
|
||||||
|
dueAt,
|
||||||
|
});
|
||||||
|
const sent = mockSendMail.mock.calls[0][0] as any;
|
||||||
|
expect(sent.subject).not.toMatch(/[\r\n]/);
|
||||||
|
expect(sent.subject.length).toBe(150);
|
||||||
|
expect(sent.subject.startsWith('Erinnerung: Zeile1 Bcc:')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('laesst die Beschreibung weg, wenn sie leer ist', async () => {
|
||||||
|
await make().sendReminderEmail('t1', 'a@a.example.invalid', { title: 'T', description: ' ', dueAt });
|
||||||
|
const sent = mockSendMail.mock.calls[0][0] as any;
|
||||||
|
expect(sent.text.split('\n')).toEqual([
|
||||||
|
'Guten Tag,',
|
||||||
|
'',
|
||||||
|
expect.stringContaining('eine Erinnerung für'),
|
||||||
|
'',
|
||||||
|
'T',
|
||||||
|
'',
|
||||||
|
'http://localhost:3000',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gibt false zurueck (wirft nicht), wenn der Transport scheitert', async () => {
|
||||||
|
mockSendMail = vi.fn(async () => {
|
||||||
|
throw new Error('SMTP down');
|
||||||
|
});
|
||||||
|
const ok = await make().sendReminderEmail('t1', 'a@a.example.invalid', {
|
||||||
|
title: 'T',
|
||||||
|
description: '',
|
||||||
|
dueAt,
|
||||||
|
});
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
expect(mockClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MailService.sendWelcomeMail / hasConfiguredTransport (Willkommensmail)', () => {
|
||||||
|
it('haengt das Kopfbild als CID-Anhang an, reicht HTML und Text durch und wirft Transportfehler nach aussen', async () => {
|
||||||
|
const service = new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any);
|
||||||
|
|
||||||
|
await service.sendWelcomeMail('t1', 'neu@a.example.invalid', {
|
||||||
|
subject: 'Willkommen bei Tessera',
|
||||||
|
text: 'Text',
|
||||||
|
html: `<img src="cid:${WELCOME_HEADER_CID}">`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sent = mockSendMail.mock.calls[0][0] as any;
|
||||||
|
expect(sent.from).toBe('noreply@a.example.invalid');
|
||||||
|
expect(sent.html).toContain(`cid:${WELCOME_HEADER_CID}`);
|
||||||
|
expect(sent.text).toBe('Text');
|
||||||
|
expect(sent.attachments).toHaveLength(1);
|
||||||
|
expect(sent.attachments[0].cid).toBe(WELCOME_HEADER_CID);
|
||||||
|
expect(sent.attachments[0].contentType).toBe('image/png');
|
||||||
|
// PNG-Signatur: das Bild aus apps/api/assets/mail ist wirklich geladen.
|
||||||
|
expect((sent.attachments[0].content as Buffer).subarray(1, 4).toString()).toBe('PNG');
|
||||||
|
|
||||||
|
mockSendMail = vi.fn(async () => {
|
||||||
|
throw new Error('ECONNREFUSED');
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.sendWelcomeMail('t1', 'neu@a.example.invalid', { subject: 'S', text: 'T', html: 'H' }),
|
||||||
|
).rejects.toThrow('ECONNREFUSED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasConfiguredTransport: SmtpConfig des Mandanten oder MAIL_HOST/TESSERA_SMTP_HOST — der Rueckfall localhost:1025 zaehlt nicht', async () => {
|
||||||
|
expect(
|
||||||
|
await new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any).hasConfiguredTransport('t1'),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
await new MailService(makeFakeSettings({}) as any, makeFakeConfig({ MAIL_HOST: 'smtp.example.invalid' }) as any).hasConfiguredTransport('t1'),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
await new MailService(makeFakeSettings({}) as any, makeFakeConfig({ TESSERA_SMTP_HOST: 'legacy.example.invalid' }) as any).hasConfiguredTransport('t1'),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
await new MailService(makeFakeSettings({}) as any, makeFakeConfig({}) as any).hasConfiguredTransport('t1'),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user