feat(notifications): dispatcher worker, prefs, encryption (PMS-92) #61
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/pms-92-notifications-dispatcher"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 iswith_encryption_key(db, key).src/api/router.rs:100now wires the sameencryption_keyit already passes toBillingService, sonotification_channels.config_encryptedstops being encrypted under[0u8; 32].dispatch()readsuser_notification_preferencesper(user_id, event_type, channel_type)and skips suppressed fan-outs (absent prefs = send).notifications.statusis bound explicitly on insert as'pending', not via the column default.context.recipient_user_id/recipient_emailare 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.rsintroducesDispatcherWorker(drain viaSELECT ... FOR UPDATE SKIP LOCKED, per-channel transport, 1m/5m/30m/2h/6h backoff, five-retry cap, fail-loud for unwired channels). Spawned fromsrc/main.rsat startup with a 5s tick and batch size 25.AuthService::with_dispatcherandTicketService::with_dispatcherthreadNotificationsServiceto the four legacy callers (auth/service.rspassword_reset + welcome,tickets/service.rs:649ticket-note email,tickets/automation.rs:253-284send_notificationaction). The PMS-85 TODO inautomation.rsis removed.005_notification_dispatcher_backoff.sqladdsattempt_count+next_attempt_at(plus a partial index on pending+due rows).006_notification_dispatcher_defaults.sqlseeds default templates + rules for the four transactionalevent_types so a fresh tenant dispatches out of the box.tests/notifications.rscovers preference enforcement (opt out of email, opt into in_app -> exactly one row inserted), one worker tick flipping the row tosent/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 inconfig_encrypted)..forgejo/workflows/check.ymlrunscargo test --test notificationsalongside the existing module suites.Test plan
--test notifications.tokio::time::sleepflakiness):cargo test --test notificationslocally against a real Postgres.just dev) and confirm the dispatcher worker logsnotifications dispatcher worker startedat startup./api/v1/notifications/dispatchwith a seeded rule + arecipient_emailcontext entry; check thatnotificationsrow appears, then status flips tosentwithin ~5s.auth.password_reset(POST/api/v1/auth/forgot-password) and confirm a row lands innotificationsforevent_type='auth.password_reset'; withLogMailerit should flip tosent, with real SMTP it should send.#PMS-92
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