--- 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: " next to , 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().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) 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. @~/.claude/gsd-core/workflows/execute-plan.md @~/.claude/gsd-core/templates/summary.md @.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 ## 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 `` commands (not the curl end-to-end command, which creates data) before continuing. Nothing is carried over from an earlier conversation: each task's `` 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`. Task 1 (tracer): create a reminder, see it in the widget, get notified at due time (browser and desktop) 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 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" 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 --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 ``, 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`: - 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 `` 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. 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 non-zero exit, a non-zero "failed" count in the "Test Files" or "Tests" summary line, or "No test files found" 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 non-zero exit, a non-zero "failed" count in the "Test Files" or "Tests" summary line, or "No test files found" 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 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 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 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) 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" 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" - 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. Task 2: due state, "Erledigt", "Später erinnern", edit and delete before due 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 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 - 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. 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 ``, 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). pnpm --filter @tessera/api exec vitest run src/reminders src/prisma/rls-access-inventory.spec.ts non-zero exit, a non-zero "failed" count in the "Test Files" or "Tests" summary line, or "No test files found" 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 non-zero exit, a non-zero "failed" count in the "Test Files" or "Tests" summary line, or "No test files found" - 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. Task 3: optional e-mail at due time (exactly once), toggle in the widget, docs, full gates, manual check list 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 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" - 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: " 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>