Compare commits
95 Commits
v1.4.0
...
645c5e5887
| Author | SHA1 | Date | |
|---|---|---|---|
| 645c5e5887 | |||
| 2dd11b439d | |||
| 59b8cd43fc | |||
| 5919a55cbf | |||
| 7edaf8c00b | |||
| 61a971ccc0 | |||
| 471cfbf98b | |||
| a257bc3f86 | |||
| 714f731ac9 | |||
| e10da76259 | |||
| 31d514b7ca | |||
| 52f538c432 | |||
| 32441d77c7 | |||
| 0b34e82b21 | |||
| af78157536 | |||
| 7e70fc4d32 | |||
| f78b422abf | |||
| c846eb49cb | |||
| af87c2c112 | |||
| bd73fa5e74 | |||
| 86ad95f74e | |||
| 12214a948e | |||
| 071082983b | |||
| c2e4467dd8 | |||
| be1e0035e0 | |||
| f7213f5e45 | |||
| e72814abd9 | |||
| 0e72ad45f8 | |||
| b15c74632b | |||
| 7188c5b958 | |||
| 8c644de5da | |||
| 6879c756f2 | |||
| 709b41a007 | |||
| 325c5ddbf2 | |||
| cd1f8f6cda | |||
| 41d00a3623 | |||
| d0e649baa1 | |||
| cbc89d9810 | |||
| 3cb43d6cc0 | |||
| 4ff9a239fd | |||
| 00c2cfe2c9 | |||
| cd4b5b56ee | |||
| b15a43f3d3 | |||
| 8f41bd26bd | |||
| ee97b4ed9f | |||
| bc4c0119de | |||
| c703d87a1c | |||
| 76a923450f | |||
| a435a30c34 | |||
| 46ebb4e7ce | |||
| 9c9e1420fe | |||
| 97744b59cd | |||
| acd3c7a05f | |||
| b9c05791b2 | |||
| 0751198822 | |||
| c0b145a9b0 | |||
| bc260100f6 | |||
| e48c0de238 | |||
| e7fc4de430 | |||
| b9d87be360 | |||
| 643b1a2caa | |||
| 12eea333ba | |||
| bdaa3c8db5 | |||
| 03026d616f | |||
| 105b66ed8d | |||
| cb45d2663a | |||
| 69d17302e1 | |||
| 0aaa15240d | |||
| 76f6d87973 | |||
| 9fa0a3f498 | |||
| 76d17fe3c9 | |||
| a8a910fd29 | |||
| 8440db8c6e | |||
| 8532b63097 | |||
| b06a2d1066 | |||
| 228112014c | |||
| 76520b2010 | |||
| 30e4bc3a4f | |||
| 219038b3ee | |||
| 522cdfd368 | |||
| b80db49c5e | |||
| 7192be223a | |||
| 3c62a1a29c | |||
| 3fc33e3507 | |||
| acfffa3097 | |||
| 59e9c34fa7 | |||
| b3b7b5d5e5 | |||
| 2aeb3e8ce3 | |||
| 4fa5aafc54 | |||
| 5ae9aaa0d6 | |||
| 25c8db746a | |||
| 187fb76c91 | |||
| cf7784e409 | |||
| 59db32a01f | |||
| b35edd5f31 |
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
context: default
|
||||||
|
phase: quick-auftraege-1.9.x (keine GSD-Phase)
|
||||||
|
task: 0
|
||||||
|
total_tasks: 0
|
||||||
|
status: paused
|
||||||
|
last_updated: 2026-09-30T18:00:00.000Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# BLOCKING CONSTRAINTS — Read Before Anything Else
|
||||||
|
|
||||||
|
- [ ] CONSTRAINT: live NICHT pushen/taggen, bis der User es verlangt.
|
||||||
|
- [ ] CONSTRAINT: Gebündelt pushen, nicht nach jeder Kleinigkeit.
|
||||||
|
- [ ] CONSTRAINT: Browser-Prüfungen im Dunkelmodus.
|
||||||
|
- [ ] CONSTRAINT: Nie Wichtiges (Logo, Text) nur in Mailbilder packen – OWA zeigt eingebettete Bilder nicht.
|
||||||
|
|
||||||
|
<current_state>
|
||||||
|
main = live = v1.9.0 (a257bc3), CI + Release grün. alpha zuletzt 714f731 (Inhalt identisch). Arbeitsbaum sauber.
|
||||||
|
</current_state>
|
||||||
|
|
||||||
|
<completed_work>
|
||||||
|
- 30.09.: Windows-Test bestanden, Review seit 26.09. + Fixes, v1.8.0.
|
||||||
|
- Dashboard-Skalierung (nie scrollen), eigene Module Keep-Alive, Favoriten enger.
|
||||||
|
- Sicherheit: Rolle/Aktiv-Status je Anfrage aus DB; /login-Weiterleitung.
|
||||||
|
- Willkommensmail + eigene Vorlage (Platzhalter, Vorschau, Testmail, Anmeldehinweise), Spalte Letzte Anmeldung; v1.9.0.
|
||||||
|
</completed_work>
|
||||||
|
|
||||||
|
<remaining_work>
|
||||||
|
- User: live auf 1.9.0 ziehen (df -h / vorher), Willkommensmail in OWA prüfen.
|
||||||
|
</remaining_work>
|
||||||
|
|
||||||
|
<next_action>
|
||||||
|
Nachfragen, ob live gezogen und OWA ok; sonst neuen Auftrag abwarten.
|
||||||
|
</next_action>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"timestamp": "2026-09-30T18:00:00.000Z",
|
||||||
|
"phase": null,
|
||||||
|
"phase_name": "Quick-Auftraege nach Freigabe 1.9.0 (keine GSD-Phase)",
|
||||||
|
"phase_dir": null,
|
||||||
|
"plan": null,
|
||||||
|
"task": 0,
|
||||||
|
"total_tasks": 0,
|
||||||
|
"status": "paused",
|
||||||
|
"completed_tasks": [
|
||||||
|
{"id": 1, "name": "30.09.: Windows-Test (Tray-Update + Erinnerungs-Toast) bestanden; Review aller Aenderungen seit 26.09. + Fixes; Freigabe 1.8.0 (af78157)", "status": "done"},
|
||||||
|
{"id": 2, "name": "Dashboard 1:1-Skalierung (__canvas, passt Inhalt ein = nie scrollen), eigene Module Keep-Alive (max 5), Favoriten-Kachelansicht enger + lange Namen klein/zweizeilig", "status": "done"},
|
||||||
|
{"id": 3, "name": "Sicherheit: JwtStrategy liest Rolle/isActive/mustChangePassword je Anfrage aus DB; /login leitet Angemeldete aufs Dashboard", "status": "done"},
|
||||||
|
{"id": 4, "name": "Willkommensmail (Briefsymbol Benutzerliste, jederzeit an jeden) + Spalte Letzte Anmeldung + eigene Vorlage je Mandant (Admin -> Willkommensmail, Platzhalter, Vorschau, Testmail, Anmeldehinweise editierbar); Freigabe 1.9.0 (a257bc3)", "status": "done"}
|
||||||
|
],
|
||||||
|
"remaining_tasks": [],
|
||||||
|
"blockers": [],
|
||||||
|
"async_jobs": [],
|
||||||
|
"human_actions_pending": [
|
||||||
|
{"action": "Live-Server auf v1.9.0 ziehen (vorher df -h /, ggf. docker image prune -f, nie -a)", "context": "CI 11 Laeufe gruen, Release Tessera 1.9.0 mit Setup.exe + AppImage", "blocking": false},
|
||||||
|
{"action": "Willkommensmail in OWA (owa.ctl.de) pruefen: dunkler Kopf ohne weisse Luecke", "context": "OWA zeigt CID-Bilder nicht, Outlook-Programm schon", "blocking": false}
|
||||||
|
],
|
||||||
|
"decisions": [
|
||||||
|
{"decision": "Dashboard: alles mitskalieren inkl. Schrift, nie scrollen; Leinwand = Flaeche beim ersten Oeffnen je Reiter", "rationale": "AskUserQuestion 30.09.", "phase": "quick"},
|
||||||
|
{"decision": "Keine naechtliche Docker-Aufraeumung auf alpha", "rationale": "User 30.09.: passt so", "phase": "quick"},
|
||||||
|
{"decision": "Willkommensmail jederzeit an jeden Benutzer; Link Passwort festlegen 7 Tage", "rationale": "User 30.09.", "phase": "quick"},
|
||||||
|
{"decision": "PMG ohne API-Token (Proxmox kennt keine), eigener Auditor-Benutzer; Anleitung im Admin-Handbuch", "rationale": "Proxmox Bugzilla 5849 offen", "phase": "quick"}
|
||||||
|
],
|
||||||
|
"uncommitted_files": [],
|
||||||
|
"next_action": "Nichts offen von Claudes Seite. Beim Start: fragen, ob live auf 1.9.0 gezogen ist und ob die Willkommensmail in OWA passt; sonst neuen Auftrag abwarten.",
|
||||||
|
"context_notes": "main = live = v1.9.0 (a257bc3). alpha lief zuletzt auf 714f731 (= Inhalt 1.9.0). Lokaler Stack aus 714f731 gebaut. Test-Postfach MailHog nur bei Bedarf: docker run -d --rm --name mailhog --network tessera-ctl_backend-net --network-alias mailhog -p 127.0.0.1:8025:8025 mailhog/mailhog. Diagnose auf alpha ohne Passwort: JWT im api-Container mit crypto + process.env.JWT_SECRET signieren (nur lesend, kurzlebig)."
|
||||||
|
}
|
||||||
+15
-4
@@ -6,8 +6,8 @@ current_phase_name: desktop-client-fertigstellen
|
|||||||
status: verified
|
status: verified
|
||||||
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
|
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
|
||||||
last_updated: "2026-09-23T15:30:00.000Z"
|
last_updated: "2026-09-23T15:30:00.000Z"
|
||||||
last_activity: 2026-09-23
|
last_activity: 2026-10-01
|
||||||
last_activity_desc: Quick 260924-m4n — flackernder Test entschaerft, alte DashboardImage-Spalte entfernt (mit Schutzklausel); 1.4.0 vom Nutzer ausdruecklich NICHT freigegeben
|
last_activity_desc: Quick 260928-ujj — Design Mosaik uebernommen, Hintergrund pro Benutzer in der DB; Freigabe 1.5.0
|
||||||
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
|
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
|
||||||
progress:
|
progress:
|
||||||
total_phases: 18
|
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)
|
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
|
||||||
Plan: 6 of 6
|
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
|
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
|
||||||
Last activity: 2026-09-22 - Quick 260922-ge2: XFrame-Ausschnitt waehlen und einpassen, Zoom, Nur anzeigen (Browser-Befund Rahmenhoehe behoben); davor Desktop: Download-Knoepfe in der App oeffnen jetzt den System-Browser (fast, 747a4d4); davor Quick 260922-frg: Tray-Update-Eintrag nennt den Grund einer fehlgeschlagenen Pruefung (HTTP 401 durch Passwortschutz am Proxy vor alpha), Klick prueft erneut, Pruefung alle 4 h; davor Kosmetik am Bilderrahmen (fast, 8b45a28): „1 Stunde“ statt „60 Minuten“, Bildanzahl in der Einstellungs-Kopfzeile; am 21.09. davor Quick 260921-pi9 und 260921-qd3: die zwei bestellten Dashboard-Widgets „Bilderrahmen“ und „XFrame“ gebaut, im Browser nachgewiesen, gepusht
|
Last activity: 2026-09-30 - v1.9.0 freigegeben (Willkommensmail + Vorlage, Rollen je Anfrage aus DB, Dashboard-Skalierung, Keep-Alive eigene Module); pausiert mit HANDOFF
|
||||||
|
|
||||||
Progress: [██████████] 99%
|
Progress: [██████████] 99%
|
||||||
|
|
||||||
@@ -475,6 +475,17 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
|
|||||||
| 260924-h7x | **Proxmox-Seite neu gestaltet (Status bestimmt das Bild) und Dashboard-Reiter in die Kopfzeile.** Nutzer hob am 24.09. die Umbausperre vom 23.09. selbst auf. Design-Plan aus dem frontend-design-Skill: Statusfarben als OKLCH-Tokens (`--status-ok/warn/down/idle/orphan`, dazu `-fg`-Textvarianten fuer 4,5:1), Gesundheitsbalken mit Legende, Karten mit Statusleiste links und im Statuston getoentem Schatten, eingelassene Messfelder, Knoten als Einschuebe mit Balken nach Schwellen (80/92 %, Sicherung > 26 h), PMG-Zahlfelder. **Deaktivierter Server = „Offline & verwaist“** (Vorrang vor allem, keine alten Werte, gestrichelt). Sortierung down/warn/ok/idle/orphan, Spaltenfluss statt Raster. Reiter als eingelassener Umschalter per Portal in der Kopfzeilenmitte (`header-center-slot`), eigene Zeile entfallen, Pfeiltasten, weiche Randausblendung bei Ueberlauf; unter 640 px Logo nur Bildmarke. Browser: hell/dunkel 1400 px, 390 px ohne Ueberlauf. web 789 gruen. | 2026-09-24 | 0fa7ce0,57c338f,7416a92,0b659d6,57a4196 | [260924-h7x-proxmox-seite-status-design-und-dashboar](./quick/260924-h7x-proxmox-seite-status-design-und-dashboar/) |
|
| 260924-h7x | **Proxmox-Seite neu gestaltet (Status bestimmt das Bild) und Dashboard-Reiter in die Kopfzeile.** Nutzer hob am 24.09. die Umbausperre vom 23.09. selbst auf. Design-Plan aus dem frontend-design-Skill: Statusfarben als OKLCH-Tokens (`--status-ok/warn/down/idle/orphan`, dazu `-fg`-Textvarianten fuer 4,5:1), Gesundheitsbalken mit Legende, Karten mit Statusleiste links und im Statuston getoentem Schatten, eingelassene Messfelder, Knoten als Einschuebe mit Balken nach Schwellen (80/92 %, Sicherung > 26 h), PMG-Zahlfelder. **Deaktivierter Server = „Offline & verwaist“** (Vorrang vor allem, keine alten Werte, gestrichelt). Sortierung down/warn/ok/idle/orphan, Spaltenfluss statt Raster. Reiter als eingelassener Umschalter per Portal in der Kopfzeilenmitte (`header-center-slot`), eigene Zeile entfallen, Pfeiltasten, weiche Randausblendung bei Ueberlauf; unter 640 px Logo nur Bildmarke. Browser: hell/dunkel 1400 px, 390 px ohne Ueberlauf. web 789 gruen. | 2026-09-24 | 0fa7ce0,57c338f,7416a92,0b659d6,57a4196 | [260924-h7x-proxmox-seite-status-design-und-dashboar](./quick/260924-h7x-proxmox-seite-status-design-und-dashboar/) |
|
||||||
| 260924-i8v | **Proxmox-Kachel fuers Dashboard.** Modul-Kachel ueber den Weg aus 260922-m1h (Typ `proxmox` in packages/shared + Modulbindung, API-Freigabeliste, Registry, Katalog), nur fuer Benutzer mit Modulzugriff. Kompakter Gesundheitsbalken + Zusammenfassung in Worten, Serverliste nach Dringlichkeit mit je einer Kennzahl (Gaeste/Auslastung, aelteste Sicherung, eingehende Mails, unbekannt nie 0), Links auf /modules/proxmox (nicht im Bearbeitungsmodus), liest jede Minute den Zwischenstand (pausiert bei verborgenem Tab, loest NIE eine Abfrage aus), Titel + Serverauswahl an der Kachel und unter Einstellungen > Dashboard, Groessenstufen per Container-Query. Gemeinsame Teile nach `components/proxmox/` verschoben. Browser: Katalog, Kachel hell/dunkel, schmale Stufe (nur Punkte+Namen). web 864, api 1370 gruen. | 2026-09-24 | a906c67,92bf130,a217d60,377b6e3,586da44,602a45c | [260924-i8v-proxmox-kachel-fuers-dashboard](./quick/260924-i8v-proxmox-kachel-fuers-dashboard/) |
|
| 260924-i8v | **Proxmox-Kachel fuers Dashboard.** Modul-Kachel ueber den Weg aus 260922-m1h (Typ `proxmox` in packages/shared + Modulbindung, API-Freigabeliste, Registry, Katalog), nur fuer Benutzer mit Modulzugriff. Kompakter Gesundheitsbalken + Zusammenfassung in Worten, Serverliste nach Dringlichkeit mit je einer Kennzahl (Gaeste/Auslastung, aelteste Sicherung, eingehende Mails, unbekannt nie 0), Links auf /modules/proxmox (nicht im Bearbeitungsmodus), liest jede Minute den Zwischenstand (pausiert bei verborgenem Tab, loest NIE eine Abfrage aus), Titel + Serverauswahl an der Kachel und unter Einstellungen > Dashboard, Groessenstufen per Container-Query. Gemeinsame Teile nach `components/proxmox/` verschoben. Browser: Katalog, Kachel hell/dunkel, schmale Stufe (nur Punkte+Namen). web 864, api 1370 gruen. | 2026-09-24 | a906c67,92bf130,a217d60,377b6e3,586da44,602a45c | [260924-i8v-proxmox-kachel-fuers-dashboard](./quick/260924-i8v-proxmox-kachel-fuers-dashboard/) |
|
||||||
| 260924-m4n | **Flackernden Test entschaerft, alte Bildspalte entfernt.** (1) `tenant-selector.test.tsx`: Ursache war das Laden der Bausteine INNERHALB des ersten Tests (zaehlte in dessen 5-s-Grenze) -> Import vorab, Doppelfall getrennt, dasselbe in zwei weiteren Marktplatz-Tests; langsamster Web-Test jetzt < 2 s (mit 2 Kernen 1,3 s); act()-Warnungen der Proxmox-Kachel weg. (2) DashboardImage Stufe 2: Migration `20260924120000_dashboard_image_drop_data` mit Schutz (bricht ab, wenn noch Zeilen ohne `storagePath`; Zeilenschutz fuer die Pruefung abgeschaltet, sonst saehe sie still 0), `storagePath` NOT NULL, `data` weg, `system_read_policy` weg, Bootstrap-Umzug + `forSystem()` entfernt, Upload legt Zeile gleich mit Pfad an. Vorbedingung alpha geprueft (0 von 3 ohne Pfad); Live nicht pruefbar. Rueckweg bei Abbruch in `docs/anleitung-betrieb.md` Kap. 4. Browser/API: Bilder laden, Upload+Anzeige+Loeschen ok. api 1364, web 865 gruen. | 2026-09-24 | b10734f,dd54ec5 | [260924-m4n-flackernden-test-entschaerfen-und-dashbo](./quick/260924-m4n-flackernden-test-entschaerfen-und-dashbo/) |
|
| 260924-m4n | **Flackernden Test entschaerft, alte Bildspalte entfernt.** (1) `tenant-selector.test.tsx`: Ursache war das Laden der Bausteine INNERHALB des ersten Tests (zaehlte in dessen 5-s-Grenze) -> Import vorab, Doppelfall getrennt, dasselbe in zwei weiteren Marktplatz-Tests; langsamster Web-Test jetzt < 2 s (mit 2 Kernen 1,3 s); act()-Warnungen der Proxmox-Kachel weg. (2) DashboardImage Stufe 2: Migration `20260924120000_dashboard_image_drop_data` mit Schutz (bricht ab, wenn noch Zeilen ohne `storagePath`; Zeilenschutz fuer die Pruefung abgeschaltet, sonst saehe sie still 0), `storagePath` NOT NULL, `data` weg, `system_read_policy` weg, Bootstrap-Umzug + `forSystem()` entfernt, Upload legt Zeile gleich mit Pfad an. Vorbedingung alpha geprueft (0 von 3 ohne Pfad); Live nicht pruefbar. Rueckweg bei Abbruch in `docs/anleitung-betrieb.md` Kap. 4. Browser/API: Bilder laden, Upload+Anzeige+Loeschen ok. api 1364, web 865 gruen. | 2026-09-24 | b10734f,dd54ec5 | [260924-m4n-flackernden-test-entschaerfen-und-dashbo](./quick/260924-m4n-flackernden-test-entschaerfen-und-dashbo/) |
|
||||||
|
| 260925-bow | **Was-ist-neu-Fenster nach Versionswechsel.** Spalte `User.lastSeenReleaseVersion` (Migration 20260925120000), Versionsnummer allein aus der API (`GET /users/me/release-notice`, Semver-Funktionen in packages/shared), Fenster im Portal-Rahmen einmal nach Versionswechsel, gemerkt erst beim Schliessen (`POST`), nur freigegebene Versionen (`dev` nie), hoechstens 3 Versionen + Hinweis auf aeltere + Link /changelog; neue Konten bekommen die laufende Version eingetragen; vorhandene ohne Stand sehen nur die aktuelle. Changelog-Text bleibt serverseitig. Browser: 1.3.0 -> Fenster 1.4.0, Verstanden merkt 1.4.0, kein zweites Mal; 1.0.0 -> 1.4.0/1.3.1/1.3.0 + „2 aelteren Versionen“; Link-Kontrast nachgebessert. api 1435, web 924 gruen. | 2026-09-25 | 59db32a,187fb76,5ae9aaa,b3b7b5d | [260925-bow-was-ist-neu-fenster-beim-ersten-anmelden](./quick/260925-bow-was-ist-neu-fenster-beim-ersten-anmelden/) |
|
||||||
|
| 260928-ujj | **Design Mosaik uebernommen + Hintergrund pro Benutzer.** Merge design/mosaik (76d17fe, inkl. RESIZE_AXIS_FALLBACK), Spalte `User.dashboardBackground` JSONB (Migration 20260928120000), `PATCH /users/me/dashboard-background` mit `parseDashboardBackground` aus packages/shared (Preset-Liste, imageId nur UUID), Web liest aus Sitzung, alte localStorage-Wahl einmalig uebernommen. Browser: Duenen gewaehlt, DB-Zeile gesetzt, nach localStorage-Loeschen weiter sichtbar. api 1462, web 952 gruen; Freigabe als 1.5.0. | 2026-09-28 | 9fa0a3f,0aaa152,cb45d26 | [260928-ujj-design-mosaik-uebernehmen-und-als-1-5-0-](./quick/260928-ujj-design-mosaik-uebernehmen-und-als-1-5-0-/) |
|
||||||
|
| 260929-9wc | **Eigene Module (nur lokal, nicht gepusht).** Modell `CustomModule` + Migration 20260929120000 mit RLS (Muster ProxmoxServer), `/custom-modules` (GET alle Angemeldeten, POST/PATCH/DELETE Admin, nur https ohne Zugangsdaten), `MODULE_CATEGORIES` in packages/shared, Seitenleisten-Eintrag unter gewaehlter Kategorie, Rahmen-Seite `/modules/custom/[id]` mit XFRAME_SANDBOX + no-referrer + „In neuem Tab öffnen“, Verwaltung `/admin/custom-modules`, Zugriffsklassifikation 61/224/6. Gruppen-Beschraenkung zurueckgestellt (ModuleGrant haengt an Module). api 1495, web 992 gruen; Browser dunkel 9 Schritte bestanden. | 2026-09-29 | b9d87be,e7fc4de,e48c0de | [260929-9wc-eigene-module-admin-legt-seitenleisten-e](./quick/260929-9wc-eigene-module-admin-legt-seitenleisten-e/) |
|
||||||
|
| 260929-d37 | **Desktop-App nur einmal starten.** User-Meldung Windows 11: beim Systemstart zwei Instanzen/zwei Tray-Symbole. `tauri-plugin-single-instance` 2.4.5 als erstes Plugin, zweiter Start ruft `show_main_window` (neuer Helper, ersetzt 3 Kopien) und beendet sich. cargo build/test (44)/clippy gruen. Windows-Pruefung offen (VM 8233 oder User-PC nach naechster Desktop-Version). | 2026-09-29 | c0b145a,0751198 | [260929-d37-desktop-client-nur-einmal-starten-single](./quick/260929-d37-desktop-client-nur-einmal-starten-single/) |
|
||||||
|
| 260929-dmx | **Widget-Raster horizontal feiner + Kalender schmaler.** COLS lg 48/md 40/sm 24/xs 16/xxs 4, GRID_VERSION 3 (v2->v3 nur x/w/minW/maxW x2), alle minW/defaultW x2, Kalender minW 8 (~250 px). Browser: Anordnung pixelgleich, Kalender bis 252 px, Schritt 33 px. Auch: Hover-Anheben der Widgets entfernt (acd3c7a, Nutzerwunsch). | 2026-09-29 | 97744b5,9c9e142,46ebb4e | [260929-dmx-widget-raster-horizontal-feiner-48-spalt](./quick/260929-dmx-widget-raster-horizontal-feiner-48-spalt/) |
|
||||||
|
| 260929-dzu | **Eigene Module fuer jeden Benutzer (persoenlich).** `CustomModule.ownerUserId` (null = gemeinsam), RLS-Muster SearchProvider, Einstellungen > Eigene Module (nur eigene), Verwaltung nur gemeinsame; Browser: Sichtbarkeit/Rechte wie verlangt. Nebenbei ohne eigenen Quick: Zentrierung entfernt (bc4c011), Desktop neue Fenster -> System-Browser (76a9234, Windows-VM bestaetigt), Single-Instance auf VM bestaetigt. | 2026-09-29 | c703d87,ee97b4e,8f41bd2 | [260929-dzu-eigene-module-fuer-jeden-benutzer-persoe](./quick/260929-dzu-eigene-module-fuer-jeden-benutzer-persoe/) |
|
||||||
|
| 260929-if2 | **Erinnerungen-Widget (Reminder).** Modell `Reminder` + RLS, API /reminders (anlegen/listen/bearbeiten/loeschen/erledigt/snooze, 409/404-Regeln), E-Mail-Scheduler alle 30 s mit Claim-once + max. 3 Versuche, globaler ReminderNotifier (Browser-Notification, Desktop via Tauri-Notification mit Laufzeit-Capability nur fuer die Server-Origin, Pattern escaped + vorab geprueft). Verifier human_needed (Windows-Toast offen); Browser dunkel bestanden inkl. echter Mail ueber MailHog. api 1570, web 1069, cargo 57. Nebenbei: eigene Module ohne Kopfzeile (cd1f8f6), Update-Klick prueft frisch (41d00a3). | 2026-09-29 | 325c5dd,709b41a,6879c75 | [260929-if2-reminder-widget-mit-benachrichtigung](./quick/260929-if2-reminder-widget-mit-benachrichtigung/) |
|
||||||
|
| 260929-lh3 | **Favoriten: eigene Symbol-Adresse wirkt.** Neue iconUrl ersetzt Upload + bumpt iconVersion; iconUrl wird auch gespeichert, wenn nur der Browser sie laden kann (kein 422 mehr, nur Formpruefung); Kachel: Proxy -> iconUrl direkt -> origin/favicon -> Buchstabe; Discovery liest <link rel=icon> auch aus Nicht-2xx-Seiten (docuvita 400). | 2026-09-29 | 7188c5b,b15c746,0e72ad4 | [260929-lh3-favoriten-eigenes-symbol-wirkt-nicht](./quick/260929-lh3-favoriten-eigenes-symbol-wirkt-nicht/) |
|
||||||
|
| 261001-cxo | Desktop-Client: Links mit target=_blank (Favoriten) oeffnen jetzt im System-Browser (DesktopExternalLinks -> window.open) | 2026-10-01 | 61a971c | [261001-cxo](./quick/261001-cxo-desktop-client-links-mit-target-blank-oe/) |
|
||||||
|
| 261001-g68 | Erinnerung: Cursor sprang beim Schreiben der Beschreibung in den Titel (Fokus-Effekt hing an inline onClose, Kachel zeichnet alle 10 s neu) – Fokus nur beim Oeffnen | 2026-10-01 | siehe git log | [261001-g68](./quick/261001-g68-erinnerung-cursor-springt-aus-beschreibu/) |
|
||||||
|
| 261001-hbi | Favoriten: Logo fuer per JavaScript gesetzte Symbole (hosteurope.de) – Rueckfall auf DuckDuckGo-Symboldienst beim Ausliefern, nur oeffentliche Seiten | 2026-10-01 | siehe git log | [261001-hbi](./quick/261001-hbi-favoriten-logo-fuer-per-javascript-geset/) |
|
||||||
|
|
||||||
## Deferred Items
|
## Deferred Items
|
||||||
|
|
||||||
@@ -520,4 +531,4 @@ 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.
|
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.
|
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
|
Resume file: None
|
||||||
Last activity: 2026-09-22 - Quick 260922-hk4: Bilderrahmen-Bilder im Dateibereich, Selbstheilung aus der alten Spalte
|
Last activity: 2026-09-29 - Quick 260929-if2 Erinnerungen-Widget (lokal, nicht gepusht); v1.7.0 auf alpha+live
|
||||||
|
|||||||
+326
@@ -0,0 +1,326 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260925-bow
|
||||||
|
plan: 01
|
||||||
|
quick_id: 260925-bow
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
autonomous: true
|
||||||
|
requirements: [QUICK-260925-bow]
|
||||||
|
files_modified:
|
||||||
|
- packages/shared/src/index.ts
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/prisma/migrations/20260925120000_user_last_seen_release/migration.sql (neu)
|
||||||
|
- apps/api/src/health/app-version.ts
|
||||||
|
- apps/api/src/health/release-version.spec.ts (neu)
|
||||||
|
- apps/api/src/user/dto/release-seen.dto.ts (neu)
|
||||||
|
- apps/api/src/user/user.controller.ts
|
||||||
|
- apps/api/src/user/user.controller.spec.ts
|
||||||
|
- apps/api/src/user/user.service.ts
|
||||||
|
- apps/api/src/user/user.service.spec.ts
|
||||||
|
- apps/api/src/user/admin-seed.service.ts
|
||||||
|
- apps/api/src/user/admin-seed.service.spec.ts
|
||||||
|
- apps/web/src/lib/release-notes.ts (neu)
|
||||||
|
- apps/web/src/lib/release-notes.test.ts (neu)
|
||||||
|
- apps/web/src/lib/release-notice-actions.ts (neu)
|
||||||
|
- apps/web/src/lib/release-notice-actions.test.ts (neu)
|
||||||
|
- apps/web/src/components/release-notice/release-notice-dialog.tsx (neu)
|
||||||
|
- apps/web/src/components/release-notice/release-notice-dialog.test.tsx (neu)
|
||||||
|
- apps/web/src/components/release-notice/release-notice-host.tsx (neu)
|
||||||
|
- apps/web/src/components/release-notice/release-notice-host.test.tsx (neu)
|
||||||
|
- apps/web/src/components/changelog/changelog-view.tsx
|
||||||
|
- apps/web/src/components/layout/app-shell.tsx
|
||||||
|
- apps/web/src/messages/de.json
|
||||||
|
- apps/web/src/messages/en.json
|
||||||
|
- apps/web/src/lib/changelog.ts (nur Kopfkommentar)
|
||||||
|
- apps/web/next.config.ts (nur Kommentar)
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
- docs/anleitung-anwender.md
|
||||||
|
- docs/anleitung-entwicklung.md
|
||||||
|
- CHANGELOG.md
|
||||||
|
|
||||||
|
estimate:
|
||||||
|
tokens: 90000
|
||||||
|
raw_tokens: 90000
|
||||||
|
tasks: 3
|
||||||
|
confidence: low
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Wer sich nach einem Versionswechsel zum ersten Mal im Portal anmeldet (laufende freigegebene Version liegt über der zuletzt gesehenen), sieht einmalig ein Fenster „Neu in Version X.Y.Z“ mit den Gruppen „Neu“, „Verbessert“ (aus Geändert) und „Behoben“ aus CHANGELOG.md, neueste Version zuerst (D-02, D-03, D-07)"
|
||||||
|
- "„Verstanden“, das Schließen-Kreuz, Escape, ein Klick auf den abgedunkelten Hintergrund oder auf „Alle Änderungen ansehen“ schließen das Fenster und merken die Version dauerhaft pro Benutzer in der Datenbank – im Browser und in der Desktop-App erscheint es danach nicht mehr; gemerkt wird erst beim Schließen, nie beim Öffnen (D-01, D-05)"
|
||||||
|
- "Wer mehrere Versionen verpasst hat, sieht höchstens die drei neuesten; bei mehr steht ein Satz mit der Zahl der weiteren Versionen im Fenster; unten steht immer der Link „Alle Änderungen ansehen“ zu /changelog (D-03)"
|
||||||
|
- "Bestandsbenutzer ohne gemerkten Stand sehen nur die laufende Version; Benutzer, die ein Administrator anlegt, die der LDAP-Abgleich anlegt, und der Erst-Administrator bekommen bei der Anlage die laufende Version eingetragen und sehen kein Fenster bis zur nächsten Version (D-04)"
|
||||||
|
- "Ohne gültige freigegebene Versionsnummer (lokaler Stand „dev“, bloßer Commit-Stempel) erscheint nie ein Fenster; auf der Anmeldeseite und auf /change-password erscheint es auch nicht; der Erststart-Dialog der Desktop-App und die Bildschirmfoto-Funktion von „Fehler melden“ bleiben unberührt (D-02, D-08)"
|
||||||
|
- "Der Server nimmt als „gesehen“ nur eine wohlgeformte Version X.Y.Z an, die nicht über der laufenden liegt, senkt einen gemerkten Stand nie ab und ändert ausschließlich die Zeile des angemeldeten Benutzers in dessen Mandanten (D-05)"
|
||||||
|
- "Der Text der Änderungsliste bleibt im Server-Bundle; in den öffentlich abrufbaren Client-Chunks unter /_next/static steht er nicht (Bestandsregel aus quick-260916-dcz)"
|
||||||
|
artifacts:
|
||||||
|
- path: "packages/shared/src/index.ts"
|
||||||
|
provides: "parseReleaseVersion, compareReleaseVersions, ReleaseNoticeResponse — eine Implementierung für API und Web (D-02, D-06)"
|
||||||
|
contains: "export function parseReleaseVersion"
|
||||||
|
- path: "apps/api/prisma/migrations/20260925120000_user_last_seen_release/migration.sql"
|
||||||
|
provides: "nullbare Spalte User.lastSeenReleaseVersion (D-01)"
|
||||||
|
contains: "lastSeenReleaseVersion"
|
||||||
|
- path: "apps/api/src/health/app-version.ts"
|
||||||
|
provides: "getRunningRelease() — einzige Quelle der laufenden Version (API-APP_VERSION)"
|
||||||
|
contains: "export function getRunningRelease"
|
||||||
|
- path: "apps/api/src/user/user.controller.ts"
|
||||||
|
provides: "GET /users/me/release-notice, POST /users/me/release-seen"
|
||||||
|
contains: "me/release-seen"
|
||||||
|
- path: "apps/web/src/lib/release-notes.ts"
|
||||||
|
provides: "selectReleaseNotice — reine Auswahl der Versionsabschnitte (Bereich, Deckel 3, null, unparsbar)"
|
||||||
|
contains: "export function selectReleaseNotice"
|
||||||
|
- path: "apps/web/src/lib/release-notice-actions.ts"
|
||||||
|
provides: "Server-Aktionen fetchReleaseNotice / markReleaseSeenAction ('use server')"
|
||||||
|
contains: "'use server'"
|
||||||
|
- path: "apps/web/src/components/release-notice/release-notice-dialog.tsx"
|
||||||
|
provides: "barrierefreies Fenster (role=dialog, aria-modal, Fokusfalle, Escape)"
|
||||||
|
contains: "aria-modal"
|
||||||
|
- path: "apps/web/src/components/release-notice/release-notice-host.tsx"
|
||||||
|
provides: "lädt die Nachricht einmal je Seitenladung im Portal-Rahmen und merkt beim Schließen"
|
||||||
|
contains: "markReleaseSeenAction"
|
||||||
|
key_links:
|
||||||
|
- from: "apps/web/src/components/layout/app-shell.tsx"
|
||||||
|
to: "apps/web/src/components/release-notice/release-notice-host.tsx"
|
||||||
|
via: "<ReleaseNoticeHost /> im Portal-Rahmen (nur (portal)-Layout, nie /login)"
|
||||||
|
pattern: "ReleaseNoticeHost"
|
||||||
|
- from: "apps/web/src/lib/release-notice-actions.ts"
|
||||||
|
to: "GET /users/me/release-notice"
|
||||||
|
via: "fetch mit Session-Cookie über API_INTERNAL_URL, danach selectReleaseNotice(changelogMarkdown, …)"
|
||||||
|
pattern: "users/me/release-notice"
|
||||||
|
- from: "apps/web/src/components/release-notice/release-notice-host.tsx"
|
||||||
|
to: "POST /users/me/release-seen"
|
||||||
|
via: "markReleaseSeenAction(notice.currentRelease) im onClose"
|
||||||
|
pattern: "markReleaseSeenAction"
|
||||||
|
- from: "apps/api/src/user/user.service.ts + admin-seed.service.ts"
|
||||||
|
to: "apps/api/src/health/app-version.ts"
|
||||||
|
via: "lastSeenReleaseVersion: getRunningRelease() bei jeder Benutzeranlage"
|
||||||
|
pattern: "getRunningRelease"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260925-bow — „Was ist neu“-Fenster beim ersten Anmelden nach einem Versionswechsel
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Nach einem Versionswechsel zeigt Tessera jedem Benutzer beim ersten Laden des Portals einmal ein
|
||||||
|
Fenster mit den für ihn wichtigen Änderungen (Neu / Verbessert / Behoben) aus CHANGELOG.md. Der
|
||||||
|
gesehene Stand wird pro Benutzer in der Datenbank gemerkt, damit das Fenster im Browser und in der
|
||||||
|
Desktop-App genau einmal erscheint.
|
||||||
|
|
||||||
|
Purpose: Anwender erfahren ohne Suchen, was sich geändert hat und welche Fehler behoben sind
|
||||||
|
(Nutzerwunsch vom 25.09.).
|
||||||
|
Output: neue Spalte + Migration, zwei API-Endpunkte, reine Versions- und Auswahlfunktionen mit
|
||||||
|
Tests, Server-Aktionen, barrierefreies Fenster im Portal-Rahmen, Doku, CHANGELOG-Eintrag.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@~/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/STATE.md
|
||||||
|
@./CLAUDE.md
|
||||||
|
|
||||||
|
## Verbindliche Entscheidungen des Orchestrators (hier nummeriert, in den Aufgaben zitiert)
|
||||||
|
|
||||||
|
- **D-01** Pro Benutzer in der DB: neue nullbare Spalte `User.lastSeenReleaseVersion String?` (Prisma-Migration). Gilt damit für Browser und Desktop-App.
|
||||||
|
- **D-02** Nur freigegebene Versionen zählen: die laufende Version kommt aus APP_VERSION (führendes `v` und den Describe-Anhang `-N-g<sha>` entfernen, z. B. `v10.2.3-5-gabc1234` → `10.2.3`). Nicht parsebar (`dev`, bloßer Commit-SHA) → nie ein Fenster.
|
||||||
|
- **D-03** Einmal nach der Anmeldung im Portal-Rahmen (nicht auf der Anmeldeseite), wenn laufende Version > gemerkte. Inhalt: jede freigegebene Version aus CHANGELOG mit gemerkt < Version ≤ laufend, neueste zuerst, nur die Abschnitte Neu / Geändert / Behoben (andere Abschnitte und leere weglassen). Höchstens 3 Versionen; bei mehr ein Satz plus Link „Alle Änderungen ansehen“ zu /changelog. Unten immer ein Link zu /changelog.
|
||||||
|
- **D-04** `lastSeen === null` (Bestandsbenutzer beim ersten Ausrollen): nur der Abschnitt der laufenden Version. Neu angelegte Benutzer bekommen bei der Anlage die laufende Version eingetragen (alle Anlagewege). Die API braucht dafür die Version selbst — eine einzige Quelle wählen und dokumentieren.
|
||||||
|
- **D-05** Schließen („Verstanden“, Escape, Klick auf den Hintergrund) merkt über einen kleinen angemeldeten Endpunkt (`POST /users/me/release-seen` mit der Version); der Server prüft Wohlgeformtheit und „nicht größer als die laufende Version“. Erst beim Schließen merken, nicht beim Öffnen.
|
||||||
|
- **D-06** Versionsvergleich: numerischer semver-Vergleich, reine Funktion, mit Unit-Tests.
|
||||||
|
- **D-07** Barrierefreies Fenster (role="dialog", aria-modal, Fokusfalle, Anfangsfokus auf Überschrift oder Schließen-Knopf, Escape schließt, reduzierte Bewegung). Vorbilder: `widget-catalog-modal.tsx`, `bug-report-dialog.tsx`. Tailwind-4-Tokens, Dunkelmodus. Titel „Neu in Version 1.4.0“; Gruppen „Neu“, „Verbessert“ (für Geändert), „Behoben“; Einträge mit demselben Renderer wie die Seite /changelog. Sie-Form, Schlüssel in de.json + en.json.
|
||||||
|
- **D-08** Nicht auf der Anmeldeseite; darf den Erststart-Dialog der Desktop-App nicht blockieren; darf die Bildschirmfoto-Funktion von „Fehler melden“ nicht stören (offen sein ist in Ordnung).
|
||||||
|
- **D-09** Tests: Parser/Auswahl (Bereich, Deckel 3, null, unparsebar), semver-Vergleich, Fenster rendern/schließen merkt, nicht gezeigt wenn aktuell, Endpunkt-Validierung + Mandanten-/Benutzerbindung, Anlagewege setzen das Feld. Volle API- und Web-Suiten, `turbo type-check lint`, Biome-Warnungen Web ≤ 53, API ≤ 82 (Stand vorher gemessen: 53 / 82), RLS-Bestandsaufnahme-Spec + `docs/mandantentrennung-zugriffsklassifikation.md` nachziehen.
|
||||||
|
- **D-10** CHANGELOG „Unveröffentlicht → Neu“-Eintrag; Erwähnung im Anwenderhandbuch.
|
||||||
|
- **D-11** Browserprüfung macht der Orchestrator (siehe `<verification>`), nicht der Executor.
|
||||||
|
|
||||||
|
## Einzige Quelle der laufenden Version (Entscheidung zu D-04, von Claude getroffen)
|
||||||
|
|
||||||
|
**Die API-Umgebungsvariable `APP_VERSION`, gelesen über `getRunningRelease()` in
|
||||||
|
`apps/api/src/health/app-version.ts`.** Begründung: zwei der drei Anlagewege laufen ohne jede
|
||||||
|
Web-Anfrage (LDAP-Abgleich per Zeitplan, Erst-Administrator beim API-Start) und können die Version
|
||||||
|
nur aus der API kennen; die Prüfung „nicht größer als laufend“ in `POST /users/me/release-seen` ebenso.
|
||||||
|
Das Web wertet für diese Funktion seine eigene `NEXT_PUBLIC_APP_VERSION` NICHT aus, sondern nimmt
|
||||||
|
`currentRelease` aus der Antwort von `GET /users/me/release-notice`. Beide Abbilder bekommen im
|
||||||
|
CI denselben `APP_VERSION`-Wert (`.gitea/scripts/publish-images.sh`, eine Schleife für web und
|
||||||
|
api), deshalb passen Änderungsliste (im Web-Abbild) und Version (aus der API) im Betrieb zusammen.
|
||||||
|
Fehlt der Abschnitt der laufenden Version in der Änderungsliste des Web-Abbilds, entsteht einfach
|
||||||
|
kein Fenster (und nichts wird gemerkt). Parse- und Vergleichsfunktion stehen EINMAL in
|
||||||
|
`packages/shared/src/index.ts` und werden von API und Web importiert.
|
||||||
|
|
||||||
|
## Bestand, den der Executor kennen muss (vom Planer gelesen)
|
||||||
|
|
||||||
|
- `apps/web/src/lib/changelog.ts`: `changelogMarkdown` (Bauzeit-Text aus `TESSERA_CHANGELOG_MD`), `filterChangelogForChannel`. Dieses Modul darf nur Server-Code importieren — sonst landet der Text in öffentlichen Client-Chunks. Eine `'use server'`-Datei ist Server-Code (Client-Komponenten bekommen nur eine Aktions-Referenz).
|
||||||
|
- `apps/web/src/components/changelog/changelog-view.tsx`: `ChangelogView` rendert Markdown mit `MDEditor.Markdown` + `rehype-sanitize`, Farbmodus nach Mount. Wird wiederverwendet (D-07).
|
||||||
|
- `apps/web/src/components/layout/app-shell.tsx`: Portal-Rahmen (nur im `(portal)`-Layout; `/login` liegt in `(auth)` ohne AppShell). Anmeldung navigiert per `window.location.href` → AppShell wird frisch gemountet.
|
||||||
|
- `apps/web/src/lib/auth-actions.ts` + `auth-actions.test.ts`: Muster für Server-Aktionen (Cookie `session` → `Cookie: session=…` an `API_URL = process.env.API_INTERNAL_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'`) und deren Tests (`next/headers` gemockt, `fetch` per `vi.stubGlobal`).
|
||||||
|
- `apps/web/src/middleware.ts` leitet bei `mustChangePassword` auf `/change-password` (liegt IM Portal-Rahmen).
|
||||||
|
- `apps/api/src/health/app-version.ts`: `getAppVersion()` liest `process.env.APP_VERSION || 'dev'` zur Laufzeit (Tests: `vi.stubEnv`, Vorbild `health.controller.spec.ts`).
|
||||||
|
- `packages/shared/src/index.ts`: rohes TypeScript ohne Bauschritt, die API lädt es im Betrieb über das Type-Stripping von Node 24 → nur löschbare Syntax (keine `enum`, kein `namespace`, keine Parameter-Eigenschaften), keine relativen Importe in neue Dateien (CJS-`require` findet keine `.ts`-Endung) — deshalb alles direkt in `index.ts`. Das Web importiert bereits Laufzeitwerte daraus (`widget-registry.tsx`).
|
||||||
|
- `apps/api/src/user/user.controller.ts`: `@Controller('users')` + `@UseGuards(RolesGuard)`; Selbstbedienungswege ohne `@Roles` (Vorbild `PATCH me/accent-color`: `forTenant(this.prisma, currentUser.tenantId)` → `tenantPrisma.user.update({ where: { id: currentUser.id } … })`). Globale `ValidationPipe({ whitelist: true, transform: true })` → Body braucht eine DTO-Klasse mit class-validator-Dekoratoren, sonst werden Felder entfernt. Route-Reihenfolge: neue statische `me/…`-Routen VOR `@Get(':id')` einfügen (Projektregel gegen 404-Shadowing).
|
||||||
|
- `apps/api/src/user/user.controller.spec.ts`: Zwei-Klienten-Attrappe (`forTenant` gemockt → `prisma.__makeBoundClient(tenantId)`, `scopedFindUnique`/`scopedUpdate` filtern nach Mandant, `boundCallLog`).
|
||||||
|
- Benutzer-Anlagewege (per grep `user\.create` vollständig ermittelt): `UserService.create()` in `apps/api/src/user/user.service.ts` (einziger Erzeugungspunkt für Admin-Anlage `POST /users` UND beide LDAP-Wege `LdapService.upsertMappedUser`/`importUsersByDn`) und `AdminSeedService` in `apps/api/src/user/admin-seed.service.ts` (Erst-Administrator). `apps/api/scripts/rls-scratch-check.mjs` legt nur Wegwerf-Testbenutzer in einer Prüf-DB an — kein Produktweg, bleibt unverändert.
|
||||||
|
- Migrationen laufen beim API-Start (`apps/api/scripts/migrate-and-start.sh`). Letzte vorhandene: `20260924120000_dashboard_image_drop_data`. Die Anmelde-Funktionen `auth_lookup_*` liefern eine feste Spaltenliste (`RETURNS TABLE`) — eine neue Spalte berührt sie nicht.
|
||||||
|
- RLS-Buchführung: `apps/api/src/prisma/rls-access-inventory.spec.ts` prüft Paare (Datei, Modell); das Paar `user.controller.ts | user | gebunden` existiert. Die Übersichtszeile `| user | 8 | 14 | 0 |` und die Summenzeile `| **Summe** | **61** | **213** | **6** |` in `docs/mandantentrennung-zugriffsklassifikation.md` werden mit der Gate-Schleife nachgerechnet (siehe Aufgabe 2).
|
||||||
|
- Stilregeln neuer UI-Dateien (aus quick-260924-i8v übernommen, Gate in Aufgabe 3): keine Versal- oder Sperrschrift-Klassen, keine Mittelpunkt- oder Pfeilzeichen in Texten, kein rohes HTML-Einfügen.
|
||||||
|
- Commits je Aufgabe mit `feat(260925-bow)` / `test(260925-bow)` / `docs(260925-bow)`; `.planning/**` committet der Executor nicht. Nicht pushen, kein Tag, keine Freigabe.
|
||||||
|
|
||||||
|
<!-- planner-discipline-allow: lib/changelog -->
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="tracer" tdd="true">
|
||||||
|
<name>Aufgabe 1 (Tracer): Bestandsbenutzer mit altem Stand sieht das Fenster, Schließen merkt die Version — DB → API → Server-Aktion → Fenster im Portal</name>
|
||||||
|
<files>packages/shared/src/index.ts, apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260925120000_user_last_seen_release/migration.sql, apps/api/src/health/app-version.ts, apps/api/src/health/release-version.spec.ts, apps/api/src/user/dto/release-seen.dto.ts, apps/api/src/user/user.controller.ts, apps/api/src/user/user.controller.spec.ts, apps/web/src/lib/release-notes.ts, apps/web/src/lib/release-notes.test.ts, apps/web/src/lib/release-notice-actions.ts, apps/web/src/lib/release-notice-actions.test.ts, apps/web/src/components/release-notice/release-notice-dialog.tsx, apps/web/src/components/release-notice/release-notice-dialog.test.tsx, apps/web/src/components/release-notice/release-notice-host.tsx, apps/web/src/components/release-notice/release-notice-host.test.tsx, apps/web/src/components/changelog/changelog-view.tsx, apps/web/src/components/layout/app-shell.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/web/src/lib/changelog.ts, apps/web/src/lib/changelog.test.ts (Abschnittszerlegung, CRLF-Normalisierung, Testform)
|
||||||
|
- apps/web/src/components/changelog/changelog-view.tsx
|
||||||
|
- apps/web/src/components/dashboard/widget-catalog-modal.tsx (Hintergrund als echte Schaltfläche, role=dialog, Escape) und apps/web/src/components/bug-report/bug-report-dialog.tsx (Knopfklassen `bg-primary … text-primary-foreground`)
|
||||||
|
- apps/web/src/lib/auth-actions.ts (fetchSessionState) und apps/web/src/lib/auth-actions.test.ts
|
||||||
|
- apps/web/src/components/layout/app-shell.tsx, apps/web/src/components/layout/header.tsx (Server-Aktion aus useEffect, Ref-Sperre gegen StrictMode-Doppeleffekt)
|
||||||
|
- packages/shared/src/index.ts (Warnkommentar über WIDGET_TYPES), apps/api/src/health/app-version.ts, apps/api/src/health/health.controller.spec.ts
|
||||||
|
- apps/api/src/user/user.controller.ts (Selbstbedienungswege ab `me/avatar`), apps/api/src/user/user.controller.spec.ts, apps/api/src/user/dto/create-user.dto.ts
|
||||||
|
- apps/api/prisma/schema.prisma (model User), apps/api/prisma/migrations/20260702000000_add_user_accent_color/migration.sql (Form einer Spaltenergänzung)
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- Versionen (shared, getestet in apps/api/src/health/release-version.spec.ts): parseReleaseVersion liefert für `v10.2.3` → `10.2.3`, `10.2.3` → `10.2.3`, `v10.2.3-5-gabc1234` → `10.2.3`, `10.2.3-12-g0123456789abcdef` → `10.2.3`, führende Nullen `010.02.3` → `10.2.3`; `null` für `dev`, leer, bloßen SHA `abc1234`, `10.2`, `10.2.3-rc.1`, `10.2.3-dirty`, Leerzeichen am Rand, Eingaben über 64 Zeichen. compareReleaseVersions: `1.10.0` > `1.9.0` (numerisch, nicht lexikografisch), `2.0.0` > `1.99.99`, gleich → 0, `v10.2.3` gegen `10.2.3` → 0; wirft bei nicht parsebarer Eingabe. getRunningRelease mit `vi.stubEnv('APP_VERSION', 'v10.2.3-5-gabc1234')` → `10.2.3`, mit `dev` oder ungesetzt → null
|
||||||
|
- API GET /users/me/release-notice: liefert `{ currentRelease, lastSeenReleaseVersion }` des angemeldeten Benutzers, gelesen über den an `currentUser.tenantId` gebundenen Klienten (boundCallLog), fremder Benutzer desselben Ids in anderem Mandanten ist unsichtbar → NotFoundException
|
||||||
|
- API POST /users/me/release-seen: `10.2.3` bei laufend `10.2.3` → gespeichert, Antwort nennt den gespeicherten Stand; Formate `v10.2.3`, `10.2`, `abc`, `10.2.3-5-gabc1234`, Nicht-String → BadRequestException; Version über der laufenden → BadRequestException; laufend nicht parsebar (`dev`) → BadRequestException; gemerkt `10.2.3`, gesendet `10.1.0` → bleibt `10.2.3` (nie absenken); gemerkter Wert unparsebar → wird überschrieben; zwei Benutzer in zwei Mandanten: nur die Zeile des Anfragenden ändert sich
|
||||||
|
- Web selectReleaseNotice (release-notes.test.ts, eigene Markdown-Fixtures): current null oder unparsebar → null; lastSeen null → nur der Abschnitt der laufenden Version; lastSeen ≥ current → null; lastSeen `1.3.0`, current `1.4.0` → Versionen 1.4.0 und 1.3.1 (neueste zuerst), omittedCount 0; lastSeen `1.0.0` bei fünf Versionen darüber → die drei neuesten, omittedCount 2; lastSeen unparsebar → wie null; „Unveröffentlicht“ mit Punkten wird nie ausgewählt; Versionen über current (Web neuer als API) werden ausgelassen; laufende Version ohne Abschnitt in der Liste → null; nur Neu/Geändert/Behoben in fester Reihenfolge new, changed, fixed unabhängig von der Dateireihenfolge; „Entfernt“ und leere Abschnitte fehlen; eine Version nur mit „Entfernt“ fällt ganz weg; CRLF wird normalisiert
|
||||||
|
- Web Server-Aktionen: ohne Cookie → null und kein fetch; API 200 → Ergebnis von selectReleaseNotice auf dem (gemockten) changelogMarkdown; API nicht-ok oder Netzfehler → null; markReleaseSeenAction schickt POST mit `Content-Type: application/json`, Cookie und `{ version }`, liefert `{ success: false }` bei nicht-ok
|
||||||
|
- Fenster: Titel „Neu in Version 1.4.0“ (Schlüssel), role=dialog mit aria-modal und aria-labelledby auf die Überschrift, Anfangsfokus auf der Überschrift; Gruppenüberschriften aus den Schlüsseln new/changed/fixed; Versionsunterüberschriften nur bei mehr als einer Version; Satz über weitere Versionen nur bei omittedCount > 0; Link zu /changelog immer vorhanden; „Verstanden“, Kreuz, Escape, Hintergrund und Link rufen onClose genau einmal; Tab vom letzten fokussierbaren Element springt zum ersten, Umschalt+Tab vom ersten zum letzten
|
||||||
|
- Host: ohne Nachricht rendert er nichts; mit Nachricht erscheint das Fenster, markReleaseSeenAction wird beim Öffnen NICHT aufgerufen; nach Schließen verschwindet das Fenster und markReleaseSeenAction wurde genau einmal mit currentRelease aufgerufen; auf `/change-password` wird fetchReleaseNotice nicht aufgerufen, nach dem Wechsel auf `/` genau einmal; StrictMode-Doppeleffekt führt nicht zu zwei Abrufen
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Umsetzung in dieser Reihenfolge, jeweils Test zuerst (rot), dann Code (grün):
|
||||||
|
|
||||||
|
1. **Versionen (D-02, D-06)** in `packages/shared/src/index.ts` direkt (keine neue Datei, nur löschbare Syntax; den Warnkommentar über `WIDGET_TYPES` um den Hinweis ergänzen, dass diese Funktionen der zweite Laufzeit-Import der API sind): `parseReleaseVersion(raw: string): string | null` mit dem Muster optionales `v`, drei Zifferngruppen zu je 1–6 Ziffern, optional genau der Describe-Anhang `-<Zahl>-g<4–40 Hex-Zeichen>`, ganze Zeichenkette verankert, kein Trimmen, Länge über 64 ergibt null; Rückgabe kanonisch `Number(a).Number(b).Number(c)`. `compareReleaseVersions(a: string, b: string): number` parst beide, wirft `Error` bei null, vergleicht die drei Zahlen der Reihe nach und liefert -1/0/1. Dazu `export interface ReleaseNoticeResponse { currentRelease: string | null; lastSeenReleaseVersion: string | null }`. Tests in `apps/api/src/health/release-version.spec.ts` (API-Suite, weil `packages/shared` keinen eigenen Testlauf hat; Vorbild `widget-module-map.spec.ts`).
|
||||||
|
|
||||||
|
2. **Laufende Version der API (einzige Quelle, siehe Kontext):** in `apps/api/src/health/app-version.ts` `export function getRunningRelease(): string | null` = `parseReleaseVersion(getAppVersion().version)`, Laufzeit-Import aus `@tessera/shared`, Kopfkommentar ergänzen (warum die API die Quelle ist). Tests im selben Spec.
|
||||||
|
|
||||||
|
3. **Spalte (D-01):** `lastSeenReleaseVersion String?` im `model User` (neben `accentColor`), Migration `apps/api/prisma/migrations/20260925120000_user_last_seen_release/migration.sql` mit Kopfkommentar (quick-260925-bow, wozu die Spalte dient, null = Bestandsbenutzer) und genau `ALTER TABLE "User" ADD COLUMN "lastSeenReleaseVersion" TEXT;`. Danach `pnpm --filter @tessera/api exec prisma generate`. Kein Standardwert, kein Backfill (D-04: null ist gewollt).
|
||||||
|
|
||||||
|
4. **Endpunkte (D-05)** in `apps/api/src/user/user.controller.ts`, beide VOR `@Get(':id')`, ohne `@Roles` (jeder angemeldete Benutzer), beide über `forTenant(this.prisma, currentUser.tenantId)` mit `where: { id: currentUser.id }` — kein Kennungsparameter aus der Anfrage. `GET me/release-notice`: `findUnique` mit `select: { lastSeenReleaseVersion: true }`, fehlt die Zeile, dann `NotFoundException`, sonst `ReleaseNoticeResponse` mit `currentRelease: getRunningRelease()`. `POST me/release-seen` mit `@HttpCode(HttpStatus.OK)` und neuer DTO `apps/api/src/user/dto/release-seen.dto.ts` (`ReleaseSeenDto`, `version` mit `@IsString()`, `@MaxLength(32)`, `@Matches` auf die kanonische Form X.Y.Z): im Rumpf zusätzlich prüfen (Unit-Tests rufen die Methode ohne Pipe): `typeof version === 'string'` und `parseReleaseVersion(version) === version`, sonst `BadRequestException`; ist `getRunningRelease()` null, dann `BadRequestException` (keine freigegebene Version); ist `compareReleaseVersions(version, running) > 0`, dann `BadRequestException`. Dann gemerkten Stand lesen (`findUnique`, fehlt er, dann `NotFoundException`); nur wenn der gemerkte Wert null oder unparsebar ist oder die neue Version größer ist, `update` mit `data: { lastSeenReleaseVersion: version }`; Antwort `{ success: true, lastSeenReleaseVersion: <gespeicherter Stand> }`. JSDoc je Methode mit Bezug auf quick-260925-bow und die Mandantenbindung. Tests im bestehenden `user.controller.spec.ts` mit der vorhandenen Zwei-Klienten-Attrappe, `APP_VERSION` per `vi.stubEnv` (in `afterEach` `vi.unstubAllEnvs()`).
|
||||||
|
|
||||||
|
5. **Auswahl (D-03, D-04)** in neuer reiner Datei `apps/web/src/lib/release-notes.ts` (importiert NICHT das Changelog-Modul und keine React-/Next-Module, damit Typen daraus auch in Client-Komponenten sicher sind): Typen `ReleaseSectionKind = 'new' | 'changed' | 'fixed'`, `ReleaseNotesSection { kind; markdown }` (nur die Zeilen unter der Gruppenüberschrift, Leerzeilen am Rand entfernt), `ReleaseNotesVersion { version; sections }`, `ReleaseNotice { currentRelease; versions; omittedCount }`, Konstante `RELEASE_NOTICE_MAX_VERSIONS = 3`. `parseChangelogReleases(markdown)`: CRLF normalisieren, an `## `-Überschriften zerlegen, Version = erstes Wort der Überschrift durch `parseReleaseVersion` (aus `@tessera/shared`) — „Unveröffentlicht“ und alles Unparsebare fällt weg; darin `### `-Gruppen genau „Neu“ als new, „Geändert“ als changed, „Behoben“ als fixed, andere Gruppen verwerfen, Gruppe ohne Listenpunkt (`^\s*[-*] `) verwerfen, Ausgabe in fester Reihenfolge new/changed/fixed, Versionen ohne Gruppe verwerfen. `selectReleaseNotice(markdown, currentRelease, lastSeen)`: Regeln wie im behavior-Block; Sortierung absteigend per `compareReleaseVersions` (nicht der Dateireihenfolge vertrauen); ergibt sich keine Version, Rückgabe null.
|
||||||
|
|
||||||
|
6. **Server-Aktionen** in neuer Datei `apps/web/src/lib/release-notice-actions.ts` mit `'use server'` (eigene Datei, damit `auth-actions.ts` die Änderungsliste nicht importiert): `fetchReleaseNotice(): Promise<ReleaseNotice | null>` liest das Cookie `session` wie `fetchSessionState`, ruft `GET ${API_URL}/users/me/release-notice` mit `cache: 'no-store'`, prüft die Antwortform (beide Felder string oder null, sonst null) und gibt `selectReleaseNotice(changelogMarkdown, body.currentRelease, body.lastSeenReleaseVersion)` zurück; jeder Fehler still mit Rückgabe null. `markReleaseSeenAction(version: string): Promise<{ success: boolean }>` schickt `POST ${API_URL}/users/me/release-seen`. `changelogMarkdown` kommt aus `@/lib/changelog` — erlaubt, weil Server-Code. Tests nach Vorbild `auth-actions.test.ts`, `@/lib/changelog` per `vi.mock` mit eigenem Markdown.
|
||||||
|
|
||||||
|
7. **Renderer wiederverwenden (D-07):** `ChangelogView` bekommt eine optionale Eigenschaft `variant?: 'card' | 'plain'` (Vorgabe `card`, Seite /changelog unverändert); `plain` lässt Rahmen, Hintergrund und Innenabstand weg, `data-testid` bleibt. Die bestehenden Tests der Seite müssen unverändert grün bleiben.
|
||||||
|
|
||||||
|
8. **Fenster (D-03, D-07, D-08)** `apps/web/src/components/release-notice/release-notice-dialog.tsx` (`'use client'`), Eigenschaften `{ notice: ReleaseNotice; onClose: () => void }`. Aufbau nach `widget-catalog-modal.tsx`: äußerer `fixed inset-0 z-50`-Container, Hintergrund als echte Schaltfläche mit aria-label (Schlüssel `releaseNotice.close`) und `bg-black/50`, Dialog `role="dialog"`, `aria-modal="true"`, `aria-labelledby` auf die Überschrift (`useId`), `bg-card border border-border rounded-lg shadow-xl`, `w-full max-w-lg mx-4 max-h-[85vh] flex flex-col`. Kopf: `h2` „Neu in Version {version}“ mit `tabIndex={-1}` und Anfangsfokus per Ref beim Mount, daneben Schließen-Kreuz (SVG aus dem Katalog-Fenster, aria-label `common.close`). Mitte: Einleitungssatz, dann scrollbarer Bereich (`overflow-y-auto`, `tabIndex={0}`, aria-label) mit je Version (Unterüberschrift „Version {version}“ nur bei mehr als einer Version) je Gruppe eine `h3` (`text-sm font-semibold text-foreground`) und `<ChangelogView markdown={section.markdown} variant="plain" />`. Fuß: bei `omittedCount > 0` der Satz `moreVersions` (ICU-Plural), links `next/link` „Alle Änderungen ansehen“ auf `/changelog` (Klick ruft onClose, Navigation läuft normal weiter), rechts Hauptknopf „Verstanden“ mit den Knopfklassen aus `bug-report-dialog.tsx`. Tastatur: `keydown`-Listener auf `document` — Escape ruft onClose; Tab/Umschalt+Tab zyklisch innerhalb der aktuell fokussierbaren Elemente des Dialogs (`a[href]`, `button:not([disabled])`, `[tabindex]:not([tabindex="-1"])`, dynamisch abgefragt, weil die Markdown-Ausgabe Links enthalten kann); liegt der Fokus auf der Überschrift, springt Tab auf das erste Element. Beim Unmount den Fokus auf das zuvor aktive Element zurückgeben. Keine Einblendanimation; falls doch ein Übergang nötig ist, nur mit `motion-safe:`-Präfix. Nur Tailwind-Tokens (`bg-card`, `text-foreground`, `text-muted-foreground`, `border-border`, `bg-primary`), damit Dunkelmodus automatisch stimmt. Stilregeln aus dem Kontext beachten.
|
||||||
|
|
||||||
|
9. **Host** `apps/web/src/components/release-notice/release-notice-host.tsx` (`'use client'`): Zustand `notice`, Ref-Sperre „schon abgefragt“; `useEffect` auf `usePathname()`: ist die Sperre gesetzt oder beginnt der Pfad mit `/change-password`, nichts tun; sonst Sperre setzen und `fetchReleaseNotice()` aufrufen, Ergebnis in den Zustand (Fehler still). Das Fenster per `React.lazy` + `Suspense fallback={null}` laden, damit der Markdown-Renderer nur geladen wird, wenn wirklich eine Nachricht da ist. `onClose`: zuerst `setNotice(null)` (Fenster sofort weg), dann `void markReleaseSeenAction(notice.currentRelease)` — schlägt das Merken fehl, erscheint das Fenster beim nächsten Laden erneut (gewollt, nicht stumm verloren). In `app-shell.tsx` `<ReleaseNoticeHost />` nach `</main>` einfügen (nur dort; Anmeldeseite hat keine AppShell, der Erststart-Dialog der Desktop-App ist die lokale `apps/desktop/src/setup.html` vor dem Portal und wird nicht berührt; die Bildschirmfoto-Funktion rastert `document.body` und nimmt ein offenes Fenster einfach mit).
|
||||||
|
|
||||||
|
10. **Texte (D-07)** neuer Namensraum `releaseNotice` in `de.json` und `en.json` mit identischem Schlüsselsatz: `title` („Neu in Version {version}“ / „New in version {version}“), `intro` („Tessera wurde aktualisiert. Das hat sich für Sie geändert:“ / „Tessera has been updated. Here is what changed for you:“), `versionHeading` („Version {version}“), `section.new` („Neu“ / „New“), `section.changed` („Verbessert“ / „Improved“), `section.fixed` („Behoben“ / „Fixed“), `moreVersions` (DE: „Dazu kommen Änderungen aus {count, plural, one {# älteren Version} other {# älteren Versionen}}.“, EN: „There are also changes from {count, plural, one {# earlier version} other {# earlier versions}}.“), `showAll` („Alle Änderungen ansehen“ / „View all changes“), `confirm` („Verstanden“ / „Got it“), `close` („Fenster schließen“ / „Close window“), `contentLabel` („Änderungen“ / „Changes“). Echte Umlaute, Sie-Form.
|
||||||
|
|
||||||
|
Commit(s): `feat(260925-bow): …` und `test(260925-bow): …` (TDD-Reihenfolge darf in einzelnen Commits sichtbar sein).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec prisma generate >/dev/null && pnpm --filter @tessera/api exec prisma validate && node -e "const s=require('./packages/shared/src/index.ts'); if (s.parseReleaseVersion('v10.2.3-5-gabc1234')!=='10.2.3' || s.parseReleaseVersion('dev')!==null || s.compareReleaseVersions('1.10.0','1.9.0')<=0) process.exit(1)" && pnpm --filter @tessera/api exec vitest run release-version user.controller && pnpm --filter @tessera/web exec vitest run release-notes release-notice changelog && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test -z "$(grep -lE '^import .*lib/changelog' apps/web/src/lib/release-notes.ts apps/web/src/components/release-notice/*.tsx)" && grep -q "^'use server'" apps/web/src/lib/release-notice-actions.ts && test "$(grep -rl '<ReleaseNoticeHost' apps/web/src --include=*.tsx | grep -v '\.test\.tsx$')" = "apps/web/src/components/layout/app-shell.tsx" && awk '/me\/release-notice|me\/release-seen/{n=NR} /@Get\(.:id.\)/{if(!g)g=NR} END{exit !(n && g && n<g)}' apps/api/src/user/user.controller.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Mit gesetzter APP_VERSION liefert die API die laufende Version und den gemerkten Stand, nimmt nur gültige, nicht zu hohe Versionen als gesehen an und bindet beides an den angemeldeten Benutzer im eigenen Mandanten; das Web wählt die richtigen Abschnitte (Bereich, Deckel 3, null, unparsebar), zeigt im Portal-Rahmen ein barrierefreies Fenster und merkt erst beim Schließen. Gezielte API- und Web-Tests grün, beide type-checks grün, Node lädt die gemeinsamen Funktionen ohne Bauschritt.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Aufgabe 2: Neue Benutzer sehen kein Verlaufsfenster — alle Anlagewege tragen die laufende Version ein; RLS-Buchführung nachziehen</name>
|
||||||
|
<files>apps/api/src/user/user.service.ts, apps/api/src/user/user.service.spec.ts, apps/api/src/user/admin-seed.service.ts, apps/api/src/user/admin-seed.service.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md</files>
|
||||||
|
<read_first>
|
||||||
|
- apps/api/src/user/user.service.ts (`create()` samt Kopfkommentar „EINZIGER Erzeugungspunkt“), apps/api/src/user/user.service.spec.ts (describe „create — Standardgruppen-Mitgliedschaft“)
|
||||||
|
- apps/api/src/user/admin-seed.service.ts (Erstanlage), apps/api/src/user/admin-seed.service.spec.ts
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md: Abschnitt „Übersicht je Bereich“ (Zeile `| user |` und `| **Summe** |`), Fundstellenzeile `apps/api/src/user/user.controller.ts | user`
|
||||||
|
</read_first>
|
||||||
|
<behavior>
|
||||||
|
- UserService.create mit `APP_VERSION=v10.2.3-3-gabc1234` → die an `tenantPrisma.user.create` übergebenen Daten enthalten `lastSeenReleaseVersion: '10.2.3'`; mit `APP_VERSION=dev` bzw. ungesetzt → `lastSeenReleaseVersion: null`
|
||||||
|
- Der Wert lässt sich über die Parameter von create() nicht von außen setzen (kein neues Feld in der Signatur)
|
||||||
|
- AdminSeedService legt den Erst-Administrator mit `lastSeenReleaseVersion` der laufenden Version an (`10.2.3` bzw. null bei `dev`)
|
||||||
|
- LDAP-Anlage: beide LDAP-Wege gehen über UserService.create (grep-Nachweis, kein eigener `user.create` in apps/api/src/ldap)
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Per D-04: In `UserService.create()` in den `data` von `tenantPrisma.user.create` das Feld `lastSeenReleaseVersion: getRunningRelease()` ergänzen (Import aus `../health/app-version`), NICHT als Parameter der Methode — der Wert ist eine Eigenschaft des Servers, nicht des Aufrufers. Kopfkommentar von `create()` um einen Absatz ergänzen: neue Benutzer (Admin-Anlage, beide LDAP-Wege) bekommen die laufende freigegebene Version eingetragen, damit sie kein „Was ist neu“-Fenster mit Altlasten sehen (quick-260925-bow); `null` auf Ständen ohne freigegebene Version. Dasselbe in `AdminSeedService` bei der Erstanlage des Administrators (dort ebenfalls kurzer Kommentar). Tests zuerst: in `user.service.spec.ts` und `admin-seed.service.spec.ts` je ein Fall mit `vi.stubEnv('APP_VERSION', 'v10.2.3-3-gabc1234')` und ein Fall mit `dev`, `vi.unstubAllEnvs()` im `afterEach`.
|
||||||
|
|
||||||
|
RLS-Buchführung (D-09): Aufgabe 1 hat in `user.controller.ts` neue gebundene Rohtreffer `tenantPrisma.user.` hinzugefügt (erwartet +3: ein `findUnique` im GET, `findUnique` + `update` im POST). Das Paar (Datei, Modell) bleibt `gebunden`, die Spec braucht keine neue Zeile. Mit der Gate-Schleife nachrechnen (je Bereichsverzeichnis `grep -ro` auf `this.prisma.<Modell>`, `tenantPrisma.<Modell>.`, `systemPrisma.<Modell>.`, ohne spec-Dateien; Summe über alle Bereiche) und eintragen: Zeile `| user | … |` mit den gemessenen Werten und einem vorangestellten Vermerk im etablierten Stil (**quick-260925-bow:** +N gebunden in `user.controller.ts`, „Was ist neu“-Fenster, `GET me/release-notice` und `POST me/release-seen`, nachgemessen mit der Gate-Schleife; danach „Vorher:“ und der bisherige Text); Summenzeile: Werte ersetzen, den Vermerk **quick-260925-bow:** an den Anfang der Hinweisspalte stellen und den bisherigen Text mit „Vorher:“ anhängen (die Gates erwarten den Vermerk jeweils direkt nach den Zahlen); in der Fundstellenzeile `apps/api/src/user/user.controller.ts | user` die Aufzählung der Selbstbedienungszugriffe um die beiden neuen Wege ergänzen (weiterhin `forTenant()`, `where: { id: currentUser.id }`). Werte messen, nicht aus diesem Plan abschreiben.
|
||||||
|
|
||||||
|
Commit(s): `feat(260925-bow): …`, `test(260925-bow): …`, `docs(260925-bow): RLS-Buchfuehrung …`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run user.service admin-seed rls-access-inventory && test -z "$(grep -rnE '(tenantPrisma|this\.prisma|tx)\.user\.create' apps/api/src/ldap --include=*.ts | grep -v '\.spec\.ts')" && grep -q 'getRunningRelease' apps/api/src/user/user.service.ts && grep -q 'getRunningRelease' apps/api/src/user/admin-seed.service.ts && K=docs/mandantentrennung-zugriffsklassifikation.md && U=$(grep -ro "this\.prisma\.[a-zA-Z]*" apps/api/src/user | grep -v spec | wc -l | tr -d ' ') && B=$(grep -ro "tenantPrisma\.[a-zA-Z]*\." apps/api/src/user | grep -v spec | wc -l | tr -d ' ') && S=$(grep -ro "systemPrisma\.[a-zA-Z]*\." apps/api/src/user | grep -v spec | wc -l | tr -d ' ') && { grep -qE "^\| user \| ${U} \| ${B} \| ${S} \| \*\*quick-260925-bow" "$K" || { echo "ZEILE user nennt nicht ${U}/${B}/${S} mit Vermerk"; exit 1; }; } && TU=0 && TB=0 && TS=0 && for d in apps/api/src/*/; do u=$(grep -ro "this\.prisma\.[a-zA-Z]*" "$d" 2>/dev/null | grep -v spec | wc -l | tr -d ' '); b=$(grep -ro "tenantPrisma\.[a-zA-Z]*\." "$d" 2>/dev/null | grep -v spec | wc -l | tr -d ' '); s=$(grep -ro "systemPrisma\.[a-zA-Z]*\." "$d" 2>/dev/null | grep -v spec | wc -l | tr -d ' '); TU=$((TU+u)); TB=$((TB+b)); TS=$((TS+s)); done && echo "ABGELEITET ${TU}/${TB}/${TS}" && { grep -qE "^\| \*\*Summe\*\* \| \*\*${TU}\*\* \| \*\*${TB}\*\* \| \*\*${TS}\*\* \| \*\*quick-260925-bow" "$K" || { echo "SUMMENZEILE nennt nicht ${TU}/${TB}/${TS} mit Vermerk"; exit 1; }; } && grep -E '^\| apps/api/src/user/user\.controller\.ts \| user \|' "$K" | grep -q 'release'</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Jeder Anlageweg (Admin-Anlage, LDAP-Abgleich und -Import über UserService.create, Erst-Administrator) trägt die laufende freigegebene Version ein, auf dev-Ständen null; Tests dafür grün; RLS-Bestandsaufnahme-Spec grün; Übersichts-, Summen- und Fundstellenzeile nachgemessen und mit Vermerk fortgeschrieben.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Aufgabe 3: Doku, CHANGELOG, Kommentare zur Importregel; volle Suiten, Lint, Biome-Grenzen und Nachweis, dass die Änderungsliste nicht in Client-Chunks landet</name>
|
||||||
|
<files>CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-entwicklung.md, apps/web/src/lib/changelog.ts, apps/web/next.config.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- CHANGELOG.md (Kopf bis „## 1.4.0“; „## Unveröffentlicht“ ist derzeit leer)
|
||||||
|
- docs/anleitung-anwender.md, Abschnitt „## Was ist neu“
|
||||||
|
- docs/anleitung-entwicklung.md, Absatz „**Änderungsliste (`CHANGELOG.md`):**“ (enthält die Regel, welche Datei das Changelog-Modul importieren darf)
|
||||||
|
- Kopfkommentar von apps/web/src/lib/changelog.ts und Kommentarblock oben in apps/web/next.config.ts
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Per D-10: Unter `## Unveröffentlicht` in CHANGELOG.md eine Gruppe `### Neu` mit einem Punkt in Alltagssprache, Sie-Form, echte Umlaute, ohne Dateinamen/Fachbegriffe, sinngemäß: Nach einem Versionswechsel zeigt Tessera bei Ihrer ersten Anmeldung ein Fenster mit den wichtigsten Änderungen der neuen Version – neue Funktionen, Verbesserungen und behobene Fehler; haben Sie mehrere Versionen verpasst, erscheinen die drei neuesten; „Verstanden“ schließt das Fenster, es erscheint erst mit der nächsten Version wieder, im Browser wie in der Desktop-App; die vollständige Liste bleibt unter „Was ist neu“. Das Wort „Versionswechsel“ muss im Punkt vorkommen (Gate).
|
||||||
|
|
||||||
|
Anwenderhandbuch `docs/anleitung-anwender.md`, Abschnitt „Was ist neu“: neuen Absatz (Sie-Form) — das Fenster nach einem Versionswechsel, was es zeigt (Neu / Verbessert / Behoben, höchstens drei Versionen, Link „Alle Änderungen ansehen“), wie es sich schließt, dass es pro Benutzer nur einmal je Version erscheint (auch in der Desktop-App), dass neu angelegte Konten es erst mit der nächsten Version sehen, und dass Beta-Punkte unter „Noch nicht freigegeben“ darin nicht vorkommen. Das Wort „Versionswechsel“ muss im Abschnitt vorkommen (Gate).
|
||||||
|
|
||||||
|
Entwicklerdoku `docs/anleitung-entwicklung.md`, im Absatz zur Änderungsliste: die Importregel erweitern (Server-Code darf das Changelog-Modul importieren: `page.tsx` UND die `'use server'`-Datei `release-notice-actions.ts`; Client-Komponenten nie, auch nicht `release-notes.ts`), danach ein kurzer Absatz zum Fenster: einzige Quelle der laufenden Version ist `APP_VERSION` der API (`getRunningRelease()`), Begründung (LDAP-Abgleich und Erst-Administrator laufen ohne Web), Endpunkte `GET /users/me/release-notice` und `POST /users/me/release-seen` (Validierung, nie absenken), Spalte `User.lastSeenReleaseVersion` (null = Bestandsbenutzer, zeigt nur die laufende Version), gemeinsame Funktionen `parseReleaseVersion`/`compareReleaseVersions` in `packages/shared`, Folge für die Freigabe: erst ein Tag `vX.Y.Z` (bzw. dessen Describe-Stand auf Beta) löst das Fenster aus, Punkte unter „Unveröffentlicht“ nie; lokal mit `dev` erscheint nie ein Fenster (zum Ausprobieren `APP_VERSION` als Build-Arg setzen). Die Kopfkommentare in `apps/web/src/lib/changelog.ts` und `apps/web/next.config.ts` an dieselbe erweiterte Importregel anpassen (nur Kommentar, kein Code).
|
||||||
|
|
||||||
|
Dann die vollen Prüfungen (D-09). Der Web-Build für den Chunk-Nachweis dauert einige Minuten (Befehl mit großzügiger Zeitgrenze ausführen); Build-Ausgaben (`apps/web/.next`, ggf. geändertes `apps/web/next-env.d.ts`) nicht committen — `next-env.d.ts` bei Änderung per `git checkout --` zurücksetzen. Scheitert `next build` lokal aus Gründen außerhalb dieser Änderung, das in der SUMMARY mit der Fehlermeldung festhalten und den Importnachweis aus Aufgabe 1 als Ersatz benennen — nicht stillschweigend überspringen. Biome-Formatierung neuer Dateien mit `biome check --write` angleichen; keine neuen `any`, Nicht-null-Behauptungen oder Biome-Ausnahmen in neuen Dateien.
|
||||||
|
|
||||||
|
Commit: `docs(260925-bow): …`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run && pnpm --filter @tessera/web exec vitest run && pnpm turbo run type-check lint && W=$(pnpm --filter @tessera/web exec biome lint . 2>&1 | grep -oE '^Found [0-9]+ warning' | grep -oE '[0-9]+' || true) && A=$(pnpm --filter @tessera/api exec biome lint . 2>&1 | grep -oE '^Found [0-9]+ warning' | grep -oE '[0-9]+' || true) && echo "Biome web=${W:-0} api=${A:-0}" && test "${W:-0}" -le 53 && test "${A:-0}" -le 82 && test -z "$(grep -vE '^\s*(//|\*|/\*|\{/\*)' apps/web/src/components/release-notice/release-notice-dialog.tsx apps/web/src/components/release-notice/release-notice-host.tsx | grep -nE 'uppercase|tracking-widest|·|→|dangerouslySetInnerHTML')" && node -e 'for (const f of ["de","en"]) { const m = require("./apps/web/src/messages/" + f + ".json").releaseNotice; if (!m) throw new Error(f + ": releaseNotice fehlt"); for (const k of ["title","intro","versionHeading","moreVersions","showAll","confirm","close","contentLabel"]) if (typeof m[k] !== "string" || !m[k]) throw new Error(f + ": fehlt releaseNotice." + k); for (const k of ["new","changed","fixed"]) if (!m.section || !m.section[k]) throw new Error(f + ": fehlt releaseNotice.section." + k); if (/·|→/.test(JSON.stringify(m))) throw new Error(f + ": verbotenes Zeichen"); }' && awk '/^## Unveröffentlicht/{f=1; next} /^## /{f=0} f' CHANGELOG.md | grep -q '^### Neu' && awk '/^## Unveröffentlicht/{f=1; next} /^## /{f=0} f' CHANGELOG.md | grep -q 'Versionswechsel' && awk '/^## Was ist neu/{f=1; next} /^## /{f=0} f' docs/anleitung-anwender.md | grep -q 'Versionswechsel' && grep -q 'release-notice-actions' docs/anleitung-entwicklung.md && grep -q 'getRunningRelease' docs/anleitung-entwicklung.md && LOG=$(mktemp) && { pnpm --filter @tessera/web build >"$LOG" 2>&1 || { tail -40 "$LOG"; exit 1; }; } && P='hochgeladene Bilder liegen jetzt im Dateibereich des Servers' && test -n "$(grep -rl "$P" apps/web/.next/server)" && test -z "$(grep -rl "$P" apps/web/.next/static)" && { git checkout -- apps/web/next-env.d.ts 2>/dev/null || true; } && git diff --quiet -- apps/web/next-env.d.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<done>CHANGELOG, Anwender- und Entwicklerdoku beschreiben das Fenster; Kommentare zur Importregel stimmen; volle API- und Web-Suite grün, `turbo type-check lint` grün, Biome-Warnungen Web ≤ 53 und API ≤ 82, Stil- und i18n-Gate leer bzw. vollständig; nach `next build` steht der Änderungslistentext im Server-Bundle, aber nicht unter `.next/static`.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Browser/Desktop-App → Web-Server-Aktion | Client ruft `fetchReleaseNotice`/`markReleaseSeenAction`; Eingabe `version` ist unvertrauenswürdig |
|
||||||
|
| Web → API (`/users/me/release-*`) | Session-Cookie authentifiziert; Body `{ version }` unvertrauenswürdig |
|
||||||
|
| API → PostgreSQL (User-Zeile) | Zeilenschutz je Mandant über `forTenant()` |
|
||||||
|
| Änderungsliste (Bauzeit-Text) → Client-Bundles | Text darf nicht in öffentlich abrufbare `/_next/static`-Chunks |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-BOW-01 | Tampering | `POST /users/me/release-seen` | low | mitigate | DTO (`@IsString`, `@MaxLength(32)`, `@Matches` kanonisch) plus Rumpfprüfung `parseReleaseVersion(v) === v`, `≤ getRunningRelease()`, `null`-laufend → 400; nie absenken. Wirkung beschränkt auf das eigene Fenster |
|
||||||
|
| T-BOW-02 | Elevation of Privilege | `GET/POST /users/me/release-*` | medium | mitigate | Kein Kennungsparameter; `where: { id: currentUser.id }` über `forTenant(this.prisma, currentUser.tenantId)`; Test mit zwei Benutzern in zwei Mandanten (Aufgabe 1) |
|
||||||
|
| T-BOW-03 | Information Disclosure | `release-notice-actions.ts` / Client-Chunks | low | mitigate | Changelog-Modul nur aus `'use server'`-Datei und `page.tsx`; `release-notes.ts` und Fenster-Dateien ohne diesen Import (Gate Aufgabe 1); Build-Nachweis `.next/static` enthält den Text nicht (Gate Aufgabe 3) |
|
||||||
|
| T-BOW-04 | Tampering (XSS) | `ReleaseNoticeDialog` | low | mitigate | Markdown ausschließlich über `ChangelogView` (`MDEditor.Markdown` + `rehype-sanitize`), kein rohes HTML-Einfügen (Stil-Gate Aufgabe 3); Quelle ist die versionierte CHANGELOG.md |
|
||||||
|
| T-BOW-05 | Denial of Service | Versionsparser | low | mitigate | Eingabelänge ≤ 64 im Parser, `@MaxLength(32)` in der DTO, verankerter Ausdruck ohne verschachtelte Wiederholungen |
|
||||||
|
| T-BOW-06 | Information Disclosure | `GET /users/me/release-notice` | low | accept | Nennt nur die laufende Version, die `GET /health/version` ohnehin öffentlich liefert (T-KU1-03), und den eigenen gemerkten Stand |
|
||||||
|
| T-BOW-07 | Tampering | Migration `20260925120000_user_last_seen_release` | low | accept | Reines `ADD COLUMN` nullbar ohne Standardwert, kein Datenumbau; `auth_lookup_*` liefern feste Spaltenlisten und bleiben unberührt |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
Executor (automatisiert, siehe Aufgaben): gezielte Tests je Schicht (Aufgabe 1), Anlagewege + RLS-Buchführung (Aufgabe 2), volle API- und Web-Suite, `pnpm turbo run type-check lint`, Biome-Warnungen Web ≤ 53 / API ≤ 82, Stil- und i18n-Gate, Chunk-Nachweis per `next build` (Aufgabe 3).
|
||||||
|
|
||||||
|
Orchestrator (D-11, Browserprüfung mit Playwright am lokalen Stack, NICHT Aufgabe des Executors):
|
||||||
|
1. Lokal mit einer freigegebenen Versionsnummer bauen, damit überhaupt ein Fenster entstehen kann (lokal steht sonst `dev`): `docker compose build --build-arg APP_VERSION=1.4.0-1-g0000000 api web && docker compose up -d --force-recreate api web` — die Migration läuft beim API-Start.
|
||||||
|
2. `docker exec tessera-ctl-db-1 psql -U tessera -d tessera -c "UPDATE \"User\" SET \"lastSeenReleaseVersion\"='1.0.0' WHERE username='admin'"` → Anmelden: Fenster „Neu in Version 1.4.0“ mit 1.4.0, 1.3.1, 1.3.0, Satz „Dazu kommen Änderungen aus 2 älteren Versionen.“, Link zu /changelog; Überschrift hat den Fokus; Tab bleibt im Fenster; Dunkelmodus prüfen.
|
||||||
|
3. „Verstanden“ → DB zeigt `1.4.0`; Neuladen → kein Fenster.
|
||||||
|
4. `lastSeenReleaseVersion = NULL` → nur Abschnitt 1.4.0, keine Versionsunterüberschrift. `= '1.4.0'` → kein Fenster. Escape und Hintergrundklick schließen ebenfalls und merken.
|
||||||
|
5. Anmeldeseite zeigt nie ein Fenster; „Fehler melden“ mit offenem Fenster funktioniert (Bildschirmfoto enthält das Fenster).
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Bestandsbenutzer mit älterem Stand sehen nach einem Versionswechsel genau einmal das Fenster mit Neu / Verbessert / Behoben der verpassten Versionen (höchstens drei, neueste zuerst, Hinweis auf weitere), Schließen merkt dauerhaft pro Benutzer.
|
||||||
|
- Neu angelegte Benutzer (Admin, LDAP, Erst-Administrator) und dev-Stände sehen kein Fenster.
|
||||||
|
- Server validiert und bindet an Benutzer und Mandant; RLS-Buchführung nachgemessen.
|
||||||
|
- Alle Suiten, type-check, lint grün; Biome Web ≤ 53, API ≤ 82; Änderungsliste nicht in Client-Chunks.
|
||||||
|
- CHANGELOG „Unveröffentlicht → Neu“, Anwender- und Entwicklerdoku ergänzt.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/quick/260925-bow-was-ist-neu-fenster-beim-ersten-anmelden/260925-bow-SUMMARY.md` when done (gemessene Biome-Zahlen, gemessene RLS-Zählwerte user/Summe, Ergebnis des Chunk-Nachweises, Testzahlen API/Web).
|
||||||
|
</output>
|
||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260925-bow
|
||||||
|
plan: 01
|
||||||
|
quick_id: 260925-bow
|
||||||
|
status: complete
|
||||||
|
subsystem: web + api (Benutzer, Änderungsliste)
|
||||||
|
tags: [release-notice, changelog, user, prisma, a11y, rls]
|
||||||
|
requires:
|
||||||
|
- CHANGELOG.md als Bauzeit-Text (quick-260916-dcz)
|
||||||
|
- APP_VERSION als Laufzeit-ENV der API (quick-260914-ku1)
|
||||||
|
provides:
|
||||||
|
- "Spalte User.lastSeenReleaseVersion (Migration 20260925120000_user_last_seen_release)"
|
||||||
|
- "parseReleaseVersion / compareReleaseVersions / ReleaseNoticeResponse in @tessera/shared"
|
||||||
|
- "getRunningRelease() als einzige Quelle der laufenden Version"
|
||||||
|
- "GET /users/me/release-notice, POST /users/me/release-seen"
|
||||||
|
- "selectReleaseNotice (Web), Server-Aktionen, ReleaseNoticeDialog, ReleaseNoticeHost in AppShell"
|
||||||
|
affects:
|
||||||
|
- apps/web/src/components/layout/app-shell.tsx
|
||||||
|
- apps/api/src/user/user.service.ts (create)
|
||||||
|
- apps/api/src/user/admin-seed.service.ts
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "Server-Aktion in eigener 'use server'-Datei als einziger Web-Importeur der Änderungsliste neben page.tsx"
|
||||||
|
- "Fenster per React.lazy nur bei vorhandener Nachricht geladen"
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- apps/api/prisma/migrations/20260925120000_user_last_seen_release/migration.sql
|
||||||
|
- apps/api/src/health/release-version.spec.ts
|
||||||
|
- apps/api/src/user/dto/release-seen.dto.ts
|
||||||
|
- apps/web/src/lib/release-notes.ts
|
||||||
|
- apps/web/src/lib/release-notes.test.ts
|
||||||
|
- apps/web/src/lib/release-notice-actions.ts
|
||||||
|
- apps/web/src/lib/release-notice-actions.test.ts
|
||||||
|
- apps/web/src/components/release-notice/release-notice-dialog.tsx
|
||||||
|
- apps/web/src/components/release-notice/release-notice-dialog.test.tsx
|
||||||
|
- apps/web/src/components/release-notice/release-notice-host.tsx
|
||||||
|
- apps/web/src/components/release-notice/release-notice-host.test.tsx
|
||||||
|
modified:
|
||||||
|
- packages/shared/src/index.ts
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/src/health/app-version.ts
|
||||||
|
- apps/api/src/user/user.controller.ts
|
||||||
|
- apps/api/src/user/user.controller.spec.ts
|
||||||
|
- apps/api/src/user/user.service.ts
|
||||||
|
- apps/api/src/user/user.service.spec.ts
|
||||||
|
- apps/api/src/user/admin-seed.service.ts
|
||||||
|
- apps/api/src/user/admin-seed.service.spec.ts
|
||||||
|
- apps/web/src/components/changelog/changelog-view.tsx
|
||||||
|
- apps/web/src/components/layout/app-shell.tsx
|
||||||
|
- apps/web/src/messages/de.json
|
||||||
|
- apps/web/src/messages/en.json
|
||||||
|
- apps/web/src/messages/umlaut-dictionary.ts
|
||||||
|
- apps/web/src/lib/changelog.ts
|
||||||
|
- apps/web/next.config.ts
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
- docs/anleitung-anwender.md
|
||||||
|
- docs/anleitung-entwicklung.md
|
||||||
|
- CHANGELOG.md
|
||||||
|
decisions:
|
||||||
|
- "Einzige Quelle der laufenden Version ist APP_VERSION der API (getRunningRelease()); das Web nimmt currentRelease aus GET /users/me/release-notice"
|
||||||
|
- "Fehlt der Abschnitt der laufenden Version in der Änderungsliste des Web-Abbilds ganz, gibt es kein Fenster; eine laufende Version nur mit „Entfernt“ zählt ebenso als ohne Abschnitt"
|
||||||
|
- "Scrollbereich des Fensters ist ein benannter section ohne tabIndex (keine neue Biome-Warnung, keine Biome-Ausnahme)"
|
||||||
|
metrics:
|
||||||
|
duration: 16min
|
||||||
|
completed: 2026-09-25
|
||||||
|
actuals:
|
||||||
|
tokens: 30400
|
||||||
|
tasks: 3
|
||||||
|
commits: 9
|
||||||
|
plan_head_before: 9225ed1bf980aa688e50b55f23162927bf95b40c
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260925-bow: „Was ist neu“-Fenster beim ersten Anmelden nach einem Versionswechsel Summary
|
||||||
|
|
||||||
|
Nach einem Versionswechsel zeigt Tessera jedem Benutzer beim ersten Laden des Portals einmal ein Fenster „Neu in Version X.Y.Z“ mit Neu / Verbessert / Behoben aus CHANGELOG.md (höchstens drei Versionen, neueste zuerst). Der gesehene Stand liegt pro Benutzer in der neuen Spalte `User.lastSeenReleaseVersion` und wird erst beim Schließen über `POST /users/me/release-seen` gemerkt, gebunden an Benutzer und Mandanten.
|
||||||
|
|
||||||
|
## Was gebaut wurde
|
||||||
|
|
||||||
|
**Aufgabe 1 (Tracer, DB → API → Server-Aktion → Fenster)**
|
||||||
|
- `packages/shared`: `parseReleaseVersion` (optionales `v`, drei Zifferngruppen zu je 1 bis 6 Ziffern, optional Describe-Anhang, verankert, ≤ 64 Zeichen, kanonisch ohne führende Nullen), `compareReleaseVersions` (numerisch, wirft bei Unparsebarem), `ReleaseNoticeResponse`. Warnkommentar über `WIDGET_TYPES` ergänzt.
|
||||||
|
- `getRunningRelease()` in `app-version.ts` mit Begründung, warum die API die Quelle ist.
|
||||||
|
- Spalte + Migration (`ALTER TABLE "User" ADD COLUMN "lastSeenReleaseVersion" TEXT;`), kein Standardwert, kein Backfill. **Lokal auf die DB angewendet** (`prisma migrate deploy` über 172.19.0.2); `tessera_app` hat Rechte auf Tabellenebene und damit auch auf die neue Spalte.
|
||||||
|
- `GET me/release-notice` und `POST me/release-seen` in `user.controller.ts` vor der Kennungs-Route, ohne `@Roles`, beide über `forTenant()` + `where: { id: currentUser.id }`. `ReleaseSeenDto` mit `@IsString`, `@MaxLength(32)`, `@Matches` auf kanonisches X.Y.Z; im Rumpf zusätzlich Format-, dev- und „nicht über laufend“-Prüfung; nie absenken, unparsebaren Altwert überschreiben.
|
||||||
|
- Web: `release-notes.ts` (reine Auswahl, kein Import der Änderungsliste), `release-notice-actions.ts` (`'use server'`), `ReleaseNoticeDialog` (role=dialog, aria-modal, aria-labelledby, Anfangsfokus Überschrift, Escape, Fokusfalle mit dynamischer Elementliste, Fokus-Rückgabe, keine Animation, nur Tailwind-Tokens), `ReleaseNoticeHost` (Ref-Sperre, nicht auf `/change-password`, `React.lazy`, merkt erst beim Schließen), eingebunden nur in `AppShell`. `ChangelogView` mit `variant="plain"`. Texte `releaseNotice` in de.json/en.json.
|
||||||
|
|
||||||
|
**Aufgabe 2 (Anlagewege)**
|
||||||
|
- `UserService.create()` (Admin-Anlage und beide LDAP-Wege) und `AdminSeedService` setzen `lastSeenReleaseVersion: getRunningRelease()`; kein neuer Parameter. grep-Nachweis: kein eigener `user.create` in `apps/api/src/ldap`.
|
||||||
|
- RLS-Buchführung nachgemessen mit der Gate-Schleife: **user 8/17/0** (vorher 8/14/0, +3 gebunden in `user.controller.ts`), **Summe 61/216/6** (vorher 61/213/6). Übersichts-, Summen- und Fundstellenzeile mit Vermerk **quick-260925-bow** fortgeschrieben. `rls-access-inventory.spec.ts` grün (Paar `user.controller.ts | user | gebunden` unverändert).
|
||||||
|
|
||||||
|
**Aufgabe 3 (Doku und volle Prüfungen)**
|
||||||
|
- CHANGELOG „Unveröffentlicht → Neu“, Abschnitt „Was ist neu“ im Anwenderhandbuch, Entwicklerdoku (Importregel erweitert, Versionsquelle, Endpunkte, Spalte, Folge für die Freigabe, Ausprobieren mit `APP_VERSION`), Kopfkommentare `changelog.ts` und `next.config.ts`.
|
||||||
|
|
||||||
|
## Gemessene Ergebnisse
|
||||||
|
|
||||||
|
| Prüfung | Ergebnis |
|
||||||
|
|---|---|
|
||||||
|
| API-Suite (voll) | 85 Dateien, **1435 Tests grün** |
|
||||||
|
| Web-Suite (voll) | 95 Dateien, **924 Tests grün** |
|
||||||
|
| `pnpm turbo run type-check lint` | 9/9 Tasks erfolgreich |
|
||||||
|
| Biome-Warnungen | **Web 53** (Grenze 53), **API 82** (Grenze 82) |
|
||||||
|
| Stil-Gate (Versal/Sperrschrift, `·`, `→`, rohes HTML) | leer |
|
||||||
|
| i18n-Gate `releaseNotice` de/en | vollständig |
|
||||||
|
| RLS user / Summe | 8/17/0 / 61/216/6 |
|
||||||
|
| `next build` | erfolgreich |
|
||||||
|
| Chunk-Nachweis | Satz „hochgeladene Bilder liegen jetzt im Dateibereich des Servers“: 2 Dateien unter `.next/server` (`changelog/page.js` und der Server-Chunk der Aktion), **0 unter `.next/static`**; ebenso der neue CHANGELOG-Eintrag (2 / 0). `next-env.d.ts` unverändert. |
|
||||||
|
|
||||||
|
Abgleich mit der echten CHANGELOG.md: gemerkt `1.0.0`, laufend `1.4.0` ergibt 1.4.0 (new/changed/fixed), 1.3.1 (changed/fixed), 1.3.0 (new/changed/fixed) und `omittedCount` 2, also genau das, was die Browserprüfung des Orchestrators erwartet; gemerkt `null` ergibt nur 1.4.0; gemerkt `1.4.0` ergibt kein Fenster.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] StrictMode-Doppeleffekt verwarf das Abrufergebnis im Host**
|
||||||
|
- **Found during:** Aufgabe 1 (Host-Test „StrictMode-Doppeleffekt führt nicht zu zwei Abrufen“)
|
||||||
|
- **Issue:** Ein Abbruch-Flag im Aufräumen des Effekts wurde beim simulierten Unmount gesetzt, der zweite Effektlauf fragte wegen der Ref-Sperre nicht erneut, das einzige Ergebnis ging verloren, kein Fenster.
|
||||||
|
- **Fix:** Abbruch-Flag entfernt (setState nach Unmount ist in React 19 folgenlos), Kommentar dazu.
|
||||||
|
- **Commit:** 187fb76
|
||||||
|
|
||||||
|
**2. [Rule 3 - Blocking] Umlaut-Wächter kannte „Verbessert“ nicht**
|
||||||
|
- **Found during:** Aufgabe 3 (volle Web-Suite)
|
||||||
|
- **Issue:** `umlaut-guard.spec.ts` meldete das korrekt geschriebene Wort „Verbessert“ (Gruppe `releaseNotice.section.changed`) als unbekanntes „ss“-Wort.
|
||||||
|
- **Fix:** „Verbessert“ in `UMLAUT_ALLOWLIST` (`umlaut-dictionary.ts`) mit Vermerk aufgenommen.
|
||||||
|
- **Commit:** 2aeb3e8
|
||||||
|
|
||||||
|
**3. [Rule 3 - Blocking] Scrollbereich ohne `tabIndex={0}`**
|
||||||
|
- **Issue:** Der geplante `div tabIndex={0} aria-label` erzeugte zwei neue Biome-Warnungen (`noNoninteractiveTabindex`, `useAriaPropsSupportedByRole`) und hätte die Grenze Web ≤ 53 gerissen; Biome-Ausnahmen in neuen Dateien sind laut Plan verboten.
|
||||||
|
- **Fix:** Benannter `<section aria-label={contentLabel}>` ohne tabIndex. Heutige Chromium- und WebKit-Versionen (Browser und Desktop-App) machen einen Scroll-Container ohne fokussierbaren Inhalt selbst per Tastatur erreichbar; enthält er Links, scrollt der Tab-Fokus mit. Innere Versionsblöcke sind `div` statt `section`, damit keine verschachtelten Landmarken entstehen.
|
||||||
|
- **Commit:** 187fb76
|
||||||
|
|
||||||
|
**4. [Kleinigkeit] Überschriften-Hierarchie**
|
||||||
|
- Bei einer Version sind die Gruppen `h3` (wie geplant); bei mehreren Versionen trägt die Version `h3` und die Gruppen `h4` (statt einer Versionszeile ohne Überschriftenrolle). Test entsprechend.
|
||||||
|
|
||||||
|
**5. [Hinweis] `prisma validate` braucht DATABASE_URL**
|
||||||
|
- Das Aufgabe-1-Gate ruft `prisma validate` ohne Umgebung auf; hier scheitert das nur an der fehlenden `DATABASE_URL` (P1012). Mit gesetzter URL (echte Container-IP bzw. Platzhalter) ist das Schema gültig. `prisma format` wurde bewusst NICHT verwendet, weil es die ganze Schemadatei umformatiert hätte; die neue Zeile ist von Hand eingetragen.
|
||||||
|
|
||||||
|
**6. [Hinweis] Bestehende Format-Befunde nicht angefasst**
|
||||||
|
- `biome check` meldet in `app-shell.tsx` und `changelog-view.tsx` Format-/Importreihenfolge-Befunde, die schon vorher bestanden; das Lint-Skript ist `biome lint`, die Dateien wurden deshalb nicht umformatiert. Neue Dateien sind mit `biome check --write` formatiert, ohne Ausnahmen, `any` nur in Test-Attrappen wie im Bestand.
|
||||||
|
|
||||||
|
## Hinweise für den Orchestrator (Browserprüfung, D-11)
|
||||||
|
|
||||||
|
- Die Migration ist auf der lokalen DB bereits angewendet; der laufende API-Container (altes Abbild) stört sich an der zusätzlichen nullbaren Spalte nicht. Für die Prüfung muss wie im Plan beschrieben mit `--build-arg APP_VERSION=1.4.0-1-g0000000` neu gebaut werden (der Describe-Anhang braucht mindestens 4 Hex-Zeichen nach `g`, `g0000000` passt).
|
||||||
|
- Auf `/change-password` wird nicht abgefragt; die nächste Seite danach fragt einmal.
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
Keine neuen Angriffsflächen außerhalb des Bedrohungsmodells: die zwei Endpunkte (T-BOW-01/02/05/06), die Migration (T-BOW-07), die Server-Aktion/Chunks (T-BOW-03) und das Markdown-Rendering über `ChangelogView` mit `rehype-sanitize` (T-BOW-04) sind dort erfasst und umgesetzt.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
| Hash | Nachricht |
|
||||||
|
|---|---|
|
||||||
|
| b35edd5 | test(260925-bow): Versionsvergleich und Was-ist-neu-Endpunkte (rot) |
|
||||||
|
| 59db32a | feat(260925-bow): gesehene Version pro Benutzer merken - Spalte, Versionsfunktionen, API |
|
||||||
|
| cf7784e | test(260925-bow): Auswahl, Server-Aktionen, Fenster und Host des Was-ist-neu-Fensters |
|
||||||
|
| 187fb76 | feat(260925-bow): Was-ist-neu-Fenster im Portal-Rahmen |
|
||||||
|
| 25c8db7 | test(260925-bow): Anlagewege tragen die laufende Version ein (rot) |
|
||||||
|
| 5ae9aaa | feat(260925-bow): neue Benutzer bekommen die laufende Version eingetragen |
|
||||||
|
| 4fa5aaf | docs(260925-bow): RLS-Buchfuehrung nachgemessen (user 8/17/0, Summe 61/216/6) |
|
||||||
|
| 2aeb3e8 | fix(260925-bow): Umlaut-Waechter kennt das korrekte Wort Verbessert |
|
||||||
|
| b3b7b5d | docs(260925-bow): Was-ist-neu-Fenster in CHANGELOG, Anwender- und Entwicklerdoku |
|
||||||
|
|
||||||
|
Nicht gepusht, kein Tag, keine Freigabe. `.planning/**` nicht committet.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
Alle 11 neuen Dateien vorhanden, alle 9 Commits im Verlauf (`git rev-list --count 9225ed1..HEAD` = 9).
|
||||||
+193
@@ -0,0 +1,193 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260928-ujj
|
||||||
|
plan: 01
|
||||||
|
quick_id: 260928-ujj
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
autonomous: true
|
||||||
|
requirements: [QUICK-260928-ujj]
|
||||||
|
files_modified:
|
||||||
|
- apps/web/** (Merge design/mosaik, 115 Dateien, nur apps/web)
|
||||||
|
- packages/shared/src/index.ts
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql (neu)
|
||||||
|
- apps/api/src/auth/auth.service.ts
|
||||||
|
- apps/api/src/auth/auth.service.spec.ts
|
||||||
|
- apps/api/src/user/user.controller.ts
|
||||||
|
- apps/api/src/user/user.controller.spec.ts
|
||||||
|
- apps/web/src/lib/auth-actions.ts
|
||||||
|
- apps/web/src/lib/stores/auth-store.ts
|
||||||
|
- apps/web/src/components/layout/header.tsx
|
||||||
|
- apps/web/src/components/layout/header.test.tsx
|
||||||
|
- apps/web/src/lib/dashboard-background.ts
|
||||||
|
- apps/web/src/lib/dashboard-background.test.ts
|
||||||
|
- apps/web/src/components/dashboard/dashboard-background.tsx
|
||||||
|
- apps/web/src/components/dashboard/dashboard-background.test.tsx (neu)
|
||||||
|
- "apps/web/src/app/(portal)/page.tsx"
|
||||||
|
- "apps/web/src/app/(portal)/page.test.tsx"
|
||||||
|
- apps/web/src/messages/de.json
|
||||||
|
- apps/web/src/messages/en.json
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
- docs/anleitung-anwender.md
|
||||||
|
- docs/anleitung-entwicklung.md
|
||||||
|
- CHANGELOG.md
|
||||||
|
|
||||||
|
estimate:
|
||||||
|
tokens: 95000
|
||||||
|
raw_tokens: 95000
|
||||||
|
tasks: 3
|
||||||
|
confidence: low
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "main enthaelt das freigegebene Design Mosaik als Merge-Commit mit design/mosaik (76d17fe) als zweitem Elternteil; apps/web-Tests sind gruen"
|
||||||
|
- "Widgets lassen sich schmaler ziehen, auch wenn die Maus dabei leicht wackelt (RESIZE_AXIS_FALLBACK in dashboard-grid.tsx, Test in dashboard-grid.test.tsx gruen)"
|
||||||
|
- "Der gewaehlte Dashboard-Hintergrund steht pro Benutzer in der Datenbank (User.dashboardBackground) und kommt ueber dieselbe Anmelde-/Sitzungsantwort zurueck wie accentColor — im zweiten Browser erscheint derselbe Hintergrund"
|
||||||
|
- "PATCH /users/me/dashboard-background nimmt nur 'none', bekannte Preset-Kennungen oder eine UUID-Bildkennung an; alles andere ergibt 400 und schreibt nichts"
|
||||||
|
- "Eine bereits im localStorage gespeicherte Wahl wird einmalig in die Datenbank uebernommen und der alte Schluessel entfernt"
|
||||||
|
- "CHANGELOG.md 'Unveroeffentlicht' beschreibt das neue Aussehen (Neu/Geaendert) und den Resize-Fehler (Behoben); die Anwender-Anleitung beschreibt die Hintergrundwahl"
|
||||||
|
artifacts:
|
||||||
|
- path: apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql
|
||||||
|
provides: "Spalte User.dashboardBackground (JSONB, nullable)"
|
||||||
|
contains: "dashboardBackground"
|
||||||
|
- path: packages/shared/src/index.ts
|
||||||
|
provides: "DASHBOARD_BACKGROUND_PRESET_IDS, Typ DashboardBackground, parseDashboardBackground() — eine Pruefregel fuer API und Web"
|
||||||
|
contains: "parseDashboardBackground"
|
||||||
|
- path: apps/api/src/user/user.controller.ts
|
||||||
|
provides: "PATCH me/dashboard-background"
|
||||||
|
contains: "me/dashboard-background"
|
||||||
|
- path: apps/web/src/components/dashboard/dashboard-background.tsx
|
||||||
|
provides: "useDashboardBackground liest aus dem Auth-Store und speichert ueber die Server-Aktion"
|
||||||
|
key_links:
|
||||||
|
- from: apps/web/src/components/dashboard/dashboard-background.tsx
|
||||||
|
to: "PATCH /users/me/dashboard-background"
|
||||||
|
via: "updateDashboardBackgroundAction in apps/web/src/lib/auth-actions.ts"
|
||||||
|
pattern: "updateDashboardBackgroundAction"
|
||||||
|
- from: apps/api/src/auth/auth.service.ts
|
||||||
|
to: "User.dashboardBackground"
|
||||||
|
via: "select neben accentColor, Ausgabe durch parseDashboardBackground normalisiert"
|
||||||
|
pattern: "dashboardBackground: true"
|
||||||
|
- from: apps/web/src/components/layout/header.tsx
|
||||||
|
to: apps/web/src/lib/stores/auth-store.ts
|
||||||
|
via: "setUser-Abbildung uebernimmt dashboardBackground aus der Sitzung"
|
||||||
|
pattern: "dashboardBackground"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Das vom Nutzer abgenommene Design „Mosaik“ (Zweig design/mosaik, nur apps/web) in main uebernehmen, die Hintergrundwahl des Dashboards von localStorage auf ein Datenbankfeld pro Benutzer umstellen (Muster accentColor) und CHANGELOG sowie Anleitungen fuer Version 1.5.0 vorbereiten.
|
||||||
|
|
||||||
|
Purpose: Das neue Aussehen samt Resize-Fix soll als 1.5.0 ausgeliefert werden; der Hintergrund soll dem Benutzer auf jedem Geraet folgen statt an einem Browser zu kleben.
|
||||||
|
Output: Merge-Commit, Migration + API-Weg + Web-Anbindung mit Tests, CHANGELOG-/Doku-Eintraege. Release (Tag, live-Zweig, Push) ist NICHT Teil dieses Plans — nicht pushen.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@~/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/STATE.md
|
||||||
|
@CLAUDE.md
|
||||||
|
@.planning/HANDOFF.json
|
||||||
|
|
||||||
|
Fakten (nicht neu herleiten):
|
||||||
|
- Zweig design/mosaik liegt lokal (Spitze 76d17fe, 15 Commits auf 3fc33e3), aendert nur apps/web (115 Dateien, keine package.json/Lockfile). Merge ist konfliktfrei. Die nicht committete Aenderung an .planning/HANDOFF.json beruehrt der Merge nicht — NICHT stagen, NICHT verwerfen.
|
||||||
|
- Hintergrund-Datentyp heute in apps/web/src/lib/dashboard-background.ts (Stand design/mosaik): kind 'none' | 'preset' (id aus mist, pebble, bloom, dunes, mosaic) | 'image' (imageId = Kennung eines Bilderrahmen-Bildes, DashboardImage.id ist uuid()). Speicherung per localStorage-Schluessel tessera.dashboardBackground.<userId>.
|
||||||
|
- Vorbild accentColor: schema.prisma Zeile ~45; Auswahl in apps/api/src/auth/auth.service.ts (~Zeile 320-345, select mit accentColor, speist die Sitzungsantwort); PATCH me/accent-color in apps/api/src/user/user.controller.ts (~Zeile 463-484, forTenant + where id currentUser.id, Inline-Body-Typ); Web: AuthUser in auth-actions.ts und stores/auth-store.ts, updateAccentColorAction in auth-actions.ts (~Zeile 220), setUser-Abbildung in components/layout/header.tsx (~Zeile 47-55).
|
||||||
|
- Vorbild Migration: apps/api/prisma/migrations/20260925120000_user_last_seen_release/migration.sql (deutscher Kopfkommentar, Hinweis auf auth_lookup_*-Funktionen mit fester Spaltenliste).
|
||||||
|
- NestJS-Routenreihenfolge: @Patch(':id') steht bei Zeile ~273; zweisegmentige Pfade wie me/accent-color werden davon nicht verschattet — der neue Pfad me/dashboard-background ist ebenfalls zweisegmentig.
|
||||||
|
- GET /dashboard/images/:id prueft den Besitz (dashboard-images.service.ts) — eine fremde Bildkennung liefert nur 404, deshalb reicht serverseitig die Formatpruefung.
|
||||||
|
- RLS-Inventar-Test apps/api/src/prisma/rls-access-inventory.spec.ts vergleicht Paare (Datei, Modell) gegen docs/mandantentrennung-zugriffsklassifikation.md; user.controller.ts + user existiert schon gebunden, also kein neues Paar — nur den Zeilentext fortschreiben.
|
||||||
|
- Lokale DB hat keinen Host-Port: Prisma vom Host ueber die Container-IP (172.19.x, docker inspect) mit tessera:tessera_dev.
|
||||||
|
- CHANGELOG.md wird vom Was-ist-neu-Fenster geparst: Ueberschriftenformat „## Unveröffentlicht“ / „### Neu|Geändert|Behoben“ exakt beibehalten.
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Design Mosaik in main mergen</name>
|
||||||
|
<files>apps/web/** (aus design/mosaik)</files>
|
||||||
|
<action>Auf main (HEAD a8a910f) pruefen, dass design/mosaik auf 76d17fe steht und `git diff --name-only 3fc33e3 design/mosaik` ausschliesslich apps/web-Pfade zeigt. Dann `git merge --no-ff design/mosaik -m "feat(260928-ujj): Design Mosaik uebernehmen"` ausfuehren (Nachricht kurz, deutsch ohne Umlaute; im Rumpf eine Zeile, dass der Merge den Resize-Achsen-Fallback fuer schmaler gezogene Widgets mitbringt). .planning/HANDOFF.json bleibt unangetastet und ungestaged. Kein pnpm install noetig (keine Abhaengigkeitsaenderung). Danach Web-Tests und Typpruefung laufen lassen; schlaegt etwas fehl, das im Klon gruen war, Ursache beheben und als eigener Commit fix(260928-ujj) nachziehen — nicht in den Merge-Commit falten.</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && git merge-base --is-ancestor 76d17fe HEAD && grep -q RESIZE_AXIS_FALLBACK apps/web/src/components/dashboard/dashboard-grid.tsx && pnpm --filter web test && pnpm --filter web type-check</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Merge-Commit auf main mit 76d17fe als Elternteil; dashboard-grid.tsx enthaelt RESIZE_AXIS_FALLBACK; `pnpm --filter web test` und `pnpm --filter web type-check` gruen; HANDOFF.json weiterhin nur als lokale Aenderung vorhanden.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: Dashboard-Hintergrund pro Benutzer in der Datenbank</name>
|
||||||
|
<files>packages/shared/src/index.ts, apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql, apps/api/src/auth/auth.service.ts, apps/api/src/auth/auth.service.spec.ts, apps/api/src/user/user.controller.ts, apps/api/src/user/user.controller.spec.ts, apps/web/src/lib/auth-actions.ts, apps/web/src/lib/stores/auth-store.ts, apps/web/src/components/layout/header.tsx, apps/web/src/components/layout/header.test.tsx, apps/web/src/lib/dashboard-background.ts, apps/web/src/lib/dashboard-background.test.ts, apps/web/src/components/dashboard/dashboard-background.tsx, apps/web/src/components/dashboard/dashboard-background.test.tsx, apps/web/src/app/(portal)/page.tsx, apps/web/src/app/(portal)/page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, docs/mandantentrennung-zugriffsklassifikation.md</files>
|
||||||
|
<behavior>
|
||||||
|
- API: PATCH me/dashboard-background mit background {kind:'none'} / {kind:'preset', id:'dunes'} / {kind:'image', imageId:<uuid>} schreibt genau das normalisierte Objekt (Zusatzschluessel entfernt) per forTenant mit where id = currentUser.id und liefert {success:true, dashboardBackground}
|
||||||
|
- API: unbekannte Preset-Kennung, unbekanntes kind, imageId kein UUID (z. B. 'img-1', mit Anfuehrungszeichen/Klammern, laenger als 36), background null/fehlend/kein Objekt/Array ergibt BadRequestException und kein update-Aufruf
|
||||||
|
- API: auth.service liefert dashboardBackground neben accentColor; gespeichertes gueltiges Objekt kommt normalisiert zurueck, NULL oder ungueltiger Inhalt kommt als null zurueck
|
||||||
|
- Web-Lib: takeLegacyDashboardBackground(userId) liefert eine gueltige alte localStorage-Wahl und entfernt den Schluessel; ungueltiger Wert ergibt null und entfernt ebenfalls; gesperrter Speicher ergibt null ohne Ausnahme
|
||||||
|
- Web-Lib: Preset-Kennungen in BACKGROUND_PRESETS sind deckungsgleich mit DASHBOARD_BACKGROUND_PRESET_IDS aus @tessera/shared
|
||||||
|
- Web-Hook: background kommt aus user.dashboardBackground im Auth-Store (null ergibt 'none'); choose() setzt den Store sofort und ruft updateDashboardBackgroundAction; bei Fehlschlag wird der vorige Wert zurueckgesetzt
|
||||||
|
- Web-Hook: ist der Server-Wert null und liegt eine alte localStorage-Wahl vor, wird sie genau einmal gespeichert; ist der Server-Wert gesetzt, passiert keine Uebernahme
|
||||||
|
</behavior>
|
||||||
|
<action>Zuerst die Tests aus dem behavior-Block schreiben (rot), dann umsetzen.
|
||||||
|
|
||||||
|
Gemeinsame Pruefregel: In packages/shared/src/index.ts DASHBOARD_BACKGROUND_PRESET_IDS (mist, pebble, bloom, dunes, mosaic als const-Tupel), Typ DashboardBackgroundPresetId, Typ DashboardBackground (drei Faelle wie im Web heute) und parseDashboardBackground(value: unknown): DashboardBackground | null ergaenzen. Die Funktion baut immer ein frisches Objekt nur aus den erlaubten Feldern; imageId muss eine UUID (8-4-4-4-12 Hex, Gross/Klein egal) sein — das haelt auch jede CSS-Einschleusung in den spaeteren url("...")-Stil fern. Deutscher Kommentar mit Verweis quick-260928-ujj im Stil der Datei.
|
||||||
|
|
||||||
|
API: In schema.prisma am User nach lastSeenReleaseVersion das Feld dashboardBackground Json? mit Kommentar (quick-260928-ujj, null = nie gewaehlt, sonst normalisiertes Objekt inkl. kind none). Migration 20260928120000_user_dashboard_background/migration.sql von Hand im Stil der Vorlage: deutscher Kopfkommentar (Zweck, NULL-Bedeutung, kein Backfill, auth_lookup_* unberuehrt) und ALTER TABLE "User" ADD COLUMN "dashboardBackground" JSONB. Danach `pnpm --filter api exec prisma generate`. Laeuft der lokale Stack, die Migration zusaetzlich per prisma migrate deploy gegen die Container-IP der db einspielen (tessera:tessera_dev, DB-Name aus .env/Compose) — kein Gate. In auth.service.ts im select neben accentColor dashboardBackground aufnehmen und in der Rueckgabe durch parseDashboardBackground normalisieren; auth.service.spec.ts Fixture/Erwartungen (~Zeile 500-540) ergaenzen. In user.controller.ts direkt nach updateAccentColor eine Methode updateDashboardBackground mit @Patch('me/dashboard-background'), Inline-Body-Typ mit background: unknown (wie accent-color, bewusst keine DTO-Klasse — die globale ValidationPipe mit whitelist wuerde verschachtelte Felder sonst nicht pruefen), parseDashboardBackground, bei null BadRequestException('Invalid dashboard background.'), sonst forTenant(...).user.update mit where id currentUser.id und data dashboardBackground; JSDoc mit Bedrohungsverweis T-ujj-01/02. Tests als neuer describe-Block „Dashboard-Hintergrund (quick-260928-ujj)“ in user.controller.spec.ts nach dem Muster des Was-ist-neu-Blocks. In docs/mandantentrennung-zugriffsklassifikation.md die Zeile zu apps/api/src/user/user.controller.ts fortschreiben: seit quick-260928-ujj schreibt der Selbstbedienungsweg PATCH me/dashboard-background dashboardBackground, ebenfalls forTenant mit where id currentUser.id, ohne Kennungsparameter.
|
||||||
|
|
||||||
|
Web: AuthUser in auth-actions.ts und stores/auth-store.ts um dashboardBackground?: DashboardBackground | null (Typ aus @tessera/shared) erweitern; updateDashboardBackgroundAction(background) als Server-Aktion nach dem Muster updateAccentColorAction (PATCH /users/me/dashboard-background, Body mit background). header.tsx setUser-Abbildung um dashboardBackground erweitern, header.test.tsx-Fixture nachziehen. apps/web/src/lib/dashboard-background.ts: Typ und Preset-Kennungen aus @tessera/shared beziehen (BackgroundPresetId als Alias behalten, falls genutzt), BACKGROUND_PRESETS/presetBackground bleiben; loadDashboardBackground und saveDashboardBackground ersetzen durch takeLegacyDashboardBackground(userId) (liest, prueft mit parseDashboardBackground, entfernt Schluessel); Kopfkommentar aktualisieren (Speicherung jetzt in der Datenbank, „Prototyp“ entfernen). components/dashboard/dashboard-background.tsx: useDashboardBackground liest user aus useAuthStore statt userId-Parameter; choose() wie im behavior-Block (optimistisch, bei Fehlschlag zuruecksetzen); einmalige Uebernahme der alten Wahl per useRef je Benutzerkennung. Aufrufstelle in app/(portal)/page.tsx anpassen (Kommentar „Prototyp mit localStorage“ ersetzen), page.test.tsx bei Bedarf nachziehen. Hook-Tests in neuer Datei components/dashboard/dashboard-background.test.tsx mit gemocktem @/lib/auth-actions. Hinweistext dashboard.background.hint in de.json auf „Gilt nur für Sie – auf jedem Gerät, auf dem Sie sich anmelden.“ und in en.json sinngemaess („Applies only to you – on every device you sign in on.“) aendern; bestehende Tests, die den alten Text pruefen, anpassen.
|
||||||
|
|
||||||
|
Commit(s): test(260928-ujj) fuer die roten Tests, feat(260928-ujj): Dashboard-Hintergrund pro Benutzer in der Datenbank — deutsch ohne Umlaute.</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/shared type-check && pnpm --filter api type-check && pnpm --filter api test && pnpm --filter web type-check && pnpm --filter web test && grep -q '"dashboardBackground" JSONB' apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql && ! grep -v '^\s*//' apps/web/src/lib/dashboard-background.ts | grep -q 'setItem'</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Migration und Schemafeld vorhanden, Prisma-Client generiert; PATCH me/dashboard-background prueft und speichert, Sitzungsantwort liefert dashboardBackground; Web liest aus dem Store und speichert ueber die API, alte localStorage-Wahl wird einmalig uebernommen; api- und web-Tests inkl. rls-access-inventory.spec.ts gruen, Typpruefung aller drei Pakete sauber.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: CHANGELOG, Anleitungen, Abschlusspruefung</name>
|
||||||
|
<files>CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-entwicklung.md</files>
|
||||||
|
<action>Vorher `git log --format='%h %s%n%b' 3fc33e3..design/mosaik` lesen, um alle sichtbaren Aenderungen zu erfassen. CHANGELOG.md, Abschnitt „## Unveröffentlicht“ (bestehenden Neu-Eintrag zum Was-ist-neu-Fenster behalten), in Alltagssprache, Sie-Form, ganze Saetze, Stil der bisherigen Eintraege, keine Fachbegriffe:
|
||||||
|
- „### Neu“: ein Eintrag zum waehlbaren Dashboard-Hintergrund (Knopf „Hintergrund“: keiner, ruhige Flaechen und Motive, eigenes Bild aus den Bilderrahmen-Bildern oder neu hochgeladen; gilt nur fuer Sie und folgt Ihnen auf jedes Geraet und in die Desktop-App; eine bisher im Browser gemerkte Wahl wird automatisch uebernommen).
|
||||||
|
- „### Geändert“ (neu anlegen, zwischen Neu und Behoben): drei bis vier Eintraege — (1) neues Aussehen: dunkle App-Leiste, neu gestaltete Seitenleiste mit Modul-Kacheln, deutschen Kategorienamen und der Begruessung unten, Akzentfarbe nur beim Modul im Fokus; (2) neue Anmeldeseite, geteilt mit dunklem Markenbereich und Farbmosaik; (3) Dashboard: Kacheln mittig ausgerichtet, Widgets mit gelbem Symbol-Feld, heben sich beim Darueberfahren leicht an und blenden beim Laden sanft ein, Begruessung/Befehlsleiste ueber den Kacheln, ruhigere Kalender-, Favoriten- und Notiz-Kacheln; (4) Kalender: Terminliste einzeilig mit „Heute“/„Morgen“ statt Datum. Eintraege aus den Commit-Rumpfen ergaenzen, die fuer Anwender sichtbar sind (z. B. mobile Schublade), nichts Internes.
|
||||||
|
- „### Behoben“ (neu anlegen): Widgets liessen sich manchmal nicht schmaler ziehen, wenn die Maus dabei leicht wackelte — jetzt klappt das zuverlaessig.
|
||||||
|
docs/anleitung-anwender.md knapp nachziehen: Abschnitt „Aufbau der Oberfläche“ (dunkle App-Leiste, Seitenleiste mit Modul-Kacheln und Begruessung unten — nur was sich wirklich geaendert hat, gegen den gemergten Code pruefen), Abschnitt „Anmeldung“ falls die Seite beschrieben ist, Abschnitt „Dashboard“ um einen Absatz **Hintergrund** (Knopf, Auswahl, pro Benutzer gespeichert, im dunklen Erscheinungsbild werden eigene Bilder abgedunkelt und „Blüte“ durch „Nebel“ ersetzt), Kalender-Zeile der Widget-Tabelle (einzeilige Terminliste, „Heute“/„Morgen“). docs/anleitung-entwicklung.md: kurzer Absatz neben der Stelle zu User.lastSeenReleaseVersion (~Zeile 669) zu User.dashboardBackground, PATCH /users/me/dashboard-background und parseDashboardBackground in @tessera/shared als einzige Pruefregel.
|
||||||
|
Abschlusspruefung: web- und api-Build, Biome auf allen in dieser Aufgabe und im Merge geaenderten ts/tsx-Dateien ohne Fehler (Fehler in unveraenderten Dateien sind ausser Umfang; Biome-Fehler in gemergten Dateien beheben als fix(260928-ujj)). Laeuft der lokale Stack: api und web mit --build neu starten und per Playwright MCP pruefen — Hintergrund waehlen, Seite neu laden, in einem zweiten Browserkontext (gleicher Benutzer) erscheint derselbe Hintergrund; nie per fetch aus der Seite messen. Kein Push, kein Tag. Commit docs(260928-ujj): CHANGELOG und Anleitungen fuer Design Mosaik.</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && awk '/^## Unver/{f=1;next} /^## [0-9]/{f=0} f' CHANGELOG.md | grep -c '^### \(Neu\|Geändert\|Behoben\)$' | grep -q '^3$' && grep -q 'Hintergrund' docs/anleitung-anwender.md && grep -q 'dashboardBackground' docs/anleitung-entwicklung.md && pnpm exec biome check $(git diff --name-only --diff-filter=AM 3fc33e3 HEAD -- '*.ts' '*.tsx') && pnpm --filter api build && pnpm --filter web build</automated>
|
||||||
|
</verify>
|
||||||
|
<done>CHANGELOG „Unveröffentlicht“ hat Neu, Geändert und Behoben mit den beschriebenen Eintraegen; Anwender- und Entwickler-Anleitung beschreiben Hintergrundwahl und neues Aussehen; Biome ohne Fehler auf den geaenderten Dateien; api- und web-Build erfolgreich; alles committet, nichts gepusht.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Browser -> API PATCH /users/me/dashboard-background | Unvertrauter JSON-Body wird gespeichert und spaeter als CSS-Stil (url("...")) gerendert |
|
||||||
|
| DB -> Web (Sitzungsantwort) | Gespeicherter JSON-Wert fliesst in style-Attribut des Dashboard-Hintergrunds |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-ujj-01 | Tampering | user.controller.ts updateDashboardBackground / parseDashboardBackground | medium | mitigate | Allowlist fuer kind und Preset-Kennungen, imageId nur als UUID, frisch aufgebautes Objekt ohne Zusatzschluessel; ungueltig ergibt 400 ohne Schreibzugriff; Ausgabe in auth.service erneut durch parseDashboardBackground |
|
||||||
|
| T-ujj-02 | Elevation of Privilege | PATCH me/dashboard-background | medium | mitigate | Kein Kennungsparameter; forTenant(prisma, currentUser.tenantId).user.update mit where id = currentUser.id |
|
||||||
|
| T-ujj-03 | Information Disclosure | imageId eines fremden Bildes | low | accept | GET /dashboard/images/:id prueft Besitz; fremde Kennung ergibt nur ein fehlendes Bild beim eigenen Benutzer |
|
||||||
|
| T-ujj-04 | Denial of Service | uebergrosser Body | low | accept | Express-JSON-Grenze greift; gespeichert wird nur das normalisierte Kleinobjekt |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `git merge-base --is-ancestor 76d17fe main` ist erfolgreich (Merge-Commit mit 76d17fe als zweitem Elternteil)
|
||||||
|
- `pnpm --filter web test`, `pnpm --filter api test` gruen; type-check fuer shared, api, web sauber
|
||||||
|
- Biome ohne Fehler auf geaenderten ts/tsx-Dateien; `pnpm --filter api build` und `pnpm --filter web build` erfolgreich
|
||||||
|
- Nichts gepusht, kein Tag; .planning/HANDOFF.json unveraendert als lokale Aenderung
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
main traegt das Design Mosaik inklusive Resize-Fix, der Dashboard-Hintergrund wird pro Benutzer in der Datenbank gespeichert und geprueft, CHANGELOG und Anleitungen sind fuer 1.5.0 vorbereitet — bereit fuer die Freigabe durch den Orchestrator.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/quick/260928-ujj-design-mosaik-uebernehmen-und-als-1-5-0-/260928-ujj-SUMMARY.md` when done
|
||||||
|
</output>
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260928-ujj
|
||||||
|
plan: 01
|
||||||
|
quick_id: 260928-ujj
|
||||||
|
status: complete
|
||||||
|
subsystem: web, api, shared, docs
|
||||||
|
tags: [design-mosaik, dashboard-background, prisma-migration, changelog, 1.5.0]
|
||||||
|
requires:
|
||||||
|
- design/mosaik (76d17fe)
|
||||||
|
provides:
|
||||||
|
- Design Mosaik auf main (Merge 9fa0a3f)
|
||||||
|
- User.dashboardBackground (JSONB) + PATCH /users/me/dashboard-background
|
||||||
|
- parseDashboardBackground / DASHBOARD_BACKGROUND_PRESET_IDS in @tessera/shared
|
||||||
|
- CHANGELOG "Unveröffentlicht" mit Neu/Geändert/Behoben fuer 1.5.0
|
||||||
|
affects:
|
||||||
|
- apps/web (115 Dateien aus dem Merge)
|
||||||
|
- apps/api user/auth
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "Gemeinsame Pruefregel in @tessera/shared, angewendet beim Schreiben (Controller) und Lesen (getMe)"
|
||||||
|
- "Hook liest Benutzerwahl aus dem Auth-Store und speichert optimistisch ueber Server-Aktion mit Ruecksetzen"
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql
|
||||||
|
- apps/web/src/components/dashboard/dashboard-background.test.tsx
|
||||||
|
modified:
|
||||||
|
- packages/shared/src/index.ts
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/src/auth/auth.service.ts
|
||||||
|
- apps/api/src/auth/auth.service.spec.ts
|
||||||
|
- apps/api/src/user/user.controller.ts
|
||||||
|
- apps/api/src/user/user.controller.spec.ts
|
||||||
|
- apps/web/src/lib/auth-actions.ts
|
||||||
|
- apps/web/src/lib/stores/auth-store.ts
|
||||||
|
- apps/web/src/components/layout/header.tsx
|
||||||
|
- apps/web/src/components/layout/header.test.tsx
|
||||||
|
- apps/web/src/lib/dashboard-background.ts
|
||||||
|
- apps/web/src/lib/dashboard-background.test.ts
|
||||||
|
- apps/web/src/components/dashboard/dashboard-background.tsx
|
||||||
|
- apps/web/src/app/(portal)/page.tsx
|
||||||
|
- apps/web/src/messages/de.json
|
||||||
|
- apps/web/src/messages/en.json
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
- docs/anleitung-anwender.md
|
||||||
|
- docs/anleitung-entwicklung.md
|
||||||
|
- CHANGELOG.md
|
||||||
|
decisions:
|
||||||
|
- "Dashboard-Hintergrund als JSONB-Spalte User.dashboardBackground; null = nie gewaehlt, { kind: 'none' } = bewusst kein Hintergrund"
|
||||||
|
- "parseDashboardBackground in @tessera/shared ist die einzige Pruefregel (Allowlist kind/Preset, imageId nur UUID) fuer API-Schreiben, API-Lesen und Web-Altdatenuebernahme"
|
||||||
|
- "Alter localStorage-Schluessel wird immer einmal gelesen und entfernt, uebernommen nur bei Server-Wert null"
|
||||||
|
- "Biome: nur vom Merge neu eingebrachte Befunde behoben; vorbestehende Format-/Importbefunde (158 an der Basis) bleiben ausser Umfang"
|
||||||
|
metrics:
|
||||||
|
duration: 17min
|
||||||
|
completed: 2026-09-28
|
||||||
|
estimate:
|
||||||
|
tokens: 95000
|
||||||
|
tasks: 3
|
||||||
|
actuals:
|
||||||
|
tokens: 95700
|
||||||
|
tasks: 3
|
||||||
|
commits: 17
|
||||||
|
plan_head_before: a8a910fd29cf6f2e4c3b228e2a71b2af226f39da
|
||||||
|
plan_head_after: cb45d2663ac65954463e8c5a6bf859f73a555e86
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260928-ujj Plan 01: Design Mosaik uebernehmen und fuer 1.5.0 vorbereiten — Summary
|
||||||
|
|
||||||
|
Design „Mosaik“ per `--no-ff`-Merge (zweiter Elternteil 76d17fe) auf main übernommen. Der Dashboard-Hintergrund wird jetzt pro Benutzer in `User.dashboardBackground` (JSONB) gespeichert: `PATCH /users/me/dashboard-background` prüft mit dem gemeinsamen `parseDashboardBackground`, und der Wert kommt zusammen mit `accentColor` über `getMe` zurück. Die alte Wahl aus dem localStorage wird einmal übernommen. CHANGELOG und beide Anleitungen sind für 1.5.0 vorbereitet.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
| Task | Commit | Beschreibung |
|
||||||
|
|------|--------|--------------|
|
||||||
|
| 1 | 9fa0a3f | feat(260928-ujj): Design Mosaik uebernehmen (Merge, Eltern a8a910f + 76d17fe; bringt 12 Commits aus design/mosaik mit) |
|
||||||
|
| 2 (RED) | 76f6d87 | test(260928-ujj): rote Tests fuer Dashboard-Hintergrund in der Datenbank |
|
||||||
|
| 2 (GREEN) | 0aaa152 | feat(260928-ujj): Dashboard-Hintergrund pro Benutzer in der Datenbank |
|
||||||
|
| 3 (Biome) | 69d1730 | fix(260928-ujj): Biome-Formatierung der mit Design Mosaik eingebrachten Dateien |
|
||||||
|
| 3 | cb45d26 | docs(260928-ujj): CHANGELOG und Anleitungen fuer Design Mosaik |
|
||||||
|
|
||||||
|
`commits: 17` wurde gemessen mit `git rev-list --count a8a910f..HEAD`. Die Zahl enthält die 12 Commits aus design/mosaik, die der Merge mitbringt. Auf der ersten Elternlinie stehen 5 eigene Commits.
|
||||||
|
|
||||||
|
## Verifikation
|
||||||
|
|
||||||
|
- `git merge-base --is-ancestor 76d17fe HEAD`: erfolgreich. `RESIZE_AXIS_FALLBACK` steht in `dashboard-grid.tsx`.
|
||||||
|
- Web-Tests (vitest 4.1.9): 97 Dateien, **952 Tests grün**. Direkt nach dem Merge waren es 96 Dateien und 940 Tests.
|
||||||
|
- API-Tests (vitest 3.2.6): 85 Dateien, **1462 Tests grün**, einschließlich `rls-access-inventory.spec.ts`.
|
||||||
|
- tsc: shared, api und web sind sauber.
|
||||||
|
- Builds: `pnpm --filter api build` und `pnpm --filter web build` sind erfolgreich.
|
||||||
|
- Biome bringt **keine neuen Fehler**. In den seit 3fc33e3 geänderten ts/tsx-Dateien standen an der Basis 158 Fehler, jetzt sind es 156. Alle verbleibenden Befunde bestanden schon vorher (84 format, 72 organizeImports in 93 Dateien).
|
||||||
|
- Die Migration ist lokal eingespielt (`prisma migrate deploy` gegen 172.19.0.2). Die Spalte `dashboardBackground` hat den Typ jsonb.
|
||||||
|
- Lokaler Stack neu gebaut mit `docker compose up -d --build api web`. Die API meldet die Route `Mapped {/users/me/dashboard-background, PATCH}`. Ein Aufruf ohne Anmeldung ergibt 401.
|
||||||
|
- Nichts gepusht, kein Tag gesetzt, den live-Zweig nicht angefasst. `.planning/HANDOFF.json` ist weiter nur eine lokale Änderung und wurde nicht gestaged.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 3 - Blocking] Das Biome-Gate des Plans kann auf den geänderten Dateien nicht fehlerfrei werden**
|
||||||
|
- **Found during:** Task 2 und Task 3
|
||||||
|
- **Issue:** Die Verify-Zeile `biome check $(git diff --name-only --diff-filter=AM 3fc33e3 HEAD ...)` endet mit Exit 1. Die 93 betroffenen Dateien hatten schon an der Basis 3fc33e3 zusammen 158 Format- und Importbefunde.
|
||||||
|
- **Fix:** Ich habe nur die Befunde behoben, die der Merge neu eingebracht hat (12 Dateien, nur Formatierung und Importreihenfolge, eigener Commit 69d1730), dazu alle Befunde in den von mir geschriebenen Zeilen und neuen Dateien. Den Rest ganzer Dateien habe ich nicht umformatiert. Die Vorgabe des Auftraggebers („no new errors“) ist erfüllt: 158 → 156.
|
||||||
|
- **Commit:** 69d1730
|
||||||
|
|
||||||
|
**2. [Rule 2 - Missing critical] Der alte localStorage-Schlüssel wird auch bei gesetztem Server-Wert aufgeräumt**
|
||||||
|
- **Found during:** Task 2
|
||||||
|
- **Issue:** Nach Plan hätte bei gesetztem Server-Wert keine Übernahme stattgefunden, der alte Schlüssel wäre dann aber für immer liegen geblieben.
|
||||||
|
- **Fix:** `takeLegacyDashboardBackground` wird je Benutzer einmal aufgerufen und entfernt den Schlüssel immer. Übernommen wird die alte Wahl nur, wenn der Server-Wert `null` ist. Ein Test deckt das ab.
|
||||||
|
|
||||||
|
**3. [Rule 1 - Doc] Die Anwender-Anleitung war schon vor dem Merge an drei Stellen veraltet**
|
||||||
|
- Die Seitenleiste zeigte bereits vorher keine Sprachumschaltung und keine Name/Rolle-Zeile mehr. Die Reiter standen seit 1.4.0 in der Kopfzeile. Der Stift-Schalter ist mit Mosaik zu „Bearbeiten“/„Fertig“ oben rechts geworden. Diese Stellen habe ich beim Nachziehen gegen den gemergten Code korrigiert.
|
||||||
|
|
||||||
|
### Nicht ausgeführt
|
||||||
|
|
||||||
|
- **Browser-Prüfung per Playwright MCP** (Hintergrund wählen, neu laden, zweiter Browserkontext): In dieser Ausführungsumgebung gab es kein Playwright-MCP-Werkzeug. Ersatzweise habe ich den lokalen Stack neu gebaut und geprüft, dass die Route gemappt ist, ohne Anmeldung 401 liefert und die Spalte in der DB existiert. Die Prüfung über zwei Geräte im Browser steht noch aus.
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
|
||||||
|
- Eine Übernahme der alten Wahl, die fehlschlägt (z. B. wegen eines API-Ausfalls genau in dem Moment), geht verloren, weil der Schlüssel schon beim Lesen entfernt wird. Der Benutzer wählt dann einfach neu. Das ist bewusst so, damit die Übernahme garantiert nur einmal passiert.
|
||||||
|
- Alte Prototyp-Werte mit einer Bildkennung, die keine UUID ist, werden nicht übernommen. Echte Bilderrahmen-Kennungen sind immer UUIDs.
|
||||||
|
- Das Web liest `@tessera/shared` jetzt auch in `dashboard-background.ts` zur Laufzeit. `parseDashboardBackground` enthält nur löschbare Syntax (Regel aus dem Warnkommentar über `WIDGET_TYPES`).
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
Keine über das Threat-Register hinaus. T-ujj-01 und T-ujj-02 sind wie geplant umgesetzt: Allowlist und UUID-Prüfung beim Schreiben und Lesen, `forTenant` mit `where id = currentUser.id`, kein Kennungsparameter.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- FOUND: apps/api/prisma/migrations/20260928120000_user_dashboard_background/migration.sql
|
||||||
|
- FOUND: apps/web/src/components/dashboard/dashboard-background.test.tsx
|
||||||
|
- FOUND commits: 9fa0a3f, 76f6d87, 0aaa152, 69d1730, cb45d26
|
||||||
+391
@@ -0,0 +1,391 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260929-9wc
|
||||||
|
plan: 01
|
||||||
|
quick_id: 260929-9wc
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
autonomous: true
|
||||||
|
requirements: [QUICK-260929-9wc]
|
||||||
|
files_modified:
|
||||||
|
- packages/shared/src/index.ts
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/prisma/migrations/20260929120000_custom_module/migration.sql (neu)
|
||||||
|
- apps/api/src/custom-modules/dto/custom-module.dto.ts (neu)
|
||||||
|
- apps/api/src/custom-modules/dto/custom-module.dto.spec.ts (neu)
|
||||||
|
- apps/api/src/custom-modules/custom-modules.service.ts (neu)
|
||||||
|
- apps/api/src/custom-modules/custom-modules.service.spec.ts (neu)
|
||||||
|
- apps/api/src/custom-modules/custom-modules.controller.ts (neu)
|
||||||
|
- apps/api/src/custom-modules/custom-modules.controller.spec.ts (neu)
|
||||||
|
- apps/api/src/custom-modules/custom-modules.module.ts (neu)
|
||||||
|
- apps/api/src/app.module.ts
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
- apps/web/src/lib/custom-modules-api.ts (neu)
|
||||||
|
- apps/web/src/lib/custom-modules-api.test.ts (neu)
|
||||||
|
- apps/web/src/lib/stores/nav-store.test.ts (neu)
|
||||||
|
- apps/web/src/components/layout/sidebar.tsx
|
||||||
|
- apps/web/src/components/layout/sidebar.test.tsx
|
||||||
|
- apps/web/src/components/modules/custom-module-view.tsx (neu)
|
||||||
|
- apps/web/src/components/modules/custom-module-view.test.tsx (neu)
|
||||||
|
- apps/web/src/app/(portal)/modules/custom/[id]/page.tsx (neu)
|
||||||
|
- apps/web/src/app/(portal)/admin/custom-modules/page.tsx (neu)
|
||||||
|
- apps/web/src/app/(portal)/admin/custom-modules/components/CustomModuleFormModal.tsx (neu)
|
||||||
|
- apps/web/src/app/(portal)/admin/custom-modules/components/DeleteCustomModuleDialog.tsx (neu)
|
||||||
|
- apps/web/src/app/(portal)/admin/custom-modules/custom-modules-page.test.tsx (neu)
|
||||||
|
- apps/web/src/components/admin/admin-sidebar.tsx
|
||||||
|
- apps/web/src/messages/de.json
|
||||||
|
- apps/web/src/messages/en.json
|
||||||
|
- apps/web/src/messages/umlaut-dictionary.ts
|
||||||
|
- apps/web/src/messages/module-categories.spec.ts (neu)
|
||||||
|
- CHANGELOG.md
|
||||||
|
|
||||||
|
estimate:
|
||||||
|
tokens: 150000
|
||||||
|
raw_tokens: 150000
|
||||||
|
tasks: 3
|
||||||
|
confidence: low
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Ein Administrator legt unter Verwaltung > Eigene Module einen Eintrag mit Name, https-Adresse und einer der fünf Seitenleisten-Kategorien an, ändert ihn und löscht ihn (D-01, D-07)"
|
||||||
|
- "Jeder angemeldete Benutzer sieht jedes eigene Modul als Eintrag unter der gewählten Kategorie in der Seitenleiste (auch eingeklappt und in der Suche); nach Anlegen, Ändern oder Löschen zieht die Seitenleiste ohne Neuladen nach (D-01, D-05)"
|
||||||
|
- "Ein Klick öffnet /modules/custom/<id>: ein eingebetteter Rahmen füllt den Inhaltsbereich mit exakt dem Sandbox-Wert XFRAME_SANDBOX und referrerPolicy no-referrer, darüber steht immer sichtbar der Knopf „In neuem Tab öffnen“ (echter Link, target _blank, rel noopener noreferrer); die Kopfzeile zeigt den Namen des Eintrags (D-06)"
|
||||||
|
- "Eine Adresse, die nicht https ist oder Zugangsdaten enthält, lehnt die API mit 400 und das Formular mit einer Meldung ab; eine solche Adresse wird nie als Rahmen oder Link gerendert (D-04, D-06)"
|
||||||
|
- "POST/PATCH/DELETE /custom-modules sind nur für ADMIN und SUPER_ADMIN offen (sonst 403), GET /custom-modules und GET /custom-modules/:id für jeden angemeldeten Benutzer, ohne Anmeldung 401 (D-04)"
|
||||||
|
- "Die Tabelle CustomModule trägt tenantId, ENABLE/FORCE ROW LEVEL SECURITY und tenant_isolation_policy; jeder Zugriff im Dienst läuft über `const tenantPrisma = forTenant(this.prisma, tenantId)`; rls-coverage.spec.ts und rls-access-inventory.spec.ts sind grün (D-03)"
|
||||||
|
- "Alle neuen Texte stehen deutsch (Sie-Form) und englisch; CHANGELOG nennt die Neuerung unter „Unveröffentlicht“ > „Neu“ in Alltagssprache (D-08, D-09)"
|
||||||
|
artifacts:
|
||||||
|
- path: "apps/api/prisma/migrations/20260929120000_custom_module/migration.sql"
|
||||||
|
provides: "Tabelle CustomModule mit tenantId, Index, RLS ENABLE/FORCE, tenant_isolation_policy ohne Benutzerdimension, ohne system_read_policy"
|
||||||
|
- path: "apps/api/src/custom-modules/custom-modules.controller.ts"
|
||||||
|
provides: "GET '' und GET ':id' (jeder Angemeldete), POST/PATCH ':id'/DELETE ':id' mit @Roles(ADMIN, SUPER_ADMIN); list vor getOne deklariert"
|
||||||
|
- path: "apps/api/src/custom-modules/custom-modules.service.ts"
|
||||||
|
provides: "list/getOne/create/update/remove, je Methode ein forTenant-Klient, Fremd-Mandant oder unbekannte id -> NotFoundException"
|
||||||
|
- path: "apps/api/src/custom-modules/dto/custom-module.dto.ts"
|
||||||
|
provides: "CreateCustomModuleDto/UpdateCustomModuleDto: Name 1-100 Zeichen, Adresse nur https ohne Zugangsdaten max 2048, Kategorie @IsIn(MODULE_CATEGORIES)"
|
||||||
|
- path: "packages/shared/src/index.ts"
|
||||||
|
provides: "MODULE_CATEGORIES = ['domain-tools','security-tools','fleet','infrastructure','procurement'] + Typ ModuleCategory"
|
||||||
|
- path: "apps/web/src/lib/custom-modules-api.ts"
|
||||||
|
provides: "CustomModule-Typ, listCustomModules/getCustomModule/createCustomModule/updateCustomModule/deleteCustomModule, checkCustomModuleUrl"
|
||||||
|
- path: "apps/web/src/components/modules/custom-module-view.tsx"
|
||||||
|
provides: "Rahmen-Ansicht mit Leiste (Name, Hinweis, „In neuem Tab öffnen“) und Vollflächen-iframe"
|
||||||
|
- path: "apps/web/src/app/(portal)/admin/custom-modules/page.tsx"
|
||||||
|
provides: "Verwaltungsseite: Liste, Anlegen/Bearbeiten (Formular-Dialog), Löschen (Bestätigung)"
|
||||||
|
key_links:
|
||||||
|
- from: "apps/web/src/components/layout/sidebar.tsx"
|
||||||
|
to: "GET /custom-modules"
|
||||||
|
via: "listCustomModules() im selben Effekt wie /modules/active, ausgelöst durch sidebarRefreshKey"
|
||||||
|
pattern: "listCustomModules"
|
||||||
|
- from: "apps/web/src/app/(portal)/admin/custom-modules/page.tsx"
|
||||||
|
to: "apps/web/src/components/layout/sidebar.tsx"
|
||||||
|
via: "useMarketplaceStore bumpSidebarRefresh() nach jedem erfolgreichen Speichern/Löschen"
|
||||||
|
pattern: "bumpSidebarRefresh"
|
||||||
|
- from: "apps/web/src/components/modules/custom-module-view.tsx"
|
||||||
|
to: "apps/web/src/components/dashboard/widgets/xframe-config.ts"
|
||||||
|
via: "Import XFRAME_SANDBOX — ein Sandbox-Wert für XFrame und eigene Module"
|
||||||
|
pattern: "XFRAME_SANDBOX"
|
||||||
|
- from: "apps/api/src/custom-modules/custom-modules.service.ts"
|
||||||
|
to: "apps/api/src/prisma/prisma-tenant.extension.ts"
|
||||||
|
via: "const tenantPrisma = forTenant(this.prisma, tenantId)"
|
||||||
|
pattern: "const tenantPrisma = forTenant\\(this\\.prisma, tenantId\\)"
|
||||||
|
- from: "apps/api/src/app.module.ts"
|
||||||
|
to: "apps/api/src/custom-modules/custom-modules.module.ts"
|
||||||
|
via: "imports: [..., CustomModulesModule]"
|
||||||
|
pattern: "CustomModulesModule"
|
||||||
|
- from: "docs/mandantentrennung-zugriffsklassifikation.md"
|
||||||
|
to: "apps/api/src/prisma/rls-access-inventory.spec.ts"
|
||||||
|
via: "Bestandsaufnahme-Zeile custom-modules.service.ts | customModule | muss-mandantengebunden | gebunden"
|
||||||
|
pattern: "custom-modules.service.ts \\| customModule"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260929-9wc — Eigene Module: externe Seiten als Seitenleisten-Einträge
|
||||||
|
|
||||||
|
Nutzerauftrag (29.09.): Der Administrator legt Seitenleisten-Einträge an, die externe Seiten per
|
||||||
|
eingebettetem Rahmen in Tessera zeigen.
|
||||||
|
|
||||||
|
## Festgelegte Punkte (mit dem Nutzer entschieden, nicht verhandelbar)
|
||||||
|
|
||||||
|
- **D-01** Der Admin legt Einträge an mit Name, https-Adresse und Seitenleisten-Kategorie (eine der
|
||||||
|
bestehenden Kategorien). Einträge sind für ALLE Benutzer sichtbar.
|
||||||
|
- **D-02** Einschränkung auf Gruppen NUR, wenn der bestehende ModuleGrant/Gruppen-Mechanismus das mit
|
||||||
|
sehr wenig Aufwand hergibt — sonst weglassen und als zurückgestellt notieren.
|
||||||
|
**Entscheidung beim Planen: zurückgestellt.** Begründung (gemessen im Schema):
|
||||||
|
`ModuleGrant.moduleId` ist ein Pflicht-Fremdschlüssel auf `Module` (`onDelete: Cascade`), eigene
|
||||||
|
Module sind keine `Module`-Zeilen. Eine Einschränkung bräuchte eine neue Freigabetabelle oder einen
|
||||||
|
Umbau von `ModuleGrant` samt `module-access.service.ts` und der Admin-Freigabeoberfläche — das ist
|
||||||
|
nicht „sehr wenig Aufwand“. Im SUMMARY unter „Bewusst offen“ notieren; im Code nichts dafür bauen.
|
||||||
|
- **D-03** Prisma-Modell `CustomModule` + Migration MIT Zeilenschutz nach Muster `ProxmoxServer`
|
||||||
|
(tenantId-Spalte, Regel, prisma-tenant-Erweiterung); RLS-Inventar-Test und
|
||||||
|
`docs/mandantentrennung-zugriffsklassifikation.md` fortschreiben.
|
||||||
|
- **D-04** API: GET-Liste für jeden angemeldeten Benutzer; POST/PATCH/DELETE nur Admin; Adresse nur https.
|
||||||
|
- **D-05** Seitenleiste: jedes eigene Modul erscheint als Eintrag unter seiner Kategorie.
|
||||||
|
- **D-06** Seite `/modules/custom/[id]`: Rahmen über die ganze Fläche genau wie das XFrame-Widget
|
||||||
|
(derselbe Sandbox-Wert ohne Navigation des obersten Fensters, `referrerPolicy="no-referrer"`, nur
|
||||||
|
https) PLUS immer sichtbarer Knopf „In neuem Tab öffnen“ (viele Seiten verbieten das Einbetten).
|
||||||
|
- **D-07** Verwaltungsoberfläche im Admin-Bereich: einfache Liste + Anlegen/Bearbeiten/Löschen im Stil
|
||||||
|
der bestehenden Admin-Seiten (Vorbild `admin/groups`).
|
||||||
|
- **D-08** Texte deutsch und englisch; App-Texte im Deutschen in Sie-Form.
|
||||||
|
- **D-09** CHANGELOG unter „Unveröffentlicht“ > „Neu“, Alltagssprache für Nicht-Programmierer.
|
||||||
|
- **D-10** Tests: API-Dienst/Controller, Web-Komponenten, RLS-Inventar. Statische GET-Routen stehen im
|
||||||
|
Controller VOR `@Get(':id')`.
|
||||||
|
- **D-11** Abschluss: Browser-Prüfung mit Playwright MCP am lokalen Stack (web :3000, api :3001, admin /
|
||||||
|
admin123) im DUNKELMODUS (Umschalten über den Theme-Knopf der Kopfzeile, nie per classList).
|
||||||
|
Migration vom Host über die Container-IP (172.19.x, `tessera:tessera_dev`), danach
|
||||||
|
`docker compose up -d --build web api`.
|
||||||
|
- **D-12** Nur lokal committen, NIEMALS `git push`.
|
||||||
|
|
||||||
|
## Grundlagen (wiederverwenden, nicht neu erfinden)
|
||||||
|
|
||||||
|
- **Kategorien**: Die Seitenleiste gruppiert nach `Module.category`; im Einsatz sind genau fünf
|
||||||
|
Kennungen aus den Seeds (`domain-tools`, `security-tools`, `fleet`, `infrastructure`, `procurement`),
|
||||||
|
deren Anzeigenamen in `moduleCategories` von `de.json`/`en.json` stehen und über
|
||||||
|
`useCategoryLabel()` aufgelöst werden. Neu: diese Liste einmal als `MODULE_CATEGORIES` in
|
||||||
|
`packages/shared/src/index.ts` — die API prüft per `@IsIn`, das Formular baut daraus die Auswahl.
|
||||||
|
- **Zeilenschutz-Vorbild**: `apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql`
|
||||||
|
(Kopfkommentar-Pflicht, `ENABLE`/`FORCE`, `tenant_isolation_policy` OHNE Benutzerdimension, weil
|
||||||
|
Verwaltungsdaten des Mandanten). KEINE `system_read_policy` — es gibt keinen Hintergrunddienst.
|
||||||
|
- **API-Vorbild**: `apps/api/src/proxmox/proxmox.controller.ts` (`requireTenantId(req)`,
|
||||||
|
`@Roles(Role.ADMIN, Role.SUPER_ADMIN)`, tenantId nur aus `req.tenantId`) und
|
||||||
|
`proxmox.service.ts` (je Methode `const tenantPrisma = forTenant(this.prisma, tenantId);`). Globale
|
||||||
|
Wächter JwtAuthGuard/TenantGuard/RolesGuard stehen in `app.module.ts`; ValidationPipe mit
|
||||||
|
`whitelist: true, transform: true` in `main.ts`.
|
||||||
|
- **Rahmen-Vorbild**: `apps/web/src/components/dashboard/widgets/xframe-config.ts` (`XFRAME_SANDBOX`,
|
||||||
|
Begründung im Dateikopf; `isHttpsUrl` aus `picture-frame-config.ts`) und `xframe-widget.tsx`
|
||||||
|
(`frameAttrs` mit `allow: ''`, `referrerPolicy: 'no-referrer'`; `NewTabLink` als echter Link).
|
||||||
|
- **Seitenleiste**: `apps/web/src/components/layout/sidebar.tsx` lädt `/modules/active`, gruppiert
|
||||||
|
nach Kategorie, Auffrischung über `useMarketplaceStore` `sidebarRefreshKey`/`bumpSidebarRefresh`;
|
||||||
|
sie veröffentlicht die Liste in `useNavStore`, aus der `resolvePageTitle` den Kopfzeilen-Titel über
|
||||||
|
Pfadsegment == `slug` findet.
|
||||||
|
- **Routen**: Der statische Ordner `modules/custom/[id]` hat im App Router Vorrang vor
|
||||||
|
`modules/[category]/[moduleSlug]` — kein Konflikt.
|
||||||
|
|
||||||
|
## Verbindliche Regeln für alle Aufgaben
|
||||||
|
|
||||||
|
- `de.json` mit echten Umlauten. `umlaut-guard.spec.ts` meldet jedes NEUE deutsche Wort mit
|
||||||
|
ae/oe/ue/ss, das noch nicht auf der Liste steht (etwa „Adressen“ oder „müssen“) — ist es korrektes Deutsch,
|
||||||
|
gehört es in `UMLAUT_ALLOWLIST` in `apps/web/src/messages/umlaut-dictionary.ts`. Jeder neue Schlüssel
|
||||||
|
in `de.json` UND `en.json` (Schlüssel-Gleichheit wird geprüft).
|
||||||
|
- Keine Großbuchstaben-Etiketten, keine Mittelpunkt-Ketten, kein Pfeilzeichen in Texten oder Knöpfen
|
||||||
|
(Stil der letzten Quick-Aufträge). Keine neuen Pakete.
|
||||||
|
- Biome-Grundlinie gemessen am 29.09.: Web 55 Warnungen, API 82 — darf nicht steigen.
|
||||||
|
- Die bereits vorgemerkten Löschungen `.planning/.continue-here.md` und `.planning/HANDOFF.json`
|
||||||
|
(Sitzungsübergabe) nicht wiederherstellen.
|
||||||
|
- Commits nur lokal. Kein `git push`, auch nicht am Ende (D-12).
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Administratoren binden externe Webseiten als „Eigene Module“ in die Seitenleiste ein: Name,
|
||||||
|
https-Adresse, Kategorie. Alle Benutzer sehen die Einträge unter der gewählten Kategorie; ein Klick
|
||||||
|
zeigt die Seite in einem abgesicherten, flächenfüllenden Rahmen mit immer sichtbarem „In neuem Tab
|
||||||
|
öffnen“. Die Daten liegen mandantengetrennt mit Zeilenschutz in der Tabelle `CustomModule`
|
||||||
|
(D-01 bis D-12; D-02 Gruppen-Einschränkung bewusst zurückgestellt).
|
||||||
|
|
||||||
|
Purpose: Werkzeuge, für die es (noch) kein eigenes Tessera-Modul gibt, sind trotzdem aus der zentralen
|
||||||
|
Plattform heraus erreichbar — der Kernnutzen „nicht zwischen Anwendungen wechseln“.
|
||||||
|
Output: Tabelle + Migration mit Zeilenschutz, API `/custom-modules`, Seitenleisten-Einträge,
|
||||||
|
Rahmen-Seite, Verwaltungsseite, Texte de/en, Tests, fortgeschriebene Zugriffsklassifikation, CHANGELOG.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@~/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/STATE.md
|
||||||
|
@./CLAUDE.md
|
||||||
|
@apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql
|
||||||
|
@apps/api/src/proxmox/proxmox.controller.ts
|
||||||
|
@apps/web/src/components/dashboard/widgets/xframe-config.ts
|
||||||
|
@apps/web/src/components/layout/sidebar.tsx
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="tracer" tdd="true">
|
||||||
|
<name>Aufgabe 1 (Tracer): Ein eigenes Modul von der Datenbank bis in Seitenleiste und Rahmen-Seite</name>
|
||||||
|
<files>packages/shared/src/index.ts, apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260929120000_custom_module/migration.sql, apps/api/src/custom-modules/dto/custom-module.dto.ts, apps/api/src/custom-modules/dto/custom-module.dto.spec.ts, apps/api/src/custom-modules/custom-modules.service.ts, apps/api/src/custom-modules/custom-modules.service.spec.ts, apps/api/src/custom-modules/custom-modules.controller.ts, apps/api/src/custom-modules/custom-modules.controller.spec.ts, apps/api/src/custom-modules/custom-modules.module.ts, apps/api/src/app.module.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/custom-modules-api.ts, apps/web/src/lib/custom-modules-api.test.ts, apps/web/src/lib/stores/nav-store.test.ts, apps/web/src/components/layout/sidebar.tsx, apps/web/src/components/layout/sidebar.test.tsx, apps/web/src/components/modules/custom-module-view.tsx, apps/web/src/components/modules/custom-module-view.test.tsx, apps/web/src/app/(portal)/modules/custom/[id]/page.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, apps/web/src/messages/module-categories.spec.ts</files>
|
||||||
|
<precondition>Der lokale Stack läuft: `docker compose ps --format '{{.Service}} {{.State}}'` zeigt db, api und web als running.</precondition>
|
||||||
|
<read_first>apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql, apps/api/src/proxmox/proxmox.controller.ts, apps/api/src/proxmox/proxmox.service.ts (nur createServer/updateServer/deleteServer, Zeilen 120-240), apps/api/src/proxmox/proxmox.service.spec.ts (Kopf bis makeFakePrisma), apps/api/src/tenders/tenders.controller.spec.ts (Reihenfolge-Test ab Zeile 365), apps/api/src/prisma/rls-access-inventory.spec.ts (Zeilen 1-80 und parseDocEntries), apps/web/src/components/dashboard/widgets/xframe-widget.tsx (Zeilen 95-115 und NewTabLink), apps/web/src/lib/favorites-api.test.ts (Kopf), apps/web/src/components/layout/sidebar.test.tsx</read_first>
|
||||||
|
<behavior>
|
||||||
|
- DTO: `https://example.com` mit Kategorie `infrastructure` und Name „Wiki“ ist gültig; `http://example.com`, `javascript:alert(1)`, `data:text/html,x`, `ftp://x`, unparsbarer Text, `https://user:pw@example.com` sind ungültig; Kategorie `other` ist ungültig; leerer oder nur aus Leerzeichen bestehender Name ist ungültig; Name über 100 und Adresse über 2048 Zeichen sind ungültig; Update-DTO akzeptiert Teilmengen, prüft aber jedes gesetzte Feld gleich
|
||||||
|
- Dienst: create speichert tenantId aus dem Argument (nie aus dem DTO); list liefert nur Zeilen des Mandanten, nach Name sortiert; getOne/update/remove mit unbekannter id oder Zeile eines anderen Mandanten -> NotFoundException; forTenant wird je Methode mit (prisma, tenantId) aufgerufen
|
||||||
|
- Controller: create/update/remove tragen ROLES_KEY [ADMIN, SUPER_ADMIN], list/getOne tragen keine Rollen; fehlendes req.tenantId -> ForbiddenException; tenantId kommt aus req.tenantId; `list` ist vor `getOne` deklariert
|
||||||
|
- Web-Client: checkCustomModuleUrl('https://a.de') = 'ok', 'http://a.de' = 'notHttps', 'https://u:p@a.de' = 'credentials', 'kaputt' = 'notHttps'; listCustomModules ruft GET {API}/custom-modules mit credentials include
|
||||||
|
- Seitenleiste: ein eigenes Modul mit Kategorie `infrastructure` erscheint unter dieser Kategorie als Link auf /modules/custom/<id>; eine Kategorie, die nur eigene Module hat, erscheint trotzdem; auf /modules/custom/<id> trägt genau dieser Eintrag die Auswahlmarke; die bestehenden Abruf-Zählertests bleiben unverändert grün
|
||||||
|
- Kopfzeilen-Titel: resolvePageTitle('/modules/custom/abc', [{ id: 'abc', slug: 'abc', name: 'Wiki', category: 'infrastructure' }]) liefert { text: 'Wiki' }
|
||||||
|
- Rahmen-Ansicht: rendert iframe mit src = Adresse, title = Name, sandbox exakt XFRAME_SANDBOX (enthält kein top-navigation-Token), referrerpolicy no-referrer, allow leer; der Link „In neuem Tab öffnen“ ist sichtbar mit href = Adresse, target _blank, rel „noopener noreferrer“; bei nicht gültiger Adresse kein iframe und kein Link, stattdessen Hinweistext; bei 404 der Nicht-gefunden-Text
|
||||||
|
- Kategorien-Gleichlauf: jede Kennung aus MODULE_CATEGORIES hat einen Schlüssel in moduleCategories von de.json und en.json
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Tests zuerst schreiben (rot), dann bauen (grün). Reihenfolge der Arbeit:
|
||||||
|
|
||||||
|
1. Gemeinsame Kategorienliste (D-01): in `packages/shared/src/index.ts` `MODULE_CATEGORIES` als `as const`-Liste der fünf Kennungen `domain-tools`, `security-tools`, `fleet`, `infrastructure`, `procurement` plus `export type ModuleCategory`, mit kurzem Kommentar, dass die Liste den Seed-Kategorien der Module und den Schlüsseln `moduleCategories` in den Übersetzungen entspricht. Neue Spec `apps/web/src/messages/module-categories.spec.ts` prüft den Gleichlauf mit `de.json` und `en.json`.
|
||||||
|
|
||||||
|
2. Datenbank (D-03): in `apps/api/prisma/schema.prisma` hinter `ProxmoxServerStatus` das Modell `CustomModule` mit `id String @id @default(uuid())`, `tenantId String`, `name String`, `url String`, `category String` (Kommentar: eine der MODULE_CATEGORIES), `createdAt DateTime @default(now())`, `updatedAt DateTime @updatedAt`, `@@index([tenantId])` — ohne Relation zu Tenant (Muster ProxmoxServer). Migration `apps/api/prisma/migrations/20260929120000_custom_module/migration.sql` von Hand nach Vorbild 20260923140000: deutscher Kopfkommentar (Zweck, Zeilenschutz OHNE Benutzerdimension weil Verwaltungsdaten des Mandanten, bewusst KEINE system_read_policy weil kein Hintergrunddienst, Rechte für tessera_app kommen über ALTER DEFAULT PRIVILEGES, Hinweis dass die Regeln erst mit der Anwendungsrolle wirken), dann CREATE TABLE "CustomModule" mit den Spalten in Prisma-Form (TIMESTAMP(3), updatedAt ohne Default), Primärschlüssel "CustomModule_pkey", Index "CustomModule_tenantId_idx", `ENABLE ROW LEVEL SECURITY`, `FORCE ROW LEVEL SECURITY` und `CREATE POLICY tenant_isolation_policy ON "CustomModule" USING ("tenantId" = current_tenant_id());`. Danach `pnpm --filter @tessera/api exec prisma generate`.
|
||||||
|
|
||||||
|
3. DTO `apps/api/src/custom-modules/dto/custom-module.dto.ts` (D-04): `CreateCustomModuleDto` mit `name` (`@Transform` trimmt Zeichenketten, `@IsString`, `@IsNotEmpty`, `@MaxLength(100)`), `url` (`@IsString`, `@MaxLength(2048)`, eigene `@ValidatorConstraint` nach Muster `PmgOhneTokenConstraint` in `proxmox-server.dto.ts`: gültig nur, wenn `new URL(wert)` ohne Fehler parst, `protocol === 'https:'`, `hostname` nicht leer und `username`/`password` leer sind; Meldung deutsch in der ASCII-Schreibweise der übrigen API-Meldungen, z. B. „Nur https-Adressen ohne Zugangsdaten sind erlaubt.“), `category` (`@IsIn([...MODULE_CATEGORIES])` aus `@tessera/shared`). `UpdateCustomModuleDto extends PartialType(CreateCustomModuleDto)` aus `@nestjs/mapped-types` (Muster `ldap-config.dto.ts`). Spec `dto/custom-module.dto.spec.ts` mit `plainToInstance` + `validate` deckt die Fälle aus `<behavior>` ab.
|
||||||
|
|
||||||
|
4. Dienst `apps/api/src/custom-modules/custom-modules.service.ts` (D-03, D-04): `@Injectable` mit `PrismaService`; Methoden `list(tenantId)`, `getOne(tenantId, id)`, `create(tenantId, dto)`, `update(tenantId, id, dto)`, `remove(tenantId, id)`. JEDE Methode beginnt mit genau der Zuweisung `const tenantPrisma = forTenant(this.prisma, tenantId);` — `rls-access-inventory.spec.ts` erkennt nur diese Form, ein anderer Name oder ein Aufruf ohne Zuweisung macht die Spec rot. `list` filtert zusätzlich explizit `where: { tenantId }` und sortiert `orderBy: { name: 'asc' }`. `getOne`/`update`/`remove` lesen per `findUnique({ where: { id } })` und werfen `NotFoundException`, wenn die Zeile fehlt oder `row.tenantId !== tenantId` (zweites Netz, weil der RLS-Schalter heute aus ist — Muster DashboardImage). Antworten wählen per `select` genau `id, name, url, category, createdAt, updatedAt`; wird dafür eine Konstante genutzt, muss sie in derselben Datei als Objektliteral stehen (die Inventar-Spec löst nur solche Konstanten auf). `remove` liefert `{ deleted: true }`. Spec `custom-modules.service.spec.ts` nach Muster `proxmox.service.spec.ts` (`vi.mock('../prisma/prisma-tenant.extension', ...)` mit durchreichendem `forTenant`, Fake-Prisma mit Map).
|
||||||
|
|
||||||
|
5. Controller `apps/api/src/custom-modules/custom-modules.controller.ts` (D-04, D-10): `@Controller('custom-modules')`, `requireTenantId(req)` wie im Proxmox-Controller. Deklarationsreihenfolge verbindlich: `list` (`@Get()`), dann `getOne` (`@Get(':id')`), dann `create` (`@Post()`), `update` (`@Patch(':id')`), `remove` (`@Delete(':id')`); die drei schreibenden mit `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`. Kopfkommentar: jede künftige statische GET-Route MUSS über `getOne` stehen (sonst fängt `:id` sie ab). Kein `@UseModule` — eigene Module hängen an keiner Modul-Aktivierung, sichtbar für alle (D-01). Spec `custom-modules.controller.spec.ts` nach Muster `bug-reports.controller.spec.ts`/`tenders.controller.spec.ts`: Rollen-Metadaten per `Reflect.getMetadata(ROLES_KEY, ...)`, Reihenfolge per `Object.getOwnPropertyNames(CustomModulesController.prototype)`, tenantId-Weitergabe, ForbiddenException ohne Mandant.
|
||||||
|
|
||||||
|
6. `apps/api/src/custom-modules/custom-modules.module.ts` (Controller + Dienst; PrismaModule ist global — prüfen, wie ProxmoxModule an PrismaService kommt, und genauso verfahren) und Aufnahme von `CustomModulesModule` in `imports` von `apps/api/src/app.module.ts`.
|
||||||
|
|
||||||
|
7. Zugriffsklassifikation (D-03) in `docs/mandantentrennung-zugriffsklassifikation.md`, alle Zahlen NACHGEMESSEN, nicht abgeschrieben: (a) in der Bestandsaufnahme-Tabelle (Kopf `| Datei | Modell | Klasse | Stand | Begründung |`) hinter den Proxmox-Zeilen die Zeile `| apps/api/src/custom-modules/custom-modules.service.ts | customModule | muss-mandantengebunden | gebunden | **quick-260929-9wc:** ... |` mit Begründung (Admin-verwaltete Seitenleisten-Einträge, tenantId-Spalte, tenant_isolation_policy ohne Benutzerdimension, Migration 20260929120000, keine system_read_policy, je Methode ein forTenant-Klient, Besitzprüfung row.tenantId -> 404). (b) In der Übersicht je Bereich eine Zeile `custom-modules` vor der Summenzeile. Gemessen wird mit der Gate-Schleife über `for d in apps/api/src/*/` mit den drei Greps `this\.prisma\.[a-zA-Z]*`, `tenantPrisma\.[a-zA-Z]*\.` und `systemPrisma\.[a-zA-Z]*\.` (nur .ts ohne spec). Beim Planen gemessen: Summe vorher 61/217/6, die Tabelle nennt aber 61/216/6 — die Zeile `user` nennt 17 gebunden, gemessen sind 18 (Drift aus quick-260928-ujj, Hintergrund pro Benutzer). Diese Drift in der Zeile `user` und in der Summenzeile mit „Nachgemessen quick-260929-9wc“ korrigieren, dann die neue Summe eintragen. (c) Klassen-Verteilung: Überschrift und Tabelle nennen 77 Paare/40 muss-mandantengebunden, die Bestandsaufnahme hat beim Planen aber schon 78 Zeilen/41 muss (gezählt mit `grep -cE '^\| apps/api/src/'`); nach dem neuen Eintrag nachzählen (erwartet 79/42), Überschrift, Tabelle und einen Nachtrag-Absatz „quick-260929-9wc“ entsprechend fortschreiben (Drift benennen, dann +1).
|
||||||
|
|
||||||
|
8. Web-Client `apps/web/src/lib/custom-modules-api.ts` nach Muster `favorites-api.ts`/`proxmox-api.ts` (`NEXT_PUBLIC_API_URL`, `credentials: 'include'`): Typ `CustomModule` (`id, name, url, category, createdAt, updatedAt`), `listCustomModules()`, `getCustomModule(id)` (liefert `null` bei 404), `createCustomModule(input)`, `updateCustomModule(id, input)`, `deleteCustomModule(id)` — Fehler werfen mit Status und Servermeldung. Dazu die reine Funktion `checkCustomModuleUrl(value): 'ok' | 'notHttps' | 'credentials'`, die für die https-Prüfung `isHttpsUrl` aus `xframe-config.ts` nutzt (EINE https-Regel im Web) und Zugangsdaten per URL-Parser erkennt. Test `custom-modules-api.test.ts`.
|
||||||
|
|
||||||
|
9. Seitenleiste `apps/web/src/components/layout/sidebar.tsx` (D-05): im bestehenden Abruf-Effekt (derselbe Auslöser `sidebarRefreshKey`) zusätzlich `listCustomModules()` laden, Fehler still wie beim Modulabruf (leere Liste). Einträge vereinheitlichen (z. B. interner Typ mit `key`, `name`, `category`, `href`, `tileSlug`): Module behalten `href = /modules/<kategorie>/<slug>` und ihre Aktiv-Regel, eigene Module bekommen `href = /modules/custom/<id>` und das allgemeine Kachelsymbol (`ModuleTile` mit einer Kennung ohne eigenes Symbol, z. B. `custom`). Gruppierung, Suche, eingeklappte Kachelliste und der Leer-Zustand arbeiten auf der vereinigten Liste; innerhalb einer Kategorie stehen eingebaute Module vor eigenen. Für den Kopfzeilen-Titel die vereinigte Liste in `useNavStore` veröffentlichen, eigene Module mit `slug` = ihre id (`resolvePageTitle` findet das Pfadsegment dann ohne Änderung) — Test in neuer Datei `apps/web/src/lib/stores/nav-store.test.ts`. In `sidebar.test.tsx` `@/lib/custom-modules-api` per `vi.mock` ersetzen (Standard: leere Liste), damit die bestehenden Zähltests auf `fetch` unverändert gelten; neue Tests für die Fälle aus `<behavior>`.
|
||||||
|
|
||||||
|
10. Rahmen-Seite (D-06): `apps/web/src/app/(portal)/modules/custom/[id]/page.tsx` als Server-Komponente, die `params` (Promise, Muster `[moduleSlug]/page.tsx`) auflöst und `<CustomModuleView id={id} />` rendert — ohne ModuleAccessGate, weil eigene Module für alle sichtbar sind (D-01). `apps/web/src/components/modules/custom-module-view.tsx` (Client): lädt per `getCustomModule(id)`; Ladezustand, Nicht-gefunden-Text, sonst eine schmale Leiste (Name, kurzer Hinweis dass manche Seiten das Einbetten verbieten, rechts der Link „In neuem Tab öffnen“ als echter `<a>` mit `target="_blank"` und `rel="noopener noreferrer"`, als Knopf gestaltet und immer sichtbar) und darunter das iframe, das die restliche Höhe füllt (Behälter z. B. `flex flex-col` mit Höhe `calc(100vh - var(--header-height) - 1.5rem)`, iframe `flex-1 w-full rounded-lg border-0 bg-background`). iframe-Attribute wie `frameAttrs` im XFrame-Widget: `src`, `title` = Name, `sandbox={XFRAME_SANDBOX}` (importiert aus `xframe-config.ts`, NICHT kopieren), `allow=""`, `referrerPolicy="no-referrer"`. iframe und Link nur, wenn `checkCustomModuleUrl(url) === 'ok'`, sonst Hinweistext. Test `custom-module-view.test.tsx` mit gemocktem `getCustomModule`.
|
||||||
|
|
||||||
|
11. Texte (D-08) im neuen Namensraum `customModules` in `de.json` und `en.json`: mindestens `openInNewTab` („In neuem Tab öffnen“ / „Open in new tab“), `embedHint` (z. B. „Manche Seiten lassen sich nicht einbetten. Öffnen Sie die Seite dann in einem neuen Tab.“), `notFound` („Dieses Modul gibt es nicht mehr.“), `invalidUrl`. Neue Wörter mit ae/oe/ue/ss nach der Umlaut-Regel oben behandeln.
|
||||||
|
|
||||||
|
12. Datenbank lokal migrieren und API neu bauen (D-11): Container-IP holen mit `docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1`, dann `DATABASE_URL="postgresql://tessera:tessera_dev@<IP>:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy`; danach `docker compose up -d --build api` und warten, bis `curl -sf http://localhost:3001/health` antwortet. Kontrolle, dass keine Schemaabweichung zu CustomModule bleibt: `pnpm --filter @tessera/api exec prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --script` darf „CustomModule“ nicht enthalten (andere, schon vorher bestehende Abweichungen aus handgeschriebenem SQL sind nicht Gegenstand dieser Aufgabe).
|
||||||
|
|
||||||
|
13. Lokal committen (z. B. `feat(api,web): eigene Module — Tabelle, API, Seitenleiste, Rahmen-Seite`), NICHT pushen (D-12).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/api exec vitest run src/custom-modules src/prisma && pnpm --filter @tessera/web exec vitest run src/components/layout/sidebar.test.tsx src/components/modules/custom-module-view.test.tsx src/lib/custom-modules-api.test.ts src/lib/stores/nav-store.test.ts src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && grep -q 'CREATE POLICY tenant_isolation_policy ON "CustomModule"' apps/api/prisma/migrations/20260929120000_custom_module/migration.sql && grep -q '| apps/api/src/custom-modules/custom-modules.service.ts | customModule | muss-mandantengebunden | gebunden |' docs/mandantentrennung-zugriffsklassifikation.md && grep -q 'XFRAME_SANDBOX' apps/web/src/components/modules/custom-module-view.tsx && J=$(mktemp) && curl -sf -c "$J" -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}' http://localhost:3001/auth/login >/dev/null && ID=$(curl -sf -b "$J" -H 'Content-Type: application/json' -d '{"name":"Tracer","url":"https://example.com","category":"infrastructure"}' http://localhost:3001/custom-modules | node -pe 'JSON.parse(require("fs").readFileSync(0,"utf8")).id') && curl -sf -b "$J" http://localhost:3001/custom-modules | grep -q "$ID" && curl -sf -b "$J" "http://localhost:3001/custom-modules/$ID" | grep -q 'example.com' && test "$(curl -s -o /dev/null -w '%{http_code}' -b "$J" -H 'Content-Type: application/json' -d '{"name":"X","url":"http://example.com","category":"infrastructure"}' http://localhost:3001/custom-modules)" = 400 && test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3001/custom-modules)" = 401 && curl -sf -b "$J" -X DELETE "http://localhost:3001/custom-modules/$ID" >/dev/null && test "$(curl -s -o /dev/null -w '%{http_code}' -b "$J" "http://localhost:3001/custom-modules/$ID")" = 404</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Tabelle CustomModule mit Zeilenschutz ist lokal angelegt; die neu gebaute API nimmt einen https-Eintrag vom Admin an, liefert ihn in Liste und Einzelabruf, lehnt http mit 400 und Anonyme mit 401 ab, löscht ihn (danach 404); Seitenleiste und Rahmen-Seite sind komponentengetestet; RLS-Specs grün, Zugriffsklassifikation nachgemessen fortgeschrieben; lokal committet, nicht gepusht.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Aufgabe 2: Verwaltungsseite „Eigene Module“ — Liste, Anlegen, Bearbeiten, Löschen</name>
|
||||||
|
<files>apps/web/src/app/(portal)/admin/custom-modules/page.tsx, apps/web/src/app/(portal)/admin/custom-modules/components/CustomModuleFormModal.tsx, apps/web/src/app/(portal)/admin/custom-modules/components/DeleteCustomModuleDialog.tsx, apps/web/src/app/(portal)/admin/custom-modules/custom-modules-page.test.tsx, apps/web/src/components/admin/admin-sidebar.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts</files>
|
||||||
|
<read_first>apps/web/src/app/(portal)/admin/groups/page.tsx, apps/web/src/app/(portal)/admin/groups/components/GroupFormModal.tsx, apps/web/src/app/(portal)/admin/groups/components/DeleteGroupDialog.tsx, apps/web/src/app/(portal)/admin/groups/groups-page.test.tsx (Kopf mit dem next-intl-Mock), apps/web/src/components/admin/admin-sidebar.tsx, apps/web/src/lib/custom-modules-api.ts (aus Aufgabe 1)</read_first>
|
||||||
|
<behavior>
|
||||||
|
- Ohne Einträge: Leer-Zustand mit Überschrift, kurzer Erklärung und Knopf „Eigenes Modul anlegen“
|
||||||
|
- Mit Einträgen: Tabelle mit Name (Link auf /modules/custom/<id>), Adresse, Kategorie als Anzeigename (useCategoryLabel), Aktionen Bearbeiten und Löschen
|
||||||
|
- Anlegen: Formular mit Name, Adresse, Kategorie-Auswahl aus MODULE_CATEGORIES; http-Adresse oder Adresse mit Zugangsdaten zeigt die passende Meldung und ruft createCustomModule NICHT auf; leerer Name ebenso; gültige Eingabe ruft createCustomModule mit getrimmtem Namen, lädt die Liste neu und ruft bumpSidebarRefresh genau einmal
|
||||||
|
- Bearbeiten: Formular ist mit den Werten vorbelegt, Speichern ruft updateCustomModule(id, ...) und bumpSidebarRefresh
|
||||||
|
- Löschen: Bestätigungsdialog nennt den Namen; Bestätigen ruft deleteCustomModule(id), Liste neu, bumpSidebarRefresh; Abbrechen ruft nichts
|
||||||
|
- Serverfehler beim Speichern bleibt im Dialog sichtbar, Dialog bleibt offen
|
||||||
|
- Benutzer mit Rolle USER sieht den Zugriff-verweigert-Text statt der Seite (nur Anzeige; durchgesetzt wird serverseitig)
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Tests zuerst (`custom-modules-page.test.tsx`, Muster `groups-page.test.tsx`: namensraumfähiger next-intl-Mock, `@/lib/custom-modules-api` und `@/lib/stores/marketplace-store` per `vi.mock`, Auth-Store mit Rolle ADMIN bzw. USER), dann bauen (D-07):
|
||||||
|
|
||||||
|
1. Seite `apps/web/src/app/(portal)/admin/custom-modules/page.tsx` (Client) im Aufbau von `admin/groups/page.tsx`: Rollen-Anzeigeprüfung ADMIN/SUPER_ADMIN (sonst `common.accessDenied`), Überschrift „Eigene Module“ mit Knopf „Eigenes Modul anlegen“ (`btn btn-primary`), darunter ein Satz Erklärung (externe Webseiten als Einträge in der Seitenleiste, alle Benutzer sehen sie), Fehlerzeile im Stil der Gruppenseite, Leer-Zustand bzw. Tabelle (`overflow-x-auto rounded-md border border-border`, Kopf `bg-muted/50`) mit Name (Link auf die Rahmen-Seite), Adresse (gekürzt mit `truncate` und `title`), Kategorie über `useCategoryLabel()`, Aktionen Bearbeiten/Löschen. Nach jedem erfolgreichen Anlegen, Ändern oder Löschen: Liste neu laden und `useMarketplaceStore.getState().bumpSidebarRefresh()` (bzw. über den Hook) aufrufen, damit die Seitenleiste ohne Neuladen nachzieht (D-05).
|
||||||
|
|
||||||
|
2. `components/CustomModuleFormModal.tsx` nach Muster `GroupFormModal.tsx` (gleicher Dialog-Rahmen, gleiche Knopfklassen): Felder Name (Pflicht, `maxLength` 100), Adresse (`type="url"`, `maxLength` 2048, Platzhaltertext `https://…`), Kategorie (`<select>` über `MODULE_CATEGORIES` aus `@tessera/shared`, beschriftet mit `useCategoryLabel()`, Vorgabe beim Anlegen: `infrastructure`). Vor dem Senden `checkCustomModuleUrl` aus Aufgabe 1 anwenden und je Ergebnis eine eigene übersetzte Meldung zeigen; Name wird getrimmt. Beim Bearbeiten nur `updateCustomModule`, beim Anlegen nur `createCustomModule`. Serverfehler im Dialog anzeigen.
|
||||||
|
|
||||||
|
3. `components/DeleteCustomModuleDialog.tsx` nach Muster `DeleteGroupDialog.tsx`: Rückfrage mit Namen, Bestätigen/Abbrechen.
|
||||||
|
|
||||||
|
4. `apps/web/src/components/admin/admin-sidebar.tsx`: neuer Eintrag direkt hinter „Module“ mit `href: '/admin/custom-modules'`, `label: t('admin.customModules')`, `show: true`, Symbol im Stil der übrigen 16-px-Strichsymbole (z. B. Fenster mit Pfeil nach außen oder Puzzleteil). Der Pfad beginnt NICHT mit `/admin/modules`, damit „Module“ nicht mitmarkiert wird.
|
||||||
|
|
||||||
|
5. Texte (D-08) in `de.json` und `en.json`: `header.admin.customModules` („Eigene Module“ / „Custom modules“) und Namensraum `admin.customModules` mit Titel, Erklärung, Anlegen, Bearbeiten, Löschen, Feldbeschriftungen (Name, Adresse, Kategorie), Aktionen-Spalte, Leer-Zustand (Überschrift + Satz), Löschrückfrage mit `{name}` (z. B. „Möchten Sie „{name}“ wirklich löschen? Der Eintrag verschwindet für alle Benutzer aus der Seitenleiste.“), Meldungen `nameRequired`, `urlNotHttps` („Bitte geben Sie eine Adresse ein, die mit https:// beginnt.“), `urlCredentials` („Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.“), Speichern-Fehler. Sie-Form. Neue Wörter mit ae/oe/ue/ss nach der Umlaut-Regel behandeln.
|
||||||
|
|
||||||
|
6. Lokal committen (z. B. `feat(web): Verwaltung „Eigene Module“`), NICHT pushen (D-12).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run "src/app/(portal)/admin/custom-modules" src/components/layout/sidebar.test.tsx src/messages && pnpm --filter @tessera/web exec tsc --noEmit && grep -q "/admin/custom-modules" apps/web/src/components/admin/admin-sidebar.tsx && grep -q "bumpSidebarRefresh" "apps/web/src/app/(portal)/admin/custom-modules/page.tsx" && grep -q "MODULE_CATEGORIES" "apps/web/src/app/(portal)/admin/custom-modules/components/CustomModuleFormModal.tsx" && node -e 'for (const f of ["de","en"]) { const m = require("./apps/web/src/messages/" + f + ".json"); if (!m.header.admin.customModules) throw new Error(f + ": header.admin.customModules fehlt"); for (const k of ["title","create","urlNotHttps","urlCredentials","nameRequired"]) if (!(k in m.admin.customModules)) throw new Error(f + ": admin.customModules." + k + " fehlt"); for (const k of ["openInNewTab","embedHint","notFound"]) if (!(k in m.customModules)) throw new Error(f + ": customModules." + k + " fehlt"); }'</automated>
|
||||||
|
</verify>
|
||||||
|
<done>Unter Verwaltung > Eigene Module listet die Seite alle Einträge des Mandanten; Anlegen, Bearbeiten und Löschen funktionieren mit Prüfung der Adresse im Formular und ziehen die Seitenleiste sofort nach; Texte de/en vollständig; Tests grün; lokal committet, nicht gepusht.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Aufgabe 3: CHANGELOG, alle Tore, Stack neu bauen, Browser-Prüfung im Dunkelmodus</name>
|
||||||
|
<files>CHANGELOG.md</files>
|
||||||
|
<read_first>CHANGELOG.md (Zeilen 1-45)</read_first>
|
||||||
|
<action>
|
||||||
|
1. CHANGELOG (D-09): unter `## Unveröffentlicht` (heute leer) einen Abschnitt `### Neu` mit einem Punkt in Alltagssprache und Sie-Form, Stil der Einträge von 1.5.x, sinngemäß: „Eigene Module: Als Administrator können Sie unter „Verwaltung“ > „Eigene Module“ andere Webseiten in die Seitenleiste aufnehmen – mit Name, Adresse (nur https) und Kategorie, etwa „Infrastruktur“. Alle Benutzer sehen die Einträge; ein Klick zeigt die Seite direkt in Tessera. Manche Seiten verbieten das Einbetten – dafür gibt es immer den Knopf „In neuem Tab öffnen“.“ Keine Fachbegriffe wie iframe, Sandbox, API, RLS.
|
||||||
|
|
||||||
|
2. Alle Tore laufen lassen und die gemessenen Zahlen im SUMMARY festhalten: vollständige Web- und API-Testläufe, `pnpm turbo run type-check lint`, Biome-Warnungen Web höchstens 55 und API höchstens 82.
|
||||||
|
|
||||||
|
3. Stack neu bauen (D-11): Migration ist aus Aufgabe 1 bereits angewendet (zur Sicherheit erneut `prisma migrate deploy` über die Container-IP, muss „No pending migrations“ melden), dann `docker compose up -d --build web api`; warten, bis `http://localhost:3001/health` und `http://localhost:3000/login` antworten.
|
||||||
|
|
||||||
|
4. Lokal committen (z. B. `docs(changelog): eigene Module unter Unveröffentlicht`), NICHT pushen (D-12). Zum Schluss prüfen, dass HEAD auf keinem entfernten Zweig liegt.
|
||||||
|
|
||||||
|
5. Browser-Prüfung (D-11) nach der Liste in `<verification>` — Playwright MCP, echte Navigation, dunkel über den Theme-Knopf. Ist Playwright MCP im Ausführungskontext nicht verfügbar, die Prüfung im SUMMARY als „an den Orchestrator übergeben“ vermerken; der Orchestrator führt sie dann durch.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && pnpm --filter @tessera/web exec vitest run && pnpm --filter @tessera/api exec vitest run && pnpm turbo run type-check lint && W=$(pnpm --filter @tessera/web exec biome lint . 2>&1 | grep -oE '^Found [0-9]+ warning' | grep -oE '[0-9]+'); test "${W:-0}" -le 55 && A=$(pnpm --filter @tessera/api exec biome lint . 2>&1 | grep -oE '^Found [0-9]+ warning' | grep -oE '[0-9]+'); test "${A:-0}" -le 82 && sed -n '/^## Unveröffentlicht/,/^## 1\.5\.2/p' CHANGELOG.md | grep -q "Eigene Module" && test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/login)" = 200 && test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:3001/custom-modules)" = 401 && test -z "$(git branch -r --contains HEAD)"</automated>
|
||||||
|
<human-check>Browser-Prüfung im Dunkelmodus nach den Schritten 1-9 in <verification> (Playwright MCP, lokaler Stack nach `docker compose up -d --build web api`).</human-check>
|
||||||
|
</verify>
|
||||||
|
<done>CHANGELOG nennt die Neuerung unter „Unveröffentlicht“ > „Neu“; alle Test-, Typ- und Lint-Tore grün, Biome-Grundlinie gehalten; web und api laufen neu gebaut; Browser-Prüfung im Dunkelmodus durchgeführt (oder ausdrücklich an den Orchestrator übergeben); alle Commits lokal, nichts gepusht.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Browser -> API `/custom-modules` | Nicht vertrauenswürdige Eingaben (Name, Adresse, Kategorie, id) und Rollenanspruch aus der Sitzung |
|
||||||
|
| Admin-Eingabe -> alle Benutzer des Mandanten | Eine vom Admin gespeicherte Adresse wird jedem Benutzer als Rahmen und Link ausgeliefert |
|
||||||
|
| Tessera-Seite -> eingebettete Fremdseite | Fremder Inhalt läuft im Rahmen innerhalb des Tessera-Tabs |
|
||||||
|
| API -> PostgreSQL | Mandantentrennung über tenantId, forTenant und tenant_isolation_policy |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-9WC-01 | Elevation of Privilege | CustomModulesController POST/PATCH/DELETE | high | mitigate | `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` an den drei schreibenden Methoden, globaler RolesGuard; Controller-Spec prüft die Metadaten; GET-Routen bewusst ohne Rolle (D-04) |
|
||||||
|
| T-9WC-02 | Information Disclosure | CustomModulesService, Tabelle CustomModule | high | mitigate | tenantId ausschließlich aus `req.tenantId`; je Methode `const tenantPrisma = forTenant(this.prisma, tenantId)`; `list` filtert zusätzlich `where: { tenantId }`; getOne/update/remove prüfen `row.tenantId !== tenantId` -> 404; Migration mit ENABLE/FORCE RLS und tenant_isolation_policy; rls-coverage/rls-access-inventory grün |
|
||||||
|
| T-9WC-03 | Tampering | Adresse (DTO + Web-Rendering) | high | mitigate | API: eigene Constraint über den URL-Parser, nur `https:`, Hostname nötig, max 2048; Web: iframe und Link nur bei `checkCustomModuleUrl(url) === 'ok'` — `javascript:`, `data:` und `http:` werden nie gerendert, auch nicht bei manipulierter Datenbankzeile |
|
||||||
|
| T-9WC-04 | Spoofing | Eingebettete Fremdseite | medium | mitigate | `sandbox={XFRAME_SANDBOX}` (ohne Navigation des obersten Fensters und ohne `allow-modals`, Begründung in `xframe-config.ts`), `allow=""`; Test prüft den exakten Sandbox-Wert |
|
||||||
|
| T-9WC-05 | Information Disclosure | Referrer an Fremdseite | low | mitigate | `referrerPolicy="no-referrer"` am iframe, `rel="noopener noreferrer"` am Link „In neuem Tab öffnen“ |
|
||||||
|
| T-9WC-06 | Information Disclosure | Zugangsdaten in der Adresse | medium | mitigate | API und Formular lehnen Adressen mit Benutzername/Kennwort ab — sonst sähe jeder Benutzer die Zugangsdaten in der Adresse |
|
||||||
|
| T-9WC-07 | Denial of Service | Name/Adresse-Felder | low | mitigate | `@MaxLength(100)` Name, `@MaxLength(2048)` Adresse, Kategorie per `@IsIn` auf fünf Werte begrenzt; ValidationPipe `whitelist: true` verwirft Zusatzfelder (z. B. untergeschobenes tenantId) |
|
||||||
|
| T-9WC-08 | Spoofing | Admin bindet eine täuschend echte Fremdseite ein | low | accept | Der Admin ist vertrauenswürdig (ASVS L1); Einträge sind nur für Admins änderbar, der Name steht sichtbar in Leiste und Kopfzeile |
|
||||||
|
| T-9WC-SC | Tampering | npm/pip/cargo installs | high | accept | Dieser Plan installiert keine Pakete; alle genutzten Bibliotheken (class-validator, @nestjs/mapped-types, Prisma) sind bereits im Lockfile |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
Executor (Tore in Aufgabe 3 gebündelt):
|
||||||
|
- `pnpm --filter @tessera/web exec vitest run` und `pnpm --filter @tessera/api exec vitest run` vollständig grün
|
||||||
|
- `pnpm turbo run type-check lint` grün; Biome-Warnungen Web höchstens 55, API höchstens 82
|
||||||
|
- API-Durchstich per curl aus Aufgabe 1 (Anlegen, Liste, Einzelabruf, 400 bei http, 401 anonym, Löschen, 404 danach)
|
||||||
|
- `test -z "$(git branch -r --contains HEAD)"` — nichts gepusht
|
||||||
|
|
||||||
|
**Browser-Prüfung (D-11)** — Playwright MCP gegen web :3000, Anmeldung admin / admin123, IMMER echte
|
||||||
|
Navigation (`browser_navigate`) und gerenderten Inhalt auslesen, nie per `fetch()` aus der Seite
|
||||||
|
messen. Zuerst über den Theme-Knopf der Kopfzeile auf dunkel schalten (nicht per classList):
|
||||||
|
1. Verwaltung > „Eigene Module“ (neuer Eintrag in der Admin-Leiste, „Module“ ist dabei nicht
|
||||||
|
markiert): Leer-Zustand mit Knopf „Eigenes Modul anlegen“.
|
||||||
|
2. Anlegen mit Name „Beispielseite“, Adresse `http://example.com` -> Meldung, nichts gespeichert;
|
||||||
|
dann `https://user:pw@example.com` -> Meldung; dann `https://example.com`, Kategorie
|
||||||
|
„Infrastruktur“ -> gespeichert, Tabelle zeigt den Eintrag, die Seitenleiste zeigt „Beispielseite“
|
||||||
|
unter „Infrastruktur“ OHNE Neuladen.
|
||||||
|
3. Zweiter Eintrag „GitHub“, `https://github.com`, Kategorie „Sicherheit“ -> erscheint unter
|
||||||
|
„Sicherheit“.
|
||||||
|
4. Klick auf „Beispielseite“: `/modules/custom/<id>`, Kopfzeilen-Titel „Beispielseite“, Auswahlmarke
|
||||||
|
am Eintrag, der Rahmen füllt den Inhaltsbereich ohne doppelten Rollbalken, „In neuem Tab öffnen“
|
||||||
|
sichtbar; im Accessibility-Snapshot/DOM trägt das iframe den Sandbox-Wert aus `XFRAME_SANDBOX` und
|
||||||
|
`referrerpolicy="no-referrer"`. Der Link öffnet einen neuen Tab mit example.com.
|
||||||
|
5. Klick auf „GitHub“: der Rahmen zeigt die Einbettungssperre des Browsers, der Knopf „In neuem Tab
|
||||||
|
öffnen“ ist trotzdem sichtbar und funktioniert.
|
||||||
|
6. Seitenleiste eingeklappt: beide Einträge als Kachel mit Namen im Tooltip; Suche „Beisp“ findet den
|
||||||
|
Eintrag.
|
||||||
|
7. Bearbeiten: „Beispielseite“ in „Beispiel“ umbenennen -> Seitenleiste zieht sofort nach. Löschen mit
|
||||||
|
Rückfrage -> Eintrag verschwindet aus Tabelle und Seitenleiste; die alte Adresse
|
||||||
|
`/modules/custom/<id>` zeigt „Dieses Modul gibt es nicht mehr.“
|
||||||
|
8. Sprache auf Englisch: keine rohen Übersetzungsschlüssel auf Verwaltungsseite und Rahmen-Seite.
|
||||||
|
9. Screenshots (dunkel) von Verwaltungsseite, Seitenleiste mit Einträgen und Rahmen-Seite ablegen;
|
||||||
|
danach die Testeinträge löschen, damit die lokale Datenbank sauber bleibt.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Admins verwalten eigene Module (Name, https-Adresse, Kategorie) unter Verwaltung > Eigene Module;
|
||||||
|
alle Benutzer sehen sie unter der Kategorie in der Seitenleiste (D-01, D-05, D-07).
|
||||||
|
- Die Rahmen-Seite bettet nur https-Adressen ein, mit dem XFrame-Sandbox-Wert und ohne Referrer, und
|
||||||
|
zeigt immer „In neuem Tab öffnen“ (D-06).
|
||||||
|
- API: GET für jeden Angemeldeten, Schreiben nur Admin, http und Zugangsdaten in der Adresse werden
|
||||||
|
abgewiesen; `list` steht vor `getOne` (D-04, D-10).
|
||||||
|
- Tabelle CustomModule mit Zeilenschutz; Zugriffsklassifikation nachgemessen fortgeschrieben (inkl.
|
||||||
|
der beim Planen gefundenen Drift in `user` und der Klassen-Verteilung); RLS-Specs grün (D-03).
|
||||||
|
- Texte de/en in Sie-Form, CHANGELOG ergänzt (D-08, D-09); alle Tore grün, Biome-Grundlinie gehalten.
|
||||||
|
- Browser-Prüfung im Dunkelmodus bestanden (D-11); alle Commits nur lokal (D-12).
|
||||||
|
- Gruppen-Einschränkung bewusst NICHT gebaut, im SUMMARY als zurückgestellt begründet (D-02).
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/quick/260929-9wc-eigene-module-admin-legt-seitenleisten-e/260929-9wc-SUMMARY.md` when done
|
||||||
|
(deutsch, Muster der letzten Quick-Summaries: Was gebaut wurde, Abweichungen, Tore mit gemessenen Zahlen
|
||||||
|
inkl. Biome-Warnungen und nachgemessener Klassifikationszahlen, Ergebnis der Browser-Prüfung mit
|
||||||
|
Screenshot-Pfaden, „Bewusst offen“: Gruppen-Einschränkung für eigene Module (D-02, Begründung
|
||||||
|
ModuleGrant-Fremdschlüssel auf Module), Hinweis dass nichts gepusht wurde).
|
||||||
|
</output>
|
||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260929-9wc
|
||||||
|
plan: 01
|
||||||
|
quick_id: 260929-9wc
|
||||||
|
subsystem: api, web, prisma
|
||||||
|
tags: [custom-modules, sidebar, iframe, rls, admin]
|
||||||
|
status: complete
|
||||||
|
requires: []
|
||||||
|
provides:
|
||||||
|
- Tabelle CustomModule mit Zeilenschutz (Migration 20260929120000)
|
||||||
|
- API /custom-modules (GET fuer jeden Angemeldeten, POST/PATCH/DELETE nur Admin)
|
||||||
|
- Seitenleisten-Eintraege und Rahmen-Seite /modules/custom/[id]
|
||||||
|
- Verwaltungsseite Verwaltung > Eigene Module
|
||||||
|
- MODULE_CATEGORIES in @tessera/shared
|
||||||
|
affects: [sidebar, admin-sidebar, docs/mandantentrennung-zugriffsklassifikation.md]
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- apps/api/prisma/migrations/20260929120000_custom_module/migration.sql
|
||||||
|
- apps/api/src/custom-modules/ (Controller, Dienst, Modul, DTO, je mit Spec)
|
||||||
|
- apps/web/src/lib/custom-modules-api.ts
|
||||||
|
- apps/web/src/components/modules/custom-module-view.tsx
|
||||||
|
- apps/web/src/app/(portal)/modules/custom/[id]/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/admin/custom-modules/ (page, FormModal, DeleteDialog, Test)
|
||||||
|
- apps/web/src/messages/module-categories.spec.ts
|
||||||
|
modified:
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/src/app.module.ts
|
||||||
|
- packages/shared/src/index.ts
|
||||||
|
- apps/web/src/components/layout/sidebar.tsx (+ Test)
|
||||||
|
- apps/web/src/components/admin/admin-sidebar.tsx
|
||||||
|
- apps/web/src/messages/de.json, en.json
|
||||||
|
- apps/web/src/app/(portal)/modules/module-layouts.test.tsx
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
- CHANGELOG.md
|
||||||
|
decisions:
|
||||||
|
- "Gruppen-Einschraenkung (D-02) zurueckgestellt, siehe Bewusst offen"
|
||||||
|
- "Eigene Module haengen an keiner Modul-Aktivierung (kein @UseModule, kein ModuleAccessGate)"
|
||||||
|
- "Seitenleiste vereinheitlicht Module und eigene Module in einem internen Eintragstyp; eingebaute Module stehen je Kategorie vor eigenen"
|
||||||
|
- "https-Regel im Web bleibt EINE (isHttpsUrl aus xframe-config), Sandbox-Wert wird importiert, nicht kopiert"
|
||||||
|
duration: ca. 10 Minuten reine Ausfuehrung
|
||||||
|
completed: 2026-09-29
|
||||||
|
commits: 3
|
||||||
|
plan_head_before: 643b1a2caa01a506b0b7aec8c6236f98769e19f0
|
||||||
|
plan_head_after: e48c0de23816702b42a4fb265a22298c32769206
|
||||||
|
actuals:
|
||||||
|
tokens: 31000
|
||||||
|
tasks: 3
|
||||||
|
commits: 3
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase quick-260929-9wc Plan 01: Eigene Module Summary
|
||||||
|
|
||||||
|
Administratoren binden externe https-Seiten als Seitenleisten-Eintraege ein (Name, Adresse, Kategorie); alle Benutzer sehen sie unter der Kategorie, ein Klick zeigt die Seite in einem Rahmen mit dem XFrame-Sandbox-Wert und immer sichtbarem Knopf „In neuem Tab öffnen“. Daten liegen mandantengetrennt mit Zeilenschutz in der neuen Tabelle `CustomModule`.
|
||||||
|
|
||||||
|
## Was gebaut wurde
|
||||||
|
|
||||||
|
**Aufgabe 1 (Tracer), Commit b9d87be**
|
||||||
|
- `MODULE_CATEGORIES` (fuenf Kennungen) + Typ `ModuleCategory` in `packages/shared`; Gleichlauf-Spec gegen `moduleCategories` in de.json/en.json.
|
||||||
|
- Prisma-Modell `CustomModule` und handgeschriebene Migration `20260929120000_custom_module` (ENABLE/FORCE RLS, `tenant_isolation_policy` ohne Benutzerdimension, bewusst keine `system_read_policy`).
|
||||||
|
- API `/custom-modules`: DTO (Name 1 bis 100, Adresse nur https ohne Zugangsdaten, max 2048, Kategorie per `@IsIn`), Dienst (je Methode `const tenantPrisma = forTenant(this.prisma, tenantId)`, `row.tenantId`-Pruefung, 404 bei fremd/unbekannt), Controller (`list` vor `getOne`, schreibende Routen `@Roles(ADMIN, SUPER_ADMIN)`), Modul in `app.module.ts`.
|
||||||
|
- Zugriffsklassifikation nachgemessen fortgeschrieben (siehe Zahlen).
|
||||||
|
- Web: `custom-modules-api.ts` (inkl. `checkCustomModuleUrl`), Seitenleiste mit vereinigter Eintragsliste (Gruppierung, Suche, eingeklappte Kacheln, Auswahlmarke, Kopfzeilen-Titel ueber `useNavStore` mit slug = id), `CustomModuleView` + Seite `/modules/custom/[id]`, Texte `customModules` de/en.
|
||||||
|
- Lokal migriert (Container-IP, `prisma migrate deploy`), API neu gebaut; curl-Durchstich bestanden.
|
||||||
|
|
||||||
|
**Aufgabe 2, Commit e7fc4de**
|
||||||
|
- Verwaltungsseite `admin/custom-modules` (Liste, Leer-Zustand, Anlegen/Bearbeiten-Dialog mit Adresspruefung vor dem Senden, Loeschen mit Rueckfrage), Aufruf von `bumpSidebarRefresh` nach jedem erfolgreichen Speichern/Loeschen, Admin-Leisten-Eintrag hinter „Module“, Texte `admin.customModules` und `header.admin.customModules` de/en.
|
||||||
|
|
||||||
|
**Aufgabe 3, Commit e48c0de**
|
||||||
|
- CHANGELOG-Eintrag unter „Unveröffentlicht“ > „Neu“, alle Tore, Stack neu gebaut.
|
||||||
|
|
||||||
|
## Tore (gemessen)
|
||||||
|
|
||||||
|
| Tor | Ergebnis |
|
||||||
|
|-----|----------|
|
||||||
|
| Web-Tests vollstaendig | 102 Dateien, 992 Tests, alle gruen |
|
||||||
|
| API-Tests vollstaendig | 88 Dateien, 1495 Tests, alle gruen |
|
||||||
|
| `pnpm turbo run type-check lint` | 9/9 Aufgaben erfolgreich |
|
||||||
|
| Biome-Warnungen Web | 55 (Grundlinie 55) |
|
||||||
|
| Biome-Warnungen API | 82 (Grundlinie 82) |
|
||||||
|
| rls-coverage.spec / rls-access-inventory.spec | gruen (30 Zusicherungen im Inventar) |
|
||||||
|
| `prisma migrate deploy` (zweiter Lauf) | „No pending migrations to apply.“ |
|
||||||
|
| `prisma migrate diff` | enthaelt „CustomModule“ nicht |
|
||||||
|
| curl-Durchstich | Anlegen, Liste, Einzelabruf ok; http 400; anonym 401; Loeschen; danach 404 |
|
||||||
|
| Stack | web :3000/login 200, api /health ok, `GET /custom-modules` anonym 401 |
|
||||||
|
| Nicht gepusht | `git branch -r --contains HEAD` leer |
|
||||||
|
|
||||||
|
**Zugriffsklassifikation nachgemessen (Gate-Schleife, nur .ts ohne spec):**
|
||||||
|
- Summe vorher gemessen 61/217/6 (Dokument nannte 61/216/6); Drift in `user`: gemessen 18 gebunden statt 17 (aus quick-260928-ujj), korrigiert.
|
||||||
|
- `custom-modules`: 0/7/0 (list 1, getOne 1, create 1, update 2, remove 2).
|
||||||
|
- Neue Summe: 61/224/6.
|
||||||
|
- Klassen-Verteilung: Ueberschrift/Tabelle nannten 77 Paare/40 muss, Bestandsaufnahme hatte schon 78/41 (`grep -cE '^\| apps/api/src/'`); nach neuem Eintrag 79 Paare, davon 42 muss, 21 keine-mandantengebundene-tabelle, 14 beides, 2 bewusst-uebergreifend. Nachtrag-Absatz „quick-260929-9wc“ ergaenzt.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] Endlosschleife beim Laden der Verwaltungsseite**
|
||||||
|
- **Found during:** Aufgabe 2 (Test zaehlte 5 statt 2 Listenabrufe)
|
||||||
|
- **Issue:** `fetchModules` hing per `useCallback` an `t` (Uebersetzungsfunktion); ein Mock liefert je Render eine neue Funktion, der Effekt lief erneut. Auch mit echtem next-intl fragil.
|
||||||
|
- **Fix:** Fehler als Boolean `loadFailed` gefuehrt, Text erst im JSX uebersetzt; `fetchModules` ohne Abhaengigkeit.
|
||||||
|
- **Files modified:** `apps/web/src/app/(portal)/admin/custom-modules/page.tsx`
|
||||||
|
- **Commit:** e7fc4de
|
||||||
|
|
||||||
|
**2. [Rule 3 - Blocking] Layout-Waechter der Modulordner**
|
||||||
|
- **Found during:** Aufgabe 3 (voller Web-Testlauf)
|
||||||
|
- **Issue:** `module-layouts.test.tsx` (T-e8k-04) verlangt in jedem nicht-dynamischen Ordner unter `modules/` eine `layout.tsx` mit ModuleAccessGate; der neue Ordner `custom/` ist bewusst fuer alle sichtbar (D-01) und hat keine Schranke.
|
||||||
|
- **Fix:** Explizite, begruendete Ausnahmeliste `DIRS_WITHOUT_GATE = ['custom']` im Test, statt eine wirkungslose Durchreich-Layout-Datei anzulegen.
|
||||||
|
- **Files modified:** `apps/web/src/app/(portal)/modules/module-layouts.test.tsx`
|
||||||
|
- **Commit:** e48c0de
|
||||||
|
|
||||||
|
**3. Plan-Feinheit (kein Regelfall):** Die Seitenleisten-Fehlerbehandlung fuer `listCustomModules` laesst bei Fehler den bisherigen Stand stehen (leer beim ersten Laden), wie der Modulabruf, statt aktiv zu leeren; Ergebnis beim ersten Laden identisch mit „leere Liste“.
|
||||||
|
|
||||||
|
## Bewusst offen
|
||||||
|
|
||||||
|
- **Gruppen-Einschraenkung fuer eigene Module (D-02) zurueckgestellt.** `ModuleGrant.moduleId` ist ein Pflicht-Fremdschluessel auf `Module` (`onDelete: Cascade`); eigene Module sind keine `Module`-Zeilen. Eine Einschraenkung braeuchte eine neue Freigabetabelle oder einen Umbau von `ModuleGrant` samt `module-access.service.ts` und der Admin-Freigabeoberflaeche, also nicht „sehr wenig Aufwand“. Im Code nichts dafuer gebaut.
|
||||||
|
|
||||||
|
## Browser-Pruefung offen (Orchestrator)
|
||||||
|
|
||||||
|
Playwright MCP steht in diesem Ausfuehrungskontext nicht zur Verfuegung. Der Orchestrator fuehrt die Pruefung durch: web :3000, Anmeldung admin / admin123, echte Navigation (`browser_navigate`), nie per `fetch()` aus der Seite messen, zuerst ueber den Theme-Knopf der Kopfzeile auf dunkel schalten. Stack ist neu gebaut und laeuft.
|
||||||
|
|
||||||
|
1. Verwaltung > „Eigene Module“ (neuer Eintrag in der Admin-Leiste hinter „Module“, „Module“ dabei nicht markiert): Leer-Zustand mit Knopf „Eigenes Modul anlegen“.
|
||||||
|
2. Anlegen mit Name „Beispielseite“, Adresse `http://example.com` -> Meldung, nichts gespeichert; dann `https://user:pw@example.com` -> Meldung; dann `https://example.com`, Kategorie „Infrastruktur“ -> gespeichert, Tabelle zeigt den Eintrag, die Seitenleiste zeigt „Beispielseite“ unter „Infrastruktur“ OHNE Neuladen.
|
||||||
|
3. Zweiter Eintrag „GitHub“, `https://github.com`, Kategorie „Sicherheit“ -> erscheint unter „Sicherheit“.
|
||||||
|
4. Klick auf „Beispielseite“: `/modules/custom/<id>`, Kopfzeilen-Titel „Beispielseite“, Auswahlmarke am Eintrag, Rahmen fuellt den Inhaltsbereich ohne doppelten Rollbalken, „In neuem Tab öffnen“ sichtbar; iframe traegt den Sandbox-Wert `allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox` und `referrerpolicy="no-referrer"`; der Link oeffnet einen neuen Tab mit example.com.
|
||||||
|
5. Klick auf „GitHub“: Rahmen zeigt die Einbettungssperre des Browsers, „In neuem Tab öffnen“ ist trotzdem sichtbar und funktioniert.
|
||||||
|
6. Seitenleiste eingeklappt: beide Eintraege als Kachel mit Namen im Tooltip; Suche „Beisp“ findet den Eintrag.
|
||||||
|
7. Bearbeiten: „Beispielseite“ in „Beispiel“ umbenennen -> Seitenleiste zieht sofort nach. Loeschen mit Rueckfrage -> Eintrag verschwindet aus Tabelle und Seitenleiste; alte Adresse `/modules/custom/<id>` zeigt „Dieses Modul gibt es nicht mehr.“
|
||||||
|
8. Sprache auf Englisch: keine rohen Uebersetzungsschluessel auf Verwaltungsseite und Rahmen-Seite.
|
||||||
|
9. Screenshots (dunkel) von Verwaltungsseite, Seitenleiste mit Eintraegen und Rahmen-Seite ablegen; danach die Testeintraege loeschen, damit die lokale Datenbank sauber bleibt.
|
||||||
|
|
||||||
|
Hinweis: Der curl-Durchstich hat seinen Testeintrag bereits geloescht; die lokale Datenbank enthaelt keine eigenen Module.
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
Keine.
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
Keine neue Angriffsflaeche ausserhalb des Plan-Bedrohungsmodells (T-9WC-01 bis 07 umgesetzt: Rollen-Metadaten per Spec geprueft, tenantId nur aus `req.tenantId`, https-Regel in API und Web, exakter Sandbox-Wert, Referrer/`rel`, MaxLength, `whitelist: true`).
|
||||||
|
|
||||||
|
## Nichts gepusht
|
||||||
|
|
||||||
|
Drei lokale Commits (b9d87be, e7fc4de, e48c0de), kein `git push`; die vorgemerkten Loeschungen von `.planning/.continue-here.md` und `.planning/HANDOFF.json` blieben unangetastet im Index.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- Dateien vorhanden: Migration, `custom-modules.service.ts`/`controller.ts`/`module.ts`/`dto`, `custom-modules-api.ts`, `custom-module-view.tsx`, `modules/custom/[id]/page.tsx`, `admin/custom-modules/page.tsx` mit Komponenten (alle im Commit-Stat sichtbar).
|
||||||
|
- Commits vorhanden: b9d87be, e7fc4de, e48c0de (`git log`), `commits: 3` gemessen ueber `rev-list` vom Ledger.
|
||||||
|
|
||||||
|
## Browser-Pruefung (Orchestrator, 29.09., dunkel)
|
||||||
|
|
||||||
|
Durchgefuehrt per Playwright MCP auf :3000, Theme per Kopfzeilen-Knopf auf „Dunkel“:
|
||||||
|
1. Verwaltung > „Eigene Module“: Eintrag in der Admin-Leiste, Leer-Zustand korrekt.
|
||||||
|
2. http://example.com -> „Bitte geben Sie eine Adresse ein, die mit https:// beginnt.“; https://user:pw@example.com -> „Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.“; https://example.com / Infrastruktur -> gespeichert, Seitenleiste zeigt „Beispielseite“ ohne Neuladen.
|
||||||
|
3. „GitHub“ / Sicherheit erscheint unter „Sicherheit“.
|
||||||
|
4. Rahmen-Seite: Kopfzeilen-Titel, Auswahlmarke, kein doppelter Rollbalken; sandbox = `allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox`, referrerpolicy = no-referrer; Link target=_blank rel="noopener noreferrer".
|
||||||
|
5. GitHub: Einbettung per frame-ancestors blockiert, „In neuem Tab öffnen“ oeffnet github.com im neuen Tab.
|
||||||
|
6. Eingeklappt: Eintraege als Symbole; Suche „Beisp“ findet den Eintrag.
|
||||||
|
7. Umbenennen zieht Seitenleiste sofort nach; Loeschen mit Rueckfrage; alte Adresse zeigt „Dieses Modul gibt es nicht mehr.“
|
||||||
|
8. Englisch: nicht per Oberflaeche umgeschaltet; stattdessen Schluessel-Paritaet de/en geprueft (keine fehlenden Schluessel, alle Texte ueber t()).
|
||||||
|
9. Testeintraege geloescht, lokale DB ohne eigene Module.
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
quick_id: 260929-d37
|
||||||
|
type: quick
|
||||||
|
wave: 1
|
||||||
|
autonomous: true
|
||||||
|
files_modified:
|
||||||
|
- apps/desktop/src-tauri/Cargo.toml
|
||||||
|
- apps/desktop/src-tauri/Cargo.lock
|
||||||
|
- apps/desktop/src-tauri/src/lib.rs
|
||||||
|
- CHANGELOG.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260929-d37: Desktop-Client nur einmal starten (Single-Instance)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
User report (29.09.2026, Windows 11): at system start Tessera launches twice and two tray icons appear.
|
||||||
|
`apps/desktop/src-tauri/src/lib.rs` has no single-instance guard. Autostart via `tauri-plugin-autostart`
|
||||||
|
(HKCU Run key, only set when the user ticks "Mit Windows starten"); a second launch source (Windows 11
|
||||||
|
"restart restartable apps after sign-in", a stale Run/Startup entry from an older install, or a manual
|
||||||
|
double-click) starts a second full process with its own tray icon.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Only one Tessera desktop process runs per user session. A second launch hands off to the running one
|
||||||
|
(show + unminimize + focus the main window) and exits immediately — no second tray icon.
|
||||||
|
|
||||||
|
## Task 1: Single-instance plugin
|
||||||
|
|
||||||
|
- files: apps/desktop/src-tauri/Cargo.toml, apps/desktop/src-tauri/Cargo.lock, apps/desktop/src-tauri/src/lib.rs
|
||||||
|
- action:
|
||||||
|
- Add `tauri-plugin-single-instance = "2"` to `[dependencies]` (resolve with cargo; lockfile updated).
|
||||||
|
- Register it as the FIRST plugin in `tauri::Builder` (plugin docs require it to be registered first):
|
||||||
|
`.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { focus main window }))`.
|
||||||
|
- Callback: reuse the exact show/unminimize/set_focus sequence already used by the tray "open" handler
|
||||||
|
(around lib.rs:820-835). If that sequence is duplicated 3x already, extract a small helper
|
||||||
|
`fn show_main_window(app: &AppHandle)` and use it in all places (keep behavior identical).
|
||||||
|
- Short German comment above the plugin line explaining why (double start at Windows sign-in, two tray icons).
|
||||||
|
- No capabilities/permissions change needed (plugin has no JS API); verify by building.
|
||||||
|
- verify: `cd apps/desktop/src-tauri && cargo build` succeeds; `cargo test` (existing unit tests) green; `cargo clippy` no new warnings if clippy is available.
|
||||||
|
- done: builds, tests green, commit `fix(desktop): nur eine Instanz — zweiter Start holt das Fenster nach vorne`.
|
||||||
|
|
||||||
|
## Task 2: CHANGELOG
|
||||||
|
|
||||||
|
- files: CHANGELOG.md
|
||||||
|
- action: under `## Unveröffentlicht` add a `### Behoben` section (after `### Neu`) with one plain-German bullet (app text uses "Sie"), e.g.:
|
||||||
|
"Desktop-App: Tessera startet nicht mehr doppelt. Wird die App ein zweites Mal gestartet – etwa beim Anmelden an Windows –, holt sie nur das vorhandene Fenster nach vorne; im Infobereich erscheint nur noch ein Symbol."
|
||||||
|
Follow existing CHANGELOG style; run the repo's changelog/umlaut checks if any exist (web tests touching CHANGELOG, e.g. `pnpm --filter web test -- changelog`).
|
||||||
|
- done: commit `docs(changelog): Desktop-App startet nicht mehr doppelt`.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Commit locally only. NEVER `git push`.
|
||||||
|
- Do not touch the desktop version numbers (release process handles them).
|
||||||
|
- Real Windows verification is not possible from here; state in SUMMARY that the Windows check (VM 8233 or user's PC after next desktop release) is open.
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
quick_id: 260929-d37
|
||||||
|
status: complete
|
||||||
|
commits: 2
|
||||||
|
plan_head_before: bc26010
|
||||||
|
plan_head_after: 0751198
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260929-d37: Desktop-Client nur einmal starten (Single-Instance)
|
||||||
|
|
||||||
|
`tauri-plugin-single-instance` (2.4.5) ist als erstes Plugin im `tauri::Builder` registriert. Ein zweiter Start holt das Fenster der laufenden Instanz nach vorne und beendet sich, es entsteht kein zweites Tray-Symbol.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
1. **Single-Instance-Plugin** — Commit `c0b145a` (`fix(desktop): nur eine Instanz — zweiter Start holt das Fenster nach vorne`)
|
||||||
|
- `Cargo.toml` und `Cargo.lock` um `tauri-plugin-single-instance = "2"` erweitert.
|
||||||
|
- Die Sequenz unminimize/show/set_focus stand dreimal in `lib.rs` (Tray "open", "change_server", Tray-Linksklick). Sie ist jetzt der Helper `show_main_window(&AppHandle)`, den auch der Single-Instance-Callback nutzt. Das Verhalten der Tray-Handler ist unverändert. Bei "change_server" läuft `navigate` weiterhin vor dem Anzeigen.
|
||||||
|
- Kurzer deutscher Kommentar über der Plugin-Zeile.
|
||||||
|
2. **CHANGELOG** — Commit `0751198` (`docs(changelog): Desktop-App startet nicht mehr doppelt`)
|
||||||
|
- Unter "Unveröffentlicht" neuer Abschnitt "Behoben" mit einem Eintrag.
|
||||||
|
|
||||||
|
## Verifikation
|
||||||
|
|
||||||
|
- `cargo build`: ok
|
||||||
|
- `cargo test`: 44 Tests grün
|
||||||
|
- `cargo clippy`: keine Warnungen
|
||||||
|
- Vitest `changelog.test.ts`, `release-notes.test.ts`, `changelog-page.test.tsx`: 33 Tests grün
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None - plan executed exactly as written.
|
||||||
|
|
||||||
|
## Offen
|
||||||
|
|
||||||
|
Die Prüfung unter Windows steht aus, weil sie von hier aus nicht möglich ist. Sie kann in der Windows-Test-VM 8233 oder auf dem PC des Users nach dem nächsten Desktop-Release erfolgen: App zweimal starten, es darf nur ein Tray-Symbol erscheinen und das Fenster kommt nach vorne. Die Desktop-Versionsnummern sind unverändert.
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
quick_id: 260929-dmx
|
||||||
|
type: quick
|
||||||
|
wave: 1
|
||||||
|
autonomous: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260929-dmx: Widget-Raster horizontal feiner (48 Spalten), Kalender schmaler
|
||||||
|
|
||||||
|
## User requests (29.09.2026)
|
||||||
|
|
||||||
|
1. "das kalender widget soll in der breite schmäler gemacht werden können."
|
||||||
|
2. "und mache das widget raster horizontal etwas feiner."
|
||||||
|
|
||||||
|
## Measurements (orchestrator, browser, lg breakpoint, grid width 1593 px, margin 12)
|
||||||
|
|
||||||
|
- Today: 24 cols → one width unit ≈ 66 px. Calendar minW 6 ≈ 383 px, default 8 ≈ 515 px.
|
||||||
|
- Calendar rendered at 251 px: fully usable (month grid, header "September 2026", event list truncates titles cleanly).
|
||||||
|
- Calendar at 185 px: header clipped, event titles reduced to one letter → too narrow.
|
||||||
|
- Target: calendar minimum ≈ 250 px.
|
||||||
|
|
||||||
|
## Decision (locked)
|
||||||
|
|
||||||
|
Double the HORIZONTAL resolution only: `COLS = { lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 }` in
|
||||||
|
`apps/web/src/components/dashboard/dashboard-grid.tsx`. Row height (20) and margin (12) unchanged.
|
||||||
|
Every existing widget keeps its exact on-screen size and position.
|
||||||
|
|
||||||
|
## Task 1: Grid version 3 (horizontal x2) with migration
|
||||||
|
|
||||||
|
- files: apps/web/src/lib/grid-layout-migration.ts (+ test), apps/web/src/components/dashboard/dashboard-grid.tsx (+ test), apps/web/src/lib/stores/dashboard-store.ts (only if needed)
|
||||||
|
- action:
|
||||||
|
- Bump `GRID_VERSION` to 3. Migration becomes stepwise and cumulative:
|
||||||
|
v1 → v2: existing behavior (x,y,w,h,minW,minH,maxW,maxH × 2).
|
||||||
|
v2 → v3: NEW, horizontal only: x, w, minW, maxW × 2 (y, h, minH, maxH unchanged).
|
||||||
|
So a v1 layout gets both steps, a v2 layout only the second, a v3 layout nothing. Keep idempotence and marker semantics (marker only in persisted JSON). Update the file header comment (German, same style) to document v3.
|
||||||
|
- `COLS` as above. Check every other place that depends on column count or widget width units:
|
||||||
|
centering offset, `RESIZE_AXIS_FALLBACK`, `breakpointFor`, default positions when adding a widget
|
||||||
|
(`dashboard-grid.tsx` ~380: `defaultW ?? 4`, `minW ?? 4` fallbacks → 8), empty-dashboard suggestions,
|
||||||
|
any layout templates/seed data in apps/web or apps/api (grep `defaultW`, `w:` in dashboard code,
|
||||||
|
`layouts` defaults in apps/api/src/dashboard). Anything expressed in width units gets × 2.
|
||||||
|
- Tests: extend grid-layout-migration tests (v1→v3, v2→v3, v3 untouched, idempotence, marker 3 written), update dashboard-grid tests pinning cols.
|
||||||
|
- verify: `pnpm --filter web exec vitest run src/lib src/components/dashboard` green.
|
||||||
|
|
||||||
|
## Task 2: Widget width constraints in new units
|
||||||
|
|
||||||
|
- files: apps/web/src/components/dashboard/widget-registry.tsx (+ test)
|
||||||
|
- action: In `WIDGET_CONSTRAINTS` double every `minW` and `defaultW` (same physical size as before),
|
||||||
|
EXCEPT calendar: `minW: 8` (≈ 251 px at lg — the measured usable minimum), `defaultW: 16` (unchanged size).
|
||||||
|
minH/defaultH unchanged. Update the comment above calendar (German): narrower on user request 29.09., 8 of 48 ≈ 250 px measured usable.
|
||||||
|
Existing layouts: the existing override logic in dashboard-grid (quick-260916-dyv: stored minW/minH replaced by constants in every breakpoint) must pick up the new calendar minW so existing calendars can be shrunk — verify that path with a test.
|
||||||
|
- verify: web tests green; `pnpm turbo run type-check lint --filter web` green; biome warnings for web not above baseline 55.
|
||||||
|
|
||||||
|
## Task 3: CHANGELOG, rebuild, commit
|
||||||
|
|
||||||
|
- CHANGELOG.md under `## Unveröffentlicht` → `### Geändert` (section exists) add plain-German bullets (app text uses "Sie"):
|
||||||
|
- Dashboard: Das Raster ist in der Breite doppelt so fein – Widgets lassen sich in kleineren Schritten breiter oder schmaler ziehen und genauer platzieren. Bestehende Anordnungen bleiben unverändert.
|
||||||
|
- Dashboard: Das Kalender-Widget lässt sich deutlich schmaler ziehen als bisher.
|
||||||
|
- Rebuild local stack: `docker compose up -d --build web` (plain `up` does not rebuild).
|
||||||
|
- Commits per task, messages end with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`.
|
||||||
|
- NEVER git push (orchestrator pushes after browser check).
|
||||||
|
- Browser check is done by the orchestrator (resize calendar to minimum, existing layout unchanged after migration, marker 3 persisted).
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
---
|
||||||
|
quick_id: 260929-dmx
|
||||||
|
status: complete
|
||||||
|
commits: 3
|
||||||
|
plan_head_before: acd3c7a05f9f01e2f5cf8ebe9aed66d8c789a055
|
||||||
|
plan_head_after: 46ebb4e7ceafa1dc782514e487ac820166279f48
|
||||||
|
completed: 2026-09-29
|
||||||
|
actuals:
|
||||||
|
tasks: 3
|
||||||
|
commits: 3
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260929-dmx: Widget-Raster horizontal 48 Spalten, Kalender schmaler
|
||||||
|
|
||||||
|
Grid version 3: horizontal resolution doubled (COLS lg 48 / md 40 / sm 24 / xs 16 / xxs 4), row height 20 and margin 12 unchanged. Stored layouts are migrated stepwise, so every existing widget keeps its on-screen size and position. Calendar minimum is now 8 of 48 columns (about 250 px at lg).
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
- 97744b5 feat: grid v3 with stepwise migration, COLS, fallbacks
|
||||||
|
- 9c9e142 feat: widget constraints in 48-column units, calendar minW 8
|
||||||
|
- 46ebb4e docs(changelog): two bullets under "Unveröffentlicht / Geändert"
|
||||||
|
|
||||||
|
## Every place where width units changed
|
||||||
|
|
||||||
|
1. `apps/web/src/lib/grid-layout-migration.ts`: `GRID_VERSION` 2 to 3. Migration is now a step table: v1 to v2 scales x, y, w, h, minW, minH, maxW, maxH by 2; v2 to v3 scales only x, w, minW, maxW by 2. A v1 layout gets both steps, a v2 layout only the second, a v3 layout nothing. Marker semantics unchanged (marker only in the persisted JSON, stripped on load, re-added by `withGridVersion`). Header comment updated.
|
||||||
|
2. `apps/web/src/components/dashboard/dashboard-grid.tsx`:
|
||||||
|
- `COLS` is `{ lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 }`.
|
||||||
|
- Fallback for a widget without a layout entry: `defaultW ?? 8` and `minW ?? 8` (was 4/4). The `?? 4` fallbacks for height stay.
|
||||||
|
- Comment block above BREAKPOINTS/COLS extended.
|
||||||
|
3. `apps/web/src/components/dashboard/widget-registry.tsx` (`WIDGET_CONSTRAINTS`, minW/defaultW doubled, heights untouched):
|
||||||
|
- clock 4/8
|
||||||
|
- search 12/24
|
||||||
|
- calendar minW 8, defaultW 16. Calendar is the only one that is not a plain doubling for minW: 8 instead of 12.
|
||||||
|
- note 8/12
|
||||||
|
- calculator 6/12
|
||||||
|
- favorites 2/12
|
||||||
|
- stopwatch 8/12
|
||||||
|
- picture-frame 8/16
|
||||||
|
- xframe 8/24
|
||||||
|
- proxmox 6/16
|
||||||
|
- Comments updated, German, including the calendar note (narrower on user request 29.09., 8 of 48 is about 250 px measured usable).
|
||||||
|
4. `dashboard-store.ts` needed no code change. `addWidget` reads `defaultW` from `WIDGET_CONSTRAINTS`, so new widgets are placed in the new units automatically. Load and save already route through `migrateGridLayouts` and `withGridVersion`.
|
||||||
|
5. `centeringOffset` needed no code change. It takes `cols` as a parameter and gets the new `COLS[breakpoint]`.
|
||||||
|
6. `breakpointFor` needed no change. It depends only on BREAKPOINTS, not on the column count.
|
||||||
|
|
||||||
|
The existing override in `applyConstraintMinima` (quick-260916-dyv) already replaces the stored minW/minH from the constants in every breakpoint, so existing calendars pick up minW 8 with no code change there. This is verified by a new test.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- `grid-layout-migration.test.ts`: v1 to v3 (x/w/minW/maxW times 4, y/h/minH/maxH times 2), v2 to v3, v3 untouched, v1 result equals v2 result, idempotence for both paths with marker 3 written, future marker 4 untouched, string marker, foreign values.
|
||||||
|
- `dashboard-store.test.ts`: marker 3, new expected values, new-widget default of 8 wide.
|
||||||
|
- `dashboard-grid.test.tsx`: COLS pin, `centeringOffset` with 48 columns, data-grid fallbacks, minima overrides, new Test 9c (existing calendar with stored minW 12 gets minW 8 in every breakpoint, w 6 raised to 8, w 16 kept).
|
||||||
|
- `widget-registry.test.tsx`: constraints table.
|
||||||
|
- Verification: `pnpm --filter web exec vitest run src/lib src/components/dashboard` green (513). The full web suite is green (995). Type-check for `@tessera/web` is green. Biome shows 55 warnings, equal to the baseline. Note: the turbo filter name is `@tessera/web`, not `web`.
|
||||||
|
|
||||||
|
## Rebuild
|
||||||
|
|
||||||
|
`docker compose up -d --build web` ran, and the web container is up. Browser check is left to the orchestrator (calendar at minimum, existing layout unchanged after migration, marker 3 persisted).
|
||||||
|
|
||||||
|
## Found but deliberately left
|
||||||
|
|
||||||
|
- `apps/api/src/dashboard/dashboard.service.spec.ts:834` stores `__gridVersion: 2` as a passthrough fixture. The API only passes the JSON through, so the value is arbitrary, and I did not touch it.
|
||||||
|
- `RESIZE_AXIS_FALLBACK` and its tests use abstract grid numbers and are unit-independent, so nothing was changed. Its resize logic has no column dependency.
|
||||||
|
- Historical comments that mention "24 Spalten" (the quick-260922-vdk explanation in `dashboard-grid.tsx`, the quick-260916-bwo test description, the bwo header text) describe past states and were left. The vdk comment's numbers (24 columns, 50 px) describe the old bug, not the current state.
|
||||||
|
- Widget internals (calendar, favorites, calculator) use pixel-based or container-based layout, not grid units, so no change was needed.
|
||||||
|
- md/sm/xs/xxs columns are doubled proportionally with lg. Only lg was measured, and I did not check the smaller breakpoints in a browser.
|
||||||
|
- API/`seed`: no default layouts or seed data with width units exist in apps/api (empty defaults `{ lg: [], ... }` only).
|
||||||
|
- A brand-new empty v2 layout is not re-saved with marker 3 on load (`migrated` stays false for empty layouts, existing behaviour). The marker gets written on the next save.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
None. The plan was executed as written. The SUMMARY, STATE, PLAN and ROADMAP files were not committed, as instructed.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
Commits 97744b5, 9c9e142, 46ebb4e exist. Changed files exist. Working tree contains only the untracked quick-task directory.
|
||||||
|
|
||||||
|
## Browser-Pruefung (Orchestrator, 29.09., dunkel, lg 1888 px)
|
||||||
|
|
||||||
|
- Bestehende Anordnung nach Migration pixelgenau gleich (6 Widgets, left/top/width/height vor und nach identisch); DB: `__gridVersion` 3, lg-w verdoppelt.
|
||||||
|
- Kalender im Bearbeitungsmodus nach links gezogen: stoppt bei 252 px (vorher Minimum 383 px), Monatsraster, Kopf und Terminliste sauber lesbar.
|
||||||
|
- Schrittweite beim Ziehen 33 px (284/317/350), vorher 66 px.
|
||||||
|
- Kalender danach wieder auf 515 px gezogen, Testzustand zurueckgesetzt.
|
||||||
|
- Nicht im Browser geprueft: kleinere Breakpoints (md/sm/xs/xxs).
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
quick_id: 260929-dzu
|
||||||
|
type: quick
|
||||||
|
wave: 1
|
||||||
|
autonomous: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260929-dzu: Eigene Module für jeden Benutzer (persönlich)
|
||||||
|
|
||||||
|
## User request (29.09.2026)
|
||||||
|
|
||||||
|
"Jeder User soll eigene Module anlegen können. nicht nur admins."
|
||||||
|
Decision (AskUserQuestion, locked): **"Nur er selbst"** — a normal user's entries are visible ONLY to that user.
|
||||||
|
Admins keep creating shared entries (visible to everyone) on /admin/custom-modules as today.
|
||||||
|
No user can put anything into another user's sidebar.
|
||||||
|
|
||||||
|
## Existing state (quick 260929-9wc, commits b9d87be, e7fc4de)
|
||||||
|
|
||||||
|
- Prisma `CustomModule { id, tenantId, name, url, category, createdAt, updatedAt }`, migration
|
||||||
|
`20260929120000_custom_module` with RLS (tenant only, pattern ProxmoxServer).
|
||||||
|
- API `apps/api/src/custom-modules/*`: GET list/one for any authenticated user; POST/PATCH/DELETE admin only;
|
||||||
|
https-only, no credentials in URL.
|
||||||
|
- Web: sidebar loads `listCustomModules()`, frame page `/modules/custom/[id]`, admin page `/admin/custom-modules`
|
||||||
|
with `CustomModuleFormModal` + `DeleteCustomModuleDialog`, `bumpSidebarRefresh` after changes.
|
||||||
|
|
||||||
|
## Task 1: Model + API (tests first)
|
||||||
|
|
||||||
|
- Add nullable `ownerUserId String?` (+ relation to User with onDelete: Cascade, index `[tenantId, ownerUserId]`)
|
||||||
|
via NEW migration (e.g. `20260929130000_custom_module_owner`). `null` = shared (admin-made), set = personal.
|
||||||
|
- RLS: extend the existing policy the way user-scoped tables already do it (find the pattern used by e.g.
|
||||||
|
DashboardImage / Favorite / other tables with a user dimension). Personal rows must only be readable/writable by
|
||||||
|
their owner; shared rows readable by the whole tenant. If the project's RLS pattern handles the user dimension
|
||||||
|
in the service layer instead, follow that pattern and document it. Update the RLS inventory test and
|
||||||
|
`docs/mandantentrennung-zugriffsklassifikation.md` (re-measure totals as last time).
|
||||||
|
- Service/controller:
|
||||||
|
- `GET /custom-modules` → shared rows + rows owned by the caller. Response carries `personal: boolean` (or `ownerUserId === me`).
|
||||||
|
- `GET /custom-modules/:id` → 404 unless shared or owned by caller.
|
||||||
|
- `POST /custom-modules` → any authenticated user; body flag `shared?: boolean`. `shared: true` only allowed for admins
|
||||||
|
(403 otherwise); default personal (ownerUserId = caller). The admin page sends `shared: true`.
|
||||||
|
- `PATCH` / `DELETE` → personal rows: only the owner (404 for others, do not leak existence); shared rows: admin only (403 for non-admin).
|
||||||
|
Ownership/shared-ness cannot be changed via PATCH.
|
||||||
|
- Keep URL validation. Keep static routes before `:id`.
|
||||||
|
- Admin page list: `GET /custom-modules?scope=shared` (admin) or filter client-side — pick the simplest; the admin page shows only shared entries; the settings page only the caller's personal ones.
|
||||||
|
- Tests: service + controller specs for all permission cases (user A cannot see/edit/delete user B's entry; non-admin cannot create/edit/delete shared; admin personal vs shared).
|
||||||
|
- verify: `pnpm --filter @tessera/api exec vitest run src/custom-modules` + RLS inventory test green; migrate local DB (db container IP 172.19.x, tessera/tessera_dev), rebuild api, curl check.
|
||||||
|
|
||||||
|
## Task 2: Web — settings section
|
||||||
|
|
||||||
|
- Settings: new section/page "Eigene Module" in the user settings (`apps/web/src/app/(portal)/settings/`, follow how
|
||||||
|
`general` / `dashboard` sub-pages and their nav are built). Reuse `CustomModuleFormModal` and
|
||||||
|
`DeleteCustomModuleDialog` (move to a shared location if needed, e.g. `components/custom-modules/`) — one form, two callers.
|
||||||
|
Intro text (Sie-Form): e.g. "Nehmen Sie Webseiten, die Sie oft brauchen, als eigene Einträge in Ihre Seitenleiste auf. Diese Einträge sehen nur Sie."
|
||||||
|
- Admin page: shows only shared entries; intro text states they are visible for all users.
|
||||||
|
- Sidebar: unchanged behavior, shows shared + own personal entries (API already filters). `bumpSidebarRefresh` after changes on the settings page too.
|
||||||
|
- de + en texts; umlaut dictionary if needed.
|
||||||
|
- Tests: component tests for the settings page (create/edit/delete, list only personal), admin page still passes `shared: true`.
|
||||||
|
- verify: `pnpm --filter @tessera/web exec vitest run` green; `pnpm turbo run type-check lint` green; biome web ≤ 55, api ≤ 82.
|
||||||
|
|
||||||
|
## Task 3: CHANGELOG + rebuild
|
||||||
|
|
||||||
|
- CHANGELOG `## Unveröffentlicht` → adjust the existing "Eigene Module" bullet under "Neu" (not released yet, so rewrite it):
|
||||||
|
every user can add own entries under "Einstellungen → Eigene Module", visible only to them; administrators can additionally add entries for everyone under "Verwaltung → Eigene Module". Plain German, Sie-Form.
|
||||||
|
- Update `docs/anleitung-anwender.md` (and admin guide if it mentions custom modules) accordingly.
|
||||||
|
- `docker compose up -d --build web api`.
|
||||||
|
- Commits per task, end with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`. NEVER git push.
|
||||||
|
- Browser check is done by the orchestrator (normal user + admin, dark mode).
|
||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260929-dzu
|
||||||
|
plan: 01
|
||||||
|
quick_id: 260929-dzu
|
||||||
|
subsystem: api, web, prisma
|
||||||
|
tags: [custom-modules, personal, rls, settings]
|
||||||
|
status: complete
|
||||||
|
requires: [260929-9wc]
|
||||||
|
provides:
|
||||||
|
- Spalte CustomModule.ownerUserId (NULL = gemeinsam, gesetzt = persoenlich), Migration 20260929130000
|
||||||
|
- Zeilenschutz mit Benutzerdimension nach Muster SearchProvider
|
||||||
|
- API /custom-modules mit persoenlichen und gemeinsamen Eintraegen (Antwortfeld personal)
|
||||||
|
- Einstellungen > Eigene Module (/settings/custom-modules) fuer jeden Benutzer
|
||||||
|
- gemeinsame Oberflaeche CustomModuleManager (Formular, Loeschdialog, Liste) fuer Verwaltung und Einstellungen
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- apps/api/prisma/migrations/20260929130000_custom_module_owner/migration.sql
|
||||||
|
- apps/web/src/components/custom-modules/custom-module-manager.tsx
|
||||||
|
- apps/web/src/app/(portal)/settings/custom-modules/page.tsx
|
||||||
|
- apps/web/src/app/(portal)/settings/custom-modules/custom-modules-settings.test.tsx
|
||||||
|
modified:
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/src/custom-modules/ (Dienst, Controller, DTO, Specs)
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
- apps/web/src/components/custom-modules/custom-module-form-modal.tsx (verschoben aus admin/custom-modules/components)
|
||||||
|
- apps/web/src/components/custom-modules/delete-custom-module-dialog.tsx (verschoben)
|
||||||
|
- apps/web/src/app/(portal)/admin/custom-modules/page.tsx (+ Test)
|
||||||
|
- apps/web/src/components/settings/settings-sidebar.tsx
|
||||||
|
- apps/web/src/lib/custom-modules-api.ts
|
||||||
|
- apps/web/src/messages/de.json, en.json
|
||||||
|
- CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md
|
||||||
|
decisions:
|
||||||
|
- "RLS: Muster SearchProvider (nullable Besitzerspalte, vier Regeln je Befehl), nicht die einfache Muster DashboardImage (Pflicht-userId)"
|
||||||
|
- "Gemeinsame Eintraege werden ohne Benutzerkontext geschrieben (forTenant ohne userId), persoenliche mit Benutzer"
|
||||||
|
- "Rollenpruefung fuer gemeinsame Eintraege im Dienst statt per @Roles, weil sie vom Eintrag abhaengt"
|
||||||
|
- "Filter fuer Verwaltung/Einstellungen im Web ueber personal, kein scope-Parameter in der API"
|
||||||
|
- "Texte von Formular und Loeschdialog in eigenen Namensraum customModules.form, Umzug aus admin.customModules"
|
||||||
|
completed: 2026-09-29
|
||||||
|
commits: 3
|
||||||
|
plan_head_before: 76a923450fd2f492ec046d5945a6965c8e94b707
|
||||||
|
plan_head_after: 8f41bd26bd3eddee1979485281cb375ade52949d
|
||||||
|
actuals:
|
||||||
|
tokens: 42000
|
||||||
|
tasks: 3
|
||||||
|
commits: 3
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase quick-260929-dzu Plan 01: Eigene Module fuer jeden Benutzer Summary
|
||||||
|
|
||||||
|
Jeder angemeldete Benutzer legt unter Einstellungen > Eigene Module persoenliche Seitenleisten-Eintraege an, die nur er sieht; Administratoren pflegen weiter gemeinsame Eintraege unter Verwaltung > Eigene Module (Senden von `shared: true`). Niemand kann etwas in die Seitenleiste eines anderen Benutzers legen.
|
||||||
|
|
||||||
|
## Was gebaut wurde
|
||||||
|
|
||||||
|
**Aufgabe 1, Commit c703d87 (Modell + API, Tests zuerst angepasst)**
|
||||||
|
- Schema: `ownerUserId String?` mit Relation zu `User` (`onDelete: Cascade`), Index `[tenantId, ownerUserId]`; Gegenfeld `customModules` am `User`.
|
||||||
|
- Migration `20260929130000_custom_module_owner` (von Hand, mit Kopfkommentar): Spalte, Index, Fremdschluessel, alte Regel ersetzt durch vier Regeln.
|
||||||
|
- Dienst/Controller: `GET /custom-modules` liefert gemeinsame plus eigene Zeilen mit `personal: boolean` (ownerUserId wird nicht ausgeliefert); `GET :id` 404 bei fremdem persoenlichem Eintrag (auch fuer Administratoren); `POST` fuer jeden Angemeldeten, `shared: true` nur fuer ADMIN/SUPER_ADMIN (sonst 403), Standard persoenlich; `PATCH`/`DELETE`: persoenlich nur Besitzer (fremd: 404), gemeinsam nur Administrator (sonst 403). `shared`/`ownerUserId` sind per PATCH nicht aenderbar (`OmitType` im DTO plus `whitelist`).
|
||||||
|
- Routen: `list` steht weiter vor `getOne`; kein `@Roles` mehr an den Schreibrouten, die Rollenpruefung sitzt im Dienst.
|
||||||
|
- Specs: Dienst (22 Faelle: A sieht/aendert/loescht B nicht, Nicht-Admin nicht shared, Admin persoenlich vs. gemeinsam, RLS-Bindung mit/ohne Benutzer), Controller, DTO-Pipe-Faelle.
|
||||||
|
- Zugriffsklassifikation nachgemessen (siehe unten).
|
||||||
|
|
||||||
|
**Aufgabe 2, Commit ee97b4e (Web)**
|
||||||
|
- Neue Seite `/settings/custom-modules` und Nav-Eintrag „Eigene Module“ unter „Allgemein“.
|
||||||
|
- Gemeinsame Komponenten unter `components/custom-modules/`: `CustomModuleFormModal` und `DeleteCustomModuleDialog` (verschoben, Parameter `shared`) plus neu `CustomModuleManager` (Liste, Anlegen/Bearbeiten/Loeschen, `bumpSidebarRefresh`), aufgerufen mit `scope="shared"` (Verwaltung) oder `scope="personal"` (Einstellungen). Filter ueber `personal` im Web.
|
||||||
|
- Verwaltung sendet beim Anlegen `shared: true`, zeigt nur gemeinsame Eintraege, Einleitung nennt „alle Benutzer“; Einstellungen senden kein `shared`, Einleitung: „Diese Einträge sehen nur Sie.“
|
||||||
|
- Texte de/en (Namensraeume `customModules.form`, `customModules.manage`, `settings.customModules`), Umlaut-Waechter gruen.
|
||||||
|
- Tests: neuer Settings-Test (7), Admin-Test angepasst (`shared: true`, Filter; 14).
|
||||||
|
|
||||||
|
**Aufgabe 3, Commit 8f41bd2 (Doku) + Neubau**
|
||||||
|
- CHANGELOG-Punkt „Eigene Module“ umgeschrieben (Einstellungen fuer jeden, Verwaltung zusaetzlich fuer alle), `docs/anleitung-anwender.md` (Abschnitt „Allgemein > Eigene Module“) und `docs/anleitung-administration.md` (Unterabschnitt bei 5.).
|
||||||
|
- `docker compose up -d --build web api`: web :3000/login 200, api /health ok, `GET /custom-modules` anonym 401, `/settings/custom-modules` ohne Anmeldung 307 (Umleitung auf Login).
|
||||||
|
|
||||||
|
## RLS-Muster und Begruendung
|
||||||
|
|
||||||
|
Gefolgt bin ich dem Muster **SearchProvider** aus `20260911120000_rls_user_dimension_personal_tables`: nullable Besitzerspalte, vier nach Befehl getrennte Regeln.
|
||||||
|
- SELECT: Mandant UND (kein Benutzer gesetzt ODER `ownerUserId IS NULL` ODER `ownerUserId = current_user_id()`).
|
||||||
|
- INSERT/UPDATE/DELETE: Mandant UND (kein Benutzer gesetzt ODER `ownerUserId = current_user_id()`).
|
||||||
|
|
||||||
|
Warum nicht das einfachere Muster DashboardImage/Favorite (Pflicht-`userId`, eine Regel): eigene Module haben gemeinsame Zeilen (`NULL`), die jeder lesen, aber nur ein Administrator schreiben darf. Eine einzelne Regel, die die gemeinsame Zeile zum Lesen freigibt, wuerde sie auch zum Aendern/Loeschen freigeben (Praezedenz 260910-jab (3)), deshalb getrennte Befehle. Folge: ein Benutzerkontext kann gemeinsame Zeilen nicht schreiben; der Dienst bindet Schreibzugriffe auf gemeinsame Eintraege deshalb bewusst OHNE Benutzer (`forTenant(prisma, tenantId)`), nachdem er die Administrator-Rolle geprueft hat. Persoenliche Zugriffe binden mit Benutzer. Wie bei allen RLS-Regeln wirkt der Schutz erst mit dem Datenbankrollen-Schalter (heute AUS); bis dahin tragen die Anwendungspruefungen (`row.tenantId`, `ownerUserId`) den Schutz.
|
||||||
|
|
||||||
|
## Curl-Pruefung (lokal, echte API :3001)
|
||||||
|
|
||||||
|
Benutzer: admin (SUPER_ADMIN), testuser (USER), curltmp (USER, nur fuer die Pruefung angelegt und danach geloescht).
|
||||||
|
|
||||||
|
| Fall | Ergebnis |
|
||||||
|
|------|----------|
|
||||||
|
| USER legt Eintrag ohne shared an | 200, `personal: true` |
|
||||||
|
| USER `shared: true` | 403 „Gemeinsame Einträge dürfen nur Administratoren anlegen“ |
|
||||||
|
| Admin `shared: true` | 200, `personal: false` |
|
||||||
|
| Admin ohne shared | 200, `personal: true` |
|
||||||
|
| Liste USER | gemeinsam + eigener |
|
||||||
|
| Liste zweiter USER | nur gemeinsam |
|
||||||
|
| Liste Admin | nur gemeinsam (persoenliche Eintraege anderer nicht) |
|
||||||
|
| zweiter USER: GET / PATCH / DELETE auf fremden persoenlichen Eintrag | 404 / 404 / 404 |
|
||||||
|
| Admin: GET / DELETE auf persoenlichen Eintrag eines Benutzers | 404 / 404 |
|
||||||
|
| USER GET gemeinsam | 200 |
|
||||||
|
| USER PATCH / DELETE gemeinsam | 403 / 403 |
|
||||||
|
| Admin PATCH gemeinsam (mit eingeschmuggeltem `shared:false`) | 200, bleibt gemeinsam |
|
||||||
|
| USER PATCH eigenen mit `shared:true`, `ownerUserId:null` | 200, bleibt persoenlich |
|
||||||
|
| http-Adresse | 400 |
|
||||||
|
| anonym | 401 |
|
||||||
|
| Benutzer loeschen -> seine persoenlichen Eintraege | Cascade, 0 Zeilen |
|
||||||
|
|
||||||
|
Alle Testeintraege sind geloescht, `CustomModule` ist leer.
|
||||||
|
|
||||||
|
## Tore (gemessen)
|
||||||
|
|
||||||
|
| Tor | Ergebnis |
|
||||||
|
|-----|----------|
|
||||||
|
| API-Tests vollstaendig | 88 Dateien, 1511 Tests gruen |
|
||||||
|
| Web-Tests vollstaendig | 103 Dateien, 1003 Tests gruen |
|
||||||
|
| `pnpm turbo run type-check lint --force` | 9/9 erfolgreich |
|
||||||
|
| Biome-Warnungen Web / API | 55 (Grundlinie 55) / 82 (Grundlinie 82) |
|
||||||
|
| rls-coverage / rls-access-inventory | gruen |
|
||||||
|
| `prisma migrate deploy` lokal (Container-IP 172.19.0.2) | Migration angewendet, `migrate diff` danach leer |
|
||||||
|
| Zugriffsklassifikation | Gate-Schleife 61/223/6 (vorher 61/224/6); `custom-modules` 0/6/0 |
|
||||||
|
|
||||||
|
## Testbenutzer fuer die Browser-Pruefung des Orchestrators
|
||||||
|
|
||||||
|
Es gab lokal schon die Nicht-Admin-Konten `nutzer1` und `nutzer2`, deren Passwoerter aber nicht bekannt sind. Deshalb habe ich per Admin-API angelegt: Login **testuser**, Passwort **Test1234!test** (Rolle USER, `mustChangePassword` auf false gesetzt, damit die Anmeldung nicht auf die Passwort-Seite umleitet). Der Administrator ist wie gehabt admin / admin123.
|
||||||
|
|
||||||
|
Vorschlag fuer die Browser-Pruefung (dunkel): als testuser unter Einstellungen > Allgemein > Eigene Module einen Eintrag anlegen (Seitenleiste zieht ohne Neuladen nach), als admin unter Verwaltung > Eigene Module einen gemeinsamen Eintrag anlegen (testuser sieht ihn in der Seitenleiste, kann ihn unter Einstellungen aber nicht bearbeiten), als admin pruefen, dass der persoenliche Eintrag von testuser weder in Seitenleiste noch Verwaltung erscheint. Danach die Testeintraege loeschen.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 3 - Blocking] Festplatte voll (0 Byte frei) mitten in der Arbeit**
|
||||||
|
- **Found during:** Aufgabe 2 (Biome meldete „No space left on device“)
|
||||||
|
- **Issue:** die Docker-Build-Cache-Ablagen der Neubauten fuellten die Platte.
|
||||||
|
- **Fix:** `docker builder prune -f` (nur Build-Cache, 17,97 GB, keine Images, Container oder Volumes); danach type-check/lint/Tests frisch und vollstaendig wiederholt, alle gruen.
|
||||||
|
- **Commit:** kein Code betroffen.
|
||||||
|
|
||||||
|
**2. [Rule 1 - Bug] Detektor-Vorgaben fuer `forTenant`**
|
||||||
|
- **Found during:** Aufgabe 1 (rls-access-inventory schlug zweimal fehl)
|
||||||
|
- **Issue:** eine Ternary-Bindung (`shared ? forTenant(..) : forTenant(..)`) und ein `client.customModule.create` in einer Hilfsfunktion werden vom Detektor nicht als Zuweisungsform/Modellaufruf erkannt.
|
||||||
|
- **Fix:** je Zweig `const tenantPrisma = forTenant(...)` mit direktem Modellaufruf; Ausnahmeliste unveraendert leer.
|
||||||
|
- **Files modified:** `apps/api/src/custom-modules/custom-modules.service.ts`
|
||||||
|
- **Commit:** c703d87
|
||||||
|
|
||||||
|
**3. Plan-Feinheit:** Kein API-Parameter `scope`; die Verwaltung filtert im Web ueber `personal` (Plan liess beides zu, „das Einfachste“). Nebenwirkung: die Verwaltungsseite laedt auch die eigenen persoenlichen Eintraege des Administrators und blendet sie aus.
|
||||||
|
|
||||||
|
**4. Plan-Feinheit:** Formular-/Dialog-Texte aus `admin.customModules` in den neuen Namensraum `customModules.form` umgezogen (beide Aufrufer teilen sie); Admin-Test entsprechend angepasst. Die Anleitung des Anwenders hatte den Punkt „Eigene Module“ vorher nicht, er ist jetzt neu beschrieben (der Plan sprach von „aktualisieren“).
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
|
||||||
|
- Zwischen c703d87 und ee97b4e liegt ein fremder Commit `bc4c011` (fix(web) Widgets nicht mehr zur Mitte versetzen), nicht von diesem Plan; er beruehrt CHANGELOG.md und `docs/anleitung-anwender.md` an anderen Stellen. Die 3 Commits dieses Plans sind c703d87, ee97b4e, 8f41bd2 (`git rev-list` ab dem Vorgaenger von c703d87 zaehlt 4 inklusive des fremden). Der Ledger nach Protokoll 0c wurde nicht vor dem ersten Commit angelegt, `plan_head_before` ist deshalb der Vorgaenger von c703d87.
|
||||||
|
- Die Verwaltungs-Nav zeigt weiterhin „Eigene Module“; sie fuehrt jetzt auf die gemeinsamen Eintraege, die Einleitung nennt das.
|
||||||
|
- Nichts gepusht.
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
Keine.
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
Keine neue Angriffsflaeche ausserhalb des bestehenden Modells: `ownerUserId` kommt nie aus dem Body (Whitelist, im Test belegt), `shared` ist per PATCH nicht setzbar, fremde persoenliche Eintraege sind ununterscheidbar 404.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- Dateien vorhanden: Migration `20260929130000_custom_module_owner`, `custom-module-manager.tsx`, `settings/custom-modules/page.tsx`, Settings-Test.
|
||||||
|
- Commits vorhanden: c703d87, ee97b4e, 8f41bd2 (`git log`); nichts gepusht (`git branch -r --contains HEAD` leer).
|
||||||
|
|
||||||
|
## Browser-Pruefung (Orchestrator, 29.09.)
|
||||||
|
|
||||||
|
- testuser: Einstellungen > Eigene Module vorhanden; „Meine Seite“ angelegt -> sofort in eigener Seitenleiste (Infrastruktur).
|
||||||
|
- admin: sieht „Meine Seite“ weder in Seitenleiste noch Verwaltung; Direktlink zeigt „Dieses Modul gibt es nicht mehr.“; Verwaltung heisst „Gemeinsamen Eintrag anlegen“.
|
||||||
|
- admin legt „Firmenseite“ (Sicherheit) an -> testuser sieht sie in der Seitenleiste, nicht in seinen Einstellungen; DELETE als testuser -> 403.
|
||||||
|
- Nebenbei: Widgets nicht mehr zentriert (bc4c011) — alle linken Kanten am Raster (272 px bei Rasterbeginn 260 + 12 Rand).
|
||||||
|
- Testeintraege geloescht, CustomModule leer.
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260929-if2
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/prisma/migrations/20260929140000_reminder/migration.sql
|
||||||
|
- apps/api/src/app.module.ts
|
||||||
|
- apps/api/src/reminders/reminders.module.ts
|
||||||
|
- apps/api/src/reminders/reminders.controller.ts
|
||||||
|
- apps/api/src/reminders/reminders.controller.spec.ts
|
||||||
|
- apps/api/src/reminders/reminders.service.ts
|
||||||
|
- apps/api/src/reminders/reminders.service.spec.ts
|
||||||
|
- apps/api/src/reminders/dto/reminder.dto.ts
|
||||||
|
- apps/api/src/reminders/reminder-mail.scheduler.ts
|
||||||
|
- apps/api/src/reminders/reminder-mail.scheduler.spec.ts
|
||||||
|
- apps/api/src/mail/mail.service.ts
|
||||||
|
- apps/api/src/mail/mail.service.spec.ts
|
||||||
|
- apps/api/src/prisma/rls-access-inventory.spec.ts
|
||||||
|
- packages/shared/src/index.ts
|
||||||
|
- apps/web/src/lib/reminders-api.ts
|
||||||
|
- apps/web/src/lib/reminders-api.test.ts
|
||||||
|
- apps/web/src/lib/reminder-notify.ts
|
||||||
|
- apps/web/src/lib/reminder-notify.test.ts
|
||||||
|
- apps/web/src/lib/reminder-time.ts
|
||||||
|
- apps/web/src/lib/reminder-time.test.ts
|
||||||
|
- apps/web/src/components/reminders/reminder-notifier.tsx
|
||||||
|
- apps/web/src/components/reminders/reminder-notifier.test.tsx
|
||||||
|
- apps/web/src/components/layout/app-shell.tsx
|
||||||
|
- apps/web/src/components/dashboard/widgets/reminder-widget.tsx
|
||||||
|
- apps/web/src/components/dashboard/widgets/reminder-widget.test.tsx
|
||||||
|
- apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx
|
||||||
|
- apps/web/src/components/dashboard/widget-registry.tsx
|
||||||
|
- apps/web/src/components/dashboard/widgets/widget-icon.tsx
|
||||||
|
- apps/web/src/components/dashboard/widgets/widget-wrapper.tsx
|
||||||
|
- apps/web/src/app/(portal)/page.tsx
|
||||||
|
- apps/web/src/messages/de.json
|
||||||
|
- apps/web/src/messages/en.json
|
||||||
|
- apps/web/src/messages/umlaut-dictionary.ts
|
||||||
|
- apps/desktop/src-tauri/src/lib.rs
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||||
|
- docs/anleitung-anwender.md
|
||||||
|
- CHANGELOG.md
|
||||||
|
autonomous: true
|
||||||
|
requirements: [QUICK-260929-if2]
|
||||||
|
|
||||||
|
estimate:
|
||||||
|
tokens: 260000
|
||||||
|
raw_tokens: 260000
|
||||||
|
tasks: 3
|
||||||
|
confidence: low
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "A user adds the dashboard widget 'Erinnerungen', creates a reminder with date, time, title and description in local time, and sees only their own open reminders, sorted by due time (D-05)"
|
||||||
|
- "At the due time (D-02, no advance warning) an open Tessera browser tab shows a Web Notification once permission was granted. Permission is asked only from the widget on the first creation, never on page load (D-04)"
|
||||||
|
- "At the due time the desktop app shows a native OS notification, also while the main window is hidden in the tray, through the notification plugin, which the page may call only from the stored server origin"
|
||||||
|
- "Every client shows each (reminder id, dueAt) at most once: tabs of one browser share the local claim, and the desktop app and the browser each notify once"
|
||||||
|
- "A due reminder stays in the widget, highlighted, with 'Erledigt' (removes it) and 'Später erinnern' (+10 min, +1 h, tomorrow at the same time). Snoozing sets a new dueAt, so notifications fire again and the e-mail is sent again when enabled (D-01, D-03)"
|
||||||
|
- "Upcoming reminders can be edited and deleted. Editing a due reminder is rejected with 409, snoozing a reminder that is not due yet is rejected with 409"
|
||||||
|
- "With 'zusätzlich per E-Mail' on, the server sends exactly one e-mail per due occurrence through the tenant SMTP config (time shown in Europe/Berlin), without any open client and also with several API instances (atomic claim). The toggle is disabled with an explanation when SMTP is not configured or the user has no e-mail"
|
||||||
|
- "A foreign reminder id always returns 404 (never 403). The Reminder table has a tenant+user RLS policy and a system read policy. rls-coverage and rls-access-inventory stay green, and the classification doc is re-measured"
|
||||||
|
- "All API and web tests are green, type-check and lint are green, Biome warnings stay at web <= 55 and api <= 82, and cargo test/fmt/clippy are green"
|
||||||
|
artifacts:
|
||||||
|
- path: "apps/api/prisma/migrations/20260929140000_reminder/migration.sql"
|
||||||
|
provides: "Reminder table, FK to User with cascade, indexes, RLS ENABLE+FORCE, tenant_isolation_policy with user dimension, system_read_policy FOR SELECT"
|
||||||
|
contains: "system_read_policy"
|
||||||
|
- path: "apps/api/src/reminders/reminders.service.ts"
|
||||||
|
provides: "Owner-scoped CRUD, snooze, email availability, all via forTenant(prisma, tenantId, userId)"
|
||||||
|
- path: "apps/api/src/reminders/reminders.controller.ts"
|
||||||
|
provides: "GET /reminders, GET /reminders/email-status, POST /reminders, PATCH /reminders/:id, POST /reminders/:id/snooze, DELETE /reminders/:id"
|
||||||
|
- path: "apps/api/src/reminders/reminder-mail.scheduler.ts"
|
||||||
|
provides: "30-second global tick, reads candidates through the system context, then claims and sends each one tenant-bound"
|
||||||
|
- path: "apps/web/src/lib/reminder-notify.ts"
|
||||||
|
provides: "Tauri-vs-browser notification helper, one-time permission request, local dedup claim with Web Locks"
|
||||||
|
- path: "apps/web/src/components/reminders/reminder-notifier.tsx"
|
||||||
|
provides: "Global notifier mounted in AppShell, polls and fires on due"
|
||||||
|
- path: "apps/web/src/components/dashboard/widgets/reminder-widget.tsx"
|
||||||
|
provides: "Erinnerungen widget: list, due highlight, create/edit modal, Erledigt, Später erinnern, e-mail toggle"
|
||||||
|
- path: "apps/desktop/src-tauri/src/lib.rs"
|
||||||
|
provides: "server_origin_pattern() (host escaped for URLPattern, self-checked with tauri::utils::acl::RemoteUrlPattern, None when it does not parse or match) + grant_server_notifications() runtime remote capability; server_origin_* unit tests pin the exact 3-permission set, the IPv6 and wildcard-host patterns and the match/no-match behavior"
|
||||||
|
key_links:
|
||||||
|
- from: "apps/web/src/components/layout/app-shell.tsx"
|
||||||
|
to: "apps/web/src/components/reminders/reminder-notifier.tsx"
|
||||||
|
via: "<ReminderNotifier /> next to <ReleaseNoticeHost />, so notifications fire on every portal page and not only when the widget is visible"
|
||||||
|
pattern: "ReminderNotifier"
|
||||||
|
- from: "apps/web/src/lib/reminder-notify.ts"
|
||||||
|
to: "tauri-plugin-notification"
|
||||||
|
via: "window.__TAURI_INTERNALS__.invoke('plugin:notification|notify', { options: { title, body } })"
|
||||||
|
pattern: "plugin:notification\\|notify"
|
||||||
|
- from: "apps/desktop/src-tauri/src/lib.rs"
|
||||||
|
to: "tauri runtime authority"
|
||||||
|
via: "app.add_capability(CapabilityBuilder::new(..).remote(<stored origin>).window(\"main\").permission(notification:*)) in setup() and save_server_url()"
|
||||||
|
pattern: "add_capability"
|
||||||
|
- from: "apps/api/src/reminders/reminder-mail.scheduler.ts"
|
||||||
|
to: "apps/api/src/mail/mail.service.ts"
|
||||||
|
via: "claim via tenant-bound updateMany(where emailSentAt null, same dueAt) -> sendReminderEmail -> release claim only on transport failure"
|
||||||
|
pattern: "sendReminderEmail"
|
||||||
|
- from: "apps/api/src/reminders/reminders.service.ts (snooze)"
|
||||||
|
to: "Reminder.emailSentAt / emailAttempts"
|
||||||
|
via: "snooze writes new dueAt AND resets emailSentAt=null, emailAttempts=0"
|
||||||
|
pattern: "emailAttempts: 0"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260929-if2: Reminder widget "Erinnerungen" with notifications (desktop, browser, optional e-mail)
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Build a new dashboard widget called "Erinnerungen" (widget type key `reminder`). A user sets personal one-time reminders (date + time + title + description). At the due time Tessera notifies them: a native OS notification in the desktop app (also while the window is hidden in the tray), a Web Notification in the browser, and optionally one e-mail sent by the server. After the due time the reminder stays in the widget, highlighted, until the user clicks "Erledigt" or snoozes it with "Später erinnern".
|
||||||
|
|
||||||
|
Purpose: this is the first time-driven feature that reaches the user outside the dashboard, and it reuses the SMTP setup, the desktop notification plugin and the RLS pattern that already exist.
|
||||||
|
|
||||||
|
Output: Prisma model + migration with RLS, NestJS module `reminders` (CRUD, snooze, email status, mail scheduler), web widget + global notifier + helpers, a Tauri runtime capability, tests, re-measured classification doc, CHANGELOG entry and user guide entry.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||||
|
@~/.claude/gsd-core/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/STATE.md
|
||||||
|
@./CLAUDE.md
|
||||||
|
|
||||||
|
Pattern sources, read before the task that uses them:
|
||||||
|
@apps/api/src/custom-modules/custom-modules.service.ts
|
||||||
|
@apps/api/src/custom-modules/custom-modules.controller.ts
|
||||||
|
@apps/api/src/custom-modules/custom-modules.controller.spec.ts
|
||||||
|
@apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql
|
||||||
|
@apps/api/prisma/migrations/20260914120000_rls_system_context_read/migration.sql
|
||||||
|
@apps/api/src/tenders/tender-digest.scheduler.ts
|
||||||
|
@apps/api/src/mail/mail.service.ts
|
||||||
|
@apps/api/src/prisma/prisma-tenant.extension.ts
|
||||||
|
@apps/web/src/components/dashboard/widget-registry.tsx
|
||||||
|
@apps/web/src/components/layout/app-shell.tsx
|
||||||
|
@apps/web/src/lib/favorites-api.ts
|
||||||
|
@apps/web/src/components/custom-modules/custom-module-form-modal.tsx
|
||||||
|
@apps/web/src/components/dashboard/widgets/picture-frame-lightbox.tsx
|
||||||
|
@apps/desktop/src-tauri/src/lib.rs
|
||||||
|
</context>
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
Locked (from the user, must be implemented exactly; cited as D-NN in the tasks):
|
||||||
|
|
||||||
|
- **D-01** One-time reminders only. No recurrence field and no recurrence UI.
|
||||||
|
- **D-02** No advance warning. Notifications and the e-mail fire exactly at `dueAt`, never before.
|
||||||
|
- **D-03** After the due time the reminder stays in the widget, marked as due, with two actions. "Erledigt" removes it from the list. "Später erinnern" offers +10 min, +1 h and "morgen zur gleichen Uhrzeit"; each option sets a new `dueAt`, the notifications fire again, and the e-mail is sent again if it is enabled.
|
||||||
|
- **D-04** Browser notifications: yes. The permission is requested once, from the widget, on the first reminder creation (a user gesture), never on page load.
|
||||||
|
- **D-05** Reminders are personal. Only the owner sees and edits them, and a foreign id returns 404.
|
||||||
|
|
||||||
|
Chosen by the planner (Claude's discretion). Each choice is documented in code comments where it applies:
|
||||||
|
|
||||||
|
- **E-01 Desktop mechanism: the page-side notifier plus a runtime remote capability.** The global web notifier (it runs in the Tauri webview as well) calls the notification plugin through `window.__TAURI_INTERNALS__.invoke('plugin:notification|notify', …)`. The static `capabilities/default.json` has no `remote` block on purpose (T-JN2-01, see the doc comment on `get_server_url`), so Tauri rejects plugin calls from the server page. The Rust side therefore adds, at runtime, one capability bound to exactly the stored server origin (`scheme://host[:port]`), limited to window `main`, and granting only `notification:allow-notify`, `notification:allow-is-permission-granted` and `notification:allow-request-permission`. App commands such as `save_server_url` stay local-only. Tauri 2.11.3 has `dynamic-acl` in its default features, and `tauri::ipc::CapabilityBuilder::remote()` plus `Manager::add_capability()` exist. Two alternatives were rejected. A Rust-side poll of the API fails because Basic-Auth in front of alpha returns 401 to reqwest (the same problem the updater has) and because the session cookie lives only in the webview. The Web Notification API inside the webview is not an option either: the plugin's init script replaces `window.Notification` with a polyfill that makes the same IPC call. On Windows that polyfill also reports `permission = "denied"` on every page load until `requestPermission()` runs. So the helper never uses `Notification.permission` inside Tauri and calls invoke directly. Timers in a hidden webview are throttled by Chromium (at most once per minute after 5 minutes hidden), so a notification from the tray can arrive up to about 1 minute late. That is accepted.
|
||||||
|
- **E-02 "Erledigt" deletes the row.** No history UI was requested, and deleting avoids any retention question. The same `DELETE /reminders/:id` backs both "Löschen" (offered before due) and "Erledigt" (offered after due).
|
||||||
|
- **E-03 Catch-up window of 24 h.** When a client opens late, it still notifies once for reminders that became due within the last 24 h. Older due reminders are only shown, highlighted, in the widget. The e-mail scheduler also only picks reminders due within the last 24 h. That covers API restarts and downtime, and it keeps a late SMTP setup from sending mails about old reminders.
|
||||||
|
- **E-04 E-mail delivery semantics.** The scheduler claims before sending (`emailSentAt = now`, `emailAttempts + 1`, only where `emailSentAt IS NULL`, `dueAt` unchanged and `emailAttempts < 3`). It releases the claim (`emailSentAt = null`) only when the transport throws, so a failed send retries at most 3 times. When the tenant has no SmtpConfig or the user has no e-mail address at send time, the claim is kept: the occurrence counts as handled and is logged, with no send and no retry loop. A snooze resets both fields.
|
||||||
|
- **E-05 "Morgen zur gleichen Uhrzeit".** Computed on the client in local time: take the original `dueAt`, add one calendar day (`setDate(+1)`, which is DST-safe), and repeat until the result is in the future. Typical case: due today 14:00, snoozed at 14:05, new time tomorrow 14:00. +10 min and +1 h count from *now*, not from the old dueAt. The client sends the computed ISO `dueAt`, and the server only validates it.
|
||||||
|
- **E-06 Limits.** At most 100 reminders per user (create returns 409 above that). Title 1–200 characters, description 0–2000 characters. `dueAt` must be after *now* and at most 5 years ahead (both return 400).
|
||||||
|
- **E-07 E-mail language and time zone.** The mail is in German with the time formatted in `Europe/Berlin` (`de-DE`, `dateStyle: 'full'`, `timeStyle: 'short'`, followed by " Uhr"). No per-user locale is stored in `User`. The mail is text-only (no HTML), and CR/LF are stripped from the subject.
|
||||||
|
- **E-08 Scheduler tick.** One global 30-second interval registered through `SchedulerRegistry.addInterval` in `onApplicationBootstrap` (lifecycle choice as in `TenderSchedulerService`). It does not use the `require('cron')` + cast workaround, which would add a Biome warning. An in-process `running` flag skips overlapping ticks.
|
||||||
|
- **E-09 SMTP "configured"** means the tenant has a `SmtpConfig` row (`SettingsService.getSmtpConfig(tenantId) !== null`), the same rule as `TenderMailService`. The environment fallback of `MailService` does not count.
|
||||||
|
|
||||||
|
## Interfaces (contract the three tasks share)
|
||||||
|
|
||||||
|
API (all routes need authentication; `tenantId` comes from `req.tenantId` and the user from `@CurrentUser()`; never from the body):
|
||||||
|
|
||||||
|
| Route | Body | Result | Errors |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `GET /reminders` | – | `Reminder[]` of the caller, `dueAt` ascending | – |
|
||||||
|
| `GET /reminders/email-status` (Task 3) | – | `{ smtpConfigured: boolean, hasEmail: boolean }` | – |
|
||||||
|
| `POST /reminders` | `{ title, description?, dueAt (ISO 8601), emailEnabled? (Task 3) }` | `Reminder` | 400 invalid/past/>5 y, 400 emailEnabled while unavailable, 409 limit |
|
||||||
|
| `PATCH /reminders/:id` (Task 2) | partial create body | `Reminder` | 404 foreign/unknown, 409 already due, 400 as above |
|
||||||
|
| `POST /reminders/:id/snooze` (Task 2) | `{ dueAt }` | `Reminder` | 404, 409 not due yet, 400 past/>5 y |
|
||||||
|
| `DELETE /reminders/:id` (Task 2) | – | `{ deleted: true }` | 404 |
|
||||||
|
|
||||||
|
`Reminder` response = exactly `{ id, title, description, dueAt, emailEnabled, createdAt, updatedAt }` through a `REMINDER_SELECT` constant (pattern `CUSTOM_MODULE_SELECT`). `tenantId`, `userId`, `emailSentAt` and `emailAttempts` never leave the service.
|
||||||
|
|
||||||
|
Prisma model `Reminder`: `id String @id @default(uuid())`, `tenantId String`, `userId String`, `user User @relation(fields: [userId], references: [id], onDelete: Cascade)`, `title String`, `description String @default("")`, `dueAt DateTime`, `emailEnabled Boolean @default(false)`, `emailSentAt DateTime?`, `emailAttempts Int @default(0)`, `createdAt DateTime @default(now())`, `updatedAt DateTime @updatedAt`, `@@index([tenantId, userId, dueAt])`, `@@index([dueAt])`. `User` gets `reminders Reminder[]`. There is no `doneAt` column (E-02) and no recurrence column (D-01).
|
||||||
|
|
||||||
|
Web: `apps/web/src/lib/reminders-api.ts` exports the type `Reminder` (dates as ISO strings), `ReminderRequestError` (carries `status: number`), `listReminders()`, `createReminder(input)`, `updateReminder(id, patch)`, `snoozeReminder(id, dueAt)`, `deleteReminder(id)`, `getReminderEmailStatus()`. It follows the `favorites-api.ts` pattern: `NEXT_PUBLIC_API_URL`, `credentials: 'include'`, and a non-2xx status throws `ReminderRequestError(status)`. After every successful mutation the widget dispatches `window.dispatchEvent(new Event('tessera:reminders-changed'))` (constant `REMINDERS_CHANGED_EVENT`, exported from `reminders-api.ts`).
|
||||||
|
|
||||||
|
## Execution segments (context budget)
|
||||||
|
|
||||||
|
The plan-level estimate (260k tokens raw, calibration factor 1 with 0 samples, confidence low) is above `workflow.smart_zone_tokens` (100k, measured with `config-get`). Quick mode runs exactly one `260929-if2-PLAN.md` per task directory, so this plan is not split into separate plan files. The three task commits are the cut points instead, and each segment is sized on its own:
|
||||||
|
|
||||||
|
| Segment | Ends with commit subject | Raw projection |
|
||||||
|
|---|---|---|
|
||||||
|
| Task 1 (tracer) | `feat(260929-if2): Erinnerungen anlegen und zur Faelligkeit benachrichtigen (Tracer)` | ~100k |
|
||||||
|
| Task 2 | `feat(260929-if2): faellige Erinnerungen erledigen, spaeter erinnern, bearbeiten und loeschen` | ~65k |
|
||||||
|
| Task 3 | `feat(260929-if2): Erinnerung zusaetzlich per E-Mail, Doku und Aenderungsliste` | ~95k |
|
||||||
|
|
||||||
|
Rules for the executor:
|
||||||
|
|
||||||
|
- **Resume rule.** Before the first task, run `git log --format=%s -n 50 --grep='^feat(260929-if2): '` on the current branch. Start at the first task whose commit subject is missing. For every task that is already committed, re-run only its vitest and cargo `<automated>` commands (not the curl end-to-end command, which creates data) before continuing. Nothing is carried over from an earlier conversation: each task's `<read_first>` names everything it needs, and the committed code is the handoff.
|
||||||
|
- **Stop rule.** Stop only directly after a task commit, never in the middle of a task. When the context is past roughly half of the budget after a commit, do not start the next task: write `260929-if2-SUMMARY.md` with `status: halted`, the commit hashes and the measured gate results so far, plus the line "Fortsetzen bei Task N", and return. A new dispatch of the same plan continues through the resume rule and finally rewrites the SUMMARY with `status: complete`.
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="tracer">
|
||||||
|
<name>Task 1 (tracer): create a reminder, see it in the widget, get notified at due time (browser and desktop)</name>
|
||||||
|
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20260929140000_reminder/migration.sql, apps/api/src/reminders/reminders.module.ts, apps/api/src/reminders/reminders.controller.ts, apps/api/src/reminders/reminders.controller.spec.ts, apps/api/src/reminders/reminders.service.ts, apps/api/src/reminders/reminders.service.spec.ts, apps/api/src/reminders/dto/reminder.dto.ts, apps/api/src/app.module.ts, packages/shared/src/index.ts, apps/web/src/lib/reminders-api.ts, apps/web/src/lib/reminders-api.test.ts, apps/web/src/lib/reminder-notify.ts, apps/web/src/lib/reminder-notify.test.ts, apps/web/src/components/reminders/reminder-notifier.tsx, apps/web/src/components/reminders/reminder-notifier.test.tsx, apps/web/src/components/layout/app-shell.tsx, apps/web/src/components/dashboard/widgets/reminder-widget.tsx, apps/web/src/components/dashboard/widgets/reminder-widget.test.tsx, apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx, apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/dashboard/widgets/widget-icon.tsx, apps/web/src/components/dashboard/widgets/widget-wrapper.tsx, apps/web/src/app/(portal)/page.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/desktop/src-tauri/src/lib.rs, docs/mandantentrennung-zugriffsklassifikation.md</files>
|
||||||
|
<read_first>apps/api/src/custom-modules/custom-modules.service.ts, apps/api/src/custom-modules/custom-modules.controller.spec.ts, apps/api/prisma/migrations/20260921120000_dashboard_image/migration.sql, apps/api/prisma/migrations/20260929130000_custom_module_owner/migration.sql (header comment style), apps/web/src/components/dashboard/widget-registry.tsx, apps/web/src/components/dashboard/widgets/favorites-widget.test.tsx (mock style for next-intl and the api module), apps/web/src/components/custom-modules/custom-module-form-modal.tsx, apps/web/src/components/dashboard/widgets/picture-frame-lightbox.tsx (createPortal into document.body), apps/desktop/src-tauri/src/lib.rs lines 630-810 (commands, setup, window builder), docs/mandantentrennung-zugriffsklassifikation.md sections "Übersicht je Bereich" and "Bestandsaufnahme"</read_first>
|
||||||
|
<action>
|
||||||
|
Build ONE thin path through every layer: DB, API, web widget, global notifier, desktop Rust. Only create and list are in this task. Due highlight, edit, delete, snooze and e-mail come later, but the schema and the migration are final now because an applied migration can no longer be changed.
|
||||||
|
|
||||||
|
1. DB (D-05). Add the `Reminder` model exactly as in "Interfaces" and `reminders Reminder[]` on `User` in `apps/api/prisma/schema.prisma`, with a German comment above the model ("quick-260929-if2: persoenliche Erinnerungen, einmalig (D-01)…"). Write the migration `apps/api/prisma/migrations/20260929140000_reminder/migration.sql` by hand:
|
||||||
|
- Start with the mandatory German header comment in the style of `20260929130000_custom_module_owner`: purpose, owner semantics, both policies, the note that app-role grants come through ALTER DEFAULT PRIVILEGES, and the "switch is OFF" note.
|
||||||
|
- Generate the CREATE TABLE, index and FK statements with `prisma migrate diff --from-url <local db url> --to-schema-datamodel prisma/schema.prisma --script`, so that the names match Prisma (`Reminder_pkey`, `Reminder_tenantId_userId_dueAt_idx`, `Reminder_dueAt_idx`, `Reminder_userId_fkey` with ON DELETE CASCADE).
|
||||||
|
- Then add `ENABLE` and `FORCE ROW LEVEL SECURITY`, plus `tenant_isolation_policy` in the user-dimension form of DashboardImage (`"tenantId" = current_tenant_id() AND (current_user_id() IS NULL OR "userId" = current_user_id())`).
|
||||||
|
- Also add `CREATE POLICY system_read_policy ON "Reminder" FOR SELECT USING (is_system_context());`. The comment must say it serves the e-mail scheduler's candidate query (Task 3) and that it is read-only, as in migration 20260914120000.
|
||||||
|
- Run `pnpm --filter @tessera/api exec prisma generate`.
|
||||||
|
|
||||||
|
2. API module `apps/api/src/reminders/`, registered in `apps/api/src/app.module.ts`:
|
||||||
|
- `dto/reminder.dto.ts`: `CreateReminderDto` with `title` (`@IsString @IsNotEmpty @MaxLength(200)`, trimmed via `@Transform`), `description` (`@IsOptional @IsString @MaxLength(2000)`) and `dueAt` (`@IsISO8601({ strict: true })`). Do not add `emailEnabled` yet (Task 3).
|
||||||
|
- `reminders.service.ts`: `list(tenantId, userId)` and `create(tenantId, userId, dto)`.
|
||||||
|
- Every method uses its own `const tenantPrisma = forTenant(this.prisma, tenantId, userId)`. Always use that assignment form and always the name `tenantPrisma`, because `rls-access-inventory.spec.ts` and the doc's gate loop detect it by exactly that form.
|
||||||
|
- Every `where` also carries `tenantId` and `userId`, as an app-level check while the RLS switch is off.
|
||||||
|
- `list` returns the caller's rows ordered by `dueAt` ascending through `REMINDER_SELECT`, with `dueAt` serialized as ISO.
|
||||||
|
- `create` rejects a `dueAt` that is not after now or more than 5 years ahead with `BadRequestException`, and returns 409 `ConflictException` once the user already has 100 rows (E-06). It sets `tenantId` and `userId` from the arguments only.
|
||||||
|
- Add a German class comment explaining ownership (404, never 403, D-05) and the RLS binding.
|
||||||
|
- `reminders.controller.ts` at path `reminders`. Build `requireTenantId` as in `CustomModulesController`, with `@Get()` list and `@Post()` create. Put a German ROUTE-ORDER comment at the top: every static GET route (Task 3 adds `email-status`) must stand above any `:id` route.
|
||||||
|
- `reminders.module.ts`: controller + service (PrismaModule is global).
|
||||||
|
- Specs:
|
||||||
|
- `reminders.service.spec.ts`: list is scoped to tenant+user and sorted; create stores the ids from the arguments, not from the body; past dueAt gives 400; more than 5 years gives 400; the 101st reminder gives 409.
|
||||||
|
- `reminders.controller.spec.ts`: tenantId is passed through; ForbiddenException without tenantId; the global ValidationPipe (whitelist) strips `tenantId`/`userId` from the body; there is a route-order assertion like in the custom-modules spec.
|
||||||
|
|
||||||
|
3. Shared type: append `'reminder'` at the end of `WIDGET_TYPES` in `packages/shared/src/index.ts`. It is a platform widget, so there is no entry in `WIDGET_MODULE_SLUGS`.
|
||||||
|
|
||||||
|
4. Web data and notify helpers.
|
||||||
|
- `apps/web/src/lib/reminders-api.ts`: the type `Reminder`, `ReminderRequestError`, `REMINDERS_CHANGED_EVENT`, `listReminders` and `createReminder`, as specified in "Interfaces". Test file `reminders-api.test.ts`: URLs, `credentials: 'include'`, a non-2xx status throws with that status.
|
||||||
|
- `apps/web/src/lib/reminder-notify.ts`, pure functions without React:
|
||||||
|
- `isTauriWebview()`: true when `window.__TAURI_INTERNALS__` has an `invoke` function. Narrow through `unknown`; no `any` and no non-null assertions, because of the Biome baseline.
|
||||||
|
- `requestBrowserPermissionOnce()`: does nothing inside Tauri, when `Notification` is missing, when the permission is not `'default'`, or when the localStorage flag `tessera.reminders.permissionAsked` is already set. Otherwise it sets the flag and calls `Notification.requestPermission()` (D-04).
|
||||||
|
- `browserPermissionState()`: returns `'desktop' | 'granted' | 'default' | 'denied' | 'unsupported'`.
|
||||||
|
- `showReminderNotification({ title, body, tag })`: inside Tauri it calls invoke `plugin:notification|notify` with `{ options: { title, body } }` and catches errors with a single `console.warn('[reminders] …')`. Outside Tauri it creates `new Notification(title, { body, tag })` only when the permission is `'granted'`, inside try/catch.
|
||||||
|
- `claimNotification(key, nowMs)`: a localStorage record `tessera.reminders.notified` mapping key to ms. It returns true only on the first claim of a key and prunes entries older than 7 days.
|
||||||
|
- `withNotifyLock(fn)`: runs `fn` under `navigator.locks.request('tessera-reminder-notify', …)` when available, otherwise calls it directly.
|
||||||
|
- `remindersToNotify(reminders, nowMs)`: returns the reminders with `dueAt <= now` and `dueAt > now - 24 h` (E-03, D-02: never before dueAt).
|
||||||
|
- Comment in German why Tauri never uses `Notification.permission` (the polyfill reports "denied" on Windows until `requestPermission`, see E-01).
|
||||||
|
- `reminder-notify.test.ts` covers: dedup (same key twice gives true, then false; the key `${id}|${dueAt}` changes after a snooze), pruning, the Tauri branch calls invoke with the exact command and payload, the browser branch only with `'granted'`, the permission is asked at most once and never in Tauri, and the 24 h window.
|
||||||
|
|
||||||
|
5. Global notifier `apps/web/src/components/reminders/reminder-notifier.tsx` (`'use client'`, renders null). It is mounted in `apps/web/src/components/layout/app-shell.tsx` right after `<ReleaseNoticeHost />`, with a comment that it is global so notifications fire on every portal page, not only when the widget is visible.
|
||||||
|
- It loads `listReminders()` on mount, every 60 s, on the `REMINDERS_CHANGED_EVENT`, on `visibilitychange` to visible, and on window `focus`.
|
||||||
|
- A local 10-second tick against the cached list runs `withNotifyLock` → `claimNotification('${id}|${dueAt}')` → `showReminderNotification`.
|
||||||
|
- Notification title: `widgets.reminder.notificationTitle` ("Erinnerung: {title}"). Body: the description, cut to 200 characters, or the due time formatted locally when the description is empty. Tag: `reminder-${id}-${dueAt}`.
|
||||||
|
- On `ReminderRequestError` with status 401 it stops polling until the next focus. Other errors are ignored silently until the next tick.
|
||||||
|
- `reminder-notifier.test.tsx` uses fake timers and a mocked api module: a due reminder gives exactly one notification across several ticks; a reminder that is not due yet gives none; after `dueAt` changes it notifies again; the change event triggers a refetch.
|
||||||
|
|
||||||
|
6. Widget, minimal:
|
||||||
|
- `apps/web/src/components/dashboard/widgets/reminder-widget.tsx` (`WidgetProps`) lists the user's reminders (title, due time via `Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' })`, description clamped to 2 lines) and shows an empty state. A button "Neue Erinnerung" opens `reminder-form-modal.tsx`.
|
||||||
|
- The modal is rendered with `createPortal` into `document.body`, because react-grid-layout transforms would break `position: fixed` (precedent: picture-frame-lightbox). It follows the dialog markup of custom-module-form-modal (`role="dialog"`, `aria-modal`, Escape closes). Fields: date (`type="date"`), time (`type="time"`), title, description. The defaults are today and the next full hour.
|
||||||
|
- Local inputs become ISO via `new Date(`${date}T${time}`)` → `toISOString()` in a small exported function (Task 2 moves it into `reminder-time.ts`). The client check "must be in the future" mirrors the server rule.
|
||||||
|
- On submit, call `requestBrowserPermissionOnce()` synchronously first (a user gesture, D-04), then `createReminder`, then refetch and dispatch `REMINDERS_CHANGED_EVENT`.
|
||||||
|
- Registration:
|
||||||
|
- `apps/web/src/components/dashboard/widget-registry.tsx`: `WIDGET_CONSTRAINTS.reminder = { minW: 8, minH: 4, defaultW: 12, defaultH: 10 }` with a German comment giving the reason in 48-column units (like the note/favorites widgets: list plus button row; 8 columns ≈ 230 px is the smallest usable width). Add a `ReminderIcon` (bell) and the registry entry `nameKey: 'reminder.name'` / `descriptionKey: 'reminder.description'`.
|
||||||
|
- `apps/web/src/components/dashboard/widgets/widget-icon.tsx`: bell path under `reminder`.
|
||||||
|
- `apps/web/src/components/dashboard/widgets/widget-wrapper.tsx`: add `'reminder'` to `FRAME_HEADER_TYPES` (the unified header supplies icon + name and the hide-title toggle).
|
||||||
|
- `apps/web/src/app/(portal)/page.tsx`: `registerWidget('reminder', ReminderWidget)`.
|
||||||
|
- Texts under `widgets.reminder` in `apps/web/src/messages/de.json` and `en.json`: German uses "Sie" and real umlauts; English mirrors the keys. Keys for this task: name "Erinnerungen", description "Termine und Aufgaben mit Benachrichtigung zur gewünschten Zeit", add, empty, dateLabel, timeLabel, titleLabel, descriptionLabel, save, cancel, pastError, saveError, loadError, limitReached, notificationTitle.
|
||||||
|
- `reminder-widget.test.tsx`: the list renders sorted; create calls `createReminder` with the ISO built from the local inputs; rendering does NOT call `Notification.requestPermission`; the first create calls it once; a second create does not call it again.
|
||||||
|
|
||||||
|
7. Desktop (E-01) in `apps/desktop/src-tauri/src/lib.rs`. Facts measured during planning, which the code must respect: in tauri 2.11.3 `add_capability` runs `Resolved::resolve(..).unwrap()` while it holds the runtime-authority mutex (`src/ipc/authority.rs`, `src/lib.rs`), and tauri-utils 2.9.3 `resolve_command` panics with "invalid URL pattern for remote URL" on a pattern it cannot parse. So an unparsable pattern or an unknown permission does NOT come back as `Err`; it crashes `setup()`, and a `catch_unwind` around it would leave a poisoned mutex that breaks every later IPC call. The only safe guard is to validate the inputs before the call. Do not use `catch_unwind`.
|
||||||
|
- Add `fn server_origin_pattern(url: &str) -> Option<String>`:
|
||||||
|
- Reuse `parse_server_url` (http and https only) and take `host_str()`.
|
||||||
|
- Put a backslash in front of every host character outside ASCII `A-Z`, `a-z`, `0-9`, `.` and `-` (URLPattern escaping). Why: the url crate accepts `http://*.example.com/` and returns the host `*.example.com`, which unescaped would become a wildcard pattern for every subdomain. IPv6 hosts come back in brackets (`[::1]`), and the URLPattern tokenizer (urlpattern 0.3.0) rejects `http://[::1]:8080` with `Tokenizer(InvalidName, 1)`, while the escaped form `http://\[\:\:1\]:8080` parses and matches only `[::1]:8080`. Both were measured.
|
||||||
|
- Append `:port` only when the port is explicit and not the default. Path, query and fragment are dropped.
|
||||||
|
- Self-check before returning: parse the pattern with `tauri::utils::acl::RemoteUrlPattern` (its `FromStr` is the parser Tauri uses for `remote.urls`) and require `.test(&parsed_url)` to be true. Return `None` otherwise.
|
||||||
|
- Add `const SERVER_NOTIFICATION_PERMISSIONS: [&str; 3]` with exactly `notification:allow-notify`, `notification:allow-is-permission-granted` and `notification:allow-request-permission`. These identifiers exist in tauri-plugin-notification 2.3.3 (`permissions/autogenerated/commands/notify.toml`, `is_permission_granted.toml`, `request_permission.toml`). An unknown identifier would panic inside `add_capability` as well, which is one reason the exact-set test below exists.
|
||||||
|
- Add `fn grant_server_notifications(app: &AppHandle, url: &str)`:
|
||||||
|
- When `server_origin_pattern` returns `None`, write one `eprintln!` and add no capability. Desktop toasts are then off for that address, while the browser notifications and the e-mail keep working.
|
||||||
|
- Otherwise build `tauri::ipc::CapabilityBuilder::new("server-notifications").remote(pattern).local(false).window("main")` plus each permission, call `app.add_capability(...)`, and on `Err` write one `eprintln!`. It must never fail startup.
|
||||||
|
- Call it in `setup()` once the stored server URL is known, before the first `navigate`, and in `save_server_url` right after the store is saved, before `navigate`.
|
||||||
|
- Doc comment in German: why a runtime capability and not the static `default.json`; exactly the stored origin, escaped and self-checked, never a wildcard; why the self-check is required (panic inside `add_capability`, see above); only notification permissions, no app commands; T-JN2-01 remains in force for `get_server_url` and the other commands; after a server change the old origin keeps notification rights until the app restarts (accepted, T-IF2-03). Also explain the rejected alternatives: the Rust poll (Basic-Auth 401 as with the updater, the session cookie lives in the webview) and the native Web Notification (plugin polyfill).
|
||||||
|
- Unit tests in `mod tests`. Every new test name starts with `server_origin_` followed by a German description, like the existing `server_host_*` tests, so that the verify command can count them. There are at least 10:
|
||||||
|
- `https://alpha.tessera.ctl.de/dashboard?x=1#h` gives exactly `https://alpha.tessera.ctl.de` (path, query and fragment removed).
|
||||||
|
- `http://192.168.13.12:8080/` gives exactly `http://192.168.13.12:8080`.
|
||||||
|
- `https://alpha.tessera.ctl.de:443/` gives exactly `https://alpha.tessera.ctl.de` (the default port is omitted).
|
||||||
|
- With and without a trailing slash, the result is the same.
|
||||||
|
- `ftp://…` gives `None`, and unparsable input gives `None`.
|
||||||
|
- IPv6: `http://[::1]:8080/` gives exactly the raw string `r"http://\[\:\:1\]:8080"`.
|
||||||
|
- Wildcard host: `http://*.example.com/` gives exactly `r"http://\*.example.com"`, with no unescaped `*`.
|
||||||
|
- Match behavior through `tauri::utils::acl::RemoteUrlPattern`: every pattern above parses. It matches its own origin with a different path and query. It does NOT match the other scheme, another port, the subdomain `x.alpha.tessera.ctl.de`, or a different host. The wildcard pattern does not match `http://a.example.com/`. The IPv6 pattern matches `http://[::1]:8080/dashboard` but not `http://[::2]:8080/` and not `http://[::1]/`.
|
||||||
|
- Exact permission set: `assert_eq!` of `SERVER_NOTIFICATION_PERMISSIONS` against the three identifiers above, in that order. This pins the set (T-IF2-03), so an added `notification:default` or a wildcard permission fails the test.
|
||||||
|
- Run `cargo fmt`.
|
||||||
|
|
||||||
|
8. RLS docs (the inventory spec enforces this now): add rows to the "Bestandsaufnahme" table of `docs/mandantentrennung-zugriffsklassifikation.md` for `apps/api/src/reminders/reminders.service.ts` / `reminder` (class `muss-mandantengebunden`, stand `gebunden`), with a reason that names quick-260929-if2, the user dimension and 404. Add an area row `reminders` in "Übersicht je Bereich", update the "Summe" row and the pair count in "Klassen-Verteilung". Re-measure these with the gate loop the doc describes (per area: `this.prisma.` / `tenantPrisma.` / `systemPrisma.` raw hits, .ts without spec). Write down measured numbers, not copied ones.
|
||||||
|
|
||||||
|
9. Migrate locally and rebuild:
|
||||||
|
- Read the DB container IP with `docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1` (currently 172.19.0.2).
|
||||||
|
- Run `DATABASE_URL=postgresql://tessera:tessera_dev@$DB_IP:5432/tessera pnpm --filter @tessera/api exec prisma migrate deploy`, then `migrate diff --exit-code` must be empty.
|
||||||
|
- Run `docker compose up -d --build web api`. If the disk fills up, run `docker builder prune -f` (only the build cache).
|
||||||
|
- The curl end-to-end command in `<verify>` creates one "Tracer-Test" reminder for testuser each time it runs and leaves it in place. Task 2 deletes every row with that title.
|
||||||
|
- Commit locally: `feat(260929-if2): Erinnerungen anlegen und zur Faelligkeit benachrichtigen (Tracer)`, message ending with the Co-Authored-By line. NEVER push.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api exec vitest run src/reminders src/prisma/rls-coverage.spec.ts src/prisma/rls-access-inventory.spec.ts src/dashboard/widget-module-map.spec.ts</automated>
|
||||||
|
<fails_when>non-zero exit, a non-zero "failed" count in the "Test Files" or "Tests" summary line, or "No test files found"</fails_when>
|
||||||
|
<automated>pnpm --filter @tessera/web exec vitest run src/lib/reminder-notify.test.ts src/lib/reminders-api.test.ts src/components/reminders src/components/dashboard src/messages</automated>
|
||||||
|
<fails_when>non-zero exit, a non-zero "failed" count in the "Test Files" or "Tests" summary line, or "No test files found"</fails_when>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib && cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib server_origin_ 2>&1 | grep -E 'test result: ok\. [1-9][0-9]+ passed' && cargo fmt --manifest-path apps/desktop/src-tauri/Cargo.toml --check && cargo clippy --manifest-path apps/desktop/src-tauri/Cargo.toml -- -D warnings</automated>
|
||||||
|
<fails_when>non-zero exit: "test result: FAILED" in the full run, no "test result: ok. N passed" line with N of at least 10 under the server_origin_ filter (grep prints nothing), a diff printed by cargo fmt --check, or an "error:" line from clippy</fails_when>
|
||||||
|
<automated>DB_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1) && cd /home/vicolab/projects/tessera-ctl/apps/api && DATABASE_URL=postgresql://tessera:tessera_dev@$DB_IP:5432/tessera pnpm exec prisma migrate diff --from-url postgresql://tessera:tessera_dev@$DB_IP:5432/tessera --to-schema-datamodel prisma/schema.prisma --exit-code</automated>
|
||||||
|
<fails_when>non-zero exit (2 when the database and schema.prisma differ, printing diff statements instead of "No difference detected."; 1 on a Prisma error such as an unreachable database)</fails_when>
|
||||||
|
<automated>T=$(mktemp) && A=$(mktemp) && curl -sf -c "$T" -H 'Content-Type: application/json' -d '{"username":"testuser","password":"Test1234!test"}' http://localhost:3001/auth/login >/dev/null && curl -sf -c "$A" -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin123"}' http://localhost:3001/auth/login >/dev/null && DUE=$(date -u -d '+2 minutes' +%Y-%m-%dT%H:%M:00.000Z) && curl -sf -b "$T" -H 'Content-Type: application/json' -d "{\"title\":\"Tracer-Test\",\"dueAt\":\"$DUE\"}" http://localhost:3001/reminders | grep -q '"id"' && curl -sf -b "$T" http://localhost:3001/reminders | grep -q 'Tracer-Test' && ADM=$(curl -sf -b "$A" http://localhost:3001/reminders) && echo "$ADM" | grep -q '^\[' && ! echo "$ADM" | grep -q 'Tracer-Test' && echo "tracer e2e ok"</automated>
|
||||||
|
<fails_when>non-zero exit and no "tracer e2e ok" line: a login, the POST or a GET answered with a non-2xx status (curl -f), the POST response has no "id", testuser's list lacks "Tracer-Test", admin's answer is not a JSON array, or admin's list contains "Tracer-Test"</fails_when>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- The migration is applied locally and `migrate diff` is empty.
|
||||||
|
- POST+GET work for testuser, and admin does not see testuser's reminder (D-05).
|
||||||
|
- The widget is in the catalog and lists and creates reminders; the permission is asked only on the first create (D-04).
|
||||||
|
- The notifier fires once per (id, dueAt) at or after dueAt, never before (D-02).
|
||||||
|
- The Tauri branch invokes `plugin:notification|notify`; lib.rs grants the runtime capability for the stored origin only (E-01). The origin pattern is escaped and self-checked, so an IPv6 or wildcard-looking host can neither crash startup nor widen the grant. At least 10 `server_origin_*` tests pass, including the exact 3-permission set, and cargo test/fmt/clippy are green.
|
||||||
|
- rls-coverage and rls-access-inventory are green, and the doc is re-measured.
|
||||||
|
- Committed locally, not pushed.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 2: due state, "Erledigt", "Später erinnern", edit and delete before due</name>
|
||||||
|
<files>apps/api/src/reminders/reminders.controller.ts, apps/api/src/reminders/reminders.controller.spec.ts, apps/api/src/reminders/reminders.service.ts, apps/api/src/reminders/reminders.service.spec.ts, apps/api/src/reminders/dto/reminder.dto.ts, apps/web/src/lib/reminders-api.ts, apps/web/src/lib/reminders-api.test.ts, apps/web/src/lib/reminder-time.ts, apps/web/src/lib/reminder-time.test.ts, apps/web/src/components/dashboard/widgets/reminder-widget.tsx, apps/web/src/components/dashboard/widgets/reminder-widget.test.tsx, apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, docs/mandantentrennung-zugriffsklassifikation.md</files>
|
||||||
|
<read_first>apps/api/src/reminders/reminders.service.ts (from Task 1), apps/api/src/custom-modules/custom-modules.service.ts (loadVisible → 404 pattern), apps/web/src/components/dashboard/widgets/reminder-widget.tsx (from Task 1), apps/web/src/app/globals.css (tokens --color-status-warn / --color-status-warn-fg), apps/web/src/messages/umlaut-guard.spec.ts</read_first>
|
||||||
|
<behavior>
|
||||||
|
- Service: PATCH on a foreign or unknown id gives 404; PATCH on a due reminder (dueAt <= now) gives 409; PATCH with a past dueAt gives 400; a valid PATCH changes only the given fields.
|
||||||
|
- Service: snooze on a reminder that is not due yet gives 409; snooze with a past dueAt gives 400; a valid snooze writes the new dueAt AND emailSentAt=null AND emailAttempts=0; snooze on a foreign id gives 404.
|
||||||
|
- Service: DELETE on a foreign id gives 404, on the own id it deletes and returns { deleted: true } (serves both "Löschen" and "Erledigt", E-02).
|
||||||
|
- reminder-time: snoozeTarget('10m') = now+10 min, '1h' = now+1 h, 'tomorrow' = original local time on the next calendar day, repeated until it is in the future (E-05); localInputsToIso/isoToLocalInputs round-trip.
|
||||||
|
- Widget: a due row gets a highlight + "Fällig" badge + "Erledigt" + "Später erinnern" (three options), without edit/delete; an upcoming row gets edit + delete, without Erledigt/Später; "Erledigt" calls deleteReminder and removes the row; each snooze option calls snoozeReminder with the dueAt from snoozeTarget; a row becomes due through the local 10 s tick without reloading.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Expand the tracer so the full lifecycle of D-01/D-03 works end to end.
|
||||||
|
|
||||||
|
1. API in `apps/api/src/reminders/`:
|
||||||
|
- `UpdateReminderDto = PartialType(CreateReminderDto)` and `SnoozeReminderDto { dueAt: IsISO8601 strict }` in `dto/reminder.dto.ts`.
|
||||||
|
- In the service, a private `loadOwn(tenantPrisma, tenantId, userId, id)` returns the row or throws `NotFoundException` for unknown, foreign-tenant and foreign-user rows alike (D-05, never 403).
|
||||||
|
- `update`: 409 `ConflictException` when `row.dueAt <= now` ("Die Erinnerung ist bereits fällig"). A new `dueAt` passes the same future/5-year check as `create`, via a shared private `assertValidDueAt`.
|
||||||
|
- `snooze`: 409 when `row.dueAt > now` (not due yet); validates `dueAt`; writes `{ dueAt, emailSentAt: null, emailAttempts: 0 }` (D-03, so the e-mail fires again).
|
||||||
|
- `remove`: deletes the row (E-02).
|
||||||
|
- All of them use the `const tenantPrisma = forTenant(this.prisma, tenantId, userId)` assignment form, and the `where` clauses carry `tenantId` and `userId`.
|
||||||
|
- Controller: `@Patch(':id')`, `@Post(':id/snooze')`, `@Delete(':id')`, all below the static routes.
|
||||||
|
- Extend both specs with the behaviors above, and extend the route-order spec.
|
||||||
|
|
||||||
|
2. Web helpers:
|
||||||
|
- `apps/web/src/lib/reminder-time.ts` holds pure functions: `snoozeTarget(preset: '10m' | '1h' | 'tomorrow', originalDueAt: Date, now: Date): Date` per E-05, `localInputsToIso(date, time): string | null` (moved here from the Task 1 widget), `isoToLocalInputs(iso): { date, time }`, `defaultNewReminderInputs(now)` (the next full hour).
|
||||||
|
- `apps/web/src/lib/reminder-time.test.ts` has the cases from `<behavior>`, including one across the end of a month.
|
||||||
|
- Extend `apps/web/src/lib/reminders-api.ts` with `updateReminder`, `snoozeReminder` and `deleteReminder`, plus tests.
|
||||||
|
|
||||||
|
3. Widget `apps/web/src/components/dashboard/widgets/reminder-widget.tsx`:
|
||||||
|
- `now` state refreshed every 10 s decides due vs. upcoming. The sort stays `dueAt` ascending.
|
||||||
|
- Due rows (D-03): border/background from the status-warn token (`border-status-warn`, `bg-status-warn/10`, readable in dark mode) and a "Fällig" badge (`bg-status-warn text-status-warn-fg`). Buttons "Erledigt" (calls `deleteReminder`) and "Später erinnern", which toggles an inline option row: "In 10 Minuten", "In 1 Stunde", "Morgen um {time}", where `{time}` is the original local time. It calls `snoozeReminder(id, snoozeTarget(...).toISOString())`.
|
||||||
|
- Upcoming rows: edit (pencil) opens `reminder-form-modal.tsx` prefilled through `isoToLocalInputs` and saves with `updateReminder`. Delete (trash) uses an inline two-step confirm ("Löschen?" Ja/Nein).
|
||||||
|
- On 409 the widget shows the matching text (edit → alreadyDue, snooze → notDue, create → limitReached) and refetches.
|
||||||
|
- After every successful mutation: refetch + dispatch `REMINDERS_CHANGED_EVENT`, so the global notifier picks up a new dueAt immediately.
|
||||||
|
- Buttons are compact and allowed to wrap at minW 8.
|
||||||
|
- Browser hint: when `browserPermissionState()` is `'denied'`, show a muted line saying that the browser blocks notifications and that due reminders then only appear here. Show nothing in Tauri.
|
||||||
|
|
||||||
|
4. New texts under `widgets.reminder` in de/en: due, done, snooze, snooze10m, snooze1h, snoozeTomorrow (with `{time}`), edit, delete, deleteConfirm, yes, no, alreadyDue, notDue, permissionDenied. Run `umlaut-guard.spec.ts`. Extend `apps/web/src/messages/umlaut-dictionary.ts` only if the guard flags a correct German token.
|
||||||
|
|
||||||
|
5. Re-measure the `reminders` area row and the "Summe" row in `docs/mandantentrennung-zugriffsklassifikation.md` with the gate loop, since the service has new bound raw hits.
|
||||||
|
|
||||||
|
6. Rebuild with `docker compose up -d --build web api`. Then, as testuser: DELETE every "Tracer-Test" row from Task 1 (the tracer verify may have run more than once), so GET no longer lists that title. Check that admin gets 404 for PATCH, snooze and DELETE on a testuser id. Commit `feat(260929-if2): faellige Erinnerungen erledigen, spaeter erinnern, bearbeiten und loeschen` (Co-Authored-By line; NEVER push).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api exec vitest run src/reminders src/prisma/rls-access-inventory.spec.ts</automated>
|
||||||
|
<fails_when>non-zero exit, a non-zero "failed" count in the "Test Files" or "Tests" summary line, or "No test files found"</fails_when>
|
||||||
|
<automated>pnpm --filter @tessera/web exec vitest run src/lib/reminder-time.test.ts src/lib/reminders-api.test.ts src/components/dashboard/widgets/reminder-widget.test.tsx src/components/reminders src/messages</automated>
|
||||||
|
<fails_when>non-zero exit, a non-zero "failed" count in the "Test Files" or "Tests" summary line, or "No test files found"</fails_when>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- API: foreign ids give 404 on PATCH, snooze and DELETE; editing a due reminder gives 409; snoozing a reminder that is not due gives 409; snooze resets emailSentAt and emailAttempts.
|
||||||
|
- Widget: due rows are highlighted with Erledigt and the three snooze options (D-03); upcoming rows are editable and deletable.
|
||||||
|
- The Tracer-Test row is removed; the doc is re-measured; committed locally.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 3: optional e-mail at due time (exactly once), toggle in the widget, docs, full gates, manual check list</name>
|
||||||
|
<files>apps/api/src/reminders/reminder-mail.scheduler.ts, apps/api/src/reminders/reminder-mail.scheduler.spec.ts, apps/api/src/reminders/reminders.service.ts, apps/api/src/reminders/reminders.service.spec.ts, apps/api/src/reminders/reminders.controller.ts, apps/api/src/reminders/reminders.controller.spec.ts, apps/api/src/reminders/reminders.module.ts, apps/api/src/reminders/dto/reminder.dto.ts, apps/api/src/mail/mail.service.ts, apps/api/src/mail/mail.service.spec.ts, apps/api/src/prisma/rls-access-inventory.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/reminders-api.ts, apps/web/src/lib/reminders-api.test.ts, apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx, apps/web/src/components/dashboard/widgets/reminder-widget.tsx, apps/web/src/components/dashboard/widgets/reminder-widget.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, CHANGELOG.md, docs/anleitung-anwender.md</files>
|
||||||
|
<read_first>apps/api/src/tenders/tender-digest.scheduler.ts (the forSystem candidates → forTenant loop, per-candidate try/catch), apps/api/src/tenders/tender-digest.scheduler.spec.ts (how forSystem/forTenant are mocked), apps/api/src/tenders/tender-scheduler.service.ts (onApplicationBootstrap), apps/api/src/mail/mail.service.ts (deliver, sendBugReport), apps/api/src/settings/settings.service.ts (getSmtpConfig), apps/api/src/prisma/rls-access-inventory.spec.ts lines 130-192 (FORSYSTEM_ALLOWED_CALL_SITES with its history comment), docs/mandantentrennung-zugriffsklassifikation.md section "Der Hintergrunddienst als Falle", CHANGELOG.md head, docs/anleitung-anwender.md section "Dashboard" (table "Verfügbare Widgets") and "Fenster, Infobereich und Beenden"</read_first>
|
||||||
|
<behavior>
|
||||||
|
- Claim-once: two scheduler instances (or two overlapping ticks) against the same fake store, where updateMany returns count 1 for the first claim and 0 afterwards, lead to exactly one sendReminderEmail call.
|
||||||
|
- Transport failure (sendReminderEmail returns false) releases the claim (emailSentAt=null only where emailSentAt equals the claimed timestamp), so the next tick retries; after 3 attempts (emailAttempts >= 3) the reminder is no longer a candidate.
|
||||||
|
- No SmtpConfig or no user e-mail at send time: the claim is kept, nothing is sent, one log line appears, and nothing is retried (E-04).
|
||||||
|
- The candidate query selects only emailEnabled=true, emailSentAt=null, emailAttempts<3, dueAt <= now AND dueAt >= now-24h (E-03), and only scalar fields (no relation include on the system client).
|
||||||
|
- One failing candidate does not stop the others; an overlapping tick is skipped while `running` is true.
|
||||||
|
- After a snooze (Task 2 reset) the same reminder is a candidate again and gets exactly one more e-mail (D-03).
|
||||||
|
- MailService.sendReminderEmail: subject "Erinnerung: <title>" without CR/LF, text contains the Europe/Berlin time ("… um HH:MM Uhr"), title, description and the app URL; returns true on success and false when deliver throws.
|
||||||
|
- Service/API: GET /reminders/email-status returns { smtpConfigured, hasEmail }; create/update with emailEnabled=true while unavailable gives 400.
|
||||||
|
- Widget: the e-mail checkbox is disabled with the explanation text when smtpConfigured=false, and disabled with the no-address text when hasEmail=false; otherwise it is enabled and sent as emailEnabled; rows with emailEnabled show a small mail icon.
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
Add the server-side e-mail path (E-04, E-07, E-08, E-09), the toggle, the documentation, and run the final gates.
|
||||||
|
|
||||||
|
1. `apps/api/src/mail/mail.service.ts`: add `sendReminderEmail(tenantId, to, reminder: { title: string; description: string; dueAt: Date }): Promise<boolean>`.
|
||||||
|
- Subject: `Erinnerung: ${title}`, with CR/LF replaced by spaces and cut to 150 characters.
|
||||||
|
- The text body is German in the Sie form: salutation, "Sie haben in Tessera eine Erinnerung für {Zeit} gesetzt:", title, description (if present), then the link `this.appUrl`. `{Zeit}` = `Intl.DateTimeFormat('de-DE', { timeZone: 'Europe/Berlin', dateStyle: 'full', timeStyle: 'short' })` + " Uhr".
|
||||||
|
- Call `this.deliver(tenantId, …, 'Reminder')` in try/catch; return true on success, log and return false on failure. No HTML.
|
||||||
|
- Add spec cases to `mail.service.spec.ts`.
|
||||||
|
|
||||||
|
2. `RemindersService`:
|
||||||
|
- `getEmailAvailability(tenantId, userId)` returns `{ smtpConfigured: (await settingsService.getSmtpConfig(tenantId)) !== null, hasEmail: Boolean(user.email) }`, where the user is read through the tenant-bound client.
|
||||||
|
- `create`/`update` accept `emailEnabled` (`@IsOptional @IsBoolean` in the DTO) and throw 400 when it is true while either flag is false.
|
||||||
|
- Controller: `@Get('email-status')` placed directly under `@Get()` and above every `:id` route (NestJS route-order rule); extend the route-order spec.
|
||||||
|
- `reminders.module.ts` imports `MailModule` and `SettingsModule` and provides `ReminderMailScheduler`.
|
||||||
|
|
||||||
|
3. `apps/api/src/reminders/reminder-mail.scheduler.ts`, class `ReminderMailScheduler implements OnApplicationBootstrap`:
|
||||||
|
- `onApplicationBootstrap` registers `this.schedulerRegistry.addInterval('reminder-email', setInterval(() => void this.runTick(), 30_000))`. It first removes an existing entry (try/catch as in the tender schedulers) and logs one line. Errors are only logged, never thrown.
|
||||||
|
- `runTick(now = new Date())` returns immediately while `this.running` is set; otherwise it sets the flag and clears it in `finally`.
|
||||||
|
- Candidates come from EXACTLY ONE `const systemPrisma = forSystem(this.prisma)` and `systemPrisma.reminder.findMany`, with the where clause from `<behavior>`, `select { id, tenantId, userId, dueAt }`, `orderBy dueAt asc`, `take 200`.
|
||||||
|
- Keep the select scalar. A relation include/select on the system client would make `User` a system-read model that needs its own `system_read_policy` (the WINDOWS #27 form).
|
||||||
|
- For each candidate, inside try/catch:
|
||||||
|
1. `const tenantPrisma = forTenant(this.prisma, c.tenantId)` (bound, without a user, as in the digest).
|
||||||
|
2. Claim: `tenantPrisma.reminder.updateMany({ where: { id, tenantId, dueAt: c.dueAt, emailEnabled: true, emailSentAt: null, emailAttempts: { lt: 3 } }, data: { emailSentAt: now, emailAttempts: { increment: 1 } } })`. Continue unless the count is 1.
|
||||||
|
3. Load the title/description/dueAt of the row and the user's e-mail through `tenantPrisma`, and check SMTP availability via `SettingsService.getSmtpConfig`.
|
||||||
|
4. When SMTP is missing or there is no address, log "übersprungen" and keep the claim.
|
||||||
|
5. Otherwise call `sendReminderEmail`. On false, release the claim with `updateMany({ where: { id, emailSentAt: now }, data: { emailSentAt: null } })`.
|
||||||
|
- German class comment: why the claim comes before sending (no duplicate mails with several instances and restarts, at-most-3 attempts), why there is a 24 h window, and why it runs every 30 s.
|
||||||
|
- `reminder-mail.scheduler.spec.ts` covers every scheduler behavior above, mocked the way `tender-digest.scheduler.spec.ts` does it.
|
||||||
|
|
||||||
|
4. RLS inventory:
|
||||||
|
- Add `['apps/api/src/reminders/reminder-mail.scheduler.ts', 1]` to `FORSYSTEM_ALLOWED_CALL_SITES` in `apps/api/src/prisma/rls-access-inventory.spec.ts`, and extend the history comment with a quick-260929-if2 paragraph (candidate query only, all writes bound per row, policy `system_read_policy` from migration 20260929140000; new total "6 Dateien, 7 Aufrufe").
|
||||||
|
- In `docs/mandantentrennung-zugriffsklassifikation.md`:
|
||||||
|
- Add the scheduler rows (`reminder` → class `beides`, stand `system-gebunden`; `user` → `beides`, `gebunden` if read directly) and the `reminders.service.ts` / `user` row if the service reads the user.
|
||||||
|
- Add a paragraph to "Der Hintergrunddienst als Falle" for the new case.
|
||||||
|
- Re-measure the area row, the "Summe" row and "Klassen-Verteilung" with the gate loop.
|
||||||
|
- Run both RLS specs.
|
||||||
|
|
||||||
|
5. Web:
|
||||||
|
- `getReminderEmailStatus()` in `reminders-api.ts`, plus a test.
|
||||||
|
- `reminder-form-modal.tsx` gets the checkbox "Zusätzlich per E-Mail erinnern". Its disabled state and explanation come from the status. It is loaded once per widget mount and treated as unavailable while unknown or failed.
|
||||||
|
- The mail icon appears on rows where `emailEnabled` is set.
|
||||||
|
- New texts: emailLabel, emailNoSmtp ("E-Mail-Erinnerungen sind nicht möglich, weil kein E-Mail-Versand eingerichtet ist. Bitte wenden Sie sich an Ihren Administrator."), emailNoAddress ("In Ihrem Konto ist keine E-Mail-Adresse hinterlegt."), emailOn (for the icon's aria-label). de and en.
|
||||||
|
- Extend the widget tests.
|
||||||
|
|
||||||
|
6. Documentation in plain German for non-programmers, Sie form, real umlauts:
|
||||||
|
- `CHANGELOG.md`: under "## Unveröffentlicht" add a new "### Neu" section ABOVE the existing "### Behoben". Bullet: "Dashboard: Neues Widget „Erinnerungen“ …". It covers date/time/title/description, the notification at exactly the chosen time in the browser (after a one-time permission) and in the desktop app (also from the notification area), the optional e-mail (also when Tessera is not open anywhere), and a due reminder staying highlighted until "Erledigt" or "Später erinnern" (in 10 minutes, in 1 hour, tomorrow at the same time). Add the note that the desktop app needs its new version for this, delivered through the update in the Tessera icon's menu.
|
||||||
|
- `docs/anleitung-anwender.md`:
|
||||||
|
- A row "Erinnerungen" in the table "Verfügbare Widgets", plus a short paragraph below it: the browser asks once, blocked notifications only show in the widget, the e-mail option and why it can be greyed out, snooze options, editing/deleting only before the due time, reminders are personal.
|
||||||
|
- One sentence in "Fenster, Infobereich und Beenden": reminders also appear while the window is in the notification area.
|
||||||
|
|
||||||
|
7. Final gates, in this order. All must pass:
|
||||||
|
- Full API and web test suites.
|
||||||
|
- `pnpm turbo run type-check lint --force`.
|
||||||
|
- Biome counts web <= 55 and api <= 82, measured with the Biome command in `<verify>` (it prints both counts and exits non-zero above the limit).
|
||||||
|
- cargo test/fmt/clippy.
|
||||||
|
- `docker compose up -d --build web api`, then GET /health on the API.
|
||||||
|
- curl as testuser: GET /reminders/email-status answers; POST with `emailEnabled: true` behaves as the status says (201 or 400).
|
||||||
|
- Delete all test reminders again.
|
||||||
|
- Commit `feat(260929-if2): Erinnerung zusaetzlich per E-Mail, Doku und Aenderungsliste` (Co-Authored-By line). NEVER push.
|
||||||
|
- Copy the section "Manuelle Prüfschritte für den Orchestrator" of this plan into the SUMMARY, adjusted to the actual state (for example whether SMTP is configured locally).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>pnpm --filter @tessera/api exec vitest run src/reminders src/mail src/prisma</automated>
|
||||||
|
<fails_when>non-zero exit, a non-zero "failed" count in the "Test Files" or "Tests" summary line, or "No test files found"</fails_when>
|
||||||
|
<automated>pnpm --filter @tessera/api exec vitest run && pnpm --filter @tessera/web exec vitest run && pnpm turbo run type-check lint --force</automated>
|
||||||
|
<fails_when>non-zero exit: a failed test in either suite, or a turbo task reported as failed (a type error or a Biome lint error)</fails_when>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && ok=1; for p in web:55 api:82; do n=${p%%:*}; max=${p#*:}; out=$(pnpm --filter @tessera/$n exec biome lint . 2>&1) && echo "$out" | grep -qE 'Checked [0-9]+ files' || { echo "$n: biome did not run"; ok=0; continue; }; c=$(echo "$out" | grep -oE 'Found [0-9]+ warnings?' | grep -oE '[0-9]+'); echo "$n: ${c:-0} warnings (max $max)"; [ "${c:-0}" -le "$max" ] || ok=0; done; [ "$ok" = 1 ]</automated>
|
||||||
|
<fails_when>non-zero exit, together with a "biome did not run" line or a "web: N warnings (max 55)" / "api: N warnings (max 82)" line whose N is above its max (baseline measured during planning: exactly 55 and 82)</fails_when>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib && cargo fmt --manifest-path apps/desktop/src-tauri/Cargo.toml --check && cargo clippy --manifest-path apps/desktop/src-tauri/Cargo.toml -- -D warnings</automated>
|
||||||
|
<fails_when>non-zero exit: "test result: FAILED", a diff printed by cargo fmt --check, or an "error:" line from clippy</fails_when>
|
||||||
|
<automated>cd /home/vicolab/projects/tessera-ctl && grep -q "reminder-mail.scheduler.ts', 1" apps/api/src/prisma/rls-access-inventory.spec.ts && grep -q "Erinnerungen" CHANGELOG.md && grep -q "Erinnerungen" docs/anleitung-anwender.md</automated>
|
||||||
|
<fails_when>non-zero exit: one of the three strings is missing from its file</fails_when>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- The e-mail is claimed atomically and sent exactly once per due occurrence (tests prove this with concurrent claims), and it is sent again after a snooze.
|
||||||
|
- A transport failure is retried at most 3 times; a missing SMTP config or address is skipped without a loop.
|
||||||
|
- The toggle is disabled with an explanation when e-mail is unavailable.
|
||||||
|
- The inventory allowlist and the doc are updated and re-measured.
|
||||||
|
- CHANGELOG and user guide are written.
|
||||||
|
- All gates are green, and the Biome counts do not exceed the baseline.
|
||||||
|
- The containers are rebuilt, the test data removed, and the work committed locally and never pushed.
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| browser/webview → API `/reminders*` | untrusted body and ids; identity from the session cookie only |
|
||||||
|
| server page (remote origin) → Tauri IPC | web content from the configured server calls the native notification plugin |
|
||||||
|
| API scheduler → SMTP | user-provided title/description go into a mail |
|
||||||
|
| scheduler (all tenants) → DB | the system context reads across tenants |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||||
|
| T-IF2-01 | Information disclosure | `RemindersService` loadOwn/list | high | mitigate | Every query uses `forTenant(prisma, tenantId, userId)` plus `where { tenantId, userId }`; foreign/unknown ids give 404 (never 403); RLS `tenant_isolation_policy` with a user dimension; service specs assert the 404 cases (D-05) |
|
||||||
|
| T-IF2-02 | Tampering | DTOs / controller | high | mitigate | Global ValidationPipe `whitelist` strips `tenantId`/`userId`/`emailSentAt`/`emailAttempts`; the service sets the ids from the token; the controller spec proves the stripping |
|
||||||
|
| T-IF2-03 | Elevation of privilege | `grant_server_notifications` (lib.rs) | medium | mitigate | The runtime capability binds exactly the stored `scheme://host[:port]`, window `main`, and only the three `notification:` permissions; no app commands (save_server_url etc. stay local-only, T-JN2-01). Host characters outside `A-Za-z0-9.-` are escaped, so a host such as `*.example.com` cannot become a wildcard, and the pattern is self-checked with `RemoteUrlPattern` (parse + match of the stored URL), so an IPv6 or otherwise unparsable pattern yields no grant instead of a panic inside `add_capability`. `server_origin_*` unit tests pin the exact 3-permission set with `assert_eq!` and the match/no-match behavior (other scheme, port, subdomain, host; IPv6; wildcard host). Accepted residual risk: after a server change the old origin keeps the notify right until the app restarts |
|
||||||
|
| T-IF2-04 | Denial of service | create/list, notifier polling | medium | mitigate | At most 100 reminders per user (409); title ≤ 200, description ≤ 2000; the notifier polls every 60 s (local ticks without network); the scheduler uses `take 200` and a reentrancy guard |
|
||||||
|
| T-IF2-05 | Tampering (header injection) | `MailService.sendReminderEmail` | medium | mitigate | CR/LF stripped from the subject, text-only body (no HTML, so no HTML injection); recipient only the owner's stored address |
|
||||||
|
| T-IF2-06 | Repudiation / integrity | e-mail duplicates across instances | medium | mitigate | Atomic claim `updateMany … emailSentAt: null, dueAt: <read value>` with a count check before sending; release only on transport failure; at most 3 attempts; the spec proves a single send with concurrent claims |
|
||||||
|
| T-IF2-07 | Information disclosure | system context read | medium | mitigate | `system_read_policy` is FOR SELECT only; the candidate select is scalar-only; every write is tenant-bound per row; `FORSYSTEM_ALLOWED_CALL_SITES` pins exactly 1 call in `reminder-mail.scheduler.ts` |
|
||||||
|
| T-IF2-08 | Information disclosure | OS notification / e-mail content | low | accept | Title/description are the user's own text, shown to that user on their own device and mailbox; lock-screen visibility is the user's OS setting |
|
||||||
|
| T-IF2-09 | Information disclosure | `GET /reminders/email-status` | low | accept | Reveals only two booleans (tenant SMTP present, own address present) to an authenticated user of that tenant |
|
||||||
|
| T-IF2-10 | Spoofing / XSS | widget rendering, notification body | low | mitigate | React escapes the text; Notification/plugin bodies are plain text; nothing is rendered as HTML |
|
||||||
|
| T-IF2-SC | Tampering | npm/pip/cargo installs | high | mitigate | No new packages in this plan (nodemailer, @nestjs/schedule, the Tauri plugins and tauri-plugin-notification are already installed); the executor must not add dependencies, so no package-legitimacy gate is triggered |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `pnpm --filter @tessera/api exec vitest run` and `pnpm --filter @tessera/web exec vitest run` are fully green.
|
||||||
|
- `pnpm turbo run type-check lint --force` is green. Biome: web ≤ 55, api ≤ 82 warnings.
|
||||||
|
- `cargo test --lib`, `cargo fmt --check` and `cargo clippy -- -D warnings` on `apps/desktop/src-tauri/Cargo.toml` are green.
|
||||||
|
- `rls-coverage.spec.ts` and `rls-access-inventory.spec.ts` are green, and the doc numbers are re-measured with the gate loop.
|
||||||
|
- The local migration is applied, and `prisma migrate diff --exit-code` shows no difference.
|
||||||
|
- curl end to end: create/list as testuser, 404 for admin on testuser's id, email-status answers, and the test data is removed.
|
||||||
|
- Local commits only. `git log origin/main..HEAD` shows the new commits and nothing was pushed.
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- A user can create personal one-time reminders in the new "Erinnerungen" widget and see them sorted, with due ones highlighted (D-01, D-05).
|
||||||
|
- A browser tab and the desktop app (including tray mode) each notify exactly once per due occurrence at the due time (D-02, D-04, E-01).
|
||||||
|
- "Erledigt" removes a reminder; "Später erinnern" (+10 min / +1 h / tomorrow same time) re-arms the notifications and the e-mail (D-03).
|
||||||
|
- With e-mail enabled, exactly one mail per due occurrence is sent even with no client open (E-04); the toggle explains why it is disabled when unavailable.
|
||||||
|
- RLS, the inventory and the docs are consistent; CHANGELOG and user guide describe the feature in plain German.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
## Manuelle Prüfschritte für den Orchestrator
|
||||||
|
|
||||||
|
These are carried out by the orchestrator after execution; the executor copies them into the SUMMARY.
|
||||||
|
|
||||||
|
**Browser (Playwright MCP, dark mode through the theme button; never measure via fetch from the page):**
|
||||||
|
1. Log in as `testuser` / `Test1234!test`. Allow notifications for the origin in the Playwright context (`grantPermissions(['notifications'])`); otherwise the prompt stays "default". Also check once with a new context without that grant: the prompt must appear only after clicking "Speichern" on the first reminder, not on page load.
|
||||||
|
2. "Bearbeiten" → "Widget hinzufügen" → catalog shows "Erinnerungen" with the bell icon → add it → "Fertig". Screenshot in dark mode: header with the icon chip, empty state, button "Neue Erinnerung".
|
||||||
|
3. Create a reminder due in 2 minutes (title + description). The list shows it with the local time.
|
||||||
|
4. Before the due time, install a spy in the page (`page.evaluate`, wrap `window.Notification` and count the calls). Switch to another portal page (e.g. Marktplatz) and wait past the due time. Expect exactly one notification call with the title "Erinnerung: …" even though the dashboard is not visible (the notifier is global). Back on the dashboard, the row is highlighted as "Fällig" with "Erledigt" and "Später erinnern". Take a dark-mode screenshot of the highlighted row.
|
||||||
|
5. Open a second tab of the same session and wait 20 s: no second notification for the same reminder.
|
||||||
|
6. "Später erinnern" → "In 10 Minuten": the row is upcoming again with the new time (edit/delete visible). "Bearbeiten": change the title and save. Delete with the confirm step. Create another one, let it become due, then "Erledigt": it disappears.
|
||||||
|
7. The e-mail checkbox in the form: with no tenant SMTP configured locally it is disabled with the explanation text. If SMTP (e.g. mailhog from `docker-compose.dev.yml`) is configured under Einstellungen → SMTP, enable it, let a reminder become due, and verify that exactly one mail arrives with the time in Europe/Berlin. Snooze by 10 minutes, and after that a second mail arrives.
|
||||||
|
8. As `admin` (admin123): the widget does not show testuser's reminders.
|
||||||
|
9. Remove all test reminders afterwards.
|
||||||
|
|
||||||
|
**Windows VM (Proxmox VM 8233, per the stored VM notes):**
|
||||||
|
- The Rust change only takes effect in a desktop client built from this commit. Since nothing is pushed, CI does not build one. The Windows check therefore needs either the user's push + CI build, or a locally built NSIS package. An older installed client shows the widget and sends e-mails, but shows no toast; this is expected and is mentioned in the CHANGELOG.
|
||||||
|
10. With the new client connected to the server that has this code: create a reminder due in 3 minutes, close the window with X (the app goes to the tray), and wait. A Windows toast from "Tessera" with "Erinnerung: …" appears within about 1 minute of the due time (WebView2 throttles hidden timers, E-01). After the toast, open the window: the reminder shows "Fällig".
|
||||||
|
11. With the desktop app and a browser open at the same time, each shows the notification exactly once.
|
||||||
|
12. After "Server-Adresse ändern…" to the same server, notifications still work (capability re-granted in `save_server_url`).
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Create `.planning/quick/260929-if2-reminder-widget-mit-benachrichtigung/260929-if2-SUMMARY.md` when done. It must contain: the measured gate table (test counts, Biome counts, cargo, RLS specs, migrate diff, gate-loop numbers), the curl results, any deviations, and the manual check steps above, adjusted to the actual state.
|
||||||
|
</output>
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260929-if2
|
||||||
|
plan: 01
|
||||||
|
subsystem: dashboard-widgets, api-reminders, desktop-notifications
|
||||||
|
tags: [reminders, notifications, tauri, rls, scheduler, smtp]
|
||||||
|
status: complete
|
||||||
|
requires: []
|
||||||
|
provides:
|
||||||
|
- "Widget 'Erinnerungen' (type reminder) with create/edit/delete, due highlight, Erledigt, Spaeter erinnern"
|
||||||
|
- "API /reminders (list, email-status, create, patch, snooze, delete) with owner scoping (404 for foreign ids)"
|
||||||
|
- "Reminder table with tenant+user RLS policy and system_read_policy"
|
||||||
|
- "Global ReminderNotifier in AppShell (browser Web Notification, Tauri plugin notification)"
|
||||||
|
- "ReminderMailScheduler: atomic-claim e-mail once per due occurrence"
|
||||||
|
- "Tauri runtime remote capability for notification permissions on the stored server origin only"
|
||||||
|
affects: [dashboard, desktop, mail, rls-inventory]
|
||||||
|
tech-stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- "claim-before-send scheduler (updateMany with count check), forSystem candidate query + forTenant per row"
|
||||||
|
- "runtime Tauri capability with escaped and self-checked URL pattern (no catch_unwind)"
|
||||||
|
key-files:
|
||||||
|
created:
|
||||||
|
- apps/api/prisma/migrations/20260929140000_reminder/migration.sql
|
||||||
|
- apps/api/src/reminders/ (module, controller, service, scheduler, dto, 4 specs)
|
||||||
|
- apps/web/src/lib/reminders-api.ts
|
||||||
|
- apps/web/src/lib/reminder-notify.ts
|
||||||
|
- apps/web/src/lib/reminder-time.ts
|
||||||
|
- apps/web/src/components/reminders/reminder-notifier.tsx
|
||||||
|
- apps/web/src/components/dashboard/widgets/reminder-widget.tsx
|
||||||
|
- apps/web/src/components/dashboard/widgets/reminder-form-modal.tsx
|
||||||
|
modified:
|
||||||
|
- apps/api/prisma/schema.prisma
|
||||||
|
- apps/api/src/app.module.ts
|
||||||
|
- apps/api/src/mail/mail.service.ts
|
||||||
|
- apps/api/src/prisma/rls-access-inventory.spec.ts
|
||||||
|
- apps/desktop/src-tauri/src/lib.rs
|
||||||
|
- packages/shared/src/index.ts
|
||||||
|
- apps/web (registry, widget-icon, widget-wrapper, app-shell, page.tsx, messages de/en, umlaut-dictionary)
|
||||||
|
- docs/mandantentrennung-zugriffsklassifikation.md, docs/anleitung-anwender.md, CHANGELOG.md
|
||||||
|
key-decisions:
|
||||||
|
- "E-01 runtime remote capability for exactly the stored origin (escaped, self-checked with RemoteUrlPattern), window main, three notification permissions"
|
||||||
|
- "E-02 Erledigt deletes the row; E-04 claim before send, release only on transport failure, max 3 attempts"
|
||||||
|
- "Snooze resets emailSentAt/emailAttempts so the mail fires again (D-03)"
|
||||||
|
duration: about 1 h 15 min
|
||||||
|
completed: 2026-09-29
|
||||||
|
commits: 3
|
||||||
|
plan_head_before: cd1f8f6cda7d3b1b6fa22ab8ec9274201d9c2089
|
||||||
|
plan_head_after: 8027c4857080790bf9994553fbda8036d81f8eb3
|
||||||
|
actuals:
|
||||||
|
tokens: 43000
|
||||||
|
tasks: 3
|
||||||
|
commits: 3
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase quick-260929-if2 Plan 01: Erinnerungen-Widget mit Benachrichtigung Summary
|
||||||
|
|
||||||
|
Persoenliche einmalige Erinnerungen als Dashboard-Widget: Benachrichtigung zur Faelligkeit im Browser und als native Windows-Meldung in der Desktop-App (auch im Infobereich), optional eine E-Mail, die der Server genau einmal je Faelligkeit ueber einen atomaren Anspruch versendet.
|
||||||
|
|
||||||
|
## Commits (lokal, nicht gepusht)
|
||||||
|
|
||||||
|
| Task | Hash | Betreff |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 (Tracer) | 26f8f0f | feat(260929-if2): Erinnerungen anlegen und zur Faelligkeit benachrichtigen (Tracer) |
|
||||||
|
| 2 | 580c31c | feat(260929-if2): faellige Erinnerungen erledigen, spaeter erinnern, bearbeiten und loeschen |
|
||||||
|
| 3 | 8027c48 | feat(260929-if2): Erinnerung zusaetzlich per E-Mail, Doku und Aenderungsliste |
|
||||||
|
|
||||||
|
`commits:` gemessen mit `git rev-list --count cd1f8f6..HEAD` = 3. Der Tracer-Feedback-Gate (Auto-Modus: `<verify>` erneut ausfuehren) lief vor Task 2: API-, Web-, cargo-Tests, `migrate diff` und die curl-End-to-End-Kette waren gruen, also wurde erweitert.
|
||||||
|
|
||||||
|
## Gemessene Gates (nach Task 3)
|
||||||
|
|
||||||
|
| Gate | Ergebnis |
|
||||||
|
|---|---|
|
||||||
|
| API vitest komplett | 91 Dateien, 1570 Tests, alle gruen |
|
||||||
|
| Web vitest komplett | 108 Dateien, 1069 Tests, alle gruen |
|
||||||
|
| `pnpm turbo run type-check lint --force` | 9/9 Tasks erfolgreich |
|
||||||
|
| Biome-Warnungen | web 55 (max 55), api 82 (max 82), also exakt die Grundlinie |
|
||||||
|
| cargo test --lib | 57 gruen, davon 12 `server_origin_*` (Plan verlangte mindestens 10) |
|
||||||
|
| cargo fmt --check / clippy -D warnings | sauber |
|
||||||
|
| rls-coverage + rls-access-inventory | gruen (30 Zusicherungen im Inventar) |
|
||||||
|
| `prisma migrate diff --exit-code` | "No difference detected" (Exit 0), Migration lokal angewendet |
|
||||||
|
| Gate-Schleife (Rohtreffer, ohne spec) | Summe 61 / 235 / 7 (ungebunden / gebunden / System); Bereich `reminders` 0 / 12 / 1 |
|
||||||
|
| Bestandsaufnahme-Doku | 83 Paare (44 muss-mandantengebunden, 21 keine-mandantengebundene-tabelle, 16 beides, 2 bewusst-uebergreifend), mit `grep -cE '^\| apps/api/src/'` nachgezaehlt |
|
||||||
|
| FORSYSTEM_ALLOWED_CALL_SITES | neu `reminder-mail.scheduler.ts` = 1, Summe 6 Dateien / 7 Aufrufe |
|
||||||
|
|
||||||
|
## curl-Ergebnisse (lokal, gegen die neu gebauten Container)
|
||||||
|
|
||||||
|
- Tracer: testuser POST + GET ok, admin sieht das Tracer-Test nicht (`tracer e2e ok`); Vergangenheit ergibt 400.
|
||||||
|
- Task 2: admin bekommt fuer PATCH, snooze und DELETE auf eine testuser-id je 404; testuser: snooze auf nicht faellige Erinnerung 409, PATCH 200. Alle Test-Zeilen geloescht.
|
||||||
|
- Task 3: `GET /reminders/email-status` liefert `{"smtpConfigured":true,"hasEmail":true}` (lokal ist SmtpConfig auf `mailhog:1025` gesetzt, testuser hat `testuser@example.com`); POST mit `emailEnabled:true` ergibt 201. `/health` ok.
|
||||||
|
- Planerlauf gegen die echte Datenbank: eine Erinnerung mit E-Mail wurde 1 Minute nach Anlage faellig; der Planer versuchte den Versand im 30-s-Takt genau dreimal (mailhog-Container laeuft lokal nicht, also Transportfehler und Freigabe), danach `emailAttempts = 3` und keine weiteren Versuche. Damit ist Anspruch, Freigabe und die Grenze von 3 Versuchen live belegt. Alle Test-Zeilen danach geloescht (`count(*) = 0`).
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
1. **[Rule 3 - Blocking] Bestehende Tests an die neue Kachel angepasst.** `widget-registry.test.tsx` (Typliste, Groessentabelle, Zaehler 40 auf 44) und `widget-catalog-modal.test.tsx` (letzte Kachel nun `reminder`) pruefen die exakte Kachelliste. Ohne Anpassung waere die Suite rot. Commit 26f8f0f.
|
||||||
|
2. **[Rule 3] `reminder-time.ts` schon in Task 1.** Der Plan legte `localInputsToIso` zuerst in die Kachel und verschob sie in Task 2; ich habe sie gleich in `reminder-time.ts` angelegt und in Task 2 nur erweitert (`isoToLocalInputs`, `snoozeTarget`). Kein Verhalten anders.
|
||||||
|
3. **[Rule 2 - Lesbarkeit] "Faellig"-Abzeichen mit 20 % Flaeche.** Der Plan nannte `bg-status-warn text-status-warn-fg`; die Schrift-Variante erreicht auf voller Warnflaeche nur rund 2,6:1 (globals.css-Kommentar). Verwendet wird `bg-status-warn/20 text-status-warn-fg` (die dokumentierte Pillen-Form). Bitte im Dunkelmodus mitpruefen.
|
||||||
|
4. **[Rule 2] `lässt` auf die Umlaut-Allowlist** (`umlaut-dictionary.ts`), weil der Text `alreadyDue` "lässt sich nicht mehr bearbeiten" korrektes Deutsch ist, das der Guard sonst meldet (im Plan vorgesehen, "nur wenn der Guard warnt").
|
||||||
|
5. **Formular-Details:** `emailEnabled` wird beim Bearbeiten nur gesendet, wenn es sich aendert (sonst kippt eine unveraenderte Alt-Einstellung das Speichern mit 400, wenn SMTP inzwischen fehlt); ein bereits angehaktes Feld bleibt bedienbar, damit man es abwaehlen kann. Beim Bearbeiten wird nie die Browser-Erlaubnis abgefragt (D-04: nur beim ersten Anlegen). Zusaetzlich bekam `ReminderFormModal` die Props `emailStatus` und `onStale` (409 beim Bearbeiten laedt neu).
|
||||||
|
6. **Commit-Zeile:** Die Co-Authored-By-Zeile ist `Claude Sonnet 5.5 <noreply@anthropic.com>` (das laufende Modell, wie die Umgebung sie vorgibt), nicht "Claude Opus 5.5 (1M context)" wie in der Aufgabenbeschreibung. Bei Bedarf per Umschreiben anzupassen, bevor gepusht wird.
|
||||||
|
|
||||||
|
Stop-Regel des Plans: nach jedem Commit lag der Kontext weit unter der Haelfte, daher alle drei Segmente in einem Lauf.
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
Keine. Alle Daten der Kachel kommen aus der API; keine Platzhalter.
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
Keine neue Flaeche ausserhalb des Plan-Threat-Models. Zur Beachtung (T-IF2-03, im Plan akzeptiert): nach einem Serverwechsel behaelt der alte Ursprung sein Benachrichtigungsrecht bis zum App-Neustart.
|
||||||
|
|
||||||
|
## Nicht messbar ohne GUI (bitte pruefen)
|
||||||
|
|
||||||
|
- Das Rust-Laufzeitrecht (`add_capability` mit `remote`) ist per Unit-Tests der Musterbildung abgesichert (Escaping, Selbstpruefung, exakte 3er-Menge), aber der echte Toast in der Desktop-App ist nur auf der Windows-VM pruefbar (siehe Schritte 10 bis 12 unten).
|
||||||
|
|
||||||
|
## Manuelle Pruefschritte fuer den Orchestrator
|
||||||
|
|
||||||
|
Angepasst an den Ist-Zustand: lokal ist SMTP auf `mailhog:1025` gesetzt, der mailhog-Container laeuft aber NICHT (`docker ps` zeigt keinen). Die E-Mail-Checkbox ist also aktiv (nicht ausgegraut), Mails scheitern lokal beim Transport, bis mailhog laeuft (z. B. aus `docker-compose.dev.yml` starten).
|
||||||
|
|
||||||
|
**Browser (Playwright MCP, dunkel per Theme-Knopf; nie per fetch aus der Seite messen):**
|
||||||
|
1. Als `testuser` / `Test1234!test` anmelden. Benachrichtigungen fuer den Ursprung im Playwright-Kontext erlauben (`grantPermissions(['notifications'])`), sonst bleibt die Abfrage "default". Einmal auch mit neuem Kontext ohne Erlaubnis: die Abfrage erscheint erst nach Klick auf "Speichern" beim ersten Anlegen, nicht beim Laden der Seite.
|
||||||
|
2. "Bearbeiten" -> "Widget hinzufuegen" -> Katalog zeigt "Erinnerungen" mit Glocken-Symbol -> hinzufuegen -> "Fertig". Dunkel-Screenshot: Kopfzeile mit Symbol-Chip, Leerzustand, Knopf "Neue Erinnerung".
|
||||||
|
3. Erinnerung mit Faelligkeit in 2 Minuten anlegen (Titel + Beschreibung). Die Liste zeigt sie mit lokaler Zeit; der E-Mail-Haken ist bedienbar.
|
||||||
|
4. Vor der Faelligkeit einen Spion in die Seite legen (`page.evaluate`, `window.Notification` umhuellen und Aufrufe zaehlen). Auf eine andere Portalseite (z. B. Marktplatz) wechseln und die Faelligkeit abwarten. Erwartet: genau ein Aufruf mit Titel "Erinnerung: ...", obwohl das Dashboard nicht sichtbar ist (der Melder ist global). Zurueck auf dem Dashboard: Zeile hervorgehoben, Abzeichen "Faellig", Knoepfe "Erledigt" und "Spaeter erinnern". Dunkel-Screenshot der hervorgehobenen Zeile (Lesbarkeit des Abzeichens beurteilen).
|
||||||
|
5. Zweiten Tab derselben Sitzung oeffnen und 20 s warten: keine zweite Benachrichtigung fuer dieselbe Erinnerung.
|
||||||
|
6. "Spaeter erinnern" -> "In 10 Minuten": Zeile ist wieder kuenftig mit neuer Zeit (Bearbeiten/Loeschen sichtbar). "Bearbeiten": Titel aendern, speichern. Loeschen mit Rueckfrage (Ja/Nein). Eine weitere Erinnerung faellig werden lassen, dann "Erledigt": sie verschwindet.
|
||||||
|
7. E-Mail: lokal ist SMTP gesetzt (mailhog:1025), Checkbox ist bedienbar. Fuer den echten Mailfluss zuerst mailhog starten, dann eine Erinnerung mit Haken faellig werden lassen: genau eine Mail mit Zeit in Europe/Berlin. Nach "In 10 Minuten" kommt nach Ablauf eine zweite. (Den ausgegrauten Zustand mit Erklaerungstext kann man pruefen, indem man in Einstellungen -> SMTP die Einrichtung entfernt; danach wiederherstellen.)
|
||||||
|
8. Als `admin` (admin123): das Widget zeigt keine Erinnerungen von testuser.
|
||||||
|
9. Am Ende alle Test-Erinnerungen entfernen.
|
||||||
|
|
||||||
|
**Windows-VM (Proxmox 8233, laut VM-Notizen):**
|
||||||
|
Die Rust-Aenderung wirkt nur in einem Desktop-Client, der aus diesem Stand gebaut ist. Da nichts gepusht wurde, baut CI keinen; der Windows-Test braucht entweder Push + CI-Paket oder ein lokal gebautes NSIS-Paket. Ein aelterer Client zeigt das Widget und bekommt E-Mails, aber keine Toasts (im CHANGELOG vermerkt).
|
||||||
|
10. Mit dem neuen Client, verbunden mit dem Server mit diesem Code: Erinnerung in 3 Minuten anlegen, Fenster mit X schliessen (Infobereich), warten. Ein Windows-Toast von "Tessera" mit "Erinnerung: ..." erscheint innerhalb rund einer Minute nach der Faelligkeit (WebView2 drosselt versteckte Timer, E-01). Danach das Fenster oeffnen: die Erinnerung zeigt "Faellig".
|
||||||
|
11. Desktop-App und Browser gleichzeitig offen: jede zeigt die Benachrichtigung genau einmal.
|
||||||
|
12. Nach "Server-Adresse aendern..." auf denselben Server funktionieren die Benachrichtigungen weiter (Berechtigung wird in `save_server_url` erneut erteilt).
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- Erstellte Dateien vorhanden: Migration, `apps/api/src/reminders/*` (inkl. `reminder-mail.scheduler.ts`), `reminders-api.ts`, `reminder-notify.ts`, `reminder-time.ts`, `reminder-notifier.tsx`, `reminder-widget.tsx`, `reminder-form-modal.tsx` (alle in den Commits enthalten).
|
||||||
|
- Commits 26f8f0f, 580c31c, 8027c48 liegen auf `main` (`git log cd1f8f6..HEAD`); `git status` zeigt ausser dem `.planning`-Verzeichnis nichts Uncommittetes.
|
||||||
|
- Nichts gepusht.
|
||||||
|
|
||||||
|
## Browser-Pruefung (Orchestrator, 29.09., dunkel, testuser)
|
||||||
|
|
||||||
|
- Katalog zeigt „Erinnerungen“; Widget hinzugefuegt, Leerzustand + „Neue Erinnerung“.
|
||||||
|
- „Kaffee holen“ faellig 14:14 mit E-Mail-Haken, danach auf den Marktplatz gewechselt: genau eine Browser-Benachrichtigung „Erinnerung: Kaffee holen“ um 14:14:08 (global, nicht nur auf dem Dashboard).
|
||||||
|
- MailHog (lokal gestartet, Alias mailhog im backend-net): genau eine Mail an testuser@example.com um 14:14:15, Text „Dienstag, 29. September 2026 um 14:14 Uhr“ (Berlin).
|
||||||
|
- Zweiter Tab derselben Sitzung, 20 s: keine zweite Benachrichtigung.
|
||||||
|
- Faellig-Zustand dunkel gut lesbar (Rahmen + Abzeichen „Fällig“, Erledigt / Später erinnern).
|
||||||
|
- Später erinnern: Menue In 10 Minuten / In 1 Stunde / Morgen um 14:14; „In 10 Minuten“ -> 14:26. Bearbeiten (Titel) ok. Löschen mit Rueckfrage Ja/Nein ok.
|
||||||
|
- „Wasser trinken“ (ohne E-Mail) faellig -> Erledigt -> verschwindet; keine zweite Mail.
|
||||||
|
- Aufgeraeumt: Reminder-Tabelle leer, MailHog-Container entfernt.
|
||||||
|
- Offen: Windows-Toast der Desktop-App (braucht CI-Paket nach Push), Erlaubnisabfrage-erst-nach-Speichern nur per Komponententest belegt (Playwright-Kontext hatte die Erlaubnis vorab).
|
||||||
+127
@@ -0,0 +1,127 @@
|
|||||||
|
---
|
||||||
|
phase: quick-260929-if2
|
||||||
|
verified: 2026-09-29T12:20:00Z
|
||||||
|
status: human_needed
|
||||||
|
score: 8/9 must-haves verified
|
||||||
|
behavior_unverified: 1
|
||||||
|
overrides_applied: 0
|
||||||
|
behavior_unverified_items:
|
||||||
|
- truth: "At the due time the desktop app shows a native OS notification, also while the main window is hidden in the tray, through the notification plugin, which the page may call only from the stored server origin"
|
||||||
|
test: "Windows VM with a desktop client built from commit 6879c75 (or CI package): create a reminder due in 3 minutes, close the window with X, wait; then open the window"
|
||||||
|
expected: "A Windows toast 'Erinnerung: ...' appears within about 1 minute of the due time; the reminder shows 'Faellig' afterwards. Also: after 'Server-Adresse aendern...' to the same server the toast still works"
|
||||||
|
why_human: "Unit tests only pin the URL pattern (escape, self-check, match/no-match) and the exact 3-permission set. Whether Tauri accepts the runtime capability (remote + notification:allow-* identifiers) and delivers the toast cannot be seen by grep or cargo test; add_capability panics rather than returning Err on a bad pattern/identifier"
|
||||||
|
human_verification:
|
||||||
|
- test: "Browser (Playwright MCP, dark): create a reminder due in 2 min, switch to another portal page, wait past due time, spy on window.Notification"
|
||||||
|
expected: "Exactly one Notification call titled 'Erinnerung: ...' although the dashboard is not visible; on return the row is highlighted with 'Faellig', 'Erledigt' and 'Spaeter erinnern'; a second tab of the same session gives no second notification"
|
||||||
|
why_human: "Real Notification permission flow and multi-tab Web Locks/localStorage dedup need a live browser (orchestrator runs these)"
|
||||||
|
- test: "Browser, fresh context without notification grant"
|
||||||
|
expected: "Permission prompt appears only after clicking 'Speichern' on the first reminder, never on page load"
|
||||||
|
why_human: "Browser permission UI"
|
||||||
|
- test: "Dark-mode look of the 'Faellig' badge (bg-status-warn/20 text-status-warn-fg, deviation 3) and highlighted row"
|
||||||
|
expected: "Readable contrast"
|
||||||
|
why_human: "Visual"
|
||||||
|
- test: "E-mail flow with a reachable SMTP (start mailhog or real SMTP), reminder with the e-mail tick due, then snooze +10 min"
|
||||||
|
expected: "Exactly one mail with time in Europe/Berlin; after the snooze a second one; e-mail checkbox greyed out with explanation when SMTP is removed"
|
||||||
|
why_human: "Real transport; locally the mailhog container is not running (summary: 3 failed attempts then stop, as designed)"
|
||||||
|
- test: "Windows VM: desktop app and browser open simultaneously"
|
||||||
|
expected: "Each shows the notification exactly once"
|
||||||
|
why_human: "Needs Windows GUI"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260929-if2: Reminder widget "Erinnerungen" Verification Report
|
||||||
|
|
||||||
|
**Phase Goal:** Reminder widget with notification in desktop app, browser and optionally by e-mail; one-time, no advance warning, after due "Erledigt"/"Spaeter erinnern" (10 min / 1 h / tomorrow); personal with RLS; e-mail exactly once per due occurrence.
|
||||||
|
**Verified:** 2026-09-29
|
||||||
|
**Status:** human_needed
|
||||||
|
**Re-verification:** No, initial verification
|
||||||
|
|
||||||
|
Note on commits: verified against the re-created commits 325c5dd, 709b41a, 6879c75 (HEAD, three commits above cd1f8f6; nothing pushed, `git log origin/main..HEAD` shows exactly these). No source files were modified by this verification. The only untracked path is the task directory. During verification an unrelated test reminder ("Kaffee holen") appeared in the live DB, presumably from the orchestrator's browser check; it was not touched.
|
||||||
|
|
||||||
|
## Goal Achievement
|
||||||
|
|
||||||
|
### Observable Truths
|
||||||
|
|
||||||
|
| # | Truth | Status | Evidence |
|
||||||
|
|---|-------|--------|----------|
|
||||||
|
| 1 | User adds widget, creates reminder (date/time/title/description, local time), sees only own open reminders sorted by due time (D-05) | VERIFIED | `reminder-widget.tsx` sorts by dueAt, lists via `listReminders`; `reminders.service.ts list()` uses `forTenant(prisma, tenantId, userId)` + `where {tenantId,userId}`, `orderBy dueAt asc`; registered in registry, `page.tsx` (`registerWidget('reminder', ReminderWidget)`), `WIDGET_TYPES` in shared; widget test green; live GET as testuser returns 200 array |
|
||||||
|
| 2 | At due time an open tab shows a Web Notification once granted; permission asked only from widget at first creation, never on page load (D-04) | VERIFIED (unit-level; live browser in human items) | `reminder-notify.ts`: `requestBrowserPermissionOnce` (flag in localStorage, no-op in Tauri/when not 'default'); called first in submit handler only when `!reminder` (create); `ReminderNotifier` mounted in `app-shell.tsx:48`; `remindersToNotify` only `dueAt <= now` (D-02) within 24 h; widget/notifier/notify tests green (410 web tests in scope, 1069 full) |
|
||||||
|
| 3 | Desktop app shows native OS notification also while window hidden in tray, via plugin, callable only from stored server origin | PRESENT_BEHAVIOR_UNVERIFIED | Code present and wired: `showReminderNotification` invokes `plugin:notification\|notify` with `{options:{title,body}}`; `grant_server_notifications` called in `setup()` before first `navigate` and in `save_server_url`; `server_origin_pattern` escapes host, self-checks with `RemoteUrlPattern`; `SERVER_NOTIFICATION_PERMISSIONS` pinned to 3 ids; capability `.local(false).window("main")`; plugin registered (`lib.rs:867`). cargo: 57 passed, 12 `server_origin_*`; fmt ok. `add_capability` in tauri 2.11.3 appends (checked source), so repeated calls do not overwrite. Actual toast delivery / runtime acceptance is not exercised by any test, so routed to human (Windows) |
|
||||||
|
| 4 | Every client shows each (id, dueAt) at most once: tabs share local claim, desktop and browser each notify once | VERIFIED (unit-level) | `claimNotification` (localStorage record, key `${id}\|${dueAt}`, 7-day prune) under `withNotifyLock` (Web Locks); key changes after snooze; notifier test: one notification across several ticks, again after dueAt change. Separate webview storage means desktop and browser each notify once by design. Multi-tab live check in human items |
|
||||||
|
| 5 | Due reminder stays highlighted with "Erledigt" (removes) and "Spaeter erinnern" (+10 min, +1 h, tomorrow same time); snooze sets new dueAt so notifications and e-mail fire again (D-01, D-03) | VERIFIED | Widget: due rows (`dueAt <= now`, 10 s tick) get `border-status-warn`, badge, Erledigt (`deleteReminder`), snooze options via `snoozeTarget`; `reminder-time.ts snoozeTarget` (now+10m, now+1h, original time + calendar days until future); service `snooze` writes `{dueAt, emailSentAt: null, emailAttempts: 0}`; no recurrence field/UI anywhere in schema/DTO; time/widget/service tests green |
|
||||||
|
| 6 | Upcoming reminders editable/deletable; editing a due one is 409; snoozing a not-due one is 409 | VERIFIED | `service.update` 409 when `dueAt <= now`; `snooze` 409 when `dueAt > now`; controller has PATCH/POST snooze/DELETE below static `email-status`; route-order spec in controller spec (14 tests green); widget shows edit/delete only on non-due rows |
|
||||||
|
| 7 | With e-mail on, server sends exactly one mail per due occurrence via tenant SMTP (Europe/Berlin), no client needed, several instances safe (atomic claim); toggle disabled with explanation when SMTP missing / no e-mail | VERIFIED (unit-level; real transport in human items) | `reminder-mail.scheduler.ts`: single `forSystem` call, scalar select, candidate filter (emailEnabled, emailSentAt null, attempts<3, due within 24 h), claim `updateMany` with `dueAt` equality and `emailSentAt: null`, count===1 check before send, release only on transport failure with own timestamp, skip keeps claim, `running` reentrancy guard, 30 s `addInterval`. `MailService.sendReminderEmail`: CR/LF stripped subject, `Europe/Berlin` `de-DE` + " Uhr", text only, returns bool. Scheduler spec: 16 tests incl. two instances one mail, 3-attempt cap, snooze re-arm. Toggle: `emailAvailable`/`emailHint` in form modal; email-status endpoint live: `{"smtpConfigured":true,"hasEmail":true}`. Summary reports a live DB run with 3 attempts then stop |
|
||||||
|
| 8 | Foreign reminder id always 404 (never 403); Reminder has tenant+user RLS policy and system read policy; rls-coverage and rls-access-inventory green; classification doc re-measured | VERIFIED | `loadOwn` throws `NotFoundException` for unknown/foreign; live DELETE on unknown id returns 404; DB: `relrowsecurity` and `relforcerowsecurity` both true; policies `tenant_isolation_policy` (ALL, tenant AND user dim) and `system_read_policy` (SELECT, `is_system_context()`); `migrate diff --exit-code` "No difference detected"; rls-coverage and rls-access-inventory pass; `FORSYSTEM_ALLOWED_CALL_SITES` has `reminder-mail.scheduler.ts, 1`; doc updated in all three commits (numbers not independently re-counted) |
|
||||||
|
| 9 | All API/web tests green, type-check and lint green, Biome warnings web <= 55 and api <= 82, cargo test/fmt/clippy green | VERIFIED (clippy not re-run) | API full: 91 files / 1570 tests pass; web full: 108 files / 1069 tests pass; `turbo run type-check lint --force`: 9/9 successful; Biome web 55, api 82 (exactly baseline); cargo test 57 pass, fmt ok. Clippy `-D warnings` not re-run by me (summary claims clean) |
|
||||||
|
|
||||||
|
**Score:** 8/9 truths verified (1 present, behavior-unverified)
|
||||||
|
|
||||||
|
### Required Artifacts
|
||||||
|
|
||||||
|
| Artifact | Status | Details |
|
||||||
|
|----------|--------|---------|
|
||||||
|
| `apps/api/prisma/migrations/20260929140000_reminder/migration.sql` | VERIFIED | Table, indexes, FK cascade, ENABLE+FORCE RLS, both policies; applied locally, no schema drift |
|
||||||
|
| `apps/api/src/reminders/reminders.service.ts` / `.controller.ts` | VERIFIED | Owner-scoped CRUD/snooze/email-status, all through `forTenant(..., tenantId, userId)`; `REMINDER_SELECT` excludes tenantId/userId/emailSentAt/emailAttempts |
|
||||||
|
| `apps/api/src/reminders/reminder-mail.scheduler.ts` | VERIFIED | Registered in `RemindersModule` providers; module registered in `app.module.ts` |
|
||||||
|
| `apps/web/src/lib/reminder-notify.ts` | VERIFIED | Tauri vs browser branch, one-time permission, dedup with Web Locks |
|
||||||
|
| `apps/web/src/components/reminders/reminder-notifier.tsx` | VERIFIED | Mounted in `app-shell.tsx` |
|
||||||
|
| `apps/web/src/components/dashboard/widgets/reminder-widget.tsx` (+ form modal) | VERIFIED | Wired via registry, `page.tsx`, `widget-wrapper` FRAME_HEADER_TYPES, icon |
|
||||||
|
| `apps/desktop/src-tauri/src/lib.rs` | VERIFIED (code), see truth 3 | `server_origin_pattern`, `grant_server_notifications`, 12 tests |
|
||||||
|
|
||||||
|
### Key Link Verification
|
||||||
|
|
||||||
|
| From | To | Status | Details |
|
||||||
|
|------|----|--------|---------|
|
||||||
|
| `app-shell.tsx` | `ReminderNotifier` | WIRED | line 48 |
|
||||||
|
| `reminder-notify.ts` | plugin notification | WIRED | `invoke('plugin:notification\|notify', { options })` |
|
||||||
|
| `lib.rs` | Tauri runtime authority | WIRED | `add_capability` in `grant_server_notifications`, called in `setup()` and `save_server_url` |
|
||||||
|
| scheduler | `MailService.sendReminderEmail` | WIRED | claim then send, release on false |
|
||||||
|
| `snooze` | `emailSentAt`/`emailAttempts` reset | WIRED | `data: { dueAt, emailSentAt: null, emailAttempts: 0 }` |
|
||||||
|
|
||||||
|
### Data-Flow Trace (Level 4)
|
||||||
|
|
||||||
|
Widget and notifier data come from `listReminders()` -> `GET /reminders` -> Prisma query (live check returned real rows). FLOWING. No hardcoded/static fallbacks.
|
||||||
|
|
||||||
|
### Behavioral Spot-Checks
|
||||||
|
|
||||||
|
| Behavior | Command | Result | Status |
|
||||||
|
|----------|---------|--------|--------|
|
||||||
|
| API reminders/mail/prisma/dashboard specs | `vitest run src/reminders src/mail src/prisma src/dashboard` | 17 files, 267 passed | PASS |
|
||||||
|
| Web reminder specs | `vitest run src/lib/reminder src/components/reminders src/components/dashboard src/messages` | 30 files, 410 passed | PASS |
|
||||||
|
| Full suites | api / web `vitest run` | 1570 / 1069 passed | PASS |
|
||||||
|
| cargo | `cargo test --lib`; `server_origin_` filter | 57 passed; 12 passed | PASS |
|
||||||
|
| Schema drift | `prisma migrate diff --exit-code` | exit 0 | PASS |
|
||||||
|
| RLS in DB | `pg_policies`, `pg_class` | both policies present, RLS+FORCE on | PASS |
|
||||||
|
| Live API | login, GET /reminders, GET /reminders/email-status, DELETE unknown id | 200, 200, 200, 404 | PASS |
|
||||||
|
| Type-check + lint, Biome | `turbo run type-check lint --force`; biome lint | 9/9; 55 / 82 warnings | PASS |
|
||||||
|
|
||||||
|
### Probe Execution
|
||||||
|
|
||||||
|
No probes declared. SKIPPED.
|
||||||
|
|
||||||
|
### Requirements Coverage
|
||||||
|
|
||||||
|
QUICK-260929-if2 (all decisions D-01..D-05, E-01..E-09) implemented as described; no REQUIREMENTS.md mapping.
|
||||||
|
|
||||||
|
### Anti-Patterns Found
|
||||||
|
|
||||||
|
None. No TBD/FIXME/XXX/TODO in the new files; no stubs; no debt markers. Working tree clean apart from the task directory.
|
||||||
|
|
||||||
|
### Notes / minor observations (non-blocking)
|
||||||
|
|
||||||
|
- Deviation 3 (badge uses `bg-status-warn/20` instead of the plan's solid fill) is documented and justified by contrast; flagged for a dark-mode visual check.
|
||||||
|
- Summary deviation 6 mentions the trailer as "Claude Sonnet 5.5"; the re-created commits carry "Claude Opus 5.5 (1M context)". Immaterial to the goal.
|
||||||
|
- `claimNotification` claims before showing: a browser whose permission is still 'default' at due time loses that occurrence's notification (still visible in the widget, highlighted). Consistent with the plan ("blocked notifications only show in the widget").
|
||||||
|
- Fingerprint fields (`covered_files`/`covered_digest`) were not generated because the fingerprint verb was not run in this environment.
|
||||||
|
|
||||||
|
## Human Verification Required
|
||||||
|
|
||||||
|
See frontmatter `human_verification` and `behavior_unverified_items`. Summary: (1) Windows toast in the tray, runtime capability accepted by Tauri, plus desktop+browser once-each; (2) live browser notification, permission prompt timing and two-tab dedup; (3) dark-mode look of the "Faellig" badge; (4) real SMTP flow (mailhog is not running locally).
|
||||||
|
|
||||||
|
## Gaps Summary
|
||||||
|
|
||||||
|
No gaps. All code-verifiable must-haves hold in the codebase and the automated gates reproduce the summary's numbers (tests, type-check, lint, Biome baseline, migrate diff, RLS state in the live DB). The single behavior-dependent truth that cannot be proven without a GUI is the actual desktop toast through the runtime Tauri capability; it is left PRESENT_BEHAVIOR_UNVERIFIED and routed to the Windows check, so the status is `human_needed`, not `passed`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Verified: 2026-09-29_
|
||||||
|
_Verifier: Claude (gsd-verifier)_
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
quick_id: 260929-lh3
|
||||||
|
type: quick
|
||||||
|
wave: 1
|
||||||
|
autonomous: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260929-lh3: Favoriten — eigene Symbol-Adresse wirkt nicht
|
||||||
|
|
||||||
|
## User reports (29.09.2026, alpha 8c644de)
|
||||||
|
|
||||||
|
1. Favorite with URL https://docuvita.ctl.local/server/services/web/ shows a black circle with a white "V"
|
||||||
|
instead of the page's favicon (visible in the browser tab).
|
||||||
|
2. Setting an explicit icon URL ("Symbol-Adresse", field `iconUrl`) to
|
||||||
|
https://nextcloud.com/c/uploads/2025/10/Nextcloud_01-standard-logo.png on a favorite does not change the shown icon.
|
||||||
|
|
||||||
|
## Measured facts (orchestrator, from inside the alpha api container)
|
||||||
|
|
||||||
|
- docuvita.ctl.local resolves (172.16.0.46). Server-side GET of the page returns **400** but the HTML contains
|
||||||
|
`<link rel="SHORTCUT ICON" type="image/png" href="/webclient/docuvita/resources/brandimage/favicon.ico" />`.
|
||||||
|
Server-side GET of that icon returns **404 text/html** (also with a Chrome User-Agent). Root /favicon.ico → 404.
|
||||||
|
→ docuvita refuses the files to the server; the browser can load them (user sees the icon in the tab).
|
||||||
|
- Discovery (`apps/api/src/favorites/icon-discovery.service.ts` `discoverFavoriteIconUrl`) ignores non-2xx HTML
|
||||||
|
(`fetchHtml` returns null) → falls back to `{origin}/favicon.ico`; proxy fails → browser direct
|
||||||
|
`{origin}/favicon.ico` shows the "V" (a real icon served to browsers at the root).
|
||||||
|
- Explicit `iconUrl` that the server cannot fetch → API answers 422 `iconUrlUnreachable` (quick 260923-lrr),
|
||||||
|
so the user cannot set the docuvita icon URL at all.
|
||||||
|
|
||||||
|
## Task 1: Explicit icon URL change must show immediately (bug 2)
|
||||||
|
|
||||||
|
- files: apps/api/src/favorites/*, apps/web/src/components/dashboard/widgets/favorites-widget.tsx, apps/web/src/lib/favorites-api.ts (+ tests)
|
||||||
|
- action: Reproduce locally (admin/admin123, favorites widget): set/change `iconUrl` to a reachable PNG (e.g. the Nextcloud URL,
|
||||||
|
and a second different one). Find why the tile keeps the old image — likely the icon proxy URL
|
||||||
|
(`/favorites/:id/icon?...`) does not change when `iconUrl` changes (browser/HTTP cache, Cache-Control on the proxy
|
||||||
|
response, `iconVersion` only bumped on upload, or the server returns a cached/discovered icon instead of the explicit one).
|
||||||
|
Fix at the root: the explicit `iconUrl` wins over discovery, and any change of `iconUrl` changes the image URL
|
||||||
|
(e.g. cache-buster from `iconVersion` bumped on every iconUrl change, or a hash of iconUrl). Add regression tests
|
||||||
|
(API: PATCH iconUrl bumps version / proxy serves new bytes; web: tile src changes when iconUrl changes).
|
||||||
|
- verify: api + web tests for favorites green.
|
||||||
|
- done: commit `fix(favorites): geaenderte Symbol-Adresse wird sofort angezeigt`.
|
||||||
|
|
||||||
|
## Task 2: Accept icon URLs the server cannot fetch; browser loads them directly (bug 1)
|
||||||
|
|
||||||
|
- action:
|
||||||
|
- API: an explicit `iconUrl` that is a valid http/https URL is stored even if the server cannot fetch it
|
||||||
|
(no more 422 for "unreachable"; keep validation of scheme/length and keep rejecting non-image responses only
|
||||||
|
when the server DID get a response with a non-image content type — decide and document). Keep SSRF guard for
|
||||||
|
server-side fetches unchanged.
|
||||||
|
- Web tile: chain for an explicit `iconUrl`: proxy image → on error the browser loads `iconUrl` directly
|
||||||
|
(`<img>` with referrerPolicy="no-referrer", only http/https) → letter fallback. Existing chain for discovered icons unchanged.
|
||||||
|
- Discovery improvement (small, safe): if the page answers non-2xx but returns HTML with a `<link rel=icon>`,
|
||||||
|
still use that icon URL (so docuvita-like servers yield `/webclient/.../favicon.ico`, which the browser can then load directly).
|
||||||
|
- Remove/adjust the now-unused `iconUrlUnreachable` error text (de/en) if no longer reachable.
|
||||||
|
- verify: api + web favorites tests green; type-check/lint green; biome web ≤ 55, api ≤ 82.
|
||||||
|
- done: commit `fix(favorites): Symbol-Adresse auch speichern, wenn nur der Browser sie laden kann`.
|
||||||
|
|
||||||
|
## Task 3: CHANGELOG + rebuild
|
||||||
|
|
||||||
|
- CHANGELOG `## Unveröffentlicht` → `### Behoben`: two plain-German bullets (Sie-Form) for both fixes.
|
||||||
|
- `docker compose up -d --build web api`.
|
||||||
|
- Commit `docs(changelog): Favoriten-Symbole`.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Commit locally only, NEVER git push. Commits end with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`.
|
||||||
|
- Commit with explicit paths only.
|
||||||
|
- Browser check is done by the orchestrator.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
quick_id: 260929-lh3
|
||||||
|
phase: quick
|
||||||
|
plan: 260929-lh3
|
||||||
|
subsystem: favorites
|
||||||
|
tags: [favorites, icons, icon-discovery, browser-fallback]
|
||||||
|
status: complete
|
||||||
|
commits: 3
|
||||||
|
plan_head_before: 8c644de5dad56a0394a14a00180a125919565246
|
||||||
|
plan_head_after: 0e72ad45f8833cb93ae9a8c0afa1f6d4e948e3e0
|
||||||
|
actuals:
|
||||||
|
tasks: 3
|
||||||
|
commits: 3
|
||||||
|
key-files:
|
||||||
|
modified:
|
||||||
|
- apps/api/src/favorites/favorites.service.ts
|
||||||
|
- apps/api/src/favorites/icon-discovery.service.ts
|
||||||
|
- apps/api/src/favorites/dto/create-favorite.dto.ts
|
||||||
|
- apps/api/src/favorites/dto/update-favorite.dto.ts
|
||||||
|
- apps/web/src/components/dashboard/widgets/favorites-widget.tsx
|
||||||
|
- apps/web/src/lib/favorites-api.ts
|
||||||
|
- apps/web/src/messages/de.json
|
||||||
|
- apps/web/src/messages/en.json
|
||||||
|
- CHANGELOG.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# Quick 260929-lh3: Favoriten-Symbol-Adresse Summary
|
||||||
|
|
||||||
|
Explicit icon URLs are now stored even when the server cannot fetch them, the tile loads them directly in the browser, discovery uses `<link rel=icon>` from non-2xx HTML pages, and a newly entered icon URL replaces a previously uploaded icon.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
- 7188c5b `fix(favorites): geaenderte Symbol-Adresse wird sofort angezeigt` (Task 1)
|
||||||
|
- b15c746 `fix(favorites): Symbol-Adresse auch speichern, wenn nur der Browser sie laden kann` (Task 2)
|
||||||
|
- 0e72ad4 `docs(changelog): Favoriten-Symbole` (Task 3)
|
||||||
|
|
||||||
|
## Root cause, bug 2 ("Symbol-Adresse wirkt nicht")
|
||||||
|
|
||||||
|
Measured, not assumed:
|
||||||
|
|
||||||
|
- Local reproduction (API with curl, and the real widget in headless Chromium via CDP): changing `iconUrl` on a favorite WITHOUT an uploaded icon works. `iconVersion` is bumped, the tile is remounted, the `<img>` src changes (`?v=1` -> `?v=2`), and the bytes change (naturalWidth 626 -> 48). The proxy, `Cache-Control` and Next rewrite are not the cause.
|
||||||
|
- The alpha database (read-only psql) shows the save path works there too: favorite "Medon" holds the Nextcloud URL with `iconVersion = 1`. No favorite on alpha has `uploadedIconMime` set, and the alpha web image contains the `?v=` code.
|
||||||
|
- The real defect found in the code: `getIconBytes` always serves the uploaded file when `uploadedIconMime` is set, and `update()` never cleared it. A newly entered `iconUrl` was therefore saved but invisible for any favorite with an uploaded icon (the form even said "Ein hochgeladenes Symbol hat Vorrang"). Fixed: a new, different, non-empty `iconUrl` now clears `uploadedIconMime`, removes the file, and bumps `iconVersion`. An unchanged `iconUrl` (the form resends it on every save) leaves the upload alone.
|
||||||
|
- Caveat for the orchestrator: for the exact alpha "Medon" case (no upload) I could not reproduce a stale display; the alpha row and local browser behavior are both correct. The browser check on alpha (https, NPM, basic auth) remains the only place this can still show. If it still fails there with the new build, capture the network request of `/api-proxy/favorites/<id>/icon?v=1` in the alpha browser.
|
||||||
|
|
||||||
|
## Root cause, bug 1 (black circle with "V")
|
||||||
|
|
||||||
|
`fetchHtml` dropped every non-2xx response, so docuvita (answers the server with 400 but ships `<link rel="SHORTCUT ICON" href="/webclient/.../favicon.ico">`) fell back to `{origin}/favicon.ico`; the proxy failed and the browser showed the root favicon (the "V"). And an explicit `iconUrl` was rejected by the 422 fetch probe because docuvita returns 404 HTML to the server.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- API: `assertIconUrlLoadable` (422) replaced by `assertIconUrlWellFormed` (http/https, <= 2048 chars, else 400); DTO `@MaxLength(2048)` on both DTOs. Decision, documented in code: even a response the server DID receive with a non-image type does not reject, because "server gets no image" does not mean "browser gets none". SSRF guard for server-side fetches is unchanged.
|
||||||
|
- Discovery: `fetchWithRedirectGuard` got `allowErrorStatus` (used only by the HTML search; `fetchIconBytes` stays strict). On a non-2xx page only `<link rel=...icon>` counts, not `og:image`.
|
||||||
|
- Web tile: proxy -> `iconUrl` direct (`referrerPolicy="no-referrer"`, http/https only) -> `{origin}/favicon.ico` (skipped when identical) -> letter. Existing chain for discovered icons behaves as before.
|
||||||
|
- Removed the `iconUrlUnreachable` reason, 422 handling and de/en texts; adjusted the upload hint (a newly entered address replaces an upload).
|
||||||
|
- CHANGELOG: two plain-German bullets under Unveröffentlicht / Behoben.
|
||||||
|
- Rebuilt `web` and `api` (`docker compose up -d --build`, healthy). Smoke test against the rebuilt API: URL the server cannot fetch is saved (proxy answers 502, tile falls back to browser), `javascript:` is rejected with 400. Test favorite deleted afterwards.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- API favorites specs: 99 tests green. Web favorites widget, favorites-api and messages tests: 47 green. `tsc --noEmit` clean for web and api.
|
||||||
|
- Biome: web 55 warnings, api 82 warnings (at the limits, not above).
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
- [Rule 2 - Missing validation] Plan said "keep validation of scheme/length", but none existed for `iconUrl`; added form validation (service + DTO length) so the SSRF-relevant direct browser load never receives non-http(s) schemes.
|
||||||
|
- Task 1 needed no web change: the existing test "Speichern mit neuer Logo-Adresse ... ?v=1" already covers the src change; API regression tests were added.
|
||||||
|
- Commits were made on `main` as instructed (no worktree).
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
Commits 7188c5b, b15c746, 0e72ad4 exist; SUMMARY location correct; PLAN/SUMMARY/STATE not committed.
|
||||||
|
|
||||||
|
## Browser-Pruefung (Orchestrator, 29.09.)
|
||||||
|
|
||||||
|
- Favorit „Claude“: iconUrl -> Nextcloud-PNG (PATCH 200), nach Neuladen Proxy-Bild ?v=5 mit 626 px Breite geladen.
|
||||||
|
- iconUrl -> docuvita-Brand-Icon (vom Dev-Host nicht aufloesbar): PATCH 200 (frueher 422), Kachel faellt sauber zurueck. Im Firmennetz laedt der Browser direkt.
|
||||||
|
- Zurueckgesetzt auf das urspruengliche Symbol.
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
quick_id: 261001-cxo
|
||||||
|
description: "Desktop-Client: Links mit target=_blank oeffnen"
|
||||||
|
date: 2026-10-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Desktop-Client: Links mit target=_blank oeffnen
|
||||||
|
|
||||||
|
**Befund (VM 8233, Client 1.9.0 gegen alpha):** Klick auf Favorit (`<a target="_blank">`) tut nichts; Such-Widget (`window.open`) oeffnet Edge ueber `on_new_window` (lib.rs). Der Rust-Weg funktioniert also, nur der Link-Klick erreicht ihn nicht.
|
||||||
|
|
||||||
|
## Task 1 — DesktopExternalLinks
|
||||||
|
- `apps/web/src/components/desktop/desktop-external-links.tsx`: im Desktop-Client (Cookie `tessera_desktop`) Links-/Mittelklick auf `a[href][target=_blank]` mit http/https per `window.open(href,'_blank','noopener,noreferrer')` oeffnen, `preventDefault`. Listener auf `window` (Bubble, nach React) -> von der Seite verhinderte Klicks (Favoriten im Bearbeiten-Modus) bleiben verhindert.
|
||||||
|
- In `apps/web/src/app/layout.tsx` neben `DesktopContextMenuGuard` einhaengen.
|
||||||
|
- Test `desktop-external-links.test.tsx`.
|
||||||
|
- CHANGELOG „Unveröffentlicht → Behoben“.
|
||||||
|
|
||||||
|
**Verify:** vitest gruen, tsc, biome; nach alpha-Pull auf VM 8233: Favorit oeffnet Edge.
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
quick_id: 261001-cxo
|
||||||
|
status: complete
|
||||||
|
date: 2026-10-01
|
||||||
|
commit: 61a971c
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary: Desktop-Client – Links mit target=_blank
|
||||||
|
|
||||||
|
- Nachgestellt auf VM 8233 (Client 1.9.0, alpha): Favorit-Klick ohne Wirkung, Such-Widget (`window.open`) oeffnet Edge.
|
||||||
|
- Neu `DesktopExternalLinks` (apps/web/src/components/desktop/desktop-external-links.tsx), in `app/layout.tsx` eingehaengt: im Client Links-/Mittelklick auf `a[target=_blank]` mit http/https -> `window.open(href,'_blank','noopener,noreferrer')`; verhinderte Klicks bleiben verhindert.
|
||||||
|
- 4 Tests (desktop-external-links.test.tsx), tsc + biome sauber. CHANGELOG „Unveröffentlicht → Behoben“.
|
||||||
|
- Reine Web-Aenderung: kein neuer Client noetig, wirkt nach Pull des web-Images.
|
||||||
|
- Offen: Nachweis auf VM nach alpha-Pull (User).
|
||||||
|
|
||||||
|
## Nachtrag (gleicher Tag): erste Fassung wirkte nicht
|
||||||
|
- Nach alpha-Pull weiter ohne Wirkung. Diagnose per temporaerem Klick-Protokoll (lokaler Stack, VM-Client per portproxy auf localhost:3000): `preventDefault` kam aus `<anonymous>:1:442` = Link-Skript von tauri-plugin-opener (init-iife.js, Listener auf `window`): faengt `target=_blank`-Klicks ab und ruft `plugin:opener|open_url` – von der Server-Seite nicht freigegeben, Klick verpufft. Unser Listener auf `window` lief danach und sah den Klick als verhindert.
|
||||||
|
- Fix: Listener auf `document` (Bubble) – nach React (Wurzel document), vor dem Opener-Skript. Auf VM nachgewiesen: Favorit oeffnet Edge, im Bearbeiten-Modus nichts (React-onClick verhindert).
|
||||||
|
- Commit siehe git log; Test „kommt dem Link-Skript des Clients auf window zuvor“.
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
quick_id: 261001-g68
|
||||||
|
description: "Erinnerung: Cursor springt aus Beschreibung in Titel"
|
||||||
|
date: 2026-10-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Erinnerung: Cursor springt aus Beschreibung in Titel
|
||||||
|
|
||||||
|
**Befund:** `ReminderFormModal` setzte den Fokus auf den Titel im selben Effekt wie den Escape-Listener, Abhaengigkeit `[onClose]`. `onClose` ist in der Kachel eine Inline-Funktion; die Kachel zeichnet alle 10 s neu (NOW_TICK_MS) und bei jedem Neuladen → Effekt laeuft erneut → Cursor springt in den Titel (User: beim Schreiben, und bei Loeschen-Taste in leerer Beschreibung).
|
||||||
|
|
||||||
|
## Task 1
|
||||||
|
- Fokus-Effekt nur beim Oeffnen (`[]`), Escape-Listener ueber `onCloseRef`.
|
||||||
|
- Test im Widget: Formular oeffnen, Beschreibung fokussieren, 30 s Takt → Fokus bleibt.
|
||||||
|
- CHANGELOG „Unveröffentlicht → Behoben“.
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
quick_id: 261001-g68
|
||||||
|
status: complete
|
||||||
|
date: 2026-10-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
- `reminder-form-modal.tsx`: Fokus auf Titel nur einmal beim Oeffnen; Escape ueber Ref statt `[onClose]`-Abhaengigkeit.
|
||||||
|
- Neuer Test in `reminder-widget.test.tsx` – schlaegt ohne Fix fehl, mit Fix gruen; 28/28 Erinnerungs-Tests, tsc, biome sauber.
|
||||||
|
- Andere Dialoge mit `[onClose]`-Fokus (Widget-Katalog, Bilderrahmen-Lightbox) fokussieren nur den Dialog ohne Eingabefelder – nicht betroffen.
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
quick_id: 261001-hbi
|
||||||
|
description: "Favoriten: Logo fuer per JavaScript gesetzte Symbole"
|
||||||
|
date: 2026-10-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Favoriten: Logo fuer per JavaScript gesetzte Symbole
|
||||||
|
|
||||||
|
**Befund:** https://www.hosteurope.de/ liefert im HTML nur `<link rel="icon" href="data:;base64,=">`; das echte Symbol (img1.wsimg.com/.../HostEurope.png) setzt erst JavaScript. `/favicon.ico`, `/apple-touch-icon.png`, `/favicon.svg` antworten 200 mit text/html. Die serverseitige Suche faellt auf `/favicon.ico` zurueck, der Abruf scheitert (kein Bild) → Buchstabe.
|
||||||
|
|
||||||
|
## Task 1
|
||||||
|
- `IconDiscoveryService.fetchPublicServiceIconBytes(pageUrl)`: DuckDuckGo-Symboldienst (`icons.duckduckgo.com/ip3/<host>.ico`), NUR wenn die Seite oeffentlich ist (isPublicHttpUrl) — interne Hostnamen verlassen das Haus nicht; 404 fuer Unbekanntes → wirft → Buchstabe bleibt.
|
||||||
|
- `FavoritesService.getIconBytes`: scheitert das gespeicherte Symbol, einmal den Dienst fragen, sonst 502 wie bisher. Repariert auch bestehende Favoriten ohne Neuanlage.
|
||||||
|
- Tests in beiden Specs; CHANGELOG.
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
quick_id: 261001-hbi
|
||||||
|
status: complete
|
||||||
|
date: 2026-10-01
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
- Rueckfall auf den oeffentlichen Symbol-Dienst beim Ausliefern (`getIconBytes`), nur fuer oeffentliche Seiten.
|
||||||
|
- 5 neue Tests (Dienst-URL, interne Seite fragt nicht, 404 wirft, Service nutzt Rueckfall / nicht bei Erfolg); 111/111 Favoriten-Tests, tsc, biome-Stand unveraendert.
|
||||||
|
- Lokal im Browser nachgewiesen: Favorit https://www.hosteurope.de/ zeigt das gruene H-Logo (32x32 ueber /api-proxy/favorites/<id>/icon).
|
||||||
+119
@@ -4,6 +4,125 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
|||||||
|
|
||||||
## Unveröffentlicht
|
## Unveröffentlicht
|
||||||
|
|
||||||
|
## 1.9.1 – 2026-10-01
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Desktop-App: Favoriten und andere Links, die sich in einem neuen Fenster öffnen (etwa „In neuem Tab öffnen“ oder Quellen im Ausschreibungs-Radar), öffnen sich jetzt in Ihrem normalen Browser. Bisher passierte beim Klick in der Desktop-App nichts.
|
||||||
|
- Erinnerungen: Beim Schreiben der Beschreibung springt der Cursor nicht mehr in die Titelzeile zurück. Bisher passierte das alle paar Sekunden, weil sich die Kachel regelmäßig neu aufbaut.
|
||||||
|
- Favoriten: Auch Seiten, die ihr Logo erst beim Laden im Browser setzen (etwa Host Europe), zeigen jetzt ihr Logo statt nur des Anfangsbuchstabens. Findet Tessera auf der Seite selbst kein Logo, fragt es bei öffentlichen Adressen einen Logo-Dienst; interne Adressen werden dabei nie weitergegeben. Das gilt auch für bereits angelegte Favoriten.
|
||||||
|
|
||||||
|
## 1.9.0 – 2026-09-30
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- Verwaltung: Eigene Vorlage für die Willkommensmail unter Administrator → Willkommensmail. Betreff, Überschrift, Einleitung, Abschluss und der Anmeldehinweis (getrennt für Verzeichnis- und lokale Konten) lassen sich anpassen, mit Platzhaltern wie {{vorname}}, {{benutzername}}, {{adresse}} oder {{firma}} (eine Tabelle auf der Seite erklärt sie). Die Seite zeigt eine Live-Vorschau der echten Mail und schickt auf Wunsch eine Testmail an Ihre Adresse; „Auf Standard zurücksetzen“ stellt die mitgelieferten Texte wieder her. Logo, Zugangsdaten und Knöpfe fügt Tessera immer selbst ein.
|
||||||
|
- Benutzerverwaltung: Willkommensmail. Über das Briefsymbol in der Benutzerliste schicken Sie einem Benutzer eine gestaltete Willkommensmail mit Tessera-Logo, Adresse, Benutzername und einem Knopf „Zu Tessera“. Konten aus dem Verzeichnis erhalten den Hinweis auf ihr Windows-Passwort, lokale Konten einen Link „Passwort festlegen“ (7 Tage gültig) – ein Passwort steht nie in der Mail. Die Liste zeigt, wann die Mail zuletzt ging.
|
||||||
|
- Benutzerverwaltung: Neue Spalte „Letzte Anmeldung“.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Benutzerverwaltung: Die Aktionen je Zeile sind jetzt Symbole (Willkommensmail, Details, Bearbeiten, Löschen), damit die Liste ohne seitliches Scrollen passt.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Anmeldung: Wer schon angemeldet ist und die Anmeldeseite aufruft, landet jetzt direkt auf dem Dashboard.
|
||||||
|
- Willkommensmail: Logo und Schriftzug erscheinen jetzt in jedem Mailprogramm. Bisher steckten sie in einem Bild; zeigte Outlook es nicht an, blieb nur ein großer schwarzer Kasten. Die Welle darunter ist nur noch ein schmaler Streifen; zeigt ein Mailprogramm sie nicht an (etwa Outlook im Browser), bleibt keine weiße Lücke mehr.
|
||||||
|
- Anmeldung: Eine geänderte Rolle, eine Deaktivierung oder das Löschen eines Kontos wirkt jetzt sofort. Bisher galt bis zu 30 Tage die Rolle vom Zeitpunkt der Anmeldung weiter – ein herabgestufter Administrator behielt seine Rechte, ein deaktiviertes Konto konnte mit seiner Sitzung weiterarbeiten, und die Benutzerliste ließ sich nach einer Rollenänderung nicht laden.
|
||||||
|
|
||||||
|
## 1.8.0 – 2026-09-30
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- Dashboard: Neues Widget „Erinnerungen“. Sie legen eine Erinnerung mit Datum, Uhrzeit, Titel und Beschreibung an, und Tessera meldet sich genau zur gewählten Zeit: im Browser mit einer Benachrichtigung (der Browser fragt dafür einmal um Erlaubnis, und zwar beim ersten Anlegen), in der Desktop-App mit einer Windows-Benachrichtigung – auch wenn das Fenster im Infobereich liegt. Wenn Sie möchten, schickt Tessera zusätzlich eine E-Mail an Ihre Adresse, auch dann, wenn Tessera gerade nirgends geöffnet ist. Eine fällige Erinnerung bleibt im Widget hervorgehoben stehen, bis Sie „Erledigt“ wählen oder mit „Später erinnern“ verschieben – auf in 10 Minuten, in 1 Stunde oder morgen zur gleichen Uhrzeit; dann meldet sich Tessera (und bei Bedarf die E-Mail) noch einmal. Erinnerungen sind persönlich: nur Sie sehen und ändern Ihre. Für die Desktop-Benachrichtigungen braucht die Desktop-App ihre neue Version, die Sie über „Auf Version … aktualisieren“ im Menü des Tessera-Symbols erhalten; Widget und E-Mail funktionieren auch mit der bisherigen Version.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Eigene Module: Einmal geöffnete Seiten bleiben im Hintergrund offen. Wechseln Sie zurück, ist die Seite sofort da – im selben Zustand, ohne neu zu laden. Tessera hält die fünf zuletzt benutzten offen; beim Abmelden werden sie geschlossen.
|
||||||
|
- Dashboard, Favoriten: In der Kachelansicht stehen die Symbole enger beieinander; der Abstand zwischen ihnen ist etwa halb so groß, in eine Zeile passen mehr Favoriten. Passt ein Name nicht in eine Zeile, wird er kleiner geschrieben und auf zwei Zeilen umbrochen.
|
||||||
|
- Dashboard: Es sieht jetzt auf jedem Bildschirm gleich aus. Tessera merkt sich die Fläche des Bildschirms, an dem Sie ein Dashboard zuerst öffnen, und zeigt es auf anderen Bildschirmen maßstäblich verkleinert oder vergrößert – samt Schrift und ohne Scrollen. Ist ein Dashboard länger als der Bildschirm, wird es so weit verkleinert, dass es ganz hineinpasst. Auf dem Handy bleibt es bei der bisherigen Anordnung untereinander.
|
||||||
|
- Eigene Module: Neue Einträge sind mit der Kategorie „Eigene Module“ vorbelegt.
|
||||||
|
- Dashboard: Der Kalender lässt sich nicht mehr so schmal ziehen, dass seine Überschrift abgeschnitten wird.
|
||||||
|
- Eigene Module: Die Seite füllt jetzt den ganzen Inhaltsbereich. Name und Hinweiszeile darüber sind weggefallen – der Name steht ohnehin oben in der Leiste, und „In neuem Tab öffnen“ sitzt jetzt dort rechts.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Eigene Module: Lässt sich ein Eintrag nicht laden, sagt Tessera das jetzt, statt „nicht gefunden“ zu melden. Fehlende Berechtigung und ungültige Angaben werden beim Speichern und Löschen eigens genannt.
|
||||||
|
- Seitenleiste: Eingeklappt stehen eigene Module jetzt bei ihrer Kategorie, in derselben Reihenfolge wie ausgeklappt.
|
||||||
|
- Erinnerungen: Ohne Browser-Speicher (etwa im privaten Fenster) kam dieselbe Benachrichtigung alle 10 Sekunden – jetzt nur einmal.
|
||||||
|
- Erinnerungen: Nach einer Änderung von Datum oder Uhrzeit kommt die E-Mail zuverlässig zur neuen Zeit. Deaktivierte Benutzer bekommen keine Erinnerungs-E-Mails mehr.
|
||||||
|
- Erinnerungen: „Später erinnern“ zeigt „Heute um …“, wenn die Uhrzeit heute noch kommt, statt fälschlich „Morgen um …“. Speichern ohne Zeitänderung verschiebt die Fälligkeit nicht mehr um Sekunden.
|
||||||
|
- Dashboard: Wird ein Bild gelöscht, das als Hintergrund gewählt war, gilt wieder „kein Hintergrund“.
|
||||||
|
- Dashboard: Ein noch offener Tab mit älterer Tessera-Version kann die Anordnung nicht mehr verziehen; er bittet stattdessen, die Seite neu zu laden.
|
||||||
|
- Desktop-App: Während ein Update installiert wird, bietet das Menü kein zweites mehr an. Nach einer fehlgeschlagenen Update-Prüfung genügt wieder ein Klick zum Installieren.
|
||||||
|
- Desktop-App: „Auf Version … aktualisieren“ im Menü des Tessera-Symbols scheiterte mit „Signaturprüfung fehlgeschlagen“ und öffnete stattdessen die Download-Seite, wenn der Server seit der letzten Update-Prüfung der App eine neuere Version bekommen hatte. Die App fragt jetzt beim Klick zuerst frisch nach und installiert genau die Version, die der Server in diesem Moment anbietet.
|
||||||
|
- Dashboard, Favoriten: Eine neu eingetragene Logo-Adresse wird jetzt sofort angezeigt. Bisher blieb ein früher hochgeladenes eigenes Symbol stehen und verdeckte die neue Adresse; jetzt ersetzt die neue Adresse es.
|
||||||
|
- Dashboard, Favoriten: Eine Logo-Adresse lässt sich jetzt auch speichern, wenn Tessera das Bild selbst nicht laden kann – etwa bei Seiten im internen Netz, die nur Ihrem Browser das Symbol geben. Die Kachel lädt das Bild dann direkt in Ihrem Browser. Auch die automatische Erkennung findet das Symbol solcher Seiten jetzt eher, statt auf ein Ersatzsymbol zurückzufallen.
|
||||||
|
|
||||||
|
## 1.7.0 – 2026-09-29
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- Eigene Module: Als Kategorie steht jetzt auch „Eigene Module“ zur Auswahl. Einträge dort erscheinen gesammelt in einer eigenen Gruppe ganz unten in der Seitenleiste; die Gruppe ist nur zu sehen, solange ein Eintrag darin liegt. Bestehende Einträge verschieben Sie über „Bearbeiten“ dorthin.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Seitenleiste: Ist sie eingeklappt, sind die Symbole etwas größer und stehen etwas enger beieinander.
|
||||||
|
- Dashboard: Im Such-Widget haben die Auswahl der Suchmaschine und das Suchfeld keine helle Linie an der Unterkante mehr.
|
||||||
|
|
||||||
|
## 1.6.0 – 2026-09-29
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- Eigene Module: Jeder Benutzer kann unter „Einstellungen → Eigene Module“ Webseiten, die er oft braucht, als eigene Einträge in seine Seitenleiste aufnehmen – mit Name, Adresse (nur https) und Kategorie, etwa „Infrastruktur“. Diese Einträge sieht nur der Benutzer selbst. Ein Klick zeigt die Seite direkt in Tessera. Manche Seiten verbieten das Einbetten – dafür gibt es immer den Knopf „In neuem Tab öffnen“. Administratoren können zusätzlich unter „Verwaltung → Eigene Module“ Einträge für alle Benutzer anlegen; die sehen dann alle unter der gewählten Kategorie.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Dashboard: Die Widgets bleiben beim Darüberfahren mit der Maus ruhig stehen, sie heben sich nicht mehr an.
|
||||||
|
- Dashboard: Die Widgets stehen in der Ansicht genau dort, wo Sie sie beim Bearbeiten platziert haben. Bisher rückte Tessera sie nach dem Bearbeiten zur Seitenmitte, sodass etwa ein einzelnes Widget oben links plötzlich in die Mitte sprang.
|
||||||
|
- Dashboard: Das Raster ist in der Breite doppelt so fein – Widgets lassen sich in kleineren Schritten breiter oder schmaler ziehen und genauer platzieren. Bestehende Anordnungen bleiben unverändert.
|
||||||
|
- Dashboard: Das Kalender-Widget lässt sich deutlich schmaler ziehen als bisher.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Desktop-App: Tessera startet nicht mehr doppelt. Wird die App ein zweites Mal gestartet – etwa beim Anmelden an Windows –, holt sie nur das vorhandene Fenster nach vorne; im Infobereich erscheint nur noch ein Symbol.
|
||||||
|
- Desktop-App: Die Suche im Such-Widget und Knöpfe wie „In neuem Tab öffnen“ funktionieren jetzt auch in der Desktop-App – die Seite öffnet sich in Ihrem normalen Browser. Bisher passierte dort beim Klick nichts.
|
||||||
|
|
||||||
|
## 1.5.2 – 2026-09-28
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- Dashboard: Den Titel eines Widgets können Sie jetzt ausblenden. Im Bearbeitungsmodus sitzt dafür oben in der Mitte jedes Widgets mit Titel ein kleines „T“; ein Klick blendet den Titel aus, ein zweiter wieder ein. Die Einstellung gilt je Widget und bleibt gespeichert. Im Bearbeitungsmodus sehen Sie den Titel weiterhin, damit Sie ihn ändern können; Knöpfe wie der Stift der Notiz bleiben auch ohne Titel oben rechts erreichbar.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Seitenleiste: Die Kategorien (etwa „Fuhrpark“ oder „Infrastruktur“) sind etwas größer beschriftet, die Module darunter etwas kleiner – so ist die Gliederung auf einen Blick erkennbar.
|
||||||
|
|
||||||
|
## 1.5.1 – 2026-09-28
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Dashboard: Der Knopf „Bearbeiten“ sitzt jetzt als Stift-Symbol rechts in der dunklen Leiste oben und nimmt über den Widgets keinen Platz mehr weg. Im Bearbeitungsmodus erscheinen dort auch „Hintergrund“, „Widget hinzufügen“ und „Fertig“.
|
||||||
|
|
||||||
|
## 1.5.0 – 2026-09-28
|
||||||
|
|
||||||
|
### Neu
|
||||||
|
|
||||||
|
- Nach einem Versionswechsel zeigt Tessera bei Ihrer ersten Anmeldung ein Fenster mit den wichtigsten Änderungen der neuen Version – neue Funktionen, Verbesserungen und behobene Fehler. Haben Sie mehrere Versionen verpasst, erscheinen die drei neuesten. „Verstanden“ schließt das Fenster; es erscheint erst mit der nächsten Version wieder, im Browser wie in der Desktop-App. Die vollständige Liste finden Sie weiterhin unter „Was ist neu“.
|
||||||
|
- Dashboard: Sie können jetzt einen Hintergrund wählen. Im Bearbeitungsmodus öffnet der Knopf „Hintergrund“ eine Auswahl – keiner, ruhige Flächen und Motive in Gelb, Grau und Graphit, oder ein eigenes Bild aus Ihren Bilderrahmen-Bildern bzw. ein neu hochgeladenes. Der Hintergrund gilt nur für Sie und folgt Ihnen auf jedes Gerät, auf dem Sie sich anmelden, auch in die Desktop-App. Eine bisher nur in Ihrem Browser gemerkte Wahl wird dabei automatisch übernommen.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- Neues Aussehen: Tessera hat eine dunkle App-Leiste oben und eine neu gestaltete Seitenleiste. Jedes Modul erscheint dort mit einer eigenen kleinen Kachel, die Kategorien tragen deutsche Namen und sind anfangs aufgeklappt, und unten in der Seitenleiste begrüßt Sie Tessera mit Ihrem Namen und dem heutigen Datum. Die persönliche Akzentfarbe hebt nur noch das Modul hervor, in dem Sie gerade arbeiten. Auch die übrigen Seiten – Marktplatz, Module, Einstellungen und Verwaltung – folgen diesem ruhigeren Stil.
|
||||||
|
- Neue Anmeldeseite: Auf großen Bildschirmen ist sie geteilt, links ein dunkler Bereich mit dem Tessera-Zeichen und einem Farbmosaik, rechts das Anmeldeformular.
|
||||||
|
- Dashboard: Die Kacheln stehen mittig auf der Seite. Jedes Widget trägt oben ein gelbes Symbol-Feld, hebt sich beim Darüberfahren leicht an und blendet beim Laden sanft ein. Die Knöpfe „Bearbeiten“, „Widget hinzufügen“ und „Hintergrund“ sitzen jetzt als Leiste oben rechts über den Kacheln statt unten rechts. Kalender, Favoriten und Notizen sind ruhiger gestaltet, und ein leeres Dashboard schlägt Ihnen passende erste Kacheln vor.
|
||||||
|
- Kalender-Widget: Die nächsten Termine stehen jetzt in einer kompakten, einzeiligen Liste, in der „Heute“ und „Morgen“ statt des Datums erscheinen – so passen auch in eine kleine Kachel mehrere Termine. Den Ort eines Termins sehen Sie, wenn Sie mit der Maus darüberfahren.
|
||||||
|
- Auf dem Handy öffnet sich die Seitenleiste als Schublade über der ganzen Seite, samt App-Leiste.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Dashboard: Widgets ließen sich manchmal nicht schmaler ziehen, wenn die Maus dabei leicht nach oben oder unten wackelte. Jetzt klappt das zuverlässig.
|
||||||
|
|
||||||
## 1.4.0 – 2026-09-25
|
## 1.4.0 – 2026-09-25
|
||||||
|
|
||||||
### Neu
|
### Neu
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ COPY --from=builder /app/node_modules/.pnpm/@prisma+client@6.19.3_prisma@6.19.3_
|
|||||||
COPY --from=builder /app/apps/api/prisma ./apps/api/prisma
|
COPY --from=builder /app/apps/api/prisma ./apps/api/prisma
|
||||||
COPY --from=builder /app/packages/shared/src ./packages/shared/src
|
COPY --from=builder /app/packages/shared/src ./packages/shared/src
|
||||||
COPY apps/api/scripts ./apps/api/scripts
|
COPY apps/api/scripts ./apps/api/scripts
|
||||||
|
# Kopfbild der Willkommensmail (MailService.loadWelcomeHeaderPng liest
|
||||||
|
# apps/api/assets/mail/welcome-header.png relativ zu dist/mail/).
|
||||||
|
COPY apps/api/assets ./apps/api/assets
|
||||||
# Desktop-Pakete (Phase 18, D-08): im CI legt desktop-collect.sh Pakete +
|
# Desktop-Pakete (Phase 18, D-08): im CI legt desktop-collect.sh Pakete +
|
||||||
# manifest.json in diesen Ordner, lokal liegt nur der Platzhalter. Nur
|
# manifest.json in diesen Ordner, lokal liegt nur der Platzhalter. Nur
|
||||||
# lesend zur Laufzeit -- kein chown noetig.
|
# lesend zur Laufzeit -- kein chown noetig.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
@@ -0,0 +1,27 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="80" viewBox="0 0 1200 80">
|
||||||
|
<!--
|
||||||
|
Wellenstreifen unter dem Kopf der Tessera-Systemmails (Willkommensmail).
|
||||||
|
Quelle des PNG daneben (welcome-header.png, 1200x80, angezeigt 600x40);
|
||||||
|
erzeugt mit `node apps/api/scripts/render-mail-header.mjs`.
|
||||||
|
|
||||||
|
Seit quick-260930 (Rueckmeldung des Nutzers: in Outlook "ein riesiger
|
||||||
|
schwarzer Fleck, kein Logo") steckt KEIN Logo und KEIN Text mehr im Bild:
|
||||||
|
Bildmarke und Schriftzug stehen als HTML im Mailkopf und erscheinen immer.
|
||||||
|
Dieses Bild ist nur noch Schmuck: oben die Kopffarbe, darunter die
|
||||||
|
Duenen-Wellen des Dashboard-Hintergrunds (dunkle Fassung,
|
||||||
|
apps/web/src/lib/dashboard-background.ts) mit der feinen gelben Linie,
|
||||||
|
unten laeuft es ins Weiss der Karte aus. Zeigt ein Mailprogramm das Bild
|
||||||
|
nicht (Outlook mit gesperrten Bildern), bleibt dort die dunkle Kopffarbe
|
||||||
|
der Zelle stehen — der Kopf wirkt nur etwas hoeher, keine weisse Luecke.
|
||||||
|
Seit quick-260930 (zweite Rueckmeldung) 80 statt 112 px hoch (y-Werte
|
||||||
|
x 80/112), in der Mail 40 statt 56 px.
|
||||||
|
-->
|
||||||
|
<rect width="1200" height="80" fill="#ffffff"/>
|
||||||
|
<!-- Flaechen von unten nach oben uebereinander, jede von oben bis zu ihrer
|
||||||
|
Wellenlinie: so teilen sich benachbarte Baender dieselbe Kante, ohne
|
||||||
|
Luecken dazwischen. -->
|
||||||
|
<path d="M0 0 V65.7 C220 55.7 420 72.9 600 68.6 S1000 54.3 1200 61.4 V0 Z" fill="#d9dce0"/>
|
||||||
|
<path d="M0 0 V50 C250 35.7 420 61.4 640 54.3 S1040 35.7 1200 45.7 V0 Z" fill="#2c3036"/>
|
||||||
|
<path d="M0 0 V31.4 C330 12.9 520 44.3 700 37.1 S1060 15.7 1200 28.6 V0 Z" fill="#1a1c20"/>
|
||||||
|
<path d="M0 31.4 C330 12.9 520 44.3 700 37.1 S1060 15.7 1200 28.6" fill="none" stroke="#ffed00" stroke-opacity="0.75" stroke-width="3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
-- quick-260925-bow: "Was ist neu"-Fenster nach einem Versionswechsel.
|
||||||
|
--
|
||||||
|
-- Merkt pro Benutzer die zuletzt gesehene freigegebene Version (X.Y.Z), damit
|
||||||
|
-- das Fenster im Browser und in der Desktop-App genau einmal je Version
|
||||||
|
-- erscheint. Gesetzt wird der Wert nur ueber POST /users/me/release-seen
|
||||||
|
-- (beim Schliessen des Fensters) und bei der Anlage neuer Benutzer (laufende
|
||||||
|
-- Version). NULL = Bestandsbenutzer ohne gemerkten Stand; sie sehen beim
|
||||||
|
-- ersten Mal nur den Abschnitt der laufenden Version. Bewusst kein
|
||||||
|
-- Standardwert und kein Backfill.
|
||||||
|
--
|
||||||
|
-- Die Anmelde-Funktionen auth_lookup_* liefern eine feste Spaltenliste
|
||||||
|
-- (RETURNS TABLE) und bleiben von der neuen Spalte unberuehrt.
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "lastSeenReleaseVersion" TEXT;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- quick-260928-ujj: Dashboard-Hintergrund pro Benutzer in der Datenbank.
|
||||||
|
--
|
||||||
|
-- Bisher lag die Wahl des Dashboard-Hintergrunds (Design "Mosaik") im
|
||||||
|
-- localStorage des Browsers und folgte dem Benutzer nicht auf ein anderes
|
||||||
|
-- Geraet oder in die Desktop-App. Jetzt steht sie hier, geschrieben nur ueber
|
||||||
|
-- PATCH /users/me/dashboard-background und dort wie beim Lesen durch
|
||||||
|
-- parseDashboardBackground (@tessera/shared) geprueft und normalisiert.
|
||||||
|
-- NULL = nie gewaehlt (das Web uebernimmt dann einmalig eine alte
|
||||||
|
-- localStorage-Wahl); sonst ein Objekt { kind: 'none' | 'preset' | 'image', ... }.
|
||||||
|
-- Bewusst kein Standardwert und kein Backfill.
|
||||||
|
--
|
||||||
|
-- Die Anmelde-Funktionen auth_lookup_* liefern eine feste Spaltenliste
|
||||||
|
-- (RETURNS TABLE) und bleiben von der neuen Spalte unberuehrt.
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "dashboardBackground" JSONB;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
-- 260929-9wc — Eigene Module: externe Seiten als Seitenleisten-Eintraege.
|
||||||
|
--
|
||||||
|
-- Zweck: neue Tabelle "CustomModule". Der Administrator legt Eintraege an
|
||||||
|
-- (Name, https-Adresse, Kategorie), alle Benutzer des Mandanten sehen sie in
|
||||||
|
-- der Seitenleiste und ein Klick zeigt die Seite im Rahmen. Mehrere Zeilen je
|
||||||
|
-- Mandant, Vorbild "ProxmoxServer" (tenantId-Spalte, keine Relation zu
|
||||||
|
-- Tenant).
|
||||||
|
--
|
||||||
|
-- Von Hand geschrieben (Vorbild 20260923140000_proxmox_server), von Hand
|
||||||
|
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
|
||||||
|
--
|
||||||
|
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die
|
||||||
|
-- Tabelle traegt `tenantId` und `tenant_isolation_policy` OHNE
|
||||||
|
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — eigene
|
||||||
|
-- Module sind Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines
|
||||||
|
-- einzelnen Benutzers.
|
||||||
|
--
|
||||||
|
-- BEWUSST KEINE `system_read_policy`: es gibt keinen Hintergrunddienst, der
|
||||||
|
-- eigene Module ueber alle Mandanten lesen muesste; jeder Zugriff laeuft
|
||||||
|
-- mandantengebunden ueber `forTenant(prisma, tenantId)`.
|
||||||
|
--
|
||||||
|
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
|
||||||
|
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
|
||||||
|
-- tun.
|
||||||
|
--
|
||||||
|
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
|
||||||
|
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
|
||||||
|
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
|
||||||
|
|
||||||
|
CREATE TABLE "CustomModule" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"url" TEXT NOT NULL,
|
||||||
|
"category" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "CustomModule_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "CustomModule_tenantId_idx" ON "CustomModule"("tenantId");
|
||||||
|
|
||||||
|
ALTER TABLE "CustomModule" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "CustomModule" FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_policy ON "CustomModule"
|
||||||
|
USING ("tenantId" = current_tenant_id());
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
-- 260929-dzu — Eigene Module fuer jeden Benutzer: persoenliche Eintraege.
|
||||||
|
--
|
||||||
|
-- Zweck: jeder Benutzer darf eigene Seitenleisten-Eintraege anlegen, die nur
|
||||||
|
-- er selbst sieht. Die Spalte "ownerUserId" unterscheidet: NULL = gemeinsamer
|
||||||
|
-- Eintrag (vom Administrator, fuer alle sichtbar, bisheriges Verhalten),
|
||||||
|
-- gesetzt = persoenlicher Eintrag dieses Benutzers. Faellt der Benutzer weg,
|
||||||
|
-- fallen seine Eintraege mit (ON DELETE CASCADE). Bestehende Zeilen bleiben
|
||||||
|
-- gemeinsam (NULL).
|
||||||
|
--
|
||||||
|
-- Zeilenschutz: Muster "SearchProvider" (20260911120000_rls_user_dimension_
|
||||||
|
-- personal_tables) — Spalte mit NULL = gemeinsame Zeile. Die eine Regel
|
||||||
|
-- "tenant_isolation_policy" (aus 20260929120000, ohne Benutzerdimension) wird
|
||||||
|
-- durch vier nach Befehl getrennte Regeln ersetzt (Praezedenz 260910-jab (3)):
|
||||||
|
-- ein einzelner USING-Ausdruck, der die gemeinsame Zeile zum Lesen einschliesst,
|
||||||
|
-- wuerde sie sonst auch zum Aendern/Entfernen freigeben.
|
||||||
|
-- SELECT: Mandant UND (kein Benutzer gesetzt ODER gemeinsame Zeile ODER
|
||||||
|
-- eigene Zeile).
|
||||||
|
-- INSERT/UPDATE/DELETE: Mandant UND (kein Benutzer gesetzt ODER eigene
|
||||||
|
-- Zeile). Ein Benutzerkontext kann gemeinsame Zeilen also NICHT
|
||||||
|
-- schreiben; der Administrator-Weg fuer gemeinsame Eintraege bindet
|
||||||
|
-- deshalb ohne Benutzer (`forTenant(prisma, tenantId)`), die
|
||||||
|
-- Rollenpruefung liegt im Controller/Dienst.
|
||||||
|
-- Die Regelnamen sind neu (vier statt eine), rls-coverage.spec.ts fordert nur
|
||||||
|
-- mindestens eine Regel je Tabelle mit eingeschaltetem RLS.
|
||||||
|
--
|
||||||
|
-- Rechte fuer tessera_app kommen ueber ALTER DEFAULT PRIVILEGES aus
|
||||||
|
-- 20260909130000_rls_app_role — hier nichts zu tun.
|
||||||
|
--
|
||||||
|
-- WICHTIG: wie alle RLS-Regeln dieses Schemas wirken diese erst, wenn die
|
||||||
|
-- Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter heute AUS, siehe
|
||||||
|
-- docs/mandantentrennung-datenbankrolle.md). Bis dahin tragen die
|
||||||
|
-- Anwendungspruefungen im Dienst den Schutz allein.
|
||||||
|
|
||||||
|
ALTER TABLE "CustomModule" ADD COLUMN "ownerUserId" TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX "CustomModule_tenantId_ownerUserId_idx" ON "CustomModule"("tenantId", "ownerUserId");
|
||||||
|
|
||||||
|
ALTER TABLE "CustomModule" ADD CONSTRAINT "CustomModule_ownerUserId_fkey"
|
||||||
|
FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
DROP POLICY tenant_isolation_policy ON "CustomModule";
|
||||||
|
|
||||||
|
CREATE POLICY tenant_user_read_policy ON "CustomModule"
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
"tenantId" = current_tenant_id()
|
||||||
|
AND (
|
||||||
|
current_user_id() IS NULL
|
||||||
|
OR "ownerUserId" IS NULL
|
||||||
|
OR "ownerUserId" = current_user_id()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY tenant_user_insert_policy ON "CustomModule"
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
"tenantId" = current_tenant_id()
|
||||||
|
AND (current_user_id() IS NULL OR "ownerUserId" = current_user_id())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY tenant_user_update_policy ON "CustomModule"
|
||||||
|
FOR UPDATE
|
||||||
|
USING (
|
||||||
|
"tenantId" = current_tenant_id()
|
||||||
|
AND (current_user_id() IS NULL OR "ownerUserId" = current_user_id())
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
"tenantId" = current_tenant_id()
|
||||||
|
AND (current_user_id() IS NULL OR "ownerUserId" = current_user_id())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE POLICY tenant_user_delete_policy ON "CustomModule"
|
||||||
|
FOR DELETE
|
||||||
|
USING (
|
||||||
|
"tenantId" = current_tenant_id()
|
||||||
|
AND (current_user_id() IS NULL OR "ownerUserId" = current_user_id())
|
||||||
|
);
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
-- 260929-if2 — Erinnerungen: persoenliche, einmalige Erinnerungen je Benutzer.
|
||||||
|
--
|
||||||
|
-- Zweck: die Tabelle "Reminder" traegt die Erinnerungen des Dashboard-Widgets
|
||||||
|
-- „Erinnerungen“ (Titel, Beschreibung, Faelligkeit, optional E-Mail). Es gibt
|
||||||
|
-- keine Wiederholung (D-01) und keine Historie: „Erledigt“ loescht die Zeile.
|
||||||
|
--
|
||||||
|
-- Besitz: eine Erinnerung gehoert genau einem Benutzer (gleicher Mandant UND
|
||||||
|
-- gleicher Benutzer, D-05). Faellt der Benutzer weg, fallen seine Erinnerungen
|
||||||
|
-- mit (ON DELETE CASCADE). Die Anwendung antwortet fuer fremde Kennungen mit
|
||||||
|
-- 404 (nie 403).
|
||||||
|
--
|
||||||
|
-- Spuren des E-Mail-Planers: "emailSentAt" ist der ANSPRUCH auf den Versand
|
||||||
|
-- (wird vor dem Senden gesetzt, damit mehrere API-Instanzen nicht doppelt
|
||||||
|
-- senden), "emailAttempts" zaehlt die Versuche (hoechstens 3). Ein Verschieben
|
||||||
|
-- der Faelligkeit setzt beide zurueck.
|
||||||
|
--
|
||||||
|
-- Zeilenschutz, zwei Regeln:
|
||||||
|
-- tenant_isolation_policy — Mandant UND Benutzer (Form aus DashboardImage,
|
||||||
|
-- 20260921120000_dashboard_image): ohne gesetzten Benutzer (Hintergrund-
|
||||||
|
-- dienst, der je Mandant gebunden schreibt) gilt nur der Mandant, mit
|
||||||
|
-- Benutzer zusaetzlich "userId".
|
||||||
|
-- system_read_policy — NUR FOR SELECT, Form aus 20260914120000_rls_system_
|
||||||
|
-- context_read. Sie bedient allein die Kandidatenabfrage des E-Mail-
|
||||||
|
-- Planers (reminder-mail.scheduler.ts), der einmal ueber ALLE Mandanten
|
||||||
|
-- liest und dann je Zeile gebunden anspricht. Schreiben bleibt der
|
||||||
|
-- Mandantenregel vorbehalten.
|
||||||
|
--
|
||||||
|
-- Rechte fuer die Anwendungsrolle tessera_app: kommen ueber ALTER DEFAULT
|
||||||
|
-- PRIVILEGES aus 20260909130000_rls_app_role automatisch — hier nichts zu tun.
|
||||||
|
--
|
||||||
|
-- WICHTIG: wie alle RLS-Regeln dieses Schemas wirken diese erst, wenn die
|
||||||
|
-- Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter heute AUS, siehe
|
||||||
|
-- docs/mandantentrennung-datenbankrolle.md). Bis dahin tragen die
|
||||||
|
-- Anwendungspruefungen im Dienst den Schutz allein.
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Reminder" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL DEFAULT '',
|
||||||
|
"dueAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"emailEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"emailSentAt" TIMESTAMP(3),
|
||||||
|
"emailAttempts" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Reminder_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Reminder_tenantId_userId_dueAt_idx" ON "Reminder"("tenantId", "userId", "dueAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Reminder_dueAt_idx" ON "Reminder"("dueAt");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Reminder" ADD CONSTRAINT "Reminder_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Zeilenschutz: Mandant UND Benutzer (Muster 20260921120000)
|
||||||
|
ALTER TABLE "Reminder" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "Reminder" FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_policy ON "Reminder"
|
||||||
|
USING (
|
||||||
|
"tenantId" = current_tenant_id()
|
||||||
|
AND (current_user_id() IS NULL OR "userId" = current_user_id())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Systemkontext: nur Lesen, fuer die Kandidatenabfrage des E-Mail-Planers
|
||||||
|
CREATE POLICY system_read_policy ON "Reminder"
|
||||||
|
FOR SELECT USING (is_system_context());
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Willkommensmail aus der Benutzerverwaltung (Administrator → Benutzer).
|
||||||
|
--
|
||||||
|
-- Merkt pro Benutzer, wann zuletzt eine Willkommensmail verschickt wurde,
|
||||||
|
-- damit die Liste "Willkommensmail gesendet am …" zeigen kann. Gesetzt nur
|
||||||
|
-- ueber POST /users/:id/welcome-mail nach erfolgreichem Versand; erneutes
|
||||||
|
-- Senden ueberschreibt den Wert. NULL = nie gesendet. Kein Standardwert,
|
||||||
|
-- kein Backfill.
|
||||||
|
--
|
||||||
|
-- Keine neue Regel noetig: die Spalte liegt in "User", dessen
|
||||||
|
-- tenant_isolation_policy die ganze Zeile schuetzt. Die Anmelde-Funktionen
|
||||||
|
-- auth_lookup_* liefern eine feste Spaltenliste (RETURNS TABLE) und bleiben
|
||||||
|
-- unberuehrt.
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "welcomeMailSentAt" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
-- Willkommensmail: eigene Vorlage je Mandant (Administrator → Willkommensmail).
|
||||||
|
--
|
||||||
|
-- Zweck: neue Tabelle "WelcomeMailTemplate". Ein Administrator kann Betreff,
|
||||||
|
-- Ueberschrift, Einleitung und Abschlusstext der Willkommensmail anpassen
|
||||||
|
-- (mit Platzhaltern wie {{name}}). Hoechstens EINE Zeile je Mandant
|
||||||
|
-- ("tenantId" eindeutig); "Auf Standard zuruecksetzen" loescht die Zeile, dann
|
||||||
|
-- gelten wieder die Standardtexte aus dem Code. Die festen Bausteine der Mail
|
||||||
|
-- (Kopf, Zugangsdaten, Anmeldehinweis, Knoepfe, Fusszeile) stehen NICHT in
|
||||||
|
-- der Tabelle. "updatedBy" haelt den Benutzernamen des letzten Bearbeiters
|
||||||
|
-- als reinen Anzeigetext (keine Relation).
|
||||||
|
--
|
||||||
|
-- Von Hand geschrieben (Vorbild 20260929120000_custom_module).
|
||||||
|
--
|
||||||
|
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die
|
||||||
|
-- Tabelle traegt `tenantId` und `tenant_isolation_policy` OHNE
|
||||||
|
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`) — die
|
||||||
|
-- Vorlage ist Verwaltungsdatum des Mandanten, nicht persoenliches Datum eines
|
||||||
|
-- einzelnen Benutzers.
|
||||||
|
--
|
||||||
|
-- BEWUSST KEINE `system_read_policy`: gelesen wird nur beim Versand einer
|
||||||
|
-- Willkommensmail, und zwar gebunden an den Mandanten des Zielbenutzers
|
||||||
|
-- (`forTenant(prisma, tenantId)`); es gibt keinen Hintergrunddienst, der die
|
||||||
|
-- Vorlagen ueber alle Mandanten liest.
|
||||||
|
--
|
||||||
|
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
|
||||||
|
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
|
||||||
|
-- tun.
|
||||||
|
--
|
||||||
|
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
|
||||||
|
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
|
||||||
|
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
|
||||||
|
|
||||||
|
CREATE TABLE "WelcomeMailTemplate" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"subject" TEXT NOT NULL,
|
||||||
|
"heading" TEXT NOT NULL,
|
||||||
|
"intro" TEXT NOT NULL,
|
||||||
|
"closing" TEXT NOT NULL,
|
||||||
|
"updatedBy" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "WelcomeMailTemplate_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "WelcomeMailTemplate_tenantId_key" ON "WelcomeMailTemplate"("tenantId");
|
||||||
|
|
||||||
|
ALTER TABLE "WelcomeMailTemplate" ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE "WelcomeMailTemplate" FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY tenant_isolation_policy ON "WelcomeMailTemplate"
|
||||||
|
USING ("tenantId" = current_tenant_id());
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Willkommensmail: Anmeldehinweis je Kontoart als Teil der eigenen Vorlage.
|
||||||
|
--
|
||||||
|
-- Zweck: zwei neue Spalten in "WelcomeMailTemplate" —
|
||||||
|
-- "loginHintDirectory" (Hinweis fuer verzeichnisgefuehrte Konten, Standard
|
||||||
|
-- "Melden Sie sich mit Ihrem Benutzernamen und Ihrem gewohnten
|
||||||
|
-- Windows-Passwort an.") und "loginHintLocal" (Hinweis vor dem Knopf
|
||||||
|
-- "Passwort festlegen" fuer lokale Konten).
|
||||||
|
--
|
||||||
|
-- Bewusst NULLABLE ohne Default: bestehende Vorlagen behalten NULL, und
|
||||||
|
-- WelcomeMailTemplateService setzt dafuer den Standardtext aus
|
||||||
|
-- @tessera/shared ein. So steht der Standardtext nur an EINER Stelle im
|
||||||
|
-- Code und nicht zusaetzlich in der Datenbank.
|
||||||
|
--
|
||||||
|
-- Zeilenschutz: unveraendert (tenant_isolation_policy der Tabelle gilt fuer
|
||||||
|
-- die neuen Spalten mit).
|
||||||
|
|
||||||
|
ALTER TABLE "WelcomeMailTemplate" ADD COLUMN "loginHintDirectory" TEXT;
|
||||||
|
ALTER TABLE "WelcomeMailTemplate" ADD COLUMN "loginHintLocal" TEXT;
|
||||||
@@ -43,9 +43,21 @@ model User {
|
|||||||
lastLoginAt DateTime?
|
lastLoginAt DateTime?
|
||||||
avatarPath String?
|
avatarPath String?
|
||||||
accentColor String?
|
accentColor String?
|
||||||
|
// quick-260925-bow: zuletzt gesehene freigegebene Version (X.Y.Z) fuer das
|
||||||
|
// "Was ist neu"-Fenster; null = Bestandsbenutzer (sieht nur die laufende Version)
|
||||||
|
lastSeenReleaseVersion String?
|
||||||
|
// quick-260928-ujj: gewaehlter Dashboard-Hintergrund; null = nie gewaehlt,
|
||||||
|
// sonst das durch parseDashboardBackground (@tessera/shared) normalisierte
|
||||||
|
// Objekt, auch { kind: 'none' } fuer bewusst "kein Hintergrund"
|
||||||
|
dashboardBackground Json?
|
||||||
|
// Willkommensmail aus der Benutzerverwaltung: Zeitpunkt des letzten
|
||||||
|
// Versands; null = nie gesendet
|
||||||
|
welcomeMailSentAt DateTime?
|
||||||
passwordResetTokens PasswordResetToken[]
|
passwordResetTokens PasswordResetToken[]
|
||||||
groupMemberships GroupMembership[]
|
groupMemberships GroupMembership[]
|
||||||
moduleGrants ModuleGrant[]
|
moduleGrants ModuleGrant[]
|
||||||
|
customModules CustomModule[]
|
||||||
|
reminders Reminder[]
|
||||||
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@index([username])
|
@@index([username])
|
||||||
@@ -709,3 +721,70 @@ model ProxmoxServerStatus {
|
|||||||
|
|
||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Eigene Module (quick-260929-9wc): vom Administrator angelegte Seitenleisten-
|
||||||
|
// Eintraege, die eine externe https-Seite im Rahmen zeigen. Sichtbar fuer alle
|
||||||
|
// Benutzer des Mandanten. Zeilenschutz nach Muster ProxmoxServer (tenantId,
|
||||||
|
// keine Relation zu Tenant).
|
||||||
|
model CustomModule {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
name String
|
||||||
|
url String
|
||||||
|
category String // eine der MODULE_CATEGORIES aus @tessera/shared
|
||||||
|
// quick-260929-dzu: null = gemeinsamer Eintrag (vom Administrator, fuer alle
|
||||||
|
// sichtbar); gesetzt = persoenlicher Eintrag, nur fuer diesen Benutzer
|
||||||
|
// sichtbar. Faellt der Benutzer weg, fallen seine Eintraege mit.
|
||||||
|
ownerUserId String?
|
||||||
|
owner User? @relation(fields: [ownerUserId], references: [id], onDelete: Cascade)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([tenantId, ownerUserId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// quick-260929-if2: persoenliche Erinnerungen, einmalig (D-01). Eine Zeile
|
||||||
|
// gehoert genau einem Benutzer (D-05); fuer fremde Kennungen antwortet die API
|
||||||
|
// mit 404. "Erledigt" loescht die Zeile (E-02), es gibt keine Historie.
|
||||||
|
// emailSentAt/emailAttempts sind die Rechenspur des E-Mail-Planers (Anspruch
|
||||||
|
// vor dem Senden, hoechstens 3 Versuche); ein Verschieben (dueAt) setzt beide
|
||||||
|
// zurueck, damit die E-Mail erneut verschickt wird (D-03).
|
||||||
|
model Reminder {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
title String
|
||||||
|
description String @default("")
|
||||||
|
dueAt DateTime
|
||||||
|
emailEnabled Boolean @default(false)
|
||||||
|
emailSentAt DateTime?
|
||||||
|
emailAttempts Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([tenantId, userId, dueAt])
|
||||||
|
@@index([dueAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eigene Vorlage der Willkommensmail (Administrator → Willkommensmail):
|
||||||
|
// hoechstens eine je Mandant; fehlt sie, gelten die Standardtexte aus
|
||||||
|
// @tessera/shared (DEFAULT_WELCOME_MAIL_TEXTS). Nur die sechs Texte —
|
||||||
|
// Kopf, Zugangsdaten und Knoepfe bleiben fest im Code.
|
||||||
|
model WelcomeMailTemplate {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
tenantId String @unique
|
||||||
|
subject String
|
||||||
|
heading String
|
||||||
|
intro String
|
||||||
|
// Anmeldehinweise je Kontoart (Migration 20260930170000); NULL in einer
|
||||||
|
// aelteren Vorlage = Standardtext aus @tessera/shared
|
||||||
|
loginHintDirectory String?
|
||||||
|
loginHintLocal String?
|
||||||
|
closing String
|
||||||
|
// Benutzername des Administrators, der zuletzt gespeichert hat (Anzeige)
|
||||||
|
updatedBy String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* render-mail-header.mjs — erzeugt das Kopfbild der Willkommensmail
|
||||||
|
* (apps/api/assets/mail/welcome-header.png, 1200x80 fuer hochaufloesende
|
||||||
|
* Bildschirme, in der Mail 600x40 angezeigt) aus der daneben liegenden
|
||||||
|
* Quelle welcome-header.svg.
|
||||||
|
*
|
||||||
|
* Warum ein PNG statt Inline-SVG oder CSS-Hintergrund: Outlook (Word-
|
||||||
|
* Darstellung) und viele Webmailer zeigen weder SVG noch Hintergrundbilder
|
||||||
|
* zuverlaessig an. Das PNG wird als CID-Anhang eingebettet (MailService),
|
||||||
|
* die Mail laedt also nichts von aussen nach.
|
||||||
|
*
|
||||||
|
* Das PNG liegt fertig im Repo; dieses Skript ist nur noetig, wenn die SVG
|
||||||
|
* geaendert wird. Kein neues Paket: `sharp` ist ueber Next.js (apps/web)
|
||||||
|
* bereits installiert und wird von dort aufgeloest. Der Schriftzug wird
|
||||||
|
* mit den Systemschriften des erzeugenden Rechners gesetzt (fontconfig:
|
||||||
|
* Segoe UI, Inter, Noto Sans, DejaVu Sans — die erste vorhandene gewinnt).
|
||||||
|
*
|
||||||
|
* Aufruf (Repo-Wurzel): node apps/api/scripts/render-mail-header.mjs
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const assetDir = join(here, '..', 'assets', 'mail');
|
||||||
|
const webDir = join(here, '..', '..', 'web');
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const nextPkg = require.resolve('next/package.json', { paths: [webDir] });
|
||||||
|
const sharp = createRequire(nextPkg)('sharp');
|
||||||
|
|
||||||
|
const svg = readFileSync(join(assetDir, 'welcome-header.svg'));
|
||||||
|
const png = await sharp(svg, { density: 72 })
|
||||||
|
.resize(1200, 80)
|
||||||
|
.png({ compressionLevel: 9, palette: false })
|
||||||
|
.toBuffer();
|
||||||
|
writeFileSync(join(assetDir, 'welcome-header.png'), png);
|
||||||
|
console.log(`welcome-header.png geschrieben (${png.length} Bytes)`);
|
||||||
@@ -27,6 +27,8 @@ import { TenantModule } from './tenant/tenant.module';
|
|||||||
import { TendersModule } from './tenders/tenders.module';
|
import { TendersModule } from './tenders/tenders.module';
|
||||||
import { UserModule } from './user/user.module';
|
import { UserModule } from './user/user.module';
|
||||||
import { ProxmoxModule } from './proxmox/proxmox.module';
|
import { ProxmoxModule } from './proxmox/proxmox.module';
|
||||||
|
import { CustomModulesModule } from './custom-modules/custom-modules.module';
|
||||||
|
import { RemindersModule } from './reminders/reminders.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -53,6 +55,8 @@ import { ProxmoxModule } from './proxmox/proxmox.module';
|
|||||||
TendersModule,
|
TendersModule,
|
||||||
BugReportsModule,
|
BugReportsModule,
|
||||||
ProxmoxModule,
|
ProxmoxModule,
|
||||||
|
CustomModulesModule,
|
||||||
|
RemindersModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global JWT guard: all routes require auth unless @Public()
|
// Global JWT guard: all routes require auth unless @Public()
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ interface FakeUserRow {
|
|||||||
mustChangePassword: boolean;
|
mustChangePassword: boolean;
|
||||||
avatarPath?: string | null;
|
avatarPath?: string | null;
|
||||||
accentColor?: string | null;
|
accentColor?: string | null;
|
||||||
|
dashboardBackground?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BoundCall {
|
interface BoundCall {
|
||||||
@@ -501,6 +502,8 @@ describe('AuthService.getMe', () => {
|
|||||||
mustChangePassword: false,
|
mustChangePassword: false,
|
||||||
avatarPath: 'avatars/u1.png',
|
avatarPath: 'avatars/u1.png',
|
||||||
accentColor: '#3b82f6',
|
accentColor: '#3b82f6',
|
||||||
|
// quick-260928-ujj: gespeicherter Zusatzschluessel wird bei der Ausgabe verworfen.
|
||||||
|
dashboardBackground: { kind: 'preset', id: 'dunes', extra: 'weg' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const ldapUserRow: FakeUserRow = {
|
const ldapUserRow: FakeUserRow = {
|
||||||
@@ -515,6 +518,7 @@ describe('AuthService.getMe', () => {
|
|||||||
mustChangePassword: false,
|
mustChangePassword: false,
|
||||||
avatarPath: null,
|
avatarPath: null,
|
||||||
accentColor: null,
|
accentColor: null,
|
||||||
|
dashboardBackground: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -535,6 +539,7 @@ describe('AuthService.getMe', () => {
|
|||||||
tenantId: 't1',
|
tenantId: 't1',
|
||||||
mustChangePassword: false,
|
mustChangePassword: false,
|
||||||
accentColor: '#3b82f6',
|
accentColor: '#3b82f6',
|
||||||
|
dashboardBackground: { kind: 'preset', id: 'dunes' },
|
||||||
isLocalUser: true,
|
isLocalUser: true,
|
||||||
hasAvatar: true,
|
hasAvatar: true,
|
||||||
});
|
});
|
||||||
@@ -549,6 +554,30 @@ describe('AuthService.getMe', () => {
|
|||||||
expect(result).toMatchObject({ isLocalUser: false, hasAvatar: false });
|
expect(result).toMatchObject({ isLocalUser: false, hasAvatar: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('quick-260928-ujj: dashboardBackground NULL (nie gewaehlt) kommt als null zurueck', async () => {
|
||||||
|
const result = await service.getMe('t1', 'u2');
|
||||||
|
|
||||||
|
expect(result).toHaveProperty('dashboardBackground', null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quick-260928-ujj: ungueltiger gespeicherter Hintergrund kommt als null zurueck (T-ujj-01)', async () => {
|
||||||
|
prisma.__users.set('u1', {
|
||||||
|
...prisma.__users.get('u1'),
|
||||||
|
dashboardBackground: { kind: 'image', imageId: '") ; background: url("x' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.getMe('t1', 'u1');
|
||||||
|
|
||||||
|
expect(result).toHaveProperty('dashboardBackground', null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quick-260928-ujj: dashboardBackground steht im select neben accentColor', async () => {
|
||||||
|
await service.getMe('t1', 'u1');
|
||||||
|
|
||||||
|
const call = prisma.__boundCallLog.find((c: any) => c.method === 'findUnique');
|
||||||
|
expect(call.args.select).toMatchObject({ accentColor: true, dashboardBackground: true });
|
||||||
|
});
|
||||||
|
|
||||||
it('FREMDER Mandant (Klient unter t2, Zeile unter t1): liefert null, kein Fehler', async () => {
|
it('FREMDER Mandant (Klient unter t2, Zeile unter t1): liefert null, kein Fehler', async () => {
|
||||||
const result = await service.getMe('t2', 'u1');
|
const result = await service.getMe('t2', 'u1');
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
|
import { parseDashboardBackground } from '@tessera/shared';
|
||||||
import * as argon2 from 'argon2';
|
import * as argon2 from 'argon2';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
@@ -16,6 +17,7 @@ import { LdapService } from '../ldap/ldap.service';
|
|||||||
import { MailService } from '../mail/mail.service';
|
import { MailService } from '../mail/mail.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
import { PASSWORD_RESET_TOKEN_TTL_MS } from './password-reset-token';
|
||||||
import type { JwtPayload, LoginUser } from './types/auth-user';
|
import type { JwtPayload, LoginUser } from './types/auth-user';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -238,7 +240,7 @@ export class AuthService {
|
|||||||
|
|
||||||
// Generate a unique reset token
|
// Generate a unique reset token
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
const expiresAt = new Date(Date.now() + PASSWORD_RESET_TOKEN_TTL_MS); // 1 hour
|
||||||
|
|
||||||
// Create the reset token record — mandantengebunden, sobald der
|
// Create the reset token record — mandantengebunden, sobald der
|
||||||
// Benutzer und damit sein Mandant bekannt sind (WINDOWS #20, Aufgabe 1).
|
// Benutzer und damit sein Mandant bekannt sind (WINDOWS #20, Aufgabe 1).
|
||||||
@@ -331,6 +333,7 @@ export class AuthService {
|
|||||||
ldapDn: true,
|
ldapDn: true,
|
||||||
avatarPath: true,
|
avatarPath: true,
|
||||||
accentColor: true,
|
accentColor: true,
|
||||||
|
dashboardBackground: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -338,10 +341,13 @@ export class AuthService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { passwordHash, ldapDn, avatarPath, ...publicFields } = user;
|
const { passwordHash, ldapDn, avatarPath, dashboardBackground, ...publicFields } = user;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...publicFields,
|
...publicFields,
|
||||||
|
// quick-260928-ujj (T-ujj-01): auch beim Lesen durch die gemeinsame
|
||||||
|
// Pruefregel — NULL oder ein ungueltiger Inhalt ergibt null.
|
||||||
|
dashboardBackground: parseDashboardBackground(dashboardBackground),
|
||||||
isLocalUser: !!passwordHash && !ldapDn,
|
isLocalUser: !!passwordHash && !ldapDn,
|
||||||
hasAvatar: !!avatarPath,
|
hasAvatar: !!avatarPath,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { ForbiddenException } from '@nestjs/common';
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { JwtStrategy } from '../strategies/jwt.strategy';
|
import { JwtStrategy } from '../strategies/jwt.strategy';
|
||||||
import { ForcePasswordChangeInterceptor } from './force-password-change.interceptor';
|
import { ForcePasswordChangeInterceptor } from './force-password-change.interceptor';
|
||||||
|
|
||||||
|
vi.mock('../../prisma/prisma-tenant.extension', () => ({
|
||||||
|
forTenant: vi.fn((p: unknown) => p),
|
||||||
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ForcePasswordChangeInterceptor.intercept — pinnt Sperre, Erlaubnisliste
|
* ForcePasswordChangeInterceptor.intercept — pinnt Sperre, Erlaubnisliste
|
||||||
* und die Teilstring-Falle (260921-fi3, Aufgabe 1, Befund 1/D-01/D-02/D-03).
|
* und die Teilstring-Falle (260921-fi3, Aufgabe 1, Befund 1/D-01/D-02/D-03).
|
||||||
@@ -30,7 +34,19 @@ const nextHandle = { handle: () => of('ok') } as any;
|
|||||||
|
|
||||||
describe('ForcePasswordChangeInterceptor.intercept', () => {
|
describe('ForcePasswordChangeInterceptor.intercept', () => {
|
||||||
it('Nahttest (D-03): JwtStrategy.validate() -> request.user -> GET /users wirft ForbiddenException — scheitert gegen den heutigen Quelltext, weil das Feld auf dem Weg verloren geht', async () => {
|
it('Nahttest (D-03): JwtStrategy.validate() -> request.user -> GET /users wirft ForbiddenException — scheitert gegen den heutigen Quelltext, weil das Feld auf dem Weg verloren geht', async () => {
|
||||||
const strategy = new JwtStrategy({ get: () => 'test-secret' } as any);
|
const prisma = {
|
||||||
|
user: {
|
||||||
|
findUnique: async () => ({
|
||||||
|
id: 'u1',
|
||||||
|
username: 'admin',
|
||||||
|
role: 'ADMIN',
|
||||||
|
tenantId: 't1',
|
||||||
|
isActive: true,
|
||||||
|
mustChangePassword: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
const strategy = new JwtStrategy({ get: () => 'test-secret' } as any, prisma);
|
||||||
const user = await strategy.validate({
|
const user = await strategy.validate({
|
||||||
sub: 'u1',
|
sub: 'u1',
|
||||||
username: 'admin',
|
username: 'admin',
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* Gueltigkeit eines Kennwort-Tokens (`PasswordResetToken`, T-02-13): eine
|
||||||
|
* Stunde, einmal verwendbar. Gemeinsam genutzt vom Weg "Passwort
|
||||||
|
* vergessen" (`AuthService.requestPasswordReset`) und vom Link "Passwort
|
||||||
|
* festlegen" der Willkommensmail (`WelcomeMailService`) — beide legen
|
||||||
|
* dieselbe Art Token an und fuehren auf dieselbe Seite
|
||||||
|
* `/reset-password/<token>`, deshalb gilt dieselbe Frist.
|
||||||
|
*/
|
||||||
|
export const PASSWORD_RESET_TOKEN_TTL_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gueltigkeit des Links "Passwort festlegen" in der Willkommensmail: 7 Tage.
|
||||||
|
* Neue Mitarbeiter lesen die Mail oft erst Tage spaeter; eine Stunde wie bei
|
||||||
|
* "Passwort vergessen" (dort fordert der Benutzer den Link selbst an und
|
||||||
|
* nutzt ihn sofort) waere hier fast immer abgelaufen. Einmal verwendbar
|
||||||
|
* bleibt der Link trotzdem, und jede neue Willkommensmail legt einen neuen an.
|
||||||
|
*/
|
||||||
|
export const WELCOME_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { forTenant } from '../../prisma/prisma-tenant.extension';
|
||||||
import { JwtStrategy } from './jwt.strategy';
|
import { JwtStrategy } from './jwt.strategy';
|
||||||
|
|
||||||
|
vi.mock('../../prisma/prisma-tenant.extension', () => ({
|
||||||
|
forTenant: vi.fn((p: unknown) => p),
|
||||||
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JwtStrategy.validate — pinnt die Durchreichung von mustChangePassword
|
* JwtStrategy.validate — seit quick-260930 kommen Rolle, Aktiv-Status und
|
||||||
* (260921-fi3, Aufgabe 1, Befund 1/D-01). Direkte Konstruktion ohne
|
* Kennwort-Pflicht bei jeder Anfrage aus der Datenbank, nicht aus dem Token
|
||||||
|
* (Rollenaenderung/Deaktivierung wirkt sofort). Direkte Konstruktion ohne
|
||||||
* Nest-Testmodul, Muster aus `../../tenant/tenant.guard.spec.ts`.
|
* Nest-Testmodul, Muster aus `../../tenant/tenant.guard.spec.ts`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -11,51 +18,77 @@ function makeConfigService() {
|
|||||||
return { get: () => 'test-secret' } as any;
|
return { get: () => 'test-secret' } as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
tenantId: string;
|
||||||
|
isActive: boolean;
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
function makePrisma(row: Row) {
|
||||||
|
return { user: { findUnique: vi.fn(async () => row) } } as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
sub: 'u1',
|
||||||
|
username: 'kschaller',
|
||||||
|
role: 'SUPER_ADMIN' as const,
|
||||||
|
tenantId: 't1',
|
||||||
|
mustChangePassword: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const dbRow = {
|
||||||
|
id: 'u1',
|
||||||
|
username: 'kschaller',
|
||||||
|
role: 'ADMIN',
|
||||||
|
tenantId: 't1',
|
||||||
|
isActive: true,
|
||||||
|
mustChangePassword: false,
|
||||||
|
};
|
||||||
|
|
||||||
describe('JwtStrategy.validate', () => {
|
describe('JwtStrategy.validate', () => {
|
||||||
it('Anspruch mustChangePassword=true im Token: liefert request.user.mustChangePassword === true', async () => {
|
it('Rolle kommt aus der Datenbank, nicht aus dem Token (herabgestufter Super-Admin ist sofort Admin)', async () => {
|
||||||
const strategy = new JwtStrategy(makeConfigService());
|
const prisma = makePrisma(dbRow);
|
||||||
|
const strategy = new JwtStrategy(makeConfigService(), prisma);
|
||||||
|
|
||||||
const result = await strategy.validate({
|
const result = await strategy.validate(payload);
|
||||||
sub: 'u1',
|
|
||||||
username: 'admin',
|
|
||||||
role: 'ADMIN',
|
|
||||||
tenantId: 't1',
|
|
||||||
mustChangePassword: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.mustChangePassword).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Anspruch fehlt im Token (Alt-Sitzung, vor dieser Aenderung ausgestellt): liefert false statt undefined', async () => {
|
|
||||||
const strategy = new JwtStrategy(makeConfigService());
|
|
||||||
|
|
||||||
const result = await strategy.validate({
|
|
||||||
sub: 'u1',
|
|
||||||
username: 'admin',
|
|
||||||
role: 'ADMIN',
|
|
||||||
tenantId: 't1',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.mustChangePassword).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('id, username, role und tenantId werden unveraendert wie bisher durchgereicht', async () => {
|
|
||||||
const strategy = new JwtStrategy(makeConfigService());
|
|
||||||
|
|
||||||
const result = await strategy.validate({
|
|
||||||
sub: 'u1',
|
|
||||||
username: 'nutzer1',
|
|
||||||
role: 'USER',
|
|
||||||
tenantId: 't2',
|
|
||||||
mustChangePassword: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
id: 'u1',
|
id: 'u1',
|
||||||
username: 'nutzer1',
|
username: 'kschaller',
|
||||||
role: 'USER',
|
role: 'ADMIN',
|
||||||
tenantId: 't2',
|
tenantId: 't1',
|
||||||
mustChangePassword: false,
|
mustChangePassword: false,
|
||||||
});
|
});
|
||||||
|
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
|
||||||
|
expect(prisma.user.findUnique).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { id: 'u1' } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deaktiviertes Konto: 401, auch mit gueltigem Token', async () => {
|
||||||
|
const strategy = new JwtStrategy(makeConfigService(), makePrisma({ ...dbRow, isActive: false }));
|
||||||
|
await expect(strategy.validate(payload)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('geloeschtes Konto: 401', async () => {
|
||||||
|
const strategy = new JwtStrategy(makeConfigService(), makePrisma(null));
|
||||||
|
await expect(strategy.validate(payload)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Konto gehoert nicht (mehr) zum Mandanten aus dem Token: 401', async () => {
|
||||||
|
const strategy = new JwtStrategy(makeConfigService(), makePrisma({ ...dbRow, tenantId: 't2' }));
|
||||||
|
await expect(strategy.validate(payload)).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Kennwort-Pflicht kommt aus der Datenbank (vom Administrator nachtraeglich gesetzt)', async () => {
|
||||||
|
const strategy = new JwtStrategy(
|
||||||
|
makeConfigService(),
|
||||||
|
makePrisma({ ...dbRow, mustChangePassword: true }),
|
||||||
|
);
|
||||||
|
const result = await strategy.validate(payload);
|
||||||
|
expect(result.mustChangePassword).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { Strategy } from 'passport-jwt';
|
import { Strategy } from 'passport-jwt';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { forTenant } from '../../prisma/prisma-tenant.extension';
|
||||||
import type { AuthUser, JwtPayload } from '../types/auth-user';
|
import type { AuthUser, JwtPayload } from '../types/auth-user';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,7 +19,10 @@ function cookieExtractor(req: Request): string | null {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
constructor(configService: ConfigService) {
|
constructor(
|
||||||
|
configService: ConfigService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: cookieExtractor,
|
jwtFromRequest: cookieExtractor,
|
||||||
ignoreExpiration: false,
|
ignoreExpiration: false,
|
||||||
@@ -25,16 +30,46 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Token beweist nur, WER angemeldet ist — Rolle, Aktiv-Status und
|
||||||
|
* Kennwort-Pflicht kommen bei JEDER Anfrage frisch aus der Datenbank
|
||||||
|
* (quick-260930, Befund des Nutzers): vorher galt die Rolle aus dem
|
||||||
|
* 30-Tage-Token. Ein herabgestufter Administrator behielt bis zum Ablauf
|
||||||
|
* seine alten Rechte, ein deaktiviertes oder geloeschtes Konto (etwa per
|
||||||
|
* LDAP-Abgleich beim Austritt) arbeitete mit seiner Sitzung weiter, und
|
||||||
|
* Oberflaeche (liest die Rolle ueber /auth/me aus der Datenbank) und API
|
||||||
|
* (las sie aus dem Token) sahen verschiedene Rollen — die Benutzerliste
|
||||||
|
* scheiterte dann im Client.
|
||||||
|
*
|
||||||
|
* Ein Primaerschluessel-Lesezugriff je Anfrage, gebunden an den Mandanten
|
||||||
|
* aus dem Token (`forTenant`); gehoert das Konto nicht (mehr) zu diesem
|
||||||
|
* Mandanten, fehlt es oder ist es deaktiviert, gilt die Sitzung als
|
||||||
|
* ungueltig (401) — die Web-Oberflaeche leitet dann zur Anmeldung.
|
||||||
|
*/
|
||||||
async validate(payload: JwtPayload): Promise<AuthUser> {
|
async validate(payload: JwtPayload): Promise<AuthUser> {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, payload.tenantId);
|
||||||
|
const user = await tenantPrisma.user.findUnique({
|
||||||
|
where: { id: payload.sub },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
role: true,
|
||||||
|
tenantId: true,
|
||||||
|
isActive: true,
|
||||||
|
mustChangePassword: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.isActive || user.tenantId !== payload.tenantId) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: payload.sub,
|
id: user.id,
|
||||||
username: payload.username,
|
username: user.username,
|
||||||
role: payload.role,
|
role: user.role as AuthUser['role'],
|
||||||
tenantId: payload.tenantId,
|
tenantId: user.tenantId,
|
||||||
// Ein vor dieser Aenderung ausgestelltes Token traegt diesen Anspruch
|
mustChangePassword: user.mustChangePassword === true,
|
||||||
// nicht; der strenge Vergleich ergibt dann false, laufende Sitzungen
|
|
||||||
// verhalten sich unveraendert (260921-fi3, D-01 — keine Aussperrwelle).
|
|
||||||
mustChangePassword: payload.mustChangePassword === true,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { ForbiddenException, ValidationPipe } from '@nestjs/common';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||||
|
import { CustomModulesController } from './custom-modules.controller';
|
||||||
|
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
|
||||||
|
|
||||||
|
function makeService() {
|
||||||
|
return {
|
||||||
|
list: vi.fn(async (..._args: unknown[]) => []),
|
||||||
|
getOne: vi.fn(async (..._args: unknown[]) => ({})),
|
||||||
|
create: vi.fn(async (..._args: unknown[]) => ({})),
|
||||||
|
update: vi.fn(async (..._args: unknown[]) => ({})),
|
||||||
|
remove: vi.fn(async (..._args: unknown[]) => ({ deleted: true })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = (tenantId?: string) => ({ tenantId }) as any;
|
||||||
|
const user = { id: 'u1', username: 'u', role: 'USER', tenantId: 't1' } as any;
|
||||||
|
const proto = CustomModulesController.prototype as any;
|
||||||
|
|
||||||
|
describe('CustomModulesController — Rollen (quick-260929-dzu)', () => {
|
||||||
|
// Jeder Angemeldete darf persoenliche Eintraege anlegen/aendern/loeschen; die
|
||||||
|
// Administrator-Pflicht fuer gemeinsame Eintraege prueft der Dienst (hangt
|
||||||
|
// vom Eintrag ab, nicht von der Route) — siehe custom-modules.service.spec.ts.
|
||||||
|
it.each([
|
||||||
|
'list',
|
||||||
|
'getOne',
|
||||||
|
'create',
|
||||||
|
'update',
|
||||||
|
'remove',
|
||||||
|
])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => {
|
||||||
|
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('haengt an Pfad custom-modules', () => {
|
||||||
|
expect(Reflect.getMetadata('path', CustomModulesController)).toBe('custom-modules');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CustomModulesController — Mandant', () => {
|
||||||
|
it('reicht req.tenantId an den Dienst weiter', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
const controller = new CustomModulesController(service as any);
|
||||||
|
await controller.list(req('t1'), user);
|
||||||
|
await controller.getOne(req('t1'), user, 'x');
|
||||||
|
await controller.create(req('t1'), user, { name: 'a', url: 'https://a.de', category: 'fleet' });
|
||||||
|
await controller.update(req('t1'), user, 'x', { name: 'b' });
|
||||||
|
await controller.remove(req('t1'), user, 'x');
|
||||||
|
expect(service.list).toHaveBeenCalledWith('t1', user);
|
||||||
|
expect(service.getOne).toHaveBeenCalledWith('t1', user, 'x');
|
||||||
|
expect(service.create.mock.calls[0].slice(0, 2)).toEqual(['t1', user]);
|
||||||
|
expect(service.update.mock.calls[0].slice(0, 3)).toEqual(['t1', user, 'x']);
|
||||||
|
expect(service.remove).toHaveBeenCalledWith('t1', user, 'x');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wirft ForbiddenException ohne req.tenantId', async () => {
|
||||||
|
const controller = new CustomModulesController(makeService() as any);
|
||||||
|
await expect(controller.list(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
await expect(controller.getOne(req(), user, 'x')).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
await expect(
|
||||||
|
controller.create(req(), user, { name: 'a', url: 'https://a.de', category: 'fleet' }),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
await expect(controller.remove(req(), user, 'x')).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die globale Pipe verwirft ein untergeschobenes tenantId (T-9WC-07)', async () => {
|
||||||
|
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||||
|
const out: any = await pipe.transform(
|
||||||
|
{ name: 'a', url: 'https://a.de', category: 'fleet', tenantId: 'evil' },
|
||||||
|
{ type: 'body', metatype: CreateCustomModuleDto },
|
||||||
|
);
|
||||||
|
expect(out).not.toHaveProperty('tenantId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die globale Pipe verwirft ownerUserId, laesst shared beim Anlegen durch', async () => {
|
||||||
|
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||||
|
const out: any = await pipe.transform(
|
||||||
|
{ name: 'a', url: 'https://a.de', category: 'fleet', ownerUserId: 'evil', shared: true },
|
||||||
|
{ type: 'body', metatype: CreateCustomModuleDto },
|
||||||
|
);
|
||||||
|
expect(out).not.toHaveProperty('ownerUserId');
|
||||||
|
expect(out.shared).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die globale Pipe verwirft shared und ownerUserId beim Aendern', async () => {
|
||||||
|
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||||
|
const out: any = await pipe.transform(
|
||||||
|
{ name: 'b', shared: true, ownerUserId: 'evil' },
|
||||||
|
{ type: 'body', metatype: UpdateCustomModuleDto },
|
||||||
|
);
|
||||||
|
expect(out).not.toHaveProperty('shared');
|
||||||
|
expect(out).not.toHaveProperty('ownerUserId');
|
||||||
|
expect(out.name).toBe('b');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CustomModulesController — Routen-Reihenfolge (statisch vor :id)', () => {
|
||||||
|
it('deklariert list vor getOne', () => {
|
||||||
|
const methods = Object.getOwnPropertyNames(CustomModulesController.prototype);
|
||||||
|
const listIdx = methods.indexOf('list');
|
||||||
|
const idIdx = methods.indexOf('getOne');
|
||||||
|
expect(listIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(idIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(listIdx).toBeLessThan(idIdx);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import type { AuthenticatedRequest, AuthUser } from '../auth/types/auth-user';
|
||||||
|
import { CustomModulesService } from './custom-modules.service';
|
||||||
|
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eigene Module (quick-260929-9wc, erweitert in quick-260929-dzu). Jeder
|
||||||
|
* angemeldete Benutzer darf lesen und eigene (persoenliche) Eintraege anlegen,
|
||||||
|
* aendern und loeschen; gemeinsame Eintraege (`shared: true`) darf nur ein
|
||||||
|
* Administrator anlegen, aendern und loeschen — diese Rollenentscheidung trifft
|
||||||
|
* der Dienst, weil sie vom Eintrag abhaengt (gemeinsam oder persoenlich), nicht
|
||||||
|
* von der Route. Deshalb tragen die Routen kein `@Roles`. Kein `@UseModule`:
|
||||||
|
* eigene Module haengen an keiner Modul-Aktivierung. `tenantId` kommt
|
||||||
|
* ausschliesslich aus `req.tenantId` (gesetzt vom `TenantGuard`), der Benutzer
|
||||||
|
* aus dem Token.
|
||||||
|
*
|
||||||
|
* ROUTEN-REIHENFOLGE: NestJS bildet Routen in Deklarationsreihenfolge ab.
|
||||||
|
* Jede kuenftige statische GET-Route MUSS ueber `getOne` (`@Get(':id')`)
|
||||||
|
* stehen, sonst faengt `:id` sie ab (404-Shadowing); der Controller-Test
|
||||||
|
* haelt die Reihenfolge von `list` vor `getOne` fest.
|
||||||
|
*/
|
||||||
|
@Controller('custom-modules')
|
||||||
|
export class CustomModulesController {
|
||||||
|
constructor(private readonly service: CustomModulesService) {}
|
||||||
|
|
||||||
|
private requireTenantId(req: AuthenticatedRequest): string {
|
||||||
|
const tenantId = req.tenantId;
|
||||||
|
if (!tenantId) {
|
||||||
|
throw new ForbiddenException('Kein Mandantenkontext');
|
||||||
|
}
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list(@Req() req: AuthenticatedRequest, @CurrentUser() user: AuthUser) {
|
||||||
|
return this.service.list(this.requireTenantId(req), user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async getOne(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
return this.service.getOne(this.requireTenantId(req), user, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Body() dto: CreateCustomModuleDto,
|
||||||
|
) {
|
||||||
|
return this.service.create(this.requireTenantId(req), user, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
async update(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateCustomModuleDto,
|
||||||
|
) {
|
||||||
|
return this.service.update(this.requireTenantId(req), user, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
async remove(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
return this.service.remove(this.requireTenantId(req), user, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CustomModulesController } from './custom-modules.controller';
|
||||||
|
import { CustomModulesService } from './custom-modules.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eigene Module (quick-260929-9wc). `PrismaModule` ist global (wie bei
|
||||||
|
* `ProxmoxModule`, das PrismaService ebenfalls ohne eigenen Import erhaelt).
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
controllers: [CustomModulesController],
|
||||||
|
providers: [CustomModulesService],
|
||||||
|
})
|
||||||
|
export class CustomModulesModule {}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// `forTenant` reicht den Klienten durch — Mandantenbindung selbst prueft
|
||||||
|
// rls-access-inventory.spec.ts; hier zaehlt, mit welchen Argumenten je Methode
|
||||||
|
// gebunden wird (mit oder ohne Benutzer).
|
||||||
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
|
forTenant: vi.fn((p: unknown) => p),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
import { CustomModulesService } from './custom-modules.service';
|
||||||
|
|
||||||
|
function makeFakePrisma() {
|
||||||
|
const rows = new Map<string, any>();
|
||||||
|
let seq = 0;
|
||||||
|
const customModule = {
|
||||||
|
create: vi.fn(async ({ data }: { data: any }) => {
|
||||||
|
const id = `cm-${++seq}`;
|
||||||
|
const row = { id, createdAt: new Date(), updatedAt: new Date(), ...data };
|
||||||
|
rows.set(id, row);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
findMany: vi.fn(async ({ where, orderBy }: { where?: any; orderBy?: any } = {}) => {
|
||||||
|
let list = [...rows.values()];
|
||||||
|
if (where?.tenantId) list = list.filter((r) => r.tenantId === where.tenantId);
|
||||||
|
if (where?.OR) {
|
||||||
|
list = list.filter((r) =>
|
||||||
|
where.OR.some((c: { ownerUserId: string | null }) => r.ownerUserId === c.ownerUserId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (orderBy?.name === 'asc') list.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
return list;
|
||||||
|
}),
|
||||||
|
findUnique: vi.fn(async ({ where }: { where: { id: string } }) => rows.get(where.id) ?? null),
|
||||||
|
update: vi.fn(async ({ where, data }: { where: { id: string }; data: any }) => {
|
||||||
|
const row = { ...rows.get(where.id), ...data };
|
||||||
|
rows.set(where.id, row);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
delete: vi.fn(async ({ where }: { where: { id: string } }) => {
|
||||||
|
rows.delete(where.id);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return { customModule, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dto = { name: 'Wiki', url: 'https://example.com', category: 'infrastructure' as const };
|
||||||
|
const admin = { id: 'admin1', role: Role.ADMIN };
|
||||||
|
const userA = { id: 'ua', role: Role.USER };
|
||||||
|
const userB = { id: 'ub', role: Role.USER };
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
return { prisma, service: new CustomModulesService(prisma as any) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CustomModulesService — anlegen', () => {
|
||||||
|
it('speichert tenantId aus dem Argument, nie aus dem DTO', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
await service.create('t1', userA, { ...dto, tenantId: 'evil' } as any);
|
||||||
|
expect(prisma.customModule.create.mock.calls[0][0].data.tenantId).toBe('t1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne shared-Angabe ist der Eintrag persoenlich (ownerUserId = Aufrufer)', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
const res: any = await service.create('t1', userA, dto);
|
||||||
|
expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBe('ua');
|
||||||
|
expect(res.personal).toBe(true);
|
||||||
|
expect(res).not.toHaveProperty('ownerUserId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auch ein Administrator legt ohne shared persoenlich an', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
const res: any = await service.create('t1', admin, dto);
|
||||||
|
expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBe('admin1');
|
||||||
|
expect(res.personal).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shared: true durch einen Administrator legt einen gemeinsamen Eintrag an', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
const res: any = await service.create('t1', admin, { ...dto, shared: true });
|
||||||
|
expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBeNull();
|
||||||
|
expect(res.personal).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shared: true durch einen normalen Benutzer -> ForbiddenException, nichts gespeichert', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
await expect(service.create('t1', userA, { ...dto, shared: true })).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
expect(prisma.customModule.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shared: false durch einen normalen Benutzer bleibt persoenlich', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
await service.create('t1', userA, { ...dto, shared: false });
|
||||||
|
expect(prisma.customModule.create.mock.calls[0][0].data.ownerUserId).toBe('ua');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('das Feld shared landet nie in den gespeicherten Daten', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
await service.create('t1', admin, { ...dto, shared: true });
|
||||||
|
expect(prisma.customModule.create.mock.calls[0][0].data).not.toHaveProperty('shared');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CustomModulesService — lesen', () => {
|
||||||
|
it('list liefert gemeinsame plus eigene Eintraege, nie die eines anderen Benutzers', async () => {
|
||||||
|
const { service } = setup();
|
||||||
|
await service.create('t1', admin, { ...dto, name: 'Gemeinsam', shared: true });
|
||||||
|
await service.create('t1', userA, { ...dto, name: 'A-privat' });
|
||||||
|
await service.create('t1', userB, { ...dto, name: 'B-privat' });
|
||||||
|
const resA: any[] = await service.list('t1', userA);
|
||||||
|
expect(resA.map((r) => [r.name, r.personal])).toEqual([
|
||||||
|
['A-privat', true],
|
||||||
|
['Gemeinsam', false],
|
||||||
|
]);
|
||||||
|
const resB: any[] = await service.list('t1', userB);
|
||||||
|
expect(resB.map((r) => r.name)).toEqual(['B-privat', 'Gemeinsam']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('list sieht auch als Administrator keine persoenlichen Eintraege anderer', async () => {
|
||||||
|
const { service } = setup();
|
||||||
|
await service.create('t1', userA, { ...dto, name: 'A-privat' });
|
||||||
|
await service.create('t1', admin, { ...dto, name: 'Gemeinsam', shared: true });
|
||||||
|
const res: any[] = await service.list('t1', admin);
|
||||||
|
expect(res.map((r) => r.name)).toEqual(['Gemeinsam']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('list liefert nur Zeilen des Mandanten, nach Name sortiert', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
await service.create('t1', admin, { ...dto, name: 'Zebra', shared: true });
|
||||||
|
await service.create('t1', admin, { ...dto, name: 'Anker', shared: true });
|
||||||
|
await service.create('t2', admin, { ...dto, name: 'Fremd', shared: true });
|
||||||
|
const result: any[] = await service.list('t1', userA);
|
||||||
|
expect(result.map((r) => r.name)).toEqual(['Anker', 'Zebra']);
|
||||||
|
expect(prisma.customModule.findMany.mock.calls[0]?.[0]?.where).toEqual({
|
||||||
|
tenantId: 't1',
|
||||||
|
OR: [{ ownerUserId: null }, { ownerUserId: 'ua' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getOne liefert einen gemeinsamen Eintrag jedem, ohne tenantId und ownerUserId', async () => {
|
||||||
|
const { service } = setup();
|
||||||
|
const created: any = await service.create('t1', admin, { ...dto, shared: true });
|
||||||
|
const row: any = await service.getOne('t1', userA, created.id);
|
||||||
|
expect(row.name).toBe('Wiki');
|
||||||
|
expect(row.personal).toBe(false);
|
||||||
|
expect(row).not.toHaveProperty('tenantId');
|
||||||
|
expect(row).not.toHaveProperty('ownerUserId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getOne liefert einen eigenen persoenlichen Eintrag', async () => {
|
||||||
|
const { service } = setup();
|
||||||
|
const created: any = await service.create('t1', userA, dto);
|
||||||
|
const row: any = await service.getOne('t1', userA, created.id);
|
||||||
|
expect(row.personal).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getOne auf den persoenlichen Eintrag eines anderen -> NotFoundException (auch fuer Administratoren)', async () => {
|
||||||
|
const { service } = setup();
|
||||||
|
const created: any = await service.create('t1', userA, dto);
|
||||||
|
await expect(service.getOne('t1', userB, created.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
await expect(service.getOne('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getOne/update/remove mit unbekannter id -> NotFoundException', async () => {
|
||||||
|
const { service } = setup();
|
||||||
|
await expect(service.getOne('t1', userA, 'nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
await expect(service.update('t1', userA, 'nope', { name: 'x' })).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
await expect(service.remove('t1', userA, 'nope')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getOne/update/remove mit Zeile eines anderen Mandanten -> NotFoundException', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
const created: any = await service.create('t2', admin, { ...dto, shared: true });
|
||||||
|
await expect(service.getOne('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
await expect(service.update('t1', admin, created.id, { name: 'x' })).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
await expect(service.remove('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
expect(prisma.customModule.update).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.customModule.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CustomModulesService — aendern und loeschen', () => {
|
||||||
|
it('der Besitzer aendert und loescht seinen persoenlichen Eintrag', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
const created: any = await service.create('t1', userA, dto);
|
||||||
|
const updated: any = await service.update('t1', userA, created.id, { name: 'Neu' });
|
||||||
|
expect(updated.name).toBe('Neu');
|
||||||
|
expect(updated.personal).toBe(true);
|
||||||
|
await expect(service.remove('t1', userA, created.id)).resolves.toEqual({ deleted: true });
|
||||||
|
expect(prisma.rows.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein anderer Benutzer kann den persoenlichen Eintrag weder aendern noch loeschen (404)', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
const created: any = await service.create('t1', userA, dto);
|
||||||
|
await expect(service.update('t1', userB, created.id, { name: 'x' })).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
await expect(service.remove('t1', userB, created.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
expect(prisma.customModule.update).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.customModule.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('auch ein Administrator kann den persoenlichen Eintrag eines Benutzers nicht aendern (404)', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
const created: any = await service.create('t1', userA, dto);
|
||||||
|
await expect(service.update('t1', admin, created.id, { name: 'x' })).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
await expect(service.remove('t1', admin, created.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
expect(prisma.rows.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein normaler Benutzer kann einen gemeinsamen Eintrag weder aendern noch loeschen (403)', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
const created: any = await service.create('t1', admin, { ...dto, shared: true });
|
||||||
|
await expect(service.update('t1', userA, created.id, { name: 'x' })).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
await expect(service.remove('t1', userA, created.id)).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
expect(prisma.customModule.update).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.customModule.delete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Administrator aendert und loescht einen gemeinsamen Eintrag', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
const created: any = await service.create('t1', admin, { ...dto, shared: true });
|
||||||
|
const updated: any = await service.update('t1', admin, created.id, { name: 'Neu' });
|
||||||
|
expect(updated.name).toBe('Neu');
|
||||||
|
expect(updated.personal).toBe(false);
|
||||||
|
await expect(service.remove('t1', admin, created.id)).resolves.toEqual({ deleted: true });
|
||||||
|
expect(prisma.rows.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update aendert nur gesetzte Felder und nie Besitz oder Gemeinsamkeit', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
const created: any = await service.create('t1', userA, dto);
|
||||||
|
await service.update('t1', userA, created.id, {
|
||||||
|
name: 'Neu',
|
||||||
|
shared: true,
|
||||||
|
ownerUserId: 'ub',
|
||||||
|
} as any);
|
||||||
|
expect(prisma.customModule.update.mock.calls[0][0].data).toEqual({ name: 'Neu' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CustomModulesService — RLS-Bindung', () => {
|
||||||
|
it('bindet persoenliche Zugriffe mit Benutzer, gemeinsame Schreibzugriffe ohne', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
vi.mocked(forTenant).mockClear();
|
||||||
|
const shared: any = await service.create('t1', admin, { ...dto, shared: true });
|
||||||
|
const mine: any = await service.create('t1', userA, dto);
|
||||||
|
await service.list('t1', userA);
|
||||||
|
await service.getOne('t1', userA, mine.id);
|
||||||
|
await service.update('t1', userA, mine.id, { name: 'a' });
|
||||||
|
await service.update('t1', admin, shared.id, { name: 'b' });
|
||||||
|
const calls = vi.mocked(forTenant).mock.calls;
|
||||||
|
// create shared: ohne Benutzer
|
||||||
|
expect(calls[0]).toEqual([prisma, 't1']);
|
||||||
|
// create personal + list + getOne + (update personal: Laden + Schreiben)
|
||||||
|
expect(calls[1]).toEqual([prisma, 't1', 'ua']);
|
||||||
|
expect(calls[2]).toEqual([prisma, 't1', 'ua']);
|
||||||
|
expect(calls[3]).toEqual([prisma, 't1', 'ua']);
|
||||||
|
// update shared als Admin: Laden mit Benutzer, Schreiben ohne
|
||||||
|
expect(calls[calls.length - 2]).toEqual([prisma, 't1', 'admin1']);
|
||||||
|
expect(calls[calls.length - 1]).toEqual([prisma, 't1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
import type { CreateCustomModuleDto, UpdateCustomModuleDto } from './dto/custom-module.dto';
|
||||||
|
|
||||||
|
/** Antwortfelder — genau diese, nichts anderes verlaesst den Dienst. */
|
||||||
|
const CUSTOM_MODULE_SELECT = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
url: true,
|
||||||
|
category: true,
|
||||||
|
ownerUserId: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Der Aufrufer, wie ihn der Controller aus dem Anmelde-Token liest. */
|
||||||
|
export interface CustomModuleCaller {
|
||||||
|
id: string;
|
||||||
|
role: Role;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdmin(caller: CustomModuleCaller): boolean {
|
||||||
|
return caller.role === Role.ADMIN || caller.role === Role.SUPER_ADMIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aus der Zeile die Antwort machen: `personal` statt der Besitzer-Kennung. */
|
||||||
|
function toResponse<T extends { ownerUserId: string | null }>(row: T) {
|
||||||
|
const { ownerUserId, ...rest } = row;
|
||||||
|
return { ...rest, personal: ownerUserId !== null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eigene Module (quick-260929-9wc, erweitert in quick-260929-dzu): Seitenleisten-
|
||||||
|
* Eintraege mit externer https-Adresse. Zwei Arten:
|
||||||
|
*
|
||||||
|
* - gemeinsam (`ownerUserId` null): vom Administrator angelegt, fuer alle
|
||||||
|
* Benutzer des Mandanten sichtbar; Schreiben nur fuer Administratoren.
|
||||||
|
* - persoenlich (`ownerUserId` = Benutzer): nur der Besitzer sieht, aendert und
|
||||||
|
* loescht ihn. Ein anderer Benutzer bekommt fuer die id immer 404 — nie einen
|
||||||
|
* Hinweis, dass es sie gibt.
|
||||||
|
*
|
||||||
|
* `tenantId` kommt ausschliesslich als Argument (aus `req.tenantId`), nie aus
|
||||||
|
* dem DTO. Je Methode ein eigener `forTenant`-Klient.
|
||||||
|
*
|
||||||
|
* RLS-BINDUNG (Muster SearchProvider, siehe Migration 20260929130000): Lesen
|
||||||
|
* und Schreiben PERSOENLICHER Eintraege laeuft mit dem Benutzer als drittem
|
||||||
|
* Argument (`forTenant(prisma, tenantId, user.id)`); die Regel laesst dann nur
|
||||||
|
* gemeinsame und eigene Zeilen zu. Schreiben GEMEINSAMER Eintraege laeuft
|
||||||
|
* bewusst OHNE Benutzer (`forTenant(prisma, tenantId)`), weil die Regel einem
|
||||||
|
* Benutzerkontext das Schreiben gemeinsamer Zeilen verwehrt — die
|
||||||
|
* Rollenpruefung (Administrator) sitzt vorher im Dienst. Zusaetzlich pruefen
|
||||||
|
* alle Methoden `row.tenantId` und `row.ownerUserId` in der Anwendung, solange
|
||||||
|
* der RLS-Schalter aus ist.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CustomModulesService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** Gemeinsame Eintraege plus die eigenen des Aufrufers. */
|
||||||
|
async list(tenantId: string, caller: CustomModuleCaller) {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId, caller.id);
|
||||||
|
const rows = await tenantPrisma.customModule.findMany({
|
||||||
|
where: { tenantId, OR: [{ ownerUserId: null }, { ownerUserId: caller.id }] },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
select: CUSTOM_MODULE_SELECT,
|
||||||
|
});
|
||||||
|
return rows.map(toResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOne(tenantId: string, caller: CustomModuleCaller, id: string) {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId, caller.id);
|
||||||
|
const row = await this.loadVisible(tenantPrisma, tenantId, caller, id);
|
||||||
|
const { tenantId: _omit, ...result } = row;
|
||||||
|
return toResponse(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(tenantId: string, caller: CustomModuleCaller, dto: CreateCustomModuleDto) {
|
||||||
|
const shared = dto.shared === true;
|
||||||
|
if (shared && !isAdmin(caller)) {
|
||||||
|
throw new ForbiddenException('Gemeinsame Einträge dürfen nur Administratoren anlegen');
|
||||||
|
}
|
||||||
|
const data = {
|
||||||
|
tenantId,
|
||||||
|
name: dto.name,
|
||||||
|
url: dto.url,
|
||||||
|
category: dto.category,
|
||||||
|
ownerUserId: shared ? null : caller.id,
|
||||||
|
};
|
||||||
|
if (shared) {
|
||||||
|
// Gemeinsam: ohne Benutzerkontext (die Regel verwehrt ihn dort).
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
const row = await tenantPrisma.customModule.create({ data, select: CUSTOM_MODULE_SELECT });
|
||||||
|
return toResponse(row);
|
||||||
|
}
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId, caller.id);
|
||||||
|
const row = await tenantPrisma.customModule.create({ data, select: CUSTOM_MODULE_SELECT });
|
||||||
|
return toResponse(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
tenantId: string,
|
||||||
|
caller: CustomModuleCaller,
|
||||||
|
id: string,
|
||||||
|
dto: UpdateCustomModuleDto,
|
||||||
|
) {
|
||||||
|
const tenantPrisma = await this.writableClient(tenantId, caller, id);
|
||||||
|
const data: { name?: string; url?: string; category?: string } = {};
|
||||||
|
if (dto.name !== undefined) data.name = dto.name;
|
||||||
|
if (dto.url !== undefined) data.url = dto.url;
|
||||||
|
if (dto.category !== undefined) data.category = dto.category;
|
||||||
|
// Besitz und Gemeinsamkeit stehen nie in `data` — sie aendern sich nicht.
|
||||||
|
const row = await tenantPrisma.customModule.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
select: CUSTOM_MODULE_SELECT,
|
||||||
|
});
|
||||||
|
return toResponse(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(tenantId: string, caller: CustomModuleCaller, id: string) {
|
||||||
|
const tenantPrisma = await this.writableClient(tenantId, caller, id);
|
||||||
|
await tenantPrisma.customModule.delete({ where: { id } });
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeile laden, die der Aufrufer sehen darf: gleicher Mandant UND (gemeinsam
|
||||||
|
* ODER eigene). Alles andere — unbekannt, fremder Mandant, fremder
|
||||||
|
* persoenlicher Eintrag — ist ununterscheidbar 404.
|
||||||
|
*/
|
||||||
|
private async loadVisible(
|
||||||
|
tenantPrisma: ReturnType<typeof forTenant>,
|
||||||
|
tenantId: string,
|
||||||
|
caller: CustomModuleCaller,
|
||||||
|
id: string,
|
||||||
|
) {
|
||||||
|
const row = await tenantPrisma.customModule.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { ...CUSTOM_MODULE_SELECT, tenantId: true },
|
||||||
|
});
|
||||||
|
if (!row || row.tenantId !== tenantId) {
|
||||||
|
throw new NotFoundException('Eigenes Modul nicht gefunden');
|
||||||
|
}
|
||||||
|
if (row.ownerUserId !== null && row.ownerUserId !== caller.id) {
|
||||||
|
throw new NotFoundException('Eigenes Modul nicht gefunden');
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Klient fuer Aendern/Loeschen: persoenlicher Eintrag -> mit Benutzer (nur der
|
||||||
|
* Besitzer kommt bis hierher); gemeinsamer Eintrag -> nur Administrator (403
|
||||||
|
* sonst, der Eintrag ist fuer alle sichtbar, sein Bestehen ist kein
|
||||||
|
* Geheimnis), dann ohne Benutzerkontext.
|
||||||
|
*/
|
||||||
|
private async writableClient(tenantId: string, caller: CustomModuleCaller, id: string) {
|
||||||
|
const userClient = forTenant(this.prisma, tenantId, caller.id);
|
||||||
|
const row = await this.loadVisible(userClient, tenantId, caller, id);
|
||||||
|
if (row.ownerUserId === caller.id) {
|
||||||
|
return userClient;
|
||||||
|
}
|
||||||
|
if (!isAdmin(caller)) {
|
||||||
|
throw new ForbiddenException('Gemeinsame Einträge dürfen nur Administratoren ändern');
|
||||||
|
}
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
return tenantPrisma;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { plainToInstance } from 'class-transformer';
|
||||||
|
import { validate } from 'class-validator';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { CreateCustomModuleDto, UpdateCustomModuleDto } from './custom-module.dto';
|
||||||
|
|
||||||
|
async function errorsFor<T extends object>(cls: new () => T, plain: Record<string, unknown>) {
|
||||||
|
const dto = plainToInstance(cls, plain);
|
||||||
|
const errors = await validate(dto as object);
|
||||||
|
return errors.map((e) => e.property);
|
||||||
|
}
|
||||||
|
|
||||||
|
const valid = { name: 'Wiki', url: 'https://example.com', category: 'infrastructure' };
|
||||||
|
|
||||||
|
describe('CreateCustomModuleDto', () => {
|
||||||
|
it('nimmt einen gueltigen Eintrag an', async () => {
|
||||||
|
expect(await errorsFor(CreateCustomModuleDto, valid)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'http://example.com',
|
||||||
|
'javascript:alert(1)',
|
||||||
|
'data:text/html,x',
|
||||||
|
'ftp://x',
|
||||||
|
'kaputt',
|
||||||
|
'https://user:pw@example.com',
|
||||||
|
'https://user@example.com',
|
||||||
|
])('lehnt die Adresse %s ab', async (url) => {
|
||||||
|
expect(await errorsFor(CreateCustomModuleDto, { ...valid, url })).toContain('url');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt eine unbekannte Kategorie ab', async () => {
|
||||||
|
expect(await errorsFor(CreateCustomModuleDto, { ...valid, category: 'other' })).toContain(
|
||||||
|
'category',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['', ' '])('lehnt den Namen %j ab', async (name) => {
|
||||||
|
expect(await errorsFor(CreateCustomModuleDto, { ...valid, name })).toContain('name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('trimmt den Namen', () => {
|
||||||
|
const dto = plainToInstance(CreateCustomModuleDto, { ...valid, name: ' Wiki ' });
|
||||||
|
expect(dto.name).toBe('Wiki');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt zu lange Namen und Adressen ab', async () => {
|
||||||
|
expect(await errorsFor(CreateCustomModuleDto, { ...valid, name: 'a'.repeat(101) })).toContain(
|
||||||
|
'name',
|
||||||
|
);
|
||||||
|
const longUrl = `https://example.com/${'a'.repeat(2048)}`;
|
||||||
|
expect(await errorsFor(CreateCustomModuleDto, { ...valid, url: longUrl })).toContain('url');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('UpdateCustomModuleDto', () => {
|
||||||
|
it('akzeptiert Teilmengen', async () => {
|
||||||
|
expect(await errorsFor(UpdateCustomModuleDto, { name: 'Neu' })).toEqual([]);
|
||||||
|
expect(await errorsFor(UpdateCustomModuleDto, {})).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prueft jedes gesetzte Feld gleich', async () => {
|
||||||
|
expect(await errorsFor(UpdateCustomModuleDto, { url: 'http://example.com' })).toContain('url');
|
||||||
|
expect(await errorsFor(UpdateCustomModuleDto, { category: 'other' })).toContain('category');
|
||||||
|
expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'name',
|
||||||
|
'url',
|
||||||
|
'category',
|
||||||
|
])('lehnt %s: null ab statt es durchzulassen', async (field) => {
|
||||||
|
expect(await errorsFor(UpdateCustomModuleDto, { [field]: null })).toContain(field);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { OmitType, PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CUSTOM_MODULE_CATEGORIES } from '@tessera/shared';
|
||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsIn,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
Validate,
|
||||||
|
ValidatorConstraint,
|
||||||
|
type ValidatorConstraintInterface,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adresse eines eigenen Moduls (T-9WC-03, T-9WC-06): gueltig nur, wenn der
|
||||||
|
* URL-Parser sie annimmt, das Schema `https:` ist, ein Rechnername da ist und
|
||||||
|
* weder Benutzername noch Kennwort in der Adresse stehen — sonst saehe jeder
|
||||||
|
* Benutzer die Zugangsdaten. `javascript:`, `data:`, `http:` und `ftp:` fallen
|
||||||
|
* damit heraus.
|
||||||
|
*/
|
||||||
|
@ValidatorConstraint({ name: 'nurHttpsOhneZugangsdaten', async: false })
|
||||||
|
class NurHttpsOhneZugangsdatenConstraint implements ValidatorConstraintInterface {
|
||||||
|
validate(value: unknown): boolean {
|
||||||
|
if (typeof value !== 'string') return false;
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(value);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
parsed.protocol === 'https:' &&
|
||||||
|
parsed.hostname !== '' &&
|
||||||
|
parsed.username === '' &&
|
||||||
|
parsed.password === ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage(): string {
|
||||||
|
return 'Nur https-Adressen ohne Zugangsdaten sind erlaubt.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimString = ({ value }: { value: unknown }) =>
|
||||||
|
typeof value === 'string' ? value.trim() : value;
|
||||||
|
|
||||||
|
/** DTO fuer das Anlegen eines eigenen Moduls. */
|
||||||
|
export class CreateCustomModuleDto {
|
||||||
|
@Transform(trimString)
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(100)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@Transform(trimString)
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2048)
|
||||||
|
@Validate(NurHttpsOhneZugangsdatenConstraint)
|
||||||
|
url!: string;
|
||||||
|
|
||||||
|
@IsIn([...CUSTOM_MODULE_CATEGORIES])
|
||||||
|
category!: (typeof CUSTOM_MODULE_CATEGORIES)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* quick-260929-dzu: `true` legt einen gemeinsamen Eintrag fuer alle Benutzer
|
||||||
|
* an (nur Administratoren, sonst 403 im Dienst). Fehlt das Feld oder ist es
|
||||||
|
* `false`, ist der Eintrag persoenlich und gehoert dem Aufrufer.
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
shared?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teil-Update: jedes gesetzte Feld wird genauso geprueft wie beim Anlegen.
|
||||||
|
* `shared` ist ausgenommen — ob ein Eintrag gemeinsam oder persoenlich ist,
|
||||||
|
* aendert sich nach dem Anlegen nicht (die globale Pipe verwirft das Feld
|
||||||
|
* dank `whitelist: true`).
|
||||||
|
* `skipNullProperties: false`: fehlende Felder bleiben unveraendert, ein
|
||||||
|
* ausdrueckliches `null` wird aber geprueft und damit abgelehnt (400) — sonst
|
||||||
|
* liefe `{"name": null}` bis in die Datenbank und endete als 500.
|
||||||
|
*/
|
||||||
|
export class UpdateCustomModuleDto extends PartialType(
|
||||||
|
OmitType(CreateCustomModuleDto, ['shared'] as const),
|
||||||
|
{ skipNullProperties: false },
|
||||||
|
) {}
|
||||||
@@ -21,7 +21,11 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
InternalServerErrorException,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import { DashboardImagesService } from './dashboard-images.service';
|
import { DashboardImagesService } from './dashboard-images.service';
|
||||||
@@ -73,11 +77,21 @@ interface BoundCall {
|
|||||||
|
|
||||||
type ModelMethods = Record<string, (...args: unknown[]) => Promise<unknown>>;
|
type ModelMethods = Record<string, (...args: unknown[]) => Promise<unknown>>;
|
||||||
|
|
||||||
|
interface UserRow {
|
||||||
|
id: string;
|
||||||
|
dashboardBackground: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
interface FakePrisma {
|
interface FakePrisma {
|
||||||
dashboardImage: ModelMethods;
|
dashboardImage: ModelMethods;
|
||||||
|
user: ModelMethods;
|
||||||
__rows: ImageRow[];
|
__rows: ImageRow[];
|
||||||
|
__users: UserRow[];
|
||||||
__boundCallLog: BoundCall[];
|
__boundCallLog: BoundCall[];
|
||||||
__makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods };
|
__makeBoundClient(
|
||||||
|
tenantId: string,
|
||||||
|
userId?: string,
|
||||||
|
): { dashboardImage: ModelMethods; user: ModelMethods };
|
||||||
}
|
}
|
||||||
|
|
||||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
||||||
@@ -147,8 +161,28 @@ function pick(row: ImageRow, select: Record<string, boolean> | undefined) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
function makeFakePrisma(rows: ImageRow[] = [], users: UserRow[] = []): FakePrisma {
|
||||||
const boundCallLog: BoundCall[] = [];
|
const boundCallLog: BoundCall[] = [];
|
||||||
|
// quick-260930: Hintergrund-Wahl (`User.dashboardBackground`) — bedingtes
|
||||||
|
// updateMany ueber den JSON-Pfad `imageId`, wie Prisma es auf PostgreSQL filtert.
|
||||||
|
const user: ModelMethods = {
|
||||||
|
updateMany: vi.fn(async (raw: unknown) => {
|
||||||
|
const args = raw as {
|
||||||
|
where: { id: string; dashboardBackground: { path: string[]; equals: unknown } };
|
||||||
|
data: { dashboardBackground: unknown };
|
||||||
|
};
|
||||||
|
const [key] = args.where.dashboardBackground.path;
|
||||||
|
let count = 0;
|
||||||
|
for (const u of users) {
|
||||||
|
const bg = u.dashboardBackground as Record<string, unknown> | null;
|
||||||
|
if (u.id !== args.where.id || !bg || bg[key] !== args.where.dashboardBackground.equals)
|
||||||
|
continue;
|
||||||
|
u.dashboardBackground = args.data.dashboardBackground;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return { count };
|
||||||
|
}),
|
||||||
|
};
|
||||||
const dashboardImage: ModelMethods = {
|
const dashboardImage: ModelMethods = {
|
||||||
findMany: vi.fn(async (raw: unknown) => {
|
findMany: vi.fn(async (raw: unknown) => {
|
||||||
const args = raw as {
|
const args = raw as {
|
||||||
@@ -168,11 +202,17 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
|||||||
}),
|
}),
|
||||||
count: vi.fn(async (raw: unknown) => {
|
count: vi.fn(async (raw: unknown) => {
|
||||||
const args = raw as { where: { tenantId: string; userId: string } };
|
const args = raw as { where: { tenantId: string; userId: string } };
|
||||||
return rows.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId).length;
|
return rows.filter(
|
||||||
|
(r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId,
|
||||||
|
).length;
|
||||||
}),
|
}),
|
||||||
create: vi.fn(async (raw: unknown) => {
|
create: vi.fn(async (raw: unknown) => {
|
||||||
const args = raw as { data: Partial<ImageRow>; select?: Record<string, boolean> };
|
const args = raw as { data: Partial<ImageRow>; select?: Record<string, boolean> };
|
||||||
const created = makeRow({ id: `new-${rows.length + 1}`, ...args.data, createdAt: new Date('2026-02-02') });
|
const created = makeRow({
|
||||||
|
id: `new-${rows.length + 1}`,
|
||||||
|
...args.data,
|
||||||
|
createdAt: new Date('2026-02-02'),
|
||||||
|
});
|
||||||
rows.push(created);
|
rows.push(created);
|
||||||
return pick(created, args.select);
|
return pick(created, args.select);
|
||||||
}),
|
}),
|
||||||
@@ -196,20 +236,29 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
function wrap(tenantId: string, userId?: string) {
|
function wrapModel(model: string, methods: ModelMethods, tenantId: string, userId?: string) {
|
||||||
const wrapped: ModelMethods = {};
|
const wrapped: ModelMethods = {};
|
||||||
for (const method of Object.keys(dashboardImage)) {
|
for (const method of Object.keys(methods)) {
|
||||||
wrapped[method] = async (...args: unknown[]) => {
|
wrapped[method] = async (...args: unknown[]) => {
|
||||||
boundCallLog.push({ tenantId, userId, model: 'dashboardImage', method });
|
boundCallLog.push({ tenantId, userId, model, method });
|
||||||
return dashboardImage[method](...args);
|
return methods[method](...args);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { dashboardImage: wrapped };
|
return wrapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrap(tenantId: string, userId?: string) {
|
||||||
|
return {
|
||||||
|
dashboardImage: wrapModel('dashboardImage', dashboardImage, tenantId, userId),
|
||||||
|
user: wrapModel('user', user, tenantId, userId),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const fake: FakePrisma = {
|
const fake: FakePrisma = {
|
||||||
dashboardImage,
|
dashboardImage,
|
||||||
|
user,
|
||||||
__rows: rows,
|
__rows: rows,
|
||||||
|
__users: users,
|
||||||
__boundCallLog: boundCallLog,
|
__boundCallLog: boundCallLog,
|
||||||
__makeBoundClient(tenantId: string, userId?: string) {
|
__makeBoundClient(tenantId: string, userId?: string) {
|
||||||
return wrap(tenantId, userId);
|
return wrap(tenantId, userId);
|
||||||
@@ -253,9 +302,17 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
|||||||
const result = await makeService(prisma).list('user-1', 'tenant-1');
|
const result = await makeService(prisma).list('user-1', 'tenant-1');
|
||||||
expect(result.map((r) => r.id)).toEqual(['a', 'b']);
|
expect(result.map((r) => r.id)).toEqual(['a', 'b']);
|
||||||
for (const r of result) {
|
for (const r of result) {
|
||||||
expect(Object.keys(r).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']);
|
expect(Object.keys(r).sort()).toEqual([
|
||||||
|
'createdAt',
|
||||||
|
'id',
|
||||||
|
'mimeType',
|
||||||
|
'originalName',
|
||||||
|
'size',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as { select: Record<string, boolean> };
|
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as {
|
||||||
|
select: Record<string, boolean>;
|
||||||
|
};
|
||||||
expect(call.select.data).toBeUndefined();
|
expect(call.select.data).toBeUndefined();
|
||||||
expect(call.select.storagePath).toBeUndefined();
|
expect(call.select.storagePath).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -274,14 +331,22 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
|||||||
expect(result.mimeType).toBe('image/png');
|
expect(result.mimeType).toBe('image/png');
|
||||||
expect(result.originalName).toBe('irgendwas.txt');
|
expect(result.originalName).toBe('irgendwas.txt');
|
||||||
expect(result.size).toBe(PNG.length);
|
expect(result.size).toBe(PNG.length);
|
||||||
expect(Object.keys(result).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']);
|
expect(Object.keys(result).sort()).toEqual([
|
||||||
|
'createdAt',
|
||||||
|
'id',
|
||||||
|
'mimeType',
|
||||||
|
'originalName',
|
||||||
|
'size',
|
||||||
|
]);
|
||||||
expect(prisma.__rows[0].userId).toBe('user-1');
|
expect(prisma.__rows[0].userId).toBe('user-1');
|
||||||
expect(prisma.__rows[0].tenantId).toBe('tenant-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 () => {
|
it('Test 4: Textdatei mit behauptetem image/png scheitert mit deutscher Meldung, nichts wird angelegt', async () => {
|
||||||
const prisma = makeFakePrisma();
|
const prisma = makeFakePrisma();
|
||||||
await expect(makeService(prisma).upload(user, file(TEXT, 'image/png', 'bild.png'))).rejects.toThrow(
|
await expect(
|
||||||
|
makeService(prisma).upload(user, file(TEXT, 'image/png', 'bild.png')),
|
||||||
|
).rejects.toThrow(
|
||||||
new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'),
|
new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'),
|
||||||
);
|
);
|
||||||
expect(prisma.dashboardImage.create).not.toHaveBeenCalled();
|
expect(prisma.dashboardImage.create).not.toHaveBeenCalled();
|
||||||
@@ -304,7 +369,9 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Test 6: Zaehler zaehlt nur den eigenen Benutzer im eigenen Mandanten (fremde Zeilen zaehlen nicht)', async () => {
|
it('Test 6: Zaehler zaehlt nur den eigenen Benutzer im eigenen Mandanten (fremde Zeilen zaehlen nicht)', async () => {
|
||||||
const foreign = Array.from({ length: 30 }, (_, i) => makeRow({ id: `f${i}`, userId: 'user-2' }));
|
const foreign = Array.from({ length: 30 }, (_, i) =>
|
||||||
|
makeRow({ id: `f${i}`, userId: 'user-2' }),
|
||||||
|
);
|
||||||
const prisma = makeFakePrisma(foreign);
|
const prisma = makeFakePrisma(foreign);
|
||||||
await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({
|
await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({
|
||||||
mimeType: 'image/png',
|
mimeType: 'image/png',
|
||||||
@@ -321,14 +388,20 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
|||||||
|
|
||||||
it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => {
|
it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => {
|
||||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', userId: 'user-2' })]);
|
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', userId: 'user-2' })]);
|
||||||
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => {
|
it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => {
|
||||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', tenantId: 'tenant-2' })]);
|
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', tenantId: 'tenant-2' })]);
|
||||||
const service = makeService(prisma);
|
const service = makeService(prisma);
|
||||||
await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||||
await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
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 () => {
|
it('Test 10: getBytes — eigenes Bild liefert mimeType und die gespeicherten Bytes', async () => {
|
||||||
@@ -347,11 +420,52 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
|||||||
const service = makeService(prisma);
|
const service = makeService(prisma);
|
||||||
await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' });
|
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']);
|
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-user', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||||
await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
NotFoundException,
|
||||||
|
);
|
||||||
|
await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
expect(prisma.__rows).toHaveLength(2);
|
expect(prisma.__rows).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Test 11b (quick-260930): war das geloeschte Bild der Dashboard-Hintergrund, steht die Wahl danach auf „kein Hintergrund“ — andere Wahlen und andere Benutzer bleiben', async () => {
|
||||||
|
const users: UserRow[] = [
|
||||||
|
{ id: 'user-1', dashboardBackground: { kind: 'image', imageId: 'eigen' } },
|
||||||
|
{ id: 'user-2', dashboardBackground: { kind: 'image', imageId: 'eigen' } },
|
||||||
|
];
|
||||||
|
const prisma = makeFakePrisma(
|
||||||
|
[makeStoredRow({ id: 'eigen' }), makeStoredRow({ id: 'zweites' })],
|
||||||
|
users,
|
||||||
|
);
|
||||||
|
const service = makeService(prisma);
|
||||||
|
|
||||||
|
await service.remove('eigen', 'user-1', 'tenant-1');
|
||||||
|
|
||||||
|
expect(users[0].dashboardBackground).toEqual({ kind: 'none' });
|
||||||
|
// nur die eigene Zeile
|
||||||
|
expect(users[1].dashboardBackground).toEqual({ kind: 'image', imageId: 'eigen' });
|
||||||
|
const call = vi.mocked(prisma.user.updateMany).mock.calls[0][0];
|
||||||
|
expect(call).toEqual({
|
||||||
|
where: { id: 'user-1', dashboardBackground: { path: ['imageId'], equals: 'eigen' } },
|
||||||
|
data: { dashboardBackground: { kind: 'none' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ein anderes Bild loeschen laesst eine andere Wahl stehen.
|
||||||
|
users[0].dashboardBackground = { kind: 'preset', id: 'mist' };
|
||||||
|
await service.remove('zweites', 'user-1', 'tenant-1');
|
||||||
|
expect(users[0].dashboardBackground).toEqual({ kind: 'preset', id: 'mist' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Test 11c (quick-260930): scheitert das Zuruecksetzen der Wahl, ist das Bild trotzdem geloescht (kein Fehler nach aussen)', async () => {
|
||||||
|
const prisma = makeFakePrisma([makeStoredRow({ id: 'eigen' })]);
|
||||||
|
vi.mocked(prisma.user.updateMany).mockRejectedValueOnce(new Error('db weg'));
|
||||||
|
await expect(makeService(prisma).remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({
|
||||||
|
id: 'eigen',
|
||||||
|
});
|
||||||
|
expect(prisma.__rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => {
|
it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => {
|
||||||
const prisma = makeFakePrisma([]);
|
const prisma = makeFakePrisma([]);
|
||||||
const service = makeService(prisma);
|
const service = makeService(prisma);
|
||||||
@@ -370,7 +484,17 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
|||||||
// Stufe 2 vergibt der Dienst die UUID selbst und legt die Zeile gleich
|
// Stufe 2 vergibt der Dienst die UUID selbst und legt die Zeile gleich
|
||||||
// MIT Pfad an — kein nachtraegliches `update` mehr (m4n).
|
// MIT Pfad an — kein nachtraegliches `update` mehr (m4n).
|
||||||
const methods = prisma.__boundCallLog.map((c) => c.method);
|
const methods = prisma.__boundCallLog.map((c) => c.method);
|
||||||
expect(methods).toEqual(['findMany', 'count', 'create', 'findUnique', 'findUnique', 'delete']);
|
// quick-260930: `remove` setzt zusaetzlich die Hintergrund-Wahl zurueck (user.updateMany).
|
||||||
|
expect(methods).toEqual([
|
||||||
|
'findMany',
|
||||||
|
'count',
|
||||||
|
'create',
|
||||||
|
'findUnique',
|
||||||
|
'findUnique',
|
||||||
|
'delete',
|
||||||
|
'updateMany',
|
||||||
|
]);
|
||||||
|
expect(prisma.__boundCallLog.at(-1)?.model).toBe('user');
|
||||||
expect(vi.mocked(forSystem)).not.toHaveBeenCalled();
|
expect(vi.mocked(forSystem)).not.toHaveBeenCalled();
|
||||||
for (const c of prisma.__boundCallLog) {
|
for (const c of prisma.__boundCallLog) {
|
||||||
expect(c.tenantId).toBe('tenant-1');
|
expect(c.tenantId).toBe('tenant-1');
|
||||||
@@ -387,14 +511,20 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
|||||||
const onDisk = storedFile('user-1', result.id);
|
const onDisk = storedFile('user-1', result.id);
|
||||||
expect(fs.existsSync(onDisk)).toBe(true);
|
expect(fs.existsSync(onDisk)).toBe(true);
|
||||||
expect(fs.readFileSync(onDisk).equals(PNG)).toBe(true);
|
expect(fs.readFileSync(onDisk).equals(PNG)).toBe(true);
|
||||||
expect(prisma.__rows[0].storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
|
expect(prisma.__rows[0].storagePath).toBe(
|
||||||
|
`user-files/dashboard-images/user-1/${result.id}.png`,
|
||||||
|
);
|
||||||
// Die Zeile traegt den Pfad schon beim Anlegen (Pflichtfeld seit Stufe 2),
|
// Die Zeile traegt den Pfad schon beim Anlegen (Pflichtfeld seit Stufe 2),
|
||||||
// die Kennung ist eine vom Dienst vergebene UUID, und Bytes gehen nie in
|
// die Kennung ist eine vom Dienst vergebene UUID, und Bytes gehen nie in
|
||||||
// die Zeile.
|
// die Zeile.
|
||||||
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as { data: Record<string, unknown> };
|
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as {
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
};
|
||||||
expect(createArgs.data.storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
|
expect(createArgs.data.storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
|
||||||
expect(createArgs.data.id).toBe(result.id);
|
expect(createArgs.data.id).toBe(result.id);
|
||||||
expect(result.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
expect(result.id).toMatch(
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||||
|
);
|
||||||
expect(createArgs.data).not.toHaveProperty('data');
|
expect(createArgs.data).not.toHaveProperty('data');
|
||||||
expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
|
expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -444,7 +574,10 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
|||||||
// Eigene Kennung: das Verzeichnis ist ueber alle Tests dieser Datei
|
// Eigene Kennung: das Verzeichnis ist ueber alle Tests dieser Datei
|
||||||
// dasselbe, eine von Test 8/10 angelegte `img-1.png` waere sonst da.
|
// dasselbe, eine von Test 8/10 angelegte `img-1.png` waere sonst da.
|
||||||
const prisma = makeFakePrisma([
|
const prisma = makeFakePrisma([
|
||||||
makeRow({ id: 'datei-fehlt', storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png' }),
|
makeRow({
|
||||||
|
id: 'datei-fehlt',
|
||||||
|
storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png',
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
await expect(makeService(prisma).getBytes('datei-fehlt', 'user-1', 'tenant-1')).rejects.toThrow(
|
await expect(makeService(prisma).getBytes('datei-fehlt', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -456,7 +589,9 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
|||||||
const onDisk = storedFile('user-1', 'weg');
|
const onDisk = storedFile('user-1', 'weg');
|
||||||
expect(fs.existsSync(onDisk)).toBe(true);
|
expect(fs.existsSync(onDisk)).toBe(true);
|
||||||
|
|
||||||
await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'weg' });
|
await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({
|
||||||
|
id: 'weg',
|
||||||
|
});
|
||||||
expect(prisma.__rows).toHaveLength(0);
|
expect(prisma.__rows).toHaveLength(0);
|
||||||
expect(fs.existsSync(onDisk)).toBe(false);
|
expect(fs.existsSync(onDisk)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
import * as path from 'node:path';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Injectable,
|
Injectable,
|
||||||
@@ -5,12 +8,9 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import * as fs from 'node:fs/promises';
|
|
||||||
import * as path from 'node:path';
|
|
||||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import {
|
import {
|
||||||
DASHBOARD_IMAGE_MAX_COUNT,
|
DASHBOARD_IMAGE_MAX_COUNT,
|
||||||
type DashboardImageMime,
|
type DashboardImageMime,
|
||||||
@@ -314,6 +314,15 @@ export class DashboardImagesService {
|
|||||||
* Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht.
|
* Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht.
|
||||||
* Zeile zuerst, Datei danach: ein Fehler beim Entfernen der Datei wird
|
* Zeile zuerst, Datei danach: ein Fehler beim Entfernen der Datei wird
|
||||||
* protokolliert und geschluckt (T-HK4-04).
|
* protokolliert und geschluckt (T-HK4-04).
|
||||||
|
*
|
||||||
|
* quick-260930: War das Bild der Dashboard-Hintergrund des Benutzers
|
||||||
|
* (`User.dashboardBackground` = `{ kind: 'image', imageId: <diese UUID> }`),
|
||||||
|
* wird die Wahl im selben Vorgang auf „kein Hintergrund“ gesetzt — sonst
|
||||||
|
* zeigte sie auf ein Bild, das es nicht mehr gibt. Bedingtes `updateMany`
|
||||||
|
* (JSON-Pfad `imageId`), damit jede andere Wahl unberuehrt bleibt; nur die
|
||||||
|
* eigene Zeile (`id: userId`). Ein Fehler dabei wird wie beim Entfernen der
|
||||||
|
* Datei protokolliert und geschluckt: das Bild ist schon weg, und das Web
|
||||||
|
* zeigt eine Wahl mit nicht ladbarem Bild ohnehin als „kein Hintergrund“.
|
||||||
*/
|
*/
|
||||||
async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> {
|
async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> {
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
@@ -323,6 +332,19 @@ export class DashboardImagesService {
|
|||||||
}
|
}
|
||||||
await tenantPrisma.dashboardImage.delete({ where: { id } });
|
await tenantPrisma.dashboardImage.delete({ where: { id } });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await tenantPrisma.user.updateMany({
|
||||||
|
where: { id: userId, dashboardBackground: { path: ['imageId'], equals: id } },
|
||||||
|
data: { dashboardBackground: { kind: 'none' } },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Hintergrund-Wahl zum geloeschten Bilderrahmen-Bild ${id} konnte nicht zurueckgesetzt werden: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const absolute = absoluteImagePath(row.storagePath);
|
const absolute = absoluteImagePath(row.storagePath);
|
||||||
if (absolute !== null) {
|
if (absolute !== null) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
IsString,
|
IsString,
|
||||||
IsUrl,
|
IsUrl,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
|
MaxLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,6 +25,7 @@ export class CreateFavoriteDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@MaxLength(2048)
|
||||||
iconUrl?: string;
|
iconUrl?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsInt, IsOptional, IsString, IsUrl } from 'class-validator';
|
import { IsInt, IsOptional, IsString, IsUrl, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO for updating an existing FavoriteLink.
|
* DTO for updating an existing FavoriteLink.
|
||||||
@@ -19,6 +19,8 @@ export class UpdateFavoriteDto {
|
|||||||
* No strict type validation so null passes through to Prisma.
|
* No strict type validation so null passes through to Prisma.
|
||||||
*/
|
*/
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2048)
|
||||||
iconUrl?: string | null;
|
iconUrl?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
HttpException,
|
HttpException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
PayloadTooLargeException,
|
PayloadTooLargeException,
|
||||||
UnprocessableEntityException,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { FavoritesService } from './favorites.service';
|
import { FavoritesService } from './favorites.service';
|
||||||
@@ -221,7 +220,11 @@ function expectBoundCall(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeIconDiscovery(
|
function makeIconDiscovery(
|
||||||
overrides: Partial<{ discoverFavoriteIconUrl: any; fetchIconBytes: any }> = {},
|
overrides: Partial<{
|
||||||
|
discoverFavoriteIconUrl: any;
|
||||||
|
fetchIconBytes: any;
|
||||||
|
fetchPublicServiceIconBytes: any;
|
||||||
|
}> = {},
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
discoverFavoriteIconUrl:
|
discoverFavoriteIconUrl:
|
||||||
@@ -230,6 +233,11 @@ function makeIconDiscovery(
|
|||||||
fetchIconBytes:
|
fetchIconBytes:
|
||||||
overrides.fetchIconBytes ??
|
overrides.fetchIconBytes ??
|
||||||
vi.fn(async () => ({ contentType: 'image/png', body: Buffer.from('png') })),
|
vi.fn(async () => ({ contentType: 'image/png', body: Buffer.from('png') })),
|
||||||
|
fetchPublicServiceIconBytes:
|
||||||
|
overrides.fetchPublicServiceIconBytes ??
|
||||||
|
vi.fn(async () => {
|
||||||
|
throw new Error('icon service: unknown');
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,6 +520,35 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
|||||||
await expect(service.getIconBytes('t2', 'f1', 'user-a1')).rejects.toThrow(NotFoundException);
|
await expect(service.getIconBytes('t2', 'f1', 'user-a1')).rejects.toThrow(NotFoundException);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('quick-261001-hbi: gespeichertes Symbol scheitert -> Symbol-Dienst mit der Seiten-URL', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const iconDiscovery = makeIconDiscovery({
|
||||||
|
fetchIconBytes: vi.fn(async () => {
|
||||||
|
throw new Error('not an image');
|
||||||
|
}),
|
||||||
|
fetchPublicServiceIconBytes: vi.fn(async () => ({
|
||||||
|
contentType: 'image/png',
|
||||||
|
body: Buffer.from('ddg'),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
|
||||||
|
const result = await service.getIconBytes('t1', 'f1', 'user-a1');
|
||||||
|
|
||||||
|
expect(iconDiscovery.fetchPublicServiceIconBytes).toHaveBeenCalledWith(baseRow.url);
|
||||||
|
expect(result.body).toEqual(Buffer.from('ddg'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('quick-261001-hbi: gespeichertes Symbol klappt -> Symbol-Dienst wird nicht gefragt', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const iconDiscovery = makeIconDiscovery();
|
||||||
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
|
||||||
|
await service.getIconBytes('t1', 'f1', 'user-a1');
|
||||||
|
|
||||||
|
expect(iconDiscovery.fetchPublicServiceIconBytes).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('fetchIconBytes wirft -> HttpException mit Status 502', async () => {
|
it('fetchIconBytes wirft -> HttpException mit Status 502', async () => {
|
||||||
const prisma = makeFakePrisma([baseRow]);
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
const iconDiscovery = makeIconDiscovery({
|
const iconDiscovery = makeIconDiscovery({
|
||||||
@@ -859,6 +896,40 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('update — ausdrueckliche Logo-Adresse verdraengt ein hochgeladenes Symbol (260929-lh3)', () => {
|
||||||
|
const file = { buffer: PNG, originalname: 'x.png', mimetype: 'image/png', size: PNG.length };
|
||||||
|
|
||||||
|
it('neue, abweichende iconUrl bei vorhandenem Upload: Upload-Typ null, Datei weg, iconVersion erneut +1 — die neue Adresse wird angezeigt', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||||
|
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(true);
|
||||||
|
|
||||||
|
const updated = await service.update('t1', 'f1', 'user-a1', {
|
||||||
|
iconUrl: 'https://neu.invalid/logo.png',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(updated.iconUrl).toBe('https://neu.invalid/logo.png');
|
||||||
|
expect(updated.uploadedIconMime).toBeNull();
|
||||||
|
expect(updated.iconVersion).toBe(2);
|
||||||
|
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UNVERAENDERTE iconUrl bei vorhandenem Upload (das Formular schickt sie bei jedem Speichern mit): Upload bleibt', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
await service.uploadIcon('t1', 'f1', 'user-a1', file);
|
||||||
|
|
||||||
|
const updated = await service.update('t1', 'f1', 'user-a1', {
|
||||||
|
iconUrl: baseRow.iconUrl,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(updated.uploadedIconMime).toBe('image/png');
|
||||||
|
expect(updated.iconVersion).toBe(1);
|
||||||
|
expect(fs.existsSync(fileFor('user-a1', 'f1', 'png'))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('remove() mit hochgeladenem Symbol', () => {
|
describe('remove() mit hochgeladenem Symbol', () => {
|
||||||
it('Zeile und Datei weg', async () => {
|
it('Zeile und Datei weg', async () => {
|
||||||
const prisma = makeFakePrisma([baseRow]);
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
@@ -886,26 +957,50 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('create/update — Abrufprobe fuer eine explizite iconUrl (260923-lrr)', () => {
|
describe('create/update — ausdrueckliche iconUrl: nur Formpruefung, kein Abruf (260929-lh3)', () => {
|
||||||
it('create mit expliziter iconUrl: Probe genau einmal; wirft -> UnprocessableEntityException, favoriteLink.create NICHT aufgerufen', async () => {
|
const widgets = [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }];
|
||||||
const prisma = makeFakePrisma([], [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]);
|
const failingFetch = () =>
|
||||||
const iconDiscovery = makeIconDiscovery({
|
vi.fn(async () => {
|
||||||
fetchIconBytes: vi.fn(async () => {
|
throw new Error('server bekommt 404/HTML');
|
||||||
throw new Error('blocked');
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('create mit einer Adresse, die der SERVER nicht abrufen kann: wird gespeichert, KEIN Abruf, KEINE Erkennung', async () => {
|
||||||
|
const prisma = makeFakePrisma([], widgets);
|
||||||
|
const iconDiscovery = makeIconDiscovery({ fetchIconBytes: failingFetch() });
|
||||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
|
||||||
|
const created = await service.create('t1', 'user-a1', {
|
||||||
|
widgetId: 'widget-a1',
|
||||||
|
title: 'Docuvita',
|
||||||
|
url: 'https://docuvita.ctl.local/server/services/web/',
|
||||||
|
iconUrl: 'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(created.iconUrl).toBe(
|
||||||
|
'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico',
|
||||||
|
);
|
||||||
|
expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled();
|
||||||
|
expect(iconDiscovery.discoverFavoriteIconUrl).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.__favorites.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['kein http/https', 'ftp://x.invalid/icon.png'],
|
||||||
|
['javascript-Schema', 'javascript:alert(1)'],
|
||||||
|
['keine Adresse', 'kein url'],
|
||||||
|
['laenger als 2048 Zeichen', `https://x.invalid/${'a'.repeat(2050)}`],
|
||||||
|
])('create mit ungueltiger iconUrl (%s) -> BadRequestException, nichts geschrieben', async (_label, iconUrl) => {
|
||||||
|
const prisma = makeFakePrisma([], widgets);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.create('t1', 'user-a1', {
|
service.create('t1', 'user-a1', {
|
||||||
widgetId: 'widget-a1',
|
widgetId: 'widget-a1',
|
||||||
title: 'X',
|
title: 'X',
|
||||||
url: 'https://x.invalid',
|
url: 'https://x.invalid',
|
||||||
iconUrl: 'https://x.invalid/logo.png',
|
iconUrl,
|
||||||
} as any),
|
} as any),
|
||||||
).rejects.toThrow(UnprocessableEntityException);
|
).rejects.toThrow(BadRequestException);
|
||||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledTimes(1);
|
|
||||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://x.invalid/logo.png');
|
|
||||||
expect(prisma.__favorites.size).toBe(0);
|
expect(prisma.__favorites.size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -922,24 +1017,31 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
|||||||
iconVersion: 0,
|
iconVersion: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
it('update mit neuer, abweichender iconUrl: fetchIconBytes genau einmal mit dieser Adresse; wirft -> UnprocessableEntityException, favoriteLink.update NICHT aufgerufen', async () => {
|
it('update mit neuer iconUrl, die der Server nicht abrufen kann: gespeichert, iconVersion +1, KEIN Abruf', async () => {
|
||||||
const prisma = makeFakePrisma([baseRow]);
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
const iconDiscovery = makeIconDiscovery({
|
const iconDiscovery = makeIconDiscovery({ fetchIconBytes: failingFetch() });
|
||||||
fetchIconBytes: vi.fn(async () => {
|
|
||||||
throw new Error('blocked');
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
|
|
||||||
|
const updated = await service.update('t1', 'f1', 'user-a1', {
|
||||||
|
iconUrl: 'https://neu.invalid/icon.png',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(updated.iconUrl).toBe('https://neu.invalid/icon.png');
|
||||||
|
expect(updated.iconVersion).toBe(1);
|
||||||
|
expect(iconDiscovery.fetchIconBytes).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('update mit ungueltiger neuer iconUrl -> BadRequestException, Zeile unveraendert', async () => {
|
||||||
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
|
const service = new FavoritesService(prisma as any, makeIconDiscovery() as any);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.update('t1', 'f1', 'user-a1', { iconUrl: 'https://neu.invalid/icon.png' } as any),
|
service.update('t1', 'f1', 'user-a1', { iconUrl: 'file:///etc/passwd' } as any),
|
||||||
).rejects.toThrow(UnprocessableEntityException);
|
).rejects.toThrow(BadRequestException);
|
||||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledTimes(1);
|
|
||||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://neu.invalid/icon.png');
|
|
||||||
expect(prisma.__favorites.get('f1').iconUrl).toBe(baseRow.iconUrl);
|
expect(prisma.__favorites.get('f1').iconUrl).toBe(baseRow.iconUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('update mit UNVERAENDERTER iconUrl: keine Probe, keine Erhoehung', async () => {
|
it('update mit UNVERAENDERTER iconUrl: keine Pruefung, keine Erhoehung', async () => {
|
||||||
const prisma = makeFakePrisma([baseRow]);
|
const prisma = makeFakePrisma([baseRow]);
|
||||||
const iconDiscovery = makeIconDiscovery();
|
const iconDiscovery = makeIconDiscovery();
|
||||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||||
@@ -958,18 +1060,5 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
|||||||
|
|
||||||
expect(updated.iconVersion).toBe(0);
|
expect(updated.iconVersion).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('update mit neuer, erreichbarer iconUrl: iconVersion +1', async () => {
|
|
||||||
const prisma = makeFakePrisma([baseRow]);
|
|
||||||
const iconDiscovery = makeIconDiscovery();
|
|
||||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
|
||||||
|
|
||||||
const updated = await service.update('t1', 'f1', 'user-a1', {
|
|
||||||
iconUrl: 'https://neu.invalid/icon.png',
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://neu.invalid/icon.png');
|
|
||||||
expect(updated.iconVersion).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
PayloadTooLargeException,
|
PayloadTooLargeException,
|
||||||
UnprocessableEntityException,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { UploadedFileLike } from '../auth/types/auth-user';
|
import type { UploadedFileLike } from '../auth/types/auth-user';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@@ -77,12 +76,16 @@ import { IconDiscoveryService, normalizeUrl } from './icon-discovery.service';
|
|||||||
* Datei, dann die Zeile; scheitert die Zeile, wird die neue Datei wieder
|
* Datei, dann die Zeile; scheitert die Zeile, wird die neue Datei wieder
|
||||||
* entfernt. Entfernen/Loeschen aktualisiert zuerst die Zeile, ein
|
* entfernt. Entfernen/Loeschen aktualisiert zuerst die Zeile, ein
|
||||||
* Dateifehler wird protokolliert und geschluckt.
|
* Dateifehler wird protokolliert und geschluckt.
|
||||||
* - Abrufprobe: `assertIconUrlLoadable()` ruft `fetchIconBytes` einmal ab,
|
* - 260929-lh3 (loest die Abrufprobe von 260923-lrr ab): eine ausdrueckliche
|
||||||
* um eine im Formular NICHT abrufbare Logo-Adresse (z. B. hinter einer
|
* Logo-Adresse wird nur auf Form (http/https, <= 2048 Zeichen) geprueft und
|
||||||
* Cloudflare-Pruefung) mit `UnprocessableEntityException` (422) statt
|
* auch gespeichert, wenn der Server sie nicht abrufen kann — der Browser der
|
||||||
* stiller Speicherung abzuweisen — keine Umgehung von Bot-Sperren, nur
|
* Kachel laedt sie dann direkt. Ein hochgeladenes Symbol wird von einer
|
||||||
* derselbe Abruf, den `GET /favorites/:id/icon` ohnehin ausloest.
|
* neuen, abweichenden Adresse verdraengt (Vorrang der Datei sonst: Adresse
|
||||||
|
* gespeichert, aber unsichtbar).
|
||||||
*/
|
*/
|
||||||
|
/** Hoechstlaenge einer ausdruecklichen Logo-Adresse (260929-lh3). */
|
||||||
|
const ICON_URL_MAX_LENGTH = 2048;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FavoritesService {
|
export class FavoritesService {
|
||||||
private readonly logger = new Logger(FavoritesService.name);
|
private readonly logger = new Logger(FavoritesService.name);
|
||||||
@@ -107,19 +110,31 @@ export class FavoritesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prueft, ob sich das Bild unter `iconUrl` serverseitig abrufen laesst
|
* Prueft eine ausdruecklich eingetragene Logo-Adresse NUR auf Form (260929-lh3):
|
||||||
* (260923-lrr) — derselbe `fetchIconBytes`-Aufruf, den `getIconBytes`
|
* gueltige http/https-Adresse, hoechstens 2048 Zeichen. Bewusst KEIN
|
||||||
* ohnehin ausloest, hier nur zur Speicherzeit als Probe. Jeder Fehler
|
* serverseitiger Abruf mehr — Server wie docuvita liefern dem Server ein
|
||||||
* (SSRF-Ablehnung, Zeitgrenze, kein `image/*`, Cloudflare-Pruefung o. ae.)
|
* 404/HTML, dem Browser aber das Bild; die fruehere Abrufprobe (422,
|
||||||
* wird zu derselben deutschen 422-Meldung — keine Unterscheidung, aus der
|
* 260923-lrr) machte genau diese Adressen unspeicherbar. Entscheidung: auch
|
||||||
* sich etwas ueber die gepruefte Adresse ablesen liesse.
|
* eine Antwort, die der Server sieht und die kein Bild ist, weist NICHT ab —
|
||||||
|
* "Server bekommt kein Bild" heisst nicht "Browser bekommt keins", und der
|
||||||
|
* Server kann beides nicht unterscheiden. Der SSRF-Schutz bleibt unveraendert
|
||||||
|
* dort, wo der Server tatsaechlich abruft (`getIconBytes`/Erkennung); scheitert
|
||||||
|
* der Proxy, laedt die Kachel die Adresse direkt im Browser.
|
||||||
*/
|
*/
|
||||||
private async assertIconUrlLoadable(iconUrl: string): Promise<void> {
|
private assertIconUrlWellFormed(iconUrl: string): void {
|
||||||
|
let parsed: URL | null = null;
|
||||||
try {
|
try {
|
||||||
await this.iconDiscovery.fetchIconBytes(iconUrl);
|
parsed = new URL(iconUrl);
|
||||||
} catch {
|
} catch {
|
||||||
throw new UnprocessableEntityException(
|
parsed = null;
|
||||||
'Das Bild unter dieser Adresse konnte nicht geladen werden. Die Seite blockiert vermutlich automatische Abrufe (zum Beispiel durch eine Cloudflare-Prüfung) oder ist nicht erreichbar. Bitte laden Sie das Symbol stattdessen hoch.',
|
}
|
||||||
|
if (
|
||||||
|
parsed === null ||
|
||||||
|
(parsed.protocol !== 'http:' && parsed.protocol !== 'https:') ||
|
||||||
|
iconUrl.length > ICON_URL_MAX_LENGTH
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Die Logo-Adresse muss eine gültige http- oder https-Adresse sein (höchstens 2048 Zeichen).',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,8 +144,8 @@ export class FavoritesService {
|
|||||||
* Verifies the target widget belongs to the caller BEFORE any icon
|
* Verifies the target widget belongs to the caller BEFORE any icon
|
||||||
* discovery network call (T-GWH-05).
|
* discovery network call (T-GWH-05).
|
||||||
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection.
|
* If iconUrl is not provided, triggers server-side icon discovery with SSRF protection.
|
||||||
* If iconUrl IS provided (260923-lrr), it must load successfully or the
|
* If iconUrl IS provided it is stored as given after a form check only
|
||||||
* create is rejected with 422 — nothing is written on a failed probe.
|
* (260929-lh3, see assertIconUrlWellFormed) — no server-side fetch.
|
||||||
*/
|
*/
|
||||||
async create(tenantId: string, userId: string, dto: CreateFavoriteDto) {
|
async create(tenantId: string, userId: string, dto: CreateFavoriteDto) {
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
@@ -154,8 +169,8 @@ export class FavoritesService {
|
|||||||
let iconUrl = dto.iconUrl ?? null;
|
let iconUrl = dto.iconUrl ?? null;
|
||||||
|
|
||||||
if (iconUrl) {
|
if (iconUrl) {
|
||||||
// 260923-lrr: explizit uebergebene Adresse wird einmal probiert.
|
// 260929-lh3: nur Formpruefung, kein serverseitiger Abruf.
|
||||||
await this.assertIconUrlLoadable(iconUrl);
|
this.assertIconUrlWellFormed(iconUrl);
|
||||||
} else {
|
} else {
|
||||||
// Server-side icon discovery (D-05) — only when caller did not supply an icon
|
// Server-side icon discovery (D-05) — only when caller did not supply an icon
|
||||||
iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url);
|
iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url);
|
||||||
@@ -179,10 +194,11 @@ export class FavoritesService {
|
|||||||
* Verifies userId ownership before applying changes (T-08-06).
|
* Verifies userId ownership before applying changes (T-08-06).
|
||||||
* Accepts null as an explicit value for iconUrl (clears stored icon).
|
* Accepts null as an explicit value for iconUrl (clears stored icon).
|
||||||
*
|
*
|
||||||
* 260923-lrr: eine neue, vom gespeicherten Wert ABWEICHENDE `iconUrl`
|
* 260929-lh3: eine neue, vom gespeicherten Wert ABWEICHENDE `iconUrl`
|
||||||
* durchlaeuft die Abrufprobe (`assertIconUrlLoadable`), bevor irgendetwas
|
* durchlaeuft nur die Formpruefung (`assertIconUrlWellFormed`), bevor
|
||||||
* geschrieben wird; misslingt sie, bleibt die Zeile unveraendert. Jede
|
* irgendetwas geschrieben wird; sie wird auch gespeichert, wenn der Server
|
||||||
* tatsaechliche Aenderung der Symbolquelle erhoeht `iconVersion`.
|
* sie nicht abrufen kann. Jede tatsaechliche Aenderung der Symbolquelle
|
||||||
|
* erhoeht `iconVersion`.
|
||||||
*/
|
*/
|
||||||
async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) {
|
async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) {
|
||||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
@@ -205,8 +221,8 @@ export class FavoritesService {
|
|||||||
if ('iconUrl' in dto) {
|
if ('iconUrl' in dto) {
|
||||||
if (dto.iconUrl) {
|
if (dto.iconUrl) {
|
||||||
if (dto.iconUrl !== link.iconUrl) {
|
if (dto.iconUrl !== link.iconUrl) {
|
||||||
// 260923-lrr: nur eine NEUE, abweichende Adresse wird probiert.
|
// 260929-lh3: nur eine NEUE, abweichende Adresse wird geprueft (Form).
|
||||||
await this.assertIconUrlLoadable(dto.iconUrl);
|
this.assertIconUrlWellFormed(dto.iconUrl);
|
||||||
}
|
}
|
||||||
// Explicit icon URL supplied — respect it as-is.
|
// Explicit icon URL supplied — respect it as-is.
|
||||||
data.iconUrl = dto.iconUrl;
|
data.iconUrl = dto.iconUrl;
|
||||||
@@ -219,14 +235,34 @@ export class FavoritesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.iconUrl !== undefined && data.iconUrl !== link.iconUrl) {
|
// 260929-lh3: eine NEUE, ausdruecklich eingetragene Logo-Adresse muss
|
||||||
|
// Vorrang vor einem frueher hochgeladenen Symbol haben. `getIconBytes`
|
||||||
|
// liefert bei gesetztem `uploadedIconMime` IMMER die Datei — ohne diesen
|
||||||
|
// Schritt blieb die neue Adresse gespeichert, aber unsichtbar (die Kachel
|
||||||
|
// zeigte weiter das alte hochgeladene Bild). Nur bei einer tatsaechlichen
|
||||||
|
// Aenderung: das Formular schickt die unveraenderte Adresse bei jedem
|
||||||
|
// Speichern mit, das darf ein hochgeladenes Symbol nicht verdraengen.
|
||||||
|
const iconUrlChanged = data.iconUrl !== undefined && data.iconUrl !== link.iconUrl;
|
||||||
|
const explicitUrlReplacesUpload =
|
||||||
|
iconUrlChanged && Boolean(dto.iconUrl) && link.uploadedIconMime !== null;
|
||||||
|
if (explicitUrlReplacesUpload) {
|
||||||
|
data.uploadedIconMime = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iconUrlChanged) {
|
||||||
data.iconVersion = { increment: 1 };
|
data.iconVersion = { increment: 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
return tenantPrisma.favoriteLink.update({
|
const updated = await tenantPrisma.favoriteLink.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (explicitUrlReplacesUpload && link.uploadedIconMime !== null) {
|
||||||
|
await this.removeIconFile(id, link.userId, link.uploadedIconMime, 'ersetzte');
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -461,7 +497,9 @@ export class FavoritesService {
|
|||||||
* Throws NotFoundException (404) if the row doesn't exist, isn't owned
|
* Throws NotFoundException (404) if the row doesn't exist, isn't owned
|
||||||
* by the caller, or has neither an uploaded icon nor a stored iconUrl.
|
* by the caller, or has neither an uploaded icon nor a stored iconUrl.
|
||||||
* Throws a 502 HttpException if the upstream fetch fails (unreachable,
|
* Throws a 502 HttpException if the upstream fetch fails (unreachable,
|
||||||
* timeout, non-image, or SSRF-blocked) -- never returns a placeholder image.
|
* timeout, non-image, or SSRF-blocked) AND the public icon service fallback
|
||||||
|
* (quick-261001-hbi, public pages only) has no icon either -- never returns
|
||||||
|
* a placeholder image.
|
||||||
*/
|
*/
|
||||||
async getIconBytes(
|
async getIconBytes(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
@@ -500,7 +538,14 @@ export class FavoritesService {
|
|||||||
try {
|
try {
|
||||||
return await this.iconDiscovery.fetchIconBytes(link.iconUrl);
|
return await this.iconDiscovery.fetchIconBytes(link.iconUrl);
|
||||||
} catch {
|
} catch {
|
||||||
throw new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY);
|
// quick-261001-hbi: Seite liefert kein abrufbares Symbol (z. B. per
|
||||||
|
// JavaScript gesetzt) -- einmal beim oeffentlichen Symbol-Dienst fragen,
|
||||||
|
// nur fuer oeffentlich erreichbare Seiten.
|
||||||
|
try {
|
||||||
|
return await this.iconDiscovery.fetchPublicServiceIconBytes(link.url);
|
||||||
|
} catch {
|
||||||
|
throw new HttpException('Icon fetch failed', HttpStatus.BAD_GATEWAY);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,24 +18,21 @@ vi.mock('undici', () => ({
|
|||||||
|
|
||||||
import { Agent } from 'undici';
|
import { Agent } from 'undici';
|
||||||
import {
|
import {
|
||||||
|
discardBody,
|
||||||
IconDiscoveryService,
|
IconDiscoveryService,
|
||||||
isPublicHttpUrl,
|
isPublicHttpUrl,
|
||||||
normalizeUrl,
|
normalizeUrl,
|
||||||
|
readTextCapped,
|
||||||
} from './icon-discovery.service';
|
} from './icon-discovery.service';
|
||||||
|
|
||||||
function mockResponse(options: {
|
function mockResponse(options: { contentType?: string; body?: ArrayBuffer }): Response {
|
||||||
contentType?: string;
|
|
||||||
body?: ArrayBuffer;
|
|
||||||
}): Response {
|
|
||||||
const body = options.body ?? new ArrayBuffer(10);
|
const body = options.body ?? new ArrayBuffer(10);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
get: (name: string) =>
|
get: (name: string) =>
|
||||||
name.toLowerCase() === 'content-type'
|
name.toLowerCase() === 'content-type' ? (options.contentType ?? 'image/png') : null,
|
||||||
? (options.contentType ?? 'image/png')
|
|
||||||
: null,
|
|
||||||
},
|
},
|
||||||
arrayBuffer: async () => body,
|
arrayBuffer: async () => body,
|
||||||
} as unknown as Response;
|
} as unknown as Response;
|
||||||
@@ -106,9 +103,7 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl', () => {
|
|||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
get: (n: string) =>
|
get: (n: string) =>
|
||||||
n.toLowerCase() === 'content-type'
|
n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null,
|
||||||
? 'text/html; charset=utf-8'
|
|
||||||
: null,
|
|
||||||
},
|
},
|
||||||
text: async () => html,
|
text: async () => html,
|
||||||
}),
|
}),
|
||||||
@@ -133,6 +128,58 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('IconDiscoveryService.discoverFavoriteIconUrl — Seite mit Fehlerstatus (260929-lh3)', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function htmlResponse(status: number, html: string) {
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
get: (n: string) =>
|
||||||
|
n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null,
|
||||||
|
},
|
||||||
|
text: async () => html,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('Seite antwortet 400, traegt aber <link rel="SHORTCUT ICON"> (docuvita) -> dieser Verweis wird genutzt', async () => {
|
||||||
|
const html =
|
||||||
|
'<html><head><link rel="SHORTCUT ICON" type="image/png" href="/webclient/docuvita/resources/brandimage/favicon.ico" /></head></html>';
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(htmlResponse(400, html)));
|
||||||
|
|
||||||
|
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl(
|
||||||
|
'http://8.8.8.8/server/services/web/',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(icon).toBe('http://8.8.8.8/webclient/docuvita/resources/brandimage/favicon.ico');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehlerseite ohne Symbol-Verweis, nur og:image -> Rueckfall <origin>/favicon.ico (og:image einer Fehlerseite zaehlt nicht)', async () => {
|
||||||
|
const html =
|
||||||
|
'<html><head><meta property="og:image" content="https://cdn.invalid/x.png"></head></html>';
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(htmlResponse(404, html)));
|
||||||
|
|
||||||
|
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/x');
|
||||||
|
|
||||||
|
expect(icon).toBe('http://8.8.8.8/favicon.ico');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetchIconBytes bleibt streng: Fehlerstatus -> wirft (kein allowErrorStatus fuer Bilder)', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({ ...htmlResponse(404, ''), headers: { get: () => 'text/html' } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
new IconDiscoveryService().fetchIconBytes('http://8.8.8.8/favicon.ico'),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('IconDiscoveryService.fetchIconBytes', () => {
|
describe('IconDiscoveryService.fetchIconBytes', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@@ -155,16 +202,13 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects when Content-Type is not an image', async () => {
|
it('rejects when Content-Type is not an image', async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })));
|
||||||
'fetch',
|
|
||||||
vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })),
|
|
||||||
);
|
|
||||||
|
|
||||||
const service = new IconDiscoveryService();
|
const service = new IconDiscoveryService();
|
||||||
|
|
||||||
await expect(
|
await expect(service.fetchIconBytes('http://8.8.8.8/favicon.ico')).rejects.toThrow(
|
||||||
service.fetchIconBytes('http://8.8.8.8/favicon.ico'),
|
/not an image/,
|
||||||
).rejects.toThrow(/not an image/);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects when the SSRF guard blocks the target', async () => {
|
it('rejects when the SSRF guard blocks the target', async () => {
|
||||||
@@ -173,9 +217,9 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
|
|||||||
|
|
||||||
const service = new IconDiscoveryService();
|
const service = new IconDiscoveryService();
|
||||||
|
|
||||||
await expect(
|
await expect(service.fetchIconBytes('http://127.0.0.1/favicon.ico')).rejects.toThrow(
|
||||||
service.fetchIconBytes('http://127.0.0.1/favicon.ico'),
|
/blocked or failed/,
|
||||||
).rejects.toThrow(/blocked or failed/);
|
);
|
||||||
expect(fetchSpy).not.toHaveBeenCalled();
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,9 +232,60 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
|
|||||||
|
|
||||||
const service = new IconDiscoveryService();
|
const service = new IconDiscoveryService();
|
||||||
|
|
||||||
|
await expect(service.fetchIconBytes('http://8.8.8.8/favicon.ico')).rejects.toThrow(
|
||||||
|
/size limit/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('IconDiscoveryService.fetchPublicServiceIconBytes (quick-261001-hbi)', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fragt fuer eine oeffentliche Seite den Symbol-Dienst mit dem Hostnamen', async () => {
|
||||||
|
const fetchSpy = vi.fn().mockResolvedValue(mockResponse({ contentType: 'image/png' }));
|
||||||
|
vi.stubGlobal('fetch', fetchSpy);
|
||||||
|
|
||||||
|
const service = new IconDiscoveryService();
|
||||||
|
const result = await service.fetchPublicServiceIconBytes('http://8.8.8.8/start');
|
||||||
|
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchSpy.mock.calls[0][0]).toBe('https://icons.duckduckgo.com/ip3/8.8.8.8.ico');
|
||||||
|
expect(result.contentType).toBe('image/png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fragt fuer eine interne Seite NICHT (Hostname verlaesst das Haus nicht)', async () => {
|
||||||
|
const fetchSpy = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchSpy);
|
||||||
|
|
||||||
|
const service = new IconDiscoveryService();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.fetchIconBytes('http://8.8.8.8/favicon.ico'),
|
service.fetchPublicServiceIconBytes('https://docuvita.ctl.local/x'),
|
||||||
).rejects.toThrow(/size limit/);
|
).rejects.toThrow(/not public/);
|
||||||
|
await expect(service.fetchPublicServiceIconBytes('http://192.168.1.5/')).rejects.toThrow(
|
||||||
|
/not public/,
|
||||||
|
);
|
||||||
|
expect(fetchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Dienst kennt kein Symbol (404) -> wirft', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
...mockResponse({ contentType: 'image/png' }),
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new IconDiscoveryService();
|
||||||
|
|
||||||
|
await expect(service.fetchPublicServiceIconBytes('http://8.8.8.8/')).rejects.toThrow(
|
||||||
|
/blocked or failed/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -210,10 +305,7 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', (
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('still returns a URL string', async () => {
|
it('still returns a URL string', async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })));
|
||||||
'fetch',
|
|
||||||
vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })),
|
|
||||||
);
|
|
||||||
|
|
||||||
const service = new IconDiscoveryService();
|
const service = new IconDiscoveryService();
|
||||||
const result = await service.discoverFavoriteIconUrl('http://8.8.8.8/page');
|
const result = await service.discoverFavoriteIconUrl('http://8.8.8.8/page');
|
||||||
@@ -287,3 +379,143 @@ describe('IconDiscoveryService — Dispatcher (260917-jdd)', () => {
|
|||||||
expect(calls[0][1].dispatcher).toBe(calls[1][1].dispatcher);
|
expect(calls[0][1].dispatcher).toBe(calls[1][1].dispatcher);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('readTextCapped / discardBody — Groessendeckel beim Lesen (T-08-09)', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Stream aus `chunks` Stuecken je `chunkChars` ASCII-Zeichen; zaehlt gelesene Stuecke und Abbruch. */
|
||||||
|
function countingStream(chunks: number, chunkChars: number) {
|
||||||
|
const state = { pulled: 0, cancelled: false };
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
pull(controller) {
|
||||||
|
if (state.pulled >= chunks) {
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.pulled += 1;
|
||||||
|
controller.enqueue(encoder.encode('a'.repeat(chunkChars)));
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
state.cancelled = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { body, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('bricht den Stream nach der Grenze ab statt alles zu lesen', async () => {
|
||||||
|
const { body, state } = countingStream(1000, 1000);
|
||||||
|
const text = await readTextCapped({ body, text: async () => 'unbenutzt' } as never, 2500);
|
||||||
|
|
||||||
|
expect(text).toHaveLength(2500);
|
||||||
|
expect(state.pulled).toBeLessThan(10);
|
||||||
|
expect(state.cancelled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gibt nach der Zeitgrenze zurueck, was bis dahin da ist (tropfender Server)', async () => {
|
||||||
|
let cancelled = false;
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new TextEncoder().encode('<link rel="icon">'));
|
||||||
|
// danach kommt nichts mehr, der Stream bleibt offen
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
cancelled = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await readTextCapped({ body, text: async () => '' } as never, 200000, 50);
|
||||||
|
|
||||||
|
expect(text).toBe('<link rel="icon">');
|
||||||
|
expect(cancelled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liest kurze Seiten vollstaendig, auch Mehrbyte-Zeichen ueber Chunk-Grenzen', async () => {
|
||||||
|
const bytes = new TextEncoder().encode('<p>Grüße</p>');
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
// Das "ü" (2 Bytes) wird absichtlich zerteilt.
|
||||||
|
controller.enqueue(bytes.slice(0, 5));
|
||||||
|
controller.enqueue(bytes.slice(5));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await readTextCapped({ body, text: async () => '' } as never, 200000);
|
||||||
|
|
||||||
|
expect(text).toBe('<p>Grüße</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Stream: Rueckfall auf text() mit Deckel', async () => {
|
||||||
|
const text = await readTextCapped(
|
||||||
|
{ body: null, text: async () => 'x'.repeat(50) } as never,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(text).toBe('x'.repeat(10));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discardBody bricht einen offenen Body ab und vertraegt fehlenden Body', () => {
|
||||||
|
const { body, state } = countingStream(5, 10);
|
||||||
|
discardBody({ body } as never);
|
||||||
|
expect(state.cancelled).toBe(true);
|
||||||
|
expect(() => discardBody({ body: null } as never)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Discovery: Fehlerstatus ohne HTML-Typ -> Body wird verworfen, Rueckfall favicon.ico', async () => {
|
||||||
|
const { body, state } = countingStream(5, 10);
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
get: (n: string) => (n.toLowerCase() === 'content-type' ? 'application/json' : null),
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/x');
|
||||||
|
|
||||||
|
expect(icon).toBe('http://8.8.8.8/favicon.ico');
|
||||||
|
expect(state.cancelled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Discovery: riesige HTML-Seite wird nur bis zur Grenze gelesen, Symbol am Anfang gefunden', async () => {
|
||||||
|
const head = '<html><head><link rel="icon" href="/klein.png" /></head><body>';
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const state = { pulled: 0, cancelled: false };
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
pull(controller) {
|
||||||
|
state.pulled += 1;
|
||||||
|
controller.enqueue(encoder.encode(state.pulled === 1 ? head : 'a'.repeat(64 * 1024)));
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
state.cancelled = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
headers: { get: (n: string) => (n.toLowerCase() === 'content-type' ? 'text/html' : null) },
|
||||||
|
body,
|
||||||
|
text: async () => {
|
||||||
|
throw new Error('text() darf bei vorhandenem Stream nicht laufen');
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/');
|
||||||
|
|
||||||
|
expect(icon).toBe('http://8.8.8.8/klein.png');
|
||||||
|
expect(state.cancelled).toBe(true);
|
||||||
|
// 200 000 Zeichen bei 64-KiB-Stuecken: hoechstens eine Handvoll gelesen.
|
||||||
|
expect(state.pulled).toBeLessThan(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { lookup } from 'node:dns/promises';
|
import { lookup } from 'node:dns/promises';
|
||||||
import { isIP } from 'node:net';
|
import { isIP } from 'node:net';
|
||||||
import { Agent, fetch as undiciFetch, type Response as UndiciResponse } from 'undici';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { Agent, type Response as UndiciResponse, fetch as undiciFetch } from 'undici';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side favicon / icon discovery with SSRF protection (T-08-05).
|
* Server-side favicon / icon discovery with SSRF protection (T-08-05).
|
||||||
@@ -25,6 +25,18 @@ const MAX_REDIRECTS = 2;
|
|||||||
const MAX_HTML_CHARS = 200000;
|
const MAX_HTML_CHARS = 200000;
|
||||||
const MAX_ICON_BYTES = 1_000_000;
|
const MAX_ICON_BYTES = 1_000_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* quick-261001-hbi — oeffentlicher Symbol-Dienst als letzter Rueckfall. Manche
|
||||||
|
* Seiten setzen ihr Symbol erst per JavaScript (hosteurope.de: im HTML nur
|
||||||
|
* `<link rel="icon" href="data:;base64,=">`, `/favicon.ico` liefert eine
|
||||||
|
* HTML-Seite) — ohne Browser findet die Suche dort nichts. DuckDuckGo kennt
|
||||||
|
* das gerenderte Symbol und antwortet fuer Unbekanntes mit 404 (dann bleibt
|
||||||
|
* der Buchstabe). Gefragt wird NUR fuer oeffentlich erreichbare Adressen,
|
||||||
|
* damit interne Hostnamen (docuvita.ctl.local, private IPs) das Haus nie
|
||||||
|
* verlassen; der Dienst erfaehrt nur den Hostnamen.
|
||||||
|
*/
|
||||||
|
const PUBLIC_ICON_SERVICE = 'https://icons.duckduckgo.com/ip3/';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 260917-jdd — Ziel ist ein Bildchen, kein Geheimnis: selbstsignierte,
|
* 260917-jdd — Ziel ist ein Bildchen, kein Geheimnis: selbstsignierte,
|
||||||
* abgelaufene oder falsch benannte Zertifikate sollen das Symbol eines
|
* abgelaufene oder falsch benannte Zertifikate sollen das Symbol eines
|
||||||
@@ -49,6 +61,8 @@ const LENIENT_TLS_AGENT = new Agent({ connect: { rejectUnauthorized: false } });
|
|||||||
type FetchHtmlResult = {
|
type FetchHtmlResult = {
|
||||||
html: string;
|
html: string;
|
||||||
finalUrl: string;
|
finalUrl: string;
|
||||||
|
/** false = die Seite antwortete mit einem Fehlerstatus (z. B. 400/404), lieferte aber HTML (260929-lh3). */
|
||||||
|
ok: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isPrivateIpv4(address: string): boolean {
|
function isPrivateIpv4(address: string): boolean {
|
||||||
@@ -56,9 +70,7 @@ function isPrivateIpv4(address: string): boolean {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
parts.length !== 4 ||
|
parts.length !== 4 ||
|
||||||
parts.some(
|
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
|
||||||
(part) => !Number.isInteger(part) || part < 0 || part > 255,
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -115,12 +127,7 @@ function isPrivateIpAddress(address: string): boolean {
|
|||||||
function isBlockedHostname(hostname: string): boolean {
|
function isBlockedHostname(hostname: string): boolean {
|
||||||
const h = hostname.trim().toLowerCase();
|
const h = hostname.trim().toLowerCase();
|
||||||
|
|
||||||
return (
|
return h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h === '0.0.0.0';
|
||||||
h === 'localhost' ||
|
|
||||||
h.endsWith('.localhost') ||
|
|
||||||
h.endsWith('.local') ||
|
|
||||||
h === '0.0.0.0'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isPublicHttpUrl(url: URL): Promise<boolean> {
|
export async function isPublicHttpUrl(url: URL): Promise<boolean> {
|
||||||
@@ -202,7 +209,7 @@ function toAbsoluteUrl(value: string | undefined, base: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractIconFromHtml(html: string, baseUrl: string): string | null {
|
function extractIconFromHtml(html: string, baseUrl: string, linkTagsOnly = false): string | null {
|
||||||
const linkTags = html.match(/<link\b[^>]*>/gi) ?? [];
|
const linkTags = html.match(/<link\b[^>]*>/gi) ?? [];
|
||||||
const metaTags = html.match(/<meta\b[^>]*>/gi) ?? [];
|
const metaTags = html.match(/<meta\b[^>]*>/gi) ?? [];
|
||||||
|
|
||||||
@@ -214,30 +221,26 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null {
|
|||||||
}))
|
}))
|
||||||
.filter((c) => c.href);
|
.filter((c) => c.href);
|
||||||
|
|
||||||
const appleTouchIcon = linkCandidates.find((c) =>
|
const appleTouchIcon = linkCandidates.find((c) => c.rel.includes('apple-touch-icon'))?.href;
|
||||||
c.rel.includes('apple-touch-icon'),
|
|
||||||
)?.href;
|
|
||||||
|
|
||||||
if (appleTouchIcon) return appleTouchIcon;
|
if (appleTouchIcon) return appleTouchIcon;
|
||||||
|
|
||||||
const icon = linkCandidates.find((c) =>
|
const icon = linkCandidates.find((c) => c.rel.split(/\s+/).includes('icon'))?.href;
|
||||||
c.rel.split(/\s+/).includes('icon'),
|
|
||||||
)?.href;
|
|
||||||
|
|
||||||
if (icon) return icon;
|
if (icon) return icon;
|
||||||
|
|
||||||
const shortcutIcon = linkCandidates.find((c) =>
|
const shortcutIcon = linkCandidates.find((c) => c.rel.includes('shortcut icon'))?.href;
|
||||||
c.rel.includes('shortcut icon'),
|
|
||||||
)?.href;
|
|
||||||
|
|
||||||
if (shortcutIcon) return shortcutIcon;
|
if (shortcutIcon) return shortcutIcon;
|
||||||
|
|
||||||
const imageSrc = linkCandidates.find((c) =>
|
const imageSrc = linkCandidates.find((c) => c.rel.includes('image_src'))?.href;
|
||||||
c.rel.includes('image_src'),
|
|
||||||
)?.href;
|
|
||||||
|
|
||||||
if (imageSrc) return imageSrc;
|
if (imageSrc) return imageSrc;
|
||||||
|
|
||||||
|
// 260929-lh3: eine Fehlerseite (Status != 2xx) traegt kein Vorschaubild der
|
||||||
|
// Seite — nur die ausdruecklichen Symbol-Verweise (<link rel=...icon>) zaehlen.
|
||||||
|
if (linkTagsOnly) return null;
|
||||||
|
|
||||||
const metaImage = metaTags
|
const metaImage = metaTags
|
||||||
.map((tag) => parseAttributes(tag))
|
.map((tag) => parseAttributes(tag))
|
||||||
.map((a) => ({
|
.map((a) => ({
|
||||||
@@ -247,9 +250,7 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null {
|
|||||||
.find(
|
.find(
|
||||||
(c) =>
|
(c) =>
|
||||||
c.content &&
|
c.content &&
|
||||||
(c.property === 'og:image' ||
|
(c.property === 'og:image' || c.property === 'og:logo' || c.property === 'twitter:image'),
|
||||||
c.property === 'og:logo' ||
|
|
||||||
c.property === 'twitter:image'),
|
|
||||||
)?.content;
|
)?.content;
|
||||||
|
|
||||||
return metaImage ?? null;
|
return metaImage ?? null;
|
||||||
@@ -266,7 +267,13 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null {
|
|||||||
*/
|
*/
|
||||||
async function fetchWithRedirectGuard(
|
async function fetchWithRedirectGuard(
|
||||||
pageUrl: URL,
|
pageUrl: URL,
|
||||||
options: { accept: string; timeoutMs: number; userAgent?: string },
|
options: {
|
||||||
|
accept: string;
|
||||||
|
timeoutMs: number;
|
||||||
|
userAgent?: string;
|
||||||
|
/** 260929-lh3: auch eine 4xx/5xx-Antwort zurueckgeben (nur fuer die HTML-Suche). */
|
||||||
|
allowErrorStatus?: boolean;
|
||||||
|
},
|
||||||
): Promise<{ response: UndiciResponse; finalUrl: URL } | null> {
|
): Promise<{ response: UndiciResponse; finalUrl: URL } | null> {
|
||||||
let currentUrl = pageUrl;
|
let currentUrl = pageUrl;
|
||||||
|
|
||||||
@@ -298,7 +305,7 @@ async function fetchWithRedirectGuard(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) return null;
|
if (!response.ok && !options.allowErrorStatus) return null;
|
||||||
|
|
||||||
return { response, finalUrl: currentUrl };
|
return { response, finalUrl: currentUrl };
|
||||||
} catch {
|
} catch {
|
||||||
@@ -311,23 +318,98 @@ async function fetchWithRedirectGuard(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Minimaler Ausschnitt einer Antwort, den die beiden Helfer brauchen. */
|
||||||
|
type BodyResponse = Pick<UndiciResponse, 'body' | 'text'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verwirft den Body einer nicht gebrauchten Antwort. Fehler (bereits
|
||||||
|
* gelesen/abgebrochen) sind egal.
|
||||||
|
*/
|
||||||
|
export function discardBody(response: Pick<UndiciResponse, 'body'>): void {
|
||||||
|
try {
|
||||||
|
response.body?.cancel().catch(() => {});
|
||||||
|
} catch {
|
||||||
|
// Body gesperrt oder schon verbraucht — nichts zu tun.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest den Antworttext hoechstens bis `maxChars` Zeichen und bricht den
|
||||||
|
* Stream danach ab (T-08-09). Vorher wurde der komplette Body gelesen und
|
||||||
|
* erst danach abgeschnitten — eine riesige Seite landete ganz im Speicher.
|
||||||
|
* Dekodiert wird UTF-8 wie bei `Response.text()`; da jedes Zeichen aus
|
||||||
|
* mindestens einem Byte entsteht, bleibt der Speicher bei ~maxChars plus
|
||||||
|
* einem Chunk. Ohne Stream (`body === null`) wie bisher ueber `text()`.
|
||||||
|
* `timeoutMs` begrenzt zusaetzlich die Lesedauer: die Zeitgrenze von
|
||||||
|
* `fetchWithRedirectGuard` endet mit den Kopfzeilen, ein Server, der den
|
||||||
|
* Body tropfenweise liefert, hielte die Anfrage sonst beliebig lange auf.
|
||||||
|
* Nach Ablauf zaehlt, was bis dahin gelesen ist.
|
||||||
|
*/
|
||||||
|
export async function readTextCapped(
|
||||||
|
response: BodyResponse,
|
||||||
|
maxChars: number,
|
||||||
|
timeoutMs = HTML_FETCH_TIMEOUT_MS,
|
||||||
|
): Promise<string> {
|
||||||
|
if (!response.body) {
|
||||||
|
return (await response.text()).slice(0, maxChars);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let text = '';
|
||||||
|
// cancel() beendet ein haengendes read() mit done: true.
|
||||||
|
const deadline = setTimeout(() => void reader.cancel().catch(() => {}), timeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
text += decoder.decode();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
text += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
if (text.length >= maxChars) {
|
||||||
|
await reader.cancel().catch(() => {});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(deadline);
|
||||||
|
}
|
||||||
|
|
||||||
|
return text.slice(0, maxChars);
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
|
async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
|
||||||
const result = await fetchWithRedirectGuard(pageUrl, {
|
const result = await fetchWithRedirectGuard(pageUrl, {
|
||||||
accept: 'text/html,application/xhtml+xml,*/*',
|
accept: 'text/html,application/xhtml+xml,*/*',
|
||||||
timeoutMs: HTML_FETCH_TIMEOUT_MS,
|
timeoutMs: HTML_FETCH_TIMEOUT_MS,
|
||||||
|
// 260929-lh3: Server wie docuvita antworten dem Server mit 400, tragen im
|
||||||
|
// HTML aber trotzdem den <link rel=icon> — den Verweis wollen wir haben.
|
||||||
|
allowErrorStatus: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result) return null;
|
if (!result) return null;
|
||||||
|
|
||||||
const contentType = result.response.headers.get('content-type') ?? '';
|
const contentType = result.response.headers.get('content-type') ?? '';
|
||||||
|
|
||||||
if (!contentType.toLowerCase().includes('text/html')) return null;
|
if (!contentType.toLowerCase().includes('text/html')) {
|
||||||
|
// Kein HTML (auch bei Fehlerstatus dank allowErrorStatus hier moeglich):
|
||||||
|
// Body verwerfen, sonst haelt undici die Verbindung bis zum Timeout offen.
|
||||||
|
discardBody(result.response);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const html = await result.response.text();
|
// T-08-09: HTML cap — schon beim Lesen, nicht erst nach dem kompletten Body.
|
||||||
|
const html = await readTextCapped(result.response, MAX_HTML_CHARS);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: html.slice(0, MAX_HTML_CHARS), // T-08-09: HTML cap
|
html,
|
||||||
finalUrl: result.finalUrl.toString(),
|
finalUrl: result.finalUrl.toString(),
|
||||||
|
ok: result.response.ok,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,12 +432,28 @@ export class IconDiscoveryService {
|
|||||||
|
|
||||||
if (!htmlResult) return fallback;
|
if (!htmlResult) return fallback;
|
||||||
|
|
||||||
return extractIconFromHtml(htmlResult.html, htmlResult.finalUrl) ?? fallback;
|
return extractIconFromHtml(htmlResult.html, htmlResult.finalUrl, !htmlResult.ok) ?? fallback;
|
||||||
} catch {
|
} catch {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* quick-261001-hbi: Symbol fuer die Seite `pageUrl` beim oeffentlichen
|
||||||
|
* Symbol-Dienst holen (siehe PUBLIC_ICON_SERVICE). Wirft, wenn die Seite
|
||||||
|
* nicht oeffentlich erreichbar ist (dann wird der Dienst NICHT gefragt) oder
|
||||||
|
* der Dienst kein Symbol kennt (404) — wie `fetchIconBytes`.
|
||||||
|
*/
|
||||||
|
async fetchPublicServiceIconBytes(
|
||||||
|
pageUrl: string,
|
||||||
|
): Promise<{ contentType: string; body: Buffer }> {
|
||||||
|
const page = new URL(normalizeUrl(pageUrl));
|
||||||
|
if (!(await isPublicHttpUrl(page))) {
|
||||||
|
throw new Error('Page is not public, icon service not asked');
|
||||||
|
}
|
||||||
|
return this.fetchIconBytes(`${PUBLIC_ICON_SERVICE}${page.hostname}.ico`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the raw bytes of a stored icon URL, SSRF-guarded, for streaming
|
* Fetch the raw bytes of a stored icon URL, SSRF-guarded, for streaming
|
||||||
* back to the browser from Tessera's own origin (avoids Cross-Origin-
|
* back to the browser from Tessera's own origin (avoids Cross-Origin-
|
||||||
@@ -365,9 +463,7 @@ export class IconDiscoveryService {
|
|||||||
* or an oversized body. Callers must not return a placeholder image; let
|
* or an oversized body. Callers must not return a placeholder image; let
|
||||||
* the caller map the failure to an HTTP error status instead.
|
* the caller map the failure to an HTTP error status instead.
|
||||||
*/
|
*/
|
||||||
async fetchIconBytes(
|
async fetchIconBytes(iconUrl: string): Promise<{ contentType: string; body: Buffer }> {
|
||||||
iconUrl: string,
|
|
||||||
): Promise<{ contentType: string; body: Buffer }> {
|
|
||||||
const url = new URL(iconUrl);
|
const url = new URL(iconUrl);
|
||||||
|
|
||||||
const result = await fetchWithRedirectGuard(url, {
|
const result = await fetchWithRedirectGuard(url, {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { VersionResponse } from '@tessera/shared';
|
import { parseReleaseVersion, type VersionResponse } from '@tessera/shared';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Versionsstempel der API (quick-260914-ku1).
|
* Versionsstempel der API (quick-260914-ku1).
|
||||||
@@ -30,3 +30,24 @@ export function formatAppVersionLine(v: VersionResponse = getAppVersion()): stri
|
|||||||
const base = `Tessera API ${v.version} (${v.channel})`;
|
const base = `Tessera API ${v.version} (${v.channel})`;
|
||||||
return v.commit ? `${base} ${v.commit}` : base;
|
return v.commit ? `${base} ${v.commit}` : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laufende FREIGEGEBENE Version der API als `X.Y.Z` oder `null`
|
||||||
|
* (quick-260925-bow, D-02/D-04).
|
||||||
|
*
|
||||||
|
* Einzige Quelle der laufenden Version fuer das "Was ist neu"-Fenster:
|
||||||
|
* `APP_VERSION` der API. Warum die API und nicht das Web: zwei der drei
|
||||||
|
* Anlagewege neuer Benutzer laufen ohne jede Web-Anfrage (LDAP-Abgleich per
|
||||||
|
* Zeitplan, Erst-Administrator beim API-Start) und tragen die Version bei der
|
||||||
|
* Anlage ein; ausserdem prueft `POST /users/me/release-seen` gegen diesen
|
||||||
|
* Wert. Das Web nimmt `currentRelease` aus `GET /users/me/release-notice`
|
||||||
|
* und wertet seine eigene `NEXT_PUBLIC_APP_VERSION` dafuer nicht aus. Beide
|
||||||
|
* Abbilder bekommen im CI denselben `APP_VERSION`-Wert
|
||||||
|
* (`.gitea/scripts/publish-images.sh`).
|
||||||
|
*
|
||||||
|
* `v1.4.0-5-gabc1234` (Beta, Describe-Stand) → `1.4.0`; `dev` oder ein
|
||||||
|
* blosser Commit-Stempel → `null` (dann erscheint nie ein Fenster).
|
||||||
|
*/
|
||||||
|
export function getRunningRelease(): string | null {
|
||||||
|
return parseReleaseVersion(getAppVersion().version);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { compareReleaseVersions, parseReleaseVersion } from '@tessera/shared';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getRunningRelease } from './app-version';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Freigegebene Versionen (quick-260925-bow, D-02/D-06).
|
||||||
|
*
|
||||||
|
* `parseReleaseVersion` und `compareReleaseVersions` stehen EINMAL in
|
||||||
|
* `packages/shared/src/index.ts` und werden von API und Web benutzt. Getestet
|
||||||
|
* wird hier in der API-Suite, weil `packages/shared` keinen eigenen Testlauf
|
||||||
|
* hat (Vorbild `widget-module-map.spec.ts`).
|
||||||
|
*
|
||||||
|
* `getRunningRelease()` ist die einzige Quelle der laufenden Version fuer das
|
||||||
|
* "Was ist neu"-Fenster: `APP_VERSION` der API, auf X.Y.Z gekuerzt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseReleaseVersion (quick-260925-bow)', () => {
|
||||||
|
it.each([
|
||||||
|
['v10.2.3', '10.2.3'],
|
||||||
|
['10.2.3', '10.2.3'],
|
||||||
|
['v10.2.3-5-gabc1234', '10.2.3'],
|
||||||
|
['10.2.3-12-g0123456789abcdef', '10.2.3'],
|
||||||
|
['010.02.3', '10.2.3'],
|
||||||
|
['1.4.0', '1.4.0'],
|
||||||
|
])('%s → %s', (raw, expected) => {
|
||||||
|
expect(parseReleaseVersion(raw)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['dev'],
|
||||||
|
[''],
|
||||||
|
['abc1234'],
|
||||||
|
['10.2'],
|
||||||
|
['10.2.3-rc.1'],
|
||||||
|
['10.2.3-dirty'],
|
||||||
|
[' 10.2.3'],
|
||||||
|
['10.2.3 '],
|
||||||
|
['V10.2.3'],
|
||||||
|
['vv10.2.3'],
|
||||||
|
['10.2.3.4'],
|
||||||
|
['1234567.0.0'],
|
||||||
|
['10.2.3-5-gxyz1234'],
|
||||||
|
['10.2.3-5-gabc'],
|
||||||
|
['10.2.3-g0123456'],
|
||||||
|
])('%j → null', (raw) => {
|
||||||
|
expect(parseReleaseVersion(raw)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Eingaben ueber 64 Zeichen ergeben null, auch wenn das Muster sonst passen wuerde', () => {
|
||||||
|
const long = `1.2.3-5-g${'a'.repeat(40)}`;
|
||||||
|
expect(long.length).toBeLessThanOrEqual(64);
|
||||||
|
expect(parseReleaseVersion(long)).toBe('1.2.3');
|
||||||
|
expect(parseReleaseVersion(`${'1'.repeat(70)}.0.0`)).toBeNull();
|
||||||
|
expect(parseReleaseVersion('x'.repeat(10_000))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Nicht-Zeichenketten ergeben null statt eines Fehlers', () => {
|
||||||
|
expect(parseReleaseVersion(undefined as unknown as string)).toBeNull();
|
||||||
|
expect(parseReleaseVersion(null as unknown as string)).toBeNull();
|
||||||
|
expect(parseReleaseVersion(123 as unknown as string)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('compareReleaseVersions (quick-260925-bow)', () => {
|
||||||
|
it('vergleicht numerisch, nicht lexikografisch', () => {
|
||||||
|
expect(compareReleaseVersions('1.10.0', '1.9.0')).toBe(1);
|
||||||
|
expect(compareReleaseVersions('1.9.0', '1.10.0')).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die hoehere Hauptversion gewinnt', () => {
|
||||||
|
expect(compareReleaseVersions('2.0.0', '1.99.99')).toBe(1);
|
||||||
|
expect(compareReleaseVersions('1.99.99', '2.0.0')).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Patch-Stelle entscheidet bei gleicher Haupt- und Nebenversion', () => {
|
||||||
|
expect(compareReleaseVersions('1.3.1', '1.3.0')).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gleiche Versionen ergeben 0, auch mit v-Praefix und Describe-Anhang', () => {
|
||||||
|
expect(compareReleaseVersions('1.4.0', '1.4.0')).toBe(0);
|
||||||
|
expect(compareReleaseVersions('v10.2.3', '10.2.3')).toBe(0);
|
||||||
|
expect(compareReleaseVersions('v10.2.3-5-gabc1234', '10.2.3')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wirft bei nicht parsebarer Eingabe', () => {
|
||||||
|
expect(() => compareReleaseVersions('dev', '1.0.0')).toThrow();
|
||||||
|
expect(() => compareReleaseVersions('1.0.0', 'abc1234')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRunningRelease (quick-260925-bow)', () => {
|
||||||
|
it('liest APP_VERSION und kuerzt den Describe-Stand auf X.Y.Z', () => {
|
||||||
|
vi.stubEnv('APP_VERSION', 'v10.2.3-5-gabc1234');
|
||||||
|
expect(getRunningRelease()).toBe('10.2.3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Tag-Stand ergibt die Version selbst', () => {
|
||||||
|
vi.stubEnv('APP_VERSION', 'v1.4.0');
|
||||||
|
expect(getRunningRelease()).toBe('1.4.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dev ergibt null', () => {
|
||||||
|
vi.stubEnv('APP_VERSION', 'dev');
|
||||||
|
expect(getRunningRelease()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ungesetzt oder leer ergibt null', () => {
|
||||||
|
vi.stubEnv('APP_VERSION', undefined);
|
||||||
|
expect(getRunningRelease()).toBeNull();
|
||||||
|
vi.stubEnv('APP_VERSION', '');
|
||||||
|
expect(getRunningRelease()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein blosser Commit-Stempel ergibt null', () => {
|
||||||
|
vi.stubEnv('APP_VERSION', 'abc1234');
|
||||||
|
expect(getRunningRelease()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import * as nodemailer from 'nodemailer';
|
import * as nodemailer from 'nodemailer';
|
||||||
import { MailService } from './mail.service';
|
import { MailService, WELCOME_HEADER_CID } from './mail.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MailService.spec — NEU (260914-eym, Etappe 3c, WINDOWS #30). Der Bereich
|
* MailService.spec — NEU (260914-eym, Etappe 3c, WINDOWS #30). Der Bereich
|
||||||
@@ -166,7 +166,11 @@ describe('MailService — Transport je Versand nach Mandant des Empfaengers (260
|
|||||||
const service = new MailService(settings as any, makeFakeConfig({}) as any);
|
const service = new MailService(settings as any, makeFakeConfig({}) as any);
|
||||||
|
|
||||||
await service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-a', 't1');
|
await service.sendPasswordResetEmail('alice@a.example.invalid', 'tok-a', 't1');
|
||||||
await service.sendWelcomeEmail('bob@b.example.invalid', 'bob', 't2');
|
await service.sendWelcomeMail('t2', 'bob@b.example.invalid', {
|
||||||
|
subject: 'Willkommen bei Tessera',
|
||||||
|
text: 'Guten Tag bob,',
|
||||||
|
html: '<p>bob</p>',
|
||||||
|
});
|
||||||
|
|
||||||
expect(settings.getDecryptedSmtpConfig.mock.calls.map((c) => c[0])).toEqual(['t1', 't2']);
|
expect(settings.getDecryptedSmtpConfig.mock.calls.map((c) => c[0])).toEqual(['t1', 't2']);
|
||||||
const transports = vi.mocked(nodemailer.createTransport).mock.calls.map((c) => c[0] as any);
|
const transports = vi.mocked(nodemailer.createTransport).mock.calls.map((c) => c[0] as any);
|
||||||
@@ -248,3 +252,113 @@ describe('MailService — Transport je Versand nach Mandant des Empfaengers (260
|
|||||||
expect(errorSpy).toHaveBeenCalled();
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('MailService.sendReminderEmail (quick-260929-if2, E-04/E-07, T-IF2-05)', () => {
|
||||||
|
const dueAt = new Date('2026-10-05T12:30:00.000Z'); // 14:30 in Europe/Berlin (Sommerzeit)
|
||||||
|
|
||||||
|
function make() {
|
||||||
|
return new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('sendet Betreff "Erinnerung: <Titel>" mit Berliner Zeit, Titel, Beschreibung und App-Adresse; true bei Erfolg', async () => {
|
||||||
|
const ok = await make().sendReminderEmail('t1', 'alice@a.example.invalid', {
|
||||||
|
title: 'Zahnarzt',
|
||||||
|
description: 'Kartenlesegeraet mitnehmen',
|
||||||
|
dueAt,
|
||||||
|
});
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
const sent = mockSendMail.mock.calls[0][0] as any;
|
||||||
|
expect(sent.to).toBe('alice@a.example.invalid');
|
||||||
|
expect(sent.from).toBe('noreply@a.example.invalid');
|
||||||
|
expect(sent.subject).toBe('Erinnerung: Zahnarzt');
|
||||||
|
expect(sent.html).toBeUndefined();
|
||||||
|
expect(sent.text).toContain('14:30 Uhr');
|
||||||
|
expect(sent.text).toContain('Zahnarzt');
|
||||||
|
expect(sent.text).toContain('Kartenlesegeraet mitnehmen');
|
||||||
|
expect(sent.text).toContain('http://localhost:3000');
|
||||||
|
expect(mockClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entfernt CR/LF aus dem Betreff und kuerzt auf 150 Zeichen', async () => {
|
||||||
|
await make().sendReminderEmail('t1', 'a@a.example.invalid', {
|
||||||
|
title: `Zeile1\r\nBcc: boese@example.invalid ${'x'.repeat(300)}`,
|
||||||
|
description: '',
|
||||||
|
dueAt,
|
||||||
|
});
|
||||||
|
const sent = mockSendMail.mock.calls[0][0] as any;
|
||||||
|
expect(sent.subject).not.toMatch(/[\r\n]/);
|
||||||
|
expect(sent.subject.length).toBe(150);
|
||||||
|
expect(sent.subject.startsWith('Erinnerung: Zeile1 Bcc:')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('laesst die Beschreibung weg, wenn sie leer ist', async () => {
|
||||||
|
await make().sendReminderEmail('t1', 'a@a.example.invalid', { title: 'T', description: ' ', dueAt });
|
||||||
|
const sent = mockSendMail.mock.calls[0][0] as any;
|
||||||
|
expect(sent.text.split('\n')).toEqual([
|
||||||
|
'Guten Tag,',
|
||||||
|
'',
|
||||||
|
expect.stringContaining('eine Erinnerung für'),
|
||||||
|
'',
|
||||||
|
'T',
|
||||||
|
'',
|
||||||
|
'http://localhost:3000',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gibt false zurueck (wirft nicht), wenn der Transport scheitert', async () => {
|
||||||
|
mockSendMail = vi.fn(async () => {
|
||||||
|
throw new Error('SMTP down');
|
||||||
|
});
|
||||||
|
const ok = await make().sendReminderEmail('t1', 'a@a.example.invalid', {
|
||||||
|
title: 'T',
|
||||||
|
description: '',
|
||||||
|
dueAt,
|
||||||
|
});
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
expect(mockClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MailService.sendWelcomeMail / hasConfiguredTransport (Willkommensmail)', () => {
|
||||||
|
it('haengt das Kopfbild als CID-Anhang an, reicht HTML und Text durch und wirft Transportfehler nach aussen', async () => {
|
||||||
|
const service = new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any);
|
||||||
|
|
||||||
|
await service.sendWelcomeMail('t1', 'neu@a.example.invalid', {
|
||||||
|
subject: 'Willkommen bei Tessera',
|
||||||
|
text: 'Text',
|
||||||
|
html: `<img src="cid:${WELCOME_HEADER_CID}">`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sent = mockSendMail.mock.calls[0][0] as any;
|
||||||
|
expect(sent.from).toBe('noreply@a.example.invalid');
|
||||||
|
expect(sent.html).toContain(`cid:${WELCOME_HEADER_CID}`);
|
||||||
|
expect(sent.text).toBe('Text');
|
||||||
|
expect(sent.attachments).toHaveLength(1);
|
||||||
|
expect(sent.attachments[0].cid).toBe(WELCOME_HEADER_CID);
|
||||||
|
expect(sent.attachments[0].contentType).toBe('image/png');
|
||||||
|
// PNG-Signatur: das Bild aus apps/api/assets/mail ist wirklich geladen.
|
||||||
|
expect((sent.attachments[0].content as Buffer).subarray(1, 4).toString()).toBe('PNG');
|
||||||
|
|
||||||
|
mockSendMail = vi.fn(async () => {
|
||||||
|
throw new Error('ECONNREFUSED');
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.sendWelcomeMail('t1', 'neu@a.example.invalid', { subject: 'S', text: 'T', html: 'H' }),
|
||||||
|
).rejects.toThrow('ECONNREFUSED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasConfiguredTransport: SmtpConfig des Mandanten oder MAIL_HOST/TESSERA_SMTP_HOST — der Rueckfall localhost:1025 zaehlt nicht', async () => {
|
||||||
|
expect(
|
||||||
|
await new MailService(makeFakeSettings({ t1: configA }) as any, makeFakeConfig({}) as any).hasConfiguredTransport('t1'),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
await new MailService(makeFakeSettings({}) as any, makeFakeConfig({ MAIL_HOST: 'smtp.example.invalid' }) as any).hasConfiguredTransport('t1'),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
await new MailService(makeFakeSettings({}) as any, makeFakeConfig({ TESSERA_SMTP_HOST: 'legacy.example.invalid' }) as any).hasConfiguredTransport('t1'),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
await new MailService(makeFakeSettings({}) as any, makeFakeConfig({}) as any).hasConfiguredTransport('t1'),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
import * as nodemailer from 'nodemailer';
|
import * as nodemailer from 'nodemailer';
|
||||||
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
|
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
|
||||||
import { SettingsService } from '../settings/settings.service';
|
import { SettingsService } from '../settings/settings.service';
|
||||||
@@ -45,15 +47,43 @@ import { SettingsService } from '../settings/settings.service';
|
|||||||
* Seit quick-260914-m97 (Fehler-melden-Knopf) ist der Versandkern
|
* Seit quick-260914-m97 (Fehler-melden-Knopf) ist der Versandkern
|
||||||
* `deliver` herausgeloest: er WIRFT bei Transportfehlern und kennt
|
* `deliver` herausgeloest: er WIRFT bei Transportfehlern und kennt
|
||||||
* Anhaenge. `sendViaTenantTransport` bleibt der verschluckende Mantel fuer
|
* Anhaenge. `sendViaTenantTransport` bleibt der verschluckende Mantel fuer
|
||||||
* Kennwort-Reset und Willkommensmail (T-02-12 unveraendert); `sendBugReport`
|
* den Kennwort-Reset (T-02-12 unveraendert); `sendBugReport` und
|
||||||
* ruft den Kern direkt, damit der Anwender erfaehrt, ob sein Bericht ankam.
|
* `sendWelcomeMail` rufen den Kern direkt, damit der Ausloesende erfaehrt,
|
||||||
|
* ob die Mail ging.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** Inhaltskennung des Kopfbilds der Willkommensmail (`<img src="cid:...">`). */
|
||||||
|
export const WELCOME_HEADER_CID = 'welcome-header@tessera';
|
||||||
|
|
||||||
|
let welcomeHeaderCache: Buffer | null | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Laedt das Kopfbild der Willkommensmail einmal je Prozess
|
||||||
|
* (apps/api/assets/mail/welcome-header.png, erzeugt von
|
||||||
|
* scripts/render-mail-header.mjs). Zur Laufzeit liegt diese Datei unter
|
||||||
|
* dist/mail/, im Test unter src/mail/ — beide Male zwei Ebenen unter
|
||||||
|
* apps/api. Fehlt das Bild, liefert die Funktion `null`; die Mail zeigt
|
||||||
|
* dann einen dunklen Textkopf statt abzubrechen.
|
||||||
|
*/
|
||||||
|
export function loadWelcomeHeaderPng(): Buffer | null {
|
||||||
|
if (welcomeHeaderCache !== undefined) return welcomeHeaderCache;
|
||||||
|
const file = path.resolve(__dirname, '..', '..', 'assets', 'mail', 'welcome-header.png');
|
||||||
|
try {
|
||||||
|
welcomeHeaderCache = fs.readFileSync(file);
|
||||||
|
} catch {
|
||||||
|
new Logger('MailService').warn(`Welcome header image missing: ${file}`);
|
||||||
|
welcomeHeaderCache = null;
|
||||||
|
}
|
||||||
|
return welcomeHeaderCache;
|
||||||
|
}
|
||||||
|
|
||||||
/** Anhang in der nodemailer-Form (`attachments` von `sendMail`). */
|
/** Anhang in der nodemailer-Form (`attachments` von `sendMail`). */
|
||||||
export interface OutgoingAttachment {
|
export interface OutgoingAttachment {
|
||||||
filename: string;
|
filename: string;
|
||||||
content: Buffer;
|
content: Buffer;
|
||||||
contentType: string;
|
contentType: string;
|
||||||
|
/** Inhaltskennung fuer eingebettete Bilder (`<img src="cid:...">`). */
|
||||||
|
cid?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Eine ausgehende Mail, wie `deliver` sie an nodemailer reicht. */
|
/** Eine ausgehende Mail, wie `deliver` sie an nodemailer reicht. */
|
||||||
@@ -186,10 +216,10 @@ export class MailService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verschluckender Mantel um `deliver` fuer Kennwort-Reset und
|
* Verschluckender Mantel um `deliver` fuer den Kennwort-Reset: Fehler
|
||||||
* Willkommensmail: Fehler werden protokolliert, nie geworfen — der
|
* werden protokolliert, nie geworfen — der Anmeldeweg antwortet weiter
|
||||||
* Anmeldeweg antwortet weiter 200, keine E-Mail-Enumeration (T-02-12
|
* 200, keine E-Mail-Enumeration (T-02-12). Die Willkommensmail benutzt
|
||||||
* bleibt fuer genau diese beiden Wege bestehen).
|
* ihn seit dem Versand aus der Benutzerverwaltung nicht mehr.
|
||||||
*/
|
*/
|
||||||
private async sendViaTenantTransport(
|
private async sendViaTenantTransport(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
@@ -275,45 +305,105 @@ export class MailService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a welcome email to a newly created user (optional — derzeit ohne
|
* Willkommensmail aus der Benutzerverwaltung (Administrator → Benutzer,
|
||||||
* Aufrufer, gemessen 260914-eym; bleibt als Pfad ueber denselben
|
* "Willkommensmail senden"). Inhalt und HTML baut
|
||||||
* Transport je Versand erhalten).
|
* `renderWelcomeMail` (welcome-mail.template.ts), die Entscheidung ueber
|
||||||
|
* den Anmeldehinweis trifft `WelcomeMailService`. Diese Methode haengt
|
||||||
|
* nur das Kopfbild als CID-Anhang an (`WELCOME_HEADER_CID`, kein
|
||||||
|
* Nachladen von aussen) und versendet ueber den Transport des Mandanten
|
||||||
|
* des EMPFAENGERS.
|
||||||
*
|
*
|
||||||
* @param tenantId - Mandant des Empfaengers (entscheidet ueber den SMTP-Transport)
|
* Fehler gehen BEWUSST nach aussen (wie `sendBugReport`): ein
|
||||||
|
* Administrator loest den Versand gezielt aus und muss erfahren, ob die
|
||||||
|
* Mail ging — es gibt hier keinen Anmeldeweg, den eine Fehlermeldung
|
||||||
|
* verraten koennte (T-02-12 betrifft nur Kennwort-Reset).
|
||||||
*/
|
*/
|
||||||
async sendWelcomeEmail(
|
async sendWelcomeMail(
|
||||||
email: string,
|
|
||||||
username: string,
|
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
locale: string = 'de',
|
to: string,
|
||||||
|
mail: { subject: string; text: string; html: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const isGerman = locale === 'de';
|
const header = loadWelcomeHeaderPng();
|
||||||
const subject = isGerman
|
await this.deliver(
|
||||||
? 'Willkommen bei Tessera'
|
tenantId,
|
||||||
: 'Welcome to Tessera';
|
{
|
||||||
|
to,
|
||||||
|
...mail,
|
||||||
|
attachments: header
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
filename: 'tessera.png',
|
||||||
|
content: header,
|
||||||
|
contentType: 'image/png',
|
||||||
|
cid: WELCOME_HEADER_CID,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
'Welcome',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const text = isGerman
|
/**
|
||||||
? [
|
* Gibt an, ob fuer den Mandanten ein Versandweg eingerichtet ist: eine
|
||||||
`Hallo ${username},`,
|
* eigene SmtpConfig ODER ein per Umgebung gesetzter Server (MAIL_HOST /
|
||||||
'',
|
* TESSERA_SMTP_HOST). Der letzte Rueckfall `localhost:1025` (Mailhog in
|
||||||
'Ihr Tessera-Account wurde erstellt.',
|
* der Entwicklung) zaehlt NICHT — sonst saehe die Oberflaeche einen
|
||||||
'',
|
* Versandweg, der im Betrieb ins Leere geht.
|
||||||
`Sie können sich unter ${this.appUrl}/login anmelden.`,
|
*/
|
||||||
'',
|
async hasConfiguredTransport(tenantId: string): Promise<boolean> {
|
||||||
'Mit freundlichen Grüßen,',
|
const smtpConfig = await this.settingsService.getDecryptedSmtpConfig(tenantId);
|
||||||
'Ihr Tessera-Team',
|
if (smtpConfig) return true;
|
||||||
].join('\n')
|
const envHost =
|
||||||
: [
|
this.configService.get<string>('MAIL_HOST') ??
|
||||||
`Hello ${username},`,
|
this.configService.get<string>('TESSERA_SMTP_HOST');
|
||||||
'',
|
return typeof envHost === 'string' && envHost.trim() !== '';
|
||||||
'Your Tessera account has been created.',
|
}
|
||||||
'',
|
|
||||||
`You can sign in at ${this.appUrl}/login.`,
|
|
||||||
'',
|
|
||||||
'Best regards,',
|
|
||||||
'The Tessera Team',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
await this.sendViaTenantTransport(tenantId, { to: email, subject, text }, 'Welcome');
|
/**
|
||||||
|
* Erinnerungs-E-Mail (quick-260929-if2): eine Mail je faelliger Erinnerung an
|
||||||
|
* die eigene Adresse des Besitzers, ueber den SMTP-Transport seines Mandanten.
|
||||||
|
* Gibt `true` zurueck, wenn der Versand gelang, `false` bei einem
|
||||||
|
* Transportfehler — der Planer (`ReminderMailScheduler`) entscheidet daran,
|
||||||
|
* ob er den Anspruch wieder freigibt (E-04). Wirft nie.
|
||||||
|
*
|
||||||
|
* Nur Text, kein HTML (T-IF2-05, kein HTML-Einschleusen). Der Betreff hat
|
||||||
|
* Zeilenumbrueche durch Leerzeichen ersetzt (Header-Einschleusung) und ist
|
||||||
|
* auf 150 Zeichen gekuerzt. Die Zeit steht in `Europe/Berlin` (E-07): im
|
||||||
|
* Benutzer ist keine Zeitzone gespeichert, das Haus arbeitet in deutscher Zeit.
|
||||||
|
*/
|
||||||
|
async sendReminderEmail(
|
||||||
|
tenantId: string,
|
||||||
|
to: string,
|
||||||
|
reminder: { title: string; description: string; dueAt: Date },
|
||||||
|
): Promise<boolean> {
|
||||||
|
const when = `${new Intl.DateTimeFormat('de-DE', {
|
||||||
|
timeZone: 'Europe/Berlin',
|
||||||
|
dateStyle: 'full',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(reminder.dueAt)} Uhr`;
|
||||||
|
const subject = `Erinnerung: ${reminder.title}`.replace(/[\r\n]+/g, ' ').slice(0, 150);
|
||||||
|
const lines = [
|
||||||
|
'Guten Tag,',
|
||||||
|
'',
|
||||||
|
`Sie haben in Tessera eine Erinnerung für ${when} gesetzt:`,
|
||||||
|
'',
|
||||||
|
reminder.title,
|
||||||
|
];
|
||||||
|
if (reminder.description.trim() !== '') {
|
||||||
|
lines.push('', reminder.description);
|
||||||
|
}
|
||||||
|
lines.push('', this.appUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.deliver(tenantId, { to, subject, text: lines.join('\n') }, 'Reminder');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to send Reminder email to ${to}`,
|
||||||
|
error instanceof Error ? error.stack : String(error),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { DEFAULT_WELCOME_MAIL_TEXTS, findUnknownWelcomeMailPlaceholders } from '@tessera/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
applyWelcomeMailPlaceholders,
|
||||||
|
firstNameOf,
|
||||||
|
renderWelcomeMail,
|
||||||
|
type WelcomeMailInput,
|
||||||
|
} from './welcome-mail.template';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Willkommensmail mit eigener Vorlage: Platzhalter, Escaping, Absaetze,
|
||||||
|
* feste Bausteine.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const base: WelcomeMailInput = {
|
||||||
|
name: 'Max Muster',
|
||||||
|
username: 'max.muster',
|
||||||
|
email: 'max@example.invalid',
|
||||||
|
tenantName: 'Muster & Co',
|
||||||
|
appUrl: 'https://tessera.example.invalid/',
|
||||||
|
account: { kind: 'directory' },
|
||||||
|
headerImageSrc: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Standard-Anmeldehinweise, damit Vorlagen im Test vollstaendig sind. */
|
||||||
|
const hints = {
|
||||||
|
loginHintDirectory: DEFAULT_WELCOME_MAIL_TEXTS.loginHintDirectory,
|
||||||
|
loginHintLocal: DEFAULT_WELCOME_MAIL_TEXTS.loginHintLocal,
|
||||||
|
};
|
||||||
|
|
||||||
|
const localAccount = {
|
||||||
|
kind: 'local',
|
||||||
|
setPasswordUrl: 'https://tessera.example.invalid/reset-password/abc',
|
||||||
|
validHours: 168,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const values = {
|
||||||
|
name: 'Max Muster',
|
||||||
|
vorname: 'Max',
|
||||||
|
benutzername: 'max.muster',
|
||||||
|
email: 'max@example.invalid',
|
||||||
|
adresse: 'https://t.example.invalid',
|
||||||
|
firma: 'Muster & Co',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Platzhalter', () => {
|
||||||
|
it('ersetzt alle sechs, Gross-/Kleinschreibung und Leerraum egal; unbekannte bleiben stehen', () => {
|
||||||
|
expect(
|
||||||
|
applyWelcomeMailPlaceholders(
|
||||||
|
'{{name}}|{{vorname}}|{{benutzername}}|{{email}}|{{adresse}}|{{firma}}|{{ NAME }}|{{xyz}}',
|
||||||
|
values,
|
||||||
|
),
|
||||||
|
).toBe(
|
||||||
|
'Max Muster|Max|max.muster|max@example.invalid|https://t.example.invalid|Muster & Co|Max Muster|{{xyz}}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findUnknownWelcomeMailPlaceholders nennt jeden unbekannten einmal', () => {
|
||||||
|
expect(findUnknownWelcomeMailPlaceholders('{{a}} {{name}} {{a}} {{ b }} {{}}')).toEqual([
|
||||||
|
'{{a}}',
|
||||||
|
'{{b}}',
|
||||||
|
'{{}}',
|
||||||
|
]);
|
||||||
|
expect(findUnknownWelcomeMailPlaceholders(DEFAULT_WELCOME_MAIL_TEXTS.heading)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('vorname: erstes Wort des Anzeigenamens, sonst Benutzername', () => {
|
||||||
|
expect(firstNameOf(' Erika Mustermann ', 'erika')).toBe('Erika');
|
||||||
|
expect(firstNameOf(null, 'erika')).toBe('erika');
|
||||||
|
expect(firstNameOf(' ', 'erika')).toBe('erika');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renderWelcomeMail mit Vorlage', () => {
|
||||||
|
it('ohne Vorlage: Standard-Betreff und -Ueberschrift', () => {
|
||||||
|
const mail = renderWelcomeMail(base);
|
||||||
|
expect(mail.subject).toBe('Willkommen bei Tessera');
|
||||||
|
expect(mail.html).toContain('Willkommen bei Tessera, Max Muster!');
|
||||||
|
expect(mail.html).toContain('Viel Erfolg mit Tessera!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Text ist reiner Text: HTML aus Vorlage und Werten wird escaped; Firma mit & korrekt', () => {
|
||||||
|
const mail = renderWelcomeMail({
|
||||||
|
...base,
|
||||||
|
name: '<script>x</script>',
|
||||||
|
texts: {
|
||||||
|
subject: 'Hallo\r\n{{firma}}',
|
||||||
|
heading: '<h2>{{name}}</h2>',
|
||||||
|
intro: '<a href="javascript:alert(1)">klick</a>',
|
||||||
|
...hints,
|
||||||
|
closing: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mail.subject).toBe('Hallo Muster & Co');
|
||||||
|
expect(mail.html).not.toContain('<script>x</script>');
|
||||||
|
expect(mail.html).not.toContain('<h2>');
|
||||||
|
expect(mail.html).not.toContain('href="javascript:');
|
||||||
|
expect(mail.html).toContain('<h2><script>x</script></h2>');
|
||||||
|
expect(mail.html).toContain('<title>Hallo Muster & Co</title>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Leerzeile = neuer Absatz, einfacher Umbruch = <br>; leerer Abschluss faellt weg', () => {
|
||||||
|
const mail = renderWelcomeMail({
|
||||||
|
...base,
|
||||||
|
texts: { subject: 's', heading: 'h', intro: 'A1\nA2\r\n\r\n\nB', ...hints, closing: ' ' },
|
||||||
|
});
|
||||||
|
expect(mail.html).toContain('>A1<br>A2</p>');
|
||||||
|
expect(mail.html).toContain('>B</p>');
|
||||||
|
expect(mail.html).not.toContain('Viel Erfolg');
|
||||||
|
expect(mail.text).toContain('A1\nA2\n\nB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('feste Bausteine bleiben bei jeder Vorlage: Adresse, Benutzername, Anmeldehinweis, Zu Tessera, Fusszeile', () => {
|
||||||
|
const mail = renderWelcomeMail({
|
||||||
|
...base,
|
||||||
|
account: {
|
||||||
|
kind: 'local',
|
||||||
|
setPasswordUrl: 'https://tessera.example.invalid/reset-password/abc',
|
||||||
|
validHours: 168,
|
||||||
|
},
|
||||||
|
texts: { subject: 'x', heading: 'y', intro: '', ...hints, closing: '' },
|
||||||
|
});
|
||||||
|
expect(mail.html).toContain('https://tessera.example.invalid/reset-password/abc');
|
||||||
|
expect(mail.html).toContain('Passwort festlegen');
|
||||||
|
expect(mail.html).toContain('7 Tage');
|
||||||
|
expect(mail.html).toContain('Zu Tessera');
|
||||||
|
expect(mail.html).toContain('max.muster');
|
||||||
|
expect(mail.html).toContain('im Auftrag Ihres Administrators');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('local-example: Knopf zur Anmeldeseite mit Beispiel-Hinweis, kein reset-password', () => {
|
||||||
|
const mail = renderWelcomeMail({
|
||||||
|
...base,
|
||||||
|
account: { kind: 'local-example' },
|
||||||
|
notice: 'Testmail',
|
||||||
|
});
|
||||||
|
expect(mail.html).toContain('Beispiel-Link');
|
||||||
|
expect(mail.html).not.toContain('reset-password');
|
||||||
|
expect(mail.html).toContain('Testmail');
|
||||||
|
expect(mail.text.startsWith('[Testmail]')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Anmeldehinweis je Kontoart', () => {
|
||||||
|
it('Standard: Verzeichniskonto bekommt den Windows-Hinweis, lokales Konto den Hinweis vor dem Knopf', () => {
|
||||||
|
const dir = renderWelcomeMail(base);
|
||||||
|
expect(dir.html).toContain('gewohnten Windows-Passwort');
|
||||||
|
expect(dir.html).not.toContain('persönliches Passwort fest');
|
||||||
|
const local = renderWelcomeMail({ ...base, account: localAccount });
|
||||||
|
expect(local.html).toContain('legen Sie bitte Ihr persönliches Passwort fest');
|
||||||
|
expect(local.html).not.toContain('Windows-Passwort');
|
||||||
|
// Hinweis steht vor dem Knopf
|
||||||
|
expect(local.html.indexOf('persönliches Passwort fest')).toBeLessThan(
|
||||||
|
local.html.indexOf('Passwort festlegen</a>'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eigener Text je Kontoart: Platzhalter ersetzt, escaped, Leerzeile = Absatz; Knopf und Gueltigkeit bleiben fest', () => {
|
||||||
|
const texts = {
|
||||||
|
...DEFAULT_WELCOME_MAIL_TEXTS,
|
||||||
|
loginHintDirectory:
|
||||||
|
'Anmeldung als {{benutzername}} mit <b>Firmenkennwort</b>.\n\nFragen? IT anrufen.',
|
||||||
|
loginHintLocal: 'Hallo {{vorname}}, bitte zuerst ein Passwort setzen.',
|
||||||
|
};
|
||||||
|
const dir = renderWelcomeMail({ ...base, texts });
|
||||||
|
expect(dir.html).toContain(
|
||||||
|
'Anmeldung als max.muster mit <b>Firmenkennwort</b>.</p>',
|
||||||
|
);
|
||||||
|
expect(dir.html).toContain('>Fragen? IT anrufen.</p>');
|
||||||
|
expect(dir.html).not.toContain('Windows-Passwort');
|
||||||
|
expect(dir.html).not.toContain('bitte zuerst ein Passwort setzen');
|
||||||
|
expect(dir.text).toContain(
|
||||||
|
'Anmeldung als max.muster mit <b>Firmenkennwort</b>.\n\nFragen? IT anrufen.',
|
||||||
|
);
|
||||||
|
|
||||||
|
const local = renderWelcomeMail({ ...base, account: localAccount, texts });
|
||||||
|
expect(local.html).toContain('Hallo Max, bitte zuerst ein Passwort setzen.');
|
||||||
|
expect(local.html).not.toContain('Firmenkennwort');
|
||||||
|
expect(local.html).toContain('reset-password/abc');
|
||||||
|
expect(local.html).toContain('7 Tage');
|
||||||
|
expect(local.text).toContain('Hallo Max, bitte zuerst ein Passwort setzen.');
|
||||||
|
|
||||||
|
const example = renderWelcomeMail({ ...base, account: { kind: 'local-example' }, texts });
|
||||||
|
expect(example.html).toContain('Hallo Max, bitte zuerst ein Passwort setzen.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leerer oder fehlender Hinweis (aeltere Aufrufer) → Standardtext', () => {
|
||||||
|
const { loginHintDirectory: _d, loginHintLocal: _l, ...old } = DEFAULT_WELCOME_MAIL_TEXTS;
|
||||||
|
const dir = renderWelcomeMail({ ...base, texts: old as any });
|
||||||
|
expect(dir.html).toContain('gewohnten Windows-Passwort');
|
||||||
|
const local = renderWelcomeMail({
|
||||||
|
...base,
|
||||||
|
account: localAccount,
|
||||||
|
texts: { ...DEFAULT_WELCOME_MAIL_TEXTS, loginHintLocal: ' ' },
|
||||||
|
});
|
||||||
|
expect(local.html).toContain('persönliches Passwort fest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('local-no-link behaelt den festen Hinweis', () => {
|
||||||
|
const mail = renderWelcomeMail({ ...base, account: { kind: 'local-no-link' } });
|
||||||
|
expect(mail.html).toContain('Ihr Startpasswort erhalten Sie von Ihrem Administrator.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Kopf (Outlook ohne CID-Bild)', () => {
|
||||||
|
it('Wellenstreifen 600x40 in einer Zelle mit dunkler Kopffarbe, Inhalt beginnt mit 24px Abstand', () => {
|
||||||
|
const mail = renderWelcomeMail({ ...base, headerImageSrc: 'cid:welcome-header@tessera' });
|
||||||
|
expect(mail.html).toMatch(
|
||||||
|
/<td bgcolor="#1a1c20" style="background-color:#1a1c20;line-height:0;font-size:0;">\n<img src="cid:welcome-header@tessera" width="600" height="40"/,
|
||||||
|
);
|
||||||
|
expect(mail.html).toContain('padding:24px 40px 12px;');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
/**
|
||||||
|
* welcome-mail.template.ts — Inhalt und Gestaltung der Willkommensmail.
|
||||||
|
*
|
||||||
|
* Reine Funktion ohne Abhaengigkeiten: bekommt die fertigen Werte
|
||||||
|
* (Anzeigename, Benutzername, Adresse, Anmeldeweg) und liefert Betreff,
|
||||||
|
* Text-Alternative und HTML. Versand und Kopfbild-Anhang erledigt
|
||||||
|
* `MailService.sendWelcomeMail`, die Entscheidung "wer bekommt welchen
|
||||||
|
* Anmeldehinweis" `WelcomeMailService`.
|
||||||
|
*
|
||||||
|
* Eigene Vorlage (Administrator → Willkommensmail): Betreff, Ueberschrift,
|
||||||
|
* Einleitung, die zwei Anmeldehinweise (Verzeichniskonto / lokales Konto)
|
||||||
|
* und Abschluss kommen als `texts` herein (Vorlage des Mandanten
|
||||||
|
* des ZIEL-Benutzers, sonst `DEFAULT_WELCOME_MAIL_TEXTS` aus
|
||||||
|
* `@tessera/shared`). Platzhalter (`{{name}}`, `{{vorname}}`,
|
||||||
|
* `{{benutzername}}`, `{{email}}`, `{{adresse}}`, `{{firma}}`) werden auf dem
|
||||||
|
* REINEN Text ersetzt und erst danach escaped. Leerzeile = neuer Absatz,
|
||||||
|
* einfacher Umbruch = `<br>`. Alles andere bleibt fest.
|
||||||
|
*
|
||||||
|
* E-Mail-tauglich gebaut, weil Outlook (Word-Darstellung), Gmail und Apple
|
||||||
|
* Mail sehr unterschiedlich darstellen:
|
||||||
|
* - Tabellenlayout, alle Stile inline, hoechstens 600 px breit;
|
||||||
|
* - keine externen Ressourcen, keine Web-Fonts (Systemschriften);
|
||||||
|
* - Kopf: Bildmarke und Schriftzug als HTML (erscheinen immer), darunter
|
||||||
|
* die Duenen-Welle als schmaler PNG-Streifen (`headerImageSrc`, im
|
||||||
|
* Versand `cid:`), weil SVG und CSS-Hintergruende in Outlook nicht
|
||||||
|
* erscheinen; fehlt das Bild, bleibt nur ein schmaler Abschluss;
|
||||||
|
* - Knoepfe als Tabelle mit Hintergrundfarbe in der Zelle ("bulletproof"),
|
||||||
|
* Outlook ignoriert Innenabstaende und Rundungen am Link selbst.
|
||||||
|
*
|
||||||
|
* Sicherheit: jeder eingesetzte Wert laeuft durch `escapeHtml`; Links werden
|
||||||
|
* nur als http(s) uebernommen. Ein Kennwort steht NIE in der Mail — lokale
|
||||||
|
* Konten bekommen einen Link zum Festlegen (Token wie beim
|
||||||
|
* "Passwort vergessen"-Weg), verzeichnisgefuehrte den Hinweis auf das
|
||||||
|
* Windows-Passwort.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_WELCOME_MAIL_TEXTS,
|
||||||
|
isWelcomeMailPlaceholder,
|
||||||
|
WELCOME_MAIL_PLACEHOLDER_RE,
|
||||||
|
type WelcomeMailPlaceholder,
|
||||||
|
type WelcomeMailTexts,
|
||||||
|
} from '@tessera/shared';
|
||||||
|
|
||||||
|
/** Farben aus dem Design "Mosaik" (globals.css / brand.ts). */
|
||||||
|
const C = {
|
||||||
|
page: '#eceef1',
|
||||||
|
card: '#ffffff',
|
||||||
|
ink: '#1a1d21',
|
||||||
|
body: '#3d4450',
|
||||||
|
muted: '#6b7280',
|
||||||
|
line: '#e3e5e9',
|
||||||
|
well: '#f7f7f5',
|
||||||
|
yellow: '#ffed00',
|
||||||
|
link: '#1d5fc2',
|
||||||
|
header: '#1a1c20',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const FONT =
|
||||||
|
"'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', Arial, sans-serif";
|
||||||
|
|
||||||
|
/** Anmeldeweg des Empfaengers — entscheidet den Hinweis in der Mail. */
|
||||||
|
export type WelcomeMailAccount =
|
||||||
|
| { kind: 'directory' }
|
||||||
|
| { kind: 'local'; setPasswordUrl: string; validHours: number }
|
||||||
|
/**
|
||||||
|
* Lokales Konto ohne Link. Wird heute nirgends erzeugt (`WelcomeMailService`
|
||||||
|
* legt fuer lokale Konten immer einen Token an); deshalb bleibt ihr Hinweis
|
||||||
|
* fest und ist nicht Teil der Vorlage.
|
||||||
|
*/
|
||||||
|
| { kind: 'local-no-link' }
|
||||||
|
/**
|
||||||
|
* Nur Testmail aus Administrator → Willkommensmail: derselbe Baustein wie
|
||||||
|
* `local`, aber OHNE Token — der Knopf fuehrt zur Anmeldeseite und ein
|
||||||
|
* Hinweis sagt, was er in der echten Mail tut.
|
||||||
|
*/
|
||||||
|
| { kind: 'local-example' };
|
||||||
|
|
||||||
|
export interface WelcomeMailInput {
|
||||||
|
/** Anzeigename, sonst Benutzername. */
|
||||||
|
name: string;
|
||||||
|
username: string;
|
||||||
|
/** E-Mail-Adresse des Empfaengers (Platzhalter `{{email}}`). */
|
||||||
|
email: string;
|
||||||
|
/** Name des Mandanten (Platzhalter `{{firma}}`). */
|
||||||
|
tenantName: string;
|
||||||
|
/** Oeffentliche Basisadresse der Web-Oberflaeche, ohne abschliessenden Schraegstrich. */
|
||||||
|
appUrl: string;
|
||||||
|
account: WelcomeMailAccount;
|
||||||
|
/** `cid:...` im Versand, `data:` in der Vorschau, `null` = Textkopf. */
|
||||||
|
headerImageSrc: string | null;
|
||||||
|
/** Eigene Vorlage des Mandanten; fehlt sie, gelten die Standardtexte. */
|
||||||
|
texts?: WelcomeMailTexts | null;
|
||||||
|
/** Hinweisleiste ganz oben (nur Testmail), reiner Text. */
|
||||||
|
notice?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderedWelcomeMail {
|
||||||
|
subject: string;
|
||||||
|
text: string;
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WELCOME_MAIL_SUBJECT = DEFAULT_WELCOME_MAIL_TEXTS.subject;
|
||||||
|
|
||||||
|
const FOOTER = 'Diese E-Mail wurde von Tessera im Auftrag Ihres Administrators versendet.';
|
||||||
|
const EXAMPLE_LINK_NOTE =
|
||||||
|
'Beispiel-Link: In der echten Willkommensmail führt dieser Knopf zu einem persönlichen Link, mit dem der neue Benutzer sein Passwort festlegt. In dieser Testmail öffnet er nur die Anmeldeseite.';
|
||||||
|
|
||||||
|
/** Werte der Platzhalter, noch NICHT escaped (Ersetzung laeuft auf reinem Text). */
|
||||||
|
export type WelcomeMailPlaceholderValues = Record<WelcomeMailPlaceholder, string>;
|
||||||
|
|
||||||
|
/** Erstes Wort des Anzeigenamens, sonst der Benutzername. */
|
||||||
|
export function firstNameOf(displayName: string | null | undefined, username: string): string {
|
||||||
|
const first = (displayName ?? '').trim().split(/\s+/)[0];
|
||||||
|
return first || username;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt die Platzhalter in einen REINEN Text ein (Gross-/Kleinschreibung
|
||||||
|
* egal). Unbekannte bleiben stehen — gespeicherte Vorlagen enthalten keine,
|
||||||
|
* das prueft die API beim Speichern. Das Ergebnis ist weiter reiner Text und
|
||||||
|
* wird erst beim Einbau ins HTML escaped.
|
||||||
|
*/
|
||||||
|
export function applyWelcomeMailPlaceholders(
|
||||||
|
text: string,
|
||||||
|
values: WelcomeMailPlaceholderValues,
|
||||||
|
): string {
|
||||||
|
return text.replace(WELCOME_MAIL_PLACEHOLDER_RE, (whole, name: string) =>
|
||||||
|
isWelcomeMailPlaceholder(name) ? values[name.toLowerCase() as WelcomeMailPlaceholder] : whole,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeilenenden vereinheitlichen, Leerraum an den Raendern entfernen. */
|
||||||
|
function normalizeText(value: string): string {
|
||||||
|
return value.replace(/\r\n?/g, '\n').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einzeilig (Betreff, Ueberschrift): jeder Umbruch/Leerraum-Lauf wird ein Leerzeichen. */
|
||||||
|
function singleLine(value: string): string {
|
||||||
|
return value.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Absaetze: Leerzeile trennt, einfacher Umbruch bleibt im Absatz. */
|
||||||
|
function paragraphsOf(value: string): string[] {
|
||||||
|
const text = normalizeText(value);
|
||||||
|
if (!text) return [];
|
||||||
|
return text
|
||||||
|
.split(/\n[ \t]*\n+/)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur http(s)-Adressen gelangen in ein href; alles andere wird leer. */
|
||||||
|
function safeUrl(value: string): string {
|
||||||
|
return /^https?:\/\//i.test(value) ? value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anmeldehinweis je Kontoart als REINER Text (Platzhalter noch nicht
|
||||||
|
* ersetzt). Verzeichnis- und lokale Konten nehmen den Text der Vorlage; ist
|
||||||
|
* er leer oder fehlt er (aeltere Aufrufer), gilt der Standardtext.
|
||||||
|
*/
|
||||||
|
function loginHintText(account: WelcomeMailAccount, texts: Partial<WelcomeMailTexts>): string {
|
||||||
|
const pick = (field: 'loginHintDirectory' | 'loginHintLocal') => {
|
||||||
|
const value = texts[field];
|
||||||
|
return typeof value === 'string' && value.trim() ? value : DEFAULT_WELCOME_MAIL_TEXTS[field];
|
||||||
|
};
|
||||||
|
switch (account.kind) {
|
||||||
|
case 'directory':
|
||||||
|
return pick('loginHintDirectory');
|
||||||
|
case 'local':
|
||||||
|
case 'local-example':
|
||||||
|
return pick('loginHintLocal');
|
||||||
|
case 'local-no-link':
|
||||||
|
return 'Ihr Startpasswort erhalten Sie von Ihrem Administrator.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validityText(hours: number): string {
|
||||||
|
const span =
|
||||||
|
hours % 24 === 0 && hours >= 24
|
||||||
|
? hours === 24
|
||||||
|
? '1 Tag'
|
||||||
|
: `${hours / 24} Tage`
|
||||||
|
: hours === 1
|
||||||
|
? '1 Stunde'
|
||||||
|
: `${hours} Stunden`;
|
||||||
|
return `Der Link ist ${span} gültig und nur einmal verwendbar. Ist er abgelaufen, fordern Sie auf der Anmeldeseite über „Passwort vergessen?“ einfach einen neuen an.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Knopf als Tabelle: Farbe an der Zelle, damit Outlook ihn als Flaeche zeigt. */
|
||||||
|
function button(href: string, label: string, bg: string, fg: string): string {
|
||||||
|
return `<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;">
|
||||||
|
<tr><td align="center" bgcolor="${bg}" style="background-color:${bg};border-radius:6px;mso-padding-alt:14px 30px;">
|
||||||
|
<a href="${escapeHtml(href)}" target="_blank" style="display:inline-block;padding:14px 30px;font-family:${FONT};font-size:16px;line-height:20px;font-weight:600;color:${fg};text-decoration:none;border-radius:6px;">${escapeHtml(label)}</a>
|
||||||
|
</td></tr></table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Kachel der Bildmarke: feste Zelle, Hoehe auch in Outlook exakt. */
|
||||||
|
function tile(color: string | null): string {
|
||||||
|
const bg = color ? `bgcolor="${color}" style="background-color:${color};` : 'style="';
|
||||||
|
return `<td width="9" height="9" ${bg}width:9px;height:9px;font-size:1px;line-height:9px;mso-line-height-rule:exactly;"> </td>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Luecke zwischen Kacheln. */
|
||||||
|
const GAP = '<td width="3" style="width:3px;font-size:1px;line-height:1px;"> </td>';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bildmarke als HTML (quick-260930): das Kachel-"T" aus Tabellenzellen —
|
||||||
|
* oben drei Kacheln (die dritte gelb, im Original gedreht), darunter zwei in
|
||||||
|
* der Mitte —, auf dunkler Grundplatte mit heller Kontur wie in der App.
|
||||||
|
* Braucht kein Bild und erscheint deshalb in jedem Mailprogramm.
|
||||||
|
*/
|
||||||
|
function logoMark(): string {
|
||||||
|
const row = (cells: Array<string | null>) =>
|
||||||
|
`<tr>${cells.map((c, i) => (i > 0 ? GAP : '') + tile(c)).join('')}</tr>`;
|
||||||
|
const spacer = `<tr><td colspan="5" height="3" style="height:3px;font-size:1px;line-height:3px;mso-line-height-rule:exactly;"> </td></tr>`;
|
||||||
|
const olive = '#9c9440';
|
||||||
|
return `<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;">
|
||||||
|
<tr><td bgcolor="#111214" style="background-color:#111214;border:1px solid #3a3d44;border-radius:10px;padding:10px 10px 10px 10px;">
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
|
||||||
|
${row([olive, olive, C.yellow])}${spacer}${row([null, olive, null])}${spacer}${row([null, olive, null])}
|
||||||
|
</table>
|
||||||
|
</td></tr></table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kopf der Mail (quick-260930, Rueckmeldung des Nutzers: in Outlook "ein
|
||||||
|
* riesiger schwarzer Fleck, kein Logo"). Vorher steckten Logo und
|
||||||
|
* Schriftzug in EINEM 150 px hohen Bild; zeigt ein Mailprogramm das
|
||||||
|
* eingebettete Bild nicht an, blieb nur die dunkle Flaeche. Jetzt:
|
||||||
|
* - Bildmarke (HTML-Kacheln) und Schriftzug "Tessera" (echter Text) in einer
|
||||||
|
* niedrigen dunklen Leiste — erscheinen immer;
|
||||||
|
* - darunter die Duenen-Welle als schmaler Bildstreifen (`src`, im Versand
|
||||||
|
* `cid:`, 600x40), der ins Weiss der Karte auslaeuft. Die Zelle um das
|
||||||
|
* Bild traegt die dunkle Kopffarbe (zweite Rueckmeldung des Nutzers: sein
|
||||||
|
* Outlook zeigt das CID-Bild nicht, vorher stand dort eine "riesengrosse
|
||||||
|
* weisse Luecke"): fehlt das Bild, wirkt der Kopf nur etwas hoeher.
|
||||||
|
*/
|
||||||
|
function headerRow(src: string | null): string {
|
||||||
|
const bar = `<tr><td bgcolor="${C.header}" style="background-color:${C.header};border-radius:12px 12px 0 0;padding:22px 32px 14px;">
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr>
|
||||||
|
<td valign="middle" style="padding:0 14px 0 0;">${logoMark()}</td>
|
||||||
|
<td valign="middle" style="font-family:${FONT};font-size:26px;line-height:32px;font-weight:700;color:#ffffff;letter-spacing:-0.5px;">Tessera</td>
|
||||||
|
</tr></table>
|
||||||
|
</td></tr>`;
|
||||||
|
const wave = src
|
||||||
|
? `<tr><td bgcolor="${C.header}" style="background-color:${C.header};line-height:0;font-size:0;">
|
||||||
|
<img src="${escapeHtml(src)}" width="600" height="40" alt="" style="display:block;width:100%;max-width:600px;height:auto;border:0;outline:none;text-decoration:none;">
|
||||||
|
</td></tr>`
|
||||||
|
: `<tr><td bgcolor="${C.header}" height="4" style="background-color:${C.header};height:4px;font-size:1px;line-height:4px;border-bottom:3px solid ${C.yellow};"> </td></tr>`;
|
||||||
|
return bar + wave;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut die Willkommensmail. Die sechs Texte (Betreff, Ueberschrift,
|
||||||
|
* Einleitung, Anmeldehinweis je Kontoart, Abschluss) kommen aus der eigenen
|
||||||
|
* Vorlage des Mandanten, sonst
|
||||||
|
* aus `DEFAULT_WELCOME_MAIL_TEXTS`. Sie sind REINER Text: Platzhalter werden
|
||||||
|
* auf dem Text ersetzt, erst danach wird alles escaped — HTML aus der
|
||||||
|
* Vorlage oder aus einem Benutzerwert erscheint als Text, nie als Markup.
|
||||||
|
* Kopf, Kasten Adresse/Benutzername, Knopf "Passwort festlegen" mit
|
||||||
|
* Gueltigkeitshinweis, Knopf "Zu Tessera" und Fusszeile sind feste Bausteine
|
||||||
|
* und nicht Teil der Vorlage.
|
||||||
|
*/
|
||||||
|
export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail {
|
||||||
|
const base = safeUrl(input.appUrl.replace(/\/+$/, ''));
|
||||||
|
const loginUrl = `${base}/login`;
|
||||||
|
const texts = input.texts ?? DEFAULT_WELCOME_MAIL_TEXTS;
|
||||||
|
|
||||||
|
const values: WelcomeMailPlaceholderValues = {
|
||||||
|
name: input.name,
|
||||||
|
vorname: firstNameOf(input.name, input.username),
|
||||||
|
benutzername: input.username,
|
||||||
|
email: input.email,
|
||||||
|
adresse: base,
|
||||||
|
firma: input.tenantName,
|
||||||
|
};
|
||||||
|
const fill = (value: string) => applyWelcomeMailPlaceholders(value, values);
|
||||||
|
|
||||||
|
const subject = singleLine(fill(texts.subject)) || WELCOME_MAIL_SUBJECT;
|
||||||
|
const heading = singleLine(fill(texts.heading));
|
||||||
|
const introParagraphs = paragraphsOf(fill(texts.intro));
|
||||||
|
const hintParagraphs = paragraphsOf(fill(loginHintText(input.account, texts)));
|
||||||
|
const closingParagraphs = paragraphsOf(fill(texts.closing));
|
||||||
|
const notice = input.notice ? singleLine(input.notice) : '';
|
||||||
|
|
||||||
|
// ── Text-Alternative ───────────────────────────────────────────────────
|
||||||
|
const textLines: string[] = [];
|
||||||
|
if (notice) textLines.push(`[${notice}]`, '');
|
||||||
|
if (heading) textLines.push(heading, '');
|
||||||
|
for (const para of introParagraphs) textLines.push(para, '');
|
||||||
|
textLines.push('Ihre Zugangsdaten', `Adresse: ${base}`, `Benutzername: ${input.username}`);
|
||||||
|
for (const para of hintParagraphs) textLines.push('', para);
|
||||||
|
if (input.account.kind === 'local') {
|
||||||
|
textLines.push(
|
||||||
|
'',
|
||||||
|
'Passwort festlegen:',
|
||||||
|
input.account.setPasswordUrl,
|
||||||
|
validityText(input.account.validHours),
|
||||||
|
);
|
||||||
|
} else if (input.account.kind === 'local-example') {
|
||||||
|
textLines.push('', 'Passwort festlegen:', loginUrl, EXAMPLE_LINK_NOTE);
|
||||||
|
}
|
||||||
|
textLines.push('', 'Zu Tessera:', loginUrl);
|
||||||
|
for (const para of closingParagraphs) textLines.push('', para);
|
||||||
|
textLines.push('', '--', FOOTER);
|
||||||
|
const text = textLines.join('\n');
|
||||||
|
|
||||||
|
// ── HTML ───────────────────────────────────────────────────────────────
|
||||||
|
const username = escapeHtml(input.username);
|
||||||
|
const baseHtml = escapeHtml(base);
|
||||||
|
|
||||||
|
const p = (content: string, extra = '') =>
|
||||||
|
`<p style="margin:0 0 16px;font-family:${FONT};font-size:16px;line-height:25px;color:${C.body};${extra}">${content}</p>`;
|
||||||
|
|
||||||
|
/** Reiner Text -> escaped, einfache Umbrueche als <br>. */
|
||||||
|
const para = (value: string) => escapeHtml(value).replace(/\n/g, '<br>');
|
||||||
|
|
||||||
|
const label = (content: string) =>
|
||||||
|
`<div style="font-family:${FONT};font-size:12px;line-height:16px;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:${C.muted};">${content}</div>`;
|
||||||
|
|
||||||
|
let accountBlock = hintParagraphs
|
||||||
|
.map((part, i) => p(para(part), i === 0 ? 'margin:24px 0 16px;' : ''))
|
||||||
|
.join('\n');
|
||||||
|
if (input.account.kind === 'local') {
|
||||||
|
const setUrl = safeUrl(input.account.setPasswordUrl);
|
||||||
|
accountBlock += `${button(setUrl, 'Passwort festlegen', C.ink, '#ffffff')}
|
||||||
|
<p style="margin:12px 0 0;font-family:${FONT};font-size:13px;line-height:20px;color:${C.muted};">${escapeHtml(validityText(input.account.validHours))}</p>`;
|
||||||
|
} else if (input.account.kind === 'local-example') {
|
||||||
|
accountBlock += `${button(loginUrl, 'Passwort festlegen', C.ink, '#ffffff')}
|
||||||
|
<p style="margin:12px 0 0;font-family:${FONT};font-size:13px;line-height:20px;color:${C.muted};">${escapeHtml(EXAMPLE_LINK_NOTE)}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noticeRow = notice
|
||||||
|
? `<tr><td style="padding:0 0 12px;"><table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0"><tr><td bgcolor="#fff8c2" style="background-color:#fff8c2;border:1px solid #e6d74c;border-radius:8px;padding:10px 16px;font-family:${FONT};font-size:13px;line-height:20px;color:${C.ink};">${escapeHtml(notice)}</td></tr></table></td></tr>
|
||||||
|
`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const headingHtml = heading
|
||||||
|
? `<h1 style="margin:0 0 16px;font-family:${FONT};font-size:24px;line-height:32px;font-weight:700;color:${C.ink};">${escapeHtml(heading)}</h1>
|
||||||
|
`
|
||||||
|
: '';
|
||||||
|
const introHtml = introParagraphs.map((part) => p(para(part))).join('\n');
|
||||||
|
|
||||||
|
const closingHtml = closingParagraphs.length
|
||||||
|
? `<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0"><tr><td style="border-top:1px solid ${C.line};padding-top:20px;">
|
||||||
|
${closingParagraphs
|
||||||
|
.map((part, i) =>
|
||||||
|
p(
|
||||||
|
para(part),
|
||||||
|
`font-size:14px;line-height:22px;margin:0${i < closingParagraphs.length - 1 ? ' 0 12px' : ''};`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.join('\n')}
|
||||||
|
</td></tr></table>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="color-scheme" content="light">
|
||||||
|
<meta name="supported-color-schemes" content="light">
|
||||||
|
<title>${escapeHtml(subject)}</title>
|
||||||
|
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
||||||
|
<style>
|
||||||
|
a { color: ${C.link}; }
|
||||||
|
@media only screen and (max-width: 620px) {
|
||||||
|
.tsr-pad { padding-left: 24px !important; padding-right: 24px !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background-color:${C.page};-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
|
||||||
|
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;font-size:1px;line-height:1px;color:${C.page};">Ihr Zugang zu Tessera ist eingerichtet – hier finden Sie Adresse und Benutzername.</div>
|
||||||
|
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" bgcolor="${C.page}" style="background-color:${C.page};">
|
||||||
|
<tr><td align="center" style="padding:32px 12px;">
|
||||||
|
<!--[if mso]><table role="presentation" width="600" border="0" cellpadding="0" cellspacing="0"><tr><td><![endif]-->
|
||||||
|
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="width:100%;max-width:600px;border-collapse:separate;">
|
||||||
|
${noticeRow}${headerRow(input.headerImageSrc)}
|
||||||
|
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:24px 40px 12px;">
|
||||||
|
${headingHtml}${introHtml}
|
||||||
|
<table role="presentation" width="100%" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;margin:8px 0 0;">
|
||||||
|
<tr><td bgcolor="${C.well}" style="background-color:${C.well};border:1px solid ${C.line};border-left:4px solid ${C.yellow};border-radius:8px;padding:18px 22px;">
|
||||||
|
${label('Adresse')}
|
||||||
|
<div style="margin:4px 0 14px;font-family:${FONT};font-size:16px;line-height:24px;font-weight:600;"><a href="${escapeHtml(loginUrl)}" target="_blank" style="color:${C.link};text-decoration:none;">${baseHtml}</a></div>
|
||||||
|
${label('Benutzername')}
|
||||||
|
<div style="margin:4px 0 0;font-family:Consolas,'SF Mono',Menlo,'Courier New',monospace;font-size:16px;line-height:24px;font-weight:600;color:${C.ink};">${username}</div>
|
||||||
|
</td></tr></table>
|
||||||
|
${accountBlock}
|
||||||
|
</td></tr>
|
||||||
|
<tr><td class="tsr-pad" bgcolor="${C.card}" align="left" style="background-color:${C.card};padding:20px 40px 8px;">
|
||||||
|
${button(loginUrl, 'Zu Tessera', C.yellow, C.ink)}
|
||||||
|
</td></tr>
|
||||||
|
<tr><td class="tsr-pad" bgcolor="${C.card}" style="background-color:${C.card};padding:20px 40px 36px;border-radius:0 0 12px 12px;">
|
||||||
|
${closingHtml}
|
||||||
|
</td></tr>
|
||||||
|
<tr><td align="center" style="padding:20px 24px 0;font-family:${FONT};font-size:12px;line-height:18px;color:${C.muted};">${escapeHtml(FOOTER)}</td></tr>
|
||||||
|
</table>
|
||||||
|
<!--[if mso]></td></tr></table><![endif]-->
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
return { subject, text, html };
|
||||||
|
}
|
||||||
@@ -181,11 +181,20 @@ const RELATION_SPEC_EXCEPTIONS = new Set<string>(['apps/api/src/tenders/backfill
|
|||||||
* seinem `forSystem()`-Aufruf aus dem Dienst entfernt. Dieselbe Migration
|
* seinem `forSystem()`-Aufruf aus dem Dienst entfernt. Dieselbe Migration
|
||||||
* nimmt die `system_read_policy` auf "DashboardImage" zurueck. Summe neu:
|
* nimmt die `system_read_policy` auf "DashboardImage" zurueck. Summe neu:
|
||||||
* 5 Dateien, 6 Aufrufe.
|
* 5 Dateien, 6 Aufrufe.
|
||||||
|
*
|
||||||
|
* quick-260929-if2 (Aufgabe 3): eine sechste Datei kommt hinzu —
|
||||||
|
* `reminders/reminder-mail.scheduler.ts`, EIN Aufruf: die Kandidatenabfrage
|
||||||
|
* des E-Mail-Planers fuer Erinnerungen (`reminder.findMany`, nur skalarer
|
||||||
|
* Select, alle Mandanten). Alle Schreib- und Folgezugriffe laufen je Zeile
|
||||||
|
* gebunden ueber `forTenant(prisma, c.tenantId)`. Die passende Regel ist
|
||||||
|
* `system_read_policy ... FOR SELECT` auf "Reminder" (Migration
|
||||||
|
* 20260929140000). Summe neu: 6 Dateien, 7 Aufrufe.
|
||||||
*/
|
*/
|
||||||
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
|
const FORSYSTEM_ALLOWED_CALL_SITES = new Map<string, number>([
|
||||||
['apps/api/src/dkv/dkv.service.ts', 1],
|
['apps/api/src/dkv/dkv.service.ts', 1],
|
||||||
['apps/api/src/ldap/ldap-config.service.ts', 2],
|
['apps/api/src/ldap/ldap-config.service.ts', 2],
|
||||||
['apps/api/src/proxmox/proxmox.service.ts', 1],
|
['apps/api/src/proxmox/proxmox.service.ts', 1],
|
||||||
|
['apps/api/src/reminders/reminder-mail.scheduler.ts', 1],
|
||||||
['apps/api/src/tenders/tender-digest.scheduler.ts', 1],
|
['apps/api/src/tenders/tender-digest.scheduler.ts', 1],
|
||||||
['apps/api/src/tenders/tender-matching.service.ts', 1],
|
['apps/api/src/tenders/tender-matching.service.ts', 1],
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsISO8601,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
ValidateIf,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
const trimString = ({ value }: { value: unknown }) =>
|
||||||
|
typeof value === 'string' ? value.trim() : value;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO fuer das Anlegen einer Erinnerung (quick-260929-if2). `tenantId`,
|
||||||
|
* `userId` und die Spuren des E-Mail-Planers gibt es hier nicht: die globale
|
||||||
|
* ValidationPipe (`whitelist: true`) verwirft sie, der Dienst setzt Mandant und
|
||||||
|
* Benutzer ausschliesslich aus dem Anmelde-Token (T-IF2-02).
|
||||||
|
*/
|
||||||
|
export class CreateReminderDto {
|
||||||
|
@Transform(trimString)
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(200)
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
/** Faelligkeit als ISO-8601-Zeitpunkt; der Client rechnet die Ortszeit um. */
|
||||||
|
@IsISO8601({ strict: true })
|
||||||
|
dueAt!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zusaetzlich per E-Mail erinnern (Aufgabe 3). `true` ist nur erlaubt, wenn
|
||||||
|
* der Mandant einen E-Mail-Versand eingerichtet UND der Benutzer eine
|
||||||
|
* Adresse hat (sonst 400 im Dienst).
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
emailEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nur ein FEHLENDES Feld wird uebersprungen — `null` wird geprueft und damit abgelehnt. */
|
||||||
|
const whenPresent = ValidateIf((_obj: object, value: unknown) => value !== undefined);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Teil-Update: jedes gesetzte Feld wird genauso geprueft wie beim Anlegen.
|
||||||
|
* Eine faellige Erinnerung laesst sich nicht aendern (409 im Dienst) — dafuer
|
||||||
|
* gibt es „Erledigt“ (loeschen) und „Spaeter erinnern“ (`SnoozeReminderDto`).
|
||||||
|
*
|
||||||
|
* WARUM KEIN `PartialType`: das setzt `@IsOptional()`, und das laesst auch
|
||||||
|
* `null` ungeprueft durch — `{"title": null}` kaeme dann als 500 aus der
|
||||||
|
* Datenbank statt als 400 aus der Pruefung. `whenPresent` ueberspringt nur
|
||||||
|
* fehlende Felder.
|
||||||
|
*/
|
||||||
|
export class UpdateReminderDto {
|
||||||
|
@whenPresent
|
||||||
|
@Transform(trimString)
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(200)
|
||||||
|
title?: string;
|
||||||
|
|
||||||
|
@whenPresent
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(2000)
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@whenPresent
|
||||||
|
@IsISO8601({ strict: true })
|
||||||
|
dueAt?: string;
|
||||||
|
|
||||||
|
@whenPresent
|
||||||
|
@IsBoolean()
|
||||||
|
emailEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Neuer Zeitpunkt beim Spaeter-Erinnern (D-03); der Client rechnet ihn aus (E-05). */
|
||||||
|
export class SnoozeReminderDto {
|
||||||
|
@IsISO8601({ strict: true })
|
||||||
|
dueAt!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ReminderMailScheduler.spec (quick-260929-if2, Aufgabe 3). Ein handgebauter,
|
||||||
|
* Prisma-foermiger Speicher (Konvention dieses Repos, vgl.
|
||||||
|
* tender-digest.scheduler.spec.ts). Wichtig: `updateMany` prueft und setzt
|
||||||
|
* OHNE `await` dazwischen — wie die eine SQL-Anweisung in der Datenbank ist der
|
||||||
|
* Anspruch damit atomar, und zwei Durchlaeufe ueber DENSELBEN Speicher koennen
|
||||||
|
* ihn nur einmal gewinnen (T-IF2-06).
|
||||||
|
*/
|
||||||
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
|
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__tenantClient(tenantId)),
|
||||||
|
forSystem: vi.fn((prisma: any) => prisma.__systemClient()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
import { ReminderMailScheduler } from './reminder-mail.scheduler';
|
||||||
|
|
||||||
|
const NOW = new Date('2026-09-29T12:00:00.000Z');
|
||||||
|
const minutesAgo = (m: number) => new Date(NOW.getTime() - m * 60_000);
|
||||||
|
|
||||||
|
interface Row {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
userId: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
dueAt: Date;
|
||||||
|
emailEnabled: boolean;
|
||||||
|
emailSentAt: Date | null;
|
||||||
|
emailAttempts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(over: Partial<Row> & { id: string }): Row {
|
||||||
|
return {
|
||||||
|
tenantId: 't1',
|
||||||
|
userId: 'u1',
|
||||||
|
title: `Titel ${over.id}`,
|
||||||
|
description: '',
|
||||||
|
dueAt: minutesAgo(1),
|
||||||
|
emailEnabled: true,
|
||||||
|
emailSentAt: null,
|
||||||
|
emailAttempts: 0,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sameTime = (a: Date | null, b: Date | null) =>
|
||||||
|
a === null || b === null ? a === b : a.getTime() === b.getTime();
|
||||||
|
|
||||||
|
function makeStore(
|
||||||
|
rows: Row[],
|
||||||
|
emails: Record<string, string | null> = { u1: 'u1@example.invalid' },
|
||||||
|
inactive: string[] = [],
|
||||||
|
) {
|
||||||
|
const systemFindMany = vi.fn(async ({ where, take, orderBy }: any) => {
|
||||||
|
let list = rows.filter(
|
||||||
|
(r) =>
|
||||||
|
r.emailEnabled === where.emailEnabled &&
|
||||||
|
r.emailSentAt === where.emailSentAt &&
|
||||||
|
r.emailAttempts < where.emailAttempts.lt &&
|
||||||
|
r.dueAt.getTime() <= where.dueAt.lte.getTime() &&
|
||||||
|
r.dueAt.getTime() >= where.dueAt.gte.getTime(),
|
||||||
|
);
|
||||||
|
if (orderBy?.dueAt === 'asc')
|
||||||
|
list = [...list].sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime());
|
||||||
|
return list
|
||||||
|
.slice(0, take)
|
||||||
|
.map(({ id, tenantId, userId, dueAt }) => ({ id, tenantId, userId, dueAt }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const boundLog: string[] = [];
|
||||||
|
const tenantClient = (tenantId: string) => ({
|
||||||
|
reminder: {
|
||||||
|
updateMany: vi.fn(async ({ where, data }: any) => {
|
||||||
|
boundLog.push(`updateMany:${tenantId}`);
|
||||||
|
let count = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.id !== where.id || r.tenantId !== where.tenantId || r.tenantId !== tenantId)
|
||||||
|
continue;
|
||||||
|
if ('dueAt' in where && !sameTime(r.dueAt, where.dueAt)) continue;
|
||||||
|
if ('emailEnabled' in where && r.emailEnabled !== where.emailEnabled) continue;
|
||||||
|
if ('emailSentAt' in where && !sameTime(r.emailSentAt, where.emailSentAt)) continue;
|
||||||
|
if (where.emailAttempts?.lt !== undefined && !(r.emailAttempts < where.emailAttempts.lt))
|
||||||
|
continue;
|
||||||
|
if (data.emailSentAt !== undefined) r.emailSentAt = data.emailSentAt;
|
||||||
|
if (data.emailAttempts?.increment) r.emailAttempts += data.emailAttempts.increment;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return { count };
|
||||||
|
}),
|
||||||
|
findFirst: vi.fn(async ({ where }: any) => {
|
||||||
|
boundLog.push(`findFirst:${tenantId}`);
|
||||||
|
const r = rows.find(
|
||||||
|
(x) => x.id === where.id && x.tenantId === tenantId && sameTime(x.dueAt, where.dueAt),
|
||||||
|
);
|
||||||
|
return r ? { title: r.title, description: r.description, dueAt: r.dueAt } : null;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
findFirst: vi.fn(async ({ where }: any) => {
|
||||||
|
const isActive = !inactive.includes(where.id);
|
||||||
|
if ('isActive' in where && where.isActive !== isActive) return null;
|
||||||
|
return { email: emails[where.id] ?? null };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const prisma: any = {
|
||||||
|
__systemClient: () => ({ reminder: { findMany: systemFindMany } }),
|
||||||
|
__tenantClient: tenantClient,
|
||||||
|
};
|
||||||
|
return { prisma, rows, systemFindMany, boundLog };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeScheduler(prisma: any, opts: { smtp?: unknown; sendResult?: boolean | Error } = {}) {
|
||||||
|
const registry = { addInterval: vi.fn(), deleteInterval: vi.fn() };
|
||||||
|
const settings = { getSmtpConfig: vi.fn(async () => (opts.smtp === undefined ? {} : opts.smtp)) };
|
||||||
|
const mail = {
|
||||||
|
sendReminderEmail: vi.fn(async () => {
|
||||||
|
if (opts.sendResult instanceof Error) throw opts.sendResult;
|
||||||
|
return opts.sendResult ?? true;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const scheduler = new ReminderMailScheduler(
|
||||||
|
registry as any,
|
||||||
|
prisma,
|
||||||
|
settings as any,
|
||||||
|
mail as any,
|
||||||
|
);
|
||||||
|
return { scheduler, registry, settings, mail };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ReminderMailScheduler — Anspruch (T-IF2-06)', () => {
|
||||||
|
it('zwei Instanzen ueber denselben Speicher senden genau eine Mail', async () => {
|
||||||
|
const store = makeStore([row({ id: 'a' })]);
|
||||||
|
const one = makeScheduler(store.prisma);
|
||||||
|
const two = makeScheduler(store.prisma);
|
||||||
|
await Promise.all([one.scheduler.runTick(NOW), two.scheduler.runTick(NOW)]);
|
||||||
|
expect(
|
||||||
|
one.mail.sendReminderEmail.mock.calls.length + two.mail.sendReminderEmail.mock.calls.length,
|
||||||
|
).toBe(1);
|
||||||
|
expect(store.rows[0].emailSentAt).toEqual(NOW);
|
||||||
|
expect(store.rows[0].emailAttempts).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein zweiter Durchlauf danach sendet nicht erneut', async () => {
|
||||||
|
const store = makeStore([row({ id: 'a' })]);
|
||||||
|
const { scheduler, mail } = makeScheduler(store.prisma);
|
||||||
|
await scheduler.runTick(NOW);
|
||||||
|
await scheduler.runTick(new Date(NOW.getTime() + 30_000));
|
||||||
|
expect(mail.sendReminderEmail).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein ueberlappender Durchlauf im selben Prozess wird uebersprungen (running)', async () => {
|
||||||
|
const store = makeStore([row({ id: 'a' })]);
|
||||||
|
const { scheduler } = makeScheduler(store.prisma);
|
||||||
|
const first = scheduler.runTick(NOW);
|
||||||
|
await scheduler.runTick(NOW); // laeuft, waehrend der erste noch nicht fertig ist
|
||||||
|
await first;
|
||||||
|
expect(store.systemFindMany).toHaveBeenCalledTimes(1);
|
||||||
|
// danach ist der Riegel wieder offen
|
||||||
|
await scheduler.runTick(NOW);
|
||||||
|
expect(store.systemFindMany).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sendet mit Titel, Beschreibung und Faelligkeit an die Adresse des Besitzers, gebunden an dessen Mandanten', async () => {
|
||||||
|
const store = makeStore([row({ id: 'a', tenantId: 't7', userId: 'u1', description: 'Text' })], {
|
||||||
|
u1: 'anna@example.invalid',
|
||||||
|
});
|
||||||
|
const { scheduler, mail, settings } = makeScheduler(store.prisma);
|
||||||
|
await scheduler.runTick(NOW);
|
||||||
|
expect(mail.sendReminderEmail).toHaveBeenCalledWith('t7', 'anna@example.invalid', {
|
||||||
|
title: 'Titel a',
|
||||||
|
description: 'Text',
|
||||||
|
dueAt: store.rows[0].dueAt,
|
||||||
|
});
|
||||||
|
expect(settings.getSmtpConfig).toHaveBeenCalledWith('t7');
|
||||||
|
expect(forTenant).toHaveBeenCalledWith(store.prisma, 't7');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ReminderMailScheduler — Fehlschlag und Wiederholung (E-04)', () => {
|
||||||
|
it('ein Transportfehler gibt den Anspruch frei; der naechste Durchlauf versucht es erneut', async () => {
|
||||||
|
const store = makeStore([row({ id: 'a' })]);
|
||||||
|
const { scheduler, mail } = makeScheduler(store.prisma, { sendResult: false });
|
||||||
|
await scheduler.runTick(NOW);
|
||||||
|
expect(store.rows[0].emailSentAt).toBeNull();
|
||||||
|
expect(store.rows[0].emailAttempts).toBe(1);
|
||||||
|
await scheduler.runTick(new Date(NOW.getTime() + 30_000));
|
||||||
|
expect(mail.sendReminderEmail).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nach drei Versuchen ist die Erinnerung kein Kandidat mehr', async () => {
|
||||||
|
const store = makeStore([row({ id: 'a' })]);
|
||||||
|
const { scheduler, mail } = makeScheduler(store.prisma, { sendResult: false });
|
||||||
|
for (let i = 0; i < 5; i++) await scheduler.runTick(new Date(NOW.getTime() + i * 30_000));
|
||||||
|
expect(mail.sendReminderEmail).toHaveBeenCalledTimes(3);
|
||||||
|
expect(store.rows[0].emailAttempts).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wirft der Versand, wird der Anspruch ebenfalls freigegeben und der Durchlauf bricht nicht ab', async () => {
|
||||||
|
const store = makeStore([row({ id: 'a' }), row({ id: 'b', dueAt: minutesAgo(2) })]);
|
||||||
|
const { scheduler, mail } = makeScheduler(store.prisma, { sendResult: new Error('kaputt') });
|
||||||
|
await expect(scheduler.runTick(NOW)).resolves.toBeUndefined();
|
||||||
|
expect(mail.sendReminderEmail).toHaveBeenCalledTimes(2);
|
||||||
|
expect(store.rows.every((r) => r.emailSentAt === null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die Freigabe trifft nur den eigenen Anspruch (gleicher Zeitstempel)', async () => {
|
||||||
|
const store = makeStore([row({ id: 'a' })]);
|
||||||
|
const { scheduler } = makeScheduler(store.prisma, { sendResult: false });
|
||||||
|
const spy = vi.spyOn(store.prisma, '__tenantClient');
|
||||||
|
await scheduler.runTick(NOW);
|
||||||
|
const client = spy.mock.results[0].value;
|
||||||
|
const releaseCall = client.reminder.updateMany.mock.calls[1][0];
|
||||||
|
expect(releaseCall.where).toEqual({ id: 'a', tenantId: 't1', emailSentAt: NOW });
|
||||||
|
expect(releaseCall.data).toEqual({ emailSentAt: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne SmtpConfig: kein Versand, Anspruch bleibt, keine Wiederholung', async () => {
|
||||||
|
const store = makeStore([row({ id: 'a' })]);
|
||||||
|
const { scheduler, mail } = makeScheduler(store.prisma, { smtp: null });
|
||||||
|
await scheduler.runTick(NOW);
|
||||||
|
await scheduler.runTick(new Date(NOW.getTime() + 30_000));
|
||||||
|
expect(mail.sendReminderEmail).not.toHaveBeenCalled();
|
||||||
|
expect(store.rows[0].emailSentAt).toEqual(NOW);
|
||||||
|
expect(store.rows[0].emailAttempts).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne E-Mail-Adresse des Benutzers: kein Versand, Anspruch bleibt', async () => {
|
||||||
|
const store = makeStore([row({ id: 'a' })], { u1: null });
|
||||||
|
const { scheduler, mail } = makeScheduler(store.prisma);
|
||||||
|
await scheduler.runTick(NOW);
|
||||||
|
await scheduler.runTick(new Date(NOW.getTime() + 30_000));
|
||||||
|
expect(mail.sendReminderEmail).not.toHaveBeenCalled();
|
||||||
|
expect(store.rows[0].emailSentAt).toEqual(NOW);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deaktivierter Benutzer: kein Versand, Anspruch bleibt wie ohne Adresse', async () => {
|
||||||
|
const store = makeStore([row({ id: 'a' })], { u1: 'u1@example.invalid' }, ['u1']);
|
||||||
|
const { scheduler, mail } = makeScheduler(store.prisma);
|
||||||
|
const spy = vi.spyOn(store.prisma, '__tenantClient');
|
||||||
|
await scheduler.runTick(NOW);
|
||||||
|
await scheduler.runTick(new Date(NOW.getTime() + 30_000));
|
||||||
|
expect(mail.sendReminderEmail).not.toHaveBeenCalled();
|
||||||
|
expect(store.rows[0].emailSentAt).toEqual(NOW);
|
||||||
|
expect(store.rows[0].emailAttempts).toBe(1);
|
||||||
|
const client = spy.mock.results[0].value;
|
||||||
|
expect(client.user.findFirst.mock.calls[0][0].where).toEqual({
|
||||||
|
id: 'u1',
|
||||||
|
tenantId: 't1',
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ReminderMailScheduler — Kandidaten (E-03, T-IF2-07)', () => {
|
||||||
|
it('die Abfrage waehlt nur emailEnabled, ohne Anspruch, unter 3 Versuchen, faellig und nicht aelter als 24 h', async () => {
|
||||||
|
const store = makeStore([
|
||||||
|
row({ id: 'ok' }),
|
||||||
|
row({ id: 'aus', emailEnabled: false }),
|
||||||
|
row({ id: 'schon', emailSentAt: minutesAgo(1) }),
|
||||||
|
row({ id: 'drei', emailAttempts: 3 }),
|
||||||
|
row({ id: 'zukunft', dueAt: new Date(NOW.getTime() + 60_000) }),
|
||||||
|
row({ id: 'alt', dueAt: new Date(NOW.getTime() - 25 * 3600_000) }),
|
||||||
|
]);
|
||||||
|
const { scheduler, mail } = makeScheduler(store.prisma);
|
||||||
|
await scheduler.runTick(NOW);
|
||||||
|
expect(mail.sendReminderEmail).toHaveBeenCalledTimes(1);
|
||||||
|
const args = store.systemFindMany.mock.calls[0][0];
|
||||||
|
expect(args.where).toEqual({
|
||||||
|
emailEnabled: true,
|
||||||
|
emailSentAt: null,
|
||||||
|
emailAttempts: { lt: 3 },
|
||||||
|
dueAt: { lte: NOW, gte: new Date(NOW.getTime() - 24 * 3600_000) },
|
||||||
|
});
|
||||||
|
expect(args.take).toBe(200);
|
||||||
|
expect(args.orderBy).toEqual({ dueAt: 'asc' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Systemklient liest nur skalar, ohne Relation', async () => {
|
||||||
|
const store = makeStore([row({ id: 'a' })]);
|
||||||
|
const { scheduler } = makeScheduler(store.prisma);
|
||||||
|
await scheduler.runTick(NOW);
|
||||||
|
expect(store.systemFindMany.mock.calls[0][0].select).toEqual({
|
||||||
|
id: true,
|
||||||
|
tenantId: true,
|
||||||
|
userId: true,
|
||||||
|
dueAt: true,
|
||||||
|
});
|
||||||
|
expect(forSystem).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine fehlerhafte Zeile haelt die uebrigen nicht an', async () => {
|
||||||
|
const store = makeStore([row({ id: 'a' }), row({ id: 'b', dueAt: minutesAgo(2) })]);
|
||||||
|
const { scheduler, mail } = makeScheduler(store.prisma);
|
||||||
|
// erste Zeile (b ist aelter, kommt zuerst): der Benutzer-Zugriff wirft
|
||||||
|
let calls = 0;
|
||||||
|
const original = store.prisma.__tenantClient;
|
||||||
|
store.prisma.__tenantClient = (tenantId: string) => {
|
||||||
|
const client = original(tenantId);
|
||||||
|
client.user.findFirst = vi.fn(async () => {
|
||||||
|
if (++calls === 1) throw new Error('DB weg');
|
||||||
|
return { email: 'u1@example.invalid' };
|
||||||
|
});
|
||||||
|
return client;
|
||||||
|
};
|
||||||
|
await scheduler.runTick(NOW);
|
||||||
|
expect(mail.sendReminderEmail).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nach einem Verschieben (Spuren zurueckgesetzt, neue Faelligkeit) gibt es genau eine weitere Mail (D-03)', async () => {
|
||||||
|
const store = makeStore([row({ id: 'a' })]);
|
||||||
|
const { scheduler, mail } = makeScheduler(store.prisma);
|
||||||
|
await scheduler.runTick(NOW);
|
||||||
|
expect(mail.sendReminderEmail).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Wie RemindersService.snooze: neue Faelligkeit, Spuren zurueck
|
||||||
|
const later = new Date(NOW.getTime() + 10 * 60_000);
|
||||||
|
Object.assign(store.rows[0], { dueAt: later, emailSentAt: null, emailAttempts: 0 });
|
||||||
|
await scheduler.runTick(new Date(later.getTime() + 5_000));
|
||||||
|
await scheduler.runTick(new Date(later.getTime() + 35_000));
|
||||||
|
expect(mail.sendReminderEmail).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ReminderMailScheduler — Start', () => {
|
||||||
|
it('registriert genau ein Intervall "reminder-email" und ersetzt ein vorhandenes', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const store = makeStore([]);
|
||||||
|
const { scheduler, registry } = makeScheduler(store.prisma);
|
||||||
|
scheduler.onApplicationBootstrap();
|
||||||
|
expect(registry.deleteInterval).toHaveBeenCalledWith('reminder-email');
|
||||||
|
expect(registry.addInterval).toHaveBeenCalledTimes(1);
|
||||||
|
expect(registry.addInterval.mock.calls[0][0]).toBe('reminder-email');
|
||||||
|
clearInterval(registry.addInterval.mock.calls[0][1]);
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Fehler beim Registrieren wird nur protokolliert, nie geworfen', () => {
|
||||||
|
const store = makeStore([]);
|
||||||
|
const { scheduler, registry } = makeScheduler(store.prisma);
|
||||||
|
registry.addInterval.mockImplementation(() => {
|
||||||
|
throw new Error('doppelt');
|
||||||
|
});
|
||||||
|
expect(() => scheduler.onApplicationBootstrap()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||||
|
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||||
|
import { MailService } from '../mail/mail.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
import { SettingsService } from '../settings/settings.service';
|
||||||
|
|
||||||
|
/** E-08: Abstand der Pruefung. */
|
||||||
|
const TICK_MS = 30_000;
|
||||||
|
/** E-03: so lange nach der Faelligkeit wird noch gemailt (Neustart, Ausfall). */
|
||||||
|
const CATCH_UP_MS = 24 * 60 * 60 * 1000;
|
||||||
|
/** E-04: hoechstens so viele Versuche je Faelligkeit. */
|
||||||
|
const MAX_ATTEMPTS = 3;
|
||||||
|
/** Kandidaten je Durchlauf. */
|
||||||
|
const BATCH = 200;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* E-Mail-Planer der Erinnerungen (quick-260929-if2): verschickt hoechstens EINE
|
||||||
|
* Mail je faelliger Erinnerung, auch wenn Tessera nirgends geoeffnet ist.
|
||||||
|
*
|
||||||
|
* WARUM DER ANSPRUCH VOR DEM SENDEN STEHT (E-04, T-IF2-06): ein `updateMany`
|
||||||
|
* setzt `emailSentAt` und zaehlt `emailAttempts` hoch, und zwar NUR, wo
|
||||||
|
* `emailSentAt` noch leer ist und `dueAt` unveraendert. Nur wer die Zeile mit
|
||||||
|
* `count === 1` bekommt, sendet. Mehrere API-Instanzen, ein Neustart mitten im
|
||||||
|
* Durchlauf oder zwei ueberlappende Durchlaeufe verschicken so nie doppelt.
|
||||||
|
* Scheitert der Transport, gibt der Planer den Anspruch wieder frei
|
||||||
|
* (`emailSentAt = null`), sodass der naechste Durchlauf es erneut versucht —
|
||||||
|
* hoechstens dreimal. Fehlt beim Senden die SMTP-Einrichtung oder die Adresse
|
||||||
|
* des Benutzers oder ist sein Konto deaktiviert (`isActive = false`), bleibt der Anspruch: die Faelligkeit gilt als erledigt und
|
||||||
|
* wird nur protokolliert, es gibt keine Wiederholschleife. Ein Verschieben
|
||||||
|
* („Spaeter erinnern“) setzt beide Felder zurueck (siehe `RemindersService`).
|
||||||
|
*
|
||||||
|
* WARUM 24 STUNDEN (E-03): bei Neustart oder Ausfall werden Erinnerungen der
|
||||||
|
* letzten 24 Stunden nachgeholt; wer spaeter SMTP einrichtet, bekommt keine
|
||||||
|
* Mails ueber alte Erinnerungen. WARUM ALLE 30 SEKUNDEN (E-08): die Mail soll
|
||||||
|
* zur Faelligkeit ankommen, die Abfrage ist ein einziger Indexzugriff auf
|
||||||
|
* `dueAt`.
|
||||||
|
*
|
||||||
|
* SYSTEMKONTEXT: die Kandidatenabfrage liest ueber ALLE Mandanten
|
||||||
|
* (`forSystem()`, `system_read_policy ... FOR SELECT`, Migration 20260929140000);
|
||||||
|
* der Select ist bewusst nur skalar — eine Relation im Systemklienten machte
|
||||||
|
* `User` zum Systemlese-Modell (WINDOWS #27). Alles Weitere, jede Schreib- und
|
||||||
|
* Leseoperation je Zeile, laeuft an den Mandanten der Zeile gebunden.
|
||||||
|
* Lebenszyklus `onApplicationBootstrap` wie `TenderSchedulerService`.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ReminderMailScheduler implements OnApplicationBootstrap {
|
||||||
|
private readonly logger = new Logger(ReminderMailScheduler.name);
|
||||||
|
private readonly JOB_NAME = 'reminder-email';
|
||||||
|
private running = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly schedulerRegistry: SchedulerRegistry,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly settingsService: SettingsService,
|
||||||
|
private readonly mail: MailService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Registriert das eine globale Intervall. Fehler werden nur protokolliert. */
|
||||||
|
onApplicationBootstrap(): void {
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
this.schedulerRegistry.deleteInterval(this.JOB_NAME);
|
||||||
|
} catch {
|
||||||
|
/* noch nicht registriert — beim ersten Start erwartet */
|
||||||
|
}
|
||||||
|
this.schedulerRegistry.addInterval(
|
||||||
|
this.JOB_NAME,
|
||||||
|
setInterval(() => {
|
||||||
|
this.runTick().catch((err) =>
|
||||||
|
this.logger.error(`Reminder email tick failed: ${(err as Error).message}`),
|
||||||
|
);
|
||||||
|
}, TICK_MS),
|
||||||
|
);
|
||||||
|
this.logger.log(
|
||||||
|
`Reminder email scheduler registered: every ${TICK_MS / 1000} s (single global job — all tenants)`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Reminder email scheduler init failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Durchlauf. Ueberlappende Aufrufe im selben Prozess werden uebersprungen. */
|
||||||
|
async runTick(now: Date = new Date()): Promise<void> {
|
||||||
|
if (this.running) return;
|
||||||
|
this.running = true;
|
||||||
|
try {
|
||||||
|
const systemPrisma = forSystem(this.prisma);
|
||||||
|
const candidates: { id: string; tenantId: string; userId: string; dueAt: Date }[] =
|
||||||
|
await systemPrisma.reminder.findMany({
|
||||||
|
where: {
|
||||||
|
emailEnabled: true,
|
||||||
|
emailSentAt: null,
|
||||||
|
emailAttempts: { lt: MAX_ATTEMPTS },
|
||||||
|
dueAt: { lte: now, gte: new Date(now.getTime() - CATCH_UP_MS) },
|
||||||
|
},
|
||||||
|
select: { id: true, tenantId: true, userId: true, dueAt: true },
|
||||||
|
orderBy: { dueAt: 'asc' },
|
||||||
|
take: BATCH,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
await this.processCandidate(candidate, now);
|
||||||
|
} catch (err) {
|
||||||
|
// Eine kaputte Zeile darf die uebrigen nicht anhalten.
|
||||||
|
this.logger.error(`Reminder email for ${candidate.id} failed: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processCandidate(
|
||||||
|
c: { id: string; tenantId: string; userId: string; dueAt: Date },
|
||||||
|
now: Date,
|
||||||
|
): Promise<void> {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, c.tenantId);
|
||||||
|
|
||||||
|
// Anspruch: nur wer count === 1 bekommt, sendet.
|
||||||
|
const claim = await tenantPrisma.reminder.updateMany({
|
||||||
|
where: {
|
||||||
|
id: c.id,
|
||||||
|
tenantId: c.tenantId,
|
||||||
|
dueAt: c.dueAt,
|
||||||
|
emailEnabled: true,
|
||||||
|
emailSentAt: null,
|
||||||
|
emailAttempts: { lt: MAX_ATTEMPTS },
|
||||||
|
},
|
||||||
|
data: { emailSentAt: now, emailAttempts: { increment: 1 } },
|
||||||
|
});
|
||||||
|
if (claim.count !== 1) return;
|
||||||
|
|
||||||
|
// Zeile nur mit UNVERAENDERTER Faelligkeit laden: wurde sie zwischen Anspruch
|
||||||
|
// und Laden verschoben, ist die Faelligkeit nicht mehr dieselbe.
|
||||||
|
const row = await tenantPrisma.reminder.findFirst({
|
||||||
|
where: { id: c.id, tenantId: c.tenantId, dueAt: c.dueAt },
|
||||||
|
select: { title: true, description: true, dueAt: true },
|
||||||
|
});
|
||||||
|
// Nur aktive Konten: ein deaktivierter Benutzer gilt wie einer ohne Adresse.
|
||||||
|
const user = await tenantPrisma.user.findFirst({
|
||||||
|
where: { id: c.userId, tenantId: c.tenantId, isActive: true },
|
||||||
|
select: { email: true },
|
||||||
|
});
|
||||||
|
const smtp = await this.settingsService.getSmtpConfig(c.tenantId);
|
||||||
|
if (!row || !user?.email || smtp === null) {
|
||||||
|
this.logger.log(
|
||||||
|
`Reminder email for ${c.id} übersprungen (${!row ? 'Zeile geändert' : !user ? 'Benutzer deaktiviert' : !user.email ? 'keine E-Mail-Adresse' : 'kein E-Mail-Versand eingerichtet'})`,
|
||||||
|
);
|
||||||
|
return; // Anspruch bleibt: gilt als erledigt, keine Wiederholschleife (E-04)
|
||||||
|
}
|
||||||
|
|
||||||
|
let sent = false;
|
||||||
|
try {
|
||||||
|
sent = await this.mail.sendReminderEmail(c.tenantId, user.email, row);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`Reminder email for ${c.id} threw: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
if (!sent) {
|
||||||
|
// Nur der eigene Anspruch (gleicher Zeitstempel) wird freigegeben.
|
||||||
|
await tenantPrisma.reminder.updateMany({
|
||||||
|
where: { id: c.id, tenantId: c.tenantId, emailSentAt: now },
|
||||||
|
data: { emailSentAt: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||||
|
import { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
|
||||||
|
import { RemindersController } from './reminders.controller';
|
||||||
|
|
||||||
|
function makeService() {
|
||||||
|
return {
|
||||||
|
list: vi.fn(async (..._args: unknown[]) => []),
|
||||||
|
getEmailAvailability: vi.fn(async (..._args: unknown[]) => ({
|
||||||
|
smtpConfigured: true,
|
||||||
|
hasEmail: true,
|
||||||
|
})),
|
||||||
|
create: vi.fn(async (..._args: unknown[]) => ({})),
|
||||||
|
update: vi.fn(async (..._args: unknown[]) => ({})),
|
||||||
|
snooze: vi.fn(async (..._args: unknown[]) => ({})),
|
||||||
|
remove: vi.fn(async (..._args: unknown[]) => ({ deleted: true })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = (tenantId?: string) => ({ tenantId }) as any;
|
||||||
|
const user = { id: 'u1', username: 'u', role: 'USER', tenantId: 't1' } as any;
|
||||||
|
const proto = RemindersController.prototype as any;
|
||||||
|
|
||||||
|
describe('RemindersController — Rollen', () => {
|
||||||
|
it.each([
|
||||||
|
'list',
|
||||||
|
'emailStatus',
|
||||||
|
'create',
|
||||||
|
'update',
|
||||||
|
'snooze',
|
||||||
|
'remove',
|
||||||
|
])('%s traegt keine Routen-Rolle (jeder Angemeldete)', (name) => {
|
||||||
|
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('haengt an Pfad reminders', () => {
|
||||||
|
expect(Reflect.getMetadata('path', RemindersController)).toBe('reminders');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RemindersController — Mandant', () => {
|
||||||
|
it('reicht req.tenantId und die Benutzerkennung an den Dienst weiter', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
const controller = new RemindersController(service as any);
|
||||||
|
await controller.list(req('t1'), user);
|
||||||
|
await controller.emailStatus(req('t1'), user);
|
||||||
|
expect(service.getEmailAvailability).toHaveBeenCalledWith('t1', 'u1');
|
||||||
|
await controller.create(req('t1'), user, { title: 'a', dueAt: '2099-01-01T10:00:00.000Z' });
|
||||||
|
expect(service.list).toHaveBeenCalledWith('t1', 'u1');
|
||||||
|
expect(service.create.mock.calls[0].slice(0, 2)).toEqual(['t1', 'u1']);
|
||||||
|
await controller.update(req('t1'), user, 'x', { title: 'b' });
|
||||||
|
await controller.snooze(req('t1'), user, 'x', { dueAt: '2099-01-01T10:00:00.000Z' });
|
||||||
|
await controller.remove(req('t1'), user, 'x');
|
||||||
|
expect(service.update.mock.calls[0].slice(0, 3)).toEqual(['t1', 'u1', 'x']);
|
||||||
|
expect(service.snooze.mock.calls[0].slice(0, 3)).toEqual(['t1', 'u1', 'x']);
|
||||||
|
expect(service.remove).toHaveBeenCalledWith('t1', 'u1', 'x');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wirft ForbiddenException ohne req.tenantId', async () => {
|
||||||
|
const controller = new RemindersController(makeService() as any);
|
||||||
|
await expect(controller.list(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
await expect(controller.emailStatus(req(), user)).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
await expect(controller.update(req(), user, 'x', {})).rejects.toBeInstanceOf(
|
||||||
|
ForbiddenException,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
controller.snooze(req(), user, 'x', { dueAt: '2099-01-01T10:00:00.000Z' }),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
await expect(controller.remove(req(), user, 'x')).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
await expect(
|
||||||
|
controller.create(req(), user, { title: 'a', dueAt: '2099-01-01T10:00:00.000Z' }),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die globale Pipe verwirft untergeschobene Felder (T-IF2-02)', async () => {
|
||||||
|
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||||
|
const out: any = await pipe.transform(
|
||||||
|
{
|
||||||
|
title: ' a ',
|
||||||
|
dueAt: '2099-01-01T10:00:00.000Z',
|
||||||
|
tenantId: 'evil',
|
||||||
|
userId: 'evil',
|
||||||
|
emailSentAt: '2020-01-01T00:00:00.000Z',
|
||||||
|
emailAttempts: 9,
|
||||||
|
},
|
||||||
|
{ type: 'body', metatype: CreateReminderDto },
|
||||||
|
);
|
||||||
|
expect(out).not.toHaveProperty('tenantId');
|
||||||
|
expect(out).not.toHaveProperty('userId');
|
||||||
|
expect(out).not.toHaveProperty('emailSentAt');
|
||||||
|
expect(out).not.toHaveProperty('emailAttempts');
|
||||||
|
expect(out.title).toBe('a');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RemindersController — Pipe fuer Aendern und Verschieben', () => {
|
||||||
|
it('Aendern verwirft untergeschobene Felder, ein Teil-Update ist erlaubt', async () => {
|
||||||
|
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||||
|
const out: any = await pipe.transform(
|
||||||
|
{ title: 'b', tenantId: 'evil', userId: 'evil', emailAttempts: 0 },
|
||||||
|
{ type: 'body', metatype: UpdateReminderDto },
|
||||||
|
);
|
||||||
|
expect(out).toEqual({ title: 'b' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'dueAt',
|
||||||
|
'emailEnabled',
|
||||||
|
])('Aendern mit %s: null ergibt 400 statt eines Datenbankfehlers', async (field) => {
|
||||||
|
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||||
|
await expect(
|
||||||
|
pipe.transform({ [field]: null }, { type: 'body', metatype: UpdateReminderDto }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Aendern: ein leerer Titel wird abgelehnt, ein leeres Objekt ist erlaubt', async () => {
|
||||||
|
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||||
|
await expect(
|
||||||
|
pipe.transform({ title: ' ' }, { type: 'body', metatype: UpdateReminderDto }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
await expect(
|
||||||
|
pipe.transform({}, { type: 'body', metatype: UpdateReminderDto }),
|
||||||
|
).resolves.toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Verschieben verlangt einen ISO-Zeitpunkt und verwirft Fremdfelder', async () => {
|
||||||
|
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||||
|
const out: any = await pipe.transform(
|
||||||
|
{ dueAt: '2099-01-01T10:00:00.000Z', userId: 'evil' },
|
||||||
|
{ type: 'body', metatype: SnoozeReminderDto },
|
||||||
|
);
|
||||||
|
expect(out).toEqual({ dueAt: '2099-01-01T10:00:00.000Z' });
|
||||||
|
await expect(
|
||||||
|
pipe.transform({ dueAt: 'morgen' }, { type: 'body', metatype: SnoozeReminderDto }),
|
||||||
|
).rejects.toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RemindersController — emailEnabled', () => {
|
||||||
|
it('die Pipe laesst emailEnabled beim Anlegen und Aendern durch und verlangt einen Wahrheitswert', async () => {
|
||||||
|
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||||
|
const created: any = await pipe.transform(
|
||||||
|
{ title: 'a', dueAt: '2099-01-01T10:00:00.000Z', emailEnabled: true },
|
||||||
|
{ type: 'body', metatype: CreateReminderDto },
|
||||||
|
);
|
||||||
|
expect(created.emailEnabled).toBe(true);
|
||||||
|
const updated: any = await pipe.transform(
|
||||||
|
{ emailEnabled: false },
|
||||||
|
{ type: 'body', metatype: UpdateReminderDto },
|
||||||
|
);
|
||||||
|
expect(updated.emailEnabled).toBe(false);
|
||||||
|
await expect(
|
||||||
|
pipe.transform(
|
||||||
|
{ title: 'a', dueAt: '2099-01-01T10:00:00.000Z', emailEnabled: 'ja' },
|
||||||
|
{ type: 'body', metatype: CreateReminderDto },
|
||||||
|
),
|
||||||
|
).rejects.toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RemindersController — Routen-Reihenfolge (statisch vor :id)', () => {
|
||||||
|
it('deklariert list vor jeder :id-Route', () => {
|
||||||
|
const methods = Object.getOwnPropertyNames(RemindersController.prototype);
|
||||||
|
const listIdx = methods.indexOf('list');
|
||||||
|
expect(listIdx).toBeGreaterThanOrEqual(0);
|
||||||
|
for (const name of methods) {
|
||||||
|
const path = Reflect.getMetadata('path', proto[name]);
|
||||||
|
if (typeof path === 'string' && path.startsWith(':id')) {
|
||||||
|
expect(listIdx).toBeLessThan(methods.indexOf(name));
|
||||||
|
// auch die statische Route email-status steht vor jeder :id-Route
|
||||||
|
expect(methods.indexOf('emailStatus')).toBeLessThan(methods.indexOf(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(methods.indexOf('emailStatus')).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(Reflect.getMetadata('path', proto.emailStatus)).toBe('email-status');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import type { AuthenticatedRequest, AuthUser } from '../auth/types/auth-user';
|
||||||
|
import { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
|
||||||
|
import { RemindersService } from './reminders.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persoenliche Erinnerungen (quick-260929-if2). Jeder angemeldete Benutzer
|
||||||
|
* verwaltet seine eigenen; fremde Kennungen sind 404 (D-05). Kein `@Roles`,
|
||||||
|
* kein `@UseModule`: das Widget haengt an keiner Modul-Aktivierung.
|
||||||
|
* `tenantId` kommt ausschliesslich aus `req.tenantId` (gesetzt vom
|
||||||
|
* `TenantGuard`), der Benutzer aus dem Token.
|
||||||
|
*
|
||||||
|
* ROUTEN-REIHENFOLGE: NestJS bildet Routen in Deklarationsreihenfolge ab.
|
||||||
|
* Jede statische GET-Route (`email-status`) MUSS ueber jeder
|
||||||
|
* `:id`-Route stehen, sonst faengt `:id` sie ab (404-Shadowing); der
|
||||||
|
* Controller-Test haelt die Reihenfolge fest.
|
||||||
|
*/
|
||||||
|
@Controller('reminders')
|
||||||
|
export class RemindersController {
|
||||||
|
constructor(private readonly service: RemindersService) {}
|
||||||
|
|
||||||
|
private requireTenantId(req: AuthenticatedRequest): string {
|
||||||
|
const tenantId = req.tenantId;
|
||||||
|
if (!tenantId) {
|
||||||
|
throw new ForbiddenException('Kein Mandantenkontext');
|
||||||
|
}
|
||||||
|
return tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list(@Req() req: AuthenticatedRequest, @CurrentUser() user: AuthUser) {
|
||||||
|
return this.service.list(this.requireTenantId(req), user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statische Route: MUSS ueber jeder :id-Route stehen (siehe Kopfkommentar).
|
||||||
|
@Get('email-status')
|
||||||
|
async emailStatus(@Req() req: AuthenticatedRequest, @CurrentUser() user: AuthUser) {
|
||||||
|
return this.service.getEmailAvailability(this.requireTenantId(req), user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Body() dto: CreateReminderDto,
|
||||||
|
) {
|
||||||
|
return this.service.create(this.requireTenantId(req), user.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
async update(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateReminderDto,
|
||||||
|
) {
|
||||||
|
return this.service.update(this.requireTenantId(req), user.id, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/snooze')
|
||||||
|
async snooze(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: SnoozeReminderDto,
|
||||||
|
) {
|
||||||
|
return this.service.snooze(this.requireTenantId(req), user.id, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
async remove(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
return this.service.remove(this.requireTenantId(req), user.id, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MailModule } from '../mail/mail.module';
|
||||||
|
import { SettingsModule } from '../settings/settings.module';
|
||||||
|
import { ReminderMailScheduler } from './reminder-mail.scheduler';
|
||||||
|
import { RemindersController } from './reminders.controller';
|
||||||
|
import { RemindersService } from './reminders.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erinnerungen (quick-260929-if2). `PrismaModule` ist global. `SettingsModule`
|
||||||
|
* liefert die Regel „E-Mail-Versand eingerichtet“ (E-09), `MailModule` den
|
||||||
|
* Versand fuer den Planer.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [SettingsModule, MailModule],
|
||||||
|
controllers: [RemindersController],
|
||||||
|
providers: [RemindersService, ReminderMailScheduler],
|
||||||
|
})
|
||||||
|
export class RemindersModule {}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// `forTenant` reicht den Klienten durch — die Bindung selbst prueft
|
||||||
|
// rls-access-inventory.spec.ts; hier zaehlt, mit welchen Argumenten gebunden wird.
|
||||||
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
|
forTenant: vi.fn((p: unknown) => p),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
import { RemindersService } from './reminders.service';
|
||||||
|
|
||||||
|
function makeFakePrisma() {
|
||||||
|
const rows = new Map<string, any>();
|
||||||
|
let seq = 0;
|
||||||
|
const reminder = {
|
||||||
|
create: vi.fn(async ({ data }: { data: any }) => {
|
||||||
|
const id = `r-${++seq}`;
|
||||||
|
const row = {
|
||||||
|
id,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
emailEnabled: false,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
rows.set(id, row);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
findMany: vi.fn(async ({ where, orderBy }: { where?: any; orderBy?: any } = {}) => {
|
||||||
|
let list = [...rows.values()];
|
||||||
|
if (where?.tenantId) list = list.filter((r) => r.tenantId === where.tenantId);
|
||||||
|
if (where?.userId) list = list.filter((r) => r.userId === where.userId);
|
||||||
|
if (orderBy?.dueAt === 'asc') list.sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime());
|
||||||
|
return list;
|
||||||
|
}),
|
||||||
|
findFirst: vi.fn(async ({ where }: { where: any }) => {
|
||||||
|
const r = rows.get(where.id);
|
||||||
|
if (!r || r.tenantId !== where.tenantId || r.userId !== where.userId) return null;
|
||||||
|
return r;
|
||||||
|
}),
|
||||||
|
update: vi.fn(async ({ where, data }: { where: any; data: any }) => {
|
||||||
|
const row = { ...rows.get(where.id), ...data };
|
||||||
|
rows.set(where.id, row);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
// Wie die eine SQL-Anweisung: Bedingung pruefen und schreiben ohne `await` dazwischen.
|
||||||
|
updateMany: vi.fn(async ({ where, data }: { where: any; data: any }) => {
|
||||||
|
const r = rows.get(where.id);
|
||||||
|
if (!r || r.tenantId !== where.tenantId || r.userId !== where.userId) return { count: 0 };
|
||||||
|
if (where.dueAt?.gt && !(r.dueAt.getTime() > where.dueAt.gt.getTime())) return { count: 0 };
|
||||||
|
rows.set(where.id, { ...r, ...data });
|
||||||
|
return { count: 1 };
|
||||||
|
}),
|
||||||
|
delete: vi.fn(async ({ where }: { where: any }) => {
|
||||||
|
rows.delete(where.id);
|
||||||
|
}),
|
||||||
|
count: vi.fn(async ({ where }: { where?: any } = {}) => {
|
||||||
|
return [...rows.values()].filter(
|
||||||
|
(r) => r.tenantId === where?.tenantId && r.userId === where?.userId,
|
||||||
|
).length;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const user = {
|
||||||
|
findFirst: vi.fn(async ({ where }: { where: any }) => ({
|
||||||
|
email: where.id === 'u-ohne-mail' ? null : `${where.id}@example.invalid`,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
return { reminder, user, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(smtp: unknown = { host: 'smtp.example.invalid' }) {
|
||||||
|
const prisma = makeFakePrisma();
|
||||||
|
const settings = { getSmtpConfig: vi.fn(async (_tenantId: string) => smtp) };
|
||||||
|
return { prisma, settings, service: new RemindersService(prisma as any, settings as any) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const inHours = (h: number) => new Date(Date.now() + h * 3600_000).toISOString();
|
||||||
|
|
||||||
|
describe('RemindersService — anlegen', () => {
|
||||||
|
it('speichert tenantId und userId aus den Argumenten, nie aus dem DTO', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
await service.create('t1', 'u1', {
|
||||||
|
title: 'Zahnarzt',
|
||||||
|
dueAt: inHours(2),
|
||||||
|
tenantId: 'evil',
|
||||||
|
userId: 'evil',
|
||||||
|
} as any);
|
||||||
|
const data = prisma.reminder.create.mock.calls[0][0].data;
|
||||||
|
expect(data.tenantId).toBe('t1');
|
||||||
|
expect(data.userId).toBe('u1');
|
||||||
|
expect(data.description).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bindet mit Mandant UND Benutzer', async () => {
|
||||||
|
const { service } = setup();
|
||||||
|
await service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) });
|
||||||
|
expect(forTenant).toHaveBeenLastCalledWith(expect.anything(), 't1', 'u1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt eine vergangene Faelligkeit mit 400 ab', async () => {
|
||||||
|
const { service } = setup();
|
||||||
|
await expect(
|
||||||
|
service.create('t1', 'u1', { title: 'a', dueAt: inHours(-1) }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lehnt eine Faelligkeit ueber 5 Jahre voraus mit 400 ab', async () => {
|
||||||
|
const { service } = setup();
|
||||||
|
await expect(
|
||||||
|
service.create('t1', 'u1', { title: 'a', dueAt: inHours(24 * 365 * 5 + 48) }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die 101. Erinnerung eines Benutzers ergibt 409', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
prisma.rows.set(`x${i}`, { id: `x${i}`, tenantId: 't1', userId: 'u1', dueAt: new Date() });
|
||||||
|
}
|
||||||
|
await expect(
|
||||||
|
service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) }),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
// ein anderer Benutzer ist davon nicht betroffen
|
||||||
|
await expect(
|
||||||
|
service.create('t1', 'u2', { title: 'a', dueAt: inHours(1) }),
|
||||||
|
).resolves.toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RemindersService — auflisten', () => {
|
||||||
|
it('liefert nur Zeilen von Mandant und Benutzer, nach Faelligkeit aufsteigend', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
await service.create('t1', 'u1', { title: 'spaet', dueAt: inHours(5) });
|
||||||
|
await service.create('t1', 'u1', { title: 'frueh', dueAt: inHours(1) });
|
||||||
|
await service.create('t1', 'u2', { title: 'fremd', dueAt: inHours(2) });
|
||||||
|
await service.create('t2', 'u1', { title: 'anderer Mandant', dueAt: inHours(2) });
|
||||||
|
const list = await service.list('t1', 'u1');
|
||||||
|
expect(list.map((r: any) => r.title)).toEqual(['frueh', 'spaet']);
|
||||||
|
const where = prisma.reminder.findMany.mock.calls[0]?.[0]?.where;
|
||||||
|
expect(where).toEqual({ tenantId: 't1', userId: 'u1' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Zeile direkt in den Speicher legen (auch faellige, die `create` ablehnen wuerde). */
|
||||||
|
function seed(
|
||||||
|
prisma: ReturnType<typeof makeFakePrisma>,
|
||||||
|
id: string,
|
||||||
|
dueAt: Date,
|
||||||
|
owner: { tenantId: string; userId: string } = { tenantId: 't1', userId: 'u1' },
|
||||||
|
) {
|
||||||
|
prisma.rows.set(id, {
|
||||||
|
id,
|
||||||
|
...owner,
|
||||||
|
title: 'alt',
|
||||||
|
description: 'beschr',
|
||||||
|
dueAt,
|
||||||
|
emailEnabled: false,
|
||||||
|
emailSentAt: new Date(),
|
||||||
|
emailAttempts: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const past = () => new Date(Date.now() - 3600_000);
|
||||||
|
const future = () => new Date(Date.now() + 3600_000);
|
||||||
|
|
||||||
|
describe('RemindersService — fremde und unbekannte Kennungen (D-05, T-IF2-01)', () => {
|
||||||
|
it.each([
|
||||||
|
['fremder Benutzer', { tenantId: 't1', userId: 'u2' }],
|
||||||
|
['fremder Mandant', { tenantId: 't2', userId: 'u1' }],
|
||||||
|
])('%s: aendern, verschieben und loeschen ergeben 404', async (_n, owner) => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
seed(prisma, 'x', past(), owner);
|
||||||
|
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(1) })).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
await expect(service.remove('t1', 'u1', 'x')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
expect(prisma.rows.has('x')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine unbekannte Kennung ergibt 404', async () => {
|
||||||
|
const { service } = setup();
|
||||||
|
await expect(service.remove('t1', 'u1', 'gibt-es-nicht')).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('das where jeder Abfrage traegt Mandant und Benutzer', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
seed(prisma, 'x', future());
|
||||||
|
await service.update('t1', 'u1', 'x', { title: 'n' });
|
||||||
|
expect(prisma.reminder.findFirst.mock.calls[0]?.[0]?.where).toEqual({
|
||||||
|
id: 'x',
|
||||||
|
tenantId: 't1',
|
||||||
|
userId: 'u1',
|
||||||
|
});
|
||||||
|
expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.where).toEqual({
|
||||||
|
id: 'x',
|
||||||
|
tenantId: 't1',
|
||||||
|
userId: 'u1',
|
||||||
|
dueAt: { gt: expect.any(Date) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RemindersService — aendern', () => {
|
||||||
|
it('eine faellige Erinnerung laesst sich nicht aendern (409)', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
seed(prisma, 'x', past());
|
||||||
|
await expect(service.update('t1', 'u1', 'x', { title: 'n' })).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine vergangene neue Faelligkeit ergibt 400', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
seed(prisma, 'x', future());
|
||||||
|
await expect(service.update('t1', 'u1', 'x', { dueAt: inHours(-2) })).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aendert nur die gesetzten Felder', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
seed(prisma, 'x', future());
|
||||||
|
await service.update('t1', 'u1', 'x', { title: 'neu' });
|
||||||
|
expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.data).toEqual({ title: 'neu' });
|
||||||
|
const newDue = inHours(9);
|
||||||
|
await service.update('t1', 'u1', 'x', { dueAt: newDue, description: '' });
|
||||||
|
expect(prisma.reminder.updateMany.mock.calls[1]?.[0]?.data).toEqual({
|
||||||
|
description: '',
|
||||||
|
dueAt: new Date(newDue),
|
||||||
|
emailSentAt: null,
|
||||||
|
emailAttempts: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine neue Faelligkeit setzt emailSentAt und emailAttempts zurueck, sonst bleiben sie', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
seed(prisma, 'x', future());
|
||||||
|
await service.update('t1', 'u1', 'x', { title: 'neu' });
|
||||||
|
expect(prisma.rows.get('x').emailAttempts).toBe(2);
|
||||||
|
expect(prisma.rows.get('x').emailSentAt).not.toBeNull();
|
||||||
|
const newDue = inHours(5);
|
||||||
|
const out: any = await service.update('t1', 'u1', 'x', { dueAt: newDue });
|
||||||
|
expect(prisma.rows.get('x')).toMatchObject({
|
||||||
|
dueAt: new Date(newDue),
|
||||||
|
emailSentAt: null,
|
||||||
|
emailAttempts: 0,
|
||||||
|
});
|
||||||
|
expect(out.dueAt).toEqual(new Date(newDue));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wird die Erinnerung zwischen Pruefung und Schreiben faellig, gibt es 409 und keine Aenderung', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
seed(prisma, 'x', future());
|
||||||
|
// Nach dem Laden (Pruefung bestanden) ist die Zeile inzwischen faellig.
|
||||||
|
prisma.reminder.findFirst.mockImplementationOnce(async () => {
|
||||||
|
const r = prisma.rows.get('x');
|
||||||
|
const loaded = { ...r };
|
||||||
|
prisma.rows.set('x', { ...r, dueAt: past() });
|
||||||
|
return loaded;
|
||||||
|
});
|
||||||
|
await expect(service.update('t1', 'u1', 'x', { title: 'neu' })).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
expect(prisma.rows.get('x').title).toBe('alt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wird die Erinnerung zwischen Pruefung und Schreiben geloescht, gibt es 404', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
seed(prisma, 'x', future());
|
||||||
|
prisma.reminder.findFirst.mockImplementationOnce(async () => {
|
||||||
|
const loaded = { ...prisma.rows.get('x') };
|
||||||
|
prisma.rows.delete('x');
|
||||||
|
return loaded;
|
||||||
|
});
|
||||||
|
await expect(service.update('t1', 'u1', 'x', { title: 'neu' })).rejects.toBeInstanceOf(
|
||||||
|
NotFoundException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RemindersService — spaeter erinnern (D-03)', () => {
|
||||||
|
it('eine noch nicht faellige Erinnerung laesst sich nicht verschieben (409)', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
seed(prisma, 'x', future());
|
||||||
|
await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(3) })).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine vergangene neue Faelligkeit ergibt 400', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
seed(prisma, 'x', past());
|
||||||
|
await expect(service.snooze('t1', 'u1', 'x', { dueAt: inHours(-1) })).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schreibt dueAt UND setzt emailSentAt und emailAttempts zurueck', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
seed(prisma, 'x', past());
|
||||||
|
const newDue = inHours(1);
|
||||||
|
await service.snooze('t1', 'u1', 'x', { dueAt: newDue });
|
||||||
|
expect(prisma.reminder.update.mock.calls[0]?.[0]?.data).toEqual({
|
||||||
|
dueAt: new Date(newDue),
|
||||||
|
emailSentAt: null,
|
||||||
|
emailAttempts: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RemindersService — loeschen (E-02)', () => {
|
||||||
|
it('loescht die eigene Zeile, vor und nach der Faelligkeit', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
seed(prisma, 'a', future());
|
||||||
|
seed(prisma, 'b', past());
|
||||||
|
await expect(service.remove('t1', 'u1', 'a')).resolves.toEqual({ deleted: true });
|
||||||
|
await expect(service.remove('t1', 'u1', 'b')).resolves.toEqual({ deleted: true });
|
||||||
|
expect(prisma.rows.size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RemindersService — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
|
||||||
|
it('getEmailAvailability: SMTP eingerichtet und Adresse vorhanden', async () => {
|
||||||
|
const { service, settings } = setup();
|
||||||
|
await expect(service.getEmailAvailability('t1', 'u1')).resolves.toEqual({
|
||||||
|
smtpConfigured: true,
|
||||||
|
hasEmail: true,
|
||||||
|
});
|
||||||
|
expect(settings.getSmtpConfig).toHaveBeenCalledWith('t1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getEmailAvailability: ohne SmtpConfig-Zeile ist der Versand nicht eingerichtet', async () => {
|
||||||
|
const { service } = setup(null);
|
||||||
|
await expect(service.getEmailAvailability('t1', 'u1')).resolves.toEqual({
|
||||||
|
smtpConfigured: false,
|
||||||
|
hasEmail: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getEmailAvailability: ohne Adresse im Konto hasEmail false, gelesen ueber den gebundenen Klienten', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
await expect(service.getEmailAvailability('t1', 'u-ohne-mail')).resolves.toEqual({
|
||||||
|
smtpConfigured: true,
|
||||||
|
hasEmail: false,
|
||||||
|
});
|
||||||
|
expect(forTenant).toHaveBeenLastCalledWith(expect.anything(), 't1', 'u-ohne-mail');
|
||||||
|
expect(prisma.user.findFirst.mock.calls[0]?.[0]?.where).toEqual({
|
||||||
|
id: 'u-ohne-mail',
|
||||||
|
tenantId: 't1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anlegen mit emailEnabled speichert das Feld, wenn E-Mail moeglich ist', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
await service.create('t1', 'u1', { title: 'a', dueAt: inHours(1), emailEnabled: true });
|
||||||
|
expect(prisma.reminder.create.mock.calls[0]?.[0]?.data.emailEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anlegen ohne emailEnabled speichert false', async () => {
|
||||||
|
const { prisma, service } = setup();
|
||||||
|
await service.create('t1', 'u1', { title: 'a', dueAt: inHours(1) });
|
||||||
|
expect(prisma.reminder.create.mock.calls[0]?.[0]?.data.emailEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anlegen mit emailEnabled ergibt 400, wenn SMTP fehlt oder die Adresse fehlt', async () => {
|
||||||
|
const noSmtp = setup(null);
|
||||||
|
await expect(
|
||||||
|
noSmtp.service.create('t1', 'u1', { title: 'a', dueAt: inHours(1), emailEnabled: true }),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
const noMail = setup();
|
||||||
|
await expect(
|
||||||
|
noMail.service.create('t1', 'u-ohne-mail', {
|
||||||
|
title: 'a',
|
||||||
|
dueAt: inHours(1),
|
||||||
|
emailEnabled: true,
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(noSmtp.prisma.rows.size).toBe(0);
|
||||||
|
expect(noMail.prisma.rows.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aendern mit emailEnabled true ergibt 400 ohne SMTP; emailEnabled false geht immer', async () => {
|
||||||
|
const { prisma, service } = setup(null);
|
||||||
|
seed(prisma, 'x', future());
|
||||||
|
await expect(service.update('t1', 'u1', 'x', { emailEnabled: true })).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
await service.update('t1', 'u1', 'x', { emailEnabled: false });
|
||||||
|
expect(prisma.reminder.updateMany.mock.calls[0]?.[0]?.data).toEqual({ emailEnabled: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
import { SettingsService } from '../settings/settings.service';
|
||||||
|
import type { CreateReminderDto, SnoozeReminderDto, UpdateReminderDto } from './dto/reminder.dto';
|
||||||
|
|
||||||
|
/** Antwortfelder — genau diese, nichts anderes verlaesst den Dienst. */
|
||||||
|
export const REMINDER_SELECT = {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
dueAt: true,
|
||||||
|
emailEnabled: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** E-06: hoechstens so viele Erinnerungen je Benutzer. */
|
||||||
|
export const MAX_REMINDERS_PER_USER = 100;
|
||||||
|
/** E-06: die Faelligkeit darf hoechstens so weit in der Zukunft liegen. */
|
||||||
|
const MAX_AHEAD_MS = 5 * 365 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persoenliche Erinnerungen (quick-260929-if2). Eine Erinnerung gehoert genau
|
||||||
|
* einem Benutzer (D-05): fuer fremde oder unbekannte Kennungen antwortet der
|
||||||
|
* Dienst immer mit 404, nie mit 403 — kein Hinweis darauf, dass es sie gibt.
|
||||||
|
*
|
||||||
|
* `tenantId` und `userId` kommen ausschliesslich als Argumente (aus
|
||||||
|
* `req.tenantId` und dem Anmelde-Token), nie aus dem DTO.
|
||||||
|
*
|
||||||
|
* RLS-BINDUNG (Muster DashboardImage, Migration 20260929140000): jede Methode
|
||||||
|
* bindet mit Mandant UND Benutzer (`forTenant(prisma, tenantId, userId)`), die
|
||||||
|
* Regel laesst dann nur eigene Zeilen zu. Zusaetzlich tragen alle `where`
|
||||||
|
* `tenantId` und `userId` als Anwendungspruefung, solange der RLS-Schalter aus
|
||||||
|
* ist.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RemindersService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly settingsService: SettingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ist die E-Mail-Erinnerung fuer diesen Benutzer moeglich? (E-09: „E-Mail-
|
||||||
|
* Versand eingerichtet“ heisst, der Mandant hat eine `SmtpConfig`-Zeile —
|
||||||
|
* dieselbe Regel wie `TenderMailService`; der Umgebungs-Rueckfall des
|
||||||
|
* `MailService` zaehlt nicht.) Der Benutzer wird ueber den gebundenen Klienten
|
||||||
|
* gelesen.
|
||||||
|
*/
|
||||||
|
async getEmailAvailability(tenantId: string, userId: string) {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
|
const user = await tenantPrisma.user.findFirst({
|
||||||
|
where: { id: userId, tenantId },
|
||||||
|
select: { email: true },
|
||||||
|
});
|
||||||
|
const smtp = await this.settingsService.getSmtpConfig(tenantId);
|
||||||
|
return { smtpConfigured: smtp !== null, hasEmail: Boolean(user?.email) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 400, wenn `emailEnabled: true` verlangt wird, obwohl E-Mail nicht moeglich ist. */
|
||||||
|
private async assertEmailAvailable(tenantId: string, userId: string) {
|
||||||
|
const { smtpConfigured, hasEmail } = await this.getEmailAvailability(tenantId, userId);
|
||||||
|
if (!smtpConfigured || !hasEmail) {
|
||||||
|
throw new BadRequestException('E-Mail-Erinnerungen sind nicht möglich');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die eigenen Erinnerungen, die naechste Faelligkeit zuerst. */
|
||||||
|
async list(tenantId: string, userId: string) {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
|
return tenantPrisma.reminder.findMany({
|
||||||
|
where: { tenantId, userId },
|
||||||
|
orderBy: { dueAt: 'asc' },
|
||||||
|
select: REMINDER_SELECT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(tenantId: string, userId: string, dto: CreateReminderDto) {
|
||||||
|
const dueAt = this.assertValidDueAt(dto.dueAt);
|
||||||
|
if (dto.emailEnabled === true) await this.assertEmailAvailable(tenantId, userId);
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
|
const count = await tenantPrisma.reminder.count({ where: { tenantId, userId } });
|
||||||
|
if (count >= MAX_REMINDERS_PER_USER) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Es sind höchstens ${MAX_REMINDERS_PER_USER} Erinnerungen möglich`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return tenantPrisma.reminder.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
userId,
|
||||||
|
title: dto.title,
|
||||||
|
description: dto.description ?? '',
|
||||||
|
dueAt,
|
||||||
|
emailEnabled: dto.emailEnabled === true,
|
||||||
|
},
|
||||||
|
select: REMINDER_SELECT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bearbeiten einer noch NICHT faelligen Erinnerung. Eine faellige ist tabu
|
||||||
|
* (409): dafuer gibt es „Erledigt“ und „Spaeter erinnern“ (D-03).
|
||||||
|
*
|
||||||
|
* WARUM `updateMany` MIT `dueAt > jetzt`: Pruefung und Schreiben sind sonst
|
||||||
|
* getrennt — wird die Erinnerung dazwischen faellig (und der E-Mail-Planer
|
||||||
|
* hat sie womoeglich schon beansprucht), wuerde trotzdem geschrieben. Die
|
||||||
|
* Bedingung im selben Schreibzugriff schliesst das aus; `count === 0` heisst
|
||||||
|
* dann 409 (oder 404, falls die Zeile inzwischen geloescht ist).
|
||||||
|
*
|
||||||
|
* Eine neue Faelligkeit setzt wie beim Verschieben die Spuren des
|
||||||
|
* E-Mail-Planers zurueck, damit zur neuen Faelligkeit eine Mail geht.
|
||||||
|
*/
|
||||||
|
async update(tenantId: string, userId: string, id: string, dto: UpdateReminderDto) {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
|
const row = await this.loadOwn(tenantPrisma, tenantId, userId, id);
|
||||||
|
const now = Date.now();
|
||||||
|
if (row.dueAt.getTime() <= now) {
|
||||||
|
throw new ConflictException('Die Erinnerung ist bereits fällig');
|
||||||
|
}
|
||||||
|
const data: {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
dueAt?: Date;
|
||||||
|
emailEnabled?: boolean;
|
||||||
|
emailSentAt?: null;
|
||||||
|
emailAttempts?: number;
|
||||||
|
} = {};
|
||||||
|
if (dto.title !== undefined) data.title = dto.title;
|
||||||
|
if (dto.description !== undefined) data.description = dto.description;
|
||||||
|
if (dto.dueAt !== undefined) {
|
||||||
|
data.dueAt = this.assertValidDueAt(dto.dueAt);
|
||||||
|
data.emailSentAt = null;
|
||||||
|
data.emailAttempts = 0;
|
||||||
|
}
|
||||||
|
if (dto.emailEnabled !== undefined) {
|
||||||
|
if (dto.emailEnabled) await this.assertEmailAvailable(tenantId, userId);
|
||||||
|
data.emailEnabled = dto.emailEnabled;
|
||||||
|
}
|
||||||
|
const result = await tenantPrisma.reminder.updateMany({
|
||||||
|
where: { id, tenantId, userId, dueAt: { gt: new Date() } },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
if (result.count !== 1) {
|
||||||
|
await this.loadOwn(tenantPrisma, tenantId, userId, id); // inzwischen geloescht: 404
|
||||||
|
throw new ConflictException('Die Erinnerung ist bereits fällig');
|
||||||
|
}
|
||||||
|
const updated = await tenantPrisma.reminder.findFirst({
|
||||||
|
where: { id, tenantId, userId },
|
||||||
|
select: REMINDER_SELECT,
|
||||||
|
});
|
||||||
|
if (!updated) {
|
||||||
|
throw new NotFoundException('Erinnerung nicht gefunden');
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Spaeter erinnern“ (D-03): nur bei einer faelligen Erinnerung (409 sonst).
|
||||||
|
* Setzt die neue Faelligkeit UND die Spuren des E-Mail-Planers zurueck, damit
|
||||||
|
* die Benachrichtigungen wieder ausloesen und die E-Mail erneut verschickt
|
||||||
|
* wird.
|
||||||
|
*/
|
||||||
|
async snooze(tenantId: string, userId: string, id: string, dto: SnoozeReminderDto) {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
|
const row = await this.loadOwn(tenantPrisma, tenantId, userId, id);
|
||||||
|
if (row.dueAt.getTime() > Date.now()) {
|
||||||
|
throw new ConflictException('Die Erinnerung ist noch nicht fällig');
|
||||||
|
}
|
||||||
|
const dueAt = this.assertValidDueAt(dto.dueAt);
|
||||||
|
return tenantPrisma.reminder.update({
|
||||||
|
where: { id, tenantId, userId },
|
||||||
|
data: { dueAt, emailSentAt: null, emailAttempts: 0 },
|
||||||
|
select: REMINDER_SELECT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Loescht die Zeile (E-02) — bedient „Loeschen“ vor und „Erledigt“ nach der Faelligkeit. */
|
||||||
|
async remove(tenantId: string, userId: string, id: string) {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||||
|
await this.loadOwn(tenantPrisma, tenantId, userId, id);
|
||||||
|
await tenantPrisma.reminder.delete({ where: { id, tenantId, userId } });
|
||||||
|
return { deleted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eigene Zeile laden. Unbekannt, fremder Mandant und fremder Benutzer sind
|
||||||
|
* ununterscheidbar 404 (D-05, T-IF2-01) — nie ein Hinweis, dass es die
|
||||||
|
* Kennung gibt.
|
||||||
|
*/
|
||||||
|
private async loadOwn(
|
||||||
|
tenantPrisma: ReturnType<typeof forTenant>,
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
id: string,
|
||||||
|
) {
|
||||||
|
const row = await tenantPrisma.reminder.findFirst({
|
||||||
|
where: { id, tenantId, userId },
|
||||||
|
select: { id: true, dueAt: true },
|
||||||
|
});
|
||||||
|
if (!row) {
|
||||||
|
throw new NotFoundException('Erinnerung nicht gefunden');
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** E-06: die Faelligkeit muss in der Zukunft und hoechstens 5 Jahre voraus liegen. */
|
||||||
|
private assertValidDueAt(iso: string, now: number = Date.now()): Date {
|
||||||
|
const dueAt = new Date(iso);
|
||||||
|
if (Number.isNaN(dueAt.getTime())) {
|
||||||
|
throw new BadRequestException('Ungültige Fälligkeit');
|
||||||
|
}
|
||||||
|
if (dueAt.getTime() <= now) {
|
||||||
|
throw new BadRequestException('Die Fälligkeit muss in der Zukunft liegen');
|
||||||
|
}
|
||||||
|
if (dueAt.getTime() > now + MAX_AHEAD_MS) {
|
||||||
|
throw new BadRequestException('Die Fälligkeit liegt zu weit in der Zukunft');
|
||||||
|
}
|
||||||
|
return dueAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { AdminSeedService } from './admin-seed.service';
|
import { AdminSeedService } from './admin-seed.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -95,6 +95,40 @@ describe('AdminSeedService', () => {
|
|||||||
service = new AdminSeedService(prisma, configService, groupsService);
|
service = new AdminSeedService(prisma, configService, groupsService);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
// quick-260925-bow (D-04): der Erst-Administrator bekommt die laufende
|
||||||
|
// freigegebene Version eingetragen und sieht kein "Was ist neu"-Fenster
|
||||||
|
// mit Altlasten; auf dev-Staenden null.
|
||||||
|
it('legt den Erst-Administrator mit lastSeenReleaseVersion der laufenden Version an', async () => {
|
||||||
|
vi.stubEnv('APP_VERSION', 'v10.2.3-3-gabc1234');
|
||||||
|
let createdData: any;
|
||||||
|
prisma.__setUserCreateImpl(async (args: any) => {
|
||||||
|
createdData = args.data;
|
||||||
|
return { id: 'u1' };
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(createdData.lastSeenReleaseVersion).toBe('10.2.3');
|
||||||
|
expect(createdData.role).toBe('SUPER_ADMIN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legt den Erst-Administrator auf dev-Staenden mit lastSeenReleaseVersion null an', async () => {
|
||||||
|
vi.stubEnv('APP_VERSION', 'dev');
|
||||||
|
let createdData: any;
|
||||||
|
prisma.__setUserCreateImpl(async (args: any) => {
|
||||||
|
createdData = args.data;
|
||||||
|
return { id: 'u1' };
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
expect(createdData).toHaveProperty('lastSeenReleaseVersion', null);
|
||||||
|
});
|
||||||
|
|
||||||
it('ruft auf einer frischen Installation in genau dieser Reihenfolge auf: tenant.upsert, ensureDefaultGroup, dann user.create', async () => {
|
it('ruft auf einer frischen Installation in genau dieser Reihenfolge auf: tenant.upsert, ensureDefaultGroup, dann user.create', async () => {
|
||||||
await service.onApplicationBootstrap();
|
await service.onApplicationBootstrap();
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import * as argon2 from 'argon2';
|
import * as argon2 from 'argon2';
|
||||||
import { GroupsService } from '../groups/groups.service';
|
import { GroupsService } from '../groups/groups.service';
|
||||||
|
import { getRunningRelease } from '../health/app-version';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import { prismaErrorCode } from '../prisma/prisma-error';
|
import { prismaErrorCode } from '../prisma/prisma-error';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@@ -116,6 +117,10 @@ export class AdminSeedService implements OnApplicationBootstrap {
|
|||||||
tenantId: tenant.id,
|
tenantId: tenant.id,
|
||||||
mustChangePassword: forceChange,
|
mustChangePassword: forceChange,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
// quick-260925-bow (D-04): laufende freigegebene Version, damit der
|
||||||
|
// Erst-Administrator kein "Was ist neu"-Fenster mit Altlasten sieht;
|
||||||
|
// null auf dev-Staenden.
|
||||||
|
lastSeenReleaseVersion: getRunningRelease(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { IsString, Matches, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body von `POST /users/me/release-seen` (quick-260925-bow, D-05).
|
||||||
|
*
|
||||||
|
* Nur die kanonische Form `X.Y.Z` (ohne `v`, ohne fuehrende Nullen, ohne
|
||||||
|
* Describe-Anhang) — so, wie `GET /users/me/release-notice` sie als
|
||||||
|
* `currentRelease` liefert. Die Methode im Controller prueft zusaetzlich
|
||||||
|
* `parseReleaseVersion(version) === version` und "nicht ueber der laufenden
|
||||||
|
* Version", weil Unit-Tests sie ohne ValidationPipe aufrufen.
|
||||||
|
*/
|
||||||
|
export class ReleaseSeenDto {
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(32)
|
||||||
|
@Matches(/^(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})\.(0|[1-9]\d{0,5})$/)
|
||||||
|
version!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
findUnknownWelcomeMailPlaceholders,
|
||||||
|
WELCOME_MAIL_LIMITS,
|
||||||
|
type WelcomeMailTexts,
|
||||||
|
} from '@tessera/shared';
|
||||||
|
import {
|
||||||
|
IsIn,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
Validate,
|
||||||
|
type ValidationArguments,
|
||||||
|
ValidatorConstraint,
|
||||||
|
type ValidatorConstraintInterface,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/** Feldnamen fuer die Fehlermeldung (Oberflaeche zeigt sie unveraendert). */
|
||||||
|
const FIELD_LABELS: Record<keyof WelcomeMailTexts, string> = {
|
||||||
|
subject: 'Betreff',
|
||||||
|
heading: 'Überschrift',
|
||||||
|
intro: 'Einleitungstext',
|
||||||
|
loginHintDirectory: 'Anmeldehinweis für Verzeichniskonten',
|
||||||
|
loginHintLocal: 'Anmeldehinweis für lokale Konten',
|
||||||
|
closing: 'Abschlusstext',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nur bekannte Platzhalter (`{{name}}`, `{{vorname}}`, `{{benutzername}}`,
|
||||||
|
* `{{email}}`, `{{adresse}}`, `{{firma}}`). Ein unbekannter wird mit 400
|
||||||
|
* abgelehnt und in der Meldung genannt ("Unbekannter Platzhalter {{xyz}}"),
|
||||||
|
* statt spaeter woertlich in der Mail zu stehen.
|
||||||
|
*/
|
||||||
|
@ValidatorConstraint({ name: 'nurBekanntePlatzhalter', async: false })
|
||||||
|
class NurBekanntePlatzhalterConstraint implements ValidatorConstraintInterface {
|
||||||
|
validate(value: unknown): boolean {
|
||||||
|
return typeof value !== 'string' || findUnknownWelcomeMailPlaceholders(value).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage(args: ValidationArguments): string {
|
||||||
|
const unknown =
|
||||||
|
typeof args.value === 'string' ? findUnknownWelcomeMailPlaceholders(args.value) : [];
|
||||||
|
const label = FIELD_LABELS[args.property as keyof WelcomeMailTexts] ?? args.property;
|
||||||
|
const noun = unknown.length > 1 ? 'Unbekannte Platzhalter' : 'Unbekannter Platzhalter';
|
||||||
|
return `${label}: ${noun} ${unknown.join(', ')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inhalt der eigenen Vorlage (PUT /welcome-mail-template, auch Grundlage fuer
|
||||||
|
* Vorschau und Testmail). Reiner Text: HTML wird beim Rendern escaped, nicht
|
||||||
|
* hier abgewiesen — ein "<" im Text ist erlaubt und erscheint als Zeichen.
|
||||||
|
* Betreff, Ueberschrift und beide Anmeldehinweise sind Pflicht, Einleitung
|
||||||
|
* und Abschluss duerfen leer sein (der Abschnitt faellt dann weg).
|
||||||
|
*/
|
||||||
|
export class WelcomeMailTemplateDto implements WelcomeMailTexts {
|
||||||
|
@IsString()
|
||||||
|
@Matches(/\S/, { message: 'Bitte geben Sie einen Betreff ein.' })
|
||||||
|
@MaxLength(WELCOME_MAIL_LIMITS.subject)
|
||||||
|
@Validate(NurBekanntePlatzhalterConstraint)
|
||||||
|
subject!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Matches(/\S/, { message: 'Bitte geben Sie eine Überschrift ein.' })
|
||||||
|
@MaxLength(WELCOME_MAIL_LIMITS.heading)
|
||||||
|
@Validate(NurBekanntePlatzhalterConstraint)
|
||||||
|
heading!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(WELCOME_MAIL_LIMITS.intro)
|
||||||
|
@Validate(NurBekanntePlatzhalterConstraint)
|
||||||
|
intro!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Matches(/\S/, { message: 'Bitte geben Sie einen Anmeldehinweis für Verzeichniskonten ein.' })
|
||||||
|
@MaxLength(WELCOME_MAIL_LIMITS.loginHintDirectory)
|
||||||
|
@Validate(NurBekanntePlatzhalterConstraint)
|
||||||
|
loginHintDirectory!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Matches(/\S/, { message: 'Bitte geben Sie einen Anmeldehinweis für lokale Konten ein.' })
|
||||||
|
@MaxLength(WELCOME_MAIL_LIMITS.loginHintLocal)
|
||||||
|
@Validate(NurBekanntePlatzhalterConstraint)
|
||||||
|
loginHintLocal!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(WELCOME_MAIL_LIMITS.closing)
|
||||||
|
@Validate(NurBekanntePlatzhalterConstraint)
|
||||||
|
closing!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vorschau: zusaetzlich die Kontoart, fuer die der Anmeldehinweis gezeigt wird. */
|
||||||
|
export class WelcomeMailPreviewDto extends WelcomeMailTemplateDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['directory', 'local'])
|
||||||
|
account?: 'directory' | 'local';
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { plainToInstance } from 'class-transformer';
|
||||||
|
import { validate } from 'class-validator';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ReleaseSeenDto } from './dto/release-seen.dto';
|
||||||
import { UserController } from './user.controller';
|
import { UserController } from './user.controller';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -121,11 +124,13 @@ describe('UserController', () => {
|
|||||||
let prisma: any;
|
let prisma: any;
|
||||||
let userService: any;
|
let userService: any;
|
||||||
let controller: UserController;
|
let controller: UserController;
|
||||||
|
let welcomeMail: { isAvailable: ReturnType<typeof vi.fn>; send: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
prisma = makeFakePrisma();
|
prisma = makeFakePrisma();
|
||||||
userService = makeUserServiceMock();
|
userService = makeUserServiceMock();
|
||||||
controller = new UserController(userService as any, prisma);
|
welcomeMail = { isAvailable: vi.fn(), send: vi.fn() };
|
||||||
|
controller = new UserController(userService as any, prisma, welcomeMail as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('findAll', () => {
|
describe('findAll', () => {
|
||||||
@@ -397,4 +402,343 @@ describe('UserController', () => {
|
|||||||
expect(userService.delete).not.toHaveBeenCalled();
|
expect(userService.delete).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
/**
|
||||||
|
* quick-260925-bow: "Was ist neu"-Fenster. Beide Wege lesen und schreiben
|
||||||
|
* ausschliesslich die Zeile des angemeldeten Benutzers ueber den an dessen
|
||||||
|
* Mandanten gebundenen Klienten; es gibt keinen Kennungsparameter.
|
||||||
|
* Die laufende Version kommt aus APP_VERSION (getRunningRelease()).
|
||||||
|
*/
|
||||||
|
describe('Was-ist-neu-Fenster (quick-260925-bow)', () => {
|
||||||
|
const me = { role: Role.USER, tenantId: 't1', id: 'u-me', username: 'me', mustChangePassword: false };
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
function seedMe(lastSeenReleaseVersion: string | null, tenantId = 't1', id = 'u-me') {
|
||||||
|
prisma.__seedUser({ id, username: id, tenantId, lastSeenReleaseVersion });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GET me/release-notice', () => {
|
||||||
|
it('liefert laufende Version und gemerkten Stand, gelesen ueber den gebundenen Klienten des eigenen Mandanten', async () => {
|
||||||
|
vi.stubEnv('APP_VERSION', 'v10.2.3-5-gabc1234');
|
||||||
|
seedMe('10.1.0');
|
||||||
|
|
||||||
|
const result = await controller.getReleaseNotice(me);
|
||||||
|
|
||||||
|
expect(result).toEqual({ currentRelease: '10.2.3', lastSeenReleaseVersion: '10.1.0' });
|
||||||
|
expectBoundCall(prisma, 't1', 'user', 'findUnique');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Bestandsbenutzer ohne gemerkten Stand: lastSeenReleaseVersion ist null; dev-Stand: currentRelease ist null', async () => {
|
||||||
|
vi.stubEnv('APP_VERSION', 'dev');
|
||||||
|
seedMe(null);
|
||||||
|
|
||||||
|
const result = await controller.getReleaseNotice(me);
|
||||||
|
|
||||||
|
expect(result).toEqual({ currentRelease: null, lastSeenReleaseVersion: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Benutzer derselben Kennung in einem fremden Mandanten ist unsichtbar: NotFoundException', async () => {
|
||||||
|
vi.stubEnv('APP_VERSION', 'v10.2.3');
|
||||||
|
seedMe('10.1.0', 't2');
|
||||||
|
|
||||||
|
await expect(controller.getReleaseNotice(me)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
expectBoundCall(prisma, 't1', 'user', 'findUnique');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST me/release-seen', () => {
|
||||||
|
it('merkt die laufende Version und nennt den gespeicherten Stand', async () => {
|
||||||
|
vi.stubEnv('APP_VERSION', 'v10.2.3');
|
||||||
|
seedMe(null);
|
||||||
|
|
||||||
|
const result = await controller.markReleaseSeen({ version: '10.2.3' }, me);
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, lastSeenReleaseVersion: '10.2.3' });
|
||||||
|
const after = await prisma.__makeBoundClient('t1').user.findUnique({ where: { id: 'u-me' } });
|
||||||
|
expect(after.lastSeenReleaseVersion).toBe('10.2.3');
|
||||||
|
expectBoundCall(prisma, 't1', 'user', 'findUnique');
|
||||||
|
expectBoundCall(prisma, 't1', 'user', 'update');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine aeltere, aber nicht zu hohe Version wird angenommen, wenn sie ueber dem gemerkten Stand liegt', async () => {
|
||||||
|
vi.stubEnv('APP_VERSION', 'v10.2.3-5-gabc1234');
|
||||||
|
seedMe('10.0.0');
|
||||||
|
|
||||||
|
const result = await controller.markReleaseSeen({ version: '10.1.0' }, me);
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, lastSeenReleaseVersion: '10.1.0' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['v10.2.3'],
|
||||||
|
['10.2'],
|
||||||
|
['abc'],
|
||||||
|
['10.2.3-5-gabc1234'],
|
||||||
|
['010.2.3'],
|
||||||
|
[''],
|
||||||
|
[' 10.2.3'],
|
||||||
|
])('nicht kanonisches Format %j → BadRequestException, nichts gespeichert', async (version) => {
|
||||||
|
vi.stubEnv('APP_VERSION', 'v10.2.3');
|
||||||
|
seedMe(null);
|
||||||
|
|
||||||
|
await expect(controller.markReleaseSeen({ version }, me)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(prisma.__boundCallLog.some((c: any) => c.method === 'update')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([[123], [null], [undefined], [{ version: '10.2.3' }], [['10.2.3']]])(
|
||||||
|
'Nicht-Zeichenkette %j → BadRequestException',
|
||||||
|
async (version) => {
|
||||||
|
vi.stubEnv('APP_VERSION', 'v10.2.3');
|
||||||
|
seedMe(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.markReleaseSeen({ version } as unknown as ReleaseSeenDto, me),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('eine Version ueber der laufenden → BadRequestException', async () => {
|
||||||
|
vi.stubEnv('APP_VERSION', 'v10.2.3');
|
||||||
|
seedMe(null);
|
||||||
|
|
||||||
|
await expect(controller.markReleaseSeen({ version: '10.2.4' }, me)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
await expect(controller.markReleaseSeen({ version: '11.0.0' }, me)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(prisma.__boundCallLog.some((c: any) => c.method === 'update')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([['dev'], ['abc1234'], [undefined]])(
|
||||||
|
'laufende Version nicht freigegeben (%j) → BadRequestException',
|
||||||
|
async (appVersion) => {
|
||||||
|
vi.stubEnv('APP_VERSION', appVersion);
|
||||||
|
seedMe(null);
|
||||||
|
|
||||||
|
await expect(controller.markReleaseSeen({ version: '1.0.0' }, me)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('ein gemerkter Stand wird nie abgesenkt', async () => {
|
||||||
|
vi.stubEnv('APP_VERSION', 'v10.2.3');
|
||||||
|
seedMe('10.2.3');
|
||||||
|
|
||||||
|
const result = await controller.markReleaseSeen({ version: '10.1.0' }, me);
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, lastSeenReleaseVersion: '10.2.3' });
|
||||||
|
expect(prisma.__boundCallLog.some((c: any) => c.method === 'update')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein unparsebarer gemerkter Wert wird ueberschrieben', async () => {
|
||||||
|
vi.stubEnv('APP_VERSION', 'v10.2.3');
|
||||||
|
seedMe('kaputt');
|
||||||
|
|
||||||
|
const result = await controller.markReleaseSeen({ version: '10.2.3' }, me);
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, lastSeenReleaseVersion: '10.2.3' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zwei Benutzer in zwei Mandanten: nur die Zeile des Anfragenden aendert sich', async () => {
|
||||||
|
vi.stubEnv('APP_VERSION', 'v10.2.3');
|
||||||
|
seedMe(null, 't1', 'u-me');
|
||||||
|
seedMe(null, 't2', 'u-other');
|
||||||
|
|
||||||
|
await controller.markReleaseSeen({ version: '10.2.3' }, me);
|
||||||
|
|
||||||
|
const mine = await prisma.__makeBoundClient('t1').user.findUnique({ where: { id: 'u-me' } });
|
||||||
|
const other = await prisma.__makeBoundClient('t2').user.findUnique({ where: { id: 'u-other' } });
|
||||||
|
expect(mine.lastSeenReleaseVersion).toBe('10.2.3');
|
||||||
|
expect(other.lastSeenReleaseVersion).toBeNull();
|
||||||
|
expect(prisma.__boundCallLog.filter((c: any) => c.method === 'update').every((c: any) => c.tenantId === 't1')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fehlt die eigene Zeile im eigenen Mandanten (Kennung nur in fremdem Mandanten) → NotFoundException, nichts geaendert', async () => {
|
||||||
|
vi.stubEnv('APP_VERSION', 'v10.2.3');
|
||||||
|
seedMe(null, 't2', 'u-me');
|
||||||
|
|
||||||
|
await expect(controller.markReleaseSeen({ version: '10.2.3' }, me)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
const foreign = await prisma.__makeBoundClient('t2').user.findUnique({ where: { id: 'u-me' } });
|
||||||
|
expect(foreign.lastSeenReleaseVersion).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ReleaseSeenDto (globale ValidationPipe)', () => {
|
||||||
|
async function errorsFor(body: unknown) {
|
||||||
|
return validate(plainToInstance(ReleaseSeenDto, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
it('nimmt die kanonische Form X.Y.Z an', async () => {
|
||||||
|
expect(await errorsFor({ version: '1.4.0' })).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ version: 'v1.4.0' }],
|
||||||
|
[{ version: '1.4' }],
|
||||||
|
[{ version: '1.4.0-5-gabc1234' }],
|
||||||
|
[{ version: 123 }],
|
||||||
|
[{}],
|
||||||
|
[{ version: `1.4.0${'0'.repeat(40)}` }],
|
||||||
|
])('lehnt %j ab', async (body) => {
|
||||||
|
expect((await errorsFor(body)).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* quick-260928-ujj: Dashboard-Hintergrund pro Benutzer in der Datenbank.
|
||||||
|
* Der Selbstbedienungsweg schreibt ausschliesslich die Zeile des
|
||||||
|
* angemeldeten Benutzers ueber den an dessen Mandanten gebundenen Klienten
|
||||||
|
* (T-ujj-02) und nimmt nur die erlaubten Formen an (T-ujj-01): gespeichert
|
||||||
|
* wird immer ein frisch aufgebautes Objekt ohne Zusatzschluessel.
|
||||||
|
*/
|
||||||
|
describe('Dashboard-Hintergrund (quick-260928-ujj)', () => {
|
||||||
|
const me = { role: Role.USER, tenantId: 't1', id: 'u-me', username: 'me', mustChangePassword: false };
|
||||||
|
const IMAGE_ID = '3f2b8c1e-9a4d-4e7f-8b21-0c5d6e7f8a9b';
|
||||||
|
|
||||||
|
function seedMe(tenantId = 't1', id = 'u-me') {
|
||||||
|
prisma.__seedUser({ id, username: id, tenantId, dashboardBackground: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function storedFor(tenantId: string, id: string) {
|
||||||
|
const row = await prisma.__makeBoundClient(tenantId).user.findUnique({ where: { id } });
|
||||||
|
return row.dashboardBackground;
|
||||||
|
}
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ kind: 'none' }, { kind: 'none' }],
|
||||||
|
[{ kind: 'preset', id: 'dunes' }, { kind: 'preset', id: 'dunes' }],
|
||||||
|
[{ kind: 'preset', id: 'mosaic', extra: 'weg' }, { kind: 'preset', id: 'mosaic' }],
|
||||||
|
[{ kind: 'image', imageId: IMAGE_ID }, { kind: 'image', imageId: IMAGE_ID }],
|
||||||
|
[{ kind: 'image', imageId: IMAGE_ID.toUpperCase(), id: 'mist' }, { kind: 'image', imageId: IMAGE_ID.toUpperCase() }],
|
||||||
|
[{ kind: 'none', id: 'dunes', imageId: IMAGE_ID }, { kind: 'none' }],
|
||||||
|
])('%j wird normalisiert als %j gespeichert, gebunden an den eigenen Mandanten', async (input, expected) => {
|
||||||
|
seedMe();
|
||||||
|
|
||||||
|
const result = await controller.updateDashboardBackground({ background: input }, me);
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, dashboardBackground: expected });
|
||||||
|
expect(await storedFor('t1', 'u-me')).toEqual(expected);
|
||||||
|
expectBoundCall(prisma, 't1', 'user', 'update');
|
||||||
|
expect(prisma.__boundCallLog.filter((c: any) => c.method === 'update').every((c: any) => c.tenantId === 't1')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ kind: 'preset', id: 'regenbogen' }],
|
||||||
|
[{ kind: 'preset' }],
|
||||||
|
[{ kind: 'preset', id: 42 }],
|
||||||
|
[{ kind: 'gradient', id: 'mist' }],
|
||||||
|
[{ kind: 'image', imageId: 'img-1' }],
|
||||||
|
[{ kind: 'image', imageId: '") ; background: url("https://boese.invalid/x' }],
|
||||||
|
[{ kind: 'image', imageId: `${IMAGE_ID})` }],
|
||||||
|
[{ kind: 'image', imageId: `${IMAGE_ID}0` }],
|
||||||
|
[{ kind: 'image', imageId: '' }],
|
||||||
|
[{ kind: 'image' }],
|
||||||
|
[{}],
|
||||||
|
[null],
|
||||||
|
[undefined],
|
||||||
|
['none'],
|
||||||
|
[42],
|
||||||
|
[[{ kind: 'none' }]],
|
||||||
|
])('ungueltige Wahl %j → BadRequestException, nichts geschrieben', async (background) => {
|
||||||
|
seedMe();
|
||||||
|
|
||||||
|
await expect(controller.updateDashboardBackground({ background }, me)).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(prisma.__boundCallLog.some((c: any) => c.method === 'update')).toBe(false);
|
||||||
|
expect(await storedFor('t1', 'u-me')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fehlender Rumpf → BadRequestException', async () => {
|
||||||
|
seedMe();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.updateDashboardBackground(undefined as unknown as { background: unknown }, me),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(prisma.__boundCallLog.some((c: any) => c.method === 'update')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zwei Benutzer in zwei Mandanten: nur die Zeile des Anfragenden aendert sich', async () => {
|
||||||
|
seedMe('t1', 'u-me');
|
||||||
|
seedMe('t2', 'u-other');
|
||||||
|
|
||||||
|
await controller.updateDashboardBackground({ background: { kind: 'preset', id: 'mist' } }, me);
|
||||||
|
|
||||||
|
expect(await storedFor('t1', 'u-me')).toEqual({ kind: 'preset', id: 'mist' });
|
||||||
|
expect(await storedFor('t2', 'u-other')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Willkommensmail (POST :id/welcome-mail, GET welcome-mail/status)', () => {
|
||||||
|
const admin = { role: Role.ADMIN, tenantId: 't1', id: 'admin1', username: 'admin1', mustChangePassword: false };
|
||||||
|
const superAdmin = { role: Role.SUPER_ADMIN, tenantId: 't9', id: 'super1', username: 'super1', mustChangePassword: false };
|
||||||
|
const sentAt = new Date('2026-09-30T10:00:00.000Z');
|
||||||
|
|
||||||
|
it('ADMIN: Ziel gebunden an den eigenen Mandanten aufgeloest, Dienst mit Ziel und Origin gerufen, Antwort traegt Empfaenger und Zeitpunkt', async () => {
|
||||||
|
const target = { id: 'u-x', username: 'x', tenantId: 't1', role: 'USER', lastLoginAt: null };
|
||||||
|
userService.findById.mockResolvedValue(target);
|
||||||
|
welcomeMail.send.mockResolvedValue({ to: 'x@x.invalid', welcomeMailSentAt: sentAt });
|
||||||
|
|
||||||
|
const result = await controller.sendWelcomeMail('u-x', admin, 'https://tessera.example.invalid');
|
||||||
|
|
||||||
|
expect(userService.findById).toHaveBeenCalledWith('t1', 'u-x');
|
||||||
|
expect(welcomeMail.send).toHaveBeenCalledWith(target, 'https://tessera.example.invalid');
|
||||||
|
expect(result).toEqual({ success: true, to: 'x@x.invalid', welcomeMailSentAt: sentAt });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ADMIN: unbekannter oder fremdmandantiger Benutzer (gebundene Aufloesung null) → NotFoundException, kein Versand', async () => {
|
||||||
|
userService.findById.mockResolvedValue(null);
|
||||||
|
await expect(controller.sendWelcomeMail('u-fremd', admin)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
expect(welcomeMail.send).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ADMIN: Ziel eines fremden Mandanten (zweite Schicht) → ForbiddenException mit Mandanten-Meldung, kein Versand', async () => {
|
||||||
|
userService.findById.mockResolvedValue({ id: 'u-y', tenantId: 't2', role: 'SUPER_ADMIN' });
|
||||||
|
await expect(controller.sendWelcomeMail('u-y', admin)).rejects.toThrow('Cannot modify users from other tenants');
|
||||||
|
expect(welcomeMail.send).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ADMIN: Ziel ist SUPER_ADMIN des eigenen Mandanten → ForbiddenException (Zielrollen-Riegel), kein Versand', async () => {
|
||||||
|
userService.findById.mockResolvedValue({ id: 'u-s', tenantId: 't1', role: 'SUPER_ADMIN' });
|
||||||
|
await expect(controller.sendWelcomeMail('u-s', admin)).rejects.toThrow('Cannot modify a SUPER_ADMIN user');
|
||||||
|
expect(welcomeMail.send).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SUPER_ADMIN: Ziel ueber die uebergreifende Aufloesung, auch in fremdem Mandanten und mit oberster Rolle', async () => {
|
||||||
|
const target = { id: 'u-z', tenantId: 't2', role: 'SUPER_ADMIN', lastLoginAt: null };
|
||||||
|
userService.findByIdForPlatformAdmin.mockResolvedValue(target);
|
||||||
|
welcomeMail.send.mockResolvedValue({ to: 'z@x.invalid', welcomeMailSentAt: sentAt });
|
||||||
|
|
||||||
|
await controller.sendWelcomeMail('u-z', superAdmin);
|
||||||
|
|
||||||
|
expect(userService.findByIdForPlatformAdmin).toHaveBeenCalledWith('u-z');
|
||||||
|
expect(welcomeMail.send).toHaveBeenCalledWith(target, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehler des Dienstes (z. B. 409 Konto deaktiviert) gehen unveraendert nach aussen', async () => {
|
||||||
|
userService.findById.mockResolvedValue({ id: 'u-x', tenantId: 't1', role: 'USER' });
|
||||||
|
welcomeMail.send.mockRejectedValue(new ConflictException('deaktiviert'));
|
||||||
|
await expect(controller.sendWelcomeMail('u-x', admin)).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET welcome-mail/status fragt den Versandweg fuer den Mandanten des Aufrufers ab', async () => {
|
||||||
|
welcomeMail.isAvailable.mockResolvedValue(true);
|
||||||
|
await expect(controller.getWelcomeMailStatus(admin)).resolves.toEqual({ available: true });
|
||||||
|
expect(welcomeMail.isAvailable).toHaveBeenCalledWith('t1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('findAll (ADMIN) liefert welcomeMailSentAt mit', async () => {
|
||||||
|
prisma.__seedUser({
|
||||||
|
id: 'u-a',
|
||||||
|
username: 'alice',
|
||||||
|
tenantId: 't1',
|
||||||
|
email: 'alice@x.invalid',
|
||||||
|
displayName: null,
|
||||||
|
role: 'USER',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
lastLoginAt: null,
|
||||||
|
welcomeMailSentAt: sentAt,
|
||||||
|
});
|
||||||
|
const result = await controller.findAll(admin);
|
||||||
|
expect((result as any[])[0].welcomeMailSentAt).toBe(sentAt);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import {
|
|||||||
Delete,
|
Delete,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Headers,
|
||||||
|
HttpStatus,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
@@ -16,6 +19,13 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { Role } from '@prisma/client';
|
import { Role } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
compareReleaseVersions,
|
||||||
|
type DashboardBackground,
|
||||||
|
parseDashboardBackground,
|
||||||
|
parseReleaseVersion,
|
||||||
|
type ReleaseNoticeResponse,
|
||||||
|
} from '@tessera/shared';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
@@ -25,9 +35,12 @@ import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
|||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { getRunningRelease } from '../health/app-version';
|
||||||
import { CreateUserDto } from './dto/create-user.dto';
|
import { CreateUserDto } from './dto/create-user.dto';
|
||||||
|
import { ReleaseSeenDto } from './dto/release-seen.dto';
|
||||||
import { UpdateUserDto } from './dto/update-user.dto';
|
import { UpdateUserDto } from './dto/update-user.dto';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
|
import { WelcomeMailService } from './welcome-mail.service';
|
||||||
|
|
||||||
/** Map accepted MIME types to file extensions (T-gbh-01). */
|
/** Map accepted MIME types to file extensions (T-gbh-01). */
|
||||||
const AVATAR_MIME_TO_EXT: Record<string, string> = {
|
const AVATAR_MIME_TO_EXT: Record<string, string> = {
|
||||||
@@ -48,6 +61,19 @@ function resolveAvatarsDir(): string {
|
|||||||
* gebundenen Klienten (`tenantPrisma`, Konvention aus `ldap`, `groups`,
|
* gebundenen Klienten (`tenantPrisma`, Konvention aus `ldap`, `groups`,
|
||||||
* `dkv`, `auth`, `user.service.ts`) oder ueber die uebergreifenden Methoden
|
* `dkv`, `auth`, `user.service.ts`) oder ueber die uebergreifenden Methoden
|
||||||
* von `UserService`, deren Rumpf je Mandant gebunden ist.
|
* von `UserService`, deren Rumpf je Mandant gebunden ist.
|
||||||
|
*
|
||||||
|
* quick-260925-bow: dazu kommen drei gebundene Zugriffe der beiden
|
||||||
|
* Selbstbedienungswege `GET me/release-notice` und `POST me/release-seen`
|
||||||
|
* ("Was ist neu"-Fenster), ebenfalls `forTenant()` mit
|
||||||
|
* `where: { id: currentUser.id }`.
|
||||||
|
*
|
||||||
|
* quick-260928-ujj: dazu kommt ein gebundener Zugriff des
|
||||||
|
* Selbstbedienungswegs `PATCH me/dashboard-background`, ebenfalls
|
||||||
|
* `forTenant()` mit `where: { id: currentUser.id }`.
|
||||||
|
*
|
||||||
|
* Willkommensmail: `POST :id/welcome-mail` loest den Zielbenutzer ueber
|
||||||
|
* `resolveTargetUser` auf; die beiden Schreibzugriffe (Token, Versandzeit)
|
||||||
|
* liegen gebunden in `WelcomeMailService`, nicht in diesem Controller.
|
||||||
*/
|
*/
|
||||||
@Controller('users')
|
@Controller('users')
|
||||||
@UseGuards(RolesGuard)
|
@UseGuards(RolesGuard)
|
||||||
@@ -55,6 +81,7 @@ export class UserController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly userService: UserService,
|
private readonly userService: UserService,
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly welcomeMailService: WelcomeMailService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,11 +133,116 @@ export class UserController {
|
|||||||
tenantId: true,
|
tenantId: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
lastLoginAt: true,
|
lastLoginAt: true,
|
||||||
|
welcomeMailSentAt: true,
|
||||||
},
|
},
|
||||||
orderBy: { username: 'asc' },
|
orderBy: { username: 'asc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /users/me/release-notice (quick-260925-bow, D-03/D-04)
|
||||||
|
*
|
||||||
|
* Grundlage des "Was ist neu"-Fensters: die laufende freigegebene Version
|
||||||
|
* der API (`getRunningRelease()`, `null` auf `dev`-Staenden) und der
|
||||||
|
* gemerkte Stand des angemeldeten Benutzers (`null` = Bestandsbenutzer).
|
||||||
|
* Welche Abschnitte der Aenderungsliste gezeigt werden, entscheidet das Web.
|
||||||
|
*
|
||||||
|
* Jeder angemeldete Benutzer, kein `@Roles`. Mandantenbindung: gelesen wird
|
||||||
|
* ausschliesslich die eigene Zeile (`where: { id: currentUser.id }`) ueber
|
||||||
|
* `forTenant(this.prisma, currentUser.tenantId)`; es gibt keinen
|
||||||
|
* Kennungsparameter. Statische `me/...`-Route steht VOR der Kennungs-Route (GET :id).
|
||||||
|
*/
|
||||||
|
@Get('me/release-notice')
|
||||||
|
async getReleaseNotice(@CurrentUser() currentUser: AuthUser): Promise<ReleaseNoticeResponse> {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId);
|
||||||
|
const user = await tenantPrisma.user.findUnique({
|
||||||
|
where: { id: currentUser.id },
|
||||||
|
select: { lastSeenReleaseVersion: true },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
currentRelease: getRunningRelease(),
|
||||||
|
lastSeenReleaseVersion: user.lastSeenReleaseVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /users/me/release-seen (quick-260925-bow, D-05)
|
||||||
|
*
|
||||||
|
* Merkt beim Schliessen des Fensters die gesehene Version. Angenommen wird
|
||||||
|
* nur die kanonische Form `X.Y.Z`, die nicht ueber der laufenden Version
|
||||||
|
* liegt; ohne freigegebene laufende Version (`dev`) wird nichts gemerkt.
|
||||||
|
* Ein gemerkter Stand wird nie abgesenkt; ein unparsebarer gemerkter Wert
|
||||||
|
* wird ueberschrieben. Die DTO prueft das Format schon in der
|
||||||
|
* ValidationPipe; die Pruefungen im Rumpf gelten zusaetzlich (Unit-Tests
|
||||||
|
* rufen die Methode ohne Pipe auf).
|
||||||
|
*
|
||||||
|
* Jeder angemeldete Benutzer, kein `@Roles`. Mandantenbindung wie oben:
|
||||||
|
* Lesen und Schreiben nur der eigenen Zeile ueber den an
|
||||||
|
* `currentUser.tenantId` gebundenen Klienten.
|
||||||
|
*/
|
||||||
|
@Post('me/release-seen')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async markReleaseSeen(
|
||||||
|
@Body() body: ReleaseSeenDto,
|
||||||
|
@CurrentUser() currentUser: AuthUser,
|
||||||
|
): Promise<{ success: true; lastSeenReleaseVersion: string }> {
|
||||||
|
const version: unknown = body?.version;
|
||||||
|
if (typeof version !== 'string' || parseReleaseVersion(version) !== version) {
|
||||||
|
throw new BadRequestException('Invalid version. Use X.Y.Z.');
|
||||||
|
}
|
||||||
|
const running = getRunningRelease();
|
||||||
|
if (running === null) {
|
||||||
|
throw new BadRequestException('No released version is running.');
|
||||||
|
}
|
||||||
|
if (compareReleaseVersions(version, running) > 0) {
|
||||||
|
throw new BadRequestException('Version is newer than the running version.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId);
|
||||||
|
const user = await tenantPrisma.user.findUnique({
|
||||||
|
where: { id: currentUser.id },
|
||||||
|
select: { lastSeenReleaseVersion: true },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const stored = user.lastSeenReleaseVersion;
|
||||||
|
if (
|
||||||
|
stored !== null &&
|
||||||
|
parseReleaseVersion(stored) !== null &&
|
||||||
|
compareReleaseVersions(version, stored) <= 0
|
||||||
|
) {
|
||||||
|
return { success: true, lastSeenReleaseVersion: stored };
|
||||||
|
}
|
||||||
|
|
||||||
|
await tenantPrisma.user.update({
|
||||||
|
where: { id: currentUser.id },
|
||||||
|
data: { lastSeenReleaseVersion: version },
|
||||||
|
});
|
||||||
|
return { success: true, lastSeenReleaseVersion: version };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /users/welcome-mail/status (Willkommensmail)
|
||||||
|
*
|
||||||
|
* Ob fuer den Mandanten des Aufrufers ein Versandweg eingerichtet ist —
|
||||||
|
* die Oberflaeche deaktiviert damit den Knopf "Willkommensmail senden"
|
||||||
|
* und nennt den Grund. Die wirksame Pruefung bleibt beim Versand selbst
|
||||||
|
* (`POST :id/welcome-mail`, dort je Mandant des ZIELS). Statische Route
|
||||||
|
* steht VOR der Kennungs-Route (GET :id).
|
||||||
|
*/
|
||||||
|
@Get('welcome-mail/status')
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async getWelcomeMailStatus(
|
||||||
|
@CurrentUser() currentUser: AuthUser,
|
||||||
|
): Promise<{ available: boolean }> {
|
||||||
|
return { available: await this.welcomeMailService.isAvailable(currentUser.tenantId) };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /users/:id
|
* GET /users/:id
|
||||||
*/
|
*/
|
||||||
@@ -223,6 +355,47 @@ export class UserController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /users/:id/welcome-mail (Willkommensmail)
|
||||||
|
*
|
||||||
|
* Schickt einem Benutzer (egal ob schon angemeldet) eine
|
||||||
|
* Willkommensmail (Adresse, Benutzername, Anmeldehinweis je Kontoart) und
|
||||||
|
* merkt den Zeitpunkt in `welcomeMailSentAt`. Dieselben Regeln wie beim
|
||||||
|
* Bearbeiten: ADMIN nur im eigenen Mandanten, SUPER_ADMIN uebergreifend;
|
||||||
|
* ein Nicht-SUPER_ADMIN darf einem SUPER_ADMIN nichts schicken
|
||||||
|
* (Zielrollen-Riegel wie in update()). Deaktiviert -> 409, ohne
|
||||||
|
* Adresse -> 400, ohne Versandweg -> 409, Versandfehler -> 502 (alles in
|
||||||
|
* `WelcomeMailService`). Der Origin-Kopf dient nur als Rueckfall fuer die
|
||||||
|
* Adresse in der Mail, wenn `TESSERA_APP_URL` fehlt.
|
||||||
|
*/
|
||||||
|
@Post(':id/welcome-mail')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async sendWelcomeMail(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@CurrentUser() currentUser: AuthUser,
|
||||||
|
@Headers('origin') origin?: string,
|
||||||
|
): Promise<{ success: true; to: string; welcomeMailSentAt: Date }> {
|
||||||
|
const user = await this.resolveTargetUser(currentUser, id);
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentUser.role !== Role.SUPER_ADMIN &&
|
||||||
|
user.tenantId !== currentUser.tenantId
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException('Cannot modify users from other tenants');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === Role.SUPER_ADMIN && currentUser.role !== Role.SUPER_ADMIN) {
|
||||||
|
throw new ForbiddenException('Cannot modify a SUPER_ADMIN user');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.welcomeMailService.send(user, origin);
|
||||||
|
return { success: true, ...result };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /users/:id
|
* DELETE /users/:id
|
||||||
* ADMIN cannot delete self or users from other tenants.
|
* ADMIN cannot delete self or users from other tenants.
|
||||||
@@ -382,6 +555,43 @@ export class UserController {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /users/me/dashboard-background (quick-260928-ujj)
|
||||||
|
*
|
||||||
|
* Speichert den gewaehlten Dashboard-Hintergrund des angemeldeten
|
||||||
|
* Benutzers. Jeder angemeldete Benutzer, kein `@Roles`.
|
||||||
|
*
|
||||||
|
* T-ujj-01 (Tampering): der Wert wird spaeter als CSS-Hintergrund
|
||||||
|
* gerendert. `parseDashboardBackground` (@tessera/shared) laesst nur
|
||||||
|
* `kind` none/preset/image, bekannte Preset-Kennungen und eine UUID als
|
||||||
|
* Bildkennung zu und baut ein frisches Objekt ohne Zusatzschluessel;
|
||||||
|
* alles andere ergibt 400 ohne Schreibzugriff. Bewusst Inline-Body-Typ
|
||||||
|
* statt DTO-Klasse (wie `me/accent-color`): die globale ValidationPipe mit
|
||||||
|
* `whitelist` wuerde das verschachtelte Objekt sonst nicht pruefen.
|
||||||
|
*
|
||||||
|
* T-ujj-02 (Elevation of Privilege): kein Kennungsparameter; geschrieben
|
||||||
|
* wird ausschliesslich die eigene Zeile (`where: { id: currentUser.id }`)
|
||||||
|
* ueber `forTenant(this.prisma, currentUser.tenantId)`.
|
||||||
|
*/
|
||||||
|
@Patch('me/dashboard-background')
|
||||||
|
async updateDashboardBackground(
|
||||||
|
@Body() body: { background: unknown },
|
||||||
|
@CurrentUser() currentUser: AuthUser,
|
||||||
|
): Promise<{ success: true; dashboardBackground: DashboardBackground }> {
|
||||||
|
const background = parseDashboardBackground(body?.background);
|
||||||
|
if (background === null) {
|
||||||
|
throw new BadRequestException('Invalid dashboard background.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantPrisma = forTenant(this.prisma, currentUser.tenantId);
|
||||||
|
await tenantPrisma.user.update({
|
||||||
|
where: { id: currentUser.id },
|
||||||
|
data: { dashboardBackground: background },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, dashboardBackground: background };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /users/me/avatar
|
* GET /users/me/avatar
|
||||||
* Stream the current user's avatar image.
|
* Stream the current user's avatar image.
|
||||||
|
|||||||
@@ -1,19 +1,33 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { GroupsModule } from '../groups/groups.module';
|
import { GroupsModule } from '../groups/groups.module';
|
||||||
|
import { MailModule } from '../mail/mail.module';
|
||||||
import { AdminSeedService } from './admin-seed.service';
|
import { AdminSeedService } from './admin-seed.service';
|
||||||
import { UserController } from './user.controller';
|
import { UserController } from './user.controller';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
|
import { WelcomeMailService } from './welcome-mail.service';
|
||||||
|
import { WelcomeMailTemplateController } from './welcome-mail-template.controller';
|
||||||
|
import { WelcomeMailTemplateService } from './welcome-mail-template.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Importiert GroupsModule für UserService.create's Standardgruppen-
|
* Importiert GroupsModule für UserService.create's Standardgruppen-
|
||||||
* Mitgliedschaft (D-11/D-12). GroupsModule importiert seinerseits
|
* Mitgliedschaft (D-11/D-12). GroupsModule importiert seinerseits
|
||||||
* UserModule nicht — GroupsService greift für Benutzerprüfungen direkt
|
* UserModule nicht — GroupsService greift für Benutzerprüfungen direkt
|
||||||
* auf PrismaService zu, keine Zirkularität.
|
* auf PrismaService zu, keine Zirkularität.
|
||||||
|
*
|
||||||
|
* Importiert MailModule für die Willkommensmail (`WelcomeMailService`).
|
||||||
|
* Zyklusfrei: MailModule -> SettingsModule, keiner von beiden importiert
|
||||||
|
* UserModule.
|
||||||
|
*
|
||||||
|
* Eigene Vorlage der Willkommensmail (Administrator → Willkommensmail):
|
||||||
|
* `WelcomeMailTemplateService` (Speicherung je Mandant) und
|
||||||
|
* `WelcomeMailTemplateController` (`/welcome-mail-template`) liegen hier,
|
||||||
|
* weil Vorschau und Testmail denselben Weg wie der Versand nutzen
|
||||||
|
* (`WelcomeMailService`: Adresse, Kopfbild, SMTP des Mandanten).
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [GroupsModule],
|
imports: [GroupsModule, MailModule],
|
||||||
controllers: [UserController],
|
controllers: [UserController, WelcomeMailTemplateController],
|
||||||
providers: [UserService, AdminSeedService],
|
providers: [UserService, AdminSeedService, WelcomeMailService, WelcomeMailTemplateService],
|
||||||
exports: [UserService],
|
exports: [UserService],
|
||||||
})
|
})
|
||||||
export class UserModule {}
|
export class UserModule {}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ConflictException } from '@nestjs/common';
|
import { ConflictException } from '@nestjs/common';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -251,6 +251,56 @@ describe('UserService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* quick-260925-bow (D-04): neue Benutzer bekommen die laufende freigegebene
|
||||||
|
* Version eingetragen, damit sie kein "Was ist neu"-Fenster mit Altlasten
|
||||||
|
* sehen. Der Wert ist eine Eigenschaft des Servers (APP_VERSION), nicht des
|
||||||
|
* Aufrufers — die Signatur von create() bekommt kein neues Feld.
|
||||||
|
*/
|
||||||
|
describe('create — lastSeenReleaseVersion (quick-260925-bow)', () => {
|
||||||
|
let prisma: any;
|
||||||
|
let service: UserService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = makeFakePrisma();
|
||||||
|
service = new UserService(prisma, { addUserToDefaultGroup: vi.fn().mockResolvedValue(undefined) } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('traegt bei freigegebener laufender Version X.Y.Z ein (Describe-Stand gekuerzt)', async () => {
|
||||||
|
vi.stubEnv('APP_VERSION', 'v10.2.3-3-gabc1234');
|
||||||
|
const result = await service.create({ username: 'neu', email: 'neu@example.com', tenantId: 't1' });
|
||||||
|
expect(result.lastSeenReleaseVersion).toBe('10.2.3');
|
||||||
|
expectBoundCall(prisma, 't1', 'user', 'create');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('traegt auf dev-Staenden null ein', async () => {
|
||||||
|
vi.stubEnv('APP_VERSION', 'dev');
|
||||||
|
const result = await service.create({ username: 'neu2', email: 'neu2@example.com', tenantId: 't1' });
|
||||||
|
expect(result.lastSeenReleaseVersion).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('traegt ohne APP_VERSION null ein', async () => {
|
||||||
|
vi.stubEnv('APP_VERSION', undefined);
|
||||||
|
const result = await service.create({ username: 'neu3', email: 'neu3@example.com', tenantId: 't1' });
|
||||||
|
expect(result.lastSeenReleaseVersion).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein von aussen untergeschobenes Feld setzt den Wert nicht (kein Parameter der Signatur)', async () => {
|
||||||
|
vi.stubEnv('APP_VERSION', 'v10.2.3');
|
||||||
|
const result = await service.create({
|
||||||
|
username: 'neu4',
|
||||||
|
email: 'neu4@example.com',
|
||||||
|
tenantId: 't1',
|
||||||
|
lastSeenReleaseVersion: '99.0.0',
|
||||||
|
} as unknown as Parameters<UserService['create']>[0]);
|
||||||
|
expect(result.lastSeenReleaseVersion).toBe('10.2.3');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('findById', () => {
|
describe('findById', () => {
|
||||||
let prisma: any;
|
let prisma: any;
|
||||||
let service: UserService;
|
let service: UserService;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ConflictException, Injectable, Logger } from '@nestjs/common';
|
|||||||
import * as argon2 from 'argon2';
|
import * as argon2 from 'argon2';
|
||||||
import type { User } from '@prisma/client';
|
import type { User } from '@prisma/client';
|
||||||
import { GroupsService } from '../groups/groups.service';
|
import { GroupsService } from '../groups/groups.service';
|
||||||
|
import { getRunningRelease } from '../health/app-version';
|
||||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
import { prismaErrorCode } from '../prisma/prisma-error';
|
import { prismaErrorCode } from '../prisma/prisma-error';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
@@ -36,6 +37,7 @@ const PLATFORM_USER_SELECT = {
|
|||||||
tenantId: true,
|
tenantId: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
lastLoginAt: true,
|
lastLoginAt: true,
|
||||||
|
welcomeMailSentAt: true,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -140,6 +142,14 @@ export class UserService {
|
|||||||
* endet damit bei JEDEM Aufrufer an dieser einen Stelle. Die Meldung
|
* endet damit bei JEDEM Aufrufer an dieser einen Stelle. Die Meldung
|
||||||
* nennt WEDER den Halter NOCH dessen Mandanten, weil das sonst eine
|
* nennt WEDER den Halter NOCH dessen Mandanten, weil das sonst eine
|
||||||
* Aussage ueber einen fremden Mandanten waere (T-DAS-08).
|
* Aussage ueber einen fremden Mandanten waere (T-DAS-08).
|
||||||
|
*
|
||||||
|
* "Was ist neu"-Fenster (quick-260925-bow, D-04): neue Benutzer (Admin-
|
||||||
|
* Anlage, AD-Abgleich und AD-Import) bekommen hier die laufende
|
||||||
|
* freigegebene Version (`getRunningRelease()`) als
|
||||||
|
* `lastSeenReleaseVersion` eingetragen, damit sie kein Fenster mit
|
||||||
|
* Aenderungen aus der Zeit vor ihrem Konto sehen; auf Staenden ohne
|
||||||
|
* freigegebene Version (`dev`) `null`. Der Wert ist eine Eigenschaft des
|
||||||
|
* Servers, kein Parameter dieser Methode.
|
||||||
*/
|
*/
|
||||||
async create(data: {
|
async create(data: {
|
||||||
username: string;
|
username: string;
|
||||||
@@ -164,6 +174,7 @@ export class UserService {
|
|||||||
...rest,
|
...rest,
|
||||||
username: rest.username.toLowerCase(),
|
username: rest.username.toLowerCase(),
|
||||||
passwordHash: password ? await argon2.hash(password) : null,
|
passwordHash: password ? await argon2.hash(password) : null,
|
||||||
|
lastSeenReleaseVersion: getRunningRelease(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
||||||
|
import { DEFAULT_WELCOME_MAIL_TEXTS } from '@tessera/shared';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
|
||||||
|
import { WelcomeMailPreviewDto, WelcomeMailTemplateDto } from './dto/welcome-mail-template.dto';
|
||||||
|
import { WelcomeMailTemplateController } from './welcome-mail-template.controller';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Administrator → Willkommensmail (eigene Vorlage).
|
||||||
|
*
|
||||||
|
* Festgenagelt: alle Routen nur ADMIN/SUPER_ADMIN; Mandant IMMER aus dem
|
||||||
|
* Token (nie aus dem Body); unbekannte Platzhalter und zu lange Texte → 400
|
||||||
|
* mit Nennung des Platzhalters; die Testmail geht an den Aufrufer selbst.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function makeTemplates() {
|
||||||
|
return {
|
||||||
|
getState: vi.fn(async (..._args: unknown[]) => ({ custom: false })),
|
||||||
|
save: vi.fn(async (..._args: unknown[]) => ({ custom: true })),
|
||||||
|
reset: vi.fn(async (..._args: unknown[]) => ({ custom: false })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeWelcome() {
|
||||||
|
return {
|
||||||
|
preview: vi.fn(async (..._args: unknown[]) => ({ subject: 's', html: '<html>', text: 't' })),
|
||||||
|
sendTest: vi.fn(async (..._args: unknown[]) => ({ to: 'ada@example.invalid' })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = { id: 'u-admin', username: 'ada', role: 'ADMIN', tenantId: 't1' } as any;
|
||||||
|
const proto = WelcomeMailTemplateController.prototype as any;
|
||||||
|
const pipe = new ValidationPipe({ whitelist: true, transform: true });
|
||||||
|
|
||||||
|
const valid = { ...DEFAULT_WELCOME_MAIL_TEXTS };
|
||||||
|
|
||||||
|
async function validateBody(body: unknown, metatype: any = WelcomeMailTemplateDto) {
|
||||||
|
return pipe.transform(body, { type: 'body', metatype });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function messagesOf(body: unknown): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
await validateBody(body);
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(BadRequestException);
|
||||||
|
return ((error as BadRequestException).getResponse() as { message: string[] }).message;
|
||||||
|
}
|
||||||
|
throw new Error('erwartete 400');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WelcomeMailTemplateController — Rechte', () => {
|
||||||
|
it.each([
|
||||||
|
'get',
|
||||||
|
'save',
|
||||||
|
'reset',
|
||||||
|
'preview',
|
||||||
|
'sendTest',
|
||||||
|
])('%s nur fuer ADMIN und SUPER_ADMIN', (name) => {
|
||||||
|
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toEqual(['ADMIN', 'SUPER_ADMIN']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('haengt an Pfad welcome-mail-template', () => {
|
||||||
|
expect(Reflect.getMetadata('path', WelcomeMailTemplateController)).toBe(
|
||||||
|
'welcome-mail-template',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WelcomeMailTemplateController — Mandant aus dem Token', () => {
|
||||||
|
it('reicht currentUser.tenantId (und Benutzername/-kennung) weiter, nie Werte aus dem Body', async () => {
|
||||||
|
const templates = makeTemplates();
|
||||||
|
const welcome = makeWelcome();
|
||||||
|
const controller = new WelcomeMailTemplateController(templates as any, welcome as any);
|
||||||
|
|
||||||
|
await controller.get(admin);
|
||||||
|
await controller.save(admin, { ...valid, tenantId: 'evil' } as any);
|
||||||
|
await controller.reset(admin);
|
||||||
|
await controller.preview(admin, { ...valid, account: 'local' }, 'https://o.example.invalid');
|
||||||
|
const result = await controller.sendTest(admin, valid as any, undefined);
|
||||||
|
|
||||||
|
expect(templates.getState).toHaveBeenCalledWith('t1');
|
||||||
|
expect(templates.save).toHaveBeenCalledWith('t1', valid, 'ada');
|
||||||
|
expect(templates.reset).toHaveBeenCalledWith('t1');
|
||||||
|
expect(welcome.preview).toHaveBeenCalledWith('t1', valid, 'local', 'https://o.example.invalid');
|
||||||
|
expect(welcome.sendTest).toHaveBeenCalledWith('t1', 'u-admin', valid, undefined);
|
||||||
|
expect(result).toEqual({ success: true, to: 'ada@example.invalid' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Vorschau ohne Kontoart zeigt die Verzeichniskonto-Variante', async () => {
|
||||||
|
const welcome = makeWelcome();
|
||||||
|
const controller = new WelcomeMailTemplateController(makeTemplates() as any, welcome as any);
|
||||||
|
await controller.preview(admin, { ...valid });
|
||||||
|
expect(welcome.preview.mock.calls[0][2]).toBe('directory');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WelcomeMailTemplateDto (globale ValidationPipe)', () => {
|
||||||
|
it('Standardtexte und alle bekannten Platzhalter (auch mit Leerraum/Grossschreibung) sind gueltig', async () => {
|
||||||
|
const out: any = await validateBody({
|
||||||
|
...valid,
|
||||||
|
intro: '{{name}} {{vorname}} {{benutzername}} {{email}} {{adresse}} {{firma}} {{ Name }}',
|
||||||
|
tenantId: 'evil',
|
||||||
|
});
|
||||||
|
expect(out).not.toHaveProperty('tenantId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unbekannter Platzhalter → 400 mit Feld und Platzhalter', async () => {
|
||||||
|
const messages = await messagesOf({ ...valid, heading: 'Hallo {{xyz}} und {{abc}}' });
|
||||||
|
expect(messages).toContain('Überschrift: Unbekannte Platzhalter {{xyz}}, {{abc}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein unbekannter Platzhalter im Betreff → "Unbekannter Platzhalter {{xyz}}"', async () => {
|
||||||
|
const messages = await messagesOf({ ...valid, subject: 'Hi {{xyz}}' });
|
||||||
|
expect(messages).toContain('Betreff: Unbekannter Platzhalter {{xyz}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Laengen: Betreff/Ueberschrift 200, Anmeldehinweise 1000, Texte 4000', async () => {
|
||||||
|
await expect(
|
||||||
|
validateBody({ ...valid, subject: 'a'.repeat(200), intro: 'b'.repeat(4000) }),
|
||||||
|
).resolves.toBeTruthy();
|
||||||
|
await expect(messagesOf({ ...valid, subject: 'a'.repeat(201) })).resolves.toHaveLength(1);
|
||||||
|
await expect(messagesOf({ ...valid, heading: 'a'.repeat(201) })).resolves.toHaveLength(1);
|
||||||
|
await expect(messagesOf({ ...valid, intro: 'a'.repeat(4001) })).resolves.toHaveLength(1);
|
||||||
|
await expect(messagesOf({ ...valid, closing: 'a'.repeat(4001) })).resolves.toHaveLength(1);
|
||||||
|
await expect(
|
||||||
|
validateBody({
|
||||||
|
...valid,
|
||||||
|
loginHintDirectory: 'a'.repeat(1000),
|
||||||
|
loginHintLocal: 'b'.repeat(1000),
|
||||||
|
}),
|
||||||
|
).resolves.toBeTruthy();
|
||||||
|
await expect(
|
||||||
|
messagesOf({ ...valid, loginHintDirectory: 'a'.repeat(1001) }),
|
||||||
|
).resolves.toHaveLength(1);
|
||||||
|
await expect(messagesOf({ ...valid, loginHintLocal: 'a'.repeat(1001) })).resolves.toHaveLength(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leerer Betreff/leere Ueberschrift → 400; leere Einleitung/Abschluss erlaubt', async () => {
|
||||||
|
expect(await messagesOf({ ...valid, subject: ' ' })).toContain(
|
||||||
|
'Bitte geben Sie einen Betreff ein.',
|
||||||
|
);
|
||||||
|
expect(await messagesOf({ ...valid, heading: '' })).toContain(
|
||||||
|
'Bitte geben Sie eine Überschrift ein.',
|
||||||
|
);
|
||||||
|
await expect(validateBody({ ...valid, intro: '', closing: '' })).resolves.toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Anmeldehinweise: Pflicht, Platzhalter geprueft, Leerzeilen erlaubt', async () => {
|
||||||
|
expect(await messagesOf({ ...valid, loginHintDirectory: ' \n ' })).toContain(
|
||||||
|
'Bitte geben Sie einen Anmeldehinweis für Verzeichniskonten ein.',
|
||||||
|
);
|
||||||
|
expect(await messagesOf({ ...valid, loginHintLocal: '' })).toContain(
|
||||||
|
'Bitte geben Sie einen Anmeldehinweis für lokale Konten ein.',
|
||||||
|
);
|
||||||
|
expect(await messagesOf({ ...valid, loginHintLocal: 'Hallo {{kennwort}}' })).toContain(
|
||||||
|
'Anmeldehinweis für lokale Konten: Unbekannter Platzhalter {{kennwort}}',
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
validateBody({ ...valid, loginHintDirectory: 'Hallo {{ Vorname }}\n\nZweiter Absatz' }),
|
||||||
|
).resolves.toBeTruthy();
|
||||||
|
const { loginHintLocal: _l, ...rest } = valid;
|
||||||
|
await expect(messagesOf(rest)).resolves.not.toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fehlendes Feld → 400', async () => {
|
||||||
|
const { closing: _closing, ...rest } = valid;
|
||||||
|
await expect(messagesOf(rest)).resolves.not.toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Vorschau: nur directory/local als Kontoart', async () => {
|
||||||
|
await expect(
|
||||||
|
validateBody({ ...valid, account: 'local' }, WelcomeMailPreviewDto),
|
||||||
|
).resolves.toBeTruthy();
|
||||||
|
await expect(
|
||||||
|
validateBody({ ...valid, account: 'root' }, WelcomeMailPreviewDto),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Headers,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { Roles } from '../auth/decorators/roles.decorator';
|
||||||
|
import type { AuthUser } from '../auth/types/auth-user';
|
||||||
|
import { WelcomeMailPreviewDto, WelcomeMailTemplateDto } from './dto/welcome-mail-template.dto';
|
||||||
|
import { WelcomeMailService } from './welcome-mail.service';
|
||||||
|
import {
|
||||||
|
WelcomeMailTemplateService,
|
||||||
|
type WelcomeMailTemplateState,
|
||||||
|
} from './welcome-mail-template.service';
|
||||||
|
|
||||||
|
/** Nur die sechs Texte aus dem DTO — nie weitere Felder in die Vorlage. */
|
||||||
|
function textsOf(dto: WelcomeMailTemplateDto) {
|
||||||
|
return {
|
||||||
|
subject: dto.subject,
|
||||||
|
heading: dto.heading,
|
||||||
|
intro: dto.intro,
|
||||||
|
loginHintDirectory: dto.loginHintDirectory,
|
||||||
|
loginHintLocal: dto.loginHintLocal,
|
||||||
|
closing: dto.closing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Administrator → Willkommensmail: eigene Vorlage je Mandant.
|
||||||
|
*
|
||||||
|
* Alle Routen nur fuer ADMIN und SUPER_ADMIN (`@Roles`), immer fuer den
|
||||||
|
* Mandanten des angemeldeten Administrators (`currentUser.tenantId`, aus dem
|
||||||
|
* Token) — ein Mandant aus der Anfrage wird nie uebernommen. Unbekannte
|
||||||
|
* Platzhalter und zu lange Texte weist das DTO mit 400 ab.
|
||||||
|
*
|
||||||
|
* - GET /welcome-mail-template wirksame Texte + Standardtexte
|
||||||
|
* - PUT /welcome-mail-template eigene Vorlage speichern
|
||||||
|
* - DELETE /welcome-mail-template auf Standard zuruecksetzen
|
||||||
|
* - POST /welcome-mail-template/preview gerendertes HTML (Beispielwerte)
|
||||||
|
* - POST /welcome-mail-template/test Testmail an die eigene Adresse
|
||||||
|
*
|
||||||
|
* Keine Kennungs-Route (`:id`) — eine Vorlage je Mandant —, deshalb keine
|
||||||
|
* Reihenfolge-Falle.
|
||||||
|
*/
|
||||||
|
@Controller('welcome-mail-template')
|
||||||
|
export class WelcomeMailTemplateController {
|
||||||
|
constructor(
|
||||||
|
private readonly templateService: WelcomeMailTemplateService,
|
||||||
|
private readonly welcomeMailService: WelcomeMailService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async get(@CurrentUser() currentUser: AuthUser): Promise<WelcomeMailTemplateState> {
|
||||||
|
return this.templateService.getState(currentUser.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put()
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async save(
|
||||||
|
@CurrentUser() currentUser: AuthUser,
|
||||||
|
@Body() dto: WelcomeMailTemplateDto,
|
||||||
|
): Promise<WelcomeMailTemplateState> {
|
||||||
|
return this.templateService.save(currentUser.tenantId, textsOf(dto), currentUser.username);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete()
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async reset(@CurrentUser() currentUser: AuthUser): Promise<WelcomeMailTemplateState> {
|
||||||
|
return this.templateService.reset(currentUser.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('preview')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async preview(
|
||||||
|
@CurrentUser() currentUser: AuthUser,
|
||||||
|
@Body() dto: WelcomeMailPreviewDto,
|
||||||
|
@Headers('origin') origin?: string,
|
||||||
|
): Promise<{ subject: string; html: string; text: string }> {
|
||||||
|
return this.welcomeMailService.preview(
|
||||||
|
currentUser.tenantId,
|
||||||
|
textsOf(dto),
|
||||||
|
dto.account ?? 'directory',
|
||||||
|
origin,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('test')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
|
||||||
|
async sendTest(
|
||||||
|
@CurrentUser() currentUser: AuthUser,
|
||||||
|
@Body() dto: WelcomeMailTemplateDto,
|
||||||
|
@Headers('origin') origin?: string,
|
||||||
|
): Promise<{ success: true; to: string }> {
|
||||||
|
const result = await this.welcomeMailService.sendTest(
|
||||||
|
currentUser.tenantId,
|
||||||
|
currentUser.id,
|
||||||
|
textsOf(dto),
|
||||||
|
origin,
|
||||||
|
);
|
||||||
|
return { success: true, ...result };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { DEFAULT_WELCOME_MAIL_TEXTS } from '@tessera/shared';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { WelcomeMailTemplateService } from './welcome-mail-template.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WelcomeMailTemplateService — Speicherung der eigenen Vorlage je Mandant.
|
||||||
|
*
|
||||||
|
* Festgenagelt: jeder Zugriff gebunden an den uebergebenen Mandanten
|
||||||
|
* (`forTenant`), Standardtexte ohne Vorlage, Speichern als upsert auf
|
||||||
|
* `tenantId`, Zuruecksetzen loescht die Vorlage.
|
||||||
|
*/
|
||||||
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
|
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__bound(tenantId)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
tenantId: string;
|
||||||
|
subject: string;
|
||||||
|
heading: string;
|
||||||
|
intro: string;
|
||||||
|
loginHintDirectory?: string | null;
|
||||||
|
loginHintLocal?: string | null;
|
||||||
|
closing: string;
|
||||||
|
updatedBy: string | null;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
function makePrisma(rows: Row[] = []) {
|
||||||
|
const log: { tenantId: string; model: string; method: string; args: any }[] = [];
|
||||||
|
return {
|
||||||
|
__log: log,
|
||||||
|
__rows: rows,
|
||||||
|
__bound(tenantId: string) {
|
||||||
|
return {
|
||||||
|
welcomeMailTemplate: {
|
||||||
|
findUnique: vi.fn(async (args: any) => {
|
||||||
|
log.push({ tenantId, model: 'welcomeMailTemplate', method: 'findUnique', args });
|
||||||
|
// RLS-Nachbildung: nur Zeilen des gebundenen Mandanten sichtbar
|
||||||
|
return (
|
||||||
|
rows.find((r) => r.tenantId === args.where.tenantId && r.tenantId === tenantId) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
upsert: vi.fn(async (args: any) => {
|
||||||
|
log.push({ tenantId, model: 'welcomeMailTemplate', method: 'upsert', args });
|
||||||
|
const existing = rows.find((r) => r.tenantId === args.where.tenantId);
|
||||||
|
if (existing) Object.assign(existing, args.update, { updatedAt: new Date() });
|
||||||
|
else rows.push({ ...args.create, updatedAt: new Date() });
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
deleteMany: vi.fn(async (args: any) => {
|
||||||
|
log.push({ tenantId, model: 'welcomeMailTemplate', method: 'deleteMany', args });
|
||||||
|
const before = rows.length;
|
||||||
|
for (let i = rows.length - 1; i >= 0; i--) {
|
||||||
|
if (rows[i].tenantId === args.where.tenantId) rows.splice(i, 1);
|
||||||
|
}
|
||||||
|
return { count: before - rows.length };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
tenant: {
|
||||||
|
findUnique: vi.fn(async (args: any) => {
|
||||||
|
log.push({ tenantId, model: 'tenant', method: 'findUnique', args });
|
||||||
|
return args.where.id === 't1' ? { name: 'Beispiel GmbH' } : null;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const texts = {
|
||||||
|
subject: 'S {{name}}',
|
||||||
|
heading: 'H',
|
||||||
|
intro: 'I',
|
||||||
|
loginHintDirectory: 'LD {{benutzername}}',
|
||||||
|
loginHintLocal: 'LL',
|
||||||
|
closing: 'C',
|
||||||
|
};
|
||||||
|
|
||||||
|
let prisma: ReturnType<typeof makePrisma>;
|
||||||
|
let service: WelcomeMailTemplateService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = makePrisma();
|
||||||
|
service = new WelcomeMailTemplateService(prisma as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WelcomeMailTemplateService', () => {
|
||||||
|
it('ohne Vorlage: Standardtexte, custom=false, getCustomTexts=null', async () => {
|
||||||
|
const state = await service.getState('t1');
|
||||||
|
expect(state.custom).toBe(false);
|
||||||
|
expect(state.texts).toEqual(DEFAULT_WELCOME_MAIL_TEXTS);
|
||||||
|
expect(state.defaults).toEqual(DEFAULT_WELCOME_MAIL_TEXTS);
|
||||||
|
expect(await service.getCustomTexts('t1')).toBeNull();
|
||||||
|
expect(prisma.__log.every((c) => c.tenantId === 't1')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('speichern: upsert auf tenantId, gebunden, mit Bearbeiter; danach custom=true', async () => {
|
||||||
|
const state = await service.save('t1', texts, 'ada');
|
||||||
|
const upsert = prisma.__log.find((c) => c.method === 'upsert');
|
||||||
|
expect(upsert?.tenantId).toBe('t1');
|
||||||
|
expect(upsert?.args.where).toEqual({ tenantId: 't1' });
|
||||||
|
expect(upsert?.args.create).toEqual({ tenantId: 't1', ...texts, updatedBy: 'ada' });
|
||||||
|
expect(state.custom).toBe(true);
|
||||||
|
expect(state.texts).toEqual(texts);
|
||||||
|
expect(state.updatedBy).toBe('ada');
|
||||||
|
expect(await service.getCustomTexts('t1')).toEqual(texts);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aeltere Vorlage ohne Anmeldehinweise (NULL) → Standardtexte fuer diese Felder, Rest bleibt eigen', async () => {
|
||||||
|
prisma.__rows.push({
|
||||||
|
tenantId: 't1',
|
||||||
|
subject: 'Alt',
|
||||||
|
heading: 'H',
|
||||||
|
intro: 'I',
|
||||||
|
loginHintDirectory: null,
|
||||||
|
loginHintLocal: null,
|
||||||
|
closing: 'C',
|
||||||
|
updatedBy: null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
const state = await service.getState('t1');
|
||||||
|
expect(state.custom).toBe(true);
|
||||||
|
expect(state.texts.subject).toBe('Alt');
|
||||||
|
expect(state.texts.loginHintDirectory).toBe(DEFAULT_WELCOME_MAIL_TEXTS.loginHintDirectory);
|
||||||
|
expect(state.texts.loginHintLocal).toBe(DEFAULT_WELCOME_MAIL_TEXTS.loginHintLocal);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Mandantenbindung: Vorlage von t1 ist fuer t2 nicht sichtbar', async () => {
|
||||||
|
await service.save('t1', texts, 'ada');
|
||||||
|
expect(await service.getCustomTexts('t2')).toBeNull();
|
||||||
|
expect(prisma.__log.filter((c) => c.method === 'findUnique').at(-1)?.tenantId).toBe('t2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zuruecksetzen: loescht gebunden, danach wieder Standard', async () => {
|
||||||
|
await service.save('t1', texts, 'ada');
|
||||||
|
const state = await service.reset('t1');
|
||||||
|
const del = prisma.__log.find((c) => c.method === 'deleteMany');
|
||||||
|
expect(del?.tenantId).toBe('t1');
|
||||||
|
expect(del?.args.where).toEqual({ tenantId: 't1' });
|
||||||
|
expect(state.custom).toBe(false);
|
||||||
|
expect(state.texts).toEqual(DEFAULT_WELCOME_MAIL_TEXTS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Firmenname gebunden gelesen; unbekannter Mandant → leer', async () => {
|
||||||
|
expect(await service.getTenantName('t1')).toBe('Beispiel GmbH');
|
||||||
|
expect(await service.getTenantName('tx')).toBe('');
|
||||||
|
expect(prisma.__log.filter((c) => c.model === 'tenant').map((c) => c.tenantId)).toEqual([
|
||||||
|
't1',
|
||||||
|
'tx',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DEFAULT_WELCOME_MAIL_TEXTS, type WelcomeMailTexts } from '@tessera/shared';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WelcomeMailTemplateService — eigene Vorlage der Willkommensmail
|
||||||
|
* (Administrator → Willkommensmail).
|
||||||
|
*
|
||||||
|
* Hoechstens eine Vorlage je Mandant (Tabelle "WelcomeMailTemplate",
|
||||||
|
* `tenantId` eindeutig). Gespeichert werden nur die sechs Texte; Kopf,
|
||||||
|
* Zugangsdaten, Knoepfe und Fusszeile bleiben fest in
|
||||||
|
* `welcome-mail.template.ts`. Fehlt die Vorlage, gelten
|
||||||
|
* `DEFAULT_WELCOME_MAIL_TEXTS` aus `@tessera/shared`. Die zwei
|
||||||
|
* Anmeldehinweise kamen spaeter hinzu (Spalten nullable): fehlt ein Wert in
|
||||||
|
* einer aelteren Vorlage, gilt fuer ihn der Standardtext.
|
||||||
|
*
|
||||||
|
* Mandantenbindung: jeder Zugriff laeuft ueber `forTenant(prisma, tenantId)`.
|
||||||
|
* Beim Versand ist `tenantId` der Mandant des ZIEL-Benutzers
|
||||||
|
* (`WelcomeMailService.send`), in der Verwaltung der Mandant des
|
||||||
|
* angemeldeten Administrators (`WelcomeMailTemplateController`). Die
|
||||||
|
* Platzhalter-Pruefung liegt im DTO (400 bei unbekanntem Platzhalter).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Antwort von GET /welcome-mail-template. */
|
||||||
|
export interface WelcomeMailTemplateState {
|
||||||
|
/** `true` = eigene Vorlage gespeichert, `false` = Standardtexte. */
|
||||||
|
custom: boolean;
|
||||||
|
/** Wirksame Texte (eigene Vorlage oder Standard) — Vorbelegung im Formular. */
|
||||||
|
texts: WelcomeMailTexts;
|
||||||
|
/** Standardtexte fuer "Auf Standard zuruecksetzen". */
|
||||||
|
defaults: WelcomeMailTexts;
|
||||||
|
updatedAt: Date | null;
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEXT_SELECT = {
|
||||||
|
subject: true,
|
||||||
|
heading: true,
|
||||||
|
intro: true,
|
||||||
|
loginHintDirectory: true,
|
||||||
|
loginHintLocal: true,
|
||||||
|
closing: true,
|
||||||
|
updatedAt: true,
|
||||||
|
updatedBy: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WelcomeMailTemplateService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async getState(tenantId: string): Promise<WelcomeMailTemplateState> {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
const row = await tenantPrisma.welcomeMailTemplate.findUnique({
|
||||||
|
where: { tenantId },
|
||||||
|
select: TEXT_SELECT,
|
||||||
|
});
|
||||||
|
const defaults = { ...DEFAULT_WELCOME_MAIL_TEXTS };
|
||||||
|
if (!row) {
|
||||||
|
return { custom: false, texts: defaults, defaults, updatedAt: null, updatedBy: null };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
custom: true,
|
||||||
|
texts: {
|
||||||
|
subject: row.subject,
|
||||||
|
heading: row.heading,
|
||||||
|
intro: row.intro,
|
||||||
|
loginHintDirectory: row.loginHintDirectory?.trim()
|
||||||
|
? row.loginHintDirectory
|
||||||
|
: defaults.loginHintDirectory,
|
||||||
|
loginHintLocal: row.loginHintLocal?.trim() ? row.loginHintLocal : defaults.loginHintLocal,
|
||||||
|
closing: row.closing,
|
||||||
|
},
|
||||||
|
defaults,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
updatedBy: row.updatedBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Texte fuer den Versand: eigene Vorlage des Mandanten, sonst `null` (= Standard). */
|
||||||
|
async getCustomTexts(tenantId: string): Promise<WelcomeMailTexts | null> {
|
||||||
|
const state = await this.getState(tenantId);
|
||||||
|
return state.custom ? state.texts : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Name des Mandanten fuer `{{firma}}`; leer, wenn der Mandant fehlt. */
|
||||||
|
async getTenantName(tenantId: string): Promise<string> {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
const tenant = await tenantPrisma.tenant.findUnique({
|
||||||
|
where: { id: tenantId },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
return tenant?.name ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(
|
||||||
|
tenantId: string,
|
||||||
|
texts: WelcomeMailTexts,
|
||||||
|
updatedBy: string,
|
||||||
|
): Promise<WelcomeMailTemplateState> {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
const data = {
|
||||||
|
subject: texts.subject,
|
||||||
|
heading: texts.heading,
|
||||||
|
intro: texts.intro,
|
||||||
|
loginHintDirectory: texts.loginHintDirectory,
|
||||||
|
loginHintLocal: texts.loginHintLocal,
|
||||||
|
closing: texts.closing,
|
||||||
|
updatedBy,
|
||||||
|
};
|
||||||
|
await tenantPrisma.welcomeMailTemplate.upsert({
|
||||||
|
where: { tenantId },
|
||||||
|
create: { tenantId, ...data },
|
||||||
|
update: data,
|
||||||
|
});
|
||||||
|
return this.getState(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Auf Standard zuruecksetzen": loescht die eigene Vorlage (idempotent). */
|
||||||
|
async reset(tenantId: string): Promise<WelcomeMailTemplateState> {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
await tenantPrisma.welcomeMailTemplate.deleteMany({ where: { tenantId } });
|
||||||
|
return this.getState(tenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
import { BadGatewayException, BadRequestException, ConflictException } from '@nestjs/common';
|
||||||
|
import { DEFAULT_WELCOME_MAIL_TEXTS, type WelcomeMailTexts } from '@tessera/shared';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
normalizeOrigin,
|
||||||
|
WelcomeMailService,
|
||||||
|
type WelcomeMailTarget,
|
||||||
|
} from './welcome-mail.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WelcomeMailService — Willkommensmail aus der Benutzerverwaltung.
|
||||||
|
*
|
||||||
|
* Festgenagelt: Versand auch an bereits angemeldete Benutzer; 409 fuer
|
||||||
|
* deaktivierte Benutzer,
|
||||||
|
* 400 ohne Adresse, 409 ohne Versandweg, 502 bei Versandfehler (und dann
|
||||||
|
* KEIN `welcomeMailSentAt`); beide Schreibzugriffe gebunden an den
|
||||||
|
* Mandanten des ZIELS; Inhalt je Kontoart (verzeichnisgefuehrt: Windows-
|
||||||
|
* Passwort, kein Token; lokal: Token-Link mit der Frist des
|
||||||
|
* "Passwort vergessen"-Wegs); Adresse aus Konfiguration bzw. Origin.
|
||||||
|
*/
|
||||||
|
vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||||
|
forTenant: vi.fn((prisma: any, tenantId: string) => prisma.__bound(tenantId)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function makePrisma() {
|
||||||
|
const log: { tenantId: string; model: string; method: string; args: any }[] = [];
|
||||||
|
return {
|
||||||
|
__log: log,
|
||||||
|
__bound(tenantId: string) {
|
||||||
|
return {
|
||||||
|
passwordResetToken: {
|
||||||
|
create: vi.fn(async (args: any) => {
|
||||||
|
log.push({ tenantId, model: 'passwordResetToken', method: 'create', args });
|
||||||
|
return { id: 'tok-row', ...args.data };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
update: vi.fn(async (args: any) => {
|
||||||
|
log.push({ tenantId, model: 'user', method: 'update', args });
|
||||||
|
return { welcomeMailSentAt: args.data.welcomeMailSentAt };
|
||||||
|
}),
|
||||||
|
findFirst: vi.fn(async (args: any) => {
|
||||||
|
log.push({ tenantId, model: 'user', method: 'findFirst', args });
|
||||||
|
return selfRow;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMail(opts: { available?: boolean; fail?: boolean } = {}) {
|
||||||
|
return {
|
||||||
|
hasConfiguredTransport: vi.fn(async () => opts.available ?? true),
|
||||||
|
sendWelcomeMail: vi.fn(async () => {
|
||||||
|
if (opts.fail) throw new Error('ECONNREFUSED');
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeConfig(values: Record<string, string | undefined>) {
|
||||||
|
return { get: vi.fn((key: string) => values[key]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const localUser: WelcomeMailTarget = {
|
||||||
|
id: 'u-local',
|
||||||
|
tenantId: 't1',
|
||||||
|
username: 'max.muster',
|
||||||
|
displayName: 'Max Muster',
|
||||||
|
email: 'max@example.invalid',
|
||||||
|
ldapDn: null,
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ldapUser: WelcomeMailTarget = {
|
||||||
|
...localUser,
|
||||||
|
id: 'u-ldap',
|
||||||
|
username: 'erika',
|
||||||
|
displayName: null,
|
||||||
|
email: 'erika@example.invalid',
|
||||||
|
ldapDn: 'CN=Erika,OU=Users,DC=example,DC=invalid',
|
||||||
|
};
|
||||||
|
|
||||||
|
let prisma: ReturnType<typeof makePrisma>;
|
||||||
|
/** Eigenes Konto des Administrators fuer die Testmail (`user.findFirst`). */
|
||||||
|
let selfRow: {
|
||||||
|
username: string;
|
||||||
|
displayName: string | null;
|
||||||
|
email: string | null;
|
||||||
|
ldapDn: string | null;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
prisma = makePrisma();
|
||||||
|
selfRow = {
|
||||||
|
username: 'admin.lokal',
|
||||||
|
displayName: 'Ada Admin',
|
||||||
|
email: 'ada@example.invalid',
|
||||||
|
ldapDn: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Vorlagen-Dienst: eigene Vorlage je Mandant (Map), Firmenname je Mandant. */
|
||||||
|
function makeTemplates(custom: Record<string, WelcomeMailTexts> = {}) {
|
||||||
|
return {
|
||||||
|
getCustomTexts: vi.fn(async (tenantId: string) => custom[tenantId] ?? null),
|
||||||
|
getTenantName: vi.fn(async (tenantId: string) => `Firma ${tenantId}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function make(
|
||||||
|
mail = makeMail(),
|
||||||
|
config = makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid' }),
|
||||||
|
templates = makeTemplates(),
|
||||||
|
) {
|
||||||
|
const service = new WelcomeMailService(
|
||||||
|
prisma as any,
|
||||||
|
mail as any,
|
||||||
|
config as any,
|
||||||
|
templates as any,
|
||||||
|
);
|
||||||
|
vi.spyOn((service as any).logger, 'log').mockImplementation(() => undefined);
|
||||||
|
vi.spyOn((service as any).logger, 'error').mockImplementation(() => undefined);
|
||||||
|
return { service, mail, templates };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WelcomeMailService.send — Vorbedingungen', () => {
|
||||||
|
it('bereits angemeldete Benutzer jeder Rolle → Versand erlaubt, welcomeMailSentAt gesetzt', async () => {
|
||||||
|
const { service, mail } = make();
|
||||||
|
await service.send({ ...ldapUser, lastLoginAt: new Date() } as WelcomeMailTarget);
|
||||||
|
await service.send({
|
||||||
|
...localUser,
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
role: 'SUPER_ADMIN',
|
||||||
|
} as WelcomeMailTarget);
|
||||||
|
expect(mail.sendWelcomeMail).toHaveBeenCalledTimes(2);
|
||||||
|
expect(prisma.__log.filter((c) => c.model === 'user')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deaktiviertes Konto → ConflictException', async () => {
|
||||||
|
const { service, mail } = make();
|
||||||
|
await expect(service.send({ ...localUser, isActive: false })).rejects.toBeInstanceOf(
|
||||||
|
ConflictException,
|
||||||
|
);
|
||||||
|
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne E-Mail-Adresse → BadRequestException', async () => {
|
||||||
|
const { service, mail } = make();
|
||||||
|
await expect(service.send({ ...localUser, email: null })).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Versandweg → ConflictException mit Hinweis auf Administrator → SMTP; Pruefung im Mandanten des Ziels', async () => {
|
||||||
|
const mail = makeMail({ available: false });
|
||||||
|
const { service } = make(mail);
|
||||||
|
await expect(service.send(localUser)).rejects.toThrow('Administrator → SMTP');
|
||||||
|
expect(mail.hasConfiguredTransport).toHaveBeenCalledWith('t1');
|
||||||
|
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.__log).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WelcomeMailService.send — Versand', () => {
|
||||||
|
it('lokales Konto: Token gebunden an den Mandanten des Ziels, Frist 7 Tage, Link in Text und HTML, kein Kennwort; welcomeMailSentAt gebunden gesetzt', async () => {
|
||||||
|
const { service, mail } = make();
|
||||||
|
const before = Date.now();
|
||||||
|
|
||||||
|
const result = await service.send(localUser);
|
||||||
|
|
||||||
|
const tokenCall = prisma.__log.find((c) => c.model === 'passwordResetToken');
|
||||||
|
expect(tokenCall?.tenantId).toBe('t1');
|
||||||
|
expect(tokenCall?.args.data.userId).toBe('u-local');
|
||||||
|
const ttl = (tokenCall as { args: any }).args.data.expiresAt.getTime() - before;
|
||||||
|
expect(ttl).toBeGreaterThan(7 * 24 * 60 * 60 * 1000 - 60 * 1000);
|
||||||
|
expect(ttl).toBeLessThanOrEqual(7 * 24 * 60 * 60 * 1000 + 1000);
|
||||||
|
|
||||||
|
expect(mail.sendWelcomeMail).toHaveBeenCalledTimes(1);
|
||||||
|
const [tenantId, to, rendered] = mail.sendWelcomeMail.mock.calls[0] as any[];
|
||||||
|
expect(tenantId).toBe('t1');
|
||||||
|
expect(to).toBe('max@example.invalid');
|
||||||
|
const token = (tokenCall as { args: any }).args.data.token;
|
||||||
|
expect(rendered.text).toContain(`https://tessera.example.invalid/reset-password/${token}`);
|
||||||
|
expect(rendered.html).toContain(`https://tessera.example.invalid/reset-password/${token}`);
|
||||||
|
expect(rendered.html).toContain('Passwort festlegen');
|
||||||
|
expect(rendered.html).toContain('Willkommen bei Tessera, Max Muster!');
|
||||||
|
expect(rendered.text).toContain('Benutzername: max.muster');
|
||||||
|
expect(rendered.html).toContain('https://tessera.example.invalid/login');
|
||||||
|
expect(rendered.text).not.toContain('Windows-Passwort');
|
||||||
|
|
||||||
|
const updateCall = prisma.__log.find((c) => c.model === 'user' && c.method === 'update');
|
||||||
|
expect(updateCall?.tenantId).toBe('t1');
|
||||||
|
expect(updateCall?.args.where).toEqual({ id: 'u-local' });
|
||||||
|
expect(updateCall?.args.data.welcomeMailSentAt).toBeInstanceOf(Date);
|
||||||
|
expect(result.to).toBe('max@example.invalid');
|
||||||
|
expect(result.welcomeMailSentAt).toBe(updateCall?.args.data.welcomeMailSentAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verzeichnisgefuehrtes Konto: kein Token, Hinweis auf das Windows-Passwort, Anrede mit Benutzername ohne Anzeigenamen', async () => {
|
||||||
|
const { service, mail } = make();
|
||||||
|
|
||||||
|
await service.send(ldapUser);
|
||||||
|
|
||||||
|
expect(prisma.__log.some((c) => c.model === 'passwordResetToken')).toBe(false);
|
||||||
|
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
|
||||||
|
expect(rendered.text).toContain('gewohnten Windows-Passwort');
|
||||||
|
expect(rendered.html).toContain('gewohnten Windows-Passwort');
|
||||||
|
expect(rendered.html).not.toContain('reset-password');
|
||||||
|
expect(rendered.html).not.toContain('Passwort festlegen');
|
||||||
|
expect(rendered.html).toContain('Willkommen bei Tessera, erika!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Versandfehler → BadGatewayException, welcomeMailSentAt wird NICHT gesetzt', async () => {
|
||||||
|
const { service } = make(makeMail({ fail: true }));
|
||||||
|
await expect(service.send(ldapUser)).rejects.toBeInstanceOf(BadGatewayException);
|
||||||
|
expect(prisma.__log.some((c) => c.model === 'user')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Anzeigename mit Markup wird im HTML maskiert', async () => {
|
||||||
|
const { service, mail } = make();
|
||||||
|
await service.send({ ...ldapUser, displayName: '<b>Böse</b>' });
|
||||||
|
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
|
||||||
|
expect(rendered.html).not.toContain('<b>Böse</b>');
|
||||||
|
expect(rendered.html).toContain('<b>Böse</b>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WelcomeMailService.resolveAppUrl', () => {
|
||||||
|
it('Konfiguration gewinnt vor dem Origin; abschliessender Schraegstrich faellt weg', () => {
|
||||||
|
const { service } = make(
|
||||||
|
makeMail(),
|
||||||
|
makeConfig({ TESSERA_APP_URL: 'https://tessera.example.invalid/' }),
|
||||||
|
);
|
||||||
|
expect(service.resolveAppUrl('https://anders.example.invalid')).toBe(
|
||||||
|
'https://tessera.example.invalid',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Konfiguration: Origin der Anfrage', () => {
|
||||||
|
const { service } = make(makeMail(), makeConfig({}));
|
||||||
|
expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe(
|
||||||
|
'https://alpha.example.invalid',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Konfiguration zeigt nur auf localhost (Compose-Vorgabe) → Origin gewinnt; ohne Origin bleibt die Konfiguration', () => {
|
||||||
|
const { service } = make(makeMail(), makeConfig({ TESSERA_APP_URL: 'http://localhost:3000' }));
|
||||||
|
expect(service.resolveAppUrl('https://alpha.example.invalid')).toBe(
|
||||||
|
'https://alpha.example.invalid',
|
||||||
|
);
|
||||||
|
expect(service.resolveAppUrl(undefined)).toBe('http://localhost:3000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ungueltiger Origin wird ignoriert', () => {
|
||||||
|
expect(normalizeOrigin('javascript:alert(1)')).toBeNull();
|
||||||
|
expect(normalizeOrigin('kein origin')).toBeNull();
|
||||||
|
expect(normalizeOrigin('https://a.example.invalid/pfad')).toBe('https://a.example.invalid');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WelcomeMailService.send — eigene Vorlage', () => {
|
||||||
|
const custom: WelcomeMailTexts = {
|
||||||
|
subject: 'Hallo {{vorname}} bei {{firma}}',
|
||||||
|
heading: 'Schön, dass Sie da sind, {{name}}!',
|
||||||
|
intro: 'Erste Zeile\nzweite Zeile\n\nIhr Konto: {{benutzername}} / {{email}} / {{adresse}}',
|
||||||
|
loginHintDirectory: 'Bitte mit dem Kennwort von {{firma}} anmelden, {{vorname}}.',
|
||||||
|
loginHintLocal: 'Zuerst ein eigenes Passwort vergeben, {{vorname}}.',
|
||||||
|
closing: 'Grüße vom <b>IT-Team</b>',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('ohne eigene Vorlage: Standardtexte; Vorlage und Firmenname werden fuer den Mandanten des ZIELS gelesen', async () => {
|
||||||
|
const { service, mail, templates } = make();
|
||||||
|
await service.send({ ...ldapUser, tenantId: 't9' });
|
||||||
|
expect(templates.getCustomTexts).toHaveBeenCalledWith('t9');
|
||||||
|
expect(templates.getTenantName).toHaveBeenCalledWith('t9');
|
||||||
|
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
|
||||||
|
expect(rendered.subject).toBe('Willkommen bei Tessera');
|
||||||
|
expect(rendered.html).toContain('Tessera gibt es auch als Desktop-App');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mit eigener Vorlage des Ziel-Mandanten: Platzhalter ersetzt, Absaetze/Umbrueche, Markup aus der Vorlage escaped, feste Bausteine bleiben', async () => {
|
||||||
|
const { service, mail } = make(undefined, undefined, makeTemplates({ t1: custom }));
|
||||||
|
await service.send(localUser);
|
||||||
|
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
|
||||||
|
expect(rendered.subject).toBe('Hallo Max bei Firma t1');
|
||||||
|
expect(rendered.html).toContain('Schön, dass Sie da sind, Max Muster!');
|
||||||
|
expect(rendered.html).toContain('Erste Zeile<br>zweite Zeile');
|
||||||
|
expect(rendered.html).toContain(
|
||||||
|
'Ihr Konto: max.muster / max@example.invalid / https://tessera.example.invalid',
|
||||||
|
);
|
||||||
|
expect(rendered.html).toContain('Grüße vom <b>IT-Team</b>');
|
||||||
|
expect(rendered.html).not.toContain('<b>IT-Team</b>');
|
||||||
|
expect(rendered.html).not.toContain('Desktop-App');
|
||||||
|
// feste Bausteine
|
||||||
|
expect(rendered.html).toContain('Passwort festlegen');
|
||||||
|
expect(rendered.html).toContain('/reset-password/');
|
||||||
|
expect(rendered.html).toContain('Zu Tessera');
|
||||||
|
expect(rendered.html).toContain('Benutzername');
|
||||||
|
expect(rendered.text).toContain('Erste Zeile\nzweite Zeile');
|
||||||
|
// Anmeldehinweis fuer lokale Konten aus der Vorlage
|
||||||
|
expect(rendered.html).toContain('Zuerst ein eigenes Passwort vergeben, Max.');
|
||||||
|
expect(rendered.html).not.toContain('persönliches Passwort fest');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Verzeichniskonto mit eigener Vorlage: eigener Verzeichnis-Hinweis statt Windows-Standard', async () => {
|
||||||
|
const { service, mail } = make(undefined, undefined, makeTemplates({ t1: custom }));
|
||||||
|
await service.send(ldapUser);
|
||||||
|
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
|
||||||
|
expect(rendered.html).toContain('Bitte mit dem Kennwort von Firma t1 anmelden');
|
||||||
|
expect(rendered.html).not.toContain('gewohnten Windows-Passwort');
|
||||||
|
expect(rendered.html).not.toContain('Zuerst ein eigenes Passwort vergeben');
|
||||||
|
expect(rendered.text).toContain('Bitte mit dem Kennwort von Firma t1 anmelden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Vorlage eines ANDEREN Mandanten wirkt nicht', async () => {
|
||||||
|
const { service, mail } = make(undefined, undefined, makeTemplates({ t2: custom }));
|
||||||
|
await service.send(localUser);
|
||||||
|
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
|
||||||
|
expect(rendered.subject).toBe('Willkommen bei Tessera');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Platzhalterwerte mit Markup werden escaped (Anzeigename in {{name}}/{{vorname}})', async () => {
|
||||||
|
const { service, mail } = make(undefined, undefined, makeTemplates({ t1: custom }));
|
||||||
|
await service.send({ ...ldapUser, displayName: '<img src=x onerror=alert(1)> Böse' });
|
||||||
|
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
|
||||||
|
expect(rendered.html).not.toContain('<img src=x');
|
||||||
|
expect(rendered.html).toContain('<img src=x onerror=alert(1)> Böse');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEFAULT_TEXTS: WelcomeMailTexts = { ...DEFAULT_WELCOME_MAIL_TEXTS };
|
||||||
|
|
||||||
|
describe('WelcomeMailService.preview', () => {
|
||||||
|
const texts: WelcomeMailTexts = {
|
||||||
|
subject: 'Betreff {{name}}',
|
||||||
|
heading: 'Hallo {{vorname}}',
|
||||||
|
intro: 'Bei {{firma}}',
|
||||||
|
loginHintDirectory: 'Verzeichnis-Hinweis {{benutzername}}',
|
||||||
|
loginHintLocal: 'Lokal-Hinweis {{benutzername}}',
|
||||||
|
closing: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('Beispielwerte, Firmenname des eigenen Mandanten, kein Token, kein Versand, kein cid:', async () => {
|
||||||
|
const { service, mail, templates } = make();
|
||||||
|
const rendered = await service.preview('t1', texts, 'local');
|
||||||
|
expect(templates.getTenantName).toHaveBeenCalledWith('t1');
|
||||||
|
expect(rendered.subject).toBe('Betreff Max Mustermann');
|
||||||
|
expect(rendered.html).toContain('Hallo Max');
|
||||||
|
expect(rendered.html).toContain('Bei Firma t1');
|
||||||
|
expect(rendered.html).toContain('/reset-password/beispiel');
|
||||||
|
expect(rendered.html).toContain('Lokal-Hinweis max.mustermann');
|
||||||
|
expect(rendered.html).not.toContain('Verzeichnis-Hinweis');
|
||||||
|
expect(rendered.html).not.toContain('cid:');
|
||||||
|
expect(prisma.__log).toHaveLength(0);
|
||||||
|
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Verzeichniskonto-Variante: Windows-Hinweis statt Passwort-Knopf', async () => {
|
||||||
|
const { service } = make();
|
||||||
|
const rendered = await service.preview('t1', DEFAULT_TEXTS, 'directory');
|
||||||
|
expect(rendered.html).toContain('gewohnten Windows-Passwort');
|
||||||
|
expect(rendered.html).not.toContain('Passwort festlegen');
|
||||||
|
const custom = await service.preview('t1', texts, 'directory');
|
||||||
|
expect(custom.html).toContain('Verzeichnis-Hinweis max.mustermann');
|
||||||
|
expect(custom.html).not.toContain('Lokal-Hinweis');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WelcomeMailService.sendTest', () => {
|
||||||
|
const texts: WelcomeMailTexts = {
|
||||||
|
subject: 'Test {{name}}',
|
||||||
|
heading: 'Hallo {{vorname}}',
|
||||||
|
intro: 'Text',
|
||||||
|
loginHintDirectory: DEFAULT_WELCOME_MAIL_TEXTS.loginHintDirectory,
|
||||||
|
loginHintLocal: 'Eigener Lokal-Hinweis für {{vorname}}',
|
||||||
|
closing: 'Gruß',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('geht an die eigene Adresse, liest das eigene Konto gebunden, erzeugt KEIN Token und setzt KEIN welcomeMailSentAt', async () => {
|
||||||
|
const { service, mail } = make();
|
||||||
|
const result = await service.sendTest('t1', 'u-self', texts);
|
||||||
|
expect(result.to).toBe('ada@example.invalid');
|
||||||
|
const find = prisma.__log.find((c) => c.method === 'findFirst');
|
||||||
|
expect(find?.tenantId).toBe('t1');
|
||||||
|
expect(find?.args.where).toEqual({ id: 'u-self', tenantId: 't1' });
|
||||||
|
expect(prisma.__log.some((c) => c.model === 'passwordResetToken')).toBe(false);
|
||||||
|
expect(prisma.__log.some((c) => c.method === 'update')).toBe(false);
|
||||||
|
|
||||||
|
const [tenantId, to, rendered] = mail.sendWelcomeMail.mock.calls[0] as any[];
|
||||||
|
expect(tenantId).toBe('t1');
|
||||||
|
expect(to).toBe('ada@example.invalid');
|
||||||
|
expect(rendered.subject).toBe('Test Ada Admin');
|
||||||
|
expect(rendered.html).toContain('Testmail');
|
||||||
|
// lokales Konto: Beispiel-Link zur Anmeldeseite, kein reset-password
|
||||||
|
expect(rendered.html).toContain('Passwort festlegen');
|
||||||
|
expect(rendered.html).toContain('Beispiel-Link');
|
||||||
|
expect(rendered.html).not.toContain('reset-password');
|
||||||
|
// Anmeldehinweis fuer lokale Konten aus dem Formularinhalt
|
||||||
|
expect(rendered.html).toContain('Eigener Lokal-Hinweis für Ada');
|
||||||
|
expect(rendered.text).not.toContain('reset-password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verzeichnisgefuehrtes eigenes Konto: Windows-Hinweis', async () => {
|
||||||
|
selfRow = { ...(selfRow as NonNullable<typeof selfRow>), ldapDn: 'CN=Ada' };
|
||||||
|
const { service, mail } = make();
|
||||||
|
await service.sendTest('t1', 'u-self', texts);
|
||||||
|
const rendered = (mail.sendWelcomeMail.mock.calls[0] as any[])[2];
|
||||||
|
expect(rendered.html).toContain('gewohnten Windows-Passwort');
|
||||||
|
expect(rendered.html).not.toContain('Passwort festlegen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne eigene Adresse → BadRequestException, kein Versand', async () => {
|
||||||
|
selfRow = { ...(selfRow as NonNullable<typeof selfRow>), email: null };
|
||||||
|
const { service, mail } = make();
|
||||||
|
await expect(service.sendTest('t1', 'u-self', texts)).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(mail.sendWelcomeMail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Versandweg → ConflictException; Versandfehler → BadGatewayException', async () => {
|
||||||
|
await expect(
|
||||||
|
make(makeMail({ available: false })).service.sendTest('t1', 'u-self', texts),
|
||||||
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
await expect(
|
||||||
|
make(makeMail({ fail: true })).service.sendTest('t1', 'u-self', texts),
|
||||||
|
).rejects.toBeInstanceOf(BadGatewayException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
BadGatewayException,
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import type { User } from '@prisma/client';
|
||||||
|
import type { WelcomeMailTexts } from '@tessera/shared';
|
||||||
|
import { WELCOME_TOKEN_TTL_MS } from '../auth/password-reset-token';
|
||||||
|
import { loadWelcomeHeaderPng, MailService, WELCOME_HEADER_CID } from '../mail/mail.service';
|
||||||
|
import {
|
||||||
|
type RenderedWelcomeMail,
|
||||||
|
renderWelcomeMail,
|
||||||
|
type WelcomeMailAccount,
|
||||||
|
} from '../mail/welcome-mail.template';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||||
|
import { WelcomeMailTemplateService } from './welcome-mail-template.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WelcomeMailService — Willkommensmail aus der Benutzerverwaltung
|
||||||
|
* (Administrator → Benutzer, "Willkommensmail senden").
|
||||||
|
*
|
||||||
|
* Kann an JEDEN Benutzer gehen, unabhaengig davon, ob und wann er sich
|
||||||
|
* schon angemeldet hat (Wunsch des Betreibers: auch zum Erneuern des
|
||||||
|
* Zugangs und zum Ausprobieren). Wer wem senden darf (nur eigener
|
||||||
|
* Mandant, ADMIN nie an SUPER_ADMIN), prueft der Controller wie beim
|
||||||
|
* Bearbeiten. Erneutes Senden ist erlaubt und ueberschreibt
|
||||||
|
* `welcomeMailSentAt`. Abgelehnt wird nur ein deaktiviertes Konto (409),
|
||||||
|
* ein Konto ohne Adresse (400) und ein Mandant ohne Versandweg (409).
|
||||||
|
*
|
||||||
|
* Anmeldehinweis je Kontoart:
|
||||||
|
* - verzeichnisgefuehrt (`ldapDn` gesetzt): Hinweis auf das gewohnte
|
||||||
|
* Windows-Passwort — Tessera kennt und setzt dieses Kennwort nicht (das
|
||||||
|
* Verzeichnis bleibt nur lesend angebunden);
|
||||||
|
* - lokal: Link "Passwort festlegen" mit einem frischen `PasswordResetToken`
|
||||||
|
* — derselbe Token, dieselbe Seite `/reset-password/<token>` und dieselbe
|
||||||
|
* Frist `WELCOME_TOKEN_TTL_MS` (7 Tage statt 1 Stunde wie beim Weg "Passwort
|
||||||
|
* vergessen"). Ein Kennwort steht NIE in der Mail.
|
||||||
|
*
|
||||||
|
* Mandantenbindung: der Aufrufer (`UserController`) hat den Zielbenutzer
|
||||||
|
* bereits rollenabhaengig aufgeloest und die Mandanten-/Rollenregeln
|
||||||
|
* geprueft; beide Zugriffe hier (`passwordResetToken.create`,
|
||||||
|
* `user.update`) binden ueber `forTenant()` an den Mandanten des ZIELS —
|
||||||
|
* dieselbe Regel wie `UserController.update`. Der Versand laeuft ueber den
|
||||||
|
* SMTP-Weg genau dieses Mandanten (`MailService.sendWelcomeMail`).
|
||||||
|
*
|
||||||
|
* Eigene Vorlage (Administrator → Willkommensmail): `send` nimmt die Texte
|
||||||
|
* der Vorlage des Mandanten des ZIELS (`WelcomeMailTemplateService`), sonst
|
||||||
|
* die Standardtexte. `preview` rendert einen (auch ungespeicherten)
|
||||||
|
* Formularinhalt mit Beispielwerten, `sendTest` schickt ihn an die eigene
|
||||||
|
* Adresse des Administrators — OHNE Token: bei einem lokalen eigenen Konto
|
||||||
|
* zeigt die Testmail den Knopf "Passwort festlegen" als Beispiel-Link zur
|
||||||
|
* Anmeldeseite (Kontoart `local-example`), damit nie ein nutzbarer
|
||||||
|
* Passwort-Link an eine Testadresse geht.
|
||||||
|
*
|
||||||
|
* Adresse in der Mail: `TESSERA_APP_URL` (in Compose aus `APP_URL`), sonst
|
||||||
|
* der Origin der Admin-Anfrage. Zeigt die Konfiguration nur auf
|
||||||
|
* `localhost` (Compose-Vorgabe, nie gesetzt), gewinnt ein vorhandener
|
||||||
|
* Origin — sonst bekaeme der Empfaenger einen Link, der auf seinem
|
||||||
|
* Rechner ins Leere fuehrt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Zielbenutzer, so weit dieser Dienst ihn liest. */
|
||||||
|
export type WelcomeMailTarget = Pick<
|
||||||
|
User,
|
||||||
|
'id' | 'tenantId' | 'username' | 'displayName' | 'email' | 'ldapDn' | 'isActive'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface WelcomeMailResult {
|
||||||
|
to: string;
|
||||||
|
welcomeMailSentAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK_APP_URL = 'http://localhost:3000';
|
||||||
|
|
||||||
|
/** Beispielwerte der Vorschau (Administrator → Willkommensmail). */
|
||||||
|
export const PREVIEW_SAMPLE = {
|
||||||
|
name: 'Max Mustermann',
|
||||||
|
username: 'max.mustermann',
|
||||||
|
email: 'max.mustermann@example.com',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** `http(s)://host[:port]` aus einem Origin-Kopf, sonst `null`. */
|
||||||
|
export function normalizeOrigin(value: string | undefined | null): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||||
|
return url.origin;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalhostUrl(value: string): boolean {
|
||||||
|
try {
|
||||||
|
const host = new URL(value).hostname;
|
||||||
|
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WelcomeMailService {
|
||||||
|
private readonly logger = new Logger(WelcomeMailService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly mailService: MailService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly templateService: WelcomeMailTemplateService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Ob fuer den Mandanten ein Versandweg eingerichtet ist (Knopf aktiv/inaktiv). */
|
||||||
|
async isAvailable(tenantId: string): Promise<boolean> {
|
||||||
|
return this.mailService.hasConfiguredTransport(tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oeffentliche Adresse fuer Links in der Mail: Konfiguration, sonst Origin
|
||||||
|
* der Anfrage, sonst die Entwicklungsvorgabe (siehe Kopfkommentar).
|
||||||
|
*/
|
||||||
|
resolveAppUrl(requestOrigin?: string | null): string {
|
||||||
|
const raw = (this.configService.get<string>('TESSERA_APP_URL') ?? '').trim();
|
||||||
|
const configured = /^https?:\/\//i.test(raw) ? raw.replace(/\/+$/, '') : '';
|
||||||
|
const origin = normalizeOrigin(requestOrigin);
|
||||||
|
if (configured && !(isLocalhostUrl(configured) && origin)) {
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
return origin ?? (configured || FALLBACK_APP_URL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(target: WelcomeMailTarget, requestOrigin?: string | null): Promise<WelcomeMailResult> {
|
||||||
|
if (!target.isActive) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Dieses Konto ist deaktiviert. Aktivieren Sie es zuerst, bevor Sie eine Willkommensmail senden.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const to = target.email?.trim();
|
||||||
|
if (!to) {
|
||||||
|
throw new BadRequestException('Für diesen Benutzer ist keine E-Mail-Adresse hinterlegt.');
|
||||||
|
}
|
||||||
|
if (!(await this.mailService.hasConfiguredTransport(target.tenantId))) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Für den E-Mail-Versand ist noch kein SMTP-Server eingerichtet. Ein Administrator legt ihn unter Administrator → SMTP fest.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const appUrl = this.resolveAppUrl(requestOrigin);
|
||||||
|
const tenantPrisma = forTenant(this.prisma, target.tenantId);
|
||||||
|
|
||||||
|
let account: WelcomeMailAccount;
|
||||||
|
if (target.ldapDn) {
|
||||||
|
account = { kind: 'directory' };
|
||||||
|
} else {
|
||||||
|
// Derselbe Token wie beim Weg "Passwort vergessen" (T-02-13): zufaellig,
|
||||||
|
// einmal verwendbar, gleiche Frist.
|
||||||
|
const token = randomUUID();
|
||||||
|
await tenantPrisma.passwordResetToken.create({
|
||||||
|
data: {
|
||||||
|
token,
|
||||||
|
userId: target.id,
|
||||||
|
expiresAt: new Date(Date.now() + WELCOME_TOKEN_TTL_MS),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
account = {
|
||||||
|
kind: 'local',
|
||||||
|
setPasswordUrl: `${appUrl}/reset-password/${token}`,
|
||||||
|
validHours: Math.round(WELCOME_TOKEN_TTL_MS / (60 * 60 * 1000)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [texts, tenantName] = await Promise.all([
|
||||||
|
this.templateService.getCustomTexts(target.tenantId),
|
||||||
|
this.templateService.getTenantName(target.tenantId),
|
||||||
|
]);
|
||||||
|
const mail = renderWelcomeMail({
|
||||||
|
name: target.displayName?.trim() || target.username,
|
||||||
|
username: target.username,
|
||||||
|
email: to,
|
||||||
|
tenantName,
|
||||||
|
appUrl,
|
||||||
|
account,
|
||||||
|
headerImageSrc: loadWelcomeHeaderPng() ? `cid:${WELCOME_HEADER_CID}` : null,
|
||||||
|
texts,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.mailService.sendWelcomeMail(target.tenantId, to, mail);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Welcome mail to user ${target.id} failed`,
|
||||||
|
error instanceof Error ? error.stack : String(error),
|
||||||
|
);
|
||||||
|
throw new BadGatewayException(
|
||||||
|
'Die Willkommensmail konnte nicht gesendet werden. Bitte prüfen Sie die SMTP-Einstellungen oder versuchen Sie es später erneut.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await tenantPrisma.user.update({
|
||||||
|
where: { id: target.id },
|
||||||
|
data: { welcomeMailSentAt: new Date() },
|
||||||
|
select: { welcomeMailSentAt: true },
|
||||||
|
});
|
||||||
|
const welcomeMailSentAt = updated.welcomeMailSentAt ?? new Date();
|
||||||
|
|
||||||
|
this.logger.log(`Welcome mail sent to user ${target.id} (tenant ${target.tenantId})`);
|
||||||
|
return { to, welcomeMailSentAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorschau fuer Administrator → Willkommensmail: rendert `texts` (auch
|
||||||
|
* ungespeichert) mit festen Beispielwerten und dem Firmennamen des eigenen
|
||||||
|
* Mandanten. Das Kopfbild steht als `data:`-Adresse im HTML statt als
|
||||||
|
* `cid:`, damit die Vorschau im Rahmen der Seite der echten Mail gleicht.
|
||||||
|
* Der Link "Passwort festlegen" ist ein Beispiel ohne Token.
|
||||||
|
*/
|
||||||
|
async preview(
|
||||||
|
tenantId: string,
|
||||||
|
texts: WelcomeMailTexts,
|
||||||
|
accountKind: 'directory' | 'local',
|
||||||
|
requestOrigin?: string | null,
|
||||||
|
): Promise<RenderedWelcomeMail> {
|
||||||
|
const appUrl = this.resolveAppUrl(requestOrigin);
|
||||||
|
const header = loadWelcomeHeaderPng();
|
||||||
|
const account: WelcomeMailAccount =
|
||||||
|
accountKind === 'directory'
|
||||||
|
? { kind: 'directory' }
|
||||||
|
: {
|
||||||
|
kind: 'local',
|
||||||
|
setPasswordUrl: `${appUrl}/reset-password/beispiel`,
|
||||||
|
validHours: Math.round(WELCOME_TOKEN_TTL_MS / (60 * 60 * 1000)),
|
||||||
|
};
|
||||||
|
return renderWelcomeMail({
|
||||||
|
name: PREVIEW_SAMPLE.name,
|
||||||
|
username: PREVIEW_SAMPLE.username,
|
||||||
|
email: PREVIEW_SAMPLE.email,
|
||||||
|
tenantName: await this.templateService.getTenantName(tenantId),
|
||||||
|
appUrl,
|
||||||
|
account,
|
||||||
|
headerImageSrc: header ? `data:image/png;base64,${header.toString('base64')}` : null,
|
||||||
|
texts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Testmail an den angemeldeten Administrator selbst, mit dem aktuellen
|
||||||
|
* (auch ungespeicherten) Formularinhalt und dem eigenen Konto als
|
||||||
|
* Beispiel. Es entsteht KEIN Token: ein verzeichnisgefuehrtes eigenes
|
||||||
|
* Konto bekommt den Windows-Hinweis, ein lokales den Beispiel-Link zur
|
||||||
|
* Anmeldeseite (`local-example`). `welcomeMailSentAt` bleibt unberuehrt.
|
||||||
|
* Ohne eigene Adresse 400, ohne Versandweg 409, Versandfehler 502.
|
||||||
|
*/
|
||||||
|
async sendTest(
|
||||||
|
tenantId: string,
|
||||||
|
userId: string,
|
||||||
|
texts: WelcomeMailTexts,
|
||||||
|
requestOrigin?: string | null,
|
||||||
|
): Promise<{ to: string }> {
|
||||||
|
const tenantPrisma = forTenant(this.prisma, tenantId);
|
||||||
|
const self = await tenantPrisma.user.findFirst({
|
||||||
|
where: { id: userId, tenantId },
|
||||||
|
select: { username: true, displayName: true, email: true, ldapDn: true },
|
||||||
|
});
|
||||||
|
const to = self?.email?.trim();
|
||||||
|
if (!self || !to) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Für Ihr eigenes Konto ist keine E-Mail-Adresse hinterlegt. Hinterlegen Sie zuerst eine Adresse (Administrator → Benutzer), dann kann die Testmail an Sie gehen.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!(await this.mailService.hasConfiguredTransport(tenantId))) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Für den E-Mail-Versand ist noch kein SMTP-Server eingerichtet. Ein Administrator legt ihn unter Administrator → SMTP fest.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mail = renderWelcomeMail({
|
||||||
|
name: self.displayName?.trim() || self.username,
|
||||||
|
username: self.username,
|
||||||
|
email: to,
|
||||||
|
tenantName: await this.templateService.getTenantName(tenantId),
|
||||||
|
appUrl: this.resolveAppUrl(requestOrigin),
|
||||||
|
account: self.ldapDn ? { kind: 'directory' } : { kind: 'local-example' },
|
||||||
|
headerImageSrc: loadWelcomeHeaderPng() ? `cid:${WELCOME_HEADER_CID}` : null,
|
||||||
|
texts,
|
||||||
|
notice:
|
||||||
|
'Testmail aus Administrator → Willkommensmail – so sieht die Willkommensmail mit Ihrer Vorlage aus.',
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.mailService.sendWelcomeMail(tenantId, to, mail);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Welcome mail test to user ${userId} failed`,
|
||||||
|
error instanceof Error ? error.stack : String(error),
|
||||||
|
);
|
||||||
|
throw new BadGatewayException(
|
||||||
|
'Die Testmail konnte nicht gesendet werden. Bitte prüfen Sie die SMTP-Einstellungen oder versuchen Sie es später erneut.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.logger.log(`Welcome mail test sent to user ${userId} (tenant ${tenantId})`);
|
||||||
|
return { to };
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+17
@@ -4034,6 +4034,22 @@ dependencies = [
|
|||||||
"zbus",
|
"zbus",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-single-instance"
|
||||||
|
version = "2.4.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "db817fe9295e19b7d8357e900af31edb93703dd9fb6de524b007b47b6afc63b0"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tauri",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
|
"zbus",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-store"
|
name = "tauri-plugin-store"
|
||||||
version = "2.4.3"
|
version = "2.4.3"
|
||||||
@@ -4246,6 +4262,7 @@ dependencies = [
|
|||||||
"tauri-plugin-autostart",
|
"tauri-plugin-autostart",
|
||||||
"tauri-plugin-notification",
|
"tauri-plugin-notification",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
|
"tauri-plugin-single-instance",
|
||||||
"tauri-plugin-store",
|
"tauri-plugin-store",
|
||||||
"tauri-plugin-updater",
|
"tauri-plugin-updater",
|
||||||
"tauri-plugin-window-state",
|
"tauri-plugin-window-state",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ tauri-plugin-store = "2"
|
|||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
tauri-plugin-autostart = "2"
|
tauri-plugin-autostart = "2"
|
||||||
tauri-plugin-window-state = "2"
|
tauri-plugin-window-state = "2"
|
||||||
|
tauri-plugin-single-instance = "2"
|
||||||
reqwest = { version = "0.12", features = ["json"] }
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use semver::Version;
|
use semver::Version;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tauri::{
|
use tauri::{
|
||||||
@@ -48,6 +49,59 @@ struct VersionResponse {
|
|||||||
/// `Update` ist Clone + Send + Sync, `app.manage` verlangt das.
|
/// `Update` ist Clone + Send + Sync, `app.manage` verlangt das.
|
||||||
struct PendingUpdate(Mutex<Option<(Update, String)>>);
|
struct PendingUpdate(Mutex<Option<(Update, String)>>);
|
||||||
|
|
||||||
|
/// Ablauf-Merker des Updaters neben `PendingUpdate`.
|
||||||
|
///
|
||||||
|
/// `installing`: von `spawn_update_install` bis zum Fehlerfall gesetzt (im
|
||||||
|
/// Erfolgsfall startet die App neu bzw. beendet sich, der Merker bleibt).
|
||||||
|
/// Solange er gesetzt ist, tun Tray-Klick, `spawn_version_check` und die
|
||||||
|
/// 4-h-Schleife nichts -- vorher konnte eine Pruefung waehrend des Downloads
|
||||||
|
/// ein neues Update ablegen und den Eintrag wieder aktiv schalten, ein
|
||||||
|
/// zweiter Klick startete dann einen parallelen Download samt Installer.
|
||||||
|
///
|
||||||
|
/// `offered`: eine Pruefung hat ein Update angeboten ("Auf Version …
|
||||||
|
/// aktualisieren"). Der Klick darauf prueft frisch (`install_after`); schlug
|
||||||
|
/// diese Pruefung fehl, war der abgelegte Stand weg und der naechste Klick
|
||||||
|
/// bot nur wieder an -- zwei weitere Klicks bis zur Installation. Der Merker
|
||||||
|
/// ueberlebt die fehlgeschlagene Pruefung, der naechste erfolgreiche Klick
|
||||||
|
/// installiert direkt. Geleert, wenn eine Pruefung "kein Update" ergibt oder
|
||||||
|
/// der Server wechselt.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct UpdateFlow {
|
||||||
|
installing: AtomicBool,
|
||||||
|
offered: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateFlow {
|
||||||
|
/// Beansprucht die Installation. `false`, wenn bereits eine laeuft --
|
||||||
|
/// dann darf der Aufrufer nichts starten.
|
||||||
|
fn try_begin_install(&self) -> bool {
|
||||||
|
self.installing
|
||||||
|
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||||
|
.is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gibt die Installation nach einem Fehler wieder frei.
|
||||||
|
fn install_failed(&self) {
|
||||||
|
self.installing.store(false, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_installing(&self) -> bool {
|
||||||
|
self.installing.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_offered(&self, offered: bool) {
|
||||||
|
self.offered.store(offered, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soll der Tray-Klick nach der frischen Pruefung installieren? Ja, wenn
|
||||||
|
/// ein Stand abgelegt war ODER vorher schon ein Update angeboten wurde
|
||||||
|
/// (Klick auf "Update-Prüfung fehlgeschlagen … – erneut prüfen" nach
|
||||||
|
/// einem Angebot).
|
||||||
|
fn install_on_click(&self, had_pending: bool) -> bool {
|
||||||
|
had_pending || self.offered.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Benachrichtigungstext der zuletzt gemeldeten fehlgeschlagenen
|
/// Benachrichtigungstext der zuletzt gemeldeten fehlgeschlagenen
|
||||||
/// Update-Pruefung. Die Pruefung laeuft alle `UPDATE_CHECK_INTERVAL` erneut;
|
/// Update-Pruefung. Die Pruefung laeuft alle `UPDATE_CHECK_INTERVAL` erneut;
|
||||||
/// gegen einen dauerhaft sperrenden Proxy wuerde sonst alle vier Stunden
|
/// gegen einen dauerhaft sperrenden Proxy wuerde sonst alle vier Stunden
|
||||||
@@ -324,6 +378,149 @@ fn server_host(url: Option<&str>) -> Option<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Die drei Benachrichtigungs-Berechtigungen, die die Server-Seite in der
|
||||||
|
/// Webview bekommt -- genau diese und keine weitere (T-IF2-03). Die
|
||||||
|
/// Bezeichner stammen aus tauri-plugin-notification 2.3.3
|
||||||
|
/// (`permissions/autogenerated/commands/notify.toml`,
|
||||||
|
/// `is_permission_granted.toml`, `request_permission.toml`). Eine unbekannte
|
||||||
|
/// Kennung wuerde in `add_capability` ebenfalls in einen Panic laufen; darum
|
||||||
|
/// pinnt ein Test die exakte Menge samt Reihenfolge.
|
||||||
|
const SERVER_NOTIFICATION_PERMISSIONS: [&str; 3] = [
|
||||||
|
"notification:allow-notify",
|
||||||
|
"notification:allow-is-permission-granted",
|
||||||
|
"notification:allow-request-permission",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Bereits berechtigte Herkunfts-Muster. `add_capability` haengt bei jedem
|
||||||
|
/// Aufruf an die bestehenden Erlaubnisse an; ein zweites Speichern derselben
|
||||||
|
/// Adresse soll nichts verdoppeln.
|
||||||
|
static GRANTED_SERVER_ORIGINS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||||
|
|
||||||
|
/// Baut aus der gespeicherten Server-Adresse das URL-Muster fuer die
|
||||||
|
/// Laufzeit-Berechtigung: genau `Schema://Host[:Port]`, ohne Pfad, Query und
|
||||||
|
/// Fragment (quick-260929-if2, E-01).
|
||||||
|
///
|
||||||
|
/// Jedes Zeichen des Hosts ausserhalb von ASCII `A-Z a-z 0-9 . -` bekommt ein
|
||||||
|
/// Backslash (URLPattern-Maskierung). Grund: der `url`-Crate nimmt
|
||||||
|
/// `http://*.example.com/` an und liefert den Host `*.example.com`, der
|
||||||
|
/// ungeschuetzt zum Platzhalter fuer JEDE Subdomain wuerde. IPv6-Adressen
|
||||||
|
/// kommen in Klammern (`[::1]`); der URLPattern-Zerleger (urlpattern 0.3.0)
|
||||||
|
/// weist `http://[::1]:8080` mit `Tokenizer(InvalidName, 1)` ab, die
|
||||||
|
/// maskierte Form `http://\[\:\:1\]:8080` dagegen parst und trifft nur
|
||||||
|
/// `[::1]:8080` (beides gemessen).
|
||||||
|
///
|
||||||
|
/// Der Port steht nur, wenn er ausdruecklich und nicht der Standardport ist
|
||||||
|
/// (der `url`-Crate laesst Standardports weg).
|
||||||
|
///
|
||||||
|
/// SELBSTPRUEFUNG, Pflicht: `add_capability` fuehrt intern
|
||||||
|
/// `Resolved::resolve(..).unwrap()` aus, waehrend es die Sperre der
|
||||||
|
/// Laufzeit-Berechtigungen haelt, und tauri-utils 2.9.3 gerät bei einem
|
||||||
|
/// nicht zerlegbaren Muster in einen Panic ("invalid URL pattern for remote
|
||||||
|
/// URL"). Ein Fehler kommt NICHT als `Err` zurueck, er legt `setup()` lahm --
|
||||||
|
/// und ein `catch_unwind` haette die Sperre vergiftet, sodass jeder spaetere
|
||||||
|
/// IPC-Aufruf scheitert. Deshalb wird das Muster VOR dem Aufruf mit
|
||||||
|
/// demselben Zerleger geprueft, den Tauri fuer `remote.urls` benutzt
|
||||||
|
/// (`RemoteUrlPattern`), und muss die gespeicherte Adresse tatsaechlich
|
||||||
|
/// treffen. Sonst `None`: kein Muster, keine Berechtigung.
|
||||||
|
fn server_origin_pattern(url: &str) -> Option<String> {
|
||||||
|
use std::str::FromStr;
|
||||||
|
let parsed = parse_server_url(url).ok()?;
|
||||||
|
let host = parsed.host_str()?;
|
||||||
|
let escaped: String = host
|
||||||
|
.chars()
|
||||||
|
.flat_map(|c| {
|
||||||
|
if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
|
||||||
|
vec![c]
|
||||||
|
} else {
|
||||||
|
vec!['\\', c]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut pattern = format!("{}://{}", parsed.scheme(), escaped);
|
||||||
|
if let Some(port) = parsed.port() {
|
||||||
|
pattern.push_str(&format!(":{}", port));
|
||||||
|
}
|
||||||
|
let checked = tauri::utils::acl::RemoteUrlPattern::from_str(&pattern).ok()?;
|
||||||
|
if checked.test(&parsed) {
|
||||||
|
Some(pattern)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Erteilt der gespeicherten Server-Adresse -- und nur ihr -- zur Laufzeit das
|
||||||
|
/// Recht, das Benachrichtigungs-Plugin aus der Webview aufzurufen
|
||||||
|
/// (quick-260929-if2, E-01). Der globale Erinnerungs-Melder der Web-Oberflaeche
|
||||||
|
/// ruft `plugin:notification|notify`, damit auch bei ausgeblendetem Fenster
|
||||||
|
/// (Infobereich) eine Windows-Meldung erscheint.
|
||||||
|
///
|
||||||
|
/// WARUM ZUR LAUFZEIT UND NICHT IN `capabilities/default.json`: die statische
|
||||||
|
/// Datei hat absichtlich keinen `remote`-Block (T-JN2-01, siehe
|
||||||
|
/// `get_server_url`), Tauri verweigert deshalb jeden Plugin- und App-Aufruf
|
||||||
|
/// aus der Server-Seite. Die Serveradresse ist erst zur Laufzeit bekannt und
|
||||||
|
/// je Installation verschieden. Die Berechtigung gilt genau fuer den
|
||||||
|
/// gespeicherten Ursprung (maskiert und selbstgeprueft, siehe
|
||||||
|
/// `server_origin_pattern` -- nie ein Platzhalter), nur fuer das Fenster
|
||||||
|
/// `main` und nur fuer die drei Benachrichtigungs-Rechte. App-Befehle wie
|
||||||
|
/// `save_server_url` bleiben lokal-only, T-JN2-01 gilt unveraendert. Nach
|
||||||
|
/// einem Serverwechsel behaelt der alte Ursprung sein Benachrichtigungsrecht
|
||||||
|
/// bis zum App-Neustart (akzeptiert, T-IF2-03).
|
||||||
|
///
|
||||||
|
/// Verworfene Alternativen: (1) ein Abruf der API von Rust aus -- der
|
||||||
|
/// vorgeschaltete Basic-Auth-Schutz von alpha liefert reqwest ein 401 (wie
|
||||||
|
/// beim Updater), und das Sitzungs-Cookie lebt nur in der Webview.
|
||||||
|
/// (2) Die Web-Benachrichtigung in der Webview -- das Plugin ersetzt
|
||||||
|
/// `window.Notification` durch einen Ersatz mit demselben IPC-Aufruf, der
|
||||||
|
/// unter Windows bei jedem Seitenaufruf "denied" meldet.
|
||||||
|
///
|
||||||
|
/// Darf den Start nie verhindern: bei einem Muster, das sich nicht bilden
|
||||||
|
/// laesst, bleibt es bei einer Zeile auf stderr -- die Desktop-Meldung ist
|
||||||
|
/// dann fuer diese Adresse aus, Browser-Meldung und E-Mail laufen weiter.
|
||||||
|
fn grant_server_notifications(app: &AppHandle, url: &str) {
|
||||||
|
let Some(pattern) = server_origin_pattern(url) else {
|
||||||
|
eprintln!(
|
||||||
|
"Benachrichtigungen: keine Berechtigung fuer die Server-Adresse erteilt (Muster nicht bildbar)"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let result = grant_origin_once(&GRANTED_SERVER_ORIGINS, &pattern, |pattern| {
|
||||||
|
let mut capability = tauri::ipc::CapabilityBuilder::new("server-notifications")
|
||||||
|
.remote(pattern.to_string())
|
||||||
|
.local(false)
|
||||||
|
.window("main");
|
||||||
|
for permission in SERVER_NOTIFICATION_PERMISSIONS {
|
||||||
|
capability = capability.permission(permission);
|
||||||
|
}
|
||||||
|
app.add_capability(capability)
|
||||||
|
});
|
||||||
|
if let Err(e) = result {
|
||||||
|
eprintln!("Benachrichtigungen: Berechtigung nicht erteilt: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fuehrt `grant` fuer `pattern` hoechstens einmal erfolgreich aus. Die Sperre
|
||||||
|
/// von `granted` bleibt ueber den Aufruf gehalten, damit zwei gleichzeitige
|
||||||
|
/// Aufrufe nicht doppelt berechtigen; vermerkt wird der Ursprung erst NACH
|
||||||
|
/// dem Erfolg. Frueher stand er schon vorher in der Liste -- schlug
|
||||||
|
/// `add_capability` fehl, gab es bis zum Neustart keinen neuen Versuch.
|
||||||
|
/// Bereits vermerkt: `Ok(())` ohne Aufruf.
|
||||||
|
fn grant_origin_once<E>(
|
||||||
|
granted: &Mutex<Vec<String>>,
|
||||||
|
pattern: &str,
|
||||||
|
grant: impl FnOnce(&str) -> Result<(), E>,
|
||||||
|
) -> Result<(), E> {
|
||||||
|
let mut granted = match granted.lock() {
|
||||||
|
Ok(guard) => guard,
|
||||||
|
Err(poisoned) => poisoned.into_inner(),
|
||||||
|
};
|
||||||
|
if granted.iter().any(|p| p == pattern) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
grant(pattern)?;
|
||||||
|
granted.push(pattern.to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Liefert (Tooltip, Menüzeile) für den Tray -- einzige Stelle, die beide
|
/// Liefert (Tooltip, Menüzeile) für den Tray -- einzige Stelle, die beide
|
||||||
/// Texte aus der Server-Adresse ableitet. Gedankenstrich U+2013 wie in
|
/// Texte aus der Server-Adresse ableitet. Gedankenstrich U+2013 wie in
|
||||||
/// `update_labels`.
|
/// `update_labels`.
|
||||||
@@ -461,7 +658,21 @@ fn clear_check_notice(app: &AppHandle) {
|
|||||||
/// / kein Update / fehlgeschlagen mit Grund); nur der http-Fall bleibt
|
/// / kein Update / fehlgeschlagen mit Grund); nur der http-Fall bleibt
|
||||||
/// gesperrt. Ein fehlgeschlagener Check war bisher vom Zustand "kein Update"
|
/// gesperrt. Ein fehlgeschlagener Check war bisher vom Zustand "kein Update"
|
||||||
/// nicht unterscheidbar (quick-260922-frg).
|
/// nicht unterscheidbar (quick-260922-frg).
|
||||||
fn spawn_version_check(app: AppHandle, server_url: String) {
|
///
|
||||||
|
/// `install_after`: der Klick auf "Auf Version … aktualisieren" installiert
|
||||||
|
/// NICHT den abgelegten Stand, sondern prueft zuerst frisch und installiert
|
||||||
|
/// genau, was diese Pruefung liefert. Grund (Nutzer, 29.09.2026: "Signatur-
|
||||||
|
/// verifikation failed", danach Browser): die Download-Adresse
|
||||||
|
/// `/desktop/download/<os>` liefert immer den AKTUELLEN Installer; ein
|
||||||
|
/// Stunden alter Stand trug noch die Signatur der Vorversion, nach dem
|
||||||
|
/// Server-Update passten Datei und Signatur nicht mehr zusammen.
|
||||||
|
///
|
||||||
|
/// Waehrend einer Installation (`UpdateFlow::installing`) tut die Funktion
|
||||||
|
/// nichts: kein Menuetext, kein Leeren, keine Ablage.
|
||||||
|
fn spawn_version_check(app: AppHandle, server_url: String, install_after: bool) {
|
||||||
|
if app.state::<UpdateFlow>().is_installing() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let update_item = app.state::<TrayItems>().update.clone();
|
let update_item = app.state::<TrayItems>().update.clone();
|
||||||
let _ = update_item.set_text(UPDATE_ITEM_CHECKING);
|
let _ = update_item.set_text(UPDATE_ITEM_CHECKING);
|
||||||
let _ = update_item.set_enabled(false);
|
let _ = update_item.set_enabled(false);
|
||||||
@@ -480,7 +691,14 @@ fn spawn_version_check(app: AppHandle, server_url: String) {
|
|||||||
.expect("CARGO_PKG_VERSION muss eine gueltige SemVer-Version sein");
|
.expect("CARGO_PKG_VERSION muss eine gueltige SemVer-Version sein");
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
match check_for_update(&app, endpoint).await {
|
let result = check_for_update(&app, endpoint).await;
|
||||||
|
// Eine parallel gestartete Pruefung (Klick waehrend der 4-h-Schleife)
|
||||||
|
// kann inzwischen eine Installation ausgeloest haben: dann weder
|
||||||
|
// Menuetext noch Ablage anfassen.
|
||||||
|
if app.state::<UpdateFlow>().is_installing() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match result {
|
||||||
Ok(Some(mut update)) => {
|
Ok(Some(mut update)) => {
|
||||||
let (menu_text, body) = Version::parse(&update.version)
|
let (menu_text, body) = Version::parse(&update.version)
|
||||||
.map(|release| release_labels(¤t, &release))
|
.map(|release| release_labels(¤t, &release))
|
||||||
@@ -489,6 +707,11 @@ fn spawn_version_check(app: AppHandle, server_url: String) {
|
|||||||
// das Plugin wendet `Update.timeout` als Gesamt-Timeout des
|
// das Plugin wendet `Update.timeout` als Gesamt-Timeout des
|
||||||
// Downloads an (updater.rs Z. 698-700).
|
// Downloads an (updater.rs Z. 698-700).
|
||||||
update.timeout = Some(Duration::from_secs(600));
|
update.timeout = Some(Duration::from_secs(600));
|
||||||
|
clear_check_notice(&app);
|
||||||
|
if install_after {
|
||||||
|
spawn_update_install(app.clone(), update, menu_text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let _ = app
|
let _ = app
|
||||||
.notification()
|
.notification()
|
||||||
.builder()
|
.builder()
|
||||||
@@ -500,11 +723,12 @@ fn spawn_version_check(app: AppHandle, server_url: String) {
|
|||||||
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
|
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
|
||||||
*pending = Some((update, menu_text));
|
*pending = Some((update, menu_text));
|
||||||
}
|
}
|
||||||
clear_check_notice(&app);
|
app.state::<UpdateFlow>().set_offered(true);
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
let _ = update_item.set_text(UPDATE_ITEM_NONE);
|
let _ = update_item.set_text(UPDATE_ITEM_NONE);
|
||||||
let _ = update_item.set_enabled(true);
|
let _ = update_item.set_enabled(true);
|
||||||
|
app.state::<UpdateFlow>().set_offered(false);
|
||||||
clear_check_notice(&app);
|
clear_check_notice(&app);
|
||||||
}
|
}
|
||||||
// Der Release-Bau verweigert http-Endpunkte (config.rs
|
// Der Release-Bau verweigert http-Endpunkte (config.rs
|
||||||
@@ -553,7 +777,13 @@ fn open_download_page(app: &AppHandle) {
|
|||||||
/// gesperrten Eintrags. Fehler: Menuetext und Stand zurueck, Eintrag wieder
|
/// gesperrten Eintrags. Fehler: Menuetext und Stand zurueck, Eintrag wieder
|
||||||
/// aktiv, Benachrichtigung mit Grund, Einstellungsseite im Browser als
|
/// aktiv, Benachrichtigung mit Grund, Einstellungsseite im Browser als
|
||||||
/// Rueckfall.
|
/// Rueckfall.
|
||||||
|
///
|
||||||
|
/// Beansprucht `UpdateFlow::installing`; laeuft bereits eine Installation,
|
||||||
|
/// passiert nichts. Freigegeben wird der Merker nur im Fehlerfall.
|
||||||
fn spawn_update_install(app: AppHandle, update: Update, menu_text: String) {
|
fn spawn_update_install(app: AppHandle, update: Update, menu_text: String) {
|
||||||
|
if !app.state::<UpdateFlow>().try_begin_install() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let item = app.state::<TrayItems>().update.clone();
|
let item = app.state::<TrayItems>().update.clone();
|
||||||
let _ = item.set_enabled(false);
|
let _ = item.set_enabled(false);
|
||||||
let _ = item.set_text("Update wird heruntergeladen…");
|
let _ = item.set_text("Update wird heruntergeladen…");
|
||||||
@@ -599,6 +829,7 @@ fn spawn_update_install(app: AppHandle, update: Update, menu_text: String) {
|
|||||||
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
|
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
|
||||||
*pending = Some((update, menu_text.clone()));
|
*pending = Some((update, menu_text.clone()));
|
||||||
}
|
}
|
||||||
|
app.state::<UpdateFlow>().install_failed();
|
||||||
let _ = item.set_enabled(true);
|
let _ = item.set_enabled(true);
|
||||||
let _ = app
|
let _ = app
|
||||||
.notification()
|
.notification()
|
||||||
@@ -654,8 +885,12 @@ fn save_server_url(app: AppHandle, url: String) -> Result<(), String> {
|
|||||||
store.set("server_url", serde_json::json!(normalized));
|
store.set("server_url", serde_json::json!(normalized));
|
||||||
store.save().map_err(|e| e.to_string())?;
|
store.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Vor dem Navigieren: die Seite darf danach sofort Desktop-Meldungen ausloesen.
|
||||||
|
grant_server_notifications(&app, &normalized);
|
||||||
apply_server(&app, Some(&normalized));
|
apply_server(&app, Some(&normalized));
|
||||||
spawn_version_check(app.clone(), normalized.clone());
|
// Ein Angebot des alten Servers gilt nicht fuer den neuen.
|
||||||
|
app.state::<UpdateFlow>().set_offered(false);
|
||||||
|
spawn_version_check(app.clone(), normalized.clone(), false);
|
||||||
|
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
let _ = window.navigate(with_desktop_marker(&parsed));
|
let _ = window.navigate(with_desktop_marker(&parsed));
|
||||||
@@ -694,8 +929,34 @@ fn get_client_info() -> String {
|
|||||||
client_info_label(env!("CARGO_PKG_VERSION"), env!("APP_COMMIT"))
|
client_info_label(env!("CARGO_PKG_VERSION"), env!("APP_COMMIT"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ziel fuer den System-Browser, wenn die Seite ein neues Fenster verlangt
|
||||||
|
/// (`window.open`, Links mit `target="_blank"`). Nur http/https -- andere
|
||||||
|
/// Schemata (file:, javascript:, eigene Protokolle) werden verworfen.
|
||||||
|
fn external_target(url: &tauri::Url) -> Option<String> {
|
||||||
|
match url.scheme() {
|
||||||
|
"http" | "https" => Some(url.to_string()),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hauptfenster nach vorne holen. Ein minimiertes Fenster (z. B. per Win+D)
|
||||||
|
/// wird zuerst wiederhergestellt.
|
||||||
|
fn show_main_window(app: &AppHandle) {
|
||||||
|
if let Some(w) = app.get_webview_window("main") {
|
||||||
|
let _ = w.unminimize();
|
||||||
|
let _ = w.show();
|
||||||
|
let _ = w.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
let app = tauri::Builder::default()
|
let app = tauri::Builder::default()
|
||||||
|
// Muss als erstes Plugin stehen. Verhindert den Doppelstart (z. B. beim
|
||||||
|
// Windows-Login) mit zwei Tray-Symbolen: Ein zweiter Start holt nur das
|
||||||
|
// Fenster der laufenden Instanz nach vorne und beendet sich sofort.
|
||||||
|
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
|
||||||
|
show_main_window(app);
|
||||||
|
}))
|
||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
.plugin(tauri_plugin_notification::init())
|
.plugin(tauri_plugin_notification::init())
|
||||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||||
@@ -734,6 +995,20 @@ pub fn run() {
|
|||||||
.center()
|
.center()
|
||||||
.decorations(true)
|
.decorations(true)
|
||||||
.resizable(true)
|
.resizable(true)
|
||||||
|
// Neue Fenster (Suche-Widget per `window.open`, „In neuem Tab
|
||||||
|
// öffnen“ bei XFrame/eigenen Modulen/Favoriten) verwarf die
|
||||||
|
// Webansicht still -- Nutzer, 29.09.2026: Suche im Client ohne
|
||||||
|
// Funktion. Jetzt gehen sie wie im Browser-Tab nach aussen, in den
|
||||||
|
// System-Browser; die App selbst oeffnet kein zweites Fenster.
|
||||||
|
.on_new_window({
|
||||||
|
let handle = app.handle().clone();
|
||||||
|
move |url, _features| {
|
||||||
|
if let Some(target) = external_target(&url) {
|
||||||
|
let _ = handle.opener().open_url(target, None::<&str>);
|
||||||
|
}
|
||||||
|
tauri::webview::NewWindowResponse::Deny
|
||||||
|
}
|
||||||
|
})
|
||||||
.on_download(|webview, event| {
|
.on_download(|webview, event| {
|
||||||
if let tauri::webview::DownloadEvent::Requested { url, .. } = event {
|
if let tauri::webview::DownloadEvent::Requested { url, .. } = event {
|
||||||
let _ = webview
|
let _ = webview
|
||||||
@@ -749,6 +1024,9 @@ pub fn run() {
|
|||||||
let server_url = stored_server_url(app.handle());
|
let server_url = stored_server_url(app.handle());
|
||||||
|
|
||||||
if let Some(url) = &server_url {
|
if let Some(url) = &server_url {
|
||||||
|
// Vor dem ersten `navigate`: Benachrichtigungsrecht fuer die
|
||||||
|
// gespeicherte Adresse (quick-260929-if2, E-01).
|
||||||
|
grant_server_notifications(app.handle(), url);
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
if let Ok(parsed) = parse_server_url(url) {
|
if let Ok(parsed) = parse_server_url(url) {
|
||||||
let _ = window.navigate(with_desktop_marker(&parsed));
|
let _ = window.navigate(with_desktop_marker(&parsed));
|
||||||
@@ -809,6 +1087,7 @@ pub fn run() {
|
|||||||
update: update.clone(),
|
update: update.clone(),
|
||||||
});
|
});
|
||||||
app.manage(PendingUpdate(Mutex::new(None)));
|
app.manage(PendingUpdate(Mutex::new(None)));
|
||||||
|
app.manage(UpdateFlow::default());
|
||||||
app.manage(LastCheckNotice(Mutex::new(String::new())));
|
app.manage(LastCheckNotice(Mutex::new(String::new())));
|
||||||
|
|
||||||
let autostart_for_menu = autostart.clone();
|
let autostart_for_menu = autostart.clone();
|
||||||
@@ -819,45 +1098,42 @@ pub fn run() {
|
|||||||
.tooltip(tray_labels(server_url.as_deref()).0)
|
.tooltip(tray_labels(server_url.as_deref()).0)
|
||||||
.show_menu_on_left_click(false)
|
.show_menu_on_left_click(false)
|
||||||
.on_menu_event(move |app, event| match event.id().as_ref() {
|
.on_menu_event(move |app, event| match event.id().as_ref() {
|
||||||
"open" => {
|
"open" => show_main_window(app),
|
||||||
if let Some(w) = app.get_webview_window("main") {
|
|
||||||
// Minimiertes Fenster (z. B. per Win+D) zuerst wiederherstellen
|
|
||||||
let _ = w.unminimize();
|
|
||||||
let _ = w.show();
|
|
||||||
let _ = w.set_focus();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"change_server" => {
|
"change_server" => {
|
||||||
if let Some(w) = app.get_webview_window("main") {
|
if let Some(w) = app.get_webview_window("main") {
|
||||||
let _ = w.navigate(setup_page_url(cfg!(windows)));
|
let _ = w.navigate(setup_page_url(cfg!(windows)));
|
||||||
let _ = w.unminimize();
|
|
||||||
let _ = w.show();
|
|
||||||
let _ = w.set_focus();
|
|
||||||
}
|
}
|
||||||
|
show_main_window(app);
|
||||||
}
|
}
|
||||||
"update" => {
|
"update" => {
|
||||||
// `take()` verhindert Doppelklick-Downloads, der
|
// `take()` verhindert Doppelklick-Downloads, der
|
||||||
// gesperrte Eintrag ebenso. Ohne abgelegten Stand
|
// gesperrte Eintrag ebenso. Liegt ein Update vor,
|
||||||
// ("Kein Update verfügbar" / "Update-Prüfung
|
// wird es NICHT aus dem Speicher installiert, sondern
|
||||||
// fehlgeschlagen") prueft der Klick erneut -- der
|
// frisch geprueft und das Ergebnis installiert
|
||||||
// Browser-Weg (`open_download_page`) ist nur noch
|
// (`install_after`, siehe spawn_version_check). Ohne
|
||||||
// Rueckfall einer fehlgeschlagenen Installation.
|
// abgelegten Stand ("Kein Update verfügbar" /
|
||||||
|
// "Update-Prüfung fehlgeschlagen") prueft der Klick
|
||||||
|
// nur erneut. Der Browser-Weg (`open_download_page`)
|
||||||
|
// bleibt Rueckfall einer fehlgeschlagenen Installation.
|
||||||
// Ohne gespeicherte Adresse gibt es nichts zu pruefen.
|
// Ohne gespeicherte Adresse gibt es nichts zu pruefen.
|
||||||
let pending = app
|
// Waehrend einer Installation tut der Klick nichts
|
||||||
|
// (`UpdateFlow`). War vorher schon ein Update
|
||||||
|
// angeboten (Stand nach fehlgeschlagener Pruefung
|
||||||
|
// weg), installiert der Klick trotzdem direkt.
|
||||||
|
let flow = app.state::<UpdateFlow>();
|
||||||
|
if flow.is_installing() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let had_pending = app
|
||||||
.state::<PendingUpdate>()
|
.state::<PendingUpdate>()
|
||||||
.0
|
.0
|
||||||
.lock()
|
.lock()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|mut guard| guard.take());
|
.and_then(|mut guard| guard.take())
|
||||||
match pending {
|
.is_some();
|
||||||
Some((update, menu_text)) => {
|
if let Some(url) = stored_server_url(app) {
|
||||||
spawn_update_install(app.clone(), update, menu_text)
|
let install_after = flow.install_on_click(had_pending);
|
||||||
}
|
spawn_version_check(app.clone(), url, install_after);
|
||||||
None => {
|
|
||||||
if let Some(url) = stored_server_url(app) {
|
|
||||||
spawn_version_check(app.clone(), url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"autostart" => {
|
"autostart" => {
|
||||||
@@ -889,11 +1165,7 @@ pub fn run() {
|
|||||||
..
|
..
|
||||||
} = event
|
} = event
|
||||||
{
|
{
|
||||||
if let Some(w) = tray.app_handle().get_webview_window("main") {
|
show_main_window(tray.app_handle());
|
||||||
let _ = w.unminimize();
|
|
||||||
let _ = w.show();
|
|
||||||
let _ = w.set_focus();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.build(app)?;
|
.build(app)?;
|
||||||
@@ -901,7 +1173,7 @@ pub fn run() {
|
|||||||
apply_server(app.handle(), server_url.as_deref());
|
apply_server(app.handle(), server_url.as_deref());
|
||||||
|
|
||||||
if let Some(url) = server_url {
|
if let Some(url) = server_url {
|
||||||
spawn_version_check(app.handle().clone(), url);
|
spawn_version_check(app.handle().clone(), url, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wiederholte Pruefung alle UPDATE_CHECK_INTERVAL. Bewusst ein
|
// Wiederholte Pruefung alle UPDATE_CHECK_INTERVAL. Bewusst ein
|
||||||
@@ -913,11 +1185,16 @@ pub fn run() {
|
|||||||
// ein Update, wird der Durchlauf uebersprungen: die Pruefung
|
// ein Update, wird der Durchlauf uebersprungen: die Pruefung
|
||||||
// wuerde den Stand leeren und "Neuer Beta-Stand" erneut melden.
|
// wuerde den Stand leeren und "Neuer Beta-Stand" erneut melden.
|
||||||
// Waehrend einer Installation ist der Stand per `take()` leer;
|
// Waehrend einer Installation ist der Stand per `take()` leer;
|
||||||
// faellt die 4-h-Marke genau in den Download, wird nur der
|
// darum prueft die Schleife zusaetzlich `UpdateFlow::installing`
|
||||||
// Fortschrittstext ueberschrieben (T-FRG-04, akzeptiert).
|
// und ueberspringt den Durchlauf (vorher: Pruefung mitten im
|
||||||
|
// Download legte ein neues Update ab und schaltete den Eintrag
|
||||||
|
// wieder aktiv -- zweiter paralleler Download moeglich).
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
std::thread::spawn(move || loop {
|
std::thread::spawn(move || loop {
|
||||||
std::thread::sleep(UPDATE_CHECK_INTERVAL);
|
std::thread::sleep(UPDATE_CHECK_INTERVAL);
|
||||||
|
if handle.state::<UpdateFlow>().is_installing() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let pending = handle
|
let pending = handle
|
||||||
.state::<PendingUpdate>()
|
.state::<PendingUpdate>()
|
||||||
.0
|
.0
|
||||||
@@ -928,7 +1205,7 @@ pub fn run() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(url) = stored_server_url(&handle) {
|
if let Some(url) = stored_server_url(&handle) {
|
||||||
spawn_version_check(handle.clone(), url);
|
spawn_version_check(handle.clone(), url, false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1223,6 +1500,28 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_target_nur_http_und_https() {
|
||||||
|
let https = tauri::Url::parse("https://www.google.com/search?q=tessera").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
external_target(&https).as_deref(),
|
||||||
|
Some("https://www.google.com/search?q=tessera")
|
||||||
|
);
|
||||||
|
let http = tauri::Url::parse("http://intranet.local/").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
external_target(&http).as_deref(),
|
||||||
|
Some("http://intranet.local/")
|
||||||
|
);
|
||||||
|
for bad in [
|
||||||
|
"file:///C:/Windows/system32/calc.exe",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"ms-settings:privacy",
|
||||||
|
] {
|
||||||
|
let url = tauri::Url::parse(bad).unwrap();
|
||||||
|
assert_eq!(external_target(&url), None, "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_server_url_gueltige_adresse() {
|
fn parse_server_url_gueltige_adresse() {
|
||||||
let parsed = parse_server_url("https://tessera.ctl.de").unwrap();
|
let parsed = parse_server_url("https://tessera.ctl.de").unwrap();
|
||||||
@@ -1303,4 +1602,190 @@ mod tests {
|
|||||||
assert_eq!(UPDATE_ITEM_INSECURE, "Update nur über https möglich");
|
assert_eq!(UPDATE_ITEM_INSECURE, "Update nur über https möglich");
|
||||||
assert_eq!(UPDATE_CHECK_INTERVAL, Duration::from_secs(4 * 3600));
|
assert_eq!(UPDATE_CHECK_INTERVAL, Duration::from_secs(4 * 3600));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- UpdateFlow ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_flow_nur_eine_installation_gleichzeitig() {
|
||||||
|
let flow = UpdateFlow::default();
|
||||||
|
assert!(!flow.is_installing());
|
||||||
|
assert!(flow.try_begin_install());
|
||||||
|
assert!(flow.is_installing());
|
||||||
|
assert!(
|
||||||
|
!flow.try_begin_install(),
|
||||||
|
"zweite Installation muss abgewiesen werden"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_flow_fehler_gibt_installation_frei() {
|
||||||
|
let flow = UpdateFlow::default();
|
||||||
|
assert!(flow.try_begin_install());
|
||||||
|
flow.install_failed();
|
||||||
|
assert!(!flow.is_installing());
|
||||||
|
assert!(flow.try_begin_install());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_flow_klick_installiert_bei_abgelegtem_stand() {
|
||||||
|
let flow = UpdateFlow::default();
|
||||||
|
assert!(flow.install_on_click(true));
|
||||||
|
assert!(!flow.install_on_click(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_flow_klick_nach_angebot_und_fehlgeschlagener_pruefung_installiert() {
|
||||||
|
let flow = UpdateFlow::default();
|
||||||
|
flow.set_offered(true);
|
||||||
|
// Stand wurde vom ersten Klick entnommen, frische Pruefung schlug fehl.
|
||||||
|
assert!(flow.install_on_click(false));
|
||||||
|
// "Kein Update" oder Serverwechsel leert das Angebot.
|
||||||
|
flow.set_offered(false);
|
||||||
|
assert!(!flow.install_on_click(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- grant_origin_once ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grant_origin_once_vermerkt_erst_nach_erfolg() {
|
||||||
|
let granted = Mutex::new(Vec::new());
|
||||||
|
let r: Result<(), &str> = grant_origin_once(&granted, "https://a", |_| Err("kaputt"));
|
||||||
|
assert_eq!(r, Err("kaputt"));
|
||||||
|
assert!(granted.lock().unwrap().is_empty());
|
||||||
|
|
||||||
|
let mut calls = 0;
|
||||||
|
let r: Result<(), &str> = grant_origin_once(&granted, "https://a", |p| {
|
||||||
|
calls += 1;
|
||||||
|
assert_eq!(p, "https://a");
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
assert_eq!(r, Ok(()));
|
||||||
|
assert_eq!(calls, 1, "nach einem Fehler muss ein neuer Versuch laufen");
|
||||||
|
assert_eq!(*granted.lock().unwrap(), vec!["https://a".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn grant_origin_once_ruft_bei_vermerktem_ursprung_nicht_erneut() {
|
||||||
|
let granted = Mutex::new(vec!["https://a".to_string()]);
|
||||||
|
let r: Result<(), &str> =
|
||||||
|
grant_origin_once(&granted, "https://a", |_| panic!("darf nicht laufen"));
|
||||||
|
assert_eq!(r, Ok(()));
|
||||||
|
let r: Result<(), &str> = grant_origin_once(&granted, "https://b", |_| Ok(()));
|
||||||
|
assert_eq!(r, Ok(()));
|
||||||
|
assert_eq!(granted.lock().unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
// --- server_origin_* (quick-260929-if2, E-01) ---
|
||||||
|
|
||||||
|
fn muster(p: &str) -> tauri::utils::acl::RemoteUrlPattern {
|
||||||
|
p.parse().expect("Muster muss zerlegbar sein")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn adresse(s: &str) -> tauri::Url {
|
||||||
|
tauri::Url::parse(s).expect("Testadresse muss gueltig sein")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_origin_entfernt_pfad_query_und_fragment() {
|
||||||
|
assert_eq!(
|
||||||
|
server_origin_pattern("https://alpha.tessera.ctl.de/dashboard?x=1#h").as_deref(),
|
||||||
|
Some("https://alpha.tessera.ctl.de")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_origin_behaelt_nicht_standard_port() {
|
||||||
|
assert_eq!(
|
||||||
|
server_origin_pattern("http://192.168.13.12:8080/").as_deref(),
|
||||||
|
Some("http://192.168.13.12:8080")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_origin_laesst_standardport_weg() {
|
||||||
|
assert_eq!(
|
||||||
|
server_origin_pattern("https://alpha.tessera.ctl.de:443/").as_deref(),
|
||||||
|
Some("https://alpha.tessera.ctl.de")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_origin_mit_und_ohne_schlussstrich_gleich() {
|
||||||
|
assert_eq!(
|
||||||
|
server_origin_pattern("https://tessera.example.de/"),
|
||||||
|
server_origin_pattern("https://tessera.example.de")
|
||||||
|
);
|
||||||
|
assert!(server_origin_pattern("https://tessera.example.de").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_origin_none_bei_fremdem_schema_und_unparsbarer_eingabe() {
|
||||||
|
assert_eq!(server_origin_pattern("ftp://tessera.example.de/"), None);
|
||||||
|
assert_eq!(server_origin_pattern("das ist keine adresse"), None);
|
||||||
|
assert_eq!(server_origin_pattern(""), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_origin_ipv6_wird_maskiert() {
|
||||||
|
assert_eq!(
|
||||||
|
server_origin_pattern("http://[::1]:8080/").as_deref(),
|
||||||
|
Some(r"http://\[\:\:1\]:8080")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_origin_platzhalter_host_wird_maskiert() {
|
||||||
|
assert_eq!(
|
||||||
|
server_origin_pattern("http://*.example.com/").as_deref(),
|
||||||
|
Some(r"http://\*.example.com")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_origin_trifft_eigenen_ursprung_mit_anderem_pfad_und_query() {
|
||||||
|
let p = muster(&server_origin_pattern("https://alpha.tessera.ctl.de/x").unwrap());
|
||||||
|
assert!(p.test(&adresse("https://alpha.tessera.ctl.de/")));
|
||||||
|
assert!(p.test(&adresse("https://alpha.tessera.ctl.de/dashboard?a=b#c")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_origin_trifft_kein_anderes_schema_port_subdomain_oder_host() {
|
||||||
|
let p = muster(&server_origin_pattern("https://alpha.tessera.ctl.de/").unwrap());
|
||||||
|
assert!(!p.test(&adresse("http://alpha.tessera.ctl.de/")));
|
||||||
|
assert!(!p.test(&adresse("https://alpha.tessera.ctl.de:8443/")));
|
||||||
|
assert!(!p.test(&adresse("https://x.alpha.tessera.ctl.de/")));
|
||||||
|
assert!(!p.test(&adresse("https://beta.tessera.ctl.de/")));
|
||||||
|
|
||||||
|
let mit_port = muster(&server_origin_pattern("http://192.168.13.12:8080/").unwrap());
|
||||||
|
assert!(mit_port.test(&adresse("http://192.168.13.12:8080/a")));
|
||||||
|
assert!(!mit_port.test(&adresse("http://192.168.13.12:8081/")));
|
||||||
|
assert!(!mit_port.test(&adresse("http://192.168.13.12/")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_origin_platzhalter_host_trifft_keine_subdomain() {
|
||||||
|
let p = muster(&server_origin_pattern("http://*.example.com/").unwrap());
|
||||||
|
assert!(!p.test(&adresse("http://a.example.com/")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_origin_ipv6_trifft_nur_die_eigene_adresse() {
|
||||||
|
let p = muster(&server_origin_pattern("http://[::1]:8080/").unwrap());
|
||||||
|
assert!(p.test(&adresse("http://[::1]:8080/dashboard")));
|
||||||
|
assert!(!p.test(&adresse("http://[::2]:8080/")));
|
||||||
|
assert!(!p.test(&adresse("http://[::1]/")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn server_origin_berechtigungen_sind_genau_diese_drei() {
|
||||||
|
// Pinnt die Menge (T-IF2-03): ein zusaetzliches `notification:default`
|
||||||
|
// oder eine Platzhalter-Berechtigung laesst den Test rot werden.
|
||||||
|
assert_eq!(
|
||||||
|
SERVER_NOTIFICATION_PERMISSIONS,
|
||||||
|
[
|
||||||
|
"notification:allow-notify",
|
||||||
|
"notification:allow-is-permission-granted",
|
||||||
|
"notification:allow-request-permission",
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import createNextIntlPlugin from 'next-intl/plugin';
|
|||||||
// dem Wurzelverzeichnis wird hier gelesen und als `env.TESSERA_CHANGELOG_MD`
|
// dem Wurzelverzeichnis wird hier gelesen und als `env.TESSERA_CHANGELOG_MD`
|
||||||
// abgelegt. Next.js ersetzt `process.env.TESSERA_CHANGELOG_MD` fuer webpack UND
|
// abgelegt. Next.js ersetzt `process.env.TESSERA_CHANGELOG_MD` fuer webpack UND
|
||||||
// Turbopack ueber denselben Define-Mechanismus. Der Text bleibt nur im
|
// Turbopack ueber denselben Define-Mechanismus. Der Text bleibt nur im
|
||||||
// Server-Bundle, weil ausschliesslich die Server-Seite (changelog/page.tsx)
|
// Server-Bundle, weil ausschliesslich Server-Code `@/lib/changelog` importiert:
|
||||||
// `@/lib/changelog` importiert — Importdisziplin, kein Client-Chunk.
|
// die Server-Seite (changelog/page.tsx) und die 'use server'-Datei
|
||||||
|
// lib/release-notice-actions.ts (quick-260925-bow) — Importdisziplin, kein
|
||||||
|
// Client-Chunk.
|
||||||
|
|
||||||
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,13 @@ import { useTranslations } from 'next-intl';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { login } from '@/lib/auth-actions';
|
import { login } from '@/lib/auth-actions';
|
||||||
import { sanitizeNextPath } from '@/lib/safe-next';
|
import { sanitizeNextPath } from '@/lib/safe-next';
|
||||||
import { BRAND_YELLOW } from '@/components/brand/brand';
|
|
||||||
import { TesseraLogo } from '@/components/brand/tessera-logo';
|
import { TesseraLogo } from '@/components/brand/tessera-logo';
|
||||||
import { DesktopDownloadLinks } from '@/components/desktop/desktop-download-links';
|
import { DesktopDownloadLinks } from '@/components/desktop/desktop-download-links';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Split-screen login page (D-01).
|
* Split-screen login page (D-01), Design „Mosaik“.
|
||||||
* Left: Tessera branding on the brand yellow background (BRAND_YELLOW).
|
* Left (ab lg): dunkles Markenpanel mit Bildmarke, Claim und Farbmosaik.
|
||||||
* Right: Login form on neutral background.
|
* Right: Anmeldekarte auf der Arbeitsflaeche; unter lg nur die Karte.
|
||||||
* No sidebar or header (D-04, handled by (auth) layout).
|
* No sidebar or header (D-04, handled by (auth) layout).
|
||||||
*/
|
*/
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
@@ -42,41 +41,52 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen bg-background">
|
||||||
{/* Left: Branding panel (hidden on mobile, visible on md+) */}
|
{/* Links (ab lg): dunkles Markenpanel im Ton der App-Leiste mit
|
||||||
<div
|
grosser Bildmarke, Produktname, einem ruhigen Satz und einem
|
||||||
className="hidden md:flex md:flex-1 items-center justify-center"
|
stillen Mosaik in Grau und Gelb — kein Verlauf, keine Animation. */}
|
||||||
style={{ backgroundColor: BRAND_YELLOW }}
|
<div className="relative hidden w-[45%] shrink-0 flex-col overflow-hidden bg-appbar px-14 py-12 text-appbar-foreground lg:flex">
|
||||||
>
|
<TesseraLogo
|
||||||
<div className="px-8 text-gray-900">
|
variant="horizontal"
|
||||||
<TesseraLogo
|
size={40}
|
||||||
variant="horizontal"
|
plateOutline="always"
|
||||||
size={104}
|
className="gap-3"
|
||||||
plateOutline={false}
|
wordmarkClassName="text-xl font-semibold tracking-normal text-appbar-foreground"
|
||||||
tagline={t('branding.tagline')}
|
/>
|
||||||
className="gap-6"
|
|
||||||
wordmarkClassName="text-5xl"
|
<div className="my-auto max-w-[30rem]">
|
||||||
taglineClassName="text-lg text-gray-800 max-w-xs"
|
<LoginMosaic />
|
||||||
/>
|
<p className="mt-12 text-[32px] font-semibold leading-10 tracking-tight">
|
||||||
|
{t('branding.claim')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-base leading-6 text-appbar-muted">
|
||||||
|
{t('branding.tagline')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Login form */}
|
{/* Rechts: Anmeldekarte zentriert auf der Arbeitsflaeche */}
|
||||||
<div className="flex flex-1 items-center justify-center bg-background px-4 py-8">
|
<div className="flex flex-1 items-center justify-center px-4 py-10">
|
||||||
<div className="w-full max-w-sm space-y-8">
|
<div className="w-full max-w-[400px]">
|
||||||
{/* Mobile-only branding */}
|
{/* Unter lg: Bildmarke ueber der Karte */}
|
||||||
<div className="text-center md:hidden">
|
<div className="mb-6 flex justify-center lg:hidden">
|
||||||
<TesseraLogo variant="horizontal" size={32} />
|
<TesseraLogo
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
variant="horizontal"
|
||||||
{t('branding.tagline')}
|
size={32}
|
||||||
</p>
|
className="gap-2.5"
|
||||||
|
wordmarkClassName="text-lg font-semibold tracking-normal"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="surface space-y-6 p-6 sm:p-8">
|
||||||
{/* Login heading */}
|
{/* Login heading */}
|
||||||
<div className="text-center">
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-foreground">
|
<h1 className="text-2xl font-semibold text-foreground">
|
||||||
{t('login')}
|
{t('login')}
|
||||||
</h2>
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
{t('loginSubtitle')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error message */}
|
{/* Error message */}
|
||||||
@@ -87,12 +97,12 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Login form */}
|
{/* Login form */}
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{/* Username */}
|
{/* Username */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label
|
<label
|
||||||
htmlFor="username"
|
htmlFor="username"
|
||||||
className="text-sm font-medium text-foreground"
|
className="block text-sm font-semibold text-foreground"
|
||||||
>
|
>
|
||||||
{t('username')}
|
{t('username')}
|
||||||
</label>
|
</label>
|
||||||
@@ -102,16 +112,15 @@ export default function LoginPage() {
|
|||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
className="flex h-8 w-full rounded-md border border-input bg-card px-2.5 text-sm placeholder:text-muted-foreground"
|
||||||
placeholder={t('username')}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Password */}
|
{/* Password */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5">
|
||||||
<label
|
<label
|
||||||
htmlFor="password"
|
htmlFor="password"
|
||||||
className="text-sm font-medium text-foreground"
|
className="block text-sm font-semibold text-foreground"
|
||||||
>
|
>
|
||||||
{t('password')}
|
{t('password')}
|
||||||
</label>
|
</label>
|
||||||
@@ -121,33 +130,30 @@ export default function LoginPage() {
|
|||||||
type="password"
|
type="password"
|
||||||
required
|
required
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
className="flex h-8 w-full rounded-md border border-input bg-card px-2.5 text-sm placeholder:text-muted-foreground"
|
||||||
placeholder={t('password')}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Remember me (D-02) */}
|
{/* Angemeldet bleiben (D-02) und Passwort vergessen (D-03) in einer Zeile */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
id="rememberMe"
|
id="rememberMe"
|
||||||
name="rememberMe"
|
name="rememberMe"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
defaultChecked
|
defaultChecked
|
||||||
className="h-4 w-4 rounded border-input text-primary focus:ring-ring"
|
className="h-4 w-4 rounded border-input"
|
||||||
/>
|
/>
|
||||||
<label
|
<label
|
||||||
htmlFor="rememberMe"
|
htmlFor="rememberMe"
|
||||||
className="text-sm text-muted-foreground"
|
className="whitespace-nowrap text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
{t('rememberMe')}
|
{t('rememberMe')}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Forgot password link (D-03) */}
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Link
|
<Link
|
||||||
href="/reset-password"
|
href="/reset-password"
|
||||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
className="whitespace-nowrap text-sm link"
|
||||||
>
|
>
|
||||||
{t('forgotPassword')}
|
{t('forgotPassword')}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -157,7 +163,7 @@ export default function LoginPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
className="btn btn-primary w-full"
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<svg
|
<svg
|
||||||
@@ -186,11 +192,49 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Desktop-App-Link (D-12), blendet sich aus ohne API-Antwort */}
|
{/* Desktop-App-Link (D-12), blendet sich aus ohne API-Antwort */}
|
||||||
<DesktopDownloadLinks />
|
<div className="mt-6">
|
||||||
|
<DesktopDownloadLinks />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stilles Mosaik (Design „Mosaik“): acht Kacheln in einem lockeren
|
||||||
|
* 4 x 3-Raster in Grautoenen, eine davon in der Akzentfarbe und — wie die
|
||||||
|
* Signalkachel der Bildmarke — leicht gedreht. Rein dekorativ.
|
||||||
|
*/
|
||||||
|
function LoginMosaic() {
|
||||||
|
// Runde 2: nur Gelb und Graphit-/Grautoene des dunklen Panels.
|
||||||
|
const tiles: { col: number; row: number; color: string; rotate?: boolean }[] = [
|
||||||
|
{ col: 1, row: 1, color: 'oklch(1 0 0 / 0.14)' },
|
||||||
|
{ col: 2, row: 1, color: 'oklch(1 0 0 / 0.08)' },
|
||||||
|
{ col: 4, row: 1, color: 'oklch(1 0 0 / 0.1)' },
|
||||||
|
{ col: 2, row: 2, color: 'oklch(1 0 0 / 0.18)' },
|
||||||
|
{ col: 3, row: 2, color: 'var(--primary)', rotate: true },
|
||||||
|
{ col: 4, row: 2, color: 'oklch(1 0 0 / 0.06)' },
|
||||||
|
{ col: 1, row: 3, color: 'oklch(1 0 0 / 0.1)' },
|
||||||
|
{ col: 3, row: 3, color: 'oklch(1 0 0 / 0.05)' },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div aria-hidden="true" className="grid w-max grid-cols-4 grid-rows-3 gap-3">
|
||||||
|
{tiles.map((tile) => (
|
||||||
|
<span
|
||||||
|
key={`${tile.col}-${tile.row}`}
|
||||||
|
className="h-14 w-14 rounded-xl"
|
||||||
|
style={{
|
||||||
|
gridColumn: tile.col,
|
||||||
|
gridRow: tile.row,
|
||||||
|
backgroundColor: tile.color,
|
||||||
|
transform: tile.rotate ? 'rotate(12deg)' : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export default function ResetPasswordTokenPage() {
|
|||||||
{/* Heading */}
|
{/* Heading */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<TesseraLogo variant="horizontal" size={32} />
|
<TesseraLogo variant="horizontal" size={32} />
|
||||||
<h2 className="mt-4 text-2xl font-bold text-foreground">
|
<h2 className="mt-4 text-2xl font-semibold text-foreground">
|
||||||
{t('resetPassword.newPasswordTitle')}
|
{t('resetPassword.newPasswordTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,7 +99,7 @@ export default function ResetPasswordTokenPage() {
|
|||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
|
className="btn btn-primary w-full"
|
||||||
>
|
>
|
||||||
{t('resetPassword.backToLogin')}
|
{t('resetPassword.backToLogin')}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -164,7 +164,7 @@ export default function ResetPasswordTokenPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
className="btn btn-primary w-full"
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default function ResetPasswordPage() {
|
|||||||
{/* Heading */}
|
{/* Heading */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<TesseraLogo variant="horizontal" size={32} />
|
<TesseraLogo variant="horizontal" size={32} />
|
||||||
<h2 className="mt-4 text-2xl font-bold text-foreground">
|
<h2 className="mt-4 text-2xl font-semibold text-foreground">
|
||||||
{t('resetPassword.title')}
|
{t('resetPassword.title')}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,7 +67,7 @@ export default function ResetPasswordPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
|
className="btn btn-primary w-full"
|
||||||
>
|
>
|
||||||
{t('resetPassword.backToLogin')}
|
{t('resetPassword.backToLogin')}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -104,7 +104,7 @@ export default function ResetPasswordPage() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="flex w-full items-center justify-center rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:opacity-50"
|
className="btn btn-primary w-full"
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<svg
|
<svg
|
||||||
|
|||||||
@@ -0,0 +1,430 @@
|
|||||||
|
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Namensraumfaehiger next-intl-Mock: Punktschluessel werden gegen den
|
||||||
|
// Namensraum aufgeloest, {param} wird eingesetzt (Muster groups-page.test.tsx).
|
||||||
|
const messages: Record<string, Record<string, unknown>> = {
|
||||||
|
'admin.customModules': {
|
||||||
|
title: 'Eigene Module',
|
||||||
|
description: 'Erklärung gemeinsam',
|
||||||
|
create: 'Gemeinsamen Eintrag anlegen',
|
||||||
|
noModules: 'Noch keine gemeinsamen Einträge',
|
||||||
|
noModulesBody: 'Legen Sie Ihren ersten gemeinsamen Eintrag an.',
|
||||||
|
},
|
||||||
|
'customModules.manage': {
|
||||||
|
actions: 'Aktionen',
|
||||||
|
loadError: 'Laden fehlgeschlagen.',
|
||||||
|
},
|
||||||
|
'customModules.form': {
|
||||||
|
create: 'Eigenes Modul anlegen',
|
||||||
|
edit: 'Eigenes Modul bearbeiten',
|
||||||
|
name: 'Name',
|
||||||
|
url: 'Adresse',
|
||||||
|
urlHint: 'Hinweis https',
|
||||||
|
category: 'Kategorie',
|
||||||
|
nameRequired: 'Bitte geben Sie einen Namen ein.',
|
||||||
|
urlNotHttps: 'Bitte geben Sie eine Adresse ein, die mit https:// beginnt.',
|
||||||
|
urlCredentials: 'Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.',
|
||||||
|
saveError: 'Das Modul konnte nicht gespeichert werden.',
|
||||||
|
deleteError: 'Das Modul konnte nicht gelöscht werden.',
|
||||||
|
forbidden: 'Sie haben keine Berechtigung für diese Aktion.',
|
||||||
|
invalidInput: 'Die Angaben wurden nicht angenommen. Bitte prüfen Sie Name, Adresse und Kategorie.',
|
||||||
|
deleteConfirm: {
|
||||||
|
title: 'Eigenes Modul löschen',
|
||||||
|
bodyShared: 'Möchten Sie „{name}“ wirklich löschen? Für alle Benutzer.',
|
||||||
|
bodyPersonal: 'Möchten Sie „{name}“ wirklich löschen? Nur bei Ihnen.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
loading: 'Laden...',
|
||||||
|
cancel: 'Abbrechen',
|
||||||
|
save: 'Speichern',
|
||||||
|
delete: 'Löschen',
|
||||||
|
edit: 'Bearbeiten',
|
||||||
|
accessDenied: 'Zugriff verweigert',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: (ns: string) => (key: string, params?: Record<string, unknown>) => {
|
||||||
|
let val: unknown = messages[ns];
|
||||||
|
for (const part of key.split('.')) {
|
||||||
|
val = (val as Record<string, unknown> | undefined)?.[part];
|
||||||
|
}
|
||||||
|
if (typeof val !== 'string') return key;
|
||||||
|
return params
|
||||||
|
? val.replace(/\{(\w+)\}/g, (_m, name: string) => String(params[name] ?? ''))
|
||||||
|
: val;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/link', () => ({
|
||||||
|
default: ({
|
||||||
|
children,
|
||||||
|
href,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
href: string;
|
||||||
|
className?: string;
|
||||||
|
}) => (
|
||||||
|
<a href={href} className={className}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/use-category-label', () => ({
|
||||||
|
useCategoryLabel: () => (slug: string) =>
|
||||||
|
({ infrastructure: 'Infrastruktur', 'security-tools': 'Sicherheit' })[slug] ?? slug,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockList, mockCreate, mockUpdate, mockDelete, mockBump } = vi.hoisted(() => ({
|
||||||
|
mockList: vi.fn(),
|
||||||
|
mockCreate: vi.fn(),
|
||||||
|
mockUpdate: vi.fn(),
|
||||||
|
mockDelete: vi.fn(),
|
||||||
|
mockBump: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/custom-modules-api', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/lib/custom-modules-api')>(
|
||||||
|
'@/lib/custom-modules-api',
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
listCustomModules: mockList,
|
||||||
|
createCustomModule: mockCreate,
|
||||||
|
updateCustomModule: mockUpdate,
|
||||||
|
deleteCustomModule: mockDelete,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/lib/stores/marketplace-store', () => ({
|
||||||
|
useMarketplaceStore: (selector: (s: { bumpSidebarRefresh: () => void }) => unknown) =>
|
||||||
|
selector({ bumpSidebarRefresh: mockBump }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mockRole = 'ADMIN';
|
||||||
|
vi.mock('@/lib/stores/auth-store', () => ({
|
||||||
|
useAuthStore: (selector: (s: { user: { role: string } }) => unknown) =>
|
||||||
|
selector({ user: { role: mockRole } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CustomModuleRequestError } from '@/lib/custom-modules-api';
|
||||||
|
import AdminCustomModulesPage from './page';
|
||||||
|
|
||||||
|
const wiki = {
|
||||||
|
id: 'cm-1',
|
||||||
|
name: 'Wiki',
|
||||||
|
url: 'https://wiki.example.com',
|
||||||
|
category: 'infrastructure',
|
||||||
|
personal: false,
|
||||||
|
createdAt: '2026-09-29T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-09-29T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRole = 'ADMIN';
|
||||||
|
mockList.mockReset().mockResolvedValue([]);
|
||||||
|
mockCreate.mockReset().mockResolvedValue(wiki);
|
||||||
|
mockUpdate.mockReset().mockResolvedValue(wiki);
|
||||||
|
mockDelete.mockReset().mockResolvedValue(undefined);
|
||||||
|
mockBump.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AdminCustomModulesPage', () => {
|
||||||
|
it('zeigt ohne Einträge den Leer-Zustand mit Anlegen-Knopf', async () => {
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
expect(await screen.findByText('Noch keine gemeinsamen Einträge')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Legen Sie Ihren ersten gemeinsamen Eintrag an.')).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' }).length,
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt mit Einträgen die Tabelle mit Link, Adresse und Kategorie-Anzeigename', async () => {
|
||||||
|
mockList.mockResolvedValue([wiki]);
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
|
||||||
|
const link = await screen.findByRole('link', { name: 'Wiki' });
|
||||||
|
expect(link).toHaveAttribute('href', '/modules/custom/cm-1');
|
||||||
|
expect(screen.getByText('https://wiki.example.com')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Infrastruktur')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Bearbeiten' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Löschen' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Anlegen: http-Adresse zeigt die Meldung und ruft createCustomModule nicht auf', async () => {
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
||||||
|
await userEvent.type(screen.getByLabelText('Adresse'), 'http://example.com');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText('Bitte geben Sie eine Adresse ein, die mit https:// beginnt.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(mockCreate).not.toHaveBeenCalled();
|
||||||
|
expect(mockBump).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Anlegen: Adresse mit Zugangsdaten zeigt die passende Meldung', async () => {
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
||||||
|
await userEvent.type(screen.getByLabelText('Adresse'), 'https://user:pw@example.com');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText('Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(mockCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Anlegen: leerer Name zeigt die Meldung', async () => {
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText('Name'), ' ');
|
||||||
|
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
|
||||||
|
expect(await screen.findByText('Bitte geben Sie einen Namen ein.')).toBeInTheDocument();
|
||||||
|
expect(mockCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Anlegen: gültige Eingabe ruft create mit getrimmtem Namen, lädt neu und aktualisiert die Seitenleiste genau einmal', async () => {
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText('Name'), ' Beispielseite ');
|
||||||
|
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||||
|
await userEvent.selectOptions(screen.getByLabelText('Kategorie'), 'security-tools');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith({
|
||||||
|
name: 'Beispielseite',
|
||||||
|
url: 'https://example.com',
|
||||||
|
category: 'security-tools',
|
||||||
|
shared: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(mockBump).toHaveBeenCalledTimes(1));
|
||||||
|
// Anfangsladen + Neuladen nach dem Speichern
|
||||||
|
expect(mockList).toHaveBeenCalledTimes(2);
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Anlegen: die Kategorie ist vorbelegt mit „Eigene Module“', async () => {
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText('Kategorie')).toHaveValue('custom-modules');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Anlegen ohne Kategoriewahl sendet die Kategorie „Eigene Module“', async () => {
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||||
|
);
|
||||||
|
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
||||||
|
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ category: 'custom-modules' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Speichern mit 403 zeigt „keine Berechtigung“, nicht den allgemeinen Fehler', async () => {
|
||||||
|
mockCreate.mockRejectedValue(new CustomModuleRequestError(403, 'Forbidden'));
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||||
|
);
|
||||||
|
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
||||||
|
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
'Sie haben keine Berechtigung für diese Aktion.',
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('Das Modul konnte nicht gespeichert werden.')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Speichern mit 400 zeigt die Meldung des Servers', async () => {
|
||||||
|
mockList.mockResolvedValue([wiki]);
|
||||||
|
mockUpdate.mockRejectedValue(
|
||||||
|
new CustomModuleRequestError(400, 'Nur https-Adressen ohne Zugangsdaten sind erlaubt.'),
|
||||||
|
);
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Bearbeiten' }));
|
||||||
|
const dialog = screen.getByRole('dialog');
|
||||||
|
await userEvent.click(within(dialog).getByRole('button', { name: 'Speichern' }));
|
||||||
|
|
||||||
|
expect(await within(dialog).findByRole('alert')).toHaveTextContent(
|
||||||
|
'Die Angaben wurden nicht angenommen. Bitte prüfen Sie Name, Adresse und Kategorie.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Bearbeiten: Formular ist vorbelegt, Speichern ruft update und aktualisiert die Seitenleiste', async () => {
|
||||||
|
mockList.mockResolvedValue([wiki]);
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Bearbeiten' }));
|
||||||
|
|
||||||
|
const dialog = screen.getByRole('dialog');
|
||||||
|
expect(within(dialog).getByLabelText('Name')).toHaveValue('Wiki');
|
||||||
|
expect(within(dialog).getByLabelText('Adresse')).toHaveValue('https://wiki.example.com');
|
||||||
|
expect(within(dialog).getByLabelText('Kategorie')).toHaveValue('infrastructure');
|
||||||
|
|
||||||
|
const nameInput = within(dialog).getByLabelText('Name');
|
||||||
|
await userEvent.clear(nameInput);
|
||||||
|
await userEvent.type(nameInput, 'Wiki neu');
|
||||||
|
await userEvent.click(within(dialog).getByRole('button', { name: 'Speichern' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith('cm-1', {
|
||||||
|
name: 'Wiki neu',
|
||||||
|
url: 'https://wiki.example.com',
|
||||||
|
category: 'infrastructure',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(mockCreate).not.toHaveBeenCalled();
|
||||||
|
await waitFor(() => expect(mockBump).toHaveBeenCalledTimes(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Serverfehler beim Speichern bleibt im Dialog sichtbar, der Dialog bleibt offen', async () => {
|
||||||
|
mockCreate.mockRejectedValue(new Error('500'));
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
await screen.findByText('Noch keine gemeinsamen Einträge');
|
||||||
|
await userEvent.click(
|
||||||
|
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.type(screen.getByLabelText('Name'), 'Beispielseite');
|
||||||
|
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText('Das Modul konnte nicht gespeichert werden.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
|
expect(mockBump).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Löschen: Rückfrage nennt den Namen, Bestätigen ruft delete, lädt neu und aktualisiert die Seitenleiste', async () => {
|
||||||
|
mockList.mockResolvedValue([wiki]);
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
|
||||||
|
|
||||||
|
const dialog = screen.getByRole('dialog');
|
||||||
|
expect(
|
||||||
|
within(dialog).getByText('Möchten Sie „Wiki“ wirklich löschen? Für alle Benutzer.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
await userEvent.click(within(dialog).getByRole('button', { name: 'Löschen' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('cm-1'));
|
||||||
|
await waitFor(() => expect(mockBump).toHaveBeenCalledTimes(1));
|
||||||
|
expect(mockList).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Löschen: Abbrechen ruft nichts auf', async () => {
|
||||||
|
mockList.mockResolvedValue([wiki]);
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
|
||||||
|
await userEvent.click(
|
||||||
|
within(screen.getByRole('dialog')).getByRole('button', { name: 'Abbrechen' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockDelete).not.toHaveBeenCalled();
|
||||||
|
expect(mockBump).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Löschfehler bleibt im Dialog sichtbar', async () => {
|
||||||
|
mockList.mockResolvedValue([wiki]);
|
||||||
|
mockDelete.mockRejectedValue(new Error('500'));
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
|
||||||
|
await userEvent.click(
|
||||||
|
within(screen.getByRole('dialog')).getByRole('button', { name: 'Löschen' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Das Modul konnte nicht gelöscht werden.')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
|
expect(mockBump).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Löschen mit 403 zeigt „keine Berechtigung“', async () => {
|
||||||
|
mockList.mockResolvedValue([wiki]);
|
||||||
|
mockDelete.mockRejectedValue(new CustomModuleRequestError(403, 'Forbidden'));
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
await userEvent.click(await screen.findByRole('button', { name: 'Löschen' }));
|
||||||
|
await userEvent.click(
|
||||||
|
within(screen.getByRole('dialog')).getByRole('button', { name: 'Löschen' }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
'Sie haben keine Berechtigung für diese Aktion.',
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('Das Modul konnte nicht gelöscht werden.')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ladefehler zeigt nur die Fehlermeldung, nicht zusätzlich den Leer-Zustand', async () => {
|
||||||
|
mockList.mockRejectedValue(new CustomModuleRequestError(500, 'Internal server error'));
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('Laden fehlgeschlagen.')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Noch keine gemeinsamen Einträge')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt nur gemeinsame Einträge, nie persönliche (Filter über personal)', async () => {
|
||||||
|
mockList.mockResolvedValue([
|
||||||
|
wiki,
|
||||||
|
{ ...wiki, id: 'cm-2', name: 'Mein Privates', personal: true },
|
||||||
|
]);
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
expect(await screen.findByRole('link', { name: 'Wiki' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Mein Privates')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nur persönliche Einträge vorhanden: Leer-Zustand der Verwaltung', async () => {
|
||||||
|
mockList.mockResolvedValue([{ ...wiki, personal: true }]);
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
expect(await screen.findByText('Noch keine gemeinsamen Einträge')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Benutzer mit Rolle USER sieht den Zugriff-verweigert-Text', async () => {
|
||||||
|
mockRole = 'USER';
|
||||||
|
render(<AdminCustomModulesPage />);
|
||||||
|
expect(await screen.findByText('Zugriff verweigert')).toBeInTheDocument();
|
||||||
|
expect(mockList).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user