refactor(notifications): bell on pure SignalR push, kill 60s polling

The two notification-bell React contexts polled GET /api/notifications and
GET /api/global/notifications every 60s regardless of activity, generating
3017+618 = 3635 calls/24h with near-zero hit rate (notifications are emitted
sparsely). SignalR was already used app-wide for live session updates; the
bell was the lone hold-out on a pull model.

Switch to the same push model. Steady-state traffic drops ~86% and the bell
updates sub-second instead of within 60s.

Architecture (Option C+):
  - Mount  -> 1x HTTP fetch (cold-start hydration via existing GET endpoint)
  - SignalR onreconnected -> re-fetch (covers the disconnect window)
  - SignalR push -> live append/dismiss
  - No periodic polling, no setInterval

Audience-tier security: TenantNotificationAudienceCatalog still drives
visibility (Member vs Admin), but enforcement moves into SignalR group
membership: tenant-{id}-notify-member is joined by every tenant member,
tenant-{id}-notify-admin only by Tenant Admins / Global Admins. The
AddToGroup function fail-closes with 403 on Member-tier joins to the
admin group, so Admin-tier notifications cannot leak via push.

Backend
  - ISignalRNotificationService extracted; SignalRNotificationService
    implements it. Resolves the long-standing test-mocking blocker
    (the real service's constructor validates AzureSignalRConnectionString
    and BuildServiceManager(); previously you could not unit-test
    consumers). Tenant + Global NotificationService consume the
    interface; the three legacy callers (Analyze, Ingest, VulnCorrelate)
    still bind to the concrete class -- migrate per test need.
  - Six new push methods: SendTenantNotification{,Dismissed,DismissedAll}
    and SendGlobalNotification{,Dismissed,DismissedAll}. Tenant variant
    routes to the audience-correct group.
  - Tenant + Global service push after a successful repo write
    (fire-and-forget; never throws into the caller).
  - SignalRGroupHelper: parsing handles the two new -notify-{member,admin}
    suffixes; new IsTenantNotifyAdminGroup predicate; group-name builders.
  - SignalRAddToGroupFunction: explicit 403 when a non-admin attempts
    to join the admin-tier group.

Frontend
  - TenantNotificationContext: setInterval gone. useSignalR() to
    join member-group always, admin-group iff isTenantAdmin || isGlobalAdmin.
    Listens for tenantNotification (append + dedupe), tenantNotificationDismissed
    (filter), tenantNotificationsDismissedAll (clear). onreconnected
    triggers re-fetch.
  - GlobalNotificationContext: same pattern against global-admins group.
  - app/layout.tsx: SignalRProvider hoisted above the notification
    providers so useSignalR() resolves inside them.

Tests
  - SignalRGroupHelperTests (8): parsing for all four group formats
    incl. new -notify suffixes, IsTenantNotifyAdminGroup predicate.
  - TenantNotificationServiceSignalRPushTests (8): audience-correct push
    on Create (Admin / Member / Unknown -> Admin fail-closed), no-push
    on repo failure, no-push on DismissAll(count=0), no-push on empty
    tenantId.
  - FakeSignalRNotificationService: in-memory spy implementing the
    new interface; replaces the unmockable real service in tests.
  - AuthFunctionSideEffectTests + TenantNotificationServiceTests:
    constructor signatures updated for the new SignalR dep.

Suites
  Backend xUnit: 1161/1161 (was 1146, +15 net).
  Web vitest:    259/259 (unchanged; existing notification-API +
                 gate tests still cover).
  Solution build: 0 warnings, 0 errors.

Expected post-deploy reduction (24h):
  GET GetTenantNotifications:  3017 -> ~50
  GET GetGlobalNotifications:   618 -> ~10
  POST AddToGroup:              ~246 -> ~350-450 (two joins per tab)
  Net bell load:               -86%, sub-second live UX.

