using Microsoft.EntityFrameworkCore; using Quartz; using OTSSignsOrchestrator.Server.Clients; using OTSSignsOrchestrator.Server.Data; using OTSSignsOrchestrator.Server.Data.Entities; namespace OTSSignsOrchestrator.Server.Jobs; /// /// Quartz job scheduled at 2 AM UTC daily (0 0 2 * * ?). /// For each active : calls /// with authorised=1 to count authorised displays; inserts a row. /// /// Uses ON CONFLICT DO NOTHING semantics to protect against double-runs. /// THIS DATA CANNOT BE RECOVERED — if the job misses a day, that data is permanently lost. /// [DisallowConcurrentExecution] public sealed class DailySnapshotJob : IJob { private readonly IServiceProvider _services; private readonly ILogger _logger; public DailySnapshotJob( 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 clientFactory = scope.ServiceProvider.GetRequiredService(); var settings = scope.ServiceProvider.GetRequiredService(); var today = DateOnly.FromDateTime(DateTime.UtcNow); var instances = await db.Instances .Include(i => i.Customer) .Include(i => i.OauthAppRegistries) .Where(i => i.Customer.Status == CustomerStatus.Active) .ToListAsync(context.CancellationToken); _logger.LogInformation("DailySnapshotJob: processing {Count} active instance(s) for {Date}", instances.Count, today); foreach (var instance in instances) { var abbrev = instance.Customer.Abbreviation; try { var oauthApp = instance.OauthAppRegistries.FirstOrDefault(); if (oauthApp is null) { _logger.LogWarning( "DailySnapshotJob: skipping {Abbrev} — no OAuth app registered", abbrev); continue; } var secret = await settings.GetAsync( Core.Services.SettingsService.InstanceOAuthSecretId(abbrev)); if (string.IsNullOrEmpty(secret)) { _logger.LogWarning( "DailySnapshotJob: skipping {Abbrev} — OAuth secret not found", abbrev); continue; } var client = await clientFactory.CreateAsync( instance.XiboUrl, oauthApp.ClientId, secret); var displays = await client.GetAllPagesAsync( (start, length) => client.GetDisplaysAsync(start, length, authorised: 1)); var screenCount = displays.Count; // ON CONFLICT DO NOTHING — protect against double-runs. // Check if a snapshot already exists for this instance + date. var exists = await db.ScreenSnapshots.AnyAsync( s => s.InstanceId == instance.Id && s.SnapshotDate == today, context.CancellationToken); if (!exists) { db.ScreenSnapshots.Add(new ScreenSnapshot { Id = Guid.NewGuid(), InstanceId = instance.Id, SnapshotDate = today, ScreenCount = screenCount, CreatedAt = DateTime.UtcNow, }); _logger.LogInformation( "DailySnapshotJob: {Abbrev} — {Count} authorised display(s)", abbrev, screenCount); } else { _logger.LogInformation( "DailySnapshotJob: {Abbrev} — snapshot already exists for {Date}, skipping", abbrev, today); } } catch (Exception ex) { // THIS DATA CANNOT BE RECOVERED — log prominently _logger.LogWarning(ex, "DailySnapshotJob: FAILED to capture snapshot for {Abbrev} on {Date}. " + "This data is permanently lost.", abbrev, today); } } await db.SaveChangesAsync(context.CancellationToken); } }