Skip to content

(modeling-commons): Durable Notifications + In-App Notifications - #48

Draft
omargfh wants to merge 20 commits into
staging/modeling-commonsfrom
feature/modeling-commons/durable-notifications
Draft

(modeling-commons): Durable Notifications + In-App Notifications#48
omargfh wants to merge 20 commits into
staging/modeling-commonsfrom
feature/modeling-commons/durable-notifications

Conversation

@omargfh

@omargfh omargfh commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

This PR lands a few key features:

  1. EventDispatcher handles events by calling into listeners
  2. UserNotificationService listens to events and delegates them to module-level Notifier
  3. Notifier returns a list of NotificationIntent when called
  4. NotificationIntent contains instructions on recipient and content of notifications
  5. UserNotificationService delivers email/in-app notifications according to user preferences
  6. UserNotificationService exposes user preferences and notifications to the frontend

omargfh added 20 commits July 27, 2026 14:53
- Add UserNotification keyed by (eventId, recipientId, category) for delivery idempotency
- Add UserNotificationPreference as sparse per-category overrides
- Add attempts and lastError to Event for poison-event handling
- Add markFailed to the event repository, incrementing attempts and storing the message
- Let findUnprocessed take an attempts ceiling so poison events stop consuming batch slots
- Carry attempts and lastError through the event record, domain type and mapper
- Add the delivery ledger repository, skipping inserts that hit the idempotency key
- Add the notification preference repository reading a user's overrides in one query
- Register both on the Dependencies interface; nothing consumes them yet
- Switch the two send sites to sendMailAsync so SMTP rejections reach the
  surrounding Promise.allSettled instead of awaiting a void return
- Retarget the unit mock and the e2e mail spy at the same method
- Add a category catalog with per-category defaults and a sparse-override merge
- Expose GET/PATCH /v1/me/notification-preferences, both session-scoped
- Reject a category absent from the catalog with 400 before any write
- Resolve intents with allSettled so one failing notifier no longer discards
  the intents another already produced
- Deliver the surviving intents, then rethrow so the event is retried
Move truncatePreview/buildEmailModel/recipient-resolution out of
model-comment.service.ts into modelCommentNotifier, which returns
NotificationIntent[] for the durable pipeline instead of sending mail
inline. model-comment.service.ts is now purely transactional; the two
e2e mail scenarios move to user-notification.feature and drain via the
event processor queue, plus a new opt-out scenario.
- Write the row whenever either channel is on, so the unique key still guards
  against a retried event resending the email
- Previously a category defaulting to email-only had no idempotency key at all
- Add the design doc and the five per-PR task files the stack was built from
…on domain

user-notification.service.ts's deliver() inlined recipient eligibility,
channel-suppression, and dedupe checks as silent early returns. Move
each into userNotificationDomain as an explicit assertion backed by a
named error (RecipientNotFoundError, RecipientDeletedError,
RecipientBannedError, RecipientEmailNotFoundError,
NotificationSuppressedError, RecipientEmailDisabledError,
NotificationAlreadyDeliveredError), and combine the dedupe/email-channel
checks into one assertEmailDeliverable call. The service now classifies
caught errors via isSkippableDeliveryError instead of relying on control
flow to mean "skip this recipient".
…cation domain

- Expose the category catalog as the NOTIFICATION_CATEGORIES tuple behind
  NotificationCategory, so DTOs can derive the literal union from it
- Add inAppEnabledCategories: the ledger row is written whenever any channel
  is on, so the feed has to re-derive which categories the user wants to see
- Add assertOwnedByRecipient and NotificationNotFoundError, which 404s rather
  than 403s so a mark-read call cannot confirm an id exists
- findAllByRecipient takes a NotificationFeedFilters (categories, since,
  unreadOnly) and orders by params.orderBy, keeping Prisma types out of the port
- Add findOneById and countUnread for the mark-read guard and the unread badge
- Rename markNotificationRead to markRead
- listUserNotificationsQuery resolves the caller's preferences into the set of
  in-app categories and filters the ledger by it, so a category muted in-app is
  still emailed but never reaches the feed
- Muting every category short-circuits to an empty page with no ledger read
- unreadCount rides in the paginated response and ignores since/unreadOnly/paging,
  so polling the badge is one request
- markRead is idempotent and rejects a notification the caller does not own
- The write stays outside transactionManager and the outbox, like markEmailSent:
  a read receipt carries no audit value and would feed the event processor its
  own noise
- GET /v1/me/notifications, paginated with since and unreadOnly filters
- PATCH /v1/me/notifications/:id/read, 204 on success and 404 on another
  user's notification
- A comment reaching the recipient's feed, mark-read clearing the unread count,
  and a cross-user mark-read returning 404
- A category muted in-app is still emailed but stays out of the feed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant