190 lines
8.0 KiB
C#
190 lines
8.0 KiB
C#
|
|
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;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// Updates the Xibo CMS screen limit and records a snapshot.
|
||
|
|
/// Handles <c>JobType = "update-screen-limit"</c>.
|
||
|
|
///
|
||
|
|
/// 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
|
||
|
|
/// </summary>
|
||
|
|
public sealed class UpdateScreenLimitPipeline : IProvisioningPipeline
|
||
|
|
{
|
||
|
|
public string HandlesJobType => "update-screen-limit";
|
||
|
|
|
||
|
|
private const int TotalSteps = 3;
|
||
|
|
|
||
|
|
private readonly IServiceProvider _services;
|
||
|
|
private readonly ILogger<UpdateScreenLimitPipeline> _logger;
|
||
|
|
|
||
|
|
public UpdateScreenLimitPipeline(
|
||
|
|
IServiceProvider services,
|
||
|
|
ILogger<UpdateScreenLimitPipeline> logger)
|
||
|
|
{
|
||
|
|
_services = services;
|
||
|
|
_logger = logger;
|
||
|
|
}
|
||
|
|
|
||
|
|
public async Task ExecuteAsync(Job job, CancellationToken ct)
|
||
|
|
{
|
||
|
|
await using var scope = _services.CreateAsyncScope();
|
||
|
|
var db = scope.ServiceProvider.GetRequiredService<OrchestratorDbContext>();
|
||
|
|
var hub = scope.ServiceProvider.GetRequiredService<IHubContext<FleetHub, IFleetClient>>();
|
||
|
|
var xiboFactory = scope.ServiceProvider.GetRequiredService<XiboClientFactory>();
|
||
|
|
var settings = scope.ServiceProvider.GetRequiredService<SettingsService>();
|
||
|
|
|
||
|
|
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<string, string>
|
||
|
|
{
|
||
|
|
["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<PipelineContext> 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<T>(Refit.IApiResponse<T> response)
|
||
|
|
{
|
||
|
|
if (!response.IsSuccessStatusCode)
|
||
|
|
throw new InvalidOperationException(
|
||
|
|
$"API call failed with status {response.StatusCode}: {response.Error?.Content}");
|
||
|
|
}
|
||
|
|
}
|