using System; using System.Collections.Generic; using System.Threading.Tasks; using AutopilotMonitor.Shared.Pagination; namespace AutopilotMonitor.Shared.DataAccess { /// /// Repository for operational events (OpsEvents table). /// Stores vital infrastructure events visible to Global Admins in the Ops dashboard. /// public interface IOpsEventRepository { Task SaveOpsEventAsync(OpsEventEntry entry); /// /// Returns all matching ops events in the given UTC window. /// is optional — when provided, scopes to a single PartitionKey for an /// indexed lookup. Sorted newest-first. No row cap; for unbounded /// windows on busy installations, prefer . /// narrows on non-key columns server-side. /// Task> GetOpsEventsAsync( string? category = null, DateTime? dateFrom = null, DateTime? dateTo = null, OpsEventQueryFilters? filters = null); /// /// Reads a single page of ops events. The returned /// carries the underlying store's opaque continuation token; null /// when this page was the last. Items in each page are sorted /// newest-first. /// Task> GetOpsEventsPageAsync( string? category, DateTime? dateFrom, DateTime? dateTo, int pageSize, string? continuation, OpsEventQueryFilters? filters = null); Task DeleteOpsEventsOlderThanAsync(DateTime cutoff); } /// /// Categories for operational events. /// public static class OpsEventCategory { public const string Consent = "Consent"; public const string Maintenance = "Maintenance"; public const string Security = "Security"; public const string Tenant = "Tenant"; public const string Agent = "Agent"; public const string Sla = "SLA"; /// Platform infrastructure alerts relayed from Azure Monitor (ops alert webhook). public const string Platform = "Platform"; /// /// Every category = every OpsEvents PartitionKey. The cross-category paged read fans out /// over exactly this list, so a category missing here is INVISIBLE to every paged reader /// (MCP, the ops dashboard) while still being written — Platform was missing that way. /// OpsEventCategoryCoverageTests reflects over the constants above and fails if a new one /// is not listed here. /// public static readonly string[] All = { Consent, Maintenance, Security, Tenant, Agent, Sla, Platform }; } /// /// Severity levels for operational events. /// public static class OpsEventSeverity { public const string Info = "Info"; public const string Warning = "Warning"; public const string Error = "Error"; public const string Critical = "Critical"; /// Canonical severities, ascending by . public static readonly string[] All = { Info, Warning, Error, Critical }; /// /// Ordinal severity rank (Info=0 … Critical=3), -1 for anything outside the vocabulary. /// Case-SENSITIVE on purpose: written severities always come from the constants above, and /// the ops-alert dispatch has always compared them verbatim. Query input goes through /// first. /// public static int Rank(string? severity) => severity switch { Info => 0, Warning => 1, Error => 2, Critical => 3, _ => -1, }; /// /// Maps caller-supplied text ("warning", "ERROR") onto the canonical constant. Returns false /// for anything outside the vocabulary so an endpoint can 400 instead of silently returning /// an empty page — Table Storage eq is case-sensitive, so an unnormalized "warning" /// would match nothing at all. /// public static bool TryNormalize(string? value, out string canonical) { foreach (var s in All) { if (string.Equals(s, value, StringComparison.OrdinalIgnoreCase)) { canonical = s; return true; } } canonical = string.Empty; return false; } /// Canonical severities at or above (empty for an unknown value). public static IReadOnlyList AtOrAbove(string minSeverity) { var min = Rank(minSeverity); if (min < 0) return new string[0]; var result = new List(); foreach (var s in All) { if (Rank(s) >= min) result.Add(s); } return result; } } /// /// Optional exact-match field filters for the ops-events reads. Every field maps to a /// SERVER-SIDE OData clause (see TableOpsEventRepository.BuildFilter) — the point is that the /// store returns only matching rows, so an operator asking for one event type does not pull a /// whole category over the wire. Mirrors AuditLogQueryFilters. /// public class OpsEventQueryFilters { /// Exact match on the EventType column (e.g. "AgentEmergencyBreak"). Case-sensitive — Table Storage cannot fold case. public string? EventType { get; set; } /// Exact match on the Severity column. Callers should pass a canonical value. public string? Severity { get; set; } /// Threshold match: every severity at or above this one (Info < Warning < Error < Critical). Same vocabulary the ops-alert rules use. public string? MinSeverity { get; set; } /// True when no filter field is set — callers can skip the filter plumbing entirely. public bool IsEmpty => string.IsNullOrEmpty(EventType) && string.IsNullOrEmpty(Severity) && string.IsNullOrEmpty(MinSeverity); } public class OpsEventEntry { public string Id { get; set; } = string.Empty; public string Category { get; set; } = string.Empty; public string EventType { get; set; } = string.Empty; public string Severity { get; set; } = OpsEventSeverity.Info; public string? TenantId { get; set; } public string? UserId { get; set; } public string Message { get; set; } = string.Empty; public string? Details { get; set; } public DateTime Timestamp { get; set; } } }