mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
eaef1601d0
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>
163 lines
5.5 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|