feat(notifications): dispatcher worker, prefs, encryption (PMS-92) #61

Merged
nrupard merged 4 commits from feat/pms-92-notifications-dispatcher into main 2026-06-04 16:58:59 +02:00
Owner

Summary

Closes the four PMS-85 blockers that the story-verification pass surfaced - dispatcher worker, preference enforcement, real encryption key, caller migration - in one PR so the worker ships with the integration it actually needs.

  • NotificationsService::new(db) is gone; the only constructor is with_encryption_key(db, key). src/api/router.rs:100 now wires the same encryption_key it already passes to BillingService, so notification_channels.config_encrypted stops being encrypted under [0u8; 32].
  • dispatch() reads user_notification_preferences per (user_id, event_type, channel_type) and skips suppressed fan-outs (absent prefs = send). notifications.status is bound explicitly on insert as 'pending', not via the column default. context.recipient_user_id / recipient_email are merged into the recipient list so transactional events do not need rule-baked recipients per tenant. Subject/body get {{key}} substitution from the context.
  • src/modules/notifications/worker.rs introduces DispatcherWorker (drain via SELECT ... FOR UPDATE SKIP LOCKED, per-channel transport, 1m/5m/30m/2h/6h backoff, five-retry cap, fail-loud for unwired channels). Spawned from src/main.rs at startup with a 5s tick and batch size 25.
  • AuthService::with_dispatcher and TicketService::with_dispatcher thread NotificationsService to the four legacy callers (auth/service.rs password_reset + welcome, tickets/service.rs:649 ticket-note email, tickets/automation.rs:253-284 send_notification action). The PMS-85 TODO in automation.rs is removed.
  • Migrations: 005_notification_dispatcher_backoff.sql adds attempt_count + next_attempt_at (plus a partial index on pending+due rows). 006_notification_dispatcher_defaults.sql seeds default templates + rules for the four transactional event_types so a fresh tenant dispatches out of the box.
  • tests/notifications.rs covers preference enforcement (opt out of email, opt into in_app -> exactly one row inserted), one worker tick flipping the row to sent/sent_at, the inbox list + mark-read endpoints, and a zero-key regression guard (POST a channel config containing a canary; assert it does NOT appear verbatim in config_encrypted).
  • CI: .forgejo/workflows/check.yml runs cargo test --test notifications alongside the existing module suites.

Test plan

  • CI green: fmt, clippy, compile, unit tests, integration tests including new --test notifications.
  • Verify the integration test runs the worker tick deterministically (no tokio::time::sleep flakiness): cargo test --test notifications locally against a real Postgres.
  • Boot the dev stack (just dev) and confirm the dispatcher worker logs notifications dispatcher worker started at startup.
  • POST to /api/v1/notifications/dispatch with a seeded rule + a recipient_email context entry; check that notifications row appears, then status flips to sent within ~5s.
  • Trigger an auth.password_reset (POST /api/v1/auth/forgot-password) and confirm a row lands in notifications for event_type='auth.password_reset'; with LogMailer it should flip to sent, with real SMTP it should send.
  • After merge, set PMS-92 to "Done" in YouTrack. PMS-85 closes once all six original AC items pass (the verification step from earlier).

#PMS-92

## Summary Closes the four PMS-85 blockers that the story-verification pass surfaced - dispatcher worker, preference enforcement, real encryption key, caller migration - in one PR so the worker ships with the integration it actually needs. - `NotificationsService::new(db)` is gone; the only constructor is `with_encryption_key(db, key)`. `src/api/router.rs:100` now wires the same `encryption_key` it already passes to `BillingService`, so `notification_channels.config_encrypted` stops being encrypted under `[0u8; 32]`. - `dispatch()` reads `user_notification_preferences` per `(user_id, event_type, channel_type)` and skips suppressed fan-outs (absent prefs = send). `notifications.status` is bound explicitly on insert as `'pending'`, not via the column default. `context.recipient_user_id` / `recipient_email` are merged into the recipient list so transactional events do not need rule-baked recipients per tenant. Subject/body get `{{key}}` substitution from the context. - `src/modules/notifications/worker.rs` introduces `DispatcherWorker` (drain via `SELECT ... FOR UPDATE SKIP LOCKED`, per-channel transport, 1m/5m/30m/2h/6h backoff, five-retry cap, fail-loud for unwired channels). Spawned from `src/main.rs` at startup with a 5s tick and batch size 25. - `AuthService::with_dispatcher` and `TicketService::with_dispatcher` thread `NotificationsService` to the four legacy callers (`auth/service.rs` password_reset + welcome, `tickets/service.rs:649` ticket-note email, `tickets/automation.rs:253-284` `send_notification` action). The PMS-85 TODO in `automation.rs` is removed. - Migrations: `005_notification_dispatcher_backoff.sql` adds `attempt_count` + `next_attempt_at` (plus a partial index on pending+due rows). `006_notification_dispatcher_defaults.sql` seeds default templates + rules for the four transactional `event_type`s so a fresh tenant dispatches out of the box. - `tests/notifications.rs` covers preference enforcement (opt out of email, opt into in_app -> exactly one row inserted), one worker tick flipping the row to `sent`/`sent_at`, the inbox list + mark-read endpoints, and a zero-key regression guard (POST a channel config containing a canary; assert it does NOT appear verbatim in `config_encrypted`). - CI: `.forgejo/workflows/check.yml` runs `cargo test --test notifications` alongside the existing module suites. ## Test plan - [ ] CI green: fmt, clippy, compile, unit tests, integration tests including new `--test notifications`. - [ ] Verify the integration test runs the worker tick deterministically (no `tokio::time::sleep` flakiness): `cargo test --test notifications` locally against a real Postgres. - [ ] Boot the dev stack (`just dev`) and confirm the dispatcher worker logs `notifications dispatcher worker started` at startup. - [ ] POST to `/api/v1/notifications/dispatch` with a seeded rule + a `recipient_email` context entry; check that `notifications` row appears, then status flips to `sent` within ~5s. - [ ] Trigger an `auth.password_reset` (POST `/api/v1/auth/forgot-password`) and confirm a row lands in `notifications` for `event_type='auth.password_reset'`; with `LogMailer` it should flip to `sent`, with real SMTP it should send. - [ ] After merge, set PMS-92 to "Done" in YouTrack. PMS-85 closes once all six original AC items pass (the verification step from earlier). #PMS-92
feat(notifications): dispatcher worker, preference enforcement, encryption key wire-up
Some checks failed
Check / fmt + clippy + compile + tests (pull_request) Failing after 12s
Build OCI container / Build and push mokosh-api image (push) Successful in 3m16s
d3717d48f3
Closes the four PMS-85 blockers that the story-verification pass surfaced, in a single PR so the worker ships with the integration it needs to drain real traffic.

