feat(messaging): message reminders - remind me about this (LC-63) #163

Merged
nrupard merged 2 commits from feat/lc-63-reminders into main 2026-05-20 18:43:23 +02:00
Owner

Summary

"Remind me about this message" (LC-63). Hover a message -> Remind -> pick a time; at that time the server pings the owner (Web Push + on-screen toast) with a deep link back to the message. Mirrors the LC-62 scheduled-message dispatcher.

Changes

  • Schema (0037_reminders.sql): reminders(id, user_id, message_id REFERENCES messages ON DELETE CASCADE, remind_at, fired_at, created_at). Pending while fired_at IS NULL; FK cascade + soft-delete guard => a deleted message's reminder never fires. State in SQLite => survives restart.
  • Dispatcher (spawn_reminders_dispatcher, 30s tick): selects due rows, claims each atomically (UPDATE fired_at WHERE fired_at IS NULL = at-most-once), broadcasts a Reminder WS event + fires Web Push. Deep link is /room/{id}#msg-{mid} (or /dm/{peer}#msg-{mid} for DMs).
  • Notification: new ChatEvent::Reminder reuses the notify pipeline but does not bump unread-mention counts and ignores room mute (explicit reminder). push::dispatch + payload::build handle it; routes/ws.rs renders ReminderFragment into #lc-notify-bus; the notify-bus client gets a reminder branch.
  • Routes: GET /reminders (list pending + recently-fired), GET /reminders/picker?message_id= (hover-menu popover), POST /reminders (preset 15m/1h/3h/tomorrow or custom datetime, room-access checked), DELETE /reminders/{id} (owner-scoped cancel). Picker swaps into a singleton #lc-reminder-modal; presets post via HTMX, custom converts to UTC client-side.
  • Privacy: every query scoped to the caller; a reminder can only target a message in a room the caller can see.
  • Account deletion also drops the user's reminders.

Acceptance criteria

  • "Remind me" inserts a row + inline confirmation.
  • Fires at remind_at through enabled channels (Web Push + on-screen banner).
  • Notification deep-links to the message.
  • Deleted message => reminder does not fire (cascade + soft-delete guard).
  • List + cancel pending reminders.
  • Survives restart (SQLite).

Open questions from the issue (left as-is for v1): reminders for rooms the user has left still fire (the link resolves; access is re-checked on navigation); snooze-from-notification not implemented.

Tests

routes_reminders.rs: create+confirm, list, owner-scoped cancel, picker access gating, dispatcher fires-due / skips-future / no-double-fire, soft-deleted-message skip. Migration 0037 appended to hand-rolled lists. just check, just test, just test-saas green.

🤖 Generated with Claude Code

