150 Commits

Author SHA1 Message Date
schalli 83b842b72c feat(nextcloud-status): Kundenname voll ausgeschrieben, Kachel-Knoepfe in die Fusszeile
Tessera CI/CD / Lint & Type Check (push) Successful in 57s
Tessera CI/CD / Tests (push) Successful in 1m45s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 21s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m47s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 15:57:02 +02:00
schalli ff4938fa97 fix(domains): geloeschter AutoDNS-Auftrag haengt nicht mehr - Domain entscheidet
AutoDNS loescht abgeschlossene Auftraege; GET /job/{id} liefert dann 404
(EF300101). Gibt es die Domain, ist die Registrierung durch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 14:57:53 +02:00
schalli f24ce735ab fix(domains): Verfuegbarkeit mit vollstaendiger Domain abfragen (gegen AutoDNS-Demo geprueft)
Mit nur dem Namensteil als searchToken liefert /domainstudio data: [].

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 14:54:19 +02:00
schalli e18b3c8e24 feat(nextcloud-status): Suche nach Kundenname, kompaktere Kacheln, Adresse in voller Laenge
Tessera CI/CD / Lint & Type Check (push) Successful in 55s
Tessera CI/CD / Tests (push) Successful in 2m24s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 20s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m41s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 14:29:15 +02:00
schalli 57ab064abe docs(quick-261008-j9f): Nextcloud-Logo per http-Adresse
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 14:14:38 +02:00
schalli 166a6fc9d1 docs(nextcloud-status): Changelog und Anleitungen zu http-Bildadressen und deutschen Meldungen (j9f)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 14:12:36 +02:00
schalli c30a82ec0f feat(nextcloud-status): Cloud-Formular zeigt nur deutsche Meldungen, Bildadresse auch per http (j9f)
- Vorabpruefung im Browser, API-Kennungen werden zu de/en-Texten
- Beschriftung Bildadresse mit neuem Hinweis zu http und https

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 14:08:21 +02:00
schalli 443256164d feat(nextcloud-status): http-Logo-Adresse wird einmalig abgeholt, Formularfehler mit Kennung (j9f)
- gemeinsamer SSRF-Schutz in common/public-url-guard.ts (Favoriten unveraendert)
- fetchLogoImage: Schutz je Sprung, Zeitlimit, 1-MiB-Deckel, Magic Bytes
- alle Formularfehler als { code, message } mit deutschem Text

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 14:06:20 +02:00
schalli 4ff43c2252 docs(quick-261008-h3t): Domains-Nameserver aus AutoDNS
Tessera CI/CD / Lint & Type Check (push) Successful in 55s
Tessera CI/CD / Tests (push) Successful in 1m34s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m24s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 12:40:35 +02:00
schalli 2176f8ecce refactor(domains): Einstellung Standard-Nameserver entfernt, Doku angepasst (h3t)
- Spalte DomainsConfig.defaultNameServers per Migration 20261008160000 entfernt
- Feld in Einstellungen/Status (API, DTO, Typen) und Karte im Web entfernt
- Anleitungen und CHANGELOG: Nameserver kommen aus AutoDNS

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 12:37:21 +02:00
schalli 1b20b84e2f feat(domains): Registrieren zeigt Nameserver aus AutoDNS nur zur Kontrolle (h3t)
- Nameserver schreibgeschuetzt in AutoDNS-Reihenfolge, ohne Eingabe
- Hinweis mit Neu-lesen-Knopf, Zusammenfassung gesperrt ohne Nameserver
- createOrder sendet keine Nameserver mehr

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 12:34:32 +02:00
schalli 473738db9e feat(domains): Standard-Nameserver aus dem AutoDNS-Profil lesen (h3t)
- parseProfileNameServers erkennt die Nameserver tolerant ([ASSUMED] Schluesselnamen)
- createOrder liest das Profil serverseitig, Browser-Liste wird ignoriert
- GET modules/domains/name-servers (Verwalten), Submit-Sperre bei weniger als zwei

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 12:32:36 +02:00
schalli 95d625bd25 docs(quick-261008-dts): Modul Domains mit AutoDNS-Anbindung
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 11:34:16 +02:00
schalli 53b73ddbf5 fix(domains): Nameserver-Beispiel zaehlt mit (ns1, ns2, ...)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 11:34:02 +02:00
schalli cc1c83ab0a fix(domains): HTTP 5xx nie als Ablehnung werten, Verwerfen mit Neupruefung, Entwurfsstand im Anspruch (CR-01, WR-01, WR-02, WR-03, IN-03)
CR-01: 5xx, 408, 425 (auch mit AutoDNS-Huelle) sind kein "abgelehnt" mehr,
Bestellung endet in UNKNOWN und der Abgleich wertet sie nicht als "Domain gibt
es nicht". 429 gilt bei der Bestellung ebenfalls als unklar, weil nicht belegt
ist, dass AutoDNS vor der Verarbeitung abgelehnt hat.
WR-01: Verwerfen eines unklaren Auftrags fragt im Augenblick des Verwerfens
erneut bei AutoDNS nach; nur ein ausdrueckliches "nicht gefunden" verwirft.
WR-02: Die Bestaetigung traegt den Entwurfsstand (updatedAt); der Anspruch
greift nur bei gleichem Stand, sonst 409 orderChanged.
WR-03: Auftraege ohne lesbares Datum/Objekt oder eine volle Trefferliste
machen "nichts gefunden" unklar statt verwerfbar.
IN-03: Scheitert das Speichern nach POST /domain, wird die Auftragsnummer
protokolliert und der Auftrag auf UNKNOWN gebracht (nie DRAFT), ohne 500.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 11:29:01 +02:00
schalli e1dd996438 fix(domains): Neu-einlesen-Knopf nur fuer Verwalter sichtbar (WR-06)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 11:29:01 +02:00
schalli 9eada2e082 fix(domains): Neuabruf ?refresh=1 nur fuer Verwalter (WR-06)
Jeder Neuabruf sind bis zu 20 Aufrufe auf dem geteilten AutoDNS-Takt; Benutzer
ohne Freigabestufe Verwalten koennten damit Bestellung und Abgleich verdraengen.
Der ModuleGuard legt die wirksame Stufe auf den Request, das Flag wird fuer
Nicht-Verwalter ignoriert (Zwischenspeicher bleibt).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 11:27:51 +02:00
schalli 268d6d55a4 fix(domains): null in den Einstellungen ergibt 400 statt 500 (WR-04)
@IsOptional liess null durch; der Dienst scheiterte dann an trim()/map() oder an
einer NOT-NULL-Spalte. Felder duerfen fehlen, aber nicht null sein (Kontext-
nummern bleiben ausdruecklich null-faehig).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 11:27:41 +02:00
schalli 9ecf191fb4 fix(domains): Preis nur anzeigen, wenn er nachweislich fuer ein Jahr gilt (WR-05)
Der Rueckgriff auf den ersten Preiseintrag entfaellt; ohne Ein-Jahres-Eintrag
erscheint "Preis unbekannt" statt eines Preises fuer einen anderen Zeitraum.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 11:27:41 +02:00
schalli d332246bde feat(domains): Domain registrieren mit Schutz vor Doppelbestellung, Aufträge, Changelog und Anleitung
- Verfügbarkeitsprüfung über DomainStudio, Entwurf mit Kontakten und Nameservern, Zusammenfassung mit System und Preis
- Verbindliches Abschicken: atomarer Anspruch DRAFT -> SUBMITTING vor dem Netzaufruf, genau ein POST /domain ohne Wiederholung, unklarer Ausgang als UNKNOWN, Bindung an das System
- Aufträge: Stand aus dem AutoDNS-Auftrag, Abgleich unklarer Aufträge, Verwerfen erst nach Abgleich
- Reiter Registrieren und Aufträge, Changelog, Anwender- und Administrationshandbuch, Mandantenschutz-Inventar

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 11:10:03 +02:00
schalli 450218fe77 feat(domains): Kunden, Kontakte aus AutoDNS mit Zuordnung und Domainliste
- Kunden mit Markierung eigene Firma, Kontakte live aus AutoDNS (seitenweise, 60 s Zwischenspeicher), Zuordnung je System
- Kontakt anlegen (Person/Organisation) und einem oder mehreren Kunden zuordnen, nur mit Verwalten
- Domainliste mit Kunde (ueber den Inhaber-Kontakt), Inhaber, Ablaufdatum, Status; Filter und Gruppierung nach Kunde
- Mandantenschutz-Inventar um die beiden neuen Zugriffspaare ergaenzt

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 10:50:06 +02:00
schalli 1f1c984184 feat(domains): Modul Domains mit AutoDNS-Zugang, Verbindungstest und Einstellungen
- Migration mit vier Tabellen (Einstellungen, Kunden, Kontaktzuordnung, Bestellungen), Zeilenschutz je Organisation
- AutoDNS-Client mit festen Demo-/Live-Adressen, Takt-Begrenzer, 20 s Zeitlimit, ohne Wiederholung
- Zugang je System verschluesselt gespeichert, Live-Wechsel nur mit Bestaetigung, Verbindungstest
- Modulseite mit Systemkennzeichnung und Reiter Einstellungen (nur Verwalten)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 10:35:21 +02:00
schalli 6b0f840c1c docs(dkv): Modulbeschreibung kuerzer
Tessera CI/CD / Lint & Type Check (push) Successful in 55s
Tessera CI/CD / Tests (push) Successful in 1m26s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 4m36s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 09:07:01 +02:00
schalli 4782fd5daf docs(dkv): Modulbeschreibung nennt Freigabestufe Verwalten, Version 1.1.0
Mit nur "Benutzen" erscheint DKV-Rechnung in der Seitenleiste, die
Seite zeigt aber "Kein Zugriff" (ganzes Modul verlangt Verwalten seit
261002-icv). Der Hinweis steht jetzt in der Marktplatz-Beschreibung.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 16:43:43 +02:00
schalli a8670def37 wip: pausiert nach Freigabe 1.10.1 2026-10-06 13:54:53 +02:00
schalli 87ba56db93 docs(changelog): Version 1.10.1 abgeschlossen
Tessera CI/CD / Build & Publish Images (push) Successful in 3m23s
Tessera CI/CD / Lint & Type Check (push) Successful in 49s
Tessera CI/CD / Tests (push) Successful in 2m2s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 13m25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 13:03:16 +02:00
schalli 6ccab8330c fix(web): Favoriten-Platzhalterbuchstabe verschwindet, sobald das Logo geladen ist
Tessera CI/CD / Lint & Type Check (push) Successful in 59s
Tessera CI/CD / Tests (push) Successful in 1m40s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 13m15s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m51s
Bei Logos mit durchsichtigem Hintergrund schien der Buchstabe durch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 11:53:18 +02:00
schalli ba4c4d52b3 fix(desktop): Linux-AppImage oeffnet Links ueber das xdg-open des Systems
tauri-plugin-opener rief das xdg-open aus dem AppImage mit dessen Umgebung
(LD_LIBRARY_PATH, GTK_PATH, GIO_EXTRA_MODULES, GDK_BACKEND ...) auf; das
Oeffnen-Programm der Arbeitsoberflaeche erbt das. Im AppImage jetzt das
System-xdg-open mit bereinigter Umgebung, sonst wie bisher der Opener.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 11:52:10 +02:00
schalli d0086fa38d docs(changelog): Version 1.10.0 abgeschlossen
Tessera CI/CD / Build & Publish Images (push) Successful in 3m14s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 13m24s
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 1m45s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 11:27:38 +02:00
schalli 2815bf8cfe chore(261002-fm5): erfundene Testdateien fuer Kantine und Handelsware
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m30s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 17s
Tessera CI/CD / Build & Publish Images (push) Successful in 2m53s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 11:04:25 +02:00
schalli da9a65c7d0 fix(261006-dcs): Linux-AppImage ohne mitgebrachte libwayland (weisses Fenster auf Arch)
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m38s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 12m14s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m26s
linuxdeploy packt libwayland-* des Debian-Baus ein; auf Arch/EndeavourOS
bricht WebKitGTK damit mit EGL_BAD_PARAMETER ab. Neuer CI-Schritt
entfernt sie, packt mit der Original-runtime neu und signiert neu.
In archlinux- und debian:trixie-Containern unter Xvfb nachgewiesen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 09:36:56 +02:00
schalli 55b2609e30 fix(261005-jqd): Bereich heisst Administration statt Verwaltung
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 1m37s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 4m36s
Leiste, Seitentitel und Pfadhinweise passend zum Profilmenue
'Einstellungen/Administration'; Handbuch und CHANGELOG nachgezogen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 09:16:54 +02:00
schalli 558d327a98 fix(261005-jqd): Profilmenue mit einem Eintrag Einstellungen bzw. Einstellungen/Administration
Tessera CI/CD / Lint & Type Check (push) Successful in 52s
Tessera CI/CD / Tests (push) Successful in 1m54s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m3s
Einstellungen und Verwaltung fuehrten in dieselbe Leiste; der doppelte
Eintrag entfaellt. Administratoren sehen 'Einstellungen/Administration'.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 15:11:57 +02:00
schalli 99adad881f docs(261005-jqd): Handbuch und CHANGELOG fuer aufgeraeumte Einstellungen und Verwaltung
Tessera CI/CD / Lint & Type Check (push) Successful in 1m8s
Tessera CI/CD / Tests (push) Successful in 1m48s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m1s
Menuepfade auf 'Verwaltung → …', Einstellungen-Kapitel beschreibt die
gemeinsame Leiste, CHANGELOG-Eintrag unter Neu.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 14:12:27 +02:00
schalli 0a35a32afb design(verwaltung): Einstellungen und Verwaltung aufgeraeumt
Gemeinsame Navigation fuer Einstellungen und Verwaltung (Verwaltung in
Benutzer und Zugang / Module / Anbindungen gegliedert), einheitlicher
Seitenkopf und Abschnittskarten, Speichern in der Kartenfusszeile.
Menuepunkt 'Administrator' heisst 'Verwaltung'; Seitentitel gleich den
Navigationseintraegen. Kein 'Zurueck zum Dashboard' mehr. Tabellen ohne
waagerechte Seitenscrollleiste; auf dem Handy Auswahlliste statt Liste.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:36:05 +02:00
schalli 831c7b8f52 fix(quick-261005-d5d): Postfach-Abruf ueber EWS mit 60-s-Zeitgrenze
Tessera CI/CD / Lint & Type Check (push) Successful in 52s
Tessera CI/CD / Tests (push) Successful in 1m45s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 20s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m41s
Gleicher eigener Timer wie im Kalender; httpreqs timeout greift
waehrend des Verbindungsaufbaus nicht. 60 s, weil PDF-Anhaenge
geladen werden.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 10:26:10 +02:00
schalli e49d4c7c9d fix(quick-261005-d5d): Kalender-Test bricht bei unerreichbarem Exchange nach 15 s ab
EWS-Aufrufe ueber httpntlm bekommen eine eigene 15-s-Zeitgrenze;
httpreqs timeout greift waehrend des Verbindungsaufbaus nicht
(gemessen 134 s). Netzfehler liefern im Test den Schluessel
'unreachable', das Formular meldet 'nicht erreichbar' statt
'Zugangsdaten pruefen'.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 09:29:25 +02:00
schalli 94bd2a4a04 chore(dev): lokaler Stack startet nach Neustart des Rechners wieder
Tessera CI/CD / Lint & Type Check (push) Successful in 1m15s
Tessera CI/CD / Tests (push) Successful in 1m48s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 25s
Tessera CI/CD / Build & Publish Images (push) Successful in 4m20s
restart: unless-stopped fuer web, api, db (docker-compose.yml) und
mailhog (docker-compose.dev.yml). Der Dev-Host startet sonntags neu,
der Stack blieb danach aus.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 08:39:30 +02:00
schalli 873d15d25e feat(quick-261005-bt1): Proxmox warnt bei gestopptem Gast mit aktivem Autostart
Fuer jeden nicht laufenden qemu/lxc (ohne Vorlage) liest der PVE-Durchlauf
onboot aus /nodes/{node}/{type}/{vmid}/config (nur GET, Deckel 40).
Befund in den Metriken (autostartStopped, autostartUnchecked); Karte wird
Warnung mit einer Zeile je Gast, Kachel zeigt 'gestoppt trotz Autostart'.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 08:30:20 +02:00
schalli 781bc9f9ee fix(quick-261005-blw): Links in Notiz-Kacheln oeffnen in neuem Tab
NoteLink ersetzt <a> der Markdown-Vorschau: target=_blank,
rel=noopener noreferrer; #-Sprungmarken bleiben im Fenster.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:32:55 +02:00
schalli 0edd6e9b1a docs: Sitzung fortgesetzt, HANDOFF nach Freigabe 1.9.2 entfernt
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:25:41 +02:00
schalli f7d4be0c7c wip: pausiert nach Freigabe 1.9.2
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 10:56:14 +02:00
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
schalli e72814abd9 docs(quick-260929-lh3): Favoriten-Symbole, Browser-Pruefung
Tessera CI/CD / Lint & Type Check (push) Successful in 52s
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 3m21s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:48:39 +02:00
schalli 0e72ad45f8 docs(changelog): Favoriten-Symbole
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:47:23 +02:00
schalli b15c74632b fix(favorites): Symbol-Adresse auch speichern, wenn nur der Browser sie laden kann
- API: ausdrueckliche iconUrl wird nur auf Form (http/https, <= 2048) geprueft
  und auch gespeichert, wenn der Server sie nicht abrufen kann; keine 422 mehr
- Erkennung: Seite mit Fehlerstatus, aber HTML mit <link rel=icon>, liefert
  diesen Verweis (docuvita); og:image einer Fehlerseite zaehlt nicht
- Kachel: Proxy -> iconUrl direkt im Browser (no-referrer, nur http/https) ->
  Origin-Favicon -> Buchstabe
- Meldung iconUrlUnreachable (de/en) entfernt, Hinweis zum Vorrang angepasst

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:34:58 +02:00
schalli 7188c5b958 fix(favorites): geaenderte Symbol-Adresse wird sofort angezeigt
- eine neue, ausdruecklich eingetragene Logo-Adresse verdraengt ein frueher
  hochgeladenes Symbol (Vorrang der Datei liess die Adresse unsichtbar)
- iconVersion steigt mit, die Kachel laedt das Bild neu
- Regressionstests: Upload wird ersetzt, unveraenderte Adresse laesst Upload stehen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 15:32:30 +02:00
schalli 8c644de5da docs(quick-260929-if2): Erinnerungen-Widget, Verifikation und Browser-Pruefung
Tessera CI/CD / Lint & Type Check (push) Successful in 57s
Tessera CI/CD / Tests (push) Successful in 1m34s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m46s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m27s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 14:17:41 +02:00
schalli 6879c756f2 feat(260929-if2): Erinnerung zusaetzlich per E-Mail, Doku und Aenderungsliste
- E-Mail-Planer: Anspruch vor dem Senden (genau eine Mail je Faelligkeit, hoechstens 3 Versuche), Systemlesen nur fuer die Kandidatenabfrage
- MailService.sendReminderEmail (Berliner Zeit, nur Text), GET /reminders/email-status, Haken im Formular mit Erklaerung
- Zugriffsklassifikation und Erlaubnisliste fuer forSystem nachgezogen, Aenderungsliste und Anwenderanleitung

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 14:11:22 +02:00
schalli 709b41a007 feat(260929-if2): faellige Erinnerungen erledigen, spaeter erinnern, bearbeiten und loeschen
- API: Aendern (409 wenn faellig), Spaeter erinnern (409 wenn nicht faellig, setzt E-Mail-Spur zurueck), Loeschen; fremde Kennungen 404
- Kachel: faellige Zeilen hervorgehoben mit Erledigt und drei Spaeter-Optionen, kuenftige mit Bearbeiten und Loeschen
- Zeit-Hilfen (morgen zur gleichen Uhrzeit), Zugriffsklassifikation nachgemessen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 14:11:22 +02:00
schalli 325c5ddbf2 feat(260929-if2): Erinnerungen anlegen und zur Faelligkeit benachrichtigen (Tracer)
- Reminder-Tabelle mit Zeilenschutz (Mandant+Benutzer, Systemlesen fuer den E-Mail-Planer), API reminders (Liste, Anlegen)
- Kachel "Erinnerungen", globaler Melder im Portalrahmen (Browser und Desktop, je Faelligkeit einmal)
- Desktop: Laufzeit-Berechtigung fuer Benachrichtigungen nur fuer die gespeicherte Server-Adresse
- Zugriffsklassifikation nachgemessen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 14:11:22 +02:00
schalli cd1f8f6cda style(web): eigene Module ohne Kopfzeile, "In neuem Tab oeffnen" in der App-Leiste
Name- und Hinweiszeile ueber dem Rahmen entfallen (Name steht schon in der
App-Leiste, Nutzerwunsch 29.09.); der Link wandert per Portal in
HEADER_ACTIONS_SLOT_ID. Ungenutzter Schluessel customModules.embedHint weg.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 13:41:09 +02:00
schalli 41d00a3623 fix(desktop): Update-Klick prueft frisch statt veralteten Stand zu installieren
Tessera CI/CD / Lint & Type Check (push) Successful in 53s
Tessera CI/CD / Tests (push) Successful in 1m17s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 6m6s
Tessera CI/CD / Build & Publish Images (push) Successful in 4m32s
Der Tray-Eintrag installierte das beim letzten Check abgelegte Update. Die
Download-Adresse liefert aber immer den aktuellen Installer; nach einem
Server-Update passte die alte Signatur nicht zur neuen Datei ("signature
verification failed", danach Browser-Rueckfall; Nutzer 29.09.). Jetzt
prueft der Klick erst frisch (install_after) und installiert das Ergebnis.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 12:31:49 +02:00
schalli d0e649baa1 docs(changelog): Version 1.7.0 abgeschlossen
Tessera CI/CD / Build & Publish Images (push) Successful in 3m7s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m46s
Tessera CI/CD / Lint & Type Check (push) Successful in 52s
Tessera CI/CD / Tests (push) Successful in 1m24s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:40:19 +02:00
schalli cbc89d9810 style(web): Such-Widget ohne helle Unterkante an Auswahl und Suchfeld
Opt-out-Klasse field-plain fuer die Fluent-Unterkante; im Dunkelmodus
wirkte sie im Such-Widget wie eine weisse Linie (Nutzerwunsch 29.09.).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:34:38 +02:00
schalli 3cb43d6cc0 fix(web): eingeklappt stehen Eintraege aus "Eigene Module" zuletzt
Die eingeklappte Seitenleiste zeigte die Kacheln in Ladefolge; ein Eintrag
aus "Eigene Module" konnte so vor einem eigenen Eintrag anderer
Kategorien stehen. Jetzt gleiche Gruppenfolge wie ausgeklappt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:30:06 +02:00
schalli 4ff9a239fd feat: Kategorie "Eigene Module" fuer selbst angelegte Eintraege
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 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m33s
CUSTOM_MODULE_CATEGORIES = MODULE_CATEGORIES + custom-modules; API prueft
dagegen, das Formular bietet sie an. Die Seitenleiste zeigt die Gruppe wie
jede Kategorie nur mit Eintrag und stellt sie immer ans Ende. Nutzerwunsch
29.09.; ohne Migration (category ist Freitext mit IsIn-Pruefung).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:24:56 +02:00
schalli 00c2cfe2c9 style(web): eingeklappte Seitenleiste mit groesseren Symbolen, dichter
Tessera CI/CD / Lint & Type Check (push) Successful in 1m1s
Tessera CI/CD / Tests (push) Successful in 1m33s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 20s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m29s
Kacheln 24 statt 20 px, Navigationssymbole 22 statt 20 px, Eintraege
34 px hoch mit 1 px Abstand (vorher 36 + 2 px) — Nutzerwunsch 29.09.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 11:17:47 +02:00
schalli cd4b5b56ee docs(changelog): Version 1.6.0 abgeschlossen
Tessera CI/CD / Build & Publish Images (push) Successful in 3m22s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m36s
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m17s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:51:17 +02:00
schalli b15a43f3d3 docs(quick-260929-dzu): eigene Module fuer jeden Benutzer, Browser-Pruefung
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:51:17 +02:00
schalli 8f41bd26bd docs(260929-dzu): Eigene Module fuer jeden Benutzer im CHANGELOG und den Anleitungen
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:29:56 +02:00
schalli ee97b4ed9f feat(260929-dzu): Einstellungen > Eigene Module fuer jeden Benutzer, Verwaltung nur gemeinsam
- gemeinsame Oberflaeche (CustomModuleManager, Formular, Loeschdialog) fuer Verwaltung und Einstellungen
- Einstellungen: persoenliche Eintraege, Verwaltung sendet shared: true und zeigt nur gemeinsame
- Navigation, Texte de/en, Komponententests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:17:22 +02:00
schalli bc4c0119de fix(web): Widgets nicht mehr zur Mitte versetzen
Die Zentrierung der belegten Spalten (Design Mosaik, Runde 3) liess
Widgets nach dem Bearbeiten springen, z. B. ein einzelnes Widget oben
links in die Seitenmitte (Nutzer, live 29.09.). Auf Wunsch ersatzlos
entfernt: Ansicht = Bearbeitungsraster. centeringOffset, breakpointFor
und die ungenutzte Prop onInsetChange entfallen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:13:03 +02:00
schalli c703d87a1c feat(260929-dzu): persoenliche eigene Module je Benutzer (API, Migration, Zeilenschutz)
- ownerUserId (NULL = gemeinsam, sonst persoenlich) mit Zeilenschutz nach Muster SearchProvider
- GET nur gemeinsame + eigene, fremde persoenliche Eintraege 404
- POST fuer jeden Benutzer, shared nur fuer Administratoren (403)
- PATCH/DELETE: persoenlich nur Besitzer, gemeinsam nur Administrator
- Zugriffsklassifikation nachgemessen: 61/223/6

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:09:08 +02:00
schalli 76a923450f fix(desktop): neue Fenster im System-Browser oeffnen
Tessera CI/CD / Lint & Type Check (push) Successful in 59s
Tessera CI/CD / Tests (push) Successful in 1m39s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 8m42s
Tessera CI/CD / Build & Publish Images (push) Successful in 5m7s
Die Webansicht verwarf window.open und Links mit target=_blank still:
Suche-Widget und "In neuem Tab oeffnen" (XFrame, eigene Module,
Favoriten) taten im Client nichts. on_new_window reicht http/https-
Adressen an den System-Browser weiter und lehnt das neue Fenster ab;
andere Schemata werden verworfen (external_target, mit Test).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 10:08:41 +02:00
schalli a435a30c34 docs(quick-260929-dmx): feineres Raster, Kalender schmaler, Browser-Pruefung
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m18s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 19s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m7s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:56:27 +02:00
schalli 46ebb4e7ce docs(changelog): Widget-Raster feiner, Kalender schmaler
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:55:07 +02:00
schalli 9c9e1420fe feat(260929-dmx): Widget-Breiten im 48er-Raster, Kalender schmaler ziehbar
- minW/defaultW aller Widgets verdoppelt (gleiche Bildschirmbreite)
- Kalender minW 8 (rund 250 px), defaultW 16
- Test: bestehender Kalender bekommt das neue Minimum in jedem Breakpoint

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:53:08 +02:00
schalli 97744b59cd feat(260929-dmx): Widget-Raster horizontal doppelt so fein (48 Spalten, Version 3)
- COLS lg 48 / md 40 / sm 24 / xs 16 / xxs 4, Zeilenhoehe und Abstand unveraendert
- Migration stufenweise: v1->v2 wie bisher, v2->v3 nur x/w/minW/maxW x2
- Rueckfallwerte fuer Widgets ohne Eintrag auf 8 Einheiten

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:51:26 +02:00
schalli acd3c7a05f style(web): Widgets heben sich beim Darueberfahren nicht mehr an
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
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 3m11s
Hover-Anheben samt Schatten ersatzlos entfernt (Wunsch des Nutzers:
Kacheln statisch, unabhaengig von Maus/Touch). Einblenden beim Laden
bleibt. CHANGELOG und Anwender-Anleitung angepasst.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:34:45 +02:00
schalli b9c05791b2 docs(quick-260929-d37): Desktop-App nur einmal starten
Tessera CI/CD / Lint & Type Check (push) Successful in 51s
Tessera CI/CD / Tests (push) Successful in 1m19s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 6m58s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m19s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:27:27 +02:00
schalli 0751198822 docs(changelog): Desktop-App startet nicht mehr doppelt
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:26:55 +02:00
schalli c0b145a9b0 fix(desktop): nur eine Instanz — zweiter Start holt das Fenster nach vorne
- tauri-plugin-single-instance als erstes Plugin registriert
- show_main_window-Helper ersetzt die dreifach duplizierte Fenster-Sequenz

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 09:26:44 +02:00
schalli bc260100f6 docs(quick-260929-9wc): eigene Module, Browser-Pruefung dunkel bestanden
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 07:35:42 +02:00
schalli e48c0de238 docs(changelog): eigene Module unter Unveröffentlicht
- Neu-Eintrag in Alltagssprache
- Layout-Wächter der Modulordner kennt den Ordner custom als bewusste Ausnahme ohne Zugriffsschranke

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 07:31:34 +02:00
schalli e7fc4de430 feat(web): Verwaltung „Eigene Module“
- Liste, Anlegen, Bearbeiten und Löschen unter Verwaltung, Adresse wird im Formular geprüft
- Seitenleiste zieht nach jedem Speichern oder Löschen ohne Neuladen nach
- Texte deutsch und englisch, Eintrag in der Admin-Leiste hinter „Module“

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 07:27:16 +02:00
schalli b9d87be360 feat(api,web): eigene Module — Tabelle, API, Seitenleiste, Rahmen-Seite
- Tabelle CustomModule mit Zeilenschutz (tenant_isolation_policy), Migration 20260929120000
- API /custom-modules: Lesen für jeden Angemeldeten, Schreiben nur Administrator, nur https ohne Zugangsdaten
- Seitenleiste zeigt eigene Module unter ihrer Kategorie, Rahmen-Seite mit Sandbox und „In neuem Tab öffnen“
- MODULE_CATEGORIES als gemeinsame Liste, Zugriffsklassifikation nachgemessen fortgeschrieben

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 07:25:33 +02:00
schalli 643b1a2caa wip: quick-auftraege nach 1.5.2 pausiert, eigene Module offen
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:23:26 +02:00
schalli 12eea333ba feat(web): Widget-Titel je Widget ausblenden, Seitenleiste gegliedert
Tessera CI/CD / Build & Publish Images (push) Successful in 2m56s
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 1m17s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m24s
Neuer Schalter im Bearbeitungsmodus (neben dem Griff) setzt hideTitle in
der Widget-Konfiguration (setWidgetConfig im Dashboard-Store, optimistisch
mit Ruecksetzen); in der Ansicht entfaellt die Titelzeile per
data-hide-title, Aktionen der Titelzeile bleiben oben rechts. Kategorien der
Seitenleiste 14 px, Moduleintraege 13 px / 32 px hoch. Version 1.5.2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:21:26 +02:00
schalli bdaa3c8db5 feat(web): Dashboard-Aktionen in die App-Leiste, Bearbeiten als Stift
Tessera CI/CD / Build & Publish Images (push) Successful in 2m53s
Tessera CI/CD / Lint & Type Check (push) Successful in 1m3s
Tessera CI/CD / Tests (push) Successful in 1m35s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m9s
Der Knopf Bearbeiten belegte eine eigene Zeile ueber den Widgets. Die
Aktionen rendern jetzt per Portal in einen Einhaengepunkt rechts in der
Kopfzeile (HEADER_ACTIONS_SLOT_ID); in der Ansicht nur der Stift, im
Bearbeitungsmodus Hintergrund, Widget hinzufuegen und Fertig. Version 1.5.1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:12:38 +02:00
schalli 03026d616f docs(changelog): Version 1.5.0 abgeschlossen
Tessera CI/CD / Build & Publish Images (push) Successful in 2m49s
Tessera CI/CD / Lint & Type Check (push) Successful in 53s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 5m2s
Tessera CI/CD / Tests (push) Successful in 1m13s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:23:55 +02:00
schalli 105b66ed8d docs(quick-260928-ujj): Design Mosaik uebernommen, Hintergrund pro Benutzer
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:23:45 +02:00
schalli cb45d2663a docs(260928-ujj): CHANGELOG und Anleitungen fuer Design Mosaik
- Unveroeffentlicht: Hintergrundwahl (Neu), neues Aussehen (Geaendert), Resize-Fehler (Behoben)
- Anwender-Anleitung: App-Leiste, Seitenleiste, Anmeldeseite, Befehlsleiste, Hintergrund, Kalender
- Entwickler-Anleitung: User.dashboardBackground, PATCH /users/me/dashboard-background, parseDashboardBackground

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:18:51 +02:00
schalli 69d17302e1 fix(260928-ujj): Biome-Formatierung der mit Design Mosaik eingebrachten Dateien
- Nur Formatierung und Importreihenfolge, keine Verhaltensaenderung
- Betrifft ausschliesslich Befunde, die der Merge neu eingebracht hat

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:14:57 +02:00
schalli 0aaa15240d feat(260928-ujj): Dashboard-Hintergrund pro Benutzer in der Datenbank
- Spalte User.dashboardBackground (JSONB) samt Migration
- PATCH /users/me/dashboard-background, geprueft mit parseDashboardBackground aus @tessera/shared (Allowlist, UUID-Bildkennung)
- getMe liefert dashboardBackground normalisiert neben accentColor
- Web liest die Wahl aus dem Auth-Store, speichert ueber die Server-Aktion, alte localStorage-Wahl wird einmalig uebernommen
- Hinweistext: gilt auf jedem Geraet

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:13:49 +02:00
schalli 76f6d87973 test(260928-ujj): rote Tests fuer Dashboard-Hintergrund in der Datenbank
- PATCH me/dashboard-background: Allowlist, UUID-Bildkennung, Mandantenbindung
- getMe liefert dashboardBackground normalisiert
- Web: Uebernahme der alten localStorage-Wahl, Hook liest aus dem Auth-Store

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:08:00 +02:00
schalli 9fa0a3f498 feat(260928-ujj): Design Mosaik uebernehmen
- Bringt den Resize-Achsen-Fallback mit, damit sich Widgets auch bei leicht wackelnder Maus schmaler ziehen lassen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 22:04:52 +02:00
schalli a8a910fd29 wip: design-mosaik paused at 4/7
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:06:28 +02:00
491 changed files with 62631 additions and 3691 deletions
+70
View File
@@ -0,0 +1,70 @@
#!/bin/sh
# appimage-strip-wayland.sh -- entfernt die mitgebrachten libwayland-*.so aus
# dem fertigen Linux-AppImage, packt es neu und signiert es fuer den Updater neu.
#
# Befund 06.10.2026 (EndeavourOS/Arch, weisses Fenster): linuxdeploy packt die
# libwayland-Bibliotheken des Bau-Systems (Debian) ein. Auf Systemen mit
# neuerem Mesa bricht WebKitGTK damit beim Start ab ("Could not create default
# EGL display: EGL_BAD_PARAMETER. Aborting...") -- das Fenster bleibt weiss.
# Nachgestellt in einem archlinux-Container unter Xvfb; ohne die vier
# libwayland-Dateien startet die App dort sauber. Die Bibliotheken gehoeren zum
# Grundsystem jeder Linux-Arbeitsumgebung (GTK haengt ohnehin davon ab), das
# Weglassen nimmt also nichts weg, was auf einem Zielsystem fehlen koennte.
#
# Tauri reicht linuxdeploy keine Ausschlussliste durch (linuxdeploy kennt
# --exclude-library, aber keine Umgebungsvariable dafuer), deshalb
# Nachbearbeitung: entpacken, loeschen, mit dem appimagetool aus Tauris
# eigenem linuxdeploy-plugin-appimage neu packen, `tauri signer sign` schreibt
# die .sig neu (die alte passt nach dem Neupacken nicht mehr).
#
# Aufruf aus dem Repo-Wurzelverzeichnis, nach `tauri build --bundles appimage`.
# Ohne TAURI_SIGNING_PRIVATE_KEY (lokaler Bau) wird nur neu gepackt, die
# veraltete .sig geloescht.
set -eu
APPIMAGE_DIR="apps/desktop/src-tauri/target/release/bundle/appimage"
TOOL_IMAGE="${TAURI_TOOLS_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/tauri}/linuxdeploy-plugin-appimage.AppImage"
APPIMAGE="$(find "$APPIMAGE_DIR" -maxdepth 1 -name '*.AppImage' | head -n 1)"
if [ -z "$APPIMAGE" ]; then
echo "Kein AppImage in $APPIMAGE_DIR gefunden." >&2
exit 1
fi
if [ ! -f "$TOOL_IMAGE" ]; then
echo "appimagetool-Quelle $TOOL_IMAGE fehlt (wird von tauri build angelegt)." >&2
exit 1
fi
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
APPIMAGE_ABS="$(cd "$(dirname "$APPIMAGE")" && pwd)/$(basename "$APPIMAGE")"
(cd "$WORK" && "$APPIMAGE_ABS" --appimage-extract >/dev/null && mv squashfs-root app)
(cd "$WORK" && "$TOOL_IMAGE" --appimage-extract >/dev/null && mv squashfs-root tool)
REMOVED="$(find "$WORK/app" -name 'libwayland-*.so*' -print)"
if [ -z "$REMOVED" ]; then
echo "Keine libwayland-Bibliotheken im AppImage -- nichts zu tun."
exit 0
fi
echo "Entferne aus dem AppImage:"
echo "$REMOVED" | sed "s#$WORK/app/# #"
echo "$REMOVED" | xargs rm -f
# Dieselbe Startdatei (runtime) wie im Original wiederverwenden: kein
# Nachladen aus dem Internet, keine andere runtime-Fassung als von Tauri.
OFFSET="$("$APPIMAGE_ABS" --appimage-offset)"
head -c "$OFFSET" "$APPIMAGE_ABS" > "$WORK/runtime"
ARCH=x86_64 "$WORK/tool/usr/bin/appimagetool" --no-appstream --runtime-file "$WORK/runtime" \
"$WORK/app" "$WORK/neu.AppImage" >/dev/null 2>&1
chmod +x "$WORK/neu.AppImage"
mv "$WORK/neu.AppImage" "$APPIMAGE_ABS"
rm -f "$APPIMAGE_ABS.sig"
if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
pnpm --filter @tessera/desktop exec tauri signer sign "$APPIMAGE_ABS" >/dev/null
test -s "$APPIMAGE_ABS.sig"
echo "AppImage neu gepackt und signiert: $(basename "$APPIMAGE_ABS")"
else
echo "AppImage neu gepackt (ohne Schluessel, keine Signatur): $(basename "$APPIMAGE_ABS")"
fi
+1 -1
View File
@@ -53,7 +53,7 @@
# Dieses Skript kennt kein Secret.
set -eu
DESKTOP_PATHS="apps/desktop .gitea/scripts/desktop-version.sh .gitea/scripts/desktop-collect.sh .gitea/scripts/desktop-stamp.sh .gitea/workflows/ci.yml"
DESKTOP_PATHS="apps/desktop .gitea/scripts/desktop-version.sh .gitea/scripts/desktop-collect.sh .gitea/scripts/desktop-stamp.sh .gitea/scripts/appimage-strip-wayland.sh .gitea/workflows/ci.yml"
out() {
printf '%s=%s\n' "$1" "$2"
+10
View File
@@ -186,6 +186,16 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: pnpm --filter @tessera/desktop exec tauri build --bundles appimage
# 06.10.2026: mitgebrachte libwayland-Bibliotheken liessen WebKitGTK auf
# Arch/EndeavourOS mit "EGL_BAD_PARAMETER" abbrechen (weisses Fenster).
# Skript entfernt sie, packt neu und signiert neu (Begruendung im Skript).
- name: Linux-AppImage nachbearbeiten (libwayland entfernen)
if: steps.reuse.outputs.reuse != 'true'
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: sh .gitea/scripts/appimage-strip-wayland.sh
- name: Windows-Installer bauen (Cross-Bau)
if: steps.reuse.outputs.reuse != 'true'
env:
+47
View File
@@ -0,0 +1,47 @@
---
context: default
phase: quick-auftraege-05.-06.10. (keine GSD-Phase)
task: 0
total_tasks: 0
status: paused
last_updated: 2026-10-06T11:54:52.897Z
---
## Critical Anti-Patterns
| Pattern | Description | Severity | Prevention Mechanism |
|---------|-------------|----------|---------------------|
| Lange Befehlsbloecke an den User | User tippt auf dem Testgeraet von Hand ab; Heredocs/mehrzeilige Bloecke scheiterten, Zeilenumbrueche gehen beim Einfuegen verloren | advisory | Memory feedback-einfache-befehle: selbst nachstellen, sonst EINE kurze Zeile |
| Zuerst diagnostizieren statt Neustart vorschlagen | Weisses Fenster auf EndeavourOS war nach Geraete-Neustart weg | advisory | Bei Darstellungsfehlern auf dem Geraet zuerst Neustart/Updates vorschlagen |
<current_state>
Alles erledigt, committet und gepusht. live = v1.10.1 (87ba56d, laeuft, per /api-proxy/health/version bestaetigt). User: "passt alles".
</current_state>
<completed_work>
- Notiz-Links in neuem Tab; Proxmox-Autostart-Warnung; Exchange-Zeitgrenzen
- Einstellungen/Administration komplett neu (gemeinsame Navigation, PageHeader, SettingsSection)
- Linux-AppImage ohne libwayland; Links ueber System-xdg-open; Favoriten-Logo-Fix
- Freigaben v1.10.0 und v1.10.1
</completed_work>
<remaining_work>
- keine
</remaining_work>
<decisions_made>
- Bereich heisst "Administration"; Profilmenue "Einstellungen/Administration"
- owa.ctl.de extra_hosts nur lokal in alpha-Compose
</decisions_made>
<blockers>
- keine
</blockers>
## Infrastructure State
- Lokaler Stack (3000) mit restart unless-stopped, MailHog 8025
- Design-Klon tessera-design geloescht
<next_action>
Start with: User fragen, was als Naechstes ansteht.
</next_action>
+33
View File
@@ -0,0 +1,33 @@
{
"version": "1.0",
"timestamp": "2026-10-06T11:54:52.897Z",
"phase": null,
"phase_name": "Quick-Auftraege 05./06.10. bis Freigabe 1.10.1 (keine GSD-Phase)",
"phase_dir": null,
"plan": null,
"task": 0,
"total_tasks": 0,
"status": "paused",
"completed_tasks": [
{"id": 1, "name": "261005-blw: Notiz-Links in neuem Tab", "status": "done", "commit": "781bc9f"},
{"id": 2, "name": "261005-bt1: Proxmox-Warnung gestoppter Gast mit Autostart", "status": "done", "commit": "873d15d"},
{"id": 3, "name": "Lokaler Stack restart unless-stopped", "status": "done", "commit": "94bd2a4"},
{"id": 4, "name": "261005-d5d: Exchange-Zeitgrenzen Kalender 15 s / Postfach 60 s, Meldung nicht erreichbar", "status": "done", "commit": "831c7b8"},
{"id": 5, "name": "261005-jqd: Einstellungen/Administration neu (gemeinsame Navigation, PageHeader, Karten), Benennung Administration", "status": "done", "commit": "55b2609"},
{"id": 6, "name": "261006-dcs: Linux-AppImage ohne libwayland", "status": "done", "commit": "da9a65c"},
{"id": 7, "name": "Freigabe v1.10.0", "status": "done", "commit": "d0086fa"},
{"id": 8, "name": "Linux-Links ueber System-xdg-open; Favoriten-Logo ohne durchscheinenden Buchstaben", "status": "done", "commit": "6ccab83"},
{"id": 9, "name": "Freigabe v1.10.1 (live bestaetigt)", "status": "done", "commit": "87ba56d"}
],
"remaining_tasks": [],
"blockers": [],
"async_jobs": [],
"human_actions_pending": [],
"decisions": [
{"decision": "Profilmenue ein Eintrag: 'Einstellungen' bzw. fuer Admins 'Einstellungen/Administration'; Bereich heisst 'Administration'", "rationale": "User 06.10.", "phase": "quick"},
{"decision": "owa.ctl.de-extra_hosts nur in alpha-Compose, nie in git", "rationale": "User 05.10., firmenspezifisch", "phase": "quick"}
],
"uncommitted_files": [],
"next_action": "Nichts offen. User fragen, was als Naechstes ansteht. Linux-Notiz-Links auf EndeavourOS nicht von selbst ansprechen.",
"context_notes": "main = origin/main = live = v1.10.1 (87ba56d). User hat keinen Shell-Zugang zum EndeavourOS-Testgeraet und tippt Befehle von Hand; nur EIN einzeiliger Befehl, vorher selbst nachstellen. alpha-Compose hat lokales extra_hosts owa.ctl.de:217.7.63.3 (nicht in git)."
}
+31 -7
View File
@@ -6,8 +6,8 @@ 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_desc: Quick 260925-bow — Was-ist-neu-Fenster nach Versionswechsel; 1.4.0 am 25.09. freigegeben und live
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:
total_phases: 18
@@ -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-22 - Quick 260922-ge2: XFrame-Ausschnitt waehlen und einpassen, Zoom, Nur anzeigen (Browser-Befund Rahmenhoehe behoben); davor Desktop: Download-Knoepfe in der App oeffnen jetzt den System-Browser (fast, 747a4d4); davor Quick 260922-frg: Tray-Update-Eintrag nennt den Grund einer fehlgeschlagenen Pruefung (HTTP 401 durch Passwortschutz am Proxy vor alpha), Klick prueft erneut, Pruefung alle 4 h; davor Kosmetik am Bilderrahmen (fast, 8b45a28): „1 Stunde“ statt „60 Minuten“, Bildanzahl in der Einstellungs-Kopfzeile; am 21.09. davor Quick 260921-pi9 und 260921-qd3: die zwei bestellten Dashboard-Widgets „Bilderrahmen“ und „XFrame“ gebaut, im Browser nachgewiesen, gepusht
Last activity: 2026-10-08 - Quick 261008-j9f Nextcloud-Logo per http
Progress: [██████████] 99%
@@ -476,6 +476,30 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 260924-i8v | **Proxmox-Kachel fuers Dashboard.** Modul-Kachel ueber den Weg aus 260922-m1h (Typ `proxmox` in packages/shared + Modulbindung, API-Freigabeliste, Registry, Katalog), nur fuer Benutzer mit Modulzugriff. Kompakter Gesundheitsbalken + Zusammenfassung in Worten, Serverliste nach Dringlichkeit mit je einer Kennzahl (Gaeste/Auslastung, aelteste Sicherung, eingehende Mails, unbekannt nie 0), Links auf /modules/proxmox (nicht im Bearbeitungsmodus), liest jede Minute den Zwischenstand (pausiert bei verborgenem Tab, loest NIE eine Abfrage aus), Titel + Serverauswahl an der Kachel und unter Einstellungen > Dashboard, Groessenstufen per Container-Query. Gemeinsame Teile nach `components/proxmox/` verschoben. Browser: Katalog, Kachel hell/dunkel, schmale Stufe (nur Punkte+Namen). web 864, api 1370 gruen. | 2026-09-24 | a906c67,92bf130,a217d60,377b6e3,586da44,602a45c | [260924-i8v-proxmox-kachel-fuers-dashboard](./quick/260924-i8v-proxmox-kachel-fuers-dashboard/) |
| 260924-m4n | **Flackernden Test entschaerft, alte Bildspalte entfernt.** (1) `tenant-selector.test.tsx`: Ursache war das Laden der Bausteine INNERHALB des ersten Tests (zaehlte in dessen 5-s-Grenze) -> Import vorab, Doppelfall getrennt, dasselbe in zwei weiteren Marktplatz-Tests; langsamster Web-Test jetzt < 2 s (mit 2 Kernen 1,3 s); act()-Warnungen der Proxmox-Kachel weg. (2) DashboardImage Stufe 2: Migration `20260924120000_dashboard_image_drop_data` mit Schutz (bricht ab, wenn noch Zeilen ohne `storagePath`; Zeilenschutz fuer die Pruefung abgeschaltet, sonst saehe sie still 0), `storagePath` NOT NULL, `data` weg, `system_read_policy` weg, Bootstrap-Umzug + `forSystem()` entfernt, Upload legt Zeile gleich mit Pfad an. Vorbedingung alpha geprueft (0 von 3 ohne Pfad); Live nicht pruefbar. Rueckweg bei Abbruch in `docs/anleitung-betrieb.md` Kap. 4. Browser/API: Bilder laden, Upload+Anzeige+Loeschen ok. api 1364, web 865 gruen. | 2026-09-24 | b10734f,dd54ec5 | [260924-m4n-flackernden-test-entschaerfen-und-dashbo](./quick/260924-m4n-flackernden-test-entschaerfen-und-dashbo/) |
| 260925-bow | **Was-ist-neu-Fenster nach Versionswechsel.** Spalte `User.lastSeenReleaseVersion` (Migration 20260925120000), Versionsnummer allein aus der API (`GET /users/me/release-notice`, Semver-Funktionen in packages/shared), Fenster im Portal-Rahmen einmal nach Versionswechsel, gemerkt erst beim Schliessen (`POST`), nur freigegebene Versionen (`dev` nie), hoechstens 3 Versionen + Hinweis auf aeltere + Link /changelog; neue Konten bekommen die laufende Version eingetragen; vorhandene ohne Stand sehen nur die aktuelle. Changelog-Text bleibt serverseitig. Browser: 1.3.0 -> Fenster 1.4.0, Verstanden merkt 1.4.0, kein zweites Mal; 1.0.0 -> 1.4.0/1.3.1/1.3.0 + „2 aelteren Versionen“; Link-Kontrast nachgebessert. api 1435, web 924 gruen. | 2026-09-25 | 59db32a,187fb76,5ae9aaa,b3b7b5d | [260925-bow-was-ist-neu-fenster-beim-ersten-anmelden](./quick/260925-bow-was-ist-neu-fenster-beim-ersten-anmelden/) |
| 260928-ujj | **Design Mosaik uebernommen + Hintergrund pro Benutzer.** Merge design/mosaik (76d17fe, inkl. RESIZE_AXIS_FALLBACK), Spalte `User.dashboardBackground` JSONB (Migration 20260928120000), `PATCH /users/me/dashboard-background` mit `parseDashboardBackground` aus packages/shared (Preset-Liste, imageId nur UUID), Web liest aus Sitzung, alte localStorage-Wahl einmalig uebernommen. Browser: Duenen gewaehlt, DB-Zeile gesetzt, nach localStorage-Loeschen weiter sichtbar. api 1462, web 952 gruen; Freigabe als 1.5.0. | 2026-09-28 | 9fa0a3f,0aaa152,cb45d26 | [260928-ujj-design-mosaik-uebernehmen-und-als-1-5-0-](./quick/260928-ujj-design-mosaik-uebernehmen-und-als-1-5-0-/) |
| 260929-9wc | **Eigene Module (nur lokal, nicht gepusht).** Modell `CustomModule` + Migration 20260929120000 mit RLS (Muster ProxmoxServer), `/custom-modules` (GET alle Angemeldeten, POST/PATCH/DELETE Admin, nur https ohne Zugangsdaten), `MODULE_CATEGORIES` in packages/shared, Seitenleisten-Eintrag unter gewaehlter Kategorie, Rahmen-Seite `/modules/custom/[id]` mit XFRAME_SANDBOX + no-referrer + „In neuem Tab öffnen“, Verwaltung `/admin/custom-modules`, Zugriffsklassifikation 61/224/6. Gruppen-Beschraenkung zurueckgestellt (ModuleGrant haengt an Module). api 1495, web 992 gruen; Browser dunkel 9 Schritte bestanden. | 2026-09-29 | b9d87be,e7fc4de,e48c0de | [260929-9wc-eigene-module-admin-legt-seitenleisten-e](./quick/260929-9wc-eigene-module-admin-legt-seitenleisten-e/) |
| 260929-d37 | **Desktop-App nur einmal starten.** User-Meldung Windows 11: beim Systemstart zwei Instanzen/zwei Tray-Symbole. `tauri-plugin-single-instance` 2.4.5 als erstes Plugin, zweiter Start ruft `show_main_window` (neuer Helper, ersetzt 3 Kopien) und beendet sich. cargo build/test (44)/clippy gruen. Windows-Pruefung offen (VM 8233 oder User-PC nach naechster Desktop-Version). | 2026-09-29 | c0b145a,0751198 | [260929-d37-desktop-client-nur-einmal-starten-single](./quick/260929-d37-desktop-client-nur-einmal-starten-single/) |
| 260929-dmx | **Widget-Raster horizontal feiner + Kalender schmaler.** COLS lg 48/md 40/sm 24/xs 16/xxs 4, GRID_VERSION 3 (v2->v3 nur x/w/minW/maxW x2), alle minW/defaultW x2, Kalender minW 8 (~250 px). Browser: Anordnung pixelgleich, Kalender bis 252 px, Schritt 33 px. Auch: Hover-Anheben der Widgets entfernt (acd3c7a, Nutzerwunsch). | 2026-09-29 | 97744b5,9c9e142,46ebb4e | [260929-dmx-widget-raster-horizontal-feiner-48-spalt](./quick/260929-dmx-widget-raster-horizontal-feiner-48-spalt/) |
| 260929-dzu | **Eigene Module fuer jeden Benutzer (persoenlich).** `CustomModule.ownerUserId` (null = gemeinsam), RLS-Muster SearchProvider, Einstellungen > Eigene Module (nur eigene), Verwaltung nur gemeinsame; Browser: Sichtbarkeit/Rechte wie verlangt. Nebenbei ohne eigenen Quick: Zentrierung entfernt (bc4c011), Desktop neue Fenster -> System-Browser (76a9234, Windows-VM bestaetigt), Single-Instance auf VM bestaetigt. | 2026-09-29 | c703d87,ee97b4e,8f41bd2 | [260929-dzu-eigene-module-fuer-jeden-benutzer-persoe](./quick/260929-dzu-eigene-module-fuer-jeden-benutzer-persoe/) |
| 260929-if2 | **Erinnerungen-Widget (Reminder).** Modell `Reminder` + RLS, API /reminders (anlegen/listen/bearbeiten/loeschen/erledigt/snooze, 409/404-Regeln), E-Mail-Scheduler alle 30 s mit Claim-once + max. 3 Versuche, globaler ReminderNotifier (Browser-Notification, Desktop via Tauri-Notification mit Laufzeit-Capability nur fuer die Server-Origin, Pattern escaped + vorab geprueft). Verifier human_needed (Windows-Toast offen); Browser dunkel bestanden inkl. echter Mail ueber MailHog. api 1570, web 1069, cargo 57. Nebenbei: eigene Module ohne Kopfzeile (cd1f8f6), Update-Klick prueft frisch (41d00a3). | 2026-09-29 | 325c5dd,709b41a,6879c75 | [260929-if2-reminder-widget-mit-benachrichtigung](./quick/260929-if2-reminder-widget-mit-benachrichtigung/) |
| 260929-lh3 | **Favoriten: eigene Symbol-Adresse wirkt.** Neue iconUrl ersetzt Upload + bumpt iconVersion; iconUrl wird auch gespeichert, wenn nur der Browser sie laden kann (kein 422 mehr, nur Formpruefung); Kachel: Proxy -> iconUrl direkt -> origin/favicon -> Buchstabe; Discovery liest <link rel=icon> auch aus Nicht-2xx-Seiten (docuvita 400). | 2026-09-29 | 7188c5b,b15c746,0e72ad4 | [260929-lh3-favoriten-eigenes-symbol-wirkt-nicht](./quick/260929-lh3-favoriten-eigenes-symbol-wirkt-nicht/) |
| 261001-cxo | Desktop-Client: Links mit target=_blank (Favoriten) oeffnen jetzt im System-Browser (DesktopExternalLinks -> window.open) | 2026-10-01 | 61a971c | [261001-cxo](./quick/261001-cxo-desktop-client-links-mit-target-blank-oe/) |
| 261001-g68 | Erinnerung: Cursor sprang beim Schreiben der Beschreibung in den Titel (Fokus-Effekt hing an inline onClose, Kachel zeichnet alle 10 s neu) – Fokus nur beim Oeffnen | 2026-10-01 | siehe git log | [261001-g68](./quick/261001-g68-erinnerung-cursor-springt-aus-beschreibu/) |
| 261001-hbi | Favoriten: Logo fuer per JavaScript gesetzte Symbole (hosteurope.de) – Rueckfall auf DuckDuckGo-Symboldienst beim Ausliefern, nur oeffentliche Seiten | 2026-10-01 | siehe git log | [261001-hbi](./quick/261001-hbi-favoriten-logo-fuer-per-javascript-geset/) |
| 261001-l4q | Zertifikat-Manager: Reiter Übersicht (Paket/ZIP hochladen, Teile erkennen/zuordnen, jedes Teil in jedem Format) + Desktop speichert blob-Downloads selbst | 2026-10-01 | siehe git log | [261001-l4q](./quick/261001-l4q-zertifikatsmodul-paket-hochladen-uebersi/) |
| 261002-fm5 | Finanzbuchhaltung: Module Kantinenabrechnung und Handelsware (DATEV-Export), im Browser nachgewiesen | 2026-10-02 | 1f85277..HEAD | [261002-fm5-finanzbuchhaltung-module-kantinenabrechn](.planning/quick/261002-fm5-finanzbuchhaltung-module-kantinenabrechn/) |
| 261002-icv | Modul-Freigabe mit Stufe Verwalten (Modul-Einstellungen ohne Admin; Kantine, Handelsware, Proxmox, DKV), im Browser nachgewiesen | 2026-10-02 | a222711..HEAD | [261002-icv-modul-freigabe-mit-stufe-verwalten-modul](.planning/quick/261002-icv-modul-freigabe-mit-stufe-verwalten-modul/) |
| 261002-k67 | Modul Nextcloud-Status mit Ampel-Kacheln und Dashboard-Uebersicht | 2026-10-02 | 5ef7b0c..87a7b7c | [261002-k67-modul-nextcloud-status-mit-ampel-kacheln](.planning/quick/261002-k67-modul-nextcloud-status-mit-ampel-kacheln/) |
| 261002-kxc | Nextcloud-Status: Benachrichtigung bei Rot je Benutzer (Mail + Desktop-Hinweis), Klartext-Fehler | 2026-10-02 | faed0d7..6c4bff6 | [261002-kxc-nextcloud-status-benachrichtigung-bei-ro](.planning/quick/261002-kxc-nextcloud-status-benachrichtigung-bei-ro/) |
| 261003-387 | Kategorien durch Admins bearbeitbar (anlegen, umbenennen, sortieren, loeschen mit Verschieben, Module zuordnen) | 2026-10-03 | 8ec116c..f2c0a89 | [261003-387-kategorien-durch-admins-bearbeitbar-umbe](.planning/quick/261003-387-kategorien-durch-admins-bearbeitbar-umbe/) |
| 261005-blw | Links in Notiz-Kacheln oeffnen in neuem Tab | 2026-10-05 | 781bc9f | [261005-blw-notiz-links-in-neuem-tab](.planning/quick/261005-blw-notiz-links-in-neuem-tab/) |
| 261005-bt1 | Proxmox: Warnung je gestopptem Gast mit aktivem Autostart (Karte, Kachel) | 2026-10-05 | 873d15d | [261005-bt1-proxmox-autostart-warnung](.planning/quick/261005-bt1-proxmox-autostart-warnung/) |
| 261005-d5d | Kalender-Test: 15-s-Zeitgrenze fuer Exchange (EWS), Meldung „nicht erreichbar“ | 2026-10-05 | e49d4c7..831c7b8 | [261005-d5d-kalender-test-zeitgrenze](.planning/quick/261005-d5d-kalender-test-zeitgrenze/) |
| 261005-jqd | Einstellungen und Verwaltung aufgeraeumt (gemeinsame Navigation, Seitenkopf, Karten; „Verwaltung“) | 2026-10-05 | 0a35a32 + (dieser Commit) | [261005-jqd-verwaltung-aufgeraeumt](.planning/quick/261005-jqd-verwaltung-aufgeraeumt/) |
| 261006-dcs | Linux-App: weißes Fenster auf Arch/EndeavourOS (libwayland aus AppImage entfernt) | 2026-10-06 | (dieser Commit) | [261006-dcs-linux-app-weisses-fenster-arch](.planning/quick/261006-dcs-linux-app-weisses-fenster-arch/) |
| 261008-dts | Modul Domains: AutoDNS-Anbindung (Zugang Demo/Live, Kunden, Kontakte, Domainliste, Registrieren ohne Doppelbestellung, Aufträge) — Needs Review (Demo-Prüfung offen) | 2026-10-08 | 53b73dd | [261008-dts-modul-domains-autodns-anbindung-kontakte](.planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/) |
| 261008-h3t | Domains: Standard-Nameserver aus AutoDNS-Profil statt Tessera-Einstellung (nur Anzeige, Sperre wenn keine) — Demo-Prüfung offen | 2026-10-08 | 2176f8e | [261008-h3t-domains-nameserver-aus-autodns-statt-tes](.planning/quick/261008-h3t-domains-nameserver-aus-autodns-statt-tes/) |
| 261008-j9f | Nextcloud-Status: http-Logo-Adresse wird einmalig abgeholt (nur Internet, SSRF-Schutz), Formular nur deutsche Meldungen | 2026-10-08 | 166a6fc | [261008-j9f-nextcloud-status-logo-per-http-adresse-h](.planning/quick/261008-j9f-nextcloud-status-logo-per-http-adresse-h/) |
## Deferred Items
@@ -517,8 +541,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-22 - Quick 260922-hk4: Bilderrahmen-Bilder im Dateibereich, Selbstheilung aus der alten Spalte
Last activity: 2026-09-29 - Quick 260929-if2 Erinnerungen-Widget (lokal, nicht gepusht); v1.7.0 auf alpha+live
@@ -0,0 +1,193 @@
---
phase: quick-260928-ujj
plan: 01
quick_id: 260928-ujj
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [QUICK-260928-ujj]
files_modified:
- apps/web/** (Merge design/mosaik, 115 Dateien, nur apps/web)
- packages/shared/src/index.ts
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql (neu)
- apps/api/src/auth/auth.service.ts
- apps/api/src/auth/auth.service.spec.ts
- apps/api/src/user/user.controller.ts
- apps/api/src/user/user.controller.spec.ts
- apps/web/src/lib/auth-actions.ts
- apps/web/src/lib/stores/auth-store.ts
- apps/web/src/components/layout/header.tsx
- apps/web/src/components/layout/header.test.tsx
- apps/web/src/lib/dashboard-background.ts
- apps/web/src/lib/dashboard-background.test.ts
- apps/web/src/components/dashboard/dashboard-background.tsx
- apps/web/src/components/dashboard/dashboard-background.test.tsx (neu)
- "apps/web/src/app/(portal)/page.tsx"
- "apps/web/src/app/(portal)/page.test.tsx"
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- docs/mandantentrennung-zugriffsklassifikation.md
- docs/anleitung-anwender.md
- docs/anleitung-entwicklung.md
- CHANGELOG.md
estimate:
tokens: 95000
raw_tokens: 95000
tasks: 3
confidence: low
must_haves:
truths:
- "main enthaelt das freigegebene Design Mosaik als Merge-Commit mit design/mosaik (76d17fe) als zweitem Elternteil; apps/web-Tests sind gruen"
- "Widgets lassen sich schmaler ziehen, auch wenn die Maus dabei leicht wackelt (RESIZE_AXIS_FALLBACK in dashboard-grid.tsx, Test in dashboard-grid.test.tsx gruen)"
- "Der gewaehlte Dashboard-Hintergrund steht pro Benutzer in der Datenbank (User.dashboardBackground) und kommt ueber dieselbe Anmelde-/Sitzungsantwort zurueck wie accentColor — im zweiten Browser erscheint derselbe Hintergrund"
- "PATCH /users/me/dashboard-background nimmt nur 'none', bekannte Preset-Kennungen oder eine UUID-Bildkennung an; alles andere ergibt 400 und schreibt nichts"
- "Eine bereits im localStorage gespeicherte Wahl wird einmalig in die Datenbank uebernommen und der alte Schluessel entfernt"
- "CHANGELOG.md 'Unveroeffentlicht' beschreibt das neue Aussehen (Neu/Geaendert) und den Resize-Fehler (Behoben); die Anwender-Anleitung beschreibt die Hintergrundwahl"
artifacts:
- path: apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql
provides: "Spalte User.dashboardBackground (JSONB, nullable)"
contains: "dashboardBackground"
- path: packages/shared/src/index.ts
provides: "DASHBOARD_BACKGROUND_PRESET_IDS, Typ DashboardBackground, parseDashboardBackground() — eine Pruefregel fuer API und Web"
contains: "parseDashboardBackground"
- path: apps/api/src/user/user.controller.ts
provides: "PATCH me/dashboard-background"
contains: "me/dashboard-background"
- path: apps/web/src/components/dashboard/dashboard-background.tsx
provides: "useDashboardBackground liest aus dem Auth-Store und speichert ueber die Server-Aktion"
key_links:
- from: apps/web/src/components/dashboard/dashboard-background.tsx
to: "PATCH /users/me/dashboard-background"
via: "updateDashboardBackgroundAction in apps/web/src/lib/auth-actions.ts"
pattern: "updateDashboardBackgroundAction"
- from: apps/api/src/auth/auth.service.ts
to: "User.dashboardBackground"
via: "select neben accentColor, Ausgabe durch parseDashboardBackground normalisiert"
pattern: "dashboardBackground: true"
- from: apps/web/src/components/layout/header.tsx
to: apps/web/src/lib/stores/auth-store.ts
via: "setUser-Abbildung uebernimmt dashboardBackground aus der Sitzung"
pattern: "dashboardBackground"
---
<objective>
Das vom Nutzer abgenommene Design „Mosaik“ (Zweig design/mosaik, nur apps/web) in main uebernehmen, die Hintergrundwahl des Dashboards von localStorage auf ein Datenbankfeld pro Benutzer umstellen (Muster accentColor) und CHANGELOG sowie Anleitungen fuer Version 1.5.0 vorbereiten.
Purpose: Das neue Aussehen samt Resize-Fix soll als 1.5.0 ausgeliefert werden; der Hintergrund soll dem Benutzer auf jedem Geraet folgen statt an einem Browser zu kleben.
Output: Merge-Commit, Migration + API-Weg + Web-Anbindung mit Tests, CHANGELOG-/Doku-Eintraege. Release (Tag, live-Zweig, Push) ist NICHT Teil dieses Plans — nicht pushen.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@CLAUDE.md
@.planning/HANDOFF.json
Fakten (nicht neu herleiten):
- Zweig design/mosaik liegt lokal (Spitze 76d17fe, 15 Commits auf 3fc33e3), aendert nur apps/web (115 Dateien, keine package.json/Lockfile). Merge ist konfliktfrei. Die nicht committete Aenderung an .planning/HANDOFF.json beruehrt der Merge nicht — NICHT stagen, NICHT verwerfen.
- Hintergrund-Datentyp heute in apps/web/src/lib/dashboard-background.ts (Stand design/mosaik): kind 'none' | 'preset' (id aus mist, pebble, bloom, dunes, mosaic) | 'image' (imageId = Kennung eines Bilderrahmen-Bildes, DashboardImage.id ist uuid()). Speicherung per localStorage-Schluessel tessera.dashboardBackground.<userId>.
- Vorbild accentColor: schema.prisma Zeile ~45; Auswahl in apps/api/src/auth/auth.service.ts (~Zeile 320-345, select mit accentColor, speist die Sitzungsantwort); PATCH me/accent-color in apps/api/src/user/user.controller.ts (~Zeile 463-484, forTenant + where id currentUser.id, Inline-Body-Typ); Web: AuthUser in auth-actions.ts und stores/auth-store.ts, updateAccentColorAction in auth-actions.ts (~Zeile 220), setUser-Abbildung in components/layout/header.tsx (~Zeile 47-55).
- Vorbild Migration: apps/api/prisma/migrations/20260925120000_user_last_seen_release/migration.sql (deutscher Kopfkommentar, Hinweis auf auth_lookup_*-Funktionen mit fester Spaltenliste).
- NestJS-Routenreihenfolge: @Patch(':id') steht bei Zeile ~273; zweisegmentige Pfade wie me/accent-color werden davon nicht verschattet — der neue Pfad me/dashboard-background ist ebenfalls zweisegmentig.
- GET /dashboard/images/:id prueft den Besitz (dashboard-images.service.ts) — eine fremde Bildkennung liefert nur 404, deshalb reicht serverseitig die Formatpruefung.
- RLS-Inventar-Test apps/api/src/prisma/rls-access-inventory.spec.ts vergleicht Paare (Datei, Modell) gegen docs/mandantentrennung-zugriffsklassifikation.md; user.controller.ts + user existiert schon gebunden, also kein neues Paar — nur den Zeilentext fortschreiben.
- Lokale DB hat keinen Host-Port: Prisma vom Host ueber die Container-IP (172.19.x, docker inspect) mit tessera:tessera_dev.
- CHANGELOG.md wird vom Was-ist-neu-Fenster geparst: Ueberschriftenformat „## Unveröffentlicht“ / „### Neu|Geändert|Behoben“ exakt beibehalten.
</context>
<tasks>
<task type="auto">
<name>Task 1: Design Mosaik in main mergen</name>
<files>apps/web/** (aus design/mosaik)</files>
<action>Auf main (HEAD a8a910f) pruefen, dass design/mosaik auf 76d17fe steht und `git diff --name-only 3fc33e3 design/mosaik` ausschliesslich apps/web-Pfade zeigt. Dann `git merge --no-ff design/mosaik -m "feat(260928-ujj): Design Mosaik uebernehmen"` ausfuehren (Nachricht kurz, deutsch ohne Umlaute; im Rumpf eine Zeile, dass der Merge den Resize-Achsen-Fallback fuer schmaler gezogene Widgets mitbringt). .planning/HANDOFF.json bleibt unangetastet und ungestaged. Kein pnpm install noetig (keine Abhaengigkeitsaenderung). Danach Web-Tests und Typpruefung laufen lassen; schlaegt etwas fehl, das im Klon gruen war, Ursache beheben und als eigener Commit fix(260928-ujj) nachziehen — nicht in den Merge-Commit falten.</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && git merge-base --is-ancestor 76d17fe HEAD && grep -q RESIZE_AXIS_FALLBACK apps/web/src/components/dashboard/dashboard-grid.tsx && pnpm --filter web test && pnpm --filter web type-check</automated>
</verify>
<done>Merge-Commit auf main mit 76d17fe als Elternteil; dashboard-grid.tsx enthaelt RESIZE_AXIS_FALLBACK; `pnpm --filter web test` und `pnpm --filter web type-check` gruen; HANDOFF.json weiterhin nur als lokale Aenderung vorhanden.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Dashboard-Hintergrund pro Benutzer in der Datenbank</name>
<files>packages/shared/src/index.ts, apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql, apps/api/src/auth/auth.service.ts, apps/api/src/auth/auth.service.spec.ts, apps/api/src/user/user.controller.ts, apps/api/src/user/user.controller.spec.ts, apps/web/src/lib/auth-actions.ts, apps/web/src/lib/stores/auth-store.ts, apps/web/src/components/layout/header.tsx, apps/web/src/components/layout/header.test.tsx, apps/web/src/lib/dashboard-background.ts, apps/web/src/lib/dashboard-background.test.ts, apps/web/src/components/dashboard/dashboard-background.tsx, apps/web/src/components/dashboard/dashboard-background.test.tsx, apps/web/src/app/(portal)/page.tsx, apps/web/src/app/(portal)/page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, docs/mandantentrennung-zugriffsklassifikation.md</files>
<behavior>
- API: PATCH me/dashboard-background mit background {kind:'none'} / {kind:'preset', id:'dunes'} / {kind:'image', imageId:<uuid>} schreibt genau das normalisierte Objekt (Zusatzschluessel entfernt) per forTenant mit where id = currentUser.id und liefert {success:true, dashboardBackground}
- API: unbekannte Preset-Kennung, unbekanntes kind, imageId kein UUID (z. B. 'img-1', mit Anfuehrungszeichen/Klammern, laenger als 36), background null/fehlend/kein Objekt/Array ergibt BadRequestException und kein update-Aufruf
- API: auth.service liefert dashboardBackground neben accentColor; gespeichertes gueltiges Objekt kommt normalisiert zurueck, NULL oder ungueltiger Inhalt kommt als null zurueck
- Web-Lib: takeLegacyDashboardBackground(userId) liefert eine gueltige alte localStorage-Wahl und entfernt den Schluessel; ungueltiger Wert ergibt null und entfernt ebenfalls; gesperrter Speicher ergibt null ohne Ausnahme
- Web-Lib: Preset-Kennungen in BACKGROUND_PRESETS sind deckungsgleich mit DASHBOARD_BACKGROUND_PRESET_IDS aus @tessera/shared
- Web-Hook: background kommt aus user.dashboardBackground im Auth-Store (null ergibt 'none'); choose() setzt den Store sofort und ruft updateDashboardBackgroundAction; bei Fehlschlag wird der vorige Wert zurueckgesetzt
- Web-Hook: ist der Server-Wert null und liegt eine alte localStorage-Wahl vor, wird sie genau einmal gespeichert; ist der Server-Wert gesetzt, passiert keine Uebernahme
</behavior>
<action>Zuerst die Tests aus dem behavior-Block schreiben (rot), dann umsetzen.
Gemeinsame Pruefregel: In packages/shared/src/index.ts DASHBOARD_BACKGROUND_PRESET_IDS (mist, pebble, bloom, dunes, mosaic als const-Tupel), Typ DashboardBackgroundPresetId, Typ DashboardBackground (drei Faelle wie im Web heute) und parseDashboardBackground(value: unknown): DashboardBackground | null ergaenzen. Die Funktion baut immer ein frisches Objekt nur aus den erlaubten Feldern; imageId muss eine UUID (8-4-4-4-12 Hex, Gross/Klein egal) sein — das haelt auch jede CSS-Einschleusung in den spaeteren url("...")-Stil fern. Deutscher Kommentar mit Verweis quick-260928-ujj im Stil der Datei.
API: In schema.prisma am User nach lastSeenReleaseVersion das Feld dashboardBackground Json? mit Kommentar (quick-260928-ujj, null = nie gewaehlt, sonst normalisiertes Objekt inkl. kind none). Migration 20260928120000_user_dashboard_background/migration.sql von Hand im Stil der Vorlage: deutscher Kopfkommentar (Zweck, NULL-Bedeutung, kein Backfill, auth_lookup_* unberuehrt) und ALTER TABLE "User" ADD COLUMN "dashboardBackground" JSONB. Danach `pnpm --filter api exec prisma generate`. Laeuft der lokale Stack, die Migration zusaetzlich per prisma migrate deploy gegen die Container-IP der db einspielen (tessera:tessera_dev, DB-Name aus .env/Compose) — kein Gate. In auth.service.ts im select neben accentColor dashboardBackground aufnehmen und in der Rueckgabe durch parseDashboardBackground normalisieren; auth.service.spec.ts Fixture/Erwartungen (~Zeile 500-540) ergaenzen. In user.controller.ts direkt nach updateAccentColor eine Methode updateDashboardBackground mit @Patch('me/dashboard-background'), Inline-Body-Typ mit background: unknown (wie accent-color, bewusst keine DTO-Klasse — die globale ValidationPipe mit whitelist wuerde verschachtelte Felder sonst nicht pruefen), parseDashboardBackground, bei null BadRequestException('Invalid dashboard background.'), sonst forTenant(...).user.update mit where id currentUser.id und data dashboardBackground; JSDoc mit Bedrohungsverweis T-ujj-01/02. Tests als neuer describe-Block „Dashboard-Hintergrund (quick-260928-ujj)“ in user.controller.spec.ts nach dem Muster des Was-ist-neu-Blocks. In docs/mandantentrennung-zugriffsklassifikation.md die Zeile zu apps/api/src/user/user.controller.ts fortschreiben: seit quick-260928-ujj schreibt der Selbstbedienungsweg PATCH me/dashboard-background dashboardBackground, ebenfalls forTenant mit where id currentUser.id, ohne Kennungsparameter.
Web: AuthUser in auth-actions.ts und stores/auth-store.ts um dashboardBackground?: DashboardBackground | null (Typ aus @tessera/shared) erweitern; updateDashboardBackgroundAction(background) als Server-Aktion nach dem Muster updateAccentColorAction (PATCH /users/me/dashboard-background, Body mit background). header.tsx setUser-Abbildung um dashboardBackground erweitern, header.test.tsx-Fixture nachziehen. apps/web/src/lib/dashboard-background.ts: Typ und Preset-Kennungen aus @tessera/shared beziehen (BackgroundPresetId als Alias behalten, falls genutzt), BACKGROUND_PRESETS/presetBackground bleiben; loadDashboardBackground und saveDashboardBackground ersetzen durch takeLegacyDashboardBackground(userId) (liest, prueft mit parseDashboardBackground, entfernt Schluessel); Kopfkommentar aktualisieren (Speicherung jetzt in der Datenbank, „Prototyp“ entfernen). components/dashboard/dashboard-background.tsx: useDashboardBackground liest user aus useAuthStore statt userId-Parameter; choose() wie im behavior-Block (optimistisch, bei Fehlschlag zuruecksetzen); einmalige Uebernahme der alten Wahl per useRef je Benutzerkennung. Aufrufstelle in app/(portal)/page.tsx anpassen (Kommentar „Prototyp mit localStorage“ ersetzen), page.test.tsx bei Bedarf nachziehen. Hook-Tests in neuer Datei components/dashboard/dashboard-background.test.tsx mit gemocktem @/lib/auth-actions. Hinweistext dashboard.background.hint in de.json auf „Gilt nur für Sie – auf jedem Gerät, auf dem Sie sich anmelden.“ und in en.json sinngemaess („Applies only to you – on every device you sign in on.“) aendern; bestehende Tests, die den alten Text pruefen, anpassen.
Commit(s): test(260928-ujj) fuer die roten Tests, feat(260928-ujj): Dashboard-Hintergrund pro Benutzer in der Datenbank — deutsch ohne Umlaute.</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/shared type-check && pnpm --filter api type-check && pnpm --filter api test && pnpm --filter web type-check && pnpm --filter web test && grep -q '"dashboardBackground" JSONB' apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql && ! grep -v '^\s*//' apps/web/src/lib/dashboard-background.ts | grep -q 'setItem'</automated>
</verify>
<done>Migration und Schemafeld vorhanden, Prisma-Client generiert; PATCH me/dashboard-background prueft und speichert, Sitzungsantwort liefert dashboardBackground; Web liest aus dem Store und speichert ueber die API, alte localStorage-Wahl wird einmalig uebernommen; api- und web-Tests inkl. rls-access-inventory.spec.ts gruen, Typpruefung aller drei Pakete sauber.</done>
</task>
<task type="auto">
<name>Task 3: CHANGELOG, Anleitungen, Abschlusspruefung</name>
<files>CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-entwicklung.md</files>
<action>Vorher `git log --format='%h %s%n%b' 3fc33e3..design/mosaik` lesen, um alle sichtbaren Aenderungen zu erfassen. CHANGELOG.md, Abschnitt „## Unveröffentlicht“ (bestehenden Neu-Eintrag zum Was-ist-neu-Fenster behalten), in Alltagssprache, Sie-Form, ganze Saetze, Stil der bisherigen Eintraege, keine Fachbegriffe:
- „### Neu“: ein Eintrag zum waehlbaren Dashboard-Hintergrund (Knopf „Hintergrund“: keiner, ruhige Flaechen und Motive, eigenes Bild aus den Bilderrahmen-Bildern oder neu hochgeladen; gilt nur fuer Sie und folgt Ihnen auf jedes Geraet und in die Desktop-App; eine bisher im Browser gemerkte Wahl wird automatisch uebernommen).
- „### Geändert“ (neu anlegen, zwischen Neu und Behoben): drei bis vier Eintraege — (1) neues Aussehen: dunkle App-Leiste, neu gestaltete Seitenleiste mit Modul-Kacheln, deutschen Kategorienamen und der Begruessung unten, Akzentfarbe nur beim Modul im Fokus; (2) neue Anmeldeseite, geteilt mit dunklem Markenbereich und Farbmosaik; (3) Dashboard: Kacheln mittig ausgerichtet, Widgets mit gelbem Symbol-Feld, heben sich beim Darueberfahren leicht an und blenden beim Laden sanft ein, Begruessung/Befehlsleiste ueber den Kacheln, ruhigere Kalender-, Favoriten- und Notiz-Kacheln; (4) Kalender: Terminliste einzeilig mit „Heute“/„Morgen“ statt Datum. Eintraege aus den Commit-Rumpfen ergaenzen, die fuer Anwender sichtbar sind (z. B. mobile Schublade), nichts Internes.
- „### Behoben“ (neu anlegen): Widgets liessen sich manchmal nicht schmaler ziehen, wenn die Maus dabei leicht wackelte — jetzt klappt das zuverlaessig.
docs/anleitung-anwender.md knapp nachziehen: Abschnitt „Aufbau der Oberfläche“ (dunkle App-Leiste, Seitenleiste mit Modul-Kacheln und Begruessung unten — nur was sich wirklich geaendert hat, gegen den gemergten Code pruefen), Abschnitt „Anmeldung“ falls die Seite beschrieben ist, Abschnitt „Dashboard“ um einen Absatz **Hintergrund** (Knopf, Auswahl, pro Benutzer gespeichert, im dunklen Erscheinungsbild werden eigene Bilder abgedunkelt und „Blüte“ durch „Nebel“ ersetzt), Kalender-Zeile der Widget-Tabelle (einzeilige Terminliste, „Heute“/„Morgen“). docs/anleitung-entwicklung.md: kurzer Absatz neben der Stelle zu User.lastSeenReleaseVersion (~Zeile 669) zu User.dashboardBackground, PATCH /users/me/dashboard-background und parseDashboardBackground in @tessera/shared als einzige Pruefregel.
Abschlusspruefung: web- und api-Build, Biome auf allen in dieser Aufgabe und im Merge geaenderten ts/tsx-Dateien ohne Fehler (Fehler in unveraenderten Dateien sind ausser Umfang; Biome-Fehler in gemergten Dateien beheben als fix(260928-ujj)). Laeuft der lokale Stack: api und web mit --build neu starten und per Playwright MCP pruefen — Hintergrund waehlen, Seite neu laden, in einem zweiten Browserkontext (gleicher Benutzer) erscheint derselbe Hintergrund; nie per fetch aus der Seite messen. Kein Push, kein Tag. Commit docs(260928-ujj): CHANGELOG und Anleitungen fuer Design Mosaik.</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && awk '/^## Unver/{f=1;next} /^## [0-9]/{f=0} f' CHANGELOG.md | grep -c '^### \(Neu\|Geändert\|Behoben\)$' | grep -q '^3$' && grep -q 'Hintergrund' docs/anleitung-anwender.md && grep -q 'dashboardBackground' docs/anleitung-entwicklung.md && pnpm exec biome check $(git diff --name-only --diff-filter=AM 3fc33e3 HEAD -- '*.ts' '*.tsx') && pnpm --filter api build && pnpm --filter web build</automated>
</verify>
<done>CHANGELOG „Unveröffentlicht“ hat Neu, Geändert und Behoben mit den beschriebenen Eintraegen; Anwender- und Entwickler-Anleitung beschreiben Hintergrundwahl und neues Aussehen; Biome ohne Fehler auf den geaenderten Dateien; api- und web-Build erfolgreich; alles committet, nichts gepusht.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser -> API PATCH /users/me/dashboard-background | Unvertrauter JSON-Body wird gespeichert und spaeter als CSS-Stil (url("...")) gerendert |
| DB -> Web (Sitzungsantwort) | Gespeicherter JSON-Wert fliesst in style-Attribut des Dashboard-Hintergrunds |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-ujj-01 | Tampering | user.controller.ts updateDashboardBackground / parseDashboardBackground | medium | mitigate | Allowlist fuer kind und Preset-Kennungen, imageId nur als UUID, frisch aufgebautes Objekt ohne Zusatzschluessel; ungueltig ergibt 400 ohne Schreibzugriff; Ausgabe in auth.service erneut durch parseDashboardBackground |
| T-ujj-02 | Elevation of Privilege | PATCH me/dashboard-background | medium | mitigate | Kein Kennungsparameter; forTenant(prisma, currentUser.tenantId).user.update mit where id = currentUser.id |
| T-ujj-03 | Information Disclosure | imageId eines fremden Bildes | low | accept | GET /dashboard/images/:id prueft Besitz; fremde Kennung ergibt nur ein fehlendes Bild beim eigenen Benutzer |
| T-ujj-04 | Denial of Service | uebergrosser Body | low | accept | Express-JSON-Grenze greift; gespeichert wird nur das normalisierte Kleinobjekt |
</threat_model>
<verification>
- `git merge-base --is-ancestor 76d17fe main` ist erfolgreich (Merge-Commit mit 76d17fe als zweitem Elternteil)
- `pnpm --filter web test`, `pnpm --filter api test` gruen; type-check fuer shared, api, web sauber
- Biome ohne Fehler auf geaenderten ts/tsx-Dateien; `pnpm --filter api build` und `pnpm --filter web build` erfolgreich
- Nichts gepusht, kein Tag; .planning/HANDOFF.json unveraendert als lokale Aenderung
</verification>
<success_criteria>
main traegt das Design Mosaik inklusive Resize-Fix, der Dashboard-Hintergrund wird pro Benutzer in der Datenbank gespeichert und geprueft, CHANGELOG und Anleitungen sind fuer 1.5.0 vorbereitet — bereit fuer die Freigabe durch den Orchestrator.
</success_criteria>
<output>
Create `.planning/quick/260928-ujj-design-mosaik-uebernehmen-und-als-1-5-0-/260928-ujj-SUMMARY.md` when done
</output>
@@ -0,0 +1,131 @@
---
phase: quick-260928-ujj
plan: 01
quick_id: 260928-ujj
status: complete
subsystem: web, api, shared, docs
tags: [design-mosaik, dashboard-background, prisma-migration, changelog, 1.5.0]
requires:
- design/mosaik (76d17fe)
provides:
- Design Mosaik auf main (Merge 9fa0a3f)
- User.dashboardBackground (JSONB) + PATCH /users/me/dashboard-background
- parseDashboardBackground / DASHBOARD_BACKGROUND_PRESET_IDS in @tessera/shared
- CHANGELOG "Unveröffentlicht" mit Neu/Geändert/Behoben fuer 1.5.0
affects:
- apps/web (115 Dateien aus dem Merge)
- apps/api user/auth
tech-stack:
added: []
patterns:
- "Gemeinsame Pruefregel in @tessera/shared, angewendet beim Schreiben (Controller) und Lesen (getMe)"
- "Hook liest Benutzerwahl aus dem Auth-Store und speichert optimistisch ueber Server-Aktion mit Ruecksetzen"
key-files:
created:
- apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql
- apps/web/src/components/dashboard/dashboard-background.test.tsx
modified:
- packages/shared/src/index.ts
- apps/api/prisma/schema.prisma
- apps/api/src/auth/auth.service.ts
- apps/api/src/auth/auth.service.spec.ts
- apps/api/src/user/user.controller.ts
- apps/api/src/user/user.controller.spec.ts
- apps/web/src/lib/auth-actions.ts
- apps/web/src/lib/stores/auth-store.ts
- apps/web/src/components/layout/header.tsx
- apps/web/src/components/layout/header.test.tsx
- apps/web/src/lib/dashboard-background.ts
- apps/web/src/lib/dashboard-background.test.ts
- apps/web/src/components/dashboard/dashboard-background.tsx
- apps/web/src/app/(portal)/page.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- docs/mandantentrennung-zugriffsklassifikation.md
- docs/anleitung-anwender.md
- docs/anleitung-entwicklung.md
- CHANGELOG.md
decisions:
- "Dashboard-Hintergrund als JSONB-Spalte User.dashboardBackground; null = nie gewaehlt, { kind: 'none' } = bewusst kein Hintergrund"
- "parseDashboardBackground in @tessera/shared ist die einzige Pruefregel (Allowlist kind/Preset, imageId nur UUID) fuer API-Schreiben, API-Lesen und Web-Altdatenuebernahme"
- "Alter localStorage-Schluessel wird immer einmal gelesen und entfernt, uebernommen nur bei Server-Wert null"
- "Biome: nur vom Merge neu eingebrachte Befunde behoben; vorbestehende Format-/Importbefunde (158 an der Basis) bleiben ausser Umfang"
metrics:
duration: 17min
completed: 2026-09-28
estimate:
tokens: 95000
tasks: 3
actuals:
tokens: 95700
tasks: 3
commits: 17
plan_head_before: a8a910fd29cf6f2e4c3b228e2a71b2af226f39da
plan_head_after: cb45d2663ac65954463e8c5a6bf859f73a555e86
---
# Quick 260928-ujj Plan 01: Design Mosaik uebernehmen und fuer 1.5.0 vorbereiten — Summary
Design „Mosaik“ per `--no-ff`-Merge (zweiter Elternteil 76d17fe) auf main übernommen. Der Dashboard-Hintergrund wird jetzt pro Benutzer in `User.dashboardBackground` (JSONB) gespeichert: `PATCH /users/me/dashboard-background` prüft mit dem gemeinsamen `parseDashboardBackground`, und der Wert kommt zusammen mit `accentColor` über `getMe` zurück. Die alte Wahl aus dem localStorage wird einmal übernommen. CHANGELOG und beide Anleitungen sind für 1.5.0 vorbereitet.
## Commits
| Task | Commit | Beschreibung |
|------|--------|--------------|
| 1 | 9fa0a3f | feat(260928-ujj): Design Mosaik uebernehmen (Merge, Eltern a8a910f + 76d17fe; bringt 12 Commits aus design/mosaik mit) |
| 2 (RED) | 76f6d87 | test(260928-ujj): rote Tests fuer Dashboard-Hintergrund in der Datenbank |
| 2 (GREEN) | 0aaa152 | feat(260928-ujj): Dashboard-Hintergrund pro Benutzer in der Datenbank |
| 3 (Biome) | 69d1730 | fix(260928-ujj): Biome-Formatierung der mit Design Mosaik eingebrachten Dateien |
| 3 | cb45d26 | docs(260928-ujj): CHANGELOG und Anleitungen fuer Design Mosaik |
`commits: 17` wurde gemessen mit `git rev-list --count a8a910f..HEAD`. Die Zahl enthält die 12 Commits aus design/mosaik, die der Merge mitbringt. Auf der ersten Elternlinie stehen 5 eigene Commits.
## Verifikation
- `git merge-base --is-ancestor 76d17fe HEAD`: erfolgreich. `RESIZE_AXIS_FALLBACK` steht in `dashboard-grid.tsx`.
- Web-Tests (vitest 4.1.9): 97 Dateien, **952 Tests grün**. Direkt nach dem Merge waren es 96 Dateien und 940 Tests.
- API-Tests (vitest 3.2.6): 85 Dateien, **1462 Tests grün**, einschließlich `rls-access-inventory.spec.ts`.
- tsc: shared, api und web sind sauber.
- Builds: `pnpm --filter api build` und `pnpm --filter web build` sind erfolgreich.
- Biome bringt **keine neuen Fehler**. In den seit 3fc33e3 geänderten ts/tsx-Dateien standen an der Basis 158 Fehler, jetzt sind es 156. Alle verbleibenden Befunde bestanden schon vorher (84 format, 72 organizeImports in 93 Dateien).
- Die Migration ist lokal eingespielt (`prisma migrate deploy` gegen 172.19.0.2). Die Spalte `dashboardBackground` hat den Typ jsonb.
- Lokaler Stack neu gebaut mit `docker compose up -d --build api web`. Die API meldet die Route `Mapped {/users/me/dashboard-background, PATCH}`. Ein Aufruf ohne Anmeldung ergibt 401.
- Nichts gepusht, kein Tag gesetzt, den live-Zweig nicht angefasst. `.planning/HANDOFF.json` ist weiter nur eine lokale Änderung und wurde nicht gestaged.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Das Biome-Gate des Plans kann auf den geänderten Dateien nicht fehlerfrei werden**
- **Found during:** Task 2 und Task 3
- **Issue:** Die Verify-Zeile `biome check $(git diff --name-only --diff-filter=AM 3fc33e3 HEAD ...)` endet mit Exit 1. Die 93 betroffenen Dateien hatten schon an der Basis 3fc33e3 zusammen 158 Format- und Importbefunde.
- **Fix:** Ich habe nur die Befunde behoben, die der Merge neu eingebracht hat (12 Dateien, nur Formatierung und Importreihenfolge, eigener Commit 69d1730), dazu alle Befunde in den von mir geschriebenen Zeilen und neuen Dateien. Den Rest ganzer Dateien habe ich nicht umformatiert. Die Vorgabe des Auftraggebers („no new errors“) ist erfüllt: 158 → 156.
- **Commit:** 69d1730
**2. [Rule 2 - Missing critical] Der alte localStorage-Schlüssel wird auch bei gesetztem Server-Wert aufgeräumt**
- **Found during:** Task 2
- **Issue:** Nach Plan hätte bei gesetztem Server-Wert keine Übernahme stattgefunden, der alte Schlüssel wäre dann aber für immer liegen geblieben.
- **Fix:** `takeLegacyDashboardBackground` wird je Benutzer einmal aufgerufen und entfernt den Schlüssel immer. Übernommen wird die alte Wahl nur, wenn der Server-Wert `null` ist. Ein Test deckt das ab.
**3. [Rule 1 - Doc] Die Anwender-Anleitung war schon vor dem Merge an drei Stellen veraltet**
- Die Seitenleiste zeigte bereits vorher keine Sprachumschaltung und keine Name/Rolle-Zeile mehr. Die Reiter standen seit 1.4.0 in der Kopfzeile. Der Stift-Schalter ist mit Mosaik zu „Bearbeiten“/„Fertig“ oben rechts geworden. Diese Stellen habe ich beim Nachziehen gegen den gemergten Code korrigiert.
### Nicht ausgeführt
- **Browser-Prüfung per Playwright MCP** (Hintergrund wählen, neu laden, zweiter Browserkontext): In dieser Ausführungsumgebung gab es kein Playwright-MCP-Werkzeug. Ersatzweise habe ich den lokalen Stack neu gebaut und geprüft, dass die Route gemappt ist, ohne Anmeldung 401 liefert und die Spalte in der DB existiert. Die Prüfung über zwei Geräte im Browser steht noch aus.
## Hinweise
- Eine Übernahme der alten Wahl, die fehlschlägt (z. B. wegen eines API-Ausfalls genau in dem Moment), geht verloren, weil der Schlüssel schon beim Lesen entfernt wird. Der Benutzer wählt dann einfach neu. Das ist bewusst so, damit die Übernahme garantiert nur einmal passiert.
- Alte Prototyp-Werte mit einer Bildkennung, die keine UUID ist, werden nicht übernommen. Echte Bilderrahmen-Kennungen sind immer UUIDs.
- Das Web liest `@tessera/shared` jetzt auch in `dashboard-background.ts` zur Laufzeit. `parseDashboardBackground` enthält nur löschbare Syntax (Regel aus dem Warnkommentar über `WIDGET_TYPES`).
## Threat Flags
Keine über das Threat-Register hinaus. T-ujj-01 und T-ujj-02 sind wie geplant umgesetzt: Allowlist und UUID-Prüfung beim Schreiben und Lesen, `forTenant` mit `where id = currentUser.id`, kein Kennungsparameter.
## Self-Check: PASSED
- FOUND: apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql
- FOUND: apps/web/src/components/dashboard/dashboard-background.test.tsx
- FOUND commits: 9fa0a3f, 76f6d87, 0aaa152, 69d1730, cb45d26
@@ -0,0 +1,391 @@
---
phase: quick-260929-9wc
plan: 01
quick_id: 260929-9wc
type: execute
wave: 1
depends_on: []
autonomous: true
requirements: [QUICK-260929-9wc]
files_modified:
- packages/shared/src/index.ts
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20260929120000_custom_module/migration.sql (neu)
- apps/api/src/custom-modules/dto/custom-module.dto.ts (neu)
- apps/api/src/custom-modules/dto/custom-module.dto.spec.ts (neu)
- apps/api/src/custom-modules/custom-modules.service.ts (neu)
- apps/api/src/custom-modules/custom-modules.service.spec.ts (neu)
- apps/api/src/custom-modules/custom-modules.controller.ts (neu)
- apps/api/src/custom-modules/custom-modules.controller.spec.ts (neu)
- apps/api/src/custom-modules/custom-modules.module.ts (neu)
- apps/api/src/app.module.ts
- docs/mandantentrennung-zugriffsklassifikation.md
- apps/web/src/lib/custom-modules-api.ts (neu)
- apps/web/src/lib/custom-modules-api.test.ts (neu)
- apps/web/src/lib/stores/nav-store.test.ts (neu)
- apps/web/src/components/layout/sidebar.tsx
- apps/web/src/components/layout/sidebar.test.tsx
- apps/web/src/components/modules/custom-module-view.tsx (neu)
- apps/web/src/components/modules/custom-module-view.test.tsx (neu)
- apps/web/src/app/(portal)/modules/custom/[id]/page.tsx (neu)
- apps/web/src/app/(portal)/admin/custom-modules/page.tsx (neu)
- apps/web/src/app/(portal)/admin/custom-modules/components/CustomModuleFormModal.tsx (neu)
- apps/web/src/app/(portal)/admin/custom-modules/components/DeleteCustomModuleDialog.tsx (neu)
- apps/web/src/app/(portal)/admin/custom-modules/custom-modules-page.test.tsx (neu)
- apps/web/src/components/admin/admin-sidebar.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/messages/umlaut-dictionary.ts
- apps/web/src/messages/module-categories.spec.ts (neu)
- CHANGELOG.md
estimate:
tokens: 150000
raw_tokens: 150000
tasks: 3
confidence: low
must_haves:
truths:
- "Ein Administrator legt unter Verwaltung > Eigene Module einen Eintrag mit Name, https-Adresse und einer der fünf Seitenleisten-Kategorien an, ändert ihn und löscht ihn (D-01, D-07)"
- "Jeder angemeldete Benutzer sieht jedes eigene Modul als Eintrag unter der gewählten Kategorie in der Seitenleiste (auch eingeklappt und in der Suche); nach Anlegen, Ändern oder Löschen zieht die Seitenleiste ohne Neuladen nach (D-01, D-05)"
- "Ein Klick öffnet /modules/custom/<id>: ein eingebetteter Rahmen füllt den Inhaltsbereich mit exakt dem Sandbox-Wert XFRAME_SANDBOX und referrerPolicy no-referrer, darüber steht immer sichtbar der Knopf „In neuem Tab öffnen“ (echter Link, target _blank, rel noopener noreferrer); die Kopfzeile zeigt den Namen des Eintrags (D-06)"
- "Eine Adresse, die nicht https ist oder Zugangsdaten enthält, lehnt die API mit 400 und das Formular mit einer Meldung ab; eine solche Adresse wird nie als Rahmen oder Link gerendert (D-04, D-06)"
- "POST/PATCH/DELETE /custom-modules sind nur für ADMIN und SUPER_ADMIN offen (sonst 403), GET /custom-modules und GET /custom-modules/:id für jeden angemeldeten Benutzer, ohne Anmeldung 401 (D-04)"
- "Die Tabelle CustomModule trägt tenantId, ENABLE/FORCE ROW LEVEL SECURITY und tenant_isolation_policy; jeder Zugriff im Dienst läuft über `const tenantPrisma = forTenant(this.prisma, tenantId)`; rls-coverage.spec.ts und rls-access-inventory.spec.ts sind grün (D-03)"
- "Alle neuen Texte stehen deutsch (Sie-Form) und englisch; CHANGELOG nennt die Neuerung unter „Unveröffentlicht“ > „Neu“ in Alltagssprache (D-08, D-09)"
artifacts:
- path: "apps/api/prisma/migrations/20260929120000_custom_module/migration.sql"
provides: "Tabelle CustomModule mit tenantId, Index, RLS ENABLE/FORCE, tenant_isolation_policy ohne Benutzerdimension, ohne system_read_policy"
- path: "apps/api/src/custom-modules/custom-modules.controller.ts"
provides: "GET '' und GET ':id' (jeder Angemeldete), POST/PATCH ':id'/DELETE ':id' mit @Roles(ADMIN, SUPER_ADMIN); list vor getOne deklariert"
- path: "apps/api/src/custom-modules/custom-modules.service.ts"
provides: "list/getOne/create/update/remove, je Methode ein forTenant-Klient, Fremd-Mandant oder unbekannte id -> NotFoundException"
- path: "apps/api/src/custom-modules/dto/custom-module.dto.ts"
provides: "CreateCustomModuleDto/UpdateCustomModuleDto: Name 1-100 Zeichen, Adresse nur https ohne Zugangsdaten max 2048, Kategorie @IsIn(MODULE_CATEGORIES)"
- path: "packages/shared/src/index.ts"
provides: "MODULE_CATEGORIES = ['domain-tools','security-tools','fleet','infrastructure','procurement'] + Typ ModuleCategory"
- path: "apps/web/src/lib/custom-modules-api.ts"
provides: "CustomModule-Typ, listCustomModules/getCustomModule/createCustomModule/updateCustomModule/deleteCustomModule, checkCustomModuleUrl"
- path: "apps/web/src/components/modules/custom-module-view.tsx"
provides: "Rahmen-Ansicht mit Leiste (Name, Hinweis, „In neuem Tab öffnen“) und Vollflächen-iframe"
- path: "apps/web/src/app/(portal)/admin/custom-modules/page.tsx"
provides: "Verwaltungsseite: Liste, Anlegen/Bearbeiten (Formular-Dialog), Löschen (Bestätigung)"
key_links:
- from: "apps/web/src/components/layout/sidebar.tsx"
to: "GET /custom-modules"
via: "listCustomModules() im selben Effekt wie /modules/active, ausgelöst durch sidebarRefreshKey"
pattern: "listCustomModules"
- from: "apps/web/src/app/(portal)/admin/custom-modules/page.tsx"
to: "apps/web/src/components/layout/sidebar.tsx"
via: "useMarketplaceStore bumpSidebarRefresh() nach jedem erfolgreichen Speichern/Löschen"
pattern: "bumpSidebarRefresh"
- from: "apps/web/src/components/modules/custom-module-view.tsx"
to: "apps/web/src/components/dashboard/widgets/xframe-config.ts"
via: "Import XFRAME_SANDBOX — ein Sandbox-Wert für XFrame und eigene Module"
pattern: "XFRAME_SANDBOX"
- from: "apps/api/src/custom-modules/custom-modules.service.ts"
to: "apps/api/src/prisma/prisma-tenant.extension.ts"
via: "const tenantPrisma = forTenant(this.prisma, tenantId)"
pattern: "const tenantPrisma = forTenant\\(this\\.prisma, tenantId\\)"
- from: "apps/api/src/app.module.ts"
to: "apps/api/src/custom-modules/custom-modules.module.ts"
via: "imports: [..., CustomModulesModule]"
pattern: "CustomModulesModule"
- from: "docs/mandantentrennung-zugriffsklassifikation.md"
to: "apps/api/src/prisma/rls-access-inventory.spec.ts"
via: "Bestandsaufnahme-Zeile custom-modules.service.ts | customModule | muss-mandantengebunden | gebunden"
pattern: "custom-modules.service.ts \\| customModule"
---
# Quick 260929-9wc — Eigene Module: externe Seiten als Seitenleisten-Einträge
Nutzerauftrag (29.09.): Der Administrator legt Seitenleisten-Einträge an, die externe Seiten per
eingebettetem Rahmen in Tessera zeigen.
## Festgelegte Punkte (mit dem Nutzer entschieden, nicht verhandelbar)
- **D-01** Der Admin legt Einträge an mit Name, https-Adresse und Seitenleisten-Kategorie (eine der
bestehenden Kategorien). Einträge sind für ALLE Benutzer sichtbar.
- **D-02** Einschränkung auf Gruppen NUR, wenn der bestehende ModuleGrant/Gruppen-Mechanismus das mit
sehr wenig Aufwand hergibt — sonst weglassen und als zurückgestellt notieren.
**Entscheidung beim Planen: zurückgestellt.** Begründung (gemessen im Schema):
`ModuleGrant.moduleId` ist ein Pflicht-Fremdschlüssel auf `Module` (`onDelete: Cascade`), eigene
Module sind keine `Module`-Zeilen. Eine Einschränkung bräuchte eine neue Freigabetabelle oder einen
Umbau von `ModuleGrant` samt `module-access.service.ts` und der Admin-Freigabeoberfläche — das ist
nicht „sehr wenig Aufwand“. Im SUMMARY unter „Bewusst offen“ notieren; im Code nichts dafür bauen.
- **D-03** Prisma-Modell `CustomModule` + Migration MIT Zeilenschutz nach Muster `ProxmoxServer`
(tenantId-Spalte, Regel, prisma-tenant-Erweiterung); RLS-Inventar-Test und
`docs/mandantentrennung-zugriffsklassifikation.md` fortschreiben.
- **D-04** API: GET-Liste für jeden angemeldeten Benutzer; POST/PATCH/DELETE nur Admin; Adresse nur https.
- **D-05** Seitenleiste: jedes eigene Modul erscheint als Eintrag unter seiner Kategorie.
- **D-06** Seite `/modules/custom/[id]`: Rahmen über die ganze Fläche genau wie das XFrame-Widget
(derselbe Sandbox-Wert ohne Navigation des obersten Fensters, `referrerPolicy="no-referrer"`, nur
https) PLUS immer sichtbarer Knopf „In neuem Tab öffnen“ (viele Seiten verbieten das Einbetten).
- **D-07** Verwaltungsoberfläche im Admin-Bereich: einfache Liste + Anlegen/Bearbeiten/Löschen im Stil
der bestehenden Admin-Seiten (Vorbild `admin/groups`).
- **D-08** Texte deutsch und englisch; App-Texte im Deutschen in Sie-Form.
- **D-09** CHANGELOG unter „Unveröffentlicht“ > „Neu“, Alltagssprache für Nicht-Programmierer.
- **D-10** Tests: API-Dienst/Controller, Web-Komponenten, RLS-Inventar. Statische GET-Routen stehen im
Controller VOR `@Get(':id')`.
- **D-11** Abschluss: Browser-Prüfung mit Playwright MCP am lokalen Stack (web :3000, api :3001, admin /
admin123) im DUNKELMODUS (Umschalten über den Theme-Knopf der Kopfzeile, nie per classList).
Migration vom Host über die Container-IP (172.19.x, `tessera:tessera_dev`), danach
`docker compose up -d --build web api`.
- **D-12** Nur lokal committen, NIEMALS `git push`.
## Grundlagen (wiederverwenden, nicht neu erfinden)
- **Kategorien**: Die Seitenleiste gruppiert nach `Module.category`; im Einsatz sind genau fünf
Kennungen aus den Seeds (`domain-tools`, `security-tools`, `fleet`, `infrastructure`, `procurement`),
deren Anzeigenamen in `moduleCategories` von `de.json`/`en.json` stehen und über
`useCategoryLabel()` aufgelöst werden. Neu: diese Liste einmal als `MODULE_CATEGORIES` in
`packages/shared/src/index.ts` — die API prüft per `@IsIn`, das Formular baut daraus die Auswahl.
- **Zeilenschutz-Vorbild**: `apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql`
(Kopfkommentar-Pflicht, `ENABLE`/`FORCE`, `tenant_isolation_policy` OHNE Benutzerdimension, weil
Verwaltungsdaten des Mandanten). KEINE `system_read_policy` — es gibt keinen Hintergrunddienst.
- **API-Vorbild**: `apps/api/src/proxmox/proxmox.controller.ts` (`requireTenantId(req)`,
`@Roles(Role.ADMIN, Role.SUPER_ADMIN)`, tenantId nur aus `req.tenantId`) und
`proxmox.service.ts` (je Methode `const tenantPrisma = forTenant(this.prisma, tenantId);`). Globale
Wächter JwtAuthGuard/TenantGuard/RolesGuard stehen in `app.module.ts`; ValidationPipe mit
`whitelist: true, transform: true` in `main.ts`.
- **Rahmen-Vorbild**: `apps/web/src/components/dashboard/widgets/xframe-config.ts` (`XFRAME_SANDBOX`,
Begründung im Dateikopf; `isHttpsUrl` aus `picture-frame-config.ts`) und `xframe-widget.tsx`
(`frameAttrs` mit `allow: ''`, `referrerPolicy: 'no-referrer'`; `NewTabLink` als echter Link).
- **Seitenleiste**: `apps/web/src/components/layout/sidebar.tsx` lädt `/modules/active`, gruppiert
nach Kategorie, Auffrischung über `useMarketplaceStore` `sidebarRefreshKey`/`bumpSidebarRefresh`;
sie veröffentlicht die Liste in `useNavStore`, aus der `resolvePageTitle` den Kopfzeilen-Titel über
Pfadsegment == `slug` findet.
- **Routen**: Der statische Ordner `modules/custom/[id]` hat im App Router Vorrang vor
`modules/[category]/[moduleSlug]` — kein Konflikt.
## Verbindliche Regeln für alle Aufgaben
- `de.json` mit echten Umlauten. `umlaut-guard.spec.ts` meldet jedes NEUE deutsche Wort mit
ae/oe/ue/ss, das noch nicht auf der Liste steht (etwa „Adressen“ oder „müssen“) — ist es korrektes Deutsch,
gehört es in `UMLAUT_ALLOWLIST` in `apps/web/src/messages/umlaut-dictionary.ts`. Jeder neue Schlüssel
in `de.json` UND `en.json` (Schlüssel-Gleichheit wird geprüft).
- Keine Großbuchstaben-Etiketten, keine Mittelpunkt-Ketten, kein Pfeilzeichen in Texten oder Knöpfen
(Stil der letzten Quick-Aufträge). Keine neuen Pakete.
- Biome-Grundlinie gemessen am 29.09.: Web 55 Warnungen, API 82 — darf nicht steigen.
- Die bereits vorgemerkten Löschungen `.planning/.continue-here.md` und `.planning/HANDOFF.json`
(Sitzungsübergabe) nicht wiederherstellen.
- Commits nur lokal. Kein `git push`, auch nicht am Ende (D-12).
<objective>
Administratoren binden externe Webseiten als „Eigene Module“ in die Seitenleiste ein: Name,
https-Adresse, Kategorie. Alle Benutzer sehen die Einträge unter der gewählten Kategorie; ein Klick
zeigt die Seite in einem abgesicherten, flächenfüllenden Rahmen mit immer sichtbarem „In neuem Tab
öffnen“. Die Daten liegen mandantengetrennt mit Zeilenschutz in der Tabelle `CustomModule`
(D-01 bis D-12; D-02 Gruppen-Einschränkung bewusst zurückgestellt).
Purpose: Werkzeuge, für die es (noch) kein eigenes Tessera-Modul gibt, sind trotzdem aus der zentralen
Plattform heraus erreichbar — der Kernnutzen „nicht zwischen Anwendungen wechseln“.
Output: Tabelle + Migration mit Zeilenschutz, API `/custom-modules`, Seitenleisten-Einträge,
Rahmen-Seite, Verwaltungsseite, Texte de/en, Tests, fortgeschriebene Zugriffsklassifikation, CHANGELOG.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
@apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql
@apps/api/src/proxmox/proxmox.controller.ts
@apps/web/src/components/dashboard/widgets/xframe-config.ts
@apps/web/src/components/layout/sidebar.tsx
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Aufgabe 1 (Tracer): Ein eigenes Modul von der Datenbank bis in Seitenleiste und Rahmen-Seite</name>
<files>packages/shared/src/index.ts, apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260929120000_custom_module/migration.sql, apps/api/src/custom-modules/dto/custom-module.dto.ts, apps/api/src/custom-modules/dto/custom-module.dto.spec.ts, apps/api/src/custom-modules/custom-modules.service.ts, apps/api/src/custom-modules/custom-modules.service.spec.ts, apps/api/src/custom-modules/custom-modules.controller.ts, apps/api/src/custom-modules/custom-modules.controller.spec.ts, apps/api/src/custom-modules/custom-modules.module.ts, apps/api/src/app.module.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/custom-modules-api.ts, apps/web/src/lib/custom-modules-api.test.ts, apps/web/src/lib/stores/nav-store.test.ts, apps/web/src/components/layout/sidebar.tsx, apps/web/src/components/layout/sidebar.test.tsx, apps/web/src/components/modules/custom-module-view.tsx, apps/web/src/components/modules/custom-module-view.test.tsx, apps/web/src/app/(portal)/modules/custom/[id]/page.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, apps/web/src/messages/module-categories.spec.ts</files>
<precondition>Der lokale Stack läuft: `docker compose ps --format '{{.Service}} {{.State}}'` zeigt db, api und web als running.</precondition>
<read_first>apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql, apps/api/src/proxmox/proxmox.controller.ts, apps/api/src/proxmox/proxmox.service.ts (nur createServer/updateServer/deleteServer, Zeilen 120-240), apps/api/src/proxmox/proxmox.service.spec.ts (Kopf bis makeFakePrisma), apps/api/src/tenders/tenders.controller.spec.ts (Reihenfolge-Test ab Zeile 365), apps/api/src/prisma/rls-access-inventory.spec.ts (Zeilen 1-80 und parseDocEntries), apps/web/src/components/dashboard/widgets/xframe-widget.tsx (Zeilen 95-115 und NewTabLink), apps/web/src/lib/favorites-api.test.ts (Kopf), apps/web/src/components/layout/sidebar.test.tsx</read_first>
<behavior>
- DTO: `https://example.com` mit Kategorie `infrastructure` und Name „Wiki“ ist gültig; `http://example.com`, `javascript:alert(1)`, `data:text/html,x`, `ftp://x`, unparsbarer Text, `https://user:pw@example.com` sind ungültig; Kategorie `other` ist ungültig; leerer oder nur aus Leerzeichen bestehender Name ist ungültig; Name über 100 und Adresse über 2048 Zeichen sind ungültig; Update-DTO akzeptiert Teilmengen, prüft aber jedes gesetzte Feld gleich
- Dienst: create speichert tenantId aus dem Argument (nie aus dem DTO); list liefert nur Zeilen des Mandanten, nach Name sortiert; getOne/update/remove mit unbekannter id oder Zeile eines anderen Mandanten -> NotFoundException; forTenant wird je Methode mit (prisma, tenantId) aufgerufen
- Controller: create/update/remove tragen ROLES_KEY [ADMIN, SUPER_ADMIN], list/getOne tragen keine Rollen; fehlendes req.tenantId -> ForbiddenException; tenantId kommt aus req.tenantId; `list` ist vor `getOne` deklariert
- Web-Client: checkCustomModuleUrl('https://a.de') = 'ok', 'http://a.de' = 'notHttps', 'https://u:p@a.de' = 'credentials', 'kaputt' = 'notHttps'; listCustomModules ruft GET {API}/custom-modules mit credentials include
- Seitenleiste: ein eigenes Modul mit Kategorie `infrastructure` erscheint unter dieser Kategorie als Link auf /modules/custom/<id>; eine Kategorie, die nur eigene Module hat, erscheint trotzdem; auf /modules/custom/<id> trägt genau dieser Eintrag die Auswahlmarke; die bestehenden Abruf-Zählertests bleiben unverändert grün
- Kopfzeilen-Titel: resolvePageTitle('/modules/custom/abc', [{ id: 'abc', slug: 'abc', name: 'Wiki', category: 'infrastructure' }]) liefert { text: 'Wiki' }
- Rahmen-Ansicht: rendert iframe mit src = Adresse, title = Name, sandbox exakt XFRAME_SANDBOX (enthält kein top-navigation-Token), referrerpolicy no-referrer, allow leer; der Link „In neuem Tab öffnen“ ist sichtbar mit href = Adresse, target _blank, rel „noopener noreferrer“; bei nicht gültiger Adresse kein iframe und kein Link, stattdessen Hinweistext; bei 404 der Nicht-gefunden-Text
- Kategorien-Gleichlauf: jede Kennung aus MODULE_CATEGORIES hat einen Schlüssel in moduleCategories von de.json und en.json
</behavior>
<action>
Tests zuerst schreiben (rot), dann bauen (grün). Reihenfolge der Arbeit:
1. Gemeinsame Kategorienliste (D-01): in `packages/shared/src/index.ts` `MODULE_CATEGORIES` als `as const`-Liste der fünf Kennungen `domain-tools`, `security-tools`, `fleet`, `infrastructure`, `procurement` plus `export type ModuleCategory`, mit kurzem Kommentar, dass die Liste den Seed-Kategorien der Module und den Schlüsseln `moduleCategories` in den Übersetzungen entspricht. Neue Spec `apps/web/src/messages/module-categories.spec.ts` prüft den Gleichlauf mit `de.json` und `en.json`.
2. Datenbank (D-03): in `apps/api/prisma/schema.prisma` hinter `ProxmoxServerStatus` das Modell `CustomModule` mit `id String @id @default(uuid())`, `tenantId String`, `name String`, `url String`, `category String` (Kommentar: eine der MODULE_CATEGORIES), `createdAt DateTime @default(now())`, `updatedAt DateTime @updatedAt`, `@@index([tenantId])` — ohne Relation zu Tenant (Muster ProxmoxServer). Migration `apps/api/prisma/migrations/20260929120000_custom_module/migration.sql` von Hand nach Vorbild 20260923140000: deutscher Kopfkommentar (Zweck, Zeilenschutz OHNE Benutzerdimension weil Verwaltungsdaten des Mandanten, bewusst KEINE system_read_policy weil kein Hintergrunddienst, Rechte für tessera_app kommen über ALTER DEFAULT PRIVILEGES, Hinweis dass die Regeln erst mit der Anwendungsrolle wirken), dann CREATE TABLE "CustomModule" mit den Spalten in Prisma-Form (TIMESTAMP(3), updatedAt ohne Default), Primärschlüssel "CustomModule_pkey", Index "CustomModule_tenantId_idx", `ENABLE ROW LEVEL SECURITY`, `FORCE ROW LEVEL SECURITY` und `CREATE POLICY tenant_isolation_policy ON "CustomModule" USING ("tenantId" = current_tenant_id());`. Danach `pnpm --filter @tessera/api exec prisma generate`.
3. DTO `apps/api/src/custom-modules/dto/custom-module.dto.ts` (D-04): `CreateCustomModuleDto` mit `name` (`@Transform` trimmt Zeichenketten, `@IsString`, `@IsNotEmpty`, `@MaxLength(100)`), `url` (`@IsString`, `@MaxLength(2048)`, eigene `@ValidatorConstraint` nach Muster `PmgOhneTokenConstraint` in `proxmox-server.dto.ts`: gültig nur, wenn `new URL(wert)` ohne Fehler parst, `protocol === 'https:'`, `hostname` nicht leer und `username`/`password` leer sind; Meldung deutsch in der ASCII-Schreibweise der übrigen API-Meldungen, z. B. „Nur https-Adressen ohne Zugangsdaten sind erlaubt.“), `category` (`@IsIn([...MODULE_CATEGORIES])` aus `@tessera/shared`). `UpdateCustomModuleDto extends PartialType(CreateCustomModuleDto)` aus `@nestjs/mapped-types` (Muster `ldap-config.dto.ts`). Spec `dto/custom-module.dto.spec.ts` mit `plainToInstance` + `validate` deckt die Fälle aus `<behavior>` ab.
4. Dienst `apps/api/src/custom-modules/custom-modules.service.ts` (D-03, D-04): `@Injectable` mit `PrismaService`; Methoden `list(tenantId)`, `getOne(tenantId, id)`, `create(tenantId, dto)`, `update(tenantId, id, dto)`, `remove(tenantId, id)`. JEDE Methode beginnt mit genau der Zuweisung `const tenantPrisma = forTenant(this.prisma, tenantId);` — `rls-access-inventory.spec.ts` erkennt nur diese Form, ein anderer Name oder ein Aufruf ohne Zuweisung macht die Spec rot. `list` filtert zusätzlich explizit `where: { tenantId }` und sortiert `orderBy: { name: 'asc' }`. `getOne`/`update`/`remove` lesen per `findUnique({ where: { id } })` und werfen `NotFoundException`, wenn die Zeile fehlt oder `row.tenantId !== tenantId` (zweites Netz, weil der RLS-Schalter heute aus ist — Muster DashboardImage). Antworten wählen per `select` genau `id, name, url, category, createdAt, updatedAt`; wird dafür eine Konstante genutzt, muss sie in derselben Datei als Objektliteral stehen (die Inventar-Spec löst nur solche Konstanten auf). `remove` liefert `{ deleted: true }`. Spec `custom-modules.service.spec.ts` nach Muster `proxmox.service.spec.ts` (`vi.mock('../prisma/prisma-tenant.extension', ...)` mit durchreichendem `forTenant`, Fake-Prisma mit Map).
5. Controller `apps/api/src/custom-modules/custom-modules.controller.ts` (D-04, D-10): `@Controller('custom-modules')`, `requireTenantId(req)` wie im Proxmox-Controller. Deklarationsreihenfolge verbindlich: `list` (`@Get()`), dann `getOne` (`@Get(':id')`), dann `create` (`@Post()`), `update` (`@Patch(':id')`), `remove` (`@Delete(':id')`); die drei schreibenden mit `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`. Kopfkommentar: jede künftige statische GET-Route MUSS über `getOne` stehen (sonst fängt `:id` sie ab). Kein `@UseModule` — eigene Module hängen an keiner Modul-Aktivierung, sichtbar für alle (D-01). Spec `custom-modules.controller.spec.ts` nach Muster `bug-reports.controller.spec.ts`/`tenders.controller.spec.ts`: Rollen-Metadaten per `Reflect.getMetadata(ROLES_KEY, ...)`, Reihenfolge per `Object.getOwnPropertyNames(CustomModulesController.prototype)`, tenantId-Weitergabe, ForbiddenException ohne Mandant.
6. `apps/api/src/custom-modules/custom-modules.module.ts` (Controller + Dienst; PrismaModule ist global — prüfen, wie ProxmoxModule an PrismaService kommt, und genauso verfahren) und Aufnahme von `CustomModulesModule` in `imports` von `apps/api/src/app.module.ts`.
7. Zugriffsklassifikation (D-03) in `docs/mandantentrennung-zugriffsklassifikation.md`, alle Zahlen NACHGEMESSEN, nicht abgeschrieben: (a) in der Bestandsaufnahme-Tabelle (Kopf `| Datei | Modell | Klasse | Stand | Begründung |`) hinter den Proxmox-Zeilen die Zeile `| apps/api/src/custom-modules/custom-modules.service.ts | customModule | muss-mandantengebunden | gebunden | **quick-260929-9wc:** ... |` mit Begründung (Admin-verwaltete Seitenleisten-Einträge, tenantId-Spalte, tenant_isolation_policy ohne Benutzerdimension, Migration 20260929120000, keine system_read_policy, je Methode ein forTenant-Klient, Besitzprüfung row.tenantId -> 404). (b) In der Übersicht je Bereich eine Zeile `custom-modules` vor der Summenzeile. Gemessen wird mit der Gate-Schleife über `for d in apps/api/src/*/` mit den drei Greps `this\.prisma\.[a-zA-Z]*`, `tenantPrisma\.[a-zA-Z]*\.` und `systemPrisma\.[a-zA-Z]*\.` (nur .ts ohne spec). Beim Planen gemessen: Summe vorher 61/217/6, die Tabelle nennt aber 61/216/6 — die Zeile `user` nennt 17 gebunden, gemessen sind 18 (Drift aus quick-260928-ujj, Hintergrund pro Benutzer). Diese Drift in der Zeile `user` und in der Summenzeile mit „Nachgemessen quick-260929-9wc“ korrigieren, dann die neue Summe eintragen. (c) Klassen-Verteilung: Überschrift und Tabelle nennen 77 Paare/40 muss-mandantengebunden, die Bestandsaufnahme hat beim Planen aber schon 78 Zeilen/41 muss (gezählt mit `grep -cE '^\| apps/api/src/'`); nach dem neuen Eintrag nachzählen (erwartet 79/42), Überschrift, Tabelle und einen Nachtrag-Absatz „quick-260929-9wc“ entsprechend fortschreiben (Drift benennen, dann +1).
8. Web-Client `apps/web/src/lib/custom-modules-api.ts` nach Muster `favorites-api.ts`/`proxmox-api.ts` (`NEXT_PUBLIC_API_URL`, `credentials: 'include'`): Typ `CustomModule` (`id, name, url, category, createdAt, updatedAt`), `listCustomModules()`, `getCustomModule(id)` (liefert `null` bei 404), `createCustomModule(input)`, `updateCustomModule(id, input)`, `deleteCustomModule(id)` — Fehler werfen mit Status und Servermeldung. Dazu die reine Funktion `checkCustomModuleUrl(value): 'ok' | 'notHttps' | 'credentials'`, die für die https-Prüfung `isHttpsUrl` aus `xframe-config.ts` nutzt (EINE https-Regel im Web) und Zugangsdaten per URL-Parser erkennt. Test `custom-modules-api.test.ts`.
9. Seitenleiste `apps/web/src/components/layout/sidebar.tsx` (D-05): im bestehenden Abruf-Effekt (derselbe Auslöser `sidebarRefreshKey`) zusätzlich `listCustomModules()` laden, Fehler still wie beim Modulabruf (leere Liste). Einträge vereinheitlichen (z. B. interner Typ mit `key`, `name`, `category`, `href`, `tileSlug`): Module behalten `href = /modules/<kategorie>/<slug>` und ihre Aktiv-Regel, eigene Module bekommen `href = /modules/custom/<id>` und das allgemeine Kachelsymbol (`ModuleTile` mit einer Kennung ohne eigenes Symbol, z. B. `custom`). Gruppierung, Suche, eingeklappte Kachelliste und der Leer-Zustand arbeiten auf der vereinigten Liste; innerhalb einer Kategorie stehen eingebaute Module vor eigenen. Für den Kopfzeilen-Titel die vereinigte Liste in `useNavStore` veröffentlichen, eigene Module mit `slug` = ihre id (`resolvePageTitle` findet das Pfadsegment dann ohne Änderung) — Test in neuer Datei `apps/web/src/lib/stores/nav-store.test.ts`. In `sidebar.test.tsx` `@/lib/custom-modules-api` per `vi.mock` ersetzen (Standard: leere Liste), damit die bestehenden Zähltests auf `fetch` unverändert gelten; neue Tests für die Fälle aus `<behavior>`.
10. Rahmen-Seite (D-06): `apps/web/src/app/(portal)/modules/custom/[id]/page.tsx` als Server-Komponente, die `params` (Promise, Muster `[moduleSlug]/page.tsx`) auflöst und `<CustomModuleView id={id} />` rendert — ohne ModuleAccessGate, weil eigene Module für alle sichtbar sind (D-01). `apps/web/src/components/modules/custom-module-view.tsx` (Client): lädt per `getCustomModule(id)`; Ladezustand, Nicht-gefunden-Text, sonst eine schmale Leiste (Name, kurzer Hinweis dass manche Seiten das Einbetten verbieten, rechts der Link „In neuem Tab öffnen“ als echter `<a>` mit `target="_blank"` und `rel="noopener noreferrer"`, als Knopf gestaltet und immer sichtbar) und darunter das iframe, das die restliche Höhe füllt (Behälter z. B. `flex flex-col` mit Höhe `calc(100vh - var(--header-height) - 1.5rem)`, iframe `flex-1 w-full rounded-lg border-0 bg-background`). iframe-Attribute wie `frameAttrs` im XFrame-Widget: `src`, `title` = Name, `sandbox={XFRAME_SANDBOX}` (importiert aus `xframe-config.ts`, NICHT kopieren), `allow=""`, `referrerPolicy="no-referrer"`. iframe und Link nur, wenn `checkCustomModuleUrl(url) === 'ok'`, sonst Hinweistext. Test `custom-module-view.test.tsx` mit gemocktem `getCustomModule`.
11. Texte (D-08) im neuen Namensraum `customModules` in `de.json` und `en.json`: mindestens `openInNewTab` („In neuem Tab öffnen“ / „Open in new tab“), `embedHint` (z. B. „Manche Seiten lassen sich nicht einbetten. Öffnen Sie die Seite dann in einem neuen Tab.“), `notFound` („Dieses Modul gibt es nicht mehr.“), `invalidUrl`. Neue Wörter mit ae/oe/ue/ss nach der Umlaut-Regel oben behandeln.
12. Datenbank lokal migrieren und API neu bauen (D-11): Container-IP holen mit `docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1`, dann `DATABASE_URL="postgresql://tessera:tessera_dev@<IP>:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy`; danach `docker compose up -d --build api` und warten, bis `curl -sf http://localhost:3001/health` antwortet. Kontrolle, dass keine Schemaabweichung zu CustomModule bleibt: `pnpm --filter @tessera/api exec prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --script` darf „CustomModule“ nicht enthalten (andere, schon vorher bestehende Abweichungen aus handgeschriebenem SQL sind nicht Gegenstand dieser Aufgabe).
13. Lokal committen (z. B. `feat(api,web): eigene Module — Tabelle, API, Seitenleiste, Rahmen-Seite`), NICHT pushen (D-12).
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/custom-modules src/prisma && pnpm --filter @tessera/web exec vitest run src/components/layout/sidebar.test.tsx src/components/modules/custom-module-view.test.tsx src/lib/custom-modules-api.test.ts src/lib/stores/nav-store.test.ts src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && grep -q 'CREATE POLICY tenant_isolation_policy ON "CustomModule"' apps/api/prisma/migrations/20260929120000_custom_module/migration.sql && grep -q '| apps/api/src/custom-modules/custom-modules.service.ts | customModule | muss-mandantengebunden | gebunden |' docs/mandantentrennung-zugriffsklassifikation.md && grep -q 'XFRAME_SANDBOX' apps/web/src/components/modules/custom-module-view.tsx && J=$(mktemp) && curl -sf -c "$J" -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}' http://localhost:3001/auth/login >/dev/null && ID=$(curl -sf -b "$J" -H 'Content-Type: application/json' -d '{"name":"Tracer","url":"https://example.com","category":"infrastructure"}' http://localhost:3001/custom-modules | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).id') && curl -sf -b "$J" http://localhost:3001/custom-modules | grep -q "$ID" && curl -sf -b "$J" "http://localhost:3001/custom-modules/$ID" | grep -q 'example.com' && test "$(curl -s -o /dev/null -w '%{http_code}' -b "$J" -H 'Content-Type: application/json' -d '{"name":"X","url":"http://example.com","category":"infrastructure"}' http://localhost:3001/custom-modules)" = 400 && test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3001/custom-modules)" = 401 && curl -sf -b "$J" -X DELETE "http://localhost:3001/custom-modules/$ID" >/dev/null && test "$(curl -s -o /dev/null -w '%{http_code}' -b "$J" "http://localhost:3001/custom-modules/$ID")" = 404</automated>
</verify>
<done>Tabelle CustomModule mit Zeilenschutz ist lokal angelegt; die neu gebaute API nimmt einen https-Eintrag vom Admin an, liefert ihn in Liste und Einzelabruf, lehnt http mit 400 und Anonyme mit 401 ab, löscht ihn (danach 404); Seitenleiste und Rahmen-Seite sind komponentengetestet; RLS-Specs grün, Zugriffsklassifikation nachgemessen fortgeschrieben; lokal committet, nicht gepusht.</done>
</task>
<task type="auto" tdd="true">
<name>Aufgabe 2: Verwaltungsseite „Eigene Module“ — Liste, Anlegen, Bearbeiten, Löschen</name>
<files>apps/web/src/app/(portal)/admin/custom-modules/page.tsx, apps/web/src/app/(portal)/admin/custom-modules/components/CustomModuleFormModal.tsx, apps/web/src/app/(portal)/admin/custom-modules/components/DeleteCustomModuleDialog.tsx, apps/web/src/app/(portal)/admin/custom-modules/custom-modules-page.test.tsx, apps/web/src/components/admin/admin-sidebar.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts</files>
<read_first>apps/web/src/app/(portal)/admin/groups/page.tsx, apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx, apps/web/src/app/(portal)/admin/groups/components/DeleteGroupDialog.tsx, apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx (Kopf mit dem next-intl-Mock), apps/web/src/components/admin/admin-sidebar.tsx, apps/web/src/lib/custom-modules-api.ts (aus Aufgabe 1)</read_first>
<behavior>
- Ohne Einträge: Leer-Zustand mit Überschrift, kurzer Erklärung und Knopf „Eigenes Modul anlegen“
- Mit Einträgen: Tabelle mit Name (Link auf /modules/custom/<id>), Adresse, Kategorie als Anzeigename (useCategoryLabel), Aktionen Bearbeiten und Löschen
- Anlegen: Formular mit Name, Adresse, Kategorie-Auswahl aus MODULE_CATEGORIES; http-Adresse oder Adresse mit Zugangsdaten zeigt die passende Meldung und ruft createCustomModule NICHT auf; leerer Name ebenso; gültige Eingabe ruft createCustomModule mit getrimmtem Namen, lädt die Liste neu und ruft bumpSidebarRefresh genau einmal
- Bearbeiten: Formular ist mit den Werten vorbelegt, Speichern ruft updateCustomModule(id, ...) und bumpSidebarRefresh
- Löschen: Bestätigungsdialog nennt den Namen; Bestätigen ruft deleteCustomModule(id), Liste neu, bumpSidebarRefresh; Abbrechen ruft nichts
- Serverfehler beim Speichern bleibt im Dialog sichtbar, Dialog bleibt offen
- Benutzer mit Rolle USER sieht den Zugriff-verweigert-Text statt der Seite (nur Anzeige; durchgesetzt wird serverseitig)
</behavior>
<action>
Tests zuerst (`custom-modules-page.test.tsx`, Muster `groups-page.test.tsx`: namensraumfähiger next-intl-Mock, `@/lib/custom-modules-api` und `@/lib/stores/marketplace-store` per `vi.mock`, Auth-Store mit Rolle ADMIN bzw. USER), dann bauen (D-07):
1. Seite `apps/web/src/app/(portal)/admin/custom-modules/page.tsx` (Client) im Aufbau von `admin/groups/page.tsx`: Rollen-Anzeigeprüfung ADMIN/SUPER_ADMIN (sonst `common.accessDenied`), Überschrift „Eigene Module“ mit Knopf „Eigenes Modul anlegen“ (`btn btn-primary`), darunter ein Satz Erklärung (externe Webseiten als Einträge in der Seitenleiste, alle Benutzer sehen sie), Fehlerzeile im Stil der Gruppenseite, Leer-Zustand bzw. Tabelle (`overflow-x-auto rounded-md border border-border`, Kopf `bg-muted/50`) mit Name (Link auf die Rahmen-Seite), Adresse (gekürzt mit `truncate` und `title`), Kategorie über `useCategoryLabel()`, Aktionen Bearbeiten/Löschen. Nach jedem erfolgreichen Anlegen, Ändern oder Löschen: Liste neu laden und `useMarketplaceStore.getState().bumpSidebarRefresh()` (bzw. über den Hook) aufrufen, damit die Seitenleiste ohne Neuladen nachzieht (D-05).
2. `components/CustomModuleFormModal.tsx` nach Muster `GroupFormModal.tsx` (gleicher Dialog-Rahmen, gleiche Knopfklassen): Felder Name (Pflicht, `maxLength` 100), Adresse (`type="url"`, `maxLength` 2048, Platzhaltertext `https://…`), Kategorie (`<select>` über `MODULE_CATEGORIES` aus `@tessera/shared`, beschriftet mit `useCategoryLabel()`, Vorgabe beim Anlegen: `infrastructure`). Vor dem Senden `checkCustomModuleUrl` aus Aufgabe 1 anwenden und je Ergebnis eine eigene übersetzte Meldung zeigen; Name wird getrimmt. Beim Bearbeiten nur `updateCustomModule`, beim Anlegen nur `createCustomModule`. Serverfehler im Dialog anzeigen.
3. `components/DeleteCustomModuleDialog.tsx` nach Muster `DeleteGroupDialog.tsx`: Rückfrage mit Namen, Bestätigen/Abbrechen.
4. `apps/web/src/components/admin/admin-sidebar.tsx`: neuer Eintrag direkt hinter „Module“ mit `href: '/admin/custom-modules'`, `label: t('admin.customModules')`, `show: true`, Symbol im Stil der übrigen 16-px-Strichsymbole (z. B. Fenster mit Pfeil nach außen oder Puzzleteil). Der Pfad beginnt NICHT mit `/admin/modules`, damit „Module“ nicht mitmarkiert wird.
5. Texte (D-08) in `de.json` und `en.json`: `header.admin.customModules` („Eigene Module“ / „Custom modules“) und Namensraum `admin.customModules` mit Titel, Erklärung, Anlegen, Bearbeiten, Löschen, Feldbeschriftungen (Name, Adresse, Kategorie), Aktionen-Spalte, Leer-Zustand (Überschrift + Satz), Löschrückfrage mit `{name}` (z. B. „Möchten Sie „{name}“ wirklich löschen? Der Eintrag verschwindet für alle Benutzer aus der Seitenleiste.“), Meldungen `nameRequired`, `urlNotHttps` („Bitte geben Sie eine Adresse ein, die mit https:// beginnt.“), `urlCredentials` („Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.“), Speichern-Fehler. Sie-Form. Neue Wörter mit ae/oe/ue/ss nach der Umlaut-Regel behandeln.
6. Lokal committen (z. B. `feat(web): Verwaltung „Eigene Module“`), NICHT pushen (D-12).
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run "src/app/(portal)/admin/custom-modules" src/components/layout/sidebar.test.tsx src/messages && pnpm --filter @tessera/web exec tsc --noEmit && grep -q "/admin/custom-modules" apps/web/src/components/admin/admin-sidebar.tsx && grep -q "bumpSidebarRefresh" "apps/web/src/app/(portal)/admin/custom-modules/page.tsx" && grep -q "MODULE_CATEGORIES" "apps/web/src/app/(portal)/admin/custom-modules/components/CustomModuleFormModal.tsx" && node -e 'for (const f of ["de","en"]) { const m = require("./apps/web/src/messages/" + f + ".json"); if (!m.header.admin.customModules) throw new Error(f + ": header.admin.customModules fehlt"); for (const k of ["title","create","urlNotHttps","urlCredentials","nameRequired"]) if (!(k in m.admin.customModules)) throw new Error(f + ": admin.customModules." + k + " fehlt"); for (const k of ["openInNewTab","embedHint","notFound"]) if (!(k in m.customModules)) throw new Error(f + ": customModules." + k + " fehlt"); }'</automated>
</verify>
<done>Unter Verwaltung > Eigene Module listet die Seite alle Einträge des Mandanten; Anlegen, Bearbeiten und Löschen funktionieren mit Prüfung der Adresse im Formular und ziehen die Seitenleiste sofort nach; Texte de/en vollständig; Tests grün; lokal committet, nicht gepusht.</done>
</task>
<task type="auto">
<name>Aufgabe 3: CHANGELOG, alle Tore, Stack neu bauen, Browser-Prüfung im Dunkelmodus</name>
<files>CHANGELOG.md</files>
<read_first>CHANGELOG.md (Zeilen 1-45)</read_first>
<action>
1. CHANGELOG (D-09): unter `## Unveröffentlicht` (heute leer) einen Abschnitt `### Neu` mit einem Punkt in Alltagssprache und Sie-Form, Stil der Einträge von 1.5.x, sinngemäß: „Eigene Module: Als Administrator können Sie unter „Verwaltung“ > „Eigene Module“ andere Webseiten in die Seitenleiste aufnehmen – mit Name, Adresse (nur https) und Kategorie, etwa „Infrastruktur“. Alle Benutzer sehen die Einträge; ein Klick zeigt die Seite direkt in Tessera. Manche Seiten verbieten das Einbetten – dafür gibt es immer den Knopf „In neuem Tab öffnen“.“ Keine Fachbegriffe wie iframe, Sandbox, API, RLS.
2. Alle Tore laufen lassen und die gemessenen Zahlen im SUMMARY festhalten: vollständige Web- und API-Testläufe, `pnpm turbo run type-check lint`, Biome-Warnungen Web höchstens 55 und API höchstens 82.
3. Stack neu bauen (D-11): Migration ist aus Aufgabe 1 bereits angewendet (zur Sicherheit erneut `prisma migrate deploy` über die Container-IP, muss „No pending migrations“ melden), dann `docker compose up -d --build web api`; warten, bis `http://localhost:3001/health` und `http://localhost:3000/login` antworten.
4. Lokal committen (z. B. `docs(changelog): eigene Module unter Unveröffentlicht`), NICHT pushen (D-12). Zum Schluss prüfen, dass HEAD auf keinem entfernten Zweig liegt.
5. Browser-Prüfung (D-11) nach der Liste in `<verification>` — Playwright MCP, echte Navigation, dunkel über den Theme-Knopf. Ist Playwright MCP im Ausführungskontext nicht verfügbar, die Prüfung im SUMMARY als „an den Orchestrator übergeben“ vermerken; der Orchestrator führt sie dann durch.
</action>
<verify>
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run && pnpm --filter @tessera/api exec vitest run && pnpm turbo run type-check lint && W=$(pnpm --filter @tessera/web exec biome lint . 2>&1 | grep -oE '^Found [0-9]+ warning' | grep -oE '[0-9]+'); test "${W:-0}" -le 55 && A=$(pnpm --filter @tessera/api exec biome lint . 2>&1 | grep -oE '^Found [0-9]+ warning' | grep -oE '[0-9]+'); test "${A:-0}" -le 82 && sed -n '/^## Unveröffentlicht/,/^## 1\.5\.2/p' CHANGELOG.md | grep -q "Eigene Module" && test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/login)" = 200 && test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3001/custom-modules)" = 401 && test -z "$(git branch -r --contains HEAD)"</automated>
<human-check>Browser-Prüfung im Dunkelmodus nach den Schritten 1-9 in &lt;verification&gt; (Playwright MCP, lokaler Stack nach `docker compose up -d --build web api`).</human-check>
</verify>
<done>CHANGELOG nennt die Neuerung unter „Unveröffentlicht“ > „Neu“; alle Test-, Typ- und Lint-Tore grün, Biome-Grundlinie gehalten; web und api laufen neu gebaut; Browser-Prüfung im Dunkelmodus durchgeführt (oder ausdrücklich an den Orchestrator übergeben); alle Commits lokal, nichts gepusht.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser -> API `/custom-modules` | Nicht vertrauenswürdige Eingaben (Name, Adresse, Kategorie, id) und Rollenanspruch aus der Sitzung |
| Admin-Eingabe -> alle Benutzer des Mandanten | Eine vom Admin gespeicherte Adresse wird jedem Benutzer als Rahmen und Link ausgeliefert |
| Tessera-Seite -> eingebettete Fremdseite | Fremder Inhalt läuft im Rahmen innerhalb des Tessera-Tabs |
| API -> PostgreSQL | Mandantentrennung über tenantId, forTenant und tenant_isolation_policy |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-9WC-01 | Elevation of Privilege | CustomModulesController POST/PATCH/DELETE | high | mitigate | `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` an den drei schreibenden Methoden, globaler RolesGuard; Controller-Spec prüft die Metadaten; GET-Routen bewusst ohne Rolle (D-04) |
| T-9WC-02 | Information Disclosure | CustomModulesService, Tabelle CustomModule | high | mitigate | tenantId ausschließlich aus `req.tenantId`; je Methode `const tenantPrisma = forTenant(this.prisma, tenantId)`; `list` filtert zusätzlich `where: { tenantId }`; getOne/update/remove prüfen `row.tenantId !== tenantId` -> 404; Migration mit ENABLE/FORCE RLS und tenant_isolation_policy; rls-coverage/rls-access-inventory grün |
| T-9WC-03 | Tampering | Adresse (DTO + Web-Rendering) | high | mitigate | API: eigene Constraint über den URL-Parser, nur `https:`, Hostname nötig, max 2048; Web: iframe und Link nur bei `checkCustomModuleUrl(url) === 'ok'` — `javascript:`, `data:` und `http:` werden nie gerendert, auch nicht bei manipulierter Datenbankzeile |
| T-9WC-04 | Spoofing | Eingebettete Fremdseite | medium | mitigate | `sandbox={XFRAME_SANDBOX}` (ohne Navigation des obersten Fensters und ohne `allow-modals`, Begründung in `xframe-config.ts`), `allow=""`; Test prüft den exakten Sandbox-Wert |
| T-9WC-05 | Information Disclosure | Referrer an Fremdseite | low | mitigate | `referrerPolicy="no-referrer"` am iframe, `rel="noopener noreferrer"` am Link „In neuem Tab öffnen“ |
| T-9WC-06 | Information Disclosure | Zugangsdaten in der Adresse | medium | mitigate | API und Formular lehnen Adressen mit Benutzername/Kennwort ab — sonst sähe jeder Benutzer die Zugangsdaten in der Adresse |
| T-9WC-07 | Denial of Service | Name/Adresse-Felder | low | mitigate | `@MaxLength(100)` Name, `@MaxLength(2048)` Adresse, Kategorie per `@IsIn` auf fünf Werte begrenzt; ValidationPipe `whitelist: true` verwirft Zusatzfelder (z. B. untergeschobenes tenantId) |
| T-9WC-08 | Spoofing | Admin bindet eine täuschend echte Fremdseite ein | low | accept | Der Admin ist vertrauenswürdig (ASVS L1); Einträge sind nur für Admins änderbar, der Name steht sichtbar in Leiste und Kopfzeile |
| T-9WC-SC | Tampering | npm/pip/cargo installs | high | accept | Dieser Plan installiert keine Pakete; alle genutzten Bibliotheken (class-validator, @nestjs/mapped-types, Prisma) sind bereits im Lockfile |
</threat_model>
<verification>
Executor (Tore in Aufgabe 3 gebündelt):
- `pnpm --filter @tessera/web exec vitest run` und `pnpm --filter @tessera/api exec vitest run` vollständig grün
- `pnpm turbo run type-check lint` grün; Biome-Warnungen Web höchstens 55, API höchstens 82
- API-Durchstich per curl aus Aufgabe 1 (Anlegen, Liste, Einzelabruf, 400 bei http, 401 anonym, Löschen, 404 danach)
- `test -z "$(git branch -r --contains HEAD)"` — nichts gepusht
**Browser-Prüfung (D-11)** — Playwright MCP gegen web :3000, Anmeldung admin / admin123, IMMER echte
Navigation (`browser_navigate`) und gerenderten Inhalt auslesen, nie per `fetch()` aus der Seite
messen. Zuerst über den Theme-Knopf der Kopfzeile auf dunkel schalten (nicht per classList):
1. Verwaltung > „Eigene Module“ (neuer Eintrag in der Admin-Leiste, „Module“ ist dabei nicht
markiert): Leer-Zustand mit Knopf „Eigenes Modul anlegen“.
2. Anlegen mit Name „Beispielseite“, Adresse `http://example.com` -> Meldung, nichts gespeichert;
dann `https://user:pw@example.com` -> Meldung; dann `https://example.com`, Kategorie
„Infrastruktur“ -> gespeichert, Tabelle zeigt den Eintrag, die Seitenleiste zeigt „Beispielseite“
unter „Infrastruktur“ OHNE Neuladen.
3. Zweiter Eintrag „GitHub“, `https://github.com`, Kategorie „Sicherheit“ -> erscheint unter
„Sicherheit“.
4. Klick auf „Beispielseite“: `/modules/custom/<id>`, Kopfzeilen-Titel „Beispielseite“, Auswahlmarke
am Eintrag, der Rahmen füllt den Inhaltsbereich ohne doppelten Rollbalken, „In neuem Tab öffnen“
sichtbar; im Accessibility-Snapshot/DOM trägt das iframe den Sandbox-Wert aus `XFRAME_SANDBOX` und
`referrerpolicy="no-referrer"`. Der Link öffnet einen neuen Tab mit example.com.
5. Klick auf „GitHub“: der Rahmen zeigt die Einbettungssperre des Browsers, der Knopf „In neuem Tab
öffnen“ ist trotzdem sichtbar und funktioniert.
6. Seitenleiste eingeklappt: beide Einträge als Kachel mit Namen im Tooltip; Suche „Beisp“ findet den
Eintrag.
7. Bearbeiten: „Beispielseite“ in „Beispiel“ umbenennen -> Seitenleiste zieht sofort nach. Löschen mit
Rückfrage -> Eintrag verschwindet aus Tabelle und Seitenleiste; die alte Adresse
`/modules/custom/<id>` zeigt „Dieses Modul gibt es nicht mehr.“
8. Sprache auf Englisch: keine rohen Übersetzungsschlüssel auf Verwaltungsseite und Rahmen-Seite.
9. Screenshots (dunkel) von Verwaltungsseite, Seitenleiste mit Einträgen und Rahmen-Seite ablegen;
danach die Testeinträge löschen, damit die lokale Datenbank sauber bleibt.
</verification>
<success_criteria>
- Admins verwalten eigene Module (Name, https-Adresse, Kategorie) unter Verwaltung > Eigene Module;
alle Benutzer sehen sie unter der Kategorie in der Seitenleiste (D-01, D-05, D-07).
- Die Rahmen-Seite bettet nur https-Adressen ein, mit dem XFrame-Sandbox-Wert und ohne Referrer, und
zeigt immer „In neuem Tab öffnen“ (D-06).
- API: GET für jeden Angemeldeten, Schreiben nur Admin, http und Zugangsdaten in der Adresse werden
abgewiesen; `list` steht vor `getOne` (D-04, D-10).
- Tabelle CustomModule mit Zeilenschutz; Zugriffsklassifikation nachgemessen fortgeschrieben (inkl.
der beim Planen gefundenen Drift in `user` und der Klassen-Verteilung); RLS-Specs grün (D-03).
- Texte de/en in Sie-Form, CHANGELOG ergänzt (D-08, D-09); alle Tore grün, Biome-Grundlinie gehalten.
- Browser-Prüfung im Dunkelmodus bestanden (D-11); alle Commits nur lokal (D-12).
- Gruppen-Einschränkung bewusst NICHT gebaut, im SUMMARY als zurückgestellt begründet (D-02).
</success_criteria>
<output>
Create `.planning/quick/260929-9wc-eigene-module-admin-legt-seitenleisten-e/260929-9wc-SUMMARY.md` when done
(deutsch, Muster der letzten Quick-Summaries: Was gebaut wurde, Abweichungen, Tore mit gemessenen Zahlen
inkl. Biome-Warnungen und nachgemessener Klassifikationszahlen, Ergebnis der Browser-Prüfung mit
Screenshot-Pfaden, „Bewusst offen“: Gruppen-Einschränkung für eigene Module (D-02, Begründung
ModuleGrant-Fremdschlüssel auf Module), Hinweis dass nichts gepusht wurde).
</output>
@@ -0,0 +1,161 @@
---
phase: quick-260929-9wc
plan: 01
quick_id: 260929-9wc
subsystem: api, web, prisma
tags: [custom-modules, sidebar, iframe, rls, admin]
status: complete
requires: []
provides:
- Tabelle CustomModule mit Zeilenschutz (Migration 20260929120000)
- API /custom-modules (GET fuer jeden Angemeldeten, POST/PATCH/DELETE nur Admin)
- Seitenleisten-Eintraege und Rahmen-Seite /modules/custom/[id]
- Verwaltungsseite Verwaltung > Eigene Module
- MODULE_CATEGORIES in @tessera/shared
affects: [sidebar, admin-sidebar, docs/mandantentrennung-zugriffsklassifikation.md]
key-files:
created:
- apps/api/prisma/migrations/20260929120000_custom_module/migration.sql
- apps/api/src/custom-modules/ (Controller, Dienst, Modul, DTO, je mit Spec)
- apps/web/src/lib/custom-modules-api.ts
- apps/web/src/components/modules/custom-module-view.tsx
- apps/web/src/app/(portal)/modules/custom/[id]/page.tsx
- apps/web/src/app/(portal)/admin/custom-modules/ (page, FormModal, DeleteDialog, Test)
- apps/web/src/messages/module-categories.spec.ts
modified:
- apps/api/prisma/schema.prisma
- apps/api/src/app.module.ts
- packages/shared/src/index.ts
- apps/web/src/components/layout/sidebar.tsx (+ Test)
- apps/web/src/components/admin/admin-sidebar.tsx
- apps/web/src/messages/de.json, en.json
- apps/web/src/app/(portal)/modules/module-layouts.test.tsx
- docs/mandantentrennung-zugriffsklassifikation.md
- CHANGELOG.md
decisions:
- "Gruppen-Einschraenkung (D-02) zurueckgestellt, siehe Bewusst offen"
- "Eigene Module haengen an keiner Modul-Aktivierung (kein @UseModule, kein ModuleAccessGate)"
- "Seitenleiste vereinheitlicht Module und eigene Module in einem internen Eintragstyp; eingebaute Module stehen je Kategorie vor eigenen"
- "https-Regel im Web bleibt EINE (isHttpsUrl aus xframe-config), Sandbox-Wert wird importiert, nicht kopiert"
duration: ca. 10 Minuten reine Ausfuehrung
completed: 2026-09-29
commits: 3
plan_head_before: 643b1a2caa01a506b0b7aec8c6236f98769e19f0
plan_head_after: e48c0de23816702b42a4fb265a22298c32769206
actuals:
tokens: 31000
tasks: 3
commits: 3
---
# Phase quick-260929-9wc Plan 01: Eigene Module Summary
Administratoren binden externe https-Seiten als Seitenleisten-Eintraege ein (Name, Adresse, Kategorie); alle Benutzer sehen sie unter der Kategorie, ein Klick zeigt die Seite in einem Rahmen mit dem XFrame-Sandbox-Wert und immer sichtbarem Knopf „In neuem Tab öffnen“. Daten liegen mandantengetrennt mit Zeilenschutz in der neuen Tabelle `CustomModule`.
## Was gebaut wurde
**Aufgabe 1 (Tracer), Commit b9d87be**
- `MODULE_CATEGORIES` (fuenf Kennungen) + Typ `ModuleCategory` in `packages/shared`; Gleichlauf-Spec gegen `moduleCategories` in de.json/en.json.
- Prisma-Modell `CustomModule` und handgeschriebene Migration `20260929120000_custom_module` (ENABLE/FORCE RLS, `tenant_isolation_policy` ohne Benutzerdimension, bewusst keine `system_read_policy`).
- API `/custom-modules`: DTO (Name 1 bis 100, Adresse nur https ohne Zugangsdaten, max 2048, Kategorie per `@IsIn`), Dienst (je Methode `const tenantPrisma = forTenant(this.prisma, tenantId)`, `row.tenantId`-Pruefung, 404 bei fremd/unbekannt), Controller (`list` vor `getOne`, schreibende Routen `@Roles(ADMIN, SUPER_ADMIN)`), Modul in `app.module.ts`.
- Zugriffsklassifikation nachgemessen fortgeschrieben (siehe Zahlen).
- Web: `custom-modules-api.ts` (inkl. `checkCustomModuleUrl`), Seitenleiste mit vereinigter Eintragsliste (Gruppierung, Suche, eingeklappte Kacheln, Auswahlmarke, Kopfzeilen-Titel ueber `useNavStore` mit slug = id), `CustomModuleView` + Seite `/modules/custom/[id]`, Texte `customModules` de/en.
- Lokal migriert (Container-IP, `prisma migrate deploy`), API neu gebaut; curl-Durchstich bestanden.
**Aufgabe 2, Commit e7fc4de**
- Verwaltungsseite `admin/custom-modules` (Liste, Leer-Zustand, Anlegen/Bearbeiten-Dialog mit Adresspruefung vor dem Senden, Loeschen mit Rueckfrage), Aufruf von `bumpSidebarRefresh` nach jedem erfolgreichen Speichern/Loeschen, Admin-Leisten-Eintrag hinter „Module“, Texte `admin.customModules` und `header.admin.customModules` de/en.
**Aufgabe 3, Commit e48c0de**
- CHANGELOG-Eintrag unter „Unveröffentlicht“ > „Neu“, alle Tore, Stack neu gebaut.
## Tore (gemessen)
| Tor | Ergebnis |
|-----|----------|
| Web-Tests vollstaendig | 102 Dateien, 992 Tests, alle gruen |
| API-Tests vollstaendig | 88 Dateien, 1495 Tests, alle gruen |
| `pnpm turbo run type-check lint` | 9/9 Aufgaben erfolgreich |
| Biome-Warnungen Web | 55 (Grundlinie 55) |
| Biome-Warnungen API | 82 (Grundlinie 82) |
| rls-coverage.spec / rls-access-inventory.spec | gruen (30 Zusicherungen im Inventar) |
| `prisma migrate deploy` (zweiter Lauf) | „No pending migrations to apply.“ |
| `prisma migrate diff` | enthaelt „CustomModule“ nicht |
| curl-Durchstich | Anlegen, Liste, Einzelabruf ok; http 400; anonym 401; Loeschen; danach 404 |
| Stack | web :3000/login 200, api /health ok, `GET /custom-modules` anonym 401 |
| Nicht gepusht | `git branch -r --contains HEAD` leer |
**Zugriffsklassifikation nachgemessen (Gate-Schleife, nur .ts ohne spec):**
- Summe vorher gemessen 61/217/6 (Dokument nannte 61/216/6); Drift in `user`: gemessen 18 gebunden statt 17 (aus quick-260928-ujj), korrigiert.
- `custom-modules`: 0/7/0 (list 1, getOne 1, create 1, update 2, remove 2).
- Neue Summe: 61/224/6.
- Klassen-Verteilung: Ueberschrift/Tabelle nannten 77 Paare/40 muss, Bestandsaufnahme hatte schon 78/41 (`grep -cE '^\| apps/api/src/'`); nach neuem Eintrag 79 Paare, davon 42 muss, 21 keine-mandantengebundene-tabelle, 14 beides, 2 bewusst-uebergreifend. Nachtrag-Absatz „quick-260929-9wc“ ergaenzt.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Endlosschleife beim Laden der Verwaltungsseite**
- **Found during:** Aufgabe 2 (Test zaehlte 5 statt 2 Listenabrufe)
- **Issue:** `fetchModules` hing per `useCallback` an `t` (Uebersetzungsfunktion); ein Mock liefert je Render eine neue Funktion, der Effekt lief erneut. Auch mit echtem next-intl fragil.
- **Fix:** Fehler als Boolean `loadFailed` gefuehrt, Text erst im JSX uebersetzt; `fetchModules` ohne Abhaengigkeit.
- **Files modified:** `apps/web/src/app/(portal)/admin/custom-modules/page.tsx`
- **Commit:** e7fc4de
**2. [Rule 3 - Blocking] Layout-Waechter der Modulordner**
- **Found during:** Aufgabe 3 (voller Web-Testlauf)
- **Issue:** `module-layouts.test.tsx` (T-e8k-04) verlangt in jedem nicht-dynamischen Ordner unter `modules/` eine `layout.tsx` mit ModuleAccessGate; der neue Ordner `custom/` ist bewusst fuer alle sichtbar (D-01) und hat keine Schranke.
- **Fix:** Explizite, begruendete Ausnahmeliste `DIRS_WITHOUT_GATE = ['custom']` im Test, statt eine wirkungslose Durchreich-Layout-Datei anzulegen.
- **Files modified:** `apps/web/src/app/(portal)/modules/module-layouts.test.tsx`
- **Commit:** e48c0de
**3. Plan-Feinheit (kein Regelfall):** Die Seitenleisten-Fehlerbehandlung fuer `listCustomModules` laesst bei Fehler den bisherigen Stand stehen (leer beim ersten Laden), wie der Modulabruf, statt aktiv zu leeren; Ergebnis beim ersten Laden identisch mit „leere Liste“.
## Bewusst offen
- **Gruppen-Einschraenkung fuer eigene Module (D-02) zurueckgestellt.** `ModuleGrant.moduleId` ist ein Pflicht-Fremdschluessel auf `Module` (`onDelete: Cascade`); eigene Module sind keine `Module`-Zeilen. Eine Einschraenkung braeuchte eine neue Freigabetabelle oder einen Umbau von `ModuleGrant` samt `module-access.service.ts` und der Admin-Freigabeoberflaeche, also nicht „sehr wenig Aufwand“. Im Code nichts dafuer gebaut.
## Browser-Pruefung offen (Orchestrator)
Playwright MCP steht in diesem Ausfuehrungskontext nicht zur Verfuegung. Der Orchestrator fuehrt die Pruefung durch: web :3000, Anmeldung admin / admin123, echte Navigation (`browser_navigate`), nie per `fetch()` aus der Seite messen, zuerst ueber den Theme-Knopf der Kopfzeile auf dunkel schalten. Stack ist neu gebaut und laeuft.
1. Verwaltung > „Eigene Module“ (neuer Eintrag in der Admin-Leiste hinter „Module“, „Module“ dabei nicht markiert): Leer-Zustand mit Knopf „Eigenes Modul anlegen“.
2. Anlegen mit Name „Beispielseite“, Adresse `http://example.com` -> Meldung, nichts gespeichert; dann `https://user:pw@example.com` -> Meldung; dann `https://example.com`, Kategorie „Infrastruktur“ -> gespeichert, Tabelle zeigt den Eintrag, die Seitenleiste zeigt „Beispielseite“ unter „Infrastruktur“ OHNE Neuladen.
3. Zweiter Eintrag „GitHub“, `https://github.com`, Kategorie „Sicherheit“ -> erscheint unter „Sicherheit“.
4. Klick auf „Beispielseite“: `/modules/custom/<id>`, Kopfzeilen-Titel „Beispielseite“, Auswahlmarke am Eintrag, Rahmen fuellt den Inhaltsbereich ohne doppelten Rollbalken, „In neuem Tab öffnen“ sichtbar; iframe traegt den Sandbox-Wert `allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox` und `referrerpolicy="no-referrer"`; der Link oeffnet einen neuen Tab mit example.com.
5. Klick auf „GitHub“: Rahmen zeigt die Einbettungssperre des Browsers, „In neuem Tab öffnen“ ist trotzdem sichtbar und funktioniert.
6. Seitenleiste eingeklappt: beide Eintraege als Kachel mit Namen im Tooltip; Suche „Beisp“ findet den Eintrag.
7. Bearbeiten: „Beispielseite“ in „Beispiel“ umbenennen -> Seitenleiste zieht sofort nach. Loeschen mit Rueckfrage -> Eintrag verschwindet aus Tabelle und Seitenleiste; alte Adresse `/modules/custom/<id>` zeigt „Dieses Modul gibt es nicht mehr.“
8. Sprache auf Englisch: keine rohen Uebersetzungsschluessel auf Verwaltungsseite und Rahmen-Seite.
9. Screenshots (dunkel) von Verwaltungsseite, Seitenleiste mit Eintraegen und Rahmen-Seite ablegen; danach die Testeintraege loeschen, damit die lokale Datenbank sauber bleibt.
Hinweis: Der curl-Durchstich hat seinen Testeintrag bereits geloescht; die lokale Datenbank enthaelt keine eigenen Module.
## Known Stubs
Keine.
## Threat Flags
Keine neue Angriffsflaeche ausserhalb des Plan-Bedrohungsmodells (T-9WC-01 bis 07 umgesetzt: Rollen-Metadaten per Spec geprueft, tenantId nur aus `req.tenantId`, https-Regel in API und Web, exakter Sandbox-Wert, Referrer/`rel`, MaxLength, `whitelist: true`).
## Nichts gepusht
Drei lokale Commits (b9d87be, e7fc4de, e48c0de), kein `git push`; die vorgemerkten Loeschungen von `.planning/.continue-here.md` und `.planning/HANDOFF.json` blieben unangetastet im Index.
## Self-Check: PASSED
- Dateien vorhanden: Migration, `custom-modules.service.ts`/`controller.ts`/`module.ts`/`dto`, `custom-modules-api.ts`, `custom-module-view.tsx`, `modules/custom/[id]/page.tsx`, `admin/custom-modules/page.tsx` mit Komponenten (alle im Commit-Stat sichtbar).
- Commits vorhanden: b9d87be, e7fc4de, e48c0de (`git log`), `commits: 3` gemessen ueber `rev-list` vom Ledger.
## Browser-Pruefung (Orchestrator, 29.09., dunkel)
Durchgefuehrt per Playwright MCP auf :3000, Theme per Kopfzeilen-Knopf auf „Dunkel“:
1. Verwaltung > „Eigene Module“: Eintrag in der Admin-Leiste, Leer-Zustand korrekt.
2. http://example.com -> „Bitte geben Sie eine Adresse ein, die mit https:// beginnt.“; https://user:pw@example.com -> „Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.“; https://example.com / Infrastruktur -> gespeichert, Seitenleiste zeigt „Beispielseite“ ohne Neuladen.
3. „GitHub“ / Sicherheit erscheint unter „Sicherheit“.
4. Rahmen-Seite: Kopfzeilen-Titel, Auswahlmarke, kein doppelter Rollbalken; sandbox = `allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox`, referrerpolicy = no-referrer; Link target=_blank rel="noopener noreferrer".
5. GitHub: Einbettung per frame-ancestors blockiert, „In neuem Tab öffnen“ oeffnet github.com im neuen Tab.
6. Eingeklappt: Eintraege als Symbole; Suche „Beisp“ findet den Eintrag.
7. Umbenennen zieht Seitenleiste sofort nach; Loeschen mit Rueckfrage; alte Adresse zeigt „Dieses Modul gibt es nicht mehr.“
8. Englisch: nicht per Oberflaeche umgeschaltet; stattdessen Schluessel-Paritaet de/en geprueft (keine fehlenden Schluessel, alle Texte ueber t()).
9. Testeintraege geloescht, lokale DB ohne eigene Module.
@@ -0,0 +1,55 @@
---
quick_id: 260929-d37
type: quick
wave: 1
autonomous: true
files_modified:
- apps/desktop/src-tauri/Cargo.toml
- apps/desktop/src-tauri/Cargo.lock
- apps/desktop/src-tauri/src/lib.rs
- CHANGELOG.md
---
# Quick 260929-d37: Desktop-Client nur einmal starten (Single-Instance)
## Problem
User report (29.09.2026, Windows 11): at system start Tessera launches twice and two tray icons appear.
`apps/desktop/src-tauri/src/lib.rs` has no single-instance guard. Autostart via `tauri-plugin-autostart`
(HKCU Run key, only set when the user ticks "Mit Windows starten"); a second launch source (Windows 11
"restart restartable apps after sign-in", a stale Run/Startup entry from an older install, or a manual
double-click) starts a second full process with its own tray icon.
## Goal
Only one Tessera desktop process runs per user session. A second launch hands off to the running one
(show + unminimize + focus the main window) and exits immediately — no second tray icon.
## Task 1: Single-instance plugin
- files: apps/desktop/src-tauri/Cargo.toml, apps/desktop/src-tauri/Cargo.lock, apps/desktop/src-tauri/src/lib.rs
- action:
- Add `tauri-plugin-single-instance = "2"` to `[dependencies]` (resolve with cargo; lockfile updated).
- Register it as the FIRST plugin in `tauri::Builder` (plugin docs require it to be registered first):
`.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { focus main window }))`.
- Callback: reuse the exact show/unminimize/set_focus sequence already used by the tray "open" handler
(around lib.rs:820-835). If that sequence is duplicated 3x already, extract a small helper
`fn show_main_window(app: &AppHandle)` and use it in all places (keep behavior identical).
- Short German comment above the plugin line explaining why (double start at Windows sign-in, two tray icons).
- No capabilities/permissions change needed (plugin has no JS API); verify by building.
- verify: `cd apps/desktop/src-tauri && cargo build` succeeds; `cargo test` (existing unit tests) green; `cargo clippy` no new warnings if clippy is available.
- done: builds, tests green, commit `fix(desktop): nur eine Instanz — zweiter Start holt das Fenster nach vorne`.
## Task 2: CHANGELOG
- files: CHANGELOG.md
- action: under `## Unveröffentlicht` add a `### Behoben` section (after `### Neu`) with one plain-German bullet (app text uses "Sie"), e.g.:
"Desktop-App: Tessera startet nicht mehr doppelt. Wird die App ein zweites Mal gestartet – etwa beim Anmelden an Windows –, holt sie nur das vorhandene Fenster nach vorne; im Infobereich erscheint nur noch ein Symbol."
Follow existing CHANGELOG style; run the repo's changelog/umlaut checks if any exist (web tests touching CHANGELOG, e.g. `pnpm --filter web test -- changelog`).
- done: commit `docs(changelog): Desktop-App startet nicht mehr doppelt`.
## Constraints
- Commit locally only. NEVER `git push`.
- Do not touch the desktop version numbers (release process handles them).
- Real Windows verification is not possible from here; state in SUMMARY that the Windows check (VM 8233 or user's PC after next desktop release) is open.
@@ -0,0 +1,41 @@
---
quick_id: 260929-d37
status: complete
commits: 2
plan_head_before: bc26010
plan_head_after: 0751198
---
# Quick 260929-d37: Desktop-Client nur einmal starten (Single-Instance)
`tauri-plugin-single-instance` (2.4.5) ist als erstes Plugin im `tauri::Builder` registriert. Ein zweiter Start holt das Fenster der laufenden Instanz nach vorne und beendet sich, es entsteht kein zweites Tray-Symbol.
## Tasks
1. **Single-Instance-Plugin** — Commit `c0b145a` (`fix(desktop): nur eine Instanz — zweiter Start holt das Fenster nach vorne`)
- `Cargo.toml` und `Cargo.lock` um `tauri-plugin-single-instance = "2"` erweitert.
- Die Sequenz unminimize/show/set_focus stand dreimal in `lib.rs` (Tray "open", "change_server", Tray-Linksklick). Sie ist jetzt der Helper `show_main_window(&AppHandle)`, den auch der Single-Instance-Callback nutzt. Das Verhalten der Tray-Handler ist unverändert. Bei "change_server" läuft `navigate` weiterhin vor dem Anzeigen.
- Kurzer deutscher Kommentar über der Plugin-Zeile.
2. **CHANGELOG** — Commit `0751198` (`docs(changelog): Desktop-App startet nicht mehr doppelt`)
- Unter "Unveröffentlicht" neuer Abschnitt "Behoben" mit einem Eintrag.
## Verifikation
- `cargo build`: ok
- `cargo test`: 44 Tests grün
- `cargo clippy`: keine Warnungen
- Vitest `changelog.test.ts`, `release-notes.test.ts`, `changelog-page.test.tsx`: 33 Tests grün
## Deviations from Plan
None - plan executed exactly as written.
## Offen
Die Prüfung unter Windows steht aus, weil sie von hier aus nicht möglich ist. Sie kann in der Windows-Test-VM 8233 oder auf dem PC des Users nach dem nächsten Desktop-Release erfolgen: App zweimal starten, es darf nur ein Tray-Symbol erscheinen und das Fenster kommt nach vorne. Die Desktop-Versionsnummern sind unverändert.
## Known Stubs
None.
## Self-Check: PASSED
@@ -0,0 +1,61 @@
---
quick_id: 260929-dmx
type: quick
wave: 1
autonomous: true
---
# Quick 260929-dmx: Widget-Raster horizontal feiner (48 Spalten), Kalender schmaler
## User requests (29.09.2026)
1. "das kalender widget soll in der breite schmäler gemacht werden können."
2. "und mache das widget raster horizontal etwas feiner."
## Measurements (orchestrator, browser, lg breakpoint, grid width 1593 px, margin 12)
- Today: 24 cols → one width unit ≈ 66 px. Calendar minW 6 ≈ 383 px, default 8 ≈ 515 px.
- Calendar rendered at 251 px: fully usable (month grid, header "September 2026", event list truncates titles cleanly).
- Calendar at 185 px: header clipped, event titles reduced to one letter → too narrow.
- Target: calendar minimum ≈ 250 px.
## Decision (locked)
Double the HORIZONTAL resolution only: `COLS = { lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 }` in
`apps/web/src/components/dashboard/dashboard-grid.tsx`. Row height (20) and margin (12) unchanged.
Every existing widget keeps its exact on-screen size and position.
## Task 1: Grid version 3 (horizontal x2) with migration
- files: apps/web/src/lib/grid-layout-migration.ts (+ test), apps/web/src/components/dashboard/dashboard-grid.tsx (+ test), apps/web/src/lib/stores/dashboard-store.ts (only if needed)
- action:
- Bump `GRID_VERSION` to 3. Migration becomes stepwise and cumulative:
v1 → v2: existing behavior (x,y,w,h,minW,minH,maxW,maxH × 2).
v2 → v3: NEW, horizontal only: x, w, minW, maxW × 2 (y, h, minH, maxH unchanged).
So a v1 layout gets both steps, a v2 layout only the second, a v3 layout nothing. Keep idempotence and marker semantics (marker only in persisted JSON). Update the file header comment (German, same style) to document v3.
- `COLS` as above. Check every other place that depends on column count or widget width units:
centering offset, `RESIZE_AXIS_FALLBACK`, `breakpointFor`, default positions when adding a widget
(`dashboard-grid.tsx` ~380: `defaultW ?? 4`, `minW ?? 4` fallbacks → 8), empty-dashboard suggestions,
any layout templates/seed data in apps/web or apps/api (grep `defaultW`, `w:` in dashboard code,
`layouts` defaults in apps/api/src/dashboard). Anything expressed in width units gets × 2.
- Tests: extend grid-layout-migration tests (v1→v3, v2→v3, v3 untouched, idempotence, marker 3 written), update dashboard-grid tests pinning cols.
- verify: `pnpm --filter web exec vitest run src/lib src/components/dashboard` green.
## Task 2: Widget width constraints in new units
- files: apps/web/src/components/dashboard/widget-registry.tsx (+ test)
- action: In `WIDGET_CONSTRAINTS` double every `minW` and `defaultW` (same physical size as before),
EXCEPT calendar: `minW: 8` (≈ 251 px at lg — the measured usable minimum), `defaultW: 16` (unchanged size).
minH/defaultH unchanged. Update the comment above calendar (German): narrower on user request 29.09., 8 of 48 ≈ 250 px measured usable.
Existing layouts: the existing override logic in dashboard-grid (quick-260916-dyv: stored minW/minH replaced by constants in every breakpoint) must pick up the new calendar minW so existing calendars can be shrunk — verify that path with a test.
- verify: web tests green; `pnpm turbo run type-check lint --filter web` green; biome warnings for web not above baseline 55.
## Task 3: CHANGELOG, rebuild, commit
- CHANGELOG.md under `## Unveröffentlicht` → `### Geändert` (section exists) add plain-German bullets (app text uses "Sie"):
- Dashboard: Das Raster ist in der Breite doppelt so fein – Widgets lassen sich in kleineren Schritten breiter oder schmaler ziehen und genauer platzieren. Bestehende Anordnungen bleiben unverändert.
- Dashboard: Das Kalender-Widget lässt sich deutlich schmaler ziehen als bisher.
- Rebuild local stack: `docker compose up -d --build web` (plain `up` does not rebuild).
- Commits per task, messages end with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`.
- NEVER git push (orchestrator pushes after browser check).
- Browser check is done by the orchestrator (resize calendar to minimum, existing layout unchanged after migration, marker 3 persisted).
@@ -0,0 +1,84 @@
---
quick_id: 260929-dmx
status: complete
commits: 3
plan_head_before: acd3c7a05f9f01e2f5cf8ebe9aed66d8c789a055
plan_head_after: 46ebb4e7ceafa1dc782514e487ac820166279f48
completed: 2026-09-29
actuals:
tasks: 3
commits: 3
---
# Quick 260929-dmx: Widget-Raster horizontal 48 Spalten, Kalender schmaler
Grid version 3: horizontal resolution doubled (COLS lg 48 / md 40 / sm 24 / xs 16 / xxs 4), row height 20 and margin 12 unchanged. Stored layouts are migrated stepwise, so every existing widget keeps its on-screen size and position. Calendar minimum is now 8 of 48 columns (about 250 px at lg).
## Commits
- 97744b5 feat: grid v3 with stepwise migration, COLS, fallbacks
- 9c9e142 feat: widget constraints in 48-column units, calendar minW 8
- 46ebb4e docs(changelog): two bullets under "Unveröffentlicht / Geändert"
## Every place where width units changed
1. `apps/web/src/lib/grid-layout-migration.ts`: `GRID_VERSION` 2 to 3. Migration is now a step table: v1 to v2 scales x, y, w, h, minW, minH, maxW, maxH by 2; v2 to v3 scales only x, w, minW, maxW by 2. A v1 layout gets both steps, a v2 layout only the second, a v3 layout nothing. Marker semantics unchanged (marker only in the persisted JSON, stripped on load, re-added by `withGridVersion`). Header comment updated.
2. `apps/web/src/components/dashboard/dashboard-grid.tsx`:
- `COLS` is `{ lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 }`.
- Fallback for a widget without a layout entry: `defaultW ?? 8` and `minW ?? 8` (was 4/4). The `?? 4` fallbacks for height stay.
- Comment block above BREAKPOINTS/COLS extended.
3. `apps/web/src/components/dashboard/widget-registry.tsx` (`WIDGET_CONSTRAINTS`, minW/defaultW doubled, heights untouched):
- clock 4/8
- search 12/24
- calendar minW 8, defaultW 16. Calendar is the only one that is not a plain doubling for minW: 8 instead of 12.
- note 8/12
- calculator 6/12
- favorites 2/12
- stopwatch 8/12
- picture-frame 8/16
- xframe 8/24
- proxmox 6/16
- Comments updated, German, including the calendar note (narrower on user request 29.09., 8 of 48 is about 250 px measured usable).
4. `dashboard-store.ts` needed no code change. `addWidget` reads `defaultW` from `WIDGET_CONSTRAINTS`, so new widgets are placed in the new units automatically. Load and save already route through `migrateGridLayouts` and `withGridVersion`.
5. `centeringOffset` needed no code change. It takes `cols` as a parameter and gets the new `COLS[breakpoint]`.
6. `breakpointFor` needed no change. It depends only on BREAKPOINTS, not on the column count.
The existing override in `applyConstraintMinima` (quick-260916-dyv) already replaces the stored minW/minH from the constants in every breakpoint, so existing calendars pick up minW 8 with no code change there. This is verified by a new test.
## Tests
- `grid-layout-migration.test.ts`: v1 to v3 (x/w/minW/maxW times 4, y/h/minH/maxH times 2), v2 to v3, v3 untouched, v1 result equals v2 result, idempotence for both paths with marker 3 written, future marker 4 untouched, string marker, foreign values.
- `dashboard-store.test.ts`: marker 3, new expected values, new-widget default of 8 wide.
- `dashboard-grid.test.tsx`: COLS pin, `centeringOffset` with 48 columns, data-grid fallbacks, minima overrides, new Test 9c (existing calendar with stored minW 12 gets minW 8 in every breakpoint, w 6 raised to 8, w 16 kept).
- `widget-registry.test.tsx`: constraints table.
- Verification: `pnpm --filter web exec vitest run src/lib src/components/dashboard` green (513). The full web suite is green (995). Type-check for `@tessera/web` is green. Biome shows 55 warnings, equal to the baseline. Note: the turbo filter name is `@tessera/web`, not `web`.
## Rebuild
`docker compose up -d --build web` ran, and the web container is up. Browser check is left to the orchestrator (calendar at minimum, existing layout unchanged after migration, marker 3 persisted).
## Found but deliberately left
- `apps/api/src/dashboard/dashboard.service.spec.ts:834` stores `__gridVersion: 2` as a passthrough fixture. The API only passes the JSON through, so the value is arbitrary, and I did not touch it.
- `RESIZE_AXIS_FALLBACK` and its tests use abstract grid numbers and are unit-independent, so nothing was changed. Its resize logic has no column dependency.
- Historical comments that mention "24 Spalten" (the quick-260922-vdk explanation in `dashboard-grid.tsx`, the quick-260916-bwo test description, the bwo header text) describe past states and were left. The vdk comment's numbers (24 columns, 50 px) describe the old bug, not the current state.
- Widget internals (calendar, favorites, calculator) use pixel-based or container-based layout, not grid units, so no change was needed.
- md/sm/xs/xxs columns are doubled proportionally with lg. Only lg was measured, and I did not check the smaller breakpoints in a browser.
- API/`seed`: no default layouts or seed data with width units exist in apps/api (empty defaults `{ lg: [], ... }` only).
- A brand-new empty v2 layout is not re-saved with marker 3 on load (`migrated` stays false for empty layouts, existing behaviour). The marker gets written on the next save.
## Deviations from Plan
None. The plan was executed as written. The SUMMARY, STATE, PLAN and ROADMAP files were not committed, as instructed.
## Self-Check: PASSED
Commits 97744b5, 9c9e142, 46ebb4e exist. Changed files exist. Working tree contains only the untracked quick-task directory.
## Browser-Pruefung (Orchestrator, 29.09., dunkel, lg 1888 px)
- Bestehende Anordnung nach Migration pixelgenau gleich (6 Widgets, left/top/width/height vor und nach identisch); DB: `__gridVersion` 3, lg-w verdoppelt.
- Kalender im Bearbeitungsmodus nach links gezogen: stoppt bei 252 px (vorher Minimum 383 px), Monatsraster, Kopf und Terminliste sauber lesbar.
- Schrittweite beim Ziehen 33 px (284/317/350), vorher 66 px.
- Kalender danach wieder auf 515 px gezogen, Testzustand zurueckgesetzt.
- Nicht im Browser geprueft: kleinere Breakpoints (md/sm/xs/xxs).
@@ -0,0 +1,66 @@
---
quick_id: 260929-dzu
type: quick
wave: 1
autonomous: true
---
# Quick 260929-dzu: Eigene Module für jeden Benutzer (persönlich)
## User request (29.09.2026)
"Jeder User soll eigene Module anlegen können. nicht nur admins."
Decision (AskUserQuestion, locked): **"Nur er selbst"** — a normal user's entries are visible ONLY to that user.
Admins keep creating shared entries (visible to everyone) on /admin/custom-modules as today.
No user can put anything into another user's sidebar.
## Existing state (quick 260929-9wc, commits b9d87be, e7fc4de)
- Prisma `CustomModule { id, tenantId, name, url, category, createdAt, updatedAt }`, migration
`20260929120000_custom_module` with RLS (tenant only, pattern ProxmoxServer).
- API `apps/api/src/custom-modules/*`: GET list/one for any authenticated user; POST/PATCH/DELETE admin only;
https-only, no credentials in URL.
- Web: sidebar loads `listCustomModules()`, frame page `/modules/custom/[id]`, admin page `/admin/custom-modules`
with `CustomModuleFormModal` + `DeleteCustomModuleDialog`, `bumpSidebarRefresh` after changes.
## Task 1: Model + API (tests first)
- Add nullable `ownerUserId String?` (+ relation to User with onDelete: Cascade, index `[tenantId, ownerUserId]`)
via NEW migration (e.g. `20260929130000_custom_module_owner`). `null` = shared (admin-made), set = personal.
- RLS: extend the existing policy the way user-scoped tables already do it (find the pattern used by e.g.
DashboardImage / Favorite / other tables with a user dimension). Personal rows must only be readable/writable by
their owner; shared rows readable by the whole tenant. If the project's RLS pattern handles the user dimension
in the service layer instead, follow that pattern and document it. Update the RLS inventory test and
`docs/mandantentrennung-zugriffsklassifikation.md` (re-measure totals as last time).
- Service/controller:
- `GET /custom-modules` → shared rows + rows owned by the caller. Response carries `personal: boolean` (or `ownerUserId === me`).
- `GET /custom-modules/:id` → 404 unless shared or owned by caller.
- `POST /custom-modules` → any authenticated user; body flag `shared?: boolean`. `shared: true` only allowed for admins
(403 otherwise); default personal (ownerUserId = caller). The admin page sends `shared: true`.
- `PATCH` / `DELETE` → personal rows: only the owner (404 for others, do not leak existence); shared rows: admin only (403 for non-admin).
Ownership/shared-ness cannot be changed via PATCH.
- Keep URL validation. Keep static routes before `:id`.
- Admin page list: `GET /custom-modules?scope=shared` (admin) or filter client-side — pick the simplest; the admin page shows only shared entries; the settings page only the caller's personal ones.
- Tests: service + controller specs for all permission cases (user A cannot see/edit/delete user B's entry; non-admin cannot create/edit/delete shared; admin personal vs shared).
- verify: `pnpm --filter @tessera/api exec vitest run src/custom-modules` + RLS inventory test green; migrate local DB (db container IP 172.19.x, tessera/tessera_dev), rebuild api, curl check.
## Task 2: Web — settings section
- Settings: new section/page "Eigene Module" in the user settings (`apps/web/src/app/(portal)/settings/`, follow how
`general` / `dashboard` sub-pages and their nav are built). Reuse `CustomModuleFormModal` and
`DeleteCustomModuleDialog` (move to a shared location if needed, e.g. `components/custom-modules/`) — one form, two callers.
Intro text (Sie-Form): e.g. "Nehmen Sie Webseiten, die Sie oft brauchen, als eigene Einträge in Ihre Seitenleiste auf. Diese Einträge sehen nur Sie."
- Admin page: shows only shared entries; intro text states they are visible for all users.
- Sidebar: unchanged behavior, shows shared + own personal entries (API already filters). `bumpSidebarRefresh` after changes on the settings page too.
- de + en texts; umlaut dictionary if needed.
- Tests: component tests for the settings page (create/edit/delete, list only personal), admin page still passes `shared: true`.
- verify: `pnpm --filter @tessera/web exec vitest run` green; `pnpm turbo run type-check lint` green; biome web ≤ 55, api ≤ 82.
## Task 3: CHANGELOG + rebuild
- CHANGELOG `## Unveröffentlicht` → adjust the existing "Eigene Module" bullet under "Neu" (not released yet, so rewrite it):
every user can add own entries under "Einstellungen → Eigene Module", visible only to them; administrators can additionally add entries for everyone under "Verwaltung → Eigene Module". Plain German, Sie-Form.
- Update `docs/anleitung-anwender.md` (and admin guide if it mentions custom modules) accordingly.
- `docker compose up -d --build web api`.
- Commits per task, end with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`. NEVER git push.
- Browser check is done by the orchestrator (normal user + admin, dark mode).
@@ -0,0 +1,170 @@
---
phase: quick-260929-dzu
plan: 01
quick_id: 260929-dzu
subsystem: api, web, prisma
tags: [custom-modules, personal, rls, settings]
status: complete
requires: [260929-9wc]
provides:
- Spalte CustomModule.ownerUserId (NULL = gemeinsam, gesetzt = persoenlich), Migration 20260929130000
- Zeilenschutz mit Benutzerdimension nach Muster SearchProvider
- API /custom-modules mit persoenlichen und gemeinsamen Eintraegen (Antwortfeld personal)
- Einstellungen > Eigene Module (/settings/custom-modules) fuer jeden Benutzer
- gemeinsame Oberflaeche CustomModuleManager (Formular, Loeschdialog, Liste) fuer Verwaltung und Einstellungen
key-files:
created:
- apps/api/prisma/migrations/20260929130000_custom_module_owner/migration.sql
- apps/web/src/components/custom-modules/custom-module-manager.tsx
- apps/web/src/app/(portal)/settings/custom-modules/page.tsx
- apps/web/src/app/(portal)/settings/custom-modules/custom-modules-settings.test.tsx
modified:
- apps/api/prisma/schema.prisma
- apps/api/src/custom-modules/ (Dienst, Controller, DTO, Specs)
- docs/mandantentrennung-zugriffsklassifikation.md
- apps/web/src/components/custom-modules/custom-module-form-modal.tsx (verschoben aus admin/custom-modules/components)
- apps/web/src/components/custom-modules/delete-custom-module-dialog.tsx (verschoben)
- apps/web/src/app/(portal)/admin/custom-modules/page.tsx (+ Test)
- apps/web/src/components/settings/settings-sidebar.tsx
- apps/web/src/lib/custom-modules-api.ts
- apps/web/src/messages/de.json, en.json
- CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md
decisions:
- "RLS: Muster SearchProvider (nullable Besitzerspalte, vier Regeln je Befehl), nicht die einfache Muster DashboardImage (Pflicht-userId)"
- "Gemeinsame Eintraege werden ohne Benutzerkontext geschrieben (forTenant ohne userId), persoenliche mit Benutzer"
- "Rollenpruefung fuer gemeinsame Eintraege im Dienst statt per @Roles, weil sie vom Eintrag abhaengt"
- "Filter fuer Verwaltung/Einstellungen im Web ueber personal, kein scope-Parameter in der API"
- "Texte von Formular und Loeschdialog in eigenen Namensraum customModules.form, Umzug aus admin.customModules"
completed: 2026-09-29
commits: 3
plan_head_before: 76a923450fd2f492ec046d5945a6965c8e94b707
plan_head_after: 8f41bd26bd3eddee1979485281cb375ade52949d
actuals:
tokens: 42000
tasks: 3
commits: 3
---
# Phase quick-260929-dzu Plan 01: Eigene Module fuer jeden Benutzer Summary
Jeder angemeldete Benutzer legt unter Einstellungen > Eigene Module persoenliche Seitenleisten-Eintraege an, die nur er sieht; Administratoren pflegen weiter gemeinsame Eintraege unter Verwaltung > Eigene Module (Senden von `shared: true`). Niemand kann etwas in die Seitenleiste eines anderen Benutzers legen.
## Was gebaut wurde
**Aufgabe 1, Commit c703d87 (Modell + API, Tests zuerst angepasst)**
- Schema: `ownerUserId String?` mit Relation zu `User` (`onDelete: Cascade`), Index `[tenantId, ownerUserId]`; Gegenfeld `customModules` am `User`.
- Migration `20260929130000_custom_module_owner` (von Hand, mit Kopfkommentar): Spalte, Index, Fremdschluessel, alte Regel ersetzt durch vier Regeln.
- Dienst/Controller: `GET /custom-modules` liefert gemeinsame plus eigene Zeilen mit `personal: boolean` (ownerUserId wird nicht ausgeliefert); `GET :id` 404 bei fremdem persoenlichem Eintrag (auch fuer Administratoren); `POST` fuer jeden Angemeldeten, `shared: true` nur fuer ADMIN/SUPER_ADMIN (sonst 403), Standard persoenlich; `PATCH`/`DELETE`: persoenlich nur Besitzer (fremd: 404), gemeinsam nur Administrator (sonst 403). `shared`/`ownerUserId` sind per PATCH nicht aenderbar (`OmitType` im DTO plus `whitelist`).
- Routen: `list` steht weiter vor `getOne`; kein `@Roles` mehr an den Schreibrouten, die Rollenpruefung sitzt im Dienst.
- Specs: Dienst (22 Faelle: A sieht/aendert/loescht B nicht, Nicht-Admin nicht shared, Admin persoenlich vs. gemeinsam, RLS-Bindung mit/ohne Benutzer), Controller, DTO-Pipe-Faelle.
- Zugriffsklassifikation nachgemessen (siehe unten).
**Aufgabe 2, Commit ee97b4e (Web)**
- Neue Seite `/settings/custom-modules` und Nav-Eintrag „Eigene Module“ unter „Allgemein“.
- Gemeinsame Komponenten unter `components/custom-modules/`: `CustomModuleFormModal` und `DeleteCustomModuleDialog` (verschoben, Parameter `shared`) plus neu `CustomModuleManager` (Liste, Anlegen/Bearbeiten/Loeschen, `bumpSidebarRefresh`), aufgerufen mit `scope="shared"` (Verwaltung) oder `scope="personal"` (Einstellungen). Filter ueber `personal` im Web.
- Verwaltung sendet beim Anlegen `shared: true`, zeigt nur gemeinsame Eintraege, Einleitung nennt „alle Benutzer“; Einstellungen senden kein `shared`, Einleitung: „Diese Einträge sehen nur Sie.“
- Texte de/en (Namensraeume `customModules.form`, `customModules.manage`, `settings.customModules`), Umlaut-Waechter gruen.
- Tests: neuer Settings-Test (7), Admin-Test angepasst (`shared: true`, Filter; 14).
**Aufgabe 3, Commit 8f41bd2 (Doku) + Neubau**
- CHANGELOG-Punkt „Eigene Module“ umgeschrieben (Einstellungen fuer jeden, Verwaltung zusaetzlich fuer alle), `docs/anleitung-anwender.md` (Abschnitt „Allgemein > Eigene Module“) und `docs/anleitung-administration.md` (Unterabschnitt bei 5.).
- `docker compose up -d --build web api`: web :3000/login 200, api /health ok, `GET /custom-modules` anonym 401, `/settings/custom-modules` ohne Anmeldung 307 (Umleitung auf Login).
## RLS-Muster und Begruendung
Gefolgt bin ich dem Muster **SearchProvider** aus `20260911120000_rls_user_dimension_personal_tables`: nullable Besitzerspalte, vier nach Befehl getrennte Regeln.
- SELECT: Mandant UND (kein Benutzer gesetzt ODER `ownerUserId IS NULL` ODER `ownerUserId = current_user_id()`).
- INSERT/UPDATE/DELETE: Mandant UND (kein Benutzer gesetzt ODER `ownerUserId = current_user_id()`).
Warum nicht das einfachere Muster DashboardImage/Favorite (Pflicht-`userId`, eine Regel): eigene Module haben gemeinsame Zeilen (`NULL`), die jeder lesen, aber nur ein Administrator schreiben darf. Eine einzelne Regel, die die gemeinsame Zeile zum Lesen freigibt, wuerde sie auch zum Aendern/Loeschen freigeben (Praezedenz 260910-jab (3)), deshalb getrennte Befehle. Folge: ein Benutzerkontext kann gemeinsame Zeilen nicht schreiben; der Dienst bindet Schreibzugriffe auf gemeinsame Eintraege deshalb bewusst OHNE Benutzer (`forTenant(prisma, tenantId)`), nachdem er die Administrator-Rolle geprueft hat. Persoenliche Zugriffe binden mit Benutzer. Wie bei allen RLS-Regeln wirkt der Schutz erst mit dem Datenbankrollen-Schalter (heute AUS); bis dahin tragen die Anwendungspruefungen (`row.tenantId`, `ownerUserId`) den Schutz.
## Curl-Pruefung (lokal, echte API :3001)
Benutzer: admin (SUPER_ADMIN), testuser (USER), curltmp (USER, nur fuer die Pruefung angelegt und danach geloescht).
| Fall | Ergebnis |
|------|----------|
| USER legt Eintrag ohne shared an | 200, `personal: true` |
| USER `shared: true` | 403 „Gemeinsame Einträge dürfen nur Administratoren anlegen“ |
| Admin `shared: true` | 200, `personal: false` |
| Admin ohne shared | 200, `personal: true` |
| Liste USER | gemeinsam + eigener |
| Liste zweiter USER | nur gemeinsam |
| Liste Admin | nur gemeinsam (persoenliche Eintraege anderer nicht) |
| zweiter USER: GET / PATCH / DELETE auf fremden persoenlichen Eintrag | 404 / 404 / 404 |
| Admin: GET / DELETE auf persoenlichen Eintrag eines Benutzers | 404 / 404 |
| USER GET gemeinsam | 200 |
| USER PATCH / DELETE gemeinsam | 403 / 403 |
| Admin PATCH gemeinsam (mit eingeschmuggeltem `shared:false`) | 200, bleibt gemeinsam |
| USER PATCH eigenen mit `shared:true`, `ownerUserId:null` | 200, bleibt persoenlich |
| http-Adresse | 400 |
| anonym | 401 |
| Benutzer loeschen -> seine persoenlichen Eintraege | Cascade, 0 Zeilen |
Alle Testeintraege sind geloescht, `CustomModule` ist leer.
## Tore (gemessen)
| Tor | Ergebnis |
|-----|----------|
| API-Tests vollstaendig | 88 Dateien, 1511 Tests gruen |
| Web-Tests vollstaendig | 103 Dateien, 1003 Tests gruen |
| `pnpm turbo run type-check lint --force` | 9/9 erfolgreich |
| Biome-Warnungen Web / API | 55 (Grundlinie 55) / 82 (Grundlinie 82) |
| rls-coverage / rls-access-inventory | gruen |
| `prisma migrate deploy` lokal (Container-IP 172.19.0.2) | Migration angewendet, `migrate diff` danach leer |
| Zugriffsklassifikation | Gate-Schleife 61/223/6 (vorher 61/224/6); `custom-modules` 0/6/0 |
## Testbenutzer fuer die Browser-Pruefung des Orchestrators
Es gab lokal schon die Nicht-Admin-Konten `nutzer1` und `nutzer2`, deren Passwoerter aber nicht bekannt sind. Deshalb habe ich per Admin-API angelegt: Login **testuser**, Passwort **Test1234!test** (Rolle USER, `mustChangePassword` auf false gesetzt, damit die Anmeldung nicht auf die Passwort-Seite umleitet). Der Administrator ist wie gehabt admin / admin123.
Vorschlag fuer die Browser-Pruefung (dunkel): als testuser unter Einstellungen > Allgemein > Eigene Module einen Eintrag anlegen (Seitenleiste zieht ohne Neuladen nach), als admin unter Verwaltung > Eigene Module einen gemeinsamen Eintrag anlegen (testuser sieht ihn in der Seitenleiste, kann ihn unter Einstellungen aber nicht bearbeiten), als admin pruefen, dass der persoenliche Eintrag von testuser weder in Seitenleiste noch Verwaltung erscheint. Danach die Testeintraege loeschen.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Festplatte voll (0 Byte frei) mitten in der Arbeit**
- **Found during:** Aufgabe 2 (Biome meldete „No space left on device“)
- **Issue:** die Docker-Build-Cache-Ablagen der Neubauten fuellten die Platte.
- **Fix:** `docker builder prune -f` (nur Build-Cache, 17,97 GB, keine Images, Container oder Volumes); danach type-check/lint/Tests frisch und vollstaendig wiederholt, alle gruen.
- **Commit:** kein Code betroffen.
**2. [Rule 1 - Bug] Detektor-Vorgaben fuer `forTenant`**
- **Found during:** Aufgabe 1 (rls-access-inventory schlug zweimal fehl)
- **Issue:** eine Ternary-Bindung (`shared ? forTenant(..) : forTenant(..)`) und ein `client.customModule.create` in einer Hilfsfunktion werden vom Detektor nicht als Zuweisungsform/Modellaufruf erkannt.
- **Fix:** je Zweig `const tenantPrisma = forTenant(...)` mit direktem Modellaufruf; Ausnahmeliste unveraendert leer.
- **Files modified:** `apps/api/src/custom-modules/custom-modules.service.ts`
- **Commit:** c703d87
**3. Plan-Feinheit:** Kein API-Parameter `scope`; die Verwaltung filtert im Web ueber `personal` (Plan liess beides zu, „das Einfachste“). Nebenwirkung: die Verwaltungsseite laedt auch die eigenen persoenlichen Eintraege des Administrators und blendet sie aus.
**4. Plan-Feinheit:** Formular-/Dialog-Texte aus `admin.customModules` in den neuen Namensraum `customModules.form` umgezogen (beide Aufrufer teilen sie); Admin-Test entsprechend angepasst. Die Anleitung des Anwenders hatte den Punkt „Eigene Module“ vorher nicht, er ist jetzt neu beschrieben (der Plan sprach von „aktualisieren“).
## Hinweise
- Zwischen c703d87 und ee97b4e liegt ein fremder Commit `bc4c011` (fix(web) Widgets nicht mehr zur Mitte versetzen), nicht von diesem Plan; er beruehrt CHANGELOG.md und `docs/anleitung-anwender.md` an anderen Stellen. Die 3 Commits dieses Plans sind c703d87, ee97b4e, 8f41bd2 (`git rev-list` ab dem Vorgaenger von c703d87 zaehlt 4 inklusive des fremden). Der Ledger nach Protokoll 0c wurde nicht vor dem ersten Commit angelegt, `plan_head_before` ist deshalb der Vorgaenger von c703d87.
- Die Verwaltungs-Nav zeigt weiterhin „Eigene Module“; sie fuehrt jetzt auf die gemeinsamen Eintraege, die Einleitung nennt das.
- Nichts gepusht.
## Known Stubs
Keine.
## Threat Flags
Keine neue Angriffsflaeche ausserhalb des bestehenden Modells: `ownerUserId` kommt nie aus dem Body (Whitelist, im Test belegt), `shared` ist per PATCH nicht setzbar, fremde persoenliche Eintraege sind ununterscheidbar 404.
## Self-Check: PASSED
- Dateien vorhanden: Migration `20260929130000_custom_module_owner`, `custom-module-manager.tsx`, `settings/custom-modules/page.tsx`, Settings-Test.
- Commits vorhanden: c703d87, ee97b4e, 8f41bd2 (`git log`); nichts gepusht (`git branch -r --contains HEAD` leer).
## Browser-Pruefung (Orchestrator, 29.09.)
- testuser: Einstellungen > Eigene Module vorhanden; „Meine Seite“ angelegt -> sofort in eigener Seitenleiste (Infrastruktur).
- admin: sieht „Meine Seite“ weder in Seitenleiste noch Verwaltung; Direktlink zeigt „Dieses Modul gibt es nicht mehr.“; Verwaltung heisst „Gemeinsamen Eintrag anlegen“.
- admin legt „Firmenseite“ (Sicherheit) an -> testuser sieht sie in der Seitenleiste, nicht in seinen Einstellungen; DELETE als testuser -> 403.
- Nebenbei: Widgets nicht mehr zentriert (bc4c011) — alle linken Kanten am Raster (272 px bei Rasterbeginn 260 + 12 Rand).
- Testeintraege geloescht, CustomModule leer.
@@ -0,0 +1,546 @@
---
phase: quick-260929-if2
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20260929140000_reminder/migration.sql
- apps/api/src/app.module.ts
- apps/api/src/reminders/reminders.module.ts
- apps/api/src/reminders/reminders.controller.ts
- apps/api/src/reminders/reminders.controller.spec.ts
- apps/api/src/reminders/reminders.service.ts
- apps/api/src/reminders/reminders.service.spec.ts
- apps/api/src/reminders/dto/reminder.dto.ts
- apps/api/src/reminders/reminder-mail.scheduler.ts
- apps/api/src/reminders/reminder-mail.scheduler.spec.ts
- apps/api/src/mail/mail.service.ts
- apps/api/src/mail/mail.service.spec.ts
- apps/api/src/prisma/rls-access-inventory.spec.ts
- packages/shared/src/index.ts
- apps/web/src/lib/reminders-api.ts
- apps/web/src/lib/reminders-api.test.ts
- apps/web/src/lib/reminder-notify.ts
- apps/web/src/lib/reminder-notify.test.ts
- apps/web/src/lib/reminder-time.ts
- apps/web/src/lib/reminder-time.test.ts
- apps/web/src/components/reminders/reminder-notifier.tsx
- apps/web/src/components/reminders/reminder-notifier.test.tsx
- apps/web/src/components/layout/app-shell.tsx
- apps/web/src/components/dashboard/widgets/reminder-widget.tsx
- apps/web/src/components/dashboard/widgets/reminder-widget.test.tsx
- apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx
- apps/web/src/components/dashboard/widget-registry.tsx
- apps/web/src/components/dashboard/widgets/widget-icon.tsx
- apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
- apps/web/src/app/(portal)/page.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/messages/umlaut-dictionary.ts
- apps/desktop/src-tauri/src/lib.rs
- docs/mandantentrennung-zugriffsklassifikation.md
- docs/anleitung-anwender.md
- CHANGELOG.md
autonomous: true
requirements: [QUICK-260929-if2]
estimate:
tokens: 260000
raw_tokens: 260000
tasks: 3
confidence: low
must_haves:
truths:
- "A user adds the dashboard widget 'Erinnerungen', creates a reminder with date, time, title and description in local time, and sees only their own open reminders, sorted by due time (D-05)"
- "At the due time (D-02, no advance warning) an open Tessera browser tab shows a Web Notification once permission was granted. Permission is asked only from the widget on the first creation, never on page load (D-04)"
- "At the due time the desktop app shows a native OS notification, also while the main window is hidden in the tray, through the notification plugin, which the page may call only from the stored server origin"
- "Every client shows each (reminder id, dueAt) at most once: tabs of one browser share the local claim, and the desktop app and the browser each notify once"
- "A due reminder stays in the widget, highlighted, with 'Erledigt' (removes it) and 'Später erinnern' (+10 min, +1 h, tomorrow at the same time). Snoozing sets a new dueAt, so notifications fire again and the e-mail is sent again when enabled (D-01, D-03)"
- "Upcoming reminders can be edited and deleted. Editing a due reminder is rejected with 409, snoozing a reminder that is not due yet is rejected with 409"
- "With 'zusätzlich per E-Mail' on, the server sends exactly one e-mail per due occurrence through the tenant SMTP config (time shown in Europe/Berlin), without any open client and also with several API instances (atomic claim). The toggle is disabled with an explanation when SMTP is not configured or the user has no e-mail"
- "A foreign reminder id always returns 404 (never 403). The Reminder table has a tenant+user RLS policy and a system read policy. rls-coverage and rls-access-inventory stay green, and the classification doc is re-measured"
- "All API and web tests are green, type-check and lint are green, Biome warnings stay at web <= 55 and api <= 82, and cargo test/fmt/clippy are green"
artifacts:
- path: "apps/api/prisma/migrations/20260929140000_reminder/migration.sql"
provides: "Reminder table, FK to User with cascade, indexes, RLS ENABLE+FORCE, tenant_isolation_policy with user dimension, system_read_policy FOR SELECT"
contains: "system_read_policy"
- path: "apps/api/src/reminders/reminders.service.ts"
provides: "Owner-scoped CRUD, snooze, email availability, all via forTenant(prisma, tenantId, userId)"
- path: "apps/api/src/reminders/reminders.controller.ts"
provides: "GET /reminders, GET /reminders/email-status, POST /reminders, PATCH /reminders/:id, POST /reminders/:id/snooze, DELETE /reminders/:id"
- path: "apps/api/src/reminders/reminder-mail.scheduler.ts"
provides: "30-second global tick, reads candidates through the system context, then claims and sends each one tenant-bound"
- path: "apps/web/src/lib/reminder-notify.ts"
provides: "Tauri-vs-browser notification helper, one-time permission request, local dedup claim with Web Locks"
- path: "apps/web/src/components/reminders/reminder-notifier.tsx"
provides: "Global notifier mounted in AppShell, polls and fires on due"
- path: "apps/web/src/components/dashboard/widgets/reminder-widget.tsx"
provides: "Erinnerungen widget: list, due highlight, create/edit modal, Erledigt, Später erinnern, e-mail toggle"
- path: "apps/desktop/src-tauri/src/lib.rs"
provides: "server_origin_pattern() (host escaped for URLPattern, self-checked with tauri::utils::acl::RemoteUrlPattern, None when it does not parse or match) + grant_server_notifications() runtime remote capability; server_origin_* unit tests pin the exact 3-permission set, the IPv6 and wildcard-host patterns and the match/no-match behavior"
key_links:
- from: "apps/web/src/components/layout/app-shell.tsx"
to: "apps/web/src/components/reminders/reminder-notifier.tsx"
via: "<ReminderNotifier /> next to <ReleaseNoticeHost />, so notifications fire on every portal page and not only when the widget is visible"
pattern: "ReminderNotifier"
- from: "apps/web/src/lib/reminder-notify.ts"
to: "tauri-plugin-notification"
via: "window.__TAURI_INTERNALS__.invoke('plugin:notification|notify', { options: { title, body } })"
pattern: "plugin:notification\\|notify"
- from: "apps/desktop/src-tauri/src/lib.rs"
to: "tauri runtime authority"
via: "app.add_capability(CapabilityBuilder::new(..).remote(<stored origin>).window(\"main\").permission(notification:*)) in setup() and save_server_url()"
pattern: "add_capability"
- from: "apps/api/src/reminders/reminder-mail.scheduler.ts"
to: "apps/api/src/mail/mail.service.ts"
via: "claim via tenant-bound updateMany(where emailSentAt null, same dueAt) -> sendReminderEmail -> release claim only on transport failure"
pattern: "sendReminderEmail"
- from: "apps/api/src/reminders/reminders.service.ts (snooze)"
to: "Reminder.emailSentAt / emailAttempts"
via: "snooze writes new dueAt AND resets emailSentAt=null, emailAttempts=0"
pattern: "emailAttempts: 0"
---
# Quick 260929-if2: Reminder widget "Erinnerungen" with notifications (desktop, browser, optional e-mail)
<objective>
Build a new dashboard widget called "Erinnerungen" (widget type key `reminder`). A user sets personal one-time reminders (date + time + title + description). At the due time Tessera notifies them: a native OS notification in the desktop app (also while the window is hidden in the tray), a Web Notification in the browser, and optionally one e-mail sent by the server. After the due time the reminder stays in the widget, highlighted, until the user clicks "Erledigt" or snoozes it with "Später erinnern".
Purpose: this is the first time-driven feature that reaches the user outside the dashboard, and it reuses the SMTP setup, the desktop notification plugin and the RLS pattern that already exist.
Output: Prisma model + migration with RLS, NestJS module `reminders` (CRUD, snooze, email status, mail scheduler), web widget + global notifier + helpers, a Tauri runtime capability, tests, re-measured classification doc, CHANGELOG entry and user guide entry.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
Pattern sources, read before the task that uses them:
@apps/api/src/custom-modules/custom-modules.service.ts
@apps/api/src/custom-modules/custom-modules.controller.ts
@apps/api/src/custom-modules/custom-modules.controller.spec.ts
@apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql
@apps/api/prisma/migrations/20260914120000_rls_system_context_read/migration.sql
@apps/api/src/tenders/tender-digest.scheduler.ts
@apps/api/src/mail/mail.service.ts
@apps/api/src/prisma/prisma-tenant.extension.ts
@apps/web/src/components/dashboard/widget-registry.tsx
@apps/web/src/components/layout/app-shell.tsx
@apps/web/src/lib/favorites-api.ts
@apps/web/src/components/custom-modules/custom-module-form-modal.tsx
@apps/web/src/components/dashboard/widgets/picture-frame-lightbox.tsx
@apps/desktop/src-tauri/src/lib.rs
</context>
## Decisions
Locked (from the user, must be implemented exactly; cited as D-NN in the tasks):
- **D-01** One-time reminders only. No recurrence field and no recurrence UI.
- **D-02** No advance warning. Notifications and the e-mail fire exactly at `dueAt`, never before.
- **D-03** After the due time the reminder stays in the widget, marked as due, with two actions. "Erledigt" removes it from the list. "Später erinnern" offers +10 min, +1 h and "morgen zur gleichen Uhrzeit"; each option sets a new `dueAt`, the notifications fire again, and the e-mail is sent again if it is enabled.
- **D-04** Browser notifications: yes. The permission is requested once, from the widget, on the first reminder creation (a user gesture), never on page load.
- **D-05** Reminders are personal. Only the owner sees and edits them, and a foreign id returns 404.
Chosen by the planner (Claude's discretion). Each choice is documented in code comments where it applies:
- **E-01 Desktop mechanism: the page-side notifier plus a runtime remote capability.** The global web notifier (it runs in the Tauri webview as well) calls the notification plugin through `window.__TAURI_INTERNALS__.invoke('plugin:notification|notify', …)`. The static `capabilities/default.json` has no `remote` block on purpose (T-JN2-01, see the doc comment on `get_server_url`), so Tauri rejects plugin calls from the server page. The Rust side therefore adds, at runtime, one capability bound to exactly the stored server origin (`scheme://host[:port]`), limited to window `main`, and granting only `notification:allow-notify`, `notification:allow-is-permission-granted` and `notification:allow-request-permission`. App commands such as `save_server_url` stay local-only. Tauri 2.11.3 has `dynamic-acl` in its default features, and `tauri::ipc::CapabilityBuilder::remote()` plus `Manager::add_capability()` exist. Two alternatives were rejected. A Rust-side poll of the API fails because Basic-Auth in front of alpha returns 401 to reqwest (the same problem the updater has) and because the session cookie lives only in the webview. The Web Notification API inside the webview is not an option either: the plugin's init script replaces `window.Notification` with a polyfill that makes the same IPC call. On Windows that polyfill also reports `permission = "denied"` on every page load until `requestPermission()` runs. So the helper never uses `Notification.permission` inside Tauri and calls invoke directly. Timers in a hidden webview are throttled by Chromium (at most once per minute after 5 minutes hidden), so a notification from the tray can arrive up to about 1 minute late. That is accepted.
- **E-02 "Erledigt" deletes the row.** No history UI was requested, and deleting avoids any retention question. The same `DELETE /reminders/:id` backs both "Löschen" (offered before due) and "Erledigt" (offered after due).
- **E-03 Catch-up window of 24 h.** When a client opens late, it still notifies once for reminders that became due within the last 24 h. Older due reminders are only shown, highlighted, in the widget. The e-mail scheduler also only picks reminders due within the last 24 h. That covers API restarts and downtime, and it keeps a late SMTP setup from sending mails about old reminders.
- **E-04 E-mail delivery semantics.** The scheduler claims before sending (`emailSentAt = now`, `emailAttempts + 1`, only where `emailSentAt IS NULL`, `dueAt` unchanged and `emailAttempts < 3`). It releases the claim (`emailSentAt = null`) only when the transport throws, so a failed send retries at most 3 times. When the tenant has no SmtpConfig or the user has no e-mail address at send time, the claim is kept: the occurrence counts as handled and is logged, with no send and no retry loop. A snooze resets both fields.
- **E-05 "Morgen zur gleichen Uhrzeit".** Computed on the client in local time: take the original `dueAt`, add one calendar day (`setDate(+1)`, which is DST-safe), and repeat until the result is in the future. Typical case: due today 14:00, snoozed at 14:05, new time tomorrow 14:00. +10 min and +1 h count from *now*, not from the old dueAt. The client sends the computed ISO `dueAt`, and the server only validates it.
- **E-06 Limits.** At most 100 reminders per user (create returns 409 above that). Title 1–200 characters, description 0–2000 characters. `dueAt` must be after *now* and at most 5 years ahead (both return 400).
- **E-07 E-mail language and time zone.** The mail is in German with the time formatted in `Europe/Berlin` (`de-DE`, `dateStyle: 'full'`, `timeStyle: 'short'`, followed by " Uhr"). No per-user locale is stored in `User`. The mail is text-only (no HTML), and CR/LF are stripped from the subject.
- **E-08 Scheduler tick.** One global 30-second interval registered through `SchedulerRegistry.addInterval` in `onApplicationBootstrap` (lifecycle choice as in `TenderSchedulerService`). It does not use the `require('cron')` + cast workaround, which would add a Biome warning. An in-process `running` flag skips overlapping ticks.
- **E-09 SMTP "configured"** means the tenant has a `SmtpConfig` row (`SettingsService.getSmtpConfig(tenantId) !== null`), the same rule as `TenderMailService`. The environment fallback of `MailService` does not count.
## Interfaces (contract the three tasks share)
API (all routes need authentication; `tenantId` comes from `req.tenantId` and the user from `@CurrentUser()`; never from the body):
| Route | Body | Result | Errors |
|---|---|---|---|
| `GET /reminders` | – | `Reminder[]` of the caller, `dueAt` ascending | – |
| `GET /reminders/email-status` (Task 3) | – | `{ smtpConfigured: boolean, hasEmail: boolean }` | – |
| `POST /reminders` | `{ title, description?, dueAt (ISO 8601), emailEnabled? (Task 3) }` | `Reminder` | 400 invalid/past/>5 y, 400 emailEnabled while unavailable, 409 limit |
| `PATCH /reminders/:id` (Task 2) | partial create body | `Reminder` | 404 foreign/unknown, 409 already due, 400 as above |
| `POST /reminders/:id/snooze` (Task 2) | `{ dueAt }` | `Reminder` | 404, 409 not due yet, 400 past/>5 y |
| `DELETE /reminders/:id` (Task 2) | – | `{ deleted: true }` | 404 |
`Reminder` response = exactly `{ id, title, description, dueAt, emailEnabled, createdAt, updatedAt }` through a `REMINDER_SELECT` constant (pattern `CUSTOM_MODULE_SELECT`). `tenantId`, `userId`, `emailSentAt` and `emailAttempts` never leave the service.
Prisma model `Reminder`: `id String @id @default(uuid())`, `tenantId String`, `userId String`, `user User @relation(fields: [userId], references: [id], onDelete: Cascade)`, `title String`, `description String @default("")`, `dueAt DateTime`, `emailEnabled Boolean @default(false)`, `emailSentAt DateTime?`, `emailAttempts Int @default(0)`, `createdAt DateTime @default(now())`, `updatedAt DateTime @updatedAt`, `@@index([tenantId, userId, dueAt])`, `@@index([dueAt])`. `User` gets `reminders Reminder[]`. There is no `doneAt` column (E-02) and no recurrence column (D-01).
Web: `apps/web/src/lib/reminders-api.ts` exports the type `Reminder` (dates as ISO strings), `ReminderRequestError` (carries `status: number`), `listReminders()`, `createReminder(input)`, `updateReminder(id, patch)`, `snoozeReminder(id, dueAt)`, `deleteReminder(id)`, `getReminderEmailStatus()`. It follows the `favorites-api.ts` pattern: `NEXT_PUBLIC_API_URL`, `credentials: 'include'`, and a non-2xx status throws `ReminderRequestError(status)`. After every successful mutation the widget dispatches `window.dispatchEvent(new Event('tessera:reminders-changed'))` (constant `REMINDERS_CHANGED_EVENT`, exported from `reminders-api.ts`).
## Execution segments (context budget)
The plan-level estimate (260k tokens raw, calibration factor 1 with 0 samples, confidence low) is above `workflow.smart_zone_tokens` (100k, measured with `config-get`). Quick mode runs exactly one `260929-if2-PLAN.md` per task directory, so this plan is not split into separate plan files. The three task commits are the cut points instead, and each segment is sized on its own:
| Segment | Ends with commit subject | Raw projection |
|---|---|---|
| Task 1 (tracer) | `feat(260929-if2): Erinnerungen anlegen und zur Faelligkeit benachrichtigen (Tracer)` | ~100k |
| Task 2 | `feat(260929-if2): faellige Erinnerungen erledigen, spaeter erinnern, bearbeiten und loeschen` | ~65k |
| Task 3 | `feat(260929-if2): Erinnerung zusaetzlich per E-Mail, Doku und Aenderungsliste` | ~95k |
Rules for the executor:
- **Resume rule.** Before the first task, run `git log --format=%s -n 50 --grep='^feat(260929-if2): '` on the current branch. Start at the first task whose commit subject is missing. For every task that is already committed, re-run only its vitest and cargo `<automated>` commands (not the curl end-to-end command, which creates data) before continuing. Nothing is carried over from an earlier conversation: each task's `<read_first>` names everything it needs, and the committed code is the handoff.
- **Stop rule.** Stop only directly after a task commit, never in the middle of a task. When the context is past roughly half of the budget after a commit, do not start the next task: write `260929-if2-SUMMARY.md` with `status: halted`, the commit hashes and the measured gate results so far, plus the line "Fortsetzen bei Task N", and return. A new dispatch of the same plan continues through the resume rule and finally rewrites the SUMMARY with `status: complete`.
<tasks>
<task type="tracer">
<name>Task 1 (tracer): create a reminder, see it in the widget, get notified at due time (browser and desktop)</name>
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260929140000_reminder/migration.sql, apps/api/src/reminders/reminders.module.ts, apps/api/src/reminders/reminders.controller.ts, apps/api/src/reminders/reminders.controller.spec.ts, apps/api/src/reminders/reminders.service.ts, apps/api/src/reminders/reminders.service.spec.ts, apps/api/src/reminders/dto/reminder.dto.ts, apps/api/src/app.module.ts, packages/shared/src/index.ts, apps/web/src/lib/reminders-api.ts, apps/web/src/lib/reminders-api.test.ts, apps/web/src/lib/reminder-notify.ts, apps/web/src/lib/reminder-notify.test.ts, apps/web/src/components/reminders/reminder-notifier.tsx, apps/web/src/components/reminders/reminder-notifier.test.tsx, apps/web/src/components/layout/app-shell.tsx, apps/web/src/components/dashboard/widgets/reminder-widget.tsx, apps/web/src/components/dashboard/widgets/reminder-widget.test.tsx, apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx, apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/dashboard/widgets/widget-icon.tsx, apps/web/src/components/dashboard/widgets/widget-wrapper.tsx, apps/web/src/app/(portal)/page.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/desktop/src-tauri/src/lib.rs, docs/mandantentrennung-zugriffsklassifikation.md</files>
<read_first>apps/api/src/custom-modules/custom-modules.service.ts, apps/api/src/custom-modules/custom-modules.controller.spec.ts, apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql, apps/api/prisma/migrations/20260929130000_custom_module_owner/migration.sql (header comment style), apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx (mock style for next-intl and the api module), apps/web/src/components/custom-modules/custom-module-form-modal.tsx, apps/web/src/components/dashboard/widgets/picture-frame-lightbox.tsx (createPortal into document.body), apps/desktop/src-tauri/src/lib.rs lines 630-810 (commands, setup, window builder), docs/mandantentrennung-zugriffsklassifikation.md sections "Übersicht je Bereich" and "Bestandsaufnahme"</read_first>
<action>
Build ONE thin path through every layer: DB, API, web widget, global notifier, desktop Rust. Only create and list are in this task. Due highlight, edit, delete, snooze and e-mail come later, but the schema and the migration are final now because an applied migration can no longer be changed.
1. DB (D-05). Add the `Reminder` model exactly as in "Interfaces" and `reminders Reminder[]` on `User` in `apps/api/prisma/schema.prisma`, with a German comment above the model ("quick-260929-if2: persoenliche Erinnerungen, einmalig (D-01)…"). Write the migration `apps/api/prisma/migrations/20260929140000_reminder/migration.sql` by hand:
- Start with the mandatory German header comment in the style of `20260929130000_custom_module_owner`: purpose, owner semantics, both policies, the note that app-role grants come through ALTER DEFAULT PRIVILEGES, and the "switch is OFF" note.
- Generate the CREATE TABLE, index and FK statements with `prisma migrate diff --from-url <local db url> --to-schema-datamodel prisma/schema.prisma --script`, so that the names match Prisma (`Reminder_pkey`, `Reminder_tenantId_userId_dueAt_idx`, `Reminder_dueAt_idx`, `Reminder_userId_fkey` with ON DELETE CASCADE).
- Then add `ENABLE` and `FORCE ROW LEVEL SECURITY`, plus `tenant_isolation_policy` in the user-dimension form of DashboardImage (`"tenantId" = current_tenant_id() AND (current_user_id() IS NULL OR "userId" = current_user_id())`).
- Also add `CREATE POLICY system_read_policy ON "Reminder" FOR SELECT USING (is_system_context());`. The comment must say it serves the e-mail scheduler's candidate query (Task 3) and that it is read-only, as in migration 20260914120000.
- Run `pnpm --filter @tessera/api exec prisma generate`.
2. API module `apps/api/src/reminders/`, registered in `apps/api/src/app.module.ts`:
- `dto/reminder.dto.ts`: `CreateReminderDto` with `title` (`@IsString @IsNotEmpty @MaxLength(200)`, trimmed via `@Transform`), `description` (`@IsOptional @IsString @MaxLength(2000)`) and `dueAt` (`@IsISO8601({ strict: true })`). Do not add `emailEnabled` yet (Task 3).
- `reminders.service.ts`: `list(tenantId, userId)` and `create(tenantId, userId, dto)`.
- Every method uses its own `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`. Always use that assignment form and always the name `tenantPrisma`, because `rls-access-inventory.spec.ts` and the doc's gate loop detect it by exactly that form.
- Every `where` also carries `tenantId` and `userId`, as an app-level check while the RLS switch is off.
- `list` returns the caller's rows ordered by `dueAt` ascending through `REMINDER_SELECT`, with `dueAt` serialized as ISO.
- `create` rejects a `dueAt` that is not after now or more than 5 years ahead with `BadRequestException`, and returns 409 `ConflictException` once the user already has 100 rows (E-06). It sets `tenantId` and `userId` from the arguments only.
- Add a German class comment explaining ownership (404, never 403, D-05) and the RLS binding.
- `reminders.controller.ts` at path `reminders`. Build `requireTenantId` as in `CustomModulesController`, with `@Get()` list and `@Post()` create. Put a German ROUTE-ORDER comment at the top: every static GET route (Task 3 adds `email-status`) must stand above any `:id` route.
- `reminders.module.ts`: controller + service (PrismaModule is global).
- Specs:
- `reminders.service.spec.ts`: list is scoped to tenant+user and sorted; create stores the ids from the arguments, not from the body; past dueAt gives 400; more than 5 years gives 400; the 101st reminder gives 409.
- `reminders.controller.spec.ts`: tenantId is passed through; ForbiddenException without tenantId; the global ValidationPipe (whitelist) strips `tenantId`/`userId` from the body; there is a route-order assertion like in the custom-modules spec.
3. Shared type: append `'reminder'` at the end of `WIDGET_TYPES` in `packages/shared/src/index.ts`. It is a platform widget, so there is no entry in `WIDGET_MODULE_SLUGS`.
4. Web data and notify helpers.
- `apps/web/src/lib/reminders-api.ts`: the type `Reminder`, `ReminderRequestError`, `REMINDERS_CHANGED_EVENT`, `listReminders` and `createReminder`, as specified in "Interfaces". Test file `reminders-api.test.ts`: URLs, `credentials: 'include'`, a non-2xx status throws with that status.
- `apps/web/src/lib/reminder-notify.ts`, pure functions without React:
- `isTauriWebview()`: true when `window.__TAURI_INTERNALS__` has an `invoke` function. Narrow through `unknown`; no `any` and no non-null assertions, because of the Biome baseline.
- `requestBrowserPermissionOnce()`: does nothing inside Tauri, when `Notification` is missing, when the permission is not `'default'`, or when the localStorage flag `tessera.reminders.permissionAsked` is already set. Otherwise it sets the flag and calls `Notification.requestPermission()` (D-04).
- `browserPermissionState()`: returns `'desktop' | 'granted' | 'default' | 'denied' | 'unsupported'`.
- `showReminderNotification({ title, body, tag })`: inside Tauri it calls invoke `plugin:notification|notify` with `{ options: { title, body } }` and catches errors with a single `console.warn('[reminders] …')`. Outside Tauri it creates `new Notification(title, { body, tag })` only when the permission is `'granted'`, inside try/catch.
- `claimNotification(key, nowMs)`: a localStorage record `tessera.reminders.notified` mapping key to ms. It returns true only on the first claim of a key and prunes entries older than 7 days.
- `withNotifyLock(fn)`: runs `fn` under `navigator.locks.request('tessera-reminder-notify', …)` when available, otherwise calls it directly.
- `remindersToNotify(reminders, nowMs)`: returns the reminders with `dueAt <= now` and `dueAt > now - 24 h` (E-03, D-02: never before dueAt).
- Comment in German why Tauri never uses `Notification.permission` (the polyfill reports "denied" on Windows until `requestPermission`, see E-01).
- `reminder-notify.test.ts` covers: dedup (same key twice gives true, then false; the key `${id}|${dueAt}` changes after a snooze), pruning, the Tauri branch calls invoke with the exact command and payload, the browser branch only with `'granted'`, the permission is asked at most once and never in Tauri, and the 24 h window.
5. Global notifier `apps/web/src/components/reminders/reminder-notifier.tsx` (`'use client'`, renders null). It is mounted in `apps/web/src/components/layout/app-shell.tsx` right after `<ReleaseNoticeHost />`, with a comment that it is global so notifications fire on every portal page, not only when the widget is visible.
- It loads `listReminders()` on mount, every 60 s, on the `REMINDERS_CHANGED_EVENT`, on `visibilitychange` to visible, and on window `focus`.
- A local 10-second tick against the cached list runs `withNotifyLock` → `claimNotification('${id}|${dueAt}')` → `showReminderNotification`.
- Notification title: `widgets.reminder.notificationTitle` ("Erinnerung: {title}"). Body: the description, cut to 200 characters, or the due time formatted locally when the description is empty. Tag: `reminder-${id}-${dueAt}`.
- On `ReminderRequestError` with status 401 it stops polling until the next focus. Other errors are ignored silently until the next tick.
- `reminder-notifier.test.tsx` uses fake timers and a mocked api module: a due reminder gives exactly one notification across several ticks; a reminder that is not due yet gives none; after `dueAt` changes it notifies again; the change event triggers a refetch.
6. Widget, minimal:
- `apps/web/src/components/dashboard/widgets/reminder-widget.tsx` (`WidgetProps`) lists the user's reminders (title, due time via `Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' })`, description clamped to 2 lines) and shows an empty state. A button "Neue Erinnerung" opens `reminder-form-modal.tsx`.
- The modal is rendered with `createPortal` into `document.body`, because react-grid-layout transforms would break `position: fixed` (precedent: picture-frame-lightbox). It follows the dialog markup of custom-module-form-modal (`role="dialog"`, `aria-modal`, Escape closes). Fields: date (`type="date"`), time (`type="time"`), title, description. The defaults are today and the next full hour.
- Local inputs become ISO via `new Date(`${date}T${time}`)` → `toISOString()` in a small exported function (Task 2 moves it into `reminder-time.ts`). The client check "must be in the future" mirrors the server rule.
- On submit, call `requestBrowserPermissionOnce()` synchronously first (a user gesture, D-04), then `createReminder`, then refetch and dispatch `REMINDERS_CHANGED_EVENT`.
- Registration:
- `apps/web/src/components/dashboard/widget-registry.tsx`: `WIDGET_CONSTRAINTS.reminder = { minW: 8, minH: 4, defaultW: 12, defaultH: 10 }` with a German comment giving the reason in 48-column units (like the note/favorites widgets: list plus button row; 8 columns ≈ 230 px is the smallest usable width). Add a `ReminderIcon` (bell) and the registry entry `nameKey: 'reminder.name'` / `descriptionKey: 'reminder.description'`.
- `apps/web/src/components/dashboard/widgets/widget-icon.tsx`: bell path under `reminder`.
- `apps/web/src/components/dashboard/widgets/widget-wrapper.tsx`: add `'reminder'` to `FRAME_HEADER_TYPES` (the unified header supplies icon + name and the hide-title toggle).
- `apps/web/src/app/(portal)/page.tsx`: `registerWidget('reminder', ReminderWidget)`.
- Texts under `widgets.reminder` in `apps/web/src/messages/de.json` and `en.json`: German uses "Sie" and real umlauts; English mirrors the keys. Keys for this task: name "Erinnerungen", description "Termine und Aufgaben mit Benachrichtigung zur gewünschten Zeit", add, empty, dateLabel, timeLabel, titleLabel, descriptionLabel, save, cancel, pastError, saveError, loadError, limitReached, notificationTitle.
- `reminder-widget.test.tsx`: the list renders sorted; create calls `createReminder` with the ISO built from the local inputs; rendering does NOT call `Notification.requestPermission`; the first create calls it once; a second create does not call it again.
7. Desktop (E-01) in `apps/desktop/src-tauri/src/lib.rs`. Facts measured during planning, which the code must respect: in tauri 2.11.3 `add_capability` runs `Resolved::resolve(..).unwrap()` while it holds the runtime-authority mutex (`src/ipc/authority.rs`, `src/lib.rs`), and tauri-utils 2.9.3 `resolve_command` panics with "invalid URL pattern for remote URL" on a pattern it cannot parse. So an unparsable pattern or an unknown permission does NOT come back as `Err`; it crashes `setup()`, and a `catch_unwind` around it would leave a poisoned mutex that breaks every later IPC call. The only safe guard is to validate the inputs before the call. Do not use `catch_unwind`.
- Add `fn server_origin_pattern(url: &str) -> Option<String>`:
- Reuse `parse_server_url` (http and https only) and take `host_str()`.
- Put a backslash in front of every host character outside ASCII `A-Z`, `a-z`, `0-9`, `.` and `-` (URLPattern escaping). Why: the url crate accepts `http://*.example.com/` and returns the host `*.example.com`, which unescaped would become a wildcard pattern for every subdomain. IPv6 hosts come back in brackets (`[::1]`), and the URLPattern tokenizer (urlpattern 0.3.0) rejects `http://[::1]:8080` with `Tokenizer(InvalidName, 1)`, while the escaped form `http://\[\:\:1\]:8080` parses and matches only `[::1]:8080`. Both were measured.
- Append `:port` only when the port is explicit and not the default. Path, query and fragment are dropped.
- Self-check before returning: parse the pattern with `tauri::utils::acl::RemoteUrlPattern` (its `FromStr` is the parser Tauri uses for `remote.urls`) and require `.test(&parsed_url)` to be true. Return `None` otherwise.
- Add `const SERVER_NOTIFICATION_PERMISSIONS: [&str; 3]` with exactly `notification:allow-notify`, `notification:allow-is-permission-granted` and `notification:allow-request-permission`. These identifiers exist in tauri-plugin-notification 2.3.3 (`permissions/autogenerated/commands/notify.toml`, `is_permission_granted.toml`, `request_permission.toml`). An unknown identifier would panic inside `add_capability` as well, which is one reason the exact-set test below exists.
- Add `fn grant_server_notifications(app: &AppHandle, url: &str)`:
- When `server_origin_pattern` returns `None`, write one `eprintln!` and add no capability. Desktop toasts are then off for that address, while the browser notifications and the e-mail keep working.
- Otherwise build `tauri::ipc::CapabilityBuilder::new("server-notifications").remote(pattern).local(false).window("main")` plus each permission, call `app.add_capability(...)`, and on `Err` write one `eprintln!`. It must never fail startup.
- Call it in `setup()` once the stored server URL is known, before the first `navigate`, and in `save_server_url` right after the store is saved, before `navigate`.
- Doc comment in German: why a runtime capability and not the static `default.json`; exactly the stored origin, escaped and self-checked, never a wildcard; why the self-check is required (panic inside `add_capability`, see above); only notification permissions, no app commands; T-JN2-01 remains in force for `get_server_url` and the other commands; after a server change the old origin keeps notification rights until the app restarts (accepted, T-IF2-03). Also explain the rejected alternatives: the Rust poll (Basic-Auth 401 as with the updater, the session cookie lives in the webview) and the native Web Notification (plugin polyfill).
- Unit tests in `mod tests`. Every new test name starts with `server_origin_` followed by a German description, like the existing `server_host_*` tests, so that the verify command can count them. There are at least 10:
- `https://alpha.tessera.ctl.de/dashboard?x=1#h` gives exactly `https://alpha.tessera.ctl.de` (path, query and fragment removed).
- `http://192.168.13.12:8080/` gives exactly `http://192.168.13.12:8080`.
- `https://alpha.tessera.ctl.de:443/` gives exactly `https://alpha.tessera.ctl.de` (the default port is omitted).
- With and without a trailing slash, the result is the same.
- `ftp://…` gives `None`, and unparsable input gives `None`.
- IPv6: `http://[::1]:8080/` gives exactly the raw string `r"http://\[\:\:1\]:8080"`.
- Wildcard host: `http://*.example.com/` gives exactly `r"http://\*.example.com"`, with no unescaped `*`.
- Match behavior through `tauri::utils::acl::RemoteUrlPattern`: every pattern above parses. It matches its own origin with a different path and query. It does NOT match the other scheme, another port, the subdomain `x.alpha.tessera.ctl.de`, or a different host. The wildcard pattern does not match `http://a.example.com/`. The IPv6 pattern matches `http://[::1]:8080/dashboard` but not `http://[::2]:8080/` and not `http://[::1]/`.
- Exact permission set: `assert_eq!` of `SERVER_NOTIFICATION_PERMISSIONS` against the three identifiers above, in that order. This pins the set (T-IF2-03), so an added `notification:default` or a wildcard permission fails the test.
- Run `cargo fmt`.
8. RLS docs (the inventory spec enforces this now): add rows to the "Bestandsaufnahme" table of `docs/mandantentrennung-zugriffsklassifikation.md` for `apps/api/src/reminders/reminders.service.ts` / `reminder` (class `muss-mandantengebunden`, stand `gebunden`), with a reason that names quick-260929-if2, the user dimension and 404. Add an area row `reminders` in "Übersicht je Bereich", update the "Summe" row and the pair count in "Klassen-Verteilung". Re-measure these with the gate loop the doc describes (per area: `this.prisma.` / `tenantPrisma.` / `systemPrisma.` raw hits, .ts without spec). Write down measured numbers, not copied ones.
9. Migrate locally and rebuild:
- Read the DB container IP with `docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1` (currently 172.19.0.2).
- Run `DATABASE_URL=postgresql://tessera:tessera_dev@$DB_IP:5432/tessera pnpm --filter @tessera/api exec prisma migrate deploy`, then `migrate diff --exit-code` must be empty.
- Run `docker compose up -d --build web api`. If the disk fills up, run `docker builder prune -f` (only the build cache).
- The curl end-to-end command in `<verify>` creates one "Tracer-Test" reminder for testuser each time it runs and leaves it in place. Task 2 deletes every row with that title.
- Commit locally: `feat(260929-if2): Erinnerungen anlegen und zur Faelligkeit benachrichtigen (Tracer)`, message ending with the Co-Authored-By line. NEVER push.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/reminders src/prisma/rls-coverage.spec.ts src/prisma/rls-access-inventory.spec.ts src/dashboard/widget-module-map.spec.ts</automated>
<fails_when>non-zero exit, a non-zero "failed" count in the "Test Files" or "Tests" summary line, or "No test files found"</fails_when>
<automated>pnpm --filter @tessera/web exec vitest run src/lib/reminder-notify.test.ts src/lib/reminders-api.test.ts src/components/reminders src/components/dashboard src/messages</automated>
<fails_when>non-zero exit, a non-zero "failed" count in the "Test Files" or "Tests" summary line, or "No test files found"</fails_when>
<automated>cd /home/vicolab/projects/tessera-ctl && cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib && cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib server_origin_ 2>&1 | grep -E 'test result: ok\. [1-9][0-9]+ passed' && cargo fmt --manifest-path apps/desktop/src-tauri/Cargo.toml --check && cargo clippy --manifest-path apps/desktop/src-tauri/Cargo.toml -- -D warnings</automated>
<fails_when>non-zero exit: "test result: FAILED" in the full run, no "test result: ok. N passed" line with N of at least 10 under the server_origin_ filter (grep prints nothing), a diff printed by cargo fmt --check, or an "error:" line from clippy</fails_when>
<automated>DB_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1) && cd /home/vicolab/projects/tessera-ctl/apps/api && DATABASE_URL=postgresql://tessera:tessera_dev@$DB_IP:5432/tessera pnpm exec prisma migrate diff --from-url postgresql://tessera:tessera_dev@$DB_IP:5432/tessera --to-schema-datamodel prisma/schema.prisma --exit-code</automated>
<fails_when>non-zero exit (2 when the database and schema.prisma differ, printing diff statements instead of "No difference detected."; 1 on a Prisma error such as an unreachable database)</fails_when>
<automated>T=$(mktemp) && A=$(mktemp) && curl -sf -c "$T" -H 'Content-Type: application/json' -d '{"username":"testuser","password":"Test1234!test"}' http://localhost:3001/auth/login >/dev/null && curl -sf -c "$A" -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}' http://localhost:3001/auth/login >/dev/null && DUE=$(date -u -d '+2 minutes' +%Y-%m-%dT%H:%M:00.000Z) && curl -sf -b "$T" -H 'Content-Type: application/json' -d "{\"title\":\"Tracer-Test\",\"dueAt\":\"$DUE\"}" http://localhost:3001/reminders | grep -q '"id"' && curl -sf -b "$T" http://localhost:3001/reminders | grep -q 'Tracer-Test' && ADM=$(curl -sf -b "$A" http://localhost:3001/reminders) && echo "$ADM" | grep -q '^\[' && ! echo "$ADM" | grep -q 'Tracer-Test' && echo "tracer e2e ok"</automated>
<fails_when>non-zero exit and no "tracer e2e ok" line: a login, the POST or a GET answered with a non-2xx status (curl -f), the POST response has no "id", testuser's list lacks "Tracer-Test", admin's answer is not a JSON array, or admin's list contains "Tracer-Test"</fails_when>
</verify>
<done>
- The migration is applied locally and `migrate diff` is empty.
- POST+GET work for testuser, and admin does not see testuser's reminder (D-05).
- The widget is in the catalog and lists and creates reminders; the permission is asked only on the first create (D-04).
- The notifier fires once per (id, dueAt) at or after dueAt, never before (D-02).
- The Tauri branch invokes `plugin:notification|notify`; lib.rs grants the runtime capability for the stored origin only (E-01). The origin pattern is escaped and self-checked, so an IPv6 or wildcard-looking host can neither crash startup nor widen the grant. At least 10 `server_origin_*` tests pass, including the exact 3-permission set, and cargo test/fmt/clippy are green.
- rls-coverage and rls-access-inventory are green, and the doc is re-measured.
- Committed locally, not pushed.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: due state, "Erledigt", "Später erinnern", edit and delete before due</name>
<files>apps/api/src/reminders/reminders.controller.ts, apps/api/src/reminders/reminders.controller.spec.ts, apps/api/src/reminders/reminders.service.ts, apps/api/src/reminders/reminders.service.spec.ts, apps/api/src/reminders/dto/reminder.dto.ts, apps/web/src/lib/reminders-api.ts, apps/web/src/lib/reminders-api.test.ts, apps/web/src/lib/reminder-time.ts, apps/web/src/lib/reminder-time.test.ts, apps/web/src/components/dashboard/widgets/reminder-widget.tsx, apps/web/src/components/dashboard/widgets/reminder-widget.test.tsx, apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, docs/mandantentrennung-zugriffsklassifikation.md</files>
<read_first>apps/api/src/reminders/reminders.service.ts (from Task 1), apps/api/src/custom-modules/custom-modules.service.ts (loadVisible → 404 pattern), apps/web/src/components/dashboard/widgets/reminder-widget.tsx (from Task 1), apps/web/src/app/globals.css (tokens --color-status-warn / --color-status-warn-fg), apps/web/src/messages/umlaut-guard.spec.ts</read_first>
<behavior>
- Service: PATCH on a foreign or unknown id gives 404; PATCH on a due reminder (dueAt <= now) gives 409; PATCH with a past dueAt gives 400; a valid PATCH changes only the given fields.
- Service: snooze on a reminder that is not due yet gives 409; snooze with a past dueAt gives 400; a valid snooze writes the new dueAt AND emailSentAt=null AND emailAttempts=0; snooze on a foreign id gives 404.
- Service: DELETE on a foreign id gives 404, on the own id it deletes and returns { deleted: true } (serves both "Löschen" and "Erledigt", E-02).
- reminder-time: snoozeTarget('10m') = now+10 min, '1h' = now+1 h, 'tomorrow' = original local time on the next calendar day, repeated until it is in the future (E-05); localInputsToIso/isoToLocalInputs round-trip.
- Widget: a due row gets a highlight + "Fällig" badge + "Erledigt" + "Später erinnern" (three options), without edit/delete; an upcoming row gets edit + delete, without Erledigt/Später; "Erledigt" calls deleteReminder and removes the row; each snooze option calls snoozeReminder with the dueAt from snoozeTarget; a row becomes due through the local 10 s tick without reloading.
</behavior>
<action>
Expand the tracer so the full lifecycle of D-01/D-03 works end to end.
1. API in `apps/api/src/reminders/`:
- `UpdateReminderDto = PartialType(CreateReminderDto)` and `SnoozeReminderDto { dueAt: IsISO8601 strict }` in `dto/reminder.dto.ts`.
- In the service, a private `loadOwn(tenantPrisma, tenantId, userId, id)` returns the row or throws `NotFoundException` for unknown, foreign-tenant and foreign-user rows alike (D-05, never 403).
- `update`: 409 `ConflictException` when `row.dueAt <= now` ("Die Erinnerung ist bereits fällig"). A new `dueAt` passes the same future/5-year check as `create`, via a shared private `assertValidDueAt`.
- `snooze`: 409 when `row.dueAt > now` (not due yet); validates `dueAt`; writes `{ dueAt, emailSentAt: null, emailAttempts: 0 }` (D-03, so the e-mail fires again).
- `remove`: deletes the row (E-02).
- All of them use the `const tenantPrisma = forTenant(this.prisma, tenantId, userId)` assignment form, and the `where` clauses carry `tenantId` and `userId`.
- Controller: `@Patch(':id')`, `@Post(':id/snooze')`, `@Delete(':id')`, all below the static routes.
- Extend both specs with the behaviors above, and extend the route-order spec.
2. Web helpers:
- `apps/web/src/lib/reminder-time.ts` holds pure functions: `snoozeTarget(preset: '10m' | '1h' | 'tomorrow', originalDueAt: Date, now: Date): Date` per E-05, `localInputsToIso(date, time): string | null` (moved here from the Task 1 widget), `isoToLocalInputs(iso): { date, time }`, `defaultNewReminderInputs(now)` (the next full hour).
- `apps/web/src/lib/reminder-time.test.ts` has the cases from `<behavior>`, including one across the end of a month.
- Extend `apps/web/src/lib/reminders-api.ts` with `updateReminder`, `snoozeReminder` and `deleteReminder`, plus tests.
3. Widget `apps/web/src/components/dashboard/widgets/reminder-widget.tsx`:
- `now` state refreshed every 10 s decides due vs. upcoming. The sort stays `dueAt` ascending.
- Due rows (D-03): border/background from the status-warn token (`border-status-warn`, `bg-status-warn/10`, readable in dark mode) and a "Fällig" badge (`bg-status-warn text-status-warn-fg`). Buttons "Erledigt" (calls `deleteReminder`) and "Später erinnern", which toggles an inline option row: "In 10 Minuten", "In 1 Stunde", "Morgen um {time}", where `{time}` is the original local time. It calls `snoozeReminder(id, snoozeTarget(...).toISOString())`.
- Upcoming rows: edit (pencil) opens `reminder-form-modal.tsx` prefilled through `isoToLocalInputs` and saves with `updateReminder`. Delete (trash) uses an inline two-step confirm ("Löschen?" Ja/Nein).
- On 409 the widget shows the matching text (edit → alreadyDue, snooze → notDue, create → limitReached) and refetches.
- After every successful mutation: refetch + dispatch `REMINDERS_CHANGED_EVENT`, so the global notifier picks up a new dueAt immediately.
- Buttons are compact and allowed to wrap at minW 8.
- Browser hint: when `browserPermissionState()` is `'denied'`, show a muted line saying that the browser blocks notifications and that due reminders then only appear here. Show nothing in Tauri.
4. New texts under `widgets.reminder` in de/en: due, done, snooze, snooze10m, snooze1h, snoozeTomorrow (with `{time}`), edit, delete, deleteConfirm, yes, no, alreadyDue, notDue, permissionDenied. Run `umlaut-guard.spec.ts`. Extend `apps/web/src/messages/umlaut-dictionary.ts` only if the guard flags a correct German token.
5. Re-measure the `reminders` area row and the "Summe" row in `docs/mandantentrennung-zugriffsklassifikation.md` with the gate loop, since the service has new bound raw hits.
6. Rebuild with `docker compose up -d --build web api`. Then, as testuser: DELETE every "Tracer-Test" row from Task 1 (the tracer verify may have run more than once), so GET no longer lists that title. Check that admin gets 404 for PATCH, snooze and DELETE on a testuser id. Commit `feat(260929-if2): faellige Erinnerungen erledigen, spaeter erinnern, bearbeiten und loeschen` (Co-Authored-By line; NEVER push).
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/reminders src/prisma/rls-access-inventory.spec.ts</automated>
<fails_when>non-zero exit, a non-zero "failed" count in the "Test Files" or "Tests" summary line, or "No test files found"</fails_when>
<automated>pnpm --filter @tessera/web exec vitest run src/lib/reminder-time.test.ts src/lib/reminders-api.test.ts src/components/dashboard/widgets/reminder-widget.test.tsx src/components/reminders src/messages</automated>
<fails_when>non-zero exit, a non-zero "failed" count in the "Test Files" or "Tests" summary line, or "No test files found"</fails_when>
</verify>
<done>
- API: foreign ids give 404 on PATCH, snooze and DELETE; editing a due reminder gives 409; snoozing a reminder that is not due gives 409; snooze resets emailSentAt and emailAttempts.
- Widget: due rows are highlighted with Erledigt and the three snooze options (D-03); upcoming rows are editable and deletable.
- The Tracer-Test row is removed; the doc is re-measured; committed locally.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: optional e-mail at due time (exactly once), toggle in the widget, docs, full gates, manual check list</name>
<files>apps/api/src/reminders/reminder-mail.scheduler.ts, apps/api/src/reminders/reminder-mail.scheduler.spec.ts, apps/api/src/reminders/reminders.service.ts, apps/api/src/reminders/reminders.service.spec.ts, apps/api/src/reminders/reminders.controller.ts, apps/api/src/reminders/reminders.controller.spec.ts, apps/api/src/reminders/reminders.module.ts, apps/api/src/reminders/dto/reminder.dto.ts, apps/api/src/mail/mail.service.ts, apps/api/src/mail/mail.service.spec.ts, apps/api/src/prisma/rls-access-inventory.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/reminders-api.ts, apps/web/src/lib/reminders-api.test.ts, apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx, apps/web/src/components/dashboard/widgets/reminder-widget.tsx, apps/web/src/components/dashboard/widgets/reminder-widget.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, CHANGELOG.md, docs/anleitung-anwender.md</files>
<read_first>apps/api/src/tenders/tender-digest.scheduler.ts (the forSystem candidates → forTenant loop, per-candidate try/catch), apps/api/src/tenders/tender-digest.scheduler.spec.ts (how forSystem/forTenant are mocked), apps/api/src/tenders/tender-scheduler.service.ts (onApplicationBootstrap), apps/api/src/mail/mail.service.ts (deliver, sendBugReport), apps/api/src/settings/settings.service.ts (getSmtpConfig), apps/api/src/prisma/rls-access-inventory.spec.ts lines 130-192 (FORSYSTEM_ALLOWED_CALL_SITES with its history comment), docs/mandantentrennung-zugriffsklassifikation.md section "Der Hintergrunddienst als Falle", CHANGELOG.md head, docs/anleitung-anwender.md section "Dashboard" (table "Verfügbare Widgets") and "Fenster, Infobereich und Beenden"</read_first>
<behavior>
- Claim-once: two scheduler instances (or two overlapping ticks) against the same fake store, where updateMany returns count 1 for the first claim and 0 afterwards, lead to exactly one sendReminderEmail call.
- Transport failure (sendReminderEmail returns false) releases the claim (emailSentAt=null only where emailSentAt equals the claimed timestamp), so the next tick retries; after 3 attempts (emailAttempts >= 3) the reminder is no longer a candidate.
- No SmtpConfig or no user e-mail at send time: the claim is kept, nothing is sent, one log line appears, and nothing is retried (E-04).
- The candidate query selects only emailEnabled=true, emailSentAt=null, emailAttempts<3, dueAt <= now AND dueAt >= now-24h (E-03), and only scalar fields (no relation include on the system client).
- One failing candidate does not stop the others; an overlapping tick is skipped while `running` is true.
- After a snooze (Task 2 reset) the same reminder is a candidate again and gets exactly one more e-mail (D-03).
- MailService.sendReminderEmail: subject "Erinnerung: <title>" without CR/LF, text contains the Europe/Berlin time ("… um HH:MM Uhr"), title, description and the app URL; returns true on success and false when deliver throws.
- Service/API: GET /reminders/email-status returns { smtpConfigured, hasEmail }; create/update with emailEnabled=true while unavailable gives 400.
- Widget: the e-mail checkbox is disabled with the explanation text when smtpConfigured=false, and disabled with the no-address text when hasEmail=false; otherwise it is enabled and sent as emailEnabled; rows with emailEnabled show a small mail icon.
</behavior>
<action>
Add the server-side e-mail path (E-04, E-07, E-08, E-09), the toggle, the documentation, and run the final gates.
1. `apps/api/src/mail/mail.service.ts`: add `sendReminderEmail(tenantId, to, reminder: { title: string; description: string; dueAt: Date }): Promise<boolean>`.
- Subject: `Erinnerung: ${title}`, with CR/LF replaced by spaces and cut to 150 characters.
- The text body is German in the Sie form: salutation, "Sie haben in Tessera eine Erinnerung für {Zeit} gesetzt:", title, description (if present), then the link `this.appUrl`. `{Zeit}` = `Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'full', timeStyle: 'short' })` + " Uhr".
- Call `this.deliver(tenantId, …, 'Reminder')` in try/catch; return true on success, log and return false on failure. No HTML.
- Add spec cases to `mail.service.spec.ts`.
2. `RemindersService`:
- `getEmailAvailability(tenantId, userId)` returns `{ smtpConfigured: (await settingsService.getSmtpConfig(tenantId)) !== null, hasEmail: Boolean(user.email) }`, where the user is read through the tenant-bound client.
- `create`/`update` accept `emailEnabled` (`@IsOptional @IsBoolean` in the DTO) and throw 400 when it is true while either flag is false.
- Controller: `@Get('email-status')` placed directly under `@Get()` and above every `:id` route (NestJS route-order rule); extend the route-order spec.
- `reminders.module.ts` imports `MailModule` and `SettingsModule` and provides `ReminderMailScheduler`.
3. `apps/api/src/reminders/reminder-mail.scheduler.ts`, class `ReminderMailScheduler implements OnApplicationBootstrap`:
- `onApplicationBootstrap` registers `this.schedulerRegistry.addInterval('reminder-email', setInterval(() => void this.runTick(), 30_000))`. It first removes an existing entry (try/catch as in the tender schedulers) and logs one line. Errors are only logged, never thrown.
- `runTick(now = new Date())` returns immediately while `this.running` is set; otherwise it sets the flag and clears it in `finally`.
- Candidates come from EXACTLY ONE `const systemPrisma = forSystem(this.prisma)` and `systemPrisma.reminder.findMany`, with the where clause from `<behavior>`, `select { id, tenantId, userId, dueAt }`, `orderBy dueAt asc`, `take 200`.
- Keep the select scalar. A relation include/select on the system client would make `User` a system-read model that needs its own `system_read_policy` (the WINDOWS #27 form).
- For each candidate, inside try/catch:
1. `const tenantPrisma = forTenant(this.prisma, c.tenantId)` (bound, without a user, as in the digest).
2. Claim: `tenantPrisma.reminder.updateMany({ where: { id, tenantId, dueAt: c.dueAt, emailEnabled: true, emailSentAt: null, emailAttempts: { lt: 3 } }, data: { emailSentAt: now, emailAttempts: { increment: 1 } } })`. Continue unless the count is 1.
3. Load the title/description/dueAt of the row and the user's e-mail through `tenantPrisma`, and check SMTP availability via `SettingsService.getSmtpConfig`.
4. When SMTP is missing or there is no address, log "übersprungen" and keep the claim.
5. Otherwise call `sendReminderEmail`. On false, release the claim with `updateMany({ where: { id, emailSentAt: now }, data: { emailSentAt: null } })`.
- German class comment: why the claim comes before sending (no duplicate mails with several instances and restarts, at-most-3 attempts), why there is a 24 h window, and why it runs every 30 s.
- `reminder-mail.scheduler.spec.ts` covers every scheduler behavior above, mocked the way `tender-digest.scheduler.spec.ts` does it.
4. RLS inventory:
- Add `['apps/api/src/reminders/reminder-mail.scheduler.ts', 1]` to `FORSYSTEM_ALLOWED_CALL_SITES` in `apps/api/src/prisma/rls-access-inventory.spec.ts`, and extend the history comment with a quick-260929-if2 paragraph (candidate query only, all writes bound per row, policy `system_read_policy` from migration 20260929140000; new total "6 Dateien, 7 Aufrufe").
- In `docs/mandantentrennung-zugriffsklassifikation.md`:
- Add the scheduler rows (`reminder` → class `beides`, stand `system-gebunden`; `user` → `beides`, `gebunden` if read directly) and the `reminders.service.ts` / `user` row if the service reads the user.
- Add a paragraph to "Der Hintergrunddienst als Falle" for the new case.
- Re-measure the area row, the "Summe" row and "Klassen-Verteilung" with the gate loop.
- Run both RLS specs.
5. Web:
- `getReminderEmailStatus()` in `reminders-api.ts`, plus a test.
- `reminder-form-modal.tsx` gets the checkbox "Zusätzlich per E-Mail erinnern". Its disabled state and explanation come from the status. It is loaded once per widget mount and treated as unavailable while unknown or failed.
- The mail icon appears on rows where `emailEnabled` is set.
- New texts: emailLabel, emailNoSmtp ("E-Mail-Erinnerungen sind nicht möglich, weil kein E-Mail-Versand eingerichtet ist. Bitte wenden Sie sich an Ihren Administrator."), emailNoAddress ("In Ihrem Konto ist keine E-Mail-Adresse hinterlegt."), emailOn (for the icon's aria-label). de and en.
- Extend the widget tests.
6. Documentation in plain German for non-programmers, Sie form, real umlauts:
- `CHANGELOG.md`: under "## Unveröffentlicht" add a new "### Neu" section ABOVE the existing "### Behoben". Bullet: "Dashboard: Neues Widget „Erinnerungen“ …". It covers date/time/title/description, the notification at exactly the chosen time in the browser (after a one-time permission) and in the desktop app (also from the notification area), the optional e-mail (also when Tessera is not open anywhere), and a due reminder staying highlighted until "Erledigt" or "Später erinnern" (in 10 minutes, in 1 hour, tomorrow at the same time). Add the note that the desktop app needs its new version for this, delivered through the update in the Tessera icon's menu.
- `docs/anleitung-anwender.md`:
- A row "Erinnerungen" in the table "Verfügbare Widgets", plus a short paragraph below it: the browser asks once, blocked notifications only show in the widget, the e-mail option and why it can be greyed out, snooze options, editing/deleting only before the due time, reminders are personal.
- One sentence in "Fenster, Infobereich und Beenden": reminders also appear while the window is in the notification area.
7. Final gates, in this order. All must pass:
- Full API and web test suites.
- `pnpm turbo run type-check lint --force`.
- Biome counts web <= 55 and api <= 82, measured with the Biome command in `<verify>` (it prints both counts and exits non-zero above the limit).
- cargo test/fmt/clippy.
- `docker compose up -d --build web api`, then GET /health on the API.
- curl as testuser: GET /reminders/email-status answers; POST with `emailEnabled: true` behaves as the status says (201 or 400).
- Delete all test reminders again.
- Commit `feat(260929-if2): Erinnerung zusaetzlich per E-Mail, Doku und Aenderungsliste` (Co-Authored-By line). NEVER push.
- Copy the section "Manuelle Prüfschritte für den Orchestrator" of this plan into the SUMMARY, adjusted to the actual state (for example whether SMTP is configured locally).
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/reminders src/mail src/prisma</automated>
<fails_when>non-zero exit, a non-zero "failed" count in the "Test Files" or "Tests" summary line, or "No test files found"</fails_when>
<automated>pnpm --filter @tessera/api exec vitest run && pnpm --filter @tessera/web exec vitest run && pnpm turbo run type-check lint --force</automated>
<fails_when>non-zero exit: a failed test in either suite, or a turbo task reported as failed (a type error or a Biome lint error)</fails_when>
<automated>cd /home/vicolab/projects/tessera-ctl && ok=1; for p in web:55 api:82; do n=${p%%:*}; max=${p#*:}; out=$(pnpm --filter @tessera/$n exec biome lint . 2>&1) && echo "$out" | grep -qE 'Checked [0-9]+ files' || { echo "$n: biome did not run"; ok=0; continue; }; c=$(echo "$out" | grep -oE 'Found [0-9]+ warnings?' | grep -oE '[0-9]+'); echo "$n: ${c:-0} warnings (max $max)"; [ "${c:-0}" -le "$max" ] || ok=0; done; [ "$ok" = 1 ]</automated>
<fails_when>non-zero exit, together with a "biome did not run" line or a "web: N warnings (max 55)" / "api: N warnings (max 82)" line whose N is above its max (baseline measured during planning: exactly 55 and 82)</fails_when>
<automated>cd /home/vicolab/projects/tessera-ctl && cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib && cargo fmt --manifest-path apps/desktop/src-tauri/Cargo.toml --check && cargo clippy --manifest-path apps/desktop/src-tauri/Cargo.toml -- -D warnings</automated>
<fails_when>non-zero exit: "test result: FAILED", a diff printed by cargo fmt --check, or an "error:" line from clippy</fails_when>
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q "reminder-mail.scheduler.ts', 1" apps/api/src/prisma/rls-access-inventory.spec.ts && grep -q "Erinnerungen" CHANGELOG.md && grep -q "Erinnerungen" docs/anleitung-anwender.md</automated>
<fails_when>non-zero exit: one of the three strings is missing from its file</fails_when>
</verify>
<done>
- The e-mail is claimed atomically and sent exactly once per due occurrence (tests prove this with concurrent claims), and it is sent again after a snooze.
- A transport failure is retried at most 3 times; a missing SMTP config or address is skipped without a loop.
- The toggle is disabled with an explanation when e-mail is unavailable.
- The inventory allowlist and the doc are updated and re-measured.
- CHANGELOG and user guide are written.
- All gates are green, and the Biome counts do not exceed the baseline.
- The containers are rebuilt, the test data removed, and the work committed locally and never pushed.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser/webview → API `/reminders*` | untrusted body and ids; identity from the session cookie only |
| server page (remote origin) → Tauri IPC | web content from the configured server calls the native notification plugin |
| API scheduler → SMTP | user-provided title/description go into a mail |
| scheduler (all tenants) → DB | the system context reads across tenants |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-IF2-01 | Information disclosure | `RemindersService` loadOwn/list | high | mitigate | Every query uses `forTenant(prisma, tenantId, userId)` plus `where { tenantId, userId }`; foreign/unknown ids give 404 (never 403); RLS `tenant_isolation_policy` with a user dimension; service specs assert the 404 cases (D-05) |
| T-IF2-02 | Tampering | DTOs / controller | high | mitigate | Global ValidationPipe `whitelist` strips `tenantId`/`userId`/`emailSentAt`/`emailAttempts`; the service sets the ids from the token; the controller spec proves the stripping |
| T-IF2-03 | Elevation of privilege | `grant_server_notifications` (lib.rs) | medium | mitigate | The runtime capability binds exactly the stored `scheme://host[:port]`, window `main`, and only the three `notification:` permissions; no app commands (save_server_url etc. stay local-only, T-JN2-01). Host characters outside `A-Za-z0-9.-` are escaped, so a host such as `*.example.com` cannot become a wildcard, and the pattern is self-checked with `RemoteUrlPattern` (parse + match of the stored URL), so an IPv6 or otherwise unparsable pattern yields no grant instead of a panic inside `add_capability`. `server_origin_*` unit tests pin the exact 3-permission set with `assert_eq!` and the match/no-match behavior (other scheme, port, subdomain, host; IPv6; wildcard host). Accepted residual risk: after a server change the old origin keeps the notify right until the app restarts |
| T-IF2-04 | Denial of service | create/list, notifier polling | medium | mitigate | At most 100 reminders per user (409); title ≤ 200, description ≤ 2000; the notifier polls every 60 s (local ticks without network); the scheduler uses `take 200` and a reentrancy guard |
| T-IF2-05 | Tampering (header injection) | `MailService.sendReminderEmail` | medium | mitigate | CR/LF stripped from the subject, text-only body (no HTML, so no HTML injection); recipient only the owner's stored address |
| T-IF2-06 | Repudiation / integrity | e-mail duplicates across instances | medium | mitigate | Atomic claim `updateMany … emailSentAt: null, dueAt: <read value>` with a count check before sending; release only on transport failure; at most 3 attempts; the spec proves a single send with concurrent claims |
| T-IF2-07 | Information disclosure | system context read | medium | mitigate | `system_read_policy` is FOR SELECT only; the candidate select is scalar-only; every write is tenant-bound per row; `FORSYSTEM_ALLOWED_CALL_SITES` pins exactly 1 call in `reminder-mail.scheduler.ts` |
| T-IF2-08 | Information disclosure | OS notification / e-mail content | low | accept | Title/description are the user's own text, shown to that user on their own device and mailbox; lock-screen visibility is the user's OS setting |
| T-IF2-09 | Information disclosure | `GET /reminders/email-status` | low | accept | Reveals only two booleans (tenant SMTP present, own address present) to an authenticated user of that tenant |
| T-IF2-10 | Spoofing / XSS | widget rendering, notification body | low | mitigate | React escapes the text; Notification/plugin bodies are plain text; nothing is rendered as HTML |
| T-IF2-SC | Tampering | npm/pip/cargo installs | high | mitigate | No new packages in this plan (nodemailer, @nestjs/schedule, the Tauri plugins and tauri-plugin-notification are already installed); the executor must not add dependencies, so no package-legitimacy gate is triggered |
</threat_model>
<verification>
- `pnpm --filter @tessera/api exec vitest run` and `pnpm --filter @tessera/web exec vitest run` are fully green.
- `pnpm turbo run type-check lint --force` is green. Biome: web ≤ 55, api ≤ 82 warnings.
- `cargo test --lib`, `cargo fmt --check` and `cargo clippy -- -D warnings` on `apps/desktop/src-tauri/Cargo.toml` are green.
- `rls-coverage.spec.ts` and `rls-access-inventory.spec.ts` are green, and the doc numbers are re-measured with the gate loop.
- The local migration is applied, and `prisma migrate diff --exit-code` shows no difference.
- curl end to end: create/list as testuser, 404 for admin on testuser's id, email-status answers, and the test data is removed.
- Local commits only. `git log origin/main..HEAD` shows the new commits and nothing was pushed.
</verification>
<success_criteria>
- A user can create personal one-time reminders in the new "Erinnerungen" widget and see them sorted, with due ones highlighted (D-01, D-05).
- A browser tab and the desktop app (including tray mode) each notify exactly once per due occurrence at the due time (D-02, D-04, E-01).
- "Erledigt" removes a reminder; "Später erinnern" (+10 min / +1 h / tomorrow same time) re-arms the notifications and the e-mail (D-03).
- With e-mail enabled, exactly one mail per due occurrence is sent even with no client open (E-04); the toggle explains why it is disabled when unavailable.
- RLS, the inventory and the docs are consistent; CHANGELOG and user guide describe the feature in plain German.
</success_criteria>
## Manuelle Prüfschritte für den Orchestrator
These are carried out by the orchestrator after execution; the executor copies them into the SUMMARY.
**Browser (Playwright MCP, dark mode through the theme button; never measure via fetch from the page):**
1. Log in as `testuser` / `Test1234!test`. Allow notifications for the origin in the Playwright context (`grantPermissions(['notifications'])`); otherwise the prompt stays "default". Also check once with a new context without that grant: the prompt must appear only after clicking "Speichern" on the first reminder, not on page load.
2. "Bearbeiten" → "Widget hinzufügen" → catalog shows "Erinnerungen" with the bell icon → add it → "Fertig". Screenshot in dark mode: header with the icon chip, empty state, button "Neue Erinnerung".
3. Create a reminder due in 2 minutes (title + description). The list shows it with the local time.
4. Before the due time, install a spy in the page (`page.evaluate`, wrap `window.Notification` and count the calls). Switch to another portal page (e.g. Marktplatz) and wait past the due time. Expect exactly one notification call with the title "Erinnerung: …" even though the dashboard is not visible (the notifier is global). Back on the dashboard, the row is highlighted as "Fällig" with "Erledigt" and "Später erinnern". Take a dark-mode screenshot of the highlighted row.
5. Open a second tab of the same session and wait 20 s: no second notification for the same reminder.
6. "Später erinnern" → "In 10 Minuten": the row is upcoming again with the new time (edit/delete visible). "Bearbeiten": change the title and save. Delete with the confirm step. Create another one, let it become due, then "Erledigt": it disappears.
7. The e-mail checkbox in the form: with no tenant SMTP configured locally it is disabled with the explanation text. If SMTP (e.g. mailhog from `docker-compose.dev.yml`) is configured under Einstellungen → SMTP, enable it, let a reminder become due, and verify that exactly one mail arrives with the time in Europe/Berlin. Snooze by 10 minutes, and after that a second mail arrives.
8. As `admin` (admin123): the widget does not show testuser's reminders.
9. Remove all test reminders afterwards.
**Windows VM (Proxmox VM 8233, per the stored VM notes):**
- The Rust change only takes effect in a desktop client built from this commit. Since nothing is pushed, CI does not build one. The Windows check therefore needs either the user's push + CI build, or a locally built NSIS package. An older installed client shows the widget and sends e-mails, but shows no toast; this is expected and is mentioned in the CHANGELOG.
10. With the new client connected to the server that has this code: create a reminder due in 3 minutes, close the window with X (the app goes to the tray), and wait. A Windows toast from "Tessera" with "Erinnerung: …" appears within about 1 minute of the due time (WebView2 throttles hidden timers, E-01). After the toast, open the window: the reminder shows "Fällig".
11. With the desktop app and a browser open at the same time, each shows the notification exactly once.
12. After "Server-Adresse ändern…" to the same server, notifications still work (capability re-granted in `save_server_url`).
<output>
Create `.planning/quick/260929-if2-reminder-widget-mit-benachrichtigung/260929-if2-SUMMARY.md` when done. It must contain: the measured gate table (test counts, Biome counts, cargo, RLS specs, migrate diff, gate-loop numbers), the curl results, any deviations, and the manual check steps above, adjusted to the actual state.
</output>
@@ -0,0 +1,152 @@
---
phase: quick-260929-if2
plan: 01
subsystem: dashboard-widgets, api-reminders, desktop-notifications
tags: [reminders, notifications, tauri, rls, scheduler, smtp]
status: complete
requires: []
provides:
- "Widget 'Erinnerungen' (type reminder) with create/edit/delete, due highlight, Erledigt, Spaeter erinnern"
- "API /reminders (list, email-status, create, patch, snooze, delete) with owner scoping (404 for foreign ids)"
- "Reminder table with tenant+user RLS policy and system_read_policy"
- "Global ReminderNotifier in AppShell (browser Web Notification, Tauri plugin notification)"
- "ReminderMailScheduler: atomic-claim e-mail once per due occurrence"
- "Tauri runtime remote capability for notification permissions on the stored server origin only"
affects: [dashboard, desktop, mail, rls-inventory]
tech-stack:
added: []
patterns:
- "claim-before-send scheduler (updateMany with count check), forSystem candidate query + forTenant per row"
- "runtime Tauri capability with escaped and self-checked URL pattern (no catch_unwind)"
key-files:
created:
- apps/api/prisma/migrations/20260929140000_reminder/migration.sql
- apps/api/src/reminders/ (module, controller, service, scheduler, dto, 4 specs)
- apps/web/src/lib/reminders-api.ts
- apps/web/src/lib/reminder-notify.ts
- apps/web/src/lib/reminder-time.ts
- apps/web/src/components/reminders/reminder-notifier.tsx
- apps/web/src/components/dashboard/widgets/reminder-widget.tsx
- apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx
modified:
- apps/api/prisma/schema.prisma
- apps/api/src/app.module.ts
- apps/api/src/mail/mail.service.ts
- apps/api/src/prisma/rls-access-inventory.spec.ts
- apps/desktop/src-tauri/src/lib.rs
- packages/shared/src/index.ts
- apps/web (registry, widget-icon, widget-wrapper, app-shell, page.tsx, messages de/en, umlaut-dictionary)
- docs/mandantentrennung-zugriffsklassifikation.md, docs/anleitung-anwender.md, CHANGELOG.md
key-decisions:
- "E-01 runtime remote capability for exactly the stored origin (escaped, self-checked with RemoteUrlPattern), window main, three notification permissions"
- "E-02 Erledigt deletes the row; E-04 claim before send, release only on transport failure, max 3 attempts"
- "Snooze resets emailSentAt/emailAttempts so the mail fires again (D-03)"
duration: about 1 h 15 min
completed: 2026-09-29
commits: 3
plan_head_before: cd1f8f6cda7d3b1b6fa22ab8ec9274201d9c2089
plan_head_after: 8027c4857080790bf9994553fbda8036d81f8eb3
actuals:
tokens: 43000
tasks: 3
commits: 3
---
# Phase quick-260929-if2 Plan 01: Erinnerungen-Widget mit Benachrichtigung Summary
Persoenliche einmalige Erinnerungen als Dashboard-Widget: Benachrichtigung zur Faelligkeit im Browser und als native Windows-Meldung in der Desktop-App (auch im Infobereich), optional eine E-Mail, die der Server genau einmal je Faelligkeit ueber einen atomaren Anspruch versendet.
## Commits (lokal, nicht gepusht)
| Task | Hash | Betreff |
|---|---|---|
| 1 (Tracer) | 26f8f0f | feat(260929-if2): Erinnerungen anlegen und zur Faelligkeit benachrichtigen (Tracer) |
| 2 | 580c31c | feat(260929-if2): faellige Erinnerungen erledigen, spaeter erinnern, bearbeiten und loeschen |
| 3 | 8027c48 | feat(260929-if2): Erinnerung zusaetzlich per E-Mail, Doku und Aenderungsliste |
`commits:` gemessen mit `git rev-list --count cd1f8f6..HEAD` = 3. Der Tracer-Feedback-Gate (Auto-Modus: `<verify>` erneut ausfuehren) lief vor Task 2: API-, Web-, cargo-Tests, `migrate diff` und die curl-End-to-End-Kette waren gruen, also wurde erweitert.
## Gemessene Gates (nach Task 3)
| Gate | Ergebnis |
|---|---|
| API vitest komplett | 91 Dateien, 1570 Tests, alle gruen |
| Web vitest komplett | 108 Dateien, 1069 Tests, alle gruen |
| `pnpm turbo run type-check lint --force` | 9/9 Tasks erfolgreich |
| Biome-Warnungen | web 55 (max 55), api 82 (max 82), also exakt die Grundlinie |
| cargo test --lib | 57 gruen, davon 12 `server_origin_*` (Plan verlangte mindestens 10) |
| cargo fmt --check / clippy -D warnings | sauber |
| rls-coverage + rls-access-inventory | gruen (30 Zusicherungen im Inventar) |
| `prisma migrate diff --exit-code` | "No difference detected" (Exit 0), Migration lokal angewendet |
| Gate-Schleife (Rohtreffer, ohne spec) | Summe 61 / 235 / 7 (ungebunden / gebunden / System); Bereich `reminders` 0 / 12 / 1 |
| Bestandsaufnahme-Doku | 83 Paare (44 muss-mandantengebunden, 21 keine-mandantengebundene-tabelle, 16 beides, 2 bewusst-uebergreifend), mit `grep -cE '^\| apps/api/src/'` nachgezaehlt |
| FORSYSTEM_ALLOWED_CALL_SITES | neu `reminder-mail.scheduler.ts` = 1, Summe 6 Dateien / 7 Aufrufe |
## curl-Ergebnisse (lokal, gegen die neu gebauten Container)
- Tracer: testuser POST + GET ok, admin sieht das Tracer-Test nicht (`tracer e2e ok`); Vergangenheit ergibt 400.
- Task 2: admin bekommt fuer PATCH, snooze und DELETE auf eine testuser-id je 404; testuser: snooze auf nicht faellige Erinnerung 409, PATCH 200. Alle Test-Zeilen geloescht.
- Task 3: `GET /reminders/email-status` liefert `{"smtpConfigured":true,"hasEmail":true}` (lokal ist SmtpConfig auf `mailhog:1025` gesetzt, testuser hat `testuser@example.com`); POST mit `emailEnabled:true` ergibt 201. `/health` ok.
- Planerlauf gegen die echte Datenbank: eine Erinnerung mit E-Mail wurde 1 Minute nach Anlage faellig; der Planer versuchte den Versand im 30-s-Takt genau dreimal (mailhog-Container laeuft lokal nicht, also Transportfehler und Freigabe), danach `emailAttempts = 3` und keine weiteren Versuche. Damit ist Anspruch, Freigabe und die Grenze von 3 Versuchen live belegt. Alle Test-Zeilen danach geloescht (`count(*) = 0`).
## Deviations from Plan
1. **[Rule 3 - Blocking] Bestehende Tests an die neue Kachel angepasst.** `widget-registry.test.tsx` (Typliste, Groessentabelle, Zaehler 40 auf 44) und `widget-catalog-modal.test.tsx` (letzte Kachel nun `reminder`) pruefen die exakte Kachelliste. Ohne Anpassung waere die Suite rot. Commit 26f8f0f.
2. **[Rule 3] `reminder-time.ts` schon in Task 1.** Der Plan legte `localInputsToIso` zuerst in die Kachel und verschob sie in Task 2; ich habe sie gleich in `reminder-time.ts` angelegt und in Task 2 nur erweitert (`isoToLocalInputs`, `snoozeTarget`). Kein Verhalten anders.
3. **[Rule 2 - Lesbarkeit] "Faellig"-Abzeichen mit 20 % Flaeche.** Der Plan nannte `bg-status-warn text-status-warn-fg`; die Schrift-Variante erreicht auf voller Warnflaeche nur rund 2,6:1 (globals.css-Kommentar). Verwendet wird `bg-status-warn/20 text-status-warn-fg` (die dokumentierte Pillen-Form). Bitte im Dunkelmodus mitpruefen.
4. **[Rule 2] `lässt` auf die Umlaut-Allowlist** (`umlaut-dictionary.ts`), weil der Text `alreadyDue` "lässt sich nicht mehr bearbeiten" korrektes Deutsch ist, das der Guard sonst meldet (im Plan vorgesehen, "nur wenn der Guard warnt").
5. **Formular-Details:** `emailEnabled` wird beim Bearbeiten nur gesendet, wenn es sich aendert (sonst kippt eine unveraenderte Alt-Einstellung das Speichern mit 400, wenn SMTP inzwischen fehlt); ein bereits angehaktes Feld bleibt bedienbar, damit man es abwaehlen kann. Beim Bearbeiten wird nie die Browser-Erlaubnis abgefragt (D-04: nur beim ersten Anlegen). Zusaetzlich bekam `ReminderFormModal` die Props `emailStatus` und `onStale` (409 beim Bearbeiten laedt neu).
6. **Commit-Zeile:** Die Co-Authored-By-Zeile ist `Claude Sonnet 5.5 <noreply@anthropic.com>` (das laufende Modell, wie die Umgebung sie vorgibt), nicht "Claude Opus 5.5 (1M context)" wie in der Aufgabenbeschreibung. Bei Bedarf per Umschreiben anzupassen, bevor gepusht wird.
Stop-Regel des Plans: nach jedem Commit lag der Kontext weit unter der Haelfte, daher alle drei Segmente in einem Lauf.
## Known Stubs
Keine. Alle Daten der Kachel kommen aus der API; keine Platzhalter.
## Threat Flags
Keine neue Flaeche ausserhalb des Plan-Threat-Models. Zur Beachtung (T-IF2-03, im Plan akzeptiert): nach einem Serverwechsel behaelt der alte Ursprung sein Benachrichtigungsrecht bis zum App-Neustart.
## Nicht messbar ohne GUI (bitte pruefen)
- Das Rust-Laufzeitrecht (`add_capability` mit `remote`) ist per Unit-Tests der Musterbildung abgesichert (Escaping, Selbstpruefung, exakte 3er-Menge), aber der echte Toast in der Desktop-App ist nur auf der Windows-VM pruefbar (siehe Schritte 10 bis 12 unten).
## Manuelle Pruefschritte fuer den Orchestrator
Angepasst an den Ist-Zustand: lokal ist SMTP auf `mailhog:1025` gesetzt, der mailhog-Container laeuft aber NICHT (`docker ps` zeigt keinen). Die E-Mail-Checkbox ist also aktiv (nicht ausgegraut), Mails scheitern lokal beim Transport, bis mailhog laeuft (z. B. aus `docker-compose.dev.yml` starten).
**Browser (Playwright MCP, dunkel per Theme-Knopf; nie per fetch aus der Seite messen):**
1. Als `testuser` / `Test1234!test` anmelden. Benachrichtigungen fuer den Ursprung im Playwright-Kontext erlauben (`grantPermissions(['notifications'])`), sonst bleibt die Abfrage "default". Einmal auch mit neuem Kontext ohne Erlaubnis: die Abfrage erscheint erst nach Klick auf "Speichern" beim ersten Anlegen, nicht beim Laden der Seite.
2. "Bearbeiten" -> "Widget hinzufuegen" -> Katalog zeigt "Erinnerungen" mit Glocken-Symbol -> hinzufuegen -> "Fertig". Dunkel-Screenshot: Kopfzeile mit Symbol-Chip, Leerzustand, Knopf "Neue Erinnerung".
3. Erinnerung mit Faelligkeit in 2 Minuten anlegen (Titel + Beschreibung). Die Liste zeigt sie mit lokaler Zeit; der E-Mail-Haken ist bedienbar.
4. Vor der Faelligkeit einen Spion in die Seite legen (`page.evaluate`, `window.Notification` umhuellen und Aufrufe zaehlen). Auf eine andere Portalseite (z. B. Marktplatz) wechseln und die Faelligkeit abwarten. Erwartet: genau ein Aufruf mit Titel "Erinnerung: ...", obwohl das Dashboard nicht sichtbar ist (der Melder ist global). Zurueck auf dem Dashboard: Zeile hervorgehoben, Abzeichen "Faellig", Knoepfe "Erledigt" und "Spaeter erinnern". Dunkel-Screenshot der hervorgehobenen Zeile (Lesbarkeit des Abzeichens beurteilen).
5. Zweiten Tab derselben Sitzung oeffnen und 20 s warten: keine zweite Benachrichtigung fuer dieselbe Erinnerung.
6. "Spaeter erinnern" -> "In 10 Minuten": Zeile ist wieder kuenftig mit neuer Zeit (Bearbeiten/Loeschen sichtbar). "Bearbeiten": Titel aendern, speichern. Loeschen mit Rueckfrage (Ja/Nein). Eine weitere Erinnerung faellig werden lassen, dann "Erledigt": sie verschwindet.
7. E-Mail: lokal ist SMTP gesetzt (mailhog:1025), Checkbox ist bedienbar. Fuer den echten Mailfluss zuerst mailhog starten, dann eine Erinnerung mit Haken faellig werden lassen: genau eine Mail mit Zeit in Europe/Berlin. Nach "In 10 Minuten" kommt nach Ablauf eine zweite. (Den ausgegrauten Zustand mit Erklaerungstext kann man pruefen, indem man in Einstellungen -> SMTP die Einrichtung entfernt; danach wiederherstellen.)
8. Als `admin` (admin123): das Widget zeigt keine Erinnerungen von testuser.
9. Am Ende alle Test-Erinnerungen entfernen.
**Windows-VM (Proxmox 8233, laut VM-Notizen):**
Die Rust-Aenderung wirkt nur in einem Desktop-Client, der aus diesem Stand gebaut ist. Da nichts gepusht wurde, baut CI keinen; der Windows-Test braucht entweder Push + CI-Paket oder ein lokal gebautes NSIS-Paket. Ein aelterer Client zeigt das Widget und bekommt E-Mails, aber keine Toasts (im CHANGELOG vermerkt).
10. Mit dem neuen Client, verbunden mit dem Server mit diesem Code: Erinnerung in 3 Minuten anlegen, Fenster mit X schliessen (Infobereich), warten. Ein Windows-Toast von "Tessera" mit "Erinnerung: ..." erscheint innerhalb rund einer Minute nach der Faelligkeit (WebView2 drosselt versteckte Timer, E-01). Danach das Fenster oeffnen: die Erinnerung zeigt "Faellig".
11. Desktop-App und Browser gleichzeitig offen: jede zeigt die Benachrichtigung genau einmal.
12. Nach "Server-Adresse aendern..." auf denselben Server funktionieren die Benachrichtigungen weiter (Berechtigung wird in `save_server_url` erneut erteilt).
## Self-Check: PASSED
- Erstellte Dateien vorhanden: Migration, `apps/api/src/reminders/*` (inkl. `reminder-mail.scheduler.ts`), `reminders-api.ts`, `reminder-notify.ts`, `reminder-time.ts`, `reminder-notifier.tsx`, `reminder-widget.tsx`, `reminder-form-modal.tsx` (alle in den Commits enthalten).
- Commits 26f8f0f, 580c31c, 8027c48 liegen auf `main` (`git log cd1f8f6..HEAD`); `git status` zeigt ausser dem `.planning`-Verzeichnis nichts Uncommittetes.
- Nichts gepusht.
## Browser-Pruefung (Orchestrator, 29.09., dunkel, testuser)
- Katalog zeigt „Erinnerungen“; Widget hinzugefuegt, Leerzustand + „Neue Erinnerung“.
- „Kaffee holen“ faellig 14:14 mit E-Mail-Haken, danach auf den Marktplatz gewechselt: genau eine Browser-Benachrichtigung „Erinnerung: Kaffee holen“ um 14:14:08 (global, nicht nur auf dem Dashboard).
- MailHog (lokal gestartet, Alias mailhog im backend-net): genau eine Mail an testuser@example.com um 14:14:15, Text „Dienstag, 29. September 2026 um 14:14 Uhr“ (Berlin).
- Zweiter Tab derselben Sitzung, 20 s: keine zweite Benachrichtigung.
- Faellig-Zustand dunkel gut lesbar (Rahmen + Abzeichen „Fällig“, Erledigt / Später erinnern).
- Später erinnern: Menue In 10 Minuten / In 1 Stunde / Morgen um 14:14; „In 10 Minuten“ -> 14:26. Bearbeiten (Titel) ok. Löschen mit Rueckfrage Ja/Nein ok.
- „Wasser trinken“ (ohne E-Mail) faellig -> Erledigt -> verschwindet; keine zweite Mail.
- Aufgeraeumt: Reminder-Tabelle leer, MailHog-Container entfernt.
- Offen: Windows-Toast der Desktop-App (braucht CI-Paket nach Push), Erlaubnisabfrage-erst-nach-Speichern nur per Komponententest belegt (Playwright-Kontext hatte die Erlaubnis vorab).
@@ -0,0 +1,127 @@
---
phase: quick-260929-if2
verified: 2026-09-29T12:20:00Z
status: human_needed
score: 8/9 must-haves verified
behavior_unverified: 1
overrides_applied: 0
behavior_unverified_items:
- truth: "At the due time the desktop app shows a native OS notification, also while the main window is hidden in the tray, through the notification plugin, which the page may call only from the stored server origin"
test: "Windows VM with a desktop client built from commit 6879c75 (or CI package): create a reminder due in 3 minutes, close the window with X, wait; then open the window"
expected: "A Windows toast 'Erinnerung: ...' appears within about 1 minute of the due time; the reminder shows 'Faellig' afterwards. Also: after 'Server-Adresse aendern...' to the same server the toast still works"
why_human: "Unit tests only pin the URL pattern (escape, self-check, match/no-match) and the exact 3-permission set. Whether Tauri accepts the runtime capability (remote + notification:allow-* identifiers) and delivers the toast cannot be seen by grep or cargo test; add_capability panics rather than returning Err on a bad pattern/identifier"
human_verification:
- test: "Browser (Playwright MCP, dark): create a reminder due in 2 min, switch to another portal page, wait past due time, spy on window.Notification"
expected: "Exactly one Notification call titled 'Erinnerung: ...' although the dashboard is not visible; on return the row is highlighted with 'Faellig', 'Erledigt' and 'Spaeter erinnern'; a second tab of the same session gives no second notification"
why_human: "Real Notification permission flow and multi-tab Web Locks/localStorage dedup need a live browser (orchestrator runs these)"
- test: "Browser, fresh context without notification grant"
expected: "Permission prompt appears only after clicking 'Speichern' on the first reminder, never on page load"
why_human: "Browser permission UI"
- test: "Dark-mode look of the 'Faellig' badge (bg-status-warn/20 text-status-warn-fg, deviation 3) and highlighted row"
expected: "Readable contrast"
why_human: "Visual"
- test: "E-mail flow with a reachable SMTP (start mailhog or real SMTP), reminder with the e-mail tick due, then snooze +10 min"
expected: "Exactly one mail with time in Europe/Berlin; after the snooze a second one; e-mail checkbox greyed out with explanation when SMTP is removed"
why_human: "Real transport; locally the mailhog container is not running (summary: 3 failed attempts then stop, as designed)"
- test: "Windows VM: desktop app and browser open simultaneously"
expected: "Each shows the notification exactly once"
why_human: "Needs Windows GUI"
---
# Quick 260929-if2: Reminder widget "Erinnerungen" Verification Report
**Phase Goal:** Reminder widget with notification in desktop app, browser and optionally by e-mail; one-time, no advance warning, after due "Erledigt"/"Spaeter erinnern" (10 min / 1 h / tomorrow); personal with RLS; e-mail exactly once per due occurrence.
**Verified:** 2026-09-29
**Status:** human_needed
**Re-verification:** No, initial verification
Note on commits: verified against the re-created commits 325c5dd, 709b41a, 6879c75 (HEAD, three commits above cd1f8f6; nothing pushed, `git log origin/main..HEAD` shows exactly these). No source files were modified by this verification. The only untracked path is the task directory. During verification an unrelated test reminder ("Kaffee holen") appeared in the live DB, presumably from the orchestrator's browser check; it was not touched.
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | User adds widget, creates reminder (date/time/title/description, local time), sees only own open reminders sorted by due time (D-05) | VERIFIED | `reminder-widget.tsx` sorts by dueAt, lists via `listReminders`; `reminders.service.ts list()` uses `forTenant(prisma, tenantId, userId)` + `where {tenantId,userId}`, `orderBy dueAt asc`; registered in registry, `page.tsx` (`registerWidget('reminder', ReminderWidget)`), `WIDGET_TYPES` in shared; widget test green; live GET as testuser returns 200 array |
| 2 | At due time an open tab shows a Web Notification once granted; permission asked only from widget at first creation, never on page load (D-04) | VERIFIED (unit-level; live browser in human items) | `reminder-notify.ts`: `requestBrowserPermissionOnce` (flag in localStorage, no-op in Tauri/when not 'default'); called first in submit handler only when `!reminder` (create); `ReminderNotifier` mounted in `app-shell.tsx:48`; `remindersToNotify` only `dueAt <= now` (D-02) within 24 h; widget/notifier/notify tests green (410 web tests in scope, 1069 full) |
| 3 | Desktop app shows native OS notification also while window hidden in tray, via plugin, callable only from stored server origin | PRESENT_BEHAVIOR_UNVERIFIED | Code present and wired: `showReminderNotification` invokes `plugin:notification\|notify` with `{options:{title,body}}`; `grant_server_notifications` called in `setup()` before first `navigate` and in `save_server_url`; `server_origin_pattern` escapes host, self-checks with `RemoteUrlPattern`; `SERVER_NOTIFICATION_PERMISSIONS` pinned to 3 ids; capability `.local(false).window("main")`; plugin registered (`lib.rs:867`). cargo: 57 passed, 12 `server_origin_*`; fmt ok. `add_capability` in tauri 2.11.3 appends (checked source), so repeated calls do not overwrite. Actual toast delivery / runtime acceptance is not exercised by any test, so routed to human (Windows) |
| 4 | Every client shows each (id, dueAt) at most once: tabs share local claim, desktop and browser each notify once | VERIFIED (unit-level) | `claimNotification` (localStorage record, key `${id}\|${dueAt}`, 7-day prune) under `withNotifyLock` (Web Locks); key changes after snooze; notifier test: one notification across several ticks, again after dueAt change. Separate webview storage means desktop and browser each notify once by design. Multi-tab live check in human items |
| 5 | Due reminder stays highlighted with "Erledigt" (removes) and "Spaeter erinnern" (+10 min, +1 h, tomorrow same time); snooze sets new dueAt so notifications and e-mail fire again (D-01, D-03) | VERIFIED | Widget: due rows (`dueAt <= now`, 10 s tick) get `border-status-warn`, badge, Erledigt (`deleteReminder`), snooze options via `snoozeTarget`; `reminder-time.ts snoozeTarget` (now+10m, now+1h, original time + calendar days until future); service `snooze` writes `{dueAt, emailSentAt: null, emailAttempts: 0}`; no recurrence field/UI anywhere in schema/DTO; time/widget/service tests green |
| 6 | Upcoming reminders editable/deletable; editing a due one is 409; snoozing a not-due one is 409 | VERIFIED | `service.update` 409 when `dueAt <= now`; `snooze` 409 when `dueAt > now`; controller has PATCH/POST snooze/DELETE below static `email-status`; route-order spec in controller spec (14 tests green); widget shows edit/delete only on non-due rows |
| 7 | With e-mail on, server sends exactly one mail per due occurrence via tenant SMTP (Europe/Berlin), no client needed, several instances safe (atomic claim); toggle disabled with explanation when SMTP missing / no e-mail | VERIFIED (unit-level; real transport in human items) | `reminder-mail.scheduler.ts`: single `forSystem` call, scalar select, candidate filter (emailEnabled, emailSentAt null, attempts<3, due within 24 h), claim `updateMany` with `dueAt` equality and `emailSentAt: null`, count===1 check before send, release only on transport failure with own timestamp, skip keeps claim, `running` reentrancy guard, 30 s `addInterval`. `MailService.sendReminderEmail`: CR/LF stripped subject, `Europe/Berlin` `de-DE` + " Uhr", text only, returns bool. Scheduler spec: 16 tests incl. two instances one mail, 3-attempt cap, snooze re-arm. Toggle: `emailAvailable`/`emailHint` in form modal; email-status endpoint live: `{"smtpConfigured":true,"hasEmail":true}`. Summary reports a live DB run with 3 attempts then stop |
| 8 | Foreign reminder id always 404 (never 403); Reminder has tenant+user RLS policy and system read policy; rls-coverage and rls-access-inventory green; classification doc re-measured | VERIFIED | `loadOwn` throws `NotFoundException` for unknown/foreign; live DELETE on unknown id returns 404; DB: `relrowsecurity` and `relforcerowsecurity` both true; policies `tenant_isolation_policy` (ALL, tenant AND user dim) and `system_read_policy` (SELECT, `is_system_context()`); `migrate diff --exit-code` "No difference detected"; rls-coverage and rls-access-inventory pass; `FORSYSTEM_ALLOWED_CALL_SITES` has `reminder-mail.scheduler.ts, 1`; doc updated in all three commits (numbers not independently re-counted) |
| 9 | All API/web tests green, type-check and lint green, Biome warnings web <= 55 and api <= 82, cargo test/fmt/clippy green | VERIFIED (clippy not re-run) | API full: 91 files / 1570 tests pass; web full: 108 files / 1069 tests pass; `turbo run type-check lint --force`: 9/9 successful; Biome web 55, api 82 (exactly baseline); cargo test 57 pass, fmt ok. Clippy `-D warnings` not re-run by me (summary claims clean) |
**Score:** 8/9 truths verified (1 present, behavior-unverified)
### Required Artifacts
| Artifact | Status | Details |
|----------|--------|---------|
| `apps/api/prisma/migrations/20260929140000_reminder/migration.sql` | VERIFIED | Table, indexes, FK cascade, ENABLE+FORCE RLS, both policies; applied locally, no schema drift |
| `apps/api/src/reminders/reminders.service.ts` / `.controller.ts` | VERIFIED | Owner-scoped CRUD/snooze/email-status, all through `forTenant(..., tenantId, userId)`; `REMINDER_SELECT` excludes tenantId/userId/emailSentAt/emailAttempts |
| `apps/api/src/reminders/reminder-mail.scheduler.ts` | VERIFIED | Registered in `RemindersModule` providers; module registered in `app.module.ts` |
| `apps/web/src/lib/reminder-notify.ts` | VERIFIED | Tauri vs browser branch, one-time permission, dedup with Web Locks |
| `apps/web/src/components/reminders/reminder-notifier.tsx` | VERIFIED | Mounted in `app-shell.tsx` |
| `apps/web/src/components/dashboard/widgets/reminder-widget.tsx` (+ form modal) | VERIFIED | Wired via registry, `page.tsx`, `widget-wrapper` FRAME_HEADER_TYPES, icon |
| `apps/desktop/src-tauri/src/lib.rs` | VERIFIED (code), see truth 3 | `server_origin_pattern`, `grant_server_notifications`, 12 tests |
### Key Link Verification
| From | To | Status | Details |
|------|----|--------|---------|
| `app-shell.tsx` | `ReminderNotifier` | WIRED | line 48 |
| `reminder-notify.ts` | plugin notification | WIRED | `invoke('plugin:notification\|notify', { options })` |
| `lib.rs` | Tauri runtime authority | WIRED | `add_capability` in `grant_server_notifications`, called in `setup()` and `save_server_url` |
| scheduler | `MailService.sendReminderEmail` | WIRED | claim then send, release on false |
| `snooze` | `emailSentAt`/`emailAttempts` reset | WIRED | `data: { dueAt, emailSentAt: null, emailAttempts: 0 }` |
### Data-Flow Trace (Level 4)
Widget and notifier data come from `listReminders()` -> `GET /reminders` -> Prisma query (live check returned real rows). FLOWING. No hardcoded/static fallbacks.
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| API reminders/mail/prisma/dashboard specs | `vitest run src/reminders src/mail src/prisma src/dashboard` | 17 files, 267 passed | PASS |
| Web reminder specs | `vitest run src/lib/reminder src/components/reminders src/components/dashboard src/messages` | 30 files, 410 passed | PASS |
| Full suites | api / web `vitest run` | 1570 / 1069 passed | PASS |
| cargo | `cargo test --lib`; `server_origin_` filter | 57 passed; 12 passed | PASS |
| Schema drift | `prisma migrate diff --exit-code` | exit 0 | PASS |
| RLS in DB | `pg_policies`, `pg_class` | both policies present, RLS+FORCE on | PASS |
| Live API | login, GET /reminders, GET /reminders/email-status, DELETE unknown id | 200, 200, 200, 404 | PASS |
| Type-check + lint, Biome | `turbo run type-check lint --force`; biome lint | 9/9; 55 / 82 warnings | PASS |
### Probe Execution
No probes declared. SKIPPED.
### Requirements Coverage
QUICK-260929-if2 (all decisions D-01..D-05, E-01..E-09) implemented as described; no REQUIREMENTS.md mapping.
### Anti-Patterns Found
None. No TBD/FIXME/XXX/TODO in the new files; no stubs; no debt markers. Working tree clean apart from the task directory.
### Notes / minor observations (non-blocking)
- Deviation 3 (badge uses `bg-status-warn/20` instead of the plan's solid fill) is documented and justified by contrast; flagged for a dark-mode visual check.
- Summary deviation 6 mentions the trailer as "Claude Sonnet 5.5"; the re-created commits carry "Claude Opus 5.5 (1M context)". Immaterial to the goal.
- `claimNotification` claims before showing: a browser whose permission is still 'default' at due time loses that occurrence's notification (still visible in the widget, highlighted). Consistent with the plan ("blocked notifications only show in the widget").
- Fingerprint fields (`covered_files`/`covered_digest`) were not generated because the fingerprint verb was not run in this environment.
## Human Verification Required
See frontmatter `human_verification` and `behavior_unverified_items`. Summary: (1) Windows toast in the tray, runtime capability accepted by Tauri, plus desktop+browser once-each; (2) live browser notification, permission prompt timing and two-tab dedup; (3) dark-mode look of the "Faellig" badge; (4) real SMTP flow (mailhog is not running locally).
## Gaps Summary
No gaps. All code-verifiable must-haves hold in the codebase and the automated gates reproduce the summary's numbers (tests, type-check, lint, Biome baseline, migrate diff, RLS state in the live DB). The single behavior-dependent truth that cannot be proven without a GUI is the actual desktop toast through the runtime Tauri capability; it is left PRESENT_BEHAVIOR_UNVERIFIED and routed to the Windows check, so the status is `human_needed`, not `passed`.
---
_Verified: 2026-09-29_
_Verifier: Claude (gsd-verifier)_
@@ -0,0 +1,67 @@
---
quick_id: 260929-lh3
type: quick
wave: 1
autonomous: true
---
# Quick 260929-lh3: Favoriten — eigene Symbol-Adresse wirkt nicht
## User reports (29.09.2026, alpha 8c644de)
1. Favorite with URL https://docuvita.ctl.local/server/services/web/ shows a black circle with a white "V"
instead of the page's favicon (visible in the browser tab).
2. Setting an explicit icon URL ("Symbol-Adresse", field `iconUrl`) to
https://nextcloud.com/c/uploads/2025/10/Nextcloud_01-standard-logo.png on a favorite does not change the shown icon.
## Measured facts (orchestrator, from inside the alpha api container)
- docuvita.ctl.local resolves (172.16.0.46). Server-side GET of the page returns **400** but the HTML contains
`<link rel="SHORTCUT ICON" type="image/png" href="/webclient/docuvita/resources/brandimage/favicon.ico" />`.
Server-side GET of that icon returns **404 text/html** (also with a Chrome User-Agent). Root /favicon.ico → 404.
→ docuvita refuses the files to the server; the browser can load them (user sees the icon in the tab).
- Discovery (`apps/api/src/favorites/icon-discovery.service.ts` `discoverFavoriteIconUrl`) ignores non-2xx HTML
(`fetchHtml` returns null) → falls back to `{origin}/favicon.ico`; proxy fails → browser direct
`{origin}/favicon.ico` shows the "V" (a real icon served to browsers at the root).
- Explicit `iconUrl` that the server cannot fetch → API answers 422 `iconUrlUnreachable` (quick 260923-lrr),
so the user cannot set the docuvita icon URL at all.
## Task 1: Explicit icon URL change must show immediately (bug 2)
- files: apps/api/src/favorites/*, apps/web/src/components/dashboard/widgets/favorites-widget.tsx, apps/web/src/lib/favorites-api.ts (+ tests)
- action: Reproduce locally (admin/admin123, favorites widget): set/change `iconUrl` to a reachable PNG (e.g. the Nextcloud URL,
and a second different one). Find why the tile keeps the old image — likely the icon proxy URL
(`/favorites/:id/icon?...`) does not change when `iconUrl` changes (browser/HTTP cache, Cache-Control on the proxy
response, `iconVersion` only bumped on upload, or the server returns a cached/discovered icon instead of the explicit one).
Fix at the root: the explicit `iconUrl` wins over discovery, and any change of `iconUrl` changes the image URL
(e.g. cache-buster from `iconVersion` bumped on every iconUrl change, or a hash of iconUrl). Add regression tests
(API: PATCH iconUrl bumps version / proxy serves new bytes; web: tile src changes when iconUrl changes).
- verify: api + web tests for favorites green.
- done: commit `fix(favorites): geaenderte Symbol-Adresse wird sofort angezeigt`.
## Task 2: Accept icon URLs the server cannot fetch; browser loads them directly (bug 1)
- action:
- API: an explicit `iconUrl` that is a valid http/https URL is stored even if the server cannot fetch it
(no more 422 for "unreachable"; keep validation of scheme/length and keep rejecting non-image responses only
when the server DID get a response with a non-image content type — decide and document). Keep SSRF guard for
server-side fetches unchanged.
- Web tile: chain for an explicit `iconUrl`: proxy image → on error the browser loads `iconUrl` directly
(`<img>` with referrerPolicy="no-referrer", only http/https) → letter fallback. Existing chain for discovered icons unchanged.
- Discovery improvement (small, safe): if the page answers non-2xx but returns HTML with a `<link rel=icon>`,
still use that icon URL (so docuvita-like servers yield `/webclient/.../favicon.ico`, which the browser can then load directly).
- Remove/adjust the now-unused `iconUrlUnreachable` error text (de/en) if no longer reachable.
- verify: api + web favorites tests green; type-check/lint green; biome web ≤ 55, api ≤ 82.
- done: commit `fix(favorites): Symbol-Adresse auch speichern, wenn nur der Browser sie laden kann`.
## Task 3: CHANGELOG + rebuild
- CHANGELOG `## Unveröffentlicht` → `### Behoben`: two plain-German bullets (Sie-Form) for both fixes.
- `docker compose up -d --build web api`.
- Commit `docs(changelog): Favoriten-Symbole`.
## Constraints
- Commit locally only, NEVER git push. Commits end with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`.
- Commit with explicit paths only.
- Browser check is done by the orchestrator.
@@ -0,0 +1,82 @@
---
quick_id: 260929-lh3
phase: quick
plan: 260929-lh3
subsystem: favorites
tags: [favorites, icons, icon-discovery, browser-fallback]
status: complete
commits: 3
plan_head_before: 8c644de5dad56a0394a14a00180a125919565246
plan_head_after: 0e72ad45f8833cb93ae9a8c0afa1f6d4e948e3e0
actuals:
tasks: 3
commits: 3
key-files:
modified:
- apps/api/src/favorites/favorites.service.ts
- apps/api/src/favorites/icon-discovery.service.ts
- apps/api/src/favorites/dto/create-favorite.dto.ts
- apps/api/src/favorites/dto/update-favorite.dto.ts
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx
- apps/web/src/lib/favorites-api.ts
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- CHANGELOG.md
---
# Quick 260929-lh3: Favoriten-Symbol-Adresse Summary
Explicit icon URLs are now stored even when the server cannot fetch them, the tile loads them directly in the browser, discovery uses `<link rel=icon>` from non-2xx HTML pages, and a newly entered icon URL replaces a previously uploaded icon.
## Commits
- 7188c5b `fix(favorites): geaenderte Symbol-Adresse wird sofort angezeigt` (Task 1)
- b15c746 `fix(favorites): Symbol-Adresse auch speichern, wenn nur der Browser sie laden kann` (Task 2)
- 0e72ad4 `docs(changelog): Favoriten-Symbole` (Task 3)
## Root cause, bug 2 ("Symbol-Adresse wirkt nicht")
Measured, not assumed:
- Local reproduction (API with curl, and the real widget in headless Chromium via CDP): changing `iconUrl` on a favorite WITHOUT an uploaded icon works. `iconVersion` is bumped, the tile is remounted, the `<img>` src changes (`?v=1` -> `?v=2`), and the bytes change (naturalWidth 626 -> 48). The proxy, `Cache-Control` and Next rewrite are not the cause.
- The alpha database (read-only psql) shows the save path works there too: favorite "Medon" holds the Nextcloud URL with `iconVersion = 1`. No favorite on alpha has `uploadedIconMime` set, and the alpha web image contains the `?v=` code.
- The real defect found in the code: `getIconBytes` always serves the uploaded file when `uploadedIconMime` is set, and `update()` never cleared it. A newly entered `iconUrl` was therefore saved but invisible for any favorite with an uploaded icon (the form even said "Ein hochgeladenes Symbol hat Vorrang"). Fixed: a new, different, non-empty `iconUrl` now clears `uploadedIconMime`, removes the file, and bumps `iconVersion`. An unchanged `iconUrl` (the form resends it on every save) leaves the upload alone.
- Caveat for the orchestrator: for the exact alpha "Medon" case (no upload) I could not reproduce a stale display; the alpha row and local browser behavior are both correct. The browser check on alpha (https, NPM, basic auth) remains the only place this can still show. If it still fails there with the new build, capture the network request of `/api-proxy/favorites/<id>/icon?v=1` in the alpha browser.
## Root cause, bug 1 (black circle with "V")
`fetchHtml` dropped every non-2xx response, so docuvita (answers the server with 400 but ships `<link rel="SHORTCUT ICON" href="/webclient/.../favicon.ico">`) fell back to `{origin}/favicon.ico`; the proxy failed and the browser showed the root favicon (the "V"). And an explicit `iconUrl` was rejected by the 422 fetch probe because docuvita returns 404 HTML to the server.
## Changes
- API: `assertIconUrlLoadable` (422) replaced by `assertIconUrlWellFormed` (http/https, <= 2048 chars, else 400); DTO `@MaxLength(2048)` on both DTOs. Decision, documented in code: even a response the server DID receive with a non-image type does not reject, because "server gets no image" does not mean "browser gets none". SSRF guard for server-side fetches is unchanged.
- Discovery: `fetchWithRedirectGuard` got `allowErrorStatus` (used only by the HTML search; `fetchIconBytes` stays strict). On a non-2xx page only `<link rel=...icon>` counts, not `og:image`.
- Web tile: proxy -> `iconUrl` direct (`referrerPolicy="no-referrer"`, http/https only) -> `{origin}/favicon.ico` (skipped when identical) -> letter. Existing chain for discovered icons behaves as before.
- Removed the `iconUrlUnreachable` reason, 422 handling and de/en texts; adjusted the upload hint (a newly entered address replaces an upload).
- CHANGELOG: two plain-German bullets under Unveröffentlicht / Behoben.
- Rebuilt `web` and `api` (`docker compose up -d --build`, healthy). Smoke test against the rebuilt API: URL the server cannot fetch is saved (proxy answers 502, tile falls back to browser), `javascript:` is rejected with 400. Test favorite deleted afterwards.
## Verification
- API favorites specs: 99 tests green. Web favorites widget, favorites-api and messages tests: 47 green. `tsc --noEmit` clean for web and api.
- Biome: web 55 warnings, api 82 warnings (at the limits, not above).
## Deviations from Plan
- [Rule 2 - Missing validation] Plan said "keep validation of scheme/length", but none existed for `iconUrl`; added form validation (service + DTO length) so the SSRF-relevant direct browser load never receives non-http(s) schemes.
- Task 1 needed no web change: the existing test "Speichern mit neuer Logo-Adresse ... ?v=1" already covers the src change; API regression tests were added.
- Commits were made on `main` as instructed (no worktree).
## Known Stubs
None.
## Self-Check: PASSED
Commits 7188c5b, b15c746, 0e72ad4 exist; SUMMARY location correct; PLAN/SUMMARY/STATE not committed.
## Browser-Pruefung (Orchestrator, 29.09.)
- Favorit „Claude“: iconUrl -> Nextcloud-PNG (PATCH 200), nach Neuladen Proxy-Bild ?v=5 mit 626 px Breite geladen.
- iconUrl -> docuvita-Brand-Icon (vom Dev-Host nicht aufloesbar): PATCH 200 (frueher 422), Kachel faellt sauber zurueck. Im Firmennetz laedt der Browser direkt.
- Zurueckgesetzt auf das urspruengliche Symbol.
@@ -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,5 @@
Name;Gegenkonto;Konto
Kaffee Bohnen 1kg;2010;4000
Tee Früchte;2011;4000
Kakao Pulver;2012;4001
Müsliriegel;2013;4001
1 Name Gegenkonto Konto
2 Kaffee Bohnen 1kg 2010 4000
3 Tee Früchte 2011 4000
4 Kakao Pulver 2012 4001
5 Müsliriegel 2013 4001
@@ -0,0 +1,5 @@
PersNr;Name;Menge;EK-Preis;Netto;Zu/Abschlag;MwSt;Zuschuss;Betrag;Abrechnung von;Abrechnung bis
1001;Müller, Jürgen;1;3,50;3,50;0,00;0,00;0,00;7,94;01.03.2026;31.03.2026
1002;Köhler, Änne;1;3,50;3,50;0,00;0,00;0,00;51,5;01.03.2026;31.03.2026
1003;Strauß, Lüder;1;3,50;3,50;0,00;0,00;0,00;abc;01.03.2026;31.03.2026
1004;Weiß, Özge;1;3,50;3,50;0,00;0,00;0,00;12,30;01.04.2026;30.04.2026
1 PersNr Name Menge EK-Preis Netto Zu/Abschlag MwSt Zuschuss Betrag Abrechnung von Abrechnung bis
2 1001 Müller, Jürgen 1 3,50 3,50 0,00 0,00 0,00 7,94 01.03.2026 31.03.2026
3 1002 Köhler, Änne 1 3,50 3,50 0,00 0,00 0,00 51,5 01.03.2026 31.03.2026
4 1003 Strauß, Lüder 1 3,50 3,50 0,00 0,00 0,00 abc 01.03.2026 31.03.2026
5 1004 Weiß, Özge 1 3,50 3,50 0,00 0,00 0,00 12,30 01.04.2026 30.04.2026
@@ -0,0 +1,5 @@
PersNr;Name;Menge;EK-Preis;Netto;Zu/Abschlag;MwSt;Zuschuss;Betrag;Abrechnung von;Abrechnung bis
1001;Müller, Jürgen;1;3,50;3,50;0,00;0,00;0,00;7,94;01.03.2026;31.03.2026
1002;Köhler, Änne;1;3,50;3,50;0,00;0,00;0,00;51,5;01.03.2026;31.03.2026
1005;Schmidt, Eva;1;3,50;3,50;0,00;0,00;0,00;0,00;01.03.2026;31.03.2026
1006;Öztürk, Can;1;3,50;3,50;0,00;0,00;0,00;23,10;01.03.2026;31.03.2026
1 PersNr Name Menge EK-Preis Netto Zu/Abschlag MwSt Zuschuss Betrag Abrechnung von Abrechnung bis
2 1001 Müller, Jürgen 1 3,50 3,50 0,00 0,00 0,00 7,94 01.03.2026 31.03.2026
3 1002 Köhler, Änne 1 3,50 3,50 0,00 0,00 0,00 51,5 01.03.2026 31.03.2026
4 1005 Schmidt, Eva 1 3,50 3,50 0,00 0,00 0,00 0,00 01.03.2026 31.03.2026
5 1006 Öztürk, Can 1 3,50 3,50 0,00 0,00 0,00 23,10 01.03.2026 31.03.2026
@@ -0,0 +1,309 @@
---
phase: quick-261002-icv
plan: 01
type: execute
wave: 1
depends_on: []
quick_id: 261002-icv
description: "Modul-Freigabe mit Stufe: Benutzen (USE, Standard) und Verwalten (MANAGE)"
date: 2026-10-02
files_modified:
# Task 1 — tracer: DB level -> access resolution -> guard -> kantine settings -> /modules/active canManage -> web tab
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20261002140000_module_grant_level/migration.sql
- apps/api/src/groups/migration-sql.spec.ts
- apps/api/src/module-registry/module-access.service.ts
- apps/api/src/module-registry/module-access.service.spec.ts
- apps/api/src/module-registry/module.guard.ts
- apps/api/src/module-registry/module.guard.spec.ts
- apps/api/src/groups/dto/create-module-grant.dto.ts
- apps/api/src/groups/dto/create-module-grant.dto.spec.ts
- apps/api/src/groups/module-grants.service.ts
- apps/api/src/groups/module-grants.service.spec.ts
- apps/api/src/kantine-datev/kantine-datev.controller.ts
- apps/api/src/kantine-datev/kantine-datev.controller.spec.ts
- apps/web/src/lib/api.ts
- apps/web/src/lib/use-module-capability.ts
- apps/web/src/app/(portal)/modules/kantine-datev/page.tsx
- apps/web/src/app/(portal)/modules/kantine-datev/kantine-datev.test.tsx
# Task 2 — remaining module conversions (API + their web pages), DKV gate
- apps/api/src/proxmox/proxmox.controller.ts
- apps/api/src/proxmox/proxmox-client.service.ts
- apps/api/src/handelsware-datev/handelsware-datev.controller.ts
- apps/api/src/handelsware-datev/handelsware-datev.controller.spec.ts
- apps/api/src/dkv/dkv.controller.ts
- apps/api/src/module-registry/module-manage-handlers.spec.ts
- apps/web/src/lib/module-access-actions.ts
- apps/web/src/components/modules/module-access-gate.tsx
- apps/web/src/components/modules/module-access-gate.test.tsx
- apps/web/src/app/(portal)/modules/handelsware-datev/page.tsx
- apps/web/src/app/(portal)/modules/handelsware-datev/components/ImportTab.tsx
- apps/web/src/app/(portal)/modules/handelsware-datev/handelsware-datev.test.tsx
- apps/web/src/app/(portal)/modules/proxmox/page.tsx
- apps/web/src/app/(portal)/modules/proxmox/settings/page.tsx
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx
- apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx
- apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx
- apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
# Task 3 — admin grant UI with level, docs, changelog, rebuild
- apps/web/src/app/(portal)/admin/modules/grants/page.tsx
- apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx
- apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx
- apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx
- docs/anleitung-administration.md
- docs/anleitung-anwender.md
- CHANGELOG.md
autonomous: true
requirements: [QUICK-261002-icv]
estimate:
tokens: 190000
raw_tokens: 190000
tasks: 3
confidence: low
must_haves:
truths:
- "An admin can set every module grant (group or single user) to Benutzen (USE) or Verwalten (MANAGE); grants that existed before the migration are USE"
- "A non-admin with MANAGE on a module can call that module's settings/administration endpoints (2xx) and sees its settings tab/controls; with only USE the same endpoints return 403 and the controls are hidden"
- "If a user has USE via one grant and MANAGE via another for the same module, the effective level is MANAGE"
- "MANAGE on module A grants nothing extra on module B, and a MANAGE grant on a deactivated module grants nothing"
- "Managers still cannot grant/revoke access, activate/deactivate modules, or reach users/groups/LDAP/SMTP/platform-wide tender settings (those stay @Roles(ADMIN, SUPER_ADMIN))"
- "ADMIN and SUPER_ADMIN keep full rights: they resolve to MANAGE on every active module"
- "DKV (dkv-fleet), admin-only for every handler today, becomes manager-level as a whole; USE-level DKV users get no API access (unchanged) and see an explanatory access page"
artifacts:
- path: "apps/api/prisma/migrations/20261002140000_module_grant_level/migration.sql"
provides: "ModuleGrantLevel enum + ModuleGrant.level NOT NULL DEFAULT 'USE'"
contains: "ModuleGrantLevel"
- path: "apps/api/src/module-registry/module.guard.ts"
provides: "ModuleManage(slug) decorator + MODULE_MANAGE_KEY enforced by ModuleGuard"
exports: ["ModuleGuard", "UseModule", "ModuleManage", "MODULE_SLUG_KEY", "MODULE_MANAGE_KEY"]
- path: "apps/api/src/module-registry/module-access.service.ts"
provides: "getModuleAccessLevels — single source of truth for access AND level"
- path: "apps/web/src/lib/use-module-capability.ts"
provides: "useCanManageModule(slug) display hook fed by GET /modules/active canManage"
- path: "apps/api/src/module-registry/module-manage-handlers.spec.ts"
provides: "metadata proof: converted handlers use ModuleManage, admin-only handlers keep @Roles"
key_links:
- from: "apps/api/src/module-registry/module.guard.ts"
to: "ModuleAccessService.getModuleAccessLevels"
via: "per-request memo request.moduleAccessLevels"
pattern: "getModuleAccessLevels"
- from: "apps/api/src/groups/dto/create-module-grant.dto.ts"
to: "ModuleGrantsService.grant -> ModuleGrant.level"
via: "POST /module-grants { level }"
pattern: "IsEnum\\(ModuleGrantLevel\\)"
- from: "GET /modules/active (canManage)"
to: "apps/web/src/lib/use-module-capability.ts -> module pages"
via: "useCanManageModule"
pattern: "useCanManageModule"
---
<objective>
Add a second level to every module grant: "Benutzen" (USE, default, today's behavior) and "Verwalten" (MANAGE = use the module AND change that module's own settings/configuration). Backend is the source of truth via a reusable `@ModuleManage('<slug>')` decorator on `ModuleGuard`; the web learns the effective level per module from `GET /modules/active` (`canManage`) and shows settings tabs/controls to admins AND managers. Admins assign the level in the grant matrix (groups) and the user access dialog (single users).
Locked decisions from the request (cited below as L-xx):
- L-01 Only admins grant modules and choose the level; module activation and grant endpoints stay admin-only; managers get no users/groups/global settings.
- L-02 MANAGE grantable to single users AND groups like today; USE+MANAGE via different grants → MANAGE wins.
- L-03 Admins/super-admins implicitly manage every module.
- L-04 Applies to ALL module-scoped admin-only handlers; truly system-wide/cross-module ones stay admin-only and are listed in the SUMMARY with reason. DKV: admin-only usage today → convert whole module to manager-level, document, never widen USE.
- L-05 Reusable decorator/guard, no per-controller ad-hoc checks; web gets the effective level (`canManage`) from the module-list endpoint.
- L-06 Prisma migration: enum level column on ModuleGrant, default USE, hand-written SQL, RLS gates/tests checked.
- L-07 Admin grant UI: level choice per grant (Benutzen / Verwalten), formal German "Sie" + English, level shown in grant lists.
- L-08 Module pages: settings tabs/controls for admins AND managers (replace role checks with per-module capability).
- L-09 Tests: guard/access resolution (user grant, group grant, mixed, admin bypass, no grant), controller metadata, DTO validation, web grant UI + one module settings tab; full api + web suites, tsc, biome on touched files.
- L-10 CHANGELOG (Unveröffentlicht, user-facing German) + docs/ where grants are explained.
- L-11 Local migration via container IP + `docker compose up -d --build api web`; no push.
- L-12 NestJS static routes before `:id` (no new routes planned); never mention "Mandant"/tenant in new user-facing texts.
Output: migration, level-aware access service + guard + decorator, converted controllers (kantine-datev, handelsware-datev, proxmox, dkv-fleet), admin grant UI with level, capability-driven module pages, tests, docs, changelog, rebuilt local stack. Three atomic commits on main, NOT pushed.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
Discovered facts the executor can rely on (verified during planning):
- `RolesGuard`, `JwtAuthGuard`, `TenantGuard` are global `APP_GUARD`s (apps/api/src/app.module.ts). Any handler still carrying `@Roles(ADMIN, SUPER_ADMIN)` blocks managers regardless of other guards — converted handlers MUST drop `@Roles`.
- `ModuleRegistryModule` exports `ModuleRegistryService`, `ModuleAccessService`, `ModuleGuard`; DkvModule, KantineDatevModule, HandelswareDatev, Proxmox modules already import it (no DI change needed).
- Module slugs: `kantine-datev`, `handelsware-datev`, `proxmox`, `dkv-fleet` (apps/api/src/dkv/dkv.seed.ts), `tender-radar`, `cert-manager`, `domaincheck`.
- `ModuleAccessService.getAccessibleModuleIds` is consumed by ModuleGuard, `getCatalogFlags`, `findAccessibleModules` and `apps/api/src/dashboard/dashboard.service.ts` — its signature must stay.
- `rls-access-inventory.spec.ts` keys on (file, model) pairs and bound/unbound state: every new Prisma access in module-access.service.ts / module-grants.service.ts MUST go through the existing `forTenant(...)` client of that method, and must not add `include:`/relation `select:` to new models.
- ValidationPipe is global with `whitelist: true, transform: true` (apps/api/src/main.ts).
- Web module pages are reachable via `/modules/<slug>` (own layout with `ModuleAccessGate`) AND via the sidebar link `/modules/<category>/<slug>` (generic `[category]/[moduleSlug]/page.tsx` → `ModuleAccessGate` → `ModuleShell`). Module page components are client components using `useAuthStore`.
- i18n namespaces: matrix uses `admin.groups.grants` (has `matrixCheckboxLabel`); UserAccessModal uses `admin.users.grants` (has `directCheckboxLabel`); matrix page texts `adminModules.grants`; gate texts `modules.accessDenied`; `proxmox.settings.accessDeniedText`; `kantineDatev.notConfigured.user`; `handelswareDatev.notConfigured.user`. `apps/web/src/messages/umlaut-guard.spec.ts` requires real umlauts in de.json.
- Migration convention: hand-written SQL with German header comment (model: apps/api/prisma/migrations/20261002120000_kantine_datev_config/migration.sql); latest migration is 20261002130000_handelsware_datev.
- Commits: German subject, conventional prefix, end with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`. Never push.
@apps/api/src/module-registry/module.guard.ts
@apps/api/src/module-registry/module-access.service.ts
@apps/api/src/groups/module-grants.service.ts
@apps/api/src/groups/dto/create-module-grant.dto.ts
@apps/api/src/kantine-datev/kantine-datev.controller.ts
@apps/web/src/components/modules/module-access-gate.tsx
@apps/web/src/lib/module-access-actions.ts
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer — grant level end-to-end: DB column → access levels → ModuleManage guard → kantine settings → /modules/active canManage → kantine settings tab</name>
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261002140000_module_grant_level/migration.sql, apps/api/src/groups/migration-sql.spec.ts, apps/api/src/module-registry/module-access.service.ts, apps/api/src/module-registry/module-access.service.spec.ts, apps/api/src/module-registry/module.guard.ts, apps/api/src/module-registry/module.guard.spec.ts, apps/api/src/groups/dto/create-module-grant.dto.ts, apps/api/src/groups/dto/create-module-grant.dto.spec.ts, apps/api/src/groups/module-grants.service.ts, apps/api/src/groups/module-grants.service.spec.ts, apps/api/src/kantine-datev/kantine-datev.controller.ts, apps/api/src/kantine-datev/kantine-datev.controller.spec.ts, apps/web/src/lib/api.ts, apps/web/src/lib/use-module-capability.ts, apps/web/src/app/(portal)/modules/kantine-datev/page.tsx, apps/web/src/app/(portal)/modules/kantine-datev/kantine-datev.test.tsx</files>
<behavior>
- ModuleAccessService.getModuleAccessLevels: USER with only a direct USE grant → Map {m1: USE}; direct USE + group MANAGE on same module → MANAGE (L-02); MANAGE only via group → MANAGE; MANAGE grant on a module whose TenantModuleActivation is inactive → absent; ADMIN and SUPER_ADMIN → every active module = MANAGE without grant queries (L-03); no grants → empty Map; rows without a `level` field (old mocks) count as USE.
- getAccessibleModuleIds still returns exactly the key set (existing spec cases keep passing); findAccessibleModules rows carry `canManage` true/false.
- ModuleGuard: @UseModule route + USE → allowed; @ModuleManage route + USE → ForbiddenException; + MANAGE → allowed; admin → allowed; no grant → ForbiddenException; MANAGE on module "a" while the route is ModuleManage('b') → ForbiddenException; a second canActivate on the same request object reuses request.moduleAccessLevels and does not call the service again; ModuleManage(slug) sets MODULE_SLUG_KEY, MODULE_MANAGE_KEY=true and guards metadata containing ModuleGuard.
- CreateModuleGrantDto: level 'USE', 'MANAGE' or omitted → valid; 'ADMIN' and lowercase 'manage' → validation error on `level`.
- ModuleGrantsService.grant: no level → create with USE; level MANAGE → create with MANAGE; existing USE row + level MANAGE → update to MANAGE and log line; existing MANAGE row + no level → no update, existing returned (repeat click never downgrades); P2002 race + level given → same update rule.
- migration-sql.spec: the new migration contains the CREATE TYPE and ADD COLUMN statements below.
- Kantine controller metadata: saveSettings has MODULE_MANAGE_KEY true, slug 'kantine-datev', no ROLES_KEY; getSettings/preview/export have no MODULE_MANAGE_KEY.
- Kantine web page: USER whose /modules/active entry has canManage true sees the "Einstellungen" tab; USER with canManage false does not; ADMIN sees it without any /modules/active fetch.
</behavior>
<action>
**Schema + migration (L-06).** In `apps/api/prisma/schema.prisma` add `enum ModuleGrantLevel { USE MANAGE }` next to `enum MembershipSource`, and on `model ModuleGrant` add `level ModuleGrantLevel @default(USE)` with a German comment (261002-icv: Freigabestufe; USE = Benutzen, Standard und Bestand; MANAGE = Verwalten — Modul benutzen und dessen eigene Einstellungen ändern; Freigaben erteilen bleibt Administratoren vorbehalten). Hand-write `apps/api/prisma/migrations/20261002140000_module_grant_level/migration.sql`: German header comment in the style of 20261002120000_kantine_datev_config (purpose; existing rows become USE through the DEFAULT, so nobody gains rights; no new table, the existing ModuleGrant row policies filter rows not columns and stay unchanged, so rls-coverage needs nothing; PostgreSQL grants USAGE on new types to PUBLIC, so tessera_app can use the enum; switch-is-off note). Statements, exactly: `CREATE TYPE "ModuleGrantLevel" AS ENUM ('USE', 'MANAGE');` and `ALTER TABLE "ModuleGrant" ADD COLUMN "level" "ModuleGrantLevel" NOT NULL DEFAULT 'USE';`. Add a describe block to `apps/api/src/groups/migration-sql.spec.ts` using its `readMigrationSql('_module_grant_level')` helper asserting both statements. Run `pnpm --filter @tessera/api exec prisma generate`. Apply locally (L-11): IP via `docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1`, then `DATABASE_URL="postgresql://tessera:tessera_dev@<IP>:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy`, confirm with `prisma migrate status` and a drift check `prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --exit-code` (same env, run in apps/api via the filter) — exit code 0 means schema and SQL agree.
**Access resolution (L-02, L-03, L-05).** In `module-access.service.ts` add `getModuleAccessLevels(tenantId, userId, role): Promise<Map<string, ModuleGrantLevel>>` as the single resolution. Admin/SUPER_ADMIN branch: same activation query as today, every moduleId → MANAGE. Other roles: the same two grant queries as today (direct `userId`, group via `group: { memberships: { some: { userId } } }`), now selecting `{ moduleId: true, level: true }`; merge so MANAGE wins (any value other than 'MANAGE' counts as USE); intersect with the same active-activation query as today. Use the one `forTenant` client of the method for all accesses (rls-access-inventory). Rewrite `getAccessibleModuleIds` to return the key set of `getModuleAccessLevels` (signature unchanged). `findAccessibleModules` returns each catalog row spread plus `canManage: level === 'MANAGE'` (catalog query stays on `this.prisma` as today). Update the class/method doc comments (Freigabestufe, 261002-icv). Update the doc comment of `GET /modules/active` in module-registry.controller.ts only if you touch it — no code change there is needed.
**Guard + decorator (L-05).** In `module.guard.ts` export `MODULE_MANAGE_KEY = 'moduleManage'`. In `canActivate`, after the existing slug/tenant/user/findBySlug steps: read `requireManage` with `reflector.getAllAndOverride<boolean>(MODULE_MANAGE_KEY, [handler, class])`; take `request.moduleAccessLevels` if it is already a Map (class-level @UseModule plus handler-level @ModuleManage run this guard twice per request), else call `getModuleAccessLevels`; no entry for module.id → existing "not accessible" ForbiddenException; requireManage and level not MANAGE → ForbiddenException with message `Module '<slug>' requires manage permission`; store `request.moduleAccessLevels` and keep setting `request.moduleAccessIds` (Set of keys). Export `ModuleManage(slug)` = applyDecorators(SetMetadata(MODULE_SLUG_KEY, slug), SetMetadata(MODULE_MANAGE_KEY, true), UseGuards(ModuleGuard)) with a German JSDoc: replaces `@Roles(ADMIN, SUPER_ADMIN)` for module-scoped configuration; usable on a handler inside a @UseModule controller or on a whole controller; admins pass via the D-03 short-circuit; never combine with @Roles on the same handler (global RolesGuard would still block managers); tenant/user/role only from the JWT (T-15-10). Update `module.guard.spec.ts` mocks from `getAccessibleModuleIds` to `getModuleAccessLevels` and add the behavior cases.
**Grant write side (L-01, L-02).** `CreateModuleGrantDto`: add optional `level?: ModuleGrantLevel` with `@IsOptional()` and `@IsEnum(ModuleGrantLevel)` (import from @prisma/client); doc comment: level is ignored on DELETE. New `apps/api/src/groups/dto/create-module-grant.dto.spec.ts` following `apps/api/src/custom-modules/dto/custom-module.dto.spec.ts` (plainToInstance + validate). `ModuleGrantsService.grant` accepts `level?: ModuleGrantLevel`; keep the existing check order (XOR, tenant cross-check, activation). Then find the existing row for the exact target with `tenantPrisma.moduleGrant.findFirst` (same where as the current P2002 branch): if it exists and a level was given that differs → `tenantPrisma.moduleGrant.update({ where: { id: existing.id }, data: { level } })`, log `Grant-Stufe geändert: tenant=… module=… <target> level=<level>`, return it; if it exists otherwise → return it unchanged (no level given never changes the level). If not, create with `level: level ?? 'USE'` and add `level=` to the existing log line; the P2002 branch applies the same rule. Replace the outdated class comment sentence about the record carrying no level (old D-04) with: since 261002-icv the row carries `level`; only this admin-only service sets it. Controller unchanged (stays admin-only, L-01). Extend `module-grants.service.spec.ts` with the grant cases.
**First converted handler.** In `kantine-datev.controller.ts` replace `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` on `saveSettings` with `@ModuleManage('kantine-datev')`, remove now-unused `Role`/`Roles` imports, and update the header comment (settings: administrators and users with Freigabestufe Verwalten, 261002-icv). Update `kantine-datev.controller.spec.ts` (its ROLES_KEY expectation on saveSettings becomes the MODULE_MANAGE_KEY expectation).
**Web capability (L-05, L-08).** `apps/web/src/lib/api.ts`: add `canManage?: boolean` to `ApiModule`. New `apps/web/src/lib/use-module-capability.ts` exporting `useCanManageModule(moduleSlug: string): boolean | null`: null while `useAuthStore` user is null; true immediately for ADMIN/SUPER_ADMIN (mirrors the backend short-circuit, no fetch); otherwise one fetch of `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/modules/active` with `credentials: 'include'`, `cache: 'no-store'`, result true only if an entry has this slug AND `canManage === true`; non-ok or thrown → false; ignore results after unmount. German doc comment: display only, ModuleGuard is the binding check. In `kantine-datev/page.tsx` replace the `isAdmin` role check with `useCanManageModule('kantine-datev') === true` (settings tab + BillingTab hint), renaming the BillingTab prop `isAdmin` → `canManage`. Extend `kantine-datev.test.tsx`: stub global fetch (vi.stubGlobal, unstub in afterEach) answering `/modules/active` with `[{ slug: 'kantine-datev', canManage: true }]` or `canManage: false`, plus an ADMIN case asserting fetch was not called; existing ADMIN/USER cases must stay green.
Biome-lint the touched files (`pnpm exec biome lint <files>` from repo root), commit `feat(module-grants): Freigabestufe Verwalten – Datenbank, Zugriffsprüfung und Kantinen-Einstellungen` (attribution line). Do not push.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/module-registry src/groups src/kantine-datev rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run kantine-datev && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit</automated>
</verify>
<done>Migration applied locally (`prisma migrate status` up to date, drift check exit 0); listed api/web tests green incl. rls gates; both type-checks clean; a USER with a MANAGE grant passes `PUT /modules/kantine-datev/settings` guard logic (unit-proven) and sees the Einstellungen tab; one commit, not pushed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Convert remaining module-scoped admin handlers (proxmox, handelsware-datev, dkv-fleet) + their web pages and DKV access page</name>
<files>apps/api/src/proxmox/proxmox.controller.ts, apps/api/src/proxmox/proxmox-client.service.ts, apps/api/src/handelsware-datev/handelsware-datev.controller.ts, apps/api/src/handelsware-datev/handelsware-datev.controller.spec.ts, apps/api/src/dkv/dkv.controller.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, apps/web/src/lib/module-access-actions.ts, apps/web/src/components/modules/module-access-gate.tsx, apps/web/src/components/modules/module-access-gate.test.tsx, apps/web/src/app/(portal)/modules/handelsware-datev/page.tsx, apps/web/src/app/(portal)/modules/handelsware-datev/components/ImportTab.tsx, apps/web/src/app/(portal)/modules/handelsware-datev/handelsware-datev.test.tsx, apps/web/src/app/(portal)/modules/proxmox/page.tsx, apps/web/src/app/(portal)/modules/proxmox/settings/page.tsx, apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx, apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx, apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx, apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx, apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
<behavior>
- module-manage-handlers.spec (metadata, L-04/L-09): DkvController class has MODULE_SLUG_KEY 'dkv-fleet', MODULE_MANAGE_KEY true, guards metadata (GUARDS_METADATA from @nestjs/common/constants) containing ModuleGuard, and none of its prototype methods has ROLES_KEY; ProxmoxController create/update/remove/poll/test/testDraft have MODULE_MANAGE_KEY true + slug 'proxmox' and no ROLES_KEY, `list` has neither; KantineDatevController.saveSettings and HandelswareDatevController.saveSettings are manage-level; STAY ADMIN-ONLY: TendersController getSourceConfig/saveSourceConfig/pollNow have ROLES_KEY [ADMIN, SUPER_ADMIN] and no MODULE_MANAGE_KEY; ModuleGrantsController matrix/userAccess/create/remove and ModuleRegistryController activate/deactivate have ROLES_KEY [ADMIN, SUPER_ADMIN].
- Gate: slug 'dkv-fleet' with level 'manage' → children; 'use' → denied page with the manage-required body text; 'none' or thrown → standard denied text; other slugs keep calling checkModuleAccess exactly once (existing tests unchanged).
- Handelsware page: USER with canManage true sees the settings tab; false does not.
- Proxmox: USER with canManage true sees the manager controls (poll button / settings link / enabled form); USER with canManage false keeps today's read-only view; ADMIN/SUPER_ADMIN unchanged.
</behavior>
<action>
**API conversions (L-04, L-05).** `proxmox.controller.ts`: replace `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` on create, update, remove, poll, test and testDraft with `@ModuleManage('proxmox')`; `GET servers` stays USE (class @UseModule); drop unused imports; update the header comment. `proxmox-client.service.ts`: comment only — the SSRF safeguard is now "administrator or a user the administrator explicitly granted Verwalten for the Proxmox module (`@ModuleManage('proxmox')`, 261002-icv)". `handelsware-datev.controller.ts`: `saveSettings` → `@ModuleManage('handelsware-datev')` (account routes are already USE-level, leave them), update header comment and `handelsware-datev.controller.spec.ts` (ROLES_KEY expectation → MODULE_MANAGE_KEY). `dkv.controller.ts`: today it has NO @UseModule and every one of its 11 handlers carries @Roles(ADMIN, SUPER_ADMIN) — i.e. DKV usage itself is admin-only. Per L-04 put `@ModuleManage('dkv-fleet')` on the class, remove all 11 per-handler @Roles plus the `Role`/`Roles` imports, and rewrite the header comment: whole module is Verwalten-level; USE-level users keep getting 403 exactly as before (not widened); the class guard additionally requires the dkv-fleet activation (the web page already required it). No route order changes anywhere (L-12).
**Leave admin-only (do not edit; list in SUMMARY with reason):** tenders `getSourceConfig`/`saveSourceConfig`/`pollNow` (platform-wide singleton poll config and upstream fetch for the whole installation, not module-per-company configuration); tenders `createRssFeed` scope 'platform' and `removeRssFeed` platform-feed branch (platform-wide feeds shown to every user of the installation); module-registry activate/deactivate and all module-grants routes (L-01); custom-modules shared entries (not a registry module, no @UseModule, sidebar entries for everyone); groups/user/ldap/settings (SMTP)/tenant/welcome-mail controllers (global administration). Confirm with `grep -rn "Roles(" apps/api/src --include=*.ts` that cert-manager, domaincheck and reminders have no admin-only handler; mention that in the SUMMARY.
New `apps/api/src/module-registry/module-manage-handlers.spec.ts` with the metadata assertions from behavior (if importing several controllers in one file proves problematic, split per controller next to it and adjust files list in the SUMMARY).
**Web (L-08).** `module-access-actions.ts`: add `getModuleAccessLevel(moduleSlug): Promise<'none' | 'use' | 'manage'>` (same cookie forwarding and fail-closed handling, reading `canManage` from /modules/active); make `checkModuleAccess` delegate (`!== 'none'`) with unchanged signature. `module-access-gate.tsx`: add `MANAGE_ONLY_MODULE_SLUGS = new Set(['dkv-fleet'])` with a German comment pointing at the class-level @ModuleManage on DkvController; for those slugs call getModuleAccessLevel ('manage' → children, 'use' → ModuleAccessDenied with body `t('accessDenied.manageRequiredBody')`, else the standard denied texts); all other slugs keep the current checkModuleAccess path. Extend `module-access-gate.test.tsx`. Handelsware: `useCanManageModule('handelsware-datev') === true` replaces isAdmin in page.tsx; rename ImportTab prop `isAdmin` → `canManage`. Proxmox: `useCanManageModule('proxmox')` in page.tsx and settings/page.tsx (settings page shows its existing loading placeholder while the value is null, the denied text when false); rename the `isAdmin` props of ServerCard and ServerForm to `canManage` and update their tests and code comments (e.g. the idle-text comment about the poll endpoint). Update `proxmox-page-roles.test.tsx` (stub fetch for USER cases: `[]` for read-only, `[{ slug: 'proxmox', canManage: true }]` for the new manager case) and add one handelsware manager case.
**Texts (de + en, formal "Sie", real umlauts, no "Mandant"/tenant in new wording, L-12):** `proxmox.settings.accessDeniedText` → „Diese Seite steht Administratoren und Benutzern zur Verfügung, die dieses Modul verwalten dürfen.“ / "This page is available to administrators and to users who may manage this module."; in `kantineDatev.notConfigured.user` and `handelswareDatev.notConfigured.user` replace only the leading „Ein Administrator muss“ with „Ein Administrator oder jemand, der dieses Modul verwalten darf, muss“ (rest verbatim; en: "An administrator or someone who may manage this module must …"); new `modules.accessDenied.manageRequiredBody` → „Dieses Modul steht nur Benutzern zur Verfügung, die es verwalten dürfen. Wenden Sie sich an Ihren Administrator.“ / "This module is only available to users who may manage it. Please contact your administrator.".
Biome-lint touched files, commit `feat(module-grants): Proxmox, Handelsware und DKV mit Freigabestufe Verwalten` (attribution line). Do not push.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/module-registry src/kantine-datev src/handelsware-datev src/proxmox src/dkv src/tenders src/groups && pnpm --filter @tessera/web exec vitest run proxmox handelsware-datev kantine-datev module-access umlaut && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test -z "$(grep -nE '^\s*@Roles\(' apps/api/src/dkv/dkv.controller.ts apps/api/src/proxmox/proxmox.controller.ts apps/api/src/kantine-datev/kantine-datev.controller.ts apps/api/src/handelsware-datev/handelsware-datev.controller.ts)" && grep -qE '^\s*@Roles\(' apps/api/src/tenders/tenders.controller.ts</automated>
</verify>
<done>No decorator-level @Roles left in the four converted controllers, tenders keeps its @Roles on getSourceConfig/saveSourceConfig/pollNow (proven by the metadata spec); metadata spec proves converted vs. admin-only handlers; proxmox/handelsware/kantine pages and DKV gate follow canManage; tests and type-checks green; one commit, not pushed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Admin grant UI with level (matrix + user dialog), docs, changelog, full suites, local rebuild</name>
<files>apps/api/src/groups/module-grants.service.ts, apps/api/src/groups/module-grants.service.spec.ts, apps/web/src/app/(portal)/admin/modules/grants/page.tsx, apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx, apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx, apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, docs/anleitung-administration.md, docs/anleitung-anwender.md, CHANGELOG.md</files>
<behavior>
- getMatrix: each grant item is { moduleId, groupId, level }.
- getUserAccess: each module row keeps { module, viaGroups, direct } and adds `directLevel` (level of the direct grant or null) and `manageViaGroups` (display names of the groups granting MANAGE, subset of viaGroups).
- Matrix: a granted cell shows a level select with Benutzen/Verwalten reflecting the response; choosing Verwalten POSTs /module-grants with { moduleId, groupId, level: 'MANAGE' }; a failed POST rolls the select back; ticking an empty cell POSTs without level and shows Benutzen.
- User dialog: direct grant row shows a level select; changing it POSTs { moduleId, userId, level }; a group chip granting MANAGE shows the „Verwalten“ marker.
</behavior>
<action>
**API read side for the UI (L-07).** In `module-grants.service.ts` `getMatrix`: add `level: true` to the group-grant select and return `level` per grant item. `getUserAccess`: select `{ moduleId: true, level: true }` for direct grants; group grants already include the row (use its `level`); add `directLevel` and `manageViaGroups` per row as in behavior, leaving existing keys untouched. Keep every access on the method's `tenantPrisma` (rls-access-inventory). Extend the spec.
**Matrix page (`admin/modules/grants/page.tsx`).** State becomes a Map cellKey → 'USE' | 'MANAGE'. For a granted cell render, next to the checkbox, a compact native select (options from `admin.groups.grants.levelUse` / `levelManage`, aria-label `levelSelectLabel` with module + group) styled like the page inputs and disabled while that cell is saving; on change POST `/module-grants` with `{ moduleId, groupId, level }` using the same optimistic update + rollback + error banner as `toggleGrant`. Ticking an empty cell keeps POSTing without level (server default USE) and stores USE locally; revoke unchanged. Below the table render `adminModules.grants.levelExplanation` and the rewritten `adminNote`. Extend `grants-matrix.test.tsx` (its existing fetch-stub pattern).
**User dialog (`UserAccessModal.tsx`).** Extend the row type with `directLevel` and `manageViaGroups`. Group chips whose name is in `manageViaGroups` show the suffix marker `t('manageMarker')`. When `direct` is true, show a level select next to the checkbox (option labels from `admin.groups.grants`, aria-label `t('directLevelLabel', { module, user })`); change POSTs `{ moduleId, userId, level }` with the existing optimistic/rollback pattern; ticking the checkbox sets `directLevel` 'USE' locally. Extend `user-access-modal.test.tsx`.
**Texts (de/en, formal "Sie", no "Mandant"/tenant, real umlauts):** `admin.groups.grants.levelUse` „Benutzen“ / "Use"; `admin.groups.grants.levelManage` „Verwalten“ / "Manage"; `admin.groups.grants.levelSelectLabel` „Stufe für {module} in Gruppe {group}“ / "Level for {module} in group {group}"; `admin.users.grants.directLevelLabel` „Stufe der direkten Freigabe von {module} für {user}“ / "Level of the direct grant of {module} for {user}"; `admin.users.grants.manageMarker` „Verwalten“ / "Manage"; `adminModules.grants.levelExplanation` „„Benutzen“: Das Modul öffnen und damit arbeiten. „Verwalten“: zusätzlich die Einstellungen dieses Moduls ändern. Freigaben vergeben und Module aktivieren dürfen weiterhin nur Administratoren. Hat jemand über mehrere Wege Zugriff, gilt die höhere Stufe.“ (en equivalent); rewrite `adminModules.grants.adminNote` → „Administratoren haben immer Zugriff auf alle aktiven Module und dürfen deren Einstellungen ändern – diese Matrix betrifft nur Benutzer ohne Administratorrechte.“ / "Administrators always have access to all active modules and may change their settings — this matrix only affects users without administrator rights.".
**Docs (L-10, German, formal, new sentences without "Mandant").** `docs/anleitung-administration.md`: chapter 1 bullet on admin access (mention that the level Verwalten exists and admins implicitly have it); chapter 2 "Details zu Gruppen und Modulzugriff" (level select on the direct grant, Verwalten marker on group chips); chapter 5 new subsection "Freigabestufen: Benutzen und Verwalten" — what Verwalten unlocks per module (Kantinenabrechnung: Einstellungen; Handelsware: Einstellungen; Proxmox: Server anlegen, ändern, löschen, prüfen, sofort abrufen; DKV-Rechnung: das gesamte Modul, Benutzen allein reicht dort nicht), what stays admin-only (Freigaben erteilen und Stufe wählen, Module aktivieren, Benutzer, Gruppen, LDAP, SMTP, Willkommensmail, gemeinsame eigene Module, Ausschreibungs-Radar-Plattformeinstellungen: Abrufintervall, „Jetzt abrufen“, plattformweite RSS-Feeds), the higher level wins, existing grants are Benutzen; update the Freigaben-Matrix paragraph (level select per granted cell) and add a Fehlersuche row (user does not see the Einstellungen tab → grant is only Benutzen). `docs/anleitung-anwender.md`: after the Aktivieren/Freigeben paragraph a short paragraph on Verwalten; DKV-Rechnung section note (needs Verwalten); Proxmox server line and the Kantinenabrechnung/Handelsware "Einmalig einrichten" lines → "Administrator oder wer das Modul verwalten darf". `CHANGELOG.md` → under „## Unveröffentlicht“ / „### Neu“ one bullet in the existing user-facing style: two levels Benutzen (as before) and Verwalten; managers change the module's own settings (examples: Kantinenabrechnung, Handelsware, Proxmox-Server) without being administrators; admins choose the level per group in the Freigaben-Matrix or per user in the user details; existing grants stay Benutzen; the DKV-Rechnung module is available to administrators and to users with Verwalten.
**Finish.** Run full suites `pnpm --filter @tessera/api test` and `pnpm --filter @tessera/web test`, both type-checks, biome lint on every file touched in this plan. Rebuild the local stack from the repo root with `docker compose up -d --build api web`, then check `docker compose logs api --tail 120` for a clean start (no migration/Prisma error). No browser check (orchestrator does it), no push. Commit `feat(module-grants): Stufe Verwalten in Freigaben-Matrix und Benutzerdetails, Doku und Changelog` (attribution line). In the SUMMARY list: converted handlers per controller, the admin-only handlers with reasons (from Task 2), the DKV behavior change (now additionally requires activation; USE-level users see the explanatory page), and test counts.
</action>
<verify>
<automated>pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && node -e 'const de=require("./apps/web/src/messages/de.json"),en=require("./apps/web/src/messages/en.json");const ks=["admin.groups.grants.levelUse","admin.groups.grants.levelManage","admin.groups.grants.levelSelectLabel","admin.users.grants.directLevelLabel","admin.users.grants.manageMarker","adminModules.grants.levelExplanation","adminModules.grants.adminNote","modules.accessDenied.manageRequiredBody","proxmox.settings.accessDeniedText"];const g=(o,k)=>k.split(".").reduce((a,p)=>a&&a[p],o);for(const k of ks)for(const m of [de,en]){const v=g(m,k);if(typeof v!=="string"||/mandant|tenant/i.test(v)){console.error("bad key",k);process.exit(1)}}' && grep -q "Verwalten" CHANGELOG.md && docker compose ps --status running --services | grep -qx api && docker compose ps --status running --services | grep -qx web</automated>
</verify>
<done>Admins choose Benutzen/Verwalten per group cell and per direct user grant, lists show the level; full api + web suites, both type-checks and biome green; docs and changelog updated; api and web containers rebuilt and running with a clean api log; commit on main, not pushed; SUMMARY lists admin-only handlers with reasons.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser → API (module routes) | untrusted caller; identity/role/tenant only from the validated JWT |
| admin browser → POST /module-grants | the `level` field is client-supplied and decides future privileges |
| web UI capability display | `canManage` in the page is display only; ModuleGuard is binding |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-icv-01 | Elevation of Privilege | module-grants.controller.ts | high | mitigate | Grant/revoke routes keep @Roles(ADMIN, SUPER_ADMIN) (L-01); metadata spec in module-manage-handlers.spec.ts asserts it, so a manager cannot grant themselves or others |
| T-icv-02 | Elevation of Privilege | ModuleGuard / ModuleManage | high | mitigate | Level resolved server-side from ModuleGrant rows via getModuleAccessLevels using JWT userId/role/tenantId only; never from body/query; guard spec covers USE→403, MANAGE→ok, no grant→403 |
| T-icv-03 | Tampering | CreateModuleGrantDto.level | medium | mitigate | @IsOptional + @IsEnum(ModuleGrantLevel); DTO spec rejects 'ADMIN' and lowercase values; global whitelist ValidationPipe |
| T-icv-04 | Elevation of Privilege | cross-module scope | high | mitigate | MANAGE checked for the route's own slug's moduleId only; guard test: MANAGE on A → 403 on ModuleManage('b') |
| T-icv-05 | Elevation of Privilege | deactivated module | medium | mitigate | Levels intersected with active TenantModuleActivation (same as today); service test for inactive-module MANAGE grant |
| T-icv-06 | Elevation of Privilege | converted handlers still carrying @Roles or missing guard | high | mitigate | Verify gate: no decorator-level @Roles in the 4 converted controllers; metadata spec asserts MODULE_MANAGE_KEY + ModuleGuard on each converted handler/class |
| T-icv-07 | Elevation of Privilege | platform-wide tender settings, activation, users/groups/SMTP | high | mitigate | Left on @Roles(ADMIN, SUPER_ADMIN); metadata spec (tenders getSourceConfig/saveSourceConfig/pollNow, module-grants, activate/deactivate keep ROLES_KEY) plus the tenders @Roles presence gate prove nothing global was widened |
| T-icv-08 | Elevation of Privilege | DKV (dkv-fleet) | medium | mitigate | Whole controller @ModuleManage('dkv-fleet'): USE-level users stay at 403 as before (no silent widening); web gate shows explanatory page |
| T-icv-09 | Information Disclosure / SSRF | proxmox server addresses entered by managers | medium | accept | Proxmox targets are private by design (T-DHH-02, no address filter possible); the admin explicitly delegates via Verwalten; documented in proxmox-client.service.ts comment and admin docs |
| T-icv-10 | Repudiation | grant level changes | low | mitigate | Logger lines for create and level change include level=<level> (D-23 pattern, no audit table) |
| T-icv-11 | Tampering | repeated grant click downgrading MANAGE | low | mitigate | grant() never changes level when no level is sent; service test |
| T-icv-SC | Tampering | npm/pip/cargo installs | low | accept | No new packages in this plan; nothing to verify |
</threat_model>
<verification>
- Task verify commands above all pass; Task 3 runs the full api + web suites (includes rls-coverage, rls-access-inventory, umlaut-guard, migration-sql specs).
- `prisma migrate status` up to date locally; drift check exit 0.
- `docker compose ps` shows api and web running after rebuild; api log clean.
- Source coverage audit:
| Source item | Covered by |
|-------------|------------|
| GOAL: second grant level USE/MANAGE, managers change own module settings | Tasks 1-3 |
| L-01 admin-only grants/activation/global admin | Task 1 (controller untouched), Task 2 (metadata spec), T-icv-01/07 |
| L-02 users + groups, MANAGE wins | Task 1 (service + tests), Task 3 (UI both places) |
| L-03 admins implicit manage | Task 1 (short-circuit → MANAGE, hook short-circuit) |
| L-04 all module-scoped handlers, global ones listed, DKV converted | Task 1 (kantine), Task 2 (proxmox, handelsware, dkv, admin-only list) |
| L-05 reusable decorator/guard, canManage in /modules/active | Task 1 |
| L-06 migration default USE, RLS gates | Task 1 |
| L-07 admin grant UI level choice + display | Task 3 |
| L-08 module pages for admins AND managers | Task 1 (kantine), Task 2 (handelsware, proxmox, DKV gate) |
| L-09 tests + full suites + tsc + biome | Tasks 1-3 |
| L-10 CHANGELOG + docs | Task 3 |
| L-11 local migration + rebuild, no push | Task 1 (migrate), Task 3 (rebuild) |
| L-12 route order, no Mandant in texts | Task 2/3 text rules + node key check |
</verification>
<success_criteria>
- ModuleGrant has `level` (USE default); all existing grants are USE.
- `@ModuleManage(slug)` exists and is used by kantine-datev saveSettings, handelsware-datev saveSettings, six proxmox write handlers, and the whole DkvController; no @Roles remains on those handlers.
- GET /modules/active returns `canManage`; module pages show settings/controls for admins and managers only.
- Admin matrix and user dialog set and show the level.
- Full api + web test suites, both tsc runs and biome on touched files are green; local stack rebuilt; three commits on main, not pushed.
</success_criteria>
<output>
Create `.planning/quick/261002-icv-modul-freigabe-mit-stufe-verwalten-modul/261002-icv-SUMMARY.md` when done. It MUST contain a section "Bewusst nur für Administratoren" listing each handler kept admin-only with its reason, and a section on the DKV behavior change.
</output>
@@ -0,0 +1,147 @@
---
phase: quick-261002-icv
plan: 01
quick_id: 261002-icv
subsystem: module-grants
tags: [berechtigungen, freigabestufe, module-guard, prisma, nestjs, nextjs]
status: complete
completed: 2026-10-02
commits: 3
plan_head_before: b94d267584398be7ccf714954dbd71ce577ef301
plan_head_after: eaf2c4574afb41f0787eb17874b709bd0faf1a01
actuals:
tasks: 3
commits: 3
requires: []
provides:
- "ModuleGrant.level (USE/MANAGE), Bestand = USE"
- "ModuleAccessService.getModuleAccessLevels als einzige Auflösung für Zugriff und Stufe"
- "@ModuleManage(slug) am ModuleGuard"
- "GET /modules/active liefert canManage je Modul"
- "Web-Hook useCanManageModule(slug)"
affects: [kantine-datev, handelsware-datev, proxmox, dkv-fleet, admin-freigaben]
key-files:
created:
- apps/api/prisma/migrations/20261002140000_module_grant_level/migration.sql
- apps/api/src/groups/dto/create-module-grant.dto.spec.ts
- apps/api/src/module-registry/module-manage-handlers.spec.ts
- apps/web/src/lib/use-module-capability.ts
modified:
- apps/api/prisma/schema.prisma
- apps/api/src/module-registry/module-access.service.ts
- apps/api/src/module-registry/module.guard.ts
- apps/api/src/groups/module-grants.service.ts
- apps/api/src/groups/dto/create-module-grant.dto.ts
- apps/api/src/kantine-datev/kantine-datev.controller.ts
- apps/api/src/handelsware-datev/handelsware-datev.controller.ts
- apps/api/src/proxmox/proxmox.controller.ts
- apps/api/src/dkv/dkv.controller.ts
- apps/web/src/lib/module-access-actions.ts
- apps/web/src/components/modules/module-access-gate.tsx
- "apps/web/src/app/(portal)/admin/modules/grants/page.tsx"
- "apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx"
decisions:
- "Stufe wird ausschließlich serverseitig aus ModuleGrant-Zeilen aufgelöst; MANAGE gewinnt bei mehreren Wegen."
- "Wiederholter Klick auf eine Matrix-Zelle (POST ohne level) ändert die Stufe nie."
- "DKV-Fleet wird als Ganzes Verwalten-Stufe, Benutzen-Stufe bekommt keinen API-Zugriff (wie bisher)."
---
# Quick 261002-icv: Modul-Freigabe mit Stufe Benutzen und Verwalten
Jede Modul-Freigabe (Gruppe oder einzelner Benutzer) hat jetzt eine Stufe: **Benutzen** (USE, Standard und Bestand) oder **Verwalten** (MANAGE, zusätzlich die eigenen Einstellungen dieses einen Moduls ändern). Die Stufe wird im Backend über den neuen Dekorator `@ModuleManage('<slug>')` am `ModuleGuard` erzwungen. Die Weboberfläche erfährt die wirksame Stufe aus `GET /modules/active` (`canManage`) und zeigt Einstellungen nur Administratoren und Verwaltern. Administratoren vergeben die Stufe in der Freigaben-Matrix (je Gruppe) und im Benutzer-Detaildialog (je Benutzer).
## Was gebaut wurde
**Datenbank (Task 1).** Migration `20261002140000_module_grant_level` (von Hand geschrieben): `CREATE TYPE "ModuleGrantLevel"` und `ADD COLUMN "level" ... NOT NULL DEFAULT 'USE'`. Lokal angewendet über die Container-IP; `prisma migrate status` aktuell, Drift-Prüfung (`migrate diff --exit-code`) Rückgabewert 0. Die vorhandenen vier Freigaben stehen lokal alle auf USE. Keine neue Tabelle, daher keine Änderung an RLS-Regeln; `rls-coverage` und `rls-access-inventory` grün.
**Zugriffsauflösung und Wächter (Task 1).**
- `ModuleAccessService.getModuleAccessLevels` ist die einzige Auflösung: ADMIN/SUPER_ADMIN bekommen auf jedem aktiven Modul MANAGE ohne Grant-Abfragen; andere Rollen Direkt- plus Gruppen-Grants, MANAGE gewinnt, geschnitten mit aktiven Aktivierungen (MANAGE auf deaktiviertem Modul zählt nicht). Alles über denselben `forTenant`-Klienten. `getAccessibleModuleIds` ist nur noch die Schlüsselmenge (Signatur unverändert), `findAccessibleModules` liefert `canManage`.
- `ModuleGuard` liest `MODULE_MANAGE_KEY`, nutzt pro Request `request.moduleAccessLevels` (Klassen-`@UseModule` plus Handler-`@ModuleManage` fragen den Dienst nur einmal) und wirft bei Stufe USE `Module '<slug>' requires manage permission`. MANAGE gilt nur für das Modul der Route.
- `CreateModuleGrantDto.level` (optional, `@IsEnum`). `ModuleGrantsService.grant`: ohne Stufe USE; vorhandene Freigabe wird nur bei ausdrücklich anderer Stufe geändert (Logzeile `Grant-Stufe geändert … level=…`), ein Wiederholungsklick stuft nie herab; P2002-Wettlauf wendet dieselbe Regel an.
**Umgestellte Handler (Tasks 1 und 2).**
| Controller | Auf `@ModuleManage` umgestellt |
|---|---|
| `KantineDatevController` | `saveSettings` (`kantine-datev`) |
| `HandelswareDatevController` | `saveSettings` (`handelsware-datev`) |
| `ProxmoxController` | `create`, `update`, `remove`, `poll`, `test`, `testDraft` (`proxmox`); `list` bleibt Benutzen |
| `DkvController` | ganze Klasse (`dkv-fleet`), alle 11 früheren `@Roles` entfernt |
In den vier Controllern steht kein dekoratorseitiges `@Roles` mehr.
**Web (Tasks 1 bis 3).** `useCanManageModule(slug)` (Admins sofort `true` ohne Abfrage, sonst eine Abfrage von `/modules/active`, Fehler = `false`). Kantinenabrechnung, Handelsware, Proxmox-Seite, Proxmox-Einstellungen, `ServerCard`/`ServerForm` (Props `isAdmin` zu `canManage`) und das Proxmox-Dashboard-Widget (Einstellungs-Link) folgen `canManage`. Neue Server-Funktion `getModuleAccessLevel`; `checkModuleAccess` delegiert. `ModuleAccessGate` kennt `MANAGE_ONLY_MODULE_SLUGS = {'dkv-fleet'}`. Matrix und Benutzerdialog haben ein Stufen-Auswahlfeld (optimistisch, Rücksprung bei Fehler); Gruppen, die Verwalten gewähren, sind im Dialog mit „Verwalten“ markiert. Die API liefert dafür `level` je Matrix-Eintrag sowie `directLevel` und `manageViaGroups` je Modulzeile.
**Texte, Doku, Changelog.** Neue Schlüssel in `de.json`/`en.json` (formales „Sie“, echte Umlaute, kein „Mandant“/Tenant in den neuen Formulierungen; per Skript geprüft). `docs/anleitung-administration.md` (Kapitel 1, 2, 5 mit neuem Abschnitt „Freigabestufen: Benutzen und Verwalten“, Fehlersuche), `docs/anleitung-anwender.md`, `CHANGELOG.md` (Unveröffentlicht, Neu).
## Bewusst nur für Administratoren
Diese Handler blieben unverändert auf `@Roles(ADMIN, SUPER_ADMIN)`; die Metadaten-Spec `module-manage-handlers.spec.ts` beweist das:
| Handler | Grund |
|---|---|
| `TendersController.getSourceConfig` | plattformweiter Singleton der Abrufeinstellungen für die ganze Installation, keine Konfiguration eines einzelnen Moduls je Firma |
| `TendersController.saveSourceConfig` | wie oben; ändert Abrufintervall und Quelle für alle |
| `TendersController.pollNow` | stößt den plattformweiten Abruf beim Datenanbieter an (Lastschalter, T-lvg-01) |
| `TendersController.createRssFeed` (Bereich „platform“), `removeRssFeed` (plattformweiter Zweig) | prüfen die Rolle inline; plattformweite RSS-Feeds sehen alle Benutzer der Installation. Nicht angefasst |
| `ModuleRegistryController.activate` / `deactivate` | Modul-Aktivierung ist Sache der Administratoren (L-01) |
| `ModuleGrantsController.matrix` / `userAccess` / `create` / `remove` | Freigaben vergeben und Stufe wählen bleibt Administratoren vorbehalten (L-01); sonst könnte sich ein Verwalter selbst Rechte geben (T-icv-01) |
| Benutzer-, Gruppen-, LDAP-, SMTP-, Willkommensmail-, Mandanten-Controller | globale Administration, nicht modulgebunden |
| Gemeinsame eigene Module (`custom-modules`) | kein Registry-Modul und kein `@UseModule`; Seitenleisten-Einträge für alle, Rollenprüfung im Dienst |
Geprüft mit `grep -rn "Roles(" apps/api/src`: `cert-manager`, `domaincheck` und `reminders` haben keinen Administrator-Handler, also nichts umzustellen.
## DKV-Verhalten (Änderung)
Vorher trug jeder der 11 DKV-Handler `@Roles(ADMIN, SUPER_ADMIN)`, das Modul war faktisch nur für Administratoren benutzbar, und der Controller hatte kein `@UseModule`. Jetzt trägt die ganze Klasse `@ModuleManage('dkv-fleet')`:
- Zugriff haben Administratoren und Benutzer mit Stufe Verwalten.
- Benutzer mit nur Benutzen bekommen weiterhin 403 von der API (nichts wurde aufgeweitet).
- Neu ist, dass der Wächter zusätzlich die Aktivierung von `dkv-fleet` für den Mandanten verlangt (die Webseite verlangte sie schon). Ein Administrator ohne Aktivierung wird jetzt von der API ebenfalls abgewiesen.
- `ModuleAccessGate` zeigt Benutzern mit nur Benutzen eine erklärende Zugriffsseite („Dieses Modul steht nur Benutzern zur Verfügung, die es verwalten dürfen …“), sonst den Standardtext.
## Tests und Prüfungen (ehrlich)
- **API:** `pnpm --filter @tessera/api test`: 111 Dateien, 1911 Tests, alle grün (vorher in den Teilläufen u. a. `rls-coverage`, `rls-access-inventory`, `migration-sql`).
- **Web:** `pnpm --filter @tessera/web test`: 115 Dateien, 1234 Tests, alle grün (Vollauf vor einer reinen Umbenennung unbenutzter Testparameter; danach die betroffenen `admin`-Tests erneut grün, 7 Dateien, 96 Tests).
- **tsc:** `tsc --noEmit` in api und web ohne Fehler.
- **Biome:** `biome lint` auf allen berührten TS/TSX-Dateien ohne neue Meldungen. Zwei bereits vorhandene Warnungen bleiben bestehen und stammen nicht aus diesem Plan (`noAssignInExpressions` in `migration-sql.spec.ts`, `noArrayIndexKey` in `grants/page.tsx`).
- **Lokaler Stand:** `docker compose up -d --build api web` erfolgreich; api, web und db laufen; Log meldet `Nest application successfully started`, kein Migrations- oder Prisma-Fehler. Browserprüfung macht der Orchestrator.
## Abweichungen vom Plan
**1. [Rule 2 - Konsistenz] Proxmox-Dashboard-Widget auf `canManage` umgestellt**
- **Gefunden bei:** Task 2
- **Problem:** `proxmox-widget.tsx` blendete den Link „Zu den Einstellungen“ nur für Administratoren ein, obwohl Verwalter die Einstellungsseite jetzt nutzen dürfen. Die Datei stand nicht in der Dateiliste des Plans.
- **Fix:** `useCanManageModule('proxmox')` statt Rollenprüfung; bestehender Widget-Test blieb grün.
- **Commit:** c2ebc8d
**2. [Rule 1 - Typfehler] `applyToExisting` in `ModuleGrantsService.grant`** brauchte den Typ `ModuleGrant`, damit `tsc` die Spec akzeptiert. Behoben vor dem Commit von Task 1 (a222711).
Sonst: Plan wie geschrieben ausgeführt. Die Metadaten-Spec liegt wie geplant als eine Datei vor. Beim Handelsware-Test wurde eine Textprüfung (`/Ein Administrator muss zuerst/`) an den neuen Wortlaut angepasst, ebenso bei der Kantinenabrechnung.
## Bekannte Stubs
Keine.
## Threat Flags
Keine neuen Angriffsflächen außerhalb des Bedrohungsmodells des Plans. Hinweis zu T-icv-09: Wer für Proxmox „Verwalten“ erhält, darf Serveradressen eintragen (private Ziele per Design); das steht im Kommentar von `proxmox-client.service.ts` und im Administrationshandbuch.
## Commits (nicht gepusht)
- `a222711` feat(module-grants): Freigabestufe Verwalten – Datenbank, Zugriffsprüfung und Kantinen-Einstellungen
- `c2ebc8d` feat(module-grants): Proxmox, Handelsware und DKV mit Freigabestufe Verwalten
- `eaf2c45` feat(module-grants): Stufe Verwalten in Freigaben-Matrix und Benutzerdetails, Doku und Changelog
## Self-Check: PASSED
- Migration, `module.guard.ts`, `use-module-capability.ts`, `module-manage-handlers.spec.ts`, `create-module-grant.dto.spec.ts` vorhanden.
- Alle drei Commit-Hashes existieren auf `main` (`git rev-list --count` über das Ledger: 3).
- Keine unbeabsichtigten Löschungen in den Commits.
## Browser-Prüfung (Orchestrator, 02.10., lokal, dunkel)
- Testgruppe „Buchhaltung Test“ mit Testbenutzer (USER) per API angelegt; in der Freigaben-Matrix Kantinenabrechnung = Verwalten, Handelsware = Benutzen gesetzt, bleibt nach Neuladen erhalten.
- Als Testbenutzer: Kantine zeigt Reiter Einstellungen, Speichern klappt; Handelsware ohne Einstellungs-Reiter; API: Handelsware-Einstellungen PUT → 403, DKV ohne Freigabe → 403.
- Korrektur: Matrix zeigte Kategorie-Kennungen („accounting“) → jetzt Anzeigenamen (Finanzbuchhaltung, Fuhrpark, …).
- Testbenutzer und Testgruppe wieder gelöscht.
@@ -0,0 +1,345 @@
---
phase: quick-261002-k67
plan: 01
type: execute
wave: 1
depends_on: []
quick_id: 261002-k67
description: "Neues Modul Nextcloud-Status: Clouds als Ampel-Kacheln mit Versionsbewertung, stündlicher Prüfung und Dashboard-Kachel"
date: 2026-10-02
files_modified:
# Task 1 — tracer: DB -> status.php check -> eol reference -> rating -> list API -> module page tiles
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql
- apps/api/src/nextcloud-status/nextcloud-rating.ts
- apps/api/src/nextcloud-status/nextcloud-rating.spec.ts
- apps/api/src/nextcloud-status/nextcloud-status-fetch.ts
- apps/api/src/nextcloud-status/nextcloud-status-fetch.spec.ts
- apps/api/src/nextcloud-status/nextcloud-release.service.ts
- apps/api/src/nextcloud-status/nextcloud-release.service.spec.ts
- apps/api/src/nextcloud-status/nextcloud-status.service.ts
- apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts
- apps/api/src/nextcloud-status/nextcloud-status.controller.ts
- apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts
- apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts
- apps/api/src/nextcloud-status/nextcloud-status.seed.ts
- apps/api/src/nextcloud-status/nextcloud-status.module.ts
- apps/api/src/app.module.ts
- docs/mandantentrennung-zugriffsklassifikation.md
- apps/web/src/lib/nextcloud-status-api.ts
- apps/web/src/components/nextcloud-status/rating-display.ts
- apps/web/src/app/(portal)/modules/nextcloud-status/layout.tsx
- apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx
- apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx
- apps/web/src/app/(portal)/modules/module-layouts.test.tsx
- apps/web/src/lib/module-loader.ts
- apps/web/src/lib/module-identity.ts
- apps/web/src/lib/stores/nav-store.ts
- apps/web/src/components/modules/module-tile.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/messages/umlaut-dictionary.ts
# Task 2 — manager write paths, logo, check-all, hourly scheduler, sorting
- apps/api/src/nextcloud-status/nextcloud-logo-rules.ts
- apps/api/src/nextcloud-status/nextcloud-logo-rules.spec.ts
- apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts
- apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.ts
- apps/api/src/module-registry/module-manage-handlers.spec.ts
- apps/api/src/prisma/rls-access-inventory.spec.ts
- apps/web/src/components/nextcloud-status/sort-clouds.ts
- apps/web/src/components/nextcloud-status/sort-clouds.test.ts
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.test.tsx
# Task 3 — dashboard widget, docs, changelog, full suites, rebuild
- packages/shared/src/index.ts
- apps/api/src/dashboard/widget-module-map.ts
- apps/api/src/dashboard/widget-module-map.spec.ts
- apps/web/src/components/dashboard/widget-registry.tsx
- apps/web/src/components/dashboard/widget-registry.test.tsx
- apps/web/src/components/dashboard/widget-catalog-modal.test.tsx
- apps/web/src/components/dashboard/widgets/widget-icon.tsx
- apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
- apps/web/src/components/dashboard/widgets/nextcloud-status-widget.tsx
- apps/web/src/components/dashboard/widgets/nextcloud-status-widget.test.tsx
- apps/web/src/app/(portal)/page.tsx
- apps/web/src/app/(portal)/page.test.tsx
- CHANGELOG.md
- docs/anleitung-anwender.md
- docs/anleitung-administration.md
autonomous: true
requirements: [QUICK-261002-k67]
estimate:
tokens: 170000
raw_tokens: 170000
tasks: 3
confidence: low
must_haves:
truths:
- "A user with grant level Benutzen (USE) on nextcloud-status sees one tile per cloud: logo (or initials), Kundenname, URL link opening in a new tab, installed version, traffic-light color with a short plain-language reason, and the time of the last check; the page also names the newest overall Nextcloud version"
- "The traffic light follows the locked rules: GREEN = latest patch of its cycle and EOL more than 90 days away; YELLOW = patch update available in its cycle or EOL within 90 days; RED = EOL passed (or major older than every listed cycle), unreachable/invalid answer, maintenance mode, or needsDbUpgrade; without reference data the tile is grey with 'Bewertung nicht möglich' (red status conditions still red)"
- "Only administrators and users with Verwalten (MANAGE) can add/edit/delete clouds, upload/remove logos, and trigger 'Jetzt prüfen' or a per-tile check — API returns 403 for USE-level users and the controls are hidden for them"
- "Every cloud is checked automatically once per hour (also on a fresh database after the first start), each check fetches only <url>/status.php with a 10 s timeout, max 3 redirects and a size cap, and only parsed fields reach the browser"
- "Version reference data from endoflife.date is cached for 12 h; an outage keeps the last good data and never breaks the page"
- "The user can sort tiles by Kundenname, Status (red first), Version, or Support-Ende, and the choice survives a reload for the same user"
- "Users with module access can place a 'Nextcloud-Status' dashboard tile showing green/yellow/red counters and the red/yellow clouds with reason; clicking opens the module; users without access never see the tile in the catalog or on the dashboard"
artifacts:
- path: "apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql"
provides: "NextcloudInstance table with tenant_isolation_policy and system_read_policy"
contains: "NextcloudInstance"
- path: "apps/api/src/nextcloud-status/nextcloud-rating.ts"
provides: "pure rating function with injected date"
exports: ["rateNextcloud"]
- path: "apps/api/src/nextcloud-status/nextcloud-status-fetch.ts"
provides: "normalizeCloudUrl, parseNextcloudStatus, fetchNextcloudStatus (injectable fetch)"
exports: ["normalizeCloudUrl", "parseNextcloudStatus", "fetchNextcloudStatus"]
- path: "apps/api/src/nextcloud-status/nextcloud-release.service.ts"
provides: "endoflife.date cache (12 h, keep last good, backoff) + parseEndOfLife"
- path: "apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts"
provides: "hourly check job registered in onApplicationBootstrap"
- path: "apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx"
provides: "tile grid, sorting, manager controls"
- path: "apps/web/src/components/dashboard/widgets/nextcloud-status-widget.tsx"
provides: "dashboard overview tile"
key_links:
- from: "apps/api/src/nextcloud-status/nextcloud-status.service.ts"
to: "rateNextcloud + NextcloudReleaseService.getReference"
via: "listForTenant maps every row to a rating at read time"
pattern: "rateNextcloud\\("
- from: "apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts"
to: "NextcloudStatusService.loadAllInstancesForScheduler (forSystem) -> checkInstance (forTenant)"
via: "onApplicationBootstrap registers the cron job nextcloud-status-poll"
pattern: "onApplicationBootstrap"
- from: "packages/shared/src/index.ts WIDGET_MODULE_SLUGS"
to: "DashboardService fail-closed filter + web catalog visibleWidgetTypes"
via: "'nextcloud-status': 'nextcloud-status'"
pattern: "'nextcloud-status': 'nextcloud-status'"
- from: "apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx"
to: "useCanManageModule('nextcloud-status')"
via: "manager-only controls"
pattern: "useCanManageModule\\('nextcloud-status'\\)"
---
<objective>
New Tessera module "Nextcloud-Status" (slug `nextcloud-status`, category `infrastructure`, next to Proxmox). Managers register customer Nextcloud instances (URL + Kundenname + optional logo); Tessera checks each instance's public `status.php` every hour and on demand, compares the installed version with the endoflife.date reference data and shows a traffic-light tile per cloud plus a dashboard overview tile.
Locked decisions from the request (cited below as L-xx):
- L-01 Slug `nextcloud-status`, category `infrastructure`; follow the Proxmox module end to end (seed, NestJS module, ModuleGuard, Prisma model with RLS + hand-written migration, web route with ModuleAccessGate, sidebar/marketplace/icon registrations, module-layouts test, dashboard widget registration).
- L-02 Managers add a cloud with URL and Kundenname; optional logo either uploaded (PostgreSQL bytea, magic-byte type check, 1 MiB limit, served via an authenticated route) or an https URL the browser loads directly — the API never fetches the logo URL.
- L-03 Status: server-side GET `<url>/status.php`, ~10 s timeout, no credentials, http and https, at most a few redirects, capped response size; last result stored on the record (versionstring, maintenance, reachable, error text, checkedAt). URL policy like Proxmox (manager-entered, internal hosts allowed) with the SSRF consideration documented in a code comment; only parsed JSON fields, never raw bodies, reach the client.
- L-04 Reference data from https://endoflife.date/api/nextcloud.json, fetched server-side, cached ~12 h, outage-tolerant (keep last good); never fetched → tiles show the version without rating (grey "Bewertung nicht möglich").
- L-05 Traffic light (locked): GREEN = latest patch of its major cycle AND cycle still supported; YELLOW = update available within its cycle OR cycle EOL within the next 90 days; RED = cycle EOL passed (or major not in the list and older than all listed), unreachable, maintenance on, or needsDbUpgrade. Also show the newest overall Nextcloud version. Rating is a pure function with thorough Vitest tests and an injected date.
- L-06 Tile: logo, Kundenname, URL (new tab), installed version, status color + short German reason ("Aktuell", "Update auf 34.0.4 verfügbar", "Support endet am 30.06.2027", "Support abgelaufen seit …", "Nicht erreichbar", "Wartungsmodus"), last check time.
- L-07 Sorting selectable: Kundenname (A–Z), Status (rot zuerst), Version, Support-Ende; remembered per user in localStorage (try/catch).
- L-08 Polling hourly via scheduler (Proxmox/Tender pattern incl. the onApplicationBootstrap lesson), plus "Jetzt prüfen" (whole list) and per-tile refresh; background checks per tenant in system context like the other background jobs.
- L-09 Rights (locked): USE sees tiles; MANAGE (`@ModuleManage`) or admin adds/edits/deletes clouds, uploads logos, triggers checks. Web uses `useCanManageModule`.
- L-10 Dashboard widget (locked): counters (grün/gelb/rot) and the red/yellow clouds (name + reason); click opens the module; registered and sized like the Proxmox widget.
- L-11 UI texts German (formal "Sie") and English; UI texts never name the tenant concept; dark/light via existing tokens.
- L-12 Tests: api Vitest for rating, status.php parser (valid, maintenance, garbage, timeout), eol cache, service CRUD, controller guard metadata (static routes before `:id`); web tests for page (tiles, sorting, manager-only controls) and widget; full api + web suites, tsc, biome on touched files.
- L-13 CHANGELOG (Unveröffentlicht, user-facing German) + user/admin docs in `docs/`.
- L-14 Local migration via container IP, rebuild `docker compose up -d --build api web`; do NOT push; browser check is the orchestrator's job.
Claude's discretion (decided here, apply as written):
- D-A Logo bytes live in bytea columns on the instance row as L-02 says (note: dashboard images moved to the file area in 260922-hk4; for a handful of logos ≤ 1 MiB the DB is fine and needs no file cleanup). Every list/scheduler query uses an explicit `select` without the bytes. Accepted types PNG/JPEG/GIF/WebP via the existing `detectImageMime` — no SVG (script risk). Upload and logo URL are mutually exclusive: uploading clears `logoUrl`; saving a non-empty `logoUrl` clears the upload.
- D-B The rating is computed in the API at read time (`GET instances`), so page and widget show the same result; the API returns reason codes plus parameters, the web translates them (German + English).
- D-C One global hourly cron job `nextcloud-status-poll` (`0 * * * *`), registered unconditionally in `onApplicationBootstrap` without reading the database at registration time — a fresh database cannot end up without the job (Tender lesson). Each tick reads `(id, tenantId)` of all instances in system context (the single `forSystem` call), then checks each instance tenant-bound with concurrency 4 and an overlap guard.
- D-D Reference cache in memory (no table): TTL 12 h; stale data is returned immediately and refreshed in the background; only an empty cache is awaited; after a failure no new attempt for 15 min; concurrent refreshes share one request; bootstrap warms it up without blocking.
- D-E A major newer than every listed cycle (fresh release not yet on endoflife.date) rates GREEN "Aktuell"; a major inside the listed range but missing from it rates grey.
- D-F An answer that is not a valid Nextcloud status JSON (or `installed` not true) is RED with its own reason "Keine gültige Nextcloud-Antwort" (variant of "unreachable").
- D-G Status sort order red, yellow, grey, green (then name); version sort oldest first, unknown last; Support-Ende earliest first, unknown last; ties by name.
- D-H TLS certificates of the clouds are verified (no opt-out); a certificate error shows as "Nicht erreichbar" with the error code as detail.
Output: migration + model, API module (pure functions, release cache, service, controller, scheduler, seed), module page with tiles/sorting/manager form, dashboard widget, tests, docs, changelog, rebuilt local stack. Three atomic commits on main, NOT pushed.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
Discovered facts the executor can rely on (verified during planning):
- Proxmox touchpoints (grep `proxmox` across apps/ and packages/) are the template: `apps/api/src/proxmox/{proxmox.module.ts, proxmox.seed.ts, proxmox.controller.ts, proxmox.service.ts, proxmox-scheduler.service.ts}`, `apps/api/src/app.module.ts`, migration `20260923140000_proxmox_server`, web `apps/web/src/app/(portal)/modules/proxmox/{layout.tsx,page.tsx}`, `apps/web/src/lib/{proxmox-api.ts,module-loader.ts,module-identity.ts,stores/nav-store.ts}`, `apps/web/src/components/modules/module-tile.tsx` (ICONS map keyed by `ModuleIconId`), widget files under `apps/web/src/components/dashboard/`.
- `PrismaService` is injected without importing a Prisma module (global); `forTenant`/`forSystem` come from `apps/api/src/prisma/prisma-tenant.extension.ts`. `ModuleRegistryModule` must be imported for `ModuleRegistryService` (seed) and `ModuleGuard`. `ScheduleModule` is already global in app.module.ts.
- `@UseModule(slug)` (class) and `@ModuleManage(slug)` (handler) live in `apps/api/src/module-registry/module.guard.ts` (quick 261002-icv). Never put a role decorator on a manage handler — the global RolesGuard would block managers. `GET /modules/active` already carries `canManage`; `apps/web/src/lib/use-module-capability.ts` exports `useCanManageModule`.
- Cron: `ProxmoxSchedulerService` resolves `CronJob` via `require('cron').CronJob` (pnpm strict isolation) and registers with `SchedulerRegistry.addCronJob` — reuse that workaround verbatim.
- HTTP: `apps/api/src/favorites/icon-discovery.service.ts` uses `fetch as undiciFetch` from `undici` (dependency 7.28.0) with `redirect: 'manual'`, AbortController timeouts and a capped body reader (`readTextCapped`) — same approach here, but WITHOUT the private-IP filter (L-03: internal hosts allowed).
- Magic bytes: `detectImageMime(buffer)` in `apps/api/src/dashboard/dashboard-image-rules.ts` (PNG/JPEG/GIF/WebP). Serving pattern for uploaded images: `apps/api/src/favorites/favorites.controller.ts` `getIcon` (Content-Type from detected mime, `Cache-Control: private, max-age=86400`, `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; sandbox`) and `FileInterceptor(field, { limits: { fileSize, files: 1 } })`. The web loads authenticated images through the same-origin proxy `/api-proxy/<api path>` with a `?v=<version>` cache buster (favorites-widget.tsx).
- RLS gates: `rls-coverage.spec.ts` needs the policies in the migration; `rls-access-inventory.spec.ts` compares every (file, model) Prisma access against the Fundstellentabelle in `docs/mandantentrennung-zugriffsklassifikation.md` (plus Bereichszeile, Summenzeile, Paarzählung — follow the quick-261002-fm5 and proxmox rows) and allows `forSystem(` only at the call sites listed in `FORSYSTEM_ALLOWED_CALL_SITES`. A file with tenant-bound AND one system read on the same model has Stand `system-gebunden` (precedent `proxmox.service.ts`/`proxmoxServer`). Never use `include:` or relation `select:` in this module.
- Status colors: tokens `bg-status-ok|warn|down|idle` and `text-status-*-fg`, pill form `bg-status-ok/12 text-status-ok-fg` (see `apps/web/src/components/proxmox/status-styles.ts`); class strings must be literal (Tailwind scanning).
- Module routes in the web: `/modules/nextcloud-status` (own layout with `ModuleAccessGate`) AND the sidebar route `/modules/infrastructure/nextcloud-status` (generic `[category]/[moduleSlug]` page → `module-loader.ts`).
- i18n: widget catalog names live under `widgets.<key>.name/description` in de.json/en.json; module texts get a new top-level namespace `nextcloudStatus`. `apps/web/src/messages/umlaut-guard.spec.ts` rejects ae/oe/ue/ss tokens in de.json that are not in `UMLAUT_ALLOWLIST` (e.g. "aktuell", "Neueste" may need allowlisting — run the test).
- Widget tests enumerate all widget types (currently eleven): `widget-registry.test.tsx`, `widget-catalog-modal.test.tsx`, `apps/api/src/dashboard/widget-module-map.spec.ts`; `(portal)/page.test.tsx` mocks each widget module.
- endoflife.date sample (2026-10-02): `[{"cycle":"35","releaseDate":"2026-09-16","eol":"2027-09-30","latest":"35.0.1",...},{"cycle":"34","eol":"2027-06-30","latest":"34.0.4"},{"cycle":"33","eol":"2027-02-28","latest":"33.0.9"},{"cycle":"32","eol":"2026-09-30","latest":"32.0.15"},...]`; `eol` may also be a boolean. Nextcloud `status.php` returns `installed, maintenance, needsDbUpgrade, version ("31.0.5.1"), versionstring ("31.0.5"), edition, productname, extendedSupport`.
- Migration convention: hand-written SQL with German header comment (model: `20260923140000_proxmox_server`); latest existing migration is `20261002140000_module_grant_level`. Local DB: no host port — IP via `docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1`, then `DATABASE_URL="postgresql://tessera:tessera_dev@<IP>:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy`.
- Commits: German subject, conventional prefix, end with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`. Never push. PLAN/SUMMARY/STATE are committed by the orchestrator, not by the executor.
@apps/api/src/proxmox/proxmox.controller.ts
@apps/api/src/proxmox/proxmox-scheduler.service.ts
@apps/api/src/proxmox/proxmox.seed.ts
@apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql
@apps/web/src/app/(portal)/modules/proxmox/page.tsx
@apps/web/src/components/dashboard/widgets/proxmox-widget.tsx
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer — a registered cloud is checked, rated and shown as a tile (DB → status.php → endoflife reference → rating → GET instances → module page)</name>
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql, apps/api/src/nextcloud-status/nextcloud-rating.ts, apps/api/src/nextcloud-status/nextcloud-rating.spec.ts, apps/api/src/nextcloud-status/nextcloud-status-fetch.ts, apps/api/src/nextcloud-status/nextcloud-status-fetch.spec.ts, apps/api/src/nextcloud-status/nextcloud-release.service.ts, apps/api/src/nextcloud-status/nextcloud-release.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.service.ts, apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts, apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts, apps/api/src/nextcloud-status/nextcloud-status.seed.ts, apps/api/src/nextcloud-status/nextcloud-status.module.ts, apps/api/src/app.module.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/nextcloud-status-api.ts, apps/web/src/components/nextcloud-status/rating-display.ts, apps/web/src/app/(portal)/modules/nextcloud-status/layout.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx, apps/web/src/app/(portal)/modules/module-layouts.test.tsx, apps/web/src/lib/module-loader.ts, apps/web/src/lib/module-identity.ts, apps/web/src/lib/stores/nav-store.ts, apps/web/src/components/modules/module-tile.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts</files>
<behavior>
- rateNextcloud (reference = 35/2027-09-30/35.0.1, 34/2027-06-30/34.0.4, 33/2027-02-28/33.0.9, 32/2026-09-30/32.0.15, 31/2026-02-28/31.0.14; now = 2026-10-02 unless stated): 35.0.1 → green/current; 35.0.2 (newer than listed latest) → green/current; 34.0.3 → yellow/update-available, updateTo 34.0.4; 32.0.15 → red/eol-passed with eolDate 2026-09-30; 32.0.15 at now 2026-08-01 → yellow/eol-soon; 33.0.9 at 2026-12-15 → yellow/eol-soon eolDate 2027-02-28; boundary: EOL exactly 90 days ahead → yellow, 91 days → green; EOL day itself → yellow, the day after → red; 33.0.5 at 2026-12-15 → yellow, reason eol-soon AND updateTo 33.0.9; major 20 (older than all listed) → red/eol-passed without date; major 36 (newer than all) → green/current; cycle with eol false → supported, green when latest; eol true → red/eol-passed without date; reachable false → red/unreachable even with reference null; errorKind not-nextcloud → red/invalid-response; maintenance true → red/maintenance; needsDbUpgrade true → red/needs-db-upgrade; red priority unreachable > invalid-response > maintenance > needs-db-upgrade > eol-passed; reference null + reachable → unknown/no-reference; reachable but no parseable version → unknown/version-unknown; never checked → unknown/not-checked; newestVersion(reference) = latest of the highest cycle ('35.0.1').
- normalizeCloudUrl: ' https://cloud.kunde.de/ ' → 'https://cloud.kunde.de'; '.../status.php' and '.../index.php' suffix stripped; subpath 'https://host/nextcloud/' → 'https://host/nextcloud'; query/hash dropped; 'http://intern:8080' kept; ftp:, javascript:, URL with user:pass@, empty, longer than 2048 → null.
- parseNextcloudStatus: valid JSON → fields; maintenance true kept; needsDbUpgrade missing → false; versionstring missing → first three parts of version; HTML, empty, array, installed false, non-object → null; overlong edition/productname cut to 64 chars.
- fetchNextcloudStatus (injected fetch): 200 valid → reachable true + fields; 200 maintenance → reachable true, maintenance true; 200 garbage → reachable false, errorKind not-nextcloud; 503 → errorKind http-status, errorDetail 'HTTP 503'; never-resolving fetch → errorKind timeout after the injected timeout (fake timers or a 20 ms timeout); rejected fetch with cause.code ENOTFOUND → errorKind network, detail 'ENOTFOUND'; cert error code (e.g. CERT_HAS_EXPIRED) → errorKind tls; 301 with relative Location → followed once and succeeds; redirect to ftp: → errorKind redirect; 4 consecutive redirects → errorKind redirect; body over 64 KiB → errorKind too-large; request carries no Authorization/Cookie header and targets exactly '<base>/status.php'.
- NextcloudReleaseService (injected fetch + clock): first getReference fetches and parses; second call within 12 h does not fetch; after 12 h returns the stale data immediately and refreshes in the background; failure with existing data keeps it; failure without data → null; no new attempt within 15 min after a failure; garbage/empty array keeps last good; parallel calls share one request; parseEndOfLife skips entries without numeric cycle or valid latest.
- NextcloudStatusService (mocked prisma via forTenant, mocked fetch + release): createInstance normalizes the URL (invalid → BadRequestException with German message), creates the row and runs the first check; checkInstance writes reachable/maintenance/needsDbUpgrade/versionString/edition/errorKind/errorDetail/lastCheckedAt; checkInstance for an unknown id → NotFoundException; listForTenant selects no logo bytes, returns { instances (with rating), reference: { newestVersion, fetchedAt } }.
- Controller metadata: class has MODULE_SLUG_KEY 'nextcloud-status' + ModuleGuard; `list` has no MODULE_MANAGE_KEY; `create` and `checkOne` have MODULE_MANAGE_KEY true and no ROLES_KEY.
- Web page test: with a mocked list (one green, one yellow with updateTo, one red unreachable, one grey) it renders four tiles with Kundenname, version, the translated reasons ('Aktuell', 'Update auf 34.0.4 verfügbar', 'Nicht erreichbar', 'Bewertung nicht möglich'), the URL as link with target _blank and rel noopener noreferrer, and the line with the newest Nextcloud version; an empty list shows the empty state.
</behavior>
<action>
**Schema + migration (L-01, L-02, L-03, D-A).** In `apps/api/prisma/schema.prisma` add `model NextcloudInstance` after `ProxmoxServerStatus` with a German comment (quick-261002-k67; status lives on the row, L-03; logo bytes per D-A; no relation to Tenant, pattern ProxmoxServer): `id String @id @default(uuid())`, `tenantId String`, `customerName String`, `baseUrl String`, `logoUrl String?`, `logoData Bytes?`, `logoMime String?`, `logoVersion Int @default(0)`, `lastCheckedAt DateTime?`, `reachable Boolean?` (null = never checked), `maintenance Boolean?`, `needsDbUpgrade Boolean?`, `versionString String?`, `edition String?`, `productName String?`, `errorKind String?`, `errorDetail String?`, `createdAt`, `updatedAt @updatedAt`, `@@index([tenantId])`. Hand-write `apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql` in the style of 20260923140000_proxmox_server: German header (purpose; tenant_isolation_policy WITHOUT user dimension because clouds are shared data of the organisation; `system_read_policy ... FOR SELECT USING (is_system_context())` because the hourly job of Task 2 reads id/tenantId of all rows; rights via ALTER DEFAULT PRIVILEGES; switch-is-off note), CREATE TABLE with `"logoData" BYTEA`, index, ENABLE + FORCE ROW LEVEL SECURITY, both policies. Run `pnpm --filter @tessera/api exec prisma generate`; apply locally (L-14) with the container-IP command from the context and confirm `prisma migrate status` is up to date and `prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --exit-code` exits 0.
**Pure functions (L-03, L-05, D-E, D-F).** `nextcloud-rating.ts` (no Nest, no Prisma): types `NextcloudCycle { cycle: number; eol: string | boolean; latest: string }`, `NextcloudReference { cycles: NextcloudCycle[]; fetchedAt: string }`, `RatingLevel = 'green'|'yellow'|'red'|'unknown'`, `RatingReason = 'current'|'update-available'|'eol-soon'|'eol-passed'|'unreachable'|'invalid-response'|'maintenance'|'needs-db-upgrade'|'no-reference'|'version-unknown'|'not-checked'`, `NextcloudRating { level; reason; updateTo: string|null; eolDate: string|null; cycle: number|null }`. Export `rateNextcloud(status, reference, now: Date)` and `newestVersion(reference)`. Compare dates as UTC calendar days ('YYYY-MM-DD' of `now`), EOL-soon window `EOL_WARNING_DAYS = 90` inclusive, version compare numerically on three parts parsed with an anchored regex from versionString (fallback: first three parts of `version`). Implement exactly the cases in `<behavior>`; German JSDoc explaining each rule and citing L-05. `nextcloud-status-fetch.ts`: `normalizeCloudUrl(raw)`, `parseNextcloudStatus(text)` (JSON.parse in try, whitelist fields, version strings limited to digits/dots and 32 chars), and `fetchNextcloudStatus(baseUrl, opts?: { fetchImpl?, timeoutMs? })` → `{ reachable, maintenance, needsDbUpgrade, versionString, edition, productName, errorKind, errorDetail }`. Use `undiciFetch` by default, `redirect: 'manual'`, at most `MAX_REDIRECTS = 3` hops (each Location resolved against the current URL, only http/https), one AbortController for the whole request with `STATUS_TIMEOUT_MS = 10_000`, headers only `Accept: application/json` and a `User-Agent: Tessera-Nextcloud-Status`, body read through a capped reader with `MAX_STATUS_BYTES = 64 * 1024`, discard bodies of redirect/error responses. errorKind values: timeout, network, tls, http-status, not-nextcloud, too-large, redirect; errorDetail is only our own short code ('HTTP 503', the `cause.code` such as ENOTFOUND/ECONNREFUSED/CERT_HAS_EXPIRED, max 120 chars) — never a response body or exception message. Put a German SSRF comment block above the function (L-03): manager-entered URLs incl. internal hosts are allowed on purpose like Proxmox (T-DHH-02); limits applied (only `/status.php`, GET, no credentials, 3 redirects, 10 s, 64 KiB, only whitelisted fields leave the server); a person with Verwalten could thereby learn whether an internal address answers — accepted.
**Reference cache (L-04, D-D).** `nextcloud-release.service.ts`: `@Injectable() NextcloudReleaseService` without constructor parameters; test seams are two instance fields `fetchImpl` (default `undiciFetch`) and `now` (default `() => Date.now()`) that specs overwrite on the instance. Export pure `parseEndOfLife(json): NextcloudCycle[]` (cycle must be /^\d+$/, latest a dotted version, eol a 'YYYY-MM-DD' string or boolean; invalid entries skipped). `getReference(): Promise<NextcloudReference | null>` and `refresh(): Promise<void>` per D-D: source URL constant `ENDOFLIFE_URL = 'https://endoflife.date/api/nextcloud.json'`, `CACHE_TTL_MS = 12 h`, `RETRY_BACKOFF_MS = 15 min`, 10 s timeout, 512 KiB cap, empty parse result counts as failure, failures logged once per attempt with Logger.warn.
**Service + DTO + controller + seed + module (L-01, L-09).** `dto/nextcloud-instance.dto.ts`: `CreateNextcloudInstanceDto { customerName (IsString, IsNotEmpty, MaxLength 120); baseUrl (IsString, IsNotEmpty, MaxLength 2048); logoUrl? (IsOptional, ValidateIf non-empty, IsUrl https only with require_protocol, MaxLength 2048) }` and `UpdateNextcloudInstanceDto` with all three optional (same validators; empty logoUrl = remove). `nextcloud-status.service.ts`: inject PrismaService and NextcloudReleaseService; a module-level `PUBLIC_SELECT` constant without `logoData`; every method uses its own `const tenantPrisma = forTenant(this.prisma, tenantId)`; `listForTenant(tenantId)` (findMany ordered by customerName, maps to a view `{ id, customerName, baseUrl, logoUrl, hasUploadedLogo, logoVersion, status: { checkedAt, reachable, maintenance, needsDbUpgrade, versionString, edition, errorKind, errorDetail }, rating }` using `rateNextcloud(..., reference, new Date())`, returns `{ instances, reference: { newestVersion, fetchedAt } }`); `createInstance(tenantId, dto)` (normalizeCloudUrl → BadRequestException 'Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.'; create; then `checkInstance`); `checkInstance(tenantId, id)` (findFirst by id+tenantId → NotFoundException; fetchNextcloudStatus; update status columns + lastCheckedAt; return the view). `nextcloud-status.controller.ts`: `@Controller('modules/nextcloud-status')`, class `@UseModule('nextcloud-status')`, `requireTenantId` as in ProxmoxController; `@Get('instances') list`; `@Post('instances') @ModuleManage('nextcloud-status') create`; `@Post('instances/:id/check') @ModuleManage('nextcloud-status') checkOne`. Header comment: rights per L-09, route order rule (static routes before `:id`, see Task 2). `nextcloud-status.seed.ts` like proxmox.seed.ts: slug 'nextcloud-status', name 'Nextcloud-Status', version '1.0.0', category 'infrastructure', description de 'Versionen und Erreichbarkeit Ihrer Nextcloud-Clouds im Blick' / en 'Keep track of versions and availability of your Nextcloud clouds', isSystem true. `nextcloud-status.module.ts` like ProxmoxModule (imports ModuleRegistryModule, providers NextcloudStatusService + NextcloudReleaseService, OnModuleInit seed with log line 'Nextcloud-Status module seeded in registry'). Register `NextcloudStatusModule` in `app.module.ts` right after ProxmoxModule. Specs per `<behavior>` (controller spec pattern: Reflect.getMetadata on prototype methods, like module-manage-handlers.spec.ts).
**RLS inventory doc.** Run `pnpm --filter @tessera/api exec vitest run rls-coverage rls-access-inventory`; add the Bereichszeile `nextcloud-status`, update the Summenzeile and Paarzählung, and add the Fundstellentabelle row(s) for `apps/api/src/nextcloud-status/nextcloud-status.service.ts` / `nextcloudInstance` (Stand `gebunden` for now; Task 2 turns it into `system-gebunden`) in `docs/mandantentrennung-zugriffsklassifikation.md`, counted with the Gate-Schleife exactly as the fm5 rows describe; both specs green.
**Web tracer (L-06, L-11).** `apps/web/src/lib/nextcloud-status-api.ts` (pattern proxmox-api.ts, `credentials: 'include'`): exported types mirroring the API view and `listInstances()`; plus `logoSrc(instance)` returning `/api-proxy/modules/nextcloud-status/instances/<id>/logo?v=<logoVersion>` when `hasUploadedLogo`, else `logoUrl`, else null. `apps/web/src/components/nextcloud-status/rating-display.ts`: literal class map `RATING_STYLE` per level (green→status-ok, yellow→status-warn, red→status-down, unknown→status-idle; fill + pill + text), `ratingReasonText(t, rating, locale)` mapping reason → key under `nextcloudStatus.reason.*` with params (version, date formatted with Intl.DateTimeFormat for the locale in UTC, e.g. 30.06.2027), shared later by the widget. `layout.tsx` = ModuleAccessGate moduleSlug "nextcloud-status" (copy proxmox/layout.tsx). `page.tsx` ('use client'): PageHeader with title, a muted line "Neueste Nextcloud-Version: {version}" (or "Versionsdaten derzeit nicht verfügbar"), loading skeleton, empty state, responsive tile grid (`grid gap-4 sm:grid-cols-2 xl:grid-cols-3`) of `CloudTile`. `CloudTile.tsx`: colored left strip + status pill with reason, logo (img with `referrerPolicy="no-referrer"`, alt = Kundenname, fallback initials on null or onError), Kundenname, URL link (`target="_blank" rel="noopener noreferrer"`), installed version (or "—"), last check as relative time ("vor 12 Min.", "noch nie"), errorDetail in small muted text only when red/unreachable. Use existing tokens (bg-card, text-muted-foreground, dark: variants as in proxmox ServerCard) — no new colors. Registrations: module-loader.ts entry 'nextcloud-status' (dynamic import of the page, ssr false); module-identity.ts new ModuleIconId 'cloud' mapped from 'nextcloud-status'; module-tile.tsx ICONS 'cloud' (lucide cloud path `M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z`); nav-store.ts MODULE_TITLE_KEYS 'nextcloud-status' → 'nextcloudStatus.title'; module-layouts.test.tsx add ['nextcloud-status', NextcloudStatusLayout] to the it.each. Messages: new top-level `nextcloudStatus` namespace in de.json (formal Sie, real umlauts) and en.json with identical keys: title, newestVersion, referenceUnavailable, empty, lastCheck/never, version labels, and `reason.{current: 'Aktuell', updateAvailable: 'Update auf {version} verfügbar', eolSoon: 'Support endet am {date}', eolPassed: 'Support abgelaufen seit {date}', eolPassedNoDate: 'Support abgelaufen', unreachable: 'Nicht erreichbar', invalidResponse: 'Keine gültige Nextcloud-Antwort', maintenance: 'Wartungsmodus', needsDbUpgrade: 'Datenbank-Aktualisierung ausstehend', noReference: 'Bewertung nicht möglich', versionUnknown: 'Version unbekannt', notChecked: 'Noch nicht geprüft'}` plus English equivalents ('Up to date', 'Update to {version} available', 'Support ends on {date}', …). UI texts never name the tenant concept (L-11). Run the umlaut guard and add legitimately correct tokens to `UMLAUT_ALLOWLIST` only if it fails. Page test per `<behavior>` (mock `@/lib/nextcloud-status-api` and next-intl like the proxmox page tests).
Biome-lint the touched files (`pnpm exec biome lint <files>` from repo root, `biome check --write` on new files only), commit `feat(nextcloud-status): Modul mit Statusabruf, Versionsbewertung und Kachelansicht` (attribution line). Do not push.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/nextcloud-status rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run nextcloud-status module-layouts umlaut && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit</automated>
</verify>
<done>NextcloudInstance migration applied locally without drift; rating, parser, fetch, release cache, service and controller specs green; GET /modules/nextcloud-status/instances returns rated instances plus newest version; the module page renders the tiles with translated reasons; module registered in loader, identity, tile icon, nav titles and layouts test; RLS gates green; commit on main, not pushed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Managers maintain clouds (edit, delete, logo), trigger checks, hourly job runs; everyone can sort the tiles</name>
<files>apps/api/src/nextcloud-status/nextcloud-logo-rules.ts, apps/api/src/nextcloud-status/nextcloud-logo-rules.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.service.ts, apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts, apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts, apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.module.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, apps/api/src/prisma/rls-access-inventory.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/nextcloud-status-api.ts, apps/web/src/components/nextcloud-status/sort-clouds.ts, apps/web/src/components/nextcloud-status/sort-clouds.test.ts, apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.test.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts</files>
<behavior>
- checkLogoUpload (nextcloud-logo-rules): PNG/JPEG/GIF/WebP bytes ≤ 1 MiB → detected mime; SVG text, PDF, empty, renamed HTML → rejected; 1 MiB + 1 byte → rejected (second net behind multer).
- Service: updateInstance changes name/URL (new URL normalized and re-checked; unchanged URL not re-checked); non-empty logoUrl clears logoData/logoMime and bumps logoVersion; empty logoUrl sets null; uploadLogo stores bytes + detected mime, clears logoUrl, bumps logoVersion; removeLogo clears bytes/mime and bumps logoVersion; getLogo returns { data, mime } or NotFoundException when none; deleteInstance removes the row; every write/read of a foreign id → NotFoundException (where id + tenantId); checkAllForTenant checks all instances of the tenant with at most 4 in parallel and returns the refreshed list; loadAllInstancesForScheduler selects only id and tenantId through forSystem.
- Scheduler: onApplicationBootstrap registers exactly one cron job 'nextcloud-status-poll' with '0 * * * *' without any database read, starts it, and fires one non-blocking release refresh; a thrown error during bootstrap is logged and does not propagate; tick groups instances by tenant and calls checkInstance(tenantId, id) for each; one failing instance does not stop the others; a tick started while the previous one runs is skipped.
- Controller metadata + order: list and logo (GET) have no MODULE_MANAGE_KEY; create, update, remove, checkAll, checkOne, uploadLogo, removeLogo have MODULE_MANAGE_KEY true and no ROLES_KEY; the static handler for POST 'instances/check' is declared before every handler whose path contains ':id' (index check on Object.getOwnPropertyNames of the prototype).
- sortClouds: 'name' → A–Z with German collation (Ä next to A, case-insensitive); 'status' → red, yellow, unknown, green, ties by name; 'version' → oldest first, missing version last; 'eol' → earliest eolDate first, missing last; input array not mutated. readSortPreference/writeSortPreference: key 'tessera:nextcloud-status:sort:<userId>', unknown stored value → 'name', throwing localStorage → default and no throw.
- Page: USE user (useCanManageModule false) sees tiles and the sort selector but no "Cloud hinzufügen", "Jetzt prüfen", per-tile refresh/edit buttons; manager (true) sees all of them; choosing 'Status' reorders tiles red first and writes the preference; a stored preference is applied on load; "Jetzt prüfen" calls checkAll and replaces the list.
- CloudForm: required Kundenname and URL; logo choice "Keins / Bild hochladen / Bildadresse"; https-only hint on logo URL; submit calls create (or update) and, when a file is chosen, uploadLogo afterwards; delete asks for confirmation before calling remove; API error message is shown in the form.
</behavior>
<action>
**API write paths (L-02, L-09, D-A).** `nextcloud-logo-rules.ts`: `NEXTCLOUD_LOGO_MAX_BYTES = 1024 * 1024`, `checkLogoUpload(buffer)` → mime or null, built on `detectImageMime` from `../dashboard/dashboard-image-rules` (German comment: no SVG because inline script; magic bytes instead of client mimetype, pattern T-PI9-01). Extend `nextcloud-status.service.ts` with `updateInstance`, `deleteInstance`, `uploadLogo(tenantId, id, file)` (BadRequestException 'Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.'), `getLogo`, `removeLogo`, `checkAllForTenant` (small promise pool of 4, each instance isolated with try/catch + Logger.warn), `listInstanceIdsForTenant`, and `loadAllInstancesForScheduler()` — the single `const systemPrisma = forSystem(this.prisma);` access, `select: { id: true, tenantId: true }`, German comment naming FORSYSTEM_ALLOWED_CALL_SITES and the system_read_policy of migration 20261002150000. All per-row writes stay `forTenant` with `where: { id, tenantId }`.
**Controller routes (L-08, L-09, L-12).** Final handler order in `nextcloud-status.controller.ts`: `@Get('instances') list`; `@Post('instances') create`; `@Post('instances/check') checkAll` (static, BEFORE any `:id` route); `@Put('instances/:id') update`; `@Delete('instances/:id') remove`; `@Post('instances/:id/check') checkOne`; `@Get('instances/:id/logo') logo` (USE level; sets Content-Type from the stored mime, `Cache-Control: private, max-age=86400`, `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; sandbox`, sends the bytes — pattern favorites getIcon); `@Post('instances/:id/logo')` with `FileInterceptor('logo', { limits: { fileSize: NEXTCLOUD_LOGO_MAX_BYTES, files: 1 } })` uploadLogo; `@Delete('instances/:id/logo') removeLogo`. Every write/check handler gets `@ModuleManage('nextcloud-status')` and no role decorator. Extend the controller spec with the metadata and declaration-order assertions, and add a `NextcloudStatusController` block to `apps/api/src/module-registry/module-manage-handlers.spec.ts` (manage handlers listed via it.each, list/logo stay USE level).
**Scheduler (L-08, D-C).** `nextcloud-status-scheduler.service.ts` implementing `OnApplicationBootstrap` (German header: why not OnModuleInit — Tender bootstrap lesson; why a single global job instead of per-tenant jobs — fixed hourly interval, no per-row setting, no DB read at registration). Reuse the `require('cron').CronJob` workaround and the SchedulerRegistry calls from ProxmoxSchedulerService; job name `nextcloud-status-poll`, cron `0 * * * *`; `running` flag as overlap guard; tick = `loadAllInstancesForScheduler()` → group by tenantId → all instances of the tick run through one pool of at most 4 concurrent `checkInstance(tenantId, id)` calls (each bound to its own tenantId), every error logged with tenant and id. Bootstrap also calls `void this.release.refresh()` (caught). Register the scheduler in `nextcloud-status.module.ts` providers. Spec per `<behavior>` with mocked SchedulerRegistry (pattern proxmox-scheduler.service.spec.ts).
**RLS gates.** Add `['apps/api/src/nextcloud-status/nextcloud-status.service.ts', 1]` to `FORSYSTEM_ALLOWED_CALL_SITES` in `rls-access-inventory.spec.ts`, extending its history comment (quick-261002-k67: hourly job reads id/tenantId of all instances, writes per row tenant-bound; new totals). Update `docs/mandantentrennung-zugriffsklassifikation.md`: Bereichszeile counts (bound/system), Summenzeile, Fundstellentabelle row for `nextcloud-status.service.ts`/`nextcloudInstance` now `system-gebunden` with the explanation (precedent proxmox row); recount with the Gate-Schleife, never copy numbers.
**Web (L-02, L-07, L-09, D-G).** `nextcloud-status-api.ts`: add `createInstance`, `updateInstance`, `deleteInstance`, `checkAll`, `checkOne`, `uploadLogo` (FormData field 'logo'), `removeLogo`; non-ok responses throw an Error carrying the API `message` (pattern proxmox-api.ts). `sort-clouds.ts`: `SortKey = 'name'|'status'|'version'|'eol'`, `sortClouds(items, key)` per D-G (pure, returns a new array, `localeCompare(…, 'de', { sensitivity: 'base' })`), `readSortPreference(userId)` / `writeSortPreference(userId, key)` with key `tessera:nextcloud-status:sort:<userId>`, everything in try/catch, unknown values fall back to 'name'. Page: sort `<select>` with the four options (label "Sortieren nach"), applied via useMemo; user id from `useAuthStore`; `const canManage = useCanManageModule('nextcloud-status') === true` gates "Cloud hinzufügen", "Jetzt prüfen" (spinner while running, then replace list with the response) and, on each tile, refresh (checkOne, replaces that tile) and edit (opens CloudForm). `CloudForm.tsx`: dialog/panel (follow the proxmox ServerForm look) for add/edit with Kundenname, URL (placeholder https://cloud.example.com), logo choice radio "Kein Logo / Bild hochladen / Bildadresse (https)", file input accept="image/png,image/jpeg,image/gif,image/webp", client-side size hint 1 MB, preview of the current logo, "Logo entfernen", delete button with confirmation dialog ("Möchten Sie die Cloud „{name}“ wirklich entfernen?"), saving state "Wird geprüft …" (create awaits the first check), API errors shown inline. All new texts in `nextcloudStatus.*` de + en, formal Sie, real umlauts, no tenant wording; umlaut guard green. Tests per `<behavior>`: `sort-clouds.test.ts`, `CloudForm.test.tsx`, and new manager/USE/sorting cases in `nextcloud-status-page.test.tsx` (mock `@/lib/use-module-capability`).
Biome-lint touched files, commit `feat(nextcloud-status): Clouds verwalten, Logos, stündliche Prüfung und Sortierung` (attribution line). Do not push.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/nextcloud-status src/module-registry rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run nextcloud-status umlaut && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test -z "$(grep -nE '^\s*@Roles\(' apps/api/src/nextcloud-status/nextcloud-status.controller.ts)"</automated>
</verify>
<done>All write, logo and check routes exist behind ModuleManage (metadata + route-order specs green); the hourly job is registered in onApplicationBootstrap without a DB read and checks every instance tenant-bound; forSystem allowlist and RLS doc updated; the page offers sorting for everyone (remembered per user) and add/edit/delete/logo/check controls only to managers; commit on main, not pushed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Dashboard tile "Nextcloud-Status", docs and changelog, full suites, local rebuild</name>
<files>packages/shared/src/index.ts, apps/api/src/dashboard/widget-module-map.ts, apps/api/src/dashboard/widget-module-map.spec.ts, apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/dashboard/widget-registry.test.tsx, apps/web/src/components/dashboard/widget-catalog-modal.test.tsx, apps/web/src/components/dashboard/widgets/widget-icon.tsx, apps/web/src/components/dashboard/widgets/widget-wrapper.tsx, apps/web/src/components/dashboard/widgets/nextcloud-status-widget.tsx, apps/web/src/components/dashboard/widgets/nextcloud-status-widget.test.tsx, apps/web/src/app/(portal)/page.tsx, apps/web/src/app/(portal)/page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md</files>
<behavior>
- WIDGET_TYPES ends with 'nextcloud-status' (twelve types, existing order unchanged); WIDGET_MODULE_SLUGS maps proxmox → proxmox and 'nextcloud-status' → 'nextcloud-status'; getModuleSlugForWidgetType('nextcloud-status') = 'nextcloud-status'; all other types stay platform tiles.
- Catalog: without module access neither Proxmox nor Nextcloud-Status appears; with access to 'nextcloud-status' only that module tile is added.
- Widget: list with 2 green, 1 yellow (updateTo 34.0.4), 1 red (maintenance) → counters 2/1/1, list shows the red cloud first, then the yellow one, each with name + translated reason; all green → a calm "Alle Clouds sind aktuell" line and no list; empty list → hint "Noch keine Cloud eingetragen"; view mode rows link to /modules/nextcloud-status; edit mode rows are not links; the widget never calls a check endpoint (only listInstances); unknown-rated clouds counted separately only when > 0.
</behavior>
<action>
**Widget registration (L-10).** `packages/shared/src/index.ts`: append `'nextcloud-status'` to WIDGET_TYPES and add `'nextcloud-status': 'nextcloud-status'` to WIDGET_MODULE_SLUGS (extend the comment: second module tile, quick-261002-k67; slug equals the seed and the class-level UseModule). Update the comment in `apps/api/src/dashboard/widget-module-map.ts` (no longer exactly one entry) and `widget-module-map.spec.ts` (module tiles = proxmox and nextcloud-status). `widget-registry.tsx`: WIDGET_CONSTRAINTS `'nextcloud-status': { minW: 6, minH: 4, defaultW: 12, defaultH: 8 }` with a comment, an inline `NextcloudStatusIcon` (cloud path from Task 1), and the registry entry (nameKey 'nextcloudStatus.name', descriptionKey 'nextcloudStatus.description', moduleSlug from WIDGET_MODULE_SLUGS, PlaceholderWidget). `widget-icon.tsx`: 'nextcloud-status' cloud glyph. `widget-wrapper.tsx`: add 'nextcloud-status' to FRAME_HEADER_TYPES (frame header = icon + name, hide-title toggle via TITLE_TYPES) and update its comment. `(portal)/page.tsx`: import and `registerWidget('nextcloud-status', NextcloudStatusWidget)`; add the matching vi.mock in `page.test.tsx`. Update the enumerations in `widget-registry.test.tsx` (twelve types, constraints table, moduleSlug assertion for both module tiles, visibleWidgetTypes cases) and `widget-catalog-modal.test.tsx` (counts and module-access cases).
**Widget component (L-10, L-11).** `nextcloud-status-widget.tsx` ('use client', WidgetProps): reads only `listInstances()` from `@/lib/nextcloud-status-api` (German comment: never triggers checks — pattern T-I8V-02), refresh every 5 min with visibility pause and immediate reload on return (pattern proxmox-widget.tsx); three counter chips using `RATING_STYLE` from `rating-display.ts` (grün/gelb/rot labels plus "ohne Bewertung" only when > 0); below, red then yellow clouds sorted by name (`sortClouds(items, 'status')` from Task 2) with status dot, Kundenname and `ratingReasonText`; container-query compact mode like the proxmox widget (narrow: only counters); in view mode each row and the counter area are Next `Link`s to `/modules/nextcloud-status`, in edit mode plain rows without tabstop (links are in the drag-cancel selector, see proxmox comment); loading, error ("Status konnte nicht geladen werden") and empty states. Messages: `widgets.nextcloudStatus.{name: 'Nextcloud-Status', description: 'Ampelübersicht Ihrer Nextcloud-Clouds'}` and `nextcloudStatus.widget.*` texts in de + en. Widget test per `<behavior>`.
**Docs + changelog (L-13).** `CHANGELOG.md` under "## Unveröffentlicht" → "### Neu": one user-facing German bullet (module in Infrastruktur, Aktivierung im Marktplatz + Freigabe, tiles with Ampel and what the colors mean, newest version shown, hourly check plus "Jetzt prüfen", sorting remembered, logo upload or address, who may maintain clouds = Verwalten, dashboard tile). `docs/anleitung-anwender.md`: new section "### Nextcloud-Status" after "### Proxmox" (what the tile shows, the exact traffic-light rules in plain words incl. 3-month window, grey state, sorting, the dashboard tile, what Verwalten users can do). `docs/anleitung-administration.md`: new subsection after "### Proxmox-Server anbinden…" — "### Nextcloud-Status: Clouds eintragen" (who may maintain, Tessera reads only the public status.php, no login data needed, the API container needs outbound access to the clouds and to endoflife.date, internal addresses allowed, certificate must be valid, logo rules 1 MB PNG/JPEG/GIF/WebP or https address loaded by the browser), plus its entry in the table of contents if the section list there names subsections. No tenant wording in user-facing docs/changelog.
**Final gates (L-12, L-14).** Run full `pnpm --filter @tessera/api test` and `pnpm --filter @tessera/web test`, both tsc, biome lint on all touched files of the three tasks; fix any enumeration test that still expects eleven widget types. Rebuild `docker compose up -d --build api web`, wait for healthy, check `docker compose logs api` for 'Nextcloud-Status module seeded in registry', the mapped routes `/modules/nextcloud-status/instances`, the scheduler log line, and no migration errors; confirm with psql in the db container that the `Module` row `nextcloud-status` has category `infrastructure`. Commit `feat(nextcloud-status): Dashboard-Kachel, Anleitung und Changelog` (attribution line). Do not push; leave the browser check to the orchestrator and list in the SUMMARY what to click (add a public cloud such as a real customer URL, manager vs USE view, sorting, widget).
</action>
<verify>
<automated>pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && node -e 'const de=require("./apps/web/src/messages/de.json"),en=require("./apps/web/src/messages/en.json");const w=(o,p,r)=>{for(const[k,v]of Object.entries(o||{})){const q=p+"."+k;if(v&&typeof v==="object")w(v,q,r);else r[q]=v}return r};const pick=(m)=>({...w(m.nextcloudStatus,"nextcloudStatus",{}),...w(m.widgets&&m.widgets.nextcloudStatus,"widgets.nextcloudStatus",{})});const a=pick(de),b=pick(en);if(Object.keys(a).length<10||Object.keys(a).sort().join()!==Object.keys(b).sort().join()){console.error("key mismatch");process.exit(1)}for(const v of [...Object.values(a),...Object.values(b)])if(/mandant|tenant/i.test(String(v))){console.error("bad text",v);process.exit(1)}' && grep -q "Nextcloud" CHANGELOG.md && grep -q "### Nextcloud-Status" docs/anleitung-anwender.md && docker compose ps --status running --services | grep -qx api && docker compose ps --status running --services | grep -qx web && docker compose logs api 2>&1 | grep -q "Nextcloud-Status module seeded in registry"</automated>
</verify>
<done>Dashboard tile registered (shared types, module map, registry, icon, wrapper, page) and visible only with module access; widget shows counters and red/yellow clouds and links to the module; CHANGELOG, user and admin docs updated; full api + web suites, tsc and biome green; api and web rebuilt and running with the module seeded; commit on main, not pushed.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser → API (`/modules/nextcloud-status/*`) | untrusted caller; tenant/user/role only from the validated JWT |
| API → customer cloud (`<url>/status.php`) | outbound request to a manager-entered address, untrusted response |
| API → endoflife.date | outbound request to a public service, untrusted response |
| browser → logo URL host | the viewer's browser loads an external image |
| manager upload → DB → other users' browsers | uploaded bytes are served back to every module user |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-k67-01 | Information Disclosure (SSRF) | fetchNextcloudStatus | medium | accept | Internal targets allowed by design like Proxmox (L-03); limits: only GET `<url>/status.php`, no credentials, http/https, 3 redirects, 10 s, 64 KiB, whitelisted parsed fields only, errorDetail only own codes; write access requires Verwalten; documented in the code comment and admin docs |
| T-k67-02 | Tampering / Spoofing | logo upload + GET logo | high | mitigate | multer fileSize 1 MiB + service re-check; type from magic bytes (PNG/JPEG/GIF/WebP, no SVG); served with detected mime, nosniff, `default-src 'none'; sandbox`, private cache; logo-rules spec |
| T-k67-03 | Elevation of Privilege | controller write/check/logo routes | high | mitigate | `@ModuleManage('nextcloud-status')` on every write/check handler, no role decorator; controller spec + module-manage-handlers.spec metadata; verify gate greps for role decorators |
| T-k67-04 | Information Disclosure | cross-tenant rows | high | mitigate | forTenant on every request path, `where: { id, tenantId }` → 404, tenant_isolation_policy; system read limited to `select { id, tenantId }` in one allowlisted call site (rls-access-inventory) |
| T-k67-05 | Information Disclosure | external logo URL | low | mitigate | https only (DTO), `referrerPolicy="no-referrer"` on the img, API never fetches it (L-02) |
| T-k67-06 | Denial of Service | hourly job / reference fetch | medium | mitigate | concurrency 4, overlap guard, per-instance try/catch, 10 s timeouts, size caps; reference cache 12 h with 15 min failure backoff and shared in-flight request |
| T-k67-07 | Information Disclosure | list response | medium | mitigate | `PUBLIC_SELECT` without logo bytes; raw bodies never stored or returned; service spec asserts the select |
| T-k67-08 | Elevation of Privilege | route shadowing (`instances/check` vs `:id`) | medium | mitigate | static route declared before `:id` routes; declaration-order assertion in the controller spec |
| T-k67-09 | Tampering | stored URL | low | mitigate | normalizeCloudUrl rejects non-http(s), embedded credentials, overlong input; link rendered with `rel="noopener noreferrer"` |
| T-k67-SC | Tampering | npm/pip/cargo installs | low | accept | No new packages (undici, class-validator, multer, cron already present); nothing to verify |
</threat_model>
<verification>
- Task verify commands all pass; Task 3 runs the full api + web suites (includes rls-coverage, rls-access-inventory, umlaut-guard, module-layouts, widget enumerations).
- `prisma migrate status` up to date locally; drift check exit 0.
- `docker compose ps` shows api and web running after the rebuild; api log shows the seed line and the routes.
- Source coverage audit:
| Source item | Covered by |
|-------------|------------|
| GOAL: Nextcloud-Status module with traffic-light tiles | Tasks 1-3 |
| L-01 slug/category, Proxmox touchpoints end to end | Task 1 (API, migration, web route, registrations, layouts test), Task 3 (widget registration) |
| L-02 URL + Kundenname, logo upload (bytea, magic bytes, 1 MiB, auth route) or https URL | Task 1 (model, create), Task 2 (logo routes, form) |
| L-03 status.php fetch limits, stored result, SSRF comment, no raw bodies | Task 1 |
| L-04 endoflife.date cache 12 h, outage-tolerant, grey without data | Task 1 |
| L-05 traffic-light rules + newest version, pure function with date injection | Task 1 |
| L-06 tile content and reason texts | Task 1 |
| L-07 four sort options remembered per user | Task 2 |
| L-08 hourly job (onApplicationBootstrap), "Jetzt prüfen", per-tile refresh, system context | Task 1 (checkOne), Task 2 (checkAll, scheduler) |
| L-09 rights USE vs MANAGE/admin, useCanManageModule | Task 1 (create/checkOne), Task 2 (all write routes, UI gating) |
| L-10 dashboard widget counters + red/yellow list | Task 3 |
| L-11 German Sie + English, no tenant wording, tokens | Tasks 1-3 + node key check |
| L-12 tests, full suites, tsc, biome | Tasks 1-3 |
| L-13 CHANGELOG + docs | Task 3 |
| L-14 local migration, rebuild, no push | Task 1 (migrate), Task 3 (rebuild) |
</verification>
<success_criteria>
- `NextcloudInstance` exists with RLS policies; migration applied locally without drift.
- Rating, parser, fetch, release cache, service, controller, scheduler, logo rules, sort, page, form and widget tests pass; full api + web suites, both tsc runs and biome on touched files are green.
- USE users see rated tiles and can sort; managers and admins can additionally add/edit/delete clouds, manage logos and trigger checks; the API enforces this with ModuleManage.
- The hourly job is registered on every start, independent of existing rows.
- The dashboard tile appears in the catalog only with module access and links to the module.
- CHANGELOG and both guides describe the module; three commits on main, nothing pushed.
</success_criteria>
<output>
Create `.planning/quick/261002-k67-modul-nextcloud-status-mit-ampel-kacheln/261002-k67-SUMMARY.md` when done (not committed by the executor).
</output>
@@ -0,0 +1,124 @@
---
phase: quick-261002-k67
plan: 01
subsystem: nextcloud-status
tags: [nextcloud, ampel, modul, dashboard-kachel, scheduler, rls]
status: complete
requires:
- module-registry (ModuleGuard, ModuleManage, seedModule)
- Freigabestufe Verwalten (quick-261002-icv)
provides:
- Modul "nextcloud-status" (Kategorie infrastructure) mit Ampel-Kacheln je Cloud
- Tabelle NextcloudInstance (RLS, system_read_policy)
- stündlicher Prüfauftrag nextcloud-status-poll
- Dashboard-Kachel "nextcloud-status" (zweite modulgebundene Kachel)
affects:
- WIDGET_TYPES / WIDGET_MODULE_SLUGS (@tessera/shared)
- docs/mandantentrennung-zugriffsklassifikation.md, FORSYSTEM_ALLOWED_CALL_SITES
tech-stack:
added: []
patterns:
- reine Bewertungsfunktion mit eingereichtem Datum
- Stale-while-revalidate-Zwischenspeicher im Speicher (12 h, 15 min Pause nach Fehlschlag)
- ein globaler Cron-Auftrag in onApplicationBootstrap ohne Datenbankzugriff bei der Registrierung
key-files:
created:
- apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql
- apps/api/src/nextcloud-status/ (Rating, Fetch, Release, Service, Controller, Scheduler, Logo-Regeln, DTO, Seed, Modul, je mit Spec)
- apps/web/src/lib/nextcloud-status-api.ts
- apps/web/src/components/nextcloud-status/ (rating-display, sort-clouds + Test)
- apps/web/src/app/(portal)/modules/nextcloud-status/ (layout, page, CloudTile, CloudForm + Tests)
- apps/web/src/components/dashboard/widgets/nextcloud-status-widget.tsx (+ Test)
modified:
- apps/api/prisma/schema.prisma, apps/api/src/app.module.ts
- apps/api/src/prisma/rls-access-inventory.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md
- apps/api/src/module-registry/module-manage-handlers.spec.ts
- packages/shared/src/index.ts, apps/api/src/dashboard/widget-module-map(.spec).ts
- Web-Registrierungen (module-loader, module-identity, nav-store, module-tile, module-layouts.test, widget-registry(+Tests), widget-icon, widget-wrapper, (portal)/page(+Test)), de.json, en.json, umlaut-dictionary.ts
- CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md
decisions:
- "Logo-Bytes als bytea an der Zeile; Listen-/Planerabfragen wählen sie nie aus, nur getLogo (D-A)"
- "Bewertung beim Lesen in der API; Grundkennung plus Parameter, Übersetzung im Web (D-B)"
- "Ein globaler Cron-Auftrag, beim Start ohne Datenbankzugriff registriert (D-C)"
- "Zertifikate der Clouds werden geprüft, Fehler erscheint als Nicht erreichbar mit Fehlercode (D-H)"
metrics:
duration: etwa 1 h 15 min
completed: 2026-10-02
actuals:
tokens: 52000
tasks: 3
commits: 3
plan_head_before: 7188733f70f88ce7a61c53f76a614c476864bbfd
plan_head_after: 87a7b7cbcd86756b1892c14abd77f66744edc969
---
# Phase quick-261002-k67 Plan 01: Modul Nextcloud-Status mit Ampel-Kacheln
Neues Modul „Nextcloud-Status“ (Gruppe Infrastruktur): Verwalter tragen Nextcloud-Clouds ihrer Kunden ein, Tessera ruft stündlich `<Adresse>/status.php` ab, bewertet die Version gegen die Daten von endoflife.date (Ampel grün/gelb/rot/grau) und zeigt je Cloud eine Kachel sowie eine Übersichtskachel auf dem Dashboard.
## Was gebaut wurde
**Aufgabe 1 (Tracer), Commit 5ef7b0c:** Tabelle `NextcloudInstance` samt Zeilenschutz (Migration 20261002150000, lokal angewendet, `migrate status` aktuell, Drift-Prüfung Exit 0). Reine Funktion `rateNextcloud` (alle Regeln aus L-05 einschließlich der Grenzen 90/91 Tage und Support-Ende-Tag gelb, Tag danach rot), `normalizeCloudUrl`, `parseNextcloudStatus`, `fetchNextcloudStatus` (nur GET `<Adresse>/status.php`, 10 s, 3 Weiterleitungen, 64 KiB, keine Zugangsdaten, nur eigene Fehlerkürzel). `NextcloudReleaseService` als Zwischenspeicher (12 h, veraltete Daten sofort, Erneuerung im Hintergrund, 15 min Pause nach Fehlschlag, geteilte Anfrage). Dienst, Controller (`GET instances`, `POST instances`, `POST instances/:id/check`), Seed, Modul, RLS-Inventar. Web: Modulseite mit Kacheln (Ampelleiste, Pille mit Klartext, Logo oder Initialen, Version, letzte Prüfung), Registrierungen in Loader, Identität (Wolkensymbol), Seitenleisten-Titel, Layout-Test, Texte de/en.
**Aufgabe 2, Commit 6698ef1:** Ändern, Entfernen, Logo hochladen/abrufen/entfernen, „Jetzt prüfen“ (`POST instances/check`, statisch vor allen `:id`-Routen) und Einzelprüfung, alle mit `@ModuleManage('nextcloud-status')` und ohne Rollen-Decorator; Logo per Magic Bytes (PNG/JPEG/GIF/WebP, kein SVG, 1 MiB, multer plus Zweitprüfung). Stündlicher Auftrag `nextcloud-status-poll` (`0 * * * *`) in `onApplicationBootstrap` ohne Datenbankzugriff; je Durchlauf ein `forSystem`-Aufruf (nur `id`, `tenantId`), danach jede Cloud an ihren Mandanten gebunden, höchstens vier gleichzeitig, Überlappungsschutz. Web: Sortierung (Kundenname, Status, Version, Support-Ende; je Benutzer in localStorage), Formular (Anlegen/Bearbeiten/Löschen mit Bestätigung/Logo), Verwalten-Knöpfe nur mit `useCanManageModule`. `FORSYSTEM_ALLOWED_CALL_SITES` und die Zugriffsklassifikation nachgezogen.
**Aufgabe 3, Commit 87a7b7c:** Dashboard-Kachel `nextcloud-status` (geteilte Typliste, Modulzuordnung `nextcloud-status` → `nextcloud-status`, Registry, Symbol, Rahmenkopf, Katalog nur mit Modulzugriff): Zähler grün/gelb/rot (und „ohne Bewertung“ nur wenn > 0), darunter rote, dann gelbe Clouds mit Grund; Klick öffnet das Modul, im Bearbeitungsmodus keine Links, ruft nie eine Prüfung auf. CHANGELOG, Anwender- und Administrationsanleitung.
## Testergebnisse (ehrlich)
| Prüfung | Ergebnis |
|---|---|
| API-Tests gesamt (`pnpm --filter @tessera/api test`) | 118 Dateien, **2030 Tests grün** |
| Web-Tests gesamt (`pnpm --filter @tessera/web test`) | 119 Dateien, **1276 Tests grün** |
| `tsc --noEmit` api | grün |
| `tsc --noEmit` web | grün |
| Biome lint auf den neuen/geänderten Dateien | keine Meldungen auf neuen Dateien; die zehn verbleibenden Warnungen der Gesamtläufe stammen aus bestehenden Widget-Dateien (z. B. Stoppuhr) und wurden nicht angefasst |
| Biome check --write | nur auf neuen Dateien angewendet |
| Schlüsselabgleich de/en (`nextcloudStatus`, `widgets.nextcloudStatus`) und Prüfung auf Mandanten-Wörter | 74 Schlüssel je Sprache, deckungsgleich, kein Treffer |
| Umlaut-Wächter | grün (Allowlist: Neueste, ausstehend, Statusseite, Bildadresse, aktuell) |
| rls-coverage, rls-access-inventory | grün (93 Paare, Bereichszeile 0/14/1, Summe 61/264/8, 7 Dateien/8 `forSystem`-Aufrufe) |
Neue Tests: Rating (22), Fetch/Parser/URL (35), Release-Cache (10), Service (18), Controller (11), Scheduler (5), Logo-Regeln (6), module-manage-handlers (+9), Web: Seite (13), CloudForm (9), sort-clouds (8), Widget (8) sowie angepasste Aufzählungstests der Widget-Typen.
## Lokaler Stand
- Migration lokal angewendet (Container-IP 172.19.0.2), `docker compose up -d --build api web` gebaut, api healthy, web läuft.
- API-Log: „Nextcloud-Status module seeded in registry“, „Nextcloud-Status cron job registered: 0 * * * *“, alle neun Routen gemappt (`instances/check` vor `instances/:id`), „No pending migrations to apply“.
- Datenbank: `Module`-Zeile `nextcloud-status`, Kategorie `infrastructure`, `isSystem` true; Tabelle `NextcloudInstance` vorhanden.
- Nicht gepusht. SUMMARY/STATE/PLAN nicht committiert (macht der Orchestrator).
## Abweichungen vom Plan
Keine funktionalen Abweichungen. Drei kleine Anmerkungen:
1. **[Hinweis] Zugriffsklassifikation in zwei Schritten:** Task 1 trug `nextcloud-status.service.ts`/`nextcloudInstance` als `gebunden` ein (0/4/0), Task 2 stellte es wie geplant auf `system-gebunden` (0/14/1) um. Die Zahlen stammen aus der Gate-Schleife (`grep -cE 'tenantPrisma\.[a-zA-Z]*\.'` über die nicht-Spec-Dateien), nicht aus Annahmen.
2. **[Rule 1 - Fehler] Zählerstand im Registry-Test:** Die Erwartung `counted` (44) im bestehenden Registry-Test musste auf 48 steigen (zwölf Typen mal vier Felder); dazu mussten die Aufzählungen aus dem Plan angepasst werden (Katalog-, Registry-, Modulkarten-Tests). Beim ersten Voll-Lauf war zusätzlich `widget-module-map.spec.ts` noch auf einen Modulbezug eingestellt; im selben Task behoben, danach alle Tests grün.
3. **[Hinweis] Ledger für `commits:`:** Der Ledger-Eintrag wurde erst nach dem ersten Commit angelegt und aus dessen Elternkommit (7188733) gebildet; die Zählung (3) entspricht `git rev-list --count 7188733..HEAD`.
## Bekannte Stubs
Keine.
## Bedrohungen (Threat Model)
Alle als `mitigate` eingestuften Punkte sind umgesetzt und getestet: T-k67-02 (Logo: Magic Bytes, nosniff, Sandbox-CSP, 1 MiB), -03 (`@ModuleManage` ohne Rollen-Decorator, Metadaten-Specs), -04 (`forTenant`, `where { id, tenantId }`, ein einziger `forSystem`-Aufruf mit `select { id, tenantId }`), -05 (nur https, `referrerPolicy="no-referrer"`, API ruft die Logo-Adresse nie ab), -06 (Nebenläufigkeit 4, Überlappungsschutz, Zeitlimits), -07 (`PUBLIC_SELECT` ohne `logoData`, Spec prüft), -08 (statische Route vor `:id`, Reihenfolge-Test), -09 (`normalizeCloudUrl`). T-k67-01 (SSRF auf manuell eingegebene, auch interne Adressen) ist wie geplant akzeptiert und im Code und in der Administrationsanleitung dokumentiert.
## Threat Flags
Keine neuen Angriffsflächen außerhalb des Plans.
## Für die Browser-Prüfung (Orchestrator)
1. Als Administrator im Marktplatz „Nextcloud-Status“ aktivieren (falls noch nicht), Freigabe erteilen; die Seite unter `/modules/infrastructure/nextcloud-status` und `/modules/nextcloud-status` öffnen (Seitenleiste, Gruppe Infrastruktur).
2. „Cloud hinzufügen“: eine öffentlich erreichbare Nextcloud eintragen (Kundenname + Adresse, z. B. eine echte Kunden-Cloud). Erwartung: Kachel mit Version, Ampel und Klartext, „Zuletzt geprüft vor …“; Kopfzeile nennt die neueste Nextcloud-Version. Außerdem eine nicht erreichbare Adresse (rot „Nicht erreichbar“) und eine Adresse ohne Nextcloud (rot „Keine gültige Nextcloud-Antwort“) probieren.
3. Logo: einmal PNG hochladen (Kachel zeigt es), einmal https-Bildadresse, „Logo entfernen“ (Initialen); eine Nicht-Bilddatei und eine Datei über 1 MB (Fehlermeldung im Formular).
4. Sortierung: alle vier Optionen, danach neu laden (Auswahl bleibt, im Dunkelmodus ebenfalls prüfen).
5. Verwalten-Stufe: Benutzer nur mit „Benutzen“ anmelden (sieht Kacheln und Sortierung, aber weder „Cloud hinzufügen“, „Jetzt prüfen“, Kachel-Knöpfe noch Bearbeiten), Benutzer mit „Verwalten“ (sieht alles). „Jetzt prüfen“ und Kachel-Prüfung ausprobieren.
6. Dashboard: im Bearbeitungsmodus „Widget hinzufügen“ → „Nextcloud-Status“ (nur mit Modulzugriff im Katalog). Zähler, rote/gelbe Liste mit Grund, Klick öffnet das Modul; kleine Kachel zeigt nur die Zähler.
7. Optional: `docker compose logs api` nach der vollen Stunde — Prüfläufe der Clouds laufen ohne Fehler.
## Self-Check: PASSED
- Dateien vorhanden: Migration, `apps/api/src/nextcloud-status/*`, `apps/web/src/app/(portal)/modules/nextcloud-status/*`, Widget und Tests (alle per Commit nachgewiesen).
- Commits vorhanden: 5ef7b0c, 6698ef1, 87a7b7c (`git log`), 3 Commits seit 7188733.
- Laufender Stand: api healthy, Seed- und Planer-Logzeilen vorhanden, Modul-Zeile in der Datenbank.
@@ -0,0 +1,306 @@
---
phase: quick-261002-kxc
plan: 01
type: execute
wave: 1
depends_on: []
quick_id: 261002-kxc
description: "Nextcloud-Status: persönliche Benachrichtigung (Glocke je Kachel) bei Störung und Wiederherstellung, per E-Mail und in Tessera"
date: 2026-10-02
files_modified:
# Task 1 — tracer: DB -> Ausfall-Regeln -> Pruefung -> Anspruch -> Mail; Glocke API + Kachel
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20261002170000_nextcloud_alerts/migration.sql
- apps/api/src/nextcloud-status/nextcloud-alert-rules.ts
- apps/api/src/nextcloud-status/nextcloud-alert-rules.spec.ts
- apps/api/src/nextcloud-status/nextcloud-alert-mail.ts
- apps/api/src/nextcloud-status/nextcloud-alert-mail.spec.ts
- apps/api/src/nextcloud-status/nextcloud-alert.service.ts
- apps/api/src/nextcloud-status/nextcloud-alert.service.spec.ts
- apps/api/src/nextcloud-status/nextcloud-status.service.ts
- apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts
- apps/api/src/nextcloud-status/nextcloud-status.controller.ts
- apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts
- apps/api/src/nextcloud-status/nextcloud-status.module.ts
- apps/api/src/mail/mail.service.ts
- apps/api/src/mail/mail.service.spec.ts
- apps/api/src/module-registry/module-manage-handlers.spec.ts
- docs/mandantentrennung-zugriffsklassifikation.md
- apps/web/src/lib/nextcloud-status-api.ts
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx
- apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx
- apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
# Task 2 — Wiederholung nach 5 min, Adresswechsel, Hinweis auf der Kachel
- apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts
- apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.ts
# Task 3 — In-App-Meldung, Anleitung, Changelog, Abschluss
- apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx
- apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.test.tsx
- apps/web/src/components/layout/app-shell.tsx
- CHANGELOG.md
- docs/anleitung-anwender.md
- docs/anleitung-administration.md
autonomous: true
requirements: [QUICK-261002-kxc]
estimate:
tokens: 150000
raw_tokens: 150000
tasks: 3
confidence: low
must_haves:
truths:
- "Every user who can open Nextcloud-Status (grant Benutzen or Verwalten, or administrator) sees a bell on each cloud tile, can switch it on and off, and the state survives a reload; it is personal per user and per cloud (L-01)"
- "When a subscribed cloud turns red (unreachable, invalid answer, maintenance, database upgrade pending, support expired) every subscriber with current module access and an active account gets exactly one e-mail; while it stays red nothing more is sent; when it turns yellow or green again exactly one 'wieder in Ordnung' e-mail follows (L-02, L-05, L-06)"
- "A cloud that fails one check keeps showing its last known state with the hint 'Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt'; it turns red and triggers the notification only after a second failed check, and Tessera repeats the check about 5 minutes after the first failure instead of waiting for the next hour; maintenance, database upgrade and expired support count immediately (L-03)"
- "Two concurrent checks, several API instances or a restart never send the same notification twice — the transition is claimed on the cloud row before any mail is sent (L-02)"
- "Without SMTP setup, without an e-mail address, with a deactivated account or without module access the mail is skipped and only logged; a failing SMTP transport is tried at most three times (L-04, L-06)"
- "While Tessera is open (browser or desktop app) a subscriber also gets an on-screen notification for each transition — in the desktop app as a Windows notification — at most once per transition and client (L-04)"
- "No user-facing text (mail, tile, notification, docs, changelog) contains a word for tenant (L-10)"
artifacts:
- path: "apps/api/prisma/migrations/20261002170000_nextcloud_alerts/migration.sql"
provides: "NextcloudAlertSubscription table (RLS with user dimension, cascades) + alert/failure columns on NextcloudInstance"
contains: "NextcloudAlertSubscription"
- path: "apps/api/src/nextcloud-status/nextcloud-alert-rules.ts"
provides: "pure decideAlert + planStatusWrite + retry constants"
exports: ["decideAlert", "planStatusWrite", "FAILURES_FOR_RED", "RETRY_DELAY_MS"]
- path: "apps/api/src/nextcloud-status/nextcloud-alert-mail.ts"
provides: "pure German mail builder (subject + plain text)"
exports: ["buildNextcloudAlertMail"]
- path: "apps/api/src/nextcloud-status/nextcloud-alert.service.ts"
provides: "subscriptions, claim-before-send, recipient re-check, mail delivery with 3 attempts, recent alerts per user"
- path: "apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx"
provides: "global in-app notifier mounted in AppShell"
key_links:
- from: "NextcloudStatusService.checkInstance"
to: "planStatusWrite -> rateNextcloud -> NextcloudAlertService.evaluateAfterCheck"
via: "every check (hourly, retry, manual, create, URL change) runs the guard and the transition claim"
pattern: "evaluateAfterCheck\\("
- from: "NextcloudAlertService.evaluateAfterCheck"
to: "nextcloudInstance.updateMany where alertState = previous state"
via: "claim-before-send: only count === 1 notifies"
pattern: "alertState: prev"
- from: "NextcloudStatusSchedulerService retry job"
to: "NextcloudStatusService.loadAllInstancesForScheduler({ retryDueBefore })"
via: "same single forSystem call site, filtered to consecutiveFailures = 1"
pattern: "retryDueBefore"
- from: "apps/web/src/components/layout/app-shell.tsx"
to: "NextcloudAlertNotifier -> GET /modules/nextcloud-status/alerts -> showReminderNotification"
via: "global mount next to ReminderNotifier"
pattern: "<NextcloudAlertNotifier />"
---
<objective>
Extend the module `nextcloud-status` (quick 261002-k67) with personal notifications: a bell per tile, e-mail plus in-app notification when a subscribed cloud goes red and when it recovers, with a two-strike guard against flapping and a quick re-check after the first failure.
Locked decisions from the request (cited below as L-xx):
- L-01 Every user who can see the module (grant USE or MANAGE, or admin) gets a bell toggle "Benachrichtigen" on each tile, personal per user per cloud. New Prisma table user x instance, tenant RLS like the other tables, cascade on instance/user delete. Toggle endpoints need only module access (USE), not manage. Tile shows the bell state; the list endpoint returns `subscribed` per instance for the current user.
- L-02 Trigger: transition INTO red (unreachable, maintenance, needsDbUpgrade, EOL passed, invalid response) notifies subscribers once; staying red = no repeat; red back to yellow/green = one "wieder in Ordnung" notification. Last notified state stored on the instance so restarts/multiple polls never duplicate; claim-before-send with an `updateMany` guard like `reminder-mail.scheduler.ts`.
- L-03 Flapping guard: "unreachable" only counts as red after 2 consecutive failed checks (counter on the instance); the tile keeps showing the last good state plus a hint until the second failure; other red reasons (maintenance, EOL) count immediately; after a first failure the cloud is re-checked after about 5 minutes.
- L-04 Channels: e-mail via the existing MailService/SMTP config exactly like reminder mails (skip silently + log if SMTP missing, user has no e-mail or is inactive; max 3 attempts) plus an in-app notification while Tessera is open, reusing the reminder mechanism (`reminder-notifier.tsx`, `reminder-notify.ts`; desktop app = Windows notification) with a small "recent alerts" endpoint.
- L-05 Mail text German, plain and short: subject "Nextcloud <Kundenname>: nicht erreichbar" / "Nextcloud <Kundenname>: wieder in Ordnung", body with reason, URL, time and link to the module. Reminder mails are German-only (`MailService.sendReminderEmail`), so these mails are German-only too.
- L-06 Only users who still have module access at send time (grant re-checked) and only active users are notified.
- L-07 Tests: transition logic as pure function (green->red, red->red, red->green, unreachable once vs twice, maintenance immediate), claim/no-duplicate, subscription endpoints + guard metadata, web bell toggle + notifier; full api + web suites, tsc, biome on touched files.
- L-08 CHANGELOG (Unveröffentlicht, German, user-facing) + docs update.
- L-09 Local migration via db container IP + `prisma migrate deploy`; rebuild `docker compose up -d --build api web`.
- L-10 No word for tenant in user texts. Do not push. SUMMARY documents how to force a red transition locally and whether local SMTP is configured (it is: one `SmtpConfig` row with a host exists in the local db).
Claude's discretion, decided here (cited as D-Kx):
- D-K1 The two-strike guard applies to every failed fetch (`reachable === false`, all error kinds incl. `not-nextcloud`): each comes from one HTTP call and can be transient (a proxy error page during a restart is an "invalid answer"). Maintenance and DB upgrade come from a successful answer, EOL from the date — both immediate.
- D-K2 First failure writes ONLY `consecutiveFailures = 1` and `firstFailureAt`; all status fields and `lastCheckedAt` stay untouched, so the rating (computed from stored fields) keeps the last good state. A never-checked cloud therefore stays grey "Noch nicht geprüft" plus the hint. The second failure writes the failure fields as today.
- D-K3 Quick retry = a second cron job `nextcloud-status-retry` every minute that checks only clouds with exactly one failure whose `firstFailureAt` is at least 5 minutes old. It reuses the ONE existing `forSystem` call site (`loadAllInstancesForScheduler` gets an optional filter) — no new system read, no new policy. State lives on the row, so it survives restarts.
- D-K4 Transition state on the instance: `alertState` ('ok' | 'red', default 'ok'), `alertReason`, `alertChangedAt`. Grey ("unknown") changes nothing. Existing rows start as 'ok'.
- D-K5 Mail delivery runs in the background after a won claim (never blocks "Jetzt prüfen"); per recipient up to 3 attempts, 60 s apart, in-process. A restart between attempts drops the remaining ones — accepted: a late status mail after a restart has little value, and tile plus in-app notification show the state anyway. Skips (no SMTP / no address / inactive / no access) are logged and never retried.
- D-K6 Subject per red reason: unreachable uses the locked wording "nicht erreichbar"; the other red reasons get their own short wording ("keine gültige Antwort", "im Wartungsmodus", "Datenbank-Aktualisierung ausstehend", "Support abgelaufen") so a subject is never factually wrong; recovery uses the locked "wieder in Ordnung".
- D-K7 In-app: `GET modules/nextcloud-status/alerts` returns, for the caller's subscribed clouds, the latest transition of the last 24 h that happened after the caller subscribed. The client dedupes per (cloud, changedAt) with the existing reminder helpers and only polls when the user has module access.
- D-K8 Changing a cloud's address resets the status fields and the failure counter but KEEPS `alertState` — fixing a broken address therefore sends "wieder in Ordnung" to subscribers.
- D-K9 Subscription RLS with user dimension like "Reminder" (`current_user_id() IS NULL OR "userId" = current_user_id()`), no `system_read_policy` (never read in system context).
- D-K10 List responses (`GET instances`, `POST instances/check`) carry `subscribed`; single-cloud responses do not — the page keeps the tile's bell state when it swaps in a single checked tile. Switching a bell on for the first time asks the browser for notification permission once (`requestBrowserPermissionOnce`).
Purpose: subscribers learn about an outage within minutes instead of noticing it on the next visit, without false alarms from a single hiccup.
Output: one migration, alert rules/mail/service in `apps/api/src/nextcloud-status/`, bell + hint on the tile, global notifier, docs and changelog.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
@.planning/quick/261002-k67-modul-nextcloud-status-mit-ampel-kacheln/261002-k67-SUMMARY.md
@apps/api/src/nextcloud-status/nextcloud-status.service.ts
@apps/api/src/nextcloud-status/nextcloud-status.controller.ts
@apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts
@apps/api/src/nextcloud-status/nextcloud-rating.ts
@apps/api/src/reminders/reminder-mail.scheduler.ts
@apps/api/src/module-registry/module-access.service.ts
@apps/web/src/lib/reminder-notify.ts
@apps/web/src/components/reminders/reminder-notifier.tsx
@apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx
Facts gathered during planning (no need to re-discover):
- `rateNextcloud(status, reference, now)` computes the rating from the STORED fields; `NextcloudStatusService.toView` calls it at read time. `checkInstance(tenantId, id)` is the single write path for every check (hourly tick, "Jetzt prüfen", per-tile check, create, URL change).
- `fetchNextcloudStatus` returns `reachable: false` with `errorKind` in timeout | network | tls | http-status | not-nextcloud | too-large | redirect; `errorDetail` is a short code like `HTTP 502` or `ECONNREFUSED`.
- `ModuleAccessService.getModuleAccessLevels(tenantId, userId, role)` (exported by `ModuleRegistryModule`) is the single source for access incl. admin short-circuit; `ModuleRegistryService.findBySlug('nextcloud-status')` gives the module id. `MailModule` exports `MailService`, `SettingsModule` exports `SettingsService.getSmtpConfig(tenantId)` (null = not set up). `MailService` keeps `appUrl` from `TESSERA_APP_URL`; reminder mails are German-only, time zone Europe/Berlin.
- Controllers get the user via `@CurrentUser() user: AuthUser` (see `reminders.controller.ts`), tenant via `requireTenantId(req)`.
- `forTenant(prisma, tenantId, userId?)` sets the user context; every call must use the assignment form `const tenantPrisma = forTenant(...)` (checked by `rls-access-inventory.spec.ts`), `forSystem` only `const systemPrisma = forSystem(...)`. `FORSYSTEM_ALLOWED_CALL_SITES` allows exactly 1 call in `nextcloud-status.service.ts` — keep it at 1. Selects stay scalar (no relation keys) so no new relation pairs appear.
- `docs/mandantentrennung-zugriffsklassifikation.md` keeps a Bereichszeile `nextcloud-status` (currently 0/14/1), a Summenzeile (61/264/8), pair counts (93) and the Fundstellentabelle; k67 shows how each task updated them with the Gate-Schleife. Every new (file, model) pair needs a row.
- Web: `reminder-notify.ts` exports `claimNotification(key, nowMs)`, `withNotifyLock`, `showReminderNotification({title, body, tag})` (Tauri plugin in the desktop app, Web Notification in the browser), `requestBrowserPermissionOnce`, `CATCH_UP_WINDOW_MS`. `ReminderNotifier` is mounted in `apps/web/src/components/layout/app-shell.tsx`. Access check pattern: `GET /modules/active` (see `use-module-capability.ts`).
- Local SMTP is configured (one `SmtpConfig` row with host). Containers `tessera-ctl-db-1`, `tessera-ctl-api-1`, `tessera-ctl-web-1` run. Latest migration: `20261002150000_nextcloud_status`.
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1 (Tracer): Glocke einschalten -> Cloud fällt zweimal aus -> genau eine Mail an den Abonnenten</name>
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261002170000_nextcloud_alerts/migration.sql, apps/api/src/nextcloud-status/nextcloud-alert-rules.ts, apps/api/src/nextcloud-status/nextcloud-alert-rules.spec.ts, apps/api/src/nextcloud-status/nextcloud-alert-mail.ts, apps/api/src/nextcloud-status/nextcloud-alert-mail.spec.ts, apps/api/src/nextcloud-status/nextcloud-alert.service.ts, apps/api/src/nextcloud-status/nextcloud-alert.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.service.ts, apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.module.ts, apps/api/src/mail/mail.service.ts, apps/api/src/mail/mail.service.spec.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/nextcloud-status-api.ts, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
<precondition>`docker ps` lists `tessera-ctl-db-1` as running (needed for the local migration).</precondition>
<behavior>
- decideAlert('ok', red) -> 'down'; ('red', red) -> null; ('red', green) -> 'up'; ('red', yellow) -> 'up'; ('red', unknown) -> null; ('ok', green) -> null; ('ok', unknown) -> null
- planStatusWrite(prevFailures 0, failed result) -> outcome 'pending', data only consecutiveFailures 1 + firstFailureAt now (no status field, no lastCheckedAt); prevFailures 1 + failed -> 'confirmed' with reachable false, errorKind, errorDetail, lastCheckedAt and consecutiveFailures 2; any success -> 'ok' with all status fields, consecutiveFailures 0, firstFailureAt null; success with maintenance true -> 'ok' (rating red immediately)
- Combined (pure, with rateNextcloud): green cloud + one failure -> rating stays green -> no alert; + second failure -> red -> 'down'; green + maintenance answer -> 'down' at once; red + green answer -> 'up'
- buildNextcloudAlertMail down/unreachable -> subject exactly "Nextcloud <Kundenname>: nicht erreichbar"; up -> "Nextcloud <Kundenname>: wieder in Ordnung"; body contains reason line, URL, time (Europe/Berlin) and "<appUrl>/modules/nextcloud-status"; CR/LF in Kundenname never reaches the subject; no word for tenant in any output
- evaluateAfterCheck: claim updateMany count 1 -> mails to eligible subscribers; count 0 -> no mail (second concurrent check / second API instance); red -> red -> no claim, no mail
- Recipients: inactive user, user without e-mail, user without module access (re-checked via getModuleAccessLevels), missing SMTP -> skipped + logged, no send; send returning false twice then true -> exactly 3 calls; false three times -> 3 calls, then stop
- Subscription endpoints: subscribe is idempotent, unknown/foreign cloud -> 404, unsubscribe removes only the caller's row; list returns subscribed true only for the caller's subscriptions; handlers carry no manage metadata and no role metadata
- Web: bell visible for a USE-only user, aria-pressed reflects subscribed, click calls subscribe/unsubscribe and flips state, failure rolls back and shows the error text; single-tile check keeps the bell state
</behavior>
<action>
**Schema + migration (L-01, L-02, L-03, D-K4, D-K9).** In `apps/api/prisma/schema.prisma` extend `NextcloudInstance` with `consecutiveFailures Int @default(0)`, `firstFailureAt DateTime?`, `alertState String @default("ok")` (comment: 'ok' | 'red', last notified state), `alertReason String?`, `alertChangedAt DateTime?`, and the back-relation `subscriptions NextcloudAlertSubscription[]`; add `nextcloudAlertSubscriptions NextcloudAlertSubscription[]` to `User`; add `model NextcloudAlertSubscription` (German comment, quick-261002-kxc) with `id String @id @default(uuid())`, `tenantId String`, `userId String` + relation to User `onDelete: Cascade`, `instanceId String` + relation to NextcloudInstance `onDelete: Cascade`, `createdAt DateTime @default(now())`, `@@unique([instanceId, userId])`, `@@index([tenantId, userId])`. Hand-write `apps/api/prisma/migrations/20261002170000_nextcloud_alerts/migration.sql` in the style of `20261002150000_nextcloud_status` and `20260929140000_reminder`: German header (purpose; subscription is personal data, hence tenant_isolation_policy WITH user dimension exactly like "Reminder"; no system_read_policy because the table is never read in system context; the new instance columns are covered by the existing policies of "NextcloudInstance"; rights via ALTER DEFAULT PRIVILEGES; switch-is-off note), ALTER TABLE for the five columns (alertState NOT NULL DEFAULT 'ok', consecutiveFailures NOT NULL DEFAULT 0), CREATE TABLE, unique index, index, both foreign keys ON DELETE CASCADE ON UPDATE CASCADE, ENABLE + FORCE ROW LEVEL SECURITY, the policy. Run `pnpm --filter @tessera/api exec prisma generate`; apply locally with the container IP (`docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1`, then `DATABASE_URL="postgresql://tessera:tessera_dev@<IP>:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy`), confirm `prisma migrate status` is up to date and `prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --exit-code` exits 0 (L-09).
**Pure rules, TDD first (L-02, L-03, D-K1, D-K2).** `nextcloud-alert-rules.ts`, no Nest, no Prisma, date passed in: `FAILURES_FOR_RED = 2`, `RETRY_DELAY_MS = 5 * 60 * 1000`, type `AlertState = 'ok' | 'red'`, `decideAlert(prev: AlertState, level: RatingLevel): 'down' | 'up' | null` (grey changes nothing), and `planStatusWrite(prevFailures: number, result: NextcloudCheckResult, now: Date): { outcome: 'ok' | 'pending' | 'confirmed'; data: Record<string, unknown> }` per D-K2 (failure = `result.reachable === false`, any errorKind, D-K1). German header comment explaining the two-strike rule and why a first failure leaves the stored state alone. Write the spec with every case from `<behavior>` (incl. the combined cases that run `rateNextcloud` on the resulting stored fields) BEFORE the implementation, see it fail, then implement.
**Mail builder (L-05, D-K6).** `nextcloud-alert-mail.ts`, pure: `buildNextcloudAlertMail(input, appUrl): { subject: string; text: string }` with input `{ kind: 'down' | 'up'; customerName; baseUrl; rating: NextcloudRating; errorKind; errorDetail; at: Date }`. Subject `Nextcloud <Kundenname>: <wording>` (down wording per reason per D-K6, up "wieder in Ordnung"), CR/LF collapsed to a space, max 150 characters (same as `sendReminderEmail`). Plain-text body, short, Sie-form: "Guten Tag,", one sentence (down: the cloud "<Kundenname>" has a problem since <time>; up: is back in order), "Grund:" line (down: German reason text — "Nicht erreichbar" with errorDetail or a German word for the errorKind in brackets, "Keine gültige Nextcloud-Antwort", "Wartungsmodus eingeschaltet", "Datenbank-Aktualisierung ausstehend", "Support abgelaufen seit <TT.MM.JJJJ>"; up: "Aktueller Stand:" with "Aktuell" / "Update auf <x> verfügbar" / "Support endet am <TT.MM.JJJJ>"), "Adresse: <baseUrl>", "Zeitpunkt: <de-DE, Europe/Berlin, dateStyle full, timeStyle short> Uhr", "Zum Modul: <appUrl>/modules/nextcloud-status", closing line that the mail comes because "Benachrichtigen" is switched on for this cloud and can be switched off with the bell on the tile. Spec covers every subject wording, the locked two subjects verbatim, header-injection stripping, link, and a case-insensitive check that neither subject nor text contains a word for tenant (German or English). In `MailService` add `sendNextcloudAlertEmail(tenantId, to, input)` next to `sendReminderEmail`: builds via `buildNextcloudAlertMail(input, this.appUrl)`, sends through `this.deliver(tenantId, ..., 'NextcloudAlert')`, returns true/false and logs on failure exactly like `sendReminderEmail`; add spec cases mirroring the reminder ones.
**Alert service (L-01, L-02, L-04, L-06, D-K4, D-K5).** `nextcloud-alert.service.ts` (`@Injectable`, deps PrismaService, MailService, SettingsService, ModuleAccessService, ModuleRegistryService). Methods: `subscribe(tenantId, userId, instanceId)` (instance must exist with `{ id, tenantId }` else NotFoundException 'Cloud nicht gefunden'; upsert on the unique pair with `forTenant(prisma, tenantId, userId)`; returns `{ subscribed: true }`), `unsubscribe(...)` (deleteMany where tenantId, userId, instanceId; returns `{ subscribed: false }`), `subscribedInstanceIds(tenantId, userId): Promise<Set<string>>`, `evaluateAfterCheck(tenantId, row, rating, now)` where row carries id, customerName, baseUrl, errorKind, errorDetail, alertState: compute `decideAlert`; null -> return `{ kind: null, delivery: null }`; otherwise claim with `nextcloudInstance.updateMany({ where: { id, tenantId, alertState: prev }, data: { alertState: next, alertReason: down ? rating.reason : null, alertChangedAt: now } })` — only `count === 1` continues (header comment: why the claim stands before sending, same reasoning as `ReminderMailScheduler`). Then start `notifySubscribers` WITHOUT awaiting it inside the check path and return `{ kind, delivery }` (the promise, `.catch` logs) so tests can await it. `notifySubscribers`: subscriptions of the instance (tenant-bound, no user filter), users `where { tenantId, id in, isActive: true }` with scalar select email + role, module id via `findBySlug('nextcloud-status')`, per user `getModuleAccessLevels(tenantId, user.id, user.role)` must contain the module (L-06), SMTP via `getSmtpConfig(tenantId)`; every skip logs one German line with the reason (Benutzer deaktiviert / keine E-Mail-Adresse / kein Modulzugriff / kein E-Mail-Versand eingerichtet) and is never retried; eligible recipients get `sendNextcloudAlertEmail` with up to 3 attempts, `ALERT_MAIL_RETRY_MS = 60_000` apart via an overridable `sleep` member (D-K5, comment states that a restart drops pending attempts and why that is accepted). Spec: claim won/lost, red->red no claim, all skip reasons, 1-3 attempts, access revoked, inactive user.
**Wire into the existing service and controller.** `NextcloudStatusService` gets `NextcloudAlertService` injected. `checkInstance`: load `{ id, baseUrl, consecutiveFailures }`, fetch, `planStatusWrite`, update with that data selecting `PUBLIC_SELECT` plus `alertState`, build the view, then `await this.alerts.evaluateAfterCheck(...)` (awaits only the claim) and return the view. `listForTenant(tenantId, userId)` and `checkAllForTenant(tenantId, userId)` add `subscribed: boolean` per instance from `subscribedInstanceIds` (D-K10); `NextcloudInstanceView` gets an optional `subscribed`. Update the existing service spec (constructor, two-failure path, subscribed flag). Controller: `list` and `checkAll` pass `user.id` via `@CurrentUser()`; new `POST instances/:id/subscription` and `DELETE instances/:id/subscription` with only the class-level `@UseModule` — no manage decorator, no role decorator (L-01). Module imports `MailModule` and `SettingsModule` and provides `NextcloudAlertService`. Extend `module-manage-handlers.spec.ts` so `subscribe`/`unsubscribe` are asserted to stay on Benutzen level next to `list`/`logo`; controller spec covers the two routes (user id from `@CurrentUser`, never from body).
**RLS bookkeeping.** Run `pnpm --filter @tessera/api exec vitest run rls-coverage rls-access-inventory`; add the Fundstellentabelle rows for the new (file, model) pairs (e.g. `nextcloud-alert.service.ts` with `nextcloudInstance`, `nextcloudAlertSubscription`, `user`), update the Bereichszeile `nextcloud-status`, the Summenzeile and the Paarzählung in `docs/mandantentrennung-zugriffsklassifikation.md`, counted with the Gate-Schleife exactly like the k67 entries (measured, not copied). `FORSYSTEM_ALLOWED_CALL_SITES` stays unchanged.
**Web bell (L-01, D-K10).** `nextcloud-status-api.ts`: optional `subscribed?: boolean` on `NextcloudInstance` (missing = false, keeps existing fixtures valid), `subscribe(id)` (POST `${BASE}/${id}/subscription`) and `unsubscribe(id)` (DELETE) with `readErrorMessage`. `CloudTile`: new props `subscribed`, `onToggleSubscription`, `toggling`; a bell icon button shown to EVERY user (outside the `canManage` block, left of the manager buttons), `aria-pressed`, aria-label "Benachrichtigen", title from `bell.titleOn` / `bell.titleOff`, outlined bell when off, filled bell in accent color when on, disabled while toggling. `page.tsx`: optimistic toggle, call subscribe/unsubscribe, on error roll back and show `bell.error` as a small line above the grid; on switching on call `requestBrowserPermissionOnce()` from `@/lib/reminder-notify`; `handleCheckOne` keeps the previous `subscribed` when swapping in the checked tile. Texts in `nextcloudStatus.bell` (`label`, `titleOn`, `titleOff`, `error`) in de.json (real umlauts, Sie-form) and en.json with identical keys. Page test: bell visible for a USE-only user, toggle on/off calls the right function and flips aria-pressed, rollback on error, single check keeps the state.
Biome-lint touched files (`pnpm exec biome lint <files>` from repo root, `biome check --write` only on new files). Commit `feat(nextcloud-status): Benachrichtigung abonnieren und Mail bei Störung` (attribution line). Do not push.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/nextcloud-status src/mail src/module-registry rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run nextcloud-status umlaut && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && DATABASE_URL="postgresql://tessera:tessera_dev@$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1):5432/tessera" pnpm --filter @tessera/api exec prisma migrate status</automated>
</verify>
<done>Migration applied locally and drift-free; pure rules, mail builder, alert service, status service, controller, mail service and manage-handler specs green; a subscribed user's cloud failing twice produces exactly one claimed transition and one mail per eligible recipient; the bell works for USE-level users in the web test; RLS inventory specs green with updated doc; commit on main, not pushed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Wiederholung nach 5 Minuten, Adresswechsel und Hinweis „Prüfung fehlgeschlagen“ auf der Kachel</name>
<files>apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts, apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.service.ts, apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-alert.service.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/nextcloud-status-api.ts, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/nextcloud-status-page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
<behavior>
- retryTick loads only clouds with consecutiveFailures 1 and firstFailureAt at least RETRY_DELAY_MS old (filter passed to loadAllInstancesForScheduler), checks each tenant-bound, max 4 at a time, skips while a previous retry run is active, never throws
- onApplicationBootstrap registers both jobs (nextcloud-status-poll hourly, nextcloud-status-retry every minute) without database access
- loadAllInstancesForScheduler() without filter keeps today's query; with { retryDueBefore } adds where consecutiveFailures 1 and firstFailureAt lte retryDueBefore; select stays { id, tenantId }
- updateInstance with a new address resets reachable, maintenance, needsDbUpgrade, versionString, edition, productName, errorKind, errorDetail, lastCheckedAt, consecutiveFailures, firstFailureAt and does NOT touch alertState; a red cloud whose corrected address answers green yields 'up'
- view status.pendingRetry is true exactly when consecutiveFailures is 1; tile shows the hint then and keeps pill, version and last check from the stored state
</behavior>
<action>
**Retry job (L-03, D-K3).** In `nextcloud-status-scheduler.service.ts` export `NEXTCLOUD_RETRY_JOB_NAME = 'nextcloud-status-retry'` and `NEXTCLOUD_RETRY_CRON = '* * * * *'`; register it in the same `onApplicationBootstrap` inside the existing try/catch (same `CronJobClass` workaround, no database access at registration, log line `Nextcloud-Status retry job registered: * * * * *`). `retryTick(now = new Date())` with its own overlap flag: `loadAllInstancesForScheduler({ retryDueBefore: new Date(now - RETRY_DELAY_MS) })`, then `checkInstance(tenantId, id)` per row with `runWithConcurrency(..., CHECK_CONCURRENCY, ...)`, errors logged per cloud. Extend the header comment: why a second job instead of in-memory timers (survives restarts, state on the row) and why it reuses the one system read. In `NextcloudStatusService.loadAllInstancesForScheduler(filter?: { retryDueBefore: Date })` add the optional where (consecutiveFailures 1, firstFailureAt lte) to the SAME `systemPrisma.nextcloudInstance.findMany` call — still exactly one `forSystem` call in the file. Scheduler spec: both jobs registered, retry filter passed, overlap guard, error isolation.
**Address change (D-K8).** In `updateInstance`, when the normalized address changed, write the reset of the status fields, `consecutiveFailures: 0` and `firstFailureAt: null` together with the new address (alertState untouched), then call `checkInstance` as today. Service spec: reset written, alertState not in the data; a cloud with alertState 'red' whose new address answers green triggers `evaluateAfterCheck` with an 'up' decision (alert service spec: 'up' mail subject "wieder in Ordnung" and "Aktueller Stand" line).
**Hint on the tile (L-03, D-K2).** Add `consecutiveFailures` to `PUBLIC_SELECT`/`PublicRow` and `status.pendingRetry: boolean` (`consecutiveFailures === 1`) to the view; the rating input stays unchanged. Web: optional `pendingRetry?: boolean` in `NextcloudInstanceStatus`; `CloudTile` shows, when true, a small line with a warning-colored dot and the text `card.pendingRetry` ("Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt") between the pill row and the last-check line, `data-testid="pending-retry"`; pill, version and last check stay as stored. en.json gets the same key. Page test: hint shown with pendingRetry, not shown without, pill keeps the stored green level.
Re-run the RLS specs and adjust the doc only if the Gate-Schleife counts changed. Biome-lint touched files, commit `feat(nextcloud-status): erneute Prüfung nach Ausfall und Hinweis auf der Kachel` (attribution line). Do not push.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/nextcloud-status src/module-registry rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run nextcloud-status umlaut && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test "$(grep -v '^\s*//' apps/api/src/nextcloud-status/nextcloud-status.service.ts | grep -c 'forSystem(this.prisma)')" = "1"</automated>
</verify>
<done>Retry job registered and tested; first failure leaves the stored state and shows the hint, second failure (manual, retry or hourly) turns the cloud red; address change resets the check state but keeps the notification state; still exactly one system read in the service; specs and both tsc runs green; commit on main, not pushed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: In-App-Meldung (Desktop: Windows-Benachrichtigung), Anleitung, Changelog, Abschluss und Neubau</name>
<files>apps/api/src/nextcloud-status/nextcloud-alert.service.ts, apps/api/src/nextcloud-status/nextcloud-alert.service.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.ts, apps/api/src/nextcloud-status/nextcloud-status.controller.spec.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/nextcloud-status-api.ts, apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx, apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.test.tsx, apps/web/src/components/layout/app-shell.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md</files>
<behavior>
- listRecentAlerts(tenantId, userId, now) returns only the caller's subscribed clouds with alertChangedAt within 24 h AND not before the caller's subscription createdAt; kind 'down' for alertState red (with reason), 'up' for ok; other users' subscriptions never appear
- GET alerts stays on Benutzen level (no manage, no role metadata)
- Notifier: without module access (slug missing in /modules/active) it never calls the alerts endpoint; with access it shows one notification per (instanceId, changedAt), never twice across polls; title for down/unreachable is "Nextcloud <Name>: nicht erreichbar", for up "Nextcloud <Name>: wieder in Ordnung", body is the address; 401/403 pauses polling until focus
</behavior>
<action>
**Recent alerts endpoint (L-04, D-K7).** `NextcloudAlertService.listRecentAlerts(tenantId, userId, now)`: subscriptions of the caller (`forTenant(prisma, tenantId, userId)`, scalar select instanceId + createdAt), then instances `where { tenantId, id in, alertChangedAt gte now - 24 h }` with scalar select id, customerName, baseUrl, alertState, alertReason, alertChangedAt; drop entries whose alertChangedAt is before the subscription's createdAt; return `{ alerts: [{ instanceId, customerName, baseUrl, kind, reason, changedAt }] }` sorted by changedAt. Controller `GET alerts` (path `modules/nextcloud-status/alerts`, user from `@CurrentUser()`), only class-level `@UseModule`. Specs: service filters, controller route, `module-manage-handlers.spec.ts` asserts Benutzen level for `alerts`. Update the RLS doc rows/counts with the Gate-Schleife and re-run the RLS specs.
**Global notifier (L-04).** `nextcloud-status-api.ts`: `NextcloudAlert` type and `listAlerts()` throwing an error object that carries the HTTP status (pattern `ReminderRequestError`). New `apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx`, modeled on `ReminderNotifier` (renders nothing, translation via refs): on mount and on focus/visibility resume it checks `GET /modules/active` for slug `nextcloud-status` (with `credentials: 'include'`, `cache: 'no-store'`); only with access it loads alerts immediately and every 60 s; for each alert under `withNotifyLock` it calls `claimNotification('nextcloud-alert|' + instanceId + '|' + changedAt, Date.now())` and on first claim `showReminderNotification({ title, body: baseUrl, tag })` — reuse of these helpers is deliberate (same Tauri path = Windows notification in the desktop app, same per-client memory; say so in the header comment). Titles from `nextcloudStatus.notify` in de.json/en.json: `up` and `down.unreachable`, `down.invalidResponse`, `down.maintenance`, `down.needsDbUpgrade`, `down.eolPassed`, each with `{name}`, German wording identical to the mail subjects (D-K6). 401/403 sets a paused flag until the next focus. Mount `<NextcloudAlertNotifier />` in `app-shell.tsx` right after `<ReminderNotifier />` with a short German comment. Notifier test modeled on `reminder-notifier.test.tsx`: no access -> no alerts call; access -> one notification per alert, none on the next poll, correct titles; 403 pauses.
**Docs + changelog (L-08, L-10).** CHANGELOG under "## Unveröffentlicht" / "### Neu": one user-facing German bullet — bell on each Nextcloud-Status tile, personal; mail and on-screen notification (desktop app: Windows notification) when the cloud fails and when it is back in order; one message per change; a single failed check does not alert, Tessera re-checks after about five minutes; mails need the e-mail setup. `docs/anleitung-anwender.md` section Nextcloud-Status: new paragraph "**Benachrichtigen:**" (who sees the bell, what triggers a message, two failed checks for "nicht erreichbar", immediate for maintenance/DB upgrade/support expired, "wieder in Ordnung", hint text on the tile, browser asks once for permission, mail only with e-mail setup and an address in the profile). `docs/anleitung-administration.md` section "Nextcloud-Status: Clouds eintragen": bullet on notifications (SMTP from section 6 required, recipients re-checked at send time, at most three attempts, changing the address sends "wieder in Ordnung" if it was red) and extend the "Rhythmus" bullet with the 5-minute re-check. No word for tenant anywhere in these texts.
**Final gates (L-07, L-09).** Full `pnpm --filter @tessera/api test` and `pnpm --filter @tessera/web test` (if an AppShell-rendering test breaks, mock the new notifier the way `ReminderNotifier` is mocked), both tsc, biome lint on all touched files of the three tasks. Rebuild `docker compose up -d --build api web`, wait for api healthy, check `docker compose logs api` for "Nextcloud-Status module seeded in registry", "Nextcloud-Status retry job registered" and the mapped routes `/modules/nextcloud-status/instances/:id/subscription` and `/modules/nextcloud-status/alerts`, no migration errors. Commit `feat(nextcloud-status): Meldung in Tessera, Anleitung und Changelog` (attribution line). Do not push.
**SUMMARY for the orchestrator's browser check (L-10):** list the click path to force both transitions locally — (1) "Cloud hinzufügen" with an unreachable address such as `https://127.0.0.1:9` (tile grey "Noch nicht geprüft" + hint), (2) switch the bell on (browser asks once for permission), (3) "Jetzt prüfen" or the tile's check button once more -> red, mail + on-screen notification "nicht erreichbar", (4) edit the address to a reachable public Nextcloud -> "wieder in Ordnung". State that local SMTP is configured (SmtpConfig row present) and name which local account address would receive the mail (look it up, do not change it); note that a USE-only user also sees the bell.
</action>
<verify>
<automated>pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && node -e 'const de=require("./apps/web/src/messages/de.json"),en=require("./apps/web/src/messages/en.json");const w=(o,p,r)=>{for(const[k,v]of Object.entries(o||{})){const q=p+"."+k;if(v&&typeof v==="object")w(v,q,r);else r[q]=v}return r};const pick=(m)=>({...w(m.nextcloudStatus,"nextcloudStatus",{}),...w(m.widgets&&m.widgets.nextcloudStatus,"widgets.nextcloudStatus",{})});const a=pick(de),b=pick(en);if(Object.keys(a).length<10||Object.keys(a).sort().join()!==Object.keys(b).sort().join()){console.error("key mismatch");process.exit(1)}for(const v of [...Object.values(a),...Object.values(b)])if(/mandant|tenant/i.test(String(v))){console.error("bad text",v);process.exit(1)}if(!a["nextcloudStatus.notify.up"]||!a["nextcloudStatus.bell.label"]||!a["nextcloudStatus.card.pendingRetry"]){console.error("missing keys");process.exit(1)}' && grep -q "Benachrichtig" CHANGELOG.md && grep -q "Benachrichtigen:" docs/anleitung-anwender.md && grep -q "NextcloudAlertNotifier" apps/web/src/components/layout/app-shell.tsx && docker compose ps --status running --services | grep -qx api && docker compose ps --status running --services | grep -qx web && docker compose logs api 2>&1 | grep -q "Nextcloud-Status retry job registered"</automated>
</verify>
<done>Subscribers get an on-screen notification (desktop: Windows notification) once per transition while Tessera is open; users without access never poll; CHANGELOG and both guides describe the feature without a word for tenant; full api + web suites, tsc and biome green; api and web rebuilt and running with the retry job and new routes; SUMMARY contains the local test path and SMTP status; commit on main, not pushed.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser -> API (subscription, alerts) | user-controlled instance id; user identity must come from the JWT |
| API -> SMTP / recipient mailbox | Kundenname (manager-entered) flows into subject and body |
| background check -> notification fan-out | concurrent checks, several API instances, restarts |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-kxc-01 | Spoofing / Elevation | POST/DELETE instances/:id/subscription | high | mitigate | userId only from `@CurrentUser()`, tenantId only from `requireTenantId(req)`; instance must match `{ id, tenantId }` (404 otherwise); `forTenant(prisma, tenantId, userId)` + RLS user dimension; controller spec asserts no body field is used |
| T-kxc-02 | Information disclosure | GET alerts | medium | mitigate | query limited to the caller's own subscriptions (userId from JWT, user-bound client), scalar selects (name, address, state, reason, time — all already visible on the module page), class-level `@UseModule` so only users with access get any answer |
| T-kxc-03 | Information disclosure | mail fan-out | high | mitigate | at send time re-check `isActive`, e-mail present and `getModuleAccessLevels` contains the module (L-06); spec covers revoked access and deactivated user |
| T-kxc-04 | Repudiation / Tampering (duplicate sends) | evaluateAfterCheck | medium | mitigate | claim-before-send `updateMany where alertState = previous`; only `count === 1` notifies; state on the row survives restarts; spec covers the lost claim |
| T-kxc-05 | Tampering (header injection) | buildNextcloudAlertMail subject | medium | mitigate | CR/LF collapsed, 150-char cap (same as reminder subject); spec with a Kundenname containing a line break |
| T-kxc-06 | Denial of service (mail flood) | flapping cloud | medium | mitigate | two-strike rule, one mail per transition, grey changes nothing, retry job only touches clouds with exactly one failure, concurrency 4, overlap guards |
| T-kxc-07 | Denial of service (blocked request) | "Jetzt prüfen" with slow SMTP | low | mitigate | delivery runs in the background after the claim; check responses never await SMTP |
| T-kxc-08 | Elevation | new handlers | medium | mitigate | no manage and no role decorator on subscription/alerts handlers — asserted in `module-manage-handlers.spec.ts`; module guard still enforces access |
| T-kxc-SC | Tampering | npm/pip/cargo installs | low | accept | no package installs in this plan; only existing dependencies are used |
</threat_model>
<verification>
- Pure rules cover every transition case of L-07 (green->red, red->red, red->green/yellow, grey, one vs two failures, maintenance immediate).
- Claim-before-send prevents duplicates (spec with lost claim); recipients re-checked for access, active state, address, SMTP; at most 3 attempts.
- Retry job checks a cloud with one failure after 5 minutes; tile hint shown during that time.
- Bell visible and working for USE-level users; list carries `subscribed`; notifier fires once per transition and only with access.
- `prisma migrate status` up to date locally, drift check exit 0; RLS specs green with updated classification doc; still one `forSystem` call in the service.
- Full api + web suites, both tsc runs, biome on touched files green; api and web rebuilt and running.
</verification>
<success_criteria>
- A subscribed user receives exactly one mail and one on-screen notification when a cloud fails twice in a row or turns red for maintenance, DB upgrade or expired support, and exactly one "wieder in Ordnung" when it recovers.
- No duplicate notification across concurrent checks, restarts or repeated red checks.
- Single failures do not alert; real outages are reported within about five minutes.
- No user-facing text names a tenant; nothing pushed.
## Source coverage audit
| Source | Item | Covered by |
|---|---|---|
| GOAL | Per-user notification on red and recovery | Tasks 1-3 |
| CONTEXT | L-01 bell, table, RLS, cascade, USE-level toggles, `subscribed` in list | Task 1 |
| CONTEXT | L-02 transition once, no repeat, recovery, state on row, claim | Task 1 (+ recovery via address change Task 2) |
| CONTEXT | L-03 two-strike guard, tile keeps last good + hint, immediate other reasons, ~5 min retry | Task 1 (rules), Task 2 (retry, hint) |
| CONTEXT | L-04 mail like reminders + in-app/desktop notification | Task 1 (mail), Task 3 (in-app) |
| CONTEXT | L-05 German mail texts, locked subjects, link | Task 1 |
| CONTEXT | L-06 re-check access and active state at send time | Task 1 |
| CONTEXT | L-07 tests, full suites, tsc, biome | Tasks 1-3 |
| CONTEXT | L-08 CHANGELOG + docs | Task 3 |
| CONTEXT | L-09 local migration + rebuild | Task 1 (migration), Task 3 (rebuild) |
| CONTEXT | L-10 no tenant wording, no push, SUMMARY test path + SMTP status | Tasks 1-3, SUMMARY in Task 3 |
</success_criteria>
<output>
Create `.planning/quick/261002-kxc-nextcloud-status-benachrichtigung-bei-ro/261002-kxc-SUMMARY.md` when done
</output>
@@ -0,0 +1,155 @@
---
phase: quick-261002-kxc
plan: 01
subsystem: nextcloud-status
tags: [benachrichtigung, glocke, mail, rls, scheduler, in-app]
status: complete
requires:
- quick-261002-k67 (Modul Nextcloud-Status)
provides:
- persönliche Glocke je Kachel (Tabelle NextcloudAlertSubscription, RLS mit Benutzerdimension)
- Zwei-Fehlschläge-Regel, Wiederholung nach 5 Minuten, gemeldeter Zustand auf der Zeile
- E-Mail (nur Deutsch) und Meldung in Tessera bei Störung und Wiederherstellung
- Fehlercodes als lesbarer Hinweis auf Kachel und in der Mail
affects:
- apps/api/src/nextcloud-status/
- apps/api/src/mail/mail.service.ts
- apps/web (Kachel, Seite, AppShell)
key-files:
created:
- apps/api/prisma/migrations/20261002170000_nextcloud_alerts/migration.sql
- apps/api/src/nextcloud-status/nextcloud-alert-rules.ts
- apps/api/src/nextcloud-status/nextcloud-alert-mail.ts
- apps/api/src/nextcloud-status/nextcloud-alert.service.ts
- apps/web/src/components/nextcloud-status/nextcloud-alert-notifier.tsx
- apps/web/src/components/nextcloud-status/error-hint.ts
modified:
- apps/api/prisma/schema.prisma
- apps/api/src/nextcloud-status/nextcloud-status.service.ts
- apps/api/src/nextcloud-status/nextcloud-status.controller.ts
- apps/api/src/nextcloud-status/nextcloud-status-scheduler.service.ts
- apps/api/src/nextcloud-status/nextcloud-status.module.ts
- apps/api/src/mail/mail.service.ts
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudTile.tsx
- apps/web/src/app/(portal)/modules/nextcloud-status/page.tsx
- apps/web/src/components/layout/app-shell.tsx
- docs/mandantentrennung-zugriffsklassifikation.md
- CHANGELOG.md
- docs/anleitung-anwender.md
- docs/anleitung-administration.md
decisions:
- "Zwei-Fehlschläge-Regel für jeden fehlgeschlagenen Abruf (D-K1); der erste Fehlschlag schreibt nur Zähler und Zeitpunkt, die Kachel behält den guten Stand (D-K2)"
- "Der Zähler einer dauerhaft roten Cloud wird bei 2 gedeckelt (sonst wüchse er stündlich weiter)"
- "Wiederholung als zweiter Cron-Auftrag (jede Minute) über denselben einen Systemlesezugriff (D-K3)"
- "Anspruch vor dem Senden per updateMany auf alertState; Versand im Hintergrund, bis zu 3 Versuche im Abstand von 60 s (D-K5)"
- "Fehler-Hinweise: gleiche Zuordnung in API (Mail) und Web (Kachel); Rohkennung bleibt als Tooltip"
metrics:
duration: "ca. 20 Minuten"
completed: 2026-10-02
actuals:
tokens: 32500
tasks: 3
commits: 3
plan_head_before: 6829c44464ef3d68711117af656d4c6db79892ec
plan_head_after: 6c4bff6f6cf85867cdea54c1dbcc91d2c7fff028
---
# Quick 261002-kxc: Nextcloud-Status – Benachrichtigung bei Rot und Wiederherstellung
Persönliche Glocke je Kachel: Wer eingeschaltet hat, bekommt bei Störung und bei „wieder in Ordnung“ je Änderung genau eine E-Mail und eine Meldung in Tessera (Desktop-App: Windows-Benachrichtigung). Ein einzelner Fehlschlag löst nichts aus; Tessera prüft nach etwa fünf Minuten erneut.
## Commits
| Aufgabe | Commit | Inhalt |
|---|---|---|
| 1 (Tracer) | faed0d7 | Migration, reine Regeln, Mailbaustein, Alert-Dienst, Glocke in API und Kachel, RLS-Dokument |
| 2 | 11a70c9 | Wiederholungsauftrag, Adresswechsel, Hinweis „Prüfung fehlgeschlagen“, Fehlercodes in Klartext |
| 3 | 6c4bff6 | Endpunkt `GET alerts`, globaler Melder im AppShell, Anleitung, Changelog |
Nicht gepusht. Die Commit-Zeile trägt „Claude Sonnet 5.5“ (die Vorgabe der Umgebung für Commit-Anhänge), nicht den im Auftrag genannten Opus-Text, weil ich Sonnet 5.5 bin und die Angabe sonst falsch wäre.
## Was gebaut wurde
- **Datenbank:** Migration `20261002170000_nextcloud_alerts` (lokal angewendet, `migrate status` aktuell, Drift-Prüfung `--exit-code` = 0). Neue Tabelle `NextcloudAlertSubscription` mit Mandant-und-Benutzer-Regel wie „Reminder“, ohne `system_read_policy`, Cascade bei Cloud und Benutzer. Neue Spalten an `NextcloudInstance`: `consecutiveFailures`, `firstFailureAt`, `alertState` ('ok'|'red'), `alertReason`, `alertChangedAt`.
- **Reine Regeln** (`nextcloud-alert-rules.ts`): `decideAlert`, `planStatusWrite`, `FAILURES_FOR_RED = 2`, `RETRY_DELAY_MS = 5 min`.
- **Mail** (`nextcloud-alert-mail.ts`, `MailService.sendNextcloudAlertEmail`): Betreff „Nextcloud <Kundenname>: nicht erreichbar“ bzw. „… wieder in Ordnung“ wortgleich, eigene Betreffe für die anderen roten Gründe, Text mit Grund, Adresse, Zeit (Europe/Berlin), Link zum Modul; CR/LF und 150 Zeichen wie bei Erinnerungen.
- **Alert-Dienst:** Abonnieren/Abbestellen (idempotent, 404 für fremde Clouds), Anspruch vor dem Senden (`updateMany where alertState = bisher`, nur `count === 1` meldet), Versand im Hintergrund, Empfänger beim Senden neu geprüft (aktiv, Adresse, Modulzugriff über `getModuleAccessLevels`, SMTP), bis zu 3 Versuche, `listRecentAlerts` für die Meldung in Tessera.
- **Status-Dienst/Controller:** `checkInstance` ist weiter der einzige Schreibweg und läuft jetzt über `planStatusWrite` und `evaluateAfterCheck`; Listen tragen `subscribed`; `POST/DELETE instances/:id/subscription` und `GET alerts` nur mit Modul-Guard (kein Verwalten, keine Rolle).
- **Planer:** zweiter Auftrag `nextcloud-status-retry` (jede Minute), filtert dieselbe `forSystem`-Abfrage auf genau einen Fehlschlag älter als 5 Minuten. Weiterhin genau ein `forSystem`-Aufruf im Dienst.
- **Web:** Glocke an jeder Kachel (auch für „Benutzen“), optimistisches Umschalten mit Rücknahme bei Fehler, Browser-Erlaubnis einmalig beim ersten Einschalten; Hinweis „Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt“; `NextcloudAlertNotifier` im AppShell (nutzt die Helfer der Erinnerungen, daher Windows-Benachrichtigung in der Desktop-App; fragt ohne Modulzugriff nie ab; 401/403 pausiert bis Fokus).
- **Zusatzwunsch Fehlertexte:** Kachel und Mail zeigen statt `ERR_TLS_CERT_ALTNAME_INVALID` & Co. Klartext: „Zertifikat passt nicht zur Adresse“, „Zertifikat abgelaufen“, „Zertifikat nicht vertrauenswürdig“, „Adresse nicht gefunden“ (ENOTFOUND/EAI_AGAIN), „Verbindung abgelehnt“, „Zeitüberschreitung“, „Server antwortet mit Fehler <Code>“, sonst „Verbindungsfehler“. Auf der Kachel bleibt die Rohkennung als `title`-Tooltip. In der Mail steht nur der Klartext. Zuordnung als reine Funktionen `describeCheckError` (API) und `errorHint` (Web), je mit Tests; de/en-Texte unter `nextcloudStatus.errorHint`.
## Tests und Prüfungen (gemessen)
| Prüfung | Ergebnis |
|---|---|
| API komplett (`pnpm --filter @tessera/api test`) | 121 Dateien, 2120 Tests, alle grün |
| Web komplett (`pnpm --filter @tessera/web test`) | 121 Dateien, 1321 Tests, alle grün |
| `tsc --noEmit` API / Web | beide fehlerfrei |
| Biome `lint` auf allen berührten Dateien der drei Aufgaben (25 Dateien) | keine Fehler, 2 Warnungen in `mail.service.spec.ts` (Zeilen 130 und 150, Non-Null-Zusicherungen, vor dieser Arbeit vorhanden, nicht angefasst) |
| Biome `check` auf den neuen Dateien und dem AppShell | sauber |
| RLS-Specs (`rls-coverage`, `rls-access-inventory`) | grün, Dokument nachgeführt |
| Migration | angewendet, Drift 0 |
Neu hinzugekommen: Regeln 19 Tests, Mailbaustein 27, Alert-Dienst 22, Web-Melder 11, Fehlerhinweis 20 u. a. sowie Erweiterungen in Status-Dienst, Controller, Scheduler, MailService und Seitentest.
Hinweis zur Reihenfolge: Bei den reinen Regeln habe ich Spec und Implementierung gemeinsam geschrieben und nicht ausdrücklich zuerst den roten Lauf beobachtet; die Fälle aus dem Plan sind vollständig abgedeckt.
## RLS-Dokument (`docs/mandantentrennung-zugriffsklassifikation.md`)
- Bereichszeile `nextcloud-status`: 0/23/1 (gemessen mit der Gate-Schleife über `nextcloud-status/`; Dienst 14, Alert-Dienst 9 gebundene Rohtreffer).
- Neue Fundstellen: drei Paare `nextcloud-alert.service.ts` (`nextcloudAlertSubscription`, `nextcloudInstance`, `user`), Paarzahl 96 (53→56 `muss-mandantengebunden`).
- Summenzeile von mir nur um die eigenen +9 fortgeschrieben (61/273/8). **Auffälligkeit:** Eine frische Messung über alle Bereiche ergibt 61/280/8; die Differenz von 7 stammt aus älteren, nicht nachgeführten Zeilen (`dashboard` 30 statt 29, `groups` 33 statt 31, `reminders` 13 statt 12, weitere Bereiche ohne eigene Zeile). Das habe ich nicht stillschweigend „repariert“, sondern in der Summenzeile vermerkt.
- `FORSYSTEM_ALLOWED_CALL_SITES` unverändert; genau ein `forSystem(this.prisma)` in `nextcloud-status.service.ts`.
## Abweichungen vom Plan
**1. [Rule 3 - Blockierend] Umlaut-Wächter**
- Gefunden bei Aufgabe 2: `umlaut-guard.spec.ts` meldete „passt“ und „vertrauenswürdig“ als neue Wörter.
- Behoben: beide in `UMLAUT_ALLOWLIST` (`apps/web/src/messages/umlaut-dictionary.ts`) ergänzt (korrektes Deutsch).
**2. [Rule 2 - Korrektheit] Zähler gedeckelt**
- `consecutiveFailures` einer dauerhaft roten Cloud würde sonst bei jeder stündlichen Prüfung weiterzählen; gedeckelt bei 2 (`FAILURES_FOR_RED`). Mit Test.
**3. Zusatzwunsch Fehlertexte** (Auftrag, nicht im Plan): zusätzlich neue Dateien `error-hint.ts`/`.test.ts` und `describeCheckError` in `nextcloud-alert-mail.ts`; Doku-Satz „Fehlercode steht klein darunter“ in der Administrationsanleitung angepasst.
**4. Commit-Anhang:** siehe oben (Sonnet 5.5 statt Opus-Text).
Sonst: Plan wie geschrieben ausgeführt. Keine Authentifizierungs-Hürden, keine Paketinstallationen.
## Lokaler Neubau
`docker compose up -d --build api web` ausgeführt; api, web und db laufen. Im API-Protokoll: „Nextcloud-Status module seeded in registry“, „Nextcloud-Status retry job registered: * * * * *“, Routen `/modules/nextcloud-status/instances/:id/subscription` (POST, DELETE) und `/modules/nextcloud-status/alerts` (GET) gemappt, „No pending migrations to apply“, keine Fehler.
## Für die Browser-Prüfung (Orchestrator)
**Lokaler SMTP-Stand:** Ja, eingerichtet. Genau eine `SmtpConfig`-Zeile: Host `mailhog`, Port 1025, Absender `tessera@tessera.local`. Die Mails landen also in MailHog (nicht in einem echten Postfach). Empfangsadressen der lokalen Konten (nur nachgesehen, nichts geändert): `admin` (SUPER_ADMIN) `admin@tessera.local`, `nutzer1` `nutzer1@tessera.local`, `nutzer2` `nutzer2@tessera.local`, `testuser` `testuser@example.com`; alle aktiv. Lokal gibt es bereits 5 Clouds. Ich habe keinen Versand ausgelöst (nur Unit-Tests mit gemocktem Transport).
**Klickweg, beide Übergänge zu erzwingen** (als Admin; ein Benutzer mit nur „Benutzen“ sieht die Glocke ebenfalls, kann aber keine Clouds anlegen oder prüfen):
1. „Cloud hinzufügen“ mit einer nicht erreichbaren Adresse, z. B. `https://127.0.0.1:9`. Die Kachel bleibt grau „Noch nicht geprüft“ und zeigt den Hinweis „Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt“.
2. Glocke auf dieser Kachel einschalten (Browser fragt einmalig nach der Erlaubnis für Benachrichtigungen).
3. „Jetzt prüfen“ oder den Prüfknopf der Kachel noch einmal drücken: zweiter Fehlschlag, Kachel wird rot „Nicht erreichbar“ mit Klartext-Grund (bei 127.0.0.1:9 „Verbindung abgelehnt“). Es kommt eine Mail „Nextcloud <Name>: nicht erreichbar“ (in MailHog) und die Meldung in Tessera. Ohne Knopfdruck passiert dasselbe automatisch nach etwa 5 Minuten durch den Wiederholungsauftrag. Weitere Prüfungen, solange die Cloud rot bleibt, senden nichts mehr.
4. Adresse der Cloud bearbeiten auf eine erreichbare öffentliche Nextcloud: sie wird sofort neu geprüft, die Kachel wird grün oder gelb, es kommt „Nextcloud <Name>: wieder in Ordnung“ (Mail und Meldung).
Sofort rot ohne Wartezeit: eine Cloud, die im Wartungsmodus steht oder auf eine Datenbank-Aktualisierung wartet, wird beim ersten Abruf gemeldet.
## Known Stubs
Keine.
## Threat Flags
Keine neuen Angriffsflächen außerhalb des Plan-Bedrohungsmodells (T-kxc-01 bis -08 umgesetzt: Benutzer nur aus dem Token, 404 für fremde Clouds, Empfänger beim Senden neu geprüft, Anspruch vor dem Senden, CR/LF-Schutz im Betreff, Versand im Hintergrund, keine Verwalten-/Rollen-Decorators an den neuen Handlern, durch Tests festgeschrieben).
## Self-Check: PASSED
- Dateien vorhanden: Migration, `nextcloud-alert-rules.ts`, `nextcloud-alert-mail.ts`, `nextcloud-alert.service.ts`, `nextcloud-alert-notifier.tsx`, `error-hint.ts` – gefunden.
- Commits vorhanden: faed0d7, 11a70c9, 6c4bff6 (gemessen mit `git rev-list --count 6829c44..HEAD` = 3).
## Browser-Prüfung (Orchestrator, 02.10., lokal, dunkel, MailHog)
- Grundmodul (k67): 5 echte öffentliche Clouds + 1 kaputte; Ampel korrekt (35.0.1/34.0.4 grün, 33.0.5/33.0.8 Enterprise gelb „Update auf 33.0.9“, Zertifikatsfehler rot), Sortierung Status, Logo per Upload und per URL, Dashboard-Kachel mit Zählern 2/2/1.
- Glocke an „Ausfall AG“ (https://127.0.0.1:9): erster Abruf grau + Wiederholungshinweis, zweiter rot „Nicht erreichbar“ (Port 9 ist von fetch gesperrt → „Verbindungsfehler“ korrekt; normaler Port liefert ECONNREFUSED → „Verbindung abgelehnt“).
- Mail „Nextcloud Ausfall AG: nicht erreichbar“ in MailHog, Text verständlich; Browser-Benachrichtigung erschienen.
- Adresse auf erreichbare Cloud geändert → grün, Mail + Benachrichtigung „wieder in Ordnung“.
@@ -0,0 +1,265 @@
---
phase: quick-261003-387
plan: 01
type: execute
wave: 1
depends_on: []
quick_id: 261003-387
description: "Modulkategorien durch Administratoren bearbeitbar: anlegen, umbenennen, sortieren, löschen mit Verschieben, Module zuordnen und innerhalb der Kategorie sortieren"
date: 2026-10-03
files_modified:
# Task 1 — tracer: DB -> Dienst (Grundbestand + Überlagerung) -> GET /module-categories + /modules/active -> Store -> Seitenleiste/Beschriftung
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20261003120000_module_categories/migration.sql
- apps/api/src/module-categories/module-categories.service.ts
- apps/api/src/module-categories/module-categories.service.spec.ts
- apps/api/src/module-categories/module-categories.controller.ts
- apps/api/src/module-categories/module-categories.controller.spec.ts
- apps/api/src/module-categories/module-categories.module.ts
- apps/api/src/module-categories/dto/module-category.dto.ts
- apps/api/src/module-registry/module-registry.module.ts
- apps/api/src/module-registry/module-registry.controller.ts
- apps/api/src/app.module.ts
- docs/mandantentrennung-zugriffsklassifikation.md
- apps/web/src/lib/module-categories-api.ts
- apps/web/src/lib/stores/module-category-store.ts
- apps/web/src/lib/module-category-order.ts
- apps/web/src/lib/module-category-order.test.ts
- apps/web/src/lib/use-category-label.ts
- apps/web/src/components/layout/sidebar.tsx
- apps/web/src/components/layout/sidebar.test.tsx
# Task 2 — API: Verwaltungs-Endpunkte, Löschen mit Verschieben, Überlagerung überall, eigene Module
- apps/api/src/groups/groups.module.ts
- apps/api/src/groups/module-grants.controller.ts
- apps/api/src/custom-modules/custom-modules.module.ts
- apps/api/src/custom-modules/custom-modules.service.ts
- apps/api/src/custom-modules/custom-modules.service.spec.ts
- apps/api/src/custom-modules/custom-modules.controller.spec.ts
- apps/api/src/custom-modules/dto/custom-module.dto.ts
# Task 3 — Web: Verwaltungsseite, Marktplatz, Formular, Texte, Doku
- apps/web/src/app/(portal)/admin/modules/categories/page.tsx
- apps/web/src/app/(portal)/admin/modules/categories/categories-page.test.tsx
- apps/web/src/app/(portal)/admin/modules/page.tsx
- apps/web/src/app/(portal)/marketplace/page.tsx
- apps/web/src/app/(portal)/modules/[category]/page.tsx
- apps/web/src/components/custom-modules/custom-module-form-modal.tsx
- apps/web/src/lib/custom-modules-api.ts
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/messages/umlaut-dictionary.ts
- CHANGELOG.md
- docs/anleitung-administration.md
- docs/anleitung-anwender.md
autonomous: true
requirements: [QUICK-261003-387]
estimate:
tokens: 240000
raw_tokens: 240000
tasks: 3
confidence: low
must_haves:
truths:
- "Ein Administrator sieht unter Administrator → Module neben „Freigaben-Matrix“ einen Knopf „Kategorien“, der /admin/modules/categories öffnet; Nicht-Administratoren bekommen dort den Zugriffshinweis und von der API 403."
- "Ein Administrator kann eine Kategorie anlegen, umbenennen, mit Pfeilen nach oben/unten verschieben und löschen; „Eigene Module“ lässt sich umbenennen und verschieben, aber nicht löschen (Knopf gesperrt, API 400)."
- "Löschen einer nicht leeren Kategorie fragt nach einer Zielkategorie; alle Module, gemeinsame UND persönliche eigene Module, landen dort — kein Eintrag geht verloren; ohne Ziel antwortet die API 409 und löscht nichts."
- "Ein Administrator ordnet jedes Marktplatz-Modul und jedes gemeinsame eigene Modul per Auswahlfeld einer Kategorie zu und sortiert die Einträge innerhalb einer Kategorie mit Pfeilen; die Spalte Module.category (für alle gleich) bleibt unverändert."
- "Seitenleiste, Marktplatz (Filterchips und Kartenreihenfolge), Kategorieseite /modules/<kategorie> und Freigaben-Matrix zeigen die Kategorie aus der Zuordnung in der eingestellten Kategorie- und Modulreihenfolge; nicht umbenannte Standardkategorien bleiben übersetzt (de/en), umbenannte zeigen den gespeicherten Namen."
- "Im Formular für eigene Module stehen alle vorhandenen Kategorien zur Wahl; bestehende Werte bleiben gültig; eine unbekannte Kategorie lehnt die API mit 400 ab."
- "Alte Modul-Adressen /modules/<alte-kategorie>/<slug> öffnen das Modul weiterhin, weil die Seite nur über den Slug auflöst."
artifacts:
- path: "apps/api/prisma/migrations/20261003120000_module_categories/migration.sql"
provides: "Tabellen ModuleCategory + ModuleCategoryPlacement mit RLS, Spalte CustomModule.sortOrder"
contains: "tenant_isolation_policy"
- path: "apps/api/src/module-categories/module-categories.service.ts"
provides: "Grundbestand je Organisation, CRUD, Löschen mit Verschieben, Überlagerung applyToModules, assertCategoryKey"
- path: "apps/api/src/module-categories/module-categories.controller.ts"
provides: "GET /module-categories (alle), Verwaltungs-Endpunkte nur ADMIN/SUPER_ADMIN, statische Routen vor :key"
- path: "apps/web/src/app/(portal)/admin/modules/categories/page.tsx"
provides: "Verwaltungsseite Kategorien"
- path: "apps/web/src/lib/stores/module-category-store.ts"
provides: "Geteilter Kategorienstand für Beschriftung, Reihenfolge und Auswahlfelder"
key_links:
- from: "apps/api/src/module-registry/module-registry.controller.ts"
to: "ModuleCategoriesService.applyToModules"
via: "GET /modules, /modules/active, /modules/catalog liefern die zugeordnete Kategorie + sortOrder"
pattern: "applyToModules"
- from: "apps/api/src/groups/module-grants.controller.ts"
to: "ModuleCategoriesService.applyToModules"
via: "GET /module-grants/matrix sortiert Module nach Kategorie- und Modulreihenfolge"
pattern: "applyToModules"
- from: "apps/web/src/lib/use-category-label.ts"
to: "apps/web/src/lib/stores/module-category-store.ts"
via: "gespeicherter Name vor Übersetzung, Übersetzung vor Kennung"
pattern: "useModuleCategoryStore"
- from: "apps/web/src/components/layout/sidebar.tsx"
to: "apps/web/src/lib/module-category-order.ts"
via: "Gruppenreihenfolge und Reihenfolge innerhalb der Gruppe"
pattern: "module-category-order"
---
<objective>
Modulkategorien werden pro Organisation durch Administratoren pflegbar: anlegen, umbenennen, sortieren, löschen (mit Verschieben der Inhalte), Module und gemeinsame eigene Module zuordnen und innerhalb der Kategorie sortieren. Die eingestellte Kategorie und Reihenfolge gilt in Seitenleiste, Marktplatz, Kategorieseite, Freigaben-Matrix und im Formular für eigene Module.
Purpose: Heute legt jedes Modul seine Kategorie fest (Module.category aus dem Manifest, für alle Organisationen gleich); Administratoren können die Seitenleiste nicht nach ihren Abläufen ordnen.
Output: Zwei neue Tabellen mit Zeilenschutz, ein Kategorien-Dienst mit Endpunkten, eine Verwaltungsseite, angepasste Anzeigen, CHANGELOG und Handbuch.
Festgelegte Entwurfsentscheidungen (aus dem Auftrag, Planer-Ermessen hier dokumentiert):
- E-01 Datenform: ModuleCategory {id, tenantId, key, name (null = Übersetzung moduleCategories.<key>), sortOrder, isSystem}, eindeutig (tenantId, key). key ist UNVERÄNDERLICH und bleibt URL-Segment /modules/<key>/<slug>; Umbenennen ändert nur name. isSystem=true nur für „custom-modules“ (Eigene Module): umbenennbar, verschiebbar, nicht löschbar.
- E-02 Zuordnung Marktplatz-Module: ModuleCategoryPlacement {id, tenantId, moduleId → Module (onDelete Cascade), categoryKey, sortOrder}, eindeutig (tenantId, moduleId). Wirksame Kategorie = Zuordnung, sonst Module.category. Module.category wird NIE geändert.
- E-03 Gemeinsame eigene Module: CustomModule ist schon eine Zeile je Organisation; Zuordnung schreibt direkt CustomModule.category, Reihenfolge in der neuen Spalte CustomModule.sortOrder (Int, null erlaubt). Persönliche eigene Module wählen ihre Kategorie selbst (jede vorhandene) und bekommen nie eine sortOrder.
- E-04 Grundbestand ohne SQL-Rückfüllung: der Dienst legt beim ersten Lesen je Organisation die Standardkategorien an (Reihenfolge von CUSTOM_MODULE_CATEGORIES, also die sechs Modulkategorien und „Eigene Module“ zuletzt, name null) und legt fehlende Zeilen für jede wirksam benutzte Kennung nach (Kategorie eines später ausgelieferten Moduls, vorhandene Werte eigener Module), jeweils hinten angehängt. Eine gelöschte Standardkategorie kommt nur wieder, wenn ein Modul sie wirksam benutzt (z. B. ein neu ausgeliefertes Modul mit diesem Manifest-Wert).
- E-05 Löschen: alle Inhalte wandern in die gewählte Zielkategorie — Marktplatz-Module (Zuordnung umgeschrieben bzw. neu angelegt, damit die Manifest-Kategorie sie nicht zurückholt), gemeinsame UND persönliche eigene Module (persönliche Einträge gehen mit den anderen mit, nicht nach „Eigene Module“). Verschobene Einträge werden hinten angehängt.
- E-06 Neue Kennung: aus dem Namen gebildet (klein, ä→ae, ö→oe, ü→ue, ß→ss, sonst nur a-z0-9 und Bindestrich, höchstens 40 Zeichen, leer → „kategorie“); kollidiert sie mit einer Kennung der Organisation, einem Modul-Slug (eigene Routenordner unter /modules) oder „custom“, wird „-2“, „-3“ … angehängt.
- E-07 Wirksame Kategorie wird SERVERSEITIG über die Modullisten gelegt (/modules, /modules/active, /modules/catalog, /module-grants/matrix): jedes Modul bekommt category = wirksame Kennung und sortOrder (Zahl oder null), Liste sortiert nach Kategorie-Reihenfolge, dann sortOrder (null zuletzt), dann Name. Dadurch gruppieren Kategorieseite, Marktplatz und Matrix ohne eigene Logik richtig.
- E-08 Reihenfolge innerhalb einer Kategorie (Seitenleiste): sortOrder aufsteigend, null zuletzt; bei Gleichstand eingebaute Module vor eigenen, dann Name. Persönliche eigene Module stehen damit immer hinter den vom Administrator sortierten Einträgen.
- E-09 Nicht im Umfang: der Benutzer-Zugriffsdialog (Benutzerdetails) behält seine bisherige Sortierung; keine „Auf Standardnamen zurücksetzen“-Funktion.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
Bestehende Muster (einmal lesen, dann nachbauen):
- Migration mit Kopfkommentar + RLS ohne Benutzerdimension: apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql
- RLS-Regeln eigener Module (Klient ohne Benutzer darf alle Zeilen der Organisation ändern): apps/api/prisma/migrations/20260929130000_custom_module_owner/migration.sql
- forTenant / withTenantTransaction: apps/api/src/prisma/prisma-tenant.extension.ts
- Rollen je Methode: apps/api/src/module-registry/module-registry.controller.ts (@UseGuards(RolesGuard) + @Roles(Role.ADMIN, Role.SUPER_ADMIN), tenantId = req.tenantId ?? req.user?.tenantId)
- Zugriffsinventar: apps/api/src/prisma/rls-access-inventory.spec.ts gegen docs/mandantentrennung-zugriffsklassifikation.md (Zeilenformat wie Eintrag nextcloud-status.service.ts)
- Löschdialog mit Rückfrage: apps/web/src/app/(portal)/admin/groups/components/DeleteGroupDialog.tsx
- Admin-Seite mit Rollenprüfung und Kopf-Link: apps/web/src/app/(portal)/admin/modules/page.tsx, apps/web/src/app/(portal)/admin/modules/grants/page.tsx
- Seitenleisten-Test zählt fetch-Aufrufe: apps/web/src/components/layout/sidebar.test.tsx (API-Helfer werden als Modul gemockt, z. B. @/lib/custom-modules-api)
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer — Kategorietabellen, Grundbestand, Lese-Endpunkt und wirksame Kategorie bis in die Seitenleiste</name>
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261003120000_module_categories/migration.sql, apps/api/src/module-categories/module-categories.service.ts, apps/api/src/module-categories/module-categories.service.spec.ts, apps/api/src/module-categories/module-categories.controller.ts, apps/api/src/module-categories/module-categories.controller.spec.ts, apps/api/src/module-categories/module-categories.module.ts, apps/api/src/module-categories/dto/module-category.dto.ts, apps/api/src/module-registry/module-registry.module.ts, apps/api/src/module-registry/module-registry.controller.ts, apps/api/src/app.module.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/module-categories-api.ts, apps/web/src/lib/stores/module-category-store.ts, apps/web/src/lib/module-category-order.ts, apps/web/src/lib/module-category-order.test.ts, apps/web/src/lib/use-category-label.ts, apps/web/src/components/layout/sidebar.tsx, apps/web/src/components/layout/sidebar.test.tsx</files>
<read_first>apps/api/prisma/migrations/20261002150000_nextcloud_status/migration.sql, apps/api/src/custom-modules/custom-modules.service.ts, apps/api/src/module-registry/module-registry.controller.ts, apps/web/src/components/layout/sidebar.tsx, apps/web/src/components/layout/sidebar.test.tsx, apps/web/src/lib/use-category-label.ts, packages/shared/src/index.ts (Zeilen 270-300)</read_first>
<action>
Schema (E-01, E-02, E-03): in schema.prisma die Modelle ModuleCategory und ModuleCategoryPlacement wie in E-01/E-02 beschrieben anlegen (beide mit tenantId String, createdAt/updatedAt, @@index([tenantId]); ModuleCategory @@unique([tenantId, key]), sortOrder Int @default(0), isSystem Boolean @default(false), name String?; Placement @@unique([tenantId, moduleId]), sortOrder Int, Relation zu Module mit onDelete: Cascade und Gegenfeld categoryPlacements an Module). An CustomModule die Spalte sortOrder Int? ergänzen und den Kommentar an category auf „Kennung einer ModuleCategory der Organisation“ ändern. Migration 20261003120000_module_categories: DDL mit `pnpm --filter @tessera/api exec prisma migrate diff --from-migrations prisma/migrations --to-schema-datamodel prisma/schema.prisma --script` erzeugen (Shadow-DB per --shadow-database-url über die Container-IP, falls nötig) oder von Hand nach Vorbild schreiben; dann von Hand den Pflicht-Kopfkommentar (Zweck, quick-261003-387, Zeilenschutz ohne Benutzerdimension weil gemeinsame Daten der Organisation, Rechte über ALTER DEFAULT PRIVILEGES, Schalter-Hinweis) und für BEIDE Tabellen ENABLE + FORCE ROW LEVEL SECURITY und CREATE POLICY tenant_isolation_policy … USING ("tenantId" = current_tenant_id()) ergänzen. Keine system_read_policy (kein Hintergrunddienst). Migration lokal anwenden: IP mit `docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1`, dann `DATABASE_URL="postgresql://tessera:tessera_dev@<IP>:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy` und `pnpm --filter @tessera/api exec prisma generate`.
Dienst apps/api/src/module-categories/module-categories.service.ts (injiziert nur PrismaService; je Methode eigener Klient `const tenantPrisma = forTenant(this.prisma, tenantId)`; Lesen des globalen Modulkatalogs über this.prisma.module wie in module-access.service.ts, mit gleichem Begründungskommentar). In diesem Task: (a) private ensure(tenantId) nach E-04 — keine Zeile vorhanden → createMany mit skipDuplicates für CUSTOM_MODULE_CATEGORIES in dieser Reihenfolge (sortOrder = Index, isSystem nur für CUSTOM_MODULE_CATEGORY); danach fehlende Kennungen aus wirksamer Modulkategorie (Zuordnung sonst Module.category) und distinct CustomModule.category der Organisation mit sortOrder = bisheriges Maximum + 1 nachlegen (skipDuplicates); liefert die Zeilen sortiert nach sortOrder, dann key. (b) listCategories(tenantId) → [{id, key, name, sortOrder, isSystem}]. (c) applyToModules(tenantId, modules) generisch über {id, category, name} nach E-07 (gibt category und sortOrder: number | null zurück, unbekannte Kategorie sortiert zuletzt). (d) rename(tenantId, key, name) — Name getrimmt 1–60 Zeichen, unbekannte Kennung 404, „Eigene Module“ erlaubt (D-Auftrag: umbenennbar).
Controller apps/api/src/module-categories/module-categories.controller.ts mit @Controller('module-categories'): GET '' für alle angemeldeten Benutzer → listCategories; PATCH ':key' mit @UseGuards(RolesGuard) @Roles(Role.ADMIN, Role.SUPER_ADMIN) → rename (DTO RenameModuleCategoryDto in dto/module-category.dto.ts mit Transform-Trim, IsString, IsNotEmpty, MaxLength(60)). Alle späteren statischen Routen kommen VOR ':key' (NestJS-Route-Order). ModuleCategoriesModule (providers + exports ModuleCategoriesService, controllers) anlegen, in app.module.ts registrieren und von ModuleRegistryModule importieren. In ModuleRegistryController ModuleCategoriesService injizieren und findActive über applyToModules leiten (findAll/findCatalog folgen in Task 2).
Inventar: rls-access-inventory.spec.ts laufen lassen und für die neuen Paare (Datei module-categories.service.ts × moduleCategory, moduleCategoryPlacement, customModule, module) Zeilen in docs/mandantentrennung-zugriffsklassifikation.md im vorhandenen Format ergänzen (Stand gebunden bzw. für module der dokumentierte ungebundene Katalogzugriff); in Task 2 kommen weitere Treffer hinzu — die Rohzahlen dann nachziehen.
Web: apps/web/src/lib/module-categories-api.ts mit Typ ModuleCategoryInfo {id, key, name: string | null, sortOrder, isSystem} und listModuleCategories() (GET, credentials include, wirft bei !ok). apps/web/src/lib/stores/module-category-store.ts (zustand wie marketplace-store): categories, loaded, load() ruft listModuleCategories und schluckt Fehler still (Seitenleisten-Muster), ensureLoaded() lädt nur wenn !loaded. apps/web/src/lib/module-category-order.ts als reine Funktionen: categoryRank(categories, key) und compareSidebarEntries nach E-08; Tests in module-category-order.test.ts. use-category-label.ts: liest den Store; gespeicherter name (nicht null) vor Übersetzung, Übersetzung vor Kennung; abonniert den Store, damit Beschriftungen nach dem Laden neu rendern. Sidebar: SidebarModule und SidebarEntry um sortOrder (number | null) und custom-Kennzeichen erweitern (eigene Module aus der CustomModule-Antwort übernehmen sortOrder, siehe Task 2 für das API-Feld; bis dahin null); fetchActiveModules lädt zusätzlich den Store (load()) im selben Auffrisch-Takt; orderedCategories sortiert Gruppen nach categoryRank (unbekannte Kennungen zuletzt in Fundreihenfolge) und Einträge je Gruppe nach compareSidebarEntries — der bisherige feste Sonderfall „Eigene Module immer zuletzt“ entfällt, weil die Reihenfolge jetzt aus den Kategoriezeilen kommt (Standard: zuletzt). sidebar.test.tsx: @/lib/module-categories-api als Modul mocken (fetch-Zähler bleiben unverändert) und Tests ergänzen: Gruppen folgen der Store-Reihenfolge; umbenannte Kategorie zeigt den Namen; Einträge innerhalb einer Gruppe folgen sortOrder, null zuletzt, eingebaut vor eigenem.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/module-categories src/module-registry rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run sidebar module-category-order && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && docker exec tessera-ctl-db-1 psql -U tessera -d tessera -tAc "select count(*) from pg_class where relname in ('ModuleCategory','ModuleCategoryPlacement') and relrowsecurity and relforcerowsecurity" | grep -qx 2</automated>
</verify>
<done>Migration lokal angewendet, beide Tabellen mit FORCE RLS; GET /module-categories liefert für eine frische Organisation sieben Standardkategorien in Standardreihenfolge mit name null; PATCH benennt um (nur Administratoren); /modules/active liefert wirksame Kategorie + sortOrder; die Seitenleiste ordnet Gruppen und Einträge nach Store und zeigt umbenannte Namen; RLS-Gates grün.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: API — Anlegen, Sortieren, Zuordnen, Löschen mit Verschieben, Überlagerung in allen Modullisten, eigene Module gegen vorhandene Kategorien prüfen</name>
<files>apps/api/src/module-categories/module-categories.service.ts, apps/api/src/module-categories/module-categories.service.spec.ts, apps/api/src/module-categories/module-categories.controller.ts, apps/api/src/module-categories/module-categories.controller.spec.ts, apps/api/src/module-categories/dto/module-category.dto.ts, apps/api/src/module-registry/module-registry.controller.ts, apps/api/src/groups/groups.module.ts, apps/api/src/groups/module-grants.controller.ts, apps/api/src/custom-modules/custom-modules.module.ts, apps/api/src/custom-modules/custom-modules.service.ts, apps/api/src/custom-modules/custom-modules.service.spec.ts, apps/api/src/custom-modules/custom-modules.controller.spec.ts, apps/api/src/custom-modules/dto/custom-module.dto.ts, docs/mandantentrennung-zugriffsklassifikation.md</files>
<read_first>apps/api/src/module-categories/module-categories.service.ts (aus Task 1), apps/api/src/groups/module-grants.controller.ts, apps/api/src/custom-modules/dto/custom-module.dto.ts, apps/api/src/custom-modules/custom-modules.service.spec.ts, apps/api/src/prisma/prisma-tenant.extension.ts (withTenantTransaction)</read_first>
<behavior>
- create: Name „Werkzeuge & Tools“ ergibt Kennung „werkzeuge-tools“, sortOrder = Maximum + 1, name gespeichert; Name, dessen Kennung einem Modul-Slug (z. B. „proxmox“), „custom“ oder einer vorhandenen Kennung entspricht, bekommt „-2“; leerer Name 400.
- reorderCategories: keys muss genau eine Umstellung aller Kennungen der Organisation sein, sonst 400; danach sortOrder = Index.
- assign module: legt Zuordnung an oder schreibt sie um (categoryKey, sortOrder = Maximum der Zielkategorie + 1); Module.category bleibt unverändert; unbekannte Kategorie 400, unbekanntes Modul 404.
- assign custom: nur gemeinsame eigene Module (ownerUserId null), persönliches oder fremdes 404; schreibt CustomModule.category und sortOrder.
- reorderItems: items muss genau die Menge der nicht persönlichen Einträge der Kategorie sein (Marktplatz-Module mit wirksamer Kategorie + gemeinsame eigene Module), sonst 400; schreibt sortOrder = Index (Module per Upsert der Zuordnung).
- remove: isSystem → 400; unbekannte Kennung → 404; nicht leer (wirksame Module, gemeinsame oder persönliche eigene Module) ohne moveTo → 409 und nichts gelöscht; moveTo gleich key oder unbekannt → 400; mit Ziel: Zuordnungen umgeschrieben, Module mit Manifest-Kategorie ohne Zuordnung bekommen eine Zuordnung zum Ziel, alle CustomModule-Zeilen (auch persönliche) bekommen das Ziel, alles in einer Transaktion, dann Zeile gelöscht; leere Kategorie ohne moveTo wird gelöscht; nach dem Löschen legt ensure die Kategorie nicht wieder an.
- applyToModules: Zuordnung schlägt Manifest; Sortierung Kategorie-Reihenfolge, dann sortOrder (null zuletzt), dann Name.
- getOverview: je Kategorie in Reihenfolge {key, name, sortOrder, isSystem, items: [{type: 'module'|'custom', id, name, slug?}] in Reihenfolge, personalCount}.
- Custom modules: create/update mit Kategorie, die die Organisation nicht hat → 400; vorhandene Kennung (auch neu angelegte) → ok.
</behavior>
<action>
Dienst ergänzen (E-02, E-03, E-05, E-06): create(tenantId, name), reorderCategories(tenantId, keys), assign(tenantId, {type, id, categoryKey}), reorderItems(tenantId, key, items), remove(tenantId, key, moveTo?), getOverview(tenantId), assertCategoryKey(tenantId, key) (ruft ensure, wirft BadRequestException mit deutscher Meldung „Unbekannte Kategorie“). Mehrschrittige Schreibvorgänge (reorderCategories, reorderItems, remove) über withTenantTransaction(this.prisma, tenantId, async (tx) => …), damit das Inventar sie als gebunden erkennt. Eigene Module werden über den Organisations-Klienten OHNE Benutzer gelesen/geschrieben (die Regel aus 20260929130000 lässt dann alle Zeilen der Organisation zu); die Administrator-Prüfung sitzt im Controller. Zusätzlich jede Abfrage mit tenantId im where (Anwendungsprüfung, solange der RLS-Schalter aus ist). Fehlermeldungen deutsch, ohne das Wort Mandant.
Controller (statische Routen VOR ':key', alle schreibenden und overview nur ADMIN/SUPER_ADMIN): GET 'overview', POST '' (CreateModuleCategoryDto {name}), PUT 'order' ({keys: string[]}, ArrayMinSize 1, jedes Element passend zu /^[a-z0-9][a-z0-9-]{0,59}$/), PUT 'assignment' ({type: IsIn ['module','custom'], id: IsString, categoryKey}), dann PATCH ':key' (aus Task 1), PUT ':key/items' ({items: [{type, id}]} mit ValidateNested + Type), DELETE ':key' mit optionalem Query moveTo. Controller-Spec: Rollen-Metadaten je Verwaltungsmethode (Muster expectAdminOnly aus module-manage-handlers.spec.ts), GET '' ohne @Roles, und Reihenfolge der Routen (Index von 'overview', 'order', 'assignment' im Quelltext vor dem ersten ':key').
Überlagerung (E-07): ModuleRegistryController.findAll (mit @Req; ohne tenantId unverändert zurückgeben) und findCatalog über applyToModules leiten; ModuleGrantsController.matrix: Ergebnis von getMatrix nehmen und modules durch applyToModules ersetzen (GroupsModule importiert ModuleCategoriesModule; getMatrix im Dienst bleibt unverändert, damit bestehende Specs halten). Benutzer-Zugriffsdialog unverändert (E-09).
Eigene Module: CUSTOM_MODULE_SELECT um sortOrder erweitern (Antwortfeld für die Seitenleiste). DTO: @IsIn([...CUSTOM_MODULE_CATEGORIES]) durch IsString + Matches(/^[a-z0-9][a-z0-9-]{0,59}$/) ersetzen; Typ string. CustomModulesService injiziert ModuleCategoriesService (CustomModulesModule importiert ModuleCategoriesModule) und ruft assertCategoryKey in create und in update (nur wenn category gesetzt). Bestehende Specs auf den neuen Konstruktor-Parameter anpassen (Stub mit assertCategoryKey), neuen Fall „unbekannte Kategorie 400“ ergänzen. Danach rls-access-inventory.spec.ts laufen lassen und die Rohzahlen/Methodenliste der Zeilen für module-categories.service.ts nachziehen.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/module-categories src/module-registry src/groups src/custom-modules rls-coverage rls-access-inventory && pnpm --filter @tessera/api exec tsc --noEmit && node -e 'const s=require("fs").readFileSync("apps/api/src/module-categories/module-categories.controller.ts","utf8");const k=s.indexOf("\x27:key");for(const r of ["\x27overview\x27","\x27order\x27","\x27assignment\x27"]){const i=s.indexOf(r);if(i<0||k<0||i>k){console.error("route order",r);process.exit(1)}}' && grep -q "applyToModules" apps/api/src/groups/module-grants.controller.ts</automated>
</verify>
<done>Alle Verwaltungsendpunkte vorhanden, nur für Administratoren, statische Routen vor :key; Löschen verschiebt alle Einträge einschließlich persönlicher eigener Module in einer Transaktion und verweigert ohne Ziel mit 409; /modules, /modules/catalog und /module-grants/matrix liefern wirksame Kategorie und Reihenfolge; eigene Module akzeptieren jede vorhandene Kategorie und lehnen unbekannte mit 400 ab; API-Gates grün.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Web — Verwaltungsseite „Kategorien“, Marktplatz, Formular, Texte, CHANGELOG, Handbuch, Gesamtprüfung und Neubau</name>
<files>apps/web/src/lib/module-categories-api.ts, apps/web/src/app/(portal)/admin/modules/categories/page.tsx, apps/web/src/app/(portal)/admin/modules/categories/categories-page.test.tsx, apps/web/src/app/(portal)/admin/modules/page.tsx, apps/web/src/app/(portal)/marketplace/page.tsx, apps/web/src/app/(portal)/modules/[category]/page.tsx, apps/web/src/components/custom-modules/custom-module-form-modal.tsx, apps/web/src/lib/custom-modules-api.ts, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, CHANGELOG.md, docs/anleitung-administration.md, docs/anleitung-anwender.md</files>
<read_first>apps/web/src/app/(portal)/admin/modules/page.tsx, apps/web/src/app/(portal)/admin/modules/grants/page.tsx (Kopf, Zurück-Link), apps/web/src/app/(portal)/admin/groups/components/DeleteGroupDialog.tsx, apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx (Mock-Muster), apps/web/src/components/custom-modules/custom-module-form-modal.tsx, apps/web/src/app/(portal)/marketplace/page.tsx</read_first>
<behavior>
- Seite listet Kategorien in Reihenfolge mit Beschriftung aus useCategoryLabel und darunter ihre Einträge; Pfeil nach oben bei der ersten bzw. nach unten bei der letzten Kategorie/Eintrag gesperrt.
- „Kategorie anlegen“ sendet POST mit dem Namen; Umbenennen sendet PATCH; Pfeile senden PUT order bzw. PUT :key/items mit der vollständigen neuen Reihenfolge.
- Auswahlfeld je Eintrag sendet PUT assignment.
- Löschen-Knopf bei „Eigene Module“ gesperrt; leere Kategorie → einfache Rückfrage → DELETE ohne moveTo; nicht leere (items oder personalCount > 0) → Dialog mit Zielauswahl (ohne die zu löschende) → DELETE mit moveTo.
- Nach jeder Änderung: Übersicht neu laden, Kategorienstand neu laden, Seitenleiste auffrischen (bumpSidebarRefresh).
- Nicht-Administrator sieht den Zugriffshinweis und es wird nichts geladen.
- Formular für eigene Module bietet alle Kategorien aus dem Store in Reihenfolge an; ein vorhandener Wert, der (noch) nicht im Store steht, bleibt als Option erhalten.
</behavior>
<action>
module-categories-api.ts um getModuleCategoryOverview, createModuleCategory, renameModuleCategory, reorderModuleCategories, reorderModuleCategoryItems, assignModuleCategory, deleteModuleCategory(key, moveTo?) erweitern (Fehlerklasse mit status wie CustomModuleRequestError; Fehlermeldung der API anzeigen). CustomModule-Typ in custom-modules-api.ts um sortOrder: number | null ergänzen.
Neue Seite apps/web/src/app/(portal)/admin/modules/categories/page.tsx (Client-Komponente, Rollenprüfung und Layout wie admin/modules/page.tsx, Zurück-Link „Module“ wie in grants/page.tsx): Kopf „Kategorien“ mit Erklärung; Eingabe + Knopf „Kategorie anlegen“; je Kategorie eine Karte mit Name, Pfeilen nach oben/unten (aria-label „Kategorie nach oben/unten verschieben“), „Umbenennen“ (Eingabe an Ort und Stelle, Speichern/Abbrechen), „Löschen“ (bei isSystem gesperrt mit Hinweis „Diese Kategorie kann nicht gelöscht werden“); darin die Einträge (Marktplatz-Module und gemeinsame eigene Module, letztere mit kleinem Hinweis „Eigenes Modul“) mit Pfeilen und einem Auswahlfeld „Kategorie“ (alle Kategorien); bei personalCount > 0 der Satz „Außerdem N persönliche Einträge von Benutzern“; leere Kategorie zeigt „Keine Module in dieser Kategorie“. Löschdialog nach Vorbild DeleteGroupDialog nach E-05 (Text: die Module werden in die gewählte Kategorie verschoben, auch persönliche Einträge von Benutzern; nichts geht verloren). Pfeile verschieben durch Tauschen in der lokalen Liste und senden die vollständige neue Reihenfolge. admin/modules/page.tsx: neben dem Link „Freigaben-Matrix“ einen zweiten Link „Kategorien“ auf /admin/modules/categories; die Kategorie-Plakette zeigt categoryLabel(mod.category) statt der Kennung.
Marktplatz: Filterchips nach Store-Reihenfolge (categoryRank) statt alphabetisch, Store per ensureLoaded laden; Karten behalten die vom Server gelieferte Reihenfolge. Kategorieseite /modules/[category]/page.tsx: Titel über useCategoryLabel statt formatCategoryName (Filter auf mod.category bleibt, die API liefert jetzt die wirksame Kategorie). Prüfen und im SUMMARY festhalten, dass /modules/[category]/[moduleSlug] nur über den Slug auflöst (ModuleAccessGate + ModuleShell); der Zurück-Link nutzt das URL-Segment und darf bleiben. Formular custom-module-form-modal.tsx: Optionen aus dem Store (ensureLoaded beim Öffnen) statt CUSTOM_MODULE_CATEGORIES, Rückfall auf CUSTOM_MODULE_CATEGORIES nur solange der Store leer ist; Vorbelegung bleibt CUSTOM_MODULE_CATEGORY. Freigaben-Matrix braucht keine Änderung (Server sortiert, Beschriftung über useCategoryLabel); Matrix-Test muss weiter grün sein.
Texte: neue Schlüssel unter adminModules (categoriesLink sowie Bereich categories mit allen Seiten- und Dialogtexten) in de.json UND en.json mit identischer Schlüsselmenge; Deutsch mit echten Umlauten und „Sie“, Englisch sachlich; keines der Wörter „Mandant“ oder „tenant“ in Texten. Meldet der Umlaut-Wächter ein korrektes Wort, es in die Erlaubnisliste von umlaut-dictionary.ts aufnehmen. Tests in categories-page.test.tsx für die Fälle aus behavior (API-Helfer und Stores als Modul mocken wie in grants-matrix.test.tsx).
CHANGELOG.md unter „Unveröffentlicht → Neu“ ein Absatz in Alltagssprache (wo die Seite liegt, was Administratoren tun können, dass beim Löschen alle Einträge in eine gewählte Kategorie wandern und „Eigene Module“ nicht löschbar ist, dass Seitenleiste und Marktplatz der Reihenfolge folgen, dass Benutzer für ihre eigenen Einträge jede Kategorie wählen können). docs/anleitung-administration.md: neuer Abschnitt „### Kategorien“ in Kapitel 5 nach „Freigaben-Matrix“ (Anlegen, Umbenennen, Reihenfolge, Zuordnen, Sortieren, Löschen mit Ziel inkl. persönlicher Einträge, „Eigene Module“ nicht löschbar, alte Lesezeichen funktionieren weiter); docs/anleitung-anwender.md: Satz zu „Eigene Module … ganz unten“ anpassen (Reihenfolge legt der Administrator fest, standardmäßig unten; Auswahl umfasst alle Kategorien).
Abschluss: komplette Suites, tsc beider Apps, `biome check` auf alle NEUEN Dateien und `biome lint` auf die berührten bestehenden Dateien (diese haben schon heute Format-/Import-Abweichungen; nicht ganze Altdateien umformatieren, damit der Diff klein bleibt), dann `docker compose up -d --build api web` und prüfen, dass beide Dienste laufen und die API ohne Migrationsfehler startet. Nicht pushen; Browserprüfung macht der Orchestrator.
</action>
<verify>
<automated>pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && pnpm exec biome check apps/api/src/module-categories "apps/web/src/app/(portal)/admin/modules/categories" apps/web/src/lib/module-categories-api.ts apps/web/src/lib/module-category-order.ts apps/web/src/lib/module-category-order.test.ts apps/web/src/lib/stores/module-category-store.ts && pnpm exec biome lint apps/api/src/custom-modules apps/api/src/module-registry/module-registry.controller.ts apps/api/src/groups/module-grants.controller.ts "apps/web/src/app/(portal)/admin/modules/page.tsx" "apps/web/src/app/(portal)/marketplace/page.tsx" "apps/web/src/app/(portal)/modules/[category]/page.tsx" apps/web/src/components/layout/sidebar.tsx apps/web/src/components/custom-modules/custom-module-form-modal.tsx apps/web/src/lib/use-category-label.ts && node -e 'const de=require("./apps/web/src/messages/de.json"),en=require("./apps/web/src/messages/en.json");const w=(o,p,r)=>{for(const[k,v]of Object.entries(o||{})){const q=p+"."+k;if(v&&typeof v==="object")w(v,q,r);else r[q]=v}return r};const a=w(de.adminModules&&de.adminModules.categories,"c",{}),b=w(en.adminModules&&en.adminModules.categories,"c",{});if(Object.keys(a).length<10||Object.keys(a).sort().join()!==Object.keys(b).sort().join()){console.error("key mismatch");process.exit(1)}for(const v of [...Object.values(a),...Object.values(b)])if(/mandant|tenant/i.test(String(v))){console.error("bad text",v);process.exit(1)}' && grep -q "Kategorien" CHANGELOG.md && grep -q "### Kategorien" docs/anleitung-administration.md && docker compose ps --status running --services | grep -qx api && docker compose ps --status running --services | grep -qx web</automated>
</verify>
<done>Administrator → Module → „Kategorien“ ist erreichbar und deckt Anlegen, Umbenennen, Sortieren, Löschen mit Zielauswahl, Zuordnen und Sortieren der Einträge ab; Marktplatz, Kategorieseite, Matrix und Formular folgen den eingestellten Kategorien; Texte de/en vollständig ohne „Mandant/tenant“; CHANGELOG und Handbuch ergänzt; beide Suites, tsc und biome grün; api und web neu gebaut und laufend; nichts gepusht.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → API /module-categories | Eingaben (Name, Kennungen, Modul-IDs, moveTo) sind unvertraut |
| Organisation A ↔ Organisation B | Kategorien und Zuordnungen sind je Organisation getrennt (tenantId + RLS) |
| Benutzer ↔ Administrator | Nur Administratoren ändern Kategorien; persönliche eigene Module bleiben für andere unsichtbar |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-387-01 | Elevation of Privilege | module-categories.controller.ts Schreibrouten + overview | high | mitigate | @UseGuards(RolesGuard) + @Roles(ADMIN, SUPER_ADMIN) je Methode; Controller-Spec prüft die Metadaten; GET '' liefert nur Kennung/Name/Reihenfolge |
| T-387-02 | Information Disclosure | ModuleCategory, ModuleCategoryPlacement | high | mitigate | tenant_isolation_policy mit FORCE RLS in Migration 20261003120000; forTenant/withTenantTransaction je Methode; tenantId zusätzlich im where; rls-coverage + rls-access-inventory grün |
| T-387-03 | Information Disclosure | getOverview / Löschen mit Verschieben | medium | mitigate | Übersicht nennt für persönliche eigene Module nur eine Anzahl (personalCount), nie Name oder Adresse; assign/reorderItems akzeptieren nur gemeinsame eigene Module (persönliche 404) |
| T-387-04 | Tampering | assign/reorderItems mit fremden IDs | medium | mitigate | Modul-ID gegen Katalog, eigene Module mit where {id, tenantId, ownerUserId: null}; reorderItems verlangt exakt die Menge der Einträge der Kategorie, sonst 400 |
| T-387-05 | Denial of Service | remove ohne Ziel / Datenverlust | medium | mitigate | Nicht leere Kategorie ohne moveTo → 409, nichts gelöscht; Verschieben und Löschen in einer Transaktion; „Eigene Module“ (isSystem) nicht löschbar |
| T-387-06 | Tampering | Kennung als URL-Segment | low | mitigate | Kennung serverseitig aus dem Namen gebildet (a-z0-9-), kollisionsfrei gegen Modul-Slugs und „custom“; Kennungen in DTOs per Regex geprüft |
| T-387-SC | Tampering | npm/pip/cargo installs | high | accept | Keine neuen Pakete in diesem Auftrag (nur vorhandene Abhängigkeiten) |
</threat_model>
<verification>
- Alle drei automatisierten Prüfungen grün; vollständige API- und Web-Suite grün.
- Migration lokal angewendet, FORCE RLS auf beiden neuen Tabellen.
- docker compose: api und web neu gebaut und laufend.
- Browserprüfung (Orchestrator, dunkel): Kategorie anlegen, Modul hineinschieben, umbenennen, sortieren, nicht leere Kategorie löschen mit Ziel; Seitenleiste und Marktplatz folgen; alte Modul-Adresse öffnet weiter.
</verification>
<success_criteria>
- Administratoren pflegen Kategorien vollständig über die neue Seite; kein Modul geht beim Löschen verloren.
- Wirksame Kategorie und Reihenfolge gelten in Seitenleiste, Marktplatz, Kategorieseite, Freigaben-Matrix und Formular für eigene Module.
- Module.category bleibt unangetastet; Standardkategorien bleiben übersetzt, solange sie nicht umbenannt sind.
- RLS-Gates, Inventar-Doku, CHANGELOG und Handbuch aktuell; nichts gepusht.
</success_criteria>
<output>
Create `.planning/quick/261003-387-kategorien-durch-admins-bearbeitbar-umbe/261003-387-SUMMARY.md` when done (inkl. der Entscheidungen E-01 bis E-09 und des Befunds zur Slug-Auflösung von /modules/[category]/[moduleSlug]).
</output>
@@ -0,0 +1,141 @@
---
phase: quick-261003-387
plan: 01
subsystem: modules
tags: [module-categories, admin, sidebar, marketplace, rls, prisma, nestjs, nextjs]
status: complete
requirements: [QUICK-261003-387]
completed: 2026-10-03
duration: 23 min
commits: 3
plan_head_before: 53a49a109fc70dab6d5acf43d41649939cb1251d
plan_head_after: f2c0a896e840493d56305726ecaf98eb92a86889
actuals:
tokens: 215000
tasks: 3
commits: 3
key-files:
created:
- apps/api/prisma/migrations/20261003120000_module_categories/migration.sql
- apps/api/src/module-categories/module-categories.service.ts
- apps/api/src/module-categories/module-categories.controller.ts
- apps/api/src/module-categories/module-categories.module.ts
- apps/api/src/module-categories/dto/module-category.dto.ts
- apps/api/src/module-categories/module-categories.service.spec.ts
- apps/api/src/module-categories/module-categories.controller.spec.ts
- apps/api/src/module-categories/module-categories.fake-prisma.ts
- apps/api/src/module-registry/module-registry.controller.categories.spec.ts
- apps/web/src/app/(portal)/admin/modules/categories/page.tsx
- apps/web/src/app/(portal)/admin/modules/categories/categories-page.test.tsx
- apps/web/src/lib/module-category-order.ts
- apps/web/src/lib/stores/module-category-store.ts
modified:
- apps/api/prisma/schema.prisma
- apps/api/src/module-registry/module-registry.controller.ts
- apps/api/src/groups/module-grants.controller.ts
- apps/api/src/custom-modules/custom-modules.service.ts
- apps/web/src/components/layout/sidebar.tsx
- apps/web/src/lib/use-category-label.ts
- apps/web/src/lib/module-categories-api.ts
- apps/web/src/app/(portal)/marketplace/page.tsx
- apps/web/src/components/custom-modules/custom-module-form-modal.tsx
- docs/mandantentrennung-zugriffsklassifikation.md
- CHANGELOG.md
---
# Quick 261003-387: Modulkategorien durch Administratoren bearbeitbar
Administratoren pflegen die Modulkategorien ihrer Organisation jetzt selbst (Administrator → Module → „Kategorien“): anlegen, umbenennen, sortieren, Module und gemeinsame eigene Module zuordnen und sortieren, löschen mit Zielkategorie. Seitenleiste, Marktplatz, Kategorieseite, Freigaben-Matrix und das Formular für eigene Module folgen der Einstellung.
## Was gebaut wurde
**Task 1 (Tracer, 8ec116c):** Tabellen `ModuleCategory` und `ModuleCategoryPlacement` mit `FORCE ROW LEVEL SECURITY` und `tenant_isolation_policy`, Spalte `CustomModule.sortOrder`; Migration lokal angewendet (`prisma migrate diff` gegen die Datenbank ist leer). `ModuleCategoriesService` mit Grundbestand je Organisation (`ensure`), `listCategories`, `applyToModules`, `rename`. `GET /module-categories` (jeder Angemeldete), `PATCH :key` (nur Administratoren). `/modules/active` liefert die wirksame Kategorie samt `sortOrder`. Web: API-Client, Kategorienspeicher, `module-category-order.ts`, `useCategoryLabel` (gespeicherter Name vor Übersetzung vor Kennung), Seitenleiste ordnet Gruppen und Einträge nach dem Speicher.
**Task 2 (API, af1878d):** `create`, `reorderCategories`, `assign`, `reorderItems`, `remove`, `getOverview`, `assertCategoryKey`; Controller mit Routen `overview`, `POST`, `order`, `assignment` vor `:key`, dazu `:key/items` und `DELETE :key?moveTo=`. Überlagerung in `GET /modules`, `/modules/catalog` und `/module-grants/matrix`. Eigene Module prüfen die Kategorie gegen die Organisation (400), das DTO prüft nur das Format der Kennung. Zugriffsinventar (Zeilen, Bereichszeile, Summe, Paarzahl) nachgezogen.
**Task 3 (Web, f2c0a89):** Verwaltungsseite `/admin/modules/categories` mit Löschdialog (Zielauswahl), Knopf „Kategorien“ neben „Freigaben-Matrix“, Kategorie-Plakette mit Anzeigenamen, Marktplatz-Chips in Kategorienreihenfolge, Kategorieseite mit gespeichertem Namen, Formular mit allen Kategorien der Organisation (vorhandener Wert bleibt Option), Texte de/en, CHANGELOG, Handbuch Administration und Anwender.
## Entscheidungen E-01 bis E-09 (wie im Plan umgesetzt)
- **E-01** `ModuleCategory {id, tenantId, key, name?, sortOrder, isSystem}`, eindeutig `(tenantId, key)`; Kennung unveränderlich, `isSystem` nur für `custom-modules`.
- **E-02** `ModuleCategoryPlacement` je `(tenantId, moduleId)`; wirksam ist die Zuordnung, sonst `Module.category`. `Module.category` wird nie geschrieben (durch Test belegt).
- **E-03** Gemeinsame eigene Module: `CustomModule.category` und neue Spalte `sortOrder` direkt; persönliche bekommen nie eine `sortOrder`.
- **E-04** Grundbestand beim ersten Lesen, ohne SQL-Rückfüllung; fehlende benutzte Kennungen werden hinten nachgelegt; eine gelöschte Standardkategorie kommt nur zurück, wenn ein Modul sie wirksam benutzt.
- **E-05** Löschen verschiebt Marktplatz-Module (Zuordnung umgeschrieben bzw. neu angelegt), gemeinsame UND persönliche eigene Module in einer Transaktion; ohne Ziel 409.
- **E-06** Kennung aus dem Namen (ä→ae …, höchstens 40 Zeichen, leer → `kategorie`), bei Kollision mit Kennung, Modul-Slug oder `custom` `-2`, `-3` …
- **E-07** Überlagerung serverseitig in allen Modullisten (Kategorie-Reihenfolge, dann `sortOrder` mit null zuletzt, dann Name).
- **E-08** Seitenleiste: `sortOrder` aufsteigend, null zuletzt, eingebaut vor eigenem, dann Name.
- **E-09** Benutzer-Zugriffsdialog unverändert; keine „Auf Standardnamen zurücksetzen“-Funktion.
Zusätzlich festgelegt (Planer-Ermessen): Die letzte verbleibende Kategorie kann nie gelöscht werden, weil „Eigene Module“ nicht löschbar ist; dadurch fällt `ensure` nie auf den Standardbestand zurück.
## Befund zur Slug-Auflösung von /modules/[category]/[moduleSlug]
Die Seite löst das Modul ausschließlich über `moduleSlug` auf (`ModuleShell` → `ModuleAccessGate`); `category` wird nur für den „Zurück“-Link verwendet. Alte Adressen `/modules/<alte-kategorie>/<slug>` öffnen das Modul deshalb weiter. Einzige Folge: Der „Zurück“-Link einer solchen alten Adresse führt auf `/modules/<alte-kategorie>`, und diese Kategorieseite zeigt dann „Keine aktiven Module in dieser Kategorie“ (kein 404, nur leer). Der Plan lässt den Link bewusst bestehen; nicht geändert.
## Abweichungen vom Plan
### Automatisch behoben
**1. [Rule 3 - Blockierend] Kategorienspeicher übernahm eine unerwartete Antwort**
- **Gefunden bei:** Task 3, `tenant-selector.test.tsx` (zwei Tests rot: `categories.find is not a function`)
- **Problem:** Der Test-Fetch liefert für jede Adresse dieselben Daten; der Speicher übernahm sie als Kategorienliste.
- **Lösung:** `load()` übernimmt nur ein Feld (`Array.isArray`), sonst bleibt der bisherige Stand.
- **Dateien:** `apps/web/src/lib/stores/module-category-store.ts`
**2. [Rule 1 - Bug] Bestehende Tests an das neue Verhalten angepasst**
- `custom-module.dto.spec.ts`: „lehnt eine unbekannte Kategorie ab“ galt nur für die feste Liste; das DTO prüft jetzt das Format, die Existenz prüft der Dienst (neuer Dienst-Test, 400).
- `sidebar.test.tsx`: Kategorienspeicher als Fixture (Gruppenreihenfolge kommt nicht mehr aus dem festen Sonderfall „Eigene Module zuletzt“).
- `umlaut-dictionary.ts`: „Neuer“ in die Erlaubnisliste (korrektes Deutsch).
### Ergänzungen über den Plan hinaus
- `module-categories.fake-prisma.ts` (Speicher-Attrappe für den Dienst-Test) und `module-registry.controller.categories.spec.ts` (belegt die Überlagerung in `/modules`, `/active`, `/catalog` und Matrix).
- Zusätzliche Tests in `custom-modules-page.test.tsx` (Formular-Optionen) und `marketplace-filters.test.tsx` (Chip-Reihenfolge).
## Prüfergebnisse (ehrlich)
| Prüfung | Ergebnis |
|---|---|
| API-Tests (`pnpm --filter @tessera/api test`) | 125 Dateien, **2205 Tests, alle grün** |
| Web-Tests (`pnpm --filter @tessera/web test`) | 126 Dateien, **1361 Tests, alle grün** |
| `tsc --noEmit` api / web | beide fehlerfrei |
| `biome check` auf alle neuen Dateien | sauber (0 Fehler, 0 Warnungen) |
| `biome lint` auf berührte bestehende Dateien | 0 Fehler; 1 bereits vorhandene Warnung (`sidebar.tsx`, a11y `role="group"`), nicht von dieser Änderung |
| `rls-coverage` + `rls-access-inventory` | grün (35 Tests) |
| Routenreihenfolge (statisch vor `:key`) | Skript aus dem Plan grün, zusätzlich Controller-Test |
| Migration lokal | angewendet, `prisma migrate diff` leer, beide Tabellen `relrowsecurity` + `relforcerowsecurity` |
| `docker compose up -d --build api web` | api (healthy) und web laufen, API startet ohne Migrationsfehler, 9 Logzeilen zu `/module-categories`-Routen, `GET /module-categories` ohne Anmeldung → 401, Startseite antwortet |
Nicht geprüft: Anmeldung und Browserablauf (macht der Orchestrator). Keine Prüfung gegen die laufende API mit Administrator-Anmeldung durchgeführt.
## Zugriffsinventar
Vier neue Zeilen für `module-categories.service.ts` (`moduleCategory`, `moduleCategoryPlacement`, `customModule` gebunden, `module` ungebunden). Bereichszeile `module-categories` 4/22/0 (mit der Gate-Schleife nachgemessen), Summe 61/273/8 → 65/295/8, Paarzahl 96 → 100 (59 muss-mandantengebunden, 23 keine-mandantengebundene-tabelle, 16 beides, 2 bewusst-uebergreifend).
## Bekannte Stubs
Keine.
## Threat Flags
Keine neue Angriffsfläche außerhalb des Plan-Bedrohungsmodells. T-387-01 bis T-387-06 sind umgesetzt: `@Roles(ADMIN, SUPER_ADMIN)` je Verwaltungsmethode (Controller-Test, auch „keine Methode ohne Rolle außer `list`“), FORCE RLS plus `tenantId` in jedem `where`, persönliche Einträge nur als Anzahl, `assign`/`reorderItems` nur für gemeinsame Einträge (persönliche und fremde: 404/400), 409 ohne Ziel und Transaktion beim Löschen, Kennungen per Regex im DTO.
## Offene Hinweise
- Das Ändern der Kategorie eines gemeinsamen eigenen Moduls über dessen Formular setzt `sortOrder` nicht zurück; die alte Position kann in der neuen Kategorie also mitten in der Reihenfolge landen, bis ein Administrator neu sortiert. Zuordnen über die Kategorienseite hängt dagegen hinten an.
- Gepusht wurde nichts; `STATE.md`, `PLAN.md` und diese Datei sind nicht committet (Vorgabe).
## Self-Check: PASSED
- Neue Dateien vorhanden (Migration, Dienst, Controller, DTO, Seite, Tests): bestätigt über `git diff --stat` (43 Dateien).
- Commits vorhanden: 8ec116c, af1878d, f2c0a89 (`git rev-list --count 53a49a1..HEAD` = 3).
## Browser-Prüfung (Orchestrator, 03.10., lokal)
- Administrator → Module → Kategorien: alle Bereiche mit Modulen, „Eigene Module“ nicht löschbar.
- Neue Kategorie „Server“ angelegt, Proxmox per Auswahl hinein, nach ganz oben sortiert → Seitenleiste folgt.
- Umbenannt in „Serverraum“ (Kennung bleibt `server`).
- „Infrastruktur“ gelöscht mit Ziel „Serverraum“ → Nextcloud-Status umgezogen.
- Marktplatz und Freigaben-Matrix zeigen dieselbe Einteilung und Reihenfolge.
- Alte Adressen /modules/infrastructure/proxmox und …/nextcloud-status öffnen weiter das Modul.
@@ -0,0 +1,14 @@
---
quick_id: 261005-blw
slug: notiz-links-in-neuem-tab
date: 2026-10-05
---
# Notiz-Links in neuem Tab
User 05.10.: URL in einer Notiz oeffnet im selben Fenster. Links sollen immer in einem neuen Tab oeffnen.
## Task 1
- `NoteLink` in `note-task-list.tsx`: ersetzt `<a>` der Markdown-Vorschau, setzt `target="_blank"` + `rel="noopener noreferrer"`, `#`-Sprungmarken bleiben im Fenster, `node` nicht ins DOM.
- In `note-widget.tsx` per `previewOptions.components.a` einhaengen.
- Test mit echtem `MDEditor.Markdown` + `rehypeSanitize` (Markdown-Link, nackte URL, Sprungmarke).
@@ -0,0 +1,13 @@
---
quick_id: 261005-blw
status: complete
date: 2026-10-05
---
# 261005-blw: Notiz-Links in neuem Tab — SUMMARY
- `NoteLink` (note-task-list.tsx) ersetzt Links der Notiz-Vorschau: neuer Tab, `rel="noopener noreferrer"`; `#...` bleibt im selben Fenster.
- Eingehaengt ueber `previewOptions.components.a` in note-widget.tsx.
- Desktop-App: Opener-Link-Skript faengt `target="_blank"` auf `document` ab und oeffnet im System-Browser.
- Nachweis: Vitest note-* 24/24 gruen (neuer Test mit echtem MDEditor.Markdown + rehypeSanitize), tsc sauber; Biome-Befunde der drei Dateien unveraendert gegenueber HEAD (5, vorbestehend).
- Nicht im Browser geprueft (lokaler Web-Container nicht neu gebaut).
@@ -0,0 +1,20 @@
---
quick_id: 261005-bt1
slug: proxmox-autostart-warnung
date: 2026-10-05
---
# Proxmox: Warnung bei gestopptem Gast mit Autostart
User 05.10.: Warnung fuer jede VM oder jeden LXC, der nicht laeuft, fuer den Autostart aber aktiv ist.
## Task 1 — API
- `/cluster/resources` liefert `onboot` nicht. Fuer jeden nicht laufenden qemu/lxc (ohne Vorlage) `GET /nodes/{node}/{type}/{vmid}/config` lesen, Deckel 40 je Durchlauf.
- Metriken PVE: `autostartStopped[]` (vmid, name, type, node, status) + `autostartUnchecked` (Abruf fehlgeschlagen/Deckel). Kein Schema-Umbau (metrics ist JSON).
- Nur GET, Lese-Riegel (`proxmox-nur-lesen.spec.ts`) bleibt gruen.
## Task 2 — Web
- `serverHealth`: Autostart-Befund -> warn.
- ServerCard: Warnblock mit einer Zeile je Gast, Hinweis bei ungeprueften.
- Dashboard-Kachel: Kennzahl „N gestoppt trotz Autostart“ (Vorrang vor Auslastung).
- de/en-Texte, Handbuch, CHANGELOG.
@@ -0,0 +1,13 @@
---
quick_id: 261005-bt1
status: complete
date: 2026-10-05
---
# 261005-bt1: Proxmox Autostart-Warnung — SUMMARY
- API: `listStoppedPveGuests` + `readPveOnboot` (proxmox-normalize.ts), `checkPveAutostart` im PVE-Durchlauf (proxmox.service.ts), Deckel `PVE_AUTOSTART_QUERY_CAP = 40`. Fehlendes `onboot` = aus; fehlgeschlagener Config-Abruf (z. B. 403 ohne VM.Audit) macht den Server NICHT unerreichbar, sondern zaehlt in `autostartUnchecked`.
- Web: `autostartAlerts` + Warnstufe in proxmox-status.ts, `AutostartNotice` in ServerCard, Kennzahl `autostart` in der Kachel. Alte Zwischenlagerstaende ohne Feld -> keine Warnung.
- Tests: api 2208/2208, web 1370/1370 gruen (neu: Service-Durchlauf mit onboot/ohne/403/Vorlage, Normalisierer, serverHealth, Kennzahl, Kachel-Render, ServerCard-Render). tsc beider Apps sauber; Biome-Befunde der geaenderten Dateien unveraendert gegenueber HEAD.
- Nicht gegen echten PVE geprueft (lokaler Stack aus, Zugangsdaten nur verschluesselt auf alpha). alpha hat PVE01 mit 2 gestoppten Gaesten -> nach Deploy dort pruefen.
- Folgen fuer den Abfrageweg: je PVE-Durchlauf eine zusaetzliche GET-Anfrage pro gestopptem Gast.
@@ -0,0 +1,16 @@
---
quick_id: 261005-d5d
slug: kalender-test-zeitgrenze
date: 2026-10-05
---
# Kalender-Test: Zeitgrenze + Meldung „nicht erreichbar“
Anlass 05.10.: Exchange auf alpha/live nicht erreichbar (interner DNS -> 172.16.0.3), „Verbindung testen“ hing ~2 min auf „wird geprueft“, danach „Adresse und Zugangsdaten pruefen“.
## Task 1 — API
- EWS-Aufrufe (httpntlm) mit 15 s Zeitgrenze.
- Netzfehler (TIMEOUT, ETIMEDOUT, ECONNREFUSED, ...) im Test als `CalendarSourceUnreachableError`; Service liefert Schluessel `unreachable`.
## Task 2 — Web
- Formular zeigt bei `unreachable` eigene Meldung (de/en).
@@ -0,0 +1,13 @@
---
quick_id: 261005-d5d
status: complete
date: 2026-10-05
---
# 261005-d5d: Kalender-Test Zeitgrenze — SUMMARY
- `ntlmPost` (exchange.provider.ts): eigene 15-s-Zeitgrenze per Timer, zusaetzlich `timeout` an httpntlm. httpreqs `timeout` allein greift NICHT waehrend des Verbindungsaufbaus ueber den Keep-alive-Agenten — echter Lauf gegen 172.16.0.3 dauerte damit noch 134,5 s; erst der eigene Timer brachte 15,0 s. Unit-Test haette das nicht gefunden (Stub), deshalb echter Lauf im lokalen Container.
- `CalendarSourceUnreachableError` + `isNetworkUnreachableError` (calendar.service.ts); `testConnection`/`testConnectionFromConfig` liefern `error: 'unreachable'`, lastSyncError „Server not reachable“.
- Formular: `formTestUnreachable` statt `formTestFailed`.
- Nachweis (lokaler Container, kompilierter Provider): 172.16.0.3 -> nicht erreichbar nach 15,0 s; owa.ctl.de mit falschem Passwort -> erreichbar/abgelehnt 0,0 s. Tests api 2219+, web 1372 gruen.
- Nachtrag (User „fix“): `inbox/exchange-inbox.provider.ts` bekommt denselben Timer, 60 s (laedt PDF-Anhaenge). testConnection meldet dann „EWS server not reachable (no answer within 60 s)“. Unit-Test mit Fake-Timern; echter Lauf nicht wiederholt (gleiche Mechanik wie im Kalender, dort echt gemessen).
@@ -0,0 +1,13 @@
---
quick_id: 261005-jqd
slug: verwaltung-aufgeraeumt
date: 2026-10-05
---
# Einstellungen und Verwaltung aufgeräumt
User 05.10.: Menüpunkt einheitlich „Verwaltung“; Einstellungen/Administration unübersichtlich — mit Design-Skill komplett überarbeiten, Umgebung vorher klonen.
- Klon ~/projects/tessera-design (Zweig design/verwaltung), Stack auf 3100/3101 mit DB-Kopie.
- Gemeinsame Navigation (ControlCenterNav), PageHeader, SettingsSection; alle 15 Seiten umstellen.
- Nach Freigabe („Gefällt mir sehr gut. Übernehmen“): nach main, Handbuch + CHANGELOG.
@@ -0,0 +1,15 @@
---
quick_id: 261005-jqd
status: complete
date: 2026-10-05
---
# 261005-jqd: Einstellungen und Verwaltung aufgeräumt — SUMMARY
- Neu: apps/web/src/components/control-center/ (Nav, Layout, PageHeader, SettingsSection) + cc-*-Regeln in globals.css. Alte admin-sidebar/settings-sidebar entfernt; beide Layouts nutzen ControlCenterLayout.
- Verwaltung gruppiert: Benutzer und Zugang / Module (inkl. Freigaben, Kategorien) / Anbindungen. „Administrator“ → „Verwaltung“; Seitentitel = Navigationsnamen (Benutzer, Module, Freigaben, Verzeichnis (LDAP), E-Mail-Versand). /settings leitet auf Konto.
- Alle 15 Seiten auf PageHeader + Karten; Konto/SMTP/LDAP in Abschnitte; Benutzertabelle ohne Seiten-Scrollleiste; Module je Kategorie; Handy: Auswahlliste, Tabellen mit Container-Query-Mindestbreite scrollen in der Karte.
- Nachweis: Web 1372/1372 Tests, tsc sauber; Sichtprüfung hell/dunkel, 390/1280/1440 px (Fotos .playwright-mcp/design-before, design-r1..r3). Umgesetzt im Klon mit 4 parallelen Helfern nach Regeldatei, danach Abgleich.
- Doku: anleitung-anwender/-administration/-betrieb Pfade auf „Verwaltung → …“, Einstellungen-Kapitel neu beschrieben; CHANGELOG „Neu“.
- Nachtrag (User: „Einstellungen und Verwalten zeigen auf die gleiche Seite … Lass es bei Einstellungen; bei Admin Einstellungen/Administration“): Profilmenü nur noch EIN Eintrag (/settings), Beschriftung für ADMIN/SUPER_ADMIN `header.settingsAdmin` = „Einstellungen/Administration“; Verwaltungs-Eintrag entfernt. Im Browser geprüft (localhost:3000).
- Nachtrag 06.10. (User: „In den Einstellungen heißt es immer noch Verwaltung. Dort soll auch Administration stehen“): Leistenblock, Seitentitel oben und Pfadhinweise „Administration → …“ (de); Handbuch + CHANGELOG nachgezogen. Im Browser geprüft.
@@ -0,0 +1,10 @@
---
quick_id: 261006-dcs
date: 2026-10-06
---
# Linux-App: weißes Fenster auf EndeavourOS/Arch
User 06.10.: neuester Linux-Client startet auf EndeavourOS, zeigt nur weißes Fenster. WEBKIT_DISABLE_DMABUF_RENDERER / COMPOSITING / GDK_BACKEND=x11 helfen nicht; keine NVIDIA.
- Nachstellen im archlinux-Container (Xvfb), Ursache finden, im CI-Bau beheben.
@@ -0,0 +1,12 @@
---
quick_id: 261006-dcs
status: complete
date: 2026-10-06
---
# 261006-dcs: Linux-App weißes Fenster (Arch) — SUMMARY
- Nachgestellt: AppImage 1.9.2-beta.fffb7ff in archlinux:latest unter Xvfb -> "Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...". Ursache: von linuxdeploy mitgebrachte libwayland-{client,cursor,egl,server} (Debian-Bau) passen nicht zu neuerem Mesa.
- Probe: dieselbe AppImage ohne die vier Dateien startet auf Arch UND Debian trixie (beide Bildschirmfotos zeigen die Einrichtungsseite).
- Lösung: .gitea/scripts/appimage-strip-wayland.sh nach "tauri build --bundles appimage": entpacken, libwayland-* löschen, mit appimagetool aus Tauris linuxdeploy-plugin-appimage und der runtime des Originals neu packen, `tauri signer sign` schreibt .sig neu (mit Wegwerfschlüssel geprüft). CI-Schritt + DESKTOP_PATHS (Stempel) ergänzt.
- linuxdeploy kennt --exclude-library, Tauri reicht aber keine Option durch -> Nachbearbeitung statt Bau-Option.
@@ -0,0 +1,380 @@
---
phase: quick-261008-dts
plan: 01
type: execute
wave: 1
depends_on: []
quick_id: 261008-dts
description: "Neues Modul Domains: AutoDNS-Anbindung (Demo/Live), Kontakte mit Kundenzuordnung, Domainliste, Registrierung mit Schutz vor Doppelbestellungen"
date: 2026-10-08
files_modified:
# Task 1 — tracer: DB (all four tables) -> AutoDNS client -> settings + connection test -> module page with Einstellungen tab
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20261008120000_domains_autodns/migration.sql
- apps/api/src/domains/autodns-client.ts
- apps/api/src/domains/autodns-client.spec.ts
- apps/api/src/domains/domains.types.ts
- apps/api/src/domains/domains-settings.service.ts
- apps/api/src/domains/domains-settings.service.spec.ts
- apps/api/src/domains/dto/domains-settings.dto.ts
- apps/api/src/domains/domains.controller.ts
- apps/api/src/domains/domains.controller.spec.ts
- apps/api/src/domains/domains.seed.ts
- apps/api/src/domains/domains.module.ts
- apps/api/src/app.module.ts
- apps/api/src/module-registry/module-manage-handlers.spec.ts
- docs/mandantentrennung-zugriffsklassifikation.md
- apps/web/src/lib/domains-api.ts
- apps/web/src/app/(portal)/modules/domains/layout.tsx
- apps/web/src/app/(portal)/modules/domains/page.tsx
- apps/web/src/app/(portal)/modules/domains/components/EnvironmentBadge.tsx
- apps/web/src/app/(portal)/modules/domains/components/SettingsTab.tsx
- apps/web/src/app/(portal)/modules/domains/domains-page.test.tsx
- apps/web/src/app/(portal)/modules/module-layouts.test.tsx
- apps/web/src/lib/module-loader.ts
- apps/web/src/lib/module-identity.ts
- apps/web/src/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
- apps/web/src/messages/umlaut-dictionary.ts
# Task 2 — customers, contacts (read, create, assign), domain list
- apps/api/src/domains/autodns-parse.ts
- apps/api/src/domains/autodns-parse.spec.ts
- apps/api/src/domains/domains-cache.ts
- apps/api/src/domains/domains-directory.service.ts
- apps/api/src/domains/domains-directory.service.spec.ts
- apps/api/src/domains/dto/domains-customer.dto.ts
- apps/api/src/domains/dto/domains-contact.dto.ts
- apps/web/src/components/domains/group-by-customer.ts
- apps/web/src/components/domains/group-by-customer.test.ts
- apps/web/src/app/(portal)/modules/domains/components/DomainsTab.tsx
- apps/web/src/app/(portal)/modules/domains/components/ContactsTab.tsx
- apps/web/src/app/(portal)/modules/domains/components/ContactForm.tsx
- apps/web/src/app/(portal)/modules/domains/components/ContactForm.test.tsx
- apps/web/src/app/(portal)/modules/domains/components/CustomersTab.tsx
# Task 3 — availability, orders with money safety, job tracking, changelog, docs, full gates
- apps/api/src/domains/domain-name.ts
- apps/api/src/domains/domain-name.spec.ts
- apps/api/src/domains/domains-orders.service.ts
- apps/api/src/domains/domains-orders.service.spec.ts
- apps/api/src/domains/dto/domains-order.dto.ts
- apps/web/src/components/domains/order-status.ts
- apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx
- apps/web/src/app/(portal)/modules/domains/components/RegisterTab.test.tsx
- apps/web/src/app/(portal)/modules/domains/components/OrdersTab.tsx
- apps/web/src/app/(portal)/modules/domains/components/OrdersTab.test.tsx
- CHANGELOG.md
- docs/anleitung-anwender.md
- docs/anleitung-administration.md
autonomous: true
requirements: [QUICK-261008-dts]
estimate:
tokens: 190000
raw_tokens: 190000
tasks: 3
confidence: low
must_haves:
truths:
- "After activation in the Marktplatz and a Freigabe, a user with Benutzen opens the module Domains and sees the tabs Domains, Kontakte, Kunden and Aufträge; a user with Verwalten or an administrator additionally sees Registrieren and Einstellungen, and the API answers 403 to Benutzen-only users on every settings, connection-test, customer-write, contact-create, contact-assign, availability, order-create, submit and cancel route"
- "A manager stores AutoDNS user, password and context separately for the Demo and the Live system, switches between them only after an explicit confirmation (default Demo), sets the default nameservers, and 'Verbindung testen' sends exactly one GET /hello to the fixed host of that environment with Basic auth, X-Domainrobot-Context and a Tessera User-Agent; the password is stored AES-encrypted via CryptoService and never appears in any API response"
- "The Kontakte tab lists every AutoDNS contact of the active environment with its customer or 'Nicht zugeordnet', can re-read the list from AutoDNS on demand, and lets managers create PERSON/ORG contacts (name, organisation, address, phone, e-mail) and assign one or many contacts to a customer; one customer can be marked 'Eigene Firma'; the list filters and groups by customer"
- "The Domains tab lists every AutoDNS domain of the active environment with customer (from the owner contact's assignment, else 'Nicht zugeordnet'), owner, expiry date and status, filterable and groupable by customer"
- "Registering requires availability FREE from DomainStudio, owner/admin/tech/zone contacts, 2 to 6 nameservers prefilled from the settings, a summary with environment and price, a ticked confirmation and the button 'Jetzt verbindlich registrieren'; the server sends POST /domain at most once per order (atomic DRAFT to SUBMITTING claim, no retry), answers 409 to a second confirmation or after an environment switch, and stores UNKNOWN instead of guessing when the outcome is unclear"
- "Order status follows the AutoDNS job (läuft, erfolgreich, fehlgeschlagen, Rückfrage nötig) whenever the Aufträge tab opens or refreshes; an unclear order is reconciled against AutoDNS and can only be discarded after a reconciliation found neither the domain nor a job"
- "All AutoDNS traffic goes through one client with the two fixed base URLs, at most one request start per 350 ms per process, a 20 s timeout, no retries, and status.type ERROR counts as failure even with HTTP 200; AutoDNS login failures reach the browser as 502, never as 401"
artifacts:
- path: "apps/api/prisma/migrations/20261008120000_domains_autodns/migration.sql"
provides: "DomainsConfig, DomainsCustomer, DomainsContactAssignment, DomainsOrder with tenant_isolation_policy"
contains: "DomainsOrder"
- path: "apps/api/src/domains/autodns-client.ts"
provides: "fixed Demo/Live base URLs, autodnsRequest (never throws), envelope parser, rate limiter"
exports: ["AUTODNS_BASE_URLS", "autodnsRequest", "parseAutodnsEnvelope", "AutodnsRateLimiter"]
- path: "apps/api/src/domains/domains-settings.service.ts"
provides: "encrypted per-environment credentials, masked settings view, connection test, active credentials"
- path: "apps/api/src/domains/domains-directory.service.ts"
provides: "customers, live contact/domain lists with customer join, contact create, assignment"
- path: "apps/api/src/domains/domains-orders.service.ts"
provides: "availability, draft, single-shot submit with atomic claim, job refresh, reconciliation, cancel"
- path: "apps/web/src/app/(portal)/modules/domains/page.tsx"
provides: "module page with environment badge and six tabs gated by useCanManageModule"
- path: "apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx"
provides: "availability, contact and nameserver choice, summary, explicit binding confirmation"
key_links:
- from: "apps/api/src/domains/domains-orders.service.ts submitOrder"
to: "domainsOrder.updateMany where status DRAFT and active environment, then autodnsRequest POST /domain once"
via: "count === 1 gate before the network call"
pattern: "status: 'DRAFT'"
- from: "apps/api/src/domains/domains-settings.service.ts"
to: "CryptoService encrypt/decrypt"
via: "per-environment password columns, masked response"
pattern: "crypto\\.encrypt\\("
- from: "apps/api/src/domains/domains-directory.service.ts listDomains"
to: "domainsContactAssignment of the active environment"
via: "owner contact id -> customer"
pattern: "ownerc"
- from: "apps/api/src/domains/domains.controller.ts"
to: "ModuleGuard"
via: "class UseModule('domains') + handler ModuleManage('domains')"
pattern: "@ModuleManage\\('domains'\\)"
- from: "apps/web/src/app/(portal)/modules/domains/page.tsx"
to: "useCanManageModule('domains')"
via: "Registrieren/Einstellungen tabs and write controls only for managers"
pattern: "useCanManageModule\\('domains'\\)"
---
<objective>
New Tessera module "Domains" (slug `domains`) connected to the AutoDNS / InterNetX Domainrobot JSON API. Stage 1 delivers: settings with encrypted per-environment access and connection test, AutoDNS contacts with a local customer assignment, the domain list, and domain registration that can never order twice. Asynchronous AutoDNS jobs are tracked.
Locked decisions from the request (cited below as L-xx):
- L-01 Module "Domains" on the AutoDNS JSON API — Live `https://api.autodns.com/v1`, Demo `https://api.demo.autodns.com/v1`; HTTP Basic auth plus header `X-Domainrobot-Context`; no API key; a dedicated API user without 2FA.
- L-02 Settings: API access (user, password AES-encrypted via CryptoService like the LDAP bind password, never returned to the client, context as a number field per environment), Demo/Live switch, default nameservers, connection test.
- L-03 Contacts: list of the AutoDNS domain contacts; create via form (Person/Organisation, address, phone, e-mail); read in existing AutoDNS contacts; a contact can be assigned to a customer (domains mostly for customers, also for the own company — the own company is one customer entry); list filterable/groupable by customer.
- L-04 Register a domain: availability check (DomainStudio), choose contacts from the list (owner/admin-c/tech-c/zone-c), nameservers prefilled, summary plus explicit confirmation "Jetzt verbindlich registrieren" (costs money). Double orders impossible: local order, atomic status change, exactly one POST without retry, UNKNOWN on an unclear outcome, order bound to its environment. Asynchronous jobs: track job status.
- L-05 Domain list: domains from AutoDNS with customer (derived from the owner contact), owner, expiry date, status.
- L-06 Rights: viewing with "Benutzen"; registering, creating contacts, customers and settings with "Verwalten" (or admin) — per route like Nextcloud-Status.
- L-07 Transfer, cancellation (Kündigung) and DNS zones are out of scope; the AutoDNS client keeps a generic request method so these fit in without restructuring.
- L-08 Patterns: Nextcloud-Status (261002-k67), Handelsware-Datev (settings tab), Design Mosaik (PageHeader, SettingsSection).
- L-09 Tests with a mocked API (injected fetch); the real check against the Demo system happens only after the user enters Demo credentials.
- L-10 UI texts German (formal Sie) and English; no tenant wording ("Mandant") in any UI text, changelog or guide.
- L-11 CHANGELOG entry under "Unveröffentlicht" in simple words like the existing entries.
- L-12 The module is usable after activation in the Marktplatz plus a Freigabe.
Claude's discretion (decided here, apply as written):
- D-A Identity: slug `domains`, name "Domains", version '1.0.0', category `domain-tools` (next to Domaincheck; admins can move it), description de "Domains bei AutoDNS registrieren, Kontakte und Kunden zuordnen" / en "Register domains with AutoDNS, assign contacts and customers", isSystem true. New ModuleIconId `earth` (lucide "earth" glyph: circle cx 12 cy 12 r 10 plus the paths `M21.54 15H17a2 2 0 0 0-2 2v4.54`, `M7 3.34V5a3 3 0 0 0 3 3a2 2 0 0 1 2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2c0-1.1.9-2 2-2h3.17`, `M11 21.95V18a2 2 0 0 0-2-2a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05`) so it differs from Domaincheck's globe.
- D-B Data model, one migration `20261008120000_domains_autodns`: enums `AutodnsEnvironment { DEMO LIVE }` and `DomainOrderStatus { DRAFT SUBMITTING SUBMITTED SUCCESS FAILED UNKNOWN CANCELED }`; tables `DomainsConfig` (singleton per tenantId), `DomainsCustomer`, `DomainsContactAssignment`, `DomainsOrder` (columns in Task 1). AutoDNS stays the source of truth for contacts and domains (read live, never mirrored); locally only what AutoDNS does not know (customer assignment) or what money safety needs (orders). AutoDNS contact ids and job ids are stored as decimal strings (opaque identifiers, no int32 overflow, no bigint JSON trouble); the API exposes contact ids as numbers.
- D-C AutoDNS client: base URL only from the constant map DEMO/LIVE (no free URL input, no SSRF surface), TLS verified, `redirect: 'error'` (credentials never follow a redirect), `undiciFetch` with injectable `fetchImpl`, 20 s timeout per call, process-wide limiter (one request start per 350 ms — the documented limit is 3 per second per IP), NO retry anywhere (also not for reads), response body capped at 5 MiB, envelope parsed as `status.code ?? status.resultCode`, failure when HTTP is not 2xx OR `status.type === 'ERROR'` OR any `messages[].status === 'ERROR'`; error texts only from `messages[].text` (each cut to 200 chars, at most 5) — never headers, never the password. Header `X-Domainrobot-Demo` is never sent; the environment is chosen by base URL only.
- D-D Credentials: separate columns per environment; save encrypts with `CryptoService.encrypt`; an empty or missing password field keeps the stored one (LDAP pattern); responses carry only `hasPassword`; a decrypt failure throws a loud InternalServerError ('Das gespeicherte AutoDNS-Passwort ließ sich nicht entschlüsseln. Bitte tragen Sie es in den Einstellungen neu ein.') and is logged — never treated as "no password". An environment counts as configured when user, password and context are all set. The Live context field is prefilled with 4 in the UI when empty; the Demo context has no default (A1 in the research).
- D-E Environments: new installations start on DEMO. Switching to LIVE needs a UI confirmation dialog AND `confirmLive: true` in the request (400 code `confirmLiveRequired` otherwise). Every contact assignment and every order carries its environment; all reads use the active environment. A permanent badge in the page header shows "Demo-System (Testbetrieb)", "Live-System – Registrierungen kosten Geld" or "AutoDNS nicht eingerichtet".
- D-F Error mapping: AutoDNS auth/permission failures map to HTTP 502 with code `autodnsAuth` (never 401/403 — the web treats 401 as an expired Tessera session); other AutoDNS failures 502 `autodnsError` with the joined message texts; timeout/network 504 `autodnsUnavailable`; not configured 409 `notConfigured`. The connection test always answers 200 with `{ ok, kind, message }`.
- D-G "Bestehende Kontakte einlesen" = the contact list is read live from AutoDNS (button "Aus AutoDNS neu einlesen" bypasses the cache); unassigned contacts appear as "Nicht zugeordnet" and managers assign one or many to a customer. AutoDNS contacts are not edited or deleted in this stage (owner changes can affect domains, research pitfall 9).
- D-H Customers: own table, name unique per organisation (409 `customerNameTaken`), at most one "Eigene Firma" (setting it clears the flag on the others), deleting a customer with assigned contacts → 409 `customerInUse`. No company name or nameserver is preset anywhere.
- D-I Lists: page size 100, at most 2000 entries per list with `truncated: true` beyond, in-memory cache of 60 s keyed by tenant + environment + config version (`DomainsConfig.updatedAt`), invalidated by contact creation and by a successful submit; `?refresh=1` bypasses it.
- D-J Domain list: `POST /domain/_search` with `keys[]=expire&keys[]=ownerc`; owner name from the (cached) contact list by owner id; customer = customer of the owner contact's assignment in the active environment, otherwise null ("Nicht zugeordnet"); status shown from `registryStatus` mapped to German labels with the raw value as fallback, plus "Kündigung vorgemerkt" when `cancelationStatus` is set.
- D-K Availability: input normalised (trim, lowercase, strip `http(s)://`, path and trailing dot), converted with `domainToASCII` from `node:url` (umlaut domains become punycode), pre-filtered with an anchored hostname pattern; `POST /domainstudio` with `searchToken` = first label and `sources.initial` = `{ tlds: [rest], services: ['WHOIS', 'PRICE'] }`, currency EUR; only the envelope whose `domain` equals the requested name counts; only WHOIS status `FREE` is orderable (everything else including ERROR/TIMEOUT is not); price = the 1-year entry (else the first), `null` when missing → UI shows "Preis nicht ermittelbar" in the summary.
- D-L Orders: one open order per (tenant, environment, domain) enforced by the nullable column `openKey` with `@@unique([tenantId, environment, openKey])` (Postgres lets NULLs repeat, Prisma can express it, no drift). `openKey` = domain name while the order is DRAFT, SUBMITTING, SUBMITTED, UNKNOWN or SUCCESS; set to null on FAILED and CANCELED. SUCCESS keeps the key on purpose: right after a registration a lagging WHOIS could still say FREE. A new draft for a domain with an existing DRAFT updates that draft (same id); any other open state → 409 `orderOpen`.
- D-M Submit protocol: `updateMany where { id, tenantId, status: DRAFT, environment: <active> }` → data `{ status: SUBMITTING, confirmedAt, confirmedByUserId, confirmedByUsername }`; count 0 → load the row → 404 when missing, 409 `environmentChanged` when its environment differs from the active one, else 409 `alreadySubmitted`. Count 1 → exactly one `POST /domain` (20 s timeout, no retry) → parsed success with job → SUBMITTED + jobId + jobStatus; parsed AutoDNS refusal (business/auth/http with envelope) → FAILED + errorText, openKey null; thrown error, timeout or unparseable body → UNKNOWN. Never back to DRAFT. A SUBMITTING row older than 120 s (process died mid-call) becomes UNKNOWN on the next refresh.
- D-N Job tracking is pull-based: `POST orders/refresh` checks up to 20 open orders (oldest `lastCheckedAt` first) whenever the Aufträge tab opens, on "Aktualisieren", and every 30 s while open orders exist and the page is visible. No cron job and no system-context read (the module needs no `forSystem` call and no `system_read_policy`); `refreshOrder` is the single entry point. Job mapping: SUCCESS → SUCCESS; FAILED/CANCELED → FAILED/CANCELED (openKey null, errorText from messages); RUNNING/WAIT/DEFERRED/NOT_SET → stays SUBMITTED with that jobStatus; SUPPORT → stays SUBMITTED, shown as "Rückfrage nötig". UNKNOWN reconciliation: `GET /domain/{name}` succeeds → SUCCESS; else `POST /job/_search` filtered by `object` = domain, newest job created after `confirmedAt` minus 5 min → SUBMITTED with that job; else stays UNKNOWN with `lastCheckedAt` set.
- D-O Registration form: period fixed 1 year; all four contacts required; defaults admin-c = owner, tech-c and zone-c = first contact of the "Eigene Firma" customer if one exists, else the owner; nameservers prefilled from the settings, 2 to 6 required; the request never asks AutoDNS to skip its WHOIS check (no query parameters on `POST /domain`).
- D-P Rights per route (all under `@Controller('modules/domains')`, class `@UseModule('domains')`): Benutzen = GET status, GET customers, GET contacts, GET domains, GET orders, POST orders/refresh (only syncs state from AutoDNS, changes nothing there). Verwalten (`@ModuleManage('domains')`, never with a role decorator) = GET settings, PUT settings, POST connection-test, POST customers, PUT customers/:id, DELETE customers/:id, POST contacts, POST contacts/assign, POST availability, POST orders, POST orders/:id/submit, POST orders/:id/cancel. Static routes are declared before every `:id` route.
Output: migration + models, API module (client, parsers, cache, three services, controller, seed), module page with six tabs, 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
@.planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/261008-dts-RESEARCH.md
Discovered facts the executor can rely on (verified during planning on 2026-10-08):
- Templates: `apps/api/src/nextcloud-status/{nextcloud-status.controller.ts, nextcloud-status.module.ts, nextcloud-status.seed.ts, nextcloud-status-fetch.ts}` (controller with `requireTenantId`, class `@UseModule`, handler `@ModuleManage`, seed via `seedModule`, injected `fetchImpl`), `apps/api/src/handelsware-datev/{handelsware-datev.controller.ts, handelsware-datev.service.ts}` (singleton config via `forTenant(...).<model>.findUnique({ where: { tenantId } })` + `upsert`, errors as `{ code, message }` objects), `apps/api/src/proxmox/proxmox-client.service.ts` (`undiciFetch` instead of global fetch, AbortController timeout, certificate error codes, short error details).
- `CryptoService` (`apps/api/src/crypto/crypto.service.ts`) is provided by the GLOBAL `CryptoModule` — inject it, do not import a module. `encrypt(plain)` → `iv:authTag:ciphertext`; `decrypt` throws on bad input. LDAP precedent for keep-if-empty and masking: `apps/api/src/ldap/ldap-config.service.ts` around `decryptBindPassword` and the update path.
- `PrismaService` is global; `forTenant` from `apps/api/src/prisma/prisma-tenant.extension.ts` wraps every model op in a one-element transaction that sets the tenant — `updateMany` through it returns `{ count }` and is atomic in Postgres (a concurrent second UPDATE re-checks the WHERE after the first commits). Never hold a transaction across an AutoDNS call. Never use `include:` or relation `select:` in this module (rls inventory).
- Global `ValidationPipe({ whitelist: true, transform: true })` in `apps/api/src/main.ts`; `class-validator` 0.15, `class-transformer`, `undici` 7.28.0 are already dependencies — no new packages.
- Guard: `ModuleGuard` needs the module activated for the tenant (also for admins); admins and MANAGE grants pass `@ModuleManage`. `apps/api/src/module-registry/module-manage-handlers.spec.ts` has helpers `expectManage(controller, name, slug)` and the USE-level pattern (see the `NextcloudStatusController` blocks).
- RLS gates: `apps/api/src/prisma/rls-coverage.spec.ts` needs ENABLE + FORCE + `tenant_isolation_policy` for each new table in the migration; `apps/api/src/prisma/rls-access-inventory.spec.ts` compares every (file, model) Prisma access against the Fundstellentabelle in `docs/mandantentrennung-zugriffsklassifikation.md` (also maintain the Bereichszeile, the Summenzeile and the Paarzählung paragraph — follow the `handelsware-datev` and `module-categories` rows, recount with the Gate-Schleife `for d in apps/api/src/*/`, never copy numbers). Planned pairs: `domains-settings.service.ts`/`domainsConfig` (Task 1), `domains-directory.service.ts`/`domainsCustomer` and `/domainsContactAssignment` (Task 2), `domains-orders.service.ts`/`domainsOrder` (Task 3), all `muss-mandantengebunden` / `gebunden`. No `forSystem` anywhere in this module.
- Migration convention: hand-written SQL with a German header comment (model `apps/api/prisma/migrations/20261002130000_handelsware_datev/migration.sql`; enum precedent `20261002140000_module_grant_level` uses `CREATE TYPE ... AS ENUM`). Latest existing migration: `20261003120000_module_categories`. Local DB has no host port: `IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1)`, then `DATABASE_URL="postgresql://tessera:tessera_dev@$IP:5432/tessera"` for `pnpm --filter @tessera/api exec prisma migrate deploy|status|diff`. The api container also runs migrate deploy on start.
- Web registration points: `apps/web/src/lib/module-loader.ts` (dynamic page import, ssr false), `apps/web/src/lib/module-identity.ts` (`ModuleIconId` union + ICONS map), `apps/web/src/components/modules/module-tile.tsx` (GLYPHS map keyed by ModuleIconId, inline SVG children), `apps/web/src/lib/stores/nav-store.ts` (`MODULE_TITLE_KEYS`), `apps/web/src/app/(portal)/modules/module-layouts.test.tsx` (it.each of slug + layout). Module route `/modules/domains` (own layout with `ModuleAccessGate`) and the sidebar route `/modules/<category>/domains` via the generic page and module-loader.
- UI building blocks: `PageHeader` (`@/components/layout/page-header`, props title/description/actions/moduleSlug), `TabBar` (`@/components/accounting/tab-bar`), `SettingsSection` (`@/components/control-center/settings-section`, card with title/description/actions/footer/flush; its `cc-section` styles are global in `apps/web/src/app/globals.css`), `useCanManageModule` (`@/lib/use-module-capability`, null while loading → treat as false). Status tokens: `bg-status-ok|warn|down|idle`, pill form `bg-status-warn/12 text-status-warn-fg` (literal class strings only). No shared confirm-dialog component exists — build the confirmation inline like `CloudForm.tsx` in nextcloud-status.
- i18n: new top-level namespace `domains` in `apps/web/src/messages/de.json` and `en.json` (free, verified). `apps/web/src/messages/umlaut-guard.spec.ts` rejects ae/oe/ue/ss tokens in de.json unless listed in `UMLAUT_ALLOWLIST` (`apps/web/src/messages/umlaut-dictionary.ts`) — write real umlauts, allowlist only legitimately correct tokens after running the test. There is no general de/en parity test; Task 3 verifies the `domains` keys with a node check.
- Local stack is running (api, db, web, mailhog); `admin` / `admin123` logs in at `http://localhost:3001/auth/login` (200 on 2026-10-08); `GET /modules/catalog` returns `{ id, slug, isActiveForTenant, ... }`; `POST /modules/<id>/activate` activates as admin; `GET /health` answers `{"status":"ok"}`. Rebuild with `docker compose up -d --build api` (plain `up` does not rebuild).
- AutoDNS facts (research, verified against the OpenAPI): envelope `{ status: { code, text, type }, stid, object: { type, value, summary }, messages: [{ code, text, status }], data: [...] }`; `GET /hello` tests login; `POST /contact/_search` and `POST /domain/_search` take `{ filters, view: { limit, offset }, orders }` and report the total in `object.summary`; `POST /contact` answers `data[0].id`; `POST /domain` is asynchronous and answers a job (`data[0].id`, fallback `object.value` when `object.type === 'job'`); `GET /job/{id}` status enum RUNNING, SUCCESS, FAILED, CANCELED, SUPPORT, DEFERRED, NOT_SET, WAIT (read `data[0].job.status ?? data[0].status`); DomainStudio WHOIS status at `data[i].services.whois.data.status`, price entries at `data[i].services.price.data.prices[]` (read `amount`/`currency` directly or under `price`). Wrong login: HTTP 401 with `messages[0].code` `EF00202`.
- Pitfall from STATE.md ("Tautologischer Test"): tests against an external system must assert the SHAPE and literal values of the outgoing call (method, exact URL, exact header set, exact JSON body written out in the test), never values rebuilt with the production helper. Example literal: user `api-user`, password `geheim` → `Authorization: Basic YXBpLXVzZXI6Z2VoZWlt`.
- Commits: German subject, conventional prefix `feat(domains):`, body ends with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`. Never push (the user bundles pushes). PLAN/SUMMARY/STATE are committed by the orchestrator, not by the executor. No deploy to the test server.
@apps/api/src/nextcloud-status/nextcloud-status.controller.ts
@apps/api/src/handelsware-datev/handelsware-datev.service.ts
@apps/api/src/proxmox/proxmox-client.service.ts
@apps/api/src/crypto/crypto.service.ts
@apps/api/prisma/migrations/20261002130000_handelsware_datev/migration.sql
@apps/web/src/app/(portal)/modules/handelsware-datev/page.tsx
@apps/web/src/app/(portal)/modules/handelsware-datev/components/SettingsTab.tsx
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer — a manager stores AutoDNS access and tests the connection (DB → encrypted settings → AutoDNS client → API → module page with Einstellungen)</name>
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261008120000_domains_autodns/migration.sql, apps/api/src/domains/autodns-client.ts, apps/api/src/domains/autodns-client.spec.ts, apps/api/src/domains/domains.types.ts, apps/api/src/domains/domains-settings.service.ts, apps/api/src/domains/domains-settings.service.spec.ts, apps/api/src/domains/dto/domains-settings.dto.ts, apps/api/src/domains/domains.controller.ts, apps/api/src/domains/domains.controller.spec.ts, apps/api/src/domains/domains.seed.ts, apps/api/src/domains/domains.module.ts, apps/api/src/app.module.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/domains-api.ts, apps/web/src/app/(portal)/modules/domains/layout.tsx, apps/web/src/app/(portal)/modules/domains/page.tsx, apps/web/src/app/(portal)/modules/domains/components/EnvironmentBadge.tsx, apps/web/src/app/(portal)/modules/domains/components/SettingsTab.tsx, apps/web/src/app/(portal)/modules/domains/domains-page.test.tsx, apps/web/src/app/(portal)/modules/module-layouts.test.tsx, apps/web/src/lib/module-loader.ts, apps/web/src/lib/module-identity.ts, apps/web/src/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, apps/web/src/messages/umlaut-dictionary.ts</files>
<precondition>The local stack (db, api, web) is running and `admin`/`admin123` logs in at http://localhost:3001/auth/login.</precondition>
<behavior>
- autodnsRequest (injected fetch, limiter with 0 ms spacing unless stated): DEMO targets exactly `https://api.demo.autodns.com/v1/hello`, LIVE exactly `https://api.autodns.com/v1/hello`; an environment value outside DEMO/LIVE throws before any fetch; GET sends exactly the headers Authorization `Basic YXBpLXVzZXI6Z2VoZWlt` (user api-user, password geheim), `X-Domainrobot-Context` '4', Accept 'application/json', User-Agent starting with 'Tessera/' — and no Content-Type; POST with body adds Content-Type 'application/json' and sends the JSON body; options carry `redirect: 'error'`; `keys: ['expire','ownerc']` appends `?keys[]=expire&keys[]=ownerc`; a path containing '..', '?' or '//' throws before fetch.
- Envelope: HTTP 200 + status.type SUCCESS → ok true with data, object (type/value/summary), statusCode; HTTP 200 + status.type ERROR → ok false kind 'business' with the message texts; `status.resultCode` is read when `code` is missing; HTTP 401 → kind 'auth', 403 → 'forbidden', 429 → 'rate-limit', other non-2xx with envelope → 'business', without envelope → 'http'; HTML or empty 200 body → 'invalid-response'; never-resolving fetch with a 20 ms timeout → 'timeout'; rejection with cause.code ENOTFOUND → 'network'; CERT_HAS_EXPIRED → 'tls'; body over the 5 MiB cap → 'invalid-response'; message texts cut to 200 chars, at most 5; JSON.stringify(result) never contains the password or 'Basic '; a failing fetch is called exactly once (no retry).
- AutodnsRateLimiter (fake clock): three scheduled calls start at t=0, ≥350 ms, ≥700 ms; a rejected task does not block the next one.
- DomainsSettingsService (mocked prisma via forTenant, mocked CryptoService with encrypt → 'enc(<plain>)'): getSettings without row → environment DEMO, demo/live { user null, hasPassword false, context null }, defaultNameServers [], configured { demo false, live false }; saveSettings with demoPassword 'geheim' stores demoEncryptedPassword 'enc(geheim)'; saving without password (or empty) keeps the stored encrypted value; only provided fields change (partial update); response and JSON.stringify(response) contain neither 'geheim' nor 'enc(' nor any key with 'ncrypted'; environment LIVE from DEMO without confirmLive → BadRequest code confirmLiveRequired, with confirmLive true → saved; defaultNameServers lowercased, trimmed, exactly one entry → BadRequest, 7 entries → BadRequest, invalid hostname → BadRequest; getStatus → { environment, configured (active env), demoConfigured, liveConfigured, defaultNameServers }; testConnection for an unconfigured environment → { ok false, kind 'not-configured' } without fetch; configured DEMO → exactly one GET to `https://api.demo.autodns.com/v1/hello` with the decrypted password, 200 SUCCESS → { ok true }, 401 → { ok false, kind 'auth', message mentions Benutzername, Passwort und Kontext }; decrypt throwing → InternalServerErrorException, not a silent "no password"; getActiveCredentials returns { environment, credentials, configVersion } or throws ConflictException code notConfigured.
- Controller metadata: class MODULE_SLUG_KEY 'domains' with ModuleGuard; getStatus has no MODULE_MANAGE_KEY; getSettings, saveSettings, testConnection have MODULE_MANAGE_KEY true and no ROLES_KEY.
- Web page test (mock `@/lib/domains-api`, `@/lib/use-module-capability`, next-intl like the nextcloud-status page test): manager sees the tab "Einstellungen" and the badge "Demo-System (Testbetrieb)"; status LIVE shows "Live-System – Registrierungen kosten Geld"; not configured shows "AutoDNS nicht eingerichtet" plus the setup hint; a non-manager does not see "Einstellungen"; SettingsTab: password inputs start empty with the placeholder for a stored password when hasPassword is true; the Live context input shows 4 when the stored value is null; choosing "Live-System" opens a confirmation and only the confirmed save sends `confirmLive: true`; "Verbindung testen" calls testConnection once per click, is disabled while running and shows the success or error text.
</behavior>
<action>
**Schema + migration (D-B, L-02).** In `apps/api/prisma/schema.prisma`, after the Nextcloud-Status models, add a German comment block (quick-261008-dts; AutoDNS is the source of truth; ids as strings per D-B; RLS like ProxmoxServer; no relation to Tenant) and: enum `AutodnsEnvironment { DEMO LIVE }`; enum `DomainOrderStatus { DRAFT SUBMITTING SUBMITTED SUCCESS FAILED UNKNOWN CANCELED }`; model `DomainsConfig` (`id` uuid, `tenantId String @unique`, `environment AutodnsEnvironment @default(DEMO)`, `demoUser String?`, `demoEncryptedPassword String?`, `demoContext Int?`, `liveUser String?`, `liveEncryptedPassword String?`, `liveContext Int?`, `defaultNameServers String[] @default([])`, createdAt, updatedAt @updatedAt, `@@index([tenantId])`); model `DomainsCustomer` (`id`, `tenantId`, `name`, `isOwnCompany Boolean @default(false)`, back-relation `assignments DomainsContactAssignment[]`, timestamps, `@@unique([tenantId, name])`, `@@index([tenantId])`); model `DomainsContactAssignment` (`id`, `tenantId`, `environment AutodnsEnvironment`, `autodnsContactId String`, `customerId String` with relation to DomainsCustomer `onDelete: Restrict`, timestamps, `@@unique([tenantId, environment, autodnsContactId])`, `@@index([tenantId])`, `@@index([customerId])`); model `DomainsOrder` (`id`, `tenantId`, `environment AutodnsEnvironment`, `domainName String`, `openKey String?`, `status DomainOrderStatus @default(DRAFT)`, `payload Json`, `jobId String?`, `jobStatus String?`, `errorText String?`, `createdByUserId String`, `confirmedByUserId String?`, `confirmedByUsername String?`, `confirmedAt DateTime?`, `lastCheckedAt DateTime?`, timestamps, `@@unique([tenantId, environment, openKey])`, `@@index([tenantId])`). To get the exact DDL Prisma expects (TEXT[] default, FK clause, index names), run `prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --script` against the local DB BEFORE writing the file and use that DDL as the body. Hand-write `apps/api/prisma/migrations/20261008120000_domains_autodns/migration.sql`: German header (purpose of the four tables; openKey rule from D-L; `tenant_isolation_policy` WITHOUT user dimension because these are organisation data; NO `system_read_policy` because no background job reads across tenants, D-N; rights via ALTER DEFAULT PRIVILEGES; switch-is-off note as in the handelsware header), both `CREATE TYPE ... AS ENUM`, the tables, indexes, FK, then per table ENABLE + FORCE ROW LEVEL SECURITY and `CREATE POLICY tenant_isolation_policy ... USING ("tenantId" = current_tenant_id())`. Run `pnpm --filter @tessera/api exec prisma generate`, apply locally via the container IP (`migrate deploy`), confirm `migrate status` is up to date and `migrate diff ... --exit-code` exits 0.
**AutoDNS client (D-C, L-01, L-07).** `apps/api/src/domains/autodns-client.ts`, framework-free: `AUTODNS_BASE_URLS` constant (DEMO/LIVE URLs from L-01, `as const`), `AutodnsCredentials { environment; user; password; context: number }`, `AutodnsFailureKind` ('auth' | 'forbidden' | 'rate-limit' | 'business' | 'http' | 'timeout' | 'network' | 'tls' | 'invalid-response'), result union `{ ok: true; httpStatus; statusCode; statusType; object; data: unknown[]; messages: string[]; stid }` / `{ ok: false; kind; httpStatus: number | null; statusCode; messages; stid }`. Export pure `parseAutodnsEnvelope(httpStatus, text)`, `buildAutodnsHeaders(credentials, hasBody)`, class `AutodnsRateLimiter` (constructor `minIntervalMs = 350`, injectable `now` and `sleep`; `schedule(task)` chains starts ≥ minIntervalMs apart; failures do not break the chain) with a module-level default instance, and `autodnsRequest(credentials, method: 'GET' | 'POST' | 'PUT', path, opts?: { body?, keys?, fetchImpl?, timeoutMs?, limiter? })` that NEVER throws for network/HTTP problems (only for programming errors: unknown environment, bad path). Path must start with '/', contain no '..', '?' or '//'; callers encode dynamic segments with encodeURIComponent. Use `undiciFetch` by default (comment why not global fetch, pattern proxmox-client.service.ts), `redirect: 'error'`, AbortController with `AUTODNS_TIMEOUT_MS = 20_000`, capped body reader `AUTODNS_MAX_BODY_BYTES = 5 * 1024 * 1024`, certificate codes → 'tls' (copy the set from proxmox-client.service.ts), User-Agent `Tessera/${process.env.APP_VERSION || 'dev'}`. German header comment: fixed hosts (no SSRF), Basic auth + context header (L-01), no retry ever and why (money: a repeated POST /domain could register twice; login: repeated wrong logins can lock the user), 3 requests per second per IP, HTTP 200 with status.type ERROR is a failure, never log or return headers. Keep the generic `autodnsRequest` so transfer/cancellation/zones (L-07) need no new transport. Spec `autodns-client.spec.ts` per `<behavior>` with literal URLs, headers and bodies.
**Settings service + DTO (D-D, D-E, D-F, L-02).** `apps/api/src/domains/domains.types.ts` for shared view types. `dto/domains-settings.dto.ts` `SaveDomainsSettingsDto`, every field optional: `environment` (IsIn DEMO/LIVE), `confirmLive` (IsBoolean), `demoUser`/`liveUser` (IsString, MaxLength 100), `demoPassword`/`livePassword` (IsString, MaxLength 200), `demoContext`/`liveContext` (IsInt, Min 1, Max 2147483647, nullable via ValidateIf), `defaultNameServers` (IsArray, ArrayMaxSize 6, each IsString MaxLength 253). `domains-settings.service.ts` (`@Injectable`, inject PrismaService and CryptoService; every method its own `const tenantPrisma = forTenant(this.prisma, tenantId)`; all access to `domainsConfig` only here): `getStatus`, `getSettings` (masked view per `<behavior>`), `saveSettings` (read current row, enforce confirmLive for a switch to LIVE, normalise and validate nameservers — 0 or 2..6, anchored hostname pattern, German messages 'Bitte geben Sie mindestens zwei Nameserver an.' / 'Höchstens sechs Nameserver sind möglich.' / 'Der Nameserver „{name}“ ist kein gültiger Rechnername.' — encrypt non-empty passwords, upsert only provided fields, return the masked view), `testConnection(tenantId, environment)` (single `autodnsRequest(GET '/hello')`, result `{ ok, kind?, message }` with German messages: success 'Verbindung erfolgreich. AutoDNS hat die Anmeldung bestätigt.', auth 'Anmeldung bei AutoDNS fehlgeschlagen. Bitte prüfen Sie Benutzername, Passwort und Kontext.', timeout/network/tls their own short German texts, business → 'AutoDNS meldet: <texts>'), `getActiveCredentials(tenantId)` → `{ environment, credentials, configVersion: updatedAt ms }` or ConflictException `{ code: 'notConfigured', message: 'AutoDNS ist für das gewählte System noch nicht eingerichtet. Bitte hinterlegen Sie den Zugang in den Einstellungen.' }`, plus a private `decryptPassword` that throws the loud error from D-D. Also export a small helper `autodnsFailureToHttp(result)` (in this file or domains.types.ts) that maps a failed result to the D-F exceptions with `{ code, message }` — used by Tasks 2 and 3. Spec per `<behavior>`.
**Controller + seed + module (L-06, L-12, D-A, D-P).** `domains.controller.ts`: `@Controller('modules/domains')`, class `@UseModule('domains')`, `requireTenantId` like NextcloudStatusController; handlers in this order: `@Get('status') getStatus`; `@Get('settings') @ModuleManage('domains') getSettings`; `@Put('settings') @ModuleManage('domains') saveSettings`; `@Post('connection-test') @ModuleManage('domains') testConnection` (body `{ environment }` validated by a tiny DTO with IsIn). German header comment: rights table of D-P, rule "static routes before any `:id` route" (Tasks 2 and 3 add `:id` routes at the end), never a role decorator on manage handlers. `domains.seed.ts` per D-A (pattern nextcloud-status.seed.ts). `domains.module.ts` imports ModuleRegistryModule, provides DomainsSettingsService, OnModuleInit seeds with try/catch and logs 'Domains module seeded in registry'. Register `DomainsModule` in `apps/api/src/app.module.ts` next to NextcloudStatusModule. `domains.controller.spec.ts` asserts the metadata from `<behavior>` (Reflect.getMetadata on prototype methods). In `apps/api/src/module-registry/module-manage-handlers.spec.ts` add a `DomainsController` block: it.each over the manage handlers with `expectManage(..., 'domains')` and a USE-level it.each (`getStatus`).
**RLS inventory doc.** Run `pnpm --filter @tessera/api exec vitest run rls-coverage rls-access-inventory`; add the Bereichszeile `domains`, update Summenzeile and Paarzählung, and add the Fundstellentabelle row `apps/api/src/domains/domains-settings.service.ts` / `domainsConfig` (`muss-mandantengebunden`, `gebunden`, German explanation: singleton per organisation, encrypted passwords, policy without user dimension, no system policy) in `docs/mandantentrennung-zugriffsklassifikation.md`, counted with the Gate-Schleife; both specs green.
**Web tracer (L-02, L-08, L-10, D-D, D-E).** `apps/web/src/lib/domains-api.ts` (pattern nextcloud-status-api.ts: `NEXT_PUBLIC_API_URL`, `credentials: 'include'`, `cache: 'no-store'` on GETs): class `DomainsRequestError(status, code, message)` built from the API `{ code, message }`; types `DomainsEnvironment`, `DomainsStatus`, `DomainsSettings`, `SaveDomainsSettingsInput`, `ConnectionTestResult`; functions `getDomainsStatus`, `getDomainsSettings`, `saveDomainsSettings`, `testDomainsConnection(environment)`. `layout.tsx` = ModuleAccessGate moduleSlug "domains" (copy handelsware-datev/layout.tsx). `components/EnvironmentBadge.tsx`: pill with literal classes per D-E (Demo `bg-status-warn/12 text-status-warn-fg`, Live `bg-status-down/12 text-status-down-fg`, not configured `bg-status-idle/12 text-status-idle-fg`). `page.tsx` ('use client'): `const canManage = useCanManageModule('domains') === true`; loads `getDomainsStatus` once (exposes a reload callback to children); `PageHeader moduleSlug="domains"` with title, description and the badge as `actions`; `TabBar` with typed tab ids (this task: only 'settings' for managers; Tasks 2/3 add 'domains', 'contacts', 'customers', 'register', 'orders'); when the active environment is not configured, a hint card ("AutoDNS ist noch nicht eingerichtet." + for managers a button "Zu den Einstellungen", for others "Bitte wenden Sie sich an einen Administrator oder an jemanden mit der Freigabestufe Verwalten."). `components/SettingsTab.tsx` built from `SettingsSection` cards, each card saving only its own fields (partial PUT): "System" (radio "Demo-System (Testbetrieb)" / "Live-System (kostenpflichtig)", explanation, switching to Live opens an inline confirmation "Ab jetzt laufen Registrierungen über das Live-System von AutoDNS und kosten Geld." with "Live-System verwenden" / "Abbrechen"; only the confirmed save sends `confirmLive: true`), "Zugang Demo-System" and "Zugang Live-System" (Benutzername, Passwort type password autoComplete new-password with placeholder "Gespeichert – leer lassen, um es beizubehalten" when hasPassword, Kontext as number input — Live prefilled 4 when null — hint "Verwenden Sie einen eigenen AutoDNS-Benutzer für Tessera ohne Zwei-Faktor-Anmeldung."; footer "Verbindung testen" + "Speichern"; the test button is disabled while running and while the card has unsaved changes, with hint "Bitte zuerst speichern"), "Standard-Nameserver" (2 to 6 inputs with add/remove, hint that the nameservers must already be set up, footer "Speichern"). After each save reload the status (badge). Registrations: module-loader.ts entry `domains`; module-identity.ts `earth` in the `ModuleIconId` union and `domains: 'earth'`; module-tile.tsx GLYPHS `earth` with the D-A glyph; nav-store.ts `domains: 'domains.title'`; module-layouts.test.tsx add `['domains', DomainsLayout]`. Messages: new top-level `domains` namespace in de.json (formal Sie, real umlauts) and en.json with identical keys (title "Domains", description, environment.*, tabs.*, notConfigured.*, settings.*, errors.request). Run the umlaut guard; allowlist only correct tokens if it fails. `domains-page.test.tsx` per `<behavior>`.
**Tracer run.** Biome-lint the touched files (`pnpm exec biome lint <files>` from the repo root; `biome check --write` only on new files). Rebuild the api (`docker compose up -d --build api`), wait until `curl -sf http://localhost:3001/health` answers, check `docker compose logs api` for 'Domains module seeded in registry', then run the `<verify>` command. Commit `feat(domains): Modul Domains mit AutoDNS-Zugang, Verbindungstest und Einstellungen` (attribution line). Do not push.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/domains rls-coverage rls-access-inventory module-manage-handlers && pnpm --filter @tessera/web exec vitest run modules/domains module-layouts src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && A=$(mktemp) && curl -sf -c "$A" -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}' http://localhost:3001/auth/login >/dev/null && MID=$(curl -sf -b "$A" http://localhost:3001/modules/catalog | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const m=JSON.parse(s).find(x=>x.slug==="domains");if(!m)process.exit(1);process.stdout.write(m.isActiveForTenant?"":m.id)})') && { [ -z "$MID" ] || curl -sf -b "$A" -X POST "http://localhost:3001/modules/$MID/activate" >/dev/null; } && curl -sf -b "$A" http://localhost:3001/modules/domains/status | grep -q '"environment"' && S=$(curl -sf -b "$A" http://localhost:3001/modules/domains/settings) && echo "$S" | grep -q '"hasPassword"' && ! echo "$S" | grep -qi 'ncrypted' && echo "tracer e2e ok"</automated>
<fails_when>non-zero exit and no "tracer e2e ok": a spec, tsc run, the login, the catalog lookup (module not seeded), the activation, GET status or GET settings failed, or the settings answer leaks an encrypted-password field</fails_when>
</verify>
<done>Migration applied locally without drift; client, settings service and controller specs green; module seeded and activatable; GET status and the masked GET settings answer through the real stack; the module page shows the environment badge and the Einstellungen tab with per-environment access, Live confirmation, nameservers and connection test; registrations (loader, icon, nav title, layouts test) done; RLS gates green; commit on main, not pushed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Customers, AutoDNS contacts (read in, create, assign) and the domain list, filterable and groupable by customer</name>
<files>apps/api/src/domains/autodns-parse.ts, apps/api/src/domains/autodns-parse.spec.ts, apps/api/src/domains/domains-cache.ts, apps/api/src/domains/domains-directory.service.ts, apps/api/src/domains/domains-directory.service.spec.ts, apps/api/src/domains/dto/domains-customer.dto.ts, apps/api/src/domains/dto/domains-contact.dto.ts, apps/api/src/domains/domains.types.ts, apps/api/src/domains/domains.controller.ts, apps/api/src/domains/domains.controller.spec.ts, apps/api/src/domains/domains.module.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/domains-api.ts, apps/web/src/components/domains/group-by-customer.ts, apps/web/src/components/domains/group-by-customer.test.ts, apps/web/src/app/(portal)/modules/domains/page.tsx, apps/web/src/app/(portal)/modules/domains/components/DomainsTab.tsx, apps/web/src/app/(portal)/modules/domains/components/ContactsTab.tsx, apps/web/src/app/(portal)/modules/domains/components/ContactForm.tsx, apps/web/src/app/(portal)/modules/domains/components/ContactForm.test.tsx, apps/web/src/app/(portal)/modules/domains/components/CustomersTab.tsx, apps/web/src/app/(portal)/modules/domains/domains-page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts</files>
<behavior>
- parseContacts: AutoDNS contact objects → { id (number), type, displayName (organization, else 'fname lname', else alias, else '#id'), fname, lname, organization, address (string[]), pcode, city, country, email, phone, alias }; id given as string '123' → 123; entries without a numeric id are skipped; parseDomains: → { name, expire (ISO string or null), registryStatus, cancelationStatus, ownerContactId (from ownerc.id or a bare number, else null) }.
- TtlCache (domains-cache.ts, injected clock): get after 59 s hits, after 61 s misses; set prunes expired entries; delete by prefix.
- listContacts (mocked autodnsRequest + prisma): first call POST /contact/_search with body exactly { filters: [], view: { limit: 100, offset: 0 }, orders: [{ key: 'lname', type: 'ASC' }] }; object.summary 250 → three calls with offsets 0, 100, 200; summary 5000 → stops at 2000 entries and truncated true; each contact carries customerId/customerName from the assignment of the ACTIVE environment only (an assignment of the other environment with the same contact id is ignored); second call within 60 s does not call AutoDNS; refresh true does; config version change misses the cache; AutoDNS auth failure → 502 code autodnsAuth; not configured → 409 notConfigured.
- createContact: PERSON dto → exactly one POST /contact with body exactly { type: 'PERSON', fname: 'Erika', lname: 'Muster', address: ['Musterstraße 1'], pcode: '12345', city: 'Berlin', country: 'DE', email: 'erika@example.com', phone: '+49 30 123456' } (no organization key); ORG adds organization and requires it (BadRequest without); response data[0].id 4711 → returns { id: 4711, ... }; with customerId → creates the assignment (environment active, autodnsContactId '4711'); unknown customerId → NotFoundException BEFORE the AutoDNS call; the contact cache of the tenant/environment is cleared.
- assignContacts: { contactIds: [1, 2, 2], customerId } → upsert per distinct id in the active environment; customerId null → deleteMany of those ids in the active environment; foreign customerId → NotFoundException; more than 500 ids → BadRequest.
- Customers: create trims the name, duplicate → 409 customerNameTaken; isOwnCompany true clears the flag on all other customers of the tenant first; update/delete of a foreign id → NotFoundException (where id + tenantId); delete with assignments → 409 customerInUse; listCustomers returns { id, name, isOwnCompany, contactCount (assignments in the active environment) } ordered by name.
- listDomains: POST /domain/_search?keys[]=expire&keys[]=ownerc with body { filters: [], view: { limit: 100, offset: 0 }, orders: [{ key: 'name', type: 'ASC' }] }; owner name from the contact list; customer from the owner's assignment (active environment), null when unassigned or no owner; truncated flag like contacts.
- Controller: getStatus, listCustomers, listContacts, listDomains have no MODULE_MANAGE_KEY; createCustomer, updateCustomer, deleteCustomer, createContact, assignContacts have MODULE_MANAGE_KEY true and no ROLES_KEY; every handler whose path contains ':id' is declared after all static handlers (index check on Object.getOwnPropertyNames of the prototype).
- groupByCustomer (web): groups by customerName with German collation, "Nicht zugeordnet" group last; the own-company customer group first; filter value 'all' / '<customerId>' / 'unassigned'; text search case-insensitive over the given fields; input not mutated.
- ContactForm: PERSON needs Vorname, Nachname, Straße, PLZ, Ort, Land, E-Mail, Telefon; ORG additionally Organisation; phone must start with '+' (hint "Internationale Schreibweise, z. B. +49 30 123456"); invalid e-mail blocks submit; submit calls createContact once with the trimmed values and the chosen customer; API error text is shown in the form; the submit button is disabled while saving.
- Page: a USE user sees Domains, Kontakte, Kunden but not Einstellungen and no "Neuer Kontakt", no assignment controls, no customer edit buttons; a manager sees all of them; Domains tab with a mocked list renders groups per customer with "Nicht zugeordnet" last, the expiry date as dd.mm.yyyy and the status label; choosing a customer in the filter hides the other groups.
</behavior>
<action>
**Parsers and cache (D-G, D-I, D-J).** `autodns-parse.ts` (pure, no Nest): `parseContacts(data)`, `parseDomains(data)` defensive as in `<behavior>` (unknown fields ignored, strings capped at 200 chars). `domains-cache.ts`: small `TtlCache<T>` (ttl ms, injectable `now`, `get`, `set`, `deleteByPrefix`), German comment why in-memory and why the key contains the config version (D-I). Specs per `<behavior>`.
**Directory service + DTOs (L-03, L-05, D-G, D-H, D-I, D-J).** `dto/domains-customer.dto.ts`: `DomainsCustomerDto { name (IsString, IsNotEmpty, MaxLength 120); isOwnCompany? (IsBoolean) }`. `dto/domains-contact.dto.ts`: `CreateDomainsContactDto { type (IsIn PERSON/ORG); organization? (MaxLength 120); fname, lname (IsNotEmpty, MaxLength 80); street (IsArray, ArrayMinSize 1, ArrayMaxSize 3, each IsString IsNotEmpty MaxLength 100); pcode (MaxLength 20); city (MaxLength 80); country (Matches /^[A-Z]{2}$/); email (IsEmail, MaxLength 200); phone (Matches /^\+[0-9][0-9 .\-\/]{5,30}$/); customerId? (IsUUID) }` and `AssignDomainsContactsDto { contactIds (IsArray, ArrayMinSize 1, ArrayMaxSize 500, each IsInt Min 1); customerId (IsUUID or null via ValidateIf) }`. `domains-directory.service.ts` (inject PrismaService and DomainsSettingsService; all access to `domainsCustomer` and `domainsContactAssignment` only here; each method its own `forTenant` client with `where` including tenantId; no include/relation select): `listCustomers`, `createCustomer`, `updateCustomer`, `deleteCustomer` (P2002 → 409, assignments → 409, foreign id → 404), `listContacts(tenantId, { refresh })` → `{ environment, contacts, truncated, fetchedAt }`, `createContact(tenantId, dto)` (build the exact AutoDNS body from `<behavior>`; send phone trimmed with inner whitespace collapsed; never send a customer field to AutoDNS), `assignContacts(tenantId, dto)`, `listDomains(tenantId, { refresh })` → `{ environment, domains: [{ name, expire, status, cancelationPending, ownerContactId, ownerName, customerId, customerName }], truncated, fetchedAt }`, and a public `findContactsByIds(tenantId, ids)` (used by Task 3 for the summary). Paging helper loops `view.offset` in steps of 100 until `object.summary` or 2000 entries are reached — calls run sequentially through the client's limiter, never in parallel. AutoDNS failures go through `autodnsFailureToHttp` (D-F). Spec per `<behavior>` with mocked `autodnsRequest` (vi.mock of `./autodns-client` keeping `parseAutodnsEnvelope` real is fine) and literal request bodies.
**Controller routes (L-06, D-P).** Add, in this order after the Task 1 handlers and before any `:id` route: `@Get('customers') listCustomers`; `@Post('customers') @ModuleManage('domains') createCustomer`; `@Get('contacts') listContacts` (query `refresh`, '1' or 'true' → true); `@Post('contacts') @ModuleManage('domains') createContact`; `@Post('contacts/assign') @ModuleManage('domains') assignContacts`; `@Get('domains') listDomains` (query refresh); then at the end `@Put('customers/:id') @ModuleManage('domains') updateCustomer` and `@Delete('customers/:id') @ModuleManage('domains') deleteCustomer` (ParseUUIDPipe on `:id`). Provide DomainsDirectoryService in `domains.module.ts`. Extend `domains.controller.spec.ts` (metadata + declaration order) and the `DomainsController` block in `module-manage-handlers.spec.ts`.
**RLS doc.** Add the Fundstellentabelle rows `domains-directory.service.ts` / `domainsCustomer` and / `domainsContactAssignment` (`muss-mandantengebunden`, `gebunden`, German explanation incl. environment in the assignment key), update the `domains` Bereichszeile, Summenzeile and Paarzählung via the Gate-Schleife; rls specs green.
**Web (L-03, L-05, L-06, L-08, D-G, D-H).** `domains-api.ts`: types `DomainsCustomer`, `DomainsContact`, `DomainsDomain`, list result types with `truncated`, functions `listCustomers`, `createCustomer`, `updateCustomer`, `deleteCustomer`, `listContacts({ refresh })`, `createContact`, `assignContacts`, `listDomains({ refresh })`. `apps/web/src/components/domains/group-by-customer.ts`: generic pure helpers `filterByCustomer(items, filter)`, `groupByCustomer(items, customers)` and `matchesText(item, query, fields)` per `<behavior>` (literal "unassigned" key, label from messages). `page.tsx`: tabs 'domains' (default, everyone), 'contacts' (everyone), 'customers' (everyone), 'settings' (managers); customers are loaded once in the page and passed down (reload after changes). `DomainsTab.tsx`: toolbar with search field ("Domain suchen"), customer filter select (Alle Kunden / each customer / Nicht zugeordnet), toggle "Nach Kunde gruppieren" (default on), button "Aus AutoDNS neu laden"; table inside `SettingsSection flush` per group: Domain, Kunde, Inhaber, Ablaufdatum (Intl.DateTimeFormat of the active locale, dd.mm.yyyy in German), Status (label map ACTIVE → "Aktiv", PENDING → "In Bearbeitung", HOLD → "Gesperrt (Registry)", LOCK → "Gesperrt", other → raw value; plus "Kündigung vorgemerkt"); empty state, loading state, truncated hint "Es werden die ersten 2000 Einträge angezeigt.", error from the API message. `ContactsTab.tsx`: same toolbar ("Aus AutoDNS neu einlesen" button for everyone, with the short explanation that existing AutoDNS contacts appear here automatically and can be assigned to a customer); columns Name, Organisation, Ort, E-Mail, Kunde; managers get row checkboxes plus a bar "Ausgewählte zuordnen: [Kunde ▾ incl. „Zuordnung entfernen“] Zuordnen" and the button "Neuer Kontakt" opening `ContactForm.tsx` (Typ radio Person/Organisation, Organisation, Vorname, Nachname, Straße und Hausnummer + optional second line, PLZ, Ort, Land select built from a constant ISO list (DACH, all EU countries, GB, NO, US) labelled via `Intl.DisplayNames` of the locale, default DE, Telefon, E-Mail, Kunde (optional select); client validation per `<behavior>`; "Speichern" / "Abbrechen"). `CustomersTab.tsx`: list with name, badge "Eigene Firma", number of assigned contacts; managers: inline "Kunde anlegen" (name + checkbox "Das ist unsere eigene Firma"), rename/flag edit, delete with inline confirmation and the 409 text shown. All write controls only when `canManage`. Messages for all new texts in `domains.*` de + en, formal Sie, real umlauts, no tenant wording; umlaut guard green. Tests per `<behavior>`: `group-by-customer.test.ts`, `ContactForm.test.tsx`, new cases in `domains-page.test.tsx`.
Biome-lint touched files, commit `feat(domains): Kunden, Kontakte aus AutoDNS mit Zuordnung und Domainliste` (attribution line). Do not push.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/domains rls-coverage rls-access-inventory module-manage-handlers && pnpm --filter @tessera/web exec vitest run modules/domains components/domains src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test -z "$(grep -nE '^\s*@Roles\(' apps/api/src/domains/domains.controller.ts)"</automated>
<fails_when>a domains/rls/manage spec or web test fails, a tsc run fails, or the controller carries a role decorator</fails_when>
</verify>
<done>Customers can be created, flagged as eigene Firma, renamed and deleted (blocked while in use); the contact list reads all AutoDNS contacts of the active environment with paging, cache and on-demand re-read, shows the customer per contact and lets managers create contacts and assign one or many to a customer; the domain list shows customer (via owner), owner, expiry and status; both lists filter and group by customer; all write routes behind ModuleManage with order and metadata specs green; RLS doc updated; commit on main, not pushed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Register a domain without any chance of a double order, track the AutoDNS job, then changelog, guides, full gates and local rebuild</name>
<files>apps/api/src/domains/domain-name.ts, apps/api/src/domains/domain-name.spec.ts, apps/api/src/domains/autodns-parse.ts, apps/api/src/domains/autodns-parse.spec.ts, apps/api/src/domains/domains-orders.service.ts, apps/api/src/domains/domains-orders.service.spec.ts, apps/api/src/domains/dto/domains-order.dto.ts, apps/api/src/domains/domains.types.ts, apps/api/src/domains/domains.controller.ts, apps/api/src/domains/domains.controller.spec.ts, apps/api/src/domains/domains.module.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/domains-api.ts, apps/web/src/components/domains/order-status.ts, apps/web/src/app/(portal)/modules/domains/page.tsx, apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx, apps/web/src/app/(portal)/modules/domains/components/RegisterTab.test.tsx, apps/web/src/app/(portal)/modules/domains/components/OrdersTab.tsx, apps/web/src/app/(portal)/modules/domains/components/OrdersTab.test.tsx, apps/web/src/app/(portal)/modules/domains/domains-page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md</files>
<behavior>
- normalizeDomainName: ' https://Beispiel.DE/pfad ' → 'beispiel.de'; 'beispiel.de.' → 'beispiel.de'; 'müller.de' → 'xn--mller-kva.de' (unicode form kept for display); 'beispiel' (no dot), 'bei spiel.de', '-a.de', a label over 63 chars, '' → null; splitDomain('beispiel.co.uk') → { label: 'beispiel', tld: 'co.uk' }.
- checkAvailability: invalid name → BadRequest code invalidDomain without fetch; exactly one POST /domainstudio with body exactly { searchToken: 'beispiel', currency: 'EUR', sources: { initial: { tlds: ['de'], services: ['WHOIS', 'PRICE'] } } }; envelope for beispiel.de with whois FREE and a 1-year price 4.9 EUR → { domain: 'beispiel.de', available: true, whoisStatus: 'FREE', price: { amount: 4.9, currency: 'EUR' } }; ASSIGNED → available false; ERROR/TIMEOUT → available false with that status; only an envelope for another domain → available false, status 'NO_RESULT'; no price entry → price null.
- createOrder (draft): availability is re-checked server-side and must be FREE (else 409 notAvailable, no row); nameServers fewer than 2 or more than 6 or invalid → BadRequest; missing contact id → BadRequest; creates DRAFT with environment = active, openKey = domain, payload { ownerContactId, adminContactId, techContactId, zoneContactId, nameServers, periodYears: 1, price, availabilityCheckedAt }, createdByUserId; returns the summary incl. contact display names (findContactsByIds); an existing DRAFT for the same domain/environment is updated (same id); an existing SUBMITTING/SUBMITTED/UNKNOWN/SUCCESS → 409 orderOpen; P2002 race → 409 orderOpen.
- submitOrder (in-memory prisma mock whose updateMany really flips status only when the where still matches): first call → updateMany where contains id, tenantId, status 'DRAFT', environment 'DEMO' and data status 'SUBMITTING' with confirmedByUserId/Username/At; exactly ONE POST to `https://api.demo.autodns.com/v1/domain` — URL without any query string — with body exactly { name: 'beispiel.de', period: { unit: 'YEAR', period: 1 }, ownerc: { id: 11 }, adminc: { id: 11 }, techc: { id: 22 }, zonec: { id: 22 }, nameServers: [{ name: 'ns1.example.com' }, { name: 'ns2.example.com' }] }; job data[0] { id: 987, status: 'RUNNING' } → SUBMITTED, jobId '987', jobStatus 'RUNNING'; Promise.all of two submits → one result, one ConflictException code alreadySubmitted, the POST happened exactly once; environment switched to LIVE after the draft → 409 environmentChanged and no fetch; already SUBMITTED → 409 alreadySubmitted; foreign id → 404.
- submit outcomes: never-resolving fetch (20 ms timeout) → UNKNOWN, fetch called once; rejection ECONNRESET → UNKNOWN; HTTP 200 with HTML → UNKNOWN; HTTP 200 + status.type ERROR with message 'Domain not available' → FAILED, errorText contains it, openKey null; HTTP 401 → FAILED with the login text, openKey null; success without any job id → SUBMITTED with jobId null.
- refreshOpenOrders: SUBMITTED with jobId → GET /job/987: SUCCESS → SUCCESS (openKey kept), cache invalidated; FAILED → FAILED + errorText + openKey null; RUNNING → stays SUBMITTED, jobStatus RUNNING, lastCheckedAt set; SUPPORT → stays SUBMITTED, jobStatus SUPPORT; SUBMITTING with confirmedAt 3 min ago → UNKNOWN, with confirmedAt 30 s ago → unchanged; UNKNOWN: GET /domain/beispiel.de success → SUCCESS; not found, job search returns a job for beispiel.de created after confirmedAt → SUBMITTED with that job id; nothing found → stays UNKNOWN with lastCheckedAt set; at most 20 orders per call, oldest lastCheckedAt first; an AutoDNS failure for one order does not stop the others.
- cancelOrder: DRAFT → CANCELED, openKey null; UNKNOWN with lastCheckedAt set → CANCELED; UNKNOWN never checked → 409 checkFirst; any other status → 409 notCancelable.
- listOrders: newest first, at most 200, view { id, environment, domainName, domainNameUnicode, status, jobStatus, errorText, confirmedByUsername, confirmedAt, createdAt, lastCheckedAt, price }.
- Controller: checkAvailability, createOrder, submitOrder, cancelOrder have MODULE_MANAGE_KEY true and no ROLES_KEY; listOrders and refreshOrders have none; 'orders/refresh' is declared before every ':id' handler.
- RegisterTab: "Verfügbarkeit prüfen" shows "frei" with price or the not-available text; contact selects only after FREE, defaults admin = owner, tech/zone = first contact of the Eigene-Firma customer else owner; nameservers prefilled from status.defaultNameServers; "Zusammenfassung anzeigen" calls createOrder; the summary shows environment badge, domain, Laufzeit 1 Jahr, price or "Preis nicht ermittelbar", the four contacts and the nameservers; "Jetzt verbindlich registrieren" is disabled until the checkbox is ticked; two fast clicks call submitOrder exactly once (ref guard) and the button shows "Wird übermittelt …"; result SUBMITTED shows the in-progress text, FAILED the error, UNKNOWN the "Ergebnis ungeklärt" text with the advice not to order again; "Abbrechen" calls cancelOrder.
- OrdersTab: refreshOrders is called on mount; rows show domain, Demo/Live, status label (Entwurf, Wird übermittelt, In Bearbeitung, Rückfrage nötig, Registriert, Fehlgeschlagen, Ergebnis ungeklärt, Verworfen), confirmed by/at and error text; with an open order a 30 s interval refreshes (fake timers) and stops when none are open; "Verwerfen" appears for managers only on DRAFT and on UNKNOWN with lastCheckedAt, asks for confirmation (UNKNOWN text: only discard after checking in AutoDNS that the domain was not ordered) and then calls cancelOrder.
</behavior>
<action>
**Domain names and parsers (D-K, D-N).** `domain-name.ts`: `normalizeDomainName(raw)` → `{ ascii, unicode } | null` using `domainToASCII`/`domainToUnicode` from `node:url` and an anchored hostname pattern (labels 1–63 chars, letters/digits/hyphen, no leading/trailing hyphen, at least two labels, total ≤ 253), `splitDomain(ascii)`. Extend `autodns-parse.ts` with `parseDomainStudio(data, wantedAscii)`, `extractJobFromSubmit(result)` (`data[0].id`/`data[0].status`, fallback `object.value` when `object.type === 'job'`), `parseJob(data)` (`data[0].job ?? data[0]` → `{ id, status, subStatus, messages }`). Specs per `<behavior>`.
**Orders service + DTOs (L-04, D-K, D-L, D-M, D-N, D-O).** `dto/domains-order.dto.ts`: `CheckAvailabilityDto { domain (IsString, IsNotEmpty, MaxLength 300) }`, `CreateDomainsOrderDto { domain; ownerContactId, adminContactId, techContactId, zoneContactId (IsInt, Min 1); nameServers (IsArray, ArrayMinSize 2, ArrayMaxSize 6, each IsString MaxLength 253) }`. `domains-orders.service.ts` (inject PrismaService, DomainsSettingsService, DomainsDirectoryService; all `domainsOrder` access only here, each method its own `forTenant` client, `where` always with tenantId): `checkAvailability`, `createOrder(tenantId, userId, dto)`, `submitOrder(tenantId, user, id)` implementing D-M exactly — the claim via `updateMany` with `status: 'DRAFT'` and the active environment in the `where`, the count check BEFORE any network call, exactly one `autodnsRequest` POST '/domain' without `keys` and without query parameters, outcome mapping per D-M, no loop and no retry around the call (German comment block above the method: why count === 1, why UNKNOWN instead of DRAFT, why no retry, why the environment is part of the claim — cite L-04 and research pitfalls 1–3), `refreshOpenOrders(tenantId)` and `refreshOrder` per D-N, `cancelOrder` per `<behavior>`, `listOrders`. Error objects `{ code, message }` with German messages: notAvailable 'Die Domain ist nicht frei und kann nicht registriert werden.', orderOpen 'Für diese Domain gibt es bereits einen offenen Auftrag. Bitte sehen Sie unter „Aufträge“ nach.', alreadySubmitted 'Dieser Auftrag wurde bereits abgeschickt.', environmentChanged 'Das System wurde inzwischen gewechselt. Bitte prüfen Sie die Verfügbarkeit erneut.', checkFirst 'Bitte aktualisieren Sie den Auftrag zuerst, damit Tessera bei AutoDNS nachsehen kann.', notCancelable 'Dieser Auftrag kann nicht mehr verworfen werden.'. After SUCCESS and after a successful submit clear the tenant's domain/contact cache entries (directory exposes `invalidate(tenantId)`). Spec per `<behavior>` — the concurrency case uses an in-memory order row whose mocked `updateMany` evaluates the where against the current row synchronously, so the test proves the count gate, not just the call.
**Controller routes (L-06, D-P).** Add before the `:id` block: `@Post('availability') @ModuleManage('domains') checkAvailability`; `@Get('orders') listOrders`; `@Post('orders') @ModuleManage('domains') createOrder`; `@Post('orders/refresh') refreshOrders`; and at the end, after the customers `:id` handlers: `@Post('orders/:id/submit') @ModuleManage('domains') submitOrder` (passes `user.id` and `user.username` from `@CurrentUser`) and `@Post('orders/:id/cancel') @ModuleManage('domains') cancelOrder` (ParseUUIDPipe). Provide DomainsOrdersService in the module. Extend the controller spec (metadata + order) and the `DomainsController` block in `module-manage-handlers.spec.ts`. Add the Fundstellentabelle row `domains-orders.service.ts` / `domainsOrder` (German: claim via updateMany count gate, openKey uniqueness, audit columns) and update Bereichszeile, Summenzeile, Paarzählung with the Gate-Schleife.
**Web (L-04, L-06, D-E, D-N, D-O).** `domains-api.ts`: types `AvailabilityResult`, `DomainsOrder`, `OrderSummary`; `checkAvailability`, `createOrder`, `submitOrder`, `cancelOrder`, `listOrders`, `refreshOrders`. `apps/web/src/components/domains/order-status.ts`: literal class map and label key per status (SUBMITTED with jobStatus SUPPORT → "Rückfrage nötig"; SUCCESS → status-ok; FAILED → status-down; UNKNOWN → status-warn; DRAFT/CANCELED → status-idle) and `isOpen(order)`. `page.tsx`: add 'register' (managers, placed after Kunden) and 'orders' (everyone) tabs; final tab order Domains, Kontakte, Kunden, Registrieren, Aufträge, Einstellungen. `RegisterTab.tsx` per `<behavior>` and D-O: step 1 domain field + "Verfügbarkeit prüfen"; step 2 four contact selects grouped by customer (`optgroup`), nameserver inputs (2–6, add/remove), "Zusammenfassung anzeigen"; step 3 summary in a `SettingsSection` with the environment badge, checkbox text Live "Ich bestätige die verbindliche und kostenpflichtige Registrierung bei AutoDNS." / Demo "Ich bestätige die Registrierung im Demo-System von AutoDNS (Testbetrieb).", primary button "Jetzt verbindlich registrieren" (disabled until ticked; a `useRef` flag blocks a second call even before re-render), "Abbrechen"; result panel with link/button to the Aufträge tab. `OrdersTab.tsx` per `<behavior>` (refresh on mount, "Aktualisieren" button, 30 s interval only while open orders exist and `document.visibilityState === 'visible'`, cleared on unmount). Messages in `domains.*` de + en; umlaut guard green. Tests: `RegisterTab.test.tsx`, `OrdersTab.test.tsx`, page tab-visibility cases for Registrieren/Aufträge in `domains-page.test.tsx`.
**Changelog + guides (L-11, L-10).** `CHANGELOG.md` under "## Unveröffentlicht" add "### Neu" above the existing "### Geändert" with one user-facing German bullet in simple words: new module „Domains“ (group Domains), activation in the Marktplatz plus Freigabe; connection to AutoDNS with Demo and Live system, own access per system, password stored encrypted, connection test; Kontakte from AutoDNS with Kunden-Zuordnung (eigene Firma as a customer), new contacts via form, filter/group by customer; Domainliste with customer, owner, expiry, status; Registrieren with availability check, contact and nameserver choice, summary and the button „Jetzt verbindlich registrieren“, protection against double orders, status in „Aufträge“; who may do what (Benutzen sees, Verwalten registers/creates/sets up). `docs/anleitung-anwender.md`: section "### Domains" after "### Domaincheck" plus its entry in the table of contents (tabs, filter/grouping, how to register, what the order states mean, what "Ergebnis ungeklärt" means and why not to order again). `docs/anleitung-administration.md`: subsection "### Domains: AutoDNS anbinden" after "### Nextcloud-Status: Clouds eintragen" (create a dedicated AutoDNS API user without two-factor login, context per system — Live usually 4, Demo as stated by InterNetX —, start with the Demo system, connection test once per click because repeated wrong logins can lock the user, default nameservers must already be set up, outbound access from the api container to api.autodns.com and api.demo.autodns.com, AutoDNS allows three requests per second, Verwalten rights). No tenant or licensing wording.
**Final gates.** Run the full `pnpm --filter @tessera/api test` and `pnpm --filter @tessera/web test`, both tsc, biome lint on all files touched by the three tasks. Rebuild `docker compose up -d --build api web`, wait for `/health`, check `docker compose logs api` for 'Domains module seeded in registry' and the mapped `/modules/domains/...` routes (orders/refresh before orders/:id/submit), no migration errors. Commit `feat(domains): Domain registrieren mit Schutz vor Doppelbestellung, Aufträge, Changelog und Anleitung` (attribution line). Do not push.
</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 && test -z "$(grep -nE '^\s*@Roles\(' apps/api/src/domains/domains.controller.ts)" && node -e 'const de=require("./apps/web/src/messages/de.json"),en=require("./apps/web/src/messages/en.json");const w=(o,p,r)=>{for(const[k,v]of Object.entries(o||{})){const q=p+"."+k;if(v&&typeof v==="object")w(v,q,r);else r[q]=v}return r};const a=w(de.domains,"domains",{}),b=w(en.domains,"domains",{});if(Object.keys(a).length<40||Object.keys(a).sort().join()!==Object.keys(b).sort().join()){console.error("key mismatch");process.exit(1)}for(const v of [...Object.values(a),...Object.values(b)])if(/mandant|tenant|lizenz|licens/i.test(String(v))){console.error("bad text",v);process.exit(1)}' && grep -q "AutoDNS" CHANGELOG.md && grep -q "^### Domains" docs/anleitung-anwender.md && grep -q "^### Domains: AutoDNS anbinden" docs/anleitung-administration.md && docker compose ps --status running --services | grep -qx api && docker compose ps --status running --services | grep -qx web && docker compose logs api 2>&1 | grep -q "Domains module seeded in registry" && A=$(mktemp) && curl -sf -c "$A" -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}' http://localhost:3001/auth/login >/dev/null && curl -sf -b "$A" http://localhost:3001/modules/domains/customers | grep -q '^\[' && curl -sf -b "$A" http://localhost:3001/modules/domains/orders | grep -q '^\[' && echo "final gates ok"</automated>
<fails_when>any api or web test, tsc, the role-decorator gate, the de/en key parity or wording check, the CHANGELOG/guide greps, the running-container checks, the seed log line, or the customers/orders calls through the rebuilt stack fail</fails_when>
</verify>
<done>Availability check, draft, single-shot submit with atomic claim, UNKNOWN handling, job tracking and reconciliation work with the specs green (including the parallel double-submit case with exactly one POST); Registrieren and Aufträge tabs behave as specified for managers and Benutzen users; CHANGELOG and both guides describe the module; full api + web suites, tsc and biome green; api and web rebuilt and running with the module seeded; commit on main, not pushed.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser → API (`/modules/domains/*`) | untrusted caller; tenant, user and role only from the validated session; rights by ModuleGuard |
| API → AutoDNS (`api.autodns.com`, `api.demo.autodns.com`) | outbound HTTPS with stored credentials; responses untrusted; POST /domain spends money |
| DB at rest (`DomainsConfig`) | AutoDNS passwords stored there |
| AutoDNS response → browser | message texts and contact data rendered in the UI |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-dts-01 | Information Disclosure | AutoDNS password (settings service, responses, logs) | high | mitigate | AES-256-GCM via CryptoService per environment column; responses carry only `hasPassword`; client result and errors never contain headers or the password (spec asserts via JSON.stringify); request log records only method/path/status; decrypt failure is loud, never "no password" |
| T-dts-02 | Elevation of Privilege | settings, connection test, customer/contact writes, availability, order create/submit/cancel | high | mitigate | `@ModuleManage('domains')` on each, no role decorator; controller spec + module-manage-handlers spec; verify greps for role decorators |
| T-dts-03 | Tampering / Repudiation (financial) | domain registration | critical | mitigate | atomic DRAFT→SUBMITTING claim with count gate before the network call; `openKey` unique per tenant/environment/domain; exactly one POST, no retry, UNKNOWN on unclear outcome, reconciliation before discard; explicit checkbox + button; audit columns confirmedByUserId/Username/At; parallel double-submit spec |
| T-dts-04 | Spoofing / SSRF | AutoDNS client | medium | mitigate | base URL only from the fixed DEMO/LIVE map, TLS verified, `redirect: 'error'`, path validation, dynamic segments encoded |
| T-dts-05 | Information Disclosure | cross-tenant rows (config, customers, assignments, orders) | high | mitigate | forTenant on every access, `where` with tenantId, 404 for foreign ids, tenant_isolation_policy on all four tables, rls-coverage + rls-access-inventory |
| T-dts-06 | Denial of Service | AutoDNS rate limit / account lock | medium | mitigate | process-wide 350 ms spacing, sequential paging capped at 2000, 60 s cache, refresh capped at 20 orders, connection test exactly one call per click, no loops on login failure |
| T-dts-07 | Tampering | Demo/Live mix-up | high | mitigate | separate credentials per environment; environment in every assignment and order; claim requires order environment = active environment (409 otherwise); switch to Live needs dialog + `confirmLive`; permanent badge; default Demo |
| T-dts-08 | Elevation of Privilege | route shadowing | medium | mitigate | static routes declared before `:id` routes; declaration-order assertion in the controller spec |
| T-dts-09 | Tampering / Injection | domain names, ids, contact fields | medium | mitigate | `normalizeDomainName` (domainToASCII + anchored pattern), class-validator DTOs (IsInt ids, IsUUID customer ids, Matches for country/phone), ParseUUIDPipe on `:id` |
| T-dts-10 | Information Disclosure | AutoDNS error passthrough | low | mitigate | only `messages[].text`, max 5 × 200 chars; AutoDNS 401/403 mapped to 502 so the browser session is not mistaken as expired |
| T-dts-SC | Tampering | npm/pip/cargo installs | low | accept | no new packages (undici, class-validator, class-transformer already present); nothing to verify |
</threat_model>
<verification>
- Each task's `<automated>` command passes; Task 1 additionally proves the path through the rebuilt api with curl, Task 3 runs the full api + web suites (includes rls-coverage, rls-access-inventory, umlaut guard, module-layouts) and calls the module through the rebuilt stack.
- `prisma migrate status` up to date locally; `migrate diff --exit-code` exits 0.
- Source coverage audit:
| Source item | Covered by |
|-------------|------------|
| GOAL: module Domains with AutoDNS settings, contacts, domain list, safe registration | Tasks 1–3 |
| L-01 hosts, Basic auth + context header, no API key, API user without 2FA | Task 1 (client, settings hint), Task 3 (admin guide) |
| L-02 encrypted password never returned, context per environment, Demo/Live switch, default nameservers, connection test | Task 1 |
| L-03 contact list, create form, read in existing contacts, customer assignment, eigene Firma, filter/group by customer | Task 2 |
| L-04 availability, contact choice, prefilled nameservers, summary + confirmation, no double orders, job tracking | Task 3 |
| L-05 domain list with customer via owner, owner, expiry, status | Task 2 |
| L-06 rights per route | Tasks 1–3 (controller + module-manage-handlers spec), web gating |
| L-07 transfer/cancellation/zones excluded, generic client kept | Task 1 (generic `autodnsRequest`) |
| L-08 Nextcloud-Status / Handelsware / PageHeader + SettingsSection patterns | Tasks 1–3 |
| L-09 mocked API tests; Demo check after credentials | Tasks 1–3 specs; SUMMARY checklist |
| L-10 German Sie + English, no tenant wording | Tasks 1–3 + node wording check |
| L-11 CHANGELOG under Unveröffentlicht | Task 3 |
| L-12 usable after activation + Freigabe | Task 1 (seed, guard, curl activation) |
| RESEARCH: no /domain/_check, DomainStudio WHOIS+PRICE | Task 3 (D-K) |
| RESEARCH: async POST /domain + GET /job, job search reconciliation | Task 3 (D-M, D-N) |
| RESEARCH: envelope status.type ERROR on HTTP 200, code/resultCode | Task 1 (client) |
| RESEARCH: 3 requests/s, paging, no per-row enrichment | Tasks 1–2 (limiter, paging, cache) |
| RESEARCH: Demo context unclear → free integer per environment | Task 1 (D-D) |
| RESEARCH: route order, module-manage-handlers, RLS specs | Tasks 1–3 |
| RESEARCH: .de nameserver check, contact roles | Task 3 (D-O, guide) |
| RESEARCH open question 2 (missing price) | Task 3 (D-K summary hint) |
| RESEARCH open question 3 (cron vs. on open) | decided D-N (pull-based, no cron) |
| RESEARCH: no dashboard widget in stage 1 | respected (no widget files) |
</verification>
<success_criteria>
- Four new tables with RLS policies; migration applied locally without drift.
- Client, parser, cache, settings, directory, orders, controller specs and the web tests pass; full api + web suites, both tsc runs and biome on touched files are green.
- Benutzen users see domains, contacts, customers and orders; Verwalten users and admins additionally set up AutoDNS, create contacts and customers, assign contacts and register domains; the API enforces this with ModuleManage.
- A registration can be submitted to AutoDNS at most once per order; a second click, a second tab or an environment switch gets 409; an unclear outcome is stored as UNKNOWN and reconciled.
- CHANGELOG and both guides describe the module; three commits on main, nothing pushed.
</success_criteria>
<output>
Create `.planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/261008-dts-SUMMARY.md` when done (not committed by the executor). Besides the measured gate table, deviations and threat status, it must contain a checklist for the first real test against the AutoDNS Demo system once the user has entered Demo credentials — each item closes one research assumption: (1) Demo context value that works (A1); (2) "Verbindung testen" succeeds; (3) create a PERSON and an ORG contact — accepted field set and phone format (A2); (4) availability check returns exactly the requested domain with WHOIS status and price (A3); (5) a Demo registration returns a job id where expected (A4) with `{ id }` contact references (A5); (6) the job reaches SUCCESS and the order follows on "Aktualisieren"; (7) the domain list shows it with owner, expiry and status, and `registryStatus` is present (D-J); (8) a .de registration with the default nameservers passes the nameserver check (A8). Also list the browser check steps for the orchestrator: activate in the Marktplatz, Freigabe Benutzen vs. Verwalten, settings (Live confirmation, masked password), contacts (create, assign, filter, group), customers (eigene Firma, delete blocked), registration summary and double-click, orders tab — dark mode preferred.
</output>
@@ -0,0 +1,217 @@
# Quick 261008-dts: Modul "Domains" (AutoDNS) - Research
**Researched:** 2026-10-08
**Domain:** AutoDNS/InterNetX Domainrobot JSON API + Tessera-Modulmuster
**Confidence:** MEDIUM (Endpunkte und Schemas HIGH aus OpenAPI; reale Antworten der Auftrags-Endpunkte und Demo-Kontext nicht ohne Zugangsdaten pruefbar)
Es gibt keine CONTEXT.md fuer diese Aufgabe. Es wird kein neues npm-Paket benoetigt (HTTP ueber `undici` 7.28.0, `class-validator`, `@nestjs/schedule` sind bereits in `apps/api/package.json`), daher entfaellt das Package Legitimacy Audit. [VERIFIED: apps/api/package.json Zeilen 25, 32, 52]
## Project Constraints (aus CLAUDE.md / Memory)
- UI-Texte Deutsch, siezen; keine "Mandant"-Begriffe in neuen UI-Texten; keine Lizenzierungs-/Mandantenfaehigkeits-Themen anschneiden.
- Keine firmenspezifischen Werte hart in generische Admin-UI (Standard-Nameserver also nur als Einstellung, nicht als Konstante).
- Neue Module: `@UseModule(slug)` auf Klassenebene ist Pflicht, Schreib-/Pruefrouten zusaetzlich `@ModuleManage(slug)`; NIE ein Rollen-Decorator auf Verwalten-Handlern.
- Statische Routen VOR `:id`-Routen (Unit-Tests fangen das nicht, Reihenfolge im Controller-Spec festschreiben).
- Zugangsdaten nie an den Client zurueckgeben (LDAP-Muster: Maske `'********'`).
- Tests mit gemockter API; kein Docker-Deploy auf Testserver durch Claude.
- Alle Aenderungen laufen ueber einen GSD-Workflow.
## Summary
Die AutoDNS-JSON-API deckt alle vier Etappe-1-Funktionen ab. Es gibt KEINEN `POST /domain/_check`; die Verfuegbarkeitspruefung laeuft ueber `POST /domainstudio` (WHOIS-Dienst je Domain, optional PRICE). `POST /domain` ist asynchron und liefert einen Job; der Verlauf wird ueber `GET /job/{id}` verfolgt. Die Antwort-Huelle ist fuer alle Routen gleich (status/stid/object/data/messages).
Im Code ist das Nextcloud-Status-Modul (Controller/Seed/Modul) plus Handelsware-Datev (Einstellungs-Tab, Singleton-Konfiguration) das beste Vorbild; die AES-Verschluesselung kommt aus `CryptoService`. Der groesste fachliche Risikopunkt ist Geldsicherheit: `POST /domain` darf pro Bestellung hoechstens einmal abgeschickt werden, und ein Timeout heisst NICHT "nicht bestellt".
**Primary recommendation:** Zwei Umgebungen (DEMO/LIVE) mit je eigenem Zugang, feste Basis-URLs (keine freie URL-Eingabe), lokale Bestelltabelle mit atomarem Statuswechsel DRAFT -> SUBMITTING vor dem API-Aufruf, Kontakte und Domains live aus AutoDNS lesen und nur die Kundenzuordnung lokal halten.
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary | Rationale |
|---|---|---|---|
| AutoDNS-Aufrufe, Zugangsdaten | API/Backend | - | Passwort darf nie in den Browser; Basic Auth serverseitig |
| Kundenzuordnung Kontakte | Database | API | Nur lokal vorhanden, AutoDNS kennt "Kunde" nicht |
| Bestell-Zustand (Idempotenz) | Database + API | - | Atomarer Statuswechsel in DB schuetzt vor Doppelbestellung |
| Job-Status-Nachverfolgung | API (Abruf beim Oeffnen + Cron) | Browser (Polling der eigenen API) | Browser spricht nie mit AutoDNS |
| Filter/Gruppierung nach Kunde | Browser | API | Kleine Datenmenge, Zusammenfuehren im API-Dienst |
| Berechtigung | API (ModuleGuard) | Browser (`useCanManageModule`, nur Anzeige) | Bindend ist nur die API |
## AutoDNS JSON API (verifiziert)
Quelle der Schemas: OpenAPI 2.0 `https://raw.githubusercontent.com/InterNetX/domainrobot-api/master/src/domainrobot.json` (Info version `v1`, host `api.autodns.com`, basePath `/v1`), gelesen und ausgewertet am 2026-10-08. [VERIFIED: openapi domainrobot.json]
### Basis, Authentisierung, Limits
| Punkt | Wert | Quelle |
|---|---|---|
| Live-URL | `https://api.autodns.com/v1` | [CITED: help.internetx.com/x/cQbj "Interface addresses"] |
| Demo-URL | `https://api.demo.autodns.com/v1` | [CITED: help.internetx.com/x/cQbj; java-domainrobot-sdk README] |
| Erreichbarkeit | Beide Hosts antworten von hier mit HTTP 401 ohne Zugangsdaten | [VERIFIED: curl-Probe 2026-10-08, ohne Zugangsdaten] |
| Auth | HTTP Basic (Benutzer/Passwort) + Header `X-Domainrobot-Context: <int>`; Alternative Session (`X-Domainrobot-SessionId`), von InterNetX nicht empfohlen ausser bei Zwangs-Timeout | [CITED: help.internetx.com Suchergebnis "Login and Authentication"; js-sdk `DomainRobotService.js` setzt Basic + Context-Header] |
| User-Agent | Pflicht ("mandatory") - eigenen setzen, z. B. `Tessera/<version>` | [CITED: help.internetx.com/x/cQbj] |
| Rate-Limit | "Only 3 requests per second and IP" - Aufrufe serialisieren, Listen seitenweise, kein paralleles Fan-out | [CITED: help.internetx.com/x/cQbj] |
| Verbindungstest | `GET /hello` ("performs no operation, used only for testing the connection and authentication credentials") | [CITED: help.internetx.com/x/cQbj; OpenAPI `/hello`] |
| Live-Context | Standard `4` | [CITED: wisecp-Doku und help.internetx.com (Plugin-Seite): "default context for AutoDNS is 4"] |
| Demo-Context | UNKLAR: Drittanbieter nennt `1` fuer Demo, die offiziellen SDK-Beispiele nutzen die Demo-URL mit `"4"` | [ASSUMED] - Kontext deshalb je Umgebung als freie Ganzzahl speichern, nicht hartkodieren |
| 2FA | OpenAPI kennt Header `X-Domainrobot-2FA-Token`; Hilfeseite sagt 2FA nur fuer XML | [ASSUMED] Widerspruch - dedizierten API-Benutzer OHNE 2FA empfehlen |
| `X-Domainrobot-Demo` (boolean Header) | in OpenAPI vorhanden, Wirkung undokumentiert | NICHT verwenden; Umgebung nur ueber die Basis-URL waehlen |
Fehlerhafte Anmeldung: Live liefert 401 mit `{"messages":[{"code":"EF00202","text":"User does not exist or password incorrect.","status":"ERROR",...}],"status":{"code":null,"text":null,"type":"ERROR"},"stid":"..."}`; Demo ohne Basic-Header liefert 401 `EF1321001 "The authsession could not be found."`. [VERIFIED: curl-Probe 2026-10-08] Wichtig: wiederholte falsche Anmeldungen koennen den Benutzer sperren [ASSUMED] - Verbindungstest nie in Schleife/Retry.
### Antwort-Huelle
```json
{ "status": {"code":"S0301","text":"...","type":"SUCCESS"},
"stid": "20180915-app1",
"object": {"type":"contact","value":"100101","summary":1},
"messages": [ {"code":"...","text":"...","status":"ERROR","objects":[...]} ],
"data": [ { } ] }
```
- `status.type` Enum: `SUCCESS`, `ERROR`, `NOTIFY`, `NOTICE`, `NICCOM_NOTIFY`. [VERIFIED: OpenAPI StatusType]
- Code-Praefixe: `S` Erfolg, `E` Fehler, `N` Benachrichtigung ("accepted, further processing required"). [CITED: help.internetx.com/x/cQbj]
- Die Hilfeseite zeigt das Feld als `resultCode`, OpenAPI und die reale 401-Antwort nutzen `code`. Parser muss `status.code ?? status.resultCode` lesen. [VERIFIED: Probe + OpenAPI; Abweichung CITED]
- `object.summary` = Gesamtzahl bei Listen (Paging). [VERIFIED: OpenAPI ResponseObject "amount of objects found in list tasks"]
- HTTP-Status ist nicht verlaesslich fuer Fachfehler: IMMER `status.type === 'ERROR'` bzw. `messages[].status` pruefen, auch bei HTTP 200. [ASSUMED]
- Fehlertexte an den Client nur gekuerzt und ohne Header/Zugangsdaten durchreichen (`messages[].text`).
### Routen und minimale Nutzlasten
| Zweck | Route | Nutzlast / Hinweis |
|---|---|---|
| Kontakt anlegen | `POST /contact` | `{"type":"PERSON","fname":"Max","lname":"Muster","address":["Musterstr. 1"],"pcode":"12345","city":"Berlin","country":"DE","email":"x@example.com","phone":"+49 30 123456"}`; `type` Enum `PERSON`, `ORG`, `ROLE`; `ORG` braucht `organization`; `address` ist ein ARRAY; `alias` optional (wird generiert); Antwort `data[0].id` (Ganzzahl). Pflichtfelder sind in der OpenAPI nicht als `required` markiert -> obige Menge ist [ASSUMED], gegen Demo pruefen. `extensions` (z. B. `general.gender`, `it.entityType`) nur TLD-abhaengig, fuer `.de` nicht noetig [ASSUMED]. |
| Kontakte listen | `POST /contact/_search` | Body `Query`: `{"filters":[{"key":"lname","value":"%test%","operator":"LIKE"}],"view":{"limit":100,"offset":0},"orders":[{"key":"lname","type":"ASC"}]}`; leere `filters` = alle. Filterbare Schluessel: country, pcode, city, type, title, lname, alias, state, id, email, fname, address, created, phone, organization, comment, updated ... Operatoren: EQUAL, NOT_EQUAL, LIKE, ILIKE, GREATER, LESS, IN, IS_NULL ... [VERIFIED: OpenAPI + js-sdk ContactList.js] |
| Kontakt lesen/aendern | `GET/PUT /contact/{id}` | Spaeter; `DELETE /contact/{id}` NICHT in Etappe 1 anbieten |
| Verfuegbarkeit | `POST /domainstudio` | Es gibt keine Route `/domain/_check` (alle `/domain*`-Pfade der OpenAPI durchgesehen). Body `DomainEnvelopeSearchRequest`: `{"searchToken":"example","currency":"EUR","checkPortfolio":true,"sources":{"initial":{"tlds":["de"],"services":["WHOIS","PRICE"]}}}`; alternativ `sources.custom.domains:["example.de"]`. Antwort `data[]` = `DomainEnvelope` mit `domain`, `services.whois.data.status` (Enum `FREE`, `ASSIGNED`, `MARKET`, `PREMIUM`, `INVALID`, `ERROR`, `TIMEOUT`, `RESERVED`, `PREMIUM_CLAIM`, `CLAIM`, `OFFER`) und `services.price.data.prices[]` (`amount`, `currency`, `period`). [VERIFIED: OpenAPI Schemas]. Die genaue Kombination `initial.tlds` + `searchToken` bzw. `custom.domains` liefert ein Ergebnis fuer genau die gewuenschte Domain: [ASSUMED], gegen Demo pruefen. Nur `status === 'FREE'` zaehlt als bestellbar; alles andere (auch ERROR/TIMEOUT) = nicht bestellbar. |
| Domain bestellen | `POST /domain` | "The operation is asynchronous and creates a job." Body (Domain): `{"name":"beispiel.de","period":{"unit":"YEAR","period":1},"ownerc":{"id":123},"adminc":{"id":123},"techc":{"id":123},"zonec":{"id":123},"nameServers":[{"name":"ns1.example.com"},{"name":"ns2.example.com"}]}`. Contacts als Objekte mit `id` (SDK-Beispiel reicht ganze Kontakt-Objekte; nur `{id}` genuegt vermutlich: [ASSUMED]). Query-Parameter `ignoreWhois`, `nsCheck`, `replyTo` - `ignoreWhois` NIE setzen. Antwort `JsonResponseDataJob`: `data[0]` = Job. Wo genau die Job-Id steht (`data[0].id`, ausserdem vermutlich `object.type="job"`/`object.value`): [ASSUMED], gegen Demo pruefen. `confirmOrder` (Nutzungsbedingungen mancher TLDs) und `period` je nach TLD: [VERIFIED: Feldbeschreibung OpenAPI]. Zusatz `nameServerEntries` nur fuer `.de` und schliesst `nameServers` aus. |
| Job lesen | `GET /job/{id}` | Antwort `ObjectJob`: `data[0].job.status` (Enum `RUNNING`, `SUCCESS`, `FAILED`, `CANCELED`, `SUPPORT`, `DEFERRED`, `NOT_SET`, `WAIT`), `.job.subStatus`, `.job.action`, `.object` (Domain-Name), `.job.events[]`. Terminal: `SUCCESS`, `FAILED`, `CANCELED`; `SUPPORT` = Eingriff durch InterNetX noetig (als "Rueckfrage noetig" anzeigen); `WAIT`/`DEFERRED`/`RUNNING` = offen. [VERIFIED: OpenAPI Enum; Terminal-Deutung ASSUMED] |
| Jobs suchen | `POST /job/_search` | Query mit Schluesseln `id`, `status`, `object`, `type`, `action`, `created` -> Abgleich nach Timeout (siehe Pitfall 1). |
| Domains listen | `POST /domain/_search?keys[]=expire&keys[]=ownerc` | Body `Query` wie bei Kontakten (`view.limit/offset`). Zusatzfelder per wiederholtem Query-Parameter `keys[]` (Format `?keys[]=a&keys[]=b`): zulaessig u. a. `expire`, `ownerc`, `adminc`, `techc`, `zonec`, `nserver`, `autorenew`, `cancelationStatus`, `authinfo`, `certificate`. Statusfelder: `registryStatus` (`ACTIVE`, `HOLD`, `LOCK`, `PENDING` ...), `cancelationStatus`, `action`. [VERIFIED: OpenAPI + js-sdk DomainService.list + php-sdk DomainList.php] |
| Domain lesen | `GET /domain/{name}` | Detail/Abgleich nach Timeout. |
Spaeter (Architektur offenhalten, nicht bauen): Transfer `POST /domain/_transfer`, Kuendigung `POST /domain/{name}/cancelation`, Zonen `GET/PUT /zone/{name}/{virtualNameServer}`, Auftrag bestaetigen/abbrechen `PUT /job/{id}/_confirm` / `_cancel`, Poll-Nachrichten `GET /poll`. [VERIFIED: OpenAPI paths] Deshalb den AutoDNS-Client als eigene Klasse mit allgemeiner `request(method, path, body, keys)`-Methode plus duenner Fachschicht bauen.
### .de-Besonderheiten (kurz)
- Seit 25.05.2018 erhebt DENIC nur noch den Domaininhaber als personenbezogene Kontaktdaten; Admin-C/Tech-C/Zone-C entfallen bei DENIC, zusaetzlich zwei neutrale Adressen (General Request/Abuse). AutoDNS-Objekte kennen weiter `ownerc/adminc/techc/zonec`; sicher ist, den Inhaber vollstaendig mit Anschrift zu fuehren und fuer die uebrigen drei Rollen einen Kontakt zu referenzieren. [CITED: denic.de Pressemitteilung 25.05.2018 laut Suchergebnis; Abbildung auf AutoDNS-Pflichtfelder ASSUMED] - Empfehlung: alle vier IDs im Formular verlangen, Voreinstellung = Inhaber bzw. eigener Firmenkontakt als Technik-/Zonen-Kontakt.
- `.de` verlangt 2 bis 6 funktionierende Nameserver; DENIC prueft sie bei der Registrierung (Nast-Check), unerreichbare/nicht eingerichtete Nameserver lassen den Auftrag scheitern. Parameter `nsCheck` existiert. [CITED: namecheap/ans.co.uk Suchergebnis; AutoDNS-Verhalten ASSUMED] - Auftrag `FAILED` mit `messages` verstaendlich anzeigen.
- Regeln pro TLD unterscheiden sich stark (`extensions`); Etappe 1 auf Kern-TLDs (.de/.com/.net/.org) beschraenken oder bei fehlenden Pflichtangaben die API-Fehlermeldung durchreichen.
## Codebase-Integration (verifiziert durch Lesen der Dateien)
### Backend: Dateien als Vorlage kopieren
| Zweck | Vorlage | Hinweis |
|---|---|---|
| Modul + Seed beim Start | `apps/api/src/nextcloud-status/nextcloud-status.module.ts`, `nextcloud-status.seed.ts` | `seedModule({slug, name, version, category, description:{de,en}, isSystem:true})`; `onModuleInit` mit try/catch. Kategorie: `'infrastructure'` (existiert, `proxmox`/`nextcloud-status` nutzen sie) oder `'domain-tools'` (existiert, `domaincheck` nutzt sie). [VERIFIED: nextcloud-status.seed.ts; module-categories.service.spec.ts Zeilen 17-20] Empfehlung: `'domain-tools'` zu Domaincheck, Kategorien sind laut 261003-387 durch Admins umbenennbar. |
| Controller | `nextcloud-status.controller.ts` | `@Controller('modules/<slug>')` + `@UseModule('<slug>')` Klasse; `requireTenantId(req)` aus `req.tenantId`; Schreib-/Pruef-/Einstellungsrouten `@ModuleManage('<slug>')`; Lese-Routen ohne. |
| Singleton-Einstellungen + Tab | `handelsware-datev.controller.ts`, `handelsware-datev.service.ts`, Web `modules/handelsware-datev/page.tsx` + `components/SettingsTab.tsx` | Tab "Einstellungen" nur bei `useCanManageModule`. |
| Verschluesselung | `apps/api/src/crypto/crypto.service.ts` (`CryptoModule`) | `encrypt(plain)` -> `iv:authTag:ciphertext` (AES-256-GCM, Schluessel `TESSERA_ENCRYPTION_KEY`, 64 Hex). Entschluesseln-Fehler NICHT schlucken (kein stiller Wechsel auf "ohne Passwort"), wie `ldap-config.service.ts` `decryptBindPassword`. Passwort-Feld im API-Response als `'********'` bzw. `hasPassword: boolean`; leeres Feld beim Speichern = unveraendert (LDAP-Muster `dto.bindPassword \|\| config.bindPassword`). |
| HTTP-Client | `apps/api/src/proxmox/proxmox-client.service.ts` | `undiciFetch` (NICHT globales `fetch`), `AbortController`-Timeout, Fehlerdetail gekuerzt (500 Zeichen), Fehlerklassifikation. Fuer AutoDNS: festes Basis-URL-Mapping DEMO/LIVE, kein Eintrag frei waehlbar (kein SSRF), 15-20 s Timeout, Basic-Header aus entschluesselten Daten. |
| Hintergrunddienst (Auftragsstatus) | `nextcloud-status-scheduler.service.ts` | `OnApplicationBootstrap`, `SchedulerRegistry` + `require('cron').CronJob`-Umgehung, Ueberlappungsschutz `running`; Systemkontext-Lesen nur ueber `forSystem()` (+ `system_read_policy`). Fuer Etappe 1 genuegt: Status beim Oeffnen/"Aktualisieren" abfragen, optional minuetlicher Cron nur fuer offene Auftraege. |
| DTOs | `nextcloud-status/dto/nextcloud-instance.dto.ts` | `class-validator`; globale `ValidationPipe` ist in `main.ts` aktiv. |
| DB-Zugriff | `forTenant(this.prisma, tenantId)` aus `prisma/prisma-tenant.extension.ts` | Jeder neue Zugriff in `rls-access-inventory.spec.ts` eintragen, wenn dort Klassifikation verlangt wird. |
| Migration | `apps/api/prisma/migrations/20261002130000_handelsware_datev/migration.sql` | Handgeschrieben mit Kopfkommentar; je Tabelle `tenantId`, `ENABLE`+`FORCE ROW LEVEL SECURITY`, `tenant_isolation_policy USING ("tenantId" = current_tenant_id())`; sonst faellt `rls-coverage.spec.ts`. Naechster Zeitstempel > `20261003120000`. |
| Registrierungspunkte | `apps/api/src/app.module.ts` (Importliste), `module-manage-handlers.spec.ts` (Pflicht-Test: jede Schreibroute verlangt Manage, Leseroute nicht) | |
### Frontend: Registrierungspunkte (alle noetig, sonst erscheint das Modul nicht korrekt)
- `apps/web/src/lib/module-loader.ts`: Eintrag `'<slug>': { component: dynamic(() => import('@/app/(portal)/modules/<slug>/page'), { ssr: false }) }`.
- `apps/web/src/lib/module-identity.ts`: Symbol in `ICONS` (z. B. `domaincheck: 'globe'` existiert; neuer Slug braucht eigenen Eintrag; fehlt er, gilt 'tile').
- `apps/web/src/lib/stores/nav-store.ts`: `MODULE_TITLE_KEYS['<slug>'] = '<ns>.title'`.
- `apps/web/src/app/(portal)/modules/<slug>/layout.tsx`: `<ModuleAccessGate moduleSlug="<slug>">` und Eintrag in `module-layouts.test.tsx`.
- Seite: `PageHeader` aus `@/components/layout/page-header`, `TabBar` aus `@/components/accounting/tab-bar` (Handelsware-Muster: Tabs Kontakte / Domains / Registrieren / Einstellungen), `useCanManageModule(slug)`. `SettingsSection`/`ControlCenterNav` (`components/control-center/`) gehoeren zum Administrationsbereich (`/admin/...`); fuer ein Modul reicht der Einstellungs-Tab nach Handelsware-Muster. [VERIFIED: ControlCenterNav listet nur /settings und /admin Seiten]
- API-Client: `apps/web/src/lib/<name>-api.ts` nach `nextcloud-status-api.ts` (`credentials: 'include'`, `NEXT_PUBLIC_API_URL`).
- Texte in `apps/web/src/messages/de.json` UND `en.json` (Paritaets-Tests); deutsche Texte mit echten Umlauten, `umlaut-guard.spec.ts` laeuft ueber de.json - neue Woerter ggf. in `umlaut-dictionary.ts`.
- Kein Dashboard-Widget in Etappe 1 (`WIDGET_MODULE_SLUGS` unveraendert).
## Datenmodell-Empfehlung
Prinzip: AutoDNS bleibt die Quelle der Wahrheit fuer Kontakte und Domains; lokal liegt nur, was AutoDNS nicht kennt oder was Geld-/Idempotenz-Schutz braucht.
| Tabelle (alle mit `tenantId`, RLS) | Inhalt | Begruendung |
|---|---|---|
| `DomainsConfig` (Singleton je tenantId) | `environment` (`DEMO`/`LIVE`, Standard `DEMO`), je Umgebung `demoUser`, `demoEncryptedPassword`, `demoContext Int?`, `liveUser`, `liveEncryptedPassword`, `liveContext Int? (Standard 4)`, `defaultNameServers` (Text-Array/Json) | Zwei getrennte Zugangssaetze: Umschalter kann nie versehentlich Live-Zugang in Demo tippen und umgekehrt; Wechsel verliert nichts. Passwort-Spalten `iv:authTag:ciphertext`. |
| `DomainsCustomer` | `id`, `name` (je Mandant eindeutig), `isOwnCompany Boolean` | Kunden gibt es im System noch nicht (Nextcloud nutzt nur Freitext `customerName`). Eigene kleine Tabelle erlaubt Gruppieren/Filtern ohne Tippfehler-Duplikate; die eigene Firma ist ein Eintrag mit Markierung. Kein Firmenname als Vorgabe. |
| `DomainsContactAssignment` | `environment`, `autodnsContactId Int`, `customerId` (FK `DomainsCustomer`, `onDelete: Restrict`) ; `@@unique([tenantId, environment, autodnsContactId])` | AutoDNS-Kontakt-IDs von Demo und Live kollidieren zwangslaeufig, daher Umgebung im Schluessel. Kontakte selbst werden NICHT gespiegelt. |
| `DomainsOrder` | `id`, `environment`, `domainName`, `status` (`DRAFT`, `SUBMITTING`, `SUBMITTED`, `SUCCESS`, `FAILED`, `UNKNOWN`, `CANCELED`), `jobId Int?`, `payload Json` (Kontakt-IDs, Nameserver, Laufzeit, angezeigter Preis/Waehrung), `createdByUserId`, `confirmedByUserId`, `confirmedAt`, `errorText`, `lastCheckedAt` ; Teil-Unique auf offene Bestellungen je (`tenantId`,`environment`,`domainName`) | Idempotenz, Nachverfolgbarkeit, Audit "wer hat Geld ausgegeben". |
| Kein lokaler Domain-Cache in Etappe 1 | Domainliste wird live seitenweise gelesen (`view.limit`), `ownerc` per `keys[]` mitgeholt; Kunde der Domain = Kunde des Inhaber-Kontakts (aus `DomainsContactAssignment`) | Immer aktuell, keine Synchronisationsfehler. Bei vielen Hundert Domains oder AutoDNS-Ausfall spaeter Snapshot-Tabelle + Cron nachruesten. Kurzer In-Memory-Zwischenspeicher (z. B. 60 s) pro (tenant, environment) schuetzt das 3-Aufrufe-pro-Sekunde-Limit. |
"Kunde" einer Domain, die der Inhaber-Kontakt nicht zuordenbar macht (Kontakt ohne Zuordnung): als "Nicht zugeordnet" anzeigen und im Filter anbieten. Spaetere Abweichung (Domain gehoert anderem Kunden als ihr Inhaber) ist eine eigene Zuordnungstabelle in Etappe 2 - Architektur laesst das zu.
## Architektur-Muster fuer die Registrierung (Geldsicherheit)
```
Browser --1 Pruefen(name,tld)--> API --> POST /domainstudio (WHOIS+PRICE) --> Ergebnis FREE?
Browser --2 Entwurf(Kontakte,NS,Laufzeit)--> API: legt DomainsOrder DRAFT an, liefert Zusammenfassung + orderId
Browser --3 "Verbindlich registrieren" (orderId, Haken gesetzt)--> API:
UPDATE ... SET status='SUBMITTING' WHERE id=? AND status='DRAFT' AND environment=<aktive> (Zaehler == 1 ?)
nein -> 409 "Bereits abgeschickt"
ja -> POST /domain (EINMAL, KEIN Retry) -> Job-Id speichern, status='SUBMITTED'
Netzwerkfehler/Timeout/unlesbare Antwort -> status='UNKNOWN' (nie zurueck auf DRAFT)
Browser/Cron --4 Aktualisieren--> GET /job/{id} -> SUCCESS/FAILED/... ; bei UNKNOWN: POST /job/_search bzw. GET /domain/{name} zum Abgleich
```
## Don't Hand-Roll
| Problem | Nicht bauen | Stattdessen | Warum |
|---|---|---|---|
| Verschluesselung | eigenes Krypto | `CryptoService` | AES-256-GCM, einheitlicher Schluessel |
| Moduleberechtigung | eigene Rollenpruefung | `@UseModule` + `@ModuleManage` | Gruppen-/Direktfreigaben, Admin-Kurzschluss |
| Mandantenschutz | WHERE tenantId von Hand | `forTenant()` + RLS-Migration | RLS-Tests erzwingen es |
| HTTP/Timeout | neuer Client-Stil | `undici` wie Proxmox | bewaehrt, testbar |
| Domainnamen-Pruefung | Regex-Eigenbau | Pruefung per Muster `^[a-z0-9-]+\.[a-z.]{2,}$` nur als Vorfilter; Gueltigkeit entscheidet DomainStudio-Status `INVALID` | IDN/TLD-Sonderfaelle |
## Common Pitfalls
1. **Timeout bei `POST /domain` ist unbekannter Ausgang.** Nie automatisch wiederholen. Status `UNKNOWN`; Abgleich per `GET /domain/{name}` (existiert -> bestellt) oder `POST /job/_search`; dem Benutzer "Ergebnis ungeklaert, bitte pruefen" zeigen. Kein globaler Retry-Wrapper fuer POST.
2. **Doppelklick / zwei Browser-Tabs / Wiederholen nach Fehler.** Schutz nur serverseitig durch atomaren Compare-and-Set in der DB (`updateMany ... where status='DRAFT'`, Zaehler pruefen); Button-Sperre im Browser ist nur Komfort. Eine neue Bestellung derselben Domain erst, wenn die alte `FAILED`/`CANCELED` ist. AutoDNS bietet keinen dokumentierten Idempotenz-Schluessel; Header `X-Domainrobot-Ctid` existiert (js-sdk `Headers.js`), eine serverseitige Dublettenerkennung darauf ist [ASSUMED] - nur zur Korrelation im Log verwenden.
3. **Demo/Live verwechselt.** Umgebung ist Teil jeder Bestellung und jeder Zuordnung; beim Bestaetigen muss `order.environment === config.environment` gelten (sonst 409). Deutlicher, dauerhafter Banner "DEMO-System" bzw. "LIVE - kostenpflichtig" in Modulkopf und Bestaetigungsdialog; Live-Registrierung verlangt ausdrueckliches Haeckchen "verbindlich registrieren (kostenpflichtig)". Umgebungswechsel nur mit Verwalten und mit Bestaetigungsdialog; Standard fuer neue Installation: DEMO. Basis-URL ist Aufzaehlung, nie Freitext.
4. **Geheimnisse.** Passwort nie in Responses, Logs oder Fehlertexten (Basic-Header, Request-Body-Logging aus; `request-log.ts` protokolliert nur Methode/Pfad/Status). Fehlermeldungen gekuerzt; AutoDNS-`messages[].text` ist unkritisch, aber ohne `stid`-Zwang anzeigen. Entschluesselungsfehler lautstark loggen, nicht als "kein Passwort" werten.
5. **NestJS-Routenreihenfolge.** `contacts/import`, `contacts/availability`, `registrations/preview`, `domains/check`, `connection-test` stehen VOR `contacts/:id`, `registrations/:id`; im Controller-Spec die Deklarationsreihenfolge pruefen (Vorbild `handelsware-datev.controller.spec.ts`). Jede neue Verwalten-Route in `module-manage-handlers.spec.ts` eintragen.
6. **Rate-Limit 3 Anfragen/s/IP.** Einen Limiter (Warteschlange mit 350 ms Abstand je Prozess) im AutoDNS-Client, Listen in Seiten zu je 100-250; die Domainliste nicht pro Zeile mit `GET /domain/{name}` anreichern.
7. **HTTP 200 mit Fachfehler.** `status.type==='ERROR'` immer auswerten (auch `messages[]`), sonst gilt eine fehlgeschlagene Bestellung als erfolgreich.
8. **Kontakt-IDs ueber Umgebungen.** Siehe Datenmodell; Zuordnungen nie ohne `environment` auswerten.
9. **Kontakte aendern kann Domains beeinflussen** (`confirmOwnerConsent` bei Inhaberwechsel gTLDs). Etappe 1: Kontakte nur anlegen/lesen, nicht aendern.
10. **Nameserver-Check .de.** Standard-Nameserver in den Einstellungen muessen vorab bei AutoDNS/Anbieter eingerichtet sein, sonst scheitert `.de` mit `FAILED`.
## Validation Architecture
| Property | Value |
|---|---|
| Framework | Vitest 3.2.6 (apps/api), 4.1.9 (apps/web) [VERIFIED: CLAUDE.md Stack-Tabelle] |
| API-Mock | AutoDNS-Client hinter Schnittstelle (`AutodnsClient`), im Test durch Fake ersetzt; HTTP-Schicht mit `undici` `MockAgent` pruefen: Header (`Authorization`, `X-Domainrobot-Context`, `User-Agent`), URL je Umgebung, Timeout |
| Quick run | `pnpm --filter api exec vitest run src/autodns-domains` |
| Pflicht-Tests | Controller-Reihenfolge; `module-manage-handlers.spec.ts` erweitern; Passwort nie in Response (`'********'`/`hasPassword`); Bestellung: zweiter Bestaetigungsaufruf -> 409 und `POST /domain` genau einmal; Timeout -> `UNKNOWN` ohne Retry; Umgebungswechsel zwischen DRAFT und Bestaetigung -> 409; HTTP 200 + `status.type=ERROR` -> Fehler; `rls-coverage.spec.ts` und RLS-Inventar; de/en-Paritaet der Texte |
| Echtprobe | Erst nach Eintrag von Demo-Zugangsdaten durch den User: Verbindungstest, Kontakt anlegen, Verfuegbarkeit, Bestellung, Job-Antwortform (schliesst die [ASSUMED]-Punkte) |
## Security Domain
| ASVS | Gilt | Kontrolle |
|---|---|---|
| V4 Zugriffskontrolle | ja | `@UseModule` Klasse, `@ModuleManage` auf Registrieren/Kontakt anlegen/Einstellungen/Verbindungstest; `tenantId` nur aus `req.tenantId` |
| V5 Eingabevalidierung | ja | `class-validator`-DTOs; Domainname normalisieren (klein, ohne Protokoll/Pfad); Kontakt-/Job-Ids als Integer |
| V6 Kryptografie | ja | `CryptoService`, nichts Eigenes |
| V9 Kommunikation | ja | nur `https`, feste Hosts (kein SSRF), Zertifikatspruefung an |
| V7 Protokollierung | ja | Bestellungen mit Benutzer-Id und Zeitpunkt in `DomainsOrder` (Audit) |
## Assumptions Log
| # | Annahme | Abschnitt | Risiko wenn falsch |
|---|---|---|---|
| A1 | Demo-Context ist unklar (1 laut Drittanbieter, 4 in SDK-Beispielen) | Basis | Verbindungstest Demo scheitert; deshalb frei eintragbar |
| A2 | Pflichtfelder Kontakt (type, Name/Organisation, address[], pcode, city, country, email, phone) | Routen | Demo-Fehler beim Anlegen; Formular nachbessern |
| A3 | `POST /domainstudio` mit `initial.tlds`+`searchToken` bzw. `custom.domains` liefert genau die gewuenschte Domain mit WHOIS-Status | Routen | Verfuegbarkeitspruefung anders aufbauen |
| A4 | Job-Id in Antwort von `POST /domain` unter `data[0].id` | Routen | Statusnachverfolgung muss Antwortform nachziehen; Fallback `POST /job/_search` |
| A5 | `{ "id": n }` genuegt als Kontaktverweis in `ownerc/adminc/techc/zonec` | Routen | ganzes Kontakt-Objekt aus `GET /contact/{id}` mitsenden |
| A6 | Dedizierter API-Benutzer ohne 2FA; 2FA-Token-Header fuer JSON unsicher | Basis | Anmeldung scheitert bei 2FA-Benutzer |
| A7 | HTTP 200 mit Fachfehler moeglich; 429 bei Rate-Limit | Pitfalls | schlimmstenfalls zu vorsichtige Auswertung |
| A8 | DENIC/AutoDNS-.de-Pflichten fuer adminc/techc/zonec und Nameserver-Pruefung | .de | Bestellung scheitert mit klarer API-Meldung |
| A9 | Falsche Anmeldungen koennen den Benutzer sperren | Basis | Verbindungstest nur einmal je Klick, nie Schleife |
## Open Questions
1. **Existiert beim User ein dedizierter AutoDNS-API-Benutzer (ohne 2FA) und welcher Context gilt fuer Live/Demo?** Klaeren beim Eintragen der Demo-Zugangsdaten; Einstellungsmaske nimmt beides auf.
2. **Preisanzeige in der Zusammenfassung:** `PRICE`-Dienst liefert Betrag/Waehrung; falls er fehlt oder `FAILED` ist, Bestellung nur mit Hinweis "Preis nicht ermittelbar" zulassen, nicht stillschweigend ohne Preis.
3. **Taeglicher Abgleich der Auftraege:** Entscheidung Etappe 1 ohne Cron (Abfrage beim Oeffnen/Aktualisieren) oder mit minuetlichem Cron nur fuer offene Auftraege. Empfehlung: Cron nachruesten, sobald die Demo-Antwortform bekannt ist.
## Sources
### Primary (HIGH)
- OpenAPI 2.0 `https://raw.githubusercontent.com/InterNetX/domainrobot-api/master/src/domainrobot.json` - Pfade, Schemas, Enums
- `https://help.internetx.com/x/cQbj` (JSON API Basics) - URLs live/demo, Limits, `/hello`, Statuscodes S/E/N
- InterNetX SDK-Beispiele: `js-domainrobot-sdk` (`examples/contact|domain|domainstudio`, `src/services/DomainService.js`, `src/lib/Headers.js`), `php-domainrobot-sdk` (`example/domain/DomainList.php`), `java-domainrobot-sdk` README (Demo-URL)
- Curl-Proben ohne Zugangsdaten gegen beide Hosts am 2026-10-08
- Codebase: `apps/api/src/nextcloud-status/*`, `apps/api/src/crypto/crypto.service.ts`, `apps/api/src/ldap/ldap-config.service.ts`, `apps/api/src/module-registry/module.guard.ts`, `apps/api/src/handelsware-datev/*`, `apps/api/prisma/migrations/20261002130000_handelsware_datev`, `apps/web/src/lib/module-loader.ts|module-identity.ts|stores/nav-store.ts`
### Secondary/Tertiary (MEDIUM/LOW)
- docs.wisecp.com/en/internetx.md (Demo-Context 1, Drittanbieter)
- DENIC Pressemitteilung 25.05.2018 und Registrar-Wissensdatenbanken (.de-Regeln, ueber Websuche, nicht im Volltext geprueft)
## Metadata
**Confidence:** Standard-Stack HIGH (nichts Neues); AutoDNS-Routen/Schemas HIGH; Antwortformen der Bestell-/Job-Routen MEDIUM; .de-Regeln LOW-MEDIUM
**Research date:** 2026-10-08, gueltig ca. 30 Tage (OpenAPI aendert sich selten, aber vor Implementierung gegen Demo gegenpruefen)
@@ -0,0 +1,181 @@
---
phase: 261008-dts-modul-domains-autodns-anbindung-kontakte
reviewed: 2026-10-08T00:00:00Z
depth: quick
files_reviewed: 37
files_reviewed_list:
- apps/api/prisma/migrations/20261008120000_domains_autodns/migration.sql
- apps/api/prisma/schema.prisma
- apps/api/src/app.module.ts
- apps/api/src/domains/autodns-client.ts
- apps/api/src/domains/autodns-parse.ts
- apps/api/src/domains/domain-name.ts
- apps/api/src/domains/domains-cache.ts
- apps/api/src/domains/domains-directory.service.ts
- apps/api/src/domains/domains-orders.service.ts
- apps/api/src/domains/domains-settings.service.ts
- apps/api/src/domains/domains.controller.ts
- apps/api/src/domains/domains.module.ts
- apps/api/src/domains/domains.seed.ts
- apps/api/src/domains/domains.types.ts
- apps/api/src/domains/dto/domains-contact.dto.ts
- apps/api/src/domains/dto/domains-customer.dto.ts
- apps/api/src/domains/dto/domains-order.dto.ts
- apps/api/src/domains/dto/domains-settings.dto.ts
- apps/web/src/app/(portal)/modules/domains/components/ContactForm.tsx
- apps/web/src/app/(portal)/modules/domains/components/ContactsTab.tsx
- apps/web/src/app/(portal)/modules/domains/components/CustomersTab.tsx
- apps/web/src/app/(portal)/modules/domains/components/DomainsTab.tsx
- apps/web/src/app/(portal)/modules/domains/components/EnvironmentBadge.tsx
- apps/web/src/app/(portal)/modules/domains/components/OrdersTab.tsx
- apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx
- apps/web/src/app/(portal)/modules/domains/components/SettingsTab.tsx
- apps/web/src/app/(portal)/modules/domains/layout.tsx
- apps/web/src/app/(portal)/modules/domains/page.tsx
- apps/web/src/components/domains/group-by-customer.ts
- apps/web/src/components/domains/order-status.ts
- apps/web/src/lib/domains-api.ts
- apps/web/src/lib/module-identity.ts
- apps/web/src/lib/module-loader.ts
- apps/web/src/lib/stores/nav-store.ts
- apps/web/src/components/modules/module-tile.tsx
findings:
critical: 1
warning: 6
info: 4
total: 11
status: issues_found
---
# Quick 261008-dts: Code Review Report
**Reviewed:** 2026-10-08
**Depth:** quick (focus area 1, money safety, traced in full)
**Files Reviewed:** 37
**Status:** issues_found
## Summary
The core money-safety design holds up. The DRAFT -> SUBMITTING claim is an atomic `updateMany` that includes the environment in its `where`, and it happens before any network call. `autodnsRequest` has no retry and there is exactly one `POST /domain`. Timeouts and network errors end in UNKNOWN. The web client has no automatic retry and locks the confirm button after an error. Concurrent confirms, a second tab and a double click cannot produce two POSTs.
Secrets, tenant isolation, permission levels and route order are clean:
- **Secrets:** The password never leaves the settings service. Views only carry `hasPassword`. No log line or error text contains headers or credentials.
- **Tenant isolation:** Every Prisma `where` names `tenantId`, RLS is on for all four tables, and the cache key includes tenant, environment and config version.
- **Permissions:** Writes are `@ModuleManage`, reads use the class-level `@UseModule`, and there is no `@Roles`.
- **Route order:** `orders/refresh` comes before `orders/:id/*`.
One real hole remains in the "never guess" rule. A non-2xx answer that carries an envelope is treated as "AutoDNS rejected the order", even for 5xx (CR-01). Several smaller gaps around the discard and reconcile path make that hole easier to reach.
## Fix Status (2026-10-08)
| ID | Status | Commit | Note |
|----|--------|--------|------|
| CR-01 | fixed | cc1c83a | 5xx, 408, 425 (with or without envelope) now classify as `http`; only 4xx with envelope, 401/403 and 2xx+ERROR are definitive. Submit: `http`, timeouts, network, tls and 429 all end in UNKNOWN (429 origin is not proven to be AutoDNS pre-processing). Reconcile treats non-`business` failures as inconclusive. Tests: client spec table, order spec for 408/425/429/500/502/503/504 on submit, reconcile and job search. Requires human verification (logic change). |
| WR-01 | fixed | cc1c83a | `cancelOrder` re-runs the reconcile at discard time; only an explicit not-found discards. Found -> 409 `orderFound`, inconclusive -> 409 `checkFirst`, other system -> 409 `environmentChanged`. Requires human verification. |
| WR-02 | fixed | cc1c83a | Summary carries `version` (draft `updatedAt`); submit DTO requires it, claim `where` includes `updatedAt`, mismatch -> 409 `orderChanged` (German message shown by the register dialog). Requires human verification. |
| WR-03 | fixed | cc1c83a | Jobs for the domain without readable date, jobs without readable object and a full result page (10) without match are inconclusive: `lastCheckedAt` stays empty. Note: the `object` filter key is still unverified against the live API. |
| WR-04 | fixed | 268d6d5 | `@ValidateIf(v !== undefined)` instead of `@IsOptional()`; context numbers stay nullable. DTO spec added. |
| WR-05 | fixed | 9ecf191 | `prices[0]` fallback removed; no verifiable one-year entry -> no price. |
| WR-06 | fixed | 9eada2e, e1dd996 | `ModuleGuard` stores `request.moduleAccessLevel`; `?refresh` is ignored unless MANAGE. Refresh buttons hidden for non-managers. |
| IN-01 | skipped | - | Out of scope (needs grace-period design for `openKey` on SUCCESS). |
| IN-02 | skipped | - | Out of scope (needs a migration with a partial unique index). |
| IN-03 | fixed | cc1c83a | Post-POST write wrapped: logs order id and job number, falls back to UNKNOWN (never DRAFT/FAILED), returns an UNKNOWN view instead of 500; if that fails too the row stays SUBMITTING and becomes UNKNOWN after 2 minutes. |
| IN-04 | skipped | - | Out of scope (needs a migration). |
## Critical Issues
### CR-01: HTTP 5xx with an AutoDNS envelope is classified as definitive rejection, so the order is FAILED and the domain key is freed (double-registration path)
**File:** `apps/api/src/domains/autodns-client.ts:150-155`, used at `apps/api/src/domains/domains-orders.service.ts:546-556` and `:718`
**Issue:** `failureKindForStatus` returns `'business'` for every non-2xx status other than 401, 403 and 429 whenever the body looks like an envelope. The envelope test is loose: `'messages' in parsed` is enough. `autodns-client.spec.ts:187` pins `500 + envelope -> business` as intended.
`outcomeOfSubmit` maps `business` to `FAILED` with `openKey: null`. A 500, 502, 503, 504 or 408 with a JSON envelope therefore marks the order as not placed. That is the case the class header says must never be guessed. A gateway or backend that times out after the job was created, but still answers with an AutoDNS-style error envelope, will:
1. save the order as FAILED and free `openKey`, and
2. let the user (or a second user) create and submit a new order for the same domain, which is a second `POST /domain` and a second charge.
The same classification weakens `reconcile`. Line 718 reads `!domain.ok && domain.kind !== 'business'` as "AutoDNS said: domain does not exist". A 500 with an envelope is then taken as "not found" and the order can move toward discardable.
**Fix:** Treat only unambiguous rejections as FAILED. That means HTTP 4xx other than 408 and 425 with an envelope, plus 2xx with `status.type === 'ERROR'`. Everything with `httpStatus >= 500` becomes UNKNOWN. In `parseAutodnsEnvelope`, return `'http'` for a non-2xx status that is 408, 425 or 5xx, regardless of the envelope:
```ts
function failureKindForStatus(httpStatus: number, hasEnvelope: boolean): AutodnsFailureKind {
if (httpStatus === 401) return 'auth';
if (httpStatus === 403) return 'forbidden';
if (httpStatus === 429) return 'rate-limit';
if (httpStatus >= 500 || httpStatus === 408 || httpStatus === 425) return 'http';
return hasEnvelope ? 'business' : 'http';
}
```
In `outcomeOfSubmit`, also keep `rate-limit` out of the definitive-FAILED group unless it is known to come from AutoDNS. Update `autodns-client.spec.ts:187` (`[500, 'business']` becomes `[500, 'http']`) and add an order-service test: submit answered with `500 + envelope` must end in UNKNOWN with `openKey` kept.
## Warnings
### WR-01: "Discard" of an UNKNOWN order relies on a check of arbitrary age
**File:** `apps/api/src/domains/domains-orders.service.ts:754-767`, `apps/web/src/components/domains/order-status.ts:53-55`
**Issue:** After one reconcile that found nothing, `lastCheckedAt` is set. From then on the order is discardable forever. The web client also stops polling it, because `isOpen` is false once `lastCheckedAt` is set. Registrar jobs can be delayed, so a check that was true ten minutes ago may no longer be true. A later discard frees `openKey`, and a re-order then runs a second `POST /domain` while the first can still complete.
**Fix:** In `cancelOrder`, require a recent check. For example, reject with `checkFirst` when `lastCheckedAt` is older than 5 minutes. Better, run `reconcile` inside `cancelOrder` and discard only if it still finds nothing.
### WR-02: Registration is claimed with a payload the user did not confirm (lost update on a shared DRAFT)
**File:** `apps/api/src/domains/domains-orders.service.ts:386-393` and `:466-488`
**Issue:** `createOrder` for an existing DRAFT overwrites `payload` (contacts, name servers) with `updateMany`. `submitOrder` reads the payload first and claims afterwards, with no version check. If manager B re-runs "Show summary" for the same domain after manager A has seen the summary, A confirms and registers with B's owner contact and name servers. The same happens if B's update lands between A's read and A's claim.
**Fix:** Return `order.updatedAt` or a payload hash in the summary. The client sends it back on submit, and the claim becomes `where: { id, tenantId, status: 'DRAFT', environment, updatedAt: before.updatedAt }`. Alternatively refuse to overwrite a DRAFT that another user created.
### WR-03: Job matching in `reconcile` can silently miss an existing job and then allow discard
**File:** `apps/api/src/domains/domains-orders.service.ts:727-742`, `apps/api/src/domains/autodns-parse.ts:287-306`
**Issue:** Candidates need `j.object === row.domainName && j.created && created >= since`. If AutoDNS returns a matching job with no readable `created` (the parser also falls back to `started` and `added`), or with the object in another form, the job is dropped. The result is "nothing found", `lastCheckedAt` is set, and the order becomes discardable although a job exists. The `filters` key `object` is also unverified against the live API.
**Fix:** Fail towards caution. If a job for the domain exists but cannot be dated, treat it as a match, or leave `lastCheckedAt` unset and do not make the order discardable.
### WR-04: `@IsOptional()` lets `null` through, which causes 500 instead of 400
**File:** `apps/api/src/domains/dto/domains-settings.dto.ts:25-69`, `apps/api/src/domains/domains-settings.service.ts:194-203`
**Issue:** `@IsOptional()` skips validation for `null` as well as `undefined`. Sending `{"demoUser": null}`, `{"defaultNameServers": null}` or `{"environment": null}` passes validation. The service only checks `!== undefined` and then calls `.trim()` or `.map()` on `null`, which is a TypeError, or writes `null` into a non-null column. This needs a manager account, but it is a bug and not a validation response.
**Fix:** Use `@ValidateIf((_o, v) => v !== undefined)` in place of `@IsOptional()` for these fields. Alternatively check `!= null` in the service.
### WR-05: Price shown for one year can actually be another period's price
**File:** `apps/api/src/domains/autodns-parse.ts:195-196`
**Issue:** `price = parsePriceEntry(oneYear) ?? parsePriceEntry(prices[0])`. If no entry matches one year, the first entry is used and then displayed as the price for 1 year in the confirmation of a binding order. The fallback could be a multi-year or transfer price.
**Fix:** Drop the `prices[0]` fallback and return `null`. The UI already says "price unknown" for `null`.
### WR-06: Any USE user can force full AutoDNS re-reads with `?refresh=1`
**File:** `apps/api/src/domains/domains.controller.ts:105-129`, `apps/api/src/domains/domains-directory.service.ts:160-197`
**Issue:** `GET contacts?refresh=1` and `GET domains?refresh=1` skip the cache for read-only users. Each call is up to 20 sequential POSTs on the shared 3 requests/second limiter, in addition to any refresh cycle. A read-only user can starve order submission and reconcile, and since the limiter is shared per process, that affects the whole instance.
**Fix:** Honour `refresh` only for managers. Add `@ModuleManage` on a separate refresh route, or ignore the flag without MANAGE. A minimum interval per tenant (for example 10 seconds) also helps.
## Info
### IN-01: SUCCESS keeps `openKey` for good
**File:** `apps/api/prisma/migrations/20261008120000_domains_autodns/migration.sql` (header comment), `apps/api/src/domains/domains-orders.service.ts:362`
**Issue:** A registered domain that is later deleted or expires can never be ordered again through Tessera in that environment. The user sees `orderOpen` for good.
**Fix:** Clear `openKey` on SUCCESS after a grace period (for example 24 hours), or let `createOrder` ignore SUCCESS rows older than that.
### IN-02: "Own company" is not enforced in the database
**File:** `apps/api/src/domains/domains-directory.service.ts:286-311`
**Issue:** Two concurrent `createCustomer` calls with `isOwnCompany: true` can both succeed, leaving two own companies. The register tab then picks the first one.
**Fix:** Add a partial unique index: `CREATE UNIQUE INDEX ... ON "DomainsCustomer"("tenantId") WHERE "isOwnCompany"`.
### IN-03: Failure of the final status write is not handled
**File:** `apps/api/src/domains/domains-orders.service.ts:522-533`
**Issue:** If the database write after `POST /domain` throws, the user gets a 500 although the order may have been placed. The state is still safe (SUBMITTING becomes UNKNOWN after 2 minutes). The error is neither logged with the order id nor turned into a clear UNKNOWN answer.
**Fix:** Wrap the write, log the order id, and return an UNKNOWN view or a dedicated error code. The web client already locks the button.
### IN-04: Redundant index
**File:** `apps/api/prisma/migrations/20261008120000_domains_autodns/migration.sql` (`DomainsConfig_tenantId_idx`)
**Issue:** `DomainsConfig_tenantId_key` already covers `tenantId`, so the extra index is redundant.
**Fix:** Remove `@@index([tenantId])` on `DomainsConfig` in a later migration.
---
_Reviewed: 2026-10-08_
_Reviewer: Claude (gsd-code-reviewer)_
_Depth: quick (money-safety path traced in full)_
@@ -0,0 +1,172 @@
---
phase: quick-261008-dts
plan: 01
subsystem: domains
tags: [autodns, domains, nestjs, nextjs, prisma, rls, money-safety]
status: complete
requires:
- module-registry (ModuleGuard, UseModule, ModuleManage)
- crypto (CryptoService, global)
- prisma tenant extension (forTenant)
provides:
- Modul Domains (Slug domains) mit AutoDNS-Anbindung Demo/Live
- Kunden, Kontakt-Zuordnung, Domainliste, Verfügbarkeit, Registrierung, Aufträge
affects:
- docs/mandantentrennung-zugriffsklassifikation.md
- CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md
tech-stack:
added: []
patterns:
- atomarer Anspruch (updateMany where status DRAFT, count === 1) vor dem Netzaufruf
- ein Client für alle AutoDNS-Aufrufe (feste Hosts, Takt 350 ms, kein Retry)
- Pull-Abgleich offener Aufträge statt Cron
key-files:
created:
- apps/api/src/domains/autodns-client.ts
- apps/api/src/domains/autodns-parse.ts
- apps/api/src/domains/domain-name.ts
- apps/api/src/domains/domains-settings.service.ts
- apps/api/src/domains/domains-directory.service.ts
- apps/api/src/domains/domains-orders.service.ts
- apps/api/prisma/migrations/20261008120000_domains_autodns/migration.sql
- apps/web/src/app/(portal)/modules/domains/page.tsx
- apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx
- apps/web/src/app/(portal)/modules/domains/components/OrdersTab.tsx
modified:
- apps/api/src/domains/domains.controller.ts
- CHANGELOG.md
decisions:
- "Aufträge des nicht aktiven Systems werden beim Abgleich nicht angefasst (Zugangsdaten gehören zum aktiven System); nur der SUBMITTING-zu-UNKNOWN-Übergang gilt für alle."
- "Beim Abschicken wird der Entwurf VOR dem Anspruch gelesen, damit genau der bestätigte Inhalt hinausgeht; der Zugang wird vor dem Anspruch geladen, damit ein fehlender Zugang nie einen Auftrag in SUBMITTING stehen lässt."
- "HTTP-Antwort ohne Umschlag (Gateway 502/504) zählt als UNKNOWN, nicht als FAILED; FAILED nur bei ausdrücklicher AutoDNS-Ablehnung (auth, forbidden, rate-limit, business)."
- "UNKNOWN wird beim Abschicken ohne lastCheckedAt gespeichert; erst ein Abgleich mit Ergebnis setzt es und schaltet das Verwerfen frei. Ein Fehler beim Nachsehen setzt es nicht."
- "Auftragssuche beim Abgleich nimmt nur Aufträge mit lesbarer Anlagezeit ab 5 Minuten vor der Bestätigung; ohne Zeit zählt ein Auftrag nicht (lieber UNKNOWN als ein falscher Treffer)."
metrics:
tasks: 3
completed: 2026-10-08
actuals:
tokens: 109600
tasks: 3
commits: 3
plan_head_before: 6b0f840c1cf1ad09d593e9f607b5230541fdfb2b
plan_head_after: d332246bdead2b5ec87156dd610945687c672016
---
# Phase quick-261008-dts Plan 01: Modul Domains (AutoDNS) Summary
Neues Modul „Domains“ mit verschlüsselter Demo-/Live-Anbindung an AutoDNS, Kunden- und Kontaktzuordnung, Domainliste und einer Registrierung, die pro Auftrag höchstens einmal an AutoDNS geht und bei unklarem Ausgang auf „Ergebnis ungeklärt“ stehen bleibt, statt zu raten.
## Commits
| Aufgabe | Commit | Inhalt |
|---|---|---|
| 1 (Tracer) | 1f1c984 | Migration (4 Tabellen mit Zeilenschutz), AutoDNS-Client, Einstellungen, Verbindungstest, Modulseite mit Reiter Einstellungen, Registrierungen (Loader, Icon, Navigationstitel) |
| 2 | 450218f | Kunden, Kontakte live aus AutoDNS (Seiten, 60-s-Zwischenspeicher), Zuordnung, Kontakt anlegen, Domainliste, Reiter Domains/Kontakte/Kunden |
| 3 | d332246 | Verfügbarkeit, Entwurf, verbindliches Abschicken mit atomarem Anspruch, Auftragsverfolgung und Abgleich, Reiter Registrieren/Aufträge, Changelog, Anwender- und Administrationshandbuch, Mandantenschutz-Inventar |
Nichts gepusht. Docs-Artefakte (PLAN, RESEARCH, SUMMARY, STATE) sind nicht committet.
## Aufgabe 3 im Detail (Geldsicherheit)
- **Anspruch vor dem Netz:** `submitOrder` führt `updateMany` mit `id, tenantId, status: 'DRAFT', environment: <aktiv>` aus. Nur bei `count === 1` geht genau ein `POST /domain` (ohne Query, ohne Wiederholung) hinaus. Wer `count !== 1` bekommt, lädt die Zeile neu und erhält 404, 409 `environmentChanged` oder 409 `alreadySubmitted`.
- **Ausgang:** Auftragsnummer vorhanden gleich SUBMITTED. Ausdrückliche AutoDNS-Ablehnung (auth, forbidden, rate-limit, business, auch HTTP 200 mit `status.type` ERROR) gleich FAILED mit `openKey` null. Zeitablauf, Netz, TLS, unlesbare oder umschlaglose Antwort gleich UNKNOWN, nie zurück auf DRAFT.
- **Eindeutigkeit:** `openKey` (Domain) mit `@@unique([tenantId, environment, openKey])`; FAILED und CANCELED geben ihn frei, SUCCESS behält ihn bewusst.
- **Abgleich:** Pull beim Öffnen des Reiters „Aufträge“, bei „Aktualisieren“ und alle 30 s bei offenen Aufträgen und sichtbarer Seite; höchstens 20 Aufträge je Lauf, am längsten ungeprüfte zuerst; ein Fehler bei einem Auftrag hält die anderen nicht auf. SUBMITTING älter als 120 s wird UNKNOWN. UNKNOWN: erst `GET /domain/{name}`, dann `POST /job/_search`.
- **Verwerfen:** Entwurf jederzeit; UNKNOWN erst nach einem Abgleich mit Ergebnis (`lastCheckedAt` gesetzt), im Browser mit zusätzlicher Warnung.
- **Browser:** `useRef`-Sperre gegen Doppelklick; nach einem Fehler beim Abschicken bleibt der Knopf gesperrt, bis „Zurück“ gewählt wird (der Auftrag könnte angekommen sein).
## Messwerte der Prüfkette (Aufgabe 3)
| Segment | Ergebnis |
|---|---|
| `pnpm --filter @tessera/api test` | 133 Dateien, 2423 Tests grün |
| `pnpm --filter @tessera/web test` | 132 Dateien, 1455 Tests grün |
| `tsc --noEmit` api und web | grün |
| `biome check` (alle angefassten Dateien) | grün |
| rls-coverage, rls-access-inventory | 35 Tests grün (domains: 0/30/0, Paare 104) |
| Rollen-Decorator im Controller | keiner |
| de/en-Schlüsselgleichheit `domains.*`, Wortprüfung (Mandant, Lizenz) | 207 Schlüssel, gleich, ohne Treffer |
| CHANGELOG-/Handbuch-Greps | ok |
| Neuaufbau `docker compose up -d --build api web` | api, web, db laufen; „Domains module seeded in registry“; Routen `orders/refresh` vor `orders/:id/submit`; keine ausstehenden Migrationen; `GET customers` und `GET orders` geben `[]`; `POST availability` ohne Zugang gibt 409 `notConfigured` |
| Gegenprobe Doppelbestellung | Anspruchsprüfung testweise ausgebaut: Test „zwei gleichzeitige Bestätigungen“ und Test „Systemwechsel zwischen Lesen und Anspruch“ schlugen fehl; danach zurückgebaut |
## Abweichungen vom Plan
### Automatisch behoben und ergänzt
**1. [Regel 2 - Fehlende Absicherung] Kontakt-Prüfung beim Entwurf**
- **Ort:** `createOrder`
- **Problem:** Ohne Prüfung könnte ein Entwurf Kontaktnummern enthalten, die bei AutoDNS nicht (mehr) existieren.
- **Lösung:** Alle vier Kontaktnummern werden gegen die Kontaktliste geprüft (`findContactsByIds`), sonst 400 `contactNotFound`. Grenze: bei mehr als 2000 Kontakten kann ein vorhandener Kontakt außerhalb der gelesenen Liste fälschlich abgelehnt werden; die Meldung rät zum Neu-Einlesen.
**2. [Regel 3 - Blockierend] Zeitlimit im Test einstellbar**
- `DomainsSettingsService.transport` bekam das Feld `timeoutMs` (nur für Tests), damit der Zeitablauf-Fall mit 20 ms getestet werden kann; im Betrieb bleibt es leer (20 s). `HOSTNAME_PATTERN` wird exportiert und im Auftragsdienst wiederverwendet.
**3. [Regel 1 - Fehler im eigenen Entwurf] Zusätzliche Absicherungen im Abgleich**
- Ein Fehler beim Nachsehen (Anmeldung, Zeitablauf, Gateway) setzt `lastCheckedAt` nicht, damit „Verwerfen“ nie auf einer Prüfung beruht, die nichts geprüft hat. Nur eine ausdrückliche AutoDNS-Antwort „gibt es nicht“ (Fehlerart business) zählt als Ergebnis.
- Alle Schreibzugriffe des Abgleichs sind an den erwarteten Zustand gebunden (`status` in der `where`-Klausel), damit zwei gleichzeitige Abgleiche sich nicht überschreiben.
**4. [Hinweis] Reiter Aufträge für alle, auch ohne Einrichtung**
- Der Reiter ist immer sichtbar (Liste kommt aus der Datenbank); der Abgleich überspringt ihn still, wenn das aktive System nicht eingerichtet ist.
**5. [Aufräumen] Versehentliche Fremdformatierung rückgängig gemacht**
- Ein Formatierungslauf hatte `module-manage-handlers.spec.ts` in unbeteiligten Zeilen umgebrochen; die Datei wurde zurückgesetzt und nur um die sechs Handlernamen ergänzt.
### Nicht behoben (vorbestehend)
Keine fehlschlagenden Tests. `biome organizeImports` meldet in `module-manage-handlers.spec.ts` eine bereits vor dieser Arbeit vorhandene Importreihenfolge; bewusst nicht angefasst.
## Threat-Status
| ID | Stand |
|---|---|
| T-dts-01 Passwort | umgesetzt (Aufgabe 1); Antworten tragen nur `hasPassword` |
| T-dts-02 Rechte | alle Schreib- und Verwalten-Wege tragen `@ModuleManage('domains')`, kein Rollen-Decorator; Controller- und Handler-Spec grün |
| T-dts-03 Doppelbestellung | umgesetzt und mit Gegenprobe belegt (siehe oben) |
| T-dts-04 SSRF | feste Hosts, `redirect: 'error'`, Pfadprüfung |
| T-dts-05 Organisationstrennung | `forTenant` überall, RLS-Gates grün |
| T-dts-06 Abfragegrenze | Takt 350 ms, höchstens 20 Aufträge je Abgleich, Verbindungstest ein Aufruf |
| T-dts-07 Demo/Live | System in Anspruch und Zuordnung, Wechsel nur mit Bestätigung |
| T-dts-08 Routen-Reihenfolge | `orders/refresh` vor `orders/:id/...`, Spec prüft |
| T-dts-09 Eingaben | `normalizeDomainName`, Validatoren, `ParseUUIDPipe` |
| T-dts-10 Fehlertexte | nur `messages[].text`, gekürzt; AutoDNS 401/403 als 502 |
## Bekannte Stubs
Keine. Die Anzeige „Preis nicht ermittelbar“ ist gewollt (AutoDNS nennt keinen Preis).
## Prüfung gegen AutoDNS-Demo
Voraussetzung: Demo-Zugangsdaten eintragen (Domains, Einstellungen, Zugang Demo-System, Benutzername, Passwort, Kontext). System bleibt auf „Demo-System“. Jeder Punkt schließt eine offene Annahme aus der Recherche.
| Nr. | Annahme | Prüfung in der Oberfläche | Wenn es abweicht |
|---|---|---|---|
| 1 | A1: Welcher Demo-Kontext funktioniert (1 oder 4) | Einstellungen, Zugang Demo-System: Kontext 4 eintragen, speichern, „Verbindung testen“; bei Fehler 1 versuchen. Je Versuch genau einmal klicken (Sperrgefahr) | Wert merken und in die Handbücher übernehmen |
| 2 | A6/A9: Anmeldung ohne Zwei-Faktor, Test gelingt | „Verbindung testen“ zeigt „Verbindung erfolgreich“ | bei Anmeldefehler Benutzer ohne 2FA verwenden |
| 3 | A2: Kontaktfelder und Telefonformat | Reiter Kontakte, „Neuer Kontakt“: erst Person, dann Organisation anlegen (Telefon +49 30 123456). Meldung „Der Kontakt wurde bei AutoDNS angelegt“, Kontakt erscheint nach „Aus AutoDNS neu einlesen“ | Fehlermeldung von AutoDNS lesen (steht im Formular); Pflichtfelder oder Telefonformat anpassen |
| 4 | A3: Verfügbarkeitsprüfung liefert genau die gefragte Domain mit WHOIS-Status und Preis | Reiter Registrieren: eine sicher freie Domain (zufälliger Name) und eine belegte (zum Beispiel denic.de) prüfen. Frei zeigt „… ist frei“ mit Preis; belegt zeigt „nicht frei“ mit Status. Außerdem eine Umlautdomain prüfen | Steht immer „nicht frei“ mit Status „NO_RESULT“, liefert DomainStudio den Namen anders (Suchwort, Endung) und `parseDomainStudio` oder die Anfrage ist anzupassen. Fehlt nur der Preis, erscheint „Preis nicht ermittelbar“: Preisform prüfen |
| 5 | A4/A5: Registrierung liefert Auftragsnummer, `{ id }` genügt als Kontaktverweis | Zusammenfassung erstellen, Häkchen setzen, „Jetzt verbindlich registrieren“. Erwartet: „wird bearbeitet“, Reiter Aufträge zeigt „In Bearbeitung“ | „Fehlgeschlagen“ mit Text von AutoDNS: Text lesen (Kontaktverweis, Nameserver, Pflichtfelder). Steht ein Auftrag ohne Nummer auf „In Bearbeitung“ und ändert sich nie, ist die Fundstelle der Nummer anders (`extractJobFromSubmit`) |
| 6 | Auftrag erreicht SUCCESS, Tessera folgt | Reiter Aufträge, „Aktualisieren“ (oder 30 s warten): Stand wechselt auf „Registriert“ | bleibt er „In Bearbeitung“, in AutoDNS den Auftrag ansehen; „Rückfrage nötig“ heißt Status SUPPORT |
| 7 | D-J: Domainliste mit Inhaber, Ablaufdatum, `registryStatus` | Reiter Domains, „Aus AutoDNS neu laden“: die neue Domain erscheint mit Inhaber, Ablaufdatum und Status (Aktiv, In Bearbeitung …). Kunde nach Zuordnung des Inhaber-Kontakts | Status als Rohwert angezeigt: `registryStatus` hat andere Werte, Beschriftungen nachziehen. Ablaufdatum „–“: Feld `expire` fehlt in der Antwort |
| 8 | A8: `.de` mit Standard-Nameservern besteht die Nameserver-Prüfung | Standard-Nameserver in den Einstellungen eintragen (bei AutoDNS eingerichtete), eine freie `.de`-Domain registrieren | „Fehlgeschlagen“ mit Nameserver-Meldung: Nameserver einrichten oder korrigieren |
| 9 | Neu in Aufgabe 3: Auftragssuche (`POST /job/_search`, Filter `object`, Anlagezeit) und `GET /domain/{name}` für nicht Gefundenes | Schwer künstlich auszulösen. Wenn ein Auftrag je auf „Ergebnis ungeklärt“ steht: „Aktualisieren“ klicken. Erwartet: Tessera findet die Domain (Registriert), einen Auftrag (In Bearbeitung) oder setzt die Prüfung (dann ist „Verwerfen“ möglich) | Bleibt „Verwerfen“ trotz Abgleich gesperrt, liefert `GET /domain/{name}` für Unbekanntes keinen business-Fehler; Antwortform prüfen |
| 10 | Neu in Aufgabe 3: Umlautdomain wird als Punycode gesucht und bestellt | Umlautdomain prüfen; Anzeige zeigt die Umlautform | DomainStudio erwartet ggf. die Unicode-Form im Suchwort |
| 11 | D-J: Wert für „Kündigung vorgemerkt“ | Eine Domain mit vorgemerkter Kündigung in der Liste ansehen (falls vorhanden) | Werte von `cancelationStatus` prüfen (NONE und NOT_SET gelten als „keine“) |
## Browser-Prüfschritte für den Orchestrator (dunkler Modus bevorzugt)
Lokal: `http://localhost:3000`, Anmeldung `admin` / `admin123`. Playwright-Prüfung mit Theme-Knopf auf dunkel.
1. **Aktivieren:** Marktplatz, Modul „Domains“ aktivieren. Erwartet: Modul in der Seitenleiste (Gruppe Domains), Erde-Symbol.
2. **Freigabe Benutzen gegen Verwalten:** Mit einem normalen Benutzer mit „Benutzen“ anmelden: Reiter Domains, Kontakte, Kunden, Aufträge sichtbar; kein Registrieren, kein Einstellungen, kein „Neuer Kontakt“, keine Zuordnung, keine Kunden-Knöpfe. Mit Admin oder „Verwalten“: zusätzlich Registrieren und Einstellungen.
3. **Einstellungen:** Ohne Zugang zeigt die Kopfzeile „AutoDNS nicht eingerichtet“ mit Hinweis und Knopf „Zu den Einstellungen“. Passwort eintragen, speichern, Seite neu laden: Passwortfeld leer mit Platzhalter „Gespeichert – leer lassen …“. Live-Kontext ist mit 4 vorbelegt. Auswahl „Live-System“: Bestätigungsfeld erscheint; „Abbrechen“ stellt Demo zurück; erst „Live-System verwenden“ speichert, Kopfzeile zeigt dann „Live-System – Registrierungen kosten Geld“. Wieder auf Demo zurückstellen. Nameserver (zwei) eintragen und speichern.
4. **Kontakte:** Liste, Suche, Filter „Nicht zugeordnet“, „Nach Kunde gruppieren“; Kontakte ankreuzen, Kunden wählen, „Zuordnen“; „Zuordnung entfernen“; „Neuer Kontakt“ mit ungültiger E-Mail und mit Telefon ohne „+“ (Fehler sichtbar), dann gültig speichern.
5. **Kunden:** Kunden anlegen, einen als „Eigene Firma“ markieren (Kennzeichnung erscheint, bei einem zweiten wandert sie); Kunden mit Kontakten löschen: Meldung „Diesem Kunden sind noch Kontakte zugeordnet …“.
6. **Domains:** Liste, Gruppen pro Kunde mit „Nicht zugeordnet“ zuletzt, Kundenfilter blendet andere Gruppen aus, Datum als TT.MM.JJJJ.
7. **Registrieren:** Ungültiger Name zeigt Meldung; freie und belegte Domain; Kontaktauswahl gruppiert nach Kunde, Voreinstellung Admin gleich Inhaber, Technik/Zone gleich eigene Firma; Nameserver hinzufügen/entfernen (2 bis 6); „Zusammenfassung anzeigen“ zeigt System-Kennzeichnung, Preis oder „Preis nicht ermittelbar“; „Jetzt verbindlich registrieren“ bleibt gesperrt bis zum Häkchen; **Doppelklick auf den Knopf** darf nur einen Auftrag erzeugen (Reiter Aufträge zeigt eine Zeile, Server-Log zeigt ein einziges „Registrierung abgeschickt“). Vorsicht: Mit Demo-Zugang ohne echte Daten antwortet AutoDNS mit einem Fehler, die Zeile steht dann auf „Fehlgeschlagen“ oder „Ergebnis ungeklärt“; beides ist ein gültiger Ausgang.
8. **Aufträge:** Reiter zeigt Zeilen mit System (Demo/Live), Stand, Bestätigt von/am, Fehlertext; „Aktualisieren“; „Verwerfen“ nur für Verwalter bei Entwurf und geprüftem unklarem Auftrag, mit Rückfrage.
## Self-Check: PASSED
- Dateien vorhanden: domain-name.ts, domains-orders.service.ts, dto/domains-order.dto.ts, RegisterTab.tsx, OrdersTab.tsx, order-status.ts (alle von `git status` als hinzugefügt bestätigt).
- Commits vorhanden: 1f1c984, 450218f, d332246 liegen in der Historie von HEAD (`git rev-list --count 6b0f840..HEAD` gleich 3).
@@ -0,0 +1,131 @@
---
phase: quick-261008-dts
verified: 2026-10-08T11:25:00Z
status: human_needed
score: 7/7 must-haves verified
covered_files: [".planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/261008-dts-PLAN.md",".planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/261008-dts-SUMMARY.md","CHANGELOG.md","apps/api/prisma/migrations/20261008120000_domains_autodns/migration.sql","apps/api/prisma/schema.prisma","apps/api/src/app.module.ts","apps/api/src/domains/autodns-client.spec.ts","apps/api/src/domains/autodns-client.ts","apps/api/src/domains/autodns-parse.spec.ts","apps/api/src/domains/autodns-parse.ts","apps/api/src/domains/domain-name.spec.ts","apps/api/src/domains/domain-name.ts","apps/api/src/domains/domains-cache.ts","apps/api/src/domains/domains-directory.service.spec.ts","apps/api/src/domains/domains-directory.service.ts","apps/api/src/domains/domains-orders.service.spec.ts","apps/api/src/domains/domains-orders.service.ts","apps/api/src/domains/domains-settings.service.spec.ts","apps/api/src/domains/domains-settings.service.ts","apps/api/src/domains/domains.controller.spec.ts","apps/api/src/domains/domains.controller.ts","apps/api/src/domains/domains.module.ts","apps/api/src/domains/domains.seed.ts","apps/api/src/domains/domains.types.ts","apps/api/src/domains/dto/domains-contact.dto.ts","apps/api/src/domains/dto/domains-customer.dto.ts","apps/api/src/domains/dto/domains-order.dto.ts","apps/api/src/domains/dto/domains-settings.dto.ts","apps/api/src/module-registry/module-manage-handlers.spec.ts","apps/web/src/app/(portal)/modules/domains/components/ContactForm.test.tsx","apps/web/src/app/(portal)/modules/domains/components/ContactForm.tsx","apps/web/src/app/(portal)/modules/domains/components/ContactsTab.tsx","apps/web/src/app/(portal)/modules/domains/components/CustomersTab.tsx","apps/web/src/app/(portal)/modules/domains/components/DomainsTab.tsx","apps/web/src/app/(portal)/modules/domains/components/EnvironmentBadge.tsx","apps/web/src/app/(portal)/modules/domains/components/OrdersTab.test.tsx","apps/web/src/app/(portal)/modules/domains/components/OrdersTab.tsx","apps/web/src/app/(portal)/modules/domains/components/RegisterTab.test.tsx","apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx","apps/web/src/app/(portal)/modules/domains/components/SettingsTab.tsx","apps/web/src/app/(portal)/modules/domains/domains-page.test.tsx","apps/web/src/app/(portal)/modules/domains/layout.tsx","apps/web/src/app/(portal)/modules/domains/page.tsx","apps/web/src/app/(portal)/modules/module-layouts.test.tsx","apps/web/src/components/domains/group-by-customer.test.ts","apps/web/src/components/domains/group-by-customer.ts","apps/web/src/components/domains/order-status.test.ts","apps/web/src/components/domains/order-status.ts","apps/web/src/components/domains/ui-classes.ts","apps/web/src/components/modules/module-tile.tsx","apps/web/src/lib/domains-api.ts","apps/web/src/lib/module-identity.ts","apps/web/src/lib/module-loader.ts","apps/web/src/lib/stores/nav-store.ts","apps/web/src/messages/de.json","apps/web/src/messages/en.json","apps/web/src/messages/umlaut-dictionary.ts","docs/anleitung-administration.md","docs/anleitung-anwender.md","docs/mandantentrennung-zugriffsklassifikation.md"]
covered_digest: "v3:sha256:cbd9180b81679f6e773368c9acac6050b8ea89f07a2ceb0a6717989336115fdf"
behavior_unverified: 0
overrides_applied: 0
human_verification:
- test: "Demo-Kontext und Verbindungstest (A1, A2/A6/A9): Demo-Zugangsdaten eintragen, Kontext 4 (sonst 1), je Versuch genau einmal auf 'Verbindung testen' klicken"
expected: "'Verbindung erfolgreich. AutoDNS hat die Anmeldung bestätigt.' Welcher Demo-Kontext funktioniert, wird in die Handbücher übernommen"
why_human: "Braucht das echte AutoDNS-Demo-System und echte Zugangsdaten; Tests laufen nur gegen einen nachgebauten Aufruf"
- test: "Kontakte anlegen (A2): Person und Organisation mit Telefon +49 30 123456 anlegen, danach 'Aus AutoDNS neu einlesen'"
expected: "Meldung 'Der Kontakt wurde bei AutoDNS angelegt', Kontakt erscheint in der Liste. Feldsatz und Telefonformat werden von AutoDNS angenommen"
why_human: "Die Form des POST /contact-Bodys ist nur gegen die echte API belegbar"
- test: "Verfügbarkeit (A3, Umlaut): freie, belegte (z. B. denic.de) und Umlautdomain prüfen"
expected: "Frei mit Preis, belegt mit WHOIS-Status, Umlautdomain als Punycode gesucht; ein fehlender Preis zeigt 'Preis nicht ermittelbar'"
why_human: "Antwortform von POST /domainstudio (WHOIS-Status, Preisfundstelle) ist nur gegen das echte System prüfbar"
- test: "Registrierung im Demo-System (A4, A5, A8): Zusammenfassung, Häkchen, 'Jetzt verbindlich registrieren' für eine freie .de-Domain mit Standard-Nameservern"
expected: "Auftrag 'In Bearbeitung' mit Auftragsnummer, Kontaktverweis { id } wird akzeptiert, Nameserver-Prüfung besteht. Danach 'Aktualisieren' bis 'Registriert' (Job SUCCESS); Doppelklick erzeugt genau einen Auftrag"
why_human: "POST /domain ist asynchron; Job-Fundstelle, Kontaktverweis und Nameserver-Prüfung sind nur am Demo-System belegbar"
- test: "Domainliste nach Registrierung (D-J) und Abgleich unklarer Aufträge"
expected: "Domain erscheint mit Inhaber, Ablaufdatum und Status (registryStatus vorhanden); GET /domain/{name} liefert für Unbekanntes einen business-Fehler, damit 'Verwerfen' nach einem Abgleich freigeschaltet wird"
why_human: "registryStatus-Werte, cancelationStatus-Werte und die Antwort auf unbekannte Domains sind Annahmen über die echte API"
- test: "Rechte im Browser (optional, dunkler Modus): mit einem Benutzer nur mit 'Benutzen' anmelden"
expected: "Reiter Domains, Kontakte, Kunden, Aufträge sichtbar; kein Registrieren, kein Einstellungen, keine Schreibknöpfe; die API antwortet bei Schreibrouten 403"
why_human: "Die API-Metadaten und Oberflächen-Tests sind grün; ein echter Benutzen-Benutzer wurde in dieser Prüfung nicht angelegt (kein Eingriff in lokale Benutzer)"
---
# Quick-Task 261008-dts: Modul Domains (AutoDNS) Verification Report
**Task Goal:** Neues Modul "Domains" mit Anbindung an AutoDNS/InterNetX JSON-API: Einstellungen (verschlüsselter Zugang, Demo/Live, Kontext, Standard-Nameserver, Verbindungstest), Kontakte (lesen, anlegen, Kunden zuordnen, filtern/gruppieren), Domain registrieren (Verfügbarkeit, Kontakte, Nameserver, Zusammenfassung + ausdrückliche Bestätigung, keine Doppelbestellung, Job-Status), Domainliste. Ansehen mit "Benutzen", Ändern mit "Verwalten" oder Admin. Tests mit gemockter API. UI Deutsch, siezen, keine Mandant-Begriffe. CHANGELOG + Anleitungen.
**Verified:** 2026-10-08
**Status:** human_needed
**Re-verification:** No, initial verification
Alle sieben Wahrheiten sind im Code belegt. Es gibt keine Lücken. Offen sind nur Prüfungen, die das echte AutoDNS-Demo-System brauchen (gemäß Auftrag human_needed, keine Lücke).
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Reiter je Recht; API antwortet auf Schreib-/Verwalten-Routen mit 403 für Benutzen | VERIFIED | `domains.controller.ts`: Klasse `@UseModule('domains')`; 12 Handler mit `@ModuleManage('domains')` (GET/PUT settings, connection-test, POST customers, PUT/DELETE customers/:id, POST contacts, contacts/assign, availability, orders, orders/:id/submit, orders/:id/cancel); GET status/customers/contacts/domains/orders und POST orders/refresh nur Klasse. Kein `@Roles(` (grep leer). `domains.controller.spec.ts` und `module-manage-handlers.spec.ts` grün (Metadaten, Deklarationsreihenfolge). `page.tsx`: `useCanManageModule('domains') === true` steuert Registrieren/Einstellungen; Reihenfolge Domains, Kontakte, Kunden, Registrieren, Aufträge, Einstellungen. Live-Stack: Admin erhält 200 auf alle Benutzen-Routen. |
| 2 | Zugang je System verschlüsselt, Demo/Live mit Bestätigung, ein GET /hello | VERIFIED | Migration/Schema: getrennte Spalten `demo*`/`live*`. `domains-settings.service.ts`: `crypto.encrypt` beim Speichern, leeres Passwort behält Wert, Antwort nur `hasPassword`, Entschlüsselungsfehler wirft `InternalServerErrorException`. Live-Stack: `GET /settings` ohne Passwortfeld; `PUT {environment:LIVE}` ohne `confirmLive` gibt 400 `confirmLiveRequired`. `testConnection` genau ein `autodnsRequest(GET /hello)`; Spec prüft literalen Basic-Header `YXBpLXVzZXI6Z2VoZWlt`, URL, Kontext-Header. Neuer Stand startet auf DEMO. Verbindungstest ohne Zugang: `{ok:false,kind:'not-configured'}` (live bestätigt). |
| 3 | Kontakte: live aus AutoDNS, "Nicht zugeordnet", neu einlesen, anlegen, zuordnen, eigene Firma, Filter/Gruppierung | VERIFIED | `domains-directory.service.ts`: `fetchAll` seitenweise (100, max 2000, sequentiell), 60-s-`TtlCache`, Zuordnung je aktivem System, `createContact`, `assignContacts`, `isOwnCompany` löst Markierung bei anderen. Web: `ContactsTab`, `ContactForm`, `CustomersTab`, `group-by-customer.ts`. 27 Directory-Tests, ContactForm-/Gruppierungs-/Seitentests grün. Live: `GET contacts` ohne Zugang gibt 409 `notConfigured`. Echte AutoDNS-Antwort siehe Human-Punkte. |
| 4 | Domainliste mit Kunde (über Inhaber-Kontakt), Inhaber, Ablauf, Status | VERIFIED | `listDomains` nutzt `POST /domain/_search?keys[]=expire&keys[]=ownerc`, Inhabername und Kunde über Zuordnung des aktiven Systems, `cancelationPending`. `DomainsTab.tsx` mit Filter/Gruppierung; Seitentest prüft Gruppen, "Nicht zugeordnet" zuletzt, dd.mm.yyyy. |
| 5 | Registrieren: DomainStudio FREE, vier Kontakte, 2-6 Nameserver, Zusammenfassung, ausdrückliche Bestätigung, höchstens ein POST /domain, 409 bei Zweitbestätigung/Systemwechsel, UNKNOWN statt Raten | VERIFIED | `domains-orders.service.ts` `submitOrder` (Zeilen 456-534): Zugang vorab, Lesen, dann `updateMany where {id, tenantId, status:'DRAFT', environment}`; bei `count !== 1` kein Netzaufruf, 404/409 `environmentChanged`/`alreadySubmitted`; genau ein `callRaw('POST','/domain')` ohne Query und ohne Schleife; Ausgang: Job → SUBMITTED, auth/forbidden/rate-limit/business → FAILED (openKey null), alles andere (Zeitablauf, Netz, TLS, umschlaglos) → UNKNOWN, nie zurück auf DRAFT. `createOrder` prüft Verfügbarkeit serverseitig neu (`notAvailable` 409), Nameserver 2-6, Kontakte gegen Liste. Migration: `@@unique(tenantId, environment, openKey)`. Verhaltensbelegt: Test "zwei gleichzeitige Bestätigungen" (Spec 519-545, `Promise.allSettled`, Lese-Barriere, In-Memory-`updateMany` wertet where aus) endet mit einem Erfolg, einem `alreadySubmitted`, genau einem POST; Systemwechsel-Tests ohne Fetch. `RegisterTab.tsx`: `useRef`-Sperre, Häkchen Pflicht. Alle 66 Tests grün. |
| 6 | Auftragsstatus folgt dem Job; unklarer Auftrag erst nach Abgleich verwerfbar | VERIFIED | `refreshOpenOrders` (max 20, älteste Prüfung zuerst, SUBMITTING älter 120 s → UNKNOWN), `refreshFromJob` (SUCCESS/FAILED/CANCELED/Rest bleibt SUBMITTED mit jobStatus), `reconcile` (GET /domain/{name}, dann POST /job/_search; ein Fehler beim Nachsehen setzt `lastCheckedAt` nicht), `cancelOrder` (UNKNOWN ohne `lastCheckedAt` → 409 `checkFirst`). `OrdersTab`: Abgleich beim Öffnen, 30-s-Intervall nur bei offenen Aufträgen. Live: `POST orders/refresh` 201. Reale Job-Antwortform: Human-Punkt. |
| 7 | Ein Client, feste Hosts, 350 ms, 20 s, kein Retry, status.type ERROR = Fehler, AutoDNS-Login als 502 | VERIFIED | `autodns-client.ts`: `AUTODNS_BASE_URLS` Konstante, `redirect:'error'`, `AutodnsRateLimiter(350)`, `AUTODNS_TIMEOUT_MS=20_000`, 5-MiB-Deckel, kein Retry, Pfadprüfung, `status.type==='ERROR'` → business. `domains.types.ts` `autodnsFailureToHttp`: auth/forbidden → `BadGatewayException` `autodnsAuth` (nie 401/403), Zeit/Netz/TLS → 504. Alle Aufrufer (Settings, Directory, Orders) gehen durch `autodnsRequest`. 313-Zeilen-Spec mit literalen Werten grün. |
**Score:** 7/7 Wahrheiten verifiziert (0 present, behavior-unverified; die Verhaltens-Wahrheit 5 ist durch einen benannten, bestehenden Test belegt)
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `apps/api/prisma/migrations/20261008120000_domains_autodns/migration.sql` | 4 Tabellen, 2 Enums, RLS | VERIFIED | Vier Tabellen mit ENABLE + FORCE + `tenant_isolation_policy`; `migrate status` "up to date", `migrate diff --exit-code` "No difference" |
| `apps/api/src/domains/autodns-client.ts` | Client, Parser, Limiter | VERIFIED | Exporte `AUTODNS_BASE_URLS`, `autodnsRequest`, `parseAutodnsEnvelope`, `AutodnsRateLimiter` vorhanden |
| `domains-settings.service.ts` | verschlüsselt, maskiert, Test | VERIFIED | 323 Zeilen, `crypto.encrypt(`, `decryptPassword` laut |
| `domains-directory.service.ts` | Kunden, Listen, Zuordnung | VERIFIED | 585 Zeilen, Rest siehe Wahrheit 3/4 |
| `domains-orders.service.ts` | Anspruch, Abgleich | VERIFIED | 782 Zeilen, Anspruch mit `status: 'DRAFT'` |
| `.../modules/domains/page.tsx` | Badge, 6 Reiter | VERIFIED | `useCanManageModule('domains')` steuert Reiter |
| `.../components/RegisterTab.tsx` | Verfügbarkeit bis Bestätigung | VERIFIED | `submittingRef`, Checkbox, gesperrter Knopf |
### Key Link Verification
| From | To | Via | Status | Details |
|------|----|-----|--------|---------|
| `submitOrder` | `domainsOrder.updateMany` DRAFT, dann `POST /domain` | `claim.count !== 1` vor dem Netz | WIRED | Zeilen 480-511 |
| Settings-Dienst | `CryptoService` | `crypto.encrypt(` je System | WIRED | Zeilen 200-201 |
| `listDomains` | Zuordnung des aktiven Systems | Inhaber-Kontakt-Id → Kunde | WIRED | `loadAssignments(tenantId, environment)` |
| Controller | `ModuleGuard` | `@UseModule('domains')` + `@ModuleManage('domains')` | WIRED | 12 Verwalten-Handler, kein Rollen-Decorator |
| `page.tsx` | `useCanManageModule('domains')` | Reiter/Schreibknöpfe | WIRED | Zeile 36 |
| Modul-Registrierung | Seed, Loader, Icon, Navtitel, Layout-Test, `app.module.ts` | | WIRED | Log "Domains module seeded in registry"; Routen `orders/refresh` vor `orders/:id/submit` |
### Data-Flow Trace (Level 4)
| Artifact | Variable | Source | Real Data | Status |
|----------|----------|--------|-----------|--------|
| `DomainsTab` | Domainliste | `GET /modules/domains/domains` → `POST /domain/_search` (AutoDNS live) | Ja (gegen Mock getestet) | FLOWING |
| `ContactsTab` | Kontakte | `POST /contact/_search` + lokale Zuordnung | Ja | FLOWING |
| `OrdersTab` | Aufträge | `DomainsOrder` aus der Datenbank, Abgleich mit AutoDNS-Job | Ja | FLOWING |
| `EnvironmentBadge` | System/Status | `GET status` (live: DEMO, nicht eingerichtet) | Ja | FLOWING |
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| Domains-, RLS- und Rechte-Specs | `vitest run src/domains rls-coverage rls-access-inventory module-manage-handlers` | 10 Dateien, 267 Tests grün | PASS |
| Vollständige API-Suite | `pnpm --filter @tessera/api test` | 133 Dateien, 2423 Tests grün | PASS |
| Vollständige Web-Suite | `pnpm --filter @tessera/web test` | 132 Dateien, 1455 Tests grün | PASS |
| Web Domains/Meldungen/Layouts | `vitest run modules/domains components/domains module-layouts src/messages` | 10 Dateien, 98 Tests grün | PASS |
| Typprüfung | `tsc --noEmit` api und web | beide ohne Ausgabe | PASS |
| Lint | `biome lint` auf Domains-Dateien | 42 Dateien, keine Funde | PASS |
| Migration ohne Abweichung | `prisma migrate status` / `migrate diff --exit-code` | "up to date" / "No difference" | PASS |
| Live-Stack (Admin) | curl status/settings/customers/orders; connection-test; PUT LIVE ohne confirmLive | 200 / maskiert / 200 `[]` / `not-configured` / 400 `confirmLiveRequired` | PASS |
| Wortlaut | de/en `domains.*`-Schlüssel, Suche Mandant/Tenant/Lizenz in Texten, CHANGELOG, Handbücher | 207 = 207, gleiche Schlüssel, keine Treffer | PASS |
### Probe Execution
Step 7c: übersprungen, der Plan deklariert keine Probe-Skripte.
### Requirements Coverage
| Requirement | Source Plan | Description | Status | Evidence |
|-------------|-------------|-------------|--------|----------|
| QUICK-261008-dts (L-01 bis L-12) | 261008-dts-PLAN.md | Modul Domains | SATISFIED im Code | Wahrheiten 1-7; CHANGELOG-Eintrag unter "Unveröffentlicht / Neu"; `### Domains` in `anleitung-anwender.md` und Inhaltsverzeichnis; `### Domains: AutoDNS anbinden` in `anleitung-administration.md`; keine Transfer-/Kündigungs-/Zonenfunktion (L-07), generischer `autodnsRequest` vorhanden. Gegenprobe gegen echtes System: Human-Punkte |
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| (alle geänderten Dateien) | | TBD/FIXME/XXX/TODO/HACK | keiner | Suche ohne Treffer |
| `domains.controller.ts` `POST connection-test` | | antwortet mit HTTP 201 statt 200 (Nest-Standard für POST) | Info | Plan sagt "immer 200 mit { ok, kind, message }". Der Inhalt stimmt, der Browser wertet jedes 2xx gleich. Kein Eingriff nötig |
| `autodns-client.ts` | 150-155 | HTTP-Fehler mit lesbarer Hülle zählt als `business`, also als ausdrückliche Ablehnung (FAILED) | Info | Ein Gateway, das JSON mit `messages` liefert, könnte fälschlich als FAILED statt UNKNOWN gelten. Gateway-Seiten ohne Hülle gehen korrekt auf UNKNOWN. Geringes, hinnehmbares Restrisiko |
### Human Verification Required
Die Punkte stehen oben im Frontmatter (`human_verification`). Sie decken die elf Annahmen aus der SUMMARY-Checkliste ab, die nur das echte AutoDNS-Demo-System schließen kann: Demo-Kontext (A1), Anmeldung ohne 2FA, Kontaktfelder und Telefonformat (A2), DomainStudio-Antwort und Preis (A3), Job-Rückgabe und Kontaktverweis (A4/A5), Job bis SUCCESS, `registryStatus`/`cancelationStatus`, Nameserver-Prüfung für .de (A8), Auftragssuche und `GET /domain/{name}` für Unbekanntes sowie Umlautdomains. Dazu kommt eine optionale Browser-Prüfung mit einem reinen "Benutzen"-Benutzer.
### Gaps Summary
Keine Lücken. Der Code erreicht das Ziel: Migration ohne Abweichung, Client, drei Dienste, Controller mit durchgängigem `@ModuleManage` und ohne Rollen-Decorator, sechs Reiter mit Rechtesteuerung, Doppelbestell-Schutz durch atomaren Anspruch (durch einen benannten Nebenläufigkeits-Test belegt), Tests mit gemockter API, deutsche Texte ohne verbotene Begriffe, CHANGELOG und beide Handbücher. Der lokale Stack läuft mit dem Modul. Offen sind nur die Annahmen über die echte AutoDNS-API, die laut Auftrag als human_needed zählen.
---
_Verified: 2026-10-08_
_Verifier: Claude (gsd-verifier)_
@@ -0,0 +1,329 @@
---
phase: quick-261008-h3t
plan: 01
type: execute
wave: 1
depends_on: []
quick_id: 261008-h3t
description: "Domains: Standard-Nameserver aus dem AutoDNS-Benutzerprofil statt aus einer Tessera-Einstellung"
date: 2026-10-08
files_modified:
# Task 1 — tracer (API): AutoDNS profile -> parser -> draft payload -> POST /domain body, plus GET name-servers route
- apps/api/src/domains/domain-name.ts
- apps/api/src/domains/autodns-parse.ts
- apps/api/src/domains/autodns-parse.spec.ts
- apps/api/src/domains/domains-orders.service.ts
- apps/api/src/domains/domains-orders.service.spec.ts
- apps/api/src/domains/dto/domains-order.dto.ts
- apps/api/src/domains/domains.controller.ts
- apps/api/src/domains/domains.controller.spec.ts
- apps/api/src/module-registry/module-manage-handlers.spec.ts
- apps/api/src/domains/domains-settings.service.ts
# Task 2 — web: Registrieren shows AutoDNS nameservers read-only, hint and lock when missing
- apps/web/src/lib/domains-api.ts
- apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx
- apps/web/src/app/(portal)/modules/domains/components/RegisterTab.test.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
# Task 3 — remove the setting everywhere (DB column, API, web), docs, changelog, rebuild
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20261008160000_domains_drop_default_nameservers/migration.sql
- apps/api/src/domains/domains-settings.service.spec.ts
- apps/api/src/domains/domains.types.ts
- apps/api/src/domains/dto/domains-settings.dto.ts
- apps/api/src/domains/dto/domains-settings.dto.spec.ts
- apps/web/src/app/(portal)/modules/domains/components/SettingsTab.tsx
- apps/web/src/app/(portal)/modules/domains/domains-page.test.tsx
- CHANGELOG.md
- docs/anleitung-anwender.md
- docs/anleitung-administration.md
- docs/mandantentrennung-zugriffsklassifikation.md
autonomous: true
requirements: [QUICK-261008-h3t]
estimate:
tokens: 100000
raw_tokens: 100000
tasks: 3
confidence: low
must_haves:
truths:
- "The Einstellungen tab of the module Domains shows no 'Standard-Nameserver' card any more; GET/PUT settings and GET status carry no nameserver field; after the migration the table DomainsConfig has no nameserver column (D-01)"
- "A manager opening the Registrieren tab sees the standard nameservers that AutoDNS holds in the profile of the configured AutoDNS user (GET /user/{user}/{context}/profile of the active system), read-only, in AutoDNS order (by the number in the profile key), with no input, add or remove controls (D-02, D-03)"
- "Creating a draft ignores any nameservers sent by the browser, reads the AutoDNS profile on the server, stores exactly that ordered list in the draft payload and returns it in the summary; confirming sends exactly that stored list in that order in the one POST /domain; the WR-02 version binding, the atomic DRAFT-to-SUBMITTING claim, the single POST without retry and the UNKNOWN handling are unchanged (D-03)"
- "If AutoDNS cannot be read or the profile yields fewer than two recognisable nameservers (or two different values for the same number), the Registrieren tab shows a German Sie-form hint ('In AutoDNS sind keine Standard-Nameserver hinterlegt …' respectively 'Tessera konnte die Standard-Nameserver nicht aus AutoDNS lesen …'), 'Zusammenfassung anzeigen' stays disabled, the server refuses the draft with 409 noDefaultNameServers or the AutoDNS error without writing a row, and submit refuses a draft with fewer than two nameservers before the claim — nothing is guessed (D-04)"
- "docs/anleitung-anwender.md, docs/anleitung-administration.md and the existing Domains lines under 'Unveröffentlicht' in CHANGELOG.md describe nameservers coming from AutoDNS; the 261008-dts SUMMARY is unchanged (D-05)"
- "The full api and web test suites, both tsc runs and the de/en key parity stay green; the rebuilt stack answers GET modules/domains/name-servers with 200, 409, 502 or 504 (never 404 or 500)"
artifacts:
- path: "apps/api/src/domains/autodns-parse.ts"
provides: "parseProfileNameServers: tolerant recognition of the standard nameservers in an AutoDNS user profile ([ASSUMED] key names)"
exports: ["parseProfileNameServers"]
- path: "apps/api/src/domains/domains-orders.service.ts"
provides: "getProfileNameServers, profile read inside createOrder, pre-claim guard for drafts without nameservers"
contains: "noDefaultNameServers"
- path: "apps/api/prisma/migrations/20261008160000_domains_drop_default_nameservers/migration.sql"
provides: "drops the obsolete settings column"
contains: "DROP COLUMN"
- path: "apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx"
provides: "read-only AutoDNS nameserver list, hint with retry, summary locked without nameservers"
key_links:
- from: "apps/api/src/domains/domains-orders.service.ts createOrder"
to: "AutoDNS GET /user/{user}/{context}/profile"
via: "readProfileNameServers(credentials) -> payload.nameServers"
pattern: "readProfileNameServers\\(credentials\\)"
- from: "apps/api/src/domains/domains-orders.service.ts submitOrder"
to: "AutoDNS POST /domain"
via: "stored payload list in stored order"
pattern: "payload\\.nameServers\\.map"
- from: "apps/api/src/domains/domains.controller.ts"
to: "DomainsOrdersService.getProfileNameServers"
via: "GET name-servers with ModuleManage('domains')"
pattern: "@Get\\('name-servers'\\)"
- from: "apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx"
to: "GET /modules/domains/name-servers"
via: "getNameServers() in apps/web/src/lib/domains-api.ts"
pattern: "getNameServers\\("
---
<objective>
The Tessera setting "Standard-Nameserver" in the module Domains (built in quick 261008-dts, commits 1f1c984..53b73dd) is wrong and goes away. AutoDNS already holds the standard nameservers in the profile of the AutoDNS user (for this user: ns2.ctl.de, b.ns14.net, c.ns14.net, d.ns14.net in exactly that order). Tessera reads them from AutoDNS when a registration is prepared, shows them for control only, stores them with the draft the user confirms and sends them explicitly, in AutoDNS order, in the single POST /domain. Company-specific values are never hard-coded in Tessera — not in code, tests, defaults or docs.
Locked requirements from the request (cited below as D-xx; numbering follows the request):
- D-01 Remove the settings card "Standard-Nameserver" (web SettingsTab, API DTO/service, validation). DB column: removed by migration (chosen in Task 3; values are worthless).
- D-02 On registration, read the standard nameservers from the AutoDNS user profile (GET /user/{name}/{context}/profile, response UserProfileViews with profiles[] of key/value). The key names are UNKNOWN: recognise tolerantly (keys containing "ns" plus a number, sorted by that number), encapsulated in one function with tests, assumption documented as [ASSUMED] and put on the demo checklist.
- D-03 Registrieren tab and summary show the AutoDNS nameservers read-only, in AutoDNS order. They are stored with the draft (part of what the user confirms; WR-02 version binding stays intact) and sent explicitly in that order in POST /domain.
- D-04 If Tessera cannot read nameservers from AutoDNS (error or no matching keys): clear German Sie-form hint starting "In AutoDNS sind keine Standard-Nameserver hinterlegt …" and registration locked — nothing guessed.
- D-05 Update docs/anleitung-anwender.md, docs/anleitung-administration.md and the existing Domains lines under "Unveröffentlicht" in CHANGELOG.md (adapt, no new line). Do NOT change the 261008-dts SUMMARY.
- Money safety (atomic claim, exactly one POST, UNKNOWN) is not touched except where needed; all existing tests stay green.
Discretion choices (documented here, cited in tasks):
- On a read ERROR (auth, timeout, gateway) the hint says "Tessera konnte die Standard-Nameserver nicht aus AutoDNS lesen …" plus the AutoDNS detail, because "no nameservers stored" would be false in that case; both cases lock registration identically. The "no matching keys" case uses the requested opening sentence verbatim.
- The server reads the profile itself inside createOrder (authoritative); the browser list is informational. The client never sends nameservers.
- Fewer than two recognised nameservers counts as "not stored" (.de needs at least two). No upper cap and no truncation (truncating would be guessing); AutoDNS validates the rest.
Purpose: AutoDNS stays the single source of the nameserver defaults; Tessera holds no company-specific values and never registers with nameservers the user did not see.
Output: profile parser with tests, API route GET name-servers, draft/summary/submit using the AutoDNS list, read-only Registrieren display with lock, setting removed including DB column, docs and changelog updated.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
@.planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/261008-dts-SUMMARY.md
@.planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/261008-dts-REVIEW.md
Project rules that apply here:
- NestJS: static routes before any `:id` route (domains.controller.spec.ts checks declaration order).
- API TS lib has no Object.hasOwn; use `Object.prototype.hasOwnProperty.call` or `in`.
- Never read .env files. Rebuild locally with `docker compose up -d --build api web` (the api container runs `prisma migrate deploy` on start). No deploy to the test server, no push (commits stay local; the user pushes bundled).
- UI texts: Sie-form, real umlauts (apps/web/src/messages/umlaut-guard.spec.ts fails on ae/oe/ue substitutes), de/en keys identical, no "Mandant"/"Lizenz".
- Global ValidationPipe: `whitelist: true`, no forbidNonWhitelisted — unknown body fields are stripped, not rejected.
AutoDNS spec facts (Swagger 2.0, already checked by the planner; the local copy is a session scratch file the executor need not open):
- GET /user/{name}/{context}/profile (operationId userProfileInfo, task 1301017) -> JsonResponseDataUserProfileViews: `data` is an array of UserProfileViews `{ profiles: UserProfileView[] }`; UserProfileView has `key` (string, example "techc"), `value` (string), `flag`, `inherited` (bool), `readonly` (bool), created/updated/owner/updater.
- Domain has `nameServers[]` (objects with `name`), `nameServerGroup`, `zone`. The existing POST /domain body already sends `nameServers: [{ name }]`.
- Base URLs (autodns-client.ts AUTODNS_BASE_URLS): Demo `https://api.demo.autodns.com/v1`, Live `https://api.autodns.com/v1`. `autodnsRequest` rejects paths containing `..`, `?`, `#`, `//` or whitespace by throwing.
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1 (tracer): AutoDNS profile -> parser -> draft payload -> POST /domain body, plus GET name-servers</name>
<files>apps/api/src/domains/domain-name.ts, apps/api/src/domains/autodns-parse.ts, apps/api/src/domains/autodns-parse.spec.ts, apps/api/src/domains/domains-orders.service.ts, apps/api/src/domains/domains-orders.service.spec.ts, apps/api/src/domains/dto/domains-order.dto.ts, apps/api/src/domains/domains.controller.ts, apps/api/src/domains/domains.controller.spec.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, apps/api/src/domains/domains-settings.service.ts</files>
<read_first>apps/api/src/domains/autodns-parse.ts, apps/api/src/domains/domains-orders.service.ts (lines 1-600: ERR, readPayload, callRaw, createOrder, submitOrder), apps/api/src/domains/domains-orders.service.spec.ts (harness lines 1-160, createOrder block around line 333, submit block around line 480-560), apps/api/src/domains/domains.controller.ts, apps/api/src/domains/domains.controller.spec.ts (lines 1-60 and 95-140), apps/api/src/module-registry/module-manage-handlers.spec.ts (lines 85-105), apps/api/src/domains/domain-name.ts</read_first>
<behavior>
- parseProfileNameServers, UserProfileViews shape: entries ns3/ns1/ns4/ns2 listed out of order (plus a techc entry) -> hostnames ordered ns1, ns2, ns3, ns4; techc ignored (D-02)
- numeric sort: ns10 comes after ns9, not after ns1
- value normalisation: " Z.Example.NET. " -> "z.example.net"; values that are no hostname (IP 192.0.2.1, empty, "kein rechner", "a.example.org 192.0.2.1") are skipped
- key variants recognised: nameserver1, NS_2, default_ns3, nserver4 (case-insensitive)
- flat items `{ key, value }` directly in data are accepted as well as `{ profiles: [...] }` and a single object with profiles
- same number with two different hostnames -> empty list (ambiguous, nothing guessed); same number with the same hostname -> once; the same hostname under two numbers -> first kept
- list fallback, only when no numbered key yields a hostname: key "nameservers" with "a.example.org, b.example.org" -> that order; two list keys with different lists -> empty
- garbage input (null, "x", {}, [1, 2]) -> `{ nameServers: [], keys: [] }`; `keys` lists the profile keys seen (for the log line)
- getProfileNameServers: exactly one GET to `https://api.demo.autodns.com/v1/user/api-user/4/profile`; returns `{ environment: 'DEMO', nameServers }` in key order; a user name with a space is percent-encoded in the path
- profile without nameserver keys -> 409 code noDefaultNameServers; AutoDNS 401 -> 502 autodnsAuth; timeout -> 504 (D-04)
- createOrder: payload.nameServers and summary.nameServers equal the profile order (neither alphabetical nor from the request); a request body with nameServers ['evil.example.com', 'x.example.com'] is ignored (D-03)
- createOrder with a profile without nameservers -> 409 noDefaultNameServers, no row written, no /domainstudio call; profile answered 500 -> rejected, no row (D-04)
- createOrder on an existing DRAFT re-reads the profile and returns a new version (WR-02 binding intact)
- tracer chain (describe 'Nameserver aus AutoDNS (h3t)'): createOrder -> submitOrder(id, version) -> exactly one POST /domain whose body.nameServers is [{ name }] in profile order
- submit guard: a DRAFT whose payload.nameServers is [] or has one entry -> 400 orderInvalid, the claim updateMany is not called, zero AutoDNS calls
- every existing submit, claim, UNKNOWN, reconcile and cancel test passes unchanged
- controller: getNameServers is GET 'name-servers', carries ModuleManage('domains'), no role decorator, is declared before every :id handler and forwards req.tenantId
</behavior>
<action>
Write the tests from the behavior list first (RED), then implement (GREEN). This task proves the money path end-to-end inside the API; the web follows in Task 2, the removal of the old setting in Task 3.
1. domain-name.ts: move the hostname regex constant HOSTNAME_PATTERN here unchanged and export it. In domains-settings.service.ts delete its local definition and import it from './domain-name' (its own nameserver normaliser stays until Task 3). In domains-orders.service.ts import it from './domain-name' instead of the settings service. autodns-parse.ts imports it from './domain-name' too (no dependency from the parser on a Nest service).
2. autodns-parse.ts (D-02): add exported parseProfileNameServers(data: unknown) returning `{ nameServers: string[]; keys: string[] }`. Doc comment in German stating: the key names of the standard nameservers in the AutoDNS user profile are not documented — [ASSUMED], recognised tolerantly, checked by H-1 on the demo checklist of quick 261008-h3t. Rules:
a. Collect entries: data may be an array of `{ profiles: [...] }` (spec), an array of flat `{ key, value }` items, or one object with `profiles`. Keep items whose key and value are strings. `keys` = distinct trimmed keys in first-seen order, at most 50, each cut to 60 characters.
b. Normalise a value: trim, lowercase, remove one trailing dot; accept it only if the whole result matches HOSTNAME_PATTERN (no token splitting for numbered keys).
c. Numbered keys: lowercase the key and search anywhere in it for the regex `(?:nameserver|name[_-]server|nserver|ns)[_.-]?(\d{1,2})(?!\d)`; the captured number is the position. Skip entries whose value is not a hostname (for example glue addresses).
d. If one position carries two different hostnames, return an empty list (never pick one). The same hostname twice at one position counts once.
e. Sort by position numerically, then drop repeated hostnames keeping the first occurrence.
f. Only if step c produced no hostname: keys matching exactly `^(?:default[_.-]?)?(?:nameservers?|name[_-]servers?|nservers?|ns)$` whose value split on `[\s,;]+` gives tokens that are ALL hostnames provide that list in written order; two such keys with different lists give an empty list.
g. No minimum here; the caller enforces it.
3. domains-orders.service.ts:
- Add ERR.noDefaultNameServers with code 'noDefaultNameServers' and message "In AutoDNS sind keine Standard-Nameserver hinterlegt. Bitte hinterlegen Sie mindestens zwei Nameserver als Standard in AutoDNS; bis dahin ist keine Registrierung möglich." (thrown as ConflictException, D-04).
- Private readProfileNameServers(credentials): exactly one callRaw GET to the path `/user/` + encodeURIComponent(credentials.user) + `/` + credentials.context + `/profile` — no query, no retry. A thrown error (for example the client's path check) becomes BadGatewayException with code 'autodnsError' and message "Tessera konnte die Standard-Nameserver nicht aus AutoDNS lesen."; a result with ok false is thrown via autodnsFailureToHttp. Parse with parseProfileNameServers; with fewer than MIN_NAMESERVERS (2) entries log one warning via this.logger.warn that starts with "Keine Standard-Nameserver im AutoDNS-Profil erkannt" and lists only the profile key names (never values, never credentials), then throw ERR.noDefaultNameServers. Otherwise return the list.
- Public getProfileNameServers(tenantId): getActiveCredentials (409 notConfigured unchanged), then readProfileNameServers; returns `{ environment, nameServers }`.
- createOrder (D-03): delete the use of the request's nameservers and the method normalizeNameServers plus MAX_NAMESERVERS (no other user). Directly after the existing-order check and before availabilityFor, call readProfileNameServers(credentials) and put the returned ordered list into payload.nameServers. Everything else (availability, contact check, write, summary with version) stays as is; the summary keeps returning payload.nameServers.
- submitOrder: the only change is the existing pre-claim guard — "payload missing" becomes "payload missing OR payload.nameServers.length below MIN_NAMESERVERS", same 400 orderInvalid. Do not touch the claim, the single POST, the body mapping (order preserved, never sorted), outcomeOfSubmit, recoverFailedOutcomeWrite, reconcile or cancelOrder.
- Adjust doc comments that mention nameservers coming from the settings or the browser.
4. dto/domains-order.dto.ts: remove the nameServers field from CreateDomainsOrderDto and the class-validator imports that become unused; doc comment: the nameservers come from AutoDNS, never from the browser. A browser still sending the field is stripped by the whitelist ValidationPipe.
5. domains.controller.ts: add handler getNameServers with `@Get('name-servers')` and `@ModuleManage('domains')`, calling this.orders.getProfileNameServers(this.requireTenantId(req)). Place it directly after checkAvailability in the static section (before every `:id` route). No role decorator. Add "Standard-Nameserver aus AutoDNS lesen" to the Verwalten list in the class doc comment.
6. Tests:
- autodns-parse.spec.ts: the parser cases from the behavior list, example hostnames only (example.org, example.net, example.com).
- domains-orders.service.spec.ts: extend makeHarness so GET calls whose URL ends with '/profile' are answered by a separate, overridable profile responder (default: envelope with one `{ profiles: [...] }` item listing ns3, ns1, ns4, ns2 out of order with example hostnames whose alphabetical order differs from the key order, plus a techc entry); these calls are still recorded in h.calls. Remove nameServers from the createOrder dto fixture and delete the "Nameserver %j -> BadRequest %s" table test (its rules are gone with D-02). Add the service, createOrder, tracer-chain and submit-guard tests from the behavior list. If an existing test counts all calls of a createOrder run, account for the one profile call explicitly rather than weakening the assertion.
- domains.controller.spec.ts: add 'getNameServers' to MANAGE_HANDLERS, `getProfileNameServers` to makeOrders, the route expectation `[0, 'name-servers']`, and a forwarding test.
- module-manage-handlers.spec.ts: add 'getNameServers' to the DomainsController list only; do not reformat the file (its organizeImports finding is pre-existing).
Commit: `feat(domains): Standard-Nameserver aus dem AutoDNS-Profil lesen (h3t)`.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/domains module-manage-handlers rls-coverage rls-access-inventory && pnpm --filter @tessera/api exec tsc --noEmit && test -z "$(grep -nE '^\s*@Roles\(' apps/api/src/domains/domains.controller.ts)" && grep -q "@Get('name-servers')" apps/api/src/domains/domains.controller.ts && grep -q "Nameserver aus AutoDNS (h3t)" apps/api/src/domains/domains-orders.service.spec.ts && echo "tracer api ok"</automated>
</verify>
<done>Profile parser covered by tests; createOrder stores the AutoDNS list in AutoDNS order and the chained test proves the one POST /domain carries it unchanged; missing or unreadable profile nameservers block the draft with 409/502/504 and no row; drafts without two nameservers never reach the claim; GET name-servers is a Verwalten route before all :id routes; every existing domains test is green.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Registrieren shows the AutoDNS nameservers read-only, with hint and lock when missing</name>
<files>apps/web/src/lib/domains-api.ts, apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx, apps/web/src/app/(portal)/modules/domains/components/RegisterTab.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
<read_first>apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx, apps/web/src/app/(portal)/modules/domains/components/RegisterTab.test.tsx, apps/web/src/lib/domains-api.ts (lines 1-80 and 280-360), apps/web/src/messages/de.json and en.json (block domains.register)</read_first>
<behavior>
- With getNameServers resolving ['z.example.net', 'b.example.org', 'a.example.org'], the nameserver section lists exactly these in this DOM order, read-only: no textbox labelled 'Nameserver 1', no button 'Nameserver hinzufügen' (D-03)
- getNameServers rejecting with DomainsRequestError(409, 'noDefaultNameServers', …) shows an alert containing "In AutoDNS sind keine Standard-Nameserver hinterlegt"; after a free domain and chosen contacts, "Zusammenfassung anzeigen" is disabled and createOrder is never called (D-04)
- getNameServers rejecting with DomainsRequestError(502, 'autodnsAuth', 'Anmeldung bei AutoDNS fehlgeschlagen …') shows "Tessera konnte die Standard-Nameserver nicht aus AutoDNS lesen" plus the server text; "Erneut aus AutoDNS lesen" calls getNameServers again, and after success the list appears and the summary button becomes enabled
- createOrder is called with exactly { domain, ownerContactId, adminContactId, techContactId, zoneContactId } — no nameServers key (D-03)
- the summary shows the server's list in its order: "z.example.net, b.example.org, a.example.org"
- existing tests for double-click lock, submit error lock, orderChanged and cancel stay green
</behavior>
<action>
1. domains-api.ts: add interface DomainsNameServers `{ environment: DomainsEnvironment; nameServers: string[] }` and function getNameServers() that requests '/name-servers' (GET, same request helper as listOrders). Remove nameServers from CreateOrderInput (D-03: the server reads them itself). Leave the DomainsStatus and DomainsSettings types alone — Task 3 removes their old field.
2. RegisterTab.tsx (D-03, D-04):
- Remove the editable nameserver list completely: the MIN/MAX constants used only for it, initialNameServers, the nameServers state seeded from the status prop, the inputs and the add/remove buttons. resetAll no longer touches nameservers.
- New state for the AutoDNS list, a small union: loading, ok with list, missing, unreadable with detail. Load via getNameServers on mount and whenever status.environment changes, guarded by an alive flag like the contacts effect. DomainsRequestError with code 'noDefaultNameServers' -> missing; any other error -> unreadable with detail = the DomainsRequestError message, else tc('requestFailed').
- When missing or unreadable, render one hint with role="alert" at the top of the input view (above the domain section, so it is visible before anything is filled in): t('nameServersMissing') or t('nameServersUnreadable') followed by the detail line, plus a SECONDARY_BUTTON t('nameServersRetry') that reloads. The availability check stays usable.
- The nameserver section keeps its place (shown once the domain is free) and keeps the "Zusammenfassung anzeigen" footer; description t('nameServersDescription'); content: loading -> t('nameServersLoading'); ok -> an ordered list (ol) whose items show t('nameServer', { number }) and the hostname as plain text, nothing editable; missing or unreadable -> t('nameServersBlocked').
- canSummarize additionally requires the ok state with at least two entries.
- onSummary calls createOrder without nameservers.
- The summary row keeps summary.nameServers joined with ", " (server order = AutoDNS order). Update the component doc comment (nameservers come from AutoDNS, read-only).
3. de.json / en.json, block domains.register (identical keys in both; Sie-form; real umlauts):
- intro: "Prüfen Sie, ob eine Domain frei ist, wählen Sie die Kontakte und registrieren Sie die Domain verbindlich bei AutoDNS. Die Nameserver übernimmt Tessera aus AutoDNS."
- nameServersDescription: "Diese Standard-Nameserver sind in AutoDNS hinterlegt. Tessera verwendet sie unverändert und in dieser Reihenfolge; ändern lassen sie sich nur in AutoDNS."
- keep nameServer ("Nameserver {number}"); delete addNameServer and removeNameServer
- add nameServersLoading: "Nameserver werden aus AutoDNS gelesen …"
- add nameServersMissing: "In AutoDNS sind keine Standard-Nameserver hinterlegt. Bitte hinterlegen Sie mindestens zwei Nameserver als Standard in AutoDNS. Bis dahin ist keine Registrierung möglich."
- add nameServersUnreadable: "Tessera konnte die Standard-Nameserver nicht aus AutoDNS lesen. Bis das gelingt, ist keine Registrierung möglich." (discretion choice, see objective)
- add nameServersRetry: "Erneut aus AutoDNS lesen"
- add nameServersBlocked: "Ohne Standard-Nameserver aus AutoDNS ist keine Registrierung möglich – siehe Hinweis oben."
- English counterparts with the same meaning. Do not touch domains.settings.nameServers yet (Task 3).
4. RegisterTab.test.tsx: add a mockNameServers for getNameServers in the existing vi.mock (default resolves the example list from the behavior block); replace the prefill assertion and the "zwischen 2 und 6" test with the behavior cases; keep the status fixture as it is (Task 3 drops its old field).
Commit: `feat(domains): Registrieren zeigt Nameserver aus AutoDNS nur zur Kontrolle (h3t)`.
</action>
<verify>
<automated>pnpm --filter @tessera/web exec vitest run modules/domains src/messages && pnpm --filter @tessera/web exec tsc --noEmit && node -e 'const de=require("./apps/web/src/messages/de.json"),en=require("./apps/web/src/messages/en.json");const w=(o,p,r)=>{for(const[k,v]of Object.entries(o||{})){const q=p+"."+k;if(v&&typeof v==="object")w(v,q,r);else r[q]=v}return r};const a=w(de.domains,"domains",{}),b=w(en.domains,"domains",{});if(Object.keys(a).length<40||Object.keys(a).sort().join()!==Object.keys(b).sort().join()){console.error("key mismatch");process.exit(1)}if(!String(a["domains.register.nameServersMissing"]).startsWith("In AutoDNS sind keine Standard-Nameserver hinterlegt")){console.error("hint text");process.exit(1)}for(const v of [...Object.values(a),...Object.values(b)])if(/mandant|tenant|lizenz|licens/i.test(String(v))){console.error("bad text",v);process.exit(1)}console.log("web register ok")'</automated>
</verify>
<done>The Registrieren tab lists the AutoDNS nameservers read-only in AutoDNS order, shows the German hint with a retry button when they are missing or unreadable, keeps "Zusammenfassung anzeigen" disabled in that case, and no longer sends nameservers when creating a draft; de/en keys match; web domains tests and the umlaut guard are green.</done>
</task>
<task type="auto">
<name>Task 3: Remove the setting everywhere (DB column, API, web), update docs and changelog, rebuild</name>
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261008160000_domains_drop_default_nameservers/migration.sql, apps/api/src/domains/domains-settings.service.ts, apps/api/src/domains/domains-settings.service.spec.ts, apps/api/src/domains/domains.types.ts, apps/api/src/domains/dto/domains-settings.dto.ts, apps/api/src/domains/dto/domains-settings.dto.spec.ts, apps/web/src/lib/domains-api.ts, apps/web/src/app/(portal)/modules/domains/components/SettingsTab.tsx, apps/web/src/app/(portal)/modules/domains/domains-page.test.tsx, apps/web/src/app/(portal)/modules/domains/components/RegisterTab.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md, docs/mandantentrennung-zugriffsklassifikation.md</files>
<read_first>apps/api/src/domains/domains-settings.service.ts, apps/api/src/domains/domains-settings.service.spec.ts, apps/api/src/domains/dto/domains-settings.dto.ts, apps/api/src/domains/dto/domains-settings.dto.spec.ts, apps/web/src/app/(portal)/modules/domains/components/SettingsTab.tsx (lines 1-40 and 340-509), apps/web/src/app/(portal)/modules/domains/domains-page.test.tsx (fixtures near lines 70-90, nameserver test near line 357), CHANGELOG.md lines 1-15, docs/anleitung-administration.md lines 362-372, docs/anleitung-anwender.md lines 174-180, docs/mandantentrennung-zugriffsklassifikation.md line 901</read_first>
<precondition>The local stack is up: `docker compose ps --status running --services` lists db (the rebuild below needs it).</precondition>
<reversibility rating="costly">Dropping the column discards the stored values (the user confirmed they are worthless); bringing it back would need a new migration.</reversibility>
<!-- planner-discipline-allow: defaultNameServers -->
<action>
1. Database (D-01): remove the field defaultNameServers from model DomainsConfig in schema.prisma. Create apps/api/prisma/migrations/20261008160000_domains_drop_default_nameservers/migration.sql with a short German header comment (quick-261008-h3t: die Standard-Nameserver kommen aus dem AutoDNS-Benutzerprofil; die Spalte wird nicht mehr gelesen, ihre Werte sind wertlos) and the single statement ALTER TABLE "DomainsConfig" DROP COLUMN "defaultNameServers"; — removal chosen over leaving the column unused because the module is unreleased, nothing reads the column after this task, and a dead column would mislead later work. No RLS change (no new table). Run `pnpm --filter @tessera/api exec prisma generate`.
2. API (D-01):
- domains-settings.service.ts: remove the nameserver constants, the HOSTNAME_PATTERN import, the field from ConfigRow, normalizeNameServers, the saveSettings branch, getDefaultNameServers (no caller since Task 1) and the field in toSettingsView and getStatus; update the class doc comment (no Standard-Nameserver any more). Keep the remaining two forTenant raw hits (loadRow, saveSettings) unchanged so the access inventory stays correct.
- domains.types.ts: remove the field from DomainsSettingsView and DomainsStatusView.
- dto/domains-settings.dto.ts: remove the field and the class-validator imports that become unused.
- Specs: domains-settings.service.spec.ts drops the fixture fields and the nameserver test and adjusts view expectations; add one assertion that getSettings and getStatus return objects without any nameserver property. dto spec: remove the field from the valid body and from the null list.
3. Web (D-01):
- domains-api.ts: remove the field from DomainsStatus, DomainsSettings and SaveDomainsSettingsInput.
- SettingsTab.tsx: delete NameServersCard, padNameServers, the row constants, its render line and imports that become unused.
- de.json and en.json: delete the block domains.settings.nameServers in both.
- domains-page.test.tsx: drop the fixture fields and the test "Nameserver: speichert die bereinigte Liste"; add a test that the Einstellungen tab renders no "Standard-Nameserver" heading and no element with id domains-nameservers.
- RegisterTab.test.tsx: drop the old field from the status fixture.
4. Docs and changelog (D-05; Sie-form in user docs as before; never name the user's concrete nameserver hostnames anywhere):
- docs/anleitung-administration.md, section "Domains: AutoDNS anbinden": replace the bullet "Standard-Nameserver" with a bullet "Nameserver kommen aus AutoDNS": Tessera has no own nameserver setting; for every registration it reads the standard nameservers from the AutoDNS user profile of the AutoDNS user entered in the settings (also values inherited from a parent user) and uses them unchanged, in the order stored there; store at least two there; they must be set up, because for .de domains the DENIC checks them at registration; if Tessera finds none or cannot read them, the Registrieren tab shows a hint and registration is not possible. In the bullet "Eigener AutoDNS-Benutzer" add that the user must be able to read its own user profile.
- docs/anleitung-anwender.md, "Eine Domain registrieren" step 2: replace the sentence about prefilled nameservers ("zwei bis sechs") with: below the contacts you see, for control only, the nameservers stored as standard in AutoDNS, in the order stored there; they can only be changed in AutoDNS; if none are stored, a hint appears and registration is locked. Step 3 (summary lists Nameserver) stays.
- CHANGELOG.md under "Unveröffentlicht", adapt the existing Domains lines, no new line: in the line "Domains, Anbindung an AutoDNS" delete the closing sentence about Standard-Nameserver being prefilled; in the line "Domains, Registrieren" replace "Kontakte und Nameserver wählen" with "Kontakte wählen; die Nameserver übernimmt Tessera unverändert und in derselben Reihenfolge aus den Standardwerten in AutoDNS (sind dort keine hinterlegt, ist die Registrierung gesperrt)".
- docs/mandantentrennung-zugriffsklassifikation.md line 901 (row domains-settings.service.ts): remove ", Standard-Nameserver" from the description and append "Spalte für Standard-Nameserver mit quick-261008-h3t entfernt (Migration 20261008160000)." Keep the table columns unchanged.
- Do not edit .planning/quick/261008-dts-*/261008-dts-SUMMARY.md.
5. Run `pnpm exec biome check` on the touched source directories apps/api/src/domains and "apps/web/src/app/(portal)/modules/domains" plus apps/web/src/lib/domains-api.ts (not on module-manage-handlers.spec.ts, whose finding is pre-existing) and fix what it reports.
6. Rebuild locally: `docker compose up -d --build api web`; wait until the api answers on http://localhost:3001 and its log shows the migrations applied without error. Nothing is pushed and nothing is deployed to the test server.
Commit: `refactor(domains): Einstellung Standard-Nameserver entfernt, Doku angepasst (h3t)`.
</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 && ! grep -rn defaultNameServers apps/api/src apps/web/src && ! grep -rnE "ns14|ns2\.ctl" apps/api/src apps/web/src docs CHANGELOG.md && grep -q 'DROP COLUMN "defaultNameServers"' apps/api/prisma/migrations/20261008160000_domains_drop_default_nameservers/migration.sql && grep -q "Nameserver kommen aus AutoDNS" docs/anleitung-administration.md && grep -q "Standardwerten in AutoDNS" CHANGELOG.md && node -e 'const de=require("./apps/web/src/messages/de.json"),en=require("./apps/web/src/messages/en.json");const w=(o,p,r)=>{for(const[k,v]of Object.entries(o||{})){const q=p+"."+k;if(v&&typeof v==="object")w(v,q,r);else r[q]=v}return r};const a=w(de.domains,"domains",{}),b=w(en.domains,"domains",{});if(Object.keys(a).sort().join()!==Object.keys(b).sort().join()||Object.keys(a).some(k=>k.startsWith("domains.settings.nameServers"))){console.error("keys");process.exit(1)}' && docker compose ps --status running --services | grep -qx api && docker compose ps --status running --services | grep -qx web && test "$(docker compose exec -T db psql -U tessera -d tessera -tAc "SELECT count(*) FROM information_schema.columns WHERE table_name='DomainsConfig' AND column_name='defaultNameServers'")" = "0" && A=$(mktemp) && curl -sf -c "$A" -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}' http://localhost:3001/auth/login >/dev/null && MID=$(curl -sf -b "$A" http://localhost:3001/modules/catalog | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const m=JSON.parse(s).find(x=>x.slug==="domains");if(!m)process.exit(1);process.stdout.write(m.isActiveForTenant?"":m.id)})') && { [ -z "$MID" ] || curl -sf -b "$A" -X POST "http://localhost:3001/modules/$MID/activate" >/dev/null; } && S=$(curl -sf -b "$A" http://localhost:3001/modules/domains/settings) && echo "$S" | grep -q '"hasPassword"' && ! echo "$S" | grep -q defaultNameServers && C=$(curl -s -o /dev/null -w '%{http_code}' -b "$A" http://localhost:3001/modules/domains/name-servers) && case "$C" in 200|409|502|504) echo "final gates ok (name-servers $C)";; *) echo "name-servers answered $C"; exit 1;; esac</automated>
<human-check>End of task, with Demo credentials entered by the user (record as checklist H-1..H-4 in the SUMMARY; not run by the executor): H-1 [ASSUMED] key names — Registrieren tab lists the Demo user's standard nameservers in the AutoDNS order; if instead the hint "In AutoDNS sind keine Standard-Nameserver hinterlegt" appears although AutoDNS has values, read the warning line "Keine Standard-Nameserver im AutoDNS-Profil erkannt" in `docker compose logs api` (it lists the key names) and adapt parseProfileNameServers. H-2 the AutoDNS user may read its own profile (no 403; a 403 shows the unreadable hint). H-3 a demo registration sends the shown nameservers and AutoDNS accepts them explicitly. H-4 Live: the list matches the four values the user named, in that order. H-x replaces item 8 (A8) of the 261008-dts demo checklist, whose step "Standard-Nameserver in den Einstellungen eintragen" no longer exists.</human-check>
</verify>
<done>No "Standard-Nameserver" card in Einstellungen, no nameserver field in settings/status API and web types, column dropped by migration 20261008160000 and absent in the local DB; docs and the existing CHANGELOG Domains lines describe nameservers from AutoDNS; full api and web suites, both tsc runs and biome on touched files are green; the rebuilt stack serves GET name-servers without 404/500.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser -> API | Manager-controlled request bodies for draft creation and submit; the nameserver list must not be taken from here |
| API -> AutoDNS | Profile read (new GET) and the existing single POST /domain; AutoDNS answers are untrusted input to the parser |
| API -> logs | New warning line when no nameservers are recognised |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-h3t-01 | Tampering | createOrder request body | high | mitigate | Field removed from CreateDomainsOrderDto (whitelist strips it); server reads the profile itself; test proves a sent list is ignored (Task 1) |
| T-h3t-02 | Tampering | draft vs. confirmed order | high | mitigate | List stored in the draft payload; summary returns it; WR-02 `updatedAt` binding in the claim unchanged; re-creating a draft re-reads the profile and changes the version (Task 1 test) |
| T-h3t-03 | Tampering | profile path built from the stored AutoDNS user name | medium | mitigate | `encodeURIComponent` on the user name plus the client's existing path check; a thrown path error becomes 502 autodnsError, never a request to another path (Task 1) |
| T-h3t-04 | Repudiation / integrity | guessing nameservers | high | mitigate | Ambiguous or fewer than two recognised values -> 409 noDefaultNameServers, no draft; submit guard rejects drafts with fewer than two nameservers before the claim; web keeps the summary button disabled (Tasks 1, 2) |
| T-h3t-05 | Information Disclosure | warning log line | low | mitigate | Logs only profile key names (max 50, 60 chars each), never values or credentials (Task 1) |
| T-h3t-06 | Denial of Service | extra AutoDNS calls | low | accept | One profile GET per Registrieren load and per draft, Verwalten only, through the shared 350 ms limiter, no retry |
| T-h3t-07 | Elevation of Privilege | GET name-servers | medium | mitigate | `@ModuleManage('domains')`, no role decorator, class-level `@UseModule`; controller spec and module-manage-handlers spec assert it (Task 1) |
| T-h3t-08 | Tampering | money-safety path | critical | mitigate | Claim, single POST, outcome mapping, UNKNOWN recovery, reconcile and cancel untouched; all existing submit/claim/reconcile tests must pass unchanged (Task 1 verify, Task 3 full suite) |
| T-h3t-SC | Tampering | npm/pip/cargo installs | high | accept | No package installs in this plan; nothing to audit |
</threat_model>
<verification>
- Task 1: api domains specs, module-manage-handlers, rls-coverage, rls-access-inventory and api tsc green; tracer chain test present; no role decorator in the controller.
- Task 2: web domains tests and message guards green, web tsc green, de/en key parity, hint text starts with the requested sentence.
- Task 3: full api and web suites, both tsc runs, biome on touched files, no old field in apps/*/src, no company-specific nameserver hostnames in code, docs or changelog, migration present and applied (column absent in the local DB), rebuilt api/web running, GET name-servers answers 200/409/502/504.
</verification>
<success_criteria>
- The setting "Standard-Nameserver" is gone from UI, API, DTOs, types and database (D-01).
- Registration uses only the nameservers AutoDNS holds in the user profile, recognised by one tested function with the [ASSUMED] key rule (D-02).
- Registrieren and the summary show them read-only in AutoDNS order; the draft stores them, the one POST /domain sends them in that order, WR-02 still binds the confirmation (D-03).
- Without readable nameservers, a clear German hint appears and registration is locked in browser and server (D-04).
- Docs and the existing CHANGELOG Domains lines updated; 261008-dts SUMMARY untouched (D-05).
- Money safety unchanged; every pre-existing test still green.
</success_criteria>
<output>
Create `.planning/quick/261008-h3t-domains-nameserver-aus-autodns-statt-tes/261008-h3t-SUMMARY.md` when done. Include: commits per task, test/gate measurements, the demo checklist H-1..H-4 from the Task 3 human-check (H-1 marked [ASSUMED], with the log-line instructions), the note that it supersedes item 8 of the 261008-dts checklist, and browser steps for the orchestrator (dark mode): Einstellungen without a nameserver card; Registrieren with the read-only list or the hint plus "Erneut aus AutoDNS lesen" and a disabled "Zusammenfassung anzeigen".
</output>
@@ -0,0 +1,96 @@
---
phase: quick-261008-h3t
plan: 01
subsystem: domains
tags: [autodns, nameserver, domains, migration]
requires:
- quick-261008-dts (Modul Domains)
provides:
- Standard-Nameserver kommen aus dem AutoDNS-Benutzerprofil (GET /user/{user}/{context}/profile)
- GET modules/domains/name-servers (Verwalten)
affects:
- apps/api/src/domains
- apps/web Registrieren und Einstellungen
tech-stack:
added: []
patterns:
- tolerante Profilauswertung in einer Funktion (parseProfileNameServers)
key-files:
created:
- apps/api/prisma/migrations/20261008160000_domains_drop_default_nameservers/migration.sql
modified:
- apps/api/src/domains/autodns-parse.ts
- apps/api/src/domains/domain-name.ts
- apps/api/src/domains/domains-orders.service.ts
- apps/api/src/domains/domains.controller.ts
- apps/api/src/domains/domains-settings.service.ts
- apps/api/src/domains/dto/domains-order.dto.ts
- apps/api/src/domains/dto/domains-settings.dto.ts
- apps/api/prisma/schema.prisma
- apps/web/src/app/(portal)/modules/domains/components/RegisterTab.tsx
- apps/web/src/app/(portal)/modules/domains/components/SettingsTab.tsx
- apps/web/src/lib/domains-api.ts
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- CHANGELOG.md
- docs/anleitung-anwender.md
- docs/anleitung-administration.md
- docs/mandantentrennung-zugriffsklassifikation.md
decisions:
- Spalte DomainsConfig.defaultNameServers per Migration entfernt (Modul unveroeffentlicht, Werte wertlos)
- Bei Lesefehler eigener Hinweis "nicht aus AutoDNS lesen" statt "keine hinterlegt"; beide sperren die Registrierung
- Weniger als zwei erkannte Nameserver gelten als nicht hinterlegt; keine Kuerzung, kein Raten
status: complete
actuals:
tokens: 60000
tasks: 3
commits: 3
plan_head_before: 95d625bd25e6713fb98a29b27bb08b8efe2ab8b0
plan_head_after: 2176f8ecce233a5e3c6416e866f73d2a8823a37e
completed: 2026-10-08
---
# Phase quick-261008-h3t Plan 01: Domains, Nameserver aus AutoDNS Summary
Tessera liest die Standard-Nameserver der Registrierung aus dem AutoDNS-Benutzerprofil (tolerante Schluessel-Erkennung), zeigt sie nur lesend in AutoDNS-Reihenfolge, speichert sie im Entwurf und sendet sie unveraendert im einen POST /domain; die Tessera-Einstellung samt DB-Spalte ist entfernt.
## Commits
| Task | Commit | Beschreibung |
| ---- | ------ | ------------ |
| 1 (tracer) | 473738d | feat(domains): Standard-Nameserver aus dem AutoDNS-Profil lesen (h3t) |
| 2 | 1b20b84 | feat(domains): Registrieren zeigt Nameserver aus AutoDNS nur zur Kontrolle (h3t) |
| 3 | 2176f8e | refactor(domains): Einstellung Standard-Nameserver entfernt, Doku angepasst (h3t) |
## Messungen
- API-Suite: 135 Dateien, 2498 Tests gruen; Web-Suite: 132 Dateien, 1458 Tests gruen.
- tsc (api, web) ohne Fehler; biome check auf den beruehrten Verzeichnissen sauber.
- de/en-Schluesselparitaet gruen; kein `defaultNameServers` mehr in apps/*/src; keine firmenspezifischen Nameserver-Namen in Code, Docs, Changelog.
- Tracer-Kette (Entwurf -> Bestaetigung -> genau ein POST /domain mit Profil-Nameservern in Profil-Reihenfolge) als Test "Nameserver aus AutoDNS (h3t)" gruen.
- Neu gebauter Stack: Migration 20261008160000 angewendet, Spalte in der lokalen DB weg, GET settings ohne Nameserver-Feld, GET name-servers antwortet 409 notConfigured (lokal ist kein AutoDNS-Zugang hinterlegt), GET status 200.
## Deviations from Plan
None - plan executed exactly as written. Hinweis: ein einzelner Web-Test (domains-page, "Speichern") fiel in einem Lauf unter Last durch (Zeitueberschreitung) und war im Einzel- und Wiederholungslauf gruen; unveraendert, nicht von dieser Aenderung verursacht.
## Known Stubs
None.
## Demo-Checkliste (vom Benutzer mit Demo-Zugang auszufuehren, nicht vom Executor)
- H-1 [ASSUMED] Schluesselnamen: Der Reiter Registrieren listet die Standard-Nameserver des Demo-Benutzers in AutoDNS-Reihenfolge. Erscheint stattdessen "In AutoDNS sind keine Standard-Nameserver hinterlegt", obwohl AutoDNS Werte hat, die Warnzeile "Keine Standard-Nameserver im AutoDNS-Profil erkannt" in `docker compose logs api` lesen (sie nennt die Schluesselnamen) und `parseProfileNameServers` anpassen.
- H-2 Der AutoDNS-Benutzer darf sein eigenes Profil lesen (kein 403; ein 403 zeigt den Hinweis "nicht aus AutoDNS lesen").
- H-3 Eine Demo-Registrierung sendet die angezeigten Nameserver und AutoDNS akzeptiert sie ausdruecklich.
- H-4 Live: die Liste entspricht den vier vom Benutzer genannten Werten in dieser Reihenfolge.
- H-x ersetzt Punkt 8 (A8) der Checkliste von 261008-dts, dessen Schritt "Standard-Nameserver in den Einstellungen eintragen" entfaellt.
## Browser-Schritte fuer den Orchestrator (Dunkelmodus)
1. Domains -> Einstellungen: keine Karte "Standard-Nameserver" mehr.
2. Domains -> Registrieren, Domain pruefen: entweder schreibgeschuetzte Nameserver-Liste in AutoDNS-Reihenfolge, oder (lokal ohne Zugang) Hinweis oben mit "Erneut aus AutoDNS lesen" und gesperrtem "Zusammenfassung anzeigen".
## Self-Check: PASSED
Commits 473738d, 1b20b84, 2176f8e liegen auf main; Migration und Parser vorhanden.
@@ -0,0 +1,297 @@
---
phase: quick-261008-j9f
plan: 01
type: execute
wave: 1
depends_on: []
quick_id: 261008-j9f
description: "Nextcloud-Status: Logo per http-Adresse wird einmalig von Tessera abgeholt und wie ein Upload gespeichert (SSRF-geschützt); keine englischen Rohmeldungen mehr im Cloud-Formular"
date: 2026-10-08
files_modified:
# Task 1 — tracer (API): http logo address -> shared SSRF guard -> capped download -> stored like an upload; all form errors as code + German text
- apps/api/src/common/public-url-guard.ts
- apps/api/src/favorites/icon-discovery.service.ts
- apps/api/src/nextcloud-status/nextcloud-form-errors.ts
- apps/api/src/nextcloud-status/nextcloud-logo-fetch.ts
- apps/api/src/nextcloud-status/nextcloud-logo-fetch.spec.ts
- apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts
- apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.spec.ts
- apps/api/src/nextcloud-status/nextcloud-status.service.ts
- apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts
# Task 2 — web: error codes -> de/en texts, client pre-check, new label and hint
- apps/web/src/lib/nextcloud-status-api.ts
- apps/web/src/components/nextcloud-status/cloud-form-errors.ts
- apps/web/src/components/nextcloud-status/cloud-form-errors.test.ts
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.test.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/messages/umlaut-dictionary.ts
# Task 3 — docs, changelog, full suites, rebuild, live API probe
- CHANGELOG.md
- docs/anleitung-anwender.md
- docs/anleitung-administration.md
autonomous: true
requirements: [QUICK-261008-j9f]
estimate:
tokens: 90000
raw_tokens: 90000
tasks: 3
confidence: low
must_haves:
truths:
- "A manager who enters a public http:// image address when adding or editing a cloud gets the image downloaded by Tessera exactly once on save and stored exactly like an upload (logoData/logoMime on the row, 1 MiB limit, PNG/JPEG/GIF/WebP decided by magic bytes via checkLogoUpload, SVG refused); logoUrl stays empty, the response carries hasUploadedLogo true and the tile shows the logo through the existing GET instances/:id/logo route (D-01)"
- "An http address whose host is localhost, *.localhost, *.local, 0.0.0.0, a private/loopback/link-local/CGNAT/multicast IP literal or a name resolving to one is refused BEFORE any request is sent to it — also when it is the target of a redirect; at most 3 redirects, one total time limit for all hops plus the body read, and reading stops as soon as more than 1 MiB has arrived (D-02)"
- "Every failed download is reported in the form as a German Sie-form text and nothing is saved (no row created, no row changed): internal address -> hint to use „Bild hochladen“; not reachable/timeout/HTTP error -> check address or upload; no image -> allowed formats; larger than 1 MB -> size hint (D-03)"
- "https:// logo addresses behave exactly as before: stored as logoUrl, loaded by the viewer's browser, the server never fetches them (D-04)"
- "The cloud form never shows an English raw message: the browser pre-checks customer name, cloud address, logo address and logo file and shows German texts; every API error of these routes carries a stable code (body.code, or the code as class-validator message) that the web maps to de/en texts; 413 maps to the file-size text, unknown errors fall back to the generic German save/delete text (D-05)"
- "The logo address option is labelled „Bildadresse“ and its hint explains: https addresses are loaded by the browser, http images are fetched once by Tessera and stored, internal addresses only via upload; de/en keys identical, Sie-form, real umlauts, no Mandant/Lizenz words (D-06)"
- "Favorites icon discovery behaves exactly as before: the SSRF guard functions moved verbatim into a shared file, icon-discovery.service.ts re-exports isPublicHttpUrl, and icon-discovery.service.spec.ts, favorites.service.ts and favorites.controller.ts are byte-identical to commit 4ff43c2 and green"
- "CHANGELOG.md (Unveröffentlicht), docs/anleitung-anwender.md and docs/anleitung-administration.md describe http logos fetched once and the German messages; full api/web suites and both tsc runs green; the rebuilt stack answers a POST with logoUrl http://127.0.0.1/logo.png with 400 and code logoFetchInternal (D-07)"
artifacts:
- path: "apps/api/src/common/public-url-guard.ts"
provides: "isPublicHttpUrl plus the private-IP and blocked-hostname helpers, moved verbatim from favorites/icon-discovery.service.ts"
contains: "export async function isPublicHttpUrl"
- path: "apps/api/src/nextcloud-status/nextcloud-logo-fetch.ts"
provides: "classifyLogoUrl (none/remote/fetch/invalid) and fetchLogoImage (per-hop SSRF guard, manual redirects, total deadline, streaming 1 MiB cap, magic-byte check, typed failure codes)"
exports: ["classifyLogoUrl", "fetchLogoImage", "LOGO_FETCH_TIMEOUT_MS", "LOGO_FETCH_MAX_REDIRECTS"]
- path: "apps/api/src/nextcloud-status/nextcloud-form-errors.ts"
provides: "the 12 form error codes with their German messages, nextcloudFormError(code) helper and validateInstanceInput"
contains: "logoFetchInternal"
- path: "apps/web/src/components/nextcloud-status/cloud-form-errors.ts"
provides: "validateCloudForm (client pre-check) and cloudFormErrorKey (error -> translation key)"
exports: ["CLOUD_FORM_ERROR_CODES", "validateCloudForm", "cloudFormErrorKey"]
key_links:
- from: "apps/api/src/nextcloud-status/nextcloud-status.service.ts"
to: "apps/api/src/nextcloud-status/nextcloud-logo-fetch.ts"
via: "createInstance/updateInstance call fetchLogoImage for http addresses BEFORE any write"
pattern: "fetchLogoImage"
- from: "apps/api/src/nextcloud-status/nextcloud-logo-fetch.ts"
to: "apps/api/src/common/public-url-guard.ts"
via: "isPublicHttpUrl checked for the start address and every redirect hop"
pattern: "isPublicHttpUrl"
- from: "apps/api/src/favorites/icon-discovery.service.ts"
to: "apps/api/src/common/public-url-guard.ts"
via: "import + re-export, behaviour unchanged"
pattern: "public-url-guard"
- from: "apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx"
to: "apps/web/src/components/nextcloud-status/cloud-form-errors.ts"
via: "pre-check before saving, error -> t(key) after a failed save"
pattern: "cloudFormErrorKey"
---
<objective>
Allow http:// logo addresses in the Nextcloud-Status cloud form: Tessera downloads the image once on save (SSRF-protected, time and size limited) and stores it exactly like an uploaded logo; https addresses stay unchanged. At the same time remove every English raw validation message from this form (client pre-check plus API error codes mapped to de/en texts) and adjust form texts, changelog and manuals.
User decisions (Variante B, from the task description, numbered here for traceability):
- D-01: http logo addresses are allowed; Tessera downloads the image server-side ONCE and stores it exactly as if uploaded (same storage path, same limits: max 1 MB, image types by byte check like the upload); logoUrl is then not stored (emptied).
- D-02: Internet addresses only — SSRF protection is mandatory: block private/internal IPs, localhost, .local etc., also after redirects; reuse the existing guard (apps/api/src/favorites/icon-discovery.service.ts) where it can be shared cleanly WITHOUT changing favorites behaviour; time limit; size limit while streaming.
- D-03: On failure (internal, unreachable, no image, too large) a clear German message (Sie-form), e.g. pointing to upload for internal addresses.
- D-04: https addresses behave as before (browser loads them, logoUrl stored).
- D-05: No English raw messages in this form: all validation errors (customer name, address, logo address, logo file) appear in German. Preferred: client pre-checks + API returns error codes that the web maps to de/en texts — follow the module's existing pattern (codes + translation, like errorKind/error-hint and the domains module's `{ code, message }`).
- D-06: Form texts: label „Bildadresse (https)“ and the https-only hint change (http now allowed; hint that http images are fetched once and stored by Tessera, internal addresses only via upload). de/en keys identical, Sie-form, no Mandant/Lizenz terms.
- D-07: CHANGELOG.md under „## Unveröffentlicht“ (existing ### Neu / ### Geändert, add ### Behoben if needed) in plain words; docs/anleitung-anwender.md section Nextcloud-Status adjusted (and docs/anleitung-administration.md, which states the server never fetches the logo address).
Purpose: Customers' logos often sit on plain-http sites; today the form rejects them with "logoUrl must be a URL address". The user wants them to just work, safely.
Output: shared SSRF guard file, logo download module with tests, code-based form errors in API and web, updated texts and docs, rebuilt local stack.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
@.planning/quick/261002-k67-modul-nextcloud-status-mit-ampel-kacheln/261002-k67-SUMMARY.md
Project rules that apply here:
- API TS lib has no Object.hasOwn; use `Object.prototype.hasOwnProperty.call` or `in`.
- Global ValidationPipe (apps/api/src/main.ts): `whitelist: true, transform: true`, no exceptionFactory. Global pipes run before any controller/param pipe, so a per-route pipe cannot replace its messages — codes must come from the DTO decorators' `message` option and from the service.
- NestJS: static routes before `:id` routes (unchanged here — no new routes).
- Never read .env files. Rebuild locally with `docker compose up -d --build api web`. No deploy to the test server, no push (commits stay local; the user pushes bundled).
- UI texts: Sie-form, real umlauts (apps/web/src/messages/umlaut-guard.spec.ts fails on ae/oe/ue/ss tokens not on UMLAUT_ALLOWLIST in apps/web/src/messages/umlaut-dictionary.ts — add correct German words there if the guard trips), de/en keys identical, no „Mandant“/„Lizenz“.
- RLS inventory: nextcloud-status.service.ts currently has 14 `tenantPrisma.<model>.` call sites (docs/mandantentrennung-zugriffsklassifikation.md row nextcloud-status 0/23/1). Do NOT add new tenantPrisma calls; the logo download needs none. If the count changes anyway, update that row and the inventory spec the way quick-261002-k67 did.
- Local admin for the live probe: admin/admin123.
Existing code facts (already read by the planner):
- apps/api/src/favorites/icon-discovery.service.ts: module-private `isPrivateIpv4`, `isPrivateIpv6`, `isPrivateIpAddress`, `isBlockedHostname`, exported `isPublicHttpUrl(url: URL): Promise<boolean>` (http/https only, blocked names, IP literal check, otherwise `dns.lookup(all:true)` and every address must be public). `fetchWithRedirectGuard` is private, returns null for both "blocked" and "failed" (cannot tell them apart), uses a lenient-TLS agent and MAX_REDIRECTS 2 — not suitable to share as is. favorites.service.ts imports `IconDiscoveryService, normalizeUrl` from it; icon-discovery.service.spec.ts imports `discardBody, IconDiscoveryService, isPublicHttpUrl, normalizeUrl, readTextCapped` from it and mocks `undici`.
- apps/api/src/nextcloud-status/nextcloud-logo-rules.ts: `NEXTCLOUD_LOGO_MAX_BYTES` (1 MiB), `checkLogoUpload(buffer)` -> `DashboardImageMime | null` (empty, too large or not PNG/JPEG/GIF/WebP -> null).
- apps/api/src/nextcloud-status/nextcloud-status-fetch.ts: `fetchNextcloudStatus(baseUrl, { fetchImpl?, timeoutMs? })` is the module's pattern for an injectable `undici` fetch with `redirect: 'manual'`, one AbortController deadline and `Promise.race` against abort for hanging servers; `normalizeCloudUrl(raw)` returns null for invalid input. This status fetch deliberately allows internal addresses — do not change it.
- apps/api/src/nextcloud-status/nextcloud-status.service.ts: `INVALID_URL_MESSAGE`, `createInstance` (normalize -> create -> checkInstance), `updateInstance` (findFirst 404 -> build data -> update -> re-check if URL changed; non-empty logoUrl replaces upload, empty removes only the address, logoVersion increments), `uploadLogo` (checkLogoUpload -> German BadRequest), several `NotFoundException('Cloud nicht gefunden')`.
- Domains pattern for codes: `throw new BadRequestException({ code: 'customerNameRequired', message: 'Bitte geben Sie einen Namen an.' })`; web apps/web/src/lib/domains-api.ts `DomainsRequestError(status, code, message)` reads `body.code`.
- DTO spec pattern: apps/api/src/domains/dto/domains-settings.dto.spec.ts (`plainToInstance` + `validate`, `import 'reflect-metadata'`).
- Web: apps/web/src/lib/nextcloud-status-api.ts `readErrorMessage(res, fallback)` returns body.message (string or first array entry) — this is how "logoUrl must be a URL address" reaches the user today. CloudForm.tsx shows `t('required')`, `t('logoFileTooLarge')`, or the raw error text; CloudForm.test.tsx renders with real de.json and mocks the api module via importOriginal.
</context>
<tasks>
<task type="tracer" tdd="true">
<name>Task 1 (tracer, API): http logo address -> shared SSRF guard -> capped download -> stored like an upload; every form error as code + German text</name>
<files>apps/api/src/common/public-url-guard.ts, apps/api/src/favorites/icon-discovery.service.ts, apps/api/src/nextcloud-status/nextcloud-form-errors.ts, apps/api/src/nextcloud-status/nextcloud-logo-fetch.ts, apps/api/src/nextcloud-status/nextcloud-logo-fetch.spec.ts, apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts, apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.spec.ts, apps/api/src/nextcloud-status/nextcloud-status.service.ts, apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts</files>
<read_first>apps/api/src/favorites/icon-discovery.service.ts (lines 1-160), apps/api/src/nextcloud-status/nextcloud-status-fetch.ts (lines 180-300), apps/api/src/nextcloud-status/nextcloud-status.service.ts, apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts (lines 1-150, 314-380), apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts, apps/api/src/nextcloud-status/nextcloud-logo-rules.ts, apps/api/src/domains/dto/domains-settings.dto.spec.ts (lines 1-15)</read_first>
<behavior>
nextcloud-logo-fetch.spec.ts (no real network: inject a fake fetchImpl built from global `Response`; use IP literals so the REAL isPublicHttpUrl decides without DNS, or inject `isPublic` for hostname cases):
- classifyLogoUrl: undefined -> unchanged/none semantics as specified below; '' and ' ' -> none; ' https://a.de/x.png ' -> remote with trimmed url; 'https://intranet/logo.png' (no TLD) -> remote; 'http://a.de/x.png' -> fetch; 'ftp://a.de/x', 'javascript:alert(1)', 'kein link', 'http://user:pw@a.de/x.png', a 2049-char http URL -> invalid.
- Success: http://93.184.216.34/logo.png answers 200 with PNG magic bytes -> ok, mime image/png, data equals the bytes; the single fetchImpl call used method GET, redirect 'manual', an AbortSignal, and sent no Cookie/Authorization header.
- Content-type is not trusted: 200 with content-type image/png but HTML bytes -> logoFetchNotImage; SVG bytes -> logoFetchNotImage; empty body -> logoFetchNotImage.
- Internal start address: http://127.0.0.1/x, http://10.0.0.5/x, http://192.168.1.10/x, http://169.254.169.254/latest, http://localhost/x, http://nas.local/x -> logoFetchInternal and fetchImpl NEVER called.
- Redirect to internal: 302 Location http://10.0.0.5/logo.png -> logoFetchInternal, fetchImpl called exactly once.
- Redirect to public then image: 301 -> https://93.184.216.35/logo.png -> ok (two calls).
- Four redirects in a row -> logoFetchUnreachable; 302 without Location -> logoFetchUnreachable.
- HTTP 404 -> logoFetchUnreachable; fetchImpl rejects (ECONNREFUSED-like error) -> logoFetchUnreachable.
- Too large: content-length 2097152 -> logoFetchTooLarge without reading; a body stream without content-length that keeps delivering 256 KiB chunks -> logoFetchTooLarge, reading stops (the stream's pull count stays small, e.g. <= 6) and the stream is cancelled.
- Time limit: fetchImpl that never settles, timeoutMs 50 -> logoFetchUnreachable well under 1 s; a body that stalls after the headers, timeoutMs 50 -> logoFetchUnreachable.
nextcloud-instance.dto.spec.ts: for wrong types (number customerName, missing baseUrl on create, number logoUrl) every constraint message is one of the 12 codes — no English sentence.
nextcloud-status.service.spec.ts (mock './nextcloud-logo-fetch' via importOriginal, keep classifyLogoUrl real, stub fetchLogoImage):
- createInstance with http logo, fetch ok -> prisma create data has logoData (Uint8Array of the bytes), logoMime 'image/png', logoUrl null; the result view has hasUploadedLogo true.
- createInstance with http logo, fetch fails with logoFetchInternal -> BadRequestException whose getResponse() equals { code: 'logoFetchInternal', message: <German text> }; prisma create and fetchNextcloudStatus NOT called.
- createInstance with https logo -> fetchLogoImage not called, logoUrl stored as before (D-04).
- updateInstance with http logo, fetch ok -> update data sets logoData/logoMime, logoUrl null, logoVersion increment; fetch fails -> no update call; unknown id -> NotFound (code notFound) and fetchLogoImage not called.
- Codes: create with customerName ' ' -> customerNameRequired; 121-char name -> customerNameTooLong; baseUrl '' -> baseUrlRequired; baseUrl 'cloud.example.de' -> baseUrlInvalid (replaces the old INVALID_URL_MESSAGE test, same German text); logoUrl 'ftp://x' -> logoUrlInvalid; uploadLogo with a non-image -> logoFileInvalid, with a buffer over 1 MiB -> logoFileTooLarge.
</behavior>
<action>
Work in this order so the http path is proven first (tracer), then the remaining codes are added.
1. Shared guard (D-02, reuse without changing favorites). Create apps/api/src/common/public-url-guard.ts and MOVE, character for character, `isPrivateIpv4`, `isPrivateIpv6`, `isPrivateIpAddress`, `isBlockedHostname` and the exported `isPublicHttpUrl` (with their imports from node:dns/promises and node:net) from apps/api/src/favorites/icon-discovery.service.ts. Add a short German header comment (shared SSRF guard, origin T-08-05, now also used by the Nextcloud logo download). In icon-discovery.service.ts delete the moved definitions and now-unused imports, import `isPublicHttpUrl` from '../common/public-url-guard' for its own use and re-export it under the same name so existing imports keep working. Do not touch anything else in that file (lenient TLS agent, redirect budget, timeouts stay as they are) and do not edit icon-discovery.service.spec.ts, favorites.service.ts or favorites.controller.ts.
2. Error catalogue. Create apps/api/src/nextcloud-status/nextcloud-form-errors.ts with a readonly map from the 12 codes to their German Sie-form messages and the derived type `NextcloudFormErrorCode`:
customerNameRequired „Bitte geben Sie einen Kundennamen ein.“; customerNameTooLong „Der Kundenname darf höchstens 120 Zeichen lang sein.“; baseUrlRequired „Bitte geben Sie die Adresse der Cloud ein.“; baseUrlInvalid „Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.“; logoUrlInvalid „Bitte geben Sie eine gültige Bildadresse mit http:// oder https:// ein.“; logoFileInvalid „Bitte laden Sie ein Bild im Format PNG, JPEG, GIF oder WebP bis 1 MB hoch.“; logoFileTooLarge „Die Datei ist größer als 1 MB.“; logoFetchInternal „Diese Bildadresse ist nur intern erreichbar. Tessera holt nur Bilder aus dem Internet ab. Bitte laden Sie das Bild über „Bild hochladen“ hoch.“; logoFetchUnreachable „Das Bild konnte unter dieser Adresse nicht abgerufen werden. Bitte prüfen Sie die Adresse oder laden Sie das Bild über „Bild hochladen“ hoch.“; logoFetchNotImage „Unter dieser Adresse liegt kein Bild im Format PNG, JPEG, GIF oder WebP.“; logoFetchTooLarge „Das Bild unter dieser Adresse ist größer als 1 MB.“; notFound „Diese Cloud gibt es nicht mehr. Bitte laden Sie die Seite neu.“
Export `nextcloudFormError(code)` returning `new NotFoundException({ code, message })` for notFound and `new BadRequestException({ code, message })` for all others (domains pattern), and `NEXTCLOUD_FORM_ERROR_CODES` (array of the keys) for the DTO spec. Export `validateInstanceInput(input, mode)` with mode 'create' or 'update': customerName (required on create, checked when present on update) is trimmed — empty -> customerNameRequired, longer than 120 -> customerNameTooLong; baseUrl (same presence rule) — empty after trim -> baseUrlRequired, `normalizeCloudUrl` null -> baseUrlInvalid; returns the trimmed name and normalized URL. Check order: name, address, then (in the service) logo.
3. Download module (D-01, D-02, D-03). Create apps/api/src/nextcloud-status/nextcloud-logo-fetch.ts:
- `classifyLogoUrl(raw: string)` -> one of: kind 'none' (empty after trim), kind 'remote' with the trimmed string (https), kind 'fetch' with a URL object (http), kind 'invalid' (longer than 2048, not parseable by `new URL`, protocol other than http/https, username or password present, no hostname). Hosts without a dot stay allowed like before (the old validator had require_tld false).
- Constants `LOGO_FETCH_TIMEOUT_MS = 8000`, `LOGO_FETCH_MAX_REDIRECTS = 3`; failure codes typed as the four logoFetch* members of NextcloudFormErrorCode.
- `fetchLogoImage(url: URL, opts?: { fetchImpl?: typeof undiciFetch; isPublic?: (u: URL) => Promise<boolean>; timeoutMs?: number })` resolving to either ok with data (Buffer) and mime (DashboardImageMime) or not-ok with code. Defaults: undici's `fetch` (module import, like nextcloud-status-fetch.ts; normal certificate checks — no lenient agent) and `isPublicHttpUrl` from '../common/public-url-guard'.
- One AbortController plus one timer for the WHOLE operation (all hops and the body read); race the work against the abort like fetchNextcloudStatus so a hanging server cannot hold the request; always clear the timer.
- Loop over hops: before EVERY request await isPublic(current) — false -> logoFetchInternal without sending anything. Request: method GET, redirect 'manual', the signal, headers Accept (image/png, image/jpeg, image/gif, image/webp, image/*;q=0.8) and a browser-like User-Agent (as in IconDiscoveryService.fetchIconBytes, WAFs block bare clients); never cookies or credentials. 3xx: discard the body, missing/unparseable Location or more than LOGO_FETCH_MAX_REDIRECTS hops -> logoFetchUnreachable, otherwise resolve Location against the current URL and continue (the next iteration re-checks it). Non-2xx -> discard body, logoFetchUnreachable. Thrown errors or abort -> logoFetchUnreachable.
- Size: a numeric content-length above NEXTCLOUD_LOGO_MAX_BYTES -> discard, logoFetchTooLarge. Otherwise read `response.body` with a reader, summing chunk lengths; as soon as the sum exceeds NEXTCLOUD_LOGO_MAX_BYTES cancel the reader and return logoFetchTooLarge. A null body counts as empty.
- Type: `checkLogoUpload(bytes)`; null -> logoFetchNotImage (the content-type header is ignored for the decision). Never log or return response text; log at most host plus code at debug/warn level.
- German header comment: why http only is fetched (D-01/D-04), guard per hop, deadline, cap, magic bytes, residual DNS-rebinding window accepted like favorites (T-j9f-02).
4. DTO (D-05). In apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts keep only type guards that carry codes: Create — customerName and baseUrl with IsString whose message is 'customerNameRequired' / 'baseUrlRequired'; logoUrl IsOptional plus IsString with message 'logoUrlInvalid'. Update — the same three fields, all IsOptional. Remove the URL, not-empty and max-length decorators and the ValidateIf; those rules now live in validateInstanceInput and classifyLogoUrl (lengths stay enforced: 120 / 2048 / 2048). Update the class comment (http allowed, fetched once; https loaded by the browser). Create apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.spec.ts per the behavior list (each constraint message must be in NEXTCLOUD_FORM_ERROR_CODES; valid payloads with http and https logo and empty logoUrl produce no errors).
5. Service wiring (D-01, D-03, D-04). In nextcloud-status.service.ts remove INVALID_URL_MESSAGE.
- createInstance: validateInstanceInput(dto, 'create'); if dto.logoUrl is given classify it — invalid -> nextcloudFormError('logoUrlInvalid'); fetch kind -> await fetchLogoImage BEFORE creating, a failure throws nextcloudFormError(result.code). The single existing create call then writes logoUrl (remote only, else null) and, for a fetched image, logoData (new Uint8Array of the bytes) and logoMime. Then checkInstance as before.
- updateInstance: keep the existing findFirst (unknown id -> notFound BEFORE any download), then validateInstanceInput(dto, 'update'), then the logo: remote -> previous behaviour (logoUrl set, logoData/logoMime cleared); fetch -> download first, on success logoData/logoMime set, logoUrl null; none -> logoUrl null only (previous behaviour, an upload stays); every logo change increments logoVersion. A download failure throws before the update call. URL-change re-check logic unchanged.
- uploadLogo: missing file or checkLogoUpload null -> logoFileTooLarge when the buffer is longer than NEXTCLOUD_LOGO_MAX_BYTES, otherwise logoFileInvalid.
- Replace every `NotFoundException('Cloud nicht gefunden')` thrown by checkInstance, updateInstance, deleteInstance, uploadLogo and removeLogo with nextcloudFormError('notFound'); leave getLogo's „Kein Logo vorhanden“ (image route, not the form).
- Keep the number of tenantPrisma call sites at 14 (no extra queries). Update the method comments (D-A note: http image stored like an upload).
- Extend nextcloud-status.service.spec.ts per the behavior list; adapt the existing invalid-address and upload-rejection tests to assert the codes (instance checks on NotFoundException/BadRequestException stay valid).
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/nextcloud-status src/favorites rls-coverage rls-access-inventory && pnpm --filter @tessera/api exec tsc --noEmit && git diff --quiet 4ff43c2 -- apps/api/src/favorites/icon-discovery.service.spec.ts apps/api/src/favorites/favorites.service.ts apps/api/src/favorites/favorites.controller.ts && grep -q "export async function isPublicHttpUrl" apps/api/src/common/public-url-guard.ts && grep -q "public-url-guard" apps/api/src/favorites/icon-discovery.service.ts && grep -q "public-url-guard" apps/api/src/nextcloud-status/nextcloud-logo-fetch.ts && grep -q "fetchLogoImage" apps/api/src/nextcloud-status/nextcloud-status.service.ts && test "$(grep -cE 'tenantPrisma\.[a-zA-Z]*\.' apps/api/src/nextcloud-status/nextcloud-status.service.ts)" = "14" && echo "tracer api ok"</automated>
</verify>
<done>An http logo address on create/update is downloaded once through the shared per-hop guard (deadline, 1 MiB streaming cap, magic bytes) and stored in logoData/logoMime with logoUrl null; internal targets (also via redirect) are refused without a request; every failure and every validation error of these routes is a `{ code, message }` with German text (DTO messages are codes); https behaves as before; favorites files unchanged and green; RLS inventory unchanged.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2 (web): German errors only — client pre-check, code -> de/en text, new label and hint</name>
<files>apps/web/src/lib/nextcloud-status-api.ts, apps/web/src/components/nextcloud-status/cloud-form-errors.ts, apps/web/src/components/nextcloud-status/cloud-form-errors.test.ts, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts</files>
<read_first>apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx, apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.test.tsx, apps/web/src/lib/nextcloud-status-api.ts (lines 95-200), apps/web/src/lib/domains-api.ts (lines 50-77), apps/api/src/nextcloud-status/nextcloud-form-errors.ts (from Task 1, for the exact code list)</read_first>
<behavior>
cloud-form-errors.test.ts:
- validateCloudForm: name ' ' -> customerNameRequired; 121 chars -> customerNameTooLong; address '' -> baseUrlRequired; 'cloud.example.de' or 'ftp://x' -> baseUrlInvalid; logo mode 'url' with 'ftp://x', 'kein link' or 'http://u:p@a.de/x.png' -> logoUrlInvalid; logo mode 'url' with 'http://logo.example.de/a.png' or 'https://…' or '' -> null; logo mode 'none'/'upload' ignores the logo field.
- cloudFormErrorKey: NextcloudFormError with each known code -> 'errors.<code>'; code null and status 413 -> 'errors.logoFileTooLarge'; status 404 without code -> 'errors.notFound'; unknown code or English-only message -> the given fallback ('saveError' or 'deleteError'); a plain Error -> fallback.
CloudForm.test.tsx (real de.json):
- Empty customer name -> „Bitte geben Sie einen Kundennamen ein.“, no API call; address without scheme -> „Bitte geben Sie eine gültige Adresse mit http:// oder https:// ein.“, no API call; logo address 'ftp://x' -> German logoUrlInvalid text, no API call.
- An http logo address is sent unchanged to createInstance (http allowed now).
- createInstance rejects with NextcloudFormError(400, 'logoFetchInternal', …) -> the German text containing „Bild hochladen“ is shown.
- createInstance rejects with NextcloudFormError(400, null, 'logoUrl must be a URL address') -> „Die Cloud konnte nicht gespeichert werden.“ is shown and the English text is NOT in the document.
- uploadLogo rejects with status 413 -> „Die Datei ist größer als 1 MB.“
- A picked file whose type is not PNG/JPEG/GIF/WebP -> logoFileInvalid text immediately.
- Existing tests adapted: label „Bildadresse“ (radio and input), the new hint (mentions that Tessera fetches http images once), the „Kein Logo“, edit, delete and too-large-file cases stay green.
</behavior>
<action>
1. API client (D-05). In apps/web/src/lib/nextcloud-status-api.ts add an exported class `NextcloudFormError extends Error` with readonly `status: number` and `code: string | null` (message = server message or fallback, so other callers that read `.message` keep working). Replace readErrorMessage by a reader that builds this error: code = `body.code` when it is a string; otherwise, when `body.message` is an array whose first entry is a string made only of letters starting lowercase (a class-validator code from Task 1's DTO), that entry; otherwise null. Throw it from createInstance, updateInstance, deleteInstance, uploadLogo, removeLogo (other functions may use it too; NextcloudRequestError for alerts stays).
2. Pure helpers (D-05). Create apps/web/src/components/nextcloud-status/cloud-form-errors.ts:
- `CLOUD_FORM_ERROR_CODES`: the same 12 codes as the API catalogue (customerNameRequired, customerNameTooLong, baseUrlRequired, baseUrlInvalid, logoUrlInvalid, logoFileInvalid, logoFileTooLarge, logoFetchInternal, logoFetchUnreachable, logoFetchNotImage, logoFetchTooLarge, notFound) and the union type.
- `validateCloudForm({ customerName, baseUrl, logoMode, logoUrl })` mirroring the API rules (trim; name 1–120; address required and parseable with http/https and no username/password; logo address only checked in mode 'url' and when non-empty: max 2048, parseable, http/https, no credentials) -> first failing code or null, in the order name, address, logo.
- `isAllowedLogoFileType(type: string)` for PNG/JPEG/GIF/WebP.
- `cloudFormErrorKey(err, fallback)` with fallback 'saveError' | 'deleteError' -> a translation key: 'errors.<code>' for a NextcloudFormError with a known code, 'errors.logoFileTooLarge' for status 413, 'errors.notFound' for status 404, else the fallback. It never returns server text.
Write cloud-form-errors.test.ts per the behavior list.
3. Form (D-05, D-06). In CloudForm.tsx keep the error state as a translation key (or null) and always render it through `t(...)` — the server's message text is never rendered. handleSubmit: run validateCloudForm before setSaving; on a code set 'errors.<code>' and return without any API call. The catch blocks use cloudFormErrorKey with 'saveError' / 'deleteError'. handleFile: a picked file whose type is not allowed -> 'errors.logoFileInvalid' and no upload; oversized after shrinking -> 'errors.logoFileTooLarge' (replaces the old top-level key). Update the component doc comment: logo is an upload or an image address; https is loaded by the browser, http is fetched once by Tessera and stored like an upload.
4. Texts (D-06), de.json and en.json under nextcloudStatus.form, keys identical in both files:
- Add `errors` with all 12 codes. de texts exactly as the API catalogue in Task 1; en: customerNameRequired "Please enter a customer name."; customerNameTooLong "The customer name may be at most 120 characters long."; baseUrlRequired "Please enter the address of the cloud."; baseUrlInvalid "Please enter a valid address starting with http:// or https://."; logoUrlInvalid "Please enter a valid image address starting with http:// or https://."; logoFileInvalid "Please upload a PNG, JPEG, GIF or WebP image of at most 1 MB."; logoFileTooLarge "The file is larger than 1 MB."; logoFetchInternal "This image address can only be reached internally. Tessera only fetches images from the internet. Please use “Upload image” instead."; logoFetchUnreachable "The image could not be retrieved from this address. Please check the address or use “Upload image”."; logoFetchNotImage "There is no PNG, JPEG, GIF or WebP image at this address."; logoFetchTooLarge "The image at this address is larger than 1 MB."; notFound "This cloud no longer exists. Please reload the page."
- Remove the now unused top-level keys `required` and `logoFileTooLarge` from both files.
- `logoUrl`: de „Bildadresse“, en "Image address". `logoUrlHint`: de „Adressen mit https:// lädt Ihr Browser direkt. Ein Bild unter einer http://-Adresse holt Tessera beim Speichern einmalig ab und speichert es wie ein hochgeladenes Bild. Bilder, die nur intern erreichbar sind, laden Sie bitte über „Bild hochladen“ hoch.“; en "Addresses starting with https:// are loaded directly by your browser. An image at an http:// address is fetched once by Tessera when you save and stored like an uploaded image. For images that can only be reached internally, please use “Upload image”." Placeholder unchanged.
- If umlaut-guard.spec.ts flags a correct German word, add it to UMLAUT_ALLOWLIST in apps/web/src/messages/umlaut-dictionary.ts (one line each); never rewrite a word into ae/oe/ue/ss.
5. Adapt and extend CloudForm.test.tsx per the behavior list (label queries „Bildadresse“, hint assertion on the new text, the old raw-message test becomes the code-based and the English-fallback test).
</action>
<verify>
<automated>pnpm --filter @tessera/web exec vitest run nextcloud-status src/messages && pnpm --filter @tessera/web exec tsc --noEmit && node -e 'const de=require("./apps/web/src/messages/de.json"),en=require("./apps/web/src/messages/en.json");const w=(o,p,r)=>{for(const[k,v]of Object.entries(o||{})){const q=p+"."+k;if(v&&typeof v==="object")w(v,q,r);else r[q]=v}return r};const a=w(de.nextcloudStatus,"n",{}),b=w(en.nextcloudStatus,"n",{});if(Object.keys(a).sort().join()!==Object.keys(b).sort().join()){console.error("key mismatch");process.exit(1)}const codes=["customerNameRequired","customerNameTooLong","baseUrlRequired","baseUrlInvalid","logoUrlInvalid","logoFileInvalid","logoFileTooLarge","logoFetchInternal","logoFetchUnreachable","logoFetchNotImage","logoFetchTooLarge","notFound"];for(const c of codes)if(!a["n.form.errors."+c]){console.error("missing",c);process.exit(1)}if(a["n.form.logoUrl"]!=="Bildadresse"||!/http:\/\//.test(a["n.form.logoUrlHint"])||/beginnen/.test(a["n.form.logoUrlHint"])){console.error("label/hint");process.exit(1)}if("n.form.required" in a||"n.form.logoFileTooLarge" in a){console.error("old keys");process.exit(1)}for(const v of [...Object.values(a),...Object.values(b)])if(/mandant|tenant|lizenz|licens/i.test(String(v))){console.error("bad text",v);process.exit(1)}console.log("web form ok")'</automated>
</verify>
<done>The form blocks invalid input with German texts before calling the API, maps every API error of these routes via its code (or 413/404) to de/en texts, never shows server text (an English-only server message shows the generic German save error), accepts http logo addresses, and shows the label „Bildadresse“ with the new hint; de/en keys identical; web tests, umlaut guard and tsc green.</done>
</task>
<task type="auto">
<name>Task 3: Changelog and manuals, full suites, rebuild and live API probe</name>
<files>CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md</files>
<read_first>CHANGELOG.md (lines 1-20), docs/anleitung-anwender.md (lines 225-240), docs/anleitung-administration.md (lines 348-362)</read_first>
<precondition>The local Docker stack from docker-compose.yml (db, api, web) can be built and started on the dev host.</precondition>
<action>
1. CHANGELOG.md under „## Unveröffentlicht“ (D-07), plain words, Sie-form, no technical terms:
- In the existing „### Geändert“ add a bullet: Nextcloud-Status — as logo you can now also enter an image address beginning with http://; Tessera fetches the image once when saving and keeps it like an uploaded image (at most 1 MB, PNG, JPEG, GIF or WebP). Addresses that are only reachable internally are not fetched — such images are uploaded via „Bild hochladen“. Addresses with https:// are still loaded directly by the browser. The field is now called „Bildadresse“.
- Add „### Behoben“ after „### Geändert“ (inside „Unveröffentlicht“ only) with a bullet: Nextcloud-Status — the cloud form showed English messages for some inputs (for example for an image address with http://). All notes in this form now appear in German (in English with English language setting).
2. docs/anleitung-anwender.md, section Nextcloud-Status (the „Clouds pflegen“ paragraph): replace the sentence about the https address with: upload (PNG, JPEG, GIF or WebP, at most 1 MB) or an image address — https addresses are loaded by the browser, for http addresses Tessera fetches the image once when saving and stores it like an upload; internal addresses cannot be fetched, the form then points to „Bild hochladen“.
3. docs/anleitung-administration.md, Nextcloud-Status „Logo:“ bullet: the sentence that the server never fetches the address is no longer true — state that https addresses are loaded by the viewer's browser and never by the server; http addresses are fetched exactly once by the Tessera server on saving, only from the internet (internal and private addresses, also via redirects, are refused before any request), with a time limit, at most 1 MB and only PNG/JPEG/GIF/WebP checked on the bytes; the result is stored like an upload, the address itself is not kept. Upload and address still exclude each other.
4. Run the full gates: both test suites, both tsc runs, Biome lint on the changed/new files only (fix findings in new files; do not touch unrelated warnings). Rebuild with `docker compose up -d --build api web` and wait until api is up.
5. Live probe (the verify command does it): log in as admin, activate nextcloud-status if inactive, POST instances with a valid name and address and logoUrl http://127.0.0.1/logo.png -> 400 with code logoFetchInternal; the same with logoUrl ftp://x -> 400 logoUrlInvalid; customerName '' -> 400 customerNameRequired. None of these creates a row (validation and download refusal happen before the create).
6. In the SUMMARY list browser steps for the orchestrator (see output).
</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 && sed -n '/^## Unveröffentlicht/,/^## 1\./p' CHANGELOG.md | grep -q "http://" && sed -n '/^## Unveröffentlicht/,/^## 1\./p' CHANGELOG.md | grep -q "### Behoben" && grep -q "einmalig" docs/anleitung-anwender.md && grep -q "einmalig" docs/anleitung-administration.md && docker compose ps --status running --services | grep -qx api && docker compose ps --status running --services | grep -qx web && A=$(mktemp) && B=$(mktemp) && curl -sf -c "$A" -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}' http://localhost:3001/auth/login >/dev/null && MID=$(curl -sf -b "$A" http://localhost:3001/modules/catalog | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{const m=JSON.parse(s).find(x=>x.slug==="nextcloud-status");if(!m)process.exit(1);process.stdout.write(m.isActiveForTenant?"":m.id)})') && { [ -z "$MID" ] || curl -sf -b "$A" -X POST "http://localhost:3001/modules/$MID/activate" >/dev/null; } && P=http://localhost:3001/modules/nextcloud-status/instances && test "$(curl -s -o "$B" -w '%{http_code}' -b "$A" -H 'Content-Type: application/json' -d '{"customerName":"Probe j9f","baseUrl":"https://cloud.example.invalid","logoUrl":"http://127.0.0.1/logo.png"}' $P)" = 400 && grep -q '"code":"logoFetchInternal"' "$B" && test "$(curl -s -o "$B" -w '%{http_code}' -b "$A" -H 'Content-Type: application/json' -d '{"customerName":"Probe j9f","baseUrl":"https://cloud.example.invalid","logoUrl":"ftp://x"}' $P)" = 400 && grep -q '"code":"logoUrlInvalid"' "$B" && test "$(curl -s -o "$B" -w '%{http_code}' -b "$A" -H 'Content-Type: application/json' -d '{"customerName":"","baseUrl":"https://cloud.example.invalid"}' $P)" = 400 && grep -q '"code":"customerNameRequired"' "$B" && ! curl -sf -b "$A" $P | grep -q "Probe j9f" && echo "final gates ok"</automated>
</verify>
<done>CHANGELOG (Geändert + Behoben under Unveröffentlicht), user and admin manuals describe http logos fetched once and internal addresses via upload; full api/web suites and both tsc runs green; rebuilt stack refuses an internal http logo with 400 logoFetchInternal, an invalid one with logoUrlInvalid, an empty name with customerNameRequired, and no probe row exists.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser -> API (POST/PUT instances) | Manager-supplied logo address (untrusted string) crosses here; only @ModuleManage('nextcloud-status') routes accept it |
| API -> internet (logo download) | Tessera server issues an outbound GET to a user-chosen http address and follows redirects chosen by a remote server |
| remote server -> API (response bytes) | Untrusted bytes that are stored and later served under Tessera's origin |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-j9f-01 | Information disclosure / Elevation (SSRF) | nextcloud-logo-fetch.ts fetchLogoImage | high | mitigate | isPublicHttpUrl (shared guard) before the first request and before every redirect hop; refused targets get no request at all; redirect 'manual', at most 3 hops, http/https only, URLs with credentials rejected by classifyLogoUrl, no cookies/auth headers; tests for literal private IPs, localhost/.local, metadata IP 169.254.169.254 and redirect-to-internal |
| T-j9f-02 | Information disclosure (DNS rebinding) | guard lookup vs. connect | low | accept | Same residual window as favorites icon fetch (T-08-05): name checked by lookup, connection resolves again. Only managers can trigger it, the result is stored only if the bytes are a PNG/JPEG/GIF/WebP, and the client only ever sees one of four fixed codes. Documented in the module header comment |
| T-j9f-03 | Denial of service | fetchLogoImage body read | medium | mitigate | One deadline (8 s) for all hops and the body, race against abort for hanging servers, content-length pre-check, streaming cap that cancels the reader once more than 1 MiB arrived; tests for never-settling fetch, stalled body and endless stream |
| T-j9f-04 | Tampering (malicious content served under Tessera origin) | stored logo bytes | high | mitigate | Type decided only by checkLogoUpload magic bytes (no SVG, content-type ignored), stored in the same logoData/logoMime columns as uploads and served by the existing logo route with nosniff and sandbox CSP (T-k67-02) |
| T-j9f-05 | Information disclosure (error oracle) | service error responses | low | mitigate | Only fixed codes and fixed German texts reach the client; no response text, headers or resolved IPs are returned or logged; logoFetchInternal is decided from the guard alone (no request) |
| T-j9f-06 | Elevation of privilege | create/update routes | medium | mitigate | Unchanged: @ModuleManage('nextcloud-status') without role decorator, tenantId only from req.tenantId, where { id, tenantId }; unknown id -> notFound before any download; tenantPrisma call count stays 14 (gate) |
| T-j9f-07 | Tampering (regression in favorites SSRF guard) | common/public-url-guard.ts | medium | mitigate | Verbatim move plus re-export; icon-discovery.service.spec.ts, favorites.service.ts and favorites.controller.ts must be byte-identical to 4ff43c2 and green (Task 1 gate) |
| T-j9f-SC | Tampering | npm/pip/cargo installs | high | accept | No package is installed; undici, class-validator and class-transformer are already dependencies of apps/api |
</threat_model>
<verification>
- Task 1 gate: nextcloud-status and favorites specs, RLS coverage/inventory, api tsc; favorites files unchanged vs 4ff43c2; guard shared; tenantPrisma count 14.
- Task 2 gate: web nextcloud-status tests, message specs (umlaut guard), web tsc, de/en parity, 12 error keys, new label/hint, old keys gone, no Mandant/Lizenz words.
- Task 3 gate: full api and web suites, both tsc, changelog/manual greps, running api/web, live probe with three 400 codes and no probe row.
</verification>
<success_criteria>
- http logo addresses work and are stored like uploads; https unchanged (D-01, D-04).
- Internal/private targets, also via redirects, are refused before any request; time and size limits enforced (D-02).
- Every failure and validation error in the cloud form appears in German (English with English UI), never as a raw server message (D-03, D-05).
- Label „Bildadresse“ and new hint, de/en identical (D-06).
- CHANGELOG and both manuals updated; all suites green; local stack rebuilt (D-07).
- Favorites behaviour unchanged.
</success_criteria>
<output>
Create `.planning/quick/261008-j9f-nextcloud-status-logo-per-http-adresse-h/261008-j9f-SUMMARY.md` when done. Include: commits per task, test/gate measurements (test counts, tsc, Biome on new files, live probe output), any umlaut allowlist additions, and browser steps for the orchestrator (dark mode): (1) Nextcloud-Status -> edit a cloud -> „Bildadresse“ shows the new hint; (2) enter a public http:// image address of a real website, save -> tile shows the logo, reopening the form shows „Bild hochladen“ selected (stored like an upload); (3) http://192.168.x.x/logo.png -> German hint pointing to „Bild hochladen“, nothing saved; (4) http address of an HTML page -> „Unter dieser Adresse liegt kein Bild …“; (5) empty name, address without http(s) and ftp:// logo address -> German texts without any request; (6) https logo address still works as before; (7) switch the UI to English once and repeat (5) -> English texts; (8) favorites: an existing favorite still shows its icon.
</output>
@@ -0,0 +1,118 @@
---
phase: quick-261008-j9f
plan: 01
subsystem: nextcloud-status
tags: [ssrf, logo, validation, i18n, nextcloud-status]
requires: []
provides:
- "http logo address fetched once by Tessera and stored like an upload"
- "shared SSRF guard (apps/api/src/common/public-url-guard.ts)"
- "code-based form errors (API + web) for the cloud form"
affects: [favorites (re-export only), nextcloud-status]
tech-stack:
added: []
patterns: ["{ code, message } errors mapped to de/en texts in the web (as in Domains)"]
key-files:
created:
- apps/api/src/common/public-url-guard.ts
- apps/api/src/nextcloud-status/nextcloud-form-errors.ts
- apps/api/src/nextcloud-status/nextcloud-logo-fetch.ts
- apps/api/src/nextcloud-status/nextcloud-logo-fetch.spec.ts
- apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.spec.ts
- apps/web/src/components/nextcloud-status/cloud-form-errors.ts
- apps/web/src/components/nextcloud-status/cloud-form-errors.test.ts
modified:
- apps/api/src/favorites/icon-discovery.service.ts
- apps/api/src/nextcloud-status/dto/nextcloud-instance.dto.ts
- apps/api/src/nextcloud-status/nextcloud-status.service.ts
- apps/api/src/nextcloud-status/nextcloud-status.service.spec.ts
- apps/web/src/lib/nextcloud-status-api.ts
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx
- apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.test.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/messages/umlaut-dictionary.ts
- CHANGELOG.md
- docs/anleitung-anwender.md
- docs/anleitung-administration.md
decisions:
- "Only http addresses are downloaded; https stays a browser-loaded logoUrl (D-04)"
- "DTO keeps only type guards whose messages are error codes; length and URL rules live in validateInstanceInput / classifyLogoUrl so they arrive as { code, message }"
- "Web never renders server text; unknown errors fall back to the generic German save/delete text"
metrics:
tasks: 3
completed: 2026-10-08
status: complete
commits: 3
plan_head_before: 4ff43c225241133c759d729b31cfc324467e00d2
plan_head_after: 166a6fc9d15ae4289f47a0314e102b37ff2380f9
actuals:
tokens: 60000
tasks: 3
commits: 3
---
# Phase quick-261008-j9f Plan 01: Nextcloud-Status http logo address Summary
An http:// logo address in the Nextcloud cloud form is now downloaded once by Tessera (SSRF-guarded per hop, 8 s total deadline, 1 MiB streaming cap, magic-byte check) and stored in logoData/logoMime like an upload; every error of the form now appears in German (English with the English UI) via stable error codes.
## Commits
| Task | Commit | Description |
| ---- | ------ | ----------- |
| 1 (tracer, API) | 4432561 | shared SSRF guard, fetchLogoImage, error catalogue, DTO codes, service wiring + specs |
| 2 (web) | c30a82e | client pre-check, code -> de/en mapping, label "Bildadresse" + new hint, umlaut allowlist |
| 3 (docs) | 166a6fc | CHANGELOG (Geändert + Behoben), both manuals |
## Measurements
- Task 1 gate: nextcloud-status + favorites + rls-coverage/inventory: 18 files, 365 tests green; api tsc clean; favorites spec/service/controller byte-identical to 4ff43c2; tenantPrisma call sites still 14.
- New specs: nextcloud-logo-fetch.spec.ts 16 tests, dto spec 3 tests, service spec 32 tests, web cloud-form-errors.test.ts + extended CloudForm.test.tsx.
- Full suites: api 137 files / 2525 tests green; web 133 files / 1472 tests green; both `tsc --noEmit` clean.
- Biome: clean on all new/changed files (pre-existing findings in untouched favorites files left alone).
- de/en parity, 12 error keys, label/hint, old keys removed, no Mandant/Lizenz words: "web form ok".
- Umlaut allowlist addition: `Adressen` (correct German, in the new hint).
## Live probe (rebuilt local stack, admin)
```
POST instances logoUrl http://127.0.0.1/logo.png -> 400 {"code":"logoFetchInternal", ...German text}
POST instances logoUrl ftp://x -> 400 {"code":"logoUrlInvalid", ...}
POST instances customerName "" -> 400 {"code":"customerNameRequired", ...}
POST instances customerName 5 -> 400 {"message":["customerNameRequired"]} (DTO message = code)
POST instances logoUrl http://example.com/ -> 400 {"code":"logoFetchNotImage", ...}
POST instances logoUrl http://upload.wikimedia.org/...png (follows http->https redirect)
-> 201, hasUploadedLogo true, logoUrl null
GET instances/:id/logo -> 200 image/png
```
The 201 probe row was deleted again; no "Probe j9f" row remains.
## Deviations from Plan
None - plan executed as written. Notes:
- The user-visible old/new `Cloud nicht gefunden` message changed to the code-based `notFound` text on all cloud routes as the plan specified (not a deviation).
- A rejected file type in the upload picker clears the pending file and shows the error; a subsequent submit clears the error and saves without logo (same behaviour as the existing too-large case).
## Known Stubs
None.
## Threat Flags
None - the new outbound request surface (logo download) is exactly the one covered by T-j9f-01..05 in the plan's threat model.
## Browser steps for the orchestrator (dark mode)
1. Nextcloud-Status -> edit a cloud -> radio "Bildadresse" shows the new hint (https loaded by browser, http fetched once, internal via "Bild hochladen").
2. Enter a public http:// image address of a real website, save -> tile shows the logo; reopening the form shows "Bild hochladen" selected (stored like an upload). A working example: http://upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png
3. http://192.168.x.x/logo.png -> German hint pointing to "Bild hochladen", nothing saved.
4. http address of an HTML page (e.g. http://example.com/) -> "Unter dieser Adresse liegt kein Bild ...".
5. Empty name, address without http(s) and an ftp:// logo address -> German texts without any request.
6. https logo address still works as before (browser loads it, logoUrl kept).
7. Switch the UI to English once and repeat (5) -> English texts.
8. Favorites: an existing favorite still shows its icon.
## Self-Check: PASSED
- Created files exist (public-url-guard.ts, nextcloud-form-errors.ts, nextcloud-logo-fetch.ts + spec, dto spec, cloud-form-errors.ts + test).
- Commits 4432561, c30a82e, 166a6fc exist on main (3 commits measured from plan_head_before).
+173
View File
@@ -6,7 +6,180 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
### Neu
- Neues Modul „Domains“ in der Gruppe „Domains“: Domains bei AutoDNS registrieren, Kontakte und Kunden zuordnen. Ein Administrator aktiviert das Modul im Marktplatz; wer es nutzen soll, bekommt zusätzlich die Freigabe. Mit „Benutzen“ sehen Sie die Domainliste, die Kontakte, die Kunden und die Aufträge; mit „Verwalten“ (und als Administrator) registrieren Sie Domains, legen Kontakte und Kunden an und richten die Anbindung ein.
- Domains, Anbindung an AutoDNS: Unter „Einstellungen“ tragen Sie Benutzername, Passwort und Kontext getrennt für das Demo-System (zum Ausprobieren) und das Live-System (echte, kostenpflichtige Registrierungen) ein. Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt. Neue Installationen starten im Demo-System; der Wechsel auf das Live-System verlangt eine ausdrückliche Bestätigung, und eine Kennzeichnung oben auf der Seite zeigt jederzeit, welches System gerade gilt. „Verbindung testen“ prüft den Zugang mit einem einzigen Aufruf.
- Domains, Kontakte und Kunden: Alle Kontakte, die bei AutoDNS vorhanden sind, erscheinen automatisch in Tessera („Aus AutoDNS neu einlesen“ holt sie auf Wunsch sofort neu; das dürfen Benutzer mit der Freigabe „Verwalten“). Neue Kontakte (Person oder Organisation) legen Sie über ein Formular an. Jeden Kontakt – einzeln oder viele auf einmal – ordnen Sie einem Kunden zu; Ihre eigene Firma legen Sie ebenfalls als Kunden an und markieren sie. Die Listen lassen sich nach Kunde filtern und gruppieren.
- Domains, Domainliste: Alle Domains aus AutoDNS mit Kunde (über den Inhaber-Kontakt), Inhaber, Ablaufdatum und Status, ebenfalls filterbar und nach Kunde gruppierbar.
- Domains, Registrieren: Verfügbarkeit prüfen, Kontakte wählen (die Nameserver übernimmt Tessera unverändert und in derselben Reihenfolge aus den Standardwerten in AutoDNS; sind dort keine hinterlegt, ist die Registrierung gesperrt), Zusammenfassung mit System und Preis ansehen und mit „Jetzt verbindlich registrieren“ bestätigen. Tessera schützt vor Doppelbestellungen: Jede Registrierung wird höchstens ein einziges Mal an AutoDNS geschickt, auch bei Doppelklick oder zwei geöffneten Fenstern, und nie automatisch wiederholt. Ist unklar, ob AutoDNS die Bestellung angenommen hat, steht der Auftrag auf „Ergebnis ungeklärt“, bis Tessera ihn mit AutoDNS abgeglichen hat. Den Stand jeder Registrierung sehen Sie im Reiter „Aufträge“; er wird beim Öffnen und danach alle 30 Sekunden mit AutoDNS abgeglichen.
### Geändert
- DKV-Rechnung: Die Modulbeschreibung im Marktplatz weist jetzt darauf hin, dass das Modul die Freigabestufe „Verwalten“ benötigt. Mit „Benutzen“ erscheint es zwar in der Seitenleiste, öffnet aber nur die Seite „Kein Zugriff“. Modulversion 1.1.0.
- Nextcloud-Status: Über der Kachelliste gibt es jetzt ein Suchfeld. Es zeigt nur die Clouds, deren Kundenname den eingegebenen Text enthält; Groß- und Kleinschreibung spielen keine Rolle, die gewählte Sortierung bleibt erhalten. Die Kacheln sind kompakter, der Kundenname und die Adresse der Cloud stehen in voller Länge da, statt abgeschnitten zu werden. Die Knöpfe Benachrichtigen, Prüfen und Bearbeiten sitzen jetzt unten rechts neben „Zuletzt geprüft“.
- Nextcloud-Status: Als Logo können Sie jetzt auch eine Bildadresse eingeben, die mit http:// beginnt. Tessera holt das Bild beim Speichern einmalig ab und behält es wie ein hochgeladenes Bild (höchstens 1 MB; PNG, JPEG, GIF oder WebP). Adressen, die nur intern erreichbar sind, holt Tessera nicht ab – solche Bilder laden Sie über „Bild hochladen“ hoch. Adressen mit https:// lädt weiterhin direkt Ihr Browser. Das Feld heißt jetzt „Bildadresse“.
### Behoben
- Nextcloud-Status: Das Formular zum Hinzufügen und Bearbeiten einer Cloud zeigte bei manchen Eingaben englische Meldungen (zum Beispiel bei einer Bildadresse mit http://). Alle Hinweise in diesem Formular erscheinen jetzt auf Deutsch (bei englischer Spracheinstellung auf Englisch).
## 1.10.1 – 2026-10-06
### Behoben
- Favoriten: Bei Logos mit durchsichtigem Hintergrund schien der graue Platzhalter-Buchstabe durch. Er verschwindet jetzt, sobald das Logo geladen ist.
- Linux-App: Links, die in einem neuen Fenster aufgehen (zum Beispiel aus Notizen oder Favoriten), übergibt die App jetzt an das Öffnen-Programm Ihres Systems statt an eine mitgebrachte Kopie. Auf manchen Systemen öffnete sich dadurch vorher kein Browser.
## 1.10.0 – 2026-10-06
### Neu
- Einstellungen und Administration sind neu und aufgeräumter aufgebaut. Beide Bereiche teilen sich jetzt eine Leiste: oben Ihre persönlichen Einstellungen, darunter – für Administratoren – die Administration, gegliedert in „Benutzer und Zugang“, „Module“ und „Anbindungen“. „Freigaben“ und „Kategorien“ stehen dort direkt, statt nur über Knöpfe auf der Modulseite. Jede Seite hat oben Titel und eine kurze Erklärung, darunter Karten mit je einem Thema; Speichern steht unten rechts in der jeweiligen Karte. Formulare wie Konto, E-Mail-Versand und Verzeichnis sind in Abschnitte gegliedert, die Benutzertabelle passt ohne seitliches Scrollen auf den Bildschirm, und die Module sind nach Kategorie gruppiert. Im Profilmenü gibt es nur noch einen Eintrag: „Einstellungen“, bei Administratoren „Einstellungen/Administration“; der Bereich heißt in der Leiste ebenfalls „Administration“ (vorher „Administrator“). Die Seitentitel heißen wie die Einträge in der Leiste (zum Beispiel „Benutzer“ statt „Benutzerverwaltung“). Auf dem Handy wählen Sie die Seite über eine Auswahlliste.
- Kategorien der Module lassen sich jetzt von Administratoren selbst gestalten, unter Administration → Kategorien. Sie können neue Kategorien anlegen, bestehende umbenennen und mit Pfeilen in eine andere Reihenfolge bringen, jedes Modul (auch gemeinsame eigene Module) einer Kategorie zuordnen und die Module innerhalb einer Kategorie sortieren. Seitenleiste, Marktplatz und Freigaben-Matrix folgen dieser Reihenfolge und den neuen Namen. Löschen Sie eine Kategorie, in der noch Module liegen, wählen Sie eine Zielkategorie: Alle Module wandern dorthin, auch die persönlichen Einträge der Benutzer, es geht nichts verloren. „Eigene Module“ lässt sich umbenennen und verschieben, aber nicht löschen. Benutzer können für ihre eigenen Einträge jetzt jede vorhandene Kategorie wählen. Alte Modul-Adressen aus Lesezeichen funktionieren weiter.
- Neues Modul „Nextcloud-Status“ in der Gruppe „Infrastruktur“. Es zeigt für jede eingetragene Nextcloud-Cloud Ihrer Kunden eine Kachel mit Logo (oder Initialen), Kundenname, Adresse (öffnet in einem neuen Tab), installierter Version, Ampelfarbe mit kurzer Begründung und dem Zeitpunkt der letzten Prüfung; oben steht die neueste Nextcloud-Version. Grün heißt: neuester Stand seiner Version und der Support läuft noch mehr als drei Monate. Gelb heißt: ein Update steht an oder der Support endet in den nächsten drei Monaten. Rot heißt: der Support ist abgelaufen, die Cloud ist nicht erreichbar, im Wartungsmodus oder wartet auf eine Datenbank-Aktualisierung. Grau heißt: Bewertung nicht möglich, zum Beispiel wenn die Versionsdaten gerade nicht abrufbar sind. Tessera prüft jede Cloud automatisch einmal pro Stunde; „Jetzt prüfen“ und ein Knopf je Kachel prüfen sofort. Die Kacheln lassen sich nach Kundenname, Status (Rot zuerst), Version oder Support-Ende sortieren, die Wahl merkt sich Tessera für jeden Benutzer. Clouds eintragen, ändern, entfernen und Logos hinterlegen (Bild hochladen bis 1 MB oder eine https-Bildadresse) dürfen Administratoren und Benutzer mit der Freigabestufe „Verwalten“; alle anderen mit Freigabe sehen die Kacheln. Aktivieren Sie das Modul als Administrator im Marktplatz und erteilen Sie die Freigabe.
- Nextcloud-Status: Auf jeder Kachel gibt es jetzt eine Glocke „Benachrichtigen“, die jeder Benutzer mit Zugriff auf das Modul für sich ein- und ausschalten kann. Ist sie an, meldet Tessera per E-Mail und, solange Tessera geöffnet ist, als Benachrichtigung auf dem Bildschirm (in der Desktop-App als Windows-Benachrichtigung), wenn die Cloud eine Störung hat – nicht erreichbar, keine gültige Antwort, Wartungsmodus, ausstehende Datenbank-Aktualisierung oder abgelaufener Support – und wenn sie wieder in Ordnung ist. Pro Änderung kommt genau eine Nachricht, solange die Störung anhält, nicht jede Stunde neu. Ein einzelner fehlgeschlagener Abruf löst keine Meldung aus: Die Kachel zeigt weiter den letzten guten Stand mit dem Hinweis „Prüfung fehlgeschlagen, wird in wenigen Minuten wiederholt“, und Tessera prüft nach etwa fünf Minuten erneut. Für die E-Mails muss der Mailversand eingerichtet sein und im Benutzerprofil eine E-Mail-Adresse stehen. Außerdem nennt die Kachel bei „Nicht erreichbar“ jetzt den Grund in Klartext, zum Beispiel „Zertifikat passt nicht zur Adresse“, „Adresse nicht gefunden“ oder „Zeitüberschreitung“, statt eines technischen Fehlercodes.
- Neue Dashboard-Kachel „Nextcloud-Status“: drei Zähler für Grün, Gelb und Rot und darunter die roten und gelben Clouds mit Kundenname und Grund. Ein Klick öffnet das Modul. Die Kachel erscheint nur für Benutzer, die das Modul nutzen dürfen.
- Neue Gruppe „Finanzbuchhaltung“ in der Seitenleiste mit zwei Modulen. Beide aktiviert ein Administrator im Marktplatz; wer sie nutzen soll, bekommt zusätzlich die Freigabe.
- Kantinenabrechnung: Die CSV-Datei der Kantine hochladen (Excel-Export mit UTF-8 oder Windows-1252 ist beides in Ordnung). Tessera zeigt Zeilenzahl, Abrechnungsmonat und Gesamtbetrag, nennt fehlerhafte Zeilen mit Zeilennummer und weist auf unterschiedliche Abrechnungsmonate hin. Ist alles in Ordnung, laden Sie mit einem Klick die DATEV-Lohndatei herunter. Beraternummer, Mandantennummer und Lohnart trägt ein Administrator einmalig ein; bis dahin ist der Download gesperrt. Die hochgeladenen Daten werden nicht gespeichert.
- Handelsware: Eine Excel-Liste mit Handelswaren-Umsätzen hochladen. Tessera ordnet jedem Produkt sein Konto zu, markiert neue Produkte mit „neu“ und vergibt ihnen das nächste freie Gegenkonto. Das Buchungsdatum wird aus dem Dateinamen abgeleitet (letzter Tag des Monats) und lässt sich ändern. Neue Konten werden erst beim Herunterladen der Buchungsdatei gespeichert. Im Reiter „Konten“ pflegen Sie die Kontenliste, lesen sie aus einer CSV-Datei ein (ersetzt alle vorhandenen Konten, nach Rückfrage) und exportieren sie als CSV. Standard-Erlöskonto und Startwert für das Gegenkonto trägt ein Administrator einmalig ein.
- Modul-Freigaben haben jetzt zwei Stufen: „Benutzen“ (wie bisher) und „Verwalten“. Wer ein Modul verwalten darf, ändert dessen Einstellungen selbst, ohne Administrator zu sein, zum Beispiel in der Kantinenabrechnung, bei Handelsware und bei den Proxmox-Servern. Ein Administrator wählt die Stufe je Gruppe in der Freigaben-Matrix oder je Benutzer in den Benutzerdetails; bestehende Freigaben bleiben „Benutzen“. Freigaben vergeben und Module aktivieren dürfen weiterhin nur Administratoren. Das Modul DKV-Rechnung steht Administratoren und Benutzern mit „Verwalten“ zur Verfügung.
- Proxmox: Für jede virtuelle Maschine und jeden Container auf einem PVE-Server, der nicht läuft, obwohl der Autostart eingeschaltet ist, zeigt die Serverkarte jetzt eine Warnung mit Nummer, Name, Knoten und Zustand. Die Karte steht dann auf „Warnung“, die Dashboard-Kachel zeigt „gestoppt trotz Autostart“. Vorlagen zählen nicht. Kann Tessera die Autostart-Einstellung nicht lesen, nennt die Karte, bei wie vielen Gästen das so war (dem Zugang fehlt dann meist das Recht VM.Audit aus der Rolle PVEAuditor).
### Behoben
- Linux-App: Auf Arch-basierten Systemen wie EndeavourOS zeigte die App nach dem Start nur ein weißes Fenster. Die App bringt die Grafik-Bibliotheken, die dort nicht zum System passten, nicht mehr selbst mit, sondern nutzt die des Systems.
- Kalender (Exchange): Ist der Exchange-Server nicht erreichbar, bricht „Verbindung testen“ jetzt nach 15 Sekunden ab, statt rund zwei Minuten auf „Verbindung wird geprüft …“ zu stehen, und meldet „Der Kalender-Server ist nicht erreichbar“ statt des Hinweises auf Adresse und Zugangsdaten. Auch der regelmäßige Abruf im Hintergrund wartet nicht mehr länger als 15 Sekunden.
- Postfach-Abruf über Exchange (E-Mail-Alarme, PDF-Anhänge): Ein nicht erreichbarer Exchange-Server blockiert den Abruf und den Verbindungstest nicht mehr minutenlang, sondern höchstens 60 Sekunden.
- Links in Notiz-Kacheln öffnen jetzt immer in einem neuen Tab, statt das Dashboard im selben Fenster zu verlassen. In der Desktop-App öffnen sie wie gewohnt im Browser.
## 1.9.2 – 2026-10-02
### Neu
- Zertifikat-Manager: Neuer Reiter „Übersicht“. Ziehen Sie alle Dateien, die Sie vom Zertifikatsaussteller bekommen haben, auf einmal hinein – gern auch direkt die ZIP-Datei. Tessera zeigt, was jede Datei ist (Serverzertifikat, Zwischenzertifikat, Stammzertifikat, privater Schlüssel, Zertifikatsanfrage), wofür sie gebraucht wird, wie lange sie gültig ist und was zusammengehört. Unter jedem Teil können Sie es in jedem passenden Format herunterladen: Zertifikate als PEM (.crt), DER (.cer), mit Kette, PKCS#7 (.p7b) oder als PFX mit Schlüssel und Kette; den Schlüssel als PEM, RSA-PEM oder DER; die Anfrage als PEM oder DER. Passwortgeschützte PFX-Dateien lassen sich mit dem Passwort entsperren.
### Behoben
- Desktop-App: Downloads, die Tessera erst im Fenster erstellt (zum Beispiel im Zertifikat-Manager), werden jetzt im Ordner „Downloads“ gespeichert; eine Meldung nennt den Dateinamen. Bisher öffnete Windows nur den Hinweis „Holen Sie sich eine App, um diesen ‚blob‘-Link zu öffnen“. Dafür ist die neue Version der Desktop-App nötig.
- Zertifikat-Manager: Die Texte sprechen Sie jetzt durchgehend mit „Sie“ an.
## 1.9.1 – 2026-10-01
### Behoben
- Desktop-App: Favoriten und andere Links, die sich in einem neuen Fenster öffnen (etwa „In neuem Tab öffnen“ oder Quellen im Ausschreibungs-Radar), öffnen sich jetzt in Ihrem normalen Browser. Bisher passierte beim Klick in der Desktop-App nichts.
- Erinnerungen: Beim Schreiben der Beschreibung springt der Cursor nicht mehr in die Titelzeile zurück. Bisher passierte das alle paar Sekunden, weil sich die Kachel regelmäßig neu aufbaut.
- Favoriten: Auch Seiten, die ihr Logo erst beim Laden im Browser setzen (etwa Host Europe), zeigen jetzt ihr Logo statt nur des Anfangsbuchstabens. Findet Tessera auf der Seite selbst kein Logo, fragt es bei öffentlichen Adressen einen Logo-Dienst; interne Adressen werden dabei nie weitergegeben. Das gilt auch für bereits angelegte Favoriten.
## 1.9.0 – 2026-09-30
### Neu
- Verwaltung: Eigene Vorlage für die Willkommensmail unter Administrator → Willkommensmail. Betreff, Überschrift, Einleitung, Abschluss und der Anmeldehinweis (getrennt für Verzeichnis- und lokale Konten) lassen sich anpassen, mit Platzhaltern wie {{vorname}}, {{benutzername}}, {{adresse}} oder {{firma}} (eine Tabelle auf der Seite erklärt sie). Die Seite zeigt eine Live-Vorschau der echten Mail und schickt auf Wunsch eine Testmail an Ihre Adresse; „Auf Standard zurücksetzen“ stellt die mitgelieferten Texte wieder her. Logo, Zugangsdaten und Knöpfe fügt Tessera immer selbst ein.
- Benutzerverwaltung: Willkommensmail. Über das Briefsymbol in der Benutzerliste schicken Sie einem Benutzer eine gestaltete Willkommensmail mit Tessera-Logo, Adresse, Benutzername und einem Knopf „Zu Tessera“. Konten aus dem Verzeichnis erhalten den Hinweis auf ihr Windows-Passwort, lokale Konten einen Link „Passwort festlegen“ (7 Tage gültig) – ein Passwort steht nie in der Mail. Die Liste zeigt, wann die Mail zuletzt ging.
- Benutzerverwaltung: Neue Spalte „Letzte Anmeldung“.
### Geändert
- Benutzerverwaltung: Die Aktionen je Zeile sind jetzt Symbole (Willkommensmail, Details, Bearbeiten, Löschen), damit die Liste ohne seitliches Scrollen passt.
### Behoben
- Anmeldung: Wer schon angemeldet ist und die Anmeldeseite aufruft, landet jetzt direkt auf dem Dashboard.
- Willkommensmail: Logo und Schriftzug erscheinen jetzt in jedem Mailprogramm. Bisher steckten sie in einem Bild; zeigte Outlook es nicht an, blieb nur ein großer schwarzer Kasten. Die Welle darunter ist nur noch ein schmaler Streifen; zeigt ein Mailprogramm sie nicht an (etwa Outlook im Browser), bleibt keine weiße Lücke mehr.
- Anmeldung: Eine geänderte Rolle, eine Deaktivierung oder das Löschen eines Kontos wirkt jetzt sofort. Bisher galt bis zu 30 Tage die Rolle vom Zeitpunkt der Anmeldung weiter – ein herabgestufter Administrator behielt seine Rechte, ein deaktiviertes Konto konnte mit seiner Sitzung weiterarbeiten, und die Benutzerliste ließ sich nach einer Rollenänderung nicht laden.
## 1.8.0 – 2026-09-30
### Neu
- Dashboard: Neues Widget „Erinnerungen“. Sie legen eine Erinnerung mit Datum, Uhrzeit, Titel und Beschreibung an, und Tessera meldet sich genau zur gewählten Zeit: im Browser mit einer Benachrichtigung (der Browser fragt dafür einmal um Erlaubnis, und zwar beim ersten Anlegen), in der Desktop-App mit einer Windows-Benachrichtigung – auch wenn das Fenster im Infobereich liegt. Wenn Sie möchten, schickt Tessera zusätzlich eine E-Mail an Ihre Adresse, auch dann, wenn Tessera gerade nirgends geöffnet ist. Eine fällige Erinnerung bleibt im Widget hervorgehoben stehen, bis Sie „Erledigt“ wählen oder mit „Später erinnern“ verschieben – auf in 10 Minuten, in 1 Stunde oder morgen zur gleichen Uhrzeit; dann meldet sich Tessera (und bei Bedarf die E-Mail) noch einmal. Erinnerungen sind persönlich: nur Sie sehen und ändern Ihre. Für die Desktop-Benachrichtigungen braucht die Desktop-App ihre neue Version, die Sie über „Auf Version … aktualisieren“ im Menü des Tessera-Symbols erhalten; Widget und E-Mail funktionieren auch mit der bisherigen Version.
### Geändert
- Eigene Module: Einmal geöffnete Seiten bleiben im Hintergrund offen. Wechseln Sie zurück, ist die Seite sofort da – im selben Zustand, ohne neu zu laden. Tessera hält die fünf zuletzt benutzten offen; beim Abmelden werden sie geschlossen.
- Dashboard, Favoriten: In der Kachelansicht stehen die Symbole enger beieinander; der Abstand zwischen ihnen ist etwa halb so groß, in eine Zeile passen mehr Favoriten. Passt ein Name nicht in eine Zeile, wird er kleiner geschrieben und auf zwei Zeilen umbrochen.
- Dashboard: Es sieht jetzt auf jedem Bildschirm gleich aus. Tessera merkt sich die Fläche des Bildschirms, an dem Sie ein Dashboard zuerst öffnen, und zeigt es auf anderen Bildschirmen maßstäblich verkleinert oder vergrößert – samt Schrift und ohne Scrollen. Ist ein Dashboard länger als der Bildschirm, wird es so weit verkleinert, dass es ganz hineinpasst. Auf dem Handy bleibt es bei der bisherigen Anordnung untereinander.
- Eigene Module: Neue Einträge sind mit der Kategorie „Eigene Module“ vorbelegt.
- Dashboard: Der Kalender lässt sich nicht mehr so schmal ziehen, dass seine Überschrift abgeschnitten wird.
- Eigene Module: Die Seite füllt jetzt den ganzen Inhaltsbereich. Name und Hinweiszeile darüber sind weggefallen – der Name steht ohnehin oben in der Leiste, und „In neuem Tab öffnen“ sitzt jetzt dort rechts.
### Behoben
- Eigene Module: Lässt sich ein Eintrag nicht laden, sagt Tessera das jetzt, statt „nicht gefunden“ zu melden. Fehlende Berechtigung und ungültige Angaben werden beim Speichern und Löschen eigens genannt.
- Seitenleiste: Eingeklappt stehen eigene Module jetzt bei ihrer Kategorie, in derselben Reihenfolge wie ausgeklappt.
- Erinnerungen: Ohne Browser-Speicher (etwa im privaten Fenster) kam dieselbe Benachrichtigung alle 10 Sekunden – jetzt nur einmal.
- Erinnerungen: Nach einer Änderung von Datum oder Uhrzeit kommt die E-Mail zuverlässig zur neuen Zeit. Deaktivierte Benutzer bekommen keine Erinnerungs-E-Mails mehr.
- Erinnerungen: „Später erinnern“ zeigt „Heute um …“, wenn die Uhrzeit heute noch kommt, statt fälschlich „Morgen um …“. Speichern ohne Zeitänderung verschiebt die Fälligkeit nicht mehr um Sekunden.
- Dashboard: Wird ein Bild gelöscht, das als Hintergrund gewählt war, gilt wieder „kein Hintergrund“.
- Dashboard: Ein noch offener Tab mit älterer Tessera-Version kann die Anordnung nicht mehr verziehen; er bittet stattdessen, die Seite neu zu laden.
- Desktop-App: Während ein Update installiert wird, bietet das Menü kein zweites mehr an. Nach einer fehlgeschlagenen Update-Prüfung genügt wieder ein Klick zum Installieren.
- Desktop-App: „Auf Version … aktualisieren“ im Menü des Tessera-Symbols scheiterte mit „Signaturprüfung fehlgeschlagen“ und öffnete stattdessen die Download-Seite, wenn der Server seit der letzten Update-Prüfung der App eine neuere Version bekommen hatte. Die App fragt jetzt beim Klick zuerst frisch nach und installiert genau die Version, die der Server in diesem Moment anbietet.
- Dashboard, Favoriten: Eine neu eingetragene Logo-Adresse wird jetzt sofort angezeigt. Bisher blieb ein früher hochgeladenes eigenes Symbol stehen und verdeckte die neue Adresse; jetzt ersetzt die neue Adresse es.
- Dashboard, Favoriten: Eine Logo-Adresse lässt sich jetzt auch speichern, wenn Tessera das Bild selbst nicht laden kann – etwa bei Seiten im internen Netz, die nur Ihrem Browser das Symbol geben. Die Kachel lädt das Bild dann direkt in Ihrem Browser. Auch die automatische Erkennung findet das Symbol solcher Seiten jetzt eher, statt auf ein Ersatzsymbol zurückzufallen.
## 1.7.0 – 2026-09-29
### Neu
- Eigene Module: Als Kategorie steht jetzt auch „Eigene Module“ zur Auswahl. Einträge dort erscheinen gesammelt in einer eigenen Gruppe ganz unten in der Seitenleiste; die Gruppe ist nur zu sehen, solange ein Eintrag darin liegt. Bestehende Einträge verschieben Sie über „Bearbeiten“ dorthin.
### Geändert
- Seitenleiste: Ist sie eingeklappt, sind die Symbole etwas größer und stehen etwas enger beieinander.
- Dashboard: Im Such-Widget haben die Auswahl der Suchmaschine und das Suchfeld keine helle Linie an der Unterkante mehr.
## 1.6.0 – 2026-09-29
### Neu
- Eigene Module: Jeder Benutzer kann unter „Einstellungen → Eigene Module“ Webseiten, die er oft braucht, als eigene Einträge in seine Seitenleiste aufnehmen – mit Name, Adresse (nur https) und Kategorie, etwa „Infrastruktur“. Diese Einträge sieht nur der Benutzer selbst. Ein Klick zeigt die Seite direkt in Tessera. Manche Seiten verbieten das Einbetten – dafür gibt es immer den Knopf „In neuem Tab öffnen“. Administratoren können zusätzlich unter „Verwaltung → Eigene Module“ Einträge für alle Benutzer anlegen; die sehen dann alle unter der gewählten Kategorie.
### Geändert
- Dashboard: Die Widgets bleiben beim Darüberfahren mit der Maus ruhig stehen, sie heben sich nicht mehr an.
- Dashboard: Die Widgets stehen in der Ansicht genau dort, wo Sie sie beim Bearbeiten platziert haben. Bisher rückte Tessera sie nach dem Bearbeiten zur Seitenmitte, sodass etwa ein einzelnes Widget oben links plötzlich in die Mitte sprang.
- Dashboard: Das Raster ist in der Breite doppelt so fein – Widgets lassen sich in kleineren Schritten breiter oder schmaler ziehen und genauer platzieren. Bestehende Anordnungen bleiben unverändert.
- Dashboard: Das Kalender-Widget lässt sich deutlich schmaler ziehen als bisher.
### Behoben
- Desktop-App: Tessera startet nicht mehr doppelt. Wird die App ein zweites Mal gestartet – etwa beim Anmelden an Windows –, holt sie nur das vorhandene Fenster nach vorne; im Infobereich erscheint nur noch ein Symbol.
- Desktop-App: Die Suche im Such-Widget und Knöpfe wie „In neuem Tab öffnen“ funktionieren jetzt auch in der Desktop-App – die Seite öffnet sich in Ihrem normalen Browser. Bisher passierte dort beim Klick nichts.
## 1.5.2 – 2026-09-28
### Neu
- Dashboard: Den Titel eines Widgets können Sie jetzt ausblenden. Im Bearbeitungsmodus sitzt dafür oben in der Mitte jedes Widgets mit Titel ein kleines „T“; ein Klick blendet den Titel aus, ein zweiter wieder ein. Die Einstellung gilt je Widget und bleibt gespeichert. Im Bearbeitungsmodus sehen Sie den Titel weiterhin, damit Sie ihn ändern können; Knöpfe wie der Stift der Notiz bleiben auch ohne Titel oben rechts erreichbar.
### Geändert
- Seitenleiste: Die Kategorien (etwa „Fuhrpark“ oder „Infrastruktur“) sind etwas größer beschriftet, die Module darunter etwas kleiner – so ist die Gliederung auf einen Blick erkennbar.
## 1.5.1 – 2026-09-28
### Geändert
- Dashboard: Der Knopf „Bearbeiten“ sitzt jetzt als Stift-Symbol rechts in der dunklen Leiste oben und nimmt über den Widgets keinen Platz mehr weg. Im Bearbeitungsmodus erscheinen dort auch „Hintergrund“, „Widget hinzufügen“ und „Fertig“.
## 1.5.0 – 2026-09-28
### Neu
- Nach einem Versionswechsel zeigt Tessera bei Ihrer ersten Anmeldung ein Fenster mit den wichtigsten Änderungen der neuen Version – neue Funktionen, Verbesserungen und behobene Fehler. Haben Sie mehrere Versionen verpasst, erscheinen die drei neuesten. „Verstanden“ schließt das Fenster; es erscheint erst mit der nächsten Version wieder, im Browser wie in der Desktop-App. Die vollständige Liste finden Sie weiterhin unter „Was ist neu“.
- Dashboard: Sie können jetzt einen Hintergrund wählen. Im Bearbeitungsmodus öffnet der Knopf „Hintergrund“ eine Auswahl – keiner, ruhige Flächen und Motive in Gelb, Grau und Graphit, oder ein eigenes Bild aus Ihren Bilderrahmen-Bildern bzw. ein neu hochgeladenes. Der Hintergrund gilt nur für Sie und folgt Ihnen auf jedes Gerät, auf dem Sie sich anmelden, auch in die Desktop-App. Eine bisher nur in Ihrem Browser gemerkte Wahl wird dabei automatisch übernommen.
### Geändert
- Neues Aussehen: Tessera hat eine dunkle App-Leiste oben und eine neu gestaltete Seitenleiste. Jedes Modul erscheint dort mit einer eigenen kleinen Kachel, die Kategorien tragen deutsche Namen und sind anfangs aufgeklappt, und unten in der Seitenleiste begrüßt Sie Tessera mit Ihrem Namen und dem heutigen Datum. Die persönliche Akzentfarbe hebt nur noch das Modul hervor, in dem Sie gerade arbeiten. Auch die übrigen Seiten – Marktplatz, Module, Einstellungen und Verwaltung – folgen diesem ruhigeren Stil.
- Neue Anmeldeseite: Auf großen Bildschirmen ist sie geteilt, links ein dunkler Bereich mit dem Tessera-Zeichen und einem Farbmosaik, rechts das Anmeldeformular.
- Dashboard: Die Kacheln stehen mittig auf der Seite. Jedes Widget trägt oben ein gelbes Symbol-Feld, hebt sich beim Darüberfahren leicht an und blendet beim Laden sanft ein. Die Knöpfe „Bearbeiten“, „Widget hinzufügen“ und „Hintergrund“ sitzen jetzt als Leiste oben rechts über den Kacheln statt unten rechts. Kalender, Favoriten und Notizen sind ruhiger gestaltet, und ein leeres Dashboard schlägt Ihnen passende erste Kacheln vor.
- Kalender-Widget: Die nächsten Termine stehen jetzt in einer kompakten, einzeiligen Liste, in der „Heute“ und „Morgen“ statt des Datums erscheinen – so passen auch in eine kleine Kachel mehrere Termine. Den Ort eines Termins sehen Sie, wenn Sie mit der Maus darüberfahren.
- Auf dem Handy öffnet sich die Seitenleiste als Schublade über der ganzen Seite, samt App-Leiste.
### Behoben
- Dashboard: Widgets ließen sich manchmal nicht schmaler ziehen, wenn die Maus dabei leicht nach oben oder unten wackelte. Jetzt klappt das zuverlässig.
## 1.4.0 – 2026-09-25
+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,16 @@
-- quick-260928-ujj: Dashboard-Hintergrund pro Benutzer in der Datenbank.
--
-- Bisher lag die Wahl des Dashboard-Hintergrunds (Design "Mosaik") im
-- localStorage des Browsers und folgte dem Benutzer nicht auf ein anderes
-- Geraet oder in die Desktop-App. Jetzt steht sie hier, geschrieben nur ueber
-- PATCH /users/me/dashboard-background und dort wie beim Lesen durch
-- parseDashboardBackground (@tessera/shared) geprueft und normalisiert.
-- NULL = nie gewaehlt (das Web uebernimmt dann einmalig eine alte
-- localStorage-Wahl); sonst ein Objekt { kind: 'none' | 'preset' | 'image', ... }.
-- Bewusst kein Standardwert und kein Backfill.
--
-- Die Anmelde-Funktionen auth_lookup_* liefern eine feste Spaltenliste
-- (RETURNS TABLE) und bleiben von der neuen Spalte unberuehrt.
-- AlterTable
ALTER TABLE "User" ADD COLUMN "dashboardBackground" JSONB;
@@ -0,0 +1,47 @@
-- 260929-9wc — Eigene Module: externe Seiten als Seitenleisten-Eintraege.
--
-- Zweck: neue Tabelle "CustomModule". Der Administrator legt Eintraege an
-- (Name, https-Adresse, Kategorie), alle Benutzer des Mandanten sehen sie in
-- der Seitenleiste und ein Klick zeigt die Seite im Rahmen. Mehrere Zeilen je
-- Mandant, Vorbild "ProxmoxServer" (tenantId-Spalte, keine Relation zu
-- Tenant).
--
-- 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())`) — eigene
-- Module sind Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines
-- einzelnen Benutzers.
--
-- BEWUSST KEINE `system_read_policy`: es gibt keinen Hintergrunddienst, der
-- eigene Module ueber alle Mandanten lesen muesste; jeder Zugriff laeuft
-- mandantengebunden ueber `forTenant(prisma, tenantId)`.
--
-- 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 "CustomModule" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"url" TEXT NOT NULL,
"category" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CustomModule_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "CustomModule_tenantId_idx" ON "CustomModule"("tenantId");
ALTER TABLE "CustomModule" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "CustomModule" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "CustomModule"
USING ("tenantId" = current_tenant_id());
@@ -0,0 +1,77 @@
-- 260929-dzu — Eigene Module fuer jeden Benutzer: persoenliche Eintraege.
--
-- Zweck: jeder Benutzer darf eigene Seitenleisten-Eintraege anlegen, die nur
-- er selbst sieht. Die Spalte "ownerUserId" unterscheidet: NULL = gemeinsamer
-- Eintrag (vom Administrator, fuer alle sichtbar, bisheriges Verhalten),
-- gesetzt = persoenlicher Eintrag dieses Benutzers. Faellt der Benutzer weg,
-- fallen seine Eintraege mit (ON DELETE CASCADE). Bestehende Zeilen bleiben
-- gemeinsam (NULL).
--
-- Zeilenschutz: Muster "SearchProvider" (20260911120000_rls_user_dimension_
-- personal_tables) — Spalte mit NULL = gemeinsame Zeile. Die eine Regel
-- "tenant_isolation_policy" (aus 20260929120000, ohne Benutzerdimension) wird
-- durch vier nach Befehl getrennte Regeln ersetzt (Praezedenz 260910-jab (3)):
-- ein einzelner USING-Ausdruck, der die gemeinsame Zeile zum Lesen einschliesst,
-- wuerde sie sonst auch zum Aendern/Entfernen freigeben.
-- SELECT: Mandant UND (kein Benutzer gesetzt ODER gemeinsame Zeile ODER
-- eigene Zeile).
-- INSERT/UPDATE/DELETE: Mandant UND (kein Benutzer gesetzt ODER eigene
-- Zeile). Ein Benutzerkontext kann gemeinsame Zeilen also NICHT
-- schreiben; der Administrator-Weg fuer gemeinsame Eintraege bindet
-- deshalb ohne Benutzer (`forTenant(prisma, tenantId)`), die
-- Rollenpruefung liegt im Controller/Dienst.
-- Die Regelnamen sind neu (vier statt eine), rls-coverage.spec.ts fordert nur
-- mindestens eine Regel je Tabelle mit eingeschaltetem RLS.
--
-- Rechte fuer tessera_app kommen ueber ALTER DEFAULT PRIVILEGES aus
-- 20260909130000_rls_app_role — hier nichts zu tun.
--
-- WICHTIG: wie alle RLS-Regeln dieses Schemas wirken diese erst, wenn die
-- Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter heute AUS, siehe
-- docs/mandantentrennung-datenbankrolle.md). Bis dahin tragen die
-- Anwendungspruefungen im Dienst den Schutz allein.
ALTER TABLE "CustomModule" ADD COLUMN "ownerUserId" TEXT;
CREATE INDEX "CustomModule_tenantId_ownerUserId_idx" ON "CustomModule"("tenantId", "ownerUserId");
ALTER TABLE "CustomModule" ADD CONSTRAINT "CustomModule_ownerUserId_fkey"
FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
DROP POLICY tenant_isolation_policy ON "CustomModule";
CREATE POLICY tenant_user_read_policy ON "CustomModule"
FOR SELECT
USING (
"tenantId" = current_tenant_id()
AND (
current_user_id() IS NULL
OR "ownerUserId" IS NULL
OR "ownerUserId" = current_user_id()
)
);
CREATE POLICY tenant_user_insert_policy ON "CustomModule"
FOR INSERT
WITH CHECK (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "ownerUserId" = current_user_id())
);
CREATE POLICY tenant_user_update_policy ON "CustomModule"
FOR UPDATE
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "ownerUserId" = current_user_id())
)
WITH CHECK (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "ownerUserId" = current_user_id())
);
CREATE POLICY tenant_user_delete_policy ON "CustomModule"
FOR DELETE
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "ownerUserId" = current_user_id())
);
@@ -0,0 +1,73 @@
-- 260929-if2 — Erinnerungen: persoenliche, einmalige Erinnerungen je Benutzer.
--
-- Zweck: die Tabelle "Reminder" traegt die Erinnerungen des Dashboard-Widgets
-- „Erinnerungen“ (Titel, Beschreibung, Faelligkeit, optional E-Mail). Es gibt
-- keine Wiederholung (D-01) und keine Historie: „Erledigt“ loescht die Zeile.
--
-- Besitz: eine Erinnerung gehoert genau einem Benutzer (gleicher Mandant UND
-- gleicher Benutzer, D-05). Faellt der Benutzer weg, fallen seine Erinnerungen
-- mit (ON DELETE CASCADE). Die Anwendung antwortet fuer fremde Kennungen mit
-- 404 (nie 403).
--
-- Spuren des E-Mail-Planers: "emailSentAt" ist der ANSPRUCH auf den Versand
-- (wird vor dem Senden gesetzt, damit mehrere API-Instanzen nicht doppelt
-- senden), "emailAttempts" zaehlt die Versuche (hoechstens 3). Ein Verschieben
-- der Faelligkeit setzt beide zurueck.
--
-- Zeilenschutz, zwei Regeln:
-- tenant_isolation_policy — Mandant UND Benutzer (Form aus DashboardImage,
-- 20260921120000_dashboard_image): ohne gesetzten Benutzer (Hintergrund-
-- dienst, der je Mandant gebunden schreibt) gilt nur der Mandant, mit
-- Benutzer zusaetzlich "userId".
-- system_read_policy — NUR FOR SELECT, Form aus 20260914120000_rls_system_
-- context_read. Sie bedient allein die Kandidatenabfrage des E-Mail-
-- Planers (reminder-mail.scheduler.ts), der einmal ueber ALLE Mandanten
-- liest und dann je Zeile gebunden anspricht. Schreiben bleibt der
-- Mandantenregel vorbehalten.
--
-- Rechte fuer die Anwendungsrolle tessera_app: kommen ueber ALTER DEFAULT
-- PRIVILEGES aus 20260909130000_rls_app_role automatisch — hier nichts zu tun.
--
-- WICHTIG: wie alle RLS-Regeln dieses Schemas wirken diese erst, wenn die
-- Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter heute AUS, siehe
-- docs/mandantentrennung-datenbankrolle.md). Bis dahin tragen die
-- Anwendungspruefungen im Dienst den Schutz allein.
-- CreateTable
CREATE TABLE "Reminder" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"dueAt" TIMESTAMP(3) NOT NULL,
"emailEnabled" BOOLEAN NOT NULL DEFAULT false,
"emailSentAt" TIMESTAMP(3),
"emailAttempts" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Reminder_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Reminder_tenantId_userId_dueAt_idx" ON "Reminder"("tenantId", "userId", "dueAt");
-- CreateIndex
CREATE INDEX "Reminder_dueAt_idx" ON "Reminder"("dueAt");
-- AddForeignKey
ALTER TABLE "Reminder" ADD CONSTRAINT "Reminder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Zeilenschutz: Mandant UND Benutzer (Muster 20260921120000)
ALTER TABLE "Reminder" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Reminder" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "Reminder"
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
);
-- Systemkontext: nur Lesen, fuer die Kandidatenabfrage des E-Mail-Planers
CREATE POLICY system_read_policy ON "Reminder"
FOR SELECT USING (is_system_context());
@@ -0,0 +1,15 @@
-- Willkommensmail aus der Benutzerverwaltung (Administrator → Benutzer).
--
-- Merkt pro Benutzer, wann zuletzt eine Willkommensmail verschickt wurde,
-- damit die Liste "Willkommensmail gesendet am …" zeigen kann. Gesetzt nur
-- ueber POST /users/:id/welcome-mail nach erfolgreichem Versand; erneutes
-- Senden ueberschreibt den Wert. NULL = nie gesendet. Kein Standardwert,
-- kein Backfill.
--
-- Keine neue Regel noetig: die Spalte liegt in "User", dessen
-- tenant_isolation_policy die ganze Zeile schuetzt. Die Anmelde-Funktionen
-- auth_lookup_* liefern eine feste Spaltenliste (RETURNS TABLE) und bleiben
-- unberuehrt.
-- AlterTable
ALTER TABLE "User" ADD COLUMN "welcomeMailSentAt" TIMESTAMP(3);
@@ -0,0 +1,52 @@
-- Willkommensmail: eigene Vorlage je Mandant (Administrator → Willkommensmail).
--
-- Zweck: neue Tabelle "WelcomeMailTemplate". Ein Administrator kann Betreff,
-- Ueberschrift, Einleitung und Abschlusstext der Willkommensmail anpassen
-- (mit Platzhaltern wie {{name}}). Hoechstens EINE Zeile je Mandant
-- ("tenantId" eindeutig); "Auf Standard zuruecksetzen" loescht die Zeile, dann
-- gelten wieder die Standardtexte aus dem Code. Die festen Bausteine der Mail
-- (Kopf, Zugangsdaten, Anmeldehinweis, Knoepfe, Fusszeile) stehen NICHT in
-- der Tabelle. "updatedBy" haelt den Benutzernamen des letzten Bearbeiters
-- als reinen Anzeigetext (keine Relation).
--
-- Von Hand geschrieben (Vorbild 20260929120000_custom_module).
--
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die
-- Tabelle traegt `tenantId` und `tenant_isolation_policy` OHNE
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — die
-- Vorlage ist Verwaltungsdatum des Mandanten, nicht persoenliches Datum eines
-- einzelnen Benutzers.
--
-- BEWUSST KEINE `system_read_policy`: gelesen wird nur beim Versand einer
-- Willkommensmail, und zwar gebunden an den Mandanten des Zielbenutzers
-- (`forTenant(prisma, tenantId)`); es gibt keinen Hintergrunddienst, der die
-- Vorlagen ueber alle Mandanten liest.
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
CREATE TABLE "WelcomeMailTemplate" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"subject" TEXT NOT NULL,
"heading" TEXT NOT NULL,
"intro" TEXT NOT NULL,
"closing" TEXT NOT NULL,
"updatedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WelcomeMailTemplate_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "WelcomeMailTemplate_tenantId_key" ON "WelcomeMailTemplate"("tenantId");
ALTER TABLE "WelcomeMailTemplate" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "WelcomeMailTemplate" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "WelcomeMailTemplate"
USING ("tenantId" = current_tenant_id());
@@ -0,0 +1,18 @@
-- Willkommensmail: Anmeldehinweis je Kontoart als Teil der eigenen Vorlage.
--
-- Zweck: zwei neue Spalten in "WelcomeMailTemplate" —
-- "loginHintDirectory" (Hinweis fuer verzeichnisgefuehrte Konten, Standard
-- "Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten
-- Windows-Passwort an.") und "loginHintLocal" (Hinweis vor dem Knopf
-- "Passwort festlegen" fuer lokale Konten).
--
-- Bewusst NULLABLE ohne Default: bestehende Vorlagen behalten NULL, und
-- WelcomeMailTemplateService setzt dafuer den Standardtext aus
-- @tessera/shared ein. So steht der Standardtext nur an EINER Stelle im
-- Code und nicht zusaetzlich in der Datenbank.
--
-- Zeilenschutz: unveraendert (tenant_isolation_policy der Tabelle gilt fuer
-- die neuen Spalten mit).
ALTER TABLE "WelcomeMailTemplate" ADD COLUMN "loginHintDirectory" TEXT;
ALTER TABLE "WelcomeMailTemplate" ADD COLUMN "loginHintLocal" TEXT;
@@ -0,0 +1,46 @@
-- 261002-fm5 — Finanzbuchhaltung: Modul "Kantinenabrechnung" (kantine-datev).
--
-- Zweck: eine neue Tabelle `KantineDatevConfig` mit den drei Nummern, die der
-- Administrator einmalig je Mandant hinterlegt (Beraternummer, Mandantennummer,
-- Lohnart). Eine Zeile je Mandant (Singleton, Vorbild `DkvModuleConfig`). Die
-- Felder sind Text, damit fuehrende Nullen erhalten bleiben, und haben
-- ABSICHTLICH keinen Standardwert: solange sie leer sind, sperrt das Modul die
-- Verarbeitung. Die hochgeladene Kantinen-CSV wird nicht gespeichert.
--
-- Von Hand geschrieben (Vorbild 20260923140000_proxmox_server), von Hand
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
--
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die Tabelle
-- traegt `tenantId` und `tenant_isolation_policy` OHNE Benutzerdimension
-- (`USING ("tenantId" = current_tenant_id())`, Form aus `DkvModuleConfig`) —
-- Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers.
-- Keine `system_read_policy`: es gibt keinen Hintergrunddienst, der diese
-- Einstellungen ueber alle Mandanten liest.
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
CREATE TABLE "KantineDatevConfig" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"beraterNr" TEXT,
"mandantNr" TEXT,
"lohnart" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "KantineDatevConfig_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "KantineDatevConfig_tenantId_key" ON "KantineDatevConfig"("tenantId");
CREATE INDEX "KantineDatevConfig_tenantId_idx" ON "KantineDatevConfig"("tenantId");
ALTER TABLE "KantineDatevConfig" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "KantineDatevConfig" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "KantineDatevConfig"
USING ("tenantId" = current_tenant_id());
@@ -0,0 +1,70 @@
-- 261002-fm5 — Finanzbuchhaltung: Modul "Handelsware" (handelsware-datev).
--
-- Zweck: zwei neue Tabellen. `HandelswareDatevConfig` traegt die Einstellungen
-- des Mandanten (Standard-Erloeskonto fuer neue Konten, Startwert fuer die
-- Gegenkonto-Vergabe bei leerer Kontenliste) — eine Zeile je Mandant
-- (Singleton, Vorbild `DkvModuleConfig`/`KantineDatevConfig`). Beide Zahlen
-- haben ABSICHTLICH keinen Standardwert: solange sie leer sind, sperrt das
-- Modul die Verarbeitung. `HandelswareKonto` ist die Kontenliste (Produktname
-- -> Gegenkonto, Erloeskonto) — mehrere Zeilen je Mandant, der Name ist je
-- Mandant eindeutig, das Gegenkonto bewusst nicht (mehrere Produkte duerfen
-- auf dasselbe Gegenkonto laufen).
--
-- Von Hand geschrieben (Vorbild 20260923140000_proxmox_server), von Hand
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
--
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): beide
-- Tabellen tragen `tenantId` und `tenant_isolation_policy` OHNE
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`, Form aus
-- `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten
-- eines Benutzers. Keine `system_read_policy`: es gibt keinen Hintergrunddienst,
-- der diese Tabellen ueber alle Mandanten liest.
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
-- 1) HandelswareDatevConfig
CREATE TABLE "HandelswareDatevConfig" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"erloeskonto" INTEGER,
"startGegenkonto" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HandelswareDatevConfig_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "HandelswareDatevConfig_tenantId_key" ON "HandelswareDatevConfig"("tenantId");
CREATE INDEX "HandelswareDatevConfig_tenantId_idx" ON "HandelswareDatevConfig"("tenantId");
ALTER TABLE "HandelswareDatevConfig" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "HandelswareDatevConfig" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "HandelswareDatevConfig"
USING ("tenantId" = current_tenant_id());
-- 2) HandelswareKonto
CREATE TABLE "HandelswareKonto" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"gegenkonto" INTEGER NOT NULL,
"erloeskonto" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HandelswareKonto_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "HandelswareKonto_tenantId_name_key" ON "HandelswareKonto"("tenantId", "name");
CREATE INDEX "HandelswareKonto_tenantId_idx" ON "HandelswareKonto"("tenantId");
ALTER TABLE "HandelswareKonto" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "HandelswareKonto" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "HandelswareKonto"
USING ("tenantId" = current_tenant_id());
@@ -0,0 +1,28 @@
-- 261002-icv — Freigabestufe fuer Modul-Freigaben: Benutzen (USE) und
-- Verwalten (MANAGE).
--
-- Zweck: jede Zeile in "ModuleGrant" bekommt eine Stufe. USE ist der Bestand
-- und der Standard (Modul oeffnen und benutzen). MANAGE erlaubt zusaetzlich,
-- die eigenen Einstellungen dieses einen Moduls zu aendern. Freigaben
-- erteilen, Module aktivieren und die uebrige Verwaltung bleiben
-- Administratoren vorbehalten (das erzwingt die Anwendung, nicht diese
-- Migration).
--
-- Bestandsdaten: durch den DEFAULT 'USE' werden alle vorhandenen Freigaben zu
-- USE — niemand gewinnt durch die Migration Rechte.
--
-- Von Hand geschrieben (Vorbild 20261002120000_kantine_datev_config).
--
-- Zeilenschutz: keine neue Tabelle. Die vorhandenen Regeln auf "ModuleGrant"
-- filtern Zeilen, nicht Spalten, und bleiben unveraendert — rls-coverage
-- braucht nichts. PostgreSQL gewaehrt USAGE auf neue Typen automatisch an
-- PUBLIC, die Anwendungsrolle tessera_app kann den Aufzaehlungstyp also
-- verwenden.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken die Zeilenregeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
CREATE TYPE "ModuleGrantLevel" AS ENUM ('USE', 'MANAGE');
ALTER TABLE "ModuleGrant" ADD COLUMN "level" "ModuleGrantLevel" NOT NULL DEFAULT 'USE';
@@ -0,0 +1,65 @@
-- quick-261002-k67 — Modul Nextcloud-Status.
--
-- Zweck: neue Tabelle "NextcloudInstance" fuer die vom Verwalter
-- eingetragenen Nextcloud-Clouds der Kunden (Kundenname, Adresse, optionales
-- Logo) samt zuletzt ermitteltem Zustand (Erreichbarkeit, Wartungsmodus,
-- Versionstext, Fehlerart). Der Zustand liegt direkt auf der Zeile, es gibt
-- kein Zwischenlager. Logo-Bytes liegen als BYTEA an der Zeile (hoechstens
-- 1 MiB, Pruefung im Dienst); Abfragen ausser dem Logo-Abruf waehlen sie
-- nie mit aus.
--
-- Von Hand geschrieben (Vorbild 20260923140000_proxmox_server), von Hand
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
--
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die
-- Tabelle traegt `tenantId` und `tenant_isolation_policy` OHNE
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — die
-- Clouds sind gemeinsame Daten der Organisation, nicht persoenliche Daten
-- eines einzelnen Benutzers.
--
-- Zusaetzlich eine `system_read_policy` (Form aus
-- 20260914120000_rls_system_context_read): der stuendliche Hintergrunddienst
-- liest ueber `forSystem()` genau einmal je Durchlauf Kennung und Mandant
-- aller Clouds und prueft danach jede Cloud an ihren eigenen Mandanten
-- gebunden. Geschrieben wird nie im Systemkontext.
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
CREATE TABLE "NextcloudInstance" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"customerName" TEXT NOT NULL,
"baseUrl" TEXT NOT NULL,
"logoUrl" TEXT,
"logoData" BYTEA,
"logoMime" TEXT,
"logoVersion" INTEGER NOT NULL DEFAULT 0,
"lastCheckedAt" TIMESTAMP(3),
"reachable" BOOLEAN,
"maintenance" BOOLEAN,
"needsDbUpgrade" BOOLEAN,
"versionString" TEXT,
"edition" TEXT,
"productName" TEXT,
"errorKind" TEXT,
"errorDetail" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NextcloudInstance_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "NextcloudInstance_tenantId_idx" ON "NextcloudInstance"("tenantId");
ALTER TABLE "NextcloudInstance" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "NextcloudInstance" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "NextcloudInstance"
USING ("tenantId" = current_tenant_id());
CREATE POLICY system_read_policy ON "NextcloudInstance"
FOR SELECT USING (is_system_context());
@@ -0,0 +1,68 @@
-- quick-261002-kxc — Nextcloud-Status: persoenliche Benachrichtigung.
--
-- Zweck: (1) neue Tabelle "NextcloudAlertSubscription" — wer fuer welche
-- Cloud die Glocke eingeschaltet hat (je Benutzer und Cloud hoechstens eine
-- Zeile); (2) fuenf neue Spalten an "NextcloudInstance": Zaehler und Zeitpunkt
-- der aufeinanderfolgenden Fehlschlaege (Zwei-Fehlschlaege-Regel) und der
-- zuletzt gemeldete Zustand ('ok' | 'red') samt Grund und Zeitpunkt. Der
-- gemeldete Zustand wird VOR dem Mailversand per bedingtem Update beansprucht,
-- damit mehrere API-Instanzen oder ein Neustart nie doppelt melden.
-- Bestehende Zeilen starten als 'ok' ohne Fehlschlaege.
--
-- Von Hand geschrieben (Vorbild 20261002150000_nextcloud_status und
-- 20260929140000_reminder).
--
-- Zeilenschutz: das Abonnement ist ein persoenliches Datum, deshalb
-- `tenant_isolation_policy` MIT Benutzerdimension — exakt wie "Reminder"
-- (ohne gesetzten Benutzer gilt nur der Mandant, mit Benutzer zusaetzlich
-- "userId"). Keine `system_read_policy`: die Tabelle wird nie im
-- Systemkontext gelesen, jede Abfrage laeuft an den Mandanten gebunden. Die
-- neuen Spalten von "NextcloudInstance" fallen unter deren bestehende Regeln.
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
-- AlterTable
ALTER TABLE "NextcloudInstance"
ADD COLUMN "consecutiveFailures" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "firstFailureAt" TIMESTAMP(3),
ADD COLUMN "alertState" TEXT NOT NULL DEFAULT 'ok',
ADD COLUMN "alertReason" TEXT,
ADD COLUMN "alertChangedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "NextcloudAlertSubscription" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"instanceId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "NextcloudAlertSubscription_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "NextcloudAlertSubscription_instanceId_userId_key" ON "NextcloudAlertSubscription"("instanceId", "userId");
-- CreateIndex
CREATE INDEX "NextcloudAlertSubscription_tenantId_userId_idx" ON "NextcloudAlertSubscription"("tenantId", "userId");
-- AddForeignKey
ALTER TABLE "NextcloudAlertSubscription" ADD CONSTRAINT "NextcloudAlertSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NextcloudAlertSubscription" ADD CONSTRAINT "NextcloudAlertSubscription_instanceId_fkey" FOREIGN KEY ("instanceId") REFERENCES "NextcloudInstance"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Zeilenschutz: Mandant UND Benutzer (Muster "Reminder")
ALTER TABLE "NextcloudAlertSubscription" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "NextcloudAlertSubscription" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "NextcloudAlertSubscription"
USING (
"tenantId" = current_tenant_id()
AND (current_user_id() IS NULL OR "userId" = current_user_id())
);
@@ -0,0 +1,89 @@
-- quick-261003-387 — Modulkategorien durch Administratoren bearbeitbar.
--
-- Zweck: zwei neue Tabellen und eine neue Spalte.
-- * "ModuleCategory": die Kategorien einer Organisation (Kennung, optionaler
-- eigener Name, Reihenfolge, Systemkennzeichen fuer "Eigene Module").
-- Die Kennung ist unveraenderlich, sie steht als URL-Segment in
-- /modules/<kennung>/<slug>.
-- * "ModuleCategoryPlacement": Zuordnung eines Marktplatz-Moduls zu einer
-- Kategorie der Organisation samt Reihenfolge. Die Spalte "Module"."category"
-- (fuer alle Organisationen gleich) bleibt unveraendert; die wirksame
-- Kategorie ist die Zuordnung, sonst diese Spalte.
-- * "CustomModule"."sortOrder": Reihenfolge gemeinsamer eigener Module
-- innerhalb ihrer Kategorie (NULL = noch nicht sortiert).
-- Es gibt keine Rueckfuellung in SQL: der Dienst legt den Grundbestand je
-- Organisation beim ersten Lesen an.
--
-- Von Hand geschrieben (Vorbild 20261002150000_nextcloud_status), von Hand
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
--
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): beide
-- Tabellen tragen `tenantId` und `tenant_isolation_policy` OHNE
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — die
-- Kategorien sind gemeinsame Einstellungen der Organisation, nicht
-- persoenliche Daten eines Benutzers. Keine `system_read_policy`: es gibt
-- keinen Hintergrunddienst, der darueber liest.
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
-- AlterTable
ALTER TABLE "CustomModule" ADD COLUMN "sortOrder" INTEGER;
-- CreateTable
CREATE TABLE "ModuleCategory" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"key" TEXT NOT NULL,
"name" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"isSystem" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ModuleCategory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ModuleCategoryPlacement" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"moduleId" TEXT NOT NULL,
"categoryKey" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ModuleCategoryPlacement_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ModuleCategory_tenantId_idx" ON "ModuleCategory"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "ModuleCategory_tenantId_key_key" ON "ModuleCategory"("tenantId", "key");
-- CreateIndex
CREATE INDEX "ModuleCategoryPlacement_tenantId_idx" ON "ModuleCategoryPlacement"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "ModuleCategoryPlacement_tenantId_moduleId_key" ON "ModuleCategoryPlacement"("tenantId", "moduleId");
-- AddForeignKey
ALTER TABLE "ModuleCategoryPlacement" ADD CONSTRAINT "ModuleCategoryPlacement_moduleId_fkey" FOREIGN KEY ("moduleId") REFERENCES "Module"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Zeilenschutz
ALTER TABLE "ModuleCategory" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "ModuleCategory" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "ModuleCategory"
USING ("tenantId" = current_tenant_id());
ALTER TABLE "ModuleCategoryPlacement" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "ModuleCategoryPlacement" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "ModuleCategoryPlacement"
USING ("tenantId" = current_tenant_id());
@@ -0,0 +1,165 @@
-- 261008-dts — Modul "Domains" (AutoDNS / InterNetX Domainrobot).
--
-- Zweck: vier neue Tabellen und zwei Aufzaehlungen.
-- * "DomainsConfig": Einstellungen der Organisation (Singleton), getrennte
-- Zugaenge fuer das Demo- und das Live-System, Passwoerter AES-verschluesselt
-- (CryptoService), Standard-Nameserver.
-- * "DomainsCustomer": Kunden, denen AutoDNS-Kontakte zugeordnet werden;
-- eine Zeile darf als "Eigene Firma" markiert sein (Pruefung im Dienst).
-- * "DomainsContactAssignment": Zuordnung AutoDNS-Kontakt -> Kunde. Die
-- Umgebung (DEMO/LIVE) gehoert zum Schluessel, weil Kontakt-Ids beider
-- Systeme kollidieren. Kontakte und Domains selbst werden NICHT gespiegelt,
-- AutoDNS bleibt die Quelle der Wahrheit.
-- * "DomainsOrder": Domain-Bestellungen (Geldsicherheit). "openKey" traegt den
-- Domainnamen, solange die Bestellung offen ist (DRAFT, SUBMITTING,
-- SUBMITTED, UNKNOWN, SUCCESS) und ist bei FAILED/CANCELED NULL. Die
-- Unique-Regel (tenantId, environment, openKey) verhindert zwei offene
-- Bestellungen derselben Domain; NULLs wiederholen sich in Postgres
-- beliebig. SUCCESS behaelt den Schluessel mit Absicht: kurz nach der
-- Registrierung kann ein verzoegerter WHOIS noch "frei" melden.
--
-- AutoDNS-Kontakt- und Job-Ids stehen als TEXT (opake Dezimalzahlen).
--
-- Von Hand geschrieben (Vorbild 20261002130000_handelsware_datev); der
-- DDL-Teil stammt aus `prisma migrate diff`, damit der Stand ohne Abweichung
-- zum Schema passt.
--
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): alle
-- Tabellen tragen `tenantId` und `tenant_isolation_policy` OHNE
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — das sind
-- Verwaltungsdaten der Organisation, keine persoenlichen Daten eines
-- Benutzers. Keine `system_read_policy`: es gibt keinen Hintergrunddienst, der
-- diese Tabellen ueber alle Organisationen liest (Auftragsstatus wird beim
-- Oeffnen der Seite abgefragt, nicht per Zeitplan).
--
-- 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).
-- CreateEnum
CREATE TYPE "AutodnsEnvironment" AS ENUM ('DEMO', 'LIVE');
-- CreateEnum
CREATE TYPE "DomainOrderStatus" AS ENUM ('DRAFT', 'SUBMITTING', 'SUBMITTED', 'SUCCESS', 'FAILED', 'UNKNOWN', 'CANCELED');
-- CreateTable
CREATE TABLE "DomainsConfig" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"environment" "AutodnsEnvironment" NOT NULL DEFAULT 'DEMO',
"demoUser" TEXT,
"demoEncryptedPassword" TEXT,
"demoContext" INTEGER,
"liveUser" TEXT,
"liveEncryptedPassword" TEXT,
"liveContext" INTEGER,
"defaultNameServers" TEXT[] DEFAULT ARRAY[]::TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DomainsConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DomainsCustomer" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"isOwnCompany" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DomainsCustomer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DomainsContactAssignment" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"environment" "AutodnsEnvironment" NOT NULL,
"autodnsContactId" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DomainsContactAssignment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "DomainsOrder" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"environment" "AutodnsEnvironment" NOT NULL,
"domainName" TEXT NOT NULL,
"openKey" TEXT,
"status" "DomainOrderStatus" NOT NULL DEFAULT 'DRAFT',
"payload" JSONB NOT NULL,
"jobId" TEXT,
"jobStatus" TEXT,
"errorText" TEXT,
"createdByUserId" TEXT NOT NULL,
"confirmedByUserId" TEXT,
"confirmedByUsername" TEXT,
"confirmedAt" TIMESTAMP(3),
"lastCheckedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DomainsOrder_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "DomainsConfig_tenantId_key" ON "DomainsConfig"("tenantId");
-- CreateIndex
CREATE INDEX "DomainsConfig_tenantId_idx" ON "DomainsConfig"("tenantId");
-- CreateIndex
CREATE INDEX "DomainsCustomer_tenantId_idx" ON "DomainsCustomer"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "DomainsCustomer_tenantId_name_key" ON "DomainsCustomer"("tenantId", "name");
-- CreateIndex
CREATE INDEX "DomainsContactAssignment_tenantId_idx" ON "DomainsContactAssignment"("tenantId");
-- CreateIndex
CREATE INDEX "DomainsContactAssignment_customerId_idx" ON "DomainsContactAssignment"("customerId");
-- CreateIndex
CREATE UNIQUE INDEX "DomainsContactAssignment_tenantId_environment_autodnsContac_key" ON "DomainsContactAssignment"("tenantId", "environment", "autodnsContactId");
-- CreateIndex
CREATE INDEX "DomainsOrder_tenantId_idx" ON "DomainsOrder"("tenantId");
-- CreateIndex
CREATE UNIQUE INDEX "DomainsOrder_tenantId_environment_openKey_key" ON "DomainsOrder"("tenantId", "environment", "openKey");
-- AddForeignKey
ALTER TABLE "DomainsContactAssignment" ADD CONSTRAINT "DomainsContactAssignment_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "DomainsCustomer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Zeilenschutz
ALTER TABLE "DomainsConfig" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "DomainsConfig" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "DomainsConfig"
USING ("tenantId" = current_tenant_id());
ALTER TABLE "DomainsCustomer" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "DomainsCustomer" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "DomainsCustomer"
USING ("tenantId" = current_tenant_id());
ALTER TABLE "DomainsContactAssignment" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "DomainsContactAssignment" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "DomainsContactAssignment"
USING ("tenantId" = current_tenant_id());
ALTER TABLE "DomainsOrder" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "DomainsOrder" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "DomainsOrder"
USING ("tenantId" = current_tenant_id());
@@ -0,0 +1,3 @@
-- quick-261008-h3t: Die Standard-Nameserver kommen aus dem AutoDNS-Benutzerprofil.
-- Die Spalte wird nicht mehr gelesen, ihre Werte sind wertlos.
ALTER TABLE "DomainsConfig" DROP COLUMN "defaultNameServers";
+337 -1
View File
@@ -46,9 +46,19 @@ model User {
// quick-260925-bow: zuletzt gesehene freigegebene Version (X.Y.Z) fuer das
// "Was ist neu"-Fenster; null = Bestandsbenutzer (sieht nur die laufende Version)
lastSeenReleaseVersion String?
// quick-260928-ujj: gewaehlter Dashboard-Hintergrund; null = nie gewaehlt,
// 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[]
customModules CustomModule[]
reminders Reminder[]
nextcloudAlertSubscriptions NextcloudAlertSubscription[]
@@index([tenantId])
@@index([username])
@@ -110,6 +120,7 @@ model Module {
updatedAt DateTime @updatedAt
activations TenantModuleActivation[]
grants ModuleGrant[]
categoryPlacements ModuleCategoryPlacement[]
}
model TenantModuleActivation {
@@ -131,12 +142,20 @@ model TenantModuleActivation {
// darf höchstens eine Gruppe die Standard-Markierung tragen, DB-erzwungen
// über einen partiellen Unique-Index in der Hand-SQL-Ergänzung dieser
// Migration (Prisma 6.19 kennt keine partiellen Indizes ohne Preview-Flag).
// D-04: ModuleGrant trägt bewusst KEIN Rechtestufen-Feld — nur Zugriff an/aus.
// D-04 (überholt durch 261002-icv): ModuleGrant trägt seit 261002-icv die Freigabestufe `level`.
enum MembershipSource {
MANUAL
LDAP
}
// 261002-icv: Freigabestufe einer Modul-Freigabe. USE = Benutzen (Standard und
// Bestand), MANAGE = Verwalten (Modul benutzen UND dessen eigene Einstellungen
// ändern). Freigaben erteilen bleibt Administratoren vorbehalten.
enum ModuleGrantLevel {
USE
MANAGE
}
model Group {
id String @id @default(uuid())
tenantId String
@@ -182,6 +201,10 @@ model ModuleGrant {
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
// 261002-icv: Freigabestufe; USE = Benutzen (Standard und Bestand),
// MANAGE = Verwalten — Modul benutzen und dessen eigene Einstellungen
// ändern; Freigaben erteilen bleibt Administratoren vorbehalten.
level ModuleGrantLevel @default(USE)
// Entweder-oder (Gruppe XOR Benutzer, D-04) + Duplikat-Schutz je Variante
// werden per hand-editierter migration.sql ergänzt — Prisma 6.19 hat kein
@@ -335,6 +358,56 @@ model DkvModuleConfig {
@@index([tenantId])
}
// quick-261002-fm5: Kantinenabrechnung (Modul kantine-datev). Eine Zeile je
// Mandant (Singleton wie DkvModuleConfig). Die drei Nummern stehen als Text,
// damit fuehrende Nullen erhalten bleiben; sie haben bewusst KEINEN
// Standardwert — der Administrator hinterlegt sie einmalig, bis dahin ist die
// Verarbeitung gesperrt. Die hochgeladene CSV selbst wird nie gespeichert.
model KantineDatevConfig {
id String @id @default(uuid())
tenantId String @unique
beraterNr String?
mandantNr String?
lohnart String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId])
}
// quick-261002-fm5: Handelsware (Modul handelsware-datev). Einstellungen je
// Mandant (Singleton wie KantineDatevConfig): Standard-Erloeskonto fuer neue
// Konten und Startwert fuer die Gegenkonto-Vergabe bei leerer Kontenliste.
// Beide Zahlen haben bewusst KEINEN Standardwert — der Administrator hinterlegt
// sie einmalig, bis dahin ist die Verarbeitung gesperrt.
model HandelswareDatevConfig {
id String @id @default(uuid())
tenantId String @unique
erloeskonto Int?
startGegenkonto Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId])
}
// quick-261002-fm5: Kontenliste der Handelsware (Produktname -> Gegenkonto,
// Erloeskonto). Der Name ist je Mandant eindeutig; das Gegenkonto bewusst
// NICHT (mehrere Produkte duerfen auf dasselbe Gegenkonto laufen, wie in der
// Desktop-Vorlage). Keine Relation zu Tenant, Zeilenschutz nach ProxmoxServer.
model HandelswareKonto {
id String @id @default(uuid())
tenantId String
name String
gegenkonto Int
erloeskonto Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, name])
@@index([tenantId])
}
// Phase 14, Plan 03 (INGEST-05, CONFIG-02, D-06/D-07) — per-tenant portal-
// alert mailbox config, mirroring DkvModuleConfig's shape/pattern exactly
// (own tenantId @unique row, own encrypted creds — D-03: each module keeps
@@ -712,3 +785,266 @@ model ProxmoxServerStatus {
@@index([tenantId])
}
// Nextcloud-Status (quick-261002-k67): vom Verwalter eingetragene Clouds der
// Kunden. Der zuletzt ermittelte Zustand liegt direkt auf der Zeile (L-03),
// es gibt kein Zwischenlager. Logo-Bytes liegen als bytea an der Zeile (D-A,
// hoechstens 1 MiB); Listen- und Planerabfragen waehlen sie nie mit aus.
// Zeilenschutz nach Muster ProxmoxServer (tenantId, keine Relation zu Tenant).
model NextcloudInstance {
id String @id @default(uuid())
tenantId String
customerName String
baseUrl String
logoUrl String?
logoData Bytes?
logoMime String?
logoVersion Int @default(0)
lastCheckedAt DateTime?
reachable Boolean? // null = noch nie geprueft
maintenance Boolean?
needsDbUpgrade Boolean?
versionString String?
edition String?
productName String?
errorKind String?
errorDetail String?
// quick-261002-kxc: Zwei-Fehlschlaege-Regel und zuletzt gemeldeter Zustand.
// consecutiveFailures/firstFailureAt: aufeinanderfolgende fehlgeschlagene
// Abrufe (der erste aendert den gespeicherten Zustand nicht).
consecutiveFailures Int @default(0)
firstFailureAt DateTime?
// zuletzt gemeldeter Zustand: 'ok' | 'red' (Anspruch vor dem Mailversand)
alertState String @default("ok")
alertReason String?
alertChangedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
subscriptions NextcloudAlertSubscription[]
@@index([tenantId])
}
// quick-261002-kxc: persoenliche Benachrichtigung (Glocke) je Benutzer und
// Nextcloud-Cloud. Zeilenschutz MIT Benutzerdimension wie "Reminder"; faellt
// Benutzer oder Cloud weg, faellt das Abonnement mit.
model NextcloudAlertSubscription {
id String @id @default(uuid())
tenantId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
instanceId String
instance NextcloudInstance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([instanceId, userId])
@@index([tenantId, userId])
}
// Domains (quick-261008-dts): Anbindung an AutoDNS / InterNetX (Domainrobot).
// AutoDNS bleibt die Quelle der Wahrheit fuer Kontakte und Domains (werden live
// gelesen, nie gespiegelt); lokal liegt nur, was AutoDNS nicht kennt (Zuordnung
// Kontakt -> Kunde) oder was Geldsicherheit braucht (Bestellungen). AutoDNS-
// Kontakt- und Job-Ids sind opake Dezimalzahlen und stehen als String (kein
// Int32-Ueberlauf). Zeilenschutz nach Muster ProxmoxServer (tenantId, keine
// Relation zu Tenant).
enum AutodnsEnvironment {
DEMO
LIVE
}
enum DomainOrderStatus {
DRAFT
SUBMITTING
SUBMITTED
SUCCESS
FAILED
UNKNOWN
CANCELED
}
// Einstellungen je Organisation (Singleton). Getrennte Zugaenge fuer das
// Demo- und das Live-System; Passwoerter AES-verschluesselt (CryptoService),
// nie an den Client zurueckgegeben.
model DomainsConfig {
id String @id @default(uuid())
tenantId String @unique
environment AutodnsEnvironment @default(DEMO)
demoUser String?
demoEncryptedPassword String?
demoContext Int?
liveUser String?
liveEncryptedPassword String?
liveContext Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId])
}
// Kunden, denen AutoDNS-Kontakte zugeordnet werden. Hoechstens einer ist die
// eigene Firma (Pruefung im Dienst). Kein Firmenname als Vorgabe.
model DomainsCustomer {
id String @id @default(uuid())
tenantId String
name String
isOwnCompany Boolean @default(false)
assignments DomainsContactAssignment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, name])
@@index([tenantId])
}
// Zuordnung AutoDNS-Kontakt -> Kunde. Die Umgebung ist Teil des Schluessels,
// weil Kontakt-Ids von Demo- und Live-System zwangslaeufig kollidieren.
model DomainsContactAssignment {
id String @id @default(uuid())
tenantId String
environment AutodnsEnvironment
autodnsContactId String
customerId String
customer DomainsCustomer @relation(fields: [customerId], references: [id], onDelete: Restrict)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, environment, autodnsContactId])
@@index([tenantId])
@@index([customerId])
}
// Domain-Bestellungen (Geldsicherheit). `openKey` traegt den Domainnamen,
// solange die Bestellung offen ist (DRAFT, SUBMITTING, SUBMITTED, UNKNOWN,
// SUCCESS), und ist bei FAILED/CANCELED null; die Unique-Regel darauf
// verhindert zwei offene Bestellungen derselben Domain (NULLs wiederholen
// sich in Postgres).
model DomainsOrder {
id String @id @default(uuid())
tenantId String
environment AutodnsEnvironment
domainName String
openKey String?
status DomainOrderStatus @default(DRAFT)
payload Json
jobId String?
jobStatus String?
errorText String?
createdByUserId String
confirmedByUserId String?
confirmedByUsername String?
confirmedAt DateTime?
lastCheckedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, environment, openKey])
@@index([tenantId])
}
// Eigene Module (quick-260929-9wc): vom Administrator angelegte Seitenleisten-
// Eintraege, die eine externe https-Seite im Rahmen zeigen. Sichtbar fuer alle
// Benutzer des Mandanten. Zeilenschutz nach Muster ProxmoxServer (tenantId,
// keine Relation zu Tenant).
model CustomModule {
id String @id @default(uuid())
tenantId String
name String
url String
category String // Kennung einer ModuleCategory der Organisation
// quick-261003-387: Reihenfolge innerhalb der Kategorie (nur gemeinsame
// Eintraege; null = noch nicht sortiert, steht hinten). Persoenliche
// Eintraege bekommen nie eine sortOrder.
sortOrder Int?
// quick-260929-dzu: null = gemeinsamer Eintrag (vom Administrator, fuer alle
// sichtbar); gesetzt = persoenlicher Eintrag, nur fuer diesen Benutzer
// sichtbar. Faellt der Benutzer weg, fallen seine Eintraege mit.
ownerUserId String?
owner User? @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId])
@@index([tenantId, ownerUserId])
}
// quick-260929-if2: persoenliche Erinnerungen, einmalig (D-01). Eine Zeile
// gehoert genau einem Benutzer (D-05); fuer fremde Kennungen antwortet die API
// mit 404. "Erledigt" loescht die Zeile (E-02), es gibt keine Historie.
// emailSentAt/emailAttempts sind die Rechenspur des E-Mail-Planers (Anspruch
// vor dem Senden, hoechstens 3 Versuche); ein Verschieben (dueAt) setzt beide
// zurueck, damit die E-Mail erneut verschickt wird (D-03).
model Reminder {
id String @id @default(uuid())
tenantId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
title String
description String @default("")
dueAt DateTime
emailEnabled Boolean @default(false)
emailSentAt DateTime?
emailAttempts Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId, userId, dueAt])
@@index([dueAt])
}
// Eigene Vorlage der Willkommensmail (Administrator → Willkommensmail):
// hoechstens eine je Mandant; fehlt sie, gelten die Standardtexte aus
// @tessera/shared (DEFAULT_WELCOME_MAIL_TEXTS). Nur die sechs Texte —
// Kopf, Zugangsdaten und Knoepfe bleiben fest im Code.
model WelcomeMailTemplate {
id String @id @default(uuid())
tenantId String @unique
subject String
heading String
intro String
// Anmeldehinweise je Kontoart (Migration 20260930170000); NULL in einer
// aelteren Vorlage = Standardtext aus @tessera/shared
loginHintDirectory String?
loginHintLocal String?
closing String
// Benutzername des Administrators, der zuletzt gespeichert hat (Anzeige)
updatedBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// quick-261003-387: Modulkategorien je Organisation, durch Administratoren
// pflegbar. `key` ist unveraenderlich (URL-Segment /modules/<key>/<slug>),
// `name` null = Uebersetzung moduleCategories.<key>. isSystem nur fuer
// "custom-modules" (Eigene Module): umbenennbar und verschiebbar, nicht
// loeschbar.
model ModuleCategory {
id String @id @default(uuid())
tenantId String
key String
name String?
sortOrder Int @default(0)
isSystem Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, key])
@@index([tenantId])
}
// quick-261003-387: Zuordnung eines Marktplatz-Moduls zu einer Kategorie der
// Organisation samt Reihenfolge. Wirksame Kategorie = Zuordnung, sonst
// Module.category (die Spalte selbst bleibt fuer alle gleich).
model ModuleCategoryPlacement {
id String @id @default(uuid())
tenantId String
moduleId String
module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade)
categoryKey String
sortOrder Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, moduleId])
@@index([tenantId])
}
+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;
}
+14
View File
@@ -17,6 +17,7 @@ import { DkvModule } from './dkv/dkv.module';
import { CertManagerModule } from './cert-manager/cert-manager.module';
import { FavoritesModule } from './favorites/favorites.module';
import { DomaincheckModule } from './domaincheck/domaincheck.module';
import { DomainsModule } from './domains/domains.module';
import { GroupsModule } from './groups/groups.module';
import { ModuleRegistryModule } from './module-registry/module-registry.module';
import { PrismaModule } from './prisma/prisma.module';
@@ -26,7 +27,13 @@ import { TenantGuard } from './tenant/tenant.guard';
import { TenantModule } from './tenant/tenant.module';
import { TendersModule } from './tenders/tenders.module';
import { UserModule } from './user/user.module';
import { HandelswareDatevModule } from './handelsware-datev/handelsware-datev.module';
import { KantineDatevModule } from './kantine-datev/kantine-datev.module';
import { NextcloudStatusModule } from './nextcloud-status/nextcloud-status.module';
import { ProxmoxModule } from './proxmox/proxmox.module';
import { CustomModulesModule } from './custom-modules/custom-modules.module';
import { ModuleCategoriesModule } from './module-categories/module-categories.module';
import { RemindersModule } from './reminders/reminders.module';
@Module({
imports: [
@@ -53,6 +60,13 @@ import { ProxmoxModule } from './proxmox/proxmox.module';
TendersModule,
BugReportsModule,
ProxmoxModule,
NextcloudStatusModule,
DomainsModule,
KantineDatevModule,
HandelswareDatevModule,
CustomModulesModule,
ModuleCategoriesModule,
RemindersModule,
],
providers: [
// Global JWT guard: all routes require auth unless @Public()
+29
View File
@@ -49,6 +49,7 @@ interface FakeUserRow {
mustChangePassword: boolean;
avatarPath?: string | null;
accentColor?: string | null;
dashboardBackground?: unknown;
}
interface BoundCall {
@@ -501,6 +502,8 @@ describe('AuthService.getMe', () => {
mustChangePassword: false,
avatarPath: 'avatars/u1.png',
accentColor: '#3b82f6',
// quick-260928-ujj: gespeicherter Zusatzschluessel wird bei der Ausgabe verworfen.
dashboardBackground: { kind: 'preset', id: 'dunes', extra: 'weg' },
};
const ldapUserRow: FakeUserRow = {
@@ -515,6 +518,7 @@ describe('AuthService.getMe', () => {
mustChangePassword: false,
avatarPath: null,
accentColor: null,
dashboardBackground: null,
};
beforeEach(() => {
@@ -535,6 +539,7 @@ describe('AuthService.getMe', () => {
tenantId: 't1',
mustChangePassword: false,
accentColor: '#3b82f6',
dashboardBackground: { kind: 'preset', id: 'dunes' },
isLocalUser: true,
hasAvatar: true,
});
@@ -549,6 +554,30 @@ describe('AuthService.getMe', () => {
expect(result).toMatchObject({ isLocalUser: false, hasAvatar: false });
});
it('quick-260928-ujj: dashboardBackground NULL (nie gewaehlt) kommt als null zurueck', async () => {
const result = await service.getMe('t1', 'u2');
expect(result).toHaveProperty('dashboardBackground', null);
});
it('quick-260928-ujj: ungueltiger gespeicherter Hintergrund kommt als null zurueck (T-ujj-01)', async () => {
prisma.__users.set('u1', {
...prisma.__users.get('u1'),
dashboardBackground: { kind: 'image', imageId: '") ; background: url("x' },
});
const result = await service.getMe('t1', 'u1');
expect(result).toHaveProperty('dashboardBackground', null);
});
it('quick-260928-ujj: dashboardBackground steht im select neben accentColor', async () => {
await service.getMe('t1', 'u1');
const call = prisma.__boundCallLog.find((c: any) => c.method === 'findUnique');
expect(call.args.select).toMatchObject({ accentColor: true, dashboardBackground: true });
});
it('FREMDER Mandant (Klient unter t2, Zeile unter t1): liefert null, kein Fehler', async () => {
const result = await service.getMe('t2', 'u1');
+8 -2
View File
@@ -8,6 +8,7 @@ import {
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { Role } from '@prisma/client';
import { parseDashboardBackground } from '@tessera/shared';
import * as argon2 from 'argon2';
import { randomUUID } from 'node:crypto';
import { Response } from 'express';
@@ -16,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';
/**
@@ -238,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).
@@ -331,6 +333,7 @@ export class AuthService {
ldapDn: true,
avatarPath: true,
accentColor: true,
dashboardBackground: true,
},
});
@@ -338,10 +341,13 @@ export class AuthService {
return null;
}
const { passwordHash, ldapDn, avatarPath, ...publicFields } = user;
const { passwordHash, ldapDn, avatarPath, dashboardBackground, ...publicFields } = user;
return {
...publicFields,
// quick-260928-ujj (T-ujj-01): auch beim Lesen durch die gemeinsame
// Pruefregel — NULL oder ein ungueltiger Inhalt ergibt null.
dashboardBackground: parseDashboardBackground(dashboardBackground),
isLocalUser: !!passwordHash && !ldapDn,
hasAvatar: !!avatarPath,
};
@@ -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;
}
type Row = {
id: string;
username: string;
role: string;
tenantId: string;
isActive: boolean;
mustChangePassword: boolean;
} | null;
function makePrisma(row: Row) {
return { user: { findUnique: vi.fn(async () => row) } } as any;
}
const payload = {
sub: 'u1',
username: 'kschaller',
role: 'SUPER_ADMIN' as const,
tenantId: 't1',
mustChangePassword: false,
};
const dbRow = {
id: 'u1',
username: 'kschaller',
role: 'ADMIN',
tenantId: 't1',
isActive: true,
mustChangePassword: false,
};
describe('JwtStrategy.validate', () => {
it('Anspruch mustChangePassword=true im Token: liefert request.user.mustChangePassword === true', async () => {
const strategy = new JwtStrategy(makeConfigService());
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({
sub: 'u1',
username: 'admin',
role: 'ADMIN',
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 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,
};
}
}
+6 -1
View File
@@ -1,4 +1,4 @@
import type { Role } from '@prisma/client';
import type { ModuleGrantLevel, Role } from '@prisma/client';
import type { Request } from 'express';
/**
@@ -83,6 +83,11 @@ export interface AuthUser {
export interface AuthenticatedRequest extends Request {
user?: AuthUser;
tenantId?: string | null;
/**
* Wirksame Freigabestufe des Aufrufers fuer das Modul der Route; setzt
* `ModuleGuard` (nur auf Routen mit `@UseModule`/`@ModuleManage`).
*/
moduleAccessLevel?: ModuleGrantLevel;
}
/**
+40 -1
View File
@@ -27,7 +27,7 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
}));
import { forTenant } from '../prisma/prisma-tenant.extension';
import { CalendarService } from './calendar.service';
import { CalendarService, CalendarSourceUnreachableError } from './calendar.service';
function _applySelect(row: any, select: Record<string, boolean> | undefined) {
if (!select) return { ...row };
@@ -533,6 +533,45 @@ describe('CalendarService — Bindung an forTenant() (260911-cwh)', () => {
expect(vi.mocked(prisma.calendarSource.create)).not.toHaveBeenCalled();
});
// ─── quick-261005: Kalender-Server nicht erreichbar ──────────────────────
it('testConnectionFromConfig: nicht erreichbarer Server liefert den Schluessel "unreachable"', async () => {
const prisma = makeFakePrisma();
const exchange = {
fetchEvents: vi.fn(async () => []),
testConnection: vi.fn(async () => {
throw new CalendarSourceUnreachableError();
}),
};
const { service } = makeCalendarService(prisma, { exchangeProvider: exchange });
const result = await service.testConnectionFromConfig({
type: 'exchange',
url: 'https://owa.example.invalid/EWS/Exchange.asmx',
exchangeMode: 'ews',
} as any);
expect(result).toEqual({ success: false, error: 'unreachable' });
});
it('testConnection (gespeicherte Quelle): nicht erreichbar -> "unreachable", lastSyncError ohne Details', async () => {
const prisma = makeFakePrisma();
prisma.__seedSource({ id: 'src-a1', userId: 'user-a1', tenantId: 't1', type: 'exchange' });
const exchange = {
fetchEvents: vi.fn(async () => []),
testConnection: vi.fn(async () => {
throw new CalendarSourceUnreachableError();
}),
};
const { service } = makeCalendarService(prisma, { exchangeProvider: exchange });
const result = await service.testConnection('src-a1', 'user-a1', 't1');
expect(result).toEqual({ success: false, error: 'unreachable' });
const updateCall = vi.mocked(prisma.calendarSource.update).mock.calls.at(-1)?.[0] as any;
expect(updateCall.data).toEqual({ lastSyncError: 'Server not reachable' });
});
// ─── Wachhund ────────────────────────────────────────────────────────
it('keine Methode dieses Bereichs erzeugt mehr als EINEN gebundenen Klienten je Aufruf', async () => {
+46 -4
View File
@@ -30,6 +30,43 @@ export interface CalendarEvent {
color?: string;
}
/**
* quick-261005: Ein Provider wirft diesen Fehler aus `testConnection`, wenn
* der Kalender-Server gar nicht erreichbar ist (Zeitueberschreitung,
* abgewiesene Verbindung, Name unbekannt) — im Unterschied zu „erreichbar,
* aber Anmeldung abgelehnt“ (`false`). Traegt bewusst keine Details (T-05-13).
*/
export class CalendarSourceUnreachableError extends Error {
constructor() {
super('Calendar server not reachable');
this.name = 'CalendarSourceUnreachableError';
}
}
/** Fehlerkennungen von Node/httpreq, die „Server nicht erreichbar“ bedeuten. */
const NETWORK_UNREACHABLE_CODES = new Set([
'TIMEOUT', // httpreq bei Ablauf von `timeout`
'ETIMEDOUT',
'ECONNREFUSED',
'ECONNRESET',
'EHOSTUNREACH',
'ENETUNREACH',
'ENOTFOUND',
'EAI_AGAIN',
]);
export function isNetworkUnreachableError(error: unknown): boolean {
const code = (error as { code?: unknown; cause?: { code?: unknown } } | null)?.code;
const causeCode = (error as { cause?: { code?: unknown } } | null)?.cause?.code;
return (
(typeof code === 'string' && NETWORK_UNREACHABLE_CODES.has(code)) ||
(typeof causeCode === 'string' && NETWORK_UNREACHABLE_CODES.has(causeCode))
);
}
/** Stabiler Fehlerschluessel fuer die Oberflaeche (dort uebersetzt). */
export const CALENDAR_TEST_UNREACHABLE = 'unreachable';
/**
* Provider interface for calendar source integrations.
* Each provider (ICS, CalDAV, Exchange) implements this contract.
@@ -319,13 +356,15 @@ export class CalendarService {
});
return { success };
} catch {
const errorMsg = 'Connection failed'; // T-05-13: generic error, no credentials
} catch (error) {
const unreachable = error instanceof CalendarSourceUnreachableError;
// T-05-13: generic error, no credentials
const errorMsg = unreachable ? 'Server not reachable' : 'Connection failed';
await tenantPrisma.calendarSource.update({
where: { id },
data: { lastSyncError: errorMsg },
});
return { success: false, error: errorMsg };
return { success: false, error: unreachable ? CALENDAR_TEST_UNREACHABLE : errorMsg };
}
}
@@ -364,7 +403,10 @@ export class CalendarService {
try {
const success = await provider.testConnection(tempSource);
return { success };
} catch {
} catch (error) {
if (error instanceof CalendarSourceUnreachableError) {
return { success: false, error: CALENDAR_TEST_UNREACHABLE };
}
return { success: false, error: 'Connection failed' };
}
}
@@ -0,0 +1,110 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { CalendarSourceUnreachableError, isNetworkUnreachableError } from '../calendar.service';
import type { ExchangeProvider as ExchangeProviderType } from './exchange.provider';
/**
* ExchangeProvider.testConnection (quick-261005): Zeitgrenze fuer EWS und
* getrennte Meldung „nicht erreichbar“.
*
* httpntlm wird per CommonJS-`require` geladen — `vi.mock` greift dort nicht.
* Wie in `inbox/exchange-inbox.provider.spec.ts` wird deshalb Nodes
* `require.cache` vor dem ersten Laden des Providers mit einem Stub belegt.
*/
const httpntlmPath = require.resolve('httpntlm');
const httpntlmPost = vi.fn((_opts: any, cb: (err: Error | null, res: any) => void) => {
cb(new Error('httpntlmPost not configured for this test'), null);
});
require.cache[httpntlmPath] = {
id: httpntlmPath,
filename: httpntlmPath,
loaded: true,
exports: { post: httpntlmPost },
} as any;
let ExchangeProvider: typeof ExchangeProviderType;
beforeAll(async () => {
({ ExchangeProvider } = await import('./exchange.provider'));
});
beforeEach(() => {
httpntlmPost.mockReset();
});
const SOURCE = {
id: 'src-1',
url: 'https://owa.example.invalid/EWS/Exchange.asmx',
username: 'kalender',
password: 'geheim',
domain: 'CONTOSO',
exchangeMode: 'ews',
};
function failWith(code: string) {
httpntlmPost.mockImplementation((_opts, cb) => {
const err = Object.assign(new Error(`fail ${code}`), { code });
cb(err, null);
});
}
describe('ExchangeProvider.testConnection (EWS)', () => {
it('uebergibt httpntlm eine Zeitgrenze von 15 Sekunden', async () => {
httpntlmPost.mockImplementation((_opts, cb) => cb(null, { statusCode: 200, body: '' }));
await new ExchangeProvider().testConnection(SOURCE);
expect(httpntlmPost).toHaveBeenCalled();
expect(httpntlmPost.mock.calls[0][0].timeout).toBe(15_000);
});
it.each([
'TIMEOUT',
'ETIMEDOUT',
'ECONNREFUSED',
'ENOTFOUND',
'EHOSTUNREACH',
])('Netzfehler %s -> CalendarSourceUnreachableError', async (code) => {
failWith(code);
await expect(new ExchangeProvider().testConnection(SOURCE)).rejects.toBeInstanceOf(
CalendarSourceUnreachableError,
);
});
it('keine Rueckmeldung von httpntlm (Verbindungsaufbau haengt) -> nach 15 s nicht erreichbar', async () => {
vi.useFakeTimers();
try {
httpntlmPost.mockImplementation(() => {
/* ruft nie zurueck — wie ein unbeantworteter Verbindungsaufbau */
});
const pending = new ExchangeProvider().testConnection(SOURCE);
const assertion = expect(pending).rejects.toBeInstanceOf(CalendarSourceUnreachableError);
await vi.advanceTimersByTimeAsync(14_999);
await vi.advanceTimersByTimeAsync(1);
await assertion;
} finally {
vi.useRealTimers();
}
});
it('erreichbar, aber Anmeldung abgelehnt (401) -> false, kein Wurf', async () => {
httpntlmPost.mockImplementation((_opts, cb) => cb(null, { statusCode: 401, body: '' }));
await expect(new ExchangeProvider().testConnection(SOURCE)).resolves.toBe(false);
});
it('sonstiger Fehler ohne Netzkennung -> false', async () => {
httpntlmPost.mockImplementation((_opts, cb) => cb(new Error('kaputt'), null));
await expect(new ExchangeProvider().testConnection(SOURCE)).resolves.toBe(false);
});
});
describe('isNetworkUnreachableError', () => {
it('erkennt Kennung direkt und in cause, sonst nicht', () => {
expect(isNetworkUnreachableError({ code: 'ETIMEDOUT' })).toBe(true);
expect(isNetworkUnreachableError({ cause: { code: 'ECONNREFUSED' } })).toBe(true);
expect(isNetworkUnreachableError(new Error('x'))).toBe(false);
expect(isNetworkUnreachableError(null)).toBe(false);
});
});
@@ -1,6 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
import type { AuthProviderCallback } from '@microsoft/microsoft-graph-client';
import { CalendarEvent, CalendarProvider } from '../calendar.service';
import {
CalendarEvent,
CalendarProvider,
CalendarSourceUnreachableError,
isNetworkUnreachableError,
} from '../calendar.service';
/** Optionen, die ntlmPost() unten uebergibt — nichts darueber hinaus. */
interface NtlmOptions {
@@ -11,6 +16,8 @@ interface NtlmOptions {
workstation: string;
body: string;
headers: Record<string, string>;
/** Millisekunden bis zum Abbruch mit `code: 'TIMEOUT'` (siehe ntlmPost). */
timeout: number;
}
/**
@@ -31,6 +38,13 @@ const httpntlm = require('httpntlm') as {
post: (opts: NtlmOptions, cb: (err: Error | null, res: NtlmResponse) => void) => void;
};
/**
* quick-261005: Ohne Grenze wartete ein EWS-Aufruf auf eine nicht
* erreichbare Adresse rund zwei Minuten (TCP-Verbindungsaufbau des
* Betriebssystems), der Test-Knopf hing so lange auf „wird geprueft“.
*/
const EWS_TIMEOUT_MS = 15_000;
const NS_SOAP = 'http://schemas.xmlsoap.org/soap/envelope/';
const NS_TYPES = 'http://schemas.microsoft.com/exchange/services/2006/types';
const NS_MESSAGES = 'http://schemas.microsoft.com/exchange/services/2006/messages';
@@ -74,7 +88,15 @@ function extractAttr(xml: string, tag: string, attr: string): string {
function ntlmPost(opts: NtlmOptions): Promise<{ statusCode: number; body: string }> {
return new Promise((resolve, reject) => {
// Eigene Zeitgrenze zusaetzlich zu `opts.timeout`: httpreq setzt seine
// nur als Leerlaufgrenze am Socket, die waehrend des Verbindungsaufbaus
// ueber den Keep-alive-Agenten von httpntlm NICHT greift — gemessen
// 05.10.: 134 s bis zum Fehler trotz `timeout: 15000`.
const timer = setTimeout(() => {
reject(Object.assign(new Error('EWS request timed out'), { code: 'TIMEOUT' }));
}, opts.timeout);
httpntlm.post(opts, (err, res) => {
clearTimeout(timer);
if (err) return reject(err);
resolve({
statusCode: res.statusCode,
@@ -152,7 +174,10 @@ export class ExchangeProvider implements CalendarProvider {
} else {
return await this.testEwsConnection(source);
}
} catch {
} catch (error) {
// quick-261005: „nicht erreichbar“ getrennt melden, damit die
// Oberflaeche nicht „Zugangsdaten pruefen“ sagt, wenn das Netz fehlt.
if (isNetworkUnreachableError(error)) throw new CalendarSourceUnreachableError();
return false;
}
}
@@ -316,6 +341,7 @@ export class ExchangeProvider implements CalendarProvider {
domain: source.domain ?? '',
workstation: '',
body: soap,
timeout: EWS_TIMEOUT_MS,
headers: {
'Content-Type': 'text/xml; charset=utf-8',
'SOAPAction': `"http://schemas.microsoft.com/exchange/services/2006/messages/${action}"`,
@@ -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');
});
});

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