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 /notifications lists, 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 few Parameters and a Link, 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:RetentionInDays are purged daily.

How it works

Projects

ProjectHolds
Blouppy.Notifications.ModelsNotification, NotificationType, NotificationChannel, NotificationParameterNames, and Preferences/ (NotificationPreferences, NotificationChannelPreference, QuietHours)
Blouppy.Notifications.AbstractionsINotificationsService (the reader's own actions), INotificationPreferencesService, INotificationRetentionService (for the scheduler)
Blouppy.NotificationsDispatch/ (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

IEmailSenderIServerEventPublisherBlouppyDbContextNotificationDispatcherNotifyWhen…IntegrationEventHandlerRabbitMQIEmailSenderIServerEventPublisherBlouppyDbContextNotificationDispatcherNotifyWhen…IntegrationEventHandlerRabbitMQOrganizationInvitationCreatedDispatchAsync(NotificationRequest)per recipient: matrix, then quiet hours (e-mail only)AddRangeAsync(notifications)notifications.changed on the user's topicSendAsync (errors logged, not rethrown)
IEmailSenderIServerEventPublisherBlouppyDbContextNotificationDispatcherNotifyWhen…IntegrationEventHandlerRabbitMQIEmailSenderIServerEventPublisherBlouppyDbContextNotificationDispatcherNotifyWhen…IntegrationEventHandlerRabbitMQOrganizationInvitationCreatedDispatchAsync(NotificationRequest)per recipient: matrix, then quiet hours (e-mail only)AddRangeAsync(notifications)notifications.changed on the user's topicSendAsync (errors logged, not rethrown)

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:

  1. The matrix.NotificationPreferences.Allows(type, channel) decides. A person with no row gets NotificationPreferences.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.
  2. 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 that IUsersPublicDataService.GetTimeZoneIdAsync returns, which is UTC until the person picks one. A zone the process cannot resolve also falls back to UTC.
  3. 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

EndpointDoes
GET /api/v1/notificationsOne page (Page, PageSize = 20, Types, IsRead, CreatedFrom)
GET /api/v1/notifications/unread-countThe bell's count
PUT / DELETE /api/v1/notifications/{id}/readMark as read / unread
PUT /api/v1/notifications/readMark all as read
DELETE /api/v1/notifications/{id}, DELETE /api/v1/notificationsDelete one / all
GET / PUT /api/v1/notifications/preferencesThe 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

SettingDefaultEffect
Notifications:RetentionInDays90Age 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:

  1. 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.
  2. Add any new parameter name to NotificationParameterNames. The handler, the e-mail and the client all read the names from there.
  3. Write a handler in Blouppy.Notifications/IntegrationEventHandlers/ that implements IIntegrationEventHandler<TEvent>, resolves its recipients and display values through the peers' .PublicData services, and ends with notificationDispatcher.DispatchAsync(new NotificationRequest(...)). AddNotificationsDomain scans the assembly for handlers, so there is nothing to register.
  4. If the type may use e-mail (the default AllowedChannels), add its arm to NotificationEmailFactory.Create. The switch throws ArgumentOutOfRangeException for a type it does not know. If the type is in-app only, set AllowedChannels = [NotificationChannel.InApp].
  5. 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.
  6. In the Angular app, add the type to NOTIFICATION_TYPES and NOTIFICATION_TYPE_ICONS (core/models/notifications/notification-type.model.ts), and add notifications.typeNames.<Type>, notifications.typeHints.<Type> and notifications.types.<Type> to both assets/i18n/en.json and fr.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 an Email cell for every type, including the invitation, whose notification is in-app only.
  • Two channels.NotificationChannel has InApp and Email, and the dispatcher handles each of them explicitly.