Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Services/TenantNotificationService.cs
T
Oliver Kieselbach 0b80f09a74 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>
2026-05-08 18:32:22 +02:00

142 lines
5.2 KiB
C#

using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// Service for managing tenant-scoped persistent in-app notifications.
/// Backed by <see cref="ITenantNotificationRepository"/>; all reads/writes are partitioned per
/// tenantId so cross-tenant access is impossible at the repository layer.
/// Designed to be called fire-and-forget from request handlers — failures are logged, never thrown.
/// </summary>
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;
}
public virtual async Task CreateNotificationAsync(string tenantId, string type, string title, string message, string? href = null)
{
if (string.IsNullOrWhiteSpace(tenantId))
{
_logger.LogWarning("CreateNotificationAsync called with empty tenantId — skipping ({Type}: {Title})", type, title);
return;
}
try
{
var notificationId = Guid.NewGuid().ToString("N")[..12];
var notification = new GlobalNotification
{
NotificationId = notificationId,
Type = type,
Title = title,
Message = message,
Href = href,
CreatedAt = DateTime.UtcNow,
CreatedBy = "system",
IsDismissed = false
};
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)
{
_logger.LogWarning(ex, "Failed to create tenant notification (tenant={TenantId} type={Type}: {Title})", tenantId, type, title);
}
}
/// <summary>
/// Returns active (non-dismissed) tenant notifications visible to a caller of the given audience tier.
/// A notification is included iff <c>TenantNotificationAudienceCatalog.Resolve(type) &lt;= callerAudience</c>:
/// Admin callers see Member + Admin notifications, Member callers see only Member notifications.
/// </summary>
public async Task<List<GlobalNotificationDto>> GetActiveNotificationsAsync(string tenantId, NotificationAudience callerAudience)
{
try
{
var notifications = await _repo.GetNotificationsAsync(tenantId);
return notifications
.Where(n => TenantNotificationAudienceCatalog.Resolve(n.Type) <= callerAudience)
.Select(n => new GlobalNotificationDto
{
Id = n.NotificationId,
Type = n.Type,
Title = n.Title,
Message = n.Message,
Href = n.Href,
CreatedAt = n.CreatedAt
}).ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve tenant notifications for tenant {TenantId}", tenantId);
return new List<GlobalNotificationDto>();
}
}
public async Task<bool> DismissNotificationAsync(string tenantId, string notificationId, string dismissedBy)
{
try
{
var dismissed = await _repo.DismissNotificationAsync(tenantId, notificationId, dismissedBy);
if (dismissed)
{
await _signalr.SendTenantNotificationDismissedAsync(tenantId, notificationId);
}
return dismissed;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to dismiss tenant notification (tenant={TenantId} id={NotificationId})",
tenantId, notificationId);
return false;
}
}
public async Task<int> DismissAllNotificationsAsync(string tenantId)
{
try
{
var count = await _repo.DismissAllNotificationsAsync(tenantId);
if (count > 0)
{
await _signalr.SendTenantNotificationDismissedAllAsync(tenantId);
}
return count;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to dismiss all tenant notifications for tenant {TenantId}", tenantId);
return 0;
}
}
}