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.

Notification Dispatch & Delivery Flow
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 / OperationScope MappingPush Mechanism
SendAsync(..., NotificationTarget.User, ...)Resolves target User IDPersists UserNotification entity, AES encrypts ID, pushes payload to user_{userId} and triggers UnreadCountUpdated
SendAsync(..., NotificationTarget.Role, ...)Resolves User IDs by RoleIdIterates through matching users, persisting and pushing notifications individually
SendAsync(..., NotificationTarget.Tenant, ...)Resolves User IDs by TenantIdIterates through matching users in the tenant, persisting and pushing notifications individually
SendAsync(..., NotificationTarget.Broadcast, ...)Resolves all active User IDsBroadcasts to all users globally by iterating through the entire system user list
GetUnreadCountAsync(userId, ...)Queries unread notifications in DbCalled by the hub on connection to retrieve initial unread count

Notification API Endpoints

MethodEndpointDescriptionAuth
GET/api/v1/notificationsList user notifications (paginated, returning decrypted records)🔒 Required
GET/api/v1/notifications/unread-countGet current unread count from the database🔒 Required
PUT/api/v1/notifications/{id}/readMark a single notification as read (using encrypted ID)🔒 Required
PUT/api/v1/notifications/read-allMark all notifications for the authenticated user as read🔒 Required
DELETE/api/v1/notifications/{id}Soft-delete a notification (using encrypted ID)🔒 Required