Notifications
Notifications is a domain, Blouppy.Notifications, which listens for integration
events and turns some of them into news for a person. The Starter ships one
notification type, the invitation to an organization. Adding a type follows the
same pattern.
What it does
For the person using the app
- A bell in the shell shows an unread count that updates live. The inbox at
/notificationslists, filters, marks as read or unread and deletes notifications. - My account → Notifications (
/account/notification-settings) holds a matrix of type × channel (InApp,Email) and an optional quiet hours window, such as 22:00 to 07:00. Quiet hours are read in the person's own time zone. - Each notification is written in the reader's language when it is displayed, not when it is created.
For the developer
- A notification is not a sentence. It is a
NotificationType, a fewParametersand aLink, and the client builds the text from a translation key. - Each handler ends with one call,
INotificationDispatcher.DispatchAsync, and the dispatcher applies the preferences. A new type gets preference handling with no extra code. - Notifications older than
Notifications:RetentionInDaysare purged daily.
How it works
Projects
| Project | Holds |
|---|---|
Blouppy.Notifications.Models | Notification, NotificationType, NotificationChannel, NotificationParameterNames, and Preferences/ (NotificationPreferences, NotificationChannelPreference, QuietHours) |
Blouppy.Notifications.Abstractions | INotificationsService (the reader's own actions), INotificationPreferencesService, INotificationRetentionService (for the scheduler) |
Blouppy.Notifications | Dispatch/ (INotificationDispatcher, NotificationDispatcher), IntegrationEventHandlers/, Emails/NotificationEmailFactory, the services, the validator |
There is no .PublicData, because no other domain reads notifications. The read side
(the inbox, its unread count, the preferences) goes through
Blouppy.ApiQuery, as described in Reads and writes.
The rows are stored in BlouppyDbContext, in the tables notifications,
notification_preferences and notification_channel_preferences. Types and channels
are stored by name, not by number.
The flow
A NotificationRequest names the recipients, the type, the parameters and the link.
Its AllowedChannels property is the set of channels this type may use at all. It
defaults to both. For each recipient, NotificationDispatcher applies three rules:
- The matrix.
NotificationPreferences.Allows(type, channel)decides. A person with no row getsNotificationPreferences.DefaultFor, where every cell is allowed. A pair with no stored row is also allowed, so a type added later reaches people who saved their preferences before it existed. - Quiet hours hold back e-mail only. An in-app row interrupts nobody, so it is
always written. The window comes from
QuietHours. A window whose end is before its start wraps around midnight. It is read in the time zone thatIUsersPublicDataService.GetTimeZoneIdAsyncreturns, which isUTCuntil the person picks one. A zone the process cannot resolve also falls back to UTC. - A failed e-mail does not undo the in-app notification. The exception is logged rather than rethrown, so the broker does not redeliver the event and write the row a second time.
The recipients are de-duplicated before the dispatch. The live frame is a signal only, and the client re-reads its list when it arrives. See Real time.
await serverEventPublisher.PublishAsync(
ServerEventTopic.ForUser(notification.RecipientId),
ServerEvent.From(ServerEventName, new { type = request.Type.ToString() }),
cancellationToken);
The shipped type
NotifyWhenOrganizationInvitationCreatedIntegrationEventHandler handles
OrganizationInvitationCreatedIntegrationEvent. It notifies the invitee only if the
invitee already has an account, with actorName and organizationName as parameters
and NotificationRoutes.Organization(id) as the link. It restricts
AllowedChannels to InApp. The invitation e-mail itself is sent by the
Organizations domain to an address that may have no account yet, and a reader cannot
switch it off.
E-mails are built by NotificationEmailFactory.Create, one switch arm per type.
They are in English and every parameter is HTML-encoded. The link is made absolute
with ApplicationUrl.
API
| Endpoint | Does |
|---|---|
GET /api/v1/notifications | One page (Page, PageSize = 20, Types, IsRead, CreatedFrom) |
GET /api/v1/notifications/unread-count | The bell's count |
PUT / DELETE /api/v1/notifications/{id}/read | Mark as read / unread |
PUT /api/v1/notifications/read | Mark all as read |
DELETE /api/v1/notifications/{id}, DELETE /api/v1/notifications | Delete one / all |
GET / PUT /api/v1/notifications/preferences | The matrix and quiet hours |
Each route acts for the current user. INotificationsService takes the owner from
ICurrentUserService, so no route can address another person's notifications. The
MCP server offers the same through two tools,
list_my_notifications and mark_notifications_read. On an account erasure, the
person's notifications and preferences are deleted (see Privacy).
Configuration
| Setting | Default | Effect |
|---|---|---|
Notifications:RetentionInDays | 90 | Age past which a notification is deleted, read or not. Must be at least 1, checked at startup |
NotificationPurgeJob (Blouppy.Infrastructure.Scheduling/Jobs/) runs once a day
(Interval = TimeSpan.FromDays(1)) and calls
INotificationRetentionService.PurgeExpiredAsync. That method deletes in batches of
1,000 until a batch comes back short. Like every recurring job, it runs on Quartz.NET
with a clustered store, one run at a time across replicas.
E-mail goes through IEmailSender, which is MailPit in development and your SMTP
server in production (see Deployment). The in-app channel needs no
configuration.
Extending it
Adding a notification type is the pattern to copy. Say a member should be told when they are removed from an organization:
- Add a member to
NotificationType(Blouppy.Notifications.Models/NotificationType.cs). It is persisted by name, so a member is renamed only with a migration and a new translation key. - Add any new parameter name to
NotificationParameterNames. The handler, the e-mail and the client all read the names from there. - Write a handler in
Blouppy.Notifications/IntegrationEventHandlers/that implementsIIntegrationEventHandler<TEvent>, resolves its recipients and display values through the peers'.PublicDataservices, and ends withnotificationDispatcher.DispatchAsync(new NotificationRequest(...)).AddNotificationsDomainscans the assembly for handlers, so there is nothing to register. - If the type may use e-mail (the default
AllowedChannels), add its arm toNotificationEmailFactory.Create. TheswitchthrowsArgumentOutOfRangeExceptionfor a type it does not know. If the type is in-app only, setAllowedChannels = [NotificationChannel.InApp]. - If the integration event is new, or not yet consumed by the API, add its contract, consumer and publisher to the messaging adapter, as described in Events and messaging.
- In the Angular app, add the type to
NOTIFICATION_TYPESandNOTIFICATION_TYPE_ICONS(core/models/notifications/notification-type.model.ts), and addnotifications.typeNames.<Type>,notifications.typeHints.<Type>andnotifications.types.<Type>to bothassets/i18n/en.jsonandfr.json. The settings matrix is built from that list.
No schema migration is needed. The preference matrix stores one row per pair rather than one column per pair, and a missing pair is allowed.
The new type's preferences need no code. The dispatcher applies the matrix and quiet
hours to every NotificationRequest.
Limits
- One type. Only the invitation to an organization is sent.
- E-mails are in English. Each e-mail is written by a background consumer with no browser language to ask. Translating them would need a server-side catalogue keyed on a stored language, and that is not built.
- No digest and no deferred delivery. An e-mail held back by quiet hours is not sent later. The in-app row is the only record of it.
- The matrix does not know
AllowedChannels. The settings page draws anEmailcell for every type, including the invitation, whose notification is in-app only. - Two channels.
NotificationChannelhasInAppandEmail, and the dispatcher handles each of them explicitly.