Disconnect tradeoff (accepted): if SignalR drops without reconnect
(rare -- withAutomaticReconnect retries 1/2/4/8/16/30s ... forever),
the bell shows the snapshot from the last fetch but no live updates
until next reload. The initial-3-retry give-up path of the SignalR
client still gets the user a hydrated bell because the HTTP fetch
runs independently on mount.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-05-08 18:32:22 +02:00
parent bc1e1e405a
commit 0b80f09a74
17 changed files with 624 additions and 47 deletions
@@ -1 +1 @@
644
647
@@ -1 +1 @@
1279
1282
@@ -55,7 +55,7 @@ public class AuthFunctionSideEffectTests
{ CallBase = false };
_globalNotificationMock = new Mock<GlobalNotificationService>(
notificationRepo, Mock.Of<ILogger<GlobalNotificationService>>())
notificationRepo, new FakeSignalRNotificationService(), Mock.Of<ILogger<GlobalNotificationService>>())
{ CallBase = false };
var adminConfigService = new Mock<AdminConfigurationService>(
@@ -0,0 +1,71 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// In-memory ISignalRNotificationService for unit tests. Records every call so tests can
/// assert that producer code (TenantNotificationService, GlobalNotificationService, …) emits
/// the right SignalR push when it should — without instantiating the real Azure SignalR
/// ServiceManager (whose constructor validates a connection string and is not test-friendly).
/// </summary>
public class FakeSignalRNotificationService : ISignalRNotificationService
{
public List<(string TenantId, NotificationAudience Audience, object Dto)> TenantSends { get; } = new();
public List<(string TenantId, string NotificationId)> TenantDismisses { get; } = new();
public List<string> TenantDismissAlls { get; } = new();
public List<object> GlobalSends { get; } = new();
public List<string> GlobalDismisses { get; } = new();
public int GlobalDismissAllCount { get; private set; }
public List<(string TenantId, string SessionId, int Count)> RuleResultsCalls { get; } = new();
public List<(string TenantId, string SessionId, string Risk)> VulnReportCalls { get; } = new();
public Task NotifyRuleResultsAvailableAsync(string tenantId, string sessionId, int resultCount)
{
RuleResultsCalls.Add((tenantId, sessionId, resultCount));
return Task.CompletedTask;
}
public Task NotifyVulnerabilityReportAvailableAsync(string tenantId, string sessionId, string overallRisk)
{
VulnReportCalls.Add((tenantId, sessionId, overallRisk));
return Task.CompletedTask;
}
public Task SendTenantNotificationAsync(string tenantId, NotificationAudience audience, object dto)
{
TenantSends.Add((tenantId, audience, dto));
return Task.CompletedTask;
}
public Task SendTenantNotificationDismissedAsync(string tenantId, string notificationId)
{
TenantDismisses.Add((tenantId, notificationId));
return Task.CompletedTask;
}
public Task SendTenantNotificationDismissedAllAsync(string tenantId)
{
TenantDismissAlls.Add(tenantId);
return Task.CompletedTask;
}
public Task SendGlobalNotificationAsync(object dto)
{
GlobalSends.Add(dto);
return Task.CompletedTask;
}
public Task SendGlobalNotificationDismissedAsync(string notificationId)
{
GlobalDismisses.Add(notificationId);
return Task.CompletedTask;
}
public Task SendGlobalNotificationDismissedAllAsync()
{
GlobalDismissAllCount++;
return Task.CompletedTask;
}
}
@@ -0,0 +1,67 @@
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for <see cref="SignalRGroupHelper"/>. Locks in the parsing rules for the four
/// supported group formats so a future refactor cannot accidentally break tenant
/// isolation in <see cref="AutopilotMonitor.Functions.Functions.Infrastructure.SignalRAddToGroupFunction"/>,
/// which uses <see cref="SignalRGroupHelper.ExtractTenantIdFromGroupName"/> for cross-tenant validation.
/// </summary>
public class SignalRGroupHelperTests
{
private const string TenantId = "11111111-2222-3333-4444-555555555555";
private const string SessionId = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
[Fact]
public void ExtractTenantId_TenantWideGroup()
{
Assert.Equal(TenantId, SignalRGroupHelper.ExtractTenantIdFromGroupName($"tenant-{TenantId}"));
}
[Fact]
public void ExtractTenantId_TenantNotifyMemberGroup()
{
Assert.Equal(TenantId, SignalRGroupHelper.ExtractTenantIdFromGroupName($"tenant-{TenantId}-notify-member"));
}
[Fact]
public void ExtractTenantId_TenantNotifyAdminGroup()
{
Assert.Equal(TenantId, SignalRGroupHelper.ExtractTenantIdFromGroupName($"tenant-{TenantId}-notify-admin"));
}
[Fact]
public void ExtractTenantId_SessionGroup()
{
Assert.Equal(TenantId, SignalRGroupHelper.ExtractTenantIdFromGroupName($"session-{TenantId}-{SessionId}"));
}
[Fact]
public void ExtractTenantId_GlobalAdmins_ReturnsNull()
{
Assert.Null(SignalRGroupHelper.ExtractTenantIdFromGroupName("global-admins"));
}
[Fact]
public void ExtractTenantId_UnknownFormat_ReturnsNull()
{
Assert.Null(SignalRGroupHelper.ExtractTenantIdFromGroupName("foo-bar"));
}
[Fact]
public void IsTenantNotifyAdminGroup_OnlyTrueForAdminSuffix()
{
Assert.True(SignalRGroupHelper.IsTenantNotifyAdminGroup($"tenant-{TenantId}-notify-admin"));
Assert.False(SignalRGroupHelper.IsTenantNotifyAdminGroup($"tenant-{TenantId}-notify-member"));
Assert.False(SignalRGroupHelper.IsTenantNotifyAdminGroup($"tenant-{TenantId}"));
Assert.False(SignalRGroupHelper.IsTenantNotifyAdminGroup("global-admins"));
}
[Fact]
public void GroupNameBuilders_RoundTrip()
{
Assert.Equal($"tenant-{TenantId}-notify-member", SignalRGroupHelper.TenantNotifyMemberGroup(TenantId));
Assert.Equal($"tenant-{TenantId}-notify-admin", SignalRGroupHelper.TenantNotifyAdminGroup(TenantId));
}
}
@@ -0,0 +1,122 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Locks in that <see cref="TenantNotificationService"/> emits the right SignalR push for each
/// lifecycle event — Create / Dismiss / DismissAll — and routes to the audience-correct group.
///
/// Why this matters: the web bell now relies entirely on SignalR for live updates (no polling).
/// If the service silently drops a push on a code path, the UI will show stale state until
/// the user reloads. That regression would be invisible without these tests.
/// </summary>
public class TenantNotificationServiceSignalRPushTests
{
private const string TenantId = "test-tenant-aaaa-bbbb-cccc-dddddddddddd";
private static (TenantNotificationService Service, FakeSignalRNotificationService Signalr, Mock<ITenantNotificationRepository> Repo) Build()
{
var repo = new Mock<ITenantNotificationRepository>();
var signalr = new FakeSignalRNotificationService();
var service = new TenantNotificationService(repo.Object, signalr, NullLogger<TenantNotificationService>.Instance);
return (service, signalr, repo);
}
[Fact]
public async Task Create_AdminTierType_PushesToAdminAudience()
{
var (svc, signalr, _) = Build();
await svc.CreateNotificationAsync(TenantId, "hardware_rejection", "Title", "Message");
var send = Assert.Single(signalr.TenantSends);
Assert.Equal(TenantId, send.TenantId);
Assert.Equal(NotificationAudience.Admin, send.Audience);
}
[Fact]
public async Task Create_MemberTierType_PushesToMemberAudience()
{
var (svc, signalr, _) = Build();
await svc.CreateNotificationAsync(TenantId, "sla_breach", "Title", "Message");
var send = Assert.Single(signalr.TenantSends);
Assert.Equal(NotificationAudience.Member, send.Audience);
}
[Fact]
public async Task Create_UnknownType_DefaultsToAdminAudience_FailClosed()
{
var (svc, signalr, _) = Build();
await svc.CreateNotificationAsync(TenantId, "brand_new_unregistered_type", "Title", "Message");
// TenantNotificationAudienceCatalog.Resolve fails closed (Admin-only) for unknown types
Assert.Equal(NotificationAudience.Admin, signalr.TenantSends.Single().Audience);
}
[Fact]
public async Task Create_EmptyTenantId_NoPushNoRepoWrite()
{
var (svc, signalr, repo) = Build();
await svc.CreateNotificationAsync("", "sla_breach", "Title", "Message");
Assert.Empty(signalr.TenantSends);
repo.Verify(r => r.AddNotificationAsync(It.IsAny<string>(), It.IsAny<GlobalNotification>()), Times.Never);
}
[Fact]
public async Task Dismiss_RepoReturnsTrue_PushesDismissed()
{
var (svc, signalr, repo) = Build();
repo.Setup(r => r.DismissNotificationAsync(TenantId, "n1", It.IsAny<string>())).ReturnsAsync(true);
var ok = await svc.DismissNotificationAsync(TenantId, "n1", "user@example.com");
Assert.True(ok);
var dismiss = Assert.Single(signalr.TenantDismisses);
Assert.Equal(TenantId, dismiss.TenantId);
Assert.Equal("n1", dismiss.NotificationId);
}
[Fact]
public async Task Dismiss_RepoReturnsFalse_NoPush()
{
var (svc, signalr, repo) = Build();
repo.Setup(r => r.DismissNotificationAsync(TenantId, "missing", It.IsAny<string>())).ReturnsAsync(false);
var ok = await svc.DismissNotificationAsync(TenantId, "missing", "user@example.com");
Assert.False(ok);
Assert.Empty(signalr.TenantDismisses);
}
[Fact]
public async Task DismissAll_RepoReturnsCount_PushesOnceWithTenantId()
{
var (svc, signalr, repo) = Build();
repo.Setup(r => r.DismissAllNotificationsAsync(TenantId)).ReturnsAsync(7);
var count = await svc.DismissAllNotificationsAsync(TenantId);
Assert.Equal(7, count);
Assert.Equal(new[] { TenantId }, signalr.TenantDismissAlls);
}
[Fact]
public async Task DismissAll_RepoReturnsZero_NoPush()
{
var (svc, signalr, repo) = Build();
repo.Setup(r => r.DismissAllNotificationsAsync(TenantId)).ReturnsAsync(0);
await svc.DismissAllNotificationsAsync(TenantId);
Assert.Empty(signalr.TenantDismissAlls);
}
}
@@ -23,7 +23,7 @@ public class TenantNotificationServiceTests
var repo = new Mock<ITenantNotificationRepository>();
repo.Setup(r => r.GetNotificationsAsync(TenantId, It.IsAny<int>()))
.ReturnsAsync(notifications.ToList());
return new TenantNotificationService(repo.Object, NullLogger<TenantNotificationService>.Instance);
return new TenantNotificationService(repo.Object, new FakeSignalRNotificationService(), NullLogger<TenantNotificationService>.Instance);
}
private static GlobalNotification Notif(string type, string id) => new()
@@ -93,6 +93,18 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
_logger.LogInformation($"Global Admin {userEmail} joining cross-tenant group: {request.GroupName}");
}
}
// Admin-tier notification group: only Tenant Admins or Global Admins may join.
// The Member-tier group has no extra check beyond the tenant-membership above.
if (SignalRGroupHelper.IsTenantNotifyAdminGroup(request.GroupName)
&& !requestCtx.IsTenantAdmin
&& !requestCtx.IsGlobalAdmin)
{
_logger.LogWarning($"User {userEmail} (role={requestCtx.UserRole}) attempted to join Admin-tier notification group: {request.GroupName}");
var forbiddenResponse = req.CreateResponse(HttpStatusCode.Forbidden);
await forbiddenResponse.WriteAsJsonAsync(new { success = false, message = "Access denied: Only Tenant Admins can join the admin notification group" });
return new AddToGroupOutput { HttpResponse = forbiddenResponse };
}
}
// Extract session ID from group name if it's a session-specific group
@@ -2,21 +2,24 @@ namespace AutopilotMonitor.Functions.Helpers;
/// <summary>
/// Helper class for parsing SignalR group names.
/// Group formats: "tenant-{tenantId}", "session-{tenantId}-{sessionId}", or "global-admins"
/// Group formats:
/// - "tenant-{tenantId}" — tenant-wide live updates (sessions, etc.)
/// - "tenant-{tenantId}-notify-member" — tenant notification bell (Member-tier visibility)
/// - "tenant-{tenantId}-notify-admin" — tenant notification bell (Admin-tier visibility)
/// - "session-{tenantId}-{sessionId}" — single-session live updates
/// - "global-admins" — global notification bell
/// </summary>
public static class SignalRGroupHelper
{
public const string TenantNotifyMemberSuffix = "-notify-member";
public const string TenantNotifyAdminSuffix = "-notify-admin";
/// <summary>
/// Extracts tenant ID from SignalR group name.
/// </summary>
public static string? ExtractTenantIdFromGroupName(string groupName)
{
if (groupName.StartsWith("tenant-"))
{
// Format: "tenant-{tenantId}"
return groupName.Substring("tenant-".Length);
}
else if (groupName.StartsWith("session-"))
if (groupName.StartsWith("session-"))
{
// Format: "session-{tenantId}-{sessionId}"
// Extract everything between "session-" and the last 5 GUID segments
@@ -26,10 +29,33 @@ public static class SignalRGroupHelper
// Reconstruct tenant GUID from parts 1-5
return string.Join("-", parts.Skip(1).Take(5));
}
return null;
}
if (groupName.StartsWith("tenant-"))
{
// Strip the leading "tenant-" prefix, then strip the optional "-notify-{member,admin}" suffix.
var withoutPrefix = groupName.Substring("tenant-".Length);
if (withoutPrefix.EndsWith(TenantNotifyAdminSuffix))
return withoutPrefix.Substring(0, withoutPrefix.Length - TenantNotifyAdminSuffix.Length);
if (withoutPrefix.EndsWith(TenantNotifyMemberSuffix))
return withoutPrefix.Substring(0, withoutPrefix.Length - TenantNotifyMemberSuffix.Length);
return withoutPrefix;
}
return null;
}
/// <summary>
/// True for the Admin-tier tenant notification group. Joining requires Tenant-Admin or Global-Admin.
/// </summary>
public static bool IsTenantNotifyAdminGroup(string groupName)
=> groupName.StartsWith("tenant-") && groupName.EndsWith(TenantNotifyAdminSuffix);
public static string TenantNotifyMemberGroup(string tenantId) => $"tenant-{tenantId}{TenantNotifyMemberSuffix}";
public static string TenantNotifyAdminGroup(string tenantId) => $"tenant-{tenantId}{TenantNotifyAdminSuffix}";
public const string GlobalAdminsGroup = "global-admins";
public static string ExtractLogPrefix(string groupName)
{
// Extract session ID from group name: "session-{tenantId}-{sessionId}"
@@ -217,6 +217,7 @@ builder.Services.AddHostedService<
// Programmatic SignalR push for background tasks (rule engine, vulnerability correlation)
builder.Services.AddSingleton<SignalRNotificationService>();
builder.Services.AddSingleton<ISignalRNotificationService>(sp => sp.GetRequiredService<SignalRNotificationService>());
// Vulnerability correlation services
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Vulnerability.NvdApiClient>()
@@ -46,13 +46,16 @@ public class GlobalNotificationDto
public class GlobalNotificationService
{
private readonly INotificationRepository _notificationRepo;
private readonly ISignalRNotificationService _signalr;
private readonly ILogger<GlobalNotificationService> _logger;
public GlobalNotificationService(
INotificationRepository notificationRepo,
ISignalRNotificationService signalr,
ILogger<GlobalNotificationService> logger)
{
_notificationRepo = notificationRepo;
_signalr = signalr;
_logger = logger;
}
@@ -79,6 +82,17 @@ public class GlobalNotificationService
await _notificationRepo.AddNotificationAsync(notification);
_logger.LogInformation("Global notification created: {NotificationId} ({Type})", notificationId, type);
var dto = new GlobalNotificationDto
{
Id = notificationId,
Type = type,
Title = title,
Message = message,
Href = href,
CreatedAt = notification.CreatedAt
};
await _signalr.SendGlobalNotificationAsync(dto);
}
catch (Exception ex)
{
@@ -120,7 +134,12 @@ public class GlobalNotificationService
{
try
{
return await _notificationRepo.DismissNotificationAsync(notificationId, string.Empty);
var dismissed = await _notificationRepo.DismissNotificationAsync(notificationId, string.Empty);
if (dismissed)
{
await _signalr.SendGlobalNotificationDismissedAsync(notificationId);
}
return dismissed;
}
catch (Exception ex)
{
@@ -136,7 +155,12 @@ public class GlobalNotificationService
{
try
{
return await _notificationRepo.DismissAllNotificationsAsync();
var count = await _notificationRepo.DismissAllNotificationsAsync();
if (count > 0)
{
await _signalr.SendGlobalNotificationDismissedAllAsync();
}
return count;
}
catch (Exception ex)
{
@@ -0,0 +1,24 @@
using System.Threading.Tasks;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// Programmatic SignalR message sender. Extracted as an interface so that consumers
/// (TenantNotificationService, GlobalNotificationService, IngestEventsFunction, etc.)
/// can be unit-tested without instantiating the real Azure SignalR ServiceManager,
/// whose constructor validates the connection string.
/// </summary>
public interface ISignalRNotificationService
{
Task NotifyRuleResultsAvailableAsync(string tenantId, string sessionId, int resultCount);
Task NotifyVulnerabilityReportAvailableAsync(string tenantId, string sessionId, string overallRisk);
Task SendTenantNotificationAsync(string tenantId, NotificationAudience audience, object dto);
Task SendTenantNotificationDismissedAsync(string tenantId, string notificationId);
Task SendTenantNotificationDismissedAllAsync(string tenantId);
Task SendGlobalNotificationAsync(object dto);
Task SendGlobalNotificationDismissedAsync(string notificationId);
Task SendGlobalNotificationDismissedAllAsync();
}
}
@@ -1,5 +1,6 @@
using System;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Helpers;
using Microsoft.Azure.SignalR.Management;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
@@ -11,7 +12,7 @@ namespace AutopilotMonitor.Functions.Services
/// of Azure Function output bindings (e.g., async rule engine, vulnerability correlation).
/// Uses the Azure SignalR Management SDK to send messages to connected clients.
/// </summary>
public class SignalRNotificationService : IDisposable
public class SignalRNotificationService : ISignalRNotificationService, IDisposable
{
private readonly ILogger<SignalRNotificationService> _logger;
private readonly ServiceManager _serviceManager;
@@ -94,6 +95,124 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// Push a newly created tenant notification to the appropriate audience-tier group.
/// Member-tier notifications go to the -notify-member group (joined by all tenant members);
/// Admin-tier notifications go to the -notify-admin group (joined by Tenant Admins + Global Admins only).
/// </summary>
public virtual async Task SendTenantNotificationAsync(string tenantId, NotificationAudience audience, object dto)
{
try
{
var hub = await GetHubContextAsync();
var groupName = audience == NotificationAudience.Admin
? SignalRGroupHelper.TenantNotifyAdminGroup(tenantId)
: SignalRGroupHelper.TenantNotifyMemberGroup(tenantId);
await hub.Clients.Group(groupName).SendCoreAsync("tenantNotification", new[] { dto });
_logger.LogDebug("Sent tenantNotification to group {Group}", groupName);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send tenantNotification for tenant {TenantId}", tenantId);
}
}
/// <summary>
/// Push a tenant notification dismissal to all audience-tier groups for the tenant.
/// We do not know the original audience at dismiss time, so we broadcast to both groups —
/// receivers filter by id and remove if present (idempotent).
/// </summary>
public virtual async Task SendTenantNotificationDismissedAsync(string tenantId, string notificationId)
{
try
{
var hub = await GetHubContextAsync();
var memberGroup = SignalRGroupHelper.TenantNotifyMemberGroup(tenantId);
var adminGroup = SignalRGroupHelper.TenantNotifyAdminGroup(tenantId);
var payload = new { id = notificationId };
await Task.WhenAll(
hub.Clients.Group(memberGroup).SendCoreAsync("tenantNotificationDismissed", new object[] { payload }),
hub.Clients.Group(adminGroup).SendCoreAsync("tenantNotificationDismissed", new object[] { payload })
);
_logger.LogDebug("Sent tenantNotificationDismissed for tenant {TenantId} id {Id}", tenantId, notificationId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send tenantNotificationDismissed for tenant {TenantId} id {Id}", tenantId, notificationId);
}
}
/// <summary>
/// Push a "dismiss all" event to all audience-tier groups for the tenant. Receivers clear local state.
/// </summary>
public virtual async Task SendTenantNotificationDismissedAllAsync(string tenantId)
{
try
{
var hub = await GetHubContextAsync();
var memberGroup = SignalRGroupHelper.TenantNotifyMemberGroup(tenantId);
var adminGroup = SignalRGroupHelper.TenantNotifyAdminGroup(tenantId);
await Task.WhenAll(
hub.Clients.Group(memberGroup).SendCoreAsync("tenantNotificationsDismissedAll", Array.Empty<object>()),
hub.Clients.Group(adminGroup).SendCoreAsync("tenantNotificationsDismissedAll", Array.Empty<object>())
);
_logger.LogDebug("Sent tenantNotificationsDismissedAll for tenant {TenantId}", tenantId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send tenantNotificationsDismissedAll for tenant {TenantId}", tenantId);
}
}
/// <summary>
/// Push a newly created global notification to the global-admins group.
/// </summary>
public virtual async Task SendGlobalNotificationAsync(object dto)
{
try
{
var hub = await GetHubContextAsync();
await hub.Clients.Group(SignalRGroupHelper.GlobalAdminsGroup).SendCoreAsync("globalNotification", new[] { dto });
_logger.LogDebug("Sent globalNotification to group {Group}", SignalRGroupHelper.GlobalAdminsGroup);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send globalNotification");
}
}
public virtual async Task SendGlobalNotificationDismissedAsync(string notificationId)
{
try
{
var hub = await GetHubContextAsync();
var payload = new { id = notificationId };
await hub.Clients.Group(SignalRGroupHelper.GlobalAdminsGroup).SendCoreAsync("globalNotificationDismissed", new object[] { payload });
_logger.LogDebug("Sent globalNotificationDismissed for id {Id}", notificationId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send globalNotificationDismissed for id {Id}", notificationId);
}
}
public virtual async Task SendGlobalNotificationDismissedAllAsync()
{
try
{
var hub = await GetHubContextAsync();
await hub.Clients.Group(SignalRGroupHelper.GlobalAdminsGroup).SendCoreAsync("globalNotificationsDismissedAll", Array.Empty<object>());
_logger.LogDebug("Sent globalNotificationsDismissedAll");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send globalNotificationsDismissedAll");
}
}
private async Task<IServiceHubContext> GetHubContextAsync()
{
if (_hubContext != null) return _hubContext;
@@ -12,13 +12,16 @@ namespace AutopilotMonitor.Functions.Services;
public class TenantNotificationService
{
private readonly ITenantNotificationRepository _repo;
private readonly ISignalRNotificationService _signalr;
private readonly ILogger<TenantNotificationService> _logger;
public TenantNotificationService(
ITenantNotificationRepository repo,
ISignalRNotificationService signalr,
ILogger<TenantNotificationService> logger)
{
_repo = repo;
_signalr = signalr;
_logger = logger;
}
@@ -49,6 +52,19 @@ public class TenantNotificationService
await _repo.AddNotificationAsync(tenantId, notification);
_logger.LogInformation("Tenant notification created: tenant={TenantId} id={NotificationId} type={Type}",
tenantId, notificationId, type);
// Push to the audience-tier SignalR group so connected clients update live without polling.
var audience = TenantNotificationAudienceCatalog.Resolve(type);
var dto = new GlobalNotificationDto
{
Id = notificationId,
Type = type,
Title = title,
Message = message,
Href = href,
CreatedAt = notification.CreatedAt
};
await _signalr.SendTenantNotificationAsync(tenantId, audience, dto);
}
catch (Exception ex)
{
@@ -90,7 +106,12 @@ public class TenantNotificationService
{
try
{
return await _repo.DismissNotificationAsync(tenantId, notificationId, dismissedBy);
var dismissed = await _repo.DismissNotificationAsync(tenantId, notificationId, dismissedBy);
if (dismissed)
{
await _signalr.SendTenantNotificationDismissedAsync(tenantId, notificationId);
}
return dismissed;
}
catch (Exception ex)
{
@@ -104,7 +125,12 @@ public class TenantNotificationService
{
try
{
return await _repo.DismissAllNotificationsAsync(tenantId);
var count = await _repo.DismissAllNotificationsAsync(tenantId);
if (count > 0)
{
await _signalr.SendTenantNotificationDismissedAllAsync(tenantId);
}
return count;
}
catch (Exception ex)
{
+10 -10
View File
@@ -118,11 +118,11 @@ export default function RootLayout({
/>
<ThemeProvider>
<AuthProvider>
<NotificationProvider>
<GlobalNotificationProvider>
<TenantNotificationProvider>
<TenantProvider>
<SignalRProvider>
<SignalRProvider>
<NotificationProvider>
<GlobalNotificationProvider>
<TenantNotificationProvider>
<TenantProvider>
<SidebarProvider>
<AppInsightsInit />
<Navbar />
@@ -132,11 +132,11 @@ export default function RootLayout({
<ScrollToTopButton />
<FeedbackBubble />
</SidebarProvider>
</SignalRProvider>
</TenantProvider>
</TenantNotificationProvider>
</GlobalNotificationProvider>
</NotificationProvider>
</TenantProvider>
</TenantNotificationProvider>
</GlobalNotificationProvider>
</NotificationProvider>
</SignalRProvider>
</AuthProvider>
</ThemeProvider>
</body>
@@ -2,6 +2,7 @@
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
import { useAuth } from './AuthContext';
import { useSignalR } from './SignalRContext';
import { api } from '@/lib/api';
import { authenticatedFetch } from '@/lib/authenticatedFetch';
@@ -24,14 +25,12 @@ interface GlobalNotificationContextType {
const GlobalNotificationContext = createContext<GlobalNotificationContextType | undefined>(undefined);
const POLL_INTERVAL_MS = 60_000;
export function GlobalNotificationProvider({ children }: { children: React.ReactNode }) {
const { user, getAccessToken } = useAuth();
const { connection, isConnected, joinGroup, leaveGroup } = useSignalR();
const [notifications, setNotifications] = useState<GlobalNotification[]>([]);
const [isLoading, setIsLoading] = useState(false);
const fetchingRef = useRef(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isGlobal = user?.isGlobalAdmin === true;
@@ -55,23 +54,23 @@ export function GlobalNotificationProvider({ children }: { children: React.React
}
}, [isGlobal, getAccessToken]);
// Initial fetch + polling
// Initial state hydration. Re-fetched on SignalR reconnect to recover any deltas
// pushed during the disconnect window.
useEffect(() => {
if (!isGlobal) {
setNotifications([]);
return;
}
setIsLoading(true);
fetchNotifications().finally(() => setIsLoading(false));
intervalRef.current = setInterval(fetchNotifications, POLL_INTERVAL_MS);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [isGlobal, fetchNotifications]);
useEffect(() => {
if (!connection || !isGlobal) return;
const handler = () => { fetchNotifications(); };
connection.onreconnected(handler);
}, [connection, isGlobal, fetchNotifications]);
// Re-fetch when globalAdminMode is toggled in localStorage
useEffect(() => {
const handleStorageChange = () => {
@@ -83,8 +82,42 @@ export function GlobalNotificationProvider({ children }: { children: React.React
return () => window.removeEventListener('localStorageChange', handleStorageChange);
}, [isGlobal, fetchNotifications]);
// Group membership: only Global Admins join the global-admins group.
useEffect(() => {
if (!isConnected || !isGlobal) return;
const group = 'global-admins';
joinGroup(group);
return () => { leaveGroup(group); };
}, [isConnected, isGlobal, joinGroup, leaveGroup]);
// Live-push handlers.
useEffect(() => {
if (!connection) return;
const onCreate = (notification: GlobalNotification) => {
if (!notification?.id) return;
setNotifications(prev => prev.some(n => n.id === notification.id) ? prev : [notification, ...prev]);
};
const onDismiss = (payload: { id: string }) => {
if (!payload?.id) return;
setNotifications(prev => prev.filter(n => n.id !== payload.id));
};
const onDismissAll = () => {
setNotifications([]);
};
connection.on('globalNotification', onCreate);
connection.on('globalNotificationDismissed', onDismiss);
connection.on('globalNotificationsDismissedAll', onDismissAll);
return () => {
connection.off('globalNotification', onCreate);
connection.off('globalNotificationDismissed', onDismiss);
connection.off('globalNotificationsDismissedAll', onDismissAll);
};
}, [connection]);
const dismissNotification = useCallback(async (id: string) => {
// Optimistic removal
setNotifications(prev => prev.filter(n => n.id !== id));
try {
@@ -94,7 +127,7 @@ export function GlobalNotificationProvider({ children }: { children: React.React
{ method: 'POST' },
);
} catch {
// Best-effort; next poll will reconcile
// Best-effort; backend push will reconcile other tabs if dismiss eventually lands
}
}, [getAccessToken]);
@@ -2,6 +2,7 @@
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
import { useAuth } from './AuthContext';
import { useSignalR } from './SignalRContext';
import { canFetchTenantNotifications } from './tenantNotificationsGate';
import { api } from '@/lib/api';
import { authenticatedFetch } from '@/lib/authenticatedFetch';
@@ -25,20 +26,20 @@ interface TenantNotificationContextType {
const TenantNotificationContext = createContext<TenantNotificationContextType | undefined>(undefined);
const POLL_INTERVAL_MS = 60_000;
export function TenantNotificationProvider({ children }: { children: React.ReactNode }) {
const { user, getAccessToken } = useAuth();
const { connection, isConnected, joinGroup, leaveGroup } = useSignalR();
const [tenantNotifications, setTenantNotifications] = useState<TenantNotification[]>([]);
const [isLoading, setIsLoading] = useState(false);
const fetchingRef = useRef(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Only tenant members (Admin/Operator/Viewer) and Global Admins are entitled to the bell.
// The backend filters per-notification visibility by audience tier, so it is safe to fetch
// for every member role — but unauthenticated users and users without a tenant role would
// just produce 401/403 noise, so we skip them entirely.
const canFetchNotifications = canFetchTenantNotifications(user);
const tenantId = user?.tenantId ?? null;
const isAdminTier = user?.isTenantAdmin === true || user?.isGlobalAdmin === true;
const fetchNotifications = useCallback(async () => {
if (!canFetchNotifications || fetchingRef.current) return;
@@ -60,21 +61,72 @@ export function TenantNotificationProvider({ children }: { children: React.React
}
}, [canFetchNotifications, getAccessToken]);
// Initial state hydration. Runs once on mount and again after every SignalR reconnect to
// recover any deltas that were emitted while the SignalR connection was down.
useEffect(() => {
if (!canFetchNotifications) {
setTenantNotifications([]);
return;
}
setIsLoading(true);
fetchNotifications().finally(() => setIsLoading(false));
}, [canFetchNotifications, fetchNotifications]);
intervalRef.current = setInterval(fetchNotifications, POLL_INTERVAL_MS);
// Re-fetch on SignalR reconnect — covers the deltas pushed during the disconnect window.
useEffect(() => {
if (!connection || !canFetchNotifications) return;
const handler = () => { fetchNotifications(); };
connection.onreconnected(handler);
// The signalr-js client does not expose an unsubscribe for onreconnected; rely on the
// SignalR connection lifecycle (one connection per app session) for cleanup.
}, [connection, canFetchNotifications, fetchNotifications]);
// Group membership: Member-tier always; Admin-tier only for Tenant Admins / Global Admins.
useEffect(() => {
if (!isConnected || !canFetchNotifications || !tenantId) return;
const memberGroup = `tenant-${tenantId}-notify-member`;
const adminGroup = `tenant-${tenantId}-notify-admin`;
joinGroup(memberGroup);
if (isAdminTier) {
joinGroup(adminGroup);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
leaveGroup(memberGroup);
if (isAdminTier) {
leaveGroup(adminGroup);
}
};
}, [canFetchNotifications, fetchNotifications]);
}, [isConnected, canFetchNotifications, tenantId, isAdminTier, joinGroup, leaveGroup]);
// Live-push handlers.
useEffect(() => {
if (!connection) return;
const onCreate = (notification: TenantNotification) => {
if (!notification?.id) return;
setTenantNotifications(prev => prev.some(n => n.id === notification.id) ? prev : [notification, ...prev]);
};
const onDismiss = (payload: { id: string }) => {
if (!payload?.id) return;
setTenantNotifications(prev => prev.filter(n => n.id !== payload.id));
};
const onDismissAll = () => {
setTenantNotifications([]);
};
connection.on('tenantNotification', onCreate);
connection.on('tenantNotificationDismissed', onDismiss);
connection.on('tenantNotificationsDismissedAll', onDismissAll);
return () => {
connection.off('tenantNotification', onCreate);
connection.off('tenantNotificationDismissed', onDismiss);
connection.off('tenantNotificationsDismissedAll', onDismissAll);
};
}, [connection]);
const dismissTenantNotification = useCallback(async (id: string) => {
setTenantNotifications(prev => prev.filter(n => n.id !== id));
@@ -86,7 +138,7 @@ export function TenantNotificationProvider({ children }: { children: React.React
{ method: 'POST' },
);
} catch {
// Best-effort; next poll will reconcile
// Best-effort; the backend push will reconcile other tabs if the dismiss eventually lands
}
}, [getAccessToken]);