mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 07:13:12 +00:00
feat(work-orders): add Sync kill-switch and ops health coexistence endpoints
This commit is contained in:
parent
1074ed860b
commit
89cb520db9
9 changed files with 221 additions and 33 deletions
|
|
@ -1,32 +1,49 @@
|
||||||
using Amazon.DynamoDBv2;
|
using Amazon.DynamoDBv2;
|
||||||
using Amazon.DynamoDBv2.Model;
|
using Amazon.DynamoDBv2.Model;
|
||||||
|
using Api.SeaHavenIndustries.Options;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.Controllers
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
{
|
{
|
||||||
[Authorize]
|
[Authorize(Roles = "Admin")]
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class SyncController : Controller
|
public class SyncController : Controller
|
||||||
{
|
{
|
||||||
private readonly ApplicationDbContext _db;
|
private readonly ApplicationDbContext _db;
|
||||||
private readonly AmazonDynamoDBClient _dynamo;
|
private readonly AmazonDynamoDBClient _dynamo;
|
||||||
|
private readonly SyncOptions _syncOptions;
|
||||||
private const string WO_TABLE = "WorkOrders";
|
private const string WO_TABLE = "WorkOrders";
|
||||||
private const string COMMENTS_TABLE = "WorkOrderComments";
|
private const string COMMENTS_TABLE = "WorkOrderComments";
|
||||||
private const string VENDOR_REPLIES_TABLE = "VendorReplies";
|
private const string VENDOR_REPLIES_TABLE = "VendorReplies";
|
||||||
|
|
||||||
public SyncController(ApplicationDbContext db)
|
public SyncController(ApplicationDbContext db, IOptions<SyncOptions> syncOptions)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
|
_syncOptions = syncOptions.Value;
|
||||||
_dynamo = new AmazonDynamoDBClient(Amazon.RegionEndpoint.USEast1);
|
_dynamo = new AmazonDynamoDBClient(Amazon.RegionEndpoint.USEast1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private IActionResult? SyncDisabledResult()
|
||||||
|
{
|
||||||
|
if (_syncOptions.Enabled)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return StatusCode(StatusCodes.Status503ServiceUnavailable, new
|
||||||
|
{
|
||||||
|
message = "DynamoDB sync bridge is disabled. Use POST /api/workorders/ingest."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("WorkOrders")]
|
[HttpPost("WorkOrders")]
|
||||||
public async Task<IActionResult> SyncWorkOrders()
|
public async Task<IActionResult> SyncWorkOrders()
|
||||||
{
|
{
|
||||||
|
if (SyncDisabledResult() is { } disabled)
|
||||||
|
return disabled;
|
||||||
var synced = 0;
|
var synced = 0;
|
||||||
var created = 0;
|
var created = 0;
|
||||||
var updated = 0;
|
var updated = 0;
|
||||||
|
|
@ -62,12 +79,12 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
var existing = await _db.workOrders
|
var existing = await _db.workOrders
|
||||||
.FirstOrDefaultAsync(w => w.ExternalWorkOrderId == externalId);
|
.FirstOrDefaultAsync(w => w.ExternalWorkOrderId == externalId);
|
||||||
|
|
||||||
var locationId = await ResolveLocationId(
|
var (locationId, locationWasCreated) = await ResolveLocationId(
|
||||||
GetString(item, "site_code"),
|
GetString(item, "site_code"),
|
||||||
GetString(item, "building"),
|
GetString(item, "building"),
|
||||||
GetString(item, "address"));
|
GetString(item, "address"));
|
||||||
|
|
||||||
if (locationId.HasValue && !locationsCreated.Equals(0))
|
if (locationWasCreated)
|
||||||
locationsCreated++;
|
locationsCreated++;
|
||||||
|
|
||||||
if (existing == null)
|
if (existing == null)
|
||||||
|
|
@ -138,6 +155,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[HttpPost("Comments")]
|
[HttpPost("Comments")]
|
||||||
public async Task<IActionResult> SyncComments()
|
public async Task<IActionResult> SyncComments()
|
||||||
{
|
{
|
||||||
|
if (SyncDisabledResult() is { } disabled)
|
||||||
|
return disabled;
|
||||||
|
|
||||||
var synced = 0;
|
var synced = 0;
|
||||||
var created = 0;
|
var created = 0;
|
||||||
var skipped = 0;
|
var skipped = 0;
|
||||||
|
|
@ -212,6 +232,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[HttpPost("BackfillInternalWONumbers")]
|
[HttpPost("BackfillInternalWONumbers")]
|
||||||
public async Task<IActionResult> BackfillInternalWONumbers()
|
public async Task<IActionResult> BackfillInternalWONumbers()
|
||||||
{
|
{
|
||||||
|
if (SyncDisabledResult() is { } disabled)
|
||||||
|
return disabled;
|
||||||
|
|
||||||
var wosMissing = await _db.workOrders
|
var wosMissing = await _db.workOrders
|
||||||
.Where(w => w.InternalWONumber == null || w.InternalWONumber == "")
|
.Where(w => w.InternalWONumber == null || w.InternalWONumber == "")
|
||||||
.OrderBy(w => w.Id)
|
.OrderBy(w => w.Id)
|
||||||
|
|
@ -243,6 +266,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[HttpPost("BackfillDispatchNumbers")]
|
[HttpPost("BackfillDispatchNumbers")]
|
||||||
public async Task<IActionResult> BackfillDispatchNumbers()
|
public async Task<IActionResult> BackfillDispatchNumbers()
|
||||||
{
|
{
|
||||||
|
if (SyncDisabledResult() is { } disabled)
|
||||||
|
return disabled;
|
||||||
|
|
||||||
var missing = await _db.Dispatches
|
var missing = await _db.Dispatches
|
||||||
.Where(d => d.DispatchNumber == null || d.DispatchNumber == "")
|
.Where(d => d.DispatchNumber == null || d.DispatchNumber == "")
|
||||||
.OrderBy(d => d.Id)
|
.OrderBy(d => d.Id)
|
||||||
|
|
@ -270,6 +296,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[HttpPost("VendorReplies")]
|
[HttpPost("VendorReplies")]
|
||||||
public async Task<IActionResult> SyncVendorReplies()
|
public async Task<IActionResult> SyncVendorReplies()
|
||||||
{
|
{
|
||||||
|
if (SyncDisabledResult() is { } disabled)
|
||||||
|
return disabled;
|
||||||
|
|
||||||
var synced = 0;
|
var synced = 0;
|
||||||
var created = 0;
|
var created = 0;
|
||||||
var skipped = 0;
|
var skipped = 0;
|
||||||
|
|
@ -341,6 +370,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[HttpPost("BackfillCommentTypes")]
|
[HttpPost("BackfillCommentTypes")]
|
||||||
public async Task<IActionResult> BackfillCommentTypes()
|
public async Task<IActionResult> BackfillCommentTypes()
|
||||||
{
|
{
|
||||||
|
if (SyncDisabledResult() is { } disabled)
|
||||||
|
return disabled;
|
||||||
|
|
||||||
var updated = await _db.Database.ExecuteSqlRawAsync(
|
var updated = await _db.Database.ExecuteSqlRawAsync(
|
||||||
"UPDATE Comments SET CommentType = 'customer' WHERE ExternalCommentId IS NOT NULL AND (CommentType IS NULL OR CommentType = '')");
|
"UPDATE Comments SET CommentType = 'customer' WHERE ExternalCommentId IS NOT NULL AND (CommentType IS NULL OR CommentType = '')");
|
||||||
return Ok(new { updated });
|
return Ok(new { updated });
|
||||||
|
|
@ -349,6 +381,9 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
[HttpPost("All")]
|
[HttpPost("All")]
|
||||||
public async Task<IActionResult> SyncAll()
|
public async Task<IActionResult> SyncAll()
|
||||||
{
|
{
|
||||||
|
if (SyncDisabledResult() is { } disabled)
|
||||||
|
return disabled;
|
||||||
|
|
||||||
var woResult = await SyncWorkOrders() as OkObjectResult;
|
var woResult = await SyncWorkOrders() as OkObjectResult;
|
||||||
var commentResult = await SyncComments() as OkObjectResult;
|
var commentResult = await SyncComments() as OkObjectResult;
|
||||||
|
|
||||||
|
|
@ -359,17 +394,17 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<int?> ResolveLocationId(string? siteCode, string? building, string? address)
|
private async Task<(int? LocationId, bool Created)> ResolveLocationId(string? siteCode, string? building, string? address)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(siteCode) && string.IsNullOrWhiteSpace(building))
|
if (string.IsNullOrWhiteSpace(siteCode) && string.IsNullOrWhiteSpace(building))
|
||||||
return null;
|
return (null, false);
|
||||||
|
|
||||||
var matchCode = siteCode ?? building;
|
var matchCode = siteCode ?? building;
|
||||||
var existing = await _db.Locations
|
var existing = await _db.Locations
|
||||||
.FirstOrDefaultAsync(l => l.Name == matchCode || l.Title == matchCode);
|
.FirstOrDefaultAsync(l => l.Name == matchCode || l.Title == matchCode);
|
||||||
|
|
||||||
if (existing != null)
|
if (existing != null)
|
||||||
return existing.Id;
|
return (existing.Id, false);
|
||||||
|
|
||||||
var location = new Locations
|
var location = new Locations
|
||||||
{
|
{
|
||||||
|
|
@ -382,7 +417,7 @@ namespace Api.SeaHavenIndustries.Controllers
|
||||||
_db.Locations.Add(location);
|
_db.Locations.Add(location);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
return location.Id;
|
return (location.Id, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? GetString(Dictionary<string, AttributeValue> item, string key)
|
private static string? GetString(Dictionary<string, AttributeValue> item, string key)
|
||||||
|
|
|
||||||
29
Api.SeaHavenIndustries/Controllers/WorkOrderOpsController.cs
Normal file
29
Api.SeaHavenIndustries/Controllers/WorkOrderOpsController.cs
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace Api.SeaHavenIndustries.Controllers
|
||||||
|
{
|
||||||
|
/// <summary>Ops health and coexistence metrics (Fase 7).</summary>
|
||||||
|
[Authorize(Roles = "Admin")]
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/workorders/ops")]
|
||||||
|
public class WorkOrderOpsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IWorkOrderOpsHealthService _healthService;
|
||||||
|
|
||||||
|
public WorkOrderOpsController(IWorkOrderOpsHealthService healthService)
|
||||||
|
{
|
||||||
|
_healthService = healthService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("health")]
|
||||||
|
[ProducesResponseType(typeof(WorkOrderOpsHealthDto), StatusCodes.Status200OK)]
|
||||||
|
public async Task<ActionResult<WorkOrderOpsHealthDto>> GetHealth(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var health = await _healthService.GetHealthAsync(cancellationToken);
|
||||||
|
return Ok(health);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
using Api.SeaHavenIndustries.Options;
|
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using Api.SeaHavenIndustries.Options;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.HostedServices
|
namespace Api.SeaHavenIndustries.HostedServices
|
||||||
{
|
{
|
||||||
|
|
@ -33,8 +34,8 @@ namespace Api.SeaHavenIndustries.HostedServices
|
||||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
if (_lastRunDate != today)
|
if (_lastRunDate != today)
|
||||||
{
|
{
|
||||||
_lastRunDate = today;
|
if (await RunJobAsync(stoppingToken))
|
||||||
await RunJobAsync(stoppingToken);
|
_lastRunDate = today;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,7 +43,7 @@ namespace Api.SeaHavenIndustries.HostedServices
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RunJobAsync(CancellationToken stoppingToken)
|
private async Task<bool> RunJobAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -52,11 +53,13 @@ namespace Api.SeaHavenIndustries.HostedServices
|
||||||
|
|
||||||
_runState.LastPastDueCacheRunUtc = DateTime.UtcNow;
|
_runState.LastPastDueCacheRunUtc = DateTime.UtcNow;
|
||||||
_runState.LastPastDueCacheError = null;
|
_runState.LastPastDueCacheError = null;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
{
|
{
|
||||||
_runState.LastPastDueCacheError = ex.Message;
|
_runState.LastPastDueCacheError = ex.Message;
|
||||||
_logger.LogError(ex, "PastDue cache hosted job failed.");
|
_logger.LogError(ex, "PastDue cache hosted job failed.");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -67,8 +70,7 @@ namespace Api.SeaHavenIndustries.HostedServices
|
||||||
if (!TimeOnly.TryParse(_options.RunAtUtc, out var runAt))
|
if (!TimeOnly.TryParse(_options.RunAtUtc, out var runAt))
|
||||||
runAt = new TimeOnly(0, 10);
|
runAt = new TimeOnly(0, 10);
|
||||||
|
|
||||||
var current = TimeOnly.FromDateTime(now);
|
return TimeOnly.FromDateTime(now) >= runAt;
|
||||||
return current >= runAt && current < runAt.AddMinutes(1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
using Api.SeaHavenIndustries.Options;
|
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using SeaHaven.Services.Helpers;
|
using SeaHaven.Services.Helpers;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
using SeaHaven.Services.Interfaces;
|
using SeaHaven.Services.Interfaces;
|
||||||
|
using Api.SeaHavenIndustries.Options;
|
||||||
|
|
||||||
namespace Api.SeaHavenIndustries.HostedServices
|
namespace Api.SeaHavenIndustries.HostedServices
|
||||||
{
|
{
|
||||||
|
|
@ -34,8 +35,8 @@ namespace Api.SeaHavenIndustries.HostedServices
|
||||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||||
if (_lastRunDate != today)
|
if (_lastRunDate != today)
|
||||||
{
|
{
|
||||||
_lastRunDate = today;
|
if (await RunJobAsync(stoppingToken))
|
||||||
await RunJobAsync(stoppingToken);
|
_lastRunDate = today;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,7 +44,7 @@ namespace Api.SeaHavenIndustries.HostedServices
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RunJobAsync(CancellationToken stoppingToken)
|
private async Task<bool> RunJobAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -55,11 +56,13 @@ namespace Api.SeaHavenIndustries.HostedServices
|
||||||
|
|
||||||
_runState.LastWeekRolledRunUtc = DateTime.UtcNow;
|
_runState.LastWeekRolledRunUtc = DateTime.UtcNow;
|
||||||
_runState.LastWeekRolledError = null;
|
_runState.LastWeekRolledError = null;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
{
|
{
|
||||||
_runState.LastWeekRolledError = ex.Message;
|
_runState.LastWeekRolledError = ex.Message;
|
||||||
_logger.LogError(ex, "WeekRolled hosted job failed.");
|
_logger.LogError(ex, "WeekRolled hosted job failed.");
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,8 +78,7 @@ namespace Api.SeaHavenIndustries.HostedServices
|
||||||
if (!TimeOnly.TryParse(_options.RunAtUtc, out var runAt))
|
if (!TimeOnly.TryParse(_options.RunAtUtc, out var runAt))
|
||||||
runAt = new TimeOnly(0, 5);
|
runAt = new TimeOnly(0, 5);
|
||||||
|
|
||||||
var current = TimeOnly.FromDateTime(now);
|
return TimeOnly.FromDateTime(now) >= runAt;
|
||||||
return current >= runAt && current < runAt.AddMinutes(1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
12
Api.SeaHavenIndustries/Options/LegacyEndpointOptions.cs
Normal file
12
Api.SeaHavenIndustries/Options/LegacyEndpointOptions.cs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
namespace Api.SeaHavenIndustries.Options
|
||||||
|
{
|
||||||
|
public class LegacyEndpointOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "LegacyEndpoints";
|
||||||
|
|
||||||
|
public bool DeprecationEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>ISO date returned in Sunset header (e.g. 2026-12-31).</summary>
|
||||||
|
public string? SunsetDate { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
10
Api.SeaHavenIndustries/Options/SyncOptions.cs
Normal file
10
Api.SeaHavenIndustries/Options/SyncOptions.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
namespace Api.SeaHavenIndustries.Options
|
||||||
|
{
|
||||||
|
public class SyncOptions
|
||||||
|
{
|
||||||
|
public const string SectionName = "Sync";
|
||||||
|
|
||||||
|
/// <summary>When false, SyncController endpoints return 503 (post-Lambda cutover). Default false to avoid dual-run with ingest.</summary>
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
namespace Api.SeaHavenIndustries.Options
|
|
||||||
{
|
|
||||||
/// <summary>In-memory last-run timestamps for hosted domain jobs (ops health).</summary>
|
|
||||||
public class WorkOrderJobRunState
|
|
||||||
{
|
|
||||||
public DateTime? LastWeekRolledRunUtc { get; set; }
|
|
||||||
public DateTime? LastPastDueCacheRunUtc { get; set; }
|
|
||||||
public string? LastWeekRolledError { get; set; }
|
|
||||||
public string? LastPastDueCacheError { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
using Api.SeaHavenIndustries.Helper;
|
using Api.SeaHavenIndustries.Helper;
|
||||||
using Api.SeaHavenIndustries.HostedServices;
|
using Api.SeaHavenIndustries.HostedServices;
|
||||||
using Api.SeaHavenIndustries.Options;
|
using Api.SeaHavenIndustries.Options;
|
||||||
using Data.SeaHavenIndustries;
|
using Data.SeaHavenIndustries;
|
||||||
|
|
@ -11,6 +11,7 @@ using Microsoft.OpenApi.Models;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using SeaHaven.DataServices.DependencyInjection;
|
using SeaHaven.DataServices.DependencyInjection;
|
||||||
using SeaHaven.Services.DependencyInjection;
|
using SeaHaven.Services.DependencyInjection;
|
||||||
|
using SeaHaven.Services.Implementation;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
ConfigurationManager configuration = builder.Configuration;
|
ConfigurationManager configuration = builder.Configuration;
|
||||||
|
|
@ -55,9 +56,35 @@ builder.Services.AddBusinessServices();
|
||||||
|
|
||||||
builder.Services.Configure<WorkOrderJobsOptions>(
|
builder.Services.Configure<WorkOrderJobsOptions>(
|
||||||
builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName));
|
builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName));
|
||||||
builder.Services.AddSingleton<WorkOrderJobRunState>();
|
builder.Services.AddOptions<WorkOrderIngestOptions>()
|
||||||
|
.Bind(builder.Configuration.GetSection(WorkOrderIngestOptions.SectionName))
|
||||||
|
.Validate(
|
||||||
|
o => !o.Enabled
|
||||||
|
|| (!string.IsNullOrWhiteSpace(o.ApiKey)
|
||||||
|
&& !o.ApiKey.Contains("${", StringComparison.Ordinal)
|
||||||
|
&& o.ApiKey.Length >= 32),
|
||||||
|
"WorkOrderIngest:ApiKey must be a real secret (env/user-secrets, length >= 32) when ingest is enabled.")
|
||||||
|
.ValidateOnStart();
|
||||||
|
builder.Services.Configure<SyncOptions>(
|
||||||
|
builder.Configuration.GetSection(SyncOptions.SectionName));
|
||||||
|
builder.Services.Configure<LegacyEndpointOptions>(
|
||||||
|
builder.Configuration.GetSection(LegacyEndpointOptions.SectionName));
|
||||||
|
builder.Services.AddSingleton<WorkOrderJobRunStateAccessor>();
|
||||||
|
builder.Services.AddSingleton(sp => sp.GetRequiredService<WorkOrderJobRunStateAccessor>().State);
|
||||||
|
builder.Services.AddScoped<Api.SeaHavenIndustries.Filters.IngestApiKeyFilter>();
|
||||||
builder.Services.AddHostedService<WorkOrderWeekRolledHostedService>();
|
builder.Services.AddHostedService<WorkOrderWeekRolledHostedService>();
|
||||||
builder.Services.AddHostedService<PastDueCacheHostedService>();
|
builder.Services.AddHostedService<PastDueCacheHostedService>();
|
||||||
|
builder.Services.AddOptions<SeaHaven.Services.Implementation.WorkOrderOpsHealthOptions>()
|
||||||
|
.Configure<Microsoft.Extensions.Options.IOptions<SyncOptions>,
|
||||||
|
Microsoft.Extensions.Options.IOptions<WorkOrderIngestOptions>,
|
||||||
|
Microsoft.Extensions.Options.IOptions<LegacyEndpointOptions>>(
|
||||||
|
(health, sync, ingest, legacy) =>
|
||||||
|
{
|
||||||
|
health.SyncEnabled = sync.Value.Enabled;
|
||||||
|
health.IngestEnabled = ingest.Value.Enabled;
|
||||||
|
health.LegacyDeprecationEnabled = legacy.Value.DeprecationEnabled;
|
||||||
|
health.LegacySunsetDate = legacy.Value.SunsetDate;
|
||||||
|
});
|
||||||
|
|
||||||
builder.Services.AddAuthentication(options =>
|
builder.Services.AddAuthentication(options =>
|
||||||
{
|
{
|
||||||
|
|
@ -186,3 +213,4 @@ app.MapControllers();
|
||||||
//}
|
//}
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
using Data.SeaHavenIndustries;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SeaHaven.Services.DTOs;
|
||||||
|
using SeaHaven.Services.Interfaces;
|
||||||
|
|
||||||
|
namespace SeaHaven.Services.Implementation
|
||||||
|
{
|
||||||
|
public class WorkOrderOpsHealthService : IWorkOrderOpsHealthService
|
||||||
|
{
|
||||||
|
private readonly ApplicationDbContext _db;
|
||||||
|
private readonly WorkOrderJobRunStateAccessor _runState;
|
||||||
|
private readonly WorkOrderOpsHealthOptions _options;
|
||||||
|
|
||||||
|
public WorkOrderOpsHealthService(
|
||||||
|
ApplicationDbContext db,
|
||||||
|
WorkOrderJobRunStateAccessor runState,
|
||||||
|
IOptions<WorkOrderOpsHealthOptions> options)
|
||||||
|
{
|
||||||
|
_db = db;
|
||||||
|
_runState = runState;
|
||||||
|
_options = options.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkOrderOpsHealthDto> GetHealthAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var since = DateTime.UtcNow.AddHours(-24);
|
||||||
|
|
||||||
|
var syncRejected = await _db.WorkOrderAuditLogs
|
||||||
|
.CountAsync(l => l.Action == "SyncRejected" && l.CreatedAt >= since, cancellationToken);
|
||||||
|
|
||||||
|
var fieldLocks = await _db.WorkOrderFieldLocks.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
var state = _runState.State;
|
||||||
|
DateTime? ledgerWeekRolled = null;
|
||||||
|
if (await _db.WorkOrderWeekRolledLedgers.AnyAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
ledgerWeekRolled = await _db.WorkOrderWeekRolledLedgers
|
||||||
|
.MaxAsync(l => (DateTime?)l.ProcessedAt, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WorkOrderOpsHealthDto
|
||||||
|
{
|
||||||
|
LastWeekRolledRunUtc = state.LastWeekRolledRunUtc ?? ledgerWeekRolled,
|
||||||
|
LastPastDueCacheRunUtc = state.LastPastDueCacheRunUtc,
|
||||||
|
LastWeekRolledError = state.LastWeekRolledError,
|
||||||
|
LastPastDueCacheError = state.LastPastDueCacheError,
|
||||||
|
SyncRejectedLast24h = syncRejected,
|
||||||
|
FieldLockCount = fieldLocks,
|
||||||
|
SyncEnabled = _options.SyncEnabled,
|
||||||
|
IngestEnabled = _options.IngestEnabled,
|
||||||
|
LegacyDeprecationEnabled = _options.LegacyDeprecationEnabled,
|
||||||
|
LegacySunsetDate = _options.LegacySunsetDate,
|
||||||
|
CheckedAtUtc = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Options bridged from API configuration for health snapshots.</summary>
|
||||||
|
public class WorkOrderOpsHealthOptions
|
||||||
|
{
|
||||||
|
public bool SyncEnabled { get; set; } = true;
|
||||||
|
public bool IngestEnabled { get; set; } = true;
|
||||||
|
public bool LegacyDeprecationEnabled { get; set; }
|
||||||
|
public string? LegacySunsetDate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Shared in-memory job run timestamps (API hosted services + ops health).</summary>
|
||||||
|
public class WorkOrderJobRunStateAccessor
|
||||||
|
{
|
||||||
|
public WorkOrderJobRunState State { get; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WorkOrderJobRunState
|
||||||
|
{
|
||||||
|
public DateTime? LastWeekRolledRunUtc { get; set; }
|
||||||
|
public DateTime? LastPastDueCacheRunUtc { get; set; }
|
||||||
|
public string? LastWeekRolledError { get; set; }
|
||||||
|
public string? LastPastDueCacheError { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue