70 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | estimate | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| quick-260929-if2 | 01 | execute | 1 |
|
true |
|
|
|
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.
<execution_context>
@/.claude/gsd-core/workflows/execute-plan.md
@/.claude/gsd-core/templates/summary.md
</execution_context>
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 staticcapabilities/default.jsonhas noremoteblock on purpose (T-JN2-01, see the doc comment onget_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 windowmain, and granting onlynotification:allow-notify,notification:allow-is-permission-grantedandnotification:allow-request-permission. App commands such assave_server_urlstay local-only. Tauri 2.11.3 hasdynamic-aclin its default features, andtauri::ipc::CapabilityBuilder::remote()plusManager::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 replaceswindow.Notificationwith a polyfill that makes the same IPC call. On Windows that polyfill also reportspermission = "denied"on every page load untilrequestPermission()runs. So the helper never usesNotification.permissioninside 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/:idbacks 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 whereemailSentAt IS NULL,dueAtunchanged andemailAttempts < 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 ISOdueAt, 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.
dueAtmust 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 inUser. 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.addIntervalinonApplicationBootstrap(lifecycle choice as inTenderSchedulerService). It does not use therequire('cron')+ cast workaround, which would add a Biome warning. An in-processrunningflag skips overlapping ticks. - E-09 SMTP "configured" means the tenant has a
SmtpConfigrow (SettingsService.getSmtpConfig(tenantId) !== null), the same rule asTenderMailService. The environment fallback ofMailServicedoes 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.mdwithstatus: 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 withstatus: complete.
-
DB (D-05). Add the
Remindermodel exactly as in "Interfaces" andreminders Reminder[]onUserinapps/api/prisma/schema.prisma, with a German comment above the model ("quick-260929-if2: persoenliche Erinnerungen, einmalig (D-01)…"). Write the migrationapps/api/prisma/migrations/20260929140000_reminder/migration.sqlby 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_fkeywith ON DELETE CASCADE). - Then add
ENABLEandFORCE ROW LEVEL SECURITY, plustenant_isolation_policyin 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.
- Start with the mandatory German header comment in the style of
-
API module
apps/api/src/reminders/, registered inapps/api/src/app.module.ts:dto/reminder.dto.ts:CreateReminderDtowithtitle(@IsString @IsNotEmpty @MaxLength(200), trimmed via@Transform),description(@IsOptional @IsString @MaxLength(2000)) anddueAt(@IsISO8601({ strict: true })). Do not addemailEnabledyet (Task 3).reminders.service.ts:list(tenantId, userId)andcreate(tenantId, userId, dto).- Every method uses its own
const tenantPrisma = forTenant(this.prisma, tenantId, userId). Always use that assignment form and always the nametenantPrisma, becauserls-access-inventory.spec.tsand the doc's gate loop detect it by exactly that form. - Every
wherealso carriestenantIdanduserId, as an app-level check while the RLS switch is off. listreturns the caller's rows ordered bydueAtascending throughREMINDER_SELECT, withdueAtserialized as ISO.createrejects adueAtthat is not after now or more than 5 years ahead withBadRequestException, and returns 409ConflictExceptiononce the user already has 100 rows (E-06). It setstenantIdanduserIdfrom the arguments only.- Add a German class comment explaining ownership (404, never 403, D-05) and the RLS binding.
- Every method uses its own
reminders.controller.tsat pathreminders. BuildrequireTenantIdas inCustomModulesController, with@Get()list and@Post()create. Put a German ROUTE-ORDER comment at the top: every static GET route (Task 3 addsemail-status) must stand above any:idroute.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) stripstenantId/userIdfrom the body; there is a route-order assertion like in the custom-modules spec.
-
Shared type: append
'reminder'at the end ofWIDGET_TYPESinpackages/shared/src/index.ts. It is a platform widget, so there is no entry inWIDGET_MODULE_SLUGS. -
Web data and notify helpers.
apps/web/src/lib/reminders-api.ts: the typeReminder,ReminderRequestError,REMINDERS_CHANGED_EVENT,listRemindersandcreateReminder, as specified in "Interfaces". Test filereminders-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 whenwindow.__TAURI_INTERNALS__has aninvokefunction. Narrow throughunknown; noanyand no non-null assertions, because of the Biome baseline.requestBrowserPermissionOnce(): does nothing inside Tauri, whenNotificationis missing, when the permission is not'default', or when the localStorage flagtessera.reminders.permissionAskedis already set. Otherwise it sets the flag and callsNotification.requestPermission()(D-04).browserPermissionState(): returns'desktop' | 'granted' | 'default' | 'denied' | 'unsupported'.showReminderNotification({ title, body, tag }): inside Tauri it calls invokeplugin:notification|notifywith{ options: { title, body } }and catches errors with a singleconsole.warn('[reminders] …'). Outside Tauri it createsnew Notification(title, { body, tag })only when the permission is'granted', inside try/catch.claimNotification(key, nowMs): a localStorage recordtessera.reminders.notifiedmapping key to ms. It returns true only on the first claim of a key and prunes entries older than 7 days.withNotifyLock(fn): runsfnundernavigator.locks.request('tessera-reminder-notify', …)when available, otherwise calls it directly.remindersToNotify(reminders, nowMs): returns the reminders withdueAt <= nowanddueAt > 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 untilrequestPermission, see E-01).
reminder-notify.test.tscovers: 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.
-
Global notifier
apps/web/src/components/reminders/reminder-notifier.tsx('use client', renders null). It is mounted inapps/web/src/components/layout/app-shell.tsxright 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 theREMINDERS_CHANGED_EVENT, onvisibilitychangeto visible, and on windowfocus. - 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
ReminderRequestErrorwith status 401 it stops polling until the next focus. Other errors are ignored silently until the next tick. reminder-notifier.test.tsxuses 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; afterdueAtchanges it notifies again; the change event triggers a refetch.
- It loads
-
Widget, minimal:
apps/web/src/components/dashboard/widgets/reminder-widget.tsx(WidgetProps) lists the user's reminders (title, due time viaIntl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }), description clamped to 2 lines) and shows an empty state. A button "Neue Erinnerung" opensreminder-form-modal.tsx.- The modal is rendered with
createPortalintodocument.body, because react-grid-layout transforms would breakposition: 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 intoreminder-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), thencreateReminder, then refetch and dispatchREMINDERS_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 aReminderIcon(bell) and the registry entrynameKey: 'reminder.name'/descriptionKey: 'reminder.description'.apps/web/src/components/dashboard/widgets/widget-icon.tsx: bell path underreminder.apps/web/src/components/dashboard/widgets/widget-wrapper.tsx: add'reminder'toFRAME_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.reminderinapps/web/src/messages/de.jsonanden.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 callscreateReminderwith the ISO built from the local inputs; rendering does NOT callNotification.requestPermission; the first create calls it once; a second create does not call it again.
-
Desktop (E-01) in
apps/desktop/src-tauri/src/lib.rs. Facts measured during planning, which the code must respect: in tauri 2.11.3add_capabilityrunsResolved::resolve(..).unwrap()while it holds the runtime-authority mutex (src/ipc/authority.rs,src/lib.rs), and tauri-utils 2.9.3resolve_commandpanics 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 asErr; it crashessetup(), and acatch_unwindaround 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 usecatch_unwind.- Add
fn server_origin_pattern(url: &str) -> Option<String>:- Reuse
parse_server_url(http and https only) and takehost_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 acceptshttp://*.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) rejectshttp://[::1]:8080withTokenizer(InvalidName, 1), while the escaped formhttp://\[\:\:1\]:8080parses and matches only[::1]:8080. Both were measured. - Append
:portonly 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(itsFromStris the parser Tauri uses forremote.urls) and require.test(&parsed_url)to be true. ReturnNoneotherwise.
- Reuse
- Add
const SERVER_NOTIFICATION_PERMISSIONS: [&str; 3]with exactlynotification:allow-notify,notification:allow-is-permission-grantedandnotification: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 insideadd_capabilityas well, which is one reason the exact-set test below exists. - Add
fn grant_server_notifications(app: &AppHandle, url: &str):- When
server_origin_patternreturnsNone, write oneeprintln!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, callapp.add_capability(...), and onErrwrite oneeprintln!. It must never fail startup.
- When
- Call it in
setup()once the stored server URL is known, before the firstnavigate, and insave_server_urlright after the store is saved, beforenavigate. - 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 insideadd_capability, see above); only notification permissions, no app commands; T-JN2-01 remains in force forget_server_urland 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 withserver_origin_followed by a German description, like the existingserver_host_*tests, so that the verify command can count them. There are at least 10:https://alpha.tessera.ctl.de/dashboard?x=1#hgives exactlyhttps://alpha.tessera.ctl.de(path, query and fragment removed).http://192.168.13.12:8080/gives exactlyhttp://192.168.13.12:8080.https://alpha.tessera.ctl.de:443/gives exactlyhttps://alpha.tessera.ctl.de(the default port is omitted).- With and without a trailing slash, the result is the same.
ftp://…givesNone, and unparsable input givesNone.- IPv6:
http://[::1]:8080/gives exactly the raw stringr"http://\[\:\:1\]:8080". - Wildcard host:
http://*.example.com/gives exactlyr"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 subdomainx.alpha.tessera.ctl.de, or a different host. The wildcard pattern does not matchhttp://a.example.com/. The IPv6 pattern matcheshttp://[::1]:8080/dashboardbut nothttp://[::2]:8080/and nothttp://[::1]/. - Exact permission set:
assert_eq!ofSERVER_NOTIFICATION_PERMISSIONSagainst the three identifiers above, in that order. This pins the set (T-IF2-03), so an addednotification:defaultor a wildcard permission fails the test.
- Run
cargo fmt.
- Add
-
RLS docs (the inventory spec enforces this now): add rows to the "Bestandsaufnahme" table of
docs/mandantentrennung-zugriffsklassifikation.mdforapps/api/src/reminders/reminders.service.ts/reminder(classmuss-mandantengebunden, standgebunden), with a reason that names quick-260929-if2, the user dimension and 404. Add an area rowremindersin "Ü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. -
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, thenmigrate diff --exit-codemust be empty. - Run
docker compose up -d --build web api. If the disk fills up, rundocker 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. 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 <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> 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 <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> 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 <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> 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 <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> 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" <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>
- Read the DB container IP with
- The migration is applied locally and
migrate diffis 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 10server_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.
-
API in
apps/api/src/reminders/:UpdateReminderDto = PartialType(CreateReminderDto)andSnoozeReminderDto { dueAt: IsISO8601 strict }indto/reminder.dto.ts.- In the service, a private
loadOwn(tenantPrisma, tenantId, userId, id)returns the row or throwsNotFoundExceptionfor unknown, foreign-tenant and foreign-user rows alike (D-05, never 403). update: 409ConflictExceptionwhenrow.dueAt <= now("Die Erinnerung ist bereits fällig"). A newdueAtpasses the same future/5-year check ascreate, via a shared privateassertValidDueAt.snooze: 409 whenrow.dueAt > now(not due yet); validatesdueAt; 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 thewhereclauses carrytenantIdanduserId. - 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.
-
Web helpers:
apps/web/src/lib/reminder-time.tsholds pure functions:snoozeTarget(preset: '10m' | '1h' | 'tomorrow', originalDueAt: Date, now: Date): Dateper 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.tshas the cases from<behavior>, including one across the end of a month.- Extend
apps/web/src/lib/reminders-api.tswithupdateReminder,snoozeReminderanddeleteReminder, plus tests.
-
Widget
apps/web/src/components/dashboard/widgets/reminder-widget.tsx:nowstate refreshed every 10 s decides due vs. upcoming. The sort staysdueAtascending.- 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" (callsdeleteReminder) 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 callssnoozeReminder(id, snoozeTarget(...).toISOString()). - Upcoming rows: edit (pencil) opens
reminder-form-modal.tsxprefilled throughisoToLocalInputsand saves withupdateReminder. 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.
-
New texts under
widgets.reminderin de/en: due, done, snooze, snooze10m, snooze1h, snoozeTomorrow (with{time}), edit, delete, deleteConfirm, yes, no, alreadyDue, notDue, permissionDenied. Runumlaut-guard.spec.ts. Extendapps/web/src/messages/umlaut-dictionary.tsonly if the guard flags a correct German token. -
Re-measure the
remindersarea row and the "Summe" row indocs/mandantentrennung-zugriffsklassifikation.mdwith the gate loop, since the service has new bound raw hits. -
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. Commitfeat(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 <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> 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 <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>
- 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.