## Summary "Remind me about this message" (LC-63). Hover a message -> **Remind** -> pick a time; at that time the server pings the owner (Web Push + on-screen toast) with a deep link back to the message. Mirrors the LC-62 scheduled-message dispatcher. ## Changes - **Schema** (`0037_reminders.sql`): `reminders(id, user_id, message_id REFERENCES messages ON DELETE CASCADE, remind_at, fired_at, created_at)`. Pending while `fired_at IS NULL`; FK cascade + soft-delete guard => a deleted message's reminder never fires. State in SQLite => survives restart. - **Dispatcher** (`spawn_reminders_dispatcher`, 30s tick): selects due rows, claims each atomically (`UPDATE fired_at WHERE fired_at IS NULL` = at-most-once), broadcasts a `Reminder` WS event + fires Web Push. Deep link is `/room/{id}#msg-{mid}` (or `/dm/{peer}#msg-{mid}` for DMs). - **Notification**: new `ChatEvent::Reminder` reuses the notify pipeline but does **not** bump unread-mention counts and **ignores room mute** (explicit reminder). `push::dispatch` + `payload::build` handle it; `routes/ws.rs` renders `ReminderFragment` into `#lc-notify-bus`; the notify-bus client gets a `reminder` branch. - **Routes**: `GET /reminders` (list pending + recently-fired), `GET /reminders/picker?message_id=` (hover-menu popover), `POST /reminders` (preset `15m/1h/3h/tomorrow` or custom datetime, room-access checked), `DELETE /reminders/{id}` (owner-scoped cancel). Picker swaps into a singleton `#lc-reminder-modal`; presets post via HTMX, custom converts to UTC client-side. - **Privacy**: every query scoped to the caller; a reminder can only target a message in a room the caller can see. - **Account deletion** also drops the user's reminders. ## Acceptance criteria - [x] "Remind me" inserts a row + inline confirmation. - [x] Fires at `remind_at` through enabled channels (Web Push + on-screen banner). - [x] Notification deep-links to the message. - [x] Deleted message => reminder does not fire (cascade + soft-delete guard). - [x] List + cancel pending reminders. - [x] Survives restart (SQLite). Open questions from the issue (left as-is for v1): reminders for rooms the user has left still fire (the link resolves; access is re-checked on navigation); snooze-from-notification not implemented. ## Tests `routes_reminders.rs`: create+confirm, list, owner-scoped cancel, picker access gating, dispatcher fires-due / skips-future / no-double-fire, soft-deleted-message skip. Migration `0037` appended to hand-rolled lists. `just check`, `just test`, `just test-saas` green. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(messaging): "remind me about this message" reminders (LC-63)
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 1m20s
53ab287ced
Right-click/hover a message -> "Remind" -> pick a time; at that time the server pings the owner (Web Push + on-screen toast) with a deep link back to the message. Mirrors the scheduled-message dispatcher.

Schema: migration 0037 adds reminders(id, user_id, message_id REFERENCES messages ON DELETE CASCADE, remind_at, fired_at, created_at). A row is pending while fired_at IS NULL; the FK cascade plus a soft-delete guard mean a deleted message's reminder never fires. State lives in SQLite, so reminders survive a restart.

Dispatcher: spawn_reminders_dispatcher in main.rs polls every 30s. It selects due, not-yet-fired reminders (joined to message + room), claims each atomically (UPDATE fired_at WHERE fired_at IS NULL -> at-most-once), then broadcasts a Reminder event over the WS hub and fires Web Push. The deep link resolves to /room/{id}#msg-{mid}, or /dm/{peer}#msg-{mid} for DM rooms.

Notification: new ChatEvent::Reminder reuses the existing notify pipeline. It does NOT bump the client's unread-mention counts (it is not a new message) and it ignores room mute (the user explicitly asked to be pinged). push::dispatch and payload::build now handle Reminder alongside Mentioned; routes/ws.rs renders a ReminderFragment into #lc-notify-bus; the layout notify-bus client gets a dedicated "reminder" branch that fires a banner without touching counts.

Routes: GET /reminders (pending + recently-fired list), GET /reminders/picker?message_id= (the hover-menu popover), POST /reminders (preset 15m/1h/3h/tomorrow or a custom datetime, room-access checked), DELETE /reminders/{id} (owner-scoped cancel). The picker swaps into a singleton #lc-reminder-modal slot in the shell; presets post via HTMX, custom time converts to UTC client-side. Reminders are private: every query is scoped to the caller, and a reminder can only be set on a message in a room the caller can see.

Account deletion now also drops the user's reminders (those on other people's messages; reminders on their own messages already cascade).

Tests: routes_reminders.rs covers create+confirm, list, owner-scoped cancel, picker access gating, dispatcher fires-due/skips-future/no-double-fire, and the soft-deleted-message skip. Migration 0037 appended to the hand-rolled migration lists. just check, just test, just test-saas all green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
fix(reminders): re-check room access at fire time (LC-63)
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 1m18s
8dcae8855f
The dispatcher read the message snippet fresh and pushed it to the owner even if they had lost access to the room since setting the reminder, leaking current content to someone no longer allowed to see it. Re-check is_room_accessible at fire time (mirrors the scheduled-message re-validate-at-delivery pattern) and suppress the notification when access is gone. The row stays claimed so it is not re-checked every tick. Site-admin god-mode is preserved by resolving the owner's role for the check.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
nrupard deleted branch feat/lc-63-reminders 2026-05-20 18:43:23 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!163
No description provided.