Skip to content
fullstackhero

Reference

Notifications module

Per-user inbox driven by integration events from other modules — denormalized rows, real-time SignalR push, idempotent mark-read.

views 0 Last updated

The Notifications module is the inbox layer of fullstackhero. It owns one denormalized row type (Notification), exposes four read/mark endpoints, and is driven entirely by integration events published by other modules. Today it consumes Chat mentions (inbox row + realtime push) and the Billing lifecycle events (tenant-admin emails for invoices and expiry); tickets, webhooks, and anything else can opt in next. New notifications are pushed to the recipient in real time via the same SignalR hub Chat uses, so the bell-icon badge updates without a refresh. Around 800 lines of code across the runtime project — intentionally thin.

What ships in v10

  • One row typeNotification(Id, UserId, Type, Title, Body?, Link?, Source, MetadataJson, CreatedAtUtc, ReadAtUtc?). Denormalized so the inbox renders without back-references into the source module.
  • Real-time push — the integration-event handler broadcasts a NotificationCreated SignalR event to the recipient’s user:{id} group immediately after writing the row.
  • Idempotent mark-readNotification.ReadAtUtc ??= DateTime.UtcNow means calling mark-read twice is a no-op.
  • Bulk mark-all-read via ExecuteUpdateAsync — single SQL UPDATE, no row materialisation, scoped to the caller; returns the number of rows touched.
  • Four endpointsGET / (inbox, with an unreadOnly filter), GET /unread-count (badge), POST /{id}/read (single), POST /read-all (bulk).
  • Billing lifecycle emails — handlers for the Billing module’s InvoiceIssued, TenantNearingExpiry, TenantEnteredGrace, and TenantExpired integration events email the tenant admin via the Mailing building block’s IMailService.
  • Free-form Type + MetadataJson — source modules pick the strings (chat.mention, ticket.assigned, billing.invoice.issued) and the UI uses them to render the right icon and link.
  • 2 permissionsNotifications.Inbox.View and Notifications.Inbox.MarkRead, both granted to Basic users by default.

Architecture at a glance

src/Modules/Notifications/
├── Modules.Notifications/ ~800 LoC
│ ├── NotificationsModule.cs IModule — order 750 (BEFORE Chat 800)
│ ├── Domain/Notification.cs AggregateRoot, denormalized inbox row
│ ├── Data/NotificationsDbContext.cs Schema: notifications
│ ├── IntegrationEventHandlers/
│ │ ├── MentionedInChannelIntegrationEventHandler.cs Chat mention → inbox + push
│ │ ├── InvoiceIssuedEmailHandler.cs Billing → tenant-admin email
│ │ ├── TenantNearingExpiryEmailHandler.cs Billing → tenant-admin email
│ │ ├── TenantEnteredGraceEmailHandler.cs Billing → tenant-admin email
│ │ ├── TenantExpiredEmailHandler.cs Billing → tenant-admin email
│ │ └── BillingEmailSender.cs / BillingEmailBodies.cs Shared email plumbing
│ └── Features/v1/ 4 endpoints
└── Modules.Notifications.Contracts/ Commands, queries, DTOs, permissions

The handler bridge

The whole module is essentially a fan-out from integration events to inbox rows + SignalR pushes. The Chat-mention handler is the canonical example:

src/Modules/Notifications/Modules.Notifications/IntegrationEventHandlers/MentionedInChannelIntegrationEventHandler.cs
public sealed class MentionedInChannelIntegrationEventHandler(
NotificationsDbContext db,
IHubContext<AppHub> hub,
IMultiTenantContextAccessor<AppTenantInfo> tenantAccessor,
ILogger<MentionedInChannelIntegrationEventHandler> logger)
: IIntegrationEventHandler<MentionedInChannelIntegrationEvent>
{
public async Task HandleAsync(MentionedInChannelIntegrationEvent @event, CancellationToken ct = default)
{
// Fail loud on a tenant mismatch rather than write to the wrong tenant — the DbContext
// captures its tenant at construction.
var ambientTenantId = tenantAccessor.MultiTenantContext.TenantInfo?.Id;
if (!string.Equals(ambientTenantId, @event.TenantId, StringComparison.Ordinal))
throw new InvalidOperationException("Tenant context mismatch ...");
var notification = Notification.Create(
userId: @event.MentionedUserId,
type: "chat.mention",
title: string.IsNullOrEmpty(@event.ChannelName)
? "You were mentioned in a conversation"
: $"You were mentioned in #{@event.ChannelName}",
body: @event.BodyPreview,
link: $"/chat/{@event.ChannelId}?messageId={@event.MessageId}",
source: @event.Source,
metadata: new { channelId = @event.ChannelId, messageId = @event.MessageId, /* … */ });
db.Notifications.Add(notification);
await db.SaveChangesAsync(ct).ConfigureAwait(false);
await hub.Clients.Group($"user:{@event.MentionedUserId}")
.SendAsync("NotificationCreated", new { id = notification.Id, /* dto fields */ }, ct)
.ConfigureAwait(false);
}
}

