diff --git a/Api.SeaHavenIndustries/Controllers/SyncController.cs b/Api.SeaHavenIndustries/Controllers/SyncController.cs index 5d7f778..d246273 100644 --- a/Api.SeaHavenIndustries/Controllers/SyncController.cs +++ b/Api.SeaHavenIndustries/Controllers/SyncController.cs @@ -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) { _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 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 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 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 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 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 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 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 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 item, string key) diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderOpsController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderOpsController.cs new file mode 100644 index 0000000..dfbff62 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderOpsController.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers +{ + /// Ops health and coexistence metrics (Fase 7). + [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> GetHealth(CancellationToken cancellationToken) + { + var health = await _healthService.GetHealthAsync(cancellationToken); + return Ok(health); + } + } +} diff --git a/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs b/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs index 67b8cba..6daf1f7 100644 --- a/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs +++ b/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs @@ -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 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; } } } diff --git a/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs b/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs index 7caf0a2..dc5a309 100644 --- a/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs +++ b/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs @@ -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 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; } } } diff --git a/Api.SeaHavenIndustries/Options/LegacyEndpointOptions.cs b/Api.SeaHavenIndustries/Options/LegacyEndpointOptions.cs new file mode 100644 index 0000000..ffd01ef --- /dev/null +++ b/Api.SeaHavenIndustries/Options/LegacyEndpointOptions.cs @@ -0,0 +1,12 @@ +namespace Api.SeaHavenIndustries.Options +{ + public class LegacyEndpointOptions + { + public const string SectionName = "LegacyEndpoints"; + + public bool DeprecationEnabled { get; set; } + + /// ISO date returned in Sunset header (e.g. 2026-12-31). + public string? SunsetDate { get; set; } + } +} diff --git a/Api.SeaHavenIndustries/Options/SyncOptions.cs b/Api.SeaHavenIndustries/Options/SyncOptions.cs new file mode 100644 index 0000000..230e16c --- /dev/null +++ b/Api.SeaHavenIndustries/Options/SyncOptions.cs @@ -0,0 +1,10 @@ +namespace Api.SeaHavenIndustries.Options +{ + public class SyncOptions + { + public const string SectionName = "Sync"; + + /// When false, SyncController endpoints return 503 (post-Lambda cutover). Default false to avoid dual-run with ingest. + public bool Enabled { get; set; } + } +} diff --git a/Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs b/Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs deleted file mode 100644 index dd95de5..0000000 --- a/Api.SeaHavenIndustries/Options/WorkOrderJobRunState.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Api.SeaHavenIndustries.Options -{ - /// In-memory last-run timestamps for hosted domain jobs (ops health). - public class WorkOrderJobRunState - { - public DateTime? LastWeekRolledRunUtc { get; set; } - public DateTime? LastPastDueCacheRunUtc { get; set; } - public string? LastWeekRolledError { get; set; } - public string? LastPastDueCacheError { get; set; } - } -} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index 922582e..e897f24 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -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( builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName)); -builder.Services.AddSingleton(); +builder.Services.AddOptions() + .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( + builder.Configuration.GetSection(SyncOptions.SectionName)); +builder.Services.Configure( + builder.Configuration.GetSection(LegacyEndpointOptions.SectionName)); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService().State); +builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddOptions() + .Configure, + Microsoft.Extensions.Options.IOptions, + Microsoft.Extensions.Options.IOptions>( + (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(); + diff --git a/SeaHaven.Services/Implementation/WorkOrderOpsHealthService.cs b/SeaHaven.Services/Implementation/WorkOrderOpsHealthService.cs new file mode 100644 index 0000000..11656ed --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderOpsHealthService.cs @@ -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 options) + { + _db = db; + _runState = runState; + _options = options.Value; + } + + public async Task 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 + }; + } + } + + /// Options bridged from API configuration for health snapshots. + 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; } + } + + /// Shared in-memory job run timestamps (API hosted services + ops health). + 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; } + } +}