Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Services/GlobalNotificationService.cs
T
Oliver Kieselbach eaef1601d0 feat(backend,mcp,web): typisierung follow-up — the deliberately-untyped remainder is typed end-to-end, and the raw table entities leave the wire (#137)
Closes the documented read-model follow-up of the typed API contract (51737174). Three categories:

WIRE-IDENTICAL moves/substitutions (parity facts where a literal changed, move-only otherwise): RuleDryRun + trace classes and RuleDryRunVerdict move to Shared/RulesApiModels (DryRunAnalyzeRuleResponse.Result concrete; Evidence/MatchedConditions stay heterogeneous by design); BackfillResult(+Sample)/ReclassificationResult(+Sample)/MaintenanceResult move to Shared/AdminApiModels — the shared MaintenanceJobRunResponse union envelope is split into BackfillJobRunResponse/ReclassifyJobRunResponse (identical key layout, split only for the typed slot); TenantConfigFieldSchema moves as a plain class with the record's constructor signature (Shared has no IsExternalInit — call sites just lowercase their named args); HealthCheck moves (HealthCheckResult stays in Functions, it never hits the wire); GlobalNotificationDto moves (one DTO, both notification endpoints); GetMetricsSummaryAsync builds MetricsSummaryTenantItem instead of the anonymous row ([ProjectedItems] dropped, slot concrete, substitution parity fact incl. the per-item windowDays duplicate; IMetricsRepository signature follows).

UNTYPED ENDPOINTS -> DTO (parity facts mandatory): auth/me success body becomes AuthMeResponse (15 keys in exact wire order; role null-omission fact; blocked arms stay anonymous error shapes; AuthFunctionTests.ToDynamic now exposes wire casing so asserts read camelCase for DTOs and error literals alike). auth/mcp becomes CheckMcpAccessResponse — the four fixed keys are non-nullable strings on McpAccessCheckResult, so the dictionary never emitted null values and WhenWritingNull changes nothing; the four conditional keys are nullable slots in exact insertion order, isGlobalAdmin emitted only as literal true; three facts pin plain/platform+delegated/denied against the old Dictionary literal.

ENTITY-HYGIENE (DELIBERATE wire change, exact-JSON shape pins instead of parity facts): GetGlobalAdminsResponse/AddGlobalAdminResponse serve GlobalAdminRow, GET+POST tenants/{id}/admins serve TenantAdminRow, preview/whitelist serves PreviewWhitelistTenantEntry { tenantId } — partitionKey/rowKey/eTag/timestamp (and the whitelist's garbage approvedAt/approvedBy defaults) are gone from the wire. Every wire instance was synthetically back-mapped from Shared read models (GlobalAdminEntry/TenantMember) anyway; the services now map forward into the rows and the ITableEntity types stay storage-only. Consumer audit (web+MCP) found exactly ONE reader of a table key: AdminConfigContext preview-approval (t.partitionKey -> t.tenantId, same change). Shape pins assert the full JSON and table-key absence.

Guard: TypedResponseGuardTests gains an object-slot reflection ratchet over the reachable wire-type graph (IApiResponse + [WireContract] roots, transitive property walk; [ProjectedItems] slots and dictionary values exempt) with mutation-proven red/green; baseline holds exactly RuleDryRunCondition.Evidence, and stale entries fail the ratchet-down arm.

Codegen: manifest 298->311 exported types, every former unknown slot concrete in both wire-types.generated.ts copies; generator idempotent. MCP adopts: access-guard.ts drops its inline auth/mcp cast for CheckMcpAccessResponse (defensive delegatedTenantIds normalization kept), rules.ts drops the local RuleDryRun view (the dead verdict cast disappears; matchedConditions feeds interpolateRuleTemplate typed), get_tenant_config_schema gets its envelope type. Web adopts: new types/auth.ts shim (TenantRole/HomedApp narrowing over AuthMeResponse), AuthContext.UserInfo derives from the wire type and both duplicated as-cast mapping blocks collapse into one toUserInfo(); health-check page, notification contexts and the TenantAdmin types re-export generated interfaces.

Deliberately untouched: config/all + tenants/{id}/admins stay bare arrays (typed items, envelope would break four array-typed web readers — ime-versions precedent); error bodies stay anonymous; error-body ratchet remains a separate PR; the two documented guard baseline entries stay.

Suites: Backend 5330, Agent 2805, DecisionCore 577, SummaryDialog 44, MCP tsc 0 + 617, Web tsc 0 + vitest 957 + eslint 0; solution builds warning-free.

Deploy note: backend + web + MCP all need deployment; the category-C wire change is only visible in the GA admin UI (preview-approval badges) between backend and web deploy.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 15:22:16 +02:00

163 lines
5.5 KiB
C#

using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Azure;
using Azure.Data.Tables;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services;
/// <summary>
/// Entity for persistent Global Admin notifications stored in Table Storage.
/// All GAs share the same notification pool; dismiss is global.
/// </summary>
public class GlobalNotificationEntity : ITableEntity
{
public string PartitionKey { get; set; } = "notifications";
public string RowKey { get; set; } = string.Empty; // invertedTicks_notificationId (newest-first)
public DateTimeOffset? Timestamp { get; set; }
public ETag ETag { get; set; }
public string NotificationId { get; set; } = string.Empty;
public string Type { get; set; } = "info"; // "session_report" | "preview_signup"
public string Title { get; set; } = string.Empty;
public string Message { get; set; } = string.Empty;
public string? Href { get; set; }
public DateTime CreatedAt { get; set; }
public bool Dismissed { get; set; }
}
// GlobalNotificationDto is wire contract and lives in AutopilotMonitor.Shared.Models
// (NotificationsApiModels.cs).
/// <summary>
/// Service for managing persistent Global Admin in-app notifications.
/// Notifications survive page reloads and persist until actively dismissed.
/// Delegates storage to INotificationRepository; keeps business logic (fire-and-forget, DTO conversion).
/// </summary>
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;
}
/// <summary>
/// Creates a new persistent notification. Designed to be called fire-and-forget;
/// failures are logged but never thrown.
/// </summary>
public virtual async Task CreateNotificationAsync(string type, string title, string message, string? href = null)
{
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,
IsDismissed = false
};
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)
{
_logger.LogWarning(ex, "Failed to create global notification ({Type}: {Title})", type, title);
}
}
/// <summary>
/// Returns all active (non-dismissed) notifications, newest first.
/// </summary>
public async Task<List<GlobalNotificationDto>> GetActiveNotificationsAsync()
{
try
{
var notifications = await _notificationRepo.GetNotificationsAsync();
return notifications.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 global notifications");
return new List<GlobalNotificationDto>();
}
}
/// <summary>
/// Marks a single notification as dismissed.
/// Returns true if found and dismissed, false if not found.
/// </summary>
public async Task<bool> DismissNotificationAsync(string notificationId)
{
try
{
var dismissed = await _notificationRepo.DismissNotificationAsync(notificationId, string.Empty);
if (dismissed)
{
await _signalr.SendGlobalNotificationDismissedAsync(notificationId);
}
return dismissed;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to dismiss global notification {NotificationId}", notificationId);
return false;
}
}
/// <summary>
/// Dismisses all active notifications. Returns the count of dismissed items.
/// </summary>
public async Task<int> DismissAllNotificationsAsync()
{
try
{
var count = await _notificationRepo.DismissAllNotificationsAsync();
if (count > 0)
{
await _signalr.SendGlobalNotificationDismissedAllAsync();
}
return count;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to dismiss all global notifications");
return 0;
}
}
}