45 Commits

Author SHA1 Message Date
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
schalli 645c5e5887 docs(changelog): Version 1.9.1 abgeschlossen
Tessera CI/CD / Build & Publish Images (push) Successful in 3m47s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 12m17s
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m33s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 14:30:17 +02:00
schalli 2dd11b439d fix(favorites): Logo auch fuer Seiten, die es per JavaScript setzen
Tessera CI/CD / Lint & Type Check (push) Successful in 49s
Tessera CI/CD / Tests (push) Successful in 1m28s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 20s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m18s
hosteurope.de liefert im HTML nur einen leeren data:-Platzhalter, das
echte Symbol setzt erst JavaScript; /favicon.ico antwortet mit HTML. Die
Kachel zeigte deshalb nur den Buchstaben. Scheitert das gespeicherte
Symbol, fragt getIconBytes jetzt einmal den DuckDuckGo-Symboldienst -
nur fuer oeffentlich erreichbare Seiten, interne Hostnamen verlassen das
Haus nicht; kennt der Dienst nichts (404), bleibt es beim Buchstaben.
Wirkt auch fuer bestehende Favoriten.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 12:28:32 +02:00
schalli 59b8cd43fc fix(reminders): Cursor springt beim Schreiben nicht mehr in den Titel
Tessera CI/CD / Lint & Type Check (push) Successful in 47s
Tessera CI/CD / Tests (push) Successful in 1m24s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m56s
Der Fokus auf das Titelfeld lag im selben Effekt wie der Escape-Listener
mit Abhaengigkeit onClose. Die Kachel uebergibt onClose inline und
zeichnet alle 10 s neu (NOW_TICK_MS) - der Effekt lief jedes Mal erneut
und holte den Cursor aus der Beschreibung zurueck in den Titel. Fokus
jetzt nur beim Oeffnen, Escape ueber eine Ref.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:38:58 +02:00
schalli 5919a55cbf fix(desktop): Link-Klicks vor dem Opener-Skript des Clients abfangen
Tessera CI/CD / Lint & Type Check (push) Successful in 53s
Tessera CI/CD / Tests (push) Successful in 1m33s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m13s
Die erste Fassung lauschte auf window und lief damit nach dem von
tauri-plugin-opener eingeschleusten Link-Skript. Dieses ruft bei
target=_blank preventDefault und plugin:opener|open_url auf, was von der
Server-Seite aus nicht freigegeben ist - der Klick verpuffte weiter
(auf VM 8233 per Klick-Protokoll gemessen). Der Helfer lauscht jetzt auf
document: nach Reacts Handlern, vor dem Opener-Skript.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 10:20:42 +02:00
schalli 7edaf8c00b docs(quick-261001-cxo): Summary und STATE
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 09:20:49 +02:00
schalli 61a971ccc0 fix(desktop): Links mit target=_blank im Client ueber window.open oeffnen
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m36s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 20s
Tessera CI/CD / Build & Publish Images (push) Successful in 4m32s
Im Desktop-Client unter Windows tat ein Klick auf Favoriten und andere
Links mit target=_blank nichts, waehrend window.open (Such-Widget) ueber
on_new_window im System-Browser landete (VM 8233 nachgestellt).
DesktopExternalLinks leitet im Client Links- und Mittelklicks auf solche
http/https-Links auf window.open um; von der Seite verhinderte Klicks
(Favoriten im Bearbeiten-Modus) bleiben verhindert.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 09:20:11 +02:00
schalli 471cfbf98b wip: pausiert nach Freigabe 1.9.0
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m29s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m5s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 19:21:01 +02:00
schalli a257bc3f86 docs(changelog): Version 1.9.0 abgeschlossen
Tessera CI/CD / Build & Publish Images (push) Successful in 3m8s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m52s
Tessera CI/CD / Lint & Type Check (push) Successful in 49s
Tessera CI/CD / Tests (push) Successful in 1m22s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 18:54:25 +02:00
schalli 714f731ac9 feat(admin): Anmeldehinweise der Willkommensmail in der Vorlage bearbeitbar
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m29s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 4m14s
Neue Felder loginHintDirectory/loginHintLocal (Platzhalter, Pflicht, max. 1000),
Migration 20260930170000 (nullable, leer = Standard aus @tessera/shared).
Knopf Passwort festlegen und Gueltigkeitshinweis bleiben fest; local-no-link
wird nie erzeugt und bleibt fest. Vorschau springt beim Bearbeiten auf die
passende Kontoart. Lokal im Browser nachgewiesen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 17:56:05 +02:00
schalli e10da76259 feat(admin): eigene Vorlage fuer die Willkommensmail mit Platzhaltern, Vorschau und Testmail
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m22s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m10s
Administrator -> Willkommensmail: Betreff, Ueberschrift, Einleitung, Abschluss je
Mandant (Tabelle WelcomeMailTemplate, RLS je Mandant, Migration 20260930150000);
Platzhalter {{name}} {{vorname}} {{benutzername}} {{email}} {{adresse}} {{firma}},
unbekannte -> 400 bzw. Hinweis beim Tippen; Werte escaped, Vorlage reiner Text.
Live-Vorschau per API gerendert, Testmail an die eigene Adresse ohne Token,
Zuruecksetzen auf Standard. Feste Bausteine (Kopf, Zugangsdaten, Anmeldehinweis,
Knoepfe, Fusszeile) bleiben immer drin.
Kopf: Wellenzelle dunkel statt weiss, Streifen 600x40, Inhalt 24 px naeher –
keine weisse Luecke, wenn OWA das CID-Bild nicht zeigt.
Lokal nachgewiesen: Hinweis/Sperre bei {{xyz}}, Speichern, Testmail (Link nur
/login), echte Mail mit eigener Vorlage und 7-Tage-Link.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 16:49:17 +02:00
schalli 31d514b7ca fix(web): /login leitet angemeldete Benutzer aufs Dashboard (bzw. sicheres next)
Tessera CI/CD / Lint & Type Check (push) Successful in 53s
Tessera CI/CD / Tests (push) Successful in 1m30s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 20s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m28s
Nur bei gueltiger Signatur und ohne ausstehenden Kennwortwechsel; next ueber
sanitizeNextPath, /login als Ziel -> Dashboard. Gesperrte Konten: API lehnt
ab, Oberflaeche loescht das Cookie serverseitig, keine Schleife.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 16:30:44 +02:00
schalli 52f538c432 fix(mail): Willkommensmail-Kopf ohne Bildzwang – Logo als HTML, Welle als Streifen
Rueckmeldung des Nutzers: in Outlook nur ein grosser schwarzer Kasten, kein
Logo (das eingebettete Kopfbild wurde nicht angezeigt; Logo und Schriftzug
steckten nur darin). Jetzt Bildmarke aus Tabellenzellen und Schriftzug als
Text in einer niedrigen dunklen Leiste, darunter die Duenen-Welle als
600x56-Streifen, der ins Weiss auslaeuft; fehlt das Bild, bleibt nur Abstand.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 16:29:16 +02:00
schalli 32441d77c7 feat(users): Willkommensmail mit Wellen-Kopf und Logo, Spalte Letzte Anmeldung
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 1m18s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m11s
POST /users/:id/welcome-mail (gleiche Rechte wie Bearbeiten, jederzeit sendbar),
GET /users/welcome-mail/status; HTML-Mail (Tabellenlayout, Inline-Stile,
Kopfbild als CID-PNG aus assets/mail/welcome-header.svg, erzeugt mit
scripts/render-mail-header.mjs) plus Textfassung. Verzeichniskonten: Hinweis
auf Windows-Passwort; lokale Konten: Link Passwort festlegen (7 Tage, einmalig).
Neue Spalte User.welcomeMailSentAt (Migration 20260930120000). Benutzerliste:
Spalte Letzte Anmeldung, Zeilenaktionen als Symbole. Dockerfile kopiert
apps/api/assets. Lokal per MailHog nachgewiesen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 15:28:52 +02:00
schalli 0b34e82b21 fix(auth): Rolle, Aktiv-Status und Kennwort-Pflicht je Anfrage aus der Datenbank
JwtStrategy.validate las bisher alles aus dem 30-Tage-Token: ein herabgestufter
Administrator behielt seine Rechte bis zum Ablauf, ein deaktiviertes oder
geloeschtes Konto arbeitete mit seiner Sitzung weiter, und Oberflaeche (/auth/me
aus der DB) und API (Token) sahen verschiedene Rollen – die Benutzerliste
scheiterte nach einer Rollenaenderung (Befund des Nutzers auf alpha).
Jetzt ein gebundener PK-Lesezugriff je Anfrage (forTenant), 401 bei fehlendem,
deaktiviertem oder mandantenfremdem Konto. Lokal nachgewiesen: Herabstufen ->
sofort 403, Deaktivieren -> sofort 401.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 15:28:52 +02:00
schalli af78157536 docs(changelog): Version 1.8.0 abgeschlossen
Tessera CI/CD / Build & Publish Images (push) Successful in 3m2s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m21s
Tessera CI/CD / Lint & Type Check (push) Successful in 49s
Tessera CI/CD / Tests (push) Successful in 1m31s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 13:48:47 +02:00
schalli 7e70fc4d32 feat(custom-modules): besuchte eigene Module offen halten (Keep-Alive, hoechstens 5)
Tessera CI/CD / Lint & Type Check (push) Successful in 53s
Tessera CI/CD / Tests (push) Successful in 1m37s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 20s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m12s
iframes liegen dauerhaft in einem Behaelter im Portal-Rahmen und werden per
position: fixed deckungsgleich ueber den Platzhalter der Modulseite gelegt;
beim Wegnavigieren nur versteckt (visibility), nie neu eingehaengt. Name/Adresse
je Modul im Sitzungsspeicher, Nachladen im Hintergrund, 404 verwirft. Abmelden
und Loeschen leeren. Im Browser nachgewiesen: gleiches iframe-Element und kein
neuer Seitenabruf nach Dashboard -> Modul B -> Modul A; folgt Seitenleiste
ein-/ausgeklappt und Handybreite.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 12:02:50 +02:00
schalli f78b422abf fix(dashboard): nie scrollen – Inhalt hoeher als die Leinwand wird eingepasst
Tessera CI/CD / Lint & Type Check (push) Successful in 1m29s
Tessera CI/CD / Tests (push) Successful in 1m43s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 22s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m45s
Die Einpassung nimmt max(Leinwandhoehe, Rasterhoehe) (fitCanvasToContent).
Anlass: Dashboard des Nutzers 940 px hoch bei Leinwand 849 px, dadurch
scrollte es ueberall. Im Bearbeitungsmodus bleibt die Hoehe vom Beginn des
Bearbeitens stehen, damit der Massstab beim Ziehen nicht wandert.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 11:55:50 +02:00
schalli c846eb49cb style(favorites): Kachelansicht enger, lange Namen kleiner und zweizeilig
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m20s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m58s
Spaltenmindestbreite 76 -> 52 px (Abstand zwischen den Symbolen ~59 -> ~27 px
bei einer 373 px breiten Kachel). LauncherLabel misst den Titel einzeilig in
12 px; passt er nicht, 10 px, zweizeilig mit Silbentrennung und Auslassung.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 11:33:20 +02:00
schalli af87c2c112 feat(dashboard): auf jedem Bildschirm dasselbe Bild – Leinwand je Reiter, massstaeblich skaliert
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m20s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m54s
Je Reiter wird die Flaeche des ersten Desktop-Bildschirms als __canvas im
Layout-JSON gespeichert (ohne API/DB-Aenderung, GRID_VERSION bleibt 3).
Das Raster rendert in Leinwandbreite und wird per transform: scale(min(bw/cw, bh/ch))
eingepasst, Schrift eingeschlossen, ohne Scrollen; unter 768 px wie bisher.
Ziehen/Groesse aendern unter Skalierung ueber eine eigene Positionsstrategie
(createScaledStrategy aus react-grid-layout 2.2.3 rechnet den Rasterversatz falsch).
Im Browser nachgewiesen: 1920x1080 -> 1366x768 (Faktor 0,66), Ziehen +200 px
folgt der Maus, 700 px ohne Skalierung.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 11:19:27 +02:00
schalli bd73fa5e74 docs(proxmox): Lesezugang anlegen – API-Token fuer PVE/PBS, eigener Auditor-Benutzer fuer PMG
PMG kennt keine API-Token (Proxmox-Bugzilla 5849, offen); Schritt-fuer-Schritt
fuer Oberflaeche und Kommandozeile, Rolle an Benutzer UND Token.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 11:12:13 +02:00
schalli 86ad95f74e docs: Windows-Test bestanden, Review-Fixes seit 26.09., Uebergabe verbraucht
Tessera CI/CD / Lint & Type Check (push) Successful in 46s
Tessera CI/CD / Tests (push) Successful in 1m19s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m30s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m12s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:23:59 +02:00
schalli 12214a948e fix(dashboard): Rasterversion schuetzen, Kalender-Mindestbreite, Breiten je Breakpoint, Hintergrund
- Layout mit neuerer Rasterversion wird nie gespeichert, Bearbeiten gesperrt mit Hinweis
- Kalender minW 11 (~260 px bei lg), Breiten je Breakpoint auf Spaltenzahl begrenzt
- optimistische Ruecksetzung nur, wenn noch der gesetzte Wert steht
- Titel-Schalter mit fester Beschriftung + aria-pressed
- Hintergrund-Dialog: Fokus rein/zurueck, Tab bleibt im Dialog
- Loeschen eines Bildes setzt eine darauf zeigende Hintergrund-Wahl zurueck
- Uebersetzungen und CHANGELOG

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:20:16 +02:00
schalli 071082983b fix(desktop,favorites): keine zweite Update-Installation, Lesegrenzen bei der Symbolsuche
- Desktop: Merker "Installation laeuft" sperrt Pruefschleife und Klick; ein angebotenes Update bleibt nach fehlgeschlagener Pruefung per Klick installierbar
- Desktop: Benachrichtigungsrecht erst nach erfolgreichem add_capability vermerken
- Favoriten: HTML nur bis MAX_HTML_CHARS und hoechstens 4 s lesen, Nicht-HTML-Antworten verwerfen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:20:16 +02:00
schalli c2e4467dd8 fix(reminders): keine Dauermeldung ohne localStorage, Mail nach Zeitaenderung, null-Pruefung
- Merkliste zusaetzlich im Arbeitsspeicher (sonst alle 10 s dieselbe Meldung)
- Aendern der Faelligkeit atomar gegen gleichzeitiges Faelligwerden, setzt Mail-Spur zurueck
- UpdateReminderDto lehnt null ab (400 statt 500)
- keine Mails an deaktivierte Benutzer
- Spaeter erinnern beschriftet heute/morgen nach dem berechneten Zeitpunkt
- Bearbeiten schickt dueAt nur bei geaenderter Zeit

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:20:16 +02:00
schalli be1e0035e0 fix(custom-modules): null beim Aendern ablehnen, Ladefehler statt 404, Fehlertexte, Seitenleiste eingeklappt
- PATCH mit null fuer name/url/category ergibt 400 statt 500
- Modulansicht unterscheidet Ladefehler von "nicht gefunden"
- Formular/Loeschdialog nennen 403 und 400 eigens
- eingeklappte Seitenleiste folgt der Gruppenreihenfolge der ausgeklappten
- neue Eintraege sind mit "Eigene Module" vorbelegt
- Verwaltung zeigt bei Ladefehler nicht zusaetzlich "keine Eintraege"

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 03:20:16 +02:00
schalli f7213f5e45 wip: pausiert nach 1.7.0-Folgearbeiten, Windows-Test offen
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:49:15 +02:00
214 changed files with 20017 additions and 1389 deletions
+45
View File
@@ -0,0 +1,45 @@
---
context: default
phase: quick-auftraege-1.9.x (keine GSD-Phase)
task: 0
total_tasks: 0
status: paused
last_updated: 2026-10-02T08:00:00.000Z
---
# BLOCKING CONSTRAINTS — Read Before Anything Else
- [ ] CONSTRAINT: live NICHT pushen/taggen, bis der User es verlangt.
- [ ] CONSTRAINT: Gebündelt pushen, nicht nach jeder Kleinigkeit.
- [ ] CONSTRAINT: Browser-Prüfungen im Dunkelmodus.
- [ ] CONSTRAINT: Echte Kundenzertifikate/Schlüssel nie ins Repo, lokale Kopien nach dem Test löschen.
<current_state>
main = live = v1.9.2 (2d6caec). Tag gepusht; CI-Läufe 481/482/483 grün, Release Tessera 1.9.2 mit Setup.exe + AppImage. Arbeitsbaum sauber.
</current_state>
<completed_work>
- 01.10.: Desktop-Favoriten öffnen im System-Browser (261001-cxo), Erinnerung-Cursor (261001-g68), Favoriten-Logo-Rückfall (261001-hbi); Freigabe 1.9.1, live gezogen.
- 01./02.10.: Zertifikat-Manager Reiter „Übersicht“ (261001-l4q): Paket/ZIP hochladen, Teile erkennen und zuordnen, jedes Teil in jedem Format; Desktop-Client speichert blob:/data:-Downloads selbst (VM gegen alpha nachgewiesen); geschützte PFX nur ruhiger Hinweis.
- Kalender-Test gehärtet (CI-Flake).
- 02.10.: Freigabe 1.9.2.
</completed_work>
<remaining_work>
- User: live auf 1.9.2 ziehen (df -h / vorher), alpha auf 2d6caec.
- Nächster Chat: NEUES MODUL (Auftrag kommt vom User).
</remaining_work>
<decisions_made>
- 1.9.2 statt 1.10.0 – ausdrücklicher Wunsch des Users.
- Logo-Rückfall DuckDuckGo nur für öffentliche Hosts.
- Aussteller-PFX-Passwort unbekannt und unnötig → ruhiger Hinweis.
</decisions_made>
<context>
VM 8233: Client 1.9.1 Stand c1b2654 an alpha; Aussteller-ZIP auf dem VM-Desktop. alpha-Admin admin / admin1234.
</context>
<next_action>
Neuen Chat abwarten: User bringt ein neues Modul. Fragen, ob live auf 1.9.2 gezogen ist.
</next_action>
+11 -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-09-23
last_activity: 2026-10-02
last_activity_desc: Quick 260928-ujj — Design Mosaik uebernommen, Hintergrund pro Benutzer in der DB; Freigabe 1.5.0
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-29 - Quick 260929-if2 Erinnerungen-Widget (lokal, nicht gepusht); v1.7.0 auf alpha+live
Last activity: 2026-10-02 - Quick 261002-icv: Freigabestufe Verwalten (lokal, nicht gepusht); 261002-fm5 Finanzbuchhaltung gepusht (CI 484 gruen)
Progress: [██████████] 99%
@@ -483,6 +483,12 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 260929-dzu | **Eigene Module fuer jeden Benutzer (persoenlich).** `CustomModule.ownerUserId` (null = gemeinsam), RLS-Muster SearchProvider, Einstellungen > Eigene Module (nur eigene), Verwaltung nur gemeinsame; Browser: Sichtbarkeit/Rechte wie verlangt. Nebenbei ohne eigenen Quick: Zentrierung entfernt (bc4c011), Desktop neue Fenster -> System-Browser (76a9234, Windows-VM bestaetigt), Single-Instance auf VM bestaetigt. | 2026-09-29 | c703d87,ee97b4e,8f41bd2 | [260929-dzu-eigene-module-fuer-jeden-benutzer-persoe](./quick/260929-dzu-eigene-module-fuer-jeden-benutzer-persoe/) |
| 260929-if2 | **Erinnerungen-Widget (Reminder).** Modell `Reminder` + RLS, API /reminders (anlegen/listen/bearbeiten/loeschen/erledigt/snooze, 409/404-Regeln), E-Mail-Scheduler alle 30 s mit Claim-once + max. 3 Versuche, globaler ReminderNotifier (Browser-Notification, Desktop via Tauri-Notification mit Laufzeit-Capability nur fuer die Server-Origin, Pattern escaped + vorab geprueft). Verifier human_needed (Windows-Toast offen); Browser dunkel bestanden inkl. echter Mail ueber MailHog. api 1570, web 1069, cargo 57. Nebenbei: eigene Module ohne Kopfzeile (cd1f8f6), Update-Klick prueft frisch (41d00a3). | 2026-09-29 | 325c5dd,709b41a,6879c75 | [260929-if2-reminder-widget-mit-benachrichtigung](./quick/260929-if2-reminder-widget-mit-benachrichtigung/) |
| 260929-lh3 | **Favoriten: eigene Symbol-Adresse wirkt.** Neue iconUrl ersetzt Upload + bumpt iconVersion; iconUrl wird auch gespeichert, wenn nur der Browser sie laden kann (kein 422 mehr, nur Formpruefung); Kachel: Proxy -> iconUrl direkt -> origin/favicon -> Buchstabe; Discovery liest <link rel=icon> auch aus Nicht-2xx-Seiten (docuvita 400). | 2026-09-29 | 7188c5b,b15c746,0e72ad4 | [260929-lh3-favoriten-eigenes-symbol-wirkt-nicht](./quick/260929-lh3-favoriten-eigenes-symbol-wirkt-nicht/) |
| 261001-cxo | Desktop-Client: Links mit target=_blank (Favoriten) oeffnen jetzt im System-Browser (DesktopExternalLinks -> window.open) | 2026-10-01 | 61a971c | [261001-cxo](./quick/261001-cxo-desktop-client-links-mit-target-blank-oe/) |
| 261001-g68 | Erinnerung: Cursor sprang beim Schreiben der Beschreibung in den Titel (Fokus-Effekt hing an inline onClose, Kachel zeichnet alle 10 s neu) – Fokus nur beim Oeffnen | 2026-10-01 | siehe git log | [261001-g68](./quick/261001-g68-erinnerung-cursor-springt-aus-beschreibu/) |
| 261001-hbi | Favoriten: Logo fuer per JavaScript gesetzte Symbole (hosteurope.de) – Rueckfall auf DuckDuckGo-Symboldienst beim Ausliefern, nur oeffentliche Seiten | 2026-10-01 | siehe git log | [261001-hbi](./quick/261001-hbi-favoriten-logo-fuer-per-javascript-geset/) |
| 261001-l4q | Zertifikat-Manager: Reiter Übersicht (Paket/ZIP hochladen, Teile erkennen/zuordnen, jedes Teil in jedem Format) + Desktop speichert blob-Downloads selbst | 2026-10-01 | siehe git log | [261001-l4q](./quick/261001-l4q-zertifikatsmodul-paket-hochladen-uebersi/) |
| 261002-fm5 | Finanzbuchhaltung: Module Kantinenabrechnung und Handelsware (DATEV-Export), im Browser nachgewiesen | 2026-10-02 | 1f85277..HEAD | [261002-fm5-finanzbuchhaltung-module-kantinenabrechn](.planning/quick/261002-fm5-finanzbuchhaltung-module-kantinenabrechn/) |
| 261002-icv | Modul-Freigabe mit Stufe Verwalten (Modul-Einstellungen ohne Admin; Kantine, Handelsware, Proxmox, DKV), im Browser nachgewiesen | 2026-10-02 | a222711..HEAD | [261002-icv-modul-freigabe-mit-stufe-verwalten-modul](.planning/quick/261002-icv-modul-freigabe-mit-stufe-verwalten-modul/) |
## Deferred Items
@@ -524,8 +530,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,17 @@
---
quick_id: 261001-cxo
description: "Desktop-Client: Links mit target=_blank oeffnen"
date: 2026-10-01
---
# Desktop-Client: Links mit target=_blank oeffnen
**Befund (VM 8233, Client 1.9.0 gegen alpha):** Klick auf Favorit (`<a target="_blank">`) tut nichts; Such-Widget (`window.open`) oeffnet Edge ueber `on_new_window` (lib.rs). Der Rust-Weg funktioniert also, nur der Link-Klick erreicht ihn nicht.
## Task 1 — DesktopExternalLinks
- `apps/web/src/components/desktop/desktop-external-links.tsx`: im Desktop-Client (Cookie `tessera_desktop`) Links-/Mittelklick auf `a[href][target=_blank]` mit http/https per `window.open(href,'_blank','noopener,noreferrer')` oeffnen, `preventDefault`. Listener auf `window` (Bubble, nach React) -> von der Seite verhinderte Klicks (Favoriten im Bearbeiten-Modus) bleiben verhindert.
- In `apps/web/src/app/layout.tsx` neben `DesktopContextMenuGuard` einhaengen.
- Test `desktop-external-links.test.tsx`.
- CHANGELOG „Unveröffentlicht → Behoben“.
**Verify:** vitest gruen, tsc, biome; nach alpha-Pull auf VM 8233: Favorit oeffnet Edge.
@@ -0,0 +1,19 @@
---
quick_id: 261001-cxo
status: complete
date: 2026-10-01
commit: 61a971c
---
# Summary: Desktop-Client – Links mit target=_blank
- Nachgestellt auf VM 8233 (Client 1.9.0, alpha): Favorit-Klick ohne Wirkung, Such-Widget (`window.open`) oeffnet Edge.
- Neu `DesktopExternalLinks` (apps/web/src/components/desktop/desktop-external-links.tsx), in `app/layout.tsx` eingehaengt: im Client Links-/Mittelklick auf `a[target=_blank]` mit http/https -> `window.open(href,'_blank','noopener,noreferrer')`; verhinderte Klicks bleiben verhindert.
- 4 Tests (desktop-external-links.test.tsx), tsc + biome sauber. CHANGELOG „Unveröffentlicht → Behoben“.
- Reine Web-Aenderung: kein neuer Client noetig, wirkt nach Pull des web-Images.
- Offen: Nachweis auf VM nach alpha-Pull (User).
## Nachtrag (gleicher Tag): erste Fassung wirkte nicht
- Nach alpha-Pull weiter ohne Wirkung. Diagnose per temporaerem Klick-Protokoll (lokaler Stack, VM-Client per portproxy auf localhost:3000): `preventDefault` kam aus `<anonymous>:1:442` = Link-Skript von tauri-plugin-opener (init-iife.js, Listener auf `window`): faengt `target=_blank`-Klicks ab und ruft `plugin:opener|open_url` – von der Server-Seite nicht freigegeben, Klick verpufft. Unser Listener auf `window` lief danach und sah den Klick als verhindert.
- Fix: Listener auf `document` (Bubble) – nach React (Wurzel document), vor dem Opener-Skript. Auf VM nachgewiesen: Favorit oeffnet Edge, im Bearbeiten-Modus nichts (React-onClick verhindert).
- Commit siehe git log; Test „kommt dem Link-Skript des Clients auf window zuvor“.
@@ -0,0 +1,14 @@
---
quick_id: 261001-g68
description: "Erinnerung: Cursor springt aus Beschreibung in Titel"
date: 2026-10-01
---
# Erinnerung: Cursor springt aus Beschreibung in Titel
**Befund:** `ReminderFormModal` setzte den Fokus auf den Titel im selben Effekt wie den Escape-Listener, Abhaengigkeit `[onClose]`. `onClose` ist in der Kachel eine Inline-Funktion; die Kachel zeichnet alle 10 s neu (NOW_TICK_MS) und bei jedem Neuladen → Effekt laeuft erneut → Cursor springt in den Titel (User: beim Schreiben, und bei Loeschen-Taste in leerer Beschreibung).
## Task 1
- Fokus-Effekt nur beim Oeffnen (`[]`), Escape-Listener ueber `onCloseRef`.
- Test im Widget: Formular oeffnen, Beschreibung fokussieren, 30 s Takt → Fokus bleibt.
- CHANGELOG „Unveröffentlicht → Behoben“.
@@ -0,0 +1,10 @@
---
quick_id: 261001-g68
status: complete
date: 2026-10-01
---
# Summary
- `reminder-form-modal.tsx`: Fokus auf Titel nur einmal beim Oeffnen; Escape ueber Ref statt `[onClose]`-Abhaengigkeit.
- Neuer Test in `reminder-widget.test.tsx` – schlaegt ohne Fix fehl, mit Fix gruen; 28/28 Erinnerungs-Tests, tsc, biome sauber.
- Andere Dialoge mit `[onClose]`-Fokus (Widget-Katalog, Bilderrahmen-Lightbox) fokussieren nur den Dialog ohne Eingabefelder – nicht betroffen.
@@ -0,0 +1,14 @@
---
quick_id: 261001-hbi
description: "Favoriten: Logo fuer per JavaScript gesetzte Symbole"
date: 2026-10-01
---
# Favoriten: Logo fuer per JavaScript gesetzte Symbole
**Befund:** https://www.hosteurope.de/ liefert im HTML nur `<link rel="icon" href="data:;base64,=">`; das echte Symbol (img1.wsimg.com/.../HostEurope.png) setzt erst JavaScript. `/favicon.ico`, `/apple-touch-icon.png`, `/favicon.svg` antworten 200 mit text/html. Die serverseitige Suche faellt auf `/favicon.ico` zurueck, der Abruf scheitert (kein Bild) → Buchstabe.
## Task 1
- `IconDiscoveryService.fetchPublicServiceIconBytes(pageUrl)`: DuckDuckGo-Symboldienst (`icons.duckduckgo.com/ip3/<host>.ico`), NUR wenn die Seite oeffentlich ist (isPublicHttpUrl) — interne Hostnamen verlassen das Haus nicht; 404 fuer Unbekanntes → wirft → Buchstabe bleibt.
- `FavoritesService.getIconBytes`: scheitert das gespeicherte Symbol, einmal den Dienst fragen, sonst 502 wie bisher. Repariert auch bestehende Favoriten ohne Neuanlage.
- Tests in beiden Specs; CHANGELOG.
@@ -0,0 +1,10 @@
---
quick_id: 261001-hbi
status: complete
date: 2026-10-01
---
# Summary
- Rueckfall auf den oeffentlichen Symbol-Dienst beim Ausliefern (`getIconBytes`), nur fuer oeffentliche Seiten.
- 5 neue Tests (Dienst-URL, interne Seite fragt nicht, 404 wirft, Service nutzt Rueckfall / nicht bei Erfolg); 111/111 Favoriten-Tests, tsc, biome-Stand unveraendert.
- Lokal im Browser nachgewiesen: Favorit https://www.hosteurope.de/ zeigt das gruene H-Logo (32x32 ueber /api-proxy/favorites/<id>/icon).
@@ -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.
+59
View File
@@ -6,14 +6,73 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
### Neu
- Neue Gruppe „Finanzbuchhaltung“ in der Seitenleiste mit zwei Modulen. Beide aktiviert ein Administrator im Marktplatz; wer sie nutzen soll, bekommt zusätzlich die Freigabe.
- Kantinenabrechnung: Die CSV-Datei der Kantine hochladen (Excel-Export mit UTF-8 oder Windows-1252 ist beides in Ordnung). Tessera zeigt Zeilenzahl, Abrechnungsmonat und Gesamtbetrag, nennt fehlerhafte Zeilen mit Zeilennummer und weist auf unterschiedliche Abrechnungsmonate hin. Ist alles in Ordnung, laden Sie mit einem Klick die DATEV-Lohndatei herunter. Beraternummer, Mandantennummer und Lohnart trägt ein Administrator einmalig ein; bis dahin ist der Download gesperrt. Die hochgeladenen Daten werden nicht gespeichert.
- Handelsware: Eine Excel-Liste mit Handelswaren-Umsätzen hochladen. Tessera ordnet jedem Produkt sein Konto zu, markiert neue Produkte mit „neu“ und vergibt ihnen das nächste freie Gegenkonto. Das Buchungsdatum wird aus dem Dateinamen abgeleitet (letzter Tag des Monats) und lässt sich ändern. Neue Konten werden erst beim Herunterladen der Buchungsdatei gespeichert. Im Reiter „Konten“ pflegen Sie die Kontenliste, lesen sie aus einer CSV-Datei ein (ersetzt alle vorhandenen Konten, nach Rückfrage) und exportieren sie als CSV. Standard-Erlöskonto und Startwert für das Gegenkonto trägt ein Administrator einmalig ein.
- Modul-Freigaben haben jetzt zwei Stufen: „Benutzen“ (wie bisher) und „Verwalten“. Wer ein Modul verwalten darf, ändert dessen Einstellungen selbst, ohne Administrator zu sein, zum Beispiel in der Kantinenabrechnung, bei Handelsware und bei den Proxmox-Servern. Ein Administrator wählt die Stufe je Gruppe in der Freigaben-Matrix oder je Benutzer in den Benutzerdetails; bestehende Freigaben bleiben „Benutzen“. Freigaben vergeben und Module aktivieren dürfen weiterhin nur Administratoren. Das Modul DKV-Rechnung steht Administratoren und Benutzern mit „Verwalten“ zur Verfügung.
## 1.9.2 – 2026-10-02
### Neu
- Zertifikat-Manager: Neuer Reiter „Übersicht“. Ziehen Sie alle Dateien, die Sie vom Zertifikatsaussteller bekommen haben, auf einmal hinein – gern auch direkt die ZIP-Datei. Tessera zeigt, was jede Datei ist (Serverzertifikat, Zwischenzertifikat, Stammzertifikat, privater Schlüssel, Zertifikatsanfrage), wofür sie gebraucht wird, wie lange sie gültig ist und was zusammengehört. Unter jedem Teil können Sie es in jedem passenden Format herunterladen: Zertifikate als PEM (.crt), DER (.cer), mit Kette, PKCS#7 (.p7b) oder als PFX mit Schlüssel und Kette; den Schlüssel als PEM, RSA-PEM oder DER; die Anfrage als PEM oder DER. Passwortgeschützte PFX-Dateien lassen sich mit dem Passwort entsperren.
### Behoben
- Desktop-App: Downloads, die Tessera erst im Fenster erstellt (zum Beispiel im Zertifikat-Manager), werden jetzt im Ordner „Downloads“ gespeichert; eine Meldung nennt den Dateinamen. Bisher öffnete Windows nur den Hinweis „Holen Sie sich eine App, um diesen ‚blob‘-Link zu öffnen“. Dafür ist die neue Version der Desktop-App nötig.
- Zertifikat-Manager: Die Texte sprechen Sie jetzt durchgehend mit „Sie“ an.
## 1.9.1 – 2026-10-01
### Behoben
- Desktop-App: Favoriten und andere Links, die sich in einem neuen Fenster öffnen (etwa „In neuem Tab öffnen“ oder Quellen im Ausschreibungs-Radar), öffnen sich jetzt in Ihrem normalen Browser. Bisher passierte beim Klick in der Desktop-App nichts.
- Erinnerungen: Beim Schreiben der Beschreibung springt der Cursor nicht mehr in die Titelzeile zurück. Bisher passierte das alle paar Sekunden, weil sich die Kachel regelmäßig neu aufbaut.
- Favoriten: Auch Seiten, die ihr Logo erst beim Laden im Browser setzen (etwa Host Europe), zeigen jetzt ihr Logo statt nur des Anfangsbuchstabens. Findet Tessera auf der Seite selbst kein Logo, fragt es bei öffentlichen Adressen einen Logo-Dienst; interne Adressen werden dabei nie weitergegeben. Das gilt auch für bereits angelegte Favoriten.
## 1.9.0 – 2026-09-30
### Neu
- Verwaltung: Eigene Vorlage für die Willkommensmail unter Administrator → Willkommensmail. Betreff, Überschrift, Einleitung, Abschluss und der Anmeldehinweis (getrennt für Verzeichnis- und lokale Konten) lassen sich anpassen, mit Platzhaltern wie {{vorname}}, {{benutzername}}, {{adresse}} oder {{firma}} (eine Tabelle auf der Seite erklärt sie). Die Seite zeigt eine Live-Vorschau der echten Mail und schickt auf Wunsch eine Testmail an Ihre Adresse; „Auf Standard zurücksetzen“ stellt die mitgelieferten Texte wieder her. Logo, Zugangsdaten und Knöpfe fügt Tessera immer selbst ein.
- Benutzerverwaltung: Willkommensmail. Über das Briefsymbol in der Benutzerliste schicken Sie einem Benutzer eine gestaltete Willkommensmail mit Tessera-Logo, Adresse, Benutzername und einem Knopf „Zu Tessera“. Konten aus dem Verzeichnis erhalten den Hinweis auf ihr Windows-Passwort, lokale Konten einen Link „Passwort festlegen“ (7 Tage gültig) – ein Passwort steht nie in der Mail. Die Liste zeigt, wann die Mail zuletzt ging.
- Benutzerverwaltung: Neue Spalte „Letzte Anmeldung“.
### Geändert
- Benutzerverwaltung: Die Aktionen je Zeile sind jetzt Symbole (Willkommensmail, Details, Bearbeiten, Löschen), damit die Liste ohne seitliches Scrollen passt.
### Behoben
- Anmeldung: Wer schon angemeldet ist und die Anmeldeseite aufruft, landet jetzt direkt auf dem Dashboard.
- Willkommensmail: Logo und Schriftzug erscheinen jetzt in jedem Mailprogramm. Bisher steckten sie in einem Bild; zeigte Outlook es nicht an, blieb nur ein großer schwarzer Kasten. Die Welle darunter ist nur noch ein schmaler Streifen; zeigt ein Mailprogramm sie nicht an (etwa Outlook im Browser), bleibt keine weiße Lücke mehr.
- Anmeldung: Eine geänderte Rolle, eine Deaktivierung oder das Löschen eines Kontos wirkt jetzt sofort. Bisher galt bis zu 30 Tage die Rolle vom Zeitpunkt der Anmeldung weiter – ein herabgestufter Administrator behielt seine Rechte, ein deaktiviertes Konto konnte mit seiner Sitzung weiterarbeiten, und die Benutzerliste ließ sich nach einer Rollenänderung nicht laden.
## 1.8.0 – 2026-09-30
### Neu
- Dashboard: Neues Widget „Erinnerungen“. Sie legen eine Erinnerung mit Datum, Uhrzeit, Titel und Beschreibung an, und Tessera meldet sich genau zur gewählten Zeit: im Browser mit einer Benachrichtigung (der Browser fragt dafür einmal um Erlaubnis, und zwar beim ersten Anlegen), in der Desktop-App mit einer Windows-Benachrichtigung – auch wenn das Fenster im Infobereich liegt. Wenn Sie möchten, schickt Tessera zusätzlich eine E-Mail an Ihre Adresse, auch dann, wenn Tessera gerade nirgends geöffnet ist. Eine fällige Erinnerung bleibt im Widget hervorgehoben stehen, bis Sie „Erledigt“ wählen oder mit „Später erinnern“ verschieben – auf in 10 Minuten, in 1 Stunde oder morgen zur gleichen Uhrzeit; dann meldet sich Tessera (und bei Bedarf die E-Mail) noch einmal. Erinnerungen sind persönlich: nur Sie sehen und ändern Ihre. Für die Desktop-Benachrichtigungen braucht die Desktop-App ihre neue Version, die Sie über „Auf Version … aktualisieren“ im Menü des Tessera-Symbols erhalten; Widget und E-Mail funktionieren auch mit der bisherigen Version.
### Geändert
- Eigene Module: Einmal geöffnete Seiten bleiben im Hintergrund offen. Wechseln Sie zurück, ist die Seite sofort da – im selben Zustand, ohne neu zu laden. Tessera hält die fünf zuletzt benutzten offen; beim Abmelden werden sie geschlossen.
- Dashboard, Favoriten: In der Kachelansicht stehen die Symbole enger beieinander; der Abstand zwischen ihnen ist etwa halb so groß, in eine Zeile passen mehr Favoriten. Passt ein Name nicht in eine Zeile, wird er kleiner geschrieben und auf zwei Zeilen umbrochen.
- Dashboard: Es sieht jetzt auf jedem Bildschirm gleich aus. Tessera merkt sich die Fläche des Bildschirms, an dem Sie ein Dashboard zuerst öffnen, und zeigt es auf anderen Bildschirmen maßstäblich verkleinert oder vergrößert – samt Schrift und ohne Scrollen. Ist ein Dashboard länger als der Bildschirm, wird es so weit verkleinert, dass es ganz hineinpasst. Auf dem Handy bleibt es bei der bisherigen Anordnung untereinander.
- Eigene Module: Neue Einträge sind mit der Kategorie „Eigene Module“ vorbelegt.
- Dashboard: Der Kalender lässt sich nicht mehr so schmal ziehen, dass seine Überschrift abgeschnitten wird.
- Eigene Module: Die Seite füllt jetzt den ganzen Inhaltsbereich. Name und Hinweiszeile darüber sind weggefallen – der Name steht ohnehin oben in der Leiste, und „In neuem Tab öffnen“ sitzt jetzt dort rechts.
### Behoben
- Eigene Module: Lässt sich ein Eintrag nicht laden, sagt Tessera das jetzt, statt „nicht gefunden“ zu melden. Fehlende Berechtigung und ungültige Angaben werden beim Speichern und Löschen eigens genannt.
- Seitenleiste: Eingeklappt stehen eigene Module jetzt bei ihrer Kategorie, in derselben Reihenfolge wie ausgeklappt.
- Erinnerungen: Ohne Browser-Speicher (etwa im privaten Fenster) kam dieselbe Benachrichtigung alle 10 Sekunden – jetzt nur einmal.
- Erinnerungen: Nach einer Änderung von Datum oder Uhrzeit kommt die E-Mail zuverlässig zur neuen Zeit. Deaktivierte Benutzer bekommen keine Erinnerungs-E-Mails mehr.
- Erinnerungen: „Später erinnern“ zeigt „Heute um …“, wenn die Uhrzeit heute noch kommt, statt fälschlich „Morgen um …“. Speichern ohne Zeitänderung verschiebt die Fälligkeit nicht mehr um Sekunden.
- Dashboard: Wird ein Bild gelöscht, das als Hintergrund gewählt war, gilt wieder „kein Hintergrund“.
- Dashboard: Ein noch offener Tab mit älterer Tessera-Version kann die Anordnung nicht mehr verziehen; er bittet stattdessen, die Seite neu zu laden.
- Desktop-App: Während ein Update installiert wird, bietet das Menü kein zweites mehr an. Nach einer fehlgeschlagenen Update-Prüfung genügt wieder ein Klick zum Installieren.
- Desktop-App: „Auf Version … aktualisieren“ im Menü des Tessera-Symbols scheiterte mit „Signaturprüfung fehlgeschlagen“ und öffnete stattdessen die Download-Seite, wenn der Server seit der letzten Update-Prüfung der App eine neuere Version bekommen hatte. Die App fragt jetzt beim Klick zuerst frisch nach und installiert genau die Version, die der Server in diesem Moment anbietet.
- Dashboard, Favoriten: Eine neu eingetragene Logo-Adresse wird jetzt sofort angezeigt. Bisher blieb ein früher hochgeladenes eigenes Symbol stehen und verdeckte die neue Adresse; jetzt ersetzt die neue Adresse es.
- Dashboard, Favoriten: Eine Logo-Adresse lässt sich jetzt auch speichern, wenn Tessera das Bild selbst nicht laden kann – etwa bei Seiten im internen Netz, die nur Ihrem Browser das Symbol geben. Die Kachel lädt das Bild dann direkt in Ihrem Browser. Auch die automatische Erkennung findet das Symbol solcher Seiten jetzt eher, statt auf ein Ersatzsymbol zurückzufallen.
+3
View File
@@ -47,6 +47,9 @@ COPY --from=builder /app/node_modules/.pnpm/@prisma+client@6.19.3_prisma@6.19.3_
COPY --from=builder /app/apps/api/prisma ./apps/api/prisma
COPY --from=builder /app/packages/shared/src ./packages/shared/src
COPY apps/api/scripts ./apps/api/scripts
# Kopfbild der Willkommensmail (MailService.loadWelcomeHeaderPng liest
# apps/api/assets/mail/welcome-header.png relativ zu dist/mail/).
COPY apps/api/assets ./apps/api/assets
# Desktop-Pakete (Phase 18, D-08): im CI legt desktop-collect.sh Pakete +
# manifest.json in diesen Ordner, lokal liegt nur der Platzhalter. Nur
# lesend zur Laufzeit -- kein chown noetig.
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+27
View File
@@ -0,0 +1,27 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="80" viewBox="0 0 1200 80">
<!--
Wellenstreifen unter dem Kopf der Tessera-Systemmails (Willkommensmail).
Quelle des PNG daneben (welcome-header.png, 1200x80, angezeigt 600x40);
erzeugt mit `node apps/api/scripts/render-mail-header.mjs`.
Seit quick-260930 (Rueckmeldung des Nutzers: in Outlook "ein riesiger
schwarzer Fleck, kein Logo") steckt KEIN Logo und KEIN Text mehr im Bild:
Bildmarke und Schriftzug stehen als HTML im Mailkopf und erscheinen immer.
Dieses Bild ist nur noch Schmuck: oben die Kopffarbe, darunter die
Duenen-Wellen des Dashboard-Hintergrunds (dunkle Fassung,
apps/web/src/lib/dashboard-background.ts) mit der feinen gelben Linie,
unten laeuft es ins Weiss der Karte aus. Zeigt ein Mailprogramm das Bild
nicht (Outlook mit gesperrten Bildern), bleibt dort die dunkle Kopffarbe
der Zelle stehen — der Kopf wirkt nur etwas hoeher, keine weisse Luecke.
Seit quick-260930 (zweite Rueckmeldung) 80 statt 112 px hoch (y-Werte
x 80/112), in der Mail 40 statt 56 px.
-->
<rect width="1200" height="80" fill="#ffffff"/>
<!-- Flaechen von unten nach oben uebereinander, jede von oben bis zu ihrer
Wellenlinie: so teilen sich benachbarte Baender dieselbe Kante, ohne
Luecken dazwischen. -->
<path d="M0 0 V65.7 C220 55.7 420 72.9 600 68.6 S1000 54.3 1200 61.4 V0 Z" fill="#d9dce0"/>
<path d="M0 0 V50 C250 35.7 420 61.4 640 54.3 S1040 35.7 1200 45.7 V0 Z" fill="#2c3036"/>
<path d="M0 0 V31.4 C330 12.9 520 44.3 700 37.1 S1060 15.7 1200 28.6 V0 Z" fill="#1a1c20"/>
<path d="M0 31.4 C330 12.9 520 44.3 700 37.1 S1060 15.7 1200 28.6" fill="none" stroke="#ffed00" stroke-opacity="0.75" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,15 @@
-- Willkommensmail aus der Benutzerverwaltung (Administrator → Benutzer).
--
-- Merkt pro Benutzer, wann zuletzt eine Willkommensmail verschickt wurde,
-- damit die Liste "Willkommensmail gesendet am …" zeigen kann. Gesetzt nur
-- ueber POST /users/:id/welcome-mail nach erfolgreichem Versand; erneutes
-- Senden ueberschreibt den Wert. NULL = nie gesendet. Kein Standardwert,
-- kein Backfill.
--
-- Keine neue Regel noetig: die Spalte liegt in "User", dessen
-- tenant_isolation_policy die ganze Zeile schuetzt. Die Anmelde-Funktionen
-- auth_lookup_* liefern eine feste Spaltenliste (RETURNS TABLE) und bleiben
-- unberuehrt.
-- AlterTable
ALTER TABLE "User" ADD COLUMN "welcomeMailSentAt" TIMESTAMP(3);
@@ -0,0 +1,52 @@
-- Willkommensmail: eigene Vorlage je Mandant (Administrator → Willkommensmail).
--
-- Zweck: neue Tabelle "WelcomeMailTemplate". Ein Administrator kann Betreff,
-- Ueberschrift, Einleitung und Abschlusstext der Willkommensmail anpassen
-- (mit Platzhaltern wie {{name}}). Hoechstens EINE Zeile je Mandant
-- ("tenantId" eindeutig); "Auf Standard zuruecksetzen" loescht die Zeile, dann
-- gelten wieder die Standardtexte aus dem Code. Die festen Bausteine der Mail
-- (Kopf, Zugangsdaten, Anmeldehinweis, Knoepfe, Fusszeile) stehen NICHT in
-- der Tabelle. "updatedBy" haelt den Benutzernamen des letzten Bearbeiters
-- als reinen Anzeigetext (keine Relation).
--
-- Von Hand geschrieben (Vorbild 20260929120000_custom_module).
--
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die
-- Tabelle traegt `tenantId` und `tenant_isolation_policy` OHNE
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — die
-- Vorlage ist Verwaltungsdatum des Mandanten, nicht persoenliches Datum eines
-- einzelnen Benutzers.
--
-- BEWUSST KEINE `system_read_policy`: gelesen wird nur beim Versand einer
-- Willkommensmail, und zwar gebunden an den Mandanten des Zielbenutzers
-- (`forTenant(prisma, tenantId)`); es gibt keinen Hintergrunddienst, der die
-- Vorlagen ueber alle Mandanten liest.
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
CREATE TABLE "WelcomeMailTemplate" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"subject" TEXT NOT NULL,
"heading" TEXT NOT NULL,
"intro" TEXT NOT NULL,
"closing" TEXT NOT NULL,
"updatedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WelcomeMailTemplate_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "WelcomeMailTemplate_tenantId_key" ON "WelcomeMailTemplate"("tenantId");
ALTER TABLE "WelcomeMailTemplate" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "WelcomeMailTemplate" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "WelcomeMailTemplate"
USING ("tenantId" = current_tenant_id());
@@ -0,0 +1,18 @@
-- Willkommensmail: Anmeldehinweis je Kontoart als Teil der eigenen Vorlage.
--
-- Zweck: zwei neue Spalten in "WelcomeMailTemplate" —
-- "loginHintDirectory" (Hinweis fuer verzeichnisgefuehrte Konten, Standard
-- "Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten
-- Windows-Passwort an.") und "loginHintLocal" (Hinweis vor dem Knopf
-- "Passwort festlegen" fuer lokale Konten).
--
-- Bewusst NULLABLE ohne Default: bestehende Vorlagen behalten NULL, und
-- WelcomeMailTemplateService setzt dafuer den Standardtext aus
-- @tessera/shared ein. So steht der Standardtext nur an EINER Stelle im
-- Code und nicht zusaetzlich in der Datenbank.
--
-- Zeilenschutz: unveraendert (tenant_isolation_policy der Tabelle gilt fuer
-- die neuen Spalten mit).
ALTER TABLE "WelcomeMailTemplate" ADD COLUMN "loginHintDirectory" TEXT;
ALTER TABLE "WelcomeMailTemplate" ADD COLUMN "loginHintLocal" TEXT;
@@ -0,0 +1,46 @@
-- 261002-fm5 — Finanzbuchhaltung: Modul "Kantinenabrechnung" (kantine-datev).
--
-- Zweck: eine neue Tabelle `KantineDatevConfig` mit den drei Nummern, die der
-- Administrator einmalig je Mandant hinterlegt (Beraternummer, Mandantennummer,
-- Lohnart). Eine Zeile je Mandant (Singleton, Vorbild `DkvModuleConfig`). Die
-- Felder sind Text, damit fuehrende Nullen erhalten bleiben, und haben
-- ABSICHTLICH keinen Standardwert: solange sie leer sind, sperrt das Modul die
-- Verarbeitung. Die hochgeladene Kantinen-CSV wird nicht gespeichert.
--
-- Von Hand geschrieben (Vorbild 20260923140000_proxmox_server), von Hand
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
--
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die Tabelle
-- traegt `tenantId` und `tenant_isolation_policy` OHNE Benutzerdimension
-- (`USING ("tenantId" = current_tenant_id())`, Form aus `DkvModuleConfig`) —
-- Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers.
-- Keine `system_read_policy`: es gibt keinen Hintergrunddienst, der diese
-- Einstellungen ueber alle Mandanten liest.
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
CREATE TABLE "KantineDatevConfig" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"beraterNr" TEXT,
"mandantNr" TEXT,
"lohnart" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "KantineDatevConfig_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "KantineDatevConfig_tenantId_key" ON "KantineDatevConfig"("tenantId");
CREATE INDEX "KantineDatevConfig_tenantId_idx" ON "KantineDatevConfig"("tenantId");
ALTER TABLE "KantineDatevConfig" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "KantineDatevConfig" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "KantineDatevConfig"
USING ("tenantId" = current_tenant_id());
@@ -0,0 +1,70 @@
-- 261002-fm5 — Finanzbuchhaltung: Modul "Handelsware" (handelsware-datev).
--
-- Zweck: zwei neue Tabellen. `HandelswareDatevConfig` traegt die Einstellungen
-- des Mandanten (Standard-Erloeskonto fuer neue Konten, Startwert fuer die
-- Gegenkonto-Vergabe bei leerer Kontenliste) — eine Zeile je Mandant
-- (Singleton, Vorbild `DkvModuleConfig`/`KantineDatevConfig`). Beide Zahlen
-- haben ABSICHTLICH keinen Standardwert: solange sie leer sind, sperrt das
-- Modul die Verarbeitung. `HandelswareKonto` ist die Kontenliste (Produktname
-- -> Gegenkonto, Erloeskonto) — mehrere Zeilen je Mandant, der Name ist je
-- Mandant eindeutig, das Gegenkonto bewusst nicht (mehrere Produkte duerfen
-- auf dasselbe Gegenkonto laufen).
--
-- Von Hand geschrieben (Vorbild 20260923140000_proxmox_server), von Hand
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
--
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): beide
-- Tabellen tragen `tenantId` und `tenant_isolation_policy` OHNE
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`, Form aus
-- `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten
-- eines Benutzers. Keine `system_read_policy`: es gibt keinen Hintergrunddienst,
-- der diese Tabellen ueber alle Mandanten liest.
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
-- 1) HandelswareDatevConfig
CREATE TABLE "HandelswareDatevConfig" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"erloeskonto" INTEGER,
"startGegenkonto" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HandelswareDatevConfig_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "HandelswareDatevConfig_tenantId_key" ON "HandelswareDatevConfig"("tenantId");
CREATE INDEX "HandelswareDatevConfig_tenantId_idx" ON "HandelswareDatevConfig"("tenantId");
ALTER TABLE "HandelswareDatevConfig" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "HandelswareDatevConfig" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "HandelswareDatevConfig"
USING ("tenantId" = current_tenant_id());
-- 2) HandelswareKonto
CREATE TABLE "HandelswareKonto" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"gegenkonto" INTEGER NOT NULL,
"erloeskonto" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HandelswareKonto_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "HandelswareKonto_tenantId_name_key" ON "HandelswareKonto"("tenantId", "name");
CREATE INDEX "HandelswareKonto_tenantId_idx" ON "HandelswareKonto"("tenantId");
ALTER TABLE "HandelswareKonto" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "HandelswareKonto" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "HandelswareKonto"
USING ("tenantId" = current_tenant_id());
@@ -0,0 +1,28 @@
-- 261002-icv — Freigabestufe fuer Modul-Freigaben: Benutzen (USE) und
-- Verwalten (MANAGE).
--
-- Zweck: jede Zeile in "ModuleGrant" bekommt eine Stufe. USE ist der Bestand
-- und der Standard (Modul oeffnen und benutzen). MANAGE erlaubt zusaetzlich,
-- die eigenen Einstellungen dieses einen Moduls zu aendern. Freigaben
-- erteilen, Module aktivieren und die uebrige Verwaltung bleiben
-- Administratoren vorbehalten (das erzwingt die Anwendung, nicht diese
-- Migration).
--
-- Bestandsdaten: durch den DEFAULT 'USE' werden alle vorhandenen Freigaben zu
-- USE — niemand gewinnt durch die Migration Rechte.
--
-- Von Hand geschrieben (Vorbild 20261002120000_kantine_datev_config).
--
-- Zeilenschutz: keine neue Tabelle. Die vorhandenen Regeln auf "ModuleGrant"
-- filtern Zeilen, nicht Spalten, und bleiben unveraendert — rls-coverage
-- braucht nichts. PostgreSQL gewaehrt USAGE auf neue Typen automatisch an
-- PUBLIC, die Anwendungsrolle tessera_app kann den Aufzaehlungstyp also
-- verwenden.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken die Zeilenregeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
CREATE TYPE "ModuleGrantLevel" AS ENUM ('USE', 'MANAGE');
ALTER TABLE "ModuleGrant" ADD COLUMN "level" "ModuleGrantLevel" NOT NULL DEFAULT 'USE';
+87 -1
View File
@@ -50,6 +50,9 @@ model User {
// sonst das durch parseDashboardBackground (@tessera/shared) normalisierte
// Objekt, auch { kind: 'none' } fuer bewusst "kein Hintergrund"
dashboardBackground Json?
// Willkommensmail aus der Benutzerverwaltung: Zeitpunkt des letzten
// Versands; null = nie gesendet
welcomeMailSentAt DateTime?
passwordResetTokens PasswordResetToken[]
groupMemberships GroupMembership[]
moduleGrants ModuleGrant[]
@@ -137,12 +140,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
@@ -188,6 +199,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
@@ -341,6 +356,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
@@ -764,3 +829,24 @@ model Reminder {
@@index([tenantId, userId, dueAt])
@@index([dueAt])
}
// Eigene Vorlage der Willkommensmail (Administrator → Willkommensmail):
// hoechstens eine je Mandant; fehlt sie, gelten die Standardtexte aus
// @tessera/shared (DEFAULT_WELCOME_MAIL_TEXTS). Nur die sechs Texte —
// Kopf, Zugangsdaten und Knoepfe bleiben fest im Code.
model WelcomeMailTemplate {
id String @id @default(uuid())
tenantId String @unique
subject String
heading String
intro String
// Anmeldehinweise je Kontoart (Migration 20260930170000); NULL in einer
// aelteren Vorlage = Standardtext aus @tessera/shared
loginHintDirectory String?
loginHintLocal String?
closing String
// Benutzername des Administrators, der zuletzt gespeichert hat (Anzeige)
updatedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env node
/**
* render-mail-header.mjs — erzeugt das Kopfbild der Willkommensmail
* (apps/api/assets/mail/welcome-header.png, 1200x80 fuer hochaufloesende
* Bildschirme, in der Mail 600x40 angezeigt) aus der daneben liegenden
* Quelle welcome-header.svg.
*
* Warum ein PNG statt Inline-SVG oder CSS-Hintergrund: Outlook (Word-
* Darstellung) und viele Webmailer zeigen weder SVG noch Hintergrundbilder
* zuverlaessig an. Das PNG wird als CID-Anhang eingebettet (MailService),
* die Mail laedt also nichts von aussen nach.
*
* Das PNG liegt fertig im Repo; dieses Skript ist nur noetig, wenn die SVG
* geaendert wird. Kein neues Paket: `sharp` ist ueber Next.js (apps/web)
* bereits installiert und wird von dort aufgeloest. Der Schriftzug wird
* mit den Systemschriften des erzeugenden Rechners gesetzt (fontconfig:
* Segoe UI, Inter, Noto Sans, DejaVu Sans — die erste vorhandene gewinnt).
*
* Aufruf (Repo-Wurzel): node apps/api/scripts/render-mail-header.mjs
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const assetDir = join(here, '..', 'assets', 'mail');
const webDir = join(here, '..', '..', 'web');
const require = createRequire(import.meta.url);
const nextPkg = require.resolve('next/package.json', { paths: [webDir] });
const sharp = createRequire(nextPkg)('sharp');
const svg = readFileSync(join(assetDir, 'welcome-header.svg'));
const png = await sharp(svg, { density: 72 })
.resize(1200, 80)
.png({ compressionLevel: 9, palette: false })
.toBuffer();
writeFileSync(join(assetDir, 'welcome-header.png'), png);
console.log(`welcome-header.png geschrieben (${png.length} Bytes)`);
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { decodeCsvText } from './decode-csv-text';
describe('decodeCsvText', () => {
it('liest gueltiges UTF-8 unveraendert', () => {
expect(decodeCsvText(Buffer.from('Müller;Straße', 'utf8'))).toBe('Müller;Straße');
});
it('entfernt ein UTF-8-BOM', () => {
const buf = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('Name;Wert', 'utf8')]);
expect(decodeCsvText(buf)).toBe('Name;Wert');
});
it('faellt bei ungueltigem UTF-8 auf Windows-1252 zurueck (Umlaut)', () => {
expect(decodeCsvText(Buffer.from('Müller', 'latin1'))).toBe('Müller');
});
it('liest das Euro-Zeichen (0x80) in Windows-1252', () => {
expect(decodeCsvText(Buffer.from([0x31, 0x30, 0x80]))).toBe('10€');
});
});
@@ -0,0 +1,18 @@
/**
* Dekodiert hochgeladene CSV-Bytes zu Text (quick-261002-fm5).
*
* Excel und Warenwirtschaftssysteme liefern CSV entweder als UTF-8 (mit oder
* ohne Byte-Order-Mark) oder als Windows-1252. Zuerst wird streng als UTF-8
* gelesen: sind die Bytes kein gueltiges UTF-8 (typisch bei Umlauten in
* Windows-1252), faellt die Funktion auf Windows-1252 zurueck. `TextDecoder`
* verwirft ein fuehrendes BOM standardmaessig.
*
* Gemeinsam genutzt von Kantinenabrechnung und Handelsware.
*/
export function decodeCsvText(buffer: Buffer): string {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(buffer);
} catch {
return new TextDecoder('windows-1252').decode(buffer);
}
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { decodeUploadFilename } from './decode-upload-filename';
describe('decodeUploadFilename', () => {
it('laesst ASCII-Namen unveraendert', () => {
expect(decodeUploadFilename('HWA 0326 Test.xlsx')).toBe('HWA 0326 Test.xlsx');
});
it('kehrt latin1-gelesenes UTF-8 um', () => {
const mojibake = Buffer.from('Käse 0326.xlsx', 'utf8').toString('latin1');
expect(decodeUploadFilename(mojibake)).toBe('Käse 0326.xlsx');
});
it('laesst einen schon richtigen Namen mit Umlaut stehen', () => {
expect(decodeUploadFilename('Käse.xlsx')).toBe('Käse.xlsx');
});
it('laesst Namen mit Zeichen ueber 255 stehen', () => {
expect(decodeUploadFilename('Preis €.xlsx')).toBe('Preis €.xlsx');
});
});
@@ -0,0 +1,14 @@
/**
* Multer liefert `originalname` je nach Version als latin1-gelesene Bytes: ein
* UTF-8-Dateiname wie "Käse.xlsx" kommt als "Käse.xlsx" an. Diese Funktion
* kehrt das um, ohne einen schon richtigen Namen zu zerstoeren: ist der Name
* nicht aus latin1-Zeichen zusammengesetzt (Zeichen > 255) oder ergibt die
* Umkehrung kein gueltiges UTF-8, bleibt er unveraendert.
*/
export function decodeUploadFilename(name: string): string {
for (let i = 0; i < name.length; i++) {
if (name.charCodeAt(i) > 255) return name;
}
const converted = Buffer.from(name, 'latin1').toString('utf8');
return converted.includes('�') ? name : converted;
}
+4
View File
@@ -26,6 +26,8 @@ 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 { ProxmoxModule } from './proxmox/proxmox.module';
import { CustomModulesModule } from './custom-modules/custom-modules.module';
import { RemindersModule } from './reminders/reminders.module';
@@ -55,6 +57,8 @@ import { RemindersModule } from './reminders/reminders.module';
TendersModule,
BugReportsModule,
ProxmoxModule,
KantineDatevModule,
HandelswareDatevModule,
CustomModulesModule,
RemindersModule,
],
+2 -1
View File
@@ -17,6 +17,7 @@ import { LdapService } from '../ldap/ldap.service';
import { MailService } from '../mail/mail.service';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { PASSWORD_RESET_TOKEN_TTL_MS } from './password-reset-token';
import type { JwtPayload, LoginUser } from './types/auth-user';
/**
@@ -239,7 +240,7 @@ export class AuthService {
// Generate a unique reset token
const token = randomUUID();
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
const expiresAt = new Date(Date.now() + PASSWORD_RESET_TOKEN_TTL_MS); // 1 hour
// Create the reset token record — mandantengebunden, sobald der
// Benutzer und damit sein Mandant bekannt sind (WINDOWS #20, Aufgabe 1).
@@ -1,9 +1,13 @@
import { ForbiddenException } from '@nestjs/common';
import { of } from 'rxjs';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { JwtStrategy } from '../strategies/jwt.strategy';
import { ForcePasswordChangeInterceptor } from './force-password-change.interceptor';
vi.mock('../../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
}));
/**
* ForcePasswordChangeInterceptor.intercept — pinnt Sperre, Erlaubnisliste
* und die Teilstring-Falle (260921-fi3, Aufgabe 1, Befund 1/D-01/D-02/D-03).
@@ -30,7 +34,19 @@ const nextHandle = { handle: () => of('ok') } as any;
describe('ForcePasswordChangeInterceptor.intercept', () => {
it('Nahttest (D-03): JwtStrategy.validate() -> request.user -> GET /users wirft ForbiddenException — scheitert gegen den heutigen Quelltext, weil das Feld auf dem Weg verloren geht', async () => {
const strategy = new JwtStrategy({ get: () => 'test-secret' } as any);
const prisma = {
user: {
findUnique: async () => ({
id: 'u1',
username: 'admin',
role: 'ADMIN',
tenantId: 't1',
isActive: true,
mustChangePassword: true,
}),
},
} as any;
const strategy = new JwtStrategy({ get: () => 'test-secret' } as any, prisma);
const user = await strategy.validate({
sub: 'u1',
username: 'admin',
+18
View File
@@ -0,0 +1,18 @@
/**
* Gueltigkeit eines Kennwort-Tokens (`PasswordResetToken`, T-02-13): eine
* Stunde, einmal verwendbar. Gemeinsam genutzt vom Weg "Passwort
* vergessen" (`AuthService.requestPasswordReset`) und vom Link "Passwort
* festlegen" der Willkommensmail (`WelcomeMailService`) — beide legen
* dieselbe Art Token an und fuehren auf dieselbe Seite
* `/reset-password/<token>`, deshalb gilt dieselbe Frist.
*/
export const PASSWORD_RESET_TOKEN_TTL_MS = 60 * 60 * 1000;
/**
* Gueltigkeit des Links "Passwort festlegen" in der Willkommensmail: 7 Tage.
* Neue Mitarbeiter lesen die Mail oft erst Tage spaeter; eine Stunde wie bei
* "Passwort vergessen" (dort fordert der Benutzer den Link selbst an und
* nutzt ihn sofort) waere hier fast immer abgelaufen. Einmal verwendbar
* bleibt der Link trotzdem, und jede neue Willkommensmail legt einen neuen an.
*/
export const WELCOME_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
@@ -1,9 +1,16 @@
import { describe, expect, it } from 'vitest';
import { UnauthorizedException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { forTenant } from '../../prisma/prisma-tenant.extension';
import { JwtStrategy } from './jwt.strategy';
vi.mock('../../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
}));
/**
* JwtStrategy.validate — pinnt die Durchreichung von mustChangePassword
* (260921-fi3, Aufgabe 1, Befund 1/D-01). Direkte Konstruktion ohne
* JwtStrategy.validate — seit quick-260930 kommen Rolle, Aktiv-Status und
* Kennwort-Pflicht bei jeder Anfrage aus der Datenbank, nicht aus dem Token
* (Rollenaenderung/Deaktivierung wirkt sofort). Direkte Konstruktion ohne
* Nest-Testmodul, Muster aus `../../tenant/tenant.guard.spec.ts`.
*/
@@ -11,51 +18,77 @@ function makeConfigService() {
return { get: () => 'test-secret' } as any;
}
describe('JwtStrategy.validate', () => {
it('Anspruch mustChangePassword=true im Token: liefert request.user.mustChangePassword === true', async () => {
const strategy = new JwtStrategy(makeConfigService());
type Row = {
id: string;
username: string;
role: string;
tenantId: string;
isActive: boolean;
mustChangePassword: boolean;
} | null;
const result = await strategy.validate({
function makePrisma(row: Row) {
return { user: { findUnique: vi.fn(async () => row) } } as any;
}
const payload = {
sub: 'u1',
username: 'admin',
role: 'ADMIN',
username: 'kschaller',
role: 'SUPER_ADMIN' as const,
tenantId: 't1',
mustChangePassword: true,
});
expect(result.mustChangePassword).toBe(true);
});
it('Anspruch fehlt im Token (Alt-Sitzung, vor dieser Aenderung ausgestellt): liefert false statt undefined', async () => {
const strategy = new JwtStrategy(makeConfigService());
const result = await strategy.validate({
sub: 'u1',
username: 'admin',
role: 'ADMIN',
tenantId: 't1',
});
expect(result.mustChangePassword).toBe(false);
});
it('id, username, role und tenantId werden unveraendert wie bisher durchgereicht', async () => {
const strategy = new JwtStrategy(makeConfigService());
const result = await strategy.validate({
sub: 'u1',
username: 'nutzer1',
role: 'USER',
tenantId: 't2',
mustChangePassword: false,
});
};
const dbRow = {
id: 'u1',
username: 'kschaller',
role: 'ADMIN',
tenantId: 't1',
isActive: true,
mustChangePassword: false,
};
describe('JwtStrategy.validate', () => {
it('Rolle kommt aus der Datenbank, nicht aus dem Token (herabgestufter Super-Admin ist sofort Admin)', async () => {
const prisma = makePrisma(dbRow);
const strategy = new JwtStrategy(makeConfigService(), prisma);
const result = await strategy.validate(payload);
expect(result).toEqual({
id: 'u1',
username: 'nutzer1',
role: 'USER',
tenantId: 't2',
username: 'kschaller',
role: 'ADMIN',
tenantId: 't1',
mustChangePassword: false,
});
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
expect(prisma.user.findUnique).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: 'u1' } }),
);
});
it('deaktiviertes Konto: 401, auch mit gueltigem Token', async () => {
const strategy = new JwtStrategy(makeConfigService(), makePrisma({ ...dbRow, isActive: false }));
await expect(strategy.validate(payload)).rejects.toBeInstanceOf(UnauthorizedException);
});
it('geloeschtes Konto: 401', async () => {
const strategy = new JwtStrategy(makeConfigService(), makePrisma(null));
await expect(strategy.validate(payload)).rejects.toBeInstanceOf(UnauthorizedException);
});
it('Konto gehoert nicht (mehr) zum Mandanten aus dem Token: 401', async () => {
const strategy = new JwtStrategy(makeConfigService(), makePrisma({ ...dbRow, tenantId: 't2' }));
await expect(strategy.validate(payload)).rejects.toBeInstanceOf(UnauthorizedException);
});
it('Kennwort-Pflicht kommt aus der Datenbank (vom Administrator nachtraeglich gesetzt)', async () => {
const strategy = new JwtStrategy(
makeConfigService(),
makePrisma({ ...dbRow, mustChangePassword: true }),
);
const result = await strategy.validate(payload);
expect(result.mustChangePassword).toBe(true);
});
});
+45 -10
View File
@@ -1,8 +1,10 @@
import { Injectable } from '@nestjs/common';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';
import { Request } from 'express';
import { PrismaService } from '../../prisma/prisma.service';
import { forTenant } from '../../prisma/prisma-tenant.extension';
import type { AuthUser, JwtPayload } from '../types/auth-user';
/**
@@ -17,7 +19,10 @@ function cookieExtractor(req: Request): string | null {
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(configService: ConfigService) {
constructor(
configService: ConfigService,
private readonly prisma: PrismaService,
) {
super({
jwtFromRequest: cookieExtractor,
ignoreExpiration: false,
@@ -25,16 +30,46 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
});
}
/**
* Das Token beweist nur, WER angemeldet ist — Rolle, Aktiv-Status und
* Kennwort-Pflicht kommen bei JEDER Anfrage frisch aus der Datenbank
* (quick-260930, Befund des Nutzers): vorher galt die Rolle aus dem
* 30-Tage-Token. Ein herabgestufter Administrator behielt bis zum Ablauf
* seine alten Rechte, ein deaktiviertes oder geloeschtes Konto (etwa per
* LDAP-Abgleich beim Austritt) arbeitete mit seiner Sitzung weiter, und
* Oberflaeche (liest die Rolle ueber /auth/me aus der Datenbank) und API
* (las sie aus dem Token) sahen verschiedene Rollen — die Benutzerliste
* scheiterte dann im Client.
*
* Ein Primaerschluessel-Lesezugriff je Anfrage, gebunden an den Mandanten
* aus dem Token (`forTenant`); gehoert das Konto nicht (mehr) zu diesem
* Mandanten, fehlt es oder ist es deaktiviert, gilt die Sitzung als
* ungueltig (401) — die Web-Oberflaeche leitet dann zur Anmeldung.
*/
async validate(payload: JwtPayload): Promise<AuthUser> {
const tenantPrisma = forTenant(this.prisma, payload.tenantId);
const user = await tenantPrisma.user.findUnique({
where: { id: payload.sub },
select: {
id: true,
username: true,
role: true,
tenantId: true,
isActive: true,
mustChangePassword: true,
},
});
if (!user || !user.isActive || user.tenantId !== payload.tenantId) {
throw new UnauthorizedException();
}
return {
id: payload.sub,
username: payload.username,
role: payload.role,
tenantId: payload.tenantId,
// Ein vor dieser Aenderung ausgestelltes Token traegt diesen Anspruch
// nicht; der strenge Vergleich ergibt dann false, laufende Sitzungen
// verhalten sich unveraendert (260921-fi3, D-01 — keine Aussperrwelle).
mustChangePassword: payload.mustChangePassword === true,
id: user.id,
username: user.username,
role: user.role as AuthUser['role'],
tenantId: user.tenantId,
mustChangePassword: user.mustChangePassword === true,
};
}
}
@@ -0,0 +1,268 @@
import AdmZip from 'adm-zip';
import * as forge from 'node-forge';
import { beforeAll, describe, expect, it } from 'vitest';
import { analyzeBundle, exportBundleItem, safeBaseName } from './cert-bundle';
/**
* cert-bundle.spec (quick-261001-l4q) — Zertifikatspaket wie vom Aussteller:
* Stamm -> Zwischen -> Server, dazu Schluessel, CSR und PFX, als ZIP.
* Alles hier erzeugt (keine echten Kundendaten im Repo).
*/
interface Pki {
rootPem: string;
interPem: string;
leafPem: string;
keyPem: string;
csrPem: string;
pfx: Buffer;
leafModulus: string;
}
let pki: Pki;
function makeCert(
subjectCn: string,
pub: forge.pki.PublicKey,
signer: forge.pki.PrivateKey,
issuer: forge.pki.CertificateField[] | null,
ca: boolean,
serial: string,
): forge.pki.Certificate {
const cert = forge.pki.createCertificate();
cert.publicKey = pub;
cert.serialNumber = serial;
cert.validity.notBefore = new Date(Date.now() - 86_400_000);
cert.validity.notAfter = new Date(Date.now() + 90 * 86_400_000);
const subject = [{ name: 'commonName', value: subjectCn }];
cert.setSubject(subject);
cert.setIssuer(issuer ?? subject);
const ext: object[] = [{ name: 'basicConstraints', cA: ca }];
if (!ca) ext.push({ name: 'subjectAltName', altNames: [{ type: 2, value: subjectCn }] });
cert.setExtensions(ext);
cert.sign(signer as forge.pki.rsa.PrivateKey, forge.md.sha256.create());
return cert;
}
beforeAll(() => {
const rootKeys = forge.pki.rsa.generateKeyPair(1024);
const interKeys = forge.pki.rsa.generateKeyPair(1024);
const leafKeys = forge.pki.rsa.generateKeyPair(1024);
const root = makeCert('Test Root CA', rootKeys.publicKey, rootKeys.privateKey, null, true, '01');
const inter = makeCert(
'Test Intermediate CA',
interKeys.publicKey,
rootKeys.privateKey,
root.subject.attributes,
true,
'02',
);
const leaf = makeCert(
'www.example.test',
leafKeys.publicKey,
interKeys.privateKey,
inter.subject.attributes,
false,
'03',
);
const csr = forge.pki.createCertificationRequest();
csr.publicKey = leafKeys.publicKey;
csr.setSubject([{ name: 'commonName', value: 'www.example.test' }]);
csr.sign(leafKeys.privateKey, forge.md.sha256.create());
const p12 = forge.pkcs12.toPkcs12Asn1(leafKeys.privateKey, [leaf, inter], 'geheim', {
algorithm: '3des',
});
pki = {
rootPem: forge.pki.certificateToPem(root),
interPem: forge.pki.certificateToPem(inter),
leafPem: forge.pki.certificateToPem(leaf),
keyPem: forge.pki.privateKeyInfoToPem(
forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(leafKeys.privateKey)),
),
csrPem: forge.pki.certificationRequestToPem(csr),
pfx: Buffer.from(forge.asn1.toDer(p12).getBytes(), 'binary'),
leafModulus: leafKeys.publicKey.n.toString(16),
};
}, 60_000);
function issuerZip(): Buffer {
const zip = new AdmZip();
zip.addFile('www.example.test/www.example.test.pem', Buffer.from(pki.leafPem + pki.interPem));
zip.addFile('www.example.test/www.example.test.key', Buffer.from(pki.keyPem));
zip.addFile('www.example.test/www.example.test.csr', Buffer.from(pki.csrPem));
zip.addFile('www.example.test/www.example.test.pfx', pki.pfx);
zip.addFile('www.example.test/.dnstxtrecord', Buffer.from('_dnsauth abc123'));
return zip.toBuffer();
}
describe('analyzeBundle', () => {
it('ZIP vom Aussteller: erkennt jedes Teil, fasst PEM/PFX zusammen, ordnet Schluessel und Kette zu', () => {
const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'geheim');
const kinds = r.items.map((i) => (i.kind === 'certificate' ? i.role : i.kind));
expect(kinds).toEqual(['end-entity', 'intermediate', 'privateKey', 'csr']);
const [leaf, inter, key, csr] = r.items;
expect(leaf.cn).toBe('www.example.test');
expect(leaf.san).toEqual(['www.example.test']);
// in PEM UND PFX enthalten -> ein Eintrag mit beiden Quellen
expect(leaf.sources.sort()).toEqual(['www.example.test.pem', 'www.example.test.pfx']);
expect(leaf.chainIds).toEqual([inter.id]);
expect(leaf.matchId).toBe(key.id);
expect(key.matchId).toBe(leaf.id);
expect(key.sources.sort()).toEqual(['www.example.test.key', 'www.example.test.pfx']);
expect(csr.matchId).toBe(leaf.id);
expect(csr.cn).toBe('www.example.test');
expect(leaf.baseName).toBe('www.example.test');
expect(r.locked).toEqual([]);
expect(r.ignored).toEqual(['.dnstxtrecord']);
});
it('PFX ohne passendes Passwort wird als gesperrt gemeldet, der Rest trotzdem erkannt', () => {
const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'falsch');
expect(r.locked).toEqual(['www.example.test.pfx']);
expect(r.items.filter((i) => i.kind === 'certificate')).toHaveLength(2);
});
it('Stammzertifikat wird als root erkannt und an die Kette gehaengt', () => {
const r = analyzeBundle(
[
{
originalname: 'chain.pem',
buffer: Buffer.from(pki.leafPem + pki.interPem + pki.rootPem),
},
],
'',
);
expect(r.items.map((i) => i.role)).toEqual(['end-entity', 'intermediate', 'root']);
expect(r.items[0].chainIds).toHaveLength(2);
});
it('ohne Dateien -> 400', () => {
expect(() => analyzeBundle([], '')).toThrow(/No files/);
});
it('ZIP mit zu vielen Dateien -> 400', () => {
const zip = new AdmZip();
for (let i = 0; i < 101; i++) zip.addFile(`f${i}.txt`, Buffer.from('x'));
expect(() =>
analyzeBundle([{ originalname: 'gross.zip', buffer: zip.toBuffer() }], ''),
).toThrow(/too many files/);
});
});
describe('exportBundleItem', () => {
function bundle() {
const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'geheim');
const byId = Object.fromEntries(r.items.map((i) => [i.id, i]));
return { items: r.items, byId };
}
const decode = (b64: string) => Buffer.from(b64, 'base64');
it('Zertifikat in jedem Format liest sich wieder ein', () => {
const { items, byId } = bundle();
const leaf = items[0];
const chain = leaf.chainIds.map((id) => byId[id].pem);
const keyPem = byId[leaf.matchId!].pem;
const base = { kind: leaf.kind, pem: leaf.pem, baseName: leaf.baseName, chain, keyPem };
const crt = exportBundleItem({ ...base, format: 'crt' });
expect(crt.filename).toBe('www.example.test.crt');
expect(
forge.pki.certificateFromPem(decode(crt.content).toString()).subject.getField('CN').value,
).toBe('www.example.test');
const cer = exportBundleItem({ ...base, format: 'cer' });
expect(cer.filename).toBe('www.example.test.cer');
forge.pki.certificateFromAsn1(forge.asn1.fromDer(decode(cer.content).toString('binary')));
const full = exportBundleItem({ ...base, format: 'fullchain' });
expect(
decode(full.content)
.toString()
.match(/BEGIN CERTIFICATE/g),
).toHaveLength(2);
const p7b = exportBundleItem({ ...base, format: 'p7b' });
const p7 = forge.pkcs7.messageFromPem(decode(p7b.content).toString());
expect('certificates' in p7 ? p7.certificates : []).toHaveLength(2);
const pfx = exportBundleItem({ ...base, format: 'pfx', password: 'neu' });
expect(pfx.filename).toBe('www.example.test.pfx');
const p12 = forge.pkcs12.pkcs12FromAsn1(
forge.asn1.fromDer(decode(pfx.content).toString('binary')),
'neu',
);
expect(p12.getBags({ bagType: forge.pki.oids.certBag })[forge.pki.oids.certBag]).toHaveLength(
2,
);
const keyBag = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag })[
forge.pki.oids.pkcs8ShroudedKeyBag
]![0];
expect((keyBag.key as forge.pki.rsa.PrivateKey).n.toString(16)).toBe(pki.leafModulus);
});
it('PFX ohne Passwort -> 400', () => {
const { items } = bundle();
expect(() =>
exportBundleItem({ kind: 'certificate', pem: items[0].pem, format: 'pfx' }),
).toThrow(/password is required/);
});
it('Schluessel als PKCS#8, PKCS#1 und DER', () => {
const { items } = bundle();
const key = items.find((i) => i.kind === 'privateKey')!;
const base = { kind: key.kind, pem: key.pem, baseName: key.baseName };
expect(decode(exportBundleItem({ ...base, format: 'key' }).content).toString()).toContain(
'BEGIN PRIVATE KEY',
);
const rsa = exportBundleItem({ ...base, format: 'key-rsa' });
expect(rsa.filename).toBe('www.example.test.rsa.key');
expect(decode(rsa.content).toString()).toContain('BEGIN RSA PRIVATE KEY');
const der = exportBundleItem({ ...base, format: 'key-der' });
const info = forge.asn1.fromDer(decode(der.content).toString('binary'));
expect((forge.pki.privateKeyFromAsn1(info) as forge.pki.rsa.PrivateKey).n.toString(16)).toBe(
pki.leafModulus,
);
});
it('CSR als PEM und DER', () => {
const { items } = bundle();
const csr = items.find((i) => i.kind === 'csr')!;
const der = exportBundleItem({
kind: 'csr',
pem: csr.pem,
baseName: csr.baseName,
format: 'csr-der',
});
expect(der.filename).toBe('www.example.test.csr.der');
forge.pki.certificationRequestFromAsn1(
forge.asn1.fromDer(decode(der.content).toString('binary')),
);
});
it('unpassendes Format -> 400', () => {
const { items } = bundle();
expect(() =>
exportBundleItem({ kind: 'csr', pem: items[3].pem, format: 'pfx', password: 'x' }),
).toThrow();
expect(() =>
exportBundleItem({ kind: 'privateKey', pem: 'kein pem', format: 'key-rsa' }),
).toThrow(/Failed to export/);
});
});
describe('safeBaseName', () => {
it('Platzhalter, Leerzeichen und Pfadteile werden entschaerft', () => {
expect(safeBaseName('*.example.de', 'x')).toBe('wildcard.example.de');
expect(safeBaseName('Encryption Everywhere DV TLS CA - G1', 'x')).toBe(
'Encryption_Everywhere_DV_TLS_CA_-_G1',
);
expect(safeBaseName('../../etc/passwd', 'x')).toBe('etc_passwd');
expect(safeBaseName('', 'fallback')).toBe('fallback');
});
});
+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 });
}
}
@@ -64,4 +64,12 @@ describe('UpdateCustomModuleDto', () => {
expect(await errorsFor(UpdateCustomModuleDto, { category: 'other' })).toContain('category');
expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name');
});
it.each([
'name',
'url',
'category',
])('lehnt %s: null ab statt es durchzulassen', async (field) => {
expect(await errorsFor(UpdateCustomModuleDto, { [field]: null })).toContain(field);
});
});
@@ -78,7 +78,11 @@ export class CreateCustomModuleDto {
* `shared` ist ausgenommen — ob ein Eintrag gemeinsam oder persoenlich ist,
* aendert sich nach dem Anlegen nicht (die globale Pipe verwirft das Feld
* dank `whitelist: true`).
* `skipNullProperties: false`: fehlende Felder bleiben unveraendert, ein
* ausdrueckliches `null` wird aber geprueft und damit abgelehnt (400) — sonst
* liefe `{"name": null}` bis in die Datenbank und endete als 500.
*/
export class UpdateCustomModuleDto extends PartialType(
OmitType(CreateCustomModuleDto, ['shared'] as const),
{ skipNullProperties: false },
) {}
@@ -21,7 +21,11 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
}),
}));
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
import { DashboardImagesService } from './dashboard-images.service';
@@ -73,11 +77,21 @@ interface BoundCall {
type ModelMethods = Record<string, (...args: unknown[]) => Promise<unknown>>;
interface UserRow {
id: string;
dashboardBackground: unknown;
}
interface FakePrisma {
dashboardImage: ModelMethods;
user: ModelMethods;
__rows: ImageRow[];
__users: UserRow[];
__boundCallLog: BoundCall[];
__makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods };
__makeBoundClient(
tenantId: string,
userId?: string,
): { dashboardImage: ModelMethods; user: ModelMethods };
}
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
@@ -147,8 +161,28 @@ function pick(row: ImageRow, select: Record<string, boolean> | undefined) {
return out;
}
function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
function makeFakePrisma(rows: ImageRow[] = [], users: UserRow[] = []): FakePrisma {
const boundCallLog: BoundCall[] = [];
// quick-260930: Hintergrund-Wahl (`User.dashboardBackground`) — bedingtes
// updateMany ueber den JSON-Pfad `imageId`, wie Prisma es auf PostgreSQL filtert.
const user: ModelMethods = {
updateMany: vi.fn(async (raw: unknown) => {
const args = raw as {
where: { id: string; dashboardBackground: { path: string[]; equals: unknown } };
data: { dashboardBackground: unknown };
};
const [key] = args.where.dashboardBackground.path;
let count = 0;
for (const u of users) {
const bg = u.dashboardBackground as Record<string, unknown> | null;
if (u.id !== args.where.id || !bg || bg[key] !== args.where.dashboardBackground.equals)
continue;
u.dashboardBackground = args.data.dashboardBackground;
count++;
}
return { count };
}),
};
const dashboardImage: ModelMethods = {
findMany: vi.fn(async (raw: unknown) => {
const args = raw as {
@@ -168,11 +202,17 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
}),
count: vi.fn(async (raw: unknown) => {
const args = raw as { where: { tenantId: string; userId: string } };
return rows.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId).length;
return rows.filter(
(r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId,
).length;
}),
create: vi.fn(async (raw: unknown) => {
const args = raw as { data: Partial<ImageRow>; select?: Record<string, boolean> };
const created = makeRow({ id: `new-${rows.length + 1}`, ...args.data, createdAt: new Date('2026-02-02') });
const created = makeRow({
id: `new-${rows.length + 1}`,
...args.data,
createdAt: new Date('2026-02-02'),
});
rows.push(created);
return pick(created, args.select);
}),
@@ -196,20 +236,29 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
}),
};
function wrap(tenantId: string, userId?: string) {
function wrapModel(model: string, methods: ModelMethods, tenantId: string, userId?: string) {
const wrapped: ModelMethods = {};
for (const method of Object.keys(dashboardImage)) {
for (const method of Object.keys(methods)) {
wrapped[method] = async (...args: unknown[]) => {
boundCallLog.push({ tenantId, userId, model: 'dashboardImage', method });
return dashboardImage[method](...args);
boundCallLog.push({ tenantId, userId, model, method });
return methods[method](...args);
};
}
return { dashboardImage: wrapped };
return wrapped;
}
function wrap(tenantId: string, userId?: string) {
return {
dashboardImage: wrapModel('dashboardImage', dashboardImage, tenantId, userId),
user: wrapModel('user', user, tenantId, userId),
};
}
const fake: FakePrisma = {
dashboardImage,
user,
__rows: rows,
__users: users,
__boundCallLog: boundCallLog,
__makeBoundClient(tenantId: string, userId?: string) {
return wrap(tenantId, userId);
@@ -253,9 +302,17 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
const result = await makeService(prisma).list('user-1', 'tenant-1');
expect(result.map((r) => r.id)).toEqual(['a', 'b']);
for (const r of result) {
expect(Object.keys(r).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']);
expect(Object.keys(r).sort()).toEqual([
'createdAt',
'id',
'mimeType',
'originalName',
'size',
]);
}
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as { select: Record<string, boolean> };
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as {
select: Record<string, boolean>;
};
expect(call.select.data).toBeUndefined();
expect(call.select.storagePath).toBeUndefined();
});
@@ -274,14 +331,22 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
expect(result.mimeType).toBe('image/png');
expect(result.originalName).toBe('irgendwas.txt');
expect(result.size).toBe(PNG.length);
expect(Object.keys(result).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']);
expect(Object.keys(result).sort()).toEqual([
'createdAt',
'id',
'mimeType',
'originalName',
'size',
]);
expect(prisma.__rows[0].userId).toBe('user-1');
expect(prisma.__rows[0].tenantId).toBe('tenant-1');
});
it('Test 4: Textdatei mit behauptetem image/png scheitert mit deutscher Meldung, nichts wird angelegt', async () => {
const prisma = makeFakePrisma();
await expect(makeService(prisma).upload(user, file(TEXT, 'image/png', 'bild.png'))).rejects.toThrow(
await expect(
makeService(prisma).upload(user, file(TEXT, 'image/png', 'bild.png')),
).rejects.toThrow(
new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'),
);
expect(prisma.dashboardImage.create).not.toHaveBeenCalled();
@@ -304,7 +369,9 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
});
it('Test 6: Zaehler zaehlt nur den eigenen Benutzer im eigenen Mandanten (fremde Zeilen zaehlen nicht)', async () => {
const foreign = Array.from({ length: 30 }, (_, i) => makeRow({ id: `f${i}`, userId: 'user-2' }));
const foreign = Array.from({ length: 30 }, (_, i) =>
makeRow({ id: `f${i}`, userId: 'user-2' }),
);
const prisma = makeFakePrisma(foreign);
await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({
mimeType: 'image/png',
@@ -321,14 +388,20 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => {
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', userId: 'user-2' })]);
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(
NotFoundException,
);
});
it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => {
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', tenantId: 'tenant-2' })]);
const service = makeService(prisma);
await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(
NotFoundException,
);
await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(
NotFoundException,
);
});
it('Test 10: getBytes — eigenes Bild liefert mimeType und die gespeicherten Bytes', async () => {
@@ -347,11 +420,52 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
const service = makeService(prisma);
await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' });
expect(prisma.__rows.map((r) => r.id)).toEqual(['fremd-user', 'fremd-tenant']);
await expect(service.remove('fremd-user', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
await expect(service.remove('fremd-user', 'user-1', 'tenant-1')).rejects.toThrow(
NotFoundException,
);
await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(
NotFoundException,
);
expect(prisma.__rows).toHaveLength(2);
});
it('Test 11b (quick-260930): war das geloeschte Bild der Dashboard-Hintergrund, steht die Wahl danach auf „kein Hintergrund“ — andere Wahlen und andere Benutzer bleiben', async () => {
const users: UserRow[] = [
{ id: 'user-1', dashboardBackground: { kind: 'image', imageId: 'eigen' } },
{ id: 'user-2', dashboardBackground: { kind: 'image', imageId: 'eigen' } },
];
const prisma = makeFakePrisma(
[makeStoredRow({ id: 'eigen' }), makeStoredRow({ id: 'zweites' })],
users,
);
const service = makeService(prisma);
await service.remove('eigen', 'user-1', 'tenant-1');
expect(users[0].dashboardBackground).toEqual({ kind: 'none' });
// nur die eigene Zeile
expect(users[1].dashboardBackground).toEqual({ kind: 'image', imageId: 'eigen' });
const call = vi.mocked(prisma.user.updateMany).mock.calls[0][0];
expect(call).toEqual({
where: { id: 'user-1', dashboardBackground: { path: ['imageId'], equals: 'eigen' } },
data: { dashboardBackground: { kind: 'none' } },
});
// Ein anderes Bild loeschen laesst eine andere Wahl stehen.
users[0].dashboardBackground = { kind: 'preset', id: 'mist' };
await service.remove('zweites', 'user-1', 'tenant-1');
expect(users[0].dashboardBackground).toEqual({ kind: 'preset', id: 'mist' });
});
it('Test 11c (quick-260930): scheitert das Zuruecksetzen der Wahl, ist das Bild trotzdem geloescht (kein Fehler nach aussen)', async () => {
const prisma = makeFakePrisma([makeStoredRow({ id: 'eigen' })]);
vi.mocked(prisma.user.updateMany).mockRejectedValueOnce(new Error('db weg'));
await expect(makeService(prisma).remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({
id: 'eigen',
});
expect(prisma.__rows).toHaveLength(0);
});
it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => {
const prisma = makeFakePrisma([]);
const service = makeService(prisma);
@@ -370,7 +484,17 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
// Stufe 2 vergibt der Dienst die UUID selbst und legt die Zeile gleich
// MIT Pfad an — kein nachtraegliches `update` mehr (m4n).
const methods = prisma.__boundCallLog.map((c) => c.method);
expect(methods).toEqual(['findMany', 'count', 'create', 'findUnique', 'findUnique', 'delete']);
// quick-260930: `remove` setzt zusaetzlich die Hintergrund-Wahl zurueck (user.updateMany).
expect(methods).toEqual([
'findMany',
'count',
'create',
'findUnique',
'findUnique',
'delete',
'updateMany',
]);
expect(prisma.__boundCallLog.at(-1)?.model).toBe('user');
expect(vi.mocked(forSystem)).not.toHaveBeenCalled();
for (const c of prisma.__boundCallLog) {
expect(c.tenantId).toBe('tenant-1');
@@ -387,14 +511,20 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
const onDisk = storedFile('user-1', result.id);
expect(fs.existsSync(onDisk)).toBe(true);
expect(fs.readFileSync(onDisk).equals(PNG)).toBe(true);
expect(prisma.__rows[0].storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
expect(prisma.__rows[0].storagePath).toBe(
`user-files/dashboard-images/user-1/${result.id}.png`,
);
// Die Zeile traegt den Pfad schon beim Anlegen (Pflichtfeld seit Stufe 2),
// die Kennung ist eine vom Dienst vergebene UUID, und Bytes gehen nie in
// die Zeile.
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as { data: Record<string, unknown> };
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as {
data: Record<string, unknown>;
};
expect(createArgs.data.storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
expect(createArgs.data.id).toBe(result.id);
expect(result.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
expect(result.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
expect(createArgs.data).not.toHaveProperty('data');
expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
});
@@ -444,7 +574,10 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
// Eigene Kennung: das Verzeichnis ist ueber alle Tests dieser Datei
// dasselbe, eine von Test 8/10 angelegte `img-1.png` waere sonst da.
const prisma = makeFakePrisma([
makeRow({ id: 'datei-fehlt', storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png' }),
makeRow({
id: 'datei-fehlt',
storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png',
}),
]);
await expect(makeService(prisma).getBytes('datei-fehlt', 'user-1', 'tenant-1')).rejects.toThrow(
NotFoundException,
@@ -456,7 +589,9 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
const onDisk = storedFile('user-1', 'weg');
expect(fs.existsSync(onDisk)).toBe(true);
await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'weg' });
await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({
id: 'weg',
});
expect(prisma.__rows).toHaveLength(0);
expect(fs.existsSync(onDisk)).toBe(false);
});
@@ -1,3 +1,6 @@
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import {
BadRequestException,
Injectable,
@@ -5,12 +8,9 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
import { forTenant } from '../prisma/prisma-tenant.extension';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import {
DASHBOARD_IMAGE_MAX_COUNT,
type DashboardImageMime,
@@ -314,6 +314,15 @@ export class DashboardImagesService {
* Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht.
* Zeile zuerst, Datei danach: ein Fehler beim Entfernen der Datei wird
* protokolliert und geschluckt (T-HK4-04).
*
* quick-260930: War das Bild der Dashboard-Hintergrund des Benutzers
* (`User.dashboardBackground` = `{ kind: 'image', imageId: <diese UUID> }`),
* wird die Wahl im selben Vorgang auf „kein Hintergrund“ gesetzt — sonst
* zeigte sie auf ein Bild, das es nicht mehr gibt. Bedingtes `updateMany`
* (JSON-Pfad `imageId`), damit jede andere Wahl unberuehrt bleibt; nur die
* eigene Zeile (`id: userId`). Ein Fehler dabei wird wie beim Entfernen der
* Datei protokolliert und geschluckt: das Bild ist schon weg, und das Web
* zeigt eine Wahl mit nicht ladbarem Bild ohnehin als „kein Hintergrund“.
*/
async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> {
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
@@ -323,6 +332,19 @@ export class DashboardImagesService {
}
await tenantPrisma.dashboardImage.delete({ where: { id } });
try {
await tenantPrisma.user.updateMany({
where: { id: userId, dashboardBackground: { path: ['imageId'], equals: id } },
data: { dashboardBackground: { kind: 'none' } },
});
} catch (error) {
this.logger.warn(
`Hintergrund-Wahl zum geloeschten Bilderrahmen-Bild ${id} konnte nicht zurueckgesetzt werden: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
const absolute = absoluteImagePath(row.storagePath);
if (absolute !== null) {
try {
+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)
}))
@@ -220,7 +220,11 @@ function expectBoundCall(
}
function makeIconDiscovery(
overrides: Partial<{ discoverFavoriteIconUrl: any; fetchIconBytes: any }> = {},
overrides: Partial<{
discoverFavoriteIconUrl: any;
fetchIconBytes: any;
fetchPublicServiceIconBytes: any;
}> = {},
) {
return {
discoverFavoriteIconUrl:
@@ -229,6 +233,11 @@ function makeIconDiscovery(
fetchIconBytes:
overrides.fetchIconBytes ??
vi.fn(async () => ({ contentType: 'image/png', body: Buffer.from('png') })),
fetchPublicServiceIconBytes:
overrides.fetchPublicServiceIconBytes ??
vi.fn(async () => {
throw new Error('icon service: unknown');
}),
};
}
@@ -511,6 +520,35 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
await expect(service.getIconBytes('t2', 'f1', 'user-a1')).rejects.toThrow(NotFoundException);
});
it('quick-261001-hbi: gespeichertes Symbol scheitert -> Symbol-Dienst mit der Seiten-URL', async () => {
const prisma = makeFakePrisma([baseRow]);
const iconDiscovery = makeIconDiscovery({
fetchIconBytes: vi.fn(async () => {
throw new Error('not an image');
}),
fetchPublicServiceIconBytes: vi.fn(async () => ({
contentType: 'image/png',
body: Buffer.from('ddg'),
})),
});
const service = new FavoritesService(prisma as any, iconDiscovery as any);
const result = await service.getIconBytes('t1', 'f1', 'user-a1');
expect(iconDiscovery.fetchPublicServiceIconBytes).toHaveBeenCalledWith(baseRow.url);
expect(result.body).toEqual(Buffer.from('ddg'));
});
it('quick-261001-hbi: gespeichertes Symbol klappt -> Symbol-Dienst wird nicht gefragt', async () => {
const prisma = makeFakePrisma([baseRow]);
const iconDiscovery = makeIconDiscovery();
const service = new FavoritesService(prisma as any, iconDiscovery as any);
await service.getIconBytes('t1', 'f1', 'user-a1');
expect(iconDiscovery.fetchPublicServiceIconBytes).not.toHaveBeenCalled();
});
it('fetchIconBytes wirft -> HttpException mit Status 502', async () => {
const prisma = makeFakePrisma([baseRow]);
const iconDiscovery = makeIconDiscovery({
+10 -1
View File
@@ -497,7 +497,9 @@ export class FavoritesService {
* Throws NotFoundException (404) if the row doesn't exist, isn't owned
* by the caller, or has neither an uploaded icon nor a stored iconUrl.
* Throws a 502 HttpException if the upstream fetch fails (unreachable,
* timeout, non-image, or SSRF-blocked) -- never returns a placeholder image.
* timeout, non-image, or SSRF-blocked) AND the public icon service fallback
* (quick-261001-hbi, public pages only) has no icon either -- never returns
* a placeholder image.
*/
async getIconBytes(
tenantId: string,
@@ -535,8 +537,15 @@ export class FavoritesService {
try {
return await this.iconDiscovery.fetchIconBytes(link.iconUrl);
} catch {
// quick-261001-hbi: Seite liefert kein abrufbares Symbol (z. B. per
// JavaScript gesetzt) -- einmal beim oeffentlichen Symbol-Dienst fragen,
// nur fuer oeffentlich erreichbare Seiten.
try {
return await this.iconDiscovery.fetchPublicServiceIconBytes(link.url);
} catch {
throw new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY);
}
}
}
}
@@ -18,24 +18,21 @@ vi.mock('undici', () => ({
import { Agent } from 'undici';
import {
discardBody,
IconDiscoveryService,
isPublicHttpUrl,
normalizeUrl,
readTextCapped,
} from './icon-discovery.service';
function mockResponse(options: {
contentType?: string;
body?: ArrayBuffer;
}): Response {
function mockResponse(options: { contentType?: string; body?: ArrayBuffer }): Response {
const body = options.body ?? new ArrayBuffer(10);
return {
ok: true,
status: 200,
headers: {
get: (name: string) =>
name.toLowerCase() === 'content-type'
? (options.contentType ?? 'image/png')
: null,
name.toLowerCase() === 'content-type' ? (options.contentType ?? 'image/png') : null,
},
arrayBuffer: async () => body,
} as unknown as Response;
@@ -106,9 +103,7 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl', () => {
status: 200,
headers: {
get: (n: string) =>
n.toLowerCase() === 'content-type'
? 'text/html; charset=utf-8'
: null,
n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null,
},
text: async () => html,
}),
@@ -164,7 +159,8 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl — Seite mit Fehlerstatu
});
it('Fehlerseite ohne Symbol-Verweis, nur og:image -> Rueckfall <origin>/favicon.ico (og:image einer Fehlerseite zaehlt nicht)', async () => {
const html = '<html><head><meta property="og:image" content="https://cdn.invalid/x.png"></head></html>';
const html =
'<html><head><meta property="og:image" content="https://cdn.invalid/x.png"></head></html>';
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(htmlResponse(404, html)));
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/x');
@@ -173,7 +169,10 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl — Seite mit Fehlerstatu
});
it('fetchIconBytes bleibt streng: Fehlerstatus -> wirft (kein allowErrorStatus fuer Bilder)', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ...htmlResponse(404, ''), headers: { get: () => 'text/html' } }));
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ...htmlResponse(404, ''), headers: { get: () => 'text/html' } }),
);
await expect(
new IconDiscoveryService().fetchIconBytes('http://8.8.8.8/favicon.ico'),
@@ -203,16 +202,13 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
});
it('rejects when Content-Type is not an image', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })),
);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })));
const service = new IconDiscoveryService();
await expect(
service.fetchIconBytes('http://8.8.8.8/favicon.ico'),
).rejects.toThrow(/not an image/);
await expect(service.fetchIconBytes('http://8.8.8.8/favicon.ico')).rejects.toThrow(
/not an image/,
);
});
it('rejects when the SSRF guard blocks the target', async () => {
@@ -221,9 +217,9 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
const service = new IconDiscoveryService();
await expect(
service.fetchIconBytes('http://127.0.0.1/favicon.ico'),
).rejects.toThrow(/blocked or failed/);
await expect(service.fetchIconBytes('http://127.0.0.1/favicon.ico')).rejects.toThrow(
/blocked or failed/,
);
expect(fetchSpy).not.toHaveBeenCalled();
});
@@ -236,9 +232,60 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
const service = new IconDiscoveryService();
await expect(service.fetchIconBytes('http://8.8.8.8/favicon.ico')).rejects.toThrow(
/size limit/,
);
});
});
describe('IconDiscoveryService.fetchPublicServiceIconBytes (quick-261001-hbi)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('fragt fuer eine oeffentliche Seite den Symbol-Dienst mit dem Hostnamen', async () => {
const fetchSpy = vi.fn().mockResolvedValue(mockResponse({ contentType: 'image/png' }));
vi.stubGlobal('fetch', fetchSpy);
const service = new IconDiscoveryService();
const result = await service.fetchPublicServiceIconBytes('http://8.8.8.8/start');
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy.mock.calls[0][0]).toBe('https://icons.duckduckgo.com/ip3/8.8.8.8.ico');
expect(result.contentType).toBe('image/png');
});
it('fragt fuer eine interne Seite NICHT (Hostname verlaesst das Haus nicht)', async () => {
const fetchSpy = vi.fn();
vi.stubGlobal('fetch', fetchSpy);
const service = new IconDiscoveryService();
await expect(
service.fetchIconBytes('http://8.8.8.8/favicon.ico'),
).rejects.toThrow(/size limit/);
service.fetchPublicServiceIconBytes('https://docuvita.ctl.local/x'),
).rejects.toThrow(/not public/);
await expect(service.fetchPublicServiceIconBytes('http://192.168.1.5/')).rejects.toThrow(
/not public/,
);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('Dienst kennt kein Symbol (404) -> wirft', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
...mockResponse({ contentType: 'image/png' }),
ok: false,
status: 404,
}),
);
const service = new IconDiscoveryService();
await expect(service.fetchPublicServiceIconBytes('http://8.8.8.8/')).rejects.toThrow(
/blocked or failed/,
);
});
});
@@ -258,10 +305,7 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', (
});
it('still returns a URL string', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })),
);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })));
const service = new IconDiscoveryService();
const result = await service.discoverFavoriteIconUrl('http://8.8.8.8/page');
@@ -335,3 +379,143 @@ describe('IconDiscoveryService — Dispatcher (260917-jdd)', () => {
expect(calls[0][1].dispatcher).toBe(calls[1][1].dispatcher);
});
});
describe('readTextCapped / discardBody — Groessendeckel beim Lesen (T-08-09)', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
/** Stream aus `chunks` Stuecken je `chunkChars` ASCII-Zeichen; zaehlt gelesene Stuecke und Abbruch. */
function countingStream(chunks: number, chunkChars: number) {
const state = { pulled: 0, cancelled: false };
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (state.pulled >= chunks) {
controller.close();
return;
}
state.pulled += 1;
controller.enqueue(encoder.encode('a'.repeat(chunkChars)));
},
cancel() {
state.cancelled = true;
},
});
return { body, state };
}
it('bricht den Stream nach der Grenze ab statt alles zu lesen', async () => {
const { body, state } = countingStream(1000, 1000);
const text = await readTextCapped({ body, text: async () => 'unbenutzt' } as never, 2500);
expect(text).toHaveLength(2500);
expect(state.pulled).toBeLessThan(10);
expect(state.cancelled).toBe(true);
});
it('gibt nach der Zeitgrenze zurueck, was bis dahin da ist (tropfender Server)', async () => {
let cancelled = false;
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode('<link rel="icon">'));
// danach kommt nichts mehr, der Stream bleibt offen
},
cancel() {
cancelled = true;
},
});
const text = await readTextCapped({ body, text: async () => '' } as never, 200000, 50);
expect(text).toBe('<link rel="icon">');
expect(cancelled).toBe(true);
});
it('liest kurze Seiten vollstaendig, auch Mehrbyte-Zeichen ueber Chunk-Grenzen', async () => {
const bytes = new TextEncoder().encode('<p>Grüße</p>');
const body = new ReadableStream<Uint8Array>({
start(controller) {
// Das "ü" (2 Bytes) wird absichtlich zerteilt.
controller.enqueue(bytes.slice(0, 5));
controller.enqueue(bytes.slice(5));
controller.close();
},
});
const text = await readTextCapped({ body, text: async () => '' } as never, 200000);
expect(text).toBe('<p>Grüße</p>');
});
it('ohne Stream: Rueckfall auf text() mit Deckel', async () => {
const text = await readTextCapped(
{ body: null, text: async () => 'x'.repeat(50) } as never,
10,
);
expect(text).toBe('x'.repeat(10));
});
it('discardBody bricht einen offenen Body ab und vertraegt fehlenden Body', () => {
const { body, state } = countingStream(5, 10);
discardBody({ body } as never);
expect(state.cancelled).toBe(true);
expect(() => discardBody({ body: null } as never)).not.toThrow();
});
it('Discovery: Fehlerstatus ohne HTML-Typ -> Body wird verworfen, Rueckfall favicon.ico', async () => {
const { body, state } = countingStream(5, 10);
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 500,
headers: {
get: (n: string) => (n.toLowerCase() === 'content-type' ? 'application/json' : null),
},
body,
}),
);
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/x');
expect(icon).toBe('http://8.8.8.8/favicon.ico');
expect(state.cancelled).toBe(true);
});
it('Discovery: riesige HTML-Seite wird nur bis zur Grenze gelesen, Symbol am Anfang gefunden', async () => {
const head = '<html><head><link rel="icon" href="/klein.png" /></head><body>';
const encoder = new TextEncoder();
const state = { pulled: 0, cancelled: false };
const body = new ReadableStream<Uint8Array>({
pull(controller) {
state.pulled += 1;
controller.enqueue(encoder.encode(state.pulled === 1 ? head : 'a'.repeat(64 * 1024)));
},
cancel() {
state.cancelled = true;
},
});
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: { get: (n: string) => (n.toLowerCase() === 'content-type' ? 'text/html' : null) },
body,
text: async () => {
throw new Error('text() darf bei vorhandenem Stream nicht laufen');
},
}),
);
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/');
expect(icon).toBe('http://8.8.8.8/klein.png');
expect(state.cancelled).toBe(true);
// 200 000 Zeichen bei 64-KiB-Stuecken: hoechstens eine Handvoll gelesen.
expect(state.pulled).toBeLessThan(10);
});
});
+114 -41
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
import { Agent, fetch as undiciFetch, type Response as UndiciResponse } from 'undici';
import { Injectable } from '@nestjs/common';
import { Agent, type Response as UndiciResponse, fetch as undiciFetch } from 'undici';
/**
* Server-side favicon / icon discovery with SSRF protection (T-08-05).
@@ -25,6 +25,18 @@ const MAX_REDIRECTS = 2;
const MAX_HTML_CHARS = 200000;
const MAX_ICON_BYTES = 1_000_000;
/**
* quick-261001-hbi — oeffentlicher Symbol-Dienst als letzter Rueckfall. Manche
* Seiten setzen ihr Symbol erst per JavaScript (hosteurope.de: im HTML nur
* `<link rel="icon" href="data:;base64,=">`, `/favicon.ico` liefert eine
* HTML-Seite) — ohne Browser findet die Suche dort nichts. DuckDuckGo kennt
* das gerenderte Symbol und antwortet fuer Unbekanntes mit 404 (dann bleibt
* der Buchstabe). Gefragt wird NUR fuer oeffentlich erreichbare Adressen,
* damit interne Hostnamen (docuvita.ctl.local, private IPs) das Haus nie
* verlassen; der Dienst erfaehrt nur den Hostnamen.
*/
const PUBLIC_ICON_SERVICE = 'https://icons.duckduckgo.com/ip3/';
/**
* 260917-jdd — Ziel ist ein Bildchen, kein Geheimnis: selbstsignierte,
* abgelaufene oder falsch benannte Zertifikate sollen das Symbol eines
@@ -58,9 +70,7 @@ function isPrivateIpv4(address: string): boolean {
if (
parts.length !== 4 ||
parts.some(
(part) => !Number.isInteger(part) || part < 0 || part > 255,
)
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
) {
return true;
}
@@ -117,12 +127,7 @@ function isPrivateIpAddress(address: string): boolean {
function isBlockedHostname(hostname: string): boolean {
const h = hostname.trim().toLowerCase();
return (
h === 'localhost' ||
h.endsWith('.localhost') ||
h.endsWith('.local') ||
h === '0.0.0.0'
);
return h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h === '0.0.0.0';
}
export async function isPublicHttpUrl(url: URL): Promise<boolean> {
@@ -204,11 +209,7 @@ function toAbsoluteUrl(value: string | undefined, base: string): string | null {
}
}
function extractIconFromHtml(
html: string,
baseUrl: string,
linkTagsOnly = false,
): string | null {
function extractIconFromHtml(html: string, baseUrl: string, linkTagsOnly = false): string | null {
const linkTags = html.match(/<link\b[^>]*>/gi) ?? [];
const metaTags = html.match(/<meta\b[^>]*>/gi) ?? [];
@@ -220,27 +221,19 @@ function extractIconFromHtml(
}))
.filter((c) => c.href);
const appleTouchIcon = linkCandidates.find((c) =>
c.rel.includes('apple-touch-icon'),
)?.href;
const appleTouchIcon = linkCandidates.find((c) => c.rel.includes('apple-touch-icon'))?.href;
if (appleTouchIcon) return appleTouchIcon;
const icon = linkCandidates.find((c) =>
c.rel.split(/\s+/).includes('icon'),
)?.href;
const icon = linkCandidates.find((c) => c.rel.split(/\s+/).includes('icon'))?.href;
if (icon) return icon;
const shortcutIcon = linkCandidates.find((c) =>
c.rel.includes('shortcut icon'),
)?.href;
const shortcutIcon = linkCandidates.find((c) => c.rel.includes('shortcut icon'))?.href;
if (shortcutIcon) return shortcutIcon;
const imageSrc = linkCandidates.find((c) =>
c.rel.includes('image_src'),
)?.href;
const imageSrc = linkCandidates.find((c) => c.rel.includes('image_src'))?.href;
if (imageSrc) return imageSrc;
@@ -257,9 +250,7 @@ function extractIconFromHtml(
.find(
(c) =>
c.content &&
(c.property === 'og:image' ||
c.property === 'og:logo' ||
c.property === 'twitter:image'),
(c.property === 'og:image' || c.property === 'og:logo' || c.property === 'twitter:image'),
)?.content;
return metaImage ?? null;
@@ -327,6 +318,71 @@ async function fetchWithRedirectGuard(
return null;
}
/** Minimaler Ausschnitt einer Antwort, den die beiden Helfer brauchen. */
type BodyResponse = Pick<UndiciResponse, 'body' | 'text'>;
/**
* Verwirft den Body einer nicht gebrauchten Antwort. Fehler (bereits
* gelesen/abgebrochen) sind egal.
*/
export function discardBody(response: Pick<UndiciResponse, 'body'>): void {
try {
response.body?.cancel().catch(() => {});
} catch {
// Body gesperrt oder schon verbraucht — nichts zu tun.
}
}
/**
* Liest den Antworttext hoechstens bis `maxChars` Zeichen und bricht den
* Stream danach ab (T-08-09). Vorher wurde der komplette Body gelesen und
* erst danach abgeschnitten — eine riesige Seite landete ganz im Speicher.
* Dekodiert wird UTF-8 wie bei `Response.text()`; da jedes Zeichen aus
* mindestens einem Byte entsteht, bleibt der Speicher bei ~maxChars plus
* einem Chunk. Ohne Stream (`body === null`) wie bisher ueber `text()`.
* `timeoutMs` begrenzt zusaetzlich die Lesedauer: die Zeitgrenze von
* `fetchWithRedirectGuard` endet mit den Kopfzeilen, ein Server, der den
* Body tropfenweise liefert, hielte die Anfrage sonst beliebig lange auf.
* Nach Ablauf zaehlt, was bis dahin gelesen ist.
*/
export async function readTextCapped(
response: BodyResponse,
maxChars: number,
timeoutMs = HTML_FETCH_TIMEOUT_MS,
): Promise<string> {
if (!response.body) {
return (await response.text()).slice(0, maxChars);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let text = '';
// cancel() beendet ein haengendes read() mit done: true.
const deadline = setTimeout(() => void reader.cancel().catch(() => {}), timeoutMs);
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
text += decoder.decode();
break;
}
text += decoder.decode(value, { stream: true });
if (text.length >= maxChars) {
await reader.cancel().catch(() => {});
break;
}
}
} finally {
clearTimeout(deadline);
}
return text.slice(0, maxChars);
}
async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
const result = await fetchWithRedirectGuard(pageUrl, {
accept: 'text/html,application/xhtml+xml,*/*',
@@ -340,12 +396,18 @@ async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
const contentType = result.response.headers.get('content-type') ?? '';
if (!contentType.toLowerCase().includes('text/html')) return null;
if (!contentType.toLowerCase().includes('text/html')) {
// Kein HTML (auch bei Fehlerstatus dank allowErrorStatus hier moeglich):
// Body verwerfen, sonst haelt undici die Verbindung bis zum Timeout offen.
discardBody(result.response);
return null;
}
const html = await result.response.text();
// T-08-09: HTML cap — schon beim Lesen, nicht erst nach dem kompletten Body.
const html = await readTextCapped(result.response, MAX_HTML_CHARS);
return {
html: html.slice(0, MAX_HTML_CHARS), // T-08-09: HTML cap
html,
finalUrl: result.finalUrl.toString(),
ok: result.response.ok,
};
@@ -370,15 +432,28 @@ export class IconDiscoveryService {
if (!htmlResult) return fallback;
return (
extractIconFromHtml(htmlResult.html, htmlResult.finalUrl, !htmlResult.ok) ??
fallback
);
return extractIconFromHtml(htmlResult.html, htmlResult.finalUrl, !htmlResult.ok) ?? fallback;
} catch {
return fallback;
}
}
/**
* quick-261001-hbi: Symbol fuer die Seite `pageUrl` beim oeffentlichen
* Symbol-Dienst holen (siehe PUBLIC_ICON_SERVICE). Wirft, wenn die Seite
* nicht oeffentlich erreichbar ist (dann wird der Dienst NICHT gefragt) oder
* der Dienst kein Symbol kennt (404) — wie `fetchIconBytes`.
*/
async fetchPublicServiceIconBytes(
pageUrl: string,
): Promise<{ contentType: string; body: Buffer }> {
const page = new URL(normalizeUrl(pageUrl));
if (!(await isPublicHttpUrl(page))) {
throw new Error('Page is not public, icon service not asked');
}
return this.fetchIconBytes(`${PUBLIC_ICON_SERVICE}${page.hostname}.ico`);
}
/**
* Fetch the raw bytes of a stored icon URL, SSRF-guarded, for streaming
* back to the browser from Tessera's own origin (avoids Cross-Origin-
@@ -388,9 +463,7 @@ export class IconDiscoveryService {
* or an oversized body. Callers must not return a placeholder image; let
* the caller map the failure to an HTTP error status instead.
*/
async fetchIconBytes(
iconUrl: string,
): Promise<{ contentType: string; body: Buffer }> {
async fetchIconBytes(iconUrl: string): Promise<{ contentType: string; body: Buffer }> {
const url = new URL(iconUrl);
const result = await fetchWithRedirectGuard(url, {
@@ -0,0 +1,25 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { describe, expect, it } from 'vitest';
import { CreateModuleGrantDto } from './create-module-grant.dto';
async function errorsFor(plain: Record<string, unknown>) {
const dto = plainToInstance(CreateModuleGrantDto, plain);
const errors = await validate(dto as object);
return errors.map((e) => e.property);
}
describe('CreateModuleGrantDto — Freigabestufe (261002-icv)', () => {
it('ohne level ist gültig', async () => {
expect(await errorsFor({ moduleId: 'm1', groupId: 'g1' })).toEqual([]);
});
it.each(['USE', 'MANAGE'])('level %s ist gültig', async (level) => {
expect(await errorsFor({ moduleId: 'm1', userId: 'u1', level })).toEqual([]);
});
it.each(['ADMIN', 'manage', 'use', '', 1])('level %j wird abgelehnt', async (level) => {
expect(await errorsFor({ moduleId: 'm1', userId: 'u1', level })).toContain('level');
});
});
@@ -1,4 +1,5 @@
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { ModuleGrantLevel } from '@prisma/client';
import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
/**
* DTO für Grant-Erstellung und -Entzug (PERM-03).
@@ -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;
}
+50 -2
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import * as nodemailer from 'nodemailer';
import { MailService } from './mail.service';
import { MailService, WELCOME_HEADER_CID } from './mail.service';
/**
* MailService.spec — NEU (260914-eym, Etappe 3c, WINDOWS #30). Der Bereich
@@ -166,7 +166,11 @@ describe('MailService — Transport je Versand nach Mandant des Empfaengers (260
const service = new MailService(settings as any, makeFakeConfig({}) as any);
await service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-a', 't1');
await service.sendWelcomeEmail('bob@b.example.invalid', 'bob', 't2');
await service.sendWelcomeMail('t2', 'bob@b.example.invalid', {
subject: 'Willkommen bei Tessera',
text: 'Guten Tag bob,',
html: '<p>bob</p>',
});
expect(settings.getDecryptedSmtpConfig.mock.calls.map((c) => c[0])).toEqual(['t1', 't2']);
const transports = vi.mocked(nodemailer.createTransport).mock.calls.map((c) => c[0] as any);
@@ -314,3 +318,47 @@ describe('MailService.sendReminderEmail (quick-260929-if2, E-04/E-07, T-IF2-05)'
expect(mockClose).toHaveBeenCalledTimes(1);
});
});
describe('MailService.sendWelcomeMail / hasConfiguredTransport (Willkommensmail)', () => {
it('haengt das Kopfbild als CID-Anhang an, reicht HTML und Text durch und wirft Transportfehler nach aussen', async () => {
const service = new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any);
await service.sendWelcomeMail('t1', 'neu@a.example.invalid', {
subject: 'Willkommen bei Tessera',
text: 'Text',
html: `<img src="cid:${WELCOME_HEADER_CID}">`,
});
const sent = mockSendMail.mock.calls[0][0] as any;
expect(sent.from).toBe('noreply@a.example.invalid');
expect(sent.html).toContain(`cid:${WELCOME_HEADER_CID}`);
expect(sent.text).toBe('Text');
expect(sent.attachments).toHaveLength(1);
expect(sent.attachments[0].cid).toBe(WELCOME_HEADER_CID);
expect(sent.attachments[0].contentType).toBe('image/png');
// PNG-Signatur: das Bild aus apps/api/assets/mail ist wirklich geladen.
expect((sent.attachments[0].content as Buffer).subarray(1, 4).toString()).toBe('PNG');
mockSendMail = vi.fn(async () => {
throw new Error('ECONNREFUSED');
});
await expect(
service.sendWelcomeMail('t1', 'neu@a.example.invalid', { subject: 'S', text: 'T', html: 'H' }),
).rejects.toThrow('ECONNREFUSED');
});
it('hasConfiguredTransport: SmtpConfig des Mandanten oder MAIL_HOST/TESSERA_SMTP_HOST — der Rueckfall localhost:1025 zaehlt nicht', async () => {
expect(
await new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any).hasConfiguredTransport('t1'),
).toBe(true);
expect(
await new MailService(makeFakeSettings({}) as any, makeFakeConfig({ MAIL_HOST: 'smtp.example.invalid' }) as any).hasConfiguredTransport('t1'),
).toBe(true);
expect(
await new MailService(makeFakeSettings({}) as any, makeFakeConfig({ TESSERA_SMTP_HOST: 'legacy.example.invalid' }) as any).hasConfiguredTransport('t1'),
).toBe(true);
expect(
await new MailService(makeFakeSettings({}) as any, makeFakeConfig({}) as any).hasConfiguredTransport('t1'),
).toBe(false);
});
});
+83 -40
View File
@@ -1,5 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
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 { SettingsService } from '../settings/settings.service';
@@ -45,15 +47,43 @@ import { SettingsService } from '../settings/settings.service';
* Seit quick-260914-m97 (Fehler-melden-Knopf) ist der Versandkern
* `deliver` herausgeloest: er WIRFT bei Transportfehlern und kennt
* Anhaenge. `sendViaTenantTransport` bleibt der verschluckende Mantel fuer
* Kennwort-Reset und Willkommensmail (T-02-12 unveraendert); `sendBugReport`
* ruft den Kern direkt, damit der Anwender erfaehrt, ob sein Bericht ankam.
* den Kennwort-Reset (T-02-12 unveraendert); `sendBugReport` und
* `sendWelcomeMail` rufen den Kern direkt, damit der Ausloesende erfaehrt,
* ob die Mail ging.
*/
/** Inhaltskennung des Kopfbilds der Willkommensmail (`<img src="cid:...">`). */
export const WELCOME_HEADER_CID = 'welcome-header@tessera';
let welcomeHeaderCache: Buffer | null | undefined;
/**
* Laedt das Kopfbild der Willkommensmail einmal je Prozess
* (apps/api/assets/mail/welcome-header.png, erzeugt von
* scripts/render-mail-header.mjs). Zur Laufzeit liegt diese Datei unter
* dist/mail/, im Test unter src/mail/ — beide Male zwei Ebenen unter
* apps/api. Fehlt das Bild, liefert die Funktion `null`; die Mail zeigt
* dann einen dunklen Textkopf statt abzubrechen.
*/
export function loadWelcomeHeaderPng(): Buffer | null {
if (welcomeHeaderCache !== undefined) return welcomeHeaderCache;
const file = path.resolve(__dirname, '..', '..', 'assets', 'mail', 'welcome-header.png');
try {
welcomeHeaderCache = fs.readFileSync(file);
} catch {
new Logger('MailService').warn(`Welcome header image missing: ${file}`);
welcomeHeaderCache = null;
}
return welcomeHeaderCache;
}
/** Anhang in der nodemailer-Form (`attachments` von `sendMail`). */
export interface OutgoingAttachment {
filename: string;
content: Buffer;
contentType: string;
/** Inhaltskennung fuer eingebettete Bilder (`<img src="cid:...">`). */
cid?: string;
}
/** Eine ausgehende Mail, wie `deliver` sie an nodemailer reicht. */
@@ -186,10 +216,10 @@ export class MailService {
}
/**
* Verschluckender Mantel um `deliver` fuer Kennwort-Reset und
* Willkommensmail: Fehler werden protokolliert, nie geworfen — der
* Anmeldeweg antwortet weiter 200, keine E-Mail-Enumeration (T-02-12
* bleibt fuer genau diese beiden Wege bestehen).
* Verschluckender Mantel um `deliver` fuer den Kennwort-Reset: Fehler
* werden protokolliert, nie geworfen — der Anmeldeweg antwortet weiter
* 200, keine E-Mail-Enumeration (T-02-12). Die Willkommensmail benutzt
* ihn seit dem Versand aus der Benutzerverwaltung nicht mehr.
*/
private async sendViaTenantTransport(
tenantId: string,
@@ -275,46 +305,59 @@ export class MailService {
}
/**
* Send a welcome email to a newly created user (optional — derzeit ohne
* Aufrufer, gemessen 260914-eym; bleibt als Pfad ueber denselben
* Transport je Versand erhalten).
* Willkommensmail aus der Benutzerverwaltung (Administrator → Benutzer,
* "Willkommensmail senden"). Inhalt und HTML baut
* `renderWelcomeMail` (welcome-mail.template.ts), die Entscheidung ueber
* den Anmeldehinweis trifft `WelcomeMailService`. Diese Methode haengt
* nur das Kopfbild als CID-Anhang an (`WELCOME_HEADER_CID`, kein
* Nachladen von aussen) und versendet ueber den Transport des Mandanten
* des EMPFAENGERS.
*
* @param tenantId - Mandant des Empfaengers (entscheidet ueber den SMTP-Transport)
* Fehler gehen BEWUSST nach aussen (wie `sendBugReport`): ein
* Administrator loest den Versand gezielt aus und muss erfahren, ob die
* Mail ging — es gibt hier keinen Anmeldeweg, den eine Fehlermeldung
* verraten koennte (T-02-12 betrifft nur Kennwort-Reset).
*/
async sendWelcomeEmail(
email: string,
username: string,
async sendWelcomeMail(
tenantId: string,
locale: string = 'de',
to: string,
mail: { subject: string; text: string; html: string },
): Promise<void> {
const isGerman = locale === 'de';
const subject = isGerman
? 'Willkommen bei Tessera'
: 'Welcome to Tessera';
const text = isGerman
const header = loadWelcomeHeaderPng();
await this.deliver(
tenantId,
{
to,
...mail,
attachments: header
? [
`Hallo ${username},`,
'',
'Ihr Tessera-Account wurde erstellt.',
'',
`Sie können sich unter ${this.appUrl}/login anmelden.`,
'',
'Mit freundlichen Grüßen,',
'Ihr Tessera-Team',
].join('\n')
: [
`Hello ${username},`,
'',
'Your Tessera account has been created.',
'',
`You can sign in at ${this.appUrl}/login.`,
'',
'Best regards,',
'The Tessera Team',
].join('\n');
{
filename: 'tessera.png',
content: header,
contentType: 'image/png',
cid: WELCOME_HEADER_CID,
},
]
: undefined,
},
'Welcome',
);
}
await this.sendViaTenantTransport(tenantId, { to: email, subject, text }, 'Welcome');
/**
* Gibt an, ob fuer den Mandanten ein Versandweg eingerichtet ist: eine
* eigene SmtpConfig ODER ein per Umgebung gesetzter Server (MAIL_HOST /
* TESSERA_SMTP_HOST). Der letzte Rueckfall `localhost:1025` (Mailhog in
* der Entwicklung) zaehlt NICHT — sonst saehe die Oberflaeche einen
* Versandweg, der im Betrieb ins Leere geht.
*/
async hasConfiguredTransport(tenantId: string): Promise<boolean> {
const smtpConfig = await this.settingsService.getDecryptedSmtpConfig(tenantId);
if (smtpConfig) return true;
const envHost =
this.configService.get<string>('MAIL_HOST') ??
this.configService.get<string>('TESSERA_SMTP_HOST');
return typeof envHost === 'string' && envHost.trim() !== '';
}
/**
@@ -0,0 +1,213 @@
import { DEFAULT_WELCOME_MAIL_TEXTS, findUnknownWelcomeMailPlaceholders } from '@tessera/shared';
import { describe, expect, it } from 'vitest';
import {
applyWelcomeMailPlaceholders,
firstNameOf,
renderWelcomeMail,
type WelcomeMailInput,
} from './welcome-mail.template';
/**
* Willkommensmail mit eigener Vorlage: Platzhalter, Escaping, Absaetze,
* feste Bausteine.
*/
const base: WelcomeMailInput = {
name: 'Max Muster',
username: 'max.muster',
email: 'max@example.invalid',
tenantName: 'Muster & Co',
appUrl: 'https://tessera.example.invalid/',
account: { kind: 'directory' },
headerImageSrc: null,
};
/** Standard-Anmeldehinweise, damit Vorlagen im Test vollstaendig sind. */
const hints = {
loginHintDirectory: DEFAULT_WELCOME_MAIL_TEXTS.loginHintDirectory,
loginHintLocal: DEFAULT_WELCOME_MAIL_TEXTS.loginHintLocal,
};
const localAccount = {
kind: 'local',
setPasswordUrl: 'https://tessera.example.invalid/reset-password/abc',
validHours: 168,
} as const;
const values = {
name: 'Max Muster',
vorname: 'Max',
benutzername: 'max.muster',
email: 'max@example.invalid',
adresse: 'https://t.example.invalid',
firma: 'Muster & Co',
};
describe('Platzhalter', () => {
it('ersetzt alle sechs, Gross-/Kleinschreibung und Leerraum egal; unbekannte bleiben stehen', () => {
expect(
applyWelcomeMailPlaceholders(
'{{name}}|{{vorname}}|{{benutzername}}|{{email}}|{{adresse}}|{{firma}}|{{ NAME }}|{{xyz}}',
values,
),
).toBe(
'Max Muster|Max|max.muster|max@example.invalid|https://t.example.invalid|Muster & Co|Max Muster|{{xyz}}',
);
});
it('findUnknownWelcomeMailPlaceholders nennt jeden unbekannten einmal', () => {
expect(findUnknownWelcomeMailPlaceholders('{{a}} {{name}} {{a}} {{ b }} {{}}')).toEqual([
'{{a}}',
'{{b}}',
'{{}}',
]);
expect(findUnknownWelcomeMailPlaceholders(DEFAULT_WELCOME_MAIL_TEXTS.heading)).toEqual([]);
});
it('vorname: erstes Wort des Anzeigenamens, sonst Benutzername', () => {
expect(firstNameOf(' Erika Mustermann ', 'erika')).toBe('Erika');
expect(firstNameOf(null, 'erika')).toBe('erika');
expect(firstNameOf(' ', 'erika')).toBe('erika');
});
});
describe('renderWelcomeMail mit Vorlage', () => {
it('ohne Vorlage: Standard-Betreff und -Ueberschrift', () => {
const mail = renderWelcomeMail(base);
expect(mail.subject).toBe('Willkommen bei Tessera');
expect(mail.html).toContain('Willkommen bei Tessera, Max Muster!');
expect(mail.html).toContain('Viel Erfolg mit Tessera!');
});
it('Text ist reiner Text: HTML aus Vorlage und Werten wird escaped; Firma mit & korrekt', () => {
const mail = renderWelcomeMail({
...base,
name: '<script>x</script>',
texts: {
subject: 'Hallo\r\n{{firma}}',
heading: '<h2>{{name}}</h2>',
intro: '<a href="javascript:alert(1)">klick</a>',
...hints,
closing: '',
},
});
expect(mail.subject).toBe('Hallo Muster & Co');
expect(mail.html).not.toContain('<script>x</script>');
expect(mail.html).not.toContain('<h2>');
expect(mail.html).not.toContain('href="javascript:');
expect(mail.html).toContain('&lt;h2&gt;&lt;script&gt;x&lt;/script&gt;&lt;/h2&gt;');
expect(mail.html).toContain('<title>Hallo Muster &amp; Co</title>');
});
it('Leerzeile = neuer Absatz, einfacher Umbruch = <br>; leerer Abschluss faellt weg', () => {
const mail = renderWelcomeMail({
...base,
texts: { subject: 's', heading: 'h', intro: 'A1\nA2\r\n\r\n\nB', ...hints, closing: ' ' },
});
expect(mail.html).toContain('>A1<br>A2</p>');
expect(mail.html).toContain('>B</p>');
expect(mail.html).not.toContain('Viel Erfolg');
expect(mail.text).toContain('A1\nA2\n\nB');
});
it('feste Bausteine bleiben bei jeder Vorlage: Adresse, Benutzername, Anmeldehinweis, Zu Tessera, Fusszeile', () => {
const mail = renderWelcomeMail({
...base,
account: {
kind: 'local',
setPasswordUrl: 'https://tessera.example.invalid/reset-password/abc',
validHours: 168,
},
texts: { subject: 'x', heading: 'y', intro: '', ...hints, closing: '' },
});
expect(mail.html).toContain('https://tessera.example.invalid/reset-password/abc');
expect(mail.html).toContain('Passwort festlegen');
expect(mail.html).toContain('7 Tage');
expect(mail.html).toContain('Zu Tessera');
expect(mail.html).toContain('max.muster');
expect(mail.html).toContain('im Auftrag Ihres Administrators');
});
it('local-example: Knopf zur Anmeldeseite mit Beispiel-Hinweis, kein reset-password', () => {
const mail = renderWelcomeMail({
...base,
account: { kind: 'local-example' },
notice: 'Testmail',
});
expect(mail.html).toContain('Beispiel-Link');
expect(mail.html).not.toContain('reset-password');
expect(mail.html).toContain('Testmail');
expect(mail.text.startsWith('[Testmail]')).toBe(true);
});
});
describe('Anmeldehinweis je Kontoart', () => {
it('Standard: Verzeichniskonto bekommt den Windows-Hinweis, lokales Konto den Hinweis vor dem Knopf', () => {
const dir = renderWelcomeMail(base);
expect(dir.html).toContain('gewohnten Windows-Passwort');
expect(dir.html).not.toContain('persönliches Passwort fest');
const local = renderWelcomeMail({ ...base, account: localAccount });
expect(local.html).toContain('legen Sie bitte Ihr persönliches Passwort fest');
expect(local.html).not.toContain('Windows-Passwort');
// Hinweis steht vor dem Knopf
expect(local.html.indexOf('persönliches Passwort fest')).toBeLessThan(
local.html.indexOf('Passwort festlegen</a>'),
);
});
it('eigener Text je Kontoart: Platzhalter ersetzt, escaped, Leerzeile = Absatz; Knopf und Gueltigkeit bleiben fest', () => {
const texts = {
...DEFAULT_WELCOME_MAIL_TEXTS,
loginHintDirectory:
'Anmeldung als {{benutzername}} mit <b>Firmenkennwort</b>.\n\nFragen? IT anrufen.',
loginHintLocal: 'Hallo {{vorname}}, bitte zuerst ein Passwort setzen.',
};
const dir = renderWelcomeMail({ ...base, texts });
expect(dir.html).toContain(
'Anmeldung als max.muster mit &lt;b&gt;Firmenkennwort&lt;/b&gt;.</p>',
);
expect(dir.html).toContain('>Fragen? IT anrufen.</p>');
expect(dir.html).not.toContain('Windows-Passwort');
expect(dir.html).not.toContain('bitte zuerst ein Passwort setzen');
expect(dir.text).toContain(
'Anmeldung als max.muster mit <b>Firmenkennwort</b>.\n\nFragen? IT anrufen.',
);
const local = renderWelcomeMail({ ...base, account: localAccount, texts });
expect(local.html).toContain('Hallo Max, bitte zuerst ein Passwort setzen.');
expect(local.html).not.toContain('Firmenkennwort');
expect(local.html).toContain('reset-password/abc');
expect(local.html).toContain('7 Tage');
expect(local.text).toContain('Hallo Max, bitte zuerst ein Passwort setzen.');
const example = renderWelcomeMail({ ...base, account: { kind: 'local-example' }, texts });
expect(example.html).toContain('Hallo Max, bitte zuerst ein Passwort setzen.');
});
it('leerer oder fehlender Hinweis (aeltere Aufrufer) → Standardtext', () => {
const { loginHintDirectory: _d, loginHintLocal: _l, ...old } = DEFAULT_WELCOME_MAIL_TEXTS;
const dir = renderWelcomeMail({ ...base, texts: old as any });
expect(dir.html).toContain('gewohnten Windows-Passwort');
const local = renderWelcomeMail({
...base,
account: localAccount,
texts: { ...DEFAULT_WELCOME_MAIL_TEXTS, loginHintLocal: ' ' },
});
expect(local.html).toContain('persönliches Passwort fest');
});
it('local-no-link behaelt den festen Hinweis', () => {
const mail = renderWelcomeMail({ ...base, account: { kind: 'local-no-link' } });
expect(mail.html).toContain('Ihr Startpasswort erhalten Sie von Ihrem Administrator.');
});
});
describe('Kopf (Outlook ohne CID-Bild)', () => {
it('Wellenstreifen 600x40 in einer Zelle mit dunkler Kopffarbe, Inhalt beginnt mit 24px Abstand', () => {
const mail = renderWelcomeMail({ ...base, headerImageSrc: 'cid:welcome-header@tessera' });
expect(mail.html).toMatch(
/<td bgcolor="#1a1c20" style="background-color:#1a1c20;line-height:0;font-size:0;">\n<img src="cid:welcome-header@tessera" width="600" height="40"/,
);
expect(mail.html).toContain('padding:24px 40px 12px;');
});
});
+419
View File
@@ -0,0 +1,419 @@
/**
* welcome-mail.template.ts — Inhalt und Gestaltung der Willkommensmail.
*
* Reine Funktion ohne Abhaengigkeiten: bekommt die fertigen Werte
* (Anzeigename, Benutzername, Adresse, Anmeldeweg) und liefert Betreff,
* Text-Alternative und HTML. Versand und Kopfbild-Anhang erledigt
* `MailService.sendWelcomeMail`, die Entscheidung "wer bekommt welchen
* Anmeldehinweis" `WelcomeMailService`.
*
* Eigene Vorlage (Administrator → Willkommensmail): Betreff, Ueberschrift,
* Einleitung, die zwei Anmeldehinweise (Verzeichniskonto / lokales Konto)
* und Abschluss kommen als `texts` herein (Vorlage des Mandanten
* des ZIEL-Benutzers, sonst `DEFAULT_WELCOME_MAIL_TEXTS` aus
* `@tessera/shared`). Platzhalter (`{{name}}`, `{{vorname}}`,
* `{{benutzername}}`, `{{email}}`, `{{adresse}}`, `{{firma}}`) werden auf dem
* REINEN Text ersetzt und erst danach escaped. Leerzeile = neuer Absatz,
* einfacher Umbruch = `<br>`. Alles andere bleibt fest.
*
* E-Mail-tauglich gebaut, weil Outlook (Word-Darstellung), Gmail und Apple
* Mail sehr unterschiedlich darstellen:
* - Tabellenlayout, alle Stile inline, hoechstens 600 px breit;
* - keine externen Ressourcen, keine Web-Fonts (Systemschriften);
* - Kopf: Bildmarke und Schriftzug als HTML (erscheinen immer), darunter
* die Duenen-Welle als schmaler PNG-Streifen (`headerImageSrc`, im
* Versand `cid:`), weil SVG und CSS-Hintergruende in Outlook nicht
* erscheinen; fehlt das Bild, bleibt nur ein schmaler Abschluss;
* - Knoepfe als Tabelle mit Hintergrundfarbe in der Zelle ("bulletproof"),
* Outlook ignoriert Innenabstaende und Rundungen am Link selbst.
*
* Sicherheit: jeder eingesetzte Wert laeuft durch `escapeHtml`; Links werden
* nur als http(s) uebernommen. Ein Kennwort steht NIE in der Mail — lokale
* Konten bekommen einen Link zum Festlegen (Token wie beim
* "Passwort vergessen"-Weg), verzeichnisgefuehrte den Hinweis auf das
* Windows-Passwort.
*/
import {
DEFAULT_WELCOME_MAIL_TEXTS,
isWelcomeMailPlaceholder,
WELCOME_MAIL_PLACEHOLDER_RE,
type WelcomeMailPlaceholder,
type WelcomeMailTexts,
} from '@tessera/shared';
/** Farben aus dem Design "Mosaik" (globals.css / brand.ts). */
const C = {
page: '#eceef1',
card: '#ffffff',
ink: '#1a1d21',
body: '#3d4450',
muted: '#6b7280',
line: '#e3e5e9',
well: '#f7f7f5',
yellow: '#ffed00',
link: '#1d5fc2',
header: '#1a1c20',
} as const;
const FONT =
"'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', Arial, sans-serif";
/** Anmeldeweg des Empfaengers — entscheidet den Hinweis in der Mail. */
export type WelcomeMailAccount =
| { kind: 'directory' }
| { kind: 'local'; setPasswordUrl: string; validHours: number }
/**
* Lokales Konto ohne Link. Wird heute nirgends erzeugt (`WelcomeMailService`
* legt fuer lokale Konten immer einen Token an); deshalb bleibt ihr Hinweis
* fest und ist nicht Teil der Vorlage.
*/
| { kind: 'local-no-link' }
/**
* Nur Testmail aus Administrator → Willkommensmail: derselbe Baustein wie
* `local`, aber OHNE Token — der Knopf fuehrt zur Anmeldeseite und ein
* Hinweis sagt, was er in der echten Mail tut.
*/
| { kind: 'local-example' };
export interface WelcomeMailInput {
/** Anzeigename, sonst Benutzername. */
name: string;
username: string;
/** E-Mail-Adresse des Empfaengers (Platzhalter `{{email}}`). */
email: string;
/** Name des Mandanten (Platzhalter `{{firma}}`). */
tenantName: string;
/** Oeffentliche Basisadresse der Web-Oberflaeche, ohne abschliessenden Schraegstrich. */
appUrl: string;
account: WelcomeMailAccount;
/** `cid:...` im Versand, `data:` in der Vorschau, `null` = Textkopf. */
headerImageSrc: string | null;
/** Eigene Vorlage des Mandanten; fehlt sie, gelten die Standardtexte. */
texts?: WelcomeMailTexts | null;
/** Hinweisleiste ganz oben (nur Testmail), reiner Text. */
notice?: string | null;
}
export interface RenderedWelcomeMail {
subject: string;
text: string;
html: string;
}
export const WELCOME_MAIL_SUBJECT = DEFAULT_WELCOME_MAIL_TEXTS.subject;
const FOOTER = 'Diese E-Mail wurde von Tessera im Auftrag Ihres Administrators versendet.';
const EXAMPLE_LINK_NOTE =
'Beispiel-Link: In der echten Willkommensmail führt dieser Knopf zu einem persönlichen Link, mit dem der neue Benutzer sein Passwort festlegt. In dieser Testmail öffnet er nur die Anmeldeseite.';
/** Werte der Platzhalter, noch NICHT escaped (Ersetzung laeuft auf reinem Text). */
export type WelcomeMailPlaceholderValues = Record<WelcomeMailPlaceholder, string>;
/** Erstes Wort des Anzeigenamens, sonst der Benutzername. */
export function firstNameOf(displayName: string | null | undefined, username: string): string {
const first = (displayName ?? '').trim().split(/\s+/)[0];
return first || username;
}
/**
* Setzt die Platzhalter in einen REINEN Text ein (Gross-/Kleinschreibung
* egal). Unbekannte bleiben stehen — gespeicherte Vorlagen enthalten keine,
* das prueft die API beim Speichern. Das Ergebnis ist weiter reiner Text und
* wird erst beim Einbau ins HTML escaped.
*/
export function applyWelcomeMailPlaceholders(
text: string,
values: WelcomeMailPlaceholderValues,
): string {
return text.replace(WELCOME_MAIL_PLACEHOLDER_RE, (whole, name: string) =>
isWelcomeMailPlaceholder(name) ? values[name.toLowerCase() as WelcomeMailPlaceholder] : whole,
);
}
/** Zeilenenden vereinheitlichen, Leerraum an den Raendern entfernen. */
function normalizeText(value: string): string {
return value.replace(/\r\n?/g, '\n').trim();
}
/** Einzeilig (Betreff, Ueberschrift): jeder Umbruch/Leerraum-Lauf wird ein Leerzeichen. */
function singleLine(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
/** Absaetze: Leerzeile trennt, einfacher Umbruch bleibt im Absatz. */
function paragraphsOf(value: string): string[] {
const text = normalizeText(value);
if (!text) return [];
return text
.split(/\n[ \t]*\n+/)
.map((part) => part.trim())
.filter(Boolean);
}
export function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/** Nur http(s)-Adressen gelangen in ein href; alles andere wird leer. */
function safeUrl(value: string): string {
return /^https?:\/\//i.test(value) ? value : '';
}
/**
* Anmeldehinweis je Kontoart als REINER Text (Platzhalter noch nicht
* ersetzt). Verzeichnis- und lokale Konten nehmen den Text der Vorlage; ist
* er leer oder fehlt er (aeltere Aufrufer), gilt der Standardtext.
*/
function loginHintText(account: WelcomeMailAccount, texts: Partial<WelcomeMailTexts>): string {
const pick = (field: 'loginHintDirectory' | 'loginHintLocal') => {
const value = texts[field];
return typeof value === 'string' && value.trim() ? value : DEFAULT_WELCOME_MAIL_TEXTS[field];
};
switch (account.kind) {
case 'directory':
return pick('loginHintDirectory');
case 'local':
case 'local-example':
return pick('loginHintLocal');
case 'local-no-link':
return 'Ihr Startpasswort erhalten Sie von Ihrem Administrator.';
}
}
function validityText(hours: number): string {
const span =
hours % 24 === 0 && hours >= 24
? hours === 24
? '1 Tag'
: `${hours / 24} Tage`
: hours === 1
? '1 Stunde'
: `${hours} Stunden`;
return `Der Link ist ${span} gültig und nur einmal verwendbar. Ist er abgelaufen, fordern Sie auf der Anmeldeseite über „Passwort vergessen?“ einfach einen neuen an.`;
}
/** Knopf als Tabelle: Farbe an der Zelle, damit Outlook ihn als Flaeche zeigt. */
function button(href: string, label: string, bg: string, fg: string): string {
return `<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;">
<tr><td align="center" bgcolor="${bg}" style="background-color:${bg};border-radius:6px;mso-padding-alt:14px 30px;">
<a href="${escapeHtml(href)}" target="_blank" style="display:inline-block;padding:14px 30px;font-family:${FONT};font-size:16px;line-height:20px;font-weight:600;color:${fg};text-decoration:none;border-radius:6px;">${escapeHtml(label)}</a>
</td></tr></table>`;
}
/** Eine Kachel der Bildmarke: feste Zelle, Hoehe auch in Outlook exakt. */
function tile(color: string | null): string {
const bg = color ? `bgcolor="${color}" style="background-color:${color};` : 'style="';
return `<td width="9" height="9" ${bg}width:9px;height:9px;font-size:1px;line-height:9px;mso-line-height-rule:exactly;">&nbsp;</td>`;
}
/** Luecke zwischen Kacheln. */
const GAP = '<td width="3" style="width:3px;font-size:1px;line-height:1px;">&nbsp;</td>';
/**
* Bildmarke als HTML (quick-260930): das Kachel-"T" aus Tabellenzellen —
* oben drei Kacheln (die dritte gelb, im Original gedreht), darunter zwei in
* der Mitte —, auf dunkler Grundplatte mit heller Kontur wie in der App.
* Braucht kein Bild und erscheint deshalb in jedem Mailprogramm.
*/
function logoMark(): string {
const row = (cells: Array<string | null>) =>
`<tr>${cells.map((c, i) => (i > 0 ? GAP : '') + tile(c)).join('')}</tr>`;
const spacer = `<tr><td colspan="5" height="3" style="height:3px;font-size:1px;line-height:3px;mso-line-height-rule:exactly;">&nbsp;</td></tr>`;
const olive = '#9c9440';
return `<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;">
<tr><td bgcolor="#111214" style="background-color:#111214;border:1px solid #3a3d44;border-radius:10px;padding:10px 10px 10px 10px;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
${row([olive, olive, C.yellow])}${spacer}${row([null, olive, null])}${spacer}${row([null, olive, null])}
</table>
</td></tr></table>`;
}
/**
* Kopf der Mail (quick-260930, Rueckmeldung des Nutzers: in Outlook "ein
* riesiger schwarzer Fleck, kein Logo"). Vorher steckten Logo und
* Schriftzug in EINEM 150 px hohen Bild; zeigt ein Mailprogramm das
* eingebettete Bild nicht an, blieb nur die dunkle Flaeche. Jetzt:
* - Bildmarke (HTML-Kacheln) und Schriftzug "Tessera" (echter Text) in einer
* niedrigen dunklen Leiste — erscheinen immer;
* - darunter die Duenen-Welle als schmaler Bildstreifen (`src`, im Versand
* `cid:`, 600x40), der ins Weiss der Karte auslaeuft. Die Zelle um das
* Bild traegt die dunkle Kopffarbe (zweite Rueckmeldung des Nutzers: sein
* Outlook zeigt das CID-Bild nicht, vorher stand dort eine "riesengrosse
* weisse Luecke"): fehlt das Bild, wirkt der Kopf nur etwas hoeher.
*/
function headerRow(src: string | null): string {
const bar = `<tr><td bgcolor="${C.header}" style="background-color:${C.header};border-radius:12px 12px 0 0;padding:22px 32px 14px;">
<table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr>
<td valign="middle" style="padding:0 14px 0 0;">${logoMark()}</td>
<td valign="middle" style="font-family:${FONT};font-size:26px;line-height:32px;font-weight:700;color:#ffffff;letter-spacing:-0.5px;">Tessera</td>
</tr></table>
</td></tr>`;
const wave = src
? `<tr><td bgcolor="${C.header}" style="background-color:${C.header};line-height:0;font-size:0;">
<img src="${escapeHtml(src)}" width="600" height="40" alt="" style="display:block;width:100%;max-width:600px;height:auto;border:0;outline:none;text-decoration:none;">
</td></tr>`
: `<tr><td bgcolor="${C.header}" height="4" style="background-color:${C.header};height:4px;font-size:1px;line-height:4px;border-bottom:3px solid ${C.yellow};">&nbsp;</td></tr>`;
return bar + wave;
}
/**
* Baut die Willkommensmail. Die sechs Texte (Betreff, Ueberschrift,
* Einleitung, Anmeldehinweis je Kontoart, Abschluss) kommen aus der eigenen
* Vorlage des Mandanten, sonst
* aus `DEFAULT_WELCOME_MAIL_TEXTS`. Sie sind REINER Text: Platzhalter werden
* auf dem Text ersetzt, erst danach wird alles escaped — HTML aus der
* Vorlage oder aus einem Benutzerwert erscheint als Text, nie als Markup.
* Kopf, Kasten Adresse/Benutzername, Knopf "Passwort festlegen" mit
* Gueltigkeitshinweis, Knopf "Zu Tessera" und Fusszeile sind feste Bausteine
* und nicht Teil der Vorlage.
*/
export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail {
const base = safeUrl(input.appUrl.replace(/\/+$/, ''));
const loginUrl = `${base}/login`;
const texts = input.texts ?? DEFAULT_WELCOME_MAIL_TEXTS;
const values: WelcomeMailPlaceholderValues = {
name: input.name,
vorname: firstNameOf(input.name, input.username),
benutzername: input.username,
email: input.email,
adresse: base,
firma: input.tenantName,
};
const fill = (value: string) => applyWelcomeMailPlaceholders(value, values);
const subject = singleLine(fill(texts.subject)) || WELCOME_MAIL_SUBJECT;
const heading = singleLine(fill(texts.heading));
const introParagraphs = paragraphsOf(fill(texts.intro));
const hintParagraphs = paragraphsOf(fill(loginHintText(input.account, texts)));
const closingParagraphs = paragraphsOf(fill(texts.closing));
const notice = input.notice ? singleLine(input.notice) : '';
// ── Text-Alternative ───────────────────────────────────────────────────
const textLines: string[] = [];
if (notice) textLines.push(`[${notice}]`, '');
if (heading) textLines.push(heading, '');
for (const para of introParagraphs) textLines.push(para, '');
textLines.push('Ihre Zugangsdaten', `Adresse: ${base}`, `Benutzername: ${input.username}`);
for (const para of hintParagraphs) textLines.push('', para);
if (input.account.kind === 'local') {
textLines.push(
'',
'Passwort festlegen:',
input.account.setPasswordUrl,
validityText(input.account.validHours),
);
} else if (input.account.kind === 'local-example') {
textLines.push('', 'Passwort festlegen:', loginUrl, EXAMPLE_LINK_NOTE);
}
textLines.push('', 'Zu Tessera:', loginUrl);
for (const para of closingParagraphs) textLines.push('', para);
textLines.push('', '--', FOOTER);
const text = textLines.join('\n');
// ── HTML ───────────────────────────────────────────────────────────────
const username = escapeHtml(input.username);
const baseHtml = escapeHtml(base);
const p = (content: string, extra = '') =>
`<p style="margin:0 0 16px;font-family:${FONT};font-size:16px;line-height:25px;color:${C.body};${extra}">${content}</p>`;
/** Reiner Text -> escaped, einfache Umbrueche als <br>. */
const para = (value: string) => escapeHtml(value).replace(/\n/g, '<br>');
const label = (content: string) =>
`<div style="font-family:${FONT};font-size:12px;line-height:16px;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:${C.muted};">${content}</div>`;
let accountBlock = hintParagraphs
.map((part, i) => p(para(part), i === 0 ? 'margin:24px 0 16px;' : ''))
.join('\n');
if (input.account.kind === 'local') {
const setUrl = safeUrl(input.account.setPasswordUrl);
accountBlock += `${button(setUrl, 'Passwort festlegen', C.ink, '#ffffff')}
<p style="margin:12px 0 0;font-family:${FONT};font-size:13px;line-height:20px;color:${C.muted};">${escapeHtml(validityText(input.account.validHours))}</p>`;
} else if (input.account.kind === 'local-example') {
accountBlock += `${button(loginUrl, 'Passwort festlegen', C.ink, '#ffffff')}
<p style="margin:12px 0 0;font-family:${FONT};font-size:13px;line-height:20px;color:${C.muted};">${escapeHtml(EXAMPLE_LINK_NOTE)}</p>`;
}
const noticeRow = notice
? `<tr><td style="padding:0 0 12px;"><table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0"><tr><td bgcolor="#fff8c2" style="background-color:#fff8c2;border:1px solid #e6d74c;border-radius:8px;padding:10px 16px;font-family:${FONT};font-size:13px;line-height:20px;color:${C.ink};">${escapeHtml(notice)}</td></tr></table></td></tr>
`
: '';
const headingHtml = heading
? `<h1 style="margin:0 0 16px;font-family:${FONT};font-size:24px;line-height:32px;font-weight:700;color:${C.ink};">${escapeHtml(heading)}</h1>
`
: '';
const introHtml = introParagraphs.map((part) => p(para(part))).join('\n');
const closingHtml = closingParagraphs.length
? `<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0"><tr><td style="border-top:1px solid ${C.line};padding-top:20px;">
${closingParagraphs
.map((part, i) =>
p(
para(part),
`font-size:14px;line-height:22px;margin:0${i < closingParagraphs.length - 1 ? ' 0 12px' : ''};`,
),
)
.join('\n')}
</td></tr></table>`
: '';
const html = `<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<title>${escapeHtml(subject)}</title>
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
<style>
a { color: ${C.link}; }
@media only screen and (max-width: 620px) {
.tsr-pad { padding-left: 24px !important; padding-right: 24px !important; }
}
</style>
</head>
<body style="margin:0;padding:0;background-color:${C.page};-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:${C.page};">Ihr Zugang zu Tessera ist eingerichtet – hier finden Sie Adresse und Benutzername.</div>
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" bgcolor="${C.page}" style="background-color:${C.page};">
<tr><td align="center" style="padding:32px 12px;">
<!--[if mso]><table role="presentation" width="600" border="0" cellpadding="0" cellspacing="0"><tr><td><![endif]-->
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="width:100%;max-width:600px;border-collapse:separate;">
${noticeRow}${headerRow(input.headerImageSrc)}
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:24px 40px 12px;">
${headingHtml}${introHtml}
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;margin:8px 0 0;">
<tr><td bgcolor="${C.well}" style="background-color:${C.well};border:1px solid ${C.line};border-left:4px solid ${C.yellow};border-radius:8px;padding:18px 22px;">
${label('Adresse')}
<div style="margin:4px 0 14px;font-family:${FONT};font-size:16px;line-height:24px;font-weight:600;"><a href="${escapeHtml(loginUrl)}" target="_blank" style="color:${C.link};text-decoration:none;">${baseHtml}</a></div>
${label('Benutzername')}
<div style="margin:4px 0 0;font-family:Consolas,'SF Mono',Menlo,'Courier New',monospace;font-size:16px;line-height:24px;font-weight:600;color:${C.ink};">${username}</div>
</td></tr></table>
${accountBlock}
</td></tr>
<tr><td class="tsr-pad" bgcolor="${C.card}" align="left" style="background-color:${C.card};padding:20px 40px 8px;">
${button(loginUrl, 'Zu Tessera', C.yellow, C.ink)}
</td></tr>
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:20px 40px 36px;border-radius:0 0 12px 12px;">
${closingHtml}
</td></tr>
<tr><td align="center" style="padding:20px 24px 0;font-family:${FONT};font-size:12px;line-height:18px;color:${C.muted};">${escapeHtml(FOOTER)}</td></tr>
</table>
<!--[if mso]></td></tr></table><![endif]-->
</td></tr>
</table>
</body>
</html>`;
return { subject, text, html };
}
@@ -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,100 @@
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 { 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('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),
);
}
@@ -30,7 +30,9 @@ import type { ProxmoxErrorKind } from './proxmox.types';
* Keine SSRF-Adresspruefung wie `isPublicHttpUrl`: Proxmox-Server stehen
* per Definition im privaten Netz, eine solche Pruefung wuerde jede reale
* Adresse blockieren (T-DHH-02). Die Absicherung ist stattdessen, dass nur
* ein Administrator (`@Roles(ADMIN, SUPER_ADMIN)`) Adressen eintragen darf
* ein Administrator oder ein Benutzer, dem der Administrator ausdruecklich
* die Freigabestufe Verwalten fuer das Proxmox-Modul gegeben hat
* (`@ModuleManage('proxmox')`, 261002-icv), Adressen eintragen darf
* — siehe Bedrohungsmodell T-DHH-02 im Plan.
*/
+12 -12
View File
@@ -9,10 +9,8 @@ import {
Put,
Req,
} from '@nestjs/common';
import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { UseModule } from '../module-registry/module.guard';
import { ModuleManage, UseModule } from '../module-registry/module.guard';
import {
CreateProxmoxServerDto,
TestProxmoxServerDto,
@@ -26,9 +24,11 @@ import { ProxmoxService } from './proxmox.service';
* `domaincheck.controller.ts`) — Aktivierung UND Freigabe. `tenantId` kommt
* ausschliesslich aus `req.tenantId` (gesetzt vom `TenantGuard`), nie aus
* Body oder Query. Lesen (`GET servers`) steht jedem Benutzer mit
* Modulzugriff offen; Schreiben (`POST servers`, `POST servers/test`,
* `POST servers/:id/poll`, `POST servers/:id/test`) zusaetzlich
* `@Roles(ADMIN, SUPER_ADMIN)` (T-DHH-05). `servers/test` (statisch, zwei
* Modulzugriff offen; Schreiben (`POST servers`, `PUT`/`DELETE servers/:id`,
* `POST servers/test`, `POST servers/:id/poll`, `POST servers/:id/test`)
* zusaetzlich `@ModuleManage('proxmox')` — Administratoren und Benutzer mit
* der Freigabestufe Verwalten (261002-icv, vorher `@Roles(ADMIN,
* SUPER_ADMIN)`; T-DHH-05). `servers/test` (statisch, zwei
* Segmente) und `servers/:id/test` (drei Segmente) ueberschneiden sich
* nicht — beide POST, aber unterschiedliche Segmentzahl, deshalb keine
* Reihenfolge-Abhaengigkeit (anders als `GET :id` vs. statische Routen).
@@ -55,7 +55,7 @@ export class ProxmoxController {
}
@Post('servers')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
@ModuleManage('proxmox')
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateProxmoxServerDto) {
const tenantId = this.requireTenantId(req);
const created = await this.proxmoxService.createServer(tenantId, dto);
@@ -65,7 +65,7 @@ export class ProxmoxController {
}
@Put('servers/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
@ModuleManage('proxmox')
async update(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@@ -78,7 +78,7 @@ export class ProxmoxController {
}
@Delete('servers/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
@ModuleManage('proxmox')
async remove(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
const tenantId = this.requireTenantId(req);
const deleted = await this.proxmoxService.deleteServer(tenantId, id);
@@ -87,7 +87,7 @@ export class ProxmoxController {
}
@Post('servers/:id/poll')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
@ModuleManage('proxmox')
async poll(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.proxmoxService.pollServer(this.requireTenantId(req), id);
}
@@ -101,7 +101,7 @@ export class ProxmoxController {
* OHNE den Zwischenlagerstand zu ueberschreiben.
*/
@Post('servers/:id/test')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
@ModuleManage('proxmox')
async test(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@@ -115,7 +115,7 @@ export class ProxmoxController {
* noch keinen gespeicherten Server, `dto` ist deshalb die einzige Quelle.
*/
@Post('servers/test')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
@ModuleManage('proxmox')
async testDraft(@Req() req: AuthenticatedRequest, @Body() dto: TestProxmoxServerDto) {
this.requireTenantId(req);
return this.proxmoxService.testDraftConnection(dto);
+30 -2
View File
@@ -1,4 +1,3 @@
import { PartialType } from '@nestjs/mapped-types';
import { Transform } from 'class-transformer';
import {
IsBoolean,
@@ -7,6 +6,7 @@ import {
IsOptional,
IsString,
MaxLength,
ValidateIf,
} from 'class-validator';
const trimString = ({ value }: { value: unknown }) =>
@@ -44,12 +44,40 @@ export class CreateReminderDto {
emailEnabled?: boolean;
}
/** Nur ein FEHLENDES Feld wird uebersprungen — `null` wird geprueft und damit abgelehnt. */
const whenPresent = ValidateIf((_obj: object, value: unknown) => value !== undefined);
/**
* Teil-Update: jedes gesetzte Feld wird genauso geprueft wie beim Anlegen.
* Eine faellige Erinnerung laesst sich nicht aendern (409 im Dienst) — dafuer
* gibt es „Erledigt“ (loeschen) und „Spaeter erinnern“ (`SnoozeReminderDto`).
*
* WARUM KEIN `PartialType`: das setzt `@IsOptional()`, und das laesst auch
* `null` ungeprueft durch — `{"title": null}` kaeme dann als 500 aus der
* Datenbank statt als 400 aus der Pruefung. `whenPresent` ueberspringt nur
* fehlende Felder.
*/
export class UpdateReminderDto extends PartialType(CreateReminderDto) {}
export class UpdateReminderDto {
@whenPresent
@Transform(trimString)
@IsString()
@IsNotEmpty()
@MaxLength(200)
title?: string;
@whenPresent
@IsString()
@MaxLength(2000)
description?: string;
@whenPresent
@IsISO8601({ strict: true })
dueAt?: string;
@whenPresent
@IsBoolean()
emailEnabled?: boolean;
}
/** Neuer Zeitpunkt beim Spaeter-Erinnern (D-03); der Client rechnet ihn aus (E-05). */
export class SnoozeReminderDto {
@@ -48,7 +48,11 @@ function row(over: Partial<Row> & { id: string }): Row {
const sameTime = (a: Date | null, b: Date | null) =>
a === null || b === null ? a === b : a.getTime() === b.getTime();
function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u1@example.invalid' }) {
function makeStore(
rows: Row[],
emails: Record<string, string | null> = { u1: 'u1@example.invalid' },
inactive: string[] = [],
) {
const systemFindMany = vi.fn(async ({ where, take, orderBy }: any) => {
let list = rows.filter(
(r) =>
@@ -58,8 +62,11 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
r.dueAt.getTime() <= where.dueAt.lte.getTime() &&
r.dueAt.getTime() >= where.dueAt.gte.getTime(),
);
if (orderBy?.dueAt === 'asc') list = [...list].sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime());
return list.slice(0, take).map(({ id, tenantId, userId, dueAt }) => ({ id, tenantId, userId, dueAt }));
if (orderBy?.dueAt === 'asc')
list = [...list].sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime());
return list
.slice(0, take)
.map(({ id, tenantId, userId, dueAt }) => ({ id, tenantId, userId, dueAt }));
});
const boundLog: string[] = [];
@@ -69,11 +76,13 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
boundLog.push(`updateMany:${tenantId}`);
let count = 0;
for (const r of rows) {
if (r.id !== where.id || r.tenantId !== where.tenantId || r.tenantId !== tenantId) continue;
if (r.id !== where.id || r.tenantId !== where.tenantId || r.tenantId !== tenantId)
continue;
if ('dueAt' in where && !sameTime(r.dueAt, where.dueAt)) continue;
if ('emailEnabled' in where && r.emailEnabled !== where.emailEnabled) continue;
if ('emailSentAt' in where && !sameTime(r.emailSentAt, where.emailSentAt)) continue;
if (where.emailAttempts?.lt !== undefined && !(r.emailAttempts < where.emailAttempts.lt)) continue;
if (where.emailAttempts?.lt !== undefined && !(r.emailAttempts < where.emailAttempts.lt))
continue;
if (data.emailSentAt !== undefined) r.emailSentAt = data.emailSentAt;
if (data.emailAttempts?.increment) r.emailAttempts += data.emailAttempts.increment;
count++;
@@ -89,7 +98,11 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
}),
},
user: {
findFirst: vi.fn(async ({ where }: any) => ({ email: emails[where.id] ?? null })),
findFirst: vi.fn(async ({ where }: any) => {
const isActive = !inactive.includes(where.id);
if ('isActive' in where && where.isActive !== isActive) return null;
return { email: emails[where.id] ?? null };
}),
},
});
@@ -100,10 +113,7 @@ function makeStore(rows: Row[], emails: Record<string, string | null> = { u1: 'u
return { prisma, rows, systemFindMany, boundLog };
}
function makeScheduler(
prisma: any,
opts: { smtp?: unknown; sendResult?: boolean | Error } = {},
) {
function makeScheduler(prisma: any, opts: { smtp?: unknown; sendResult?: boolean | Error } = {}) {
const registry = { addInterval: vi.fn(), deleteInterval: vi.fn() };
const settings = { getSmtpConfig: vi.fn(async () => (opts.smtp === undefined ? {} : opts.smtp)) };
const mail = {
@@ -112,7 +122,12 @@ function makeScheduler(
return opts.sendResult ?? true;
}),
};
const scheduler = new ReminderMailScheduler(registry as any, prisma, settings as any, mail as any);
const scheduler = new ReminderMailScheduler(
registry as any,
prisma,
settings as any,
mail as any,
);
return { scheduler, registry, settings, mail };
}
@@ -126,7 +141,9 @@ describe('ReminderMailScheduler — Anspruch (T-IF2-06)', () => {
const one = makeScheduler(store.prisma);
const two = makeScheduler(store.prisma);
await Promise.all([one.scheduler.runTick(NOW), two.scheduler.runTick(NOW)]);
expect(one.mail.sendReminderEmail.mock.calls.length + two.mail.sendReminderEmail.mock.calls.length).toBe(1);
expect(
one.mail.sendReminderEmail.mock.calls.length + two.mail.sendReminderEmail.mock.calls.length,
).toBe(1);
expect(store.rows[0].emailSentAt).toEqual(NOW);
expect(store.rows[0].emailAttempts).toBe(1);
});
@@ -223,6 +240,23 @@ describe('ReminderMailScheduler — Fehlschlag und Wiederholung (E-04)', () => {
expect(mail.sendReminderEmail).not.toHaveBeenCalled();
expect(store.rows[0].emailSentAt).toEqual(NOW);
});
it('deaktivierter Benutzer: kein Versand, Anspruch bleibt wie ohne Adresse', async () => {
const store = makeStore([row({ id: 'a' })], { u1: 'u1@example.invalid' }, ['u1']);
const { scheduler, mail } = makeScheduler(store.prisma);
const spy = vi.spyOn(store.prisma, '__tenantClient');
await scheduler.runTick(NOW);
await scheduler.runTick(new Date(NOW.getTime() + 30_000));
expect(mail.sendReminderEmail).not.toHaveBeenCalled();
expect(store.rows[0].emailSentAt).toEqual(NOW);
expect(store.rows[0].emailAttempts).toBe(1);
const client = spy.mock.results[0].value;
expect(client.user.findFirst.mock.calls[0][0].where).toEqual({
id: 'u1',
tenantId: 't1',
isActive: true,
});
});
});
describe('ReminderMailScheduler — Kandidaten (E-03, T-IF2-07)', () => {
@@ -26,7 +26,7 @@ const BATCH = 200;
* Scheitert der Transport, gibt der Planer den Anspruch wieder frei
* (`emailSentAt = null`), sodass der naechste Durchlauf es erneut versucht —
* hoechstens dreimal. Fehlt beim Senden die SMTP-Einrichtung oder die Adresse
* des Benutzers, bleibt der Anspruch: die Faelligkeit gilt als erledigt und
* des Benutzers oder ist sein Konto deaktiviert (`isActive = false`), bleibt der Anspruch: die Faelligkeit gilt als erledigt und
* wird nur protokolliert, es gibt keine Wiederholschleife. Ein Verschieben
* („Spaeter erinnern“) setzt beide Felder zurueck (siehe `RemindersService`).
*
@@ -104,9 +104,7 @@ export class ReminderMailScheduler implements OnApplicationBootstrap {
await this.processCandidate(candidate, now);
} catch (err) {
// Eine kaputte Zeile darf die uebrigen nicht anhalten.
this.logger.error(
`Reminder email for ${candidate.id} failed: ${(err as Error).message}`,
);
this.logger.error(`Reminder email for ${candidate.id} failed: ${(err as Error).message}`);
}
}
} finally {
@@ -140,14 +138,15 @@ export class ReminderMailScheduler implements OnApplicationBootstrap {
where: { id: c.id, tenantId: c.tenantId, dueAt: c.dueAt },
select: { title: true, description: true, dueAt: true },
});
// Nur aktive Konten: ein deaktivierter Benutzer gilt wie einer ohne Adresse.
const user = await tenantPrisma.user.findFirst({
where: { id: c.userId, tenantId: c.tenantId },
where: { id: c.userId, tenantId: c.tenantId, isActive: true },
select: { email: true },
});
const smtp = await this.settingsService.getSmtpConfig(c.tenantId);
if (!row || !user?.email || smtp === null) {
this.logger.log(
`Reminder email for ${c.id} übersprungen (${!row ? 'Zeile geändert' : !user?.email ? 'keine E-Mail-Adresse' : 'kein E-Mail-Versand eingerichtet'})`,
`Reminder email for ${c.id} übersprungen (${!row ? 'Zeile geändert' : !user ? 'Benutzer deaktiviert' : !user.email ? 'keine E-Mail-Adresse' : 'kein E-Mail-Versand eingerichtet'})`,
);
return; // Anspruch bleibt: gilt als erledigt, keine Wiederholschleife (E-04)
}
@@ -1,5 +1,5 @@
import 'reflect-metadata';
import { ForbiddenException, ValidationPipe } from '@nestjs/common';
import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
@@ -24,7 +24,14 @@ const user = { id: 'u1', username: 'u', role: 'USER', tenantId: 't1' } as any;
const proto = RemindersController.prototype as any;
describe('RemindersController — Rollen', () => {
it.each(['list', 'emailStatus', 'create', 'update', 'snooze', 'remove'])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => {
it.each([
'list',
'emailStatus',
'create',
'update',
'snooze',
'remove',
])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => {
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
});
@@ -55,7 +62,9 @@ describe('RemindersController — Mandant', () => {
const controller = new RemindersController(makeService() as any);
await expect(controller.list(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.emailStatus(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.update(req(), user, 'x', {})).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.update(req(), user, 'x', {})).rejects.toBeInstanceOf(
ForbiddenException,
);
await expect(
controller.snooze(req(), user, 'x', { dueAt: '2099-01-01T10:00:00.000Z' }),
).rejects.toBeInstanceOf(ForbiddenException);
@@ -96,6 +105,28 @@ describe('RemindersController — Pipe fuer Aendern und Verschieben', () => {
expect(out).toEqual({ title: 'b' });
});
it.each([
'title',
'description',
'dueAt',
'emailEnabled',
])('Aendern mit %s: null ergibt 400 statt eines Datenbankfehlers', async (field) => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
await expect(
pipe.transform({ [field]: null }, { type: 'body', metatype: UpdateReminderDto }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('Aendern: ein leerer Titel wird abgelehnt, ein leeres Objekt ist erlaubt', async () => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
await expect(
pipe.transform({ title: ' ' }, { type: 'body', metatype: UpdateReminderDto }),
).rejects.toBeInstanceOf(BadRequestException);
await expect(
pipe.transform({}, { type: 'body', metatype: UpdateReminderDto }),
).resolves.toEqual({});
});
it('Verschieben verlangt einen ISO-Zeitpunkt und verwirft Fremdfelder', async () => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
const out: any = await pipe.transform(
@@ -16,7 +16,13 @@ function makeFakePrisma() {
const reminder = {
create: vi.fn(async ({ data }: { data: any }) => {
const id = `r-${++seq}`;
const row = { id, createdAt: new Date(), updatedAt: new Date(), emailEnabled: false, ...data };
const row = {
id,
createdAt: new Date(),
updatedAt: new Date(),
emailEnabled: false,
...data,
};
rows.set(id, row);
return row;
}),
@@ -37,6 +43,14 @@ function makeFakePrisma() {
rows.set(where.id, row);
return row;
}),
// Wie die eine SQL-Anweisung: Bedingung pruefen und schreiben ohne `await` dazwischen.
updateMany: vi.fn(async ({ where, data }: { where: any; data: any }) => {
const r = rows.get(where.id);
if (!r || r.tenantId !== where.tenantId || r.userId !== where.userId) return { count: 0 };
if (where.dueAt?.gt && !(r.dueAt.getTime() > where.dueAt.gt.getTime())) return { count: 0 };
rows.set(where.id, { ...r, ...data });
return { count: 1 };
}),
delete: vi.fn(async ({ where }: { where: any }) => {
rows.delete(where.id);
}),
@@ -106,7 +120,9 @@ describe('RemindersService — anlegen', () => {
service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) }),
).rejects.toBeInstanceOf(ConflictException);
// ein anderer Benutzer ist davon nicht betroffen
await expect(service.create('t1', 'u2', { title: 'a', dueAt: inHours(1) })).resolves.toBeTruthy();
await expect(
service.create('t1', 'u2', { title: 'a', dueAt: inHours(1) }),
).resolves.toBeTruthy();
});
});
@@ -153,15 +169,21 @@ describe('RemindersService — fremde und unbekannte Kennungen (D-05, T-IF2-01)'
])('%s: aendern, verschieben und loeschen ergeben 404', async (_n, owner) => {
const { prisma, service } = setup();
seed(prisma, 'x', past(), owner);
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(NotFoundException);
await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(1) })).rejects.toBeInstanceOf(NotFoundException);
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(
NotFoundException,
);
await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(1) })).rejects.toBeInstanceOf(
NotFoundException,
);
await expect(service.remove('t1', 'u1', 'x')).rejects.toBeInstanceOf(NotFoundException);
expect(prisma.rows.has('x')).toBe(true);
});
it('eine unbekannte Kennung ergibt 404', async () => {
const { service } = setup();
await expect(service.remove('t1', 'u1', 'gibt-es-nicht')).rejects.toBeInstanceOf(NotFoundException);
await expect(service.remove('t1', 'u1', 'gibt-es-nicht')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('das where jeder Abfrage traegt Mandant und Benutzer', async () => {
@@ -173,10 +195,11 @@ describe('RemindersService — fremde und unbekannte Kennungen (D-05, T-IF2-01)'
tenantId: 't1',
userId: 'u1',
});
expect(prisma.reminder.update.mock.calls[0]?.[0]?.where).toEqual({
expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.where).toEqual({
id: 'x',
tenantId: 't1',
userId: 'u1',
dueAt: { gt: expect.any(Date) },
});
});
});
@@ -185,7 +208,9 @@ describe('RemindersService — aendern', () => {
it('eine faellige Erinnerung laesst sich nicht aendern (409)', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', past());
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(ConflictException);
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(
ConflictException,
);
});
it('eine vergangene neue Faelligkeit ergibt 400', async () => {
@@ -200,14 +225,61 @@ describe('RemindersService — aendern', () => {
const { prisma, service } = setup();
seed(prisma, 'x', future());
await service.update('t1', 'u1', 'x', { title: 'neu' });
expect(prisma.reminder.update.mock.calls[0]?.[0]?.data).toEqual({ title: 'neu' });
expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.data).toEqual({ title: 'neu' });
const newDue = inHours(9);
await service.update('t1', 'u1', 'x', { dueAt: newDue, description: '' });
expect(prisma.reminder.update.mock.calls[1]?.[0]?.data).toEqual({
expect(prisma.reminder.updateMany.mock.calls[1]?.[0]?.data).toEqual({
description: '',
dueAt: new Date(newDue),
emailSentAt: null,
emailAttempts: 0,
});
});
it('eine neue Faelligkeit setzt emailSentAt und emailAttempts zurueck, sonst bleiben sie', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', future());
await service.update('t1', 'u1', 'x', { title: 'neu' });
expect(prisma.rows.get('x').emailAttempts).toBe(2);
expect(prisma.rows.get('x').emailSentAt).not.toBeNull();
const newDue = inHours(5);
const out: any = await service.update('t1', 'u1', 'x', { dueAt: newDue });
expect(prisma.rows.get('x')).toMatchObject({
dueAt: new Date(newDue),
emailSentAt: null,
emailAttempts: 0,
});
expect(out.dueAt).toEqual(new Date(newDue));
});
it('wird die Erinnerung zwischen Pruefung und Schreiben faellig, gibt es 409 und keine Aenderung', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', future());
// Nach dem Laden (Pruefung bestanden) ist die Zeile inzwischen faellig.
prisma.reminder.findFirst.mockImplementationOnce(async () => {
const r = prisma.rows.get('x');
const loaded = { ...r };
prisma.rows.set('x', { ...r, dueAt: past() });
return loaded;
});
await expect(service.update('t1', 'u1', 'x', { title: 'neu' })).rejects.toBeInstanceOf(
ConflictException,
);
expect(prisma.rows.get('x').title).toBe('alt');
});
it('wird die Erinnerung zwischen Pruefung und Schreiben geloescht, gibt es 404', async () => {
const { prisma, service } = setup();
seed(prisma, 'x', future());
prisma.reminder.findFirst.mockImplementationOnce(async () => {
const loaded = { ...prisma.rows.get('x') };
prisma.rows.delete('x');
return loaded;
});
await expect(service.update('t1', 'u1', 'x', { title: 'neu' })).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
describe('RemindersService — spaeter erinnern (D-03)', () => {
@@ -276,7 +348,10 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
hasEmail: false,
});
expect(forTenant).toHaveBeenLastCalledWith(expect.anything(), 't1', 'u-ohne-mail');
expect(prisma.user.findFirst.mock.calls[0]?.[0]?.where).toEqual({ id: 'u-ohne-mail', tenantId: 't1' });
expect(prisma.user.findFirst.mock.calls[0]?.[0]?.where).toEqual({
id: 'u-ohne-mail',
tenantId: 't1',
});
});
it('anlegen mit emailEnabled speichert das Feld, wenn E-Mail moeglich ist', async () => {
@@ -298,7 +373,11 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
).rejects.toBeInstanceOf(BadRequestException);
const noMail = setup();
await expect(
noMail.service.create('t1', 'u-ohne-mail', { title: 'a', dueAt: inHours(1), emailEnabled: true }),
noMail.service.create('t1', 'u-ohne-mail', {
title: 'a',
dueAt: inHours(1),
emailEnabled: true,
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(noSmtp.prisma.rows.size).toBe(0);
expect(noMail.prisma.rows.size).toBe(0);
@@ -311,6 +390,6 @@ describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
BadRequestException,
);
await service.update('t1', 'u1', 'x', { emailEnabled: false });
expect(prisma.reminder.update.mock.calls[0]?.[0]?.data).toEqual({ emailEnabled: false });
expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.data).toEqual({ emailEnabled: false });
});
});

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