- Drop NotificationsService::new(); only with_encryption_key(db, key) survives. Router was constructing the service with [0u8; 32], so notification_channels.config_encrypted was encrypted under a zero key (effectively plaintext). The new constructor is the only path, and the router now threads the same encryption_key it already passes to BillingService.
- dispatch() consults user_notification_preferences per (user_id, event_type, channel_type) and skips suppressed fan-outs. Absent preferences keep the project default (send). status='pending' is now bound explicitly on insert instead of relying on the column default.
- dispatch() honors caller-supplied context.recipient_user_id / recipient_email so transactional events (password reset, welcome, ticket note, automation notify) can fan out to a specific user without rewriting rule.recipients per tenant. Template subject/body get {{key}} substitution from the context.
- DispatcherWorker (src/modules/notifications/worker.rs) drains status='pending' rows via SELECT ... FOR UPDATE SKIP LOCKED, runs per-channel transport (in_app = DB flip, email = host Mailer, others = fail loud), and applies the 1m/5m/30m/2h/6h backoff with a five-retry cap. Spawned from main.rs at startup.
- AuthService::with_dispatcher and TicketService::with_dispatcher wire NotificationsService through to the four legacy callers: src/modules/auth/service.rs password_reset + welcome, src/modules/tickets/service.rs send_note_email, src/modules/tickets/automation.rs send_notification action. The PMS-85 TODO comment in automation.rs is removed.
- Migrations 005 (attempt_count + next_attempt_at columns on notifications) and 006 (default templates + rules for the four transactional event_types) ship with the change so a fresh tenant can dispatch out of the box.
- tests/notifications.rs covers the preference-enforcement path (opt out of email, opt into in_app -> exactly one row), the worker tick (status flips to 'sent', sent_at stamped), the inbox endpoints (list + mark-read), and a zero-key regression guard (POSTed plaintext must not appear verbatim in config_encrypted).

#PMS-92
style: apply rustfmt to PMS-92 notifications dispatcher work
Some checks failed
Check / fmt + clippy + compile + tests (pull_request) Failing after 22s
Build OCI container / Build and push mokosh-api image (push) Successful in 2m52s
76a54d7066
CI fmt run wrapped a handful of multi-line expressions back into single lines and resorted the auth/service.rs imports alphabetically. Functional behavior unchanged.

#PMS-92
style: blank line between bullet list and paragraph in dispatch doc
Some checks failed
Check / fmt + clippy + compile + tests (pull_request) Failing after 58s
Build OCI container / Build and push mokosh-api image (push) Successful in 7m16s
4f43203228
clippy::doc-lazy-continuation treats the post-bullet paragraph as a list continuation without indentation. Insert a blank /// line so it parses as its own paragraph.

#PMS-92
style(tests): factor notifications row tuple into NotifRow alias
All checks were successful
Check / fmt + clippy + compile + tests (pull_request) Successful in 55s
Create release / Create release from merged PR (pull_request) Has been skipped
09e5435900
clippy::type-complexity fires on the 4-element tuple inside Vec<...>. Lift it into a local type alias so the read site stays one-liner-readable while satisfying the lint.

#PMS-92
nrupard deleted branch feat/pms-92-notifications-dispatcher 2026-06-04 16:58:59 +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/mokosh-server!61
No description provided.