32 Commits

Author SHA1 Message Date
schalli 3562b154f0 fix(web): Oberflaechentexte ohne Mandanten-Begriff, Organisationsauswahl im Marktplatz nur bei mehreren
Tessera CI/CD / Lint & Type Check (push) Successful in 57s
Tessera CI/CD / Tests (push) Successful in 1m51s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m23s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 02:58:50 +02:00
schalli 2053dbac28 docs(quick-261003-387): Kategorien bearbeitbar
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 02:53:19 +02:00
schalli f2c0a896e8 feat(quick-261003-387): Verwaltungsseite Kategorien, Marktplatz-Reihenfolge, Formular, Texte, CHANGELOG und Handbuch
- Administrator -> Module -> Kategorien: anlegen, umbenennen, sortieren, Eintraege zuordnen und sortieren, loeschen mit Zielauswahl
- Marktplatz-Filter folgen der Kategorienreihenfolge, Kategorieseite und Modulliste zeigen gespeicherte Namen
- Formular fuer eigene Module bietet alle Kategorien der Organisation an
- Texte de/en, CHANGELOG, Handbuch Administration und Anwender

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 02:48:04 +02:00
schalli af1878d5ee feat(quick-261003-387): Kategorien-API - Anlegen, Sortieren, Zuordnen, Loeschen mit Verschieben, Ueberlagerung in allen Modullisten
- Verwaltungs-Endpunkte nur fuer Administratoren, statische Routen vor :key
- Loeschen verschiebt alle Eintraege inkl. persoenlicher eigener Module in einer Transaktion, ohne Ziel 409
- /modules, /modules/catalog und /module-grants/matrix liefern wirksame Kategorie und Reihenfolge
- eigene Module pruefen die Kategorie gegen die Organisation (400 bei unbekannter)
- Zugriffsinventar nachgezogen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 02:39:36 +02:00
schalli 8ec116c75a feat(quick-261003-387): Modulkategorien je Organisation - Tabellen, Grundbestand, Lese-Endpunkt bis in die Seitenleiste
- Tabellen ModuleCategory und ModuleCategoryPlacement mit Zeilenschutz, Spalte CustomModule.sortOrder
- Dienst legt den Grundbestand beim ersten Lesen an, legt die wirksame Kategorie ueber Modullisten
- GET /module-categories, PATCH :key (nur Administratoren), /modules/active liefert wirksame Kategorie
- Seitenleiste ordnet Gruppen und Eintraege nach Kategorienspeicher, zeigt umbenannte Namen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 02:33:59 +02:00
schalli 53a49a109f perf(desktop): Dashboard-Aufbau in der Linux-App ohne Einblend-Animation, Melder gegen Fokus-Schuebe gedrosselt
Tessera CI/CD / Lint & Type Check (push) Successful in 49s
Tessera CI/CD / Tests (push) Successful in 1m45s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 4m35s
Gemessen in der echten Linux-App (WebKitGTK): Haenger beim Oeffnen des
Dashboards von 550-1100 ms auf 250-350 ms. Im alpha-Log kamen beim Oeffnen
bis zu 50 Abfragen je Sekunde von Erinnerungs- und Nextcloud-Melder; Fokus-/
Sichtbarkeitswechsel loesen jetzt hoechstens alle 10 s eine Abfrage aus.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 01:19:35 +02:00
schalli 19340fcebd fix(nextcloud-status): grosse Logos beim Hochladen verkleinern, Doppelklick auf Jetzt pruefen sperren
Tessera CI/CD / Lint & Type Check (push) Successful in 56s
Tessera CI/CD / Tests (push) Successful in 2m5s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 21s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m30s
Ein 2346-px-Logo liess die Linux-App beim Neuzeichnen (drehende Lade-Symbole)
kurz einfrieren; Logos werden jetzt im Browser auf max. 256 px verkleinert.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 23:42:10 +02:00
schalli 0afcf237e8 feat(api): Protokollzeile je Anfrage und je Nextcloud-Pruefung im Docker-Log
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 23:42:10 +02:00
schalli fffb7ffbec fix(desktop): Dateidialog folgt dem Dunkelmodus (GTK prefer-dark via set_theme)
Tessera CI/CD / Lint & Type Check (push) Successful in 1m0s
Tessera CI/CD / Tests (push) Successful in 1m49s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 15m0s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m33s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 23:31:26 +02:00
schalli 9829228726 fix(nextcloud-status): breite Logos groesser und eckig
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 23:29:31 +02:00
schalli bbbafd11bc style(dashboard): duenne, dezente Scrollleiste in Kacheln
Tessera CI/CD / Lint & Type Check (push) Successful in 59s
Tessera CI/CD / Tests (push) Successful in 1m51s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 21s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m16s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 23:10:00 +02:00
schalli 14674fced3 docs(quick-261002-kxc): Nextcloud-Status Benachrichtigung
Tessera CI/CD / Lint & Type Check (push) Successful in 58s
Tessera CI/CD / Tests (push) Successful in 2m19s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 23s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m44s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 15:37:43 +02:00
schalli 6c4bff6f6c feat(nextcloud-status): Meldung in Tessera, Anleitung und Changelog
- Endpunkt für letzte Übergänge der abonnierten Clouds, globaler Melder im Portalrahmen (Desktop: Windows-Benachrichtigung)
- Anleitung für Anwender und Administration, Changelog

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 15:30:45 +02:00
schalli 11a70c9ee9 feat(nextcloud-status): erneute Prüfung nach Ausfall und Hinweis auf der Kachel
- Wiederholungsauftrag jede Minute für Clouds mit einem Fehlschlag älter als fünf Minuten
- Neue Adresse setzt den Prüfstand zurück, der gemeldete Zustand bleibt
- Kachel: Hinweis Prüfung fehlgeschlagen, Fehlercodes als lesbarer Text mit Tooltip

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 15:26:23 +02:00
schalli faed0d760f feat(nextcloud-status): Benachrichtigung abonnieren und Mail bei Störung
- Glocke je Kachel (persönlich, Benutzen-Ebene), Tabelle NextcloudAlertSubscription mit RLS
- Zwei-Fehlschläge-Regel und gemeldeter Zustand auf der Zeile, Anspruch vor dem Mailversand
- Mail (nur Deutsch) über MailService, bis zu drei Versuche, Zugriff beim Senden erneut geprüft
- Fehlercodes in der Mail als lesbarer Hinweis

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-02 15:23:34 +02:00
schalli 6829c44464 docs(quick-261002-k67): Modul Nextcloud-Status
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 15:04:01 +02:00
schalli 87a7b7cbcd feat(nextcloud-status): Dashboard-Kachel, Anleitung und Changelog
- Kachel mit Zählern und roten/gelben Clouds, nur mit Modulzugriff sichtbar (geteilte Widget-Typen, Modulzuordnung, Katalog)
- Anleitung für Anwender und Administratoren, Eintrag im Changelog

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 15:00:31 +02:00
schalli 6698ef1a92 feat(nextcloud-status): Clouds verwalten, Logos, stündliche Prüfung und Sortierung
- Ändern, Entfernen, Logo-Upload und -Abruf, Sammelprüfung nur mit Verwalten
- Stündlicher Auftrag beim Start registriert, Prüfung je Cloud an ihren Mandanten gebunden
- Sortierung nach Kundenname, Status, Version und Support-Ende, je Benutzer gemerkt
- Formular zum Anlegen und Bearbeiten, Zugriffsinventar angepasst

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:54:58 +02:00
schalli 5ef7b0c6cc feat(nextcloud-status): Modul mit Statusabruf, Versionsbewertung und Kachelansicht
- NextcloudInstance mit Zeilenschutz, Migration 20261002150000
- Bewertung (Ampel) als reine Funktion, status.php-Abruf mit Grenzen, endoflife.date-Zwischenspeicher
- API: Liste mit Bewertung, Anlegen und Einzelprüfung nur mit Verwalten
- Modulseite mit Kacheln, Registrierung in Seitenleiste, Marktplatz und Symbolen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 14:49:02 +02:00
schalli 7188733f70 docs(quick-261002-icv): Freigabestufe Verwalten
Tessera CI/CD / Lint & Type Check (push) Successful in 56s
Tessera CI/CD / Tests (push) Successful in 2m3s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 21s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m28s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:42:22 +02:00
schalli 0e6e55ef65 fix(quick-261002-icv): Freigaben-Matrix zeigt Bereichsnamen statt Kennungen
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:40:04 +02:00
schalli eaf2c4574a feat(module-grants): Stufe Verwalten in Freigaben-Matrix und Benutzerdetails, Doku und Changelog
- Matrix und Benutzerdetails liefern und zeigen die Stufe, Auswahlfeld Benutzen/Verwalten
- Gruppen mit Verwalten sind in den Benutzerdetails markiert
- Administrationshandbuch, Anwenderanleitung und Changelog beschreiben die Stufen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:35:00 +02:00
schalli c2ebc8daa0 feat(module-grants): Proxmox, Handelsware und DKV mit Freigabestufe Verwalten
- Proxmox-Schreibwege und Handelsware-Einstellungen auf @ModuleManage umgestellt
- DKV-Fleet: ganze Klasse Verwalten-Stufe, Benutzen allein bleibt ohne Zugriff
- Metadaten-Test belegt umgestellte und bewusst Administratoren vorbehaltene Handler
- Webseiten (Proxmox, Handelsware, Widget) folgen canManage, DKV-Zugriffsseite erklärt die Stufe

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:31:08 +02:00
schalli a222711ad9 feat(module-grants): Freigabestufe Verwalten – Datenbank, Zugriffsprüfung und Kantinen-Einstellungen
- Migration: ModuleGrant.level (USE/MANAGE), Bestand bleibt USE
- ModuleAccessService.getModuleAccessLevels als einzige Auflösung, MANAGE gewinnt
- @ModuleManage(slug) am ModuleGuard, GET /modules/active liefert canManage
- Kantinenabrechnung: Einstellungen für Benutzer mit Verwalten, Web-Hook useCanManageModule

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:28:24 +02:00
schalli b94d267584 docs(quick-261002-fm5): Finanzbuchhaltung-Module Kantinenabrechnung und Handelsware
Tessera CI/CD / Lint & Type Check (push) Successful in 53s
Tessera CI/CD / Tests (push) Successful in 1m45s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m19s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:52:33 +02:00
schalli 46d19da54a fix(quick-261002-fm5): Einstellungstexte ohne Mandanten-Hinweis
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:52:21 +02:00
schalli 14933753e7 docs: Kantinenabrechnung und Handelsware in Changelog und Anwenderanleitung
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:47:09 +02:00
schalli 42b89f110b feat(handelsware-datev): Modulseite mit Import, Konten und Einstellungen
- Reiter Import: Vorschau mit neu-Markierung, Buchungsdatum (TTMM) aus dem Dateinamen, Download speichert neue Konten
- Reiter Konten: anlegen, bearbeiten, loeschen mit Rueckfrage, CSV-Import (ersetzt alles, nach Rueckfrage) und -Export
- Reiter Einstellungen nur fuer Administratoren; Texte de/en

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:47:09 +02:00
schalli 44c1d4351f feat(handelsware-datev): API, Kontenliste mit Zeilenschutz und DATEV-Export
- Prisma-Modelle HandelswareDatevConfig und HandelswareKonto mit Zeilenschutz (Migration 20261002130000)
- XLSX lesen (B1 Kopf, A/B ab Zeile 2, Zahl oder deutscher Text), Konten zuordnen, TXT erzeugen
- neue Konten werden nur beim Export in einer mandantengebundenen Transaktion gespeichert (409 bei geaenderter Liste)
- Konten-CSV Import (alles ersetzen) und Export mit Schutz vor Formeleinschleusung
- Einstellungen nur fuer Administratoren, statische Routen vor accounts/:id

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:38:22 +02:00
schalli 1f85277a3b feat(kantine-datev): Kantinenabrechnung als Modul in neuer Gruppe Finanzbuchhaltung
- neue Seitenleisten-Kategorie accounting (Finanzbuchhaltung / Financial accounting)
- CSV (UTF-8, UTF-8 mit BOM, Windows-1252) pruefen, Vorschau mit Zeilenfehlern, DATEV-Lohn-ASCII-Export
- Beraternummer, Mandantennummer, Lohnart je Mandant als Admin-Einstellung (KantineDatevConfig mit Zeilenschutz), anfangs leer
- hochgeladene Daten werden nicht gespeichert; Download per Blob (auch im Desktop-Client)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:32:55 +02:00
schalli 0edd6e9b1a docs: Sitzung fortgesetzt, HANDOFF nach Freigabe 1.9.2 entfernt
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:25:41 +02:00
schalli f7d4be0c7c wip: pausiert nach Freigabe 1.9.2
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 10:56:14 +02:00
216 changed files with 21901 additions and 380 deletions
+20 -9
View File
@@ -4,7 +4,7 @@ phase: quick-auftraege-1.9.x (keine GSD-Phase)
task: 0
total_tasks: 0
status: paused
last_updated: 2026-09-30T18:00:00.000Z
last_updated: 2026-10-02T08:00:00.000Z
---
# BLOCKING CONSTRAINTS — Read Before Anything Else
@@ -12,23 +12,34 @@ last_updated: 2026-09-30T18:00:00.000Z
- [ ] 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: Nie Wichtiges (Logo, Text) nur in Mailbilder packen – OWA zeigt eingebettete Bilder nicht.
- [ ] CONSTRAINT: Echte Kundenzertifikate/Schlüssel nie ins Repo, lokale Kopien nach dem Test löschen.
<current_state>
main = live = v1.9.0 (a257bc3), CI + Release grün. alpha zuletzt 714f731 (Inhalt identisch). Arbeitsbaum sauber.
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>
- 30.09.: Windows-Test bestanden, Review seit 26.09. + Fixes, v1.8.0.
- Dashboard-Skalierung (nie scrollen), eigene Module Keep-Alive, Favoriten enger.
- Sicherheit: Rolle/Aktiv-Status je Anfrage aus DB; /login-Weiterleitung.
- Willkommensmail + eigene Vorlage (Platzhalter, Vorschau, Testmail, Anmeldehinweise), Spalte Letzte Anmeldung; v1.9.0.
- 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.0 ziehen (df -h / vorher), Willkommensmail in OWA prüfen.
- 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>
Nachfragen, ob live gezogen und OWA ok; sonst neuen Auftrag abwarten.
Neuen Chat abwarten: User bringt ein neues Modul. Fragen, ob live auf 1.9.2 gezogen ist.
</next_action>
-33
View File
@@ -1,33 +0,0 @@
{
"version": "1.0",
"timestamp": "2026-09-30T18:00:00.000Z",
"phase": null,
"phase_name": "Quick-Auftraege nach Freigabe 1.9.0 (keine GSD-Phase)",
"phase_dir": null,
"plan": null,
"task": 0,
"total_tasks": 0,
"status": "paused",
"completed_tasks": [
{"id": 1, "name": "30.09.: Windows-Test (Tray-Update + Erinnerungs-Toast) bestanden; Review aller Aenderungen seit 26.09. + Fixes; Freigabe 1.8.0 (af78157)", "status": "done"},
{"id": 2, "name": "Dashboard 1:1-Skalierung (__canvas, passt Inhalt ein = nie scrollen), eigene Module Keep-Alive (max 5), Favoriten-Kachelansicht enger + lange Namen klein/zweizeilig", "status": "done"},
{"id": 3, "name": "Sicherheit: JwtStrategy liest Rolle/isActive/mustChangePassword je Anfrage aus DB; /login leitet Angemeldete aufs Dashboard", "status": "done"},
{"id": 4, "name": "Willkommensmail (Briefsymbol Benutzerliste, jederzeit an jeden) + Spalte Letzte Anmeldung + eigene Vorlage je Mandant (Admin -> Willkommensmail, Platzhalter, Vorschau, Testmail, Anmeldehinweise editierbar); Freigabe 1.9.0 (a257bc3)", "status": "done"}
],
"remaining_tasks": [],
"blockers": [],
"async_jobs": [],
"human_actions_pending": [
{"action": "Live-Server auf v1.9.0 ziehen (vorher df -h /, ggf. docker image prune -f, nie -a)", "context": "CI 11 Laeufe gruen, Release Tessera 1.9.0 mit Setup.exe + AppImage", "blocking": false},
{"action": "Willkommensmail in OWA (owa.ctl.de) pruefen: dunkler Kopf ohne weisse Luecke", "context": "OWA zeigt CID-Bilder nicht, Outlook-Programm schon", "blocking": false}
],
"decisions": [
{"decision": "Dashboard: alles mitskalieren inkl. Schrift, nie scrollen; Leinwand = Flaeche beim ersten Oeffnen je Reiter", "rationale": "AskUserQuestion 30.09.", "phase": "quick"},
{"decision": "Keine naechtliche Docker-Aufraeumung auf alpha", "rationale": "User 30.09.: passt so", "phase": "quick"},
{"decision": "Willkommensmail jederzeit an jeden Benutzer; Link Passwort festlegen 7 Tage", "rationale": "User 30.09.", "phase": "quick"},
{"decision": "PMG ohne API-Token (Proxmox kennt keine), eigener Auditor-Benutzer; Anleitung im Admin-Handbuch", "rationale": "Proxmox Bugzilla 5849 offen", "phase": "quick"}
],
"uncommitted_files": [],
"next_action": "Nichts offen von Claudes Seite. Beim Start: fragen, ob live auf 1.9.0 gezogen ist und ob die Willkommensmail in OWA passt; sonst neuen Auftrag abwarten.",
"context_notes": "main = live = v1.9.0 (a257bc3). alpha lief zuletzt auf 714f731 (= Inhalt 1.9.0). Lokaler Stack aus 714f731 gebaut. Test-Postfach MailHog nur bei Bedarf: docker run -d --rm --name mailhog --network tessera-ctl_backend-net --network-alias mailhog -p 127.0.0.1:8025:8025 mailhog/mailhog. Diagnose auf alpha ohne Passwort: JWT im api-Container mit crypto + process.env.JWT_SECRET signieren (nur lesend, kurzlebig)."
}
+10 -5
View File
@@ -6,7 +6,7 @@ current_phase_name: desktop-client-fertigstellen
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."
last_updated: "2026-09-23T15:30:00.000Z"
last_activity: 2026-10-01
last_activity: 2026-10-02
last_activity_desc: Quick 260928-ujj — Design Mosaik uebernommen, Hintergrund pro Benutzer in der DB; Freigabe 1.5.0
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
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)
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
Last activity: 2026-09-30 - v1.9.0 freigegeben (Willkommensmail + Vorlage, Rollen je Anfrage aus DB, Dashboard-Skalierung, Keep-Alive eigene Module); pausiert mit HANDOFF
Last activity: 2026-10-03 - Quick 261003-387 Kategorien bearbeitbar (lokal nachgewiesen, nicht gepusht)
Progress: [██████████] 99%
@@ -487,6 +487,11 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 261001-g68 | Erinnerung: Cursor sprang beim Schreiben der Beschreibung in den Titel (Fokus-Effekt hing an inline onClose, Kachel zeichnet alle 10 s neu) – Fokus nur beim Oeffnen | 2026-10-01 | siehe git log | [261001-g68](./quick/261001-g68-erinnerung-cursor-springt-aus-beschreibu/) |
| 261001-hbi | Favoriten: Logo fuer per JavaScript gesetzte Symbole (hosteurope.de) – Rueckfall auf DuckDuckGo-Symboldienst beim Ausliefern, nur oeffentliche Seiten | 2026-10-01 | siehe git log | [261001-hbi](./quick/261001-hbi-favoriten-logo-fuer-per-javascript-geset/) |
| 261001-l4q | Zertifikat-Manager: Reiter Übersicht (Paket/ZIP hochladen, Teile erkennen/zuordnen, jedes Teil in jedem Format) + Desktop speichert blob-Downloads selbst | 2026-10-01 | siehe git log | [261001-l4q](./quick/261001-l4q-zertifikatsmodul-paket-hochladen-uebersi/) |
| 261002-fm5 | Finanzbuchhaltung: Module Kantinenabrechnung und Handelsware (DATEV-Export), im Browser nachgewiesen | 2026-10-02 | 1f85277..HEAD | [261002-fm5-finanzbuchhaltung-module-kantinenabrechn](.planning/quick/261002-fm5-finanzbuchhaltung-module-kantinenabrechn/) |
| 261002-icv | Modul-Freigabe mit Stufe Verwalten (Modul-Einstellungen ohne Admin; Kantine, Handelsware, Proxmox, DKV), im Browser nachgewiesen | 2026-10-02 | a222711..HEAD | [261002-icv-modul-freigabe-mit-stufe-verwalten-modul](.planning/quick/261002-icv-modul-freigabe-mit-stufe-verwalten-modul/) |
| 261002-k67 | Modul Nextcloud-Status mit Ampel-Kacheln und Dashboard-Uebersicht | 2026-10-02 | 5ef7b0c..87a7b7c | [261002-k67-modul-nextcloud-status-mit-ampel-kacheln](.planning/quick/261002-k67-modul-nextcloud-status-mit-ampel-kacheln/) |
| 261002-kxc | Nextcloud-Status: Benachrichtigung bei Rot je Benutzer (Mail + Desktop-Hinweis), Klartext-Fehler | 2026-10-02 | faed0d7..6c4bff6 | [261002-kxc-nextcloud-status-benachrichtigung-bei-ro](.planning/quick/261002-kxc-nextcloud-status-benachrichtigung-bei-ro/) |
| 261003-387 | Kategorien durch Admins bearbeitbar (anlegen, umbenennen, sortieren, loeschen mit Verschieben, Module zuordnen) | 2026-10-03 | 8ec116c..f2c0a89 | [261003-387-kategorien-durch-admins-bearbeitbar-umbe](.planning/quick/261003-387-kategorien-durch-admins-bearbeitbar-umbe/) |
## Deferred Items
@@ -528,8 +533,8 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
## Session Continuity
Last session: 2026-09-22T13:40: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.
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.
Last session: 2026-10-02T09:10:00Z
Resumed: 2026-10-02 ueber /gsd-resume-work (HANDOFF nach Freigabe 1.9.2 eingelesen und entfernt).
Stopped at: Session resumed — wartet auf Rueckmeldung live/alpha auf 1.9.2 und den Auftrag fuer das neue Modul.
Resume file: None
Last activity: 2026-09-29 - Quick 260929-if2 Erinnerungen-Widget (lokal, nicht gepusht); v1.7.0 auf alpha+live
@@ -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>
@@ -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.
@@ -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>
@@ -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.
@@ -0,0 +1,345 @@
---
phase: quick-261002-k67
plan: 01
type: execute
wave: 1
depends_on: []
quick_id: 261002-k67
description: "Neues Modul Nextcloud-Status: Clouds als Ampel-Kacheln mit Versionsbewertung, stündlicher Prüfung und Dashboard-Kachel"
date: 2026-10-02
files_modified:
# Task 1 — tracer: DB -> status.php check -> eol reference -> rating -> list API -> module page tiles
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql
- apps/api/src/nextcloud-status/nextcloud-rating.ts
- apps/api/src/nextcloud-status/nextcloud-rating.spec.ts
- apps/api/src/nextcloud-status/nextcloud-status-fetch.ts
- apps/api/src/nextcloud-status/nextcloud-status-fetch.spec.ts
- apps/api/src/nextcloud-status/nextcloud-release.service.ts
- apps/api/src/nextcloud-status/nextcloud-release.service.spec.ts
- apps/api/src/nextcloud-status/nextcloud-status.service.ts
- apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts
- apps/api/src/nextcloud-status/nextcloud-status.controller.ts
- apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts
- apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts
- apps/api/src/nextcloud-status/nextcloud-status.seed.ts
- apps/api/src/nextcloud-status/nextcloud-status.module.ts
- apps/api/src/app.module.ts
- docs/mandantentrennung-zugriffsklassifikation.md
- apps/web/src/lib/nextcloud-status-api.ts
- apps/web/src/components/nextcloud-status/rating-display.ts
- apps/web/src/app/(portal)/modules/nextcloud-status/layout.tsx
- apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx
- apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx
- apps/web/src/app/(portal)/modules/module-layouts.test.tsx
- apps/web/src/lib/module-loader.ts
- apps/web/src/lib/module-identity.ts
- apps/web/src/lib/stores/nav-store.ts
- apps/web/src/components/modules/module-tile.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/messages/umlaut-dictionary.ts
# Task 2 — manager write paths, logo, check-all, hourly scheduler, sorting
- apps/api/src/nextcloud-status/nextcloud-logo-rules.ts
- apps/api/src/nextcloud-status/nextcloud-logo-rules.spec.ts
- apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts
- apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.ts
- apps/api/src/module-registry/module-manage-handlers.spec.ts
- apps/api/src/prisma/rls-access-inventory.spec.ts
- apps/web/src/components/nextcloud-status/sort-clouds.ts
- apps/web/src/components/nextcloud-status/sort-clouds.test.ts
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.test.tsx
# Task 3 — dashboard widget, docs, changelog, full suites, rebuild
- packages/shared/src/index.ts
- apps/api/src/dashboard/widget-module-map.ts
- apps/api/src/dashboard/widget-module-map.spec.ts
- apps/web/src/components/dashboard/widget-registry.tsx
- apps/web/src/components/dashboard/widget-registry.test.tsx
- apps/web/src/components/dashboard/widget-catalog-modal.test.tsx
- apps/web/src/components/dashboard/widgets/widget-icon.tsx
- apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
- apps/web/src/components/dashboard/widgets/nextcloud-status-widget.tsx
- apps/web/src/components/dashboard/widgets/nextcloud-status-widget.test.tsx
- apps/web/src/app/(portal)/page.tsx
- apps/web/src/app/(portal)/page.test.tsx
- CHANGELOG.md
- docs/anleitung-anwender.md
- docs/anleitung-administration.md
autonomous: true
requirements: [QUICK-261002-k67]
estimate:
tokens: 170000
raw_tokens: 170000
tasks: 3
confidence: low
must_haves:
truths:
- "A user with grant level Benutzen (USE) on nextcloud-status sees one tile per cloud: logo (or initials), Kundenname, URL link opening in a new tab, installed version, traffic-light color with a short plain-language reason, and the time of the last check; the page also names the newest overall Nextcloud version"
- "The traffic light follows the locked rules: GREEN = latest patch of its cycle and EOL more than 90 days away; YELLOW = patch update available in its cycle or EOL within 90 days; RED = EOL passed (or major older than every listed cycle), unreachable/invalid answer, maintenance mode, or needsDbUpgrade; without reference data the tile is grey with 'Bewertung nicht möglich' (red status conditions still red)"
- "Only administrators and users with Verwalten (MANAGE) can add/edit/delete clouds, upload/remove logos, and trigger 'Jetzt prüfen' or a per-tile check — API returns 403 for USE-level users and the controls are hidden for them"
- "Every cloud is checked automatically once per hour (also on a fresh database after the first start), each check fetches only <url>/status.php with a 10 s timeout, max 3 redirects and a size cap, and only parsed fields reach the browser"
- "Version reference data from endoflife.date is cached for 12 h; an outage keeps the last good data and never breaks the page"
- "The user can sort tiles by Kundenname, Status (red first), Version, or Support-Ende, and the choice survives a reload for the same user"
- "Users with module access can place a 'Nextcloud-Status' dashboard tile showing green/yellow/red counters and the red/yellow clouds with reason; clicking opens the module; users without access never see the tile in the catalog or on the dashboard"
artifacts:
- path: "apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql"
provides: "NextcloudInstance table with tenant_isolation_policy and system_read_policy"
contains: "NextcloudInstance"
- path: "apps/api/src/nextcloud-status/nextcloud-rating.ts"
provides: "pure rating function with injected date"
exports: ["rateNextcloud"]
- path: "apps/api/src/nextcloud-status/nextcloud-status-fetch.ts"
provides: "normalizeCloudUrl, parseNextcloudStatus, fetchNextcloudStatus (injectable fetch)"
exports: ["normalizeCloudUrl", "parseNextcloudStatus", "fetchNextcloudStatus"]
- path: "apps/api/src/nextcloud-status/nextcloud-release.service.ts"
provides: "endoflife.date cache (12 h, keep last good, backoff) + parseEndOfLife"
- path: "apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts"
provides: "hourly check job registered in onApplicationBootstrap"
- path: "apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx"
provides: "tile grid, sorting, manager controls"
- path: "apps/web/src/components/dashboard/widgets/nextcloud-status-widget.tsx"
provides: "dashboard overview tile"
key_links:
- from: "apps/api/src/nextcloud-status/nextcloud-status.service.ts"
to: "rateNextcloud + NextcloudReleaseService.getReference"
via: "listForTenant maps every row to a rating at read time"
pattern: "rateNextcloud\\("
- from: "apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts"
to: "NextcloudStatusService.loadAllInstancesForScheduler (forSystem) -> checkInstance (forTenant)"
via: "onApplicationBootstrap registers the cron job nextcloud-status-poll"
pattern: "onApplicationBootstrap"
- from: "packages/shared/src/index.ts WIDGET_MODULE_SLUGS"
to: "DashboardService fail-closed filter + web catalog visibleWidgetTypes"
via: "'nextcloud-status': 'nextcloud-status'"
pattern: "'nextcloud-status': 'nextcloud-status'"
- from: "apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx"
to: "useCanManageModule('nextcloud-status')"
via: "manager-only controls"
pattern: "useCanManageModule\\('nextcloud-status'\\)"
---
<objective>
New Tessera module "Nextcloud-Status" (slug `nextcloud-status`, category `infrastructure`, next to Proxmox). Managers register customer Nextcloud instances (URL + Kundenname + optional logo); Tessera checks each instance's public `status.php` every hour and on demand, compares the installed version with the endoflife.date reference data and shows a traffic-light tile per cloud plus a dashboard overview tile.
Locked decisions from the request (cited below as L-xx):
- L-01 Slug `nextcloud-status`, category `infrastructure`; follow the Proxmox module end to end (seed, NestJS module, ModuleGuard, Prisma model with RLS + hand-written migration, web route with ModuleAccessGate, sidebar/marketplace/icon registrations, module-layouts test, dashboard widget registration).
- L-02 Managers add a cloud with URL and Kundenname; optional logo either uploaded (PostgreSQL bytea, magic-byte type check, 1 MiB limit, served via an authenticated route) or an https URL the browser loads directly — the API never fetches the logo URL.
- L-03 Status: server-side GET `<url>/status.php`, ~10 s timeout, no credentials, http and https, at most a few redirects, capped response size; last result stored on the record (versionstring, maintenance, reachable, error text, checkedAt). URL policy like Proxmox (manager-entered, internal hosts allowed) with the SSRF consideration documented in a code comment; only parsed JSON fields, never raw bodies, reach the client.
- L-04 Reference data from https://endoflife.date/api/nextcloud.json, fetched server-side, cached ~12 h, outage-tolerant (keep last good); never fetched → tiles show the version without rating (grey "Bewertung nicht möglich").
- L-05 Traffic light (locked): GREEN = latest patch of its major cycle AND cycle still supported; YELLOW = update available within its cycle OR cycle EOL within the next 90 days; RED = cycle EOL passed (or major not in the list and older than all listed), unreachable, maintenance on, or needsDbUpgrade. Also show the newest overall Nextcloud version. Rating is a pure function with thorough Vitest tests and an injected date.
- L-06 Tile: logo, Kundenname, URL (new tab), installed version, status color + short German reason ("Aktuell", "Update auf 34.0.4 verfügbar", "Support endet am 30.06.2027", "Support abgelaufen seit …", "Nicht erreichbar", "Wartungsmodus"), last check time.
- L-07 Sorting selectable: Kundenname (A–Z), Status (rot zuerst), Version, Support-Ende; remembered per user in localStorage (try/catch).
- L-08 Polling hourly via scheduler (Proxmox/Tender pattern incl. the onApplicationBootstrap lesson), plus "Jetzt prüfen" (whole list) and per-tile refresh; background checks per tenant in system context like the other background jobs.
- L-09 Rights (locked): USE sees tiles; MANAGE (`@ModuleManage`) or admin adds/edits/deletes clouds, uploads logos, triggers checks. Web uses `useCanManageModule`.
- L-10 Dashboard widget (locked): counters (grün/gelb/rot) and the red/yellow clouds (name + reason); click opens the module; registered and sized like the Proxmox widget.
- L-11 UI texts German (formal "Sie") and English; UI texts never name the tenant concept; dark/light via existing tokens.
- L-12 Tests: api Vitest for rating, status.php parser (valid, maintenance, garbage, timeout), eol cache, service CRUD, controller guard metadata (static routes before `:id`); web tests for page (tiles, sorting, manager-only controls) and widget; full api + web suites, tsc, biome on touched files.
- L-13 CHANGELOG (Unveröffentlicht, user-facing German) + user/admin docs in `docs/`.
- L-14 Local migration via container IP, rebuild `docker compose up -d --build api web`; do NOT push; browser check is the orchestrator's job.
Claude's discretion (decided here, apply as written):
- D-A Logo bytes live in bytea columns on the instance row as L-02 says (note: dashboard images moved to the file area in 260922-hk4; for a handful of logos ≤ 1 MiB the DB is fine and needs no file cleanup). Every list/scheduler query uses an explicit `select` without the bytes. Accepted types PNG/JPEG/GIF/WebP via the existing `detectImageMime` — no SVG (script risk). Upload and logo URL are mutually exclusive: uploading clears `logoUrl`; saving a non-empty `logoUrl` clears the upload.
- D-B The rating is computed in the API at read time (`GET instances`), so page and widget show the same result; the API returns reason codes plus parameters, the web translates them (German + English).
- D-C One global hourly cron job `nextcloud-status-poll` (`0 * * * *`), registered unconditionally in `onApplicationBootstrap` without reading the database at registration time — a fresh database cannot end up without the job (Tender lesson). Each tick reads `(id, tenantId)` of all instances in system context (the single `forSystem` call), then checks each instance tenant-bound with concurrency 4 and an overlap guard.
- D-D Reference cache in memory (no table): TTL 12 h; stale data is returned immediately and refreshed in the background; only an empty cache is awaited; after a failure no new attempt for 15 min; concurrent refreshes share one request; bootstrap warms it up without blocking.
- D-E A major newer than every listed cycle (fresh release not yet on endoflife.date) rates GREEN "Aktuell"; a major inside the listed range but missing from it rates grey.
- D-F An answer that is not a valid Nextcloud status JSON (or `installed` not true) is RED with its own reason "Keine gültige Nextcloud-Antwort" (variant of "unreachable").
- D-G Status sort order red, yellow, grey, green (then name); version sort oldest first, unknown last; Support-Ende earliest first, unknown last; ties by name.
- D-H TLS certificates of the clouds are verified (no opt-out); a certificate error shows as "Nicht erreichbar" with the error code as detail.
Output: migration + model, API module (pure functions, release cache, service, controller, scheduler, seed), module page with tiles/sorting/manager form, dashboard widget, tests, docs, changelog, rebuilt local stack. Three atomic commits on main, NOT pushed.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
Discovered facts the executor can rely on (verified during planning):
- Proxmox touchpoints (grep `proxmox` across apps/ and packages/) are the template: `apps/api/src/proxmox/{proxmox.module.ts, proxmox.seed.ts, proxmox.controller.ts, proxmox.service.ts, proxmox-scheduler.service.ts}`, `apps/api/src/app.module.ts`, migration `20260923140000_proxmox_server`, web `apps/web/src/app/(portal)/modules/proxmox/{layout.tsx,page.tsx}`, `apps/web/src/lib/{proxmox-api.ts,module-loader.ts,module-identity.ts,stores/nav-store.ts}`, `apps/web/src/components/modules/module-tile.tsx` (ICONS map keyed by `ModuleIconId`), widget files under `apps/web/src/components/dashboard/`.
- `PrismaService` is injected without importing a Prisma module (global); `forTenant`/`forSystem` come from `apps/api/src/prisma/prisma-tenant.extension.ts`. `ModuleRegistryModule` must be imported for `ModuleRegistryService` (seed) and `ModuleGuard`. `ScheduleModule` is already global in app.module.ts.
- `@UseModule(slug)` (class) and `@ModuleManage(slug)` (handler) live in `apps/api/src/module-registry/module.guard.ts` (quick 261002-icv). Never put a role decorator on a manage handler — the global RolesGuard would block managers. `GET /modules/active` already carries `canManage`; `apps/web/src/lib/use-module-capability.ts` exports `useCanManageModule`.
- Cron: `ProxmoxSchedulerService` resolves `CronJob` via `require('cron').CronJob` (pnpm strict isolation) and registers with `SchedulerRegistry.addCronJob` — reuse that workaround verbatim.
- HTTP: `apps/api/src/favorites/icon-discovery.service.ts` uses `fetch as undiciFetch` from `undici` (dependency 7.28.0) with `redirect: 'manual'`, AbortController timeouts and a capped body reader (`readTextCapped`) — same approach here, but WITHOUT the private-IP filter (L-03: internal hosts allowed).
- Magic bytes: `detectImageMime(buffer)` in `apps/api/src/dashboard/dashboard-image-rules.ts` (PNG/JPEG/GIF/WebP). Serving pattern for uploaded images: `apps/api/src/favorites/favorites.controller.ts` `getIcon` (Content-Type from detected mime, `Cache-Control: private, max-age=86400`, `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; sandbox`) and `FileInterceptor(field, { limits: { fileSize, files: 1 } })`. The web loads authenticated images through the same-origin proxy `/api-proxy/<api path>` with a `?v=<version>` cache buster (favorites-widget.tsx).
- RLS gates: `rls-coverage.spec.ts` needs the policies in the migration; `rls-access-inventory.spec.ts` compares every (file, model) Prisma access against the Fundstellentabelle in `docs/mandantentrennung-zugriffsklassifikation.md` (plus Bereichszeile, Summenzeile, Paarzählung — follow the quick-261002-fm5 and proxmox rows) and allows `forSystem(` only at the call sites listed in `FORSYSTEM_ALLOWED_CALL_SITES`. A file with tenant-bound AND one system read on the same model has Stand `system-gebunden` (precedent `proxmox.service.ts`/`proxmoxServer`). Never use `include:` or relation `select:` in this module.
- Status colors: tokens `bg-status-ok|warn|down|idle` and `text-status-*-fg`, pill form `bg-status-ok/12 text-status-ok-fg` (see `apps/web/src/components/proxmox/status-styles.ts`); class strings must be literal (Tailwind scanning).
- Module routes in the web: `/modules/nextcloud-status` (own layout with `ModuleAccessGate`) AND the sidebar route `/modules/infrastructure/nextcloud-status` (generic `[category]/[moduleSlug]` page → `module-loader.ts`).
- i18n: widget catalog names live under `widgets.<key>.name/description` in de.json/en.json; module texts get a new top-level namespace `nextcloudStatus`. `apps/web/src/messages/umlaut-guard.spec.ts` rejects ae/oe/ue/ss tokens in de.json that are not in `UMLAUT_ALLOWLIST` (e.g. "aktuell", "Neueste" may need allowlisting — run the test).
- Widget tests enumerate all widget types (currently eleven): `widget-registry.test.tsx`, `widget-catalog-modal.test.tsx`, `apps/api/src/dashboard/widget-module-map.spec.ts`; `(portal)/page.test.tsx` mocks each widget module.
- endoflife.date sample (2026-10-02): `[{"cycle":"35","releaseDate":"2026-09-16","eol":"2027-09-30","latest":"35.0.1",...},{"cycle":"34","eol":"2027-06-30","latest":"34.0.4"},{"cycle":"33","eol":"2027-02-28","latest":"33.0.9"},{"cycle":"32","eol":"2026-09-30","latest":"32.0.15"},...]`; `eol` may also be a boolean. Nextcloud `status.php` returns `installed, maintenance, needsDbUpgrade, version ("31.0.5.1"), versionstring ("31.0.5"), edition, productname, extendedSupport`.
- Migration convention: hand-written SQL with German header comment (model: `20260923140000_proxmox_server`); latest existing migration is `20261002140000_module_grant_level`. Local DB: no host port — IP via `docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1`, then `DATABASE_URL="postgresql://tessera:tessera_dev@<IP>:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy`.
- Commits: German subject, conventional prefix, end with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`. Never push. PLAN/SUMMARY/STATE are committed by the orchestrator, not by the executor.
@apps/api/src/proxmox/proxmox.controller.ts
@apps/api/src/proxmox/proxmox-scheduler.service.ts
@apps/api/src/proxmox/proxmox.seed.ts
@apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql
@apps/web/src/app/(portal)/modules/proxmox/page.tsx
@apps/web/src/components/dashboard/widgets/proxmox-widget.tsx
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer — a registered cloud is checked, rated and shown as a tile (DB → status.php → endoflife reference → rating → GET instances → module page)</name>
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql, apps/api/src/nextcloud-status/nextcloud-rating.ts, apps/api/src/nextcloud-status/nextcloud-rating.spec.ts, apps/api/src/nextcloud-status/nextcloud-status-fetch.ts, apps/api/src/nextcloud-status/nextcloud-status-fetch.spec.ts, apps/api/src/nextcloud-status/nextcloud-release.service.ts, apps/api/src/nextcloud-status/nextcloud-release.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.service.ts, apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts, apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts, apps/api/src/nextcloud-status/nextcloud-status.seed.ts, apps/api/src/nextcloud-status/nextcloud-status.module.ts, apps/api/src/app.module.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/nextcloud-status-api.ts, apps/web/src/components/nextcloud-status/rating-display.ts, apps/web/src/app/(portal)/modules/nextcloud-status/layout.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx, apps/web/src/app/(portal)/modules/module-layouts.test.tsx, apps/web/src/lib/module-loader.ts, apps/web/src/lib/module-identity.ts, apps/web/src/lib/stores/nav-store.ts, apps/web/src/components/modules/module-tile.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts</files>
<behavior>
- rateNextcloud (reference = 35/2027-09-30/35.0.1, 34/2027-06-30/34.0.4, 33/2027-02-28/33.0.9, 32/2026-09-30/32.0.15, 31/2026-02-28/31.0.14; now = 2026-10-02 unless stated): 35.0.1 → green/current; 35.0.2 (newer than listed latest) → green/current; 34.0.3 → yellow/update-available, updateTo 34.0.4; 32.0.15 → red/eol-passed with eolDate 2026-09-30; 32.0.15 at now 2026-08-01 → yellow/eol-soon; 33.0.9 at 2026-12-15 → yellow/eol-soon eolDate 2027-02-28; boundary: EOL exactly 90 days ahead → yellow, 91 days → green; EOL day itself → yellow, the day after → red; 33.0.5 at 2026-12-15 → yellow, reason eol-soon AND updateTo 33.0.9; major 20 (older than all listed) → red/eol-passed without date; major 36 (newer than all) → green/current; cycle with eol false → supported, green when latest; eol true → red/eol-passed without date; reachable false → red/unreachable even with reference null; errorKind not-nextcloud → red/invalid-response; maintenance true → red/maintenance; needsDbUpgrade true → red/needs-db-upgrade; red priority unreachable > invalid-response > maintenance > needs-db-upgrade > eol-passed; reference null + reachable → unknown/no-reference; reachable but no parseable version → unknown/version-unknown; never checked → unknown/not-checked; newestVersion(reference) = latest of the highest cycle ('35.0.1').
- normalizeCloudUrl: ' https://cloud.kunde.de/ ' → 'https://cloud.kunde.de'; '.../status.php' and '.../index.php' suffix stripped; subpath 'https://host/nextcloud/' → 'https://host/nextcloud'; query/hash dropped; 'http://intern:8080' kept; ftp:, javascript:, URL with user:pass@, empty, longer than 2048 → null.
- parseNextcloudStatus: valid JSON → fields; maintenance true kept; needsDbUpgrade missing → false; versionstring missing → first three parts of version; HTML, empty, array, installed false, non-object → null; overlong edition/productname cut to 64 chars.
- fetchNextcloudStatus (injected fetch): 200 valid → reachable true + fields; 200 maintenance → reachable true, maintenance true; 200 garbage → reachable false, errorKind not-nextcloud; 503 → errorKind http-status, errorDetail 'HTTP 503'; never-resolving fetch → errorKind timeout after the injected timeout (fake timers or a 20 ms timeout); rejected fetch with cause.code ENOTFOUND → errorKind network, detail 'ENOTFOUND'; cert error code (e.g. CERT_HAS_EXPIRED) → errorKind tls; 301 with relative Location → followed once and succeeds; redirect to ftp: → errorKind redirect; 4 consecutive redirects → errorKind redirect; body over 64 KiB → errorKind too-large; request carries no Authorization/Cookie header and targets exactly '<base>/status.php'.
- NextcloudReleaseService (injected fetch + clock): first getReference fetches and parses; second call within 12 h does not fetch; after 12 h returns the stale data immediately and refreshes in the background; failure with existing data keeps it; failure without data → null; no new attempt within 15 min after a failure; garbage/empty array keeps last good; parallel calls share one request; parseEndOfLife skips entries without numeric cycle or valid latest.
- NextcloudStatusService (mocked prisma via forTenant, mocked fetch + release): createInstance normalizes the URL (invalid → BadRequestException with German message), creates the row and runs the first check; checkInstance writes reachable/maintenance/needsDbUpgrade/versionString/edition/errorKind/errorDetail/lastCheckedAt; checkInstance for an unknown id → NotFoundException; listForTenant selects no logo bytes, returns { instances (with rating), reference: { newestVersion, fetchedAt } }.
- Controller metadata: class has MODULE_SLUG_KEY 'nextcloud-status' + ModuleGuard; `list` has no MODULE_MANAGE_KEY; `create` and `checkOne` have MODULE_MANAGE_KEY true and no ROLES_KEY.
- Web page test: with a mocked list (one green, one yellow with updateTo, one red unreachable, one grey) it renders four tiles with Kundenname, version, the translated reasons ('Aktuell', 'Update auf 34.0.4 verfügbar', 'Nicht erreichbar', 'Bewertung nicht möglich'), the URL as link with target _blank and rel noopener noreferrer, and the line with the newest Nextcloud version; an empty list shows the empty state.
</behavior>
<action>
**Schema + migration (L-01, L-02, L-03, D-A).** In `apps/api/prisma/schema.prisma` add `model NextcloudInstance` after `ProxmoxServerStatus` with a German comment (quick-261002-k67; status lives on the row, L-03; logo bytes per D-A; no relation to Tenant, pattern ProxmoxServer): `id String @id @default(uuid())`, `tenantId String`, `customerName String`, `baseUrl String`, `logoUrl String?`, `logoData Bytes?`, `logoMime String?`, `logoVersion Int @default(0)`, `lastCheckedAt DateTime?`, `reachable Boolean?` (null = never checked), `maintenance Boolean?`, `needsDbUpgrade Boolean?`, `versionString String?`, `edition String?`, `productName String?`, `errorKind String?`, `errorDetail String?`, `createdAt`, `updatedAt @updatedAt`, `@@index([tenantId])`. Hand-write `apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql` in the style of 20260923140000_proxmox_server: German header (purpose; tenant_isolation_policy WITHOUT user dimension because clouds are shared data of the organisation; `system_read_policy ... FOR SELECT USING (is_system_context())` because the hourly job of Task 2 reads id/tenantId of all rows; rights via ALTER DEFAULT PRIVILEGES; switch-is-off note), CREATE TABLE with `"logoData" BYTEA`, index, ENABLE + FORCE ROW LEVEL SECURITY, both policies. Run `pnpm --filter @tessera/api exec prisma generate`; apply locally (L-14) with the container-IP command from the context and confirm `prisma migrate status` is up to date and `prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --exit-code` exits 0.
**Pure functions (L-03, L-05, D-E, D-F).** `nextcloud-rating.ts` (no Nest, no Prisma): types `NextcloudCycle { cycle: number; eol: string | boolean; latest: string }`, `NextcloudReference { cycles: NextcloudCycle[]; fetchedAt: string }`, `RatingLevel = 'green'|'yellow'|'red'|'unknown'`, `RatingReason = 'current'|'update-available'|'eol-soon'|'eol-passed'|'unreachable'|'invalid-response'|'maintenance'|'needs-db-upgrade'|'no-reference'|'version-unknown'|'not-checked'`, `NextcloudRating { level; reason; updateTo: string|null; eolDate: string|null; cycle: number|null }`. Export `rateNextcloud(status, reference, now: Date)` and `newestVersion(reference)`. Compare dates as UTC calendar days ('YYYY-MM-DD' of `now`), EOL-soon window `EOL_WARNING_DAYS = 90` inclusive, version compare numerically on three parts parsed with an anchored regex from versionString (fallback: first three parts of `version`). Implement exactly the cases in `<behavior>`; German JSDoc explaining each rule and citing L-05. `nextcloud-status-fetch.ts`: `normalizeCloudUrl(raw)`, `parseNextcloudStatus(text)` (JSON.parse in try, whitelist fields, version strings limited to digits/dots and 32 chars), and `fetchNextcloudStatus(baseUrl, opts?: { fetchImpl?, timeoutMs? })` → `{ reachable, maintenance, needsDbUpgrade, versionString, edition, productName, errorKind, errorDetail }`. Use `undiciFetch` by default, `redirect: 'manual'`, at most `MAX_REDIRECTS = 3` hops (each Location resolved against the current URL, only http/https), one AbortController for the whole request with `STATUS_TIMEOUT_MS = 10_000`, headers only `Accept: application/json` and a `User-Agent: Tessera-Nextcloud-Status`, body read through a capped reader with `MAX_STATUS_BYTES = 64 * 1024`, discard bodies of redirect/error responses. errorKind values: timeout, network, tls, http-status, not-nextcloud, too-large, redirect; errorDetail is only our own short code ('HTTP 503', the `cause.code` such as ENOTFOUND/ECONNREFUSED/CERT_HAS_EXPIRED, max 120 chars) — never a response body or exception message. Put a German SSRF comment block above the function (L-03): manager-entered URLs incl. internal hosts are allowed on purpose like Proxmox (T-DHH-02); limits applied (only `/status.php`, GET, no credentials, 3 redirects, 10 s, 64 KiB, only whitelisted fields leave the server); a person with Verwalten could thereby learn whether an internal address answers — accepted.
**Reference cache (L-04, D-D).** `nextcloud-release.service.ts`: `@Injectable() NextcloudReleaseService` without constructor parameters; test seams are two instance fields `fetchImpl` (default `undiciFetch`) and `now` (default `() => Date.now()`) that specs overwrite on the instance. Export pure `parseEndOfLife(json): NextcloudCycle[]` (cycle must be /^\d+$/, latest a dotted version, eol a 'YYYY-MM-DD' string or boolean; invalid entries skipped). `getReference(): Promise<NextcloudReference | null>` and `refresh(): Promise<void>` per D-D: source URL constant `ENDOFLIFE_URL = 'https://endoflife.date/api/nextcloud.json'`, `CACHE_TTL_MS = 12 h`, `RETRY_BACKOFF_MS = 15 min`, 10 s timeout, 512 KiB cap, empty parse result counts as failure, failures logged once per attempt with Logger.warn.
**Service + DTO + controller + seed + module (L-01, L-09).** `dto/nextcloud-instance.dto.ts`: `CreateNextcloudInstanceDto { customerName (IsString, IsNotEmpty, MaxLength 120); baseUrl (IsString, IsNotEmpty, MaxLength 2048); logoUrl? (IsOptional, ValidateIf non-empty, IsUrl https only with require_protocol, MaxLength 2048) }` and `UpdateNextcloudInstanceDto` with all three optional (same validators; empty logoUrl = remove). `nextcloud-status.service.ts`: inject PrismaService and NextcloudReleaseService; a module-level `PUBLIC_SELECT` constant without `logoData`; every method uses its own `const tenantPrisma = forTenant(this.prisma, tenantId)`; `listForTenant(tenantId)` (findMany ordered by customerName, maps to a view `{ id, customerName, baseUrl, logoUrl, hasUploadedLogo, logoVersion, status: { checkedAt, reachable, maintenance, needsDbUpgrade, versionString, edition, errorKind, errorDetail }, rating }` using `rateNextcloud(..., reference, new Date())`, returns `{ instances, reference: { newestVersion, fetchedAt } }`); `createInstance(tenantId, dto)` (normalizeCloudUrl → BadRequestException 'Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.'; create; then `checkInstance`); `checkInstance(tenantId, id)` (findFirst by id+tenantId → NotFoundException; fetchNextcloudStatus; update status columns + lastCheckedAt; return the view). `nextcloud-status.controller.ts`: `@Controller('modules/nextcloud-status')`, class `@UseModule('nextcloud-status')`, `requireTenantId` as in ProxmoxController; `@Get('instances') list`; `@Post('instances') @ModuleManage('nextcloud-status') create`; `@Post('instances/:id/check') @ModuleManage('nextcloud-status') checkOne`. Header comment: rights per L-09, route order rule (static routes before `:id`, see Task 2). `nextcloud-status.seed.ts` like proxmox.seed.ts: slug 'nextcloud-status', name 'Nextcloud-Status', version '1.0.0', category 'infrastructure', description de 'Versionen und Erreichbarkeit Ihrer Nextcloud-Clouds im Blick' / en 'Keep track of versions and availability of your Nextcloud clouds', isSystem true. `nextcloud-status.module.ts` like ProxmoxModule (imports ModuleRegistryModule, providers NextcloudStatusService + NextcloudReleaseService, OnModuleInit seed with log line 'Nextcloud-Status module seeded in registry'). Register `NextcloudStatusModule` in `app.module.ts` right after ProxmoxModule. Specs per `<behavior>` (controller spec pattern: Reflect.getMetadata on prototype methods, like module-manage-handlers.spec.ts).
**RLS inventory doc.** Run `pnpm --filter @tessera/api exec vitest run rls-coverage rls-access-inventory`; add the Bereichszeile `nextcloud-status`, update the Summenzeile and Paarzählung, and add the Fundstellentabelle row(s) for `apps/api/src/nextcloud-status/nextcloud-status.service.ts` / `nextcloudInstance` (Stand `gebunden` for now; Task 2 turns it into `system-gebunden`) in `docs/mandantentrennung-zugriffsklassifikation.md`, counted with the Gate-Schleife exactly as the fm5 rows describe; both specs green.
**Web tracer (L-06, L-11).** `apps/web/src/lib/nextcloud-status-api.ts` (pattern proxmox-api.ts, `credentials: 'include'`): exported types mirroring the API view and `listInstances()`; plus `logoSrc(instance)` returning `/api-proxy/modules/nextcloud-status/instances/<id>/logo?v=<logoVersion>` when `hasUploadedLogo`, else `logoUrl`, else null. `apps/web/src/components/nextcloud-status/rating-display.ts`: literal class map `RATING_STYLE` per level (green→status-ok, yellow→status-warn, red→status-down, unknown→status-idle; fill + pill + text), `ratingReasonText(t, rating, locale)` mapping reason → key under `nextcloudStatus.reason.*` with params (version, date formatted with Intl.DateTimeFormat for the locale in UTC, e.g. 30.06.2027), shared later by the widget. `layout.tsx` = ModuleAccessGate moduleSlug "nextcloud-status" (copy proxmox/layout.tsx). `page.tsx` ('use client'): PageHeader with title, a muted line "Neueste Nextcloud-Version: {version}" (or "Versionsdaten derzeit nicht verfügbar"), loading skeleton, empty state, responsive tile grid (`grid gap-4 sm:grid-cols-2 xl:grid-cols-3`) of `CloudTile`. `CloudTile.tsx`: colored left strip + status pill with reason, logo (img with `referrerPolicy="no-referrer"`, alt = Kundenname, fallback initials on null or onError), Kundenname, URL link (`target="_blank" rel="noopener noreferrer"`), installed version (or "—"), last check as relative time ("vor 12 Min.", "noch nie"), errorDetail in small muted text only when red/unreachable. Use existing tokens (bg-card, text-muted-foreground, dark: variants as in proxmox ServerCard) — no new colors. Registrations: module-loader.ts entry 'nextcloud-status' (dynamic import of the page, ssr false); module-identity.ts new ModuleIconId 'cloud' mapped from 'nextcloud-status'; module-tile.tsx ICONS 'cloud' (lucide cloud path `M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z`); nav-store.ts MODULE_TITLE_KEYS 'nextcloud-status' → 'nextcloudStatus.title'; module-layouts.test.tsx add ['nextcloud-status', NextcloudStatusLayout] to the it.each. Messages: new top-level `nextcloudStatus` namespace in de.json (formal Sie, real umlauts) and en.json with identical keys: title, newestVersion, referenceUnavailable, empty, lastCheck/never, version labels, and `reason.{current: 'Aktuell', updateAvailable: 'Update auf {version} verfügbar', eolSoon: 'Support endet am {date}', eolPassed: 'Support abgelaufen seit {date}', eolPassedNoDate: 'Support abgelaufen', unreachable: 'Nicht erreichbar', invalidResponse: 'Keine gültige Nextcloud-Antwort', maintenance: 'Wartungsmodus', needsDbUpgrade: 'Datenbank-Aktualisierung ausstehend', noReference: 'Bewertung nicht möglich', versionUnknown: 'Version unbekannt', notChecked: 'Noch nicht geprüft'}` plus English equivalents ('Up to date', 'Update to {version} available', 'Support ends on {date}', …). UI texts never name the tenant concept (L-11). Run the umlaut guard and add legitimately correct tokens to `UMLAUT_ALLOWLIST` only if it fails. Page test per `<behavior>` (mock `@/lib/nextcloud-status-api` and next-intl like the proxmox page tests).
Biome-lint the touched files (`pnpm exec biome lint <files>` from repo root, `biome check --write` on new files only), commit `feat(nextcloud-status): Modul mit Statusabruf, Versionsbewertung und Kachelansicht` (attribution line). Do not push.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/nextcloud-status rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run nextcloud-status module-layouts umlaut && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit</automated>
</verify>
<done>NextcloudInstance migration applied locally without drift; rating, parser, fetch, release cache, service and controller specs green; GET /modules/nextcloud-status/instances returns rated instances plus newest version; the module page renders the tiles with translated reasons; module registered in loader, identity, tile icon, nav titles and layouts test; RLS gates green; commit on main, not pushed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Managers maintain clouds (edit, delete, logo), trigger checks, hourly job runs; everyone can sort the tiles</name>
<files>apps/api/src/nextcloud-status/nextcloud-logo-rules.ts, apps/api/src/nextcloud-status/nextcloud-logo-rules.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.service.ts, apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts, apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts, apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.module.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, apps/api/src/prisma/rls-access-inventory.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/nextcloud-status-api.ts, apps/web/src/components/nextcloud-status/sort-clouds.ts, apps/web/src/components/nextcloud-status/sort-clouds.test.ts, apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.test.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts</files>
<behavior>
- checkLogoUpload (nextcloud-logo-rules): PNG/JPEG/GIF/WebP bytes ≤ 1 MiB → detected mime; SVG text, PDF, empty, renamed HTML → rejected; 1 MiB + 1 byte → rejected (second net behind multer).
- Service: updateInstance changes name/URL (new URL normalized and re-checked; unchanged URL not re-checked); non-empty logoUrl clears logoData/logoMime and bumps logoVersion; empty logoUrl sets null; uploadLogo stores bytes + detected mime, clears logoUrl, bumps logoVersion; removeLogo clears bytes/mime and bumps logoVersion; getLogo returns { data, mime } or NotFoundException when none; deleteInstance removes the row; every write/read of a foreign id → NotFoundException (where id + tenantId); checkAllForTenant checks all instances of the tenant with at most 4 in parallel and returns the refreshed list; loadAllInstancesForScheduler selects only id and tenantId through forSystem.
- Scheduler: onApplicationBootstrap registers exactly one cron job 'nextcloud-status-poll' with '0 * * * *' without any database read, starts it, and fires one non-blocking release refresh; a thrown error during bootstrap is logged and does not propagate; tick groups instances by tenant and calls checkInstance(tenantId, id) for each; one failing instance does not stop the others; a tick started while the previous one runs is skipped.
- Controller metadata + order: list and logo (GET) have no MODULE_MANAGE_KEY; create, update, remove, checkAll, checkOne, uploadLogo, removeLogo have MODULE_MANAGE_KEY true and no ROLES_KEY; the static handler for POST 'instances/check' is declared before every handler whose path contains ':id' (index check on Object.getOwnPropertyNames of the prototype).
- sortClouds: 'name' → A–Z with German collation (Ä next to A, case-insensitive); 'status' → red, yellow, unknown, green, ties by name; 'version' → oldest first, missing version last; 'eol' → earliest eolDate first, missing last; input array not mutated. readSortPreference/writeSortPreference: key 'tessera:nextcloud-status:sort:<userId>', unknown stored value → 'name', throwing localStorage → default and no throw.
- Page: USE user (useCanManageModule false) sees tiles and the sort selector but no "Cloud hinzufügen", "Jetzt prüfen", per-tile refresh/edit buttons; manager (true) sees all of them; choosing 'Status' reorders tiles red first and writes the preference; a stored preference is applied on load; "Jetzt prüfen" calls checkAll and replaces the list.
- CloudForm: required Kundenname and URL; logo choice "Keins / Bild hochladen / Bildadresse"; https-only hint on logo URL; submit calls create (or update) and, when a file is chosen, uploadLogo afterwards; delete asks for confirmation before calling remove; API error message is shown in the form.
</behavior>
<action>
**API write paths (L-02, L-09, D-A).** `nextcloud-logo-rules.ts`: `NEXTCLOUD_LOGO_MAX_BYTES = 1024 * 1024`, `checkLogoUpload(buffer)` → mime or null, built on `detectImageMime` from `../dashboard/dashboard-image-rules` (German comment: no SVG because inline script; magic bytes instead of client mimetype, pattern T-PI9-01). Extend `nextcloud-status.service.ts` with `updateInstance`, `deleteInstance`, `uploadLogo(tenantId, id, file)` (BadRequestException 'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.'), `getLogo`, `removeLogo`, `checkAllForTenant` (small promise pool of 4, each instance isolated with try/catch + Logger.warn), `listInstanceIdsForTenant`, and `loadAllInstancesForScheduler()` — the single `const systemPrisma = forSystem(this.prisma);` access, `select: { id: true, tenantId: true }`, German comment naming FORSYSTEM_ALLOWED_CALL_SITES and the system_read_policy of migration 20261002150000. All per-row writes stay `forTenant` with `where: { id, tenantId }`.
**Controller routes (L-08, L-09, L-12).** Final handler order in `nextcloud-status.controller.ts`: `@Get('instances') list`; `@Post('instances') create`; `@Post('instances/check') checkAll` (static, BEFORE any `:id` route); `@Put('instances/:id') update`; `@Delete('instances/:id') remove`; `@Post('instances/:id/check') checkOne`; `@Get('instances/:id/logo') logo` (USE level; sets Content-Type from the stored mime, `Cache-Control: private, max-age=86400`, `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; sandbox`, sends the bytes — pattern favorites getIcon); `@Post('instances/:id/logo')` with `FileInterceptor('logo', { limits: { fileSize: NEXTCLOUD_LOGO_MAX_BYTES, files: 1 } })` uploadLogo; `@Delete('instances/:id/logo') removeLogo`. Every write/check handler gets `@ModuleManage('nextcloud-status')` and no role decorator. Extend the controller spec with the metadata and declaration-order assertions, and add a `NextcloudStatusController` block to `apps/api/src/module-registry/module-manage-handlers.spec.ts` (manage handlers listed via it.each, list/logo stay USE level).
**Scheduler (L-08, D-C).** `nextcloud-status-scheduler.service.ts` implementing `OnApplicationBootstrap` (German header: why not OnModuleInit — Tender bootstrap lesson; why a single global job instead of per-tenant jobs — fixed hourly interval, no per-row setting, no DB read at registration). Reuse the `require('cron').CronJob` workaround and the SchedulerRegistry calls from ProxmoxSchedulerService; job name `nextcloud-status-poll`, cron `0 * * * *`; `running` flag as overlap guard; tick = `loadAllInstancesForScheduler()` → group by tenantId → all instances of the tick run through one pool of at most 4 concurrent `checkInstance(tenantId, id)` calls (each bound to its own tenantId), every error logged with tenant and id. Bootstrap also calls `void this.release.refresh()` (caught). Register the scheduler in `nextcloud-status.module.ts` providers. Spec per `<behavior>` with mocked SchedulerRegistry (pattern proxmox-scheduler.service.spec.ts).
**RLS gates.** Add `['apps/api/src/nextcloud-status/nextcloud-status.service.ts', 1]` to `FORSYSTEM_ALLOWED_CALL_SITES` in `rls-access-inventory.spec.ts`, extending its history comment (quick-261002-k67: hourly job reads id/tenantId of all instances, writes per row tenant-bound; new totals). Update `docs/mandantentrennung-zugriffsklassifikation.md`: Bereichszeile counts (bound/system), Summenzeile, Fundstellentabelle row for `nextcloud-status.service.ts`/`nextcloudInstance` now `system-gebunden` with the explanation (precedent proxmox row); recount with the Gate-Schleife, never copy numbers.
**Web (L-02, L-07, L-09, D-G).** `nextcloud-status-api.ts`: add `createInstance`, `updateInstance`, `deleteInstance`, `checkAll`, `checkOne`, `uploadLogo` (FormData field 'logo'), `removeLogo`; non-ok responses throw an Error carrying the API `message` (pattern proxmox-api.ts). `sort-clouds.ts`: `SortKey = 'name'|'status'|'version'|'eol'`, `sortClouds(items, key)` per D-G (pure, returns a new array, `localeCompare(…, 'de', { sensitivity: 'base' })`), `readSortPreference(userId)` / `writeSortPreference(userId, key)` with key `tessera:nextcloud-status:sort:<userId>`, everything in try/catch, unknown values fall back to 'name'. Page: sort `<select>` with the four options (label "Sortieren nach"), applied via useMemo; user id from `useAuthStore`; `const canManage = useCanManageModule('nextcloud-status') === true` gates "Cloud hinzufügen", "Jetzt prüfen" (spinner while running, then replace list with the response) and, on each tile, refresh (checkOne, replaces that tile) and edit (opens CloudForm). `CloudForm.tsx`: dialog/panel (follow the proxmox ServerForm look) for add/edit with Kundenname, URL (placeholder https://cloud.example.com), logo choice radio "Kein Logo / Bild hochladen / Bildadresse (https)", file input accept="image/png,image/jpeg,image/gif,image/webp", client-side size hint 1 MB, preview of the current logo, "Logo entfernen", delete button with confirmation dialog ("Möchten Sie die Cloud „{name}“ wirklich entfernen?"), saving state "Wird geprüft …" (create awaits the first check), API errors shown inline. All new texts in `nextcloudStatus.*` de + en, formal Sie, real umlauts, no tenant wording; umlaut guard green. Tests per `<behavior>`: `sort-clouds.test.ts`, `CloudForm.test.tsx`, and new manager/USE/sorting cases in `nextcloud-status-page.test.tsx` (mock `@/lib/use-module-capability`).
Biome-lint touched files, commit `feat(nextcloud-status): Clouds verwalten, Logos, stündliche Prüfung und Sortierung` (attribution line). Do not push.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/nextcloud-status src/module-registry rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run nextcloud-status umlaut && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test -z "$(grep -nE '^\s*@Roles\(' apps/api/src/nextcloud-status/nextcloud-status.controller.ts)"</automated>
</verify>
<done>All write, logo and check routes exist behind ModuleManage (metadata + route-order specs green); the hourly job is registered in onApplicationBootstrap without a DB read and checks every instance tenant-bound; forSystem allowlist and RLS doc updated; the page offers sorting for everyone (remembered per user) and add/edit/delete/logo/check controls only to managers; commit on main, not pushed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Dashboard tile "Nextcloud-Status", docs and changelog, full suites, local rebuild</name>
<files>packages/shared/src/index.ts, apps/api/src/dashboard/widget-module-map.ts, apps/api/src/dashboard/widget-module-map.spec.ts, apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/dashboard/widget-registry.test.tsx, apps/web/src/components/dashboard/widget-catalog-modal.test.tsx, apps/web/src/components/dashboard/widgets/widget-icon.tsx, apps/web/src/components/dashboard/widgets/widget-wrapper.tsx, apps/web/src/components/dashboard/widgets/nextcloud-status-widget.tsx, apps/web/src/components/dashboard/widgets/nextcloud-status-widget.test.tsx, apps/web/src/app/(portal)/page.tsx, apps/web/src/app/(portal)/page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md</files>
<behavior>
- WIDGET_TYPES ends with 'nextcloud-status' (twelve types, existing order unchanged); WIDGET_MODULE_SLUGS maps proxmox → proxmox and 'nextcloud-status' → 'nextcloud-status'; getModuleSlugForWidgetType('nextcloud-status') = 'nextcloud-status'; all other types stay platform tiles.
- Catalog: without module access neither Proxmox nor Nextcloud-Status appears; with access to 'nextcloud-status' only that module tile is added.
- Widget: list with 2 green, 1 yellow (updateTo 34.0.4), 1 red (maintenance) → counters 2/1/1, list shows the red cloud first, then the yellow one, each with name + translated reason; all green → a calm "Alle Clouds sind aktuell" line and no list; empty list → hint "Noch keine Cloud eingetragen"; view mode rows link to /modules/nextcloud-status; edit mode rows are not links; the widget never calls a check endpoint (only listInstances); unknown-rated clouds counted separately only when > 0.
</behavior>
<action>
**Widget registration (L-10).** `packages/shared/src/index.ts`: append `'nextcloud-status'` to WIDGET_TYPES and add `'nextcloud-status': 'nextcloud-status'` to WIDGET_MODULE_SLUGS (extend the comment: second module tile, quick-261002-k67; slug equals the seed and the class-level UseModule). Update the comment in `apps/api/src/dashboard/widget-module-map.ts` (no longer exactly one entry) and `widget-module-map.spec.ts` (module tiles = proxmox and nextcloud-status). `widget-registry.tsx`: WIDGET_CONSTRAINTS `'nextcloud-status': { minW: 6, minH: 4, defaultW: 12, defaultH: 8 }` with a comment, an inline `NextcloudStatusIcon` (cloud path from Task 1), and the registry entry (nameKey 'nextcloudStatus.name', descriptionKey 'nextcloudStatus.description', moduleSlug from WIDGET_MODULE_SLUGS, PlaceholderWidget). `widget-icon.tsx`: 'nextcloud-status' cloud glyph. `widget-wrapper.tsx`: add 'nextcloud-status' to FRAME_HEADER_TYPES (frame header = icon + name, hide-title toggle via TITLE_TYPES) and update its comment. `(portal)/page.tsx`: import and `registerWidget('nextcloud-status', NextcloudStatusWidget)`; add the matching vi.mock in `page.test.tsx`. Update the enumerations in `widget-registry.test.tsx` (twelve types, constraints table, moduleSlug assertion for both module tiles, visibleWidgetTypes cases) and `widget-catalog-modal.test.tsx` (counts and module-access cases).
**Widget component (L-10, L-11).** `nextcloud-status-widget.tsx` ('use client', WidgetProps): reads only `listInstances()` from `@/lib/nextcloud-status-api` (German comment: never triggers checks — pattern T-I8V-02), refresh every 5 min with visibility pause and immediate reload on return (pattern proxmox-widget.tsx); three counter chips using `RATING_STYLE` from `rating-display.ts` (grün/gelb/rot labels plus "ohne Bewertung" only when > 0); below, red then yellow clouds sorted by name (`sortClouds(items, 'status')` from Task 2) with status dot, Kundenname and `ratingReasonText`; container-query compact mode like the proxmox widget (narrow: only counters); in view mode each row and the counter area are Next `Link`s to `/modules/nextcloud-status`, in edit mode plain rows without tabstop (links are in the drag-cancel selector, see proxmox comment); loading, error ("Status konnte nicht geladen werden") and empty states. Messages: `widgets.nextcloudStatus.{name: 'Nextcloud-Status', description: 'Ampelübersicht Ihrer Nextcloud-Clouds'}` and `nextcloudStatus.widget.*` texts in de + en. Widget test per `<behavior>`.
**Docs + changelog (L-13).** `CHANGELOG.md` under "## Unveröffentlicht" → "### Neu": one user-facing German bullet (module in Infrastruktur, Aktivierung im Marktplatz + Freigabe, tiles with Ampel and what the colors mean, newest version shown, hourly check plus "Jetzt prüfen", sorting remembered, logo upload or address, who may maintain clouds = Verwalten, dashboard tile). `docs/anleitung-anwender.md`: new section "### Nextcloud-Status" after "### Proxmox" (what the tile shows, the exact traffic-light rules in plain words incl. 3-month window, grey state, sorting, the dashboard tile, what Verwalten users can do). `docs/anleitung-administration.md`: new subsection after "### Proxmox-Server anbinden…" — "### Nextcloud-Status: Clouds eintragen" (who may maintain, Tessera reads only the public status.php, no login data needed, the API container needs outbound access to the clouds and to endoflife.date, internal addresses allowed, certificate must be valid, logo rules 1 MB PNG/JPEG/GIF/WebP or https address loaded by the browser), plus its entry in the table of contents if the section list there names subsections. No tenant wording in user-facing docs/changelog.
**Final gates (L-12, L-14).** Run full `pnpm --filter @tessera/api test` and `pnpm --filter @tessera/web test`, both tsc, biome lint on all touched files of the three tasks; fix any enumeration test that still expects eleven widget types. Rebuild `docker compose up -d --build api web`, wait for healthy, check `docker compose logs api` for 'Nextcloud-Status module seeded in registry', the mapped routes `/modules/nextcloud-status/instances`, the scheduler log line, and no migration errors; confirm with psql in the db container that the `Module` row `nextcloud-status` has category `infrastructure`. Commit `feat(nextcloud-status): Dashboard-Kachel, Anleitung und Changelog` (attribution line). Do not push; leave the browser check to the orchestrator and list in the SUMMARY what to click (add a public cloud such as a real customer URL, manager vs USE view, sorting, widget).
</action>
<verify>
<automated>pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && node -e 'const de=require("./apps/web/src/messages/de.json"),en=require("./apps/web/src/messages/en.json");const w=(o,p,r)=>{for(const[k,v]of Object.entries(o||{})){const q=p+"."+k;if(v&&typeof v==="object")w(v,q,r);else r[q]=v}return r};const pick=(m)=>({...w(m.nextcloudStatus,"nextcloudStatus",{}),...w(m.widgets&&m.widgets.nextcloudStatus,"widgets.nextcloudStatus",{})});const a=pick(de),b=pick(en);if(Object.keys(a).length<10||Object.keys(a).sort().join()!==Object.keys(b).sort().join()){console.error("key mismatch");process.exit(1)}for(const v of [...Object.values(a),...Object.values(b)])if(/mandant|tenant/i.test(String(v))){console.error("bad text",v);process.exit(1)}' && grep -q "Nextcloud" CHANGELOG.md && grep -q "### Nextcloud-Status" docs/anleitung-anwender.md && docker compose ps --status running --services | grep -qx api && docker compose ps --status running --services | grep -qx web && docker compose logs api 2>&1 | grep -q "Nextcloud-Status module seeded in registry"</automated>
</verify>
<done>Dashboard tile registered (shared types, module map, registry, icon, wrapper, page) and visible only with module access; widget shows counters and red/yellow clouds and links to the module; CHANGELOG, user and admin docs updated; full api + web suites, tsc and biome green; api and web rebuilt and running with the module seeded; commit on main, not pushed.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser → API (`/modules/nextcloud-status/*`) | untrusted caller; tenant/user/role only from the validated JWT |
| API → customer cloud (`<url>/status.php`) | outbound request to a manager-entered address, untrusted response |
| API → endoflife.date | outbound request to a public service, untrusted response |
| browser → logo URL host | the viewer's browser loads an external image |
| manager upload → DB → other users' browsers | uploaded bytes are served back to every module user |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-k67-01 | Information Disclosure (SSRF) | fetchNextcloudStatus | medium | accept | Internal targets allowed by design like Proxmox (L-03); limits: only GET `<url>/status.php`, no credentials, http/https, 3 redirects, 10 s, 64 KiB, whitelisted parsed fields only, errorDetail only own codes; write access requires Verwalten; documented in the code comment and admin docs |
| T-k67-02 | Tampering / Spoofing | logo upload + GET logo | high | mitigate | multer fileSize 1 MiB + service re-check; type from magic bytes (PNG/JPEG/GIF/WebP, no SVG); served with detected mime, nosniff, `default-src 'none'; sandbox`, private cache; logo-rules spec |
| T-k67-03 | Elevation of Privilege | controller write/check/logo routes | high | mitigate | `@ModuleManage('nextcloud-status')` on every write/check handler, no role decorator; controller spec + module-manage-handlers.spec metadata; verify gate greps for role decorators |
| T-k67-04 | Information Disclosure | cross-tenant rows | high | mitigate | forTenant on every request path, `where: { id, tenantId }` → 404, tenant_isolation_policy; system read limited to `select { id, tenantId }` in one allowlisted call site (rls-access-inventory) |
| T-k67-05 | Information Disclosure | external logo URL | low | mitigate | https only (DTO), `referrerPolicy="no-referrer"` on the img, API never fetches it (L-02) |
| T-k67-06 | Denial of Service | hourly job / reference fetch | medium | mitigate | concurrency 4, overlap guard, per-instance try/catch, 10 s timeouts, size caps; reference cache 12 h with 15 min failure backoff and shared in-flight request |
| T-k67-07 | Information Disclosure | list response | medium | mitigate | `PUBLIC_SELECT` without logo bytes; raw bodies never stored or returned; service spec asserts the select |
| T-k67-08 | Elevation of Privilege | route shadowing (`instances/check` vs `:id`) | medium | mitigate | static route declared before `:id` routes; declaration-order assertion in the controller spec |
| T-k67-09 | Tampering | stored URL | low | mitigate | normalizeCloudUrl rejects non-http(s), embedded credentials, overlong input; link rendered with `rel="noopener noreferrer"` |
| T-k67-SC | Tampering | npm/pip/cargo installs | low | accept | No new packages (undici, class-validator, multer, cron already present); nothing to verify |
</threat_model>
<verification>
- Task verify commands all pass; Task 3 runs the full api + web suites (includes rls-coverage, rls-access-inventory, umlaut-guard, module-layouts, widget enumerations).
- `prisma migrate status` up to date locally; drift check exit 0.
- `docker compose ps` shows api and web running after the rebuild; api log shows the seed line and the routes.
- Source coverage audit:
| Source item | Covered by |
|-------------|------------|
| GOAL: Nextcloud-Status module with traffic-light tiles | Tasks 1-3 |
| L-01 slug/category, Proxmox touchpoints end to end | Task 1 (API, migration, web route, registrations, layouts test), Task 3 (widget registration) |
| L-02 URL + Kundenname, logo upload (bytea, magic bytes, 1 MiB, auth route) or https URL | Task 1 (model, create), Task 2 (logo routes, form) |
| L-03 status.php fetch limits, stored result, SSRF comment, no raw bodies | Task 1 |
| L-04 endoflife.date cache 12 h, outage-tolerant, grey without data | Task 1 |
| L-05 traffic-light rules + newest version, pure function with date injection | Task 1 |
| L-06 tile content and reason texts | Task 1 |
| L-07 four sort options remembered per user | Task 2 |
| L-08 hourly job (onApplicationBootstrap), "Jetzt prüfen", per-tile refresh, system context | Task 1 (checkOne), Task 2 (checkAll, scheduler) |
| L-09 rights USE vs MANAGE/admin, useCanManageModule | Task 1 (create/checkOne), Task 2 (all write routes, UI gating) |
| L-10 dashboard widget counters + red/yellow list | Task 3 |
| L-11 German Sie + English, no tenant wording, tokens | Tasks 1-3 + node key check |
| L-12 tests, full suites, tsc, biome | Tasks 1-3 |
| L-13 CHANGELOG + docs | Task 3 |
| L-14 local migration, rebuild, no push | Task 1 (migrate), Task 3 (rebuild) |
</verification>
<success_criteria>
- `NextcloudInstance` exists with RLS policies; migration applied locally without drift.
- Rating, parser, fetch, release cache, service, controller, scheduler, logo rules, sort, page, form and widget tests pass; full api + web suites, both tsc runs and biome on touched files are green.
- USE users see rated tiles and can sort; managers and admins can additionally add/edit/delete clouds, manage logos and trigger checks; the API enforces this with ModuleManage.
- The hourly job is registered on every start, independent of existing rows.
- The dashboard tile appears in the catalog only with module access and links to the module.
- CHANGELOG and both guides describe the module; three commits on main, nothing pushed.
</success_criteria>
<output>
Create `.planning/quick/261002-k67-modul-nextcloud-status-mit-ampel-kacheln/261002-k67-SUMMARY.md` when done (not committed by the executor).
</output>
@@ -0,0 +1,124 @@
---
phase: quick-261002-k67
plan: 01
subsystem: nextcloud-status
tags: [nextcloud, ampel, modul, dashboard-kachel, scheduler, rls]
status: complete
requires:
- module-registry (ModuleGuard, ModuleManage, seedModule)
- Freigabestufe Verwalten (quick-261002-icv)
provides:
- Modul "nextcloud-status" (Kategorie infrastructure) mit Ampel-Kacheln je Cloud
- Tabelle NextcloudInstance (RLS, system_read_policy)
- stündlicher Prüfauftrag nextcloud-status-poll
- Dashboard-Kachel "nextcloud-status" (zweite modulgebundene Kachel)
affects:
- WIDGET_TYPES / WIDGET_MODULE_SLUGS (@tessera/shared)
- docs/mandantentrennung-zugriffsklassifikation.md, FORSYSTEM_ALLOWED_CALL_SITES
tech-stack:
added: []
patterns:
- reine Bewertungsfunktion mit eingereichtem Datum
- Stale-while-revalidate-Zwischenspeicher im Speicher (12 h, 15 min Pause nach Fehlschlag)
- ein globaler Cron-Auftrag in onApplicationBootstrap ohne Datenbankzugriff bei der Registrierung
key-files:
created:
- apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql
- apps/api/src/nextcloud-status/ (Rating, Fetch, Release, Service, Controller, Scheduler, Logo-Regeln, DTO, Seed, Modul, je mit Spec)
- apps/web/src/lib/nextcloud-status-api.ts
- apps/web/src/components/nextcloud-status/ (rating-display, sort-clouds + Test)
- apps/web/src/app/(portal)/modules/nextcloud-status/ (layout, page, CloudTile, CloudForm + Tests)
- apps/web/src/components/dashboard/widgets/nextcloud-status-widget.tsx (+ Test)
modified:
- apps/api/prisma/schema.prisma, apps/api/src/app.module.ts
- apps/api/src/prisma/rls-access-inventory.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md
- apps/api/src/module-registry/module-manage-handlers.spec.ts
- packages/shared/src/index.ts, apps/api/src/dashboard/widget-module-map(.spec).ts
- Web-Registrierungen (module-loader, module-identity, nav-store, module-tile, module-layouts.test, widget-registry(+Tests), widget-icon, widget-wrapper, (portal)/page(+Test)), de.json, en.json, umlaut-dictionary.ts
- CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md
decisions:
- "Logo-Bytes als bytea an der Zeile; Listen-/Planerabfragen wählen sie nie aus, nur getLogo (D-A)"
- "Bewertung beim Lesen in der API; Grundkennung plus Parameter, Übersetzung im Web (D-B)"
- "Ein globaler Cron-Auftrag, beim Start ohne Datenbankzugriff registriert (D-C)"
- "Zertifikate der Clouds werden geprüft, Fehler erscheint als Nicht erreichbar mit Fehlercode (D-H)"
metrics:
duration: etwa 1 h 15 min
completed: 2026-10-02
actuals:
tokens: 52000
tasks: 3
commits: 3
plan_head_before: 7188733f70f88ce7a61c53f76a614c476864bbfd
plan_head_after: 87a7b7cbcd86756b1892c14abd77f66744edc969
---
# Phase quick-261002-k67 Plan 01: Modul Nextcloud-Status mit Ampel-Kacheln
Neues Modul „Nextcloud-Status“ (Gruppe Infrastruktur): Verwalter tragen Nextcloud-Clouds ihrer Kunden ein, Tessera ruft stündlich `<Adresse>/status.php` ab, bewertet die Version gegen die Daten von endoflife.date (Ampel grün/gelb/rot/grau) und zeigt je Cloud eine Kachel sowie eine Übersichtskachel auf dem Dashboard.
## Was gebaut wurde
**Aufgabe 1 (Tracer), Commit 5ef7b0c:** Tabelle `NextcloudInstance` samt Zeilenschutz (Migration 20261002150000, lokal angewendet, `migrate status` aktuell, Drift-Prüfung Exit 0). Reine Funktion `rateNextcloud` (alle Regeln aus L-05 einschließlich der Grenzen 90/91 Tage und Support-Ende-Tag gelb, Tag danach rot), `normalizeCloudUrl`, `parseNextcloudStatus`, `fetchNextcloudStatus` (nur GET `<Adresse>/status.php`, 10 s, 3 Weiterleitungen, 64 KiB, keine Zugangsdaten, nur eigene Fehlerkürzel). `NextcloudReleaseService` als Zwischenspeicher (12 h, veraltete Daten sofort, Erneuerung im Hintergrund, 15 min Pause nach Fehlschlag, geteilte Anfrage). Dienst, Controller (`GET instances`, `POST instances`, `POST instances/:id/check`), Seed, Modul, RLS-Inventar. Web: Modulseite mit Kacheln (Ampelleiste, Pille mit Klartext, Logo oder Initialen, Version, letzte Prüfung), Registrierungen in Loader, Identität (Wolkensymbol), Seitenleisten-Titel, Layout-Test, Texte de/en.
**Aufgabe 2, Commit 6698ef1:** Ändern, Entfernen, Logo hochladen/abrufen/entfernen, „Jetzt prüfen“ (`POST instances/check`, statisch vor allen `:id`-Routen) und Einzelprüfung, alle mit `@ModuleManage('nextcloud-status')` und ohne Rollen-Decorator; Logo per Magic Bytes (PNG/JPEG/GIF/WebP, kein SVG, 1 MiB, multer plus Zweitprüfung). Stündlicher Auftrag `nextcloud-status-poll` (`0 * * * *`) in `onApplicationBootstrap` ohne Datenbankzugriff; je Durchlauf ein `forSystem`-Aufruf (nur `id`, `tenantId`), danach jede Cloud an ihren Mandanten gebunden, höchstens vier gleichzeitig, Überlappungsschutz. Web: Sortierung (Kundenname, Status, Version, Support-Ende; je Benutzer in localStorage), Formular (Anlegen/Bearbeiten/Löschen mit Bestätigung/Logo), Verwalten-Knöpfe nur mit `useCanManageModule`. `FORSYSTEM_ALLOWED_CALL_SITES` und die Zugriffsklassifikation nachgezogen.
**Aufgabe 3, Commit 87a7b7c:** Dashboard-Kachel `nextcloud-status` (geteilte Typliste, Modulzuordnung `nextcloud-status` → `nextcloud-status`, Registry, Symbol, Rahmenkopf, Katalog nur mit Modulzugriff): Zähler grün/gelb/rot (und „ohne Bewertung“ nur wenn > 0), darunter rote, dann gelbe Clouds mit Grund; Klick öffnet das Modul, im Bearbeitungsmodus keine Links, ruft nie eine Prüfung auf. CHANGELOG, Anwender- und Administrationsanleitung.
## Testergebnisse (ehrlich)
| Prüfung | Ergebnis |
|---|---|
| API-Tests gesamt (`pnpm --filter @tessera/api test`) | 118 Dateien, **2030 Tests grün** |
| Web-Tests gesamt (`pnpm --filter @tessera/web test`) | 119 Dateien, **1276 Tests grün** |
| `tsc --noEmit` api | grün |
| `tsc --noEmit` web | grün |
| Biome lint auf den neuen/geänderten Dateien | keine Meldungen auf neuen Dateien; die zehn verbleibenden Warnungen der Gesamtläufe stammen aus bestehenden Widget-Dateien (z. B. Stoppuhr) und wurden nicht angefasst |
| Biome check --write | nur auf neuen Dateien angewendet |
| Schlüsselabgleich de/en (`nextcloudStatus`, `widgets.nextcloudStatus`) und Prüfung auf Mandanten-Wörter | 74 Schlüssel je Sprache, deckungsgleich, kein Treffer |
| Umlaut-Wächter | grün (Allowlist: Neueste, ausstehend, Statusseite, Bildadresse, aktuell) |
| rls-coverage, rls-access-inventory | grün (93 Paare, Bereichszeile 0/14/1, Summe 61/264/8, 7 Dateien/8 `forSystem`-Aufrufe) |
Neue Tests: Rating (22), Fetch/Parser/URL (35), Release-Cache (10), Service (18), Controller (11), Scheduler (5), Logo-Regeln (6), module-manage-handlers (+9), Web: Seite (13), CloudForm (9), sort-clouds (8), Widget (8) sowie angepasste Aufzählungstests der Widget-Typen.
## Lokaler Stand
- Migration lokal angewendet (Container-IP 172.19.0.2), `docker compose up -d --build api web` gebaut, api healthy, web läuft.
- API-Log: „Nextcloud-Status module seeded in registry“, „Nextcloud-Status cron job registered: 0 * * * *“, alle neun Routen gemappt (`instances/check` vor `instances/:id`), „No pending migrations to apply“.
- Datenbank: `Module`-Zeile `nextcloud-status`, Kategorie `infrastructure`, `isSystem` true; Tabelle `NextcloudInstance` vorhanden.
- Nicht gepusht. SUMMARY/STATE/PLAN nicht committiert (macht der Orchestrator).
## Abweichungen vom Plan
Keine funktionalen Abweichungen. Drei kleine Anmerkungen:
1. **[Hinweis] Zugriffsklassifikation in zwei Schritten:** Task 1 trug `nextcloud-status.service.ts`/`nextcloudInstance` als `gebunden` ein (0/4/0), Task 2 stellte es wie geplant auf `system-gebunden` (0/14/1) um. Die Zahlen stammen aus der Gate-Schleife (`grep -cE 'tenantPrisma\.[a-zA-Z]*\.'` über die nicht-Spec-Dateien), nicht aus Annahmen.
2. **[Rule 1 - Fehler] Zählerstand im Registry-Test:** Die Erwartung `counted` (44) im bestehenden Registry-Test musste auf 48 steigen (zwölf Typen mal vier Felder); dazu mussten die Aufzählungen aus dem Plan angepasst werden (Katalog-, Registry-, Modulkarten-Tests). Beim ersten Voll-Lauf war zusätzlich `widget-module-map.spec.ts` noch auf einen Modulbezug eingestellt; im selben Task behoben, danach alle Tests grün.
3. **[Hinweis] Ledger für `commits:`:** Der Ledger-Eintrag wurde erst nach dem ersten Commit angelegt und aus dessen Elternkommit (7188733) gebildet; die Zählung (3) entspricht `git rev-list --count 7188733..HEAD`.
## Bekannte Stubs
Keine.
## Bedrohungen (Threat Model)
Alle als `mitigate` eingestuften Punkte sind umgesetzt und getestet: T-k67-02 (Logo: Magic Bytes, nosniff, Sandbox-CSP, 1 MiB), -03 (`@ModuleManage` ohne Rollen-Decorator, Metadaten-Specs), -04 (`forTenant`, `where { id, tenantId }`, ein einziger `forSystem`-Aufruf mit `select { id, tenantId }`), -05 (nur https, `referrerPolicy="no-referrer"`, API ruft die Logo-Adresse nie ab), -06 (Nebenläufigkeit 4, Überlappungsschutz, Zeitlimits), -07 (`PUBLIC_SELECT` ohne `logoData`, Spec prüft), -08 (statische Route vor `:id`, Reihenfolge-Test), -09 (`normalizeCloudUrl`). T-k67-01 (SSRF auf manuell eingegebene, auch interne Adressen) ist wie geplant akzeptiert und im Code und in der Administrationsanleitung dokumentiert.
## Threat Flags
Keine neuen Angriffsflächen außerhalb des Plans.
## Für die Browser-Prüfung (Orchestrator)
1. Als Administrator im Marktplatz „Nextcloud-Status“ aktivieren (falls noch nicht), Freigabe erteilen; die Seite unter `/modules/infrastructure/nextcloud-status` und `/modules/nextcloud-status` öffnen (Seitenleiste, Gruppe Infrastruktur).
2. „Cloud hinzufügen“: eine öffentlich erreichbare Nextcloud eintragen (Kundenname + Adresse, z. B. eine echte Kunden-Cloud). Erwartung: Kachel mit Version, Ampel und Klartext, „Zuletzt geprüft vor …“; Kopfzeile nennt die neueste Nextcloud-Version. Außerdem eine nicht erreichbare Adresse (rot „Nicht erreichbar“) und eine Adresse ohne Nextcloud (rot „Keine gültige Nextcloud-Antwort“) probieren.
3. Logo: einmal PNG hochladen (Kachel zeigt es), einmal https-Bildadresse, „Logo entfernen“ (Initialen); eine Nicht-Bilddatei und eine Datei über 1 MB (Fehlermeldung im Formular).
4. Sortierung: alle vier Optionen, danach neu laden (Auswahl bleibt, im Dunkelmodus ebenfalls prüfen).
5. Verwalten-Stufe: Benutzer nur mit „Benutzen“ anmelden (sieht Kacheln und Sortierung, aber weder „Cloud hinzufügen“, „Jetzt prüfen“, Kachel-Knöpfe noch Bearbeiten), Benutzer mit „Verwalten“ (sieht alles). „Jetzt prüfen“ und Kachel-Prüfung ausprobieren.
6. Dashboard: im Bearbeitungsmodus „Widget hinzufügen“ → „Nextcloud-Status“ (nur mit Modulzugriff im Katalog). Zähler, rote/gelbe Liste mit Grund, Klick öffnet das Modul; kleine Kachel zeigt nur die Zähler.
7. Optional: `docker compose logs api` nach der vollen Stunde — Prüfläufe der Clouds laufen ohne Fehler.
## Self-Check: PASSED
- Dateien vorhanden: Migration, `apps/api/src/nextcloud-status/*`, `apps/web/src/app/(portal)/modules/nextcloud-status/*`, Widget und Tests (alle per Commit nachgewiesen).
- Commits vorhanden: 5ef7b0c, 6698ef1, 87a7b7c (`git log`), 3 Commits seit 7188733.
- Laufender Stand: api healthy, Seed- und Planer-Logzeilen vorhanden, Modul-Zeile in der Datenbank.
@@ -0,0 +1,306 @@
---
phase: quick-261002-kxc
plan: 01
type: execute
wave: 1
depends_on: []
quick_id: 261002-kxc
description: "Nextcloud-Status: persönliche Benachrichtigung (Glocke je Kachel) bei Störung und Wiederherstellung, per E-Mail und in Tessera"
date: 2026-10-02
files_modified:
# Task 1 — tracer: DB -> Ausfall-Regeln -> Pruefung -> Anspruch -> Mail; Glocke API + Kachel
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20261002170000_nextcloud_alerts/migration.sql
- apps/api/src/nextcloud-status/nextcloud-alert-rules.ts
- apps/api/src/nextcloud-status/nextcloud-alert-rules.spec.ts
- apps/api/src/nextcloud-status/nextcloud-alert-mail.ts
- apps/api/src/nextcloud-status/nextcloud-alert-mail.spec.ts
- apps/api/src/nextcloud-status/nextcloud-alert.service.ts
- apps/api/src/nextcloud-status/nextcloud-alert.service.spec.ts
- apps/api/src/nextcloud-status/nextcloud-status.service.ts
- apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts
- apps/api/src/nextcloud-status/nextcloud-status.controller.ts
- apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts
- apps/api/src/nextcloud-status/nextcloud-status.module.ts
- apps/api/src/mail/mail.service.ts
- apps/api/src/mail/mail.service.spec.ts
- apps/api/src/module-registry/module-manage-handlers.spec.ts
- docs/mandantentrennung-zugriffsklassifikation.md
- apps/web/src/lib/nextcloud-status-api.ts
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx
- apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx
- apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
# Task 2 — Wiederholung nach 5 min, Adresswechsel, Hinweis auf der Kachel
- apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts
- apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.ts
# Task 3 — In-App-Meldung, Anleitung, Changelog, Abschluss
- apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx
- apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.test.tsx
- apps/web/src/components/layout/app-shell.tsx
- CHANGELOG.md
- docs/anleitung-anwender.md
- docs/anleitung-administration.md
autonomous: true
requirements: [QUICK-261002-kxc]
estimate:
tokens: 150000
raw_tokens: 150000
tasks: 3
confidence: low
must_haves:
truths:
- "Every user who can open Nextcloud-Status (grant Benutzen or Verwalten, or administrator) sees a bell on each cloud tile, can switch it on and off, and the state survives a reload; it is personal per user and per cloud (L-01)"
- "When a subscribed cloud turns red (unreachable, invalid answer, maintenance, database upgrade pending, support expired) every subscriber with current module access and an active account gets exactly one e-mail; while it stays red nothing more is sent; when it turns yellow or green again exactly one 'wieder in Ordnung' e-mail follows (L-02, L-05, L-06)"
- "A cloud that fails one check keeps showing its last known state with the hint 'Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt'; it turns red and triggers the notification only after a second failed check, and Tessera repeats the check about 5 minutes after the first failure instead of waiting for the next hour; maintenance, database upgrade and expired support count immediately (L-03)"
- "Two concurrent checks, several API instances or a restart never send the same notification twice — the transition is claimed on the cloud row before any mail is sent (L-02)"
- "Without SMTP setup, without an e-mail address, with a deactivated account or without module access the mail is skipped and only logged; a failing SMTP transport is tried at most three times (L-04, L-06)"
- "While Tessera is open (browser or desktop app) a subscriber also gets an on-screen notification for each transition — in the desktop app as a Windows notification — at most once per transition and client (L-04)"
- "No user-facing text (mail, tile, notification, docs, changelog) contains a word for tenant (L-10)"
artifacts:
- path: "apps/api/prisma/migrations/20261002170000_nextcloud_alerts/migration.sql"
provides: "NextcloudAlertSubscription table (RLS with user dimension, cascades) + alert/failure columns on NextcloudInstance"
contains: "NextcloudAlertSubscription"
- path: "apps/api/src/nextcloud-status/nextcloud-alert-rules.ts"
provides: "pure decideAlert + planStatusWrite + retry constants"
exports: ["decideAlert", "planStatusWrite", "FAILURES_FOR_RED", "RETRY_DELAY_MS"]
- path: "apps/api/src/nextcloud-status/nextcloud-alert-mail.ts"
provides: "pure German mail builder (subject + plain text)"
exports: ["buildNextcloudAlertMail"]
- path: "apps/api/src/nextcloud-status/nextcloud-alert.service.ts"
provides: "subscriptions, claim-before-send, recipient re-check, mail delivery with 3 attempts, recent alerts per user"
- path: "apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx"
provides: "global in-app notifier mounted in AppShell"
key_links:
- from: "NextcloudStatusService.checkInstance"
to: "planStatusWrite -> rateNextcloud -> NextcloudAlertService.evaluateAfterCheck"
via: "every check (hourly, retry, manual, create, URL change) runs the guard and the transition claim"
pattern: "evaluateAfterCheck\\("
- from: "NextcloudAlertService.evaluateAfterCheck"
to: "nextcloudInstance.updateMany where alertState = previous state"
via: "claim-before-send: only count === 1 notifies"
pattern: "alertState: prev"
- from: "NextcloudStatusSchedulerService retry job"
to: "NextcloudStatusService.loadAllInstancesForScheduler({ retryDueBefore })"
via: "same single forSystem call site, filtered to consecutiveFailures = 1"
pattern: "retryDueBefore"
- from: "apps/web/src/components/layout/app-shell.tsx"
to: "NextcloudAlertNotifier -> GET /modules/nextcloud-status/alerts -> showReminderNotification"
via: "global mount next to ReminderNotifier"
pattern: "<NextcloudAlertNotifier />"
---
<objective>
Extend the module `nextcloud-status` (quick 261002-k67) with personal notifications: a bell per tile, e-mail plus in-app notification when a subscribed cloud goes red and when it recovers, with a two-strike guard against flapping and a quick re-check after the first failure.
Locked decisions from the request (cited below as L-xx):
- L-01 Every user who can see the module (grant USE or MANAGE, or admin) gets a bell toggle "Benachrichtigen" on each tile, personal per user per cloud. New Prisma table user x instance, tenant RLS like the other tables, cascade on instance/user delete. Toggle endpoints need only module access (USE), not manage. Tile shows the bell state; the list endpoint returns `subscribed` per instance for the current user.
- L-02 Trigger: transition INTO red (unreachable, maintenance, needsDbUpgrade, EOL passed, invalid response) notifies subscribers once; staying red = no repeat; red back to yellow/green = one "wieder in Ordnung" notification. Last notified state stored on the instance so restarts/multiple polls never duplicate; claim-before-send with an `updateMany` guard like `reminder-mail.scheduler.ts`.
- L-03 Flapping guard: "unreachable" only counts as red after 2 consecutive failed checks (counter on the instance); the tile keeps showing the last good state plus a hint until the second failure; other red reasons (maintenance, EOL) count immediately; after a first failure the cloud is re-checked after about 5 minutes.
- L-04 Channels: e-mail via the existing MailService/SMTP config exactly like reminder mails (skip silently + log if SMTP missing, user has no e-mail or is inactive; max 3 attempts) plus an in-app notification while Tessera is open, reusing the reminder mechanism (`reminder-notifier.tsx`, `reminder-notify.ts`; desktop app = Windows notification) with a small "recent alerts" endpoint.
- L-05 Mail text German, plain and short: subject "Nextcloud <Kundenname>: nicht erreichbar" / "Nextcloud <Kundenname>: wieder in Ordnung", body with reason, URL, time and link to the module. Reminder mails are German-only (`MailService.sendReminderEmail`), so these mails are German-only too.
- L-06 Only users who still have module access at send time (grant re-checked) and only active users are notified.
- L-07 Tests: transition logic as pure function (green->red, red->red, red->green, unreachable once vs twice, maintenance immediate), claim/no-duplicate, subscription endpoints + guard metadata, web bell toggle + notifier; full api + web suites, tsc, biome on touched files.
- L-08 CHANGELOG (Unveröffentlicht, German, user-facing) + docs update.
- L-09 Local migration via db container IP + `prisma migrate deploy`; rebuild `docker compose up -d --build api web`.
- L-10 No word for tenant in user texts. Do not push. SUMMARY documents how to force a red transition locally and whether local SMTP is configured (it is: one `SmtpConfig` row with a host exists in the local db).
Claude's discretion, decided here (cited as D-Kx):
- D-K1 The two-strike guard applies to every failed fetch (`reachable === false`, all error kinds incl. `not-nextcloud`): each comes from one HTTP call and can be transient (a proxy error page during a restart is an "invalid answer"). Maintenance and DB upgrade come from a successful answer, EOL from the date — both immediate.
- D-K2 First failure writes ONLY `consecutiveFailures = 1` and `firstFailureAt`; all status fields and `lastCheckedAt` stay untouched, so the rating (computed from stored fields) keeps the last good state. A never-checked cloud therefore stays grey "Noch nicht geprüft" plus the hint. The second failure writes the failure fields as today.
- D-K3 Quick retry = a second cron job `nextcloud-status-retry` every minute that checks only clouds with exactly one failure whose `firstFailureAt` is at least 5 minutes old. It reuses the ONE existing `forSystem` call site (`loadAllInstancesForScheduler` gets an optional filter) — no new system read, no new policy. State lives on the row, so it survives restarts.
- D-K4 Transition state on the instance: `alertState` ('ok' | 'red', default 'ok'), `alertReason`, `alertChangedAt`. Grey ("unknown") changes nothing. Existing rows start as 'ok'.
- D-K5 Mail delivery runs in the background after a won claim (never blocks "Jetzt prüfen"); per recipient up to 3 attempts, 60 s apart, in-process. A restart between attempts drops the remaining ones — accepted: a late status mail after a restart has little value, and tile plus in-app notification show the state anyway. Skips (no SMTP / no address / inactive / no access) are logged and never retried.
- D-K6 Subject per red reason: unreachable uses the locked wording "nicht erreichbar"; the other red reasons get their own short wording ("keine gültige Antwort", "im Wartungsmodus", "Datenbank-Aktualisierung ausstehend", "Support abgelaufen") so a subject is never factually wrong; recovery uses the locked "wieder in Ordnung".
- D-K7 In-app: `GET modules/nextcloud-status/alerts` returns, for the caller's subscribed clouds, the latest transition of the last 24 h that happened after the caller subscribed. The client dedupes per (cloud, changedAt) with the existing reminder helpers and only polls when the user has module access.
- D-K8 Changing a cloud's address resets the status fields and the failure counter but KEEPS `alertState` — fixing a broken address therefore sends "wieder in Ordnung" to subscribers.
- D-K9 Subscription RLS with user dimension like "Reminder" (`current_user_id() IS NULL OR "userId" = current_user_id()`), no `system_read_policy` (never read in system context).
- D-K10 List responses (`GET instances`, `POST instances/check`) carry `subscribed`; single-cloud responses do not — the page keeps the tile's bell state when it swaps in a single checked tile. Switching a bell on for the first time asks the browser for notification permission once (`requestBrowserPermissionOnce`).
Purpose: subscribers learn about an outage within minutes instead of noticing it on the next visit, without false alarms from a single hiccup.
Output: one migration, alert rules/mail/service in `apps/api/src/nextcloud-status/`, bell + hint on the tile, global notifier, docs and changelog.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
@.planning/quick/261002-k67-modul-nextcloud-status-mit-ampel-kacheln/261002-k67-SUMMARY.md
@apps/api/src/nextcloud-status/nextcloud-status.service.ts
@apps/api/src/nextcloud-status/nextcloud-status.controller.ts
@apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts
@apps/api/src/nextcloud-status/nextcloud-rating.ts
@apps/api/src/reminders/reminder-mail.scheduler.ts
@apps/api/src/module-registry/module-access.service.ts
@apps/web/src/lib/reminder-notify.ts
@apps/web/src/components/reminders/reminder-notifier.tsx
@apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx
Facts gathered during planning (no need to re-discover):
- `rateNextcloud(status, reference, now)` computes the rating from the STORED fields; `NextcloudStatusService.toView` calls it at read time. `checkInstance(tenantId, id)` is the single write path for every check (hourly tick, "Jetzt prüfen", per-tile check, create, URL change).
- `fetchNextcloudStatus` returns `reachable: false` with `errorKind` in timeout | network | tls | http-status | not-nextcloud | too-large | redirect; `errorDetail` is a short code like `HTTP 502` or `ECONNREFUSED`.
- `ModuleAccessService.getModuleAccessLevels(tenantId, userId, role)` (exported by `ModuleRegistryModule`) is the single source for access incl. admin short-circuit; `ModuleRegistryService.findBySlug('nextcloud-status')` gives the module id. `MailModule` exports `MailService`, `SettingsModule` exports `SettingsService.getSmtpConfig(tenantId)` (null = not set up). `MailService` keeps `appUrl` from `TESSERA_APP_URL`; reminder mails are German-only, time zone Europe/Berlin.
- Controllers get the user via `@CurrentUser() user: AuthUser` (see `reminders.controller.ts`), tenant via `requireTenantId(req)`.
- `forTenant(prisma, tenantId, userId?)` sets the user context; every call must use the assignment form `const tenantPrisma = forTenant(...)` (checked by `rls-access-inventory.spec.ts`), `forSystem` only `const systemPrisma = forSystem(...)`. `FORSYSTEM_ALLOWED_CALL_SITES` allows exactly 1 call in `nextcloud-status.service.ts` — keep it at 1. Selects stay scalar (no relation keys) so no new relation pairs appear.
- `docs/mandantentrennung-zugriffsklassifikation.md` keeps a Bereichszeile `nextcloud-status` (currently 0/14/1), a Summenzeile (61/264/8), pair counts (93) and the Fundstellentabelle; k67 shows how each task updated them with the Gate-Schleife. Every new (file, model) pair needs a row.
- Web: `reminder-notify.ts` exports `claimNotification(key, nowMs)`, `withNotifyLock`, `showReminderNotification({title, body, tag})` (Tauri plugin in the desktop app, Web Notification in the browser), `requestBrowserPermissionOnce`, `CATCH_UP_WINDOW_MS`. `ReminderNotifier` is mounted in `apps/web/src/components/layout/app-shell.tsx`. Access check pattern: `GET /modules/active` (see `use-module-capability.ts`).
- Local SMTP is configured (one `SmtpConfig` row with host). Containers `tessera-ctl-db-1`, `tessera-ctl-api-1`, `tessera-ctl-web-1` run. Latest migration: `20261002150000_nextcloud_status`.
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1 (Tracer): Glocke einschalten -> Cloud fällt zweimal aus -> genau eine Mail an den Abonnenten</name>
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261002170000_nextcloud_alerts/migration.sql, apps/api/src/nextcloud-status/nextcloud-alert-rules.ts, apps/api/src/nextcloud-status/nextcloud-alert-rules.spec.ts, apps/api/src/nextcloud-status/nextcloud-alert-mail.ts, apps/api/src/nextcloud-status/nextcloud-alert-mail.spec.ts, apps/api/src/nextcloud-status/nextcloud-alert.service.ts, apps/api/src/nextcloud-status/nextcloud-alert.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.service.ts, apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.module.ts, apps/api/src/mail/mail.service.ts, apps/api/src/mail/mail.service.spec.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/nextcloud-status-api.ts, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
<precondition>`docker ps` lists `tessera-ctl-db-1` as running (needed for the local migration).</precondition>
<behavior>
- decideAlert('ok', red) -> 'down'; ('red', red) -> null; ('red', green) -> 'up'; ('red', yellow) -> 'up'; ('red', unknown) -> null; ('ok', green) -> null; ('ok', unknown) -> null
- planStatusWrite(prevFailures 0, failed result) -> outcome 'pending', data only consecutiveFailures 1 + firstFailureAt now (no status field, no lastCheckedAt); prevFailures 1 + failed -> 'confirmed' with reachable false, errorKind, errorDetail, lastCheckedAt and consecutiveFailures 2; any success -> 'ok' with all status fields, consecutiveFailures 0, firstFailureAt null; success with maintenance true -> 'ok' (rating red immediately)
- Combined (pure, with rateNextcloud): green cloud + one failure -> rating stays green -> no alert; + second failure -> red -> 'down'; green + maintenance answer -> 'down' at once; red + green answer -> 'up'
- buildNextcloudAlertMail down/unreachable -> subject exactly "Nextcloud <Kundenname>: nicht erreichbar"; up -> "Nextcloud <Kundenname>: wieder in Ordnung"; body contains reason line, URL, time (Europe/Berlin) and "<appUrl>/modules/nextcloud-status"; CR/LF in Kundenname never reaches the subject; no word for tenant in any output
- evaluateAfterCheck: claim updateMany count 1 -> mails to eligible subscribers; count 0 -> no mail (second concurrent check / second API instance); red -> red -> no claim, no mail
- Recipients: inactive user, user without e-mail, user without module access (re-checked via getModuleAccessLevels), missing SMTP -> skipped + logged, no send; send returning false twice then true -> exactly 3 calls; false three times -> 3 calls, then stop
- Subscription endpoints: subscribe is idempotent, unknown/foreign cloud -> 404, unsubscribe removes only the caller's row; list returns subscribed true only for the caller's subscriptions; handlers carry no manage metadata and no role metadata
- Web: bell visible for a USE-only user, aria-pressed reflects subscribed, click calls subscribe/unsubscribe and flips state, failure rolls back and shows the error text; single-tile check keeps the bell state
</behavior>
<action>
**Schema + migration (L-01, L-02, L-03, D-K4, D-K9).** In `apps/api/prisma/schema.prisma` extend `NextcloudInstance` with `consecutiveFailures Int @default(0)`, `firstFailureAt DateTime?`, `alertState String @default("ok")` (comment: 'ok' | 'red', last notified state), `alertReason String?`, `alertChangedAt DateTime?`, and the back-relation `subscriptions NextcloudAlertSubscription[]`; add `nextcloudAlertSubscriptions NextcloudAlertSubscription[]` to `User`; add `model NextcloudAlertSubscription` (German comment, quick-261002-kxc) with `id String @id @default(uuid())`, `tenantId String`, `userId String` + relation to User `onDelete: Cascade`, `instanceId String` + relation to NextcloudInstance `onDelete: Cascade`, `createdAt DateTime @default(now())`, `@@unique([instanceId, userId])`, `@@index([tenantId, userId])`. Hand-write `apps/api/prisma/migrations/20261002170000_nextcloud_alerts/migration.sql` in the style of `20261002150000_nextcloud_status` and `20260929140000_reminder`: German header (purpose; subscription is personal data, hence tenant_isolation_policy WITH user dimension exactly like "Reminder"; no system_read_policy because the table is never read in system context; the new instance columns are covered by the existing policies of "NextcloudInstance"; rights via ALTER DEFAULT PRIVILEGES; switch-is-off note), ALTER TABLE for the five columns (alertState NOT NULL DEFAULT 'ok', consecutiveFailures NOT NULL DEFAULT 0), CREATE TABLE, unique index, index, both foreign keys ON DELETE CASCADE ON UPDATE CASCADE, ENABLE + FORCE ROW LEVEL SECURITY, the policy. Run `pnpm --filter @tessera/api exec prisma generate`; apply locally with the container IP (`docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1`, then `DATABASE_URL="postgresql://tessera:tessera_dev@<IP>:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy`), confirm `prisma migrate status` is up to date and `prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --exit-code` exits 0 (L-09).
**Pure rules, TDD first (L-02, L-03, D-K1, D-K2).** `nextcloud-alert-rules.ts`, no Nest, no Prisma, date passed in: `FAILURES_FOR_RED = 2`, `RETRY_DELAY_MS = 5 * 60 * 1000`, type `AlertState = 'ok' | 'red'`, `decideAlert(prev: AlertState, level: RatingLevel): 'down' | 'up' | null` (grey changes nothing), and `planStatusWrite(prevFailures: number, result: NextcloudCheckResult, now: Date): { outcome: 'ok' | 'pending' | 'confirmed'; data: Record<string, unknown> }` per D-K2 (failure = `result.reachable === false`, any errorKind, D-K1). German header comment explaining the two-strike rule and why a first failure leaves the stored state alone. Write the spec with every case from `<behavior>` (incl. the combined cases that run `rateNextcloud` on the resulting stored fields) BEFORE the implementation, see it fail, then implement.
**Mail builder (L-05, D-K6).** `nextcloud-alert-mail.ts`, pure: `buildNextcloudAlertMail(input, appUrl): { subject: string; text: string }` with input `{ kind: 'down' | 'up'; customerName; baseUrl; rating: NextcloudRating; errorKind; errorDetail; at: Date }`. Subject `Nextcloud <Kundenname>: <wording>` (down wording per reason per D-K6, up "wieder in Ordnung"), CR/LF collapsed to a space, max 150 characters (same as `sendReminderEmail`). Plain-text body, short, Sie-form: "Guten Tag,", one sentence (down: the cloud "<Kundenname>" has a problem since <time>; up: is back in order), "Grund:" line (down: German reason text — "Nicht erreichbar" with errorDetail or a German word for the errorKind in brackets, "Keine gültige Nextcloud-Antwort", "Wartungsmodus eingeschaltet", "Datenbank-Aktualisierung ausstehend", "Support abgelaufen seit <TT.MM.JJJJ>"; up: "Aktueller Stand:" with "Aktuell" / "Update auf <x> verfügbar" / "Support endet am <TT.MM.JJJJ>"), "Adresse: <baseUrl>", "Zeitpunkt: <de-DE, Europe/Berlin, dateStyle full, timeStyle short> Uhr", "Zum Modul: <appUrl>/modules/nextcloud-status", closing line that the mail comes because "Benachrichtigen" is switched on for this cloud and can be switched off with the bell on the tile. Spec covers every subject wording, the locked two subjects verbatim, header-injection stripping, link, and a case-insensitive check that neither subject nor text contains a word for tenant (German or English). In `MailService` add `sendNextcloudAlertEmail(tenantId, to, input)` next to `sendReminderEmail`: builds via `buildNextcloudAlertMail(input, this.appUrl)`, sends through `this.deliver(tenantId, ..., 'NextcloudAlert')`, returns true/false and logs on failure exactly like `sendReminderEmail`; add spec cases mirroring the reminder ones.
**Alert service (L-01, L-02, L-04, L-06, D-K4, D-K5).** `nextcloud-alert.service.ts` (`@Injectable`, deps PrismaService, MailService, SettingsService, ModuleAccessService, ModuleRegistryService). Methods: `subscribe(tenantId, userId, instanceId)` (instance must exist with `{ id, tenantId }` else NotFoundException 'Cloud nicht gefunden'; upsert on the unique pair with `forTenant(prisma, tenantId, userId)`; returns `{ subscribed: true }`), `unsubscribe(...)` (deleteMany where tenantId, userId, instanceId; returns `{ subscribed: false }`), `subscribedInstanceIds(tenantId, userId): Promise<Set<string>>`, `evaluateAfterCheck(tenantId, row, rating, now)` where row carries id, customerName, baseUrl, errorKind, errorDetail, alertState: compute `decideAlert`; null -> return `{ kind: null, delivery: null }`; otherwise claim with `nextcloudInstance.updateMany({ where: { id, tenantId, alertState: prev }, data: { alertState: next, alertReason: down ? rating.reason : null, alertChangedAt: now } })` — only `count === 1` continues (header comment: why the claim stands before sending, same reasoning as `ReminderMailScheduler`). Then start `notifySubscribers` WITHOUT awaiting it inside the check path and return `{ kind, delivery }` (the promise, `.catch` logs) so tests can await it. `notifySubscribers`: subscriptions of the instance (tenant-bound, no user filter), users `where { tenantId, id in, isActive: true }` with scalar select email + role, module id via `findBySlug('nextcloud-status')`, per user `getModuleAccessLevels(tenantId, user.id, user.role)` must contain the module (L-06), SMTP via `getSmtpConfig(tenantId)`; every skip logs one German line with the reason (Benutzer deaktiviert / keine E-Mail-Adresse / kein Modulzugriff / kein E-Mail-Versand eingerichtet) and is never retried; eligible recipients get `sendNextcloudAlertEmail` with up to 3 attempts, `ALERT_MAIL_RETRY_MS = 60_000` apart via an overridable `sleep` member (D-K5, comment states that a restart drops pending attempts and why that is accepted). Spec: claim won/lost, red->red no claim, all skip reasons, 1-3 attempts, access revoked, inactive user.
**Wire into the existing service and controller.** `NextcloudStatusService` gets `NextcloudAlertService` injected. `checkInstance`: load `{ id, baseUrl, consecutiveFailures }`, fetch, `planStatusWrite`, update with that data selecting `PUBLIC_SELECT` plus `alertState`, build the view, then `await this.alerts.evaluateAfterCheck(...)` (awaits only the claim) and return the view. `listForTenant(tenantId, userId)` and `checkAllForTenant(tenantId, userId)` add `subscribed: boolean` per instance from `subscribedInstanceIds` (D-K10); `NextcloudInstanceView` gets an optional `subscribed`. Update the existing service spec (constructor, two-failure path, subscribed flag). Controller: `list` and `checkAll` pass `user.id` via `@CurrentUser()`; new `POST instances/:id/subscription` and `DELETE instances/:id/subscription` with only the class-level `@UseModule` — no manage decorator, no role decorator (L-01). Module imports `MailModule` and `SettingsModule` and provides `NextcloudAlertService`. Extend `module-manage-handlers.spec.ts` so `subscribe`/`unsubscribe` are asserted to stay on Benutzen level next to `list`/`logo`; controller spec covers the two routes (user id from `@CurrentUser`, never from body).
**RLS bookkeeping.** Run `pnpm --filter @tessera/api exec vitest run rls-coverage rls-access-inventory`; add the Fundstellentabelle rows for the new (file, model) pairs (e.g. `nextcloud-alert.service.ts` with `nextcloudInstance`, `nextcloudAlertSubscription`, `user`), update the Bereichszeile `nextcloud-status`, the Summenzeile and the Paarzählung in `docs/mandantentrennung-zugriffsklassifikation.md`, counted with the Gate-Schleife exactly like the k67 entries (measured, not copied). `FORSYSTEM_ALLOWED_CALL_SITES` stays unchanged.
**Web bell (L-01, D-K10).** `nextcloud-status-api.ts`: optional `subscribed?: boolean` on `NextcloudInstance` (missing = false, keeps existing fixtures valid), `subscribe(id)` (POST `${BASE}/${id}/subscription`) and `unsubscribe(id)` (DELETE) with `readErrorMessage`. `CloudTile`: new props `subscribed`, `onToggleSubscription`, `toggling`; a bell icon button shown to EVERY user (outside the `canManage` block, left of the manager buttons), `aria-pressed`, aria-label "Benachrichtigen", title from `bell.titleOn` / `bell.titleOff`, outlined bell when off, filled bell in accent color when on, disabled while toggling. `page.tsx`: optimistic toggle, call subscribe/unsubscribe, on error roll back and show `bell.error` as a small line above the grid; on switching on call `requestBrowserPermissionOnce()` from `@/lib/reminder-notify`; `handleCheckOne` keeps the previous `subscribed` when swapping in the checked tile. Texts in `nextcloudStatus.bell` (`label`, `titleOn`, `titleOff`, `error`) in de.json (real umlauts, Sie-form) and en.json with identical keys. Page test: bell visible for a USE-only user, toggle on/off calls the right function and flips aria-pressed, rollback on error, single check keeps the state.
Biome-lint touched files (`pnpm exec biome lint <files>` from repo root, `biome check --write` only on new files). Commit `feat(nextcloud-status): Benachrichtigung abonnieren und Mail bei Störung` (attribution line). Do not push.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/nextcloud-status src/mail src/module-registry rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run nextcloud-status umlaut && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && DATABASE_URL="postgresql://tessera:tessera_dev@$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1):5432/tessera" pnpm --filter @tessera/api exec prisma migrate status</automated>
</verify>
<done>Migration applied locally and drift-free; pure rules, mail builder, alert service, status service, controller, mail service and manage-handler specs green; a subscribed user's cloud failing twice produces exactly one claimed transition and one mail per eligible recipient; the bell works for USE-level users in the web test; RLS inventory specs green with updated doc; commit on main, not pushed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Wiederholung nach 5 Minuten, Adresswechsel und Hinweis „Prüfung fehlgeschlagen“ auf der Kachel</name>
<files>apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts, apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.service.ts, apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-alert.service.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/nextcloud-status-api.ts, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
<behavior>
- retryTick loads only clouds with consecutiveFailures 1 and firstFailureAt at least RETRY_DELAY_MS old (filter passed to loadAllInstancesForScheduler), checks each tenant-bound, max 4 at a time, skips while a previous retry run is active, never throws
- onApplicationBootstrap registers both jobs (nextcloud-status-poll hourly, nextcloud-status-retry every minute) without database access
- loadAllInstancesForScheduler() without filter keeps today's query; with { retryDueBefore } adds where consecutiveFailures 1 and firstFailureAt lte retryDueBefore; select stays { id, tenantId }
- updateInstance with a new address resets reachable, maintenance, needsDbUpgrade, versionString, edition, productName, errorKind, errorDetail, lastCheckedAt, consecutiveFailures, firstFailureAt and does NOT touch alertState; a red cloud whose corrected address answers green yields 'up'
- view status.pendingRetry is true exactly when consecutiveFailures is 1; tile shows the hint then and keeps pill, version and last check from the stored state
</behavior>
<action>
**Retry job (L-03, D-K3).** In `nextcloud-status-scheduler.service.ts` export `NEXTCLOUD_RETRY_JOB_NAME = 'nextcloud-status-retry'` and `NEXTCLOUD_RETRY_CRON = '* * * * *'`; register it in the same `onApplicationBootstrap` inside the existing try/catch (same `CronJobClass` workaround, no database access at registration, log line `Nextcloud-Status retry job registered: * * * * *`). `retryTick(now = new Date())` with its own overlap flag: `loadAllInstancesForScheduler({ retryDueBefore: new Date(now - RETRY_DELAY_MS) })`, then `checkInstance(tenantId, id)` per row with `runWithConcurrency(..., CHECK_CONCURRENCY, ...)`, errors logged per cloud. Extend the header comment: why a second job instead of in-memory timers (survives restarts, state on the row) and why it reuses the one system read. In `NextcloudStatusService.loadAllInstancesForScheduler(filter?: { retryDueBefore: Date })` add the optional where (consecutiveFailures 1, firstFailureAt lte) to the SAME `systemPrisma.nextcloudInstance.findMany` call — still exactly one `forSystem` call in the file. Scheduler spec: both jobs registered, retry filter passed, overlap guard, error isolation.
**Address change (D-K8).** In `updateInstance`, when the normalized address changed, write the reset of the status fields, `consecutiveFailures: 0` and `firstFailureAt: null` together with the new address (alertState untouched), then call `checkInstance` as today. Service spec: reset written, alertState not in the data; a cloud with alertState 'red' whose new address answers green triggers `evaluateAfterCheck` with an 'up' decision (alert service spec: 'up' mail subject "wieder in Ordnung" and "Aktueller Stand" line).
**Hint on the tile (L-03, D-K2).** Add `consecutiveFailures` to `PUBLIC_SELECT`/`PublicRow` and `status.pendingRetry: boolean` (`consecutiveFailures === 1`) to the view; the rating input stays unchanged. Web: optional `pendingRetry?: boolean` in `NextcloudInstanceStatus`; `CloudTile` shows, when true, a small line with a warning-colored dot and the text `card.pendingRetry` ("Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt") between the pill row and the last-check line, `data-testid="pending-retry"`; pill, version and last check stay as stored. en.json gets the same key. Page test: hint shown with pendingRetry, not shown without, pill keeps the stored green level.
Re-run the RLS specs and adjust the doc only if the Gate-Schleife counts changed. Biome-lint touched files, commit `feat(nextcloud-status): erneute Prüfung nach Ausfall und Hinweis auf der Kachel` (attribution line). Do not push.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/nextcloud-status src/module-registry rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run nextcloud-status umlaut && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test "$(grep -v '^\s*//' apps/api/src/nextcloud-status/nextcloud-status.service.ts | grep -c 'forSystem(this.prisma)')" = "1"</automated>
</verify>
<done>Retry job registered and tested; first failure leaves the stored state and shows the hint, second failure (manual, retry or hourly) turns the cloud red; address change resets the check state but keeps the notification state; still exactly one system read in the service; specs and both tsc runs green; commit on main, not pushed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: In-App-Meldung (Desktop: Windows-Benachrichtigung), Anleitung, Changelog, Abschluss und Neubau</name>
<files>apps/api/src/nextcloud-status/nextcloud-alert.service.ts, apps/api/src/nextcloud-status/nextcloud-alert.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/nextcloud-status-api.ts, apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx, apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.test.tsx, apps/web/src/components/layout/app-shell.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md</files>
<behavior>
- listRecentAlerts(tenantId, userId, now) returns only the caller's subscribed clouds with alertChangedAt within 24 h AND not before the caller's subscription createdAt; kind 'down' for alertState red (with reason), 'up' for ok; other users' subscriptions never appear
- GET alerts stays on Benutzen level (no manage, no role metadata)
- Notifier: without module access (slug missing in /modules/active) it never calls the alerts endpoint; with access it shows one notification per (instanceId, changedAt), never twice across polls; title for down/unreachable is "Nextcloud <Name>: nicht erreichbar", for up "Nextcloud <Name>: wieder in Ordnung", body is the address; 401/403 pauses polling until focus
</behavior>
<action>
**Recent alerts endpoint (L-04, D-K7).** `NextcloudAlertService.listRecentAlerts(tenantId, userId, now)`: subscriptions of the caller (`forTenant(prisma, tenantId, userId)`, scalar select instanceId + createdAt), then instances `where { tenantId, id in, alertChangedAt gte now - 24 h }` with scalar select id, customerName, baseUrl, alertState, alertReason, alertChangedAt; drop entries whose alertChangedAt is before the subscription's createdAt; return `{ alerts: [{ instanceId, customerName, baseUrl, kind, reason, changedAt }] }` sorted by changedAt. Controller `GET alerts` (path `modules/nextcloud-status/alerts`, user from `@CurrentUser()`), only class-level `@UseModule`. Specs: service filters, controller route, `module-manage-handlers.spec.ts` asserts Benutzen level for `alerts`. Update the RLS doc rows/counts with the Gate-Schleife and re-run the RLS specs.
**Global notifier (L-04).** `nextcloud-status-api.ts`: `NextcloudAlert` type and `listAlerts()` throwing an error object that carries the HTTP status (pattern `ReminderRequestError`). New `apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx`, modeled on `ReminderNotifier` (renders nothing, translation via refs): on mount and on focus/visibility resume it checks `GET /modules/active` for slug `nextcloud-status` (with `credentials: 'include'`, `cache: 'no-store'`); only with access it loads alerts immediately and every 60 s; for each alert under `withNotifyLock` it calls `claimNotification('nextcloud-alert|' + instanceId + '|' + changedAt, Date.now())` and on first claim `showReminderNotification({ title, body: baseUrl, tag })` — reuse of these helpers is deliberate (same Tauri path = Windows notification in the desktop app, same per-client memory; say so in the header comment). Titles from `nextcloudStatus.notify` in de.json/en.json: `up` and `down.unreachable`, `down.invalidResponse`, `down.maintenance`, `down.needsDbUpgrade`, `down.eolPassed`, each with `{name}`, German wording identical to the mail subjects (D-K6). 401/403 sets a paused flag until the next focus. Mount `<NextcloudAlertNotifier />` in `app-shell.tsx` right after `<ReminderNotifier />` with a short German comment. Notifier test modeled on `reminder-notifier.test.tsx`: no access -> no alerts call; access -> one notification per alert, none on the next poll, correct titles; 403 pauses.
**Docs + changelog (L-08, L-10).** CHANGELOG under "## Unveröffentlicht" / "### Neu": one user-facing German bullet — bell on each Nextcloud-Status tile, personal; mail and on-screen notification (desktop app: Windows notification) when the cloud fails and when it is back in order; one message per change; a single failed check does not alert, Tessera re-checks after about five minutes; mails need the e-mail setup. `docs/anleitung-anwender.md` section Nextcloud-Status: new paragraph "**Benachrichtigen:**" (who sees the bell, what triggers a message, two failed checks for "nicht erreichbar", immediate for maintenance/DB upgrade/support expired, "wieder in Ordnung", hint text on the tile, browser asks once for permission, mail only with e-mail setup and an address in the profile). `docs/anleitung-administration.md` section "Nextcloud-Status: Clouds eintragen": bullet on notifications (SMTP from section 6 required, recipients re-checked at send time, at most three attempts, changing the address sends "wieder in Ordnung" if it was red) and extend the "Rhythmus" bullet with the 5-minute re-check. No word for tenant anywhere in these texts.
**Final gates (L-07, L-09).** Full `pnpm --filter @tessera/api test` and `pnpm --filter @tessera/web test` (if an AppShell-rendering test breaks, mock the new notifier the way `ReminderNotifier` is mocked), both tsc, biome lint on all touched files of the three tasks. Rebuild `docker compose up -d --build api web`, wait for api healthy, check `docker compose logs api` for "Nextcloud-Status module seeded in registry", "Nextcloud-Status retry job registered" and the mapped routes `/modules/nextcloud-status/instances/:id/subscription` and `/modules/nextcloud-status/alerts`, no migration errors. Commit `feat(nextcloud-status): Meldung in Tessera, Anleitung und Changelog` (attribution line). Do not push.
**SUMMARY for the orchestrator's browser check (L-10):** list the click path to force both transitions locally — (1) "Cloud hinzufügen" with an unreachable address such as `https://127.0.0.1:9` (tile grey "Noch nicht geprüft" + hint), (2) switch the bell on (browser asks once for permission), (3) "Jetzt prüfen" or the tile's check button once more -> red, mail + on-screen notification "nicht erreichbar", (4) edit the address to a reachable public Nextcloud -> "wieder in Ordnung". State that local SMTP is configured (SmtpConfig row present) and name which local account address would receive the mail (look it up, do not change it); note that a USE-only user also sees the bell.
</action>
<verify>
<automated>pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && node -e 'const de=require("./apps/web/src/messages/de.json"),en=require("./apps/web/src/messages/en.json");const w=(o,p,r)=>{for(const[k,v]of Object.entries(o||{})){const q=p+"."+k;if(v&&typeof v==="object")w(v,q,r);else r[q]=v}return r};const pick=(m)=>({...w(m.nextcloudStatus,"nextcloudStatus",{}),...w(m.widgets&&m.widgets.nextcloudStatus,"widgets.nextcloudStatus",{})});const a=pick(de),b=pick(en);if(Object.keys(a).length<10||Object.keys(a).sort().join()!==Object.keys(b).sort().join()){console.error("key mismatch");process.exit(1)}for(const v of [...Object.values(a),...Object.values(b)])if(/mandant|tenant/i.test(String(v))){console.error("bad text",v);process.exit(1)}if(!a["nextcloudStatus.notify.up"]||!a["nextcloudStatus.bell.label"]||!a["nextcloudStatus.card.pendingRetry"]){console.error("missing keys");process.exit(1)}' && grep -q "Benachrichtig" CHANGELOG.md && grep -q "Benachrichtigen:" docs/anleitung-anwender.md && grep -q "NextcloudAlertNotifier" apps/web/src/components/layout/app-shell.tsx && docker compose ps --status running --services | grep -qx api && docker compose ps --status running --services | grep -qx web && docker compose logs api 2>&1 | grep -q "Nextcloud-Status retry job registered"</automated>
</verify>
<done>Subscribers get an on-screen notification (desktop: Windows notification) once per transition while Tessera is open; users without access never poll; CHANGELOG and both guides describe the feature without a word for tenant; full api + web suites, tsc and biome green; api and web rebuilt and running with the retry job and new routes; SUMMARY contains the local test path and SMTP status; commit on main, not pushed.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser -> API (subscription, alerts) | user-controlled instance id; user identity must come from the JWT |
| API -> SMTP / recipient mailbox | Kundenname (manager-entered) flows into subject and body |
| background check -> notification fan-out | concurrent checks, several API instances, restarts |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-kxc-01 | Spoofing / Elevation | POST/DELETE instances/:id/subscription | high | mitigate | userId only from `@CurrentUser()`, tenantId only from `requireTenantId(req)`; instance must match `{ id, tenantId }` (404 otherwise); `forTenant(prisma, tenantId, userId)` + RLS user dimension; controller spec asserts no body field is used |
| T-kxc-02 | Information disclosure | GET alerts | medium | mitigate | query limited to the caller's own subscriptions (userId from JWT, user-bound client), scalar selects (name, address, state, reason, time — all already visible on the module page), class-level `@UseModule` so only users with access get any answer |
| T-kxc-03 | Information disclosure | mail fan-out | high | mitigate | at send time re-check `isActive`, e-mail present and `getModuleAccessLevels` contains the module (L-06); spec covers revoked access and deactivated user |
| T-kxc-04 | Repudiation / Tampering (duplicate sends) | evaluateAfterCheck | medium | mitigate | claim-before-send `updateMany where alertState = previous`; only `count === 1` notifies; state on the row survives restarts; spec covers the lost claim |
| T-kxc-05 | Tampering (header injection) | buildNextcloudAlertMail subject | medium | mitigate | CR/LF collapsed, 150-char cap (same as reminder subject); spec with a Kundenname containing a line break |
| T-kxc-06 | Denial of service (mail flood) | flapping cloud | medium | mitigate | two-strike rule, one mail per transition, grey changes nothing, retry job only touches clouds with exactly one failure, concurrency 4, overlap guards |
| T-kxc-07 | Denial of service (blocked request) | "Jetzt prüfen" with slow SMTP | low | mitigate | delivery runs in the background after the claim; check responses never await SMTP |
| T-kxc-08 | Elevation | new handlers | medium | mitigate | no manage and no role decorator on subscription/alerts handlers — asserted in `module-manage-handlers.spec.ts`; module guard still enforces access |
| T-kxc-SC | Tampering | npm/pip/cargo installs | low | accept | no package installs in this plan; only existing dependencies are used |
</threat_model>
<verification>
- Pure rules cover every transition case of L-07 (green->red, red->red, red->green/yellow, grey, one vs two failures, maintenance immediate).
- Claim-before-send prevents duplicates (spec with lost claim); recipients re-checked for access, active state, address, SMTP; at most 3 attempts.
- Retry job checks a cloud with one failure after 5 minutes; tile hint shown during that time.
- Bell visible and working for USE-level users; list carries `subscribed`; notifier fires once per transition and only with access.
- `prisma migrate status` up to date locally, drift check exit 0; RLS specs green with updated classification doc; still one `forSystem` call in the service.
- Full api + web suites, both tsc runs, biome on touched files green; api and web rebuilt and running.
</verification>
<success_criteria>
- A subscribed user receives exactly one mail and one on-screen notification when a cloud fails twice in a row or turns red for maintenance, DB upgrade or expired support, and exactly one "wieder in Ordnung" when it recovers.
- No duplicate notification across concurrent checks, restarts or repeated red checks.
- Single failures do not alert; real outages are reported within about five minutes.
- No user-facing text names a tenant; nothing pushed.
## Source coverage audit
| Source | Item | Covered by |
|---|---|---|
| GOAL | Per-user notification on red and recovery | Tasks 1-3 |
| CONTEXT | L-01 bell, table, RLS, cascade, USE-level toggles, `subscribed` in list | Task 1 |
| CONTEXT | L-02 transition once, no repeat, recovery, state on row, claim | Task 1 (+ recovery via address change Task 2) |
| CONTEXT | L-03 two-strike guard, tile keeps last good + hint, immediate other reasons, ~5 min retry | Task 1 (rules), Task 2 (retry, hint) |
| CONTEXT | L-04 mail like reminders + in-app/desktop notification | Task 1 (mail), Task 3 (in-app) |
| CONTEXT | L-05 German mail texts, locked subjects, link | Task 1 |
| CONTEXT | L-06 re-check access and active state at send time | Task 1 |
| CONTEXT | L-07 tests, full suites, tsc, biome | Tasks 1-3 |
| CONTEXT | L-08 CHANGELOG + docs | Task 3 |
| CONTEXT | L-09 local migration + rebuild | Task 1 (migration), Task 3 (rebuild) |
| CONTEXT | L-10 no tenant wording, no push, SUMMARY test path + SMTP status | Tasks 1-3, SUMMARY in Task 3 |
</success_criteria>
<output>
Create `.planning/quick/261002-kxc-nextcloud-status-benachrichtigung-bei-ro/261002-kxc-SUMMARY.md` when done
</output>
@@ -0,0 +1,155 @@
---
phase: quick-261002-kxc
plan: 01
subsystem: nextcloud-status
tags: [benachrichtigung, glocke, mail, rls, scheduler, in-app]
status: complete
requires:
- quick-261002-k67 (Modul Nextcloud-Status)
provides:
- persönliche Glocke je Kachel (Tabelle NextcloudAlertSubscription, RLS mit Benutzerdimension)
- Zwei-Fehlschläge-Regel, Wiederholung nach 5 Minuten, gemeldeter Zustand auf der Zeile
- E-Mail (nur Deutsch) und Meldung in Tessera bei Störung und Wiederherstellung
- Fehlercodes als lesbarer Hinweis auf Kachel und in der Mail
affects:
- apps/api/src/nextcloud-status/
- apps/api/src/mail/mail.service.ts
- apps/web (Kachel, Seite, AppShell)
key-files:
created:
- apps/api/prisma/migrations/20261002170000_nextcloud_alerts/migration.sql
- apps/api/src/nextcloud-status/nextcloud-alert-rules.ts
- apps/api/src/nextcloud-status/nextcloud-alert-mail.ts
- apps/api/src/nextcloud-status/nextcloud-alert.service.ts
- apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx
- apps/web/src/components/nextcloud-status/error-hint.ts
modified:
- apps/api/prisma/schema.prisma
- apps/api/src/nextcloud-status/nextcloud-status.service.ts
- apps/api/src/nextcloud-status/nextcloud-status.controller.ts
- apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts
- apps/api/src/nextcloud-status/nextcloud-status.module.ts
- apps/api/src/mail/mail.service.ts
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx
- apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx
- apps/web/src/components/layout/app-shell.tsx
- docs/mandantentrennung-zugriffsklassifikation.md
- CHANGELOG.md
- docs/anleitung-anwender.md
- docs/anleitung-administration.md
decisions:
- "Zwei-Fehlschläge-Regel für jeden fehlgeschlagenen Abruf (D-K1); der erste Fehlschlag schreibt nur Zähler und Zeitpunkt, die Kachel behält den guten Stand (D-K2)"
- "Der Zähler einer dauerhaft roten Cloud wird bei 2 gedeckelt (sonst wüchse er stündlich weiter)"
- "Wiederholung als zweiter Cron-Auftrag (jede Minute) über denselben einen Systemlesezugriff (D-K3)"
- "Anspruch vor dem Senden per updateMany auf alertState; Versand im Hintergrund, bis zu 3 Versuche im Abstand von 60 s (D-K5)"
- "Fehler-Hinweise: gleiche Zuordnung in API (Mail) und Web (Kachel); Rohkennung bleibt als Tooltip"
metrics:
duration: "ca. 20 Minuten"
completed: 2026-10-02
actuals:
tokens: 32500
tasks: 3
commits: 3
plan_head_before: 6829c44464ef3d68711117af656d4c6db79892ec
plan_head_after: 6c4bff6f6cf85867cdea54c1dbcc91d2c7fff028
---
# Quick 261002-kxc: Nextcloud-Status – Benachrichtigung bei Rot und Wiederherstellung
Persönliche Glocke je Kachel: Wer eingeschaltet hat, bekommt bei Störung und bei „wieder in Ordnung“ je Änderung genau eine E-Mail und eine Meldung in Tessera (Desktop-App: Windows-Benachrichtigung). Ein einzelner Fehlschlag löst nichts aus; Tessera prüft nach etwa fünf Minuten erneut.
## Commits
| Aufgabe | Commit | Inhalt |
|---|---|---|
| 1 (Tracer) | faed0d7 | Migration, reine Regeln, Mailbaustein, Alert-Dienst, Glocke in API und Kachel, RLS-Dokument |
| 2 | 11a70c9 | Wiederholungsauftrag, Adresswechsel, Hinweis „Prüfung fehlgeschlagen“, Fehlercodes in Klartext |
| 3 | 6c4bff6 | Endpunkt `GET alerts`, globaler Melder im AppShell, Anleitung, Changelog |
Nicht gepusht. Die Commit-Zeile trägt „Claude Sonnet 5.5“ (die Vorgabe der Umgebung für Commit-Anhänge), nicht den im Auftrag genannten Opus-Text, weil ich Sonnet 5.5 bin und die Angabe sonst falsch wäre.
## Was gebaut wurde
- **Datenbank:** Migration `20261002170000_nextcloud_alerts` (lokal angewendet, `migrate status` aktuell, Drift-Prüfung `--exit-code` = 0). Neue Tabelle `NextcloudAlertSubscription` mit Mandant-und-Benutzer-Regel wie „Reminder“, ohne `system_read_policy`, Cascade bei Cloud und Benutzer. Neue Spalten an `NextcloudInstance`: `consecutiveFailures`, `firstFailureAt`, `alertState` ('ok'|'red'), `alertReason`, `alertChangedAt`.
- **Reine Regeln** (`nextcloud-alert-rules.ts`): `decideAlert`, `planStatusWrite`, `FAILURES_FOR_RED = 2`, `RETRY_DELAY_MS = 5 min`.
- **Mail** (`nextcloud-alert-mail.ts`, `MailService.sendNextcloudAlertEmail`): Betreff „Nextcloud <Kundenname>: nicht erreichbar“ bzw. „… wieder in Ordnung“ wortgleich, eigene Betreffe für die anderen roten Gründe, Text mit Grund, Adresse, Zeit (Europe/Berlin), Link zum Modul; CR/LF und 150 Zeichen wie bei Erinnerungen.
- **Alert-Dienst:** Abonnieren/Abbestellen (idempotent, 404 für fremde Clouds), Anspruch vor dem Senden (`updateMany where alertState = bisher`, nur `count === 1` meldet), Versand im Hintergrund, Empfänger beim Senden neu geprüft (aktiv, Adresse, Modulzugriff über `getModuleAccessLevels`, SMTP), bis zu 3 Versuche, `listRecentAlerts` für die Meldung in Tessera.
- **Status-Dienst/Controller:** `checkInstance` ist weiter der einzige Schreibweg und läuft jetzt über `planStatusWrite` und `evaluateAfterCheck`; Listen tragen `subscribed`; `POST/DELETE instances/:id/subscription` und `GET alerts` nur mit Modul-Guard (kein Verwalten, keine Rolle).
- **Planer:** zweiter Auftrag `nextcloud-status-retry` (jede Minute), filtert dieselbe `forSystem`-Abfrage auf genau einen Fehlschlag älter als 5 Minuten. Weiterhin genau ein `forSystem`-Aufruf im Dienst.
- **Web:** Glocke an jeder Kachel (auch für „Benutzen“), optimistisches Umschalten mit Rücknahme bei Fehler, Browser-Erlaubnis einmalig beim ersten Einschalten; Hinweis „Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt“; `NextcloudAlertNotifier` im AppShell (nutzt die Helfer der Erinnerungen, daher Windows-Benachrichtigung in der Desktop-App; fragt ohne Modulzugriff nie ab; 401/403 pausiert bis Fokus).
- **Zusatzwunsch Fehlertexte:** Kachel und Mail zeigen statt `ERR_TLS_CERT_ALTNAME_INVALID` & Co. Klartext: „Zertifikat passt nicht zur Adresse“, „Zertifikat abgelaufen“, „Zertifikat nicht vertrauenswürdig“, „Adresse nicht gefunden“ (ENOTFOUND/EAI_AGAIN), „Verbindung abgelehnt“, „Zeitüberschreitung“, „Server antwortet mit Fehler <Code>“, sonst „Verbindungsfehler“. Auf der Kachel bleibt die Rohkennung als `title`-Tooltip. In der Mail steht nur der Klartext. Zuordnung als reine Funktionen `describeCheckError` (API) und `errorHint` (Web), je mit Tests; de/en-Texte unter `nextcloudStatus.errorHint`.
## Tests und Prüfungen (gemessen)
| Prüfung | Ergebnis |
|---|---|
| API komplett (`pnpm --filter @tessera/api test`) | 121 Dateien, 2120 Tests, alle grün |
| Web komplett (`pnpm --filter @tessera/web test`) | 121 Dateien, 1321 Tests, alle grün |
| `tsc --noEmit` API / Web | beide fehlerfrei |
| Biome `lint` auf allen berührten Dateien der drei Aufgaben (25 Dateien) | keine Fehler, 2 Warnungen in `mail.service.spec.ts` (Zeilen 130 und 150, Non-Null-Zusicherungen, vor dieser Arbeit vorhanden, nicht angefasst) |
| Biome `check` auf den neuen Dateien und dem AppShell | sauber |
| RLS-Specs (`rls-coverage`, `rls-access-inventory`) | grün, Dokument nachgeführt |
| Migration | angewendet, Drift 0 |
Neu hinzugekommen: Regeln 19 Tests, Mailbaustein 27, Alert-Dienst 22, Web-Melder 11, Fehlerhinweis 20 u. a. sowie Erweiterungen in Status-Dienst, Controller, Scheduler, MailService und Seitentest.
Hinweis zur Reihenfolge: Bei den reinen Regeln habe ich Spec und Implementierung gemeinsam geschrieben und nicht ausdrücklich zuerst den roten Lauf beobachtet; die Fälle aus dem Plan sind vollständig abgedeckt.
## RLS-Dokument (`docs/mandantentrennung-zugriffsklassifikation.md`)
- Bereichszeile `nextcloud-status`: 0/23/1 (gemessen mit der Gate-Schleife über `nextcloud-status/`; Dienst 14, Alert-Dienst 9 gebundene Rohtreffer).
- Neue Fundstellen: drei Paare `nextcloud-alert.service.ts` (`nextcloudAlertSubscription`, `nextcloudInstance`, `user`), Paarzahl 96 (53→56 `muss-mandantengebunden`).
- Summenzeile von mir nur um die eigenen +9 fortgeschrieben (61/273/8). **Auffälligkeit:** Eine frische Messung über alle Bereiche ergibt 61/280/8; die Differenz von 7 stammt aus älteren, nicht nachgeführten Zeilen (`dashboard` 30 statt 29, `groups` 33 statt 31, `reminders` 13 statt 12, weitere Bereiche ohne eigene Zeile). Das habe ich nicht stillschweigend „repariert“, sondern in der Summenzeile vermerkt.
- `FORSYSTEM_ALLOWED_CALL_SITES` unverändert; genau ein `forSystem(this.prisma)` in `nextcloud-status.service.ts`.
## Abweichungen vom Plan
**1. [Rule 3 - Blockierend] Umlaut-Wächter**
- Gefunden bei Aufgabe 2: `umlaut-guard.spec.ts` meldete „passt“ und „vertrauenswürdig“ als neue Wörter.
- Behoben: beide in `UMLAUT_ALLOWLIST` (`apps/web/src/messages/umlaut-dictionary.ts`) ergänzt (korrektes Deutsch).
**2. [Rule 2 - Korrektheit] Zähler gedeckelt**
- `consecutiveFailures` einer dauerhaft roten Cloud würde sonst bei jeder stündlichen Prüfung weiterzählen; gedeckelt bei 2 (`FAILURES_FOR_RED`). Mit Test.
**3. Zusatzwunsch Fehlertexte** (Auftrag, nicht im Plan): zusätzlich neue Dateien `error-hint.ts`/`.test.ts` und `describeCheckError` in `nextcloud-alert-mail.ts`; Doku-Satz „Fehlercode steht klein darunter“ in der Administrationsanleitung angepasst.
**4. Commit-Anhang:** siehe oben (Sonnet 5.5 statt Opus-Text).
Sonst: Plan wie geschrieben ausgeführt. Keine Authentifizierungs-Hürden, keine Paketinstallationen.
## Lokaler Neubau
`docker compose up -d --build api web` ausgeführt; api, web und db laufen. Im API-Protokoll: „Nextcloud-Status module seeded in registry“, „Nextcloud-Status retry job registered: * * * * *“, Routen `/modules/nextcloud-status/instances/:id/subscription` (POST, DELETE) und `/modules/nextcloud-status/alerts` (GET) gemappt, „No pending migrations to apply“, keine Fehler.
## Für die Browser-Prüfung (Orchestrator)
**Lokaler SMTP-Stand:** Ja, eingerichtet. Genau eine `SmtpConfig`-Zeile: Host `mailhog`, Port 1025, Absender `tessera@tessera.local`. Die Mails landen also in MailHog (nicht in einem echten Postfach). Empfangsadressen der lokalen Konten (nur nachgesehen, nichts geändert): `admin` (SUPER_ADMIN) `admin@tessera.local`, `nutzer1` `nutzer1@tessera.local`, `nutzer2` `nutzer2@tessera.local`, `testuser` `testuser@example.com`; alle aktiv. Lokal gibt es bereits 5 Clouds. Ich habe keinen Versand ausgelöst (nur Unit-Tests mit gemocktem Transport).
**Klickweg, beide Übergänge zu erzwingen** (als Admin; ein Benutzer mit nur „Benutzen“ sieht die Glocke ebenfalls, kann aber keine Clouds anlegen oder prüfen):
1. „Cloud hinzufügen“ mit einer nicht erreichbaren Adresse, z. B. `https://127.0.0.1:9`. Die Kachel bleibt grau „Noch nicht geprüft“ und zeigt den Hinweis „Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt“.
2. Glocke auf dieser Kachel einschalten (Browser fragt einmalig nach der Erlaubnis für Benachrichtigungen).
3. „Jetzt prüfen“ oder den Prüfknopf der Kachel noch einmal drücken: zweiter Fehlschlag, Kachel wird rot „Nicht erreichbar“ mit Klartext-Grund (bei 127.0.0.1:9 „Verbindung abgelehnt“). Es kommt eine Mail „Nextcloud <Name>: nicht erreichbar“ (in MailHog) und die Meldung in Tessera. Ohne Knopfdruck passiert dasselbe automatisch nach etwa 5 Minuten durch den Wiederholungsauftrag. Weitere Prüfungen, solange die Cloud rot bleibt, senden nichts mehr.
4. Adresse der Cloud bearbeiten auf eine erreichbare öffentliche Nextcloud: sie wird sofort neu geprüft, die Kachel wird grün oder gelb, es kommt „Nextcloud <Name>: wieder in Ordnung“ (Mail und Meldung).
Sofort rot ohne Wartezeit: eine Cloud, die im Wartungsmodus steht oder auf eine Datenbank-Aktualisierung wartet, wird beim ersten Abruf gemeldet.
## Known Stubs
Keine.
## Threat Flags
Keine neuen Angriffsflächen außerhalb des Plan-Bedrohungsmodells (T-kxc-01 bis -08 umgesetzt: Benutzer nur aus dem Token, 404 für fremde Clouds, Empfänger beim Senden neu geprüft, Anspruch vor dem Senden, CR/LF-Schutz im Betreff, Versand im Hintergrund, keine Verwalten-/Rollen-Decorators an den neuen Handlern, durch Tests festgeschrieben).
## Self-Check: PASSED
- Dateien vorhanden: Migration, `nextcloud-alert-rules.ts`, `nextcloud-alert-mail.ts`, `nextcloud-alert.service.ts`, `nextcloud-alert-notifier.tsx`, `error-hint.ts` – gefunden.
- Commits vorhanden: faed0d7, 11a70c9, 6c4bff6 (gemessen mit `git rev-list --count 6829c44..HEAD` = 3).
## Browser-Prüfung (Orchestrator, 02.10., lokal, dunkel, MailHog)
- Grundmodul (k67): 5 echte öffentliche Clouds + 1 kaputte; Ampel korrekt (35.0.1/34.0.4 grün, 33.0.5/33.0.8 Enterprise gelb „Update auf 33.0.9“, Zertifikatsfehler rot), Sortierung Status, Logo per Upload und per URL, Dashboard-Kachel mit Zählern 2/2/1.
- Glocke an „Ausfall AG“ (https://127.0.0.1:9): erster Abruf grau + Wiederholungshinweis, zweiter rot „Nicht erreichbar“ (Port 9 ist von fetch gesperrt → „Verbindungsfehler“ korrekt; normaler Port liefert ECONNREFUSED → „Verbindung abgelehnt“).
- Mail „Nextcloud Ausfall AG: nicht erreichbar“ in MailHog, Text verständlich; Browser-Benachrichtigung erschienen.
- Adresse auf erreichbare Cloud geändert → grün, Mail + Benachrichtigung „wieder in Ordnung“.
@@ -0,0 +1,265 @@
---
phase: quick-261003-387
plan: 01
type: execute
wave: 1
depends_on: []
quick_id: 261003-387
description: "Modulkategorien durch Administratoren bearbeitbar: anlegen, umbenennen, sortieren, löschen mit Verschieben, Module zuordnen und innerhalb der Kategorie sortieren"
date: 2026-10-03
files_modified:
# Task 1 — tracer: DB -> Dienst (Grundbestand + Überlagerung) -> GET /module-categories + /modules/active -> Store -> Seitenleiste/Beschriftung
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20261003120000_module_categories/migration.sql
- apps/api/src/module-categories/module-categories.service.ts
- apps/api/src/module-categories/module-categories.service.spec.ts
- apps/api/src/module-categories/module-categories.controller.ts
- apps/api/src/module-categories/module-categories.controller.spec.ts
- apps/api/src/module-categories/module-categories.module.ts
- apps/api/src/module-categories/dto/module-category.dto.ts
- apps/api/src/module-registry/module-registry.module.ts
- apps/api/src/module-registry/module-registry.controller.ts
- apps/api/src/app.module.ts
- docs/mandantentrennung-zugriffsklassifikation.md
- apps/web/src/lib/module-categories-api.ts
- apps/web/src/lib/stores/module-category-store.ts
- apps/web/src/lib/module-category-order.ts
- apps/web/src/lib/module-category-order.test.ts
- apps/web/src/lib/use-category-label.ts
- apps/web/src/components/layout/sidebar.tsx
- apps/web/src/components/layout/sidebar.test.tsx
# Task 2 — API: Verwaltungs-Endpunkte, Löschen mit Verschieben, Überlagerung überall, eigene Module
- apps/api/src/groups/groups.module.ts
- apps/api/src/groups/module-grants.controller.ts
- apps/api/src/custom-modules/custom-modules.module.ts
- apps/api/src/custom-modules/custom-modules.service.ts
- apps/api/src/custom-modules/custom-modules.service.spec.ts
- apps/api/src/custom-modules/custom-modules.controller.spec.ts
- apps/api/src/custom-modules/dto/custom-module.dto.ts
# Task 3 — Web: Verwaltungsseite, Marktplatz, Formular, Texte, Doku
- apps/web/src/app/(portal)/admin/modules/categories/page.tsx
- apps/web/src/app/(portal)/admin/modules/categories/categories-page.test.tsx
- apps/web/src/app/(portal)/admin/modules/page.tsx
- apps/web/src/app/(portal)/marketplace/page.tsx
- apps/web/src/app/(portal)/modules/[category]/page.tsx
- apps/web/src/components/custom-modules/custom-module-form-modal.tsx
- apps/web/src/lib/custom-modules-api.ts
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/messages/umlaut-dictionary.ts
- CHANGELOG.md
- docs/anleitung-administration.md
- docs/anleitung-anwender.md
autonomous: true
requirements: [QUICK-261003-387]
estimate:
tokens: 240000
raw_tokens: 240000
tasks: 3
confidence: low
must_haves:
truths:
- "Ein Administrator sieht unter Administrator → Module neben „Freigaben-Matrix“ einen Knopf „Kategorien“, der /admin/modules/categories öffnet; Nicht-Administratoren bekommen dort den Zugriffshinweis und von der API 403."
- "Ein Administrator kann eine Kategorie anlegen, umbenennen, mit Pfeilen nach oben/unten verschieben und löschen; „Eigene Module“ lässt sich umbenennen und verschieben, aber nicht löschen (Knopf gesperrt, API 400)."
- "Löschen einer nicht leeren Kategorie fragt nach einer Zielkategorie; alle Module, gemeinsame UND persönliche eigene Module, landen dort — kein Eintrag geht verloren; ohne Ziel antwortet die API 409 und löscht nichts."
- "Ein Administrator ordnet jedes Marktplatz-Modul und jedes gemeinsame eigene Modul per Auswahlfeld einer Kategorie zu und sortiert die Einträge innerhalb einer Kategorie mit Pfeilen; die Spalte Module.category (für alle gleich) bleibt unverändert."
- "Seitenleiste, Marktplatz (Filterchips und Kartenreihenfolge), Kategorieseite /modules/<kategorie> und Freigaben-Matrix zeigen die Kategorie aus der Zuordnung in der eingestellten Kategorie- und Modulreihenfolge; nicht umbenannte Standardkategorien bleiben übersetzt (de/en), umbenannte zeigen den gespeicherten Namen."
- "Im Formular für eigene Module stehen alle vorhandenen Kategorien zur Wahl; bestehende Werte bleiben gültig; eine unbekannte Kategorie lehnt die API mit 400 ab."
- "Alte Modul-Adressen /modules/<alte-kategorie>/<slug> öffnen das Modul weiterhin, weil die Seite nur über den Slug auflöst."
artifacts:
- path: "apps/api/prisma/migrations/20261003120000_module_categories/migration.sql"
provides: "Tabellen ModuleCategory + ModuleCategoryPlacement mit RLS, Spalte CustomModule.sortOrder"
contains: "tenant_isolation_policy"
- path: "apps/api/src/module-categories/module-categories.service.ts"
provides: "Grundbestand je Organisation, CRUD, Löschen mit Verschieben, Überlagerung applyToModules, assertCategoryKey"
- path: "apps/api/src/module-categories/module-categories.controller.ts"
provides: "GET /module-categories (alle), Verwaltungs-Endpunkte nur ADMIN/SUPER_ADMIN, statische Routen vor :key"
- path: "apps/web/src/app/(portal)/admin/modules/categories/page.tsx"
provides: "Verwaltungsseite Kategorien"
- path: "apps/web/src/lib/stores/module-category-store.ts"
provides: "Geteilter Kategorienstand für Beschriftung, Reihenfolge und Auswahlfelder"
key_links:
- from: "apps/api/src/module-registry/module-registry.controller.ts"
to: "ModuleCategoriesService.applyToModules"
via: "GET /modules, /modules/active, /modules/catalog liefern die zugeordnete Kategorie + sortOrder"
pattern: "applyToModules"
- from: "apps/api/src/groups/module-grants.controller.ts"
to: "ModuleCategoriesService.applyToModules"
via: "GET /module-grants/matrix sortiert Module nach Kategorie- und Modulreihenfolge"
pattern: "applyToModules"
- from: "apps/web/src/lib/use-category-label.ts"
to: "apps/web/src/lib/stores/module-category-store.ts"
via: "gespeicherter Name vor Übersetzung, Übersetzung vor Kennung"
pattern: "useModuleCategoryStore"
- from: "apps/web/src/components/layout/sidebar.tsx"
to: "apps/web/src/lib/module-category-order.ts"
via: "Gruppenreihenfolge und Reihenfolge innerhalb der Gruppe"
pattern: "module-category-order"
---
<objective>
Modulkategorien werden pro Organisation durch Administratoren pflegbar: anlegen, umbenennen, sortieren, löschen (mit Verschieben der Inhalte), Module und gemeinsame eigene Module zuordnen und innerhalb der Kategorie sortieren. Die eingestellte Kategorie und Reihenfolge gilt in Seitenleiste, Marktplatz, Kategorieseite, Freigaben-Matrix und im Formular für eigene Module.
Purpose: Heute legt jedes Modul seine Kategorie fest (Module.category aus dem Manifest, für alle Organisationen gleich); Administratoren können die Seitenleiste nicht nach ihren Abläufen ordnen.
Output: Zwei neue Tabellen mit Zeilenschutz, ein Kategorien-Dienst mit Endpunkten, eine Verwaltungsseite, angepasste Anzeigen, CHANGELOG und Handbuch.
Festgelegte Entwurfsentscheidungen (aus dem Auftrag, Planer-Ermessen hier dokumentiert):
- E-01 Datenform: ModuleCategory {id, tenantId, key, name (null = Übersetzung moduleCategories.<key>), sortOrder, isSystem}, eindeutig (tenantId, key). key ist UNVERÄNDERLICH und bleibt URL-Segment /modules/<key>/<slug>; Umbenennen ändert nur name. isSystem=true nur für „custom-modules“ (Eigene Module): umbenennbar, verschiebbar, nicht löschbar.
- E-02 Zuordnung Marktplatz-Module: ModuleCategoryPlacement {id, tenantId, moduleId → Module (onDelete Cascade), categoryKey, sortOrder}, eindeutig (tenantId, moduleId). Wirksame Kategorie = Zuordnung, sonst Module.category. Module.category wird NIE geändert.
- E-03 Gemeinsame eigene Module: CustomModule ist schon eine Zeile je Organisation; Zuordnung schreibt direkt CustomModule.category, Reihenfolge in der neuen Spalte CustomModule.sortOrder (Int, null erlaubt). Persönliche eigene Module wählen ihre Kategorie selbst (jede vorhandene) und bekommen nie eine sortOrder.
- E-04 Grundbestand ohne SQL-Rückfüllung: der Dienst legt beim ersten Lesen je Organisation die Standardkategorien an (Reihenfolge von CUSTOM_MODULE_CATEGORIES, also die sechs Modulkategorien und „Eigene Module“ zuletzt, name null) und legt fehlende Zeilen für jede wirksam benutzte Kennung nach (Kategorie eines später ausgelieferten Moduls, vorhandene Werte eigener Module), jeweils hinten angehängt. Eine gelöschte Standardkategorie kommt nur wieder, wenn ein Modul sie wirksam benutzt (z. B. ein neu ausgeliefertes Modul mit diesem Manifest-Wert).
- E-05 Löschen: alle Inhalte wandern in die gewählte Zielkategorie — Marktplatz-Module (Zuordnung umgeschrieben bzw. neu angelegt, damit die Manifest-Kategorie sie nicht zurückholt), gemeinsame UND persönliche eigene Module (persönliche Einträge gehen mit den anderen mit, nicht nach „Eigene Module“). Verschobene Einträge werden hinten angehängt.
- E-06 Neue Kennung: aus dem Namen gebildet (klein, ä→ae, ö→oe, ü→ue, ß→ss, sonst nur a-z0-9 und Bindestrich, höchstens 40 Zeichen, leer → „kategorie“); kollidiert sie mit einer Kennung der Organisation, einem Modul-Slug (eigene Routenordner unter /modules) oder „custom“, wird „-2“, „-3“ … angehängt.
- E-07 Wirksame Kategorie wird SERVERSEITIG über die Modullisten gelegt (/modules, /modules/active, /modules/catalog, /module-grants/matrix): jedes Modul bekommt category = wirksame Kennung und sortOrder (Zahl oder null), Liste sortiert nach Kategorie-Reihenfolge, dann sortOrder (null zuletzt), dann Name. Dadurch gruppieren Kategorieseite, Marktplatz und Matrix ohne eigene Logik richtig.
- E-08 Reihenfolge innerhalb einer Kategorie (Seitenleiste): sortOrder aufsteigend, null zuletzt; bei Gleichstand eingebaute Module vor eigenen, dann Name. Persönliche eigene Module stehen damit immer hinter den vom Administrator sortierten Einträgen.
- E-09 Nicht im Umfang: der Benutzer-Zugriffsdialog (Benutzerdetails) behält seine bisherige Sortierung; keine „Auf Standardnamen zurücksetzen“-Funktion.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
Bestehende Muster (einmal lesen, dann nachbauen):
- Migration mit Kopfkommentar + RLS ohne Benutzerdimension: apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql
- RLS-Regeln eigener Module (Klient ohne Benutzer darf alle Zeilen der Organisation ändern): apps/api/prisma/migrations/20260929130000_custom_module_owner/migration.sql
- forTenant / withTenantTransaction: apps/api/src/prisma/prisma-tenant.extension.ts
- Rollen je Methode: apps/api/src/module-registry/module-registry.controller.ts (@UseGuards(RolesGuard) + @Roles(Role.ADMIN, Role.SUPER_ADMIN), tenantId = req.tenantId ?? req.user?.tenantId)
- Zugriffsinventar: apps/api/src/prisma/rls-access-inventory.spec.ts gegen docs/mandantentrennung-zugriffsklassifikation.md (Zeilenformat wie Eintrag nextcloud-status.service.ts)
- Löschdialog mit Rückfrage: apps/web/src/app/(portal)/admin/groups/components/DeleteGroupDialog.tsx
- Admin-Seite mit Rollenprüfung und Kopf-Link: apps/web/src/app/(portal)/admin/modules/page.tsx, apps/web/src/app/(portal)/admin/modules/grants/page.tsx
- Seitenleisten-Test zählt fetch-Aufrufe: apps/web/src/components/layout/sidebar.test.tsx (API-Helfer werden als Modul gemockt, z. B. @/lib/custom-modules-api)
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer — Kategorietabellen, Grundbestand, Lese-Endpunkt und wirksame Kategorie bis in die Seitenleiste</name>
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261003120000_module_categories/migration.sql, apps/api/src/module-categories/module-categories.service.ts, apps/api/src/module-categories/module-categories.service.spec.ts, apps/api/src/module-categories/module-categories.controller.ts, apps/api/src/module-categories/module-categories.controller.spec.ts, apps/api/src/module-categories/module-categories.module.ts, apps/api/src/module-categories/dto/module-category.dto.ts, apps/api/src/module-registry/module-registry.module.ts, apps/api/src/module-registry/module-registry.controller.ts, apps/api/src/app.module.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/module-categories-api.ts, apps/web/src/lib/stores/module-category-store.ts, apps/web/src/lib/module-category-order.ts, apps/web/src/lib/module-category-order.test.ts, apps/web/src/lib/use-category-label.ts, apps/web/src/components/layout/sidebar.tsx, apps/web/src/components/layout/sidebar.test.tsx</files>
<read_first>apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql, apps/api/src/custom-modules/custom-modules.service.ts, apps/api/src/module-registry/module-registry.controller.ts, apps/web/src/components/layout/sidebar.tsx, apps/web/src/components/layout/sidebar.test.tsx, apps/web/src/lib/use-category-label.ts, packages/shared/src/index.ts (Zeilen 270-300)</read_first>
<action>
Schema (E-01, E-02, E-03): in schema.prisma die Modelle ModuleCategory und ModuleCategoryPlacement wie in E-01/E-02 beschrieben anlegen (beide mit tenantId String, createdAt/updatedAt, @@index([tenantId]); ModuleCategory @@unique([tenantId, key]), sortOrder Int @default(0), isSystem Boolean @default(false), name String?; Placement @@unique([tenantId, moduleId]), sortOrder Int, Relation zu Module mit onDelete: Cascade und Gegenfeld categoryPlacements an Module). An CustomModule die Spalte sortOrder Int? ergänzen und den Kommentar an category auf „Kennung einer ModuleCategory der Organisation“ ändern. Migration 20261003120000_module_categories: DDL mit `pnpm --filter @tessera/api exec prisma migrate diff --from-migrations prisma/migrations --to-schema-datamodel prisma/schema.prisma --script` erzeugen (Shadow-DB per --shadow-database-url über die Container-IP, falls nötig) oder von Hand nach Vorbild schreiben; dann von Hand den Pflicht-Kopfkommentar (Zweck, quick-261003-387, Zeilenschutz ohne Benutzerdimension weil gemeinsame Daten der Organisation, Rechte über ALTER DEFAULT PRIVILEGES, Schalter-Hinweis) und für BEIDE Tabellen ENABLE + FORCE ROW LEVEL SECURITY und CREATE POLICY tenant_isolation_policy … USING ("tenantId" = current_tenant_id()) ergänzen. Keine system_read_policy (kein Hintergrunddienst). Migration lokal anwenden: IP mit `docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1`, dann `DATABASE_URL="postgresql://tessera:tessera_dev@<IP>:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy` und `pnpm --filter @tessera/api exec prisma generate`.
Dienst apps/api/src/module-categories/module-categories.service.ts (injiziert nur PrismaService; je Methode eigener Klient `const tenantPrisma = forTenant(this.prisma, tenantId)`; Lesen des globalen Modulkatalogs über this.prisma.module wie in module-access.service.ts, mit gleichem Begründungskommentar). In diesem Task: (a) private ensure(tenantId) nach E-04 — keine Zeile vorhanden → createMany mit skipDuplicates für CUSTOM_MODULE_CATEGORIES in dieser Reihenfolge (sortOrder = Index, isSystem nur für CUSTOM_MODULE_CATEGORY); danach fehlende Kennungen aus wirksamer Modulkategorie (Zuordnung sonst Module.category) und distinct CustomModule.category der Organisation mit sortOrder = bisheriges Maximum + 1 nachlegen (skipDuplicates); liefert die Zeilen sortiert nach sortOrder, dann key. (b) listCategories(tenantId) → [{id, key, name, sortOrder, isSystem}]. (c) applyToModules(tenantId, modules) generisch über {id, category, name} nach E-07 (gibt category und sortOrder: number | null zurück, unbekannte Kategorie sortiert zuletzt). (d) rename(tenantId, key, name) — Name getrimmt 1–60 Zeichen, unbekannte Kennung 404, „Eigene Module“ erlaubt (D-Auftrag: umbenennbar).
Controller apps/api/src/module-categories/module-categories.controller.ts mit @Controller('module-categories'): GET '' für alle angemeldeten Benutzer → listCategories; PATCH ':key' mit @UseGuards(RolesGuard) @Roles(Role.ADMIN, Role.SUPER_ADMIN) → rename (DTO RenameModuleCategoryDto in dto/module-category.dto.ts mit Transform-Trim, IsString, IsNotEmpty, MaxLength(60)). Alle späteren statischen Routen kommen VOR ':key' (NestJS-Route-Order). ModuleCategoriesModule (providers + exports ModuleCategoriesService, controllers) anlegen, in app.module.ts registrieren und von ModuleRegistryModule importieren. In ModuleRegistryController ModuleCategoriesService injizieren und findActive über applyToModules leiten (findAll/findCatalog folgen in Task 2).
Inventar: rls-access-inventory.spec.ts laufen lassen und für die neuen Paare (Datei module-categories.service.ts × moduleCategory, moduleCategoryPlacement, customModule, module) Zeilen in docs/mandantentrennung-zugriffsklassifikation.md im vorhandenen Format ergänzen (Stand gebunden bzw. für module der dokumentierte ungebundene Katalogzugriff); in Task 2 kommen weitere Treffer hinzu — die Rohzahlen dann nachziehen.
Web: apps/web/src/lib/module-categories-api.ts mit Typ ModuleCategoryInfo {id, key, name: string | null, sortOrder, isSystem} und listModuleCategories() (GET, credentials include, wirft bei !ok). apps/web/src/lib/stores/module-category-store.ts (zustand wie marketplace-store): categories, loaded, load() ruft listModuleCategories und schluckt Fehler still (Seitenleisten-Muster), ensureLoaded() lädt nur wenn !loaded. apps/web/src/lib/module-category-order.ts als reine Funktionen: categoryRank(categories, key) und compareSidebarEntries nach E-08; Tests in module-category-order.test.ts. use-category-label.ts: liest den Store; gespeicherter name (nicht null) vor Übersetzung, Übersetzung vor Kennung; abonniert den Store, damit Beschriftungen nach dem Laden neu rendern. Sidebar: SidebarModule und SidebarEntry um sortOrder (number | null) und custom-Kennzeichen erweitern (eigene Module aus der CustomModule-Antwort übernehmen sortOrder, siehe Task 2 für das API-Feld; bis dahin null); fetchActiveModules lädt zusätzlich den Store (load()) im selben Auffrisch-Takt; orderedCategories sortiert Gruppen nach categoryRank (unbekannte Kennungen zuletzt in Fundreihenfolge) und Einträge je Gruppe nach compareSidebarEntries — der bisherige feste Sonderfall „Eigene Module immer zuletzt“ entfällt, weil die Reihenfolge jetzt aus den Kategoriezeilen kommt (Standard: zuletzt). sidebar.test.tsx: @/lib/module-categories-api als Modul mocken (fetch-Zähler bleiben unverändert) und Tests ergänzen: Gruppen folgen der Store-Reihenfolge; umbenannte Kategorie zeigt den Namen; Einträge innerhalb einer Gruppe folgen sortOrder, null zuletzt, eingebaut vor eigenem.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/module-categories src/module-registry rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run sidebar module-category-order && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && docker exec tessera-ctl-db-1 psql -U tessera -d tessera -tAc "select count(*) from pg_class where relname in ('ModuleCategory','ModuleCategoryPlacement') and relrowsecurity and relforcerowsecurity" | grep -qx 2</automated>
</verify>
<done>Migration lokal angewendet, beide Tabellen mit FORCE RLS; GET /module-categories liefert für eine frische Organisation sieben Standardkategorien in Standardreihenfolge mit name null; PATCH benennt um (nur Administratoren); /modules/active liefert wirksame Kategorie + sortOrder; die Seitenleiste ordnet Gruppen und Einträge nach Store und zeigt umbenannte Namen; RLS-Gates grün.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: API — Anlegen, Sortieren, Zuordnen, Löschen mit Verschieben, Überlagerung in allen Modullisten, eigene Module gegen vorhandene Kategorien prüfen</name>
<files>apps/api/src/module-categories/module-categories.service.ts, apps/api/src/module-categories/module-categories.service.spec.ts, apps/api/src/module-categories/module-categories.controller.ts, apps/api/src/module-categories/module-categories.controller.spec.ts, apps/api/src/module-categories/dto/module-category.dto.ts, apps/api/src/module-registry/module-registry.controller.ts, apps/api/src/groups/groups.module.ts, apps/api/src/groups/module-grants.controller.ts, apps/api/src/custom-modules/custom-modules.module.ts, apps/api/src/custom-modules/custom-modules.service.ts, apps/api/src/custom-modules/custom-modules.service.spec.ts, apps/api/src/custom-modules/custom-modules.controller.spec.ts, apps/api/src/custom-modules/dto/custom-module.dto.ts, docs/mandantentrennung-zugriffsklassifikation.md</files>
<read_first>apps/api/src/module-categories/module-categories.service.ts (aus Task 1), apps/api/src/groups/module-grants.controller.ts, apps/api/src/custom-modules/dto/custom-module.dto.ts, apps/api/src/custom-modules/custom-modules.service.spec.ts, apps/api/src/prisma/prisma-tenant.extension.ts (withTenantTransaction)</read_first>
<behavior>
- create: Name „Werkzeuge & Tools“ ergibt Kennung „werkzeuge-tools“, sortOrder = Maximum + 1, name gespeichert; Name, dessen Kennung einem Modul-Slug (z. B. „proxmox“), „custom“ oder einer vorhandenen Kennung entspricht, bekommt „-2“; leerer Name 400.
- reorderCategories: keys muss genau eine Umstellung aller Kennungen der Organisation sein, sonst 400; danach sortOrder = Index.
- assign module: legt Zuordnung an oder schreibt sie um (categoryKey, sortOrder = Maximum der Zielkategorie + 1); Module.category bleibt unverändert; unbekannte Kategorie 400, unbekanntes Modul 404.
- assign custom: nur gemeinsame eigene Module (ownerUserId null), persönliches oder fremdes 404; schreibt CustomModule.category und sortOrder.
- reorderItems: items muss genau die Menge der nicht persönlichen Einträge der Kategorie sein (Marktplatz-Module mit wirksamer Kategorie + gemeinsame eigene Module), sonst 400; schreibt sortOrder = Index (Module per Upsert der Zuordnung).
- remove: isSystem → 400; unbekannte Kennung → 404; nicht leer (wirksame Module, gemeinsame oder persönliche eigene Module) ohne moveTo → 409 und nichts gelöscht; moveTo gleich key oder unbekannt → 400; mit Ziel: Zuordnungen umgeschrieben, Module mit Manifest-Kategorie ohne Zuordnung bekommen eine Zuordnung zum Ziel, alle CustomModule-Zeilen (auch persönliche) bekommen das Ziel, alles in einer Transaktion, dann Zeile gelöscht; leere Kategorie ohne moveTo wird gelöscht; nach dem Löschen legt ensure die Kategorie nicht wieder an.
- applyToModules: Zuordnung schlägt Manifest; Sortierung Kategorie-Reihenfolge, dann sortOrder (null zuletzt), dann Name.
- getOverview: je Kategorie in Reihenfolge {key, name, sortOrder, isSystem, items: [{type: 'module'|'custom', id, name, slug?}] in Reihenfolge, personalCount}.
- Custom modules: create/update mit Kategorie, die die Organisation nicht hat → 400; vorhandene Kennung (auch neu angelegte) → ok.
</behavior>
<action>
Dienst ergänzen (E-02, E-03, E-05, E-06): create(tenantId, name), reorderCategories(tenantId, keys), assign(tenantId, {type, id, categoryKey}), reorderItems(tenantId, key, items), remove(tenantId, key, moveTo?), getOverview(tenantId), assertCategoryKey(tenantId, key) (ruft ensure, wirft BadRequestException mit deutscher Meldung „Unbekannte Kategorie“). Mehrschrittige Schreibvorgänge (reorderCategories, reorderItems, remove) über withTenantTransaction(this.prisma, tenantId, async (tx) => …), damit das Inventar sie als gebunden erkennt. Eigene Module werden über den Organisations-Klienten OHNE Benutzer gelesen/geschrieben (die Regel aus 20260929130000 lässt dann alle Zeilen der Organisation zu); die Administrator-Prüfung sitzt im Controller. Zusätzlich jede Abfrage mit tenantId im where (Anwendungsprüfung, solange der RLS-Schalter aus ist). Fehlermeldungen deutsch, ohne das Wort Mandant.
Controller (statische Routen VOR ':key', alle schreibenden und overview nur ADMIN/SUPER_ADMIN): GET 'overview', POST '' (CreateModuleCategoryDto {name}), PUT 'order' ({keys: string[]}, ArrayMinSize 1, jedes Element passend zu /^[a-z0-9][a-z0-9-]{0,59}$/), PUT 'assignment' ({type: IsIn ['module','custom'], id: IsString, categoryKey}), dann PATCH ':key' (aus Task 1), PUT ':key/items' ({items: [{type, id}]} mit ValidateNested + Type), DELETE ':key' mit optionalem Query moveTo. Controller-Spec: Rollen-Metadaten je Verwaltungsmethode (Muster expectAdminOnly aus module-manage-handlers.spec.ts), GET '' ohne @Roles, und Reihenfolge der Routen (Index von 'overview', 'order', 'assignment' im Quelltext vor dem ersten ':key').
Überlagerung (E-07): ModuleRegistryController.findAll (mit @Req; ohne tenantId unverändert zurückgeben) und findCatalog über applyToModules leiten; ModuleGrantsController.matrix: Ergebnis von getMatrix nehmen und modules durch applyToModules ersetzen (GroupsModule importiert ModuleCategoriesModule; getMatrix im Dienst bleibt unverändert, damit bestehende Specs halten). Benutzer-Zugriffsdialog unverändert (E-09).
Eigene Module: CUSTOM_MODULE_SELECT um sortOrder erweitern (Antwortfeld für die Seitenleiste). DTO: @IsIn([...CUSTOM_MODULE_CATEGORIES]) durch IsString + Matches(/^[a-z0-9][a-z0-9-]{0,59}$/) ersetzen; Typ string. CustomModulesService injiziert ModuleCategoriesService (CustomModulesModule importiert ModuleCategoriesModule) und ruft assertCategoryKey in create und in update (nur wenn category gesetzt). Bestehende Specs auf den neuen Konstruktor-Parameter anpassen (Stub mit assertCategoryKey), neuen Fall „unbekannte Kategorie 400“ ergänzen. Danach rls-access-inventory.spec.ts laufen lassen und die Rohzahlen/Methodenliste der Zeilen für module-categories.service.ts nachziehen.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/module-categories src/module-registry src/groups src/custom-modules rls-coverage rls-access-inventory && pnpm --filter @tessera/api exec tsc --noEmit && node -e 'const s=require("fs").readFileSync("apps/api/src/module-categories/module-categories.controller.ts","utf8");const k=s.indexOf("\x27:key");for(const r of ["\x27overview\x27","\x27order\x27","\x27assignment\x27"]){const i=s.indexOf(r);if(i<0||k<0||i>k){console.error("route order",r);process.exit(1)}}' && grep -q "applyToModules" apps/api/src/groups/module-grants.controller.ts</automated>
</verify>
<done>Alle Verwaltungsendpunkte vorhanden, nur für Administratoren, statische Routen vor :key; Löschen verschiebt alle Einträge einschließlich persönlicher eigener Module in einer Transaktion und verweigert ohne Ziel mit 409; /modules, /modules/catalog und /module-grants/matrix liefern wirksame Kategorie und Reihenfolge; eigene Module akzeptieren jede vorhandene Kategorie und lehnen unbekannte mit 400 ab; API-Gates grün.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Web — Verwaltungsseite „Kategorien“, Marktplatz, Formular, Texte, CHANGELOG, Handbuch, Gesamtprüfung und Neubau</name>
<files>apps/web/src/lib/module-categories-api.ts, apps/web/src/app/(portal)/admin/modules/categories/page.tsx, apps/web/src/app/(portal)/admin/modules/categories/categories-page.test.tsx, apps/web/src/app/(portal)/admin/modules/page.tsx, apps/web/src/app/(portal)/marketplace/page.tsx, apps/web/src/app/(portal)/modules/[category]/page.tsx, apps/web/src/components/custom-modules/custom-module-form-modal.tsx, apps/web/src/lib/custom-modules-api.ts, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, CHANGELOG.md, docs/anleitung-administration.md, docs/anleitung-anwender.md</files>
<read_first>apps/web/src/app/(portal)/admin/modules/page.tsx, apps/web/src/app/(portal)/admin/modules/grants/page.tsx (Kopf, Zurück-Link), apps/web/src/app/(portal)/admin/groups/components/DeleteGroupDialog.tsx, apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx (Mock-Muster), apps/web/src/components/custom-modules/custom-module-form-modal.tsx, apps/web/src/app/(portal)/marketplace/page.tsx</read_first>
<behavior>
- Seite listet Kategorien in Reihenfolge mit Beschriftung aus useCategoryLabel und darunter ihre Einträge; Pfeil nach oben bei der ersten bzw. nach unten bei der letzten Kategorie/Eintrag gesperrt.
- „Kategorie anlegen“ sendet POST mit dem Namen; Umbenennen sendet PATCH; Pfeile senden PUT order bzw. PUT :key/items mit der vollständigen neuen Reihenfolge.
- Auswahlfeld je Eintrag sendet PUT assignment.
- Löschen-Knopf bei „Eigene Module“ gesperrt; leere Kategorie → einfache Rückfrage → DELETE ohne moveTo; nicht leere (items oder personalCount > 0) → Dialog mit Zielauswahl (ohne die zu löschende) → DELETE mit moveTo.
- Nach jeder Änderung: Übersicht neu laden, Kategorienstand neu laden, Seitenleiste auffrischen (bumpSidebarRefresh).
- Nicht-Administrator sieht den Zugriffshinweis und es wird nichts geladen.
- Formular für eigene Module bietet alle Kategorien aus dem Store in Reihenfolge an; ein vorhandener Wert, der (noch) nicht im Store steht, bleibt als Option erhalten.
</behavior>
<action>
module-categories-api.ts um getModuleCategoryOverview, createModuleCategory, renameModuleCategory, reorderModuleCategories, reorderModuleCategoryItems, assignModuleCategory, deleteModuleCategory(key, moveTo?) erweitern (Fehlerklasse mit status wie CustomModuleRequestError; Fehlermeldung der API anzeigen). CustomModule-Typ in custom-modules-api.ts um sortOrder: number | null ergänzen.
Neue Seite apps/web/src/app/(portal)/admin/modules/categories/page.tsx (Client-Komponente, Rollenprüfung und Layout wie admin/modules/page.tsx, Zurück-Link „Module“ wie in grants/page.tsx): Kopf „Kategorien“ mit Erklärung; Eingabe + Knopf „Kategorie anlegen“; je Kategorie eine Karte mit Name, Pfeilen nach oben/unten (aria-label „Kategorie nach oben/unten verschieben“), „Umbenennen“ (Eingabe an Ort und Stelle, Speichern/Abbrechen), „Löschen“ (bei isSystem gesperrt mit Hinweis „Diese Kategorie kann nicht gelöscht werden“); darin die Einträge (Marktplatz-Module und gemeinsame eigene Module, letztere mit kleinem Hinweis „Eigenes Modul“) mit Pfeilen und einem Auswahlfeld „Kategorie“ (alle Kategorien); bei personalCount > 0 der Satz „Außerdem N persönliche Einträge von Benutzern“; leere Kategorie zeigt „Keine Module in dieser Kategorie“. Löschdialog nach Vorbild DeleteGroupDialog nach E-05 (Text: die Module werden in die gewählte Kategorie verschoben, auch persönliche Einträge von Benutzern; nichts geht verloren). Pfeile verschieben durch Tauschen in der lokalen Liste und senden die vollständige neue Reihenfolge. admin/modules/page.tsx: neben dem Link „Freigaben-Matrix“ einen zweiten Link „Kategorien“ auf /admin/modules/categories; die Kategorie-Plakette zeigt categoryLabel(mod.category) statt der Kennung.
Marktplatz: Filterchips nach Store-Reihenfolge (categoryRank) statt alphabetisch, Store per ensureLoaded laden; Karten behalten die vom Server gelieferte Reihenfolge. Kategorieseite /modules/[category]/page.tsx: Titel über useCategoryLabel statt formatCategoryName (Filter auf mod.category bleibt, die API liefert jetzt die wirksame Kategorie). Prüfen und im SUMMARY festhalten, dass /modules/[category]/[moduleSlug] nur über den Slug auflöst (ModuleAccessGate + ModuleShell); der Zurück-Link nutzt das URL-Segment und darf bleiben. Formular custom-module-form-modal.tsx: Optionen aus dem Store (ensureLoaded beim Öffnen) statt CUSTOM_MODULE_CATEGORIES, Rückfall auf CUSTOM_MODULE_CATEGORIES nur solange der Store leer ist; Vorbelegung bleibt CUSTOM_MODULE_CATEGORY. Freigaben-Matrix braucht keine Änderung (Server sortiert, Beschriftung über useCategoryLabel); Matrix-Test muss weiter grün sein.
Texte: neue Schlüssel unter adminModules (categoriesLink sowie Bereich categories mit allen Seiten- und Dialogtexten) in de.json UND en.json mit identischer Schlüsselmenge; Deutsch mit echten Umlauten und „Sie“, Englisch sachlich; keines der Wörter „Mandant“ oder „tenant“ in Texten. Meldet der Umlaut-Wächter ein korrektes Wort, es in die Erlaubnisliste von umlaut-dictionary.ts aufnehmen. Tests in categories-page.test.tsx für die Fälle aus behavior (API-Helfer und Stores als Modul mocken wie in grants-matrix.test.tsx).
CHANGELOG.md unter „Unveröffentlicht → Neu“ ein Absatz in Alltagssprache (wo die Seite liegt, was Administratoren tun können, dass beim Löschen alle Einträge in eine gewählte Kategorie wandern und „Eigene Module“ nicht löschbar ist, dass Seitenleiste und Marktplatz der Reihenfolge folgen, dass Benutzer für ihre eigenen Einträge jede Kategorie wählen können). docs/anleitung-administration.md: neuer Abschnitt „### Kategorien“ in Kapitel 5 nach „Freigaben-Matrix“ (Anlegen, Umbenennen, Reihenfolge, Zuordnen, Sortieren, Löschen mit Ziel inkl. persönlicher Einträge, „Eigene Module“ nicht löschbar, alte Lesezeichen funktionieren weiter); docs/anleitung-anwender.md: Satz zu „Eigene Module … ganz unten“ anpassen (Reihenfolge legt der Administrator fest, standardmäßig unten; Auswahl umfasst alle Kategorien).
Abschluss: komplette Suites, tsc beider Apps, `biome check` auf alle NEUEN Dateien und `biome lint` auf die berührten bestehenden Dateien (diese haben schon heute Format-/Import-Abweichungen; nicht ganze Altdateien umformatieren, damit der Diff klein bleibt), dann `docker compose up -d --build api web` und prüfen, dass beide Dienste laufen und die API ohne Migrationsfehler startet. Nicht pushen; Browserprüfung macht der Orchestrator.
</action>
<verify>
<automated>pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && pnpm exec biome check apps/api/src/module-categories "apps/web/src/app/(portal)/admin/modules/categories" apps/web/src/lib/module-categories-api.ts apps/web/src/lib/module-category-order.ts apps/web/src/lib/module-category-order.test.ts apps/web/src/lib/stores/module-category-store.ts && pnpm exec biome lint apps/api/src/custom-modules apps/api/src/module-registry/module-registry.controller.ts apps/api/src/groups/module-grants.controller.ts "apps/web/src/app/(portal)/admin/modules/page.tsx" "apps/web/src/app/(portal)/marketplace/page.tsx" "apps/web/src/app/(portal)/modules/[category]/page.tsx" apps/web/src/components/layout/sidebar.tsx apps/web/src/components/custom-modules/custom-module-form-modal.tsx apps/web/src/lib/use-category-label.ts && node -e 'const de=require("./apps/web/src/messages/de.json"),en=require("./apps/web/src/messages/en.json");const w=(o,p,r)=>{for(const[k,v]of Object.entries(o||{})){const q=p+"."+k;if(v&&typeof v==="object")w(v,q,r);else r[q]=v}return r};const a=w(de.adminModules&&de.adminModules.categories,"c",{}),b=w(en.adminModules&&en.adminModules.categories,"c",{});if(Object.keys(a).length<10||Object.keys(a).sort().join()!==Object.keys(b).sort().join()){console.error("key mismatch");process.exit(1)}for(const v of [...Object.values(a),...Object.values(b)])if(/mandant|tenant/i.test(String(v))){console.error("bad text",v);process.exit(1)}' && grep -q "Kategorien" CHANGELOG.md && grep -q "### Kategorien" docs/anleitung-administration.md && docker compose ps --status running --services | grep -qx api && docker compose ps --status running --services | grep -qx web</automated>
</verify>
<done>Administrator → Module → „Kategorien“ ist erreichbar und deckt Anlegen, Umbenennen, Sortieren, Löschen mit Zielauswahl, Zuordnen und Sortieren der Einträge ab; Marktplatz, Kategorieseite, Matrix und Formular folgen den eingestellten Kategorien; Texte de/en vollständig ohne „Mandant/tenant“; CHANGELOG und Handbuch ergänzt; beide Suites, tsc und biome grün; api und web neu gebaut und laufend; nichts gepusht.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → API /module-categories | Eingaben (Name, Kennungen, Modul-IDs, moveTo) sind unvertraut |
| Organisation A ↔ Organisation B | Kategorien und Zuordnungen sind je Organisation getrennt (tenantId + RLS) |
| Benutzer ↔ Administrator | Nur Administratoren ändern Kategorien; persönliche eigene Module bleiben für andere unsichtbar |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-387-01 | Elevation of Privilege | module-categories.controller.ts Schreibrouten + overview | high | mitigate | @UseGuards(RolesGuard) + @Roles(ADMIN, SUPER_ADMIN) je Methode; Controller-Spec prüft die Metadaten; GET '' liefert nur Kennung/Name/Reihenfolge |
| T-387-02 | Information Disclosure | ModuleCategory, ModuleCategoryPlacement | high | mitigate | tenant_isolation_policy mit FORCE RLS in Migration 20261003120000; forTenant/withTenantTransaction je Methode; tenantId zusätzlich im where; rls-coverage + rls-access-inventory grün |
| T-387-03 | Information Disclosure | getOverview / Löschen mit Verschieben | medium | mitigate | Übersicht nennt für persönliche eigene Module nur eine Anzahl (personalCount), nie Name oder Adresse; assign/reorderItems akzeptieren nur gemeinsame eigene Module (persönliche 404) |
| T-387-04 | Tampering | assign/reorderItems mit fremden IDs | medium | mitigate | Modul-ID gegen Katalog, eigene Module mit where {id, tenantId, ownerUserId: null}; reorderItems verlangt exakt die Menge der Einträge der Kategorie, sonst 400 |
| T-387-05 | Denial of Service | remove ohne Ziel / Datenverlust | medium | mitigate | Nicht leere Kategorie ohne moveTo → 409, nichts gelöscht; Verschieben und Löschen in einer Transaktion; „Eigene Module“ (isSystem) nicht löschbar |
| T-387-06 | Tampering | Kennung als URL-Segment | low | mitigate | Kennung serverseitig aus dem Namen gebildet (a-z0-9-), kollisionsfrei gegen Modul-Slugs und „custom“; Kennungen in DTOs per Regex geprüft |
| T-387-SC | Tampering | npm/pip/cargo installs | high | accept | Keine neuen Pakete in diesem Auftrag (nur vorhandene Abhängigkeiten) |
</threat_model>
<verification>
- Alle drei automatisierten Prüfungen grün; vollständige API- und Web-Suite grün.
- Migration lokal angewendet, FORCE RLS auf beiden neuen Tabellen.
- docker compose: api und web neu gebaut und laufend.
- Browserprüfung (Orchestrator, dunkel): Kategorie anlegen, Modul hineinschieben, umbenennen, sortieren, nicht leere Kategorie löschen mit Ziel; Seitenleiste und Marktplatz folgen; alte Modul-Adresse öffnet weiter.
</verification>
<success_criteria>
- Administratoren pflegen Kategorien vollständig über die neue Seite; kein Modul geht beim Löschen verloren.
- Wirksame Kategorie und Reihenfolge gelten in Seitenleiste, Marktplatz, Kategorieseite, Freigaben-Matrix und Formular für eigene Module.
- Module.category bleibt unangetastet; Standardkategorien bleiben übersetzt, solange sie nicht umbenannt sind.
- RLS-Gates, Inventar-Doku, CHANGELOG und Handbuch aktuell; nichts gepusht.
</success_criteria>
<output>
Create `.planning/quick/261003-387-kategorien-durch-admins-bearbeitbar-umbe/261003-387-SUMMARY.md` when done (inkl. der Entscheidungen E-01 bis E-09 und des Befunds zur Slug-Auflösung von /modules/[category]/[moduleSlug]).
</output>
@@ -0,0 +1,141 @@
---
phase: quick-261003-387
plan: 01
subsystem: modules
tags: [module-categories, admin, sidebar, marketplace, rls, prisma, nestjs, nextjs]
status: complete
requirements: [QUICK-261003-387]
completed: 2026-10-03
duration: 23 min
commits: 3
plan_head_before: 53a49a109fc70dab6d5acf43d41649939cb1251d
plan_head_after: f2c0a896e840493d56305726ecaf98eb92a86889
actuals:
tokens: 215000
tasks: 3
commits: 3
key-files:
created:
- apps/api/prisma/migrations/20261003120000_module_categories/migration.sql
- apps/api/src/module-categories/module-categories.service.ts
- apps/api/src/module-categories/module-categories.controller.ts
- apps/api/src/module-categories/module-categories.module.ts
- apps/api/src/module-categories/dto/module-category.dto.ts
- apps/api/src/module-categories/module-categories.service.spec.ts
- apps/api/src/module-categories/module-categories.controller.spec.ts
- apps/api/src/module-categories/module-categories.fake-prisma.ts
- apps/api/src/module-registry/module-registry.controller.categories.spec.ts
- apps/web/src/app/(portal)/admin/modules/categories/page.tsx
- apps/web/src/app/(portal)/admin/modules/categories/categories-page.test.tsx
- apps/web/src/lib/module-category-order.ts
- apps/web/src/lib/stores/module-category-store.ts
modified:
- apps/api/prisma/schema.prisma
- apps/api/src/module-registry/module-registry.controller.ts
- apps/api/src/groups/module-grants.controller.ts
- apps/api/src/custom-modules/custom-modules.service.ts
- apps/web/src/components/layout/sidebar.tsx
- apps/web/src/lib/use-category-label.ts
- apps/web/src/lib/module-categories-api.ts
- apps/web/src/app/(portal)/marketplace/page.tsx
- apps/web/src/components/custom-modules/custom-module-form-modal.tsx
- docs/mandantentrennung-zugriffsklassifikation.md
- CHANGELOG.md
---
# Quick 261003-387: Modulkategorien durch Administratoren bearbeitbar
Administratoren pflegen die Modulkategorien ihrer Organisation jetzt selbst (Administrator → Module → „Kategorien“): anlegen, umbenennen, sortieren, Module und gemeinsame eigene Module zuordnen und sortieren, löschen mit Zielkategorie. Seitenleiste, Marktplatz, Kategorieseite, Freigaben-Matrix und das Formular für eigene Module folgen der Einstellung.
## Was gebaut wurde
**Task 1 (Tracer, 8ec116c):** Tabellen `ModuleCategory` und `ModuleCategoryPlacement` mit `FORCE ROW LEVEL SECURITY` und `tenant_isolation_policy`, Spalte `CustomModule.sortOrder`; Migration lokal angewendet (`prisma migrate diff` gegen die Datenbank ist leer). `ModuleCategoriesService` mit Grundbestand je Organisation (`ensure`), `listCategories`, `applyToModules`, `rename`. `GET /module-categories` (jeder Angemeldete), `PATCH :key` (nur Administratoren). `/modules/active` liefert die wirksame Kategorie samt `sortOrder`. Web: API-Client, Kategorienspeicher, `module-category-order.ts`, `useCategoryLabel` (gespeicherter Name vor Übersetzung vor Kennung), Seitenleiste ordnet Gruppen und Einträge nach dem Speicher.
**Task 2 (API, af1878d):** `create`, `reorderCategories`, `assign`, `reorderItems`, `remove`, `getOverview`, `assertCategoryKey`; Controller mit Routen `overview`, `POST`, `order`, `assignment` vor `:key`, dazu `:key/items` und `DELETE :key?moveTo=`. Überlagerung in `GET /modules`, `/modules/catalog` und `/module-grants/matrix`. Eigene Module prüfen die Kategorie gegen die Organisation (400), das DTO prüft nur das Format der Kennung. Zugriffsinventar (Zeilen, Bereichszeile, Summe, Paarzahl) nachgezogen.
**Task 3 (Web, f2c0a89):** Verwaltungsseite `/admin/modules/categories` mit Löschdialog (Zielauswahl), Knopf „Kategorien“ neben „Freigaben-Matrix“, Kategorie-Plakette mit Anzeigenamen, Marktplatz-Chips in Kategorienreihenfolge, Kategorieseite mit gespeichertem Namen, Formular mit allen Kategorien der Organisation (vorhandener Wert bleibt Option), Texte de/en, CHANGELOG, Handbuch Administration und Anwender.
## Entscheidungen E-01 bis E-09 (wie im Plan umgesetzt)
- **E-01** `ModuleCategory {id, tenantId, key, name?, sortOrder, isSystem}`, eindeutig `(tenantId, key)`; Kennung unveränderlich, `isSystem` nur für `custom-modules`.
- **E-02** `ModuleCategoryPlacement` je `(tenantId, moduleId)`; wirksam ist die Zuordnung, sonst `Module.category`. `Module.category` wird nie geschrieben (durch Test belegt).
- **E-03** Gemeinsame eigene Module: `CustomModule.category` und neue Spalte `sortOrder` direkt; persönliche bekommen nie eine `sortOrder`.
- **E-04** Grundbestand beim ersten Lesen, ohne SQL-Rückfüllung; fehlende benutzte Kennungen werden hinten nachgelegt; eine gelöschte Standardkategorie kommt nur zurück, wenn ein Modul sie wirksam benutzt.
- **E-05** Löschen verschiebt Marktplatz-Module (Zuordnung umgeschrieben bzw. neu angelegt), gemeinsame UND persönliche eigene Module in einer Transaktion; ohne Ziel 409.
- **E-06** Kennung aus dem Namen (ä→ae …, höchstens 40 Zeichen, leer → `kategorie`), bei Kollision mit Kennung, Modul-Slug oder `custom` `-2`, `-3` …
- **E-07** Überlagerung serverseitig in allen Modullisten (Kategorie-Reihenfolge, dann `sortOrder` mit null zuletzt, dann Name).
- **E-08** Seitenleiste: `sortOrder` aufsteigend, null zuletzt, eingebaut vor eigenem, dann Name.
- **E-09** Benutzer-Zugriffsdialog unverändert; keine „Auf Standardnamen zurücksetzen“-Funktion.
Zusätzlich festgelegt (Planer-Ermessen): Die letzte verbleibende Kategorie kann nie gelöscht werden, weil „Eigene Module“ nicht löschbar ist; dadurch fällt `ensure` nie auf den Standardbestand zurück.
## Befund zur Slug-Auflösung von /modules/[category]/[moduleSlug]
Die Seite löst das Modul ausschließlich über `moduleSlug` auf (`ModuleShell` → `ModuleAccessGate`); `category` wird nur für den „Zurück“-Link verwendet. Alte Adressen `/modules/<alte-kategorie>/<slug>` öffnen das Modul deshalb weiter. Einzige Folge: Der „Zurück“-Link einer solchen alten Adresse führt auf `/modules/<alte-kategorie>`, und diese Kategorieseite zeigt dann „Keine aktiven Module in dieser Kategorie“ (kein 404, nur leer). Der Plan lässt den Link bewusst bestehen; nicht geändert.
## Abweichungen vom Plan
### Automatisch behoben
**1. [Rule 3 - Blockierend] Kategorienspeicher übernahm eine unerwartete Antwort**
- **Gefunden bei:** Task 3, `tenant-selector.test.tsx` (zwei Tests rot: `categories.find is not a function`)
- **Problem:** Der Test-Fetch liefert für jede Adresse dieselben Daten; der Speicher übernahm sie als Kategorienliste.
- **Lösung:** `load()` übernimmt nur ein Feld (`Array.isArray`), sonst bleibt der bisherige Stand.
- **Dateien:** `apps/web/src/lib/stores/module-category-store.ts`
**2. [Rule 1 - Bug] Bestehende Tests an das neue Verhalten angepasst**
- `custom-module.dto.spec.ts`: „lehnt eine unbekannte Kategorie ab“ galt nur für die feste Liste; das DTO prüft jetzt das Format, die Existenz prüft der Dienst (neuer Dienst-Test, 400).
- `sidebar.test.tsx`: Kategorienspeicher als Fixture (Gruppenreihenfolge kommt nicht mehr aus dem festen Sonderfall „Eigene Module zuletzt“).
- `umlaut-dictionary.ts`: „Neuer“ in die Erlaubnisliste (korrektes Deutsch).
### Ergänzungen über den Plan hinaus
- `module-categories.fake-prisma.ts` (Speicher-Attrappe für den Dienst-Test) und `module-registry.controller.categories.spec.ts` (belegt die Überlagerung in `/modules`, `/active`, `/catalog` und Matrix).
- Zusätzliche Tests in `custom-modules-page.test.tsx` (Formular-Optionen) und `marketplace-filters.test.tsx` (Chip-Reihenfolge).
## Prüfergebnisse (ehrlich)
| Prüfung | Ergebnis |
|---|---|
| API-Tests (`pnpm --filter @tessera/api test`) | 125 Dateien, **2205 Tests, alle grün** |
| Web-Tests (`pnpm --filter @tessera/web test`) | 126 Dateien, **1361 Tests, alle grün** |
| `tsc --noEmit` api / web | beide fehlerfrei |
| `biome check` auf alle neuen Dateien | sauber (0 Fehler, 0 Warnungen) |
| `biome lint` auf berührte bestehende Dateien | 0 Fehler; 1 bereits vorhandene Warnung (`sidebar.tsx`, a11y `role="group"`), nicht von dieser Änderung |
| `rls-coverage` + `rls-access-inventory` | grün (35 Tests) |
| Routenreihenfolge (statisch vor `:key`) | Skript aus dem Plan grün, zusätzlich Controller-Test |
| Migration lokal | angewendet, `prisma migrate diff` leer, beide Tabellen `relrowsecurity` + `relforcerowsecurity` |
| `docker compose up -d --build api web` | api (healthy) und web laufen, API startet ohne Migrationsfehler, 9 Logzeilen zu `/module-categories`-Routen, `GET /module-categories` ohne Anmeldung → 401, Startseite antwortet |
Nicht geprüft: Anmeldung und Browserablauf (macht der Orchestrator). Keine Prüfung gegen die laufende API mit Administrator-Anmeldung durchgeführt.
## Zugriffsinventar
Vier neue Zeilen für `module-categories.service.ts` (`moduleCategory`, `moduleCategoryPlacement`, `customModule` gebunden, `module` ungebunden). Bereichszeile `module-categories` 4/22/0 (mit der Gate-Schleife nachgemessen), Summe 61/273/8 → 65/295/8, Paarzahl 96 → 100 (59 muss-mandantengebunden, 23 keine-mandantengebundene-tabelle, 16 beides, 2 bewusst-uebergreifend).
## Bekannte Stubs
Keine.
## Threat Flags
Keine neue Angriffsfläche außerhalb des Plan-Bedrohungsmodells. T-387-01 bis T-387-06 sind umgesetzt: `@Roles(ADMIN, SUPER_ADMIN)` je Verwaltungsmethode (Controller-Test, auch „keine Methode ohne Rolle außer `list`“), FORCE RLS plus `tenantId` in jedem `where`, persönliche Einträge nur als Anzahl, `assign`/`reorderItems` nur für gemeinsame Einträge (persönliche und fremde: 404/400), 409 ohne Ziel und Transaktion beim Löschen, Kennungen per Regex im DTO.
## Offene Hinweise
- Das Ändern der Kategorie eines gemeinsamen eigenen Moduls über dessen Formular setzt `sortOrder` nicht zurück; die alte Position kann in der neuen Kategorie also mitten in der Reihenfolge landen, bis ein Administrator neu sortiert. Zuordnen über die Kategorienseite hängt dagegen hinten an.
- Gepusht wurde nichts; `STATE.md`, `PLAN.md` und diese Datei sind nicht committet (Vorgabe).
## Self-Check: PASSED
- Neue Dateien vorhanden (Migration, Dienst, Controller, DTO, Seite, Tests): bestätigt über `git diff --stat` (43 Dateien).
- Commits vorhanden: 8ec116c, af1878d, f2c0a89 (`git rev-list --count 53a49a1..HEAD` = 3).
## Browser-Prüfung (Orchestrator, 03.10., lokal)
- Administrator → Module → Kategorien: alle Bereiche mit Modulen, „Eigene Module“ nicht löschbar.
- Neue Kategorie „Server“ angelegt, Proxmox per Auswahl hinein, nach ganz oben sortiert → Seitenleiste folgt.
- Umbenannt in „Serverraum“ (Kennung bleibt `server`).
- „Infrastruktur“ gelöscht mit Ziel „Serverraum“ → Nextcloud-Status umgezogen.
- Marktplatz und Freigaben-Matrix zeigen dieselbe Einteilung und Reihenfolge.
- Alte Adressen /modules/infrastructure/proxmox und …/nextcloud-status öffnen weiter das Modul.
+11
View File
@@ -4,6 +4,17 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
## Unveröffentlicht
### Neu
- Kategorien der Module lassen sich jetzt von Administratoren selbst gestalten, unter Administrator → Module → „Kategorien“. Sie können neue Kategorien anlegen, bestehende umbenennen und mit Pfeilen in eine andere Reihenfolge bringen, jedes Modul (auch gemeinsame eigene Module) einer Kategorie zuordnen und die Module innerhalb einer Kategorie sortieren. Seitenleiste, Marktplatz und Freigaben-Matrix folgen dieser Reihenfolge und den neuen Namen. Löschen Sie eine Kategorie, in der noch Module liegen, wählen Sie eine Zielkategorie: Alle Module wandern dorthin, auch die persönlichen Einträge der Benutzer, es geht nichts verloren. „Eigene Module“ lässt sich umbenennen und verschieben, aber nicht löschen. Benutzer können für ihre eigenen Einträge jetzt jede vorhandene Kategorie wählen. Alte Modul-Adressen aus Lesezeichen funktionieren weiter.
- Neues Modul „Nextcloud-Status“ in der Gruppe „Infrastruktur“. Es zeigt für jede eingetragene Nextcloud-Cloud Ihrer Kunden eine Kachel mit Logo (oder Initialen), Kundenname, Adresse (öffnet in einem neuen Tab), installierter Version, Ampelfarbe mit kurzer Begründung und dem Zeitpunkt der letzten Prüfung; oben steht die neueste Nextcloud-Version. Grün heißt: neuester Stand seiner Version und der Support läuft noch mehr als drei Monate. Gelb heißt: ein Update steht an oder der Support endet in den nächsten drei Monaten. Rot heißt: der Support ist abgelaufen, die Cloud ist nicht erreichbar, im Wartungsmodus oder wartet auf eine Datenbank-Aktualisierung. Grau heißt: Bewertung nicht möglich, zum Beispiel wenn die Versionsdaten gerade nicht abrufbar sind. Tessera prüft jede Cloud automatisch einmal pro Stunde; „Jetzt prüfen“ und ein Knopf je Kachel prüfen sofort. Die Kacheln lassen sich nach Kundenname, Status (Rot zuerst), Version oder Support-Ende sortieren, die Wahl merkt sich Tessera für jeden Benutzer. Clouds eintragen, ändern, entfernen und Logos hinterlegen (Bild hochladen bis 1 MB oder eine https-Bildadresse) dürfen Administratoren und Benutzer mit der Freigabestufe „Verwalten“; alle anderen mit Freigabe sehen die Kacheln. Aktivieren Sie das Modul als Administrator im Marktplatz und erteilen Sie die Freigabe.
- Nextcloud-Status: Auf jeder Kachel gibt es jetzt eine Glocke „Benachrichtigen“, die jeder Benutzer mit Zugriff auf das Modul für sich ein- und ausschalten kann. Ist sie an, meldet Tessera per E-Mail und, solange Tessera geöffnet ist, als Benachrichtigung auf dem Bildschirm (in der Desktop-App als Windows-Benachrichtigung), wenn die Cloud eine Störung hat – nicht erreichbar, keine gültige Antwort, Wartungsmodus, ausstehende Datenbank-Aktualisierung oder abgelaufener Support – und wenn sie wieder in Ordnung ist. Pro Änderung kommt genau eine Nachricht, solange die Störung anhält, nicht jede Stunde neu. Ein einzelner fehlgeschlagener Abruf löst keine Meldung aus: Die Kachel zeigt weiter den letzten guten Stand mit dem Hinweis „Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt“, und Tessera prüft nach etwa fünf Minuten erneut. Für die E-Mails muss der Mailversand eingerichtet sein und im Benutzerprofil eine E-Mail-Adresse stehen. Außerdem nennt die Kachel bei „Nicht erreichbar“ jetzt den Grund in Klartext, zum Beispiel „Zertifikat passt nicht zur Adresse“, „Adresse nicht gefunden“ oder „Zeitüberschreitung“, statt eines technischen Fehlercodes.
- Neue Dashboard-Kachel „Nextcloud-Status“: drei Zähler für Grün, Gelb und Rot und darunter die roten und gelben Clouds mit Kundenname und Grund. Ein Klick öffnet das Modul. Die Kachel erscheint nur für Benutzer, die das Modul nutzen dürfen.
- Neue Gruppe „Finanzbuchhaltung“ in der Seitenleiste mit zwei Modulen. Beide aktiviert ein Administrator im Marktplatz; wer sie nutzen soll, bekommt zusätzlich die Freigabe.
- Kantinenabrechnung: Die CSV-Datei der Kantine hochladen (Excel-Export mit UTF-8 oder Windows-1252 ist beides in Ordnung). Tessera zeigt Zeilenzahl, Abrechnungsmonat und Gesamtbetrag, nennt fehlerhafte Zeilen mit Zeilennummer und weist auf unterschiedliche Abrechnungsmonate hin. Ist alles in Ordnung, laden Sie mit einem Klick die DATEV-Lohndatei herunter. Beraternummer, Mandantennummer und Lohnart trägt ein Administrator einmalig ein; bis dahin ist der Download gesperrt. Die hochgeladenen Daten werden nicht gespeichert.
- Handelsware: Eine Excel-Liste mit Handelswaren-Umsätzen hochladen. Tessera ordnet jedem Produkt sein Konto zu, markiert neue Produkte mit „neu“ und vergibt ihnen das nächste freie Gegenkonto. Das Buchungsdatum wird aus dem Dateinamen abgeleitet (letzter Tag des Monats) und lässt sich ändern. Neue Konten werden erst beim Herunterladen der Buchungsdatei gespeichert. Im Reiter „Konten“ pflegen Sie die Kontenliste, lesen sie aus einer CSV-Datei ein (ersetzt alle vorhandenen Konten, nach Rückfrage) und exportieren sie als CSV. Standard-Erlöskonto und Startwert für das Gegenkonto trägt ein Administrator einmalig ein.
- Modul-Freigaben haben jetzt zwei Stufen: „Benutzen“ (wie bisher) und „Verwalten“. Wer ein Modul verwalten darf, ändert dessen Einstellungen selbst, ohne Administrator zu sein, zum Beispiel in der Kantinenabrechnung, bei Handelsware und bei den Proxmox-Servern. Ein Administrator wählt die Stufe je Gruppe in der Freigaben-Matrix oder je Benutzer in den Benutzerdetails; bestehende Freigaben bleiben „Benutzen“. Freigaben vergeben und Module aktivieren dürfen weiterhin nur Administratoren. Das Modul DKV-Rechnung steht Administratoren und Benutzern mit „Verwalten“ zur Verfügung.
## 1.9.2 – 2026-10-02
### Neu
@@ -0,0 +1,46 @@
-- 261002-fm5 — Finanzbuchhaltung: Modul "Kantinenabrechnung" (kantine-datev).
--
-- Zweck: eine neue Tabelle `KantineDatevConfig` mit den drei Nummern, die der
-- Administrator einmalig je Mandant hinterlegt (Beraternummer, Mandantennummer,
-- Lohnart). Eine Zeile je Mandant (Singleton, Vorbild `DkvModuleConfig`). Die
-- Felder sind Text, damit fuehrende Nullen erhalten bleiben, und haben
-- ABSICHTLICH keinen Standardwert: solange sie leer sind, sperrt das Modul die
-- Verarbeitung. Die hochgeladene Kantinen-CSV wird nicht gespeichert.
--
-- Von Hand geschrieben (Vorbild 20260923140000_proxmox_server), von Hand
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
--
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die Tabelle
-- traegt `tenantId` und `tenant_isolation_policy` OHNE Benutzerdimension
-- (`USING ("tenantId" = current_tenant_id())`, Form aus `DkvModuleConfig`) —
-- Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers.
-- Keine `system_read_policy`: es gibt keinen Hintergrunddienst, der diese
-- Einstellungen ueber alle Mandanten liest.
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
CREATE TABLE "KantineDatevConfig" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"beraterNr" TEXT,
"mandantNr" TEXT,
"lohnart" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "KantineDatevConfig_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "KantineDatevConfig_tenantId_key" ON "KantineDatevConfig"("tenantId");
CREATE INDEX "KantineDatevConfig_tenantId_idx" ON "KantineDatevConfig"("tenantId");
ALTER TABLE "KantineDatevConfig" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "KantineDatevConfig" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "KantineDatevConfig"
USING ("tenantId" = current_tenant_id());
@@ -0,0 +1,70 @@
-- 261002-fm5 — Finanzbuchhaltung: Modul "Handelsware" (handelsware-datev).
--
-- Zweck: zwei neue Tabellen. `HandelswareDatevConfig` traegt die Einstellungen
-- des Mandanten (Standard-Erloeskonto fuer neue Konten, Startwert fuer die
-- Gegenkonto-Vergabe bei leerer Kontenliste) — eine Zeile je Mandant
-- (Singleton, Vorbild `DkvModuleConfig`/`KantineDatevConfig`). Beide Zahlen
-- haben ABSICHTLICH keinen Standardwert: solange sie leer sind, sperrt das
-- Modul die Verarbeitung. `HandelswareKonto` ist die Kontenliste (Produktname
-- -> Gegenkonto, Erloeskonto) — mehrere Zeilen je Mandant, der Name ist je
-- Mandant eindeutig, das Gegenkonto bewusst nicht (mehrere Produkte duerfen
-- auf dasselbe Gegenkonto laufen).
--
-- Von Hand geschrieben (Vorbild 20260923140000_proxmox_server), von Hand
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
--
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): beide
-- Tabellen tragen `tenantId` und `tenant_isolation_policy` OHNE
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`, Form aus
-- `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten
-- eines Benutzers. Keine `system_read_policy`: es gibt keinen Hintergrunddienst,
-- der diese Tabellen ueber alle Mandanten liest.
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
-- 1) HandelswareDatevConfig
CREATE TABLE "HandelswareDatevConfig" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"erloeskonto" INTEGER,
"startGegenkonto" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HandelswareDatevConfig_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "HandelswareDatevConfig_tenantId_key" ON "HandelswareDatevConfig"("tenantId");
CREATE INDEX "HandelswareDatevConfig_tenantId_idx" ON "HandelswareDatevConfig"("tenantId");
ALTER TABLE "HandelswareDatevConfig" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "HandelswareDatevConfig" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "HandelswareDatevConfig"
USING ("tenantId" = current_tenant_id());
-- 2) HandelswareKonto
CREATE TABLE "HandelswareKonto" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"gegenkonto" INTEGER NOT NULL,
"erloeskonto" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HandelswareKonto_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "HandelswareKonto_tenantId_name_key" ON "HandelswareKonto"("tenantId", "name");
CREATE INDEX "HandelswareKonto_tenantId_idx" ON "HandelswareKonto"("tenantId");
ALTER TABLE "HandelswareKonto" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "HandelswareKonto" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "HandelswareKonto"
USING ("tenantId" = current_tenant_id());
@@ -0,0 +1,28 @@
-- 261002-icv — Freigabestufe fuer Modul-Freigaben: Benutzen (USE) und
-- Verwalten (MANAGE).
--
-- Zweck: jede Zeile in "ModuleGrant" bekommt eine Stufe. USE ist der Bestand
-- und der Standard (Modul oeffnen und benutzen). MANAGE erlaubt zusaetzlich,
-- die eigenen Einstellungen dieses einen Moduls zu aendern. Freigaben
-- erteilen, Module aktivieren und die uebrige Verwaltung bleiben
-- Administratoren vorbehalten (das erzwingt die Anwendung, nicht diese
-- Migration).
--
-- Bestandsdaten: durch den DEFAULT 'USE' werden alle vorhandenen Freigaben zu
-- USE — niemand gewinnt durch die Migration Rechte.
--
-- Von Hand geschrieben (Vorbild 20261002120000_kantine_datev_config).
--
-- Zeilenschutz: keine neue Tabelle. Die vorhandenen Regeln auf "ModuleGrant"
-- filtern Zeilen, nicht Spalten, und bleiben unveraendert — rls-coverage
-- braucht nichts. PostgreSQL gewaehrt USAGE auf neue Typen automatisch an
-- PUBLIC, die Anwendungsrolle tessera_app kann den Aufzaehlungstyp also
-- verwenden.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken die Zeilenregeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
CREATE TYPE "ModuleGrantLevel" AS ENUM ('USE', 'MANAGE');
ALTER TABLE "ModuleGrant" ADD COLUMN "level" "ModuleGrantLevel" NOT NULL DEFAULT 'USE';
@@ -0,0 +1,65 @@
-- quick-261002-k67 — Modul Nextcloud-Status.
--
-- Zweck: neue Tabelle "NextcloudInstance" fuer die vom Verwalter
-- eingetragenen Nextcloud-Clouds der Kunden (Kundenname, Adresse, optionales
-- Logo) samt zuletzt ermitteltem Zustand (Erreichbarkeit, Wartungsmodus,
-- Versionstext, Fehlerart). Der Zustand liegt direkt auf der Zeile, es gibt
-- kein Zwischenlager. Logo-Bytes liegen als BYTEA an der Zeile (hoechstens
-- 1 MiB, Pruefung im Dienst); Abfragen ausser dem Logo-Abruf waehlen sie
-- nie mit aus.
--
-- Von Hand geschrieben (Vorbild 20260923140000_proxmox_server), von Hand
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
--
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die
-- Tabelle traegt `tenantId` und `tenant_isolation_policy` OHNE
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — die
-- Clouds sind gemeinsame Daten der Organisation, nicht persoenliche Daten
-- eines einzelnen Benutzers.
--
-- Zusaetzlich eine `system_read_policy` (Form aus
-- 20260914120000_rls_system_context_read): der stuendliche Hintergrunddienst
-- liest ueber `forSystem()` genau einmal je Durchlauf Kennung und Mandant
-- aller Clouds und prueft danach jede Cloud an ihren eigenen Mandanten
-- gebunden. Geschrieben wird nie im Systemkontext.
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
CREATE TABLE "NextcloudInstance" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"customerName" TEXT NOT NULL,
"baseUrl" TEXT NOT NULL,
"logoUrl" TEXT,
"logoData" BYTEA,
"logoMime" TEXT,
"logoVersion" INTEGER NOT NULL DEFAULT 0,
"lastCheckedAt" TIMESTAMP(3),
"reachable" BOOLEAN,
"maintenance" BOOLEAN,
"needsDbUpgrade" BOOLEAN,
"versionString" TEXT,
"edition" TEXT,
"productName" TEXT,
"errorKind" TEXT,
"errorDetail" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NextcloudInstance_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "NextcloudInstance_tenantId_idx" ON "NextcloudInstance"("tenantId");
ALTER TABLE "NextcloudInstance" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "NextcloudInstance" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "NextcloudInstance"
USING ("tenantId" = current_tenant_id());
CREATE POLICY system_read_policy ON "NextcloudInstance"
FOR SELECT USING (is_system_context());
@@ -0,0 +1,68 @@
-- quick-261002-kxc — Nextcloud-Status: persoenliche Benachrichtigung.
--
-- Zweck: (1) neue Tabelle "NextcloudAlertSubscription" — wer fuer welche
-- Cloud die Glocke eingeschaltet hat (je Benutzer und Cloud hoechstens eine
-- Zeile); (2) fuenf neue Spalten an "NextcloudInstance": Zaehler und Zeitpunkt
-- der aufeinanderfolgenden Fehlschlaege (Zwei-Fehlschlaege-Regel) und der
-- zuletzt gemeldete Zustand ('ok' | 'red') samt Grund und Zeitpunkt. Der
-- gemeldete Zustand wird VOR dem Mailversand per bedingtem Update beansprucht,
-- damit mehrere API-Instanzen oder ein Neustart nie doppelt melden.
-- Bestehende Zeilen starten als 'ok' ohne Fehlschlaege.
--
-- Von Hand geschrieben (Vorbild 20261002150000_nextcloud_status und
-- 20260929140000_reminder).
--
-- Zeilenschutz: das Abonnement ist ein persoenliches Datum, deshalb
-- `tenant_isolation_policy` MIT Benutzerdimension — exakt wie "Reminder"
-- (ohne gesetzten Benutzer gilt nur der Mandant, mit Benutzer zusaetzlich
-- "userId"). Keine `system_read_policy`: die Tabelle wird nie im
-- Systemkontext gelesen, jede Abfrage laeuft an den Mandanten gebunden. Die
-- neuen Spalten von "NextcloudInstance" fallen unter deren bestehende Regeln.
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
-- AlterTable
ALTER TABLE "NextcloudInstance"
ADD COLUMN "consecutiveFailures" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "firstFailureAt" TIMESTAMP(3),
ADD COLUMN "alertState" TEXT NOT NULL DEFAULT 'ok',
ADD COLUMN "alertReason" TEXT,
ADD COLUMN "alertChangedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "NextcloudAlertSubscription" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"instanceId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "NextcloudAlertSubscription_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "NextcloudAlertSubscription_instanceId_userId_key" ON "NextcloudAlertSubscription"("instanceId", "userId");
-- CreateIndex
CREATE INDEX "NextcloudAlertSubscription_tenantId_userId_idx" ON "NextcloudAlertSubscription"("tenantId", "userId");
-- AddForeignKey
ALTER TABLE "NextcloudAlertSubscription" ADD CONSTRAINT "NextcloudAlertSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NextcloudAlertSubscription" ADD CONSTRAINT "NextcloudAlertSubscription_instanceId_fkey" FOREIGN KEY ("instanceId") REFERENCES "NextcloudInstance"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Zeilenschutz: Mandant UND Benutzer (Muster "Reminder")
ALTER TABLE "NextcloudAlertSubscription" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "NextcloudAlertSubscription" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "NextcloudAlertSubscription"
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
);
@@ -0,0 +1,89 @@
-- quick-261003-387 — Modulkategorien durch Administratoren bearbeitbar.
--
-- Zweck: zwei neue Tabellen und eine neue Spalte.
-- * "ModuleCategory": die Kategorien einer Organisation (Kennung, optionaler
-- eigener Name, Reihenfolge, Systemkennzeichen fuer "Eigene Module").
-- Die Kennung ist unveraenderlich, sie steht als URL-Segment in
-- /modules/<kennung>/<slug>.
-- * "ModuleCategoryPlacement": Zuordnung eines Marktplatz-Moduls zu einer
-- Kategorie der Organisation samt Reihenfolge. Die Spalte "Module"."category"
-- (fuer alle Organisationen gleich) bleibt unveraendert; die wirksame
-- Kategorie ist die Zuordnung, sonst diese Spalte.
-- * "CustomModule"."sortOrder": Reihenfolge gemeinsamer eigener Module
-- innerhalb ihrer Kategorie (NULL = noch nicht sortiert).
-- Es gibt keine Rueckfuellung in SQL: der Dienst legt den Grundbestand je
-- Organisation beim ersten Lesen an.
--
-- Von Hand geschrieben (Vorbild 20261002150000_nextcloud_status), von Hand
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
--
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): beide
-- Tabellen tragen `tenantId` und `tenant_isolation_policy` OHNE
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — die
-- Kategorien sind gemeinsame Einstellungen der Organisation, nicht
-- persoenliche Daten eines Benutzers. Keine `system_read_policy`: es gibt
-- keinen Hintergrunddienst, der darueber liest.
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
-- AlterTable
ALTER TABLE "CustomModule" ADD COLUMN "sortOrder" INTEGER;
-- CreateTable
CREATE TABLE "ModuleCategory" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"name" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isSystem" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ModuleCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ModuleCategoryPlacement" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"moduleId" TEXT NOT NULL,
"categoryKey" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ModuleCategoryPlacement_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ModuleCategory_tenantId_idx" ON "ModuleCategory"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "ModuleCategory_tenantId_key_key" ON "ModuleCategory"("tenantId", "key");
-- CreateIndex
CREATE INDEX "ModuleCategoryPlacement_tenantId_idx" ON "ModuleCategoryPlacement"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "ModuleCategoryPlacement_tenantId_moduleId_key" ON "ModuleCategoryPlacement"("tenantId", "moduleId");
-- AddForeignKey
ALTER TABLE "ModuleCategoryPlacement" ADD CONSTRAINT "ModuleCategoryPlacement_moduleId_fkey" FOREIGN KEY ("moduleId") REFERENCES "Module"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Zeilenschutz
ALTER TABLE "ModuleCategory" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "ModuleCategory" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "ModuleCategory"
USING ("tenantId" = current_tenant_id());
ALTER TABLE "ModuleCategoryPlacement" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "ModuleCategoryPlacement" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "ModuleCategoryPlacement"
USING ("tenantId" = current_tenant_id());
+161 -2
View File
@@ -58,6 +58,7 @@ model User {
moduleGrants ModuleGrant[]
customModules CustomModule[]
reminders Reminder[]
nextcloudAlertSubscriptions NextcloudAlertSubscription[]
@@index([tenantId])
@@index([username])
@@ -119,6 +120,7 @@ model Module {
updatedAt DateTime @updatedAt
activations TenantModuleActivation[]
grants ModuleGrant[]
categoryPlacements ModuleCategoryPlacement[]
}
model TenantModuleActivation {
@@ -140,12 +142,20 @@ model TenantModuleActivation {
// darf höchstens eine Gruppe die Standard-Markierung tragen, DB-erzwungen
// über einen partiellen Unique-Index in der Hand-SQL-Ergänzung dieser
// 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 {
MANUAL
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 {
id String @id @default(uuid())
tenantId String
@@ -191,6 +201,10 @@ model ModuleGrant {
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
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
// werden per hand-editierter migration.sql ergänzt — Prisma 6.19 hat kein
@@ -344,6 +358,56 @@ model DkvModuleConfig {
@@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-
// alert mailbox config, mirroring DkvModuleConfig's shape/pattern exactly
// (own tenantId @unique row, own encrypted creds — D-03: each module keeps
@@ -722,6 +786,61 @@ model ProxmoxServerStatus {
@@index([tenantId])
}
// Nextcloud-Status (quick-261002-k67): vom Verwalter eingetragene Clouds der
// Kunden. Der zuletzt ermittelte Zustand liegt direkt auf der Zeile (L-03),
// es gibt kein Zwischenlager. Logo-Bytes liegen als bytea an der Zeile (D-A,
// hoechstens 1 MiB); Listen- und Planerabfragen waehlen sie nie mit aus.
// Zeilenschutz nach Muster ProxmoxServer (tenantId, keine Relation zu Tenant).
model NextcloudInstance {
id String @id @default(uuid())
tenantId String
customerName String
baseUrl String
logoUrl String?
logoData Bytes?
logoMime String?
logoVersion Int @default(0)
lastCheckedAt DateTime?
reachable Boolean? // null = noch nie geprueft
maintenance Boolean?
needsDbUpgrade Boolean?
versionString String?
edition String?
productName String?
errorKind String?
errorDetail String?
// quick-261002-kxc: Zwei-Fehlschlaege-Regel und zuletzt gemeldeter Zustand.
// consecutiveFailures/firstFailureAt: aufeinanderfolgende fehlgeschlagene
// Abrufe (der erste aendert den gespeicherten Zustand nicht).
consecutiveFailures Int @default(0)
firstFailureAt DateTime?
// zuletzt gemeldeter Zustand: 'ok' | 'red' (Anspruch vor dem Mailversand)
alertState String @default("ok")
alertReason String?
alertChangedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
subscriptions NextcloudAlertSubscription[]
@@index([tenantId])
}
// quick-261002-kxc: persoenliche Benachrichtigung (Glocke) je Benutzer und
// Nextcloud-Cloud. Zeilenschutz MIT Benutzerdimension wie "Reminder"; faellt
// Benutzer oder Cloud weg, faellt das Abonnement mit.
model NextcloudAlertSubscription {
id String @id @default(uuid())
tenantId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
instanceId String
instance NextcloudInstance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([instanceId, userId])
@@index([tenantId, userId])
}
// Eigene Module (quick-260929-9wc): vom Administrator angelegte Seitenleisten-
// Eintraege, die eine externe https-Seite im Rahmen zeigen. Sichtbar fuer alle
// Benutzer des Mandanten. Zeilenschutz nach Muster ProxmoxServer (tenantId,
@@ -731,7 +850,11 @@ model CustomModule {
tenantId String
name String
url String
category String // eine der MODULE_CATEGORIES aus @tessera/shared
category String // Kennung einer ModuleCategory der Organisation
// quick-261003-387: Reihenfolge innerhalb der Kategorie (nur gemeinsame
// Eintraege; null = noch nicht sortiert, steht hinten). Persoenliche
// Eintraege bekommen nie eine sortOrder.
sortOrder Int?
// quick-260929-dzu: null = gemeinsamer Eintrag (vom Administrator, fuer alle
// sichtbar); gesetzt = persoenlicher Eintrag, nur fuer diesen Benutzer
// sichtbar. Faellt der Benutzer weg, fallen seine Eintraege mit.
@@ -788,3 +911,39 @@ model WelcomeMailTemplate {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// quick-261003-387: Modulkategorien je Organisation, durch Administratoren
// pflegbar. `key` ist unveraenderlich (URL-Segment /modules/<key>/<slug>),
// `name` null = Uebersetzung moduleCategories.<key>. isSystem nur fuer
// "custom-modules" (Eigene Module): umbenennbar und verschiebbar, nicht
// loeschbar.
model ModuleCategory {
id String @id @default(uuid())
tenantId String
key String
name String?
sortOrder Int @default(0)
isSystem Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, key])
@@index([tenantId])
}
// quick-261003-387: Zuordnung eines Marktplatz-Moduls zu einer Kategorie der
// Organisation samt Reihenfolge. Wirksame Kategorie = Zuordnung, sonst
// Module.category (die Spalte selbst bleibt fuer alle gleich).
model ModuleCategoryPlacement {
id String @id @default(uuid())
tenantId String
moduleId String
module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade)
categoryKey String
sortOrder Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, moduleId])
@@index([tenantId])
}
@@ -0,0 +1,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;
}
+8
View File
@@ -26,8 +26,12 @@ import { TenantGuard } from './tenant/tenant.guard';
import { TenantModule } from './tenant/tenant.module';
import { TendersModule } from './tenders/tenders.module';
import { UserModule } from './user/user.module';
import { HandelswareDatevModule } from './handelsware-datev/handelsware-datev.module';
import { KantineDatevModule } from './kantine-datev/kantine-datev.module';
import { NextcloudStatusModule } from './nextcloud-status/nextcloud-status.module';
import { ProxmoxModule } from './proxmox/proxmox.module';
import { CustomModulesModule } from './custom-modules/custom-modules.module';
import { ModuleCategoriesModule } from './module-categories/module-categories.module';
import { RemindersModule } from './reminders/reminders.module';
@Module({
@@ -55,7 +59,11 @@ import { RemindersModule } from './reminders/reminders.module';
TendersModule,
BugReportsModule,
ProxmoxModule,
NextcloudStatusModule,
KantineDatevModule,
HandelswareDatevModule,
CustomModulesModule,
ModuleCategoriesModule,
RemindersModule,
],
providers: [
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { formatRequestLine } from './request-log';
describe('formatRequestLine', () => {
it('schreibt Methode, Pfad, Status, Dauer und Benutzer', () => {
expect(
formatRequestLine({
method: 'GET',
url: '/modules/x',
status: 200,
ms: 12,
username: 'admin',
}),
).toBe('GET /modules/x 200 12 ms user=admin');
});
it('laesst den Abfrageteil weg', () => {
expect(
formatRequestLine({ method: 'GET', url: '/auth/reset?token=geheim', status: 200, ms: 1 }),
).toBe('GET /auth/reset 200 1 ms user=-');
});
it('markiert langsame Anfragen', () => {
expect(formatRequestLine({ method: 'POST', url: '/a', status: 201, ms: 4500 })).toContain(
'LANGSAM',
);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { Logger } from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express';
/**
* Eine Protokollzeile je API-Anfrage im Docker-Log (quick-261002):
* `GET /modules/nextcloud-status/instances 200 34 ms user=admin`.
*
* Nur Methode, Pfad OHNE Abfrageteil (dort koennten Kennungen oder Token
* stehen), Status, Dauer und Benutzername — nie Koerper, Cookies oder
* Kopfzeilen. `/health` wird ausgelassen, der Docker-Healthcheck fragt es
* alle paar Sekunden ab. Fehler (>= 500) als `error`, Abweisungen (>= 400)
* als `warn`, langsame Anfragen (>= 3 s) mit Vermerk.
*/
const logger = new Logger('HTTP');
export const SLOW_REQUEST_MS = 3000;
export function formatRequestLine(input: {
method: string;
url: string;
status: number;
ms: number;
username?: string | null;
}): string {
const path = input.url.split('?')[0] ?? input.url;
const slow = input.ms >= SLOW_REQUEST_MS ? ' LANGSAM' : '';
return `${input.method} ${path} ${input.status} ${input.ms} ms user=${input.username ?? '-'}${slow}`;
}
export function requestLogMiddleware(req: Request, res: Response, next: NextFunction): void {
const url = req.originalUrl ?? req.url;
if (url === '/health' || url.startsWith('/health?') || url.startsWith('/health/')) {
next();
return;
}
const started = process.hrtime.bigint();
res.on('finish', () => {
const ms = Number((process.hrtime.bigint() - started) / 1_000_000n);
const username = (req as Request & { user?: { username?: string } }).user?.username;
const line = formatRequestLine({
method: req.method,
url,
status: res.statusCode,
ms,
username,
});
if (res.statusCode >= 500) logger.error(line);
else if (res.statusCode >= 400 || ms >= SLOW_REQUEST_MS) logger.warn(line);
else logger.log(line);
});
next();
}
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { ModuleCategoriesModule } from '../module-categories/module-categories.module';
import { CustomModulesController } from './custom-modules.controller';
import { CustomModulesService } from './custom-modules.service';
@@ -7,6 +8,7 @@ import { CustomModulesService } from './custom-modules.service';
* `ProxmoxModule`, das PrismaService ebenfalls ohne eigenen Import erhaelt).
*/
@Module({
imports: [ModuleCategoriesModule],
controllers: [CustomModulesController],
providers: [CustomModulesService],
})
@@ -1,4 +1,4 @@
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { Role } from '@prisma/client';
import { describe, expect, it, vi } from 'vitest';
@@ -51,9 +51,18 @@ const admin = { id: 'admin1', role: Role.ADMIN };
const userA = { id: 'ua', role: Role.USER };
const userB = { id: 'ub', role: Role.USER };
// Kategorien-Dienst (quick-261003-387): die Organisation fuehrt eine feste
// Menge von Kennungen; eine andere lehnt assertCategoryKey mit 400 ab.
const KNOWN_CATEGORIES = new Set(['fleet', 'infrastructure', 'custom-modules', 'neu-angelegt']);
function setup() {
const prisma = makeFakePrisma();
return { prisma, service: new CustomModulesService(prisma as any) };
const categories = {
assertCategoryKey: vi.fn(async (_tenantId: string, key: string) => {
if (!KNOWN_CATEGORIES.has(key)) throw new BadRequestException('Unbekannte Kategorie');
}),
};
return { prisma, categories, service: new CustomModulesService(prisma as any, categories as any) };
}
describe('CustomModulesService — anlegen', () => {
@@ -106,6 +115,40 @@ describe('CustomModulesService — anlegen', () => {
});
});
describe('CustomModulesService — Kategorie gegen die Organisation pruefen', () => {
it('create: eine vorhandene Kennung (auch eine neu angelegte) ist erlaubt', async () => {
const { categories, service } = setup();
const res: any = await service.create('t1', userA, { ...dto, category: 'neu-angelegt' });
expect(res.category).toBe('neu-angelegt');
expect(categories.assertCategoryKey).toHaveBeenCalledWith('t1', 'neu-angelegt');
});
it('create: eine unbekannte Kategorie -> 400, nichts gespeichert', async () => {
const { prisma, service } = setup();
await expect(
service.create('t1', userA, { ...dto, category: 'gibtsnicht' }),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.customModule.create).not.toHaveBeenCalled();
});
it('update: eine unbekannte Kategorie -> 400, der Eintrag bleibt unveraendert', async () => {
const { prisma, service } = setup();
const created: any = await service.create('t1', userA, dto);
await expect(
service.update('t1', userA, created.id, { category: 'gibtsnicht' }),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.customModule.update).not.toHaveBeenCalled();
});
it('update ohne Kategorie prueft nichts', async () => {
const { categories, service } = setup();
const created: any = await service.create('t1', userA, dto);
categories.assertCategoryKey.mockClear();
await service.update('t1', userA, created.id, { name: 'Neuer Name' });
expect(categories.assertCategoryKey).not.toHaveBeenCalled();
});
});
describe('CustomModulesService — lesen', () => {
it('list liefert gemeinsame plus eigene Eintraege, nie die eines anderen Benutzers', async () => {
const { service } = setup();
@@ -1,5 +1,6 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { Role } from '@prisma/client';
import { ModuleCategoriesService } from '../module-categories/module-categories.service';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import type { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
@@ -10,6 +11,7 @@ const CUSTOM_MODULE_SELECT = {
name: true,
url: true,
category: true,
sortOrder: true,
ownerUserId: true,
createdAt: true,
updatedAt: true,
@@ -56,7 +58,10 @@ function toResponse<T extends { ownerUserId: string | null }>(row: T) {
*/
@Injectable()
export class CustomModulesService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly categories: ModuleCategoriesService,
) {}
/** Gemeinsame Eintraege plus die eigenen des Aufrufers. */
async list(tenantId: string, caller: CustomModuleCaller) {
@@ -81,6 +86,9 @@ export class CustomModulesService {
if (shared && !isAdmin(caller)) {
throw new ForbiddenException('Gemeinsame Einträge dürfen nur Administratoren anlegen');
}
// quick-261003-387: jede Kategorie der Organisation ist erlaubt, eine
// unbekannte lehnt der Dienst mit 400 ab (statt fester Liste im DTO).
await this.categories.assertCategoryKey(tenantId, dto.category);
const data = {
tenantId,
name: dto.name,
@@ -106,6 +114,9 @@ export class CustomModulesService {
dto: UpdateCustomModuleDto,
) {
const tenantPrisma = await this.writableClient(tenantId, caller, id);
if (dto.category !== undefined) {
await this.categories.assertCategoryKey(tenantId, dto.category);
}
const data: { name?: string; url?: string; category?: string } = {};
if (dto.name !== undefined) data.name = dto.name;
if (dto.url !== undefined) data.url = dto.url;
@@ -29,11 +29,23 @@ describe('CreateCustomModuleDto', () => {
expect(await errorsFor(CreateCustomModuleDto, { ...valid, url })).toContain('url');
});
it('lehnt eine unbekannte Kategorie ab', async () => {
expect(await errorsFor(CreateCustomModuleDto, { ...valid, category: 'other' })).toContain(
'category',
);
});
// quick-261003-387: das DTO prueft nur das Format der Kennung; ob die
// Organisation sie fuehrt, entscheidet der Dienst (400, siehe Dienst-Spec).
it.each(['', 'Gross', 'mit leerzeichen', '-fuehrend', 'x'.repeat(61)])(
'lehnt die Kategorie-Kennung %j ab',
async (category) => {
expect(await errorsFor(CreateCustomModuleDto, { ...valid, category })).toContain('category');
},
);
it.each(['fleet', 'custom-modules', 'werkzeuge-tools'])(
'akzeptiert die Kategorie-Kennung %s',
async (category) => {
expect(await errorsFor(CreateCustomModuleDto, { ...valid, category })).not.toContain(
'category',
);
},
);
it.each(['', ' '])('lehnt den Namen %j ab', async (name) => {
expect(await errorsFor(CreateCustomModuleDto, { ...valid, name })).toContain('name');
@@ -61,7 +73,7 @@ describe('UpdateCustomModuleDto', () => {
it('prueft jedes gesetzte Feld gleich', async () => {
expect(await errorsFor(UpdateCustomModuleDto, { url: 'http://example.com' })).toContain('url');
expect(await errorsFor(UpdateCustomModuleDto, { category: 'other' })).toContain('category');
expect(await errorsFor(UpdateCustomModuleDto, { category: 'Nicht Gueltig' })).toContain('category');
expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name');
});
@@ -1,12 +1,11 @@
import { OmitType, PartialType } from '@nestjs/mapped-types';
import { CUSTOM_MODULE_CATEGORIES } from '@tessera/shared';
import { Transform } from 'class-transformer';
import {
IsBoolean,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
Validate,
ValidatorConstraint,
@@ -60,8 +59,13 @@ export class CreateCustomModuleDto {
@Validate(NurHttpsOhneZugangsdatenConstraint)
url!: string;
@IsIn([...CUSTOM_MODULE_CATEGORIES])
category!: (typeof CUSTOM_MODULE_CATEGORIES)[number];
/**
* Kennung einer Kategorie der Organisation (quick-261003-387). Das Format
* prueft das DTO, ob die Kategorie existiert der Dienst (sonst 400).
*/
@IsString()
@Matches(/^[a-z0-9][a-z0-9-]{0,59}$/)
category!: string;
/**
* quick-260929-dzu: `true` legt einen gemeinsamen Eintrag fuer alle Benutzer
@@ -21,14 +21,26 @@ describe('widget-module-map (quick-260922-m1h)', () => {
}
});
// quick-260924-i8v: Proxmox ist die erste modulgebundene Kachel; alle
// uebrigen bleiben Plattform-Kacheln ohne Modulbezug.
it('nur proxmox traegt einen Modulbezug, alle uebrigen Kacheln sind Plattform-Kacheln', () => {
for (const type of WIDGET_TYPES.filter((t) => t !== 'proxmox')) {
// quick-260924-i8v: Proxmox ist die erste modulgebundene Kachel,
// quick-261002-k67 ergaenzt Nextcloud-Status; alle uebrigen bleiben
// Plattform-Kacheln ohne Modulbezug.
it('nur proxmox und nextcloud-status tragen einen Modulbezug, alle uebrigen Kacheln sind Plattform-Kacheln', () => {
expect(Object.keys(WIDGET_MODULE_MAP).sort()).toEqual(['nextcloud-status', 'proxmox']);
for (const type of WIDGET_TYPES.filter((t) => t !== 'proxmox' && t !== 'nextcloud-status')) {
expect(getModuleSlugForWidgetType(type)).toBeUndefined();
}
});
it("die Nextcloud-Status-Kachel gehoert zum Modul 'nextcloud-status' (T-k67-04)", () => {
expect(getModuleSlugForWidgetType('nextcloud-status')).toBe('nextcloud-status');
});
it('WIDGET_TYPES endet mit nextcloud-status (zwoelf Typen, Reihenfolge unveraendert)', () => {
expect(WIDGET_TYPES).toHaveLength(12);
expect(WIDGET_TYPES.at(-1)).toBe('nextcloud-status');
expect(WIDGET_TYPES.at(-2)).toBe('reminder');
});
it("die Proxmox-Kachel gehoert zum Modul 'proxmox' (T-I8V-01)", () => {
expect(getModuleSlugForWidgetType('proxmox')).toBe('proxmox');
});
+6 -5
View File
@@ -23,16 +23,17 @@ import { WIDGET_MODULE_SLUGS } from '@tessera/shared';
* für ein Feld, das derzeit für jede Zeile leer wäre, wiegt schwerer als
* diese Konstante mit identischer Aussagekraft (15-RESEARCH.md Pitfall 5).
*
* Seit quick-260924-i8v steht dort genau ein Eintrag: `proxmox` →
* `proxmox`. Die übrigen neun Widget-Typen (clock/search/calendar/note/
* calculator/favorites/stopwatch/picture-frame/xframe) sind
* Plattform-Widgets ohne Modulbezug.
* Seit quick-261002-k67 stehen dort zwei Einträge: `proxmox` → `proxmox`
* (quick-260924-i8v) und `nextcloud-status` → `nextcloud-status`. Die
* übrigen Widget-Typen (clock/search/calendar/note/calculator/favorites/
* stopwatch/picture-frame/xframe/reminder) sind Plattform-Widgets ohne
* Modulbezug.
*/
export const WIDGET_MODULE_MAP: Readonly<Record<string, string>> = WIDGET_MODULE_SLUGS;
/**
* Liefert den Modul-Slug für einen Widget-Typ, oder `undefined`, wenn
* der Typ kein Modul-Widget ist (alle Typen außer `proxmox`). Einziger Lesezugriff auf die Zuordnungstabelle,
* der Typ kein Modul-Widget ist (alle Typen außer `proxmox` und `nextcloud-status`). Einziger Lesezugriff auf die Zuordnungstabelle,
* damit Tests sie gezielt mocken können.
*/
export function getModuleSlugForWidgetType(widgetType: string): string | undefined {
+12 -17
View File
@@ -15,8 +15,7 @@ import {
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import { ModuleManage } from '../module-registry/module.guard';
import type {
AuthenticatedRequest,
UploadedFileLike,
@@ -29,11 +28,17 @@ import { DkvHistoryQueryDto } from './dto/dkv-history.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).
* Global JwtAuthGuard enforces JWT authentication; RolesGuard enforces the
* @Roles decorator. No route is publicly accessible.
* The whole module is Verwalten-level: `@ModuleManage('dkv-fleet')` on the
* class replaces the former per-handler @Roles(ADMIN, SUPER_ADMIN). Access is
* 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).
* 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
*/
@Controller('dkv')
@ModuleManage('dkv-fleet')
export class DkvController {
constructor(
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('config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async getConfig(@Req() req: AuthenticatedRequest) {
const tenantId = this._requireTenant(req);
const config = await this.dkvService.getConfigForApi(tenantId);
@@ -79,7 +84,6 @@ export class DkvController {
* or stops the cron job if isActive is false.
*/
@Put('config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async saveConfig(@Req() req: AuthenticatedRequest, @Body() dto: DkvConfigDto) {
const tenantId = this._requireTenant(req);
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('check-now')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async checkNow(@Req() req: AuthenticatedRequest) {
const tenantId = this._requireTenant(req);
return this.dkvService.checkNow(tenantId);
@@ -109,7 +112,6 @@ export class DkvController {
* Used by the InboxConfigForm "Verbindung testen" button before saving.
*/
@Post('test-connection')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async testConnection(@Req() req: AuthenticatedRequest, @Body() dto: DkvConfigDto) {
const tenantId = this._requireTenant(req);
return this.dkvService.testConnection(tenantId, dto);
@@ -122,7 +124,6 @@ export class DkvController {
* T-07-06: pagination parameters validated by DkvHistoryQueryDto.
*/
@Get('history')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async getHistory(@Req() req: AuthenticatedRequest, @Query() query: DkvHistoryQueryDto) {
const tenantId = this._requireTenant(req);
const page = query.page ?? 1;
@@ -140,7 +141,6 @@ export class DkvController {
* containing path separators or non-whitelisted characters is rejected.
*/
@Get('exports/:filename')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async downloadExport(
@Req() req: AuthenticatedRequest,
@Param('filename') filename: string,
@@ -168,7 +168,6 @@ export class DkvController {
/** GET /dkv/vehicles — list all vehicle master records for this tenant. */
@Get('vehicles')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async listVehicles(@Req() req: AuthenticatedRequest) {
const tenantId = this._requireTenant(req);
return this.dkvService.listVehicles(tenantId);
@@ -176,7 +175,6 @@ export class DkvController {
/** POST /dkv/vehicles — create a new vehicle master record. */
@Post('vehicles')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async createVehicle(@Req() req: AuthenticatedRequest, @Body() dto: CreateVehicleDto) {
const tenantId = this._requireTenant(req);
return this.dkvService.createVehicle(tenantId, dto);
@@ -184,7 +182,6 @@ export class DkvController {
/** PUT /dkv/vehicles/:id — update an existing vehicle master record. */
@Put('vehicles/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async updateVehicle(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@@ -196,7 +193,6 @@ export class DkvController {
/** DELETE /dkv/vehicles/:id — delete a vehicle master record. */
@Delete('vehicles/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async deleteVehicle(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
const tenantId = this._requireTenant(req);
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.
*/
@Post('vehicles/import')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB — generous for any realistic vehicle list (WR-05)
}))
@@ -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).
@@ -21,4 +22,12 @@ export class CreateModuleGrantDto {
@IsString()
@IsOptional()
userId?: string;
/**
* Freigabestufe (261002-icv): 'USE' (Benutzen, Standard) oder 'MANAGE'
* (Verwalten). Beim Entzug (DELETE) wird das Feld ignoriert.
*/
@IsOptional()
@IsEnum(ModuleGrantLevel)
level?: ModuleGrantLevel;
}
+2
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { ModuleCategoriesModule } from '../module-categories/module-categories.module';
import { GroupsController } from './groups.controller';
import { GroupsService } from './groups.service';
import { ModuleGrantsController } from './module-grants.controller';
@@ -17,6 +18,7 @@ import { ModuleGrantsService } from './module-grants.service';
* ModuleAccessService (15-01/15-05), das hier nicht verwendet wird.
*/
@Module({
imports: [ModuleCategoriesModule],
controllers: [GroupsController, ModuleGrantsController],
providers: [GroupsService, ModuleGrantsService],
exports: [GroupsService, ModuleGrantsService],
+14
View File
@@ -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/);
});
});
describe('module_grant_level migration.sql (261002-icv)', () => {
const sql = readMigrationSql('_module_grant_level');
it('legt den Aufzählungstyp ModuleGrantLevel mit USE und MANAGE an', () => {
expect(sql).toContain(`CREATE TYPE "ModuleGrantLevel" AS ENUM ('USE', 'MANAGE');`);
});
it('fügt die Spalte level mit Standard USE hinzu (Bestand wird USE)', () => {
expect(sql).toContain(
`ALTER TABLE "ModuleGrant" ADD COLUMN "level" "ModuleGrantLevel" NOT NULL DEFAULT 'USE';`,
);
});
});
@@ -13,6 +13,7 @@ import { Role } from '@prisma/client';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { ModuleCategoriesService } from '../module-categories/module-categories.service';
import { CreateModuleGrantDto } from './dto/create-module-grant.dto';
import { ModuleGrantsService } from './module-grants.service';
@@ -29,7 +30,10 @@ import { ModuleGrantsService } from './module-grants.service';
*/
@Controller('module-grants')
export class ModuleGrantsController {
constructor(private readonly moduleGrantsService: ModuleGrantsService) {}
constructor(
private readonly moduleGrantsService: ModuleGrantsService,
private readonly moduleCategoriesService: ModuleCategoriesService,
) {}
private getTenantId(req: AuthenticatedRequest): string {
const tenantId = req.tenantId ?? req.user?.tenantId;
@@ -41,13 +45,20 @@ export class ModuleGrantsController {
/**
* GET /module-grants/matrix
* Module × Gruppen mit den bestehenden Gruppen-Grants (D-15).
* Module × Gruppen mit den bestehenden Gruppen-Grants (D-15); die Module
* stehen in der eingestellten Kategorie- und Modulreihenfolge.
*/
@Get('matrix')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async matrix(@Req() req: AuthenticatedRequest) {
return this.moduleGrantsService.getMatrix(this.getTenantId(req));
const tenantId = this.getTenantId(req);
const result = await this.moduleGrantsService.getMatrix(tenantId);
// quick-261003-387: Module in der Kategorie- und Modulreihenfolge der
// Organisation, jedes mit seiner wirksamen Kategorie. getMatrix selbst
// bleibt unveraendert.
const modules = await this.moduleCategoriesService.applyToModules(tenantId, result.modules);
return { ...result, modules };
}
/**
@@ -124,6 +124,12 @@ function makeFakePrisma() {
findFirst: async ({ where }: any) => {
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) => {
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', () => {
it('entfernt einen bestehenden Grant', async () => {
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.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 () => {
@@ -469,11 +563,32 @@ describe('ModuleGrantsService.getUserAccess', () => {
module: { id: 'mod-1', category: 'ops', name: 'Modul Eins' },
viaGroups: ['Gruppe A'],
direct: false,
directLevel: null,
manageViaGroups: [],
},
]);
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 () => {
const prisma = makeFakePrisma();
seedBase(prisma);
+63 -23
View File
@@ -4,6 +4,7 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
import { type ModuleGrant, ModuleGrantLevel } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
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
* Ansicht im Admin-UI.
*
* D-04: der Datensatz trägt keine Rechtestufe, und dieser Service bietet
* keine Methode, die eine solche setzen könnte.
* Seit 261002-icv trägt der Datensatz eine Freigabestufe `level` (Benutzen /
* Verwalten); nur dieser ausschließlich Administratoren zugängliche Service
* setzt sie.
*/
@Injectable()
export class ModuleGrantsService {
@@ -82,9 +84,9 @@ export class ModuleGrantsService {
*/
async grant(
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)) {
throw new BadRequestException(
'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 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 {
const created = await tenantPrisma.moduleGrant.create({
@@ -127,27 +160,18 @@ export class ModuleGrantsService {
moduleId,
groupId: groupId ?? null,
userId: userId ?? null,
level: level ?? ModuleGrantLevel.USE,
},
});
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;
} catch (err: unknown) {
if (prismaErrorCode(err) === 'P2002') {
const existing = await tenantPrisma.moduleGrant.findFirst({
where: {
tenantId,
moduleId,
groupId: groupId ?? null,
userId: userId ?? null,
},
});
const existing = await tenantPrisma.moduleGrant.findFirst({ where: targetWhere });
if (existing) {
this.logger.log(
`Grant bereits vorhanden (Doppelklick abgefangen): tenant=${tenantId} module=${moduleId} ${target}`,
);
return existing;
return applyToExisting(existing);
}
}
throw err;
@@ -202,7 +226,7 @@ export class ModuleGrantsService {
}),
tenantPrisma.moduleGrant.findMany({
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) => ({
moduleId: g.moduleId,
groupId: g.groupId,
level: g.level ?? ModuleGrantLevel.USE,
})),
};
}
@@ -231,7 +256,8 @@ export class ModuleGrantsService {
* dadurch sichtbar. `modules` beantwortet je aktivem Modul die andere
* Frage (welche Gruppe gewährt dieses Modul, und besteht zusätzlich ein
* 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
* Projektionsstellen (viaGroups-Namen, groups[].name) liefern
@@ -258,7 +284,7 @@ export class ModuleGrantsService {
}),
tenantPrisma.moduleGrant.findMany({
where: { tenantId, userId },
select: { moduleId: true },
select: { moduleId: true, level: true },
}),
// Mandantengebunden seit 260909-jts (Aufgabe 3): der Kontext wird
// ü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 manageGroupNamesByModule = new Map<string, string[]>();
for (const g of groupGrants) {
if (!g.group) continue;
const displayName = g.group.internalName ?? g.group.name;
const names = groupNamesByModule.get(g.moduleId) ?? [];
names.push(g.group.internalName ?? g.group.name);
names.push(displayName);
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
@@ -304,7 +340,11 @@ export class ModuleGrantsService {
modules: modules.map((module) => ({
module,
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;
}
+39
View File
@@ -319,6 +319,45 @@ describe('MailService.sendReminderEmail (quick-260929-if2, E-04/E-07, T-IF2-05)'
});
});
describe('MailService.sendNextcloudAlertEmail (quick-261002-kxc, L-04/L-05)', () => {
const input = {
kind: 'down' as const,
customerName: 'Kunde A',
baseUrl: 'https://cloud.a.de',
rating: { level: 'red' as const, reason: 'unreachable' as const, updateTo: null, eolDate: null, cycle: null },
errorKind: 'network',
errorDetail: 'ECONNREFUSED',
at: new Date('2026-10-02T12:30:00.000Z'),
};
function make() {
return new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any);
}
it('sendet Betreff, Text mit Berliner Zeit und Modul-Link ueber den Transport des Mandanten; true bei Erfolg', async () => {
const ok = await make().sendNextcloudAlertEmail('t1', 'alice@a.example.invalid', input);
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('Nextcloud Kunde A: nicht erreichbar');
expect(sent.html).toBeUndefined();
expect(sent.text).toContain('14:30 Uhr');
expect(sent.text).toContain('Verbindung abgelehnt');
expect(sent.text).toContain('http://localhost:3000/modules/nextcloud-status');
expect(mockClose).toHaveBeenCalledTimes(1);
});
it('gibt false zurueck (wirft nicht), wenn der Transport scheitert', async () => {
mockSendMail = vi.fn(async () => {
throw new Error('SMTP down');
});
const ok = await make().sendNextcloudAlertEmail('t1', 'a@a.example.invalid', input);
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);
+29
View File
@@ -4,6 +4,10 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as nodemailer from 'nodemailer';
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
import {
buildNextcloudAlertMail,
type NextcloudAlertMailInput,
} from '../nextcloud-status/nextcloud-alert-mail';
import { SettingsService } from '../settings/settings.service';
/**
@@ -406,4 +410,29 @@ export class MailService {
return false;
}
}
/**
* Benachrichtigung des Moduls Nextcloud-Status (quick-261002-kxc, L-04/L-05):
* Stoerung oder "wieder in Ordnung" einer Cloud. Text und Betreff baut der
* reine Baustein `buildNextcloudAlertMail` (nur Deutsch, wie die
* Erinnerungsmails). Liefert true bei Erfolg, false (und ein Protokolleintrag)
* bei Transportfehlern — der Aufrufer entscheidet ueber Wiederholungen.
*/
async sendNextcloudAlertEmail(
tenantId: string,
to: string,
input: NextcloudAlertMailInput,
): Promise<boolean> {
const { subject, text } = buildNextcloudAlertMail(input, this.appUrl);
try {
await this.deliver(tenantId, { to, subject, text }, 'NextcloudAlert');
return true;
} catch (error) {
this.logger.error(
`Failed to send NextcloudAlert email to ${to}`,
error instanceof Error ? error.stack : String(error),
);
return false;
}
}
}
+5 -4
View File
@@ -3,12 +3,16 @@ import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import cookieParser from 'cookie-parser';
import { AppModule } from './app.module';
import { requestLogMiddleware } from './common/request-log';
import { formatAppVersionLine } from './health/app-version';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService);
// Eine Zeile je Anfrage im Docker-Log (Pfad, Status, Dauer, Benutzer)
app.use(requestLogMiddleware);
// Cookie parser for JWT httpOnly cookies
app.use(cookieParser());
@@ -21,10 +25,7 @@ async function bootstrap() {
);
// CORS with credentials for cross-origin cookie support (Pitfall 4)
const corsOrigin = configService.get<string>(
'CORS_ORIGIN',
'http://localhost:3000',
);
const corsOrigin = configService.get<string>('CORS_ORIGIN', 'http://localhost:3000');
app.enableCors({
origin: corsOrigin,
credentials: true,
@@ -0,0 +1,75 @@
import { Transform, Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsIn,
IsNotEmpty,
IsString,
Matches,
MaxLength,
ValidateNested,
} from 'class-validator';
const trimString = ({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value;
/** Kennung einer Kategorie: klein, a-z, 0-9, Bindestrich (T-387-06). */
export const CATEGORY_KEY_PATTERN = /^[a-z0-9][a-z0-9-]{0,59}$/;
/** Neuer Anzeigename einer Kategorie (die Kennung bleibt unveraendert). */
export class RenameModuleCategoryDto {
@Transform(trimString)
@IsString()
@IsNotEmpty()
@MaxLength(60)
name!: string;
}
/** Neue Kategorie; die Kennung bildet der Dienst aus dem Namen. */
export class CreateModuleCategoryDto extends RenameModuleCategoryDto {}
/** Neue Reihenfolge aller Kategorien (Kennungen). */
export class ReorderModuleCategoriesDto {
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(200)
@IsString({ each: true })
@Matches(CATEGORY_KEY_PATTERN, { each: true })
keys!: string[];
}
/** Zuordnung eines Eintrags zu einer Kategorie. */
export class AssignModuleCategoryDto {
@IsIn(['module', 'custom'])
type!: 'module' | 'custom';
@IsString()
@IsNotEmpty()
@MaxLength(100)
id!: string;
@IsString()
@Matches(CATEGORY_KEY_PATTERN)
categoryKey!: string;
}
/** Ein Eintrag in der Reihenfolge einer Kategorie. */
export class CategoryItemRefDto {
@IsIn(['module', 'custom'])
type!: 'module' | 'custom';
@IsString()
@IsNotEmpty()
@MaxLength(100)
id!: string;
}
/** Neue Reihenfolge der Eintraege einer Kategorie. */
export class ReorderCategoryItemsDto {
@IsArray()
@ArrayMaxSize(1000)
@ValidateNested({ each: true })
@Type(() => CategoryItemRefDto)
items!: CategoryItemRefDto[];
}
@@ -0,0 +1,150 @@
import 'reflect-metadata';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { ForbiddenException, ValidationPipe } from '@nestjs/common';
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { Role } from '@prisma/client';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import {
AssignModuleCategoryDto,
CreateModuleCategoryDto,
ReorderCategoryItemsDto,
ReorderModuleCategoriesDto,
} from './dto/module-category.dto';
import { ModuleCategoriesController } from './module-categories.controller';
const proto = ModuleCategoriesController.prototype as any;
const ADMIN_ONLY = [Role.ADMIN, Role.SUPER_ADMIN];
describe('ModuleCategoriesController — Rollen (T-387-01)', () => {
it('haengt an Pfad module-categories', () => {
expect(Reflect.getMetadata('path', ModuleCategoriesController)).toBe('module-categories');
});
it('GET list traegt keine Rolle (jeder Angemeldete)', () => {
expect(Reflect.getMetadata(ROLES_KEY, proto.list)).toBeUndefined();
});
it.each([
'overview',
'create',
'reorder',
'assign',
'rename',
'reorderItems',
'remove',
])('%s ist nur fuer Administratoren', (name) => {
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toEqual(ADMIN_ONLY);
expect(Reflect.getMetadata(GUARDS_METADATA, proto[name]), name).toContain(RolesGuard);
});
it('jede Methode ausser list traegt eine Rolle (keine vergessen)', () => {
const names = Object.getOwnPropertyNames(proto).filter(
(n) => n !== 'constructor' && n !== 'tenantIdOf' && typeof proto[n] === 'function',
);
for (const name of names.filter((n) => n !== 'list')) {
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toEqual(ADMIN_ONLY);
}
});
});
describe('ModuleCategoriesController — Organisation', () => {
it('reicht die Organisation aus der Anfrage weiter', async () => {
const service = { listCategories: vi.fn(async () => []), rename: vi.fn(async () => ({})) };
const controller = new ModuleCategoriesController(service as any);
await controller.list({ tenantId: 't1' } as any);
await controller.rename({ tenantId: 't1' } as any, 'fleet', { name: 'X' });
expect(service.listCategories).toHaveBeenCalledWith('t1');
expect(service.rename).toHaveBeenCalledWith('t1', 'fleet', 'X');
});
it('ohne Organisation -> 403', async () => {
const controller = new ModuleCategoriesController({} as any);
await expect(controller.list({} as any)).rejects.toBeInstanceOf(ForbiddenException);
});
});
describe('ModuleCategoriesController — Routen-Reihenfolge (statisch vor :key)', () => {
it("steht jede statische Route im Quelltext vor der ersten ':key'-Route", () => {
const source = readFileSync(join(__dirname, 'module-categories.controller.ts'), 'utf8');
const firstParam = source.indexOf("':key");
expect(firstParam).toBeGreaterThan(0);
for (const route of ["'overview'", "'order'", "'assignment'"]) {
const at = source.indexOf(route);
expect(at, route).toBeGreaterThan(0);
expect(at, route).toBeLessThan(firstParam);
}
});
});
describe('ModuleCategoriesController — Weiterreichen', () => {
it('reicht Organisation und Eingaben an den Dienst', async () => {
const service = {
getOverview: vi.fn(async () => []),
create: vi.fn(async () => ({})),
reorderCategories: vi.fn(async () => []),
assign: vi.fn(async () => ({})),
reorderItems: vi.fn(async () => ({})),
remove: vi.fn(async () => ({})),
};
const c = new ModuleCategoriesController(service as any);
const req = { tenantId: 't1' } as any;
await c.overview(req);
await c.create(req, { name: 'Neu' });
await c.reorder(req, { keys: ['a'] });
await c.assign(req, { type: 'module', id: 'x', categoryKey: 'a' });
await c.reorderItems(req, 'a', { items: [{ type: 'module', id: 'x' }] });
await c.remove(req, 'a', 'b');
await c.remove(req, 'a');
expect(service.getOverview).toHaveBeenCalledWith('t1');
expect(service.create).toHaveBeenCalledWith('t1', 'Neu');
expect(service.reorderCategories).toHaveBeenCalledWith('t1', ['a']);
expect(service.assign).toHaveBeenCalledWith('t1', {
type: 'module',
id: 'x',
categoryKey: 'a',
});
expect(service.reorderItems).toHaveBeenCalledWith('t1', 'a', [{ type: 'module', id: 'x' }]);
expect(service.remove).toHaveBeenNthCalledWith(1, 't1', 'a', 'b');
expect(service.remove).toHaveBeenNthCalledWith(2, 't1', 'a', undefined);
});
});
describe('Module-Kategorien-DTOs', () => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
const run = (metatype: any, value: unknown) => pipe.transform(value, { type: 'body', metatype });
it('Name wird getrimmt, leer oder zu lang -> 400', async () => {
await expect(run(CreateModuleCategoryDto, { name: ' Neu ' })).resolves.toMatchObject({
name: 'Neu',
});
await expect(run(CreateModuleCategoryDto, { name: ' ' })).rejects.toThrow();
await expect(run(CreateModuleCategoryDto, { name: 'x'.repeat(61) })).rejects.toThrow();
});
it('Reihenfolge verlangt Kennungen im erlaubten Format', async () => {
await expect(run(ReorderModuleCategoriesDto, { keys: ['a', 'b-1'] })).resolves.toBeDefined();
await expect(run(ReorderModuleCategoriesDto, { keys: [] })).rejects.toThrow();
await expect(run(ReorderModuleCategoriesDto, { keys: ['A b'] })).rejects.toThrow();
});
it('Zuordnung verlangt Art module|custom', async () => {
await expect(
run(AssignModuleCategoryDto, { type: 'x', id: 'a', categoryKey: 'a' }),
).rejects.toThrow();
await expect(
run(AssignModuleCategoryDto, { type: 'custom', id: 'a', categoryKey: 'a' }),
).resolves.toBeDefined();
});
it('Eintragsreihenfolge prueft jedes Element', async () => {
await expect(
run(ReorderCategoryItemsDto, { items: [{ type: 'module', id: 'a' }] }),
).resolves.toBeDefined();
await expect(
run(ReorderCategoryItemsDto, { items: [{ type: 'x', id: 'a' }] }),
).rejects.toThrow();
});
});
@@ -0,0 +1,118 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
Param,
Patch,
Post,
Put,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import {
AssignModuleCategoryDto,
CreateModuleCategoryDto,
RenameModuleCategoryDto,
ReorderCategoryItemsDto,
ReorderModuleCategoriesDto,
} from './dto/module-category.dto';
import { ModuleCategoriesService } from './module-categories.service';
/**
* Modulkategorien (quick-261003-387).
*
* - GET /module-categories — alle angemeldeten Benutzer (Beschriftung und
* Reihenfolge der Seitenleiste); liefert nur Kennung, Name, Reihenfolge.
* - Verwaltung (Uebersicht, Anlegen, Umbenennen, Sortieren, Zuordnen, Loeschen)
* nur ADMIN/SUPER_ADMIN, je Methode ueber @UseGuards(RolesGuard) + @Roles.
*
* ROUTEN-REIHENFOLGE: statische Routen (overview, order, assignment) MUESSEN
* vor den Routen mit `:key` stehen, sonst faengt `:key` sie ab (404-
* Shadowing, NestJS bildet in Deklarationsreihenfolge ab). Der Controller-
* Test haelt das fest.
*/
@Controller('module-categories')
export class ModuleCategoriesController {
constructor(private readonly service: ModuleCategoriesService) {}
private tenantIdOf(req: AuthenticatedRequest): string {
const tenantId = req.tenantId ?? req.user?.tenantId;
if (!tenantId) {
throw new ForbiddenException('Kein Organisationskontext');
}
return tenantId;
}
@Get()
async list(@Req() req: AuthenticatedRequest) {
return this.service.listCategories(this.tenantIdOf(req));
}
@Get('overview')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async overview(@Req() req: AuthenticatedRequest) {
return this.service.getOverview(this.tenantIdOf(req));
}
@Post()
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateModuleCategoryDto) {
return this.service.create(this.tenantIdOf(req), dto.name);
}
@Put('order')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async reorder(@Req() req: AuthenticatedRequest, @Body() dto: ReorderModuleCategoriesDto) {
return this.service.reorderCategories(this.tenantIdOf(req), dto.keys);
}
@Put('assignment')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async assign(@Req() req: AuthenticatedRequest, @Body() dto: AssignModuleCategoryDto) {
return this.service.assign(this.tenantIdOf(req), dto);
}
@Patch(':key')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async rename(
@Req() req: AuthenticatedRequest,
@Param('key') key: string,
@Body() dto: RenameModuleCategoryDto,
) {
return this.service.rename(this.tenantIdOf(req), key, dto.name);
}
@Put(':key/items')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async reorderItems(
@Req() req: AuthenticatedRequest,
@Param('key') key: string,
@Body() dto: ReorderCategoryItemsDto,
) {
return this.service.reorderItems(this.tenantIdOf(req), key, dto.items);
}
@Delete(':key')
@UseGuards(RolesGuard)
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async remove(
@Req() req: AuthenticatedRequest,
@Param('key') key: string,
@Query('moveTo') moveTo?: string,
) {
return this.service.remove(this.tenantIdOf(req), key, moveTo);
}
}
@@ -0,0 +1,138 @@
import { vi } from 'vitest';
/**
* Speicher-Attrappe fuer ModuleCategoriesService-Tests: bildet die wenigen
* Prisma-Aufrufe des Dienstes mit einfachen Gleichheits-/`in`-Filtern nach.
* Kein Produktionscode (nur von *.spec.ts importiert).
*/
// biome-ignore lint/suspicious/noExplicitAny: Attrappe fuer beliebige Tabellenzeilen
type Row = Record<string, any>;
function matches(row: Row, where: Row = {}): boolean {
return Object.entries(where).every(([field, cond]) => {
if (field === 'tenantId_moduleId') {
return row.tenantId === cond.tenantId && row.moduleId === cond.moduleId;
}
if (cond && typeof cond === 'object' && !(cond instanceof Date)) {
if ('in' in cond) return (cond.in as unknown[]).includes(row[field]);
if ('not' in cond) return row[field] !== cond.not;
}
return row[field] === cond;
});
}
function sortBy(list: Row[], orderBy?: Row | Row[]): Row[] {
const orders = orderBy ? (Array.isArray(orderBy) ? orderBy : [orderBy]) : [];
return [...list].sort((a, b) => {
for (const order of orders) {
const [field, dir] = Object.entries(order)[0] as [string, 'asc' | 'desc'];
if (a[field] === b[field]) continue;
const cmp = a[field] < b[field] ? -1 : 1;
return dir === 'desc' ? -cmp : cmp;
}
return 0;
});
}
function table(initial: Row[] = [], idPrefix = 'r') {
const rows: Row[] = [...initial];
let seq = 0;
const api = {
rows,
findMany: vi.fn(
async (args: { where?: Row; orderBy?: Row | Row[]; distinct?: string[] } = {}) => {
let list = sortBy(
rows.filter((r) => matches(r, args.where)),
args.orderBy,
);
const distinct = args.distinct;
if (distinct) {
const seen = new Set<string>();
list = list.filter((r) => {
const k = distinct.map((f) => r[f]).join('|');
if (seen.has(k)) return false;
seen.add(k);
return true;
});
}
return list.map((r) => ({ ...r }));
},
),
findFirst: vi.fn(async (args: { where?: Row } = {}) => {
const r = rows.find((row) => matches(row, args.where));
return r ? { ...r } : null;
}),
findUnique: vi.fn(async (args: { where: Row }) => {
const r = rows.find((row) => matches(row, args.where));
return r ? { ...r } : null;
}),
create: vi.fn(async ({ data }: { data: Row }) => {
const row = { id: `${idPrefix}-${++seq}`, ...data };
rows.push(row);
return { ...row };
}),
createMany: vi.fn(async ({ data }: { data: Row[]; skipDuplicates?: boolean }) => {
let count = 0;
for (const d of data) {
const dup = rows.some(
(r) =>
r.tenantId === d.tenantId &&
(d.key !== undefined ? r.key === d.key : r.moduleId === d.moduleId),
);
if (dup) continue;
rows.push({ id: `${idPrefix}-${++seq}`, ...d });
count++;
}
return { count };
}),
update: vi.fn(async ({ where, data }: { where: Row; data: Row }) => {
const r = rows.find((row) => matches(row, where));
if (!r) throw new Error('P2025');
Object.assign(r, data);
return { ...r };
}),
updateMany: vi.fn(async ({ where, data }: { where: Row; data: Row }) => {
const hit = rows.filter((row) => matches(row, where));
for (const r of hit) Object.assign(r, data);
return { count: hit.length };
}),
upsert: vi.fn(async ({ where, create, update }: { where: Row; create: Row; update: Row }) => {
const r = rows.find((row) => matches(row, where));
if (r) {
Object.assign(r, update);
return { ...r };
}
const row = { id: `${idPrefix}-${++seq}`, ...create };
rows.push(row);
return { ...row };
}),
delete: vi.fn(async ({ where }: { where: Row }) => {
const i = rows.findIndex((row) => matches(row, where));
if (i < 0) throw new Error('P2025');
const [gone] = rows.splice(i, 1);
return gone;
}),
deleteMany: vi.fn(async ({ where }: { where: Row }) => {
let count = 0;
for (let i = rows.length - 1; i >= 0; i--) {
if (matches(rows[i], where)) {
rows.splice(i, 1);
count++;
}
}
return { count };
}),
};
return api;
}
export function makeFakePrisma(
seed: { modules?: Row[]; placements?: Row[]; customModules?: Row[]; categories?: Row[] } = {},
) {
return {
module: table(seed.modules ?? [], 'm'),
moduleCategory: table(seed.categories ?? [], 'c'),
moduleCategoryPlacement: table(seed.placements ?? [], 'p'),
customModule: table(seed.customModules ?? [], 'cm'),
};
}
@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { ModuleCategoriesController } from './module-categories.controller';
import { ModuleCategoriesService } from './module-categories.service';
/**
* Modulkategorien je Organisation (quick-261003-387). `PrismaModule` ist
* global. Der Dienst wird exportiert, weil Modulliste, Freigaben-Matrix und
* eigene Module die wirksame Kategorie darueberlegen bzw. pruefen.
*/
@Module({
controllers: [ModuleCategoriesController],
providers: [ModuleCategoriesService],
exports: [ModuleCategoriesService],
})
export class ModuleCategoriesModule {}
@@ -0,0 +1,512 @@
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
// `forTenant`/`withTenantTransaction` reichen den Klienten durch — die
// Mandantenbindung selbst prueft rls-access-inventory.spec.ts.
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
withTenantTransaction: vi.fn(async (p: unknown, _t: string, fn: (tx: unknown) => unknown) =>
fn(p),
),
}));
import { makeFakePrisma } from './module-categories.fake-prisma';
import { ModuleCategoriesService, slugifyCategoryName } from './module-categories.service';
const DEFAULT_KEYS = [
'domain-tools',
'security-tools',
'fleet',
'infrastructure',
'procurement',
'accounting',
'custom-modules',
];
function setup(seed: Parameters<typeof makeFakePrisma>[0] = {}) {
const prisma = makeFakePrisma(seed);
return { prisma, service: new ModuleCategoriesService(prisma as any) };
}
describe('ModuleCategoriesService — Grundbestand (E-04)', () => {
it('legt fuer eine frische Organisation sieben Standardkategorien in Standardreihenfolge an', async () => {
const { service } = setup();
const list = await service.listCategories('t1');
expect(list.map((c) => c.key)).toEqual(DEFAULT_KEYS);
expect(list.every((c) => c.name === null)).toBe(true);
expect(list.map((c) => c.sortOrder)).toEqual([0, 1, 2, 3, 4, 5, 6]);
expect(list.filter((c) => c.isSystem).map((c) => c.key)).toEqual(['custom-modules']);
});
it('legt ein zweites Mal nichts doppelt an', async () => {
const { prisma, service } = setup();
await service.listCategories('t1');
await service.listCategories('t1');
expect(prisma.moduleCategory.rows).toHaveLength(7);
});
it('haengt eine bisher unbekannte, wirksam benutzte Kennung hinten an', async () => {
const { service } = setup({
modules: [{ id: 'm1', slug: 'neu', name: 'Neu', category: 'brandneu' }],
});
const list = await service.listCategories('t1');
expect(list.map((c) => c.key)).toEqual([...DEFAULT_KEYS, 'brandneu']);
expect(list[7].sortOrder).toBe(7);
});
it('nimmt Kennungen eigener Module (auch persoenlicher) auf', async () => {
const { service } = setup({
customModules: [
{ id: 'c1', tenantId: 't1', category: 'altlast', ownerUserId: 'u1', name: 'x' },
],
});
const list = await service.listCategories('t1');
expect(list.map((c) => c.key)).toContain('altlast');
});
it('nutzt die Zuordnung statt der Manifest-Kategorie fuer den Nachschub', async () => {
const { service } = setup({
modules: [{ id: 'm1', slug: 'a', name: 'A', category: 'fleet' }],
placements: [
{ id: 'p1', tenantId: 't1', moduleId: 'm1', categoryKey: 'zugeordnet', sortOrder: 0 },
],
categories: [
{
id: 'c1',
tenantId: 't1',
key: 'custom-modules',
name: null,
sortOrder: 0,
isSystem: true,
},
],
});
const list = await service.listCategories('t1');
// „fleet“ wird von keinem Modul wirksam benutzt und kommt nicht zurueck.
expect(list.map((c) => c.key)).toEqual(['custom-modules', 'zugeordnet']);
});
it('trennt Organisationen: t2 sieht die Zeilen von t1 nicht', async () => {
const { prisma, service } = setup();
await service.listCategories('t1');
await service.listCategories('t2');
expect(prisma.moduleCategory.rows.filter((r) => r.tenantId === 't2')).toHaveLength(7);
expect(prisma.moduleCategory.rows).toHaveLength(14);
});
});
describe('ModuleCategoriesService — applyToModules (E-07)', () => {
const modules = [
{ id: 'a', slug: 'a', name: 'Zeta', category: 'fleet' },
{ id: 'b', slug: 'b', name: 'Alpha', category: 'fleet' },
{ id: 'c', slug: 'c', name: 'Mitte', category: 'domain-tools' },
];
it('sortiert nach Kategoriereihenfolge, dann Name', async () => {
const { service } = setup({ modules });
const out = await service.applyToModules('t1', modules);
expect(out.map((m) => m.id)).toEqual(['c', 'b', 'a']);
expect(out[0].sortOrder).toBeNull();
});
it('Zuordnung schlaegt Manifest, aendert aber die Eingabe nicht', async () => {
const { service } = setup({
modules,
placements: [
{ id: 'p', tenantId: 't1', moduleId: 'c', categoryKey: 'accounting', sortOrder: 3 },
],
});
const out = await service.applyToModules('t1', modules);
expect(out.find((m) => m.id === 'c')).toMatchObject({ category: 'accounting', sortOrder: 3 });
expect(modules[2].category).toBe('domain-tools');
});
it('sortOrder aufsteigend, null zuletzt', async () => {
const { service } = setup({
modules,
placements: [{ id: 'p1', tenantId: 't1', moduleId: 'a', categoryKey: 'fleet', sortOrder: 0 }],
});
const out = await service.applyToModules('t1', modules);
expect(out.filter((m) => m.category === 'fleet').map((m) => m.id)).toEqual(['a', 'b']);
});
it('eine Zuordnung zu geloeschter Kategorie legt die Kennung wieder an und bleibt sortierbar', async () => {
const { service } = setup({
modules,
placements: [{ id: 'p', tenantId: 't1', moduleId: 'a', categoryKey: 'weg', sortOrder: 0 }],
});
const out = await service.applyToModules('t1', modules);
expect(out.find((m) => m.id === 'a')?.category).toBe('weg');
});
});
describe('ModuleCategoriesService — umbenennen', () => {
it('speichert den getrimmten Namen, Kennung bleibt', async () => {
const { service } = setup();
const res = await service.rename('t1', 'fleet', ' Fuhrpark ');
expect(res).toMatchObject({ key: 'fleet', name: 'Fuhrpark' });
});
it('erlaubt „Eigene Module“', async () => {
const { service } = setup();
const res = await service.rename('t1', 'custom-modules', 'Meine Sachen');
expect(res.name).toBe('Meine Sachen');
});
it('unbekannte Kennung -> 404', async () => {
const { service } = setup();
await expect(service.rename('t1', 'gibtsnicht', 'X')).rejects.toBeInstanceOf(NotFoundException);
});
it('leerer oder zu langer Name -> 400', async () => {
const { service } = setup();
await expect(service.rename('t1', 'fleet', ' ')).rejects.toThrow('1 bis 60');
await expect(service.rename('t1', 'fleet', 'x'.repeat(61))).rejects.toThrow('1 bis 60');
});
});
const MODULES = [
{ id: 'm-fleet', slug: 'dkv-fleet', name: 'DKV', category: 'fleet' },
{ id: 'm-fleet2', slug: 'tanken', name: 'Tanken', category: 'fleet' },
{ id: 'm-dom', slug: 'domaincheck', name: 'Domaincheck', category: 'domain-tools' },
{ id: 'm-prox', slug: 'proxmox', name: 'Proxmox', category: 'infrastructure' },
];
function custom(id: string, category: string, extra: Record<string, unknown> = {}) {
return {
id,
tenantId: 't1',
name: id,
url: 'https://x.de',
category,
ownerUserId: null,
sortOrder: null,
...extra,
};
}
describe('slugifyCategoryName (E-06)', () => {
it.each([
['Werkzeuge & Tools', 'werkzeuge-tools'],
['Übergröße Maßnahmen', 'uebergroesse-massnahmen'],
[' -- ', 'kategorie'],
['Café Ünï', 'cafe-ueni'],
['x'.repeat(60), 'x'.repeat(40)],
])('%j -> %j', (input, expected) => {
expect(slugifyCategoryName(input)).toBe(expected);
});
});
describe('ModuleCategoriesService — anlegen', () => {
it('bildet die Kennung aus dem Namen und haengt hinten an', async () => {
const { service } = setup({ modules: MODULES });
const created = await service.create('t1', 'Werkzeuge & Tools');
expect(created).toMatchObject({
key: 'werkzeuge-tools',
name: 'Werkzeuge & Tools',
isSystem: false,
});
expect(created.sortOrder).toBe(7);
});
it('haengt -2 an, wenn die Kennung ein Modul-Slug ist', async () => {
const { service } = setup({ modules: MODULES });
expect((await service.create('t1', 'Proxmox')).key).toBe('proxmox-2');
});
it('haengt -2 an bei „custom“ und bei einer vorhandenen Kennung, dann -3', async () => {
const { service } = setup({ modules: MODULES });
expect((await service.create('t1', 'Custom')).key).toBe('custom-2');
expect((await service.create('t1', 'Custom')).key).toBe('custom-3');
expect((await service.create('t1', 'Fleet')).key).toBe('fleet-2');
});
it('leerer Name -> 400', async () => {
const { service } = setup();
await expect(service.create('t1', ' ')).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('ModuleCategoriesService — reorderCategories', () => {
it('schreibt sortOrder = Index', async () => {
const { service } = setup();
const reversed = [...DEFAULT_KEYS].reverse();
const out = await service.reorderCategories('t1', reversed);
expect(out.map((c) => c.key)).toEqual(reversed);
expect(out.map((c) => c.sortOrder)).toEqual([0, 1, 2, 3, 4, 5, 6]);
});
it.each([
['fehlende Kennung', DEFAULT_KEYS.slice(1)],
['unbekannte Kennung', [...DEFAULT_KEYS.slice(1), 'fremd']],
['doppelte Kennung', [...DEFAULT_KEYS.slice(1), DEFAULT_KEYS[1]]],
])('%s -> 400', async (_label, keys) => {
const { service } = setup();
await expect(service.reorderCategories('t1', keys)).rejects.toBeInstanceOf(BadRequestException);
});
});
describe('ModuleCategoriesService — zuordnen', () => {
it('Modul: legt die Zuordnung an, Module.category bleibt, Position hinten', async () => {
const { prisma, service } = setup({ modules: MODULES });
await service.assign('t1', { type: 'module', id: 'm-dom', categoryKey: 'fleet' });
expect(prisma.moduleCategoryPlacement.rows).toEqual([
expect.objectContaining({ moduleId: 'm-dom', categoryKey: 'fleet', sortOrder: 0 }),
]);
expect(prisma.module.rows.find((m) => m.id === 'm-dom')?.category).toBe('domain-tools');
await service.assign('t1', { type: 'module', id: 'm-prox', categoryKey: 'fleet' });
expect(
prisma.moduleCategoryPlacement.rows.find((p) => p.moduleId === 'm-prox')?.sortOrder,
).toBe(1);
});
it('Modul: schreibt eine bestehende Zuordnung um (kein zweiter Datensatz)', async () => {
const { prisma, service } = setup({
modules: MODULES,
placements: [
{ id: 'p', tenantId: 't1', moduleId: 'm-dom', categoryKey: 'fleet', sortOrder: 5 },
],
});
await service.assign('t1', { type: 'module', id: 'm-dom', categoryKey: 'accounting' });
expect(prisma.moduleCategoryPlacement.rows).toHaveLength(1);
expect(prisma.moduleCategoryPlacement.rows[0]).toMatchObject({ categoryKey: 'accounting' });
});
it('unbekannte Kategorie -> 400, unbekanntes Modul -> 404', async () => {
const { service } = setup({ modules: MODULES });
await expect(
service.assign('t1', { type: 'module', id: 'm-dom', categoryKey: 'gibtsnicht' }),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
service.assign('t1', { type: 'module', id: 'nix', categoryKey: 'fleet' }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('eigenes gemeinsames Modul: schreibt Kategorie und sortOrder', async () => {
const { prisma, service } = setup({ customModules: [custom('c1', 'infrastructure')] });
await service.assign('t1', { type: 'custom', id: 'c1', categoryKey: 'fleet' });
expect(prisma.customModule.rows[0]).toMatchObject({ category: 'fleet', sortOrder: 0 });
});
it('eigenes persoenliches oder fremdes Modul -> 404, nichts geaendert', async () => {
const { prisma, service } = setup({
customModules: [
custom('priv', 'infrastructure', { ownerUserId: 'u1' }),
custom('fremd', 'infrastructure', { tenantId: 't2' }),
],
});
await expect(
service.assign('t1', { type: 'custom', id: 'priv', categoryKey: 'fleet' }),
).rejects.toBeInstanceOf(NotFoundException);
await expect(
service.assign('t1', { type: 'custom', id: 'fremd', categoryKey: 'fleet' }),
).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.customModule.rows.map((r) => r.category)).toEqual([
'infrastructure',
'infrastructure',
]);
});
});
describe('ModuleCategoriesService — reorderItems (T-387-04)', () => {
const seed = () => ({
modules: MODULES,
customModules: [custom('c1', 'fleet'), custom('priv', 'fleet', { ownerUserId: 'u1' })],
});
it('schreibt sortOrder = Index, Module per Zuordnung, eigene Module direkt', async () => {
const { prisma, service } = setup(seed());
await service.reorderItems('t1', 'fleet', [
{ type: 'custom', id: 'c1' },
{ type: 'module', id: 'm-fleet2' },
{ type: 'module', id: 'm-fleet' },
]);
const order = (id: string) =>
prisma.moduleCategoryPlacement.rows.find((p) => p.moduleId === id)?.sortOrder;
expect(order('m-fleet2')).toBe(1);
expect(order('m-fleet')).toBe(2);
expect(prisma.customModule.rows.find((r) => r.id === 'c1')?.sortOrder).toBe(0);
// Persoenliche Eintraege bekommen nie eine sortOrder.
expect(prisma.customModule.rows.find((r) => r.id === 'priv')?.sortOrder).toBeNull();
});
it.each([
[
'fehlender Eintrag',
[
{ type: 'module', id: 'm-fleet' },
{ type: 'custom', id: 'c1' },
],
],
[
'fremder Eintrag',
[
{ type: 'module', id: 'm-fleet' },
{ type: 'module', id: 'm-fleet2' },
{ type: 'custom', id: 'c1' },
{ type: 'module', id: 'm-dom' },
],
],
[
'persoenlicher Eintrag',
[
{ type: 'module', id: 'm-fleet' },
{ type: 'module', id: 'm-fleet2' },
{ type: 'custom', id: 'c1' },
{ type: 'custom', id: 'priv' },
],
],
[
'Doppelter',
[
{ type: 'module', id: 'm-fleet' },
{ type: 'module', id: 'm-fleet' },
{ type: 'custom', id: 'c1' },
],
],
])('%s -> 400, nichts geschrieben', async (_l, items) => {
const { prisma, service } = setup(seed());
await expect(service.reorderItems('t1', 'fleet', items as any)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(prisma.moduleCategoryPlacement.rows).toHaveLength(0);
});
it('unbekannte Kategorie -> 404', async () => {
const { service } = setup(seed());
await expect(service.reorderItems('t1', 'gibtsnicht', [])).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
describe('ModuleCategoriesService — loeschen (E-05, T-387-05)', () => {
it('„Eigene Module“ (isSystem) -> 400', async () => {
const { service } = setup();
await expect(service.remove('t1', 'custom-modules', 'fleet')).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('unbekannte Kennung -> 404', async () => {
const { service } = setup();
await expect(service.remove('t1', 'gibtsnicht')).rejects.toBeInstanceOf(NotFoundException);
});
it('nicht leer ohne Ziel -> 409 und nichts geloescht', async () => {
const { prisma, service } = setup({ modules: MODULES });
await expect(service.remove('t1', 'fleet')).rejects.toBeInstanceOf(ConflictException);
expect(prisma.moduleCategory.rows.some((c) => c.key === 'fleet')).toBe(true);
expect(prisma.moduleCategoryPlacement.rows).toHaveLength(0);
});
it('nur persoenliche Eintraege zaehlen auch als nicht leer -> 409', async () => {
const { service } = setup({
customModules: [custom('priv', 'fleet', { ownerUserId: 'u1' })],
});
await expect(service.remove('t1', 'fleet')).rejects.toBeInstanceOf(ConflictException);
});
it('Ziel gleich Kennung oder unbekannt -> 400', async () => {
const { service } = setup({ modules: MODULES });
await expect(service.remove('t1', 'fleet', 'fleet')).rejects.toBeInstanceOf(
BadRequestException,
);
await expect(service.remove('t1', 'fleet', 'nix')).rejects.toBeInstanceOf(BadRequestException);
});
it('leere Kategorie ohne Ziel wird geloescht und kommt nicht zurueck', async () => {
const { prisma, service } = setup({ modules: MODULES });
await service.remove('t1', 'accounting');
expect(prisma.moduleCategory.rows.some((c) => c.key === 'accounting')).toBe(false);
const list = await service.listCategories('t1');
expect(list.map((c) => c.key)).not.toContain('accounting');
});
it('mit Ziel wandern Module (auch mit Manifest-Kategorie), gemeinsame UND persoenliche eigene Module', async () => {
const { prisma, service } = setup({
modules: MODULES,
placements: [
{ id: 'p', tenantId: 't1', moduleId: 'm-fleet2', categoryKey: 'fleet', sortOrder: 0 },
{ id: 'p2', tenantId: 't1', moduleId: 'm-dom', categoryKey: 'procurement', sortOrder: 4 },
],
customModules: [
custom('shared', 'fleet', { sortOrder: 1 }),
custom('priv', 'fleet', { ownerUserId: 'u1' }),
custom('andere', 'infrastructure'),
],
});
const res = await service.remove('t1', 'fleet', 'procurement');
expect(res).toEqual({ deleted: true, moved: 4 });
expect(prisma.moduleCategory.rows.some((c) => c.key === 'fleet')).toBe(false);
const place = (id: string) =>
prisma.moduleCategoryPlacement.rows.find((p) => p.moduleId === id);
// Manifest-Modul ohne Zuordnung bekommt eine, damit „fleet“ es nicht zurueckholt.
expect(place('m-fleet')).toMatchObject({ categoryKey: 'procurement' });
expect(place('m-fleet2')).toMatchObject({ categoryKey: 'procurement' });
// Verschobene Eintraege stehen hinten (nach dem bisherigen Maximum 4).
expect(place('m-fleet')?.sortOrder).toBeGreaterThan(4);
expect(place('m-fleet2')?.sortOrder).toBeGreaterThan(4);
const rows = (id: string) => prisma.customModule.rows.find((r) => r.id === id) ?? {};
expect(rows('shared')).toMatchObject({ category: 'procurement' });
expect((rows('shared') as { sortOrder: number }).sortOrder).toBeGreaterThan(4);
expect(rows('priv')).toMatchObject({ category: 'procurement', sortOrder: null });
expect(rows('andere')).toMatchObject({ category: 'infrastructure' });
// Nach dem Loeschen legt ensure „fleet“ nicht wieder an.
expect((await service.listCategories('t1')).map((c) => c.key)).not.toContain('fleet');
});
it('beruehrt eine andere Organisation nicht', async () => {
const { prisma, service } = setup({
modules: [],
customModules: [custom('t2-eintrag', 'fleet', { tenantId: 't2' })],
});
await service.listCategories('t1');
await service.listCategories('t2');
await service.remove('t1', 'fleet');
expect(prisma.moduleCategory.rows.some((c) => c.tenantId === 't2' && c.key === 'fleet')).toBe(
true,
);
});
});
describe('ModuleCategoriesService — Uebersicht', () => {
it('liefert je Kategorie in Reihenfolge die Eintraege und nur die Zahl persoenlicher', async () => {
const { service } = setup({
modules: MODULES,
placements: [
{ id: 'p', tenantId: 't1', moduleId: 'm-fleet2', categoryKey: 'fleet', sortOrder: 0 },
],
customModules: [
custom('shared', 'fleet', { name: 'Gemeinsam', sortOrder: 1 }),
custom('priv', 'fleet', { name: 'Geheim', ownerUserId: 'u1' }),
custom('priv2', 'fleet', { name: 'Geheim 2', ownerUserId: 'u2' }),
],
});
const overview = await service.getOverview('t1');
expect(overview.map((c) => c.key)).toEqual(DEFAULT_KEYS);
const fleet = overview.find((c) => c.key === 'fleet');
if (!fleet) throw new Error('fleet fehlt');
expect(fleet.items.map((i) => `${i.type}:${i.name}`)).toEqual([
'module:Tanken',
'custom:Gemeinsam',
'module:DKV',
]);
expect(fleet.items[0]).toMatchObject({ slug: 'tanken' });
expect(fleet.personalCount).toBe(2);
expect(JSON.stringify(overview)).not.toContain('Geheim');
expect(overview.find((c) => c.key === 'domain-tools')?.items.map((i) => i.name)).toEqual([
'Domaincheck',
]);
});
});
describe('ModuleCategoriesService — assertCategoryKey', () => {
it('kennt Standardkategorien, lehnt unbekannte mit 400 ab', async () => {
const { service } = setup();
await expect(service.assertCategoryKey('t1', 'fleet')).resolves.toBeUndefined();
await expect(service.assertCategoryKey('t1', 'nix')).rejects.toThrow('Unbekannte Kategorie');
});
});
@@ -0,0 +1,568 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { CUSTOM_MODULE_CATEGORIES, CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
/** Eine Kategorie, wie sie GET /module-categories liefert. */
export interface ModuleCategoryRow {
id: string;
key: string;
name: string | null;
sortOrder: number;
isSystem: boolean;
}
/** Zuordnung eines Marktplatz-Moduls (nur die Felder, die der Dienst braucht). */
interface PlacementRow {
moduleId: string;
categoryKey: string;
sortOrder: number;
}
/** Grundbestand und Zuordnungen einer Organisation nach `ensure`. */
export interface CategoryState {
categories: ModuleCategoryRow[];
placements: PlacementRow[];
}
/** Art eines Eintrags in einer Kategorie. */
export type CategoryItemType = 'module' | 'custom';
/** Ein Eintrag in der Verwaltungsuebersicht (persoenliche Eintraege nie einzeln). */
export interface OverviewItem {
type: CategoryItemType;
id: string;
name: string;
slug?: string;
}
/** Eine Kategorie mit ihren Eintraegen fuer die Verwaltungsseite. */
export interface CategoryOverview extends ModuleCategoryRow {
items: OverviewItem[];
/** Anzahl persoenlicher eigener Module von Benutzern — nie Name oder Adresse (T-387-03). */
personalCount: number;
}
/** Ein Eintrag mit wirksamer Kategorie, wie die Eintragsliste ihn intern fuehrt. */
interface ItemRow {
type: CategoryItemType;
id: string;
name: string;
slug?: string;
category: string;
sortOrder: number | null;
personal: boolean;
}
const MAX_NAME_LENGTH = 60;
/**
* Kennung aus einem Namen bilden (E-06): klein, ae/oe/ue/ss, sonst nur a-z,
* 0-9 und Bindestrich, hoechstens 40 Zeichen; leer wird „kategorie“.
*/
export function slugifyCategoryName(name: string): string {
const base = name
.toLowerCase()
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+/, '')
.slice(0, 40)
.replace(/-+$/, '');
return base === '' ? 'kategorie' : base;
}
/** Reihenfolge der Eintraege: sortOrder aufsteigend (null zuletzt), eingebaut vor eigenem, Name. */
function compareItems(a: ItemRow, b: ItemRow): number {
if (a.sortOrder !== b.sortOrder) {
if (a.sortOrder === null) return 1;
if (b.sortOrder === null) return -1;
return a.sortOrder - b.sortOrder;
}
if (a.type !== b.type) return a.type === 'module' ? -1 : 1;
return a.name.localeCompare(b.name);
}
function sameSet(a: string[], b: string[]): boolean {
return a.length === b.length && new Set(a).size === a.length && b.every((x) => a.includes(x));
}
const CATEGORY_SELECT = {
id: true,
key: true,
name: true,
sortOrder: true,
isSystem: true,
} as const;
/**
* Modulkategorien je Organisation (quick-261003-387).
*
* Modell (Entscheidungen E-01 bis E-08 aus dem Plan):
* - `ModuleCategory` — Kategorien der Organisation; `key` ist unveraenderlich
* (URL-Segment), `name` null = Uebersetzung `moduleCategories.<key>`.
* - `ModuleCategoryPlacement` — Zuordnung eines Marktplatz-Moduls. Die
* wirksame Kategorie ist die Zuordnung, sonst `Module.category`; die Spalte
* `Module.category` wird nie geaendert (sie gilt fuer alle Organisationen).
* - Gemeinsame eigene Module tragen ihre Kategorie und Reihenfolge direkt
* (`CustomModule.category`, `CustomModule.sortOrder`).
*
* Der Grundbestand wird ohne SQL-Rueckfuellung beim ersten Lesen angelegt
* (`ensure`): die Standardkategorien in der Reihenfolge von
* CUSTOM_MODULE_CATEGORIES, danach hinten angehaengt jede wirksam benutzte
* Kennung, die noch fehlt.
*
* RLS-BINDUNG: je Methode ein eigener `forTenant`-Klient, zusaetzlich steht
* `tenantId` in jedem `where` (Anwendungspruefung, solange der RLS-Schalter
* aus ist). Der globale Modulkatalog wird ueber den ungebundenen Klienten
* gelesen — dieselbe Begruendung wie in `module-access.service.ts`: die Tabelle
* "Module" traegt heute keinen Zeilenschutz, eine Bindung waere heute
* wirkungslos; sie wuerde erst katastrophal, WENN diese Tabelle eine Regel
* bekaeme (dann verschwaende der Katalog fuer jede Organisation). Diese
* Bedingung steht hier als Bedingung, nicht als heute beobachtbare Tatsache.
*/
@Injectable()
export class ModuleCategoriesService {
constructor(private readonly prisma: PrismaService) {}
/**
* Grundbestand sicherstellen und Zustand liefern (E-04). Legt beim ersten
* Lesen die Standardkategorien an und ergaenzt jede wirksam benutzte
* Kennung (Manifest-Kategorie eines Moduls ohne Zuordnung, Kategorie
* eigener Module), die noch fehlt, hinten. Eine geloeschte Standardkategorie
* kommt nur zurueck, wenn ein Modul sie wirksam benutzt.
*/
async ensure(tenantId: string): Promise<CategoryState> {
const tenantPrisma = forTenant(this.prisma, tenantId);
let categories: ModuleCategoryRow[] = await tenantPrisma.moduleCategory.findMany({
where: { tenantId },
orderBy: [{ sortOrder: 'asc' }, { key: 'asc' }],
select: CATEGORY_SELECT,
});
if (categories.length === 0) {
await tenantPrisma.moduleCategory.createMany({
data: CUSTOM_MODULE_CATEGORIES.map((key, index) => ({
tenantId,
key,
name: null,
sortOrder: index,
isSystem: key === CUSTOM_MODULE_CATEGORY,
})),
skipDuplicates: true,
});
categories = await tenantPrisma.moduleCategory.findMany({
where: { tenantId },
orderBy: [{ sortOrder: 'asc' }, { key: 'asc' }],
select: CATEGORY_SELECT,
});
}
const placements: PlacementRow[] = await tenantPrisma.moduleCategoryPlacement.findMany({
where: { tenantId },
select: { moduleId: true, categoryKey: true, sortOrder: true },
});
const modules = await this.prisma.module.findMany({
select: { id: true, category: true },
});
const customRows = await tenantPrisma.customModule.findMany({
where: { tenantId },
select: { category: true },
distinct: ['category'],
});
const placementByModule = new Map(placements.map((p) => [p.moduleId, p.categoryKey]));
const used = new Set<string>();
for (const m of modules) used.add(placementByModule.get(m.id) ?? m.category);
for (const c of customRows) used.add(c.category);
const known = new Set(categories.map((c) => c.key));
const missing = [...used].filter((key) => !known.has(key)).sort();
if (missing.length > 0) {
const base = categories.reduce((max, c) => Math.max(max, c.sortOrder), -1) + 1;
await tenantPrisma.moduleCategory.createMany({
data: missing.map((key, index) => ({
tenantId,
key,
name: null,
sortOrder: base + index,
isSystem: false,
})),
skipDuplicates: true,
});
categories = await tenantPrisma.moduleCategory.findMany({
where: { tenantId },
orderBy: [{ sortOrder: 'asc' }, { key: 'asc' }],
select: CATEGORY_SELECT,
});
}
return { categories, placements };
}
/** Alle Kategorien der Organisation in ihrer Reihenfolge. */
async listCategories(tenantId: string): Promise<ModuleCategoryRow[]> {
const { categories } = await this.ensure(tenantId);
return categories;
}
/**
* Wirksame Kategorie und Reihenfolge ueber eine Modulliste legen (E-07):
* jedes Modul bekommt `category` = wirksame Kennung (Zuordnung schlaegt
* Manifest) und `sortOrder` (Zahl oder null). Sortiert nach Reihenfolge
* der Kategorie, dann `sortOrder` (null zuletzt), dann Name. Eine
* unbekannte Kategorie sortiert zuletzt.
*/
async applyToModules<T extends { id: string; category: string; name: string }>(
tenantId: string,
modules: T[],
): Promise<Array<Omit<T, 'category'> & { category: string; sortOrder: number | null }>> {
const { categories, placements } = await this.ensure(tenantId);
const rank = new Map(categories.map((c, index) => [c.key, index]));
const placementByModule = new Map(placements.map((p) => [p.moduleId, p]));
const result = modules.map((module) => {
const placement = placementByModule.get(module.id);
return {
...module,
category: placement?.categoryKey ?? module.category,
sortOrder: placement?.sortOrder ?? null,
};
});
return result.sort((a, b) => {
const ra = rank.get(a.category) ?? Number.MAX_SAFE_INTEGER;
const rb = rank.get(b.category) ?? Number.MAX_SAFE_INTEGER;
if (ra !== rb) return ra - rb;
if (a.sortOrder !== b.sortOrder) {
if (a.sortOrder === null) return 1;
if (b.sortOrder === null) return -1;
return a.sortOrder - b.sortOrder;
}
return a.name.localeCompare(b.name);
});
}
/** Anzeigename aendern (1-60 Zeichen, getrimmt); die Kennung bleibt. */
async rename(tenantId: string, key: string, name: string): Promise<ModuleCategoryRow> {
const trimmed = this.validName(name);
const { categories } = await this.ensure(tenantId);
const existing = categories.find((c) => c.key === key);
if (!existing) {
throw new NotFoundException('Kategorie nicht gefunden');
}
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.moduleCategory.update({
where: { id: existing.id, tenantId },
data: { name: trimmed },
select: CATEGORY_SELECT,
});
}
/** Fehlermeldung „Unbekannte Kategorie“ (400), wenn die Organisation die Kennung nicht fuehrt. */
async assertCategoryKey(tenantId: string, key: string): Promise<void> {
const { categories } = await this.ensure(tenantId);
if (!categories.some((c) => c.key === key)) {
throw new BadRequestException('Unbekannte Kategorie');
}
}
/** Neue Kategorie hinten anfuegen; die Kennung wird aus dem Namen gebildet (E-06). */
async create(tenantId: string, name: string): Promise<ModuleCategoryRow> {
const trimmed = this.validName(name);
const { categories } = await this.ensure(tenantId);
// Modul-Slugs sind eigene Routenordner unter /modules, „custom“ ist die
// Adresse eigener Module; beides darf keine Kategoriekennung werden.
const slugs = await this.prisma.module.findMany({ select: { slug: true } });
const taken = new Set<string>([
...categories.map((c) => c.key),
...slugs.map((m) => m.slug),
'custom',
]);
const base = slugifyCategoryName(trimmed);
let key = base;
for (let n = 2; taken.has(key); n++) key = `${base}-${n}`;
const sortOrder = categories.reduce((max, c) => Math.max(max, c.sortOrder), -1) + 1;
const tenantPrisma = forTenant(this.prisma, tenantId);
return tenantPrisma.moduleCategory.create({
data: { tenantId, key, name: trimmed, sortOrder, isSystem: false },
select: CATEGORY_SELECT,
});
}
/** Kategorien umsortieren: `keys` muss genau eine Umstellung aller Kennungen sein. */
async reorderCategories(tenantId: string, keys: string[]): Promise<ModuleCategoryRow[]> {
const { categories } = await this.ensure(tenantId);
if (
!sameSet(
keys,
categories.map((c) => c.key),
)
) {
throw new BadRequestException('Die Liste muss genau alle Kategorien enthalten');
}
await withTenantTransaction(this.prisma, tenantId, async (tx) => {
for (const [index, key] of keys.entries()) {
await tx.moduleCategory.updateMany({
where: { tenantId, key },
data: { sortOrder: index },
});
}
});
return this.listCategories(tenantId);
}
/**
* Einen Eintrag einer Kategorie zuordnen und hinten anhaengen (E-02, E-03).
* Marktplatz-Module: Zuordnung anlegen oder umschreiben, `Module.category`
* bleibt unveraendert. Eigene Module: nur gemeinsame (ein persoenliches oder
* fremdes ist 404), die Kategorie steht direkt auf der Zeile.
*/
async assign(
tenantId: string,
input: { type: CategoryItemType; id: string; categoryKey: string },
): Promise<{ type: CategoryItemType; id: string; categoryKey: string }> {
const { type, id, categoryKey } = input;
await this.assertCategoryKey(tenantId, categoryKey);
const tenantPrisma = forTenant(this.prisma, tenantId);
if (type === 'module') {
const found = await this.prisma.module.findUnique({ where: { id }, select: { id: true } });
if (!found) throw new NotFoundException('Modul nicht gefunden');
await withTenantTransaction(this.prisma, tenantId, async (tx) => {
const sortOrder = await this.nextSortOrder(tx, tenantId, categoryKey);
await tx.moduleCategoryPlacement.upsert({
where: { tenantId_moduleId: { tenantId, moduleId: id } },
create: { tenantId, moduleId: id, categoryKey, sortOrder },
update: { categoryKey, sortOrder },
});
});
} else {
const found = await tenantPrisma.customModule.findFirst({
where: { id, tenantId, ownerUserId: null },
select: { id: true },
});
if (!found) throw new NotFoundException('Eigenes Modul nicht gefunden');
await withTenantTransaction(this.prisma, tenantId, async (tx) => {
const sortOrder = await this.nextSortOrder(tx, tenantId, categoryKey);
await tx.customModule.updateMany({
where: { id, tenantId, ownerUserId: null },
data: { category: categoryKey, sortOrder },
});
});
}
return { type, id, categoryKey };
}
/**
* Reihenfolge innerhalb einer Kategorie: `items` muss genau die Menge der
* nicht persoenlichen Eintraege der Kategorie sein (Marktplatz-Module mit
* wirksamer Kategorie plus gemeinsame eigene Module), sonst 400 (T-387-04).
*/
async reorderItems(
tenantId: string,
key: string,
items: Array<{ type: CategoryItemType; id: string }>,
): Promise<{ ok: true }> {
const state = await this.ensure(tenantId);
if (!state.categories.some((c) => c.key === key)) {
throw new NotFoundException('Kategorie nicht gefunden');
}
const rows = await this.loadItems(tenantId, state);
const expected = rows.filter((r) => r.category === key && !r.personal);
if (
!sameSet(
items.map((i) => `${i.type}:${i.id}`),
expected.map((r) => `${r.type}:${r.id}`),
)
) {
throw new BadRequestException('Die Liste muss genau die Einträge dieser Kategorie enthalten');
}
await withTenantTransaction(this.prisma, tenantId, async (tx) => {
for (const [index, item] of items.entries()) {
if (item.type === 'module') {
await tx.moduleCategoryPlacement.upsert({
where: { tenantId_moduleId: { tenantId, moduleId: item.id } },
create: { tenantId, moduleId: item.id, categoryKey: key, sortOrder: index },
update: { categoryKey: key, sortOrder: index },
});
} else {
await tx.customModule.updateMany({
where: { id: item.id, tenantId, ownerUserId: null },
data: { sortOrder: index },
});
}
}
});
return { ok: true };
}
/**
* Kategorie loeschen (E-05). „Eigene Module“ (isSystem) nie (400). Eine nicht
* leere Kategorie ohne `moveTo` wird nicht angefasst (409); mit Ziel wandern
* ALLE Inhalte dorthin — Marktplatz-Module (Zuordnung umgeschrieben bzw. neu
* angelegt, damit die Manifest-Kategorie sie nicht zurueckholt), gemeinsame
* UND persoenliche eigene Module — in einer Transaktion mit dem Loeschen.
*/
async remove(
tenantId: string,
key: string,
moveTo?: string,
): Promise<{ deleted: true; moved: number }> {
const state = await this.ensure(tenantId);
const category = state.categories.find((c) => c.key === key);
if (!category) throw new NotFoundException('Kategorie nicht gefunden');
if (category.isSystem) {
throw new BadRequestException('Diese Kategorie kann nicht gelöscht werden');
}
if (moveTo !== undefined && moveTo !== '') {
if (moveTo === key) {
throw new BadRequestException('Die Zielkategorie muss eine andere Kategorie sein');
}
if (!state.categories.some((c) => c.key === moveTo)) {
throw new BadRequestException('Unbekannte Kategorie');
}
}
const target = moveTo === undefined || moveTo === '' ? null : moveTo;
const rows = await this.loadItems(tenantId, state);
const contents = rows.filter((r) => r.category === key).sort(compareItems);
if (contents.length > 0 && target === null) {
throw new ConflictException(
'Die Kategorie enthält Einträge – bitte eine Zielkategorie wählen',
);
}
await withTenantTransaction(this.prisma, tenantId, async (tx) => {
if (target !== null && contents.length > 0) {
let next = await this.nextSortOrder(tx, tenantId, target);
for (const item of contents) {
if (item.type === 'module') {
await tx.moduleCategoryPlacement.upsert({
where: { tenantId_moduleId: { tenantId, moduleId: item.id } },
create: { tenantId, moduleId: item.id, categoryKey: target, sortOrder: next },
update: { categoryKey: target, sortOrder: next },
});
next++;
} else if (!item.personal) {
await tx.customModule.updateMany({
where: { id: item.id, tenantId },
data: { category: target, sortOrder: next },
});
next++;
}
}
// Persoenliche Eintraege gehen mit (nur das Feld `category`, ohne
// ihren Inhalt zu lesen); sie tragen nie eine sortOrder.
await tx.customModule.updateMany({
where: { tenantId, category: key },
data: { category: target },
});
}
await tx.moduleCategory.deleteMany({ where: { tenantId, key } });
});
return { deleted: true, moved: contents.length };
}
/** Alle Kategorien mit ihren Eintraegen in Reihenfolge — fuer die Verwaltungsseite. */
async getOverview(tenantId: string): Promise<CategoryOverview[]> {
const state = await this.ensure(tenantId);
const rows = await this.loadItems(tenantId, state);
return state.categories.map((category) => {
const inCategory = rows.filter((r) => r.category === category.key);
return {
...category,
items: inCategory
.filter((r) => !r.personal)
.sort(compareItems)
.map((r) => ({
type: r.type,
id: r.id,
name: r.name,
...(r.slug === undefined ? {} : { slug: r.slug }),
})),
personalCount: inCategory.filter((r) => r.personal).length,
};
});
}
private validName(name: string): string {
const trimmed = name.trim();
if (trimmed.length < 1 || trimmed.length > MAX_NAME_LENGTH) {
throw new BadRequestException('Der Name muss 1 bis 60 Zeichen lang sein');
}
return trimmed;
}
/**
* Alle Eintraege der Organisation mit wirksamer Kategorie: Marktplatz-Module
* (Zuordnung schlaegt Manifest) und eigene Module (gemeinsame und
* persoenliche; Letztere nur mit Kategorie, nie mit Inhalt).
*/
private async loadItems(tenantId: string, state: CategoryState): Promise<ItemRow[]> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const placementByModule = new Map(state.placements.map((p) => [p.moduleId, p]));
const modules = await this.prisma.module.findMany({
select: { id: true, slug: true, name: true, category: true },
});
const customs = await tenantPrisma.customModule.findMany({
where: { tenantId },
select: { id: true, name: true, category: true, sortOrder: true, ownerUserId: true },
});
return [
...modules.map((m): ItemRow => {
const placement = placementByModule.get(m.id);
return {
type: 'module',
id: m.id,
name: m.name,
slug: m.slug,
category: placement?.categoryKey ?? m.category,
sortOrder: placement?.sortOrder ?? null,
personal: false,
};
}),
...customs.map(
(c): ItemRow => ({
type: 'custom',
id: c.id,
name: c.name,
category: c.category,
sortOrder: c.sortOrder,
personal: c.ownerUserId !== null,
}),
),
];
}
/** Naechste freie Position in einer Kategorie (Maximum der nicht persoenlichen Eintraege + 1). */
private async nextSortOrder(
tx: Prisma.TransactionClient,
tenantId: string,
categoryKey: string,
): Promise<number> {
const placed = await tx.moduleCategoryPlacement.findMany({
where: { tenantId, categoryKey },
select: { sortOrder: true },
});
const custom = await tx.customModule.findMany({
where: { tenantId, category: categoryKey, ownerUserId: null },
select: { sortOrder: true },
});
const all = [...placed, ...custom]
.map((r) => r.sortOrder)
.filter((n): n is number => typeof n === 'number');
return all.length === 0 ? 0 : Math.max(...all) + 1;
}
}
@@ -30,8 +30,8 @@ const BOUND_MODEL_NAMES = ['tenantModuleActivation', 'moduleGrant'];
function makeFakePrisma(opts: {
activations?: { moduleId: string }[];
directGrants?: { moduleId: string }[];
groupGrants?: { moduleId: string }[];
directGrants?: { moduleId: string; level?: string }[];
groupGrants?: { moduleId: string; level?: string }[];
} = {}) {
const activations = opts.activations ?? [];
const directGrants = opts.directGrants ?? [];
@@ -250,6 +250,108 @@ describe('ModuleAccessService.getAccessibleModuleIds — USER (Grant-Auflösung,
});
});
describe('ModuleAccessService.getModuleAccessLevels — Freigabestufe (261002-icv)', () => {
it('Direkt-Grant USE: Map {mod-1: USE}', async () => {
const prisma = makeFakePrisma({
activations: [{ moduleId: 'mod-1' }],
directGrants: [{ moduleId: 'mod-1', level: 'USE' }],
});
const service = new ModuleAccessService(prisma as any);
const result = await service.getModuleAccessLevels('t1', 'user-1', 'USER');
expect(result).toEqual(new Map([['mod-1', 'USE']]));
});
it('Direkt-Grant USE plus Gruppen-Grant MANAGE auf dasselbe Modul: MANAGE gewinnt (L-02)', async () => {
const prisma = makeFakePrisma({
activations: [{ moduleId: 'mod-1' }],
directGrants: [{ moduleId: 'mod-1', level: 'USE' }],
groupGrants: [{ moduleId: 'mod-1', level: 'MANAGE' }],
});
const service = new ModuleAccessService(prisma as any);
expect((await service.getModuleAccessLevels('t1', 'user-1', 'USER')).get('mod-1')).toBe('MANAGE');
});
it('Gruppen-Grant USE plus Direkt-Grant MANAGE: ebenfalls MANAGE (Reihenfolge egal)', async () => {
const prisma = makeFakePrisma({
activations: [{ moduleId: 'mod-1' }],
directGrants: [{ moduleId: 'mod-1', level: 'MANAGE' }],
groupGrants: [{ moduleId: 'mod-1', level: 'USE' }],
});
const service = new ModuleAccessService(prisma as any);
expect((await service.getModuleAccessLevels('t1', 'user-1', 'USER')).get('mod-1')).toBe('MANAGE');
});
it('MANAGE nur über eine Gruppe: MANAGE', async () => {
const prisma = makeFakePrisma({
activations: [{ moduleId: 'mod-1' }],
groupGrants: [{ moduleId: 'mod-1', level: 'MANAGE' }],
});
const service = new ModuleAccessService(prisma as any);
expect((await service.getModuleAccessLevels('t1', 'user-1', 'USER')).get('mod-1')).toBe('MANAGE');
});
it('MANAGE-Grant auf ein deaktiviertes Modul: fehlt in der Map (T-icv-05)', async () => {
const prisma = makeFakePrisma({
activations: [],
directGrants: [{ moduleId: 'mod-1', level: 'MANAGE' }],
});
const service = new ModuleAccessService(prisma as any);
expect((await service.getModuleAccessLevels('t1', 'user-1', 'USER')).has('mod-1')).toBe(false);
});
it.each(['ADMIN', 'SUPER_ADMIN'] as const)(
'%s: jedes aktive Modul mit MANAGE, ohne Grant-Abfragen (L-03)',
async (role) => {
const prisma = makeFakePrisma({ activations: [{ moduleId: 'mod-1' }, { moduleId: 'mod-2' }] });
const service = new ModuleAccessService(prisma as any);
const result = await service.getModuleAccessLevels('t1', 'a-1', role);
expect(result).toEqual(new Map([['mod-1', 'MANAGE'], ['mod-2', 'MANAGE']]));
expect(prisma.moduleGrant.findMany).not.toHaveBeenCalled();
},
);
it('ohne Grants: leere Map', async () => {
const prisma = makeFakePrisma({ activations: [{ moduleId: 'mod-1' }] });
const service = new ModuleAccessService(prisma as any);
expect(await service.getModuleAccessLevels('t1', 'user-1', 'USER')).toEqual(new Map());
});
it('Zeilen ohne level-Feld (alte Mocks) zählen als USE', async () => {
const prisma = makeFakePrisma({
activations: [{ moduleId: 'mod-1' }],
directGrants: [{ moduleId: 'mod-1' }],
});
const service = new ModuleAccessService(prisma as any);
expect((await service.getModuleAccessLevels('t1', 'user-1', 'USER')).get('mod-1')).toBe('USE');
});
it('findAccessibleModules liefert canManage je Zeile', async () => {
const prisma = makeFakePrisma({
activations: [{ moduleId: 'mod-1' }, { moduleId: 'mod-2' }],
directGrants: [
{ moduleId: 'mod-1', level: 'MANAGE' },
{ moduleId: 'mod-2', level: 'USE' },
],
});
const service = new ModuleAccessService(prisma as any);
const rows = await service.findAccessibleModules('t1', 'user-1', 'USER');
expect(rows.find((r: any) => r.id === 'mod-1')?.canManage).toBe(true);
expect(rows.find((r: any) => r.id === 'mod-2')?.canManage).toBe(false);
});
});
describe('ModuleAccessService.findAccessibleModules — ordering (PERM-04)', () => {
it('sortiert die zugänglichen Module deterministisch nach Namen aufsteigend', async () => {
const prisma = makeFakePrisma({
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Role } from '@prisma/client';
import { ModuleGrantLevel, Role } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
@@ -10,37 +10,44 @@ import { forTenant } from '../prisma/prisma-tenant.extension';
* `getAccessibleModuleIds` auf, damit Sidebar, Modulseiten und API
* niemals auseinanderdriften können — genau das Sicherheitsloch, das
* D-01 strukturell verhindert.
*
* Seit 261002-icv traegt jede Freigabe eine Stufe (Benutzen / Verwalten).
* `getModuleAccessLevels` ist die einzige Aufloesung fuer Zugriff UND Stufe;
* `getAccessibleModuleIds` ist nur noch deren Schluesselmenge.
*/
@Injectable()
export class ModuleAccessService {
constructor(private readonly prisma: PrismaService) {}
/**
* Berechnet die Menge der moduleIds, auf die dieser Benutzer Zugriff hat.
* Berechnet je Modul, auf das dieser Benutzer Zugriff hat, die wirksame
* Freigabestufe (261002-icv). Einzige Aufloesung fuer Zugriff UND Stufe.
*
* D-03: ADMIN/SUPER_ADMIN umgehen jede Grant-Prüfung — sie erhalten alle
* mandantenweit aktiven Module ihres eigenen Mandanten. Diese Rolle
* kommt ausschließlich aus dem JWT (Aufrufer), nie aus Body/Params —
* D-03/L-03: ADMIN/SUPER_ADMIN umgehen jede Grant-Pruefung — sie erhalten
* alle aktiven Module ihres eigenen Mandanten mit Stufe MANAGE. Diese Rolle
* kommt ausschliesslich aus dem JWT (Aufrufer), nie aus Body/Params —
* der Kurzschluss kann daher keine Module eines fremden Mandanten
* liefern, weil `tenantId` ebenfalls aus dem JWT stammt (T-15-10).
*
* Für alle anderen Rollen (USER): Vereinigungsmenge aus Direkt-Grants
* und Grants über Gruppenmitgliedschaften, geschnitten mit den
* mandantenweit aktiven Modulen (D-02 — ein Grant auf ein deaktiviertes
* Modul gewährt keinen Zugriff). Die Gruppen-Query ist eine einzige
* verschachtelte Prisma-Query (`group: { memberships: { some: { userId } } }`)
* statt einer Schleife über die Gruppen des Benutzers — sonst entsteht
* ein N+1 pro geschütztem Endpoint.
* Fuer alle anderen Rollen (USER): Vereinigungsmenge aus Direkt-Grants
* und Grants ueber Gruppenmitgliedschaften, geschnitten mit den
* aktiven Modulen (D-02 — ein Grant auf ein deaktiviertes Modul gewaehrt
* keinen Zugriff, auch keine Verwaltungsstufe). Besteht Zugriff ueber
* mehrere Wege, gilt die hoehere Stufe (MANAGE gewinnt, L-02); ein Wert
* ausser 'MANAGE' (z. B. eine Zeile ohne `level`) zaehlt als USE. Die
* Gruppen-Query ist eine einzige verschachtelte Prisma-Query statt einer
* Schleife ueber die Gruppen des Benutzers — sonst entsteht ein N+1 pro
* geschuetztem Endpoint.
*
* Rein lesend, kein Caching über Request-Grenzen hinweg (D-09) — ein
* Freigabe-Entzug wirkt bei der nächsten Anfrage.
* Rein lesend, kein Caching ueber Request-Grenzen hinweg (D-09) — ein
* Freigabe-Entzug wirkt bei der naechsten Anfrage.
*/
async getAccessibleModuleIds(
async getModuleAccessLevels(
tenantId: string,
userId: string,
role: Role,
): Promise<Set<string>> {
// EIN gebundener Klient fuer alle vier mandantengebundenen Zugriffe
): Promise<Map<string, ModuleGrantLevel>> {
// EIN gebundener Klient fuer alle mandantengebundenen Zugriffe
// dieser Methode (Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge)
// — nicht ein Klient je Modellzugriff (260910-exd, Aufgabe 2). Die
// bestehenden `where`-Filter mit tenantId bleiben ZUSAETZLICH stehen:
@@ -61,46 +68,80 @@ export class ModuleAccessService {
where: { tenantId, isActive: true },
select: { moduleId: true },
});
return new Set(activations.map((a: { moduleId: string }) => a.moduleId));
return new Map(
activations.map((a: { moduleId: string }) => [a.moduleId, ModuleGrantLevel.MANAGE]),
);
}
const [direct, viaGroup] = await Promise.all([
tenantPrisma.moduleGrant.findMany({
where: { tenantId, userId },
select: { moduleId: true },
select: { moduleId: true, level: true },
}),
tenantPrisma.moduleGrant.findMany({
where: { tenantId, group: { memberships: { some: { userId } } } },
select: { moduleId: true },
select: { moduleId: true, level: true },
}),
]);
const grantedIds = [...direct, ...viaGroup].map((g: { moduleId: string }) => g.moduleId);
if (grantedIds.length === 0) {
return new Set();
const granted = new Map<string, ModuleGrantLevel>();
for (const g of [...direct, ...viaGroup] as Array<{
moduleId: string;
level?: ModuleGrantLevel;
}>) {
const level =
g.level === ModuleGrantLevel.MANAGE ? ModuleGrantLevel.MANAGE : ModuleGrantLevel.USE;
if (level === ModuleGrantLevel.MANAGE || !granted.has(g.moduleId)) {
granted.set(g.moduleId, level);
}
}
if (granted.size === 0) {
return new Map();
}
const activations = await tenantPrisma.tenantModuleActivation.findMany({
where: {
tenantId,
isActive: true,
moduleId: { in: grantedIds },
moduleId: { in: [...granted.keys()] },
},
select: { moduleId: true },
});
return new Set(activations.map((a: { moduleId: string }) => a.moduleId));
const result = new Map<string, ModuleGrantLevel>();
for (const a of activations as Array<{ moduleId: string }>) {
const level = granted.get(a.moduleId);
if (level) result.set(a.moduleId, level);
}
return result;
}
/**
* Menge der moduleIds, auf die dieser Benutzer Zugriff hat — die
* Schluesselmenge von `getModuleAccessLevels` (Signatur unveraendert,
* Verbraucher: Guard-Altpfade, Katalog, Dashboard).
*/
async getAccessibleModuleIds(
tenantId: string,
userId: string,
role: Role,
): Promise<Set<string>> {
const levels = await this.getModuleAccessLevels(tenantId, userId, role);
return new Set(levels.keys());
}
/**
* Lädt die vollständigen Module-Datensätze, auf die dieser Benutzer
* Zugriff hat, sortiert nach Name. Bedient `GET /modules/active` — die
* explizite Sortierung hält die Sidebar-Reihenfolge über Aufrufe hinweg
* stabil.
* stabil. Jede Zeile traegt `canManage` (261002-icv): wahr bei Freigabestufe
* Verwalten (Administratoren: immer) — nur zur Anzeige, bindend bleibt der
* ModuleGuard.
*/
async findAccessibleModules(tenantId: string, userId: string, role: Role) {
const accessibleIds = await this.getAccessibleModuleIds(tenantId, userId, role);
const levels = await this.getModuleAccessLevels(tenantId, userId, role);
if (accessibleIds.size === 0) {
if (levels.size === 0) {
return [];
}
@@ -111,10 +152,14 @@ export class ModuleAccessService {
// Tabelle eine Regel gibt — dann verschwaende der gesamte Katalog fuer
// jeden Mandanten. Diese Bedingung steht hier als Bedingung, nicht als
// heute beobachtbare Tatsache.
return this.prisma.module.findMany({
where: { id: { in: [...accessibleIds] } },
const rows = await this.prisma.module.findMany({
where: { id: { in: [...levels.keys()] } },
orderBy: { name: 'asc' },
});
return rows.map((row) => ({
...row,
canManage: levels.get(row.id) === ModuleGrantLevel.MANAGE,
}));
}
/**
@@ -0,0 +1,114 @@
import 'reflect-metadata';
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { Role } from '@prisma/client';
import { describe, expect, it } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { DkvController } from '../dkv/dkv.controller';
import { ModuleGrantsController } from '../groups/module-grants.controller';
import { HandelswareDatevController } from '../handelsware-datev/handelsware-datev.controller';
import { KantineDatevController } from '../kantine-datev/kantine-datev.controller';
import { NextcloudStatusController } from '../nextcloud-status/nextcloud-status.controller';
import { ProxmoxController } from '../proxmox/proxmox.controller';
import { TendersController } from '../tenders/tenders.controller';
import { ModuleRegistryController } from './module-registry.controller';
import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY, ModuleGuard } from './module.guard';
/**
* Metadaten-Beweis für die Freigabestufe Verwalten (261002-icv, L-04/L-09):
* welche Handler auf `@ModuleManage` umgestellt wurden und welche bewusst
* Administratoren vorbehalten bleiben (T-icv-01/06/07/08). Reine Metadaten —
* kein Nest-Start, keine Datenbank.
*/
const ADMIN_ONLY = [Role.ADMIN, Role.SUPER_ADMIN];
function methodsOf(controller: { prototype: object }): string[] {
return Object.getOwnPropertyNames(controller.prototype).filter(
(name) => name !== 'constructor' && typeof (controller.prototype as any)[name] === 'function',
);
}
function handler(controller: { prototype: object }, name: string) {
return (controller.prototype as any)[name];
}
function expectManage(controller: { prototype: object }, name: string, slug: string) {
const fn = handler(controller, name);
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn), `${name} MODULE_MANAGE_KEY`).toBe(true);
expect(Reflect.getMetadata(MODULE_SLUG_KEY, fn), `${name} MODULE_SLUG_KEY`).toBe(slug);
expect(Reflect.getMetadata(GUARDS_METADATA, fn), `${name} guards`).toContain(ModuleGuard);
expect(Reflect.getMetadata(ROLES_KEY, fn), `${name} ROLES_KEY`).toBeUndefined();
}
function expectAdminOnly(controller: { prototype: object }, name: string) {
const fn = handler(controller, name);
expect(Reflect.getMetadata(ROLES_KEY, fn), `${name} ROLES_KEY`).toEqual(ADMIN_ONLY);
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn), `${name} MODULE_MANAGE_KEY`).toBeUndefined();
}
describe('Umgestellte Handler (Verwalten)', () => {
it('DkvController: ganze Klasse Verwalten, kein Handler trägt @Roles', () => {
expect(Reflect.getMetadata(MODULE_SLUG_KEY, DkvController)).toBe('dkv-fleet');
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, DkvController)).toBe(true);
expect(Reflect.getMetadata(GUARDS_METADATA, DkvController)).toContain(ModuleGuard);
const names = methodsOf(DkvController).filter((n) => Reflect.hasMetadata('path', handler(DkvController, n)));
expect(names.length).toBe(11);
for (const name of names) {
expect(Reflect.getMetadata(ROLES_KEY, handler(DkvController, name)), name).toBeUndefined();
}
});
it.each(['create', 'update', 'remove', 'poll', 'test', 'testDraft'])(
'ProxmoxController.%s verlangt Verwalten für proxmox',
(name) => {
expectManage(ProxmoxController, name, 'proxmox');
},
);
it('ProxmoxController.list bleibt auf Benutzen-Ebene', () => {
const fn = handler(ProxmoxController, 'list');
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
});
it.each(['create', 'update', 'remove', 'checkAll', 'checkOne', 'uploadLogo', 'removeLogo'])(
'NextcloudStatusController.%s verlangt Verwalten für nextcloud-status',
(name) => {
expectManage(NextcloudStatusController, name, 'nextcloud-status');
},
);
it.each(['list', 'logo', 'subscribe', 'unsubscribe', 'recentAlerts'])('NextcloudStatusController.%s bleibt auf Benutzen-Ebene', (name) => {
const fn = handler(NextcloudStatusController, name);
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
});
it('KantineDatevController.saveSettings und HandelswareDatevController.saveSettings verlangen Verwalten', () => {
expectManage(KantineDatevController, 'saveSettings', 'kantine-datev');
expectManage(HandelswareDatevController, 'saveSettings', 'handelsware-datev');
});
});
describe('Bewusst nur für Administratoren (T-icv-01, T-icv-07)', () => {
it.each(['getSourceConfig', 'saveSourceConfig', 'pollNow'])(
'TendersController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)',
(name) => {
expectAdminOnly(TendersController, name);
},
);
it.each(['matrix', 'userAccess', 'create', 'remove'])(
'ModuleGrantsController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)',
(name) => {
expectAdminOnly(ModuleGrantsController, name);
},
);
it.each(['activate', 'deactivate'])(
'ModuleRegistryController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)',
(name) => {
expectAdminOnly(ModuleRegistryController, name);
},
);
});
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from 'vitest';
import { ModuleGrantsController } from '../groups/module-grants.controller';
import { ModuleRegistryController } from './module-registry.controller';
/**
* quick-261003-387 (E-07): alle Modullisten laufen ueber
* `ModuleCategoriesService.applyToModules`, damit Seitenleiste, Marktplatz,
* Kategorieseite und Freigaben-Matrix dieselbe wirksame Kategorie sehen.
*/
const overlay = vi.fn(async (_tenantId: string, modules: Array<{ id: string }>) =>
modules.map((m) => ({ ...m, category: 'ueberlagert', sortOrder: 7 })),
);
const categories = { applyToModules: overlay } as any;
const req = { tenantId: 't1', user: { id: 'u1', role: 'USER', tenantId: 't1' } } as any;
describe('ModuleRegistryController — Kategorie-Ueberlagerung', () => {
const registry = { findAll: vi.fn(async () => [{ id: 'a', name: 'A', category: 'fleet' }]) };
const access = {
findAccessibleModules: vi.fn(async () => [{ id: 'a', name: 'A', category: 'fleet' }]),
getCatalogFlags: vi.fn(
async () => new Map([['a', { isActiveForTenant: true, hasAccess: true }]]),
),
};
const controller = new ModuleRegistryController(registry as any, access as any, categories);
it('GET /modules liefert die wirksame Kategorie', async () => {
expect(await controller.findAll(req)).toEqual([
{ id: 'a', name: 'A', category: 'ueberlagert', sortOrder: 7 },
]);
expect(overlay).toHaveBeenCalledWith('t1', expect.any(Array));
});
it('GET /modules ohne Organisationskontext bleibt unveraendert', async () => {
overlay.mockClear();
expect(await controller.findAll({} as any)).toEqual([
{ id: 'a', name: 'A', category: 'fleet' },
]);
expect(overlay).not.toHaveBeenCalled();
});
it('GET /modules/active liefert die wirksame Kategorie', async () => {
expect(await controller.findActive(req)).toEqual([
{ id: 'a', name: 'A', category: 'ueberlagert', sortOrder: 7 },
]);
});
it('GET /modules/catalog liefert die wirksame Kategorie samt Flags', async () => {
expect(await controller.findCatalog(req)).toEqual([
{
id: 'a',
name: 'A',
category: 'ueberlagert',
sortOrder: 7,
isActiveForTenant: true,
hasAccess: true,
},
]);
});
});
describe('ModuleGrantsController.matrix — Kategorie-Ueberlagerung', () => {
it('ersetzt nur die Module, Gruppen und Freigaben bleiben', async () => {
const grants = {
getMatrix: vi.fn(async () => ({
modules: [{ id: 'a', name: 'A', category: 'fleet' }],
groups: [{ id: 'g' }],
grants: [{ moduleId: 'a', groupId: 'g', level: 'USE' }],
})),
};
const controller = new ModuleGrantsController(grants as any, categories);
const out = await controller.matrix(req);
expect(out.modules).toEqual([{ id: 'a', name: 'A', category: 'ueberlagert', sortOrder: 7 }]);
expect(out.groups).toEqual([{ id: 'g' }]);
expect(out.grants).toHaveLength(1);
});
});
@@ -11,6 +11,7 @@ import { Role } from '@prisma/client';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { Roles } from '../auth/decorators/roles.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { ModuleCategoriesService } from '../module-categories/module-categories.service';
import { ModuleAccessService } from './module-access.service';
import { ModuleRegistryService } from './module-registry.service';
@@ -32,6 +33,7 @@ export class ModuleRegistryController {
constructor(
private readonly moduleRegistryService: ModuleRegistryService,
private readonly moduleAccessService: ModuleAccessService,
private readonly moduleCategoriesService: ModuleCategoriesService,
) {}
/**
@@ -40,8 +42,13 @@ export class ModuleRegistryController {
* Available to any authenticated user (T-03-03: module catalog is non-sensitive).
*/
@Get()
async findAll() {
return this.moduleRegistryService.findAll();
async findAll(@Req() req: AuthenticatedRequest) {
const modules = await this.moduleRegistryService.findAll();
// quick-261003-387: wirksame Kategorie + Reihenfolge der Organisation;
// ohne Organisationskontext bleibt die Liste unveraendert.
const tenantId = req?.tenantId ?? req?.user?.tenantId;
if (!tenantId) return modules;
return this.moduleCategoriesService.applyToModules(tenantId, modules);
}
/**
@@ -60,7 +67,10 @@ export class ModuleRegistryController {
if (!tenantId || !userId || !role) {
throw new ForbiddenException('No user context');
}
return this.moduleAccessService.findAccessibleModules(tenantId, userId, role);
const modules = await this.moduleAccessService.findAccessibleModules(tenantId, userId, role);
// quick-261003-387: wirksame Kategorie + Reihenfolge der Organisation
// (Zuordnung schlaegt Manifest-Kategorie), sortiert fuer die Seitenleiste.
return this.moduleCategoriesService.applyToModules(tenantId, modules);
}
/**
@@ -87,7 +97,9 @@ export class ModuleRegistryController {
this.moduleAccessService.getCatalogFlags(tenantId, userId, role),
]);
return modules.map((module) => ({
// quick-261003-387: wirksame Kategorie + Reihenfolge der Organisation.
const withCategories = await this.moduleCategoriesService.applyToModules(tenantId, modules);
return withCategories.map((module) => ({
...module,
isActiveForTenant: flags.get(module.id)?.isActiveForTenant ?? false,
hasAccess: flags.get(module.id)?.hasAccess ?? false,
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { ModuleCategoriesModule } from '../module-categories/module-categories.module';
import { ModuleAccessService } from './module-access.service';
import { ModuleRegistryController } from './module-registry.controller';
import { ModuleRegistryService } from './module-registry.service';
@@ -19,6 +20,7 @@ import { ModuleGuard } from './module.guard';
* Plan 15-05, Grant-Services Plan 15-03) can inject and use them.
*/
@Module({
imports: [ModuleCategoriesModule],
controllers: [ModuleRegistryController],
providers: [ModuleRegistryService, ModuleAccessService, ModuleGuard],
exports: [ModuleRegistryService, ModuleAccessService, ModuleGuard],
@@ -1,6 +1,7 @@
import { ForbiddenException } from '@nestjs/common';
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { describe, expect, it, vi } from 'vitest';
import { ModuleGuard } from './module.guard';
import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY, ModuleGuard, ModuleManage } from './module.guard';
/**
* ModuleGuard.canActivate — deckt die vollständige Behavior-Liste aus
@@ -18,26 +19,28 @@ function makeContext(request: any) {
} as any;
}
function makeReflector(slug: string | undefined) {
return { getAllAndOverride: vi.fn(() => slug) } as any;
function makeReflector(slug: string | undefined, manage = false) {
return {
getAllAndOverride: vi.fn((key: string) => (key === MODULE_MANAGE_KEY ? manage : slug)),
} as any;
}
describe('ModuleGuard.canActivate', () => {
it('gibt true zurück und ruft keinen Service auf, wenn kein @UseModule-Slug in den Metadaten steht', async () => {
const moduleRegistryService = { findBySlug: vi.fn() } as any;
const moduleAccessService = { getAccessibleModuleIds: vi.fn() } as any;
const moduleAccessService = { getModuleAccessLevels: vi.fn() } as any;
const guard = new ModuleGuard(makeReflector(undefined), moduleRegistryService, moduleAccessService);
const result = await guard.canActivate(makeContext({}));
expect(result).toBe(true);
expect(moduleRegistryService.findBySlug).not.toHaveBeenCalled();
expect(moduleAccessService.getAccessibleModuleIds).not.toHaveBeenCalled();
expect(moduleAccessService.getModuleAccessLevels).not.toHaveBeenCalled();
});
it('wirft ForbiddenException("No tenant context"), wenn weder request.tenantId noch request.user.tenantId gesetzt sind', async () => {
const moduleRegistryService = { findBySlug: vi.fn() } as any;
const moduleAccessService = { getAccessibleModuleIds: vi.fn() } as any;
const moduleAccessService = { getModuleAccessLevels: vi.fn() } as any;
const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
await expect(guard.canActivate(makeContext({ user: {} }))).rejects.toThrow(
@@ -47,7 +50,7 @@ describe('ModuleGuard.canActivate', () => {
it('wirft ForbiddenException("No user context"), wenn tenantId gesetzt ist, aber userId/role fehlen', async () => {
const moduleRegistryService = { findBySlug: vi.fn() } as any;
const moduleAccessService = { getAccessibleModuleIds: vi.fn() } as any;
const moduleAccessService = { getModuleAccessLevels: vi.fn() } as any;
const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
await expect(
@@ -57,7 +60,7 @@ describe('ModuleGuard.canActivate', () => {
it('wirft ForbiddenException bei unbekanntem Slug (findBySlug liefert null)', async () => {
const moduleRegistryService = { findBySlug: vi.fn().mockResolvedValue(null) } as any;
const moduleAccessService = { getAccessibleModuleIds: vi.fn() } as any;
const moduleAccessService = { getModuleAccessLevels: vi.fn() } as any;
const guard = new ModuleGuard(makeReflector('unknown-slug'), moduleRegistryService, moduleAccessService);
await expect(
@@ -65,7 +68,7 @@ describe('ModuleGuard.canActivate', () => {
makeContext({ tenantId: 't1', user: { id: 'user-1', role: 'USER' } }),
),
).rejects.toThrow(ForbiddenException);
expect(moduleAccessService.getAccessibleModuleIds).not.toHaveBeenCalled();
expect(moduleAccessService.getModuleAccessLevels).not.toHaveBeenCalled();
});
it('USER ohne Grant auf ein aktives Modul: wirft ForbiddenException', async () => {
@@ -73,7 +76,7 @@ describe('ModuleGuard.canActivate', () => {
findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }),
} as any;
const moduleAccessService = {
getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set()),
getModuleAccessLevels: vi.fn().mockResolvedValue(new Map()),
} as any;
const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
@@ -89,7 +92,7 @@ describe('ModuleGuard.canActivate', () => {
findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }),
} as any;
const moduleAccessService = {
getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set(['mod-1'])),
getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['mod-1', 'MANAGE']])),
} as any;
const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
const request = { tenantId: 't1', user: { id: 'admin-1', role: 'ADMIN' } };
@@ -97,16 +100,16 @@ describe('ModuleGuard.canActivate', () => {
const result = await guard.canActivate(makeContext(request));
expect(result).toBe(true);
expect(moduleAccessService.getAccessibleModuleIds).toHaveBeenCalledWith('t1', 'admin-1', 'ADMIN');
expect(moduleAccessService.getModuleAccessLevels).toHaveBeenCalledWith('t1', 'admin-1', 'ADMIN');
});
it('USER mit Zugriff: gibt true zurück und legt das Ergebnis auf request.moduleAccessIds ab (Per-Request-Memoisierung, D-09)', async () => {
const moduleRegistryService = {
findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }),
} as any;
const accessibleIds = new Set(['mod-1']);
const accessibleIds = new Map([['mod-1', 'USE']]);
const moduleAccessService = {
getAccessibleModuleIds: vi.fn().mockResolvedValue(accessibleIds),
getModuleAccessLevels: vi.fn().mockResolvedValue(accessibleIds),
} as any;
const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
const request = { tenantId: 't1', user: { id: 'user-1', role: 'USER' } };
@@ -114,7 +117,8 @@ describe('ModuleGuard.canActivate', () => {
const result = await guard.canActivate(makeContext(request));
expect(result).toBe(true);
expect((request as any).moduleAccessIds).toBe(accessibleIds);
expect((request as any).moduleAccessLevels).toBe(accessibleIds);
expect([...(request as any).moduleAccessIds]).toEqual(['mod-1']);
});
/**
@@ -141,7 +145,7 @@ describe('ModuleGuard.canActivate', () => {
// Fall A: der Benutzer hat tatsächlich keine Freigabe.
const moduleAccessServiceGenuinelyEmpty = {
getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set()),
getModuleAccessLevels: vi.fn().mockResolvedValue(new Map()),
} as any;
const guardA = new ModuleGuard(
makeReflector('domaincheck'),
@@ -153,7 +157,7 @@ describe('ModuleGuard.canActivate', () => {
// einer ungebunden gebliebenen Abfrage) trotzdem eine leere Menge —
// aus Sicht des Wächters nicht von Fall A zu unterscheiden.
const moduleAccessServiceQueryFoundNothing = {
getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set()),
getModuleAccessLevels: vi.fn().mockResolvedValue(new Map()),
} as any;
const guardB = new ModuleGuard(
makeReflector('domaincheck'),
@@ -183,3 +187,71 @@ describe('ModuleGuard.canActivate', () => {
).toBe(messageA);
});
});
describe('ModuleGuard — Freigabestufe (261002-icv)', () => {
const registry = {
findBySlug: vi.fn().mockImplementation(async (slug: string) => ({ id: `id-${slug}`, slug })),
} as any;
const userRequest = () => ({ tenantId: 't1', user: { id: 'user-1', role: 'USER' } });
it('@UseModule-Route + USE: erlaubt', async () => {
const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['id-a', 'USE']])) } as any;
const guard = new ModuleGuard(makeReflector('a'), registry, access);
expect(await guard.canActivate(makeContext(userRequest()))).toBe(true);
});
it('@ModuleManage-Route + USE: ForbiddenException mit Hinweis auf Verwalten', async () => {
const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['id-a', 'USE']])) } as any;
const guard = new ModuleGuard(makeReflector('a', true), registry, access);
await expect(guard.canActivate(makeContext(userRequest()))).rejects.toThrow(
"Module 'a' requires manage permission",
);
});
it('@ModuleManage-Route + MANAGE: erlaubt', async () => {
const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['id-a', 'MANAGE']])) } as any;
const guard = new ModuleGuard(makeReflector('a', true), registry, access);
expect(await guard.canActivate(makeContext(userRequest()))).toBe(true);
});
it('Administrator (MANAGE auf allen aktiven Modulen): erlaubt', async () => {
const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['id-a', 'MANAGE']])) } as any;
const guard = new ModuleGuard(makeReflector('a', true), registry, access);
const request = { tenantId: 't1', user: { id: 'admin-1', role: 'ADMIN' } };
expect(await guard.canActivate(makeContext(request))).toBe(true);
expect(access.getModuleAccessLevels).toHaveBeenCalledWith('t1', 'admin-1', 'ADMIN');
});
it('@ModuleManage-Route ohne Freigabe: ForbiddenException (nicht zugänglich)', async () => {
const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map()) } as any;
const guard = new ModuleGuard(makeReflector('a', true), registry, access);
await expect(guard.canActivate(makeContext(userRequest()))).rejects.toThrow(
"Module 'a' is not accessible for this user",
);
});
it('MANAGE auf Modul a gewährt nichts auf Modul b (T-icv-04)', async () => {
const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['id-a', 'MANAGE']])) } as any;
const guard = new ModuleGuard(makeReflector('b', true), registry, access);
await expect(guard.canActivate(makeContext(userRequest()))).rejects.toThrow(ForbiddenException);
});
it('ein zweiter Lauf auf demselben Request nutzt request.moduleAccessLevels und fragt den Dienst nicht erneut', async () => {
const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['id-a', 'MANAGE']])) } as any;
const request = userRequest();
await new ModuleGuard(makeReflector('a'), registry, access).canActivate(makeContext(request));
await new ModuleGuard(makeReflector('a', true), registry, access).canActivate(makeContext(request));
expect(access.getModuleAccessLevels).toHaveBeenCalledTimes(1);
});
it('ModuleManage(slug) setzt Slug, Manage-Schlüssel und den ModuleGuard', () => {
class Probe {
@ModuleManage('probe')
handler() {}
}
const handler = Probe.prototype.handler;
expect(Reflect.getMetadata(MODULE_SLUG_KEY, handler)).toBe('probe');
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, handler)).toBe(true);
expect(Reflect.getMetadata(GUARDS_METADATA, handler)).toContain(ModuleGuard);
});
});
+66 -10
View File
@@ -8,6 +8,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ModuleGrantLevel } from '@prisma/client';
import { ModuleAccessService } from './module-access.service';
import { ModuleRegistryService } from './module-registry.service';
@@ -16,6 +17,12 @@ import { ModuleRegistryService } from './module-registry.service';
*/
export const MODULE_SLUG_KEY = 'moduleSlug';
/**
* Metadata key set by @ModuleManage(): the route needs the Freigabestufe
* Verwalten (MANAGE) for the module, not just access (261002-icv).
*/
export const MODULE_MANAGE_KEY = 'moduleManage';
/**
* Guard that checks whether the requesting user has access to the module
* identified by its slug — Aktivierung UND (Rolle ODER Direkt-Grant ODER
@@ -28,6 +35,13 @@ export const MODULE_SLUG_KEY = 'moduleSlug';
* T-15-03: Ohne `@UseModule(slug)`-Metadaten gibt der Guard bewusst
* `true` zurück (Durchsetzung hängt am Dekorator) — jeder neue
* Modul-Controller MUSS `@UseModule` tragen (Projektregel seit Phase 3).
*
* 261002-icv: Trägt die Route zusätzlich `@ModuleManage(slug)`, genügt
* Zugriff allein nicht — die wirksame Freigabestufe muss Verwalten sein
* (Administratoren erfüllen das über den Kurzschluss in
* `getModuleAccessLevels`). Die Stufe wird serverseitig aus den ModuleGrant-
* Zeilen aufgelöst, nie aus Body/Query (T-icv-02), und nur für das Modul
* der Route (T-icv-04).
*/
@Injectable()
export class ModuleGuard implements CanActivate {
@@ -71,22 +85,37 @@ export class ModuleGuard implements CanActivate {
);
}
const accessibleModuleIds = await this.moduleAccessService.getAccessibleModuleIds(
tenantId,
userId,
role,
);
const requireManage =
this.reflector.getAllAndOverride<boolean>(MODULE_MANAGE_KEY, [
context.getHandler(),
context.getClass(),
]) === true;
if (!accessibleModuleIds.has(module.id)) {
// Per-Request-Memoisierung (D-09): ein Klassen-@UseModule plus ein
// Handler-@ModuleManage lassen diesen Guard zweimal pro Request laufen;
// die Aufloesung bezahlt nur der erste Lauf. Ueber Request-Grenzen
// hinweg wird nichts zwischengespeichert.
const levels: Map<string, ModuleGrantLevel> =
request.moduleAccessLevels instanceof Map
? request.moduleAccessLevels
: await this.moduleAccessService.getModuleAccessLevels(tenantId, userId, role);
const level = levels.get(module.id);
if (!level) {
throw new ForbiddenException(
`Module '${moduleSlug}' is not accessible for this user`,
);
}
// Per-Request-Memoisierung (D-09): ein nachfolgender Handler im
// selben Request bezahlt die Auflösung nicht ein zweites Mal. Über
// Request-Grenzen hinweg wird nichts zwischengespeichert.
request.moduleAccessIds = accessibleModuleIds;
if (requireManage && level !== ModuleGrantLevel.MANAGE) {
throw new ForbiddenException(
`Module '${moduleSlug}' requires manage permission`,
);
}
request.moduleAccessLevels = levels;
request.moduleAccessIds = new Set(levels.keys());
return true;
}
@@ -107,3 +136,30 @@ export function UseModule(slug: string) {
UseGuards(ModuleGuard),
);
}
/**
* Decorator fuer modul-eigene Konfiguration: verlangt Zugriff auf das Modul
* UND die Freigabestufe Verwalten (261002-icv). Ersetzt
* `@Roles(ADMIN, SUPER_ADMIN)` fuer Handler, die nur dieses eine Modul
* konfigurieren.
*
* Verwendbar auf einem Handler innerhalb eines `@UseModule`-Controllers oder
* auf einem ganzen Controller. Administratoren bestehen ueber den
* D-03-Kurzschluss (sie loesen auf allen aktiven Modulen zu MANAGE auf).
*
* Niemals zusammen mit `@Roles` am selben Handler: der globale RolesGuard
* wuerde Verwalter trotzdem sperren. Mandant, Benutzer und Rolle stammen
* ausschliesslich aus dem JWT (T-15-10).
*
* Usage:
* @ModuleManage('kantine-datev')
* @Put('settings')
* saveSettings(...) { ... }
*/
export function ModuleManage(slug: string) {
return applyDecorators(
SetMetadata(MODULE_SLUG_KEY, slug),
SetMetadata(MODULE_MANAGE_KEY, true),
UseGuards(ModuleGuard),
);
}
@@ -0,0 +1,47 @@
import { IsNotEmpty, IsOptional, IsString, IsUrl, MaxLength, ValidateIf } from 'class-validator';
/**
* Eingaben fuer das Anlegen und Aendern einer Nextcloud-Cloud
* (quick-261002-k67). Die Adresse wird im Dienst zusaetzlich normalisiert
* (`normalizeCloudUrl`); hier gilt nur die Formpruefung. Eine Logo-Adresse
* ist nur mit https erlaubt — sie wird vom Browser geladen, nie vom Server.
*/
export class CreateNextcloudInstanceDto {
@IsString()
@IsNotEmpty()
@MaxLength(120)
customerName!: string;
@IsString()
@IsNotEmpty()
@MaxLength(2048)
baseUrl!: string;
// Leere Zeichenkette = kein Logo; sonst nur eine https-Adresse.
@IsOptional()
@ValidateIf((_o, value) => typeof value === 'string' && value !== '')
@IsUrl({ protocols: ['https'], require_protocol: true, require_tld: false })
@MaxLength(2048)
logoUrl?: string;
}
export class UpdateNextcloudInstanceDto {
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(120)
customerName?: string;
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(2048)
baseUrl?: string;
// Leere Zeichenkette = Logo-Adresse entfernen.
@IsOptional()
@ValidateIf((_o, value) => typeof value === 'string' && value !== '')
@IsUrl({ protocols: ['https'], require_protocol: true, require_tld: false })
@MaxLength(2048)
logoUrl?: string;
}
@@ -0,0 +1,173 @@
import { describe, expect, it } from 'vitest';
import { buildNextcloudAlertMail, describeCheckError } from './nextcloud-alert-mail';
import type { NextcloudRating } from './nextcloud-rating';
const APP = 'https://tessera.example.invalid';
const AT = new Date('2026-10-02T12:30:00.000Z'); // 14:30 Uhr Berliner Zeit
function rating(
reason: NextcloudRating['reason'],
extra: Partial<NextcloudRating> = {},
): NextcloudRating {
const level = ['current', 'update-available', 'eol-soon'].includes(reason)
? reason === 'current'
? 'green'
: 'yellow'
: 'red';
return { level, reason, updateTo: null, eolDate: null, cycle: null, ...extra };
}
function down(over: Partial<Parameters<typeof buildNextcloudAlertMail>[0]> = {}) {
return buildNextcloudAlertMail(
{
kind: 'down',
customerName: 'Kunde A',
baseUrl: 'https://cloud.a.de',
rating: rating('unreachable'),
errorKind: 'network',
errorDetail: 'ECONNREFUSED',
at: AT,
...over,
},
APP,
);
}
describe('describeCheckError', () => {
it.each([
['tls', 'ERR_TLS_CERT_ALTNAME_INVALID', 'Zertifikat passt nicht zur Adresse'],
['tls', 'HOSTNAME_MISMATCH', 'Zertifikat passt nicht zur Adresse'],
['tls', 'CERT_HAS_EXPIRED', 'Zertifikat abgelaufen'],
['tls', 'DEPTH_ZERO_SELF_SIGNED_CERT', 'Zertifikat nicht vertrauenswürdig'],
['tls', 'SELF_SIGNED_CERT_IN_CHAIN', 'Zertifikat nicht vertrauenswürdig'],
['tls', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'Zertifikat nicht vertrauenswürdig'],
['network', 'ENOTFOUND', 'Adresse nicht gefunden'],
['network', 'EAI_AGAIN', 'Adresse nicht gefunden'],
['network', 'ECONNREFUSED', 'Verbindung abgelehnt'],
['timeout', null, 'Zeitüberschreitung'],
['network', 'ETIMEDOUT', 'Zeitüberschreitung'],
['http-status', 'HTTP 502', 'Server antwortet mit Fehler 502'],
['network', null, 'Verbindungsfehler'],
['network', 'ECONNRESET', 'Verbindungsfehler'],
['tls', 'WAS_AUCH_IMMER', 'Verbindungsfehler'],
[null, null, 'Verbindungsfehler'],
] as const)('%s / %s -> %s', (kind, detail, expected) => {
expect(describeCheckError(kind, detail)).toBe(expected);
});
});
describe('buildNextcloudAlertMail', () => {
it('Betreff nicht erreichbar wortgleich', () => {
expect(down().subject).toBe('Nextcloud Kunde A: nicht erreichbar');
});
it('Betreff wieder in Ordnung wortgleich', () => {
const mail = buildNextcloudAlertMail(
{
kind: 'up',
customerName: 'Kunde A',
baseUrl: 'https://cloud.a.de',
rating: rating('current'),
errorKind: null,
errorDetail: null,
at: AT,
},
APP,
);
expect(mail.subject).toBe('Nextcloud Kunde A: wieder in Ordnung');
});
it.each([
['invalid-response', 'keine gültige Antwort'],
['maintenance', 'im Wartungsmodus'],
['needs-db-upgrade', 'Datenbank-Aktualisierung ausstehend'],
['eol-passed', 'Support abgelaufen'],
] as const)('Betreff je Grund: %s', (reason, wording) => {
expect(down({ rating: rating(reason) }).subject).toBe(`Nextcloud Kunde A: ${wording}`);
});
it('Text enthaelt Grund, Adresse, Zeit in Berliner Zeit und Link zum Modul', () => {
const { text } = down();
expect(text.startsWith('Guten Tag,')).toBe(true);
expect(text).toContain('Grund: Nicht erreichbar (Verbindung abgelehnt)');
expect(text).toContain('Adresse: https://cloud.a.de');
expect(text).toContain('14:30 Uhr');
expect(text).toContain(`Zum Modul: ${APP}/modules/nextcloud-status`);
expect(text).toContain('Benachrichtigen');
expect(text).not.toContain('ECONNREFUSED');
});
it('Gründe im Text: Wartung, Datenbank, Support mit Datum', () => {
expect(down({ rating: rating('maintenance') }).text).toContain(
'Grund: Wartungsmodus eingeschaltet',
);
expect(down({ rating: rating('needs-db-upgrade') }).text).toContain(
'Grund: Datenbank-Aktualisierung ausstehend',
);
expect(down({ rating: rating('invalid-response') }).text).toContain(
'Grund: Keine gültige Nextcloud-Antwort',
);
expect(down({ rating: rating('eol-passed', { eolDate: '2026-09-30' }) }).text).toContain(
'Grund: Support abgelaufen seit 30.09.2026',
);
expect(down({ rating: rating('eol-passed') }).text).toContain('Grund: Support abgelaufen');
});
it('wieder in Ordnung nennt den aktuellen Stand', () => {
const base = {
kind: 'up' as const,
customerName: 'Kunde A',
baseUrl: 'https://cloud.a.de',
errorKind: null,
errorDetail: null,
at: AT,
};
expect(buildNextcloudAlertMail({ ...base, rating: rating('current') }, APP).text).toContain(
'Aktueller Stand: Aktuell',
);
expect(
buildNextcloudAlertMail(
{ ...base, rating: rating('update-available', { updateTo: '35.0.2' }) },
APP,
).text,
).toContain('Aktueller Stand: Update auf 35.0.2 verfügbar');
expect(
buildNextcloudAlertMail(
{ ...base, rating: rating('eol-soon', { eolDate: '2026-12-31' }) },
APP,
).text,
).toContain('Aktueller Stand: Support endet am 31.12.2026');
});
it('entfernt CR/LF aus dem Betreff und kuerzt auf 150 Zeichen (Header-Einschleusung)', () => {
const { subject } = down({
customerName: `Kunde\r\nBcc: boese@example.invalid ${'x'.repeat(300)}`,
});
expect(subject).not.toMatch(/[\r\n]/);
expect(subject.length).toBe(150);
expect(subject.startsWith('Nextcloud Kunde Bcc:')).toBe(true);
});
it('kein Wort fuer Mandant/Tenant in Betreff oder Text', () => {
const mails = [
down(),
down({ rating: rating('maintenance') }),
down({ rating: rating('eol-passed', { eolDate: '2026-09-30' }) }),
buildNextcloudAlertMail(
{
kind: 'up',
customerName: 'Kunde A',
baseUrl: 'https://cloud.a.de',
rating: rating('current'),
errorKind: null,
errorDetail: null,
at: AT,
},
APP,
),
];
for (const mail of mails) {
expect(`${mail.subject}\n${mail.text}`).not.toMatch(/mandant|tenant/i);
}
});
});
@@ -0,0 +1,145 @@
/**
* nextcloud-alert-mail — reiner Baustein der Benachrichtigungsmail
* (quick-261002-kxc, L-05, D-K6). Kein Nest, kein Zugriff auf die Uhr oder
* den Versand: Betreff und Klartext entstehen aus den Eingaben. Die Mail ist
* wie die Erinnerungsmails nur Deutsch, Zeit in Europe/Berlin.
*/
import type { NextcloudRating } from './nextcloud-rating';
export interface NextcloudAlertMailInput {
kind: 'down' | 'up';
customerName: string;
baseUrl: string;
rating: NextcloudRating;
errorKind: string | null;
errorDetail: string | null;
at: Date;
}
const SUBJECT_MAX = 150;
const CERT_NAME_CODES = new Set(['ERR_TLS_CERT_ALTNAME_INVALID', 'HOSTNAME_MISMATCH']);
const CERT_EXPIRED_CODES = new Set(['CERT_HAS_EXPIRED']);
const CERT_UNTRUSTED_CODES = new Set([
'DEPTH_ZERO_SELF_SIGNED_CERT',
'SELF_SIGNED_CERT_IN_CHAIN',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
'CERT_UNTRUSTED',
'CERT_NOT_YET_VALID',
'CERT_REVOKED',
]);
const DNS_CODES = new Set(['ENOTFOUND', 'EAI_AGAIN']);
const TIMEOUT_CODES = new Set(['ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT']);
/**
* Uebersetzt Fehlerart und Kurzkennung eines fehlgeschlagenen Abrufs in einen
* lesbaren deutschen Hinweis. Die Rohkennung bleibt in der Datenbank; die
* Kachel (Web) bildet dieselben Regeln ab.
*/
export function describeCheckError(
errorKind: string | null | undefined,
errorDetail: string | null | undefined,
): string {
const detail = errorDetail ?? '';
if (errorKind === 'tls') {
if (CERT_NAME_CODES.has(detail)) return 'Zertifikat passt nicht zur Adresse';
if (CERT_EXPIRED_CODES.has(detail)) return 'Zertifikat abgelaufen';
if (CERT_UNTRUSTED_CODES.has(detail)) return 'Zertifikat nicht vertrauenswürdig';
return 'Verbindungsfehler';
}
if (errorKind === 'timeout') return 'Zeitüberschreitung';
if (errorKind === 'http-status') {
const match = /^HTTP (\d{3})$/.exec(detail);
return match ? `Server antwortet mit Fehler ${match[1]}` : 'Verbindungsfehler';
}
if (errorKind === 'network') {
if (DNS_CODES.has(detail)) return 'Adresse nicht gefunden';
if (detail === 'ECONNREFUSED') return 'Verbindung abgelehnt';
if (TIMEOUT_CODES.has(detail)) return 'Zeitüberschreitung';
}
return 'Verbindungsfehler';
}
function subjectWording(input: NextcloudAlertMailInput): string {
if (input.kind === 'up') return 'wieder in Ordnung';
switch (input.rating.reason) {
case 'invalid-response':
return 'keine gültige Antwort';
case 'maintenance':
return 'im Wartungsmodus';
case 'needs-db-upgrade':
return 'Datenbank-Aktualisierung ausstehend';
case 'eol-passed':
return 'Support abgelaufen';
default:
return 'nicht erreichbar';
}
}
/** 'YYYY-MM-DD' als 'TT.MM.JJJJ'. */
function germanDay(day: string): string {
const [y, m, d] = day.split('-');
return `${d}.${m}.${y}`;
}
function reasonLine(input: NextcloudAlertMailInput): string {
const { rating } = input;
if (input.kind === 'up') {
if (rating.reason === 'update-available' && rating.updateTo) {
return `Aktueller Stand: Update auf ${rating.updateTo} verfügbar`;
}
if (rating.reason === 'eol-soon' && rating.eolDate) {
return `Aktueller Stand: Support endet am ${germanDay(rating.eolDate)}`;
}
return 'Aktueller Stand: Aktuell';
}
switch (rating.reason) {
case 'invalid-response':
return 'Grund: Keine gültige Nextcloud-Antwort';
case 'maintenance':
return 'Grund: Wartungsmodus eingeschaltet';
case 'needs-db-upgrade':
return 'Grund: Datenbank-Aktualisierung ausstehend';
case 'eol-passed':
return rating.eolDate
? `Grund: Support abgelaufen seit ${germanDay(rating.eolDate)}`
: 'Grund: Support abgelaufen';
default:
return `Grund: Nicht erreichbar (${describeCheckError(input.errorKind, input.errorDetail)})`;
}
}
export function buildNextcloudAlertMail(
input: NextcloudAlertMailInput,
appUrl: string,
): { subject: string; text: string } {
const subject = `Nextcloud ${input.customerName}: ${subjectWording(input)}`
.replace(/[\r\n]+/g, ' ')
.slice(0, SUBJECT_MAX);
const when = `${new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
dateStyle: 'full',
timeStyle: 'short',
}).format(input.at)} Uhr`;
const name = input.customerName.replace(/[\r\n]+/g, ' ');
const sentence =
input.kind === 'down'
? `die Nextcloud "${name}" hat seit ${when} ein Problem.`
: `die Nextcloud "${name}" ist seit ${when} wieder in Ordnung.`;
const lines = [
'Guten Tag,',
'',
sentence,
'',
reasonLine(input),
`Adresse: ${input.baseUrl}`,
`Zeitpunkt: ${when}`,
'',
`Zum Modul: ${appUrl}/modules/nextcloud-status`,
'',
'Sie erhalten diese Nachricht, weil für diese Cloud "Benachrichtigen" eingeschaltet ist. Mit der Glocke auf der Kachel können Sie das jederzeit ausschalten.',
];
return { subject, text: lines.join('\n') };
}
@@ -0,0 +1,175 @@
import { describe, expect, it } from 'vitest';
import {
decideAlert,
FAILURES_FOR_RED,
planStatusWrite,
RETRY_DELAY_MS,
} from './nextcloud-alert-rules';
import { type NextcloudReference, rateNextcloud } from './nextcloud-rating';
import type { NextcloudCheckResult } from './nextcloud-status-fetch';
const NOW = new Date('2026-10-02T12:00:00Z');
const REFERENCE: NextcloudReference = {
fetchedAt: '2026-10-02T10:00:00.000Z',
cycles: [{ cycle: 35, eol: '2099-09-30', latest: '35.0.1' }],
};
const OK: NextcloudCheckResult = {
reachable: true,
maintenance: false,
needsDbUpgrade: false,
versionString: '35.0.1',
edition: 'enterprise',
productName: 'Nextcloud',
errorKind: null,
errorDetail: null,
};
const FAILED: NextcloudCheckResult = {
reachable: false,
maintenance: null,
needsDbUpgrade: null,
versionString: null,
edition: null,
productName: null,
errorKind: 'network',
errorDetail: 'ECONNREFUSED',
};
describe('Konstanten', () => {
it('zwei Fehlschlaege fuer Rot, Wiederholung nach fuenf Minuten', () => {
expect(FAILURES_FOR_RED).toBe(2);
expect(RETRY_DELAY_MS).toBe(5 * 60 * 1000);
});
});
describe('decideAlert', () => {
it.each([
['ok', 'red', 'down'],
['red', 'red', null],
['red', 'green', 'up'],
['red', 'yellow', 'up'],
['red', 'unknown', null],
['ok', 'green', null],
['ok', 'yellow', null],
['ok', 'unknown', null],
] as const)('%s + %s -> %s', (prev, level, expected) => {
expect(decideAlert(prev, level)).toBe(expected);
});
});
describe('planStatusWrite', () => {
it('erster Fehlschlag: nur Zaehler und Zeitpunkt, kein Statusfeld, kein lastCheckedAt', () => {
const plan = planStatusWrite(0, FAILED, NOW);
expect(plan.outcome).toBe('pending');
expect(plan.data).toEqual({ consecutiveFailures: 1, firstFailureAt: NOW });
expect(plan.data).not.toHaveProperty('lastCheckedAt');
expect(plan.data).not.toHaveProperty('reachable');
});
it('zweiter Fehlschlag: bestaetigt, schreibt die Fehlerfelder', () => {
const plan = planStatusWrite(1, FAILED, NOW);
expect(plan.outcome).toBe('confirmed');
expect(plan.data).toMatchObject({
reachable: false,
errorKind: 'network',
errorDetail: 'ECONNREFUSED',
lastCheckedAt: NOW,
consecutiveFailures: 2,
});
expect(plan.data).not.toHaveProperty('firstFailureAt');
});
it('weitere Fehlschlaege einer roten Cloud lassen den Zaehler bei 2', () => {
const plan = planStatusWrite(2, FAILED, NOW);
expect(plan.outcome).toBe('confirmed');
expect(plan.data.consecutiveFailures).toBe(2);
});
it('jeder Erfolg setzt Zaehler und Zeitpunkt zurueck und schreibt alle Statusfelder', () => {
for (const prev of [0, 1, 2]) {
const plan = planStatusWrite(prev, OK, NOW);
expect(plan.outcome).toBe('ok');
expect(plan.data).toMatchObject({
reachable: true,
maintenance: false,
needsDbUpgrade: false,
versionString: '35.0.1',
edition: 'enterprise',
productName: 'Nextcloud',
errorKind: null,
errorDetail: null,
lastCheckedAt: NOW,
consecutiveFailures: 0,
firstFailureAt: null,
});
}
});
it('Wartungsmodus ist ein Erfolg der Pruefung (sofort rot ueber die Bewertung)', () => {
const plan = planStatusWrite(0, { ...OK, maintenance: true }, NOW);
expect(plan.outcome).toBe('ok');
expect(plan.data.maintenance).toBe(true);
});
});
/** Zustand der Zeile nach dem Schreiben, als Bewertungseingabe. */
function rateStored(stored: Record<string, unknown>) {
return rateNextcloud(
{
checkedAt: (stored.lastCheckedAt as Date) ?? null,
reachable: (stored.reachable as boolean | null) ?? null,
maintenance: (stored.maintenance as boolean | null) ?? null,
needsDbUpgrade: (stored.needsDbUpgrade as boolean | null) ?? null,
versionString: (stored.versionString as string | null) ?? null,
errorKind: (stored.errorKind as string | null) ?? null,
},
REFERENCE,
NOW,
);
}
describe('Zusammenspiel mit der Bewertung', () => {
const greenStored = {
lastCheckedAt: new Date('2026-10-02T11:00:00Z'),
reachable: true,
maintenance: false,
needsDbUpgrade: false,
versionString: '35.0.1',
errorKind: null,
};
it('gruene Cloud + ein Fehlschlag: bleibt gruen, keine Meldung', () => {
const plan = planStatusWrite(0, FAILED, NOW);
const rating = rateStored({ ...greenStored, ...plan.data });
expect(rating.level).toBe('green');
expect(decideAlert('ok', rating.level)).toBeNull();
});
it('gruene Cloud + zweiter Fehlschlag: rot -> down', () => {
const plan = planStatusWrite(1, FAILED, NOW);
const rating = rateStored({ ...greenStored, ...plan.data });
expect(rating).toMatchObject({ level: 'red', reason: 'unreachable' });
expect(decideAlert('ok', rating.level)).toBe('down');
});
it('gruene Cloud + Wartungsantwort: sofort down', () => {
const plan = planStatusWrite(0, { ...OK, maintenance: true }, NOW);
const rating = rateStored({ ...greenStored, ...plan.data });
expect(rating).toMatchObject({ level: 'red', reason: 'maintenance' });
expect(decideAlert('ok', rating.level)).toBe('down');
});
it('rote Cloud + gruene Antwort: up', () => {
const plan = planStatusWrite(2, OK, NOW);
const rating = rateStored({ ...greenStored, ...plan.data });
expect(rating.level).toBe('green');
expect(decideAlert('red', rating.level)).toBe('up');
});
it('nie geprueft + ein Fehlschlag: bleibt grau, keine Meldung', () => {
const plan = planStatusWrite(0, FAILED, NOW);
const rating = rateStored({ ...plan.data, lastCheckedAt: null });
expect(rating.level).toBe('unknown');
expect(decideAlert('ok', rating.level)).toBeNull();
});
});
@@ -0,0 +1,92 @@
/**
* nextcloud-alert-rules — reine Regeln der Benachrichtigung
* (quick-261002-kxc, L-02, L-03, D-K1, D-K2). Kein Nest, kein Prisma, kein
* Zugriff auf die Uhr: das Datum wird hereingereicht.
*
* ZWEI-FEHLSCHLAEGE-REGEL: ein einzelner fehlgeschlagener Abruf kann eine
* kurze Stoerung sein (Neustart hinter dem Proxy, Netzwackler). Deshalb gilt
* eine Cloud erst nach zwei aufeinanderfolgenden Fehlschlaegen als "nicht
* erreichbar". Der ERSTE Fehlschlag schreibt nur den Zaehler und den
* Zeitpunkt, alle Statusfelder und `lastCheckedAt` bleiben unangetastet — die
* Bewertung wird aus den gespeicherten Feldern berechnet und zeigt dadurch
* weiter den letzten guten Stand. Wartungsmodus, ausstehende
* Datenbankaktualisierung und abgelaufener Support kommen aus einer
* erfolgreichen Antwort bzw. dem Datum und zaehlen sofort.
*
* UEBERGANG: `decideAlert` vergleicht den zuletzt gemeldeten Zustand
* ('ok' | 'red') mit der Ampel. Grau aendert nichts.
*/
import type { RatingLevel } from './nextcloud-rating';
import type { NextcloudCheckResult } from './nextcloud-status-fetch';
/** Anzahl aufeinanderfolgender Fehlschlaege, ab der "nicht erreichbar" gilt. */
export const FAILURES_FOR_RED = 2;
/** Wartezeit bis zur Wiederholung nach dem ersten Fehlschlag. */
export const RETRY_DELAY_MS = 5 * 60 * 1000;
/** Zuletzt gemeldeter Zustand einer Cloud. */
export type AlertState = 'ok' | 'red';
/** 'down' = eben rot geworden, 'up' = wieder in Ordnung, null = nichts zu melden. */
export type AlertTransition = 'down' | 'up' | null;
export function decideAlert(prev: AlertState, level: RatingLevel): AlertTransition {
if (level === 'unknown') return null;
if (prev === 'ok' && level === 'red') return 'down';
if (prev === 'red' && level !== 'red') return 'up';
return null;
}
export interface StatusWritePlan {
/** 'ok' = Abruf gelungen, 'pending' = erster Fehlschlag, 'confirmed' = bestaetigter Fehlschlag. */
outcome: 'ok' | 'pending' | 'confirmed';
data: Record<string, unknown>;
}
export function planStatusWrite(
prevFailures: number,
result: NextcloudCheckResult,
now: Date,
): StatusWritePlan {
if (result.reachable) {
return {
outcome: 'ok',
data: {
reachable: result.reachable,
maintenance: result.maintenance,
needsDbUpgrade: result.needsDbUpgrade,
versionString: result.versionString,
edition: result.edition,
productName: result.productName,
errorKind: result.errorKind,
errorDetail: result.errorDetail,
lastCheckedAt: now,
consecutiveFailures: 0,
firstFailureAt: null,
},
};
}
const failures = prevFailures + 1;
if (failures < FAILURES_FOR_RED) {
// Erster Fehlschlag: Statusfelder und lastCheckedAt bleiben unberuehrt (D-K2).
return { outcome: 'pending', data: { consecutiveFailures: failures, firstFailureAt: now } };
}
return {
outcome: 'confirmed',
data: {
reachable: result.reachable,
maintenance: result.maintenance,
needsDbUpgrade: result.needsDbUpgrade,
versionString: result.versionString,
edition: result.edition,
productName: result.productName,
errorKind: result.errorKind,
errorDetail: result.errorDetail,
lastCheckedAt: now,
// Gedeckelt: eine dauerhaft rote Cloud zaehlt nicht endlos weiter.
consecutiveFailures: FAILURES_FOR_RED,
},
};
}
@@ -0,0 +1,416 @@
import { NotFoundException } from '@nestjs/common';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
forSystem: vi.fn((p: unknown) => p),
}));
import { forTenant } from '../prisma/prisma-tenant.extension';
import { ALERT_MAIL_RETRY_MS, NextcloudAlertService } from './nextcloud-alert.service';
import { buildNextcloudAlertMail } from './nextcloud-alert-mail';
import type { NextcloudRating } from './nextcloud-rating';
const NOW = new Date('2026-10-02T12:30:00Z');
const RED: NextcloudRating = {
level: 'red',
reason: 'unreachable',
updateTo: null,
eolDate: null,
cycle: null,
};
const GREEN: NextcloudRating = {
level: 'green',
reason: 'current',
updateTo: null,
eolDate: null,
cycle: 35,
};
const ROW = {
id: 'i1',
customerName: 'Kunde A',
baseUrl: 'https://cloud.a.de',
errorKind: 'network',
errorDetail: 'ECONNREFUSED',
alertState: 'ok',
};
function makeService() {
const prisma = {
nextcloudInstance: { findFirst: vi.fn(), findMany: vi.fn(), updateMany: vi.fn() },
nextcloudAlertSubscription: {
findMany: vi.fn().mockResolvedValue([]),
upsert: vi.fn(),
deleteMany: vi.fn(),
},
user: { findMany: vi.fn().mockResolvedValue([]) },
};
const mail = { sendNextcloudAlertEmail: vi.fn().mockResolvedValue(true) };
const settings = { getSmtpConfig: vi.fn().mockResolvedValue({ host: 'smtp.example.invalid' }) };
const moduleAccess = {
getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['mod1', 'USE']])),
};
const moduleRegistry = { findBySlug: vi.fn().mockResolvedValue({ id: 'mod1' }) };
const service = new NextcloudAlertService(
prisma as never,
mail as never,
settings as never,
moduleAccess as never,
moduleRegistry as never,
);
const sleep = vi.fn().mockResolvedValue(undefined);
service.sleep = sleep;
const logSpy = vi.spyOn((service as any).logger, 'log').mockImplementation(() => undefined);
const warnSpy = vi.spyOn((service as any).logger, 'warn').mockImplementation(() => undefined);
vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
return { prisma, mail, settings, moduleAccess, moduleRegistry, service, sleep, logSpy, warnSpy };
}
function withSubscriber(ctx: ReturnType<typeof makeService>, user: Record<string, unknown> = {}) {
ctx.prisma.nextcloudInstance.updateMany.mockResolvedValue({ count: 1 });
ctx.prisma.nextcloudAlertSubscription.findMany.mockResolvedValue([{ userId: 'u1' }]);
ctx.prisma.user.findMany.mockResolvedValue([
{ id: 'u1', email: 'u1@example.invalid', role: 'USER', isActive: true, ...user },
]);
}
describe('NextcloudAlertService Abonnements', () => {
let ctx: ReturnType<typeof makeService>;
beforeEach(() => {
vi.mocked(forTenant).mockClear();
ctx = makeService();
});
it('subscribe ist idempotent (upsert) und bindet Benutzer und Mandant ueber den Klienten', async () => {
ctx.prisma.nextcloudInstance.findFirst.mockResolvedValue({ id: 'i1' });
expect(await ctx.service.subscribe('t1', 'u1', 'i1')).toEqual({ subscribed: true });
expect(await ctx.service.subscribe('t1', 'u1', 'i1')).toEqual({ subscribed: true });
expect(forTenant).toHaveBeenCalledWith(ctx.prisma, 't1', 'u1');
expect(ctx.prisma.nextcloudInstance.findFirst.mock.calls[0][0].where).toEqual({
id: 'i1',
tenantId: 't1',
});
const args = ctx.prisma.nextcloudAlertSubscription.upsert.mock.calls[0][0];
expect(args.where).toEqual({ instanceId_userId: { instanceId: 'i1', userId: 'u1' } });
expect(args.create).toEqual({ tenantId: 't1', userId: 'u1', instanceId: 'i1' });
expect(args.update).toEqual({});
});
it('subscribe fuer unbekannte oder fremde Cloud -> 404 und keine Zeile', async () => {
ctx.prisma.nextcloudInstance.findFirst.mockResolvedValue(null);
await expect(ctx.service.subscribe('t1', 'u1', 'fremd')).rejects.toThrow(NotFoundException);
expect(ctx.prisma.nextcloudAlertSubscription.upsert).not.toHaveBeenCalled();
});
it('unsubscribe loescht nur die Zeile des Aufrufers', async () => {
expect(await ctx.service.unsubscribe('t1', 'u1', 'i1')).toEqual({ subscribed: false });
expect(ctx.prisma.nextcloudAlertSubscription.deleteMany).toHaveBeenCalledWith({
where: { tenantId: 't1', userId: 'u1', instanceId: 'i1' },
});
});
it('subscribedInstanceIds liefert nur die Kennungen des Aufrufers', async () => {
ctx.prisma.nextcloudAlertSubscription.findMany.mockResolvedValue([
{ instanceId: 'a' },
{ instanceId: 'b' },
]);
const ids = await ctx.service.subscribedInstanceIds('t1', 'u1');
expect([...ids]).toEqual(['a', 'b']);
expect(ctx.prisma.nextcloudAlertSubscription.findMany.mock.calls[0][0].where).toEqual({
tenantId: 't1',
userId: 'u1',
});
});
});
describe('NextcloudAlertService.listRecentAlerts', () => {
const T = (iso: string) => new Date(iso);
let ctx: ReturnType<typeof makeService>;
beforeEach(() => {
vi.mocked(forTenant).mockClear();
ctx = makeService();
});
const inst = (over: Record<string, unknown>) => ({
id: 'i1',
customerName: 'Kunde A',
baseUrl: 'https://cloud.a.de',
alertState: 'red',
alertReason: 'unreachable',
alertChangedAt: T('2026-10-02T12:00:00Z'),
...over,
});
it('liefert nur Uebergaenge der letzten 24 Stunden nach dem Einschalten der Glocke, mit down/up', async () => {
ctx.prisma.nextcloudAlertSubscription.findMany.mockResolvedValue([
{ instanceId: 'i1', createdAt: T('2026-10-02T10:00:00Z') },
{ instanceId: 'i2', createdAt: T('2026-10-02T13:00:00Z') }, // Uebergang lag davor
{ instanceId: 'i3', createdAt: T('2026-10-02T09:00:00Z') },
]);
ctx.prisma.nextcloudInstance.findMany.mockResolvedValue([
inst({
id: 'i3',
alertState: 'ok',
alertReason: null,
alertChangedAt: T('2026-10-02T12:30:00Z'),
}),
inst({ id: 'i1' }),
inst({ id: 'i2', alertChangedAt: T('2026-10-02T11:00:00Z') }),
]);
const now = T('2026-10-02T14:00:00Z');
const { alerts } = await ctx.service.listRecentAlerts('t1', 'u1', now);
expect(forTenant).toHaveBeenCalledWith(ctx.prisma, 't1', 'u1');
expect(ctx.prisma.nextcloudAlertSubscription.findMany.mock.calls[0][0].where).toEqual({
tenantId: 't1',
userId: 'u1',
});
const args = ctx.prisma.nextcloudInstance.findMany.mock.calls[0][0];
expect(args.where).toEqual({
tenantId: 't1',
id: { in: ['i1', 'i2', 'i3'] },
alertChangedAt: { gte: T('2026-10-01T14:00:00Z') },
});
expect(args.select).not.toHaveProperty('logoData');
expect(alerts).toEqual([
{
instanceId: 'i1',
customerName: 'Kunde A',
baseUrl: 'https://cloud.a.de',
kind: 'down',
reason: 'unreachable',
changedAt: '2026-10-02T12:00:00.000Z',
},
{
instanceId: 'i3',
customerName: 'Kunde A',
baseUrl: 'https://cloud.a.de',
kind: 'up',
reason: null,
changedAt: '2026-10-02T12:30:00.000Z',
},
]);
});
it('ohne Abonnements: leer, kein Zugriff auf die Clouds (fremde Abonnements erscheinen nie)', async () => {
ctx.prisma.nextcloudAlertSubscription.findMany.mockResolvedValue([]);
expect(await ctx.service.listRecentAlerts('t1', 'u1', T('2026-10-02T14:00:00Z'))).toEqual({
alerts: [],
});
expect(ctx.prisma.nextcloudInstance.findMany).not.toHaveBeenCalled();
});
});
describe('NextcloudAlertService.evaluateAfterCheck', () => {
let ctx: ReturnType<typeof makeService>;
beforeEach(() => {
ctx = makeService();
});
it('Anspruch gewonnen (count 1): Mail an den berechtigten Abonnenten, Zustand wird auf rot gesetzt', async () => {
withSubscriber(ctx);
const result = await ctx.service.evaluateAfterCheck('t1', ROW, RED, NOW);
expect(result.kind).toBe('down');
await result.delivery;
expect(ctx.prisma.nextcloudInstance.updateMany).toHaveBeenCalledWith({
where: { id: 'i1', tenantId: 't1', alertState: 'ok' },
data: { alertState: 'red', alertReason: 'unreachable', alertChangedAt: NOW },
});
expect(ctx.mail.sendNextcloudAlertEmail).toHaveBeenCalledTimes(1);
expect(ctx.mail.sendNextcloudAlertEmail).toHaveBeenCalledWith(
't1',
'u1@example.invalid',
expect.objectContaining({ kind: 'down', customerName: 'Kunde A', rating: RED, at: NOW }),
);
});
it('Anspruch verloren (count 0): keine Mail, auch kein Abonnentenlesen', async () => {
withSubscriber(ctx);
ctx.prisma.nextcloudInstance.updateMany.mockResolvedValue({ count: 0 });
const result = await ctx.service.evaluateAfterCheck('t1', ROW, RED, NOW);
expect(result).toEqual({ kind: null, delivery: null });
expect(ctx.mail.sendNextcloudAlertEmail).not.toHaveBeenCalled();
expect(ctx.prisma.nextcloudAlertSubscription.findMany).not.toHaveBeenCalled();
});
it('rot -> rot: kein Anspruch, keine Mail', async () => {
const result = await ctx.service.evaluateAfterCheck(
't1',
{ ...ROW, alertState: 'red' },
RED,
NOW,
);
expect(result).toEqual({ kind: null, delivery: null });
expect(ctx.prisma.nextcloudInstance.updateMany).not.toHaveBeenCalled();
});
it('gruen -> gruen und grau aendern nichts', async () => {
await ctx.service.evaluateAfterCheck('t1', ROW, GREEN, NOW);
await ctx.service.evaluateAfterCheck(
't1',
{ ...ROW, alertState: 'red' },
{ ...GREEN, level: 'unknown', reason: 'not-checked' },
NOW,
);
expect(ctx.prisma.nextcloudInstance.updateMany).not.toHaveBeenCalled();
});
it('rot -> gruen: genau eine "wieder in Ordnung"-Mail, Grund wird geleert', async () => {
withSubscriber(ctx);
const result = await ctx.service.evaluateAfterCheck(
't1',
{ ...ROW, alertState: 'red' },
GREEN,
NOW,
);
expect(result.kind).toBe('up');
await result.delivery;
expect(ctx.prisma.nextcloudInstance.updateMany).toHaveBeenCalledWith({
where: { id: 'i1', tenantId: 't1', alertState: 'red' },
data: { alertState: 'ok', alertReason: null, alertChangedAt: NOW },
});
expect(ctx.mail.sendNextcloudAlertEmail).toHaveBeenCalledTimes(1);
expect(ctx.mail.sendNextcloudAlertEmail.mock.calls[0][2]).toMatchObject({
kind: 'up',
rating: GREEN,
});
});
it('der Pruefpfad wartet nicht auf den Versand (Hintergrund)', async () => {
withSubscriber(ctx);
let release!: (v: boolean) => void;
ctx.mail.sendNextcloudAlertEmail.mockImplementation(
() => new Promise<boolean>((resolve) => (release = resolve)),
);
const result = await ctx.service.evaluateAfterCheck('t1', ROW, RED, NOW);
expect(result.kind).toBe('down');
await vi.waitFor(() => expect(ctx.mail.sendNextcloudAlertEmail).toHaveBeenCalledTimes(1));
release(true);
await result.delivery;
});
});
describe('NextcloudAlertService Empfaenger (L-06)', () => {
let ctx: ReturnType<typeof makeService>;
beforeEach(() => {
ctx = makeService();
});
async function run() {
const result = await ctx.service.evaluateAfterCheck('t1', ROW, RED, NOW);
await result.delivery;
}
it('deaktivierter Benutzer: uebersprungen und protokolliert', async () => {
withSubscriber(ctx, { isActive: false });
await run();
expect(ctx.mail.sendNextcloudAlertEmail).not.toHaveBeenCalled();
expect(ctx.logSpy.mock.calls.some((c) => String(c[0]).includes('Benutzer deaktiviert'))).toBe(
true,
);
});
it('ohne E-Mail-Adresse: uebersprungen und protokolliert', async () => {
withSubscriber(ctx, { email: null });
await run();
expect(ctx.mail.sendNextcloudAlertEmail).not.toHaveBeenCalled();
expect(ctx.logSpy.mock.calls.some((c) => String(c[0]).includes('keine E-Mail-Adresse'))).toBe(
true,
);
});
it('Modulzugriff inzwischen entzogen: uebersprungen und protokolliert', async () => {
withSubscriber(ctx);
ctx.moduleAccess.getModuleAccessLevels.mockResolvedValue(new Map());
await run();
expect(ctx.moduleAccess.getModuleAccessLevels).toHaveBeenCalledWith('t1', 'u1', 'USER');
expect(ctx.mail.sendNextcloudAlertEmail).not.toHaveBeenCalled();
expect(ctx.logSpy.mock.calls.some((c) => String(c[0]).includes('kein Modulzugriff'))).toBe(
true,
);
});
it('ohne SMTP-Einrichtung: uebersprungen und protokolliert, kein Zugriffs-Lookup', async () => {
withSubscriber(ctx);
ctx.settings.getSmtpConfig.mockResolvedValue(null);
await run();
expect(ctx.mail.sendNextcloudAlertEmail).not.toHaveBeenCalled();
expect(
ctx.logSpy.mock.calls.some((c) => String(c[0]).includes('kein E-Mail-Versand eingerichtet')),
).toBe(true);
});
it('ein Empfaenger ohne Zugriff haelt den anderen nicht auf', async () => {
ctx.prisma.nextcloudInstance.updateMany.mockResolvedValue({ count: 1 });
ctx.prisma.nextcloudAlertSubscription.findMany.mockResolvedValue([
{ userId: 'u1' },
{ userId: 'u2' },
]);
ctx.prisma.user.findMany.mockResolvedValue([
{ id: 'u1', email: 'u1@example.invalid', role: 'USER', isActive: true },
{ id: 'u2', email: 'u2@example.invalid', role: 'ADMIN', isActive: true },
]);
ctx.moduleAccess.getModuleAccessLevels.mockImplementation(async (_t: string, id: string) =>
id === 'u1' ? new Map() : new Map([['mod1', 'MANAGE']]),
);
await run();
expect(ctx.mail.sendNextcloudAlertEmail).toHaveBeenCalledTimes(1);
expect(ctx.mail.sendNextcloudAlertEmail.mock.calls[0][1]).toBe('u2@example.invalid');
});
it('false, false, dann true: genau drei Aufrufe, je 60 s Abstand', async () => {
withSubscriber(ctx);
ctx.mail.sendNextcloudAlertEmail
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
await run();
expect(ctx.mail.sendNextcloudAlertEmail).toHaveBeenCalledTimes(3);
expect(ctx.sleep).toHaveBeenCalledTimes(2);
expect(ctx.sleep).toHaveBeenCalledWith(ALERT_MAIL_RETRY_MS);
});
it('dreimal false: drei Aufrufe, dann Schluss', async () => {
withSubscriber(ctx);
ctx.mail.sendNextcloudAlertEmail.mockResolvedValue(false);
await run();
expect(ctx.mail.sendNextcloudAlertEmail).toHaveBeenCalledTimes(3);
expect(ctx.sleep).toHaveBeenCalledTimes(2);
expect(ctx.warnSpy).toHaveBeenCalled();
});
it('Erfolg beim ersten Versuch: kein Warten, ein Aufruf', async () => {
withSubscriber(ctx);
await run();
expect(ctx.mail.sendNextcloudAlertEmail).toHaveBeenCalledTimes(1);
expect(ctx.sleep).not.toHaveBeenCalled();
});
it('keine Abonnenten: nichts zu tun', async () => {
ctx.prisma.nextcloudInstance.updateMany.mockResolvedValue({ count: 1 });
await run();
expect(ctx.prisma.user.findMany).not.toHaveBeenCalled();
expect(ctx.mail.sendNextcloudAlertEmail).not.toHaveBeenCalled();
});
it('rot -> gruen (z. B. nach korrigierter Adresse): Betreff "wieder in Ordnung" und Zeile "Aktueller Stand"', async () => {
withSubscriber(ctx);
let built: { subject: string; text: string } | null = null;
ctx.mail.sendNextcloudAlertEmail.mockImplementation(
async (_t: string, _to: string, input: Parameters<typeof buildNextcloudAlertMail>[0]) => {
built = buildNextcloudAlertMail(input, 'https://tessera.example.invalid');
return true;
},
);
await run2(ctx, { ...ROW, alertState: 'red' }, GREEN);
expect(built).not.toBeNull();
expect((built as unknown as { subject: string }).subject).toBe(
'Nextcloud Kunde A: wieder in Ordnung',
);
expect((built as unknown as { text: string }).text).toContain('Aktueller Stand: Aktuell');
});
});
async function run2(ctx: ReturnType<typeof makeService>, row: typeof ROW, rating: NextcloudRating) {
const result = await ctx.service.evaluateAfterCheck('t1', row, rating, NOW);
await result.delivery;
}
@@ -0,0 +1,293 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { Role } from '@prisma/client';
import { MailService } from '../mail/mail.service';
import { ModuleAccessService } from '../module-registry/module-access.service';
import { ModuleRegistryService } from '../module-registry/module-registry.service';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { SettingsService } from '../settings/settings.service';
import { type AlertState, type AlertTransition, decideAlert } from './nextcloud-alert-rules';
import type { NextcloudRating } from './nextcloud-rating';
/** Slug des Moduls — Grundlage der Zugriffspruefung beim Versand (L-06). */
const MODULE_SLUG = 'nextcloud-status';
/** Hoechstzahl der Versuche je Empfaenger (L-04). */
export const ALERT_MAIL_MAX_ATTEMPTS = 3;
/** Abstand zwischen zwei Versuchen (D-K5). */
export const ALERT_MAIL_RETRY_MS = 60_000;
/** Die Spalten einer Cloud, die fuer die Entscheidung und den Mailtext noetig sind. */
export interface AlertCheckedRow {
id: string;
customerName: string;
baseUrl: string;
errorKind: string | null;
errorDetail: string | null;
alertState: string;
}
/** Eine Meldung fuer die Anzeige in Tessera (D-K7). */
export interface RecentAlert {
instanceId: string;
customerName: string;
baseUrl: string;
kind: 'down' | 'up';
/** Rote Grundkennung (z. B. 'unreachable'); nur bei `down`. */
reason: string | null;
changedAt: string;
}
/** Zeitraum, in dem ein Uebergang noch als Meldung in Tessera erscheint. */
export const RECENT_ALERT_WINDOW_MS = 24 * 60 * 60 * 1000;
export interface AlertEvaluation {
kind: AlertTransition;
/** Der laufende Versand (nur fuer Tests zum Abwarten; der Pruefpfad wartet nie darauf). */
delivery: Promise<void> | null;
}
/**
* NextcloudAlertService — persoenliche Benachrichtigung (quick-261002-kxc).
*
* WARUM DER ANSPRUCH VOR DEM SENDEN STEHT (L-02, T-kxc-04): ein `updateMany`
* setzt den gemeldeten Zustand NUR, wo er noch der bisherige ist. Nur wer die
* Zeile mit `count === 1` bekommt, meldet. Zwei gleichzeitige Pruefungen,
* mehrere API-Instanzen oder ein Neustart mitten im Durchlauf verschicken so
* nie doppelt — derselbe Gedanke wie in `ReminderMailScheduler`. Der Zustand
* steht auf der Zeile, nicht im Speicher.
*
* VERSAND IM HINTERGRUND (D-K5, T-kxc-07): der Pruefpfad wartet nur auf den
* Anspruch, nie auf SMTP. Je Empfaenger bis zu drei Versuche im Abstand von
* 60 Sekunden, im Prozess. Ein Neustart zwischen den Versuchen verwirft die
* restlichen — bewusst hingenommen: eine Statusmail nach einem Neustart hat
* wenig Wert, Kachel und Meldung in Tessera zeigen den Zustand ohnehin.
* Ueberspringen (kein Versand eingerichtet, keine Adresse, deaktiviert, kein
* Modulzugriff) wird nur protokolliert und nie wiederholt.
*/
@Injectable()
export class NextcloudAlertService {
private readonly logger = new Logger(NextcloudAlertService.name);
/** Ueberschreibbar, damit Tests nicht echte Minuten warten. */
sleep: (ms: number) => Promise<void> = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
constructor(
private readonly prisma: PrismaService,
private readonly mail: MailService,
private readonly settings: SettingsService,
private readonly moduleAccess: ModuleAccessService,
private readonly moduleRegistry: ModuleRegistryService,
) {}
/** Schaltet die Glocke ein. Idempotent; fremde oder unbekannte Cloud: 404. */
async subscribe(
tenantId: string,
userId: string,
instanceId: string,
): Promise<{ subscribed: true }> {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const instance = await tenantPrisma.nextcloudInstance.findFirst({
where: { id: instanceId, tenantId },
select: { id: true },
});
if (!instance) throw new NotFoundException('Cloud nicht gefunden');
await tenantPrisma.nextcloudAlertSubscription.upsert({
where: { instanceId_userId: { instanceId, userId } },
create: { tenantId, userId, instanceId },
update: {},
});
return { subscribed: true };
}
/** Schaltet die Glocke aus. Loescht nur die Zeile des Aufrufers. */
async unsubscribe(
tenantId: string,
userId: string,
instanceId: string,
): Promise<{ subscribed: false }> {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
await tenantPrisma.nextcloudAlertSubscription.deleteMany({
where: { tenantId, userId, instanceId },
});
return { subscribed: false };
}
/** Kennungen der Clouds, fuer die der Benutzer die Glocke eingeschaltet hat. */
async subscribedInstanceIds(tenantId: string, userId: string): Promise<Set<string>> {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const rows: { instanceId: string }[] = await tenantPrisma.nextcloudAlertSubscription.findMany({
where: { tenantId, userId },
select: { instanceId: true },
});
return new Set(rows.map((r) => r.instanceId));
}
/**
* Letzte Uebergaenge der Clouds, fuer die DER AUFRUFER die Glocke eingeschaltet
* hat (quick-261002-kxc, L-04, D-K7, T-kxc-02): je Cloud der juengste
* Uebergang der letzten 24 Stunden, sofern er NACH dem Einschalten der Glocke
* lag. Der Browser zeigt daraus je (Cloud, Zeitpunkt) hoechstens einmal eine
* Meldung. Nur skalare Felder, die die Modulseite ohnehin zeigt.
*/
async listRecentAlerts(
tenantId: string,
userId: string,
now: Date = new Date(),
): Promise<{ alerts: RecentAlert[] }> {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
const subs: { instanceId: string; createdAt: Date }[] =
await tenantPrisma.nextcloudAlertSubscription.findMany({
where: { tenantId, userId },
select: { instanceId: true, createdAt: true },
});
if (subs.length === 0) return { alerts: [] };
const since = new Date(now.getTime() - RECENT_ALERT_WINDOW_MS);
const instances: {
id: string;
customerName: string;
baseUrl: string;
alertState: string;
alertReason: string | null;
alertChangedAt: Date | null;
}[] = await tenantPrisma.nextcloudInstance.findMany({
where: {
tenantId,
id: { in: subs.map((s) => s.instanceId) },
alertChangedAt: { gte: since },
},
select: {
id: true,
customerName: true,
baseUrl: true,
alertState: true,
alertReason: true,
alertChangedAt: true,
},
});
const subscribedAt = new Map(subs.map((s) => [s.instanceId, s.createdAt]));
const alerts: RecentAlert[] = [];
for (const i of instances) {
const subscribed = subscribedAt.get(i.id);
if (!i.alertChangedAt || !subscribed || i.alertChangedAt < subscribed) continue;
alerts.push({
instanceId: i.id,
customerName: i.customerName,
baseUrl: i.baseUrl,
kind: i.alertState === 'red' ? 'down' : 'up',
reason: i.alertState === 'red' ? i.alertReason : null,
changedAt: i.alertChangedAt.toISOString(),
});
}
alerts.sort((a, b) => a.changedAt.localeCompare(b.changedAt));
return { alerts };
}
/**
* Entscheidet nach einer Pruefung, ob eine Meldung faellig ist, beansprucht
* den Uebergang und startet den Versand im Hintergrund. Wartet nur auf den
* Anspruch.
*/
async evaluateAfterCheck(
tenantId: string,
row: AlertCheckedRow,
rating: NextcloudRating,
now: Date = new Date(),
): Promise<AlertEvaluation> {
const prev: AlertState = row.alertState === 'red' ? 'red' : 'ok';
const kind = decideAlert(prev, rating.level);
if (kind === null) return { kind: null, delivery: null };
const next: AlertState = kind === 'down' ? 'red' : 'ok';
const tenantPrisma = forTenant(this.prisma, tenantId);
// Anspruch VOR dem Versand: nur wer count === 1 bekommt, meldet.
const claim = await tenantPrisma.nextcloudInstance.updateMany({
where: { id: row.id, tenantId, alertState: prev },
data: {
alertState: next,
alertReason: kind === 'down' ? rating.reason : null,
alertChangedAt: now,
},
});
if (claim.count !== 1) return { kind: null, delivery: null };
const delivery = this.notifySubscribers(tenantId, row, kind, rating, now).catch((err) =>
this.logger.error(
`Nextcloud-Benachrichtigung für Cloud ${row.id} fehlgeschlagen: ${(err as Error).message}`,
),
);
return { kind, delivery };
}
private async notifySubscribers(
tenantId: string,
row: AlertCheckedRow,
kind: 'down' | 'up',
rating: NextcloudRating,
now: Date,
): Promise<void> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const subs: { userId: string }[] = await tenantPrisma.nextcloudAlertSubscription.findMany({
where: { tenantId, instanceId: row.id },
select: { userId: true },
});
if (subs.length === 0) return;
const users: { id: string; email: string | null; role: Role; isActive: boolean }[] =
await tenantPrisma.user.findMany({
where: { tenantId, id: { in: subs.map((s) => s.userId) } },
select: { id: true, email: true, role: true, isActive: true },
});
const smtp = await this.settings.getSmtpConfig(tenantId);
if (smtp === null) {
this.logger.log(
`Nextcloud-Meldung für Cloud ${row.id} übersprungen (kein E-Mail-Versand eingerichtet)`,
);
return;
}
const module = await this.moduleRegistry.findBySlug(MODULE_SLUG);
const deliverTo = async (user: (typeof users)[number]): Promise<void> => {
// Zugriff und Konto werden JETZT geprueft, nicht beim Einschalten der Glocke (L-06).
if (!user.isActive) return this.skip(row.id, user.id, 'Benutzer deaktiviert');
if (!user.email) return this.skip(row.id, user.id, 'keine E-Mail-Adresse');
const levels = await this.moduleAccess.getModuleAccessLevels(tenantId, user.id, user.role);
if (!module || !levels.has(module.id)) {
return this.skip(row.id, user.id, 'kein Modulzugriff');
}
for (let attempt = 1; attempt <= ALERT_MAIL_MAX_ATTEMPTS; attempt++) {
const ok = await this.mail.sendNextcloudAlertEmail(tenantId, user.email, {
kind,
customerName: row.customerName,
baseUrl: row.baseUrl,
rating,
errorKind: row.errorKind,
errorDetail: row.errorDetail,
at: now,
});
if (ok) return;
if (attempt < ALERT_MAIL_MAX_ATTEMPTS) await this.sleep(ALERT_MAIL_RETRY_MS);
}
this.logger.warn(
`Nextcloud-Meldung für Cloud ${row.id} an Benutzer ${user.id} nach ${ALERT_MAIL_MAX_ATTEMPTS} Versuchen nicht zugestellt`,
);
};
await Promise.all(
users.map((user) =>
deliverTo(user).catch((err) =>
this.logger.error(
`Nextcloud-Meldung für Cloud ${row.id} an Benutzer ${user.id} fehlgeschlagen: ${(err as Error).message}`,
),
),
),
);
}
private skip(instanceId: string, userId: string, reason: string): void {
this.logger.log(
`Nextcloud-Meldung für Cloud ${instanceId} an Benutzer ${userId} übersprungen (${reason})`,
);
}
}
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { checkLogoUpload, NEXTCLOUD_LOGO_MAX_BYTES } from './nextcloud-logo-rules';
const PNG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
const JPEG = [0xff, 0xd8, 0xff, 0xe0];
const GIF = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
const WEBP = [0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50];
describe('checkLogoUpload', () => {
it.each([
['PNG', PNG, 'image/png'],
['JPEG', JPEG, 'image/jpeg'],
['GIF', GIF, 'image/gif'],
['WebP', WEBP, 'image/webp'],
])('erkennt %s', (_name, bytes, mime) => {
expect(checkLogoUpload(Buffer.from([...bytes, 1, 2, 3]))).toBe(mime);
});
it('lehnt SVG-Text, PDF, leere Dateien und umbenanntes HTML ab', () => {
expect(
checkLogoUpload(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>')),
).toBeNull();
expect(checkLogoUpload(Buffer.from('%PDF-1.7 ...'))).toBeNull();
expect(checkLogoUpload(Buffer.alloc(0))).toBeNull();
expect(checkLogoUpload(Buffer.from('<html><script>alert(1)</script></html>'))).toBeNull();
});
it('genau 1 MiB ist erlaubt, ein Byte mehr nicht', () => {
const ok = Buffer.concat([
Buffer.from(PNG),
Buffer.alloc(NEXTCLOUD_LOGO_MAX_BYTES - PNG.length),
]);
expect(ok.length).toBe(NEXTCLOUD_LOGO_MAX_BYTES);
expect(checkLogoUpload(ok)).toBe('image/png');
expect(checkLogoUpload(Buffer.concat([ok, Buffer.from([0])]))).toBeNull();
});
});

Some files were not shown because too many files have changed in this diff Show More