Compare commits
67 Commits
6cc0d02e97
...
v1.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
| ad004b286d | |||
| 39b1f74b56 | |||
| cf67c8a389 | |||
| d9f2af32d6 | |||
| 3e8c0f4ef5 | |||
| 6c5946ca6e | |||
| 1315f370a9 | |||
| 8be0725577 | |||
| 56c07c3581 | |||
| ee2b0256b5 | |||
| 82472ee665 | |||
| 8cbfb8b69d | |||
| 9039cea686 | |||
| 441854af72 | |||
| d146234bba | |||
| a8a39a4842 | |||
| 80a0d23ccf | |||
| cf70a197cd | |||
| 30fdd99724 | |||
| 445b1d3100 | |||
| 5aa577a7fa | |||
| 747a4d432b | |||
| 2c01f9d783 | |||
| d73aad1ef1 | |||
| ae36a22a51 | |||
| 8b45a281be | |||
| 31ca115796 | |||
| 8686b1a673 | |||
| 20a9eb2c8c | |||
| d63d9f5563 | |||
| 8bf3601a18 | |||
| c3b45974f5 | |||
| c080580459 | |||
| 737974b653 | |||
| 573d070041 | |||
| 6def5396e4 | |||
| d0266bf86e | |||
| 7691d1fd6d | |||
| f23671ac6c | |||
| ad834076c2 | |||
| d8fb9ae07d | |||
| 3892c5f3c6 | |||
| 32591b6690 | |||
| 52668c2c88 | |||
| 7c9d7c1223 | |||
| f2fc39f51c | |||
| b188946e31 | |||
| 8d845e732e | |||
| e55e4bb23f | |||
| a6181e2751 | |||
| 9f02fcc190 | |||
| de7fdb7377 | |||
| c0ab5b5584 | |||
| 8d604b855a | |||
| f471b785db | |||
| 6c10c9bbe0 | |||
| 5a03b75f1e | |||
| 69fe706580 | |||
| f7b5df4db8 | |||
| b406a9c5f7 | |||
| 9aa87bd000 | |||
| e651c24647 | |||
| b601141bcf | |||
| 0c89c13bb2 | |||
| 3d0bc0bfaa | |||
| a8531d44df | |||
| 7557c9aafe |
@@ -0,0 +1,154 @@
|
||||
---
|
||||
context: default
|
||||
phase: null
|
||||
task: null
|
||||
total_tasks: null
|
||||
status: paused
|
||||
last_updated: 2026-09-22T14:15:00.000Z
|
||||
---
|
||||
|
||||
<current_state>
|
||||
Kein laufender Meilenstein. Alle 18 Phasen sind abgeschlossen; seit der Freigabe
|
||||
1.2.0 laeuft die Arbeit als Quick-Tasks. **Version 1.3.0 wurde am 22.09.2026
|
||||
freigegeben** (Tag `v1.3.0` auf `d146234`, Abbilder `live` und `v1.3.0`,
|
||||
Gitea-Release mit `Tessera-Setup-1.3.0.exe` und `Tessera-1.3.0.AppImage`).
|
||||
|
||||
`main == origin/main` auf `1315f37`, Arbeitsbaum sauber, CI gruen. Der lokale
|
||||
Docker-Stack laeuft mit genau diesem Stand (web, api, db).
|
||||
|
||||
Unterbrochen wurde NICHT mitten in einer Aufgabe — alle acht Auftraege dieser
|
||||
Sitzung sind fertig, nachgewiesen und gepusht. Der naechste Auftrag (Proxmox)
|
||||
ist inhaltlich geklaert, wartet aber auf Zugangsdaten des Nutzers.
|
||||
</current_state>
|
||||
|
||||
<completed_work>
|
||||
|
||||
Diese Sitzung (21.09. abends bis 22.09. nachmittags):
|
||||
|
||||
- quick-260921-pi9 — Dashboard-Widget „Bilderrahmen" (Upload oder https-Adresse, Diashow, Grossansicht)
|
||||
- quick-260921-qd3 — Dashboard-Widget „XFrame" (Webseite als Rahmen, Sandbox ohne Top-Navigation)
|
||||
- quick-260922-frg — Tray-Update-Eintrag nennt den Grund einer fehlgeschlagenen Pruefung, Klick prueft erneut, Pruefung alle 4 h
|
||||
- fast `747a4d4` — Download-Knoepfe im Desktop-Client oeffnen den System-Browser (waren ohne Funktion)
|
||||
- quick-260922-ge2 — XFrame: Ausschnitt waehlen und einpassen, Zoom, „Nur anzeigen"
|
||||
- **Freigabe 1.3.0** — CHANGELOG abgeschlossen, `live` vorgezogen, Tag gepusht, drei CI-Laeufe gruen
|
||||
- quick-260922-hk4 — Bilderrahmen-Bilder in den Dateibereich `user-files` statt in die Datenbank, automatischer Umzug beim Start, Selbstheilung aus der alten Spalte
|
||||
- quick-260922-m1h — Widget-Typen an EINER Stelle, Katalog aus der Registry + Modulfilter, Kachel kennt ihr Modul
|
||||
|
||||
Jeder Punkt wurde im Browser (Playwright-MCP) gegen den lokalen Stack geprueft;
|
||||
die Pruefprotokolle stehen in den jeweiligen SUMMARY.md unter `.planning/quick/`.
|
||||
</completed_work>
|
||||
|
||||
<remaining_work>
|
||||
|
||||
1. **Proxmox-Modul** (PVE, PBS, PMG) — **nur beobachten**, keine Eingriffe.
|
||||
Seite: Server anbinden, VMs/Container mit CPU, Arbeitsspeicher, Plattenplatz
|
||||
und Erreichbarkeit; bei PBS Sicherungslaeufe und Pruefstatus, bei PMG die
|
||||
Mail-Zahlen (zugestellt, gefiltert, blockiert, Quarantaene).
|
||||
2. **Proxmox-Kachel** — kompakte Fassung ueber den neuen Weg (drei Stellen,
|
||||
siehe unten). Vorschlag fuer den Inhalt steht in der Sitzung: Ampel je Server
|
||||
+ drei Balken; PBS: Alter der letzten Sicherung, Pruefergebnis, freier Platz;
|
||||
PMG: Tageszahlen in einer Zeile. Idee fuer spaeter: eine Sammelkachel
|
||||
„Alles in Ordnung?" mit einer Zeile je Server.
|
||||
</remaining_work>
|
||||
|
||||
<decisions_made>
|
||||
|
||||
- **Bilder auf die Festplatte, nicht in die Datenbank** — Grund ist die Sicherung
|
||||
(`pg_dump` von Hand; 30 Bilder à 5 MiB je Benutzer waeren 150 MB pro Benutzer
|
||||
im Abzug), nicht die Geschwindigkeit, und die Einheitlichkeit mit Avataren
|
||||
(`user-files/avatars`) und DKV-Exporten.
|
||||
- **Zweistufige Umstellung:** Spalte `data` bleibt vorerst stehen; `getBytes`
|
||||
stellt eine fehlende Datei daraus wieder her. DROP erst, wenn alpha UND live
|
||||
einmal mit dieser Version gelaufen sind — Todo liegt unter
|
||||
`.planning/todos/pending/2026-09-22-dashboard-image-data-spalte-entfernen.md`.
|
||||
- **Proxmox: nur beobachten**, Zugriff ueber die normale Modulfreigabe
|
||||
(Nutzeransage 22.09.).
|
||||
- **Kacheln gesperrter Module erscheinen gar nicht erst im Katalog**
|
||||
(Nutzeransage 22.09.) — so umgesetzt in m1h.
|
||||
- **Basic-Auth am Proxy vor alpha bleibt** (Nutzerentscheidung). Aus dem
|
||||
Firmennetz greift eine Ausnahme; von aussen 401, und der Client sagt das seit
|
||||
frg selbst. **Nicht erneut vorschlagen, das Thema ist entschieden.**
|
||||
</decisions_made>
|
||||
|
||||
<blockers>
|
||||
- Proxmox braucht Zugangsdaten und Serveradressen des Nutzers (API-Token, nur
|
||||
lesend, z. B. Rolle `PVEAuditor`). Der Nutzer legt sie morgen an. Planung und
|
||||
Modulskelett koennen vorher entstehen, die Anbindung nicht getestet werden.
|
||||
</blockers>
|
||||
|
||||
## Required Reading (in order)
|
||||
|
||||
1. `.planning/quick/260922-m1h-dashboard-widgets-ein-modul-bringt-seine/260922-m1h-SUMMARY.md` — Abschnitt „So fuegt man kuenftig eine Modul-Kachel hinzu": drei Stellen statt sieben.
|
||||
2. `docs/anleitung-entwicklung.md` — Modul-Walkthrough (Backend + Frontend) und der neue Abschnitt „Eine Kachel zum Modul".
|
||||
3. `.planning/quick/260922-hk4-bilderrahmen-bilder-auf-die-festplatte/260922-hk4-SUMMARY.md` — Dateiablage, wenn das Proxmox-Modul jemals Dateien speichert.
|
||||
4. `.planning/STATE.md` — Abschnitte „Current Position" und die letzten Zeilen der Quick-Tabelle.
|
||||
|
||||
## Critical Anti-Patterns (do NOT repeat these)
|
||||
|
||||
- [ANTI-PATTERN]: Planannahmen ueber den Bestand ungeprueft uebernehmen → m1h: der Plan
|
||||
behauptete, `apps/web` importiere `@tessera/shared` bereits; es gab KEINE
|
||||
Abhaengigkeit, und zwei Kommentare hielten das als Absicht fest. Der Executor
|
||||
hat vor der ersten Zeile Code nachgemessen (Bau, Produktions-Abbild, natives
|
||||
Type-Stripping unter `node:24-alpine`) statt der Annahme zu folgen.
|
||||
**Mitigation:** jede Plan-Behauptung ueber vorhandene Abhaengigkeiten oder
|
||||
Muster vor dem Umsetzen einmal am Code pruefen.
|
||||
- [ANTI-PATTERN]: Vorschau und Darstellung mit unterschiedlichen Layoutmassen →
|
||||
ge2: die Kachel nutzte eine andere Rahmenhoehe als die Vorschau, wodurch Seiten
|
||||
mit fensterhoehen-abhaengigem Layout (`vh`) an anderer Stelle lagen als
|
||||
ausgewaehlt. **Mitigation:** Auswahl und Darstellung immer gegen dieselben
|
||||
Masse rechnen.
|
||||
- [ANTI-PATTERN]: `position: fixed` in einer Dashboard-Kachel → pi9: die
|
||||
Grossansicht blieb auf die Kachelflaeche beschraenkt, weil `react-grid-item`
|
||||
eine CSS-`transform` traegt und damit zum Bezugsrahmen wird.
|
||||
**Mitigation:** Overlays aus einer Kachel per `createPortal` in `document.body`
|
||||
rendern (Muster: Kalender-Tooltip, jetzt auch `picture-frame-lightbox.tsx`).
|
||||
- [ANTI-PATTERN]: Playwright klickt in einem per `transform` skalierten iframe
|
||||
nicht → ge2. **Mitigation:** Klickpunkt umrechnen und per
|
||||
`elementFromPoint` + `mouse.click` pruefen; ist eine Werkzeuggrenze, kein Produktfehler.
|
||||
|
||||
## Infrastructure State
|
||||
|
||||
- **Lokaler Stack:** `docker compose` mit web, api, db laeuft auf dem Stand
|
||||
`1315f37` (`up -d --build` am 22.09. nachmittags). `up` allein baut NICHT neu.
|
||||
DB ohne Host-Port — Prisma vom Host ueber die Container-IP
|
||||
(`docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1`,
|
||||
`tessera:tessera_dev`).
|
||||
- **Testdaten lokal:** im Admin-Dashboard stehen ein Bilderrahmen (zwei Bilder)
|
||||
und ein XFrame auf `example.com` mit Ausschnitt. Harmlos, darf bleiben.
|
||||
- **alpha** (`alpha.tessera.ctl.de`) wurde am 22.09. auf `80a0d23` gezogen; die
|
||||
spaeteren Commits (hk4, m1h) sind dort noch nicht drauf. Basic-Auth am Proxy
|
||||
bleibt — von aussen 401, deshalb Messungen gegen alpha nur am Proxy vorbei
|
||||
(auf dem Testserver `docker compose exec api` gegen `localhost:3001` oder
|
||||
Host-Port 3000).
|
||||
- **Live** (`tessera.ctl.de`) laeuft noch auf 1.2.0; der Pull auf 1.3.0 steht beim
|
||||
Nutzer aus.
|
||||
- **Desktop-Client:** neuester CI-Stempel `5aa577a` (Lauf 405). Der Nutzer muss
|
||||
ihn einmal per Browser installieren, danach laeuft das Update ueber das Tray.
|
||||
|
||||
<context>
|
||||
Die Sitzung war eine lange Kette kleiner, vollstaendig abgeschlossener Auftraege.
|
||||
Der rote Faden am Ende: Der Nutzer will als naechstes ein Proxmox-Modul, das
|
||||
zusaetzlich als Kachel auf dem Dashboard erscheint — und kuenftig sollen weitere
|
||||
Module dasselbe tun. Deshalb wurde zuerst das Fundament geraeumt (m1h), damit
|
||||
eine Modul-Kachel drei Handgriffe kostet statt sieben und Kacheln gesperrter
|
||||
Module automatisch verschwinden. Das Geruest dafuer (`WIDGET_MODULE_SLUGS`,
|
||||
serverseitiger Filter in `dashboard.service.ts`) ist vorhanden und noch leer;
|
||||
Proxmox waere der erste Eintrag.
|
||||
|
||||
Fuer Proxmox selbst ist vorgemerkt: Serveradressen traegt nur ein Administrator
|
||||
ein (damit ist die Adresse eine bewusste Freigabe statt beliebiger Eingabe), fuer
|
||||
genau diese Adressen werden Zertifikatsfehler toleriert (Muster:
|
||||
`favorites/icon-discovery.service.ts`, undici-Dispatcher — Nodes globales `fetch`
|
||||
ignoriert ihn), Zugangsdaten verschluesselt per `CryptoService` (Muster
|
||||
`ldap-config.service.ts`), Abfrage im Hintergrund je Mandant nach dem Muster
|
||||
`dkv-scheduler.service.ts` (`onApplicationBootstrap`, nicht `onModuleInit`).
|
||||
</context>
|
||||
|
||||
<next_action>
|
||||
Start with: Proxmox-Modul planen (`/gsd-quick` mit eigenem Plan wie bei ge2/hk4) —
|
||||
Datenmodell fuer Serverzugaenge (Adresse, Typ `pve|pbs|pmg`, verschluesselter
|
||||
Token), Abfrage im Hintergrund je Mandant, Modulskelett nach
|
||||
`docs/anleitung-entwicklung.md`, Seite mit Serverliste und Auslastung. Die
|
||||
Kachel kommt danach als eigener kleiner Auftrag ueber den neuen Weg. Vorher beim
|
||||
Nutzer abholen: Serveradressen und die lesenden API-Token.
|
||||
</next_action>
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"timestamp": "2026-09-22T14:15:00.000Z",
|
||||
"phase": null,
|
||||
"phase_name": "kein laufender Meilenstein — Quick-Tasks nach der Freigabe 1.3.0",
|
||||
"phase_dir": null,
|
||||
"plan": null,
|
||||
"task": null,
|
||||
"total_tasks": null,
|
||||
"status": "paused",
|
||||
"completed_tasks": [
|
||||
{"id": 1, "name": "quick-260921-pi9 Bilderrahmen-Widget", "status": "done", "commit": "737974b,c080580,c3b4597,8bf3601"},
|
||||
{"id": 2, "name": "quick-260921-qd3 XFrame-Widget", "status": "done", "commit": "d63d9f5,20a9eb2"},
|
||||
{"id": 3, "name": "quick-260922-frg Tray-Update nennt den Grund, prueft alle 4 h", "status": "done", "commit": "d73aad1"},
|
||||
{"id": 4, "name": "fast Download-Knoepfe im Desktop-Client", "status": "done", "commit": "747a4d4"},
|
||||
{"id": 5, "name": "quick-260922-ge2 XFrame-Ausschnitt, Zoom, Nur anzeigen", "status": "done", "commit": "445b1d3,30fdd99,cf70a19"},
|
||||
{"id": 6, "name": "Freigabe 1.3.0", "status": "done", "commit": "d146234 (Tag v1.3.0)"},
|
||||
{"id": 7, "name": "quick-260922-hk4 Bilder in den Dateibereich statt in die Datenbank", "status": "done", "commit": "9039cea,8cbfb8b,82472ee"},
|
||||
{"id": 8, "name": "quick-260922-m1h Modul bringt seine Kachel selbst mit", "status": "done", "commit": "56c07c3,8be0725"}
|
||||
],
|
||||
"remaining_tasks": [
|
||||
{"id": 9, "name": "Proxmox-Modul (PVE/PBS/PMG), nur beobachten — Plan schreiben und bauen", "status": "not_started"},
|
||||
{"id": 10, "name": "Proxmox-Kachel (kompakte Version) ueber den neuen Weg", "status": "not_started"}
|
||||
],
|
||||
"blockers": [
|
||||
{"description": "Proxmox braucht Zugangsdaten des Nutzers (API-Token, nur lesend) und die Server-Adressen", "type": "human_action", "workaround": "Nutzer legt sie morgen an; Planung und Geruest koennen vorher entstehen"}
|
||||
],
|
||||
"async_jobs": [],
|
||||
"human_actions_pending": [
|
||||
{"action": "Live-Server auf 1.3.0 ziehen (docker compose -f docker-compose.prod.yml pull && up -d --force-recreate api web)", "context": "Version 1.3.0 ist freigegeben, Abbilder live/v1.3.0 liegen bereit", "blocking": false},
|
||||
{"action": "Neuen Desktop-Client einmal per Browser installieren", "context": "Danach laeuft das Update ueber das Tray-Symbol; Live hatte den Updater bis 1.2.0 nicht", "blocking": false},
|
||||
{"action": "Proxmox-API-Token (nur lesend, z. B. Rolle PVEAuditor) fuer PVE, PBS und PMG anlegen und die Server-Adressen bereithalten", "context": "Voraussetzung fuer das naechste Modul; der Nutzer macht das morgen", "blocking": true}
|
||||
],
|
||||
"decisions": [
|
||||
{"decision": "Bilderrahmen-Bilder liegen im Dateibereich user-files, nicht als bytea in der Datenbank", "rationale": "Sicherung laeuft per pg_dump von Hand; 30 Bilder a 5 MiB je Benutzer waeren 150 MB pro Benutzer im Abzug. Geschwindigkeit war NICHT das Argument. Dazu Einheitlichkeit mit Avataren und DKV-Exporten.", "phase": null},
|
||||
{"decision": "Spalte data bleibt vorerst stehen, DROP erst nach nachgewiesenem Lauf auf alpha und live", "rationale": "Zweistufige Umstellung; getBytes stellt eine fehlende Datei aus data wieder her (alter pg_dump + leeres Volume).", "phase": null},
|
||||
{"decision": "Widget-Typen an EINER Stelle (packages/shared), Katalog filtert nach Modulzugriff", "rationale": "Vorarbeit fuer Proxmox und weitere Modul-Kacheln; vorher sieben Hartkodierungen je Kachel.", "phase": null},
|
||||
{"decision": "Proxmox zunaechst NUR beobachten, keine Eingriffe; Zugriff ueber die normale Modulfreigabe", "rationale": "Ausdrueckliche Ansage des Nutzers am 22.09.", "phase": null},
|
||||
{"decision": "Kacheln gesperrter Module erscheinen gar nicht erst im Katalog", "rationale": "Ausdrueckliche Ansage des Nutzers am 22.09.", "phase": null},
|
||||
{"decision": "Basic-Auth am Proxy vor alpha bleibt", "rationale": "Entscheidung des Nutzers; aus dem Firmennetz greift eine Ausnahme. Nicht mehr ansprechen.", "phase": null}
|
||||
],
|
||||
"uncommitted_files": [],
|
||||
"next_action": "Proxmox-Modul planen: Prisma-Modelle fuer Serverzugaenge (Adresse, Typ pve|pbs|pmg, Token verschluesselt per CryptoService), Poller je Mandant nach dem Muster dkv-scheduler (onApplicationBootstrap), Modul-Skelett nach docs/anleitung-entwicklung.md, danach die Kachel ueber den neuen Weg (WIDGET_TYPES + WIDGET_MODULE_SLUGS + registerWidget).",
|
||||
"context_notes": "Sitzung lief ueber 1,5 Tage: zwei neue Widgets, drei Desktop-Korrekturen, Freigabe 1.3.0, Speicherumstellung, Widget-Aufraeumen. Alles gepusht, main == origin/main auf 1315f37, Arbeitsbaum sauber, CI gruen. Der lokale Docker-Stack laeuft mit dem aktuellen Stand; Testfavoriten/Test-Widgets im Admin-Dashboard sind lokal stehen geblieben (harmlos). Naechster Schritt ist inhaltlich klar, haengt aber an den Zugangsdaten des Nutzers."
|
||||
}
|
||||
+23
-10
@@ -1,13 +1,13 @@
|
||||
---
|
||||
gsd_state_version: "1.0"
|
||||
milestone: v1.2
|
||||
milestone: v1.3
|
||||
current_phase: 18
|
||||
current_phase_name: desktop-client-fertigstellen
|
||||
status: verified
|
||||
stopped_at: "Sieben Quick-Vorgaenge am 2026-09-21 abgeschlossen und verifiziert (9ie, a1d, bi2, fi3, gof, i8x, iwr). Der fehlerverdaechtige Lint-Rueckstand ist damit vollstaendig geprueft: zwei echte Fehler gefunden und behoben (tote Passwortwechsel-Sperre an der API, stille 403-Antworten in der Benutzerverwaltung), der Rest war harmlos oder Absicht, je Stelle in den Akten begruendet. Offen und NICHT fehlerverdaechtig: 288 any im Quellcode, 30 a11y-Befunde mit Gestaltungsbedarf. Naechster Auftrag des Nutzers: zwei neue Dashboard-Widgets."
|
||||
last_updated: "2026-09-21T12:45:00.000Z"
|
||||
last_activity: 2026-09-21
|
||||
last_activity_desc: Quick 260921-9ie, a1d, bi2, fi3 und gof — Lint-Tor scharf, Benutzerverwaltung meldet abgewiesene Aktionen, Lint-Rueckstand 2923 → 446, erzwungener Passwortwechsel an der API durchgesetzt (war eine tote Sperre), 21 Effekt-Abhaengigkeiten einzeln beurteilt; alle fuenf verifiziert, die letzten drei am laufenden System
|
||||
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-22T21:10:00.000Z"
|
||||
last_activity: 2026-09-22
|
||||
last_activity_desc: Quick 260922-vdk — Dashboard-Raster misst seine Breite auch aus dem Leerzustand; im echten Linux-Client gegengemessen (469 -> 389 px, Ziehen erreicht den rechten Rand)
|
||||
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-21 - Quick 260921-iwr: 30 Listenschluessel- und Zusicherungs-Stellen geprueft, kein echter Fehler; beide Verdachtsmomente (LDAP-Seite, Cert-Manager) durch Messung widerlegt. Der fehlerverdaechtige Rueckstand ist damit abgearbeitet
|
||||
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
|
||||
|
||||
Progress: [██████████] 99%
|
||||
|
||||
@@ -451,6 +451,19 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
|
||||
| 260921-gof | **21 React-Effekt-Abhaengigkeiten einzeln beurteilt — 15 davon waren Fallen, nicht Fehler.** Die Klasse war aus 260921-bi2 zurueckgestellt worden, weil jeder Befund einzeln zu beurteilen ist. Ergebnis: nur **2 echte Defekte** (A), **15 Fallen** (B, das naive Eintragen haette eine Abruf-Schleife erzeugt), **3 Absicht** (C, mit begruendetem `biome-ignore` — erste Verwendung im Projekt), **1 Ballast** (D). **Die gefaehrlichste Stelle:** `calendar-widget.tsx:82` — `showToday` setzt bei jedem Klick ein frisches `Date`; `monthDate` naiv in die Liste einzutragen haette **jeden** Druck auf den Monatstitel einen Termin-Abruf ausloesen lassen, ueber die API bis zum Exchange-Server. Reihenfolge war Pflicht: erst Identitaet stabilisieren, dann die Liste umstellen. **Die haeufigste Falle:** `t` aus `useTranslations` ist in diesem Projekt bei jedem Durchlauf eine frische Funktion (die Test-Attrappen sind nachweislich so gebaut) — 8 Befunde. Griff ohne Ausnahme-Kommentar: den uebersetzten Text vor dem Hook in eine Konstante ziehen und diese eintragen; React vergleicht Zeichenketten per Wert. **Nebenbefund:** 11 `eslint-disable`-Zeilen fuer genau diese Regel waren wirkungslos, seit Biome ESLint abgeloest hat — alle entfernt. **Laufzeitnachweis vom Orchestrator im Browser** (Netzwerkprotokoll, nie `fetch` aus der Seite; gegen neu gebaute Abbilder): Dashboard 62 s Ruhe → Protokoll byte-identisch, genau 1 `calendar/events`; Monatstitel 3x gedrueckt → nur der erste Druck (Bereich aendert sich wirklich) loest einen Abruf aus, Druck 2 und 3 **null**; "Weiter" 3x → 3 Abrufe, korrekt; Stoppuhr 6 s real → Anzeige 00:06, 4 Runden ueber 4,8 s → 16/17/19/20 monoton, kein Ruecksprung. Dazu acht weitere Ansichten je 20-25 s ruhen gelassen (Marktplatz, Modulverwaltung, Gruppenverwaltung, DKV dreimal, Ausschreibungsradar zweimal) — jeder Endpunkt genau einmal. `InvoiceHistoryTable` hatte als einzige Datei keinen Test und ist damit gemessen statt nur gelesen; `ResultsList` ist die Stelle, an der die `t`-Falle in bi2 tatsaechlich zuschnappte. **Zahlen:** Warnungen 467 → 446 (exakt 21, nichts anderswo gewachsen), `useExhaustiveDependencies` 0, web-Tests 66/462 → 67/477, api 71/1136 unveraendert, type-check 4/4, `pnpm lint` 5/5 ohne Fehlerrang. Verifikation passed. **Benannt, nicht behoben:** zwei Verschwendungen im Kalender-Abruffenster (gleicher Zeitbereich zweimal geholt; `calendar/sources` bei jedem Monatswechsel) — vorbestehend; und `t` in vier vorbestehenden Abhaengigkeitslisten ausserhalb des Auftrags, die Biome nie gemeldet hat. | 2026-09-21 | b3f0e3c,e2c508c,e780b2c | [260921-gof-effekt-abhaengigkeiten-in-react-21-befun](./quick/260921-gof-effekt-abhaengigkeiten-in-react-21-befun/) |
|
||||
| 260921-i8x | **Fuenf fehlerverdaechtige Lint-Klassen geprueft — kein einziger echter Fehler darunter.** Zwoelf Stellen einzeln beurteilt, Ergebnis: 7x gleichwertig oder Absicht, 2x Haertung, 3x idiomatisch korrekt. Das ist das Ergebnis, keine Ausrede — die Klassen klangen gefaehrlicher als sie waren. **Die eine Stelle mit echtem Wert:** `apps/web/src/lib/safe-next.ts`, der Schutz gegen Weiterleitung auf fremde Seiten nach der Anmeldung. Der Kommentar dort behauptete, die Steuerzeichen stuenden als Unicode-Escapes im Muster; die rohen Bytes zeigten das Gegenteil (NUL, 0x1F, 0x7F direkt eingebettet). Funktionierte, war aber zerbrechlich: verschluckt ein Werkzeug das NUL-Byte, wird aus dem Bereich stillschweigend ein anderer und der Schutz loechrig — der Rueckgabewert landet in `login/page.tsx` direkt in `window.location.href`. Jetzt echte Escapes, Datei ohne ein einziges Steuerbyte. **Beweis der Gleichwertigkeit, nicht Behauptung:** ueber alle 65536 Codepunkte dieselbe Menge abgelehnter Zeichen — 54 Stueck (32 Steuerzeichen 0x00-0x1F, dazu 0x7F, Backslash und die 20 Leerraum-Zeichen von `\s`), null Abweichung; vom Orchestrator unabhaengig gegen ein selbst gebautes Referenzmuster nachgerechnet. **Bewusst nicht angefasst:** die NUL-Maskierung in `ldap.service.ts` (RFC 4515) — genau dieses Zeichen zu treffen ist ihr Zweck, wer sie "repariert", oeffnet LDAP-Filter-Injection. Ebenso die drei `while ((m = re.exec(s)))`-Schleifen (idiomatisch, kein verrutschtes Gleichheitszeichen) und `noUselessSwitchCase` aus bi2. **Zwei Korrekturen an frueheren Annahmen:** `sanitizeNextPath` laeuft NICHT in der Edge-Middleware (die importiert nur `buildNextParam`), und das blosse Umschreiben auf Escapes senkt die Warnzahl nicht — Biome beanstandet die Escape-Schreibweise genauso, es braucht zusaetzlich einen einzeiligen Unterdrueckungskommentar. **Werkzeugfalle, dreimal zugeschnappt:** das Schreibwerkzeug wandelt `\uXXXX` still in das echte Zeichen um — der Planer erzeugte so zehn rohe Steuerbytes in seiner ersten Planfassung, der Executor zweimal in Commit-Text und Akte (git verweigerte den Commit wegen eines NUL-Bytes), und der Orchestrator beim Nachrechnen. Umgehung ueber `python3`/`chr(92)` ist im Plan hinterlegt. **Zahlen:** 446 → 434, `noControlCharactersInRegex`/`useIterableCallbackReturn`/`noGlobalIsNan` je 0, `suppressions/unused` 0, web-Tests 67/477 → 68/481, api 71/1136 → 72/1137, type-check 4/4, lint 5/5. | 2026-09-21 | f85c91b,076ca4b,b92dd5d | [260921-i8x-fehlerverdaechtige-lint-klassen-steuerze](./quick/260921-i8x-fehlerverdaechtige-lint-klassen-steuerze/) |
|
||||
| 260921-iwr | **Listenschluessel und Ausrufezeichen-Zusicherungen: 30 Stellen geprueft, wieder kein echter Fehler.** Damit ist der fehlerverdaechtige Rueckstand abgearbeitet. **Zwei Vorannahmen des Orchestrators widerlegt, beide durch Messung statt Argument:** (1) Die LDAP-Seite galt als heisser Kandidat, weil dort Zuordnungsregeln hinzugefuegt und geloescht werden — die Liste, die tatsaechlich waechst und schrumpft (`config.fieldMappings`), benutzt jedoch laengst `key={mapping.id}`; die sechs Meldungen betreffen zustandslose Textlisten. (2) Im Cert-Manager galt eine Zusicherung auf hochgeladenen Dateiinhalt als moeglicher Absturz — der Planer hat eine 83-Byte-Schrottdatei gebaut, die node-forge `bag.cert = null` setzen laesst, und gegen den echten Dienst laufen lassen: **alle vier Pfade enden mit 400, nie 500**, und `certificateToPem(null)` wirft nachweislich, statt still ein falsches Zertifikat zu bauen. Also weder Verfuegbarkeits- noch Integritaetsluecke, sondern eine irrefuehrende Fehlermeldung. **Ein Fund dreht die Richtung um:** bei `admin/modules/grants/page.tsx:246` waere die Korrektur schaedlich — die Gruppierung fasst nur aufeinanderfolgende Kategorien zusammen, die Positionsnummer ist dort fuer die Eindeutigkeit noetig, ohne sie entstuenden doppelte Schluessel. **Geaendert: 5 Stellen** (drei Waechter im Cert-Manager, die den Meldungstext praezisieren — Status bleibt 400, rot-dann-gruen belegt; zwei ueberfluessige Zusicherungen in `imap.provider.ts`, die imapflow ohnehin als Pflichtfeld typisiert). **25 Stellen bleiben bewusst stehen und bleiben in der Zaehlung sichtbar** — mit Begruendung je Stelle in der Akte, damit der naechste Durchgang sie nicht erneut aufrollt; kein Unterdrueckungskommentar, um die Zahl zu schoenen. **Das Tor hat sich selbst bewaehrt:** der erste Entwurf eines Waechters erzeugte einen neuen Lint-Fund (430 statt 429) und wurde von der Verifikation des Plans gefangen; die Reparatur brach `tsc`, weil `@types/node-forge` `Bag.cert` als `Certificate | undefined` deklariert, waehrend die Bibliothek zur Laufzeit `null` zuweist — Endfassung prueft beides. **Zahlen:** 434 → 429, `noArrayIndexKey` unveraendert 19 (alle geprueft, alle harmlos), `noNonNullAssertion` 11 → 6, web-Tests 68/481 → 69/484, api 72/1137 → 72/1143, type-check 4/4, lint 5/5. | 2026-09-21 | 8716fa5,b4aaed4,27909e4,de69863 | [260921-iwr-listenschluessel-per-positionsnummer-und](./quick/260921-iwr-listenschluessel-per-positionsnummer-und/) |
|
||||
| 260921-jt4 | **Barrierefreiheit von 30 auf 1 Befund, plus die vier zurueckgestellten Restposten.** Die 30 a11y-Befunde galten seit bi2 als "braucht Bedienentscheidungen"; die hat der Orchestrator getroffen, und der Planer hat **zwei davon widerlegt**: (1) Der vorgesehene Rueckfallweg (`role` + `tabIndex` + Tastaturhandler, wo kein echter Knopf geht) tauscht gemessen drei Befunde gegen einen neuen `useSemanticElements` — eine Regel, die bi2 gerade erst auf 0 gebracht hatte; wird nirgends benutzt, fuer den Verschachtelungsfall (Marktplatz-Karte) tritt eine deckende Geschwister-Schaltflaeche an seine Stelle. (2) **Vier der elf "Klick"-Befunde sind gar keine Klicks**, sondern `onError`-Handler an `<img>` — da gibt es keinen Tastaturweg zu schaffen, sie bekommen `aria-hidden`. **Ein Fund darueber hinaus:** alle fuenf ARIA-Befunde sind `aria-label` auf rollenlosen Elementen — die werden von Vorleseprogrammen still verworfen, die Beschriftungen kamen also bei niemandem an; jetzt mit korrekter Rolle. Sechs Stellen wurden zu echten `<button>` (Aussehen unveraendert), vier `autoFocus` auf Seiten entfernt (auf Seiten reisst er beim Laden den Fokus an sich — im Dialog waere er richtig gewesen, alle vier waren Seiten). **Ein Befund bleibt bewusst stehen und bleibt gezaehlt** (`calculator-widget.tsx:323`), samt ausdruecklich verworfener Umgehung. **Restposten:** ZIP-Name uebersetzt mit getesteter Schutzfunktion `zip-filename.ts` (der frueher genannte Umlaut-Einwand trifft fuer "Zertifikate.zip" nicht zu, die Schutzfunktion sichert kuenftige Uebersetzungen ab); die ueberfluessige `case`-Marke im Normalisierer aufgeloest, Absicht in den Kommentar gewandert; Kalender-Verschwendung abgestellt. **Zur `t`-Frage eine Korrektur an gof:** `use-intl` 4.13 erzeugt `t` in einem `useMemo`, es ist also in der Bibliothek stabil — instabil ist es nur in den Test-Attrappen, und daher kam der Beleg von damals. Die acht Korrekturen aus gof bleiben richtig und schaedlich sind sie nicht, aber die Begruendung war zu breit; ein Test an der Wurzel misst es jetzt. **Laufzeitnachweis vom Orchestrator** (Browser, 90 Tage Vorschau — bei der Voreinstellung 30 tritt der Doppelabruf gar nicht auf, die Messung haette also nichts gezeigt): drei Monatswechsel holen `calendar/sources` nur noch **1x statt 4x**, und der Termin-Abruf mit identischem Zeitraum ist weg (3 Klicks → 2 Abrufe statt 3). Der 5-Minuten-Auffrischer bleibt unangetastet — belegt nicht durch Warten im Browser (zwei Messversuche waren ungueltig, weil das Werkzeug die Seite zwischendurch neu laedt: nach 330 s Wartezeit war das Dokument 37 s alt), sondern durch Test 19 mit gestellter Uhr: nach `advanceTimersByTime(300_000)` werden **beide** Abrufe erneut ausgefuehrt. **Zahlen:** 429 → 399, a11y 30 → 1, web-Tests 69/484 → 73/529, api 72/1143 unveraendert, type-check 4/4, lint 5/5, keine neuen Unterdrueckungen. | 2026-09-21 | a8531d4,3d0bc0b,0c89c13,b601141,e651c24,+7 | [260921-jt4-barrierefreiheit-mit-bedienentscheidunge](./quick/260921-jt4-barrierefreiheit-mit-bedienentscheidunge/) |
|
||||
| 260921-ldf | **Der Wackeltest war ein echter Produktfehler — nachgewiesen, nicht vermutet.** CI-Lauf 395 war rot; durchgefallen war ein Test aus quick-260914-m97, rund einmal in 17 vollen Laeufen, isoliert nie. Symptom: Vorschaubild da, Haekchen "Bildschirmfoto anhaengen" aus. **Ursache:** der Fehler-melden-Dialog war dauerhaft eingehaengt, sein `useState(screenshot !== null)` lief damit genau einmal — beim allerersten Laden der Seite, als noch kein Bild existierte — und der richtige Wert wurde erst von einem `useEffect` nachgezogen, der bauartbedingt nach dem Commit laeuft. **Beleg, deterministisch statt statistisch:** ein MutationObserver ueber jeden einzelnen DOM-Commit zeigt gegen den alten Stand, ohne jede kuenstliche Verzoegerung: `COMMIT dialog=true img=ja box=AUS` gefolgt von `COMMIT dialog=true img=ja box=AN`. Der falsche Zustand entsteht bei JEDEM Oeffnen, nicht nur unter Last, und haelt zwei Makrotask-Runden — dazwischen darf der Browser zeichnen, ein Nutzer kann es also sehen. **Ehrliche Einordnung der Tragweite:** die Korrektur kommt binnen Millisekunden, lange bevor jemand "Senden" treffen kann. Der befuerchtete Fall (Bild gesehen, abgeschickt, Bild fehlt) ist NICHT erreichbar; es bleibt ein kurzes Flackern. Repariert wurde trotzdem der Produktcode, nicht der Test — wer einen wirklich vorhandenen falschen Zustand im Test wegberuhigt, laesst ihn stehen. **Zwei Teilursachen, einzeln reicht keine:** der Dialog wird nur noch eingehaengt, solange er offen ist (frischer Mount je Oeffnen, der zuruecksetzende Effekt entfaellt), und das Haekchen wird beim Rendern abgeleitet statt nachgezogen. Dieselbe Ursache lag an einer zweiten Stelle: nach einem Versand stand beim erneuten Oeffnen zwei Runden lang der alte Danke-Bildschirm im DOM. **Zur Statistik, weil es der Kern der Sache ist:** 20 volle Laeufe ohne Fehlschlag gelten ausdruecklich NICHT als Beweis — bei der Ausgangsrate 1:17 waeren sie auch ohne Reparatur zu rund 30 Prozent zu erwarten. Tragend ist, dass der falsche Zwischenzustand nicht mehr existiert und die neuen Tests gegen den alten Stand 5 von 5 rot sind. Kein `retry`, kein hoeheres Zeitlimit — die Ursache war nie blosse Zeit. **Zwei Konstruktionsfehler des Tests mitbehoben:** das `expect` innerhalb der Attrappe (wirft es, landet der Fehler mitten im `await` von `captureScreenshot`, dessen `catch` still `null` liefert — der Test waere viel spaeter mit "kein Vorschaubild" durchgefallen, also in die falsche Richtung zeigend) und die per `Object.defineProperty` gesetzte `document.body`-Groesse, die `cleanup()` ueberlebte und alle zwoelf folgenden Tests derselben Datei 3200x1000 sehen liess. **Widerlegt unterwegs:** der Verdacht auf den dynamischen Import von `html-to-image` — er loest auf, bevor ein zuvor gesetzter `setTimeout(0)` feuert, ueberschreitet also keine Makrotask-Grenze. **Zahlen:** Warnungen 399 unveraendert, web-Tests 529 → 531, api 72/1143 unveraendert, type-check 4/4, lint 5/5. | 2026-09-21 | c0ab5b5,de7fdb7,9f02fcc,a6181e2 | [260921-ldf-wackeltest-fehler-melden-haekchen-bildsc](./quick/260921-ldf-wackeltest-fehler-melden-haekchen-bildsc/) |
|
||||
| 260921-m34 | **288 `any` im Backend beurteilt: 15 bleiben, mit Urteil je Stelle.** Drei Durchgaenge. **Der groesste Posten war ein einziges Missverstaendnis:** 105 Stellen trugen `forTenant(...) as any`, obwohl `prisma.$extends()` laengst einen getypten Klienten liefert — die Zusicherung war nie noetig. Entfernen ergab genau EINEN Folgefehler, und der war selbst ein Befund (eine Handannotation, die nur existierte, um unter dem ungetypten Klienten eine Meldung zu umgehen, und falsch geworden war). **Aufgabe 2 war die sicherheitsrelevante:** ein gemeinsamer Typ `AuthUser` fuer die Aufrufer-Identitaet. Die `tenantId`-Frage wurde HERGELEITET, nicht nach Bequemlichkeit entschieden — `string | undefined` erzeugt 8 Fehler, `string` keinen, und das war ausdruecklich kein Argument. Belege: Pflichtspalte in `schema.prisma:38`, Bestandstyp `SessionUser`, und der Super-Admin-Zweig in `TenantGuard`. Der dritte Beleg widerlegt `string` NICHT, weil der Waechter sein Anfrageobjekt ungetypt holt und `AuthUser` gar nicht liest — der Zweig kann also nicht zu totem Code werden. Dass es ihn gibt, steht trotzdem im Typsystem: `AuthenticatedRequest.tenantId` ist `string | null | undefined`, das `null` stammt nur von dort, mit Warnkommentar. `tenant.guard.ts` ueber den ganzen Lauf 0 geaenderte Zeilen (Tor). **Aufgabe 3 ist zugleich das Urteilsregister:** typisiert 252, auf `unknown` umgestellt 21, bleibt 15 — jede der 15 mit Begruendung im Code (6 node-forge, wo die mitgelieferten Typen die Bibliothek nachweislich falsch beschreiben; 3 Cron; 4 `withTenantTransaction`, wo der genaue Typ eine bewusst unvollstaendige Test-Attrappe braeche; 2 imapflow). Null war ausdruecklich NICHT das Ziel. **Vier Befunde gemeldet statt still repariert** — zwei davon brauchen eine Entscheidung des Nutzers: (B-06, sicherheitsrelevant) `imap.provider.ts:402` setzt `requireTLS`, das es in imapflow 1.4.3 NIRGENDS gibt (vom Orchestrator unabhaengig nachgeprueft: kein Treffer im ganzen Paket). Die Option wird still verworfen, die Einstellung "STARTTLS" erzwingt also nichts; die Bibliothek faellt dann auf ihr Standardverhalten zurueck und setzt laut eigener Dokumentation unverschluesselt fort, wenn der Server kein STARTTLS anbietet — sie nennt das selbst eine Downgrade-Angriffsflaeche. Richtig waere `doSTARTTLS: true`. Die `as any`-Zusicherung hatte das verdeckt. (B-05) `imap.provider.ts:78` liest `.parameters` von einer Zeichenkette (imapflow deklariert `disposition: string`, die Parameter liegen in `dispositionParameters`) — zur Laufzeit immer `undefined`, Outlook-Anhaenge als `application/octet-stream` werden ueber Content-Disposition nicht erkannt; betrifft den DKV-Rechnungseinzug. Dazu (B-04) eine Falle im RLS-Erkenner (er zaehlt jede `select:`-Angabe ausserhalb eines Modellaufrufs als Verstoss) — Erkenner NICHT aufgeweicht, Typ anders hergeleitet; und (B-07) httpntlm liefert den Rumpf als Zeichenkette, nicht als Buffer. **Zahlen:** Diagnosen 399 → 125, `any` im Quellcode 288 → 15, `apps/web` 1 → 0, Disziplin-Zaehler unveraendert (`as unknown as` 33, `noNonNullAssertion` 56, Unterdrueckungen 1, `ts-expect-error` 0), api 72/1143, web 73/531, type-check 4/4, lint 5/5, RLS-Waechter 30/30. | 2026-09-21 | b188946,f2fc39f,7c9d7c1,52668c2,32591b6,3892c5f,d8fb9ae | [260921-m34-288-any-im-backend-einzeln-beurteilen-un](./quick/260921-m34-288-any-im-backend-einzeln-beurteilen-un/) |
|
||||
| 260921-oxm | **IMAP: STARTTLS erzwingt jetzt wirklich, Outlook-Anhaenge werden erkannt.** Die zwei Befunde aus m34, beide mit Entscheidung des Nutzers behoben. **(B-06, Sicherheit)** `imap.provider.ts` setzte `requireTLS` — eine Option, die es in imapflow 1.4.3 NIRGENDS gibt (Orchestrator: kein Treffer im ganzen Paket). Sie wurde still verworfen, die Bibliothek fiel auf ihr Standardverhalten zurueck und setzte laut eigener Dokumentation unverschluesselt fort, wenn der Server kein STARTTLS anbietet — Benutzername und Kennwort gingen dann im Klartext. Ersetzt durch `doSTARTTLS`, nachgeprueft in `imap-flow.d.ts:81` und `imap-flow.js:1183`. Bei implizitem TLS wird ausdruecklich `false` gesetzt, nicht weggelassen: die Bibliothek wirft bei `secure=true` zusammen mit `doSTARTTLS=true`. **Gewollte Verhaltensaenderung:** ein auf STARTTLS eingestelltes Postfach, dessen Server das nicht anbietet, meldet ab jetzt einen Verbindungsfehler statt still im Klartext zu verbinden. **(B-05)** `imap.provider.ts:78` las `.parameters` von einer Zeichenkette — imapflow fuehrt die Parameter in `dispositionParameters` (`imap-flow.d.ts:450`), der Ausdruck war zur Laufzeit immer leer. Anhaenge als `application/octet-stream` (typisch Outlook) wurden ueber die Content-Disposition nicht erkannt; betraf den DKV-Rechnungseinzug. Nachgeprueft: imapflow schreibt die Schluessel klein und setzt RFC-2231-Fortsetzungen selbst zusammen — dafuer war nichts zu tun. **Rot-dann-gruen belegt:** gegen den Stand mit Tests aber ohne Reparatur scheiterten genau 3 von 12 Faellen, danach 12/12. Zwei der fuenf neuen Faelle sind absichtlich von Anfang an gruen — sie sichern ab, dass B-05 nicht zu viel einsammelt. **Die `as any`-Zusicherung konnte ersatzlos entfallen** (sie existierte nur wegen der erfundenen Option); alle sechs uebergebenen Felder sind jetzt deklariert. **Zahlen:** `any` im Backend 15 → 13, `as unknown as` 33 → 27 (Testdoppel-Einhaengung in einen Helfer gezogen statt fuenf neue Umdeutungen), kein Zaehler gestiegen, api-Tests 1143 → 1148, web 73/531, type-check 4/4, lint 5/5. | 2026-09-21 | 7691d1f,d0266bf,6def539 | [260921-oxm-imap-starttls-wirklich-erzwingen-und-anh](./quick/260921-oxm-imap-starttls-wirklich-erzwingen-und-anh/) |
|
||||
| 260921-pi9 | **Dashboard-Widget „Bilderrahmen“: eigene Bilder oder https-Adressen als Diashow.** Erstes der zwei vom Nutzer bestellten Widgets. **API:** neues Prisma-Modell `DashboardImage` (Bytes in der Datenbank — kein neues Docker-Volume, Sicherung ueber den DB-Dump), handgeschriebene Migration `20260921120000_dashboard_image` mit RLS-Regel inklusive Benutzerdimension; Routen `GET/POST /dashboard/images`, `GET/DELETE /dashboard/images/:id`; Bildtyp ausschliesslich ueber Magic Bytes (PNG/JPEG/GIF/WebP), nicht ueber den behaupteten MIME-Typ; 5 MiB je Datei (multer-Grenze, 413), 30 Bilder je Benutzer; fremde Kennung → 404, nie 403; Binaerantwort mit `Cache-Control: private`, `nosniff`, `Content-Disposition: inline` ohne Dateinamen, CSP `default-src 'none'; sandbox`. **Web:** Widget `picture-frame` mit einer geordneten Liste `images` aus Eintraegen mit `kind`-Unterscheider (`upload` oder `url`), Bildausschnitt contain/cover, Intervall 0/5…3600 s, Reihenfolge oder Zufall (nie dasselbe zweimal), Unterschrift-Streifen, Grossansicht per Klick (nicht im Bearbeitungsmodus), kaputte Bilder fallen aus dem Umlauf; Bildverwaltung im `WidgetSettingsPanel` (Upload, https-Adresse, Unterschrift, Pfeile, Entfernen loescht den Upload auch serverseitig). Fremdbilder laedt AUSSCHLIESSLICH der Browser (`<img referrerPolicy="no-referrer">`) — die API ruft nie eine Adresse ab, keine SSRF-Flaeche; https-Pflicht web-seitig zweifach (Formular + Render-Resolver), weil die API Widget-Configs nicht inhaltlich prueft. **Browser-Rundgang (Orchestrator, zehn Punkte) fand drei Dinge, behoben in 8bf3601:** die Grossansicht war auf die Kachelflaeche beschraenkt (ein `react-grid-item` mit CSS-`transform` wird fuer `position: fixed` zum Bezugsrahmen → `createPortal` in `document.body` wie der Kalender-Tooltip), „1 Minuten“ → ICU-Plural, Standardkachel 8x8 zu flach → 8x12. curl-Rundgang gegen die lebende API belegt 201/400/413/404/401 und fremder Benutzer → 404. **Zahlen:** api 1148 → 1175, web 531 → 569, type-check 4/4, lint 5/5, RLS-Waechter 78/78, Zaehler unveraendert (`as unknown as` 27/6, `noNonNullAssertion` 56, `noExplicitAny` 13). Anwenderhandbuch und CHANGELOG ergaenzt. | 2026-09-21 | 737974b,c080580,c3b4597,8bf3601 | [260921-pi9-dashboard-widget-bilderrahmen-bilder-hoc](./quick/260921-pi9-dashboard-widget-bilderrahmen-bilder-hoc/) |
|
||||
| 260921-qd3 | **Dashboard-Widget „XFrame“: eine Webseite per https-Adresse als Rahmen in der Kachel.** Zweites der zwei vom Nutzer bestellten Widgets, vom Nutzer so benannt. Config `url` (https-Pflicht ueber dieselbe `isHttpsUrl`-Regel wie der Bilderrahmen, web-seitig doppelt: Formular + Render-Resolver), `title` (max. 100 Zeichen, Kopfleiste), `reloadSeconds` (0/60/300/600/1800/3600; Timer haengt den Rahmen per `key` neu ein, nicht im Bearbeitungsmodus). `<iframe sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox">` — bewusst OHNE `allow-top-navigation*` (die eingebettete Seite kann den Tessera-Tab nicht umleiten) und OHNE `allow-modals`; `allow=""` (keine Kamera/Mikro/Standort-Delegation), `referrerPolicy="no-referrer"`, `loading="lazy"`. Die API ruft die Adresse nie ab (nur `'xframe'` im `@IsIn` des DTO; kein CSP/Frame-Header in apps/web noetig, per grep belegt). Im Bearbeitungsmodus liegt eine unsichtbare Flaeche ueber dem Rahmen, sonst schluckt der iframe die Zeigerereignisse und die Kachel liesse sich nicht ziehen. Ob eine Seite das Einbetten verweigert, entscheidet die fremde Seite (`X-Frame-Options`/`frame-ancestors`, cross-origin nicht erkennbar) — deshalb dauerhafter Hinweis im Formular und immer ein Link „In neuem Tab öffnen“ (`rel="noopener noreferrer"`, in der Kopfleiste oder als Ecksymbol). Formular als eigenes Modul `xframe-config-form.tsx` wie beim Bilderrahmen; Kachel-Vorgabe 12x12. Browser-Rundgang (Orchestrator, neun Punkte + Tests) ohne Befund: example.com im Rahmen, google.com verweigert mit `X-Frame-Options: sameorigin` und der Link fuehrt trotzdem hin, Neuladen nach 60 s mit genau einem zweiten Dokumentabruf, Ziehen und Groesse aendern ueber dem Rahmen, API-Log ohne Fremdabruf. **Zahlen:** web 569 → 603, api 1175 unveraendert, type-check 4/4, lint 5/5, Zaehler unveraendert (`as unknown as` 27/6, `noNonNullAssertion` 56, `noExplicitAny` 13). Biome `useAnchorContent` wertet `aria-label` nicht als Linkinhalt → `sr-only`-Text statt `biome-ignore`. | 2026-09-21 | d63d9f5,20a9eb2 | [260921-qd3-dashboard-widget-xframe-eine-webseite-pe](./quick/260921-qd3-dashboard-widget-xframe-eine-webseite-pe/) |
|
||||
| fast-260922 | **Kosmetik nach dem Browser-Rundgang (fast, ohne Akte).** Bilderrahmen: das laengste Wechselintervall (3600 s) hiess „60 Minuten“, beim XFrame dieselbe Stufe „Jede Stunde“ → neuer Schluessel `pictureFrame.intervalHours` (ICU-Plural, de + en); Kopfzeile „Bilderrahmen #N“ unter Einstellungen → Dashboard nennt jetzt „— 1 Bild“ / „— N Bilder“ (zwei Schluessel statt ICU, weil die Panel-Tests eine einfache Uebersetzungs-Attrappe nutzen). Web-Tests 603 → 604. Hinweis fuer spaeter: `gsd-tools quick-tasks-append` scheitert an dieser Tabelle, weil aeltere Zeilen (260918-gza, 260921-iwr, 260921-m34) unmaskierte `\|` im Text tragen — Zeilen daher von Hand anfuegen. | 2026-09-22 | 8b45a28 | — |
|
||||
| 260922-frg | **Desktop-Client: Update-Eintrag im Tray nie mehr stumm ausgegraut.** Befund des Nutzers: „Update installieren“ bleibt grau, obwohl alpha `1.2.0-beta.gc001a08` anbietet und der Client auf `a6d1a64` steht — auch nach App-Neustart. Nachgemessen: Tessera-seitig antwortet `/desktop/update` auf dem alpha-Server selbst (am Proxy vorbei) mit 200 und gueltigem Manifest; DAVOR antwortet der Nginx Proxy Manager auf jede Anfrage an alpha mit `401 Basic` (vom Dev-Host und vom Testserver ueber 217.7.63.32 gemessen). Die Webansicht der App merkt sich das Proxy-Passwort, der Updater (`tauri-plugin-updater`, eigener reqwest) nicht. **Produktfehler:** das Plugin verschluckt Nicht-2xx-Status (`updater.rs` 529-559: `last_error` bleibt leer → `Err(ReleaseNotFound)`), unser `Err(_) => {}` machte daraus stumm denselben grauen Eintrag wie „kein Update“; geprueft wurde nur beim Start. **Fix (d73aad1, nur lib.rs + CHANGELOG):** drei Endzustaende, alle anklickbar — „Auf Beta-Stand … aktualisieren“ (installiert), „Kein Update verfügbar – erneut prüfen“, „Update-Prüfung fehlgeschlagen (HTTP 401) – erneut prüfen“ (Statuscode per eigener Diagnose-Anfrage nachgeliefert, nur Status gelesen); Benachrichtigung mit Erklaerung (Passwortschutz/Zugriffsliste am Proxy), entprellt ueber `LastCheckNotice`; Wiederhol-Thread alle 4 h (`std::thread`, ueberspringt bei abgelegtem Update); http-Server weiterhin „Update nur über https möglich“. Proxy-Zugangsdaten NICHT in den Client (T-FRG-03). `cargo fmt/clippy/test/build` gruen, 37 → 44 Tests, Rot-Nachweis 9x E0425. **Behebung beim Nutzer:** Passwortschutz vor alpha im Proxy Manager entfernen oder `/api-proxy/desktop/*` durchlassen; neuen Client einmal ueber den Browser installieren. | 2026-09-22 | d73aad1 | [260922-frg-desktop-client-update-eintrag-im-tray-ni](./quick/260922-frg-desktop-client-update-eintrag-im-tray-ni/) |
|
||||
| fast-260922-b | **Desktop-App: Download-Knoepfe in der App ohne Funktion (fast, 747a4d4).** Befund des Nutzers: „Herunterladen“ unter Einstellungen → Desktop-App tut in der App nichts (Windows und Linux). Ursache: die Webansicht hatte keinen Download-Handler — webkit2gtk verwirft Downloads dann still, WebView2 zeigte ebenfalls nichts. Fix: Hauptfenster entsteht im Code (`app.windows` in tauri.conf.json leer), weil nur `WebviewWindowBuilder` `on_download` annimmt; der Handler bricht den Download in der App ab und oeffnet die Adresse per Opener im System-Browser (Fortschritt, Speicherort, Passwortfenster fuer den Proxy). Capability `main` unveraendert. cargo fmt/clippy/test gruen. Nicht am laufenden Client geprueft (kein Display auf dem Dev-Host) — CI baut, Nachweis beim Nutzer oder auf der Windows-VM. | 2026-09-22 | 747a4d4 | — |
|
||||
| 260922-ge2 | **XFrame: Ausschnitt der Seite waehlen und einpassen, Zoom, „Nur anzeigen“.** Wunsch des Nutzers: nur einen bestimmten Ausschnitt der eingebetteten Seite zeigen, und die Groesse soll skalieren. Config: `crop {x,y,w,h}` in Seitenpixeln bei fester Layoutbreite 1280 (`XFRAME_PAGE_WIDTH`, keine UI), Klemmung ueber EINE Funktion `clampXframeCrop` (x+w ≤ 1280 verschiebt x; w ≥ 100, h ≥ 60, y+h ≤ 4000); `zoom` (50…150 %, nur Ganzseiten-Modus); `readOnly` (transparente Flaeche ueber dem Rahmen im Ansichtsmodus). Kachel: `computeCropLayout` (contain + Zentrierung, Massstab darf > 1 sein), der `<iframe>` wird selbst verschoben und skaliert (cross-origin — die Seite laesst sich von aussen nicht scrollen), Kachelmass per ResizeObserver. Einstellungen: Vorschau der Seite bei 1280 px (Stage 3000 Seitenpixel hoch, eigener Bildlauf), Rahmen als `<fieldset>` (Biome `useSemanticElements`) mit vier Eckgriffen, Ziehen per Pointer-Events mit lokalem Entwurf und genau einem PATCH beim Loslassen, Zahlenfelder als Tastaturweg; Zoom-Auswahl nur ohne Ausschnitt; Aktivieren setzt `readOnly` mit. **Befund im Browser-Rundgang, behoben (cf70a19):** Kachel und Vorschau hatten verschiedene Rahmenhoehen (max(y+h,720) vs. 3000) — bei vh-relativen Seiten (example.com `margin: 15vh`) lag derselbe Inhalt an verschiedenen Stellen, der gewaehlte Ausschnitt haette in der Kachel daneben gelegen; jetzt dieselbe Layouthoehe. Neun Pruefpunkte bestanden (Verschieben, Ecken mit fester Gegenecke und Mindestbreite, Klemmung der Zahlenfelder, Einpassen und Mitskalieren bei Kachelgroesse, Nur-anzeigen, Zoom 60 %, verweigernde Seite). Playwright kann in einem per `transform` skalierten iframe nicht selbst klicken — per `elementFromPoint` + `mouse.click` umgangen, ist eine Werkzeuggrenze. Test-Helfer `src/test/fake-resize-observer.ts`. **Zahlen:** web 604 → 640, api 1175, type-check 4/4, lint 5/5 (web 53 Warnungen unveraendert), `as unknown as` 27/6, Umlaut-Allowlist + „Ausschnitt“. | 2026-09-22 | 445b1d3,30fdd99,cf70a19 | [260922-ge2-xframe-widget-ausschnitt-der-eingebettet](./quick/260922-ge2-xframe-widget-ausschnitt-der-eingebettet/) |
|
||||
| 260922-hk4 | **Bilderrahmen-Bilder liegen jetzt im Dateibereich statt in der Datenbank.** Frage des Nutzers nach der Freigabe 1.3.0, ob `bytea` auf Dauer sinnvoll ist. Befund: Geschwindigkeit ist NICHT das Argument (ein Bild wird je Browser einmal taeglich geladen), die SICHERUNG ist es — gesichert wird von Hand per `pg_dump`, und 30 Bilder à 5 MiB je Benutzer waeren im Extremfall 150 MB pro Benutzer in jedem Abzug (alpha-DB heute 18 MB). Dazu Einheitlichkeit: Profilbilder (`user-files/avatars`, `User.avatarPath`) und DKV-Exporte liegen laengst im Volume. Umsetzung: Spalte `storagePath`, Ablage `user-files/dashboard-images/<userId>/<uuid>.<ext>` — Dateiname IMMER vom Server (UUID + Endung aus dem erkannten Mime-Typ), `originalName` nie im Pfad; ein eigener Ordner je Benutzer ist ausdruecklich KEIN Schutz, es entscheidet weiterhin die Besitzpruefung im Dienst. Umzug laeuft automatisch beim Start (`onApplicationBootstrap` ueber `forSystem()`), idempotent; die Spalte `data` bleibt bewusst vorerst stehen (Todo fuer den DROP, erst wenn alpha und live einmal gelaufen sind). **Befund im Rundgang, eigener Commit:** eine Zeile zeigte auf eine fehlende Datei (lokal Host vs. Container-Volume; im Betrieb: alter `pg_dump` + leeres Volume) — `getBytes` stellt die Datei jetzt aus der noch vorhandenen Spalte `data` wieder her, statt 404 zu melden. **Zahlen:** api 1175 → 1188, web 640, type-check 4/4, lint 5/5, RLS-Waechter 78/78. | 2026-09-22 | 9039cea,8cbfb8b,82472ee | [260922-hk4-bilderrahmen-bilder-auf-die-festplatte](./quick/260922-hk4-bilderrahmen-bilder-auf-die-festplatte/) |
|
||||
| 260922-m1h | **Ein Modul bringt seine Dashboard-Kachel jetzt selbst mit (Vorarbeit fuer Proxmox).** Bestandsaufnahme (lesend) hatte ergeben: ein neuer Widget-Typ war an SIEBEN Stellen hartkodiert (Union-Typ, Constraints, Registry, eigene `wireXWidget()` je Typ, Aufruf in page.tsx, zweite Liste im Katalogfenster, `@IsIn` im API-DTO); die Verbindung Kachel↔Modul existierte als `WIDGET_MODULE_MAP` in `dashboard.service.ts` (filtert fail-closed), war aber nie befuellt; der Katalog zeigte jedem alle Kacheln, auch die gesperrter Module. Umbau: `WIDGET_TYPES`/`WidgetType`/`WIDGET_MODULE_SLUGS` in `packages/shared` als EINE Quelle (API validiert per `@IsIn` gegen genau sie), ein generisches `registerWidget()` statt neun Funktionen, Katalog leitet seine Liste aus der Registry ab und filtert ueber `/modules/active` (fail-closed bei Fehler, reine Funktion `visibleWidgetTypes`), nicht verfuegbare Kachel zeigt `widgets.unavailable` statt leer zu bleiben. Deckungsgleichheits-Test faengt kuenftig jede vergessene Stelle. **Befund des Executors, geprueft statt vermutet:** `apps/web` hatte KEINE Abhaengigkeit auf `@tessera/shared` (frueher bewusst) — vor der Umsetzung nachgemessen, dass Bau und Produktions-Abbild das tragen (node:24-alpine strippt die Typen nativ); Folgeregel „nur loeschbare Syntax in shared“ steht als Warnung in der Datei. Verhalten der neun Kacheln unveraendert, im Browser bestaetigt (Reihenfolge, Anlegen, Entfernen, keine rohen Schluessel). Bewusst offen: der Einstellungs-Zweig je Typ in `widget-settings-panel.tsx` und die Live-Aktualisierung des Katalogs. **Zahlen:** api 1188 → 1202, web 640 → 659, type-check 4/4, lint 5/5 (74/53 wie Basis). | 2026-09-22 | 56c07c3,8be0725 | [260922-m1h-dashboard-widgets-ein-modul-bringt-seine](./quick/260922-m1h-dashboard-widgets-ein-modul-bringt-seine/) |
|
||||
| 260922-vdk | **Dashboard-Raster misst seine Breite auch aus dem Leerzustand heraus.** Meldung des Nutzers aus dem **Linux-Client**: rechts neben dem Kalender freie Flaeche, in die sich keine Kachel schieben laesst — „als ob es keinen Anker gibt“. Aus dem Bildschirmfoto zurueckgerechnet (Spaltenbreite 51,5 px, Platzhalter auf Spalte 13 = letzte moegliche, Rasterende bei x=1459 bei ~1660 px Inhaltsbreite): das Raster rechnete mit **1200 px** statt mit der echten Breite, rechts blieben ~460 px totes Feld. Ursache: die Breitenmessung hing in `useEffect(..., [])` mit `if (!containerRef.current) return` — haengt `DashboardGrid` mit NULL Kacheln ein, rendert der fruehe Ruecksprung in den Leerzustand den gemessenen `<div>` gar nicht, der Effekt bricht ab und laeuft nie wieder, auch nicht wenn spaeter die erste Kachel entsteht. `width` blieb die ganze Sitzung auf dem Startwert 1200; react-grid-layout vergleicht strikt (`width > breakpoint`), 1200 ist damit `md` (20 Spalten, 51,6 px) statt `lg`. Fix: Ref-Rueckruf `measureRef` statt Einmal-Effekt — folgt dem Knoten ueber den Wechsel Leerzustand ↔ gefuellt, misst synchron in der Commit-Phase, haengt den ResizeObserver dort an; Fenster-Horcher als zusaetzliches Netz; `applyWidth` verwirft 0 und nicht endliche Werte. **Verhalten sonst unveraendert** — belegte Plaetze bleiben gesperrt, nichts weicht aus (Ansage des Nutzers). **Geprueft im echten Client**, nicht im Browser: `Tessera-1.3.0.AppImage` auf `DISPLAY=:10` ueber den WebKit-Remote-Inspektor gesteuert. Gleicher Fehlerfall vorher/nachher: Kachel 469 px → **389 px** bei 1000 px Bereich, Ziehen endet jetzt bei 603 px = `1000 − 8 − 389`, exakt der rechte Rand. **Messfalle notiert:** im Client gegen `style.width`/`style.transform` messen, nie gegen `getBoundingClientRect()` — bei Fenster im Hintergrund friert WebKitGTK die Animationsuhr ein und der `width`-Uebergang bleibt auf dem alten Wert stehen. **Zahlen:** web 659 → 661 Tests, type-check 4/4, lint 5/5, Biome web 53 Warnungen unveraendert. | 2026-09-22 | d9f2af3,cf67c8a | [260922-vdk-dashboard-raster-misst-seine-breite-nich](./quick/260922-vdk-dashboard-raster-misst-seine-breite-nich/) |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
@@ -492,8 +505,8 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-09-21T04:50:00Z
|
||||
Resumed: 2026-09-21 — Sitzung ueber /gsd-resume-work fortgesetzt. Stand geprueft: Arbeitsbaum sauber, main == origin/main auf 55aa287, CI-Lauf 387 fuer 55aa287 erfolgreich (Beta-Images gebaut). Push und CI aus dem letzten Stopp-Punkt sind damit erledigt.
|
||||
Stopped at: Warte auf Nutzerentscheidung, womit weitergearbeitet wird. Offen fuer den User: alpha pullen (web+api) und danach am Windows-VM-Client die echte Fehlermeldung schicken (Betreff `[Desktop/Windows]` + `Herkunft:`-Zeile pruefen); eigenen Arbeitsplatz-Client einmal per Browser-Installer erneuern; Freigabe 1.3.0 auf Zuruf. Technisch offen im Ledger: WINDOWS #35 (Biome laeuft nicht — biome.json:3 `organizeImports` ist in Biome 2.5.0 unbekannt, `biome check` bricht mit Konfigurationsfehler ab, reproduziert 2026-09-21) und WINDOWS #36 (403-Antworten bleiben in handleSubmit/handleDelete ohne sichtbare Reaktion).
|
||||
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.
|
||||
Resume file: None
|
||||
Last activity: 2026-09-21 - Quick 260921-iwr: 30 Listenschluessel- und Zusicherungs-Stellen geprueft, kein echter Fehler; beide Verdachtsmomente (LDAP-Seite, Cert-Manager) durch Messung widerlegt. Der fehlerverdaechtige Rueckstand ist damit abgearbeitet
|
||||
Last activity: 2026-09-22 - Quick 260922-hk4: Bilderrahmen-Bilder im Dateibereich, Selbstheilung aus der alten Spalte
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# GSD Debug Knowledge Base
|
||||
|
||||
Geloeste Debug-Sitzungen. Wird von `gsd-debugger` zu Beginn einer neuen
|
||||
Untersuchung gelesen, um bekannte Muster als Hypothesen-Kandidaten
|
||||
vorzuschlagen.
|
||||
|
||||
---
|
||||
|
||||
## wackeltest-bugreport-haekchen — Vorschaubild sichtbar, Haekchen "Bildschirmfoto anhaengen" aus (Wackeltest CI 395)
|
||||
- **Date:** 2026-09-21
|
||||
- **Error patterns:** toBeChecked, Received element is not checked, flaky, Wackeltest, nur unter Last, isoliert nie, Zustand erst einen Commit spaeter richtig
|
||||
- **Root cause(s):** Dialog dauerhaft eingehaengt, sodass `useState(abgeleiteterWert)` nur beim allerersten Mount lief (Daten noch nicht da); zusammen damit: Anfangszustand per `useEffect` nachgezogen, und passive Effekte laufen NACH dem Commit — React schreibt deshalb bei jedem Oeffnen erst den falschen, dann den richtigen Zustand in den DOM
|
||||
- **Fix:** Dialog nur einhaengen, solange offen (`{open && <Dialog/>}`) — frischer Mount je Oeffnen; abgeleiteten Wert beim Rendern ableiten statt per Effekt nachziehen (`const attach = screenshot !== null && (attachChoice ?? true)`); zuruecksetzender Effekt entfaellt ersatzlos
|
||||
- **Files changed:** apps/web/src/components/bug-report/bug-report-dialog.tsx, apps/web/src/components/bug-report/bug-report-button.tsx, apps/web/src/components/bug-report/bug-report-button.test.tsx
|
||||
- **Why not caught:** Es gab ein Tor, aber ein stumpfes — Test 1 las den DOM EINMAL nach `findByRole` und traf damit mal den falschen ersten, meist den richtigen zweiten Commit (1:17). Eine Stichprobe am Ende kann einen falschen Zwischen-Commit grundsaetzlich nicht zuverlaessig sehen. Lint und Typpruefung koennen diese Klasse gar nicht sehen.
|
||||
- **Recurrence guard:** Regressionstest apps/web/src/components/bug-report/bug-report-button.test.tsx:"Test 14 (quick-260921-ldf): kein falscher Zwischenzustand" und ":"Test 15 (quick-260921-ldf): erneutes Oeffnen startet frisch" — beide beobachten per MutationObserver JEDEN Commit statt einer Stichprobe und sind gegen den Stand davor deterministisch rot (5/5)
|
||||
- **Merksatz fuer aehnliche Faelle:** Bei einem Wackeltest zuerst per MutationObserver pruefen, ob der beobachtete Zwischenzustand ueberhaupt in den DOM geschrieben wird. Wird er es, ist es ein Produktfehler und der Test hat recht — dann nicht den Test beruhigen (kein retry, kein hoeheres Zeitlimit), sondern den Zustand beseitigen.
|
||||
---
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
status: resolved
|
||||
trigger: "CI 395 rot: apps/web/src/components/bug-report/bug-report-button.test.tsx Test 1 -- Vorschaubild da, Haekchen 'Bildschirmfoto anhaengen' aus. 1 Fehlschlag in ~17 vollen Laeufen, isoliert nie."
|
||||
created: 2026-09-21T00:00:00Z
|
||||
updated: 2026-09-21T00:00:00Z
|
||||
symptoms_prefilled: true
|
||||
goal: find_and_fix
|
||||
---
|
||||
|
||||
## Current Focus
|
||||
|
||||
reasoning_checkpoint:
|
||||
hypothesis: "attach ist abgeleiteter Zustand, der per passivem useEffect nachgezogen wird. Da BugReportDialog dauerhaft eingehaengt ist, laeuft useState(screenshot !== null) nur beim ersten Mount (screenshot noch null) -> attach startet immer false. Deshalb committet React BEI JEDEM Oeffnen zuerst einen DOM-Zustand 'Dialog offen + Bild da + Haekchen AUS' und korrigiert ihn erst im naechsten Commit."
|
||||
confirming_evidence:
|
||||
- "MutationObserver-Protokoll (H2): COMMIT dialog=true img=ja box=AUS, danach COMMIT dialog=true img=ja box=AN -- der falsche Zustand ist ein echter, committeter DOM-Zustand, kein Testartefakt."
|
||||
- "H3 (roher Klick ohne act): der falsche Zustand haelt ZWEI volle Makrotask-Runden. Zwei Makrotask-Grenzen = zwei Gelegenheiten des Browsers zu zeichnen."
|
||||
- "H1 (kuenstlicher Makrotask im toPng-Mock): Fehlschlag 4 von 4, exakt dieselbe Meldung wie in CI 395 -- die Wackelbedingung ist reine Beobachtungszeit."
|
||||
- "H3b: dasselbe Muster beim erneuten Oeffnen -- der alte Danke-Bildschirm steht zwei Runden lang im DOM, bevor das frische Formular erscheint."
|
||||
falsification_test: "Waere es ein reines Testartefakt, duerfte im MutationObserver-Protokoll kein Commit mit img=ja/box=AUS auftauchen. Er taucht auf, ausnahmslos, bei jedem Oeffnen."
|
||||
fix_rationale: "Ursache ist die Konstruktion: Zustand wird per Effekt nachgezogen statt beim Rendern abgeleitet, und der Dialog bleibt ueber das Schliessen hinaus eingehaengt. Beides beseitigen: (1) attach waehrend des Renderns aus screenshot ableiten, (2) den Dialog nur einhaengen, solange er offen ist -> jeder Oeffnungsvorgang startet mit frischem Zustand, schon im ersten Commit."
|
||||
blind_spots: "Ob der Browser den Zwischen-Frame tatsaechlich zeichnet, ist hier nicht im echten Browser gemessen -- belegt ist, dass der falsche Zustand zwei Makrotask-Grenzen ueberdauert, also mindestens zwei Zeichengelegenheiten offenstehen."
|
||||
candidate_causes:
|
||||
- "code: abgeleiteter Zustand per passivem Effekt statt beim Rendern (bestaetigt)"
|
||||
- "code: Dialog dauerhaft eingehaengt -> useState-Startwert veraltet (bestaetigt, zweite Teilursache)"
|
||||
- "environment: Ereignisschleifen-Last im vollen Vitest-Lauf (nur Ausloeser der Beobachtung, nicht Ursache)"
|
||||
- "data: Datenform des Bildes -- ausgeschlossen, img src ist im Fehlerfall korrekt"
|
||||
and_gate: "ja -- zwei Bedingungen zusammen: (a) attach wird per Effekt nachgezogen UND (b) der Dialog bleibt eingehaengt, sodass der useState-Startwert aus der Zeit vor dem ersten Bild stammt. Ohne (b) waere (a) beim ersten Oeffnen unauffaellig; ohne (a) waere (b) folgenlos."
|
||||
test: Fix anwenden, danach H2/H3-Instrumentierung erneut laufen lassen
|
||||
expecting: Erster Commit mit Dialog traegt bereits Haekchen AN
|
||||
next_action: bug-report-dialog.tsx und bug-report-button.tsx anpassen
|
||||
|
||||
## Symptoms
|
||||
|
||||
expected: Nach Klick auf den Fehler-melden-Knopf oeffnet der Dialog mit Vorschaubild UND gesetztem Haekchen "Bildschirmfoto anhaengen".
|
||||
actual: Vorschaubild ist da (img src == DATA_URL, Checkbox nicht disabled), aber die Checkbox ist nicht checked.
|
||||
errors: |
|
||||
Error: expect(element).toBeChecked()
|
||||
Received element is not checked:
|
||||
<input class="h-4 w-4" id="bug-report-attach" type="checkbox" />
|
||||
bug-report-button.test.tsx:126:72
|
||||
reproduction: pnpm --filter @tessera/web exec vitest run (voller Lauf), ~1 von 17. Isoliert (nur die Datei) in 6 Laeufen nie.
|
||||
started: CI-Lauf 395 (2026-09-21), Test existiert seit quick-260914-m97
|
||||
|
||||
## Eliminated
|
||||
|
||||
- hypothesis: "Reines Testartefakt -- die Pruefung misst einen Zustand, den der Nutzer nie sieht"
|
||||
evidence: "MutationObserver-Protokoll zeigt den Zustand als echten DOM-Commit, der zwei Makrotask-Runden ueberdauert. Der Browser hat in dieser Zeit mindestens zwei Zeichengelegenheiten."
|
||||
timestamp: T2
|
||||
|
||||
- hypothesis: "Der dynamische Import von html-to-image kostet einen Makrotask und kippt dadurch die Reihenfolge"
|
||||
evidence: "Gemessen: await import('html-to-image') loest ohne Makrotask-Grenze auf (Timer 0, davor gesetzt, feuert NACH dem Import). Der Import ist nicht die Ursache -- die falsche Reihenfolge besteht auch ohne ihn."
|
||||
timestamp: T2
|
||||
|
||||
## Evidence
|
||||
|
||||
- timestamp: T0
|
||||
checked: apps/web/src/components/bug-report/bug-report-button.tsx
|
||||
found: "handleClick: setCapturing(true); const shot = await captureScreenshot(); setScreenshot(shot); setOpen(true); setCapturing(false). BugReportDialog wird IMMER gerendert (kein bedingtes Mounten) -- die Instanz bleibt ueber open-Wechsel hinweg bestehen."
|
||||
implication: "useState(screenshot !== null) im Dialog laeuft nur EINMAL, beim ersten Mount des Knopfs, da ist screenshot noch null -> attach startet IMMER false. Das Haekchen wird ausschliesslich durch den useEffect gesetzt."
|
||||
|
||||
- timestamp: T0
|
||||
checked: apps/web/src/components/bug-report/bug-report-dialog.tsx
|
||||
found: "const [attach, setAttach] = useState(screenshot !== null); useEffect(() => { if (open) { ... setAttach(screenshot !== null); ... } }, [open, screenshot]);"
|
||||
implication: "attach ist abgeleiteter Zustand, synchronisiert per passivem Effekt. Zwischen dem Commit (Bild im DOM) und dem Lauf des passiven Effekts (Haekchen an) existiert zwangslaeufig ein Zustand 'Bild da, Haekchen aus'."
|
||||
|
||||
- timestamp: T0
|
||||
checked: apps/web/src/lib/bug-report-api.ts captureScreenshot
|
||||
found: "await import('html-to-image') -- dynamischer Import VOR dem toPng-Aufruf; Fehler werden geschluckt (catch -> null)."
|
||||
implication: "Zwei await-Stufen vor setScreenshot/setOpen. Unter Last kann die Aufloesung nach dem Ende des act()-Bereichs von user.click() landen."
|
||||
|
||||
- timestamp: T1
|
||||
checked: "Kuenstlicher Makrotask im toPng-Mock (zz-repro.test.tsx, Verzoegerung 0/1/5/20 ms)"
|
||||
found: "4 von 4 Fehlschlaegen mit exakt der CI-Meldung 'Received element is not checked'."
|
||||
implication: "Jede Makrotask-Grenze in der Aufnahmekette genuegt, damit die Pruefung den falschen Zwischenzustand sieht. Die Last im vollen Lauf ist nur der Ausloeser."
|
||||
|
||||
- timestamp: T2
|
||||
checked: "MutationObserver ueber document.body waehrend des Oeffnens (zz-repro3.test.tsx), toPng rein mikrotask wie im echten Test"
|
||||
found: |
|
||||
COMMIT dialog=false img=nein box=-
|
||||
COMMIT dialog=false img=nein box=-
|
||||
COMMIT dialog=true img=ja box=AUS <- falscher Zustand, committet
|
||||
COMMIT dialog=true img=ja box=AN
|
||||
implication: "Der Zustand 'Bild da, Haekchen aus' ist ein echter, committeter DOM-Zustand -- bei JEDEM Oeffnen, nicht nur unter Last. Der Test faellt nur dann durch, wenn er zufaellig den ersten statt den zweiten Commit sieht."
|
||||
|
||||
- timestamp: T2
|
||||
checked: "Roher Klick ohne act(), Sampling pro Makrotask-Runde (zz-repro4.test.tsx)"
|
||||
found: "runde 1: bild=ja haekchen=AUS | runde 2: bild=ja haekchen=AUS | runde 3: bild=ja haekchen=AN"
|
||||
implication: "Der falsche Zustand ueberdauert zwei volle Ereignisschleifen-Runden. Im Browser liegen damit mindestens zwei Zeichengelegenheiten in diesem Zustand -> fuer den Nutzer sichtbar."
|
||||
|
||||
- timestamp: T2
|
||||
checked: "Erneutes Oeffnen nach Versand (zz-repro4.test.tsx, H3b)"
|
||||
found: "runde 2 und 3 zeigen den ALTEN Danke-Bildschirm (danke=true), erst runde 4 das frische Formular."
|
||||
implication: "Zweite Auspraegung derselben Ursache: auch status/description werden erst per Effekt zurueckgesetzt. Der Fix muss beide Teilursachen beseitigen."
|
||||
|
||||
- timestamp: T2
|
||||
checked: "await import('html-to-image') gegen setTimeout(0) (zz-repro2.test.tsx)"
|
||||
found: "[IMPORT-1] timerFired=false 1.13ms, [IMPORT-2] timerFired=false 0.18ms"
|
||||
implication: "Der dynamische Import ueberschreitet keine Makrotask-Grenze -- er ist nicht die Ursache."
|
||||
|
||||
## Resolution
|
||||
|
||||
root_cause: |
|
||||
Produktfehler, zwei Teilursachen im UND-Verbund (bestaetigt per MutationObserver
|
||||
ueber jeden DOM-Commit):
|
||||
(a) BugReportDialog war dauerhaft eingehaengt und gab bei geschlossenem Zustand
|
||||
nur `null` zurueck. `useState(screenshot !== null)` lief damit genau einmal,
|
||||
beim allerersten Mount des Knopfs -- da war `screenshot` noch `null`, also
|
||||
startete `attach` immer als `false`.
|
||||
(b) Der Anfangszustand wurde per `useEffect` nachgezogen. Passive Effekte laufen
|
||||
NACH dem Commit. React schrieb deshalb bei JEDEM Oeffnen zuerst den Zustand
|
||||
"Dialog offen + Vorschaubild sichtbar + Haekchen AUS" in den DOM und
|
||||
korrigierte ihn erst im naechsten Commit.
|
||||
Der falsche Zustand ueberdauerte gemessen zwei volle Makrotask-Runden -- der
|
||||
Browser hat in dieser Zeit mindestens zwei Gelegenheiten, ihn zu zeichnen.
|
||||
Die Last im vollen Vitest-Lauf war nur der Ausloeser dafuer, dass die Pruefung
|
||||
den ersten statt den zweiten Commit sah; sie war nie die Ursache.
|
||||
|
||||
fix: |
|
||||
(a) Der Dialog wird nur noch eingehaengt, solange er offen ist
|
||||
(`{open && <BugReportDialog ... />}`) -- jedes Oeffnen ist ein frischer
|
||||
Mount, der Anfangszustand gilt schon im ersten Commit. Der zuruecksetzende
|
||||
Effekt entfaellt ersatzlos.
|
||||
(b) Das Haekchen wird beim Rendern aus `screenshot` abgeleitet statt per Effekt
|
||||
nachgezogen: `const attach = screenshot !== null && (attachChoice ?? true)`.
|
||||
`attachChoice` haelt allein die bewusste Abwahl des Nutzers.
|
||||
|
||||
verification: |
|
||||
signal_reproduktion: bestaetigt -- kuenstlicher Makrotask in der Aufnahmekette
|
||||
erzwang den Fehlschlag 4/4 vor dem Fix, 4/4 gruen danach.
|
||||
signal_regressionstest: Test 14/15 sind gegen den Stand vor dem Fix in 5 von 5
|
||||
Laeufen rot, danach gruen. Deterministisch, kein retry, kein Zeitlimit.
|
||||
signal_umkehrprobe: Quellcode auf 8d604b8 zurueckgesetzt, neue Tests bleiben --
|
||||
der Fehler kehrt zurueck. Fix und Fehler haengen nachweislich zusammen.
|
||||
signal_zwischenzustand: MutationObserver-Protokoll nach dem Fix zeigt den
|
||||
ersten Commit mit Dialog bereits als "img=ja box=AN". Kein falscher Commit mehr.
|
||||
signal_kein_loeschfix: der Diff ist kein Wegnehmen einer Pruefung -- Test 1
|
||||
prueft unveraendert dieselbe Zusicherung, zwei Tests kamen hinzu.
|
||||
gates: lint 5/5 ohne Fehlerstufe, 399 Warnungen (unveraendert); type-check 4/4;
|
||||
apps/web 73 Dateien / 531 Tests; apps/api 72 Dateien / 1143 Tests.
|
||||
signal_stabilitaet: 20/20 volle Laeufe von `pnpm --filter @tessera/web exec vitest run`
|
||||
gruen, 0 Fehlschlaege, je 531 Tests. Fuer sich genommen schwach (bei 1:17 waeren
|
||||
20 gruene Laeufe auch ohne Fix zu ~30 % zu erwarten) -- der tragende Beleg ist
|
||||
der deterministische: den falschen Zustand gibt es nicht mehr.
|
||||
guardrail_verdict: accepted
|
||||
|
||||
files_changed:
|
||||
- apps/web/src/components/bug-report/bug-report-dialog.tsx
|
||||
- apps/web/src/components/bug-report/bug-report-button.tsx
|
||||
- apps/web/src/components/bug-report/bug-report-button.test.tsx
|
||||
+720
@@ -0,0 +1,720 @@
|
||||
---
|
||||
phase: quick-260921-jt4
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx
|
||||
- apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.test.tsx
|
||||
- apps/web/src/components/dashboard/widget-catalog-modal.tsx
|
||||
- apps/web/src/components/dashboard/widget-catalog-modal.test.tsx
|
||||
- apps/web/src/components/settings/calendar-settings-panel.tsx
|
||||
- apps/web/src/components/settings/calendar-settings-panel.test.tsx
|
||||
- apps/web/src/components/dashboard/widgets/note-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/note-widget.test.tsx
|
||||
- apps/web/src/components/dashboard/widgets/note-task-list.tsx
|
||||
- apps/web/src/components/dashboard/widgets/note-task-list.test.tsx
|
||||
- apps/web/src/components/dashboard/widgets/calendar-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx
|
||||
- apps/web/src/components/dashboard/widgets/calculator-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||
- apps/web/src/components/layout/header.tsx
|
||||
- apps/web/src/components/settings/account-settings-form.tsx
|
||||
- apps/web/src/app/(auth)/login/page.tsx
|
||||
- apps/web/src/app/(auth)/reset-password/page.tsx
|
||||
- apps/web/src/app/(auth)/reset-password/[token]/page.tsx
|
||||
- apps/web/src/app/(portal)/change-password/page.tsx
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/zip-filename.ts
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/zip-filename.test.ts
|
||||
- apps/web/src/lib/translations-identity.test.tsx
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- apps/api/src/tenders/tender-normalizer.service.ts
|
||||
autonomous: true
|
||||
requirements: [D-01, D-02, D-03, D-04, D-05, D-06, D-07]
|
||||
|
||||
estimate:
|
||||
tokens: 150000
|
||||
raw_tokens: 150000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Jede Stelle, die heute nur mit der Maus bedienbar ist, ist danach auch mit der
|
||||
Tastatur bedienbar und wird von einer Vorlesehilfe als Schaltflaeche angesagt —
|
||||
belegt durch Komponententests, die ein Tastaturereignis ausloesen, nicht durch
|
||||
Auszeichnungs-Behauptungen (D-01)."
|
||||
- "Das Erscheinungsbild ist an keiner der 13 Dateien ein anderes als vorher: kein
|
||||
neuer Rahmen, kein neuer Abstand, keine verschobene Kachel (D-04)."
|
||||
- "Biome meldet nach Abschluss genau 399 Befunde, 0 davon der Stufe error, und genau
|
||||
EINEN a11y-Befund — den bewusst stehengelassenen Tastatur-Handler des
|
||||
Taschenrechner-Rahmens. Keine einzige Unterdrueckung per biome-ignore (D-07)."
|
||||
- "Drei Druecke auf Weiter im Kalender-Widget holen die Quellenliste genau einmal
|
||||
statt dreimal, und zwei aufeinanderfolgende Termin-Abrufe tragen nie denselben
|
||||
from/to-Bereich — gemessen im Netzwerkprotokoll des Browsers, nicht per fetch aus
|
||||
der Seite."
|
||||
- "Der ZIP-Name des Zertifikat-Aufteilers kommt aus dem Uebersetzungskatalog und ist
|
||||
auf einer Windows-Freigabe garantiert gueltig, weil eine gepruefte Schutzfunktion
|
||||
jeden unzulaessigen Namen abfaengt — nicht, weil das deutsche Wort zufaellig
|
||||
harmlos ist."
|
||||
- "Jede neue Beschriftung steht in de.json UND en.json, deutsche Oberflaechentexte in
|
||||
der Sie-Form; beide Kataloge haben danach dieselbe Schluesselmenge (D-05)."
|
||||
- "Fuer jede der 30 Fundstellen steht in der SUMMARY, welcher der vier Wege gewaehlt
|
||||
wurde und — bei der Rueckfallvariante oder beim Stehenlassen — warum der gerade
|
||||
Weg dort nicht ging."
|
||||
artifacts:
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/zip-filename.ts
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/zip-filename.test.ts
|
||||
- apps/web/src/components/dashboard/widget-catalog-modal.test.tsx
|
||||
- apps/web/src/components/settings/calendar-settings-panel.test.tsx
|
||||
- apps/web/src/lib/translations-identity.test.tsx
|
||||
key_links:
|
||||
- "Hintergrundflaeche eines Dialogs -> echte Schaltflaeche -> onClose: beim
|
||||
Loeschen-Dialog MUSS dieser Weg abbrechen, niemals bestaetigen."
|
||||
- "note-widget -> previewOptions -> rehypeSanitize: die XSS-Schranke T-IEX-01 muss
|
||||
den Umbau des Kaestchen-Handlers unveraendert ueberleben."
|
||||
- "calendar-widget -> computeFetchWindow -> fetchEvents: der Sperrgriff darf den
|
||||
5-Minuten-Auffrischer nicht mitsperren, sonst friert die Anzeige ein."
|
||||
---
|
||||
|
||||
<objective>
|
||||
Die 30 zurueckgestellten Barrierefreiheits-Befunde abarbeiten — mit den vom Orchestrator
|
||||
getroffenen Bedienentscheidungen D-01 bis D-04 — und die vier namentlich vermerkten
|
||||
Restposten aus den heutigen Vorgaengen bi2 und gof schliessen.
|
||||
|
||||
Purpose: Heute sind mehrere Bedienelemente ausschliesslich mit der Maus erreichbar. Wer
|
||||
mit der Tastatur oder einer Vorlesehilfe arbeitet, kann sie nicht ausloesen — das ist der
|
||||
eigentliche Schaden hinter diesen Befunden, kein Schoenheitsfehler der Zaehlung.
|
||||
|
||||
Output: 13 Oberflaechendateien mit echten Schaltflaechen statt klickbarer Bereiche, zwei
|
||||
Uebersetzungskataloge im Gleichstand, fuenf neue bzw. erweiterte Testdateien, ein
|
||||
gemessener Rueckgang von 429 auf 399 Befunde und zwei Kalender-Abrufe weniger pro
|
||||
Monatswechsel.
|
||||
</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/260921-bi2-lint-rueckstand-abbauen-mechanische-fixe/260921-bi2-SUMMARY.md
|
||||
@.planning/quick/260921-gof-effekt-abhaengigkeiten-in-react-21-befun/260921-gof-VERIFICATION.md
|
||||
</context>
|
||||
|
||||
<measured_baseline>
|
||||
Vom Planer am 2026-09-21 unmittelbar vor dem Schreiben dieses Plans gemessen, nicht
|
||||
uebernommen:
|
||||
|
||||
```
|
||||
npx biome lint . --reporter=json
|
||||
-> total 429 real 350 test 79 errors 0 a11y 30
|
||||
```
|
||||
|
||||
Die Zahl 429 ist die **Gesamtzahl inklusive Testdateien** (350 echter Quelltext + 79
|
||||
Testdateien), nicht der reine Quelltextanteil — die Auftragsbeschreibung bezeichnet sie
|
||||
als "real source only", das ist eine Fehlbeschriftung der sonst korrekten Zahl. Alle 30
|
||||
a11y-Befunde liegen in echtem Quelltext.
|
||||
|
||||
Die 30 Fundstellen, mit Zeilennummer zum Zeitpunkt der Planung (Zeilen verschieben sich
|
||||
beim Umbau — arbeite nach Element, nicht nach Zeilennummer):
|
||||
|
||||
| Datei | Zeile | Regel |
|
||||
|---|---|---|
|
||||
| `app/(auth)/login/page.tsx` | 105 | noAutofocus |
|
||||
| `app/(auth)/reset-password/[token]/page.tsx` | 139 | noAutofocus |
|
||||
| `app/(auth)/reset-password/page.tsx` | 99 | noAutofocus |
|
||||
| `app/(portal)/change-password/page.tsx` | 86 | noAutofocus |
|
||||
| `marketplace/components/MarketplaceCard.tsx` | 116 | noNoninteractiveElementInteractions, noStaticElementInteractions, useKeyWithClickEvents |
|
||||
| `components/dashboard/widget-catalog-modal.tsx` | 56 | noNoninteractiveElementInteractions, noStaticElementInteractions, useKeyWithClickEvents |
|
||||
| `components/dashboard/widget-catalog-modal.tsx` | 64 | noNoninteractiveElementInteractions, useKeyWithClickEvents |
|
||||
| `dashboard/widgets/calculator-widget.tsx` | 323 | noNoninteractiveElementInteractions |
|
||||
| `dashboard/widgets/calculator-widget.tsx` | 345 | useAriaPropsSupportedByRole |
|
||||
| `dashboard/widgets/calendar-widget.tsx` | 253 | noNoninteractiveElementInteractions, noStaticElementInteractions |
|
||||
| `dashboard/widgets/favorites-widget.tsx` | 266 | useAriaPropsSupportedByRole |
|
||||
| `dashboard/widgets/favorites-widget.tsx` | 462 | noNoninteractiveElementInteractions |
|
||||
| `dashboard/widgets/favorites-widget.tsx` | 474 | noNoninteractiveElementInteractions |
|
||||
| `dashboard/widgets/note-widget.tsx` | 184 | noNoninteractiveElementInteractions, noStaticElementInteractions, useKeyWithClickEvents |
|
||||
| `components/layout/header.tsx` | 156 | noNoninteractiveElementInteractions |
|
||||
| `components/settings/account-settings-form.tsx` | 161 | noNoninteractiveElementInteractions |
|
||||
| `components/settings/calendar-settings-panel.tsx` | 159, 182, 199 | useAriaPropsSupportedByRole |
|
||||
| `components/settings/calendar-settings-panel.tsx` | 373 | noNoninteractiveElementInteractions, noStaticElementInteractions, useKeyWithClickEvents |
|
||||
|
||||
**Zwei Befunde der Auftragsbeschreibung sind beim Nachlesen anders als angenommen** — der
|
||||
Plan folgt dem gelesenen Quelltext, nicht der Annahme:
|
||||
|
||||
1. **Vier der elf `noNoninteractiveElementInteractions` sind ueberhaupt keine Klicks**,
|
||||
sondern `onError`-Handler an `<img>`-Elementen (Ersatzweg fuer nicht ladende
|
||||
Profilbilder und Favoriten-Symbole): `header.tsx:156`, `account-settings-form.tsx:161`,
|
||||
`favorites-widget.tsx:462` und `:474`. Ein Ladefehler ist keine Bedienung; hier gibt es
|
||||
nichts in eine Schaltflaeche zu verwandeln.
|
||||
2. **Alle fuenf `useAriaPropsSupportedByRole` haben dieselbe Gestalt**: ein `aria-label`
|
||||
sitzt auf einem schlichten `<div>` bzw. `<span>` ohne Rolle. Solche Elemente haben die
|
||||
Rolle `generic`, die gar keine ARIA-Merkmale traegt — die Beschriftung wird von jeder
|
||||
Vorlesehilfe **stillschweigend verworfen**. Das ist ein echter Mangel, kein Formfehler.
|
||||
</measured_baseline>
|
||||
|
||||
<verified_probes>
|
||||
Die folgenden Loesungswege wurden vom Planer **empirisch an Biome 2.5.0 mit der
|
||||
Projektkonfiguration geprueft** (Probedatei unter `apps/web/src/__probe__/`, danach
|
||||
geloescht). Das ist keine Vermutung — jede Zeile ist gemessen:
|
||||
|
||||
| Probe | Ergebnis |
|
||||
|---|---|
|
||||
| `<button type="button" className="fixed inset-0" aria-label=… onClick=…/>` | **sauber** — traegt den Dialog-Hintergrund |
|
||||
| `<button … onMouseEnter onMouseLeave onFocus onBlur>` | **sauber** — traegt die Kalender-Tageszelle |
|
||||
| Deckende Schaltflaeche als Geschwister neben der Aktionsschaltflaeche in einer Karte | **sauber** — traegt die Marktplatz-Karte |
|
||||
| `<span role="img" aria-label=…><svg aria-hidden/></span>` | **sauber** — traegt die drei Statussymbole |
|
||||
| `<div role="toolbar" aria-label=…>` ohne Handler | **sauber** — traegt die beiden Schaltflaechenreihen |
|
||||
| `<img alt="" aria-hidden="true" onError=…/>` | a11y-Befund **verschwindet** (nur der vorbestehende `performance/noImgElement` bleibt) |
|
||||
| `<div role="button" tabIndex={0} onClick onKeyDown>` | **loest `useSemanticElements` NEU aus** |
|
||||
| `<div role="group" aria-label=…>` | **loest `useSemanticElements` NEU aus** (Vorschlag: `<fieldset>`) |
|
||||
| `<div role="region" aria-label=…>` | **loest `useSemanticElements` NEU aus** (Vorschlag: `<section>`) |
|
||||
| `<div role="application"/"group"/"toolbar" … onKeyDown>` | Befund bleibt in **allen drei** Varianten |
|
||||
|
||||
**Die wichtigste Erkenntnis daraus:** Der in D-01 als Rueckfall genannte Weg
|
||||
(`role="button"` + `tabIndex={0}` + Tastaturhandler) bringt die Zaehlung NICHT auf null.
|
||||
Er tauscht drei Befunde gegen einen neuen `useSemanticElements`-Befund — eine Regel, die
|
||||
260921-bi2 gerade erst auf 0 gebracht hat. D-07 verbietet, dass die Zahl anderswo waechst.
|
||||
**Der Rueckfall wird deshalb in diesem Vorgang an keiner einzigen Stelle benutzt**; wo
|
||||
eine unmittelbare Umwandlung in `<button>` an der Verschachtelung scheitert (Marktplatz),
|
||||
tritt stattdessen die geprueft saubere deckende Geschwister-Schaltflaeche an ihre Stelle.
|
||||
</verified_probes>
|
||||
|
||||
<decisions_applied>
|
||||
Zuordnung der 30 Fundstellen zu den gesperrten Entscheidungen. Diese Tabelle ist die
|
||||
Vorgabe, nicht ein Vorschlag:
|
||||
|
||||
| # | Fundstelle | Weg | Entscheidung |
|
||||
|---|---|---|---|
|
||||
| 1-3 | MarketplaceCard.tsx (Karte) | echter Button (deckendes Geschwister) | D-01 |
|
||||
| 4-6 | widget-catalog-modal.tsx (Hintergrund) | echter Button | D-01 |
|
||||
| 7-8 | widget-catalog-modal.tsx (Dialogflaeche) | Handler entfaellt ersatzlos | D-01 |
|
||||
| 9-11 | calendar-settings-panel.tsx (Loeschdialog-Hintergrund) | echter Button | D-01 |
|
||||
| 12-14 | note-widget.tsx (Vorschau) | Kaestchen uebernimmt seinen Handler selbst | D-01 |
|
||||
| 15-16 | calendar-widget.tsx (Tageszelle) | echter Button + Fokus-Handler | D-01 |
|
||||
| 17-20 | vier `noAutofocus`-Stellen | Attribut entfaellt (alle vier sind Seiten, kein Dialog) | D-02 |
|
||||
| 21-23 | calendar-settings-panel.tsx (3 Statussymbole) | `role="img"` + uebersetzte Beschriftung | D-03 |
|
||||
| 24 | calculator-widget.tsx (Speicherzeile) | `role="toolbar"` + uebersetzte Beschriftung | D-03 |
|
||||
| 25 | favorites-widget.tsx (Ansichtsumschalter) | `role="toolbar"` + **richtige** Beschriftung | D-03 |
|
||||
| 26-29 | vier `<img onError>` | `aria-hidden="true"` | D-03 |
|
||||
| 30 | calculator-widget.tsx (Tastatur am Rahmen) | **bleibt stehen, bleibt gezaehlt** | D-07 |
|
||||
|
||||
**Zu D-02:** Keine der vier `autoFocus`-Stellen ist ein Dialog. Es sind vier
|
||||
Seitenformulare (Anmeldung, Passwort-Zuruecksetzen anfordern, Passwort-Zuruecksetzen
|
||||
einloesen, Passwort aendern). D-02 sagt fuer genau diesen Fall: Entfernen ist richtig,
|
||||
weil Fokus-Klauen beim Seitenaufruf das ist, wogegen die Regel existiert. Der
|
||||
ref+Effekt-Zweig von D-02 kommt in diesem Projekt also an keiner Stelle zum Zug — das ist
|
||||
ein Ergebnis, kein Uebersehen. Besonderer Nebennutzen bei `change-password/page.tsx`:
|
||||
unmittelbar ueber dem Formular steht der Hinweisbereich zum erzwungenen Wechsel; heute
|
||||
springt der Fokus daran vorbei, eine Vorlesehilfe liest den Hinweis nie vor.
|
||||
|
||||
**Zu Fundstelle 30 (bleibt stehen):** Der Rahmen des Taschenrechners traegt
|
||||
`role="application"` und einen `onKeyDown`, damit getippte Ziffern ankommen, sobald
|
||||
irgendeine seiner Tasten den Fokus hat. Jede sichtbare Taste ist bereits ein echtes
|
||||
`<button>` und selbst Teil der Tab-Reihenfolge. Der Handler **fuegt einen Tastaturweg
|
||||
hinzu** — er ist das Gegenteil des Schadens, den die Regel beschreibt. Alle drei
|
||||
Rollen-Alternativen wurden gemessen und aendern nichts (siehe Probentabelle).
|
||||
**Ausdruecklich NICHT gewaehlt und auch spaeter nicht nachzuholen:** den Handler per
|
||||
`addEventListener` in einem Effekt anzuhaengen. Das Verhalten waere identisch, nur die
|
||||
Regel saehe ihn nicht mehr — das waere eine geschoente Zahl ohne Gegenwert, und D-07
|
||||
verbietet genau das. Der Befund bleibt sichtbar in der Zaehlung stehen.
|
||||
</decisions_applied>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 1: Aus klickbaren Bereichen echte Schaltflaechen machen (16 Befunde, 5 Dateien)</name>
|
||||
<files>apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx, apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.test.tsx, apps/web/src/components/dashboard/widget-catalog-modal.tsx, apps/web/src/components/dashboard/widget-catalog-modal.test.tsx, apps/web/src/components/settings/calendar-settings-panel.tsx, apps/web/src/components/settings/calendar-settings-panel.test.tsx, apps/web/src/components/dashboard/widgets/note-widget.tsx, apps/web/src/components/dashboard/widgets/note-task-list.tsx, apps/web/src/components/dashboard/widgets/note-widget.test.tsx, apps/web/src/components/dashboard/widgets/note-task-list.test.tsx, apps/web/src/components/dashboard/widgets/calendar-widget.tsx, apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
|
||||
<read_first>
|
||||
Lies vor dem ersten Eingriff den Abschnitt `verified_probes` dieses Plans und die
|
||||
Pitfall-Notiz aus `260921-bi2-SUMMARY.md` (Abschnitt "tech-stack -> patterns"): ein
|
||||
a11y-Fix kann eine ANDERE Regel neu ausloesen, wenn ein Element seine interaktive
|
||||
Einstufung verliert. Miss nach jedem einzelnen Teilumbau die vollstaendige a11y-Menge,
|
||||
nicht nur die Zielregel.
|
||||
</read_first>
|
||||
<behavior>
|
||||
- MarketplaceCard: Tastaturbedienung der Karte oeffnet die Moduldetails; die
|
||||
Aktivieren-Schaltflaeche bleibt ein eigener, separat erreichbarer Tab-Stopp und
|
||||
loest beim Ausloesen NICHT zusaetzlich das Oeffnen aus.
|
||||
- MarketplaceCard, gesperrter Zustand: Tastaturbedienung ruft den Gesperrt-Hinweis auf.
|
||||
- MarketplaceCard, nicht aktiviertes Modul: es gibt keine Kartenschaltflaeche.
|
||||
- widget-catalog-modal: Ausloesen der Hintergrundflaeche schliesst; ein Klick im
|
||||
Dialog schliesst nicht; Escape schliesst weiterhin.
|
||||
- calendar-settings-panel, Loeschdialog: Ausloesen der Hintergrundflaeche **bricht ab**
|
||||
(Datensatz bleibt bestehen), Loeschen geschieht ausschliesslich ueber die
|
||||
Loeschen-Schaltflaeche.
|
||||
- note-widget: Ein Aufgabenkaestchen laesst sich mit der Tastatur umschalten und die
|
||||
zugehoerige Markdown-Zeile kippt; im Bearbeitungsmodus passiert nichts.
|
||||
- calendar-widget: Ein Tag MIT Terminen ist mit der Tastatur fokussierbar und zeigt
|
||||
beim Fokussieren dieselbe Termin-Einblendung wie beim Ueberfahren mit der Maus;
|
||||
beim Verlassen verschwindet sie. Ein Tag OHNE Termine ist kein Tab-Stopp.
|
||||
</behavior>
|
||||
<action>
|
||||
Fuenf Umbauten, jeder einzeln zu committen. Umsetzung von D-01 ueberall ohne den
|
||||
Rueckfallweg, weil dieser messbar eine andere Regel neu ausloesen wuerde (siehe
|
||||
`verified_probes`). D-04 gilt durchgehend: das Erscheinungsbild bleibt gleich.
|
||||
|
||||
**(1) MarketplaceCard.tsx — die Karte.** Der `onClick` sitzt heute auf der Karten-`<div>`,
|
||||
und die Karte enthaelt im Fuss die Aktivieren/Deaktivieren-Schaltflaeche. Eine unmittelbare
|
||||
Umwandlung der Karte in ein `<button>` ist deshalb unmoeglich (verschachtelte
|
||||
Schaltflaechen sind ungueltige Auszeichnung — genau die Falle, in die 260921-gof bei
|
||||
`DropZone.tsx` schon einmal gelaufen ist). Stattdessen: die Karten-`<div>` verliert ihren
|
||||
`onClick` und bekommt `relative`; als Geschwisterelement kommt eine deckende
|
||||
`<button type="button" className="absolute inset-0 rounded-lg …">` hinzu, die
|
||||
`handleCardClick` traegt und nur gerendert wird, wenn `isActive` gilt (heute haengt der
|
||||
Handler ebenfalls an `isActive`). Die Maus-Zeigerform der deckenden Schaltflaeche
|
||||
uebernimmt die Fallunterscheidung der Karte (`cursor-not-allowed` bei `locked`, sonst
|
||||
`cursor-pointer`), damit sich optisch nichts aendert. Der Fussbereich mit der
|
||||
Aktivieren-Schaltflaeche bekommt `relative`, damit er ueber der deckenden Flaeche liegt und
|
||||
weiterhin unmittelbar getroffen wird; das dortige `e.stopPropagation()` bleibt unangetastet.
|
||||
Die deckende Schaltflaeche braucht einen Namen fuer die Vorlesehilfe — verwende den
|
||||
vorhandenen Modulnamen ueber einen neuen Schluessel `marketplace.openDetail` (D-05).
|
||||
Setze `<h3>`/Beschreibung NICHT in die Schaltflaeche hinein: die Ueberschrift kuerzt per
|
||||
`truncate` (also `overflow-hidden`) und wuerde ein darin liegendes Deckelement beschneiden.
|
||||
|
||||
**(2) widget-catalog-modal.tsx — Hintergrund und Dialogflaeche.** Heute traegt die
|
||||
aeussere Flaeche den `onClick={onClose}`, und die Dialogflaeche haelt mit einem
|
||||
`stopPropagation` dagegen. Kehre das um: die aeussere Flaeche verliert ihren Handler
|
||||
ersatzlos; die bereits vorhandene, bislang rein optische Hintergrund-`<div>`
|
||||
(`fixed inset-0 bg-black/50`) wird zu
|
||||
`<button type="button" className="fixed inset-0 bg-black/50" onClick={onClose}>` und
|
||||
verliert dabei ihr `aria-hidden` — ein fokussierbares Element darf nicht vor der
|
||||
Vorlesehilfe verborgen sein. Sie bekommt stattdessen einen echten Namen ueber einen neuen
|
||||
Schluessel `widgets.catalogClose` (D-05). Weil der schliessende Handler danach kein
|
||||
Vorfahr der Dialogflaeche mehr ist, sondern ihr Geschwister, ist das `stopPropagation`
|
||||
auf der Dialogflaeche **toter Code und wird geloescht** — damit fallen die beiden Befunde
|
||||
an dieser Stelle weg, ohne dass sich irgendein Verhalten aendert. Der Escape-Weg im
|
||||
bestehenden Effekt bleibt unveraendert. Ersetze bei dieser Gelegenheit das fest
|
||||
verdrahtete englische `aria-label="Close"` der Schliessen-Schaltflaeche durch
|
||||
`common.close` (D-05) — es steht in der Datei, die du ohnehin umbaust.
|
||||
|
||||
**(3) calendar-settings-panel.tsx — der Loeschbestaetigungs-Dialog.** Dieselbe Gestalt,
|
||||
andere Datei: die `fixed inset-0 … bg-black/50`-Flaeche ist zugleich Hintergrund UND
|
||||
Zentrierbehaelter und prueft im Handler `e.target === e.currentTarget`. Trenne beides: der
|
||||
Behaelter behaelt `fixed inset-0 z-50 flex items-center justify-center` und verliert jeden
|
||||
Handler; die Hintergrundfarbe wandert auf eine neue
|
||||
`<button type="button" className="fixed inset-0 bg-black/50" onClick={() => setDeletingId(null)}>`;
|
||||
die Dialogkarte bekommt `relative`, damit sie weiterhin ueber dem Hintergrund liegt.
|
||||
Beschriftung ueber einen neuen Schluessel `widgets.calendar.deleteDialogCancel`, dessen
|
||||
Text das Abbrechen benennt — dieser Weg darf niemals loeschen (siehe threat_model
|
||||
T-JT4-04). Ersetze ausserdem das fest verdrahtete englische `aria-label="Confirm deletion"`
|
||||
des `role="alertdialog"` durch einen neuen Schluessel
|
||||
`widgets.calendar.deleteDialogLabel` (D-05).
|
||||
|
||||
**(4) note-widget.tsx + note-task-list.tsx — das Aufgabenkaestchen bedient sich selbst.**
|
||||
Heute faengt der Vorschau-Behaelter die Klicks ab (`handlePreviewClick`) und das von
|
||||
`NoteCheckbox` gerenderte Kaestchen traegt `readOnly`. Dreh das um: `NoteCheckbox`
|
||||
bekommt einen `onChange` und gibt darin sein eigenes DOM-Element an einen von aussen
|
||||
gereichten Rueckruf weiter; `readOnly` entfaellt. Die **Index-Ermittlung bleibt Wort fuer
|
||||
Wort die heutige** (alle Kaestchen im Vorschau-Behaelter einsammeln, `indexOf` auf dem
|
||||
ausloesenden Element) — sie wandert lediglich vom Behaelter-Handler in eine Funktion des
|
||||
Widgets, die den Behaelter ueber ein `ref` statt ueber `event.currentTarget` findet.
|
||||
Fasse den bisher nur module-weit gueltigen `PREVIEW_OPTIONS`-Wert in ein `useMemo`, das
|
||||
den Rueckruf ueber ein `useRef` erreicht, damit das Optionsobjekt **identitaetsstabil**
|
||||
bleibt — der bestehende Kommentar begruendet genau das, und ein pro Tastendruck neu
|
||||
erzeugtes Optionsobjekt liesse react-markdown bei jedem Zeichen neu abgleichen.
|
||||
`rehypePlugins: [[rehypeSanitize]]` muss dabei unveraendert erhalten bleiben (XSS-Schranke
|
||||
T-IEX-01, siehe threat_model T-JT4-03). Der `onClick` am Vorschau-Behaelter entfaellt
|
||||
danach ersatzlos. Die Pruefung auf `isEditing` bleibt erhalten. Nebennutzen, den du in der
|
||||
SUMMARY benennen sollst: `readOnly` war bisher nur da, um Reacts Warnung ueber ein
|
||||
gesteuertes Feld ohne `onChange` zu unterdruecken — das Kaestchen bedient sich jetzt
|
||||
tatsaechlich selbst, statt sich von seinem Behaelter bedienen zu lassen.
|
||||
|
||||
**(5) calendar-widget.tsx — die Tageszelle.** Heute traegt jede der 42 Zellen
|
||||
`onMouseEnter`/`onMouseLeave` fuer die Termin-Einblendung. Wer nicht mit der Maus
|
||||
arbeitet, bekommt die Termine eines Tages **gar nicht** zu sehen — das ist der echte
|
||||
Mangel hinter diesem Befund. Rendere die Zelle als `<button type="button">`, **wenn und
|
||||
nur wenn sie Termine hat** (`hasEvents`), sonst unveraendert als `<div>` ohne Handler.
|
||||
Nur Tage mit Terminen werden so zu Tab-Stopps; ein Widget mit 42 neuen Tab-Stopps waere
|
||||
eine Verschlechterung. Die Schaltflaechen-Variante traegt zusaetzlich `onFocus`/`onBlur`
|
||||
mit demselben Rumpf wie `onMouseEnter`/`onMouseLeave`, damit die Einblendung fuer die
|
||||
Tastatur genauso erscheint und verschwindet. Uebernimm `cellClass` unveraendert und
|
||||
ergaenze nur das, was ein `<button>` braucht, um wie die bisherige `<div>` auszusehen
|
||||
(Textausrichtung, volle Breite, kein geerbter Schaltflaechenrahmen) — `widgetNoDrag` muss
|
||||
erhalten bleiben, sonst reisst react-grid-layout die Kachel beim Klicken mit.
|
||||
Die Schaltflaeche bekommt einen Namen, der Datum und Terminzahl nennt; folge der im
|
||||
Katalog bereits vorhandenen Mehrzahl-Konvention dieses Projekts mit zwei getrennten
|
||||
Schluesseln (`widgets.calendar.dayEventsOne` / `widgets.calendar.dayEventsMany`, Vorbild
|
||||
`configMaxEventsOne`/`configMaxEventsMany`), nicht mit ICU-Plural (D-05).
|
||||
|
||||
**Uebersetzungen:** Jeder neue Schluessel kommt in `de.json` UND `en.json`, deutsche Texte
|
||||
in der Sie-Form (D-05). Keine neuen Pakete, keine Versionsanhebung, keine Umformatierung
|
||||
fremder Dateien (D-06).
|
||||
|
||||
**Tests — Tastaturereignis schlaegt Auszeichnungsbehauptung.** Erweitere bzw. lege an:
|
||||
`MarketplaceCard.test.tsx` (vorhanden), `widget-catalog-modal.test.tsx` (neu),
|
||||
`calendar-settings-panel.test.tsx` (neu), `note-widget.test.tsx` und
|
||||
`note-task-list.test.tsx` (vorhanden), `calendar-widget.test.tsx` (vorhanden).
|
||||
`@testing-library/user-event` ist in `apps/web` verfuegbar (14.6.1) — nutze es, um nach
|
||||
`.focus()` eine echte Tastaturbetaetigung auszuloesen, statt einen Klick zu senden und
|
||||
Tastaturbedienung nur zu behaupten. Fuer das Notiz-Kaestchen ist die Tastaturbetaetigung
|
||||
gerade der Punkt: ein fokussiertes Kaestchen muss sich mit der Leertaste kippen lassen.
|
||||
Fuer den Loeschdialog muss ein Test belegen, dass der Hintergrundweg abbricht und die
|
||||
Quelle **nicht** geloescht wird.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && npx biome lint . --reporter=json 2>/dev/null | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const d=JSON.parse(s).diagnostics||[];const a=d.filter(x=>/a11y\/(noNoninteractiveElementInteractions|noStaticElementInteractions|useKeyWithClickEvents)/.test(x.category));a.forEach(x=>console.log(' ',x.category.replace('lint/a11y/',''),x.location.path+':'+x.location.start.line));console.log('klick-regeln',a.length,'| semantic',d.filter(x=>x.category==='lint/a11y/useSemanticElements').length,'| a11y gesamt',d.filter(x=>x.category.includes('a11y')).length,'| gesamt',d.length,'| errors',d.filter(x=>x.severity==='error').length);})"</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/web exec vitest run 2>&1 | tail -8</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm type-check 2>&1 | tail -4</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Erste Pruefung meldet `klick-regeln 5` — genau die vier `<img onError>`-Stellen
|
||||
(`header.tsx`, `account-settings-form.tsx`, `favorites-widget.tsx` zweimal) und der
|
||||
Taschenrechner-Rahmen; `semantic 0` (die Regel ist NICHT gewachsen); `a11y gesamt 14`;
|
||||
`errors 0`. `apps/web`-Tests gruen mit mindestens 69 Dateien und mindestens 484 Tests
|
||||
(die neuen Faelle kommen obendrauf, die Zahl darf nur steigen). `pnpm type-check` 4/4.
|
||||
Fuer jede der fuenf Umbauten steht fest, welcher Weg gewaehlt wurde; der Rueckfallweg aus
|
||||
D-01 wurde an keiner Stelle benutzt.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Aufgabe 2: Rollen, Beschriftungen, Autofokus — und zwei Restposten (14 Befunde)</name>
|
||||
<files>apps/web/src/app/(auth)/login/page.tsx, apps/web/src/app/(auth)/reset-password/page.tsx, apps/web/src/app/(auth)/reset-password/[token]/page.tsx, apps/web/src/app/(portal)/change-password/page.tsx, apps/web/src/components/settings/calendar-settings-panel.tsx, apps/web/src/components/dashboard/widgets/calculator-widget.tsx, apps/web/src/components/dashboard/widgets/favorites-widget.tsx, apps/web/src/components/layout/header.tsx, apps/web/src/components/settings/account-settings-form.tsx, apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx, apps/web/src/app/(portal)/modules/cert-manager/zip-filename.ts, apps/web/src/app/(portal)/modules/cert-manager/zip-filename.test.ts, apps/api/src/tenders/tender-normalizer.service.ts, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
|
||||
<action>
|
||||
Attributarbeit ohne Strukturumbau — deutlich geringeres Risiko als Aufgabe 1, deshalb
|
||||
getrennt. Dazu zwei der vier vermerkten Restposten.
|
||||
|
||||
**(1) Vier `autoFocus` entfernen (D-02).** `login/page.tsx`, `reset-password/page.tsx`,
|
||||
`reset-password/[token]/page.tsx`, `change-password/page.tsx`. Der Planer hat alle vier
|
||||
gelesen: es sind Seitenformulare, keine Dialoge — D-02 verlangt hier das Entfernen und
|
||||
ausdruecklich NICHT den Ersatz durch ref+Effekt. Ein Ersatz per Effekt waere dasselbe
|
||||
Verhalten mit stillgelegter Regel. Entferne ausschliesslich das Attribut; `autoComplete`,
|
||||
`required` und alles andere bleibt. Halte in der SUMMARY je Stelle fest, dass es eine
|
||||
Seite und kein Dialog war.
|
||||
|
||||
**(2) Drei Statussymbole in `calendar-settings-panel.tsx` (D-03).** Die drei `<span>` mit
|
||||
`aria-label="Sync error"` / `"Connection OK"` / `"Connection error"` haben die Rolle
|
||||
`generic` und ihre Beschriftung wird stillschweigend verworfen. Die Beschriftung traegt
|
||||
hier echte Bedeutung — sie ist der EINZIGE Text dieser Symbole (das `<svg>` darin ist
|
||||
bereits `aria-hidden`). Also Ursache beheben, nicht Attribut streichen: `role="img"`
|
||||
ergaenzen (geprueft sauber). Die drei englischen Texte in einer deutschen Oberflaeche
|
||||
gehen dabei in den Katalog (D-05): fuer den Erfolgsfall ist der vorhandene Schluessel
|
||||
`widgets.calendar.connectionSuccess` woertlich passend und wird wiederverwendet; fuer die
|
||||
beiden Fehlerfaelle lege kurze eigene Schluessel an (`widgets.calendar.syncErrorLabel`,
|
||||
`widgets.calendar.connectionFailedLabel`) — der vorhandene `connectionError` ist ein
|
||||
ganzer Hinweissatz und als Symbolbeschriftung zu lang. Das `title`-Attribut mit dem
|
||||
Rohfehler bleibt unangetastet.
|
||||
|
||||
**(3) Zwei Schaltflaechenreihen (D-03).** `calculator-widget.tsx`, Speicherzeile
|
||||
(`aria-label="Speicherfunktionen"`) und `favorites-widget.tsx`, Ansichtsumschalter
|
||||
(`aria-label={t('favorites.name')}`): beide sind schlichte `<div>`, die Beschriftung
|
||||
verpufft. Ergaenze `role="toolbar"` (geprueft sauber — `role="group"` und `role="region"`
|
||||
scheiden aus, sie loesen `useSemanticElements` neu aus). Beim Taschenrechner wandert der
|
||||
fest verdrahtete deutsche Text in den Katalog (`widgets.calculator.memoryLabel`). Beim
|
||||
Favoriten-Umschalter ist die heutige Beschriftung sachlich falsch — sie sagt "Favoriten"
|
||||
ueber einem Umschalter zwischen Listen- und Kachelansicht; vergib einen neuen, zutreffenden
|
||||
Schluessel `widgets.favorites.viewModeLabel`. Das ist der Fall, in dem D-03 das Streichen
|
||||
erlauben wuerde (die Beschriftung trug keine echte Bedeutung); eine richtige Beschriftung
|
||||
ist trotzdem besser als gar keine. Uebersetze im Taschenrechner bei dieser Gelegenheit die
|
||||
beiden weiteren fest verdrahteten deutschen Beschriftungen derselben Ansicht (Anzeigefeld,
|
||||
Rueckschritt-Taste) in Katalogschluessel (D-05) — Grenze der Ausweitung: nur
|
||||
Beschriftungen in Dateien, die dieser Vorgang ohnehin aendert.
|
||||
|
||||
**(4) Vier `<img onError>` (D-03).** `header.tsx`, `account-settings-form.tsx` und
|
||||
zweimal `favorites-widget.tsx`. Ergaenze `aria-hidden="true"`. **Sei in der SUMMARY
|
||||
ehrlich darueber, was das leistet und was nicht:** alle vier tragen bereits `alt=""`, sind
|
||||
also schon aus dem Zugaenglichkeitsbaum genommen; `aria-hidden` sagt dasselbe nur
|
||||
ausdruecklich. Es ist richtige Auszeichnung, aber es verbessert fuer keinen Menschen
|
||||
etwas — der Befund verschwindet, weil die Regel ein verborgenes Element nicht mehr
|
||||
betrachtet. `onError` ist ein Ladefehler, keine Bedienung: hier gab es nie einen
|
||||
Tastaturweg zu schaffen. Keine Unterdrueckung, kein `biome-ignore`.
|
||||
|
||||
**(5) Restposten 2 — `tender-normalizer.service.ts`, `noUselessSwitchCase`.** Nachgelesen:
|
||||
die Fallmarke `case 'doe-opendata':` steht unmittelbar ueber `default:` und faellt in
|
||||
denselben Zweig; der Kommentar darunter erklaert, warum der Standardzweig auf dem
|
||||
DOE-Weg bleiben muss. 260921-bi2 hat sie stehen lassen, weil sie Absicht dokumentiert.
|
||||
Diese Absicht laesst sich ohne die ueberfluessige Marke ausdruecken und wird dabei sogar
|
||||
deutlicher: entferne die Fallmarke und erweitere den bestehenden Kommentar so, dass er
|
||||
beide Aussagen traegt — dass die DOE-Quelle hier landet UND dass kuenftige additive
|
||||
Mitglieder der SourceType-Vereinigung ebenfalls hier landen sollen, statt zu scheitern.
|
||||
Kein Verhaltenswechsel: der Zweig, in den `'doe-opendata'` faellt, ist vorher wie nachher
|
||||
derselbe. Belege das mit den vorhandenen `apps/api`-Tests.
|
||||
|
||||
**(6) Restposten 1 — ZIP-Name im Zertifikat-Aufteiler.** `downloadAllAsZip` in
|
||||
`SplitTab.tsx` ist eine Funktion ausserhalb der Komponente und kann den
|
||||
Uebersetzungs-Hook nicht aufrufen; reiche den Namen deshalb als Parameter herein und
|
||||
uebergib an der Aufrufstelle `t('actions.zipFilename')`. Neuer Schluessel unter
|
||||
`certManager.actions` in beiden Katalogen: deutsch `Zertifikate.zip`, englisch
|
||||
`certificates.zip`.
|
||||
|
||||
Der Einwand aus 260921-bi2 war, ein uebersetzter Name koenne Umlaute auf eine
|
||||
Windows-Freigabe tragen. Neuer Befund: das deutsche Wort fuer Zertifikate enthaelt keinen
|
||||
Umlaut und kein von Windows verbotenes Zeichen, der Einwand trifft fuer diesen konkreten
|
||||
Text also nicht zu. **Verlass dich aber nicht darauf, dass das Wort zufaellig harmlos
|
||||
ist** — sonst haengt die Dateisystem-Sicherheit an einer kuenftigen
|
||||
Uebersetzungsentscheidung. Lege `zip-filename.ts` mit einer kleinen, fuer sich pruefbaren
|
||||
Schutzfunktion an, die einen Namen auf das fuer eine Windows-Freigabe Zulaessige
|
||||
zurueckschneidet: die von Windows verbotenen Zeichen und Steuerzeichen ersetzen,
|
||||
Nicht-ASCII ersetzen, abschliessende Punkte und Leerzeichen entfernen, die reservierten
|
||||
Geraetenamen abfangen, bei leerem Ergebnis auf `certificates.zip` zurueckfallen und die
|
||||
Endung sicherstellen. `SplitTab.tsx` schickt den uebersetzten Namen durch diese Funktion,
|
||||
bevor er am Download landet. Decke die Funktion in `zip-filename.test.ts` ab: der
|
||||
deutsche und der englische Katalogwert kommen unveraendert durch, ein Name mit Umlaut und
|
||||
einer mit verbotenem Zeichen werden bereinigt, ein aussichtsloser Name faellt auf den
|
||||
Ersatznamen zurueck. Die Namen der einzelnen Dateien IM Archiv stammen aus der API und
|
||||
bleiben unangetastet.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && npx biome lint . --reporter=json 2>/dev/null | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const d=JSON.parse(s).diagnostics||[];const a=d.filter(x=>x.category.includes('a11y'));a.forEach(x=>console.log(' ',x.category.replace('lint/a11y/',''),x.location.path+':'+x.location.start.line));console.log('a11y',a.length,'| switch',d.filter(x=>x.category==='lint/complexity/noUselessSwitchCase').length,'| gesamt',d.length,'| errors',d.filter(x=>x.severity==='error').length);})"</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && node -e "const f=o=>Object.entries(o).flatMap(([k,v])=>v&&typeof v==='object'?f(v).map(x=>k+'.'+x):[k]);const de=f(require('./apps/web/src/messages/de.json')),en=f(require('./apps/web/src/messages/en.json'));const A=new Set(de),B=new Set(en);console.log('de',de.length,'en',en.length,'nurDe',de.filter(k=>!B.has(k)).join(',')||'-','nurEn',en.filter(k=>!A.has(k)).join(',')||'-');"</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/web exec vitest run zip-filename 2>&1 | tail -6</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/api exec vitest run 2>&1 | tail -6</automated>
|
||||
</verify>
|
||||
<done>
|
||||
Erste Pruefung meldet `a11y 1` und die einzige verbleibende Zeile nennt
|
||||
`noNoninteractiveElementInteractions` in `calculator-widget.tsx`; `switch 0`;
|
||||
`gesamt 399`; `errors 0`. Katalogpruefung: beide Kataloge gleich lang, weder `nurDe` noch
|
||||
`nurEn` nennt einen Schluessel. `zip-filename`-Tests gruen. `apps/api`-Tests gruen mit
|
||||
mindestens 72 Dateien und mindestens 1143 Tests. Weicht `gesamt` von 399 ab, ist das kein
|
||||
stiller Durchlauf: nenne die Abweichung nach Regel aufgeschluesselt und ihre Ursache.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 3: Verschwendete Abrufe — Kalender-Ladefenster und die vier t-Abhaengigkeiten</name>
|
||||
<files>apps/web/src/components/dashboard/widgets/calendar-widget.tsx, apps/web/src/components/dashboard/widgets/calendar-widget.test.tsx, apps/web/src/lib/translations-identity.test.tsx</files>
|
||||
<read_first>
|
||||
`apps/web/src/components/dashboard/widgets/calendar-month.ts`, Funktion
|
||||
`computeFetchWindow` samt Kommentar: das Ladefenster ist die **Vereinigung** aus
|
||||
42-Tage-Raster und Vorschauzeitraum, und alle vier Zwischenwerte sind auf lokale
|
||||
Tagesgrenzen gerundet, damit der Cache-Schluessel des Backends ueber den
|
||||
5-Minuten-Auffrischer stabil bleibt. Diese Eigenschaft darf der Umbau nicht verlieren.
|
||||
</read_first>
|
||||
<behavior>
|
||||
- Beim Aufbau der Kachel wird die Quellenliste genau einmal geholt.
|
||||
- Ein Monatswechsel holt die Quellenliste NICHT erneut.
|
||||
- Ein Monatswechsel, dessen berechnetes Ladefenster mit dem zuletzt geholten
|
||||
uebereinstimmt, loest KEINEN Termin-Abruf aus.
|
||||
- Ein Monatswechsel mit abweichendem Ladefenster loest genau einen Termin-Abruf aus.
|
||||
- Der 5-Minuten-Auffrischer holt weiterhin beides, auch wenn sich nichts geaendert
|
||||
hat — sonst friert die Anzeige ein.
|
||||
</behavior>
|
||||
<action>
|
||||
**(1) Das doppelte Ladefenster (Restposten 3a).** Der Planer hat nachgerechnet, woher die
|
||||
Beobachtung des Orchestrators kommt: `computeFetchWindow` nimmt `from` als den frueheren
|
||||
von Rasteranfang und heutigem Tagesbeginn und `to` als den spaeteren von Rasterende und
|
||||
Vorschauhorizont. Reicht der Vorschauhorizont ueber das Rasterende hinaus, ergeben **zwei
|
||||
benachbarte kuenftige Monate exakt dasselbe Fenster** — bei der Vorschau-Einstellung
|
||||
90 Tage trifft das fuer Oktober und November zu, bei der Voreinstellung 30 Tage nicht.
|
||||
Das erklaert, warum der Effekt nur unter bestimmten Einstellungen sichtbar ist, und es ist
|
||||
der Grund, warum die Browser-Messung unten ausdruecklich auf 90 Tage gestellt werden muss.
|
||||
|
||||
Die Vereinigung selbst ist richtig und bleibt — die Kachel zeigt Monatsraster UND
|
||||
Terminvorschau. Verschwendet wird nur der erneute Abruf eines bereits geholten Bereichs.
|
||||
Merke dir deshalb in einem `ref` das zuletzt tatsaechlich geholte `from`/`to`-Paar (als
|
||||
die beiden ISO-Zeichenketten, die auch an die API gehen) und ueberspringe den
|
||||
Termin-Abruf, wenn das neu berechnete Paar damit uebereinstimmt. Der Sperrgriff gilt
|
||||
**nur fuer den durch den Monatswechsel ausgeloesten Lauf**; der 5-Minuten-Auffrischer
|
||||
muss unbedingt weiter abrufen, auch bei gleichem Fenster, sonst veraltet die Anzeige
|
||||
still. Gib `loadData` dazu einen Parameter, der das Erzwingen ausdrueckt, und uebergib ihn
|
||||
aus dem Intervall. Achte darauf, dass der uebersprungene Lauf den Ladezustand trotzdem
|
||||
sauber beendet und die bereits geladenen Termine nicht leert.
|
||||
|
||||
**(2) Die Quellenliste bei jedem Monatswechsel (Restposten 3b).** `loadData` ruft heute
|
||||
bei jedem Lauf zuerst `fetchSources()` auf, obwohl die Quellenliste nicht vom angezeigten
|
||||
Monat abhaengt. Halte das Ergebnis in einem `ref` fest und hole die Liste nur, wenn sie
|
||||
noch unbekannt ist ODER der Lauf erzwungen wurde (also beim Aufbau und beim
|
||||
5-Minuten-Auffrischer). Damit bemerkt die Kachel eine neu eingerichtete Quelle weiterhin
|
||||
innerhalb von fuenf Minuten — das heutige Verhalten bleibt also erhalten, nur der
|
||||
Monatswechsel hoert auf, unnoetig zu fragen. Der Sonderfall "gar keine Quellen
|
||||
eingerichtet" muss sich genauso verhalten wie heute.
|
||||
|
||||
Beides sind keine Verhaltensdefekte, sondern Verschwendung — aber sie reicht durch die API
|
||||
bis zu einem echten Exchange-Server durch (siehe threat_model T-JT4-02). Aendere nichts an
|
||||
`computeFetchWindow` selbst und nichts an der Tagesgrenzen-Rundung.
|
||||
|
||||
**(3) Tests fuer beides.** `calendar-widget.test.tsx` ist vorhanden und hat die
|
||||
Abruf-Attrappen bereits eingerichtet. Ergaenze Faelle, die die Aufrufe der Attrappen
|
||||
**zaehlen**: Aufbau (je 1), Monatswechsel mit abweichendem Fenster (Termine +1, Quellen
|
||||
+0), Monatswechsel mit identischem Fenster (beide +0), und ein erzwungener Lauf ueber den
|
||||
Zeitgeber (beide +1) — fuer den letzten Fall die Zeitgeber-Attrappe von Vitest nutzen.
|
||||
Das identische Fenster stellst du her, indem du die Kachel mit der Vorschau-Einstellung
|
||||
90 Tage renderst und um einen Monat weiterschaltest.
|
||||
|
||||
**(4) Restposten 4 — die vier `t`-Abhaengigkeiten.** Der Planer hat die Annahme
|
||||
ueberprueft, auf der 260921-gof beruhte ("`t` ist in diesem Projekt bei jedem Render eine
|
||||
frische Funktion"), und sie im Quelltext der eingesetzten Fassung widerlegt: `use-intl`
|
||||
4.13.0 erzeugt `t` in einem `useMemo`, dessen Abhaengigkeiten ausschliesslich aus dem
|
||||
Intl-Kontext stammen, und der Anbieter steht in `app/layout.tsx`, also oberhalb aller
|
||||
betroffenen Komponenten. Ein Zustandswechsel in einer dieser Komponenten rendert den
|
||||
Anbieter nicht neu, also behaelt `t` seine Identitaet, also bleiben die davon abhaengigen
|
||||
Rueckrufe stabil, also laeuft der Effekt nicht erneut. Dazu passt die bereits erbrachte
|
||||
Messung aus dem gof-Nachtrag: der Marktplatz holt `modules/catalog` in 20 Sekunden genau
|
||||
einmal.
|
||||
|
||||
**Belege das, statt es zu behaupten**, und zwar einmal an der Wurzel statt viermal an den
|
||||
Symptomen: lege `translations-identity.test.tsx` an, das eine kleine Testkomponente unter
|
||||
dem echten `NextIntlClientProvider` rendert, einen Zustandswechsel in der Komponente
|
||||
ausloest und festhaelt, dass `t` vor und nach dem erneuten Render dasselbe Objekt ist.
|
||||
Dieser eine Test entscheidet alle vier Stellen auf einmal, weil der Schadensmechanismus
|
||||
ueberall derselbe ist.
|
||||
|
||||
Danach entscheide nach Messlage, nicht nach Gewohnheit:
|
||||
- **Bleibt die Identitaet erhalten** (erwartet), dann aendere an den vier Stellen
|
||||
**nichts**. Halte in der SUMMARY fest, dass die Faustregel aus gof ("`t` gehoert in
|
||||
keine Abhaengigkeitsliste") als Konvention in Ordnung bleibt, die vier verbliebenen
|
||||
Stellen aber nachweislich keinen zusaetzlichen Abruf ausloesen — und korrigiere dabei
|
||||
ausdruecklich die zugrunde liegende Annahme, damit sie nicht ein drittes Mal
|
||||
weitergetragen wird.
|
||||
- **Faellt die Identitaet doch**, dann wende an den beiden Stellen, die in einem Effekt
|
||||
landen (`marketplace/page.tsx`, `admin/users/page.tsx`), die aus gof bekannte Technik an
|
||||
(uebersetzten Text vor dem Rueckruf in eine Konstante ziehen und von der Konstante
|
||||
abhaengen). Die beiden anderen (`calendar-settings-panel.tsx`,
|
||||
`calendar-source-form.tsx`) bleiben in jedem Fall unveraendert: ihre Rueckrufe haengen
|
||||
an KEINEM Effekt, sondern werden als Ereignisbehandlung weitergereicht — eine wechselnde
|
||||
Identitaet kostet dort nichts. Nenne diesen strukturellen Grund in der SUMMARY.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/web exec vitest run calendar-widget translations-identity 2>&1 | tail -10</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && npx biome lint . --reporter=json 2>/dev/null | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const d=JSON.parse(s).diagnostics||[];console.log('gesamt',d.length,'| a11y',d.filter(x=>x.category.includes('a11y')).length,'| exhaustive',d.filter(x=>x.category==='lint/correctness/useExhaustiveDependencies').length,'| errors',d.filter(x=>x.severity==='error').length);})"</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm -C apps/web exec vitest run 2>&1 | tail -8 && pnpm type-check 2>&1 | tail -4 && pnpm lint --force 2>&1 | tail -4</automated>
|
||||
<human-check>
|
||||
Browser-Messung durch den Orchestrator (der Ausfuehrende hat keinen Browser). Instrument
|
||||
ist das Netzwerkprotokoll des Browsers ueber Playwright, **niemals ein `fetch` aus der
|
||||
Seite heraus** (siehe Merkposten "Browser-Pruefung: fetch-Falle"). Vorbereitung: Stack aus
|
||||
dem aktuellen Stand bauen, anmelden, Kalender-Kachel aufs Dashboard legen und ihre
|
||||
Vorschau auf **90 Tage** stellen — bei der Voreinstellung 30 Tage tritt das doppelte
|
||||
Ladefenster rechnerisch gar nicht auf, die Messung waere dann nichtssagend.
|
||||
|
||||
Messung: Netzwerkprotokoll leeren, dann dreimal "Weiter" druecken.
|
||||
|
||||
Erwartung nachher:
|
||||
- `calendar/sources`: genau **1** (vorher 3).
|
||||
- `calendar/events`: **weniger als 3**, und unter den abgesetzten Anfragen tragen keine
|
||||
zwei dasselbe `from`/`to`-Paar in der Abfragezeichenkette (vorher 3, darunter ein
|
||||
identisches Paar fuer zwei benachbarte Monate).
|
||||
- Gegenprobe gegen Einfrieren: rund fuenf Minuten ruhen lassen; danach muss je ein
|
||||
weiterer `calendar/events`- und `calendar/sources`-Abruf erscheinen.
|
||||
|
||||
Datenbank und Modul-Aktivierung so hinterlassen, wie sie vorgefunden wurden; Stack danach
|
||||
stoppen.
|
||||
</human-check>
|
||||
</verify>
|
||||
<done>
|
||||
`calendar-widget`-Tests belegen durch Zaehlung: Aufbau je 1 Abruf, Monatswechsel mit
|
||||
abweichendem Fenster +1 Termine/+0 Quellen, Monatswechsel mit identischem Fenster +0/+0,
|
||||
erzwungener Lauf +1/+1. `translations-identity.test.tsx` ist gruen und haelt die
|
||||
Identitaetsfrage fuer `t` fest. `gesamt 399`, `a11y 1`, `exhaustive 0`, `errors 0`.
|
||||
`apps/web` mindestens 69 Dateien / 484 Tests, `pnpm type-check` 4/4, `pnpm lint --force`
|
||||
5/5. Fuer jede der vier `t`-Stellen steht in der SUMMARY, ob sie geaendert wurde und
|
||||
warum beziehungsweise warum nicht.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Mensch -> Oberflaeche | Bedienung per Tastatur bzw. Vorlesehilfe statt per Maus — die eigentliche Grenze dieses Vorgangs |
|
||||
| Notizinhalt -> Markdown-Darstellung | Vom Nutzer eingegebener Text wird gerendert; `rehypeSanitize` ist die Schranke |
|
||||
| Web -> API -> Exchange | Jeder Termin-Abruf der Kachel erreicht ueber die eigene API einen fremden Exchange-Server |
|
||||
| Katalogtext -> Dateisystem der Gegenstelle | Ein uebersetzter Name landet als Dateiname auf einer Windows-Freigabe |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-JT4-01 | Denial of Service | alle 21 klickgebundenen Fundstellen | medium | mitigate | Bedienung, die es nur mit der Maus gibt, schliesst Tastatur- und Vorlesehilfe-Nutzung aus. Jede echte Bedienstelle wird ein `<button>`; belegt durch Komponententests, die eine Tastaturbetaetigung ausloesen, nicht durch Auszeichnungs-Behauptungen. |
|
||||
| T-JT4-02 | Denial of Service | `calendar-widget.tsx` -> API -> Exchange | medium | mitigate | Der Sperrgriff darf nur den Monatswechsel sperren, nie den 5-Minuten-Auffrischer, sonst friert die Anzeige ein (Verfuegbarkeitsschaden statt Ersparnis). Vier zaehlende Testfaelle decken beide Richtungen ab; die Tagesgrenzen-Rundung von `computeFetchWindow` bleibt unangetastet, damit der Backend-Cache-Schluessel stabil bleibt. |
|
||||
| T-JT4-03 | Tampering | `note-widget.tsx` -> `previewOptions` -> `rehypeSanitize` | high | mitigate | Der Umbau des Kaestchen-Handlers fasst das Optionsobjekt neu; faellt dabei `rehypePlugins: [[rehypeSanitize]]` heraus, kehrt die XSS-Luecke T-IEX-01 zurueck. Der Eintrag muss woertlich erhalten bleiben; ein Testfall muss belegen, dass eingebettete Auszeichnung im Vorschaumodus weiterhin entschaerft wird. |
|
||||
| T-JT4-04 | Tampering | `calendar-settings-panel.tsx`, Loeschdialog | high | mitigate | Der Hintergrund des Loeschdialogs wird zu einer echten, fokussierbaren Schaltflaeche und rueckt damit in die Tab-Reihenfolge. Sie MUSS abbrechen und darf unter keinen Umstaenden loeschen; ihre Beschriftung benennt das Abbrechen. Ein Testfall belegt, dass nach diesem Weg die Quelle noch existiert. |
|
||||
| T-JT4-05 | Tampering | `zip-filename.ts` -> Windows-Freigabe | low | mitigate | Ein uebersetzter Dateiname kann Zeichen tragen, die Windows verbietet. Eine fuer sich getestete Schutzfunktion schneidet jeden Namen auf das Zulaessige zurueck und faellt notfalls auf einen sicheren Ersatznamen zurueck — die Sicherheit haengt damit nicht an der Wortwahl einer kuenftigen Uebersetzung. |
|
||||
| T-JT4-06 | Information Disclosure | `widget-catalog-modal.tsx` | low | accept | Die Hintergrundflaeche verliert ihr `aria-hidden`, weil ein fokussierbares Element nicht vor der Vorlesehilfe verborgen sein darf. Sie erhaelt dadurch eine angesagte Beschriftung — gewollt, kein Informationsabfluss: der Dialoginhalt war ohnehin sichtbar. |
|
||||
| T-JT4-SC | Tampering | npm/pip/cargo-Installationen | n/a | accept | Dieser Vorgang installiert kein Paket und hebt keine Version an (D-06). Es gibt keine Installationsaufgabe, daher greift das Paket-Echtheitstor nicht; `pnpm-lock.yaml` und alle `package.json` duerfen im Diff nicht vorkommen. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Abschliessend, nach allen drei Aufgaben:
|
||||
|
||||
```bash
|
||||
cd /home/vicolab/projects/tessera-ctl
|
||||
|
||||
# 1) Befundstand nach Regel
|
||||
npx biome lint . --reporter=json 2>/dev/null | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const d=JSON.parse(s).diagnostics||[];const a=d.filter(x=>x.category.includes('a11y'));a.forEach(x=>console.log(' a11y',x.category.replace('lint/a11y/',''),x.location.path+':'+x.location.start.line));console.log('gesamt',d.length,'| a11y',a.length,'| errors',d.filter(x=>x.severity==='error').length);})"
|
||||
# erwartet: gesamt 399 | a11y 1 | errors 0
|
||||
# die eine a11y-Zeile nennt calculator-widget.tsx
|
||||
|
||||
# 2) Keine Unterdrueckung als Abkuerzung eingeschleust
|
||||
git diff --unified=0 $(git rev-parse HEAD) -- . | grep -c '^+.*biome-ignore' || true
|
||||
# erwartet: 0 (dieser Vorgang fuegt keine einzige neue Unterdrueckung hinzu)
|
||||
|
||||
# 3) Kataloge im Gleichstand
|
||||
node -e "const f=o=>Object.entries(o).flatMap(([k,v])=>v&&typeof v==='object'?f(v).map(x=>k+'.'+x):[k]);const de=f(require('./apps/web/src/messages/de.json')),en=f(require('./apps/web/src/messages/en.json'));const A=new Set(de),B=new Set(en);console.log('de',de.length,'en',en.length,'nurDe',de.filter(k=>!B.has(k)).join(',')||'-','nurEn',en.filter(k=>!A.has(k)).join(',')||'-');"
|
||||
|
||||
# 4) Tore und Testbestand
|
||||
pnpm -C apps/web exec vitest run 2>&1 | tail -6 # >= 69 Dateien, >= 484 Tests
|
||||
pnpm -C apps/api exec vitest run 2>&1 | tail -6 # >= 72 Dateien, >= 1143 Tests
|
||||
pnpm type-check 2>&1 | tail -4 # 4/4
|
||||
pnpm lint --force 2>&1 | tail -4 # 5/5, 0 error
|
||||
|
||||
# 5) Kein Paket, keine Version, kein Fremdumbruch
|
||||
git diff --stat $(git rev-parse HEAD) -- pnpm-lock.yaml '**/package.json'
|
||||
# erwartet: leer
|
||||
```
|
||||
|
||||
Die Zaehlungen stammen ausschliesslich aus dem Feld `category` der Biome-JSON-Ausgabe,
|
||||
niemals aus einem Textgriff in den Quelltext — ein Kommentar, der den Namen einer Regel
|
||||
nennt, wuerde eine Textzaehlung sonst selbst verfaelschen.
|
||||
|
||||
**Schritt des Orchestrators (nicht des Ausfuehrenden):** die Browser-Zaehlung aus dem
|
||||
`human-check` in Aufgabe 3. Der Ausfuehrende hat keinen Browser und darf dieses Ergebnis
|
||||
nicht behaupten.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Biome: 399 Befunde gesamt, 0 der Stufe error, genau 1 a11y-Befund
|
||||
(`calculator-widget.tsx`, begruendet stehengelassen), `useSemanticElements` unveraendert
|
||||
bei 0, `noUselessSwitchCase` bei 0.
|
||||
- Keine einzige neue `biome-ignore`-Zeile im gesamten Diff dieses Vorgangs.
|
||||
- 29 der 30 a11y-Fundstellen behoben, jede mit protokolliertem Weg; fuer die eine
|
||||
stehengelassene ist begruendet, warum sie bleibt und warum die Umgehung per
|
||||
`addEventListener` ausdruecklich nicht gewaehlt wurde.
|
||||
- Tastaturbedienung ist durch Tests mit echten Tastaturereignissen belegt, nicht durch
|
||||
Auszeichnungsbehauptungen.
|
||||
- Kalender-Kachel: Quellenliste 1 statt 3 pro drei Monatswechseln, kein Termin-Abruf mit
|
||||
wiederholtem `from`/`to`-Paar, 5-Minuten-Auffrischer nachweislich unberuehrt.
|
||||
- ZIP-Name uebersetzt UND durch eine getestete Schutzfunktion auf Windows-Freigaben
|
||||
gueltig.
|
||||
- Die vier `t`-Abhaengigkeiten sind durch Messung entschieden, nicht durch Gewohnheit; die
|
||||
zugrunde liegende Annahme aus gof ist ausdruecklich korrigiert oder bestaetigt.
|
||||
- `apps/web` >= 69 Dateien / >= 484 Tests, `apps/api` >= 72 / >= 1143, `pnpm type-check`
|
||||
4/4, `pnpm lint --force` 5/5.
|
||||
- Keine neue Abhaengigkeit, keine Versionsanhebung, kein repo-weiter Umbruch (D-06).
|
||||
</success_criteria>
|
||||
|
||||
<commit_hygiene>
|
||||
Ein Commit je Teilumbau, nicht ein Sammelcommit je Aufgabe — das ist der Grund, warum die
|
||||
Umbauten oben einzeln nummeriert sind.
|
||||
|
||||
**Fallstrick, der heute viermal zugeschlagen hat:** Das Write-Werkzeug wandelt Folgen der
|
||||
Form `\uXXXX` im Inhalt still in das tatsaechliche Zeichen um. So sind rohe Steuerzeichen
|
||||
in einen Plan, in Commit-Nachrichten und in eine SUMMARY geraten, woraufhin `git commit`
|
||||
die Annahme verweigert hat. Erzeuge solche Folgen ueber `python3` mit `chr(92)` und pruefe
|
||||
die Rohbytes danach nach, bevor du committest.
|
||||
</commit_hygiene>
|
||||
|
||||
<output>
|
||||
Erstelle `.planning/quick/260921-jt4-barrierefreiheit-mit-bedienentscheidunge/260921-jt4-SUMMARY.md`,
|
||||
wenn alle drei Aufgaben abgeschlossen sind. Die SUMMARY muss enthalten:
|
||||
|
||||
1. Eine Tabelle aller 30 a11y-Fundstellen mit gewaehltem Weg (echter Button /
|
||||
role+Tastatur / Fokus-auf-Oeffnen / Rolle korrigiert / stehengelassen) und je einer
|
||||
Zeile Begruendung, wo der gerade Weg nicht ging.
|
||||
2. Je einen Abschnitt zu den vier Restposten mit dem Ergebnis — einschliesslich der
|
||||
ausdruecklichen Aussage, ob der alte Einwand gegen den uebersetzten ZIP-Namen noch
|
||||
traegt, und ob die Annahme "`t` ist bei jedem Render frisch" bestaetigt oder korrigiert
|
||||
wurde.
|
||||
3. Die ehrliche Einordnung der vier `aria-hidden`-Ergaenzungen an den `<img>`-Elementen:
|
||||
richtige Auszeichnung, aber kein Gewinn fuer einen Menschen.
|
||||
4. Die Vorher/Nachher-Zahlen aus der Biome-JSON-Ausgabe, nach Regel aufgeschluesselt.
|
||||
</output>
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
---
|
||||
phase: quick-260921-jt4
|
||||
plan: 01
|
||||
subsystem: ui
|
||||
tags: [a11y, biome, next-intl, react, calendar, i18n]
|
||||
|
||||
requires:
|
||||
- phase: quick-260921-bi2
|
||||
provides: "155 a11y-Fixes ueber sechs Regeln; die fuenf hier bearbeiteten Regeln (30 Befunde) bewusst zurueckgestellt (D-05)"
|
||||
- phase: quick-260921-gof
|
||||
provides: "21 useExhaustiveDependencies-Befunde beurteilt; die Faustregel 't gehoert in keine Abhaengigkeitsliste' als unbestaetigte Annahme hinterlassen"
|
||||
provides:
|
||||
- "30 zurueckgestellte a11y-Befunde auf 1 gesenkt (429 -> 399 gesamt), Weg pro Fundstelle protokolliert"
|
||||
- "13 Oberflaechendateien mit echten Schaltflaechen statt klickbarer Bereiche statt Mausonly-Bedienung"
|
||||
- "Kalender-Widget ruft Quellenliste und Termine nicht mehr blind bei jedem Monatswechsel neu ab"
|
||||
- "t aus useTranslations ist am echten NextIntlClientProvider als identitaetsstabil ueber lokale Zustandswechsel nachgewiesen (widerlegt die gof-Annahme 'bei jedem Render frisch')"
|
||||
- "Uebersetzter, windows-sicherer ZIP-Dateiname im Zertifikat-Aufteiler (zip-filename.ts)"
|
||||
affects: [apps/web-a11y, dashboard-calendar-widget, i18n-catalogs]
|
||||
|
||||
actuals:
|
||||
tokens: 21513
|
||||
tasks: 3
|
||||
commits: 12
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Deckende Geschwister-Schaltflaeche statt role=button+tabIndex+onKeyDown: verschachtelte <button>-Elemente vermieden (MarketplaceCard-Muster, auch fuer Dialoghintergruende), ohne useSemanticElements neu auszuloesen."
|
||||
- "role=toolbar statt role=group/region fuer beschriftete Schaltflaechenreihen ohne semantisches HTML-Aequivalent — role=group/region loesen useSemanticElements neu aus, role=toolbar nicht (an Biome 2.5.0 mit Projektkonfiguration gemessen)."
|
||||
- "Ref-basierte Sperrgriffe gegen verschwendete Effekte: hasSourcesRef/lastFetchWindowRef ausserhalb des Render-Zustands, mit einem expliziten force-Parameter fuer den Fall, der den Sperrgriff bewusst umgehen muss (5-Minuten-Auffrischer)."
|
||||
- "t-Identitaet aus useTranslations gegen den echten NextIntlClientProvider messen (nicht behaupten) — Testkomponente unter dem echten Provider, Zustandswechsel ausloesen, per toBe (Referenzgleichheit) pruefen."
|
||||
- "Regex mit \\x00-\\x1F-Bereich vermeiden (lint/suspicious/noControlCharactersInRegex) — Steuerzeichen-Filterung zeichenweise statt per Regex, wenn das Filtern von Steuerzeichen der Zweck ist."
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/zip-filename.ts
|
||||
- apps/web/src/app/(portal)/modules/cert-manager/zip-filename.test.ts
|
||||
- apps/web/src/components/dashboard/widget-catalog-modal.test.tsx
|
||||
- apps/web/src/components/settings/calendar-settings-panel.test.tsx
|
||||
- apps/web/src/lib/translations-identity.test.tsx
|
||||
modified:
|
||||
- apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx
|
||||
- apps/web/src/components/dashboard/widget-catalog-modal.tsx
|
||||
- apps/web/src/components/settings/calendar-settings-panel.tsx
|
||||
- apps/web/src/components/dashboard/widgets/note-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/note-task-list.tsx
|
||||
- apps/web/src/components/dashboard/widgets/calendar-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/calculator-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||
- apps/web/src/components/layout/header.tsx
|
||||
- apps/web/src/components/settings/account-settings-form.tsx
|
||||
- "apps/web/src/app/(auth)/login/page.tsx"
|
||||
- "apps/web/src/app/(auth)/reset-password/page.tsx"
|
||||
- "apps/web/src/app/(auth)/reset-password/[token]/page.tsx"
|
||||
- "apps/web/src/app/(portal)/change-password/page.tsx"
|
||||
- "apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx"
|
||||
- apps/api/src/tenders/tender-normalizer.service.ts
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
|
||||
key-decisions:
|
||||
- "D-01-Rueckfall (role=button+tabIndex+onKeyDown) an keiner der sechs Umbaustellen benutzt — der Planer hatte ihn empirisch geprueft und drei Befunde gegen einen neuen useSemanticElements-Befund getauscht; D-07 verbietet Wachstum anderswo. Deckende Geschwister-Schaltflaeche stattdessen bei MarketplaceCard, widget-catalog-modal und calendar-settings-panel."
|
||||
- "calendar-widget Tageszelle wird NUR bei hasEvents zu einem <button> — 42 neue Tab-Stopps waeren eine Verschlechterung, nicht Tage ohne Termine."
|
||||
- "Vier autoFocus-Stellen sind alle Seitenformulare, kein Dialog: Attribut entfernt (D-02), der ref+Effekt-Ersatzweg kam an keiner Stelle zum Zug."
|
||||
- "t-Identitaet gemessen statt der gof-Annahme ein drittes Mal weitergetragen: bleibt bei einem echten NextIntlClientProvider-Test erhalten -> die vier verbliebenen t-Abhaengigkeitsstellen bleiben unveraendert."
|
||||
- "ZIP-Dateiname darf sich nicht auf einen zufaellig harmlosen Uebersetzungstext verlassen — eigene, fuer sich getestete Schutzfunktion (zip-filename.ts) statt Vertrauen in den Katalogwert."
|
||||
- "noUselessSwitchCase-Fallmarke in tender-normalizer.service.ts entfernt, Kommentar erweitert statt der Marke selbst eine Bedeutung zuzuschreiben, die die Regel nicht sehen kann — kein Verhaltenswechsel."
|
||||
|
||||
patterns-established:
|
||||
- "Vollstaendige a11y-Regelmenge (Ziel- UND zurueckgestellte Regeln) nach jedem Teilumbau neu messen, nicht nur die Zielregel — ein a11y-Fix kann eine ANDERE Regel neu ausloesen, wenn ein Element seine interaktive Klassifikation verliert."
|
||||
|
||||
requirements-completed: [D-01, D-02, D-03, D-04, D-05, D-06, D-07]
|
||||
|
||||
duration: ~2h (eine Sitzung)
|
||||
completed: 2026-09-21
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick-Vorgang 260921-jt4: 30 a11y-Befunde mit Bedienentscheidungen abgearbeitet Summary
|
||||
|
||||
**30 zurueckgestellte Barrierefreiheits-Befunde auf 1 gesenkt (429 -> 399 gesamt, 0 Fehler) — 13 Oberflaechendateien mit echten Schaltflaechen statt klickbarer Bereiche, das Kalender-Widget ruft Quellenliste/Termine nicht mehr blind bei jedem Monatswechsel neu ab, und die gof-Annahme "t ist bei jedem Render frisch" ist am echten `NextIntlClientProvider` widerlegt statt ein drittes Mal weitergetragen.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Tasks:** 3 von 3 (PLAN.md)
|
||||
- **Commits:** 12 (siehe Task Commits)
|
||||
- **Dateien geaendert:** 28 (23 Quell-/Testdateien + 2 i18n-Kataloge + PLAN.md/keine weiteren)
|
||||
- **apps/web Tests:** 66 -> 73 Dateien, 462 -> 529 Tests (Netto-Zuwachs 7 Dateien / 67 Tests)
|
||||
- **apps/api Tests:** unveraendert 72 Dateien / 1143 Tests (nur ein Kommentar/eine Fallmarke in tender-normalizer.service.ts geaendert, kein neuer Test noetig — die 18 vorhandenen Tests belegen weiterhin dasselbe Verhalten)
|
||||
|
||||
## Accomplishments
|
||||
|
||||
- 30 zurueckgestellte a11y-Befunde auf genau 1 gesenkt (der bewusst stehengelassene Taschenrechner-Tastaturhandler, D-07) — Biome-JSON `429 -> 399`, `errors 0` durchgehend
|
||||
- Fuenf Struktur-Umbauten (MarketplaceCard, widget-catalog-modal, calendar-settings-panel Loeschdialog, note-widget Kaestchen, calendar-widget Tageszelle) auf echte `<button>`-Elemente, jeder Umbau mit einem Test belegt, der eine ECHTE Tastaturbetaetigung ausloest (Enter/Leertaste auf einem fokussierten Element), nicht nur eine Klick-Attrappe
|
||||
- Kalender-Widget: Quellenliste und Termin-Ladefenster werden nicht mehr blind bei jedem Monatswechsel neu geholt — vier zaehlende Tests belegen Aufbau/abweichendes Fenster/identisches Fenster/erzwungener Auffrischer
|
||||
- `translations-identity.test.tsx`: `t` aus `useTranslations` ist am echten `NextIntlClientProvider` als identitaetsstabil ueber einen lokalen Zustandswechsel nachgewiesen — widerlegt die 260921-gof-Annahme "t ist bei jedem Render frisch"
|
||||
- Windows-sicherer, uebersetzter ZIP-Dateiname im Zertifikat-Aufteiler ueber eine neue, fuer sich getestete Schutzfunktion (`zip-filename.ts`), unabhaengig davon, ob der aktuelle Katalogwert zufaellig harmlos ist
|
||||
- Zwei Restposten aus 260921-bi2/gof geschlossen: ueberfluessige `case`-Marke in `tender-normalizer.service.ts` entfernt; keine neue Unterdrueckung (`biome-ignore`) im gesamten Diff
|
||||
|
||||
## Task Commits
|
||||
|
||||
Aufgabe 1 (Aus klickbaren Bereichen echte Schaltflaechen machen, 5 Teilumbauten, jeder einzeln committet):
|
||||
|
||||
1. **MarketplaceCard.tsx — Karte** - `a8531d4` (fix)
|
||||
2. **widget-catalog-modal.tsx — Hintergrund** - `3d0bc0b` (fix)
|
||||
3. **calendar-settings-panel.tsx — Loeschdialog-Hintergrund** - `0c89c13` (fix)
|
||||
4. **note-widget.tsx + note-task-list.tsx — Aufgabenkaestchen** - `b601141` (fix)
|
||||
5. **calendar-widget.tsx — Tageszelle** - `e651c24` (fix)
|
||||
|
||||
Aufgabe 2 (Rollen, Beschriftungen, Autofokus, zwei Restposten, jeder Teilumbau einzeln committet):
|
||||
|
||||
6. **autoFocus von vier Seitenformularen entfernt (D-02)** - `9aa87bd` (fix)
|
||||
7. **calendar-settings-panel: drei Statussymbole role="img" (D-03)** - `b406a9c` (fix)
|
||||
8. **calculator-widget/favorites-widget: zwei Schaltflaechenreihen role="toolbar" (D-03)** - `f7b5df4` (fix)
|
||||
9. **vier `<img onError>` mit aria-hidden (D-03)** - `69fe706` (fix)
|
||||
10. **tender-normalizer.service.ts: ueberfluessige case-Marke entfernt (Restposten 2)** - `5a03b75` (refactor)
|
||||
11. **ZIP-Name im Zertifikat-Aufteiler mit Windows-Schutzfunktion (Restposten 1)** - `6c10c9b` (feat)
|
||||
|
||||
Aufgabe 3 (Verschwendete Kalender-Abrufe + t-Identitaet, ein Commit):
|
||||
|
||||
12. **Kalender-Ladefenster/Quellenliste entdoppelt, t-Identitaet gemessen** - `f471b78` (fix)
|
||||
|
||||
_Hinweis: keine separate Plan-Metadaten-Commit — diese SUMMARY und STATE.md/ROADMAP.md werden vom Orchestrator committet._
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `apps/web/src/app/(portal)/marketplace/components/MarketplaceCard.tsx` - Kartenklick als deckende Geschwister-Schaltflaeche
|
||||
- `apps/web/src/components/dashboard/widget-catalog-modal.tsx` - Hintergrund als benannte Schaltflaeche, totes stopPropagation entfernt
|
||||
- `apps/web/src/components/settings/calendar-settings-panel.tsx` - Loeschdialog-Hintergrund als Abbrechen-Schaltflaeche; drei Statussymbole role="img"
|
||||
- `apps/web/src/components/dashboard/widgets/note-widget.tsx` + `note-task-list.tsx` - Aufgabenkaestchen bedient sich selbst (onChange statt readOnly+delegiertem Klick)
|
||||
- `apps/web/src/components/dashboard/widgets/calendar-widget.tsx` - Tageszelle als Schaltflaeche bei Terminen; Quellenliste/Ladefenster-Sperrgriffe
|
||||
- `apps/web/src/components/dashboard/widgets/calculator-widget.tsx` - Speicherzeile role="toolbar", drei Texte in den Katalog gezogen
|
||||
- `apps/web/src/components/dashboard/widgets/favorites-widget.tsx` - Ansichtsumschalter role="toolbar" mit korrigierter Beschriftung; zwei `<img>` aria-hidden
|
||||
- `apps/web/src/components/layout/header.tsx` + `apps/web/src/components/settings/account-settings-form.tsx` - Avatar-`<img onError>` aria-hidden
|
||||
- `apps/web/src/app/(auth)/login/page.tsx`, `reset-password/page.tsx`, `reset-password/[token]/page.tsx`, `apps/web/src/app/(portal)/change-password/page.tsx` - autoFocus entfernt
|
||||
- `apps/web/src/app/(portal)/modules/cert-manager/zip-filename.ts` (neu) + `.test.ts` (neu) - Windows-sichere ZIP-Namens-Schutzfunktion
|
||||
- `apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx` - uebersetzter, sanitierter ZIP-Name
|
||||
- `apps/api/src/tenders/tender-normalizer.service.ts` - ueberfluessige case-Marke entfernt, Kommentar erweitert
|
||||
- `apps/web/src/lib/translations-identity.test.tsx` (neu) - t-Identitaet am echten Provider gemessen
|
||||
- `apps/web/src/messages/de.json` / `en.json` - 20 neue Schluessel, beide Kataloge weiterhin gleich lang (912/912)
|
||||
|
||||
## Decisions Made
|
||||
|
||||
Siehe `key-decisions` im Frontmatter. Zusammengefasst: der D-01-Rueckfallweg wurde an keiner Stelle benutzt (empirisch als regelvermehrend erkannt); die Kalender-Tageszelle wird nur bei Terminen zu einem Tab-Stopp; alle vier `autoFocus`-Stellen sind Seiten, kein Dialog, deshalb Entfernen statt Ersatz; die t-Identitaetsfrage wurde gemessen statt behauptet und bestaetigt die vier verbliebenen Stellen als unveraendert richtig; der ZIP-Name haengt an einer eigenen Schutzfunktion, nicht am Zufall des Uebersetzungstexts.
|
||||
|
||||
## Die 30 a11y-Fundstellen — gewaehlter Weg je Fundstelle
|
||||
|
||||
| # | Fundstelle | Regel | Gewaehlter Weg | Begruendung (bei Rueckfall/Stehenlassen) |
|
||||
|---|---|---|---|---|
|
||||
| 1 | MarketplaceCard.tsx (Karte) | noNoninteractiveElementInteractions | echter Button (deckendes Geschwister) | Karte enthaelt bereits eine Schaltflaeche im Fuss — unmittelbare `<button>`-Umwandlung waere eine Verschachtelung (Falle wie DropZone.tsx in 260921-bi2) |
|
||||
| 2 | MarketplaceCard.tsx (Karte) | noStaticElementInteractions | echter Button (deckendes Geschwister) | s. #1 |
|
||||
| 3 | MarketplaceCard.tsx (Karte) | useKeyWithClickEvents | echter Button (deckendes Geschwister) | s. #1 — der Button traegt den Tastaturweg von selbst |
|
||||
| 4 | widget-catalog-modal.tsx (Hintergrund) | noNoninteractiveElementInteractions | echter Button | vorher rein optische Flaeche, jetzt fokussierbar und benannt |
|
||||
| 5 | widget-catalog-modal.tsx (Hintergrund) | noStaticElementInteractions | echter Button | s. #4 |
|
||||
| 6 | widget-catalog-modal.tsx (Hintergrund) | useKeyWithClickEvents | echter Button | s. #4 |
|
||||
| 7 | widget-catalog-modal.tsx (Dialogflaeche) | noNoninteractiveElementInteractions | Handler entfaellt ersatzlos | `stopPropagation` wird toter Code, sobald der Hintergrund Geschwister statt Vorfahr der Dialogflaeche ist |
|
||||
| 8 | widget-catalog-modal.tsx (Dialogflaeche) | useKeyWithClickEvents | Handler entfaellt ersatzlos | s. #7 |
|
||||
| 9 | calendar-settings-panel.tsx (Loeschdialog-Hintergrund) | noNoninteractiveElementInteractions | echter Button | muss abbrechen, darf nie loeschen (T-JT4-04); Beschriftung nennt ausdruecklich das Abbrechen |
|
||||
| 10 | calendar-settings-panel.tsx (Loeschdialog-Hintergrund) | noStaticElementInteractions | echter Button | s. #9 |
|
||||
| 11 | calendar-settings-panel.tsx (Loeschdialog-Hintergrund) | useKeyWithClickEvents | echter Button | s. #9 |
|
||||
| 12 | note-widget.tsx (Vorschau-Kaestchen) | noNoninteractiveElementInteractions | Kaestchen uebernimmt seinen Handler selbst | `readOnly` entfernt, echtes `onChange` am Kaestchen statt delegiertem Klick am Behaelter |
|
||||
| 13 | note-widget.tsx (Vorschau-Kaestchen) | noStaticElementInteractions | Kaestchen uebernimmt seinen Handler selbst | s. #12 |
|
||||
| 14 | note-widget.tsx (Vorschau-Kaestchen) | useKeyWithClickEvents | Kaestchen uebernimmt seinen Handler selbst | s. #12 — ein `<input type="checkbox">` bedient sich per Definition mit der Tastatur |
|
||||
| 15 | calendar-widget.tsx (Tageszelle) | noNoninteractiveElementInteractions | echter Button + Fokus-Handler, NUR bei Terminen | 42 neue Tab-Stopps waeren eine Verschlechterung; nur Tage mit Terminen werden zu Tab-Stopps |
|
||||
| 16 | calendar-widget.tsx (Tageszelle) | noStaticElementInteractions | echter Button + Fokus-Handler, NUR bei Terminen | s. #15 |
|
||||
| 17 | login/page.tsx | noAutofocus | Attribut entfernt | Seite, kein Dialog — D-02 verlangt Entfernen, nicht ref+Effekt |
|
||||
| 18 | reset-password/[token]/page.tsx | noAutofocus | Attribut entfernt | Seite, kein Dialog |
|
||||
| 19 | reset-password/page.tsx | noAutofocus | Attribut entfernt | Seite, kein Dialog |
|
||||
| 20 | change-password/page.tsx | noAutofocus | Attribut entfernt | Seite, kein Dialog — Nebennutzen: Fokus faellt jetzt nicht mehr am Hinweis zum erzwungenen Wechsel vorbei |
|
||||
| 21 | calendar-settings-panel.tsx (Sync-Fehler-Symbol) | useAriaPropsSupportedByRole | `role="img"` + Katalogschluessel | Beschriftung ist der einzige Text des Symbols (svg bereits aria-hidden); Rolle `generic` verwarf sie bisher stillschweigend |
|
||||
| 22 | calendar-settings-panel.tsx (Verbindung-OK-Symbol) | useAriaPropsSupportedByRole | `role="img"` + vorhandener Schluessel `connectionSuccess` | s. #21, Text passte woertlich, kein neuer Schluessel noetig |
|
||||
| 23 | calendar-settings-panel.tsx (Verbindung-fehlgeschlagen-Symbol) | useAriaPropsSupportedByRole | `role="img"` + Katalogschluessel | s. #21 |
|
||||
| 24 | calculator-widget.tsx (Speicherzeile) | useAriaPropsSupportedByRole | `role="toolbar"` + Katalogschluessel | `role="group"`/`"region"` loesen `useSemanticElements` neu aus (gemessen); `role="toolbar"` nicht |
|
||||
| 25 | favorites-widget.tsx (Ansichtsumschalter) | useAriaPropsSupportedByRole | `role="toolbar"` + KORRIGIERTE Beschriftung | alte Beschriftung ("Favoriten") war sachlich falsch fuer einen Listen/Kachel-Umschalter — D-03 erlaubt hier das Streichen, eine richtige Beschriftung ist trotzdem besser als keine |
|
||||
| 26 | header.tsx (Avatar `<img onError>`) | noNoninteractiveElementInteractions | `aria-hidden="true"` | `onError` ist ein Ladefehler, keine Bedienung; `alt=""` bereits vorhanden — richtige Auszeichnung, aber kein Gewinn fuer einen Menschen |
|
||||
| 27 | account-settings-form.tsx (Avatar `<img onError>`) | noNoninteractiveElementInteractions | `aria-hidden="true"` | s. #26 |
|
||||
| 28 | favorites-widget.tsx (Symbol-Proxy `<img onError>`) | noNoninteractiveElementInteractions | `aria-hidden="true"` | s. #26 |
|
||||
| 29 | favorites-widget.tsx (Symbol-Direkt `<img onError>`) | noNoninteractiveElementInteractions | `aria-hidden="true"` | s. #26 |
|
||||
| 30 | calculator-widget.tsx (Rahmen, `role="application"` + `onKeyDown`) | noNoninteractiveElementInteractions | **bleibt stehen, bleibt gezaehlt** | Der Handler FUEGT einen Tastaturweg hinzu (Ziffern erreichen die Logik, sobald irgendeine Taste fokussiert ist) — das Gegenteil des Schadens, den die Regel beschreibt. Alle drei Rollen-Alternativen gemessen, aendern nichts. `addEventListener` in einem Effekt ausdruecklich NICHT gewaehlt — das waere eine geschoente Zahl ohne Gegenwert (D-07). |
|
||||
|
||||
## Die vier Restposten
|
||||
|
||||
1. **ZIP-Name im Zertifikat-Aufteiler.** `downloadAllAsZip` liegt ausserhalb der Komponente und konnte den Uebersetzungs-Hook nicht aufrufen — der Name kommt jetzt als Parameter herein (`t('actions.zipFilename')`). Der 260921-bi2-Einwand ("ein uebersetzter Name koenne Umlaute auf eine Windows-Freigabe tragen") **trifft fuer den aktuellen deutschen Katalogwert nicht zu** (kein Umlaut). Die Sicherheit haengt darauf aber NICHT: `zip-filename.ts` schneidet jeden Namen unabhaengig vom Katalogwert auf das fuer Windows Zulaessige zurueck (verbotene Zeichen, Steuerzeichen, Nicht-ASCII, abschliessende Punkte/Leerzeichen, reservierte Geraetenamen, Ersatzname bei leerem Ergebnis, Endung sichergestellt) — elf Testfaelle belegen das einzeln.
|
||||
|
||||
2. **`noUselessSwitchCase` in `tender-normalizer.service.ts`.** Die Fallmarke `case 'doe-opendata':` unmittelbar ueber `default:` war ueberfluessig — entfernt, der erweiterte Kommentar traegt jetzt beide Aussagen (DÖE-Quelle landet hier UND kuenftige additive `SourceType`-Mitglieder sollen ebenfalls hier landen). Kein Verhaltenswechsel, belegt durch die 18 vorhandenen Tests.
|
||||
|
||||
3. **Doppeltes Kalender-Ladefenster.** Trat nur bei der Vorschau-Einstellung 90 Tage auf (nicht bei der Voreinstellung 30). Der eingefrorene Testzeitpunkt (15.07.2026) zeigt die Kollision zwischen **August und September** — eine Abweichung vom im PLAN genannten Monatspaar Oktober/November (derselbe Mechanismus, ein anderes "heute"; gleiche Art Abweichung wie bereits in Test 8 fuer den 30-Tage-Fall dokumentiert). `lastFetchWindowRef` ueberspringt den Termin-Abruf bei uebereinstimmendem Fenster; der 5-Minuten-Auffrischer umgeht den Sperrgriff immer.
|
||||
|
||||
4. **Die vier `t`-Abhaengigkeiten.** Die gof-Annahme "`t` ist bei jedem Render frisch" ist **ausdruecklich WIDERLEGT**: `translations-identity.test.tsx` beweist per Referenzgleichheit (nicht nur `toEqual`) an einem echten `NextIntlClientProvider`, dass `t` bei einem lokalen Zustandswechsel dasselbe Funktionsobjekt bleibt. Bestaetigt durch den `use-intl@4.13.0`-Quelltext: `translate` entsteht in einem `useMemo`, dessen Abhaengigkeiten ausschliesslich aus dem root-staendigen Intl-Kontext stammen. Die vier verbliebenen Stellen (`marketplace/page.tsx`, `admin/users/page.tsx`, `calendar-settings-panel.tsx`, `calendar-source-form.tsx`) bleiben deshalb **unveraendert** — zwei davon haengen ohnehin an keinem Effekt (Ereignisbehandlung), die beiden anderen wurden in 260921-gof bereits korrekt behandelt. Die Faustregel "`t` gehoert in keine Abhaengigkeitsliste" bleibt als Konvention in Ordnung, die zugrunde liegende Begruendung ist jetzt korrigiert.
|
||||
|
||||
## Ehrliche Einordnung: die vier `aria-hidden`-Ergaenzungen
|
||||
|
||||
Alle vier `<img onError>`-Stellen (header.tsx, account-settings-form.tsx, favorites-widget.tsx zweimal) tragen bereits `alt=""` — sind also schon aus dem Zugaenglichkeitsbaum genommen. `aria-hidden="true"` sagt dasselbe nur ausdruecklich. **Es ist richtige Auszeichnung, aber es verbessert fuer keinen Menschen etwas** — der Befund verschwindet, weil die Regel ein verborgenes Element nicht mehr betrachtet. `onError` ist ein Ladefehler, keine Bedienung: hier gab es nie einen Tastaturweg zu schaffen. Keine Unterdrueckung, kein `biome-ignore`.
|
||||
|
||||
## Vorher/Nachher (Biome-JSON, nach Regel)
|
||||
|
||||
| Zeitpunkt | gesamt | a11y (5 zurueckgestellte Regeln) | errors |
|
||||
|---|---|---|---|
|
||||
| Vor diesem Vorgang (Baseline, vom Planer gemessen) | 429 | 30 | 0 |
|
||||
| Nach Aufgabe 1 (Struktur-Umbauten) | 413 | 14 (5 = 4 `<img onError>` + Taschenrechner-Rahmen der Klick-Regeln; `useAriaPropsSupportedByRole` noch 5, `noAutofocus` noch 4) | 0 |
|
||||
| Nach Aufgabe 2 (Rollen/Beschriftungen/Autofokus/Restposten 1+2) | **399** | **1** | 0 |
|
||||
| Nach Aufgabe 3 (Kalender-Abrufe, t-Identitaet — keine a11y-Aenderung) | **399** | **1** | 0 |
|
||||
|
||||
Endstand deckt sich exakt mit dem Zielwert des Plans: `gesamt 399`, `a11y 1` (`calculator-widget.tsx`, `noNoninteractiveElementInteractions`, bewusst stehengelassen), `switch 0`, `errors 0`. Keine neue `biome-ignore`-Zeile im gesamten Diff (`git diff --unified=0 7557c9a.. | grep -c biome-ignore` → 0). Beide Kataloge nach wie vor gleich lang (912/912), keine Datei nur in einem Katalog.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug, waehrend der Arbeit selbst erkannt und vor dem Commit korrigiert] Regex mit Steuerzeichen-Bereich loeste eine neue Lint-Regel aus**
|
||||
- **Found during:** Aufgabe 2, Umsetzung der ZIP-Namens-Schutzfunktion
|
||||
- **Issue:** Die urspruengliche Fassung von `zip-filename.ts` nutzte `/[<>:"/\\|?*\x00-\x1f]/g` — ein `\x00-\x1F`-Bereich in einem Regex-Literal, den Biome (`lint/suspicious/noControlCharactersInRegex`) beanstandet, auch wenn die Absicht (Steuerzeichen ausfiltern) hier keine ist. Der Test-Datei-Umbau selbst zog dabei ausserdem `lint/style/useTemplate` (info) und `suppressions/unused` (ein ueberfluessiger `biome-ignore`-Kommentar) nach sich.
|
||||
- **Fix:** Steuerzeichen-/Sonderzeichen-Filterung auf zeichenweise Iteration umgestellt (kein Regex fuer diesen Teil); String-Verkettung im Test durch ein Template-Literal ersetzt; den ueberfluessigen `biome-ignore` entfernt.
|
||||
- **Files modified:** `apps/web/src/app/(portal)/modules/cert-manager/zip-filename.ts`, `apps/web/src/app/(portal)/modules/cert-manager/zip-filename.test.ts`
|
||||
- **Verification:** Volle Regelmessung danach zeigt `gesamt 399` (Zielwert exakt getroffen), 0 Fehler, 0 unbenutzte Unterdrueckungen.
|
||||
- **Committed in:** `6c10c9b` (im selben Commit korrigiert, nie mit dem Fund committet)
|
||||
|
||||
**2. [Rule 1 - Bug, vor dem Commit korrigiert] Write-Werkzeug wandelte `\u0007` still in ein rohes Steuerzeichen um**
|
||||
- **Found during:** Aufgabe 2, Anlegen von `zip-filename.test.ts`
|
||||
- **Issue:** Der bekannte Fallstrick aus dem Plan (`\uXXXX`-Folgen werden vom Write-Werkzeug in das tatsaechliche Zeichen umgewandelt) trat ein weiteres Mal ein — ein rohes BEL-Steuerzeichen (0x07) landete zwischen zwei Buchstaben im Testtext, sichtbar erst per `cat -A`.
|
||||
- **Fix:** Die betroffene Zeile per `python3`-Skript (Bytesuche/-ersatz, `chr(7)`) auf `String.fromCharCode(7)` innerhalb eines Template-Literals umgestellt — erzeugt das Zeichen zur Laufzeit, keine `\u`-Folge mehr im Dateiinhalt. Alle Dateien dieses Vorgangs danach auf verbliebene rohe Steuerbytes durchsucht (0 Treffer).
|
||||
- **Files modified:** `apps/web/src/app/(portal)/modules/cert-manager/zip-filename.test.ts`
|
||||
- **Verification:** `cat -A` zeigt keine Steuerbyte-Artefakte mehr; `git commit` nahm die Datei ohne Beanstandung an.
|
||||
- **Committed in:** `6c10c9b` (vor dem Commit korrigiert)
|
||||
|
||||
**3. [Rule 3 - Blocking, vor dem Commit korrigiert] userEvent-Tastaturtest haengt mit Fake-Timern**
|
||||
- **Found during:** Aufgabe 1, Testerweiterung fuer das Notiz-Kaestchen
|
||||
- **Issue:** Ein Test mit `userEvent.keyboard(' ')` haengte (5s Timeout) unter den in `beforeEach` global gesetzten Fake-Timern (`vi.useFakeTimers()`), auch mit `advanceTimers`-Option.
|
||||
- **Fix:** Fuer diesen einen Test `vi.useRealTimers()` gesetzt (das Abhaken speichert sofort ohne Entprellen, echte Zeitgeber sind dafuer unproblematisch); `afterEach` stellt ohnehin `vi.useRealTimers()` global sicher.
|
||||
- **Files modified:** `apps/web/src/components/dashboard/widgets/note-widget.test.tsx`
|
||||
- **Verification:** Test gruen, restliche 10 Tests der Datei unbeeinflusst.
|
||||
- **Committed in:** `b601141`
|
||||
|
||||
---
|
||||
|
||||
**Total deviations:** 3 auto-fixed (2 Bugs waehrend eigener Arbeit erkannt und vor dem jeweiligen Commit korrigiert, 1 blockierendes Test-Infrastruktur-Problem). Keine der drei hat den Endstand oder den Diff-Umfang ueber das im Plan Vorgesehene hinaus vergroessert — alle drei sind Korrekturen an waehrend dieses Vorgangs selbst neu geschriebenem Code, kein Scope-Creep in bestehende Dateien.
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
Keine ungeloesten Probleme. Die drei oben dokumentierten Abweichungen sind vor dem jeweiligen Commit vollstaendig geloest.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
Keine. Alle Aenderungen sind vollstaendige, funktionierende Korrekturen; keine Platzhalter, keine leeren Datenquellen.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
Keine neue Angriffsflaeche. Der Umbau des Loeschdialog-Hintergrunds (`calendar-settings-panel.tsx`) und des Dialog-Hintergrunds (`widget-catalog-modal.tsx`) macht ein zuvor rein optisches Element zu einem fokussierbaren `<button>` — das ist die im `threat_model` als `accept` eingestufte Informationsfreigabe T-JT4-06 (Dialoginhalt war ohnehin sichtbar), keine neue Flag.
|
||||
|
||||
## User Setup Required
|
||||
|
||||
None — keine externe Konfiguration erforderlich.
|
||||
|
||||
## Was an den Orchestrator geht (kein Browser hier verfuegbar)
|
||||
|
||||
Der Ausfuehrende hat keinen Browser und darf die Browser-Messung aus Aufgabe 3 nicht behaupten. An den Orchestrator zu uebergeben:
|
||||
|
||||
- **Kalender-Widget, Netzwerkprotokoll bei 90 Tagen Vorschau:** Stack bauen, anmelden, Kalender-Kachel mit Vorschau-Einstellung 90 Tage aufs Dashboard legen, Netzwerkprotokoll leeren, dreimal "Weiter" druecken.
|
||||
- Erwartung: `calendar/sources` genau 1 (vorher 3); `calendar/events` weniger als 3, kein Paar mit identischem `from`/`to`.
|
||||
- Gegenprobe: rund fuenf Minuten ruhen lassen, danach je ein weiterer `calendar/events`- und `calendar/sources`-Abruf.
|
||||
- Datenbank/Modul-Aktivierung danach unveraendert lassen, Stack stoppen.
|
||||
|
||||
Alle anderen Verifikationsschritte des Plans (Biome-Zaehlung, Katalog-Gleichstand, `apps/web`/`apps/api`-Testlaeufe, `pnpm type-check`, `pnpm lint --force`, `git diff`-Kontrollen) wurden hier bereits ausgefuehrt und sind oben mit Ergebnissen belegt.
|
||||
|
||||
## Next Phase Readiness
|
||||
|
||||
Der a11y-Rueckstand aus 260921-bi2 (30 zurueckgestellte Befunde ueber fuenf Regeln) ist vollstaendig abgearbeitet: 29 behoben, 1 bewusst und begruendet stehengelassen. Alle vier vom Nutzer/Orchestrator benannten Restposten aus den heutigen Vorgaengen bi2 und gof sind geschlossen. Kein Blocker fuer laufenden Betrieb: `pnpm lint --force` bleibt gruen (5/5), CI-Tor unveraendert scharf. Offen bleibt ausschliesslich die Browser-Gegenprobe fuer die Kalender-Netzwerkzaehlung (siehe Abschnitt oben) — reine Bestaetigung, kein bekannter Defekt.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Alle referenzierten Dateien auf Datentraeger gefunden (`zip-filename.ts`, `zip-filename.test.ts`, `translations-identity.test.tsx`, `widget-catalog-modal.test.tsx`, `calendar-settings-panel.test.tsx`, diese SUMMARY). Alle referenzierten Commit-Hashes (`a8531d4`, `3d0bc0b`, `0c89c13`, `b601141`, `e651c24`, `9aa87bd`, `b406a9c`, `f7b5df4`, `69fe706`, `5a03b75`, `6c10c9b`, `f471b78`) im Verlauf gefunden (`git log --oneline 7557c9a..HEAD`).
|
||||
|
||||
---
|
||||
*Vorgang: quick-260921-jt4*
|
||||
*Abgeschlossen: 2026-09-21*
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
---
|
||||
phase: quick-260921-ldf
|
||||
plan: 01
|
||||
subsystem: ui
|
||||
tags: [react, vitest, flaky-test, bug-report, state-management, react-effects]
|
||||
|
||||
requires:
|
||||
- phase: quick-260914-m97
|
||||
provides: "Fehler-melden-Knopf mit Bildaufnahme VOR dem Dialog; Test 1 als Zusicherung dieser Reihenfolge"
|
||||
- phase: quick-260918-gza
|
||||
provides: "Vier Herkunftsfelder in der Nutzlast (Test 12/13 derselben Datei)"
|
||||
provides:
|
||||
- "Wackeltest aus CI-Lauf 395 ursaechlich beseitigt — als Produktfehler, nicht als Testfehler"
|
||||
- "Fehler-melden-Dialog zeigt Vorschaubild und Haekchen ab dem ERSTEN Commit stimmig, statt einen Commit spaeter"
|
||||
- "Erneutes Oeffnen nach einem Versand zeigt keinen alten Danke-Bildschirm und keinen alten Text mehr"
|
||||
- "Test 14/15: MutationObserver-Pruefung ueber JEDEN Commit statt einer Stichprobe am Ende — deterministisch rot vor dem Fix"
|
||||
- "Zwei Konstruktionsfehler im Fehler-melden-Test behoben (expect in der Attrappe, nicht zurueckgesetzte document.body-Groesse)"
|
||||
affects: [web-bug-report, web-test-hygiene]
|
||||
|
||||
actuals:
|
||||
tokens: 11800
|
||||
tasks: 1
|
||||
commits: 3
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Abgeleiteten Zustand beim RENDERN ableiten, nicht per useEffect nachziehen: passive Effekte laufen nach dem Commit, also schreibt React zwangslaeufig erst den falschen und dann den richtigen Zustand in den DOM. Muster hier: `const attach = screenshot !== null && (attachChoice ?? true)` — eine eigene Zustandsvariable haelt nur noch die bewusste Wahl des Nutzers, nicht den abgeleiteten Wert."
|
||||
- "Dialoge nur einhaengen, solange sie offen sind (`{open && <Dialog ... />}`), statt sie dauerhaft eingehaengt zu lassen und `null` zurueckgeben zu lassen. Sonst laufen die useState-Startwerte genau einmal — zu einem Zeitpunkt, an dem die spaeteren Daten noch nicht da sind — und jedes weitere Oeffnen braucht einen zuruecksetzenden Effekt, der genau diese Luecke aufreisst."
|
||||
- "Wackeltests mit einem MutationObserver ueber JEDEN DOM-Commit untersuchen statt mit einer Stichprobe am Ende. Der Unterschied zwischen 'der Nutzer sieht das nie' und 'das steht bei jedem Oeffnen im DOM' ist genau so zu messen und nicht anders zu erraten."
|
||||
- "Wackelursache einkreisen, indem man die Beobachtungszeit kuenstlich verschiebt (ein setTimeout in der Attrappenkette) statt auf einen Zufallstreffer unter Last zu warten: aus 1:17 wird 4 von 4."
|
||||
- "Kein expect() innerhalb einer Attrappe, die in einem await der Komponente laeuft — ein Fehler daraus wird von einem catch in der Produktionskette verschluckt und der Test faellt an ganz anderer Stelle mit irrefuehrender Meldung durch. Beobachtung festhalten, im Testkoerper pruefen."
|
||||
- "Object.defineProperty auf document.body (scrollWidth/scrollHeight) ueberlebt cleanup() und damit die ganze Testdatei — in afterEach per Reflect.deleteProperty zuruecknehmen."
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- apps/web/src/components/bug-report/bug-report-dialog.tsx
|
||||
- apps/web/src/components/bug-report/bug-report-button.tsx
|
||||
- apps/web/src/components/bug-report/bug-report-button.test.tsx
|
||||
|
||||
key-decisions:
|
||||
- "Urteil Produktfehler, nicht Testfehler — und zwar gemessen, nicht geschaetzt: ein MutationObserver ueber jeden Commit zeigt den Zustand 'Vorschaubild sichtbar, Haekchen aus' bei JEDEM Oeffnen als echten, festgeschriebenen DOM-Zustand, nicht nur unter Last. Ohne act() haelt er zwei volle Makrotask-Runden."
|
||||
- "Repariert wurde der Ursache-Code, nicht der Test. Test 1 prueft unveraendert dieselbe Zusicherung; kein retry, kein hoeheres Zeitlimit — die Ursache war nie blosse Zeit, sondern ein falscher Zustand, den es jetzt nicht mehr gibt."
|
||||
- "Beide Teilursachen beseitigt, nicht nur eine. Die abgeleitete Ableitung allein wuerde die Abwahl des Nutzers ueber das Schliessen hinaus festhalten; das bedingte Einhaengen allein liesse den Fehler wiederkehren, falls Bild und Oeffnen je in getrennten Commits landeten. Erst zusammen ist die Zusicherung strukturell erzwungen."
|
||||
- "Der zuruecksetzende useEffect entfaellt ersatzlos statt umgebaut zu werden — ein frischer Mount setzt Status, Text, Fehlerstand und Haekchen schon durch die useState-Startwerte zurueck."
|
||||
- "Die geschluckte Ausnahme in captureScreenshot (catch liefert null) bleibt bewusst stehen: das Bild ist eine Beigabe, der Bericht geht auch ohne. Angepasst wurde stattdessen der Test, der sein expect in diese Kette gelegt hatte."
|
||||
|
||||
patterns-established:
|
||||
- "Bei einem Wackeltest zuerst fragen, ob der beobachtete Zwischenzustand ueberhaupt in den DOM geschrieben wird. Wird er es, ist es ein Produktfehler und der Test hat recht behalten — auch wenn die sichtbare Wirkung nur ein kurzes Flackern ist."
|
||||
|
||||
requirements-completed: [Ursache, Urteil, Reparatur, Nebenbefund-1, Nebenbefund-2, Stabilitaet]
|
||||
|
||||
duration: ~1h (eine Sitzung)
|
||||
completed: 2026-09-21
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick-Vorgang 260921-ldf: Wackeltest Fehler-melden-Haekchen Summary
|
||||
|
||||
**Der Wackeltest hatte recht: der Fehler-melden-Dialog schrieb bei JEDEM Oeffnen zuerst den Zustand "Vorschaubild sichtbar, Haekchen aus" in den DOM und korrigierte ihn erst einen Commit spaeter — ein Produktfehler, kein Testfehler. Repariert ist der Ursache-Code; der Test prueft unveraendert dasselbe und wackelt nicht mehr.**
|
||||
|
||||
## Die Ursache in einem Satz
|
||||
|
||||
Der Dialog war dauerhaft eingehaengt, sodass `useState(screenshot !== null)` nur ein einziges Mal lief — beim allerersten Mount des Knopfs, als noch gar kein Bild da war — und der richtige Wert erst von einem `useEffect` nachgezogen wurde, der per Bauart NACH dem Commit laeuft.
|
||||
|
||||
## Das Urteil: Produktfehler
|
||||
|
||||
Das war die eigentliche Frage, und sie ist gemessen worden statt geschaetzt. Ein `MutationObserver` ueber `document.body` protokolliert jeden einzelnen DOM-Commit waehrend des Oeffnens. Gegen den Stand vor dem Fix, mit einer Attrappe, die rein in Mikrotasks aufloest, also ohne jede kuenstliche Verzoegerung:
|
||||
|
||||
```
|
||||
COMMIT dialog=false img=nein box=-
|
||||
COMMIT dialog=false img=nein box=-
|
||||
COMMIT dialog=true img=ja box=AUS <- falsch, aber festgeschrieben
|
||||
COMMIT dialog=true img=ja box=AN
|
||||
```
|
||||
|
||||
Der falsche Zustand ist also kein Testartefakt und keine Frage der Last. Er entsteht bei **jedem** Oeffnen. Ein zweiter Versuch, diesmal mit einem rohen Klick ohne `act()` und einer Stichprobe pro Ereignisschleifen-Runde, zeigt, wie lange er haelt:
|
||||
|
||||
```
|
||||
runde 1 bild=ja haekchen=AUS
|
||||
runde 2 bild=ja haekchen=AUS
|
||||
runde 3 bild=ja haekchen=AN
|
||||
```
|
||||
|
||||
Zwei volle Makrotask-Runden. Zwischen zwei Makrotasks darf der Browser zeichnen — der Nutzer kann diesen Zustand also sehen. Damit ist die Kernfrage beantwortet: **ja, ein echter Nutzer geraet in diesen Zustand.**
|
||||
|
||||
Was dabei ehrlich dazugehoert: die Korrektur kommt binnen Millisekunden, lange bevor jemand "Senden" druecken kann. Der befuerchtete Fall — jemand sieht sein Bild, schickt ab, und das Bild fehlt — ist damit nicht erreichbar. Was bleibt, ist ein kurzes Flackern beim Oeffnen. Echt, sichtbar, aber ohne Datenverlust. Der Grund, es trotzdem im Produktcode zu reparieren statt im Test: der Test hat einen wirklich vorhandenen falschen Zustand gefunden, und wer ihn im Test wegberuhigt, laesst den Zustand stehen.
|
||||
|
||||
Nicht gemessen und deshalb hier auch nicht behauptet: ob der Browser den Zwischenschritt tatsaechlich in jedem Fall zeichnet. Belegt ist, dass er zwei Zeichengelegenheiten lang besteht.
|
||||
|
||||
## Wie die Ursache gefunden wurde
|
||||
|
||||
Drei Schritte, jeder mit einem eigenen Messergebnis:
|
||||
|
||||
1. **Beobachtungszeit kuenstlich verschoben.** Statt auf einen Zufallstreffer unter Last zu warten, bekam die `toPng`-Attrappe ein `setTimeout` in die Kette. Ergebnis: 4 von 4 Fehlschlaegen mit exakt der CI-Meldung, schon bei 0 ms. Damit war klar, dass die Last nur der Ausloeser ist und jede Makrotask-Grenze genuegt.
|
||||
2. **Eine naheliegende Erklaerung widerlegt.** Der Verdacht lag auf `await import('html-to-image')` in `captureScreenshot`. Gemessen: der Import loest auf, bevor ein zuvor gesetzter `setTimeout(0)` feuert — er ueberschreitet keine Makrotask-Grenze und ist nicht die Ursache.
|
||||
3. **Jeden Commit protokolliert.** Erst das zeigte, dass der falsche Zustand nicht gelegentlich unter Last entsteht, sondern immer.
|
||||
|
||||
Dabei fiel eine zweite Auspraegung derselben Ursache auf: beim erneuten Oeffnen nach einem Versand stand zwei Runden lang der **alte Danke-Bildschirm** im DOM, bevor das frische Formular erschien. Auch `status`, `description` und `failedStatus` wurden erst per Effekt zurueckgesetzt.
|
||||
|
||||
## Die Reparatur
|
||||
|
||||
Zwei Teilursachen, beide beseitigt — einzeln reicht keine:
|
||||
|
||||
- **`bug-report-button.tsx`:** Der Dialog wird nur noch eingehaengt, solange er offen ist. Jedes Oeffnen ist damit ein frischer Mount, und die `useState`-Startwerte gelten schon im ersten Commit. Der zuruecksetzende `useEffect` entfaellt ersatzlos — er hatte genau die Luecke aufgerissen, die er schliessen sollte.
|
||||
- **`bug-report-dialog.tsx`:** Das Haekchen wird beim Rendern abgeleitet statt nachgezogen: `const attach = screenshot !== null && (attachChoice ?? true)`. `attachChoice` haelt nur noch die bewusste Abwahl des Nutzers.
|
||||
|
||||
Nach dem Fix zeigt dasselbe Commit-Protokoll den ersten Commit mit Dialog bereits als `img=ja box=AN`, und der rohe Klick ist schon in Runde 1 richtig. Es gibt keinen falschen Zwischenzustand mehr, den man beobachten koennte — deshalb kann der Test auch nicht mehr wackeln.
|
||||
|
||||
## Der Nachweis: Test 14 und 15
|
||||
|
||||
Der alte Test 1 fand den Fehler nur durch Zufall — er las den DOM einmal, nachdem `findByRole` den Dialog gemeldet hatte, und traf mal den falschen ersten, meist den richtigen zweiten Commit. Ein Fehlschlag auf rund siebzehn volle Laeufe.
|
||||
|
||||
Die beiden neuen Tests pruefen stattdessen **jeden** Commit und dulden keinen einzigen mit sichtbarem Bild und ausgeschaltetem Haekchen. Test 14 deckt das erste Oeffnen ab, Test 15 das erneute Oeffnen nach einem Versand mit abgewaehltem Haekchen und getipptem Text.
|
||||
|
||||
Gegen den Stand vor dem Fix (`8d604b8`, Quellcode zurueckgesetzt, Tests behalten) sind beide in **5 von 5 Laeufen rot**; mit dem Fix gruen. Kein `retry`, kein hoeheres Zeitlimit — beides waere hier auch falsch gewesen, weil die Ursache nie blosse Zeit war.
|
||||
|
||||
Test 1 bleibt in der Sache unveraendert und prueft weiterhin dieselbe Zusicherung. Die Pruefmenge ist gewachsen, nicht geschrumpft.
|
||||
|
||||
## Die zwei Nebenbefunde
|
||||
|
||||
Beide waren nicht die Ursache, beide haetten die Suche in die Irre fuehren koennen. Beide sind erledigt (Commit `c0ab5b5`, vor dem Fix, unabhaengig davon gruen).
|
||||
|
||||
**1. `expect` innerhalb der Attrappe.** Test 1 pruefte mitten in der `toPng`-Attrappe, dass noch kein Dialog im DOM steht. Wirft dieses `expect`, landet der Fehler mitten im `await` von `captureScreenshot`, und dessen `catch` liefert still `null` zurueck. Der Test waere dann nicht an der geprueften Stelle durchgefallen, sondern viel spaeter mit der Meldung, es gebe kein Vorschaubild — eine Meldung, die in die voellig falsche Richtung zeigt. Die Attrappe haelt die Beobachtung jetzt nur fest, geprueft wird im Testkoerper. Bewiesen wird dasselbe.
|
||||
|
||||
**2. `document.body`-Groesse ohne Ruecknahme.** `scrollWidth`/`scrollHeight` wurden per `Object.defineProperty` auf 3200x1000 gesetzt und nie zurueckgenommen. Die eigene Eigenschaft verdeckt den Getter von `Element.prototype`, und `document.body` ueberlebt `cleanup()` — alle zwoelf folgenden Tests der Datei sahen weiterhin 3200x1000. `stubBodyGroesse` merkt sich das jetzt, `afterEach` nimmt es per `Reflect.deleteProperty` zurueck.
|
||||
|
||||
## Commits
|
||||
|
||||
1. **Zwei Konstruktionsfehler im Fehler-melden-Test behoben** — `c0ab5b5` (test)
|
||||
2. **Fehler-melden-Dialog stimmt ab dem ersten Commit, nicht erst einen spaeter** — `de7fdb7` (fix)
|
||||
3. **Test 14/15 halten jeden Commit fest statt nur den Endzustand** — `9f02fcc` (test)
|
||||
|
||||
## Pruefstand
|
||||
|
||||
- **`pnpm lint`:** 5/5 erfolgreich, keine Fehlerstufe, **399 Warnungen** — unveraendert, nicht gewachsen
|
||||
- **`pnpm type-check`:** 4/4 erfolgreich
|
||||
- **`apps/web`:** 73 Dateien / **531 Tests** (529 + Test 14/15), keine Datei dazugekommen oder weggefallen
|
||||
- **`apps/api`:** 72 Dateien / 1143 Tests, unveraendert — nicht beruehrt
|
||||
- **Stabilitaet:** **20 von 20** vollen Laeufen `pnpm --filter @tessera/web exec vitest run` gruen, 0 Fehlschlaege, je 531 Tests (je ~31 s, gegen den committeten Endstand, nacheinander)
|
||||
|
||||
Dazu die ehrliche Einordnung: 20 saubere Laeufe sind fuer sich genommen **kein** starker Beleg. Bei der gemessenen Ausgangsrate von 1:17 waeren 20 gruene Laeufe auch ohne jede Reparatur noch mit rund 30 Prozent Wahrscheinlichkeit zu erwarten. Der eigentliche Beleg ist ein anderer und er ist deterministisch: der falsche Zwischenzustand existiert nicht mehr. Das Commit-Protokoll zeigt den ersten Commit mit Dialog bereits als `img=ja box=AN`, und Test 14/15 sind gegen den Stand davor in 5 von 5 Laeufen rot. Es gibt schlicht nichts mehr, was der Test zufaellig falsch antreffen koennte. Die 20 Laeufe bestaetigen das nur, sie tragen es nicht.
|
||||
|
||||
## Was nicht angefasst wurde
|
||||
|
||||
- Keine Versionsspruenge, keine neuen Abhaengigkeiten, kein repo-weites Umformatieren
|
||||
- `STATE.md` und `ROADMAP.md` unberuehrt (Sache des Orchestrators)
|
||||
- Nicht gepusht
|
||||
- Die geschluckte Ausnahme in `captureScreenshot` bleibt bewusst stehen — das Bild ist eine Beigabe, der Bericht geht auch ohne
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
---
|
||||
phase: quick-260921-m34
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements: [QUICK-M34-01]
|
||||
files_modified:
|
||||
- apps/api/src/prisma/prisma-tenant.extension.ts
|
||||
- apps/api/src/auth/auth.service.ts
|
||||
- apps/api/src/auth/auth.controller.ts
|
||||
- apps/api/src/auth/strategies/jwt.strategy.ts
|
||||
- apps/api/src/auth/strategies/local.strategy.ts
|
||||
- apps/api/src/auth/interceptors/force-password-change.interceptor.ts
|
||||
- apps/api/src/auth/types/auth-user.ts
|
||||
- apps/api/src/bug-reports/bug-reports.service.ts
|
||||
- apps/api/src/bug-reports/bug-reports.controller.ts
|
||||
- apps/api/src/calendar/calendar.controller.ts
|
||||
- apps/api/src/calendar/calendar.service.ts
|
||||
- apps/api/src/calendar/providers/exchange.provider.ts
|
||||
- apps/api/src/cert-manager/cert-manager.controller.ts
|
||||
- apps/api/src/cert-manager/cert-manager.service.ts
|
||||
- apps/api/src/dashboard/dashboard.controller.ts
|
||||
- apps/api/src/dkv/dkv.controller.ts
|
||||
- apps/api/src/dkv/dkv.service.ts
|
||||
- apps/api/src/dkv/dkv-scheduler.service.ts
|
||||
- apps/api/src/favorites/favorites.controller.ts
|
||||
- apps/api/src/favorites/favorites.service.ts
|
||||
- apps/api/src/groups/groups.controller.ts
|
||||
- apps/api/src/groups/groups.service.ts
|
||||
- apps/api/src/groups/module-grants.controller.ts
|
||||
- apps/api/src/groups/module-grants.service.ts
|
||||
- apps/api/src/inbox/exchange-inbox.provider.ts
|
||||
- apps/api/src/inbox/imap.provider.ts
|
||||
- apps/api/src/ldap/ldap.controller.ts
|
||||
- apps/api/src/ldap/ldap.service.ts
|
||||
- apps/api/src/ldap/ldap-config.service.ts
|
||||
- apps/api/src/mail/mail.service.ts
|
||||
- apps/api/src/module-registry/module-access.service.ts
|
||||
- apps/api/src/module-registry/module-registry.controller.ts
|
||||
- apps/api/src/module-registry/module-registry.service.ts
|
||||
- apps/api/src/settings/settings.controller.ts
|
||||
- apps/api/src/settings/settings.service.ts
|
||||
- apps/api/src/tenant/tenant.controller.ts
|
||||
- apps/api/src/tenders/tenders.controller.ts
|
||||
- apps/api/src/tenders/tender-digest.scheduler.ts
|
||||
- apps/api/src/tenders/tender-email-config.service.ts
|
||||
- apps/api/src/tenders/tender-matching.service.ts
|
||||
- apps/api/src/tenders/tender-notification-pref.service.ts
|
||||
- apps/api/src/tenders/tender-rss-feed.service.ts
|
||||
- apps/api/src/tenders/tender-saved-search.service.ts
|
||||
- apps/api/src/tenders/tender-scheduler.service.ts
|
||||
- apps/api/src/tenders/tender-triage.service.ts
|
||||
- apps/api/src/user/admin-seed.service.ts
|
||||
- apps/api/src/user/user.controller.ts
|
||||
- apps/api/src/user/user.service.ts
|
||||
- apps/api/src/auth/auth.controller.spec.ts
|
||||
- apps/api/src/user/user.controller.spec.ts
|
||||
- apps/web/src/test/setup.ts
|
||||
|
||||
estimate:
|
||||
tokens: 260000
|
||||
raw_tokens: 173000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Jeder der 288 Befunde in apps/api hat am Ende genau ein Urteil: typisiert, auf unknown umgestellt, oder bleibt mit gemessener Begruendung (D-01)."
|
||||
- "Kein Befund wird durch eine Behauptung stillgelegt: noNonNullAssertion bleibt bei hoechstens 56, ts-expect-error/ts-ignore bleibt bei 0, Lint-Unterdrueckungsmarker bleiben bei 1, 'as unknown as' bleibt bei hoechstens 33 (D-02)."
|
||||
- "Das Verhalten der API ist unveraendert; jede Stelle, an der die neue Typisierung eine falsche Annahme im Bestandscode aufdeckt, wird als Befund gemeldet statt still korrigiert (D-03)."
|
||||
- "Nach JEDER Aufgabe: pnpm type-check 4/4 und pnpm lint 5/5 mit 0 Fehlern der Schwere error (D-05)."
|
||||
- "Nach JEDER Aufgabe: apps/api 72 Dateien / 1143 Tests gruen, apps/web 73 / 531 gruen (D-06)."
|
||||
- "Der gemeinsame Aufrufer-Typ leitet jedes Feld aus den beiden Signierstellen und der Verbraucherpruefung ab; kein Feld wird so getypt, dass eine bestehende Berechtigungspruefung als tot erscheint (D-02, D-03)."
|
||||
artifacts:
|
||||
- "apps/api/src/auth/types/auth-user.ts — AuthUser, AuthenticatedRequest, UploadedFileLike, JwtPayload"
|
||||
- "apps/api/src/prisma/prisma-tenant.extension.ts ohne interne Zusicherungen"
|
||||
- "Urteilsregister der bleibenden Befunde im SUMMARY, plus je eine Begruendungszeile direkt an der Codestelle"
|
||||
key_links:
|
||||
- "forTenant()/forSystem() Rueckgabewert -> 105 Aufrufstellen: der Zusicherungsverzicht darf den Erkenner in rls-access-inventory.spec.ts nicht blind machen"
|
||||
- "JwtStrategy.validate() -> AuthUser -> TenantGuard/RolesGuard: die Feldtypen entscheiden, ob Mandanten- und Rollenpruefungen weiterhin scharf sind"
|
||||
- "FileInterceptor ohne storage-Option -> multer memoryStorage -> file.buffer ist ein Buffer: nur deshalb ist UploadedFileLike ueberpruefbar und keine Behauptung"
|
||||
---
|
||||
|
||||
<objective>
|
||||
288 `any`-Befunde im Backend einzeln beurteilen und typisieren. Kein Aufraeumen nach Gefuehl: jeder Befund bekommt ein Urteil, jedes Urteil eine Begruendung, und die Zahl faellt nur um das, was tatsaechlich ehrlich getypt werden konnte (D-01).
|
||||
|
||||
Purpose: Der `any`-Rueckstand ist der letzte Posten des Rueckstands, den der Nutzer vor neuen Funktionen geraeumt haben will. Er hat keinen bekannten Fehler hinter sich — es ist reine Typarbeit. Der Wert liegt darin, dass die naechste Aenderung an Mandanten-, Rollen- und Upload-Wegen vom Compiler begleitet wird statt von Vertrauen.
|
||||
|
||||
Output: apps/api mit deutlich weniger `any`, einem gemeinsamen Aufrufer-Typ, und einem Register der Stellen, die bewusst stehen bleiben.
|
||||
|
||||
## Was vorab gemessen wurde (2026-09-21, Planungszeitpunkt)
|
||||
|
||||
Ausgangslage, mit `npx biome lint --reporter=json` gezaehlt (Feld `category`, nie durch Textsuche im Quelltext):
|
||||
|
||||
| Groesse | Wert |
|
||||
|---|---|
|
||||
| `lint/suspicious/noExplicitAny` in `apps/api/src` | **288** (48 Dateien, +1 in apps/web) |
|
||||
| `lint/style/noNonNullAssertion` in `apps/api/src` | 56 |
|
||||
| Befunde der Schwere `error` | 0 |
|
||||
| `ts-expect-error` / `ts-ignore` | 0 |
|
||||
| Lint-Unterdrueckungsmarker | 1 |
|
||||
| `as unknown as` | 33 |
|
||||
| `pnpm type-check` / `pnpm lint` | 4/4 und 5/5, Rueckgabewert 0 |
|
||||
| `apps/api` Vitest / `apps/web` Vitest | 72 Dateien / 1143 Tests, 73 / 531 |
|
||||
|
||||
Die Befunde fallen in wenige wiederkehrende Formen. Sie wurden nicht geschaetzt, sondern durch Probeumbauten am echten Baum gemessen (jeder Probeumbau danach zurueckgenommen, Baum wieder sauber):
|
||||
|
||||
| Form | Anzahl | Messergebnis |
|
||||
|---|---|---|
|
||||
| `forTenant(...) as any` / `forSystem(...) as any` | **105** | Zusicherung an allen 105 Stellen entfernt -> `tsc` meldet **genau einen** Folgefehler. Die Zusicherung war nie noetig: `prisma.$extends(...)` liefert bereits einen vollstaendig getypten Klienten. |
|
||||
| Gefolge davon (`: any[]`, `.map((g: any) => ...)`, `x as any[]`) | 22 | haengt am `any`-Klienten und faellt mit ihm |
|
||||
| Innereien von `prisma-tenant.extension.ts` | 15 | `(prisma as any)` und die Handannotationen an `$allOperations` entfernt -> `tsc` sauber. 13 fallen, 2 (`tx: any`) sind noch offen. |
|
||||
| `(req as any)` | 32 | sechs Controller auf einen getypten Request umgestellt -> `tsc` meldet **genau einen** Fehler, und der ist ein echter Befund (siehe unten) |
|
||||
| `@Req() req: any` | 25 | dito |
|
||||
| `@CurrentUser() user: any` | 13 | mit `AuthUser` getypt -> 8 Fehler im Produktivcode, 28 in Testdateien (Fixtures ohne `username`/`mustChangePassword`) |
|
||||
| `catch (e: any)` | 18 | Umstellung auf `unknown` plus Eingrenzung an der Verwendungsstelle |
|
||||
| node-forge in `cert-manager.service.ts` | 11 | gemischt; `@types/node-forge` ist installiert |
|
||||
| `@UploadedFile()` / `@UploadedFiles()` und ihre Dienst-Gegenstuecke | 11 | `Express.Multer.File` existiert hier **nicht** (kein `@types/multer`, gemessen). Alle `FileInterceptor`-Aufrufe setzen **keine** `storage`-Option -> multer memoryStorage -> `file.buffer` ist ein Buffer. |
|
||||
| `job as any` an `addCronJob` | 3 | Zusicherung entfernt -> `tsc` meldet 3 Fehler: das lokale `job` hat die Form `{ start(): void }`, `addCronJob` verlangt einen echten `CronJob`. **Nicht aufloesbar ohne Verhaltensaenderung.** |
|
||||
| Rest (httpntlm, imap, nodemailer, calendar, web-setup, ...) | 33 | einzeln zu beurteilen |
|
||||
|
||||
## Zwei Befunde, die die Messung schon jetzt aufgedeckt hat (D-03)
|
||||
|
||||
1. `apps/api/src/tenders/tender-matching.service.ts:159` — die Handannotation `(match: { tender: unknown })` verengt den Wert **faelschlich** auf `unknown`, sobald der Klient richtig getypt ist. Sie existierte nur, um unter dem `any`-Klienten TS7006 zu vermeiden. Loeschen ist der richtige Umgang, keine Zusicherung.
|
||||
2. `apps/api/src/dashboard/dashboard.controller.ts:74` — gibt `req.user?.role` (moeglicherweise `undefined`) an etwas weiter, das `Role` verlangt. Der Code nimmt an, dass ein Aufrufer immer vorhanden ist. Das ist als Befund zu melden, nicht stumm zu reparieren.
|
||||
|
||||
## Was am Ende erwartet wird, und warum
|
||||
|
||||
Erwartung: **etwa 20 bis 40 verbleibende Befunde** in `apps/api` (Ausgang 288). Herleitung, nicht Wunsch:
|
||||
|
||||
- Aufgabe 1 nimmt rund 136 (105 gemessen + 13 gemessen + rund 18 Gefolge).
|
||||
- Aufgabe 2 nimmt rund 90 (13 + 25 + 32 + 1 + 11 + rund 8 Umfeld).
|
||||
- Aufgabe 3 findet rund 62 vor und loest davon vielleicht 35; der Rest bleibt.
|
||||
|
||||
Bleiben werden voraussichtlich: die drei Cron-Stellen (gemessen nicht aufloesbar), ein Teil der node-forge-Stellen, an denen die mitgelieferten Typen die Bibliothek falsch beschreiben, sowie einzelne Stellen an Fremdbibliotheken ohne Typen. **Null ist ausdruecklich nicht das Ziel.** Wer die letzten Stellen erzwingt, tauscht eine ehrliche Warnung gegen eine unehrliche Behauptung — genau das verbietet D-02. Eine kleinere Zahl mit sauberen Urteilen ist das bessere Ergebnis.
|
||||
|
||||
## Keine laufende Umgebung noetig
|
||||
|
||||
Fuer reine Typarbeit ist kein Stapel noetig: alle Pruefungen dieses Plans sind `tsc`, Biome und die beiden Testlaeufe. Es wird **kein** `docker compose` gestartet. Sollte wider Erwarten eine Laufzeitfrage auftauchen, ist das ein Grund, sie als Befund zu melden (D-03), nicht ein Grund, einen Stapel hochzufahren.
|
||||
</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/src/prisma/prisma-tenant.extension.ts
|
||||
@apps/api/src/auth/strategies/jwt.strategy.ts
|
||||
@apps/api/src/auth/decorators/current-user.decorator.ts
|
||||
@apps/api/src/tenant/tenant.guard.ts
|
||||
@apps/api/src/bug-reports/bug-reports.service.ts
|
||||
</context>
|
||||
|
||||
<!-- planner-discipline-allow: biome-ignore, ts-expect-error, ts-ignore, as unknown as, noExplicitAny, noNonNullAssertion -->
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Aufgabe 1: Die Mandantenbindung entzaubern — 105 Zusicherungen, die nie noetig waren</name>
|
||||
<files>apps/api/src/prisma/prisma-tenant.extension.ts, apps/api/src/auth/auth.service.ts, apps/api/src/bug-reports/bug-reports.service.ts, apps/api/src/dkv/dkv.service.ts, apps/api/src/favorites/favorites.service.ts, apps/api/src/groups/groups.service.ts, apps/api/src/groups/module-grants.service.ts, apps/api/src/ldap/ldap-config.service.ts, apps/api/src/ldap/ldap.service.ts, apps/api/src/module-registry/module-access.service.ts, apps/api/src/module-registry/module-registry.service.ts, apps/api/src/settings/settings.service.ts, apps/api/src/tenant/tenant.controller.ts, apps/api/src/tenders/tender-digest.scheduler.ts, apps/api/src/tenders/tender-email-config.service.ts, apps/api/src/tenders/tender-matching.service.ts, apps/api/src/tenders/tender-notification-pref.service.ts, apps/api/src/tenders/tender-rss-feed.service.ts, apps/api/src/tenders/tender-saved-search.service.ts, apps/api/src/tenders/tender-triage.service.ts, apps/api/src/user/admin-seed.service.ts, apps/api/src/user/user.controller.ts, apps/api/src/user/user.service.ts</files>
|
||||
<read_first>apps/api/src/prisma/prisma-tenant.extension.ts (Kopfkommentar Zeile 1-196 erklaert, warum die Array-Form der Transaktion Pflicht ist — daran wird nichts geaendert), apps/api/src/prisma/rls-access-inventory.spec.ts (der Erkenner, der diese Aufrufstellen zaehlt), apps/api/src/groups/groups.service.ts Zeile 55-75 (zeigt das Gefolge: der `any`-Klient erzwingt `any[]` und `(g: any)`), apps/api/src/tenders/tender-matching.service.ts Zeile 135-170</read_first>
|
||||
<action>
|
||||
Der groesste Block ist zugleich der harmloseste, und deshalb steht er zuerst: er beweist die Methode an der breitesten Stelle, bevor irgendetwas Sicherheitsrelevantes angefasst wird.
|
||||
|
||||
Schritt A — die Innereien des Erweiterungsmoduls. In `prisma-tenant.extension.ts` sind die Zusicherungen `(prisma as any)` in `forTenant`, `forSystem` und `withTenantTransaction` unnoetig: `PrismaClient` traegt `$executeRaw` und `$transaction` bereits. Entferne sie. Entferne ebenso die Handannotation an `$allOperations` — der Parameter wird von Prisma hergeleitet, die Annotation `{ args: any; query: (args: any) => any }` ersetzt eine korrekte Herleitung durch drei `any`. Stelle `.then((results: any[]) => results[1])` auf `unknown[]` um; der Ergebnistyp der Aufrufstellen kommt aus Prismas Erweiterungstypen, nicht aus diesem Rueckgabewert (gemessen: `tsc` bleibt danach sauber). Fuer `withTenantTransaction` bleibt `fn: (tx: any)`: pruefe, ob `Prisma.TransactionClient` hier passt, und wenn ja, ziehe die vier Aufrufstellen in `groups.service.ts` und `favorites.service.ts` mit. Wenn `tsc` das nicht traegt, ist `bleibt` mit gemessener Begruendung das richtige Urteil (D-01).
|
||||
|
||||
Am Kopfkommentar (Zeile 1-196) wird nichts geaendert. Er dokumentiert Messungen zu Verbindungen und Transaktionen, nicht zu Typen.
|
||||
|
||||
Schritt B — die 105 Aufrufstellen. Die Form ist ueberall `const tenantPrisma = forTenant(this.prisma, tenantId) as any;` beziehungsweise `const systemPrisma = forSystem(this.prisma) as any;`. Entferne die Zusicherung. Gemessen: `tsc` meldet danach genau einen Folgefehler, naemlich den aus Schritt C.
|
||||
|
||||
Wichtig fuer die Mandantentrennung: der Erkenner in `rls-access-inventory.spec.ts` sucht nach `const <Name> = forTenant(` beziehungsweise `const <Name> = forSystem(`. Das Entfernen der nachgestellten Zusicherung beruehrt diesen Praefix nicht. Aendere die Zuweisungsform nicht, fasse keine zwei Aufrufe zusammen, und verschiebe keinen Aufruf in eine andere Datei — die Erlaubnisliste `FORSYSTEM_ALLOWED_CALL_SITES` haelt je Datei eine exakte Zahl fest, jede Abweichung macht die Spec rot. Das ist die eigentliche Schutzwirkung dieser Aufgabe und darf nicht beschaedigt werden (T-M34-03).
|
||||
|
||||
Schritt C — das Gefolge. Mit einem richtig getypten Klienten werden die Handannotationen, die nur seinetwegen dastanden, von Hilfe zu Schaden. Entferne sie:
|
||||
- `const groups: any[] = await tenantPrisma.group.findMany(...)` in `groups.service.ts:63` und `const masters: any[] = ...` in `dkv.service.ts:755` — samt des erklaerenden Kommentars darueber, der jetzt nicht mehr stimmt.
|
||||
- die `.map((g: any) => ...)`, `.map((u: any) => ...)`, `.map((a: any) => ...)`, `(a: any, b: any) =>` und `for (const g of groupGrants as any[])`-Stellen in `groups.service.ts`, `module-grants.service.ts`, `ldap-config.service.ts`, `tenders.controller.ts:270`.
|
||||
- **Der gemessene Befund:** `tender-matching.service.ts:159` traegt `(match: { tender: unknown })`. Diese Handannotation verengt den Wert falsch, sobald der Klient getypt ist, und erzeugt den einen Fehler aus Schritt B. Loesche die Annotation, damit der hergeleitete Typ durchkommt. Setze hier **keine** Zusicherung.
|
||||
|
||||
Fuer jede Stelle, an der das Entfernen einer Annotation einen neuen `tsc`-Fehler erzeugt, gilt: der Fehler ist ein Befund. Pruefe, was der Code tatsaechlich annimmt. Wenn die Annahme falsch war, melde sie im SUMMARY und lass das Verhalten unangetastet (D-03). Ersetze sie **nicht** durch eine Zusicherung, eine Ausrufezeichen-Behauptung oder einen Unterdrueckungskommentar (D-02) — die Zaehlwerte in `<verify>` fangen genau das ab.
|
||||
|
||||
Keine Formatierung ueber den Bestand hinaus, keine Versionsspruenge, keine neuen Abhaengigkeiten, und die Testdatei-Ausnahme in `biome.json` bleibt unberuehrt (D-04).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && npx biome lint --reporter=json --max-diagnostics=2000 apps/api/src | python3 -c "import json,sys; d=json.load(sys.stdin)['diagnostics']; a=sum(1 for x in d if x.get('category')=='lint/suspicious/noExplicitAny'); n=sum(1 for x in d if x.get('category')=='lint/style/noNonNullAssertion'); e=sum(1 for x in d if x.get('severity')=='error'); print('any',a,'nonnull',n,'error',e); sys.exit(0 if a<=155 and n<=56 and e==0 else 1)"</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && test "$(grep -rho 'ts-expect-error' apps/api/src --include='*.ts' | wc -l)" = 0 && test "$(grep -rho 'ts-ignore' apps/api/src --include='*.ts' | wc -l)" = 0 && test "$(grep -rho 'biome-ignore' apps/api/src --include='*.ts' | wc -l)" = 1 && test "$(grep -rho 'as unknown as' apps/api/src --include='*.ts' | wc -l)" -le 33 && echo "keine stillgelegten Stellen"</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm type-check 2>&1 | grep -q '4 successful, 4 total' && pnpm lint 2>&1 | grep -q '5 successful, 5 total' && echo "type-check 4/4, lint 5/5"</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --dir apps/api run test 2>&1 | tee /tmp/m34-api.log | tail -5 && grep -q 'Test Files 72 passed (72)' /tmp/m34-api.log && grep -q 'Tests 1143 passed (1143)' /tmp/m34-api.log && echo "api 72/1143 gruen"</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --dir apps/web run test 2>&1 | tee /tmp/m34-web.log | tail -5 && grep -q 'Test Files 73 passed (73)' /tmp/m34-web.log && grep -q 'Tests 531 passed (531)' /tmp/m34-web.log && echo "web 73/531 gruen"</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --dir apps/api exec vitest run src/prisma/rls-access-inventory.spec.ts 2>&1 | tail -4</automated>
|
||||
</verify>
|
||||
<done>Die Zaehlung liegt bei hoechstens 155 (Ausgang 288), `noNonNullAssertion` bei hoechstens 56, `error`-Befunde bei 0, keine neuen Unterdrueckungsmarker. `pnpm type-check` 4/4 und `pnpm lint` 5/5. Beide Testsuiten unveraendert gruen (72/1143 und 73/531), `rls-access-inventory.spec.ts` ausdruecklich gruen. Der Kopfkommentar von `prisma-tenant.extension.ts` ist unveraendert. Jede Stelle, an der ein neuer `tsc`-Fehler auftrat, steht als Befund im SUMMARY.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Aufgabe 2: Wer ruft hier eigentlich an — ein gemeinsamer Typ fuer Aufrufer, Request und Upload</name>
|
||||
<files>apps/api/src/auth/types/auth-user.ts, apps/api/src/auth/auth.controller.ts, apps/api/src/auth/auth.service.ts, apps/api/src/auth/strategies/jwt.strategy.ts, apps/api/src/auth/strategies/local.strategy.ts, apps/api/src/auth/interceptors/force-password-change.interceptor.ts, apps/api/src/bug-reports/bug-reports.controller.ts, apps/api/src/bug-reports/bug-reports.service.ts, apps/api/src/calendar/calendar.controller.ts, apps/api/src/cert-manager/cert-manager.controller.ts, apps/api/src/cert-manager/cert-manager.service.ts, apps/api/src/dashboard/dashboard.controller.ts, apps/api/src/dkv/dkv.controller.ts, apps/api/src/favorites/favorites.controller.ts, apps/api/src/groups/groups.controller.ts, apps/api/src/groups/module-grants.controller.ts, apps/api/src/ldap/ldap.controller.ts, apps/api/src/module-registry/module-registry.controller.ts, apps/api/src/settings/settings.controller.ts, apps/api/src/tenders/tenders.controller.ts, apps/api/src/user/user.controller.ts, apps/api/src/auth/auth.controller.spec.ts, apps/api/src/user/user.controller.spec.ts</files>
|
||||
<read_first>apps/api/src/auth/strategies/jwt.strategy.ts (die Quelle des Aufrufer-Objekts), apps/api/src/auth/auth.service.ts Zeile 165-200 und 370-395 (die beiden Stellen, die das Token signieren — sie bestimmen, welche Felder es ueberhaupt gibt), apps/api/src/tenant/tenant.guard.ts (der Verbraucher, dessen Pruefungen scharf bleiben muessen), apps/api/src/bug-reports/bug-reports.service.ts Zeile 55-90 (`SessionUser` und `UploadedPng` — das Vorbild im Bestand fuer schmale, nur die gelesenen Felder beschreibende Schnittstellen), apps/api/prisma/schema.prisma Zeile 23-53 (Role-Aufzaehlung und User-Modell)</read_first>
|
||||
<behavior>
|
||||
- `AuthUser` beschreibt genau die fuenf Felder, die `JwtStrategy.validate()` zurueckgibt, und keines mehr.
|
||||
- Jedes Feld ist aus den beiden Signierstellen in `auth.service.ts` belegbar; kein Feld ist erfunden.
|
||||
- `AuthenticatedRequest` beschreibt `user` (von Passport gesetzt) und `tenantId` (von `TenantGuard` gesetzt, dort auch auf `null` gesetzt) mit den Optionalitaeten, die diese beiden Setzer tatsaechlich erzeugen.
|
||||
- `UploadedFileLike` beschreibt ausschliesslich die Felder, die der Code liest: `buffer`, `originalname`, `mimetype`, `size`.
|
||||
- Keine bestehende Berechtigungspruefung wird durch die Typisierung tot, und keine wird entfernt.
|
||||
</behavior>
|
||||
<action>
|
||||
Das ist die Aufgabe mit Sicherheitsfolgen. Sie faellt den einen Typ, an dem jede Mandanten- und Rollenentscheidung der API haengt. Deshalb wird hier nichts geraten.
|
||||
|
||||
Schritt A — die Typen anlegen, in `apps/api/src/auth/types/auth-user.ts`. Vorbild ist ausdruecklich `SessionUser`/`UploadedPng` in `bug-reports.service.ts`: schmale Schnittstellen, die nur beschreiben, was gelesen wird. Ziehe `SessionUser` und `UploadedPng` danach auf die neuen Typen zurueck, damit es keine zwei konkurrierenden Beschreibungen desselben Objekts gibt.
|
||||
|
||||
- `AuthUser` — leite jedes einzelne Feld aus `JwtStrategy.validate()` (Zeile 27-38) ab und belege es an den **beiden** Signierstellen `auth.service.ts:171` und `auth.service.ts:376`. Felder: `id`, `username`, `role`, `tenantId`, `mustChangePassword`.
|
||||
- Zu `role`: die Aufzaehlung `Role` aus `@prisma/client` ist der ehrliche Typ, weil der Signierer genau den Spaltenwert schreibt. Gemessen: sechs Controller vertragen `role: Role` ohne einen einzigen Fehler. Wenn `tsc` irgendwo doch anschlaegt, weil eine Stelle gegen eine Zeichenkette ausserhalb der Aufzaehlung vergleicht, ist das ein Befund nach D-03 und wird gemeldet, nicht mit einer Zusicherung beruhigt (T-M34-02).
|
||||
- **Zu `tenantId` — das ist die sicherheitsrelevante Entscheidung dieses Plans.** Die Messung: `tenantId: string | undefined` erzeugt acht Fehler im Produktivcode, `tenantId: string` keinen. Das ist **kein** Argument fuer die bequemere Variante. Pruefe stattdessen die Belege: die Spalte `User.tenantId` ist in `schema.prisma` **nicht optional**; der Bestand beschreibt dasselbe Objekt in `SessionUser` bereits als `tenantId: string`; und `TenantGuard` haelt fuer SUPER_ADMIN einen Zweig ohne Mandanten vor. Entscheide auf dieser Grundlage und schreibe die Begruendung als Kommentar an den Typ. Wenn die Wahl auf `string` faellt, halte im selben Kommentar ausdruecklich fest, dass der SUPER_ADMIN-Zweig in `TenantGuard` ein Schutzzweig bleibt und **nicht** wegtypisiert oder entfernt werden darf, weil er sonst bei der naechsten Aenderung als toter Code geloescht wird (T-M34-01). Entferne an `TenantGuard` in dieser Aufgabe nichts.
|
||||
- `AuthenticatedRequest` — erweitert `Request` aus `express` um `user` und `tenantId`. `TenantGuard` setzt `tenantId` auf eine Zeichenkette **oder** auf `null` und laeuft auf oeffentlichen Wegen gar nicht; die Optionalitaeten muessen das abbilden.
|
||||
- `UploadedFileLike` — `Express.Multer.File` gibt es in diesem Baum **nicht** (gemessen: `@types/multer` ist nicht installiert), und D-04 verbietet, es nachzuinstallieren. Eine eigene schmale Schnittstelle ist hier trotzdem keine Behauptung, sondern belegbar: kein einziger `FileInterceptor`/`FilesInterceptor`-Aufruf setzt eine `storage`-Option, damit gilt multer memoryStorage, damit ist `file.buffer` ein Buffer. Pruefe das nach und beschreibe **nur** die gelesenen Felder. Nimm kein Feld auf, das kein Aufrufer liest.
|
||||
- `JwtPayload` — fuer `jwt.strategy.ts:27` (`payload: any`). Die Nutzlast ist an den beiden Signierstellen vollstaendig belegt; beschreibe sie danach. `mustChangePassword` wird dort bereits bewusst mit `=== true` gegen alte Token abgesichert (260921-fi3, D-01) — dieses Verhalten bleibt woertlich erhalten.
|
||||
|
||||
Schritt B — anwenden. Ersetze `@CurrentUser() user: any` (13 Stellen), `@Req() req: any` (25), `(req as any)` (32), `@Res() res: any` (1), `@UploadedFile()`/`@UploadedFiles()` samt der Dienst-Gegenstuecke `file?: any` und `files?: any[]` in `cert-manager.service.ts` (11). Zieh das Umfeld mit: `resolveTargetTenantId(currentUser: any)`, `resolveTargetUser(currentUser: any)`, `login(user: any)`, `validateUser(): Promise<any>`, `local.strategy` `validate(): Promise<any>`, `normalizePath(request: any)`, `intercept(): Observable<any>` (dort ist `unknown` der ehrliche Typ), `dkv.controller` `_requireTenant(req: any)`.
|
||||
|
||||
Ein Nebengewinn, der mitzunehmen ist: in `cert-manager.service.ts` stehen heute rund 25 Zusicherungen der Form `file.buffer as Buffer` und `file.originalname as string`, die es nur gibt, weil `file` ein `any` ist. Mit der schmalen Schnittstelle fallen sie weg — der Zaehlwert `as unknown as` darf dabei nicht steigen.
|
||||
|
||||
**Der gemessene Befund:** `dashboard.controller.ts:74` gibt einen moeglicherweise fehlenden Aufrufer-Wert an etwas weiter, das ihn zwingend verlangt. Das ist eine falsche Annahme im Bestandscode. Melde sie im SUMMARY mit Datei, Zeile und dem, was der Code annimmt. Verhalten unveraendert lassen (D-03): keine neue Pruefung einbauen, die vorher nicht da war, und keinen Wert erfinden.
|
||||
|
||||
Schritt C — die Testfixtures. Gemessen: 28 `tsc`-Fehler in `auth.controller.spec.ts` und `user.controller.spec.ts`, weil die Fixtures Teilobjekte wie `{ id, tenantId, role }` uebergeben. Ergaenze die fehlenden Felder in den Fixtures. Das ist reine Fixture-Pflege: die Zahl der Testdateien und Tests darf sich **nicht** aendern (D-06), und keine Zusicherung in einer Testdatei darf den Fehler stattdessen zudecken. Die Testdatei-Ausnahme in `biome.json` bleibt unberuehrt (D-04).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && npx biome lint --reporter=json --max-diagnostics=2000 apps/api/src | python3 -c "import json,sys; d=json.load(sys.stdin)['diagnostics']; a=sum(1 for x in d if x.get('category')=='lint/suspicious/noExplicitAny'); n=sum(1 for x in d if x.get('category')=='lint/style/noNonNullAssertion'); e=sum(1 for x in d if x.get('severity')=='error'); print('any',a,'nonnull',n,'error',e); sys.exit(0 if a<=75 and n<=56 and e==0 else 1)"</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && test "$(grep -rho 'ts-expect-error' apps/api/src --include='*.ts' | wc -l)" = 0 && test "$(grep -rho 'ts-ignore' apps/api/src --include='*.ts' | wc -l)" = 0 && test "$(grep -rho 'biome-ignore' apps/api/src --include='*.ts' | wc -l)" = 1 && test "$(grep -rho 'as unknown as' apps/api/src --include='*.ts' | wc -l)" -le 33 && echo "keine stillgelegten Stellen"</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && git diff --stat -- apps/api/src/tenant/tenant.guard.ts > /tmp/m34-guard.txt || exit 1; cat /tmp/m34-guard.txt; test ! -s /tmp/m34-guard.txt && echo "TenantGuard unberuehrt"</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm type-check 2>&1 | grep -q '4 successful, 4 total' && pnpm lint 2>&1 | grep -q '5 successful, 5 total' && echo "type-check 4/4, lint 5/5"</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --dir apps/api run test 2>&1 | tee /tmp/m34-api.log | tail -5 && grep -q 'Test Files 72 passed (72)' /tmp/m34-api.log && grep -q 'Tests 1143 passed (1143)' /tmp/m34-api.log && echo "api 72/1143 gruen"</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --dir apps/web run test 2>&1 | tee /tmp/m34-web.log | tail -5 && grep -q 'Test Files 73 passed (73)' /tmp/m34-web.log && grep -q 'Tests 531 passed (531)' /tmp/m34-web.log && echo "web 73/531 gruen"</automated>
|
||||
</verify>
|
||||
<done>Die Zaehlung liegt bei hoechstens 75. `apps/api/src/auth/types/auth-user.ts` existiert und traegt die Begruendung jedes Feldes als Kommentar, mit Verweis auf die Signierstellen und auf `schema.prisma`. `tenant.guard.ts` ist unveraendert. `SessionUser`/`UploadedPng` in `bug-reports.service.ts` sind auf die neuen Typen zurueckgezogen, es gibt keine zweite Beschreibung desselben Objekts. `noNonNullAssertion` hoechstens 56, `as unknown as` hoechstens 33, keine neuen Unterdrueckungsmarker, 0 `error`-Befunde. Beide Suiten gruen mit unveraenderten Zahlen. Der Befund aus `dashboard.controller.ts:74` steht im SUMMARY.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Aufgabe 3: Die Randschicht — Fehlerfaenger auf unknown, und ein Urteil fuer jede Stelle, die bleibt</name>
|
||||
<files>apps/api/src/calendar/calendar.service.ts, apps/api/src/calendar/providers/exchange.provider.ts, apps/api/src/cert-manager/cert-manager.service.ts, apps/api/src/dkv/dkv-scheduler.service.ts, apps/api/src/groups/groups.service.ts, apps/api/src/groups/module-grants.service.ts, apps/api/src/inbox/exchange-inbox.provider.ts, apps/api/src/inbox/imap.provider.ts, apps/api/src/ldap/ldap.service.ts, apps/api/src/mail/mail.service.ts, apps/api/src/auth/auth.service.ts, apps/api/src/tenders/tender-digest.scheduler.ts, apps/api/src/tenders/tender-email-config.service.ts, apps/api/src/tenders/tender-notification-pref.service.ts, apps/api/src/tenders/tender-saved-search.service.ts, apps/api/src/tenders/tender-scheduler.service.ts, apps/api/src/tenders/tender-triage.service.ts, apps/api/src/user/admin-seed.service.ts, apps/api/src/user/user.service.ts, apps/web/src/test/setup.ts</files>
|
||||
<read_first>apps/api/src/cert-manager/cert-manager.service.ts Zeile 220-290 und 490-545 (node-forge-Umgang), apps/api/src/dkv/dkv-scheduler.service.ts Zeile 120-140 (der require-Umweg fuer CronJob aus 07-04), apps/api/src/inbox/imap.provider.ts Zeile 20-70, apps/api/src/groups/groups.service.ts Zeile 95-110 (ein typischer Fehlerfaenger mit Prisma-Fehlercode-Pruefung)</read_first>
|
||||
<action>
|
||||
Der Rest, Form fuer Form. Hier ist die Erwartung ausdruecklich gemischt: ein Teil wird getypt, ein Teil bleibt — und das Bleiben ist ein vollwertiges Ergebnis, kein Versagen (D-01).
|
||||
|
||||
Schritt A — die 18 Fehlerfaenger. `catch (e: any)` wird zu `catch (e: unknown)` plus Eingrenzung an der Verwendungsstelle. Fast alle lesen `err.code` (Prisma-Fehlercodes wie P2025, P2002) oder `err.message`. Grenze ein, statt zu behaupten: eine Pruefung auf Objektform und Feld, oder `instanceof Error` fuer `message`. Der Bestand hat dafuer bereits ein Vorbild in `tender-matching.service.ts:123` (`(err as Error).message`) — das ist die schwaechere Variante; wo eine echte Eingrenzung ohne Aufwand moeglich ist, nimm die echte. Entscheidend: **welcher Zweig genommen wird, darf sich nicht aendern**. Ein Fehlerfaenger, der heute bei einem fremden Fehlerobjekt in den einen Zweig laeuft, muss das danach auch tun (D-03). Wo der gefangene Wert gar nicht gelesen wird, ist die Bindung ersatzlos zu streichen — genau das hat 260921-bi2 an einer Stelle bereits so gemacht.
|
||||
|
||||
Schritt B — die Stellen mit Fremdbibliotheken, einzeln beurteilt:
|
||||
|
||||
- **`addCronJob(..., job as any)`** in `dkv-scheduler.service.ts:136`, `tender-digest.scheduler.ts:86`, `tender-scheduler.service.ts:135`. **Gemessen: bleibt.** Ohne Zusicherung meldet `tsc` an allen drei Stellen, dass das lokale `job` die Form `{ start(): void }` hat, waehrend `addCronJob` einen vollstaendigen `CronJob` verlangt. Ursache ist der `require()`-Umweg aus 07-04 (pnpm-Isolation, `cron` ist eine mittelbare Abhaengigkeit). Das aufzuloesen hiesse, die Beschaffung der Klasse zu aendern — das waere eine Verhaltensaenderung und ist hier verboten (D-03), und eine neue Abhaengigkeit ist ebenfalls verboten (D-04). Trage das Urteil samt dieser Begruendung als kurzen Kommentar an jeder der drei Stellen ein.
|
||||
- **node-forge in `cert-manager.service.ts`** (11 Stellen: `p7: any` viermal, `cert.publicKey as any`, `cert.siginfo as any`, `sanExt as any`, `(n: any)`, `null as any` zweimal). `@types/node-forge` **ist** installiert. Pruefe Stelle fuer Stelle, ob der mitgelieferte Typ passt. Wo er passt: typisieren. Wo die mitgelieferten Typen die Bibliothek nachweislich falsch beschreiben — die beiden `null as any` tragen bereits den Vermerk, dass node-forge 1.4.0 einen fehlenden Schluessel akzeptiert, die Typen das aber ausschliessen — ist `bleibt` das ehrliche Urteil. Erzwinge dort nichts: eine Umdeutung ueber zwei Stufen waere schlimmer als das `any`, weil sie dieselbe Luecke verdeckt und zusaetzlich so aussieht, als sei sie geprueft (D-02).
|
||||
- **httpntlm** (`exchange-inbox.provider.ts:3` und `:241`, `exchange.provider.ts:6`) und **`authProvider`-Rueckrufe** (`exchange.provider.ts:157`, `:313`). Die Handschnittstelle fuer `post` existiert schon; sie kann enger werden, weil der Code genau weiss, welche Optionen er uebergibt und welche Antwortfelder er liest. Beschreibe nur diese. Der Fehlerparameter eines Rueckrufs ist ehrlich `Error | null`.
|
||||
- **imap** (`stream as any`, `node as any` zweimal, `} as any`) und **`nodemailer.createTransport(resolved.options as any)`**: einzeln pruefen. Wo eine Eingrenzung reicht, eingrenzen; sonst Urteil `bleibt` mit Begruendung.
|
||||
- **`(response as any).cookie(...)`** in `auth.service.ts:384`: `Response` aus `express` ist in dieser Datei bereits importiert und wird an der Schwesterstelle `auth.service.ts:181` ohne Zusicherung benutzt. Pruefe, ob die Zusicherung schlicht ueberfluessig ist.
|
||||
- **`data as any`** in `calendar.service.ts:206` und `:255`: Prisma-JSON-Eingaben. `Prisma.InputJsonValue` ist der vorgesehene Typ; pruefe, ob er traegt.
|
||||
- **`let created: any` / `const updateData: any`** in `user.service.ts:110` und `:161`: Prismas erzeugte Typen decken beides ab.
|
||||
- **`apps/web/src/test/setup.ts:8`** (`expect.extend(matchers as any)`): der einzige Befund ausserhalb von apps/api. Die Datei heisst `setup.ts` und faellt deshalb **nicht** unter die Testdatei-Ausnahme in `biome.json` — diese Ausnahme wird nicht erweitert (D-04). Beurteile die Stelle: passen die jest-dom-Matcher-Typen auf Vitests `expect.extend`, oder ist das ein bekannter Versatz zwischen den beiden Typwelten? Urteil eintragen, so oder so.
|
||||
|
||||
Schritt C — das Urteilsregister. Fuehre im SUMMARY eine Tabelle mit **jeder** Stelle, die stehen bleibt: Datei, Zeile, Form, Begruendung. Das ist der Zweck der ganzen Aufgabe — ein spaeterer Durchlauf soll diese Stellen nicht noch einmal aufreissen, sondern nachlesen koennen, warum sie so sind. Ergaenze zusaetzlich je eine kurze Begruendungszeile direkt an der Codestelle, damit die Auskunft auch dort steht, wo jemand sie zuerst sucht. Diese Zeilen sind normale erklaerende Kommentare; sie duerfen **keinen** Marker enthalten, der die Pruefung stilllegt — die Zaehlwerte in `<verify>` fangen das ab, und der Befund soll ja sichtbar bleiben (D-01, D-05).
|
||||
|
||||
Halte ausserdem im SUMMARY fest: Ausgangszahl 288, Endzahl, und die Aufteilung der 288 auf die drei Urteile.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && npx biome lint --reporter=json --max-diagnostics=2000 apps/api/src | python3 -c "import json,sys; d=json.load(sys.stdin)['diagnostics']; a=sum(1 for x in d if x.get('category')=='lint/suspicious/noExplicitAny'); n=sum(1 for x in d if x.get('category')=='lint/style/noNonNullAssertion'); e=sum(1 for x in d if x.get('severity')=='error'); print('any',a,'nonnull',n,'error',e); sys.exit(0 if a<=45 and n<=56 and e==0 else 1)"</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && npx biome lint --reporter=json --max-diagnostics=2000 apps/api/src apps/web/src | python3 -c "import json,sys; d=json.load(sys.stdin)['diagnostics']; [print(x['location']['path']+':'+str(x['location']['start']['line'])) for x in d if x.get('category')=='lint/suspicious/noExplicitAny']" | sort > /tmp/m34-rest.txt; wc -l < /tmp/m34-rest.txt; echo "--- jede dieser Zeilen muss im Urteilsregister des SUMMARY stehen ---"; cat /tmp/m34-rest.txt</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && test "$(grep -rho 'ts-expect-error' apps/api/src apps/web/src --include='*.ts' --include='*.tsx' | wc -l)" = 0 && test "$(grep -rho 'ts-ignore' apps/api/src apps/web/src --include='*.ts' --include='*.tsx' | wc -l)" = 0 && test "$(grep -rho 'biome-ignore' apps/api/src --include='*.ts' | wc -l)" = 1 && test "$(grep -rho 'as unknown as' apps/api/src --include='*.ts' | wc -l)" -le 33 && echo "keine stillgelegten Stellen"</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && git diff --stat HEAD -- biome.json > /tmp/m34-biome.txt || exit 1; cat /tmp/m34-biome.txt; test ! -s /tmp/m34-biome.txt && echo "biome.json unberuehrt"</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && git diff --stat HEAD -- package.json apps/api/package.json apps/web/package.json pnpm-lock.yaml > /tmp/m34-deps.txt || exit 1; cat /tmp/m34-deps.txt; test ! -s /tmp/m34-deps.txt && echo "keine neuen Abhaengigkeiten, keine Versionsspruenge"</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm type-check 2>&1 | grep -q '4 successful, 4 total' && pnpm lint 2>&1 | grep -q '5 successful, 5 total' && echo "type-check 4/4, lint 5/5"</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --dir apps/api run test 2>&1 | tee /tmp/m34-api.log | tail -5 && grep -q 'Test Files 72 passed (72)' /tmp/m34-api.log && grep -q 'Tests 1143 passed (1143)' /tmp/m34-api.log && echo "api 72/1143 gruen"</automated>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --dir apps/web run test 2>&1 | tee /tmp/m34-web.log | tail -5 && grep -q 'Test Files 73 passed (73)' /tmp/m34-web.log && grep -q 'Tests 531 passed (531)' /tmp/m34-web.log && echo "web 73/531 gruen"</automated>
|
||||
</verify>
|
||||
<done>Die Zaehlung in `apps/api` liegt bei hoechstens 45. Jede verbleibende Stelle aus der Ausgabe der zweiten Pruefung steht mit Datei, Zeile und Begruendung im Urteilsregister des SUMMARY und traegt eine Begruendungszeile im Code. `biome.json`, die `package.json`-Dateien und `pnpm-lock.yaml` sind unveraendert. `noNonNullAssertion` hoechstens 56, `as unknown as` hoechstens 33, keine Unterdrueckungsmarker ueber den einen Bestandsmarker hinaus, 0 `error`-Befunde. `pnpm type-check` 4/4, `pnpm lint` 5/5, beide Suiten gruen mit unveraenderten Zahlen. Das SUMMARY nennt Ausgangszahl 288, Endzahl und die Aufteilung auf die drei Urteile.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Browser -> API (JWT-Cookie) | Das Sitzungstoken traegt Identitaet, Rolle und Mandant. `JwtStrategy.validate()` ist die Stelle, an der daraus ein Objekt wird, das jede spaetere Berechtigungsentscheidung traegt. |
|
||||
| API -> PostgreSQL (RLS) | `forTenant()`/`forSystem()` setzen die Sitzungsvariablen, an denen die Zeilenregeln haengen. Wer hier die Bindung verliert, sieht fremde Mandanten oder gar nichts. |
|
||||
| Browser -> API (multipart) | Hochgeladene Dateien werden als Zertifikate, CSV und Bilder weiterverarbeitet. |
|
||||
| Aufrufer-Objekt -> TenantGuard/RolesGuard | Die Typen der Felder entscheiden mit, ob eine bestehende Pruefung als lebendig oder als tot gelesen wird. |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-M34-01 | Elevation of Privilege | `apps/api/src/auth/types/auth-user.ts` -> `tenant.guard.ts` | high | mitigate | `tenantId` wird nicht nach Bequemlichkeit getypt. Aufgabe 2 verlangt die Herleitung aus drei Belegen (Spalte `User.tenantId` in `schema.prisma`, Bestandstyp `SessionUser`, SUPER_ADMIN-Zweig in `TenantGuard`) und einen Kommentar am Typ, der festhaelt, dass der Schutzzweig in `TenantGuard` nicht wegtypisiert werden darf. `<verify>` erzwingt zusaetzlich, dass `tenant.guard.ts` in dieser Aufgabe unveraendert bleibt (`git diff --stat` leer). |
|
||||
| T-M34-02 | Elevation of Privilege | `role`-Feld, alle Rollenvergleiche | medium | mitigate | `role` als `Role`-Aufzaehlung macht jeden Vergleich gegen eine Zeichenkette ausserhalb der Aufzaehlung zum Compilerfehler. Jeder solche Fehler ist nach D-03 ein zu meldender Befund und darf nicht per Zusicherung beruhigt werden; die Zaehlwerte fuer `as unknown as` und Ausrufezeichen-Behauptungen in `<verify>` fangen den Umweg ab. |
|
||||
| T-M34-03 | Tampering | `forTenant()`/`forSystem()`, 105 Aufrufstellen | high | mitigate | Die Zuweisungsform `const X = forTenant(` bleibt woertlich erhalten — nur die nachgestellte Zusicherung faellt. Der Erkenner `rls-access-inventory.spec.ts` und die exakten Zahlen in `FORSYSTEM_ALLOWED_CALL_SITES` sind die Kontrolle; Aufgabe 1 laesst diese Spec zusaetzlich einzeln laufen. Kein Aufruf wird zusammengefasst, verschoben oder hinzugefuegt. |
|
||||
| T-M34-04 | Information Disclosure | `UploadedFileLike`, `cert-manager`/`user`/`bug-reports`/`dkv`-Uploads | medium | mitigate | Die Schnittstelle beschreibt ausschliesslich gelesene Felder und ist an der Konfiguration belegt (kein `storage`-Argument -> memoryStorage -> `buffer` ist ein Buffer). Sie ersetzt keine Pruefung: Groessengrenzen bleiben in den Interceptor-Optionen, die PNG-Signaturpruefung in `bug-reports.service.ts` bleibt, und D-03 verbietet jede Verhaltensaenderung an diesen Wegen. |
|
||||
| T-M34-05 | Repudiation | Urteilsregister | low | mitigate | Ohne Register waere nach diesem Lauf nicht nachvollziehbar, welche Stelle geprueft und bewusst gelassen wurde und welche nur uebersehen wurde. Aufgabe 3 erzeugt das Register aus der maschinellen Restliste, nicht aus dem Gedaechtnis; die zweite Pruefung in `<verify>` druckt genau diese Liste aus. |
|
||||
| T-M34-06 | Denial of Service | Fehlerfaenger, Umstellung auf `unknown` | medium | mitigate | Eine falsche Eingrenzung koennte einen Fehler kuenftig in einen anderen Zweig laufen lassen und damit einen Hintergrundlauf abbrechen, der heute weiterlaeuft. Aufgabe 3 schreibt ausdruecklich fest, dass die Zweigwahl unveraendert bleiben muss; die 1143 Tests in `apps/api` decken die Fehlerwege der betroffenen Dienste ab und muessen gruen bleiben. |
|
||||
| T-M34-SC | Tampering | Lieferkette | low | accept | Dieser Plan installiert nichts. D-04 verbietet neue Abhaengigkeiten und Versionsspruenge; `<verify>` in Aufgabe 3 erzwingt, dass `package.json` und `pnpm-lock.yaml` unveraendert bleiben. Damit entsteht keine neue Lieferkettenflaeche, und das Paket-Echtheitstor ist nicht anwendbar. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Nach jeder einzelnen Aufgabe, nicht nur am Ende (D-05, D-06):
|
||||
|
||||
1. `pnpm type-check` meldet `4 successful, 4 total`.
|
||||
2. `pnpm lint` meldet `5 successful, 5 total`, Rueckgabewert 0.
|
||||
3. Biome-JSON ueber `apps/api/src`: `severity == error` ist 0; `lint/style/noNonNullAssertion` hoechstens 56; `lint/suspicious/noExplicitAny` unter der Schranke der jeweiligen Aufgabe (155 / 75 / 45).
|
||||
4. `grep`-Zaehlungen ueber `apps/api/src`: `ts-expect-error` 0, `ts-ignore` 0, Lint-Unterdrueckungsmarker 1, `as unknown as` hoechstens 33.
|
||||
5. `apps/api` Vitest: 72 Dateien, 1143 Tests, alle gruen. `apps/web` Vitest: 73 Dateien, 531 Tests, alle gruen.
|
||||
6. `biome.json`, `package.json` (alle drei), `pnpm-lock.yaml` unveraendert.
|
||||
|
||||
Gezaehlt wird ausschliesslich ueber das Feld `category` im JSON-Bericht von Biome, nie durch Textsuche nach `any` im Quelltext — eine Textsuche zaehlt Kommentare mit und waere damit selbst verfaelschend.
|
||||
|
||||
Vor dem Festschreiben: die erzeugten Dateien und Commit-Texte auf rohe Steuerzeichen pruefen. Das Schreibwerkzeug wandelt Folgen der Form Backslash-u-vier-Ziffern in das tatsaechliche Zeichen um; das ist heute fuenfmal passiert und hat Commits scheitern lassen. Solche Folgen, falls ueberhaupt noetig, ueber `python3` mit `chr(92)` erzeugen und die Rohbytes danach kontrollieren.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Die Zahl der `any`-Befunde in `apps/api` ist von 288 auf hoechstens 45 gefallen, erwartet auf 20 bis 40.
|
||||
- Jede verbleibende Stelle hat ein Urteil mit Begruendung, im SUMMARY und im Code.
|
||||
- Kein Befund wurde durch Zusicherung, Ausrufezeichen-Behauptung, Unterdrueckungskommentar oder eine unbelegte Handschnittstelle stillgelegt (D-02) — nachgewiesen ueber die Zaehlwerte, nicht behauptet.
|
||||
- Kein Verhalten der API hat sich geaendert (D-03); jede aufgedeckte falsche Annahme steht als Befund im SUMMARY, mindestens die beiden schon gemessenen (`tender-matching.service.ts:159`, `dashboard.controller.ts:74`).
|
||||
- Der gemeinsame Aufrufer-Typ existiert, ist an den Signierstellen belegt, und `tenant.guard.ts` ist unveraendert.
|
||||
- `pnpm type-check` 4/4 und `pnpm lint` 5/5 nach jeder Aufgabe, nicht nur am Ende (D-05).
|
||||
- Beide Testsuiten gruen mit unveraenderten Zahlen (D-06).
|
||||
- Drei Commits, einer je Aufgabe, jeder fuer sich gruen.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/260921-m34-288-any-im-backend-einzeln-beurteilen-un/260921-m34-SUMMARY.md` when done.
|
||||
|
||||
Das SUMMARY traegt zwingend:
|
||||
- Ausgangszahl 288, Endzahl, Aufteilung der 288 auf die drei Urteile aus D-01.
|
||||
- Das Urteilsregister aller verbleibenden Stellen (Datei, Zeile, Form, Begruendung).
|
||||
- Die Liste der aufgedeckten falschen Annahmen im Bestandscode (D-03) — jede mit Datei, Zeile und dem, was der Code annimmt. Diese Liste ist ein Ergebnis, kein Anhang: sie ist der eigentliche Fund einer Aufgabe, die ohne bekannten Fehler begonnen hat.
|
||||
- Die Begruendung der `tenantId`-Entscheidung im gemeinsamen Aufrufer-Typ.
|
||||
</output>
|
||||
+414
@@ -0,0 +1,414 @@
|
||||
---
|
||||
phase: quick-260921-m34
|
||||
plan: 01
|
||||
subsystem: apps/api (Typdisziplin)
|
||||
tags: [typescript, any, refactor, mandantentrennung, prisma, node-forge, imapflow]
|
||||
status: complete
|
||||
requires: []
|
||||
provides:
|
||||
- "apps/api/src/auth/types/auth-user.ts (AuthUser, AuthenticatedRequest, JwtPayload, UploadedFileLike)"
|
||||
- "apps/api/src/prisma/prisma-error.ts (prismaErrorCode, prismaErrorTarget)"
|
||||
affects:
|
||||
- "apps/api/src (48 Dateien)"
|
||||
- "apps/web/src/test/setup.ts"
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "catch (e: unknown) plus Form-Eingrenzung statt catch (e: any)"
|
||||
- "Mitgelieferte Bibliothekstypen vor eigenen Handschnittstellen"
|
||||
- "Ehrliches any mit geschriebener Begruendung statt erzwungener Zusicherung"
|
||||
key-files:
|
||||
created:
|
||||
- apps/api/src/auth/types/auth-user.ts
|
||||
- apps/api/src/prisma/prisma-error.ts
|
||||
modified:
|
||||
- apps/api/src/prisma/prisma-tenant.extension.ts
|
||||
- apps/api/src/cert-manager/cert-manager.service.ts
|
||||
- apps/api/src/inbox/imap.provider.ts
|
||||
- apps/api/src/inbox/exchange-inbox.provider.ts
|
||||
- apps/api/src/calendar/providers/exchange.provider.ts
|
||||
- apps/api/src/user/user.service.ts
|
||||
- apps/api/src/groups/groups.service.ts
|
||||
- apps/api/src/ldap/ldap.service.ts
|
||||
- apps/api/src/mail/mail.service.ts
|
||||
decisions:
|
||||
- "AuthUser.tenantId ist string, aus drei Belegen hergeleitet; der SUPER_ADMIN-Zweig in TenantGuard bleibt unangetastet"
|
||||
- "Fehlereingrenzung per Form-Pruefung statt instanceof PrismaClientKnownRequestError, weil alle Testdoppel angehaengte .code-Felder werfen"
|
||||
- "15 Befunde bleiben mit Urteil und Begruendung stehen; Null war ausdruecklich nicht das Ziel"
|
||||
metrics:
|
||||
duration: "1h 20min (16:09 bis 17:29 Uhr, 21.09.2026)"
|
||||
completed: 2026-09-21
|
||||
actuals:
|
||||
tokens: 151201
|
||||
tasks: 3
|
||||
commits: 7
|
||||
plan_head_before: 8d845e7
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260921-m34: 288 any im Backend einzeln beurteilen Summary
|
||||
|
||||
Die 288 `any`-Befunde in `apps/api` sind auf **15** gefallen, jeder der 288 hat
|
||||
ein Urteil mit Begruendung, und die Arbeit hat sieben falsche Annahmen im
|
||||
Bestandscode aufgedeckt, die alle gemeldet und keine still repariert wurden.
|
||||
|
||||
## Die Zahl, und was sie bedeutet
|
||||
|
||||
| Groesse | Ausgang | Ende |
|
||||
|---|---|---|
|
||||
| `lint/suspicious/noExplicitAny` in `apps/api/src` | **288** (48 Dateien) | **15** (7 Dateien) |
|
||||
| dasselbe in `apps/web/src` | 1 | 0 |
|
||||
| `lint/style/noNonNullAssertion` in `apps/api/src` | 56 | 56 |
|
||||
| `as unknown as` in `apps/api/src` | 33 | 33 |
|
||||
| `ts-expect-error` / `ts-ignore` | 0 / 0 | 0 / 0 |
|
||||
| Lint-Unterdrueckungsmarker | 1 | 1 |
|
||||
| Befunde der Schwere `error` | 0 | 0 |
|
||||
|
||||
Die vier Zeilen in der Mitte sind die wichtigsten der Tabelle. Sie belegen, dass
|
||||
die 273 verschwundenen Befunde tatsaechlich getypt und nicht bloss stillgelegt
|
||||
wurden: haette die Arbeit die bequeme Abkuerzung genommen, waere mindestens einer
|
||||
dieser Zaehlwerte gestiegen. Keiner ist gestiegen.
|
||||
|
||||
### Aufteilung der 288 auf die drei Urteile (D-01)
|
||||
|
||||
| Urteil | Anzahl | Was dahintersteckt |
|
||||
|---|---:|---|
|
||||
| **typisiert** | 252 | Die Zusicherung war ueberfluessig oder der richtige Typ war ableitbar — aus Prismas Erweiterungstypen, aus den beiden Signierstellen des Tokens, aus `schema.prisma`, oder aus den mitgelieferten Typen einer Fremdbibliothek. |
|
||||
| **auf `unknown` umgestellt** | 21 | 18 Fehlerfaenger, die beiden `.then((results: unknown[]) => ...)` in `prisma-tenant.extension.ts` und `intercept(): Observable<unknown>`. |
|
||||
| **bleibt** | 15 | Register unten. Jede Stelle traegt ihre Begruendung ausserdem direkt im Code. |
|
||||
| | **288** | |
|
||||
|
||||
Der Plan hat 20 bis 40 verbleibende Befunde erwartet; es sind 15 geworden. Der
|
||||
Unterschied kommt nicht daher, dass hier mehr erzwungen wurde, sondern aus drei
|
||||
Messungen, die guenstiger ausfielen als die Vorschau: `@types/node-forge`
|
||||
beschreibt PKCS7 besser als angenommen (die vier `p7: any` liessen sich mit dem
|
||||
mitgelieferten `Captured<...>`-Typ aufloesen), `imapflow` deklariert
|
||||
`node.parameters` bereits vollstaendig, und `expect.extend(matchers)` in
|
||||
`apps/web` brauchte seine Zusicherung schlicht nicht mehr. Die Zaehlwerte oben
|
||||
sind der Beleg, dass dabei nichts gegen eine Behauptung getauscht wurde.
|
||||
|
||||
## Urteilsregister: die 15 Stellen, die bleiben
|
||||
|
||||
Jede dieser Zeilen steht so auch als Kommentar an der Codestelle. Wer spaeter
|
||||
hier aufraeumen will, findet die Begruendung dort, wo er zuerst nachsieht.
|
||||
|
||||
| # | Datei:Zeile | Form | Begruendung (gemessen) |
|
||||
|---|---|---|---|
|
||||
| 1 | `cert-manager/cert-manager.service.ts:296` | `cert.publicKey as any` | `@types/node-forge` kennt nur `PublicKey = rsa.PublicKey \| ed25519.Key` (index.d.ts:232). Der EC-Zweig darunter liest `curve` und `params.curve.q.bitLength()` — Felder, die node-forge zur Laufzeit liefert, die der mitgelieferte Typ aber gar nicht kennt. Eine Umdeutung ueber zwei Stufen wuerde dieselbe Luecke verdecken und zusaetzlich geprueft aussehen. |
|
||||
| 2 | `cert-manager/cert-manager.service.ts:318` | `(e: any)` | `Certificate.extensions` ist in `@types/node-forge` als `any[]` deklariert (index.d.ts:435). Der mitgelieferte Typ sagt ueber den Inhalt einer Erweiterung nichts aus. |
|
||||
| 3 | `cert-manager/cert-manager.service.ts:319` | `(sanExt as any)` | wie 2 — `sanExt` stammt aus demselben `any[]`. |
|
||||
| 4 | `cert-manager/cert-manager.service.ts:319` | `(n: any)` | wie 2. Eine eigene Schnittstelle fuer `altNames` waere unbelegt: der Compiler koennte sie an keiner Stelle gegen etwas pruefen, sie saehe aber geprueft aus (D-02). |
|
||||
| 5 | `cert-manager/cert-manager.service.ts:589` | `null as any` | node-forge 1.4.0 nimmt hier einen fehlenden Schluessel an und erzeugt ein reines Zertifikatsbuendel; `@types/node-forge` schliesst `null` aus. Die mitgelieferten Typen beschreiben die Bibliothek an dieser Stelle nachweislich falsch. |
|
||||
| 6 | `cert-manager/cert-manager.service.ts:752` | `null as any` | wie 5, zweite Aufrufstelle. |
|
||||
| 7 | `dkv/dkv-scheduler.service.ts:144` | `job as any` | Ohne die Zusicherung meldet `tsc`, dass das lokale `job` nur die Form `{ start(): void }` hat, waehrend `addCronJob()` einen vollstaendigen `CronJob` verlangt. Ursache ist der `require()`-Umweg aus 07-04 (pnpm-Isolation, `cron` ist nur mittelbare Abhaengigkeit). Aufloesen hiesse die Beschaffung der Klasse aendern (Verhaltensaenderung, D-03) oder `cron` direkt aufnehmen (neue Abhaengigkeit, D-04). |
|
||||
| 8 | `tenders/tender-digest.scheduler.ts:94` | `job as any` | wie 7. |
|
||||
| 9 | `tenders/tender-scheduler.service.ts:143` | `job as any` | wie 7. |
|
||||
| 10 | `groups/groups.service.ts:373` | `(u: any)` | Gefolge von 12: `tx` ist selbst `any`, und `any.map()` gibt dem Parameter keine kontextuelle Typisierung. Faellt automatisch mit 12. |
|
||||
| 11 | `groups/groups.service.ts:390` | `(a: any)` | wie 10. |
|
||||
| 12 | `prisma/prisma-tenant.extension.ts:264` | `fn: (tx: any)` | In Aufgabe 1 gemessen und verworfen: `Prisma.TransactionClient` erzwingt an den vier Aufrufstellen vollstaendige Prisma-Erzeugungstypen und bricht das absichtlich unvollstaendige Testdoppel in `prisma-tenant.extension.spec.ts` (TS2322). Das waere eine Aenderung an einer Teststruktur, kein ehrliches Typisieren. |
|
||||
| 13 | `prisma/prisma-tenant.extension.ts:266` | `async (tx: any)` | wie 12, dieselbe Funktion. |
|
||||
| 14 | `inbox/imap.provider.ts:78` | `(node as any).disposition...` | **Befund B-05.** Bleibt absichtlich sichtbar: der Ausdruck liest `.parameters` von einer Zeichenkette und ist zur Laufzeit immer `undefined`. Umbiegen auf `dispositionParameters` waere eine Verhaltensaenderung. |
|
||||
| 15 | `inbox/imap.provider.ts:402` | `} as any` | **Befund B-06.** Bleibt absichtlich sichtbar: die Zusicherung verdeckt, dass `requireTLS` in imapflow 1.4.3 gar nicht existiert. Die Option zu entfernen waere eine stille Reparatur. |
|
||||
|
||||
Die Gruppen dahinter sind klein: sechs Stellen an node-forge, drei an der
|
||||
Cron-Beschaffung, vier an der Transaktionshilfe, zwei absichtlich stehen
|
||||
gelassene Befunde an imapflow.
|
||||
|
||||
## Die aufgedeckten falschen Annahmen (D-03)
|
||||
|
||||
Diese Aufgabe hat ohne bekannten Fehler begonnen. Das hier ist ihr eigentlicher
|
||||
Ertrag: sieben Stellen, an denen der Bestandscode etwas annimmt, was nicht
|
||||
stimmt. **Keine davon wurde still repariert** — das Verhalten der API ist
|
||||
unveraendert.
|
||||
|
||||
**B-01 — `tenders/tender-matching.service.ts:159` (Aufgabe 1).**
|
||||
Die Handannotation `(match: { tender: unknown })` verengte den Wert falsch,
|
||||
sobald der Prisma-Klient richtig getypt war. Sie existierte nur, um unter dem
|
||||
`any`-Klienten TS7006 zu vermeiden. Annotation geloescht, damit der hergeleitete
|
||||
Typ durchkommt; keine Zusicherung an ihrer Stelle.
|
||||
|
||||
**B-02 — `dashboard/dashboard.controller.ts:74` (Aufgabe 2b).**
|
||||
Der Handler las `req.user?.role` nach `extractContext()` und gab die Rolle an
|
||||
`getWidgets(role: Role)` weiter, das sie zwingend verlangt. Die Annahme "hier
|
||||
gibt es immer einen Aufrufer" stimmt — die Pruefung "No user context" erzwingt
|
||||
sie —, aber sie stand in einer anderen Methode, wo der Compiler sie nicht sehen
|
||||
konnte. `extractContext()` gibt die Rolle jetzt mit zurueck: keine neue Pruefung,
|
||||
kein erfundener Wert, gleiche Reihenfolge, gleiche Meldungen.
|
||||
|
||||
**B-03 — `tenders/tenders.controller.ts:142` (Aufgabe 2b).**
|
||||
`resolveRequestingTenantId()` erklaerte `string | undefined`, liest aber
|
||||
`req.tenantId`, das `TenantGuard` fuer einen SUPER_ADMIN ohne Mandanten auf
|
||||
`null` setzt. Die Erklaerung war nie vollstaendig. Auf
|
||||
`string | null | undefined` erweitert — reine Erklaerung: die Funktion
|
||||
entscheidet seit jeher ueber den Wahrheitswert und faellt bei `null` wie bei
|
||||
`undefined` zu (nur global sichtbare Ausschreibungen).
|
||||
|
||||
**B-04 — Falle im Mandantentrennungs-Erkenner (Aufgabe 3b).**
|
||||
Die naheliegende Prisma-Schreibweise `Prisma.UserGetPayload<{ select: typeof X }>`
|
||||
laesst `rls-access-inventory.spec.ts` rot werden: der Erkenner zaehlt **jede**
|
||||
`select:`-Angabe ausserhalb eines erkannten Modellaufrufs als Verstoss und
|
||||
unterscheidet Typposition nicht von Aufrufposition. Beim ersten Versuch gemessen.
|
||||
Der Erkenner ist die Mandantenkontrolle (T-M34-03) und wurde **nicht**
|
||||
aufgeweicht — stattdessen leitet der Zeilentyp ueber `Pick<User, keyof typeof
|
||||
PLATFORM_USER_SELECT>` her, was ohne das Wort `select` auskommt. Wer kuenftig
|
||||
`UserGetPayload` einsetzen will, muss zuerst den Erkenner erweitern, nicht die
|
||||
Ausnahmeliste.
|
||||
|
||||
**B-05 — `inbox/imap.provider.ts:78` (Aufgabe 3c). Sicherheitsnah, offen.**
|
||||
`(node as any).disposition?.parameters?.filename` liest `.parameters` von einer
|
||||
**Zeichenkette**: imapflow deklariert `disposition` als `string`
|
||||
(`imap-flow.d.ts:448`, also "attachment"/"inline"), die zugehoerigen Parameter
|
||||
liegen in einem eigenen Feld `dispositionParameters` (:450). Der Ausdruck ist zur
|
||||
Laufzeit **immer** `undefined`, `dispositionFilename` ist stets `''`. Folge:
|
||||
Anhaenge, die als `application/octet-stream` kommen (typisch fuer Outlook),
|
||||
werden ueber den Dateinamen aus Content-Disposition **nicht** erkannt — nur ueber
|
||||
den aus Content-Type. Betrifft den DKV-Rechnungseinzug. Nicht repariert, weil das
|
||||
Umbiegen erstmals Anhaenge einsammeln wuerde, die heute uebersprungen werden —
|
||||
eine Verhaltensaenderung, die ein Mensch entscheiden muss.
|
||||
|
||||
**B-06 — `inbox/imap.provider.ts:402` (Aufgabe 3c). Sicherheitsnah, offen.**
|
||||
`requireTLS: config.encryption === 'starttls'` wird an `new ImapFlow(...)`
|
||||
uebergeben, aber `requireTLS` kommt in imapflow 1.4.3 **nirgends** vor — weder in
|
||||
`ImapFlowOptions` (`lib/imap-flow.d.ts`) noch im Laufzeitcode
|
||||
(`lib/imap-flow.js`), beides durchsucht. Die Option wird still verworfen; ein
|
||||
STARTTLS-Zwang entsteht durch sie nicht. Genau das `} as any` hat es verdeckt.
|
||||
Die Einstellung "STARTTLS" in der Postfach-Konfiguration bewirkt damit nicht das,
|
||||
was ihr Name verspricht. Nicht repariert (D-03), Zusicherung bleibt sichtbar
|
||||
stehen, damit der Befund in der Zaehlung nicht verschwindet.
|
||||
|
||||
**B-07 — httpntlm-Antwortrumpf (Aufgabe 3c). Ohne Auswirkung, aber falsch.**
|
||||
Beide Exchange-Wege riefen `res.body?.toString('utf-8')` auf und nahmen damit
|
||||
einen Buffer an. Gemessen: httpntlm reicht an httpreq durch, und httpreq gibt den
|
||||
Rumpf als **Zeichenkette** zurueck, solange die Option `binary` nicht gesetzt ist
|
||||
(`httpreq@1.1.1/lib/httpreq.js:391`) — keiner der beiden Aufrufer setzt sie. Das
|
||||
ging bisher nur gut, weil `String.prototype.toString()` sein Argument ignoriert.
|
||||
Die Testdoppel reichen umgekehrt wirklich einen Buffer herein, beide Formen kommen
|
||||
also vor. `NtlmResponse.body` nennt jetzt beide; die Fallunterscheidung liefert
|
||||
fuer jede exakt dasselbe Ergebnis wie zuvor.
|
||||
|
||||
## Die `tenantId`-Entscheidung im gemeinsamen Aufrufer-Typ
|
||||
|
||||
`AuthUser.tenantId` ist **`string`**, nicht `string | undefined`. Die Messung
|
||||
allein haette in die falsche Richtung gedraengt (`string | undefined` erzeugte
|
||||
acht Fehler im Produktivcode, `string` keinen); entschieden wurde auf drei
|
||||
Belegen:
|
||||
|
||||
1. `apps/api/prisma/schema.prisma` deklariert `User.tenantId String` **ohne** `?`.
|
||||
Die Spalte ist Pflicht, und beide Signierstellen schreiben genau diesen
|
||||
Spaltenwert — seit dem ersten Commit des Anmeldedienstes (6190f3d) gibt es
|
||||
keine Token-Erzeugung ohne diesen Anspruch.
|
||||
2. Der Bestand beschreibt dasselbe Objekt in `SessionUser`
|
||||
(`bug-reports.service.ts`) bereits als `tenantId: string`. `SessionUser` ist
|
||||
jetzt ein `Pick<>` von `AuthUser`, damit es keine zweite, abweichende
|
||||
Beschreibung desselben Objekts gibt.
|
||||
3. `TenantGuard` haelt fuer SUPER_ADMIN einen Zweig ohne Mandanten vor und setzt
|
||||
dort `req.tenantId = null`.
|
||||
|
||||
Beleg 3 spricht **nicht** gegen `string`, und genau daran haengt die
|
||||
Sicherheitsfrage: der Zweig in `TenantGuard` ist eine Tiefenverteidigung gegen
|
||||
ein Token ohne diesen Anspruch, und er liest `AuthUser` gar nicht — der Waechter
|
||||
holt sein Anfrageobjekt ungetypt. Dieser Typ kann den Zweig also nicht zu totem
|
||||
Code machen. Dass es den Zweig gibt, steht ausserdem weiterhin im Typsystem, nur
|
||||
an der richtigen Stelle: `AuthenticatedRequest.tenantId` ist
|
||||
`string | null | undefined`.
|
||||
|
||||
Am Typ steht dazu eine ausdrueckliche Warnung fuer spaetere Leser, dass der
|
||||
SUPER_ADMIN-Zweig ein Schutzzweig ist und nicht entfernt oder wegtypisiert werden
|
||||
darf (T-M34-01). `tenant.guard.ts` wurde in diesem ganzen Lauf **nicht
|
||||
angefasst** — in Aufgabe 2 per `git diff --stat` nachgewiesen.
|
||||
|
||||
## Was die Arbeit sonst noch geaendert hat
|
||||
|
||||
**Zwei neue Dateien, beide klein und begruendet.**
|
||||
`apps/api/src/auth/types/auth-user.ts` traegt den gemeinsamen Aufrufer-Typ; jedes
|
||||
Feld hat seine Herkunft als Kommentar. `apps/api/src/prisma/prisma-error.ts`
|
||||
traegt `prismaErrorCode()` und `prismaErrorTarget()`.
|
||||
|
||||
**Warum die Fehlereingrenzung Form-Pruefungen macht und kein `instanceof`.**
|
||||
Der naheliegende Weg waere
|
||||
`err instanceof Prisma.PrismaClientKnownRequestError` gewesen. Gemessen: samtliche
|
||||
Testdoppel in `apps/api` werfen `new Error(...)` mit angehaengtem `.code`
|
||||
(groups, user, ldap, tenders, module-grants, admin-seed) und
|
||||
`dashboard.service.spec.ts:451` ein reines `{ code: 'P2002' }`. Ein
|
||||
`instanceof`-Test haette all diese Werte in den jeweils **anderen** Zweig
|
||||
geschickt — eine Verhaltensaenderung, und nach T-M34-06 genau die Art von
|
||||
Aenderung, die einen Hintergrundlauf kuenftig abbrechen laesst, der heute
|
||||
weiterlaeuft. Die Helfer bilden `err?.code` und `err?.meta?.target` deshalb eins
|
||||
zu eins ab, nur ohne `any`.
|
||||
|
||||
**Nebengewinne ohne neue Zusicherungen.** In `cert-manager.service.ts` sind 25
|
||||
Zusicherungen der Form `file.buffer as Buffer` weggefallen, weil `file` kein
|
||||
`any` mehr ist. In `dkv` und `settings` fielen `req.tenantId as string |
|
||||
undefined` weg. `as unknown as` ist trotzdem bei 33 geblieben — dieselbe Zahl wie
|
||||
zu Beginn.
|
||||
|
||||
## Abweichungen vom Plan
|
||||
|
||||
**1. [Regel 3 — blockierend] Neue Datei `prisma/prisma-error.ts` statt 18
|
||||
Eingrenzungen von Hand.** Der Plan nennt fuer Aufgabe 3 Schritt A keine neue
|
||||
Datei. 18 Fehlerfaenger einzeln mit einer vierzeiligen Form-Pruefung zu versehen
|
||||
haette dieselbe Logik achtzehnmal wiederholt und die Begruendung, warum kein
|
||||
`instanceof` verwendet wird, achtzehnmal daneben. Die Datei liegt in `prisma/`,
|
||||
weil alle Aufrufer Prisma-Fehlercodes pruefen. Keine neue Abhaengigkeit.
|
||||
Commit: `32591b6`.
|
||||
|
||||
**2. [Regel 1 — Fehler] Erste Fassung von `user.service.ts` machte die
|
||||
Mandanten-Spec rot.** `Prisma.UserGetPayload<{ select: typeof X }>` hat
|
||||
`rls-access-inventory.spec.ts` gebrochen (siehe B-04). Sofort im selben Schritt
|
||||
auf `Pick<User, ...>` umgestellt, der Erkenner blieb unangetastet, Spec wieder
|
||||
30/30. Commit: `3892c5f`.
|
||||
|
||||
**3. [Messung weicht vom Plan ab] `calendar.service.ts:206/255` sind keine
|
||||
Prisma-JSON-Eingaben.** Der Plan vermutete `Prisma.InputJsonValue`. Gemessen:
|
||||
es sind dynamisch gebaute Erzeugungs- und Aenderungseingaben fuer
|
||||
`CalendarSource`. Richtig getypt mit
|
||||
`Prisma.CalendarSourceUncheckedCreateInput` / `...UncheckedUpdateInput`.
|
||||
Commit: `3892c5f`.
|
||||
|
||||
**4. [Messung weicht vom Plan ab] node-forge war besser beschrieben als
|
||||
erwartet.** Der Plan rechnete damit, dass ein Teil der elf Stellen bleibt.
|
||||
Gemessen: die vier `p7: any` liessen sich mit dem mitgelieferten
|
||||
`Captured<PkcsEnvelopedData | PkcsSignedData>` aufloesen, `cert.siginfo` war
|
||||
ohnehin getypt. Sechs bleiben, fuenf wurden getypt. Commit: `d8fb9ae`.
|
||||
|
||||
## Pruefungen — die echten Ausgaben
|
||||
|
||||
### `noExplicitAny`, `noNonNullAssertion`, `error`-Befunde
|
||||
|
||||
```
|
||||
$ npx biome lint --reporter=json --max-diagnostics=2000 apps/api/src | python3 -c "..."
|
||||
any 15 nonnull 56 error 0
|
||||
Rueckgabewert: 0
|
||||
```
|
||||
|
||||
Schranke der Aufgabe: `any <= 45`, `nonnull <= 56`, `error == 0`. Alle drei
|
||||
eingehalten.
|
||||
|
||||
### Vollstaendige Restliste (Pruefung 2 der Aufgabe)
|
||||
|
||||
```
|
||||
apps/api/src/cert-manager/cert-manager.service.ts:296:40 | const pubKey = cert.publicKey as any;
|
||||
apps/api/src/cert-manager/cert-manager.service.ts:318:48 | const sanExt = cert.extensions?.find((e: any) => e.name === 'subjectAltName');
|
||||
apps/api/src/cert-manager/cert-manager.service.ts:319:41 | const san: string[] = ((sanExt as any)?.altNames ?? []).map((n: any) =>
|
||||
apps/api/src/cert-manager/cert-manager.service.ts:319:71 | const san: string[] = ((sanExt as any)?.altNames ?? []).map((n: any) =>
|
||||
apps/api/src/cert-manager/cert-manager.service.ts:589:19 | null as any, // cert-only PFX - null key accepted by node-forge 1.4.0
|
||||
apps/api/src/cert-manager/cert-manager.service.ts:752:19 | null as any, // cert-only PFX - null key accepted by node-forge 1.4.0
|
||||
apps/api/src/dkv/dkv-scheduler.service.ts:144:55 | this.schedulerRegistry.addCronJob(jobName, job as any);
|
||||
apps/api/src/groups/groups.service.ts:373:33 | data: users.map((u: any) => ({
|
||||
apps/api/src/groups/groups.service.ts:390:39 | data: activations.map((a: any) => ({
|
||||
apps/api/src/inbox/imap.provider.ts:78:15 | ((node as any).disposition?.parameters?.filename as string | undefined)?.toLowerCase() ?? '';
|
||||
apps/api/src/inbox/imap.provider.ts:402:10 | } as any);
|
||||
apps/api/src/prisma/prisma-tenant.extension.ts:264:12 | fn: (tx: any) => Promise<T>,
|
||||
apps/api/src/prisma/prisma-tenant.extension.ts:266:41 | return prisma.$transaction(async (tx: any) => {
|
||||
apps/api/src/tenders/tender-digest.scheduler.ts:94:63 | this.schedulerRegistry.addCronJob(this.JOB_NAME, job as any);
|
||||
apps/api/src/tenders/tender-scheduler.service.ts:143:61 | this.schedulerRegistry.addCronJob(this.JOB_NAME, job as any);
|
||||
TOTAL 15
|
||||
```
|
||||
|
||||
Jede dieser 15 Zeilen steht im Urteilsregister oben. `apps/web/src` liefert
|
||||
keine Zeile mehr.
|
||||
|
||||
### Unterdrueckungsmarker
|
||||
|
||||
```
|
||||
### 3. Unterdrueckungsmarker
|
||||
keine stillgelegten Stellen
|
||||
```
|
||||
|
||||
Das heisst im Einzelnen: `ts-expect-error` 0, `ts-ignore` 0, `biome-ignore` 1
|
||||
(der eine Bestandsmarker), `as unknown as` 33.
|
||||
|
||||
### `biome.json` und Abhaengigkeiten
|
||||
|
||||
```
|
||||
### 4. biome.json
|
||||
biome.json unberuehrt
|
||||
|
||||
### 5. Abhaengigkeiten
|
||||
keine neuen Abhaengigkeiten, keine Versionsspruenge
|
||||
```
|
||||
|
||||
Geprueft ueber `git diff --stat HEAD` gegen `biome.json`, alle drei
|
||||
`package.json` und `pnpm-lock.yaml` — jede Ausgabe leer.
|
||||
|
||||
### Typlauf und Lint
|
||||
|
||||
```
|
||||
### 6. type-check / lint
|
||||
type-check 4/4, lint 5/5
|
||||
```
|
||||
|
||||
### Testsuiten
|
||||
|
||||
```
|
||||
$ pnpm --dir apps/api run test
|
||||
Test Files 72 passed (72)
|
||||
Tests 1143 passed (1143)
|
||||
Duration 8.05s
|
||||
|
||||
$ pnpm --dir apps/web run test
|
||||
Test Files 73 passed (73)
|
||||
Tests 531 passed (531)
|
||||
Duration 23.48s
|
||||
```
|
||||
|
||||
Unveraenderte Zahlen gegenueber dem Ausgang (72/1143 und 73/531).
|
||||
|
||||
### Mandantentrennungs-Erkenner, ausdruecklich einzeln
|
||||
|
||||
```
|
||||
$ pnpm --dir apps/api exec vitest run src/prisma/rls-access-inventory.spec.ts
|
||||
Test Files 1 passed (1)
|
||||
Tests 30 passed (30)
|
||||
Duration 557ms
|
||||
```
|
||||
|
||||
Das ist die Kontrolle aus T-M34-03. Sie war in Aufgabe 3b einmal rot (B-04) und
|
||||
wurde nicht durch Aufweichen, sondern durch eine andere Typschreibweise wieder
|
||||
gruen.
|
||||
|
||||
## Commits
|
||||
|
||||
| Commit | Aufgabe | `any` in `apps/api` danach |
|
||||
|---|---|---:|
|
||||
| `b188946` | 1 — Mandantenbindung entzaubert, 105 unnoetige Zusicherungen | 149 |
|
||||
| `f2fc39f` | 2a — gemeinsamer Aufrufer-Typ, aus den Signierstellen abgeleitet | 137 |
|
||||
| `7c9d7c1` | 2b — getypte Anfrage in elf Controllern, zwei Befunde gemeldet | 66 |
|
||||
| `52668c2` | 2c — Hochladewege getypt, 25 Zusicherungen fallen mit | 56 |
|
||||
| `32591b6` | 3a — 18 Fehlerfaenger auf `unknown`, mit echter Eingrenzung | 38 |
|
||||
| `3892c5f` | 3b — Prisma-nahe Formen getypt, Erkenner-Falle gemeldet | 31 |
|
||||
| `d8fb9ae` | 3c — Randschicht beurteilt, drei Befunde gemeldet | **15** |
|
||||
|
||||
Jeder Commit war fuer sich gruen: nach jedem lief `pnpm type-check` 4/4,
|
||||
`pnpm lint` 5/5 mit 0 Befunden der Schwere `error`, und beide Testsuiten mit
|
||||
unveraenderten Zahlen (D-05, D-06).
|
||||
|
||||
## Offene Punkte fuer den Menschen
|
||||
|
||||
Zwei der sieben Befunde brauchen eine Entscheidung, die diese Aufgabe nicht
|
||||
treffen durfte, weil beide das Verhalten aendern wuerden:
|
||||
|
||||
- **B-05** (`imap.provider.ts:78`): sollen Anhaenge mit
|
||||
`application/octet-stream` kuenftig auch ueber den Dateinamen aus
|
||||
Content-Disposition erkannt werden? Heute werden sie es nicht.
|
||||
- **B-06** (`imap.provider.ts:402`): soll die Postfach-Einstellung "STARTTLS"
|
||||
tatsaechlich einen Zwang bewirken? Heute wird die Option von imapflow
|
||||
verworfen.
|
||||
|
||||
Beide sind im Code markiert und stehen in der Zaehlung, verschwinden also nicht
|
||||
aus dem Blick.
|
||||
|
||||
## Self-Check
|
||||
|
||||
**BESTANDEN.**
|
||||
|
||||
Geprueft, nicht behauptet:
|
||||
|
||||
- Beide neu angelegten Dateien existieren auf der Platte
|
||||
(`apps/api/src/auth/types/auth-user.ts`,
|
||||
`apps/api/src/prisma/prisma-error.ts`).
|
||||
- Alle sieben genannten Commits sind in `git log` vorhanden.
|
||||
- `apps/api/src/tenant/tenant.guard.ts` ist ueber den gesamten Lauf
|
||||
(`git diff --stat 8d845e7..HEAD`) unveraendert — keine einzige Zeile.
|
||||
- Die 15 Zeilen der Restliste stammen aus der maschinellen Biome-Ausgabe, nicht
|
||||
aus dem Gedaechtnis, und stimmen eins zu eins mit dem Urteilsregister ueberein.
|
||||
- Die Aufteilung 252 + 21 + 15 ergibt 288.
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
---
|
||||
phase: quick-260921-oxm
|
||||
plan: 01
|
||||
type: tdd
|
||||
autonomous: true
|
||||
subsystem: apps/api/src/inbox
|
||||
requirements: []
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260921-oxm: IMAP-STARTTLS wirklich erzwingen und Anhangs-Dateinamen richtig lesen
|
||||
|
||||
## Ziel
|
||||
|
||||
Die beiden in 260921-m34 gemeldeten Befunde B-06 und B-05 in
|
||||
`apps/api/src/inbox/imap.provider.ts` beheben — mit Tests, die gegen den
|
||||
heutigen Stand rot sind, und ohne jede weitere Verhaltensaenderung.
|
||||
|
||||
## Ausgangsmessung (vor der Arbeit, gemessen am 21.09.2026 auf ad83407)
|
||||
|
||||
| Groesse | Wert |
|
||||
|---|---:|
|
||||
| `lint/suspicious/noExplicitAny` in `apps/api/src` | 15 |
|
||||
| `lint/style/noNonNullAssertion` in `apps/api/src` | 56 |
|
||||
| `as unknown as` in `apps/api/src` | 33 |
|
||||
| `biome-ignore` in `apps/api/src` | 1 |
|
||||
| `ts-expect-error` / `@ts-ignore` | 0 |
|
||||
|
||||
## Aufgabe 1 — B-06: STARTTLS erzwingen (sicherheitsrelevant)
|
||||
|
||||
<precondition>imapflow 1.4.3 deklariert `doSTARTTLS?: boolean` in `ImapFlowOptions` (`lib/imap-flow.d.ts:81`).</precondition>
|
||||
|
||||
**Test zuerst (rot):** Zwei Faelle in `imap.provider.spec.ts`, die die an
|
||||
`new ImapFlow(...)` uebergebenen Optionen pruefen:
|
||||
- `encryption: 'starttls'` ⇒ `secure === false`, `doSTARTTLS === true`, kein Feld `requireTLS`
|
||||
- `encryption: 'ssl-tls'` ⇒ `secure === true`, `doSTARTTLS !== true` (Unvertraeglichkeit
|
||||
der Bibliothek: `secure=true` zusammen mit `doSTARTTLS=true` wirft)
|
||||
|
||||
**Reparatur:** `requireTLS` durch `doSTARTTLS: config.encryption === 'starttls'`
|
||||
ersetzen. Bei `ssl-tls` ergibt der Ausdruck `false` — das ist erlaubt und
|
||||
dokumentiert ("STARTTLS explicitly disabled by config", `imap-flow.js:1210`) und
|
||||
loest die Unvertraeglichkeit nicht aus, weil die nur bei `doSTARTTLS === true`
|
||||
zuschlaegt (`imap-flow.js:1201`).
|
||||
|
||||
**Zusicherung:** `} as any` am Ende von `buildClient()` faellt ersatzlos, sobald
|
||||
alle uebergebenen Felder deklariert sind. Danach pruefen.
|
||||
|
||||
## Aufgabe 2 — B-05: Anhangs-Dateiname aus dem richtigen Feld
|
||||
|
||||
<precondition>imapflow deklariert `dispositionParameters?: { [key: string]: string }` (`lib/imap-flow.d.ts:450`) und fuellt es in `tools.js:887` mit kleingeschriebenen Schluesseln.</precondition>
|
||||
|
||||
**Test zuerst (rot):** Ein `application/octet-stream`-Knoten, dessen
|
||||
`dispositionParameters.filename` auf `.pdf` endet, muss von
|
||||
`fetchPdfAttachments()` eingesammelt werden.
|
||||
|
||||
**Reparatur:** `(node as any).disposition?.parameters?.filename` durch den
|
||||
getypten Zugriff `node.dispositionParameters?.filename` ersetzen.
|
||||
|
||||
## Verifikation
|
||||
|
||||
- `pnpm type-check` 4/4
|
||||
- `pnpm lint` 5/5, keine Befunde der Schwere `error`
|
||||
- `apps/api` mindestens 72 Dateien / 1143 Tests, `apps/web` 73/531
|
||||
- Zaehler: `as unknown as` = 33, `noNonNullAssertion` = 56, `biome-ignore` = 1,
|
||||
`ts-expect-error` = 0, `noExplicitAny` < 15
|
||||
|
||||
## Erfolgskriterien
|
||||
|
||||
- [ ] Beide Tests waren gegen den alten Stand nachweislich rot
|
||||
- [ ] Keine Verhaltensaenderung ausser der in B-06 gewollten
|
||||
- [ ] Keine neue Zusicherung, kein `!`, kein `@ts-expect-error`
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
---
|
||||
phase: quick-260921-oxm
|
||||
plan: 01
|
||||
subsystem: apps/api/src/inbox
|
||||
tags: [imap, imapflow, starttls, sicherheit, anhaenge, dkv, tdd]
|
||||
status: complete
|
||||
requires:
|
||||
- "260921-m34 (Befunde B-05 und B-06)"
|
||||
provides:
|
||||
- "ImapProvider erzwingt STARTTLS ueber die Option, die imapflow wirklich kennt"
|
||||
- "ImapProvider erkennt Anhangs-Dateinamen aus Content-Disposition"
|
||||
affects:
|
||||
- "apps/api/src/inbox/imap.provider.ts"
|
||||
- "apps/api/src/inbox/imap.provider.spec.ts"
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Konstruktoroptionen einer Fremdbibliothek im Test gegen die uebergebenen Werte pruefen, nicht gegen eine echte Verbindung"
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- apps/api/src/inbox/imap.provider.ts
|
||||
- apps/api/src/inbox/imap.provider.spec.ts
|
||||
decisions:
|
||||
- "doSTARTTLS bei ssl-tls auf false statt weglassen: schliesst die Unvertraeglichkeit secure=true + doSTARTTLS=true aus und schaltet STARTTLS dort ausdruecklich ab"
|
||||
- "Einhaengen des Testdoppels in einen Helfer gezogen, damit die neuen Faelle ohne eigene Umdeutung auskommen"
|
||||
metrics:
|
||||
duration: "35 min (17:55 bis 18:30 Uhr, 21.09.2026)"
|
||||
completed: 2026-09-21
|
||||
actuals:
|
||||
tokens: 31000
|
||||
tasks: 2
|
||||
commits: 4
|
||||
plan_head_before: ad83407
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260921-oxm: IMAP-STARTTLS wirklich erzwingen und Anhangs-Dateinamen richtig lesen Summary
|
||||
|
||||
Zwei falsche Annahmen im IMAP-Postfachzugriff sind behoben: die Einstellung
|
||||
"STARTTLS" bewirkt jetzt tatsaechlich, was ihr Name verspricht, und
|
||||
Rechnungsanhaenge aus Outlook werden wieder am Dateinamen erkannt. Beide
|
||||
Reparaturen haengen an Tests, die gegen den vorherigen Stand nachweislich rot
|
||||
waren.
|
||||
|
||||
## Was sich fuer den Betrieb aendert
|
||||
|
||||
**Die eine gewollte Verhaltensaenderung, in einem Satz:** Ein Postfach, das auf
|
||||
"STARTTLS" eingestellt ist, dessen Server diese Verschluesselung aber gar nicht
|
||||
anbietet, meldet ab jetzt einen Verbindungsfehler — bisher hat Tessera in genau
|
||||
diesem Fall stillschweigend unverschluesselt weitergemacht und Benutzername und
|
||||
Kennwort im Klartext uebertragen. Wer so ein Postfach hat, sieht den Fehler
|
||||
sofort und kann die Einstellung richtigstellen; vorher hat niemand etwas
|
||||
gemerkt.
|
||||
|
||||
**Die zweite Aenderung ist eine Reparatur, keine Umstellung:** Anhaenge, die ein
|
||||
Absender als `application/octet-stream` verschickt — was Outlook regelmaessig
|
||||
tut — wurden bisher nur dann als PDF erkannt, wenn der Dateiname zusaetzlich im
|
||||
Inhaltstyp stand. Der zweite, haeufigere Weg ueber die Angabe
|
||||
"Content-Disposition" wurde zwar abgefragt, lieferte aber baulich bedingt nie
|
||||
ein Ergebnis. Er funktioniert jetzt. Betrifft den DKV-Rechnungseinzug.
|
||||
|
||||
## B-06 — STARTTLS wurde nie erzwungen
|
||||
|
||||
`buildClient()` uebergab `requireTLS: config.encryption === 'starttls'` an
|
||||
`new ImapFlow(...)`. Diese Option kennt imapflow 1.4.3 nicht: weder
|
||||
`ImapFlowOptions` in `lib/imap-flow.d.ts` noch der Laufzeitcode in
|
||||
`lib/imap-flow.js` erwaehnen sie — beides durchsucht, kein einziger Treffer. Sie
|
||||
wurde also entgegengenommen und weggeworfen. Verdeckt hat das die Zusicherung
|
||||
`} as any` am Ende derselben Funktion: sie hat dem Compiler verboten, die
|
||||
unbekannte Option zu bemaengeln.
|
||||
|
||||
Ohne gesetzte Option galt das Standardverhalten der Bibliothek, das sie selbst
|
||||
so beschreibt: bei `secure=false` auf TLS hochstufen, *falls* der Server es
|
||||
anbietet, sonst unverschluesselt weitermachen — mit dem ausdruecklichen Zusatz
|
||||
*"This may expose the connection to a downgrade attack."*
|
||||
|
||||
**Reparatur:** `doSTARTTLS: config.encryption === 'starttls'` (deklariert in
|
||||
`imap-flow.d.ts:81`, ausgewertet in `imap-flow.js:1183`). Bei `starttls` ergibt
|
||||
der Ausdruck `true` und die Verbindung scheitert, wenn der Server kein STARTTLS
|
||||
kann. Bei `ssl-tls` ergibt er `false`, was STARTTLS ausdruecklich abschaltet
|
||||
(`imap-flow.js:1210`) — das ist wichtiger als es aussieht: die Bibliothek wirft
|
||||
bei `secure=true` zusammen mit `doSTARTTLS=true` einen Konfigurationsfehler
|
||||
(`imap-flow.js:1201`). Ein schlichtes `true`/`undefined` waere hier also falsch
|
||||
gewesen. Genau diese Kombination prueft der zweite Test mit.
|
||||
|
||||
**Die Zusicherung konnte ersatzlos entfallen.** Nach der Reparatur sind alle
|
||||
sechs uebergebenen Felder in `ImapFlowOptions` deklariert; `tsc` ist ohne das
|
||||
`as any` fehlerfrei. Damit ist die Stelle nicht nur getypt, sondern kann kuenftig
|
||||
auch keine weitere erfundene Option mehr verstecken.
|
||||
|
||||
## B-05 — der Dateiname kam aus dem falschen Feld
|
||||
|
||||
`collectPdfParts()` las `(node as any).disposition?.parameters?.filename`.
|
||||
imapflow deklariert `disposition` aber als **Zeichenkette**
|
||||
(`imap-flow.d.ts:448` — der Wert ist "attachment" oder "inline") und legt die
|
||||
zugehoerigen Parameter in ein eigenes Feld `dispositionParameters`
|
||||
(`imap-flow.d.ts:450`). Der Ausdruck las also `.parameters` von einer
|
||||
Zeichenkette und war zur Laufzeit **immer** `undefined`.
|
||||
|
||||
**Reparatur:** `node.dispositionParameters?.filename?.toLowerCase() ?? ''` —
|
||||
getypt, ohne Zusicherung. Nachgeprueft, nicht geraten: imapflow fuellt das Feld
|
||||
in `tools.js:887` ueber `getStructuredParams()`, und diese Funktion schreibt die
|
||||
Schluessel **kleingeschrieben** (`tools.js:648`). `filename` ist damit der
|
||||
richtige Schluessel, unabhaengig davon, wie der Absender die Angabe gross- oder
|
||||
kleingeschrieben hat.
|
||||
|
||||
Der zweite moegliche Fundort, den die Aufgabe erwaehnt — der Name in den
|
||||
Parametern des Inhaltstyps — war bereits vorhanden und wird weiter geprueft
|
||||
(`node.parameters?.name`). Ein dritter Fall wurde nicht erfunden. Die
|
||||
RFC-2231-Fortsetzungsparameter (`filename*0`, `filename*1` ...) setzt imapflow
|
||||
selbst wieder zu einem einzigen `filename` zusammen (`tools.js:662` ff.), es
|
||||
braucht dafuer hier also nichts.
|
||||
|
||||
## Die Tests, und der Beleg dass sie rot waren
|
||||
|
||||
Beide Faelle liegen in `apps/api/src/inbox/imap.provider.spec.ts`. Gegen den
|
||||
Stand `7691d1f` (Tests vorhanden, Reparatur noch nicht) scheiterten genau drei
|
||||
von zwoelf Faellen:
|
||||
|
||||
```
|
||||
× ImapProvider - Transportverschluesselung (B-06) > erzwingt STARTTLS, wenn die Verschluesselung auf starttls steht
|
||||
-> expected undefined to be true // Object.is equality
|
||||
× ImapProvider - Transportverschluesselung (B-06) > setzt doSTARTTLS nicht auf true, wenn die Verschluesselung auf ssl-tls steht
|
||||
-> expected { host: 'imap.example.com', ...(5) } to not have property "requireTLS"
|
||||
× ImapProvider.fetchPdfAttachments - Dateiname aus Content-Disposition (B-05) > erkennt einen application/octet-stream-Anhang am Dateinamen aus dispositionParameters
|
||||
-> expected [] to have a length of 1 but got +0
|
||||
|
||||
Test Files 1 failed (1)
|
||||
Tests 3 failed | 9 passed (12)
|
||||
```
|
||||
|
||||
Die erste Zeile ist der Kern von B-06: `doSTARTTLS` war schlicht nicht gesetzt.
|
||||
Die zweite belegt, dass stattdessen ein Feld `requireTLS` ankam, das die
|
||||
Bibliothek nicht auswertet. Die dritte belegt B-06 nicht, sondern B-05: der
|
||||
Anhang wurde gar nicht erst eingesammelt.
|
||||
|
||||
Zwei weitere neue Faelle waren von Anfang an gruen und sollen das auch bleiben —
|
||||
sie sichern, dass die Erkennung ueber den Inhaltstyp-Namen weiter greift und
|
||||
dass ein `octet-stream`-Anhang **ohne** `.pdf`-Endung weiterhin liegen bleibt.
|
||||
Ohne sie haette die Reparatur unbemerkt zu viel einsammeln koennen.
|
||||
|
||||
Nach der Reparatur: 12 von 12 gruen.
|
||||
|
||||
## Messungen
|
||||
|
||||
| Groesse | vorher (ad83407) | nachher (d0266bf) |
|
||||
|---|---:|---:|
|
||||
| `lint/suspicious/noExplicitAny` in `apps/api/src` | 15 | **13** |
|
||||
| `lint/style/noNonNullAssertion` in `apps/api/src` | 56 | 56 |
|
||||
| `as unknown as` in `apps/api/src` | 33 | **27** |
|
||||
| `biome-ignore` in `apps/api/src` | 1 | 1 |
|
||||
| `ts-expect-error` / `@ts-ignore` | 0 | 0 |
|
||||
| `pnpm type-check` | 4/4 | 4/4 |
|
||||
| `pnpm lint` | 5/5, 0 Fehler | 5/5, 0 Fehler |
|
||||
| Tests `apps/api` | 72 Dateien / 1143 | 72 Dateien / **1148** |
|
||||
| Tests `apps/web` | 73 / 531 | 73 / 531 |
|
||||
|
||||
Zwei Zeilen brauchen eine Erklaerung.
|
||||
|
||||
**`noExplicitAny` 15 auf 13:** beide verbliebenen imapflow-Stellen aus dem
|
||||
Urteilsregister von 260921-m34 (Nummern 14 und 15) sind weg. Die 13
|
||||
verbleibenden sind unveraendert die dort begruendeten: sechs an node-forge, drei
|
||||
an der Cron-Beschaffung, vier an der Transaktionshilfe.
|
||||
|
||||
**`as unknown as` 33 auf 27:** das ist keine Nebenwirkung der Reparatur, sondern
|
||||
Absicht. Die Testdatei haengte ihr Testdoppel in jedem einzelnen Fall mit
|
||||
derselben Umdeutung des Konstruktors ein — zwoelf Mal, sobald die neuen Faelle
|
||||
dazukamen. Diese eine Zeile steht jetzt in einem Helfer `useMockClient()`, und
|
||||
die neuen Faelle brauchen keine eigene Umdeutung mehr. Die Alternative waere
|
||||
gewesen, fuenf neue Umdeutungen hinzuzufuegen und den Zaehler zu heben; das war
|
||||
ausgeschlossen. Der Zaehler faellt, er steigt an keiner Stelle.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
Eine, und sie steht schon oben: der Helfer `useMockClient()` in der Testdatei
|
||||
war im Plan nicht vorgesehen. Er wurde noetig, weil die neuen Faelle das
|
||||
Testdoppel sonst nur ueber fuenf zusaetzliche Umdeutungen haetten einhaengen
|
||||
koennen — was die Vorgabe "Zaehler duerfen nicht steigen" verletzt haette. Die
|
||||
Aenderung ist mechanisch (dieselbe Zeile, an einer Stelle statt an zwoelf) und
|
||||
aendert an keinem bestehenden Fall das Verhalten; alle sieben Altfaelle sind
|
||||
unveraendert gruen.
|
||||
|
||||
Sonst nichts: keine neue Abhaengigkeit, kein Versionssprung, kein repo-weites
|
||||
Umformatieren, keine Aenderung an `STATE.md` oder `ROADMAP.md`.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
Keine.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Alle vier genannten Dateien liegen auf der Platte, alle drei Commits sind in
|
||||
`git log` auffindbar (f23671a, 7691d1f, d0266bf). Die Messungen der Tabelle oben
|
||||
stammen aus tatsaechlich gelaufenen Befehlen, nicht aus einer Schaetzung.
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
---
|
||||
phase: quick-260921-pi9
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements: [QUICK-260921-PI9]
|
||||
|
||||
files_modified:
|
||||
- apps/api/prisma/schema.prisma
|
||||
- apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql
|
||||
- apps/api/src/dashboard/dashboard-image-rules.ts
|
||||
- apps/api/src/dashboard/dashboard-image-rules.spec.ts
|
||||
- apps/api/src/dashboard/dashboard-images.service.ts
|
||||
- apps/api/src/dashboard/dashboard-images.service.spec.ts
|
||||
- apps/api/src/dashboard/dashboard-images.controller.ts
|
||||
- apps/api/src/dashboard/dashboard-images.controller.spec.ts
|
||||
- apps/api/src/dashboard/dashboard.module.ts
|
||||
- apps/api/src/dashboard/dto/create-widget.dto.ts
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
- apps/web/src/lib/dashboard-images-api.ts
|
||||
- apps/web/src/lib/dashboard-images-api.test.ts
|
||||
- apps/web/src/components/dashboard/widgets/picture-frame-config.ts
|
||||
- apps/web/src/components/dashboard/widgets/picture-frame-config.test.ts
|
||||
- apps/web/src/components/dashboard/widgets/picture-frame-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/picture-frame-widget.test.tsx
|
||||
- apps/web/src/components/dashboard/widgets/picture-frame-lightbox.tsx
|
||||
- apps/web/src/components/settings/picture-frame-config-form.tsx
|
||||
- apps/web/src/components/settings/picture-frame-config-form.test.tsx
|
||||
- apps/web/src/components/settings/widget-settings-panel.tsx
|
||||
- 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.tsx
|
||||
- apps/web/src/components/dashboard/widget-catalog-modal.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
|
||||
- CHANGELOG.md
|
||||
|
||||
estimate:
|
||||
tokens: 260000
|
||||
raw_tokens: 260000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Im Widget-Katalog gibt es „Bilderrahmen“ (en „Picture frame“); eine frisch platzierte Kachel zeigt den Hinweis „Noch keine Bilder — über die Einstellungen hinzufügen“ im Stil der anderen leeren Widgets."
|
||||
- "Unter Einstellungen → Dashboard → Bilderrahmen kann der Benutzer Bilder hochladen (PNG/JPEG/GIF/WebP, je Datei höchstens 5 MiB, je Benutzer höchstens 30) ODER eine https-Webadresse eintragen; http-, data- oder javascript-Adressen werden mit deutscher Meldung abgewiesen, eine umbenannte Nicht-Bild-Datei ebenso (Erkennung an den Magic Bytes, nicht am Dateinamen oder am gemeldeten MIME-Typ)."
|
||||
- "Jeder Eintrag hat Vorschaubild, Bildunterschrift, Entfernen und Pfeile Nach oben/Nach unten; das Entfernen eines hochgeladenen Bildes löscht es auch auf dem Server (best effort); ein Eintrag, dessen Bild nicht mehr existiert, wird als „Bild nicht verfügbar“ angezeigt und in der Kachel übersprungen."
|
||||
- "Die Kachel zeigt die Bilder gemäß Einstellung ganz sichtbar (`object-contain`) oder formatfüllend (`object-cover`), wechselt im eingestellten Intervall (5–3600 s, 0 = kein Wechsel, Voreinstellung 30 s) in Reihenfolge oder zufällig (Zufall wählt bei mehr als einem Bild nie das aktuelle erneut), zeigt die Bildunterschrift als Streifen am unteren Rand; Fremdbilder lädt ausschließlich der Browser (`<img referrerPolicy=\"no-referrer\">`), der Server ruft nie eine Webadresse ab."
|
||||
- "Klick auf das Bild (nur außerhalb des Bearbeitungsmodus) öffnet eine Großansicht mit Bildunterschrift; Escape, Klick auf den Hintergrund oder der Schließen-Knopf schließen sie, der Fokus kehrt zum Bild zurück, der Bildwechsel pausiert solange. Im Bearbeitungsmodus bleibt die ganze Karte der Ziehgriff, ein Klick öffnet nichts."
|
||||
- "Hochgeladene Bilder gehören dem hochladenden Benutzer: `GET/DELETE /dashboard/images/:id` liefern für eine fremde Kennung (anderer Benutzer ODER anderer Mandant) 404, nie 403; die Auslieferung trägt `Content-Type` aus dem gespeicherten, per Magic Bytes bestimmten Typ, `Cache-Control: private, max-age=86400`, `X-Content-Type-Options: nosniff`, `Content-Disposition: inline`."
|
||||
- "Alle Tore bleiben grün: `pnpm type-check` 4/4, `pnpm lint` 5/5, API-Tests mindestens 1170 (heute 1148), Web-Tests mindestens 560 (heute 531), RLS-Wächter 30/30; keine neue `any`, `as unknown as` in apps/api/src bleibt 27, `noNonNullAssertion` bleibt 56."
|
||||
artifacts:
|
||||
- "apps/api/prisma/schema.prisma — Modell `DashboardImage` (id, userId, tenantId, originalName, mimeType, size, data Bytes, createdAt; @@index userId, tenantId)"
|
||||
- "apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql — CREATE TABLE + Indizes + ENABLE/FORCE ROW LEVEL SECURITY + `tenant_isolation_policy` mit Benutzerdimension"
|
||||
- "apps/api/src/dashboard/dashboard-image-rules.ts — reine Regeln: `detectImageMime(buffer)`, `DASHBOARD_IMAGE_MAX_BYTES`, `DASHBOARD_IMAGE_MAX_COUNT`"
|
||||
- "apps/api/src/dashboard/dashboard-images.service.ts — `list`, `upload`, `getBytes`, `remove`, alle über `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`"
|
||||
- "apps/api/src/dashboard/dashboard-images.controller.ts — `@Controller('dashboard/images')`: `GET /`, `POST /` (FileInterceptor `image`), `GET /:id` (Binär), `DELETE /:id`"
|
||||
- "apps/web/src/components/dashboard/widgets/picture-frame-config.ts — Typen `PictureFrameEntry`/`PictureFrameConfig`, `resolvePictureFrameConfig`, `isHttpsUrl`, `pickNextIndex`, Intervall-Grenzen"
|
||||
- "apps/web/src/components/dashboard/widgets/picture-frame-widget.tsx + picture-frame-lightbox.tsx — Kachel mit Wechsel, Bildunterschrift, Großansicht"
|
||||
- "apps/web/src/components/settings/picture-frame-config-form.tsx — Bildverwaltung im WidgetSettingsPanel"
|
||||
- "apps/web/src/lib/dashboard-images-api.ts — `fetchDashboardImages`, `uploadDashboardImage`, `deleteDashboardImage`, `dashboardImageSrc`"
|
||||
- "apps/web/src/messages/de.json + en.json — Namensraum `widgets.pictureFrame`"
|
||||
- "CHANGELOG.md — Stichpunkt unter „Unveröffentlicht → Neu“"
|
||||
key_links:
|
||||
- "Datei-Eingabe im Einstellungsformular -> `uploadDashboardImage(file)` (FormData-Feld `image`) -> `POST /dashboard/images` -> `detectImageMime` + Zähler -> `DashboardImage`-Zeile -> Antwort `{ id, … }` -> `onChange({ images: [...alt, { kind: 'upload', imageId }] })` -> `PATCH /dashboard/widgets/:id/config` (bestehend, flache Zusammenführung: `images` immer als GANZES Array senden)"
|
||||
- "Kachel: `resolvePictureFrameConfig(config)` -> sichtbare Einträge -> `<img src={kind === 'upload' ? dashboardImageSrc(imageId) : url}>` -> `/api-proxy/dashboard/images/:id` (Next-Rewrite aus next.config.ts, Cookies laufen mit) -> `GET /dashboard/images/:id` -> Besitzprüfung -> Bytes"
|
||||
- "Wächter: neues Modell mit `tenantId` -> `rls-coverage.spec.ts` verlangt ENABLE + POLICY in einer Migration; neue (Datei, Modell)-Fundstelle `dashboard-images.service.ts`/`dashboardImage` -> `rls-access-inventory.spec.ts` verlangt eine Zeile in docs/mandantentrennung-zugriffsklassifikation.md"
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260921-pi9: Dashboard-Widget „Bilderrahmen“
|
||||
|
||||
<objective>
|
||||
Ein neues Dashboard-Widget „Bilderrahmen“ (Widget-Typ `picture-frame`, Übersetzungs-Namensraum `widgets.pictureFrame`): Bilder werden hochgeladen (in PostgreSQL als `bytea`, dem Benutzer gehörend, 5 MiB je Datei, 30 je Benutzer) ODER als https-Webadresse eingebunden (der Browser lädt sie direkt, der Server ruft nie etwas ab). Einstellungen: Bildausschnitt, Wechselintervall, Reihenfolge/Zufall, Bildunterschrift je Eintrag; Klick zeigt das Bild groß. Stil und Bedienmuster wie die bestehenden Widgets.
|
||||
|
||||
Purpose: erstes der zwei vom Nutzer gewünschten neuen Widgets (STATE.md „NAECHSTER AUFTRAG“); die Produktfragen sind geklärt, die technischen Entscheidungen hat der Orchestrator getroffen (siehe Kasten unten) — dieser Plan setzt sie um, ohne sie neu zu öffnen.
|
||||
Output: API-Modell + Migration + Endpunkte mit Tests, Web-Widget + Einstellungsformular + Großansicht + Übersetzungen mit Tests, Changelog-Eintrag; alle Tore grün.
|
||||
</objective>
|
||||
|
||||
## Gebundene Entscheidungen (Orchestrator, nicht neu verhandeln)
|
||||
|
||||
1. Speicherung als `bytea` im neuen Prisma-Modell `DashboardImage` (kein Docker-Volume; Sicherung deckt es mit ab). Migration als SQL-Datei, angewendet mit `prisma migrate deploy` — nie `db push`.
|
||||
2. Grenzen: 5 MiB je Datei (`limits.fileSize`), 30 Bilder je Benutzer (Dienst zählt je Mandant+Benutzer). Erlaubt PNG/JPEG/GIF/WebP, entschieden über Magic Bytes; alles andere 400 mit deutscher Meldung.
|
||||
3. Endpunkte unter `dashboard/images` (angemeldet): `GET` (eigene Liste, nur Metadaten), `POST` (multipart-Feld `image`), `GET :id` (Binär mit den genannten Headern), `DELETE :id`. Besitz = gleicher Mandant UND gleicher Benutzer; fremde Kennung → 404. Statische Route vor `:id`.
|
||||
4. Widget-Konfiguration im bestehenden Config-JSON: `images: Array<{ kind: 'upload', imageId, caption? } | { kind: 'url', url, caption? }>`, `fit: 'contain' | 'cover'`, `intervalSeconds` (0 = kein Wechsel, sonst 5–3600, Voreinstellung 30), `order: 'sequence' | 'random'`. **Befund am Code:** die API prüft Widget-Konfigurationen NICHT inhaltlich — `UpdateWidgetConfigDto` trägt nur `@IsObject()`, `DashboardService.updateWidgetConfig` führt flach zusammen (`{ ...alt, ...neu }`). Es gibt also keine serverseitige Stelle, die erweitert werden könnte; die https-Prüfung läuft deshalb **web-seitig zweifach**: im Formular (Eingabe abweisen) UND beim Rendern (`resolvePictureFrameConfig` lässt jede Nicht-https-Adresse weg). Ein manipulierter Config-Wert schadet damit nur dem eigenen Dashboard und wird dort nicht einmal gerendert.
|
||||
5. Klick auf das Bild nur außerhalb des Bearbeitungsmodus → Großansicht (Escape / Hintergrund / Schließen-Knopf; Fokus-Handhabung wie `widget-catalog-modal.tsx`: Dialog bei Öffnen fokussieren, zusätzlich Fokus-Rückgabe an den Auslöser). Im Bearbeitungsmodus kein Knopf → die ganze Karte bleibt Ziehgriff (`widget-wrapper.tsx`).
|
||||
6. Wechsel per Timer; Zufall wählt bei >1 Bild nie das aktuelle; Timer beim Aushängen geräumt; pausiert bei offener Großansicht.
|
||||
7. Leerzustand: „Noch keine Bilder — über die Einstellungen hinzufügen“, Stil `flex h-full items-center justify-center text-sm text-muted-foreground` (wie `PlaceholderWidget`/Favoriten-`empty`).
|
||||
8. Bildverwaltung im **WidgetSettingsPanel** (Einstellungen → Dashboard, je Instanz aufklappbar) — das ist die „Einstellungen“-Stelle dieser App; einen Dialog je Widget gibt es nicht. Vorschaubilder über `/api-proxy/dashboard/images/:id` (Muster `FavoriteIcon`, favorites-widget.tsx).
|
||||
9. Texte Deutsch mit „Sie“, plus Englisch; keine kundenspezifischen Vorgaben.
|
||||
10. Katalogname „Bilderrahmen“ / „Picture frame“, Beschreibung kurz („Bilder hochladen oder verlinken, als Diashow“ / „Upload or link images as a slideshow“).
|
||||
|
||||
## Ausgangsmessung (21.09.2026, 573d070)
|
||||
|
||||
| Größe | Wert |
|
||||
|---|---:|
|
||||
| API-Tests | 1148 |
|
||||
| Web-Tests | 531 |
|
||||
| `as unknown as` in apps/api/src | 27 |
|
||||
| `as unknown as` in apps/web/src | 6 |
|
||||
| `lint/style/noNonNullAssertion` in apps/api/src | 56 |
|
||||
| `lint/suspicious/noExplicitAny` in apps/api/src | 13 (jede begründet) |
|
||||
| RLS-Wächter (`src/prisma`) | 30/30 |
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@/home/vicolab/projects/tessera-ctl/CLAUDE.md
|
||||
@/home/vicolab/projects/tessera-ctl/apps/api/src/dashboard/dashboard.service.ts
|
||||
@/home/vicolab/projects/tessera-ctl/apps/api/src/dashboard/dashboard.controller.ts
|
||||
@/home/vicolab/projects/tessera-ctl/apps/api/src/bug-reports/bug-reports.controller.ts
|
||||
@/home/vicolab/projects/tessera-ctl/apps/api/src/bug-reports/bug-reports.service.ts
|
||||
@/home/vicolab/projects/tessera-ctl/apps/api/src/favorites/favorites.controller.ts
|
||||
@/home/vicolab/projects/tessera-ctl/apps/api/prisma/migrations/20260911120000_rls_user_dimension_personal_tables/migration.sql
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widget-catalog-modal.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/settings/widget-settings-panel.tsx
|
||||
</context>
|
||||
|
||||
## Hinweise für den Executor
|
||||
|
||||
- **Lokale Datenbank ohne Host-Port.** Migration anwenden: `IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1)`; `DATABASE_URL="postgresql://tessera:tessera_dev@$IP:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy`; danach `pnpm --filter @tessera/api exec prisma generate`. Läuft der Container nicht: `docker compose up -d db`. **Nie** auf den Testserver deployen, **nie** `prisma db push`.
|
||||
- **Tore vor jedem Commit:** `pnpm type-check`, `pnpm lint`, die betroffenen Vitest-Dateien; am Ende `pnpm --filter @tessera/api test` und `pnpm --filter @tessera/web test` vollständig.
|
||||
- **Wächter:** `rls-coverage.spec.ts` liest Schema und Migrationen (Regex `ALTER TABLE "X" ENABLE ROW LEVEL SECURITY` und `CREATE POLICY \w+ ON "X"`); `rls-access-inventory.spec.ts` verlangt `forTenant(` nur in der Zuweisungsform `const X = forTenant(`, jede `select:`-Angabe innerhalb eines Modellaufrufs, und je (Datei, Modell) eine Zeile in `docs/mandantentrennung-zugriffsklassifikation.md` (Tabelle „| Datei | Modell | Klasse | Stand | Begründung |“, Zeile ~660).
|
||||
- **Prisma 6: `Bytes` ist `Uint8Array`, nicht `Buffer`.** `data: file.buffer` beim Anlegen geht (Buffer ist eine Uint8Array-Unterklasse); bei der Auslieferung `res.send(Buffer.from(row.data.buffer, row.data.byteOffset, row.data.byteLength))` — keine Zusicherung nötig.
|
||||
- **multer:** `LIMIT_FILE_SIZE` bildet Nest auf 413 mit englischer Meldung ab (Muster T-M97-03 in bug-reports.controller.ts) — die deutsche Meldung für „zu groß“ entsteht im Web-Klienten aus dem Status 413.
|
||||
- **Kein `any`**, keine neue `as unknown as`, kein `!`. Multipart-Datei als bestehender Typ `UploadedFileLike` (`apps/api/src/auth/types/auth-user.ts`), Aufrufer als `AuthUser` über `@CurrentUser()`.
|
||||
- **Commits:** je Aufgabe genau ein Commit, Stil wie `git log --oneline -15`, Scope `quick-260921-pi9`, deutsche Betreffzeile. Die Akte/STATE-Commit macht der Orchestrator.
|
||||
- **Schema-Tor (Prisma erkannt):** der `[BLOCKING]`-Schritt „Migration anwenden + `prisma generate`“ steht in Aufgabe 1 VOR dem Dienstcode; ohne ihn wären Typprüfung und Tests falsch-grün.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Aufgabe 1: API — Modell, Migration, Regeln, Dienst, Controller (Ende-zu-Ende „Bild hochladen und wieder abrufen“)</name>
|
||||
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql, apps/api/src/dashboard/dashboard-image-rules.ts, apps/api/src/dashboard/dashboard-image-rules.spec.ts, apps/api/src/dashboard/dashboard-images.service.ts, apps/api/src/dashboard/dashboard-images.service.spec.ts, apps/api/src/dashboard/dashboard-images.controller.ts, apps/api/src/dashboard/dashboard-images.controller.spec.ts, apps/api/src/dashboard/dashboard.module.ts, apps/api/src/dashboard/dto/create-widget.dto.ts, docs/mandantentrennung-zugriffsklassifikation.md</files>
|
||||
<behavior>
|
||||
- `detectImageMime`: PNG-Signatur (`89 50 4E 47 0D 0A 1A 0A`) → `image/png`; `FF D8 FF` → `image/jpeg`; `GIF87a`/`GIF89a` → `image/gif`; `RIFF????WEBP` (Bytes 0–3 `RIFF`, 8–11 `WEBP`) → `image/webp`; leerer Puffer, Textdatei, SVG-Text, PDF (`%PDF`) → `null`; ein Puffer, der mit `RIFF` beginnt, aber ohne `WEBP` an Stelle 8 → `null`.
|
||||
- Dienst `upload`: keine Datei → `BadRequestException('Bitte wählen Sie eine Bilddatei aus.')`; `detectImageMime === null` → `BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.')` — auch wenn `file.mimetype` „image/png“ behauptet; Zähler `count({ where: { tenantId, userId } })` ≥ 30 → `BadRequestException('Sie haben die Höchstzahl von 30 Bildern erreicht. Bitte löschen Sie zuerst ein Bild.')`; sonst `create` mit `mimeType` aus der Erkennung (NICHT aus `file.mimetype`), `originalName` auf 255 Zeichen gekürzt, `size = buffer.length`, Antwort nur Metadaten (`id, originalName, mimeType, size, createdAt`).
|
||||
- Dienst `list`: `findMany({ where: { tenantId, userId }, select: { id, originalName, mimeType, size, createdAt }, orderBy: { createdAt: 'asc' } })` — `data` wird nie mitgeladen.
|
||||
- Dienst `getBytes`/`remove`: `findUnique({ where: { id } })`; fehlt die Zeile ODER `row.userId !== userId` ODER `row.tenantId !== tenantId` → `NotFoundException` (nie Forbidden); `remove` löscht danach.
|
||||
- Jede Methode holt ihren Klienten in der Zuweisungsform `const tenantPrisma = forTenant(this.prisma, tenantId, userId)` (Spec-Attrappe wie in dashboard.service.spec.ts: `forTenant: vi.fn((prisma, tenantId) => prisma.__makeBoundClient(tenantId))`, ein vergessener Aufruf fällt im Test auf).
|
||||
- Controller: `GET /dashboard/images` → `list`; `POST` mit `FileInterceptor('image', { limits: { fileSize: DASHBOARD_IMAGE_MAX_BYTES, files: 1 } })` → `upload(user, file)`; `GET /dashboard/images/:id` setzt `Content-Type` = gespeicherter `mimeType`, `Cache-Control: private, max-age=86400`, `X-Content-Type-Options: nosniff`, `Content-Disposition: inline` (ohne Dateinamen — `originalName` gehört nie in einen Header), zusätzlich `Content-Security-Policy: default-src 'none'; sandbox` (Muster `getIcon`), dann `res.send(Buffer)`; `DELETE /:id` → `remove`, Antwort `{ id }`. Kein `@Roles`-Dekorator (alle angemeldeten Rollen, Muster bug-reports). Mandant/Benutzer ausschließlich aus `@CurrentUser()`.
|
||||
- `CreateWidgetDto`: `@IsIn([...])` enthält zusätzlich `'picture-frame'`.
|
||||
</behavior>
|
||||
<action>
|
||||
**Schritt A — Schema und Migration.** In `schema.prisma` neben `WidgetInstance` das Modell `DashboardImage` anlegen: `id String @id @default(uuid())`, `userId String`, `tenantId String`, `originalName String`, `mimeType String`, `size Int`, `data Bytes`, `createdAt DateTime @default(now())`, `@@index([userId])`, `@@index([tenantId])` (keine Relation, wie `WidgetInstance`). Migration `20260921120000_dashboard_image/migration.sql` von Hand schreiben (Muster CREATE TABLE: `20260708090000_add_favorite_link`, Muster RLS: `20260909140000_rls_remaining_tenant_tables` Abschnitt FavoriteLink plus Benutzerdimension aus `20260911120000`): deutscher Kopfkommentar (Zweck, Grenzen, Besitz), `CREATE TABLE "DashboardImage" (… "data" BYTEA NOT NULL …)`, beide Indizes, `ALTER TABLE "DashboardImage" ENABLE ROW LEVEL SECURITY;`, `ALTER TABLE "DashboardImage" FORCE ROW LEVEL SECURITY;`, `CREATE POLICY tenant_isolation_policy ON "DashboardImage" USING ("tenantId" = current_tenant_id() AND (current_user_id() IS NULL OR "userId" = current_user_id()));`. Rechte für `tessera_app` kommen über `ALTER DEFAULT PRIVILEGES` aus `20260909130000_rls_app_role` automatisch — nichts zu tun, im Kopfkommentar erwähnen.
|
||||
|
||||
**Schritt B [BLOCKING] — Migration anwenden und Klient erzeugen** (Befehle aus den Executor-Hinweisen: `prisma migrate deploy` gegen die Container-IP, dann `prisma generate`). Erst danach gibt es `tenantPrisma.dashboardImage` im Typsystem. Prüfen: `pnpm --filter @tessera/api exec vitest run src/prisma/rls-coverage.spec.ts` muss grün sein (Test 1/2 sehen das neue Modell und die neue Policy).
|
||||
|
||||
**Schritt C — Regeln zuerst, rot.** `dashboard-image-rules.ts` mit `export const DASHBOARD_IMAGE_MAX_BYTES = 5 * 1024 * 1024`, `DASHBOARD_IMAGE_MAX_COUNT = 30`, `export type DashboardImageMime = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp'`, `export function detectImageMime(buffer: Uint8Array): DashboardImageMime | null`. Spec mit den Fällen aus `<behavior>` (mindestens 8), vor der Umsetzung rot.
|
||||
|
||||
**Schritt D — Dienst und Controller, rot dann grün.** `dashboard-images.service.ts` (`@Injectable() DashboardImagesService`, Konstruktor `private readonly prisma: PrismaService`) mit `list(userId, tenantId)`, `upload(user: AuthUser, file: UploadedFileLike | undefined)`, `getBytes(id, userId, tenantId)` → `{ mimeType, data }`, `remove(id, userId, tenantId)`. Dateikopf-Kommentar wie in dashboard.service.ts: warum die Besitzprüfung zusätzlich zur RLS-Regel nicht dekorativ ist (Schalter heute aus). Spec mit `makeFakePrisma`-Muster aus dashboard.service.spec.ts, mindestens 10 Fälle (Liste ohne `data`; Upload ohne Datei; PNG mit behauptetem `text/plain`-mimetype gelingt und speichert `image/png`; Textdatei mit behauptetem `image/png` scheitert; Zähler 30 blockt, 29 lässt durch; fremder Benutzer → 404; fremder Mandant → 404; eigenes Bild liefert Bytes; Löschen eigen/fremd; `forTenant` mit `(prisma, tenantId, userId)` aufgerufen). `dashboard-images.controller.ts` (`@Controller('dashboard/images')`, Reihenfolge `@Get()` → `@Post()` → `@Get(':id')` → `@Delete(':id')`, `@Res() res: Response` aus `express` wie favorites.controller.ts). Controller-Spec (Muster bug-reports.controller.spec.ts, mindestens 4 Fälle): Interceptor-Grenzen `fileSize === DASHBOARD_IMAGE_MAX_BYTES`, `files === 1` über die Nest-Metadaten oder den Aufruf; die vier Header der Binärantwort inklusive `Content-Disposition: inline` ohne Dateinamen; kein `@Roles`-Metadatum; Pfad `dashboard/images`. Beide in `dashboard.module.ts` registrieren (`controllers`, `providers`). `create-widget.dto.ts` um `'picture-frame'` erweitern.
|
||||
|
||||
**Schritt E — Wächter-Dokument.** In `docs/mandantentrennung-zugriffsklassifikation.md` in der (Datei, Modell)-Tabelle eine Zeile `| apps/api/src/dashboard/dashboard-images.service.ts | dashboardImage | muss-mandantengebunden | gebunden | … |` mit Begründung (Bilder eines Benutzers, `tenantId`-Spalte, Benutzerdimension in der Policy seit 20260921120000, Besitzprüfung zusätzlich in `getBytes`/`remove`, Liste/Zähler mit explizitem `where: { tenantId, userId }`). Die Bereichstabelle (Zeile „| dashboard | 1 | 12 | 0 |“) um die neuen gebundenen Treffer erhöhen — die Zahl mit der dort genannten Schleife messen, nicht schätzen.
|
||||
|
||||
Commit: `feat(quick-260921-pi9): Bilderrahmen-API - Bilder je Benutzer in der Datenbank, Magic-Byte-Pruefung, 5 MiB / 30 Stueck` (Wortlaut frei, Stil beachten).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/dashboard src/prisma && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/api lint && test "$(grep -rn 'as unknown as' apps/api/src --include=*.ts | wc -l)" -eq 27 && STAT=$(git show --stat --format= HEAD) && printf '%s' "$STAT" | grep -q 'migrations/20260921120000_dashboard_image/migration.sql'</automated>
|
||||
</verify>
|
||||
<done>Migration lokal angewendet (`prisma migrate status` meldet keine ausstehende Migration), `prisma generate` gelaufen. `dashboard-image-rules.spec.ts` ≥ 8, `dashboard-images.service.spec.ts` ≥ 10, `dashboard-images.controller.spec.ts` ≥ 4 Fälle — alle grün, davon die Regel- und Diensttests nachweislich zuerst rot (Rot-Lauf im SUMMARY nennen). RLS-Wächter `src/prisma` weiterhin 30/30 inklusive der neuen Zeile im Klassifikationsdokument. Ein Rundgang mit `curl` gegen die laufende lokale API (Cookie aus einer Anmeldung): `POST` mit einer PNG-Datei liefert 201 mit Metadaten, `GET /dashboard/images` listet sie ohne `data`, `GET /dashboard/images/<id>` liefert die Bytes mit den vier Headern, eine Textdatei als `.png` liefert 400 mit der deutschen Meldung, eine 6-MiB-Datei 413, eine erfundene Kennung 404. Zähler `as unknown as` = 27, keine neue `any`, kein `!`.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 2: Web — Widget, Großansicht, Einstellungsformular, Katalog, Übersetzungen</name>
|
||||
<files>apps/web/src/lib/dashboard-images-api.ts, apps/web/src/lib/dashboard-images-api.test.ts, apps/web/src/components/dashboard/widgets/picture-frame-config.ts, apps/web/src/components/dashboard/widgets/picture-frame-config.test.ts, apps/web/src/components/dashboard/widgets/picture-frame-widget.tsx, apps/web/src/components/dashboard/widgets/picture-frame-widget.test.tsx, apps/web/src/components/dashboard/widgets/picture-frame-lightbox.tsx, apps/web/src/components/settings/picture-frame-config-form.tsx, apps/web/src/components/settings/picture-frame-config-form.test.tsx, apps/web/src/components/settings/widget-settings-panel.tsx, 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.tsx, apps/web/src/components/dashboard/widget-catalog-modal.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</files>
|
||||
<behavior>
|
||||
- `resolvePictureFrameConfig({})` → `{ images: [], fit: 'contain', intervalSeconds: 30, order: 'sequence' }`; `intervalSeconds` 3 → 5, 9999 → 3600, 0 → 0, `'abc'` → 30; `fit: 'x'` → `'contain'`; `order: 'x'` → `'sequence'`; Einträge ohne gültiges `kind`, Upload ohne `imageId`-String, URL mit `http://`, `javascript:`, `data:` oder ohne Parser-Erfolg (`new URL` wirft) werden weggelassen; `caption` nur übernommen, wenn String, auf 200 Zeichen gekürzt.
|
||||
- `isHttpsUrl('https://a.de/b.jpg')` true; `'http://…'`, `'HTTPS://'`-Schreibweise → true (Protokoll kleingeschrieben vergleichen); `'ftp://'`, `'javascript:alert(1)'`, `'nicht-url'` false.
|
||||
- `pickNextIndex(current, count, order, random)`: `count ≤ 1` → 0; `sequence` → `(current + 1) % count`; `random` → mit gestelltem `random` nie `current` (über 200 Ziehungen bei `count = 3` kommt `current` nie heraus).
|
||||
- Widget: leere Konfiguration → Text `pictureFrame.empty`; ein URL-Eintrag → `<img>` mit `src` = URL, `referrerPolicy="no-referrer"`, Klasse `object-contain`, bei `fit: 'cover'` `object-cover`, `alt` = Bildunterschrift oder `''`; Bildunterschrift als Streifen am unteren Rand; Upload-Eintrag → `src="/api-proxy/dashboard/images/<id>"`; mit `intervalSeconds: 5` und zwei Bildern zeigt `vi.advanceTimersByTime(5000)` das zweite, `unmount()` räumt den Timer (kein `setState` nach dem Aushängen, `vi.getTimerCount()` 0); `intervalSeconds: 0` wechselt nie; `onError` am `<img>` nimmt den Eintrag aus dem Umlauf (bei zwei Einträgen bleibt nur der andere; sind alle kaputt: Text `pictureFrame.unavailable`); Klick auf das Bild bei `isEditMode: false` öffnet `role="dialog"` mit großem Bild und Unterschrift, Escape schließt, Klick auf den Hintergrund-Knopf schließt, Schließen-Knopf schließt, danach hat der Bild-Knopf wieder den Fokus; bei offener Großansicht läuft `advanceTimersByTime` ohne Bildwechsel; bei `isEditMode: true` gibt es keinen Knopf (kein `role="button"` im Widget) und kein Klick öffnet etwas.
|
||||
- Einstellungsformular: zeigt Auswahl Bildausschnitt (`contain`/`cover`), Intervall (Auswahl mit Werten 0/5/10/15/30/60/120/300/600/1800/3600), Reihenfolge (`sequence`/`random`) — jede Änderung ruft `onChange` mit dem einen Feld; die Liste zeigt je Eintrag Vorschau (`<img>` mit `referrerPolicy="no-referrer"`, bei `onError` stattdessen Text `pictureFrame.unavailable`), Unterschrift-Feld (Entwurf, Übernahme bei Blur/Enter → `onChange({ images })` mit dem ganzen Array), Pfeile hoch/runter (oberster Eintrag ohne „hoch“, unterster ohne „runter“, wie Favoriten), Entfernen; Entfernen eines Upload-Eintrags ruft `deleteDashboardImage(imageId)` (Fehler verschluckt) UND `onChange` mit dem verkürzten Array; „Webadresse hinzufügen“ mit `http://` zeigt `role="alert"` `pictureFrame.urlInvalid` und ruft `onChange` nicht; mit https ruft `onChange({ images: [...alt, { kind: 'url', url }] })`; Datei wählen ruft `uploadDashboardImage(file)` und danach `onChange({ images: [...alt, { kind: 'upload', imageId: '<id aus Antwort>' }] })`; wirft der Upload, erscheint dessen Meldung als `role="alert"` und `onChange` bleibt aus; bei 30 Einträgen ist „Bild hochladen“ deaktiviert mit Hinweis `pictureFrame.limitReached`.
|
||||
- `dashboard-images-api`: `uploadDashboardImage` sendet `POST ${API_URL}/dashboard/images` mit `credentials: 'include'` und einem `FormData`, dessen Feld `image` die Datei ist (kein `Content-Type`-Header von Hand); Status 413 → `Error('Die Datei ist zu groß – erlaubt sind höchstens 5 MB.')`; Status 400 mit `{ message: string }` → `Error(message)`; sonst allgemeine Meldung; `dashboardImageSrc('a b')` → `/api-proxy/dashboard/images/a%20b`.
|
||||
</behavior>
|
||||
<action>
|
||||
**Reihenfolge: reine Helfer zuerst (rot → grün), dann Widget, dann Formular, zuletzt Verdrahtung.**
|
||||
|
||||
1. `picture-frame-config.ts`: Typen `PictureFrameEntry` (Vereinigung mit `kind`-Unterscheider, siehe Entscheidung 4), `PictureFrameConfig`, Konstanten `PICTURE_FRAME_INTERVAL_MIN = 5`, `PICTURE_FRAME_INTERVAL_MAX = 3600`, `PICTURE_FRAME_INTERVAL_DEFAULT = 30`, `PICTURE_FRAME_INTERVAL_OPTIONS = [0, 5, 10, 15, 30, 60, 120, 300, 600, 1800, 3600]`, `PICTURE_FRAME_MAX_IMAGES = 30`, `PICTURE_FRAME_CAPTION_MAX = 200`; Funktionen `isHttpsUrl`, `resolvePictureFrameConfig`, `pickNextIndex`, `entryKey(entry, index)` (stabiler React-Schlüssel `upload:<imageId>` bzw. `url:<url>:<index>`). Ohne React-Import, damit der Test schlank bleibt (Muster `clock-font-size.ts`, `calendar-month.ts`).
|
||||
|
||||
2. `dashboard-images-api.ts` nach dem Muster `favorites-api.ts` (`API_URL` aus `NEXT_PUBLIC_API_URL`, `credentials: 'include'`): `DashboardImageMeta`, `fetchDashboardImages()`, `uploadDashboardImage(file: File)`, `deleteDashboardImage(id)`, `dashboardImageSrc(id)`. Test mit `vi.stubGlobal('fetch', …)`.
|
||||
|
||||
3. `picture-frame-widget.tsx` (`'use client'`, Props `WidgetProps`): `resolvePictureFrameConfig(config)` per `useMemo`; Zustand `index`, `brokenKeys: string[]`, `lightboxOpen`; sichtbare Einträge = alle ohne kaputte Schlüssel; `useEffect` mit `setInterval` nur wenn `intervalSeconds > 0 && visible.length > 1 && !lightboxOpen`, Räumung in der Aufräumfunktion; `random` über `pickNextIndex(…, Math.random)`. Darstellung: Rumpf `relative h-full w-full overflow-hidden`, `<img className={fit === 'cover' ? 'h-full w-full object-cover' : 'h-full w-full object-contain'} referrerPolicy="no-referrer" loading="lazy" alt={caption ?? ''} onError=…>`, Unterschrift als `absolute inset-x-0 bottom-0 bg-black/50 px-2 py-1 text-xs text-white truncate` (nur wenn vorhanden). Außerhalb des Bearbeitungsmodus liegt Bild+Streifen in einem `<button type="button" aria-label={t('pictureFrame.open')} className="block h-full w-full cursor-zoom-in">`; im Bearbeitungsmodus in einem `<div>` (kein Handler — die Karte ist der Griff, Entscheidung 5). Leerzustand und „alle kaputt“ als zentrierter grauer Text (Entscheidung 7). Keine `dangerouslySetInnerHTML`. Kommentar im Dateikopf: warum der Server nie eine Adresse abruft (T-PI9-05).
|
||||
|
||||
4. `picture-frame-lightbox.tsx`: Props `{ src, caption, onClose }`; Aufbau wie `widget-catalog-modal.tsx` (`fixed inset-0 z-50`, Hintergrund als `<button aria-label={t('pictureFrame.close')} className="fixed inset-0 bg-black/80">`, Dialog `role="dialog" aria-modal="true" tabIndex={-1}` mit `ref.focus()` beim Einhängen, `keydown`-Escape-Listener mit Aufräumung), Schließen-Knopf oben rechts, `<img className="max-h-[85vh] max-w-[90vw] object-contain" referrerPolicy="no-referrer">`, Unterschrift darunter. Fokus-Rückgabe: das Widget merkt sich den Bild-Knopf per `useRef` und ruft nach dem Schließen `.focus()`.
|
||||
|
||||
5. `picture-frame-config-form.tsx` (`PictureFrameConfigForm({ config, onChange })`, Muster `ClockConfig`/`FavoritesConfig`: Labels `mb-1 block text-sm text-foreground`, Felder `h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground`): drei `<select>` (Bildausschnitt, Intervall mit sprechenden Texten aus `pictureFrame.intervalOff`/Sekunden/Minuten, Reihenfolge); Eintragsliste mit Vorschau 48×48 (`object-cover rounded bg-muted`), Unterschrift-Eingabe (Entwurf/Übernahme wie `commitFontSize`), Pfeil- und Entfernen-Knöpfe als echte `<button>` mit `aria-label` aus `pictureFrame.moveUpButton`/`moveDownButton`/`removeButton`; darunter versteckte `<input type="file" accept="image/png,image/jpeg,image/gif,image/webp">` hinter einem Knopf `pictureFrame.uploadButton` (`disabled` ab 30 Einträgen) und eine Zeile Texteingabe + Knopf `pictureFrame.urlAddButton` mit `isHttpsUrl`-Prüfung; Fehler als `<p role="alert" className="text-xs text-destructive">`. `onChange` bekommt bei Listenänderungen IMMER das vollständige `images`-Array (serverseitig flache Zusammenführung). Im `widget-settings-panel.tsx` einen Zweig `widget.widgetType === 'picture-frame'` ergänzen (gleiche Form wie die anderen fünf).
|
||||
|
||||
6. Verdrahtung: `widget-registry.tsx` — `WidgetType` um `'picture-frame'`, `WIDGET_CONSTRAINTS['picture-frame'] = { minW: 4, minH: 4, defaultW: 8, defaultH: 8 }` (Begründung im Kommentar: Bild braucht Fläche, 8×8 = ein Quadrat wie die halbe Notiz), `PictureFrameIcon` (Inline-SVG: Rahmen `rect 3 3 18 18 rx 2`, `circle 8.5 8.5 r 1.5`, `polyline 21 15 16 10 5 21`), Registry-Eintrag mit `nameKey: 'pictureFrame.name'`, `descriptionKey: 'pictureFrame.description'`, `wirePictureFrameWidget`. `widget-catalog-modal.tsx` — `WIDGET_TYPES` ergänzen. `(portal)/page.tsx` — Import + `wirePictureFrameWidget(PictureFrameWidget)`. Tests nachziehen: `widget-registry.test.tsx` (Typliste und erwartete Constraints-Tabelle), `page.test.tsx` (`vi.mock` des neuen Widget-Moduls wie Zeile 59), `widget-catalog-modal.test.tsx` (Übersetzungsattrappe um `pictureFrame.name`/`description`, falls die Attrappe alle Namen aufzählt).
|
||||
|
||||
7. Übersetzungen `de.json`/`en.json`, Namensraum `widgets.pictureFrame` mit genau diesen Schlüsseln: `name` („Bilderrahmen“/„Picture frame“), `description`, `empty` („Noch keine Bilder — über die Einstellungen hinzufügen“), `unavailable` („Bild nicht verfügbar“), `open` („Bild groß anzeigen“), `close` („Großansicht schließen“), `fitLabel`, `fitContain` („Ganz sichtbar“), `fitCover` („Formatfüllend“), `intervalLabel` („Wechselintervall“), `intervalOff` („Kein Wechsel“), `intervalSeconds` („{n} Sekunden“), `intervalMinutes` („{n} Minuten“), `orderLabel`, `orderSequence` („Reihenfolge“), `orderRandom` („Zufall“), `imagesLabel` („Bilder“), `captionPlaceholder` („Bildunterschrift (optional)“), `uploadButton` („Bild hochladen“), `uploadHint` („PNG, JPEG, GIF oder WebP, höchstens 5 MB, bis zu 30 Bilder“), `urlPlaceholder` („https://…“), `urlAddButton` („Webadresse hinzufügen“), `urlInvalid` („Bitte geben Sie eine vollständige https-Adresse ein.“), `removeButton` („Bild entfernen“), `moveUpButton` („Nach oben“), `moveDownButton` („Nach unten“), `limitReached` („Die Höchstzahl von 30 Bildern ist erreicht.“), `uploadFailed` („Das Bild konnte nicht hochgeladen werden.“). Siezen, englische Entsprechungen in gleicher Tonlage.
|
||||
|
||||
Commit: `feat(quick-260921-pi9): Bilderrahmen-Widget - Diashow mit Grossansicht, Bildverwaltung in den Einstellungen`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard src/components/settings src/lib/dashboard-images-api.test.ts "src/app/(portal)/page.test.tsx" && pnpm --filter @tessera/web exec tsc --noEmit && pnpm --filter @tessera/web lint && test "$(grep -rn 'dangerouslySetInnerHTML' apps/web/src/components/dashboard/widgets/picture-frame-widget.tsx apps/web/src/components/dashboard/widgets/picture-frame-lightbox.tsx apps/web/src/components/settings/picture-frame-config-form.tsx | wc -l)" -eq 0 && node -e "const d=require('./apps/web/src/messages/de.json').widgets.pictureFrame,e=require('./apps/web/src/messages/en.json').widgets.pictureFrame;const m=Object.keys(d).filter(k=>!(k in e));if(m.length){console.error('en fehlt:',m);process.exit(1)}"</automated>
|
||||
</verify>
|
||||
<done>`picture-frame-config.test.ts` ≥ 10, `picture-frame-widget.test.tsx` ≥ 10, `picture-frame-config-form.test.tsx` ≥ 8, `dashboard-images-api.test.ts` ≥ 4 Fälle — alle grün, die Helfer-Tests nachweislich zuerst rot; bestehende Registry-/Katalog-/Seiten-Tests grün mit dem achten Typ. Beide Sprachdateien tragen denselben Schlüsselsatz unter `widgets.pictureFrame`. Kette nachgewiesen (Tests): Datei wählen → `uploadDashboardImage` → `onChange` mit neuem Upload-Eintrag; https-Adresse → `onChange` mit URL-Eintrag; http-Adresse → Fehler, kein `onChange`; Kachel rendert Upload-Eintrag über `/api-proxy/dashboard/images/<id>` und URL-Eintrag direkt mit `referrerPolicy="no-referrer"`; Wechsel, Pause bei Großansicht, Räumung beim Aushängen; kein Knopf im Bearbeitungsmodus. `as unknown as` in apps/web/src bleibt 6, keine `any`, kein `!`.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Aufgabe 3: Changelog, Voll-Tore, Zähler, Prüfliste für den Browser-Rundgang</name>
|
||||
<files>CHANGELOG.md</files>
|
||||
<action>
|
||||
1. `CHANGELOG.md` unter „Unveröffentlicht → Neu“ als ERSTER Stichpunkt (kein Fließtext, Tonlage der Nachbarzeilen): „Dashboard-Widget „Bilderrahmen“: eigene Bilder hochladen (PNG, JPEG, GIF, WebP; höchstens 5 MB je Bild, bis zu 30 Bilder) oder Bilder per https-Adresse einbinden; Bildausschnitt ganz sichtbar oder formatfüllend, Wechselintervall, Reihenfolge oder Zufall, Bildunterschrift; Klick zeigt das Bild groß; Verwaltung unter Einstellungen → Dashboard“.
|
||||
2. Volle Tore laufen lassen: `pnpm type-check` (4/4), `pnpm lint` (5/5), `pnpm --filter @tessera/api test`, `pnpm --filter @tessera/web test`; Zähler messen (`as unknown as` api 27 / web 6, `grep -c '!\.' ` ist ungeeignet — `noNonNullAssertion` und `noExplicitAny` über `pnpm --filter @tessera/api exec biome lint src 2>&1 | grep -c <regel>` oder die im Repo bereits genutzte Zählweise aus 260921-oxm) und die Zahlen ins SUMMARY schreiben.
|
||||
3. Im SUMMARY eine Prüfliste für den Orchestrator (Browser, Playwright-MCP, lokal — NICHT Testserver) hinterlegen, Punkt für Punkt abhakbar: (a) Dashboard → Bearbeiten → „Widget hinzufügen“ zeigt „Bilderrahmen“ mit Symbol; platzierte Kachel zeigt den Leerhinweis; (b) Einstellungen → Dashboard → „Bilderrahmen #1“ aufklappen: PNG hochladen → Vorschau erscheint, Eintrag in `GET /dashboard/images`; (c) https-Adresse hinzufügen → Eintrag; http-Adresse → rote Meldung; (d) `.txt` als `.png` umbenannt hochladen → deutsche Meldung „Nur Bilder im Format …“; (e) Intervall 5 s, zwei Bilder → Kachel wechselt; Zufall mit drei Bildern → nie dasselbe zweimal hintereinander; Bildausschnitt umschalten → `object-cover`/`object-contain` sichtbar anders; (f) Klick auf das Bild → Großansicht mit Unterschrift, Escape schließt, Hintergrund-Klick schließt; während geöffnet kein Wechsel; (g) Bearbeitungsmodus: Klick öffnet nichts, Kachel lässt sich ziehen; (h) `curl -b <cookie> -o /dev/null -w '%{http_code}' …/dashboard/images/<id>` mit dem Cookie eines ZWEITEN Benutzers → 404; (i) Eintrag entfernen → Bild verschwindet aus `GET /dashboard/images`; (j) Netzwerk-Tab: Fremdbild wird vom Browser geladen, kein Aufruf der Fremdadresse durch die API (API-Log leer).
|
||||
Commit: `docs(quick-260921-pi9): Changelog - Bilderrahmen-Widget` (nur CHANGELOG.md; Akte/STATE macht der Orchestrator).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q 'Bilderrahmen' CHANGELOG.md && pnpm type-check && pnpm lint && pnpm --filter @tessera/api test && pnpm --filter @tessera/web test</automated>
|
||||
</verify>
|
||||
<done>Changelog-Zeile steht unter „Unveröffentlicht → Neu“; `pnpm type-check` 4/4, `pnpm lint` 5/5 ohne Befund der Stufe `error`; API ≥ 1170 Tests, Web ≥ 560 Tests, alle grün; Zähler unverändert (`as unknown as` 27/6, `noNonNullAssertion` 56, `noExplicitAny` ≤ 13, `biome-ignore` 1, `ts-expect-error` 0); die zehnpunktige Prüfliste steht im SUMMARY; genau drei Code/Doku-Commits mit Scope `quick-260921-pi9` (`git log --oneline -3`).</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<assumption_delta_decision>
|
||||
Detektor gedanklich ausgeführt: **feuert** (Familie `pluralization`) — bislang hatte ein Widget-Bild genau EINE Quelle (Favoriten: Server-Proxy oder Direktbild als Ersatzkette, aber ein Datensatz); hier tritt die zweite Quelle (Upload NEBEN Webadresse) als gleichwertige Variante auf.
|
||||
|
||||
- **Primäres Nomen:** die Bildquelle — `PictureFrameEntry` als Vereinigung mit `kind`-Unterscheider (`'upload' | 'url'`).
|
||||
- **Entscheidung: `promote`.** Der allgemeine Eintragstyp ist die Primärdarstellung; `imageId` ist ein Detail der Upload-Variante, `url` ein Detail der URL-Variante. Es gibt KEINE parallele Liste `imageIds: string[]` neben `urls: string[]` — eine einzige geordnete Liste `images`, damit Reihenfolge, Unterschrift und Wechsel für beide Varianten dieselbe Logik durchlaufen.
|
||||
- Invariantentest (übernommen, in `picture-frame-config.test.ts`): `resolvePictureFrameConfig` akzeptiert beide Varianten in EINER Liste und erhält deren Reihenfolge; ein Eintrag ohne bekanntes `kind` fällt weg statt die Liste zu kippen.
|
||||
|
||||
API-Coverage-Detektor: **feuert nicht** — kein externer Dienst, keine SDK-Integration; der Browser lädt Fremdbilder, die API kennt nur ihre eigenen Zeilen.
|
||||
</assumption_delta_decision>
|
||||
|
||||
<threat_model>
|
||||
ASVS-Stufe 1, Blockschwelle `high` (jede `high`-Bedrohung MUSS mitigiert sein).
|
||||
|
||||
## Vertrauensgrenzen
|
||||
|
||||
| Grenze | Beschreibung |
|
||||
|---|---|
|
||||
| Browser → API (multipart) | Benutzer lädt beliebige Bytes unter beliebigem Namen/MIME hoch |
|
||||
| Browser → API (`:id`) | Benutzer nennt Bildkennungen — auch fremde |
|
||||
| Config-JSON → Browser | `images[].url`/`caption` stammen aus dem vom Benutzer selbst beschreibbaren Widget-Config und landen in `<img src>`/Text |
|
||||
| Browser → Fremdhost | `<img>` ruft die Webadresse ab; der Fremdhost sieht Anfrage und ggf. Referrer |
|
||||
| API → Datenbank | `bytea` je Benutzer, Mandantentrennung über RLS (Schalter heute aus) + Anwendungsprüfung |
|
||||
|
||||
## STRIDE-Register
|
||||
|
||||
| ID | Kategorie | Komponente | Schwere | Disposition | Maßnahme |
|
||||
|---|---|---|---|---|---|
|
||||
| T-PI9-01 | Tampering | `DashboardImagesService.upload` | high | mitigate | `detectImageMime` über Magic Bytes entscheidet Annahme UND gespeicherten `mimeType`; `file.mimetype`/Dateiendung werden nie ausgewertet; SVG/HTML/PDF-Polyglotte fallen durch (kein `<svg`, kein `%PDF`, kein Text). Auslieferung mit `nosniff` + `CSP default-src 'none'; sandbox`, damit auch ein unerwartet interpretierter Inhalt kein Skript im Tessera-Origin ausführt. |
|
||||
| T-PI9-02 | Denial of Service | `POST /dashboard/images` | medium | mitigate | `FileInterceptor('image', { limits: { fileSize: 5 MiB, files: 1 } })` je Route (Muster T-M97-03); `main.ts` bleibt ohne globales Body-Limit. |
|
||||
| T-PI9-03 | Denial of Service | Zähler 30 je Benutzer | medium | mitigate | `count({ where: { tenantId, userId } })` vor `create` im selben Dienst. Restrisiko (accept, low): zwei gleichzeitige Uploads desselben Benutzers können den Zähler um wenige Bilder überschreiten — kein Schaden über den eigenen Speicher hinaus, keine Transaktion nötig. |
|
||||
| T-PI9-04 | Information Disclosure (IDOR) | `GET/DELETE /dashboard/images/:id` | high | mitigate | Klient je Aufruf `forTenant(prisma, tenantId, userId)`; Anwendungsprüfung `row.userId === userId && row.tenantId === tenantId`, sonst 404 (nie 403 — Existenz fremder Kennungen bleibt verborgen); RLS-Policy mit Benutzerdimension in der Migration; Kennungen `uuid()` (nicht erratbar). Getestet: fremder Benutzer UND fremder Mandant → 404. |
|
||||
| T-PI9-05 | Server-Side Request Forgery | URL-Einträge | high | mitigate | Die API ruft NIE eine Webadresse ab: kein Proxy-Endpunkt nimmt eine URL an, `images[].url` ist für die API ein undurchsichtiger JSON-Wert. Der Browser des Benutzers lädt das Bild selbst (`<img>`); interne Hosts sieht damit nur, wer sie ohnehin erreicht. Nachweis im Rundgang (j). |
|
||||
| T-PI9-06 | Tampering (XSS) | Unterschriften, Dateinamen | medium | mitigate | Nur React-Textknoten, kein `dangerouslySetInnerHTML` (Verify-Gate in Aufgabe 2); `originalName` erscheint in keinem HTTP-Header (`Content-Disposition: inline` ohne `filename`) und nirgends als HTML; Unterschrift auf 200 Zeichen gekürzt. |
|
||||
| T-PI9-07 | Tampering (Mixed Content / gefährliche Schemata) | `images[].url` | medium | mitigate | `isHttpsUrl` (echter `URL`-Parser, `protocol === 'https:'`) im Formular UND in `resolvePictureFrameConfig` beim Rendern — `http:`, `data:`, `javascript:`, `file:` werden nie zum `src`. Serverseitig nicht prüfbar (API kennt keine Config-Inhalte, siehe Entscheidung 4) — Risiko bleibt auf das eigene Dashboard beschränkt. |
|
||||
| T-PI9-08 | Spoofing (Content-Type) | `GET /dashboard/images/:id` | medium | mitigate | `Content-Type` ausschließlich aus dem per Magic Bytes bestimmten, gespeicherten `mimeType` (eine der vier Bild-Konstanten), `X-Content-Type-Options: nosniff`. |
|
||||
| T-PI9-09 | Information Disclosure (Referrer) | `<img>` auf Fremdhost | low | mitigate | `referrerPolicy="no-referrer"` an jedem `<img>` (Widget, Großansicht, Vorschau im Formular) — der Fremdhost erfährt die Tessera-Adresse nicht. |
|
||||
| T-PI9-10 | Information Disclosure (Caches) | Auslieferung eigener Bilder | low | mitigate | `Cache-Control: private, max-age=86400` — kein gemeinsamer Zwischenspeicher (Nginx Proxy Manager) darf die Antwort für andere ausliefern. |
|
||||
| T-PI9-11 | Elevation of Privilege | multipart-Rumpf | medium | mitigate | Mandant/Benutzer nur aus `@CurrentUser()` (Sitzungsnachweis); der Rumpf hat genau das Feld `image`, keine DTO-Felder für `tenantId`/`userId` (Muster T-M97-06). |
|
||||
| T-PI9-12 | Repudiation | Löschen/Hochladen | low | accept | Kein Audit-Log für Bilder — persönliche Inhalte ohne Fremdwirkung; Zeitstempel `createdAt` reicht für ASVS 1. |
|
||||
| T-PI9-SC | Tampering (Lieferkette) | npm-Installationen | high | mitigate | Nicht ausgelöst: KEINE neuen Pakete — multer kommt über das vorhandene `@nestjs/platform-express`, Magic-Byte-Erkennung ist eine Handvoll eigener Zeilen (kein `file-type`-Paket). Sollte der Executor dennoch ein Paket installieren wollen: Stopp, Rückfrage an den Orchestrator. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Automatisch (Executor, je Aufgabe im `<verify>`): API-Tests `src/dashboard` + `src/prisma`, Web-Tests der neuen und angefassten Dateien, `tsc --noEmit` beider Apps, Biome, Zähler `as unknown as`, Schlüsselgleichheit de/en, kein `dangerouslySetInnerHTML`, Migration im Commit.
|
||||
|
||||
Am Ende (Aufgabe 3): `pnpm type-check` 4/4, `pnpm lint` 5/5, volle Testläufe beider Apps, Disziplin-Zähler wie in der Ausgangsmessung.
|
||||
|
||||
Manuell (Orchestrator, Prüfliste aus Aufgabe 3 Punkt 3, lokal im Browser): Katalog, Upload, https/http, Nicht-Bild, Wechsel/Zufall/Ausschnitt, Großansicht, Bearbeitungsmodus, 404 für fremde Kennung, Löschen, kein Server-Abruf der Fremdadresse.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- [ ] Alle sieben `must_haves.truths` erfüllt und je mit Test oder Rundgangspunkt belegt
|
||||
- [ ] Migration `20260921120000_dashboard_image` angewendet, `rls-coverage` und `rls-access-inventory` grün (30/30 in `src/prisma`)
|
||||
- [ ] Fremde Kennung → 404 (Benutzer UND Mandant), Nicht-Bild → 400 deutsch, > 5 MiB → 413, 31. Bild → 400 deutsch
|
||||
- [ ] Widget: Leerzustand, Wechsel (Reihenfolge/Zufall), Ausschnitt, Unterschrift, Großansicht mit Fokus-Rückgabe, kein Klick im Bearbeitungsmodus, Timer geräumt
|
||||
- [ ] Formular: Upload, https-Adresse, Abweisung http, Unterschrift, Pfeile, Entfernen (mit Server-Löschung), „Bild nicht verfügbar“
|
||||
- [ ] Beide Sprachdateien vollständig, Texte siezen
|
||||
- [ ] Changelog-Stichpunkt unter „Unveröffentlicht → Neu“
|
||||
- [ ] Tore grün, Zähler unverändert, keine neue `any`, drei Commits mit Scope `quick-260921-pi9`
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Nach Abschluss `.planning/quick/260921-pi9-dashboard-widget-bilderrahmen-bilder-hoc/260921-pi9-SUMMARY.md` anlegen (Muster `260921-oxm-SUMMARY.md`): Rot-Nachweis der Helfer-/Diensttests, Zahlen der Endmessung, die zehnpunktige Browser-Prüfliste für den Orchestrator, offene Punkte.
|
||||
</output>
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
---
|
||||
phase: quick-260921-pi9
|
||||
plan: 01
|
||||
subsystem: apps/api/src/dashboard, apps/web/src/components/dashboard/widgets, apps/web/src/components/settings
|
||||
tags: [dashboard, widget, bilderrahmen, upload, bytea, magic-bytes, rls, tdd, i18n]
|
||||
status: complete
|
||||
requires:
|
||||
- "STATE.md „NAECHSTER AUFTRAG“: erstes der zwei neuen Dashboard-Widgets, Produktfragen geklaert"
|
||||
- "Migration 20260911120000 (Regelform mit Benutzerdimension)"
|
||||
provides:
|
||||
- "Widget-Typ picture-frame: Diashow aus hochgeladenen Bildern und https-Adressen mit Grossansicht"
|
||||
- "API dashboard/images: Bilder je Benutzer als bytea, Magic-Byte-Pruefung, 5 MiB / 30 Stueck, Besitz = Mandant UND Benutzer"
|
||||
- "Bildverwaltung im WidgetSettingsPanel (Einstellungen -> Dashboard)"
|
||||
affects:
|
||||
- "apps/api/prisma/schema.prisma (neues Modell DashboardImage)"
|
||||
- "apps/api/src/dashboard/*"
|
||||
- "apps/web/src/components/dashboard/widget-registry.tsx (achter Typ)"
|
||||
- "apps/web/src/messages/de.json, en.json (Namensraum widgets.pictureFrame)"
|
||||
- "docs/mandantentrennung-zugriffsklassifikation.md (neues Paar, Zahlen nachgemessen)"
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Magic-Byte-Erkennung als reine Funktion (kein file-type-Paket), entscheidet Annahme UND gespeicherten Typ"
|
||||
- "Eintragstyp als Vereinigung mit kind-Unterscheider in EINER geordneten Liste (promote, kein Listenpaar)"
|
||||
- "Fremdbilder laedt nur der Browser (<img referrerPolicy=no-referrer>), die API kennt keinen URL-Proxy"
|
||||
- "Prisma-Bytes: new Uint8Array(buffer) statt Zusicherung (TS 5.9 verlangt Uint8Array<ArrayBuffer>)"
|
||||
key-files:
|
||||
created:
|
||||
- apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql
|
||||
- apps/api/src/dashboard/dashboard-image-rules.ts
|
||||
- apps/api/src/dashboard/dashboard-image-rules.spec.ts
|
||||
- apps/api/src/dashboard/dashboard-images.service.ts
|
||||
- apps/api/src/dashboard/dashboard-images.service.spec.ts
|
||||
- apps/api/src/dashboard/dashboard-images.controller.ts
|
||||
- apps/api/src/dashboard/dashboard-images.controller.spec.ts
|
||||
- apps/web/src/lib/dashboard-images-api.ts
|
||||
- apps/web/src/lib/dashboard-images-api.test.ts
|
||||
- apps/web/src/components/dashboard/widgets/picture-frame-config.ts
|
||||
- apps/web/src/components/dashboard/widgets/picture-frame-config.test.ts
|
||||
- apps/web/src/components/dashboard/widgets/picture-frame-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/picture-frame-widget.test.tsx
|
||||
- apps/web/src/components/dashboard/widgets/picture-frame-lightbox.tsx
|
||||
- apps/web/src/components/settings/picture-frame-config-form.tsx
|
||||
- apps/web/src/components/settings/picture-frame-config-form.test.tsx
|
||||
modified:
|
||||
- apps/api/prisma/schema.prisma
|
||||
- apps/api/src/dashboard/dashboard.module.ts
|
||||
- apps/api/src/dashboard/dto/create-widget.dto.ts
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
- apps/web/src/components/settings/widget-settings-panel.tsx
|
||||
- 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.tsx
|
||||
- apps/web/src/components/dashboard/widget-catalog-modal.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
|
||||
decisions:
|
||||
- "Prisma-Bytes ohne Zusicherung: new Uint8Array(file.buffer) kopiert einmal je Upload (hoechstens 5 MiB) — der Plan-Hinweis „data: file.buffer geht“ stimmt unter TS 5.9 + Prisma 6 nicht, der Compiler lehnt Buffer<ArrayBufferLike> ab"
|
||||
- "Widget-Test 11 prueft die Pause des Wechsels ueber das Verhalten (vier Intervalle vergehen, Bild bleibt), nicht ueber vi.getTimerCount(): React haelt nach einer Interaktion selbst einen Scheduler-Timer (gemessen 1), der Zaehler misst also nicht nur unseren Timer"
|
||||
- "Klassifikationsdokument: Bereichs- und Summenzeilen nachgemessen statt +6 addiert — settings (3 -> 4) und bug-reports waren seit 260914-m97 in der Summe nie mitgezaehlt, die Paarzahl der Klassen-Verteilung stand auf 72 bei tatsaechlich 73 Zeilen; jetzt 187 gebunden / 74 Paare, beides der Messung entnommen"
|
||||
- "Vorschau im Formular aria-hidden (dekorativ, die Unterschrift traegt den Sinn); das Kachelbild behaelt sein alt und damit eine Biome-Warnung der Stufe warn (onError auf <img> gilt der a11y-Regel als Interaktion — Fehlbefund)"
|
||||
metrics:
|
||||
duration: "ca. 75 min (18:35 bis 19:50 Uhr, 21.09.2026)"
|
||||
completed: 2026-09-21
|
||||
actuals:
|
||||
tokens: 36000
|
||||
tasks: 3
|
||||
commits: 3
|
||||
plan_head_before: 573d070
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260921-pi9: Dashboard-Widget „Bilderrahmen“ Summary
|
||||
|
||||
Ein neues Dashboard-Widget zeigt eigene Bilder als Diashow: hochgeladen (in
|
||||
der Datenbank, dem Benutzer gehoerend, 5 MiB je Datei, 30 je Benutzer) oder
|
||||
per https-Adresse eingebunden (der Browser laedt sie direkt, der Server ruft
|
||||
nie eine Adresse ab). Bildausschnitt, Wechselintervall, Reihenfolge/Zufall und
|
||||
Bildunterschrift stellt der Benutzer unter Einstellungen -> Dashboard ein; ein
|
||||
Klick zeigt das Bild gross. Alle Tore sind gruen, der curl-Rundgang lief gegen
|
||||
die lebende lokale API.
|
||||
|
||||
## Was gebaut wurde
|
||||
|
||||
**API (Commit 737974b).** Prisma-Modell `DashboardImage` (`data Bytes`, keine
|
||||
Relation) mit handgeschriebener Migration `20260921120000_dashboard_image`:
|
||||
Tabelle, beide Indizes, `ENABLE`/`FORCE ROW LEVEL SECURITY` und
|
||||
`tenant_isolation_policy` mit Benutzerdimension von Anfang an. Die Migration
|
||||
ist lokal angewendet (`prisma migrate status`: keine ausstehende), `prisma
|
||||
generate` gelaufen. `dashboard-image-rules.ts` erkennt PNG/JPEG/GIF/WebP an
|
||||
den Magic Bytes — `file.mimetype` und Dateiendung werden nie gelesen, der
|
||||
erkannte Typ ist zugleich der gespeicherte und der spaeter ausgelieferte.
|
||||
`DashboardImagesService` (list/upload/getBytes/remove) holt je Methode
|
||||
`const tenantPrisma = forTenant(this.prisma, tenantId, userId)`; Liste und
|
||||
Zaehler filtern explizit `{ tenantId, userId }`, `getBytes`/`remove` pruefen
|
||||
Besitz gegen Mandant UND Benutzer und antworten sonst 404 (nie 403).
|
||||
`DashboardImagesController` unter `dashboard/images`: `GET` -> `POST`
|
||||
(`FileInterceptor('image', 5 MiB, eine Datei)`) -> `GET :id` (Content-Type
|
||||
aus dem gespeicherten Typ, `Cache-Control: private, max-age=86400`,
|
||||
`X-Content-Type-Options: nosniff`, `Content-Disposition: inline` ohne
|
||||
Dateinamen, CSP `default-src 'none'; sandbox`) -> `DELETE :id`. Kein
|
||||
`@Roles`. `CreateWidgetDto` kennt `'picture-frame'`.
|
||||
|
||||
**Web (Commit c080580).** `picture-frame-config.ts`: `PictureFrameEntry` als
|
||||
Vereinigung (`upload` | `url`) in EINER Liste, `resolvePictureFrameConfig`
|
||||
laesst alles weg, was der URL-Parser nicht als `https:` erkennt (T-PI9-07:
|
||||
die API prueft Config-Inhalte nicht, deshalb entscheidet allein diese Funktion,
|
||||
was zum `src` wird), Intervall 0 oder 5..3600 s (Vorgabe 30), `pickNextIndex`
|
||||
(Zufall zieht aus count-1 Kandidaten, nie das aktuelle). `dashboard-images-api.ts`
|
||||
schickt die Datei als FormData-Feld `image` ohne eigenen Content-Type, macht
|
||||
aus 413 die deutsche Meldung, 400-Meldungen kommen bereits deutsch von der API.
|
||||
`PictureFrameWidget`: Leerhinweis im Stil der anderen Widgets, `<img
|
||||
referrerPolicy="no-referrer">` (Upload ueber `/api-proxy/dashboard/images/:id`,
|
||||
URL direkt), `object-contain`/`object-cover`, Unterschrift als Streifen, Timer
|
||||
nur bei > 1 Bild und Intervall > 0 und geschlossener Grossansicht (Raeumung im
|
||||
Cleanup), kaputte Bilder verlassen den Umlauf („Bild nicht verfuegbar“, wenn
|
||||
alle). Ausserhalb des Bearbeitungsmodus liegt das Bild in einem `<button>`
|
||||
(Grossansicht `PictureFrameLightbox`: Dialog fokussiert, Escape/Hintergrund/
|
||||
Schliessen-Knopf, danach Fokus zurueck am Bild-Knopf); im Bearbeitungsmodus ein
|
||||
`<div>` ohne Handler — die Karte bleibt der Ziehgriff. `PictureFrameConfigForm`
|
||||
im WidgetSettingsPanel: drei Auswahlfelder (senden nur ihr Feld), Eintragsliste
|
||||
mit Vorschau/Unterschrift (Entwurf, Uebernahme bei Blur/Enter)/Pfeilen/Entfernen
|
||||
(Upload wird auch serverseitig geloescht, Fehler verschluckt), Datei hochladen
|
||||
(deaktiviert ab 30), Webadresse hinzufuegen (http -> `role="alert"`, kein
|
||||
`onChange`). Listenaenderungen senden IMMER das ganze `images`-Array. Registry
|
||||
(`minW 4, minH 4, defaultW 8, defaultH 8`), Katalog, Seite, 28 Schluessel je
|
||||
Sprache unter `widgets.pictureFrame`.
|
||||
|
||||
**Doku (Commit c3b4597).** Changelog-Stichpunkt als erster unter
|
||||
„Unveroeffentlicht -> Neu“, Zeile in der Widget-Tabelle und Absatz unter
|
||||
„Dashboard > Widgets“ im Anwenderhandbuch, zwei Woerter auf der Erlaubnisliste
|
||||
des Umlaut-Waechters (siehe Deviations).
|
||||
|
||||
## Die Tests, und der Beleg dass sie rot waren
|
||||
|
||||
| Datei | Faelle | Rot-Lauf (vor der Umsetzung) |
|
||||
|---|---:|---|
|
||||
| `dashboard-image-rules.spec.ts` | 10 | `pnpm --filter @tessera/api exec vitest run src/dashboard/dashboard-image-rules.spec.ts` -> `Error: Cannot find module './dashboard-image-rules'`, 1 Test File failed, 10 Faelle nicht ausfuehrbar |
|
||||
| `dashboard-images.service.spec.ts` | 12 | `... vitest run src/dashboard/dashboard-images.service.spec.ts` -> `Cannot find module './dashboard-images.service'`, 1 failed |
|
||||
| `dashboard-images.controller.spec.ts` | 5 | `... vitest run src/dashboard/dashboard-images.controller.spec.ts` -> `Cannot find module './dashboard-images.controller'`, 1 failed |
|
||||
| `picture-frame-config.test.ts` | 11 | `pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/picture-frame-config.test.ts src/lib/dashboard-images-api.test.ts` -> `Failed to resolve import "./picture-frame-config"`, 2 Test Files failed |
|
||||
| `dashboard-images-api.test.ts` | 5 | derselbe Lauf -> `Failed to resolve import "./dashboard-images-api"` |
|
||||
| `picture-frame-widget.test.tsx` | 12 | nach der Umsetzung geschrieben (Plan verlangt Rot nur fuer Regel-/Dienst-/Helfer-Tests); erster Lauf 11/12, Test 11 wegen des React-Scheduler-Timers umgestellt (siehe decisions) |
|
||||
| `picture-frame-config-form.test.tsx` | 9 | nach der Umsetzung geschrieben; erster Lauf 9/9 |
|
||||
|
||||
Zusammen 64 neue Faelle; die bestehenden Registry-/Katalog-/Seiten-Tests laufen
|
||||
mit dem achten Typ (Constraints-Tabelle, `counted` 28 -> 32, Attrappen um
|
||||
`pictureFrame.*` und das neue Widget-Modul ergaenzt).
|
||||
|
||||
## curl-Rundgang gegen die lebende API
|
||||
|
||||
Die Container `api`/`web` lagen mit einem alten Image still; die API lief
|
||||
deshalb aus dem Quelltext (`nest build` + `node dist/main.js`) gegen eine
|
||||
eigens angelegte, leere Datenbank `tessera_pi9` auf dem lokalen db-Container
|
||||
(Migrationen angewendet, Admin per Erstanlage), danach wieder geloescht. Kein
|
||||
Zugriff auf den Testserver.
|
||||
|
||||
| Schritt | Ergebnis |
|
||||
|---|---|
|
||||
| `POST /dashboard/images` mit 4x4-PNG | 201, `{ id, originalName, mimeType: "image/png", size: 73, createdAt }` |
|
||||
| `GET /dashboard/images` | 200, Liste mit denselben fuenf Feldern, kein `data` |
|
||||
| `GET /dashboard/images/<id>` | 200, `Content-Type: image/png`, `Cache-Control: private, max-age=86400`, `X-Content-Type-Options: nosniff`, `Content-Disposition: inline`, `Content-Security-Policy: default-src 'none'; sandbox`; Bytes per `cmp` identisch mit der Quelle |
|
||||
| Textdatei als `.png` (`type=image/png`) | 400 `Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.` |
|
||||
| 6-MiB-Datei | 413 `File too large` (multer/Nest, im Web-Klienten deutsch) |
|
||||
| erfundene Kennung | 404 |
|
||||
| ohne Cookie | 401 |
|
||||
| Kennung mit dem Cookie eines ZWEITEN Benutzers (`GET` und `DELETE`) | 404 / 404 (Punkt (h) der Pruefliste bereits erledigt) |
|
||||
| eigener `DELETE` | 200 `{ id }`, Liste danach `[]` |
|
||||
|
||||
## Messungen (Endstand, HEAD c3b4597)
|
||||
|
||||
| Groesse | Ausgang (573d070) | Jetzt |
|
||||
|---|---:|---:|
|
||||
| `pnpm type-check` | 4/4 | 4/4 |
|
||||
| `pnpm lint` | 5/5 | 5/5 (api 74 Warnungen, web 53, keine Stufe `error`) |
|
||||
| API-Tests | 1148 | **1175** (75 Dateien) |
|
||||
| Web-Tests | 531 | **569** (77 Dateien) |
|
||||
| `as unknown as` in apps/api/src | 27 | 27 |
|
||||
| `as unknown as` in apps/web/src | 6 | 6 |
|
||||
| `noNonNullAssertion` in apps/api/src (biome) | 56 | 56 |
|
||||
| `noExplicitAny` in apps/api/src (biome) | 13 | 13 |
|
||||
| `biome-ignore` in apps/api/src | 1 | 1 |
|
||||
| `ts-expect-error` | 0 | 0 |
|
||||
| `dangerouslySetInnerHTML` in den drei neuen Komponenten | – | 0 |
|
||||
| RLS-Waechter `src/prisma` | 30/30 laut Plan | 78/78 (davon `rls-coverage` + `rls-access-inventory` 35/35) |
|
||||
| de/en-Schluesselgleichheit `widgets.pictureFrame` | – | 28 = 28 |
|
||||
|
||||
Keine neue `any`, kein `!`, kein neues Paket.
|
||||
|
||||
## Pruefliste fuer den Browser-Rundgang (Orchestrator, lokal, Playwright-MCP)
|
||||
|
||||
- [x] (a) Dashboard -> Bearbeiten -> „Widget hinzufuegen“ zeigt „Bilderrahmen“ mit Rahmen-Symbol; die platzierte Kachel (8x8) zeigt „Noch keine Bilder — ueber die Einstellungen hinzufuegen“
|
||||
- [x] (b) Einstellungen -> Dashboard -> „Bilderrahmen #1“ aufklappen: PNG hochladen -> Vorschau erscheint in der Liste, `GET /dashboard/images` enthaelt den Eintrag ohne `data`
|
||||
- [x] (c) https-Adresse hinzufuegen -> Eintrag mit Vorschau; http-Adresse -> rote Meldung „Bitte geben Sie eine vollstaendige https-Adresse ein.“, kein Eintrag
|
||||
- [x] (d) `.txt` als `.png` umbenannt hochladen -> rote Meldung „Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.“
|
||||
- [x] (e) Intervall 5 s, zwei Bilder -> Kachel wechselt; Zufall mit drei Bildern -> nie dasselbe zweimal hintereinander; Bildausschnitt umschalten -> `object-cover` / `object-contain` sichtbar anders
|
||||
- [x] (f) Klick auf das Bild -> Grossansicht mit Unterschrift; Escape schliesst, Hintergrund-Klick schliesst, Schliessen-Knopf schliesst; waehrend geoeffnet kein Wechsel
|
||||
- [x] (g) Bearbeitungsmodus: Klick auf das Bild oeffnet nichts, die Kachel laesst sich an jeder Stelle ziehen
|
||||
- [x] (h) `curl -b <cookie zweiter Benutzer> -o /dev/null -w '%{http_code}' .../dashboard/images/<id>` -> 404 (bereits mit curl gegen die lokale API belegt, siehe Rundgang oben; im Browser optional wiederholen)
|
||||
- [x] (i) Eintrag entfernen -> Bild verschwindet aus `GET /dashboard/images` und aus der Kachel
|
||||
- [x] (j) Netzwerk-Tab: das Fremdbild laedt der Browser selbst (Anfrage an den Fremdhost mit `Referrer Policy: no-referrer`), im API-Log kein Aufruf der Fremdadresse
|
||||
|
||||
**Rundgang durch den Orchestrator am 21.09.2026 (lokaler Stack, Abbilder aus HEAD, Playwright-MCP):** alle zehn Punkte bestanden. Belege: (a) Katalog zeigt „Bilderrahmen“ mit Beschreibung, Leerhinweis in der Kachel; (b) `rot.png` hochgeladen, Vorschau 320 px, `GET /dashboard/images` liefert `{id, originalName, mimeType, size, createdAt}` ohne `data`; (c) `http://example.com/bild.png` → Meldung, kein Eintrag; `https://www.gstatic.com/webp/gallery/1.webp` → Eintrag mit Vorschau (eine zuvor eingetragene, serverseitig 400 liefernde Wikimedia-Adresse zeigte korrekt „Bild nicht verfügbar“); (d) Textdatei als `.png` → „Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.“; (e) Intervall 5 s: blau → gstatic → rot → blau im Sekundentakt gemessen; Zufall mit drei Bildern: 8 Wechsel in 42 s, nie dasselbe zweimal hintereinander; `object-cover` nach Umschalten; (f) Grossansicht nach Portal-Korrektur 8bf3601 ueber den ganzen Viewport (Hintergrund 1905x949), Escape schliesst mit Fokusrueckgabe auf „Bild groß anzeigen“, Hintergrund-Klick schliesst, waehrend geoeffnet 6 s lang kein Wechsel; (g) Bearbeitungsmodus: kein Bild-Knopf im Widget, Ziehen ueber die Bildflaeche verschiebt die Kachel (`translate` 8 → 480 px), kein Dialog; (h) siehe curl; (i) zweiten Eintrag entfernt → `GET /dashboard/images` nur noch `rot.png`, Config nur noch zwei Eintraege; (j) Netzwerk: gstatic-Abruf kommt vom Browser, API-Log ohne Treffer auf `gstatic`.
|
||||
|
||||
**Drei Befunde aus dem Rundgang, behoben in 8bf3601:** (1) die Grossansicht war auf die Kachelflaeche (531x216) beschraenkt — die Kachel liegt in einem `react-grid-item` mit CSS-`transform`, und ein transformierter Vorfahr wird fuer `position: fixed` zum Bezugsrahmen; jetzt `createPortal` in `document.body` wie der Kalender-Tooltip; (2) „1 Minuten“ im Wechselintervall → ICU-Plural in de/en, Formular-Test nutzt dafuer `createTranslator` von next-intl auf der echten de.json; (3) Standardgroesse 8x8 (216 px hoch) zu flach → 8x12 wie der Kalender. Web-Tests 569 unveraendert in der Zahl (ein Fall um die Singular-Pruefung ergaenzt).
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
1. **[Rule 1 - Bug] `data: file.buffer` kompiliert nicht.** Der Executor-Hinweis
|
||||
„`data: file.buffer` beim Anlegen geht“ stimmt unter TS 5.9 + Prisma 6.19
|
||||
nicht: `Bytes` verlangt `Uint8Array<ArrayBuffer>`, multers `Buffer` ist
|
||||
ueber `ArrayBufferLike` getypt und wird abgelehnt (TS2322). Statt einer
|
||||
Zusicherung kopiert `new Uint8Array(file.buffer)` einmal je Upload
|
||||
(hoechstens 5 MiB). Aufgabe 1, Commit 737974b.
|
||||
2. **[Rule 3 - Blocking] Umlaut-Waechter.** Der volle Web-Testlauf meldete die
|
||||
neuen de.json-Woerter „Bildausschnitt“ und „Webadresse“ als unbekannte
|
||||
ss-Tokens. Beide sind korrektes Deutsch und stehen jetzt auf
|
||||
`UMLAUT_ALLOWLIST` in `apps/web/src/messages/umlaut-dictionary.ts` (Datei
|
||||
nicht im Plan). Aufgabe 3, Commit c3b4597.
|
||||
3. **Verify-Skript Aufgabe 1:** `git show --stat` kuerzt den Migrationspfad
|
||||
auf `.../20260921120000_dashboard_image/migration.sql`, der `grep` des
|
||||
Plans auf den vollen Pfad schlaegt deshalb fehl; mit `--stat=200` ist die
|
||||
Migration im Commit eindeutig nachgewiesen. Kein Code-Befund.
|
||||
4. **Klassifikationsdokument, mehr als die geplante eine Zeile:** die
|
||||
Nachmessung mit der Gate-Schleife ergab, dass Bereichs- und Summenzeilen
|
||||
bereits vor dieser Aufgabe um zwei Rohtreffer (settings 3 statt 4,
|
||||
bug-reports nie summiert) und die Klassen-Verteilung um ein Paar
|
||||
(bug-reports) hinterherhingen. Beides ist nachgezogen und im Dokument als
|
||||
Nachtrag 260921-pi9 begruendet; der Waechter `rls-access-inventory` prueft
|
||||
nur die Paartabelle und war davon nicht betroffen.
|
||||
5. **Widget-Test 11** misst die Pause des Wechsels ueber das Verhalten statt
|
||||
ueber `vi.getTimerCount()` (siehe decisions).
|
||||
6. **Zusatz des Orchestrators umgesetzt:** Zeile und Absatz in
|
||||
`docs/anleitung-anwender.md` im Aufgabe-3-Commit.
|
||||
|
||||
Nicht geaendert: `STATE.md`, `ROADMAP.md`, keine neue Abhaengigkeit, kein
|
||||
Deploy, kein Zugriff auf den Testserver.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
Keine. Jede Kette ist verdrahtet: Datei -> Upload -> Config -> Kachel -> Proxy
|
||||
-> API -> Bytes; https-Adresse -> Config -> Kachel -> Browser.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
Keine neue Flaeche ausserhalb des `<threat_model>` des Plans: die vier Routen
|
||||
unter `dashboard/images` und die `<img>`-Fremdabrufe sind dort als T-PI9-01
|
||||
bis T-PI9-11 erfasst und mitigiert; `Content-Disposition` ohne Dateinamen und
|
||||
`Cache-Control: private` sind mit curl belegt.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Alle 16 neu angelegten Dateien liegen auf der Platte, die drei Commits
|
||||
737974b, c080580 und c3b4597 sind in `git log` auffindbar
|
||||
(`git rev-list --count 573d070..HEAD` = 3). Die Zahlen der Tabelle stammen
|
||||
aus tatsaechlich gelaufenen Befehlen.
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
---
|
||||
phase: quick-260921-qd3
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements: [QUICK-260921-QD3]
|
||||
|
||||
files_modified:
|
||||
- apps/api/src/dashboard/dto/create-widget.dto.ts
|
||||
- apps/web/src/components/dashboard/widgets/xframe-config.ts
|
||||
- apps/web/src/components/dashboard/widgets/xframe-config.test.ts
|
||||
- apps/web/src/components/dashboard/widgets/xframe-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx
|
||||
- apps/web/src/components/settings/widget-settings-panel.tsx
|
||||
- apps/web/src/components/settings/widget-settings-panel.test.tsx
|
||||
- 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.tsx
|
||||
- apps/web/src/components/dashboard/widget-catalog-modal.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
|
||||
|
||||
estimate:
|
||||
tokens: 110000
|
||||
raw_tokens: 110000
|
||||
tasks: 2
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Im Widget-Katalog gibt es „XFrame“ (Beschreibung „Webseite einbetten“ / „Embed a web page“) mit Fenster-Symbol; eine frisch platzierte Kachel (12×8) zeigt zentriert grau „Keine Adresse eingestellt — über die Einstellungen festlegen“ und keinen Rahmen."
|
||||
- "Unter Einstellungen → Dashboard → XFrame trägt der Benutzer eine https-Adresse (Übernahme bei Blur/Enter), optional einen Titel (höchstens 100 Zeichen) und ein Neuladen-Intervall (Nie / 1 / 5 / 10 / 30 Minuten / 1 Stunde) ein; eine http-, data- oder javascript-Adresse wird mit deutscher `role=\"alert\"`-Meldung abgewiesen und NICHT gespeichert; dauerhaft steht der Hinweis, dass manche Webseiten das Einbetten verweigern."
|
||||
- "Die Kachel rendert genau ein `<iframe>` mit `src` = Adresse, `sandbox=\"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox\"` (keine Freigabe der Navigation des obersten Fensters), `referrerPolicy=\"no-referrer\"`, `loading=\"lazy\"`, leerem `allow`; mit Titel als schmale Kopfleiste samt Knopf „In neuem Tab öffnen“, ohne Titel derselbe Knopf als kleines Symbol in der Ecke; der Knopf ist ein `<a target=\"_blank\" rel=\"noopener noreferrer\">` mit `aria-label` und per Tastatur erreichbar."
|
||||
- "Bei Neuladen-Intervall > 0 wird der Rahmen im Takt neu eingehängt (`key` aus Adresse + Zähler, Zähler sichtbar als `data-reload-nonce`); der Timer wird beim Aushängen geräumt; im Bearbeitungsmodus läuft kein Timer."
|
||||
- "Im Bearbeitungsmodus liegt eine transparente Fläche über dem Rahmen, damit die ganze Kachel Ziehgriff bleibt (der Rahmen schluckt sonst die Mausereignisse); im Ansichtsmodus gibt es diese Fläche nicht."
|
||||
- "Der Server ruft die Adresse nie ab (kein Proxy, kein Fetch); die API lässt `xframe` als Widget-Typ zu und prüft die Konfiguration wie bisher nicht inhaltlich — die https-Prüfung läuft web-seitig zweifach (Formular UND `resolveXframeConfig` beim Rendern)."
|
||||
- "Alle Tore bleiben grün: `pnpm type-check` 4/4, `pnpm lint` 5/5, API-Tests mindestens 1175 (Stand nach pi9), Web-Tests mindestens 590 (heute 569), Umlaut-Wächter grün; `as unknown as` api 27 / web 6, `noNonNullAssertion` 56, `noExplicitAny` ≤ 13, `biome-ignore` 1, kein `!`, keine neue `any`."
|
||||
artifacts:
|
||||
- "apps/web/src/components/dashboard/widgets/xframe-config.ts — `XframeConfig`, `resolveXframeConfig`, `XFRAME_SANDBOX`, `XFRAME_RELOAD_OPTIONS`, `XFRAME_RELOAD_MIN/MAX`, `XFRAME_TITLE_MAX`, Re-Export `isHttpsUrl` aus picture-frame-config.ts; ohne React-Import"
|
||||
- "apps/web/src/components/dashboard/widgets/xframe-widget.tsx — Kachel mit Kopfleiste/Ecksymbol, `<iframe>`, Neulade-Timer, Bearbeitungs-Overlay, Leerzustand"
|
||||
- "apps/web/src/components/settings/widget-settings-panel.tsx — Zweig `widget.widgetType === 'xframe'` mit `XframeConfig`-Formular (Adresse, Titel, Intervall, Hinweis) und Titel in der Instanz-Kopfzeile"
|
||||
- "apps/web/src/components/dashboard/widget-registry.tsx — `WidgetType` + `'xframe'`, `WIDGET_CONSTRAINTS.xframe = { minW: 4, minH: 4, defaultW: 12, defaultH: 8 }`, `XframeIcon`, Registry-Eintrag, `wireXframeWidget`"
|
||||
- "apps/api/src/dashboard/dto/create-widget.dto.ts — `'xframe'` in `@IsIn([...])`"
|
||||
- "apps/web/src/messages/de.json + en.json — Namensraum `widgets.xframe`, identischer Schlüsselsatz"
|
||||
- "CHANGELOG.md — Stichpunkt unter „Unveröffentlicht → Neu“; docs/anleitung-anwender.md — Zeile in der Widget-Tabelle + Satz im Abschnitt Dashboard > Widgets"
|
||||
key_links:
|
||||
- "Katalog `WIDGET_TYPES` -> `addWidget('xframe')` -> `POST /dashboard/widgets` mit `widgetType: 'xframe'` -> `CreateWidgetDto @IsIn` (ohne den Eintrag 400) -> Kachel über `WIDGET_REGISTRY.xframe.component` (verdrahtet in `(portal)/page.tsx`)"
|
||||
- "Formular `commitUrl` -> `isHttpsUrl` -> `onChange({ url })` -> `PATCH /dashboard/widgets/:id/config` (flache Zusammenführung, bestehend) -> Kachel `resolveXframeConfig(config)` -> `url` nur wenn https, sonst `null` -> Leerzustand"
|
||||
- "`reloadSeconds > 0 && !isEditMode` -> `setInterval` -> `reloadNonce + 1` -> `key` wechselt -> `<iframe>` wird neu eingehängt; Aufräumfunktion `clearInterval`"
|
||||
- "`isEditMode` -> `<div className=\"absolute inset-0\" aria-hidden>` NACH dem `<iframe>` im DOM -> Mausereignisse treffen die Fläche, nicht den Rahmen -> `mousedown` steigt zur Karte `.widget-drag-handle` auf (dashboard-grid.tsx: `handle` ganze Karte, `cancel` nur input/textarea/select/button/a/[data-no-drag]/.widgetNoDrag)"
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260921-qd3: Dashboard-Widget „XFrame“
|
||||
|
||||
<objective>
|
||||
Ein neues Dashboard-Widget „XFrame“ (Widget-Typ `xframe`, Übersetzungs-Namensraum `widgets.xframe`): eine Webseite wird per https-Adresse als eingebetteter Rahmen (`<iframe>`) in der Kachel angezeigt. Einstellungen: Adresse, Titel, optionales Neuladen-Intervall. Stil und Bedienmuster wie die bestehenden Widgets; die eingebettete Seite darf die Tessera-Seite nicht verlassen (Sandbox ohne Freigabe der Navigation des obersten Fensters); der Server ruft die Adresse nie ab; das Formular weist dauerhaft darauf hin, dass manche Seiten das Einbetten verweigern, und die Kachel bietet immer „In neuem Tab öffnen“.
|
||||
|
||||
Purpose: zweites der zwei vom Nutzer gewünschten neuen Widgets (STATE.md „NAECHSTER AUFTRAG“, das erste — Bilderrahmen, quick-260921-pi9 — ist in HEAD c3b4597); die Produktfragen sind geklärt, die technischen Entscheidungen hat der Orchestrator getroffen (Kasten unten) — dieser Plan setzt sie um, ohne sie neu zu öffnen.
|
||||
Output: reiner Konfigurations-Resolver mit Tests (zuerst rot), Web-Widget + Formular im Einstellungs-Panel + Katalog/Registry/Seiten-Verdrahtung + Übersetzungen mit Tests, ein Wort im API-DTO, Changelog- und Handbuch-Eintrag; alle Tore grün.
|
||||
</objective>
|
||||
|
||||
## Gebundene Entscheidungen (Orchestrator, nicht neu verhandeln)
|
||||
|
||||
1. **Typ und Name.** Widget-Typ `xframe`, Anzeigename „XFrame“ in beiden Sprachen (Wahl des Nutzers), Beschreibung de „Webseite einbetten“ / en „Embed a web page“. Neunter Widget-Typ — `picture-frame` ist bereits da (verifiziert in HEAD: Registry, Katalog, Seite, DTO, Panel tragen es); `xframe` wird überall **nach** `picture-frame` ergänzt.
|
||||
2. **Konfiguration** im bestehenden Config-JSON: `url: string` (nur https), `title?: string` (höchstens 100 Zeichen), `reloadSeconds: number` (0 = nie; Auswahl 0/60/300/600/1800/3600; Voreinstellung 0). Resolver `resolveXframeConfig(raw)` mit Voreinstellungen und Klemmung, reines Modul ohne React-Import, zuerst rot getestet. **https-Prüfung:** `isHttpsUrl` ist in `picture-frame-config.ts` exportiert (HEAD, Zeile 45: echter `URL`-Parser, `protocol === 'https:'`) — wird importiert und re-exportiert, nicht dupliziert. **Befund am Code (wie pi9):** die API prüft Widget-Konfigurationen nicht inhaltlich (`UpdateWidgetConfigDto` nur `@IsObject()`, `updateWidgetConfig` führt flach zusammen) — deshalb läuft die https-Prüfung web-seitig zweifach: Formular (abweisen) UND Resolver (beim Rendern fällt jede Nicht-https-Adresse auf `null` → Leerzustand). Ein manipulierter Config-Wert schadet nur dem eigenen Dashboard und wird dort nicht einmal gerendert.
|
||||
3. **Rendering.** `<iframe src={url} title={title || url} sandbox={XFRAME_SANDBOX} allow="" referrerPolicy="no-referrer" loading="lazy" className="h-full w-full border-0 bg-background">` mit `XFRAME_SANDBOX = 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox'`. Keine Freigabe der Navigation des obersten Fensters (kein `allow-top-navigation`, kein `allow-top-navigation-by-user-activation`) — die eingebettete Seite kann den Tessera-Tab nicht umlenken. Dateikopf-Kommentar erklärt, warum `allow-same-origin` trotzdem nötig ist: ohne dieses Token läuft die Fremdseite in einem opaken Origin, ihre Cookies, ihr `localStorage` und ihre Same-Origin-Aufrufe brechen, die meisten Seiten sind dann unbenutzbar; der Origin ist der der Fremdseite, nicht Tesseras — die Sandbox hat hier allein die Aufgabe, Navigation des obersten Fensters und Modaldialoge (`alert`/`confirm`/`prompt` sind ohne `allow-modals` gesperrt) zu unterbinden. `allow` bleibt leer (keine Delegation von Kamera/Mikrofon/Standort). **Kein CSP-Umbau nötig:** in apps/web ist nirgends eine Content-Security-Policy, `frame-src` oder `X-Frame-Options` gesetzt (verifiziert per grep über next.config, middleware, api main.ts) — Einbetten fremder https-Seiten braucht keine Header-Änderung. Neuladen: bei `reloadSeconds > 0` bumpt ein `setInterval` einen `reloadNonce`-Zustand, der Teil des `key` des `<iframe>` ist → Neueinhängen; Aufräumfunktion räumt den Timer; im Bearbeitungsmodus kein Timer.
|
||||
4. **Bearbeitungsmodus.** Ein `<iframe>` schluckt Mausereignisse und bricht das Ziehen. Bei `isEditMode` liegt eine transparente Fläche `<div className="absolute inset-0" aria-hidden="true" data-testid="xframe-edit-overlay" />` **nach** dem `<iframe>` im DOM über dem Rahmen (Standard-`pointer-events`), damit `mousedown` zur Karte `.widget-drag-handle` aufsteigt (dashboard-grid.tsx: Griff = ganze Karte, `cancel`-Selektor `input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag` — eine `div` startet das Ziehen). Im Ansichtsmodus keine Fläche. Test: im Bearbeitungsmodus existiert die Fläche, im Ansichtsmodus nicht.
|
||||
5. **„In neuem Tab öffnen“.** `<a href={url} target="_blank" rel="noopener noreferrer" aria-label={t('xframe.openInNewTab')} title={…}>` mit Inline-SVG (externer Link), per Tastatur erreichbar (echter Link). Mit Titel sitzt er rechts in der Kopfleiste; ohne Titel als kleines Symbol in der rechten oberen Ecke (`absolute right-1 z-10`, `top-1` im Ansichtsmodus, `top-6` im Bearbeitungsmodus — die Griff-Kopfleiste der Karte ist 20 px hoch, `top-6` = 24 px liegt darunter). Der Link ist ein `a` und damit im `cancel`-Selektor: im Bearbeitungsmodus klickbar, startet kein Ziehen. Leerzustand (keine gültige Adresse): zentrierter grauer Text `xframe.empty`, kein Rahmen, kein Link. Verweigertes Einbetten (`X-Frame-Options`/`frame-ancestors` der Fremdseite) ist cross-origin nicht zuverlässig erkennbar — **nicht** versuchen; stattdessen zeigt das Formular dauerhaft den Hinweis `xframe.embedHint`, und die Kachel bietet den Link immer, sobald eine Adresse gesetzt ist.
|
||||
6. **Einstellungsformular** `XframeConfig({ config, onChange })` als weitere Formularfunktion **in** `widget-settings-panel.tsx` (Muster `ClockConfig`/`FavoritesConfig`; das Formular ist klein — drei Felder plus Hinweis —, deshalb kein eigenes Modul wie beim Bilderrahmen): Adresse als Entwurf mit Übernahme bei Blur/Enter (`http://` → `role="alert"` `xframe.urlInvalid`, kein `onChange`); Titel als Entwurf mit Übernahme bei Blur/Enter; Intervall als `<select>`; gleiche Klassenketten wie die Nachbarformulare (`mb-1 block text-sm text-foreground`, `h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground`). Zusätzlich zeigt die Instanz-Kopfzeile des Panels „— Titel“ auch für `xframe` (Bedingung, die heute `note`/`favorites` abdeckt, erweitern).
|
||||
7. **Constraints** `WIDGET_CONSTRAINTS.xframe = { minW: 4, minH: 4, defaultW: 12, defaultH: 8 }` (eine Webseite braucht Breite — halbe Rasterbreite). **Symbol** `XframeIcon`: Inline-SVG Browserfenster (`rect x=3 y=4 width=18 height=16 rx=2`, `line 3 9 → 21 9`, zwei kleine Kreise `cx=6.5`/`cx=9.5` bei `cy=6.5`, `r=0.5`), gleiche Attribute wie die Nachbarn (`aria-hidden`, `stroke="currentColor"`, `strokeWidth="2"`).
|
||||
8. **Texte** Deutsch mit „Sie“ plus Englisch; CHANGELOG-Stichpunkt unter „Unveröffentlicht → Neu“ (kein Fließtext), Zeile in der Widget-Tabelle von `docs/anleitung-anwender.md` (nach „Bilderrahmen“) und ein Satz im Abschnitt „Dashboard > Widgets“ der persönlichen Einstellungen. **Umlaut-Wächter** (`apps/web/src/messages/umlaut-guard.spec.ts`): jedes Token mit `ae/oe/ue/ss` in de.json muss in `UMLAUT_ALLOWLIST` stehen; vorab gegen die Allowlist geprüft — einziger neuer Verdachts-Token ist **`neuem`** („In neuem Tab öffnen“, korrektes Deutsch wie das bereits gelistete `neuen`) → in `umlaut-dictionary.ts` in die Allowlist aufnehmen (Muster pi9: `Webadresse`, `Bildausschnitt`). `Adresse` und `lassen` sind bereits gelistet.
|
||||
9. **API:** nur `'xframe'` in `CreateWidgetDto @IsIn` (sonst 400 beim Anlegen). Kein Prisma-Schema, keine Migration (Schema-Tor feuert nicht), kein neuer Endpunkt, **niemals** ein serverseitiger Abruf der Adresse (keine SSRF-Fläche). Keine neuen Pakete.
|
||||
|
||||
## Ausgangsmessung (21.09.2026, HEAD c3b4597 nach pi9)
|
||||
|
||||
| Größe | Wert |
|
||||
|---|---:|
|
||||
| API-Tests | 1175 |
|
||||
| Web-Tests | 569 |
|
||||
| `as unknown as` in apps/api/src | 27 |
|
||||
| `as unknown as` in apps/web/src | 6 |
|
||||
| `lint/style/noNonNullAssertion` in apps/api/src | 56 |
|
||||
| `lint/suspicious/noExplicitAny` in apps/api/src | 13 (jede begründet) |
|
||||
| `biome-ignore` in apps/api/src | 1 |
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@/home/vicolab/projects/tessera-ctl/CLAUDE.md
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widget-registry.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/picture-frame-config.ts
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/picture-frame-widget.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/picture-frame-widget.test.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/dashboard-grid.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/settings/widget-settings-panel.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/settings/widget-settings-panel.test.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/messages/umlaut-dictionary.ts
|
||||
@/home/vicolab/projects/tessera-ctl/.planning/quick/260921-pi9-dashboard-widget-bilderrahmen-bilder-hoc/260921-pi9-SUMMARY.md
|
||||
</context>
|
||||
|
||||
## Hinweise für den Executor
|
||||
|
||||
- **Ausgangspunkt ist HEAD (c3b4597).** pi9 ist vollständig committet; `git status` zeigt nur `.planning/`. Alle Stellen, an denen pi9 `picture-frame` eingetragen hat (`git diff 573d070 HEAD --stat`), bekommen `xframe` **direkt dahinter** in derselben Form: `WidgetType`, `WIDGET_CONSTRAINTS`, `WIDGET_REGISTRY`, `wire…Widget`, `WIDGET_TYPES` im Katalog, Import + `wire…` in `(portal)/page.tsx`, `vi.mock` in `page.test.tsx`, Übersetzungsattrappe im Katalog-Test, Typliste + Erwartungstabelle + Zähler im Registry-Test (32 → 36, „neun Typen“), `@IsIn` im DTO, Zweig im Panel.
|
||||
- **Tore vor jedem Commit:** `pnpm type-check`, `pnpm lint`, die betroffenen Vitest-Dateien; am Ende (Aufgabe 2) `pnpm --filter @tessera/api test` und `pnpm --filter @tessera/web test` vollständig.
|
||||
- **Rot-Nachweis:** `xframe-config.test.ts` und die Widget-Tests werden VOR der Umsetzung geschrieben und einmal rot gefahren (Ausgabe kurz im SUMMARY festhalten).
|
||||
- **Kein `any`**, keine neue `as unknown as`, kein `!`. `sandbox`, `allow`, `referrerPolicy`, `loading` sind reguläre React-Props des `<iframe>` — keine Zusicherung nötig.
|
||||
- **jsdom lädt keine Unterressourcen** — ein `<iframe src="https://…">` im Test erzeugt keinen Netzabruf; Attribute per `getAttribute` prüfen, Neueinhängen über Objektidentität (`before !== after`) und `data-reload-nonce`.
|
||||
- **Commits:** je Aufgabe genau ein Commit, Stil `git log --oneline -15`, Scope `quick-260921-qd3`, deutsche Betreffzeile. Akte/STATE-Commit macht der Orchestrator.
|
||||
- **Nie** auf den Testserver deployen; Browser-Rundgang macht der Orchestrator lokal (Prüfliste im SUMMARY).
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Aufgabe 1: Resolver (rot → grün), Widget, Formular im Panel, Verdrahtung, Übersetzungen, API-DTO — Ende-zu-Ende „Adresse eintragen → Seite erscheint in der Kachel“</name>
|
||||
<files>apps/web/src/components/dashboard/widgets/xframe-config.ts, apps/web/src/components/dashboard/widgets/xframe-config.test.ts, apps/web/src/components/dashboard/widgets/xframe-widget.tsx, apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx, apps/web/src/components/settings/widget-settings-panel.tsx, apps/web/src/components/settings/widget-settings-panel.test.tsx, 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.tsx, apps/web/src/components/dashboard/widget-catalog-modal.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, apps/api/src/dashboard/dto/create-widget.dto.ts</files>
|
||||
<behavior>
|
||||
- `resolveXframeConfig({})` → `{ url: null, title: '', reloadSeconds: 0 }`; `url: 'https://intern.example/board'` → unverändert (getrimmt); `url: ' https://a.de/x '` → `'https://a.de/x'`; `url: 'http://a.de'`, `'javascript:alert(1)'`, `'data:text/html,x'`, `'ftp://a.de'`, `'kein-url'`, `42`, `''` → `url: null`; `title: ' Board '` → `'Board'`; Titel mit 150 Zeichen → auf 100 gekürzt; `title: 7` → `''`; `reloadSeconds`: fehlt/`'abc'`/`-5`/`30` → `0` (unter dem kleinsten Intervall heißt „nie“ — schützt die Fremdseite vor Sekundentakt), `60` → `60`, `90` → `60` (größte Auswahlstufe ≤ n), `599` → `300`, `600` → `600`, `99999` → `3600`.
|
||||
- `XFRAME_SANDBOX` enthält `allow-scripts`, `allow-same-origin`, `allow-forms`, `allow-popups`, `allow-popups-to-escape-sandbox` und kein Token, das `top-navigation` oder `modals` enthält (Regex-Prüfung auf den Konstantenwert). `XFRAME_RELOAD_OPTIONS` gleich `[0, 60, 300, 600, 1800, 3600]`. `isHttpsUrl` ist aus `xframe-config` importierbar und liefert für `'HTTPS://A.DE'` true, für `'http://a.de'` false.
|
||||
- Widget, `{}` → Text `xframe.empty`, kein `<iframe>`, kein Link. `{ url: 'http://a.de' }` → ebenfalls Leerzustand (Resolver weist ab).
|
||||
- Widget, `{ url: U }` → genau ein `<iframe>` (`data-testid="xframe-frame"`) mit `src === U`, `title === U`, `sandbox === XFRAME_SANDBOX`, `sandbox` enthält kein `top-navigation`, `referrerpolicy === 'no-referrer'`, `loading === 'lazy'`, `allow === ''`, `data-reload-nonce === '0'`; kein `<h2>`; ein Link `role="link"` mit Name `xframe.openInNewTab`, `href === U`, `target === '_blank'`, `rel` enthält `noopener` und `noreferrer`, Klasse enthält `absolute` (Ecksymbol).
|
||||
- Widget, `{ url: U, title: 'Board' }` → `<h2>` mit „Board“, `<iframe title="Board">`, der Link steht in der Kopfleiste (Vorfahre mit Klasse `border-b`), Klasse ohne `absolute`.
|
||||
- Widget, `{ url: U, title: '<b>x</b>' }` → der Text `<b>x</b>` erscheint wörtlich (`getByText`), `container.querySelector('b')` ist `null` (React-Escaping).
|
||||
- Widget, `{ url: U, reloadSeconds: 60 }` mit Fake-Timern → `vi.getTimerCount()` 1; nach `advanceTimersByTime(60_000)` ist das `<iframe>`-Element ein **anderes** Objekt als vorher und `data-reload-nonce === '1'`, nach weiteren 60 000 ms `'2'`; `unmount()` → `vi.getTimerCount()` 0. `reloadSeconds: 0` → `getTimerCount()` 0. `isEditMode: true` + `reloadSeconds: 60` → `getTimerCount()` 0 (kein Neuladen beim Bearbeiten).
|
||||
- Widget, `isEditMode: true` → `data-testid="xframe-edit-overlay"` existiert, hat `aria-hidden="true"`, Klassen `absolute` und `inset-0`, steht im DOM **nach** dem `<iframe>` (`compareDocumentPosition`); `isEditMode: false` → kein Overlay.
|
||||
- Panel (`WidgetSettingsPanel` mit `{ id: 'x1', widgetType: 'xframe', config: { url: U, title: 'Board', reloadSeconds: 300 } }`, aufgeklappt): Feld `xframe-url` hat Wert U, Feld `xframe-title` Wert „Board“, Auswahl `xframe-reload` Wert `'300'`, der Hinweis `widgets.xframe.embedHint` (Text aus de.json) ist sichtbar, Instanz-Kopfzeile enthält „— Board“. Adresse auf `https://b.de/` ändern + Blur → `updateWidgetConfig('x1', { url: 'https://b.de/' })` genau einmal und `onWidgetUpdate` gleich. Adresse auf `http://b.de` + Enter → `role="alert"` mit `widgets.xframe.urlInvalid`, `aria-invalid` am Feld, **kein** Aufruf. Feld leeren + Blur → `{ url: '' }` (Adresse entfernen ist erlaubt → Leerzustand). Titel ändern + Blur → `{ title: 'Neu' }`; Auswahl `'600'` → `{ reloadSeconds: 600 }`. Gleiche Adresse erneut übernehmen (Blur ohne Änderung) → kein Aufruf.
|
||||
- Registry: `WIDGET_CONSTRAINTS.xframe` gleich `{ minW: 4, minH: 4, defaultW: 12, defaultH: 8 }`, Gesamtzähler 36; Katalog zeigt einen Knopf mit Namen /XFrame/; `CreateWidgetDto` lässt `'xframe'` zu (Auszug aus der `@IsIn`-Liste im DTO-Kommentar: „nine supported types“).
|
||||
</behavior>
|
||||
<action>
|
||||
**Reihenfolge: Resolver rot → grün, dann Widget (Tests zuerst), dann Formular im Panel, zuletzt Verdrahtung, Übersetzungen, DTO.**
|
||||
|
||||
1. **`xframe-config.ts`** (Muster `picture-frame-config.ts`, ohne React-Import). Dateikopf-Kommentar (Deutsch, wie die Nachbarn): Zweck; warum die https-Prüfung ALLEIN hier und im Formular liegt (API prüft Config nicht inhaltlich, Entscheidung 2, T-QD3-03); warum `allow-same-origin` in der Sandbox bleibt und welche Tokens bewusst fehlen (Entscheidung 3, T-QD3-01). Exporte: `XFRAME_RELOAD_OPTIONS = [0, 60, 300, 600, 1800, 3600] as const`-artig als `number[]`, `XFRAME_RELOAD_MIN = 60`, `XFRAME_RELOAD_MAX = 3600`, `XFRAME_TITLE_MAX = 100`, `XFRAME_SANDBOX` (Entscheidung 3, exakter String), `interface XframeConfig { url: string | null; title: string; reloadSeconds: number }`, `resolveXframeConfig(config: Record<string, unknown>): XframeConfig`, und `export { isHttpsUrl } from './picture-frame-config'` (Re-Export, damit Formular und Tests eine Quelle haben; Kommentar: bewusst geteilt mit dem Bilderrahmen, eine Regel für „https-Adresse“ im ganzen Dashboard). Regeln: `url` nur wenn String, getrimmt, `isHttpsUrl` true — sonst `null`; `title` nur wenn String, getrimmt, `slice(0, XFRAME_TITLE_MAX)` — sonst `''`; `reloadSeconds`: nicht endliche Zahl oder `< XFRAME_RELOAD_MIN` → 0, `≥ XFRAME_RELOAD_MAX` → 3600, sonst größter Wert aus `XFRAME_RELOAD_OPTIONS`, der `≤ n` ist (damit das `<select>` im Formular immer eine passende Option zeigt). **`xframe-config.test.ts`** mit allen Fällen aus `<behavior>` (mindestens 12 `it`), vor der Umsetzung rot.
|
||||
|
||||
2. **`xframe-widget.tsx`** (`'use client'`, `export function XframeWidget({ config, isEditMode }: WidgetProps)`; `instanceId` wird nicht gebraucht — Props-Muster wie `PictureFrameWidget`). Dateikopf-Kommentar: Sandbox-Begründung (verweist auf `XFRAME_SANDBOX`), warum der Server nie abruft (T-QD3-04), warum im Bearbeitungsmodus eine Fläche über dem Rahmen liegt (Entscheidung 4, T-QD3-07). Aufbau: `const { url, title, reloadSeconds } = useMemo(() => resolveXframeConfig(config), [config])`; `const [reloadNonce, setReloadNonce] = useState(0)`; `useEffect` mit Abhängigkeiten `[url, reloadSeconds, isEditMode]`: wenn `url === null || reloadSeconds === 0 || isEditMode` → nichts; sonst `const timer = setInterval(() => setReloadNonce((n) => n + 1), reloadSeconds * 1000)` und Aufräumfunktion `clearInterval(timer)`. Leerzustand (`url === null`): `<div className="flex h-full items-center justify-center px-2 text-center text-sm text-muted-foreground">{t('xframe.empty')}</div>` und sonst nichts. Andernfalls Wurzel `<div className="relative flex h-full w-full flex-col overflow-hidden">`: (a) bei Titel eine Kopfleiste `<div className="flex items-center gap-2 border-b border-border px-1.5 py-1.5">` mit `<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{title}</h2>` (Muster Favoriten-Kopfzeile) und dem Link (Klasse `shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground`); (b) Rumpf `<div className="relative min-h-0 flex-1">` mit dem `<iframe>` (Entscheidung 3; `key={`${url}#${reloadNonce}`}`, `data-testid="xframe-frame"`, `data-reload-nonce={reloadNonce}`), danach bei `isEditMode` die Fläche aus Entscheidung 4, danach — nur ohne Titel — der Link als Ecksymbol (`absolute right-1 z-10 rounded bg-card/80 p-1 text-muted-foreground shadow-sm hover:text-foreground` plus `top-1`/`top-6` je Modus). Der Link (eine kleine Funktion `NewTabLink({ url, className })` in derselben Datei): `<a href={url} target="_blank" rel="noopener noreferrer" aria-label={t('xframe.openInNewTab')} title={t('xframe.openInNewTab')}>` mit Inline-SVG 16×16 (`path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"`, `polyline points="15 3 21 3 21 9"`, `line x1="10" y1="14" x2="21" y2="3"`, `aria-hidden`). Bekannt und akzeptiert (wie die Titelzeile der Favoriten): im Bearbeitungsmodus überdeckt die 20-px-Griffleiste der Karte den oberen Teil der Kopfleiste — Bearbeiten ist Anordnen, nicht Lesen. **`xframe-widget.test.tsx`** (Muster `picture-frame-widget.test.tsx`: `vi.mock('next-intl')` mit Durchreiche `t(key) => key`, Fake-Timer in `beforeEach`, `cleanup` + `useRealTimers` in `afterEach`), mindestens 10 `it` gemäß `<behavior>`, vor der Umsetzung rot.
|
||||
|
||||
3. **Formular im Panel** (`widget-settings-panel.tsx`): Import `XFRAME_RELOAD_OPTIONS, XFRAME_TITLE_MAX, isHttpsUrl, resolveXframeConfig` aus `@/components/dashboard/widgets/xframe-config`; Zweig `{widget.widgetType === 'xframe' && (<XframeConfig config={widget.config} onChange={(cfg) => handleConfigChange(widget.id, cfg)} />)}` nach dem Bilderrahmen-Zweig; die Kopfzeilen-Bedingung `(widget.widgetType === 'note' || widget.widgetType === 'favorites')` um `|| widget.widgetType === 'xframe'` erweitern. Funktion `XframeConfig` am Dateiende (Kommentar `// XFrame (quick-260921-qd3, Muster ClockConfig/FavoritesConfig)`): `const { url, title, reloadSeconds } = resolveXframeConfig(config)`; `urlDraft` (`useState(url ?? '')`), `urlError` (boolean), `titleDraft` (`useState(title)`). `commitUrl`: `const raw = urlDraft.trim()`; leer → `setUrlError(false)`, wenn `url !== null` → `onChange({ url: '' })`; nicht `isHttpsUrl(raw)` → `setUrlError(true)`, kein `onChange`; sonst `setUrlError(false)`, wenn `raw !== url` → `onChange({ url: raw })`. `commitTitle`: `const next = titleDraft.trim().slice(0, XFRAME_TITLE_MAX)`; wenn `next !== title` → `onChange({ title: next })`. Felder: Adresse `<input id="xframe-url" type="url" inputMode="url" placeholder={t('xframe.urlPlaceholder')} aria-invalid={urlError || undefined} aria-describedby="xframe-url-hint">` mit `onBlur={commitUrl}` und Enter-Handling wie `commitFontSize` in `ClockConfig`; darunter `<p id="xframe-url-hint" className="mt-1 text-xs text-muted-foreground">{t('xframe.embedHint')}</p>` (dauerhaft, Entscheidung 5) und bei Fehler `<p role="alert" className="mt-1 text-xs text-destructive">{t('xframe.urlInvalid')}</p>`; Titel `<input id="xframe-title" type="text" maxLength={XFRAME_TITLE_MAX} placeholder={t('xframe.titlePlaceholder')}>` mit Blur/Enter-Übernahme; Intervall `<select id="xframe-reload" value={String(reloadSeconds)} onChange={(e) => onChange({ reloadSeconds: Number(e.target.value) })}>` über `XFRAME_RELOAD_OPTIONS` mit Beschriftung `0 → t('xframe.reloadOff')`, `60 → t('xframe.reloadMinute')`, `3600 → t('xframe.reloadHour')`, sonst `t('xframe.reloadMinutes', { n: s / 60 })`. Labels `mb-1 block text-sm text-foreground`, Felder die Klassenkette aus Entscheidung 6, Abstände `space-y-4`. **`widget-settings-panel.test.tsx`**: neuer `describe('WidgetSettingsPanel — XFrame (quick-260921-qd3)')` nach dem vorhandenen Muster (Texte aus der echten de.json, `updateWidgetConfig`-Attrappe, Instanz aufklappen per Klick auf die Kopfzeile), mindestens 6 `it` gemäß `<behavior>`.
|
||||
|
||||
4. **Verdrahtung** (alles „nach `picture-frame`“): `widget-registry.tsx` — Kopfkommentar um `xframe: Webseite als Rahmen (quick-260921-qd3)` ergänzen, `WidgetType | 'xframe'`, `WIDGET_CONSTRAINTS.xframe` mit Kommentar (`// quick-260921-qd3: eine Webseite braucht Breite — 12x8 = halbe Rasterbreite; 4x4 kleinste Kachel, in der ein Rahmen noch Sinn hat`), `XframeIcon` (Entscheidung 7), Registry-Eintrag `xframe: { type: 'xframe', nameKey: 'xframe.name', descriptionKey: 'xframe.description', icon: XframeIcon, ...WIDGET_CONSTRAINTS.xframe, component: PlaceholderWidget }`, `wireXframeWidget` nach dem Muster `wirePictureFrameWidget`. `widget-registry.test.tsx` — `'xframe'` in `ALL_WIDGET_TYPES`, Zeile in der `toEqual`-Tabelle, `counted` 32 → 36, Testtitel „…; quick-260921-qd3: XFrame dazu, neun Typen“. `widget-catalog-modal.tsx` — `'xframe'` als letzter Eintrag in `WIDGET_TYPES`; `widget-catalog-modal.test.tsx` — Attrappe um `'xframe.name': 'XFrame'`, `'xframe.description': 'Webseite einbetten'`. `(portal)/page.tsx` — `wireXframeWidget` in die Import-Liste, `import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget'`, `wireXframeWidget(XframeWidget)` nach `wirePictureFrameWidget`; `page.test.tsx` — `vi.mock('@/components/dashboard/widgets/xframe-widget', () => ({ XframeWidget: () => null }))`.
|
||||
|
||||
5. **Übersetzungen** `de.json`/`en.json`, Namensraum `widgets.xframe` direkt nach `pictureFrame`, exakt diese Schlüssel in beiden Dateien: `name` („XFrame“/„XFrame“), `description` („Webseite einbetten“/„Embed a web page“), `empty` („Keine Adresse eingestellt — über die Einstellungen festlegen“/„No address set — configure it in the settings“), `openInNewTab` („In neuem Tab öffnen“/„Open in a new tab“), `urlLabel` („Adresse (https)“/„Address (https)“), `urlPlaceholder` („https://…“ beide), `urlInvalid` („Bitte geben Sie eine vollständige https-Adresse ein.“/„Please enter a complete https address.“), `titleLabel` („Titel“/„Title“), `titlePlaceholder` („Titel (optional)“/„Title (optional)“), `reloadLabel` („Automatisch neu laden“/„Reload automatically“), `reloadOff` („Nie“/„Never“), `reloadMinute` („Jede Minute“/„Every minute“), `reloadMinutes` („Alle {n} Minuten“/„Every {n} minutes“), `reloadHour` („Jede Stunde“/„Every hour“), `embedHint` („Manche Webseiten lassen sich nicht einbetten — dann bleibt der Rahmen leer. Über „In neuem Tab öffnen“ erreichen Sie die Seite trotzdem.“/„Some web pages refuse to be embedded — the frame then stays empty. “Open in a new tab” still takes you to the page.“). **`umlaut-dictionary.ts`**: `'neuem'` in `UMLAUT_ALLOWLIST` aufnehmen (Kommentar `// XFrame-Widget (quick-260921-qd3): „In neuem Tab öffnen“, korrektes Deutsch wie neuen`). Danach `pnpm --filter @tessera/web exec vitest run src/messages` — der Wächter meldet jedes weitere vergessene Token mit Pfad; dann ebenfalls in die Allowlist (nur korrekte deutsche Wörter, keine Umschreibungen).
|
||||
|
||||
6. **API-DTO** `create-widget.dto.ts`: `'xframe'` als letzter Eintrag in `@IsIn([...])`, Kommentar „one of the nine supported types ('picture-frame' seit quick-260921-pi9, 'xframe' seit quick-260921-qd3)“. Sonst nichts an der API.
|
||||
|
||||
Commit: `feat(quick-260921-qd3): XFrame-Widget - Webseite als Rahmen im Dashboard, Sandbox ohne Top-Navigation, Neuladen-Intervall` (Wortlaut frei, Stil beachten).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard src/components/settings src/messages "src/app/(portal)/page.test.tsx" && pnpm --filter @tessera/web exec tsc --noEmit && pnpm --filter @tessera/web lint && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/api lint && test "$(grep -rn 'as unknown as' apps/web/src --include=*.ts --include=*.tsx | wc -l)" -eq 6 && grep -q "'xframe'" apps/api/src/dashboard/dto/create-widget.dto.ts && node -e "const d=require('./apps/web/src/messages/de.json').widgets.xframe,e=require('./apps/web/src/messages/en.json').widgets.xframe;if(!d||!e){console.error('xframe-Namensraum fehlt');process.exit(1)}const m=Object.keys(d).filter(k=>!(k in e)).concat(Object.keys(e).filter(k=>!(k in d)));if(m.length){console.error('Schluessel ungleich:',m);process.exit(1)}"</automated>
|
||||
</verify>
|
||||
<done>`xframe-config.test.ts` ≥ 12, `xframe-widget.test.tsx` ≥ 10, neuer Panel-`describe` ≥ 6 Fälle — alle grün, Resolver- und Widget-Tests nachweislich zuerst rot (Rot-Lauf im SUMMARY). Registry-/Katalog-/Seiten-Tests grün mit dem neunten Typ (Zähler 36). Umlaut-Wächter grün (`neuem` gelistet). Beide Sprachdateien tragen denselben Schlüsselsatz unter `widgets.xframe`. Kette nachgewiesen (Tests): https-Adresse im Formular → `updateWidgetConfig(id, { url })`; http → Meldung, kein Aufruf; Kachel rendert `<iframe>` mit exakter Sandbox, `no-referrer`, `lazy`, leerem `allow`; Neuladen hängt neu ein und räumt den Timer; im Bearbeitungsmodus Overlay und kein Timer; Titel wird escaped. `as unknown as` web 6 / api 27, keine `any`, kein `!`. `pnpm --filter @tessera/web exec tsc --noEmit` und beide Linter ohne Befund.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Aufgabe 2: Changelog, Anwenderhandbuch, Voll-Tore, Zähler, Prüfliste für den Browser-Rundgang</name>
|
||||
<files>CHANGELOG.md, docs/anleitung-anwender.md</files>
|
||||
<action>
|
||||
1. **`CHANGELOG.md`** unter „Unveröffentlicht → Neu“ als ERSTER Stichpunkt (vor dem Bilderrahmen; kein Fließtext, Tonlage der Nachbarzeilen): „Dashboard-Widget „XFrame“: eine Webseite per https-Adresse als Rahmen in der Kachel anzeigen; optionaler Titel und automatisches Neuladen (1 Minute bis 1 Stunde); die eingebettete Seite kann Tessera nicht verlassen; „In neuem Tab öffnen“ führt jederzeit zur Seite selbst — manche Webseiten lassen sich nicht einbetten, der Rahmen bleibt dann leer; Einstellungen unter Einstellungen → Dashboard“.
|
||||
2. **`docs/anleitung-anwender.md`**: (a) in der Widget-Tabelle (Kopf „| Widget | Zweck |“, Zeile ~73) nach der Zeile „Bilderrahmen“ eine Zeile „| XFrame | Zeigt eine Webseite als Rahmen in der Kachel. Die https-Adresse, einen optionalen Titel und ob die Seite automatisch neu geladen wird (nie, 1 Minute bis 1 Stunde), stellen Sie unter Einstellungen > Dashboard ein. Die eingebettete Seite kann Tessera nicht verlassen; über „In neuem Tab öffnen“ erreichen Sie die Seite jederzeit direkt. Manche Webseiten erlauben das Einbetten nicht — der Rahmen bleibt dann leer, der Knopf funktioniert trotzdem |“; (b) im Satz „Für Uhr, Suchleiste, Kalender, Notizen, Favoriten und Bilderrahmen gibt es zusätzliche Einstellungen (…)“ (Zeile ~84) „und XFrame“ sowie in der Klammer „Adresse, Titel und Neuladen des XFrame“ ergänzen; (c) im Absatz „**Dashboard > Widgets:**“ (Zeile ~154) am Ende einen Satz anfügen: „Beim XFrame tragen Sie die https-Adresse der Webseite ein (http-Adressen werden abgewiesen), optional einen Titel für die Kopfleiste und wählen, ob die Seite automatisch neu geladen wird; ein dauerhafter Hinweis erinnert daran, dass manche Webseiten das Einbetten verweigern.“ Siezen, Schreibweise der Nachbarzeilen (Anführungszeichen „…“, „Einstellungen > Dashboard“).
|
||||
3. **Volle Tore**: `pnpm type-check` (4/4), `pnpm lint` (5/5), `pnpm --filter @tessera/api test`, `pnpm --filter @tessera/web test`; Zähler messen wie in pi9-SUMMARY (Tabelle „Endmessung“: `as unknown as` api/web per grep, `noNonNullAssertion`/`noExplicitAny` per `biome lint` in apps/api/src, `biome-ignore` per grep) und ins SUMMARY schreiben.
|
||||
4. **Prüfliste** im SUMMARY für den Orchestrator (Browser, Playwright-MCP, lokal — NICHT Testserver), Punkt für Punkt abhakbar: (a) Dashboard → Bearbeiten → „Widget hinzufügen“ zeigt „XFrame“ mit Fenster-Symbol und Beschreibung „Webseite einbetten“; platzierte Kachel ist 12×8 und zeigt den Leerhinweis; (b) Einstellungen → Dashboard → „XFrame #1“ aufklappen: Hinweistext „Manche Webseiten lassen sich nicht einbetten …“ steht dauerhaft da; https-Adresse einer einbettbaren Seite (z. B. eine interne Tessera-Seite oder `https://example.com`) eintragen, Feld verlassen → Kachel zeigt die Seite; (c) `http://…` eintragen → rote Meldung, Netzwerk-Tab zeigt keinen PATCH; (d) Titel „Board“ → Kopfleiste mit Titel und Symbol „In neuem Tab öffnen“ rechts; Titel leeren → Symbol wandert in die rechte obere Ecke; (e) Klick auf das Symbol öffnet die Adresse in einem neuen Tab, der Tessera-Tab bleibt; Tab-Taste erreicht das Symbol; (f) Intervall „Jede Minute“ → nach 60 s wird der Rahmen neu geladen (Netzwerk-Tab: zweiter Dokumentabruf; im DOM springt `data-reload-nonce` auf 1); (g) Bearbeitungsmodus: Kachel lässt sich an einer Stelle **über dem Rahmen** anfassen und ziehen, Größe ändern funktioniert, im DOM liegt `xframe-edit-overlay`; Ansichtsmodus: Overlay weg, Seite bedienbar (Scrollen/Klicken im Rahmen); (h) Adresse einer Seite, die Einbetten verweigert (z. B. `https://www.google.com`) → Rahmen bleibt leer (Browser-Konsole meldet `X-Frame-Options`/`frame-ancestors`), „In neuem Tab öffnen“ funktioniert; (i) API-Log während (b)/(f): kein Abruf der Fremdadresse durch die API — nur der Browser lädt sie; (j) `pnpm --filter @tessera/api test` und `-web test` grün, Zähler wie Ausgangsmessung.
|
||||
Commit: `docs(quick-260921-qd3): Changelog und Anwenderhandbuch - XFrame-Widget` (nur CHANGELOG.md + docs/anleitung-anwender.md; Akte/STATE macht der Orchestrator).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q 'XFrame' CHANGELOG.md && test "$(grep -c '| XFrame |' docs/anleitung-anwender.md)" -eq 1 && pnpm type-check && pnpm lint && pnpm --filter @tessera/api test && pnpm --filter @tessera/web test</automated>
|
||||
</verify>
|
||||
<done>Changelog-Stichpunkt steht als erster unter „Unveröffentlicht → Neu“; Handbuch trägt die Tabellenzeile, den erweiterten Satz und den Absatz-Zusatz; `pnpm type-check` 4/4, `pnpm lint` 5/5 ohne Befund der Stufe `error`; API ≥ 1175 Tests, Web ≥ 590 Tests, alle grün; Zähler unverändert (`as unknown as` 27/6, `noNonNullAssertion` 56, `noExplicitAny` ≤ 13, `biome-ignore` 1); die zehnpunktige Prüfliste steht im SUMMARY; genau zwei Code/Doku-Commits mit Scope `quick-260921-qd3` (`git log --oneline -2`).</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<assumption_delta_decision>
|
||||
Assumption-Delta-Detektor: Quick-Aufgabe ohne ROADMAP-Abschnitt → Abfrage liefert `phase_unresolved` (übersprungen, kein Verdikt). Gedanklich ausgeführt über die Aufgabenbeschreibung: **feuert nicht** — genau EINE Adresse, EIN Titel, EIN Intervall; keine zweite Variante, kein Pflichtfeld wird optional, kein abgeleiteter Wert wird gewählt. `url` bleibt einfacher String im Config-JSON, kein Vereinigungstyp nötig. Entscheidung: `no-change`.
|
||||
|
||||
API-Coverage-Detektor (`api-coverage.cjs --json` über die Aufgabenbeschreibung): `{"detected":false,"signals":[]}` — kein externer Dienst, keine SDK-Integration; der Browser rendert eine Fremdseite in einem Rahmen, die API kennt nur das Wort `xframe` in einer Zulassungsliste. Keine COVERAGE.md nötig.
|
||||
|
||||
Schema-Tor: kein Prisma-, Migrations- oder Schema-Pfad im Umfang → feuert nicht.
|
||||
</assumption_delta_decision>
|
||||
|
||||
<threat_model>
|
||||
ASVS-Stufe 1, Blockschwelle `high` (jede `high`-Bedrohung MUSS mitigiert sein).
|
||||
|
||||
## Vertrauensgrenzen
|
||||
|
||||
| Grenze | Beschreibung |
|
||||
|---|---|
|
||||
| Config-JSON → Browser | `url`/`title`/`reloadSeconds` stammen aus dem vom Benutzer selbst beschreibbaren Widget-Config (API prüft nicht inhaltlich) und landen in `<iframe src>`, `title`, Text und Timer |
|
||||
| Tessera-Seite ↔ eingebettete Fremdseite | Die Fremdseite läuft im eigenen Origin innerhalb der Tessera-Kachel; sie sieht die Anfrage (und ohne Gegenmaßnahme den Referrer) und könnte versuchen, das oberste Fenster zu navigieren oder Berechtigungen zu nutzen |
|
||||
| Browser → Fremdhost | Nur der Browser des Benutzers ruft die Adresse ab; der Server nie |
|
||||
| Widget → Grid (Ziehen) | Ein `<iframe>` schluckt Mausereignisse; die Bedienbarkeit des Bearbeitungsmodus hängt am Overlay |
|
||||
|
||||
## STRIDE-Register
|
||||
|
||||
| ID | Kategorie | Komponente | Schwere | Disposition | Maßnahme |
|
||||
|---|---|---|---|---|---|
|
||||
| T-QD3-01 | Spoofing / Elevation (Navigation des obersten Fensters, Phishing) | `<iframe sandbox>` in `xframe-widget.tsx` | high | mitigate | `sandbox={XFRAME_SANDBOX}` OHNE `allow-top-navigation` und OHNE `allow-top-navigation-by-user-activation` — die Fremdseite kann `window.top` nicht umlenken, der Tessera-Tab bleibt Tessera. Ohne `allow-modals`: keine `alert`/`confirm`/`prompt`-Dialoge, die als Tessera-Dialoge missdeutet werden könnten. `allow-popups-to-escape-sandbox` betrifft nur NEUE Fenster (Popups verlassen die Sandbox, damit z. B. Anmelde-Popups der Fremdseite funktionieren), nie den Tessera-Tab. Getestet: Konstante und gerendertes Attribut enthalten kein `top-navigation`/`modals`. |
|
||||
| T-QD3-02 | Tampering (Clickjacking, Richtung) | Tessera als Einbettender | low | accept | Hier bettet Tessera FREMDE Seiten ein — die klassische Clickjacking-Richtung (jemand bettet Tessera ein) ist unverändert und außerhalb des Umfangs: Tesseras eigene `frame-ancestors`/`X-Frame-Options`-Lage wird durch dieses Widget nicht berührt (in apps/web ist heute kein solcher Header gesetzt; das war vor dem Widget so und bleibt so). Umgekehrt kann die eingebettete Seite Tessera-Elemente nicht überlagern: sie lebt in ihrer eigenen Kachel, Tessera legt nichts Interaktives über sie außer dem Bearbeitungs-Overlay. |
|
||||
| T-QD3-03 | Tampering (gefährliche Schemata: `javascript:`, `data:`, `http:` Mixed Content) | `url` im Config-JSON | high | mitigate | `isHttpsUrl` (echter `URL`-Parser, `protocol === 'https:'`) im Formular (Abweisung mit Meldung, kein Speichern) UND in `resolveXframeConfig` beim Rendern (`url` wird `null` → Leerzustand, `<iframe>` wird nicht einmal gerendert). Serverseitig nicht prüfbar (API kennt keine Config-Inhalte) — Risiko bleibt auf das eigene Dashboard beschränkt. Getestet: `http:`, `javascript:`, `data:`, `ftp:`, Unparsbares → `null`. |
|
||||
| T-QD3-04 | Server-Side Request Forgery | API | high | mitigate | Die API ruft NIE die Adresse ab: kein Proxy-Endpunkt, kein Fetch, `url` ist für die API ein undurchsichtiger JSON-Wert; einzige API-Änderung ist das Wort `'xframe'` in `@IsIn`. Der Browser des Benutzers lädt die Seite — interne Hosts sieht damit nur, wer sie ohnehin erreicht. Nachweis im Rundgang (i). |
|
||||
| T-QD3-05 | Information Disclosure (Referrer) | `<iframe>` auf Fremdhost, Link „In neuem Tab öffnen“ | low | mitigate | `referrerPolicy="no-referrer"` am `<iframe>`, `rel="noopener noreferrer"` am Link — der Fremdhost erfährt die Tessera-Adresse nicht, das neue Fenster hat keinen `window.opener`. |
|
||||
| T-QD3-06 | Elevation of Privilege (Berechtigungs-Delegation) | `allow`-Attribut | medium | mitigate | `allow=""` — keine Delegation von Kamera, Mikrofon, Standort, Zahlung o. ä. an die Fremdseite; getestet (`getAttribute('allow') === ''`). |
|
||||
| T-QD3-07 | Denial of Service (Bedienbarkeit: Ziehen/Größe im Bearbeitungsmodus) | Overlay in `xframe-widget.tsx` | medium | mitigate | Transparente Fläche über dem Rahmen nur bei `isEditMode` (getestet: vorhanden/nicht vorhanden, Position im DOM nach dem `<iframe>`), damit `mousedown` die Karte erreicht; im Ansichtsmodus bleibt die Seite bedienbar. Rundgang (g). |
|
||||
| T-QD3-08 | Denial of Service (Neulade-Takt gegen Fremdhost / eigenen Browser) | Timer | low | mitigate | Resolver klemmt: alles unter 60 s wird „nie“, Obergrenze 3600 s; kein Timer im Bearbeitungsmodus; Aufräumfunktion beim Aushängen (getestet über `vi.getTimerCount()`). |
|
||||
| T-QD3-09 | Tampering (XSS über Titel) | `title` in Kopfleiste und `<iframe title>` | medium | mitigate | Nur React-Textknoten bzw. Attributwert (React escaped), keine HTML-Einfügung; Titel auf 100 Zeichen gekürzt. Getestet: `<b>x</b>` erscheint wörtlich, kein `<b>`-Element. |
|
||||
| T-QD3-10 | Repudiation | Änderungen an Adresse/Titel | low | accept | Kein Audit-Log — persönliche Kachel ohne Fremdwirkung; für ASVS 1 ausreichend. |
|
||||
| T-QD3-SC | Tampering (Lieferkette) | npm-Installationen | high | mitigate | Nicht ausgelöst: KEINE neuen Pakete — `<iframe>` ist HTML, Sandbox ein Attribut. Sollte der Executor dennoch ein Paket installieren wollen: Stopp, Rückfrage an den Orchestrator. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Automatisch (Executor, je Aufgabe im `<verify>`): Web-Tests der neuen und angefassten Dateien inklusive `src/messages` (Umlaut-Wächter + de/en-Schlüsselgleichheit), `tsc --noEmit` beider Apps, Biome beider Apps, Zähler `as unknown as`, `'xframe'` im DTO, Schlüsselparität `widgets.xframe`.
|
||||
|
||||
Am Ende (Aufgabe 2): `pnpm type-check` 4/4, `pnpm lint` 5/5, volle Testläufe beider Apps, Disziplin-Zähler wie in der Ausgangsmessung, Handbuch-Zeile genau einmal.
|
||||
|
||||
Manuell (Orchestrator, Prüfliste aus Aufgabe 2 Punkt 4, lokal im Browser): Katalog, Leerzustand, https → Seite erscheint, http → Meldung, Kopfleiste/Ecksymbol, neuer Tab ohne Verlassen des Tessera-Tabs, Neuladen nach 60 s, Ziehen über dem Rahmen im Bearbeitungsmodus, verweigertes Einbetten bleibt leer + Link funktioniert, kein Server-Abruf.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- [ ] Alle sieben `must_haves.truths` erfüllt und je mit Test oder Rundgangspunkt belegt
|
||||
- [ ] Sandbox exakt `allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox`, `allow=""`, `referrerPolicy="no-referrer"`, `loading="lazy"` — per Test am gerenderten Element
|
||||
- [ ] http/javascript/data-Adressen: Formular weist ab (Meldung, kein Speichern), Resolver rendert nichts
|
||||
- [ ] Neuladen: Neueinhängen im Takt, Timer geräumt, kein Timer im Bearbeitungsmodus
|
||||
- [ ] Bearbeitungsmodus: Overlay vorhanden und nach dem Rahmen im DOM; Ansichtsmodus ohne Overlay
|
||||
- [ ] Katalog/Registry/Seite/DTO tragen `xframe` als neunten Typ, Registry-Zähler 36
|
||||
- [ ] Beide Sprachdateien vollständig, Texte siezen, Umlaut-Wächter grün (`neuem` gelistet)
|
||||
- [ ] Changelog-Stichpunkt und Handbuch-Zeile/-Sätze vorhanden
|
||||
- [ ] Tore grün, Zähler unverändert, keine neue `any`, zwei Commits mit Scope `quick-260921-qd3`
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Nach Abschluss `.planning/quick/260921-qd3-dashboard-widget-xframe-eine-webseite-pe/260921-qd3-SUMMARY.md` anlegen (Muster `260921-pi9-SUMMARY.md`): Rot-Nachweis der Resolver-/Widget-Tests, Zahlen der Endmessung, die zehnpunktige Browser-Prüfliste für den Orchestrator, offene Punkte.
|
||||
</output>
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
---
|
||||
phase: quick-260921-qd3
|
||||
plan: 01
|
||||
subsystem: apps/web/src/components/dashboard/widgets, apps/web/src/components/settings, apps/api/src/dashboard/dto
|
||||
tags: [dashboard, widget, xframe, iframe, sandbox, tdd, i18n]
|
||||
status: complete
|
||||
requires:
|
||||
- "STATE.md „NAECHSTER AUFTRAG“: zweites der zwei neuen Dashboard-Widgets, Produktfragen geklaert"
|
||||
- "quick-260921-pi9 (Bilderrahmen): isHttpsUrl in picture-frame-config.ts, Muster fuer Widget/Formular/Verdrahtung"
|
||||
provides:
|
||||
- "Widget-Typ xframe: Webseite per https-Adresse als <iframe> in der Kachel, Sandbox ohne Top-Navigation und ohne Modals"
|
||||
- "Einstellungen (Adresse, Titel, Neuladen-Intervall) im WidgetSettingsPanel, Formular als eigenes Modul"
|
||||
- "Uebersetzungs-Namensraum widgets.xframe (15 Schluessel de/en)"
|
||||
affects:
|
||||
- "apps/web/src/components/dashboard/widget-registry.tsx (neunter Typ, Zaehler 36)"
|
||||
- "apps/api/src/dashboard/dto/create-widget.dto.ts (@IsIn)"
|
||||
- "apps/web/src/messages/umlaut-dictionary.ts (Allowlist: neuem)"
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Fremdseite laedt nur der Browser (<iframe referrerPolicy=no-referrer sandbox=…>), die API kennt keinen Proxy und ruft die Adresse nie ab"
|
||||
- "https-Pruefung zweifach web-seitig (Formular weist ab, Resolver rendert nichts), weil die API Config-Inhalte nicht prueft"
|
||||
- "Neuladen ueber key-Wechsel des <iframe> (Adresse + Zaehler), Zaehler als data-reload-nonce sichtbar"
|
||||
- "Transparente Flaeche NACH dem <iframe> im DOM nur im Bearbeitungsmodus, damit die Karte Ziehgriff bleibt"
|
||||
key-files:
|
||||
created:
|
||||
- apps/web/src/components/dashboard/widgets/xframe-config.ts
|
||||
- apps/web/src/components/dashboard/widgets/xframe-config.test.ts
|
||||
- apps/web/src/components/dashboard/widgets/xframe-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx
|
||||
- apps/web/src/components/settings/xframe-config-form.tsx
|
||||
- apps/web/src/components/settings/xframe-config-form.test.tsx
|
||||
modified:
|
||||
- apps/api/src/dashboard/dto/create-widget.dto.ts
|
||||
- apps/web/src/components/settings/widget-settings-panel.tsx
|
||||
- apps/web/src/components/settings/widget-settings-panel.test.tsx
|
||||
- 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.tsx
|
||||
- apps/web/src/components/dashboard/widget-catalog-modal.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
|
||||
decisions:
|
||||
- "Formular als eigenes Modul xframe-config-form.tsx (Orchestrator-Aenderung 1, statt Funktion im Panel wie der Plan vorsah) — gleiches Muster wie der Bilderrahmen, eigener Test mit dem echten ICU-Uebersetzer"
|
||||
- "Constraints 12x12 statt 12x8 (Orchestrator-Aenderung 2): 8 Zeilen sind nur 216 px, zu flach fuer eine Webseite; Bilderrahmen wurde in 8bf3601 aus demselben Grund auf 8x12 gehoben"
|
||||
- "Link „In neuem Tab öffnen“ traegt zusaetzlich zum aria-label einen sr-only-Text: Biome useAnchorContent zaehlt aria-label nicht als Inhalt, ein biome-ignore ist verboten — der sichtbare Name bleibt identisch"
|
||||
- "Re-Export von isHttpsUrl aus picture-frame-config.ts, keine Kopie: eine Regel fuer „https-Adresse“ im ganzen Dashboard"
|
||||
metrics:
|
||||
duration: "ca. 8 min (19:12 bis 19:20 Uhr, 21.09.2026)"
|
||||
completed: 2026-09-21
|
||||
actuals:
|
||||
tokens: 14400
|
||||
tasks: 2
|
||||
commits: 2
|
||||
plan_head_before: 8bf3601
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260921-qd3: Dashboard-Widget „XFrame“ Summary
|
||||
|
||||
Ein neues Dashboard-Widget zeigt eine Webseite per https-Adresse als
|
||||
eingebetteten Rahmen in der Kachel. Die Sandbox laesst der Fremdseite
|
||||
Skripte, Formulare, Popups und ihren eigenen Origin, verbietet aber die
|
||||
Navigation des obersten Fensters und Modaldialoge — der Tessera-Tab bleibt
|
||||
Tessera. Adresse, optionaler Titel (Kopfleiste) und Neuladen-Intervall
|
||||
(nie / 1 / 5 / 10 / 30 Minuten / 1 Stunde) stehen unter Einstellungen ->
|
||||
Dashboard; „In neuem Tab öffnen“ ist immer da, sobald eine Adresse gesetzt
|
||||
ist. Der Server ruft die Adresse nie ab. Alle Tore sind gruen.
|
||||
|
||||
## Was gebaut wurde
|
||||
|
||||
**Resolver (`xframe-config.ts`).** Reines Modul ohne React: `url` nur wenn
|
||||
String, getrimmt und vom echten `URL`-Parser als `https:` erkannt (Re-Export
|
||||
`isHttpsUrl` aus dem Bilderrahmen, nicht dupliziert) — sonst `null`; `title`
|
||||
getrimmt und auf 100 Zeichen gekuerzt; `reloadSeconds` nicht endlich oder
|
||||
unter 60 -> 0 (nie), ab 3600 -> 3600, sonst die groesste Auswahlstufe <= n,
|
||||
damit das `<select>` immer eine passende Option zeigt. `XFRAME_SANDBOX =
|
||||
'allow-scripts allow-same-origin allow-forms allow-popups
|
||||
allow-popups-to-escape-sandbox'`; der Dateikopf erklaert, warum
|
||||
`allow-same-origin` bleibt (Origin der Fremdseite, nicht Tesseras; ohne das
|
||||
Token brechen Cookies/localStorage der meisten Seiten) und welche Tokens
|
||||
bewusst fehlen (Top-Navigation, Modals).
|
||||
|
||||
**Widget (`xframe-widget.tsx`).** Leerzustand als zentrierter grauer Text
|
||||
(auch bei http/javascript/data — der Resolver liefert `null`). Sonst genau
|
||||
ein `<iframe>` mit `sandbox`, `allow=""`, `referrerPolicy="no-referrer"`,
|
||||
`loading="lazy"`, `title` = Titel oder Adresse, `key` aus Adresse + Zaehler,
|
||||
Zaehler als `data-reload-nonce`. Mit Titel eine schmale Kopfleiste (`h2` +
|
||||
Link rechts, Muster Favoriten), ohne Titel der Link als Ecksymbol
|
||||
(`absolute right-1`, `top-1` im Ansichts-, `top-6` im Bearbeitungsmodus
|
||||
unter der 20-px-Griffleiste). Der Link ist ein echtes `<a target="_blank"
|
||||
rel="noopener noreferrer">` mit `aria-label`, `title` und sr-only-Text.
|
||||
Timer nur bei `url !== null && reloadSeconds > 0 && !isEditMode`,
|
||||
Aufraeumfunktion `clearInterval`. Im Bearbeitungsmodus liegt eine
|
||||
transparente `div` (`absolute inset-0`, `aria-hidden`) NACH dem Rahmen im
|
||||
DOM, damit `mousedown` zur Karte `.widget-drag-handle` aufsteigt.
|
||||
|
||||
**Formular (`xframe-config-form.tsx`, eigenes Modul).** Adresse und Titel
|
||||
als Entwuerfe mit Uebernahme bei Blur/Enter; leere Adresse -> `{ url: '' }`
|
||||
(Entfernen erlaubt), Nicht-https -> `role="alert"` + `aria-invalid`, kein
|
||||
`onChange`; unveraenderter Wert -> kein Aufruf; Intervall als `<select>`
|
||||
mit Beschriftungen Nie / Jede Minute / Alle {n} Minuten / Jede Stunde; der
|
||||
Einbett-Hinweis steht dauerhaft unter dem Adressfeld. Im Panel nur der
|
||||
Zweig `widgetType === 'xframe'` und die Kopfzeilen-Bedingung „— Titel“.
|
||||
|
||||
**Verdrahtung.** `WidgetType | 'xframe'`, `WIDGET_CONSTRAINTS.xframe =
|
||||
{ minW 4, minH 4, defaultW 12, defaultH 12 }`, `XframeIcon`
|
||||
(Browserfenster), Registry-Eintrag, `wireXframeWidget`, Katalog-Liste,
|
||||
Import + Verdrahtung in `(portal)/page.tsx`, `'xframe'` in
|
||||
`CreateWidgetDto @IsIn` (Kommentar: neun Typen). de/en: 15 Schluessel unter
|
||||
`widgets.xframe`, identischer Satz; `neuem` auf der Umlaut-Allowlist.
|
||||
|
||||
**Doku (Commit 20a9eb2).** Changelog-Stichpunkt als erster unter
|
||||
„Unveroeffentlicht -> Neu“, Zeile in der Widget-Tabelle, erweiterter Satz
|
||||
und Absatz-Zusatz unter „Dashboard > Widgets“ im Anwenderhandbuch.
|
||||
|
||||
## Die Tests, und der Beleg dass sie rot waren
|
||||
|
||||
| Datei | Faelle | Rot-Lauf (vor der Umsetzung) |
|
||||
|---|---:|---|
|
||||
| `xframe-config.test.ts` | 12 | `pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/xframe-config.test.ts src/components/dashboard/widgets/xframe-widget.test.tsx` -> `Error: Failed to resolve import "./xframe-config" from "src/components/dashboard/widgets/xframe-config.test.ts"`, Test Files 2 failed (2), Tests no tests |
|
||||
| `xframe-widget.test.tsx` | 12 | derselbe Lauf -> `Failed to resolve import "./xframe-config" from "src/components/dashboard/widgets/xframe-widget.test.tsx"`; nach der Umsetzung erster Lauf 12/12 |
|
||||
| `xframe-config-form.test.tsx` | 8 | nach dem Resolver, vor dem Formular geschrieben; erster Lauf nach der Umsetzung 8/8 |
|
||||
| `widget-settings-panel.test.tsx` (Test X1) | 1 | Zweig rendert das Formular, Kopfzeile „— Board“, Blur -> `updateWidgetConfig('x1', { url })` genau einmal |
|
||||
|
||||
Zusammen 33 neue Faelle; Registry-/Katalog-/Seiten-Tests laufen mit dem
|
||||
neunten Typ (Erwartungstabelle um `xframe`, `counted` 32 -> 36, Attrappen um
|
||||
`xframe.*` und das neue Widget-Modul ergaenzt). Web-Tests 569 -> 603
|
||||
(+34: 33 neue Faelle + 1 durch `it.each` in der Registry).
|
||||
|
||||
## Messungen (Endstand, HEAD 20a9eb2)
|
||||
|
||||
| Groesse | Ausgang (8bf3601) | Jetzt |
|
||||
|---|---:|---:|
|
||||
| `pnpm type-check` | 4/4 | 4/4 |
|
||||
| `pnpm lint` | 5/5 | 5/5 (api 74 Warnungen, web 53, keine Stufe `error`) |
|
||||
| API-Tests | 1175 | **1175** (75 Dateien) |
|
||||
| Web-Tests | 569 | **603** (80 Dateien) |
|
||||
| `as unknown as` in apps/api/src | 27 | 27 |
|
||||
| `as unknown as` in apps/web/src | 6 | 6 |
|
||||
| `noNonNullAssertion` in apps/api/src (biome) | 56 | 56 |
|
||||
| `noExplicitAny` in apps/api/src (biome) | 13 | 13 |
|
||||
| `biome-ignore` in apps/api/src | 1 | 1 |
|
||||
| `ts-expect-error` | 0 | 0 |
|
||||
| `dangerouslySetInnerHTML` / `!` / `any` in den neuen Dateien | – | 0 / 0 / 0 |
|
||||
| de/en-Schluesselgleichheit `widgets.xframe` | – | 15 = 15 |
|
||||
|
||||
Keine neue `any`, kein `!`, kein neues Paket, kein Prisma-/Schema-Pfad.
|
||||
|
||||
## Pruefliste fuer den Browser-Rundgang (Orchestrator, lokal, Playwright-MCP — NICHT Testserver)
|
||||
|
||||
- [x] (a) Dashboard -> Bearbeiten -> „Widget hinzufügen“ zeigt „XFrame“ mit Fenster-Symbol und Beschreibung „Webseite einbetten“; die platzierte Kachel ist 12x12 und zeigt „Keine Adresse eingestellt — über die Einstellungen festlegen“
|
||||
- [x] (b) Einstellungen -> Dashboard -> „XFrame #1“ aufklappen: Hinweistext „Manche Webseiten lassen sich nicht einbetten …“ steht dauerhaft da; https-Adresse einer einbettbaren Seite (z. B. eine interne Tessera-Seite oder `https://example.com`) eintragen, Feld verlassen -> Kachel zeigt die Seite
|
||||
- [x] (c) `http://…` eintragen -> rote Meldung „Bitte geben Sie eine vollständige https-Adresse ein.“, Netzwerk-Tab zeigt keinen PATCH
|
||||
- [x] (d) Titel „Board“ -> Kopfleiste mit Titel und Symbol „In neuem Tab öffnen“ rechts; Titel leeren -> Symbol wandert in die rechte obere Ecke
|
||||
- [x] (e) Klick auf das Symbol oeffnet die Adresse in einem neuen Tab, der Tessera-Tab bleibt; Tab-Taste erreicht das Symbol
|
||||
- [x] (f) Intervall „Jede Minute“ -> nach 60 s wird der Rahmen neu geladen (Netzwerk-Tab: zweiter Dokumentabruf; im DOM springt `data-reload-nonce` auf 1)
|
||||
- [x] (g) Bearbeitungsmodus: Kachel laesst sich an einer Stelle ueber dem Rahmen anfassen und ziehen, Groesse aendern funktioniert, im DOM liegt `xframe-edit-overlay`; Ansichtsmodus: Overlay weg, Seite bedienbar (Scrollen/Klicken im Rahmen)
|
||||
- [x] (h) Adresse einer Seite, die Einbetten verweigert (z. B. `https://www.google.com`) -> Rahmen bleibt leer (Browser-Konsole meldet `X-Frame-Options`/`frame-ancestors`), „In neuem Tab öffnen“ funktioniert
|
||||
- [x] (i) API-Log waehrend (b)/(f): kein Abruf der Fremdadresse durch die API — nur der Browser laedt sie
|
||||
- [x] (j) `pnpm --filter @tessera/api test` (1175) und `pnpm --filter @tessera/web test` (603) gruen, Zaehler wie Ausgangsmessung (siehe Tabelle oben)
|
||||
|
||||
**Rundgang durch den Orchestrator am 21.09.2026 (lokaler Stack, Abbilder aus 20a9eb2, Playwright-MCP):** alle Punkte bestanden. Belege: (a) Katalogeintrag „XFrame — Webseite einbetten“ mit Symbol, Kachel 801x328 px (12x12), Leerhinweis; (b) Hinweistext dauerhaft unter dem Adressfeld, `https://example.com/` → Kachel zeigt „Example Domain“ im Rahmen (`sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"`, `referrerpolicy=no-referrer`, `allow=""`, `loading=lazy`); (c) `http://example.com` → Meldung, `aria-invalid`, kein PATCH; https danach genau ein PATCH; (d) Titel „Board“ → Kopfleiste mit `<h2>` und Link in der Leiste (`border-b`-Vorfahr); (e) Klick oeffnet `https://example.com/` in einem neuen Tab, Tessera-Tab bleibt auf `/`, Tab-Taste erreicht den Link; (f) „Jede Minute“: `data-reload-nonce` 0 → 1 nach 62 s, genau ein zweiter Dokumentabruf von example.com; (g) Bearbeitungsmodus: Overlay `aria-hidden` NACH dem iframe, Ziehen ueber den Rahmen verschiebt die Kachel (`translate` 8 → 278 px), Groesse aendern 801x328 → 935x636, nach dem Speichern kein Overlay; (h) `https://www.google.com/` → Konsole „Refused to display … 'X-Frame-Options' to 'sameorigin'“, Rahmen leer, Link fuehrt zur Seite; (i) API-Log ohne Treffer auf `example.com`/`google.com`. Keine Befunde, keine Korrektur noetig.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
1. **Orchestrator-Aenderung 1 umgesetzt:** das Einstellungsformular liegt
|
||||
als `XframeConfigForm` in `apps/web/src/components/settings/xframe-config-form.tsx`
|
||||
mit eigenem Test (8 Faelle, `createTranslator`-Attrappe wie beim
|
||||
Bilderrahmen) statt als Funktion im Panel; das Panel traegt nur den
|
||||
Zweig, die Kopfzeilen-Bedingung und einen Testfall (X1). Aufgabe 1,
|
||||
Commit d63d9f5.
|
||||
2. **Orchestrator-Aenderung 2 umgesetzt:** Constraints `12x12` statt `12x8`
|
||||
(Registry, Registry-Test, Pruefpunkt (a)). Aufgabe 1, Commit d63d9f5.
|
||||
3. **[Rule 3 - Blocking] Biome `useAnchorContent`** meldete den Link
|
||||
„In neuem Tab öffnen“ (nur `aria-hidden`-SVG als Kind, `aria-label`
|
||||
zaehlt fuer die Regel nicht als Inhalt) als neue Warnung (web 53 -> 54).
|
||||
Kein `biome-ignore` erlaubt — der Link traegt zusaetzlich einen
|
||||
`sr-only`-Text mit demselben Namen; `aria-label` und `title` bleiben wie
|
||||
im Plan. Web-Lint wieder 53. Aufgabe 1, Commit d63d9f5.
|
||||
4. **Widget-Test 7** prueft `vi.getTimerCount()` direkt nach dem Rendern
|
||||
(ohne vorherige Interaktion haelt React keinen Scheduler-Timer, gemessen 1
|
||||
= nur unser Intervall) — der pi9-Vorbehalt (Test 11 dort) trat hier nicht
|
||||
auf.
|
||||
5. **Zwei zusaetzliche Widget-Faelle** ueber die geforderten 10 hinaus
|
||||
(Test 11 Ansichtsmodus ohne Overlay, Test 12 `top-1`/`top-6`); Resolver
|
||||
genau 12 wie gefordert.
|
||||
|
||||
Nicht geaendert: `STATE.md`, `ROADMAP.md`, keine neue Abhaengigkeit, kein
|
||||
Deploy, kein Zugriff auf den Testserver.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
Keine. Kette verdrahtet: Katalog -> `POST /dashboard/widgets` (`@IsIn`) ->
|
||||
Kachel ueber `WIDGET_REGISTRY.xframe.component`; Formular -> `isHttpsUrl`
|
||||
-> `PATCH /dashboard/widgets/:id/config` -> `resolveXframeConfig` ->
|
||||
`<iframe>`; Neuladen -> `key`-Wechsel; Bearbeitungsmodus -> Overlay.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
Keine neue Flaeche ausserhalb des `<threat_model>` des Plans: T-QD3-01
|
||||
(Sandbox ohne Top-Navigation/Modals), -03 (https zweifach), -04 (kein
|
||||
Server-Abruf), -05 (no-referrer, noopener noreferrer), -06 (`allow=""`),
|
||||
-07 (Overlay), -08 (Klemmung 60..3600, kein Timer beim Bearbeiten), -09
|
||||
(Titel nur als Textknoten) sind je per Test belegt; -04 zusaetzlich
|
||||
Rundgangspunkt (i).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Alle 6 neu angelegten Dateien liegen auf der Platte, die zwei Commits
|
||||
d63d9f5 und 20a9eb2 sind in `git log` auffindbar
|
||||
(`git rev-list --count 8bf3601..HEAD` = 2). Die Zahlen der Tabelle stammen
|
||||
aus tatsaechlich gelaufenen Befehlen.
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
---
|
||||
phase: quick-260922-frg
|
||||
plan: 01
|
||||
type: tdd
|
||||
autonomous: true
|
||||
subsystem: apps/desktop/src-tauri
|
||||
requirements: []
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-frg: Update-Eintrag im Tray nie mehr stumm ausgegraut
|
||||
|
||||
## Befund (Orchestrator, 22.09.2026)
|
||||
|
||||
Der Nutzer sieht im Tray-Menü nur den grauen Eintrag „Update installieren“,
|
||||
obwohl alpha das Paket `1.2.0-beta.gc001a08` anbietet und der Client auf
|
||||
Stand `a6d1a64` steht (Neustart der App ändert nichts). Nachgemessen:
|
||||
|
||||
- Die Update-Anfrage genau in der Form des Clients
|
||||
(`/api-proxy/desktop/update?target=windows&arch=x86_64¤t=1.2.0&base=https://alpha.tessera.ctl.de`)
|
||||
liefert **auf dem alpha-Server selbst** (Port 3000 am Proxy vorbei) `200` mit
|
||||
gültigem Manifest. Tessera-seitig ist alles in Ordnung.
|
||||
- **Vor** Tessera steht der Nginx Proxy Manager und antwortet auf jede Anfrage
|
||||
an `alpha.tessera.ctl.de` mit `401 Authorization Required`
|
||||
(`WWW-Authenticate: Basic`), gemessen vom Dev-Host (192.168.13.11) und vom
|
||||
Testserver selbst (192.168.200.240 über die öffentliche Adresse 217.7.63.32).
|
||||
Die Webansicht der App kann so ein Passwortfenster beantworten und sich das
|
||||
merken; der Updater (`tauri-plugin-updater`, eigener `reqwest`-Client) nicht.
|
||||
- Im Plugin führt ein Nicht-2xx-Status NICHT zu einem Fehler mit Statuscode:
|
||||
`updater.rs` Z. 529-559 loggt nur „did not respond with a successful status
|
||||
code“, lässt `last_error` leer und endet in `Err(Error::ReleaseNotFound)`.
|
||||
Unser `spawn_version_check` fängt das mit `Err(_) => {}` — **stumm**. Der
|
||||
Eintrag bleibt für immer „Update installieren“ (gesperrt), die App prüft
|
||||
außerdem nur beim Start (und beim Serverwechsel).
|
||||
|
||||
Das ist der eigentliche Produktfehler dieser Aufgabe: ein fehlgeschlagener
|
||||
Update-Check ist vom Zustand „kein Update“ nicht unterscheidbar, und es gibt
|
||||
keinen Weg, die Prüfung ohne Neustart zu wiederholen. Den Passwortschutz am
|
||||
Proxy selbst kann der Client nicht lösen (und soll es nicht: Zugangsdaten
|
||||
gehören nicht in ausgelieferte Clients) — er wird dem Nutzer als Grund
|
||||
angezeigt.
|
||||
|
||||
## Gebundene Entscheidungen (Orchestrator)
|
||||
|
||||
1. **Der Update-Eintrag hat drei Endzustände, alle anklickbar außer während
|
||||
Prüfung/Installation und bei http-Server:**
|
||||
- Update gefunden → `Auf Beta-Stand <sha7> aktualisieren` / `Auf Version X.Y.Z aktualisieren` (wie bisher), Klick installiert.
|
||||
- Kein Update → `Kein Update verfügbar – erneut prüfen`, Klick startet die Prüfung erneut.
|
||||
- Prüfung fehlgeschlagen → `Update-Prüfung fehlgeschlagen (HTTP 401) – erneut prüfen` bzw. ohne Status `Update-Prüfung fehlgeschlagen (keine Verbindung) – erneut prüfen`, Klick startet die Prüfung erneut.
|
||||
- Während der Prüfung: `Suche nach Updates…` (gesperrt). Während Download/Installation wie bisher (gesperrt, Fortschritt im Text).
|
||||
- http-Server: `Update nur über https möglich` (gesperrt, unverändert).
|
||||
- Beim Bau des Menüs steht `Suche nach Updates…` (gesperrt), weil `setup` die Prüfung sofort startet; ohne gespeicherte Server-Adresse `Kein Update verfügbar – erneut prüfen` (Klick ohne Adresse: nichts tun).
|
||||
2. **Statuscode nachliefern.** Bei `Err(ReleaseNotFound)` (= Server hat geantwortet, aber nicht 2xx/204) stellt der Client dieselbe Anfrage einmal mit seinem eigenen `reqwest`-Client (Timeout 8 s, Muster `check_server`) an die konkret gebaute Adresse (Platzhalter ersetzt: `target` = `std::env::consts::OS`, `arch` = `std::env::consts::ARCH`, `current` = `CARGO_PKG_VERSION`, `base` wie bisher) und liest NUR den Statuscode. Rumpf wird nicht ausgewertet. Bei `Err(Reqwest(..))`/`Err(Network(..))`/`Err(Io(..))` des Plugins (keine Verbindung, TLS, Timeout) keine zweite Anfrage: Status `None`.
|
||||
3. **Benachrichtigung mit Erklärung**, einmal je unterschiedlichem Fehlertext (Mutex<String> mit dem zuletzt gemeldeten Text; gleicher Text wird bei der periodischen Prüfung nicht erneut gemeldet). Texte über eine reine Funktion `check_failure_labels(status: Option<u16>) -> (String, String)`:
|
||||
- `Some(401)` / `Some(403)`: Menü `Update-Prüfung fehlgeschlagen (HTTP 401) – erneut prüfen`; Body `Der Server hat die Update-Anfrage mit HTTP 401 abgewiesen. Meist steht ein Passwortschutz oder eine Zugriffsliste am vorgeschalteten Proxy davor, die die App für Updates nicht durchlaufen kann. Anmeldung und Arbeiten in der App sind davon nicht betroffen.`
|
||||
- `Some(n)` sonst: Menü `Update-Prüfung fehlgeschlagen (HTTP n) – erneut prüfen`; Body `Der Server hat auf die Update-Anfrage mit HTTP n geantwortet statt mit Paketdaten.`
|
||||
- `None`: Menü `Update-Prüfung fehlgeschlagen (keine Verbindung) – erneut prüfen`; Body `Der Server war für die Update-Prüfung nicht erreichbar. Die App prüft in vier Stunden erneut – oder über den Menüeintrag.`
|
||||
4. **Periodische Prüfung alle 4 Stunden** (`std::thread::spawn` mit `std::thread::sleep(Duration::from_secs(4 * 3600))` in Schleife; kein neues Crate). Je Durchlauf: gespeicherte Adresse frisch über `stored_server_url(app)` lesen (Serverwechsel berücksichtigt); wenn `PendingUpdate` bereits ein Update hält → überspringen (keine wiederholte Benachrichtigung „Neuer Beta-Stand“); sonst `spawn_version_check`. Der Thread wird in `setup` einmal gestartet.
|
||||
5. **Klick auf „update“:** `PendingUpdate.take()` → vorhanden: `spawn_update_install` (wie bisher); sonst: wenn eine Server-Adresse gespeichert ist → `spawn_version_check` (manuelles „erneut prüfen“); ohne Adresse → nichts. Der bisherige Browser-Rückfall (`open_download_page`) bleibt NUR im Fehlerpfad der Installation.
|
||||
6. Ein Mutex-Zustand für die Benachrichtigungs-Entprellung als eigener `app.manage`-Typ (`LastCheckNotice(Mutex<String>)`), damit `spawn_version_check` keine Signatur-Änderung nach außen braucht.
|
||||
7. Alle Menütexte Deutsch (wie bisher, „Sie“ in Benachrichtigungen), Kommentare Deutsch im Stil der Datei, Verweise auf `updater.rs`-Zeilen wie im Bestand.
|
||||
|
||||
## Aufgabe (eine Datei Code, plus Changelog)
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 1: lib.rs — Zustände des Update-Eintrags, Statuscode-Diagnose, Entprellung, 4-Stunden-Prüfung, Klick = erneut prüfen; Tests; Changelog</name>
|
||||
<files>apps/desktop/src-tauri/src/lib.rs, CHANGELOG.md</files>
|
||||
<behavior>
|
||||
- `check_failure_labels(Some(401))` → Menü enthält `HTTP 401` und endet auf `– erneut prüfen`; Body enthält `Passwortschutz` und `Zugriffsliste`. `Some(403)` gleiche Body-Erklärung mit `HTTP 403`. `Some(502)` → Menü `HTTP 502`, Body ohne `Passwortschutz`, enthält `statt mit Paketdaten`. `None` → Menü enthält `keine Verbindung`, Body enthält `vier Stunden`.
|
||||
- `diagnostic_update_url("https://alpha.example", "windows", "x86_64", "1.2.0")` → `https://alpha.example/api-proxy/desktop/update?target=windows&arch=x86_64¤t=1.2.0&base=https%3A%2F%2Falpha.example` (Schlussstrich getrimmt; `base` kodiert wie in `update_endpoint`).
|
||||
- Konstanten: `UPDATE_ITEM_CHECKING == "Suche nach Updates…"`, `UPDATE_ITEM_NONE == "Kein Update verfügbar – erneut prüfen"`, `UPDATE_ITEM_INSECURE` unverändert.
|
||||
- `is_update_newer`, `update_labels`, `release_labels`, `update_endpoint` unverändert (bestehende Tests bleiben grün).
|
||||
</behavior>
|
||||
<action>
|
||||
1. Konstanten: `UPDATE_ITEM_DEFAULT` entfernen (durch `UPDATE_ITEM_CHECKING` und `UPDATE_ITEM_NONE` ersetzt), Kommentare anpassen. Neue Konstante `UPDATE_CHECK_INTERVAL: Duration = 4 h` mit Begründung (Tray-App läuft tagelang; nur Start-Prüfung → Update nie gesehen, Befund 22.09.2026).
|
||||
2. Reine Funktionen `check_failure_labels(status: Option<u16>) -> (String, String)` und `diagnostic_update_url(server: &str, target: &str, arch: &str, current: &str) -> String` (nutzt `api_url` + `url::Url::query_pairs_mut` wie `update_endpoint`, damit die Kodierung identisch ist). Tests zuerst (rot: Funktionen fehlen), mindestens 6 `#[test]` gemäß `<behavior>`.
|
||||
3. `async fn probe_update_status(url: String) -> Option<u16>`: `reqwest::Client::builder().timeout(8 s)`, `GET`, `Some(resp.status().as_u16())`, bei Fehler `None`. Keine Auswertung des Rumpfs, kein Folgen von Redirects nötig (Standard).
|
||||
4. `spawn_version_check`: zu Beginn `UPDATE_ITEM_CHECKING` + gesperrt (statt DEFAULT). Ergebnisse:
|
||||
- `Ok(Some(update))` wie bisher.
|
||||
- `Ok(None)` → Text `UPDATE_ITEM_NONE`, `set_enabled(true)`.
|
||||
- `Err(InsecureTransportProtocol)` → wie bisher (gesperrt).
|
||||
- `Err(ReleaseNotFound)` → `probe_update_status(diagnostic_update_url(&server_url, std::env::consts::OS, std::env::consts::ARCH, env!("CARGO_PKG_VERSION"))).await` → `check_failure_labels(status)` → Menütext setzen, `set_enabled(true)`, Benachrichtigung nur, wenn der Body vom zuletzt gemeldeten (`LastCheckNotice`) abweicht; danach dort ablegen.
|
||||
- `Err(_)` sonst → `check_failure_labels(None)`, gleiche Behandlung.
|
||||
Bei `Ok(Some)` und `Ok(None)` `LastCheckNotice` leeren, damit ein späterer Fehler wieder gemeldet wird.
|
||||
5. `setup`: Menüeintrag mit `UPDATE_ITEM_CHECKING` bauen, wenn eine Adresse gespeichert ist, sonst `UPDATE_ITEM_NONE`; `.enabled(server_url.is_none())` entsprechend. `app.manage(LastCheckNotice(Mutex::new(String::new())))`. Nach dem Start der Erstprüfung den Wiederhol-Thread starten: `let handle = app.handle().clone(); std::thread::spawn(move || loop { std::thread::sleep(UPDATE_CHECK_INTERVAL); let pending = handle.state::<PendingUpdate>().0.lock().map(|g| g.is_some()).unwrap_or(false); if pending { continue; } if let Some(url) = stored_server_url(&handle) { spawn_version_check(handle.clone(), url); } })`. Kommentar: warum kein `tokio::time` (kein neues Feature/Crate) und warum `PendingUpdate` den Durchlauf überspringt.
|
||||
6. Klick „update“: `take()` wie bisher; `None` → `if let Some(url) = stored_server_url(app) { spawn_version_check(app.clone(), url) }`. Kommentar aktualisieren (der Browser-Weg ist nicht mehr der Rückfall des Klicks).
|
||||
7. `cargo fmt`, `cargo clippy` (0 Warnungen, wie CI), `cargo test` in `apps/desktop/src-tauri` — alle bestehenden Tests plus die neuen grün. `cargo build` (Debug reicht lokal; Release/Windows baut die CI).
|
||||
8. `CHANGELOG.md` unter „Unveröffentlicht → Behoben“ als erster Stichpunkt: „Desktop-App: der Update-Eintrag im Menü des Infobereich-Symbols bleibt nicht mehr stumm ausgegraut – schlägt die Update-Prüfung fehl, steht der Grund im Eintrag (z. B. „HTTP 401“, wenn ein Passwortschutz am Proxy die Anfrage abweist) und ein Klick prüft erneut; die App prüft außerdem alle vier Stunden, nicht mehr nur beim Start“ (kein Fließtext, Tonlage der Nachbarzeilen).
|
||||
Commit: `fix(desktop): Update-Eintrag nennt den Grund einer fehlgeschlagenen Pruefung, Klick prueft erneut, Pruefung alle 4 h` (Wortlaut frei, Stil `git log --oneline -15`).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl/apps/desktop/src-tauri && cargo fmt --check && cargo clippy 2>&1 | tail -3 && cargo test 2>&1 | tail -5 && grep -q 'HTTP 401' /home/vicolab/projects/tessera-ctl/CHANGELOG.md</automated>
|
||||
</verify>
|
||||
<done>Tests in `lib.rs` ≥ 6 neue, alle grün, die Label-/URL-Tests nachweislich zuerst rot (Kompilierfehler „cannot find function“ zählt als rot — im SUMMARY nennen). `cargo clippy` ohne Warnung, `cargo fmt --check` sauber, `cargo build` erfolgreich. Menüzustände wie in Entscheidung 1; Klick ohne abgelegtes Update startet die Prüfung; Wiederhol-Thread alle 4 h; Benachrichtigung entprellt. CHANGELOG-Zeile steht. Genau ein Commit mit Scope `desktop`.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Hinweise für den Executor
|
||||
|
||||
- Nur `apps/desktop/src-tauri/src/lib.rs` und `CHANGELOG.md` anfassen. Keine neuen Crates, keine Cargo.toml-Änderung (reqwest ist da, `url` kommt über `tauri::Url`).
|
||||
- `MenuItem::set_text`/`set_enabled` liefern `Result`, wie im Bestand mit `let _ =` ignorieren.
|
||||
- `stored_server_url(app)` existiert (siehe `open_download_page`). `AppHandle` ist Clone + Send; `std::thread::spawn` mit dem Klon ist zulässig (die Setup-Funktion nutzt bereits `app.handle().clone()` für den Async-Task).
|
||||
- Die Prüfung im CI: `cargo check` + `cargo clippy`; lokal zusätzlich `cargo test`. Der Cross-Bau für Windows läuft in der CI (Stempel ändert sich, weil `apps/desktop` berührt wird).
|
||||
- Nicht anfassen: `is_update_newer`, `update_labels`, `release_labels`, `update_endpoint`, `spawn_update_install`.
|
||||
|
||||
## Verifikation durch den Orchestrator (nach CI)
|
||||
|
||||
- CI-Lauf grün, Job `desktop` hat neu gebaut (kein Cache-Treffer), Manifest im API-Abbild trägt den neuen Stempel.
|
||||
- Optional auf der Windows-Test-VM gegen alpha: neuer Client zeigt `Update-Prüfung fehlgeschlagen (HTTP 401) – erneut prüfen` und eine Benachrichtigung mit der Proxy-Erklärung.
|
||||
|
||||
<threat_model>
|
||||
ASVS 1, block on high.
|
||||
|
||||
| ID | Bedrohung | Schwere | Disposition |
|
||||
|---|---|---|---|
|
||||
| T-FRG-01 | Diagnose-Anfrage folgt Redirects zu fremden Hosts | low | Nur Statuscode wird gelesen, kein Rumpf; Ziel ist die vom Nutzer gespeicherte Server-Adresse; kein Geheimnis in der Anfrage. Akzeptiert. |
|
||||
| T-FRG-02 | Benachrichtigungs-Spam durch periodische Prüfung gegen kaputten Proxy | low | Entprellung über `LastCheckNotice` (gleicher Text wird nicht erneut gemeldet). Mitigiert. |
|
||||
| T-FRG-03 | Proxy-Zugangsdaten in den Client einbauen, um 401 zu umgehen | high | Ausdrücklich NICHT umgesetzt (Entscheidung Befund); der Grund wird angezeigt, die Behebung liegt am Proxy. Mitigiert durch Nicht-Bau. |
|
||||
| T-FRG-04 | Wiederhol-Thread startet Prüfung während Installation | low | Während der Installation ist `PendingUpdate` durch `take()` leer, ein Durchlauf würde nur eine Prüfung anstoßen; `spawn_version_check` setzt den Menütext — Restrisiko: Fortschrittstext wird überschrieben, wenn genau im Download-Fenster die 4-h-Marke fällt. Akzeptiert (Download dauert Minuten, Intervall Stunden). |
|
||||
</threat_model>
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
---
|
||||
phase: quick-260922-frg
|
||||
plan: 01
|
||||
subsystem: apps/desktop/src-tauri
|
||||
tags: [desktop, tauri, updater, tray, proxy, 401, tdd]
|
||||
status: complete
|
||||
requires:
|
||||
- "quick-260917-kgc (In-App-Updater, PendingUpdate, spawn_version_check)"
|
||||
provides:
|
||||
- "Update-Eintrag im Tray mit drei sichtbaren Endzustaenden, nie mehr stumm gesperrt"
|
||||
- "Statuscode-Diagnose nach Err(ReleaseNotFound) ueber eigene Anfrage"
|
||||
- "Wiederholte Update-Pruefung alle vier Stunden"
|
||||
- "Klick auf den Eintrag ohne abgelegtes Update prueft erneut"
|
||||
affects:
|
||||
- "apps/desktop/src-tauri/src/lib.rs"
|
||||
- "CHANGELOG.md"
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Fehlerzustand eines Hintergrund-Checks im Menuetext ausschreiben statt Eintrag stumm sperren"
|
||||
- "Zweite Diagnose-Anfrage nur fuer den Statuscode, wenn eine Bibliothek ihn verschluckt"
|
||||
- "Benachrichtigung ueber Mutex<String> mit dem zuletzt gemeldeten Text entprellen"
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- apps/desktop/src-tauri/src/lib.rs
|
||||
- CHANGELOG.md
|
||||
decisions:
|
||||
- "Proxy-Zugangsdaten werden NICHT in den Client eingebaut (T-FRG-03); der Grund wird angezeigt, die Behebung liegt am Proxy"
|
||||
- "Wiederhol-Thread als std::thread mit sleep statt tokio::time, damit kein neues Feature/Crate noetig ist"
|
||||
- "diagnostic_update_url liefert String statt Option: bei unparsbarer Adresse faellt sie auf api_url zurueck, gespeicherte Adressen sind ohnehin immer parsebar"
|
||||
- "report_check_failure und clear_check_notice als eigene Helfer, damit die drei Fehlerzweige in spawn_version_check kurz bleiben"
|
||||
metrics:
|
||||
duration: "ca. 20 min (11:05 bis 11:26 Uhr, 22.09.2026)"
|
||||
completed: 2026-09-22
|
||||
actuals:
|
||||
tokens: 5600
|
||||
tasks: 1
|
||||
commits: 1
|
||||
plan_head_before: ae36a22
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-frg: Update-Eintrag im Tray nie mehr stumm ausgegraut Summary
|
||||
|
||||
Der Update-Eintrag im Menue des Infobereich-Symbols zeigt jetzt in jedem Fall,
|
||||
was die Pruefung ergeben hat: ein Update, kein Update, oder den Grund des
|
||||
Fehlschlags (z. B. „HTTP 401“, wenn der Passwortschutz am Proxy die Anfrage
|
||||
abweist). Ein Klick auf den Eintrag prueft erneut, und die App prueft von
|
||||
selbst alle vier Stunden statt nur beim Start. Nur der http-Fall bleibt
|
||||
weiterhin dauerhaft gesperrt.
|
||||
|
||||
## Was sich fuer den Betrieb aendert
|
||||
|
||||
Bisher konnte der Nutzer nicht unterscheiden, ob es kein Update gibt oder ob
|
||||
die Pruefung gescheitert ist — in beiden Faellen stand da grau „Update
|
||||
installieren“, und ohne Neustart der App wurde nie wieder geprueft. Genau das
|
||||
war auf dem Client gegen alpha passiert: der Nginx Proxy Manager antwortet auf
|
||||
die Update-Anfrage mit 401, das Updater-Plugin macht daraus stumm
|
||||
`ReleaseNotFound`, und der Eintrag blieb grau, obwohl alpha das Paket
|
||||
`1.2.0-beta.gc001a08` bereithielt.
|
||||
|
||||
Jetzt steht in diesem Fall „Update-Prüfung fehlgeschlagen (HTTP 401) – erneut
|
||||
prüfen“ im Menue, und einmalig erscheint eine Benachrichtigung, die den
|
||||
Passwortschutz am vorgeschalteten Proxy als wahrscheinlichen Grund nennt und
|
||||
klarstellt, dass Anmeldung und Arbeiten in der App nicht betroffen sind. Den
|
||||
Passwortschutz selbst kann und soll der Client nicht umgehen; die Behebung
|
||||
liegt am Proxy (Ausnahme fuer `/api-proxy/desktop/*` oder Aufhebung des
|
||||
Schutzes fuer alpha).
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- **Konstanten:** `UPDATE_ITEM_DEFAULT` („Update installieren“) ist weg,
|
||||
ersetzt durch `UPDATE_ITEM_CHECKING` („Suche nach Updates…“, gesperrt
|
||||
waehrend der Pruefung) und `UPDATE_ITEM_NONE` („Kein Update verfügbar –
|
||||
erneut prüfen“, anklickbar). Neu `UPDATE_CHECK_INTERVAL = 4 h`.
|
||||
- **Reine Funktionen:** `check_failure_labels(Option<u16>)` liefert Menue- und
|
||||
Benachrichtigungstext (401/403 mit Proxy-Erklaerung, andere Codes neutral,
|
||||
`None` = keine Verbindung). `diagnostic_update_url` baut die Update-Adresse
|
||||
mit ersetzten Platzhaltern in genau der Kodierung von `update_endpoint`.
|
||||
- **Statuscode-Diagnose:** Bei `Err(ReleaseNotFound)` stellt
|
||||
`probe_update_status` dieselbe Anfrage einmal mit eigenem `reqwest`-Client
|
||||
(8 s Timeout, Muster `check_server`) und liest nur den Statuscode. Bei
|
||||
Verbindungsfehlern des Plugins (`Err(_)` sonst) keine zweite Anfrage.
|
||||
- **Entprellung:** `LastCheckNotice(Mutex<String>)` als eigener
|
||||
`app.manage`-Typ; `report_check_failure` meldet nur einen abweichenden Text,
|
||||
`clear_check_notice` leert ihn nach `Ok(Some)`/`Ok(None)`.
|
||||
- **Wiederhol-Thread:** in `setup` einmal gestartet, `std::thread::spawn` mit
|
||||
`sleep(UPDATE_CHECK_INTERVAL)` in Schleife, liest die Adresse je Durchlauf
|
||||
frisch und ueberspringt, wenn `PendingUpdate` bereits ein Update haelt.
|
||||
- **Klick „update“:** `take()` wie bisher; ohne abgelegtes Update startet
|
||||
`spawn_version_check` mit der gespeicherten Adresse; ohne Adresse nichts.
|
||||
`open_download_page` bleibt nur im Fehlerpfad der Installation.
|
||||
- **Menuebau:** „Suche nach Updates…“ (gesperrt) mit gespeicherter Adresse,
|
||||
sonst „Kein Update verfügbar – erneut prüfen“ (aktiv).
|
||||
- **Changelog:** neuer erster Stichpunkt unter „Unveröffentlicht → Behoben“.
|
||||
|
||||
## TDD Gate Compliance
|
||||
|
||||
**RED (nachgewiesen):** Die sieben neuen Tests wurden zuerst eingefuegt.
|
||||
`cargo test` brach mit neun Fehlern `E0425` ab — `cannot find function
|
||||
check_failure_labels`, `cannot find function diagnostic_update_url`,
|
||||
`cannot find value UPDATE_ITEM_CHECKING / UPDATE_ITEM_NONE /
|
||||
UPDATE_CHECK_INTERVAL`. Der Kompilierfehler zaehlt laut Plan als rot.
|
||||
|
||||
**GREEN:** Nach der Umsetzung `cargo test`: 44 bestanden, 0 fehlgeschlagen
|
||||
(37 Bestandstests plus 7 neue). `is_update_newer`, `update_labels`,
|
||||
`release_labels`, `update_endpoint`, `spawn_update_install` unveraendert.
|
||||
|
||||
**REFACTOR:** Zwei Helfer (`report_check_failure`, `clear_check_notice`)
|
||||
herausgezogen, damit die Fehlerzweige in `spawn_version_check` lesbar bleiben;
|
||||
der Kommentar bei `with_desktop_marker` nennt nicht mehr den alten Text
|
||||
„Update installieren“.
|
||||
|
||||
## Cargo-Ergebnisse (apps/desktop/src-tauri, lokal)
|
||||
|
||||
| Schritt | Ergebnis |
|
||||
|---|---|
|
||||
| `cargo fmt --check` | sauber |
|
||||
| `cargo clippy` | 0 Warnungen, 0 Fehler |
|
||||
| `cargo test` | 44 passed, 0 failed |
|
||||
| `cargo build` (Debug) | erfolgreich, Systembibliotheken vorhanden |
|
||||
| `grep 'HTTP 401' CHANGELOG.md` | Treffer |
|
||||
|
||||
## Commit
|
||||
|
||||
- `d73aad1` fix(desktop): Update-Eintrag nennt den Grund einer fehlgeschlagenen Pruefung, Klick prueft erneut, Pruefung alle 4 h — `apps/desktop/src-tauri/src/lib.rs`, `CHANGELOG.md`
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written. Einzige Ergaenzung ausserhalb der
|
||||
Aufzaehlung: der Doc-Kommentar bei `with_desktop_marker` verwies noch auf den
|
||||
entfernten Menuetext „Update installieren“ und wurde mitgezogen (reine
|
||||
Kommentar-Korrektur, kein Verhalten).
|
||||
|
||||
## Known Stubs
|
||||
|
||||
Keine.
|
||||
|
||||
## Offen (fuer den Orchestrator, nach CI)
|
||||
|
||||
- CI-Lauf: Job `desktop` muss neu bauen (apps/desktop beruehrt), Manifest im
|
||||
API-Abbild traegt den neuen Stempel.
|
||||
- Optional auf der Windows-Test-VM gegen alpha: neuer Client zeigt
|
||||
„Update-Prüfung fehlgeschlagen (HTTP 401) – erneut prüfen“ plus die
|
||||
Benachrichtigung mit der Proxy-Erklaerung. Damit der Client danach das
|
||||
Update tatsaechlich bekommt, muss der Proxy die Update-Anfrage durchlassen.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `apps/desktop/src-tauri/src/lib.rs` vorhanden und geaendert
|
||||
- `CHANGELOG.md` enthaelt die neue Zeile
|
||||
- Commit `d73aad1` in `git log` vorhanden
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
---
|
||||
phase: quick-260922-ge2
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements: [QUICK-260922-GE2]
|
||||
|
||||
files_modified:
|
||||
- apps/web/src/components/dashboard/widgets/xframe-config.ts
|
||||
- apps/web/src/components/dashboard/widgets/xframe-config.test.ts
|
||||
- apps/web/src/components/dashboard/widgets/xframe-crop.ts
|
||||
- apps/web/src/components/dashboard/widgets/xframe-crop.test.ts
|
||||
- apps/web/src/components/dashboard/widgets/xframe-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx
|
||||
- apps/web/src/components/settings/xframe-config-form.tsx
|
||||
- apps/web/src/components/settings/xframe-config-form.test.tsx
|
||||
- apps/web/src/test/fake-resize-observer.ts
|
||||
- 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
|
||||
|
||||
estimate:
|
||||
tokens: 130000
|
||||
raw_tokens: 130000
|
||||
tasks: 2
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Unter Einstellungen → Dashboard → XFrame schaltet „Nur einen Ausschnitt der Seite anzeigen“ eine Vorschau der Seite bei 1280 Pixel Breite frei (sichtbare Höhe 420 px, eigener Bildlauf); darüber liegt ein Rahmen, den der Benutzer mit der Maus verschiebt und an den vier Ecken in der Größe zieht; beim Loslassen wird der Ausschnitt genau einmal gespeichert; Links/Oben/Breite/Höhe stehen zusätzlich als Zahlenfelder (Übernahme bei Blur/Enter) mit demselben Klemmen."
|
||||
- "Die Kachel zeigt bei gesetztem Ausschnitt nur diesen Bereich der Seite, eingepasst (contain) und zentriert; ändert sich die Kachelgröße, skaliert der Ausschnitt mit und bleibt immer ganz sichtbar (gemessen per ResizeObserver am Kachelkörper)."
|
||||
- "Ohne Ausschnitt gibt es eine Vergrößerung (50/60/75/90/100/125/150 %) für die ganze Seite; 100 % rendert exakt wie heute (keine Transformation); die Auswahl ist nur ohne Ausschnitt sichtbar."
|
||||
- "„Nur anzeigen“ legt im Ansichtsmodus eine transparente Fläche über den Rahmen (Klicken und Scrollen in der Seite sind gesperrt); beim Einschalten des Ausschnitts wird es automatisch mit eingeschaltet; „In neuem Tab öffnen“ bleibt klickbar; im Bearbeitungsmodus liegt weiterhin nur die Bearbeitungsfläche (nie beide)."
|
||||
- "Unsinnige Werte kommen nie ins CSS: Ausschnitt-Zahlen werden auf x ≥ 0, y ≥ 0, w ∈ [100, 1280], h ∈ [60, 4000], x + w ≤ 1280 (x wird verschoben, nicht abgewiesen) geklemmt, ungültige Formen fallen auf null; Zoom auf die nächstniedrigere erlaubte Stufe (mindestens 50); readOnly nur bei echtem true."
|
||||
- "Das Formular sagt dauerhaft, dass der Ausschnitt eine Position auf der Seite ist und verrutschen kann, wenn die Seite ihren Aufbau ändert; Seiten, die das Einbetten verweigern, bleiben in Vorschau und Kachel leer (Hinweis von qd3 bleibt)."
|
||||
- "Sandbox, referrerPolicy, allow, loading, Neuladen-Nonce/Key, Kopfleiste, Leerzustand und Bearbeitungsfläche sind unverändert; die Vorschau nutzt dieselbe Sandbox und pointer-events: none; der Server ruft die Adresse weiterhin nie ab; alle Tore grün (type-check 4/4, lint 5/5 mit web weiterhin 53 Warnungen, Web-Tests ≥ 634 bei heute 604, API-Tests 1175, `as unknown as` web 6, kein `any`, kein `!`)."
|
||||
artifacts:
|
||||
- "apps/web/src/components/dashboard/widgets/xframe-config.ts — `XframeCrop`, `XframeConfig` um `crop | null`, `zoom`, `readOnly` erweitert; `XFRAME_PAGE_WIDTH`, `XFRAME_CROP_MIN_W/MIN_H/MAX_H`, `XFRAME_CROP_DEFAULT`, `XFRAME_ZOOM_OPTIONS`, `XFRAME_ZOOM_DEFAULT`, `clampXframeCrop`; Resolver klemmt"
|
||||
- "apps/web/src/components/dashboard/widgets/xframe-crop.ts — reine Geometrie ohne React: `computeCropLayout(crop, tileW, tileH)`, `applyCropDrag(mode, start, dxPage, dyPage)`, `XFRAME_PREVIEW_PAGE_HEIGHT`"
|
||||
- "apps/web/src/components/dashboard/widgets/xframe-widget.tsx — Ausschnitt-Modus (Clip + verschobener, skalierter <iframe>), Zoom-Modus, readOnly-Fläche, ResizeObserver am Kachelkörper, `data-tile-size`"
|
||||
- "apps/web/src/components/settings/xframe-config-form.tsx — Checkbox Ausschnitt, Vorschau mit Rahmen und vier Griffen (Pointer-Events), Zahlenfelder, Zoom-Auswahl (nur ohne Ausschnitt), Checkbox „Nur anzeigen“, dauerhafter Hinweis"
|
||||
- "apps/web/src/test/fake-resize-observer.ts — `stubResizeObserver({ width, height })` für Widget- und Formular-Test (ein einziger Cast `as ResizeObserverEntry`)"
|
||||
- "apps/web/src/messages/de.json + en.json — 13 neue Schlüssel unter `widgets.xframe`, identischer Satz; umlaut-dictionary.ts: `Ausschnitt` gelistet"
|
||||
- "CHANGELOG.md — Stichpunkt direkt nach dem XFrame-Stichpunkt; docs/anleitung-anwender.md — je ein Satz in Tabellenzeile und Absatz „Dashboard > Widgets“"
|
||||
key_links:
|
||||
- "Checkbox `xframe-crop-enable` -> `onChange({ crop: XFRAME_CROP_DEFAULT, readOnly: true })` (EIN Aufruf) -> `PATCH /dashboard/widgets/:id/config` (flache Zusammenführung, bestehend) -> Kachel `resolveXframeConfig` -> `crop !== null` -> ResizeObserver misst den Körper -> `computeCropLayout` -> Clip-`div` + <iframe style={left/top/transform}>"
|
||||
- "Rahmen-`onPointerDown` (Capture, jsdom: ohne) -> `onPointerMove` -> `applyCropDrag(mode, startCrop, dx/p, dy/p)` -> Entwurf im lokalen State (Rahmen folgt der Maus) -> `onPointerUp` -> `clampXframeCrop` -> genau ein `onChange({ crop })` nur bei Änderung"
|
||||
- "`zoom !== 100 && crop === null` -> <iframe absolute style={width/height %, transform scale}>; `zoom === 100` -> heutige Klassen `h-full w-full`, kein style"
|
||||
- "`readOnly && !isEditMode` -> `xframe-readonly-overlay` NACH dem Rahmen/Clip im DOM; `isEditMode` -> nur `xframe-edit-overlay`; Ecksymbol-Link `z-10` bleibt über beiden"
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-ge2: XFrame-Widget — Ausschnitt, Zoom, „Nur anzeigen“
|
||||
|
||||
<objective>
|
||||
Das XFrame-Widget (quick-260921-qd3, HEAD 5aa577a) bekommt drei Erweiterungen: (1) einen **Ausschnitt** der eingebetteten Seite — in den Einstellungen wählt der Benutzer ihn in einer Vorschau der Seite bei fester Seitenbreite 1280 px, indem er ein Rechteck verschiebt und an den Ecken zieht (alternativ vier Zahlenfelder); die Kachel zeigt nur diesen Ausschnitt, eingepasst und zentriert, und skaliert ihn mit der Kachelgröße; (2) eine **Vergrößerung** (50–150 %) für die Ganzseiten-Ansicht ohne Ausschnitt; (3) **„Nur anzeigen“** — eine Fläche über dem Rahmen sperrt Klicks und Bildlauf in der Seite (beim Einschalten des Ausschnitts automatisch an). Ehrliche Grenzen stehen im Formular: der Ausschnitt ist eine Pixelposition auf der Seite, keine Inhaltserkennung; verweigernde Seiten bleiben leer.
|
||||
|
||||
Purpose: Produktwunsch des Nutzers (abgestimmt); die technischen Entscheidungen hat der Orchestrator getroffen (Kasten unten) — dieser Plan verfeinert sie, ohne sie neu zu öffnen.
|
||||
Output: erweiterter Resolver + neues reines Geometrie-Modul (zuerst rot), erweitertes Widget und Formular mit Tests, Test-Helfer für den ResizeObserver, Übersetzungen, Changelog- und Handbuch-Sätze; alle Tore grün.
|
||||
</objective>
|
||||
|
||||
## Gebundene Entscheidungen (Orchestrator, verfeinert — nicht neu verhandeln)
|
||||
|
||||
1. **Konfiguration** (alle Felder optional, Resolver klemmt; Speicherung im bestehenden Config-JSON per flacher Zusammenführung der API). `crop: { x, y, w, h } | null` in Seitenpixeln bei virtueller Seitenbreite `XFRAME_PAGE_WIDTH = 1280` (Konstante, keine UI). Klemmen in `clampXframeCrop` (exportiert, EINE Funktion für Resolver, Zahlenfelder und Ziehen): alle vier Werte auf ganze Zahlen runden; `w` auf [`XFRAME_CROP_MIN_W` 100, 1280], `h` auf [`XFRAME_CROP_MIN_H` 60, `XFRAME_CROP_MAX_H` 4000], `x = max(0, x)`, `y = max(0, y)`, danach `x + w > 1280 → x = 1280 − w` (x verschieben, nie abweisen). Resolver: `crop` nur, wenn ein Nicht-null-Objekt mit vier endlichen Zahlen `x, y, w, h` vorliegt → geklemmt; alles andere → `null`. `zoom: number` in Prozent, `XFRAME_ZOOM_OPTIONS = [50, 60, 75, 90, 100, 125, 150]`, `XFRAME_ZOOM_DEFAULT = 100`; nicht endliche Zahl/fehlt → 100; sonst die größte Stufe ≤ n, unter 50 → 50. `readOnly: boolean`, nur `=== true` → true. `XFRAME_CROP_DEFAULT = { x: 0, y: 0, w: 1280, h: 720 }`.
|
||||
2. **Reine Geometrie** in NEUEM Modul `xframe-crop.ts` (ohne React, eigener Test zuerst rot): `computeCropLayout(crop, tileW, tileH) → { scale, left, top, frameHeight }` mit `scale = min(tileW / crop.w, tileH / crop.h)` (contain, darf > 1 sein), `left = (tileW − crop.w·scale) / 2`, `top = (tileH − crop.h·scale) / 2`, `frameHeight = max(crop.y + crop.h, 720)`; `tileW ≤ 0 || tileH ≤ 0 → { scale: 0, left: 0, top: 0, frameHeight }` (nichts rendern, bis gemessen). `applyCropDrag(mode, start, dxPage, dyPage) → XframeCrop` mit `mode ∈ 'move' | 'nw' | 'ne' | 'sw' | 'se'`: Kanten `left/top/right/bottom` aus `start` bilden; `move` verschiebt alle vier um `(dx, dy)` und klemmt die Position bei fester Größe auf `left ∈ [0, 1280 − w]`, `top ∈ [0, max(0, XFRAME_PREVIEW_PAGE_HEIGHT − h)]`; die Eckmodi bewegen nur die beiden Kanten der Ecke (`nw`: left+dx, top+dy; `ne`: right+dx, top+dy; `sw`: left+dx, bottom+dy; `se`: right+dx, bottom+dy), klemmen `left ≥ 0`, `top ≥ 0`, `right ≤ 1280`, `bottom ≤ XFRAME_PREVIEW_PAGE_HEIGHT` und halten die Mindestgröße an der bewegten Kante (`left = min(left, right − 100)`, `right = max(right, left + 100)`, `top = min(top, bottom − 60)`, `bottom = max(bottom, top + 60)`) — die gegenüberliegende Ecke bleibt stehen; Ergebnis durch `clampXframeCrop`. `XFRAME_PREVIEW_PAGE_HEIGHT = 3000` lebt hier (Vorschau UND Ziehmathematik brauchen es; 4000 bleibt die Speichergrenze für getippte Werte — ein getippter Wert jenseits der Vorschau ist gültig, aber in der Vorschau nicht sichtbar; im Hinweis nicht erwähnen, Randfall).
|
||||
3. **Rendering (Widget).** Der Kachelkörper (`<div className="relative min-h-0 flex-1 overflow-hidden">`, bereits vorhanden) bekommt `ref={bodyRef}`; ein `useEffect` mit Abhängigkeit `hasCrop = crop !== null` hängt NUR bei Ausschnitt einen `ResizeObserver` an (Muster dashboard-grid.tsx Zeile 134: `entries[0].contentRect`), hält `{ w, h }` im State, Aufräumfunktion `disconnect()`. Körper trägt im Ausschnitt-Modus `data-tile-size={`${Math.round(w)}x${Math.round(h)}`}`. **Ausschnitt-Modus** (`crop !== null`, nur wenn `layout.scale > 0`): Clip `<div data-testid="xframe-crop-clip" style={{ position: 'absolute', left, top, width: crop.w·scale, height: crop.h·scale, overflow: 'hidden' }}>` mit dem `<iframe>` darin: `style={{ position: 'absolute', left: −crop.x·scale, top: −crop.y·scale, width: XFRAME_PAGE_WIDTH, height: frameHeight, transform: `scale(${scale})`, transformOrigin: '0 0' }}`, `className="border-0 bg-background"` (KEIN `h-full w-full`), alle heutigen Attribute (`key`, `src`, `title`, `sandbox`, `allow=""`, `referrerPolicy`, `loading`, `data-testid`, `data-reload-nonce`) unverändert. Dateikopf erklärt: cross-origin lässt sich die Seite nicht von außen scrollen — deshalb wird der Rahmen SELBST verschoben und skaliert und ein Clip schneidet ihn; die feste Layoutbreite 1280 hält den Seitenaufbau über alle Kachelgrößen stabil, sonst würde die Seite bei jeder Kachelgröße anders umbrechen und der Ausschnitt verrutschen. **Zoom-Modus** (`crop === null`, `z = zoom / 100 ≠ 1`): `className="absolute left-0 top-0 border-0 bg-background"`, `style={{ width: pct, height: pct, transform: `scale(${z})`, transformOrigin: '0 0' }}` mit `pct = `${Math.round(10000 / z) / 100}%`` (60 % → `166.67%`, 150 % → `66.67%`; absolut positioniert, damit die Prozente sicher am Körper hängen). `z === 1` → exakt heute: `className="h-full w-full border-0 bg-background"`, kein `style`. **readOnly**: `!isEditMode && readOnly` → `<div data-testid="xframe-readonly-overlay" aria-hidden="true" className="absolute inset-0" />` NACH dem Rahmen bzw. Clip im DOM; `isEditMode` → nur die bestehende `xframe-edit-overlay` (nie beide). Das Ecksymbol (`z-10`) und die Kopfleiste liegen über beiden Flächen — unverändert.
|
||||
4. **Formular** (`xframe-config-form.tsx`, unter den bestehenden Feldern, in dieser Reihenfolge): (a) Checkbox `xframe-crop-enable` (Klassen wie `show-date-toggle` im Panel: `h-4 w-4 rounded border-border text-primary`, Label `text-sm text-foreground`) — Einschalten → `onChange({ crop: XFRAME_CROP_DEFAULT, readOnly: true })` (EIN Aufruf), Ausschalten → `onChange({ crop: null })` (readOnly unberührt); (b) bei `crop !== null` der Vorschaublock; (c) Zahlenfelder; (d) Zoom-Auswahl `<select id="xframe-zoom">` NUR bei `crop === null` (Optionen aus `XFRAME_ZOOM_OPTIONS`, Text `t('xframe.zoomOption', { n })`, `value={String(zoom)}`, `onChange({ zoom: Number(value) })`); (e) Checkbox `xframe-readonly`; (f) darunter dauerhaft `<p id="xframe-crop-hint" className="text-xs text-muted-foreground">{t('xframe.cropHint')}</p>`. **Vorschau**: äußerer `<div ref={previewRef} data-testid="xframe-crop-preview" className="relative overflow-y-auto rounded border border-border bg-background" style={{ height: 420 }}>`; Breite per `ResizeObserver` auf `previewRef` (gleiches Muster wie im Widget), `p = width > 0 ? width / 1280 : 0.5`; darin die Bühne `<div data-testid="xframe-crop-stage" className="relative" style={{ width: 1280·p, height: XFRAME_PREVIEW_PAGE_HEIGHT·p }}>` mit (i) `<iframe data-testid="xframe-crop-preview-frame" src={url} title={t('xframe.cropRectangle')} sandbox={XFRAME_SANDBOX} allow="" referrerPolicy="no-referrer" loading="lazy" className="border-0" style={{ position: 'absolute', left: 0, top: 0, width: 1280, height: XFRAME_PREVIEW_PAGE_HEIGHT, transform: `scale(${p})`, transformOrigin: '0 0', pointerEvents: 'none' }}>`, (ii) dem Rahmen `<div role="group" aria-label={t('xframe.cropRectangle')} data-testid="xframe-crop-rect" className="absolute cursor-move touch-none border-2 border-primary" style={{ left: c.x·p, top: c.y·p, width: c.w·p, height: c.h·p }}>` (`c = draft ?? crop`) mit vier Griffen `<div aria-hidden="true" data-testid="xframe-crop-handle-{nw|ne|sw|se}" className="absolute h-3 w-3 touch-none bg-primary …">` an den Ecken (`-left-1.5 -top-1.5 cursor-nwse-resize`, `-right-1.5 -top-1.5 cursor-nesw-resize`, `-left-1.5 -bottom-1.5 cursor-nesw-resize`, `-right-1.5 -bottom-1.5 cursor-nwse-resize`). Keine Maskierung außerhalb des Rahmens (Rand genügt). Ohne `url`: statt Bühne `<p data-testid="xframe-crop-preview-empty" className="p-3 text-sm text-muted-foreground">{t('xframe.cropPreviewEmpty')}</p>`, kein `<iframe>`. Über der Vorschau ein Hinweis `t('xframe.cropPreviewHint')` (Bedienung + „1280 Pixel Breite“). **Pointer-Logik** (eine Fabrik `dragProps(mode)` liefert `onPointerDown/onPointerMove/onPointerUp/onPointerCancel` für Rahmen und Griffe; Griffe rufen in `onPointerDown` `e.stopPropagation()`, sonst startet der Rahmen zusätzlich ein Verschieben): `onPointerDown` — nur Haupttaste (`e.button === 0`), `dragRef.current = { mode, startX: e.clientX, startY: e.clientY, start: crop }`, Capture per Helfer `capturePointer(e.currentTarget, e.pointerId)`; `onPointerMove` — bei aktivem Zug `setDraft(applyCropDrag(mode, start, (e.clientX − startX) / p, (e.clientY − startY) / p))`; `onPointerUp` — Capture lösen, `dragRef.current = null`, Ergebnis `next = draft ?? start`, `setDraft(null)`, `onChange({ crop: next })` NUR wenn sich eines der vier Felder gegenüber `crop` geändert hat (ein bloßer Klick erzeugt keinen PATCH); `onPointerCancel` — verwerfen ohne `onChange`. Deltas statt `getBoundingClientRect` — Bildlauf im Vorschaubehälter stört so nicht. **Capture-Wächter (Befund, jsdom 29.1.1):** jsdom kennt `PointerEvent` (clientX/pointerId funktionieren), aber NICHT `Element.setPointerCapture`/`releasePointerCapture` — Helfer `capturePointer(el: HTMLElement, id: number)` / `releasePointer(el, id)` rufen nur, wenn `typeof el.setPointerCapture === 'function'` (kompiliert, Biome still — geprüft); im Browser wird gefangen, in jsdom nicht (die Tests feuern Move/Up auf demselben Element). **Zahlenfelder** als eigene kleine Funktion `CropNumberFields({ crop, onCommit })` mit `key={`${crop.x}-${crop.y}-${crop.w}-${crop.h}`}` (State-Reset per Key, damit die Felder nach Ziehen/Speichern die neuen Werte zeigen): vier String-Entwürfe, `<input id="xframe-crop-{x|y|w|h}" type="number" inputMode="numeric" min={0} aria-describedby="xframe-crop-unit-hint">` in einem `grid grid-cols-4 gap-2`, Labels `t('xframe.cropX')` … (`mb-1 block text-sm text-foreground`), Felder `h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground`; Übernahme bei Blur/Enter: `Number(draft)` endlich → `next = clampXframeCrop({ ...crop, [k]: n })`, `onCommit(next)` nur bei Änderung; nicht endlich → nichts. Darunter `<p id="xframe-crop-unit-hint" className="text-xs text-muted-foreground">{t('xframe.cropUnitHint')}</p>`.
|
||||
5. **Kachelgrößen-Testhaken — Entscheidung: KEIN `measuredSize`-Prop.** Befund: `apps/web/src/test/setup.ts` polyfillt `ResizeObserver` bereits global für jsdom (feuert in `observe()` sofort mit `contentRect` 1200×800). Der Produktionscode bleibt frei von Test-Oberfläche; Widget- und Formular-Test überschreiben den Observer dateiweise mit expliziter Größe über den neuen Helfer `apps/web/src/test/fake-resize-observer.ts`: `export function stubResizeObserver(size: { width: number; height: number }): void` ruft `vi.stubGlobal('ResizeObserver', class { constructor(cb: ResizeObserverCallback) {…} observe() { this.cb([{ contentRect: new DOMRectReadOnly(0, 0, size.width, size.height) } as ResizeObserverEntry], this); } unobserve() {} disconnect() {} })` — EIN Cast `as ResizeObserverEntry` (geprüft: kompiliert unter strict/dom, kein `as unknown as`; jsdom hat `DOMRectReadOnly`); Tests rufen `vi.unstubAllGlobals()` in `afterEach`. Widget-Test misst 640×360, Formular-Test 640 breit (`p = 0.5`: 50 Bildschirm-px = 100 Seiten-px, glatte Zahlen). Die Mathematik selbst wird in `xframe-crop.test.ts` direkt geprüft.
|
||||
6. **Übersetzungen** `widgets.xframe.*`, 13 neue Schlüssel in de UND en (gleicher Satz, Deutsch mit „Sie“): `cropEnable` („Nur einen Ausschnitt der Seite anzeigen“ / „Show only a section of the page“), `cropHint` („Der Ausschnitt ist eine Position auf der Seite. Ändert die Seite ihren Aufbau, kann der Ausschnitt verrutschen und muss neu gesetzt werden.“ / „The section is a position on the page. If the page changes its layout, the section may shift and has to be set again.“), `cropPreviewHint` („Vorschau der Seite bei 1280 Pixel Breite. Ziehen Sie den Rahmen an die gewünschte Stelle; an den Ecken ändern Sie seine Größe. Nach unten scrollen zeigt mehr von der Seite.“ / „Preview of the page at 1280 pixels wide. Drag the frame to the desired spot; use the corners to change its size. Scroll down to see more of the page.“), `cropPreviewEmpty` („Die Vorschau erscheint, sobald eine Adresse eingetragen ist.“ / „The preview appears once an address is set.“), `cropRectangle` („Ausschnitt – ziehen zum Verschieben, Ecken zum Ändern der Größe“ / „Section – drag to move, corners to resize“), `cropX` („Links“/„Left“), `cropY` („Oben“/„Top“), `cropW` („Breite“/„Width“), `cropH` („Höhe“/„Height“), `cropUnitHint` („Werte in Pixeln der Seite bei 1280 Pixel Breite“ / „Values in page pixels at 1280 pixels wide“), `zoomLabel` („Vergrößerung der ganzen Seite“ / „Zoom of the full page“), `zoomOption` („{n} %“ beide), `readOnly` („Nur anzeigen – Klicks und Scrollen im Rahmen sperren“ / „View only – block clicks and scrolling inside the frame“). **Umlaut-Wächter** (Tokenizer `/[A-Za-zÄÖÜäöüß]+/g`, ganze Wörter): einziger neuer Verdachts-Token ist `Ausschnitt` (korrektes Deutsch; `Bildausschnitt` ist ein anderer Token) → in `UMLAUT_ALLOWLIST` nach `neuem` mit Kommentar `// XFrame-Ausschnitt (quick-260922-ge2): korrektes Deutsch mit „ss“`; `muss`, `Adresse` sind gelistet. Danach `pnpm --filter @tessera/web exec vitest run src/messages` — meldet der Wächter weitere Tokens, ebenfalls listen (nur korrekte deutsche Wörter).
|
||||
7. **Tore unverändert**: kein `any`, `as unknown as` web bleibt 6, kein `!`, keine HTML-Einfügung, `pnpm type-check` 4/4, `pnpm lint` 5/5, Biome-Warnungen web bleiben **53** (Ausgang gemessen) — Pointer-Handler stehen nicht auf der a11y-Handlerliste von Biome (`onClick/onMouseDown/onKey*`); sollte dennoch eine a11y-Warnung auf Rahmen oder Griffen erscheinen, ohne `biome-ignore` beheben und im SUMMARY vermerken. Keine neuen Pakete. Keine Änderung an `widget-settings-panel.tsx` (der Zweig reicht `config`/`onChange` durch), keiner an Registry, Katalog, API — die API führt die neuen Felder flach zusammen, `crop: null` wird als JSON-null gespeichert.
|
||||
|
||||
## Ausgangsmessung (22.09.2026, HEAD 5aa577a, Baum sauber)
|
||||
|
||||
| Größe | Wert |
|
||||
|---|---:|
|
||||
| Web-Tests | 604 |
|
||||
| API-Tests | 1175 |
|
||||
| `as unknown as` in apps/web/src | 6 (davon 1 in test/setup.ts) |
|
||||
| `as unknown as` in apps/api/src | 27 |
|
||||
| Biome-Warnungen web (`pnpm --filter @tessera/web lint`) | 53 |
|
||||
| `noNonNullAssertion` / `noExplicitAny` / `biome-ignore` in apps/api/src | 56 / 13 / 1 |
|
||||
| jsdom | 29.1.1 — `PointerEvent` ja, `setPointerCapture` nein, `DOMRectReadOnly` ja |
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@/home/vicolab/projects/tessera-ctl/CLAUDE.md
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/xframe-config.ts
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/xframe-config.test.ts
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/xframe-widget.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/settings/xframe-config-form.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/settings/xframe-config-form.test.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/components/dashboard/dashboard-grid.tsx
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/test/setup.ts
|
||||
@/home/vicolab/projects/tessera-ctl/apps/web/src/messages/umlaut-dictionary.ts
|
||||
@/home/vicolab/projects/tessera-ctl/.planning/quick/260921-qd3-dashboard-widget-xframe-eine-webseite-pe/260921-qd3-SUMMARY.md
|
||||
</context>
|
||||
|
||||
## Hinweise für den Executor
|
||||
|
||||
- **Ausgangspunkt HEAD 5aa577a**, Baum sauber. Nur die in `files_modified` genannten Dateien anfassen; **nie** `.planning/**` (Akte/STATE macht der Orchestrator).
|
||||
- **Tore vor jedem Commit:** `pnpm type-check`, `pnpm lint`, die betroffenen Vitest-Dateien; am Ende (Aufgabe 2) `pnpm --filter @tessera/api test` und `pnpm --filter @tessera/web test` vollständig.
|
||||
- **Rot-Nachweis:** `xframe-config.test.ts` (neue Fälle), `xframe-crop.test.ts` und die neuen Widget-Fälle werden VOR der Umsetzung geschrieben und einmal rot gefahren (Ausgabe kurz im SUMMARY festhalten).
|
||||
- **Bestehende Erwartungen anpassen:** `xframe-config.test.ts` Test 1 und Test 8 prüfen mit `toEqual` das ganze Objekt — um `crop: null, zoom: 100, readOnly: false` erweitern, sonst rot aus dem falschen Grund.
|
||||
- **Kein `any`**, keine neue `as unknown as`, kein `!`. `style`-Werte sind Zahlen (React hängt `px` an) oder Template-Strings aus eigenen Zahlen; `transform`/`transformOrigin`/`pointerEvents` sind reguläre CSS-Props.
|
||||
- **jsdom lädt keine Unterressourcen** — auch der Vorschau-`<iframe>` erzeugt keinen Netzabruf. Inline-Styles per `element.style.left` usw. prüfen (`'20px'`, `'-160px'`, `'scale(0.8)'`, `'166.67%'`).
|
||||
- **Pointer-Tests:** `fireEvent.pointerDown(el, { clientX, clientY, pointerId: 1, button: 0 })`, dann `pointerMove` und `pointerUp` auf DEMSELBEN Element (jsdom fängt nicht); Griffe stoppen die Propagation, sonst zählt der Rahmen mit.
|
||||
- **Commits:** je Aufgabe genau ein Commit, Stil `git log --oneline -15`, Scope `quick-260922-ge2`, deutsche Betreffzeile, Abschlusszeile `Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`.
|
||||
- **Nie** auf den Testserver deployen; Browser-Rundgang macht der Orchestrator lokal (Prüfliste im SUMMARY).
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Aufgabe 1: Resolver + Geometrie (rot → grün), Widget-Ausschnitt/Zoom/readOnly, Formular mit Vorschau und Ziehen, Übersetzungen — Ende-zu-Ende „Ausschnitt einschalten → Rahmen ziehen → Kachel zeigt den Ausschnitt eingepasst“</name>
|
||||
<files>apps/web/src/components/dashboard/widgets/xframe-config.ts, apps/web/src/components/dashboard/widgets/xframe-config.test.ts, apps/web/src/components/dashboard/widgets/xframe-crop.ts, apps/web/src/components/dashboard/widgets/xframe-crop.test.ts, apps/web/src/components/dashboard/widgets/xframe-widget.tsx, apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx, apps/web/src/components/settings/xframe-config-form.tsx, apps/web/src/components/settings/xframe-config-form.test.tsx, apps/web/src/test/fake-resize-observer.ts, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts</files>
|
||||
<reversibility rating="reversible">Alle neuen Config-Felder sind optional und werden geklemmt; ohne sie rendert das Widget exakt wie heute.</reversibility>
|
||||
<behavior>
|
||||
- Resolver (≥ 8 neue `it` in `xframe-config.test.ts`): `crop` fehlt / `null` / `'x'` / `{ x: 1 }` / `{ x: 'a', y: 0, w: 500, h: 300 }` → `null`; `{ x: 0, y: 0, w: 1280, h: 720 }` → unverändert; `{ x: 500, y: 0, w: 1000, h: 300 }` → `x: 280` (x verschoben, w bleibt); `w: 50 → 100`, `w: 5000 → 1280` (und x → 0), `h: 10 → 60`, `h: 9999 → 4000`; `x: -5 → 0`, `y: -1 → 0`; `x: 10.6 → 11` (gerundet); `zoom` fehlt/`'abc'`/`NaN` → 100, `70 → 60`, `200 → 150`, `0 → 50`, `10 → 50`, `125 → 125`; `readOnly: true → true`, `'true'`/`1`/fehlt → `false`; `XFRAME_ZOOM_OPTIONS` gleich `[50, 60, 75, 90, 100, 125, 150]`, `XFRAME_PAGE_WIDTH` 1280, `XFRAME_CROP_DEFAULT` gleich `{ x: 0, y: 0, w: 1280, h: 720 }`; alle sechs Felder zusammen (`url`, `title`, `reloadSeconds`, `crop`, `zoom`, `readOnly`) → `toEqual` mit Erwartung; `clampXframeCrop` ist exportiert und liefert für `{ x: 1200, y: 0, w: 200, h: 100 }` → `{ x: 1080, y: 0, w: 200, h: 100 }`.
|
||||
- Geometrie (≥ 9 `it` in `xframe-crop.test.ts`): `computeCropLayout({0,0,1280,720}, 640, 720)` → `scale 0.5, left 0, top 180` (breitenbegrenzt); `(…, 1280, 180)` → `scale 0.25, left 480, top 0` (höhenbegrenzt); `({0,0,200,100}, 800, 400)` → `scale 4, left 0, top 0` (Vergrößerung erlaubt); `frameHeight`: `{0,0,1280,100}` → 720, `{0,3000,1280,400}` → 3400; `(crop, 0, 0)` und `(crop, 640, 0)` → `scale 0` (Rest 0); `applyCropDrag('move', {100,100,400,300}, 100, 50)` → `{200,150,400,300}`; `move` um `+2000, 0` → `x: 880` (1280 − 400), `move` um `−500, −500` → `{0,0,400,300}`; `'se'` um `+100, +100` → `{100,100,500,400}`; `'nw'` um `+350, +10` → `{400,110,100,290}` (Mindestbreite an der bewegten Kante, rechte Kante 500 bleibt); `'ne'` um `+2000, 0` → `w: 1180` (rechte Kante 1280); `'sw'` um `0, +5000` → `h: 2900` (untere Kante 3000).
|
||||
- Widget (≥ 6 neue `it`, `stubResizeObserver({ width: 640, height: 360 })` in `beforeEach`, `vi.unstubAllGlobals()` in `afterEach`): (1) `{ url: U, crop: {0,0,1280,720} }` → Körper `data-tile-size="640x360"`; Clip `style.left '0px'`, `top '0px'`, `width '640px'`, `height '360px'`, `overflow 'hidden'`; `<iframe>` im Clip mit `style.left '0px'`, `top '0px'`, `width '1280px'`, `height '720px'`, `transform 'scale(0.5)'`, `transformOrigin '0 0'`, Klasse OHNE `h-full`, und weiterhin `sandbox === XFRAME_SANDBOX`, `referrerpolicy 'no-referrer'`, `allow ''`, `loading 'lazy'`, `data-reload-nonce '0'`; (2) `{ url: U, crop: {200,100,800,400} }` → Clip `left '0px'`, `top '20px'`, `width '640px'`, `height '320px'`; `<iframe>` `left '-160px'`, `top '-80px'`, `height '720px'`, `transform 'scale(0.8)'`; (3) `{ url: U, zoom: 60 }` → kein Clip, `<iframe>` `style.width '166.67%'`, `height '166.67%'`, `transform 'scale(0.6)'`, Klasse enthält `absolute`; (4) `{ url: U, zoom: 100 }` und `{ url: U }` → `style.transform ''`, Klasse enthält `h-full` und `w-full`, kein Clip; `{ url: U, crop: {…}, zoom: 60 }` → Ausschnitt-Modus, Zoom ignoriert (kein `scale(0.6)`); (5) `{ url: U, readOnly: true }` Ansichtsmodus → `xframe-readonly-overlay` mit `aria-hidden="true"`, Klassen `absolute inset-0`, im DOM NACH dem `<iframe>`; kein `xframe-edit-overlay`; Link `xframe.openInNewTab` vorhanden mit Klasse `z-10`; (6) `{ url: U, readOnly: true }` Bearbeitungsmodus → `xframe-edit-overlay` vorhanden, `xframe-readonly-overlay` NICHT; `{ url: U }` Ansichtsmodus → kein readonly-Overlay; (7) `{ url: U, crop: {…} }` Bearbeitungsmodus → Clip vorhanden UND `xframe-edit-overlay` vorhanden; `{ url: U, crop: {…}, title: 'Board' }` → `<h2>` Board bleibt. Bestehende Tests 1–12 bleiben grün.
|
||||
- Formular (≥ 7 neue `it`, `stubResizeObserver({ width: 640, height: 420 })`, Übersetzer wie bisher aus der echten de.json): (1) `{ url: U }` → Checkbox `texts.cropEnable` nicht angehakt, keine Vorschau, `<select>` `texts.zoomLabel` vorhanden mit Optionen `['50','60','75','90','100','125','150']` und Texten `'50 %' … '150 %'`, Wert `'100'`; Klick auf die Checkbox → `onChange` genau einmal mit `{ crop: { x: 0, y: 0, w: 1280, h: 720 }, readOnly: true }`; (2) `{ url: U, crop: {100,100,400,300} }` → Checkbox angehakt, `xframe-crop-preview` vorhanden, Vorschau-`<iframe>` mit `src U`, `sandbox === XFRAME_SANDBOX`, `referrerpolicy 'no-referrer'`, `allow ''`, `style.pointerEvents 'none'`, `width '1280px'`, `height '3000px'`, `transform 'scale(0.5)'`; Rahmen `xframe-crop-rect` (`role="group"`, Name `texts.cropRectangle`) mit `style.left '50px'`, `top '50px'`, `width '200px'`, `height '150px'`; vier Griffe vorhanden; KEIN `<select>` `texts.zoomLabel`; Klick auf die Checkbox → `onChange({ crop: null })` genau einmal; (3) Zahlenfelder: `xframe-crop-w` auf `'2000'` + Blur → `onChange({ crop: { x: 0, y: 100, w: 1280, h: 300 } })`; `xframe-crop-h` auf `'10'` + Enter → `onChange({ crop: { x: 100, y: 100, w: 400, h: 60 } })`; `xframe-crop-x` auf `'abc'` + Blur → kein weiterer Aufruf; `xframe-crop-x` auf `'100'` (unverändert) + Blur → kein Aufruf; (4) Ziehen am Rahmen: `pointerDown(rect, { clientX: 100, clientY: 100, pointerId: 1, button: 0 })`, `pointerMove(rect, { clientX: 150, clientY: 125, pointerId: 1 })` → `onChange` noch NICHT gerufen, Rahmen `style.left '100px'`, `top '75px'` (Entwurf folgt); `pointerUp(rect, { clientX: 150, clientY: 125, pointerId: 1 })` → `onChange` genau einmal mit `{ crop: { x: 200, y: 150, w: 400, h: 300 } }`; (5) Ecke `xframe-crop-handle-se`: Down/Move `+50/+50`/Up → genau ein Aufruf `{ crop: { x: 100, y: 100, w: 500, h: 400 } }` (der Rahmen hat NICHT zusätzlich verschoben); `xframe-crop-handle-nw` um `−50/−50` → `{ crop: { x: 0, y: 0, w: 500, h: 400 } }`; (6) Down + Up ohne Bewegung → kein Aufruf; `pointerCancel` nach Move → kein Aufruf und Rahmen wieder bei `50px`; (7) `{ url: U, readOnly: true }` → Checkbox `texts.readOnly` angehakt, Klick → `onChange({ readOnly: false })`; `{ url: U }` → nicht angehakt, Klick → `{ readOnly: true }`; Hinweis `texts.cropHint` immer sichtbar; (8) `{ crop: {…} }` ohne `url` → `xframe-crop-preview-empty` mit `texts.cropPreviewEmpty`, kein Vorschau-`<iframe>`; (9) Zoom-Auswahl `'60'` → `onChange({ zoom: 60 })` sofort. Bestehende Tests 1–8 bleiben grün.
|
||||
</behavior>
|
||||
<action>
|
||||
**Reihenfolge: Resolver + Geometrie rot → grün, dann Widget (Tests zuerst), dann Formular (Tests zuerst), zuletzt Übersetzungen + Allowlist.**
|
||||
|
||||
1. **`xframe-config.ts`** (Entscheidung 1): Dateikopf um einen Absatz ergänzen — warum Ausschnitt-Zahlen geklemmt und nie abgewiesen werden (T-GE2-05: kein negativer, kein riesiger, kein NaN-Wert erreicht das CSS; ein manipulierter Config-Wert ergibt höchstens einen anderen Ausschnitt) und dass `zoom` nur ohne Ausschnitt wirkt. Exporte: `XFRAME_PAGE_WIDTH`, `XFRAME_CROP_MIN_W`, `XFRAME_CROP_MIN_H`, `XFRAME_CROP_MAX_H`, `XFRAME_CROP_DEFAULT`, `XFRAME_ZOOM_OPTIONS: number[]`, `XFRAME_ZOOM_DEFAULT`, `interface XframeCrop { x: number; y: number; w: number; h: number }`, `clampXframeCrop(raw: XframeCrop): XframeCrop`, `XframeConfig` um `crop: XframeCrop | null; zoom: number; readOnly: boolean` erweitert; private `resolveCrop(raw: unknown)` (Objekt-Prüfung ohne Cast: `typeof raw === 'object' && raw !== null`, dann die vier Felder über `Record<string, unknown>`-Zugriff mit `typeof === 'number' && Number.isFinite`), `resolveZoom(raw: unknown)` nach dem Muster `resolveReload` (größte Stufe ≤ n, unter 50 → 50, nicht endlich → 100), `resolveReadOnly(raw: unknown)` (`raw === true`). **`xframe-config.test.ts`**: neuer `describe('resolveXframeConfig — Ausschnitt, Zoom, readOnly (quick-260922-ge2)')` mit allen Fällen aus `<behavior>`; Tests 1 und 8 um die drei neuen Felder ergänzen. Vor der Umsetzung rot.
|
||||
|
||||
2. **`xframe-crop.ts`** NEU (Entscheidung 2, ohne React-Import). Dateikopf: Zweck (Geometrie für Kachel und Vorschau), warum contain + Zentrierung (der Ausschnitt muss immer GANZ sichtbar sein, Kachelseitenverhältnis ≠ Ausschnittverhältnis), warum `frameHeight` mindestens 720 (die Seite braucht Layouthöhe, sonst rendern viele Seiten ihren Inhalt nicht), warum die Ziehmathematik die gegenüberliegende Ecke festhält. Exporte: `XFRAME_PREVIEW_PAGE_HEIGHT = 3000`, `type XframeDragMode = 'move' | 'nw' | 'ne' | 'sw' | 'se'`, `interface XframeCropLayout { scale: number; left: number; top: number; frameHeight: number }`, `computeCropLayout(crop: XframeCrop, tileW: number, tileH: number): XframeCropLayout`, `applyCropDrag(mode: XframeDragMode, start: XframeCrop, dxPage: number, dyPage: number): XframeCrop` — beide exakt wie in Entscheidung 2 beschrieben, Ergebnis von `applyCropDrag` durch `clampXframeCrop`. **`xframe-crop.test.ts`** mit den Fällen aus `<behavior>` (≥ 9 `it`), vor der Umsetzung rot (Import schlägt fehl).
|
||||
|
||||
3. **`xframe-widget.tsx`** (Entscheidung 3). Dateikopf um zwei Absätze ergänzen: (a) Ausschnitt — cross-origin kann Tessera die Seite nicht von außen scrollen, deshalb wird der `<iframe>` selbst mit `left/top` verschoben und mit `transform: scale` skaliert, ein Clip-`div` schneidet ihn; die feste Layoutbreite `XFRAME_PAGE_WIDTH` hält den Seitenaufbau über alle Kachelgrößen stabil (sonst bräche die Seite bei jeder Kachelgröße anders um und der Ausschnitt verrutschte); der ResizeObserver am Körper liefert die Kachelgröße; (b) readOnly — eine transparente Fläche über dem Rahmen sperrt Zeiger-Eingaben (Bedienkomfort, KEINE Sicherheitsmaßnahme: die Seite läuft weiter, T-GE2-02); nie zusammen mit der Bearbeitungsfläche. Imports: `useRef` dazu, `computeCropLayout` aus `./xframe-crop`, `XFRAME_PAGE_WIDTH` aus `./xframe-config`. State `const [tile, setTile] = useState({ w: 0, h: 0 })`, `const bodyRef = useRef<HTMLDivElement>(null)`, `const hasCrop = crop !== null`; `useEffect(() => { const el = bodyRef.current; if (!el || !hasCrop) return; const observer = new ResizeObserver((entries) => { const rect = entries[0].contentRect; setTile({ w: rect.width, h: rect.height }); }); observer.observe(el); return () => observer.disconnect(); }, [hasCrop])`. **Hook-Reihenfolge:** alle Hooks VOR dem Leerzustand-`return` (wie heute `useEffect` für den Timer). Ein kleines `frameAttrs`-Objekt (`src`, `title`, `sandbox`, `allow`, `referrerPolicy`, `loading`, `data-testid`, `data-reload-nonce`) und der `key` werden in allen drei Zweigen identisch gesetzt (Zweige: Ausschnitt / Zoom ≠ 100 / heute). Körper: `ref={bodyRef}`, `data-tile-size` nur bei `hasCrop`; Reihenfolge der Kinder: Rahmen bzw. Clip → `isEditMode && xframe-edit-overlay` → `!isEditMode && readOnly && xframe-readonly-overlay` → Ecksymbol-Link (unverändert `z-10`). **`xframe-widget.test.tsx`**: Import `stubResizeObserver` aus `@/test/fake-resize-observer`; `beforeEach` zusätzlich `stubResizeObserver({ width: 640, height: 360 })`, `afterEach` zusätzlich `vi.unstubAllGlobals()`; neuer `describe('XframeWidget — Ausschnitt, Zoom, readOnly (quick-260922-ge2)')` mit den Fällen aus `<behavior>` (≥ 6 `it`; Inline-Styles über `(el as HTMLElement).style.left` usw. — `getByTestId` liefert `HTMLElement`, kein Cast nötig; der `<iframe>` über `frame()` wie bisher). Vor der Umsetzung rot.
|
||||
|
||||
4. **`apps/web/src/test/fake-resize-observer.ts`** NEU (Entscheidung 5): Kopfkommentar (Englisch wie setup.ts): warum ein dateiweiser Stub statt des globalen Polyfills (explizite Größe, deterministische Geometrie), dass der eine Cast `as ResizeObserverEntry` genügt (die Klasse ist strukturell ein `ResizeObserver`), Aufräumen per `vi.unstubAllGlobals()`. Export `stubResizeObserver(size: { width: number; height: number }): void`. Import von `vi` aus `vitest`. Kein `as unknown as`.
|
||||
|
||||
5. **`xframe-config-form.tsx`** (Entscheidung 4). Dateikopf um einen Absatz ergänzen: Vorschau = derselbe `<iframe>` mit derselben Sandbox (T-GE2-01), `pointer-events: none` (T-GE2-03: die Vorschau ist nur zum Sehen — Zeigerereignisse gehen an Rahmen und Griffe, nie an die Fremdseite), Ziehen über Pointer-Events mit Capture (Wächter für jsdom), Entwurf lokal, ein `onChange` beim Loslassen; Ausschnitt = Position, kein Inhalt (Hinweis dauerhaft). Imports: `useEffect, useRef` dazu; `XFRAME_CROP_DEFAULT, XFRAME_PAGE_WIDTH, XFRAME_SANDBOX, XFRAME_ZOOM_OPTIONS, clampXframeCrop` und `type XframeCrop` aus `xframe-config`; `XFRAME_PREVIEW_PAGE_HEIGHT, applyCropDrag` und `type XframeDragMode` aus `@/components/dashboard/widgets/xframe-crop`. Destrukturierung um `crop, zoom, readOnly` erweitern. Konstante `PREVIEW_HEIGHT_PX = 420`. Komponenten in derselben Datei: `CropPreview({ url, crop, onCommit })` (Vorschau + Rahmen + Griffe + Pointer-Logik + Breitenmessung) und `CropNumberFields({ crop, onCommit })` (mit `key` vom Aufrufer); Helfer `capturePointer`/`releasePointer` (typeof-Wächter) als Modulfunktionen; `sameCrop(a, b)` für den Änderungsvergleich. Reihenfolge im JSX nach dem Intervall-Feld: Checkbox Ausschnitt → (bei crop) Hinweis `cropPreviewHint`, `CropPreview`, `CropNumberFields key=…` → (ohne crop) Zoom-Auswahl → Checkbox readOnly → `cropHint`. Klassen der Checkboxen wie `show-date-toggle` im Panel (`flex items-center gap-3`, Input `h-4 w-4 rounded border-border text-primary`). **`xframe-config-form.test.tsx`**: Import `stubResizeObserver`; `beforeEach` `stubResizeObserver({ width: 640, height: 420 })`, `afterEach` `cleanup()` + `vi.unstubAllGlobals()`; `renderForm` liefert zusätzlich `cropEnable`, `readOnly` (per `getByLabelText`) und Helfer `rect()`, `handle(corner)`, `numberField(k)`; neuer `describe('XframeConfigForm — Ausschnitt, Zoom, readOnly (quick-260922-ge2)')` mit den Fällen aus `<behavior>` (≥ 7 `it`). Vor der Umsetzung rot.
|
||||
|
||||
6. **Übersetzungen + Allowlist** (Entscheidung 6): 13 Schlüssel in `de.json` und `en.json` unter `widgets.xframe` nach `embedHint`; `Ausschnitt` in `UMLAUT_ALLOWLIST`; `pnpm --filter @tessera/web exec vitest run src/messages` grün.
|
||||
|
||||
Commit: `feat(quick-260922-ge2): XFrame-Widget - Ausschnitt der Seite waehlen und einpassen, Zoom fuer die ganze Seite, Nur anzeigen` (Wortlaut frei, Stil beachten, Co-Authored-By-Zeile).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/xframe-config.test.ts src/components/dashboard/widgets/xframe-crop.test.ts src/components/dashboard/widgets/xframe-widget.test.tsx src/components/settings/xframe-config-form.test.tsx src/components/settings/widget-settings-panel.test.tsx src/messages && pnpm --filter @tessera/web exec tsc --noEmit && pnpm --filter @tessera/web lint && pnpm --filter @tessera/web lint 2>&1 | grep -q 'Found 53 warnings' && test "$(grep -rn 'as unknown as' apps/web/src --include=*.ts --include=*.tsx | wc -l)" -eq 6 && node -e "const d=require('./apps/web/src/messages/de.json').widgets.xframe,e=require('./apps/web/src/messages/en.json').widgets.xframe;for(const k of ['cropEnable','cropHint','cropPreviewHint','cropPreviewEmpty','cropRectangle','cropX','cropY','cropW','cropH','cropUnitHint','zoomLabel','zoomOption','readOnly']){if(!(k in d)||!(k in e)){console.error('fehlt:',k);process.exit(1)}}const m=Object.keys(d).filter(k=>!(k in e)).concat(Object.keys(e).filter(k=>!(k in d)));if(m.length){console.error('Schluessel ungleich:',m);process.exit(1)}"</automated>
|
||||
</verify>
|
||||
<done>`xframe-config.test.ts` ≥ 20 Fälle (12 alte + ≥ 8 neue), `xframe-crop.test.ts` ≥ 9, `xframe-widget.test.tsx` ≥ 18 (12 + ≥ 6), `xframe-config-form.test.tsx` ≥ 15 (8 + ≥ 7) — alle grün, die neuen Resolver-, Geometrie- und Widget-Fälle nachweislich zuerst rot (Rot-Lauf im SUMMARY). Umlaut-Wächter grün (`Ausschnitt` gelistet), beide Sprachdateien tragen dieselben 13 neuen Schlüssel. Kette nachgewiesen (Tests): Checkbox → EIN `onChange` mit Ausschnitt + readOnly; Ziehen → Entwurf folgt, EIN `onChange` beim Loslassen mit geklemmtem Ausschnitt; Ecke → Größe, Gegenecke bleibt; Zahlenfelder klemmen; Kachel → Clip und verschobener, skalierter Rahmen für gemessene 640×360; Zoom 60 → Prozentmaße + `scale(0.6)`, Zoom 100 → wie heute; readOnly-Fläche nur im Ansichtsmodus, Link darüber; Sandbox/no-referrer/allow/lazy in Kachel UND Vorschau. `tsc --noEmit` ohne Befund, Biome web weiterhin 53 Warnungen, `as unknown as` web 6, keine `any`, kein `!`.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Aufgabe 2: Changelog, Anwenderhandbuch, Voll-Tore, Zähler, Prüfliste für den Browser-Rundgang</name>
|
||||
<files>CHANGELOG.md, docs/anleitung-anwender.md</files>
|
||||
<action>
|
||||
1. **`CHANGELOG.md`** unter „Unveröffentlicht → Neu“ als Stichpunkt DIREKT NACH dem bestehenden XFrame-Stichpunkt (kein Fließtext, Tonlage der Nachbarzeilen): „Dashboard-Widget „XFrame“: nur einen Ausschnitt der Webseite zeigen – den Rahmen in einer Vorschau verschieben und an den Ecken ziehen oder Links/Oben/Breite/Höhe eintippen; die Kachel zeigt genau diesen Ausschnitt und passt ihn an ihre Größe an; Vergrößerung der ganzen Seite (50 bis 150 %); „Nur anzeigen“ sperrt Klicken und Scrollen im Rahmen – der Ausschnitt ist eine Position auf der Seite und kann verrutschen, wenn die Seite ihren Aufbau ändert“.
|
||||
2. **`docs/anleitung-anwender.md`**: (a) in der Zeile „| XFrame | …“ der Widget-Tabelle (Zeile 83) vor dem schließenden „ |“ genau einen Satz anfügen: „Wahlweise zeigen Sie nur einen Ausschnitt der Seite: den Rahmen in der Vorschau verschieben oder an den Ecken ziehen (oder Links, Oben, Breite und Höhe eintippen) – die Kachel zeigt dann genau diesen Ausschnitt, passend zu ihrer Größe; für die ganze Seite gibt es eine Vergrößerung (50 bis 150 %), und „Nur anzeigen“ sperrt Klicken und Scrollen im Rahmen.“; (b) im Absatz „**Dashboard > Widgets:**“ (Zeile 155) nach dem letzten Satz („… dass manche Webseiten das Einbetten verweigern.“) genau einen Satz anfügen: „Mit „Nur einen Ausschnitt der Seite anzeigen“ erscheint eine Vorschau der Seite, in der Sie den Rahmen verschieben und an den Ecken ziehen oder die Werte eintippen; der Ausschnitt ist eine Position auf der Seite und muss neu gesetzt werden, wenn die Seite ihren Aufbau ändert.“ Siezen, Schreibweise der Nachbarzeilen (Anführungszeichen „…“).
|
||||
3. **Volle Tore**: `pnpm type-check` (4/4), `pnpm lint` (5/5), `pnpm --filter @tessera/api test`, `pnpm --filter @tessera/web test`; Zähler messen wie in der qd3-SUMMARY (Tabelle „Messungen“: `as unknown as` api/web per grep, `noNonNullAssertion`/`noExplicitAny` per `biome lint` in apps/api/src, `biome-ignore` per grep, Biome-Warnungen web) und ins SUMMARY schreiben.
|
||||
4. **Prüfliste** im SUMMARY für den Orchestrator (Browser, Playwright-MCP, lokal — NICHT Testserver), Punkt für Punkt abhakbar: (a) Einstellungen → Dashboard → XFrame mit `https://example.com`: „Nur einen Ausschnitt der Seite anzeigen“ anhaken → genau ein PATCH mit `crop` UND `readOnly: true`; Vorschau (420 px hoch, scrollbar) zeigt die Seite bei 1280 px Breite mit blauem Rahmen über der ganzen Breite (0/0/1280/720), Zahlenfelder zeigen 0/0/1280/720, Zoom-Auswahl ist verschwunden; (b) Rahmen mit der Maus verschieben → folgt flüssig, beim Loslassen genau ein PATCH, Zahlenfelder springen auf die neuen Werte; (c) Ecke unten rechts ziehen → Größe ändert sich, obere linke Ecke bleibt, ein PATCH; Ecke oben links ziehen bis unter die Mindestbreite → Rahmen bleibt 100 px breit, rechte Kante steht; (d) Zahlenfeld Breite auf 2000 → Feld zeigt 1280, Links springt auf 0; Höhe 10 → 60; (e) Dashboard: die Kachel zeigt genau den Ausschnitt, eingepasst und zentriert (DOM: `xframe-crop-clip` mit `data-tile-size` am Körper, `<iframe>` mit `transform: scale(…)`), Sandbox/no-referrer/allow unverändert; (f) Kachel im Bearbeitungsmodus vergrößern/verkleinern → nach dem Speichern skaliert der Ausschnitt mit und bleibt ganz sichtbar; Ziehen über dem Rahmen funktioniert weiterhin (`xframe-edit-overlay`); (g) „Nur anzeigen“ an: Klick und Mausrad im Rahmen bewirken nichts (DOM: `xframe-readonly-overlay`), „In neuem Tab öffnen“ klickt weiterhin; aus: Seite bedienbar; (h) Ausschnitt abhaken → PATCH `crop: null`, Zoom-Auswahl erscheint; Zoom 60 % → Kachel zeigt die Seite verkleinert (DOM: `width: 166.67%`, `transform: scale(0.6)`), 100 % → kein `style`; (i) verweigernde Seite (`https://www.google.com`) → Vorschau und Kachel bleiben leer, Formular-Hinweise stehen; API-Log ohne Abruf der Fremdadresse.
|
||||
Commit: `docs(quick-260922-ge2): Changelog und Anwenderhandbuch - XFrame-Ausschnitt, Zoom, Nur anzeigen` (nur CHANGELOG.md + docs/anleitung-anwender.md; Akte/STATE macht der Orchestrator; Co-Authored-By-Zeile).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -A1 'Dashboard-Widget „XFrame“: eine Webseite per https-Adresse' CHANGELOG.md | grep -q 'nur einen Ausschnitt der Webseite zeigen' && grep -q '| XFrame | .*Wahlweise zeigen Sie nur einen Ausschnitt der Seite' docs/anleitung-anwender.md && grep -q 'Nur einen Ausschnitt der Seite anzeigen' docs/anleitung-anwender.md && pnpm type-check && pnpm lint && pnpm --filter @tessera/api test && pnpm --filter @tessera/web test</automated>
|
||||
</verify>
|
||||
<done>Changelog-Stichpunkt steht direkt nach dem XFrame-Stichpunkt; Handbuch trägt den Tabellensatz und den Absatzsatz; `pnpm type-check` 4/4, `pnpm lint` 5/5 ohne Befund der Stufe `error`, Biome web 53 Warnungen; API 1175 Tests, Web ≥ 634 Tests, alle grün; Zähler unverändert (`as unknown as` 27/6, `noNonNullAssertion` 56, `noExplicitAny` ≤ 13, `biome-ignore` 1); die neunpunktige Prüfliste steht im SUMMARY; genau zwei Commits mit Scope `quick-260922-ge2` (`git log --oneline -2`).</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<assumption_delta_decision>
|
||||
API-Coverage-Detektor (`api-coverage.cjs --json` über die Aufgabenbeschreibung, real ausgeführt): `{"detected":false,"signals":[]}` — kein externer Dienst, keine SDK-Integration; alles bleibt im Browser (Rahmen, Transformationen, Pointer-Events), die API sieht nur drei weitere Schlüssel im undurchsichtigen Config-JSON. Keine COVERAGE.md nötig. **Feuert nicht.**
|
||||
|
||||
Assumption-Delta-Detektor: Quick-Aufgabe ohne ROADMAP-Abschnitt → `phase_unresolved` (übersprungen, kein Verdikt). Gedanklich ausgeführt: kein Pflichtfeld wird optional (die drei Felder sind neu und optional), keine zweite Variante einer Identität; `zoom` macht aus dem festen 100 % einen gewählten Wert — eine Anzeigeeinstellung je Kachel, kein Identitätsmodell. Entscheidung: `no-change`. **Feuert nicht.**
|
||||
|
||||
Schema-Tor: kein Prisma-, Migrations- oder Schema-Pfad im Umfang (Config-JSON bleibt `Json`). **Feuert nicht.**
|
||||
</assumption_delta_decision>
|
||||
|
||||
<threat_model>
|
||||
ASVS-Stufe 1, Blockschwelle `high` (jede `high`-Bedrohung MUSS mitigiert sein).
|
||||
|
||||
## Vertrauensgrenzen
|
||||
|
||||
| Grenze | Beschreibung |
|
||||
|---|---|
|
||||
| Config-JSON → Browser | `crop`/`zoom`/`readOnly` stammen aus dem vom Benutzer selbst beschreibbaren Widget-Config (API prüft nicht inhaltlich) und landen in Inline-Styles (`left/top/width/height/transform`) |
|
||||
| Tessera-Seite ↔ eingebettete Fremdseite (Kachel UND Vorschau) | Zwei `<iframe>` derselben Adresse; die Fremdseite läuft in ihrem Origin, sieht keine Tessera-Eingaben, kann das oberste Fenster nicht navigieren |
|
||||
| Zeiger → Rahmen/Griffe (Vorschau) | Pointer-Capture bindet Bewegungen an Tesseras eigene Elemente; die Vorschau-Seite ist für Zeiger unerreichbar |
|
||||
| Browser → Fremdhost | Nur der Browser des Benutzers ruft die Adresse ab (jetzt bis zu zweimal: Kachel, Vorschau); der Server nie |
|
||||
|
||||
## STRIDE-Register
|
||||
|
||||
| ID | Kategorie | Komponente | Schwere | Disposition | Maßnahme |
|
||||
|---|---|---|---|---|---|
|
||||
| T-GE2-01 | Spoofing / Elevation (Navigation des obersten Fensters, Modaldialoge) | `<iframe sandbox>` in Kachel (alle drei Render-Zweige) UND Vorschau | high | mitigate | Beide Rahmen tragen exakt `XFRAME_SANDBOX` (ohne `top-navigation`, ohne `modals`), `allow=""`, `referrerPolicy="no-referrer"`; Clip, Verschiebung und `transform: scale` ändern nichts an der Sandbox — getestet am gerenderten Attribut im Ausschnitt-Modus (Widget-Test 1) und an der Vorschau (Formular-Test 2). |
|
||||
| T-GE2-02 | Information Disclosure / Tampering (Fehldeutung: readOnly als Schutz) | `xframe-readonly-overlay` | low | accept | Die Fläche ist Bedienkomfort, keine Sicherheitsmaßnahme: die Fremdseite lädt und läuft weiter (Skripte, Cookies, Neuladen), nur Zeigerereignisse erreichen sie nicht; Tastaturfokus in den Rahmen ist weiterhin möglich. Dateikopf des Widgets sagt das ausdrücklich; die Sicherheitsgrenze bleibt die Sandbox (T-GE2-01). |
|
||||
| T-GE2-03 | Tampering (Vorschau-Seite fängt Zeigerereignisse / Clickjacking in der Vorschau) | Vorschau-`<iframe>` mit `pointer-events: none`, Rahmen und Griffe darüber | medium | mitigate | `style.pointerEvents 'none'` (getestet), Rahmen/Griffe absolut über dem Rahmen, Capture auf Tesseras eigenen Elementen; die Fremdseite kann keine Ziehbewegung abfangen und keinen Klick unter dem Rahmen entgegennehmen. |
|
||||
| T-GE2-04 | Server-Side Request Forgery | API | high | mitigate | Unverändert: die API ruft NIE die Adresse ab, kein Proxy, kein Fetch; die Vorschau lädt die Seite ein zweites Mal im Browser des Benutzers, nie am Server; keine API-Änderung in dieser Aufgabe. Nachweis im Rundgang (i). |
|
||||
| T-GE2-05 | Denial of Service (riesige/negative/NaN-Maße im CSS: Browser friert bei 10⁹-px-Rahmen ein, negative Breiten, `transform: scale(NaN)`) | `crop`/`zoom` → Inline-Styles | medium | mitigate | `clampXframeCrop` (Rundung, w ∈ [100, 1280], h ∈ [60, 4000], x/y ≥ 0, x + w ≤ 1280) im Resolver, in den Zahlenfeldern und beim Ziehen; `zoom` nur aus der erlaubten Stufenliste (mindestens 50); `computeCropLayout` liefert bei ungemessener Kachel `scale 0` (nichts rendern statt Division durch 0); Vorschau-Rahmen fest 1280×3000. Getestet: Resolver-Fälle (5000 → 1280, 9999 → 4000, −5 → 0, NaN → null/100), Geometrie (Nullkachel). |
|
||||
| T-GE2-06 | Tampering (XSS über Konfigurationswerte) | Inline-Styles, `aria-label`, Hinweistexte | low | mitigate | Keine HTML-Einfügung; alle Style-Werte sind Zahlen oder Template-Strings aus eigenen geklemmten Zahlen (React setzt sie als CSS-Eigenschaften, nicht als Markup); Texte nur aus den Sprachdateien. Sichtbarer Nachweis im Rundgang (e), (h). |
|
||||
| T-GE2-07 | Elevation of Privilege (Pointer-Capture) | `capturePointer` auf Rahmen/Griffen | low | accept | Capture bindet nur die Ereignisse eines Zeigers an ein Tessera-eigenes Element bis zum Loslassen; `onPointerCancel` verwirft; keine Rechte, kein Zugriff auf die Fremdseite; in jsdom fehlt Capture, im Browser wird sie ordnungsgemäß gelöst. |
|
||||
| T-GE2-08 | Denial of Service (Vorschau lädt die Fremdseite zusätzlich) | Vorschau-`<iframe>` | low | accept | Nur während die Einstellungen offen sind und nur bei gesetztem Ausschnitt; kein Neuladen-Timer in der Vorschau; höchstens ein zweiter Dokumentabruf — vertretbar. |
|
||||
| T-GE2-09 | Repudiation | Änderungen an Ausschnitt/Zoom/readOnly | low | accept | Kein Audit-Log — persönliche Kachel ohne Fremdwirkung; ASVS 1 genügt. |
|
||||
| T-GE2-SC | Tampering (Lieferkette) | npm-Installationen | high | mitigate | Nicht ausgelöst: KEINE neuen Pakete — Pointer-Events, `ResizeObserver`, `transform` sind Browser-APIs. Sollte der Executor dennoch ein Paket installieren wollen: Stopp, Rückfrage an den Orchestrator. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Automatisch (Executor, je Aufgabe im `<verify>`): Web-Tests der neuen und angefassten Dateien inklusive `src/messages` (Umlaut-Wächter + de/en-Schlüsselgleichheit mit den 13 neuen Schlüsseln), `tsc --noEmit`, Biome mit exakt 53 Warnungen, Zähler `as unknown as` web 6.
|
||||
|
||||
Am Ende (Aufgabe 2): `pnpm type-check` 4/4, `pnpm lint` 5/5, volle Testläufe beider Apps (API 1175, Web ≥ 634), Disziplin-Zähler wie in der Ausgangsmessung, Changelog-Stichpunkt und Handbuch-Sätze genau einmal.
|
||||
|
||||
Manuell (Orchestrator, Prüfliste aus Aufgabe 2 Punkt 4, lokal im Browser): Ausschnitt einschalten → Vorschau mit Rahmen; Ziehen verschiebt; Ecke ändert die Größe; Zahlenfelder klemmen; Kachel zeigt den Ausschnitt eingepasst und zentriert; Kachelgröße ändern → Ausschnitt skaliert mit; „Nur anzeigen“ sperrt Klicks, Link bleibt klickbar; Zoom 60 % in der Ganzseiten-Ansicht; verweigernde Seite bleibt leer; kein Server-Abruf.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- [ ] Alle sieben `must_haves.truths` erfüllt und je mit Test oder Rundgangspunkt belegt
|
||||
- [ ] Resolver klemmt Ausschnitt (x-Verschiebung statt Abweisung), Zoom-Stufen, readOnly — per Test
|
||||
- [ ] `computeCropLayout`/`applyCropDrag` rein, ohne React, mit den Rand- und Mindestgrößenfällen getestet
|
||||
- [ ] Kachel: Clip + verschobener, skalierter Rahmen für gemessene Größe; Zoom-Zweig; 100 % wie heute; readOnly-Fläche nur im Ansichtsmodus; Sandbox unverändert
|
||||
- [ ] Formular: Checkbox → EIN Aufruf mit Ausschnitt + readOnly; Vorschau mit `pointer-events: none` und derselben Sandbox; Ziehen/Ecken → EIN Aufruf beim Loslassen; Zahlenfelder; Zoom nur ohne Ausschnitt; dauerhafter Hinweis
|
||||
- [ ] Kein `measuredSize`-Prop; Kachelgröße im Test über `stubResizeObserver` (ein Cast `as ResizeObserverEntry`); Pointer-Capture mit typeof-Wächter
|
||||
- [ ] Beide Sprachdateien vollständig (13 neue Schlüssel), Texte siezen, Umlaut-Wächter grün (`Ausschnitt` gelistet)
|
||||
- [ ] Changelog-Stichpunkt und Handbuch-Sätze vorhanden
|
||||
- [ ] Tore grün, Biome web 53 Warnungen, Zähler unverändert, keine neue `any`, zwei Commits mit Scope `quick-260922-ge2`
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Nach Abschluss `.planning/quick/260922-ge2-xframe-widget-ausschnitt-der-eingebettet/260922-ge2-SUMMARY.md` anlegen (Muster `260921-qd3-SUMMARY.md`): Rot-Nachweis der Resolver-/Geometrie-/Widget-Tests, Zahlen der Endmessung, die neunpunktige Browser-Prüfliste für den Orchestrator, offene Punkte.
|
||||
</output>
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
---
|
||||
phase: quick-260922-ge2
|
||||
plan: 01
|
||||
subsystem: apps/web/src/components/dashboard/widgets, apps/web/src/components/settings, apps/web/src/test
|
||||
tags: [dashboard, widget, xframe, iframe, crop, zoom, pointer-events, resize-observer, tdd, i18n]
|
||||
status: complete
|
||||
requires:
|
||||
- "quick-260921-qd3 (XFrame-Widget): Resolver, Widget, Formular, Sandbox, Neuladen"
|
||||
provides:
|
||||
- "XFrame-Ausschnitt: crop {x,y,w,h} in Seitenpixeln bei fester Layoutbreite 1280, in der Kachel eingepasst (contain) und zentriert, skaliert mit der Kachelgroesse"
|
||||
- "Vorschau der Seite in den Einstellungen mit verschieb- und ziehbarem Rahmen (vier Ecken) plus Zahlenfelder Links/Oben/Breite/Hoehe"
|
||||
- "Vergroesserung 50..150 % fuer die Ganzseiten-Ansicht ohne Ausschnitt"
|
||||
- "„Nur anzeigen“: transparente Flaeche ueber dem Rahmen im Ansichtsmodus"
|
||||
- "Test-Helfer stubResizeObserver({ width, height }) fuer deterministische Geometrie in jsdom"
|
||||
affects:
|
||||
- "apps/web/src/messages/de.json + en.json (13 neue Schluessel widgets.xframe.*)"
|
||||
- "apps/web/src/messages/umlaut-dictionary.ts (Allowlist: Ausschnitt)"
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Cross-origin laesst sich die Seite nicht von aussen scrollen: der <iframe> selbst wird mit left/top verschoben, mit transform: scale skaliert und von einem Clip-div geschnitten; feste Layoutbreite 1280 haelt den Seitenaufbau stabil"
|
||||
- "EINE Klemmregel clampXframeCrop fuer Resolver, Zahlenfelder und Ziehen — Werte werden verschoben/gekappt, nie abgewiesen (T-GE2-05)"
|
||||
- "Reine Geometrie (computeCropLayout, applyCropDrag) ohne React in eigenem Modul, zuerst rot getestet"
|
||||
- "Pointer-Events mit Capture-Waechter (typeof setPointerCapture) — Browser faengt, jsdom nicht; Deltas statt getBoundingClientRect"
|
||||
- "Kein Test-Prop im Produktionscode: Kachelgroesse im Test ueber dateiweisen ResizeObserver-Stub (ein Cast as ResizeObserverEntry)"
|
||||
key-files:
|
||||
created:
|
||||
- apps/web/src/components/dashboard/widgets/xframe-crop.ts
|
||||
- apps/web/src/components/dashboard/widgets/xframe-crop.test.ts
|
||||
- apps/web/src/test/fake-resize-observer.ts
|
||||
modified:
|
||||
- apps/web/src/components/dashboard/widgets/xframe-config.ts
|
||||
- apps/web/src/components/dashboard/widgets/xframe-config.test.ts
|
||||
- apps/web/src/components/dashboard/widgets/xframe-widget.tsx
|
||||
- apps/web/src/components/dashboard/widgets/xframe-widget.test.tsx
|
||||
- apps/web/src/components/settings/xframe-config-form.tsx
|
||||
- apps/web/src/components/settings/xframe-config-form.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
|
||||
decisions:
|
||||
- "Rahmen der Vorschau als <fieldset aria-label> statt <div role=\"group\">: Biome useSemanticElements meldete role=group (web 53 -> 54), biome-ignore ist verboten; das <fieldset> traegt die Gruppen-Rolle implizit, getByRole('group', { name }) findet es, Preflight nimmt Rand/Innenabstand (m-0 p-0 min-w-0 zusaetzlich)"
|
||||
- "Objekt-Pruefung im Resolver ueber Typwaechter isRecord(raw): raw is Record<string, unknown> statt Zuweisung — `const r: Record<string, unknown> = raw` kompiliert unter strict nicht (object ohne Index-Signatur), ein Cast waere gegen die Regel"
|
||||
- "Kopfkommentar des Test-Helfers ohne die woertliche Phrase des Zaehlers — der grep zaehlt Kommentare mit"
|
||||
metrics:
|
||||
duration: "ca. 10 min (10:03 bis 10:14 Uhr UTC, 22.09.2026)"
|
||||
completed: 2026-09-22
|
||||
actuals:
|
||||
tokens: 17700
|
||||
tasks: 2
|
||||
commits: 2
|
||||
plan_head_before: 5aa577a
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-ge2: XFrame-Widget — Ausschnitt, Zoom, „Nur anzeigen“ Summary
|
||||
|
||||
Das XFrame-Widget zeigt auf Wunsch nur einen Ausschnitt der eingebetteten
|
||||
Seite: in den Einstellungen erscheint eine Vorschau der Seite bei fester
|
||||
Breite 1280 px, darueber ein Rahmen, den der Benutzer verschiebt und an
|
||||
den vier Ecken zieht (oder Links/Oben/Breite/Hoehe eintippt); die Kachel
|
||||
zeigt genau diesen Ausschnitt, eingepasst und zentriert, und skaliert ihn
|
||||
mit der Kachelgroesse. Ohne Ausschnitt gibt es eine Vergroesserung
|
||||
50–150 % fuer die ganze Seite; „Nur anzeigen“ sperrt Klicken und Scrollen
|
||||
im Rahmen und wird beim Einschalten des Ausschnitts automatisch mit
|
||||
gesetzt. Sandbox, no-referrer, `allow=""`, Neuladen, Kopfleiste und
|
||||
Bearbeitungsflaeche sind unveraendert; der Server ruft die Adresse
|
||||
weiterhin nie ab. Alle Tore gruen.
|
||||
|
||||
## Was gebaut wurde
|
||||
|
||||
**Resolver (`xframe-config.ts`, Commit 445b1d3).** Neue Exporte
|
||||
`XFRAME_PAGE_WIDTH` 1280, `XFRAME_CROP_MIN_W` 100, `XFRAME_CROP_MIN_H` 60,
|
||||
`XFRAME_CROP_MAX_H` 4000, `XFRAME_CROP_DEFAULT` {0,0,1280,720},
|
||||
`XFRAME_ZOOM_OPTIONS` [50,60,75,90,100,125,150], `XFRAME_ZOOM_DEFAULT` 100,
|
||||
`interface XframeCrop`, `clampXframeCrop` (runden; w auf [100,1280], h auf
|
||||
[60,4000], x/y >= 0, dann `x + w > 1280 -> x = 1280 - w` — verschieben, nie
|
||||
abweisen). `XframeConfig` um `crop: XframeCrop | null`, `zoom`, `readOnly`
|
||||
erweitert; `resolveCrop` nimmt nur ein Nicht-null-Objekt mit vier endlichen
|
||||
Zahlen (Typwaechter `isRecord`, kein Cast), `resolveZoom` liefert die
|
||||
groesste Stufe <= n (unter 50 -> 50, nicht endlich -> 100), `resolveReadOnly`
|
||||
nur bei `=== true`. Der Dateikopf erklaert, warum geklemmt und nie
|
||||
abgewiesen wird (T-GE2-05) und dass `zoom` nur ohne Ausschnitt wirkt.
|
||||
|
||||
**Geometrie (`xframe-crop.ts`, NEU, ohne React).** `XFRAME_PREVIEW_PAGE_HEIGHT`
|
||||
3000; `computeCropLayout(crop, tileW, tileH)` -> `{ scale, left, top,
|
||||
frameHeight }` mit `scale = min(tileW/w, tileH/h)` (contain, darf > 1 sein),
|
||||
Zentrierung im Rest, `frameHeight = max(y + h, 720)`; Nullkachel ->
|
||||
`scale 0` (nichts rendern, keine Division durch 0). `applyCropDrag(mode,
|
||||
start, dxPage, dyPage)`: `move` verschiebt bei fester Groesse und klemmt an
|
||||
den Seitenraendern; `nw/ne/sw/se` bewegen nur die zwei Kanten ihrer Ecke,
|
||||
die Gegenecke bleibt, Mindestgroesse an der bewegten Kante; Ergebnis durch
|
||||
`clampXframeCrop`.
|
||||
|
||||
**Widget (`xframe-widget.tsx`).** Kachelkoerper mit `ref`, `overflow-hidden`
|
||||
und — nur bei Ausschnitt — `data-tile-size="WxH"`; ein `useEffect` haengt
|
||||
nur bei `crop !== null` einen `ResizeObserver` an (Muster dashboard-grid),
|
||||
Aufraeumfunktion `disconnect()`. Drei Render-Zweige mit identischen
|
||||
`frameAttrs` (src, title, sandbox, allow, referrerPolicy, loading,
|
||||
data-testid, data-reload-nonce) und `key`: (1) Ausschnitt — Clip-`div`
|
||||
(`xframe-crop-clip`, absolut, `overflow: hidden`, Groesse `w·scale ×
|
||||
h·scale`, Position `left/top` aus dem Layout) mit dem `<iframe>` darin
|
||||
(`left = -x·scale`, `top = -y·scale`, `width 1280`, `height frameHeight`,
|
||||
`transform: scale(s)`, `transformOrigin 0 0`, Klasse ohne `h-full w-full`),
|
||||
gerendert erst ab `scale > 0`; (2) Zoom ≠ 100 ohne Ausschnitt — absolut
|
||||
positionierter Rahmen mit `width/height = 10000/z %` (60 % -> 166.67 %) und
|
||||
`transform: scale(z)`; (3) 100 % — exakt wie bisher, kein `style`. Nach dem
|
||||
Rahmen: `isEditMode` -> `xframe-edit-overlay`; `!isEditMode && readOnly` ->
|
||||
`xframe-readonly-overlay` (`absolute inset-0`, `aria-hidden`); nie beide.
|
||||
Ecksymbol-Link mit `z-10` bleibt ueber beiden. Dateikopf: warum der Rahmen
|
||||
selbst verschoben und skaliert wird (cross-origin kein Scrollen von
|
||||
aussen), warum die Layoutbreite fest 1280 ist, und dass readOnly
|
||||
Bedienkomfort und keine Sicherheitsmassnahme ist (T-GE2-02).
|
||||
|
||||
**Formular (`xframe-config-form.tsx`).** Unter dem Intervall-Feld in dieser
|
||||
Reihenfolge: Checkbox `xframe-crop-enable` (Einschalten -> EIN `onChange({
|
||||
crop: XFRAME_CROP_DEFAULT, readOnly: true })`, Ausschalten -> `{ crop: null
|
||||
}`); bei Ausschnitt Hinweis `cropPreviewHint`, `CropPreview` und
|
||||
`CropNumberFields` (mit `key` aus den vier Werten -> State-Reset nach
|
||||
Ziehen/Speichern); ohne Ausschnitt die Zoom-Auswahl `xframe-zoom`; Checkbox
|
||||
`xframe-readonly`; dauerhaft `cropHint`. `CropPreview`: aeusserer Behaelter
|
||||
420 px hoch mit eigenem Bildlauf, Breite per `ResizeObserver`, `p = width /
|
||||
1280` (vor der Messung 0.5); Buehne `1280·p × 3000·p` mit dem Vorschau-
|
||||
`<iframe>` (dieselbe `XFRAME_SANDBOX`, `allow=""`, `no-referrer`, `lazy`,
|
||||
`width 1280`, `height 3000`, `transform: scale(p)`, `pointer-events: none`,
|
||||
T-GE2-01/-03) und dem Rahmen (`<fieldset aria-label>`, `xframe-crop-rect`,
|
||||
`cursor-move touch-none border-2 border-primary`) mit vier Griffen
|
||||
(`xframe-crop-handle-{nw,ne,sw,se}`, `h-3 w-3 bg-primary`, Ecken-Cursor).
|
||||
Pointer-Logik als Fabrik `dragProps(mode)`: `onPointerDown` nur Haupttaste,
|
||||
Griffe `stopPropagation`, Zugzustand in `dragRef`, Capture per
|
||||
`capturePointer` (typeof-Waechter); `onPointerMove` -> Entwurf aus
|
||||
`applyCropDrag` mit Deltas `/ p`; `onPointerUp` -> Capture loesen, `onCommit`
|
||||
nur bei Aenderung gegenueber `crop` (`sameCrop`); `onPointerCancel` ->
|
||||
verwerfen ohne Aufruf; alle drei pruefen `drag.mode === mode`, damit das
|
||||
Bubbling vom Griff zum Rahmen (jsdom ohne Capture) nichts doppelt
|
||||
ausloest. Ohne Adresse statt der Buehne `xframe-crop-preview-empty`.
|
||||
`CropNumberFields`: vier `type="number"`-Felder `xframe-crop-{x,y,w,h}` im
|
||||
4er-Raster, Uebernahme bei Blur/Enter, `Number(draft)` endlich ->
|
||||
`clampXframeCrop({ ...crop, [k]: n })`, leer/`abc`/unveraendert -> nichts;
|
||||
darunter `cropUnitHint`.
|
||||
|
||||
**Test-Helfer (`apps/web/src/test/fake-resize-observer.ts`, NEU).**
|
||||
`stubResizeObserver({ width, height })` stubbt `ResizeObserver` dateiweise
|
||||
per `vi.stubGlobal` mit `DOMRectReadOnly`-`contentRect` — ein einziger Cast
|
||||
`as ResizeObserverEntry`; Aufraeumen per `vi.unstubAllGlobals()`. Kein
|
||||
`measuredSize`-Prop im Produktionscode.
|
||||
|
||||
**Uebersetzungen.** 13 neue Schluessel unter `widgets.xframe` in de UND en
|
||||
(28 = 28 Schluessel gesamt, Gleichheit per Skript geprueft), Deutsch mit
|
||||
„Sie“; `Ausschnitt` auf der Umlaut-Allowlist nach `neuem`.
|
||||
|
||||
**Doku (Commit 30fdd99).** Changelog-Stichpunkt direkt nach dem
|
||||
XFrame-Stichpunkt unter „Unveroeffentlicht -> Neu“; Anwenderhandbuch: ein
|
||||
Satz in der Tabellenzeile „XFrame“ und ein Satz am Ende des Absatzes
|
||||
„Dashboard > Widgets“.
|
||||
|
||||
## Die Tests, und der Beleg dass sie rot waren
|
||||
|
||||
| Datei | Faelle | Rot-Lauf (vor der Umsetzung) |
|
||||
|---|---:|---|
|
||||
| `xframe-crop.test.ts` | 11 (neu) | `pnpm --filter @tessera/web exec vitest run src/components/dashboard/widgets/xframe-config.test.ts src/components/dashboard/widgets/xframe-crop.test.ts src/components/dashboard/widgets/xframe-widget.test.tsx` -> `Error: Failed to resolve import "./xframe-crop" from "src/components/dashboard/widgets/xframe-crop.test.ts". Does the file exist?` (0 test) |
|
||||
| `xframe-config.test.ts` | 21 (12 + 9) | derselbe Lauf -> `21 tests \| 11 failed` (Tests 1 und 8 mit erweitertem `toEqual`, Tests 13–21 neu; Fehlerstellen Zeilen 26/78/97/106/111/118/128/139/146/161/172) |
|
||||
| `xframe-widget.test.tsx` | 19 (12 + 7) | derselbe Lauf -> `19 tests \| 6 failed` (Tests 13, 14, 15, 16, 17, 19: `Unable to find an element by: [data-testid="xframe-crop-clip"]` bzw. `xframe-readonly-overlay`; Test 18 war schon gruen, weil er nur Abwesenheit prueft) — gesamt `Test Files 3 failed (3), Tests 17 failed \| 23 passed (40)` |
|
||||
| `xframe-config-form.test.tsx` | 17 (8 + 9) | nach den Uebersetzungs-Schluesseln, vor dem Formular geschrieben: `pnpm --filter @tessera/web exec vitest run src/components/settings/xframe-config-form.test.tsx` -> `Test Files 1 failed (1), Tests 17 failed (17)` (der erweiterte `renderForm`-Helfer sucht die Labels `cropEnable`/`readOnly`, die das alte Formular nicht rendert — dadurch auch Tests 1–8 rot); erster Lauf nach der Umsetzung 17/17 |
|
||||
|
||||
Nach der Umsetzung: Resolver 21/21, Geometrie 11/11, Widget 19/19, Formular
|
||||
17/17, `src/messages` 6/6, `widget-settings-panel.test.tsx` unveraendert
|
||||
gruen (7 Dateien, 86 Faelle im `<verify>`-Lauf). Zusammen 36 neue Faelle;
|
||||
Web-Tests 604 -> **640** (Plan: >= 634).
|
||||
|
||||
## Messungen (Endstand, HEAD 30fdd99)
|
||||
|
||||
| Groesse | Ausgang (5aa577a) | Jetzt |
|
||||
|---|---:|---:|
|
||||
| `pnpm type-check` | 4/4 | 4/4 |
|
||||
| `pnpm lint` | 5/5 | 5/5 (api 74 Warnungen, web **53**, keine Stufe `error`) |
|
||||
| API-Tests | 1175 | **1175** (75 Dateien) |
|
||||
| Web-Tests | 604 | **640** (81 Dateien) |
|
||||
| `as unknown as` in apps/api/src | 27 | 27 |
|
||||
| `as unknown as` in apps/web/src | 6 | 6 (davon 1 in test/setup.ts) |
|
||||
| `noNonNullAssertion` in apps/api/src (biome) | 56 | 56 |
|
||||
| `noExplicitAny` in apps/api/src (biome) | 13 | 13 |
|
||||
| `biome-ignore` in apps/api/src | 1 | 1 |
|
||||
| `ts-expect-error` | 0 | 0 |
|
||||
| `dangerouslySetInnerHTML` / `!` / `any` in den angefassten Web-Dateien | – | 0 / 0 / 0 |
|
||||
| de/en-Schluesselgleichheit `widgets.xframe` | 15 = 15 | 28 = 28 |
|
||||
|
||||
Keine neue `any`, kein `!`, kein neues Paket (T-GE2-SC nicht ausgeloest),
|
||||
kein Prisma-/Schema-Pfad, keine Aenderung an Panel, Registry, Katalog oder
|
||||
API.
|
||||
|
||||
## Pruefliste fuer den Browser-Rundgang (Orchestrator, lokal, Playwright-MCP — NICHT Testserver)
|
||||
|
||||
- [x] (a) Einstellungen -> Dashboard -> XFrame mit `https://example.com`: „Nur einen Ausschnitt der Seite anzeigen“ anhaken -> genau EIN PATCH mit `crop: {x:0,y:0,w:1280,h:720}` UND `readOnly: true`; Vorschau (420 px hoch, scrollbar) zeigt die Seite bei 1280 px Breite mit blauem Rahmen ueber der ganzen Breite; Zahlenfelder zeigen 0/0/1280/720; die Zoom-Auswahl ist verschwunden
|
||||
- [x] (b) Rahmen mit der Maus verschieben -> folgt fluessig, beim Loslassen genau EIN PATCH, Zahlenfelder springen auf die neuen Werte
|
||||
- [x] (c) Ecke unten rechts ziehen -> Groesse aendert sich, obere linke Ecke bleibt, ein PATCH; Ecke oben links bis unter die Mindestbreite ziehen -> Rahmen bleibt 100 px breit, rechte Kante steht
|
||||
- [x] (d) Zahlenfeld Breite auf 2000 + Feld verlassen -> Feld zeigt 1280, Links springt auf 0; Hoehe 10 -> 60
|
||||
- [x] (e) Dashboard: die Kachel zeigt genau den Ausschnitt, eingepasst und zentriert (DOM: `xframe-crop-clip`, `data-tile-size` am Koerper, `<iframe>` mit `transform: scale(…)`); `sandbox`, `referrerpolicy=no-referrer`, `allow=""` unveraendert
|
||||
- [x] (f) Kachel im Bearbeitungsmodus vergroessern/verkleinern -> nach dem Speichern skaliert der Ausschnitt mit und bleibt ganz sichtbar; Ziehen ueber dem Rahmen funktioniert weiterhin (`xframe-edit-overlay`)
|
||||
- [x] (g) „Nur anzeigen“ an: Klick und Mausrad im Rahmen bewirken nichts (DOM: `xframe-readonly-overlay`), „In neuem Tab öffnen“ klickt weiterhin; aus: Seite bedienbar
|
||||
- [x] (h) Ausschnitt abhaken -> PATCH `crop: null`, Zoom-Auswahl erscheint; Zoom 60 % -> Kachel zeigt die Seite verkleinert (DOM: `width: 166.67%`, `transform: scale(0.6)`), 100 % -> kein `style`
|
||||
- [x] (i) verweigernde Seite (`https://www.google.com`) -> Vorschau und Kachel bleiben leer, Formular-Hinweise stehen; API-Log ohne Abruf der Fremdadresse
|
||||
|
||||
**Rundgang durch den Orchestrator am 22.09.2026 (lokaler Stack, Abbilder aus cf70a19, Playwright-MCP):** alle neun Punkte bestanden. Belege: (a) Anhaken → genau ein PATCH `{crop:{0,0,1280,720}, readOnly:true}`, Vorschau 403 px hoch mit Bildlaufleiste, Rahmen ueber die ganze Breite (Akzentfarbe gelb, nicht blau — die Kachelfarbe des Nutzers), Felder 0/0/1280/720, Zoom-Auswahl weg; (b) Verschieben folgt der Maus (200 px waehrend des Ziehens), beim Loslassen ein PATCH `{x:194,y:97,…}` (= 200/1,03), Felder springen mit; (c) Ecke unten rechts: obere linke Ecke bleibt (714/413), Groesse 620x310 → 739x389; Ecke oben links weit nach rechts: Breite bleibt 100 px (103 px Bildschirm), rechte Kante steht; (d) Breite 2000 → 1280 und Links → 0; Hoehe 10 → 60; (e) Kachel 933x601 zeigt genau den Ausschnitt 256/430/768/200 (`xframe-crop-clip` 933x243 bei top 179, `<iframe>` `left -311px; top -522px; scale(1.21)`), Sandbox/`no-referrer`/`allow=""` unveraendert; (f) Kachel auf 663x321 verkleinert → Ausschnitt skaliert auf 0,86 und bleibt ganz sichtbar, `xframe-edit-overlay` beim Ziehen da; (g) „Nur anzeigen“: Klick auf „Learn more“ im Rahmen bewirkt nichts (Frame bleibt example.com), `xframe-readonly-overlay` vorhanden, Link „In neuem Tab öffnen“ da; aus → Klick an der auf die Skalierung umgerechneten Stelle navigiert den Rahmen (Playwright selbst kann in einem per `transform` skalierten iframe nicht klicken — Werkzeuggrenze, per `elementFromPoint` + `mouse.click` umgangen); (h) Abhaken → PATCH `{crop:null}`, Zoom-Auswahl erscheint, 60 % → `width: 166.67%; height: 166.67%; transform: scale(0.6)`; (i) google.com → Vorschau-Rahmen und Kachel leer, beide Hinweise stehen, API-Log ohne Treffer.
|
||||
|
||||
**Ein Befund aus dem Rundgang, behoben in cf70a19:** die Kachel nutzte als Layouthoehe des Rahmens `max(crop.y + crop.h, 720)`, die Vorschau 3000 px. example.com setzt `margin: 15vh` — die Ueberschrift lag in der Vorschau bei y 450, in der Kachel mit 720 px Rahmen bei y 108; der in der Vorschau gewaehlte Ausschnitt haette in der Kachel etwas anderes gezeigt. Jetzt nutzt die Kachel dieselbe Layouthoehe wie die Vorschau (`XFRAME_PREVIEW_PAGE_HEIGHT`), damit vh-relative Seiten identisch umbrechen (Tests 4/13 angepasst). Zweitens ein 4-px-Querbalken in der Vorschau durch die Eckgriffe am rechten Rand → `overflow-x-hidden`. Web-Tests 640 unveraendert gruen.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
1. **[Rule 3 - Blocking] Biome `useSemanticElements`** meldete den Rahmen
|
||||
`<div role="group">` als neue Warnung (web 53 -> 54); `biome-ignore` ist
|
||||
verboten. Der Rahmen ist jetzt ein `<fieldset aria-label=…>` (implizite
|
||||
Gruppen-Rolle; `getByRole('group', { name })` findet ihn; Klassen
|
||||
zusaetzlich `m-0 p-0 min-w-0` gegen Browser-Vorgaben). Formular-Test 10
|
||||
prueft `tagName === 'FIELDSET'` statt `getAttribute('role')`. Die
|
||||
Pointer-Handler nehmen `PointerEvent<HTMLElement>`. Web-Lint wieder 53.
|
||||
Aufgabe 1, Commit 445b1d3.
|
||||
2. **[Rule 3 - Blocking] `resolveCrop`:** die im Plan skizzierte Zuweisung
|
||||
`const record: Record<string, unknown> = raw` kompiliert unter `strict`
|
||||
nicht (`object` ohne Index-Signatur, TS2322). Statt eines Casts ein
|
||||
Typwaechter `isRecord(raw): raw is Record<string, unknown>`. Aufgabe 1,
|
||||
Commit 445b1d3.
|
||||
3. **Zaehler-Falle:** der Kopfkommentar des Test-Helfers enthielt die
|
||||
Phrase „No `as unknown as`“ und liess den grep-Zaehler auf 7 springen;
|
||||
umformuliert („no double cast through `unknown`“). Kein Code betroffen.
|
||||
4. **`onPointerCancel` prueft wie Move/Up den Zugmodus** (`drag.mode ===
|
||||
mode`), damit ein Abbruch am Griff nicht zusaetzlich den Rahmen-Handler
|
||||
durchlaeuft (Bubbling ohne Capture in jsdom). Verhalten im Test 14
|
||||
unveraendert.
|
||||
5. **Commit-Nachricht von Aufgabe 1 einmal per `--amend` korrigiert**
|
||||
(vor jedem weiteren Commit, nichts baute darauf): die Testzahl stand
|
||||
zunaechst als 638, gemessen sind 640. Der endgueltige Hash ist 445b1d3.
|
||||
6. **Zwei Widget-Faelle mehr** als die geforderten sechs (Test 18 und 19
|
||||
getrennt), ein Resolver-Fall mehr (Test 21 `clampXframeCrop`).
|
||||
|
||||
Nicht geaendert: `STATE.md`, `ROADMAP.md`, `.planning/**` (ausser dieser
|
||||
Akte), keine neue Abhaengigkeit, kein Deploy, kein Zugriff auf den
|
||||
Testserver. Ein lokaler Docker-Stack wurde nicht angefasst.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
Keine. Kette verdrahtet: Checkbox -> `onChange({ crop, readOnly })` ->
|
||||
`PATCH /dashboard/widgets/:id/config` (flache Zusammenfuehrung, bestehend)
|
||||
-> `resolveXframeConfig` -> `crop !== null` -> `ResizeObserver` ->
|
||||
`computeCropLayout` -> Clip + `<iframe style>`; Rahmen `pointerdown/move/up`
|
||||
-> `applyCropDrag` -> Entwurf -> `clampXframeCrop` -> EIN `onChange`;
|
||||
Zahlenfelder -> `clampXframeCrop` -> `onChange`; `zoom` -> Prozentmasse +
|
||||
`scale`; `readOnly` -> Flaeche nur im Ansichtsmodus.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
Keine neue Flaeche ausserhalb des `<threat_model>` des Plans: T-GE2-01
|
||||
(beide Rahmen mit exakt `XFRAME_SANDBOX`, `allow=""`, no-referrer —
|
||||
Widget-Test 13/15, Formular-Test 10), T-GE2-03 (`pointer-events: none` der
|
||||
Vorschau — Formular-Test 10), T-GE2-04 (keine API-Aenderung — Rundgang i),
|
||||
T-GE2-05 (Klemmung — Resolver-Tests 13–17, Geometrie-Test 5), T-GE2-06
|
||||
(nur Zahlen/Template-Strings in Styles, Texte aus Sprachdateien), T-GE2-02
|
||||
und -07 im Dateikopf des Widgets bzw. Formulars ausdruecklich als
|
||||
Bedienkomfort/Capture ohne Rechte benannt.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Alle 3 neu angelegten Dateien liegen auf der Platte, die zwei Commits
|
||||
445b1d3 und 30fdd99 sind in `git log` auffindbar
|
||||
(`git rev-list --count 5aa577a..HEAD` = 2). Die Zahlen der Tabelle stammen
|
||||
aus tatsaechlich gelaufenen Befehlen.
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
phase: quick-260922-hk4
|
||||
plan: 01
|
||||
type: tdd
|
||||
autonomous: true
|
||||
subsystem: apps/api/src/dashboard
|
||||
requirements: []
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-hk4: Bilderrahmen-Bilder auf die Festplatte statt in die Datenbank
|
||||
|
||||
## Warum (Entscheidung des Nutzers, 22.09.2026)
|
||||
|
||||
Der Bilderrahmen legte die Bilddaten als `bytea` in der Datenbank ab
|
||||
(quick-260921-pi9). Der Nutzer hat nach der Freigabe 1.3.0 gefragt, ob das auf
|
||||
Dauer sinnvoll ist. Befund und Entscheidung:
|
||||
|
||||
- **Geschwindigkeit ist NICHT das Argument.** Ein Bild wird je Browser einmal
|
||||
taeglich geladen (`Cache-Control: private, max-age=86400`); ein paar hundert
|
||||
Kilobyte aus Postgres kosten nichts gegen die uebrige Last.
|
||||
- **Die Sicherung ist das Argument.** Gesichert wird von Hand per `pg_dump`
|
||||
(docs/anleitung-betrieb.md Kap. 6). Jedes Bild waechst in diesen Abzug hinein:
|
||||
30 Bilder à 5 MiB je Benutzer sind im Extremfall 150 MB **pro Benutzer**. Die
|
||||
alpha-Datenbank ist heute 18 MB gross (gemessen 22.09.), da faellt das sofort auf.
|
||||
- **Einheitlichkeit.** Tessera speichert Dateien laengst im Volume `user-files`:
|
||||
Profilbilder unter `user-files/avatars/<userId>.<ext>` mit `User.avatarPath` in
|
||||
der Datenbank (`user.controller.ts`), DKV-Exporte daneben mit ausschliesslich
|
||||
servergenerierten Dateinamen (`dkv-export.service.ts`). Der Bilderrahmen war der
|
||||
Ausreisser.
|
||||
- **Ein eigener Ordner je Benutzer ist KEIN Schutz.** Wer welches Bild sehen darf,
|
||||
entscheidet weiterhin der Server (Besitzpruefung + RLS-Regel). Getrennte Ordner
|
||||
bringen zusaetzlich die Gefahr von Dateinamen, die aus dem Ordner herausfuehren —
|
||||
dagegen hilft nur, was DKV schon macht: der Server vergibt den Dateinamen, nie
|
||||
der Client.
|
||||
|
||||
Bestand: alpha 3 Bilder / 1,8 MB, Live 0 (noch nicht gezogen), lokal 1–2. Der
|
||||
Umzug ist jetzt praktisch kostenlos.
|
||||
|
||||
## Gebundene Entscheidungen (Orchestrator)
|
||||
|
||||
1. **Ablage:** `user-files/dashboard-images/<userId>/<imageId>.<ext>` — ein Ordner
|
||||
je Benutzer, Dateiname ist die UUID der Datenbankzeile plus Endung aus dem
|
||||
ERKANNTEN Mime-Typ (`png|jpg|gif|webp`). Kein Byte aus der Anfrage geht in den
|
||||
Pfad. Verzeichnis-Aufloesung nach dem Muster `resolveAvatarsDir()`
|
||||
(`path.resolve(__dirname, '..', '..', '..', '..', 'user-files', ...)`), als
|
||||
eigene Funktion `resolveDashboardImagesDir()` im Dienst.
|
||||
2. **Datenbank:** Spalte `data Bytes` entfaellt, neu `storagePath String` (relativ
|
||||
zur Monorepo-Wurzel, wie `User.avatarPath`: `user-files/dashboard-images/...`).
|
||||
Rest der Zeile unveraendert (id, userId, tenantId, originalName, mimeType, size,
|
||||
createdAt), RLS-Regel und Indizes bleiben.
|
||||
3. **Migration `20260922120000_dashboard_image_to_disk`** in zwei Schritten, weil
|
||||
die vorhandenen Bytes nicht verloren gehen duerfen:
|
||||
- SQL-Migration: `ALTER TABLE "DashboardImage" ADD COLUMN "storagePath" TEXT;`
|
||||
(erst NULLbar), **nicht** sofort `DROP COLUMN "data"`.
|
||||
- Einmal-Skript `apps/api/scripts/migrate-dashboard-images-to-disk.ts`
|
||||
(ausfuehrbar per `pnpm --filter @tessera/api exec tsx scripts/...`, tsx ist
|
||||
vorhanden — sonst `ts-node`/kompiliertes JS; pruefen): liest alle Zeilen mit
|
||||
`data IS NOT NULL`, schreibt die Datei, setzt `storagePath`, laesst `data`
|
||||
stehen. Idempotent (vorhandene Datei + gesetzter `storagePath` = ueberspringen).
|
||||
- Zweite SQL-Migration `20260922120100_dashboard_image_drop_data`:
|
||||
`ALTER TABLE "DashboardImage" ALTER COLUMN "storagePath" SET NOT NULL;` und
|
||||
`ALTER TABLE "DashboardImage" DROP COLUMN "data";`.
|
||||
**Reihenfolge fuer den Betrieb dokumentieren:** beide Migrationen laufen beim
|
||||
Start automatisch (`migrate deploy`), das Umzugs-Skript liegt DAZWISCHEN. Damit
|
||||
das ohne Handarbeit klappt, macht der Dienst den Umzug selbst: siehe Punkt 4.
|
||||
4. **Automatischer Umzug beim Start statt Handarbeit** (der Nutzer soll nichts
|
||||
ausfuehren muessen): `DashboardImagesService` bekommt `onApplicationBootstrap()`,
|
||||
das alle Zeilen ohne `storagePath` einsammelt, die Bytes per rohem SQL liest
|
||||
(`$queryRaw` auf `data`, weil die Spalte dann nicht mehr im Prisma-Modell steht —
|
||||
deshalb liegt der DROP in einer SPAETEREN Migration, die erst in der naechsten
|
||||
Freigabe scharf geschaltet wird), die Datei schreibt und `storagePath` setzt.
|
||||
**Konsequenz fuer diese Aufgabe: die DROP-Migration wird NICHT mitgeliefert.**
|
||||
Sie bekommt einen Platzhalter-Eintrag in `.planning/todos/pending/` und kommt,
|
||||
wenn alle Server einmal mit dieser Version gelaufen sind. Begruendung im
|
||||
Migrations-Kommentar festhalten (Muster: zweistufige Umstellung).
|
||||
Der Bootstrap laeuft ueber den Systemkontext (`forSystem()`, Muster
|
||||
`dkv`-Scheduler), nicht ueber einen Mandantenklienten, und protokolliert
|
||||
„N Bilder auf die Festplatte umgezogen" bzw. schweigt bei 0.
|
||||
5. **Dienst:** `upload` schreibt die Datei (`fs.promises.mkdir(..., {recursive:true})`
|
||||
+ `writeFile`) NACH dem erfolgreichen `create` (Reihenfolge: Zeile zuerst, damit
|
||||
die UUID feststeht; schlaegt das Schreiben fehl, Zeile wieder loeschen und
|
||||
`InternalServerErrorException`). `getBytes` liest die Datei und liefert
|
||||
`{ mimeType, data }` wie bisher; fehlt die Datei, `NotFoundException` (Kachel
|
||||
zeigt dann „Bild nicht verfügbar", schon gebaut). `remove` loescht Zeile und
|
||||
Datei (Datei-Fehler werden geschluckt und protokolliert — eine Dateileiche ist
|
||||
harmloser als eine haengende Loeschung). `list` unveraendert.
|
||||
Der Controller bleibt unveraendert (gleiche Routen, gleiche fuenf Header).
|
||||
6. **Betriebsanleitung:** in Kapitel 6 den Satz zu `user-files` um die
|
||||
Bilderrahmen-Bilder ergaenzen (dort steht schon, wie das Volume gesichert wird);
|
||||
im Anwenderhandbuch nichts aendern (fuer Anwender aendert sich nichts).
|
||||
CHANGELOG unter „Unveröffentlicht → Geändert": „Bilderrahmen: hochgeladene
|
||||
Bilder liegen jetzt im Dateibereich des Servers statt in der Datenbank — die
|
||||
Datenbanksicherung bleibt dadurch klein; vorhandene Bilder ziehen beim ersten
|
||||
Start automatisch um" (kein Fliesstext).
|
||||
7. **Tests:** Dienst-Tests mit `memfs` ODER einem temporaeren Verzeichnis
|
||||
(`fs.mkdtempSync(os.tmpdir())`) — pruefen, was im Repo schon genutzt wird
|
||||
(`user.controller.spec.ts` fuer Avatare ansehen und demselben Muster folgen).
|
||||
Mindestens: Upload legt Datei unter `<dir>/<userId>/<id>.png` an und speichert
|
||||
`storagePath`; Upload mit fehlschlagendem Schreiben loescht die Zeile wieder;
|
||||
`getBytes` liefert den Dateiinhalt; fehlende Datei → 404; fremder Benutzer → 404
|
||||
(unveraendert); `remove` loescht Zeile und Datei; Dateiname enthaelt NIE
|
||||
`originalName`; Bootstrap-Umzug schreibt Datei und setzt `storagePath`,
|
||||
ueberspringt bereits umgezogene Zeilen.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Aufgabe 1: Schema, Migration, Dienst auf Dateiablage umstellen, Bootstrap-Umzug, Tests</name>
|
||||
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260922120000_dashboard_image_to_disk/migration.sql, apps/api/src/dashboard/dashboard-images.service.ts, apps/api/src/dashboard/dashboard-images.service.spec.ts, apps/api/src/dashboard/dashboard-images.controller.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md</files>
|
||||
<action>
|
||||
Entscheidungen 1–5 umsetzen. Reihenfolge: Schema + Migration, `prisma migrate deploy` + `generate` gegen die lokale Container-DB ([BLOCKING], Befehle in den Executor-Hinweisen), dann Tests rot, dann Dienst.
|
||||
Das Klassifikationsdokument braucht keine neue Zeile (Modell unveraendert gebunden), aber die Begruendungsspalte erwaehnt jetzt, dass die Bytes auf der Platte liegen und die Zeile den Pfad haelt — Zahlen nachmessen wie dort beschrieben.
|
||||
Commit: `refactor(quick-260922-hk4): Bilderrahmen-Bilder in user-files statt in der Datenbank`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/dashboard src/prisma && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/api lint</automated>
|
||||
</verify>
|
||||
<done>Migration angewendet, `storagePath` gefuellt fuer die vorhandenen lokalen Zeilen (Bootstrap nachgewiesen), Dateien liegen unter `user-files/dashboard-images/<userId>/`. Spalte `data` bleibt vorerst bestehen (zweistufig, siehe Plan). API-Tests ≥ 8 neue Faelle, RLS-Waechter unveraendert gruen. Keine `any`, Zaehler unveraendert.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Aufgabe 2: Changelog, Betriebsanleitung, Todo fuer die DROP-Migration, Voll-Tore</name>
|
||||
<files>CHANGELOG.md, docs/anleitung-betrieb.md, .planning/todos/pending/2026-09-22-dashboard-image-data-spalte-entfernen.md</files>
|
||||
<action>
|
||||
Entscheidung 6 umsetzen. Das Todo nennt: DROP der Spalte `data` erst, wenn alpha UND live einmal mit einer Version ≥ dieser gelaufen sind (Bootstrap-Umzug erledigt), Migrationsname `20260922120100_dashboard_image_drop_data`, plus `ALTER COLUMN "storagePath" SET NOT NULL`.
|
||||
Volle Tore: `pnpm type-check`, `pnpm lint`, `pnpm --filter @tessera/api test`, `pnpm --filter @tessera/web test`.
|
||||
Commit: `docs(quick-260922-hk4): Changelog, Betriebsanleitung und Todo zur data-Spalte`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q 'Dateibereich' CHANGELOG.md && pnpm type-check && pnpm lint && pnpm --filter @tessera/api test</automated>
|
||||
</verify>
|
||||
<done>Changelog-Zeile steht unter „Unveröffentlicht → Geändert"; Betriebsanleitung Kap. 6 nennt die Bilderrahmen-Bilder beim `user-files`-Volume; Todo angelegt; alle Tore gruen; genau zwei Commits mit Scope `quick-260922-hk4`.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Hinweise fuer den Executor
|
||||
|
||||
- Lokale Migration: `IP=$(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 `prisma generate`.
|
||||
- Testserver NICHT anfassen.
|
||||
- Commits: Conventional Commits, Scope `quick-260922-hk4`, deutscher Betreff im Stil von `git log --oneline -15`, jede Commit-Nachricht endet mit
|
||||
`Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`
|
||||
- `.planning/**` NICHT committen ausser der Todo-Datei in Aufgabe 2.
|
||||
- Qualitaetsregeln wie bisher: keine neue `any`, `as unknown as` api bleibt 27, keine `!`, kein `biome-ignore`.
|
||||
|
||||
<threat_model>
|
||||
ASVS 1, block on high.
|
||||
|
||||
| ID | Bedrohung | Schwere | Disposition |
|
||||
|---|---|---|---|
|
||||
| T-HK4-01 | Pfad-Ausbruch ueber `originalName` oder Kennung aus der Anfrage | high | Dateiname = UUID der Zeile + Endung aus dem ERKANNTEN Mime-Typ; `originalName` geht nie in den Pfad (Muster DKV T-07-09). Mitigiert. |
|
||||
| T-HK4-02 | Fremdzugriff auf Bilder ueber geratene Pfade | high | Die Datei wird nie direkt ausgeliefert; nur ueber `GET /dashboard/images/:id` mit Besitzpruefung (Mandant + Benutzer) und 404 fuer Fremde. Das Volume ist nicht im Webserver eingehaengt. Mitigiert. |
|
||||
| T-HK4-03 | Datenverlust beim Umzug | high | Zweistufig: `data` bleibt vorerst stehen, Umzug ist idempotent, DROP erst nach nachgewiesenem Lauf auf beiden Servern (Todo). Mitigiert. |
|
||||
| T-HK4-04 | Halbe Zustaende (Zeile ohne Datei / Datei ohne Zeile) | medium | Upload: Zeile zuerst, bei Schreibfehler Zeile loeschen; Loeschen: Zeile zuerst, Dateifehler wird protokolliert (Dateileiche statt haengender Loeschung); fehlende Datei = 404, die Kachel zeigt „Bild nicht verfügbar". Akzeptiert und benannt. |
|
||||
| T-HK4-05 | Volume geht verloren, Datenbank ueberlebt | low | Bewusst akzeptiert (Entscheidung des Nutzers); Betriebsanleitung nennt die Sicherung des Volumes. |
|
||||
</threat_model>
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
---
|
||||
phase: quick-260922-hk4
|
||||
plan: 01
|
||||
subsystem: apps/api/src/dashboard
|
||||
tags: [bilderrahmen, dashboard, user-files, prisma-migration, rls, tdd]
|
||||
status: complete
|
||||
requires: [quick-260921-pi9]
|
||||
provides:
|
||||
- "DashboardImage.storagePath — Bilder im Dateibereich statt als bytea"
|
||||
- "DashboardImagesService.onApplicationBootstrap() — automatischer Umzug beim Start"
|
||||
- "Migration 20260922120000_dashboard_image_to_disk (Stufe 1 von 2)"
|
||||
affects:
|
||||
- apps/api/src/dashboard/dashboard-images.service.ts
|
||||
- apps/api/prisma/schema.prisma
|
||||
- apps/api/src/prisma/rls-access-inventory.spec.ts
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
- docs/anleitung-betrieb.md
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Servergenerierter Dateiname (UUID + Endung aus dem erkannten Mime-Typ), Muster dkv-export.service.ts (T-07-09)"
|
||||
- "Relativer Pfad in der Zeile, Muster User.avatarPath (user.controller.ts)"
|
||||
- "Einmal systemgebunden lesen, je Zeile mandantengebunden schreiben (Muster DkvService.loadActiveConfigsForScheduler)"
|
||||
- "Zweistufige Spaltenablösung: ADD + NULLbar jetzt, DROP nach nachgewiesenem Lauf"
|
||||
- "Dateitests gegen ein echtes Temp-Verzeichnis statt fs-Mock (Muster desktop.service.spec.ts)"
|
||||
key-files:
|
||||
created:
|
||||
- apps/api/prisma/migrations/20260922120000_dashboard_image_to_disk/migration.sql
|
||||
- .planning/todos/pending/2026-09-22-dashboard-image-data-spalte-entfernen.md
|
||||
modified:
|
||||
- apps/api/prisma/schema.prisma
|
||||
- apps/api/src/dashboard/dashboard-images.service.ts
|
||||
- apps/api/src/dashboard/dashboard-images.service.spec.ts
|
||||
- apps/api/src/prisma/rls-access-inventory.spec.ts
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
- docs/anleitung-betrieb.md
|
||||
- CHANGELOG.md
|
||||
decisions:
|
||||
- "data Bytes? bleibt im Prisma-Modell (optional) statt $queryRaw — der Bootstrap-Umzug bleibt dadurch typisiert und ohne rohes SQL; Spalte und Feld fallen gemeinsam in Stufe 2"
|
||||
- "Systemkontext (forSystem) nur im Startpfad; die vier Anfragewege bleiben ausnahmslos mandantengebunden, auch das Schreiben des Umzugs"
|
||||
- "Neue Regel system_read_policy auf DashboardImage, damit der Umzug nach dem Scharfschalten der Datenbankrolle nicht stumm nichts findet"
|
||||
- "Testschalter DASHBOARD_IMAGES_DIR (Muster DESKTOP_DIST_DIR) statt fs-Mock — die Tests schreiben und lesen wirklich"
|
||||
metrics:
|
||||
duration: "~35 min"
|
||||
completed: 2026-09-22
|
||||
actuals:
|
||||
tokens: 21000
|
||||
tasks: 2
|
||||
commits: 2
|
||||
plan_head_before: 441854a
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-hk4: Bilderrahmen-Bilder auf die Festplatte — Summary
|
||||
|
||||
Die Bilder des Bilderrahmen-Widgets liegen jetzt unter
|
||||
`user-files/dashboard-images/<userId>/<id>.<ext>`; die Datenbankzeile hält nur
|
||||
noch den relativen Pfad, und vorhandene Bilder ziehen beim ersten Start
|
||||
automatisch um — nachgewiesen gegen die lokale Datenbank.
|
||||
|
||||
## Was gebaut wurde
|
||||
|
||||
**Aufgabe 1 — Schema, Migration, Dienst, Bootstrap-Umzug, Tests** (`9039cea`)
|
||||
|
||||
- `schema.prisma`: `data Bytes` → `data Bytes?`, neu `storagePath String?`.
|
||||
- Migration `20260922120000_dashboard_image_to_disk`: `ADD COLUMN "storagePath"`,
|
||||
`ALTER COLUMN "data" DROP NOT NULL`, dazu `system_read_policy … FOR SELECT`
|
||||
auf `"DashboardImage"`. **Kein DROP** — die Begründung steht im
|
||||
Migrationskopf (Stufe 1 von 2, T-HK4-03).
|
||||
- `DashboardImagesService`:
|
||||
- `upload` legt die Zeile an (erst danach steht die UUID fest), schreibt die
|
||||
Datei, trägt `storagePath` nach; scheitert das Schreiben, wird die Zeile
|
||||
zurückgenommen und 500 geworfen.
|
||||
- `getBytes` liest die Datei; fehlender Pfad oder fehlende Datei → 404.
|
||||
- `remove` löscht Zeile und Datei (Dateifehler wird protokolliert, nicht
|
||||
geworfen).
|
||||
- `onApplicationBootstrap()` zieht Altbestand um: **einmal systemgebunden
|
||||
lesen** (`forSystem`, Zeilen ohne `storagePath` über alle Mandanten),
|
||||
**je Zeile mandantengebunden schreiben** (`forTenant(prisma, row.tenantId,
|
||||
row.userId)`), Log „N Bilderrahmen-Bilder auf die Festplatte umgezogen",
|
||||
still bei 0, wiederholbar.
|
||||
- Dateiname IMMER servergeneriert; `absoluteImagePath()` weist jeden Pfad
|
||||
zurück, der nicht im Bilderverzeichnis liegt (T-HK4-01).
|
||||
- Tests: 23 Fälle (11 neu), echtes Temp-Verzeichnis statt `fs`-Mock.
|
||||
- RLS-Wächter und Klassifikationsdokument nachgezogen (siehe Abweichungen).
|
||||
|
||||
**Aufgabe 2 — Changelog, Betriebsanleitung, Todo** (`8cbfb8b`)
|
||||
|
||||
- CHANGELOG „Unveröffentlicht → Geändert" mit der Nutzerzeile.
|
||||
- `docs/anleitung-betrieb.md` Kap. 6: `user-files` nennt die
|
||||
Bilderrahmen-Bilder und hält fest, dass `pg_dump` sie nicht mehr enthält.
|
||||
- Todo `.planning/todos/pending/2026-09-22-dashboard-image-data-spalte-entfernen.md`
|
||||
mit Vorbedingung (`storagePath IS NULL` = 0 auf alpha UND live),
|
||||
Migrationsname `20260922120100_dashboard_image_drop_data` und allen
|
||||
Nacharbeiten an Spec und Klassifikation.
|
||||
|
||||
## TDD-Nachweis (RED → GREEN)
|
||||
|
||||
- **RED** (vor der Umsetzung, `vitest run src/dashboard/dashboard-images.service.spec.ts`):
|
||||
`Tests 12 failed | 11 passed (23)`, u. a.
|
||||
`TypeError: makeService(...).onApplicationBootstrap is not a function`
|
||||
und Erwartungen an `storagePath`, die noch niemand setzte. Die 11 grünen
|
||||
Fälle sind die unveränderten Besitz-/Magic-Byte-Prüfungen aus pi9.
|
||||
- **GREEN** nach dem Dienst: `Tests 23 passed (23)`.
|
||||
- Ein RED war ein Testfehler, kein Dienstfehler: Test 17 („Datei fehlt")
|
||||
nutzte die Kennung `img-1`, für die Test 8/10 im geteilten Temp-Verzeichnis
|
||||
schon eine Datei angelegt hatten — Kennung auf `datei-fehlt` geändert.
|
||||
|
||||
## Nachweis am laufenden System (lokal, kein Testserver)
|
||||
|
||||
- `prisma migrate deploy` gegen die lokale Container-Datenbank: Migration
|
||||
`20260922120000_dashboard_image_to_disk` angewendet, danach `prisma generate`.
|
||||
- `\d "DashboardImage"`: `data` ist jetzt NULLbar, `storagePath text`,
|
||||
Policies `tenant_isolation_policy` + `system_read_policy (FOR SELECT)`.
|
||||
- Bootstrap-Umzug gegen die echte Datenbank ausgeführt (Wegwerf-Spec, danach
|
||||
gelöscht):
|
||||
- vorher: 1 Zeile, `storagePath = null`, 502 Byte in `data`
|
||||
- Log: `1 Bilderrahmen-Bilder auf die Festplatte umgezogen`
|
||||
- nachher: `storagePath = user-files/dashboard-images/1166431d-…/f43be914-….png`
|
||||
- Datei auf der Platte: 502 Byte, `PNG image data, 320 x 200` (`file`)
|
||||
- zweiter Lauf: keine Zeile mehr offen, Datei unverändert (wiederholbar)
|
||||
|
||||
## Tore
|
||||
|
||||
| Tor | Ergebnis |
|
||||
|---|---|
|
||||
| `vitest run src/dashboard src/prisma` | 147 Tests, alle grün |
|
||||
| `pnpm type-check` (4 Pakete) | grün |
|
||||
| `pnpm lint` (5 Pakete) | grün (74 API-/53 Web-Warnungen, alle vorbestehend, keine in den geänderten Dateien) |
|
||||
| `pnpm --filter @tessera/api test` | 75 Dateien, 1186 Tests grün |
|
||||
| `pnpm --filter @tessera/web test` | 81 Dateien, 640 Tests grün |
|
||||
| `as unknown as` in apps/api | 27 (unverändert) |
|
||||
| neue `any` / `!` / `biome-ignore` | keine |
|
||||
|
||||
## Abweichungen vom Plan
|
||||
|
||||
### [Regel 3 — blockierend] Das Klassifikationsdokument brauchte doch eine Änderung
|
||||
|
||||
Der Plan sagte, das Dokument brauche keine neue Zeile. Richtig — eine neue
|
||||
ZEILE nicht, aber der `forSystem()`-Aufruf im Startpfad ändert den gemessenen
|
||||
**Stand** des Paars `dashboard-images.service.ts`/`dashboardImage` von
|
||||
`gebunden` auf `system-gebunden`, und `rls-access-inventory.spec.ts` prüft
|
||||
genau diesen Wert. Zwei Tests waren rot, bis nachgezogen war:
|
||||
|
||||
- `FORSYSTEM_ALLOWED_CALL_SITES` (die Liste ist ein „genau", kein
|
||||
„mindestens") um `apps/api/src/dashboard/dashboard-images.service.ts` = 1
|
||||
erweitert, mit Begründung im Kopfkommentar: Startpfad, kein Anfrageweg;
|
||||
geschrieben wird auch dort mandantengebunden. Präzedenz:
|
||||
`ldap-config.service.ts`, dessen Nachverschlüsselung in
|
||||
`onApplicationBootstrap()` genauso gebaut ist.
|
||||
- Klassifikationsdokument: Stand `system-gebunden` mit Begründung, Zahlen der
|
||||
Bereichszeile `dashboard` mit derselben Gate-Schleife nachgemessen
|
||||
(1/18/0 → 1/21/1; +3 gebunden = Nachtragen von `storagePath`, Rücknahme bei
|
||||
Schreibfehler, Nachtragen im Umzug), Summe 187/5 → 190/6.
|
||||
|
||||
Beides ist im Todo für Stufe 2 als Rückbau vermerkt.
|
||||
|
||||
### [Regel 2 — fehlende kritische Funktionalität] `ALTER COLUMN "data" DROP NOT NULL`
|
||||
|
||||
Der Plan nannte nur `ADD COLUMN "storagePath"`. Ohne das Lockern der
|
||||
NOT-NULL-Bedingung wäre jeder neue Upload an der Datenbank gescheitert, weil
|
||||
er keine Bytes mehr in die Zeile schreibt.
|
||||
|
||||
### [Regel 2 — fehlende kritische Funktionalität] `system_read_policy` auf `"DashboardImage"`
|
||||
|
||||
Nicht im Plan. Ohne diese Regel sähe der systemgebundene Umzug nach dem
|
||||
Scharfschalten der Datenbankrolle NULL Zeilen und stellte die Arbeit stumm
|
||||
ein — genau die Falle, die Migration 20260914120000 für die fünf
|
||||
Hintergrunddienst-Tabellen geschlossen hat. Permissiv, nur `FOR SELECT`;
|
||||
Schreiben bleibt allein der Mandantenregel unterstellt.
|
||||
|
||||
### [Entscheidung] Testschalter `DASHBOARD_IMAGES_DIR`
|
||||
|
||||
Der Plan ließ die Wahl zwischen `memfs` und einem Temp-Verzeichnis. Gewählt:
|
||||
Temp-Verzeichnis (keine neue Abhängigkeit), erreichbar über die
|
||||
Umgebungsvariable `DASHBOARD_IMAGES_DIR` — dasselbe Muster, das
|
||||
`desktop.service.ts` mit `DESKTOP_DIST_DIR` schon nutzt. Im Betrieb nie
|
||||
gesetzt; ohne sie gilt der Pfad unter der Monorepo-Wurzel.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
Keine.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
Keine neue Angriffsfläche über den `<threat_model>` des Plans hinaus. Der
|
||||
einzige neue Dateipfad-Umgang ist vollständig servergeneriert und zusätzlich
|
||||
containment-geprüft (`absoluteImagePath`).
|
||||
|
||||
## Von Hand zu prüfen (nach dem nächsten `--build`-Deploy)
|
||||
|
||||
1. Bild im Bilderrahmen-Widget hochladen → erscheint in der Kachel und in der
|
||||
Verwaltung unter Einstellungen → Dashboard.
|
||||
2. Auf dem Server nachsehen:
|
||||
`docker compose exec api ls -R /app/user-files/dashboard-images` — je
|
||||
Benutzer ein Ordner, Dateiname eine UUID mit `.png`/`.jpg`/`.gif`/`.webp`,
|
||||
nie der Originalname.
|
||||
3. Bild löschen → verschwindet aus der Kachel UND die Datei ist weg
|
||||
(`ls` wie oben).
|
||||
4. Nach dem ersten Start mit dieser Version:
|
||||
`docker compose logs api | grep umgezogen` — die Zeile „N
|
||||
Bilderrahmen-Bilder auf die Festplatte umgezogen" steht genau einmal; ein
|
||||
zweiter Neustart schweigt.
|
||||
5. `docker compose exec db psql -U tessera -d tessera -c 'SELECT count(*) FROM "DashboardImage" WHERE "storagePath" IS NULL;'`
|
||||
→ muss `0` sein (Vorbedingung für Stufe 2, siehe Todo).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `apps/api/prisma/migrations/20260922120000_dashboard_image_to_disk/migration.sql` — vorhanden
|
||||
- `apps/api/src/dashboard/dashboard-images.service.ts` — vorhanden
|
||||
- `.planning/todos/pending/2026-09-22-dashboard-image-data-spalte-entfernen.md` — vorhanden
|
||||
- Commit `9039cea` — vorhanden
|
||||
- Commit `8cbfb8b` — vorhanden
|
||||
|
||||
## Rundgang durch den Orchestrator (22.09.2026, lokaler Stack, Abbilder aus dem Commit danach)
|
||||
|
||||
Bestanden, und dabei EIN Befund gefunden und behoben (eigener Commit):
|
||||
|
||||
- Hochladen ueber die Oberflaeche legt die Datei unter `user-files/dashboard-images/<userId>/<uuid>.png` im Container-Volume an; die Liste zeigt sie, die Kachel rendert sie.
|
||||
- Loeschen entfernt Zeile UND Datei (3 Dateien/3 Zeilen → 2/2, gemessen im Container und in der Datenbank).
|
||||
- **Befund:** eine Zeile zeigte auf eine Datei, die es im Container nicht gibt — der Bootstrap-Umzug war beim Bauen auf dem HOST gelaufen (Repo-Verzeichnis), der Container hat aber das Volume `user-files`. Lokal ein Artefakt, im Betrieb aber real: wer einen `pg_dump` von VOR dem Umzug zurueckspielt, waehrend das getrennt gesicherte Volume leer ist, haette Zeilen ohne Datei, obwohl die Bytes im Abzug noch stecken.
|
||||
- **Behoben:** `getBytes` schreibt die Datei in diesem Fall aus der noch vorhandenen Spalte `data` neu und liefert sie aus (Protokoll „… aus der Datenbank wiederhergestellt"); fehlt beides, bleibt es bei 404. Nachgewiesen: Abruf lieferte 200/`image/png`/502 Byte, danach lag die Datei im Container. Zwei Tests (10b, 10c), api 1186 → 1188.
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
---
|
||||
phase: quick-260922-m1h
|
||||
plan: 01
|
||||
type: refactor
|
||||
autonomous: true
|
||||
subsystem: apps/web/src/components/dashboard
|
||||
requirements: []
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-m1h: Ein Modul bringt seine Dashboard-Kachel selbst mit
|
||||
|
||||
## Warum (Auftrag des Nutzers, 22.09.2026)
|
||||
|
||||
Als Naechstes kommt ein Proxmox-Modul (PVE/PBS/PMG), das zusaetzlich als
|
||||
kompakte Kachel auf dem Dashboard erscheinen soll — und kuenftig sollen weitere
|
||||
Module dasselbe tun (PBS: Sicherungsstatus, PMG: Mail-Zahlen). Eine
|
||||
Bestandsaufnahme (lesend, 22.09.) hat ergeben:
|
||||
|
||||
- **Ein neuer Widget-Typ ist heute an SIEBEN Stellen hartkodiert**: `WidgetType`
|
||||
(Union), `WIDGET_CONSTRAINTS`, `WIDGET_REGISTRY`, eine eigene `wireXWidget()`
|
||||
je Typ, der Aufruf in `(portal)/page.tsx`, die ZWEITE Liste `WIDGET_TYPES` in
|
||||
`widget-catalog-modal.tsx` und die `@IsIn`-Whitelist in
|
||||
`apps/api/src/dashboard/dto/create-widget.dto.ts`. Vergisst man eine, fehlt die
|
||||
Kachel im Katalog oder die API lehnt sie mit 400 ab.
|
||||
- **Die Verbindung Kachel↔Modul existiert schon, ist aber leer:**
|
||||
`apps/api/src/dashboard/widget-module-map.ts` (`WIDGET_MODULE_MAP = {}`),
|
||||
gelesen von `dashboard.service.ts` — `getWidgets()` filtert Kacheln aus, deren
|
||||
Modul der Benutzer nicht hat (fail-closed, Zeile ~164-205). Das funktioniert,
|
||||
wurde nur nie benutzt.
|
||||
- **Zwei Luecken:** (a) der Katalog („Widget hinzufuegen") zeigt JEDEM alle
|
||||
Kacheln, auch die gesperrter Module — anlegen geht, danach verschwindet die
|
||||
Kachel kommentarlos; (b) eine Kachel mit unbekanntem Typ rendert leer, ohne
|
||||
Erklaerung.
|
||||
|
||||
Diese Aufgabe raeumt das auf, BEVOR Proxmox kommt. Kein neues Modul, keine neue
|
||||
Kachel — reiner Umbau mit unveraendertem Verhalten fuer die neun vorhandenen
|
||||
Kacheln.
|
||||
|
||||
## Gebundene Entscheidungen (Orchestrator)
|
||||
|
||||
1. **Eine Quelle fuer die Typliste, geteilt zwischen Web und API.** In
|
||||
`packages/shared/src/index.ts` (wird von beiden Apps bereits importiert, z. B.
|
||||
`desktop.service.ts`, `apps/web/src/lib/app-version.ts`) kommt:
|
||||
```ts
|
||||
export const WIDGET_TYPES = ['clock','search','calendar','note','calculator','favorites','stopwatch','picture-frame','xframe'] as const;
|
||||
export type WidgetType = (typeof WIDGET_TYPES)[number];
|
||||
/** Kachel → Modul-Slug; eine Kachel ohne Eintrag ist immer sichtbar. */
|
||||
export const WIDGET_MODULE_SLUGS: Partial<Record<WidgetType, string>> = {};
|
||||
```
|
||||
`create-widget.dto.ts` validiert mit `@IsIn([...WIDGET_TYPES])`, das Frontend
|
||||
leitet `WidgetType` von dort ab. `widget-module-map.ts` behaelt seine
|
||||
oeffentliche Funktion `getModuleSlugForWidgetType()`, liest aber
|
||||
`WIDGET_MODULE_SLUGS` aus `@tessera/shared` statt einer eigenen Kopie
|
||||
(Kommentar: eine Tabelle fuer beide Seiten, damit Katalogfilter und
|
||||
Server-Filter nicht auseinanderlaufen).
|
||||
2. **Eine Anmeldestelle je Kachel.** Statt neun `wireXWidget()`-Funktionen mit je
|
||||
eigenem Bool-Flag ein generisches `registerWidget(type, component)` in
|
||||
`widget-registry.tsx`; `(portal)/page.tsx` ruft es je Kachel einmal auf (die
|
||||
Datei bleibt die Stelle, an der die Komponenten importiert werden — der
|
||||
Zirkelimport-Grund aus dem Bestandskommentar gilt weiter, also NICHT die
|
||||
Komponenten direkt in der Registry importieren). Mehrfachanmeldung desselben
|
||||
Typs ist ein No-Op (wie die bisherigen Flags); Anmeldung eines unbekannten
|
||||
Typs wirft in der Entwicklung und wird in der Produktion ignoriert.
|
||||
3. **`WIDGET_REGISTRY` bekommt `moduleSlug?: string`** je Eintrag, befuellt aus
|
||||
`WIDGET_MODULE_SLUGS`. Heute bleibt es fuer alle neun Kacheln leer.
|
||||
4. **Der Katalog leitet seine Liste aus der Registry ab** (`Object.keys` in der
|
||||
Reihenfolge der Registry-Definition, die heutige Reihenfolge bleibt erhalten —
|
||||
Test darauf) und **filtert nach Modulzugriff**: `widget-catalog-modal.tsx`
|
||||
bekommt eine Liste der zugaenglichen Modul-Slugs als Prop von der Seite, die
|
||||
sie ueber den vorhandenen Weg `/modules/active` holt (Muster
|
||||
`apps/web/src/components/layout/sidebar.tsx` — dort wird genau dieser Endpunkt
|
||||
schon gefetcht; dieselbe Hilfsfunktion nutzen, nicht neu bauen). Eine Kachel
|
||||
ohne `moduleSlug` ist immer sichtbar; eine mit `moduleSlug` nur, wenn der Slug
|
||||
in der Liste steht. Schlaegt der Abruf fehl, werden Kacheln MIT `moduleSlug`
|
||||
ausgeblendet (fail-closed, wie serverseitig).
|
||||
5. **Gesperrte/unbekannte Kachel erklaert sich.** `widget-wrapper.tsx` rendert
|
||||
heute nichts, wenn `definition?.component` fehlt. Neu: ein zentrierter grauer
|
||||
Hinweistext `widgets.unavailable` („Diese Kachel steht nicht zur Verfügung —
|
||||
das zugehörige Modul ist nicht freigegeben.") in de und en. Der Fall tritt
|
||||
erst mit Proxmox real auf, ist aber ab jetzt abgedeckt.
|
||||
6. **Verhalten der neun vorhandenen Kacheln aendert sich NICHT.** Gleiche Namen,
|
||||
gleiche Reihenfolge im Katalog, gleiche Groessenvorgaben, gleiche Einstellungen.
|
||||
Der Einstellungs-Zweig je Typ in `widget-settings-panel.tsx` bleibt wie er ist —
|
||||
den generisch zu machen waere ein eigener Umbau und gehoert NICHT in diese
|
||||
Aufgabe (im SUMMARY als bewusst offen gelassen nennen).
|
||||
7. Keine neuen Abhaengigkeiten. Keine Aenderung an der Datenbank.
|
||||
|
||||
## Aufgaben
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 1: Typliste nach @tessera/shared, generische Anmeldung, Katalog aus der Registry</name>
|
||||
<files>packages/shared/src/index.ts, apps/api/src/dashboard/dto/create-widget.dto.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/app/(portal)/page.tsx, apps/web/src/app/(portal)/page.test.tsx, apps/web/src/components/dashboard/widget-catalog-modal.tsx, apps/web/src/components/dashboard/widget-catalog-modal.test.tsx</files>
|
||||
<action>
|
||||
Entscheidungen 1-4 umsetzen. Reihenfolge: shared zuerst (beide Apps bauen dagegen), dann API-DTO und `widget-module-map.ts`, dann Registry + `registerWidget`, dann `page.tsx`, zuletzt der Katalog.
|
||||
Tests zuerst anpassen/ergaenzen, wo sie die alten Namen festhalten (`widget-registry.test.tsx` prueft heute die Typliste und die Constraints-Tabelle; `widget-catalog-modal.test.tsx` die Eintraege). Neu mindestens: Katalogreihenfolge entspricht der Registry-Reihenfolge; eine Kachel mit `moduleSlug` fehlt im Katalog, wenn der Slug nicht in den zugaenglichen Modulen steht, und erscheint, wenn doch; fehlgeschlagener Modulabruf blendet Kacheln mit `moduleSlug` aus; `registerWidget` ist idempotent; `WIDGET_TYPES` aus shared und die Registry-Schluessel sind deckungsgleich (ein Test, der kuenftig jede vergessene Stelle faengt).
|
||||
Fuer den Katalog-Test eine Kachel mit `moduleSlug` brauchen, ohne eine echte zu erfinden: die Registry im Test per Hilfsfunktion um einen Testeintrag erweitern ODER den Filter als reine Funktion `visibleWidgetTypes(registry, accessibleSlugs | null)` auslagern und diese direkt testen — die reine Funktion ist vorzuziehen (Muster `picture-frame-config.ts`).
|
||||
Commit: `refactor(quick-260922-m1h): Widget-Typen an einer Stelle, Katalog aus der Registry, Kachel kennt ihr Modul`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard "src/app/(portal)/page.test.tsx" && pnpm --filter @tessera/api exec vitest run src/dashboard && pnpm type-check && pnpm lint</automated>
|
||||
</verify>
|
||||
<done>`WIDGET_TYPES`/`WidgetType`/`WIDGET_MODULE_SLUGS` stehen in `packages/shared`; API-DTO und Web leiten davon ab; genau EINE `registerWidget`-Funktion (kein `wireXWidget` mehr); Katalogliste kommt aus der Registry (keine zweite Liste); Deckungsgleichheits-Test vorhanden und gruen. Alle bestehenden Tests gruen, Reihenfolge und Namen der neun Kacheln unveraendert.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Aufgabe 2: Gesperrte Kachel erklaert sich, Uebersetzungen, Changelog, Entwicklerdoku</name>
|
||||
<files>apps/web/src/components/dashboard/widgets/widget-wrapper.tsx, apps/web/src/components/dashboard/widgets/widget-wrapper.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, CHANGELOG.md, docs/anleitung-entwicklung.md</files>
|
||||
<action>
|
||||
Entscheidung 5 umsetzen (Hinweistext statt leerer Kachel, Test dafuer), Schluessel `widgets.unavailable` in beiden Sprachdateien.
|
||||
`docs/anleitung-entwicklung.md`: den vorhandenen Modul-Walkthrough (Abschnitt um Zeile 372-400) um einen kurzen Abschnitt „Eine Kachel zum Modul" ergaenzen — welche drei Stellen es NACH diesem Umbau noch sind (Komponente schreiben, `registerWidget` in `page.tsx`, Eintrag in `WIDGET_TYPES` + optional `WIDGET_MODULE_SLUGS` in `packages/shared`, plus Uebersetzungen und Groessenvorgaben) und dass eine Kachel mit `moduleSlug` automatisch aus Katalog und Dashboard verschwindet, wenn das Modul fehlt.
|
||||
CHANGELOG unter „Unveröffentlicht → Geändert": „Dashboard: Kacheln, die zu einem Modul gehören, erscheinen nur noch für Benutzer, die dieses Modul nutzen dürfen; eine nicht mehr freigegebene Kachel erklärt das jetzt, statt leer zu bleiben" (Stichpunkt, kein Fliesstext).
|
||||
Volle Tore am Ende.
|
||||
Commit: `docs(quick-260922-m1h): Hinweis bei gesperrter Kachel, Changelog und Entwicklerdoku`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard src/messages && pnpm type-check && pnpm lint && pnpm --filter @tessera/api test && pnpm --filter @tessera/web test</automated>
|
||||
</verify>
|
||||
<done>Unbekannter/gesperrter Typ zeigt den Hinweistext (Test); beide Sprachdateien tragen den Schluessel; Changelog-Zeile steht; Entwicklerdoku nennt die verbliebenen Schritte; alle Tore gruen; genau zwei Commits mit Scope `quick-260922-m1h`.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
## Hinweise fuer den Executor
|
||||
|
||||
- HEAD ist `ee2b025`, Arbeitsbaum sauber, Zweig `main`. Version 1.3.0 wurde heute freigegeben; dieser Umbau geht in die naechste Freigabe. Zweig `live` und Tags NICHT anfassen.
|
||||
- `packages/shared` wird von beiden Apps importiert (`@tessera/shared`); pruefen, ob ein Build-Schritt noetig ist (`pnpm --filter @tessera/shared build`?) — turbo erledigt das ueblicherweise, im Zweifel `pnpm build` fuer shared vor dem Typecheck.
|
||||
- Qualitaetsregeln: keine neue `any`, `as unknown as` api 27 / web 6 unveraendert, keine `!`, kein `biome-ignore`, web-Warnungen bleiben 53, api 74.
|
||||
- Commits: Conventional Commits, Scope `quick-260922-m1h`, deutscher Betreff im Stil von `git log --oneline -15`, Commit-Body endet mit
|
||||
`Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>`
|
||||
- `.planning/**` NICHT committen.
|
||||
- Testserver nicht anfassen. Lokaler Docker-Stack laeuft, nicht noetig fuer diese Aufgabe.
|
||||
- SUMMARY nach `/home/vicolab/projects/tessera-ctl/.planning/quick/260922-m1h-dashboard-widgets-ein-modul-bringt-seine/260922-m1h-SUMMARY.md` (`status: complete`), mit: was jetzt noch zu tun ist, um eine Modul-Kachel hinzuzufuegen (die kurze Liste), Abweichungen, Zahlen, und einer kurzen Browser-Pruefliste fuer mich.
|
||||
|
||||
<threat_model>
|
||||
ASVS 1, block on high.
|
||||
|
||||
| ID | Bedrohung | Schwere | Disposition |
|
||||
|---|---|---|---|
|
||||
| T-M1H-01 | Katalogfilter clientseitig = Umgehung moeglich (Kachel per API trotzdem anlegen) | medium | Der Katalogfilter ist Komfort, die Durchsetzung bleibt serverseitig in `dashboard.service.ts` (`getWidgets()` filtert fail-closed) und im Modul-Guard der jeweiligen Daten-Endpunkte. Im Code so kommentieren. Akzeptiert. |
|
||||
| T-M1H-02 | Kachel eines gesperrten Moduls zeigt weiter Daten | high | Daten holt jede Kachel ueber ihre eigenen Modul-Endpunkte, die `@UseModule(slug)` tragen muessen — fuer Proxmox in der naechsten Aufgabe verbindlich. Diese Aufgabe aendert daran nichts und schwaecht nichts ab. |
|
||||
| T-M1H-03 | Typliste in `packages/shared` als neue Vertrauensgrenze | low | Reine Konstantenliste, keine Laufzeitdaten; die API validiert weiterhin mit `@IsIn` gegen genau diese Liste. Mitigiert. |
|
||||
| T-M1H-04 | Fehlender Modulabruf oeffnet den Katalog | medium | Fail-closed: bei Fehler werden Kacheln MIT `moduleSlug` ausgeblendet (Entscheidung 4), Test dafuer. Mitigiert. |
|
||||
</threat_model>
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
---
|
||||
phase: quick-260922-m1h
|
||||
plan: 01
|
||||
subsystem: apps/web/src/components/dashboard
|
||||
tags: [refactor, dashboard, widgets, module-access]
|
||||
status: complete
|
||||
requires: []
|
||||
provides:
|
||||
- "WIDGET_TYPES/WidgetType/WIDGET_MODULE_SLUGS als geteilte Quelle in packages/shared"
|
||||
- "registerWidget() als einzige Anmeldestelle je Kachel"
|
||||
- "visibleWidgetTypes() — Katalogfilter nach Modulzugriff, fail-closed"
|
||||
affects:
|
||||
- apps/api/src/dashboard
|
||||
- apps/web/src/app/(portal)/page.tsx
|
||||
tech-stack:
|
||||
added:
|
||||
- "apps/web haengt jetzt auf @tessera/shared (workspace:*)"
|
||||
patterns:
|
||||
- "erster Laufzeit-Import aus @tessera/shared (bisher nur import type)"
|
||||
key-files:
|
||||
created:
|
||||
- apps/api/src/dashboard/widget-module-map.spec.ts
|
||||
modified:
|
||||
- packages/shared/src/index.ts
|
||||
- apps/web/src/components/dashboard/widget-registry.tsx
|
||||
- apps/web/src/components/dashboard/widget-catalog-modal.tsx
|
||||
- apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
|
||||
- apps/web/src/app/(portal)/page.tsx
|
||||
- apps/api/src/dashboard/dto/create-widget.dto.ts
|
||||
- apps/api/src/dashboard/widget-module-map.ts
|
||||
decisions:
|
||||
- "Typliste als Laufzeit-Konstante in packages/shared statt gespiegelter Kopien — traegt, weil Node 24 rohes TypeScript per Type-Stripping laedt"
|
||||
- "apps/web bekommt die Abhaengigkeit auf @tessera/shared; die frueher dokumentierte Gegenbegruendung war ueberholt"
|
||||
- "Katalogfilter als reine Funktion visibleWidgetTypes(registry, slugs|null) statt Logik im Dialog"
|
||||
metrics:
|
||||
duration: "~70 min"
|
||||
completed: 2026-09-22
|
||||
actuals:
|
||||
tokens: 21000
|
||||
tasks: 2
|
||||
commits: 2
|
||||
plan_head_before: ee2b025
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-m1h: Ein Modul bringt seine Dashboard-Kachel selbst mit — Zusammenfassung
|
||||
|
||||
Die Kachel-Typliste stand an sieben Stellen; sie steht jetzt an einer. Der
|
||||
Katalog fuehrt keine zweite Liste mehr und blendet Kacheln gesperrter Module
|
||||
aus, eine Kachel ohne Bauteil erklaert sich mit einem Satz statt leer zu
|
||||
bleiben. Die neun vorhandenen Kacheln verhalten sich unveraendert.
|
||||
|
||||
## So fuegt man kuenftig eine Modul-Kachel hinzu
|
||||
|
||||
Vorher sieben Stellen, jetzt drei (plus das Uebliche an Text und Maßen):
|
||||
|
||||
1. **Kachel-Komponente schreiben** — `apps/web/src/components/dashboard/widgets/<name>-widget.tsx`,
|
||||
nimmt `WidgetProps` (`instanceId`, `config`, `isEditMode`).
|
||||
2. **Typ eintragen** — in `WIDGET_TYPES` in `packages/shared/src/index.ts`. Gehoert die Kachel zu
|
||||
einem Modul, zusaetzlich `WIDGET_MODULE_SLUGS['<typ>'] = '<modul-slug>'` in derselben Datei.
|
||||
Das ist die einzige Liste — die API validiert per `@IsIn` gegen genau sie.
|
||||
3. **Anmelden** — `registerWidget('<typ>', <Name>Widget)` in `apps/web/src/app/(portal)/page.tsx`.
|
||||
|
||||
Dazu wie bei jeder Oberflaeche: Uebersetzungsschluessel `<typ>.name` und `<typ>.description` unter
|
||||
`widgets` in **de.json und en.json**, ein Inline-SVG-Symbol und die Groessenvorgaben in
|
||||
`WIDGET_CONSTRAINTS` — Symbol und Maße in `widget-registry.tsx`.
|
||||
|
||||
Eine Kachel mit `moduleSlug` verschwindet danach **von selbst** aus Katalog und Dashboard, wenn der
|
||||
Benutzer das Modul nicht nutzen darf. Vergisst man eine der drei Stellen, schlaegt der
|
||||
Deckungsgleichheits-Test in `widget-registry.test.tsx` fehl, statt dass die Kachel im Katalog fehlt
|
||||
oder die API mit 400 antwortet.
|
||||
|
||||
Dieselbe Liste steht als Abschnitt „Eine Kachel zum Modul" in
|
||||
`docs/anleitung-entwicklung.md`.
|
||||
|
||||
## Was gebaut wurde
|
||||
|
||||
**Aufgabe 1 — `56c07c3`** (`refactor`)
|
||||
|
||||
- `packages/shared/src/index.ts`: `WIDGET_TYPES`, `WidgetType`, `WIDGET_MODULE_SLUGS`.
|
||||
- `create-widget.dto.ts`: `@IsIn([...WIDGET_TYPES])` statt handgepflegter Liste.
|
||||
- `widget-module-map.ts`: liest `WIDGET_MODULE_SLUGS` statt einer eigenen Kopie; die oeffentliche
|
||||
Funktion `getModuleSlugForWidgetType()` ist unveraendert, damit `dashboard.service.spec.ts`
|
||||
sie weiter mocken kann.
|
||||
- `widget-registry.tsx`: neun `wireXWidget()` → ein `registerWidget()` (idempotent; unbekannter Typ
|
||||
wirft in der Entwicklung, wird in der Produktion ignoriert). `WidgetDefinition` traegt
|
||||
`moduleSlug?`. Neue reine Funktion `visibleWidgetTypes(registry, slugs|null)`.
|
||||
- `widget-catalog-modal.tsx`: Liste kommt aus der Registry (Reihenfolge erhalten, Test darauf),
|
||||
gefiltert nach Modulzugriff; neue Prop `accessibleModuleSlugs`.
|
||||
- `(portal)/page.tsx`: neun `registerWidget`-Aufrufe; holt `/modules/active` im Muster der
|
||||
Seitenleiste (`credentials: 'include'`, Fehler still) und reicht die Slugs an den Katalog durch.
|
||||
|
||||
**Aufgabe 2 — `8be0725`** (`docs`)
|
||||
|
||||
- `widget-wrapper.tsx`: Kachel ohne Bauteil zeigt `widgets.unavailable` zentriert und grau statt des
|
||||
rohen Typnamens; Schluessel in de.json und en.json.
|
||||
- Changelog-Stichpunkt unter „Unveroeffentlicht → Geaendert"; Entwicklerdoku-Abschnitt.
|
||||
|
||||
## Abweichungen vom Plan
|
||||
|
||||
**1. [Rule 3 — blockierend] Die Planannahme „apps/web importiert @tessera/shared bereits" war falsch**
|
||||
|
||||
- **Gefunden bei:** Aufgabe 1, vor der ersten Zeile Code.
|
||||
- **Befund:** `apps/web` hatte **keine** Abhaengigkeit auf `@tessera/shared`. Zwei Kommentare
|
||||
(`lib/app-version.ts`, `lib/desktop.ts`) dokumentierten das sogar ausdruecklich als Absicht und
|
||||
begruendeten damit gespiegelte Typen. Ohne Abhaengigkeit ist Entscheidung 1 des Plans nicht
|
||||
umsetzbar. Zudem waren **alle** bisherigen `@tessera/shared`-Importe in `apps/api` reine
|
||||
`import type` — die Typliste ist aber ein Laufzeitwert.
|
||||
- **Geprueft statt vermutet:**
|
||||
- `nest build` mit einem Laufzeit-Import: laeuft; das Ergebnis laedt `@tessera/shared` im
|
||||
fertigen `dist` tatsaechlich (nachgestellt, 9 Typen).
|
||||
- `packages/shared` liefert rohes TypeScript ohne Bauschritt — in `node:24-alpine` direkt
|
||||
geprueft: Node 24 laedt es per nativem Type-Stripping (`OK [ 'clock', 'xframe' ] {}`).
|
||||
- Die alte Gegenbegruendung ist ueberholt: der Web-Dockerfile kopiert `packages/shared` in
|
||||
deps- **und** builder-Stufe bereits. Es aendert sich nur das Lockfile (3 Zeilen).
|
||||
- `pnpm --filter @tessera/web build` laeuft durch — ohne `transpilePackages`.
|
||||
- **Umsetzung:** `@tessera/shared: workspace:*` in `apps/web/package.json`. Die beiden Kommentare,
|
||||
deren Begruendung dadurch unwahr wurde, sagen jetzt den aktuellen Stand; die Typ-Spiegel selbst
|
||||
blieben bewusst unangetastet (nicht Teil dieser Aufgabe).
|
||||
- **Nebenwirkung fuer die Zukunft:** `packages/shared/src/index.ts` darf nur noch loeschbare Syntax
|
||||
enthalten — kein `enum`, kein `namespace`, keine Parameter-Eigenschaften. Steht als Warnung in
|
||||
der Datei.
|
||||
|
||||
**2. [Abweichung vom Auftrag des Orchestrators] Keine gemeinsame Hilfsfunktion fuer `/modules/active`**
|
||||
|
||||
Der Auftrag nannte „dieselbe Hilfsfunktion wie die Seitenleiste". Eine solche gibt es nicht: die
|
||||
Seitenleiste hat einen eingebauten `fetch`, und `lib/api.ts#getActiveModules` ist serverseitig
|
||||
(Cookie-Header, kein `credentials`). Die Dashboard-Seite benutzt daher dasselbe **Muster** wie die
|
||||
Seitenleiste. Eine Hilfsfunktion herauszuloesen haette `sidebar.tsx` angefasst — ausserhalb dieser
|
||||
Aufgabe.
|
||||
|
||||
## Bewusst offen gelassen
|
||||
|
||||
- **`widget-settings-panel.tsx`** — der Einstellungs-Zweig je Typ bleibt wie er war. Den generisch
|
||||
zu machen ist ein eigener Umbau (so im Plan festgelegt). Die Datei wurde nicht angefasst.
|
||||
- **Die Typ-Spiegel** in `lib/app-version.ts` und `lib/desktop.ts` koennten jetzt echte Importe
|
||||
werden. Nicht gemacht, nur die Kommentare richtiggestellt.
|
||||
- **Katalog aktualisiert sich nicht live**, wenn im Marketplace gerade ein Modul freigeschaltet
|
||||
wird — die Seitenleiste tut das ueber `sidebarRefreshKey`, die Dashboard-Seite holt die Liste nur
|
||||
beim Aufbau. Heute ohne Wirkung (keine Kachel hat einen `moduleSlug`); mit Proxmox reicht ein
|
||||
Neuladen der Seite. Bewusst so, weil der Auffrisch-Ausloeser einen `biome-ignore` erzwungen
|
||||
haette, den die Qualitaetsregeln dieser Aufgabe ausschliessen.
|
||||
|
||||
## Keine Stubs
|
||||
|
||||
Es wurden keine Platzhalter, leeren Rueckgaben oder „coming soon"-Texte eingebaut.
|
||||
`WIDGET_MODULE_SLUGS` ist leer — das ist kein Stub, sondern der korrekte Zustand: alle neun Kacheln
|
||||
sind Plattform-Kacheln. Die erste Modul-Kachel (Proxmox) traegt sich dort ein.
|
||||
|
||||
## Bedrohungsmodell
|
||||
|
||||
| ID | Stand |
|
||||
|---|---|
|
||||
| T-M1H-01 | Akzeptiert wie geplant. Der Katalogfilter ist Komfort; im Code an drei Stellen so kommentiert. Durchsetzung bleibt `DashboardService.getWidgets` (unveraendert, 85 Tests gruen). |
|
||||
| T-M1H-02 | Unveraendert — diese Aufgabe schwaecht nichts ab. Fuer Proxmox bleibt `@UseModule(slug)` verbindlich. |
|
||||
| T-M1H-03 | Mitigiert. Reine Konstantenliste, keine Laufzeitdaten; die API validiert weiterhin `@IsIn` gegen genau diese Liste — jetzt nachweislich (Test validiert alle neun Typen und lehnt einen unbekannten ab). |
|
||||
| T-M1H-04 | Mitigiert. `accessibleModuleSlugs === null` blendet Kacheln MIT `moduleSlug` aus; Test im Katalog und in `visibleWidgetTypes`. |
|
||||
|
||||
## Zahlen
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Commits | 2 (`56c07c3`, `8be0725`), Basis `ee2b025` |
|
||||
| Dateien geaendert | 20 (1 neu) |
|
||||
| Zeilen | +610 / −150 (gemessen: `git diff --shortstat ee2b025 HEAD`) |
|
||||
| Neue Tests | 29 (Registry 9, Katalog 3, Seite 2, `widget-module-map.spec.ts` 14, Wrapper 1) |
|
||||
| API-Tests | 1202 gruen (76 Dateien) |
|
||||
| Web-Tests | 659 gruen (81 Dateien) |
|
||||
| type-check | sauber (4 Pakete) |
|
||||
| lint | api 74 / web 53 Warnungen — **unveraendert** zur Basis |
|
||||
| `as unknown as` | api 27 / web 6 — **unveraendert** |
|
||||
| neue `any` / `!` / `biome-ignore` | 0 / 0 / 0 |
|
||||
| Next.js-Produktionsbau | laeuft |
|
||||
| `nest build` | laeuft |
|
||||
|
||||
## Browser-Pruefliste
|
||||
|
||||
Der Umbau ist verhaltensneutral — die Pruefung soll vor allem bestaetigen, dass **nichts** anders
|
||||
aussieht. Lokalen Stack neu bauen (`--build`), dann im Portal:
|
||||
|
||||
1. **Dashboard oeffnen.** Alle bisherigen Kacheln stehen an ihrem Platz und funktionieren wie
|
||||
vorher (Uhr laeuft, Kalender zeigt Termine, Bilderrahmen wechselt, XFrame laedt).
|
||||
2. **Stift → „Widget hinzufuegen".** Der Katalog zeigt **neun** Kacheln in genau dieser Reihenfolge:
|
||||
Uhr, Suchleiste, Kalender, Notiz, Taschenrechner, Favoriten, Stoppuhr, Bilderrahmen, XFrame.
|
||||
Namen und Beschreibungen unveraendert.
|
||||
3. **Eine Kachel anlegen** (z. B. Stoppuhr) — sie erscheint, laesst sich ziehen, vergroessern und
|
||||
wieder entfernen. Kein 400-Fehler.
|
||||
4. **Groessen pruefen:** eine frisch angelegte Kachel hat dieselbe Startgroesse wie frueher, und
|
||||
sie laesst sich nicht kleiner ziehen als bisher.
|
||||
5. **Einstellungen → Dashboard:** die Einstellungen je Kachel sind unveraendert da (dieser Bereich
|
||||
wurde bewusst nicht angefasst).
|
||||
6. **Sprache auf Englisch umstellen** — der Katalog bleibt vollstaendig, keine rohen Schluessel wie
|
||||
`clock.name` sichtbar.
|
||||
7. *(optional, zeigt das Neue)* Der Hinweis bei einer nicht verfuegbaren Kachel laesst sich heute
|
||||
nur kuenstlich ausloesen — er greift erst mit der ersten Modul-Kachel. Wer ihn sehen will: in der
|
||||
Datenbank den `widgetType` einer vorhandenen Kachel auf `proxmox` setzen und die Seite neu laden;
|
||||
die Kachel zeigt dann „Diese Kachel steht nicht zur Verfuegung — das zugehoerige Modul ist nicht
|
||||
freigegeben." statt leer zu bleiben. Danach zuruecksetzen.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `apps/api/src/dashboard/widget-module-map.spec.ts` vorhanden.
|
||||
- Commits `56c07c3` und `8be0725` in `git log` gefunden.
|
||||
- `git diff --diff-filter=D ee2b025..HEAD` — keine geloeschten Dateien.
|
||||
- `git rev-list --count ee2b025..HEAD` = 2, gemessen.
|
||||
- `.planning/**` nicht committet.
|
||||
|
||||
## Rundgang durch den Orchestrator (22.09.2026, lokaler Stack aus 8be0725)
|
||||
|
||||
Bestanden, keine Abweichung zum Stand vorher:
|
||||
|
||||
- Dashboard zeigt die bestehenden Kacheln (Kalender, Notizen, Favoriten, Bilderrahmen, XFrame) unveraendert, keine Konsolenfehler.
|
||||
- Katalog zeigt **neun** Kacheln in der alten Reihenfolge: Uhr, Suchleiste, Kalender, Notizen, Taschenrechner, Favoriten, Stoppuhr, Bilderrahmen, XFrame; Namen und Beschreibungen unveraendert, keine rohen Schluessel.
|
||||
- Stoppuhr angelegt → erscheint (396x160 px), wird gespeichert (`stopwatch` in `GET /dashboard/widgets`), kein 400; danach wieder entfernt, Liste sauber.
|
||||
- `/modules/active` wird beim Seitenaufbau abgerufen (4x 200) — der Katalogfilter hat seine Datenquelle.
|
||||
- Der Hinweis bei nicht verfuegbarer Kachel liess sich nicht echt ausloesen (es gibt noch keine Modul-Kachel); er ist durch den Test in `widget-wrapper.test.tsx` gedeckt und greift mit Proxmox.
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
---
|
||||
phase: quick-260922-vdk
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
autonomous: true
|
||||
requirements: [QUICK-260922-VDK]
|
||||
|
||||
files_modified:
|
||||
- apps/web/src/components/dashboard/dashboard-grid.tsx
|
||||
- apps/web/src/components/dashboard/dashboard-grid.test.tsx
|
||||
- CHANGELOG.md
|
||||
|
||||
estimate:
|
||||
tokens: 55000
|
||||
raw_tokens: 55000
|
||||
tasks: 2
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Ein Dashboard, das beim Öffnen leer ist, misst die verfügbare Breite, sobald die erste Kachel erscheint: das Raster füllt den Inhaltsbereich bis zum rechten Rand, rechts bleibt kein toter Streifen, in den sich keine Kachel ziehen lässt."
|
||||
- "Die Messung überlebt den Wechsel Leerzustand → gefüllt, weil sie am eingehängten Knoten selbst hängt (Ref-Rückruf) und nicht an einem Effekt, der nur beim ersten Einhängen läuft."
|
||||
- "Gemessen wird synchron in der Commit-Phase, bevor gezeichnet wird — der bisherige Zwischenzustand mit dem angenommenen Startwert wird nie sichtbar (auch nicht auf dem heute schon funktionierenden Pfad „Neuladen mit Kacheln“)."
|
||||
- "Ändert sich die Fenstergröße, folgt die Rasterbreite; ein Fenster-Horcher ist das Sicherheitsnetz für den Fall, dass der ResizeObserver nichts meldet."
|
||||
- "Das Ziehverhalten bleibt exakt wie heute: FREE_PLACEMENT_COMPACTOR mit preventCollision, ein belegtes Feld bleibt blockiert, nichts wird zur Seite geschoben (Nutzerentscheidung 22.09.2026); Leerzustand, BREAKPOINTS, COLS, rowHeight, margin und applyConstraintMinima sind unverändert."
|
||||
- "Alle Tore grün: 12 Tests in dashboard-grid.test.tsx (10 alte unverändert + 2 neue), Web gesamt ≥ 661 Tests in 81 Dateien, `tsc --noEmit` 4/4, Biome 5/5 mit weiterhin genau 53 Warnungen in web, `as unknown as` in web weiterhin 6."
|
||||
artifacts:
|
||||
- "apps/web/src/components/dashboard/dashboard-grid.tsx — Messung über den Ref-Rückruf `measureRef` (synchrone Erstmessung + ResizeObserver am jeweils eingehängten Knoten, Trennen im null-Zweig), `applyWidth`-Wächter, Fenster-Horcher; deutscher Kommentarblock `quick-260922-vdk` mit dem Warum"
|
||||
- "apps/web/src/components/dashboard/dashboard-grid.test.tsx — zwei neue Fälle (Leerzustand → gefüllt misst 1000; resize-Ereignis misst 1600) und `vi.unstubAllGlobals()` im bestehenden `afterEach`"
|
||||
- "CHANGELOG.md — neuer Abschnitt „### Behoben“ unter „Unveröffentlicht“ mit einem Stichpunkt in Alltagssprache"
|
||||
key_links:
|
||||
- "Kachel hinzufügen → `widgets.length` wird 1 → der Zweig mit dem Raster rendert → React hängt den `<div>` ein → `measureRef(node)` → synchrone Messung + `observer.observe(node)` → `setWidth` noch vor dem Zeichnen → `Responsive width` → Spaltenbreite und Breakpoint stimmen"
|
||||
- "Letzte Kachel entfernt → Knoten wird ausgehängt → `measureRef(null)` → `observerRef.current.disconnect()` (kein zurückgegebener Aufräum-Rückgabewert, damit React den null-Aufruf beibehält) → kein weiterlaufender Beobachter"
|
||||
- "`window` resize → Horcher → `nodeRef.current.getBoundingClientRect().width` → `applyWidth` (verwirft 0 und nicht endliche Werte) → `setWidth`"
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-vdk: Das Dashboard-Raster misst seine Breite auch aus dem Leerzustand heraus
|
||||
|
||||
<objective>
|
||||
Auf einem Dashboard, das beim Öffnen leer ist, bleibt das Raster für die ganze Sitzung bei der angenommenen Breite von 1200 Pixeln stehen. Die erste hinzugefügte Kachel rechnet deshalb mit 20 statt 24 Spalten und 51,6 px Spaltenbreite, das Raster endet bei 1200 px und rechts davon liegt ein toter Bereich (gemessen: ~460 px bei 1920 px Bildschirmbreite), in den sich keine Kachel ziehen lässt. Diese Aufgabe hängt die Messung an den Knoten statt an den ersten Einhäng-Zeitpunkt, misst vor dem ersten Zeichnen und ergänzt einen Fenster-Horcher als Netz.
|
||||
|
||||
Purpose: Fehlerbehebung aus einer bereits abgeschlossenen Messung — die Ursache steht fest, dieser Plan setzt nur noch um und sichert sie mit einem Regressionstest ab.
|
||||
Output: geänderte `dashboard-grid.tsx` mit deutschem Warum-Kommentar, zwei neue Tests (zuerst rot), ein Changelog-Stichpunkt, alle Tore grün, Prüfliste für den Browser-Rundgang im SUMMARY.
|
||||
</objective>
|
||||
|
||||
## Befund (gemessen, nicht neu zu untersuchen)
|
||||
|
||||
Der bisherige Code legt den Beobachter in einem Effekt mit leerer Abhängigkeitsliste an und bricht ab, wenn der Ref noch leer ist. Hängt `DashboardGrid` ein, während das Dashboard null Kacheln hat, greift der frühe Rücksprung in den Leerzustand **vor** dem `<div>` mit dem Ref: der Ref ist leer, der Effekt bricht ab — und läuft wegen der leeren Abhängigkeitsliste nie wieder, auch nicht, wenn später Kacheln erscheinen und der `<div>` tatsächlich entsteht. Die Breite bleibt für die ganze Sitzung beim Startwert.
|
||||
|
||||
Belege (bestätigt, nicht zu wiederholen):
|
||||
|
||||
- Echter Linux-Client (Tessera-1.3.0.AppImage, WebKitGTK), leeres Dashboard, eine Kalender-Kachel hinzugefügt: Kachel 469 px breit in einem 1000 px breiten Container — das ist die Rechnung für 1200.
|
||||
- Derselbe Client nach einem Neuladen mit vorhandener Kachel: 459 px in 1176 px, also richtig — weil die Ladeschranke in `(portal)/page.tsx` das Raster aus- und wieder einhängt, der Ref beim Einhängen also existiert.
|
||||
- Screenshot des Nutzers (1920×1045): Spaltenbreite 51,5 px, Platzhalter klebt an Spalte 13, rechte Rasterkante bei x = 1459 bei einem Inhaltsbereich bis ~1920.
|
||||
|
||||
`react-grid-layout` vergleicht den Breakpoint strikt größer als (`width > breakpoint`), 1200 ist damit **nicht** `lg`, sondern `md` → 20 Spalten.
|
||||
|
||||
## Gebundene Entscheidungen (nicht neu verhandeln)
|
||||
|
||||
1. **Ref-Rückruf statt Einmal-Effekt.** Die Messung hängt am jeweils eingehängten Knoten und überlebt Aus- und Einhängen.
|
||||
2. **Synchron vor dem ersten Zeichnen.** Der Ref-Rückruf läuft in der Commit-Phase; die dort ausgelöste Zustandsänderung wird vor dem Zeichnen abgearbeitet. Ein zusätzlicher `useLayoutEffect` ist damit überflüssig.
|
||||
3. **Fenster-Horcher als Netz**, zusätzlich zum ResizeObserver, nicht statt seiner.
|
||||
4. **Verhalten sonst unverändert.** `FREE_PLACEMENT_COMPACTOR` mit `preventCollision` bleibt (Nutzerentscheidung 22.09.2026: ein belegtes Feld bleibt blockiert, nichts wird zur Seite geschoben). Leerzustand, `BREAKPOINTS`, `COLS`, `rowHeight`, `margin`, `applyConstraintMinima` bleiben wortgleich.
|
||||
5. **Startwert 1200 bleibt.** Er lebt nur noch bis zur Commit-Phase desselben Einhängens. Genau diesen Übergang 1200 → gemessen macht der Pfad „Neuladen mit Kacheln“ heute schon in Produktion, und er ist nachweislich richtig (459 px in 1176 px) — ein anderer Startwert würde eine bisher unerprobte Breakpoint-Folge einführen, ohne etwas zu verbessern.
|
||||
6. **Nur `apps/web`.** Keine API, kein Prisma, kein Docker, keine neuen Pakete (ResizeObserver und resize sind Browser-Schnittstellen).
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@apps/web/src/components/dashboard/dashboard-grid.tsx
|
||||
@apps/web/src/components/dashboard/dashboard-grid.test.tsx
|
||||
@apps/web/src/test/fake-resize-observer.ts
|
||||
@apps/web/src/test/setup.ts
|
||||
</context>
|
||||
|
||||
## Schnittstellen, die der Executor kennen muss
|
||||
|
||||
- `apps/web/src/test/fake-resize-observer.ts` exportiert `stubResizeObserver({ width, height }): void`; es ersetzt den globalen ResizeObserver per `vi.stubGlobal` durch eine Klasse, deren `observe()` den Rückruf **sofort und synchron** mit `new DOMRectReadOnly(0, 0, width, height)` aufruft. Zurückgesetzt wird nur durch `vi.unstubAllGlobals()` — `vi.restoreAllMocks()` reicht dafür nicht.
|
||||
- `apps/web/src/test/setup.ts` legt global einen ResizeObserver-Ersatz an, der immer 1200×800 meldet. Ohne `stubResizeObserver` misst jeder Test also 1200 — der neue Test wäre damit blind für genau diesen Fehler.
|
||||
- `dashboard-grid.test.tsx` ersetzt `Responsive` durch einen Durchreicher, der die Props in `captured.props` ablegt (`vi.hoisted`, Mock per `importOriginal`, damit `noCompactor` echt bleibt). Die gemessene Breite ist dadurch als `captured.props?.width` prüfbar.
|
||||
- jsdom liefert für `getBoundingClientRect()` ohne Zutun 0 — die synchrone Erstmessung schlägt im Test also nicht durch, der gestubbte Beobachter liefert den Wert. Für den Fenster-Test wird `Element.prototype.getBoundingClientRect` gezielt überschrieben.
|
||||
- `new DOMRect(0, 0, w, h)` gibt es in jsdom (die Datei `fake-resize-observer.ts` nutzt bereits `DOMRectReadOnly`) — damit braucht der Test **keinen** Cast, und der Zähler `as unknown as` bleibt bei 6.
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer" tdd="true">
|
||||
<name>Aufgabe 1: Messung an den Knoten hängen (Ref-Rückruf, synchrone Erstmessung, Fenster-Horcher) — Ende-zu-Ende „leeres Dashboard → erste Kachel füllt die volle Breite“, zuerst rot</name>
|
||||
<files>apps/web/src/components/dashboard/dashboard-grid.test.tsx, apps/web/src/components/dashboard/dashboard-grid.tsx</files>
|
||||
<behavior>
|
||||
Zuerst die Tests, beide müssen vor der Änderung rot sein:
|
||||
|
||||
- **Test 10 — Leerzustand → gefüllt:** `stubResizeObserver({ width: 1000, height: 800 })`; `captured.props` auf null setzen; mit `widgets: []` und leeren Layouts rendern → die Überschrift des Leerzustands steht da und `captured.props` ist weiterhin null (das Raster ist nicht eingehängt); danach **`rerender`** derselben Instanz mit einer Uhr-Kachel und einem passenden `lg`-Eintrag → `captured.props?.width` ist 1000. Rot vorher: der Wert bleibt 1200.
|
||||
- **Test 11 — Fenstergröße:** `stubResizeObserver({ width: 1000, height: 800 })`; mit einer Uhr-Kachel rendern → Breite 1000; danach `Element.prototype.getBoundingClientRect` per `vi.spyOn(...).mockReturnValue(new DOMRect(0, 0, 1600, 800))` überschreiben und `window.dispatchEvent(new Event('resize'))` in `act(...)` auslösen → `captured.props?.width` ist 1600. Rot vorher: der Wert bleibt 1000, weil es keinen Fenster-Horcher gibt.
|
||||
|
||||
Die zehn bestehenden Fälle bleiben unverändert und grün — insbesondere Test 4 (Raster-Konstanten), Test 7 (Compactor-Pin mit preventCollision) und Test 9/9b (Minima).
|
||||
</behavior>
|
||||
<action>
|
||||
1. **`dashboard-grid.test.tsx`** (zuerst, rot): `act` zusätzlich aus `@testing-library/react` importieren, `stubResizeObserver` aus `@/test/fake-resize-observer`. Im bestehenden `afterEach` nach `vi.restoreAllMocks()` eine Zeile `vi.unstubAllGlobals()` ergänzen (ohne sie bliebe der gestubbte Beobachter für alle folgenden Dateien stehen). Am Ende des bestehenden `describe('DashboardGrid', …)` die zwei Fälle aus dem `<behavior>`-Block anfügen, benannt „quick-260922-vdk Test 10: …“ und „quick-260922-vdk Test 11: …“, Stil und Kommentarsprache wie die Nachbarfälle (deutsch, mit dem Warum in einer Zeile). Rot-Lauf ausführen und die Fehlermeldungen für das SUMMARY festhalten.
|
||||
|
||||
2. **`dashboard-grid.tsx`**: `useCallback` zum Import aus `react` hinzufügen. Die bisherige Kombination aus `containerRef` und dem Effekt mit leerer Abhängigkeitsliste ersetzen durch:
|
||||
- zwei Refs: `nodeRef` (Typ `HTMLDivElement | null`, hält den gerade eingehängten Knoten für den Fenster-Horcher) und `observerRef` (Typ `ResizeObserver | null`, hält den laufenden Beobachter);
|
||||
- `applyWidth` als `useCallback` mit leerer Abhängigkeitsliste: nimmt eine Zahl, ruft `setWidth` nur bei endlichem Wert größer 0 auf. React verwirft gleiche Werte selbst, ein zusätzlicher Vergleich ist unnötig und eine Rückkopplungsschleife damit ausgeschlossen (T-VDK-01);
|
||||
- `measureRef` als `useCallback` über `applyWidth`, Signatur nimmt `HTMLDivElement | null` und gibt **nichts** zurück: erst einen eventuell laufenden Beobachter trennen und `observerRef` leeren, dann `nodeRef` auf den Knoten setzen, bei `null` zurückspringen, sonst `applyWidth(node.getBoundingClientRect().width)` (das ist die synchrone Erstmessung in der Commit-Phase), dann einen neuen `ResizeObserver` anlegen, der `entries[0].contentRect.width` an `applyWidth` weitergibt, ihn auf den Knoten setzen und in `observerRef` merken. Wichtig: keine Aufräumfunktion zurückgeben — React 19 ruft den Rückruf sonst beim Aushängen nicht mehr mit `null` auf, und genau dieser Zweig ist hier der Aufräumpfad;
|
||||
- einen `useEffect` über `applyWidth`, der `resize` am `window` anmeldet und im Aufräumschritt wieder abmeldet; der Horcher misst `nodeRef` erneut, wenn dort ein Knoten liegt;
|
||||
- am Raster-`<div>` `ref={measureRef}` setzen (exakt dieser Name, ein Tor prüft ihn).
|
||||
|
||||
Alle vier Hooks stehen **vor** dem frühen Rücksprung in den Leerzustand, damit die Hook-Reihenfolge stabil bleibt — derselbe Grund, den der Kommentar bei `effectiveLayouts` bereits festhält.
|
||||
|
||||
3. **Warum-Kommentar** über der Messung, deutsch, im Stil der vorhandenen Blöcke (`quick-260916-dyv`, `quick-260916-bwo`), Präfix `quick-260922-vdk:`. Inhalt in eigenen Worten: der frühe Rücksprung in den Leerzustand rendert den gemessenen Knoten gar nicht erst, ein Effekt mit leerer Abhängigkeitsliste sieht ihn deshalb nie wieder; der Ref-Rückruf folgt dem Knoten über Aus- und Einhängen hinweg und misst in der Commit-Phase, also vor dem Zeichnen; die Folge der alten Annahme war ein `md`-Breakpoint mit 20 Spalten (strikter Größer-Vergleich in RGL), 51,6 px Spaltenbreite und ein toter Streifen rechts; der Fenster-Horcher ist ein Netz für Fälle, in denen der Beobachter nichts meldet; der Wächter verwirft 0 und nicht endliche Werte, damit eine kurzzeitig zusammengefallene Fläche das Raster nicht auf Null setzt. Den Startwert-Absatz (Entscheidung 5 oben) mit aufnehmen.
|
||||
|
||||
4. Nichts anderes anfassen: Leerzustand, `BREAKPOINTS`, `COLS`, `rowHeight`, `margin`, das fehlende `containerPadding`, `dragConfig`, `resizeConfig`, `FREE_PLACEMENT_COMPACTOR`, `applyConstraintMinima` und die `data-grid`-Erzeugung bleiben wortgleich.
|
||||
|
||||
Commit: `fix(quick-260922-vdk): Dashboard-Raster misst seine Breite auch aus dem Leerzustand heraus` (Wortlaut frei, Stil der Nachbarcommits, Co-Authored-By-Zeile).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run src/components/dashboard/dashboard-grid.test.tsx && pnpm --filter @tessera/web exec tsc --noEmit && grep -q "ref={measureRef}" apps/web/src/components/dashboard/dashboard-grid.tsx && grep -q "addEventListener('resize'" apps/web/src/components/dashboard/dashboard-grid.tsx && grep -q "unstubAllGlobals" apps/web/src/components/dashboard/dashboard-grid.test.tsx && grep -q "quick-260922-vdk" apps/web/src/components/dashboard/dashboard-grid.tsx</automated>
|
||||
</verify>
|
||||
<done>`dashboard-grid.test.tsx` hat 12 grüne Fälle (10 alte wortgleich, 2 neue); die zwei neuen waren nachweislich zuerst rot, die Rot-Meldungen („1200 statt 1000“ bzw. „1000 statt 1600“) stehen im SUMMARY. `tsc --noEmit` ohne Befund. Die Messung hängt am Ref-Rückruf `measureRef`, trennt den Beobachter im null-Zweig, misst synchron bei jedem Einhängen und hat einen Fenster-Horcher. Compactor, Konstanten, Leerzustand und `applyConstraintMinima` sind unverändert (Tests 4/7/9/9b belegen es). Ein Commit mit Scope `quick-260922-vdk`.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Aufgabe 2: Changelog-Stichpunkt, volle Tore, Zähler, Prüfliste für den Browser-Rundgang</name>
|
||||
<files>CHANGELOG.md</files>
|
||||
<action>
|
||||
1. **`CHANGELOG.md`**: unter „## Unveröffentlicht“ nach der bestehenden Liste „### Geändert“ einen neuen Abschnitt „### Behoben“ anlegen (Reihenfolge wie in 1.3.0: Neu, Geändert, Behoben) mit genau einem Stichpunkt in Alltagssprache, Tonlage der Nachbarzeilen, ohne Fachbegriffe: „Dashboard: war das Dashboard beim Öffnen leer, nutzte die erste hinzugefügte Kachel nur einen Teil der Breite — rechts blieb ein toter Streifen, in den sich keine Kachel ziehen ließ; das Raster misst die verfügbare Breite jetzt in jedem Fall und folgt auch einer Änderung der Fenstergröße“.
|
||||
|
||||
2. **Volle Tore** ausführen und die Zahlen ins SUMMARY schreiben: `pnpm type-check` (4/4), `pnpm lint` (5/5), `pnpm --filter @tessera/web test`. Ausgangsmessung dieses Plans (22.09., vor der Änderung): Web 81 Dateien / 659 Tests grün, Biome web genau 53 Warnungen, `as unknown as` in web 6. Erwartet nachher: 81 Dateien / 661 Tests, Warnungen und Zähler unverändert. Weicht eine Zahl ab, im SUMMARY benennen statt stillschweigend anpassen.
|
||||
|
||||
3. **Prüfliste** für den Orchestrator ins SUMMARY schreiben (Browser, lokal, Playwright-MCP — **nicht** auf dem Testserver), Punkt für Punkt abhakbar; ausdrücklich dazuschreiben, dass Breiten am DOM gemessen werden (`getBoundingClientRect` der Elemente), **nie** per `fetch` aus der Seite heraus:
|
||||
(a) Dashboard eines Benutzers ohne Kacheln öffnen (oder alle Kacheln entfernen und neu laden) → Leerzustand mit Bildmarke;
|
||||
(b) Bearbeiten → „Widget hinzufügen“ → Uhr: die Kachel erscheint, und beim Ziehen reicht der Platzhalter bis an den rechten Rand des Inhaltsbereichs — kein toter Streifen;
|
||||
(c) messen: Breite von `.react-grid-layout` gleicht der Breite des umgebenden Inhaltsbereichs (Abweichung höchstens der Rand von 8 px); vorher lag sie bei 1200 px unabhängig von der Fensterbreite;
|
||||
(d) eine zweite Kachel auf ein belegtes Feld ziehen → sie springt zurück, nichts wird zur Seite geschoben (unverändert gewollt); Größe ziehen stoppt am Nachbarn;
|
||||
(e) Fenster schmaler und wieder breiter ziehen → das Raster folgt, Kacheln bleiben heil;
|
||||
(f) Seite mit vorhandenen Kacheln neu laden → weiterhin richtig (der bisher schon funktionierende Pfad), und beim ersten Zeichnen ist kein Sprung von schmal auf breit zu sehen;
|
||||
(g) letzte Kachel entfernen → Leerzustand erscheint, danach eine neue Kachel hinzufügen → wieder volle Breite (der Beobachter wurde sauber getrennt und neu angehängt).
|
||||
|
||||
Commit: `docs(quick-260922-vdk): Changelog - Dashboard-Raster misst seine Breite auch aus dem Leerzustand` (nur CHANGELOG.md; Akte und STATE macht der Orchestrator; Co-Authored-By-Zeile).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q 'rechts blieb ein toter Streifen' CHANGELOG.md && awk '/^## Unveröffentlicht/{f=1} /^## 1\.3\.0/{f=0} f&&/^### Behoben/{n++} END{exit !(n==1)}' CHANGELOG.md && pnpm type-check && pnpm lint && pnpm --filter @tessera/web lint 2>&1 | grep -q 'Found 53 warnings' && test "$(grep -rn 'as unknown as' apps/web/src --include=*.ts --include=*.tsx | wc -l)" -eq 6 && pnpm --filter @tessera/web test</automated>
|
||||
<human-check>Am Ende der Aufgabe: der Orchestrator geht die siebenpunktige Prüfliste im Browser durch (lokal, Playwright-MCP). Entscheidend ist Punkt (b)/(c): auf einem beim Öffnen leeren Dashboard füllt die erste Kachel den Inhaltsbereich bis zum rechten Rand, und die gemessene Rasterbreite stimmt mit der Breite des Inhaltsbereichs überein.</human-check>
|
||||
</verify>
|
||||
<done>Der Changelog trägt unter „Unveröffentlicht“ genau einen neuen Abschnitt „### Behoben“ mit dem einen Stichpunkt. `pnpm type-check` 4/4 und `pnpm lint` 5/5 ohne Befund der Stufe `error`, Biome web weiterhin genau 53 Warnungen, `as unknown as` in web weiterhin 6, Web-Tests 81 Dateien / 661 grün. Die siebenpunktige Prüfliste steht im SUMMARY. Insgesamt genau zwei Commits mit Scope `quick-260922-vdk` (`git log --oneline -2`).</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
ASVS-Stufe 1, Blockschwelle `high` (jede `high`-Bedrohung MUSS mitigiert sein).
|
||||
|
||||
## Vertrauensgrenzen
|
||||
|
||||
| Grenze | Beschreibung |
|
||||
|---|---|
|
||||
| Browser-Layout → React-Zustand | Gemessene Breiten (ResizeObserver, `getBoundingClientRect`, `resize`) werden zu Zustand und steuern die Rastergeometrie; die Werte sind nicht vertrauenswürdig im Sinne von „immer sinnvoll“ (0 während eines Übergangs, nicht endlich bei zusammengefallener Fläche) |
|
||||
| Knoten-Lebensdauer → Beobachter-Lebensdauer | Der ResizeObserver hängt an einem Knoten, der beim Wechsel Leerzustand ↔ gefüllt aus- und eingehängt wird |
|
||||
| Neue Vertrauensgrenze | Keine. Kein Netzverkehr, keine API, keine Benutzereingabe, keine Persistenz in diesem Plan |
|
||||
|
||||
## STRIDE-Register
|
||||
|
||||
| ID | Kategorie | Komponente | Schwere | Disposition | Maßnahme |
|
||||
|---|---|---|---|---|---|
|
||||
| T-VDK-01 | Denial of Service (Rückkopplung Messung → Zustand → Layout → Messung, bis der Tab steht) | `applyWidth` / ResizeObserver-Rückruf in `dashboard-grid.tsx` | medium | mitigate | `applyWidth` ruft `setWidth` nur bei endlichem Wert größer 0; React verwirft gleiche Werte (Object.is) und rendert dann gar nicht neu — der Beobachter meldet nach dem Einschwingen denselben Wert und die Kette endet. Zusätzlich beobachtet der Beobachter den äußeren `<div>`, dessen Breite nicht von der Rastergeometrie abhängt. |
|
||||
| T-VDK-02 | Denial of Service (Ressourcenleck: Beobachter oder Fenster-Horcher überleben das Aushängen, bei jedem Wechsel Leerzustand ↔ gefüllt einer mehr) | `measureRef` null-Zweig, `useEffect`-Aufräumschritt | medium | mitigate | Der Ref-Rückruf trennt den laufenden Beobachter als erste Handlung und gibt bewusst **keine** Aufräumfunktion zurück, damit React ihn beim Aushängen mit `null` aufruft; der Fenster-Horcher meldet sich im Aufräumschritt des Effekts ab. Punkt (g) der Prüfliste geht den Wechsel im Browser durch. |
|
||||
| T-VDK-03 | Denial of Service (Messung auf einer zusammengefallenen Fläche setzt die Breite auf 0 → Raster unbedienbar, Kacheln unerreichbar) | Synchrone Erstmessung in jsdom-losen Übergängen, `getBoundingClientRect` | low | mitigate | Wächter „größer 0 und endlich“; bleibt eine Messung aus, gilt weiter der letzte gültige Wert statt 0. |
|
||||
| T-VDK-04 | Tampering (stille Verhaltensänderung beim Ziehen: ein belegtes Feld würde plötzlich nachgeben) | `FREE_PLACEMENT_COMPACTOR`, `dragConfig`, `resizeConfig` | medium | mitigate | Diese Stellen werden nicht angefasst; Test 7 pinnt den Compactor als echten `noCompactor` plus `preventCollision: true`, Test 6 die Zieh-Konfiguration, Test 4 die Raster-Konstanten — alle laufen im Tor der Aufgabe 1 mit. Prüfliste (d) belegt es zusätzlich im Browser. |
|
||||
| T-VDK-05 | Information Disclosure | — | low | accept | Es werden nur Layout-Maße des eigenen Fensters gelesen; nichts verlässt den Browser, nichts wird geloggt oder gespeichert. |
|
||||
| T-VDK-06 | Repudiation | — | low | accept | Reine Anzeige-Geometrie ohne Fremdwirkung; kein Audit-Log nötig, ASVS 1 genügt. |
|
||||
| T-VDK-SC | Tampering (Lieferkette) | npm-Installationen | high | mitigate | Nicht ausgelöst: KEINE neuen Pakete — `ResizeObserver`, `getBoundingClientRect`, `window`-Ereignisse und `DOMRect` sind Browser-Schnittstellen, `act` und `vi.spyOn` sind bereits vorhanden. Will der Executor doch etwas installieren: Stopp und Rückfrage an den Orchestrator, keine Installation ohne ausdrückliche Freigabe. |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
Automatisch (Executor, in den `<verify>`-Blöcken): gezielter Testlauf von `dashboard-grid.test.tsx` (12 Fälle), `tsc --noEmit`, Struktur-Tore auf Ref-Rückruf, Fenster-Horcher, `unstubAllGlobals` und den Warum-Kommentar; danach Changelog-Tore, `pnpm type-check` 4/4, `pnpm lint` 5/5, Biome web genau 53 Warnungen, `as unknown as` web 6, voller Web-Testlauf.
|
||||
|
||||
Rot-Nachweis (Aufgabe 1): beide neuen Fälle laufen vor der Änderung rot; die Meldungen gehören ins SUMMARY.
|
||||
|
||||
Manuell (Orchestrator, Prüfliste aus Aufgabe 2 Punkt 3, lokal im Browser, nicht auf dem Testserver): leeres Dashboard → erste Kachel füllt die Breite; gemessene Rasterbreite gleicht der Breite des Inhaltsbereichs; belegtes Feld bleibt blockiert; Fenstergröße; Neuladen mit Kacheln unverändert; Leerzustand → gefüllt → Leerzustand → gefüllt.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- [ ] Auf einem beim Öffnen leeren Dashboard füllt die erste hinzugefügte Kachel den Inhaltsbereich; rechts kein toter Streifen (Test 10 + Prüfliste b/c)
|
||||
- [ ] Die Messung folgt dem Knoten über Aus- und Einhängen und läuft synchron vor dem Zeichnen (Ref-Rückruf `measureRef`, Struktur-Tor + Prüfliste f/g)
|
||||
- [ ] Fenstergröße ändern misst neu (Test 11 + Prüfliste e)
|
||||
- [ ] Ziehverhalten unverändert: belegtes Feld bleibt blockiert, nichts wird verschoben (Tests 4/6/7/9/9b grün, Prüfliste d)
|
||||
- [ ] 12 Fälle in `dashboard-grid.test.tsx`, Web gesamt 81 Dateien / 661 Tests grün
|
||||
- [ ] `pnpm type-check` 4/4, `pnpm lint` 5/5, Biome web 53 Warnungen, `as unknown as` web 6
|
||||
- [ ] Changelog: genau ein Stichpunkt unter „Unveröffentlicht → Behoben“
|
||||
- [ ] Genau zwei Commits mit Scope `quick-260922-vdk`
|
||||
- [ ] Nur `apps/web` und `CHANGELOG.md` angefasst; keine neuen Pakete
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
`.planning/quick/260922-vdk-dashboard-raster-misst-seine-breite-nich/260922-vdk-SUMMARY.md` schreiben, wenn beide Aufgaben fertig sind — mit Rot-Meldungen der zwei neuen Tests, den gemessenen Zahlen (Tests, Warnungen, Zähler) und der siebenpunktigen Prüfliste für den Browser-Rundgang.
|
||||
</output>
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
---
|
||||
phase: quick-260922-vdk
|
||||
plan: 01
|
||||
subsystem: ui
|
||||
tags: [react, react-grid-layout, ref-callback, resize-observer, dashboard, vitest]
|
||||
|
||||
requires: []
|
||||
provides:
|
||||
- "Dashboard-Raster misst seine Breite auch beim Wechsel Leerzustand -> gefuellt (Ref-Rueckruf statt Einmal-Effekt)"
|
||||
- "Fenster-Horcher als Netz zusaetzlich zum ResizeObserver"
|
||||
affects: [dashboard-grid, dashboard]
|
||||
|
||||
actuals:
|
||||
tokens: 2392
|
||||
tasks: 2
|
||||
commits: 2
|
||||
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Ref-Rueckruf (callback ref) statt useEffect mit leerer Abhaengigkeitsliste, wenn eine Messung den tatsaechlich eingehaengten Knoten ueber Aus-/Einhaengen hinweg verfolgen muss"
|
||||
|
||||
key-files:
|
||||
created: []
|
||||
modified:
|
||||
- apps/web/src/components/dashboard/dashboard-grid.tsx
|
||||
- apps/web/src/components/dashboard/dashboard-grid.test.tsx
|
||||
- CHANGELOG.md
|
||||
|
||||
key-decisions:
|
||||
- "Ref-Rueckruf measureRef ersetzt containerRef + Einmal-Effekt; misst synchron in der Commit-Phase, kein useLayoutEffect noetig"
|
||||
- "Kein Aufraeum-Rueckgabewert aus measureRef, damit React 19 den Rueckruf beim Aushaengen mit null aufruft (das ist der Aufraeumpfad)"
|
||||
- "Fenster-Horcher zusaetzlich zum ResizeObserver, nicht als Ersatz"
|
||||
- "Startwert bleibt 1200 (nur bis zur ersten Commit-Phase relevant)"
|
||||
- "FREE_PLACEMENT_COMPACTOR/preventCollision, BREAKPOINTS, COLS, rowHeight, margin, applyConstraintMinima unveraendert"
|
||||
|
||||
patterns-established:
|
||||
- "quick-260922-vdk Kommentarblock in dashboard-grid.tsx erklaert das Warum der Ref-Rueckruf-Messung"
|
||||
|
||||
requirements-completed: [QUICK-260922-VDK]
|
||||
|
||||
coverage:
|
||||
- id: D1
|
||||
description: "Dashboard-Raster misst seine Breite auch aus dem Leerzustand heraus (Ref-Rueckruf, synchrone Erstmessung, Fenster-Horcher)"
|
||||
requirement: "QUICK-260922-VDK"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/dashboard/dashboard-grid.test.tsx#quick-260922-vdk Test 10: Leerzustand -> gefuellt misst die tatsaechliche Breite statt beim Startwert 1200 stehenzubleiben"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/dashboard/dashboard-grid.test.tsx#quick-260922-vdk Test 11: Fenstergroesse aendert sich -> der Fenster-Horcher misst neu"
|
||||
status: pass
|
||||
human_judgment: true
|
||||
rationale: "Die Unit-Tests beweisen die gemessene Breite in jsdom; ob im echten Browser rechts kein toter Streifen mehr bleibt und die Rasterbreite sichtbar der Breite des Inhaltsbereichs entspricht, verlangt einen Browser-Rundgang (Prueflliste unten)."
|
||||
- id: D2
|
||||
description: "Ziehverhalten unveraendert: belegtes Feld bleibt blockiert, nichts wird zur Seite geschoben"
|
||||
verification:
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/dashboard/dashboard-grid.test.tsx#quick-260916-dyv Test 7: Compactor-Pin"
|
||||
status: pass
|
||||
- kind: unit
|
||||
ref: "apps/web/src/components/dashboard/dashboard-grid.test.tsx#quick-260916-dyv Test 6: dragConfig-Pin"
|
||||
status: pass
|
||||
human_judgment: false
|
||||
|
||||
duration: ~9min
|
||||
completed: 2026-09-22
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Quick-Aufgabe 260922-vdk: Dashboard-Raster misst seine Breite auch aus dem Leerzustand heraus Summary
|
||||
|
||||
**Messung an einem Ref-Rueckruf (`measureRef`) statt an einem Einmal-Effekt: der Beobachter folgt dem eingehaengten `<div>` ueber Leerzustand <-> gefuellt hinweg, misst synchron in der Commit-Phase, plus Fenster-Horcher als Netz — 20-Spalten/`md`-Fehlmessung nach 1200 px behoben.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~9 min
|
||||
- **Completed:** 2026-09-22
|
||||
- **Tasks:** 2/2
|
||||
- **Files modified:** 3
|
||||
|
||||
## Accomplishments
|
||||
- Dashboard-Raster misst jetzt in jedem Fall die tatsaechliche Breite des Inhaltsbereichs, auch wenn `DashboardGrid` mit null Kacheln einhaengt und die erste Kachel erst spaeter erscheint
|
||||
- Fenster-Horcher als Sicherheitsnetz zusaetzlich zum ResizeObserver
|
||||
- Zwei neue Regressionstests, nachweislich zuerst rot
|
||||
- Ziehverhalten (`FREE_PLACEMENT_COMPACTOR` mit `preventCollision`), Raster-Konstanten und Leerzustand unveraendert (per Test 4/6/7/9/9b bestaetigt)
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Aufgabe 1: Messung an den Knoten haengen (Ref-Rueckruf, synchrone Erstmessung, Fenster-Horcher)** - `d9f2af3` (fix)
|
||||
2. **Aufgabe 2: Changelog-Stichpunkt, volle Tore, Zaehler, Pruefliste** - `cf67c8a` (docs)
|
||||
|
||||
_Ledger: `plan_head_before` = `3e8c0f4` (letzter Commit vor diesem Plan — die bereits committete Akte). `git rev-list --count 3e8c0f4..HEAD` = 2, deckt sich mit den zwei oben genannten Commits._
|
||||
|
||||
**Plan metadata:** wird vom Orchestrator nach diesem SUMMARY committet (STATE.md/ROADMAP.md ausserhalb dieses Plans).
|
||||
|
||||
## Rot-Nachweis (Aufgabe 1, vor der Aenderung)
|
||||
|
||||
Testlauf vor dem Umbau von `dashboard-grid.tsx` (nur die Test-Datei war schon geaendert):
|
||||
|
||||
```
|
||||
FAIL src/components/dashboard/dashboard-grid.test.tsx > DashboardGrid > quick-260922-vdk Test 10: ...
|
||||
AssertionError: expected 1200 to be 1000 // Object.is equality
|
||||
|
||||
FAIL src/components/dashboard/dashboard-grid.test.tsx > DashboardGrid > quick-260922-vdk Test 11: ...
|
||||
AssertionError: expected 1000 to be 1600 // Object.is equality
|
||||
```
|
||||
|
||||
10 von 12 Faellen gruen, genau die zwei neuen rot — wie erwartet: Test 10 blieb beim Startwert 1200 (der frueh zurueckspringende Leerzustand rendert den gemessenen Knoten nie, der Effekt mit leerer Abhaengigkeitsliste sieht ihn also nie), Test 11 blieb bei 1000, weil es vorher keinen Fenster-Horcher gab.
|
||||
|
||||
Nach dem Umbau: alle 12 Faelle gruen (`pnpm --filter @tessera/web exec vitest run src/components/dashboard/dashboard-grid.test.tsx`).
|
||||
|
||||
## Gemessene Zahlen (nachher, alle Tore)
|
||||
|
||||
| Tor | Ausgangsmessung (22.09., vor der Aenderung) | Nachher (gemessen) |
|
||||
|---|---|---|
|
||||
| `dashboard-grid.test.tsx` | 10 Faelle | 12 Faelle, alle gruen |
|
||||
| Web Tests gesamt | 81 Dateien / 659 Tests | 81 Dateien / 661 Tests, alle gruen |
|
||||
| `pnpm type-check` | — | 4/4 ohne Befund |
|
||||
| `pnpm lint` | — | 5/5 ohne Befund der Stufe `error` |
|
||||
| Biome web Warnungen | 53 | 53 (unveraendert) |
|
||||
| `as unknown as` in web | 6 | 6 (unveraendert) |
|
||||
|
||||
Keine Abweichung — alle erwarteten Zahlen aus dem Plan treffen exakt zu.
|
||||
|
||||
## Files Created/Modified
|
||||
- `apps/web/src/components/dashboard/dashboard-grid.tsx` - `measureRef`-Ref-Rueckruf ersetzt `containerRef` + Einmal-Effekt; `applyWidth`-Wächter (verwirft 0/nicht-endliche Werte); Fenster-Horcher (`resize`-Listener); deutscher Warum-Kommentarblock `quick-260922-vdk`
|
||||
- `apps/web/src/components/dashboard/dashboard-grid.test.tsx` - zwei neue Faelle (Test 10: Leerzustand -> gefuellt misst 1000; Test 11: resize-Ereignis misst 1600), `stubResizeObserver`-Import, `vi.unstubAllGlobals()` im `afterEach`
|
||||
- `CHANGELOG.md` - neuer Abschnitt „### Behoben" unter „## Unveroeffentlicht" mit einem Stichpunkt
|
||||
|
||||
## Decisions Made
|
||||
Keine neuen Entscheidungen — die im Plan gebundenen Entscheidungen (Ref-Rueckruf statt Einmal-Effekt, synchron vor dem ersten Zeichnen, Fenster-Horcher als Netz, Ziehverhalten unveraendert, Startwert 1200 bleibt, nur `apps/web`) wurden wortgleich umgesetzt.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written.
|
||||
|
||||
## Issues Encountered
|
||||
None.
|
||||
|
||||
## Pruefliste fuer den Browser-Rundgang (Orchestrator, lokal, Playwright-MCP, nicht Testserver)
|
||||
|
||||
Breiten werden am DOM gemessen (`getBoundingClientRect` der Elemente), **nie** per `fetch` aus der Seite heraus — das taeuscht in beide Richtungen.
|
||||
|
||||
- [ ] (a) Dashboard eines Benutzers ohne Kacheln oeffnen (oder alle Kacheln entfernen und neu laden) -> Leerzustand mit Bildmarke erscheint
|
||||
- [ ] (b) Bearbeiten -> „Widget hinzufuegen" -> Uhr: die Kachel erscheint, und beim Ziehen reicht der Platzhalter bis an den rechten Rand des Inhaltsbereichs — kein toter Streifen
|
||||
- [ ] (c) messen: Breite von `.react-grid-layout` gleicht der Breite des umgebenden Inhaltsbereichs (Abweichung hoechstens der Rand von 8 px); vorher lag sie bei 1200 px unabhaengig von der Fensterbreite
|
||||
- [ ] (d) eine zweite Kachel auf ein belegtes Feld ziehen -> sie springt zurueck, nichts wird zur Seite geschoben (unveraendert gewollt); Groesse ziehen stoppt am Nachbarn
|
||||
- [ ] (e) Fenster schmaler und wieder breiter ziehen -> das Raster folgt, Kacheln bleiben heil
|
||||
- [ ] (f) Seite mit vorhandenen Kacheln neu laden -> weiterhin richtig (der bisher schon funktionierende Pfad), und beim ersten Zeichnen ist kein Sprung von schmal auf breit zu sehen
|
||||
- [ ] (g) letzte Kachel entfernen -> Leerzustand erscheint, danach eine neue Kachel hinzufuegen -> wieder volle Breite (der Beobachter wurde sauber getrennt und neu angehaengt)
|
||||
|
||||
Entscheidend: Punkt (b)/(c) — auf einem beim Oeffnen leeren Dashboard fuellt die erste Kachel den Inhaltsbereich bis zum rechten Rand, und die gemessene Rasterbreite stimmt mit der Breite des Inhaltsbereichs ueberein.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
Keine neue Vertrauensgrenze, keine neuen Pakete. Alle sechs T-VDK-Punkte aus dem Plan-Threat-Model sind mit Tests bzw. Struktur-Toren abgedeckt (siehe Rot-Nachweis und gemessene Zahlen oben); nichts Neues gefunden.
|
||||
|
||||
## Next Phase Readiness
|
||||
Kein laufender Meilenstein, keine Folge-Phase direkt abhaengig. Naechster Schritt laut STATE.md bleibt: Widget-Modul-Kopplung (`WIDGET_MODULE_MAP`) und danach das Proxmox-Modul — unabhaengig von dieser Quick-Aufgabe.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: apps/web/src/components/dashboard/dashboard-grid.tsx
|
||||
- FOUND: apps/web/src/components/dashboard/dashboard-grid.test.tsx
|
||||
- FOUND: CHANGELOG.md
|
||||
- FOUND: .planning/quick/260922-vdk-dashboard-raster-misst-seine-breite-nich/260922-vdk-SUMMARY.md
|
||||
- FOUND commit: d9f2af3
|
||||
- FOUND commit: cf67c8a
|
||||
|
||||
---
|
||||
*Phase: quick-260922-vdk*
|
||||
*Completed: 2026-09-22*
|
||||
|
||||
## Rundgang durch den Orchestrator (22.09.2026, echter Linux-Client, nicht der Browser)
|
||||
|
||||
Der Nutzer hat den Fehler im **Linux-Client** gemeldet und ausdruecklich gesagt, ein
|
||||
Browser-Test bringe nichts. Geprueft wurde deshalb im echten Paket: `Tessera-1.3.0.AppImage`
|
||||
aus dem Gitea-Release, auf dem Entwicklungsrechner gestartet (`DISPLAY=:10`,
|
||||
`WEBKIT_INSPECTOR_HTTP_SERVER=127.0.0.1:9230`), gesteuert ueber den WebKit-Remote-Inspektor
|
||||
(Treiber `scratchpad/wk.mjs`, Target-Protokoll: `Target.sendMessageToTarget` +
|
||||
`Runtime.evaluate`). Server: lokaler Stack.
|
||||
|
||||
**Ausgangsmessung VOR dem Fix** (gleiche Sitzung, gleicher Client):
|
||||
|
||||
| Weg | Bereich | Kachel | Rasterbreite laut Rechnung |
|
||||
|-----|---------|--------|----------------------------|
|
||||
| Neuladen MIT Kachel | 1176 px | 459 px | 1176 px — richtig |
|
||||
| Seitenleiste auf/zu | 1000 ↔ 1176 px | folgt | richtig |
|
||||
| **Leeres Dashboard, dann Kachel hinzufuegen** | 1000 px | **469 px** | **1200 px — falsch** |
|
||||
|
||||
Der dritte Weg ist der Fehlerfall: `DashboardGrid` haengt mit null Kacheln ein, der
|
||||
gemessene `<div>` existiert nicht, der Einmal-Effekt bricht ab und laeuft nie wieder.
|
||||
|
||||
**Nach dem Fix, derselbe Weg** (Kachel entfernt, gespeichert, neu geladen -> leeres
|
||||
Dashboard -> Kalender hinzugefuegt):
|
||||
|
||||
- `width`-Eigenschaft an `Responsive`: **1000** (= echte Bereichsbreite), Breakpoint `md`,
|
||||
20 Spalten — aus dem React-Fiber ausgelesen, nicht geraten.
|
||||
- Kachel `style.width`: **389 px** = `8 × 41,6 + 56`, exakt der Sollwert fuer 1000 px.
|
||||
- Ziehen nach rechts (synthetische Maus-Ereignisse, jeweils mit Wartezeit, damit React
|
||||
dazwischen rendert): Platzhalter laeuft 8 → 107 → 256 → 405 → 554 → **603** und bleibt
|
||||
dort. `1000 − 8 − 389 = 603` — **der Platzhalter erreicht jetzt exakt den rechten Rand**.
|
||||
Die Kachel wird auch dort abgelegt (`tileX 603`).
|
||||
|
||||
**Messfalle, dokumentiert damit sie niemanden noch einmal kostet:** `getBoundingClientRect()`
|
||||
und `getComputedStyle().width` lieferten im Client weiter 469 px, obwohl `style.width`
|
||||
bereits 389 px war. Grund: `.react-grid-item` hat `transition: width .2s`, und das
|
||||
Client-Fenster lag im Hintergrund — WebKitGTK friert die Animationsuhr dann ein, der
|
||||
Uebergang bleibt auf dem Startwert stehen (`getAnimations()` meldete eine laufende
|
||||
`CSSTransition` auf `width`). **Im Client gegen die gesetzten Werte messen
|
||||
(`style.width`, `style.transform`) oder gegen die React-Eigenschaften, nie gegen die
|
||||
gemalte Box** — sonst misst man die eingefrorene Animation statt des Ergebnisses.
|
||||
|
||||
**Nicht angefasst, wie zugesagt:** belegte Plaetze bleiben gesperrt, nichts weicht aus
|
||||
(`FREE_PLACEMENT_COMPACTOR` mit `preventCollision` unveraendert) — ausdrueckliche Ansage des
|
||||
Nutzers am 22.09.
|
||||
|
||||
**Testreste der Pruefung:** lokaler Testbenutzer `clienttest` und die zeitweise auf
|
||||
`NODE_ENV=development` gesetzte lokale API (WebKitGTK nimmt `secure`-Kekse ueber `http` nicht
|
||||
an, Chromium macht fuer `localhost` eine Ausnahme) — beides nach der Pruefung wieder
|
||||
zurueckgebaut.
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
created: 2026-09-22
|
||||
title: DashboardImage — Spalte "data" entfernen und "storagePath" auf NOT NULL setzen (Stufe 2 der Umstellung aus quick-260922-hk4)
|
||||
area: apps/api/prisma
|
||||
severity: cleanup
|
||||
trigger: erst wenn alpha UND live je einmal mit einer Version >= der Freigabe nach 1.3.0 gelaufen sind — dann hat der Bootstrap-Umzug auf beiden Servern gearbeitet und die Bytes liegen im Dateibereich.
|
||||
relates_to: quick-260922-hk4 (.planning/quick/260922-hk4-bilderrahmen-bilder-auf-die-festplatte/)
|
||||
---
|
||||
|
||||
## Worum es geht
|
||||
|
||||
Die Bilder des Bilderrahmen-Widgets sind mit `quick-260922-hk4` aus der
|
||||
Datenbank in den Dateibereich gezogen (`user-files/dashboard-images/
|
||||
<userId>/<id>.<ext>`, Pfad in der Spalte `storagePath`). Die Umstellung ist
|
||||
BEWUSST ZWEISTUFIG:
|
||||
|
||||
- **Stufe 1, ausgeliefert** (Migration `20260922120000_dashboard_image_to_disk`):
|
||||
`storagePath` dazu (NULLbar), `data` wird NULLbar — aber NICHT gelöscht.
|
||||
Der Umzug der vorhandenen Zeilen passiert beim ersten Start automatisch
|
||||
(`DashboardImagesService.onApplicationBootstrap()`).
|
||||
- **Stufe 2, dieser Zettel:** `data` löschen, `storagePath` auf NOT NULL.
|
||||
|
||||
Warum nicht sofort: `prisma migrate deploy` läuft VOR dem Anwendungsstart.
|
||||
Ein sofortiges `DROP COLUMN "data"` hätte die Bytes vernichtet, bevor der
|
||||
Umzug beim Start sie lesen konnte (T-HK4-03).
|
||||
|
||||
## Was zu tun ist
|
||||
|
||||
1. **Vorbedingung prüfen** (auf BEIDEN Servern, alpha und live):
|
||||
|
||||
```sql
|
||||
SELECT count(*) FROM "DashboardImage" WHERE "storagePath" IS NULL;
|
||||
```
|
||||
|
||||
Muss überall `0` sein. Ist sie es nicht, ist der Umzug dort noch nicht
|
||||
gelaufen (Server noch auf einer älteren Version) — dann NICHT ausliefern.
|
||||
|
||||
2. **Neue Migration** `20260922120100_dashboard_image_drop_data`:
|
||||
|
||||
```sql
|
||||
ALTER TABLE "DashboardImage" ALTER COLUMN "storagePath" SET NOT NULL;
|
||||
ALTER TABLE "DashboardImage" DROP COLUMN "data";
|
||||
```
|
||||
|
||||
3. **Schema** `apps/api/prisma/schema.prisma`: Feld `data Bytes?` entfernen,
|
||||
`storagePath String?` → `storagePath String`.
|
||||
|
||||
4. **Dienst** `apps/api/src/dashboard/dashboard-images.service.ts`:
|
||||
`onApplicationBootstrap()` samt `forSystem()`-Aufruf entfernt sich damit
|
||||
— der Umzug hat seine Arbeit getan. Danach:
|
||||
- Eintrag `apps/api/src/dashboard/dashboard-images.service.ts` aus
|
||||
`FORSYSTEM_ALLOWED_CALL_SITES` in `apps/api/src/prisma/rls-access-inventory.spec.ts`
|
||||
wieder ENTFERNEN (die Liste ist ein „genau", ein veralteter Eintrag
|
||||
macht die Spec rot).
|
||||
- In `docs/mandantentrennung-zugriffsklassifikation.md` den Stand der Zeile
|
||||
`dashboard-images.service.ts`/`dashboardImage` von `system-gebunden`
|
||||
zurück auf `gebunden` setzen und die Zahlen der Bereichszeile
|
||||
`dashboard` sowie die Summe neu messen (Gate-Schleife, nicht
|
||||
abschreiben).
|
||||
- Die Tests 18, 21, 22 und 23 der Dienst-Spec (Zeile ohne `storagePath`,
|
||||
Bootstrap-Umzug) entfallen mit dem Umzug.
|
||||
- Die Regel `system_read_policy` auf `"DashboardImage"` (angelegt in
|
||||
20260922120000) kann bleiben oder mit `DROP POLICY` fallen — bleibt sie,
|
||||
gehört sie in der Klassifikation erwähnt; fällt sie, ist die
|
||||
Aufzählung „fünf/sechs Tabellen" dort nachzuziehen.
|
||||
|
||||
5. **Prüfen**, dass die Datenbank kleiner wird:
|
||||
|
||||
```sql
|
||||
SELECT pg_size_pretty(pg_total_relation_size('"DashboardImage"'));
|
||||
```
|
||||
|
||||
(Nach dem DROP zusätzlich `VACUUM FULL "DashboardImage";`, sonst gibt
|
||||
PostgreSQL den Platz nicht ans Dateisystem zurück.)
|
||||
|
||||
## Was passiert, wenn es liegen bleibt
|
||||
|
||||
Nichts Schlimmes: die Spalte steht leer herum und kostet je neuer Zeile
|
||||
nichts. Der Gewinn der Umstellung (kleiner `pg_dump`) ist bereits da, weil
|
||||
neue Uploads keine Bytes mehr in die Zeile schreiben. Nur die Bytes der
|
||||
ALTEN Bilder bleiben bis dahin doppelt vorhanden — einmal in der Datei,
|
||||
einmal in der Spalte.
|
||||
@@ -4,8 +4,24 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
|
||||
## Unveröffentlicht
|
||||
|
||||
## 1.3.1 – 2026-09-23
|
||||
|
||||
### Geändert
|
||||
|
||||
- Bilderrahmen: hochgeladene Bilder liegen jetzt im Dateibereich des Servers statt in der Datenbank — die Datenbanksicherung bleibt dadurch klein; vorhandene Bilder ziehen beim ersten Start automatisch um
|
||||
- Dashboard: Kacheln, die zu einem Modul gehören, erscheinen nur noch für Benutzer, die dieses Modul nutzen dürfen; eine nicht mehr freigegebene Kachel erklärt das jetzt, statt leer zu bleiben
|
||||
|
||||
### Behoben
|
||||
|
||||
- Dashboard: war das Dashboard beim Öffnen leer, nutzte die erste hinzugefügte Kachel nur einen Teil der Breite — rechts blieb ein toter Streifen, in den sich keine Kachel ziehen ließ; das Raster misst die verfügbare Breite jetzt in jedem Fall und folgt auch einer Änderung der Fenstergröße
|
||||
|
||||
## 1.3.0 – 2026-09-22
|
||||
|
||||
### Neu
|
||||
|
||||
- Dashboard-Widget „XFrame“: eine Webseite per https-Adresse als Rahmen in der Kachel anzeigen; optionaler Titel und automatisches Neuladen (1 Minute bis 1 Stunde); die eingebettete Seite kann Tessera nicht verlassen; „In neuem Tab öffnen“ führt jederzeit zur Seite selbst — manche Webseiten lassen sich nicht einbetten, der Rahmen bleibt dann leer; Einstellungen unter Einstellungen → Dashboard
|
||||
- Dashboard-Widget „XFrame“: nur einen Ausschnitt der Webseite zeigen – den Rahmen in einer Vorschau verschieben und an den Ecken ziehen oder Links/Oben/Breite/Höhe eintippen; die Kachel zeigt genau diesen Ausschnitt und passt ihn an ihre Größe an; Vergrößerung der ganzen Seite (50 bis 150 %); „Nur anzeigen“ sperrt Klicken und Scrollen im Rahmen – der Ausschnitt ist eine Position auf der Seite und kann verrutschen, wenn die Seite ihren Aufbau ändert
|
||||
- Dashboard-Widget „Bilderrahmen“: eigene Bilder hochladen (PNG, JPEG, GIF, WebP; höchstens 5 MB je Bild, bis zu 30 Bilder) oder Bilder per https-Adresse einbinden; Bildausschnitt ganz sichtbar oder formatfüllend, Wechselintervall, Reihenfolge oder Zufall, Bildunterschrift; Klick zeigt das Bild groß; Verwaltung unter Einstellungen → Dashboard
|
||||
- Favoriten-Widget: Reihenfolge der Links im Bearbeitungsmodus mit den Pfeilen „Nach oben“/„Nach unten“ festlegen
|
||||
- Desktop-App: das Symbol im Infobereich zeigt den verbundenen Tessera-Server – im Hinweistext und als erste Zeile des Menüs; in der App auch unter Einstellungen → Desktop-App als „Verbunden mit: …“
|
||||
- Desktop-App: Server-Adresse nachträglich änderbar über „Server-Adresse ändern…“ im Menü des Infobereich-Symbols – ohne Neustart
|
||||
@@ -20,6 +36,8 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
|
||||
### Behoben
|
||||
|
||||
- Desktop-App: die Knöpfe „Herunterladen“ unter Einstellungen → Desktop-App waren in der App ohne Funktion (Windows und Linux) – der Download öffnet sich jetzt im Browser des Systems
|
||||
- Desktop-App: der Update-Eintrag im Menü des Infobereich-Symbols bleibt nicht mehr stumm ausgegraut – schlägt die Update-Prüfung fehl, steht der Grund im Eintrag (z. B. „HTTP 401“, wenn ein Passwortschutz am Proxy die Anfrage abweist) und ein Klick prüft erneut; die App prüft außerdem alle vier Stunden, nicht mehr nur beim Start
|
||||
- Favoriten-Widget: kein Symbol bei Seiten mit Zertifikatsfehler oder internen Adressen – das Symbol wird jetzt trotz Zertifikatsfehler geholt, bei internen Adressen versucht es der Browser direkt
|
||||
|
||||
## 1.2.0 – 2026-09-17
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
-- 260921-pi9 — Bilderrahmen-Widget: hochgeladene Bilder eines Benutzers.
|
||||
--
|
||||
-- Zweck: die Tabelle "DashboardImage" traegt die Bilddaten (bytea) fuer das
|
||||
-- Dashboard-Widget „Bilderrahmen“. Bilder liegen in der Datenbank statt in
|
||||
-- einem Docker-Volume, damit die bestehende Sicherung sie mit abdeckt.
|
||||
--
|
||||
-- Grenzen (durchgesetzt in der Anwendung, apps/api/src/dashboard/
|
||||
-- dashboard-image-rules.ts + dashboard-images.service.ts): hoechstens 5 MiB
|
||||
-- je Datei (multer-Limit je Route), hoechstens 30 Bilder je Benutzer
|
||||
-- (Zaehler je Mandant+Benutzer vor dem Anlegen); erlaubt sind nur PNG, JPEG,
|
||||
-- GIF und WebP, erkannt an den Magic Bytes — "mimeType" ist der ERKANNTE Typ,
|
||||
-- nie der vom Browser behauptete.
|
||||
--
|
||||
-- Besitz: ein Bild gehoert dem hochladenden Benutzer (gleicher Mandant UND
|
||||
-- gleicher Benutzer). Die Regel unten traegt deshalb von Anfang an die
|
||||
-- Benutzerdimension (Form aus 20260911120000_rls_user_dimension_personal_
|
||||
-- tables); die Anwendung prueft den Besitz zusaetzlich in getBytes/remove und
|
||||
-- antwortet fuer fremde Kennungen mit 404 (nie 403).
|
||||
--
|
||||
-- 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 wirkt die Regel erst, wenn die
|
||||
-- Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter heute AUS, siehe
|
||||
-- docs/mandantentrennung-datenbankrolle.md).
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "DashboardImage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tenantId" TEXT NOT NULL,
|
||||
"originalName" TEXT NOT NULL,
|
||||
"mimeType" TEXT NOT NULL,
|
||||
"size" INTEGER NOT NULL,
|
||||
"data" BYTEA NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "DashboardImage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DashboardImage_userId_idx" ON "DashboardImage"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DashboardImage_tenantId_idx" ON "DashboardImage"("tenantId");
|
||||
|
||||
-- Zeilenschutz: Mandant UND Benutzer (Muster 20260911120000)
|
||||
ALTER TABLE "DashboardImage" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "DashboardImage" FORCE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation_policy ON "DashboardImage"
|
||||
USING (
|
||||
"tenantId" = current_tenant_id()
|
||||
AND (current_user_id() IS NULL OR "userId" = current_user_id())
|
||||
);
|
||||
@@ -0,0 +1,59 @@
|
||||
-- quick-260922-hk4 — Bilderrahmen-Bilder wandern aus der Datenbank in den
|
||||
-- Dateibereich (Volume `user-files`).
|
||||
--
|
||||
-- Warum: gesichert wird von Hand per `pg_dump` (docs/anleitung-betrieb.md
|
||||
-- Kap. 6). Jedes Bild waechst in diesen Abzug hinein — 30 Bilder à 5 MiB je
|
||||
-- Benutzer sind im Extremfall 150 MB PRO BENUTZER, gegen eine heute 18 MB
|
||||
-- grosse Datenbank (gemessen 22.09.2026 auf alpha). Die Bytes liegen ab
|
||||
-- dieser Version unter
|
||||
-- `user-files/dashboard-images/<userId>/<id>.<png|jpg|gif|webp>`; die Zeile
|
||||
-- haelt nur noch den relativen Pfad in "storagePath" — dasselbe Muster wie
|
||||
-- `User.avatarPath` (user.controller.ts) und die DKV-Ausfuhren
|
||||
-- (dkv-export.service.ts). Der Dateiname ist IMMER servergeneriert (die
|
||||
-- UUID der Zeile plus die Endung aus dem an den Magic Bytes ERKANNTEN
|
||||
-- Mime-Typ); kein Byte aus der Anfrage, insbesondere nicht
|
||||
-- "originalName", geht je in einen Pfad (T-HK4-01, Muster T-07-09).
|
||||
--
|
||||
-- ZWEISTUFIG, UND WARUM DIESE MIGRATION "data" NICHT LOESCHT (T-HK4-03):
|
||||
-- Vorhandene Zeilen tragen ihre Bytes noch in "data". Der Umzug auf die
|
||||
-- Platte passiert beim ersten Start dieser Version automatisch
|
||||
-- (DashboardImagesService.onApplicationBootstrap, liest systemgebunden ueber
|
||||
-- alle Mandanten, schreibt je Zeile mandantengebunden zurueck) — der Nutzer
|
||||
-- muss nichts ausfuehren. Wuerde diese Migration die Spalte sofort
|
||||
-- loeschen, laufen Migration und Umzug im selben Start in der falschen
|
||||
-- Reihenfolge ("migrate deploy" laeuft VOR dem Anwendungsstart) und die
|
||||
-- Bytes waeren weg, bevor sie jemand gelesen hat. Deshalb:
|
||||
-- Stufe 1 (diese Migration): "storagePath" dazu (NULLbar), "data" bleibt
|
||||
-- stehen und wird NULLbar, damit neue Uploads sie leer lassen.
|
||||
-- Stufe 2 (spaetere Freigabe, Migration
|
||||
-- 20260922120100_dashboard_image_drop_data, vorgemerkt in
|
||||
-- .planning/todos/pending/): "storagePath" SET NOT NULL und
|
||||
-- DROP COLUMN "data" — erst, wenn alpha UND live einmal mit
|
||||
-- einer Version >= dieser gelaufen sind.
|
||||
--
|
||||
-- Das Prisma-Modell behaelt in Stufe 1 bewusst `data Bytes?` (optional).
|
||||
-- Damit bleibt der Bootstrap-Umzug typisiert und braucht kein rohes SQL;
|
||||
-- die Spalte verschwindet aus Modell und Tabelle gemeinsam in Stufe 2.
|
||||
--
|
||||
-- Rechte/Regeln: "tenant_isolation_policy" aus 20260921120000 bleibt
|
||||
-- unveraendert. Kein DROP POLICY.
|
||||
|
||||
-- Relativer Pfad zur Monorepo-Wurzel, z. B.
|
||||
-- "user-files/dashboard-images/<userId>/<id>.png". Stufe 2 macht die Spalte
|
||||
-- NOT NULL.
|
||||
ALTER TABLE "DashboardImage" ADD COLUMN "storagePath" TEXT;
|
||||
|
||||
-- Neue Uploads schreiben keine Bytes mehr in die Zeile; die Spalte bleibt
|
||||
-- fuer die Dauer von Stufe 1 als Sicherheitsnetz erhalten.
|
||||
ALTER TABLE "DashboardImage" ALTER COLUMN "data" DROP NOT NULL;
|
||||
|
||||
-- Systemkontext-Leserecht (Muster 20260914120000_rls_system_context_read):
|
||||
-- der Bootstrap-Umzug liest die noch nicht umgezogenen Zeilen ueber ALLE
|
||||
-- Mandanten (`forSystem()`), bevor er je Zeile mandantengebunden
|
||||
-- zurueckschreibt. Ohne diese Regel saehe er nach dem Scharfschalten der
|
||||
-- Datenbankrolle (Etappe 4, Schalter heute AUS) NULL Zeilen und stellte die
|
||||
-- Arbeit stumm ein — genau die Falle, die 20260914120000 fuer die fuenf
|
||||
-- Hintergrunddienst-Tabellen geschlossen hat. Permissiv und NUR FOR SELECT:
|
||||
-- Schreiben bleibt allein der Mandantenregel unterstellt.
|
||||
CREATE POLICY system_read_policy ON "DashboardImage"
|
||||
FOR SELECT USING (is_system_context());
|
||||
@@ -212,6 +212,37 @@ model WidgetInstance {
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
// Bilderrahmen-Widget (quick-260921-pi9): hochgeladene Bilder eines Benutzers.
|
||||
// Keine Relation — wie WidgetInstance. Grenzen (5 MiB je Datei, 30 je
|
||||
// Benutzer) und die Magic-Byte-Erkennung leben in
|
||||
// src/dashboard/dashboard-image-rules.ts; Besitz = gleicher Mandant UND
|
||||
// gleicher Benutzer (Regel in Migration 20260921120000 mit Benutzerdimension).
|
||||
//
|
||||
// quick-260922-hk4: die Bytes liegen jetzt im Dateibereich
|
||||
// (user-files/dashboard-images/<userId>/<id>.<ext>), die Zeile haelt nur
|
||||
// noch den relativen Pfad — Muster User.avatarPath. Die Datenbanksicherung
|
||||
// (pg_dump) bleibt dadurch klein.
|
||||
model DashboardImage {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
tenantId String
|
||||
originalName String
|
||||
mimeType String
|
||||
size Int
|
||||
// Stufe 1 der zweistufigen Umstellung (Migration 20260922120000): die
|
||||
// Spalte bleibt NULLbar stehen, bis der Bootstrap-Umzug auf allen Servern
|
||||
// gelaufen ist. Neue Uploads schreiben sie nie. DROP kommt mit
|
||||
// 20260922120100 (vorgemerkt in .planning/todos/pending/).
|
||||
data Bytes?
|
||||
// Relativ zur Monorepo-Wurzel; NULL nur fuer Zeilen, die der
|
||||
// Bootstrap-Umzug noch nicht angefasst hat. Wird in Stufe 2 NOT NULL.
|
||||
storagePath String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
@@index([tenantId])
|
||||
}
|
||||
|
||||
model SearchProvider {
|
||||
id String @id @default(uuid())
|
||||
userId String?
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('AuthController.me', () => {
|
||||
authService.getMe.mockResolvedValue({ id: 'u1' });
|
||||
const controller = new AuthController(authService, makeFakeUserService());
|
||||
|
||||
await controller.me({ id: 'u1', tenantId: 't1', role: 'USER' });
|
||||
await controller.me({ id: 'u1', username: 'u1', tenantId: 't1', role: Role.USER, mustChangePassword: false });
|
||||
|
||||
expect(authService.getMe).toHaveBeenCalledWith('t1', 'u1');
|
||||
});
|
||||
@@ -46,7 +46,7 @@ describe('AuthController.me', () => {
|
||||
authService.getMe.mockResolvedValue({ id: 'u1' });
|
||||
const controller = new AuthController(authService, makeFakeUserService());
|
||||
|
||||
await controller.me({ id: 'u1', tenantId: 't1', role: Role.SUPER_ADMIN });
|
||||
await controller.me({ id: 'u1', username: 'u1', tenantId: 't1', role: Role.SUPER_ADMIN, mustChangePassword: false });
|
||||
|
||||
expect(authService.getMe).toHaveBeenCalledWith('t1', 'u1');
|
||||
});
|
||||
@@ -56,7 +56,7 @@ describe('AuthController.me', () => {
|
||||
authService.getMe.mockResolvedValue(null);
|
||||
const controller = new AuthController(authService, makeFakeUserService());
|
||||
|
||||
const result = await controller.me({ id: 'u1', tenantId: 't1', role: 'USER' });
|
||||
const result = await controller.me({ id: 'u1', username: 'u1', tenantId: 't1', role: Role.USER, mustChangePassword: false });
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@@ -69,7 +69,7 @@ describe('AuthController.changePassword', () => {
|
||||
const res = {} as any;
|
||||
|
||||
const result = await controller.changePassword(
|
||||
{ id: 'u1', tenantId: 't1', role: 'USER' },
|
||||
{ id: 'u1', username: 'u1', tenantId: 't1', role: Role.USER, mustChangePassword: false },
|
||||
{ currentPassword: 'old', newPassword: 'new' } as any,
|
||||
res,
|
||||
);
|
||||
@@ -88,7 +88,7 @@ describe('AuthController.adminResetPassword', () => {
|
||||
const result = await controller.adminResetPassword(
|
||||
'target',
|
||||
{ newPassword: 'new-password' } as any,
|
||||
{ id: 'admin-1', tenantId: 't1', role: Role.ADMIN },
|
||||
{ id: 'admin-1', username: 'admin-1', tenantId: 't1', role: Role.ADMIN, mustChangePassword: false },
|
||||
);
|
||||
|
||||
expect(authService.adminResetPassword).toHaveBeenCalledWith(
|
||||
@@ -109,7 +109,7 @@ describe('AuthController.adminResetPassword', () => {
|
||||
await controller.adminResetPassword(
|
||||
'target',
|
||||
{ newPassword: 'new-password', mustChangePassword: false } as any,
|
||||
{ id: 'admin-1', tenantId: 't1', role: Role.ADMIN },
|
||||
{ id: 'admin-1', username: 'admin-1', tenantId: 't1', role: Role.ADMIN, mustChangePassword: false },
|
||||
);
|
||||
|
||||
expect(authService.adminResetPassword).toHaveBeenCalledWith(
|
||||
@@ -134,7 +134,7 @@ describe('AuthController.adminResetPassword', () => {
|
||||
await controller.adminResetPassword(
|
||||
'target',
|
||||
{ newPassword: 'new-password' } as any,
|
||||
{ id: 'super-1', tenantId: 't1', role: Role.SUPER_ADMIN },
|
||||
{ id: 'super-1', username: 'super-1', tenantId: 't1', role: Role.SUPER_ADMIN, mustChangePassword: false },
|
||||
);
|
||||
|
||||
expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledTimes(1);
|
||||
@@ -158,7 +158,7 @@ describe('AuthController.adminResetPassword', () => {
|
||||
controller.adminResetPassword(
|
||||
'unknown',
|
||||
{ newPassword: 'new-password' } as any,
|
||||
{ id: 'super-1', tenantId: 't1', role: Role.SUPER_ADMIN },
|
||||
{ id: 'super-1', username: 'super-1', tenantId: 't1', role: Role.SUPER_ADMIN, mustChangePassword: false },
|
||||
),
|
||||
).rejects.toThrow(new BadRequestException('User not found'));
|
||||
expect(authService.adminResetPassword).not.toHaveBeenCalled();
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Role } from '@prisma/client';
|
||||
import { Request, Response } from 'express';
|
||||
import { Response } from 'express';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CurrentUser } from './decorators/current-user.decorator';
|
||||
@@ -22,6 +22,7 @@ import { AdminResetPasswordDto } from './dto/admin-reset-password.dto';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { RequestResetDto, ResetPasswordDto } from './dto/reset-password.dto';
|
||||
import { RolesGuard } from './guards/roles.guard';
|
||||
import type { AuthUser, LocalAuthenticatedRequest } from './types/auth-user';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
@@ -42,7 +43,7 @@ export class AuthController {
|
||||
* Mandantenpruefung warf, damit ein API-Aufrufer denselben Statuscode
|
||||
* sieht wie vor dieser Umstellung.
|
||||
*/
|
||||
private async resolveTargetTenantId(currentUser: any, userId: string): Promise<string> {
|
||||
private async resolveTargetTenantId(currentUser: AuthUser, userId: string): Promise<string> {
|
||||
if (currentUser.role === Role.SUPER_ADMIN) {
|
||||
const target = await this.userService.findByIdForPlatformAdmin(userId);
|
||||
if (!target) {
|
||||
@@ -62,7 +63,7 @@ export class AuthController {
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
async login(
|
||||
@Req() req: Request,
|
||||
@Req() req: LocalAuthenticatedRequest,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
return this.authService.login(req.user, res);
|
||||
@@ -91,7 +92,7 @@ export class AuthController {
|
||||
* Befund C).
|
||||
*/
|
||||
@Get('me')
|
||||
async me(@CurrentUser() user: any) {
|
||||
async me(@CurrentUser() user: AuthUser) {
|
||||
return this.authService.getMe(user.tenantId, user.id);
|
||||
}
|
||||
|
||||
@@ -130,7 +131,7 @@ export class AuthController {
|
||||
@Post('change-password')
|
||||
@HttpCode(200)
|
||||
async changePassword(
|
||||
@CurrentUser() user: any,
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() dto: ChangePasswordDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
@@ -163,7 +164,7 @@ export class AuthController {
|
||||
async adminResetPassword(
|
||||
@Param('userId') userId: string,
|
||||
@Body() dto: AdminResetPasswordDto,
|
||||
@CurrentUser() currentUser: any,
|
||||
@CurrentUser() currentUser: AuthUser,
|
||||
) {
|
||||
const tenantId = await this.resolveTargetTenantId(currentUser, userId);
|
||||
await this.authService.adminResetPassword(
|
||||
|
||||
@@ -16,6 +16,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 type { JwtPayload, LoginUser } from './types/auth-user';
|
||||
|
||||
/**
|
||||
* Zeilenform der drei auth_lookup_*-Datenbankfunktionen
|
||||
@@ -29,7 +30,12 @@ interface AuthLookupUserByUsernameRow {
|
||||
passwordHash: string | null;
|
||||
ldapDn: string | null;
|
||||
isActive: boolean;
|
||||
role: string;
|
||||
/**
|
||||
* Die SQL-Funktion deklariert diese Spalte als `role "Role"` (Migration
|
||||
* 20260909160000, Zeile 64) — `string` war hier eine weitere Fassung
|
||||
* desselben Wertes, nicht seine Beschreibung (quick-260921-m34).
|
||||
*/
|
||||
role: Role;
|
||||
displayName: string | null;
|
||||
mustChangePassword: boolean;
|
||||
}
|
||||
@@ -104,7 +110,10 @@ export class AuthService {
|
||||
* T-02-01: Returns null on any failure (never reveals which field is wrong).
|
||||
* Pitfall 6: Checks isActive to prevent deactivated users from logging in.
|
||||
*/
|
||||
async validateUser(username: string, password: string): Promise<any> {
|
||||
async validateUser(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<AuthLookupUserByUsernameRow | null> {
|
||||
// Usernames are stored lowercase (case-insensitive login).
|
||||
const rows = await this.prisma.$queryRaw<AuthLookupUserByUsernameRow[]>`
|
||||
SELECT * FROM auth_lookup_user_by_username(${username.toLowerCase()})
|
||||
@@ -115,7 +124,7 @@ export class AuthService {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, user.tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, user.tenantId);
|
||||
|
||||
// LDAP users have no local password — authenticate them against the
|
||||
// directory by binding as their OWN DN with the password they entered.
|
||||
@@ -167,8 +176,8 @@ export class AuthService {
|
||||
* D-02: 30-day session.
|
||||
* T-02-02: httpOnly + secure (prod) + sameSite=lax.
|
||||
*/
|
||||
async login(user: any, response: Response) {
|
||||
const payload = {
|
||||
async login(user: LoginUser, response: Response) {
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
@@ -233,7 +242,7 @@ export class AuthService {
|
||||
|
||||
// Create the reset token record — mandantengebunden, sobald der
|
||||
// Benutzer und damit sein Mandant bekannt sind (WINDOWS #20, Aufgabe 1).
|
||||
const tenantPrisma = forTenant(this.prisma, user.tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, user.tenantId);
|
||||
await tenantPrisma.passwordResetToken.create({
|
||||
data: {
|
||||
token,
|
||||
@@ -274,7 +283,7 @@ export class AuthService {
|
||||
|
||||
// Mandant ist ab hier bekannt (aus der Funktion mitgeliefert) — beide
|
||||
// Schreibzugriffe laufen gebunden (WINDOWS #20, Aufgabe 1).
|
||||
const tenantPrisma = forTenant(this.prisma, resetToken.tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, resetToken.tenantId);
|
||||
|
||||
// Hash the new password and update user
|
||||
const passwordHash = await argon2.hash(newPassword);
|
||||
@@ -308,7 +317,7 @@ export class AuthService {
|
||||
* Zeile.
|
||||
*/
|
||||
async getMe(tenantId: string, userId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const user = await tenantPrisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
@@ -353,7 +362,7 @@ export class AuthService {
|
||||
newPassword: string,
|
||||
response: Response,
|
||||
): Promise<void> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const user = await tenantPrisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
@@ -373,7 +382,7 @@ export class AuthService {
|
||||
data: { passwordHash, mustChangePassword: false },
|
||||
});
|
||||
|
||||
const payload = {
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
@@ -381,7 +390,7 @@ export class AuthService {
|
||||
mustChangePassword: false,
|
||||
};
|
||||
const token = this.jwtService.sign(payload);
|
||||
(response as any).cookie('session', token, {
|
||||
response.cookie('session', token, {
|
||||
httpOnly: true,
|
||||
secure: this.configService.get('NODE_ENV') === 'production',
|
||||
sameSite: 'lax',
|
||||
@@ -417,7 +426,7 @@ export class AuthService {
|
||||
newPassword: string,
|
||||
mustChangePassword: boolean = true,
|
||||
): Promise<void> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const user = await tenantPrisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
import type { AuthenticatedRequest } from '../types/auth-user';
|
||||
|
||||
/**
|
||||
* Global interceptor: forces users with mustChangePassword=true to change
|
||||
@@ -30,7 +31,7 @@ const ALLOWED_ROUTES = Object.freeze([
|
||||
{ method: 'GET', path: '/auth/me' },
|
||||
]);
|
||||
|
||||
function normalizePath(request: any): string {
|
||||
function normalizePath(request: AuthenticatedRequest): string {
|
||||
const raw = request.route?.path || request.url || '';
|
||||
const withoutQuery = raw.split('?')[0];
|
||||
const withoutTrailingSlash = withoutQuery.replace(/\/+$/, '');
|
||||
@@ -41,7 +42,7 @@ function normalizePath(request: any): string {
|
||||
export class ForcePasswordChangeInterceptor implements NestInterceptor {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
// Skip public routes (login, health, reset-password)
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
@@ -51,7 +52,7 @@ export class ForcePasswordChangeInterceptor implements NestInterceptor {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const user = request.user;
|
||||
|
||||
// No user on request (shouldn't happen after auth guard, but be defensive)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-jwt';
|
||||
import { Request } from 'express';
|
||||
import type { AuthUser, JwtPayload } from '../types/auth-user';
|
||||
|
||||
/**
|
||||
* Custom extractor that reads JWT from the httpOnly "session" cookie.
|
||||
@@ -24,7 +25,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: any) {
|
||||
async validate(payload: JwtPayload): Promise<AuthUser> {
|
||||
return {
|
||||
id: payload.sub,
|
||||
username: payload.username,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-local';
|
||||
import { AuthService } from '../auth.service';
|
||||
import type { LoginUser } from '../types/auth-user';
|
||||
|
||||
@Injectable()
|
||||
export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
@@ -9,7 +10,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
|
||||
super({ usernameField: 'username' });
|
||||
}
|
||||
|
||||
async validate(username: string, password: string): Promise<any> {
|
||||
async validate(username: string, password: string): Promise<LoginUser> {
|
||||
const user = await this.authService.validateUser(username, password);
|
||||
if (!user) {
|
||||
// T-02-01: Generic error message - never reveal whether username or password is wrong
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { Role } from '@prisma/client';
|
||||
import type { Request } from 'express';
|
||||
|
||||
/**
|
||||
* Gemeinsame Beschreibung des Aufrufers (quick-260921-m34, Aufgabe 2).
|
||||
*
|
||||
* Zweck: an diesem Objekt haengt jede Mandanten- und Rollenentscheidung
|
||||
* der API. Vor dieser Datei war es an rund 70 Stellen `any` — jede
|
||||
* Berechtigungspruefung lief also ohne Begleitung durch den Compiler.
|
||||
*
|
||||
* Herkunft jedes Feldes, nicht gewaehlt sondern abgelesen: die einzige
|
||||
* Stelle, die dieses Objekt erzeugt, ist `JwtStrategy.validate()`
|
||||
* (`../strategies/jwt.strategy.ts`, Zeile 27-38). Sie liest die Ansprueche
|
||||
* eines Tokens, das ausschliesslich an zwei Stellen signiert wird:
|
||||
* `AuthService.login()` (`../auth.service.ts`, Zeile ~171) und
|
||||
* `AuthService.changePassword()` (ebenda, Zeile ~376). Was dort nicht
|
||||
* geschrieben wird, gibt es hier nicht.
|
||||
*/
|
||||
export interface AuthUser {
|
||||
/** `payload.sub` — an beiden Signierstellen `user.id`. */
|
||||
id: string;
|
||||
/** An beiden Signierstellen `user.username`; Spalte `User.username` ist Pflicht. */
|
||||
username: string;
|
||||
/**
|
||||
* An beiden Signierstellen der Spaltenwert `User.role`. Die Spalte ist in
|
||||
* `apps/api/prisma/schema.prisma` als Aufzaehlung `Role` deklariert, die
|
||||
* SQL-Funktion `auth_lookup_user_by_username` gibt sie als `"Role"` zurueck
|
||||
* (Migration 20260909160000). Deshalb ist `Role` der ehrliche Typ und nicht
|
||||
* `string`: ein Vergleich gegen eine Zeichenkette ausserhalb der
|
||||
* Aufzaehlung ist ein Fehler und soll einer sein (T-M34-02).
|
||||
*/
|
||||
role: Role;
|
||||
/**
|
||||
* SICHERHEITSRELEVANTE ENTSCHEIDUNG (T-M34-01) — hergeleitet, nicht gewaehlt.
|
||||
*
|
||||
* Beleg 1: `apps/api/prisma/schema.prisma` deklariert `User.tenantId String`
|
||||
* OHNE `?`. Die Spalte ist Pflicht, jede Benutzerzeile hat einen Mandanten.
|
||||
* Beide Signierstellen schreiben genau diesen Spaltenwert, seit dem ersten
|
||||
* Commit des Anmeldedienstes (6190f3d) — es gibt keine Token-Generation
|
||||
* ohne diesen Anspruch.
|
||||
* Beleg 2: der Bestand beschreibt dasselbe Objekt in `SessionUser`
|
||||
* (`../../bug-reports/bug-reports.service.ts`) bereits als `tenantId: string`.
|
||||
* Diese Datei zieht `SessionUser` auf `AuthUser` zurueck; eine zweite,
|
||||
* abweichende Beschreibung desselben Objekts soll es nicht geben.
|
||||
* Beleg 3: `TenantGuard` (`../../tenant/tenant.guard.ts`) haelt fuer
|
||||
* SUPER_ADMIN einen Zweig ohne Mandanten vor und setzt dort
|
||||
* `req.tenantId = null`.
|
||||
*
|
||||
* Beleg 3 spricht NICHT gegen `string`, und das ist der Punkt, an dem hier
|
||||
* nicht nach Bequemlichkeit entschieden wurde: der Zweig in `TenantGuard`
|
||||
* ist eine Tiefenverteidigung gegen ein Token OHNE diesen Anspruch, und er
|
||||
* liest `AuthUser` gar nicht — der Waechter holt sein Anfrageobjekt
|
||||
* ungetypt. Dieser Typ kann den Zweig also nicht zu totem Code machen.
|
||||
*
|
||||
* ACHTUNG fuer spaetere Leser: der SUPER_ADMIN-Zweig in `TenantGuard` ist
|
||||
* ein Schutzzweig und darf NICHT entfernt oder wegtypisiert werden, auch
|
||||
* wenn er unter diesem Typ unerreichbar aussieht. Dass es ihn gibt, steht
|
||||
* unten in `AuthenticatedRequest.tenantId` als `null` weiterhin im Typsystem.
|
||||
*/
|
||||
tenantId: string;
|
||||
/**
|
||||
* `JwtStrategy.validate()` bildet diesen Wert mit `=== true` — ein aelteres
|
||||
* Token ohne den Anspruch ergibt `false` (260921-fi3, D-01: keine
|
||||
* Aussperrwelle). Das Ergebnis ist deshalb immer ein Wahrheitswert.
|
||||
*/
|
||||
mustChangePassword: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anfrageobjekt nach den beiden globalen Waechtern.
|
||||
*
|
||||
* `user` ist ABSICHTLICH wahlfrei: `JwtAuthGuard` laesst oeffentliche Wege
|
||||
* (`@Public()`: Anmeldung, Gesundheitspruefung, Kennwort-Ruecksetzung) ohne
|
||||
* Aufrufer durch, und `TenantGuard` beginnt mit genau dieser Pruefung
|
||||
* (`if (!user) return true`). Die abwehrenden Pruefungen in den Controllern
|
||||
* ("No user context") bleiben damit lebendiger Code.
|
||||
*
|
||||
* `tenantId` setzt `TenantGuard`: eine Zeichenkette, ODER `null` fuer einen
|
||||
* SUPER_ADMIN ohne Mandantenbezug — und gar nicht auf oeffentlichen Wegen,
|
||||
* auf denen der Waechter vorzeitig zurueckkehrt. Alle drei Faelle stehen
|
||||
* hier, weil alle drei vorkommen koennen.
|
||||
*/
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: AuthUser;
|
||||
tenantId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anmeldeanfrage nach `AuthGuard('local')`.
|
||||
*
|
||||
* Hier ist `user` NICHT wahlfrei: die lokale Passport-Strategie wirft
|
||||
* `UnauthorizedException`, wenn `validateUser()` nichts liefert — der Rumpf
|
||||
* der Route laeuft nur mit gesetztem Aufrufer.
|
||||
*/
|
||||
export interface LocalAuthenticatedRequest extends Request {
|
||||
user: LoginUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Was `AuthService.login()` aus dem angemeldeten Benutzer liest — nicht mehr.
|
||||
* Geliefert wird das von `validateUser()` als Zeile der SQL-Funktion
|
||||
* `auth_lookup_user_by_username`; `displayName` ist der einzige Unterschied
|
||||
* zu `AuthUser` und der Grund, warum `login()` nicht `AuthUser` nehmen kann:
|
||||
* der Anzeigename steht in der Anmeldeantwort, aber in keinem Token.
|
||||
*/
|
||||
export interface LoginUser {
|
||||
id: string;
|
||||
username: string;
|
||||
role: Role;
|
||||
tenantId: string;
|
||||
/** Spalte `User.displayName` ist wahlfrei (`String?` in schema.prisma). */
|
||||
displayName: string | null;
|
||||
mustChangePassword: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Ansprueche des Sitzungstokens.
|
||||
*
|
||||
* Doppelrolle: dieselbe Form wird an den beiden Signierstellen geschrieben
|
||||
* und in `JwtStrategy.validate()` gelesen. Deshalb ist
|
||||
* `mustChangePassword` wahlfrei — geschrieben wird es immer, aber ein vor
|
||||
* 260921-fi3 ausgestelltes Token traegt es nicht, und die Pruefung
|
||||
* `=== true` in `validate()` haengt daran. Wer dieses Feld hier zur Pflicht
|
||||
* macht, laesst jene Pruefung ueberfluessig aussehen.
|
||||
*/
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
username: string;
|
||||
role: Role;
|
||||
tenantId: string;
|
||||
mustChangePassword?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine hochgeladene Datei, so weit der Code sie liest.
|
||||
*
|
||||
* `Express.Multer.File` gibt es in diesem Baum nicht (`@types/multer` ist
|
||||
* nicht installiert, gemessen), und Nachinstallieren ist in diesem Lauf
|
||||
* ausgeschlossen. Diese Schnittstelle ist trotzdem keine Behauptung, sondern
|
||||
* belegt: KEIN Aufruf von `FileInterceptor`/`FilesInterceptor` in
|
||||
* `apps/api/src` setzt eine `storage`-Option (nachgezaehlt: sechs Aufrufe,
|
||||
* alle nur mit `limits`). Damit gilt multers Voreinstellung memoryStorage,
|
||||
* und damit ist `buffer` ein Buffer und keine Vermutung.
|
||||
*
|
||||
* Die Schnittstelle ersetzt keine Pruefung: die Groessengrenzen bleiben in
|
||||
* den Interceptor-Optionen, die PNG-Signaturpruefung bleibt in
|
||||
* `bug-reports.service.ts` (T-M34-04).
|
||||
*/
|
||||
export interface UploadedFileLike {
|
||||
buffer: Buffer;
|
||||
originalname: string;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Body, Controller, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { BugReportsService } from './bug-reports.service';
|
||||
import { BugReportDto } from './dto/bug-report.dto';
|
||||
|
||||
@@ -30,9 +31,9 @@ export class BugReportsController {
|
||||
FileInterceptor('screenshot', { limits: { fileSize: 4 * 1024 * 1024, files: 1 } }),
|
||||
)
|
||||
async submit(
|
||||
@CurrentUser() user: any,
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() dto: BugReportDto,
|
||||
@UploadedFile() file?: any,
|
||||
@UploadedFile() file?: UploadedFileLike,
|
||||
) {
|
||||
return this.service.submit(user, dto, file);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ConflictException,
|
||||
HttpException,
|
||||
} from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { BugReportsService } from './bug-reports.service';
|
||||
@@ -37,7 +38,7 @@ const PNG_1x1 = Buffer.from(
|
||||
'base64',
|
||||
);
|
||||
|
||||
const sessionUser = { id: 'u1', username: 'anna', role: 'USER', tenantId: 't1' };
|
||||
const sessionUser = { id: 'u1', username: 'anna', role: Role.USER, tenantId: 't1' };
|
||||
|
||||
const baseDto = {
|
||||
page: '/admin/users?tab=x',
|
||||
|
||||
@@ -14,6 +14,7 @@ import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
import { BugReportDto } from './dto/bug-report.dto';
|
||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { describeOrigin } from './origin';
|
||||
|
||||
/**
|
||||
@@ -58,18 +59,22 @@ const WINDOW_MS = 10 * 60 * 1000;
|
||||
const MAX_PER_WINDOW = 5;
|
||||
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
interface SessionUser {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
tenantId: string;
|
||||
}
|
||||
/**
|
||||
* Der Aufrufer, so weit dieser Dienst ihn liest. Seit quick-260921-m34 aus
|
||||
* `AuthUser` abgeleitet statt danebengestellt: es soll nicht zwei
|
||||
* Beschreibungen desselben Sitzungsobjekts geben, die auseinanderlaufen
|
||||
* koennen. Ausgelassen wird `mustChangePassword` — dieser Dienst liest es
|
||||
* nicht.
|
||||
*/
|
||||
type SessionUser = Pick<AuthUser, 'id' | 'username' | 'role' | 'tenantId'>;
|
||||
|
||||
interface UploadedPng {
|
||||
buffer: Buffer;
|
||||
size: number;
|
||||
mimetype?: string;
|
||||
}
|
||||
/**
|
||||
* Das Bildschirmfoto, so weit dieser Dienst es liest — abgeleitet aus
|
||||
* `UploadedFileLike`, damit die Hochladewege eine gemeinsame Beschreibung
|
||||
* haben. `originalname` fehlt bewusst: der Anhangname ist hier fest
|
||||
* vorgegeben (T-M97-04), der eingereichte Name wird nie benutzt.
|
||||
*/
|
||||
type UploadedPng = Pick<UploadedFileLike, 'buffer' | 'size' | 'mimetype'>;
|
||||
|
||||
@Injectable()
|
||||
export class BugReportsService {
|
||||
@@ -118,7 +123,7 @@ export class BugReportsService {
|
||||
|
||||
// (4) Benutzerzeile: gebunden an den Sitzungs-Mandanten, nie an Rumpfdaten
|
||||
// (T-M97-06; Zeile in docs/mandantentrennung-zugriffsklassifikation.md).
|
||||
const tenantPrisma = forTenant(this.prisma, user.tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, user.tenantId);
|
||||
const row = await tenantPrisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { username: true, displayName: true, email: true, role: true },
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Query,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import { CalendarService } from './calendar.service';
|
||||
import { CreateCalendarSourceDto } from './dto/create-calendar-source.dto';
|
||||
import { UpdateCalendarSourceDto } from './dto/update-calendar-source.dto';
|
||||
@@ -45,10 +45,10 @@ import { CalendarEventsQueryDto } from './dto/calendar-events-query.dto';
|
||||
export class CalendarController {
|
||||
constructor(private readonly calendarService: CalendarService) {}
|
||||
|
||||
private extractContext(req: Request) {
|
||||
const userId = (req as any).user?.id;
|
||||
private extractContext(req: AuthenticatedRequest) {
|
||||
const userId = req.user?.id;
|
||||
const tenantId =
|
||||
(req as any).tenantId ?? (req as any).user?.tenantId;
|
||||
req.tenantId ?? req.user?.tenantId;
|
||||
|
||||
if (!tenantId) {
|
||||
throw new ForbiddenException('No tenant context');
|
||||
@@ -66,7 +66,7 @@ export class CalendarController {
|
||||
* T-05-09: Uses Prisma select to exclude credentials; returns hasCredentials boolean.
|
||||
*/
|
||||
@Get('sources')
|
||||
async getSources(@Req() req: Request) {
|
||||
async getSources(@Req() req: AuthenticatedRequest) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
return this.calendarService.getSources(userId, tenantId);
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export class CalendarController {
|
||||
*/
|
||||
@Post('sources')
|
||||
async addSource(
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Body() dto: CreateCalendarSourceDto,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
@@ -93,7 +93,7 @@ export class CalendarController {
|
||||
@Patch('sources/:id')
|
||||
async updateSource(
|
||||
@Param('id') id: string,
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Body() dto: UpdateCalendarSourceDto,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
@@ -107,7 +107,7 @@ export class CalendarController {
|
||||
@Delete('sources/:id')
|
||||
async deleteSource(
|
||||
@Param('id') id: string,
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
return this.calendarService.deleteSource(id, userId, tenantId);
|
||||
@@ -132,7 +132,7 @@ export class CalendarController {
|
||||
@Post('sources/:id/test')
|
||||
async testSource(
|
||||
@Param('id') id: string,
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
return this.calendarService.testConnection(id, userId, tenantId);
|
||||
@@ -144,7 +144,7 @@ export class CalendarController {
|
||||
*/
|
||||
@Get('events')
|
||||
async getEvents(
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Query() query: CalendarEventsQueryDto,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { CryptoService } from '../crypto/crypto.service';
|
||||
@@ -185,7 +186,7 @@ export class CalendarService {
|
||||
// Exchange EWS servers are commonly on internal networks — SSRF check skipped for exchange type
|
||||
if (dto.type !== 'exchange') await this.validateUrlNotPrivate(dto.url);
|
||||
|
||||
const data: Record<string, unknown> = {
|
||||
const data: Prisma.CalendarSourceUncheckedCreateInput = {
|
||||
userId,
|
||||
tenantId,
|
||||
name: dto.name,
|
||||
@@ -203,7 +204,7 @@ export class CalendarService {
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const created = await tenantPrisma.calendarSource.create({
|
||||
data: data as any,
|
||||
data,
|
||||
select: SOURCE_SAFE_SELECT,
|
||||
});
|
||||
|
||||
@@ -234,7 +235,7 @@ export class CalendarService {
|
||||
await this.validateUrlNotPrivate(dto.url);
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
const data: Prisma.CalendarSourceUncheckedUpdateInput = {};
|
||||
if (dto.name !== undefined) data.name = dto.name;
|
||||
if (dto.type !== undefined) data.type = dto.type;
|
||||
if (dto.url !== undefined) data.url = dto.url;
|
||||
@@ -252,7 +253,7 @@ export class CalendarService {
|
||||
|
||||
const updated = await tenantPrisma.calendarSource.update({
|
||||
where: { id },
|
||||
data: data as any,
|
||||
data,
|
||||
select: {
|
||||
...SOURCE_SAFE_SELECT,
|
||||
encryptedPassword: true,
|
||||
@@ -338,8 +339,16 @@ export class CalendarService {
|
||||
try {
|
||||
// Exchange EWS servers are commonly on internal networks — SSRF check skipped for exchange type
|
||||
if (dto.type !== 'exchange') await this.validateUrlNotPrivate(dto.url);
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e?.message ?? 'URL not allowed' };
|
||||
} catch (e: unknown) {
|
||||
// validateUrlNotPrivate() wirft ausschliesslich ForbiddenException
|
||||
// (gemessen: jeder Wurf in der Methode ist eine, und der eigene
|
||||
// catch-Block dort setzt jeden Fremdfehler in eine um). instanceof
|
||||
// Error trifft deshalb genau dieselben Faelle wie das bisherige
|
||||
// e?.message; der Ersatzzweig bleibt derselbe (D-03).
|
||||
return {
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : 'URL not allowed',
|
||||
};
|
||||
}
|
||||
|
||||
const provider = this.getProvider(dto.type);
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import type { AuthProviderCallback } from '@microsoft/microsoft-graph-client';
|
||||
import { CalendarEvent, CalendarProvider } from '../calendar.service';
|
||||
|
||||
/** Optionen, die ntlmPost() unten uebergibt — nichts darueber hinaus. */
|
||||
interface NtlmOptions {
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
domain: string;
|
||||
workstation: string;
|
||||
body: string;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Antwortform von httpntlm.post, beschrieben aus dem, was gelesen wird.
|
||||
*
|
||||
* `body` ist `Buffer | string`: httpreq (unter httpntlm) liefert eine
|
||||
* Zeichenkette, solange `binary` nicht gesetzt ist (gemessen,
|
||||
* httpreq@1.1.1/lib/httpreq.js:391) — hier wird es nicht gesetzt. Siehe die
|
||||
* ausfuehrliche Begruendung in inbox/exchange-inbox.provider.ts.
|
||||
*/
|
||||
interface NtlmResponse {
|
||||
statusCode: number;
|
||||
body?: Buffer | string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const httpntlm = require('httpntlm') as {
|
||||
post: (opts: any, cb: (err: Error | null, res: any) => void) => void;
|
||||
post: (opts: NtlmOptions, cb: (err: Error | null, res: NtlmResponse) => void) => void;
|
||||
};
|
||||
|
||||
const NS_SOAP = 'http://schemas.xmlsoap.org/soap/envelope/';
|
||||
@@ -47,15 +72,14 @@ function extractAttr(xml: string, tag: string, attr: string): string {
|
||||
return attrMatch ? attrMatch[1] : '';
|
||||
}
|
||||
|
||||
function ntlmPost(opts: {
|
||||
url: string; username: string; password: string;
|
||||
domain: string; workstation: string; body: string;
|
||||
headers: Record<string, string>;
|
||||
}): Promise<{ statusCode: number; body: string }> {
|
||||
function ntlmPost(opts: NtlmOptions): Promise<{ statusCode: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
httpntlm.post(opts, (err, res) => {
|
||||
if (err) return reject(err);
|
||||
resolve({ statusCode: res.statusCode, body: res.body?.toString('utf-8') ?? '' });
|
||||
resolve({
|
||||
statusCode: res.statusCode,
|
||||
body: typeof res.body === 'string' ? res.body : (res.body?.toString('utf-8') ?? ''),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -154,7 +178,7 @@ export class ExchangeProvider implements CalendarProvider {
|
||||
);
|
||||
|
||||
const client = GraphClient.init({
|
||||
authProvider: (done: (error: any, token: string) => void) => {
|
||||
authProvider: (done: AuthProviderCallback) => {
|
||||
// Use the password as the access token (OAuth bearer token)
|
||||
// Users configure their OAuth token in the password field for Graph API
|
||||
done(null, source.password || '');
|
||||
@@ -310,7 +334,7 @@ export class ExchangeProvider implements CalendarProvider {
|
||||
);
|
||||
|
||||
const client = GraphClient.init({
|
||||
authProvider: (done: (error: any, token: string) => void) => {
|
||||
authProvider: (done: AuthProviderCallback) => {
|
||||
done(null, source.password || '');
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { UseModule } from '../module-registry/module.guard';
|
||||
import type { UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { CertManagerService } from './cert-manager.service';
|
||||
|
||||
/**
|
||||
@@ -39,7 +40,7 @@ export class CertManagerController {
|
||||
}),
|
||||
)
|
||||
async parseCert(
|
||||
@UploadedFile() file: any,
|
||||
@UploadedFile() file: UploadedFileLike | undefined,
|
||||
@Body('password') password?: string,
|
||||
@Body('pemText') pemText?: string,
|
||||
) {
|
||||
@@ -60,7 +61,7 @@ export class CertManagerController {
|
||||
}),
|
||||
)
|
||||
async splitCerts(
|
||||
@UploadedFile() file: any,
|
||||
@UploadedFile() file: UploadedFileLike | undefined,
|
||||
@Body('password') password?: string,
|
||||
) {
|
||||
if (!file) {
|
||||
@@ -81,7 +82,7 @@ export class CertManagerController {
|
||||
}),
|
||||
)
|
||||
async mergeCerts(
|
||||
@UploadedFiles() files: any[],
|
||||
@UploadedFiles() files: UploadedFileLike[],
|
||||
@Body('outputFormat') outputFormat: string,
|
||||
@Body('password') password?: string,
|
||||
) {
|
||||
@@ -107,7 +108,7 @@ export class CertManagerController {
|
||||
}),
|
||||
)
|
||||
async convertCert(
|
||||
@UploadedFile() file: any,
|
||||
@UploadedFile() file: UploadedFileLike | undefined,
|
||||
@Body('targetFormat') targetFormat: string,
|
||||
@Body('password') password?: string,
|
||||
@Body('pemText') pemText?: string,
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import * as forge from 'node-forge';
|
||||
|
||||
/**
|
||||
* Was `forge.pkcs7.messageFromPem()` bzw. `messageFromAsn1()` zurueckgeben —
|
||||
* der mitgelieferte Typ aus `@types/node-forge`, nicht ein eigener.
|
||||
*
|
||||
* Nur die signierte Form traegt `certificates`; die Lesestellen grenzen
|
||||
* deshalb mit `'certificates' in p7` ein. Das ist verhaltensgleich zum
|
||||
* bisherigen `p7.certificates ?? []`: bei einer enveloped-Nachricht fehlt
|
||||
* das Feld, und beide Schreibweisen liefern dann die leere Liste.
|
||||
*/
|
||||
type P7Message = forge.pkcs7.Captured<
|
||||
forge.pkcs7.PkcsEnvelopedData | forge.pkcs7.PkcsSignedData
|
||||
>;
|
||||
import type { UploadedFileLike } from '../auth/types/auth-user';
|
||||
|
||||
/**
|
||||
* Eine hochgeladene Zertifikatsdatei, so weit dieser Dienst sie liest:
|
||||
* Inhalt und eingereichter Name (der Name geht ausschliesslich in
|
||||
* `detectFormat` und in Fehlermeldungen). Abgeleitet aus `UploadedFileLike`
|
||||
* statt daneben erfunden (quick-260921-m34); `mimetype` und `size` bleiben
|
||||
* bewusst draussen, weil kein Zweig dieses Dienstes sie liest.
|
||||
*/
|
||||
type CertFileLike = Pick<UploadedFileLike, 'buffer' | 'originalname'>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CertDetails — the structured result returned by parseCert
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -162,7 +185,7 @@ export class CertManagerService {
|
||||
* - Wrong PFX password → generic 400 message (password value never logged or echoed)
|
||||
*/
|
||||
async parseCert(input: {
|
||||
file?: any;
|
||||
file?: CertFileLike;
|
||||
pemText?: string;
|
||||
password?: string;
|
||||
}): Promise<CertDetails> {
|
||||
@@ -179,11 +202,11 @@ export class CertManagerService {
|
||||
}
|
||||
cert = certs[0];
|
||||
} else if (file) {
|
||||
const format = this.detectFormat(file.originalname as string, file.buffer as Buffer);
|
||||
const format = this.detectFormat(file.originalname, file.buffer);
|
||||
|
||||
if (format === 'pem') {
|
||||
// ── PEM file ───────────────────────────────────────────────────────
|
||||
const pemStr = (file.buffer as Buffer).toString('utf-8');
|
||||
const pemStr = file.buffer.toString('utf-8');
|
||||
const certs = this.parsePemChain(pemStr);
|
||||
if (certs.length === 0) {
|
||||
throw new Error('No certificate block found in PEM file');
|
||||
@@ -192,12 +215,12 @@ export class CertManagerService {
|
||||
} else if (format === 'der') {
|
||||
// ── DER binary file ────────────────────────────────────────────────
|
||||
// CRITICAL: binary encoding, never utf-8 (RESEARCH Pitfall 1)
|
||||
const asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
|
||||
const asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
|
||||
cert = forge.pki.certificateFromAsn1(asn1);
|
||||
} else if (format === 'pfx') {
|
||||
// ── PFX/PKCS12 file ───────────────────────────────────────────────
|
||||
// wrong password → forge throws → caught below → BadRequestException (T-09-02)
|
||||
const p12Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
|
||||
const p12Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
|
||||
const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password ?? '');
|
||||
const certBags = p12.getBags({ bagType: forge.pki.oids.certBag });
|
||||
const bags = certBags[forge.pki.oids.certBag] ?? [];
|
||||
@@ -217,18 +240,20 @@ export class CertManagerService {
|
||||
cert = parsedCert;
|
||||
} else {
|
||||
// ── P7B/PKCS7 file — PEM-wrapped or binary DER (Pitfall 4) ────────
|
||||
const isPemP7b = (file.buffer as Buffer)
|
||||
const isPemP7b = file.buffer
|
||||
.slice(0, 27)
|
||||
.toString('ascii')
|
||||
.includes('-----BEGIN');
|
||||
let p7: any;
|
||||
// siehe P7Message oben — mitgelieferter Typ, keine Behauptung.
|
||||
let p7: P7Message;
|
||||
if (isPemP7b) {
|
||||
p7 = forge.pkcs7.messageFromPem((file.buffer as Buffer).toString('utf-8'));
|
||||
p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8'));
|
||||
} else {
|
||||
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
|
||||
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
|
||||
p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
|
||||
}
|
||||
const p7Certs: forge.pki.Certificate[] = p7.certificates ?? [];
|
||||
const p7Certs: forge.pki.Certificate[] =
|
||||
'certificates' in p7 ? p7.certificates : [];
|
||||
if (p7Certs.length === 0) {
|
||||
throw new Error('No certificate found in P7B/PKCS7');
|
||||
}
|
||||
@@ -260,6 +285,14 @@ export class CertManagerService {
|
||||
);
|
||||
|
||||
// Key type and size
|
||||
// BLEIBT als any, mit Begruendung (260921-m34, Aufgabe 3c, D-01/D-02):
|
||||
// @types/node-forge kennt nur `PublicKey = rsa.PublicKey | ed25519.Key`
|
||||
// (index.d.ts:232). Der EC-Zweig unten liest `curve` und
|
||||
// `params.curve.q.bitLength()` — Felder, die node-forge zur Laufzeit
|
||||
// liefert, die der mitgelieferte Typ aber GAR NICHT kennt. Eine
|
||||
// Umdeutung ueber zwei Stufen wuerde dieselbe Luecke verdecken und
|
||||
// zusaetzlich so aussehen, als sei sie geprueft. Ein ehrliches any mit
|
||||
// dieser Zeile ist hier das bessere Ergebnis.
|
||||
const pubKey = cert.publicKey as any;
|
||||
let keyType = 'RSA';
|
||||
let keyBits = 0;
|
||||
@@ -273,13 +306,24 @@ export class CertManagerService {
|
||||
}
|
||||
|
||||
// Subject Alternative Names
|
||||
//
|
||||
// BLEIBEN als any, mit Begruendung (260921-m34, Aufgabe 3c, D-01/D-02):
|
||||
// @types/node-forge deklariert `Certificate.extensions` als `any[]`
|
||||
// (index.d.ts:435) und sagt damit ueber den Inhalt einer Erweiterung
|
||||
// NICHTS aus. Jede Schnittstelle, die wir hier selbst fuer `altNames`
|
||||
// schrieben, waere unbelegt — der Compiler koennte sie an keiner
|
||||
// Stelle gegen etwas pruefen, sie saehe aber geprueft aus. Die drei
|
||||
// any-Stellen dieses Blocks bleiben deshalb sichtbar stehen, statt
|
||||
// gegen eine Behauptung getauscht zu werden.
|
||||
const sanExt = cert.extensions?.find((e: any) => e.name === 'subjectAltName');
|
||||
const san: string[] = ((sanExt as any)?.altNames ?? []).map((n: any) =>
|
||||
n.type === 2 ? (n.value as string) : `IP:${(n.ip ?? n.value) as string}`,
|
||||
);
|
||||
|
||||
// Signature algorithm — OID → human-readable name
|
||||
const sigOid = (cert.siginfo as any)?.algorithmOid ?? '';
|
||||
// @types/node-forge deklariert siginfo.algorithmOid als string — die
|
||||
// Zusicherung war ueberfluessig. Das ?. bleibt woertlich erhalten.
|
||||
const sigOid = cert.siginfo?.algorithmOid ?? '';
|
||||
const signatureAlgorithm = REVERSE_OIDS[sigOid] ?? sigOid;
|
||||
|
||||
// Fingerprints
|
||||
@@ -332,7 +376,7 @@ export class CertManagerService {
|
||||
* - File size limit 5 MB enforced by FileInterceptor in the controller
|
||||
*/
|
||||
async splitCerts(input: {
|
||||
file?: any;
|
||||
file?: CertFileLike;
|
||||
password?: string;
|
||||
}): Promise<SplitResponse> {
|
||||
const { file } = input;
|
||||
@@ -344,31 +388,33 @@ export class CertManagerService {
|
||||
let certs: forge.pki.Certificate[];
|
||||
|
||||
try {
|
||||
const format = this.detectFormat(file.originalname as string, file.buffer as Buffer);
|
||||
const format = this.detectFormat(file.originalname, file.buffer);
|
||||
|
||||
if (format === 'pem') {
|
||||
// ── PEM chain (fullchain.pem, .crt — both map to 'pem' in detectFormat) ─
|
||||
const pemStr = (file.buffer as Buffer).toString('utf-8');
|
||||
const pemStr = file.buffer.toString('utf-8');
|
||||
certs = this.parsePemChain(pemStr);
|
||||
if (certs.length === 0) {
|
||||
throw new Error('No certificate blocks found in PEM file');
|
||||
}
|
||||
} else if (format === 'p7b') {
|
||||
// ── P7B/PKCS7 bundle — PEM-wrapped or binary DER (Pitfall 4) ─────────
|
||||
const isPemP7b = (file.buffer as Buffer)
|
||||
const isPemP7b = file.buffer
|
||||
.slice(0, 27)
|
||||
.toString('ascii')
|
||||
.includes('-----BEGIN');
|
||||
let p7: any;
|
||||
// Der mitgelieferte Typ traegt hier: messageFromPem/messageFromAsn1
|
||||
// liefern beide Captured<PkcsEnvelopedData | PkcsSignedData>.
|
||||
let p7: P7Message;
|
||||
if (isPemP7b) {
|
||||
// PEM-wrapped PKCS7 (e.g. -----BEGIN PKCS7-----)
|
||||
p7 = forge.pkcs7.messageFromPem((file.buffer as Buffer).toString('utf-8'));
|
||||
p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8'));
|
||||
} else {
|
||||
// Binary DER PKCS7
|
||||
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
|
||||
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
|
||||
p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
|
||||
}
|
||||
certs = (p7.certificates as forge.pki.Certificate[]) ?? [];
|
||||
certs = 'certificates' in p7 ? p7.certificates : [];
|
||||
if (certs.length === 0) {
|
||||
throw new Error('No certificates found in P7B/PKCS7 bundle');
|
||||
}
|
||||
@@ -434,7 +480,7 @@ export class CertManagerService {
|
||||
* No fallback to lower-level certBag construction was needed.
|
||||
*/
|
||||
async mergeCerts(input: {
|
||||
files?: any[];
|
||||
files?: CertFileLike[];
|
||||
outputFormat: string;
|
||||
password?: string;
|
||||
}): Promise<FileResponse> {
|
||||
@@ -453,26 +499,23 @@ export class CertManagerService {
|
||||
let certs: forge.pki.Certificate[];
|
||||
|
||||
try {
|
||||
certs = (files as any[]).flatMap((file: any) => {
|
||||
const format = this.detectFormat(
|
||||
file.originalname as string,
|
||||
file.buffer as Buffer,
|
||||
);
|
||||
certs = files.flatMap((file) => {
|
||||
const format = this.detectFormat(file.originalname, file.buffer);
|
||||
|
||||
if (format === 'pem') {
|
||||
const pemStr = (file.buffer as Buffer).toString('utf-8');
|
||||
const pemStr = file.buffer.toString('utf-8');
|
||||
const parsed = this.parsePemChain(pemStr);
|
||||
if (parsed.length === 0) {
|
||||
throw new Error(`No certificate block found in ${file.originalname as string}`);
|
||||
throw new Error(`No certificate block found in ${file.originalname}`);
|
||||
}
|
||||
return parsed;
|
||||
} else if (format === 'der') {
|
||||
// CRITICAL: binary encoding, never utf-8 (RESEARCH Pitfall 1)
|
||||
const asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
|
||||
const asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
|
||||
return [forge.pki.certificateFromAsn1(asn1)];
|
||||
} else if (format === 'pfx') {
|
||||
// Extract all certs from the PFX bag
|
||||
const p12Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
|
||||
const p12Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
|
||||
const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password ?? '');
|
||||
const certBags = p12.getBags({ bagType: forge.pki.oids.certBag });
|
||||
const bags = certBags[forge.pki.oids.certBag] ?? [];
|
||||
@@ -488,24 +531,25 @@ export class CertManagerService {
|
||||
const missingCertIndex = bagCerts.findIndex((c) => c === undefined || c === null);
|
||||
if (missingCertIndex !== -1) {
|
||||
throw new BadRequestException(
|
||||
`Certificate bag in "${file.originalname as string}" does not contain a readable X.509 certificate`,
|
||||
`Certificate bag in "${file.originalname}" does not contain a readable X.509 certificate`,
|
||||
);
|
||||
}
|
||||
return bagCerts.filter((c): c is forge.pki.Certificate => c !== undefined && c !== null);
|
||||
} else {
|
||||
// P7B/PKCS7 — PEM-wrapped or binary DER (Pitfall 4)
|
||||
const isPemP7b = (file.buffer as Buffer)
|
||||
const isPemP7b = file.buffer
|
||||
.slice(0, 27)
|
||||
.toString('ascii')
|
||||
.includes('-----BEGIN');
|
||||
let p7: any;
|
||||
// siehe P7Message oben — mitgelieferter Typ, keine Behauptung.
|
||||
let p7: P7Message;
|
||||
if (isPemP7b) {
|
||||
p7 = forge.pkcs7.messageFromPem((file.buffer as Buffer).toString('utf-8'));
|
||||
p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8'));
|
||||
} else {
|
||||
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
|
||||
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
|
||||
p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
|
||||
}
|
||||
return (p7.certificates as forge.pki.Certificate[]) ?? [];
|
||||
return 'certificates' in p7 ? p7.certificates : [];
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -536,6 +580,12 @@ export class CertManagerService {
|
||||
// Open Question 1 resolution: toPkcs12Asn1(null, certs, password) works in node-forge 1.4.0
|
||||
// null as the private key produces a cert-only PKCS12 bundle (no key bag — cert bag only)
|
||||
const p12Asn1 = forge.pkcs12.toPkcs12Asn1(
|
||||
// BLEIBT (260921-m34, Aufgabe 3c): node-forge 1.4.0 nimmt hier einen
|
||||
// fehlenden Schluessel an und erzeugt ein reines
|
||||
// Zertifikatsbuendel; @types/node-forge schliesst null aus. Die
|
||||
// mitgelieferten Typen beschreiben die Bibliothek an dieser Stelle
|
||||
// also nachweislich falsch — ein erzwungener Typ waere eine
|
||||
// Behauptung ueber etwas, das nicht stimmt.
|
||||
null as any, // cert-only PFX — null key accepted by node-forge 1.4.0
|
||||
certs,
|
||||
password!,
|
||||
@@ -571,7 +621,7 @@ export class CertManagerService {
|
||||
* - Password is never passed to the logger (T-09-02)
|
||||
*/
|
||||
async convertCert(input: {
|
||||
file?: any;
|
||||
file?: CertFileLike;
|
||||
pemText?: string;
|
||||
targetFormat: string;
|
||||
password?: string;
|
||||
@@ -602,10 +652,10 @@ export class CertManagerService {
|
||||
}
|
||||
cert = certs[0];
|
||||
} else if (file) {
|
||||
const format = this.detectFormat(file.originalname as string, file.buffer as Buffer);
|
||||
const format = this.detectFormat(file.originalname, file.buffer);
|
||||
|
||||
if (format === 'pem') {
|
||||
const pemStr = (file.buffer as Buffer).toString('utf-8');
|
||||
const pemStr = file.buffer.toString('utf-8');
|
||||
const certs = this.parsePemChain(pemStr);
|
||||
if (certs.length === 0) {
|
||||
throw new Error('No certificate block found in PEM file');
|
||||
@@ -613,11 +663,11 @@ export class CertManagerService {
|
||||
cert = certs[0];
|
||||
} else if (format === 'der') {
|
||||
// CRITICAL: binary encoding, never utf-8 (RESEARCH Pitfall 1)
|
||||
const asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
|
||||
const asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
|
||||
cert = forge.pki.certificateFromAsn1(asn1);
|
||||
} else if (format === 'pfx') {
|
||||
// PFX/PKCS12 — extract first cert bag (wrong password → BadRequestException)
|
||||
const p12Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
|
||||
const p12Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
|
||||
const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password ?? '');
|
||||
const certBags = p12.getBags({ bagType: forge.pki.oids.certBag });
|
||||
const bags = certBags[forge.pki.oids.certBag] ?? [];
|
||||
@@ -637,18 +687,20 @@ export class CertManagerService {
|
||||
cert = parsedCert;
|
||||
} else {
|
||||
// P7B — extract first cert
|
||||
const isPemP7b = (file.buffer as Buffer)
|
||||
const isPemP7b = file.buffer
|
||||
.slice(0, 27)
|
||||
.toString('ascii')
|
||||
.includes('-----BEGIN');
|
||||
let p7: any;
|
||||
// siehe P7Message oben — mitgelieferter Typ, keine Behauptung.
|
||||
let p7: P7Message;
|
||||
if (isPemP7b) {
|
||||
p7 = forge.pkcs7.messageFromPem((file.buffer as Buffer).toString('utf-8'));
|
||||
p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8'));
|
||||
} else {
|
||||
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer as Buffer));
|
||||
const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer));
|
||||
p7 = forge.pkcs7.messageFromAsn1(p7Asn1);
|
||||
}
|
||||
const p7Certs: forge.pki.Certificate[] = p7.certificates ?? [];
|
||||
const p7Certs: forge.pki.Certificate[] =
|
||||
'certificates' in p7 ? p7.certificates : [];
|
||||
if (p7Certs.length === 0) {
|
||||
throw new Error('No certificate found in P7B/PKCS7');
|
||||
}
|
||||
@@ -691,6 +743,12 @@ export class CertManagerService {
|
||||
} else {
|
||||
// PFX — cert-only PKCS12 bundle (Open Question 1: null key works in node-forge 1.4.0)
|
||||
const p12Asn1 = forge.pkcs12.toPkcs12Asn1(
|
||||
// BLEIBT (260921-m34, Aufgabe 3c): node-forge 1.4.0 nimmt hier einen
|
||||
// fehlenden Schluessel an und erzeugt ein reines
|
||||
// Zertifikatsbuendel; @types/node-forge schliesst null aus. Die
|
||||
// mitgelieferten Typen beschreiben die Bibliothek an dieser Stelle
|
||||
// also nachweislich falsch — ein erzwungener Typ waere eine
|
||||
// Behauptung ueber etwas, das nicht stimmt.
|
||||
null as any, // cert-only PFX — null key accepted by node-forge 1.4.0
|
||||
[cert],
|
||||
password!,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DASHBOARD_IMAGE_MAX_BYTES,
|
||||
DASHBOARD_IMAGE_MAX_COUNT,
|
||||
detectImageMime,
|
||||
} from './dashboard-image-rules';
|
||||
|
||||
/**
|
||||
* dashboard-image-rules.spec — NEU (quick-260921-pi9, Bilderrahmen-Widget).
|
||||
*
|
||||
* Die Magic-Byte-Erkennung entscheidet Annahme UND gespeicherten Typ eines
|
||||
* hochgeladenen Bildes (T-PI9-01); Dateiname und behaupteter MIME-Typ werden
|
||||
* nie gelesen. Deshalb wird sie hier direkt an den Bytes geprueft: vier
|
||||
* gueltige Signaturen, und alles, was NICHT durchgehen darf (leer, Text,
|
||||
* SVG, PDF, RIFF ohne WEBP).
|
||||
*/
|
||||
function bytes(...parts: (number[] | string)[]): Uint8Array {
|
||||
const out: number[] = [];
|
||||
for (const p of parts) {
|
||||
if (typeof p === 'string') {
|
||||
for (const ch of p) out.push(ch.charCodeAt(0));
|
||||
} else {
|
||||
out.push(...p);
|
||||
}
|
||||
}
|
||||
return Uint8Array.from(out);
|
||||
}
|
||||
|
||||
describe('detectImageMime (quick-260921-pi9)', () => {
|
||||
it('Test 1: PNG-Signatur 89 50 4E 47 0D 0A 1A 0A -> image/png', () => {
|
||||
expect(detectImageMime(bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], [0, 0, 0, 13]))).toBe(
|
||||
'image/png',
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 2: FF D8 FF -> image/jpeg', () => {
|
||||
expect(detectImageMime(bytes([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10], 'JFIF'))).toBe('image/jpeg');
|
||||
});
|
||||
|
||||
it('Test 3: GIF87a und GIF89a -> image/gif', () => {
|
||||
expect(detectImageMime(bytes('GIF87a', [1, 0, 1, 0]))).toBe('image/gif');
|
||||
expect(detectImageMime(bytes('GIF89a', [1, 0, 1, 0]))).toBe('image/gif');
|
||||
});
|
||||
|
||||
it('Test 4: RIFF????WEBP -> image/webp', () => {
|
||||
expect(detectImageMime(bytes('RIFF', [0x24, 0x00, 0x00, 0x00], 'WEBP', 'VP8 '))).toBe('image/webp');
|
||||
});
|
||||
|
||||
it('Test 5: leerer Puffer -> null', () => {
|
||||
expect(detectImageMime(new Uint8Array(0))).toBeNull();
|
||||
});
|
||||
|
||||
it('Test 6: Textdatei -> null, auch wenn sie lang genug ist', () => {
|
||||
expect(detectImageMime(bytes('Dies ist keine Bilddatei, sondern Text.'))).toBeNull();
|
||||
});
|
||||
|
||||
it('Test 7: SVG-Text und PDF (%PDF) -> null', () => {
|
||||
expect(detectImageMime(bytes('<svg xmlns="http://www.w3.org/2000/svg"></svg>'))).toBeNull();
|
||||
expect(detectImageMime(bytes('%PDF-1.7\n%\xe2\xe3'))).toBeNull();
|
||||
});
|
||||
|
||||
it('Test 8: RIFF-Kopf ohne WEBP an Stelle 8 (z. B. WAVE) -> null', () => {
|
||||
expect(detectImageMime(bytes('RIFF', [0x24, 0x00, 0x00, 0x00], 'WAVE', 'fmt '))).toBeNull();
|
||||
});
|
||||
|
||||
it('Test 9: zu kurze Puffer werfen nicht — PNG-Anfang mit 4 Bytes ist null, JPEG-Anfang mit 2 Bytes ist null', () => {
|
||||
expect(detectImageMime(bytes([0x89, 0x50, 0x4e, 0x47]))).toBeNull();
|
||||
expect(detectImageMime(bytes([0xff, 0xd8]))).toBeNull();
|
||||
expect(detectImageMime(bytes('RIFF'))).toBeNull();
|
||||
});
|
||||
|
||||
it('Test 10: Grenzen — 5 MiB je Datei, 30 Bilder je Benutzer', () => {
|
||||
expect(DASHBOARD_IMAGE_MAX_BYTES).toBe(5 * 1024 * 1024);
|
||||
expect(DASHBOARD_IMAGE_MAX_COUNT).toBe(30);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* dashboard-image-rules — reine Regeln fuer das Bilderrahmen-Widget
|
||||
* (quick-260921-pi9). Kein Nest, kein Prisma: Grenzen und die
|
||||
* Magic-Byte-Erkennung, damit Dienst und Controller dieselben Werte
|
||||
* anwenden und die Erkennung direkt an den Bytes testbar ist.
|
||||
*
|
||||
* Warum Magic Bytes und nicht `file.mimetype` oder die Dateiendung
|
||||
* (T-PI9-01): beides behauptet der Browser, beides kann eine umbenannte
|
||||
* HTML-, SVG- oder PDF-Datei tragen. Was hier NICHT als eine der vier
|
||||
* Signaturen erkannt wird, kommt nicht in die Datenbank — und der erkannte
|
||||
* Typ ist zugleich der Typ, mit dem die Auslieferung antwortet (T-PI9-08).
|
||||
*
|
||||
* Bewusst KEIN `file-type`-Paket (T-PI9-SC): vier feste Signaturen sind
|
||||
* eine Handvoll Zeilen und brauchen keine Abhaengigkeit.
|
||||
*/
|
||||
|
||||
/** Hoechstgroesse je Datei: 5 MiB (multer `limits.fileSize` je Route). */
|
||||
export const DASHBOARD_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/** Hoechstzahl je Benutzer (Zaehler je Mandant+Benutzer vor dem Anlegen). */
|
||||
export const DASHBOARD_IMAGE_MAX_COUNT = 30;
|
||||
|
||||
export type DashboardImageMime = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
|
||||
|
||||
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
const JPEG_SIGNATURE = [0xff, 0xd8, 0xff];
|
||||
const GIF87A = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]; // "GIF87a"
|
||||
const GIF89A = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]; // "GIF89a"
|
||||
const RIFF = [0x52, 0x49, 0x46, 0x46]; // "RIFF"
|
||||
const WEBP = [0x57, 0x45, 0x42, 0x50]; // "WEBP"
|
||||
|
||||
function startsWith(buffer: Uint8Array, signature: number[], offset = 0): boolean {
|
||||
if (buffer.length < offset + signature.length) return false;
|
||||
for (let i = 0; i < signature.length; i++) {
|
||||
if (buffer[offset + i] !== signature[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erkennt PNG, JPEG, GIF und WebP an den ersten Bytes; alles andere
|
||||
* (leer, Text, SVG, PDF, RIFF ohne WEBP an Stelle 8) ergibt `null`.
|
||||
* Wirft nie — ein zu kurzer Puffer ist schlicht kein Bild.
|
||||
*/
|
||||
export function detectImageMime(buffer: Uint8Array): DashboardImageMime | null {
|
||||
if (startsWith(buffer, PNG_SIGNATURE)) return 'image/png';
|
||||
if (startsWith(buffer, JPEG_SIGNATURE)) return 'image/jpeg';
|
||||
if (startsWith(buffer, GIF87A) || startsWith(buffer, GIF89A)) return 'image/gif';
|
||||
if (startsWith(buffer, RIFF) && startsWith(buffer, WEBP, 8)) return 'image/webp';
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import 'reflect-metadata';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* `FileInterceptor` wird als Attrappe eingesetzt, damit die Grenzen je
|
||||
* Route (T-PI9-02) am AUFRUF pruefbar sind — die echte Fabrik verschliesst
|
||||
* ihre Optionen in einer Klasse ohne lesbare Metadaten.
|
||||
*/
|
||||
const { fileInterceptorMock } = vi.hoisted(() => ({
|
||||
fileInterceptorMock: vi.fn(() => class FakeInterceptor {}),
|
||||
}));
|
||||
vi.mock('@nestjs/platform-express', () => ({ FileInterceptor: fileInterceptorMock }));
|
||||
|
||||
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||
import type { AuthUser } from '../auth/types/auth-user';
|
||||
import { DASHBOARD_IMAGE_MAX_BYTES } from './dashboard-image-rules';
|
||||
import { DashboardImagesController } from './dashboard-images.controller';
|
||||
|
||||
/**
|
||||
* dashboard-images.controller.spec — NEU (quick-260921-pi9).
|
||||
*
|
||||
* Fuenf Tests an der Grenze Browser -> API: Pfad und Routenreihenfolge
|
||||
* (statisch vor `:id`), Interceptor-Grenzen (5 MiB, genau eine Datei, Feld
|
||||
* `image`), die Header der Binaerantwort (Content-Type aus dem gespeicherten
|
||||
* Typ, private Cache, nosniff, inline OHNE Dateinamen, CSP), kein
|
||||
* `@Roles`-Metadatum, und die Weitergabe von Mandant/Benutzer ausschliesslich
|
||||
* aus dem Sitzungsnachweis (T-PI9-11).
|
||||
*/
|
||||
const user: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'anna',
|
||||
role: 'USER',
|
||||
tenantId: 'tenant-1',
|
||||
mustChangePassword: false,
|
||||
};
|
||||
|
||||
function makeService() {
|
||||
return {
|
||||
list: vi.fn(async () => []),
|
||||
upload: vi.fn(async () => ({ id: 'new' })),
|
||||
getBytes: vi.fn(async () => ({
|
||||
mimeType: 'image/webp',
|
||||
data: Uint8Array.from([0x52, 0x49, 0x46, 0x46]),
|
||||
})),
|
||||
remove: vi.fn(async (id: string) => ({ id })),
|
||||
};
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
const headers: Record<string, string> = {};
|
||||
return {
|
||||
headers,
|
||||
setHeader: vi.fn((name: string, value: string) => {
|
||||
headers[name] = value;
|
||||
}),
|
||||
send: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
// Kein vi.clearAllMocks() vor den Tests: der Interceptor-Aufruf geschieht
|
||||
// beim Laden der Klasse (Dekorator), Test 2 liest genau diesen Aufruf.
|
||||
describe('DashboardImagesController (quick-260921-pi9)', () => {
|
||||
it('Test 1: Pfad dashboard/images; Reihenfolge list -> upload -> getImage -> remove (statische Routen vor :id)', () => {
|
||||
expect(Reflect.getMetadata('path', DashboardImagesController)).toBe('dashboard/images');
|
||||
const proto = DashboardImagesController.prototype;
|
||||
const order = Object.getOwnPropertyNames(proto).filter((n) => n !== 'constructor');
|
||||
expect(order).toEqual(['list', 'upload', 'getImage', 'remove']);
|
||||
expect(Reflect.getMetadata('path', proto.getImage)).toBe(':id');
|
||||
expect(Reflect.getMetadata('path', proto.remove)).toBe(':id');
|
||||
expect(Reflect.getMetadata('path', proto.list)).toBe('/');
|
||||
expect(Reflect.getMetadata('path', proto.upload)).toBe('/');
|
||||
});
|
||||
|
||||
it('Test 2: FileInterceptor nimmt das Feld image mit fileSize 5 MiB und files 1 (T-PI9-02)', () => {
|
||||
expect(fileInterceptorMock).toHaveBeenCalledTimes(1);
|
||||
expect(fileInterceptorMock.mock.calls[0]).toEqual([
|
||||
'image',
|
||||
{ limits: { fileSize: DASHBOARD_IMAGE_MAX_BYTES, files: 1 } },
|
||||
]);
|
||||
expect(DASHBOARD_IMAGE_MAX_BYTES).toBe(5 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it('Test 3: Binaerantwort traegt Content-Type aus dem gespeicherten Typ, Cache private, nosniff, inline ohne Dateinamen, CSP; Bytes als Buffer', async () => {
|
||||
const service = makeService();
|
||||
const controller = new DashboardImagesController(service as never);
|
||||
const res = makeRes();
|
||||
|
||||
await controller.getImage('img-1', user, res as never);
|
||||
|
||||
expect(service.getBytes).toHaveBeenCalledWith('img-1', 'user-1', 'tenant-1');
|
||||
expect(res.headers['Content-Type']).toBe('image/webp');
|
||||
expect(res.headers['Cache-Control']).toBe('private, max-age=86400');
|
||||
expect(res.headers['X-Content-Type-Options']).toBe('nosniff');
|
||||
expect(res.headers['Content-Disposition']).toBe('inline');
|
||||
expect(res.headers['Content-Security-Policy']).toBe("default-src 'none'; sandbox");
|
||||
const sent = res.send.mock.calls[0][0] as unknown;
|
||||
expect(Buffer.isBuffer(sent)).toBe(true);
|
||||
expect((sent as Buffer).equals(Buffer.from([0x52, 0x49, 0x46, 0x46]))).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 4: kein @Roles-Metadatum auf einer der vier Routen — alle angemeldeten Rollen', () => {
|
||||
const proto = DashboardImagesController.prototype;
|
||||
for (const name of ['list', 'upload', 'getImage', 'remove'] as const) {
|
||||
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('Test 5: list/upload/remove reichen Mandant und Benutzer nur aus dem Sitzungsnachweis weiter; remove antwortet { id }', async () => {
|
||||
const service = makeService();
|
||||
const controller = new DashboardImagesController(service as never);
|
||||
const file = { buffer: Buffer.alloc(0), originalname: 'x.png', mimetype: 'image/png', size: 0 };
|
||||
|
||||
await controller.list(user);
|
||||
expect(service.list).toHaveBeenCalledWith('user-1', 'tenant-1');
|
||||
|
||||
await controller.upload(user, file);
|
||||
expect(service.upload).toHaveBeenCalledWith(user, file);
|
||||
|
||||
await expect(controller.remove('img-9', user)).resolves.toEqual({ id: 'img-9' });
|
||||
expect(service.remove).toHaveBeenCalledWith('img-9', 'user-1', 'tenant-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { Response } from 'express';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { DASHBOARD_IMAGE_MAX_BYTES } from './dashboard-image-rules';
|
||||
import { DashboardImagesService } from './dashboard-images.service';
|
||||
|
||||
/**
|
||||
* Bilder des Bilderrahmen-Widgets (quick-260921-pi9).
|
||||
*
|
||||
* Offen fuer ALLE angemeldeten Rollen: bewusst KEIN Rollen-Dekorator
|
||||
* (Muster bug-reports.controller.ts — der globale `JwtAuthGuard` verlangt
|
||||
* weiterhin eine Sitzung). Mandant und Benutzer kommen NUR aus dem
|
||||
* Sitzungsnachweis (`@CurrentUser()`), nie aus dem Rumpf (T-PI9-11); der
|
||||
* Multipart-Rumpf hat genau das Feld `image`.
|
||||
*
|
||||
* Groessenlimit JE ROUTE (T-PI9-02): `FileInterceptor` nimmt genau eine
|
||||
* Datei bis 5 MiB; multers `LIMIT_FILE_SIZE` bildet Nest auf 413 ab (die
|
||||
* deutsche Meldung dafuer entsteht im Web-Klienten aus dem Status).
|
||||
* `main.ts` bleibt ohne globales Body-Limit.
|
||||
*
|
||||
* Routen (statische VOR `:id`, sonst 404-Shadowing):
|
||||
* - GET /dashboard/images — eigene Bilder, nur Metadaten
|
||||
* - POST /dashboard/images — Bild hochladen (multipart-Feld `image`)
|
||||
* - GET /dashboard/images/:id — Bytes eines eigenen Bildes
|
||||
* - DELETE /dashboard/images/:id — eigenes Bild loeschen
|
||||
*/
|
||||
@Controller('dashboard/images')
|
||||
export class DashboardImagesController {
|
||||
constructor(private readonly service: DashboardImagesService) {}
|
||||
|
||||
@Get()
|
||||
async list(@CurrentUser() user: AuthUser) {
|
||||
return this.service.list(user.id, user.tenantId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(
|
||||
FileInterceptor('image', { limits: { fileSize: DASHBOARD_IMAGE_MAX_BYTES, files: 1 } }),
|
||||
)
|
||||
async upload(@CurrentUser() user: AuthUser, @UploadedFile() file?: UploadedFileLike) {
|
||||
return this.service.upload(user, file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Binaerantwort mit den Headern aus T-PI9-06/08/10: der Typ kommt aus der
|
||||
* gespeicherten Magic-Byte-Erkennung, `nosniff` verbietet dem Browser das
|
||||
* Umdeuten, `Content-Disposition: inline` traegt bewusst KEINEN Dateinamen
|
||||
* (`originalName` gehoert in keinen Header), `private` haelt gemeinsame
|
||||
* Zwischenspeicher (Nginx Proxy Manager) fern, die CSP laesst ein direkt im
|
||||
* Tab geoeffnetes Bild ohne Skript und ohne Tessera-Origin laufen (Muster
|
||||
* favorites.controller.ts `getIcon`).
|
||||
*
|
||||
* Prisma 6 liefert `Bytes` als `Uint8Array` — `Buffer.from(...)` ueber
|
||||
* denselben Speicher, keine Kopie, keine Zusicherung.
|
||||
*/
|
||||
@Get(':id')
|
||||
async getImage(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { mimeType, data } = await this.service.getBytes(id, user.id, user.tenantId);
|
||||
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.setHeader('Cache-Control', 'private, max-age=86400');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Content-Disposition', 'inline');
|
||||
res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
|
||||
res.send(Buffer.from(data.buffer, data.byteOffset, data.byteLength));
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@Param('id') id: string, @CurrentUser() user: AuthUser) {
|
||||
return this.service.remove(id, user.id, user.tenantId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Bindung an forTenant()/forSystem() — dasselbe Muster wie
|
||||
* dashboard.service.spec.ts (260910-krx): der gebundene Klient ist ein
|
||||
* ZWEITES, von `prisma` unterscheidbares Objekt ueber DEMSELBEN Speicher,
|
||||
* das protokolliert, welche Aufrufe ueber ihn liefen. Ein vergessener
|
||||
* Bindungsaufruf faellt damit auf (`prisma.dashboardImage` waere dann ohne
|
||||
* Protokoll-Eintrag). Seit quick-260922-hk4 gibt es einen zweiten
|
||||
* Klienten-Typ: der Systemkontext des Bootstrap-Umzugs (`forSystem()`,
|
||||
* liest ueber ALLE Mandanten, Muster dkv.service.ts) — das Protokoll
|
||||
* unterscheidet beide ueber `via`.
|
||||
*/
|
||||
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
forTenant: vi.fn((prisma: FakePrisma, tenantId: string, userId?: string) =>
|
||||
prisma.__makeBoundClient(tenantId, userId),
|
||||
),
|
||||
forSystem: vi.fn((prisma: FakePrisma) => prisma.__makeSystemClient()),
|
||||
}));
|
||||
|
||||
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { DashboardImagesService } from './dashboard-images.service';
|
||||
|
||||
/**
|
||||
* dashboard-images.service.spec — quick-260921-pi9 (Bilderrahmen),
|
||||
* erweitert in quick-260922-hk4 (Bilder auf der Festplatte statt in der
|
||||
* Datenbank).
|
||||
*
|
||||
* Faelle an der Grenze Dienst -> Datenbank: Liste ohne `data`, Upload ohne
|
||||
* Datei, Magic Bytes schlagen den behaupteten MIME-Typ in BEIDE Richtungen
|
||||
* (T-PI9-01), Zaehler 30 (T-PI9-03), fremder Benutzer UND fremder Mandant
|
||||
* -> 404 (T-PI9-04, nie 403), eigenes Bild liefert Bytes, Loeschen
|
||||
* eigen/fremd, und der Nachweis, dass jede Methode
|
||||
* `forTenant(prisma, tenantId, userId)` mit dem Benutzer als drittem
|
||||
* Argument aufruft.
|
||||
*
|
||||
* Dazu die Grenze Dienst -> Dateibereich (hk4, Tests 13-22): KEIN
|
||||
* `fs`-Mock, sondern ein echtes Verzeichnis unter `os.tmpdir()` (Muster
|
||||
* desktop.service.spec.ts) ueber den Testschalter
|
||||
* `DASHBOARD_IMAGES_DIR` — der Dienst schreibt und liest wirklich.
|
||||
* Geprueft werden Ablageort und Dateiname (IMMER die UUID der Zeile plus
|
||||
* die Endung aus dem ERKANNTEN Typ, NIE `originalName`, T-HK4-01), das
|
||||
* Zuruecknehmen der Zeile bei fehlgeschlagenem Schreiben (T-HK4-04), 404
|
||||
* bei fehlender Datei, das Mitloeschen der Datei und der automatische
|
||||
* Umzug beim Start (T-HK4-03).
|
||||
*/
|
||||
|
||||
interface ImageRow {
|
||||
id: string;
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
data: Uint8Array | null;
|
||||
storagePath: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface BoundCall {
|
||||
via: 'tenant' | 'system';
|
||||
tenantId: string;
|
||||
userId: string | undefined;
|
||||
model: string;
|
||||
method: string;
|
||||
}
|
||||
|
||||
type ModelMethods = Record<string, (...args: unknown[]) => Promise<unknown>>;
|
||||
|
||||
interface FakePrisma {
|
||||
dashboardImage: ModelMethods;
|
||||
__rows: ImageRow[];
|
||||
__boundCallLog: BoundCall[];
|
||||
__makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods };
|
||||
__makeSystemClient(): { dashboardImage: ModelMethods };
|
||||
}
|
||||
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
||||
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0x10, 0x4a, 0x46]);
|
||||
const TEXT = Buffer.from('nur Text, kein Bild');
|
||||
|
||||
/** Ablageort der Testdateien — echtes Verzeichnis, kein fs-Mock. */
|
||||
let imagesDir: string;
|
||||
const ORIGINAL_DIR_ENV = process.env.DASHBOARD_IMAGES_DIR;
|
||||
|
||||
beforeAll(() => {
|
||||
imagesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tessera-dashboard-images-'));
|
||||
process.env.DASHBOARD_IMAGES_DIR = imagesDir;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(imagesDir, { recursive: true, force: true });
|
||||
if (ORIGINAL_DIR_ENV === undefined) {
|
||||
delete process.env.DASHBOARD_IMAGES_DIR;
|
||||
} else {
|
||||
process.env.DASHBOARD_IMAGES_DIR = ORIGINAL_DIR_ENV;
|
||||
}
|
||||
});
|
||||
|
||||
function makeRow(overrides: Partial<ImageRow> = {}): ImageRow {
|
||||
return {
|
||||
id: overrides.id ?? 'img-1',
|
||||
userId: overrides.userId ?? 'user-1',
|
||||
tenantId: overrides.tenantId ?? 'tenant-1',
|
||||
originalName: overrides.originalName ?? 'foto.png',
|
||||
mimeType: overrides.mimeType ?? 'image/png',
|
||||
size: overrides.size ?? PNG.length,
|
||||
data: overrides.data === undefined ? null : overrides.data,
|
||||
storagePath: overrides.storagePath === undefined ? null : overrides.storagePath,
|
||||
createdAt: overrides.createdAt ?? new Date('2026-01-01'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt eine Zeile MIT passender Datei auf der Platte an — der Normalfall
|
||||
* nach dem Upload (die Tests 8-12 pruefen Besitz und Bindung, nicht die
|
||||
* Ablage).
|
||||
*/
|
||||
function makeStoredRow(overrides: Partial<ImageRow> = {}, bytes: Buffer = PNG): ImageRow {
|
||||
const row = makeRow(overrides);
|
||||
const relative = `user-files/dashboard-images/${row.userId}/${row.id}.png`;
|
||||
const absolute = path.join(imagesDir, row.userId, `${row.id}.png`);
|
||||
fs.mkdirSync(path.dirname(absolute), { recursive: true });
|
||||
fs.writeFileSync(absolute, bytes);
|
||||
return { ...row, storagePath: overrides.storagePath === undefined ? relative : overrides.storagePath };
|
||||
}
|
||||
|
||||
function storedFile(userId: string, id: string, ext = 'png'): string {
|
||||
return path.join(imagesDir, userId, `${id}.${ext}`);
|
||||
}
|
||||
|
||||
function pick(row: ImageRow, select: Record<string, boolean> | undefined) {
|
||||
if (!select) return row;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(select)) {
|
||||
if (select[key]) out[key] = row[key as keyof ImageRow];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
const boundCallLog: BoundCall[] = [];
|
||||
const dashboardImage: ModelMethods = {
|
||||
findMany: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as {
|
||||
where: { tenantId?: string; userId?: string; storagePath?: string | null };
|
||||
select?: Record<string, boolean>;
|
||||
};
|
||||
const where = args.where ?? {};
|
||||
return rows
|
||||
.filter((r) => {
|
||||
if (where.tenantId !== undefined && r.tenantId !== where.tenantId) return false;
|
||||
if (where.userId !== undefined && r.userId !== where.userId) return false;
|
||||
if ('storagePath' in where && where.storagePath === null && r.storagePath !== null) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.slice()
|
||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||
.map((r) => pick(r, args.select));
|
||||
}),
|
||||
count: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { where: { tenantId: string; userId: string } };
|
||||
return rows.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId).length;
|
||||
}),
|
||||
create: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { data: Partial<ImageRow>; select?: Record<string, boolean> };
|
||||
const created = makeRow({ id: `new-${rows.length + 1}`, ...args.data, createdAt: new Date('2026-02-02') });
|
||||
rows.push(created);
|
||||
return pick(created, args.select);
|
||||
}),
|
||||
update: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { where: { id: string }; data: Partial<ImageRow> };
|
||||
const row = rows.find((r) => r.id === args.where.id);
|
||||
if (!row) throw new Error(`update: Zeile '${args.where.id}' gibt es nicht`);
|
||||
Object.assign(row, args.data);
|
||||
return row;
|
||||
}),
|
||||
findUnique: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { where: { id: string } };
|
||||
return rows.find((r) => r.id === args.where.id) ?? null;
|
||||
}),
|
||||
delete: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { where: { id: string } };
|
||||
const idx = rows.findIndex((r) => r.id === args.where.id);
|
||||
if (idx === -1) return null;
|
||||
const [removed] = rows.splice(idx, 1);
|
||||
return removed;
|
||||
}),
|
||||
};
|
||||
|
||||
function wrap(via: 'tenant' | 'system', tenantId: string, userId?: string) {
|
||||
const wrapped: ModelMethods = {};
|
||||
for (const method of Object.keys(dashboardImage)) {
|
||||
wrapped[method] = async (...args: unknown[]) => {
|
||||
boundCallLog.push({ via, tenantId, userId, model: 'dashboardImage', method });
|
||||
return dashboardImage[method](...args);
|
||||
};
|
||||
}
|
||||
return { dashboardImage: wrapped };
|
||||
}
|
||||
|
||||
const fake: FakePrisma = {
|
||||
dashboardImage,
|
||||
__rows: rows,
|
||||
__boundCallLog: boundCallLog,
|
||||
__makeBoundClient(tenantId: string, userId?: string) {
|
||||
return wrap('tenant', tenantId, userId);
|
||||
},
|
||||
__makeSystemClient() {
|
||||
return wrap('system', '', undefined);
|
||||
},
|
||||
};
|
||||
return fake;
|
||||
}
|
||||
|
||||
function makeService(prisma: FakePrisma) {
|
||||
// Der Dienst verlangt einen PrismaService; die Attrappe deckt genau das
|
||||
// Modell ab, das der Dienst anfasst (nur ueber den gebundenen Klienten).
|
||||
// `as never` statt einer Doppelzusicherung ueber unknown (Muster
|
||||
// tender-mail.service.spec.ts; der Zusicherungs-Zaehler bleibt bei 27).
|
||||
return new DashboardImagesService(prisma as never);
|
||||
}
|
||||
|
||||
const user: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'anna',
|
||||
role: 'USER',
|
||||
tenantId: 'tenant-1',
|
||||
mustChangePassword: false,
|
||||
};
|
||||
|
||||
function file(buffer: Buffer, mimetype: string, originalname = 'foto.png'): UploadedFileLike {
|
||||
return { buffer, mimetype, originalname, size: buffer.length };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(forTenant).mockClear();
|
||||
vi.mocked(forSystem).mockClear();
|
||||
});
|
||||
|
||||
describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
it('Test 1: list liefert nur eigene Zeilen, nur Metadaten (nie data), aelteste zuerst', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
makeRow({ id: 'b', createdAt: new Date('2026-03-01') }),
|
||||
makeRow({ id: 'a', createdAt: new Date('2026-01-01') }),
|
||||
makeRow({ id: 'fremd', userId: 'user-2' }),
|
||||
]);
|
||||
const result = await makeService(prisma).list('user-1', 'tenant-1');
|
||||
expect(result.map((r) => r.id)).toEqual(['a', 'b']);
|
||||
for (const r of result) {
|
||||
expect(Object.keys(r).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']);
|
||||
}
|
||||
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as { select: Record<string, boolean> };
|
||||
expect(call.select.data).toBeUndefined();
|
||||
expect(call.select.storagePath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Test 2: upload ohne Datei -> BadRequestException mit deutscher Meldung', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
await expect(makeService(prisma).upload(user, undefined)).rejects.toThrow(
|
||||
new BadRequestException('Bitte wählen Sie eine Bilddatei aus.'),
|
||||
);
|
||||
expect(prisma.dashboardImage.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test 3: PNG-Bytes mit behauptetem text/plain gelingen und speichern image/png (Magic Bytes, nicht mimetype)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const result = await makeService(prisma).upload(user, file(PNG, 'text/plain', 'irgendwas.txt'));
|
||||
expect(result.mimeType).toBe('image/png');
|
||||
expect(result.originalName).toBe('irgendwas.txt');
|
||||
expect(result.size).toBe(PNG.length);
|
||||
expect(Object.keys(result).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']);
|
||||
expect(prisma.__rows[0].userId).toBe('user-1');
|
||||
expect(prisma.__rows[0].tenantId).toBe('tenant-1');
|
||||
});
|
||||
|
||||
it('Test 4: Textdatei mit behauptetem image/png scheitert mit deutscher Meldung, nichts wird angelegt', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
await expect(makeService(prisma).upload(user, file(TEXT, 'image/png', 'bild.png'))).rejects.toThrow(
|
||||
new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'),
|
||||
);
|
||||
expect(prisma.dashboardImage.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test 5: Zaehler — 30 eigene Bilder blocken das 31., 29 lassen das 30. durch', async () => {
|
||||
const thirty = Array.from({ length: 30 }, (_, i) => makeRow({ id: `i${i}` }));
|
||||
const full = makeFakePrisma(thirty);
|
||||
await expect(makeService(full).upload(user, file(PNG, 'image/png'))).rejects.toThrow(
|
||||
new BadRequestException(
|
||||
'Sie haben die Höchstzahl von 30 Bildern erreicht. Bitte löschen Sie zuerst ein Bild.',
|
||||
),
|
||||
);
|
||||
expect(full.dashboardImage.create).not.toHaveBeenCalled();
|
||||
|
||||
const almost = makeFakePrisma(thirty.slice(0, 29));
|
||||
await expect(makeService(almost).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
});
|
||||
|
||||
it('Test 6: Zaehler zaehlt nur den eigenen Benutzer im eigenen Mandanten (fremde Zeilen zaehlen nicht)', async () => {
|
||||
const foreign = Array.from({ length: 30 }, (_, i) => makeRow({ id: `f${i}`, userId: 'user-2' }));
|
||||
const prisma = makeFakePrisma(foreign);
|
||||
await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
const countArgs = vi.mocked(prisma.dashboardImage.count).mock.calls[0][0] as { where: unknown };
|
||||
expect(countArgs.where).toEqual({ tenantId: 'tenant-1', userId: 'user-1' });
|
||||
});
|
||||
|
||||
it('Test 7: originalName wird auf 255 Zeichen gekuerzt', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const result = await makeService(prisma).upload(user, file(PNG, 'image/png', 'x'.repeat(400)));
|
||||
expect(result.originalName).toHaveLength(255);
|
||||
});
|
||||
|
||||
it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', userId: 'user-2' })]);
|
||||
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', tenantId: 'tenant-2' })]);
|
||||
const service = makeService(prisma);
|
||||
await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('Test 10: getBytes — eigenes Bild liefert mimeType und die gespeicherten Bytes', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1' })]);
|
||||
const result = await makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1');
|
||||
expect(result.mimeType).toBe('image/png');
|
||||
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 10b: getBytes — Datei fehlt, aber die alte Spalte `data` traegt die Bytes noch: wiederherstellen statt 404', async () => {
|
||||
// Fall aus dem Browser-Rundgang 22.09.2026: ein `pg_dump` von vor dem Umzug
|
||||
// traegt die Bytes noch, das Volume `user-files` wird getrennt gesichert —
|
||||
// wer nur den Abzug zurueckspielt, haette sonst Zeilen ohne Datei.
|
||||
const row = makeRow({
|
||||
id: 'img-alt',
|
||||
data: PNG,
|
||||
storagePath: 'user-files/dashboard-images/user-1/img-alt.png',
|
||||
});
|
||||
const prisma = makeFakePrisma([row]);
|
||||
expect(fs.existsSync(storedFile('user-1', 'img-alt'))).toBe(false);
|
||||
|
||||
const result = await makeService(prisma).getBytes('img-alt', 'user-1', 'tenant-1');
|
||||
|
||||
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
||||
expect(fs.existsSync(storedFile('user-1', 'img-alt'))).toBe(true);
|
||||
expect(fs.readFileSync(storedFile('user-1', 'img-alt')).equals(PNG)).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 10c: getBytes — Datei fehlt UND `data` ist leer -> 404', async () => {
|
||||
const row = makeRow({
|
||||
id: 'img-weg',
|
||||
data: null,
|
||||
storagePath: 'user-files/dashboard-images/user-1/img-weg.png',
|
||||
});
|
||||
const prisma = makeFakePrisma([row]);
|
||||
await expect(makeService(prisma).getBytes('img-weg', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 11: remove — eigenes Bild wird geloescht und { id } geliefert; fremdes (Benutzer ODER Mandant) -> 404 ohne Loeschung', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
makeStoredRow({ id: 'eigen' }),
|
||||
makeStoredRow({ id: 'fremd-user', userId: 'user-2' }),
|
||||
makeStoredRow({ id: 'fremd-tenant', tenantId: 'tenant-2' }),
|
||||
]);
|
||||
const service = makeService(prisma);
|
||||
await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' });
|
||||
expect(prisma.__rows.map((r) => r.id)).toEqual(['fremd-user', 'fremd-tenant']);
|
||||
await expect(service.remove('fremd-user', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
expect(prisma.__rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => {
|
||||
const prisma = makeFakePrisma([]);
|
||||
const service = makeService(prisma);
|
||||
await service.list('user-1', 'tenant-1');
|
||||
const created = await service.upload(user, file(PNG, 'image/png'));
|
||||
await service.getBytes(created.id, 'user-1', 'tenant-1');
|
||||
await service.remove(created.id, 'user-1', 'tenant-1');
|
||||
|
||||
expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(4);
|
||||
for (const call of vi.mocked(forTenant).mock.calls) {
|
||||
expect(call[0]).toBe(prisma);
|
||||
expect(call[1]).toBe('tenant-1');
|
||||
expect(call[2]).toBe('user-1');
|
||||
}
|
||||
// Jeder Modellaufruf steht im Protokoll des gebundenen Klienten; der
|
||||
// Upload schreibt den Ablageort in einem zweiten Schritt nach, weil die
|
||||
// UUID der Zeile erst nach `create` feststeht (hk4).
|
||||
const methods = prisma.__boundCallLog.map((c) => c.method);
|
||||
expect(methods).toEqual(['findMany', 'count', 'create', 'update', 'findUnique', 'findUnique', 'delete']);
|
||||
for (const c of prisma.__boundCallLog) {
|
||||
expect(c.via).toBe('tenant');
|
||||
expect(c.tenantId).toBe('tenant-1');
|
||||
expect(c.userId).toBe('user-1');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)', () => {
|
||||
it('Test 13: upload schreibt die Datei unter <dir>/<userId>/<id>.png und speichert den relativen Pfad in der Zeile', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const result = await makeService(prisma).upload(user, file(PNG, 'image/png'));
|
||||
|
||||
const onDisk = storedFile('user-1', result.id);
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
expect(fs.readFileSync(onDisk).equals(PNG)).toBe(true);
|
||||
expect(prisma.__rows[0].storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
|
||||
// Die Bytes gehen NICHT mehr in die Zeile (das ist der ganze Zweck).
|
||||
expect(prisma.__rows[0].data).toBeNull();
|
||||
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as { data: Record<string, unknown> };
|
||||
expect(createArgs.data.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Test 14: der Dateiname ist IMMER die UUID plus die Endung des ERKANNTEN Typs — originalName kommt nie im Pfad vor (T-HK4-01)', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
const service = makeService(prisma);
|
||||
const boeserName = '../../../etc/passwd.png';
|
||||
const result = await service.upload(user, file(JPEG, 'image/png', boeserName));
|
||||
|
||||
// Erkannt wurde JPEG (Magic Bytes), also .jpg — nicht .png aus dem Namen.
|
||||
expect(result.mimeType).toBe('image/jpeg');
|
||||
const stored = prisma.__rows[0].storagePath ?? '';
|
||||
expect(stored).toBe(`user-files/dashboard-images/user-1/${result.id}.jpg`);
|
||||
expect(stored).not.toContain('passwd');
|
||||
expect(stored).not.toContain('..');
|
||||
expect(fs.existsSync(storedFile('user-1', result.id, 'jpg'))).toBe(true);
|
||||
// Der Anzeigename bleibt in der Zeile erhalten, nur eben als Text.
|
||||
expect(result.originalName).toBe(boeserName);
|
||||
});
|
||||
|
||||
it('Test 15: scheitert das Schreiben, wird die eben angelegte Zeile wieder geloescht und 500 geworfen (T-HK4-04)', async () => {
|
||||
const blocker = path.join(imagesDir, 'blockade');
|
||||
fs.writeFileSync(blocker, 'ich bin eine Datei, kein Verzeichnis');
|
||||
const vorher = process.env.DASHBOARD_IMAGES_DIR;
|
||||
process.env.DASHBOARD_IMAGES_DIR = path.join(blocker, 'unmoeglich');
|
||||
try {
|
||||
const prisma = makeFakePrisma();
|
||||
await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).rejects.toThrow(
|
||||
InternalServerErrorException,
|
||||
);
|
||||
expect(prisma.dashboardImage.create).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.dashboardImage.delete).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.__rows).toHaveLength(0);
|
||||
} finally {
|
||||
process.env.DASHBOARD_IMAGES_DIR = vorher;
|
||||
}
|
||||
});
|
||||
|
||||
it('Test 16: getBytes liest den Dateiinhalt (nicht die Zeile) — auch wenn in der Zeile noch alte Bytes stehen', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', data: Uint8Array.from(TEXT) }, PNG)]);
|
||||
const result = await makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1');
|
||||
expect(Buffer.from(result.data).equals(PNG)).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 17: Zeile vorhanden, Datei fehlt -> NotFoundException (die Kachel zeigt „Bild nicht verfügbar")', async () => {
|
||||
// Eigene Kennung: das Verzeichnis ist ueber alle Tests dieser Datei
|
||||
// dasselbe, eine von Test 8/10 angelegte `img-1.png` waere sonst da.
|
||||
const prisma = makeFakePrisma([
|
||||
makeRow({ id: 'datei-fehlt', storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png' }),
|
||||
]);
|
||||
await expect(makeService(prisma).getBytes('datei-fehlt', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 18: Zeile ohne storagePath (noch nicht umgezogen) -> NotFoundException statt Absturz', async () => {
|
||||
const prisma = makeFakePrisma([makeRow({ id: 'img-1', data: Uint8Array.from(PNG) })]);
|
||||
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('Test 19: remove loescht Zeile UND Datei', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'weg' })]);
|
||||
const onDisk = storedFile('user-1', 'weg');
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
|
||||
await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'weg' });
|
||||
expect(prisma.__rows).toHaveLength(0);
|
||||
expect(fs.existsSync(onDisk)).toBe(false);
|
||||
});
|
||||
|
||||
it('Test 20: fehlt die Datei beim Loeschen, gelingt das Loeschen trotzdem (eine Dateileiche ist harmloser als eine haengende Loeschung)', async () => {
|
||||
const prisma = makeFakePrisma([
|
||||
makeRow({ id: 'nur-zeile', storagePath: 'user-files/dashboard-images/user-1/nur-zeile.png' }),
|
||||
]);
|
||||
await expect(makeService(prisma).remove('nur-zeile', 'user-1', 'tenant-1')).resolves.toEqual({
|
||||
id: 'nur-zeile',
|
||||
});
|
||||
expect(prisma.__rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardImagesService — Umzug beim Start (quick-260922-hk4, T-HK4-03)', () => {
|
||||
it('Test 21: onApplicationBootstrap schreibt die Bytes alter Zeilen auf die Platte und setzt storagePath — systemgebunden lesen, je Zeile mandantengebunden schreiben', async () => {
|
||||
const alt = makeRow({ id: 'alt-1', data: Uint8Array.from(PNG) });
|
||||
const fremderMandant = makeRow({
|
||||
id: 'alt-2',
|
||||
userId: 'user-9',
|
||||
tenantId: 'tenant-2',
|
||||
mimeType: 'image/jpeg',
|
||||
data: Uint8Array.from(JPEG),
|
||||
});
|
||||
const schonUmgezogen = makeStoredRow({ id: 'neu-1' });
|
||||
const prisma = makeFakePrisma([alt, fremderMandant, schonUmgezogen]);
|
||||
|
||||
await makeService(prisma).onApplicationBootstrap();
|
||||
|
||||
expect(fs.readFileSync(storedFile('user-1', 'alt-1')).equals(PNG)).toBe(true);
|
||||
expect(fs.readFileSync(storedFile('user-9', 'alt-2', 'jpg')).equals(JPEG)).toBe(true);
|
||||
expect(prisma.__rows[0].storagePath).toBe('user-files/dashboard-images/user-1/alt-1.png');
|
||||
expect(prisma.__rows[1].storagePath).toBe('user-files/dashboard-images/user-9/alt-2.jpg');
|
||||
|
||||
// Gelesen wird EINMAL ueber den Systemkontext, geschrieben je Zeile
|
||||
// ueber einen Klienten, der auf Mandant UND Benutzer DIESER Zeile
|
||||
// gebunden ist.
|
||||
expect(vi.mocked(forSystem)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(forSystem).mock.calls[0][0]).toBe(prisma);
|
||||
const leseAufrufe = prisma.__boundCallLog.filter((c) => c.via === 'system');
|
||||
expect(leseAufrufe.map((c) => c.method)).toEqual(['findMany']);
|
||||
const findManyArgs = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as {
|
||||
where: Record<string, unknown>;
|
||||
};
|
||||
expect(findManyArgs.where.storagePath).toBeNull();
|
||||
|
||||
expect(vi.mocked(forTenant)).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(forTenant).mock.calls[0].slice(1)).toEqual(['tenant-1', 'user-1']);
|
||||
expect(vi.mocked(forTenant).mock.calls[1].slice(1)).toEqual(['tenant-2', 'user-9']);
|
||||
const schreibAufrufe = prisma.__boundCallLog.filter((c) => c.via === 'tenant');
|
||||
expect(schreibAufrufe.map((c) => c.method)).toEqual(['update', 'update']);
|
||||
|
||||
// Die bereits umgezogene Zeile wird nicht angefasst.
|
||||
expect(prisma.__rows[2].storagePath).toBe('user-files/dashboard-images/user-1/neu-1.png');
|
||||
});
|
||||
|
||||
it('Test 22: ohne offene Zeilen bleibt der Start still — kein Schreibzugriff, keine Bindung je Mandant', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'neu-2' })]);
|
||||
await makeService(prisma).onApplicationBootstrap();
|
||||
|
||||
expect(vi.mocked(forSystem)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(forTenant)).not.toHaveBeenCalled();
|
||||
expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test 23: der Umzug ist wiederholbar — ein zweiter Lauf findet nichts mehr und ueberschreibt nichts', async () => {
|
||||
const prisma = makeFakePrisma([makeRow({ id: 'alt-3', data: Uint8Array.from(PNG) })]);
|
||||
const service = makeService(prisma);
|
||||
await service.onApplicationBootstrap();
|
||||
const ersterStand = fs.statSync(storedFile('user-1', 'alt-3')).mtimeMs;
|
||||
|
||||
vi.mocked(forTenant).mockClear();
|
||||
await service.onApplicationBootstrap();
|
||||
|
||||
expect(vi.mocked(forTenant)).not.toHaveBeenCalled();
|
||||
expect(fs.statSync(storedFile('user-1', 'alt-3')).mtimeMs).toBe(ersterStand);
|
||||
expect(prisma.__rows[0].storagePath).toBe('user-files/dashboard-images/user-1/alt-3.png');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,408 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
type OnApplicationBootstrap,
|
||||
} from '@nestjs/common';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
DASHBOARD_IMAGE_MAX_COUNT,
|
||||
type DashboardImageMime,
|
||||
detectImageMime,
|
||||
} from './dashboard-image-rules';
|
||||
|
||||
/**
|
||||
* DashboardImagesService — hochgeladene Bilder des Bilderrahmen-Widgets
|
||||
* (quick-260921-pi9), seit quick-260922-hk4 im Dateibereich statt in der
|
||||
* Datenbank.
|
||||
*
|
||||
* WO DIE BYTES LIEGEN (hk4): unter
|
||||
* `user-files/dashboard-images/<userId>/<id>.<png|jpg|gif|webp>`, die Zeile
|
||||
* haelt nur noch den relativen Pfad in `storagePath` — dasselbe Muster wie
|
||||
* `User.avatarPath` (user.controller.ts) und die DKV-Ausfuhren
|
||||
* (dkv-export.service.ts). Grund ist die Sicherung: gesichert wird von Hand
|
||||
* per `pg_dump` (docs/anleitung-betrieb.md Kap. 6), und 30 Bilder à 5 MiB je
|
||||
* Benutzer waeren im Extremfall 150 MB pro Benutzer in jedem Abzug. Das
|
||||
* Volume `user-files` wird daneben gesichert. Geschwindigkeit war NICHT das
|
||||
* Argument (ein Bild wird je Browser einmal taeglich geladen).
|
||||
*
|
||||
* DER DATEINAME KOMMT IMMER VOM SERVER (T-HK4-01, Muster T-07-09 aus
|
||||
* `dkv-export.service.ts`): er ist die UUID der Zeile plus die Endung aus
|
||||
* dem an den Magic Bytes ERKANNTEN Mime-Typ. `originalName` ist reiner
|
||||
* Anzeigetext und erscheint weder im Pfad noch in einem Header (T-PI9-06).
|
||||
* `absoluteImagePath()` prueft zusaetzlich, dass der aus der Zeile
|
||||
* gelesene Pfad im Bilderverzeichnis liegt — ein Wert aus der Datenbank
|
||||
* wird nie ungeprueft an `path.join` gereicht.
|
||||
*
|
||||
* EIN EIGENER ORDNER JE BENUTZER IST KEIN SCHUTZ: wer welches Bild sehen
|
||||
* darf, entscheidet weiterhin dieser Dienst. Die Datei wird nie direkt
|
||||
* ausgeliefert, nur ueber `GET /dashboard/images/:id` mit Besitzpruefung
|
||||
* (T-HK4-02); das Volume haengt in keinem Webserver.
|
||||
*
|
||||
* HALBE ZUSTAENDE (T-HK4-04, bewusst benannt): beim Upload entsteht ZUERST
|
||||
* die Zeile (erst danach steht die UUID fest), dann die Datei; scheitert
|
||||
* das Schreiben, wird die Zeile wieder geloescht und 500 geworfen. Beim
|
||||
* Loeschen faellt ZUERST die Zeile, ein Fehler beim Entfernen der Datei
|
||||
* wird protokolliert und geschluckt — eine Dateileiche ist harmloser als
|
||||
* eine haengende Loeschung. Fehlt die Datei beim Lesen, ist die Antwort
|
||||
* 404 und die Kachel zeigt „Bild nicht verfügbar".
|
||||
*
|
||||
* Besitz: ein Bild gehoert dem hochladenden Benutzer (gleicher Mandant UND
|
||||
* gleicher Benutzer). Die Besitzpruefung in `getBytes`/`remove` (Zeile
|
||||
* holen, `userId` UND `tenantId` gegen den Sitzungsnachweis vergleichen,
|
||||
* sonst 404) ist NICHT dekorativ: die RLS-Regel auf `DashboardImage`
|
||||
* (Migration 20260921120000, mit Benutzerdimension) wirkt erst, wenn die
|
||||
* Anwendung als Rolle ohne Umgehungsrecht verbindet — der Schalter ist
|
||||
* heute AUS (docs/mandantentrennung-datenbankrolle.md). Bis dahin ist der
|
||||
* Vergleich hier der einzige wirksame Schutz gegen Quer-Lesen und
|
||||
* Quer-Loeschen; die `forTenant()`-Bindung je Methode LEGT eine
|
||||
* Mandantengrenze obendrauf, sie ersetzt den Vergleich nicht (Muster
|
||||
* dashboard.service.ts). Nach dem Scharfschalten liefert `findUnique` fuer
|
||||
* eine fremde Zeile bereits `null` — die Antwort bleibt 404, nur der Weg
|
||||
* dorthin aendert sich.
|
||||
*
|
||||
* Warum 404 und nie 403 (T-PI9-04): ein 403 wuerde verraten, dass die
|
||||
* Kennung existiert. Kennungen sind `uuid()`, nicht erratbar.
|
||||
*
|
||||
* Warum der Typ aus den Magic Bytes kommt (T-PI9-01, T-PI9-08):
|
||||
* `file.mimetype` und `originalname` behauptet der Browser; gespeichert und
|
||||
* spaeter als `Content-Type` ausgeliefert wird ausschliesslich das, was
|
||||
* `detectImageMime` an den Bytes erkannt hat.
|
||||
*
|
||||
* Zaehler (T-PI9-03): `count` je Mandant+Benutzer vor `create` im selben
|
||||
* Dienst. Zwei gleichzeitige Uploads desselben Benutzers koennen die Grenze
|
||||
* um wenige Bilder ueberschreiten — Restrisiko bewusst angenommen, es
|
||||
* betrifft nur den eigenen Speicher.
|
||||
*
|
||||
* Der Dienst ruft NIE eine Webadresse ab (T-PI9-05): URL-Eintraege des
|
||||
* Widgets sind fuer die API undurchsichtige Config-Werte, der Browser des
|
||||
* Benutzers laedt sie selbst.
|
||||
*/
|
||||
|
||||
const ORIGINAL_NAME_MAX = 255;
|
||||
|
||||
/** Ablageort unterhalb der Monorepo-Wurzel, so wie er in der Zeile steht. */
|
||||
const STORAGE_PREFIX = 'user-files/dashboard-images/';
|
||||
|
||||
/** Metadaten-Auswahl fuer Liste und Upload-Antwort — nie Bytes, nie Pfad. */
|
||||
const META_SELECT = {
|
||||
id: true,
|
||||
originalName: true,
|
||||
mimeType: true,
|
||||
size: true,
|
||||
createdAt: true,
|
||||
} as const;
|
||||
|
||||
export interface DashboardImageMeta {
|
||||
id: string;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loest das Bilderverzeichnis relativ zur Monorepo-Wurzel auf — Muster
|
||||
* `resolveAvatarsDir()` (user.controller.ts): zur Laufzeit ist
|
||||
* `__dirname` = apps/api/dist/dashboard/, also vier Ebenen hoch.
|
||||
*
|
||||
* `DASHBOARD_IMAGES_DIR` ist ein Testschalter (Muster `DESKTOP_DIST_DIR`,
|
||||
* desktop.service.ts) und im Betrieb nie gesetzt; die Tests zeigen damit
|
||||
* auf ein Wegwerfverzeichnis unter `os.tmpdir()`, statt `fs` nachzubauen.
|
||||
*/
|
||||
export function resolveDashboardImagesDir(): string {
|
||||
const override = process.env.DASHBOARD_IMAGES_DIR;
|
||||
if (override !== undefined && override !== '') {
|
||||
return path.resolve(override);
|
||||
}
|
||||
return path.resolve(__dirname, '..', '..', '..', '..', 'user-files', 'dashboard-images');
|
||||
}
|
||||
|
||||
/** Endung aus dem ERKANNTEN Typ; alles andere ergibt `null`, nie eine Vermutung. */
|
||||
function extensionFor(mimeType: string): string | null {
|
||||
switch (mimeType) {
|
||||
case 'image/png':
|
||||
return 'png';
|
||||
case 'image/jpeg':
|
||||
return 'jpg';
|
||||
case 'image/gif':
|
||||
return 'gif';
|
||||
case 'image/webp':
|
||||
return 'webp';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Relativer Pfad, wie er in der Zeile steht (`storagePath`). */
|
||||
function relativeStoragePath(userId: string, id: string, extension: string): string {
|
||||
return `${STORAGE_PREFIX}${userId}/${id}.${extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wandelt den in der Zeile gespeicherten Pfad in einen absoluten Pfad im
|
||||
* Bilderverzeichnis um — und gibt `null` zurueck, sobald der Wert nicht
|
||||
* die erwartete Form hat oder aus dem Verzeichnis herausfuehren wuerde
|
||||
* (T-HK4-01). Der Aufrufer behandelt `null` wie eine fehlende Datei.
|
||||
*/
|
||||
function absoluteImagePath(storagePath: string): string | null {
|
||||
const normalized = storagePath.split('\\').join('/');
|
||||
if (!normalized.startsWith(STORAGE_PREFIX)) return null;
|
||||
|
||||
const base = resolveDashboardImagesDir();
|
||||
const absolute = path.resolve(base, normalized.slice(STORAGE_PREFIX.length));
|
||||
if (absolute !== base && !absolute.startsWith(base + path.sep)) return null;
|
||||
return absolute;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DashboardImagesService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(DashboardImagesService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Einmaliger Umzug der Bestandsbilder beim Start (T-HK4-03), damit der
|
||||
* Betreiber nichts von Hand ausfuehren muss.
|
||||
*
|
||||
* ZWEISTUFIG, und deshalb steht die Spalte `data` noch im Schema: die
|
||||
* SQL-Migration 20260922120000 legt nur `storagePath` an und macht `data`
|
||||
* NULLbar; `migrate deploy` laeuft VOR dem Anwendungsstart, ein sofortiges
|
||||
* DROP haette die Bytes vernichtet, bevor dieser Umzug sie lesen konnte.
|
||||
* Die DROP-Migration 20260922120100 kommt erst, wenn alpha UND live
|
||||
* einmal mit einer Version >= dieser gelaufen sind (vorgemerkt in
|
||||
* `.planning/todos/pending/`).
|
||||
*
|
||||
* GELESEN WIRD SYSTEMGEBUNDEN (`forSystem()`, Muster
|
||||
* `DkvService.loadActiveConfigsForScheduler()`): der Umzug betrifft alle
|
||||
* Mandanten, ein Startpfad hat keinen Mandanten im Ruecken. Geschrieben
|
||||
* wird je Zeile MANDANTENGEBUNDEN (`forTenant()` mit Mandant UND Benutzer
|
||||
* dieser Zeile) — unter Systemkontext ist nur Lesen geoeffnet
|
||||
* (`system_read_policy ... FOR SELECT`, fuer `DashboardImage` angelegt in
|
||||
* 20260922120000). Einmal-lesen-viele-bedienen, genau wie beim
|
||||
* DKV-Planer.
|
||||
*
|
||||
* Wiederholbar: die Abfrage nimmt nur Zeilen ohne `storagePath`, ein
|
||||
* zweiter Lauf findet nichts mehr. Eine einzelne fehlgeschlagene Zeile
|
||||
* wird protokolliert und haelt den Start nicht auf.
|
||||
*/
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
const systemPrisma = forSystem(this.prisma);
|
||||
const pending = await systemPrisma.dashboardImage.findMany({
|
||||
where: { storagePath: null },
|
||||
select: { id: true, userId: true, tenantId: true, mimeType: true, data: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
let moved = 0;
|
||||
for (const row of pending) {
|
||||
if (row.data === null) continue;
|
||||
try {
|
||||
const storagePath = await this.writeImageFile(row.userId, row.id, row.mimeType, row.data);
|
||||
const tenantPrisma = forTenant(this.prisma, row.tenantId, row.userId);
|
||||
await tenantPrisma.dashboardImage.update({
|
||||
where: { id: row.id },
|
||||
data: { storagePath },
|
||||
});
|
||||
moved += 1;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Bilderrahmen-Bild ${row.id} konnte nicht auf die Festplatte umgezogen werden: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (moved > 0) {
|
||||
this.logger.log(`${moved} Bilderrahmen-Bilder auf die Festplatte umgezogen`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Eigene Bilder, aelteste zuerst, nur Metadaten. */
|
||||
async list(userId: string, tenantId: string): Promise<DashboardImageMeta[]> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
return tenantPrisma.dashboardImage.findMany({
|
||||
where: { tenantId, userId },
|
||||
select: META_SELECT,
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt eine hochgeladene Datei an: Magic Bytes entscheiden, der Zaehler
|
||||
* begrenzt, gespeichert wird der erkannte Typ — die Bytes auf der Platte,
|
||||
* die Zeile haelt den Pfad.
|
||||
*
|
||||
* Reihenfolge (T-HK4-04): Zeile zuerst, weil der Dateiname die UUID der
|
||||
* Zeile IST. Scheitert danach das Schreiben oder das Nachtragen des
|
||||
* Pfades, wird die Zeile wieder geloescht — lieber gar kein Bild als eine
|
||||
* Zeile ohne Datei.
|
||||
*/
|
||||
async upload(user: AuthUser, file: UploadedFileLike | undefined): Promise<DashboardImageMeta> {
|
||||
if (!file) {
|
||||
throw new BadRequestException('Bitte wählen Sie eine Bilddatei aus.');
|
||||
}
|
||||
|
||||
const mimeType: DashboardImageMime | null = detectImageMime(file.buffer);
|
||||
if (mimeType === null) {
|
||||
throw new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.');
|
||||
}
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, user.tenantId, user.id);
|
||||
const existing = await tenantPrisma.dashboardImage.count({
|
||||
where: { tenantId: user.tenantId, userId: user.id },
|
||||
});
|
||||
if (existing >= DASHBOARD_IMAGE_MAX_COUNT) {
|
||||
throw new BadRequestException(
|
||||
`Sie haben die Höchstzahl von ${DASHBOARD_IMAGE_MAX_COUNT} Bildern erreicht. Bitte löschen Sie zuerst ein Bild.`,
|
||||
);
|
||||
}
|
||||
|
||||
const created = await tenantPrisma.dashboardImage.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tenantId: user.tenantId,
|
||||
originalName: file.originalname.slice(0, ORIGINAL_NAME_MAX),
|
||||
mimeType,
|
||||
size: file.buffer.length,
|
||||
},
|
||||
select: META_SELECT,
|
||||
});
|
||||
|
||||
try {
|
||||
const storagePath = await this.writeImageFile(user.id, created.id, mimeType, file.buffer);
|
||||
await tenantPrisma.dashboardImage.update({
|
||||
where: { id: created.id },
|
||||
data: { storagePath },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Bilderrahmen-Bild ${created.id} konnte nicht gespeichert werden, Zeile wird zurueckgenommen: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
await tenantPrisma.dashboardImage.delete({ where: { id: created.id } });
|
||||
throw new InternalServerErrorException('Das Bild konnte nicht gespeichert werden.');
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes und gespeicherter Typ eines eigenen Bildes; fremd/unbekannt ->
|
||||
* 404. Gelesen wird die Datei, nicht die Zeile — eine Zeile ohne Pfad
|
||||
* (noch nicht umgezogen) und eine fehlende Datei ergeben denselben 404.
|
||||
*/
|
||||
async getBytes(
|
||||
id: string,
|
||||
userId: string,
|
||||
tenantId: string,
|
||||
): Promise<{ mimeType: string; data: Uint8Array }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const row = await tenantPrisma.dashboardImage.findUnique({ where: { id } });
|
||||
if (!row || row.userId !== userId || row.tenantId !== tenantId) {
|
||||
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||
}
|
||||
|
||||
const absolute = row.storagePath === null ? null : absoluteImagePath(row.storagePath);
|
||||
if (absolute === null) {
|
||||
this.logger.warn(`Bilderrahmen-Bild ${id} hat keinen gueltigen Ablageort`);
|
||||
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(absolute);
|
||||
return { mimeType: row.mimeType, data };
|
||||
} catch (error) {
|
||||
// Selbstheilung waehrend der Umstellung (T-HK4-03): fehlt die Datei,
|
||||
// steckt aber noch die alte Spalte `data` in der Zeile, wird die Datei
|
||||
// daraus neu geschrieben und ausgeliefert. Der Fall ist real: ein
|
||||
// `pg_dump` aus der Zeit vor dem Umzug traegt die Bytes noch, das
|
||||
// Volume `user-files` wird getrennt gesichert — wer nur den Abzug
|
||||
// zurueckspielt, haette sonst Zeilen ohne Datei. Nach dem Entfernen der
|
||||
// Spalte (eigenes Todo) faellt dieser Zweig ersatzlos weg.
|
||||
if (row.data !== null) {
|
||||
try {
|
||||
await this.writeImageFile(row.userId, row.id, row.mimeType, row.data);
|
||||
this.logger.log(`Bilderrahmen-Bild ${id} aus der Datenbank wiederhergestellt`);
|
||||
return { mimeType: row.mimeType, data: row.data };
|
||||
} catch (writeError) {
|
||||
this.logger.error(
|
||||
`Bilderrahmen-Bild ${id} konnte nicht wiederhergestellt werden: ${
|
||||
writeError instanceof Error ? writeError.message : String(writeError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
this.logger.warn(
|
||||
`Bilderrahmen-Bild ${id} fehlt im Dateibereich: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht.
|
||||
* Zeile zuerst, Datei danach: ein Fehler beim Entfernen der Datei wird
|
||||
* protokolliert und geschluckt (T-HK4-04).
|
||||
*/
|
||||
async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const row = await tenantPrisma.dashboardImage.findUnique({ where: { id } });
|
||||
if (!row || row.userId !== userId || row.tenantId !== tenantId) {
|
||||
throw new NotFoundException(`Image with id '${id}' not found`);
|
||||
}
|
||||
await tenantPrisma.dashboardImage.delete({ where: { id } });
|
||||
|
||||
const absolute = row.storagePath === null ? null : absoluteImagePath(row.storagePath);
|
||||
if (absolute !== null) {
|
||||
try {
|
||||
await fs.unlink(absolute);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Datei des geloeschten Bilderrahmen-Bildes ${id} konnte nicht entfernt werden: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { id };
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt die Bytes an den servergenerierten Ort und liefert den
|
||||
* relativen Pfad fuer die Zeile zurueck. Der Ordner je Benutzer entsteht
|
||||
* dabei (`recursive: true`).
|
||||
*/
|
||||
private async writeImageFile(
|
||||
userId: string,
|
||||
id: string,
|
||||
mimeType: string,
|
||||
bytes: Uint8Array,
|
||||
): Promise<string> {
|
||||
const extension = extensionFor(mimeType);
|
||||
if (extension === null) {
|
||||
throw new Error(`Unbekannter Bildtyp '${mimeType}'`);
|
||||
}
|
||||
|
||||
const storagePath = relativeStoragePath(userId, id, extension);
|
||||
const absolute = absoluteImagePath(storagePath);
|
||||
if (absolute === null) {
|
||||
throw new Error(`Ungueltiger Ablageort fuer Bild ${id}`);
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(absolute), { recursive: true });
|
||||
await fs.writeFile(absolute, bytes);
|
||||
return storagePath;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Put,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { CreateSearchProviderDto } from './dto/create-search-provider.dto';
|
||||
import { CreateWidgetDto } from './dto/create-widget.dto';
|
||||
@@ -39,42 +39,53 @@ import { UpdateWidgetConfigDto } from './dto/update-widget-config.dto';
|
||||
export class DashboardController {
|
||||
constructor(private readonly dashboardService: DashboardService) {}
|
||||
|
||||
private extractContext(req: Request) {
|
||||
const userId = (req as any).user?.id;
|
||||
/**
|
||||
* BEFUND quick-260921-m34 (D-03, gemeldet nicht repariert): `getWidgets`
|
||||
* las die Rolle vorher als `req.user?.role` NACH dieser Pruefung und gab
|
||||
* sie an `DashboardService.getWidgets(role: Role)` weiter, das eine Rolle
|
||||
* zwingend verlangt. Der Bestandscode nahm also an, dass an dieser Stelle
|
||||
* immer ein Aufrufer vorliegt. Die Annahme stimmt — die Pruefung "No user
|
||||
* context" direkt darunter erzwingt sie seit jeher —, aber der Compiler
|
||||
* konnte die beiden Stellen nicht verbinden, weil sie in zwei Methoden
|
||||
* standen. Deshalb gibt diese Methode die Rolle jetzt MIT zurueck: keine
|
||||
* neue Pruefung, kein erfundener Wert, gleiche Reihenfolge, gleiche
|
||||
* Meldungen, gleiches Verhalten — nur sichtbar statt angenommen.
|
||||
*/
|
||||
private extractContext(req: AuthenticatedRequest) {
|
||||
const user = req.user;
|
||||
const tenantId =
|
||||
(req as any).tenantId ?? (req as any).user?.tenantId;
|
||||
req.tenantId ?? user?.tenantId;
|
||||
|
||||
if (!tenantId) {
|
||||
throw new ForbiddenException('No tenant context');
|
||||
}
|
||||
if (!userId) {
|
||||
if (!user?.id) {
|
||||
throw new ForbiddenException('No user context');
|
||||
}
|
||||
|
||||
return { userId, tenantId };
|
||||
return { userId: user.id, tenantId, role: user.role };
|
||||
}
|
||||
|
||||
@Get('layout')
|
||||
async getLayout(@Req() req: Request) {
|
||||
async getLayout(@Req() req: AuthenticatedRequest) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
return this.dashboardService.getLayout(userId, tenantId);
|
||||
}
|
||||
|
||||
@Put('layout')
|
||||
async saveLayout(@Req() req: Request, @Body() dto: SaveLayoutDto) {
|
||||
async saveLayout(@Req() req: AuthenticatedRequest, @Body() dto: SaveLayoutDto) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
return this.dashboardService.saveLayout(userId, tenantId, dto);
|
||||
}
|
||||
|
||||
@Get('widgets')
|
||||
async getWidgets(@Req() req: Request) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
const role = (req as any).user?.role;
|
||||
async getWidgets(@Req() req: AuthenticatedRequest) {
|
||||
const { userId, tenantId, role } = this.extractContext(req);
|
||||
return this.dashboardService.getWidgets(userId, tenantId, role);
|
||||
}
|
||||
|
||||
@Post('widgets')
|
||||
async addWidget(@Req() req: Request, @Body() dto: CreateWidgetDto) {
|
||||
async addWidget(@Req() req: AuthenticatedRequest, @Body() dto: CreateWidgetDto) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
return this.dashboardService.addWidget(userId, tenantId, dto);
|
||||
}
|
||||
@@ -82,7 +93,7 @@ export class DashboardController {
|
||||
@Patch('widgets/:id/config')
|
||||
async updateWidgetConfig(
|
||||
@Param('id') id: string,
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Body() dto: UpdateWidgetConfigDto,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
@@ -92,7 +103,7 @@ export class DashboardController {
|
||||
@Delete('widgets/:id')
|
||||
async removeWidget(
|
||||
@Param('id') id: string,
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
return this.dashboardService.removeWidget(id, userId, tenantId);
|
||||
@@ -101,14 +112,14 @@ export class DashboardController {
|
||||
// --- Search Providers (05-02, D-15) ---
|
||||
|
||||
@Get('search-providers')
|
||||
async getSearchProviders(@Req() req: Request) {
|
||||
async getSearchProviders(@Req() req: AuthenticatedRequest) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
return this.dashboardService.getSearchProviders(userId, tenantId);
|
||||
}
|
||||
|
||||
@Post('search-providers')
|
||||
async addSearchProvider(
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Body() dto: CreateSearchProviderDto,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
@@ -118,7 +129,7 @@ export class DashboardController {
|
||||
@Delete('search-providers/:id')
|
||||
async removeSearchProvider(
|
||||
@Param('id') id: string,
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
return this.dashboardService.removeSearchProvider(id, userId, tenantId);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
import { DashboardImagesController } from './dashboard-images.controller';
|
||||
import { DashboardImagesService } from './dashboard-images.service';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
/**
|
||||
@@ -9,6 +11,8 @@ import { DashboardService } from './dashboard.service';
|
||||
* Provides:
|
||||
* - DashboardService: CRUD for per-user dashboard layouts and widget instances
|
||||
* - DashboardController: REST API for layout and widget operations
|
||||
* - DashboardImagesService/-Controller: Bilder des Bilderrahmen-Widgets
|
||||
* (quick-260921-pi9), je Benutzer in der Datenbank
|
||||
*
|
||||
* Imports ModuleRegistryModule so DashboardService can inject
|
||||
* ModuleAccessService for the D-22 widget-module filter (Plan 15-05).
|
||||
@@ -17,8 +21,8 @@ import { DashboardService } from './dashboard.service';
|
||||
*/
|
||||
@Module({
|
||||
imports: [ModuleRegistryModule],
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService],
|
||||
controllers: [DashboardController, DashboardImagesController],
|
||||
providers: [DashboardService, DashboardImagesService],
|
||||
exports: [DashboardService],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
import { WIDGET_TYPES } from '@tessera/shared';
|
||||
|
||||
/**
|
||||
* DTO for creating a new widget instance on a user's dashboard.
|
||||
* widgetType must be one of the seven supported types.
|
||||
*
|
||||
* quick-260922-m1h: `widgetType` wird gegen `WIDGET_TYPES` aus
|
||||
* `@tessera/shared` geprüft — dieselbe Liste, aus der das Frontend seine
|
||||
* Registry und seinen Katalog ableitet. Vorher stand die Liste hier ein
|
||||
* zweites Mal; vergaß man einen Eintrag, lehnte die API eine im Katalog
|
||||
* angebotene Kachel mit 400 ab.
|
||||
*
|
||||
* config is optional and defaults to {} on the model.
|
||||
*/
|
||||
export class CreateWidgetDto {
|
||||
@IsString()
|
||||
@IsIn(['clock', 'search', 'calendar', 'note', 'calculator', 'favorites', 'stopwatch'])
|
||||
@IsIn([...WIDGET_TYPES])
|
||||
widgetType!: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { WIDGET_MODULE_SLUGS, WIDGET_TYPES } from '@tessera/shared';
|
||||
import { CreateWidgetDto } from './dto/create-widget.dto';
|
||||
import { WIDGET_MODULE_MAP, getModuleSlugForWidgetType } from './widget-module-map';
|
||||
|
||||
/**
|
||||
* quick-260922-m1h: Web und API lesen dieselbe Tabelle. Liefen sie
|
||||
* auseinander, wuerde der Katalog eine Kachel anbieten, die der Server
|
||||
* danach wieder herausfiltert (oder umgekehrt).
|
||||
*/
|
||||
describe('widget-module-map (quick-260922-m1h)', () => {
|
||||
it('WIDGET_MODULE_MAP ist die Tabelle aus @tessera/shared, keine zweite Kopie', () => {
|
||||
expect(WIDGET_MODULE_MAP).toBe(WIDGET_MODULE_SLUGS);
|
||||
});
|
||||
|
||||
it('jeder Schluessel der Tabelle ist ein bekannter Widget-Typ', () => {
|
||||
for (const type of Object.keys(WIDGET_MODULE_MAP)) {
|
||||
expect(WIDGET_TYPES).toContain(type);
|
||||
}
|
||||
});
|
||||
|
||||
it('die neun heutigen Kacheln sind Plattform-Kacheln ohne Modulbezug', () => {
|
||||
for (const type of WIDGET_TYPES) {
|
||||
expect(getModuleSlugForWidgetType(type)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('ein unbekannter Typ liefert undefined statt zu werfen', () => {
|
||||
expect(getModuleSlugForWidgetType('gibt-es-nicht')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Der Kern des Umbaus: die `@IsIn`-Whitelist ist keine handgepflegte zweite
|
||||
* Liste mehr. Vergisst kuenftig jemand einen Eintrag in `packages/shared`,
|
||||
* schlaegt dieser Test fehl, statt die API eine gueltige Kachel mit 400
|
||||
* ablehnen zu lassen.
|
||||
*/
|
||||
describe('CreateWidgetDto-Whitelist (quick-260922-m1h)', () => {
|
||||
async function validateType(widgetType: string) {
|
||||
const dto = plainToInstance(CreateWidgetDto, { widgetType });
|
||||
return validate(dto);
|
||||
}
|
||||
|
||||
it.each([...WIDGET_TYPES])('akzeptiert den Typ "%s"', async (widgetType) => {
|
||||
await expect(validateType(widgetType)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('lehnt einen Typ ab, der nicht in WIDGET_TYPES steht', async () => {
|
||||
const errors = await validateType('gibt-es-nicht');
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].property).toBe('widgetType');
|
||||
expect(errors[0].constraints).toHaveProperty('isIn');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { WIDGET_MODULE_SLUGS } from '@tessera/shared';
|
||||
|
||||
/**
|
||||
* Zuordnung Widget-Typ → Modul (D-22, PERM-07).
|
||||
*
|
||||
@@ -8,24 +10,29 @@
|
||||
* Schlüssel sind Werte von `WidgetInstance.widgetType`, Werte sind
|
||||
* Modul-Slugs aus `Module.slug`.
|
||||
*
|
||||
* quick-260922-m1h: Die Tabelle selbst steht seit diesem Umbau in
|
||||
* `packages/shared/src/index.ts` als `WIDGET_MODULE_SLUGS` — EINE Tabelle
|
||||
* für beide Seiten, damit der Katalogfilter im Web
|
||||
* (`visibleWidgetTypes`, Komfort) und dieser Server-Filter (verbindlich)
|
||||
* nicht auseinanderlaufen. Hier steht nur noch der Lesezugriff; die
|
||||
* öffentliche Schnittstelle dieser Datei bleibt unverändert, weil
|
||||
* dashboard.service.spec.ts sie gezielt mockt.
|
||||
*
|
||||
* Bewusst eine TypeScript-Konstante statt einer Spalte auf
|
||||
* `WidgetInstance`: eine Migration auf einer bereits befüllten Tabelle
|
||||
* für ein Feld, das derzeit für jede Zeile leer wäre, wiegt schwerer als
|
||||
* diese Konstante mit identischer Aussagekraft (15-RESEARCH.md Pitfall 5).
|
||||
*
|
||||
* Die Tabelle ist am Ende dieser Phase bewusst leer: alle sieben heute
|
||||
* registrierten Widget-Typen (clock/search/calendar/note/calculator/
|
||||
* favorites/stopwatch, siehe apps/web/src/components/dashboard/
|
||||
* widget-registry.tsx) sind Plattform-Widgets ohne Modulbezug. Das
|
||||
* einzige bislang geplante modulgebundene Widget steht in
|
||||
* .planning/REQUIREMENTS.md unter "Future Requirements (deferred)" und
|
||||
* wird in dieser Phase bewusst nicht registriert.
|
||||
* Die Tabelle ist bewusst leer: alle neun registrierten Widget-Typen
|
||||
* (clock/search/calendar/note/calculator/favorites/stopwatch/
|
||||
* picture-frame/xframe) sind Plattform-Widgets ohne Modulbezug. Die erste
|
||||
* modulgebundene Kachel trägt ihren Slug in `WIDGET_MODULE_SLUGS` ein.
|
||||
*/
|
||||
export const WIDGET_MODULE_MAP: Readonly<Record<string, string>> = {};
|
||||
export const WIDGET_MODULE_MAP: Readonly<Record<string, string>> = WIDGET_MODULE_SLUGS;
|
||||
|
||||
/**
|
||||
* Liefert den Modul-Slug für einen Widget-Typ, oder `undefined`, wenn
|
||||
* der Typ kein Modul-Widget ist (der heutige Zustand für alle sieben
|
||||
* der Typ kein Modul-Widget ist (der heutige Zustand für alle neun
|
||||
* bestehenden Typen). Einziger Lesezugriff auf die Zuordnungstabelle,
|
||||
* damit Tests sie gezielt mocken können.
|
||||
*/
|
||||
|
||||
@@ -133,6 +133,14 @@ export class DkvSchedulerService implements OnModuleInit {
|
||||
// Cast required: our minimal CronJob type doesn't match cron's full type signature.
|
||||
// At runtime the object IS a full CronJob — SchedulerRegistry only calls stop() on it.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// URTEIL: BLEIBT (260921-m34, Aufgabe 3, D-01). Gemessen: ohne die
|
||||
// Zusicherung meldet tsc, dass das lokale `job` nur die Form
|
||||
// `{ start(): void }` hat, waehrend addCronJob() einen vollstaendigen
|
||||
// CronJob verlangt. Ursache ist der require()-Umweg aus 07-04 (pnpm-
|
||||
// Isolation, `cron` ist nur eine mittelbare Abhaengigkeit). Das
|
||||
// aufzuloesen hiesse, die Beschaffung der Klasse zu aendern — eine
|
||||
// Verhaltensaenderung — oder `cron` direkt aufzunehmen — eine neue
|
||||
// Abhaengigkeit. Beides ist hier verboten (D-03/D-04).
|
||||
this.schedulerRegistry.addCronJob(jobName, job as any);
|
||||
job.start();
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@ import {
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { Role } from '@prisma/client';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import type {
|
||||
AuthenticatedRequest,
|
||||
UploadedFileLike,
|
||||
} from '../auth/types/auth-user';
|
||||
import type { Response } from 'express';
|
||||
import { DkvSchedulerService } from './dkv-scheduler.service';
|
||||
import { DkvService } from './dkv.service';
|
||||
import { DkvConfigDto } from './dto/dkv-config.dto';
|
||||
@@ -58,7 +63,7 @@ export class DkvController {
|
||||
/** GET /dkv/config — returns module config with username + hasPassword. 404 when not yet configured. */
|
||||
@Get('config')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async getConfig(@Req() req: any) {
|
||||
async getConfig(@Req() req: AuthenticatedRequest) {
|
||||
const tenantId = this._requireTenant(req);
|
||||
const config = await this.dkvService.getConfigForApi(tenantId);
|
||||
if (!config) {
|
||||
@@ -75,7 +80,7 @@ export class DkvController {
|
||||
*/
|
||||
@Put('config')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async saveConfig(@Req() req: any, @Body() dto: DkvConfigDto) {
|
||||
async saveConfig(@Req() req: AuthenticatedRequest, @Body() dto: DkvConfigDto) {
|
||||
const tenantId = this._requireTenant(req);
|
||||
const result = await this.dkvService.saveConfig(tenantId, dto);
|
||||
|
||||
@@ -94,7 +99,7 @@ export class DkvController {
|
||||
/** POST /dkv/check-now — immediately run the inbox processing pipeline. */
|
||||
@Post('check-now')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async checkNow(@Req() req: any) {
|
||||
async checkNow(@Req() req: AuthenticatedRequest) {
|
||||
const tenantId = this._requireTenant(req);
|
||||
return this.dkvService.checkNow(tenantId);
|
||||
}
|
||||
@@ -105,7 +110,7 @@ export class DkvController {
|
||||
*/
|
||||
@Post('test-connection')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async testConnection(@Req() req: any, @Body() dto: DkvConfigDto) {
|
||||
async testConnection(@Req() req: AuthenticatedRequest, @Body() dto: DkvConfigDto) {
|
||||
const tenantId = this._requireTenant(req);
|
||||
return this.dkvService.testConnection(tenantId, dto);
|
||||
}
|
||||
@@ -118,7 +123,7 @@ export class DkvController {
|
||||
*/
|
||||
@Get('history')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async getHistory(@Req() req: any, @Query() query: DkvHistoryQueryDto) {
|
||||
async getHistory(@Req() req: AuthenticatedRequest, @Query() query: DkvHistoryQueryDto) {
|
||||
const tenantId = this._requireTenant(req);
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
@@ -137,9 +142,9 @@ export class DkvController {
|
||||
@Get('exports/:filename')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async downloadExport(
|
||||
@Req() req: any,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('filename') filename: string,
|
||||
@Res() res: any,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const tenantId = this._requireTenant(req);
|
||||
|
||||
@@ -164,7 +169,7 @@ export class DkvController {
|
||||
/** GET /dkv/vehicles — list all vehicle master records for this tenant. */
|
||||
@Get('vehicles')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async listVehicles(@Req() req: any) {
|
||||
async listVehicles(@Req() req: AuthenticatedRequest) {
|
||||
const tenantId = this._requireTenant(req);
|
||||
return this.dkvService.listVehicles(tenantId);
|
||||
}
|
||||
@@ -172,7 +177,7 @@ export class DkvController {
|
||||
/** POST /dkv/vehicles — create a new vehicle master record. */
|
||||
@Post('vehicles')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async createVehicle(@Req() req: any, @Body() dto: CreateVehicleDto) {
|
||||
async createVehicle(@Req() req: AuthenticatedRequest, @Body() dto: CreateVehicleDto) {
|
||||
const tenantId = this._requireTenant(req);
|
||||
return this.dkvService.createVehicle(tenantId, dto);
|
||||
}
|
||||
@@ -181,7 +186,7 @@ export class DkvController {
|
||||
@Put('vehicles/:id')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async updateVehicle(
|
||||
@Req() req: any,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateVehicleDto,
|
||||
) {
|
||||
@@ -192,7 +197,7 @@ export class DkvController {
|
||||
/** DELETE /dkv/vehicles/:id — delete a vehicle master record. */
|
||||
@Delete('vehicles/:id')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async deleteVehicle(@Req() req: any, @Param('id') id: string) {
|
||||
async deleteVehicle(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
const tenantId = this._requireTenant(req);
|
||||
return this.dkvService.deleteVehicle(tenantId, id);
|
||||
}
|
||||
@@ -213,8 +218,8 @@ export class DkvController {
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB — generous for any realistic vehicle list (WR-05)
|
||||
}))
|
||||
async importVehicles(
|
||||
@Req() req: any,
|
||||
@UploadedFile() file: any,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@UploadedFile() file: UploadedFileLike | undefined,
|
||||
@Body('mode') mode: string,
|
||||
) {
|
||||
const tenantId = this._requireTenant(req);
|
||||
@@ -223,7 +228,7 @@ export class DkvController {
|
||||
throw new BadRequestException('No CSV file uploaded (field name must be "file")');
|
||||
}
|
||||
|
||||
const csvText = (file.buffer as Buffer).toString('utf-8');
|
||||
const csvText = file.buffer.toString('utf-8');
|
||||
const importMode = mode === 'replace' ? 'replace' : 'merge';
|
||||
|
||||
return this.dkvService.importVehiclesCsv(tenantId, csvText, importMode);
|
||||
@@ -232,8 +237,8 @@ export class DkvController {
|
||||
// ─── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/** Extract and validate tenantId from request; throw BadRequestException when absent. */
|
||||
private _requireTenant(req: any): string {
|
||||
const tenantId = req.tenantId as string | undefined;
|
||||
private _requireTenant(req: AuthenticatedRequest): string {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new BadRequestException('No tenant context');
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ export class DkvService {
|
||||
* 260914-eym systemgebunden, eine Zeile je aktivem Mandanten).
|
||||
*/
|
||||
async loadConfig(tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
return tenantPrisma.dkvModuleConfig.findUnique({
|
||||
where: { tenantId },
|
||||
select: CONFIG_SAFE_SELECT,
|
||||
@@ -153,7 +153,7 @@ export class DkvService {
|
||||
* (T-07-12) — der Planer braucht nur tenantId und pollIntervalMin.
|
||||
*/
|
||||
async loadActiveConfigsForScheduler() {
|
||||
const systemPrisma = forSystem(this.prisma) as any;
|
||||
const systemPrisma = forSystem(this.prisma);
|
||||
return systemPrisma.dkvModuleConfig.findMany({
|
||||
where: { isActive: true },
|
||||
select: CONFIG_SAFE_SELECT,
|
||||
@@ -171,7 +171,7 @@ export class DkvService {
|
||||
* Methode).
|
||||
*/
|
||||
async getConfigForApi(tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const safe = await tenantPrisma.dkvModuleConfig.findUnique({
|
||||
where: { tenantId },
|
||||
select: CONFIG_SAFE_SELECT,
|
||||
@@ -214,7 +214,7 @@ export class DkvService {
|
||||
* Bindung als zweite Schicht bestehen.
|
||||
*/
|
||||
async saveConfig(tenantId: string, dto: DkvConfigDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
let encryptedInboxCreds: string | undefined;
|
||||
|
||||
const credChanged = (dto.password && dto.password.length > 0) ||
|
||||
@@ -277,7 +277,7 @@ export class DkvService {
|
||||
* Rueckgriff auf die gespeicherten Zugangsdaten.
|
||||
*/
|
||||
async testConnection(tenantId: string, dto: DkvConfigDto): Promise<{ success: boolean; message?: string }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
let password: string | undefined = dto.password;
|
||||
|
||||
// If no password in DTO, fall back to the stored one
|
||||
@@ -362,7 +362,7 @@ export class DkvService {
|
||||
private async _runPipeline(tenantId: string): Promise<void> {
|
||||
// Load raw config (need encryptedInboxCreds for decryption).
|
||||
// Mandantengebunden (260909-mir).
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const config = await tenantPrisma.dkvModuleConfig.findUnique({ where: { tenantId } });
|
||||
if (!config) {
|
||||
this.logger.warn(`DKV processInbox: no config for tenant ${tenantId}`);
|
||||
@@ -448,7 +448,7 @@ export class DkvService {
|
||||
// Mandantengebunden (260909-mir): EIN gebundener Klient fuer beide
|
||||
// dkvInvoiceHistory.create()-Aufrufe dieser Methode (Erfolgsfall UND
|
||||
// Zerlegungsfehler-Fall).
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
|
||||
// D-10: Up to 3 parse retries
|
||||
let parseResult: Awaited<ReturnType<typeof this.parser.parsePdf>> | null = null;
|
||||
@@ -546,7 +546,7 @@ export class DkvService {
|
||||
// ─── Vehicle CRUD ────────────────────────────────────────────────────────────
|
||||
|
||||
async listVehicles(tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
return tenantPrisma.dkvVehicleMaster.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { kennzeichen: 'asc' },
|
||||
@@ -554,7 +554,7 @@ export class DkvService {
|
||||
}
|
||||
|
||||
async createVehicle(tenantId: string, dto: CreateVehicleDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
return tenantPrisma.dkvVehicleMaster.create({
|
||||
data: { tenantId, ...dto },
|
||||
});
|
||||
@@ -571,7 +571,7 @@ export class DkvService {
|
||||
* Schreibzugriff dahinter waere genau die Luecke, nicht die Loesung.
|
||||
*/
|
||||
async updateVehicle(tenantId: string, id: string, dto: UpdateVehicleDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const existing = await tenantPrisma.dkvVehicleMaster.findFirst({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
@@ -581,7 +581,7 @@ export class DkvService {
|
||||
|
||||
/** Mandantengebunden (260909-mir, Befund G) — siehe updateVehicle() oben. */
|
||||
async deleteVehicle(tenantId: string, id: string): Promise<{ deleted: boolean }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const existing = await tenantPrisma.dkvVehicleMaster.findFirst({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
@@ -611,7 +611,7 @@ export class DkvService {
|
||||
csvText: string,
|
||||
mode: 'merge' | 'replace',
|
||||
): Promise<{ imported: number; mode: string }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const vehicles = _parseVehicleCsv(csvText);
|
||||
if (vehicles.length === 0) {
|
||||
throw new BadRequestException(
|
||||
@@ -661,7 +661,7 @@ export class DkvService {
|
||||
page: number;
|
||||
limit: number;
|
||||
}> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const skip = (page - 1) * limit;
|
||||
const [items, total] = await Promise.all([
|
||||
tenantPrisma.dkvInvoiceHistory.findMany({
|
||||
@@ -714,7 +714,7 @@ export class DkvService {
|
||||
|
||||
// Stage 2 (NEW, 260909-mir): the ownership gate. A bound read — the
|
||||
// only tenant-scoped statement of who this file belongs to.
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const owningHistoryRow = await tenantPrisma.dkvInvoiceHistory.findFirst({
|
||||
where: { tenantId, exportFilename: filename },
|
||||
});
|
||||
@@ -751,8 +751,8 @@ export class DkvService {
|
||||
vehicleFormatString: string,
|
||||
): Promise<{ lieferdatum: string; fahrzeug: string; fahrer: string; ort: string; kilometerstand: number | null }[]> {
|
||||
// Batch load vehicle master to avoid N+1 queries
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const masters: any[] = await tenantPrisma.dkvVehicleMaster.findMany({ where: { tenantId } });
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const masters = await tenantPrisma.dkvVehicleMaster.findMany({ where: { tenantId } });
|
||||
// Normalize keys: DKV PDF may omit hyphens or use spaces ("GP JL 740E" vs "GP-JL 740E")
|
||||
const masterMap = new Map(masters.map((m) => [_normalizeKennzeichen(m.kennzeichen), m]));
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { Response } from 'express';
|
||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import { CreateFavoriteDto } from './dto/create-favorite.dto';
|
||||
import { ReorderFavoritesDto } from './dto/reorder-favorites.dto';
|
||||
import { UpdateFavoriteDto } from './dto/update-favorite.dto';
|
||||
@@ -46,10 +47,10 @@ import { FavoritesService } from './favorites.service';
|
||||
export class FavoritesController {
|
||||
constructor(private readonly favoritesService: FavoritesService) {}
|
||||
|
||||
private extractContext(req: Request) {
|
||||
const userId = (req as any).user?.id;
|
||||
private extractContext(req: AuthenticatedRequest) {
|
||||
const userId = req.user?.id;
|
||||
const tenantId =
|
||||
(req as any).tenantId ?? (req as any).user?.tenantId;
|
||||
req.tenantId ?? req.user?.tenantId;
|
||||
|
||||
if (!tenantId) {
|
||||
throw new ForbiddenException('No tenant context');
|
||||
@@ -64,7 +65,7 @@ export class FavoritesController {
|
||||
@Get()
|
||||
async list(
|
||||
@Query('widgetId', ParseUUIDPipe) widgetId: string,
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
|
||||
@@ -74,7 +75,7 @@ export class FavoritesController {
|
||||
@Post()
|
||||
async create(
|
||||
@Body() dto: CreateFavoriteDto,
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
|
||||
@@ -91,7 +92,7 @@ export class FavoritesController {
|
||||
@Put('order')
|
||||
async reorder(
|
||||
@Body() dto: ReorderFavoritesDto,
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
|
||||
@@ -110,7 +111,7 @@ export class FavoritesController {
|
||||
@Get(':id/icon')
|
||||
async getIcon(
|
||||
@Param('id') id: string,
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
@@ -135,7 +136,7 @@ export class FavoritesController {
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateFavoriteDto,
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
|
||||
@@ -145,7 +146,7 @@ export class FavoritesController {
|
||||
@Delete(':id')
|
||||
async remove(
|
||||
@Param('id') id: string,
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractContext(req);
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export class FavoritesService {
|
||||
async list(tenantId: string, userId: string, widgetId: string) {
|
||||
if (!widgetId) throw new BadRequestException('widgetId is required');
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
return tenantPrisma.favoriteLink.findMany({
|
||||
where: { userId, widgetId },
|
||||
orderBy: [{ position: 'asc' }, { title: 'asc' }],
|
||||
@@ -79,7 +79,7 @@ export class FavoritesService {
|
||||
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection.
|
||||
*/
|
||||
async create(tenantId: string, userId: string, dto: CreateFavoriteDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
|
||||
// T-GWH-05: der Fremdschluessel prueft an der Zeilenschutz-Regel von
|
||||
// WidgetInstance vorbei (Aufgabe 1, Pruefung 7) — ohne diesen Riegel
|
||||
@@ -123,7 +123,7 @@ export class FavoritesService {
|
||||
* Accepts null as an explicit value for iconUrl (clears stored icon).
|
||||
*/
|
||||
async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
||||
|
||||
if (!link || link.userId !== userId) {
|
||||
@@ -164,7 +164,7 @@ export class FavoritesService {
|
||||
* Verifies userId ownership before deleting (T-08-06).
|
||||
*/
|
||||
async remove(tenantId: string, id: string, userId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
||||
|
||||
if (!link || link.userId !== userId) {
|
||||
@@ -210,7 +210,7 @@ export class FavoritesService {
|
||||
throw new BadRequestException('ids must match the favorites of this widget exactly');
|
||||
}
|
||||
|
||||
return withTenantTransaction(this.prisma, tenantId, async (tx: any) => {
|
||||
return withTenantTransaction(this.prisma, tenantId, async (tx) => {
|
||||
const existing = await tx.favoriteLink.findMany({
|
||||
where: { userId, widgetId: dto.widgetId },
|
||||
select: { id: true },
|
||||
@@ -258,7 +258,7 @@ export class FavoritesService {
|
||||
id: string,
|
||||
userId: string,
|
||||
): Promise<{ contentType: string; body: Buffer }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const link = await tenantPrisma.favoriteLink.findUnique({ where: { id } });
|
||||
|
||||
if (!link || link.userId !== userId || !link.iconUrl) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { Request } from 'express';
|
||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { AddGroupMembersDto } from './dto/add-group-members.dto';
|
||||
@@ -30,8 +30,8 @@ import { GroupsService } from './groups.service';
|
||||
export class GroupsController {
|
||||
constructor(private readonly groupsService: GroupsService) {}
|
||||
|
||||
private getTenantId(req: Request): string {
|
||||
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
|
||||
private getTenantId(req: AuthenticatedRequest): string {
|
||||
const tenantId = req.tenantId ?? req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new ForbiddenException('No tenant context');
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export class GroupsController {
|
||||
@Get()
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async list(@Req() req: Request) {
|
||||
async list(@Req() req: AuthenticatedRequest) {
|
||||
return this.groupsService.listForTenant(this.getTenantId(req));
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export class GroupsController {
|
||||
@Post()
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async create(@Body() dto: CreateGroupDto, @Req() req: Request) {
|
||||
async create(@Body() dto: CreateGroupDto, @Req() req: AuthenticatedRequest) {
|
||||
return this.groupsService.create(this.getTenantId(req), dto);
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export class GroupsController {
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateGroupDto,
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
return this.groupsService.update(this.getTenantId(req), id, dto);
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export class GroupsController {
|
||||
@Delete(':id')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async remove(@Param('id') id: string, @Req() req: Request) {
|
||||
async remove(@Param('id') id: string, @Req() req: AuthenticatedRequest) {
|
||||
return this.groupsService.remove(this.getTenantId(req), id);
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ export class GroupsController {
|
||||
@Get(':id/impact')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async impact(@Param('id') id: string, @Req() req: Request) {
|
||||
async impact(@Param('id') id: string, @Req() req: AuthenticatedRequest) {
|
||||
return this.groupsService.getImpact(this.getTenantId(req), id);
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ export class GroupsController {
|
||||
@Get(':id/members')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async members(@Param('id') id: string, @Req() req: Request) {
|
||||
async members(@Param('id') id: string, @Req() req: AuthenticatedRequest) {
|
||||
return this.groupsService.listMembers(this.getTenantId(req), id);
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ export class GroupsController {
|
||||
async addMembers(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: AddGroupMembersDto,
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
return this.groupsService.addMembers(this.getTenantId(req), id, dto.userIds);
|
||||
}
|
||||
@@ -134,7 +134,7 @@ export class GroupsController {
|
||||
async removeMember(
|
||||
@Param('id') id: string,
|
||||
@Param('userId') userId: string,
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
await this.groupsService.removeMember(this.getTenantId(req), id, userId);
|
||||
return { success: true };
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { MembershipSource } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
|
||||
import { prismaErrorCode } from '../prisma/prisma-error';
|
||||
|
||||
/**
|
||||
* Name der automatisch angelegten Standardgruppe (D-13). Geteilte Wahrheit
|
||||
@@ -53,20 +54,14 @@ export class GroupsService {
|
||||
* Mitgliederzahl. Ein Mandant ohne Gruppen liefert ein leeres Array.
|
||||
*/
|
||||
async listForTenant(tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
// Explizit als any[] annotiert (nicht nur der Rueckgabewert von await):
|
||||
// ohne diese Array-Verankerung inferiert TypeScript den Rueckgabewert
|
||||
// dieser Methode als bloss `any` statt `any[]`, und Aufrufer, die auf
|
||||
// dem Ergebnis `.find()` aufrufen, wuerden TS7006 (impliziter any-Typ
|
||||
// im Callback-Parameter) melden, obwohl der gebundene Client bewusst
|
||||
// `any` ist (siehe forTenant()-Aufrufe in dieser Datei).
|
||||
const groups: any[] = await tenantPrisma.group.findMany({
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const groups = await tenantPrisma.group.findMany({
|
||||
where: { tenantId },
|
||||
orderBy: { name: 'asc' },
|
||||
include: { _count: { select: { memberships: true } } },
|
||||
});
|
||||
|
||||
return groups.map((g: any) => ({
|
||||
return groups.map((g) => ({
|
||||
id: g.id,
|
||||
tenantId: g.tenantId,
|
||||
name: g.name,
|
||||
@@ -92,13 +87,13 @@ export class GroupsService {
|
||||
throw new BadRequestException('Gruppenname darf nicht leer sein');
|
||||
}
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
try {
|
||||
return await tenantPrisma.group.create({
|
||||
data: { tenantId, name },
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'P2002') {
|
||||
} catch (err: unknown) {
|
||||
if (prismaErrorCode(err) === 'P2002') {
|
||||
throw new ConflictException(
|
||||
`Eine Gruppe mit dem Namen '${name}' existiert bereits in diesem Mandanten`,
|
||||
);
|
||||
@@ -112,7 +107,7 @@ export class GroupsService {
|
||||
* Mandanten liefert NotFoundException statt eines Treffers.
|
||||
*/
|
||||
private async findOwned(tenantId: string, id: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const group = await tenantPrisma.group.findFirst({
|
||||
where: { id, tenantId },
|
||||
});
|
||||
@@ -188,7 +183,7 @@ export class GroupsService {
|
||||
|
||||
try {
|
||||
if (data.isDefault === true) {
|
||||
const updated = await withTenantTransaction(this.prisma, tenantId, async (tx: any) => {
|
||||
const updated = await withTenantTransaction(this.prisma, tenantId, async (tx) => {
|
||||
await tx.group.updateMany({
|
||||
where: { tenantId, isDefault: true },
|
||||
data: { isDefault: false },
|
||||
@@ -205,13 +200,13 @@ export class GroupsService {
|
||||
updateData.isDefault = false;
|
||||
}
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
return await tenantPrisma.group.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'P2002') {
|
||||
} catch (err: unknown) {
|
||||
if (prismaErrorCode(err) === 'P2002') {
|
||||
throw new ConflictException(
|
||||
`Eine Gruppe mit dem Namen '${updateData.name}' existiert bereits in diesem Mandanten`,
|
||||
);
|
||||
@@ -227,7 +222,7 @@ export class GroupsService {
|
||||
async getImpact(tenantId: string, id: string) {
|
||||
await this.findOwned(tenantId, id);
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const [memberCount, grantCount] = await Promise.all([
|
||||
tenantPrisma.groupMembership.count({ where: { groupId: id } }),
|
||||
tenantPrisma.moduleGrant.count({ where: { groupId: id } }),
|
||||
@@ -247,11 +242,11 @@ export class GroupsService {
|
||||
async remove(tenantId: string, id: string) {
|
||||
await this.findOwned(tenantId, id);
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
try {
|
||||
return await tenantPrisma.group.delete({ where: { id } });
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'P2025') {
|
||||
} catch (err: unknown) {
|
||||
if (prismaErrorCode(err) === 'P2025') {
|
||||
throw new NotFoundException(`Gruppe '${id}' nicht gefunden`);
|
||||
}
|
||||
throw err;
|
||||
@@ -265,7 +260,7 @@ export class GroupsService {
|
||||
async listMembers(tenantId: string, id: string) {
|
||||
await this.findOwned(tenantId, id);
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
return tenantPrisma.groupMembership.findMany({
|
||||
where: { groupId: id },
|
||||
include: {
|
||||
@@ -286,12 +281,12 @@ export class GroupsService {
|
||||
async addMembers(tenantId: string, id: string, userIds: string[]) {
|
||||
await this.findOwned(tenantId, id);
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const validUsers = await tenantPrisma.user.findMany({
|
||||
where: { id: { in: userIds }, tenantId },
|
||||
select: { id: true },
|
||||
});
|
||||
const validIds = validUsers.map((u: any) => u.id);
|
||||
const validIds = validUsers.map((u) => u.id);
|
||||
if (validIds.length === 0) {
|
||||
return { added: 0 };
|
||||
}
|
||||
@@ -316,7 +311,7 @@ export class GroupsService {
|
||||
async removeMember(tenantId: string, id: string, userId: string) {
|
||||
await this.findOwned(tenantId, id);
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
await tenantPrisma.groupMembership.deleteMany({
|
||||
where: { groupId: id, userId, source: MembershipSource.MANUAL },
|
||||
});
|
||||
@@ -354,14 +349,14 @@ export class GroupsService {
|
||||
* propagieren.
|
||||
*/
|
||||
async ensureDefaultGroup(tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const existingCount = await tenantPrisma.group.count({ where: { tenantId } });
|
||||
if (existingCount > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await withTenantTransaction(this.prisma, tenantId, async (tx: any) => {
|
||||
return await withTenantTransaction(this.prisma, tenantId, async (tx) => {
|
||||
const group = await tx.group.create({
|
||||
data: { tenantId, name: DEFAULT_GROUP_NAME, isDefault: true },
|
||||
});
|
||||
@@ -372,6 +367,9 @@ export class GroupsService {
|
||||
});
|
||||
if (users.length > 0) {
|
||||
await tx.groupMembership.createMany({
|
||||
// u: any bleibt (gemessen, Aufgabe 1 260921-m34) - tx ist selbst
|
||||
// any (siehe Begruendung an withTenantTransaction()), any.map()
|
||||
// gibt hier keine kontextuelle Typisierung des Parameters.
|
||||
data: users.map((u: any) => ({
|
||||
groupId: group.id,
|
||||
userId: u.id,
|
||||
@@ -387,6 +385,8 @@ export class GroupsService {
|
||||
});
|
||||
if (activations.length > 0) {
|
||||
await tx.moduleGrant.createMany({
|
||||
// a: any bleibt (gemessen, Aufgabe 1 260921-m34) - selbe Ursache
|
||||
// wie bei `u` oben: tx ist any.
|
||||
data: activations.map((a: any) => ({
|
||||
tenantId,
|
||||
moduleId: a.moduleId,
|
||||
@@ -399,8 +399,8 @@ export class GroupsService {
|
||||
|
||||
return group;
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'P2002') {
|
||||
} catch (err: unknown) {
|
||||
if (prismaErrorCode(err) === 'P2002') {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
@@ -435,7 +435,7 @@ export class GroupsService {
|
||||
* werfen — exakt das Muster aus ensureDefaultGroup().
|
||||
*/
|
||||
async reassignDefaultBeforeDelete(tenantId: string, groupId: string): Promise<boolean> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
|
||||
const group = await tenantPrisma.group.findFirst({
|
||||
where: { id: groupId, tenantId },
|
||||
@@ -458,7 +458,7 @@ export class GroupsService {
|
||||
}
|
||||
|
||||
try {
|
||||
await withTenantTransaction(this.prisma, tenantId, async (tx: any) => {
|
||||
await withTenantTransaction(this.prisma, tenantId, async (tx) => {
|
||||
await tx.group.updateMany({
|
||||
where: { tenantId, isDefault: true },
|
||||
data: { isDefault: false },
|
||||
@@ -469,8 +469,8 @@ export class GroupsService {
|
||||
});
|
||||
});
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'P2002') {
|
||||
} catch (err: unknown) {
|
||||
if (prismaErrorCode(err) === 'P2002') {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
@@ -497,7 +497,7 @@ export class GroupsService {
|
||||
* Treffer folgenlos zurückkehren statt zu werfen.
|
||||
*/
|
||||
async addUserToDefaultGroup(tenantId: string, userId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const defaultGroup = await tenantPrisma.group.findFirst({
|
||||
where: { tenantId, isDefault: true },
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { Request } from 'express';
|
||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { CreateModuleGrantDto } from './dto/create-module-grant.dto';
|
||||
@@ -31,8 +31,8 @@ import { ModuleGrantsService } from './module-grants.service';
|
||||
export class ModuleGrantsController {
|
||||
constructor(private readonly moduleGrantsService: ModuleGrantsService) {}
|
||||
|
||||
private getTenantId(req: Request): string {
|
||||
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
|
||||
private getTenantId(req: AuthenticatedRequest): string {
|
||||
const tenantId = req.tenantId ?? req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new ForbiddenException('No tenant context');
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export class ModuleGrantsController {
|
||||
@Get('matrix')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async matrix(@Req() req: Request) {
|
||||
async matrix(@Req() req: AuthenticatedRequest) {
|
||||
return this.moduleGrantsService.getMatrix(this.getTenantId(req));
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ export class ModuleGrantsController {
|
||||
@Get('users/:userId')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async userAccess(@Param('userId') userId: string, @Req() req: Request) {
|
||||
async userAccess(@Param('userId') userId: string, @Req() req: AuthenticatedRequest) {
|
||||
return this.moduleGrantsService.getUserAccess(this.getTenantId(req), userId);
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export class ModuleGrantsController {
|
||||
@Post()
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async create(@Body() dto: CreateModuleGrantDto, @Req() req: Request) {
|
||||
async create(@Body() dto: CreateModuleGrantDto, @Req() req: AuthenticatedRequest) {
|
||||
return this.moduleGrantsService.grant(this.getTenantId(req), dto);
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ export class ModuleGrantsController {
|
||||
@Delete()
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async remove(@Body() dto: CreateModuleGrantDto, @Req() req: Request) {
|
||||
async remove(@Body() dto: CreateModuleGrantDto, @Req() req: AuthenticatedRequest) {
|
||||
await this.moduleGrantsService.revoke(this.getTenantId(req), dto);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { prismaErrorCode } from '../prisma/prisma-error';
|
||||
|
||||
/**
|
||||
* Schreibseite der Modul-Freigaben (PERM-03): Grants für Gruppen und für
|
||||
@@ -48,7 +49,7 @@ export class ModuleGrantsService {
|
||||
groupId?: string,
|
||||
userId?: string,
|
||||
): Promise<void> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
if (groupId) {
|
||||
const group = await tenantPrisma.group.findFirst({
|
||||
where: { id: groupId, tenantId },
|
||||
@@ -107,7 +108,7 @@ export class ModuleGrantsService {
|
||||
// Datenbank" entfallen.
|
||||
await this.assertTargetBelongsToTenant(tenantId, groupId, userId);
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const activation = await tenantPrisma.tenantModuleActivation.findUnique({
|
||||
where: { tenantId_moduleId: { tenantId, moduleId } },
|
||||
});
|
||||
@@ -132,8 +133,8 @@ export class ModuleGrantsService {
|
||||
`Grant erteilt: tenant=${tenantId} module=${moduleId} ${target}`,
|
||||
);
|
||||
return created;
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'P2002') {
|
||||
} catch (err: unknown) {
|
||||
if (prismaErrorCode(err) === 'P2002') {
|
||||
const existing = await tenantPrisma.moduleGrant.findFirst({
|
||||
where: {
|
||||
tenantId,
|
||||
@@ -166,7 +167,7 @@ export class ModuleGrantsService {
|
||||
const { moduleId, groupId, userId } = data;
|
||||
const target = groupId ? `group=${groupId}` : `user=${userId}`;
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
await tenantPrisma.moduleGrant.deleteMany({
|
||||
where: {
|
||||
tenantId,
|
||||
@@ -189,7 +190,7 @@ export class ModuleGrantsService {
|
||||
* hinweg stabil.
|
||||
*/
|
||||
async getMatrix(tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const [activations, groups, groupGrants] = await Promise.all([
|
||||
tenantPrisma.tenantModuleActivation.findMany({
|
||||
where: { tenantId, isActive: true },
|
||||
@@ -206,18 +207,17 @@ export class ModuleGrantsService {
|
||||
]);
|
||||
|
||||
const modules = activations
|
||||
.map((a: any) => a.module)
|
||||
.map((a) => a.module)
|
||||
.sort(
|
||||
(a: any, b: any) =>
|
||||
a.category.localeCompare(b.category) || a.name.localeCompare(b.name),
|
||||
(a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name),
|
||||
);
|
||||
|
||||
return {
|
||||
modules,
|
||||
groups,
|
||||
grants: groupGrants.map((g: any) => ({
|
||||
moduleId: g.moduleId as string,
|
||||
groupId: g.groupId as string,
|
||||
grants: groupGrants.map((g) => ({
|
||||
moduleId: g.moduleId,
|
||||
groupId: g.groupId,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -246,7 +246,7 @@ export class ModuleGrantsService {
|
||||
async getUserAccess(tenantId: string, userId: string) {
|
||||
await this.assertTargetBelongsToTenant(tenantId, undefined, userId);
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const [activations, groupGrants, directGrants, memberships] = await Promise.all([
|
||||
tenantPrisma.tenantModuleActivation.findMany({
|
||||
where: { tenantId, isActive: true },
|
||||
@@ -275,9 +275,9 @@ export class ModuleGrantsService {
|
||||
}),
|
||||
]);
|
||||
|
||||
const directModuleIds = new Set(directGrants.map((g: any) => g.moduleId as string));
|
||||
const directModuleIds = new Set(directGrants.map((g) => g.moduleId));
|
||||
const groupNamesByModule = new Map<string, string[]>();
|
||||
for (const g of groupGrants as any[]) {
|
||||
for (const g of groupGrants) {
|
||||
if (!g.group) continue;
|
||||
const names = groupNamesByModule.get(g.moduleId) ?? [];
|
||||
names.push(g.group.internalName ?? g.group.name);
|
||||
@@ -285,13 +285,12 @@ export class ModuleGrantsService {
|
||||
}
|
||||
|
||||
const modules = activations
|
||||
.map((a: any) => a.module)
|
||||
.map((a) => a.module)
|
||||
.sort(
|
||||
(a: any, b: any) =>
|
||||
a.category.localeCompare(b.category) || a.name.localeCompare(b.name),
|
||||
(a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name),
|
||||
);
|
||||
|
||||
const groups = (memberships as any[])
|
||||
const groups = memberships
|
||||
.filter((m) => m.group)
|
||||
.map((m) => ({
|
||||
id: m.group.id as string,
|
||||
@@ -302,7 +301,7 @@ export class ModuleGrantsService {
|
||||
|
||||
return {
|
||||
groups,
|
||||
modules: modules.map((module: any) => ({
|
||||
modules: modules.map((module) => ({
|
||||
module,
|
||||
viaGroups: groupNamesByModule.get(module.id) ?? [],
|
||||
direct: directModuleIds.has(module.id),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const httpntlm = require('httpntlm') as { post: (opts: any, cb: (err: Error | null, res: any) => void) => void };
|
||||
const httpntlm = require('httpntlm') as {
|
||||
post: (opts: NtlmOptions, cb: (err: Error | null, res: NtlmResponse) => void) => void;
|
||||
};
|
||||
import type { InboxAttachment, InboxConfig, InboxEmail, InboxMessage } from './inbox-provider.interface';
|
||||
import type { InboxProvider } from './inbox-provider.interface';
|
||||
|
||||
@@ -236,11 +238,34 @@ interface NtlmOptions {
|
||||
rejectUnauthorized?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Antwortform von httpntlm.post, beschrieben aus dem, was der Aufrufer
|
||||
* unten liest — mehr nicht.
|
||||
*
|
||||
* BEFUND (260921-m34, Aufgabe 3c, D-03): `body` ist bewusst
|
||||
* `Buffer | string`. httpntlm reicht an httpreq durch, und httpreq gibt den
|
||||
* Rumpf als ZEICHENKETTE zurueck, solange die Option `binary` nicht gesetzt
|
||||
* ist (gemessen in httpreq@1.1.1/lib/httpreq.js:391) — keiner der beiden
|
||||
* Aufrufer in diesem Baum setzt sie. Die Testdoppel reichen dagegen einen
|
||||
* Buffer herein. Der Bestand rief hier unbesehen `.toString('utf-8')` auf;
|
||||
* das funktioniert bei einer Zeichenkette nur, weil String.toString() sein
|
||||
* Argument ignoriert. Beide Formen kommen also wirklich vor, der Typ nennt
|
||||
* beide, und die Fallunterscheidung unten liefert fuer jede exakt dasselbe
|
||||
* Ergebnis wie zuvor. Verhalten unveraendert.
|
||||
*/
|
||||
interface NtlmResponse {
|
||||
statusCode: number;
|
||||
body?: Buffer | string;
|
||||
}
|
||||
|
||||
function ntlmPost(opts: NtlmOptions): Promise<{ statusCode: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
(httpntlm as any).post(opts, (err: Error | null, res: any) => {
|
||||
httpntlm.post(opts, (err, res) => {
|
||||
if (err) return reject(err);
|
||||
resolve({ statusCode: res.statusCode, body: res.body?.toString('utf-8') ?? '' });
|
||||
resolve({
|
||||
statusCode: res.statusCode,
|
||||
body: typeof res.body === 'string' ? res.body : (res.body?.toString('utf-8') ?? ''),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,6 +72,17 @@ function makeMockClient(overrides: Partial<Record<string, unknown>> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Haengt ein Testdoppel als ImapFlow-Klient ein.
|
||||
*
|
||||
* Der Modul-Mock oben ersetzt den Konstruktor durch `vi.fn()`; diese Funktion
|
||||
* ist die einzige Stelle im Test, die das ausnutzt. Vorher stand dieselbe
|
||||
* Umdeutung in jedem einzelnen Fall.
|
||||
*/
|
||||
function useMockClient(client: ReturnType<typeof makeMockClient>): void {
|
||||
(ImapFlow as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => client);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -79,7 +90,7 @@ beforeEach(() => {
|
||||
describe('ImapProvider.fetchMessages', () => {
|
||||
it('returns one InboxMessage with bodyHtml from the html part and bodyText from the plain part', async () => {
|
||||
const client = makeMockClient();
|
||||
(ImapFlow as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => client);
|
||||
useMockClient(client);
|
||||
const provider = new ImapProvider();
|
||||
|
||||
const messages = await provider.fetchMessages(BASE_CONFIG);
|
||||
@@ -97,7 +108,7 @@ describe('ImapProvider.fetchMessages', () => {
|
||||
|
||||
it('marks each processed message \\Seen (idempotency for re-polls)', async () => {
|
||||
const client = makeMockClient();
|
||||
(ImapFlow as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => client);
|
||||
useMockClient(client);
|
||||
const provider = new ImapProvider();
|
||||
|
||||
await provider.fetchMessages(BASE_CONFIG);
|
||||
@@ -107,7 +118,7 @@ describe('ImapProvider.fetchMessages', () => {
|
||||
|
||||
it('honors the same UNSEEN + optional senderFilter search as fetchPdfAttachments', async () => {
|
||||
const client = makeMockClient();
|
||||
(ImapFlow as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => client);
|
||||
useMockClient(client);
|
||||
const provider = new ImapProvider();
|
||||
|
||||
await provider.fetchMessages({ ...BASE_CONFIG, senderFilter: 'vergabeportal.de' });
|
||||
@@ -122,7 +133,7 @@ describe('ImapProvider.fetchMessages', () => {
|
||||
const client = makeMockClient({
|
||||
connect: vi.fn().mockRejectedValue(new Error('ECONNREFUSED')),
|
||||
});
|
||||
(ImapFlow as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => client);
|
||||
useMockClient(client);
|
||||
const provider = new ImapProvider();
|
||||
|
||||
await expect(provider.fetchMessages(BASE_CONFIG)).resolves.toEqual([]);
|
||||
@@ -132,7 +143,7 @@ describe('ImapProvider.fetchMessages', () => {
|
||||
const client = makeMockClient({
|
||||
search: vi.fn().mockRejectedValue(new Error('search boom')),
|
||||
});
|
||||
(ImapFlow as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => client);
|
||||
useMockClient(client);
|
||||
const provider = new ImapProvider();
|
||||
|
||||
await expect(provider.fetchMessages(BASE_CONFIG)).resolves.toEqual([]);
|
||||
@@ -141,7 +152,7 @@ describe('ImapProvider.fetchMessages', () => {
|
||||
|
||||
it('returns [] when there are no unread messages', async () => {
|
||||
const client = makeMockClient({ search: vi.fn().mockResolvedValue([]) });
|
||||
(ImapFlow as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => client);
|
||||
useMockClient(client);
|
||||
const provider = new ImapProvider();
|
||||
|
||||
const messages = await provider.fetchMessages(BASE_CONFIG);
|
||||
@@ -164,7 +175,7 @@ describe('ImapProvider.fetchMessages', () => {
|
||||
},
|
||||
]),
|
||||
});
|
||||
(ImapFlow as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => client);
|
||||
useMockClient(client);
|
||||
const provider = new ImapProvider();
|
||||
|
||||
const messages = await provider.fetchMessages(BASE_CONFIG);
|
||||
@@ -174,3 +185,132 @@ describe('ImapProvider.fetchMessages', () => {
|
||||
expect(messages[0]!.bodyText).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Befund B-06 (gemeldet in 260921-m34): buildClient() uebergab `requireTLS`,
|
||||
* eine Option, die imapflow 1.4.3 gar nicht kennt — weder in `ImapFlowOptions`
|
||||
* (lib/imap-flow.d.ts) noch im Laufzeitcode (lib/imap-flow.js). Sie wurde still
|
||||
* verworfen, ein STARTTLS-Zwang entstand durch sie nie. Die richtige Option
|
||||
* heisst `doSTARTTLS` (imap-flow.d.ts:81).
|
||||
*
|
||||
* Geprueft wird hier ausschliesslich, was an `new ImapFlow(...)` uebergeben
|
||||
* wird — keine echte Verbindung.
|
||||
*/
|
||||
describe('ImapProvider — Transportverschluesselung (B-06)', () => {
|
||||
/** Optionen des zuletzt erzeugten ImapFlow-Klienten. */
|
||||
function lastClientOptions() {
|
||||
const calls = vi.mocked(ImapFlow).mock.calls;
|
||||
return calls[calls.length - 1]?.[0];
|
||||
}
|
||||
|
||||
it('erzwingt STARTTLS, wenn die Verschluesselung auf starttls steht', async () => {
|
||||
const client = makeMockClient();
|
||||
useMockClient(client);
|
||||
const provider = new ImapProvider();
|
||||
|
||||
await provider.testConnection({ ...BASE_CONFIG, port: 143, encryption: 'starttls' });
|
||||
|
||||
const options = lastClientOptions();
|
||||
expect(options?.secure).toBe(false);
|
||||
expect(options?.doSTARTTLS).toBe(true);
|
||||
expect(options).not.toHaveProperty('requireTLS');
|
||||
});
|
||||
|
||||
it('setzt doSTARTTLS nicht auf true, wenn die Verschluesselung auf ssl-tls steht', async () => {
|
||||
const client = makeMockClient();
|
||||
useMockClient(client);
|
||||
const provider = new ImapProvider();
|
||||
|
||||
await provider.testConnection({ ...BASE_CONFIG, encryption: 'ssl-tls' });
|
||||
|
||||
const options = lastClientOptions();
|
||||
// imapflow wirft bei secure=true zusammen mit doSTARTTLS=true
|
||||
// ("Misconfiguration", imap-flow.js:1201) — diese Kombination darf nie entstehen.
|
||||
expect(options?.secure).toBe(true);
|
||||
expect(options?.doSTARTTLS).not.toBe(true);
|
||||
expect(options).not.toHaveProperty('requireTLS');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Befund B-05 (gemeldet in 260921-m34): der Dateiname aus Content-Disposition
|
||||
* wurde als `disposition.parameters.filename` gelesen. imapflow deklariert
|
||||
* `disposition` aber als Zeichenkette (imap-flow.d.ts:448) und legt die
|
||||
* Parameter in ein eigenes Feld `dispositionParameters` (:450, gefuellt in
|
||||
* tools.js:887 mit kleingeschriebenen Schluesseln). Der alte Ausdruck war zur
|
||||
* Laufzeit immer undefined — Anhaenge, die als application/octet-stream
|
||||
* ankommen (typisch fuer Outlook), wurden darueber nie erkannt.
|
||||
*/
|
||||
describe('ImapProvider.fetchPdfAttachments — Dateiname aus Content-Disposition (B-05)', () => {
|
||||
function makeAttachmentClient(bodyStructure: unknown) {
|
||||
return makeMockClient({
|
||||
search: vi.fn().mockResolvedValue([7]),
|
||||
fetchAll: vi.fn().mockResolvedValue([
|
||||
{
|
||||
uid: 7,
|
||||
envelope: {
|
||||
messageId: '<msg-7@example.com>',
|
||||
subject: 'Rechnung',
|
||||
from: [{ address: 'rechnung@dkv.de' }],
|
||||
date: new Date('2026-07-20T08:00:00Z'),
|
||||
},
|
||||
bodyStructure,
|
||||
},
|
||||
]),
|
||||
download: vi.fn(async () => ({ content: makeReadable('%PDF-1.4 inhalt') })),
|
||||
});
|
||||
}
|
||||
|
||||
it('erkennt einen application/octet-stream-Anhang am Dateinamen aus dispositionParameters', async () => {
|
||||
const client = makeAttachmentClient({
|
||||
type: 'multipart/mixed',
|
||||
childNodes: [
|
||||
{ type: 'text/plain', part: '1' },
|
||||
{
|
||||
type: 'application/octet-stream',
|
||||
part: '2',
|
||||
disposition: 'attachment',
|
||||
dispositionParameters: { filename: 'Rechnung-4711.PDF' },
|
||||
},
|
||||
],
|
||||
});
|
||||
useMockClient(client);
|
||||
const provider = new ImapProvider();
|
||||
|
||||
const emails = await provider.fetchPdfAttachments(BASE_CONFIG);
|
||||
|
||||
expect(emails).toHaveLength(1);
|
||||
expect(emails[0]?.attachments ?? []).toHaveLength(1);
|
||||
expect(emails[0]?.attachments?.[0]?.contentType).toBe('application/pdf');
|
||||
});
|
||||
|
||||
it('erkennt einen application/octet-stream-Anhang weiterhin am Namen aus Content-Type', async () => {
|
||||
const client = makeAttachmentClient({
|
||||
type: 'application/octet-stream',
|
||||
part: '1',
|
||||
parameters: { name: 'Rechnung-4711.pdf' },
|
||||
});
|
||||
useMockClient(client);
|
||||
const provider = new ImapProvider();
|
||||
|
||||
const emails = await provider.fetchPdfAttachments(BASE_CONFIG);
|
||||
|
||||
expect(emails).toHaveLength(1);
|
||||
expect(emails[0]?.attachments ?? []).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('sammelt einen application/octet-stream-Anhang ohne .pdf-Dateinamen nicht ein', async () => {
|
||||
const client = makeAttachmentClient({
|
||||
type: 'application/octet-stream',
|
||||
part: '1',
|
||||
disposition: 'attachment',
|
||||
dispositionParameters: { filename: 'Lieferschein.docx' },
|
||||
});
|
||||
useMockClient(client);
|
||||
const provider = new ImapProvider();
|
||||
|
||||
const emails = await provider.fetchPdfAttachments(BASE_CONFIG);
|
||||
|
||||
expect(emails).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import type { Readable } from 'node:stream';
|
||||
import { ImapFlow, MessageStructureObject } from 'imapflow';
|
||||
import type { InboxAttachment, InboxConfig, InboxEmail, InboxMessage } from './inbox-provider.interface';
|
||||
import type { InboxProvider } from './inbox-provider.interface';
|
||||
@@ -14,9 +15,7 @@ const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024; // 25 MB
|
||||
* Converts a Node.js Readable stream into a Buffer.
|
||||
* Accumulates chunks up to MAX_ATTACHMENT_BYTES; throws if limit exceeded.
|
||||
*/
|
||||
async function streamToBuffer(
|
||||
stream: NodeJS.ReadableStream,
|
||||
): Promise<Buffer> {
|
||||
async function streamToBuffer(stream: Readable): Promise<Buffer> {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
@@ -24,8 +23,12 @@ async function streamToBuffer(
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
total += chunk.length;
|
||||
if (total > MAX_ATTACHMENT_BYTES) {
|
||||
// Destroy the stream to prevent further data emission
|
||||
(stream as any).destroy?.();
|
||||
// Destroy the stream to prevent further data emission.
|
||||
// Readable statt NodeJS.ReadableStream: alle drei Aufrufer reichen
|
||||
// client.download().content herein, und imapflow deklariert das als
|
||||
// Readable (imap-flow.d.ts:521). Readable traegt destroy(), also
|
||||
// braucht der Aufruf keine Zusicherung mehr.
|
||||
stream.destroy?.();
|
||||
reject(
|
||||
new Error(
|
||||
`Attachment exceeds maximum allowed size of ${MAX_ATTACHMENT_BYTES} bytes (T-07-05)`,
|
||||
@@ -59,10 +62,17 @@ function collectPdfParts(
|
||||
const type = node.type?.toLowerCase() ?? '';
|
||||
// Some mail clients (e.g. Outlook) send PDFs as application/octet-stream.
|
||||
// Fall back to checking the filename from Content-Disposition or Content-Type parameters.
|
||||
const dispositionFilename =
|
||||
((node as any).disposition?.parameters?.filename as string | undefined)?.toLowerCase() ?? '';
|
||||
const typeFilename =
|
||||
((node as any).parameters?.name as string | undefined)?.toLowerCase() ?? '';
|
||||
// Repariert in 260921-oxm (Befund B-05 aus 260921-m34): hier stand zuvor
|
||||
// `disposition?.parameters?.filename`, auf einem zu any umgedeuteten Knoten.
|
||||
// imapflow deklariert `disposition` aber als ZEICHENKETTE (imap-flow.d.ts:448, also
|
||||
// "attachment"/"inline") und legt die zugehoerigen Parameter in ein eigenes
|
||||
// Feld `dispositionParameters` (:450, gefuellt in tools.js:887 mit
|
||||
// kleingeschriebenen Schluesseln). Der alte Ausdruck las `.parameters` von
|
||||
// einer Zeichenkette und war zur Laufzeit IMMER undefined.
|
||||
const dispositionFilename = node.dispositionParameters?.filename?.toLowerCase() ?? '';
|
||||
// Hier dagegen war die Zusicherung schlicht ueberfluessig: imapflow
|
||||
// deklariert `parameters?: { [key: string]: string }` (imap-flow.d.ts:438).
|
||||
const typeFilename = node.parameters?.name?.toLowerCase() ?? '';
|
||||
const looksLikePdf =
|
||||
type === 'application/pdf' ||
|
||||
(type === 'application/octet-stream' &&
|
||||
@@ -367,13 +377,21 @@ export class ImapProvider implements InboxProvider {
|
||||
port: config.port,
|
||||
// ssl-tls = implicit TLS (port 993); starttls = STARTTLS upgrade (port 143)
|
||||
secure: config.encryption === 'ssl-tls',
|
||||
requireTLS: config.encryption === 'starttls',
|
||||
// Repariert in 260921-oxm (Befund B-06 aus 260921-m34): hier stand zuvor
|
||||
// `requireTLS`, eine Option, die imapflow 1.4.3 nirgends kennt und still
|
||||
// verwirft. Die Bibliothek heisst sie `doSTARTTLS` (imap-flow.d.ts:81).
|
||||
// true -> vor der Anmeldung auf TLS hochstufen; scheitert, wenn der
|
||||
// Server kein STARTTLS anbietet (imap-flow.js:1183)
|
||||
// false -> STARTTLS ausdruecklich aus (imap-flow.js:1210); bei ssl-tls
|
||||
// ist das noetig, weil secure=true zusammen mit
|
||||
// doSTARTTLS=true ungueltig waere (imap-flow.js:1201)
|
||||
doSTARTTLS: config.encryption === 'starttls',
|
||||
auth:
|
||||
config.username
|
||||
? { user: config.username, pass: config.password ?? '' }
|
||||
: undefined,
|
||||
// T-07-03: suppress imapflow verbose logs — they include auth credentials
|
||||
logger: false,
|
||||
} as any);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export class LdapConfigService implements OnApplicationBootstrap {
|
||||
*/
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
try {
|
||||
const systemPrisma = forSystem(this.prisma) as any;
|
||||
const systemPrisma = forSystem(this.prisma);
|
||||
const configs: { id: string; tenantId: string; encryptedBindPassword: string | null }[] =
|
||||
await systemPrisma.ldapConfig.findMany({
|
||||
select: { id: true, tenantId: true, encryptedBindPassword: true },
|
||||
@@ -84,7 +84,7 @@ export class LdapConfigService implements OnApplicationBootstrap {
|
||||
for (const config of legacy) {
|
||||
// Schreiben je Altzeile GEBUNDEN an den Mandanten der Zeile — unter
|
||||
// Systemkontext wuerde die Datenbank das Update abweisen (P2025).
|
||||
const tenantPrisma = forTenant(this.prisma, config.tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, config.tenantId);
|
||||
await tenantPrisma.ldapConfig.update({
|
||||
where: { id: config.id },
|
||||
data: {
|
||||
@@ -146,7 +146,7 @@ export class LdapConfigService implements OnApplicationBootstrap {
|
||||
* bewusst ueber alle Mandanten liest.
|
||||
*/
|
||||
async getConfig(tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const config = await tenantPrisma.ldapConfig.findUnique({
|
||||
where: { tenantId },
|
||||
include: { fieldMappings: true },
|
||||
@@ -166,7 +166,7 @@ export class LdapConfigService implements OnApplicationBootstrap {
|
||||
* gemessen.
|
||||
*/
|
||||
async createConfig(tenantId: string, dto: CreateLdapConfigDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const created = await tenantPrisma.ldapConfig.create({
|
||||
data: {
|
||||
tenantId,
|
||||
@@ -209,7 +209,7 @@ export class LdapConfigService implements OnApplicationBootstrap {
|
||||
* Mandantengebunden (WINDOWS #20 Etappe 2, 260909-ipc).
|
||||
*/
|
||||
async updateConfig(tenantId: string, dto: UpdateLdapConfigDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const updated = await tenantPrisma.ldapConfig.update({
|
||||
where: { tenantId },
|
||||
data: {
|
||||
@@ -257,7 +257,7 @@ export class LdapConfigService implements OnApplicationBootstrap {
|
||||
configId: string,
|
||||
dto: CreateFieldMappingDto,
|
||||
) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
return tenantPrisma.ldapFieldMapping.create({
|
||||
data: {
|
||||
ldapConfigId: configId,
|
||||
@@ -283,7 +283,7 @@ export class LdapConfigService implements OnApplicationBootstrap {
|
||||
* Loeschung.
|
||||
*/
|
||||
async removeFieldMapping(tenantId: string, mappingId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const mapping = await tenantPrisma.ldapFieldMapping.findUnique({
|
||||
where: { id: mappingId },
|
||||
});
|
||||
@@ -319,11 +319,11 @@ export class LdapConfigService implements OnApplicationBootstrap {
|
||||
* den es dann nicht gibt.
|
||||
*/
|
||||
async getAllActiveConfigs() {
|
||||
const systemPrisma = forSystem(this.prisma) as any;
|
||||
const systemPrisma = forSystem(this.prisma);
|
||||
const configs = await systemPrisma.ldapConfig.findMany({
|
||||
where: { isActive: true },
|
||||
include: { tenant: true, fieldMappings: true },
|
||||
});
|
||||
return configs.map((config: any) => this.withDecryptedPassword(config));
|
||||
return configs.map((config) => this.withDecryptedPassword(config));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import {
|
||||
CreateFieldMappingDto,
|
||||
CreateLdapConfigDto,
|
||||
@@ -41,7 +42,7 @@ export class LdapController {
|
||||
*/
|
||||
@Get('config')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async getConfig(@Req() req: any) {
|
||||
async getConfig(@Req() req: AuthenticatedRequest) {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new BadRequestException('No tenant context');
|
||||
@@ -65,7 +66,7 @@ export class LdapController {
|
||||
*/
|
||||
@Post('config')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async createConfig(@Req() req: any, @Body() dto: CreateLdapConfigDto) {
|
||||
async createConfig(@Req() req: AuthenticatedRequest, @Body() dto: CreateLdapConfigDto) {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new BadRequestException('No tenant context');
|
||||
@@ -92,7 +93,7 @@ export class LdapController {
|
||||
*/
|
||||
@Patch('config')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async updateConfig(@Req() req: any, @Body() dto: UpdateLdapConfigDto) {
|
||||
async updateConfig(@Req() req: AuthenticatedRequest, @Body() dto: UpdateLdapConfigDto) {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new BadRequestException('No tenant context');
|
||||
@@ -123,7 +124,7 @@ export class LdapController {
|
||||
*/
|
||||
@Post('test-connection')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async testConnection(@Req() req: any, @Body() dto: TestConnectionDto) {
|
||||
async testConnection(@Req() req: AuthenticatedRequest, @Body() dto: TestConnectionDto) {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new BadRequestException('No tenant context');
|
||||
@@ -158,7 +159,7 @@ export class LdapController {
|
||||
*/
|
||||
@Get('groups')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async listGroups(@Req() req: any) {
|
||||
async listGroups(@Req() req: AuthenticatedRequest) {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new BadRequestException('No tenant context');
|
||||
@@ -189,7 +190,7 @@ export class LdapController {
|
||||
*/
|
||||
@Post('groups/import')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async importGroups(@Req() req: any, @Body() dto: ImportGroupsDto) {
|
||||
async importGroups(@Req() req: AuthenticatedRequest, @Body() dto: ImportGroupsDto) {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new BadRequestException('No tenant context');
|
||||
@@ -225,7 +226,7 @@ export class LdapController {
|
||||
*/
|
||||
@Get('users/search')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async searchUsers(@Req() req: any, @Query('q') q: string) {
|
||||
async searchUsers(@Req() req: AuthenticatedRequest, @Query('q') q: string) {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new BadRequestException('No tenant context');
|
||||
@@ -256,7 +257,7 @@ export class LdapController {
|
||||
*/
|
||||
@Post('users/import')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async importUsers(@Req() req: any, @Body() dto: ImportUsersDto) {
|
||||
async importUsers(@Req() req: AuthenticatedRequest, @Body() dto: ImportUsersDto) {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new BadRequestException('No tenant context');
|
||||
@@ -292,7 +293,7 @@ export class LdapController {
|
||||
*/
|
||||
@Post('sync')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async triggerSync(@Req() req: any) {
|
||||
async triggerSync(@Req() req: AuthenticatedRequest) {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new BadRequestException('No tenant context');
|
||||
@@ -326,7 +327,7 @@ export class LdapController {
|
||||
*/
|
||||
@Post('config/mappings')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async addFieldMapping(@Req() req: any, @Body() dto: CreateFieldMappingDto) {
|
||||
async addFieldMapping(@Req() req: AuthenticatedRequest, @Body() dto: CreateFieldMappingDto) {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new BadRequestException('No tenant context');
|
||||
@@ -351,7 +352,7 @@ export class LdapController {
|
||||
*/
|
||||
@Delete('config/mappings/:id')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async removeFieldMapping(@Req() req: any, @Param('id') id: string) {
|
||||
async removeFieldMapping(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new BadRequestException('No tenant context');
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Client, EqualityFilter, Entry } from 'ldapts';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { prismaErrorCode, prismaErrorTarget } from '../prisma/prisma-error';
|
||||
import { GroupsService } from '../groups/groups.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
|
||||
@@ -298,7 +299,7 @@ export class LdapService {
|
||||
// Mandantengescopter Lesepfad (WINDOWS #20 Etappe 2, 260909-ipc): die
|
||||
// "bereits importiert"-Markierung darf nur die Gruppen DIESES Mandanten
|
||||
// sehen.
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
|
||||
try {
|
||||
await this.bind(client, config.bindDn, config.bindPassword);
|
||||
@@ -471,7 +472,7 @@ export class LdapService {
|
||||
// Mandantengescopter Identitaets-/Schreibpfad (WINDOWS #20 Etappe 2,
|
||||
// 260909-ipc). Nicht zu verwechseln mit resolveEmailForWrite() oben, die
|
||||
// bewusst ungebunden bleibt.
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const existingByDn = await tenantPrisma.user.findFirst({
|
||||
where: { ldapDn: dn, tenantId },
|
||||
});
|
||||
@@ -566,7 +567,7 @@ export class LdapService {
|
||||
// Mandantengescopter Lesepfad (WINDOWS #20 Etappe 2, 260909-ipc): die
|
||||
// "bereits importiert"-Markierung darf nur die Konten DIESES Mandanten
|
||||
// sehen.
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const first = (v: unknown): string =>
|
||||
Array.isArray(v) ? String(v[0] ?? '') : v != null ? String(v) : '';
|
||||
|
||||
@@ -663,7 +664,7 @@ export class LdapService {
|
||||
);
|
||||
// Mandantengescopter Dedup-/Schreibpfad (WINDOWS #20 Etappe 2,
|
||||
// 260909-ipc).
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
|
||||
try {
|
||||
await this.bind(client, config.bindDn, config.bindPassword);
|
||||
@@ -791,7 +792,7 @@ export class LdapService {
|
||||
);
|
||||
// Mandantengescopter Schreibpfad (T-16-02): app.current_tenant wird vor
|
||||
// jedem group.create() gesetzt, RLS ist das zweite Netz.
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
|
||||
try {
|
||||
await this.bind(client, config.bindDn, config.bindPassword);
|
||||
@@ -842,9 +843,9 @@ export class LdapService {
|
||||
data: { tenantId, name, ldapDn: entry.dn, ldapObjectGuid },
|
||||
});
|
||||
result.imported++;
|
||||
} catch (createError: any) {
|
||||
if (createError?.code === 'P2002') {
|
||||
const target = createError?.meta?.target;
|
||||
} catch (createError: unknown) {
|
||||
if (prismaErrorCode(createError) === 'P2002') {
|
||||
const target = prismaErrorTarget(createError);
|
||||
const targetsGuid = Array.isArray(target)
|
||||
? target.includes('ldapObjectGuid')
|
||||
: String(target ?? '').includes('ldapObjectGuid');
|
||||
@@ -934,7 +935,7 @@ export class LdapService {
|
||||
);
|
||||
|
||||
// Create tenant-scoped Prisma client per Pitfall 2
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
|
||||
try {
|
||||
// 1. Bind with service account (anonymous when not configured)
|
||||
@@ -1208,7 +1209,7 @@ export class LdapService {
|
||||
tenantId: string,
|
||||
result: LdapSyncResult,
|
||||
): Promise<void> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
|
||||
const boundGroups: { id: string; name: string; ldapDn: string | null }[] =
|
||||
await tenantPrisma.group.findMany({
|
||||
@@ -1371,7 +1372,7 @@ export class LdapService {
|
||||
tenantId: string,
|
||||
result: LdapSyncResult,
|
||||
): Promise<void> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
|
||||
const candidates: {
|
||||
id: string;
|
||||
@@ -1526,8 +1527,8 @@ export class LdapService {
|
||||
data: { name, ldapDn: dn },
|
||||
});
|
||||
result.groupsRenamed++;
|
||||
} catch (updateError: any) {
|
||||
if (updateError?.code === 'P2002') {
|
||||
} catch (updateError: unknown) {
|
||||
if (prismaErrorCode(updateError) === 'P2002') {
|
||||
// WR-02 (16-REVIEW.md): this update() writes BOTH name and
|
||||
// ldapDn in one call — @@unique([tenantId, name]) AND
|
||||
// @@unique([tenantId, ldapDn]) are both potential triggers
|
||||
@@ -1535,7 +1536,7 @@ export class LdapService {
|
||||
// collision when updateError.meta.target says so. Mirrors
|
||||
// the discrimination importGroupsByDn() already does above
|
||||
// for its own create() call.
|
||||
const target = updateError?.meta?.target;
|
||||
const target = prismaErrorTarget(updateError);
|
||||
const targetsName = Array.isArray(target)
|
||||
? target.includes('name')
|
||||
: String(target ?? '').includes('name');
|
||||
@@ -1591,8 +1592,8 @@ export class LdapService {
|
||||
await tenantPrisma.group.delete({ where: { id: group.id } });
|
||||
result.groupsDeleted++;
|
||||
anyDeleted = true;
|
||||
} catch (deleteError: any) {
|
||||
if (deleteError?.code !== 'P2025') {
|
||||
} catch (deleteError: unknown) {
|
||||
if (prismaErrorCode(deleteError) !== 'P2025') {
|
||||
throw deleteError;
|
||||
}
|
||||
// Already gone (e.g. a concurrent manual delete) — not
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
|
||||
/**
|
||||
@@ -69,7 +70,14 @@ export type BugReportMail = Pick<OutgoingMail, 'subject' | 'text' | 'attachments
|
||||
|
||||
interface ResolvedTransport {
|
||||
source: 'tenant' | 'env';
|
||||
options: nodemailer.TransportOptions & Record<string, unknown>;
|
||||
/**
|
||||
* SMTPTransport.Options statt TransportOptions & Record<string, unknown>:
|
||||
* beide Zweige von resolveTransport() bauen reine SMTP-Optionen (host,
|
||||
* port, secure, requireTLS, auth) — und genau das nimmt createTransport()
|
||||
* ohne Zusicherung entgegen. Die bisherige Kombination war zu weit und
|
||||
* brauchte deshalb ein `as any` an der Uebergabe.
|
||||
*/
|
||||
options: SMTPTransport.Options;
|
||||
from: string;
|
||||
}
|
||||
|
||||
@@ -162,7 +170,7 @@ export class MailService {
|
||||
let transport: nodemailer.Transporter | null = null;
|
||||
try {
|
||||
const resolved = await this.resolveTransport(tenantId);
|
||||
transport = nodemailer.createTransport(resolved.options as any);
|
||||
transport = nodemailer.createTransport(resolved.options);
|
||||
await transport.sendMail({
|
||||
from: resolved.from,
|
||||
to: mail.to,
|
||||
|
||||
@@ -54,7 +54,7 @@ export class ModuleAccessService {
|
||||
// bestehen: der Schalter ist weiterhin aus (#18), die Datenbankregel
|
||||
// wirkt heute nicht, und die Anwendungspruefung ist bis zum
|
||||
// Scharfschalten der einzige tatsaechliche Schutz.
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
|
||||
if (role === 'ADMIN' || role === 'SUPER_ADMIN') {
|
||||
const activations = await tenantPrisma.tenantModuleActivation.findMany({
|
||||
@@ -142,7 +142,7 @@ export class ModuleAccessService {
|
||||
// erzeugt ihren EIGENEN Klienten (dieselbe Konvention wie
|
||||
// `module-grants.service.ts`: gebundene Klienten werden nicht zwischen
|
||||
// Methoden weitergereicht). Beide laufen wie bisher nebenlaeufig.
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const [activations, accessibleIds] = await Promise.all([
|
||||
tenantPrisma.tenantModuleActivation.findMany({
|
||||
where: { tenantId, isActive: true },
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { Request } from 'express';
|
||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { ModuleAccessService } from './module-access.service';
|
||||
@@ -53,10 +53,10 @@ export class ModuleRegistryController {
|
||||
* catalog (mandantenweite Sicht, kein Benutzerfilter).
|
||||
*/
|
||||
@Get('active')
|
||||
async findActive(@Req() req: Request) {
|
||||
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
|
||||
const userId = (req as any).user?.id;
|
||||
const role = (req as any).user?.role;
|
||||
async findActive(@Req() req: AuthenticatedRequest) {
|
||||
const tenantId = req.tenantId ?? req.user?.tenantId;
|
||||
const userId = req.user?.id;
|
||||
const role = req.user?.role;
|
||||
if (!tenantId || !userId || !role) {
|
||||
throw new ForbiddenException('No user context');
|
||||
}
|
||||
@@ -74,10 +74,10 @@ export class ModuleRegistryController {
|
||||
* Erreichbar für jeden authentifizierten Benutzer, wie GET /modules.
|
||||
*/
|
||||
@Get('catalog')
|
||||
async findCatalog(@Req() req: Request) {
|
||||
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
|
||||
const userId = (req as any).user?.id;
|
||||
const role = (req as any).user?.role;
|
||||
async findCatalog(@Req() req: AuthenticatedRequest) {
|
||||
const tenantId = req.tenantId ?? req.user?.tenantId;
|
||||
const userId = req.user?.id;
|
||||
const role = req.user?.role;
|
||||
if (!tenantId || !userId || !role) {
|
||||
throw new ForbiddenException('No user context');
|
||||
}
|
||||
@@ -105,9 +105,9 @@ export class ModuleRegistryController {
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async activate(
|
||||
@Param('moduleId') moduleId: string,
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
|
||||
const tenantId = req.tenantId ?? req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new ForbiddenException('No tenant context');
|
||||
}
|
||||
@@ -124,9 +124,9 @@ export class ModuleRegistryController {
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async deactivate(
|
||||
@Param('moduleId') moduleId: string,
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
|
||||
const tenantId = req.tenantId ?? req.user?.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new ForbiddenException('No tenant context');
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export class ModuleRegistryService {
|
||||
* Returns all active modules for a given tenant.
|
||||
*/
|
||||
async findActiveForTenant(tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const activations = await tenantPrisma.tenantModuleActivation.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
@@ -77,7 +77,7 @@ export class ModuleRegistryService {
|
||||
throw new NotFoundException(`Module with id '${moduleId}' not found`);
|
||||
}
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
return tenantPrisma.tenantModuleActivation.upsert({
|
||||
where: {
|
||||
tenantId_moduleId: {
|
||||
@@ -117,7 +117,7 @@ export class ModuleRegistryService {
|
||||
// EIN gebundener Klient fuer beide Aktivierungszugriffe dieser Methode
|
||||
// (Lesen, Schreiben) — nicht ein Klient je Zugriff (260910-exd,
|
||||
// Aufgabe 3, dieselbe Konvention wie `module-access.service.ts`).
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
|
||||
// Check if activation record exists
|
||||
const activation = await tenantPrisma.tenantModuleActivation.findUnique({
|
||||
@@ -172,7 +172,7 @@ export class ModuleRegistryService {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const activation = await tenantPrisma.tenantModuleActivation.findUnique({
|
||||
where: {
|
||||
tenantId_moduleId: {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Eingrenzung gefangener Fehlerwerte auf die Form, die der Bestandscode an
|
||||
* Prisma-Fehlern tatsaechlich liest — `code` und `meta.target`, sonst nichts.
|
||||
*
|
||||
* Warum kein `instanceof Prisma.PrismaClientKnownRequestError`:
|
||||
* Der Bestand prueft ueberall `err?.code === 'P2002'` und trifft damit JEDES
|
||||
* Objekt, das ein `code`-Feld traegt. Genau darauf bauen die Testdoppel in
|
||||
* `apps/api` auf — sie werfen `const err = new Error(...); err.code = 'P2002'`
|
||||
* (groups, user, ldap, tenders, module-grants, admin-seed) und an einer Stelle
|
||||
* ein reines `{ code: 'P2002' }` (dashboard.service.spec.ts:451). Ein
|
||||
* `instanceof`-Test wuerde alle diese Werte in den jeweils ANDEREN Zweig
|
||||
* schicken. Das waere eine Verhaltensaenderung und ist deshalb verboten
|
||||
* (Plan 260921-m34, D-03 und T-M34-06).
|
||||
*
|
||||
* Diese Funktionen sind daher bewusst Form-Pruefungen ("duck typing") und
|
||||
* keine Klassenpruefungen: sie bilden `err?.code` bzw. `err?.meta?.target`
|
||||
* eins zu eins ab, nur eben ohne `any`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Liefert den Prisma-Fehlercode (z.B. `P2002`, `P2025`), wenn der gefangene
|
||||
* Wert ein Objekt mit einem Zeichenketten-Feld `code` ist — sonst `undefined`.
|
||||
*
|
||||
* Ein Feld `code`, das keine Zeichenkette ist, liefert `undefined`. Auch das
|
||||
* ist verhaltensgleich: ein Vergleich `err.code === 'P2002'` waere dort
|
||||
* ohnehin nie wahr geworden.
|
||||
*/
|
||||
export function prismaErrorCode(err: unknown): string | undefined {
|
||||
if (typeof err !== 'object' || err === null) return undefined;
|
||||
const code = (err as { code?: unknown }).code;
|
||||
return typeof code === 'string' ? code : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert `meta.target` eines Prisma-Fehlers als `unknown`.
|
||||
*
|
||||
* Bewusst `unknown` und nicht `string[]`: Prisma liefert hier je nach
|
||||
* Fehlerart ein Feld-Array ODER den Indexnamen als Zeichenkette, und der
|
||||
* Bestand in `ldap.service.ts` behandelt genau diese beiden Faelle bereits
|
||||
* getrennt (`Array.isArray(target) ? ... : String(target ?? '')`). Ein
|
||||
* engerer Rueckgabetyp waere hier eine Behauptung statt einer Messung.
|
||||
*/
|
||||
export function prismaErrorTarget(err: unknown): unknown {
|
||||
if (typeof err !== 'object' || err === null) return undefined;
|
||||
const meta = (err as { meta?: unknown }).meta;
|
||||
if (typeof meta !== 'object' || meta === null) return undefined;
|
||||
return (meta as { target?: unknown }).target;
|
||||
}
|
||||
@@ -197,13 +197,11 @@ import { PrismaClient } from '@prisma/client';
|
||||
export function forTenant(prisma: PrismaClient, tenantId: string, userId?: string) {
|
||||
return prisma.$extends({
|
||||
query: {
|
||||
$allOperations({ args, query }: { args: any; query: (args: any) => any }) {
|
||||
const setContext = (prisma as any)
|
||||
.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true), set_config('app.system_context', '', true)`;
|
||||
$allOperations({ args, query }) {
|
||||
const setContext = prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.current_user', ${userId ?? ''}, true), set_config('app.system_context', '', true)`;
|
||||
|
||||
return (prisma as any)
|
||||
.$transaction([setContext, query(args)])
|
||||
.then((results: any[]) => results[1]);
|
||||
return prisma.$transaction([setContext, query(args)])
|
||||
.then((results: unknown[]) => results[1]);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -226,13 +224,11 @@ export function forTenant(prisma: PrismaClient, tenantId: string, userId?: strin
|
||||
export function forSystem(prisma: PrismaClient) {
|
||||
return prisma.$extends({
|
||||
query: {
|
||||
$allOperations({ args, query }: { args: any; query: (args: any) => any }) {
|
||||
const setContext = (prisma as any)
|
||||
.$executeRaw`SELECT set_config('app.system_context', 'true', true), set_config('app.current_tenant', '', true), set_config('app.current_user', '', true)`;
|
||||
$allOperations({ args, query }) {
|
||||
const setContext = prisma.$executeRaw`SELECT set_config('app.system_context', 'true', true), set_config('app.current_tenant', '', true), set_config('app.current_user', '', true)`;
|
||||
|
||||
return (prisma as any)
|
||||
.$transaction([setContext, query(args)])
|
||||
.then((results: any[]) => results[1]);
|
||||
return prisma.$transaction([setContext, query(args)])
|
||||
.then((results: unknown[]) => results[1]);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -259,9 +255,15 @@ export function forSystem(prisma: PrismaClient) {
|
||||
export function withTenantTransaction<T>(
|
||||
prisma: PrismaClient,
|
||||
tenantId: string,
|
||||
// tx bleibt `any` (gemessen, Aufgabe 1 260921-m34): `Prisma.TransactionClient`
|
||||
// erzwingt an den vier Aufrufstellen (groups.service.ts, favorites.service.ts)
|
||||
// vollstaendige Prisma-Erzeugungstypen und bricht deren Testdoppel in
|
||||
// prisma-tenant.extension.spec.ts (TS2322 auf einem absichtlich unvollstaendigen
|
||||
// Fake-Objekt). Das waere eine Verhaltensaenderung an einer Teststruktur,
|
||||
// nicht ehrliches Typisieren (D-02/D-03) - bleibt.
|
||||
fn: (tx: any) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return (prisma as any).$transaction(async (tx: any) => {
|
||||
return prisma.$transaction(async (tx: any) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, true), set_config('app.system_context', '', true)`;
|
||||
return fn(tx);
|
||||
});
|
||||
|
||||
@@ -150,8 +150,24 @@ const RELATION_SPEC_EXCEPTIONS = new Set<string>(['apps/api/src/tenders/backfill
|
||||
* der Schleife ist `tenant.findMany` auf `Tenant`, das in keiner Migration
|
||||
* eine Regel traegt — kein Systemkontext noetig, Datei unveraendert.
|
||||
* Summe: 4 Dateien, 5 Aufrufe.
|
||||
*
|
||||
* SIEBTER FALL (quick-260922-hk4): `dashboard-images.service.ts`, EIN
|
||||
* Aufruf, ausschliesslich in `onApplicationBootstrap()` — der einmalige
|
||||
* Umzug der Bilderrahmen-Bilder aus der Spalte `data` in den Dateibereich.
|
||||
* Ein Startpfad hat keinen Mandanten im Ruecken und muss die noch nicht
|
||||
* umgezogenen Zeilen ALLER Mandanten sehen; die passende Regel
|
||||
* `system_read_policy ... FOR SELECT` auf "DashboardImage" legt die
|
||||
* Migration 20260922120000 an. Dieselbe Datei bedient daneben Anfragewege
|
||||
* (`list`/`upload`/`getBytes`/`remove`) — die bleiben ausnahmslos
|
||||
* mandantengebunden, und auch der Umzug SCHREIBT je Zeile ueber
|
||||
* `forTenant(prisma, row.tenantId, row.userId)`, nie ueber den
|
||||
* Systemklienten. Praezedenz fuer "ein Dienst mit Anfrageweg UND
|
||||
* systemgebundenem Startpfad": `ldap-config.service.ts`, dessen
|
||||
* Nachverschluesselung in `onApplicationBootstrap()` genauso gebaut ist.
|
||||
* Summe neu: 5 Dateien, 6 Aufrufe.
|
||||
*/
|
||||
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
|
||||
['apps/api/src/dashboard/dashboard-images.service.ts', 1],
|
||||
['apps/api/src/dkv/dkv.service.ts', 1],
|
||||
['apps/api/src/ldap/ldap-config.service.ts', 2],
|
||||
['apps/api/src/tenders/tender-digest.scheduler.ts', 1],
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import { SmtpConfigDto } from './dto/smtp-config.dto';
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
@@ -29,8 +30,8 @@ export class SettingsController {
|
||||
*/
|
||||
@Get('smtp')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async getSmtpConfig(@Req() req: any) {
|
||||
const tenantId = req.tenantId as string | undefined;
|
||||
async getSmtpConfig(@Req() req: AuthenticatedRequest) {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new BadRequestException('No tenant context');
|
||||
}
|
||||
@@ -50,8 +51,8 @@ export class SettingsController {
|
||||
*/
|
||||
@Put('smtp')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async saveSmtpConfig(@Req() req: any, @Body() dto: SmtpConfigDto) {
|
||||
const tenantId = req.tenantId as string | undefined;
|
||||
async saveSmtpConfig(@Req() req: AuthenticatedRequest, @Body() dto: SmtpConfigDto) {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new BadRequestException('No tenant context');
|
||||
}
|
||||
@@ -67,8 +68,8 @@ export class SettingsController {
|
||||
*/
|
||||
@Post('smtp/test')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async testSmtpConfig(@Req() req: any, @Body() dto: SmtpConfigDto) {
|
||||
const tenantId = req.tenantId as string | undefined;
|
||||
async testSmtpConfig(@Req() req: AuthenticatedRequest, @Body() dto: SmtpConfigDto) {
|
||||
const tenantId = req.tenantId;
|
||||
if (!tenantId) {
|
||||
throw new BadRequestException('No tenant context');
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export class SettingsService {
|
||||
* Methode, wie die restlichen Anfragewege dieser Datei.
|
||||
*/
|
||||
async getSmtpConfig(tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
return tenantPrisma.smtpConfig.findUnique({
|
||||
where: { tenantId },
|
||||
select: {
|
||||
@@ -61,7 +61,7 @@ export class SettingsService {
|
||||
* Mandantengebunden (260911-gwh): EIN Klient `tenantPrisma`.
|
||||
*/
|
||||
async saveSmtpConfig(tenantId: string, dto: SmtpConfigDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
|
||||
// Determine the encrypted password to store
|
||||
let encryptedPassword: string | undefined;
|
||||
@@ -104,7 +104,7 @@ export class SettingsService {
|
||||
* schmalem `select` — das verschluesselte Kennwort wird hier nie geladen.
|
||||
*/
|
||||
async getBugReportRecipient(tenantId: string): Promise<string | null> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const row = await tenantPrisma.smtpConfig.findUnique({
|
||||
where: { tenantId },
|
||||
select: { bugReportRecipient: true },
|
||||
@@ -134,7 +134,7 @@ export class SettingsService {
|
||||
fromAddress: string;
|
||||
decryptedPassword: string | null;
|
||||
} | null> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
const config = await tenantPrisma.smtpConfig.findUnique({
|
||||
where: { tenantId },
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { Role, type Tenant } from '@prisma/client';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
@@ -66,9 +66,15 @@ export class TenantController {
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
|
||||
const results: any[] = [];
|
||||
// Elementtyp aus dem hergeleitet, was die Schleife unten tatsaechlich
|
||||
// hineinlegt: die fuenf uebernommenen Tenant-Spalten plus die gezaehlte
|
||||
// Benutzerzahl. Kein erfundenes Feld.
|
||||
const results: (Pick<
|
||||
Tenant,
|
||||
'id' | 'name' | 'slug' | 'isActive' | 'createdAt'
|
||||
> & { userCount: number })[] = [];
|
||||
for (const tenant of tenants) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenant.id) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenant.id);
|
||||
const userCount = await tenantPrisma.user.count({
|
||||
where: { tenantId: tenant.id },
|
||||
});
|
||||
@@ -99,7 +105,7 @@ export class TenantController {
|
||||
throw new NotFoundException('Tenant not found');
|
||||
}
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, id) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, id);
|
||||
const userCount = await tenantPrisma.user.count({
|
||||
where: { tenantId: id },
|
||||
});
|
||||
@@ -160,7 +166,7 @@ export class TenantController {
|
||||
throw new NotFoundException('Tenant not found');
|
||||
}
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, id) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, id);
|
||||
const activeUserCount = await tenantPrisma.user.count({
|
||||
where: { tenantId: id, isActive: true },
|
||||
});
|
||||
|
||||
@@ -83,6 +83,14 @@ export class TenderDigestScheduler implements OnModuleInit {
|
||||
// type signature. At runtime the object IS a full CronJob —
|
||||
// SchedulerRegistry only calls stop() on it.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// URTEIL: BLEIBT (260921-m34, Aufgabe 3, D-01). Gemessen: ohne die
|
||||
// Zusicherung meldet tsc, dass das lokale `job` nur die Form
|
||||
// `{ start(): void }` hat, waehrend addCronJob() einen vollstaendigen
|
||||
// CronJob verlangt. Ursache ist der require()-Umweg aus 07-04 (pnpm-
|
||||
// Isolation, `cron` ist nur eine mittelbare Abhaengigkeit). Das
|
||||
// aufzuloesen hiesse, die Beschaffung der Klasse zu aendern — eine
|
||||
// Verhaltensaenderung — oder `cron` direkt aufzunehmen — eine neue
|
||||
// Abhaengigkeit. Beides ist hier verboten (D-03/D-04).
|
||||
this.schedulerRegistry.addCronJob(this.JOB_NAME, job as any);
|
||||
job.start();
|
||||
|
||||
@@ -121,7 +129,7 @@ export class TenderDigestScheduler implements OnModuleInit {
|
||||
// `distinct(['userId'])` liefert dann nur EINE der moeglichen
|
||||
// tenantId-Werte je Nutzer, welche ist von der internen Zeilenreihenfolge
|
||||
// abhaengig. Siehe docs/mandantentrennung-etappe2-fehlerrichtung.md.
|
||||
const systemPrisma = forSystem(this.prisma) as any;
|
||||
const systemPrisma = forSystem(this.prisma);
|
||||
const candidates: { userId: string; tenantId: string }[] = await systemPrisma.tenderMatch.findMany({
|
||||
where: { notifiedAt: null },
|
||||
select: { userId: true, tenantId: true },
|
||||
@@ -145,7 +153,7 @@ export class TenderDigestScheduler implements OnModuleInit {
|
||||
// `userId` sieht dieser Zugriff den ganzen Mandanten, exakt wie vor
|
||||
// der Migration. Ein Systemkontext fuer Hintergrunddienste ist
|
||||
// Etappe 3c, nicht Teil dieser Aenderung.
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
|
||||
const pref = await tenantPrisma.tenderNotificationPref.findUnique({
|
||||
where: { userId },
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { InboxConfig, InboxProvider } from '../inbox/inbox-provider.interfa
|
||||
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { prismaErrorCode } from '../prisma/prisma-error';
|
||||
import type { TenderEmailConfigDto } from './dto/tender-email-config.dto';
|
||||
|
||||
/**
|
||||
@@ -102,7 +103,7 @@ export class TenderEmailConfigService {
|
||||
* by userId (T-17-01) — a user only ever reads their own mailbox.
|
||||
*/
|
||||
async getConfigForApi(userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const safe = await tenantPrisma.tenderEmailConfig.findUnique({
|
||||
where: { userId },
|
||||
select: EMAIL_CONFIG_SAFE_SELECT,
|
||||
@@ -152,7 +153,7 @@ export class TenderEmailConfigService {
|
||||
*/
|
||||
async saveConfig(ctx: { userId: string; tenantId: string }, dto: TenderEmailConfigDto) {
|
||||
const { userId, tenantId } = ctx;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
let encryptedInboxCreds: string | undefined;
|
||||
|
||||
const credChanged =
|
||||
@@ -204,8 +205,8 @@ export class TenderEmailConfigService {
|
||||
update: { tenantId, ...data },
|
||||
select: EMAIL_CONFIG_SAFE_SELECT,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'P2002') {
|
||||
} catch (error: unknown) {
|
||||
if (prismaErrorCode(error) === 'P2002') {
|
||||
throw new ConflictException(
|
||||
'Die Postfach-Konfiguration konnte nicht gespeichert werden, weil bereits ein widersprüchlicher Eintrag existiert. Bitte laden Sie die Seite neu und versuchen Sie es erneut.',
|
||||
);
|
||||
@@ -240,7 +241,7 @@ export class TenderEmailConfigService {
|
||||
|
||||
if (!username || !password) {
|
||||
try {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const existing = await tenantPrisma.tenderEmailConfig.findUnique({ where: { userId } });
|
||||
if (existing?.encryptedInboxCreds) {
|
||||
const stored = JSON.parse(this.crypto.decrypt(existing.encryptedInboxCreds)) as {
|
||||
|
||||
@@ -72,7 +72,7 @@ export class TenderMatchingService {
|
||||
// und wuerde stumm. Treffer-Anlage und Sofortmeldung bleiben je Profil
|
||||
// GEBUNDEN (unten); der Katalog-Lesezugriff (`tender`, D-03) bleibt
|
||||
// ungebunden. Eine LEERE Profilliste ist Nichtstun (keine Treffer).
|
||||
const systemPrisma = forSystem(this.prisma) as any;
|
||||
const systemPrisma = forSystem(this.prisma);
|
||||
const savedSearches: Prisma.TenderSavedSearchGetPayload<Record<string, never>>[] =
|
||||
await systemPrisma.tenderSavedSearch.findMany();
|
||||
|
||||
@@ -95,7 +95,7 @@ export class TenderMatchingService {
|
||||
// Gebunden an den Mandanten DIESES Profils (260909-laa, Aufgabe 3)
|
||||
// — EIN gebundener Client je Profil, nicht je Treffer, sonst
|
||||
// entstuende pro Zeile eine eigene Transaktion.
|
||||
const tenantPrisma = forTenant(this.prisma, search.tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, search.tenantId);
|
||||
|
||||
for (const hit of hits) {
|
||||
await tenantPrisma.tenderMatch.upsert({
|
||||
@@ -134,7 +134,7 @@ export class TenderMatchingService {
|
||||
try {
|
||||
// Gebunden an den Mandanten DIESES Profils (260909-laa, Aufgabe 3)
|
||||
// — EIN gebundener Client je Profil.
|
||||
const tenantPrisma = forTenant(this.prisma, profile.tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, profile.tenantId);
|
||||
|
||||
const fresh = await tenantPrisma.tenderMatch.findMany({
|
||||
where: {
|
||||
@@ -156,7 +156,7 @@ export class TenderMatchingService {
|
||||
{ email: user.email },
|
||||
profile.tenantId,
|
||||
{ name: profile.name },
|
||||
fresh.map((match: { tender: unknown }) => match.tender),
|
||||
fresh.map((match) => match.tender),
|
||||
);
|
||||
|
||||
if (sent) {
|
||||
|
||||
@@ -57,10 +57,14 @@ export class TenderNormalizerService {
|
||||
case 'rss':
|
||||
case 'email-alert':
|
||||
return this.normalizeBag(raw);
|
||||
case 'doe-opendata':
|
||||
default:
|
||||
// default MUST stay on the DÖE path (never throw) so future
|
||||
// additive SourceType union members don't break existing callers.
|
||||
// Deckt sowohl 'doe-opendata' als auch jedes kuenftige additive
|
||||
// Mitglied der SourceType-Vereinigung ab: default MUSS auf dem
|
||||
// DÖE-Pfad bleiben (nie werfen), damit weder die heutige DÖE-Quelle
|
||||
// noch ein spaeter hinzugefuegter SourceType bestehende Aufrufer
|
||||
// zum Scheitern bringt (quick-260921-jt4: die vorherige eigene
|
||||
// 'doe-opendata'-Fallmarke unmittelbar ueber diesem Zweig war
|
||||
// ueberfluessig — derselbe Zweig, kein Verhaltenswechsel).
|
||||
return this.normalizeDoe(raw);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ConflictException, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { prismaErrorCode } from '../prisma/prisma-error';
|
||||
|
||||
/**
|
||||
* Service for managing the per-user Tender digest interval preference
|
||||
@@ -45,7 +46,7 @@ export class TenderNotificationPrefService {
|
||||
* autowrite needed to represent "using the default".
|
||||
*/
|
||||
async getForUser(userId: string, tenantId: string): Promise<{ digestInterval: string }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const existing = await tenantPrisma.tenderNotificationPref.findUnique({
|
||||
where: { userId },
|
||||
});
|
||||
@@ -63,15 +64,15 @@ export class TenderNotificationPrefService {
|
||||
* than creating a new one.
|
||||
*/
|
||||
async setForUser(userId: string, tenantId: string, digestInterval: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
try {
|
||||
return await tenantPrisma.tenderNotificationPref.upsert({
|
||||
where: { userId },
|
||||
create: { userId, tenantId, digestInterval },
|
||||
update: { digestInterval },
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'P2002') {
|
||||
} catch (error: unknown) {
|
||||
if (prismaErrorCode(error) === 'P2002') {
|
||||
throw new ConflictException(
|
||||
'Die Benachrichtigungseinstellung konnte nicht gespeichert werden, weil bereits ein widersprüchlicher Eintrag existiert. Bitte laden Sie die Seite neu und versuchen Sie es erneut.',
|
||||
);
|
||||
|
||||
@@ -44,7 +44,7 @@ const MAX_FAV_IDS = 500;
|
||||
export function buildTenderWhere(
|
||||
dto: TenderQueryDto,
|
||||
favIds?: string[],
|
||||
ownerTenantId?: string,
|
||||
ownerTenantId?: string | null,
|
||||
): Prisma.TenderWhereInput {
|
||||
const where: Prisma.TenderWhereInput = {};
|
||||
const AND: Prisma.TenderWhereInput[] = [];
|
||||
|
||||
@@ -69,7 +69,7 @@ export class TenderRssFeedSourceService {
|
||||
* Bindung nicht überflüssig, sondern das zweite Netz.
|
||||
*/
|
||||
async listForUser(userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
return tenantPrisma.tenderRssFeedSource.findMany({
|
||||
where: { OR: [{ userId: null }, { userId }] },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
@@ -93,7 +93,7 @@ export class TenderRssFeedSourceService {
|
||||
) {
|
||||
this.assertUrlAllowed(dto.url);
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, ctx.tenantId, ctx.userId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, ctx.tenantId, ctx.userId);
|
||||
const existingCount = await tenantPrisma.tenderRssFeedSource.count({
|
||||
where: { userId: ctx.userId },
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { prismaErrorCode } from '../prisma/prisma-error';
|
||||
import { CreateSavedSearchDto, UpdateSavedSearchDto } from './dto/saved-search.dto';
|
||||
|
||||
/**
|
||||
@@ -44,7 +45,7 @@ export class TenderSavedSearchService {
|
||||
* strictly by userId (V4/IDOR) — a foreign userId sees nothing.
|
||||
*/
|
||||
async list(userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
return tenantPrisma.tenderSavedSearch.findMany({
|
||||
where: { userId },
|
||||
orderBy: { name: 'asc' },
|
||||
@@ -58,7 +59,7 @@ export class TenderSavedSearchService {
|
||||
* users, since the uniqueness is scoped per-user.
|
||||
*/
|
||||
async create(userId: string, tenantId: string, dto: CreateSavedSearchDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
try {
|
||||
return await tenantPrisma.tenderSavedSearch.create({
|
||||
data: {
|
||||
@@ -69,8 +70,8 @@ export class TenderSavedSearchService {
|
||||
...(dto.instantAlert !== undefined && { instantAlert: dto.instantAlert }),
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'P2002') {
|
||||
} catch (error: unknown) {
|
||||
if (prismaErrorCode(error) === 'P2002') {
|
||||
throw new ConflictException(
|
||||
'Ein Suchprofil mit diesem Namen existiert bereits.',
|
||||
);
|
||||
@@ -87,7 +88,7 @@ export class TenderSavedSearchService {
|
||||
* leaking whether another user's profile exists).
|
||||
*/
|
||||
async update(id: string, userId: string, tenantId: string, dto: UpdateSavedSearchDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const existing = await tenantPrisma.tenderSavedSearch.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
@@ -108,8 +109,8 @@ export class TenderSavedSearchService {
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'P2002') {
|
||||
} catch (error: unknown) {
|
||||
if (prismaErrorCode(error) === 'P2002') {
|
||||
throw new ConflictException(
|
||||
'Ein Suchprofil mit diesem Namen existiert bereits.',
|
||||
);
|
||||
@@ -124,7 +125,7 @@ export class TenderSavedSearchService {
|
||||
* update().
|
||||
*/
|
||||
async remove(id: string, userId: string, tenantId: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const existing = await tenantPrisma.tenderSavedSearch.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
@@ -132,6 +132,14 @@ export class TenderSchedulerService implements OnApplicationBootstrap {
|
||||
// Cast required: our minimal CronJob type doesn't match cron's full type signature.
|
||||
// At runtime the object IS a full CronJob — SchedulerRegistry only calls stop() on it.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
// URTEIL: BLEIBT (260921-m34, Aufgabe 3, D-01). Gemessen: ohne die
|
||||
// Zusicherung meldet tsc, dass das lokale `job` nur die Form
|
||||
// `{ start(): void }` hat, waehrend addCronJob() einen vollstaendigen
|
||||
// CronJob verlangt. Ursache ist der require()-Umweg aus 07-04 (pnpm-
|
||||
// Isolation, `cron` ist nur eine mittelbare Abhaengigkeit). Das
|
||||
// aufzuloesen hiesse, die Beschaffung der Klasse zu aendern — eine
|
||||
// Verhaltensaenderung — oder `cron` direkt aufzunehmen — eine neue
|
||||
// Abhaengigkeit. Beides ist hier verboten (D-03/D-04).
|
||||
this.schedulerRegistry.addCronJob(this.JOB_NAME, job as any);
|
||||
job.start();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ConflictException, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { prismaErrorCode } from '../prisma/prisma-error';
|
||||
|
||||
/**
|
||||
* Partial triage update accepted by setTriage(). Both fields are optional
|
||||
@@ -75,7 +76,7 @@ export class TenderTriageService {
|
||||
update.favoritedAt = dto.isFavorite ? now : null;
|
||||
}
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
try {
|
||||
return await tenantPrisma.tenderTriage.upsert({
|
||||
where: { userId_tenderId: { userId, tenderId } },
|
||||
@@ -90,8 +91,8 @@ export class TenderTriageService {
|
||||
favoritedAt: dto.isFavorite ? now : null,
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'P2002') {
|
||||
} catch (error: unknown) {
|
||||
if (prismaErrorCode(error) === 'P2002') {
|
||||
throw new ConflictException(
|
||||
'Der Bearbeitungsstand zu dieser Ausschreibung konnte nicht gespeichert werden. Bitte die Seite neu laden und es erneut versuchen.',
|
||||
);
|
||||
@@ -111,7 +112,7 @@ export class TenderTriageService {
|
||||
*/
|
||||
async listForUser(userId: string, tenantId: string, tenderIds: string[]) {
|
||||
if (!tenderIds.length) return [];
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
return tenantPrisma.tenderTriage.findMany({
|
||||
where: { userId, tenderId: { in: tenderIds } },
|
||||
});
|
||||
@@ -123,7 +124,7 @@ export class TenderTriageService {
|
||||
* tender-query.builder.ts's buildTenderWhere.
|
||||
*/
|
||||
async favoriteIds(userId: string, tenantId: string): Promise<string[]> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
const rows = await tenantPrisma.tenderTriage.findMany({
|
||||
where: { userId, isFavorite: true },
|
||||
select: { tenderId: true },
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { Role } from '@prisma/client';
|
||||
import { Request } from 'express';
|
||||
import type { AuthenticatedRequest } from '../auth/types/auth-user';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { UseModule } from '../module-registry/module.guard';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -115,10 +115,10 @@ export class TendersController {
|
||||
* (`roles.guard.ts`) — one single spot in this controller resolves
|
||||
* account data from the request.
|
||||
*/
|
||||
private extractTriageContext(req: Request) {
|
||||
const userId = (req as any).user?.id;
|
||||
const tenantId = (req as any).tenantId ?? (req as any).user?.tenantId;
|
||||
const role = (req as any).user?.role;
|
||||
private extractTriageContext(req: AuthenticatedRequest) {
|
||||
const userId = req.user?.id;
|
||||
const tenantId = req.tenantId ?? req.user?.tenantId;
|
||||
const role = req.user?.role;
|
||||
|
||||
if (!tenantId) {
|
||||
throw new ForbiddenException('No tenant context');
|
||||
@@ -138,8 +138,10 @@ export class TendersController {
|
||||
* only" (fail-closed, tender-query.builder.ts) rather than 403 when no
|
||||
* tenant context is present.
|
||||
*/
|
||||
private resolveRequestingTenantId(req?: Request): string | undefined {
|
||||
return (req as any)?.user?.tenantId ?? (req as any)?.tenantId;
|
||||
private resolveRequestingTenantId(
|
||||
req?: AuthenticatedRequest,
|
||||
): string | null | undefined {
|
||||
return req?.user?.tenantId ?? req?.tenantId;
|
||||
}
|
||||
|
||||
// ─── Global read (ModuleGuard-gated, NOT tenant-scoped) ────────────────────
|
||||
@@ -164,7 +166,7 @@ export class TendersController {
|
||||
*/
|
||||
@Get()
|
||||
@UseModule('tender-radar')
|
||||
async listTenders(@Query() query: TenderQueryDto, @Req() req?: Request) {
|
||||
async listTenders(@Query() query: TenderQueryDto, @Req() req?: AuthenticatedRequest) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const skip = (page - 1) * limit;
|
||||
@@ -175,7 +177,7 @@ export class TendersController {
|
||||
// optional type only accommodates unit tests that call this method
|
||||
// directly without favOnly set (T-11-10: extractTriageContext
|
||||
// throws ForbiddenException if req/user context is genuinely absent).
|
||||
const { userId, tenantId } = this.extractTriageContext(req as Request);
|
||||
const { userId, tenantId } = this.extractTriageContext(req as AuthenticatedRequest);
|
||||
favIds = await this.tenderTriage.favoriteIds(userId, tenantId);
|
||||
}
|
||||
|
||||
@@ -263,11 +265,11 @@ export class TendersController {
|
||||
*/
|
||||
@Get('rss-feeds')
|
||||
@UseModule('tender-radar')
|
||||
async listRssFeeds(@Req() req: Request) {
|
||||
async listRssFeeds(@Req() req: AuthenticatedRequest) {
|
||||
const { userId, tenantId } = this.extractTriageContext(req);
|
||||
const feeds = await this.tenderRssFeedSource.listForUser(userId, tenantId);
|
||||
|
||||
return feeds.map(({ userId: ownerUserId, ...rest }: any) => ({
|
||||
return feeds.map(({ userId: ownerUserId, ...rest }) => ({
|
||||
...rest,
|
||||
isPlatformWide: ownerUserId === null,
|
||||
}));
|
||||
@@ -286,7 +288,7 @@ export class TendersController {
|
||||
*/
|
||||
@Post('rss-feeds')
|
||||
@UseModule('tender-radar')
|
||||
async createRssFeed(@Body() dto: TenderRssFeedDto, @Req() req: Request) {
|
||||
async createRssFeed(@Body() dto: TenderRssFeedDto, @Req() req: AuthenticatedRequest) {
|
||||
const { userId, tenantId, role } = this.extractTriageContext(req);
|
||||
|
||||
if (dto.scope === 'platform') {
|
||||
@@ -319,7 +321,7 @@ export class TendersController {
|
||||
*/
|
||||
@Delete('rss-feeds/:feedId')
|
||||
@UseModule('tender-radar')
|
||||
async removeRssFeed(@Param('feedId') feedId: string, @Req() req: Request) {
|
||||
async removeRssFeed(@Param('feedId') feedId: string, @Req() req: AuthenticatedRequest) {
|
||||
const { userId, role } = this.extractTriageContext(req);
|
||||
const isAdmin = role === Role.ADMIN || role === Role.SUPER_ADMIN;
|
||||
return this.tenderRssFeedSource.remove(feedId, { userId, isAdmin });
|
||||
@@ -342,7 +344,7 @@ export class TendersController {
|
||||
*/
|
||||
@Get('email-config')
|
||||
@UseModule('tender-radar')
|
||||
async getEmailConfig(@Req() req: Request) {
|
||||
async getEmailConfig(@Req() req: AuthenticatedRequest) {
|
||||
const { userId, tenantId } = this.extractTriageContext(req);
|
||||
return this.tenderEmailConfig.getConfigForApi(userId, tenantId);
|
||||
}
|
||||
@@ -356,7 +358,7 @@ export class TendersController {
|
||||
*/
|
||||
@Put('email-config')
|
||||
@UseModule('tender-radar')
|
||||
async saveEmailConfig(@Body() dto: TenderEmailConfigDto, @Req() req: Request) {
|
||||
async saveEmailConfig(@Body() dto: TenderEmailConfigDto, @Req() req: AuthenticatedRequest) {
|
||||
const { userId, tenantId } = this.extractTriageContext(req);
|
||||
return this.tenderEmailConfig.saveConfig({ userId, tenantId }, dto);
|
||||
}
|
||||
@@ -381,7 +383,7 @@ export class TendersController {
|
||||
*/
|
||||
@Post('email-config/test')
|
||||
@UseModule('tender-radar')
|
||||
async testEmailConnection(@Body() dto: TenderEmailConfigDto, @Req() req: Request) {
|
||||
async testEmailConnection(@Body() dto: TenderEmailConfigDto, @Req() req: AuthenticatedRequest) {
|
||||
const { userId, tenantId } = this.extractTriageContext(req);
|
||||
return this.tenderEmailConfig.testConnection(userId, tenantId, dto);
|
||||
}
|
||||
@@ -456,7 +458,7 @@ export class TendersController {
|
||||
*/
|
||||
@Get('triage')
|
||||
@UseModule('tender-radar')
|
||||
async listTriage(@Query('ids') ids: string | undefined, @Req() req: Request) {
|
||||
async listTriage(@Query('ids') ids: string | undefined, @Req() req: AuthenticatedRequest) {
|
||||
const { userId, tenantId } = this.extractTriageContext(req);
|
||||
const tenderIds = (ids ?? '')
|
||||
.split(',')
|
||||
@@ -480,7 +482,7 @@ export class TendersController {
|
||||
*/
|
||||
@Put('triage')
|
||||
@UseModule('tender-radar')
|
||||
async setTriage(@Body() dto: TenderTriageDto, @Req() req: Request) {
|
||||
async setTriage(@Body() dto: TenderTriageDto, @Req() req: AuthenticatedRequest) {
|
||||
const { userId, tenantId } = this.extractTriageContext(req);
|
||||
|
||||
return this.tenderTriage.setTriage(userId, tenantId, dto.tenderId, {
|
||||
@@ -502,7 +504,7 @@ export class TendersController {
|
||||
*/
|
||||
@Get('saved-searches')
|
||||
@UseModule('tender-radar')
|
||||
async listSavedSearches(@Req() req: Request) {
|
||||
async listSavedSearches(@Req() req: AuthenticatedRequest) {
|
||||
const { userId, tenantId } = this.extractTriageContext(req);
|
||||
return this.tenderSavedSearch.list(userId, tenantId);
|
||||
}
|
||||
@@ -517,7 +519,7 @@ export class TendersController {
|
||||
@UseModule('tender-radar')
|
||||
async createSavedSearch(
|
||||
@Body() dto: CreateSavedSearchDto,
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractTriageContext(req);
|
||||
return this.tenderSavedSearch.create(userId, tenantId, dto);
|
||||
@@ -535,7 +537,7 @@ export class TendersController {
|
||||
async updateSavedSearch(
|
||||
@Param('searchId') searchId: string,
|
||||
@Body() dto: UpdateSavedSearchDto,
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractTriageContext(req);
|
||||
return this.tenderSavedSearch.update(searchId, userId, tenantId, dto);
|
||||
@@ -550,7 +552,7 @@ export class TendersController {
|
||||
@UseModule('tender-radar')
|
||||
async removeSavedSearch(
|
||||
@Param('searchId') searchId: string,
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractTriageContext(req);
|
||||
await this.tenderSavedSearch.remove(searchId, userId, tenantId);
|
||||
@@ -571,7 +573,7 @@ export class TendersController {
|
||||
*/
|
||||
@Get('notification-pref')
|
||||
@UseModule('tender-radar')
|
||||
async getNotificationPref(@Req() req: Request) {
|
||||
async getNotificationPref(@Req() req: AuthenticatedRequest) {
|
||||
const { userId, tenantId } = this.extractTriageContext(req);
|
||||
return this.tenderNotificationPref.getForUser(userId, tenantId);
|
||||
}
|
||||
@@ -586,7 +588,7 @@ export class TendersController {
|
||||
@UseModule('tender-radar')
|
||||
async setNotificationPref(
|
||||
@Body() dto: UpdateNotificationPrefDto,
|
||||
@Req() req: Request,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
) {
|
||||
const { userId, tenantId } = this.extractTriageContext(req);
|
||||
return this.tenderNotificationPref.setForUser(userId, tenantId, dto.digestInterval);
|
||||
@@ -606,7 +608,7 @@ export class TendersController {
|
||||
*/
|
||||
@Get(':id')
|
||||
@UseModule('tender-radar')
|
||||
async getTender(@Param('id') id: string, @Req() req?: Request) {
|
||||
async getTender(@Param('id') id: string, @Req() req?: AuthenticatedRequest) {
|
||||
const tender = await this.prisma.tender.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
|
||||
import * as argon2 from 'argon2';
|
||||
import { GroupsService } from '../groups/groups.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { prismaErrorCode } from '../prisma/prisma-error';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/**
|
||||
@@ -104,7 +105,7 @@ export class AdminSeedService implements OnApplicationBootstrap {
|
||||
// `user-ungebundenes-einfuegen-abgelehnt`): eine FRISCHE Installation
|
||||
// haette ihren allerersten Administrator gar nicht anlegen koennen.
|
||||
const passwordHash = await argon2.hash(password);
|
||||
const tenantPrisma = forTenant(this.prisma, tenant.id) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenant.id);
|
||||
try {
|
||||
await tenantPrisma.user.create({
|
||||
data: {
|
||||
@@ -117,7 +118,7 @@ export class AdminSeedService implements OnApplicationBootstrap {
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
} catch (err: unknown) {
|
||||
// Entschaerfung der Startsperre (260910-das, Befund I): trifft die
|
||||
// Erstanlage die plattformweite Eindeutigkeit von username/email
|
||||
// (P2002), bedeutet das an DIESER Stelle exakt dasselbe wie ein
|
||||
@@ -129,7 +130,7 @@ export class AdminSeedService implements OnApplicationBootstrap {
|
||||
// festgehaltenen Absicht (seedAdmin() bleibt bewusst ungekapselt) --
|
||||
// JEDER ANDERE Fehler bricht den Start weiterhin ab. Nur dieser eine,
|
||||
// an dieser Stelle gleichbedeutende Fall wird ergaenzt.
|
||||
if (err?.code === 'P2002') {
|
||||
if (prismaErrorCode(err) === 'P2002') {
|
||||
this.logger.log(
|
||||
`Admin user "${username}" seed skipped: uniqueness collision on username/email (an administrator with this identity already exists, currently invisible under this tenant context) — see docs/mandantentrennung-etappe2-fehlerrichtung.md, Abschnitt "Bereich user"`,
|
||||
);
|
||||
|
||||
@@ -153,7 +153,7 @@ describe('UserController', () => {
|
||||
lastLoginAt: null,
|
||||
});
|
||||
|
||||
const result = await controller.findAll({ role: Role.ADMIN, tenantId: 't1', id: 'admin1' });
|
||||
const result = await controller.findAll({ role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false });
|
||||
|
||||
expect(result.map((u: any) => u.username)).toEqual(['alice']);
|
||||
expectBoundCall(prisma, 't1', 'user', 'findMany');
|
||||
@@ -170,6 +170,8 @@ describe('UserController', () => {
|
||||
role: Role.SUPER_ADMIN,
|
||||
tenantId: 't1',
|
||||
id: 'super1',
|
||||
username: 'super1',
|
||||
mustChangePassword: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(expected);
|
||||
@@ -183,11 +185,11 @@ describe('UserController', () => {
|
||||
const targetUser = { id: 'u-x', username: 'x', tenantId: 't1' };
|
||||
|
||||
userService.findById.mockResolvedValue(targetUser);
|
||||
await controller.findOne('u-x', { role: Role.ADMIN, tenantId: 't1', id: 'admin1' });
|
||||
await controller.findOne('u-x', { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false });
|
||||
expect(userService.findById).toHaveBeenCalledWith('t1', 'u-x');
|
||||
|
||||
userService.findByIdForPlatformAdmin.mockResolvedValue(targetUser);
|
||||
await controller.findOne('u-x', { role: Role.SUPER_ADMIN, tenantId: 't2', id: 'super1' });
|
||||
await controller.findOne('u-x', { role: Role.SUPER_ADMIN, tenantId: 't2', id: 'super1', username: 'super1', mustChangePassword: false });
|
||||
expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledWith('u-x');
|
||||
});
|
||||
|
||||
@@ -195,14 +197,14 @@ describe('UserController', () => {
|
||||
userService.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
controller.findOne('u-foreign', { role: Role.ADMIN, tenantId: 't1', id: 'admin1' }),
|
||||
controller.findOne('u-foreign', { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('Test 5: ein Mandanten-Administrator kann weiterhin keine oberste Rolle vergeben, und die Anlage eines Benutzers landet weiterhin im Mandanten des Aufrufers, wenn dieser nicht die oberste Rolle trägt', async () => {
|
||||
const currentUser = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' };
|
||||
const currentUser = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
|
||||
|
||||
await expect(
|
||||
controller.create(
|
||||
@@ -224,7 +226,7 @@ describe('UserController', () => {
|
||||
|
||||
describe('remove — Selbstlöschriegel (Befund H)', () => {
|
||||
it('Test 6: der Riegel gegen das Löschen des eigenen Kontos greift', async () => {
|
||||
const currentUser = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' };
|
||||
const currentUser = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
|
||||
userService.findById.mockResolvedValue({ id: 'admin1', tenantId: 't1' });
|
||||
|
||||
await expect(controller.remove('admin1', currentUser)).rejects.toBeInstanceOf(
|
||||
@@ -235,7 +237,7 @@ describe('UserController', () => {
|
||||
});
|
||||
|
||||
describe('Selbstbedienungswege (Befund G)', () => {
|
||||
const currentUser = { role: Role.USER, tenantId: 't1', id: 'me' };
|
||||
const currentUser = { role: Role.USER, tenantId: 't1', id: 'me', username: 'me', mustChangePassword: false };
|
||||
|
||||
beforeEach(() => {
|
||||
prisma.__seedUser({
|
||||
@@ -247,7 +249,10 @@ describe('UserController', () => {
|
||||
});
|
||||
|
||||
it('Test 7: alle fünf Zugriffe der vier Selbstbedienungswege stehen gebunden im Protokoll, mit der Mandantenkennung aus dem Sitzungsnachweis', async () => {
|
||||
await controller.uploadAvatar({ buffer: Buffer.from('x'), mimetype: 'image/png' }, currentUser);
|
||||
await controller.uploadAvatar(
|
||||
{ buffer: Buffer.from('x'), mimetype: 'image/png', originalname: 'x.png', size: 1 },
|
||||
currentUser,
|
||||
);
|
||||
await controller.deleteAvatar(currentUser);
|
||||
await controller.updateAccentColor({ color: '#ff00aa' }, currentUser);
|
||||
|
||||
@@ -280,7 +285,7 @@ describe('UserController', () => {
|
||||
|
||||
describe('update/remove — Zielrolle SUPER_ADMIN (WINDOWS #29)', () => {
|
||||
it('Test 9: ein Mandanten-Administrator kann den SUPER_ADMIN des eigenen Mandanten weder übernehmen (Kennwort setzen), noch aussperren (isActive=false), noch herabstufen (role=USER) — alle drei Angriffsformen werden mit der Zielrollen-Ausnahme abgelehnt, und der Dienst wird in keinem der drei Fälle aufgerufen', async () => {
|
||||
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' };
|
||||
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
|
||||
userService.findById.mockResolvedValue({ id: 'boss', tenantId: 't1', role: Role.SUPER_ADMIN });
|
||||
|
||||
await expect(
|
||||
@@ -300,7 +305,7 @@ describe('UserController', () => {
|
||||
});
|
||||
|
||||
it('Test 10: ein SUPER_ADMIN kann einen anderen SUPER_ADMIN weiterhin ändern — der Zielrollen-Riegel gilt nur für Nicht-SUPER_ADMIN-Aufrufer', async () => {
|
||||
const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't1', id: 'super1' };
|
||||
const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't1', id: 'super1', username: 'super1', mustChangePassword: false };
|
||||
userService.findByIdForPlatformAdmin.mockResolvedValue({
|
||||
id: 'boss',
|
||||
tenantId: 't1',
|
||||
@@ -324,7 +329,7 @@ describe('UserController', () => {
|
||||
});
|
||||
|
||||
it('Test 11: ein Mandanten-Administrator kann einen USER seines Mandanten weiterhin ändern — Regressionsschutz, der Zielrollen-Riegel engt bestehende Wege nicht zusätzlich ein', async () => {
|
||||
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' };
|
||||
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
|
||||
userService.findById.mockResolvedValue({ id: 'u1', tenantId: 't1', role: Role.USER });
|
||||
userService.update.mockResolvedValue({ id: 'u1', tenantId: 't1', role: Role.USER });
|
||||
|
||||
@@ -338,7 +343,7 @@ describe('UserController', () => {
|
||||
});
|
||||
|
||||
it('Test 12: die Mandantengrenze wird VOR der Zielrollen-Prüfung geprüft — ein Administrator, der (bei einer fehlerhaften Auflösung) ein Ziel eines fremden Mandanten mit der obersten Rolle erhält, bekommt die Mandanten-Meldung, nicht die Zielrollen-Meldung, und erfährt so nichts über die Rolle des fremden Benutzers', async () => {
|
||||
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' };
|
||||
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
|
||||
userService.findById.mockResolvedValue({ id: 'boss2', tenantId: 't2', role: Role.SUPER_ADMIN });
|
||||
|
||||
await expect(
|
||||
@@ -348,7 +353,7 @@ describe('UserController', () => {
|
||||
});
|
||||
|
||||
it('Test 13: ein Mandanten-Administrator kann den SUPER_ADMIN des eigenen Mandanten nicht löschen — die Zielrollen-Ausnahme greift, und der Dienst wird nicht aufgerufen', async () => {
|
||||
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' };
|
||||
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
|
||||
userService.findById.mockResolvedValue({ id: 'boss', tenantId: 't1', role: Role.SUPER_ADMIN });
|
||||
|
||||
await expect(controller.remove('boss', admin)).rejects.toThrow(
|
||||
@@ -358,7 +363,7 @@ describe('UserController', () => {
|
||||
});
|
||||
|
||||
it('Test 14: ein SUPER_ADMIN kann einen anderen SUPER_ADMIN weiterhin löschen — der Zielrollen-Riegel gilt nur für Nicht-SUPER_ADMIN-Aufrufer', async () => {
|
||||
const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't1', id: 'super1' };
|
||||
const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't1', id: 'super1', username: 'super1', mustChangePassword: false };
|
||||
userService.findByIdForPlatformAdmin.mockResolvedValue({
|
||||
id: 'boss',
|
||||
tenantId: 't1',
|
||||
@@ -373,7 +378,7 @@ describe('UserController', () => {
|
||||
});
|
||||
|
||||
it('Test 15: ein Mandanten-Administrator kann einen USER seines Mandanten weiterhin löschen — Regressionsschutz', async () => {
|
||||
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' };
|
||||
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
|
||||
userService.findById.mockResolvedValue({ id: 'u1', tenantId: 't1', role: Role.USER });
|
||||
userService.delete.mockResolvedValue({ message: 'User deleted' });
|
||||
|
||||
@@ -383,7 +388,7 @@ describe('UserController', () => {
|
||||
});
|
||||
|
||||
it('Test 16: die Mandantengrenze wird VOR der Zielrollen-Prüfung geprüft — beim Löschen bekommt ein Administrator mit einem fremdmandantigen Ziel der obersten Rolle die Mandanten-Meldung, nicht die Zielrollen-Meldung', async () => {
|
||||
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1' };
|
||||
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
|
||||
userService.findById.mockResolvedValue({ id: 'boss2', tenantId: 't2', role: Role.SUPER_ADMIN });
|
||||
|
||||
await expect(controller.remove('boss2', admin)).rejects.toThrow(
|
||||
|
||||
@@ -21,6 +21,7 @@ import * as path from 'node:path';
|
||||
import { Response } from 'express';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -65,7 +66,7 @@ export class UserController {
|
||||
* uebergreifende Sicht von der mandantengebundenen unterscheidet, und sie
|
||||
* darf nicht eingeebnet werden.
|
||||
*/
|
||||
private async resolveTargetUser(currentUser: any, id: string) {
|
||||
private async resolveTargetUser(currentUser: AuthUser, id: string) {
|
||||
if (currentUser.role === Role.SUPER_ADMIN) {
|
||||
return this.userService.findByIdForPlatformAdmin(id);
|
||||
}
|
||||
@@ -79,7 +80,7 @@ export class UserController {
|
||||
*/
|
||||
@Get()
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async findAll(@CurrentUser() currentUser: any) {
|
||||
async findAll(@CurrentUser() currentUser: AuthUser) {
|
||||
if (currentUser.role === Role.SUPER_ADMIN) {
|
||||
// Plattform-Administratorsicht (Befund F): die bestehende, gewollte
|
||||
// Funktion der obersten Rolle bleibt erhalten, laeuft aber ueber die
|
||||
@@ -92,7 +93,7 @@ export class UserController {
|
||||
// Mandantenbedingung im where BLEIBT erhalten -- nicht entfernen mit
|
||||
// dem Argument, das mache jetzt die Datenbank; dieselbe Regel, die die
|
||||
// Bereiche `tenders` und `dkv` aufgestellt haben.
|
||||
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId);
|
||||
return tenantPrisma.user.findMany({
|
||||
where: { tenantId: currentUser.tenantId },
|
||||
select: {
|
||||
@@ -115,7 +116,7 @@ export class UserController {
|
||||
*/
|
||||
@Get(':id')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async findOne(@Param('id') id: string, @CurrentUser() currentUser: any) {
|
||||
async findOne(@Param('id') id: string, @CurrentUser() currentUser: AuthUser) {
|
||||
const user = await this.resolveTargetUser(currentUser, id);
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
@@ -139,7 +140,7 @@ export class UserController {
|
||||
*/
|
||||
@Post()
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async create(@Body() dto: CreateUserDto, @CurrentUser() currentUser: any) {
|
||||
async create(@Body() dto: CreateUserDto, @CurrentUser() currentUser: AuthUser) {
|
||||
// ADMIN can only create users in own tenant
|
||||
const tenantId =
|
||||
currentUser.role === Role.SUPER_ADMIN && dto.tenantId
|
||||
@@ -173,7 +174,7 @@ export class UserController {
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateUserDto,
|
||||
@CurrentUser() currentUser: any,
|
||||
@CurrentUser() currentUser: AuthUser,
|
||||
) {
|
||||
const user = await this.resolveTargetUser(currentUser, id);
|
||||
if (!user) {
|
||||
@@ -228,7 +229,7 @@ export class UserController {
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||
async remove(@Param('id') id: string, @CurrentUser() currentUser: any) {
|
||||
async remove(@Param('id') id: string, @CurrentUser() currentUser: AuthUser) {
|
||||
const user = await this.resolveTargetUser(currentUser, id);
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
@@ -290,14 +291,14 @@ export class UserController {
|
||||
FileInterceptor('file', { limits: { fileSize: 2 * 1024 * 1024 } }),
|
||||
)
|
||||
async uploadAvatar(
|
||||
@UploadedFile() file: any,
|
||||
@CurrentUser() currentUser: any,
|
||||
@UploadedFile() file: UploadedFileLike | undefined,
|
||||
@CurrentUser() currentUser: AuthUser,
|
||||
) {
|
||||
if (!file?.buffer) {
|
||||
throw new BadRequestException('No file provided');
|
||||
}
|
||||
|
||||
const ext = AVATAR_MIME_TO_EXT[file.mimetype as string];
|
||||
const ext = AVATAR_MIME_TO_EXT[file.mimetype];
|
||||
if (!ext) {
|
||||
throw new BadRequestException(
|
||||
'Invalid file type. Allowed: image/png, image/jpeg, image/webp',
|
||||
@@ -318,11 +319,11 @@ export class UserController {
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, file.buffer as Buffer);
|
||||
fs.writeFileSync(filePath, file.buffer);
|
||||
|
||||
// Persist relative path (relative to monorepo root)
|
||||
const relativePath = path.join('user-files', 'avatars', filename);
|
||||
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId);
|
||||
await tenantPrisma.user.update({
|
||||
where: { id: currentUser.id },
|
||||
data: { avatarPath: relativePath },
|
||||
@@ -336,8 +337,8 @@ export class UserController {
|
||||
* Remove the current user's profile picture.
|
||||
*/
|
||||
@Delete('me/avatar')
|
||||
async deleteAvatar(@CurrentUser() currentUser: any) {
|
||||
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId) as any;
|
||||
async deleteAvatar(@CurrentUser() currentUser: AuthUser) {
|
||||
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId);
|
||||
const user = await tenantPrisma.user.findUnique({
|
||||
where: { id: currentUser.id },
|
||||
select: { avatarPath: true },
|
||||
@@ -366,13 +367,13 @@ export class UserController {
|
||||
@Patch('me/accent-color')
|
||||
async updateAccentColor(
|
||||
@Body() body: { color: string | null },
|
||||
@CurrentUser() currentUser: any,
|
||||
@CurrentUser() currentUser: AuthUser,
|
||||
) {
|
||||
if (body.color !== null && body.color !== undefined && !/^#[0-9a-fA-F]{6}$/.test(body.color)) {
|
||||
throw new BadRequestException('Invalid color format. Use hex (#rrggbb).');
|
||||
}
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId);
|
||||
await tenantPrisma.user.update({
|
||||
where: { id: currentUser.id },
|
||||
data: { accentColor: body.color ?? null },
|
||||
@@ -388,10 +389,10 @@ export class UserController {
|
||||
*/
|
||||
@Get('me/avatar')
|
||||
async getAvatar(
|
||||
@CurrentUser() currentUser: any,
|
||||
@CurrentUser() currentUser: AuthUser,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId);
|
||||
const user = await tenantPrisma.user.findUnique({
|
||||
where: { id: currentUser.id },
|
||||
select: { avatarPath: true },
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ConflictException, Injectable, Logger } from '@nestjs/common';
|
||||
import * as argon2 from 'argon2';
|
||||
import type { User } from '@prisma/client';
|
||||
import { GroupsService } from '../groups/groups.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { prismaErrorCode } from '../prisma/prisma-error';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/**
|
||||
@@ -17,6 +19,51 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
* im selben Commit auf die neue Signatur umgestellt (260910-das, Aufgabe 3),
|
||||
* damit die Typpruefung nach jeder Aufgabe sauber bleibt.
|
||||
*/
|
||||
/**
|
||||
* Spaltenauswahl der plattformweiten Benutzerliste. Als eigene Konstante,
|
||||
* damit der Elementtyp der Sammelliste unten mit `Prisma.UserGetPayload`
|
||||
* aus GENAU dieser Auswahl hergeleitet wird — eine zweite Beschreibung
|
||||
* derselben Felder waere eine Behauptung, die beim naechsten Feld
|
||||
* auseinanderlaeuft.
|
||||
*/
|
||||
const PLATFORM_USER_SELECT = {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
tenantId: true,
|
||||
createdAt: true,
|
||||
lastLoginAt: true,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Elementtyp der Sammelliste, aus PLATFORM_USER_SELECT hergeleitet statt
|
||||
* daneben beschrieben. Bewusst `Pick<User, keyof typeof ...>` und NICHT
|
||||
* `Prisma.UserGetPayload<{ select: ... }>`: die zweite Form traegt das Wort
|
||||
* select in eine Typangabe, und der Erkenner in rls-access-inventory.spec.ts
|
||||
* zaehlt jede select-Angabe ausserhalb eines Modellaufrufs als Verstoss
|
||||
* (gemessen, 260921-m34 Aufgabe 3b). Der Erkenner ist die Mandantenkontrolle
|
||||
* und wird nicht fuer eine Typschreibweise aufgeweicht (T-M34-03).
|
||||
*/
|
||||
type PlatformUserRow = Pick<User, keyof typeof PLATFORM_USER_SELECT>;
|
||||
|
||||
/**
|
||||
* Felder, die `UserService.update()` entgegennimmt. Als eigener Typ, damit
|
||||
* das intern zusammengebaute `updateData` unten daraus abgeleitet werden
|
||||
* kann statt daneben noch einmal von Hand beschrieben zu werden.
|
||||
*/
|
||||
interface UpdateUserInput {
|
||||
username?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
displayName?: string;
|
||||
role?: 'SUPER_ADMIN' | 'ADMIN' | 'USER';
|
||||
isActive?: boolean;
|
||||
mustChangePassword?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
private readonly logger = new Logger(UserService.name);
|
||||
@@ -61,7 +108,7 @@ export class UserService {
|
||||
* (Aufgabe 1, `user-gebunden-nur-eigener-mandant`).
|
||||
*/
|
||||
async findById(tenantId: string, id: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
return tenantPrisma.user.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
@@ -105,9 +152,12 @@ export class UserService {
|
||||
ldapDn?: string;
|
||||
}) {
|
||||
const { password, ...rest } = data;
|
||||
const tenantPrisma = forTenant(this.prisma, data.tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, data.tenantId);
|
||||
|
||||
let created: any;
|
||||
// Der Rueckgabewert von user.create() ist das vollstaendige User-Modell.
|
||||
// Die Zuweisung steht im try, der catch endet ausnahmslos mit throw —
|
||||
// nach dem Block ist `created` deshalb belegt, ohne Behauptung.
|
||||
let created: User;
|
||||
try {
|
||||
created = await tenantPrisma.user.create({
|
||||
data: {
|
||||
@@ -116,8 +166,8 @@ export class UserService {
|
||||
passwordHash: password ? await argon2.hash(password) : null,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'P2002') {
|
||||
} catch (err: unknown) {
|
||||
if (prismaErrorCode(err) === 'P2002') {
|
||||
throw new ConflictException(
|
||||
'Benutzername oder E-Mail-Adresse sind plattformweit bereits vergeben.',
|
||||
);
|
||||
@@ -144,21 +194,13 @@ export class UserService {
|
||||
* weil auch ein Namens- oder Adresswechsel auf denselben plattformweiten
|
||||
* Schluessel treffen kann.
|
||||
*/
|
||||
async update(
|
||||
tenantId: string,
|
||||
id: string,
|
||||
data: {
|
||||
username?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
displayName?: string;
|
||||
role?: 'SUPER_ADMIN' | 'ADMIN' | 'USER';
|
||||
isActive?: boolean;
|
||||
mustChangePassword?: boolean;
|
||||
},
|
||||
) {
|
||||
async update(tenantId: string, id: string, data: UpdateUserInput) {
|
||||
const { password, ...rest } = data;
|
||||
const updateData: any = { ...rest };
|
||||
// Aus der Signatur hergeleitet: alles ausser `password`, dafuer der
|
||||
// daraus berechnete `passwordHash`. Nichts erfunden, nichts weggelassen.
|
||||
const updateData: Omit<UpdateUserInput, 'password'> & {
|
||||
passwordHash?: string;
|
||||
} = { ...rest };
|
||||
|
||||
if (updateData.username) {
|
||||
updateData.username = updateData.username.toLowerCase();
|
||||
@@ -168,14 +210,14 @@ export class UserService {
|
||||
updateData.passwordHash = await argon2.hash(password);
|
||||
}
|
||||
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
try {
|
||||
return await tenantPrisma.user.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (err?.code === 'P2002') {
|
||||
} catch (err: unknown) {
|
||||
if (prismaErrorCode(err) === 'P2002') {
|
||||
throw new ConflictException(
|
||||
'Benutzername oder E-Mail-Adresse sind plattformweit bereits vergeben.',
|
||||
);
|
||||
@@ -189,7 +231,7 @@ export class UserService {
|
||||
* Mandanten.
|
||||
*/
|
||||
async deactivate(tenantId: string, id: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
return tenantPrisma.user.update({
|
||||
where: { id },
|
||||
data: { isActive: false },
|
||||
@@ -200,7 +242,7 @@ export class UserService {
|
||||
* Hard delete a user, gebunden an den uebergebenen Mandanten.
|
||||
*/
|
||||
async delete(tenantId: string, id: string) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||
return tenantPrisma.user.delete({ where: { id } });
|
||||
}
|
||||
|
||||
@@ -231,22 +273,12 @@ export class UserService {
|
||||
async findAllForPlatformAdmin() {
|
||||
const tenants = await this.prisma.tenant.findMany({ select: { id: true } });
|
||||
|
||||
const results: any[] = [];
|
||||
const results: PlatformUserRow[] = [];
|
||||
for (const tenant of tenants) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenant.id) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenant.id);
|
||||
const users = await tenantPrisma.user.findMany({
|
||||
where: { tenantId: tenant.id },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
tenantId: true,
|
||||
createdAt: true,
|
||||
lastLoginAt: true,
|
||||
},
|
||||
select: PLATFORM_USER_SELECT,
|
||||
});
|
||||
results.push(...users);
|
||||
}
|
||||
@@ -265,7 +297,7 @@ export class UserService {
|
||||
const tenants = await this.prisma.tenant.findMany({ select: { id: true } });
|
||||
|
||||
for (const tenant of tenants) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenant.id) as any;
|
||||
const tenantPrisma = forTenant(this.prisma, tenant.id);
|
||||
const user = await tenantPrisma.user.findUnique({ where: { id } });
|
||||
if (user) {
|
||||
return user;
|
||||
|
||||
@@ -12,13 +12,24 @@ use tauri_plugin_opener::OpenerExt;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tauri_plugin_updater::{Update, UpdaterExt};
|
||||
|
||||
/// Standardtext des Update-Eintrags im Tray-Menü, bevor eine neuere Version
|
||||
/// gefunden wurde. Der Eintrag installiert das Update seit quick-260917-kgc
|
||||
/// direkt in der App (Download, Signaturpruefung, Installation, Neustart);
|
||||
/// den Browser oeffnet er nur noch als Rueckfall. `spawn_version_check`
|
||||
/// setzt den Eintrag nach einem Serverwechsel hierauf zurück, damit kein
|
||||
/// Hinweis des alten Servers stehen bleibt.
|
||||
const UPDATE_ITEM_DEFAULT: &str = "Update installieren";
|
||||
/// Menuetext des Update-Eintrags, solange eine Pruefung laeuft (gesperrt).
|
||||
/// `spawn_version_check` setzt den Eintrag zu Beginn hierauf, auch nach einem
|
||||
/// Serverwechsel, damit kein Hinweis des alten Servers stehen bleibt. Der
|
||||
/// Eintrag installiert ein gefundenes Update seit quick-260917-kgc direkt in
|
||||
/// der App (Download, Signaturpruefung, Installation, Neustart).
|
||||
const UPDATE_ITEM_CHECKING: &str = "Suche nach Updates…";
|
||||
|
||||
/// Menuetext, wenn die Pruefung kein Update ergab. Der Eintrag bleibt
|
||||
/// anklickbar und startet die Pruefung erneut (quick-260922-frg): der Nutzer
|
||||
/// soll nie vor einem stumm ausgegrauten Eintrag stehen. Auch der Starttext
|
||||
/// ohne gespeicherte Server-Adresse (Klick ohne Adresse tut nichts).
|
||||
const UPDATE_ITEM_NONE: &str = "Kein Update verfügbar – erneut prüfen";
|
||||
|
||||
/// Abstand der wiederholten Update-Pruefung. Die Tray-App laeuft tagelang
|
||||
/// durch; mit der Pruefung nur beim Start hat ein Client ein neues Paket auf
|
||||
/// dem Server nie gesehen (Befund 22.09.2026, quick-260922-frg). Vier
|
||||
/// Stunden reichen fuer den Beta-Kanal und belasten den Server nicht.
|
||||
const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(4 * 3600);
|
||||
|
||||
/// Menuetext, wenn die gespeicherte Server-Adresse mit `http://` beginnt:
|
||||
/// das Updater-Plugin verweigert im Release-Bau unverschluesselte Endpunkte
|
||||
@@ -37,6 +48,15 @@ struct VersionResponse {
|
||||
/// `Update` ist Clone + Send + Sync, `app.manage` verlangt das.
|
||||
struct PendingUpdate(Mutex<Option<(Update, String)>>);
|
||||
|
||||
/// Benachrichtigungstext der zuletzt gemeldeten fehlgeschlagenen
|
||||
/// Update-Pruefung. Die Pruefung laeuft alle `UPDATE_CHECK_INTERVAL` erneut;
|
||||
/// gegen einen dauerhaft sperrenden Proxy wuerde sonst alle vier Stunden
|
||||
/// dieselbe Meldung erscheinen (T-FRG-02). Nur ein ABWEICHENDER Text wird
|
||||
/// erneut gemeldet; ein Erfolg (`Ok(..)`) leert den Wert, damit ein spaeterer
|
||||
/// Fehler wieder sichtbar wird. Eigener `app.manage`-Typ, damit
|
||||
/// `spawn_version_check` seine Signatur behaelt.
|
||||
struct LastCheckNotice(Mutex<String>);
|
||||
|
||||
/// Baut die Adresse eines API-Pfads aus der gespeicherten Server-Adresse.
|
||||
/// Die API ist vom Client nur ueber den Web-Ursprung erreichbar
|
||||
/// (Next.js-Rewrite `/api-proxy/*`, siehe 18-01) -- niemals direkt unter dem
|
||||
@@ -76,7 +96,7 @@ fn with_client_marker(url: &tauri::Url, version: &str, commit: &str, os: &str) -
|
||||
/// aeltere Middleware weiterhin als Desktop-App erkannt wird. Die Werte
|
||||
/// gehen NUR in die Navigation, niemals in den gespeicherten Server-Wert
|
||||
/// (`server_url` im Store bleibt die reine Adresse). Der Browser-Rueckfall
|
||||
/// des Tray-Eintrags "Update installieren" (`open_download_page`) bekommt
|
||||
/// einer fehlgeschlagenen Update-Installation (`open_download_page`) bekommt
|
||||
/// den Marker bewusst NICHT -- er oeffnet im System-Browser, dort muessen
|
||||
/// die Download-Links sichtbar bleiben.
|
||||
fn with_desktop_marker(url: &tauri::Url) -> tauri::Url {
|
||||
@@ -179,6 +199,78 @@ fn update_endpoint(server: &str) -> Option<tauri::Url> {
|
||||
Some(url)
|
||||
}
|
||||
|
||||
/// Baut die Update-Adresse mit ERSETZTEN Platzhaltern -- dieselbe Anfrage,
|
||||
/// die das Plugin stellt (`update_endpoint`, gleiche Kodierung ueber
|
||||
/// `query_pairs_mut`), nur mit konkreten Werten. `spawn_version_check` stellt
|
||||
/// sie nach `Err(ReleaseNotFound)` einmal selbst, um den Statuscode zu
|
||||
/// erfahren: das Plugin verschluckt einen Nicht-2xx-Status (updater.rs
|
||||
/// Z. 529-559 loggt nur, `last_error` bleibt leer, Ergebnis ist
|
||||
/// `ReleaseNotFound`). Reine Funktion, darum in den Tests mit festen Werten
|
||||
/// geprueft.
|
||||
fn diagnostic_update_url(server: &str, target: &str, arch: &str, current: &str) -> String {
|
||||
let base = server.trim_end_matches('/');
|
||||
match tauri::Url::parse(&api_url(server, "/desktop/update")) {
|
||||
Ok(mut url) => {
|
||||
url.query_pairs_mut()
|
||||
.append_pair("target", target)
|
||||
.append_pair("arch", arch)
|
||||
.append_pair("current", current)
|
||||
.append_pair("base", base);
|
||||
url.into()
|
||||
}
|
||||
// Gespeicherte Adressen sind immer parsebar (parse_server_url);
|
||||
// reine Absicherung, damit die Funktion kein Option liefern muss.
|
||||
Err(_) => api_url(server, "/desktop/update"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Liefert (Menuetext, Benachrichtigungstext) fuer eine fehlgeschlagene
|
||||
/// Update-Pruefung. `status` ist der Antwortcode des Servers, wenn er
|
||||
/// geantwortet hat (`Some`), sonst `None` (keine Verbindung, TLS, Timeout).
|
||||
/// 401/403 bekommen die Proxy-Erklaerung: Befund 22.09.2026 -- der Nginx
|
||||
/// Proxy Manager vor alpha verlangt Basic-Auth, die Webansicht beantwortet
|
||||
/// das Passwortfenster, der Updater (eigener `reqwest`-Client) kann es nicht.
|
||||
/// Zugangsdaten gehoeren nicht in ausgelieferte Clients (T-FRG-03), darum
|
||||
/// wird der Grund angezeigt, nicht umgangen. Alle Texte enden auf
|
||||
/// "– erneut prüfen", weil der Klick die Pruefung wiederholt.
|
||||
fn check_failure_labels(status: Option<u16>) -> (String, String) {
|
||||
match status {
|
||||
Some(code @ (401 | 403)) => (
|
||||
format!("Update-Prüfung fehlgeschlagen (HTTP {code}) – erneut prüfen"),
|
||||
format!(
|
||||
"Der Server hat die Update-Anfrage mit HTTP {code} abgewiesen. Meist steht ein \
|
||||
Passwortschutz oder eine Zugriffsliste am vorgeschalteten Proxy davor, die die \
|
||||
App für Updates nicht durchlaufen kann. Anmeldung und Arbeiten in der App sind \
|
||||
davon nicht betroffen."
|
||||
),
|
||||
),
|
||||
Some(code) => (
|
||||
format!("Update-Prüfung fehlgeschlagen (HTTP {code}) – erneut prüfen"),
|
||||
format!("Der Server hat auf die Update-Anfrage mit HTTP {code} geantwortet statt mit Paketdaten."),
|
||||
),
|
||||
None => (
|
||||
"Update-Prüfung fehlgeschlagen (keine Verbindung) – erneut prüfen".to_string(),
|
||||
"Der Server war für die Update-Prüfung nicht erreichbar. Die App prüft in vier \
|
||||
Stunden erneut – oder über den Menüeintrag."
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stellt die Update-Anfrage ein zweites Mal mit eigenem `reqwest`-Client
|
||||
/// (Timeout 8 s, Muster `check_server`) und liest NUR den Statuscode -- der
|
||||
/// Rumpf wird nicht ausgewertet, Redirects folgen dem Standard (T-FRG-01:
|
||||
/// Ziel ist die vom Nutzer gespeicherte Adresse, kein Geheimnis in der
|
||||
/// Anfrage). `None`, wenn keine Antwort kam.
|
||||
async fn probe_update_status(url: String) -> Option<u16> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(8))
|
||||
.build()
|
||||
.ok()?;
|
||||
let resp = client.get(url).send().await.ok()?;
|
||||
Some(resp.status().as_u16())
|
||||
}
|
||||
|
||||
/// Leitet (Menuetext, Benachrichtigungstext) aus Client- und Server-Version
|
||||
/// ab: eine neue Basis X.Y.Z wird als Version genannt (auch bei Beta), bei
|
||||
/// gleicher Basis der Beta-Stempel.
|
||||
@@ -321,14 +413,57 @@ async fn check_for_update(
|
||||
.await
|
||||
}
|
||||
|
||||
/// Meldet eine fehlgeschlagene Update-Pruefung: Menuetext mit Grund, Eintrag
|
||||
/// wieder anklickbar (Klick = erneut pruefen), Benachrichtigung nur, wenn
|
||||
/// der Text vom zuletzt gemeldeten abweicht (`LastCheckNotice`, T-FRG-02).
|
||||
fn report_check_failure(app: &AppHandle, update_item: &MenuItem<Wry>, status: Option<u16>) {
|
||||
let (menu_text, body) = check_failure_labels(status);
|
||||
let _ = update_item.set_text(&menu_text);
|
||||
let _ = update_item.set_enabled(true);
|
||||
let changed = app
|
||||
.state::<LastCheckNotice>()
|
||||
.0
|
||||
.lock()
|
||||
.map(|mut last| {
|
||||
if *last == body {
|
||||
false
|
||||
} else {
|
||||
*last = body.clone();
|
||||
true
|
||||
}
|
||||
})
|
||||
.unwrap_or(true);
|
||||
if changed {
|
||||
let _ = app
|
||||
.notification()
|
||||
.builder()
|
||||
.title("Tessera-Update")
|
||||
.body(body)
|
||||
.show();
|
||||
}
|
||||
}
|
||||
|
||||
/// Leert den zuletzt gemeldeten Fehlertext -- nach einer gelungenen Pruefung
|
||||
/// soll ein spaeterer Fehler wieder gemeldet werden.
|
||||
fn clear_check_notice(app: &AppHandle) {
|
||||
if let Ok(mut last) = app.state::<LastCheckNotice>().0.lock() {
|
||||
last.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Versionsprüfung, aus `setup` herausgezogen, damit sie nach einem
|
||||
/// Serverwechsel erneut laufen kann (async, non-blocking wie bisher). Setzt
|
||||
/// den Update-Eintrag zuerst auf den Standardtext zurück und leert den
|
||||
/// abgelegten Update-Stand -- nach einem Wechsel darf kein Hinweis des alten
|
||||
/// Servers stehen bleiben.
|
||||
/// Serverwechsel, beim Klick auf den Eintrag und alle
|
||||
/// `UPDATE_CHECK_INTERVAL` erneut laufen kann (async, non-blocking wie
|
||||
/// bisher). Setzt den Update-Eintrag zuerst auf "Suche nach Updates…"
|
||||
/// (gesperrt) und leert den abgelegten Update-Stand -- nach einem Wechsel
|
||||
/// darf kein Hinweis des alten Servers stehen bleiben. Jeder Ausgang der
|
||||
/// Pruefung endet in einem sichtbaren, anklickbaren Zustand (Update gefunden
|
||||
/// / kein Update / fehlgeschlagen mit Grund); nur der http-Fall bleibt
|
||||
/// gesperrt. Ein fehlgeschlagener Check war bisher vom Zustand "kein Update"
|
||||
/// nicht unterscheidbar (quick-260922-frg).
|
||||
fn spawn_version_check(app: AppHandle, server_url: String) {
|
||||
let update_item = app.state::<TrayItems>().update.clone();
|
||||
let _ = update_item.set_text(UPDATE_ITEM_DEFAULT);
|
||||
let _ = update_item.set_text(UPDATE_ITEM_CHECKING);
|
||||
let _ = update_item.set_enabled(false);
|
||||
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
|
||||
*pending = None;
|
||||
@@ -365,8 +500,13 @@ fn spawn_version_check(app: AppHandle, server_url: String) {
|
||||
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
|
||||
*pending = Some((update, menu_text));
|
||||
}
|
||||
clear_check_notice(&app);
|
||||
}
|
||||
Ok(None) => {
|
||||
let _ = update_item.set_text(UPDATE_ITEM_NONE);
|
||||
let _ = update_item.set_enabled(true);
|
||||
clear_check_notice(&app);
|
||||
}
|
||||
Ok(None) => {}
|
||||
// Der Release-Bau verweigert http-Endpunkte (config.rs
|
||||
// `validate_endpoints`), der Debug-Bau warnt nur. Keine
|
||||
// `dangerousInsecureTransportProtocol`-Freigabe (T-KGC-06): der
|
||||
@@ -374,15 +514,33 @@ fn spawn_version_check(app: AppHandle, server_url: String) {
|
||||
Err(tauri_plugin_updater::Error::InsecureTransportProtocol) => {
|
||||
let _ = update_item.set_text(UPDATE_ITEM_INSECURE);
|
||||
}
|
||||
Err(_) => {}
|
||||
// Der Server hat geantwortet, aber nicht mit 2xx/204: das Plugin
|
||||
// verschluckt den Statuscode (updater.rs Z. 529-559). Dieselbe
|
||||
// Anfrage einmal selbst stellen und nur den Status lesen -- so
|
||||
// steht im Eintrag "HTTP 401", wenn ein Passwortschutz am Proxy
|
||||
// die Anfrage abweist (Befund 22.09.2026).
|
||||
Err(tauri_plugin_updater::Error::ReleaseNotFound) => {
|
||||
let status = probe_update_status(diagnostic_update_url(
|
||||
&server_url,
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
))
|
||||
.await;
|
||||
report_check_failure(&app, &update_item, status);
|
||||
}
|
||||
// Keine Verbindung, TLS, Timeout (Reqwest/Network/Io) oder
|
||||
// unlesbares Manifest: keine zweite Anfrage, Status unbekannt.
|
||||
Err(_) => report_check_failure(&app, &update_item, None),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Oeffnet die Seite Einstellungen → Desktop-App im System-Browser -- der
|
||||
/// bisherige Weg des Tray-Eintrags, jetzt Rueckfall, wenn kein Update
|
||||
/// abgelegt ist oder die Installation in der App fehlschlaegt. Bewusst ohne
|
||||
/// `desktop=1` (siehe `with_desktop_marker`).
|
||||
/// bisherige Weg des Tray-Eintrags, seit quick-260922-frg NUR noch Rueckfall,
|
||||
/// wenn die Installation in der App fehlschlaegt (ein Klick ohne abgelegtes
|
||||
/// Update prueft stattdessen erneut). Bewusst ohne `desktop=1` (siehe
|
||||
/// `with_desktop_marker`).
|
||||
fn open_download_page(app: &AppHandle) {
|
||||
if let Some(server) = stored_server_url(app) {
|
||||
let target = format!("{}/settings/general/desktop", server.trim_end_matches('/'));
|
||||
@@ -555,6 +713,39 @@ pub fn run() {
|
||||
get_client_info
|
||||
])
|
||||
.setup(|app| {
|
||||
// Hauptfenster im Code statt in tauri.conf.json (`app.windows` ist
|
||||
// leer), weil nur der Builder einen Download-Handler annimmt. Ohne
|
||||
// Handler verpufft ein Klick auf "Herunterladen" (Einstellungen ->
|
||||
// Desktop-App) in der Webansicht: webkit2gtk verwirft den Download
|
||||
// still, WebView2 zeigte ebenfalls nichts (Nutzer, 22.09.2026,
|
||||
// Windows und Linux). Der Handler bricht den Download in der App ab
|
||||
// und reicht die Adresse an den System-Browser weiter -- dort gibt
|
||||
// es Fortschritt, Speicherort und ein Passwortfenster fuer einen
|
||||
// vorgeschalteten Proxy. Masse/Zentrierung wie bisher in der
|
||||
// Konfiguration; Fensterzustand stellt tauri_plugin_window_state
|
||||
// weiterhin her.
|
||||
tauri::WebviewWindowBuilder::new(
|
||||
app,
|
||||
"main",
|
||||
tauri::WebviewUrl::App("setup.html".into()),
|
||||
)
|
||||
.title("Tessera")
|
||||
.inner_size(1280.0, 800.0)
|
||||
.center()
|
||||
.decorations(true)
|
||||
.resizable(true)
|
||||
.on_download(|webview, event| {
|
||||
if let tauri::webview::DownloadEvent::Requested { url, .. } = event {
|
||||
let _ = webview
|
||||
.app_handle()
|
||||
.opener()
|
||||
.open_url(url.to_string(), None::<&str>);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
})
|
||||
.build()?;
|
||||
|
||||
let server_url = stored_server_url(app.handle());
|
||||
|
||||
if let Some(url) = &server_url {
|
||||
@@ -566,13 +757,16 @@ pub fn run() {
|
||||
}
|
||||
|
||||
// Tray menu: Verbunden mit ... (gesperrt) · — · Öffnen ·
|
||||
// Server-Adresse ändern... · Update installieren · — ·
|
||||
// Server-Adresse ändern... · Suche nach Updates… · — ·
|
||||
// Autostart-Haken · — · Beenden.
|
||||
// "connected" ist nicht anklickbar; "update" bleibt gesperrt, bis
|
||||
// die Versionspruefung eine neuere Version findet, und heisst dann
|
||||
// "Auf Version X.Y.Z aktualisieren" bzw. "Auf Beta-Stand ...
|
||||
// aktualisieren"; "autostart" spiegelt den tatsaechlichen
|
||||
// Systemzustand beim Start.
|
||||
// "connected" ist nicht anklickbar; "update" startet gesperrt als
|
||||
// "Suche nach Updates…", weil die Erstpruefung unten sofort
|
||||
// anlaeuft, und heisst danach "Auf Version X.Y.Z aktualisieren"
|
||||
// bzw. "Auf Beta-Stand ... aktualisieren", "Kein Update verfügbar
|
||||
// – erneut prüfen" oder "Update-Prüfung fehlgeschlagen (...) –
|
||||
// erneut prüfen" (alle anklickbar). Ohne gespeicherte Adresse
|
||||
// "Kein Update verfügbar – erneut prüfen" (Klick tut nichts);
|
||||
// "autostart" spiegelt den tatsaechlichen Systemzustand beim Start.
|
||||
let connected =
|
||||
MenuItemBuilder::with_id("connected", tray_labels(server_url.as_deref()).1)
|
||||
.enabled(false)
|
||||
@@ -580,8 +774,13 @@ pub fn run() {
|
||||
let open = MenuItemBuilder::with_id("open", "Öffnen").build(app)?;
|
||||
let change_server =
|
||||
MenuItemBuilder::with_id("change_server", "Server-Adresse ändern…").build(app)?;
|
||||
let update = MenuItemBuilder::with_id("update", UPDATE_ITEM_DEFAULT)
|
||||
.enabled(false)
|
||||
let update_text = if server_url.is_some() {
|
||||
UPDATE_ITEM_CHECKING
|
||||
} else {
|
||||
UPDATE_ITEM_NONE
|
||||
};
|
||||
let update = MenuItemBuilder::with_id("update", update_text)
|
||||
.enabled(server_url.is_none())
|
||||
.build(app)?;
|
||||
let autostart_label = if cfg!(target_os = "windows") {
|
||||
"Mit Windows starten"
|
||||
@@ -610,6 +809,7 @@ pub fn run() {
|
||||
update: update.clone(),
|
||||
});
|
||||
app.manage(PendingUpdate(Mutex::new(None)));
|
||||
app.manage(LastCheckNotice(Mutex::new(String::new())));
|
||||
|
||||
let autostart_for_menu = autostart.clone();
|
||||
|
||||
@@ -637,8 +837,12 @@ pub fn run() {
|
||||
}
|
||||
"update" => {
|
||||
// `take()` verhindert Doppelklick-Downloads, der
|
||||
// gesperrte Eintrag ebenso; ohne abgelegten Stand
|
||||
// bleibt der Browser-Weg (Einstellungen → Desktop-App).
|
||||
// gesperrte Eintrag ebenso. Ohne abgelegten Stand
|
||||
// ("Kein Update verfügbar" / "Update-Prüfung
|
||||
// fehlgeschlagen") prueft der Klick erneut -- der
|
||||
// Browser-Weg (`open_download_page`) ist nur noch
|
||||
// Rueckfall einer fehlgeschlagenen Installation.
|
||||
// Ohne gespeicherte Adresse gibt es nichts zu pruefen.
|
||||
let pending = app
|
||||
.state::<PendingUpdate>()
|
||||
.0
|
||||
@@ -649,7 +853,11 @@ pub fn run() {
|
||||
Some((update, menu_text)) => {
|
||||
spawn_update_install(app.clone(), update, menu_text)
|
||||
}
|
||||
None => open_download_page(app),
|
||||
None => {
|
||||
if let Some(url) = stored_server_url(app) {
|
||||
spawn_version_check(app.clone(), url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"autostart" => {
|
||||
@@ -696,6 +904,34 @@ pub fn run() {
|
||||
spawn_version_check(app.handle().clone(), url);
|
||||
}
|
||||
|
||||
// Wiederholte Pruefung alle UPDATE_CHECK_INTERVAL. Bewusst ein
|
||||
// schlichter Betriebssystem-Thread mit `sleep` statt
|
||||
// `tokio::time`: das Tauri-Async-Runtime braeuchte dafuer das
|
||||
// `time`-Feature bzw. ein eigenes Crate, und ein schlafender
|
||||
// Thread kostet nichts. Je Durchlauf wird die Adresse frisch
|
||||
// gelesen (Serverwechsel zaehlt). Haelt `PendingUpdate` bereits
|
||||
// ein Update, wird der Durchlauf uebersprungen: die Pruefung
|
||||
// wuerde den Stand leeren und "Neuer Beta-Stand" erneut melden.
|
||||
// Waehrend einer Installation ist der Stand per `take()` leer;
|
||||
// faellt die 4-h-Marke genau in den Download, wird nur der
|
||||
// Fortschrittstext ueberschrieben (T-FRG-04, akzeptiert).
|
||||
let handle = app.handle().clone();
|
||||
std::thread::spawn(move || loop {
|
||||
std::thread::sleep(UPDATE_CHECK_INTERVAL);
|
||||
let pending = handle
|
||||
.state::<PendingUpdate>()
|
||||
.0
|
||||
.lock()
|
||||
.map(|guard| guard.is_some())
|
||||
.unwrap_or(false);
|
||||
if pending {
|
||||
continue;
|
||||
}
|
||||
if let Some(url) = stored_server_url(&handle) {
|
||||
spawn_version_check(handle.clone(), url);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
@@ -1008,4 +1244,63 @@ mod tests {
|
||||
Err("Diese Adresse ist ungültig.".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_failure_labels_401_nennt_proxy_passwortschutz() {
|
||||
let (menu_text, body) = check_failure_labels(Some(401));
|
||||
assert!(menu_text.contains("HTTP 401"));
|
||||
assert!(menu_text.ends_with("– erneut prüfen"));
|
||||
assert!(body.contains("HTTP 401"));
|
||||
assert!(body.contains("Passwortschutz"));
|
||||
assert!(body.contains("Zugriffsliste"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_failure_labels_403_gleiche_erklaerung() {
|
||||
let (menu_text, body) = check_failure_labels(Some(403));
|
||||
assert!(menu_text.contains("HTTP 403"));
|
||||
assert!(body.contains("HTTP 403"));
|
||||
assert!(body.contains("Passwortschutz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_failure_labels_anderer_status_ohne_proxy_erklaerung() {
|
||||
let (menu_text, body) = check_failure_labels(Some(502));
|
||||
assert!(menu_text.contains("HTTP 502"));
|
||||
assert!(menu_text.ends_with("– erneut prüfen"));
|
||||
assert!(!body.contains("Passwortschutz"));
|
||||
assert!(body.contains("statt mit Paketdaten"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_failure_labels_ohne_status_keine_verbindung() {
|
||||
let (menu_text, body) = check_failure_labels(None);
|
||||
assert!(menu_text.contains("keine Verbindung"));
|
||||
assert!(menu_text.ends_with("– erneut prüfen"));
|
||||
assert!(body.contains("vier Stunden"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostic_update_url_ersetzt_platzhalter_und_kodiert_base() {
|
||||
assert_eq!(
|
||||
diagnostic_update_url("https://alpha.example", "windows", "x86_64", "1.2.0"),
|
||||
"https://alpha.example/api-proxy/desktop/update?target=windows&arch=x86_64¤t=1.2.0&base=https%3A%2F%2Falpha.example"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostic_update_url_trimmt_schlussstrich() {
|
||||
assert_eq!(
|
||||
diagnostic_update_url("https://alpha.example/", "linux", "x86_64", "1.2.0"),
|
||||
"https://alpha.example/api-proxy/desktop/update?target=linux&arch=x86_64¤t=1.2.0&base=https%3A%2F%2Falpha.example"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_item_konstanten() {
|
||||
assert_eq!(UPDATE_ITEM_CHECKING, "Suche nach Updates…");
|
||||
assert_eq!(UPDATE_ITEM_NONE, "Kein Update verfügbar – erneut prüfen");
|
||||
assert_eq!(UPDATE_ITEM_INSECURE, "Update nur über https möglich");
|
||||
assert_eq!(UPDATE_CHECK_INTERVAL, Duration::from_secs(4 * 3600));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,18 +9,7 @@
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Tessera",
|
||||
"url": "setup.html",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"center": true,
|
||||
"decorations": true,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"windows": [],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'"
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"lint": "biome lint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@tessera/shared": "workspace:*",
|
||||
"@uiw/react-md-editor": "4.1.1",
|
||||
"fflate": "^0.8.3",
|
||||
"html-to-image": "1.11.13",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user