Compare commits
18 Commits
v1.7.0
...
af87c2c112
| Author | SHA1 | Date | |
|---|---|---|---|
| af87c2c112 | |||
| bd73fa5e74 | |||
| 86ad95f74e | |||
| 12214a948e | |||
| 071082983b | |||
| c2e4467dd8 | |||
| be1e0035e0 | |||
| f7213f5e45 | |||
| e72814abd9 | |||
| 0e72ad45f8 | |||
| b15c74632b | |||
| 7188c5b958 | |||
| 8c644de5da | |||
| 6879c756f2 | |||
| 709b41a007 | |||
| 325c5ddbf2 | |||
| cd1f8f6cda | |||
| 41d00a3623 |
+5
-3
@@ -6,7 +6,7 @@ current_phase_name: desktop-client-fertigstellen
|
||||
status: verified
|
||||
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
|
||||
last_updated: "2026-09-23T15:30:00.000Z"
|
||||
last_activity: 2026-09-23
|
||||
last_activity: 2026-09-30
|
||||
last_activity_desc: Quick 260928-ujj — Design Mosaik uebernommen, Hintergrund pro Benutzer in der DB; Freigabe 1.5.0
|
||||
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
|
||||
progress:
|
||||
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
|
||||
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
|
||||
Plan: 6 of 6
|
||||
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
|
||||
Last activity: 2026-09-29 - Quicks 260929-9wc/d37/dmx/dzu + Fixes; Freigabe 1.6.0
|
||||
Last activity: 2026-09-30 - Windows-Test (Tray-Update + Erinnerungs-Toast) bestanden; Review aller Aenderungen seit 26.09. mit 4 Fix-Commits (be1e003, c2e4467, 0710829, 12214a9)
|
||||
|
||||
Progress: [██████████] 99%
|
||||
|
||||
@@ -481,6 +481,8 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
|
||||
| 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/) |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
@@ -526,4 +528,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.
|
||||
Stopped at: hk4 fertig und nachgewiesen. Dem Nutzer vorgelegt: erst das Aufraeumen (Modul bringt seine Kachel selbst mit), dann Proxmox-Modul + Kachel — Antwort steht aus.
|
||||
Resume file: None
|
||||
Last activity: 2026-09-29 - Quicks 260929-9wc/d37/dmx/dzu + Fixes; Freigabe 1.6.0
|
||||
Last activity: 2026-09-29 - Quick 260929-if2 Erinnerungen-Widget (lokal, nicht gepusht); v1.7.0 auf alpha+live
|
||||
|
||||
@@ -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.
|
||||
@@ -4,6 +4,31 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
||||
|
||||
## Unveröffentlicht
|
||||
|
||||
### 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
|
||||
|
||||
- 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. 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
|
||||
|
||||
@@ -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());
|
||||
@@ -54,6 +54,7 @@ model User {
|
||||
groupMemberships GroupMembership[]
|
||||
moduleGrants ModuleGrant[]
|
||||
customModules CustomModule[]
|
||||
reminders Reminder[]
|
||||
|
||||
@@index([tenantId])
|
||||
@@index([username])
|
||||
@@ -739,3 +740,27 @@ model CustomModule {
|
||||
@@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])
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { TendersModule } from './tenders/tenders.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
import { ProxmoxModule } from './proxmox/proxmox.module';
|
||||
import { CustomModulesModule } from './custom-modules/custom-modules.module';
|
||||
import { RemindersModule } from './reminders/reminders.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -55,6 +56,7 @@ import { CustomModulesModule } from './custom-modules/custom-modules.module';
|
||||
BugReportsModule,
|
||||
ProxmoxModule,
|
||||
CustomModulesModule,
|
||||
RemindersModule,
|
||||
],
|
||||
providers: [
|
||||
// Global JWT guard: all routes require auth unless @Public()
|
||||
|
||||
@@ -64,4 +64,12 @@ describe('UpdateCustomModuleDto', () => {
|
||||
expect(await errorsFor(UpdateCustomModuleDto, { category: 'other' })).toContain('category');
|
||||
expect(await errorsFor(UpdateCustomModuleDto, { name: ' ' })).toContain('name');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'name',
|
||||
'url',
|
||||
'category',
|
||||
])('lehnt %s: null ab statt es durchzulassen', async (field) => {
|
||||
expect(await errorsFor(UpdateCustomModuleDto, { [field]: null })).toContain(field);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,7 +78,11 @@ export class CreateCustomModuleDto {
|
||||
* `shared` ist ausgenommen — ob ein Eintrag gemeinsam oder persoenlich ist,
|
||||
* aendert sich nach dem Anlegen nicht (die globale Pipe verwirft das Feld
|
||||
* dank `whitelist: true`).
|
||||
* `skipNullProperties: false`: fehlende Felder bleiben unveraendert, ein
|
||||
* ausdrueckliches `null` wird aber geprueft und damit abgelehnt (400) — sonst
|
||||
* liefe `{"name": null}` bis in die Datenbank und endete als 500.
|
||||
*/
|
||||
export class UpdateCustomModuleDto extends PartialType(
|
||||
OmitType(CreateCustomModuleDto, ['shared'] as const),
|
||||
{ skipNullProperties: false },
|
||||
) {}
|
||||
|
||||
@@ -21,7 +21,11 @@ vi.mock('../prisma/prisma-tenant.extension', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { forSystem, forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { DashboardImagesService } from './dashboard-images.service';
|
||||
@@ -73,11 +77,21 @@ interface BoundCall {
|
||||
|
||||
type ModelMethods = Record<string, (...args: unknown[]) => Promise<unknown>>;
|
||||
|
||||
interface UserRow {
|
||||
id: string;
|
||||
dashboardBackground: unknown;
|
||||
}
|
||||
|
||||
interface FakePrisma {
|
||||
dashboardImage: ModelMethods;
|
||||
user: ModelMethods;
|
||||
__rows: ImageRow[];
|
||||
__users: UserRow[];
|
||||
__boundCallLog: BoundCall[];
|
||||
__makeBoundClient(tenantId: string, userId?: string): { dashboardImage: ModelMethods };
|
||||
__makeBoundClient(
|
||||
tenantId: string,
|
||||
userId?: string,
|
||||
): { dashboardImage: ModelMethods; user: ModelMethods };
|
||||
}
|
||||
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
||||
@@ -147,8 +161,28 @@ function pick(row: ImageRow, select: Record<string, boolean> | undefined) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
function makeFakePrisma(rows: ImageRow[] = [], users: UserRow[] = []): FakePrisma {
|
||||
const boundCallLog: BoundCall[] = [];
|
||||
// quick-260930: Hintergrund-Wahl (`User.dashboardBackground`) — bedingtes
|
||||
// updateMany ueber den JSON-Pfad `imageId`, wie Prisma es auf PostgreSQL filtert.
|
||||
const user: ModelMethods = {
|
||||
updateMany: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as {
|
||||
where: { id: string; dashboardBackground: { path: string[]; equals: unknown } };
|
||||
data: { dashboardBackground: unknown };
|
||||
};
|
||||
const [key] = args.where.dashboardBackground.path;
|
||||
let count = 0;
|
||||
for (const u of users) {
|
||||
const bg = u.dashboardBackground as Record<string, unknown> | null;
|
||||
if (u.id !== args.where.id || !bg || bg[key] !== args.where.dashboardBackground.equals)
|
||||
continue;
|
||||
u.dashboardBackground = args.data.dashboardBackground;
|
||||
count++;
|
||||
}
|
||||
return { count };
|
||||
}),
|
||||
};
|
||||
const dashboardImage: ModelMethods = {
|
||||
findMany: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as {
|
||||
@@ -168,11 +202,17 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
}),
|
||||
count: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { where: { tenantId: string; userId: string } };
|
||||
return rows.filter((r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId).length;
|
||||
return rows.filter(
|
||||
(r) => r.tenantId === args.where.tenantId && r.userId === args.where.userId,
|
||||
).length;
|
||||
}),
|
||||
create: vi.fn(async (raw: unknown) => {
|
||||
const args = raw as { data: Partial<ImageRow>; select?: Record<string, boolean> };
|
||||
const created = makeRow({ id: `new-${rows.length + 1}`, ...args.data, createdAt: new Date('2026-02-02') });
|
||||
const created = makeRow({
|
||||
id: `new-${rows.length + 1}`,
|
||||
...args.data,
|
||||
createdAt: new Date('2026-02-02'),
|
||||
});
|
||||
rows.push(created);
|
||||
return pick(created, args.select);
|
||||
}),
|
||||
@@ -196,20 +236,29 @@ function makeFakePrisma(rows: ImageRow[] = []): FakePrisma {
|
||||
}),
|
||||
};
|
||||
|
||||
function wrap(tenantId: string, userId?: string) {
|
||||
function wrapModel(model: string, methods: ModelMethods, tenantId: string, userId?: string) {
|
||||
const wrapped: ModelMethods = {};
|
||||
for (const method of Object.keys(dashboardImage)) {
|
||||
for (const method of Object.keys(methods)) {
|
||||
wrapped[method] = async (...args: unknown[]) => {
|
||||
boundCallLog.push({ tenantId, userId, model: 'dashboardImage', method });
|
||||
return dashboardImage[method](...args);
|
||||
boundCallLog.push({ tenantId, userId, model, method });
|
||||
return methods[method](...args);
|
||||
};
|
||||
}
|
||||
return { dashboardImage: wrapped };
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
function wrap(tenantId: string, userId?: string) {
|
||||
return {
|
||||
dashboardImage: wrapModel('dashboardImage', dashboardImage, tenantId, userId),
|
||||
user: wrapModel('user', user, tenantId, userId),
|
||||
};
|
||||
}
|
||||
|
||||
const fake: FakePrisma = {
|
||||
dashboardImage,
|
||||
user,
|
||||
__rows: rows,
|
||||
__users: users,
|
||||
__boundCallLog: boundCallLog,
|
||||
__makeBoundClient(tenantId: string, userId?: string) {
|
||||
return wrap(tenantId, userId);
|
||||
@@ -253,9 +302,17 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
const result = await makeService(prisma).list('user-1', 'tenant-1');
|
||||
expect(result.map((r) => r.id)).toEqual(['a', 'b']);
|
||||
for (const r of result) {
|
||||
expect(Object.keys(r).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']);
|
||||
expect(Object.keys(r).sort()).toEqual([
|
||||
'createdAt',
|
||||
'id',
|
||||
'mimeType',
|
||||
'originalName',
|
||||
'size',
|
||||
]);
|
||||
}
|
||||
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as { select: Record<string, boolean> };
|
||||
const call = vi.mocked(prisma.dashboardImage.findMany).mock.calls[0][0] as {
|
||||
select: Record<string, boolean>;
|
||||
};
|
||||
expect(call.select.data).toBeUndefined();
|
||||
expect(call.select.storagePath).toBeUndefined();
|
||||
});
|
||||
@@ -274,14 +331,22 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
expect(result.mimeType).toBe('image/png');
|
||||
expect(result.originalName).toBe('irgendwas.txt');
|
||||
expect(result.size).toBe(PNG.length);
|
||||
expect(Object.keys(result).sort()).toEqual(['createdAt', 'id', 'mimeType', 'originalName', 'size']);
|
||||
expect(Object.keys(result).sort()).toEqual([
|
||||
'createdAt',
|
||||
'id',
|
||||
'mimeType',
|
||||
'originalName',
|
||||
'size',
|
||||
]);
|
||||
expect(prisma.__rows[0].userId).toBe('user-1');
|
||||
expect(prisma.__rows[0].tenantId).toBe('tenant-1');
|
||||
});
|
||||
|
||||
it('Test 4: Textdatei mit behauptetem image/png scheitert mit deutscher Meldung, nichts wird angelegt', async () => {
|
||||
const prisma = makeFakePrisma();
|
||||
await expect(makeService(prisma).upload(user, file(TEXT, 'image/png', 'bild.png'))).rejects.toThrow(
|
||||
await expect(
|
||||
makeService(prisma).upload(user, file(TEXT, 'image/png', 'bild.png')),
|
||||
).rejects.toThrow(
|
||||
new BadRequestException('Nur Bilder im Format PNG, JPEG, GIF oder WebP sind erlaubt.'),
|
||||
);
|
||||
expect(prisma.dashboardImage.create).not.toHaveBeenCalled();
|
||||
@@ -304,7 +369,9 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
});
|
||||
|
||||
it('Test 6: Zaehler zaehlt nur den eigenen Benutzer im eigenen Mandanten (fremde Zeilen zaehlen nicht)', async () => {
|
||||
const foreign = Array.from({ length: 30 }, (_, i) => makeRow({ id: `f${i}`, userId: 'user-2' }));
|
||||
const foreign = Array.from({ length: 30 }, (_, i) =>
|
||||
makeRow({ id: `f${i}`, userId: 'user-2' }),
|
||||
);
|
||||
const prisma = makeFakePrisma(foreign);
|
||||
await expect(makeService(prisma).upload(user, file(PNG, 'image/png'))).resolves.toMatchObject({
|
||||
mimeType: 'image/png',
|
||||
@@ -321,14 +388,20 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
|
||||
it('Test 8: getBytes — fremder Benutzer (gleicher Mandant) -> NotFoundException, nie Forbidden', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', userId: 'user-2' })]);
|
||||
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
await expect(makeService(prisma).getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 9: getBytes — fremder Mandant (gleicher Benutzer) -> NotFoundException; unbekannte Kennung ebenso', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'img-1', tenantId: 'tenant-2' })]);
|
||||
const service = makeService(prisma);
|
||||
await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
await expect(service.getBytes('img-1', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
await expect(service.getBytes('gibt-es-nicht', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 10: getBytes — eigenes Bild liefert mimeType und die gespeicherten Bytes', async () => {
|
||||
@@ -347,11 +420,52 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
const service = makeService(prisma);
|
||||
await expect(service.remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'eigen' });
|
||||
expect(prisma.__rows.map((r) => r.id)).toEqual(['fremd-user', 'fremd-tenant']);
|
||||
await expect(service.remove('fremd-user', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(NotFoundException);
|
||||
await expect(service.remove('fremd-user', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
await expect(service.remove('fremd-tenant', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
expect(prisma.__rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('Test 11b (quick-260930): war das geloeschte Bild der Dashboard-Hintergrund, steht die Wahl danach auf „kein Hintergrund“ — andere Wahlen und andere Benutzer bleiben', async () => {
|
||||
const users: UserRow[] = [
|
||||
{ id: 'user-1', dashboardBackground: { kind: 'image', imageId: 'eigen' } },
|
||||
{ id: 'user-2', dashboardBackground: { kind: 'image', imageId: 'eigen' } },
|
||||
];
|
||||
const prisma = makeFakePrisma(
|
||||
[makeStoredRow({ id: 'eigen' }), makeStoredRow({ id: 'zweites' })],
|
||||
users,
|
||||
);
|
||||
const service = makeService(prisma);
|
||||
|
||||
await service.remove('eigen', 'user-1', 'tenant-1');
|
||||
|
||||
expect(users[0].dashboardBackground).toEqual({ kind: 'none' });
|
||||
// nur die eigene Zeile
|
||||
expect(users[1].dashboardBackground).toEqual({ kind: 'image', imageId: 'eigen' });
|
||||
const call = vi.mocked(prisma.user.updateMany).mock.calls[0][0];
|
||||
expect(call).toEqual({
|
||||
where: { id: 'user-1', dashboardBackground: { path: ['imageId'], equals: 'eigen' } },
|
||||
data: { dashboardBackground: { kind: 'none' } },
|
||||
});
|
||||
|
||||
// Ein anderes Bild loeschen laesst eine andere Wahl stehen.
|
||||
users[0].dashboardBackground = { kind: 'preset', id: 'mist' };
|
||||
await service.remove('zweites', 'user-1', 'tenant-1');
|
||||
expect(users[0].dashboardBackground).toEqual({ kind: 'preset', id: 'mist' });
|
||||
});
|
||||
|
||||
it('Test 11c (quick-260930): scheitert das Zuruecksetzen der Wahl, ist das Bild trotzdem geloescht (kein Fehler nach aussen)', async () => {
|
||||
const prisma = makeFakePrisma([makeStoredRow({ id: 'eigen' })]);
|
||||
vi.mocked(prisma.user.updateMany).mockRejectedValueOnce(new Error('db weg'));
|
||||
await expect(makeService(prisma).remove('eigen', 'user-1', 'tenant-1')).resolves.toEqual({
|
||||
id: 'eigen',
|
||||
});
|
||||
expect(prisma.__rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Test 12: jede Methode bindet mit (prisma, tenantId, userId) und laeuft NUR ueber den gebundenen Klienten', async () => {
|
||||
const prisma = makeFakePrisma([]);
|
||||
const service = makeService(prisma);
|
||||
@@ -370,7 +484,17 @@ describe('DashboardImagesService (quick-260921-pi9)', () => {
|
||||
// Stufe 2 vergibt der Dienst die UUID selbst und legt die Zeile gleich
|
||||
// MIT Pfad an — kein nachtraegliches `update` mehr (m4n).
|
||||
const methods = prisma.__boundCallLog.map((c) => c.method);
|
||||
expect(methods).toEqual(['findMany', 'count', 'create', 'findUnique', 'findUnique', 'delete']);
|
||||
// quick-260930: `remove` setzt zusaetzlich die Hintergrund-Wahl zurueck (user.updateMany).
|
||||
expect(methods).toEqual([
|
||||
'findMany',
|
||||
'count',
|
||||
'create',
|
||||
'findUnique',
|
||||
'findUnique',
|
||||
'delete',
|
||||
'updateMany',
|
||||
]);
|
||||
expect(prisma.__boundCallLog.at(-1)?.model).toBe('user');
|
||||
expect(vi.mocked(forSystem)).not.toHaveBeenCalled();
|
||||
for (const c of prisma.__boundCallLog) {
|
||||
expect(c.tenantId).toBe('tenant-1');
|
||||
@@ -387,14 +511,20 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
||||
const onDisk = storedFile('user-1', result.id);
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
expect(fs.readFileSync(onDisk).equals(PNG)).toBe(true);
|
||||
expect(prisma.__rows[0].storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
|
||||
expect(prisma.__rows[0].storagePath).toBe(
|
||||
`user-files/dashboard-images/user-1/${result.id}.png`,
|
||||
);
|
||||
// Die Zeile traegt den Pfad schon beim Anlegen (Pflichtfeld seit Stufe 2),
|
||||
// die Kennung ist eine vom Dienst vergebene UUID, und Bytes gehen nie in
|
||||
// die Zeile.
|
||||
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as { data: Record<string, unknown> };
|
||||
const createArgs = vi.mocked(prisma.dashboardImage.create).mock.calls[0][0] as {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(createArgs.data.storagePath).toBe(`user-files/dashboard-images/user-1/${result.id}.png`);
|
||||
expect(createArgs.data.id).toBe(result.id);
|
||||
expect(result.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
||||
expect(result.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
expect(createArgs.data).not.toHaveProperty('data');
|
||||
expect(prisma.dashboardImage.update).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -444,7 +574,10 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
||||
// Eigene Kennung: das Verzeichnis ist ueber alle Tests dieser Datei
|
||||
// dasselbe, eine von Test 8/10 angelegte `img-1.png` waere sonst da.
|
||||
const prisma = makeFakePrisma([
|
||||
makeRow({ id: 'datei-fehlt', storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png' }),
|
||||
makeRow({
|
||||
id: 'datei-fehlt',
|
||||
storagePath: 'user-files/dashboard-images/user-1/datei-fehlt.png',
|
||||
}),
|
||||
]);
|
||||
await expect(makeService(prisma).getBytes('datei-fehlt', 'user-1', 'tenant-1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
@@ -456,7 +589,9 @@ describe('DashboardImagesService — Ablage im Dateibereich (quick-260922-hk4)',
|
||||
const onDisk = storedFile('user-1', 'weg');
|
||||
expect(fs.existsSync(onDisk)).toBe(true);
|
||||
|
||||
await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({ id: 'weg' });
|
||||
await expect(makeService(prisma).remove('weg', 'user-1', 'tenant-1')).resolves.toEqual({
|
||||
id: 'weg',
|
||||
});
|
||||
expect(prisma.__rows).toHaveLength(0);
|
||||
expect(fs.existsSync(onDisk)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
@@ -5,12 +8,9 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type { AuthUser, UploadedFileLike } from '../auth/types/auth-user';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { forTenant } from '../prisma/prisma-tenant.extension';
|
||||
import {
|
||||
DASHBOARD_IMAGE_MAX_COUNT,
|
||||
type DashboardImageMime,
|
||||
@@ -314,6 +314,15 @@ export class DashboardImagesService {
|
||||
* Loescht ein eigenes Bild; fremd/unbekannt -> 404, nichts wird geloescht.
|
||||
* Zeile zuerst, Datei danach: ein Fehler beim Entfernen der Datei wird
|
||||
* protokolliert und geschluckt (T-HK4-04).
|
||||
*
|
||||
* quick-260930: War das Bild der Dashboard-Hintergrund des Benutzers
|
||||
* (`User.dashboardBackground` = `{ kind: 'image', imageId: <diese UUID> }`),
|
||||
* wird die Wahl im selben Vorgang auf „kein Hintergrund“ gesetzt — sonst
|
||||
* zeigte sie auf ein Bild, das es nicht mehr gibt. Bedingtes `updateMany`
|
||||
* (JSON-Pfad `imageId`), damit jede andere Wahl unberuehrt bleibt; nur die
|
||||
* eigene Zeile (`id: userId`). Ein Fehler dabei wird wie beim Entfernen der
|
||||
* Datei protokolliert und geschluckt: das Bild ist schon weg, und das Web
|
||||
* zeigt eine Wahl mit nicht ladbarem Bild ohnehin als „kein Hintergrund“.
|
||||
*/
|
||||
async remove(id: string, userId: string, tenantId: string): Promise<{ id: string }> {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
@@ -323,6 +332,19 @@ export class DashboardImagesService {
|
||||
}
|
||||
await tenantPrisma.dashboardImage.delete({ where: { id } });
|
||||
|
||||
try {
|
||||
await tenantPrisma.user.updateMany({
|
||||
where: { id: userId, dashboardBackground: { path: ['imageId'], equals: id } },
|
||||
data: { dashboardBackground: { kind: 'none' } },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Hintergrund-Wahl zum geloeschten Bilderrahmen-Bild ${id} konnte nicht zurueckgesetzt werden: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
const absolute = absoluteImagePath(row.storagePath);
|
||||
if (absolute !== null) {
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
IsString,
|
||||
IsUrl,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
@@ -24,6 +25,7 @@ export class CreateFavoriteDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
iconUrl?: string;
|
||||
|
||||
@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.
|
||||
@@ -19,6 +19,8 @@ export class UpdateFavoriteDto {
|
||||
* No strict type validation so null passes through to Prisma.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
iconUrl?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
HttpException,
|
||||
NotFoundException,
|
||||
PayloadTooLargeException,
|
||||
UnprocessableEntityException,
|
||||
} from '@nestjs/common';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FavoritesService } from './favorites.service';
|
||||
@@ -859,6 +858,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', () => {
|
||||
it('Zeile und Datei weg', async () => {
|
||||
const prisma = makeFakePrisma([baseRow]);
|
||||
@@ -886,26 +919,50 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('create/update — Abrufprobe fuer eine explizite iconUrl (260923-lrr)', () => {
|
||||
it('create mit expliziter iconUrl: Probe genau einmal; wirft -> UnprocessableEntityException, favoriteLink.create NICHT aufgerufen', async () => {
|
||||
const prisma = makeFakePrisma([], [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }]);
|
||||
const iconDiscovery = makeIconDiscovery({
|
||||
fetchIconBytes: vi.fn(async () => {
|
||||
throw new Error('blocked');
|
||||
}),
|
||||
describe('create/update — ausdrueckliche iconUrl: nur Formpruefung, kein Abruf (260929-lh3)', () => {
|
||||
const widgets = [{ id: 'widget-a1', userId: 'user-a1', tenantId: 't1' }];
|
||||
const failingFetch = () =>
|
||||
vi.fn(async () => {
|
||||
throw new Error('server bekommt 404/HTML');
|
||||
});
|
||||
|
||||
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 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(
|
||||
service.create('t1', 'user-a1', {
|
||||
widgetId: 'widget-a1',
|
||||
title: 'X',
|
||||
url: 'https://x.invalid',
|
||||
iconUrl: 'https://x.invalid/logo.png',
|
||||
iconUrl,
|
||||
} as any),
|
||||
).rejects.toThrow(UnprocessableEntityException);
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledTimes(1);
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://x.invalid/logo.png');
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(prisma.__favorites.size).toBe(0);
|
||||
});
|
||||
|
||||
@@ -922,24 +979,31 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
||||
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 iconDiscovery = makeIconDiscovery({
|
||||
fetchIconBytes: vi.fn(async () => {
|
||||
throw new Error('blocked');
|
||||
}),
|
||||
});
|
||||
const iconDiscovery = makeIconDiscovery({ fetchIconBytes: failingFetch() });
|
||||
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(
|
||||
service.update('t1', 'f1', 'user-a1', { iconUrl: 'https://neu.invalid/icon.png' } as any),
|
||||
).rejects.toThrow(UnprocessableEntityException);
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledTimes(1);
|
||||
expect(iconDiscovery.fetchIconBytes).toHaveBeenCalledWith('https://neu.invalid/icon.png');
|
||||
service.update('t1', 'f1', 'user-a1', { iconUrl: 'file:///etc/passwd' } as any),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
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 iconDiscovery = makeIconDiscovery();
|
||||
const service = new FavoritesService(prisma as any, iconDiscovery as any);
|
||||
@@ -958,18 +1022,5 @@ describe('FavoritesService — Bindung an forTenant() (260911-gwh)', () => {
|
||||
|
||||
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,
|
||||
NotFoundException,
|
||||
PayloadTooLargeException,
|
||||
UnprocessableEntityException,
|
||||
} from '@nestjs/common';
|
||||
import type { UploadedFileLike } from '../auth/types/auth-user';
|
||||
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
|
||||
* entfernt. Entfernen/Loeschen aktualisiert zuerst die Zeile, ein
|
||||
* Dateifehler wird protokolliert und geschluckt.
|
||||
* - Abrufprobe: `assertIconUrlLoadable()` ruft `fetchIconBytes` einmal ab,
|
||||
* um eine im Formular NICHT abrufbare Logo-Adresse (z. B. hinter einer
|
||||
* Cloudflare-Pruefung) mit `UnprocessableEntityException` (422) statt
|
||||
* stiller Speicherung abzuweisen — keine Umgehung von Bot-Sperren, nur
|
||||
* derselbe Abruf, den `GET /favorites/:id/icon` ohnehin ausloest.
|
||||
* - 260929-lh3 (loest die Abrufprobe von 260923-lrr ab): eine ausdrueckliche
|
||||
* Logo-Adresse wird nur auf Form (http/https, <= 2048 Zeichen) geprueft und
|
||||
* auch gespeichert, wenn der Server sie nicht abrufen kann — der Browser der
|
||||
* Kachel laedt sie dann direkt. Ein hochgeladenes Symbol wird von einer
|
||||
* 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()
|
||||
export class FavoritesService {
|
||||
private readonly logger = new Logger(FavoritesService.name);
|
||||
@@ -107,19 +110,31 @@ export class FavoritesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft, ob sich das Bild unter `iconUrl` serverseitig abrufen laesst
|
||||
* (260923-lrr) — derselbe `fetchIconBytes`-Aufruf, den `getIconBytes`
|
||||
* ohnehin ausloest, hier nur zur Speicherzeit als Probe. Jeder Fehler
|
||||
* (SSRF-Ablehnung, Zeitgrenze, kein `image/*`, Cloudflare-Pruefung o. ae.)
|
||||
* wird zu derselben deutschen 422-Meldung — keine Unterscheidung, aus der
|
||||
* sich etwas ueber die gepruefte Adresse ablesen liesse.
|
||||
* Prueft eine ausdruecklich eingetragene Logo-Adresse NUR auf Form (260929-lh3):
|
||||
* gueltige http/https-Adresse, hoechstens 2048 Zeichen. Bewusst KEIN
|
||||
* serverseitiger Abruf mehr — Server wie docuvita liefern dem Server ein
|
||||
* 404/HTML, dem Browser aber das Bild; die fruehere Abrufprobe (422,
|
||||
* 260923-lrr) machte genau diese Adressen unspeicherbar. Entscheidung: auch
|
||||
* 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 {
|
||||
await this.iconDiscovery.fetchIconBytes(iconUrl);
|
||||
parsed = new URL(iconUrl);
|
||||
} catch {
|
||||
throw new UnprocessableEntityException(
|
||||
'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.',
|
||||
parsed = null;
|
||||
}
|
||||
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
|
||||
* discovery network call (T-GWH-05).
|
||||
* 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
|
||||
* create is rejected with 422 — nothing is written on a failed probe.
|
||||
* If iconUrl IS provided it is stored as given after a form check only
|
||||
* (260929-lh3, see assertIconUrlWellFormed) — no server-side fetch.
|
||||
*/
|
||||
async create(tenantId: string, userId: string, dto: CreateFavoriteDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
@@ -154,8 +169,8 @@ export class FavoritesService {
|
||||
let iconUrl = dto.iconUrl ?? null;
|
||||
|
||||
if (iconUrl) {
|
||||
// 260923-lrr: explizit uebergebene Adresse wird einmal probiert.
|
||||
await this.assertIconUrlLoadable(iconUrl);
|
||||
// 260929-lh3: nur Formpruefung, kein serverseitiger Abruf.
|
||||
this.assertIconUrlWellFormed(iconUrl);
|
||||
} else {
|
||||
// Server-side icon discovery (D-05) — only when caller did not supply an icon
|
||||
iconUrl = await this.iconDiscovery.discoverFavoriteIconUrl(url);
|
||||
@@ -179,10 +194,11 @@ export class FavoritesService {
|
||||
* Verifies userId ownership before applying changes (T-08-06).
|
||||
* Accepts null as an explicit value for iconUrl (clears stored icon).
|
||||
*
|
||||
* 260923-lrr: eine neue, vom gespeicherten Wert ABWEICHENDE `iconUrl`
|
||||
* durchlaeuft die Abrufprobe (`assertIconUrlLoadable`), bevor irgendetwas
|
||||
* geschrieben wird; misslingt sie, bleibt die Zeile unveraendert. Jede
|
||||
* tatsaechliche Aenderung der Symbolquelle erhoeht `iconVersion`.
|
||||
* 260929-lh3: eine neue, vom gespeicherten Wert ABWEICHENDE `iconUrl`
|
||||
* durchlaeuft nur die Formpruefung (`assertIconUrlWellFormed`), bevor
|
||||
* irgendetwas geschrieben wird; sie wird auch gespeichert, wenn der Server
|
||||
* sie nicht abrufen kann. Jede tatsaechliche Aenderung der Symbolquelle
|
||||
* erhoeht `iconVersion`.
|
||||
*/
|
||||
async update(tenantId: string, id: string, userId: string, dto: UpdateFavoriteDto) {
|
||||
const tenantPrisma = forTenant(this.prisma, tenantId, userId);
|
||||
@@ -205,8 +221,8 @@ export class FavoritesService {
|
||||
if ('iconUrl' in dto) {
|
||||
if (dto.iconUrl) {
|
||||
if (dto.iconUrl !== link.iconUrl) {
|
||||
// 260923-lrr: nur eine NEUE, abweichende Adresse wird probiert.
|
||||
await this.assertIconUrlLoadable(dto.iconUrl);
|
||||
// 260929-lh3: nur eine NEUE, abweichende Adresse wird geprueft (Form).
|
||||
this.assertIconUrlWellFormed(dto.iconUrl);
|
||||
}
|
||||
// Explicit icon URL supplied — respect it as-is.
|
||||
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 };
|
||||
}
|
||||
|
||||
return tenantPrisma.favoriteLink.update({
|
||||
const updated = await tenantPrisma.favoriteLink.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
|
||||
if (explicitUrlReplacesUpload && link.uploadedIconMime !== null) {
|
||||
await this.removeIconFile(id, link.userId, link.uploadedIconMime, 'ersetzte');
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,24 +18,21 @@ vi.mock('undici', () => ({
|
||||
|
||||
import { Agent } from 'undici';
|
||||
import {
|
||||
discardBody,
|
||||
IconDiscoveryService,
|
||||
isPublicHttpUrl,
|
||||
normalizeUrl,
|
||||
readTextCapped,
|
||||
} from './icon-discovery.service';
|
||||
|
||||
function mockResponse(options: {
|
||||
contentType?: string;
|
||||
body?: ArrayBuffer;
|
||||
}): Response {
|
||||
function mockResponse(options: { contentType?: string; body?: ArrayBuffer }): Response {
|
||||
const body = options.body ?? new ArrayBuffer(10);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === 'content-type'
|
||||
? (options.contentType ?? 'image/png')
|
||||
: null,
|
||||
name.toLowerCase() === 'content-type' ? (options.contentType ?? 'image/png') : null,
|
||||
},
|
||||
arrayBuffer: async () => body,
|
||||
} as unknown as Response;
|
||||
@@ -106,9 +103,7 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl', () => {
|
||||
status: 200,
|
||||
headers: {
|
||||
get: (n: string) =>
|
||||
n.toLowerCase() === 'content-type'
|
||||
? 'text/html; charset=utf-8'
|
||||
: null,
|
||||
n.toLowerCase() === 'content-type' ? 'text/html; charset=utf-8' : null,
|
||||
},
|
||||
text: async () => html,
|
||||
}),
|
||||
@@ -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', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -155,16 +202,13 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
|
||||
});
|
||||
|
||||
it('rejects when Content-Type is not an image', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })),
|
||||
);
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })));
|
||||
|
||||
const service = new IconDiscoveryService();
|
||||
|
||||
await expect(
|
||||
service.fetchIconBytes('http://8.8.8.8/favicon.ico'),
|
||||
).rejects.toThrow(/not an image/);
|
||||
await expect(service.fetchIconBytes('http://8.8.8.8/favicon.ico')).rejects.toThrow(
|
||||
/not an image/,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects when the SSRF guard blocks the target', async () => {
|
||||
@@ -173,9 +217,9 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
|
||||
|
||||
const service = new IconDiscoveryService();
|
||||
|
||||
await expect(
|
||||
service.fetchIconBytes('http://127.0.0.1/favicon.ico'),
|
||||
).rejects.toThrow(/blocked or failed/);
|
||||
await expect(service.fetchIconBytes('http://127.0.0.1/favicon.ico')).rejects.toThrow(
|
||||
/blocked or failed/,
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -188,9 +232,9 @@ describe('IconDiscoveryService.fetchIconBytes', () => {
|
||||
|
||||
const service = new IconDiscoveryService();
|
||||
|
||||
await expect(
|
||||
service.fetchIconBytes('http://8.8.8.8/favicon.ico'),
|
||||
).rejects.toThrow(/size limit/);
|
||||
await expect(service.fetchIconBytes('http://8.8.8.8/favicon.ico')).rejects.toThrow(
|
||||
/size limit/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,10 +254,7 @@ describe('IconDiscoveryService.discoverFavoriteIconUrl (unchanged behaviour)', (
|
||||
});
|
||||
|
||||
it('still returns a URL string', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })),
|
||||
);
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(mockResponse({ contentType: 'text/html' })));
|
||||
|
||||
const service = new IconDiscoveryService();
|
||||
const result = await service.discoverFavoriteIconUrl('http://8.8.8.8/page');
|
||||
@@ -287,3 +328,143 @@ describe('IconDiscoveryService — Dispatcher (260917-jdd)', () => {
|
||||
expect(calls[0][1].dispatcher).toBe(calls[1][1].dispatcher);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readTextCapped / discardBody — Groessendeckel beim Lesen (T-08-09)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
/** Stream aus `chunks` Stuecken je `chunkChars` ASCII-Zeichen; zaehlt gelesene Stuecke und Abbruch. */
|
||||
function countingStream(chunks: number, chunkChars: number) {
|
||||
const state = { pulled: 0, cancelled: false };
|
||||
const encoder = new TextEncoder();
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (state.pulled >= chunks) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
state.pulled += 1;
|
||||
controller.enqueue(encoder.encode('a'.repeat(chunkChars)));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { body, state };
|
||||
}
|
||||
|
||||
it('bricht den Stream nach der Grenze ab statt alles zu lesen', async () => {
|
||||
const { body, state } = countingStream(1000, 1000);
|
||||
const text = await readTextCapped({ body, text: async () => 'unbenutzt' } as never, 2500);
|
||||
|
||||
expect(text).toHaveLength(2500);
|
||||
expect(state.pulled).toBeLessThan(10);
|
||||
expect(state.cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it('gibt nach der Zeitgrenze zurueck, was bis dahin da ist (tropfender Server)', async () => {
|
||||
let cancelled = false;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('<link rel="icon">'));
|
||||
// danach kommt nichts mehr, der Stream bleibt offen
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
|
||||
const text = await readTextCapped({ body, text: async () => '' } as never, 200000, 50);
|
||||
|
||||
expect(text).toBe('<link rel="icon">');
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it('liest kurze Seiten vollstaendig, auch Mehrbyte-Zeichen ueber Chunk-Grenzen', async () => {
|
||||
const bytes = new TextEncoder().encode('<p>Grüße</p>');
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
// Das "ü" (2 Bytes) wird absichtlich zerteilt.
|
||||
controller.enqueue(bytes.slice(0, 5));
|
||||
controller.enqueue(bytes.slice(5));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const text = await readTextCapped({ body, text: async () => '' } as never, 200000);
|
||||
|
||||
expect(text).toBe('<p>Grüße</p>');
|
||||
});
|
||||
|
||||
it('ohne Stream: Rueckfall auf text() mit Deckel', async () => {
|
||||
const text = await readTextCapped(
|
||||
{ body: null, text: async () => 'x'.repeat(50) } as never,
|
||||
10,
|
||||
);
|
||||
|
||||
expect(text).toBe('x'.repeat(10));
|
||||
});
|
||||
|
||||
it('discardBody bricht einen offenen Body ab und vertraegt fehlenden Body', () => {
|
||||
const { body, state } = countingStream(5, 10);
|
||||
discardBody({ body } as never);
|
||||
expect(state.cancelled).toBe(true);
|
||||
expect(() => discardBody({ body: null } as never)).not.toThrow();
|
||||
});
|
||||
|
||||
it('Discovery: Fehlerstatus ohne HTML-Typ -> Body wird verworfen, Rueckfall favicon.ico', async () => {
|
||||
const { body, state } = countingStream(5, 10);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
headers: {
|
||||
get: (n: string) => (n.toLowerCase() === 'content-type' ? 'application/json' : null),
|
||||
},
|
||||
body,
|
||||
}),
|
||||
);
|
||||
|
||||
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/x');
|
||||
|
||||
expect(icon).toBe('http://8.8.8.8/favicon.ico');
|
||||
expect(state.cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it('Discovery: riesige HTML-Seite wird nur bis zur Grenze gelesen, Symbol am Anfang gefunden', async () => {
|
||||
const head = '<html><head><link rel="icon" href="/klein.png" /></head><body>';
|
||||
const encoder = new TextEncoder();
|
||||
const state = { pulled: 0, cancelled: false };
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
state.pulled += 1;
|
||||
controller.enqueue(encoder.encode(state.pulled === 1 ? head : 'a'.repeat(64 * 1024)));
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: (n: string) => (n.toLowerCase() === 'content-type' ? 'text/html' : null) },
|
||||
body,
|
||||
text: async () => {
|
||||
throw new Error('text() darf bei vorhandenem Stream nicht laufen');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const icon = await new IconDiscoveryService().discoverFavoriteIconUrl('http://8.8.8.8/');
|
||||
|
||||
expect(icon).toBe('http://8.8.8.8/klein.png');
|
||||
expect(state.cancelled).toBe(true);
|
||||
// 200 000 Zeichen bei 64-KiB-Stuecken: hoechstens eine Handvoll gelesen.
|
||||
expect(state.pulled).toBeLessThan(10);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
import { isIP } from 'node:net';
|
||||
import { Agent, fetch as undiciFetch, type Response as UndiciResponse } from 'undici';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Agent, type Response as UndiciResponse, fetch as undiciFetch } from 'undici';
|
||||
|
||||
/**
|
||||
* Server-side favicon / icon discovery with SSRF protection (T-08-05).
|
||||
@@ -49,6 +49,8 @@ const LENIENT_TLS_AGENT = new Agent({ connect: { rejectUnauthorized: false } });
|
||||
type FetchHtmlResult = {
|
||||
html: 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 {
|
||||
@@ -56,9 +58,7 @@ function isPrivateIpv4(address: string): boolean {
|
||||
|
||||
if (
|
||||
parts.length !== 4 ||
|
||||
parts.some(
|
||||
(part) => !Number.isInteger(part) || part < 0 || part > 255,
|
||||
)
|
||||
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
@@ -115,12 +115,7 @@ function isPrivateIpAddress(address: string): boolean {
|
||||
function isBlockedHostname(hostname: string): boolean {
|
||||
const h = hostname.trim().toLowerCase();
|
||||
|
||||
return (
|
||||
h === 'localhost' ||
|
||||
h.endsWith('.localhost') ||
|
||||
h.endsWith('.local') ||
|
||||
h === '0.0.0.0'
|
||||
);
|
||||
return h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h === '0.0.0.0';
|
||||
}
|
||||
|
||||
export async function isPublicHttpUrl(url: URL): Promise<boolean> {
|
||||
@@ -202,7 +197,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 metaTags = html.match(/<meta\b[^>]*>/gi) ?? [];
|
||||
|
||||
@@ -214,30 +209,26 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null {
|
||||
}))
|
||||
.filter((c) => c.href);
|
||||
|
||||
const appleTouchIcon = linkCandidates.find((c) =>
|
||||
c.rel.includes('apple-touch-icon'),
|
||||
)?.href;
|
||||
const appleTouchIcon = linkCandidates.find((c) => c.rel.includes('apple-touch-icon'))?.href;
|
||||
|
||||
if (appleTouchIcon) return appleTouchIcon;
|
||||
|
||||
const icon = linkCandidates.find((c) =>
|
||||
c.rel.split(/\s+/).includes('icon'),
|
||||
)?.href;
|
||||
const icon = linkCandidates.find((c) => c.rel.split(/\s+/).includes('icon'))?.href;
|
||||
|
||||
if (icon) return icon;
|
||||
|
||||
const shortcutIcon = linkCandidates.find((c) =>
|
||||
c.rel.includes('shortcut icon'),
|
||||
)?.href;
|
||||
const shortcutIcon = linkCandidates.find((c) => c.rel.includes('shortcut icon'))?.href;
|
||||
|
||||
if (shortcutIcon) return shortcutIcon;
|
||||
|
||||
const imageSrc = linkCandidates.find((c) =>
|
||||
c.rel.includes('image_src'),
|
||||
)?.href;
|
||||
const imageSrc = linkCandidates.find((c) => c.rel.includes('image_src'))?.href;
|
||||
|
||||
if (imageSrc) return imageSrc;
|
||||
|
||||
// 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
|
||||
.map((tag) => parseAttributes(tag))
|
||||
.map((a) => ({
|
||||
@@ -247,9 +238,7 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null {
|
||||
.find(
|
||||
(c) =>
|
||||
c.content &&
|
||||
(c.property === 'og:image' ||
|
||||
c.property === 'og:logo' ||
|
||||
c.property === 'twitter:image'),
|
||||
(c.property === 'og:image' || c.property === 'og:logo' || c.property === 'twitter:image'),
|
||||
)?.content;
|
||||
|
||||
return metaImage ?? null;
|
||||
@@ -266,7 +255,13 @@ function extractIconFromHtml(html: string, baseUrl: string): string | null {
|
||||
*/
|
||||
async function fetchWithRedirectGuard(
|
||||
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> {
|
||||
let currentUrl = pageUrl;
|
||||
|
||||
@@ -298,7 +293,7 @@ async function fetchWithRedirectGuard(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) return null;
|
||||
if (!response.ok && !options.allowErrorStatus) return null;
|
||||
|
||||
return { response, finalUrl: currentUrl };
|
||||
} catch {
|
||||
@@ -311,23 +306,98 @@ async function fetchWithRedirectGuard(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Minimaler Ausschnitt einer Antwort, den die beiden Helfer brauchen. */
|
||||
type BodyResponse = Pick<UndiciResponse, 'body' | 'text'>;
|
||||
|
||||
/**
|
||||
* Verwirft den Body einer nicht gebrauchten Antwort. Fehler (bereits
|
||||
* gelesen/abgebrochen) sind egal.
|
||||
*/
|
||||
export function discardBody(response: Pick<UndiciResponse, 'body'>): void {
|
||||
try {
|
||||
response.body?.cancel().catch(() => {});
|
||||
} catch {
|
||||
// Body gesperrt oder schon verbraucht — nichts zu tun.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest den Antworttext hoechstens bis `maxChars` Zeichen und bricht den
|
||||
* Stream danach ab (T-08-09). Vorher wurde der komplette Body gelesen und
|
||||
* erst danach abgeschnitten — eine riesige Seite landete ganz im Speicher.
|
||||
* Dekodiert wird UTF-8 wie bei `Response.text()`; da jedes Zeichen aus
|
||||
* mindestens einem Byte entsteht, bleibt der Speicher bei ~maxChars plus
|
||||
* einem Chunk. Ohne Stream (`body === null`) wie bisher ueber `text()`.
|
||||
* `timeoutMs` begrenzt zusaetzlich die Lesedauer: die Zeitgrenze von
|
||||
* `fetchWithRedirectGuard` endet mit den Kopfzeilen, ein Server, der den
|
||||
* Body tropfenweise liefert, hielte die Anfrage sonst beliebig lange auf.
|
||||
* Nach Ablauf zaehlt, was bis dahin gelesen ist.
|
||||
*/
|
||||
export async function readTextCapped(
|
||||
response: BodyResponse,
|
||||
maxChars: number,
|
||||
timeoutMs = HTML_FETCH_TIMEOUT_MS,
|
||||
): Promise<string> {
|
||||
if (!response.body) {
|
||||
return (await response.text()).slice(0, maxChars);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let text = '';
|
||||
// cancel() beendet ein haengendes read() mit done: true.
|
||||
const deadline = setTimeout(() => void reader.cancel().catch(() => {}), timeoutMs);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
text += decoder.decode();
|
||||
break;
|
||||
}
|
||||
|
||||
text += decoder.decode(value, { stream: true });
|
||||
|
||||
if (text.length >= maxChars) {
|
||||
await reader.cancel().catch(() => {});
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(deadline);
|
||||
}
|
||||
|
||||
return text.slice(0, maxChars);
|
||||
}
|
||||
|
||||
async function fetchHtml(pageUrl: URL): Promise<FetchHtmlResult | null> {
|
||||
const result = await fetchWithRedirectGuard(pageUrl, {
|
||||
accept: 'text/html,application/xhtml+xml,*/*',
|
||||
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;
|
||||
|
||||
const contentType = result.response.headers.get('content-type') ?? '';
|
||||
|
||||
if (!contentType.toLowerCase().includes('text/html')) return null;
|
||||
if (!contentType.toLowerCase().includes('text/html')) {
|
||||
// Kein HTML (auch bei Fehlerstatus dank allowErrorStatus hier moeglich):
|
||||
// Body verwerfen, sonst haelt undici die Verbindung bis zum Timeout offen.
|
||||
discardBody(result.response);
|
||||
return null;
|
||||
}
|
||||
|
||||
const html = await result.response.text();
|
||||
// T-08-09: HTML cap — schon beim Lesen, nicht erst nach dem kompletten Body.
|
||||
const html = await readTextCapped(result.response, MAX_HTML_CHARS);
|
||||
|
||||
return {
|
||||
html: html.slice(0, MAX_HTML_CHARS), // T-08-09: HTML cap
|
||||
html,
|
||||
finalUrl: result.finalUrl.toString(),
|
||||
ok: result.response.ok,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -350,7 +420,7 @@ export class IconDiscoveryService {
|
||||
|
||||
if (!htmlResult) return fallback;
|
||||
|
||||
return extractIconFromHtml(htmlResult.html, htmlResult.finalUrl) ?? fallback;
|
||||
return extractIconFromHtml(htmlResult.html, htmlResult.finalUrl, !htmlResult.ok) ?? fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
@@ -365,9 +435,7 @@ export class IconDiscoveryService {
|
||||
* or an oversized body. Callers must not return a placeholder image; let
|
||||
* the caller map the failure to an HTTP error status instead.
|
||||
*/
|
||||
async fetchIconBytes(
|
||||
iconUrl: string,
|
||||
): Promise<{ contentType: string; body: Buffer }> {
|
||||
async fetchIconBytes(iconUrl: string): Promise<{ contentType: string; body: Buffer }> {
|
||||
const url = new URL(iconUrl);
|
||||
|
||||
const result = await fetchWithRedirectGuard(url, {
|
||||
|
||||
@@ -248,3 +248,69 @@ describe('MailService — Transport je Versand nach Mandant des Empfaengers (260
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -316,4 +316,51 @@ export class MailService {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* nimmt die `system_read_policy` auf "DashboardImage" zurueck. Summe neu:
|
||||
* 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>([
|
||||
['apps/api/src/dkv/dkv.service.ts', 1],
|
||||
['apps/api/src/ldap/ldap-config.service.ts', 2],
|
||||
['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-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,5 @@
|
||||
use semver::Version;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use tauri::{
|
||||
@@ -48,6 +49,59 @@ struct VersionResponse {
|
||||
/// `Update` ist Clone + Send + Sync, `app.manage` verlangt das.
|
||||
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
|
||||
/// Update-Pruefung. Die Pruefung laeuft alle `UPDATE_CHECK_INTERVAL` erneut;
|
||||
/// 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
|
||||
/// Texte aus der Server-Adresse ableitet. Gedankenstrich U+2013 wie in
|
||||
/// `update_labels`.
|
||||
@@ -461,7 +658,21 @@ fn clear_check_notice(app: &AppHandle) {
|
||||
/// / kein Update / fehlgeschlagen mit Grund); nur der http-Fall bleibt
|
||||
/// gesperrt. Ein fehlgeschlagener Check war bisher vom Zustand "kein Update"
|
||||
/// nicht unterscheidbar (quick-260922-frg).
|
||||
fn spawn_version_check(app: AppHandle, server_url: String) {
|
||||
///
|
||||
/// `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.set_text(UPDATE_ITEM_CHECKING);
|
||||
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");
|
||||
|
||||
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)) => {
|
||||
let (menu_text, body) = Version::parse(&update.version)
|
||||
.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
|
||||
// Downloads an (updater.rs Z. 698-700).
|
||||
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
|
||||
.notification()
|
||||
.builder()
|
||||
@@ -500,11 +723,12 @@ fn spawn_version_check(app: AppHandle, server_url: String) {
|
||||
if let Ok(mut pending) = app.state::<PendingUpdate>().0.lock() {
|
||||
*pending = Some((update, menu_text));
|
||||
}
|
||||
clear_check_notice(&app);
|
||||
app.state::<UpdateFlow>().set_offered(true);
|
||||
}
|
||||
Ok(None) => {
|
||||
let _ = update_item.set_text(UPDATE_ITEM_NONE);
|
||||
let _ = update_item.set_enabled(true);
|
||||
app.state::<UpdateFlow>().set_offered(false);
|
||||
clear_check_notice(&app);
|
||||
}
|
||||
// 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
|
||||
/// aktiv, Benachrichtigung mit Grund, Einstellungsseite im Browser als
|
||||
/// 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) {
|
||||
if !app.state::<UpdateFlow>().try_begin_install() {
|
||||
return;
|
||||
}
|
||||
let item = app.state::<TrayItems>().update.clone();
|
||||
let _ = item.set_enabled(false);
|
||||
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() {
|
||||
*pending = Some((update, menu_text.clone()));
|
||||
}
|
||||
app.state::<UpdateFlow>().install_failed();
|
||||
let _ = item.set_enabled(true);
|
||||
let _ = app
|
||||
.notification()
|
||||
@@ -654,8 +885,12 @@ fn save_server_url(app: AppHandle, url: String) -> Result<(), String> {
|
||||
store.set("server_url", serde_json::json!(normalized));
|
||||
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));
|
||||
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") {
|
||||
let _ = window.navigate(with_desktop_marker(&parsed));
|
||||
@@ -789,6 +1024,9 @@ pub fn run() {
|
||||
let server_url = stored_server_url(app.handle());
|
||||
|
||||
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 Ok(parsed) = parse_server_url(url) {
|
||||
let _ = window.navigate(with_desktop_marker(&parsed));
|
||||
@@ -849,6 +1087,7 @@ pub fn run() {
|
||||
update: update.clone(),
|
||||
});
|
||||
app.manage(PendingUpdate(Mutex::new(None)));
|
||||
app.manage(UpdateFlow::default());
|
||||
app.manage(LastCheckNotice(Mutex::new(String::new())));
|
||||
|
||||
let autostart_for_menu = autostart.clone();
|
||||
@@ -868,27 +1107,33 @@ pub fn run() {
|
||||
}
|
||||
"update" => {
|
||||
// `take()` verhindert Doppelklick-Downloads, der
|
||||
// gesperrte Eintrag ebenso. Ohne abgelegten Stand
|
||||
// ("Kein Update verfügbar" / "Update-Prüfung
|
||||
// fehlgeschlagen") prueft der Klick erneut -- der
|
||||
// Browser-Weg (`open_download_page`) ist nur noch
|
||||
// Rueckfall einer fehlgeschlagenen Installation.
|
||||
// gesperrte Eintrag ebenso. Liegt ein Update vor,
|
||||
// wird es NICHT aus dem Speicher installiert, sondern
|
||||
// frisch geprueft und das Ergebnis installiert
|
||||
// (`install_after`, siehe spawn_version_check). Ohne
|
||||
// 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.
|
||||
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>()
|
||||
.0
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut guard| guard.take());
|
||||
match pending {
|
||||
Some((update, menu_text)) => {
|
||||
spawn_update_install(app.clone(), update, menu_text)
|
||||
}
|
||||
None => {
|
||||
if let Some(url) = stored_server_url(app) {
|
||||
spawn_version_check(app.clone(), url);
|
||||
}
|
||||
}
|
||||
.and_then(|mut guard| guard.take())
|
||||
.is_some();
|
||||
if let Some(url) = stored_server_url(app) {
|
||||
let install_after = flow.install_on_click(had_pending);
|
||||
spawn_version_check(app.clone(), url, install_after);
|
||||
}
|
||||
}
|
||||
"autostart" => {
|
||||
@@ -928,7 +1173,7 @@ pub fn run() {
|
||||
apply_server(app.handle(), server_url.as_deref());
|
||||
|
||||
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
|
||||
@@ -940,11 +1185,16 @@ pub fn run() {
|
||||
// ein Update, wird der Durchlauf uebersprungen: die Pruefung
|
||||
// wuerde den Stand leeren und "Neuer Beta-Stand" erneut melden.
|
||||
// Waehrend einer Installation ist der Stand per `take()` leer;
|
||||
// faellt die 4-h-Marke genau in den Download, wird nur der
|
||||
// Fortschrittstext ueberschrieben (T-FRG-04, akzeptiert).
|
||||
// darum prueft die Schleife zusaetzlich `UpdateFlow::installing`
|
||||
// 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();
|
||||
std::thread::spawn(move || loop {
|
||||
std::thread::sleep(UPDATE_CHECK_INTERVAL);
|
||||
if handle.state::<UpdateFlow>().is_installing() {
|
||||
continue;
|
||||
}
|
||||
let pending = handle
|
||||
.state::<PendingUpdate>()
|
||||
.0
|
||||
@@ -955,7 +1205,7 @@ pub fn run() {
|
||||
continue;
|
||||
}
|
||||
if let Some(url) = stored_server_url(&handle) {
|
||||
spawn_version_check(handle.clone(), url);
|
||||
spawn_version_check(handle.clone(), url, false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1352,4 +1602,190 @@ mod tests {
|
||||
assert_eq!(UPDATE_ITEM_INSECURE, "Update nur über https möglich");
|
||||
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",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ const messages: Record<string, Record<string, unknown>> = {
|
||||
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.',
|
||||
@@ -58,8 +60,18 @@ vi.mock('next-intl', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => (
|
||||
<a href={href} className={className}>{children}</a>
|
||||
default: ({
|
||||
children,
|
||||
href,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
className?: string;
|
||||
}) => (
|
||||
<a href={href} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -68,13 +80,7 @@ vi.mock('@/lib/use-category-label', () => ({
|
||||
({ infrastructure: 'Infrastruktur', 'security-tools': 'Sicherheit' })[slug] ?? slug,
|
||||
}));
|
||||
|
||||
const {
|
||||
mockList,
|
||||
mockCreate,
|
||||
mockUpdate,
|
||||
mockDelete,
|
||||
mockBump,
|
||||
} = vi.hoisted(() => ({
|
||||
const { mockList, mockCreate, mockUpdate, mockDelete, mockBump } = vi.hoisted(() => ({
|
||||
mockList: vi.fn(),
|
||||
mockCreate: vi.fn(),
|
||||
mockUpdate: vi.fn(),
|
||||
@@ -106,6 +112,7 @@ vi.mock('@/lib/stores/auth-store', () => ({
|
||||
selector({ user: { role: mockRole } }),
|
||||
}));
|
||||
|
||||
import { CustomModuleRequestError } from '@/lib/custom-modules-api';
|
||||
import AdminCustomModulesPage from './page';
|
||||
|
||||
const wiki = {
|
||||
@@ -136,7 +143,9 @@ describe('AdminCustomModulesPage', () => {
|
||||
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);
|
||||
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 () => {
|
||||
@@ -154,13 +163,17 @@ describe('AdminCustomModulesPage', () => {
|
||||
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.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(
|
||||
await screen.findByText('Bitte geben Sie eine Adresse ein, die mit https:// beginnt.'),
|
||||
).toBeInTheDocument();
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
expect(mockBump).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -168,7 +181,9 @@ describe('AdminCustomModulesPage', () => {
|
||||
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.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');
|
||||
@@ -183,7 +198,9 @@ describe('AdminCustomModulesPage', () => {
|
||||
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.click(
|
||||
screen.getAllByRole('button', { name: 'Gemeinsamen Eintrag anlegen' })[0],
|
||||
);
|
||||
|
||||
await userEvent.type(screen.getByLabelText('Name'), ' ');
|
||||
await userEvent.type(screen.getByLabelText('Adresse'), 'https://example.com');
|
||||
@@ -196,7 +213,9 @@ describe('AdminCustomModulesPage', () => {
|
||||
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.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');
|
||||
@@ -217,11 +236,62 @@ describe('AdminCustomModulesPage', () => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Anlegen: die Kategorie ist vorbelegt mit Infrastruktur', async () => {
|
||||
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('infrastructure');
|
||||
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 () => {
|
||||
@@ -254,13 +324,17 @@ describe('AdminCustomModulesPage', () => {
|
||||
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.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(
|
||||
await screen.findByText('Das Modul konnte nicht gespeichert werden.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(mockBump).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -271,7 +345,9 @@ describe('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();
|
||||
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'));
|
||||
@@ -283,7 +359,9 @@ describe('AdminCustomModulesPage', () => {
|
||||
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' }));
|
||||
await userEvent.click(
|
||||
within(screen.getByRole('dialog')).getByRole('button', { name: 'Abbrechen' }),
|
||||
);
|
||||
|
||||
expect(mockDelete).not.toHaveBeenCalled();
|
||||
expect(mockBump).not.toHaveBeenCalled();
|
||||
@@ -295,13 +373,38 @@ describe('AdminCustomModulesPage', () => {
|
||||
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' }));
|
||||
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,
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
|
||||
import {
|
||||
BackgroundPicker,
|
||||
DashboardBackdrop,
|
||||
useDashboardBackground,
|
||||
} from '@/components/dashboard/dashboard-background';
|
||||
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
||||
import { DashboardTabs } from '@/components/dashboard/dashboard-tabs';
|
||||
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
||||
import { BackgroundPicker, DashboardBackdrop, useDashboardBackground } from '@/components/dashboard/dashboard-background';
|
||||
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
|
||||
import { registerWidget } from '@/components/dashboard/widget-registry';
|
||||
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
|
||||
import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
|
||||
import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget';
|
||||
import { NoteWidget } from '@/components/dashboard/widgets/note-widget';
|
||||
import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widget';
|
||||
import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget';
|
||||
import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget';
|
||||
import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget';
|
||||
import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget';
|
||||
import { ProxmoxWidget } from '@/components/dashboard/widgets/proxmox-widget';
|
||||
import { useDashboardStore } from '@/lib/stores/dashboard-store';
|
||||
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
||||
import { registerWidget } from '@/components/dashboard/widget-registry';
|
||||
import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widget';
|
||||
import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget';
|
||||
import { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
|
||||
import { FavoritesWidget } from '@/components/dashboard/widgets/favorites-widget';
|
||||
import { NoteWidget } from '@/components/dashboard/widgets/note-widget';
|
||||
import { PictureFrameWidget } from '@/components/dashboard/widgets/picture-frame-widget';
|
||||
import { ProxmoxWidget } from '@/components/dashboard/widgets/proxmox-widget';
|
||||
import { ReminderWidget } from '@/components/dashboard/widgets/reminder-widget';
|
||||
import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
|
||||
import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget';
|
||||
import { XframeWidget } from '@/components/dashboard/widgets/xframe-widget';
|
||||
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
|
||||
import { useDashboardStore } from '@/lib/stores/dashboard-store';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
@@ -39,6 +44,7 @@ registerWidget('favorites', FavoritesWidget);
|
||||
registerWidget('picture-frame', PictureFrameWidget);
|
||||
registerWidget('xframe', XframeWidget);
|
||||
registerWidget('proxmox', ProxmoxWidget);
|
||||
registerWidget('reminder', ReminderWidget);
|
||||
|
||||
/** Modul-Eintrag aus `GET /modules/active` — hier zaehlt nur der Slug. */
|
||||
interface ActiveModule {
|
||||
@@ -69,6 +75,7 @@ export default function DashboardPage() {
|
||||
activeDashboardId,
|
||||
isSwitchingDashboard,
|
||||
layouts,
|
||||
canvas,
|
||||
widgets,
|
||||
isEditMode,
|
||||
isLoading,
|
||||
@@ -83,6 +90,7 @@ export default function DashboardPage() {
|
||||
renameDashboard,
|
||||
deleteDashboard,
|
||||
reorderDashboards,
|
||||
captureCanvas,
|
||||
} = useDashboardStore();
|
||||
|
||||
// Load dashboard data on mount
|
||||
@@ -198,6 +206,8 @@ export default function DashboardPage() {
|
||||
onRemoveWidget={removeWidget}
|
||||
onOpenCatalog={() => setCatalogOpen(true)}
|
||||
onQuickAdd={(type) => addWidget(type)}
|
||||
canvas={canvas}
|
||||
onCaptureCanvas={captureCanvas}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -129,9 +129,9 @@ describe('CustomModulesSettingsPage', () => {
|
||||
render(<CustomModulesSettingsPage />);
|
||||
expect(await screen.findByText('Noch keine eigenen Module')).toBeInTheDocument();
|
||||
expect(screen.getByText('Diese Einträge sehen nur Sie.')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' }).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(screen.getAllByRole('button', { name: 'Eigenes Modul anlegen' }).length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it('listet nur die persönlichen Einträge, gemeinsame der Verwaltung nicht', async () => {
|
||||
@@ -158,7 +158,7 @@ describe('CustomModulesSettingsPage', () => {
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
name: 'Mein Wiki',
|
||||
url: 'https://example.com',
|
||||
category: 'infrastructure',
|
||||
category: 'custom-modules',
|
||||
});
|
||||
});
|
||||
expect(mockCreate.mock.calls[0][0]).not.toHaveProperty('shared');
|
||||
@@ -228,7 +228,9 @@ describe('CustomModulesSettingsPage', () => {
|
||||
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(
|
||||
await screen.findByText('Das Modul konnte nicht gespeichert werden.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
expect(mockBump).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { CUSTOM_MODULE_CATEGORIES, CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { CUSTOM_MODULE_CATEGORIES } from '@tessera/shared';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
type CustomModule,
|
||||
CustomModuleRequestError,
|
||||
checkCustomModuleUrl,
|
||||
createCustomModule,
|
||||
updateCustomModule,
|
||||
@@ -27,6 +28,23 @@ interface CustomModuleFormModalProps {
|
||||
const NAME_MAX = 100;
|
||||
const URL_MAX = 2048;
|
||||
|
||||
/**
|
||||
* Fehlertext fuer Speichern und Loeschen: 403 = keine Berechtigung, 400 = die
|
||||
* API hat die Angaben abgelehnt (mit ihrer Meldung), alles andere (500,
|
||||
* Netzwerk) = der allgemeine „bitte erneut versuchen“-Text `fallbackKey`.
|
||||
*/
|
||||
export function customModuleErrorText(
|
||||
err: unknown,
|
||||
t: (key: string, values?: Record<string, string>) => string,
|
||||
fallbackKey: 'saveError' | 'deleteError',
|
||||
): string {
|
||||
if (err instanceof CustomModuleRequestError) {
|
||||
if (err.status === 403) return t('forbidden');
|
||||
if (err.status === 400) return t('invalidInput');
|
||||
}
|
||||
return t(fallbackKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formular „Eigenes Modul“ (quick-260929-9wc, Vorbild `GroupFormModal`;
|
||||
* quick-260929-dzu: ein Formular, zwei Aufrufer — Verwaltung legt gemeinsame,
|
||||
@@ -47,7 +65,9 @@ export function CustomModuleFormModal({
|
||||
|
||||
const [name, setName] = useState(mod?.name ?? '');
|
||||
const [url, setUrl] = useState(mod?.url ?? '');
|
||||
const [category, setCategory] = useState<string>(mod?.category ?? 'infrastructure');
|
||||
// Neue Eintraege landen vorbelegt in der Gruppe „Eigene Module“; beim
|
||||
// Bearbeiten bleibt die gespeicherte Kategorie.
|
||||
const [category, setCategory] = useState<string>(mod?.category ?? CUSTOM_MODULE_CATEGORY);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -81,8 +101,8 @@ export function CustomModuleFormModal({
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {
|
||||
setError(t('saveError'));
|
||||
} catch (err) {
|
||||
setError(customModuleErrorText(err, t, 'saveError'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
|
||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||
@@ -86,9 +86,7 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1
|
||||
className={`${shared ? 'text-2xl' : 'text-lg'} font-semibold text-foreground`}
|
||||
>
|
||||
<h1 className={`${shared ? 'text-2xl' : 'text-lg'} font-semibold text-foreground`}>
|
||||
{texts.title}
|
||||
</h1>
|
||||
<button type="button" onClick={openCreate} className="btn btn-primary">
|
||||
@@ -104,9 +102,11 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bei einem Ladefehler ohne Eintraege steht nur die Fehlermeldung da —
|
||||
„keine Eintraege“ waere dann falsch. */}
|
||||
{loading ? (
|
||||
<p className="text-muted-foreground">{tCommon('loading')}</p>
|
||||
) : modules.length === 0 ? (
|
||||
) : modules.length === 0 && loadFailed ? null : modules.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<h2 className="mb-2 text-lg font-semibold text-foreground">{texts.noModules}</h2>
|
||||
<p className="mb-6 text-sm text-muted-foreground">{texts.noModulesBody}</p>
|
||||
@@ -119,9 +119,15 @@ export function CustomModuleManager({ scope, texts }: CustomModuleManagerProps)
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('name')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('url')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">{t('category')}</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
{t('name')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
{t('url')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
||||
{t('category')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
|
||||
{tManage('actions')}
|
||||
</th>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { type CustomModule, deleteCustomModule } from '@/lib/custom-modules-api';
|
||||
import { customModuleErrorText } from './custom-module-form-modal';
|
||||
|
||||
interface DeleteCustomModuleDialogProps {
|
||||
mod: CustomModule;
|
||||
@@ -37,8 +38,8 @@ export function DeleteCustomModuleDialog({
|
||||
await deleteCustomModule(mod.id);
|
||||
onDeleted();
|
||||
onClose();
|
||||
} catch {
|
||||
setError(t('deleteError'));
|
||||
} catch (err) {
|
||||
setError(customModuleErrorText(err, t, 'deleteError'));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
@@ -55,7 +56,11 @@ export function DeleteCustomModuleDialog({
|
||||
<h2 id="delete-custom-module-title" className="mb-4 text-lg font-semibold text-foreground">
|
||||
{t('deleteConfirm.title')}
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-foreground">{t(shared ? 'deleteConfirm.bodyShared' : 'deleteConfirm.bodyPersonal', { name: mod.name })}</p>
|
||||
<p className="mb-4 text-sm text-foreground">
|
||||
{t(shared ? 'deleteConfirm.bodyShared' : 'deleteConfirm.bodyPersonal', {
|
||||
name: mod.name,
|
||||
})}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { type AuthUser, useAuthStore } from '@/lib/stores/auth-store';
|
||||
|
||||
@@ -13,6 +13,23 @@ vi.mock('@/lib/auth-actions', () => ({
|
||||
updateDashboardBackgroundAction: (...args: unknown[]) => updateDashboardBackgroundAction(...args),
|
||||
}));
|
||||
|
||||
// quick-260930: Auswahlfenster (Fokus, geloeschtes Bild) braucht Uebersetzung,
|
||||
// Erscheinungsbild und die Bilderliste.
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
vi.mock('next-themes', () => ({
|
||||
useTheme: () => ({ resolvedTheme: 'light' }),
|
||||
}));
|
||||
const fetchDashboardImages = vi.fn();
|
||||
vi.mock('@/lib/dashboard-images-api', () => ({
|
||||
fetchDashboardImages: (...args: unknown[]) => fetchDashboardImages(...args),
|
||||
uploadDashboardImage: vi.fn(),
|
||||
dashboardImageSrc: (id: string) => `/api-proxy/dashboard/images/${id}`,
|
||||
}));
|
||||
|
||||
const IMAGE_ID = '11111111-2222-3333-4444-555555555555';
|
||||
|
||||
const LEGACY_KEY = 'tessera.dashboardBackground.u1';
|
||||
|
||||
function makeUser(overrides: Partial<AuthUser> = {}): AuthUser {
|
||||
@@ -141,3 +158,147 @@ describe('useDashboardBackground (quick-260928-ujj)', () => {
|
||||
expect(updateDashboardBackgroundAction).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDashboardBackground — Rueckfall nur fuer den eigenen Wert (quick-260930)', () => {
|
||||
it('scheitert der erste von zwei schnellen Klicks, bleibt die zweite Wahl stehen', async () => {
|
||||
let rejectFirst: (e: Error) => void = () => {};
|
||||
updateDashboardBackgroundAction
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectFirst = reject;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({ success: true });
|
||||
useAuthStore.setState({ user: makeUser({ dashboardBackground: { kind: 'none' } }) });
|
||||
const { result } = await renderBackgroundHook();
|
||||
|
||||
act(() => result.current.choose({ kind: 'preset', id: 'mist' }));
|
||||
act(() => result.current.choose({ kind: 'preset', id: 'dunes' }));
|
||||
await act(async () => {
|
||||
rejectFirst(new Error('offline'));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(result.current.background).toEqual({ kind: 'preset', id: 'dunes' });
|
||||
expect(useAuthStore.getState().user?.dashboardBackground).toEqual({
|
||||
kind: 'preset',
|
||||
id: 'dunes',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDashboardBackground — Bild nicht mehr vorhanden (quick-260930)', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('laesst sich das gewaehlte Bild nicht laden, gilt die Wahl als „kein Hintergrund“ (ohne zu speichern)', async () => {
|
||||
class FailingImage {
|
||||
onerror: (() => void) | null = null;
|
||||
set src(_value: string) {
|
||||
setTimeout(() => this.onerror?.(), 0);
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('Image', FailingImage);
|
||||
useAuthStore.setState({
|
||||
user: makeUser({ dashboardBackground: { kind: 'image', imageId: IMAGE_ID } }),
|
||||
});
|
||||
|
||||
const { result } = await renderBackgroundHook();
|
||||
|
||||
await waitFor(() => expect(result.current.background).toEqual({ kind: 'none' }));
|
||||
expect(updateDashboardBackgroundAction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ein ladbares Bild bleibt gewaehlt', async () => {
|
||||
class LoadingImage {
|
||||
onerror: (() => void) | null = null;
|
||||
onload: (() => void) | null = null;
|
||||
set src(_value: string) {
|
||||
setTimeout(() => this.onload?.(), 0);
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('Image', LoadingImage);
|
||||
useAuthStore.setState({
|
||||
user: makeUser({ dashboardBackground: { kind: 'image', imageId: IMAGE_ID } }),
|
||||
});
|
||||
|
||||
const { result } = await renderBackgroundHook();
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
});
|
||||
|
||||
expect(result.current.background).toEqual({ kind: 'image', imageId: IMAGE_ID });
|
||||
});
|
||||
});
|
||||
|
||||
describe('BackgroundPicker (quick-260930)', () => {
|
||||
async function renderPicker(value: import('@/lib/dashboard-background').DashboardBackground) {
|
||||
const { BackgroundPicker } = await import('./dashboard-background');
|
||||
const onChange = vi.fn();
|
||||
render(<BackgroundPicker value={value} onChange={onChange} />);
|
||||
return { onChange, trigger: screen.getByRole('button', { name: 'button' }) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
fetchDashboardImages.mockReset();
|
||||
fetchDashboardImages.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('Oeffnen setzt den Fokus auf die erste Auswahl, Escape schliesst und gibt ihn an den Knopf zurueck', async () => {
|
||||
const { trigger } = await renderPicker({ kind: 'none' });
|
||||
trigger.focus();
|
||||
fireEvent.click(trigger);
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
const first = screen.getByRole('button', { name: 'none' });
|
||||
expect(dialog).toContainElement(first);
|
||||
expect(first).toHaveFocus();
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
});
|
||||
|
||||
it('Klick nach draussen schliesst und gibt den Fokus an den Knopf zurueck', async () => {
|
||||
const { trigger } = await renderPicker({ kind: 'none' });
|
||||
fireEvent.click(trigger);
|
||||
await screen.findByRole('dialog');
|
||||
|
||||
fireEvent.mouseDown(document.body);
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
});
|
||||
|
||||
it('Tab bleibt im Fenster: vom letzten Knopf zum ersten, Umschalt+Tab vom ersten zum letzten', async () => {
|
||||
const { trigger } = await renderPicker({ kind: 'none' });
|
||||
fireEvent.click(trigger);
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
const buttons = Array.from(dialog.querySelectorAll('button'));
|
||||
const first = buttons[0];
|
||||
const last = buttons[buttons.length - 1];
|
||||
|
||||
last.focus();
|
||||
fireEvent.keyDown(document, { key: 'Tab' });
|
||||
expect(first).toHaveFocus();
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true });
|
||||
expect(last).toHaveFocus();
|
||||
});
|
||||
|
||||
it('gewaehltes Bild fehlt in der Bilderliste -> „Keiner“ ist markiert', async () => {
|
||||
fetchDashboardImages.mockResolvedValue([
|
||||
{ id: 'anderes', originalName: 'urlaub.jpg', mimeType: 'image/jpeg', size: 1, createdAt: '' },
|
||||
]);
|
||||
const { trigger } = await renderPicker({ kind: 'image', imageId: IMAGE_ID });
|
||||
fireEvent.click(trigger);
|
||||
|
||||
await screen.findByRole('button', { name: 'urlaub.jpg' });
|
||||
expect(screen.getByRole('button', { name: 'none' })).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.getByRole('button', { name: 'urlaub.jpg' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,23 @@ function setStoredBackground(userId: string, value: DashboardBackground | null)
|
||||
useAuthStore.getState().setUser({ ...current, dashboardBackground: value });
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt eine fehlgeschlagene Wahl zurueck — aber nur, wenn im Store noch
|
||||
* GENAU dieser optimistisch gesetzte Wert steht (quick-260930). Hat der
|
||||
* Benutzer inzwischen etwas anderes gewaehlt, wuerde der Fehler des ersten
|
||||
* Klicks sonst die zweite Wahl ueberschreiben.
|
||||
*/
|
||||
function revertStoredBackground(
|
||||
userId: string,
|
||||
optimistic: DashboardBackground,
|
||||
previous: DashboardBackground | null,
|
||||
) {
|
||||
const current = useAuthStore.getState().user;
|
||||
if (!current || current.id !== userId) return;
|
||||
if (current.dashboardBackground !== optimistic) return;
|
||||
setStoredBackground(userId, previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wahl des Hintergrunds fuer den angemeldeten Benutzer (quick-260928-ujj).
|
||||
*
|
||||
@@ -34,18 +51,43 @@ function setStoredBackground(userId: string, value: DashboardBackground | null)
|
||||
* die Server-Aktion; schlaegt das fehl, kommt der vorige Wert zurueck.
|
||||
* Ist der Server-Wert `null` und liegt noch eine alte localStorage-Wahl vor,
|
||||
* wird sie genau einmal je Benutzerkennung uebernommen.
|
||||
*
|
||||
* quick-260930: Zeigt die Wahl auf ein eigenes Bild, das nicht (mehr) zu
|
||||
* laden ist — etwa weil es im Bilderrahmen geloescht wurde (die API setzt die
|
||||
* gespeicherte Wahl dabei auf „kein Hintergrund“, eine schon geladene Sitzung
|
||||
* kennt aber noch den alten Wert) —, gilt sie als „kein Hintergrund“. Das
|
||||
* ist nur Anzeige; gespeichert wird dabei nichts.
|
||||
*/
|
||||
export function useDashboardBackground() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const userId = user?.id ?? null;
|
||||
const stored = user?.dashboardBackground ?? null;
|
||||
const background = stored ?? NO_BACKGROUND;
|
||||
const imageId = stored?.kind === 'image' ? stored.imageId : null;
|
||||
const [brokenImageId, setBrokenImageId] = useState<string | null>(null);
|
||||
const background =
|
||||
stored === null || (imageId !== null && imageId === brokenImageId) ? NO_BACKGROUND : stored;
|
||||
const migratedFor = useRef<string | null>(null);
|
||||
|
||||
// Laedt das gewaehlte Bild einmal zur Probe; schlaegt das fehl, wird die
|
||||
// Wahl als „kein Hintergrund“ angezeigt.
|
||||
useEffect(() => {
|
||||
if (imageId === null || typeof Image === 'undefined') return;
|
||||
let active = true;
|
||||
const probe = new Image();
|
||||
probe.onerror = () => {
|
||||
if (active) setBrokenImageId(imageId);
|
||||
};
|
||||
probe.src = dashboardImageSrc(imageId);
|
||||
return () => {
|
||||
active = false;
|
||||
probe.onerror = null;
|
||||
};
|
||||
}, [imageId]);
|
||||
|
||||
const save = useCallback(
|
||||
(id: string, value: DashboardBackground, previous: DashboardBackground | null) => {
|
||||
setStoredBackground(id, value);
|
||||
const revert = () => setStoredBackground(id, previous);
|
||||
const revert = () => revertStoredBackground(id, value, previous);
|
||||
updateDashboardBackgroundAction(value)
|
||||
.then((result) => {
|
||||
if (!result.success) revert();
|
||||
@@ -141,23 +183,88 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
||||
const [images, setImages] = useState<DashboardImageMeta[] | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Liste erfolgreich geladen? Nur dann laesst sich sagen, dass ein gewaehltes
|
||||
// Bild fehlt (nach einem Abruffehler ist die Liste nur ersatzweise leer).
|
||||
const [imagesListed, setImagesListed] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const wasOpen = useRef(false);
|
||||
|
||||
// Gewaehltes eigenes Bild, das es nicht mehr gibt -> „Keiner“ markieren
|
||||
// (quick-260930).
|
||||
const shown: DashboardBackground =
|
||||
value.kind === 'image' &&
|
||||
imagesListed &&
|
||||
images !== null &&
|
||||
!images.some((image) => image.id === value.imageId)
|
||||
? NO_BACKGROUND
|
||||
: value;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || images !== null) return;
|
||||
fetchDashboardImages()
|
||||
.then(setImages)
|
||||
.then((list) => {
|
||||
setImages(list);
|
||||
setImagesListed(true);
|
||||
})
|
||||
.catch(() => setImages([]));
|
||||
}, [open, images]);
|
||||
|
||||
// Fokus (quick-260930): beim Oeffnen auf die erste Auswahl („Keiner“),
|
||||
// nach dem Schliessen zurueck auf den Knopf „Hintergrund“ — ausser der
|
||||
// Benutzer hat zum Schliessen etwas anderes Fokussierbares angeklickt.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
wasOpen.current = true;
|
||||
dialogRef.current?.querySelector<HTMLButtonElement>('button:not([disabled])')?.focus();
|
||||
return;
|
||||
}
|
||||
if (!wasOpen.current) return;
|
||||
wasOpen.current = false;
|
||||
// Einen Takt spaeter pruefen: beim Klick nach draussen setzt der Browser
|
||||
// den Fokus erst NACH dem mousedown-Horcher (auf das Ziel oder den body).
|
||||
const timer = window.setTimeout(() => {
|
||||
const active = document.activeElement;
|
||||
if (!active || active === document.body || rootRef.current?.contains(active)) {
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onPointer(e: MouseEvent) {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
||||
}
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
// Tab bleibt im Fenster (Muster ActivationDialog im Marktplatz):
|
||||
// vom letzten Knopf zum ersten und mit Umschalt+Tab umgekehrt.
|
||||
if (e.key === 'Tab' && dialogRef.current) {
|
||||
const items = Array.from(
|
||||
dialogRef.current.querySelectorAll<HTMLElement>('button:not([disabled])'),
|
||||
);
|
||||
if (items.length === 0) return;
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (!active || !dialogRef.current.contains(active)) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
} else if (e.shiftKey && active === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onPointer);
|
||||
document.addEventListener('keydown', onKey);
|
||||
@@ -190,7 +297,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
||||
preview: CSSProperties,
|
||||
content?: React.ReactNode,
|
||||
) => {
|
||||
const selected = sameChoice(value, choice);
|
||||
const selected = sameChoice(shown, choice);
|
||||
return (
|
||||
<li key={key}>
|
||||
<button
|
||||
@@ -223,6 +330,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
||||
return (
|
||||
<div ref={rootRef} className="relative">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
@@ -253,6 +361,7 @@ export function BackgroundPicker({ value, onChange }: BackgroundPickerProps) {
|
||||
|
||||
{open && (
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-label={t('title')}
|
||||
className="absolute right-0 top-full z-30 mt-2 w-[min(22rem,calc(100vw-2rem))] rounded-lg bg-card p-4 text-card-foreground shadow-xl dark:border dark:border-border"
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Children, isValidElement } from 'react';
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Children, isValidElement } from 'react';
|
||||
import { createScaledStrategy, defaultPositionStrategy } from 'react-grid-layout/core';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { stubResizeObserver } from '@/test/fake-resize-observer';
|
||||
import { RESIZE_AXIS_FALLBACK } from './dashboard-grid';
|
||||
import {
|
||||
computeCanvasScale,
|
||||
DASHBOARD_BOTTOM_GAP_PX,
|
||||
RESIZE_AXIS_FALLBACK,
|
||||
scaledPositionStrategy,
|
||||
} from './dashboard-grid';
|
||||
|
||||
// Mock CSS imports that vitest cannot resolve
|
||||
vi.mock('react-grid-layout/css/styles.css', () => ({}));
|
||||
@@ -137,9 +143,7 @@ describe('DashboardGrid', () => {
|
||||
});
|
||||
|
||||
it('renders widget instance when widgets are provided', async () => {
|
||||
const widgets = [
|
||||
{ id: 'inst-1', widgetType: 'clock', config: { timezone: 'Europe/Berlin' } },
|
||||
];
|
||||
const widgets = [{ id: 'inst-1', widgetType: 'clock', config: { timezone: 'Europe/Berlin' } }];
|
||||
const layouts = {
|
||||
lg: [{ i: 'inst-1', x: 0, y: 0, w: 2, h: 2 }],
|
||||
md: [],
|
||||
@@ -167,9 +171,7 @@ describe('DashboardGrid', () => {
|
||||
});
|
||||
|
||||
it('shows edit affordances (delete button) when isEditMode is true', async () => {
|
||||
const widgets = [
|
||||
{ id: 'inst-2', widgetType: 'clock', config: {} },
|
||||
];
|
||||
const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }];
|
||||
const layouts = {
|
||||
lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }],
|
||||
md: [],
|
||||
@@ -312,7 +314,7 @@ describe('DashboardGrid', () => {
|
||||
expect(layouts.lg[0].minH).toBe(8);
|
||||
});
|
||||
|
||||
it('quick-260929-dmx Test 9c: bestehender Kalender (migrierte Werte minW 12, w 16) bekommt minW 8 in JEDEM Breakpoint und kann schmaler gezogen werden; zu schmale Breite wird auf 8 angehoben', async () => {
|
||||
it('quick-260929-dmx Test 9c (quick-260930: Kalender-minW 11): bestehender Kalender (migrierte Werte minW 12, w 16) bekommt minW 11 in JEDEM Breakpoint und kann schmaler gezogen werden; zu schmale Breite wird auf 11 angehoben', async () => {
|
||||
captured.props = null;
|
||||
const { DashboardGrid } = await import('./dashboard-grid');
|
||||
// So sieht ein aus v2 (minW 6 x2) migrierter Kalender im Zustand aus.
|
||||
@@ -341,25 +343,108 @@ describe('DashboardGrid', () => {
|
||||
|
||||
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
|
||||
// Breite bleibt (Bildschirmgroesse unveraendert), nur das Minimum sinkt.
|
||||
expect(passed.lg[0]).toEqual({ i: 'cal-1', x: 4, y: 0, w: 16, h: 16, minW: 8, minH: 8 });
|
||||
expect(passed.md[0]).toEqual({ i: 'cal-1', x: 0, y: 0, w: 16, h: 16, minW: 8, minH: 8 });
|
||||
expect(passed.lg[1]).toEqual({ i: 'cal-2', x: 24, y: 0, w: 8, h: 16, minW: 8, minH: 8 });
|
||||
expect(passed.lg[0]).toEqual({ i: 'cal-1', x: 4, y: 0, w: 16, h: 16, minW: 11, minH: 8 });
|
||||
expect(passed.md[0]).toEqual({ i: 'cal-1', x: 0, y: 0, w: 16, h: 16, minW: 11, minH: 8 });
|
||||
expect(passed.lg[1]).toEqual({ i: 'cal-2', x: 24, y: 0, w: 11, h: 16, minW: 11, minH: 8 });
|
||||
// Auch der data-grid-Wert der Kinder traegt das neue Minimum.
|
||||
const children = Children.toArray(captured.props?.children as React.ReactNode);
|
||||
const grid = (children[0] as React.ReactElement<Record<string, unknown>>).props['data-grid'] as Record<string, unknown>;
|
||||
expect(grid.minW).toBe(8);
|
||||
const grid = (children[0] as React.ReactElement<Record<string, unknown>>).props[
|
||||
'data-grid'
|
||||
] as Record<string, unknown>;
|
||||
expect(grid.minW).toBe(11);
|
||||
});
|
||||
|
||||
const CANCEL = 'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag';
|
||||
it('quick-260930 Test 9d: ein schmaler Kalender mit direktem Nachbarn wird nur bis zum Nachbarn angehoben (keine neue Ueberlappung), am Rasterrand nur bis zum Rand', async () => {
|
||||
captured.props = null;
|
||||
const { DashboardGrid } = await import('./dashboard-grid');
|
||||
const layouts = {
|
||||
lg: [
|
||||
// Kalender w 8 (vor quick-260930 erlaubt), Uhr direkt rechts ab x 18.
|
||||
{ i: 'cal-1', x: 10, y: 0, w: 8, h: 16 },
|
||||
{ i: 'clk-1', x: 18, y: 4, w: 8, h: 4 },
|
||||
// Kalender am rechten Rand: x 40 + 11 > 48.
|
||||
{ i: 'cal-2', x: 40, y: 20, w: 8, h: 16 },
|
||||
// Nachbar liegt tiefer (keine Zeilenueberschneidung) -> kein Hindernis.
|
||||
{ i: 'cal-3', x: 0, y: 40, w: 8, h: 8 },
|
||||
{ i: 'clk-2', x: 8, y: 48, w: 8, h: 4 },
|
||||
],
|
||||
};
|
||||
render(
|
||||
<DashboardGrid
|
||||
layouts={layouts}
|
||||
widgets={[
|
||||
{ id: 'cal-1', widgetType: 'calendar', config: {} },
|
||||
{ id: 'clk-1', widgetType: 'clock', config: {} },
|
||||
{ id: 'cal-2', widgetType: 'calendar', config: {} },
|
||||
{ id: 'cal-3', widgetType: 'calendar', config: {} },
|
||||
{ id: 'clk-2', widgetType: 'clock', config: {} },
|
||||
]}
|
||||
isEditMode
|
||||
onLayoutChange={vi.fn()}
|
||||
onRemoveWidget={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
|
||||
expect(passed.lg[0]).toMatchObject({ i: 'cal-1', x: 10, w: 8, minW: 11 });
|
||||
expect(passed.lg[1]).toMatchObject({ i: 'clk-1', x: 18, w: 8 });
|
||||
expect(passed.lg[2]).toMatchObject({ i: 'cal-2', x: 40, w: 8, minW: 11 });
|
||||
expect(passed.lg[3]).toMatchObject({ i: 'cal-3', x: 0, w: 11, minW: 11 });
|
||||
});
|
||||
|
||||
it('quick-260930 Test 9e: minW und w werden auf die Spaltenzahl des Breakpoints begrenzt (xxs 4, xs 16)', async () => {
|
||||
captured.props = null;
|
||||
const { DashboardGrid } = await import('./dashboard-grid');
|
||||
const layouts = {
|
||||
xs: [{ i: 'srch-1', x: 0, y: 0, w: 24, h: 4 }],
|
||||
xxs: [
|
||||
{ i: 'srch-1', x: 0, y: 0, w: 24, h: 4, minW: 12 },
|
||||
{ i: 'cal-1', x: 0, y: 4, w: 2, h: 16 },
|
||||
],
|
||||
};
|
||||
render(
|
||||
<DashboardGrid
|
||||
layouts={layouts}
|
||||
widgets={[
|
||||
{ id: 'srch-1', widgetType: 'search', config: {} },
|
||||
{ id: 'cal-1', widgetType: 'calendar', config: {} },
|
||||
]}
|
||||
isEditMode
|
||||
onLayoutChange={vi.fn()}
|
||||
onRemoveWidget={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const passed = captured.props?.layouts as Record<string, Array<Record<string, unknown>>>;
|
||||
expect(passed.xs[0]).toMatchObject({ w: 16, minW: 12 });
|
||||
expect(passed.xxs[0]).toMatchObject({ w: 4, minW: 4 });
|
||||
// Kalender (minW 11) am xxs-Breakpoint: Minimum 4, Breite auf 4 angehoben.
|
||||
expect(passed.xxs[1]).toMatchObject({ w: 4, minW: 4 });
|
||||
});
|
||||
|
||||
const CANCEL =
|
||||
'input, textarea, select, button, a, [contenteditable], [data-no-drag], .widgetNoDrag';
|
||||
|
||||
it('quick-260916-dyv Test 6: dragConfig-Pin — handle Karte, cancel fuer Interaktives, threshold 3; resizeConfig folgt dem Bearbeitungsmodus', async () => {
|
||||
const { DashboardGrid } = await import('./dashboard-grid');
|
||||
const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }];
|
||||
const layouts = { lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] };
|
||||
const layouts = {
|
||||
lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }],
|
||||
md: [],
|
||||
sm: [],
|
||||
xs: [],
|
||||
xxs: [],
|
||||
};
|
||||
|
||||
captured.props = null;
|
||||
render(
|
||||
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={true} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
|
||||
<DashboardGrid
|
||||
layouts={layouts}
|
||||
widgets={widgets}
|
||||
isEditMode={true}
|
||||
onLayoutChange={vi.fn()}
|
||||
onRemoveWidget={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(captured.props?.dragConfig).toEqual({
|
||||
enabled: true,
|
||||
@@ -372,7 +457,13 @@ describe('DashboardGrid', () => {
|
||||
|
||||
captured.props = null;
|
||||
render(
|
||||
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={false} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
|
||||
<DashboardGrid
|
||||
layouts={layouts}
|
||||
widgets={widgets}
|
||||
isEditMode={false}
|
||||
onLayoutChange={vi.fn()}
|
||||
onRemoveWidget={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(captured.props?.dragConfig).toEqual({
|
||||
enabled: false,
|
||||
@@ -400,7 +491,10 @@ describe('DashboardGrid', () => {
|
||||
type: unknown;
|
||||
allowOverlap: boolean;
|
||||
preventCollision?: boolean;
|
||||
compact: (layout: Array<Record<string, unknown>>, cols: number) => Array<Record<string, unknown>>;
|
||||
compact: (
|
||||
layout: Array<Record<string, unknown>>,
|
||||
cols: number,
|
||||
) => Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(compactor).toMatchObject({ type: null, allowOverlap: false, preventCollision: true });
|
||||
expect(typeof compactor.compact).toBe('function');
|
||||
@@ -423,10 +517,22 @@ describe('DashboardGrid', () => {
|
||||
expect(WIDGET_DRAG_CANCEL_SELECTOR).toBe(CANCEL);
|
||||
|
||||
const widgets = [{ id: 'inst-2', widgetType: 'clock', config: {} }];
|
||||
const layouts = { lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [] };
|
||||
const layouts = {
|
||||
lg: [{ i: 'inst-2', x: 0, y: 0, w: 2, h: 2 }],
|
||||
md: [],
|
||||
sm: [],
|
||||
xs: [],
|
||||
xxs: [],
|
||||
};
|
||||
|
||||
render(
|
||||
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={true} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
|
||||
<DashboardGrid
|
||||
layouts={layouts}
|
||||
widgets={widgets}
|
||||
isEditMode={true}
|
||||
onLayoutChange={vi.fn()}
|
||||
onRemoveWidget={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const card = document.querySelector('[data-widget-id="inst-2"]') as HTMLElement;
|
||||
@@ -455,7 +561,13 @@ describe('DashboardGrid', () => {
|
||||
cleanup();
|
||||
|
||||
render(
|
||||
<DashboardGrid layouts={layouts} widgets={widgets} isEditMode={false} onLayoutChange={vi.fn()} onRemoveWidget={vi.fn()} />,
|
||||
<DashboardGrid
|
||||
layouts={layouts}
|
||||
widgets={widgets}
|
||||
isEditMode={false}
|
||||
onLayoutChange={vi.fn()}
|
||||
onRemoveWidget={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTitle('Drag the tile to move it')).toBeNull();
|
||||
expect(document.querySelector('.widget-drag-handle')).toBeNull();
|
||||
@@ -512,7 +624,9 @@ describe('DashboardGrid', () => {
|
||||
|
||||
expect(captured.props?.width).toBe(1000);
|
||||
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 1600, 800));
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(
|
||||
new DOMRect(0, 0, 1600, 800),
|
||||
);
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
});
|
||||
@@ -528,14 +642,155 @@ describe('RESIZE_AXIS_FALLBACK (Design „Mosaik“, Runde 3)', () => {
|
||||
{ i: 'fav', x: 0, y: 0, w: 6, h: 10 },
|
||||
{ i: 'below', x: 0, y: 10, w: 6, h: 8 },
|
||||
];
|
||||
const context = { layout } as unknown as Parameters<NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>>[4];
|
||||
const context = { layout } as unknown as Parameters<
|
||||
NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>
|
||||
>[4];
|
||||
const item = layout[0] as Parameters<NonNullable<typeof RESIZE_AXIS_FALLBACK.constrainSize>>[0];
|
||||
|
||||
it('uebernimmt die Breite, wenn die Maus beim Schmalerziehen eine Zeile nach unten wackelt', () => {
|
||||
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 2, 11, 'se', context)).toEqual({ w: 2, h: 10 });
|
||||
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 2, 11, 'se', context)).toEqual({
|
||||
w: 2,
|
||||
h: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('laesst eine freie Groesse unveraendert', () => {
|
||||
expect(RESIZE_AXIS_FALLBACK.constrainSize?.(item, 3, 9, 'se', context)).toEqual({ w: 3, h: 9 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Leinwand: Dashboard wie ein Bild mitskalieren', () => {
|
||||
const ONE = {
|
||||
layouts: { lg: [{ i: 'inst-1', x: 0, y: 0, w: 8, h: 4 }], md: [], sm: [], xs: [], xxs: [] },
|
||||
widgets: [{ id: 'inst-1', widgetType: 'clock', config: {} }],
|
||||
};
|
||||
// jsdom: innerHeight 768, getBoundingClientRect -> 0 -> verfuegbare Hoehe 748.
|
||||
const JSDOM_AVAILABLE_H = 768 - DASHBOARD_BOTTOM_GAP_PX;
|
||||
|
||||
function setInnerHeight(value: number) {
|
||||
Object.defineProperty(window, 'innerHeight', { value, configurable: true, writable: true });
|
||||
}
|
||||
afterEach(() => setInnerHeight(768));
|
||||
|
||||
async function renderGrid(props: {
|
||||
canvas?: { w: number; h: number } | null;
|
||||
onCaptureCanvas?: (area: { w: number; h: number }) => void;
|
||||
}) {
|
||||
captured.props = null;
|
||||
const { DashboardGrid } = await import('./dashboard-grid');
|
||||
const view = render(
|
||||
<DashboardGrid
|
||||
{...ONE}
|
||||
isEditMode={false}
|
||||
onLayoutChange={vi.fn()}
|
||||
onRemoveWidget={vi.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
const outer = view.container.firstElementChild as HTMLElement;
|
||||
const inner = outer.firstElementChild as HTMLElement;
|
||||
return { outer, inner };
|
||||
}
|
||||
|
||||
describe('computeCanvasScale', () => {
|
||||
it('breitenbegrenzt: freier Platz unten', () => {
|
||||
expect(computeCanvasScale({ w: 1280, h: 900 }, { w: 1600, h: 800 })).toBe(0.8);
|
||||
});
|
||||
it('hoehenbegrenzt: freier Platz rechts', () => {
|
||||
expect(computeCanvasScale({ w: 1920, h: 600 }, { w: 1600, h: 800 })).toBe(0.75);
|
||||
});
|
||||
it('Vergroesserung bei groesserem Bildschirm (s > 1)', () => {
|
||||
expect(computeCanvasScale({ w: 2400, h: 1300 }, { w: 1600, h: 800 })).toBe(1.5);
|
||||
});
|
||||
it('gleiche Flaeche -> 1', () => {
|
||||
expect(computeCanvasScale({ w: 1600, h: 800 }, { w: 1600, h: 800 })).toBe(1);
|
||||
});
|
||||
it('unbrauchbare Hoehe begrenzt nicht, dann zaehlt nur die Breite', () => {
|
||||
expect(computeCanvasScale({ w: 800, h: 0 }, { w: 1600, h: 800 })).toBe(0.5);
|
||||
expect(computeCanvasScale({ w: 800, h: Number.NaN }, { w: 1600, h: 800 })).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scaledPositionStrategy (Ziehen/Groesse aendern unter transform)', () => {
|
||||
it('s = 1 ist die Standardstrategie', () => {
|
||||
expect(scaledPositionStrategy(1)).toBe(defaultPositionStrategy);
|
||||
});
|
||||
|
||||
it('s != 1: transform-Strategie mit scale, OHNE calcDragPosition (RGL rechnet dann relativ zum Raster)', () => {
|
||||
const strategy = scaledPositionStrategy(0.5);
|
||||
expect(strategy.type).toBe('transform');
|
||||
expect(strategy.scale).toBe(0.5);
|
||||
expect(strategy.calcDragPosition).toBeUndefined();
|
||||
expect(strategy.calcStyle({ left: 10, top: 20, width: 30, height: 40 })).toEqual(
|
||||
defaultPositionStrategy.calcStyle({ left: 10, top: 20, width: 30, height: 40 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('Grund: createScaledStrategy rechnet den Ziehstart in Fensterkoordinaten (ohne Raster-Versatz)', () => {
|
||||
// Kachel sichtbar bei clientX 300 (Raster beginnt bei 250 neben der
|
||||
// Seitenleiste, Kachel also 50 px im Raster), Maus 10 px in der Kachel.
|
||||
const pos = createScaledStrategy(0.5).calcDragPosition?.(310, 110, 10, 10);
|
||||
// Richtig waere (300 - 250) / 0.5 = 100; geliefert wird 300 / 0.5 = 600.
|
||||
expect(pos?.left).toBe(600);
|
||||
});
|
||||
});
|
||||
|
||||
it('skalierter Modus: virtuelle Breite canvas.w, transform scale(s) links oben, sichtbare Hoehe = Rasterhoehe x s', async () => {
|
||||
stubResizeObserver({ width: 1000, height: 800 });
|
||||
const { outer, inner } = await renderGrid({ canvas: { w: 2000, h: 800 } });
|
||||
|
||||
// s = min(1000 / 2000, 748 / 800) = 0.5
|
||||
expect(captured.props?.width).toBe(2000);
|
||||
expect(inner.style.width).toBe('2000px');
|
||||
expect(inner.style.transform).toBe('scale(0.5)');
|
||||
expect(inner.style.transformOrigin).toBe('top left');
|
||||
expect(outer.style.height).toBe('400px');
|
||||
expect(outer.style.overflow).toBe('hidden');
|
||||
const strategy = captured.props?.positionStrategy as { scale: number };
|
||||
expect(strategy.scale).toBe(0.5);
|
||||
});
|
||||
|
||||
it('skalierter Modus hoehenbegrenzt und vergroessernd', async () => {
|
||||
stubResizeObserver({ width: 1000, height: 800 });
|
||||
setInnerHeight(420); // verfuegbar 400
|
||||
const first = await renderGrid({ canvas: { w: 1000, h: 800 } });
|
||||
expect(first.inner.style.transform).toBe('scale(0.5)');
|
||||
cleanup();
|
||||
|
||||
setInnerHeight(768);
|
||||
const second = await renderGrid({ canvas: { w: 800, h: 374 } });
|
||||
// min(1000 / 800, 748 / 374) = 1.25
|
||||
expect(second.inner.style.transform).toBe('scale(1.25)');
|
||||
expect(captured.props?.width).toBe(800);
|
||||
});
|
||||
|
||||
it('schmal (< 768): wie bisher — Containerbreite, kein transform, keine Erfassung', async () => {
|
||||
stubResizeObserver({ width: 600, height: 800 });
|
||||
const onCaptureCanvas = vi.fn();
|
||||
const { outer, inner } = await renderGrid({ canvas: { w: 1600, h: 800 }, onCaptureCanvas });
|
||||
expect(captured.props?.width).toBe(600);
|
||||
expect(inner.style.transform).toBe('');
|
||||
expect(outer.style.height).toBe('');
|
||||
expect(captured.props?.positionStrategy).toBe(defaultPositionStrategy);
|
||||
|
||||
cleanup();
|
||||
await renderGrid({ canvas: null, onCaptureCanvas });
|
||||
expect(onCaptureCanvas).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ohne Leinwand am Desktop: unskaliert und die verfuegbare Flaeche wird zum Erfassen gemeldet', async () => {
|
||||
stubResizeObserver({ width: 1000, height: 800 });
|
||||
const onCaptureCanvas = vi.fn();
|
||||
const { inner } = await renderGrid({ canvas: null, onCaptureCanvas });
|
||||
expect(captured.props?.width).toBe(1000);
|
||||
expect(inner.style.transform).toBe('');
|
||||
expect(onCaptureCanvas).toHaveBeenCalledWith({ w: 1000, h: JSDOM_AVAILABLE_H });
|
||||
});
|
||||
|
||||
it('mit Leinwand wird nicht erneut erfasst', async () => {
|
||||
stubResizeObserver({ width: 1000, height: 800 });
|
||||
const onCaptureCanvas = vi.fn();
|
||||
await renderGrid({ canvas: { w: 1000, h: 748 }, onCaptureCanvas });
|
||||
expect(onCaptureCanvas).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Responsive, noCompactor } from 'react-grid-layout';
|
||||
import type { Compactor, ResponsiveLayouts } from 'react-grid-layout';
|
||||
import { defaultConstraints, type LayoutConstraint } from 'react-grid-layout/core';
|
||||
import { noCompactor, Responsive } from 'react-grid-layout';
|
||||
import {
|
||||
defaultConstraints,
|
||||
defaultPositionStrategy,
|
||||
type LayoutConstraint,
|
||||
type PositionStrategy,
|
||||
transformStrategy,
|
||||
} from 'react-grid-layout/core';
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { WIDGET_CONSTRAINTS, WIDGET_REGISTRY, type WidgetType } from './widget-registry';
|
||||
import { CANVAS_MIN_HEIGHT, CANVAS_MIN_WIDTH, type GridCanvas } from '@/lib/grid-layout-migration';
|
||||
import {
|
||||
clampWidthToCols,
|
||||
GRID_COLS,
|
||||
WIDGET_CONSTRAINTS,
|
||||
WIDGET_REGISTRY,
|
||||
type WidgetType,
|
||||
} from './widget-registry';
|
||||
import { WidgetIcon } from './widgets/widget-icon';
|
||||
import { WidgetWrapper } from './widgets/widget-wrapper';
|
||||
|
||||
@@ -23,7 +36,9 @@ import { WidgetWrapper } from './widgets/widget-wrapper';
|
||||
// damit in 48stel-Einheiten; gespeicherte Anordnungen rechnet die Migration
|
||||
// einmalig um, jedes Widget behaelt seine Bildschirmgroesse und -position.
|
||||
const BREAKPOINTS = { lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 };
|
||||
const COLS = { lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 };
|
||||
// Spaltenzahlen stehen seit quick-260930 in widget-registry.tsx (GRID_COLS),
|
||||
// weil auch der Store sie fuer neue Kacheln braucht.
|
||||
const COLS = GRID_COLS;
|
||||
|
||||
// quick-260916-dyv: Ziehen zuverlaessig.
|
||||
// - Griff ist die GANZE Karte (`widget-drag-handle` in widget-wrapper.tsx).
|
||||
@@ -93,6 +108,66 @@ const GRID_CONSTRAINTS: LayoutConstraint[] = [...defaultConstraints, RESIZE_AXIS
|
||||
|
||||
const GRID_MARGIN = 12;
|
||||
|
||||
/**
|
||||
* Dashboard wie ein Bild mitskalieren (Leinwand).
|
||||
*
|
||||
* Wunsch: an PC 1 eingerichtet, an PC 2 mit anderer Aufloesung GENAU gleich
|
||||
* aussehen — alles (auch die Schrift) gleichmaessig kleiner oder groesser,
|
||||
* nie scrollen; passt die Bildschirmform nicht, bleibt rechts oder unten
|
||||
* Platz frei.
|
||||
*
|
||||
* Umsetzung: Jeder Reiter hat eine Leinwand `{ w, h }` (gespeichert als
|
||||
* `__canvas`, siehe grid-layout-migration.ts) — die verfuegbare Flaeche, auf
|
||||
* der er eingerichtet wurde (erfasst beim ersten Oeffnen am Desktop, danach
|
||||
* fest). Wo die Leinwand erfasst wurde, sieht das Dashboard 1:1 aus. Auf
|
||||
* jedem Desktop-Bildschirm zeichnet react-grid-layout das Raster mit der
|
||||
* VIRTUELLEN Breite `canvas.w` — Breakpoint und Spaltenbreite sind damit
|
||||
* ueberall identisch — und ein umgebendes Element verkleinert/vergroessert
|
||||
* das Ergebnis per `transform: scale(s)` mit
|
||||
* `s = min(Breite / canvas.w, Hoehe / canvas.h)` (computeCanvasScale),
|
||||
* links oben ausgerichtet (nicht zentriert, bewusst entfernt in bc4c011).
|
||||
*
|
||||
* Unter CANVAS_MIN_WIDTH (Handy/schmal) bleibt alles wie zuvor: Breite =
|
||||
* gemessene Containerbreite, kein transform, keine Erfassung.
|
||||
*/
|
||||
/** Abstand unter dem Raster bis zur Fensterkante: main p-3 (12) + Wrapper p-2 (8). */
|
||||
export const DASHBOARD_BOTTOM_GAP_PX = 20;
|
||||
|
||||
/**
|
||||
* Skalierungsfaktor der Leinwand fuer die verfuegbare Flaeche: das kleinere
|
||||
* der beiden Verhaeltnisse, damit alles hineinpasst (freier Platz rechts
|
||||
* ODER unten). Eine unbrauchbare Hoehe (<= 0, nicht endlich) begrenzt nicht —
|
||||
* dann entscheidet nur die Breite.
|
||||
*/
|
||||
export function computeCanvasScale(available: GridCanvas, canvas: GridCanvas): number {
|
||||
const byWidth = available.w / canvas.w;
|
||||
const byHeight = available.h / canvas.h;
|
||||
const heightUsable = Number.isFinite(byHeight) && byHeight > 0;
|
||||
const s = heightUsable ? Math.min(byWidth, byHeight) : byWidth;
|
||||
return Number.isFinite(s) && s > 0 ? s : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Positionsstrategie fuer ein per `transform: scale(s)` skaliertes Raster.
|
||||
*
|
||||
* Bewusst NICHT `createScaledStrategy(s)` aus react-grid-layout 2.2.3: deren
|
||||
* `calcDragPosition` rechnet `(clientX - offsetX) / scale` =
|
||||
* `clientRect.left / scale` (chunk-KDANGDDL.mjs:315-320) — eine FENSTER-
|
||||
* Koordinate ohne Abzug der Raster-Oberkante/-Linkskante. Im Portal liegt das
|
||||
* Raster rechts neben der Seitenleiste; die Kachel spraenge beim Ziehstart um
|
||||
* (Seitenleiste + Abstand) / s Pixel nach rechts unten. Ohne
|
||||
* `calcDragPosition` nimmt react-grid-layout den eigenen Weg
|
||||
* (chunk-WGL5FSZH.mjs:146-163): `(clientRect.left - parentRect.left) /
|
||||
* transformScale` — relativ zum Raster und korrekt entskaliert. Die
|
||||
* Mausbewegungen teilen `DraggableCore` (Prop `scale`) bzw. `Resizable`
|
||||
* (`deltaX / transformScale`) selbst durch `scale`; beide lesen ihn aus
|
||||
* `positionStrategy.scale` (chunk-WGL5FSZH.mjs:675, 512, 527).
|
||||
*/
|
||||
export function scaledPositionStrategy(scale: number): PositionStrategy {
|
||||
if (scale === 1) return defaultPositionStrategy;
|
||||
return { type: 'transform', scale, calcStyle: transformStrategy.calcStyle };
|
||||
}
|
||||
|
||||
interface LayoutItemShape {
|
||||
i: string;
|
||||
x: number;
|
||||
@@ -113,6 +188,10 @@ interface DashboardGridProps {
|
||||
onOpenCatalog?: () => void;
|
||||
/** Leerer Zustand: fuegt eine vorgeschlagene Kachel direkt hinzu. */
|
||||
onQuickAdd?: (type: WidgetType) => void;
|
||||
/** Leinwand des aktiven Reiters; null = noch nicht erfasst (dann unskaliert). */
|
||||
canvas?: GridCanvas | null;
|
||||
/** Wird mit der verfuegbaren Flaeche gerufen, solange `canvas` fehlt (nur Desktop). */
|
||||
onCaptureCanvas?: (area: GridCanvas) => void;
|
||||
}
|
||||
|
||||
/** Vorschlaege im leeren Zustand — Plattform-Kacheln, immer verfuegbar. */
|
||||
@@ -138,6 +217,17 @@ const QUICK_ADD_TYPES: WidgetType[] = ['clock', 'calendar', 'note'];
|
||||
* chunk-WGL5FSZH.mjs:472-475), rendert die zu kleine Kachel aber woertlich —
|
||||
* der Rechner bliebe bis zum ersten Anfassen unten abgeschnitten.
|
||||
* Unbekannte Typen bleiben unveraendert; das Eingabeobjekt wird nicht mutiert.
|
||||
*
|
||||
* quick-260930: minW je Breakpoint auf dessen Spaltenzahl begrenzt (Suche
|
||||
* minW 12 am xxs-Breakpoint mit 4 Spalten), w ebenso. Die BREITE wird nur so
|
||||
* weit angehoben, wie rechts Platz ist — innerhalb des Rasters und ohne eine
|
||||
* NEUE Ueberlappung mit einem Nachbarn (freie Platzierung ohne Kompaktierung
|
||||
* wuerde sie sonst stehen lassen; Anlass: Kalender-minW 8 -> 11, schmal
|
||||
* gezogene Kalender mit direktem Nachbarn). Bleibt die Kachel dadurch unter
|
||||
* dem Minimum, ist das harmlos: react-grid-layout zeichnet sie woertlich und
|
||||
* klemmt erst beim naechsten Groessenziehen; stoesst das an den Nachbarn,
|
||||
* greift RESIZE_AXIS_FALLBACK (nur die Hoehe aendert sich), bis der Nachbar
|
||||
* verschoben ist.
|
||||
*/
|
||||
function applyConstraintMinima(
|
||||
layouts: Record<string, Array<LayoutItemShape>>,
|
||||
@@ -150,18 +240,42 @@ function applyConstraintMinima(
|
||||
for (const key of Object.keys(layouts)) {
|
||||
const entries = layouts[key];
|
||||
if (!Array.isArray(entries)) continue;
|
||||
result[key] = entries.map((entry) => {
|
||||
const cols = GRID_COLS[key] ?? Number.POSITIVE_INFINITY;
|
||||
// Zielbreite je Eintrag merken; angehoben wird im zweiten Durchgang,
|
||||
// wenn alle Hoehen feststehen (die Kollisionspruefung braucht sie).
|
||||
const targetW = new Map<string, number>();
|
||||
const next = entries.map((entry) => {
|
||||
const type = typeById.get(entry.i);
|
||||
const constraints = type ? WIDGET_CONSTRAINTS[type as WidgetType] : undefined;
|
||||
if (!constraints) return { ...entry };
|
||||
const minW = clampWidthToCols(constraints.minW, key);
|
||||
const w = Math.min(entry.w, cols);
|
||||
if (w < minW) targetW.set(entry.i, minW);
|
||||
return {
|
||||
...entry,
|
||||
w: Math.max(entry.w, constraints.minW),
|
||||
w,
|
||||
h: Math.max(entry.h, constraints.minH),
|
||||
minW: constraints.minW,
|
||||
minW,
|
||||
minH: constraints.minH,
|
||||
};
|
||||
});
|
||||
|
||||
for (const item of next) {
|
||||
const target = targetW.get(item.i);
|
||||
if (target === undefined) continue;
|
||||
const others = next.filter((o) => o !== item);
|
||||
// Schon bestehende Ueberlappungen zaehlen nicht als Hindernis — sie
|
||||
// entstehen nicht durch das Anheben.
|
||||
const blockers = others.filter((o) => !overlaps(item, o));
|
||||
while (
|
||||
item.w < target &&
|
||||
item.x + item.w + 1 <= cols &&
|
||||
!blockers.some((o) => overlaps({ ...item, w: item.w + 1 }, o))
|
||||
) {
|
||||
item.w += 1;
|
||||
}
|
||||
}
|
||||
result[key] = next;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -181,9 +295,15 @@ export function DashboardGrid({
|
||||
onRemoveWidget,
|
||||
onOpenCatalog,
|
||||
onQuickAdd,
|
||||
canvas = null,
|
||||
onCaptureCanvas,
|
||||
}: DashboardGridProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const [width, setWidth] = useState(1200);
|
||||
// Verfuegbare Hoehe bis zur unteren Fensterkante (Leinwand). 0 = unbekannt.
|
||||
const [availableHeight, setAvailableHeight] = useState(0);
|
||||
// Unskalierte Hoehe des Rasters (inneres Element) fuer die sichtbare Hoehe.
|
||||
const [innerHeight, setInnerHeight] = useState(0);
|
||||
|
||||
// quick-260922-vdk: Messung haengt am eingehaengten Knoten (Ref-Rueckruf),
|
||||
// nicht mehr an einem Effekt mit leerer Abhaengigkeitsliste.
|
||||
@@ -222,6 +342,16 @@ export function DashboardGrid({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Leinwand: Hoehe = Fensterhoehe minus Oberkante des Rasters im DOKUMENT
|
||||
// (rect.top + scrollY, also unabhaengig vom Scrollstand) minus Abstand
|
||||
// unten. Das aeussere Element ist selbst nicht transformiert, seine
|
||||
// Oberkante ist also die echte.
|
||||
const applyHeight = useCallback((node: HTMLElement) => {
|
||||
const top = node.getBoundingClientRect().top + window.scrollY;
|
||||
const next = window.innerHeight - top - DASHBOARD_BOTTOM_GAP_PX;
|
||||
setAvailableHeight(Number.isFinite(next) && next > 0 ? Math.floor(next) : 0);
|
||||
}, []);
|
||||
|
||||
const measureRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
// Ein eventuell laufender Beobachter zuerst trennen — auch der
|
||||
@@ -234,32 +364,72 @@ export function DashboardGrid({
|
||||
|
||||
// Synchrone Erstmessung in der Commit-Phase, vor dem ersten Zeichnen.
|
||||
applyWidth(node.getBoundingClientRect().width);
|
||||
applyHeight(node);
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
applyWidth(entries[0].contentRect.width);
|
||||
applyHeight(node);
|
||||
});
|
||||
observer.observe(node);
|
||||
observerRef.current = observer;
|
||||
// Bewusst keine Aufraeumfunktion zurueckgeben: React 19 ruft den
|
||||
// Ref-Rueckruf sonst beim Aushaengen nicht mehr mit null auf.
|
||||
},
|
||||
[applyWidth],
|
||||
[applyWidth, applyHeight],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
if (nodeRef.current) {
|
||||
applyWidth(nodeRef.current.getBoundingClientRect().width);
|
||||
applyHeight(nodeRef.current);
|
||||
}
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [applyWidth]);
|
||||
}, [applyWidth, applyHeight]);
|
||||
|
||||
// Unskalierte Hoehe des inneren Elements (react-grid-layout setzt die
|
||||
// Rasterhoehe selbst). `contentRect`/`offsetHeight` sind Layout-Masse und
|
||||
// vom transform unberuehrt — genau das Gewuenschte.
|
||||
const innerObserverRef = useRef<ResizeObserver | null>(null);
|
||||
const innerRef = useCallback((node: HTMLDivElement | null) => {
|
||||
innerObserverRef.current?.disconnect();
|
||||
innerObserverRef.current = null;
|
||||
if (!node) return;
|
||||
if (node.offsetHeight > 0) setInnerHeight(node.offsetHeight);
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const h = entries[0].contentRect.height;
|
||||
if (Number.isFinite(h) && h > 0) setInnerHeight(h);
|
||||
});
|
||||
observer.observe(node);
|
||||
innerObserverRef.current = observer;
|
||||
}, []);
|
||||
|
||||
// Desktop = verfuegbare Breite ab CANVAS_MIN_WIDTH; nur dort Leinwand.
|
||||
const isDesktop = width >= CANVAS_MIN_WIDTH;
|
||||
const scaled = isDesktop && canvas !== null;
|
||||
const scale = scaled ? computeCanvasScale({ w: width, h: availableHeight }, canvas) : 1;
|
||||
const positionStrategy = useMemo(() => scaledPositionStrategy(scale), [scale]);
|
||||
|
||||
// Leinwand erfassen: nur mit eingehaengtem Raster (also mit Kacheln), am
|
||||
// Desktop und mit brauchbarer Hoehe. Der Store uebernimmt sie einmal und
|
||||
// ignoriert weitere Aufrufe; danach ist `canvas` gesetzt und dieser
|
||||
// Effekt tut nichts mehr.
|
||||
const hasWidgets = widgets.length > 0;
|
||||
useEffect(() => {
|
||||
if (!onCaptureCanvas || canvas !== null || !hasWidgets) return;
|
||||
if (!isDesktop || availableHeight < CANVAS_MIN_HEIGHT) return;
|
||||
onCaptureCanvas({ w: width, h: availableHeight });
|
||||
}, [onCaptureCanvas, canvas, hasWidgets, isDesktop, width, availableHeight]);
|
||||
|
||||
// quick-260916-dyv: minW/minH (und zu kleine w/h) aus WIDGET_CONSTRAINTS —
|
||||
// siehe applyConstraintMinima. Vor dem Leerzustand, damit die Hook-Reihenfolge
|
||||
// stabil bleibt.
|
||||
const effectiveLayouts = useMemo(() => applyConstraintMinima(layouts, widgets), [layouts, widgets]);
|
||||
const effectiveLayouts = useMemo(
|
||||
() => applyConstraintMinima(layouts, widgets),
|
||||
[layouts, widgets],
|
||||
);
|
||||
|
||||
// Leerer Zustand (D-02, Design „Mosaik“ Runde 2): freundlich ohne
|
||||
// Illustration — Titel, ein Satz, drei Vorschlaege zum direkten
|
||||
@@ -269,7 +439,18 @@ export function DashboardGrid({
|
||||
<div className="flex min-h-[55vh] flex-col items-center justify-center px-4 text-center">
|
||||
<div className="dashboard-chrome flex max-w-md flex-col items-center rounded-xl px-6 py-8">
|
||||
<span className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="7" height="9" rx="1.5" />
|
||||
<rect x="14" y="3" width="7" height="5" rx="1.5" />
|
||||
<rect x="14" y="12" width="7" height="9" rx="1.5" />
|
||||
@@ -296,7 +477,19 @@ export function DashboardGrid({
|
||||
)}
|
||||
{onOpenCatalog && (
|
||||
<button type="button" onClick={onOpenCatalog} className="btn btn-primary mt-5">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 5v14M5 12h14" /></svg>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
{t('addWidget')}
|
||||
</button>
|
||||
)}
|
||||
@@ -305,56 +498,72 @@ export function DashboardGrid({
|
||||
);
|
||||
}
|
||||
|
||||
// Gleiche DOM-Struktur in beiden Modi, damit react-grid-layout beim
|
||||
// Wechsel (etwa direkt nach dem Erfassen, s = 1) nicht neu einhaengt.
|
||||
// Aeusseres Element: volle Breite (Messung), im skalierten Modus die
|
||||
// sichtbare Hoehe = unskalierte Rasterhoehe x s und `overflow: hidden` —
|
||||
// der transform aendert die Layout-Box nicht, ohne beides bliebe toter
|
||||
// Scrollraum (s < 1) bzw. ragte die 1:1-Breite seitlich hinaus.
|
||||
const outerStyle: React.CSSProperties | undefined = scaled
|
||||
? { overflow: 'hidden', ...(innerHeight > 0 ? { height: innerHeight * scale } : {}) }
|
||||
: undefined;
|
||||
const innerStyle: React.CSSProperties | undefined = scaled
|
||||
? { width: canvas.w, transform: `scale(${scale})`, transformOrigin: 'top left' }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div ref={measureRef}>
|
||||
<Responsive
|
||||
width={width}
|
||||
breakpoints={BREAKPOINTS}
|
||||
cols={COLS}
|
||||
layouts={effectiveLayouts as ResponsiveLayouts}
|
||||
rowHeight={20}
|
||||
margin={[GRID_MARGIN, GRID_MARGIN] as [number, number]}
|
||||
constraints={GRID_CONSTRAINTS}
|
||||
// containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt
|
||||
// dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin).
|
||||
dragConfig={{
|
||||
enabled: isEditMode,
|
||||
handle: WIDGET_DRAG_HANDLE_SELECTOR,
|
||||
cancel: WIDGET_DRAG_CANCEL_SELECTOR,
|
||||
threshold: 3,
|
||||
}}
|
||||
resizeConfig={{
|
||||
enabled: isEditMode,
|
||||
}}
|
||||
compactor={FREE_PLACEMENT_COMPACTOR}
|
||||
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
|
||||
>
|
||||
{widgets.map((widget, index) => {
|
||||
const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType];
|
||||
return (
|
||||
<div
|
||||
key={widget.id}
|
||||
data-grid={{
|
||||
...(effectiveLayouts.lg?.find((l) => l.i === widget.id) ?? {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: constraints?.defaultW ?? 8,
|
||||
h: constraints?.defaultH ?? 4,
|
||||
}),
|
||||
minW: constraints?.minW ?? 8,
|
||||
minH: constraints?.minH ?? 4,
|
||||
}}
|
||||
>
|
||||
<WidgetWrapper
|
||||
widget={widget}
|
||||
isEditMode={isEditMode}
|
||||
onRemove={onRemoveWidget}
|
||||
enterIndex={index}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Responsive>
|
||||
<div ref={measureRef} style={outerStyle} data-canvas-scale={scaled ? scale : undefined}>
|
||||
<div ref={innerRef} style={innerStyle}>
|
||||
<Responsive
|
||||
width={scaled ? canvas.w : width}
|
||||
positionStrategy={positionStrategy}
|
||||
breakpoints={BREAKPOINTS}
|
||||
cols={COLS}
|
||||
layouts={effectiveLayouts as ResponsiveLayouts}
|
||||
rowHeight={20}
|
||||
margin={[GRID_MARGIN, GRID_MARGIN] as [number, number]}
|
||||
constraints={GRID_CONSTRAINTS}
|
||||
// containerPadding bewusst nicht gesetzt: react-grid-layout 2.2.3 nimmt
|
||||
// dann das margin (gemessen: effectiveContainerPadding = containerPadding ?? margin).
|
||||
dragConfig={{
|
||||
enabled: isEditMode,
|
||||
handle: WIDGET_DRAG_HANDLE_SELECTOR,
|
||||
cancel: WIDGET_DRAG_CANCEL_SELECTOR,
|
||||
threshold: 3,
|
||||
}}
|
||||
resizeConfig={{
|
||||
enabled: isEditMode,
|
||||
}}
|
||||
compactor={FREE_PLACEMENT_COMPACTOR}
|
||||
onLayoutChange={(_currentLayout, allLayouts) => onLayoutChange(allLayouts)}
|
||||
>
|
||||
{widgets.map((widget, index) => {
|
||||
const constraints = WIDGET_CONSTRAINTS[widget.widgetType as WidgetType];
|
||||
return (
|
||||
<div
|
||||
key={widget.id}
|
||||
data-grid={{
|
||||
...(effectiveLayouts.lg?.find((l) => l.i === widget.id) ?? {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: constraints?.defaultW ?? 8,
|
||||
h: constraints?.defaultH ?? 4,
|
||||
}),
|
||||
minW: constraints?.minW ?? 8,
|
||||
minH: constraints?.minH ?? 4,
|
||||
}}
|
||||
>
|
||||
<WidgetWrapper
|
||||
widget={widget}
|
||||
isEditMode={isEditMode}
|
||||
onRemove={onRemoveWidget}
|
||||
enterIndex={index}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Responsive>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// quick-260930: Stammt die Anordnung von einem neueren Programmstand, ist der
|
||||
// Stift gesperrt und bittet ums Neuladen.
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const storeState = vi.hoisted(() => ({ layoutFromNewerVersion: false }));
|
||||
vi.mock('@/lib/stores/dashboard-store', () => ({
|
||||
useDashboardStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
|
||||
}));
|
||||
|
||||
import { EditModeToggle } from './edit-mode-toggle';
|
||||
|
||||
beforeEach(() => {
|
||||
storeState.layoutFromNewerVersion = false;
|
||||
});
|
||||
|
||||
describe('EditModeToggle (quick-260930)', () => {
|
||||
it('normal: Stift ist bedienbar', () => {
|
||||
const onToggle = vi.fn();
|
||||
render(<EditModeToggle isEditMode={false} onToggle={onToggle} />);
|
||||
const button = screen.getByRole('button', { name: 'editShort' });
|
||||
expect(button).toBeEnabled();
|
||||
fireEvent.click(button);
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Anordnung eines neueren Programmstands: gesperrt, Hinweis „neu laden“ als Beschreibung und Tooltip', () => {
|
||||
storeState.layoutFromNewerVersion = true;
|
||||
const onToggle = vi.fn();
|
||||
render(<EditModeToggle isEditMode={false} onToggle={onToggle} />);
|
||||
const button = screen.getByRole('button', { name: 'editShort' });
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveAttribute('title', 'reloadRequired');
|
||||
expect(button).toHaveAccessibleDescription('reloadRequired');
|
||||
fireEvent.click(button);
|
||||
expect(onToggle).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useId } from 'react';
|
||||
import { useDashboardStore } from '@/lib/stores/dashboard-store';
|
||||
|
||||
interface EditModeToggleProps {
|
||||
isEditMode: boolean;
|
||||
@@ -13,55 +15,72 @@ interface EditModeToggleProps {
|
||||
* Sitzt seit quick-260928-vxe rechts in der dunklen App-Leiste: in der
|
||||
* Ansicht nur der Stift (Name per aria-label/Tooltip), im Bearbeitungsmodus
|
||||
* der gelbe Knopf „Fertig“.
|
||||
*
|
||||
* quick-260930: Stammt die geladene Anordnung von einem neueren
|
||||
* Programmstand (`layoutFromNewerVersion` im Store), ist der Stift gesperrt
|
||||
* und nennt als Beschreibung den Grund („Bitte laden Sie die Seite neu“) —
|
||||
* Speichern wuerde die Anordnung sonst mit der alten Rastermarke
|
||||
* ueberschreiben.
|
||||
*/
|
||||
export function EditModeToggle({ isEditMode, onToggle }: EditModeToggleProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const locked = useDashboardStore((state) => state.layoutFromNewerVersion) && !isEditMode;
|
||||
const hintId = useId();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={`btn ${isEditMode ? 'btn-primary' : 'btn-appbar btn-icon'}`}
|
||||
aria-pressed={isEditMode}
|
||||
aria-label={isEditMode ? undefined : t('editShort')}
|
||||
title={isEditMode ? t('saveChanges') : t('editMode')}
|
||||
>
|
||||
{isEditMode ? (
|
||||
// Checkmark icon
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
) : null}
|
||||
{isEditMode ? null : (
|
||||
// Pencil icon
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
disabled={locked}
|
||||
className={`btn ${isEditMode ? 'btn-primary' : 'btn-appbar btn-icon'} ${locked ? 'cursor-not-allowed opacity-60' : ''}`}
|
||||
aria-pressed={isEditMode}
|
||||
aria-label={isEditMode ? undefined : t('editShort')}
|
||||
aria-describedby={locked ? hintId : undefined}
|
||||
title={locked ? t('reloadRequired') : isEditMode ? t('saveChanges') : t('editMode')}
|
||||
>
|
||||
{isEditMode ? (
|
||||
// Checkmark icon
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
) : null}
|
||||
{isEditMode ? null : (
|
||||
// Pencil icon
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
)}
|
||||
{isEditMode ? t('done') : null}
|
||||
</button>
|
||||
{locked && (
|
||||
<span id={hintId} className="sr-only">
|
||||
{t('reloadRequired')}
|
||||
</span>
|
||||
)}
|
||||
{isEditMode ? t('done') : null}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,11 +151,11 @@ describe('WidgetCatalogModal: Liste kommt aus der Registry (quick-260922-m1h)',
|
||||
).map((c) => c.getAttribute('data-widget-type'));
|
||||
}
|
||||
|
||||
it("zeigt mit Zugriff auf 'proxmox' alle zehn Kacheln in der Reihenfolge der Registry, Proxmox zuletzt", () => {
|
||||
it("zeigt mit Zugriff auf 'proxmox' alle elf Kacheln in der Reihenfolge der Registry, Erinnerungen zuletzt", () => {
|
||||
render(<WidgetCatalogModal {...baseProps} accessibleModuleSlugs={['proxmox']} />);
|
||||
|
||||
expect(catalogTypes()).toEqual([...WIDGET_TYPES]);
|
||||
expect(catalogTypes().at(-1)).toBe('proxmox');
|
||||
expect(catalogTypes().at(-1)).toBe('reminder');
|
||||
expect(Object.keys(WIDGET_REGISTRY)).toEqual([...WIDGET_TYPES]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { WIDGET_TYPES } from '@tessera/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
clampWidthToCols,
|
||||
GRID_COLS,
|
||||
registerWidget,
|
||||
visibleWidgetTypes,
|
||||
WIDGET_CONSTRAINTS,
|
||||
WIDGET_REGISTRY,
|
||||
type WidgetDefinition,
|
||||
type WidgetProps,
|
||||
type WidgetType,
|
||||
registerWidget,
|
||||
visibleWidgetTypes,
|
||||
} from './widget-registry';
|
||||
|
||||
/**
|
||||
@@ -31,34 +33,38 @@ const ALL_WIDGET_TYPES: WidgetType[] = [
|
||||
'xframe',
|
||||
// Proxmox — erste Modul-Kachel (quick-260924-i8v)
|
||||
'proxmox',
|
||||
// Erinnerungen (quick-260929-if2)
|
||||
'reminder',
|
||||
];
|
||||
|
||||
describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
it('contains entries for all expected widget types', () => {
|
||||
for (const type of ALL_WIDGET_TYPES) {
|
||||
expect(
|
||||
WIDGET_CONSTRAINTS,
|
||||
`WIDGET_CONSTRAINTS must contain key "${type}"`,
|
||||
).toHaveProperty(type);
|
||||
expect(WIDGET_CONSTRAINTS, `WIDGET_CONSTRAINTS must contain key "${type}"`).toHaveProperty(
|
||||
type,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(ALL_WIDGET_TYPES)(
|
||||
'WIDGET_CONSTRAINTS["%s"] has numeric minW, minH, defaultW, defaultH',
|
||||
(type) => {
|
||||
const constraints = WIDGET_CONSTRAINTS[type];
|
||||
it.each(
|
||||
ALL_WIDGET_TYPES,
|
||||
)('WIDGET_CONSTRAINTS["%s"] has numeric minW, minH, defaultW, defaultH', (type) => {
|
||||
const constraints = WIDGET_CONSTRAINTS[type];
|
||||
|
||||
expect(typeof constraints.minW, `${type}.minW must be a number`).toBe('number');
|
||||
expect(typeof constraints.minH, `${type}.minH must be a number`).toBe('number');
|
||||
expect(typeof constraints.defaultW, `${type}.defaultW must be a number`).toBe('number');
|
||||
expect(typeof constraints.defaultH, `${type}.defaultH must be a number`).toBe('number');
|
||||
expect(typeof constraints.minW, `${type}.minW must be a number`).toBe('number');
|
||||
expect(typeof constraints.minH, `${type}.minH must be a number`).toBe('number');
|
||||
expect(typeof constraints.defaultW, `${type}.defaultW must be a number`).toBe('number');
|
||||
expect(typeof constraints.defaultH, `${type}.defaultH must be a number`).toBe('number');
|
||||
|
||||
expect(constraints.minW, `${type}.minW must be >= 1`).toBeGreaterThanOrEqual(1);
|
||||
expect(constraints.minH, `${type}.minH must be >= 1`).toBeGreaterThanOrEqual(1);
|
||||
expect(constraints.defaultW, `${type}.defaultW >= minW`).toBeGreaterThanOrEqual(constraints.minW);
|
||||
expect(constraints.defaultH, `${type}.defaultH >= minH`).toBeGreaterThanOrEqual(constraints.minH);
|
||||
},
|
||||
);
|
||||
expect(constraints.minW, `${type}.minW must be >= 1`).toBeGreaterThanOrEqual(1);
|
||||
expect(constraints.minH, `${type}.minH must be >= 1`).toBeGreaterThanOrEqual(1);
|
||||
expect(constraints.defaultW, `${type}.defaultW >= minW`).toBeGreaterThanOrEqual(
|
||||
constraints.minW,
|
||||
);
|
||||
expect(constraints.defaultH, `${type}.defaultH >= minH`).toBeGreaterThanOrEqual(
|
||||
constraints.minH,
|
||||
);
|
||||
});
|
||||
|
||||
it('WIDGET_CONSTRAINTS contains new Phase-8 keys', () => {
|
||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('calculator');
|
||||
@@ -66,15 +72,16 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toContain('stopwatch');
|
||||
});
|
||||
|
||||
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu, zehn Typen)', () => {
|
||||
it('Test A (quick-260916-dyv): Minima = kleinste bedienbare Kachel je Typ, Vorgaben unveraendert (quick-260916-iex: Link-Widget entfernt; quick-260921-pi9: Bilderrahmen dazu; quick-260921-qd3: XFrame dazu; quick-260924-i8v: Proxmox dazu; quick-260929-if2: Erinnerungen dazu, elf Typen)', () => {
|
||||
// Raster 48 Spalten / 20 px (quick-260929-dmx, vorher 24 Spalten). Alle
|
||||
// Breitenwerte sind gegenueber dem 24er-Raster verdoppelt (gleiche
|
||||
// Bildschirmbreite), Hoehen unveraendert; einzige Ausnahme Kalender-minW 8
|
||||
// (rund 250 px, gemessene kleinste benutzbare Breite; vorher 6 von 24).
|
||||
// Bildschirmbreite), Hoehen unveraendert; einzige Ausnahme Kalender-minW
|
||||
// (quick-260930: 11 = rund 260 px an der schmalsten lg-Breite; das
|
||||
// vorige 8 ergab nur rund 186 px; vorher 6 von 24).
|
||||
expect(WIDGET_CONSTRAINTS).toEqual({
|
||||
clock: { minW: 4, minH: 2, defaultW: 8, defaultH: 4 },
|
||||
search: { minW: 12, minH: 2, defaultW: 24, defaultH: 4 },
|
||||
calendar: { minW: 8, minH: 8, defaultW: 16, defaultH: 16 },
|
||||
calendar: { minW: 11, minH: 8, defaultW: 16, defaultH: 16 },
|
||||
note: { minW: 8, minH: 4, defaultW: 12, defaultH: 8 },
|
||||
calculator: { minW: 6, minH: 10, defaultW: 12, defaultH: 10 },
|
||||
favorites: { minW: 2, minH: 3, defaultW: 12, defaultH: 10 },
|
||||
@@ -82,6 +89,7 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
'picture-frame': { minW: 8, minH: 4, defaultW: 16, defaultH: 12 },
|
||||
xframe: { minW: 8, minH: 4, defaultW: 24, defaultH: 12 },
|
||||
proxmox: { minW: 6, minH: 4, defaultW: 16, defaultH: 8 },
|
||||
reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 },
|
||||
});
|
||||
|
||||
let counted = 0;
|
||||
@@ -91,7 +99,39 @@ describe('WIDGET_CONSTRAINTS (DASH-11)', () => {
|
||||
counted++;
|
||||
}
|
||||
}
|
||||
expect(counted).toBe(40);
|
||||
expect(counted).toBe(44);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kalender-minW (quick-260930)', () => {
|
||||
it('ergibt an der schmalsten lg-Breite (1200 px, 48 Spalten, 12 px Abstand) mindestens 250 px', () => {
|
||||
const margin = 12;
|
||||
const cols = GRID_COLS.lg;
|
||||
const colWidth = (1200 - 2 * margin - (cols - 1) * margin) / cols;
|
||||
const px = (w: number) => w * colWidth + (w - 1) * margin;
|
||||
const { minW } = WIDGET_CONSTRAINTS.calendar;
|
||||
expect(px(minW)).toBeGreaterThanOrEqual(250);
|
||||
// und nicht unnoetig breit: eine Spalte weniger laege unter 250 px
|
||||
expect(px(minW - 1)).toBeLessThan(250);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clampWidthToCols (quick-260930)', () => {
|
||||
it('begrenzt Breiten auf die Spaltenzahl des Breakpoints', () => {
|
||||
expect(clampWidthToCols(24, 'xxs')).toBe(4);
|
||||
expect(clampWidthToCols(24, 'xs')).toBe(16);
|
||||
expect(clampWidthToCols(24, 'lg')).toBe(24);
|
||||
expect(clampWidthToCols(3, 'xxs')).toBe(3);
|
||||
expect(clampWidthToCols(99, 'unbekannt')).toBe(99);
|
||||
});
|
||||
|
||||
it('kein Minimum und keine Vorgabe ist nach der Begrenzung groesser als die Spaltenzahl', () => {
|
||||
for (const bp of Object.keys(GRID_COLS)) {
|
||||
for (const c of Object.values(WIDGET_CONSTRAINTS)) {
|
||||
expect(clampWidthToCols(c.minW, bp)).toBeLessThanOrEqual(GRID_COLS[bp]);
|
||||
expect(clampWidthToCols(c.defaultW, bp)).toBeLessThanOrEqual(GRID_COLS[bp]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,7 +147,7 @@ describe('Typliste ist an einer Stelle definiert (quick-260922-m1h)', () => {
|
||||
expect(Object.keys(WIDGET_CONSTRAINTS)).toEqual([...WIDGET_TYPES]);
|
||||
});
|
||||
|
||||
it('die zehn erwarteten Kacheln stehen unveraendert und in unveraenderter Reihenfolge in WIDGET_TYPES', () => {
|
||||
it('die elf erwarteten Kacheln stehen unveraendert und in unveraenderter Reihenfolge in WIDGET_TYPES', () => {
|
||||
expect([...WIDGET_TYPES]).toEqual(ALL_WIDGET_TYPES);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { WIDGET_MODULE_SLUGS, type WidgetType } from '@tessera/shared';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
/**
|
||||
* Die Typliste der Kacheln steht seit quick-260922-m1h EINMAL, in
|
||||
@@ -19,6 +19,7 @@ import { WIDGET_MODULE_SLUGS, type WidgetType } from '@tessera/shared';
|
||||
* picture-frame: Bilderrahmen (quick-260921-pi9).
|
||||
* xframe: Webseite als Rahmen (quick-260921-qd3).
|
||||
* proxmox: Zustand der Proxmox-Server, erste Modul-Kachel (quick-260924-i8v).
|
||||
* reminder: persoenliche Erinnerungen mit Benachrichtigung (quick-260929-if2).
|
||||
*/
|
||||
export type { WidgetType };
|
||||
|
||||
@@ -56,11 +57,17 @@ export const WIDGET_CONSTRAINTS: Record<
|
||||
// fuer Nav-Zeile + Wochentagskopf + 6 Rasterzeilen + Naechste-Termine-Block.
|
||||
// Runde 3 (Design „Mosaik“): defaultH 16 statt 12 — mit 12 passte unter
|
||||
// den Monat nur ein Termin.
|
||||
// quick-260929-dmx: auf Nutzerwunsch (29.09.) schmaler ziehbar — minW 8 von
|
||||
// 48 Spalten = rund 250 px am lg-Breakpoint (gemessen: Monatsraster,
|
||||
// Ueberschrift und Terminliste sind dort noch benutzbar; bei rund 185 px
|
||||
// wird die Ueberschrift abgeschnitten). defaultW 16 = unveraenderte Breite.
|
||||
calendar: { minW: 8, minH: 8, defaultW: 16, defaultH: 16 },
|
||||
// quick-260929-dmx: auf Nutzerwunsch (29.09.) schmaler ziehbar. Ziel sind
|
||||
// rund 250 px (Monatsraster, Ueberschrift und Terminliste noch benutzbar;
|
||||
// bei rund 185 px wird die Ueberschrift abgeschnitten). quick-260930: das
|
||||
// damalige minW 8 ergab am lg-Breakpoint nur rund 186 px — gerechnet an
|
||||
// der schmalsten lg-Breite (1200 px Raster, 48 Spalten, 12 px Abstand und
|
||||
// Rand): Spalte = (1200 - 2*12 - 47*12) / 48 = 12,75 px, Kachel = w*12,75 +
|
||||
// (w-1)*12. minW 11 = rund 260 px (10 waeren 235 px), auf breiteren
|
||||
// Bildschirmen entsprechend mehr. defaultW 16 = unveraenderte Breite.
|
||||
// Bestehende schmalere Kalender hebt dashboard-grid.tsx beim Anzeigen an,
|
||||
// soweit rechts Platz ist (applyConstraintMinima).
|
||||
calendar: { minW: 11, minH: 8, defaultW: 16, defaultH: 16 },
|
||||
note: { minW: 8, minH: 4, defaultW: 12, defaultH: 8 },
|
||||
// Phase 8 new widgets (D-01)
|
||||
// minH 10, nicht 9 (260916-dyv, Browser-Messung des Orchestrators): der Rechner hat
|
||||
@@ -89,8 +96,32 @@ export const WIDGET_CONSTRAINTS: Record<
|
||||
// zeigt etwa sechs Serverzeilen; 6 Spalten = rund 166 px zeigen nur Punkte
|
||||
// und Namen.
|
||||
proxmox: { minW: 6, minH: 4, defaultW: 16, defaultH: 8 },
|
||||
// quick-260929-if2: Liste plus Knopfzeile „Neue Erinnerung“. Im 48er-Raster
|
||||
// sind 8 Spalten = rund 230 px die kleinste Breite, in der Titel, Zeitangabe
|
||||
// und die umbrechenden Knoepfe noch benutzbar sind (wie Notiz und Favoriten);
|
||||
// 4 Zeilen = 104 px zeigen eine Erinnerung plus den Knopf. Vorgabe 12x10 =
|
||||
// rund 340x270 px: drei bis vier Erinnerungen auf einmal.
|
||||
reminder: { minW: 8, minH: 4, defaultW: 12, defaultH: 10 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Spalten je Breakpoint (quick-260929-dmx: 48stel am lg-Breakpoint). Steht
|
||||
* hier und nicht in dashboard-grid.tsx, weil auch der Store (neue Kacheln)
|
||||
* die Spaltenzahl braucht und die Registry ohnehin von beiden importiert wird.
|
||||
*/
|
||||
export const GRID_COLS: Record<string, number> = { lg: 48, md: 40, sm: 24, xs: 16, xxs: 4 };
|
||||
|
||||
/**
|
||||
* Begrenzt eine Breite (minW, defaultW) auf die Spaltenzahl des Breakpoints
|
||||
* (quick-260930). Sonst waere z. B. die Suche (minW 12, defaultW 24) am
|
||||
* xxs-Breakpoint mit 4 Spalten breiter als das ganze Raster. Unbekannte
|
||||
* Breakpoints bleiben unbegrenzt.
|
||||
*/
|
||||
export function clampWidthToCols(width: number, breakpoint: string): number {
|
||||
const cols = GRID_COLS[breakpoint];
|
||||
return cols === undefined ? width : Math.min(width, cols);
|
||||
}
|
||||
|
||||
/**
|
||||
* Definition for a widget type in the catalog.
|
||||
*/
|
||||
@@ -358,6 +389,27 @@ function ProxmoxIcon({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ReminderIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of all widget types. Used by the widget catalog modal
|
||||
* and the grid renderer to look up components and constraints.
|
||||
@@ -455,6 +507,15 @@ export const WIDGET_REGISTRY: Record<WidgetType, WidgetDefinition> = {
|
||||
moduleSlug: WIDGET_MODULE_SLUGS.proxmox,
|
||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||
},
|
||||
reminder: {
|
||||
type: 'reminder',
|
||||
nameKey: 'reminder.name',
|
||||
descriptionKey: 'reminder.description',
|
||||
icon: ReminderIcon,
|
||||
...WIDGET_CONSTRAINTS.reminder,
|
||||
moduleSlug: WIDGET_MODULE_SLUGS.reminder,
|
||||
component: PlaceholderWidget, // wird in (portal)/page.tsx per registerWidget() ersetzt
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -490,6 +490,75 @@ describe('FavoritesWidget', () => {
|
||||
expect(screen.getByTestId('letter-fallback-fav-id-1')).toHaveTextContent('G');
|
||||
});
|
||||
|
||||
it('ausdrueckliche Symbol-Adresse (260929-lh3): Proxy -> Browser laedt iconUrl direkt (no-referrer) -> Origin-Favicon -> Buchstabe', async () => {
|
||||
mockFetch.mockResolvedValue([
|
||||
{
|
||||
id: 'fav-id-9',
|
||||
widgetId: 'fav-1',
|
||||
title: 'Docuvita',
|
||||
url: 'https://docuvita.ctl.local/server/services/web/',
|
||||
iconUrl: 'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico',
|
||||
position: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Docuvita')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireEvent.error(screen.getByTestId('icon-proxy-fav-id-9'));
|
||||
});
|
||||
|
||||
const direct1 = screen.getByTestId('icon-direct-fav-id-9') as HTMLImageElement;
|
||||
expect(direct1.src).toBe(
|
||||
'https://docuvita.ctl.local/webclient/docuvita/resources/brandimage/favicon.ico',
|
||||
);
|
||||
expect(direct1.getAttribute('referrerpolicy')).toBe('no-referrer');
|
||||
|
||||
act(() => {
|
||||
fireEvent.error(direct1);
|
||||
});
|
||||
|
||||
const direct2 = screen.getByTestId('icon-direct-fav-id-9') as HTMLImageElement;
|
||||
expect(direct2.src).toBe('https://docuvita.ctl.local/favicon.ico');
|
||||
|
||||
act(() => {
|
||||
fireEvent.error(direct2);
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('icon-direct-fav-id-9')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('letter-fallback-fav-id-9')).toHaveTextContent('D');
|
||||
});
|
||||
|
||||
it('iconUrl mit Nicht-http-Schema wird NIE direkt geladen (javascript:/data:)', async () => {
|
||||
mockFetch.mockResolvedValue([
|
||||
{
|
||||
id: 'fav-id-8',
|
||||
widgetId: 'fav-1',
|
||||
title: 'Boese',
|
||||
url: 'https://boese.example',
|
||||
iconUrl: 'javascript:alert(1)',
|
||||
position: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Boese')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fireEvent.error(screen.getByTestId('icon-proxy-fav-id-8'));
|
||||
});
|
||||
|
||||
const direct = screen.getByTestId('icon-direct-fav-id-8') as HTMLImageElement;
|
||||
expect(direct.src).toBe('https://boese.example/favicon.ico');
|
||||
});
|
||||
|
||||
it('kein Direktbild bei Nicht-http-URL', async () => {
|
||||
mockFetch.mockResolvedValue([
|
||||
{ id: 'fav-id-3', widgetId: 'fav-1', title: 'Ablage', url: 'ftp://files.example', iconUrl: null, position: 0 },
|
||||
@@ -730,8 +799,8 @@ describe('FavoritesWidget', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('updateFavorite wirft FavoriteRequestError(iconUrlUnreachable) -> Meldung im Formular (role alert), Formular bleibt offen, uploadFavoriteIcon NICHT aufgerufen', async () => {
|
||||
mockUpdate.mockRejectedValue(new FavoriteRequestError('iconUrlUnreachable'));
|
||||
it('updateFavorite wirft (z. B. 400 ungueltige Adresse) -> allgemeine Meldung favorites.error im Formular (role alert), Formular bleibt offen, uploadFavoriteIcon NICHT aufgerufen', async () => {
|
||||
mockUpdate.mockRejectedValue(new Error('Failed to update favorite'));
|
||||
|
||||
render(<FavoritesWidget instanceId="fav-1" config={{}} isEditMode={true} />);
|
||||
|
||||
@@ -753,7 +822,7 @@ describe('FavoritesWidget', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('favorites.iconUrlUnreachable');
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('favorites.error');
|
||||
});
|
||||
expect(mockUploadIcon).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('favorite-icon-upload-fav-id-1')).toBeInTheDocument();
|
||||
|
||||
@@ -23,7 +23,7 @@ const TITLE_DEBOUNCE_MS = 1500;
|
||||
/**
|
||||
* Bildet einen Fehler aus dem Favoriten-Klienten auf einen Uebersetzungs-
|
||||
* schluessel ab (260923-lrr): `FavoriteRequestError` traegt den passenden
|
||||
* Grund (`iconUrlUnreachable`/`iconTooLarge`/`iconInvalidType`/
|
||||
* Grund (`iconTooLarge`/`iconInvalidType`/
|
||||
* `iconUploadFailed`) bereits als `reason`, jeder andere Fehler faellt auf
|
||||
* die bisherige allgemeine Meldung zurueck.
|
||||
*/
|
||||
@@ -527,13 +527,37 @@ function getDirectFaviconSrc(url: string): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* FavoriteIcon — dreistufiger Symbol-Ersatzweg (260917-jdd):
|
||||
* Direkt-ladbare Adressen fuer Stufe 2 des Ersatzwegs (260929-lh3): erst die
|
||||
* gespeicherte `iconUrl`, dann das Origin-Favicon der Favoriten-URL; nur
|
||||
* http:/https: (kein javascript:/data:, T-JDD-04), ohne Doppelte.
|
||||
*/
|
||||
function getDirectCandidates(iconUrl: string | null, url: string): string[] {
|
||||
const candidates: string[] = [];
|
||||
if (iconUrl) {
|
||||
try {
|
||||
const u = new URL(iconUrl);
|
||||
if (u.protocol === 'http:' || u.protocol === 'https:') candidates.push(u.toString());
|
||||
} catch {
|
||||
// ungueltige Adresse: uebersprungen
|
||||
}
|
||||
}
|
||||
const origin = getDirectFaviconSrc(url);
|
||||
if (origin && !candidates.includes(origin)) candidates.push(origin);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
/**
|
||||
* FavoriteIcon — Symbol-Ersatzweg (260917-jdd, erweitert 260929-lh3):
|
||||
*
|
||||
* 1. `proxy` — Server-Proxy (GET /favorites/:id/icon), der seit diesem Plan
|
||||
* auch bei Zertifikatsfehlern des Zielhosts liefert (undici-Dispatcher).
|
||||
* 2. `direct` — Direktbild aus dem Browser des Nutzers
|
||||
* (`referrerPolicy="no-referrer"`, Origin nur aus http/https); erreicht
|
||||
* interne Hosts, die der SSRF-Schutz des Servers absichtlich ablehnt.
|
||||
* 2. `direct` — Direktbilder aus dem Browser des Nutzers
|
||||
* (`referrerPolicy="no-referrer"`, nur http/https), nacheinander:
|
||||
* zuerst die gespeicherte `iconUrl` (ausdrueckliche Symbol-Adresse oder
|
||||
* erkannter Verweis — Server wie docuvita geben dem Server 404/HTML, dem
|
||||
* Browser aber das Bild), dann `{origin}/favicon.ico` (entfaellt, wenn
|
||||
* identisch). Erreicht auch interne Hosts, die der SSRF-Schutz des
|
||||
* Servers absichtlich ablehnt.
|
||||
* 3. `none` — der Buchstaben-Platzhalter liegt IMMER darunter.
|
||||
*
|
||||
* Bewusst KEIN Drittanbieter-Favicon-Dienst: der wuerde Hostnamen nach
|
||||
@@ -575,10 +599,12 @@ function FavoriteIcon({
|
||||
const proxySrc = hasServerIcon
|
||||
? `/api-proxy/favorites/${encodeURIComponent(fav.id)}/icon?v=${fav.iconVersion ?? 0}`
|
||||
: null;
|
||||
const directSrc = getDirectFaviconSrc(fav.url);
|
||||
const [stage, setStage] = useState<'proxy' | 'direct' | 'none'>(
|
||||
proxySrc ? 'proxy' : 'direct',
|
||||
);
|
||||
// 260929-lh3: Direkt-Kandidaten in Reihenfolge — gespeicherte Adresse zuerst,
|
||||
// dann das Origin-Favicon; nur http/https, ohne Doppelte.
|
||||
const directSrcs = getDirectCandidates(fav.iconUrl, fav.url);
|
||||
// 'proxy' | Index in directSrcs | Ende der Kette (Buchstabe).
|
||||
const [stage, setStage] = useState<'proxy' | number>(proxySrc ? 'proxy' : 0);
|
||||
const directSrc = typeof stage === 'number' ? (directSrcs[stage] ?? null) : null;
|
||||
|
||||
return (
|
||||
<div className={`relative flex shrink-0 items-center justify-center bg-muted ${box}`}>
|
||||
@@ -598,10 +624,10 @@ function FavoriteIcon({
|
||||
height={px}
|
||||
loading="lazy"
|
||||
className={`relative rounded-sm ${img}`}
|
||||
onError={() => setStage('direct')}
|
||||
onError={() => setStage(0)}
|
||||
/>
|
||||
)}
|
||||
{stage === 'direct' && directSrc && (
|
||||
{directSrc && (
|
||||
<img
|
||||
data-testid={`icon-direct-${fav.id}`}
|
||||
src={directSrc}
|
||||
@@ -612,7 +638,7 @@ function FavoriteIcon({
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
className={`relative rounded-sm ${img}`}
|
||||
onError={() => setStage('none')}
|
||||
onError={() => setStage((prev) => (typeof prev === 'number' ? prev + 1 : 0))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { requestBrowserPermissionOnce } from '@/lib/reminder-notify';
|
||||
import { defaultNewReminderInputs, isoToLocalInputs, localInputsToIso } from '@/lib/reminder-time';
|
||||
import {
|
||||
createReminder,
|
||||
type Reminder,
|
||||
type ReminderEmailStatus,
|
||||
ReminderRequestError,
|
||||
updateReminder,
|
||||
} from '@/lib/reminders-api';
|
||||
|
||||
interface ReminderFormModalProps {
|
||||
/** `null` = neue Erinnerung anlegen, sonst diese (noch nicht faellige) bearbeiten. */
|
||||
reminder: Reminder | null;
|
||||
/** Ergebnis von `GET /reminders/email-status`; `null` = unbekannt oder fehlgeschlagen (gilt als nicht moeglich). */
|
||||
emailStatus: ReminderEmailStatus | null;
|
||||
onClose: () => void;
|
||||
/** Nach erfolgreichem Speichern (die Kachel laedt neu und meldet die Aenderung). */
|
||||
onSaved: () => void;
|
||||
/** Der Server meldet, dass die Erinnerung inzwischen faellig ist (409) — die Kachel laedt neu. */
|
||||
onStale?: () => void;
|
||||
}
|
||||
|
||||
const TITLE_MAX = 200;
|
||||
const DESCRIPTION_MAX = 2000;
|
||||
|
||||
/**
|
||||
* Formular „Neue Erinnerung“ (quick-260929-if2). Datum und Uhrzeit sind
|
||||
* Ortszeit; erst hier wird daraus ein ISO-Zeitpunkt.
|
||||
*
|
||||
* Gerendert per `createPortal` in `document.body`: die Kachel liegt in einem
|
||||
* `react-grid-item` mit CSS-`transform`, und ein transformierter Vorfahr
|
||||
* macht sich fuer `position: fixed` zum Bezugsrahmen (Vorbild
|
||||
* `picture-frame-lightbox.tsx`). Dialog-Aufbau wie `custom-module-form-modal`.
|
||||
*
|
||||
* BEARBEITEN (`reminder` gesetzt): nur fuer eine noch nicht faellige Erinnerung;
|
||||
* eine inzwischen faellige lehnt der Server mit 409 ab (`alreadyDue`).
|
||||
*
|
||||
* ERLAUBNIS (D-04): `requestBrowserPermissionOnce()` laeuft beim ANLEGEN als
|
||||
* ERSTES im Absende-Handler, synchron und noch vor jedem `await` — so bleibt der Aufruf
|
||||
* eine Nutzerhandlung, die der Browser fuer die Frage akzeptiert. Nie beim
|
||||
* Laden der Seite.
|
||||
*/
|
||||
export function ReminderFormModal({
|
||||
reminder,
|
||||
emailStatus,
|
||||
onClose,
|
||||
onSaved,
|
||||
onStale,
|
||||
}: ReminderFormModalProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const initial = useRef(
|
||||
reminder ? isoToLocalInputs(reminder.dueAt) : defaultNewReminderInputs(new Date()),
|
||||
).current;
|
||||
const [date, setDate] = useState(initial.date);
|
||||
const [time, setTime] = useState(initial.time);
|
||||
const [title, setTitle] = useState(reminder?.title ?? '');
|
||||
const [description, setDescription] = useState(reminder?.description ?? '');
|
||||
const [emailEnabled, setEmailEnabled] = useState(reminder?.emailEnabled ?? false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
titleRef.current?.focus();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Als Erstes und synchron: Nutzerhandlung fuer die Browser-Erlaubnis (D-04).
|
||||
if (!reminder) requestBrowserPermissionOnce();
|
||||
setError(null);
|
||||
|
||||
const trimmedTitle = title.trim();
|
||||
if (trimmedTitle === '') {
|
||||
setError(t('reminder.titleRequired'));
|
||||
return;
|
||||
}
|
||||
// Beim Bearbeiten nur senden, wenn Datum oder Uhrzeit wirklich geaendert
|
||||
// wurden: das Formular kennt keine Sekunden, ein unveraendertes Feld wuerde
|
||||
// die Faelligkeit sonst um bis zu 59 s verschieben — und kurz vor der
|
||||
// Faelligkeit faelschlich „in der Vergangenheit“ melden. Ist sie inzwischen
|
||||
// faellig, antwortet der Server ohnehin mit 409 (`alreadyDue`).
|
||||
const dueChanged = !reminder || date !== initial.date || time !== initial.time;
|
||||
const dueAt = dueChanged ? localInputsToIso(date, time) : undefined;
|
||||
// Spiegel der Serverregel: die Faelligkeit muss in der Zukunft liegen.
|
||||
if (dueChanged && (!dueAt || new Date(dueAt).getTime() <= Date.now())) {
|
||||
setError(t('reminder.pastError'));
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
// `emailEnabled` nur senden, wenn es sich aendert oder beim Anlegen `true`
|
||||
// ist: ein Server, der E-Mail nicht mehr anbietet, lehnt `true` mit 400
|
||||
// ab — eine unveraenderte Alt-Einstellung darf das Speichern nicht kippen.
|
||||
const emailChanged = reminder ? emailEnabled !== reminder.emailEnabled : emailEnabled;
|
||||
const common = {
|
||||
title: trimmedTitle,
|
||||
description: description.trim(),
|
||||
...(emailChanged ? { emailEnabled } : {}),
|
||||
};
|
||||
if (reminder) {
|
||||
await updateReminder(reminder.id, { ...common, ...(dueAt ? { dueAt } : {}) });
|
||||
} else if (dueAt) {
|
||||
await createReminder({ ...common, dueAt });
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
if (err instanceof ReminderRequestError && err.status === 409) {
|
||||
// Anlegen: Grenze erreicht; Bearbeiten: inzwischen faellig.
|
||||
setError(reminder ? t('reminder.alreadyDue') : t('reminder.limitReached'));
|
||||
if (reminder) onStale?.();
|
||||
} else {
|
||||
setError(t('reminder.saveError'));
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// E-Mail moeglich: Versand eingerichtet UND Adresse im Konto. Unbekannt gilt
|
||||
// als nicht moeglich. Ein bereits angehaktes Feld bleibt bedienbar, damit man
|
||||
// es wieder abwaehlen kann.
|
||||
const emailAvailable = emailStatus?.smtpConfigured === true && emailStatus.hasEmail;
|
||||
const emailHint =
|
||||
emailStatus === null || emailAvailable
|
||||
? null
|
||||
: !emailStatus.smtpConfigured
|
||||
? t('reminder.emailNoSmtp')
|
||||
: t('reminder.emailNoAddress');
|
||||
|
||||
if (typeof document === 'undefined') return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="reminder-form-title"
|
||||
className="w-full max-w-md rounded-lg bg-card p-6 shadow-lg dark:border dark:border-border"
|
||||
>
|
||||
<h2 id="reminder-form-title" className="mb-4 text-lg font-semibold text-foreground">
|
||||
{reminder ? t('reminder.editTitle') : t('reminder.formTitle')}
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 space-y-2">
|
||||
<label htmlFor="reminder-date" className="text-sm font-medium text-foreground">
|
||||
{t('reminder.dateLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="reminder-date"
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<label htmlFor="reminder-time" className="text-sm font-medium text-foreground">
|
||||
{t('reminder.timeLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="reminder-time"
|
||||
type="time"
|
||||
value={time}
|
||||
onChange={(e) => setTime(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="reminder-title" className="text-sm font-medium text-foreground">
|
||||
{t('reminder.titleLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="reminder-title"
|
||||
ref={titleRef}
|
||||
type="text"
|
||||
maxLength={TITLE_MAX}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="reminder-description" className="text-sm font-medium text-foreground">
|
||||
{t('reminder.descriptionLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
id="reminder-description"
|
||||
rows={3}
|
||||
maxLength={DESCRIPTION_MAX}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={emailEnabled}
|
||||
disabled={!emailAvailable && !emailEnabled}
|
||||
onChange={(e) => setEmailEnabled(e.target.checked)}
|
||||
aria-describedby={emailHint ? 'reminder-email-hint' : undefined}
|
||||
/>
|
||||
{t('reminder.emailLabel')}
|
||||
</label>
|
||||
{emailHint && (
|
||||
<p id="reminder-email-hint" className="text-xs text-muted-foreground">
|
||||
{emailHint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button type="button" onClick={onClose} className="btn btn-secondary">
|
||||
{t('reminder.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={saving} className="btn btn-primary">
|
||||
{t('reminder.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
useLocale: () => 'de',
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/reminders-api', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/reminders-api')>('@/lib/reminders-api');
|
||||
return {
|
||||
REMINDERS_CHANGED_EVENT: actual.REMINDERS_CHANGED_EVENT,
|
||||
ReminderRequestError: actual.ReminderRequestError,
|
||||
listReminders: vi.fn(),
|
||||
createReminder: vi.fn(),
|
||||
updateReminder: vi.fn(),
|
||||
snoozeReminder: vi.fn(),
|
||||
deleteReminder: vi.fn(),
|
||||
getReminderEmailStatus: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
createReminder,
|
||||
deleteReminder,
|
||||
getReminderEmailStatus,
|
||||
listReminders,
|
||||
ReminderRequestError,
|
||||
snoozeReminder,
|
||||
updateReminder,
|
||||
} from '@/lib/reminders-api';
|
||||
import { ReminderWidget } from './reminder-widget';
|
||||
|
||||
const mockList = listReminders as ReturnType<typeof vi.fn>;
|
||||
const mockCreate = createReminder as ReturnType<typeof vi.fn>;
|
||||
const mockUpdate = updateReminder as ReturnType<typeof vi.fn>;
|
||||
const mockSnooze = snoozeReminder as ReturnType<typeof vi.fn>;
|
||||
const mockDelete = deleteReminder as ReturnType<typeof vi.fn>;
|
||||
const mockEmailStatus = getReminderEmailStatus as ReturnType<typeof vi.fn>;
|
||||
|
||||
function reminder(id: string, title: string, dueAt: string, description = '') {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
dueAt,
|
||||
emailEnabled: false,
|
||||
createdAt: dueAt,
|
||||
updatedAt: dueAt,
|
||||
};
|
||||
}
|
||||
|
||||
const props = { instanceId: 'w1', config: {}, isEditMode: false };
|
||||
|
||||
let requestPermission: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
mockList.mockReset();
|
||||
mockCreate.mockReset();
|
||||
mockUpdate.mockReset();
|
||||
mockSnooze.mockReset();
|
||||
mockDelete.mockReset();
|
||||
mockEmailStatus.mockReset();
|
||||
mockEmailStatus.mockResolvedValue({ smtpConfigured: true, hasEmail: true });
|
||||
requestPermission = vi.fn(async () => 'granted');
|
||||
const ctor = vi.fn();
|
||||
Object.assign(ctor, { permission: 'default', requestPermission });
|
||||
vi.stubGlobal('Notification', ctor);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function openForm() {
|
||||
fireEvent.click(await screen.findByText('reminder.add'));
|
||||
}
|
||||
|
||||
function fillAndSubmit(title: string) {
|
||||
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: title } });
|
||||
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), {
|
||||
target: { value: '2099-03-04' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('reminder.timeLabel'), { target: { value: '14:30' } });
|
||||
fireEvent.click(screen.getByText('reminder.save'));
|
||||
}
|
||||
|
||||
describe('ReminderWidget', () => {
|
||||
it('listet die Erinnerungen nach Faelligkeit sortiert', async () => {
|
||||
mockList.mockResolvedValue([
|
||||
reminder('b', 'Spaeter', '2099-01-02T10:00:00.000Z'),
|
||||
reminder('a', 'Frueh', '2099-01-01T10:00:00.000Z', 'Beschreibung'),
|
||||
]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
const rows = await screen.findAllByTestId('reminder-row');
|
||||
expect(rows[0].textContent).toContain('Frueh');
|
||||
expect(rows[0].textContent).toContain('Beschreibung');
|
||||
expect(rows[1].textContent).toContain('Spaeter');
|
||||
});
|
||||
|
||||
it('zeigt den Leerzustand', async () => {
|
||||
mockList.mockResolvedValue([]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
expect(await screen.findByText('reminder.empty')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('das Rendern fragt keine Benachrichtigungs-Erlaubnis (D-04)', async () => {
|
||||
mockList.mockResolvedValue([]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
await screen.findByText('reminder.empty');
|
||||
expect(requestPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Anlegen sendet die aus den Ortszeit-Eingaben gebildete ISO-Zeit', async () => {
|
||||
mockList.mockResolvedValue([]);
|
||||
mockCreate.mockResolvedValue(reminder('n', 'Zahnarzt', '2099-03-04T13:30:00.000Z'));
|
||||
render(<ReminderWidget {...props} />);
|
||||
await openForm();
|
||||
fillAndSubmit('Zahnarzt');
|
||||
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
|
||||
const arg = mockCreate.mock.calls[0][0];
|
||||
expect(arg.title).toBe('Zahnarzt');
|
||||
expect(arg.dueAt).toBe(new Date('2099-03-04T14:30').toISOString());
|
||||
});
|
||||
|
||||
it('die erste Anlage fragt die Erlaubnis einmal, die zweite nicht mehr', async () => {
|
||||
mockList.mockResolvedValue([]);
|
||||
mockCreate.mockResolvedValue(reminder('n', 'x', '2099-03-04T13:30:00.000Z'));
|
||||
render(<ReminderWidget {...props} />);
|
||||
|
||||
await openForm();
|
||||
fillAndSubmit('Erste');
|
||||
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
|
||||
expect(requestPermission).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Dialog ist nach dem Speichern zu; neu oeffnen
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
(Notification as unknown as { permission: string }).permission = 'default';
|
||||
await openForm();
|
||||
fillAndSubmit('Zweite');
|
||||
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(2));
|
||||
expect(requestPermission).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('lehnt eine Vergangenheit im Formular ab, ohne zu senden', async () => {
|
||||
mockList.mockResolvedValue([]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
await openForm();
|
||||
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: 'x' } });
|
||||
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), {
|
||||
target: { value: '2001-01-01' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('reminder.save'));
|
||||
expect(await screen.findByText('reminder.pastError')).toBeTruthy();
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('meldet REMINDERS_CHANGED nach dem Anlegen', async () => {
|
||||
mockList.mockResolvedValue([]);
|
||||
mockCreate.mockResolvedValue(reminder('n', 'x', '2099-03-04T13:30:00.000Z'));
|
||||
const handler = vi.fn();
|
||||
window.addEventListener('tessera:reminders-changed', handler);
|
||||
render(<ReminderWidget {...props} />);
|
||||
await openForm();
|
||||
await act(async () => {
|
||||
fillAndSubmit('Meldung');
|
||||
});
|
||||
await waitFor(() => expect(handler).toHaveBeenCalled());
|
||||
window.removeEventListener('tessera:reminders-changed', handler);
|
||||
});
|
||||
});
|
||||
|
||||
const iso = (offsetMs: number) => new Date(Date.now() + offsetMs).toISOString();
|
||||
|
||||
describe('ReminderWidget — faellig und Aktionen (D-03)', () => {
|
||||
it('eine faellige Zeile ist hervorgehoben mit Faellig, Erledigt und Spaeter erinnern, ohne Bearbeiten/Loeschen', async () => {
|
||||
mockList.mockResolvedValue([reminder('a', 'Faellig', iso(-60_000))]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
const row = await screen.findByTestId('reminder-row');
|
||||
expect(row.getAttribute('data-due')).toBe('true');
|
||||
expect(row.className).toContain('border-status-warn');
|
||||
expect(within(row).getByText('reminder.due')).toBeTruthy();
|
||||
expect(within(row).getByText('reminder.done')).toBeTruthy();
|
||||
expect(within(row).getByText('reminder.snooze')).toBeTruthy();
|
||||
expect(within(row).queryByLabelText('reminder.edit')).toBeNull();
|
||||
expect(within(row).queryByLabelText('reminder.delete')).toBeNull();
|
||||
});
|
||||
|
||||
it('eine kuenftige Zeile bietet Bearbeiten und Loeschen, aber nicht Erledigt/Spaeter', async () => {
|
||||
mockList.mockResolvedValue([reminder('a', 'Kommt', iso(3600_000))]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
const row = await screen.findByTestId('reminder-row');
|
||||
expect(row.getAttribute('data-due')).toBe('false');
|
||||
expect(within(row).getByLabelText('reminder.edit')).toBeTruthy();
|
||||
expect(within(row).getByLabelText('reminder.delete')).toBeTruthy();
|
||||
expect(within(row).queryByText('reminder.done')).toBeNull();
|
||||
expect(within(row).queryByText('reminder.snooze')).toBeNull();
|
||||
});
|
||||
|
||||
it('Erledigt ruft deleteReminder und die Zeile verschwindet nach dem Neuladen', async () => {
|
||||
mockList.mockResolvedValueOnce([reminder('a', 'Faellig', iso(-60_000))]).mockResolvedValue([]);
|
||||
mockDelete.mockResolvedValue({ deleted: true });
|
||||
render(<ReminderWidget {...props} />);
|
||||
fireEvent.click(await screen.findByText('reminder.done'));
|
||||
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('a'));
|
||||
await waitFor(() => expect(screen.queryByTestId('reminder-row')).toBeNull());
|
||||
});
|
||||
|
||||
it('jede Spaeter-Option ruft snoozeReminder mit dem Zeitpunkt aus snoozeTarget', async () => {
|
||||
const original = new Date(Date.now() - 5 * 60_000);
|
||||
mockList.mockResolvedValue([reminder('a', 'Faellig', original.toISOString())]);
|
||||
mockSnooze.mockResolvedValue({});
|
||||
render(<ReminderWidget {...props} />);
|
||||
|
||||
for (const [label, expected] of [
|
||||
['reminder.snooze10m', () => Date.now() + 10 * 60_000],
|
||||
['reminder.snooze1h', () => Date.now() + 60 * 60_000],
|
||||
] as const) {
|
||||
mockSnooze.mockClear();
|
||||
fireEvent.click(await screen.findByText('reminder.snooze'));
|
||||
fireEvent.click(await screen.findByText(label));
|
||||
await waitFor(() => expect(mockSnooze).toHaveBeenCalledTimes(1));
|
||||
const [id, dueAt] = mockSnooze.mock.calls[0];
|
||||
expect(id).toBe('a');
|
||||
expect(Math.abs(new Date(dueAt).getTime() - expected())).toBeLessThan(5_000);
|
||||
}
|
||||
|
||||
mockSnooze.mockClear();
|
||||
fireEvent.click(await screen.findByText('reminder.snooze'));
|
||||
fireEvent.click(await screen.findByText('reminder.snoozeTomorrow'));
|
||||
await waitFor(() => expect(mockSnooze).toHaveBeenCalledTimes(1));
|
||||
const tomorrow = new Date(original);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
expect(mockSnooze.mock.calls[0][1]).toBe(tomorrow.toISOString());
|
||||
});
|
||||
|
||||
it('Loeschen fragt zweistufig nach und ruft dann deleteReminder', async () => {
|
||||
mockList.mockResolvedValue([reminder('a', 'Kommt', iso(3600_000))]);
|
||||
mockDelete.mockResolvedValue({ deleted: true });
|
||||
render(<ReminderWidget {...props} />);
|
||||
fireEvent.click(await screen.findByLabelText('reminder.delete'));
|
||||
expect(mockDelete).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByText('reminder.yes'));
|
||||
await waitFor(() => expect(mockDelete).toHaveBeenCalledWith('a'));
|
||||
});
|
||||
|
||||
it('Bearbeiten oeffnet das Formular mit den Werten und speichert per updateReminder', async () => {
|
||||
const due = new Date(Date.now() + 3600_000);
|
||||
mockList.mockResolvedValue([reminder('a', 'Alt', due.toISOString(), 'Text')]);
|
||||
mockUpdate.mockResolvedValue({});
|
||||
render(<ReminderWidget {...props} />);
|
||||
fireEvent.click(await screen.findByLabelText('reminder.edit'));
|
||||
const titleInput = screen.getByLabelText('reminder.titleLabel') as HTMLInputElement;
|
||||
expect(titleInput.value).toBe('Alt');
|
||||
fireEvent.change(titleInput, { target: { value: 'Neu' } });
|
||||
fireEvent.click(screen.getByText('reminder.save'));
|
||||
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
|
||||
const [id, patch] = mockUpdate.mock.calls[0];
|
||||
expect(id).toBe('a');
|
||||
expect(patch.title).toBe('Neu');
|
||||
expect(patch.description).toBe('Text');
|
||||
// Bearbeiten fragt nie die Benachrichtigungs-Erlaubnis
|
||||
expect(requestPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Bearbeiten ohne Aenderung von Datum/Uhrzeit sendet kein dueAt (keine Sekunden-Verschiebung)', async () => {
|
||||
// Faellig in 30 s, mit Sekunden: auf Minuten gekuerzt laege sie in der Vergangenheit.
|
||||
const due = new Date(Date.now() + 30_000);
|
||||
due.setSeconds(due.getSeconds(), 500);
|
||||
mockList.mockResolvedValue([reminder('a', 'Alt', due.toISOString())]);
|
||||
mockUpdate.mockResolvedValue({});
|
||||
render(<ReminderWidget {...props} />);
|
||||
fireEvent.click(await screen.findByLabelText('reminder.edit'));
|
||||
fireEvent.change(screen.getByLabelText('reminder.titleLabel'), { target: { value: 'Neu' } });
|
||||
fireEvent.click(screen.getByText('reminder.save'));
|
||||
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
|
||||
expect(screen.queryByText('reminder.pastError')).toBeNull();
|
||||
expect(mockUpdate.mock.calls[0][1]).not.toHaveProperty('dueAt');
|
||||
expect(mockUpdate.mock.calls[0][1].title).toBe('Neu');
|
||||
});
|
||||
|
||||
it('Bearbeiten mit geaenderter Uhrzeit sendet das neue dueAt', async () => {
|
||||
const due = new Date(Date.now() + 2 * 3600_000);
|
||||
mockList.mockResolvedValue([reminder('a', 'Alt', due.toISOString())]);
|
||||
mockUpdate.mockResolvedValue({});
|
||||
render(<ReminderWidget {...props} />);
|
||||
fireEvent.click(await screen.findByLabelText('reminder.edit'));
|
||||
const next = new Date(Date.now() + 26 * 3600_000);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const nextDate = `${next.getFullYear()}-${pad(next.getMonth() + 1)}-${pad(next.getDate())}`;
|
||||
fireEvent.change(screen.getByLabelText('reminder.dateLabel'), { target: { value: nextDate } });
|
||||
fireEvent.change(screen.getByLabelText('reminder.timeLabel'), { target: { value: '09:15' } });
|
||||
fireEvent.click(screen.getByText('reminder.save'));
|
||||
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
|
||||
expect(mockUpdate.mock.calls[0][1].dueAt).toBe(new Date(`${nextDate}T09:15`).toISOString());
|
||||
});
|
||||
|
||||
it('409 beim Verschieben zeigt den Hinweis und laedt neu', async () => {
|
||||
mockList.mockResolvedValue([reminder('a', 'Faellig', iso(-60_000))]);
|
||||
mockSnooze.mockRejectedValue(new ReminderRequestError(409));
|
||||
render(<ReminderWidget {...props} />);
|
||||
fireEvent.click(await screen.findByText('reminder.snooze'));
|
||||
fireEvent.click(await screen.findByText('reminder.snooze10m'));
|
||||
expect(await screen.findByText('reminder.notDue')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('zeigt bei blockierten Browser-Benachrichtigungen einen Hinweis, sonst nicht', async () => {
|
||||
mockList.mockResolvedValue([]);
|
||||
(Notification as unknown as { permission: string }).permission = 'denied';
|
||||
const { unmount } = render(<ReminderWidget {...props} />);
|
||||
expect(await screen.findByTestId('reminder-permission-hint')).toBeTruthy();
|
||||
unmount();
|
||||
(Notification as unknown as { permission: string }).permission = 'default';
|
||||
render(<ReminderWidget {...props} />);
|
||||
await screen.findByText('reminder.empty');
|
||||
expect(screen.queryByTestId('reminder-permission-hint')).toBeNull();
|
||||
});
|
||||
|
||||
it('zeigt in der Desktop-App keinen Hinweis', async () => {
|
||||
(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ = { invoke: vi.fn() };
|
||||
(Notification as unknown as { permission: string }).permission = 'denied';
|
||||
mockList.mockResolvedValue([]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
await screen.findByText('reminder.empty');
|
||||
expect(screen.queryByTestId('reminder-permission-hint')).toBeNull();
|
||||
delete (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReminderWidget — Faelligkeit ohne Neuladen', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('eine Zeile wird durch den lokalen 10-s-Takt faellig', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-09-29T12:00:00.000Z'));
|
||||
mockList.mockResolvedValue([reminder('a', 'Bald', '2026-09-29T12:00:30.000Z')]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(screen.getByTestId('reminder-row').getAttribute('data-due')).toBe('false');
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(40_000);
|
||||
});
|
||||
expect(screen.getByTestId('reminder-row').getAttribute('data-due')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReminderWidget — Spaeter erinnern kurz nach Mitternacht', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('faellig gestern 23:50, jetzt 00:10: die Option heisst „Heute“ und verschiebt auf heute 23:50', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
vi.setSystemTime(new Date(2026, 8, 30, 0, 10));
|
||||
mockList.mockResolvedValue([
|
||||
reminder('a', 'Spaet', new Date(2026, 8, 29, 23, 50).toISOString()),
|
||||
]);
|
||||
mockSnooze.mockResolvedValue({});
|
||||
render(<ReminderWidget {...props} />);
|
||||
fireEvent.click(await screen.findByText('reminder.snooze'));
|
||||
expect(screen.queryByText('reminder.snoozeTomorrow')).toBeNull();
|
||||
fireEvent.click(await screen.findByText('reminder.snoozeToday'));
|
||||
await waitFor(() => expect(mockSnooze).toHaveBeenCalledTimes(1));
|
||||
expect(mockSnooze.mock.calls[0][1]).toBe(new Date(2026, 8, 30, 23, 50).toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReminderWidget — E-Mail-Erinnerung (Aufgabe 3, E-09)', () => {
|
||||
async function openFormAndWaitForStatus() {
|
||||
await openForm();
|
||||
// Der Status wird beim Einhaengen der Kachel geladen
|
||||
await waitFor(() => expect(mockEmailStatus).toHaveBeenCalledTimes(1));
|
||||
return screen.getByLabelText('reminder.emailLabel') as HTMLInputElement;
|
||||
}
|
||||
|
||||
it('ist bedienbar und wird als emailEnabled gesendet, wenn Versand und Adresse da sind', async () => {
|
||||
mockList.mockResolvedValue([]);
|
||||
mockCreate.mockResolvedValue(reminder('n', 'x', '2099-03-04T13:30:00.000Z'));
|
||||
render(<ReminderWidget {...props} />);
|
||||
const box = await openFormAndWaitForStatus();
|
||||
await waitFor(() => expect(box.disabled).toBe(false));
|
||||
fireEvent.click(box);
|
||||
fillAndSubmit('Mit Mail');
|
||||
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
|
||||
expect(mockCreate.mock.calls[0][0].emailEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it('ohne E-Mail-Haken wird emailEnabled gar nicht gesendet', async () => {
|
||||
mockList.mockResolvedValue([]);
|
||||
mockCreate.mockResolvedValue(reminder('n', 'x', '2099-03-04T13:30:00.000Z'));
|
||||
render(<ReminderWidget {...props} />);
|
||||
await openFormAndWaitForStatus();
|
||||
fillAndSubmit('Ohne Mail');
|
||||
await waitFor(() => expect(mockCreate).toHaveBeenCalledTimes(1));
|
||||
expect(mockCreate.mock.calls[0][0]).not.toHaveProperty('emailEnabled');
|
||||
});
|
||||
|
||||
it('ist ausgegraut mit Erklaerung, wenn kein E-Mail-Versand eingerichtet ist', async () => {
|
||||
mockEmailStatus.mockResolvedValue({ smtpConfigured: false, hasEmail: true });
|
||||
mockList.mockResolvedValue([]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
const box = await openFormAndWaitForStatus();
|
||||
expect(await screen.findByText('reminder.emailNoSmtp')).toBeTruthy();
|
||||
expect(box.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('ist ausgegraut mit eigener Erklaerung, wenn das Konto keine Adresse hat', async () => {
|
||||
mockEmailStatus.mockResolvedValue({ smtpConfigured: true, hasEmail: false });
|
||||
mockList.mockResolvedValue([]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
const box = await openFormAndWaitForStatus();
|
||||
expect(await screen.findByText('reminder.emailNoAddress')).toBeTruthy();
|
||||
expect(box.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('gilt als nicht moeglich, solange der Status fehlt oder fehlschlaegt', async () => {
|
||||
mockEmailStatus.mockRejectedValue(new Error('offline'));
|
||||
mockList.mockResolvedValue([]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
const box = await openFormAndWaitForStatus();
|
||||
expect(box.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('zeigt bei Zeilen mit emailEnabled ein Mail-Symbol, sonst nicht', async () => {
|
||||
mockList.mockResolvedValue([
|
||||
{ ...reminder('a', 'Mit', '2099-01-01T10:00:00.000Z'), emailEnabled: true },
|
||||
reminder('b', 'Ohne', '2099-01-02T10:00:00.000Z'),
|
||||
]);
|
||||
render(<ReminderWidget {...props} />);
|
||||
const rows = await screen.findAllByTestId('reminder-row');
|
||||
expect(within(rows[0]).getByTestId('reminder-email-icon')).toBeTruthy();
|
||||
expect(within(rows[1]).queryByTestId('reminder-email-icon')).toBeNull();
|
||||
});
|
||||
|
||||
it('Bearbeiten einer Erinnerung mit angehakter Mail sendet emailEnabled nur bei Aenderung', async () => {
|
||||
mockEmailStatus.mockResolvedValue({ smtpConfigured: false, hasEmail: true });
|
||||
mockList.mockResolvedValue([
|
||||
{
|
||||
...reminder('a', 'Alt', new Date(Date.now() + 3600_000).toISOString()),
|
||||
emailEnabled: true,
|
||||
},
|
||||
]);
|
||||
mockUpdate.mockResolvedValue({});
|
||||
render(<ReminderWidget {...props} />);
|
||||
fireEvent.click(await screen.findByLabelText('reminder.edit'));
|
||||
const box = screen.getByLabelText('reminder.emailLabel') as HTMLInputElement;
|
||||
expect(box.checked).toBe(true);
|
||||
expect(box.disabled).toBe(false); // angehakt bleibt bedienbar, zum Abwaehlen
|
||||
fireEvent.click(screen.getByText('reminder.save'));
|
||||
await waitFor(() => expect(mockUpdate).toHaveBeenCalledTimes(1));
|
||||
expect(mockUpdate.mock.calls[0][1]).not.toHaveProperty('emailEnabled');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,373 @@
|
||||
'use client';
|
||||
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { browserPermissionState } from '@/lib/reminder-notify';
|
||||
import { isSameLocalDay, type SnoozePreset, snoozeTarget } from '@/lib/reminder-time';
|
||||
import {
|
||||
deleteReminder,
|
||||
getReminderEmailStatus,
|
||||
listReminders,
|
||||
REMINDERS_CHANGED_EVENT,
|
||||
type Reminder,
|
||||
type ReminderEmailStatus,
|
||||
ReminderRequestError,
|
||||
snoozeReminder,
|
||||
} from '@/lib/reminders-api';
|
||||
import type { WidgetProps } from '../widget-registry';
|
||||
import { ReminderFormModal } from './reminder-form-modal';
|
||||
|
||||
/** Abstand der Auffrischung beim Server. */
|
||||
const REFRESH_MS = 60_000;
|
||||
/** Abstand, in dem die Kachel neu entscheidet, welche Erinnerung faellig ist. */
|
||||
const NOW_TICK_MS = 10_000;
|
||||
|
||||
const SNOOZE_PRESETS: SnoozePreset[] = ['10m', '1h', 'tomorrow'];
|
||||
|
||||
/**
|
||||
* Kachel „Erinnerungen“ (quick-260929-if2): die persoenlichen Erinnerungen des
|
||||
* Benutzers, die naechste Faelligkeit zuerst. Die Benachrichtigung selbst macht
|
||||
* der globale `ReminderNotifier` im Portal-Rahmen — die Kachel muss dafuer
|
||||
* nicht sichtbar sein. Nach jeder Aenderung laedt sie neu und meldet
|
||||
* `REMINDERS_CHANGED_EVENT`, damit der Melder eine neue Faelligkeit sofort kennt.
|
||||
*
|
||||
* Faellige Zeilen (D-03) bleiben hervorgehoben stehen und bieten „Erledigt“
|
||||
* (loescht die Zeile, E-02) und „Spaeter erinnern“ (+10 Minuten, +1 Stunde,
|
||||
* morgen zur gleichen Uhrzeit). Noch nicht faellige lassen sich bearbeiten und
|
||||
* loeschen. Ob eine Zeile faellig ist, entscheidet ein 10-s-Takt lokal, ohne
|
||||
* Neuladen.
|
||||
*/
|
||||
export function ReminderWidget(_props: WidgetProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const locale = useLocale();
|
||||
const [reminders, setReminders] = useState<Reminder[] | null>(null);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [modal, setModal] = useState<{ reminder: Reminder | null } | null>(null);
|
||||
const [snoozeOpenId, setSnoozeOpenId] = useState<string | null>(null);
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
// Einmal je Einhaengen der Kachel geladen; unbekannt/fehlgeschlagen = E-Mail nicht moeglich.
|
||||
const [emailStatus, setEmailStatus] = useState<ReminderEmailStatus | null>(null);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
try {
|
||||
setReminders(await listReminders());
|
||||
setLoadError(false);
|
||||
} catch {
|
||||
setLoadError(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getReminderEmailStatus()
|
||||
.then((status) => {
|
||||
if (!cancelled) setEmailStatus(status);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refetch();
|
||||
const refresh = setInterval(() => void refetch(), REFRESH_MS);
|
||||
const tick = setInterval(() => setNow(Date.now()), NOW_TICK_MS);
|
||||
const onChanged = () => void refetch();
|
||||
window.addEventListener(REMINDERS_CHANGED_EVENT, onChanged);
|
||||
return () => {
|
||||
clearInterval(refresh);
|
||||
clearInterval(tick);
|
||||
window.removeEventListener(REMINDERS_CHANGED_EVENT, onChanged);
|
||||
};
|
||||
}, [refetch]);
|
||||
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...(reminders ?? [])].sort(
|
||||
(a, b) => new Date(a.dueAt).getTime() - new Date(b.dueAt).getTime(),
|
||||
),
|
||||
[reminders],
|
||||
);
|
||||
|
||||
const dateTime = useMemo(
|
||||
() => new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }),
|
||||
[locale],
|
||||
);
|
||||
const timeOnly = useMemo(() => new Intl.DateTimeFormat(locale, { timeStyle: 'short' }), [locale]);
|
||||
|
||||
/** Nach jeder erfolgreichen Aenderung: neu laden und den Melder informieren. */
|
||||
const afterMutation = () => {
|
||||
void refetch();
|
||||
window.dispatchEvent(new Event(REMINDERS_CHANGED_EVENT));
|
||||
};
|
||||
|
||||
/** Fehler einer Zeilenaktion: 409 heisst, der Stand war veraltet — Text zeigen und neu laden. */
|
||||
const handleFailure = (err: unknown, conflictKey: string) => {
|
||||
setNotice(
|
||||
err instanceof ReminderRequestError && err.status === 409
|
||||
? t(conflictKey)
|
||||
: t('reminder.saveError'),
|
||||
);
|
||||
void refetch();
|
||||
};
|
||||
|
||||
const markDone = async (id: string) => {
|
||||
setNotice(null);
|
||||
try {
|
||||
await deleteReminder(id);
|
||||
afterMutation();
|
||||
} catch (err) {
|
||||
handleFailure(err, 'reminder.saveError');
|
||||
}
|
||||
};
|
||||
|
||||
const snooze = async (r: Reminder, preset: SnoozePreset) => {
|
||||
setNotice(null);
|
||||
setSnoozeOpenId(null);
|
||||
try {
|
||||
const target = snoozeTarget(preset, new Date(r.dueAt), new Date());
|
||||
await snoozeReminder(r.id, target.toISOString());
|
||||
afterMutation();
|
||||
} catch (err) {
|
||||
handleFailure(err, 'reminder.notDue');
|
||||
}
|
||||
};
|
||||
|
||||
// „Heute“ oder „Morgen“ aus dem tatsaechlich berechneten Zeitpunkt: kurz nach
|
||||
// Mitternacht liegt das naechste Vorkommen der Uhrzeit noch heute.
|
||||
const tomorrowLabel = (r: Reminder) => {
|
||||
const target = snoozeTarget('tomorrow', new Date(r.dueAt), new Date(now));
|
||||
const time = timeOnly.format(target);
|
||||
return isSameLocalDay(target, new Date(now))
|
||||
? t('reminder.snoozeToday', { time })
|
||||
: t('reminder.snoozeTomorrow', { time });
|
||||
};
|
||||
|
||||
const remove = async (id: string) => {
|
||||
setNotice(null);
|
||||
setConfirmDeleteId(null);
|
||||
try {
|
||||
await deleteReminder(id);
|
||||
afterMutation();
|
||||
} catch (err) {
|
||||
handleFailure(err, 'reminder.saveError');
|
||||
}
|
||||
};
|
||||
|
||||
const blocked = browserPermissionState() === 'denied';
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2 p-3" data-testid="reminder-widget">
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto">
|
||||
{loadError && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{t('reminder.loadError')}
|
||||
</p>
|
||||
)}
|
||||
{notice && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
{!loadError && reminders !== null && sorted.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('reminder.empty')}</p>
|
||||
)}
|
||||
{sorted.map((r) => {
|
||||
const due = new Date(r.dueAt).getTime() <= now;
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
data-testid="reminder-row"
|
||||
data-due={due ? 'true' : 'false'}
|
||||
className={
|
||||
due
|
||||
? 'rounded-md border border-status-warn bg-status-warn/10 px-3 py-2'
|
||||
: 'rounded-md border border-border bg-background/60 px-3 py-2'
|
||||
}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<p className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
|
||||
{r.title}
|
||||
</p>
|
||||
{r.emailEnabled && (
|
||||
<span
|
||||
className="shrink-0 text-muted-foreground"
|
||||
role="img"
|
||||
aria-label={t('reminder.emailOn')}
|
||||
title={t('reminder.emailOn')}
|
||||
data-testid="reminder-email-icon"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<path d="m3 7 9 6 9-6" />
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{due && (
|
||||
<span className="shrink-0 rounded-full bg-status-warn/20 px-2 py-0.5 text-xs font-semibold text-status-warn-fg">
|
||||
{t('reminder.due')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{dateTime.format(new Date(r.dueAt))}</p>
|
||||
{r.description !== '' && (
|
||||
<p className="mt-1 line-clamp-2 whitespace-pre-line text-xs text-muted-foreground">
|
||||
{r.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{due ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void markDone(r.id)}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{t('reminder.done')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSnoozeOpenId(snoozeOpenId === r.id ? null : r.id)}
|
||||
aria-expanded={snoozeOpenId === r.id}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('reminder.snooze')}
|
||||
</button>
|
||||
{snoozeOpenId === r.id && (
|
||||
<div className="flex w-full flex-wrap gap-2">
|
||||
{SNOOZE_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => void snooze(r, preset)}
|
||||
className="btn btn-subtle border-border"
|
||||
>
|
||||
{preset === '10m' && t('reminder.snooze10m')}
|
||||
{preset === '1h' && t('reminder.snooze1h')}
|
||||
{preset === 'tomorrow' && tomorrowLabel(r)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{confirmDeleteId === r.id ? (
|
||||
<>
|
||||
<span className="text-xs text-foreground">{t('reminder.deleteConfirm')}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void remove(r.id)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('reminder.yes')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDeleteId(null)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
{t('reminder.no')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNotice(null);
|
||||
setModal({ reminder: r });
|
||||
}}
|
||||
className="btn btn-subtle btn-icon"
|
||||
aria-label={t('reminder.edit')}
|
||||
title={t('reminder.edit')}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDeleteId(r.id)}
|
||||
className="btn btn-subtle btn-icon"
|
||||
aria-label={t('reminder.delete')}
|
||||
title={t('reminder.delete')}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3 6h18" />
|
||||
<path d="M8 6V4h8v2" />
|
||||
<path d="M19 6l-1 14H6L5 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{blocked && (
|
||||
<p className="text-xs text-muted-foreground" data-testid="reminder-permission-hint">
|
||||
{t('reminder.permissionDenied')}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNotice(null);
|
||||
setModal({ reminder: null });
|
||||
}}
|
||||
className="btn btn-secondary shrink-0 self-start"
|
||||
>
|
||||
{t('reminder.add')}
|
||||
</button>
|
||||
{modal && (
|
||||
<ReminderFormModal
|
||||
reminder={modal.reminder}
|
||||
emailStatus={emailStatus}
|
||||
onClose={() => setModal(null)}
|
||||
onSaved={afterMutation}
|
||||
onStale={() => void refetch()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -67,6 +67,12 @@ const PATHS: Record<string, ReactNode> = {
|
||||
<path d="M6 6h.01M6 18h.01" />
|
||||
</>
|
||||
),
|
||||
reminder: (
|
||||
<>
|
||||
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
|
||||
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
export function WidgetIcon({
|
||||
|
||||
@@ -71,7 +71,9 @@ describe('WidgetWrapper', () => {
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'hideTitle' }));
|
||||
const toggle = screen.getByRole('button', { name: 'hideTitle' });
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||
fireEvent.click(toggle);
|
||||
expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: true });
|
||||
// im Bearbeitungsmodus bleibt der Titel sichtbar
|
||||
expect(screen.getByRole('article')).not.toHaveAttribute('data-hide-title');
|
||||
@@ -86,7 +88,11 @@ describe('WidgetWrapper', () => {
|
||||
onRemove={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'showTitle' }));
|
||||
// quick-260930: gleiche Beschriftung, der Zustand steckt in aria-pressed.
|
||||
const toggle = screen.getByRole('button', { name: 'hideTitle' });
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||
expect(screen.queryByRole('button', { name: 'showTitle' })).toBeNull();
|
||||
fireEvent.click(toggle);
|
||||
expect(setWidgetConfig).toHaveBeenCalledWith('w-uhr', { hideTitle: false });
|
||||
|
||||
rerender(
|
||||
|
||||
@@ -8,12 +8,13 @@ import { WidgetIcon } from './widget-icon';
|
||||
|
||||
/**
|
||||
* Kacheltypen ohne eigene Kopfzeile, denen der Rahmen eine einheitliche
|
||||
* Kopfzeile (Symbol + Name) gibt (Design „Mosaik“, Runde 2). Notiz,
|
||||
* Kopfzeile (Symbol + Name) gibt (Design „Mosaik“, Runde 2; Erinnerungen seit
|
||||
* quick-260929-if2). Notiz,
|
||||
* Favoriten, Proxmox und XFrame bringen ihre eigene (mit editierbarem Titel)
|
||||
* mit; Suche und Bilderrahmen bleiben bewusst randlos — ein Suchfeld bzw.
|
||||
* ein Foto braucht keine Ueberschrift.
|
||||
*/
|
||||
const FRAME_HEADER_TYPES = new Set(['calendar', 'clock', 'calculator', 'stopwatch']);
|
||||
const FRAME_HEADER_TYPES = new Set(['calendar', 'clock', 'calculator', 'stopwatch', 'reminder']);
|
||||
|
||||
/**
|
||||
* Kacheltypen mit Titelzeile — nur diese bekommen im Bearbeitungsmodus den
|
||||
@@ -47,7 +48,12 @@ interface WidgetWrapperProps {
|
||||
* .widgetNoDrag), entscheidet der cancel-Selektor in dashboard-grid.tsx.
|
||||
* Renders the actual widget component via WIDGET_REGISTRY lookup.
|
||||
*/
|
||||
export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }: WidgetWrapperProps) {
|
||||
export function WidgetWrapper({
|
||||
widget,
|
||||
isEditMode,
|
||||
onRemove,
|
||||
enterIndex = 0,
|
||||
}: WidgetWrapperProps) {
|
||||
const t = useTranslations('widgets');
|
||||
const definition = WIDGET_REGISTRY[widget.widgetType as WidgetType];
|
||||
const WidgetComponent = definition?.component;
|
||||
@@ -102,8 +108,11 @@ export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }:
|
||||
void setWidgetConfig(widget.id, { hideTitle: !titleHidden });
|
||||
}}
|
||||
className={`ml-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-card transition-colors hover:bg-muted hover:text-foreground ${titleHidden ? 'text-muted-foreground/60' : 'text-muted-foreground'}`}
|
||||
aria-label={titleHidden ? t('showTitle') : t('hideTitle')}
|
||||
title={titleHidden ? t('showTitle') : t('hideTitle')}
|
||||
// quick-260930: feste Beschriftung plus aria-pressed — ein
|
||||
// Umschalter, dessen Name mit dem Zustand wechselt, liest sich
|
||||
// doppelt („Titel einblenden, gedrueckt“).
|
||||
aria-label={t('hideTitle')}
|
||||
title={t('hideTitle')}
|
||||
aria-pressed={titleHidden}
|
||||
data-no-drag=""
|
||||
>
|
||||
@@ -166,31 +175,29 @@ export function WidgetWrapper({ widget, isEditMode, onRemove, enterIndex = 0 }:
|
||||
quick-260916-dyv: Die Kopfleiste liegt als Overlay ueber dem Rumpf und
|
||||
aendert die Hoehenkette nicht. */}
|
||||
<div className="flex h-full flex-col">
|
||||
{WidgetComponent && definition && FRAME_HEADER_TYPES.has(widget.widgetType) && (
|
||||
<div className="widget-head" data-testid="widget-frame-head">
|
||||
<WidgetIcon type={widget.widgetType} />
|
||||
<h2 className="truncate font-semibold text-foreground">{t(definition.nameKey)}</h2>
|
||||
</div>
|
||||
)}
|
||||
<div className="@container-size min-h-0 flex-1">
|
||||
{WidgetComponent ? (
|
||||
<WidgetComponent
|
||||
instanceId={widget.id}
|
||||
config={widget.config}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
) : (
|
||||
/* quick-260922-m1h: Kein Bauteil zu diesem Typ — entweder eine alte
|
||||
{WidgetComponent && definition && FRAME_HEADER_TYPES.has(widget.widgetType) && (
|
||||
<div className="widget-head" data-testid="widget-frame-head">
|
||||
<WidgetIcon type={widget.widgetType} />
|
||||
<h2 className="truncate font-semibold text-foreground">{t(definition.nameKey)}</h2>
|
||||
</div>
|
||||
)}
|
||||
<div className="@container-size min-h-0 flex-1">
|
||||
{WidgetComponent ? (
|
||||
<WidgetComponent
|
||||
instanceId={widget.id}
|
||||
config={widget.config}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
) : (
|
||||
/* quick-260922-m1h: Kein Bauteil zu diesem Typ — entweder eine alte
|
||||
Kachel eines entfernten Typs oder (ab der ersten Modul-Kachel) eine
|
||||
Kachel, deren Modul dem Benutzer nicht freigegeben ist. Vorher
|
||||
stand hier der rohe Typname, der dem Anwender nichts sagte. */
|
||||
<div className="flex h-full items-center justify-center p-3">
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{t('unavailable')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex h-full items-center justify-center p-3">
|
||||
<p className="text-center text-sm text-muted-foreground">{t('unavailable')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { installErrorBuffer } from '@/lib/error-buffer';
|
||||
import { Header } from '@/components/layout/header';
|
||||
import { Sidebar } from '@/components/layout/sidebar';
|
||||
import { ReleaseNoticeHost } from '@/components/release-notice/release-notice-host';
|
||||
import { ReminderNotifier } from '@/components/reminders/reminder-notifier';
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const isCollapsed = useSidebarStore((s) => s.isCollapsed);
|
||||
@@ -41,6 +42,10 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
{/* "Was ist neu"-Fenster nach einem Versionswechsel (quick-260925-bow):
|
||||
nur hier im Portal-Rahmen, nie auf der Anmeldeseite. */}
|
||||
<ReleaseNoticeHost />
|
||||
{/* Erinnerungen (quick-260929-if2): bewusst GLOBAL im Rahmen, damit die
|
||||
Benachrichtigung auf jeder Portalseite ausloest — nicht nur, wenn die
|
||||
Kachel auf dem Dashboard sichtbar ist. Rendert nichts. */}
|
||||
<ReminderNotifier />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,8 +8,18 @@ vi.mock('next/navigation', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => (
|
||||
<a href={href} className={className}>{children}</a>
|
||||
default: ({
|
||||
children,
|
||||
href,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
className?: string;
|
||||
}) => (
|
||||
<a href={href} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -100,9 +110,7 @@ const mockActiveModules = [
|
||||
function stubFetch() {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) }),
|
||||
),
|
||||
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(mockActiveModules) })),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -316,7 +324,9 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByText('Mein Wiki')).toBeInTheDocument();
|
||||
});
|
||||
const groups = Array.from(
|
||||
screen.getByRole('group', { name: 'Kategorien' }).querySelectorAll(':scope > ul > li > button span.truncate'),
|
||||
screen
|
||||
.getByRole('group', { name: 'Kategorien' })
|
||||
.querySelectorAll(':scope > ul > li > button span.truncate'),
|
||||
).map((n) => n.textContent);
|
||||
expect(groups.at(-1)).toBe('custom-modules');
|
||||
expect(groups).toContain('fleet');
|
||||
@@ -340,12 +350,40 @@ describe('Sidebar', () => {
|
||||
render(<Sidebar />);
|
||||
|
||||
const hrefs = () =>
|
||||
Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) => a.getAttribute('href'));
|
||||
Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) =>
|
||||
a.getAttribute('href'),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(hrefs()).toContain('/modules/custom/cm-1');
|
||||
});
|
||||
expect(hrefs().at(-1)).toBe('/modules/custom/cm-1');
|
||||
expect(hrefs().indexOf('/modules/custom/cm-2')).toBeLessThan(hrefs().indexOf('/modules/custom/cm-1'));
|
||||
expect(hrefs().indexOf('/modules/custom/cm-2')).toBeLessThan(
|
||||
hrefs().indexOf('/modules/custom/cm-1'),
|
||||
);
|
||||
});
|
||||
|
||||
it('eingeklappt dieselbe Gruppenfolge wie ausgeklappt (eigenes Modul bei seiner Kategorie)', async () => {
|
||||
mockCustomModules = [
|
||||
customModule({ id: 'cm-1', category: 'Domain-Tools', name: 'Mein Tool' }),
|
||||
customModule({ id: 'cm-2', category: 'custom-modules', name: 'Mein Wiki' }),
|
||||
];
|
||||
mockSidebarCollapsed(true);
|
||||
const Sidebar = await importSidebar();
|
||||
render(<Sidebar />);
|
||||
|
||||
const hrefs = () =>
|
||||
Array.from(document.querySelectorAll('aside ul.border-t a')).map((a) =>
|
||||
a.getAttribute('href'),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(hrefs()).toContain('/modules/custom/cm-2');
|
||||
});
|
||||
expect(hrefs()).toEqual([
|
||||
'/modules/Domain-Tools/domaincheck',
|
||||
'/modules/custom/cm-1',
|
||||
'/modules/Utilities/converter',
|
||||
'/modules/custom/cm-2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('eingebaute Module stehen innerhalb einer Kategorie vor eigenen', async () => {
|
||||
@@ -360,7 +398,10 @@ describe('Sidebar', () => {
|
||||
.getByText('Domain-Tools')
|
||||
.closest('li')
|
||||
?.querySelectorAll('a span.truncate');
|
||||
expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual(['Domaincheck', 'Aaa Eigenes']);
|
||||
expect(Array.from(items ?? []).map((n) => n.textContent)).toEqual([
|
||||
'Domaincheck',
|
||||
'Aaa Eigenes',
|
||||
]);
|
||||
});
|
||||
|
||||
it('auf /modules/custom/<id> traegt genau dieser Eintrag die Auswahlmarke', async () => {
|
||||
@@ -379,7 +420,10 @@ describe('Sidebar', () => {
|
||||
screen.getByText('Anderes').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.getByText('Domaincheck').closest('a')?.querySelector('[data-testid="nav-selection-pill"]'),
|
||||
screen
|
||||
.getByText('Domaincheck')
|
||||
.closest('a')
|
||||
?.querySelector('[data-testid="nav-selection-pill"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { CUSTOM_MODULE_CATEGORY } from '@tessera/shared';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { TesseraLogo } from '@/components/brand/tessera-logo';
|
||||
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||
import { SidebarSearch } from '@/components/layout/sidebar-search';
|
||||
import { AppVersionBadge } from '@/components/layout/app-version-badge';
|
||||
import { ModuleTile } from '@/components/modules/module-tile';
|
||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||
import { useNavStore } from '@/lib/stores/nav-store';
|
||||
import { DashboardGreeting } from '@/components/dashboard/dashboard-greeting';
|
||||
import { AppVersionBadge } from '@/components/layout/app-version-badge';
|
||||
import { SidebarSearch } from '@/components/layout/sidebar-search';
|
||||
import { ModuleTile } from '@/components/modules/module-tile';
|
||||
import { type CustomModule, listCustomModules } from '@/lib/custom-modules-api';
|
||||
import { useMarketplaceStore } from '@/lib/stores/marketplace-store';
|
||||
import { useNavStore } from '@/lib/stores/nav-store';
|
||||
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
||||
import { useCategoryLabel } from '@/lib/use-category-label';
|
||||
|
||||
/** Fluent-Auswahlmarke: 3 x 16 px Pille am linken Rand des gewaehlten Eintrags. */
|
||||
function SelectionPill() {
|
||||
@@ -109,13 +109,17 @@ export function Sidebar() {
|
||||
const linkClass = (href: string) => itemClass(isActive(href));
|
||||
|
||||
const isEntryActive = (entry: SidebarEntry) =>
|
||||
isActive(entry.href) || (entry.extraActiveHref !== undefined && isActive(entry.extraActiveHref));
|
||||
isActive(entry.href) ||
|
||||
(entry.extraActiveHref !== undefined && isActive(entry.extraActiveHref));
|
||||
|
||||
// Moduleintraege unter einer Kategorie eine Stufe kleiner als die
|
||||
// Hauptpunkte (quick-260928-wtt): 32 statt 36 px hoch, 13 statt 14 px Schrift;
|
||||
// die 20-px-Modulkachel passt weiter hinein.
|
||||
const moduleItemClass = (active: boolean) =>
|
||||
itemClass(active).replace('h-9', 'h-8').replace('gap-3', 'gap-2.5').replace('text-sm', 'text-[13px]');
|
||||
itemClass(active)
|
||||
.replace('h-9', 'h-8')
|
||||
.replace('gap-3', 'gap-2.5')
|
||||
.replace('text-sm', 'text-[13px]');
|
||||
|
||||
// Eingeklappt (Nutzerwunsch 29.09.): Symbole etwas groesser (Kachel 24 statt
|
||||
// 20 px, Navigationssymbole 22 statt 20 px), Eintraege dichter — 34 px hoch
|
||||
@@ -186,28 +190,29 @@ export function Sidebar() {
|
||||
});
|
||||
};
|
||||
|
||||
// Eingeklappt in derselben Gruppenfolge wie ausgeklappt — „Eigene Module“
|
||||
// also auch hier immer zuletzt.
|
||||
// Eintraege nach Kategorie gruppiert: „Eigene Module“ steht immer zuletzt,
|
||||
// die uebrigen in Fundreihenfolge. Grundlage fuer BEIDE Ansichten.
|
||||
const orderedCategories = useMemo(() => {
|
||||
const categories = new Map<string, SidebarEntry[]>();
|
||||
for (const entry of entries) {
|
||||
const list = categories.get(entry.category);
|
||||
if (list) list.push(entry);
|
||||
else categories.set(entry.category, [entry]);
|
||||
}
|
||||
return Array.from(categories.entries()).sort(
|
||||
([a], [b]) => Number(a === CUSTOM_MODULE_CATEGORY) - Number(b === CUSTOM_MODULE_CATEGORY),
|
||||
);
|
||||
}, [entries]);
|
||||
|
||||
// Eingeklappt in derselben Gruppenfolge wie ausgeklappt — die Eintraege
|
||||
// stehen gruppenweise hintereinander, „Eigene Module“ also auch hier zuletzt.
|
||||
const collapsedEntries = useMemo(
|
||||
() =>
|
||||
[...entries].sort(
|
||||
(a, b) =>
|
||||
Number(a.category === CUSTOM_MODULE_CATEGORY) - Number(b.category === CUSTOM_MODULE_CATEGORY),
|
||||
),
|
||||
[entries],
|
||||
() => orderedCategories.flatMap(([, categoryEntries]) => categoryEntries),
|
||||
[orderedCategories],
|
||||
);
|
||||
|
||||
const filteredCategories = useMemo(() => {
|
||||
const categories = new Map<string, SidebarEntry[]>();
|
||||
for (const entry of entries) {
|
||||
if (!categories.has(entry.category)) categories.set(entry.category, []);
|
||||
categories.get(entry.category)!.push(entry);
|
||||
}
|
||||
|
||||
// „Eigene Module“ steht immer zuletzt, die uebrigen in Fundreihenfolge.
|
||||
const ordered = Array.from(categories.entries()).sort(
|
||||
([a], [b]) => Number(a === CUSTOM_MODULE_CATEGORY) - Number(b === CUSTOM_MODULE_CATEGORY),
|
||||
);
|
||||
const ordered = orderedCategories;
|
||||
if (!searchQuery) return ordered;
|
||||
|
||||
const q = searchQuery.toLowerCase();
|
||||
@@ -221,10 +226,22 @@ export function Sidebar() {
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [entries, searchQuery, categoryLabel]);
|
||||
}, [orderedCategories, searchQuery, categoryLabel]);
|
||||
|
||||
const navIcon = (children: React.ReactNode) => (
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width={isCollapsed ? 22 : 20} height={isCollapsed ? 22 : 20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={isCollapsed ? 22 : 20}
|
||||
height={isCollapsed ? 22 : 20}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="shrink-0"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
@@ -234,7 +251,12 @@ export function Sidebar() {
|
||||
<nav className={`flex-1 overflow-y-auto py-3 ${isCollapsed ? 'px-2' : 'px-3'}`}>
|
||||
<ul className={`flex flex-col ${isCollapsed ? 'gap-px' : 'gap-0.5'}`}>
|
||||
<li>
|
||||
<Link href="/" className={isCollapsed ? collapsedItemClass(isActive('/')) : linkClass('/')} aria-label={t('dashboard')} title={isCollapsed ? t('dashboard') : undefined}>
|
||||
<Link
|
||||
href="/"
|
||||
className={isCollapsed ? collapsedItemClass(isActive('/')) : linkClass('/')}
|
||||
aria-label={t('dashboard')}
|
||||
title={isCollapsed ? t('dashboard') : undefined}
|
||||
>
|
||||
{isActive('/') && <SelectionPill />}
|
||||
{navIcon(
|
||||
<>
|
||||
@@ -248,7 +270,16 @@ export function Sidebar() {
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/marketplace" className={isCollapsed ? collapsedItemClass(isActive('/marketplace')) : linkClass('/marketplace')} aria-label={t('marketplace')} title={isCollapsed ? t('marketplace') : undefined}>
|
||||
<Link
|
||||
href="/marketplace"
|
||||
className={
|
||||
isCollapsed
|
||||
? collapsedItemClass(isActive('/marketplace'))
|
||||
: linkClass('/marketplace')
|
||||
}
|
||||
aria-label={t('marketplace')}
|
||||
title={isCollapsed ? t('marketplace') : undefined}
|
||||
>
|
||||
{isActive('/marketplace') && <SelectionPill />}
|
||||
{navIcon(
|
||||
<>
|
||||
@@ -268,9 +299,18 @@ export function Sidebar() {
|
||||
const active = isEntryActive(entry);
|
||||
return (
|
||||
<li key={entry.key}>
|
||||
<Link href={entry.href} className={collapsedItemClass(active)} aria-label={entry.name} title={entry.name}>
|
||||
<Link
|
||||
href={entry.href}
|
||||
className={collapsedItemClass(active)}
|
||||
aria-label={entry.name}
|
||||
title={entry.name}
|
||||
>
|
||||
{active && <SelectionPill />}
|
||||
<ModuleTile slug={entry.tileSlug} size={24} tone={active ? 'accent' : 'neutral'} />
|
||||
<ModuleTile
|
||||
slug={entry.tileSlug}
|
||||
size={24}
|
||||
tone={active ? 'accent' : 'neutral'}
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
@@ -290,9 +330,7 @@ export function Sidebar() {
|
||||
{t('categories.empty')}
|
||||
</div>
|
||||
) : filteredCategories.length === 0 ? (
|
||||
<div className="px-2.5 py-2 text-sm text-muted-foreground">
|
||||
{t('noResults')}
|
||||
</div>
|
||||
<div className="px-2.5 py-2 text-sm text-muted-foreground">{t('noResults')}</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{filteredCategories.map(([category, categoryEntries]) => {
|
||||
@@ -307,7 +345,19 @@ export function Sidebar() {
|
||||
className="group flex h-8 w-full items-center gap-1.5 rounded-md px-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
>
|
||||
<span className="truncate">{categoryLabel(category)}</span>
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`ml-auto shrink-0 opacity-70 transition-transform duration-150 ${open ? '' : '-rotate-90'}`}
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -319,7 +369,11 @@ export function Sidebar() {
|
||||
<li key={entry.key}>
|
||||
<Link href={entry.href} className={moduleItemClass(active)}>
|
||||
{active && <SelectionPill />}
|
||||
<ModuleTile slug={entry.tileSlug} size={20} tone={active ? 'accent' : 'neutral'} />
|
||||
<ModuleTile
|
||||
slug={entry.tileSlug}
|
||||
size={20}
|
||||
tone={active ? 'accent' : 'neutral'}
|
||||
/>
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -353,7 +407,19 @@ export function Sidebar() {
|
||||
aria-label={isCollapsed ? t('expand') : t('collapse')}
|
||||
title={isCollapsed ? t('expand') : undefined}
|
||||
>
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={`shrink-0 transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M9 3v18" />
|
||||
<path d="m16 15-3-3 3-3" />
|
||||
@@ -377,9 +443,7 @@ export function Sidebar() {
|
||||
<aside
|
||||
className="fixed left-0 top-[var(--header-height)] z-30 hidden h-[calc(100vh-var(--header-height))] border-r border-sidebar-border transition-[width] duration-200 md:block"
|
||||
style={{
|
||||
width: isCollapsed
|
||||
? 'var(--sidebar-width-collapsed)'
|
||||
: 'var(--sidebar-width)',
|
||||
width: isCollapsed ? 'var(--sidebar-width-collapsed)' : 'var(--sidebar-width)',
|
||||
}}
|
||||
>
|
||||
{sidebarContent}
|
||||
@@ -408,7 +472,18 @@ export function Sidebar() {
|
||||
aria-label={tCommon('close')}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-appbar-muted transition-colors hover:bg-appbar-hover hover:text-appbar-foreground"
|
||||
>
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
|
||||
import { CustomModuleRequestError } from '@/lib/custom-modules-api';
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => {
|
||||
const map: Record<string, string> = {
|
||||
openInNewTab: 'In neuem Tab öffnen',
|
||||
embedHint: 'Manche Seiten lassen sich nicht einbetten.',
|
||||
notFound: 'Dieses Modul gibt es nicht mehr.',
|
||||
loadError: 'Dieses Modul konnte nicht geladen werden.',
|
||||
invalidUrl: 'Ungültige Adresse',
|
||||
loading: 'Wird geladen',
|
||||
};
|
||||
@@ -59,7 +60,7 @@ describe('CustomModuleView', () => {
|
||||
expect(mockGetCustomModule).toHaveBeenCalledWith('abc');
|
||||
});
|
||||
|
||||
it('zeigt Namen und immer sichtbar den Link „In neuem Tab öffnen“', async () => {
|
||||
it('zeigt immer den Link „In neuem Tab öffnen“, aber keine Name- oder Hinweiszeile', async () => {
|
||||
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
|
||||
render(<CustomModuleView id="abc" />);
|
||||
|
||||
@@ -67,7 +68,21 @@ describe('CustomModuleView', () => {
|
||||
expect(link).toHaveAttribute('href', 'https://example.com/wiki');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
expect(screen.getByRole('heading', { name: 'Wiki' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('heading')).toBeNull();
|
||||
});
|
||||
|
||||
it('setzt den Link in die App-Leiste, wenn deren Einhaengepunkt da ist', async () => {
|
||||
const slot = document.createElement('div');
|
||||
slot.id = 'header-actions-slot';
|
||||
document.body.appendChild(slot);
|
||||
try {
|
||||
mockGetCustomModule.mockResolvedValue(mod('https://example.com/wiki'));
|
||||
render(<CustomModuleView id="abc" />);
|
||||
const link = await screen.findByRole('link', { name: 'In neuem Tab öffnen' });
|
||||
expect(slot.contains(link)).toBe(true);
|
||||
} finally {
|
||||
slot.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -93,4 +108,19 @@ describe('CustomModuleView', () => {
|
||||
});
|
||||
expect(container.querySelector('iframe')).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['500', new CustomModuleRequestError(500, 'Internal server error')],
|
||||
['403', new CustomModuleRequestError(403, 'Forbidden')],
|
||||
['Netzwerkfehler', new TypeError('Failed to fetch')],
|
||||
])('zeigt bei %s „konnte nicht geladen werden“ statt „nicht gefunden“', async (_, err) => {
|
||||
mockGetCustomModule.mockRejectedValue(err);
|
||||
const { container } = render(<CustomModuleView id="abc" />);
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'Dieses Modul konnte nicht geladen werden.',
|
||||
);
|
||||
expect(screen.queryByText('Dieses Modul gibt es nicht mehr.')).toBeNull();
|
||||
expect(container.querySelector('iframe')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,24 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { XFRAME_SANDBOX } from '@/components/dashboard/widgets/xframe-config';
|
||||
import { HEADER_ACTIONS_SLOT_ID } from '@/components/layout/header-slot';
|
||||
import { type CustomModule, checkCustomModuleUrl, getCustomModule } from '@/lib/custom-modules-api';
|
||||
|
||||
type LoadState = { status: 'loading' } | { status: 'missing' } | { status: 'ready'; mod: CustomModule };
|
||||
type LoadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'missing' }
|
||||
| { status: 'failed' }
|
||||
| { status: 'ready'; mod: CustomModule };
|
||||
|
||||
/**
|
||||
* Rahmen-Ansicht eines eigenen Moduls (quick-260929-9wc, D-06): schmale
|
||||
* Leiste mit Name, Hinweis und dem immer sichtbaren Knopf „In neuem Tab
|
||||
* oeffnen“, darunter der flaechenfuellende Rahmen. Sandbox-Wert und
|
||||
* Rahmen-Ansicht eines eigenen Moduls (quick-260929-9wc, D-06): der Rahmen
|
||||
* fuellt den ganzen Inhaltsbereich. Der Name steht ohnehin in der App-Leiste,
|
||||
* der immer sichtbare Knopf „In neuem Tab oeffnen“ sitzt dort rechts (Portal
|
||||
* in HEADER_ACTIONS_SLOT_ID, wie die Dashboard-Aktionen); Name- und
|
||||
* Hinweiszeile ueber dem Rahmen entfielen auf Nutzerwunsch (29.09.2026). Sandbox-Wert und
|
||||
* Attribute wie im XFrame-Widget (`XFRAME_SANDBOX`, kein `allow`, kein
|
||||
* Referrer). Iframe und Link entstehen NUR bei einer https-Adresse ohne
|
||||
* Zugangsdaten — auch eine von Hand veraenderte Datenbankzeile mit
|
||||
* `javascript:` oder `http:` wird nie gerendert (T-9WC-03).
|
||||
* Nur ein 404 heisst „gibt es nicht mehr“; 403, 500 oder ein Netzwerkfehler
|
||||
* zeigen „konnte nicht geladen werden“ — der Eintrag existiert vielleicht noch.
|
||||
*/
|
||||
export function CustomModuleView({ id }: { id: string }) {
|
||||
const t = useTranslations('customModules');
|
||||
const [state, setState] = useState<LoadState>({ status: 'loading' });
|
||||
const [actionsSlot, setActionsSlot] = useState<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
setActionsSlot(document.getElementById(HEADER_ACTIONS_SLOT_ID));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -29,7 +43,7 @@ export function CustomModuleView({ id }: { id: string }) {
|
||||
setState(mod ? { status: 'ready', mod } : { status: 'missing' });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setState({ status: 'missing' });
|
||||
if (!cancelled) setState({ status: 'failed' });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -42,28 +56,47 @@ export function CustomModuleView({ id }: { id: string }) {
|
||||
if (state.status === 'missing') {
|
||||
return <div className="p-4 text-sm text-muted-foreground">{t('notFound')}</div>;
|
||||
}
|
||||
if (state.status === 'failed') {
|
||||
return (
|
||||
<div role="alert" className="p-4 text-sm text-destructive">
|
||||
{t('loadError')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { mod } = state;
|
||||
const urlOk = checkCustomModuleUrl(mod.url) === 'ok';
|
||||
|
||||
const openLink = urlOk ? (
|
||||
<a
|
||||
href={mod.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={actionsSlot ? 'btn btn-appbar' : 'btn btn-secondary self-end'}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M15 3h6v6" />
|
||||
<path d="M10 14 21 3" />
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
</svg>
|
||||
{t('openInNewTab')}
|
||||
</a>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-var(--header-height)-1.5rem)] min-h-[320px] flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-base font-semibold text-foreground">{mod.name}</h1>
|
||||
{urlOk && <p className="truncate text-xs text-muted-foreground">{t('embedHint')}</p>}
|
||||
</div>
|
||||
{urlOk && (
|
||||
<a
|
||||
href={mod.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-secondary shrink-0"
|
||||
>
|
||||
{t('openInNewTab')}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{openLink && (actionsSlot ? createPortal(openLink, actionsSlot) : openLink)}
|
||||
{urlOk ? (
|
||||
<iframe
|
||||
src={mod.url}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, string>) =>
|
||||
values?.title ? `${key}:${values.title}` : key,
|
||||
useLocale: () => 'de',
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/reminders-api', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/reminders-api')>('@/lib/reminders-api');
|
||||
return {
|
||||
REMINDERS_CHANGED_EVENT: actual.REMINDERS_CHANGED_EVENT,
|
||||
ReminderRequestError: actual.ReminderRequestError,
|
||||
listReminders: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/lib/reminder-notify', async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import('@/lib/reminder-notify')>('@/lib/reminder-notify');
|
||||
return { ...actual, showReminderNotification: vi.fn(async () => undefined) };
|
||||
});
|
||||
|
||||
import { resetNotifiedMemoryForTests, showReminderNotification } from '@/lib/reminder-notify';
|
||||
import { listReminders, ReminderRequestError } from '@/lib/reminders-api';
|
||||
import { ReminderNotifier } from './reminder-notifier';
|
||||
|
||||
const mockList = listReminders as ReturnType<typeof vi.fn>;
|
||||
const mockShow = showReminderNotification as ReturnType<typeof vi.fn>;
|
||||
|
||||
const NOW = new Date('2026-09-29T12:00:00.000Z');
|
||||
|
||||
function reminder(over: Partial<{ id: string; dueAt: string; description: string }> = {}) {
|
||||
return {
|
||||
id: 'r1',
|
||||
title: 'Zahnarzt',
|
||||
description: '',
|
||||
dueAt: new Date(NOW.getTime() + 30_000).toISOString(),
|
||||
emailEnabled: false,
|
||||
createdAt: NOW.toISOString(),
|
||||
updatedAt: NOW.toISOString(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
async function advance(ms: number) {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(ms);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
window.localStorage.clear();
|
||||
resetNotifiedMemoryForTests();
|
||||
mockList.mockReset();
|
||||
mockShow.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('ReminderNotifier', () => {
|
||||
it('meldet eine faellige Erinnerung genau einmal ueber mehrere Takte', async () => {
|
||||
mockList.mockResolvedValue([reminder()]);
|
||||
render(<ReminderNotifier />);
|
||||
await advance(0);
|
||||
expect(mockShow).not.toHaveBeenCalled(); // noch nicht faellig (D-02)
|
||||
await advance(40_000); // ueber dueAt (+30 s) hinweg
|
||||
await advance(30_000);
|
||||
expect(mockShow).toHaveBeenCalledTimes(1);
|
||||
expect(mockShow.mock.calls[0][0]).toMatchObject({
|
||||
title: 'reminder.notificationTitle:Zahnarzt',
|
||||
tag: `reminder-r1-${reminder().dueAt}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('meldet nichts, solange nichts faellig ist', async () => {
|
||||
mockList.mockResolvedValue([
|
||||
reminder({ dueAt: new Date(NOW.getTime() + 3600_000).toISOString() }),
|
||||
]);
|
||||
render(<ReminderNotifier />);
|
||||
await advance(30_000);
|
||||
expect(mockShow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('nimmt die Beschreibung als Text, gekuerzt auf 200 Zeichen', async () => {
|
||||
mockList.mockResolvedValue([
|
||||
reminder({
|
||||
dueAt: new Date(NOW.getTime() - 1000).toISOString(),
|
||||
description: 'x'.repeat(300),
|
||||
}),
|
||||
]);
|
||||
render(<ReminderNotifier />);
|
||||
await advance(0);
|
||||
expect(mockShow.mock.calls[0][0].body).toBe('x'.repeat(200));
|
||||
});
|
||||
|
||||
it('meldet nach geaendertem dueAt (Verschieben) erneut', async () => {
|
||||
const first = reminder({ dueAt: new Date(NOW.getTime() - 1000).toISOString() });
|
||||
mockList.mockResolvedValue([first]);
|
||||
render(<ReminderNotifier />);
|
||||
await advance(0);
|
||||
expect(mockShow).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verschoben auf +10 s (in der Zukunft), dann faellig
|
||||
mockList.mockResolvedValue([{ ...first, dueAt: new Date(Date.now() + 10_000).toISOString() }]);
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('tessera:reminders-changed'));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(mockShow).toHaveBeenCalledTimes(1);
|
||||
await advance(20_000);
|
||||
expect(mockShow).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('das Aenderungsereignis laedt die Liste neu', async () => {
|
||||
mockList.mockResolvedValue([]);
|
||||
render(<ReminderNotifier />);
|
||||
await advance(0);
|
||||
const before = mockList.mock.calls.length;
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('tessera:reminders-changed'));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(mockList.mock.calls.length).toBe(before + 1);
|
||||
});
|
||||
|
||||
it('pausiert bei 401 bis zum naechsten Fokus', async () => {
|
||||
mockList.mockRejectedValue(new ReminderRequestError(401));
|
||||
render(<ReminderNotifier />);
|
||||
await advance(0);
|
||||
const after401 = mockList.mock.calls.length;
|
||||
await advance(180_000);
|
||||
expect(mockList.mock.calls.length).toBe(after401);
|
||||
mockList.mockResolvedValue([]);
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(mockList.mock.calls.length).toBe(after401 + 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import { useLocale, useTranslations } from 'next-intl';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
claimNotification,
|
||||
remindersToNotify,
|
||||
showReminderNotification,
|
||||
withNotifyLock,
|
||||
} from '@/lib/reminder-notify';
|
||||
import {
|
||||
REMINDERS_CHANGED_EVENT,
|
||||
type Reminder,
|
||||
ReminderRequestError,
|
||||
listReminders,
|
||||
} from '@/lib/reminders-api';
|
||||
|
||||
/** Abstand der Abfrage beim Server. */
|
||||
const POLL_MS = 60_000;
|
||||
/** Abstand der lokalen Faelligkeitspruefung gegen die geladene Liste (ohne Netz). */
|
||||
const TICK_MS = 10_000;
|
||||
const BODY_MAX = 200;
|
||||
|
||||
/**
|
||||
* Globaler Melder fuer Erinnerungen (quick-260929-if2). Er haengt im
|
||||
* Portal-Rahmen (`AppShell`), NICHT in der Kachel: die Benachrichtigung muss
|
||||
* auf jeder Portalseite ausloesen, auch wenn das Dashboard gerade nicht
|
||||
* sichtbar ist. Rendert nichts.
|
||||
*
|
||||
* Ablauf: die Liste wird beim Einhaengen, alle 60 s, nach einer Aenderung
|
||||
* (`REMINDERS_CHANGED_EVENT`), bei Rueckkehr in den Vordergrund und bei
|
||||
* Fenster-Fokus geladen. Ein lokaler 10-s-Takt prueft die geladene Liste auf
|
||||
* Faelligkeit und meldet jede (id, dueAt) hoechstens einmal je Client —
|
||||
* mehrere Tabs teilen die lokale Merkliste und eine Web-Sperre (E-01, D-02).
|
||||
* Bei 401 (abgemeldet) ruht die Abfrage bis zum naechsten Fokus.
|
||||
*/
|
||||
export function ReminderNotifier() {
|
||||
const t = useTranslations('widgets');
|
||||
const locale = useLocale();
|
||||
const remindersRef = useRef<Reminder[]>([]);
|
||||
const pausedRef = useRef(false);
|
||||
// Uebersetzung und Sprache ueber Refs, damit die Effekte unten nicht bei
|
||||
// jedem Render neu aufgebaut werden.
|
||||
const textRef = useRef({ t, locale });
|
||||
textRef.current = { t, locale };
|
||||
|
||||
const notifyDue = useCallback(async () => {
|
||||
if (pausedRef.current) return;
|
||||
const now = Date.now();
|
||||
const due = remindersToNotify(remindersRef.current, now);
|
||||
if (due.length === 0) return;
|
||||
await withNotifyLock(async () => {
|
||||
for (const r of due) {
|
||||
if (!claimNotification(`${r.id}|${r.dueAt}`, Date.now())) continue;
|
||||
const { t: tr, locale: loc } = textRef.current;
|
||||
const body =
|
||||
r.description.trim() !== ''
|
||||
? r.description.trim().slice(0, BODY_MAX)
|
||||
: new Intl.DateTimeFormat(loc, { dateStyle: 'medium', timeStyle: 'short' }).format(
|
||||
new Date(r.dueAt),
|
||||
);
|
||||
await showReminderNotification({
|
||||
title: tr('reminder.notificationTitle', { title: r.title }),
|
||||
body,
|
||||
tag: `reminder-${r.id}-${r.dueAt}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (pausedRef.current) return;
|
||||
try {
|
||||
remindersRef.current = await listReminders();
|
||||
} catch (err) {
|
||||
if (err instanceof ReminderRequestError && err.status === 401) {
|
||||
pausedRef.current = true;
|
||||
}
|
||||
// Andere Fehler: still ignorieren, der naechste Takt versucht es erneut.
|
||||
return;
|
||||
}
|
||||
await notifyDue();
|
||||
}, [notifyDue]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const poll = setInterval(() => void load(), POLL_MS);
|
||||
const tick = setInterval(() => void notifyDue(), TICK_MS);
|
||||
|
||||
const resume = () => {
|
||||
pausedRef.current = false;
|
||||
void load();
|
||||
};
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') resume();
|
||||
};
|
||||
const onChanged = () => void load();
|
||||
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
window.addEventListener('focus', resume);
|
||||
window.addEventListener(REMINDERS_CHANGED_EVENT, onChanged);
|
||||
return () => {
|
||||
clearInterval(poll);
|
||||
clearInterval(tick);
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
window.removeEventListener('focus', resume);
|
||||
window.removeEventListener(REMINDERS_CHANGED_EVENT, onChanged);
|
||||
};
|
||||
}, [load, notifyDue]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
* Die vier neuen/geaenderten Aufrufe: `uploadFavoriteIcon` (FormData-Feld
|
||||
* `icon`, Groessenpruefung vor dem Netzwerkaufruf, 413/400/sonst ->
|
||||
* `FavoriteRequestError`), `removeFavoriteIcon` (DELETE), sowie
|
||||
* `createFavorite`/`updateFavorite` (422 -> `FavoriteRequestError` mit
|
||||
* `reason: 'iconUrlUnreachable'`, jeder andere Fehler bleibt eine generische
|
||||
* `Error` wie bisher). Muster `dashboard-images-api.test.ts`.
|
||||
* `createFavorite`/`updateFavorite` (jeder Fehler bleibt eine generische
|
||||
* `Error`; die 422-Abrufprobe ist seit 260929-lh3 entfallen). Muster
|
||||
* `dashboard-images-api.test.ts`.
|
||||
*/
|
||||
const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() }));
|
||||
|
||||
@@ -112,9 +112,9 @@ describe('favorites-api (quick-260923-lrr)', () => {
|
||||
expect(init.credentials).toBe('include');
|
||||
});
|
||||
|
||||
describe('createFavorite / updateFavorite — Abrufprobe (260923-lrr)', () => {
|
||||
it('createFavorite: 422 -> FavoriteRequestError reason iconUrlUnreachable', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 422 }));
|
||||
describe('createFavorite / updateFavorite — Fehler bleiben generisch (260929-lh3)', () => {
|
||||
it('createFavorite: 400 (ungueltige Logo-Adresse) -> generische Error, KEIN FavoriteRequestError', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 400 }));
|
||||
|
||||
const err = await createFavorite({
|
||||
widgetId: 'w1',
|
||||
@@ -123,8 +123,8 @@ describe('favorites-api (quick-260923-lrr)', () => {
|
||||
iconUrl: 'https://x.invalid/logo.png',
|
||||
}).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconUrlUnreachable');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).not.toBeInstanceOf(FavoriteRequestError);
|
||||
});
|
||||
|
||||
it('createFavorite: anderer Fehler -> wie bisher eine generische Error', async () => {
|
||||
@@ -138,15 +138,13 @@ describe('favorites-api (quick-260923-lrr)', () => {
|
||||
expect(err).not.toBeInstanceOf(FavoriteRequestError);
|
||||
});
|
||||
|
||||
it('updateFavorite: 422 -> FavoriteRequestError reason iconUrlUnreachable', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 422 }));
|
||||
it('updateFavorite: 400 (ungueltige Logo-Adresse) -> generische Error, KEIN FavoriteRequestError', async () => {
|
||||
mockFetch.mockResolvedValue(new Response('{}', { status: 400 }));
|
||||
|
||||
const err = await updateFavorite('fav-1', { iconUrl: 'https://x.invalid/logo.png' }).catch(
|
||||
(e) => e,
|
||||
);
|
||||
const err = await updateFavorite('fav-1', { iconUrl: 'https://x.invalid/logo.png' }).catch((e) => e);
|
||||
|
||||
expect(err).toBeInstanceOf(FavoriteRequestError);
|
||||
expect((err as FavoriteRequestError).reason).toBe('iconUrlUnreachable');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).not.toBeInstanceOf(FavoriteRequestError);
|
||||
});
|
||||
|
||||
it('updateFavorite: anderer Fehler -> wie bisher eine generische Error', async () => {
|
||||
|
||||
@@ -7,10 +7,9 @@
|
||||
* 260923-lrr — eigenes Symbol hochladen, Zwischenspeicher nach Aenderung
|
||||
* erneuern: `FavoriteLink` traegt jetzt `uploadedIconMime`/`iconVersion`
|
||||
* (optional, weil Testdaten und aeltere Antworten sie nicht tragen).
|
||||
* `createFavorite`/`updateFavorite` uebersetzen ein 422 (Abrufprobe der API
|
||||
* fuer eine nicht abrufbare Logo-Adresse) in `FavoriteRequestError`, damit
|
||||
* das Widget eine sprachrichtige Meldung zeigen kann, statt der bisherigen
|
||||
* generischen `Error`. `uploadFavoriteIcon`/`removeFavoriteIcon` sind neu,
|
||||
* `FavoriteRequestError` traegt den Grund einer abgewiesenen Symbol-Datei
|
||||
* (Widget bildet ihn auf eine sprachrichtige Meldung ab). 260929-lh3: die
|
||||
* 422-Abrufprobe fuer Logo-Adressen ist entfallen. `uploadFavoriteIcon`/`removeFavoriteIcon` sind neu,
|
||||
* Muster `uploadDashboardImage`/`deleteDashboardImage` (dashboard-images-api.ts).
|
||||
*/
|
||||
|
||||
@@ -34,7 +33,6 @@ export interface FavoriteLink {
|
||||
|
||||
/** Grund einer abgewiesenen Favoriten-Anfrage (260923-lrr). */
|
||||
export type FavoriteErrorReason =
|
||||
| 'iconUrlUnreachable'
|
||||
| 'iconTooLarge'
|
||||
| 'iconInvalidType'
|
||||
| 'iconUploadFailed';
|
||||
@@ -72,8 +70,9 @@ export async function fetchFavorites(widgetId: string): Promise<FavoriteLink[]>
|
||||
/**
|
||||
* Create a new favorite link.
|
||||
* Server-side icon discovery runs automatically if iconUrl is not provided.
|
||||
* 260923-lrr: eine explizite `iconUrl`, die sich serverseitig nicht laden
|
||||
* laesst, ergibt 422 -> `FavoriteRequestError('iconUrlUnreachable')`.
|
||||
* 260929-lh3: eine ausdrueckliche `iconUrl` wird auch gespeichert, wenn der
|
||||
* Server sie nicht abrufen kann (die Kachel laedt sie dann im Browser); die
|
||||
* frueheren 422-Antworten gibt es nicht mehr.
|
||||
*/
|
||||
export async function createFavorite(payload: {
|
||||
widgetId: string;
|
||||
@@ -87,7 +86,6 @@ export async function createFavorite(payload: {
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.status === 422) throw new FavoriteRequestError('iconUrlUnreachable');
|
||||
if (!res.ok) throw new Error('Failed to create favorite');
|
||||
|
||||
return res.json();
|
||||
@@ -96,8 +94,7 @@ export async function createFavorite(payload: {
|
||||
/**
|
||||
* Update an existing favorite link.
|
||||
* Pass iconUrl: null to clear a stored icon.
|
||||
* 260923-lrr: eine neue, nicht abrufbare `iconUrl` ergibt ebenso 422 ->
|
||||
* `FavoriteRequestError('iconUrlUnreachable')`.
|
||||
* 260929-lh3: wie `createFavorite` — keine 422-Abweisung wegen Abrufbarkeit mehr.
|
||||
*/
|
||||
export async function updateFavorite(
|
||||
id: string,
|
||||
@@ -109,7 +106,6 @@ export async function updateFavorite(
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (res.status === 422) throw new FavoriteRequestError('iconUrlUnreachable');
|
||||
if (!res.ok) throw new Error('Failed to update favorite');
|
||||
|
||||
return res.json();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
GRID_CANVAS_KEY,
|
||||
GRID_SCALE_FACTOR,
|
||||
GRID_VERSION,
|
||||
GRID_VERSION_KEY,
|
||||
@@ -36,7 +37,15 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
|
||||
const { layouts, migrated } = migrateGridLayouts(altLayouts());
|
||||
|
||||
expect(layouts.lg[0]).toEqual({
|
||||
i: 'a', x: 4, y: 4, w: 8, h: 6, minW: 8, minH: 4, moved: false, static: false,
|
||||
i: 'a',
|
||||
x: 4,
|
||||
y: 4,
|
||||
w: 8,
|
||||
h: 6,
|
||||
minW: 8,
|
||||
minH: 4,
|
||||
moved: false,
|
||||
static: false,
|
||||
});
|
||||
expect(layouts.lg[1]).toEqual({ i: 'b', x: 8, y: 0, w: 24, h: 4, maxW: 48, maxH: 16 });
|
||||
expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 8, h: 4 });
|
||||
@@ -53,7 +62,15 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
|
||||
const { layouts, migrated } = migrateGridLayouts({ ...alt, __gridVersion: 2 });
|
||||
|
||||
expect(layouts.lg[0]).toEqual({
|
||||
i: 'a', x: 2, y: 2, w: 4, h: 3, minW: 4, minH: 2, moved: false, static: false,
|
||||
i: 'a',
|
||||
x: 2,
|
||||
y: 2,
|
||||
w: 4,
|
||||
h: 3,
|
||||
minW: 4,
|
||||
minH: 2,
|
||||
moved: false,
|
||||
static: false,
|
||||
});
|
||||
expect(layouts.lg[1]).toEqual({ i: 'b', x: 4, y: 0, w: 12, h: 2, maxW: 24, maxH: 8 });
|
||||
expect(layouts.md[0]).toEqual({ i: 'a', x: 0, y: 0, w: 4, h: 2 });
|
||||
@@ -131,6 +148,7 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
|
||||
const future = migrateGridLayouts({ ...alt, __gridVersion: 4 });
|
||||
expect(future.layouts).toEqual(alt);
|
||||
expect(future.migrated).toBe(false);
|
||||
expect(future.newer).toBe(true);
|
||||
|
||||
const stringMarker = migrateGridLayouts({ ...alt, __gridVersion: '2' });
|
||||
expect(stringMarker.migrated).toBe(true);
|
||||
@@ -138,24 +156,132 @@ describe('grid-layout-migration (quick-260916-bwo)', () => {
|
||||
expect(Object.keys(stringMarker.layouts)).not.toContain(GRID_VERSION_KEY);
|
||||
|
||||
const odd = migrateGridLayouts({
|
||||
lg: [{ i: 'c', x: 'kaputt', y: 1, w: 2, h: 2, resizeHandles: ['se'], moved: true, static: false }],
|
||||
lg: [
|
||||
{
|
||||
i: 'c',
|
||||
x: 'kaputt',
|
||||
y: 1,
|
||||
w: 2,
|
||||
h: 2,
|
||||
resizeHandles: ['se'],
|
||||
moved: true,
|
||||
static: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(odd.layouts.lg[0]).toEqual({
|
||||
i: 'c', x: 'kaputt', y: 2, w: 8, h: 4, resizeHandles: ['se'], moved: true, static: false,
|
||||
i: 'c',
|
||||
x: 'kaputt',
|
||||
y: 2,
|
||||
w: 8,
|
||||
h: 4,
|
||||
resizeHandles: ['se'],
|
||||
moved: true,
|
||||
static: false,
|
||||
});
|
||||
expect(Number.isNaN(odd.layouts.lg[0].x)).toBe(false);
|
||||
expect(odd.migrated).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 7: Fremdwerte — Nicht-Arrays werden weggelassen, Nicht-Objekte liefern eine leere Anordnung', () => {
|
||||
const r = migrateGridLayouts({ lg: 'kaputt', md: null, sm: [{ i: 'a', x: 1, y: 1, w: 1, h: 1 }] });
|
||||
const r = migrateGridLayouts({
|
||||
lg: 'kaputt',
|
||||
md: null,
|
||||
sm: [{ i: 'a', x: 1, y: 1, w: 1, h: 1 }],
|
||||
});
|
||||
expect(Object.keys(r.layouts)).toEqual(['sm']);
|
||||
expect(r.layouts.sm[0]).toEqual({ i: 'a', x: 4, y: 2, w: 4, h: 2 });
|
||||
expect(r.migrated).toBe(true);
|
||||
|
||||
expect(migrateGridLayouts(null)).toEqual({ layouts: {}, migrated: false });
|
||||
expect(migrateGridLayouts(undefined)).toEqual({ layouts: {}, migrated: false });
|
||||
expect(migrateGridLayouts(42)).toEqual({ layouts: {}, migrated: false });
|
||||
expect(migrateGridLayouts([1, 2])).toEqual({ layouts: {}, migrated: false });
|
||||
expect(migrateGridLayouts(null)).toEqual({
|
||||
layouts: {},
|
||||
migrated: false,
|
||||
newer: false,
|
||||
canvas: null,
|
||||
});
|
||||
expect(migrateGridLayouts(undefined)).toEqual({
|
||||
layouts: {},
|
||||
migrated: false,
|
||||
newer: false,
|
||||
canvas: null,
|
||||
});
|
||||
expect(migrateGridLayouts(42)).toEqual({
|
||||
layouts: {},
|
||||
migrated: false,
|
||||
newer: false,
|
||||
canvas: null,
|
||||
});
|
||||
expect(migrateGridLayouts([1, 2])).toEqual({
|
||||
layouts: {},
|
||||
migrated: false,
|
||||
newer: false,
|
||||
canvas: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('Test 8 (quick-260930): nur eine HOEHERE Marke meldet newer — v1/v2/v3 nicht', () => {
|
||||
const alt = altLayouts();
|
||||
expect(migrateGridLayouts(alt).newer).toBe(false);
|
||||
expect(migrateGridLayouts({ ...alt, [GRID_VERSION_KEY]: 2 }).newer).toBe(false);
|
||||
expect(migrateGridLayouts({ ...alt, [GRID_VERSION_KEY]: GRID_VERSION }).newer).toBe(false);
|
||||
const future = migrateGridLayouts({ ...alt, [GRID_VERSION_KEY]: GRID_VERSION + 1 });
|
||||
expect(future.newer).toBe(true);
|
||||
// Unveraendert uebernommen, nichts skaliert.
|
||||
expect(future.layouts).toEqual(alt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('grid-layout-migration: Leinwand `__canvas` (Dashboard wie ein Bild mitskalieren)', () => {
|
||||
const v3 = () => ({ lg: [{ i: 'a', x: 0, y: 0, w: 8, h: 4 }], [GRID_VERSION_KEY]: GRID_VERSION });
|
||||
|
||||
it('liest eine gueltige Leinwand und laesst sie aus `layouts` heraus', () => {
|
||||
const r = migrateGridLayouts({ ...v3(), [GRID_CANVAS_KEY]: { w: 1600, h: 820 } });
|
||||
expect(r.canvas).toEqual({ w: 1600, h: 820 });
|
||||
expect(Object.keys(r.layouts)).toEqual(['lg']);
|
||||
expect(r.migrated).toBe(false);
|
||||
});
|
||||
|
||||
it('fehlende oder ungueltige Werte -> null', () => {
|
||||
const bad: unknown[] = [
|
||||
undefined,
|
||||
null,
|
||||
'x',
|
||||
[1600, 800],
|
||||
{ w: 1600 },
|
||||
{ w: '1600', h: 800 },
|
||||
{ w: Number.NaN, h: 800 },
|
||||
{ w: Number.POSITIVE_INFINITY, h: 800 },
|
||||
{ w: 767, h: 800 },
|
||||
{ w: 1600, h: 199 },
|
||||
];
|
||||
for (const value of bad) {
|
||||
expect(migrateGridLayouts({ ...v3(), [GRID_CANVAS_KEY]: value }).canvas).toBeNull();
|
||||
}
|
||||
// Grenzwerte sind gueltig.
|
||||
expect(migrateGridLayouts({ ...v3(), [GRID_CANVAS_KEY]: { w: 768, h: 200 } }).canvas).toEqual({
|
||||
w: 768,
|
||||
h: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it('withGridVersion schreibt die Leinwand nur, wenn eine da ist', () => {
|
||||
const { layouts } = migrateGridLayouts(v3());
|
||||
expect(withGridVersion(layouts)).not.toHaveProperty(GRID_CANVAS_KEY);
|
||||
expect(withGridVersion(layouts, null)).not.toHaveProperty(GRID_CANVAS_KEY);
|
||||
expect(withGridVersion(layouts, { w: 1920, h: 950 })).toEqual({
|
||||
...layouts,
|
||||
[GRID_VERSION_KEY]: GRID_VERSION,
|
||||
[GRID_CANVAS_KEY]: { w: 1920, h: 950 },
|
||||
});
|
||||
});
|
||||
|
||||
it('Idempotenz mit Leinwand: Laden -> Speichern -> Laden liefert dasselbe, nichts skaliert', () => {
|
||||
const once = migrateGridLayouts({ ...altLayouts(), [GRID_CANVAS_KEY]: { w: 1400, h: 700 } });
|
||||
expect(once.migrated).toBe(true);
|
||||
expect(once.canvas).toEqual({ w: 1400, h: 700 });
|
||||
const twice = migrateGridLayouts(withGridVersion(once.layouts, once.canvas));
|
||||
expect(twice.migrated).toBe(false);
|
||||
expect(twice.layouts).toEqual(once.layouts);
|
||||
expect(twice.canvas).toEqual(once.canvas);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,26 @@
|
||||
* Idempotenz: `migrateGridLayouts(withGridVersion(migrateGridLayouts(alt).layouts))`
|
||||
* liefert dasselbe Ergebnis wie `migrateGridLayouts(alt)` mit `migrated: false`.
|
||||
*
|
||||
* Neuere Marke (quick-260930): Traegt das JSON eine HOEHERE Marke als
|
||||
* `GRID_VERSION`, hat es ein neuerer Programmstand geschrieben (etwa ein
|
||||
* frisch geladener Tab neben einem alten). Umgerechnet wird dann nichts, aber
|
||||
* `newer: true` gemeldet — der Store darf diese Anordnung NIE speichern: er
|
||||
* wuerde sie mit der alten Marke zurueckschreiben, und der neue Stand wuerde
|
||||
* sie beim naechsten Laden ein zweites Mal skalieren.
|
||||
*
|
||||
* Leinwand (Bezugsgroesse, "wie ein Bild mitskalieren"): Zusaetzlich kann das
|
||||
* JSON den Schluessel `__canvas: { w, h }` tragen — die verfuegbare Flaeche
|
||||
* (Pixel), auf der die Anordnung eingerichtet wurde. Das Raster zeichnet
|
||||
* sich auf JEDEM Desktop-Bildschirm mit genau dieser Breite und skaliert das
|
||||
* Ergebnis per `transform: scale` in die dortige Flaeche (siehe
|
||||
* dashboard-grid.tsx). Wie der Marker lebt `__canvas` nur im JSON, nicht in
|
||||
* `layouts`: `migrateGridLayouts` liefert ihn getrennt als `canvas`,
|
||||
* `withGridVersion(layouts, canvas)` schreibt ihn zurueck — JEDER
|
||||
* Speicherpfad muss ihn mitgeben, sonst geht er verloren. Kein neuer
|
||||
* `GRID_VERSION`: aeltere Programmstaende ueberspringen den Schluessel, weil
|
||||
* sein Wert kein Array ist (`!Array.isArray(value)` unten) — und schreiben
|
||||
* ihn beim Speichern nicht zurueck; dann wird er einfach neu erfasst.
|
||||
*
|
||||
* Ort: Frontend, weil die Raster-Einheiten Frontend-Konstanten sind, die API
|
||||
* das JSON nur durchreicht (`@IsObject()`) und so kein Schreiben auf einem
|
||||
* GET und keine Aenderung am API-Dienst noetig ist. Reine Funktionen ohne
|
||||
@@ -43,6 +63,17 @@
|
||||
export const GRID_VERSION = 3;
|
||||
export const GRID_VERSION_KEY = '__gridVersion';
|
||||
export const GRID_SCALE_FACTOR = 2;
|
||||
export const GRID_CANVAS_KEY = '__canvas';
|
||||
/** Unter dieser Breite gilt die Handy-/Schmalansicht, dort wird nicht skaliert. */
|
||||
export const CANVAS_MIN_WIDTH = 768;
|
||||
/** Kleinere Hoehen sind Messfehler (Fenster kurz zusammengeklappt o. ae.). */
|
||||
export const CANVAS_MIN_HEIGHT = 200;
|
||||
|
||||
/** Bezugsflaeche in Pixeln, auf der die Anordnung eingerichtet wurde. */
|
||||
export interface GridCanvas {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface GridLayoutItem {
|
||||
i: string;
|
||||
@@ -67,19 +98,40 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft eine Leinwand: endliche Zahlen, w >= CANVAS_MIN_WIDTH,
|
||||
* h >= CANVAS_MIN_HEIGHT. Alles andere -> null (wird dann neu erfasst).
|
||||
*/
|
||||
export function parseGridCanvas(value: unknown): GridCanvas | null {
|
||||
if (!isPlainObject(value)) return null;
|
||||
const { w, h } = value;
|
||||
if (typeof w !== 'number' || typeof h !== 'number') return null;
|
||||
if (!Number.isFinite(w) || !Number.isFinite(h)) return null;
|
||||
if (w < CANVAS_MIN_WIDTH || h < CANVAS_MIN_HEIGHT) return null;
|
||||
return { w, h };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechnet eine rohe (aus der API geladene) Anordnung in die aktuellen
|
||||
* Raster-Einheiten um. Liefert die Anordnung OHNE Marker und die Angabe, ob
|
||||
* etwas verdoppelt wurde (dann muss der Aufrufer sofort mit Marker speichern).
|
||||
*/
|
||||
export function migrateGridLayouts(raw: unknown): { layouts: GridLayouts; migrated: boolean } {
|
||||
export function migrateGridLayouts(raw: unknown): {
|
||||
layouts: GridLayouts;
|
||||
migrated: boolean;
|
||||
/** Marke hoeher als `GRID_VERSION` — Anordnung stammt von einem neueren Programmstand. */
|
||||
newer: boolean;
|
||||
/** Gueltige Leinwand aus `__canvas`, sonst null. */
|
||||
canvas: GridCanvas | null;
|
||||
} {
|
||||
if (!isPlainObject(raw)) {
|
||||
return { layouts: {}, migrated: false };
|
||||
return { layouts: {}, migrated: false, newer: false, canvas: null };
|
||||
}
|
||||
|
||||
const markerValue = raw[GRID_VERSION_KEY];
|
||||
// Nur eine Zahl ist ein Marker; alles andere (fehlend, Zeichenkette) zaehlt als alt.
|
||||
const version = typeof markerValue === 'number' ? markerValue : 1;
|
||||
const newer = version > GRID_VERSION;
|
||||
const steps = MIGRATION_STEPS.filter((step) => version <= step.from);
|
||||
const needsScaling = steps.length > 0;
|
||||
|
||||
@@ -87,7 +139,7 @@ export function migrateGridLayouts(raw: unknown): { layouts: GridLayouts; migrat
|
||||
let migrated = false;
|
||||
|
||||
for (const key of Object.keys(raw)) {
|
||||
if (key === GRID_VERSION_KEY) continue;
|
||||
if (key === GRID_VERSION_KEY || key === GRID_CANVAS_KEY) continue;
|
||||
const value = raw[key];
|
||||
if (!Array.isArray(value)) continue;
|
||||
|
||||
@@ -108,12 +160,18 @@ export function migrateGridLayouts(raw: unknown): { layouts: GridLayouts; migrat
|
||||
});
|
||||
}
|
||||
|
||||
return { layouts, migrated };
|
||||
return { layouts, migrated, newer, canvas: parseGridCanvas(raw[GRID_CANVAS_KEY]) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Haengt den Marker fuer das Speichern an, ohne die Eingabe zu veraendern.
|
||||
* Haengt den Marker (und, falls vorhanden, die Leinwand) fuer das Speichern
|
||||
* an, ohne die Eingabe zu veraendern.
|
||||
*/
|
||||
export function withGridVersion(layouts: GridLayouts): Record<string, unknown> {
|
||||
return { ...layouts, [GRID_VERSION_KEY]: GRID_VERSION };
|
||||
export function withGridVersion(
|
||||
layouts: GridLayouts,
|
||||
canvas?: GridCanvas | null,
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = { ...layouts, [GRID_VERSION_KEY]: GRID_VERSION };
|
||||
if (canvas) result[GRID_CANVAS_KEY] = { w: canvas.w, h: canvas.h };
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
browserPermissionState,
|
||||
claimNotification,
|
||||
isTauriWebview,
|
||||
remindersToNotify,
|
||||
requestBrowserPermissionOnce,
|
||||
resetNotifiedMemoryForTests,
|
||||
showReminderNotification,
|
||||
withNotifyLock,
|
||||
} from './reminder-notify';
|
||||
import type { Reminder } from './reminders-api';
|
||||
|
||||
const NOW = Date.parse('2026-09-29T12:00:00.000Z');
|
||||
|
||||
function reminder(id: string, dueAt: string): Reminder {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
description: '',
|
||||
dueAt,
|
||||
emailEnabled: false,
|
||||
createdAt: dueAt,
|
||||
updatedAt: dueAt,
|
||||
};
|
||||
}
|
||||
|
||||
function installNotification(permission: NotificationPermission) {
|
||||
const ctor = vi.fn();
|
||||
const requestPermission = vi.fn(async () => 'granted' as NotificationPermission);
|
||||
Object.assign(ctor, { permission, requestPermission });
|
||||
vi.stubGlobal('Notification', ctor);
|
||||
return { ctor, requestPermission };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
resetNotifiedMemoryForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
delete (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function installTauri() {
|
||||
const invoke = vi.fn(async () => undefined);
|
||||
(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ = { invoke };
|
||||
return invoke;
|
||||
}
|
||||
|
||||
describe('claimNotification', () => {
|
||||
it('gibt beim ersten Mal true, danach false (gleicher Schluessel)', () => {
|
||||
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW)).toBe(true);
|
||||
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW + 1000)).toBe(false);
|
||||
});
|
||||
|
||||
it('nach einem Verschieben (neues dueAt) ist es ein neuer Schluessel', () => {
|
||||
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW)).toBe(true);
|
||||
expect(claimNotification('r1|2026-09-29T12:10:00.000Z', NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('entfernt Eintraege aelter als 7 Tage', () => {
|
||||
claimNotification('alt', NOW - 8 * 24 * 3600_000);
|
||||
claimNotification('neu', NOW);
|
||||
const stored = JSON.parse(window.localStorage.getItem('tessera.reminders.notified') ?? '{}');
|
||||
expect(Object.keys(stored)).toEqual(['neu']);
|
||||
});
|
||||
|
||||
it('ohne nutzbaren localStorage (wirft beim Lesen und Schreiben) meldet es trotzdem nur einmal', () => {
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('SecurityError');
|
||||
});
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('QuotaExceededError');
|
||||
});
|
||||
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW)).toBe(true);
|
||||
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW + 10_000)).toBe(false);
|
||||
expect(claimNotification('r1|2026-09-29T12:00:00.000Z', NOW + 3600_000)).toBe(false);
|
||||
// ein neuer Schluessel (verschoben) wird weiterhin gemeldet
|
||||
expect(claimNotification('r1|2026-09-29T12:10:00.000Z', NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it('ohne window.localStorage (Zugriff wirft) meldet es trotzdem nur einmal', () => {
|
||||
vi.spyOn(window, 'localStorage', 'get').mockImplementation(() => {
|
||||
throw new Error('SecurityError');
|
||||
});
|
||||
expect(claimNotification('k', NOW)).toBe(true);
|
||||
expect(claimNotification('k', NOW + 10_000)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remindersToNotify', () => {
|
||||
it('nimmt nur Faellige der letzten 24 Stunden (nie vorher, D-02; E-03)', () => {
|
||||
const list = [
|
||||
reminder('zukunft', new Date(NOW + 1000).toISOString()),
|
||||
reminder('genau', new Date(NOW).toISOString()),
|
||||
reminder('vor-1h', new Date(NOW - 3600_000).toISOString()),
|
||||
reminder('vor-25h', new Date(NOW - 25 * 3600_000).toISOString()),
|
||||
];
|
||||
expect(remindersToNotify(list, NOW).map((r) => r.id)).toEqual(['genau', 'vor-1h']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('showReminderNotification', () => {
|
||||
it('in Tauri: invoke plugin:notification|notify mit genau diesem Koerper', async () => {
|
||||
const invoke = installTauri();
|
||||
expect(isTauriWebview()).toBe(true);
|
||||
await showReminderNotification({ title: 'T', body: 'B', tag: 'x' });
|
||||
expect(invoke).toHaveBeenCalledWith('plugin:notification|notify', {
|
||||
options: { title: 'T', body: 'B' },
|
||||
});
|
||||
});
|
||||
|
||||
it('in Tauri: ein Fehler des Plugins wird nur als Warnung ausgegeben', async () => {
|
||||
const invoke = installTauri();
|
||||
invoke.mockRejectedValue(new Error('nope'));
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
await expect(
|
||||
showReminderNotification({ title: 'T', body: 'B', tag: 'x' }),
|
||||
).resolves.toBeUndefined();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('im Browser: nur mit Erlaubnis granted', async () => {
|
||||
const { ctor } = installNotification('granted');
|
||||
await showReminderNotification({ title: 'T', body: 'B', tag: 'tag1' });
|
||||
expect(ctor).toHaveBeenCalledWith('T', { body: 'B', tag: 'tag1' });
|
||||
});
|
||||
|
||||
it.each(['default', 'denied'] as const)('im Browser: bei %s keine Meldung', async (p) => {
|
||||
const { ctor } = installNotification(p);
|
||||
await showReminderNotification({ title: 'T', body: 'B', tag: 't' });
|
||||
expect(ctor).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestBrowserPermissionOnce', () => {
|
||||
it('fragt hoechstens einmal (D-04)', () => {
|
||||
const { requestPermission } = installNotification('default');
|
||||
requestBrowserPermissionOnce();
|
||||
requestBrowserPermissionOnce();
|
||||
expect(requestPermission).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fragt nie in Tauri', () => {
|
||||
installTauri();
|
||||
const { requestPermission } = installNotification('default');
|
||||
requestBrowserPermissionOnce();
|
||||
expect(requestPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fragt nicht, wenn die Entscheidung schon getroffen ist', () => {
|
||||
const { requestPermission } = installNotification('denied');
|
||||
requestBrowserPermissionOnce();
|
||||
expect(requestPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ohne Web-Benachrichtigungen passiert nichts', () => {
|
||||
vi.stubGlobal('Notification', undefined);
|
||||
expect(() => requestBrowserPermissionOnce()).not.toThrow();
|
||||
expect(browserPermissionState()).toBe('unsupported');
|
||||
});
|
||||
});
|
||||
|
||||
describe('browserPermissionState', () => {
|
||||
it('meldet desktop in Tauri, sonst die Browser-Erlaubnis', () => {
|
||||
installNotification('denied');
|
||||
expect(browserPermissionState()).toBe('denied');
|
||||
installTauri();
|
||||
expect(browserPermissionState()).toBe('desktop');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withNotifyLock', () => {
|
||||
it('ruft fn direkt, wenn es keine Web-Sperren gibt', async () => {
|
||||
const fn = vi.fn(() => 42);
|
||||
await expect(withNotifyLock(fn)).resolves.toBe(42);
|
||||
});
|
||||
|
||||
it('nutzt navigator.locks, wenn vorhanden', async () => {
|
||||
const request = vi.fn(async (_name: string, cb: () => unknown) => cb());
|
||||
vi.stubGlobal('navigator', { ...navigator, locks: { request } });
|
||||
await expect(withNotifyLock(() => 'x')).resolves.toBe('x');
|
||||
expect(request).toHaveBeenCalledWith('tessera-reminder-notify', expect.any(Function));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Benachrichtigungen fuer Erinnerungen (quick-260929-if2) — reine Funktionen
|
||||
* ohne React, damit Melder und Kachel sie gemeinsam nutzen und testen koennen.
|
||||
*
|
||||
* Zwei Wege, je nach Umgebung:
|
||||
* - Desktop-App (Tauri-Webview): der Aufruf geht DIREKT an das Benachrichtigungs-
|
||||
* Plugin (`plugin:notification|notify`). Die Rust-Seite erteilt dafuer zur
|
||||
* Laufzeit genau diese eine Berechtigung fuer die gespeicherte Serveradresse
|
||||
* (E-01, `grant_server_notifications` in apps/desktop/src-tauri/src/lib.rs).
|
||||
* - Browser: die Web-Benachrichtigung (`new Notification(...)`), nur mit
|
||||
* erteilter Erlaubnis.
|
||||
*
|
||||
* WARUM DER TAURI-WEG NIE `Notification.permission` LIEST (E-01): das Plugin
|
||||
* ersetzt `window.Notification` in der Webview durch einen Ersatz, der
|
||||
* denselben IPC-Aufruf macht. Dieser Ersatz meldet unter Windows bei JEDEM
|
||||
* Seitenaufruf `permission = "denied"`, bis einmal `requestPermission()`
|
||||
* gelaufen ist. Wer darauf hoeren wuerde, unterdrueckte im Desktop-Client
|
||||
* jede Meldung. Deshalb ruft der Tauri-Zweig `invoke` direkt und fragt keine
|
||||
* Erlaubnis ab.
|
||||
*/
|
||||
|
||||
import type { Reminder } from './reminders-api';
|
||||
|
||||
const PERMISSION_ASKED_KEY = 'tessera.reminders.permissionAsked';
|
||||
const NOTIFIED_KEY = 'tessera.reminders.notified';
|
||||
const LOCK_NAME = 'tessera-reminder-notify';
|
||||
|
||||
/** E-03: so lange nach der Faelligkeit wird noch nachgemeldet (ein Client, der spaet oeffnet). */
|
||||
export const CATCH_UP_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
/** Eintraege der lokalen Merkliste, die aelter sind, werden entfernt. */
|
||||
const NOTIFIED_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export type BrowserPermissionState = 'desktop' | 'granted' | 'default' | 'denied' | 'unsupported';
|
||||
|
||||
type TauriInvoke = (command: string, args?: Record<string, unknown>) => Promise<unknown>;
|
||||
|
||||
/** Die `invoke`-Funktion der Tauri-Webview, sonst `null` (Browser). */
|
||||
function tauriInvoke(): TauriInvoke | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const internals: unknown = (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
|
||||
if (typeof internals !== 'object' || internals === null) return null;
|
||||
const invoke: unknown = (internals as Record<string, unknown>).invoke;
|
||||
return typeof invoke === 'function' ? (invoke as TauriInvoke) : null;
|
||||
}
|
||||
|
||||
/** `true` in der Desktop-App (Tauri-Webview), `false` im normalen Browser. */
|
||||
export function isTauriWebview(): boolean {
|
||||
return tauriInvoke() !== null;
|
||||
}
|
||||
|
||||
function hasWebNotifications(): boolean {
|
||||
return typeof Notification !== 'undefined';
|
||||
}
|
||||
|
||||
/** Zustand der Benachrichtigungs-Erlaubnis; in der Desktop-App immer `desktop`. */
|
||||
export function browserPermissionState(): BrowserPermissionState {
|
||||
if (isTauriWebview()) return 'desktop';
|
||||
if (!hasWebNotifications()) return 'unsupported';
|
||||
return Notification.permission;
|
||||
}
|
||||
|
||||
function readFlag(key: string): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(key) !== null;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragt die Browser-Erlaubnis HOECHSTENS EINMAL (D-04). Aufzurufen aus einer
|
||||
* Nutzerhandlung (erstes Anlegen im Widget), nie beim Laden der Seite. Nichts
|
||||
* passiert in der Desktop-App, ohne Web-Benachrichtigungen, bei bereits
|
||||
* getroffener Entscheidung oder wenn die Frage schon einmal gestellt wurde.
|
||||
*/
|
||||
export function requestBrowserPermissionOnce(): void {
|
||||
if (isTauriWebview() || !hasWebNotifications()) return;
|
||||
if (Notification.permission !== 'default') return;
|
||||
if (readFlag(PERMISSION_ASKED_KEY)) return;
|
||||
try {
|
||||
window.localStorage.setItem(PERMISSION_ASKED_KEY, '1');
|
||||
} catch {
|
||||
/* ohne Speicher lieber fragen als nie */
|
||||
}
|
||||
try {
|
||||
void Notification.requestPermission();
|
||||
} catch {
|
||||
/* aeltere Browser: kein Versprechen, keine Folge */
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReminderNotification {
|
||||
title: string;
|
||||
body: string;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
/** Zeigt eine Benachrichtigung — Desktop ueber das Plugin, Browser nur mit Erlaubnis. */
|
||||
export async function showReminderNotification(n: ReminderNotification): Promise<void> {
|
||||
const invoke = tauriInvoke();
|
||||
if (invoke) {
|
||||
try {
|
||||
await invoke('plugin:notification|notify', { options: { title: n.title, body: n.body } });
|
||||
} catch (err) {
|
||||
console.warn('[reminders] Desktop-Benachrichtigung fehlgeschlagen', err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!hasWebNotifications() || Notification.permission !== 'granted') return;
|
||||
try {
|
||||
new Notification(n.title, { body: n.body, tag: n.tag });
|
||||
} catch (err) {
|
||||
console.warn('[reminders] Browser-Benachrichtigung fehlgeschlagen', err);
|
||||
}
|
||||
}
|
||||
|
||||
function readNotified(): Record<string, number> {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(NOTIFIED_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (typeof parsed !== 'object' || parsed === null) return {};
|
||||
const out: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (typeof v === 'number') out[k] = v;
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merkliste im Arbeitsspeicher dieses Tabs. Sie greift IMMER, auch wenn der
|
||||
* localStorage gesperrt, voll oder im privaten Modus nicht nutzbar ist — sonst
|
||||
* meldete der Melder dieselbe faellige Erinnerung 24 Stunden lang bei jeder
|
||||
* Pruefung erneut. Der localStorage bleibt fuer die Abstimmung zwischen Tabs.
|
||||
*/
|
||||
const notifiedInMemory = new Map<string, number>();
|
||||
|
||||
/** Nur fuer Tests: leert die Merkliste im Arbeitsspeicher. */
|
||||
export function resetNotifiedMemoryForTests(): void {
|
||||
notifiedInMemory.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merkt sich `key` (`${id}|${dueAt}`) lokal. `true` nur beim ERSTEN Mal — so
|
||||
* meldet jeder Client jede Faelligkeit hoechstens einmal, auch ueber mehrere
|
||||
* Tabs desselben Browsers hinweg (gemeinsamer localStorage). Nach einem
|
||||
* Verschieben aendert sich `dueAt` und damit der Schluessel. Eintraege
|
||||
* aelter als 7 Tage werden dabei entfernt.
|
||||
*/
|
||||
export function claimNotification(key: string, nowMs: number): boolean {
|
||||
for (const [k, at] of notifiedInMemory) {
|
||||
if (nowMs - at > NOTIFIED_TTL_MS) notifiedInMemory.delete(k);
|
||||
}
|
||||
const record = readNotified();
|
||||
for (const [k, at] of Object.entries(record)) {
|
||||
if (nowMs - at > NOTIFIED_TTL_MS) delete record[k];
|
||||
}
|
||||
const first = !(key in record) && !notifiedInMemory.has(key);
|
||||
if (first) {
|
||||
record[key] = nowMs;
|
||||
notifiedInMemory.set(key, nowMs);
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(NOTIFIED_KEY, JSON.stringify(record));
|
||||
} catch {
|
||||
/* Speicher voll oder gesperrt: dann greift nur die Merkliste im Arbeitsspeicher */
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
/** Fuehrt `fn` unter einer Web-Sperre aus (mehrere Tabs nacheinander), sonst direkt. */
|
||||
export async function withNotifyLock<T>(fn: () => T | Promise<T>): Promise<T> {
|
||||
const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined;
|
||||
if (locks && typeof locks.request === 'function') {
|
||||
return locks.request(LOCK_NAME, async () => fn());
|
||||
}
|
||||
return fn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Erinnerungen, die JETZT gemeldet werden sollen: faellig (`dueAt <= now`,
|
||||
* D-02: nie vorher) und nicht aelter als 24 Stunden (E-03).
|
||||
*/
|
||||
export function remindersToNotify(reminders: Reminder[], nowMs: number): Reminder[] {
|
||||
return reminders.filter((r) => {
|
||||
const due = new Date(r.dueAt).getTime();
|
||||
return due <= nowMs && due > nowMs - CATCH_UP_WINDOW_MS;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
defaultNewReminderInputs,
|
||||
isoToLocalInputs,
|
||||
isSameLocalDay,
|
||||
localInputsToIso,
|
||||
snoozeTarget,
|
||||
} from './reminder-time';
|
||||
|
||||
// Alle Zeitpunkte in Ortszeit gebildet (`new Date(y, m, d, h, min)`), damit die
|
||||
// Tests in jeder Zeitzone dasselbe pruefen.
|
||||
const local = (y: number, m: number, d: number, h = 0, min = 0) => new Date(y, m - 1, d, h, min);
|
||||
|
||||
describe('localInputsToIso / isoToLocalInputs', () => {
|
||||
it('laufen im Kreis', () => {
|
||||
const iso = localInputsToIso('2026-10-05', '14:30');
|
||||
expect(iso).toBe(local(2026, 10, 5, 14, 30).toISOString());
|
||||
expect(isoToLocalInputs(iso as string)).toEqual({ date: '2026-10-05', time: '14:30' });
|
||||
});
|
||||
|
||||
it('liefert null bei unvollstaendiger oder ungueltiger Eingabe', () => {
|
||||
expect(localInputsToIso('', '14:30')).toBeNull();
|
||||
expect(localInputsToIso('2026-10-05', '')).toBeNull();
|
||||
expect(localInputsToIso('2026-13-45', '14:30')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultNewReminderInputs', () => {
|
||||
it('ist die naechste volle Stunde', () => {
|
||||
expect(defaultNewReminderInputs(local(2026, 9, 29, 13, 20))).toEqual({
|
||||
date: '2026-09-29',
|
||||
time: '14:00',
|
||||
});
|
||||
});
|
||||
|
||||
it('rollt ueber Mitternacht und Monatsende', () => {
|
||||
expect(defaultNewReminderInputs(local(2026, 9, 30, 23, 30))).toEqual({
|
||||
date: '2026-10-01',
|
||||
time: '00:00',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('snoozeTarget (E-05)', () => {
|
||||
const now = local(2026, 9, 29, 14, 5);
|
||||
const original = local(2026, 9, 29, 14, 0);
|
||||
|
||||
it('10m und 1h zaehlen ab jetzt, nicht ab der alten Faelligkeit', () => {
|
||||
expect(snoozeTarget('10m', original, now).getTime()).toBe(now.getTime() + 10 * 60_000);
|
||||
expect(snoozeTarget('1h', original, now).getTime()).toBe(now.getTime() + 3600_000);
|
||||
});
|
||||
|
||||
it('morgen = urspruengliche Ortszeit am naechsten Tag', () => {
|
||||
expect(snoozeTarget('tomorrow', original, now).getTime()).toBe(
|
||||
local(2026, 9, 30, 14, 0).getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
it('morgen ueber das Monatsende', () => {
|
||||
const n = local(2026, 9, 30, 15, 0);
|
||||
expect(snoozeTarget('tomorrow', local(2026, 9, 30, 14, 0), n).getTime()).toBe(
|
||||
local(2026, 10, 1, 14, 0).getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
it('eine tagelang ueberfaellige Erinnerung landet beim naechsten kuenftigen Vorkommen', () => {
|
||||
const old = local(2026, 9, 25, 8, 15);
|
||||
const target = snoozeTarget('tomorrow', old, now);
|
||||
expect(target.getTime()).toBeGreaterThan(now.getTime());
|
||||
expect(target.getTime()).toBe(local(2026, 9, 30, 8, 15).getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('snoozeTarget kurz nach Mitternacht / isSameLocalDay', () => {
|
||||
it('faellig gestern 23:50, verschoben heute 00:10 -> heute 23:50 (heute, nicht morgen)', () => {
|
||||
const n = local(2026, 9, 30, 0, 10);
|
||||
const target = snoozeTarget('tomorrow', local(2026, 9, 29, 23, 50), n);
|
||||
expect(target.getTime()).toBe(local(2026, 9, 30, 23, 50).getTime());
|
||||
expect(isSameLocalDay(target, n)).toBe(true);
|
||||
});
|
||||
|
||||
it('der Normalfall liegt am naechsten Tag', () => {
|
||||
const n = local(2026, 9, 29, 14, 5);
|
||||
const target = snoozeTarget('tomorrow', local(2026, 9, 29, 14, 0), n);
|
||||
expect(isSameLocalDay(target, n)).toBe(false);
|
||||
});
|
||||
|
||||
it('isSameLocalDay vergleicht den Kalendertag in Ortszeit', () => {
|
||||
expect(isSameLocalDay(local(2026, 9, 30, 0, 0), local(2026, 9, 30, 23, 59))).toBe(true);
|
||||
expect(isSameLocalDay(local(2026, 9, 30, 23, 59), local(2026, 10, 1, 0, 0))).toBe(false);
|
||||
expect(isSameLocalDay(local(2026, 9, 30), local(2025, 9, 30))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Zeit-Hilfen der Erinnerungen (quick-260929-if2) — alles in Ortszeit des
|
||||
* Benutzers, reine Funktionen ohne React.
|
||||
*/
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const TIME_RE = /^\d{2}:\d{2}$/;
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
/**
|
||||
* Datums- und Zeitfeld (Ortszeit, `type="date"`/`type="time"`) als ISO-Zeitpunkt.
|
||||
* `null` bei unvollstaendiger oder ungueltiger Eingabe.
|
||||
*/
|
||||
export function localInputsToIso(date: string, time: string): string | null {
|
||||
if (!DATE_RE.test(date) || !TIME_RE.test(time)) return null;
|
||||
const d = new Date(`${date}T${time}`);
|
||||
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
||||
}
|
||||
|
||||
/** Vorbelegung fuer eine neue Erinnerung: heute, die naechste volle Stunde. */
|
||||
export function defaultNewReminderInputs(now: Date): { date: string; time: string } {
|
||||
const next = new Date(now);
|
||||
next.setHours(next.getHours() + 1, 0, 0, 0);
|
||||
return {
|
||||
date: `${next.getFullYear()}-${pad(next.getMonth() + 1)}-${pad(next.getDate())}`,
|
||||
time: `${pad(next.getHours())}:${pad(next.getMinutes())}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Zeitpunkt aus ISO-Zeichenkette als Datums- und Zeitfeld (Ortszeit) fuer das Formular. */
|
||||
export function isoToLocalInputs(iso: string): { date: string; time: string } {
|
||||
const d = new Date(iso);
|
||||
return {
|
||||
date: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`,
|
||||
time: `${pad(d.getHours())}:${pad(d.getMinutes())}`,
|
||||
};
|
||||
}
|
||||
|
||||
export type SnoozePreset = '10m' | '1h' | 'tomorrow';
|
||||
|
||||
/**
|
||||
* Neuer Zeitpunkt fuer „Spaeter erinnern“ (D-03, E-05). `10m` und `1h` zaehlen
|
||||
* ab JETZT, nicht ab der alten Faelligkeit. `tomorrow` nimmt die urspruengliche
|
||||
* Ortszeit und geht einen Kalendertag weiter (`setDate(+1)` ist sommerzeit-
|
||||
* sicher), so oft, bis das Ergebnis in der Zukunft liegt: faellig heute 14:00,
|
||||
* verschoben um 14:05 -> morgen 14:00; war die Erinnerung schon Tage ueberfaellig,
|
||||
* landet sie beim naechsten kuenftigen Vorkommen dieser Uhrzeit. Das kann auch
|
||||
* HEUTE sein (faellig gestern 23:50, verschoben heute 00:10 -> heute 23:50) —
|
||||
* die Beschriftung leitet „heute“/„morgen“ deshalb aus dem Ergebnis ab
|
||||
* (`isSameLocalDay`), nicht aus dem Namen der Option.
|
||||
*/
|
||||
export function snoozeTarget(preset: SnoozePreset, originalDueAt: Date, now: Date): Date {
|
||||
if (preset === '10m') return new Date(now.getTime() + 10 * 60_000);
|
||||
if (preset === '1h') return new Date(now.getTime() + 60 * 60_000);
|
||||
const d = new Date(originalDueAt);
|
||||
do {
|
||||
d.setDate(d.getDate() + 1);
|
||||
} while (d.getTime() <= now.getTime());
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Liegen beide Zeitpunkte am selben Kalendertag (Ortszeit)? */
|
||||
export function isSameLocalDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
ReminderRequestError,
|
||||
createReminder,
|
||||
deleteReminder,
|
||||
getReminderEmailStatus,
|
||||
listReminders,
|
||||
snoozeReminder,
|
||||
updateReminder,
|
||||
} from './reminders-api';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const ok = (body: unknown) => ({ ok: true, status: 200, json: async () => body });
|
||||
|
||||
describe('reminders-api', () => {
|
||||
it('listReminders ruft GET /reminders mit Cookie-Anmeldung', async () => {
|
||||
fetchMock.mockResolvedValue(ok([]));
|
||||
await expect(listReminders()).resolves.toEqual([]);
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toMatch(/\/reminders$/);
|
||||
expect(init.credentials).toBe('include');
|
||||
});
|
||||
|
||||
it('createReminder schickt POST mit JSON-Koerper', async () => {
|
||||
fetchMock.mockResolvedValue(ok({ id: 'r1' }));
|
||||
await createReminder({ title: 'a', dueAt: '2099-01-01T10:00:00.000Z' });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toMatch(/\/reminders$/);
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.credentials).toBe('include');
|
||||
expect(JSON.parse(init.body)).toEqual({ title: 'a', dueAt: '2099-01-01T10:00:00.000Z' });
|
||||
});
|
||||
|
||||
it('updateReminder schickt PATCH an /reminders/:id', async () => {
|
||||
fetchMock.mockResolvedValue(ok({ id: 'r 1' }));
|
||||
await updateReminder('r 1', { title: 'neu' });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toMatch(/\/reminders\/r%201$/);
|
||||
expect(init.method).toBe('PATCH');
|
||||
expect(init.credentials).toBe('include');
|
||||
expect(JSON.parse(init.body)).toEqual({ title: 'neu' });
|
||||
});
|
||||
|
||||
it('snoozeReminder schickt POST /reminders/:id/snooze mit dueAt', async () => {
|
||||
fetchMock.mockResolvedValue(ok({ id: 'r1' }));
|
||||
await snoozeReminder('r1', '2099-01-01T10:00:00.000Z');
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toMatch(/\/reminders\/r1\/snooze$/);
|
||||
expect(init.method).toBe('POST');
|
||||
expect(JSON.parse(init.body)).toEqual({ dueAt: '2099-01-01T10:00:00.000Z' });
|
||||
});
|
||||
|
||||
it('deleteReminder schickt DELETE an /reminders/:id', async () => {
|
||||
fetchMock.mockResolvedValue(ok({ deleted: true }));
|
||||
await expect(deleteReminder('r1')).resolves.toEqual({ deleted: true });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toMatch(/\/reminders\/r1$/);
|
||||
expect(init.method).toBe('DELETE');
|
||||
expect(init.credentials).toBe('include');
|
||||
});
|
||||
|
||||
it('getReminderEmailStatus ruft GET /reminders/email-status', async () => {
|
||||
fetchMock.mockResolvedValue(ok({ smtpConfigured: true, hasEmail: false }));
|
||||
await expect(getReminderEmailStatus()).resolves.toEqual({ smtpConfigured: true, hasEmail: false });
|
||||
const [url, init] = fetchMock.mock.calls[0];
|
||||
expect(url).toMatch(/\/reminders\/email-status$/);
|
||||
expect(init.credentials).toBe('include');
|
||||
});
|
||||
|
||||
it('bei 404 und 409 tragen die Fehler den Status', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 404, json: async () => ({}) });
|
||||
await expect(deleteReminder('x')).rejects.toMatchObject({ status: 404 });
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 409, json: async () => ({}) });
|
||||
await expect(snoozeReminder('x', 'y')).rejects.toMatchObject({ status: 409 });
|
||||
});
|
||||
|
||||
it('ein Status ausserhalb 2xx wirft ReminderRequestError mit diesem Status', async () => {
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 401, json: async () => ({}) });
|
||||
await expect(listReminders()).rejects.toMatchObject({ status: 401 });
|
||||
fetchMock.mockResolvedValue({ ok: false, status: 409, json: async () => ({}) });
|
||||
const err = await createReminder({ title: 'a', dueAt: 'x' }).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(ReminderRequestError);
|
||||
expect(err.status).toBe(409);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Erinnerungen — API-Klient (quick-260929-if2). Spiegelt den
|
||||
* `RemindersController` der API. Alle Aufrufe mit `credentials: 'include'`
|
||||
* (Cookie-Anmeldung), Muster `favorites-api.ts`.
|
||||
*/
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
/** Ereignis nach jeder erfolgreichen Aenderung — der globale Melder laedt daraufhin neu. */
|
||||
export const REMINDERS_CHANGED_EVENT = 'tessera:reminders-changed';
|
||||
|
||||
/** Eine Erinnerung; Zeitangaben als ISO-Zeichenketten (so liefert sie die API). */
|
||||
export interface Reminder {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
dueAt: string;
|
||||
emailEnabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Ein abgewiesener Aufruf; `status` ist der HTTP-Status (401, 404, 409 ...). */
|
||||
export class ReminderRequestError extends Error {
|
||||
public readonly status: number;
|
||||
|
||||
constructor(status: number) {
|
||||
super(`Reminder request failed (${status})`);
|
||||
this.name = 'ReminderRequestError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_URL}${path}`, { credentials: 'include', ...init });
|
||||
if (!res.ok) throw new ReminderRequestError(res.status);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
function jsonInit(method: string, body: unknown): RequestInit {
|
||||
return {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
/** Die eigenen Erinnerungen, die naechste Faelligkeit zuerst. */
|
||||
export function listReminders(): Promise<Reminder[]> {
|
||||
return request<Reminder[]>('/reminders');
|
||||
}
|
||||
|
||||
export interface ReminderInput {
|
||||
title: string;
|
||||
description?: string;
|
||||
/** ISO-8601-Zeitpunkt. */
|
||||
dueAt: string;
|
||||
/** Zusaetzlich per E-Mail erinnern; nur senden, wenn es sich aendert oder `true` ist. */
|
||||
emailEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function createReminder(input: ReminderInput): Promise<Reminder> {
|
||||
return request<Reminder>('/reminders', jsonInit('POST', input));
|
||||
}
|
||||
|
||||
export type ReminderPatch = Partial<ReminderInput>;
|
||||
|
||||
export function updateReminder(id: string, patch: ReminderPatch): Promise<Reminder> {
|
||||
return request<Reminder>(`/reminders/${encodeURIComponent(id)}`, jsonInit('PATCH', patch));
|
||||
}
|
||||
|
||||
/** „Spaeter erinnern“: neue Faelligkeit (ISO), nur bei einer faelligen Erinnerung. */
|
||||
export function snoozeReminder(id: string, dueAt: string): Promise<Reminder> {
|
||||
return request<Reminder>(
|
||||
`/reminders/${encodeURIComponent(id)}/snooze`,
|
||||
jsonInit('POST', { dueAt }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Loescht die Erinnerung — bedient „Loeschen“ (vorher) und „Erledigt“ (nachher). */
|
||||
export function deleteReminder(id: string): Promise<{ deleted: true }> {
|
||||
return request<{ deleted: true }>(`/reminders/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
/** Ob die E-Mail-Erinnerung fuer diesen Benutzer moeglich ist (Versand eingerichtet, Adresse vorhanden). */
|
||||
export interface ReminderEmailStatus {
|
||||
smtpConfigured: boolean;
|
||||
hasEmail: boolean;
|
||||
}
|
||||
|
||||
export function getReminderEmailStatus(): Promise<ReminderEmailStatus> {
|
||||
return request<ReminderEmailStatus>('/reminders/email-status');
|
||||
}
|
||||
@@ -53,6 +53,8 @@ beforeEach(() => {
|
||||
isDirty: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
layoutFromNewerVersion: false,
|
||||
canvas: null,
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1]);
|
||||
@@ -68,7 +70,11 @@ afterEach(() => {
|
||||
describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)', () => {
|
||||
it('Test 1: alte Anordnung wird beim Laden umgerechnet und SOFORT fuer den ersten Reiter mit Marker gespeichert', async () => {
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({
|
||||
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [],
|
||||
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }],
|
||||
md: [],
|
||||
sm: [],
|
||||
xs: [],
|
||||
xxs: [],
|
||||
});
|
||||
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
@@ -88,7 +94,12 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
|
||||
|
||||
it('Test 2: markierte Anordnung bleibt unveraendert, kein Speichern, kein Marker im Zustand', async () => {
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({
|
||||
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 3,
|
||||
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }],
|
||||
md: [],
|
||||
sm: [],
|
||||
xs: [],
|
||||
xxs: [],
|
||||
__gridVersion: 3,
|
||||
});
|
||||
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
@@ -129,7 +140,11 @@ describe('dashboard-store — einmalige Umrechnung mit Marker (quick-260916-bwo)
|
||||
|
||||
it('Test 5: scheitert das Sofort-Speichern, bleibt der Zustand umgerechnet, kein Fehlerzustand, console.error einmal', async () => {
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({
|
||||
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [],
|
||||
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }],
|
||||
md: [],
|
||||
sm: [],
|
||||
xs: [],
|
||||
xxs: [],
|
||||
});
|
||||
vi.mocked(api.saveLayout).mockRejectedValue(new Error('PUT failed'));
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
@@ -197,7 +212,9 @@ describe('dashboard-store — Reiterwechsel (quick-260923-ad9, Task 3)', () => {
|
||||
it('Test 9: ein Wechsel mit ungespeicherter Anordnung schreibt die Anordnung ZUERST fuer den ALTEN Reiter', async () => {
|
||||
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
useDashboardStore.getState().updateLayouts({ lg: [{ i: 'x', x: 0, y: 0, w: 1, h: 1 }], md: [], sm: [], xs: [], xxs: [] });
|
||||
useDashboardStore
|
||||
.getState()
|
||||
.updateLayouts({ lg: [{ i: 'x', x: 0, y: 0, w: 1, h: 1 }], md: [], sm: [], xs: [], xxs: [] });
|
||||
vi.mocked(api.saveLayout).mockClear();
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY });
|
||||
vi.mocked(api.fetchWidgets).mockResolvedValue([]);
|
||||
@@ -213,9 +230,16 @@ describe('dashboard-store — Reiterwechsel (quick-260923-ad9, Task 3)', () => {
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({
|
||||
lg: [{ i: 'only-on-dash-2', x: 0, y: 0, w: 2, h: 2 }], md: [], sm: [], xs: [], xxs: [], __gridVersion: 3,
|
||||
lg: [{ i: 'only-on-dash-2', x: 0, y: 0, w: 2, h: 2 }],
|
||||
md: [],
|
||||
sm: [],
|
||||
xs: [],
|
||||
xxs: [],
|
||||
__gridVersion: 3,
|
||||
});
|
||||
vi.mocked(api.fetchWidgets).mockResolvedValue([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]);
|
||||
vi.mocked(api.fetchWidgets).mockResolvedValue([
|
||||
{ id: 'w-on-dash-2', widgetType: 'clock', config: {} },
|
||||
]);
|
||||
|
||||
await useDashboardStore.getState().selectDashboard('dash-2');
|
||||
|
||||
@@ -253,7 +277,11 @@ describe('dashboard-store — Reiter anlegen/umbenennen/loeschen (quick-260923-a
|
||||
|
||||
it('Test 13: renameDashboard aktualisiert den Namen in der Reiterliste', async () => {
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
vi.mocked(api.renameDashboardTab).mockResolvedValue({ id: 'dash-1', name: 'Finanzen', position: 0 });
|
||||
vi.mocked(api.renameDashboardTab).mockResolvedValue({
|
||||
id: 'dash-1',
|
||||
name: 'Finanzen',
|
||||
position: 0,
|
||||
});
|
||||
|
||||
await useDashboardStore.getState().renameDashboard('dash-1', 'Finanzen');
|
||||
|
||||
@@ -265,7 +293,9 @@ describe('dashboard-store — Reiter anlegen/umbenennen/loeschen (quick-260923-a
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
vi.mocked(api.deleteDashboardTab).mockResolvedValue(undefined);
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY });
|
||||
vi.mocked(api.fetchWidgets).mockResolvedValue([{ id: 'w-on-dash-2', widgetType: 'clock', config: {} }]);
|
||||
vi.mocked(api.fetchWidgets).mockResolvedValue([
|
||||
{ id: 'w-on-dash-2', widgetType: 'clock', config: {} },
|
||||
]);
|
||||
|
||||
await useDashboardStore.getState().deleteDashboard('dash-1');
|
||||
|
||||
@@ -320,3 +350,211 @@ describe('dashboard-store — Reiter per Ziehen umsortieren (quick-260923-ad9, T
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dashboard-store — Anordnung eines neueren Programmstands (quick-260930)', () => {
|
||||
const FUTURE = {
|
||||
lg: [{ i: 'a', x: 2, y: 0, w: 8, h: 4 }],
|
||||
md: [],
|
||||
sm: [],
|
||||
xs: [],
|
||||
xxs: [],
|
||||
__gridVersion: 99,
|
||||
};
|
||||
|
||||
it('Test 18: hoehere Marke -> Merker gesetzt, nichts skaliert, nie gespeichert, Bearbeiten gesperrt', async () => {
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({ ...FUTURE });
|
||||
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
|
||||
const state = useDashboardStore.getState();
|
||||
expect(state.layoutFromNewerVersion).toBe(true);
|
||||
expect(state.layouts.lg[0]).toEqual({ i: 'a', x: 2, y: 0, w: 8, h: 4 });
|
||||
expect(api.saveLayout).not.toHaveBeenCalled();
|
||||
|
||||
// Bearbeiten bleibt aus.
|
||||
useDashboardStore.getState().setEditMode(true);
|
||||
expect(useDashboardStore.getState().isEditMode).toBe(false);
|
||||
|
||||
// Auch ein ausdrueckliches Speichern (etwa nach einer Aenderung) schreibt nichts.
|
||||
useDashboardStore.getState().updateLayouts({ lg: [{ i: 'a', x: 0, y: 0, w: 4, h: 4 }] });
|
||||
await useDashboardStore.getState().saveLayout();
|
||||
expect(api.saveLayout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test 19: Wechsel von einem Reiter mit neuerer Marke speichert ihn nicht; ein normaler Reiter hebt den Merker wieder auf', async () => {
|
||||
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
|
||||
vi.mocked(api.fetchLayout).mockResolvedValueOnce({ ...FUTURE });
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
useDashboardStore.setState({ isDirty: true });
|
||||
|
||||
vi.mocked(api.fetchLayout).mockResolvedValueOnce({ ...EMPTY, __gridVersion: 3 });
|
||||
await useDashboardStore.getState().selectDashboard('dash-2');
|
||||
|
||||
expect(api.saveLayout).not.toHaveBeenCalled();
|
||||
expect(useDashboardStore.getState().layoutFromNewerVersion).toBe(false);
|
||||
useDashboardStore.getState().setEditMode(true);
|
||||
expect(useDashboardStore.getState().isEditMode).toBe(true);
|
||||
});
|
||||
|
||||
it('Test 20: normale Anordnung -> Merker aus, Speichern wie gewohnt', async () => {
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({ ...EMPTY, __gridVersion: 3 });
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
expect(useDashboardStore.getState().layoutFromNewerVersion).toBe(false);
|
||||
await useDashboardStore.getState().saveLayout();
|
||||
expect(api.saveLayout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dashboard-store — Breiten je Breakpoint (quick-260930)', () => {
|
||||
it('Test 21: neue Kachel ist in keinem Breakpoint breiter als dessen Spaltenzahl', async () => {
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
useDashboardStore.setState({ layouts: { lg: [], md: [], sm: [], xs: [], xxs: [] } });
|
||||
vi.mocked(api.addWidget).mockResolvedValue({ id: 's1', widgetType: 'search', config: {} });
|
||||
|
||||
await useDashboardStore.getState().addWidget('search');
|
||||
|
||||
const { layouts } = useDashboardStore.getState();
|
||||
expect(layouts.lg[0].w).toBe(24);
|
||||
expect(layouts.xs[0].w).toBe(16);
|
||||
expect(layouts.xxs[0].w).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dashboard-store — optimistische Kachel-Konfiguration (quick-260930)', () => {
|
||||
it('Test 22: scheitert der erste von zwei schnellen Klicks, bleibt die zweite Wahl stehen', async () => {
|
||||
useDashboardStore.setState({ widgets: [{ id: 'w1', widgetType: 'clock', config: {} }] });
|
||||
let rejectFirst: (e: Error) => void = () => {};
|
||||
vi.mocked(api.updateWidgetConfig)
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectFirst = reject;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(undefined as never);
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const first = useDashboardStore.getState().setWidgetConfig('w1', { hideTitle: true });
|
||||
await useDashboardStore.getState().setWidgetConfig('w1', { hideTitle: false });
|
||||
rejectFirst(new Error('PATCH failed'));
|
||||
await first;
|
||||
|
||||
expect(useDashboardStore.getState().widgets[0].config).toEqual({ hideTitle: false });
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Test 23: scheitert der einzige Klick, kommt der vorige Wert zurueck', async () => {
|
||||
useDashboardStore.setState({
|
||||
widgets: [{ id: 'w1', widgetType: 'clock', config: { hideTitle: false } }],
|
||||
});
|
||||
vi.mocked(api.updateWidgetConfig).mockRejectedValue(new Error('PATCH failed'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await useDashboardStore.getState().setWidgetConfig('w1', { hideTitle: true });
|
||||
|
||||
expect(useDashboardStore.getState().widgets[0].config).toEqual({ hideTitle: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('dashboard-store — Leinwand (Dashboard wie ein Bild mitskalieren)', () => {
|
||||
const V3 = { lg: [{ i: 'a', x: 0, y: 0, w: 8, h: 4 }], __gridVersion: 3 };
|
||||
|
||||
it('Test 24: Reiter ohne Leinwand -> captureCanvas setzt sie und speichert SOFORT mit __canvas und Marker', async () => {
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3 });
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
expect(useDashboardStore.getState().canvas).toBeNull();
|
||||
expect(api.saveLayout).not.toHaveBeenCalled();
|
||||
|
||||
await useDashboardStore.getState().captureCanvas({ w: 1600.4, h: 820.2 });
|
||||
|
||||
expect(useDashboardStore.getState().canvas).toEqual({ w: 1600, h: 820 });
|
||||
expect(api.saveLayout).toHaveBeenCalledTimes(1);
|
||||
expect(api.saveLayout).toHaveBeenCalledWith('dash-1', {
|
||||
lg: V3.lg,
|
||||
__gridVersion: 3,
|
||||
__canvas: { w: 1600, h: 820 },
|
||||
});
|
||||
|
||||
// Danach aendert sie sich nicht mehr automatisch.
|
||||
await useDashboardStore.getState().captureCanvas({ w: 1280, h: 700 });
|
||||
expect(useDashboardStore.getState().canvas).toEqual({ w: 1600, h: 820 });
|
||||
expect(api.saveLayout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('Test 25: geladene Leinwand steht im Zustand, wird nicht neu erfasst, und saveLayout schreibt sie mit', async () => {
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3, __canvas: { w: 1920, h: 950 } });
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
expect(useDashboardStore.getState().canvas).toEqual({ w: 1920, h: 950 });
|
||||
|
||||
await useDashboardStore.getState().captureCanvas({ w: 1280, h: 700 });
|
||||
expect(api.saveLayout).not.toHaveBeenCalled();
|
||||
|
||||
await useDashboardStore.getState().saveLayout();
|
||||
expect(api.saveLayout).toHaveBeenCalledWith('dash-1', {
|
||||
lg: V3.lg,
|
||||
__gridVersion: 3,
|
||||
__canvas: { w: 1920, h: 950 },
|
||||
});
|
||||
});
|
||||
|
||||
it('Test 26: Sofort-Speichern nach der Umrechnung behaelt die Leinwand', async () => {
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({
|
||||
lg: [{ i: 'a', x: 1, y: 1, w: 2, h: 2 }],
|
||||
__canvas: { w: 1400, h: 700 },
|
||||
});
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
expect(api.saveLayout).toHaveBeenCalledWith(
|
||||
'dash-1',
|
||||
expect.objectContaining({ __gridVersion: 3, __canvas: { w: 1400, h: 700 } }),
|
||||
);
|
||||
});
|
||||
|
||||
it('Test 27: Reiterwechsel setzt die Leinwand des neuen Reiters (auch null)', async () => {
|
||||
vi.mocked(api.fetchDashboards).mockResolvedValue([DASH_1, DASH_2]);
|
||||
vi.mocked(api.fetchLayout).mockImplementation(async (id: string) =>
|
||||
id === 'dash-1' ? { ...V3, __canvas: { w: 1920, h: 950 } } : { ...V3 },
|
||||
);
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
expect(useDashboardStore.getState().canvas).toEqual({ w: 1920, h: 950 });
|
||||
|
||||
await useDashboardStore.getState().selectDashboard('dash-2');
|
||||
expect(useDashboardStore.getState().canvas).toBeNull();
|
||||
|
||||
// Erfassen gilt nun fuer dash-2.
|
||||
await useDashboardStore.getState().captureCanvas({ w: 1280, h: 700 });
|
||||
expect(api.saveLayout).toHaveBeenLastCalledWith(
|
||||
'dash-2',
|
||||
expect.objectContaining({ __canvas: { w: 1280, h: 700 } }),
|
||||
);
|
||||
|
||||
await useDashboardStore.getState().selectDashboard('dash-1');
|
||||
expect(useDashboardStore.getState().canvas).toEqual({ w: 1920, h: 950 });
|
||||
});
|
||||
|
||||
it('Test 28: nicht erfassen bei Anordnung eines neueren Programmstands oder bei zu kleiner Flaeche', async () => {
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3, __gridVersion: 99 });
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
await useDashboardStore.getState().captureCanvas({ w: 1600, h: 800 });
|
||||
expect(useDashboardStore.getState().canvas).toBeNull();
|
||||
expect(api.saveLayout).not.toHaveBeenCalled();
|
||||
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3 });
|
||||
useDashboardStore.setState({ activeDashboardId: null });
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
await useDashboardStore.getState().captureCanvas({ w: 767, h: 800 });
|
||||
await useDashboardStore.getState().captureCanvas({ w: 1600, h: 150 });
|
||||
expect(useDashboardStore.getState().canvas).toBeNull();
|
||||
expect(api.saveLayout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Test 29: scheitert das Speichern beim Erfassen, bleibt die Leinwand im Zustand, console.error einmal', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.mocked(api.fetchLayout).mockResolvedValue({ ...V3 });
|
||||
await useDashboardStore.getState().loadDashboard();
|
||||
vi.mocked(api.saveLayout).mockRejectedValueOnce(new Error('offline'));
|
||||
await useDashboardStore.getState().captureCanvas({ w: 1600, h: 800 });
|
||||
expect(useDashboardStore.getState().canvas).toEqual({ w: 1600, h: 800 });
|
||||
expect(useDashboardStore.getState().error).toBeNull();
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { create } from 'zustand';
|
||||
import * as api from '@/lib/dashboard-api';
|
||||
import type { DashboardTab } from '@/lib/dashboard-api';
|
||||
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
||||
import { WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry';
|
||||
import { migrateGridLayouts, withGridVersion } from '@/lib/grid-layout-migration';
|
||||
import { clampWidthToCols, WIDGET_CONSTRAINTS } from '@/components/dashboard/widget-registry';
|
||||
import type { DashboardTab } from '@/lib/dashboard-api';
|
||||
import * as api from '@/lib/dashboard-api';
|
||||
import {
|
||||
CANVAS_MIN_HEIGHT,
|
||||
CANVAS_MIN_WIDTH,
|
||||
type GridCanvas,
|
||||
migrateGridLayouts,
|
||||
parseGridCanvas,
|
||||
withGridVersion,
|
||||
} from '@/lib/grid-layout-migration';
|
||||
|
||||
export interface WidgetInstance {
|
||||
id: string;
|
||||
@@ -21,6 +28,19 @@ interface DashboardState {
|
||||
isDirty: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
/**
|
||||
* Die geladene Anordnung traegt eine hoehere Rastermarke, als dieser
|
||||
* Programmstand kennt (quick-260930) — ein neuerer Stand hat sie
|
||||
* geschrieben. Solange das gilt, wird nie gespeichert und der
|
||||
* Bearbeitungsmodus bleibt gesperrt; die Oberflaeche bittet ums Neuladen.
|
||||
*/
|
||||
layoutFromNewerVersion: boolean;
|
||||
/**
|
||||
* Leinwand des aktiven Reiters (Bezugsflaeche in Pixeln), siehe
|
||||
* `captureCanvas`. Gehoert wie `layouts` zum aktiven Reiter; null =
|
||||
* noch nicht erfasst.
|
||||
*/
|
||||
canvas: GridCanvas | null;
|
||||
|
||||
setEditMode: (mode: boolean) => void;
|
||||
updateLayouts: (layouts: Record<string, unknown>) => void;
|
||||
@@ -31,6 +51,8 @@ interface DashboardState {
|
||||
loadDashboard: () => Promise<void>;
|
||||
saveLayout: () => Promise<void>;
|
||||
selectDashboard: (id: string) => Promise<void>;
|
||||
/** Erfasst einmalig die Leinwand des aktiven Reiters und speichert sie sofort. */
|
||||
captureCanvas: (area: GridCanvas) => Promise<void>;
|
||||
createDashboard: () => Promise<void>;
|
||||
renameDashboard: (id: string, name: string) => Promise<void>;
|
||||
deleteDashboard: (id: string) => Promise<void>;
|
||||
@@ -79,6 +101,20 @@ function loadDashboardsOnce(): Promise<DashboardTab[]> {
|
||||
* Marker-Umrechnung oben gilt weiterhin je Reiter: `selectDashboard`
|
||||
* durchlaeuft dieselbe Umrechnung-plus-Sofort-Speichern-Logik wie
|
||||
* `loadDashboard`.
|
||||
*
|
||||
* quick-260930: Traegt die geladene Anordnung eine HOEHERE Marke als
|
||||
* `GRID_VERSION` (ein neuerer Programmstand hat sie geschrieben, dieser Tab
|
||||
* ist alt), setzt der Store `layoutFromNewerVersion`: `saveLayout` speichert
|
||||
* dann nie, `setEditMode(true)` wird ignoriert, die Oberflaeche bittet ums
|
||||
* Neuladen. Der Merker gilt je geladenem Reiter.
|
||||
*
|
||||
* Leinwand (Dashboard wie ein Bild mitskalieren): `canvas` ist die Flaeche,
|
||||
* auf der der Reiter eingerichtet wurde. Sie lebt im gespeicherten JSON als
|
||||
* `__canvas` und wird — wie der Marker — an JEDER Speicherstelle mit
|
||||
* `withGridVersion(layouts, canvas)` zurueckgeschrieben (Sofort-Speichern nach
|
||||
* der Umrechnung, `saveLayout`, `captureCanvas`); fehlt sie dort, ist sie
|
||||
* verloren und wird beim naechsten Oeffnen am dann benutzten Bildschirm neu
|
||||
* erfasst.
|
||||
*/
|
||||
export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
dashboards: [],
|
||||
@@ -90,8 +126,12 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
isDirty: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
layoutFromNewerVersion: false,
|
||||
canvas: null,
|
||||
|
||||
setEditMode: (mode: boolean) => {
|
||||
// quick-260930: Anordnung eines neueren Programmstands — nicht bearbeiten.
|
||||
if (mode && get().layoutFromNewerVersion) return;
|
||||
const prev = get().isEditMode;
|
||||
set({ isEditMode: mode });
|
||||
// On exit edit mode, auto-save if dirty (D-01)
|
||||
@@ -125,7 +165,8 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
i: newWidget.id,
|
||||
x: 0,
|
||||
y: maxY,
|
||||
w: constraints.defaultW,
|
||||
// quick-260930: nie breiter als das Raster dieses Breakpoints.
|
||||
w: clampWidthToCols(constraints.defaultW, bp),
|
||||
h: constraints.defaultH,
|
||||
});
|
||||
newLayouts[bp] = arr;
|
||||
@@ -168,12 +209,18 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
set((state) => ({
|
||||
widgets: state.widgets.map((w) => (w.id === id ? { ...w, config } : w)),
|
||||
}));
|
||||
patch({ ...before, ...partial });
|
||||
const optimistic = { ...before, ...partial };
|
||||
patch(optimistic);
|
||||
try {
|
||||
await api.updateWidgetConfig(id, partial);
|
||||
} catch (err) {
|
||||
console.error('Failed to update widget config:', err);
|
||||
patch(before);
|
||||
// quick-260930: nur zuruecksetzen, wenn noch GENAU dieser optimistische
|
||||
// Stand angezeigt wird. Hat inzwischen ein zweiter Klick etwas anderes
|
||||
// gewaehlt, wuerde der Fehler des ersten sonst die zweite Wahl
|
||||
// ueberschreiben.
|
||||
const current = get().widgets.find((w) => w.id === id)?.config;
|
||||
if (current === optimistic) patch(before);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -191,20 +238,23 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
api.fetchLayout(first.id),
|
||||
api.fetchWidgets(first.id),
|
||||
]);
|
||||
const { layouts: migratedLayouts, migrated } = migrateGridLayouts(rawLayouts);
|
||||
const { layouts: migratedLayouts, migrated, newer, canvas } = migrateGridLayouts(rawLayouts);
|
||||
set({
|
||||
dashboards,
|
||||
activeDashboardId: first.id,
|
||||
layouts: migratedLayouts,
|
||||
canvas,
|
||||
widgets,
|
||||
isLoading: false,
|
||||
layoutFromNewerVersion: newer,
|
||||
...(newer ? { isEditMode: false, isDirty: false } : {}),
|
||||
});
|
||||
// Umgerechnete Anordnung SOFORT mit Marker persistieren — nach dem set,
|
||||
// damit die Oberflaeche unabhaengig vom Speichern rendert; eigener
|
||||
// try/catch, damit ein Speicherfehler NICHT als Ladefehler erscheint.
|
||||
if (migrated) {
|
||||
try {
|
||||
await api.saveLayout(first.id, withGridVersion(migratedLayouts));
|
||||
await api.saveLayout(first.id, withGridVersion(migratedLayouts, canvas));
|
||||
} catch (err) {
|
||||
console.error('Failed to persist migrated layout:', err);
|
||||
}
|
||||
@@ -220,8 +270,11 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
saveLayout: async () => {
|
||||
const dashboardId = get().activeDashboardId;
|
||||
if (!dashboardId) return;
|
||||
// quick-260930: NIE eine Anordnung eines neueren Programmstands speichern —
|
||||
// sie ginge mit der alten Marke zurueck und wuerde doppelt skaliert.
|
||||
if (get().layoutFromNewerVersion) return;
|
||||
try {
|
||||
await api.saveLayout(dashboardId, withGridVersion(get().layouts));
|
||||
await api.saveLayout(dashboardId, withGridVersion(get().layouts, get().canvas));
|
||||
set({ isDirty: false });
|
||||
} catch (err) {
|
||||
console.error('Failed to save layout:', err);
|
||||
@@ -246,21 +299,21 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
|
||||
set({ isSwitchingDashboard: true, error: null });
|
||||
try {
|
||||
const [rawLayouts, widgets] = await Promise.all([
|
||||
api.fetchLayout(id),
|
||||
api.fetchWidgets(id),
|
||||
]);
|
||||
const { layouts: migratedLayouts, migrated } = migrateGridLayouts(rawLayouts);
|
||||
const [rawLayouts, widgets] = await Promise.all([api.fetchLayout(id), api.fetchWidgets(id)]);
|
||||
const { layouts: migratedLayouts, migrated, newer, canvas } = migrateGridLayouts(rawLayouts);
|
||||
set({
|
||||
activeDashboardId: id,
|
||||
layouts: migratedLayouts,
|
||||
canvas,
|
||||
widgets,
|
||||
isDirty: false,
|
||||
isSwitchingDashboard: false,
|
||||
layoutFromNewerVersion: newer,
|
||||
...(newer ? { isEditMode: false } : {}),
|
||||
});
|
||||
if (migrated) {
|
||||
try {
|
||||
await api.saveLayout(id, withGridVersion(migratedLayouts));
|
||||
await api.saveLayout(id, withGridVersion(migratedLayouts, canvas));
|
||||
} catch (err) {
|
||||
console.error('Failed to persist migrated layout:', err);
|
||||
}
|
||||
@@ -271,6 +324,36 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Leinwand erfassen (Dashboard wie ein Bild mitskalieren). Das Raster ruft
|
||||
* das auf, solange der aktive Reiter keine Leinwand hat und die verfuegbare
|
||||
* Flaeche Desktop-Breite hat. Die Flaeche wird EINMAL uebernommen und
|
||||
* sofort gespeichert; danach aendert sie sich nicht mehr automatisch.
|
||||
* Wo die Leinwand erfasst wurde, sieht das Dashboard 1:1 aus — auf jedem
|
||||
* anderen Bildschirm wird es gleichmaessig (auch die Schrift) auf dessen
|
||||
* Flaeche verkleinert oder vergroessert, ohne Scrollen.
|
||||
*
|
||||
* Nichts geschieht bei vorhandener Leinwand, ungueltiger Flaeche (Handy,
|
||||
* Messfehler) oder einer Anordnung eines neueren Programmstands (die wird
|
||||
* nie gespeichert). Eigener try/catch wie beim Sofort-Speichern nach der
|
||||
* Umrechnung: ein Speicherfehler laesst die Leinwand im Zustand stehen, ein
|
||||
* spaeteres `saveLayout` schreibt sie dann mit.
|
||||
*/
|
||||
captureCanvas: async (area: GridCanvas) => {
|
||||
const state = get();
|
||||
const dashboardId = state.activeDashboardId;
|
||||
if (!dashboardId || state.canvas || state.layoutFromNewerVersion) return;
|
||||
if (area.w < CANVAS_MIN_WIDTH || area.h < CANVAS_MIN_HEIGHT) return;
|
||||
const canvas = parseGridCanvas({ w: Math.round(area.w), h: Math.round(area.h) });
|
||||
if (!canvas) return;
|
||||
set({ canvas });
|
||||
try {
|
||||
await api.saveLayout(dashboardId, withGridVersion(state.layouts, canvas));
|
||||
} catch (err) {
|
||||
console.error('Failed to persist dashboard canvas:', err);
|
||||
}
|
||||
},
|
||||
|
||||
/** Legt einen neuen, leeren Reiter an und macht ihn aktiv (D-08). */
|
||||
createDashboard: async () => {
|
||||
try {
|
||||
@@ -332,7 +415,9 @@ export const useDashboardStore = create<DashboardState>()((set, get) => ({
|
||||
reorderDashboards: async (ids: string[]) => {
|
||||
const previous = get().dashboards;
|
||||
const byId = new Map(previous.map((d) => [d.id, d]));
|
||||
const optimistic = ids.map((id) => byId.get(id)).filter((d): d is DashboardTab => d !== undefined);
|
||||
const optimistic = ids
|
||||
.map((id) => byId.get(id))
|
||||
.filter((d): d is DashboardTab => d !== undefined);
|
||||
set({ dashboards: optimistic });
|
||||
|
||||
try {
|
||||
|
||||
@@ -246,7 +246,6 @@
|
||||
"catalogClose": "Dialog schließen",
|
||||
"deleteTooltip": "Widget entfernen",
|
||||
"hideTitle": "Titel ausblenden",
|
||||
"showTitle": "Titel einblenden",
|
||||
"dragHint": "Ziehen Sie die Kachel, um sie zu verschieben",
|
||||
"editMode": "Dashboard bearbeiten",
|
||||
"saveChanges": "Änderungen speichern",
|
||||
@@ -380,10 +379,9 @@
|
||||
"viewModeLabel": "Ansicht wechseln",
|
||||
"iconUrlPlaceholder": "Logo-Adresse (optional)",
|
||||
"iconUploadLabel": "Eigenes Symbol hochladen",
|
||||
"iconUploadHint": "PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB. Ein hochgeladenes Symbol hat Vorrang vor der Logo-Adresse.",
|
||||
"iconUploadHint": "PNG, JPEG, GIF, WebP, ICO oder SVG, höchstens 512 KB. Ein hochgeladenes Symbol hat Vorrang vor der Logo-Adresse; eine neu eingetragene Logo-Adresse ersetzt es aber.",
|
||||
"iconUploadedHint": "Für diesen Favoriten ist ein eigenes Symbol hochgeladen.",
|
||||
"iconRemoveButton": "Hochgeladenes Symbol entfernen",
|
||||
"iconUrlUnreachable": "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.",
|
||||
"iconTooLarge": "Die Datei ist zu groß – erlaubt sind höchstens 512 KB.",
|
||||
"iconInvalidType": "Nur Bilder im Format PNG, JPEG, GIF, WebP, ICO oder SVG sind erlaubt.",
|
||||
"iconUploadFailed": "Das Symbol konnte nicht hochgeladen werden."
|
||||
@@ -476,8 +474,48 @@
|
||||
"serversHint": "Ohne Auswahl zeigt die Kachel alle Server.",
|
||||
"chooseServers": "Server auswählen"
|
||||
},
|
||||
"reminder": {
|
||||
"name": "Erinnerungen",
|
||||
"description": "Termine und Aufgaben mit Benachrichtigung zur gewünschten Zeit",
|
||||
"add": "Neue Erinnerung",
|
||||
"empty": "Keine Erinnerungen. Legen Sie eine neue Erinnerung an.",
|
||||
"formTitle": "Neue Erinnerung",
|
||||
"dateLabel": "Datum",
|
||||
"timeLabel": "Uhrzeit",
|
||||
"titleLabel": "Titel",
|
||||
"descriptionLabel": "Beschreibung (optional)",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"titleRequired": "Bitte geben Sie einen Titel ein.",
|
||||
"pastError": "Der gewählte Zeitpunkt liegt in der Vergangenheit.",
|
||||
"saveError": "Die Erinnerung konnte nicht gespeichert werden.",
|
||||
"loadError": "Erinnerungen konnten nicht geladen werden.",
|
||||
"limitReached": "Es sind höchstens 100 Erinnerungen möglich.",
|
||||
"due": "Fällig",
|
||||
"done": "Erledigt",
|
||||
"snooze": "Später erinnern",
|
||||
"snooze10m": "In 10 Minuten",
|
||||
"snooze1h": "In 1 Stunde",
|
||||
"snoozeTomorrow": "Morgen um {time}",
|
||||
"snoozeToday": "Heute um {time}",
|
||||
"edit": "Erinnerung bearbeiten",
|
||||
"editTitle": "Erinnerung bearbeiten",
|
||||
"delete": "Erinnerung löschen",
|
||||
"deleteConfirm": "Löschen?",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"alreadyDue": "Die Erinnerung ist inzwischen fällig und lässt sich nicht mehr bearbeiten.",
|
||||
"notDue": "Die Erinnerung ist noch nicht fällig.",
|
||||
"permissionDenied": "Ihr Browser blockiert Benachrichtigungen. Fällige Erinnerungen erscheinen dann nur hier in der Kachel.",
|
||||
"emailLabel": "Zusätzlich per E-Mail erinnern",
|
||||
"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": "Zusätzlich per E-Mail",
|
||||
"notificationTitle": "Erinnerung: {title}"
|
||||
},
|
||||
"editShort": "Bearbeiten",
|
||||
"done": "Fertig",
|
||||
"reloadRequired": "Das Dashboard wurde von einer neueren Version von Tessera gespeichert. Bitte laden Sie die Seite neu, um es zu bearbeiten.",
|
||||
"toolbarLabel": "Dashboard-Aktionen",
|
||||
"emptyState": {
|
||||
"title": "Ihr Dashboard ist noch leer",
|
||||
@@ -1364,8 +1402,8 @@
|
||||
},
|
||||
"customModules": {
|
||||
"openInNewTab": "In neuem Tab öffnen",
|
||||
"embedHint": "Manche Seiten lassen sich nicht einbetten. Öffnen Sie die Seite dann in einem neuen Tab.",
|
||||
"notFound": "Dieses Modul gibt es nicht mehr.",
|
||||
"loadError": "Dieses Modul konnte nicht geladen werden. Bitte versuchen Sie es später erneut.",
|
||||
"invalidUrl": "Die Adresse dieses Moduls ist keine gültige https-Adresse und wird deshalb nicht angezeigt.",
|
||||
"loading": "Wird geladen …",
|
||||
"form": {
|
||||
@@ -1380,6 +1418,8 @@
|
||||
"urlCredentials": "Die Adresse darf keinen Benutzernamen und kein Kennwort enthalten.",
|
||||
"saveError": "Das Modul konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.",
|
||||
"deleteError": "Das Modul konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
|
||||
"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? Der Eintrag verschwindet für alle Benutzer aus der Seitenleiste.",
|
||||
|
||||
@@ -246,7 +246,6 @@
|
||||
"catalogClose": "Close dialog",
|
||||
"deleteTooltip": "Remove widget",
|
||||
"hideTitle": "Hide title",
|
||||
"showTitle": "Show title",
|
||||
"dragHint": "Drag the tile to move it",
|
||||
"editMode": "Edit dashboard",
|
||||
"saveChanges": "Save changes",
|
||||
@@ -380,10 +379,9 @@
|
||||
"viewModeLabel": "Switch view",
|
||||
"iconUrlPlaceholder": "Logo URL (optional)",
|
||||
"iconUploadLabel": "Upload custom icon",
|
||||
"iconUploadHint": "PNG, JPEG, GIF, WebP, ICO or SVG, at most 512 KB. An uploaded icon takes precedence over the logo URL.",
|
||||
"iconUploadHint": "PNG, JPEG, GIF, WebP, ICO or SVG, at most 512 KB. An uploaded icon takes precedence over the logo URL; a newly entered logo URL replaces it, though.",
|
||||
"iconUploadedHint": "A custom icon has been uploaded for this favorite.",
|
||||
"iconRemoveButton": "Remove uploaded icon",
|
||||
"iconUrlUnreachable": "The image at this address could not be loaded. The site likely blocks automated requests (for example via a Cloudflare check) or is unreachable. Please upload the icon instead.",
|
||||
"iconTooLarge": "The file is too large – at most 512 KB is allowed.",
|
||||
"iconInvalidType": "Only PNG, JPEG, GIF, WebP, ICO or SVG images are allowed.",
|
||||
"iconUploadFailed": "The icon could not be uploaded."
|
||||
@@ -476,8 +474,48 @@
|
||||
"serversHint": "With nothing selected, the tile shows all servers.",
|
||||
"chooseServers": "Choose servers"
|
||||
},
|
||||
"reminder": {
|
||||
"name": "Reminders",
|
||||
"description": "Appointments and tasks with a notification at the time you choose",
|
||||
"add": "New reminder",
|
||||
"empty": "No reminders. Create a new reminder.",
|
||||
"formTitle": "New reminder",
|
||||
"dateLabel": "Date",
|
||||
"timeLabel": "Time",
|
||||
"titleLabel": "Title",
|
||||
"descriptionLabel": "Description (optional)",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"titleRequired": "Please enter a title.",
|
||||
"pastError": "The chosen time is in the past.",
|
||||
"saveError": "The reminder could not be saved.",
|
||||
"loadError": "Reminders could not be loaded.",
|
||||
"limitReached": "You can have at most 100 reminders.",
|
||||
"due": "Due",
|
||||
"done": "Done",
|
||||
"snooze": "Remind me later",
|
||||
"snooze10m": "In 10 minutes",
|
||||
"snooze1h": "In 1 hour",
|
||||
"snoozeTomorrow": "Tomorrow at {time}",
|
||||
"snoozeToday": "Today at {time}",
|
||||
"edit": "Edit reminder",
|
||||
"editTitle": "Edit reminder",
|
||||
"delete": "Delete reminder",
|
||||
"deleteConfirm": "Delete?",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"alreadyDue": "The reminder is now due and can no longer be edited.",
|
||||
"notDue": "The reminder is not due yet.",
|
||||
"permissionDenied": "Your browser blocks notifications. Due reminders will then only appear here in the tile.",
|
||||
"emailLabel": "Also remind me by e-mail",
|
||||
"emailNoSmtp": "E-mail reminders are not possible because no e-mail delivery is set up. Please contact your administrator.",
|
||||
"emailNoAddress": "Your account has no e-mail address on file.",
|
||||
"emailOn": "Also by e-mail",
|
||||
"notificationTitle": "Reminder: {title}"
|
||||
},
|
||||
"editShort": "Edit",
|
||||
"done": "Done",
|
||||
"reloadRequired": "This dashboard was saved by a newer version of Tessera. Please reload the page to edit it.",
|
||||
"toolbarLabel": "Dashboard actions",
|
||||
"emptyState": {
|
||||
"title": "Your dashboard is still empty",
|
||||
@@ -1364,8 +1402,8 @@
|
||||
},
|
||||
"customModules": {
|
||||
"openInNewTab": "Open in new tab",
|
||||
"embedHint": "Some pages cannot be embedded. If this one stays blank, open it in a new tab.",
|
||||
"notFound": "This module no longer exists.",
|
||||
"loadError": "This module could not be loaded. Please try again later.",
|
||||
"invalidUrl": "This module's address is not a valid https address and is therefore not shown.",
|
||||
"loading": "Loading …",
|
||||
"form": {
|
||||
@@ -1380,6 +1418,8 @@
|
||||
"urlCredentials": "The address must not contain a user name or a password.",
|
||||
"saveError": "The module could not be saved. Please try again.",
|
||||
"deleteError": "The module could not be deleted. Please try again.",
|
||||
"forbidden": "You do not have permission for this action.",
|
||||
"invalidInput": "The details were not accepted. Please check name, address and category.",
|
||||
"deleteConfirm": {
|
||||
"title": "Delete custom module",
|
||||
"bodyShared": "Do you really want to delete “{name}”? The entry disappears from the sidebar for all users.",
|
||||
|
||||
@@ -103,6 +103,8 @@ export const UMLAUT_REPLACEMENTS: Record<string, string> = {
|
||||
* and must never be touched by the replacement or flagged by the guard.
|
||||
*/
|
||||
export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
// quick-260929-if2: „lässt“ (Erinnerung „lässt sich nicht mehr bearbeiten“)
|
||||
'lässt',
|
||||
'manuell',
|
||||
'Manuell',
|
||||
'Quelle',
|
||||
@@ -114,6 +116,7 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
'Energiequellen',
|
||||
'neue',
|
||||
'neuen',
|
||||
'neueren',
|
||||
'neuere',
|
||||
'Neue',
|
||||
'Neues',
|
||||
|
||||
@@ -205,6 +205,75 @@ Unter „Verwaltung → Eigene Module“ legen Sie **gemeinsame** Seitenleisten-
|
||||
|
||||
Daneben kann **jeder Benutzer** – auch Sie selbst – unter „Einstellungen → Eigene Module“ persönliche Einträge anlegen. Diese sieht ausschließlich der Besitzer; Administratoren sehen und ändern sie nicht. Wird ein Benutzer gelöscht, verschwinden seine persönlichen Einträge mit. Die Verwaltungsseite zeigt deshalb nur die gemeinsamen Einträge.
|
||||
|
||||
### Proxmox-Server anbinden: Zugang nur zum Lesen einrichten
|
||||
|
||||
Tessera schaut bei Proxmox nur zu und ändert dort nichts. Legen Sie deshalb auf jedem Proxmox-Server einen **eigenen Zugang nur für Tessera** an, der ausschließlich lesen darf – nie Ihr persönliches Konto und nie `root`. Die Server tragen Sie danach im Proxmox-Modul unter „Zu den Einstellungen“ ein.
|
||||
|
||||
| Produkt | Zugangsart in Tessera | Rolle am Proxmox-Server |
|
||||
|---|---|---|
|
||||
| PVE (Virtualisierung) | API-Token (empfohlen) | PVEAuditor |
|
||||
| PBS (Backup) | API-Token (empfohlen) | Audit |
|
||||
| PMG (Mail-Gateway) | Benutzer/Passwort | Auditor |
|
||||
|
||||
**Warum bei PMG kein Token?** Das Mail-Gateway kennt keine API-Token – Proxmox hat sie dort bisher nicht eingebaut (Stand September 2026, offene Funktionsanfrage bei Proxmox). Tessera bietet bei PMG deshalb nur Benutzername und Passwort an. Mit einem eigenen Benutzer, der nur die Rolle „Auditor“ hat, ist das genauso sicher: dieses Konto kann nichts verändern.
|
||||
|
||||
Ein Token hat bei PVE und PBS zwei Vorteile gegenüber einem Passwort: Er lässt sich einzeln widerrufen, ohne das Konto anzufassen, und er kann sich nicht an der Proxmox-Oberfläche anmelden. Wichtig bei beiden: Ein Token darf nie mehr als sein Benutzer. Die Leserolle muss deshalb **dem Benutzer und dem Token** zugewiesen werden, sonst meldet Tessera beim Verbindungstest „keine Berechtigung“.
|
||||
|
||||
#### PVE: Benutzer und API-Token anlegen
|
||||
|
||||
In der Proxmox-VE-Oberfläche (Ansicht „Rechenzentrum“ / „Datacenter“):
|
||||
|
||||
1. **Berechtigungen → Benutzer → Hinzufügen:** Benutzername `tessera`, Realm „Proxmox VE authentication server“ (`pve`), ein langes, zufälliges Passwort (es wird nirgends gebraucht), Kommentar z. B. „Tessera, nur lesen“.
|
||||
2. **Berechtigungen → API-Token → Hinzufügen:** Benutzer `tessera@pve`, Token-ID `tessera`, Haken bei „Privilegien-Trennung“ gesetzt lassen. Nach dem Speichern zeigt Proxmox die **Token-ID** (`tessera@pve!tessera`) und das **Geheimnis** – das Geheimnis erscheint **nur dieses eine Mal**, kopieren Sie es sofort.
|
||||
3. **Berechtigungen → Hinzufügen → Benutzer-Berechtigung:** Pfad `/`, Benutzer `tessera@pve`, Rolle `PVEAuditor`, „Weitergeben“ angehakt.
|
||||
4. **Berechtigungen → Hinzufügen → API-Token-Berechtigung:** Pfad `/`, API-Token `tessera@pve!tessera`, Rolle `PVEAuditor`, „Weitergeben“ angehakt.
|
||||
|
||||
Dasselbe auf der Kommandozeile eines PVE-Knotens (als root):
|
||||
|
||||
```
|
||||
pveum user add tessera@pve --comment "Tessera, nur lesen"
|
||||
pveum user token add tessera@pve tessera --privsep 1
|
||||
pveum acl modify / --users tessera@pve --roles PVEAuditor
|
||||
pveum acl modify / --tokens 'tessera@pve!tessera' --roles PVEAuditor
|
||||
```
|
||||
|
||||
Der zweite Befehl gibt das Geheimnis (`value`) einmalig aus. In einem Cluster genügt das auf einem Knoten – Benutzer und Rechte gelten für den ganzen Cluster.
|
||||
|
||||
In Tessera: Typ **PVE**, Adresse z. B. `https://pve01.intern:8006`, Zugangsart **API-Token**, Token-Kennung `tessera@pve!tessera`, Token-Geheimnis = das kopierte Geheimnis.
|
||||
|
||||
#### PBS: Benutzer und API-Token anlegen
|
||||
|
||||
In der Proxmox-Backup-Server-Oberfläche:
|
||||
|
||||
1. **Konfiguration → Zugriffssteuerung → Benutzerverwaltung → Hinzufügen:** Benutzername `tessera`, Realm „Proxmox Backup authentication server“ (`pbs`), ein langes, zufälliges Passwort.
|
||||
2. **Reiter „API-Token“ → Hinzufügen:** Benutzer `tessera@pbs`, Token-Name `tessera`. Proxmox zeigt danach **Token-ID** (`tessera@pbs!tessera`) und **Geheimnis** – das Geheimnis nur dieses eine Mal, sofort kopieren.
|
||||
3. **Reiter „Berechtigungen“ → Hinzufügen → Benutzer-Berechtigung:** Pfad `/`, Benutzer `tessera@pbs`, Rolle `Audit`, „Weitergeben“ angehakt.
|
||||
4. **Reiter „Berechtigungen“ → Hinzufügen → API-Token-Berechtigung:** Pfad `/`, API-Token `tessera@pbs!tessera`, Rolle `Audit`, „Weitergeben“ angehakt.
|
||||
|
||||
Kommandozeile des Backup-Servers (als root):
|
||||
|
||||
```
|
||||
proxmox-backup-manager user create tessera@pbs --comment "Tessera, nur lesen"
|
||||
proxmox-backup-manager user generate-token tessera@pbs tessera
|
||||
proxmox-backup-manager acl update / Audit --auth-id tessera@pbs
|
||||
proxmox-backup-manager acl update / Audit --auth-id 'tessera@pbs!tessera'
|
||||
```
|
||||
|
||||
In Tessera: Typ **PBS**, Adresse z. B. `https://pbs01.intern:8007`, Zugangsart **API-Token**, Token-Kennung `tessera@pbs!tessera`, Token-Geheimnis = das kopierte Geheimnis.
|
||||
|
||||
#### PMG: Benutzer mit Leserolle anlegen
|
||||
|
||||
In der Proxmox-Mail-Gateway-Oberfläche:
|
||||
|
||||
1. **Konfiguration → Benutzerverwaltung → Hinzufügen:** Benutzername `tessera`, Realm „Proxmox Mail Gateway authentication server“ (`pmg`), Rolle **Auditor**, ein langes, zufälliges Passwort, Haken bei „Aktiviert“.
|
||||
2. Das Passwort notieren – Tessera braucht es.
|
||||
|
||||
In Tessera: Typ **PMG**, Adresse z. B. `https://pmg01.intern:8006`, Zugangsart **Benutzer/Passwort** (bei PMG die einzige Auswahl), Benutzername `tessera@pmg`, Passwort = das vergebene Passwort.
|
||||
|
||||
#### Prüfen und später widerrufen
|
||||
|
||||
Nach dem Speichern prüft **„Verbindung testen“** den Zugang sofort. „Keine Berechtigung“ heißt fast immer: Die Rolle fehlt am Benutzer oder am Token, oder „Weitergeben“ ist nicht angehakt. Soll Tessera einen Server nicht mehr sehen, löschen Sie bei PVE/PBS einfach den Token, bei PMG den Benutzer `tessera` – andere Konten sind davon nicht betroffen.
|
||||
|
||||
## 6. SMTP
|
||||
|
||||
Unter **Administrator → SMTP** wird der Mailversand konfiguriert: Host, Port, Verschlüsselung (Keine, STARTTLS oder SSL-TLS), Benutzername, Passwort, die Absenderadresse und optional das Feld „Fehlermeldungen an“ (siehe unten). Das Passwortfeld wird aus Sicherheitsgründen nie mit dem gespeicherten Wert vorbefüllt – es bleibt beim Laden immer leer und wird nur mitgesendet, wenn tatsächlich ein neuer Wert eingegeben wurde.
|
||||
|
||||
@@ -60,6 +60,8 @@ Unten in der Seitenleiste begrüßt Tessera Sie je nach Tageszeit mit Ihrem Name
|
||||
|
||||
Das Dashboard ist Ihre persönliche Startseite und öffnet sich automatisch nach der Anmeldung. Es zeigt ein Raster aus Kacheln — den **Widgets** —, die genau dort stehen, wo Sie sie im Bearbeitungsmodus platziert haben. Jedes Widget trägt oben ein gelbes Symbol-Feld und blendet beim Laden sanft ein. Ist noch kein Widget platziert, sehen Sie den Hinweis „Ihr Dashboard ist noch leer" mit einigen Vorschlägen für erste Kacheln.
|
||||
|
||||
**Gleiches Bild auf jedem Bildschirm:** Tessera merkt sich je Dashboard die Größe der Fläche auf dem Bildschirm, an dem Sie es zum ersten Mal geöffnet haben. Auf jedem anderen Bildschirm – etwa am Laptop statt am großen Monitor – zeigt es dasselbe Dashboard maßstäblich verkleinert oder vergrößert, Kacheln und Schrift gleichermaßen, sodass es vollständig hineinpasst. Hat der Bildschirm eine andere Form, bleibt rechts oder unten etwas Platz frei. Bearbeiten können Sie das Dashboard auf jedem Bildschirm. Auf dem Handy (schmaler als 768 Pixel) ordnet Tessera die Kacheln weiterhin untereinander an.
|
||||
|
||||
**Mehrere Dashboards (Reiter):** Die Reiter stehen in der Mitte der App-Leiste — Sie können mehrere Dashboards anlegen, die dort nebeneinander stehen. Jeder Reiter trägt seine eigenen Kacheln und seine eigene Anordnung; was auf dem einen Reiter liegt, erscheint nicht auf dem anderen. Ein Klick auf einen Reiter wechselt dorthin. Beim Öffnen wird immer der ERSTE Reiter geladen — Sie legen ihn fest, indem Sie einen Reiter mit der Maus ganz nach vorn ziehen (das geht jederzeit, auch ohne den Bearbeitungsmodus). Im Bearbeitungsmodus können Sie außerdem:
|
||||
- Über den Knopf am Ende der Leiste einen neuen, leeren Reiter anlegen — er heißt automatisch „Dashboard 2", „Dashboard 3" und so weiter.
|
||||
- Den Namen des gerade aktiven Reiters ändern: Klicken Sie auf den Stift daneben, geben Sie den neuen Namen ein und bestätigen Sie mit der Eingabetaste (Escape verwirft die Änderung).
|
||||
@@ -89,6 +91,7 @@ Ihre Änderungen werden über **„Fertig"** übernommen. Verlassen Sie den Bear
|
||||
| Bilderrahmen | Diashow aus eigenen Bildern: Sie laden Bilder hoch (PNG, JPEG, GIF oder WebP, höchstens 5 MB je Bild, bis zu 30 Bilder) oder binden Bilder über eine https-Adresse ein. Ob das Bild ganz sichtbar oder formatfüllend erscheint, wie oft gewechselt wird (oder gar nicht), ob in Reihenfolge oder zufällig, und welche Bildunterschrift jedes Bild trägt, stellen Sie unter Einstellungen > Dashboard ein. Ein Klick auf das Bild zeigt es groß; Escape oder ein Klick daneben schließt die Großansicht wieder |
|
||||
| XFrame | Zeigt eine Webseite als Rahmen in der Kachel. Die https-Adresse, einen optionalen Titel und ob die Seite automatisch neu geladen wird (nie, 1 Minute bis 1 Stunde), stellen Sie unter Einstellungen > Dashboard ein. Die eingebettete Seite kann Tessera nicht verlassen; über „In neuem Tab öffnen“ erreichen Sie die Seite jederzeit direkt. Manche Webseiten erlauben das Einbetten nicht — der Rahmen bleibt dann leer, der Knopf funktioniert trotzdem. Wahlweise zeigen Sie nur einen Ausschnitt der Seite: den Rahmen in der Vorschau verschieben oder an den Ecken ziehen (oder Links, Oben, Breite und Höhe eintippen) – die Kachel zeigt dann genau diesen Ausschnitt, passend zu ihrer Größe; für die ganze Seite gibt es eine Vergrößerung (50 bis 150 %), und „Nur anzeigen“ sperrt Klicken und Scrollen im Rahmen. |
|
||||
| Proxmox | Zeigt den Zustand Ihrer Proxmox-Server: oben ein farbiger Balken mit „Alles in Ordnung“ oder zum Beispiel „1 nicht erreichbar, 1 mit Warnung“, darunter die Server, auffällige zuerst, jeweils mit einer Kennzahl (laufende Gäste, Auslastung bei einer Warnung, letzte Sicherung oder eingehende E-Mails). Ein Klick auf einen Server öffnet die Proxmox-Seite. Die Kachel steht nur Benutzern zur Verfügung, die das Proxmox-Modul nutzen dürfen. Sie aktualisiert sich jede Minute aus dem zuletzt gespeicherten Stand und fragt die Server dabei nicht neu ab. Einen Titel und die Auswahl der angezeigten Server (ohne Auswahl: alle) legen Sie im Bearbeitungsmodus direkt an der Kachel über das Titelfeld und „Server auswählen“ fest oder unter Einstellungen > Dashboard. In einer schmalen Kachel stehen nur Punkte und Namen, in einer sehr kleinen nur Balken und Zusammenfassung |
|
||||
| Erinnerungen | Persönliche Erinnerungen mit Datum, Uhrzeit, Titel und Beschreibung. Über „Neue Erinnerung“ legen Sie eine an; die Liste zeigt Ihre Erinnerungen nach Zeit sortiert. Zur gewählten Zeit meldet sich Tessera mit einer Benachrichtigung – im Browser nach einer einmaligen Erlaubnis (der Browser fragt beim ersten Anlegen), in der Desktop-App als Windows-Benachrichtigung, auch wenn das Fenster im Infobereich liegt. Blockiert Ihr Browser Benachrichtigungen, sehen Sie die fällige Erinnerung nur hier in der Kachel; ein Hinweis in der Kachel sagt das. Wer möchte, setzt beim Anlegen den Haken „Zusätzlich per E-Mail erinnern“: Tessera schickt dann zur gewählten Zeit eine E-Mail an Ihre Adresse, auch wenn Tessera nirgends geöffnet ist. Der Haken ist ausgegraut, wenn Ihr Administrator noch keinen E-Mail-Versand eingerichtet hat oder in Ihrem Konto keine E-Mail-Adresse hinterlegt ist; die Kachel nennt den Grund. Eine fällige Erinnerung bleibt hervorgehoben mit „Fällig“ stehen. „Erledigt“ entfernt sie; „Später erinnern“ verschiebt sie auf in 10 Minuten, in 1 Stunde oder morgen zur gleichen Uhrzeit — dann melden sich Benachrichtigung und (falls gewählt) E-Mail noch einmal. Bearbeiten und Löschen sind nur möglich, solange die Erinnerung noch nicht fällig ist. Erinnerungen sind persönlich: nur Sie sehen und ändern Ihre |
|
||||
|
||||
Für Uhr, Suchleiste, Kalender, Notizen, Favoriten, Bilderrahmen, XFrame und Proxmox gibt es zusätzliche Einstellungen (z. B. Zeitzone und Schriftgröße der Uhr, eigene Suchanbieter, Kalenderquellen, Überschrift der Notiz- und Favoriten-Kachel, Bilder und Wechselintervall des Bilderrahmens, Adresse, Titel und Neuladen des XFrame, Titel und angezeigte Server der Proxmox-Kachel) — diese finden Sie unter **Einstellungen > Dashboard**, siehe [Persönliche Einstellungen](#persönliche-einstellungen).
|
||||
|
||||
@@ -159,7 +162,7 @@ Das Modul zeigt den Zustand Ihrer Proxmox-Server auf einen Blick — für die dr
|
||||
|
||||
**Einen Server anlegen** (nur für Administratoren, über den Link „Zu den Einstellungen"): Name, Typ (PVE/PBS/PMG), Adresse (z. B. `https://pve.intern:8006`) und Zugang. Beim Zugang wählen Sie zwischen einem API-Token oder Benutzername/Passwort — bei PMG bietet Tessera von vornherein nur Benutzername/Passwort an, weil dieses Proxmox-Produkt keine API-Token kennt. Ein einmal gespeichertes Geheimnis (Token oder Passwort) wird nie wieder im Klartext angezeigt; lassen Sie das Feld beim Bearbeiten leer, um es unverändert zu lassen, oder tragen Sie ein neues ein, um es zu ersetzen.
|
||||
|
||||
**Zugangsrechte am Proxmox-Server:** Legen Sie dort für den Zugang, den Sie hier eintragen, ausschließlich eine NUR-LESE-Rolle an — Tessera braucht nie mehr. Bei PVE ist das die Rolle **PVEAuditor**, bei PBS **Audit** (beziehungsweise feiner **DatastoreAudit**), bei PMG **Auditor**.
|
||||
**Zugangsrechte am Proxmox-Server:** Legen Sie dort für den Zugang, den Sie hier eintragen, ausschließlich eine NUR-LESE-Rolle an — Tessera braucht nie mehr. Bei PVE ist das die Rolle **PVEAuditor**, bei PBS **Audit** (beziehungsweise feiner **DatastoreAudit**), bei PMG **Auditor**. Wie Sie diesen Zugang Schritt für Schritt anlegen (bei PVE und PBS als API-Token, bei PMG als eigener Benutzer), steht im Administrationshandbuch unter [Proxmox-Server anbinden](anleitung-administration.md#proxmox-server-anbinden-zugang-nur-zum-lesen-einrichten).
|
||||
|
||||
**Zertifikat prüfen:** Dieser Schalter steht standardmäßig auf „prüfen" (an). Nutzen Sie für einen Server ein selbstsigniertes oder sonst nicht vertrauenswürdiges Zertifikat, können Sie die Prüfung für **genau diesen einen Server** ausschalten — die Ausnahme gilt nie für einen anderen Server.
|
||||
|
||||
@@ -224,7 +227,7 @@ Schließen Sie das Fenster über das X, legt sich Tessera lediglich in den Infob
|
||||
- **Mit Windows starten** (unter Linux: **Beim Anmelden starten**) — mit Häkchen
|
||||
- **Beenden**
|
||||
|
||||
Nur „Beenden" beendet die App tatsächlich, auch im Infobereich. Fenstergröße und -position merkt sich die App bis zum nächsten Start. Fahren Sie mit der Maus über das Symbol, nennt der Hinweistext ebenfalls den verbundenen Server (unter Windows).
|
||||
Nur „Beenden" beendet die App tatsächlich, auch im Infobereich. Solange die App läuft, melden sich auch die Erinnerungen aus dem Widget „Erinnerungen“ als Windows-Benachrichtigung, wenn das Fenster im Infobereich liegt (bei ausgeblendetem Fenster kann die Meldung bis zu einer Minute nach der gewählten Zeit erscheinen). Fenstergröße und -position merkt sich die App bis zum nächsten Start. Fahren Sie mit der Maus über das Symbol, nennt der Hinweistext ebenfalls den verbundenen Server (unter Windows).
|
||||
|
||||
### Server-Adresse ändern
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -110,6 +110,7 @@ export const WIDGET_TYPES = [
|
||||
'picture-frame',
|
||||
'xframe',
|
||||
'proxmox',
|
||||
'reminder',
|
||||
] as const;
|
||||
|
||||
export type WidgetType = (typeof WIDGET_TYPES)[number];
|
||||
|
||||
Reference in New Issue
Block a user