Notification System
Real-time notification delivery via SignalR with auto-join groups, unread count tracking, and paginated history.
Notification Architecture
The Notification System uses SignalR for real-time push delivery. When a backend service calls NotificationService, the notification is persisted to the database and simultaneously pushed to the user's browser via the NotificationHub.
Domain Event Trigger
Push count (UnreadCountUpdated)
NotificationService.SendAsync()
Persist notification
Save to UserNotifications Table
Encrypt Guid ID
AES-Encrypt Notification ID
Recalculate Unread Count
Send UnreadCountUpdated
SignalR NotificationHub
user_{userId} Group
Browser (ReceiveNotification)
NotificationHub
The NotificationHub is a strongly-typed SignalR hub that auto-joins users to their personal group (user_{userId}) on connection. It also pushes the initial unread count immediately.
NotificationHub.csC#
public sealed class NotificationHub : Hub<INotificationHubClient>
{
private readonly ILogger<NotificationHub> _logger;
private readonly INotificationService _notificationService;
public NotificationHub(
ILogger<NotificationHub> logger,
INotificationService notificationService)
{
_logger = logger;
_notificationService = notificationService;
}
public override async Task OnConnectedAsync()
{
var userId = GetUserId();
if (userId.HasValue)
{
// Auto-join user-specific group
await Groups.AddToGroupAsync(Context.ConnectionId, $"user_{userId.Value}");
_logger.LogDebug("NotificationHub: {ConnectionId} joined user group {UserId}", Context.ConnectionId, userId.Value);
// Push current unread count immediately
var count = await _notificationService.GetUnreadCountAsync(userId.Value);
await Clients.Caller.UnreadCountUpdated(count);
}
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var userId = GetUserId();
if (userId.HasValue)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"user_{userId.Value}");
_logger.LogDebug("NotificationHub: {ConnectionId} left user group {UserId}", Context.ConnectionId, userId.Value);
}
await base.OnDisconnectedAsync(exception);
}
private Guid? GetUserId()
{
var claim = Context.User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return Guid.TryParse(claim, out var id) ? id : null;
}
}Auto-Join Pattern
- Zero Client Group Management: Client apps do not subscribe to or request group additions; the hub maps them on authorization.
- Connection-Independent Grouping: Users can have multiple concurrent connections (browser tabs/sessions), and all receive notifications via the user_{userId} group.
- Claim Types Mapping: The hub extracts User IDs from Context.User using the full URI identifier: ClaimTypes.NameIdentifier.
- Unread State Hydration: Pushes initial unread count to Clients.Caller immediately upon connection initialization.
Hub Client Interface
INotificationHubClient.csC#
public interface INotificationHubClient
{
// Real-time notification payload delivery (ID is AES-encrypted)
Task ReceiveNotification(NotificationPushDto notification);
// Pushes unread count updates when status changes
Task UnreadCountUpdated(int count);
}NotificationService Methods
| Method / Operation | Scope Mapping | Push Mechanism |
|---|---|---|
| SendAsync(..., NotificationTarget.User, ...) | Resolves target User ID | Persists UserNotification entity, AES encrypts ID, pushes payload to user_{userId} and triggers UnreadCountUpdated |
| SendAsync(..., NotificationTarget.Role, ...) | Resolves User IDs by RoleId | Iterates through matching users, persisting and pushing notifications individually |
| SendAsync(..., NotificationTarget.Tenant, ...) | Resolves User IDs by TenantId | Iterates through matching users in the tenant, persisting and pushing notifications individually |
| SendAsync(..., NotificationTarget.Broadcast, ...) | Resolves all active User IDs | Broadcasts to all users globally by iterating through the entire system user list |
| GetUnreadCountAsync(userId, ...) | Queries unread notifications in Db | Called by the hub on connection to retrieve initial unread count |
Notification API Endpoints
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/v1/notifications | List user notifications (paginated, returning decrypted records) | 🔒 Required |
| GET | /api/v1/notifications/unread-count | Get current unread count from the database | 🔒 Required |
| PUT | /api/v1/notifications/{id}/read | Mark a single notification as read (using encrypted ID) | 🔒 Required |
| PUT | /api/v1/notifications/read-all | Mark all notifications for the authenticated user as read | 🔒 Required |
| DELETE | /api/v1/notifications/{id} | Soft-delete a notification (using encrypted ID) | 🔒 Required |