33 Commits

Author SHA1 Message Date
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) Has started running
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
schalli 2d6caec235 docs(changelog): Version 1.9.2 abgeschlossen
Tessera CI/CD / Build & Publish Images (push) Successful in 3m17s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 13m2s
Tessera CI/CD / Tests (push) Successful in 1m56s
Tessera CI/CD / Lint & Type Check (push) Successful in 59s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 10:13:53 +02:00
schalli 90e2157613 fix(cert-manager): geschuetzte PFX nur noch als ruhiger Hinweis, wenn Zertifikat und Schluessel schon vorliegen
Tessera CI/CD / Lint & Type Check (push) Successful in 59s
Tessera CI/CD / Tests (push) Successful in 1m50s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 4m48s
Die Aussteller-ZIP enthaelt neben .pem/.key eine PFX mit einem Passwort,
das niemand kennt (Windows certutil: ERROR_INVALID_PASSWORD bei leerem
Passwort). Die Uebersicht verlangte dafuer prominent ein Passwort, obwohl
Zertifikat und Schluessel einzeln vorliegen. Jetzt: neutraler Hinweis,
dass das Passwort nicht gebraucht wird, Passwortfeld nur auf Wunsch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 09:35:53 +02:00
schalli f501ca6476 docs(quick-261001-l4q): Desktop-Nachweis auf der VM
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 09:20:35 +02:00
schalli c1b26541af test(calendar): Komponente vorab laden, damit Test 1 nicht in die Zeitgrenze laeuft
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 1m27s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 13m52s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m50s
Auf dem ausgelasteten CI-Runner dauerte das erste dynamische Laden der
Kalender-Komponente ueber 5 s; Test 1 brach ab, renderte weiter und Test 2
fand doppelt so viele Tageszellen (Lauf 478). beforeAll laedt das Modul
einmal mit 60 s Grenze.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 15:34:15 +02:00
schalli dfc4e9b781 fix(desktop): Downloads aus der Seite (blob/data) in der App speichern
Tessera CI/CD / Lint & Type Check (push) Successful in 2m38s
Tessera CI/CD / Tests (push) Failing after 1m26s
Tessera CI/CD / Desktop-Pakete bauen (push) Has been skipped
Tessera CI/CD / Build & Publish Images (push) Has been skipped
Der Client reichte jeden Download an den System-Browser weiter. Dateien,
die die Seite selbst erzeugt (blob:/data:, z. B. alle Downloads im
Zertifikat-Manager), kann der Browser nicht abrufen - Windows zeigte
nur "Holen Sie sich eine App, um diesen blob-Link zu oeffnen" (VM 8233).
Solche Downloads speichert die App jetzt selbst (Ordner Downloads) und
meldet Dateiname und Ordner; http/https-Downloads gehen weiter an den
Browser.

Dazu CHANGELOG und Quick-Doku zu 261001-l4q.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 15:28:59 +02:00
schalli c07b0cfaf0 feat(cert-manager): Zertifikatspaket hochladen, erkennen und in jedem Format herunterladen
Neuer erster Reiter Übersicht: mehrere Dateien oder die ZIP vom Aussteller
auf einmal ablegen. Der Server erkennt jedes Teil (Server-, Zwischen-,
Stammzertifikat, privater Schlüssel, CSR, auch aus PFX/P7B), fasst
Duplikate zusammen, ordnet Schlüssel/CSR dem Zertifikat zu und baut die
Kette. Unter jedem Teil stehen Download-Knöpfe für alle passenden Formate
(crt, cer, Fullchain, p7b, pfx mit Schlüssel und Kette; key PKCS#8/PKCS#1/
DER; csr PEM/DER). Geschützte PFX lassen sich mit Passwort entsperren.

