using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Quartz; using OTSSignsOrchestrator.Server.Data; using OTSSignsOrchestrator.Server.Data.Entities; using OTSSignsOrchestrator.Server.Hubs; namespace OTSSignsOrchestrator.Server.Jobs; /// /// Quartz job that runs daily to check BYOI certificate expiry dates across all enabled /// ByoiConfig entries. Alerts at 60, 30, and 7 day thresholds via FleetHub and logs to AuditLog. /// /// Severity escalation: /// - > 7 days remaining → "Warning" /// - ≤ 7 days remaining → "Critical" /// // IMMUTABLE AuditLog — this job only appends, never updates or deletes audit records. [DisallowConcurrentExecution] public sealed class ByoiCertExpiryJob : IJob { /// Alert thresholds in days. Alerts fire when remaining days ≤ threshold. internal static readonly int[] AlertThresholdDays = [60, 30, 7]; /// Days at or below which severity escalates to "Critical". internal const int CriticalThresholdDays = 7; private readonly IServiceProvider _services; private readonly ILogger _logger; public ByoiCertExpiryJob( IServiceProvider services, ILogger logger) { _services = services; _logger = logger; } public async Task Execute(IJobExecutionContext context) { await using var scope = _services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var hub = scope.ServiceProvider.GetRequiredService>(); var configs = await db.ByoiConfigs .AsNoTracking() .Include(b => b.Instance) .ThenInclude(i => i.Customer) .Where(b => b.Enabled) .ToListAsync(context.CancellationToken); foreach (var config in configs) { var daysRemaining = (config.CertExpiry - DateTime.UtcNow).TotalDays; var abbrev = config.Instance.Customer.Abbreviation; if (!ShouldAlert(daysRemaining)) continue; var severity = GetSeverity(daysRemaining); var daysInt = (int)Math.Floor(daysRemaining); var message = daysRemaining <= 0 ? $"BYOI cert for {abbrev} has EXPIRED." : $"BYOI cert for {abbrev} expires in {daysInt} days."; _logger.LogWarning("BYOI cert expiry alert: {Severity} — {Message}", severity, message); await hub.Clients.All.SendAlertRaised(severity, message); // Append-only audit log db.AuditLogs.Add(new AuditLog { Id = Guid.NewGuid(), InstanceId = config.InstanceId, Actor = "ByoiCertExpiryJob", Action = "CertExpiryAlert", Target = config.Slug, Outcome = severity, Detail = message, OccurredAt = DateTime.UtcNow, }); } await db.SaveChangesAsync(context.CancellationToken); } /// /// Determines whether an alert should fire based on remaining days. /// Alerts at ≤ 60, ≤ 30, ≤ 7 days (or already expired). /// internal static bool ShouldAlert(double daysRemaining) { foreach (var threshold in AlertThresholdDays) { if (daysRemaining <= threshold) return true; } return false; } /// /// Returns "Critical" when ≤ 7 days remain, otherwise "Warning". /// internal static string GetSeverity(double daysRemaining) => daysRemaining <= CriticalThresholdDays ? "Critical" : "Warning"; }