feat(work-orders): add Sync kill-switch and ops health coexistence endpoints

This commit is contained in:
Arthur Bassi 2026-07-13 13:22:12 -03:00
parent 1074ed860b
commit 89cb520db9
9 changed files with 221 additions and 33 deletions

View file

@ -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)

View 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);
}
}
}

View file

@ -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);
} }
} }
} }

View file

@ -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);
} }
} }
} }

View 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; }
}
}

View 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; }
}
}

View file

@ -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; }
}
}

View file

@ -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();

View file

@ -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; }
}
}