Neue Endpunkte POST analyze (Dateien/ZIP, Begrenzung Anzahl und Größe vor
dem Entpacken) und POST export (JSON, zustandslos). Modultexte siezen jetzt
durchgehend; Gültigkeit in UTC wie im Zertifikat.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 15:23:34 +02:00
188 changed files with 20103 additions and 358 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-02 - Quick 261002-k67 + 261002-kxc Nextcloud-Status mit Benachrichtigung (lokal nachgewiesen, nicht gepusht)
Progress: [██████████] 99%
@@ -486,6 +486,11 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 261001-cxo | Desktop-Client: Links mit target=_blank (Favoriten) oeffnen jetzt im System-Browser (DesktopExternalLinks -> window.open) | 2026-10-01 | 61a971c | [261001-cxo](./quick/261001-cxo-desktop-client-links-mit-target-blank-oe/) |
| 261001-g68 | Erinnerung: Cursor sprang beim Schreiben der Beschreibung in den Titel (Fokus-Effekt hing an inline onClose, Kachel zeichnet alle 10 s neu) – Fokus nur beim Oeffnen | 2026-10-01 | siehe git log | [261001-g68](./quick/261001-g68-erinnerung-cursor-springt-aus-beschreibu/) |
| 261001-hbi | Favoriten: Logo fuer per JavaScript gesetzte Symbole (hosteurope.de) – Rueckfall auf DuckDuckGo-Symboldienst beim Ausliefern, nur oeffentliche Seiten | 2026-10-01 | siehe git log | [261001-hbi](./quick/261001-hbi-favoriten-logo-fuer-per-javascript-geset/) |
| 261001-l4q | Zertifikat-Manager: Reiter Übersicht (Paket/ZIP hochladen, Teile erkennen/zuordnen, jedes Teil in jedem Format) + Desktop speichert blob-Downloads selbst | 2026-10-01 | siehe git log | [261001-l4q](./quick/261001-l4q-zertifikatsmodul-paket-hochladen-uebersi/) |
| 261002-fm5 | Finanzbuchhaltung: Module Kantinenabrechnung und Handelsware (DATEV-Export), im Browser nachgewiesen | 2026-10-02 | 1f85277..HEAD | [261002-fm5-finanzbuchhaltung-module-kantinenabrechn](.planning/quick/261002-fm5-finanzbuchhaltung-module-kantinenabrechn/) |
| 261002-icv | Modul-Freigabe mit Stufe Verwalten (Modul-Einstellungen ohne Admin; Kantine, Handelsware, Proxmox, DKV), im Browser nachgewiesen | 2026-10-02 | a222711..HEAD | [261002-icv-modul-freigabe-mit-stufe-verwalten-modul](.planning/quick/261002-icv-modul-freigabe-mit-stufe-verwalten-modul/) |
| 261002-k67 | Modul Nextcloud-Status mit Ampel-Kacheln und Dashboard-Uebersicht | 2026-10-02 | 5ef7b0c..87a7b7c | [261002-k67-modul-nextcloud-status-mit-ampel-kacheln](.planning/quick/261002-k67-modul-nextcloud-status-mit-ampel-kacheln/) |
| 261002-kxc | Nextcloud-Status: Benachrichtigung bei Rot je Benutzer (Mail + Desktop-Hinweis), Klartext-Fehler | 2026-10-02 | faed0d7..6c4bff6 | [261002-kxc-nextcloud-status-benachrichtigung-bei-ro](.planning/quick/261002-kxc-nextcloud-status-benachrichtigung-bei-ro/) |
## Deferred Items
@@ -527,8 +532,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,16 @@
---
quick_id: 261001-l4q
description: "Zertifikatsmodul: Paket hochladen, Uebersicht, Download in jedem Format"
date: 2026-10-01
---
# Zertifikatsmodul: Paket hochladen, Uebersicht, Download in jedem Format
**Auftrag (User):** Testdatei = ZIP vom Aussteller (pem mit Server+Zwischen, key, csr, pfx mit unbekanntem Passwort, .dnstxtrecord). Nach dem Hochladen soll angezeigt werden, welches Zertifikat was ist, darunter jedes Zertifikat in jedem Format herunterladbar.
**Befund vorher:** Modul nimmt nur EINE Datei; .key/.csr/.zip nicht waehlbar; keine Uebersicht; Labels teils englisch; Texte duzen.
## Tasks
1. API `cert-bundle.ts`: `analyzeBundle` (Dateien + ZIP mit Grenzen vor dem Entpacken; PEM/DER/PFX/P7B; Duplikate per SHA-256/Modulus; Schluessel/CSR-Zuordnung; Kette) und `exportBundleItem` (crt, cer, fullchain, p7b, pfx inkl. Schluessel+Kette; key PKCS#8/PKCS#1/DER; csr PEM/DER). Endpunkte POST analyze / export.
2. Web: Reiter „Übersicht“ (Standard), Mehrfach-Ablage, Karten je Teil mit Erklaerung, Status, Zuordnung, Download-Knoepfen, PFX-Passwort; geschuetzte PFX entsperren. Texte de/en, Sie-Form.
3. Desktop: blob:/data:-Downloads in der App speichern (Downloads-Ordner) + Meldung; vorher landete der Klick als „blob-Link“ im System-Browser (VM gemessen).
@@ -0,0 +1,11 @@
---
quick_id: 261001-l4q
status: complete
date: 2026-10-01
---
# Summary
- Echte Aussteller-ZIP (nur lokal, nicht im Repo): 4 Teile erkannt (Server, Zwischen, Schluessel→Server, CSR→Server), PFX als geschuetzt gemeldet, .dnstxtrecord als nicht verwendet; alle 15 Exporte mit OpenSSL gueltig (PFX mit 3 Bloecken, Schluessel-Modulus passt).
- Tests: API cert-bundle.spec 11 (selbst erzeugte PKI), Web OverviewTab.test 5 + Seitentest angepasst; gesamt Web 1189, API 1686, Rust 65 gruen; tsc/clippy/rustfmt sauber.
- Browser lokal: Uebersicht + Downloads (fullchain, pfx, rsa.key, csr.der) geprueft.
- Desktop-Client (VM, lokal): Upload/Anzeige ok; Download zeigte „blob-Link“-Fehler → Client-Fix (on_download speichert blob:/data: selbst, Meldung danach). Nachweis 02.10. auf VM gegen alpha mit Client 1.9.1 Stand c1b2654: crt + fullchain + pfx landen in Downloads, Meldung „Download gespeichert“; Windows certutil liest die PFX (2 Zertifikate, Schluesseltest fuer das Serverzertifikat bestanden). Testdateien danach geloescht.
@@ -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“.
+21
View File
@@ -4,6 +4,27 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
## Unveröffentlicht
### Neu
- Neues Modul „Nextcloud-Status“ in der Gruppe „Infrastruktur“. Es zeigt für jede eingetragene Nextcloud-Cloud Ihrer Kunden eine Kachel mit Logo (oder Initialen), Kundenname, Adresse (öffnet in einem neuen Tab), installierter Version, Ampelfarbe mit kurzer Begründung und dem Zeitpunkt der letzten Prüfung; oben steht die neueste Nextcloud-Version. Grün heißt: neuester Stand seiner Version und der Support läuft noch mehr als drei Monate. Gelb heißt: ein Update steht an oder der Support endet in den nächsten drei Monaten. Rot heißt: der Support ist abgelaufen, die Cloud ist nicht erreichbar, im Wartungsmodus oder wartet auf eine Datenbank-Aktualisierung. Grau heißt: Bewertung nicht möglich, zum Beispiel wenn die Versionsdaten gerade nicht abrufbar sind. Tessera prüft jede Cloud automatisch einmal pro Stunde; „Jetzt prüfen“ und ein Knopf je Kachel prüfen sofort. Die Kacheln lassen sich nach Kundenname, Status (Rot zuerst), Version oder Support-Ende sortieren, die Wahl merkt sich Tessera für jeden Benutzer. Clouds eintragen, ändern, entfernen und Logos hinterlegen (Bild hochladen bis 1 MB oder eine https-Bildadresse) dürfen Administratoren und Benutzer mit der Freigabestufe „Verwalten“; alle anderen mit Freigabe sehen die Kacheln. Aktivieren Sie das Modul als Administrator im Marktplatz und erteilen Sie die Freigabe.
- Nextcloud-Status: Auf jeder Kachel gibt es jetzt eine Glocke „Benachrichtigen“, die jeder Benutzer mit Zugriff auf das Modul für sich ein- und ausschalten kann. Ist sie an, meldet Tessera per E-Mail und, solange Tessera geöffnet ist, als Benachrichtigung auf dem Bildschirm (in der Desktop-App als Windows-Benachrichtigung), wenn die Cloud eine Störung hat – nicht erreichbar, keine gültige Antwort, Wartungsmodus, ausstehende Datenbank-Aktualisierung oder abgelaufener Support – und wenn sie wieder in Ordnung ist. Pro Änderung kommt genau eine Nachricht, solange die Störung anhält, nicht jede Stunde neu. Ein einzelner fehlgeschlagener Abruf löst keine Meldung aus: Die Kachel zeigt weiter den letzten guten Stand mit dem Hinweis „Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt“, und Tessera prüft nach etwa fünf Minuten erneut. Für die E-Mails muss der Mailversand eingerichtet sein und im Benutzerprofil eine E-Mail-Adresse stehen. Außerdem nennt die Kachel bei „Nicht erreichbar“ jetzt den Grund in Klartext, zum Beispiel „Zertifikat passt nicht zur Adresse“, „Adresse nicht gefunden“ oder „Zeitüberschreitung“, statt eines technischen Fehlercodes.
- Neue Dashboard-Kachel „Nextcloud-Status“: drei Zähler für Grün, Gelb und Rot und darunter die roten und gelben Clouds mit Kundenname und Grund. Ein Klick öffnet das Modul. Die Kachel erscheint nur für Benutzer, die das Modul nutzen dürfen.
- Neue Gruppe „Finanzbuchhaltung“ in der Seitenleiste mit zwei Modulen. Beide aktiviert ein Administrator im Marktplatz; wer sie nutzen soll, bekommt zusätzlich die Freigabe.
- Kantinenabrechnung: Die CSV-Datei der Kantine hochladen (Excel-Export mit UTF-8 oder Windows-1252 ist beides in Ordnung). Tessera zeigt Zeilenzahl, Abrechnungsmonat und Gesamtbetrag, nennt fehlerhafte Zeilen mit Zeilennummer und weist auf unterschiedliche Abrechnungsmonate hin. Ist alles in Ordnung, laden Sie mit einem Klick die DATEV-Lohndatei herunter. Beraternummer, Mandantennummer und Lohnart trägt ein Administrator einmalig ein; bis dahin ist der Download gesperrt. Die hochgeladenen Daten werden nicht gespeichert.
- Handelsware: Eine Excel-Liste mit Handelswaren-Umsätzen hochladen. Tessera ordnet jedem Produkt sein Konto zu, markiert neue Produkte mit „neu“ und vergibt ihnen das nächste freie Gegenkonto. Das Buchungsdatum wird aus dem Dateinamen abgeleitet (letzter Tag des Monats) und lässt sich ändern. Neue Konten werden erst beim Herunterladen der Buchungsdatei gespeichert. Im Reiter „Konten“ pflegen Sie die Kontenliste, lesen sie aus einer CSV-Datei ein (ersetzt alle vorhandenen Konten, nach Rückfrage) und exportieren sie als CSV. Standard-Erlöskonto und Startwert für das Gegenkonto trägt ein Administrator einmalig ein.
- Modul-Freigaben haben jetzt zwei Stufen: „Benutzen“ (wie bisher) und „Verwalten“. Wer ein Modul verwalten darf, ändert dessen Einstellungen selbst, ohne Administrator zu sein, zum Beispiel in der Kantinenabrechnung, bei Handelsware und bei den Proxmox-Servern. Ein Administrator wählt die Stufe je Gruppe in der Freigaben-Matrix oder je Benutzer in den Benutzerdetails; bestehende Freigaben bleiben „Benutzen“. Freigaben vergeben und Module aktivieren dürfen weiterhin nur Administratoren. Das Modul DKV-Rechnung steht Administratoren und Benutzern mit „Verwalten“ zur Verfügung.
## 1.9.2 – 2026-10-02
### Neu
- Zertifikat-Manager: Neuer Reiter „Übersicht“. Ziehen Sie alle Dateien, die Sie vom Zertifikatsaussteller bekommen haben, auf einmal hinein – gern auch direkt die ZIP-Datei. Tessera zeigt, was jede Datei ist (Serverzertifikat, Zwischenzertifikat, Stammzertifikat, privater Schlüssel, Zertifikatsanfrage), wofür sie gebraucht wird, wie lange sie gültig ist und was zusammengehört. Unter jedem Teil können Sie es in jedem passenden Format herunterladen: Zertifikate als PEM (.crt), DER (.cer), mit Kette, PKCS#7 (.p7b) oder als PFX mit Schlüssel und Kette; den Schlüssel als PEM, RSA-PEM oder DER; die Anfrage als PEM oder DER. Passwortgeschützte PFX-Dateien lassen sich mit dem Passwort entsperren.
### Behoben
- Desktop-App: Downloads, die Tessera erst im Fenster erstellt (zum Beispiel im Zertifikat-Manager), werden jetzt im Ordner „Downloads“ gespeichert; eine Meldung nennt den Dateinamen. Bisher öffnete Windows nur den Hinweis „Holen Sie sich eine App, um diesen ‚blob‘-Link zu öffnen“. Dafür ist die neue Version der Desktop-App nötig.
- Zertifikat-Manager: Die Texte sprechen Sie jetzt durchgehend mit „Sie“ an.
## 1.9.1 – 2026-10-01
### Behoben
@@ -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())
);
+119 -1
View File
@@ -58,6 +58,7 @@ model User {
moduleGrants ModuleGrant[]
customModules CustomModule[]
reminders Reminder[]
nextcloudAlertSubscriptions NextcloudAlertSubscription[]
@@index([tenantId])
@@index([username])
@@ -140,12 +141,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 +200,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 +357,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 +785,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,
@@ -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;
}
+6
View File
@@ -26,6 +26,9 @@ 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 { RemindersModule } from './reminders/reminders.module';
@@ -55,6 +58,9 @@ import { RemindersModule } from './reminders/reminders.module';
TendersModule,
BugReportsModule,
ProxmoxModule,
NextcloudStatusModule,
KantineDatevModule,
HandelswareDatevModule,
CustomModulesModule,
RemindersModule,
],
@@ -0,0 +1,268 @@
import AdmZip from 'adm-zip';
import * as forge from 'node-forge';
import { beforeAll, describe, expect, it } from 'vitest';
import { analyzeBundle, exportBundleItem, safeBaseName } from './cert-bundle';
/**
* cert-bundle.spec (quick-261001-l4q) — Zertifikatspaket wie vom Aussteller:
* Stamm -> Zwischen -> Server, dazu Schluessel, CSR und PFX, als ZIP.
* Alles hier erzeugt (keine echten Kundendaten im Repo).
*/
interface Pki {
rootPem: string;
interPem: string;
leafPem: string;
keyPem: string;
csrPem: string;
pfx: Buffer;
leafModulus: string;
}
let pki: Pki;
function makeCert(
subjectCn: string,
pub: forge.pki.PublicKey,
signer: forge.pki.PrivateKey,
issuer: forge.pki.CertificateField[] | null,
ca: boolean,
serial: string,
): forge.pki.Certificate {
const cert = forge.pki.createCertificate();
cert.publicKey = pub;
cert.serialNumber = serial;
cert.validity.notBefore = new Date(Date.now() - 86_400_000);
cert.validity.notAfter = new Date(Date.now() + 90 * 86_400_000);
const subject = [{ name: 'commonName', value: subjectCn }];
cert.setSubject(subject);
cert.setIssuer(issuer ?? subject);
const ext: object[] = [{ name: 'basicConstraints', cA: ca }];
if (!ca) ext.push({ name: 'subjectAltName', altNames: [{ type: 2, value: subjectCn }] });
cert.setExtensions(ext);
cert.sign(signer as forge.pki.rsa.PrivateKey, forge.md.sha256.create());
return cert;
}
beforeAll(() => {
const rootKeys = forge.pki.rsa.generateKeyPair(1024);
const interKeys = forge.pki.rsa.generateKeyPair(1024);
const leafKeys = forge.pki.rsa.generateKeyPair(1024);
const root = makeCert('Test Root CA', rootKeys.publicKey, rootKeys.privateKey, null, true, '01');
const inter = makeCert(
'Test Intermediate CA',
interKeys.publicKey,
rootKeys.privateKey,
root.subject.attributes,
true,
'02',
);
const leaf = makeCert(
'www.example.test',
leafKeys.publicKey,
interKeys.privateKey,
inter.subject.attributes,
false,
'03',
);
const csr = forge.pki.createCertificationRequest();
csr.publicKey = leafKeys.publicKey;
csr.setSubject([{ name: 'commonName', value: 'www.example.test' }]);
csr.sign(leafKeys.privateKey, forge.md.sha256.create());
const p12 = forge.pkcs12.toPkcs12Asn1(leafKeys.privateKey, [leaf, inter], 'geheim', {
algorithm: '3des',
});
pki = {
rootPem: forge.pki.certificateToPem(root),
interPem: forge.pki.certificateToPem(inter),
leafPem: forge.pki.certificateToPem(leaf),
keyPem: forge.pki.privateKeyInfoToPem(
forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(leafKeys.privateKey)),
),
csrPem: forge.pki.certificationRequestToPem(csr),
pfx: Buffer.from(forge.asn1.toDer(p12).getBytes(), 'binary'),
leafModulus: leafKeys.publicKey.n.toString(16),
};
}, 60_000);
function issuerZip(): Buffer {
const zip = new AdmZip();
zip.addFile('www.example.test/www.example.test.pem', Buffer.from(pki.leafPem + pki.interPem));
zip.addFile('www.example.test/www.example.test.key', Buffer.from(pki.keyPem));
zip.addFile('www.example.test/www.example.test.csr', Buffer.from(pki.csrPem));
zip.addFile('www.example.test/www.example.test.pfx', pki.pfx);
zip.addFile('www.example.test/.dnstxtrecord', Buffer.from('_dnsauth abc123'));
return zip.toBuffer();
}
describe('analyzeBundle', () => {
it('ZIP vom Aussteller: erkennt jedes Teil, fasst PEM/PFX zusammen, ordnet Schluessel und Kette zu', () => {
const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'geheim');
const kinds = r.items.map((i) => (i.kind === 'certificate' ? i.role : i.kind));
expect(kinds).toEqual(['end-entity', 'intermediate', 'privateKey', 'csr']);
const [leaf, inter, key, csr] = r.items;
expect(leaf.cn).toBe('www.example.test');
expect(leaf.san).toEqual(['www.example.test']);
// in PEM UND PFX enthalten -> ein Eintrag mit beiden Quellen
expect(leaf.sources.sort()).toEqual(['www.example.test.pem', 'www.example.test.pfx']);
expect(leaf.chainIds).toEqual([inter.id]);
expect(leaf.matchId).toBe(key.id);
expect(key.matchId).toBe(leaf.id);
expect(key.sources.sort()).toEqual(['www.example.test.key', 'www.example.test.pfx']);
expect(csr.matchId).toBe(leaf.id);
expect(csr.cn).toBe('www.example.test');
expect(leaf.baseName).toBe('www.example.test');
expect(r.locked).toEqual([]);
expect(r.ignored).toEqual(['.dnstxtrecord']);
});
it('PFX ohne passendes Passwort wird als gesperrt gemeldet, der Rest trotzdem erkannt', () => {
const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'falsch');
expect(r.locked).toEqual(['www.example.test.pfx']);
expect(r.items.filter((i) => i.kind === 'certificate')).toHaveLength(2);
});
it('Stammzertifikat wird als root erkannt und an die Kette gehaengt', () => {
const r = analyzeBundle(
[
{
originalname: 'chain.pem',
buffer: Buffer.from(pki.leafPem + pki.interPem + pki.rootPem),
},
],
'',
);
expect(r.items.map((i) => i.role)).toEqual(['end-entity', 'intermediate', 'root']);
expect(r.items[0].chainIds).toHaveLength(2);
});
it('ohne Dateien -> 400', () => {
expect(() => analyzeBundle([], '')).toThrow(/No files/);
});
it('ZIP mit zu vielen Dateien -> 400', () => {
const zip = new AdmZip();
for (let i = 0; i < 101; i++) zip.addFile(`f${i}.txt`, Buffer.from('x'));
expect(() =>
analyzeBundle([{ originalname: 'gross.zip', buffer: zip.toBuffer() }], ''),
).toThrow(/too many files/);
});
});
describe('exportBundleItem', () => {
function bundle() {
const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'geheim');
const byId = Object.fromEntries(r.items.map((i) => [i.id, i]));
return { items: r.items, byId };
}
const decode = (b64: string) => Buffer.from(b64, 'base64');
it('Zertifikat in jedem Format liest sich wieder ein', () => {
const { items, byId } = bundle();
const leaf = items[0];
const chain = leaf.chainIds.map((id) => byId[id].pem);
const keyPem = byId[leaf.matchId!].pem;
const base = { kind: leaf.kind, pem: leaf.pem, baseName: leaf.baseName, chain, keyPem };
const crt = exportBundleItem({ ...base, format: 'crt' });
expect(crt.filename).toBe('www.example.test.crt');
expect(
forge.pki.certificateFromPem(decode(crt.content).toString()).subject.getField('CN').value,
).toBe('www.example.test');
const cer = exportBundleItem({ ...base, format: 'cer' });
expect(cer.filename).toBe('www.example.test.cer');
forge.pki.certificateFromAsn1(forge.asn1.fromDer(decode(cer.content).toString('binary')));
const full = exportBundleItem({ ...base, format: 'fullchain' });
expect(
decode(full.content)
.toString()
.match(/BEGIN CERTIFICATE/g),
).toHaveLength(2);
const p7b = exportBundleItem({ ...base, format: 'p7b' });
const p7 = forge.pkcs7.messageFromPem(decode(p7b.content).toString());
expect('certificates' in p7 ? p7.certificates : []).toHaveLength(2);
const pfx = exportBundleItem({ ...base, format: 'pfx', password: 'neu' });
expect(pfx.filename).toBe('www.example.test.pfx');
const p12 = forge.pkcs12.pkcs12FromAsn1(
forge.asn1.fromDer(decode(pfx.content).toString('binary')),
'neu',
);
expect(p12.getBags({ bagType: forge.pki.oids.certBag })[forge.pki.oids.certBag]).toHaveLength(
2,
);
const keyBag = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag })[
forge.pki.oids.pkcs8ShroudedKeyBag
]![0];
expect((keyBag.key as forge.pki.rsa.PrivateKey).n.toString(16)).toBe(pki.leafModulus);
});
it('PFX ohne Passwort -> 400', () => {
const { items } = bundle();
expect(() =>
exportBundleItem({ kind: 'certificate', pem: items[0].pem, format: 'pfx' }),
).toThrow(/password is required/);
});
it('Schluessel als PKCS#8, PKCS#1 und DER', () => {
const { items } = bundle();
const key = items.find((i) => i.kind === 'privateKey')!;
const base = { kind: key.kind, pem: key.pem, baseName: key.baseName };
expect(decode(exportBundleItem({ ...base, format: 'key' }).content).toString()).toContain(
'BEGIN PRIVATE KEY',
);
const rsa = exportBundleItem({ ...base, format: 'key-rsa' });
expect(rsa.filename).toBe('www.example.test.rsa.key');
expect(decode(rsa.content).toString()).toContain('BEGIN RSA PRIVATE KEY');
const der = exportBundleItem({ ...base, format: 'key-der' });
const info = forge.asn1.fromDer(decode(der.content).toString('binary'));
expect((forge.pki.privateKeyFromAsn1(info) as forge.pki.rsa.PrivateKey).n.toString(16)).toBe(
pki.leafModulus,
);
});
it('CSR als PEM und DER', () => {
const { items } = bundle();
const csr = items.find((i) => i.kind === 'csr')!;
const der = exportBundleItem({
kind: 'csr',
pem: csr.pem,
baseName: csr.baseName,
format: 'csr-der',
});
expect(der.filename).toBe('www.example.test.csr.der');
forge.pki.certificationRequestFromAsn1(
forge.asn1.fromDer(decode(der.content).toString('binary')),
);
});
it('unpassendes Format -> 400', () => {
const { items } = bundle();
expect(() =>
exportBundleItem({ kind: 'csr', pem: items[3].pem, format: 'pfx', password: 'x' }),
).toThrow();
expect(() =>
exportBundleItem({ kind: 'privateKey', pem: 'kein pem', format: 'key-rsa' }),
).toThrow(/Failed to export/);
});
});
describe('safeBaseName', () => {
it('Platzhalter, Leerzeichen und Pfadteile werden entschaerft', () => {
expect(safeBaseName('*.example.de', 'x')).toBe('wildcard.example.de');
expect(safeBaseName('Encryption Everywhere DV TLS CA - G1', 'x')).toBe(
'Encryption_Everywhere_DV_TLS_CA_-_G1',
);
expect(safeBaseName('../../etc/passwd', 'x')).toBe('etc_passwd');
expect(safeBaseName('', 'fallback')).toBe('fallback');
});
});
+655
View File
@@ -0,0 +1,655 @@
import { BadRequestException } from '@nestjs/common';
import AdmZip from 'adm-zip';
import * as forge from 'node-forge';
import type { UploadedFileLike } from '../auth/types/auth-user';
/**
* Zertifikatspaket (quick-261001-l4q): alles, was ein Aussteller liefert —
* Zertifikat mit Kette (.pem/.crt/.cer/.p7b), privater Schluessel (.key),
* Zertifikatsanfrage (.csr), PFX/P12, gern als ZIP — auf einmal hochladen,
* erkennen, was was ist, und jedes Teil in jedem passenden Format
* herunterladen.
*
* Zustandslos wie der Rest des Moduls: `analyzeBundle` gibt je Teil den
* kanonischen PEM-Text zurueck, `exportBundleItem` baut daraus die Datei.
* Nichts wird gespeichert, Passwoerter werden nie protokolliert.
*/
type BundleFile = Pick<UploadedFileLike, 'buffer' | 'originalname'>;
export type BundleCertRole = 'end-entity' | 'intermediate' | 'root';
export type BundleItemKind = 'certificate' | 'privateKey' | 'csr';
export interface BundleItem {
id: string;
kind: BundleItemKind;
/** Nur bei Zertifikaten. */
role?: BundleCertRole;
/** Dateien, in denen dieses Teil gefunden wurde (Duplikate zusammengefasst). */
sources: string[];
/** Kanonischer PEM-Text — Grundlage fuer jeden Export. */
pem: string;
/** Vorschlag fuer den Dateinamen ohne Endung, aus dem CN abgeleitet. */
baseName: string;
cn: string;
organization: string;
issuerCn: string;
notBefore: string | null;
notAfter: string | null;
isExpired: boolean | null;
daysLeft: number | null;
san: string[];
keyType: string;
keyBits: number;
serialNumber: string;
sha256: string;
/** Zertifikat: id des passenden Schluessels; Schluessel/CSR: id des passenden Zertifikats. */
matchId: string | null;
/** Zertifikat: ids der Kette darueber (Aussteller, dessen Aussteller ...). */
chainIds: string[];
/** Formate, die `exportBundleItem` fuer dieses Teil liefern kann. */
formats: BundleExportFormat[];
}
export interface BundleAnalysis {
items: BundleItem[];
/** PFX/P12 oder verschluesselte Schluessel, die ohne (richtiges) Passwort nicht lesbar sind. */
locked: string[];
/** Dateien ohne erkennbares Zertifikat, Schluessel oder CSR. */
ignored: string[];
}
export type BundleExportFormat =
| 'crt'
| 'cer'
| 'fullchain'
| 'p7b'
| 'pfx'
| 'key'
| 'key-rsa'
| 'key-der'
| 'csr'
| 'csr-der';
export interface BundleExportInput {
kind: BundleItemKind;
pem: string;
format: BundleExportFormat;
baseName?: string;
/** Zertifikat: PEMs der Kette darueber (fuer Fullchain/P7B/PFX). */
chain?: string[];
/** Zertifikat: PEM des passenden privaten Schluessels (fuer PFX). */
keyPem?: string;
/** PFX: Passwort fuer die neue Datei. */
password?: string;
}
export interface BundleExportFile {
filename: string;
/** Base64 */
content: string;
mimeType: string;
}
const MAX_ZIP_ENTRIES = 100;
const MAX_ENTRY_BYTES = 5 * 1024 * 1024;
const PEM_BLOCK = /-----BEGIN ([A-Z0-9 ]+)-----[\s\S]+?-----END \1-----/g;
// ---------------------------------------------------------------------------
// Hilfen
// ---------------------------------------------------------------------------
function binary(buffer: Buffer): forge.util.ByteStringBuffer {
return forge.util.createBuffer(buffer.toString('binary'));
}
function bytesToBase64(bytes: string): string {
return Buffer.from(forge.util.bytesToHex(bytes), 'hex').toString('base64');
}
function textToBase64(text: string): string {
return Buffer.from(text, 'utf-8').toString('base64');
}
function sha256Of(cert: forge.pki.Certificate): string {
const md = forge.md.sha256.create();
md.update(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes());
return (md.digest().toHex().match(/.{2}/g) ?? []).join(':').toUpperCase();
}
function field(name: forge.pki.Certificate['subject'], short: string): string {
return (name.getField(short)?.value as string | undefined) ?? '';
}
/** Dateiname ohne Pfad und ohne gefaehrliche Zeichen, z. B. „*.example.de“ -> „wildcard.example.de“. */
export function safeBaseName(raw: string, fallback: string): string {
const cleaned = raw
.replace(/^\*\./, 'wildcard.')
.replace(/[^A-Za-z0-9._-]+/g, '_')
.replace(/^[._]+/, '')
.slice(0, 80);
return cleaned || fallback;
}
function certRole(cert: forge.pki.Certificate): BundleCertRole {
const bc = cert.getExtension('basicConstraints') as { cA?: boolean } | null;
if (!bc?.cA) return 'end-entity';
return cert.subject.hash === cert.issuer.hash ? 'root' : 'intermediate';
}
function publicKeyInfo(key: unknown): { keyType: string; keyBits: number; modulus: string } {
// node-forge liefert RSA-Schluessel mit `n`; EC-Schluessel kennt es nur
// eingeschraenkt (siehe Kommentar in CertManagerService.parseCert).
const k = key as { n?: forge.jsbn.BigInteger };
if (k?.n) return { keyType: 'RSA', keyBits: k.n.bitLength(), modulus: k.n.toString(16) };
return { keyType: 'EC', keyBits: 0, modulus: '' };
}
function sanOf(extensions: unknown[] | undefined): string[] {
const ext = (extensions ?? []).find((e) => (e as { name?: string }).name === 'subjectAltName') as
| { altNames?: { type: number; value?: string; ip?: string }[] }
| undefined;
return (ext?.altNames ?? []).map((n) =>
n.type === 2 ? (n.value ?? '') : `IP:${n.ip ?? n.value ?? ''}`,
);
}
// ---------------------------------------------------------------------------
// Einsammeln: Dateien (inkl. ZIP) -> rohe Teile
// ---------------------------------------------------------------------------
interface RawKey {
source: string;
pem: string;
modulus: string;
keyType: string;
keyBits: number;
}
interface RawCsr {
source: string;
pem: string;
}
interface Collected {
certs: { source: string; cert: forge.pki.Certificate }[];
keys: RawKey[];
csrs: RawCsr[];
locked: string[];
ignored: string[];
}
function expandZips(files: BundleFile[]): { name: string; buffer: Buffer }[] {
const out: { name: string; buffer: Buffer }[] = [];
for (const file of files) {
if (!file.originalname.toLowerCase().endsWith('.zip')) {
out.push({ name: file.originalname, buffer: file.buffer });
continue;
}
let zip: AdmZip;
try {
zip = new AdmZip(file.buffer);
} catch {
throw new BadRequestException(`"${file.originalname}" is not a readable ZIP archive`);
}
const entries = zip
.getEntries()
.filter((e) => !e.isDirectory && !e.entryName.startsWith('__MACOSX/'));
if (entries.length > MAX_ZIP_ENTRIES) {
throw new BadRequestException(`"${file.originalname}" contains too many files`);
}
for (const entry of entries) {
// Groesse aus dem Kopf pruefen, BEVOR entpackt wird (Zip-Bombe).
if (entry.header.size > MAX_ENTRY_BYTES) {
throw new BadRequestException(
`"${entry.entryName}" in "${file.originalname}" is too large`,
);
}
const name = entry.entryName.split('/').pop() ?? entry.entryName;
out.push({ name, buffer: entry.getData() });
}
}
return out;
}
function addKey(c: Collected, source: string, privateKey: forge.pki.rsa.PrivateKey): void {
const info = publicKeyInfo(privateKey);
const pem = forge.pki.privateKeyInfoToPem(
forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(privateKey)),
);
c.keys.push({ source, pem, ...info });
}
function collectPemText(c: Collected, source: string, text: string, password: string): boolean {
let found = false;
for (const match of text.matchAll(PEM_BLOCK)) {
const [block, type] = match;
try {
if (type === 'CERTIFICATE' || type === 'TRUSTED CERTIFICATE') {
c.certs.push({ source, cert: forge.pki.certificateFromPem(block) });
found = true;
} else if (type === 'PRIVATE KEY' || type === 'RSA PRIVATE KEY') {
const key = forge.pki.privateKeyFromPem(block) as forge.pki.rsa.PrivateKey;
addKey(c, source, key);
found = true;
} else if (type === 'ENCRYPTED PRIVATE KEY') {
const key = password ? forge.pki.decryptRsaPrivateKey(block, password) : null;
if (key) addKey(c, source, key as forge.pki.rsa.PrivateKey);
else c.locked.push(source);
found = true;
} else if (type === 'EC PRIVATE KEY') {
// node-forge kann EC nicht umrechnen — Teil bleibt im Original erhalten.
c.keys.push({ source, pem: block, modulus: '', keyType: 'EC', keyBits: 0 });
found = true;
} else if (type === 'CERTIFICATE REQUEST' || type === 'NEW CERTIFICATE REQUEST') {
c.csrs.push({
source,
pem: block.replace(/NEW CERTIFICATE REQUEST/g, 'CERTIFICATE REQUEST'),
});
found = true;
} else if (type === 'PKCS7') {
const p7 = forge.pkcs7.messageFromPem(block);
for (const cert of 'certificates' in p7 ? p7.certificates : [])
c.certs.push({ source, cert });
found = true;
}
} catch {
// PKCS#8 mit EC-Schluessel o. ae.: node-forge kann ihn nicht lesen —
// im Original behalten statt zu verwerfen.
if (type === 'PRIVATE KEY') {
c.keys.push({ source, pem: block, modulus: '', keyType: 'EC', keyBits: 0 });
found = true;
}
}
}
return found;
}
function collectPfx(c: Collected, source: string, buffer: Buffer, password: string): void {
let p12: forge.pkcs12.Pkcs12Pfx | null = null;
for (const candidate of password ? [password, ''] : ['']) {
try {
p12 = forge.pkcs12.pkcs12FromAsn1(forge.asn1.fromDer(binary(buffer)), candidate);
break;
} catch {
// naechstes Passwort versuchen
}
}
if (!p12) {
c.locked.push(source);
return;
}
for (const bag of p12.getBags({ bagType: forge.pki.oids.certBag })[forge.pki.oids.certBag] ??
[]) {
if (bag.cert) c.certs.push({ source, cert: bag.cert });
}
for (const oid of [forge.pki.oids.pkcs8ShroudedKeyBag, forge.pki.oids.keyBag]) {
for (const bag of p12.getBags({ bagType: oid })[oid] ?? []) {
if (bag.key) addKey(c, source, bag.key as forge.pki.rsa.PrivateKey);
}
}
}
function collectDer(c: Collected, source: string, buffer: Buffer): boolean {
try {
const asn1 = forge.asn1.fromDer(binary(buffer));
try {
c.certs.push({ source, cert: forge.pki.certificateFromAsn1(asn1) });
return true;
} catch {
/* kein einzelnes Zertifikat */
}
try {
const p7 = forge.pkcs7.messageFromAsn1(asn1);
const certs = 'certificates' in p7 ? p7.certificates : [];
for (const cert of certs) c.certs.push({ source, cert });
if (certs.length > 0) return true;
} catch {
/* kein PKCS#7 */
}
try {
forge.pki.certificationRequestFromAsn1(asn1);
const body = forge.asn1.toDer(asn1).getBytes();
c.csrs.push({ source, pem: forge.pem.encode({ type: 'CERTIFICATE REQUEST', body }) });
return true;
} catch {
/* keine CSR */
}
} catch {
/* kein DER */
}
return false;
}
function collect(files: BundleFile[], password: string): Collected {
const c: Collected = { certs: [], keys: [], csrs: [], locked: [], ignored: [] };
for (const { name, buffer } of expandZips(files)) {
const ext = name.split('.').pop()?.toLowerCase() ?? '';
if (ext === 'pfx' || ext === 'p12') {
collectPfx(c, name, buffer, password);
continue;
}
const head = buffer.subarray(0, 4096).toString('latin1');
const found = head.includes('-----BEGIN')
? collectPemText(c, name, buffer.toString('utf-8'), password)
: collectDer(c, name, buffer);
if (!found) c.ignored.push(name);
}
return c;
}
// ---------------------------------------------------------------------------
// analyzeBundle
// ---------------------------------------------------------------------------
const CERT_FORMATS: BundleExportFormat[] = ['crt', 'cer', 'fullchain', 'p7b', 'pfx'];
export function analyzeBundle(files: BundleFile[], password = ''): BundleAnalysis {
if (files.length === 0) throw new BadRequestException('No files provided');
const c = collect(files, password);
// Zertifikate nach Fingerabdruck zusammenfassen (PEM und PFX enthalten oft dieselben).
const certMap = new Map<string, { cert: forge.pki.Certificate; sources: Set<string> }>();
for (const { source, cert } of c.certs) {
const fp = sha256Of(cert);
const entry = certMap.get(fp) ?? { cert, sources: new Set<string>() };
entry.sources.add(source);
certMap.set(fp, entry);
}
const now = Date.now();
const certItems: BundleItem[] = [...certMap.entries()].map(([fp, { cert, sources }]) => {
const info = publicKeyInfo(cert.publicKey);
const cn = field(cert.subject, 'CN');
const notAfter = cert.validity.notAfter;
const role = certRole(cert);
return {
id: `cert-${fp.replace(/:/g, '').slice(0, 16).toLowerCase()}`,
kind: 'certificate',
role,
sources: [...sources],
pem: forge.pki.certificateToPem(cert),
baseName: safeBaseName(cn, role === 'end-entity' ? 'zertifikat' : 'ca'),
cn,
organization: field(cert.subject, 'O'),
issuerCn: field(cert.issuer, 'CN'),
notBefore: cert.validity.notBefore.toISOString(),
notAfter: notAfter.toISOString(),
isExpired: notAfter.getTime() < now,
daysLeft: Math.ceil((notAfter.getTime() - now) / 86_400_000),
san: sanOf(cert.extensions),
keyType: info.keyType,
keyBits: info.keyBits,
serialNumber: cert.serialNumber,
sha256: fp,
matchId: null,
chainIds: [],
formats: CERT_FORMATS,
// nur intern fuer Kette/Zuordnung, wird unten entfernt
_cert: cert,
_modulus: info.modulus,
} as BundleItem & { _cert: forge.pki.Certificate; _modulus: string };
});
// Kette: zu jedem Zertifikat den Aussteller im Paket suchen.
type Internal = BundleItem & { _cert: forge.pki.Certificate; _modulus: string };
const internals = certItems as Internal[];
for (const item of internals) {
let current = item._cert;
const seen = new Set<string>([item.id]);
for (let depth = 0; depth < 10; depth++) {
if (current.subject.hash === current.issuer.hash) break;
const issuer = internals.find(
(o) => !seen.has(o.id) && o._cert.subject.hash === current.issuer.hash,
);
if (!issuer) break;
item.chainIds.push(issuer.id);
seen.add(issuer.id);
current = issuer._cert;
}
}
// Schluessel: Duplikate zusammenfassen, dem Zertifikat zuordnen.
const keyMap = new Map<string, { key: RawKey; sources: Set<string> }>();
for (const key of c.keys) {
const id = key.modulus || key.pem;
const entry = keyMap.get(id) ?? { key, sources: new Set<string>() };
entry.sources.add(key.source);
keyMap.set(id, entry);
}
const keyItems: BundleItem[] = [...keyMap.values()].map(({ key, sources }, i) => {
const cert = key.modulus ? internals.find((o) => o._modulus === key.modulus) : undefined;
const id = `key-${i + 1}`;
if (cert) cert.matchId = id;
const isRsa = key.keyType === 'RSA';
return {
id,
kind: 'privateKey',
sources: [...sources],
pem: key.pem,
baseName:
cert?.baseName ??
safeBaseName(
sources
.values()
.next()
.value?.replace(/\.[^.]+$/, '') ?? '',
'schluessel',
),
cn: cert?.cn ?? '',
organization: '',
issuerCn: '',
notBefore: null,
notAfter: null,
isExpired: null,
daysLeft: null,
san: [],
keyType: key.keyType,
keyBits: key.keyBits,
serialNumber: '',
sha256: '',
matchId: cert?.id ?? null,
chainIds: [],
formats: isRsa ? ['key', 'key-rsa', 'key-der'] : ['key'],
};
});
// CSRs: Duplikate zusammenfassen, Details lesen, dem Zertifikat zuordnen.
const csrMap = new Map<string, { pem: string; sources: Set<string> }>();
for (const csr of c.csrs) {
const norm = csr.pem.replace(/\s+/g, '');
const entry = csrMap.get(norm) ?? { pem: csr.pem, sources: new Set<string>() };
entry.sources.add(csr.source);
csrMap.set(norm, entry);
}
const csrItems: BundleItem[] = [...csrMap.values()].map(({ pem, sources }, i) => {
let cn = '';
let organization = '';
let info = { keyType: '', keyBits: 0, modulus: '' };
let san: string[] = [];
try {
const csr = forge.pki.certificationRequestFromPem(pem);
cn = field(csr.subject as forge.pki.Certificate['subject'], 'CN');
organization = field(csr.subject as forge.pki.Certificate['subject'], 'O');
info = publicKeyInfo(csr.publicKey);
const ext = csr.getAttribute({ name: 'extensionRequest' }) as {
extensions?: unknown[];
} | null;
san = sanOf(ext?.extensions);
} catch {
// EC-CSR: node-forge liest sie nicht — Teil bleibt trotzdem herunterladbar.
}
const cert = info.modulus ? internals.find((o) => o._modulus === info.modulus) : undefined;
return {
id: `csr-${i + 1}`,
kind: 'csr',
sources: [...sources],
pem,
baseName: cert?.baseName ?? safeBaseName(cn, 'anfrage'),
cn,
organization,
issuerCn: '',
notBefore: null,
notAfter: null,
isExpired: null,
daysLeft: null,
san,
keyType: info.keyType,
keyBits: info.keyBits,
serialNumber: '',
sha256: '',
matchId: cert?.id ?? null,
chainIds: [],
formats: ['csr', 'csr-der'],
};
});
// Reihenfolge: Serverzertifikat(e), Zwischen-, Stammzertifikate, Schluessel, CSR.
const roleOrder: Record<BundleCertRole, number> = { 'end-entity': 0, intermediate: 1, root: 2 };
internals.sort(
(a, b) =>
roleOrder[a.role ?? 'end-entity'] - roleOrder[b.role ?? 'end-entity'] ||
b.chainIds.length - a.chainIds.length,
);
const certsClean: BundleItem[] = internals.map(({ _cert, _modulus, ...rest }) => rest);
return {
items: [...certsClean, ...keyItems, ...csrItems],
locked: [...new Set(c.locked)],
ignored: [...new Set(c.ignored)],
};
}
// ---------------------------------------------------------------------------
// exportBundleItem
// ---------------------------------------------------------------------------
const MIME = {
pem: 'application/x-pem-file',
der: 'application/x-x509-ca-cert',
p7b: 'application/x-pkcs7-certificates',
pfx: 'application/x-pkcs12',
key: 'application/x-pem-file',
octet: 'application/octet-stream',
} as const;
function pemBody(pem: string): string {
const [msg] = forge.pem.decode(pem);
if (!msg) throw new Error('no PEM block');
return msg.body;
}
export function exportBundleItem(input: BundleExportInput): BundleExportFile {
const { kind, pem, format, chain = [], keyPem, password } = input;
const base = safeBaseName(
input.baseName ?? '',
kind === 'csr' ? 'anfrage' : kind === 'privateKey' ? 'schluessel' : 'zertifikat',
);
if (!pem || typeof pem !== 'string') throw new BadRequestException('No PEM provided');
if (format === 'pfx' && (!password || password.trim() === '')) {
throw new BadRequestException('A password is required for PFX output');
}
try {
if (kind === 'certificate') {
const cert = forge.pki.certificateFromPem(pem);
const chainCerts = chain.map((p) => forge.pki.certificateFromPem(p));
switch (format) {
case 'crt':
return {
filename: `${base}.crt`,
content: textToBase64(forge.pki.certificateToPem(cert)),
mimeType: MIME.pem,
};
case 'cer':
return {
filename: `${base}.cer`,
content: bytesToBase64(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes()),
mimeType: MIME.der,
};
case 'fullchain': {
const text = [cert, ...chainCerts].map((x) => forge.pki.certificateToPem(x)).join('');
return {
filename: `${base}-fullchain.pem`,
content: textToBase64(text),
mimeType: MIME.pem,
};
}
case 'p7b': {
const p7 = forge.pkcs7.createSignedData();
for (const x of [cert, ...chainCerts]) p7.addCertificate(x);
const text = forge.pem.encode({
type: 'PKCS7',
body: forge.asn1.toDer(p7.toAsn1()).getBytes(),
});
return { filename: `${base}.p7b`, content: textToBase64(text), mimeType: MIME.p7b };
}
case 'pfx': {
const key = keyPem
? (forge.pki.privateKeyFromPem(keyPem) as forge.pki.rsa.PrivateKey)
: null;
const p12 = forge.pkcs12.toPkcs12Asn1(
// null = reines Zertifikatsbuendel ohne Schluessel (siehe
// CertManagerService.mergeCerts).
key,
[cert, ...chainCerts],
password as string, // oben geprueft: PFX verlangt ein Passwort
{ algorithm: '3des', friendlyName: input.baseName || undefined },
);
return {
filename: `${base}.pfx`,
content: bytesToBase64(forge.asn1.toDer(p12).getBytes()),
mimeType: MIME.pfx,
};
}
}
} else if (kind === 'privateKey') {
switch (format) {
case 'key':
return {
filename: `${base}.key`,
content: textToBase64(`${pem.trim()}\n`),
mimeType: MIME.key,
};
case 'key-rsa': {
const key = forge.pki.privateKeyFromPem(pem);
return {
filename: `${base}.rsa.key`,
content: textToBase64(forge.pki.privateKeyToPem(key)),
mimeType: MIME.key,
};
}
case 'key-der': {
const key = forge.pki.privateKeyFromPem(pem);
const info = forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(key));
return {
filename: `${base}.key.der`,
content: bytesToBase64(forge.asn1.toDer(info).getBytes()),
mimeType: MIME.octet,
};
}
}
} else if (kind === 'csr') {
switch (format) {
case 'csr':
return {
filename: `${base}.csr`,
content: textToBase64(`${pem.trim()}\n`),
mimeType: MIME.pem,
};
case 'csr-der':
return {
filename: `${base}.csr.der`,
content: bytesToBase64(pemBody(pem)),
mimeType: MIME.octet,
};
}
}
} catch {
// Passwort und Schluessel nie protokollieren oder zurueckgeben.
throw new BadRequestException(`Failed to export ${kind} as ${format}`);
}
throw new BadRequestException(`Unsupported format "${format}" for ${kind}`);
}
@@ -10,6 +10,12 @@ import {
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
import { UseModule } from '../module-registry/module.guard';
import type { UploadedFileLike } from '../auth/types/auth-user';
import {
analyzeBundle,
type BundleExportFormat,
type BundleItemKind,
exportBundleItem,
} from './cert-bundle';
import { CertManagerService } from './cert-manager.service';
/**
@@ -118,4 +124,50 @@ export class CertManagerController {
}
return this.certManagerService.convertCert({ file, pemText, targetFormat, password });
}
/**
* POST /modules/cert-manager/analyze (quick-261001-l4q)
* Zertifikatspaket: mehrere Dateien oder ZIP hochladen, jedes Teil erkennen
* (Server-/Zwischen-/Stammzertifikat, privater Schluessel, CSR), Duplikate
* zusammenfassen, Schluessel und Kette zuordnen.
*
* T-09-03: 20 Dateien, je 5 MB; ZIP-Inhalt zusaetzlich begrenzt (cert-bundle.ts).
* T-09-02: password is never passed to the logger
*/
@Post('analyze')
@UseInterceptors(
FilesInterceptor('files', 20, {
limits: { fileSize: 5 * 1024 * 1024 },
}),
)
async analyze(
@UploadedFiles() files: UploadedFileLike[] | undefined,
@Body('password') password?: string,
) {
return analyzeBundle(files ?? [], password ?? '');
}
/**
* POST /modules/cert-manager/export (quick-261001-l4q)
* Ein Teil aus `analyze` (PEM) in das gewuenschte Format bringen.
* JSON-Body; PFX verlangt ein Passwort fuer die neue Datei.
*/
@Post('export')
async export(
@Body('kind') kind: BundleItemKind,
@Body('pem') pem: string,
@Body('format') format: BundleExportFormat,
@Body('baseName') baseName?: string,
@Body('chain') chain?: string[],
@Body('keyPem') keyPem?: string,
@Body('password') password?: string,
) {
if (
chain !== undefined &&
(!Array.isArray(chain) || chain.some((c) => typeof c !== 'string'))
) {
throw new BadRequestException('chain must be a list of PEM strings');
}
return exportBundleItem({ kind, pem, format, baseName, chain, keyPem, password });
}
}
+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();
}
@@ -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;
}
+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';`,
);
});
});
@@ -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,
@@ -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);
},
);
});
@@ -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();
});
});
@@ -0,0 +1,21 @@
import { type DashboardImageMime, detectImageMime } from '../dashboard/dashboard-image-rules';
/**
* Regeln fuer hochgeladene Cloud-Logos (quick-261002-k67, L-02, D-A).
* Kein Nest, kein Prisma — direkt an den Bytes testbar.
*
* Erlaubt sind PNG, JPEG, GIF und WebP bis 1 MiB. KEIN SVG: ein SVG kann
* Skript tragen und wuerde, direkt im Tab geoeffnet, unter der Adresse von
* Tessera laufen. Der Typ kommt aus den Magic Bytes, nie aus dem vom Browser
* behaupteten `mimetype` oder der Dateiendung (Muster T-PI9-01); der erkannte
* Typ ist zugleich der Typ, mit dem die Auslieferung antwortet (T-k67-02).
*/
/** Hoechstgroesse: 1 MiB (multer `limits.fileSize` an der Route + zweites Netz im Dienst). */
export const NEXTCLOUD_LOGO_MAX_BYTES = 1024 * 1024;
/** Erkannter Bildtyp oder `null`, wenn leer, zu gross oder kein erlaubtes Bildformat. */
export function checkLogoUpload(buffer: Uint8Array): DashboardImageMime | null {
if (buffer.length === 0 || buffer.length > NEXTCLOUD_LOGO_MAX_BYTES) return null;
return detectImageMime(buffer);
}
@@ -0,0 +1,281 @@
import { describe, expect, it } from 'vitest';
import {
type NextcloudReference,
type NextcloudStatusInput,
newestVersion,
parseVersionTriple,
rateNextcloud,
} from './nextcloud-rating';
const REFERENCE: NextcloudReference = {
fetchedAt: '2026-10-02T10:00:00.000Z',
cycles: [
{ cycle: 35, 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' },
{ cycle: 31, eol: '2026-02-28', latest: '31.0.14' },
],
};
const NOW = new Date('2026-10-02T12:00:00Z');
function status(
versionString: string | null,
over: Partial<NextcloudStatusInput> = {},
): NextcloudStatusInput {
return {
checkedAt: '2026-10-02T11:00:00.000Z',
reachable: true,
maintenance: false,
needsDbUpgrade: false,
versionString,
errorKind: null,
...over,
};
}
describe('rateNextcloud', () => {
it('neuester Patchstand eines unterstuetzten Zweigs ist gruen', () => {
expect(rateNextcloud(status('35.0.1'), REFERENCE, NOW)).toMatchObject({
level: 'green',
reason: 'current',
updateTo: null,
});
});
it('neuer als der gelistete Patchstand ist ebenfalls gruen', () => {
expect(rateNextcloud(status('35.0.2'), REFERENCE, NOW)).toMatchObject({
level: 'green',
reason: 'current',
});
});
it('Update im eigenen Zweig ist gelb', () => {
expect(rateNextcloud(status('34.0.3'), REFERENCE, NOW)).toMatchObject({
level: 'yellow',
reason: 'update-available',
updateTo: '34.0.4',
});
});
it('Support-Ende ueberschritten ist rot mit Datum', () => {
expect(rateNextcloud(status('32.0.15'), REFERENCE, NOW)).toMatchObject({
level: 'red',
reason: 'eol-passed',
eolDate: '2026-09-30',
});
});
it('Support endet bald ist gelb', () => {
expect(
rateNextcloud(status('32.0.15'), REFERENCE, new Date('2026-08-01T00:00:00Z')),
).toMatchObject({
level: 'yellow',
reason: 'eol-soon',
eolDate: '2026-09-30',
});
expect(
rateNextcloud(status('33.0.9'), REFERENCE, new Date('2026-12-15T00:00:00Z')),
).toMatchObject({
level: 'yellow',
reason: 'eol-soon',
eolDate: '2027-02-28',
});
});
it('Grenze: genau 90 Tage bis zum Ende ist gelb, 91 Tage sind gruen', () => {
// 2027-02-28 minus 90 Tage = 2026-11-30
expect(
rateNextcloud(status('33.0.9'), REFERENCE, new Date('2026-11-30T08:00:00Z')),
).toMatchObject({
level: 'yellow',
reason: 'eol-soon',
});
expect(
rateNextcloud(status('33.0.9'), REFERENCE, new Date('2026-11-29T08:00:00Z')),
).toMatchObject({
level: 'green',
reason: 'current',
});
});
it('Grenze: am Tag des Support-Endes gelb, am Tag danach rot', () => {
expect(
rateNextcloud(status('32.0.15'), REFERENCE, new Date('2026-09-30T23:59:00Z')),
).toMatchObject({
level: 'yellow',
reason: 'eol-soon',
});
expect(
rateNextcloud(status('32.0.15'), REFERENCE, new Date('2026-10-01T00:00:00Z')),
).toMatchObject({
level: 'red',
reason: 'eol-passed',
});
});
it('Support-Ende bald hat Vorrang und nennt zusaetzlich das Update', () => {
expect(
rateNextcloud(status('33.0.5'), REFERENCE, new Date('2026-12-15T00:00:00Z')),
).toMatchObject({
level: 'yellow',
reason: 'eol-soon',
updateTo: '33.0.9',
});
});
it('Hauptversion aelter als jeder gelistete Zweig ist rot ohne Datum', () => {
expect(rateNextcloud(status('20.0.1'), REFERENCE, NOW)).toMatchObject({
level: 'red',
reason: 'eol-passed',
eolDate: null,
});
});
it('Hauptversion neuer als jeder gelistete Zweig ist gruen', () => {
expect(rateNextcloud(status('36.0.0'), REFERENCE, NOW)).toMatchObject({
level: 'green',
reason: 'current',
});
});
it('Hauptversion innerhalb der Spanne, aber nicht gelistet, ist grau', () => {
const lueckenhaft: NextcloudReference = {
...REFERENCE,
cycles: REFERENCE.cycles.filter((c) => c.cycle !== 33),
};
expect(rateNextcloud(status('33.0.1'), lueckenhaft, NOW)).toMatchObject({
level: 'unknown',
reason: 'no-reference',
});
});
it('Zweig mit eol false gilt als unterstuetzt, eol true als abgelaufen', () => {
const ohneEnde: NextcloudReference = {
...REFERENCE,
cycles: [
{ cycle: 35, eol: false, latest: '35.0.1' },
{ cycle: 30, eol: true, latest: '30.0.9' },
],
};
expect(rateNextcloud(status('35.0.1'), ohneEnde, NOW)).toMatchObject({
level: 'green',
reason: 'current',
});
expect(rateNextcloud(status('30.0.9'), ohneEnde, NOW)).toMatchObject({
level: 'red',
reason: 'eol-passed',
eolDate: null,
});
});
it('nicht erreichbar ist rot, auch ohne Vergleichsdaten', () => {
expect(
rateNextcloud(status(null, { reachable: false, errorKind: 'timeout' }), null, NOW),
).toMatchObject({
level: 'red',
reason: 'unreachable',
});
});
it('keine gueltige Nextcloud-Antwort ist rot mit eigenem Grund', () => {
expect(
rateNextcloud(status(null, { reachable: false, errorKind: 'not-nextcloud' }), REFERENCE, NOW),
).toMatchObject({
level: 'red',
reason: 'invalid-response',
});
});
it('Wartungsmodus und ausstehende Datenbankaktualisierung sind rot', () => {
expect(rateNextcloud(status('35.0.1', { maintenance: true }), REFERENCE, NOW)).toMatchObject({
level: 'red',
reason: 'maintenance',
});
expect(rateNextcloud(status('35.0.1', { needsDbUpgrade: true }), REFERENCE, NOW)).toMatchObject(
{
level: 'red',
reason: 'needs-db-upgrade',
},
);
});
it('Prioritaet der roten Gruende', () => {
expect(
rateNextcloud(status('32.0.15', { maintenance: true, needsDbUpgrade: true }), REFERENCE, NOW)
.reason,
).toBe('maintenance');
expect(rateNextcloud(status('32.0.15', { needsDbUpgrade: true }), REFERENCE, NOW).reason).toBe(
'needs-db-upgrade',
);
expect(
rateNextcloud(
status('32.0.15', { reachable: false, errorKind: 'not-nextcloud', maintenance: true }),
REFERENCE,
NOW,
).reason,
).toBe('invalid-response');
expect(
rateNextcloud(
status('32.0.15', { reachable: false, errorKind: 'timeout', maintenance: true }),
REFERENCE,
NOW,
).reason,
).toBe('unreachable');
});
it('ohne Vergleichsdaten ist die Bewertung grau', () => {
expect(rateNextcloud(status('35.0.1'), null, NOW)).toMatchObject({
level: 'unknown',
reason: 'no-reference',
});
});
it('erreichbar ohne lesbare Version ist grau', () => {
expect(rateNextcloud(status(null), REFERENCE, NOW)).toMatchObject({
level: 'unknown',
reason: 'version-unknown',
});
expect(rateNextcloud(status('abc'), REFERENCE, NOW)).toMatchObject({
level: 'unknown',
reason: 'version-unknown',
});
});
it('noch nie geprueft ist grau', () => {
expect(
rateNextcloud(
{
checkedAt: null,
reachable: null,
maintenance: null,
needsDbUpgrade: null,
versionString: null,
errorKind: null,
},
REFERENCE,
NOW,
),
).toMatchObject({ level: 'unknown', reason: 'not-checked' });
});
});
describe('newestVersion', () => {
it('liefert den Patchstand des hoechsten Zweigs', () => {
expect(newestVersion(REFERENCE)).toBe('35.0.1');
});
it('ohne Daten null', () => {
expect(newestVersion(null)).toBeNull();
expect(newestVersion({ cycles: [], fetchedAt: 'x' })).toBeNull();
});
});
describe('parseVersionTriple', () => {
it('liest drei Teile und ignoriert weitere', () => {
expect(parseVersionTriple('31.0.5')).toEqual([31, 0, 5]);
expect(parseVersionTriple('31.0.5.1')).toEqual([31, 0, 5]);
expect(parseVersionTriple('31.0')).toBeNull();
expect(parseVersionTriple(null)).toBeNull();
});
});
@@ -0,0 +1,180 @@
/**
* nextcloud-rating — reine Bewertung einer Nextcloud-Cloud (quick-261002-k67,
* L-05). Kein Nest, kein Prisma, kein Zugriff auf die Uhr: das Datum wird
* hereingereicht, damit jede Regel an festen Tagen testbar ist.
*
* Die Ampel (festgelegt):
* - GRUEN: neuester Patchstand seines Hauptzweigs UND der Zweig wird noch
* unterstuetzt (Support-Ende liegt mehr als 90 Tage entfernt).
* - GELB: im eigenen Zweig gibt es ein Update ODER das Support-Ende des
* Zweigs liegt innerhalb der naechsten 90 Tage (inklusive).
* - ROT: Support-Ende erreicht/ueberschritten (oder Hauptzweig aelter als
* jeder gelistete), nicht erreichbar, keine gueltige Nextcloud-Antwort,
* Wartungsmodus oder ausstehende Datenbankaktualisierung.
* - GRAU ("unknown"): ohne Vergleichsdaten, ohne lesbare Version oder noch
* nie geprueft — die roten Zustandsmerkmale gelten trotzdem.
*
* Reihenfolge der roten Gruende: nicht erreichbar > ungueltige Antwort >
* Wartungsmodus > Datenbankaktualisierung > Support abgelaufen.
*/
export interface NextcloudCycle {
/** Hauptversion, z. B. 34. */
cycle: number;
/** Support-Ende als Tag 'YYYY-MM-DD', `true` = beendet, `false` = ohne Ende. */
eol: string | boolean;
/** Neuester Patchstand des Zweigs, z. B. '34.0.4'. */
latest: string;
}
export interface NextcloudReference {
cycles: NextcloudCycle[];
/** ISO-Zeitpunkt des letzten erfolgreichen Abrufs. */
fetchedAt: string;
}
export type RatingLevel = 'green' | 'yellow' | 'red' | 'unknown';
export type RatingReason =
| 'current'
| 'update-available'
| 'eol-soon'
| 'eol-passed'
| 'unreachable'
| 'invalid-response'
| 'maintenance'
| 'needs-db-upgrade'
| 'no-reference'
| 'version-unknown'
| 'not-checked';
export interface NextcloudRating {
level: RatingLevel;
reason: RatingReason;
/** Neuester Patchstand im eigenen Zweig, nur wenn ein Update ansteht. */
updateTo: string | null;
/** Support-Ende des Zweigs als 'YYYY-MM-DD', wenn bekannt. */
eolDate: string | null;
/** Hauptversion der Cloud, wenn lesbar. */
cycle: number | null;
}
/** Eingabe der Bewertung: der zuletzt gespeicherte Zustand einer Cloud. */
export interface NextcloudStatusInput {
/** null = noch nie geprueft. */
checkedAt: Date | string | null;
reachable: boolean | null;
maintenance: boolean | null;
needsDbUpgrade: boolean | null;
versionString: string | null;
errorKind: string | null;
}
/** Warnfenster vor dem Support-Ende in Tagen (einschliesslich). */
export const EOL_WARNING_DAYS = 90;
const DAY_MS = 24 * 60 * 60 * 1000;
/** Versionsangabe `major.minor.patch` als Zahlentripel, sonst null. */
export function parseVersionTriple(
raw: string | null | undefined,
): [number, number, number] | null {
if (!raw) return null;
const match = /^(\d{1,4})\.(\d{1,4})\.(\d{1,4})(?:\D.*)?$/.exec(raw.trim());
if (!match) return null;
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
function compareTriples(a: [number, number, number], b: [number, number, number]): number {
for (let i = 0; i < 3; i++) {
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
}
return 0;
}
/** Tage zwischen zwei Kalendertagen 'YYYY-MM-DD' (UTC), `to - from`. */
function dayDiff(from: string, to: string): number {
return Math.round((Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / DAY_MS);
}
/** Neueste Gesamtversion laut Vergleichsdaten: Patchstand des hoechsten Zweigs. */
export function newestVersion(reference: NextcloudReference | null): string | null {
if (!reference || reference.cycles.length === 0) return null;
let best: NextcloudCycle | null = null;
for (const cycle of reference.cycles) {
if (!best || cycle.cycle > best.cycle) best = cycle;
}
return best?.latest ?? null;
}
function result(
level: RatingLevel,
reason: RatingReason,
extra: Partial<Omit<NextcloudRating, 'level' | 'reason'>> = {},
): NextcloudRating {
return { level, reason, updateTo: null, eolDate: null, cycle: null, ...extra };
}
export function rateNextcloud(
status: NextcloudStatusInput,
reference: NextcloudReference | null,
now: Date,
): NextcloudRating {
const triple = parseVersionTriple(status.versionString);
const cycleNo = triple ? triple[0] : null;
// Rote Zustandsmerkmale gehen vor jeder Versionsbewertung.
if (status.checkedAt === null && status.reachable === null) {
return result('unknown', 'not-checked');
}
if (status.reachable === false) {
if (status.errorKind === 'not-nextcloud') {
return result('red', 'invalid-response', { cycle: cycleNo });
}
return result('red', 'unreachable', { cycle: cycleNo });
}
if (status.maintenance === true) return result('red', 'maintenance', { cycle: cycleNo });
if (status.needsDbUpgrade === true) return result('red', 'needs-db-upgrade', { cycle: cycleNo });
if (!triple) return result('unknown', 'version-unknown');
if (!reference || reference.cycles.length === 0) {
return result('unknown', 'no-reference', { cycle: cycleNo });
}
const cycle = reference.cycles.find((c) => c.cycle === triple[0]);
if (!cycle) {
const numbers = reference.cycles.map((c) => c.cycle);
const highest = Math.max(...numbers);
const lowest = Math.min(...numbers);
// D-E: neuer als jeder gelistete Zweig = frisch erschienen, noch nicht
// bei endoflife.date; aelter als jeder gelistete = lange abgelaufen.
if (triple[0] > highest) return result('green', 'current', { cycle: cycleNo });
if (triple[0] < lowest) return result('red', 'eol-passed', { cycle: cycleNo });
return result('unknown', 'no-reference', { cycle: cycleNo });
}
const today = now.toISOString().slice(0, 10);
const latest = parseVersionTriple(cycle.latest);
const updateTo = latest && compareTriples(triple, latest) < 0 ? cycle.latest : null;
let eolDate: string | null = null;
let daysToEol: number | null = null;
if (cycle.eol === true) {
return result('red', 'eol-passed', { cycle: cycleNo });
}
if (typeof cycle.eol === 'string') {
eolDate = cycle.eol;
daysToEol = dayDiff(today, cycle.eol);
// Am Tag des Support-Endes ist die Version noch unterstuetzt (gelb),
// erst der Tag danach ist rot.
if (daysToEol < 0) return result('red', 'eol-passed', { cycle: cycleNo, eolDate });
}
if (daysToEol !== null && daysToEol <= EOL_WARNING_DAYS) {
return result('yellow', 'eol-soon', { cycle: cycleNo, eolDate, updateTo });
}
if (updateTo) {
return result('yellow', 'update-available', { cycle: cycleNo, eolDate, updateTo });
}
return result('green', 'current', { cycle: cycleNo, eolDate });
}
@@ -0,0 +1,130 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
CACHE_TTL_MS,
NextcloudReleaseService,
parseEndOfLife,
RETRY_BACKOFF_MS,
} from './nextcloud-release.service';
const SAMPLE = [
{ cycle: '35', eol: '2027-09-30', latest: '35.0.1' },
{ cycle: '34', eol: '2027-06-30', latest: '34.0.4' },
{ cycle: '10', eol: false, latest: '10.0.1' },
];
function ok(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200 });
}
describe('parseEndOfLife', () => {
it('liest gueltige Eintraege', () => {
expect(parseEndOfLife(SAMPLE)).toEqual([
{ cycle: 35, eol: '2027-09-30', latest: '35.0.1' },
{ cycle: 34, eol: '2027-06-30', latest: '34.0.4' },
{ cycle: 10, eol: false, latest: '10.0.1' },
]);
});
it('ueberspringt Eintraege ohne numerischen Zweig oder gueltigen Patchstand', () => {
expect(
parseEndOfLife([
{ cycle: 'x', eol: false, latest: '1.0.0' },
{ cycle: '5', eol: false, latest: 'neu' },
{ cycle: '6', eol: 'irgendwann', latest: '6.0.0' },
{ cycle: '7', eol: true, latest: '7.0.0' },
null,
'text',
]),
).toEqual([{ cycle: 7, eol: true, latest: '7.0.0' }]);
});
it('kein Array -> leer', () => {
expect(parseEndOfLife({})).toEqual([]);
});
});
describe('NextcloudReleaseService', () => {
let service: NextcloudReleaseService;
let fetchMock: ReturnType<typeof vi.fn>;
let nowMs: number;
beforeEach(() => {
service = new NextcloudReleaseService();
fetchMock = vi.fn();
nowMs = 1_000_000;
service.fetchImpl = fetchMock as never;
service.now = () => nowMs;
});
it('erster Aufruf holt und wertet aus, zweiter innerhalb 12 h nicht', async () => {
fetchMock.mockResolvedValue(ok(SAMPLE));
const first = await service.getReference();
expect(first?.cycles).toHaveLength(3);
expect(first?.fetchedAt).toBe(new Date(nowMs).toISOString());
nowMs += CACHE_TTL_MS - 1;
await service.getReference();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('nach 12 h kommen die alten Daten sofort, im Hintergrund wird erneuert', async () => {
fetchMock.mockResolvedValueOnce(ok(SAMPLE));
await service.getReference();
nowMs += CACHE_TTL_MS;
fetchMock.mockResolvedValueOnce(ok([{ cycle: '36', eol: false, latest: '36.0.0' }]));
const stale = await service.getReference();
expect(stale?.cycles).toHaveLength(3);
await service.refresh();
const fresh = await service.getReference();
expect(fresh?.cycles).toEqual([{ cycle: 36, eol: false, latest: '36.0.0' }]);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('Fehlschlag mit vorhandenen Daten behaelt sie', async () => {
fetchMock.mockResolvedValueOnce(ok(SAMPLE));
await service.getReference();
nowMs += CACHE_TTL_MS;
fetchMock.mockRejectedValueOnce(new Error('offline'));
await service.refresh();
expect((await service.getReference())?.cycles).toHaveLength(3);
});
it('Fehlschlag ohne Daten -> null; keine neue Anfrage innerhalb von 15 Minuten', async () => {
fetchMock.mockRejectedValue(new Error('offline'));
expect(await service.getReference()).toBeNull();
nowMs += RETRY_BACKOFF_MS - 1;
expect(await service.getReference()).toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(1);
nowMs += 2;
expect(await service.getReference()).toBeNull();
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('HTTP-Fehler zaehlt als Fehlschlag', async () => {
fetchMock.mockResolvedValue(new Response('x', { status: 500 }));
expect(await service.getReference()).toBeNull();
});
it('Muell oder leeres Array behaelt die letzten guten Daten', async () => {
fetchMock.mockResolvedValueOnce(ok(SAMPLE));
await service.getReference();
nowMs += CACHE_TTL_MS;
fetchMock.mockResolvedValueOnce(ok([]));
await service.refresh();
nowMs += RETRY_BACKOFF_MS;
fetchMock.mockResolvedValueOnce(new Response('<html>', { status: 200 }));
await service.refresh();
expect((await service.getReference())?.cycles).toHaveLength(3);
});
it('parallele Aufrufe teilen sich eine Anfrage', async () => {
fetchMock.mockResolvedValue(ok(SAMPLE));
const [a, b, c] = await Promise.all([
service.getReference(),
service.getReference(),
service.getReference(),
]);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(a).toEqual(b);
expect(b).toEqual(c);
});
});
@@ -0,0 +1,127 @@
import { Injectable, Logger } from '@nestjs/common';
import { fetch as undiciFetch } from 'undici';
import type { NextcloudCycle, NextcloudReference } from './nextcloud-rating';
/** Quelle der Vergleichsdaten (L-04). */
export const ENDOFLIFE_URL = 'https://endoflife.date/api/nextcloud.json';
/** So lange gelten abgerufene Daten als frisch (12 Stunden). */
export const CACHE_TTL_MS = 12 * 60 * 60 * 1000;
/** Nach einem Fehlschlag wird fruehestens nach 15 Minuten neu versucht. */
export const RETRY_BACKOFF_MS = 15 * 60 * 1000;
const FETCH_TIMEOUT_MS = 10_000;
const MAX_BYTES = 512 * 1024;
/**
* Liest die Antwort von endoflife.date: nur Eintraege mit rein numerischem
* Zweig, gepunktetem Patchstand und Support-Ende als Tag oder Wahrheitswert
* kommen durch.
*/
export function parseEndOfLife(json: unknown): NextcloudCycle[] {
if (!Array.isArray(json)) return [];
const cycles: NextcloudCycle[] = [];
for (const entry of json) {
if (!entry || typeof entry !== 'object') continue;
const e = entry as Record<string, unknown>;
const cycleRaw = typeof e.cycle === 'number' ? String(e.cycle) : e.cycle;
if (typeof cycleRaw !== 'string' || !/^\d+$/.test(cycleRaw)) continue;
if (typeof e.latest !== 'string' || !/^\d+\.\d+\.\d+(\.\d+)?$/.test(e.latest)) continue;
let eol: string | boolean;
if (typeof e.eol === 'boolean') eol = e.eol;
else if (typeof e.eol === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(e.eol)) eol = e.eol;
else continue;
cycles.push({ cycle: Number(cycleRaw), eol, latest: e.latest });
}
return cycles;
}
/**
* Zwischenspeicher fuer die Versions-Vergleichsdaten (D-D): im Speicher,
* 12 Stunden frisch. Veraltete Daten werden sofort geliefert und im
* Hintergrund erneuert; nur ein leerer Speicher wird abgewartet. Nach einem
* Fehlschlag gibt es 15 Minuten lang keinen neuen Versuch, gleichzeitige
* Aufrufe teilen sich eine Anfrage. Ein Ausfall des Dienstes behaelt die
* letzten guten Daten und stoert nie die Seite.
*/
@Injectable()
export class NextcloudReleaseService {
private readonly logger = new Logger(NextcloudReleaseService.name);
private data: NextcloudReference | null = null;
private fetchedAtMs = 0;
private lastFailureMs: number | null = null;
private inflight: Promise<void> | null = null;
/** Pruefstellen: Tests ueberschreiben diese beiden Felder. */
fetchImpl: typeof undiciFetch = undiciFetch;
now: () => number = () => Date.now();
async getReference(): Promise<NextcloudReference | null> {
if (!this.data) {
await this.refresh();
return this.data;
}
if (this.now() - this.fetchedAtMs >= CACHE_TTL_MS) {
void this.refresh();
}
return this.data;
}
/** Ruft die Daten ab (geteilt, mit Fehlschlag-Pause); wirft nie. */
refresh(): Promise<void> {
if (this.inflight) return this.inflight;
if (this.lastFailureMs !== null && this.now() - this.lastFailureMs < RETRY_BACKOFF_MS) {
return Promise.resolve();
}
this.inflight = this.load().finally(() => {
this.inflight = null;
});
return this.inflight;
}
private async load(): Promise<void> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await this.fetchImpl(ENDOFLIFE_URL, {
signal: controller.signal,
headers: { Accept: 'application/json', 'User-Agent': 'Tessera-Nextcloud-Status' },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const text = await readCapped(response);
const cycles = parseEndOfLife(JSON.parse(text));
if (cycles.length === 0) throw new Error('keine verwertbaren Eintraege');
this.fetchedAtMs = this.now();
this.data = { cycles, fetchedAt: new Date(this.fetchedAtMs).toISOString() };
this.lastFailureMs = null;
} catch (err) {
this.lastFailureMs = this.now();
this.logger.warn(
`Nextcloud-Vergleichsdaten nicht abrufbar: ${(err as Error).message}${this.data ? ' — letzten guten Daten bleiben erhalten' : ''}`,
);
} finally {
clearTimeout(timer);
}
}
}
async function readCapped(response: Awaited<ReturnType<typeof undiciFetch>>): Promise<string> {
if (!response.body) {
const text = await response.text();
if (Buffer.byteLength(text) > MAX_BYTES) throw new Error('Antwort zu gross');
return text;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let total = 0;
let text = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > MAX_BYTES) {
await reader.cancel().catch(() => {});
throw new Error('Antwort zu gross');
}
text += decoder.decode(value, { stream: true });
}
return text + decoder.decode();
}
@@ -0,0 +1,207 @@
import { describe, expect, it, vi } from 'vitest';
import {
fetchNextcloudStatus,
MAX_STATUS_BYTES,
normalizeCloudUrl,
parseNextcloudStatus,
} from './nextcloud-status-fetch';
const VALID = JSON.stringify({
installed: true,
maintenance: false,
needsDbUpgrade: false,
version: '31.0.5.1',
versionstring: '31.0.5',
edition: '',
productname: 'Nextcloud',
extendedSupport: false,
});
function res(body: string | null, status = 200, headers: Record<string, string> = {}) {
return new Response(body, { status, headers });
}
function fetchOf(...responses: Array<Response | (() => Promise<Response>)>) {
const fn = vi.fn();
for (const r of responses) {
if (typeof r === 'function') fn.mockImplementationOnce(r);
else fn.mockResolvedValueOnce(r);
}
return fn;
}
describe('normalizeCloudUrl', () => {
it.each([
[' https://cloud.kunde.de/ ', 'https://cloud.kunde.de'],
['https://cloud.kunde.de/status.php', 'https://cloud.kunde.de'],
['https://cloud.kunde.de/index.php', 'https://cloud.kunde.de'],
['https://host/nextcloud/', 'https://host/nextcloud'],
['https://host/nextcloud/index.php/?a=1#x', 'https://host/nextcloud'],
['http://intern:8080', 'http://intern:8080'],
])('%s -> %s', (input, expected) => {
expect(normalizeCloudUrl(input)).toBe(expected);
});
it.each([
'ftp://host/x',
'javascript:alert(1)',
'https://user:pass@cloud.kunde.de',
'',
' ',
'kein url',
`https://host/${'a'.repeat(2100)}`,
])('lehnt %s ab', (input) => {
expect(normalizeCloudUrl(input)).toBeNull();
});
});
describe('parseNextcloudStatus', () => {
it('liest ein gueltiges status.php', () => {
expect(parseNextcloudStatus(VALID)).toEqual({
maintenance: false,
needsDbUpgrade: false,
versionString: '31.0.5',
edition: null,
productName: 'Nextcloud',
});
});
it('behaelt Wartungsmodus', () => {
expect(
parseNextcloudStatus(
JSON.stringify({ installed: true, maintenance: true, versionstring: '31.0.5' }),
)?.maintenance,
).toBe(true);
});
it('fehlendes needsDbUpgrade ist false, fehlender versionstring faellt auf version zurueck', () => {
const parsed = parseNextcloudStatus(JSON.stringify({ installed: true, version: '31.0.5.1' }));
expect(parsed?.needsDbUpgrade).toBe(false);
expect(parsed?.versionString).toBe('31.0.5');
});
it.each([
'<html>Login</html>',
'',
'[]',
'"text"',
'42',
JSON.stringify({ installed: false, version: '31.0.5.1' }),
JSON.stringify({ version: '31.0.5.1' }),
])('kein Nextcloud: %s', (input) => {
expect(parseNextcloudStatus(input)).toBeNull();
});
it('kuerzt lange Textfelder auf 64 Zeichen und verwirft ungueltige Versionstexte', () => {
const parsed = parseNextcloudStatus(
JSON.stringify({
installed: true,
versionstring: '<script>',
edition: 'e'.repeat(200),
productname: 'p'.repeat(200),
}),
);
expect(parsed?.edition).toHaveLength(64);
expect(parsed?.productName).toHaveLength(64);
expect(parsed?.versionString).toBeNull();
});
});
describe('fetchNextcloudStatus', () => {
const BASE = 'https://cloud.kunde.de';
it('200 gueltig -> erreichbar mit Feldern, Anfrage ohne Zugangsdaten an <base>/status.php', async () => {
const fetchImpl = fetchOf(res(VALID));
const result = await fetchNextcloudStatus(BASE, { fetchImpl: fetchImpl as never });
expect(result).toMatchObject({
reachable: true,
versionString: '31.0.5',
maintenance: false,
errorKind: null,
});
expect(fetchImpl).toHaveBeenCalledTimes(1);
const [url, init] = fetchImpl.mock.calls[0];
expect(url).toBe('https://cloud.kunde.de/status.php');
const names = Object.keys(init.headers).map((h) => h.toLowerCase());
expect(names).not.toContain('authorization');
expect(names).not.toContain('cookie');
expect(init.redirect).toBe('manual');
});
it('200 Wartungsmodus -> erreichbar, maintenance true', async () => {
const body = JSON.stringify({ installed: true, maintenance: true, versionstring: '31.0.5' });
const result = await fetchNextcloudStatus(BASE, { fetchImpl: fetchOf(res(body)) as never });
expect(result).toMatchObject({ reachable: true, maintenance: true });
});
it('200 Muell -> not-nextcloud', async () => {
const result = await fetchNextcloudStatus(BASE, { fetchImpl: fetchOf(res('<html>')) as never });
expect(result).toMatchObject({ reachable: false, errorKind: 'not-nextcloud' });
});
it('503 -> http-status mit Detail', async () => {
const result = await fetchNextcloudStatus(BASE, {
fetchImpl: fetchOf(res('down', 503)) as never,
});
expect(result).toMatchObject({
reachable: false,
errorKind: 'http-status',
errorDetail: 'HTTP 503',
});
});
it('haengende Anfrage -> timeout', async () => {
const hang = () => new Promise<Response>(() => {});
const result = await fetchNextcloudStatus(BASE, {
fetchImpl: fetchOf(hang) as never,
timeoutMs: 20,
});
expect(result).toMatchObject({ reachable: false, errorKind: 'timeout' });
});
it('abgelehnte Anfrage mit cause.code -> network', async () => {
const reject = () =>
Promise.reject(
Object.assign(new TypeError('fetch failed'), { cause: { code: 'ENOTFOUND' } }),
);
const result = await fetchNextcloudStatus(BASE, { fetchImpl: fetchOf(reject) as never });
expect(result).toMatchObject({ errorKind: 'network', errorDetail: 'ENOTFOUND' });
});
it('Zertifikatsfehler -> tls', async () => {
const reject = () =>
Promise.reject(
Object.assign(new TypeError('fetch failed'), { cause: { code: 'CERT_HAS_EXPIRED' } }),
);
const result = await fetchNextcloudStatus(BASE, { fetchImpl: fetchOf(reject) as never });
expect(result).toMatchObject({ errorKind: 'tls', errorDetail: 'CERT_HAS_EXPIRED' });
});
it('301 mit relativer Adresse wird einmal gefolgt', async () => {
const fetchImpl = fetchOf(res(null, 301, { location: '/nextcloud/status.php' }), res(VALID));
const result = await fetchNextcloudStatus(BASE, { fetchImpl: fetchImpl as never });
expect(result.reachable).toBe(true);
expect(fetchImpl.mock.calls[1][0]).toBe('https://cloud.kunde.de/nextcloud/status.php');
});
it('Weiterleitung auf ftp: -> redirect', async () => {
const result = await fetchNextcloudStatus(BASE, {
fetchImpl: fetchOf(res(null, 302, { location: 'ftp://x/status.php' })) as never,
});
expect(result).toMatchObject({ errorKind: 'redirect' });
});
it('vier Weiterleitungen in Folge -> redirect', async () => {
const hop = () => res(null, 302, { location: '/status.php' });
const fetchImpl = fetchOf(hop(), hop(), hop(), hop(), res(VALID));
const result = await fetchNextcloudStatus(BASE, { fetchImpl: fetchImpl as never });
expect(result).toMatchObject({ errorKind: 'redirect' });
expect(fetchImpl).toHaveBeenCalledTimes(4);
});
it('Antwort ueber 64 KiB -> too-large', async () => {
const big = 'x'.repeat(MAX_STATUS_BYTES + 10);
const result = await fetchNextcloudStatus(BASE, { fetchImpl: fetchOf(res(big)) as never });
expect(result).toMatchObject({ errorKind: 'too-large' });
});
});
@@ -0,0 +1,288 @@
import { fetch as undiciFetch } from 'undici';
/**
* nextcloud-status-fetch — Abruf und Auswertung von `<Adresse>/status.php`
* (quick-261002-k67, L-03). Reine Funktionen plus ein Abruf mit einsetzbarem
* `fetch`, damit Tests ohne Netz auskommen.
*/
/** Zeitgrenze fuer die gesamte Anfrage (inkl. Weiterleitungen und Lesen). */
export const STATUS_TIMEOUT_MS = 10_000;
/** Hoechstzahl gefolgter Weiterleitungen. */
export const MAX_REDIRECTS = 3;
/** Groessendeckel der Antwort. */
export const MAX_STATUS_BYTES = 64 * 1024;
const MAX_URL_LENGTH = 2048;
const MAX_TEXT_FIELD = 64;
const MAX_VERSION_FIELD = 32;
const MAX_DETAIL = 120;
export type NextcloudErrorKind =
| 'timeout'
| 'network'
| 'tls'
| 'http-status'
| 'not-nextcloud'
| 'too-large'
| 'redirect';
export interface ParsedNextcloudStatus {
maintenance: boolean;
needsDbUpgrade: boolean;
versionString: string | null;
edition: string | null;
productName: string | null;
}
export interface NextcloudCheckResult {
reachable: boolean;
maintenance: boolean | null;
needsDbUpgrade: boolean | null;
versionString: string | null;
edition: string | null;
productName: string | null;
errorKind: NextcloudErrorKind | null;
errorDetail: string | null;
}
/**
* Bringt eine eingegebene Cloud-Adresse in Normalform: nur http/https, ohne
* Zugangsdaten, ohne Abfrageteil/Anker, ohne `/status.php`, `/index.php` und
* ohne abschliessenden Schrägstrich (Unterpfade wie `/nextcloud` bleiben).
* Ungueltiges ergibt `null`.
*/
export function normalizeCloudUrl(raw: string): string | null {
const trimmed = (raw ?? '').trim();
if (!trimmed || trimmed.length > MAX_URL_LENGTH) return null;
let url: URL;
try {
url = new URL(trimmed);
} catch {
return null;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
if (url.username || url.password) return null;
if (!url.hostname) return null;
let path = url.pathname.replace(/\/+$/, '');
path = path.replace(/\/(status|index)\.php$/i, '').replace(/\/+$/, '');
const normalized = `${url.protocol}//${url.host}${path}`;
return normalized.length > MAX_URL_LENGTH ? null : normalized;
}
function cleanVersion(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed || trimmed.length > MAX_VERSION_FIELD || !/^[0-9.]+$/.test(trimmed)) return null;
return trimmed;
}
function cleanText(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed ? trimmed.slice(0, MAX_TEXT_FIELD) : null;
}
/**
* Liest die Antwort von `status.php`. Nur Felder aus einer festen Liste
* werden uebernommen; alles, was kein JSON-Objekt mit `installed: true` ist,
* ergibt `null` (nicht Nextcloud).
*/
export function parseNextcloudStatus(text: string): ParsedNextcloudStatus | null {
let json: unknown;
try {
json = JSON.parse(text);
} catch {
return null;
}
if (!json || typeof json !== 'object' || Array.isArray(json)) return null;
const obj = json as Record<string, unknown>;
if (obj.installed !== true) return null;
let versionString = cleanVersion(obj.versionstring);
if (!versionString) {
const full = cleanVersion(obj.version);
versionString = full ? full.split('.').slice(0, 3).join('.') : null;
}
return {
maintenance: obj.maintenance === true,
needsDbUpgrade: obj.needsDbUpgrade === true,
versionString,
edition: cleanText(obj.edition),
productName: cleanText(obj.productname),
};
}
const TLS_CODES = new Set([
'CERT_HAS_EXPIRED',
'DEPTH_ZERO_SELF_SIGNED_CERT',
'SELF_SIGNED_CERT_IN_CHAIN',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
'ERR_TLS_CERT_ALTNAME_INVALID',
'CERT_NOT_YET_VALID',
'CERT_UNTRUSTED',
'CERT_REVOKED',
'HOSTNAME_MISMATCH',
]);
function failure(errorKind: NextcloudErrorKind, errorDetail: string | null): NextcloudCheckResult {
return {
reachable: false,
maintenance: null,
needsDbUpgrade: null,
versionString: null,
edition: null,
productName: null,
errorKind,
errorDetail: errorDetail ? errorDetail.slice(0, MAX_DETAIL) : null,
};
}
function errorCode(err: unknown): string | null {
const e = err as { code?: unknown; cause?: { code?: unknown } } | null;
const code = e?.cause?.code ?? e?.code;
return typeof code === 'string' && /^[A-Z0-9_]{2,80}$/.test(code) ? code : null;
}
class TooLargeError extends Error {}
async function readCapped(
response: { body: ReadableStream<Uint8Array> | null; text(): Promise<string> },
maxBytes: number,
): Promise<string> {
if (!response.body) {
const text = await response.text();
if (Buffer.byteLength(text) > maxBytes) throw new TooLargeError();
return text;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let total = 0;
let text = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maxBytes) {
await reader.cancel().catch(() => {});
throw new TooLargeError();
}
text += decoder.decode(value, { stream: true });
}
return text + decoder.decode();
}
function discard(response: { body?: { cancel(): Promise<void> } | null }): void {
try {
response.body?.cancel().catch(() => {});
} catch {
// schon verbraucht — nichts zu tun
}
}
export interface FetchStatusOptions {
fetchImpl?: typeof undiciFetch;
timeoutMs?: number;
}
/**
* Fragt `<baseUrl>/status.php` ab und fasst das Ergebnis zusammen.
*
* SSRF-Hinweis (L-03, wie bei Proxmox T-DHH-02): die Adresse wird vom
* Verwalter eingegeben, interne Adressen sind mit Absicht erlaubt (Clouds
* stehen oft im Haus). Begrenzungen: nur GET auf `<Adresse>/status.php`,
* keine Zugangsdaten oder Cookies, hoechstens 3 Weiterleitungen (nur
* http/https), 10 Sekunden fuer die gesamte Anfrage, 64 KiB Antwort. Zum
* Browser gelangen nur die ausgewerteten, auf eine feste Liste beschraenkten
* Felder — nie ein Antworttext, und als Fehlerdetail nur eigene Kurzkennungen
* (z. B. 'HTTP 503', 'ENOTFOUND'). Wer Verwalten darf, kann dadurch
* erfahren, ob eine interne Adresse antwortet — bewusst akzeptiert.
* Zertifikate werden geprueft (kein Abschalten, D-H).
*/
export async function fetchNextcloudStatus(
baseUrl: string,
opts: FetchStatusOptions = {},
): Promise<NextcloudCheckResult> {
const fetchImpl = opts.fetchImpl ?? undiciFetch;
const timeoutMs = opts.timeoutMs ?? STATUS_TIMEOUT_MS;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
// Fuer haengende Server, die ein abgebrochenes fetch/read nicht beenden.
const aborted = new Promise<'timeout'>((resolve) => {
controller.signal.addEventListener('abort', () => resolve('timeout'));
});
const run = async (): Promise<NextcloudCheckResult> => {
let current = `${baseUrl}/status.php`;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
let response: Awaited<ReturnType<typeof undiciFetch>>;
try {
response = await fetchImpl(current, {
method: 'GET',
redirect: 'manual',
signal: controller.signal,
headers: { Accept: 'application/json', 'User-Agent': 'Tessera-Nextcloud-Status' },
});
} catch (err) {
if (controller.signal.aborted) return failure('timeout', null);
const code = errorCode(err);
if (code && TLS_CODES.has(code)) return failure('tls', code);
return failure('network', code);
}
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get('location');
discard(response);
if (!location) return failure('redirect', 'Keine Weiterleitungsadresse');
let next: URL;
try {
next = new URL(location, current);
} catch {
return failure('redirect', 'Ungueltige Weiterleitung');
}
if (next.protocol !== 'http:' && next.protocol !== 'https:') {
return failure('redirect', 'Weiterleitung nicht http/https');
}
if (hop === MAX_REDIRECTS) return failure('redirect', 'Zu viele Weiterleitungen');
current = next.toString();
continue;
}
if (!response.ok) {
discard(response);
return failure('http-status', `HTTP ${response.status}`);
}
let text: string;
try {
text = await readCapped(response as never, MAX_STATUS_BYTES);
} catch (err) {
if (err instanceof TooLargeError) return failure('too-large', null);
if (controller.signal.aborted) return failure('timeout', null);
return failure('network', errorCode(err));
}
const parsed = parseNextcloudStatus(text);
if (!parsed) return failure('not-nextcloud', null);
return {
reachable: true,
maintenance: parsed.maintenance,
needsDbUpgrade: parsed.needsDbUpgrade,
versionString: parsed.versionString,
edition: parsed.edition,
productName: parsed.productName,
errorKind: null,
errorDetail: null,
};
}
return failure('redirect', 'Zu viele Weiterleitungen');
};
try {
const outcome = await Promise.race([run(), aborted]);
return outcome === 'timeout' ? failure('timeout', null) : outcome;
} finally {
clearTimeout(timer);
}
}
@@ -0,0 +1,205 @@
import { describe, expect, it, vi } from 'vitest';
import { RETRY_DELAY_MS } from './nextcloud-alert-rules';
import {
NEXTCLOUD_CRON,
NEXTCLOUD_JOB_NAME,
NEXTCLOUD_RETRY_CRON,
NEXTCLOUD_RETRY_JOB_NAME,
NextcloudStatusSchedulerService,
} from './nextcloud-status-scheduler.service';
function makeFakeRegistry() {
const jobs = new Map<string, any>();
return {
__jobs: jobs,
addCronJob: vi.fn((name: string, job: any) => {
if (jobs.has(name)) throw new Error(`Cron Job with the given name (${name}) already exists.`);
jobs.set(name, job);
}),
};
}
function makeScheduler(rows: { id: string; tenantId: string }[] = [], failFor: string[] = []) {
const registry = makeFakeRegistry();
const calls: Array<[string, string]> = [];
const service = {
loadAllInstancesForScheduler: vi.fn(async () => rows),
checkInstance: vi.fn(async (tenantId: string, id: string) => {
calls.push([tenantId, id]);
if (failFor.includes(id)) throw new Error(`boom ${id}`);
}),
};
const release = { refresh: vi.fn(async () => undefined) };
const scheduler = new NextcloudStatusSchedulerService(
registry as any,
service as any,
release as any,
);
const logger = (scheduler as any).logger;
const logSpy = vi.spyOn(logger, 'log').mockImplementation(() => undefined);
const errorSpy = vi.spyOn(logger, 'error').mockImplementation(() => undefined);
const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined);
return { registry, service, release, scheduler, calls, logSpy, errorSpy, warnSpy };
}
describe('NextcloudStatusSchedulerService', () => {
it('registriert den stuendlichen Auftrag und die Wiederholung ohne Datenbankzugriff und startet beide', async () => {
const { registry, service, release, scheduler, logSpy } = makeScheduler();
await scheduler.onApplicationBootstrap();
expect(registry.addCronJob).toHaveBeenCalledTimes(2);
expect(registry.__jobs.has(NEXTCLOUD_RETRY_JOB_NAME)).toBe(true);
expect(NEXTCLOUD_RETRY_JOB_NAME).toBe('nextcloud-status-retry');
expect(NEXTCLOUD_RETRY_CRON).toBe('* * * * *');
const retryJob = registry.__jobs.get(NEXTCLOUD_RETRY_JOB_NAME);
expect(retryJob.cronTime.source).toBe(NEXTCLOUD_RETRY_CRON);
expect(retryJob.isActive ?? retryJob.running).toBeTruthy();
expect(
logSpy.mock.calls.some((c) =>
String(c[0]).includes('Nextcloud-Status retry job registered: * * * * *'),
),
).toBe(true);
retryJob.stop();
expect(registry.__jobs.has(NEXTCLOUD_JOB_NAME)).toBe(true);
const job = registry.__jobs.get(NEXTCLOUD_JOB_NAME);
expect(job.cronTime.source).toBe(NEXTCLOUD_CRON);
expect(NEXTCLOUD_CRON).toBe('0 * * * *');
expect(job.isActive ?? job.running).toBeTruthy();
expect(service.loadAllInstancesForScheduler).not.toHaveBeenCalled();
expect(release.refresh).toHaveBeenCalledTimes(1);
job.stop();
});
it('ein Fehler beim Start wird protokolliert und nicht weitergeworfen', async () => {
const { registry, scheduler, errorSpy } = makeScheduler();
registry.addCronJob.mockImplementation(() => {
throw new Error('registry kaputt');
});
await expect(scheduler.onApplicationBootstrap()).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalled();
});
it('tick prueft jede Cloud an ihren eigenen Mandanten gebunden', async () => {
const rows = [
{ id: 'a1', tenantId: 'tA' },
{ id: 'b1', tenantId: 'tB' },
{ id: 'a2', tenantId: 'tA' },
];
const { scheduler, calls } = makeScheduler(rows);
await scheduler.tick();
expect(calls).toHaveLength(3);
expect(calls).toContainEqual(['tA', 'a1']);
expect(calls).toContainEqual(['tA', 'a2']);
expect(calls).toContainEqual(['tB', 'b1']);
});
it('eine fehlerhafte Cloud stoppt die anderen nicht', async () => {
const rows = [
{ id: 'x', tenantId: 't' },
{ id: 'y', tenantId: 't' },
{ id: 'z', tenantId: 't' },
];
const { scheduler, calls, errorSpy } = makeScheduler(rows, ['x']);
await scheduler.tick();
expect(calls.map((c) => c[1]).sort()).toEqual(['x', 'y', 'z']);
expect(errorSpy).toHaveBeenCalledTimes(1);
});
it('ein Durchlauf, waehrend der vorige noch laeuft, wird uebersprungen', async () => {
const { scheduler, service, warnSpy } = makeScheduler([{ id: 'a', tenantId: 't' }]);
let release!: () => void;
service.checkInstance.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
release = resolve;
}),
);
const first = scheduler.tick();
await vi.waitFor(() => expect(service.checkInstance).toHaveBeenCalledTimes(1));
await scheduler.tick();
expect(service.loadAllInstancesForScheduler).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalled();
release();
await first;
await scheduler.tick();
expect(service.loadAllInstancesForScheduler).toHaveBeenCalledTimes(2);
});
describe('retryTick', () => {
const NOW = new Date('2026-10-02T12:10:00Z');
it('laedt nur Clouds mit Fehlschlag aelter als fuenf Minuten und prueft sie gebunden', async () => {
const rows = [
{ id: 'a', tenantId: 'tA' },
{ id: 'b', tenantId: 'tB' },
];
const { scheduler, service, calls } = makeScheduler(rows);
await scheduler.retryTick(NOW);
expect(service.loadAllInstancesForScheduler).toHaveBeenCalledWith({
retryDueBefore: new Date(NOW.getTime() - RETRY_DELAY_MS),
});
expect(RETRY_DELAY_MS).toBe(5 * 60 * 1000);
expect(calls).toEqual(
expect.arrayContaining([
['tA', 'a'],
['tB', 'b'],
]),
);
expect(calls).toHaveLength(2);
});
it('prueft hoechstens vier gleichzeitig', async () => {
const rows = Array.from({ length: 10 }, (_, i) => ({ id: `c${i}`, tenantId: 't' }));
const { scheduler, service } = makeScheduler(rows);
let inFlight = 0;
let peak = 0;
service.checkInstance.mockImplementation(async () => {
inFlight++;
peak = Math.max(peak, inFlight);
await new Promise((r) => setTimeout(r, 5));
inFlight--;
});
await scheduler.retryTick(NOW);
expect(service.checkInstance).toHaveBeenCalledTimes(10);
expect(peak).toBeLessThanOrEqual(4);
expect(peak).toBeGreaterThan(1);
});
it('ueberspringt, solange der vorige Durchlauf laeuft; der stuendliche Durchlauf blockiert ihn nicht', async () => {
const { scheduler, service, warnSpy } = makeScheduler([{ id: 'a', tenantId: 't' }]);
let release!: () => void;
service.checkInstance.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
release = resolve;
}),
);
const first = scheduler.retryTick(NOW);
await vi.waitFor(() => expect(service.checkInstance).toHaveBeenCalledTimes(1));
await scheduler.retryTick(NOW);
expect(service.loadAllInstancesForScheduler).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalled();
// der stuendliche Durchlauf hat einen eigenen Schutz
await scheduler.tick();
expect(service.loadAllInstancesForScheduler).toHaveBeenCalledTimes(2);
release();
await first;
});
it('wirft nie: ein Fehler beim Laden und ein Fehler je Cloud werden protokolliert', async () => {
const { scheduler, service, errorSpy, calls } = makeScheduler(
[
{ id: 'x', tenantId: 't' },
{ id: 'y', tenantId: 't' },
],
['x'],
);
await expect(scheduler.retryTick(NOW)).resolves.toBeUndefined();
expect(calls.map((c) => c[1]).sort()).toEqual(['x', 'y']);
expect(errorSpy).toHaveBeenCalledTimes(1);
service.loadAllInstancesForScheduler.mockRejectedValueOnce(new Error('db weg'));
await expect(scheduler.retryTick(NOW)).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledTimes(2);
});
});
});
@@ -0,0 +1,164 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { RETRY_DELAY_MS } from './nextcloud-alert-rules';
import { NextcloudReleaseService } from './nextcloud-release.service';
import {
CHECK_CONCURRENCY,
NextcloudStatusService,
runWithConcurrency,
} from './nextcloud-status.service';
/**
* CronJob constructor — resolved at runtime via require() because `cron` is
* a transitive dependency of @nestjs/schedule (not a direct api dep under
* pnpm strict isolation, so `import { CronJob } from 'cron'` fails
* type-check). At runtime, cron IS on disk as @nestjs/schedule@6 declares
* it as a peer dep. Reuses the exact ProxmoxSchedulerService resolution
* workaround verbatim.
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports
const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): void } =
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
require('cron').CronJob as new (
cronTime: string,
onTick: () => void,
) => { start(): void };
/** Name des Auftrags in der Registry. */
export const NEXTCLOUD_JOB_NAME = 'nextcloud-status-poll';
/** Jede volle Stunde (L-08). */
export const NEXTCLOUD_CRON = '0 * * * *';
/** Name des Wiederholungsauftrags (quick-261002-kxc, L-03). */
export const NEXTCLOUD_RETRY_JOB_NAME = 'nextcloud-status-retry';
/** Jede Minute: prueft nur Clouds, deren erster Fehlschlag fuenf Minuten zurueckliegt. */
export const NEXTCLOUD_RETRY_CRON = '* * * * *';
/**
* NextcloudStatusSchedulerService — stuendliche Pruefung aller Clouds
* (quick-261002-k67, L-08, D-C).
*
* Lebenszyklus: `OnApplicationBootstrap`, NICHT `OnModuleInit` — die
* Reihenfolge der `onModuleInit`-Haken zwischen Modulen ist nicht
* festgelegt, und die Erfahrung "frische Datenbank ingestiert nichts bis zum
* zweiten Neustart" (Tender-Cron-Bootstrap) gilt hier genauso.
*
* Ein einziger globaler Auftrag statt je einem je Mandant: das Intervall ist
* fest (stuendlich), es gibt keine Einstellung je Zeile. Der Auftrag wird
* beim Start OHNE Datenbankzugriff registriert — eine frische Datenbank kann
* daher nie ohne Auftrag enden, auch wenn beim Start noch keine Cloud
* eingetragen ist. Jeder Durchlauf liest Kennung und Mandant aller Clouds
* (ein einziger Systemkontext-Aufruf) und prueft dann jede Cloud an ihren
* eigenen Mandanten gebunden, mit hoechstens vier gleichzeitig. Ein
* Ueberlappungsschutz ueberspringt einen Durchlauf, solange der vorige laeuft.
*/
@Injectable()
export class NextcloudStatusSchedulerService implements OnApplicationBootstrap {
private readonly logger = new Logger(NextcloudStatusSchedulerService.name);
private running = false;
private retryRunning = false;
constructor(
private readonly schedulerRegistry: SchedulerRegistry,
private readonly service: NextcloudStatusService,
private readonly release: NextcloudReleaseService,
) {}
/**
* Registriert und startet den Auftrag und stoesst das Aufwaermen der
* Vergleichsdaten an (ohne zu warten). Ein Fehler wird gefangen und
* protokolliert, nie weitergeworfen — die Anwendung startet trotzdem.
*/
async onApplicationBootstrap(): Promise<void> {
try {
const job = new CronJobClass(NEXTCLOUD_CRON, () => {
this.tick().catch((err) =>
this.logger.error(`Nextcloud poll tick failed: ${(err as Error).message}`),
);
});
// Cast noetig — dasselbe Muster wie ProxmoxSchedulerService.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: Cast wie in ProxmoxSchedulerService
this.schedulerRegistry.addCronJob(NEXTCLOUD_JOB_NAME, job as any);
job.start();
this.logger.log(`Nextcloud-Status cron job registered: ${NEXTCLOUD_CRON}`);
const retryJob = new CronJobClass(NEXTCLOUD_RETRY_CRON, () => {
this.retryTick().catch((err) =>
this.logger.error(`Nextcloud retry tick failed: ${(err as Error).message}`),
);
});
// biome-ignore lint/suspicious/noExplicitAny: Cast wie in ProxmoxSchedulerService
this.schedulerRegistry.addCronJob(NEXTCLOUD_RETRY_JOB_NAME, retryJob as any);
retryJob.start();
this.logger.log(`Nextcloud-Status retry job registered: ${NEXTCLOUD_RETRY_CRON}`);
void this.release.refresh().catch(() => undefined);
} catch (err) {
this.logger.error(`Nextcloud-Status scheduler init failed: ${(err as Error).message}`);
}
}
/** Ein Durchlauf ueber alle Clouds aller Mandanten. */
async tick(): Promise<void> {
if (this.running) {
this.logger.warn('Nextcloud poll tick skipped — previous run still active');
return;
}
this.running = true;
try {
await this.checkRows(await this.service.loadAllInstancesForScheduler());
} finally {
this.running = false;
}
}
/**
* Wiederholung: nur Clouds mit genau einem Fehlschlag, der mindestens
* `RETRY_DELAY_MS` zurueckliegt. Wirft nie; ein laufender Durchlauf haelt
* den naechsten an.
*/
async retryTick(now: Date = new Date()): Promise<void> {
if (this.retryRunning) {
this.logger.warn('Nextcloud retry tick skipped — previous run still active');
return;
}
this.retryRunning = true;
try {
const rows = await this.service.loadAllInstancesForScheduler({
retryDueBefore: new Date(now.getTime() - RETRY_DELAY_MS),
});
await this.checkRows(rows);
} catch (err) {
this.logger.error(`Nextcloud retry tick failed: ${(err as Error).message}`);
} finally {
this.retryRunning = false;
}
}
/** Prueft jede Cloud an ihren eigenen Mandanten gebunden, hoechstens vier gleichzeitig. */
private async checkRows(rows: { id: string; tenantId: string }[]): Promise<void> {
// Je Mandant gruppiert, damit jede Pruefung an IHREN Mandanten gebunden bleibt.
const byTenant = new Map<string, string[]>();
for (const row of rows) {
const ids = byTenant.get(row.tenantId) ?? [];
ids.push(row.id);
byTenant.set(row.tenantId, ids);
}
const work: { tenantId: string; id: string }[] = [];
for (const [tenantId, ids] of byTenant) {
for (const id of ids) work.push({ tenantId, id });
}
if (work.length === 0) return;
const startedAt = Date.now();
await runWithConcurrency(work, CHECK_CONCURRENCY, async ({ tenantId, id }) => {
try {
await this.service.checkInstance(tenantId, id);
} catch (err) {
this.logger.error(
`Nextcloud check failed for instance ${id} (tenant ${tenantId}): ${(err as Error).message}`,
);
}
});
this.logger.log(
`Nextcloud-Pruefdurchlauf: ${work.length} Cloud(s) in ${Date.now() - startedAt} ms`,
);
}
}
@@ -0,0 +1,111 @@
import 'reflect-metadata';
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY, ModuleGuard } from '../module-registry/module.guard';
import { NextcloudStatusController } from './nextcloud-status.controller';
const proto = NextcloudStatusController.prototype as unknown as Record<string, unknown>;
describe('NextcloudStatusController Metadaten', () => {
it('Klasse traegt Modul-Slug und ModuleGuard', () => {
expect(Reflect.getMetadata(MODULE_SLUG_KEY, NextcloudStatusController)).toBe(
'nextcloud-status',
);
expect(Reflect.getMetadata(GUARDS_METADATA, NextcloudStatusController)).toContain(ModuleGuard);
});
it.each([
'list',
'logo',
'subscribe',
'unsubscribe',
'recentAlerts',
])('%s bleibt auf Benutzen-Ebene', (name) => {
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name] as object)).toBeUndefined();
expect(Reflect.getMetadata(ROLES_KEY, proto[name] as object)).toBeUndefined();
});
it.each([
'create',
'update',
'remove',
'checkAll',
'checkOne',
'uploadLogo',
'removeLogo',
])('%s verlangt Verwalten ohne Rollen-Decorator', (name) => {
const fn = proto[name] as object;
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBe(true);
expect(Reflect.getMetadata(MODULE_SLUG_KEY, fn)).toBe('nextcloud-status');
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
});
it('die statische Route POST instances/check steht vor jedem Handler mit :id (kein 404-Shadowing)', () => {
const names = Object.getOwnPropertyNames(NextcloudStatusController.prototype).filter(
(n) => n !== 'constructor' && typeof proto[n] === 'function',
);
const pathOf = (n: string) => Reflect.getMetadata('path', proto[n] as object) as string;
expect(pathOf('checkAll')).toBe('instances/check');
const checkIndex = names.indexOf('checkAll');
const idHandlers = names.filter((n) => (pathOf(n) ?? '').includes(':id'));
expect(idHandlers.length).toBeGreaterThan(0);
for (const name of idHandlers) {
expect(checkIndex, `checkAll vor ${name}`).toBeLessThan(names.indexOf(name));
}
});
});
describe('NextcloudStatusController Glocke (quick-261002-kxc)', () => {
const pathOf = (n: string) => Reflect.getMetadata('path', proto[n] as object) as string;
const methodOf = (n: string) => Reflect.getMetadata('method', proto[n] as object) as number;
it('POST und DELETE instances/:id/subscription', () => {
expect(pathOf('subscribe')).toBe('instances/:id/subscription');
expect(pathOf('unsubscribe')).toBe('instances/:id/subscription');
// RequestMethod.POST = 1, DELETE = 3
expect(methodOf('subscribe')).toBe(1);
expect(methodOf('unsubscribe')).toBe(3);
});
it('Benutzer kommt aus dem Token, Mandant aus der Anfrage — nie aus Body oder Pfad', async () => {
const service = {
listForTenant: vi.fn().mockResolvedValue({ instances: [] }),
checkAllForTenant: vi.fn().mockResolvedValue({ instances: [] }),
};
const alerts = {
subscribe: vi.fn().mockResolvedValue({ subscribed: true }),
unsubscribe: vi.fn().mockResolvedValue({ subscribed: false }),
};
const controller = new NextcloudStatusController(service as never, alerts as never);
const req = { tenantId: 't1', body: { userId: 'fremd', tenantId: 'fremd' } } as never;
const user = { id: 'u1' } as never;
expect(await controller.subscribe(req, user, 'i1')).toEqual({ subscribed: true });
expect(alerts.subscribe).toHaveBeenCalledWith('t1', 'u1', 'i1');
expect(await controller.unsubscribe(req, user, 'i1')).toEqual({ subscribed: false });
expect(alerts.unsubscribe).toHaveBeenCalledWith('t1', 'u1', 'i1');
await controller.list(req, user);
expect(service.listForTenant).toHaveBeenCalledWith('t1', 'u1');
await controller.checkAll(req, user);
expect(service.checkAllForTenant).toHaveBeenCalledWith('t1', 'u1');
});
it('GET alerts: Benutzer aus dem Token, Mandant aus der Anfrage', async () => {
expect(pathOf('recentAlerts')).toBe('alerts');
expect(methodOf('recentAlerts')).toBe(0); // RequestMethod.GET
const alerts = { listRecentAlerts: vi.fn().mockResolvedValue({ alerts: [] }) };
const controller = new NextcloudStatusController({} as never, alerts as never);
const req = { tenantId: 't1', query: { userId: 'fremd' } } as never;
expect(await controller.recentAlerts(req, { id: 'u1' } as never)).toEqual({ alerts: [] });
expect(alerts.listRecentAlerts).toHaveBeenCalledWith('t1', 'u1');
});
it('ohne Mandantenkontext -> 403', async () => {
const controller = new NextcloudStatusController({} as never, {} as never);
await expect(controller.subscribe({} as never, { id: 'u1' } as never, 'i1')).rejects.toThrow(
'Kein Mandantenkontext',
);
});
});
@@ -0,0 +1,175 @@
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
Param,
Post,
Put,
Req,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedRequest, AuthUser, UploadedFileLike } from '../auth/types/auth-user';
import { ModuleManage, UseModule } from '../module-registry/module.guard';
import {
CreateNextcloudInstanceDto,
UpdateNextcloudInstanceDto,
} from './dto/nextcloud-instance.dto';
import { NextcloudAlertService } from './nextcloud-alert.service';
import { NEXTCLOUD_LOGO_MAX_BYTES } from './nextcloud-logo-rules';
import { NextcloudStatusService } from './nextcloud-status.service';
/**
* `@UseModule('nextcloud-status')` auf Klassenebene — Aktivierung UND
* Freigabe (Vorbild `proxmox.controller.ts`). `tenantId` kommt ausschliesslich
* aus `req.tenantId` (gesetzt vom `TenantGuard`), nie aus Body oder Query.
*
* Rechte (L-09): Lesen (`GET instances`, `GET instances/:id/logo`) steht jedem
* Benutzer mit Modulzugriff offen; jede Schreib- und Pruefroute zusaetzlich
* `@ModuleManage('nextcloud-status')` — Administratoren und Benutzer mit der
* Freigabestufe Verwalten. Auf Verwalten-Handlern steht NIE ein
* Rollen-Decorator, der globale RolesGuard wuerde Verwalter sonst aussperren.
*
* Routenreihenfolge: statische Pfade (`instances/check`) stehen VOR allen
* Pfaden mit `:id`, sonst faengt die Parameterroute sie ab (404-Shadowing,
* T-k67-08; die Reihenfolge ist im Controller-Spec festgeschrieben).
*/
@Controller('modules/nextcloud-status')
@UseModule('nextcloud-status')
export class NextcloudStatusController {
constructor(
private readonly service: NextcloudStatusService,
private readonly alerts: NextcloudAlertService,
) {}
private requireTenantId(req: AuthenticatedRequest): string {
const tenantId = req.tenantId;
if (!tenantId) {
throw new ForbiddenException('Kein Mandantenkontext');
}
return tenantId;
}
@Get('instances')
async list(@Req() req: AuthenticatedRequest, @CurrentUser() user: AuthUser) {
return this.service.listForTenant(this.requireTenantId(req), user.id);
}
/**
* Letzte Uebergaenge der Clouds mit eingeschalteter Glocke des Aufrufers
* (Meldung in Tessera, quick-261002-kxc, L-04). Nur Klassen-`@UseModule`,
* kein Verwalten, keine Rolle; der Benutzer kommt aus dem Token.
*/
@Get('alerts')
async recentAlerts(@Req() req: AuthenticatedRequest, @CurrentUser() user: AuthUser) {
return this.alerts.listRecentAlerts(this.requireTenantId(req), user.id);
}
@Post('instances')
@ModuleManage('nextcloud-status')
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateNextcloudInstanceDto) {
return this.service.createInstance(this.requireTenantId(req), dto);
}
/** "Jetzt prüfen" fuer die ganze Liste — statisch, steht vor allen `:id`-Routen. */
@Post('instances/check')
@ModuleManage('nextcloud-status')
async checkAll(@Req() req: AuthenticatedRequest, @CurrentUser() user: AuthUser) {
return this.service.checkAllForTenant(this.requireTenantId(req), user.id);
}
@Put('instances/:id')
@ModuleManage('nextcloud-status')
async update(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Body() dto: UpdateNextcloudInstanceDto,
) {
return this.service.updateInstance(this.requireTenantId(req), id, dto);
}
@Delete('instances/:id')
@ModuleManage('nextcloud-status')
async remove(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
const deleted = await this.service.deleteInstance(this.requireTenantId(req), id);
return { deleted };
}
@Post('instances/:id/check')
@ModuleManage('nextcloud-status')
async checkOne(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.service.checkInstance(this.requireTenantId(req), id);
}
/**
* Glocke "Benachrichtigen" einschalten (quick-261002-kxc, L-01, T-kxc-01,
* T-kxc-08): fuer jeden Benutzer mit Modulzugriff, bewusst OHNE
* `@ModuleManage` und ohne Rollen-Decorator. Der Benutzer kommt aus dem
* JWT, nie aus dem Body; die Kennung der Cloud wird im Dienst gegen den
* Mandanten geprueft (404 sonst).
*/
@Post('instances/:id/subscription')
async subscribe(
@Req() req: AuthenticatedRequest,
@CurrentUser() user: AuthUser,
@Param('id') id: string,
) {
return this.alerts.subscribe(this.requireTenantId(req), user.id, id);
}
/** Glocke ausschalten — wie `subscribe`, nur die eigene Zeile. */
@Delete('instances/:id/subscription')
async unsubscribe(
@Req() req: AuthenticatedRequest,
@CurrentUser() user: AuthUser,
@Param('id') id: string,
) {
return this.alerts.unsubscribe(this.requireTenantId(req), user.id, id);
}
/**
* Logo-Abruf fuer jeden Benutzer mit Modulzugriff (die Kachel laedt es per
* <img>). Typ aus dem gespeicherten, per Magic Bytes erkannten Wert; private
* Zwischenspeicherung (Adresse traegt clientseitig `?v=<logoVersion>`),
* `nosniff` und eine Sandbox-CSP — Muster `favorites.controller.ts` `getIcon`
* (T-k67-02).
*/
@Get('instances/:id/logo')
async logo(@Req() req: AuthenticatedRequest, @Param('id') id: string, @Res() res: Response) {
const { data, mime } = await this.service.getLogo(this.requireTenantId(req), id);
res.setHeader('Content-Type', mime);
res.setHeader('Cache-Control', 'private, max-age=86400');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
res.send(data);
}
/**
* Logo hochladen: Groessengrenze JE ROUTE (multers `LIMIT_FILE_SIZE` wird von
* Nest auf 413 abgebildet); Typ und Besitz pruefen im Dienst.
*/
@Post('instances/:id/logo')
@ModuleManage('nextcloud-status')
@UseInterceptors(
FileInterceptor('logo', { limits: { fileSize: NEXTCLOUD_LOGO_MAX_BYTES, files: 1 } }),
)
async uploadLogo(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@UploadedFile() file?: UploadedFileLike,
) {
return this.service.uploadLogo(this.requireTenantId(req), id, file);
}
@Delete('instances/:id/logo')
@ModuleManage('nextcloud-status')
async removeLogo(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.service.removeLogo(this.requireTenantId(req), id);
}
}
@@ -0,0 +1,40 @@
import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { MailModule } from '../mail/mail.module';
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
import { ModuleRegistryService } from '../module-registry/module-registry.service';
import { SettingsModule } from '../settings/settings.module';
import { NextcloudAlertService } from './nextcloud-alert.service';
import { NextcloudReleaseService } from './nextcloud-release.service';
import { NextcloudStatusController } from './nextcloud-status.controller';
import { seedNextcloudStatusModule } from './nextcloud-status.seed';
import { NextcloudStatusService } from './nextcloud-status.service';
import { NextcloudStatusSchedulerService } from './nextcloud-status-scheduler.service';
/**
* NestJS module for the Nextcloud-Status feature (quick-261002-k67).
* Vorbild `ProxmoxModule`: seeds itself into the module registry on startup.
*/
@Module({
imports: [ModuleRegistryModule, MailModule, SettingsModule],
controllers: [NextcloudStatusController],
providers: [
NextcloudStatusService,
NextcloudReleaseService,
NextcloudAlertService,
NextcloudStatusSchedulerService,
],
})
export class NextcloudStatusModule implements OnModuleInit {
private readonly logger = new Logger(NextcloudStatusModule.name);
constructor(private readonly moduleRegistryService: ModuleRegistryService) {}
async onModuleInit(): Promise<void> {
try {
await seedNextcloudStatusModule(this.moduleRegistryService);
this.logger.log('Nextcloud-Status module seeded in registry');
} catch (error) {
this.logger.error('Failed to seed nextcloud-status module', error);
}
}
}
@@ -0,0 +1,23 @@
import { ModuleRegistryService } from '../module-registry/module-registry.service';
/**
* Seeds the nextcloud-status module into the module registry
* (quick-261002-k67). Vorbild `proxmox.seed.ts`; Kategorie
* `infrastructure`. Der Slug ist zugleich der Wert in `@UseModule` und in
* `WIDGET_MODULE_SLUGS` (@tessera/shared).
*/
export async function seedNextcloudStatusModule(
moduleRegistryService: ModuleRegistryService,
): Promise<void> {
await moduleRegistryService.seedModule({
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,
});
}
@@ -0,0 +1,487 @@
import { BadRequestException, 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),
}));
vi.mock('./nextcloud-status-fetch', async (importOriginal) => {
const actual = await importOriginal<typeof import('./nextcloud-status-fetch')>();
return { ...actual, fetchNextcloudStatus: vi.fn() };
});
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import type { NextcloudReference } from './nextcloud-rating';
import { NextcloudStatusService, PUBLIC_SELECT } from './nextcloud-status.service';
import { fetchNextcloudStatus } from './nextcloud-status-fetch';
const REFERENCE: NextcloudReference = {
fetchedAt: '2026-10-02T10:00:00.000Z',
cycles: [{ cycle: 35, eol: '2099-09-30', latest: '35.0.1' }],
};
function makeRow(over: Record<string, unknown> = {}) {
return {
id: 'i1',
tenantId: 't1',
customerName: 'Kunde A',
baseUrl: 'https://cloud.a.de',
logoUrl: null,
logoMime: null,
logoVersion: 0,
lastCheckedAt: new Date('2026-10-02T11:00:00Z'),
reachable: true,
maintenance: false,
needsDbUpgrade: false,
versionString: '35.0.1',
edition: null,
errorKind: null,
errorDetail: null,
...over,
};
}
describe('NextcloudStatusService', () => {
let prisma: { nextcloudInstance: Record<string, ReturnType<typeof vi.fn>> };
let release: { getReference: ReturnType<typeof vi.fn> };
let alerts: {
subscribedInstanceIds: ReturnType<typeof vi.fn>;
evaluateAfterCheck: ReturnType<typeof vi.fn>;
};
let service: NextcloudStatusService;
beforeEach(() => {
vi.mocked(fetchNextcloudStatus).mockReset();
vi.mocked(forTenant).mockClear();
prisma = {
nextcloudInstance: {
findMany: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
};
release = { getReference: vi.fn().mockResolvedValue(REFERENCE) };
alerts = {
subscribedInstanceIds: vi.fn().mockResolvedValue(new Set<string>()),
evaluateAfterCheck: vi.fn().mockResolvedValue({ kind: null, delivery: null }),
};
service = new NextcloudStatusService(prisma as never, release as never, alerts as never);
});
it('listForTenant waehlt keine Logo-Bytes und liefert Bewertung und neueste Version', async () => {
prisma.nextcloudInstance.findMany.mockResolvedValue([
makeRow(),
makeRow({ id: 'i2', logoMime: 'image/png', logoVersion: 3 }),
]);
const result = await service.listForTenant('t1', 'u1');
const args = prisma.nextcloudInstance.findMany.mock.calls[0][0];
expect(args.select).toBe(PUBLIC_SELECT);
expect(args.select).not.toHaveProperty('logoData');
expect(args.where).toEqual({ tenantId: 't1' });
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
expect(result.reference).toEqual({ newestVersion: '35.0.1', fetchedAt: REFERENCE.fetchedAt });
expect(result.instances).toHaveLength(2);
expect(result.instances[0].rating).toMatchObject({ level: 'green', reason: 'current' });
expect(result.instances[0].hasUploadedLogo).toBe(false);
expect(result.instances[1]).toMatchObject({ hasUploadedLogo: true, logoVersion: 3 });
expect(JSON.stringify(result)).not.toContain('logoData');
});
it('listForTenant ohne Vergleichsdaten bewertet grau', async () => {
release.getReference.mockResolvedValue(null);
prisma.nextcloudInstance.findMany.mockResolvedValue([makeRow()]);
const result = await service.listForTenant('t1', 'u1');
expect(result.reference).toEqual({ newestVersion: null, fetchedAt: null });
expect(result.instances[0].rating.level).toBe('unknown');
});
it('createInstance normalisiert die Adresse, legt an und prueft sofort', async () => {
prisma.nextcloudInstance.create.mockResolvedValue({ id: 'i1' });
prisma.nextcloudInstance.findFirst.mockResolvedValue({
id: 'i1',
baseUrl: 'https://cloud.a.de',
});
vi.mocked(fetchNextcloudStatus).mockResolvedValue({
reachable: true,
maintenance: false,
needsDbUpgrade: false,
versionString: '35.0.1',
edition: null,
productName: 'Nextcloud',
errorKind: null,
errorDetail: null,
});
prisma.nextcloudInstance.update.mockResolvedValue(makeRow());
const view = await service.createInstance('t1', {
customerName: ' Kunde A ',
baseUrl: 'https://cloud.a.de/status.php/',
});
expect(prisma.nextcloudInstance.create.mock.calls[0][0].data).toEqual({
tenantId: 't1',
customerName: 'Kunde A',
baseUrl: 'https://cloud.a.de',
logoUrl: null,
});
expect(fetchNextcloudStatus).toHaveBeenCalledWith('https://cloud.a.de');
expect(view.id).toBe('i1');
});
it('createInstance mit ungueltiger Adresse -> BadRequest mit deutscher Meldung', async () => {
await expect(
service.createInstance('t1', { customerName: 'X', baseUrl: 'ftp://x' }),
).rejects.toThrow(BadRequestException);
await expect(
service.createInstance('t1', { customerName: 'X', baseUrl: 'ftp://x' }),
).rejects.toThrow('Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.');
expect(prisma.nextcloudInstance.create).not.toHaveBeenCalled();
});
const FAILED_RESULT = {
reachable: false,
maintenance: null,
needsDbUpgrade: null,
versionString: null,
edition: null,
productName: null,
errorKind: 'http-status' as const,
errorDetail: 'HTTP 503',
};
it('checkInstance: zweiter Fehlschlag schreibt alle Statusspalten und den Zaehler 2', async () => {
prisma.nextcloudInstance.findFirst.mockResolvedValue({
id: 'i1',
baseUrl: 'https://cloud.a.de',
consecutiveFailures: 1,
});
vi.mocked(fetchNextcloudStatus).mockResolvedValue(FAILED_RESULT);
prisma.nextcloudInstance.update.mockResolvedValue(
makeRow({
reachable: false,
versionString: null,
errorKind: 'http-status',
errorDetail: 'HTTP 503',
alertState: 'ok',
}),
);
const view = await service.checkInstance('t1', 'i1');
expect(prisma.nextcloudInstance.findFirst.mock.calls[0][0].where).toEqual({
id: 'i1',
tenantId: 't1',
});
const args = prisma.nextcloudInstance.update.mock.calls[0][0];
expect(args.data).toMatchObject({
reachable: false,
maintenance: null,
needsDbUpgrade: null,
versionString: null,
edition: null,
errorKind: 'http-status',
errorDetail: 'HTTP 503',
consecutiveFailures: 2,
});
expect(args.data.lastCheckedAt).toBeInstanceOf(Date);
expect(args.select).toMatchObject({ ...PUBLIC_SELECT, alertState: true });
expect(args.select).not.toHaveProperty('logoData');
expect(view.rating).toMatchObject({ level: 'red', reason: 'unreachable' });
// Die Meldung wird nach jeder Pruefung entschieden, mit dem Stand der Zeile
expect(alerts.evaluateAfterCheck).toHaveBeenCalledTimes(1);
const [tenant, row, rating] = alerts.evaluateAfterCheck.mock.calls[0];
expect(tenant).toBe('t1');
expect(row).toMatchObject({ id: 'i1', alertState: 'ok', errorKind: 'http-status' });
expect(rating).toMatchObject({ level: 'red', reason: 'unreachable' });
});
it('checkInstance: erster Fehlschlag schreibt nur Zaehler und Zeitpunkt, die Kachel behaelt den guten Stand', async () => {
prisma.nextcloudInstance.findFirst.mockResolvedValue({
id: 'i1',
baseUrl: 'https://cloud.a.de',
consecutiveFailures: 0,
});
vi.mocked(fetchNextcloudStatus).mockResolvedValue(FAILED_RESULT);
// Zeile bleibt im Zustand "gruen", nur der Zaehler steht auf 1
prisma.nextcloudInstance.update.mockResolvedValue(makeRow({ alertState: 'ok' }));
const view = await service.checkInstance('t1', 'i1');
const data = prisma.nextcloudInstance.update.mock.calls[0][0].data;
expect(Object.keys(data).sort()).toEqual(['consecutiveFailures', 'firstFailureAt']);
expect(data.consecutiveFailures).toBe(1);
expect(view.rating).toMatchObject({ level: 'green', reason: 'current' });
});
it('checkInstance: eine Stoerung der Meldung verwirft das Pruefergebnis nicht', async () => {
prisma.nextcloudInstance.findFirst.mockResolvedValue({
id: 'i1',
baseUrl: 'https://cloud.a.de',
consecutiveFailures: 0,
});
vi.mocked(fetchNextcloudStatus).mockResolvedValue({
...FAILED_RESULT,
reachable: true,
} as never);
prisma.nextcloudInstance.update.mockResolvedValue(makeRow({ alertState: 'ok' }));
alerts.evaluateAfterCheck.mockRejectedValue(new Error('db weg'));
vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
const view = await service.checkInstance('t1', 'i1');
expect(view.id).toBe('i1');
});
it('listForTenant liefert subscribed je Cloud nur fuer die Abonnements des Benutzers', async () => {
prisma.nextcloudInstance.findMany.mockResolvedValue([makeRow(), makeRow({ id: 'i2' })]);
alerts.subscribedInstanceIds.mockResolvedValue(new Set(['i2']));
const result = await service.listForTenant('t1', 'u1');
expect(alerts.subscribedInstanceIds).toHaveBeenCalledWith('t1', 'u1');
expect(result.instances.map((i) => [i.id, i.subscribed])).toEqual([
['i1', false],
['i2', true],
]);
});
it('checkInstance fuer unbekannte Kennung -> NotFound, kein Abruf', async () => {
prisma.nextcloudInstance.findFirst.mockResolvedValue(null);
await expect(service.checkInstance('t1', 'nix')).rejects.toThrow(NotFoundException);
expect(fetchNextcloudStatus).not.toHaveBeenCalled();
});
describe('Schreibwege (Aufgabe 2)', () => {
const OK_RESULT = {
reachable: true,
maintenance: false,
needsDbUpgrade: false,
versionString: '35.0.1',
edition: null,
productName: null,
errorKind: null,
errorDetail: null,
};
beforeEach(() => {
prisma.nextcloudInstance.delete = vi.fn();
prisma.nextcloudInstance.findFirst.mockResolvedValue({
id: 'i1',
baseUrl: 'https://cloud.a.de',
});
prisma.nextcloudInstance.update.mockResolvedValue(makeRow());
vi.mocked(fetchNextcloudStatus).mockResolvedValue(OK_RESULT);
});
it('updateInstance: nur Name -> kein Neuabruf', async () => {
await service.updateInstance('t1', 'i1', { customerName: ' Neu ' });
expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({
customerName: 'Neu',
});
expect(fetchNextcloudStatus).not.toHaveBeenCalled();
});
it('updateInstance: neue Adresse wird normalisiert, setzt den Pruefstand zurueck (alertState bleibt) und prueft neu, unveraenderte nicht', async () => {
await service.updateInstance('t1', 'i1', { baseUrl: 'https://neu.example.de/index.php/' });
const data = prisma.nextcloudInstance.update.mock.calls[0][0].data;
expect(data).toEqual({
baseUrl: 'https://neu.example.de',
reachable: null,
maintenance: null,
needsDbUpgrade: null,
versionString: null,
edition: null,
productName: null,
errorKind: null,
errorDetail: null,
lastCheckedAt: null,
consecutiveFailures: 0,
firstFailureAt: null,
});
expect(data).not.toHaveProperty('alertState');
expect(data).not.toHaveProperty('alertReason');
expect(fetchNextcloudStatus).toHaveBeenCalledTimes(1);
vi.mocked(fetchNextcloudStatus).mockClear();
await service.updateInstance('t1', 'i1', { baseUrl: 'https://cloud.a.de/' });
expect(fetchNextcloudStatus).not.toHaveBeenCalled();
});
it('updateInstance: rote Cloud, korrigierte Adresse antwortet gruen -> Meldung "wieder in Ordnung" wird entschieden', async () => {
prisma.nextcloudInstance.findFirst.mockResolvedValue({
id: 'i1',
baseUrl: 'https://kaputt.example.de',
consecutiveFailures: 0,
});
prisma.nextcloudInstance.update.mockResolvedValue(makeRow({ alertState: 'red' }));
await service.updateInstance('t1', 'i1', { baseUrl: 'https://cloud.a.de' });
expect(alerts.evaluateAfterCheck).toHaveBeenCalledTimes(1);
const [, row, rating] = alerts.evaluateAfterCheck.mock.calls[0];
expect(row.alertState).toBe('red');
expect(rating).toMatchObject({ level: 'green', reason: 'current' });
});
it('updateInstance: ungueltige Adresse -> BadRequest', async () => {
await expect(service.updateInstance('t1', 'i1', { baseUrl: 'javascript:1' })).rejects.toThrow(
BadRequestException,
);
});
it('updateInstance: nicht leere Logo-Adresse ersetzt den Upload, leere entfernt nur die Adresse', async () => {
await service.updateInstance('t1', 'i1', { logoUrl: 'https://logo.example.de/a.png' });
expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({
logoUrl: 'https://logo.example.de/a.png',
logoData: null,
logoMime: null,
logoVersion: { increment: 1 },
});
await service.updateInstance('t1', 'i1', { logoUrl: '' });
expect(prisma.nextcloudInstance.update.mock.calls[1][0].data).toEqual({
logoUrl: null,
logoVersion: { increment: 1 },
});
});
it('uploadLogo speichert Bytes und erkannten Typ, entfernt die Adresse und erhoeht die Version', async () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2]);
await service.uploadLogo('t1', 'i1', {
buffer: png,
originalname: 'x.exe',
mimetype: 'text/html',
size: png.length,
});
const data = prisma.nextcloudInstance.update.mock.calls[0][0].data;
expect(Buffer.from(data.logoData)).toEqual(png);
expect(data).toMatchObject({
logoMime: 'image/png',
logoUrl: null,
logoVersion: { increment: 1 },
});
expect(prisma.nextcloudInstance.update.mock.calls[0][0].select).toBe(PUBLIC_SELECT);
});
it('uploadLogo lehnt Nicht-Bilder, fehlende Dateien und fremde Kennungen ab', async () => {
const html = Buffer.from('<html></html>');
await expect(
service.uploadLogo('t1', 'i1', {
buffer: html,
originalname: 'a.png',
mimetype: 'image/png',
size: html.length,
}),
).rejects.toThrow(
'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.',
);
await expect(service.uploadLogo('t1', 'i1', undefined)).rejects.toThrow(BadRequestException);
prisma.nextcloudInstance.findFirst.mockResolvedValue(null);
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
await expect(
service.uploadLogo('t1', 'fremd', {
buffer: png,
originalname: 'a.png',
mimetype: 'image/png',
size: png.length,
}),
).rejects.toThrow(NotFoundException);
expect(prisma.nextcloudInstance.update).not.toHaveBeenCalled();
});
it('removeLogo loescht Bytes und Typ und erhoeht die Version', async () => {
await service.removeLogo('t1', 'i1');
expect(prisma.nextcloudInstance.update.mock.calls[0][0].data).toEqual({
logoData: null,
logoMime: null,
logoVersion: { increment: 1 },
});
});
it('getLogo liefert Bytes und Typ, ohne Logo NotFound', async () => {
prisma.nextcloudInstance.findFirst.mockResolvedValue({
logoData: new Uint8Array([1, 2, 3]),
logoMime: 'image/png',
});
const logo = await service.getLogo('t1', 'i1');
expect(logo.mime).toBe('image/png');
expect([...logo.data]).toEqual([1, 2, 3]);
expect(prisma.nextcloudInstance.findFirst.mock.calls[0][0].where).toEqual({
id: 'i1',
tenantId: 't1',
});
prisma.nextcloudInstance.findFirst.mockResolvedValue({ logoData: null, logoMime: null });
await expect(service.getLogo('t1', 'i1')).rejects.toThrow(NotFoundException);
prisma.nextcloudInstance.findFirst.mockResolvedValue(null);
await expect(service.getLogo('t1', 'x')).rejects.toThrow(NotFoundException);
});
it('deleteInstance loescht die Zeile; fremde Kennung -> NotFound', async () => {
expect(await service.deleteInstance('t1', 'i1')).toBe(true);
expect(prisma.nextcloudInstance.delete).toHaveBeenCalledWith({ where: { id: 'i1' } });
prisma.nextcloudInstance.findFirst.mockResolvedValue(null);
await expect(service.deleteInstance('t1', 'fremd')).rejects.toThrow(NotFoundException);
expect(prisma.nextcloudInstance.delete).toHaveBeenCalledTimes(1);
});
it('checkAllForTenant prueft alle mit hoechstens vier gleichzeitig und liefert die frische Liste', async () => {
const ids = Array.from({ length: 10 }, (_, i) => `i${i}`);
prisma.nextcloudInstance.findMany.mockImplementation(
async (args: { select: Record<string, boolean> }) =>
args.select.id && Object.keys(args.select).length === 1
? ids.map((id) => ({ id }))
: [makeRow()],
);
prisma.nextcloudInstance.findFirst.mockImplementation(
async ({ where }: { where: { id: string } }) => ({
id: where.id,
baseUrl: 'https://cloud.a.de',
}),
);
let inFlight = 0;
let peak = 0;
vi.mocked(fetchNextcloudStatus).mockImplementation(async () => {
inFlight++;
peak = Math.max(peak, inFlight);
await new Promise((r) => setTimeout(r, 5));
inFlight--;
return OK_RESULT;
});
const result = await service.checkAllForTenant('t1', 'u1');
expect(fetchNextcloudStatus).toHaveBeenCalledTimes(10);
expect(peak).toBeLessThanOrEqual(4);
expect(peak).toBeGreaterThan(1);
expect(result.instances).toHaveLength(1);
});
it('checkAllForTenant: eine fehlerhafte Cloud stoppt die anderen nicht', async () => {
prisma.nextcloudInstance.findMany.mockImplementation(
async (args: { select: Record<string, boolean> }) =>
Object.keys(args.select).length === 1 ? [{ id: 'a' }, { id: 'b' }] : [],
);
prisma.nextcloudInstance.findFirst.mockImplementation(
async ({ where }: { where: { id: string } }) =>
where.id === 'a' ? null : { id: where.id, baseUrl: 'https://cloud.a.de' },
);
await service.checkAllForTenant('t1', 'u1');
expect(fetchNextcloudStatus).toHaveBeenCalledTimes(1);
});
it('loadAllInstancesForScheduler waehlt nur Kennung und Mandant ueber forSystem', async () => {
prisma.nextcloudInstance.findMany.mockResolvedValue([{ id: 'i1', tenantId: 't1' }]);
const rows = await service.loadAllInstancesForScheduler();
expect(forSystem).toHaveBeenCalledWith(prisma);
expect(prisma.nextcloudInstance.findMany).toHaveBeenCalledWith({
select: { id: true, tenantId: true },
});
expect(rows).toEqual([{ id: 'i1', tenantId: 't1' }]);
});
it('loadAllInstancesForScheduler mit retryDueBefore filtert auf genau einen Fehlschlag, Select bleibt Kennung und Mandant', async () => {
prisma.nextcloudInstance.findMany.mockResolvedValue([{ id: 'i1', tenantId: 't1' }]);
const due = new Date('2026-10-02T12:00:00Z');
await service.loadAllInstancesForScheduler({ retryDueBefore: due });
expect(prisma.nextcloudInstance.findMany).toHaveBeenCalledWith({
where: { consecutiveFailures: 1, firstFailureAt: { lte: due } },
select: { id: true, tenantId: true },
});
});
it('pendingRetry ist genau bei einem Fehlschlag wahr', async () => {
prisma.nextcloudInstance.findMany.mockResolvedValue([
makeRow({ id: 'a', consecutiveFailures: 0 }),
makeRow({ id: 'b', consecutiveFailures: 1 }),
makeRow({ id: 'c', consecutiveFailures: 2 }),
]);
const result = await service.listForTenant('t1', 'u1');
expect(result.instances.map((i) => i.status.pendingRetry)).toEqual([false, true, false]);
});
});
});
@@ -0,0 +1,459 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { UploadedFileLike } from '../auth/types/auth-user';
import { PrismaService } from '../prisma/prisma.service';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import type {
CreateNextcloudInstanceDto,
UpdateNextcloudInstanceDto,
} from './dto/nextcloud-instance.dto';
import { NextcloudAlertService } from './nextcloud-alert.service';
import { planStatusWrite } from './nextcloud-alert-rules';
import { checkLogoUpload } from './nextcloud-logo-rules';
import {
type NextcloudRating,
type NextcloudReference,
newestVersion,
rateNextcloud,
} from './nextcloud-rating';
import { NextcloudReleaseService } from './nextcloud-release.service';
import { fetchNextcloudStatus, normalizeCloudUrl } from './nextcloud-status-fetch';
/**
* Alle Spalten ausser den Logo-Bytes (T-k67-07): Listen- und
* Pruefabfragen holen `logoData` nie aus der Datenbank, nur der
* Logo-Abruf. `logoMime` ist genau dann gesetzt, wenn ein Upload vorliegt.
*/
export const PUBLIC_SELECT = {
id: true,
tenantId: true,
customerName: true,
baseUrl: true,
logoUrl: true,
logoMime: true,
logoVersion: true,
lastCheckedAt: true,
reachable: true,
maintenance: true,
needsDbUpgrade: true,
versionString: true,
edition: true,
errorKind: true,
errorDetail: true,
consecutiveFailures: true,
} as const;
type PublicRow = {
id: string;
customerName: string;
baseUrl: string;
logoUrl: string | null;
logoMime: string | null;
logoVersion: number;
lastCheckedAt: Date | null;
reachable: boolean | null;
maintenance: boolean | null;
needsDbUpgrade: boolean | null;
versionString: string | null;
edition: string | null;
errorKind: string | null;
errorDetail: string | null;
consecutiveFailures: number;
};
export interface NextcloudInstanceView {
id: string;
customerName: string;
baseUrl: string;
logoUrl: string | null;
hasUploadedLogo: boolean;
logoVersion: number;
status: {
checkedAt: string | null;
reachable: boolean | null;
maintenance: boolean | null;
needsDbUpgrade: boolean | null;
versionString: string | null;
edition: string | null;
errorKind: string | null;
errorDetail: string | null;
/**
* Genau ein Fehlschlag in Folge: die Kachel zeigt den letzten guten Stand
* mit Hinweis, die Wiederholung folgt in wenigen Minuten (L-03).
*/
pendingRetry: boolean;
};
rating: NextcloudRating;
/**
* Glocke des anfragenden Benutzers (quick-261002-kxc, D-K10). Nur in den
* Listenantworten gesetzt; Einzelantworten lassen das Feld weg — die Seite
* behaelt dann den Stand der Kachel.
*/
subscribed?: boolean;
}
export interface NextcloudListView {
instances: NextcloudInstanceView[];
reference: { newestVersion: string | null; fetchedAt: string | null };
}
/** Hoechstzahl gleichzeitiger Pruefungen (Sammelpruefung und stuendlicher Durchlauf). */
export const CHECK_CONCURRENCY = 4;
/**
* Arbeitet `items` mit hoechstens `limit` gleichzeitig ab. `fn` darf werfen —
* der Aufrufer faengt je Eintrag selbst, ein Fehler stoppt die anderen nicht.
*/
export async function runWithConcurrency<T>(
items: T[],
limit: number,
fn: (item: T) => Promise<void>,
): Promise<void> {
let next = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (next < items.length) {
const item = items[next++];
await fn(item);
}
});
await Promise.all(workers);
}
const INVALID_URL_MESSAGE = 'Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.';
@Injectable()
export class NextcloudStatusService {
private readonly logger = new Logger(NextcloudStatusService.name);
constructor(
private readonly prisma: PrismaService,
private readonly release: NextcloudReleaseService,
private readonly alerts: NextcloudAlertService,
) {}
private toView(row: PublicRow, reference: NextcloudReference | null): NextcloudInstanceView {
const status = {
pendingRetry: row.consecutiveFailures === 1,
checkedAt: row.lastCheckedAt ? row.lastCheckedAt.toISOString() : null,
reachable: row.reachable,
maintenance: row.maintenance,
needsDbUpgrade: row.needsDbUpgrade,
versionString: row.versionString,
edition: row.edition,
errorKind: row.errorKind,
errorDetail: row.errorDetail,
};
return {
id: row.id,
customerName: row.customerName,
baseUrl: row.baseUrl,
logoUrl: row.logoUrl,
hasUploadedLogo: row.logoMime !== null,
logoVersion: row.logoVersion,
status,
rating: rateNextcloud(status, reference, new Date()),
};
}
/**
* Alle Clouds des Mandanten samt Bewertung zum Lesezeitpunkt (D-B) und dem
* Glockenstand des anfragenden Benutzers (D-K10).
*/
async listForTenant(tenantId: string, userId: string): Promise<NextcloudListView> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const [rows, reference, subscribed] = await Promise.all([
tenantPrisma.nextcloudInstance.findMany({
where: { tenantId },
orderBy: { customerName: 'asc' },
select: PUBLIC_SELECT,
}),
this.release.getReference(),
this.alerts.subscribedInstanceIds(tenantId, userId),
]);
return {
instances: rows.map((row) => ({
...this.toView(row as PublicRow, reference),
subscribed: subscribed.has((row as PublicRow).id),
})),
reference: {
newestVersion: newestVersion(reference),
fetchedAt: reference?.fetchedAt ?? null,
},
};
}
/** Legt eine Cloud an und fuehrt sofort die erste Pruefung aus. */
async createInstance(
tenantId: string,
dto: CreateNextcloudInstanceDto,
): Promise<NextcloudInstanceView> {
const baseUrl = normalizeCloudUrl(dto.baseUrl);
if (!baseUrl) throw new BadRequestException(INVALID_URL_MESSAGE);
const tenantPrisma = forTenant(this.prisma, tenantId);
const created = await tenantPrisma.nextcloudInstance.create({
data: {
tenantId,
customerName: dto.customerName.trim(),
baseUrl,
logoUrl: dto.logoUrl ? dto.logoUrl : null,
},
select: { id: true },
});
return this.checkInstance(tenantId, created.id);
}
/**
* Prueft eine Cloud (nur `status.php`, siehe `fetchNextcloudStatus`) und
* schreibt das Ergebnis an die Zeile. Fremde oder unbekannte Kennung: 404.
*
* Einziger Schreibweg fuer jede Pruefung (stuendlich, Wiederholung, "Jetzt
* pruefen", Anlegen, Adressaenderung). `planStatusWrite` setzt die
* Zwei-Fehlschlaege-Regel um (ein erster Fehlschlag laesst den gespeicherten
* Zustand unveraendert), danach entscheidet `evaluateAfterCheck` ueber eine
* Meldung — es wartet nur auf den Anspruch, nie auf den Mailversand.
*/
async checkInstance(tenantId: string, id: string): Promise<NextcloudInstanceView> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.nextcloudInstance.findFirst({
where: { id, tenantId },
select: { id: true, baseUrl: true, consecutiveFailures: true },
});
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
const startedAt = Date.now();
const result = await fetchNextcloudStatus(existing.baseUrl);
const now = new Date();
const plan = planStatusWrite(existing.consecutiveFailures, result, now);
const updated = await tenantPrisma.nextcloudInstance.update({
where: { id },
data: plan.data,
select: { ...PUBLIC_SELECT, alertState: true },
});
const view = this.toView(updated as PublicRow, await this.release.getReference());
const outcome = result.reachable
? `Version ${result.versionString ?? '?'}${result.maintenance ? ', Wartungsmodus' : ''}`
: `Fehler ${result.errorKind}${result.errorDetail ? ` (${result.errorDetail})` : ''}`;
this.logger.log(
`Pruefung "${updated.customerName}" ${existing.baseUrl}: ${outcome}, Ampel ${view.rating.level}, ${Date.now() - startedAt} ms`,
);
try {
await this.alerts.evaluateAfterCheck(
tenantId,
{
id,
customerName: updated.customerName,
baseUrl: updated.baseUrl,
errorKind: updated.errorKind,
errorDetail: updated.errorDetail,
alertState: updated.alertState,
},
view.rating,
now,
);
} catch (err) {
// Eine Stoerung der Meldung darf das Pruefergebnis nicht verwerfen.
this.logger.error(
`Nextcloud-Meldung nach Pruefung fehlgeschlagen (Cloud ${id}): ${(err as Error).message}`,
);
}
return view;
}
/**
* Aendert Name, Adresse und/oder Logo-Adresse. Eine neue Adresse wird
* normalisiert und sofort neu geprueft, eine unveraenderte nicht. Eine
* nicht leere Logo-Adresse ersetzt ein hochgeladenes Logo, eine leere
* entfernt nur die Adresse (D-A).
*/
async updateInstance(
tenantId: string,
id: string,
dto: UpdateNextcloudInstanceDto,
): Promise<NextcloudInstanceView> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.nextcloudInstance.findFirst({
where: { id, tenantId },
select: { id: true, baseUrl: true },
});
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
const data: Record<string, unknown> = {};
if (dto.customerName !== undefined) data.customerName = dto.customerName.trim();
let urlChanged = false;
if (dto.baseUrl !== undefined) {
const baseUrl = normalizeCloudUrl(dto.baseUrl);
if (!baseUrl) throw new BadRequestException(INVALID_URL_MESSAGE);
if (baseUrl !== existing.baseUrl) {
data.baseUrl = baseUrl;
urlChanged = true;
// Neue Adresse: der alte Pruefstand gilt nicht mehr (D-K8). `alertState`
// bleibt bewusst unberuehrt — wird eine kaputte Adresse korrigiert und
// antwortet die neue, geht "wieder in Ordnung" an die Abonnenten.
Object.assign(data, {
reachable: null,
maintenance: null,
needsDbUpgrade: null,
versionString: null,
edition: null,
productName: null,
errorKind: null,
errorDetail: null,
lastCheckedAt: null,
consecutiveFailures: 0,
firstFailureAt: null,
});
}
}
if (dto.logoUrl !== undefined) {
if (dto.logoUrl) {
data.logoUrl = dto.logoUrl;
data.logoData = null;
data.logoMime = null;
} else {
data.logoUrl = null;
}
data.logoVersion = { increment: 1 };
}
const updated = await tenantPrisma.nextcloudInstance.update({
where: { id },
data,
select: PUBLIC_SELECT,
});
if (urlChanged) return this.checkInstance(tenantId, id);
return this.toView(updated as PublicRow, await this.release.getReference());
}
/** Loescht eine Cloud. Fremde oder unbekannte Kennung: 404. */
async deleteInstance(tenantId: string, id: string): Promise<boolean> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.nextcloudInstance.findFirst({
where: { id, tenantId },
select: { id: true },
});
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
await tenantPrisma.nextcloudInstance.delete({ where: { id } });
return true;
}
/**
* Speichert ein hochgeladenes Logo. Der Typ kommt aus den Magic Bytes
* (`checkLogoUpload`), nie aus dem Mimetype des Browsers; eine vorhandene
* Logo-Adresse wird entfernt (Upload und Adresse schliessen sich aus).
*/
async uploadLogo(
tenantId: string,
id: string,
file: UploadedFileLike | undefined,
): Promise<NextcloudInstanceView> {
const mime = file ? checkLogoUpload(file.buffer) : null;
if (!file || !mime) {
throw new BadRequestException(
'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.',
);
}
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.nextcloudInstance.findFirst({
where: { id, tenantId },
select: { id: true },
});
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
const updated = await tenantPrisma.nextcloudInstance.update({
where: { id },
data: {
logoData: new Uint8Array(file.buffer),
logoMime: mime,
logoUrl: null,
logoVersion: { increment: 1 },
},
select: PUBLIC_SELECT,
});
return this.toView(updated as PublicRow, await this.release.getReference());
}
/** Liefert die Bytes des hochgeladenen Logos — die einzige Abfrage, die `logoData` auswaehlt. */
async getLogo(tenantId: string, id: string): Promise<{ data: Buffer; mime: string }> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const row = await tenantPrisma.nextcloudInstance.findFirst({
where: { id, tenantId },
select: { logoData: true, logoMime: true },
});
if (!row?.logoData || !row.logoMime) throw new NotFoundException('Kein Logo vorhanden');
return { data: Buffer.from(row.logoData), mime: row.logoMime };
}
/** Entfernt ein hochgeladenes Logo (die Kachel faellt auf Initialen zurueck). */
async removeLogo(tenantId: string, id: string): Promise<NextcloudInstanceView> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.nextcloudInstance.findFirst({
where: { id, tenantId },
select: { id: true },
});
if (!existing) throw new NotFoundException('Cloud nicht gefunden');
const updated = await tenantPrisma.nextcloudInstance.update({
where: { id },
data: { logoData: null, logoMime: null, logoVersion: { increment: 1 } },
select: PUBLIC_SELECT,
});
return this.toView(updated as PublicRow, await this.release.getReference());
}
/** Kennungen aller Clouds des Mandanten. */
async listInstanceIdsForTenant(tenantId: string): Promise<string[]> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const rows = await tenantPrisma.nextcloudInstance.findMany({
where: { tenantId },
select: { id: true },
});
return rows.map((r: { id: string }) => r.id);
}
/**
* "Jetzt pruefen" fuer die ganze Liste: alle Clouds des Mandanten mit
* hoechstens vier gleichzeitig, danach die frische Liste. Eine fehlerhafte
* Cloud stoppt die anderen nicht.
*/
async checkAllForTenant(tenantId: string, userId: string): Promise<NextcloudListView> {
const ids = await this.listInstanceIdsForTenant(tenantId);
await runWithConcurrency(ids, CHECK_CONCURRENCY, async (id) => {
try {
await this.checkInstance(tenantId, id);
} catch (err) {
this.logger.warn(
`Nextcloud-Pruefung fehlgeschlagen (Cloud ${id}): ${(err as Error).message}`,
);
}
});
return this.listForTenant(tenantId, userId);
}
/**
* Startpfad des stuendlichen Planers — der EINZIGE Systemkontext-Aufruf
* dieses Moduls (`FORSYSTEM_ALLOWED_CALL_SITES`, `rls-access-inventory.spec.ts`;
* Leserecht ueber `system_read_policy ... FOR SELECT` der Migration
* 20261002150000): nur Kennung und Mandant ALLER Clouds, nie Logo-Bytes oder
* Adressen. Geprueft und geschrieben wird danach je Cloud an ihren eigenen
* Mandanten gebunden (`checkInstance`). Mit `retryDueBefore` (Wiederholung
* nach dem ersten Fehlschlag, quick-261002-kxc) filtert dieselbe Abfrage auf
* Clouds mit genau einem Fehlschlag, der vor diesem Zeitpunkt lag.
*/
async loadAllInstancesForScheduler(filter?: {
retryDueBefore: Date;
}): Promise<{ id: string; tenantId: string }[]> {
const systemPrisma = forSystem(this.prisma);
return systemPrisma.nextcloudInstance.findMany({
// Wiederholungsauftrag (D-K3): nur Clouds mit genau einem Fehlschlag, dessen
// Zeitpunkt lange genug zurueckliegt — derselbe Systemlesezugriff, kein neuer.
...(filter
? {
where: {
consecutiveFailures: 1,
firstFailureAt: { lte: filter.retryDueBefore },
},
}
: {}),
select: { id: true, tenantId: true },
});
}
}

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