The handler runs synchronously in the originating SendMessage request scope (in-memory bus, synchronous dispatch). If the write or push fails, the SendMessage call fails. That’s deliberate — fail-fast over silent loss of notifications. If you need it to be optional later, wrap with a try-catch and log. The tenant-mismatch guard exists because the DbContext captures its tenant at construction — a publisher that didn’t establish the Finbuckle tenant context would otherwise leak rows cross-tenant silently.

The row type

src/Modules/Notifications/Modules.Notifications/Domain/Notification.cs
public sealed class Notification : AggregateRoot<Guid>
{
public string UserId { get; private set; } = default!;
public string Type { get; private set; } = default!; // e.g. "chat.mention"
public string Title { get; private set; } = default!;
public string? Body { get; private set; }
public string? Link { get; private set; }
public string Source { get; private set; } = default!; // originating module, e.g. "Chat"
public string MetadataJson { get; private set; } = "{}"; // opaque, source-defined shape
public DateTime? ReadAtUtc { get; private set; } // null = unread
public DateTime CreatedAtUtc { get; private set; }
public void MarkRead() => ReadAtUtc ??= DateTime.UtcNow; // idempotent
}

A composite index on (UserId, ReadAtUtc, CreatedAtUtc) covers the inbox list (always WHERE UserId = ? ORDER BY CreatedAtUtc DESC, optionally unread-filtered) and the unread-count query. MetadataJson is stored as jsonb.

Public API

TypePurpose
ListNotificationsQuery(unreadOnly, page, pageSize)Caller-scoped paginated list, newest first
GetUnreadCountQuery()Integer for the bell badge
MarkNotificationReadCommand(notificationId)Idempotent; 404 on cross-user access
MarkAllNotificationsReadCommand()Bulk caller-scoped via ExecuteUpdateAsync; returns rows updated

Endpoints

VerbRoutePermissionWhat it does
GET/api/v1/notificationsInbox.ViewPaginated inbox (?unreadOnly=true to filter)
GET/api/v1/notifications/unread-countInbox.ViewUnread count for the badge
POST/api/v1/notifications/{id}/readInbox.MarkReadMark single notification read
POST/api/v1/notifications/read-allInbox.MarkReadMark every unread notification read

The billing email bridge

Not every notification is an inbox row. The same module also hosts the email side of tenant lifecycle notifications: four handlers subscribe to the Billing module’s integration events (InvoiceIssuedIntegrationEvent, TenantNearingExpiryIntegrationEvent, TenantEnteredGraceIntegrationEvent, TenantExpiredIntegrationEvent), resolve the tenant admin’s address from the tenant store, and send templated emails through the Mailing building block’s IMailService. Subjects and bodies live in BillingEmailBodies.cs; BillingEmailSender.cs wraps send + logging so a mail failure never breaks the originating billing operation. Same bridge pattern, different sink — keep both flavours here so source modules never know how a notification is delivered.

How to extend

Add a new notification type from another module

  1. Define an integration event in your module’s *.Contracts assembly. IIntegrationEvent is a flat contract — carry the envelope fields as record parameters:
Modules.Tickets.Contracts/Events/TicketAssignedIntegrationEvent.cs
public sealed record TicketAssignedIntegrationEvent(
Guid Id,
DateTime OccurredOnUtc,
string? TenantId,
string CorrelationId,
string Source,
Guid TicketId,
string TicketNumber,
string AssigneeUserId) : IIntegrationEvent;
  1. Publish it from the tickets module’s AssignTicketCommandHandler via IEventBus.PublishAsync.

  2. Add an integration-event handler in the Notifications module:

public sealed class TicketAssignedNotificationHandler(/* deps */)
: IIntegrationEventHandler<TicketAssignedIntegrationEvent>
{
public async Task HandleAsync(TicketAssignedIntegrationEvent evt, CancellationToken ct = default)
{
var notification = Notification.Create(
userId: evt.AssigneeUserId,
type: "ticket.assigned",
title: $"Ticket {evt.TicketNumber} assigned to you",
body: null,
link: $"/tickets/{evt.TicketId}",
source: evt.Source,
metadata: new { evt.TicketId, evt.TicketNumber });
// ... write + push as in the mention handler
}
}
  1. The handler is auto-registered by the eventing module’s assembly scan.

Render type-specific notifications on the frontend

type is a free-form string. In the dashboard’s notification list, dispatch on type to pick the right icon and link template:

const renderers = {
'chat.mention': (n) => <ChatMentionItem {...n} />,
'ticket.assigned': (n) => <TicketAssignedItem {...n} />,
// …
};

Suppress some notification types per user

Add a NotificationPreference aggregate (per-user mute list of Type strings) and check it in each integration event handler before writing the row. A self-service “Settings → Notifications” page can toggle preferences.

Tests

  • src/Tests/Integration.Tests/Tests/Notifications/NotificationsEndpointTests.cs — the four endpoints (list, unread count, mark-read, read-all).
  • Chat’s MentionAndNotificationTests.cs (src/Tests/Integration.Tests/Tests/Chat/) asserts the full bridge: a mention produces an inbox row and a SignalR push.