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.Model;
using Api.SeaHavenIndustries.Options;
using Data.SeaHavenIndustries;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Api.SeaHavenIndustries.Controllers
{
[Authorize]
[Authorize(Roles = "Admin")]
[ApiController]
[Route("api/[controller]")]
public class SyncController : Controller
{
private readonly ApplicationDbContext _db;
private readonly AmazonDynamoDBClient _dynamo;
private readonly SyncOptions _syncOptions;
private const string WO_TABLE = "WorkOrders";
private const string COMMENTS_TABLE = "WorkOrderComments";
private const string VENDOR_REPLIES_TABLE = "VendorReplies";
public SyncController(ApplicationDbContext db)
public SyncController(ApplicationDbContext db, IOptions<SyncOptions> syncOptions)
{
_db = db;
_syncOptions = syncOptions.Value;
_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")]
public async Task<IActionResult> SyncWorkOrders()
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var synced = 0;
var created = 0;
var updated = 0;
@ -62,12 +79,12 @@ namespace Api.SeaHavenIndustries.Controllers
var existing = await _db.workOrders
.FirstOrDefaultAsync(w => w.ExternalWorkOrderId == externalId);
var locationId = await ResolveLocationId(
var (locationId, locationWasCreated) = await ResolveLocationId(
GetString(item, "site_code"),
GetString(item, "building"),
GetString(item, "address"));
if (locationId.HasValue && !locationsCreated.Equals(0))
if (locationWasCreated)
locationsCreated++;
if (existing == null)
@ -138,6 +155,9 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("Comments")]
public async Task<IActionResult> SyncComments()
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var synced = 0;
var created = 0;
var skipped = 0;
@ -212,6 +232,9 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("BackfillInternalWONumbers")]
public async Task<IActionResult> BackfillInternalWONumbers()
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var wosMissing = await _db.workOrders
.Where(w => w.InternalWONumber == null || w.InternalWONumber == "")
.OrderBy(w => w.Id)
@ -243,6 +266,9 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("BackfillDispatchNumbers")]
public async Task<IActionResult> BackfillDispatchNumbers()
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var missing = await _db.Dispatches
.Where(d => d.DispatchNumber == null || d.DispatchNumber == "")
.OrderBy(d => d.Id)
@ -270,6 +296,9 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("VendorReplies")]
public async Task<IActionResult> SyncVendorReplies()
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var synced = 0;
var created = 0;
var skipped = 0;
@ -341,6 +370,9 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("BackfillCommentTypes")]
public async Task<IActionResult> BackfillCommentTypes()
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var updated = await _db.Database.ExecuteSqlRawAsync(
"UPDATE Comments SET CommentType = 'customer' WHERE ExternalCommentId IS NOT NULL AND (CommentType IS NULL OR CommentType = '')");
return Ok(new { updated });
@ -349,6 +381,9 @@ namespace Api.SeaHavenIndustries.Controllers
[HttpPost("All")]
public async Task<IActionResult> SyncAll()
{
if (SyncDisabledResult() is { } disabled)
return disabled;
var woResult = await SyncWorkOrders() 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))
return null;
return (null, false);
var matchCode = siteCode ?? building;
var existing = await _db.Locations
.FirstOrDefaultAsync(l => l.Name == matchCode || l.Title == matchCode);
if (existing != null)
return existing.Id;
return (existing.Id, false);
var location = new Locations
{
@ -382,7 +417,7 @@ namespace Api.SeaHavenIndustries.Controllers
_db.Locations.Add(location);
await _db.SaveChangesAsync();
return location.Id;
return (location.Id, true);
}
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 SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Api.SeaHavenIndustries.Options;
namespace Api.SeaHavenIndustries.HostedServices
{
@ -33,8 +34,8 @@ namespace Api.SeaHavenIndustries.HostedServices
var today = DateOnly.FromDateTime(DateTime.UtcNow);
if (_lastRunDate != today)
{
_lastRunDate = today;
await RunJobAsync(stoppingToken);
if (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
{
@ -52,11 +53,13 @@ namespace Api.SeaHavenIndustries.HostedServices
_runState.LastPastDueCacheRunUtc = DateTime.UtcNow;
_runState.LastPastDueCacheError = null;
return true;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_runState.LastPastDueCacheError = ex.Message;
_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))
runAt = new TimeOnly(0, 10);
var current = TimeOnly.FromDateTime(now);
return current >= runAt && current < runAt.AddMinutes(1);
return TimeOnly.FromDateTime(now) >= runAt;
}
}
}

View file

@ -1,7 +1,8 @@
using Api.SeaHavenIndustries.Options;
using Microsoft.Extensions.Options;
using SeaHaven.Services.Helpers;
using SeaHaven.Services.Implementation;
using SeaHaven.Services.Interfaces;
using Api.SeaHavenIndustries.Options;
namespace Api.SeaHavenIndustries.HostedServices
{
@ -34,8 +35,8 @@ namespace Api.SeaHavenIndustries.HostedServices
var today = DateOnly.FromDateTime(DateTime.UtcNow);
if (_lastRunDate != today)
{
_lastRunDate = today;
await RunJobAsync(stoppingToken);
if (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
{
@ -55,11 +56,13 @@ namespace Api.SeaHavenIndustries.HostedServices
_runState.LastWeekRolledRunUtc = DateTime.UtcNow;
_runState.LastWeekRolledError = null;
return true;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_runState.LastWeekRolledError = ex.Message;
_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))
runAt = new TimeOnly(0, 5);
var current = TimeOnly.FromDateTime(now);
return current >= runAt && current < runAt.AddMinutes(1);
return TimeOnly.FromDateTime(now) >= runAt;
}
}
}

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.Options;
using Data.SeaHavenIndustries;
@ -11,6 +11,7 @@ using Microsoft.OpenApi.Models;
using System.Text;
using SeaHaven.DataServices.DependencyInjection;
using SeaHaven.Services.DependencyInjection;
using SeaHaven.Services.Implementation;
var builder = WebApplication.CreateBuilder(args);
ConfigurationManager configuration = builder.Configuration;
@ -55,9 +56,35 @@ builder.Services.AddBusinessServices();
builder.Services.Configure<WorkOrderJobsOptions>(
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<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 =>
{
@ -186,3 +213,4 @@ app.MapControllers();
//}
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; }
}
}