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 (Chat mentions today; tickets, billing, webhooks 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 200 lines of code across the runtime project — intentionally thin.
What ships in v10
- One row type —
Notification(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
NotificationCreatedSignalR event to the recipient’suser:{id}group immediately after writing the row. - Idempotent mark-read —
Notification.ReadAtUtc ??= DateTime.UtcNowmeans calling mark-read twice is a no-op. - Bulk mark-all-read via
ExecuteUpdateAsync— single SQLUPDATE, no row materialisation, scoped to the caller. - Four endpoints —
GET /(inbox),GET /unread-count(badge),POST /{id}/read(single),POST /read-all(bulk). - 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.
Architecture at a glance
src/Modules/Notifications/├── Modules.Notifications/ ~200 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 bridge│ └── Features/v1/ 4 endpoints└── Modules.Notifications.Contracts/ Commands, queries, DTOsThe 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:
public sealed class MentionedInChannelIntegrationEventHandler( INotificationsDbContext db, IHubContext<AppHub> hub, IUserService users) : IIntegrationEventHandler<MentionedInChannelIntegrationEvent>{ public async ValueTask HandleAsync(MentionedInChannelIntegrationEvent evt, CancellationToken ct) { var notification = Notification.Create( userId: evt.MentionedUserId, type: "chat.mention", title: $"@{evt.AuthorUsername} mentioned you", body: evt.MessagePreview, link: $"/chat/{evt.ChannelId}?message={evt.MessageId}", source: "Modules.Chat", metadataJson: JsonSerializer.Serialize(new { evt.ChannelId, evt.MessageId, evt.AuthorUserId }));
db.Notifications.Add(notification); await db.SaveChangesAsync(ct).ConfigureAwait(false);
await hub.Clients.Group($"user:{evt.MentionedUserId}").SendAsync( "NotificationCreated", NotificationDto.From(notification), ct).ConfigureAwait(false); }}The handler runs synchronously in the originating SendMessage request scope. 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 row type
public sealed class Notification : AggregateRoot{ public Guid UserId { get; private set; } 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; } = default!; public string Source { get; private set; } = default!; // e.g. "Modules.Chat" public string? MetadataJson { get; private set; } // opaque, source-defined shape public DateTime CreatedAtUtc { get; private set; } public DateTime? ReadAtUtc { get; private set; } // null = unread
public void MarkRead() => ReadAtUtc ??= DateTime.UtcNow; // idempotent}A covering composite index on (UserId, ReadAtUtc, CreatedAtUtc DESC) makes both the inbox list and the unread-count query single-pass.
Public API
| Type | Purpose |
|---|---|
ListNotificationsQuery(pageNum, 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 |
Endpoints
| Verb | Route | What it does |
|---|---|---|
| GET | /api/v1/notifications | Paginated inbox |
| GET | /api/v1/notifications/unread-count | Unread count for the badge |
| POST | /api/v1/notifications/{id}/read | Mark single notification read |
| POST | /api/v1/notifications/read-all | Mark every unread notification read |
How to extend
Add a new notification type from another module
- Define an integration event in your module’s
*.Contractsassembly:
public sealed record TicketAssignedIntegrationEvent( Guid TicketId, long TicketNumber, Guid AssigneeUserId, string AssignerUsername) : IIntegrationEvent{ public Guid Id => Guid.NewGuid(); public DateTime OccurredOnUtc => DateTime.UtcNow; public Guid? TenantId => /* current tenant */; public Guid? CorrelationId => null; public string Source => "Modules.Tickets";}-
Publish it from the tickets module’s
AssignTicketCommandHandlerviaIEventBus.PublishAsync. -
Add an integration-event handler in the Notifications module:
public sealed class TicketAssignedNotificationHandler(/* deps */) : IIntegrationEventHandler<TicketAssignedIntegrationEvent>{ public async ValueTask HandleAsync(TicketAssignedIntegrationEvent evt, CancellationToken ct) { var notification = Notification.Create( userId: evt.AssigneeUserId, type: "ticket.assigned", title: $"Ticket #{evt.TicketNumber} assigned to you", link: $"/tickets/{evt.TicketId}", source: "Modules.Tickets", metadataJson: JsonSerializer.Serialize(new { evt.TicketId, evt.TicketNumber })); // ... write + push as in the mention handler }}- 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
The module has no dedicated tests — its handlers are exercised by Chat’s integration tests (src/Tests/Integration.Tests/Tests/Chat/) which assert that a mention produces an inbox row and a SignalR push.
Related
- Chat module — the first event producer;
MentionedInChannelIntegrationEventis the canonical example. - Eventing.Abstractions — the
IEventBus/IIntegrationEventHandlercontract used here. - Architecture overview — module order and event-bus wiring.