using System.Text.Json; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using OTSSignsOrchestrator.Server.Clients; using OTSSignsOrchestrator.Server.Data; using OTSSignsOrchestrator.Server.Data.Entities; using OTSSignsOrchestrator.Server.Hubs; using OTSSignsOrchestrator.Core.Services; namespace OTSSignsOrchestrator.Server.Workers; /// /// Updates the Xibo CMS screen limit and records a snapshot. /// Handles JobType = "update-screen-limit". /// /// Steps: /// 1. update-settings — PUT /api/settings with new MAX_LICENSED_DISPLAYS /// 2. update-snapshot — Record new screen count in ScreenSnapshots for today /// 3. audit-log — Append-only AuditLog entry /// public sealed class UpdateScreenLimitPipeline : IProvisioningPipeline { public string HandlesJobType => "update-screen-limit"; private const int TotalSteps = 3; private readonly IServiceProvider _services; private readonly ILogger _logger; public UpdateScreenLimitPipeline( IServiceProvider services, ILogger logger) { _services = services; _logger = logger; } public async Task ExecuteAsync(Job job, CancellationToken ct) { await using var scope = _services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var hub = scope.ServiceProvider.GetRequiredService>(); var xiboFactory = scope.ServiceProvider.GetRequiredService(); var settings = scope.ServiceProvider.GetRequiredService(); var ctx = await BuildContextAsync(job, db, ct); var runner = new StepRunner(db, hub, _logger, job.Id, TotalSteps); var abbrev = ctx.Abbreviation; // Parse newScreenCount from Job.Parameters JSON var newScreenCount = ParseScreenCount(ctx.ParametersJson) ?? throw new InvalidOperationException( "Job.Parameters must contain 'newScreenCount' (integer) for update-screen-limit."); // Get Xibo API client via XiboClientFactory var oauthReg = await db.OauthAppRegistries .Where(r => r.InstanceId == ctx.InstanceId) .OrderByDescending(r => r.CreatedAt) .FirstOrDefaultAsync(ct) ?? throw new InvalidOperationException( $"No OauthAppRegistry found for instance {ctx.InstanceId}."); var oauthSecret = await settings.GetAsync(SettingsService.InstanceOAuthSecretId(abbrev)) ?? throw new InvalidOperationException( $"OAuth secret not found in settings for instance '{abbrev}'."); var xiboClient = await xiboFactory.CreateAsync(ctx.InstanceUrl, oauthReg.ClientId, oauthSecret); // ── Step 1: update-settings ───────────────────────────────────────── await runner.RunAsync("update-settings", async () => { var response = await xiboClient.UpdateSettingsAsync(new UpdateSettingsRequest( new Dictionary { ["MAX_LICENSED_DISPLAYS"] = newScreenCount.ToString(), })); EnsureSuccess(response); return $"Xibo MAX_LICENSED_DISPLAYS updated to {newScreenCount} for {ctx.InstanceUrl}."; }, ct); // ── Step 2: update-snapshot ───────────────────────────────────────── await runner.RunAsync("update-snapshot", async () => { var today = DateOnly.FromDateTime(DateTime.UtcNow); // Upsert: if a snapshot already exists for today, update it; otherwise insert var existing = await db.ScreenSnapshots .FirstOrDefaultAsync(s => s.InstanceId == ctx.InstanceId && s.SnapshotDate == today, ct); if (existing is not null) { existing.ScreenCount = newScreenCount; } else { db.ScreenSnapshots.Add(new ScreenSnapshot { Id = Guid.NewGuid(), InstanceId = ctx.InstanceId, SnapshotDate = today, ScreenCount = newScreenCount, CreatedAt = DateTime.UtcNow, }); } // Also update Customer.ScreenCount to reflect the new limit var customer = await db.Customers.FirstOrDefaultAsync(c => c.Id == ctx.CustomerId, ct); if (customer is not null) customer.ScreenCount = newScreenCount; await db.SaveChangesAsync(ct); return $"ScreenSnapshot recorded for {today}: {newScreenCount} screens."; }, ct); // ── Step 3: audit-log ─────────────────────────────────────────────── await runner.RunAsync("audit-log", async () => { db.AuditLogs.Add(new AuditLog { Id = Guid.NewGuid(), InstanceId = ctx.InstanceId, Actor = "system/stripe-webhook", Action = "update-screen-limit", Target = $"xibo-{abbrev}", Outcome = "success", Detail = $"Screen limit updated to {newScreenCount}. Job {job.Id}.", OccurredAt = DateTime.UtcNow, }); await db.SaveChangesAsync(ct); return "AuditLog entry written for screen limit update."; }, ct); _logger.LogInformation( "UpdateScreenLimitPipeline completed for job {JobId} (abbrev={Abbrev}, screens={Count})", job.Id, abbrev, newScreenCount); } // ───────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────── private static int? ParseScreenCount(string? parametersJson) { if (string.IsNullOrEmpty(parametersJson)) return null; using var doc = JsonDocument.Parse(parametersJson); if (doc.RootElement.TryGetProperty("newScreenCount", out var prop) && prop.TryGetInt32(out var count)) return count; return null; } private static async Task BuildContextAsync(Job job, OrchestratorDbContext db, CancellationToken ct) { var customer = await db.Customers .Include(c => c.Instances) .FirstOrDefaultAsync(c => c.Id == job.CustomerId, ct) ?? throw new InvalidOperationException($"Customer {job.CustomerId} not found for job {job.Id}."); var instance = customer.Instances.FirstOrDefault() ?? throw new InvalidOperationException($"No instance found for customer {job.CustomerId}."); var abbrev = customer.Abbreviation.ToLowerInvariant(); return new PipelineContext { JobId = job.Id, CustomerId = customer.Id, InstanceId = instance.Id, Abbreviation = abbrev, CompanyName = customer.CompanyName, AdminEmail = customer.AdminEmail, AdminFirstName = customer.AdminFirstName, InstanceUrl = instance.XiboUrl, DockerStackName = instance.DockerStackName, ParametersJson = job.Parameters, }; } private static void EnsureSuccess(Refit.IApiResponse response) { if (!response.IsSuccessStatusCode) throw new InvalidOperationException( $"API call failed with status {response.StatusCode}: {response.Error?.Content}"); } }