diff --git a/.env.example b/.env.example index cb07e57..db65d8b 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,11 @@ -# Sea Haven Industries — shoc-backend required configuration +# Sea Haven Industries ÔÇö shoc-backend required configuration # # This file documents the secrets that used to be hardcoded in appsettings*.json # and source files. Copy the values into one of the supported configuration sources; # do NOT commit real values. # # .NET resolves configuration in this order (later wins): -# 1. appsettings.json / appsettings.{Environment}.json (committed — placeholders only) +# 1. appsettings.json / appsettings.{Environment}.json (committed ÔÇö placeholders only) # 2. User Secrets (local dev): dotnet user-secrets set "Key:Sub" "value" # 3. Environment variables (use "__" as the section separator) # @@ -32,3 +32,16 @@ GoogleMaps__ApiKey=AIzaSyXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX # AWS_ACCESS_KEY_ID= # AWS_SECRET_ACCESS_KEY= # AWS_REGION=us-east-2 + +# --- Work Order ingest (Lambda cutover) --- +# Leave Enabled=false until ApiKey is a real secret (min 32 chars). Do not use appsettings placeholders. +WorkOrderIngest__Enabled=false +WorkOrderIngest__ApiKey= + +# --- Dynamo sync bridge (keep false after Lambda cutover; avoid dual-run with ingest) --- +Sync__Enabled=true + +# --- Legacy endpoint deprecation headers --- +LegacyEndpoints__DeprecationEnabled=false +LegacyEndpoints__SunsetDate=2026-12-31 + diff --git a/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs b/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs new file mode 100644 index 0000000..5074c22 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/LocationControllerSitesTests.cs @@ -0,0 +1,109 @@ +using Api.SeaHavenIndustries.Controllers; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Moq; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Validation; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class LocationControllerSitesTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static LocationController NewController(ApplicationDbContext ctx) + { + var dataService = new LocationDataService(ctx); + var service = new LocationService( + dataService, + Mock.Of(), + Mock.Of()); + + var controller = new LocationController(service, dataService) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + } + }; + + return controller; + } + + private static async Task SeedSitesAsync(ApplicationDbContext ctx) + { + ctx.Locations.AddRange( + new Locations { Id = 1, Name = "STL8", City = "St. Louis", State = "MO" }, + new Locations { Id = 2, Name = "BK5", City = "Dallas", State = "TX" }, + new Locations { Id = 3, Name = "IND9", City = "Indianapolis", State = "IN" }, + new Locations { Id = 4, Name = "DFW8", City = "Dallas", State = "TX" }); + await ctx.SaveChangesAsync(); + } + + [Fact] + public async Task GetSites_ReturnsIdCodeCityState_OrderedByCode() + { + await using var ctx = NewContext(); + await SeedSitesAsync(ctx); + var controller = NewController(ctx); + + var result = await controller.GetSites(); + + var ok = result.Should().BeOfType().Subject; + var data = ok.Value.Should().BeAssignableTo>().Subject; + + data.Should().HaveCount(4); + data.Select(s => s.Code).Should().ContainInOrder("BK5", "DFW8", "IND9", "STL8"); + data[0].Should().BeEquivalentTo(new SiteOptionDTO + { + Id = 2, + Code = "BK5", + City = "Dallas", + State = "TX" + }); + } + + [Fact] + public async Task GetSites_FiltersByCode() + { + await using var ctx = NewContext(); + await SeedSitesAsync(ctx); + var controller = NewController(ctx); + + var result = await controller.GetSites("bk5"); + + var ok = result.Should().BeOfType().Subject; + var data = ok.Value.Should().BeAssignableTo>().Subject; + + data.Should().ContainSingle(); + data[0].Code.Should().Be("BK5"); + } + + [Fact] + public async Task GetSites_FiltersByCity() + { + await using var ctx = NewContext(); + await SeedSitesAsync(ctx); + var controller = NewController(ctx); + + var result = await controller.GetSites("Dallas"); + + var ok = result.Should().BeOfType().Subject; + var data = ok.Value.Should().BeAssignableTo>().Subject; + + data.Should().HaveCount(2); + data.Select(s => s.Code).Should().BeEquivalentTo("BK5", "DFW8"); + } +} diff --git a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs new file mode 100644 index 0000000..ef2ac67 --- /dev/null +++ b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs @@ -0,0 +1,163 @@ +using Api.SeaHavenIndustries.Controllers; +using Api.SeaHavenIndustries.DTOs; +using Data.SeaHavenIndustries; +using FluentAssertions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Moq; +using SeaHaven.DataServices.Implementation; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Xunit; + +namespace Api.SeaHavenIndustries.Tests; + +public class WorkOrderControllerSearchTests +{ + private static ApplicationDbContext NewContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + private static UserManager MockUserManager() + { + var store = new Mock>(); + return new Mock>( + store.Object, null!, null!, null!, null!, null!, null!, null!, null!).Object; + } + + private static WorkOrderController NewController( + ApplicationDbContext ctx, + IWorkOrderAdvancedSearchService advancedSearch) + { + var controller = new WorkOrderController( + MockUserManager(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + ctx, + Mock.Of(), + Mock.Of(), + null!, + null!, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + advancedSearch, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of()); + + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new System.Security.Claims.ClaimsPrincipal( + new System.Security.Claims.ClaimsIdentity( + new[] + { + new System.Security.Claims.Claim( + System.Security.Claims.ClaimTypes.NameIdentifier, + "test-user") + }, + "Test")) + } + }; + + return controller; + } + + [Fact] + public async Task SearchBoard_WhenCustomPresetMissingDates_ReturnsBadRequest() + { + using var ctx = NewContext(); + var advancedSearch = new WorkOrderAdvancedSearchService( + Mock.Of()); + var controller = NewController(ctx, advancedSearch); + + var result = await controller.SearchBoard(new WorkOrderAdvancedSearchQueryDto + { + DatePreset = WorkOrderAdvancedSearchDatePreset.Custom + }); + + var badRequest = result.Should().BeOfType().Subject; + var body = badRequest.Value.Should().BeOfType().Subject; + body.Status.Should().Be("Error"); + body.Message.Should().Contain("dateFrom"); + } + + [Fact] + public async Task GetBoard_WhenWeekEndBeforeWeekStart_ReturnsBadRequest() + { + using var ctx = NewContext(); + var boardService = new WorkOrderBoardService(new WorkOrderBoardDataService(ctx)); + var controller = NewController(ctx, Mock.Of()); + // Replace board service - controller uses injected mock. Need different approach. + + // Use real board service via manual controller setup + var advancedSearch = Mock.Of(); + var controllerWithBoard = new WorkOrderController( + MockUserManager(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + ctx, + Mock.Of(), + Mock.Of(), + null!, + null!, + Mock.Of(), + boardService, + Mock.Of(), + Mock.Of(), + Mock.Of(), + advancedSearch, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of()); + + controllerWithBoard.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new System.Security.Claims.ClaimsPrincipal( + new System.Security.Claims.ClaimsIdentity( + new[] { new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, "test-user") }, + "Test")) + } + }; + + var result = await controllerWithBoard.GetBoard( + new DateOnly(2026, 6, 22), + new DateOnly(2026, 6, 15)); + + var badRequest = result.Should().BeOfType().Subject; + var body = badRequest.Value.Should().BeOfType().Subject; + body.Status.Should().Be("Error"); + body.Message.Should().Contain("weekEnd"); + } +} diff --git a/Api.SeaHavenIndustries/Controllers/LocationController.cs b/Api.SeaHavenIndustries/Controllers/LocationController.cs index 4bfba79..3d671b2 100644 --- a/Api.SeaHavenIndustries/Controllers/LocationController.cs +++ b/Api.SeaHavenIndustries/Controllers/LocationController.cs @@ -26,6 +26,13 @@ namespace Api.SeaHavenIndustries.Controllers _locationDataService = locationDataService; } + [HttpGet("sites")] + public async Task GetSites(string? search = null) + { + var data = await _locationService.GetSiteOptionsAsync(search); + return Ok(data); + } + [HttpGet("GetLocationList")] public async Task GetLocationList(string? search = "", int page = 1, int pageSize = 10) { 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/VendorController.cs b/Api.SeaHavenIndustries/Controllers/VendorController.cs index 9f6afc7..9c25ceb 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorController.cs @@ -174,6 +174,7 @@ namespace Api.SeaHavenIndustries.Controllers { v.Id, v.CompanyName, + v.ContactName, v.TradeSpecialties, v.Address, v.City, @@ -191,6 +192,7 @@ namespace Api.SeaHavenIndustries.Controllers { v.Id, v.CompanyName, + v.ContactName, v.TradeSpecialties, address = string.Join(", ", addr), distanceMiles = distance.HasValue ? Math.Round(distance.Value, 1) : (double?)null diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs index 57eb4b8..cedc42f 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -17,11 +17,12 @@ using System.Security.Claims; using static System.Runtime.InteropServices.JavaScript.JSType; using SeaHaven.Services.Interfaces; using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; using SeaHaven.DataServices.Interfaces; namespace Api.SeaHavenIndustries.Controllers { - // [Authorize] + [Authorize] [ApiController] [Route("api/WorkOrder")] [Route("api/workorders")] @@ -42,6 +43,15 @@ namespace Api.SeaHavenIndustries.Controllers private readonly Helper.SendMessage _sendMessage; private readonly Helper.VendorPortalTokenService _vendorTokens; private readonly IConfiguration _config; + private readonly IWorkOrderBoardService _workOrderBoardService; + private readonly IWorkOrderBoardUpdateService _boardUpdateService; + private readonly IWorkOrderBoardCreateService _boardCreateService; + private readonly IWorkOrderBoardCancelService _boardCancelService; + private readonly IWorkOrderAdvancedSearchService _advancedSearchService; + private readonly IWorkOrderDetailService _workOrderDetailService; + private readonly IWorkOrderCommentService _workOrderCommentService; + private readonly IWorkOrderCompletionService _workOrderCompletionService; + private readonly IWorkOrderMediaService _workOrderMediaService; public WorkOrderController( UserManager userManager, @@ -58,7 +68,16 @@ namespace Api.SeaHavenIndustries.Controllers IHttpContextAccessor httpContext, Helper.SendMessage sendMessage, Helper.VendorPortalTokenService vendorTokens, - IConfiguration config) + IConfiguration config, + IWorkOrderBoardService workOrderBoardService, + IWorkOrderBoardUpdateService boardUpdateService, + IWorkOrderBoardCreateService boardCreateService, + IWorkOrderBoardCancelService boardCancelService, + IWorkOrderAdvancedSearchService advancedSearchService, + IWorkOrderDetailService workOrderDetailService, + IWorkOrderCommentService workOrderCommentService, + IWorkOrderCompletionService workOrderCompletionService, + IWorkOrderMediaService workOrderMediaService) { _userManager = userManager; _workOrderService = workOrderService; @@ -75,7 +94,390 @@ namespace Api.SeaHavenIndustries.Controllers _sendMessage = sendMessage; _vendorTokens = vendorTokens; _config = config; + _workOrderBoardService = workOrderBoardService; + _boardUpdateService = boardUpdateService; + _boardCreateService = boardCreateService; + _boardCancelService = boardCancelService; + _advancedSearchService = advancedSearchService; + _workOrderDetailService = workOrderDetailService; + _workOrderCommentService = workOrderCommentService; + _workOrderCompletionService = workOrderCompletionService; + _workOrderMediaService = workOrderMediaService; } + + [HttpGet("board")] + public async Task GetBoard( + [FromQuery] DateOnly weekStart, + [FromQuery] DateOnly? weekEnd = null, + [FromQuery] List? dispatchers = null, + [FromQuery] bool myWorkOrders = false, + [FromQuery] List? types = null, + [FromQuery] string? search = null) + { + try + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var query = new WorkOrderBoardQueryDto + { + WeekStart = weekStart, + WeekEnd = weekEnd, + Dispatchers = dispatchers, + MyWorkOrders = myWorkOrders, + Types = types, + Search = search + }; + + var result = await _workOrderBoardService.GetBoardAsync(query, userId); + return Ok(result); + } + catch (ArgumentException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } + } + + [HttpGet("board/search")] + public async Task SearchBoard([FromQuery] WorkOrderAdvancedSearchQueryDto query) + { + try + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var result = await _advancedSearchService.SearchAsync(query, userId); + return Ok(result); + } + catch (ArgumentException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } + } + + [HttpGet("lookups/dispatchers")] + public async Task GetDispatcherLookups() + { + var result = await _workOrderBoardService.GetDispatcherLookupsAsync(); + return Ok(result); + } + + [HttpPatch("{id:int}/board")] + public async Task PatchBoardField(int id, [FromBody] WorkOrderBoardPatchRequestDto request) + { + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var row = await _boardUpdateService.PatchFieldAsync(id, request, actorId); + return Ok(row); + } + catch (WorkOrderBoardConcurrencyException ex) + { + return Conflict(new WorkOrderBoardConflictDto + { + CurrentState = ex.CurrentState + }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + catch (ArgumentException ex) + { + return BadRequest(new Response { Status = "Error", Message = ex.Message }); + } + } + + [HttpPost("board")] + public async Task CreateBoardWorkOrder([FromBody] WorkOrderBoardCreateRequestDto request) + { + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var row = await _boardCreateService.CreateAsync(request, actorId); + return Ok(row); + } + catch (ValidationException vex) + { + var errors = string.Join(", ", vex.Errors.Select(e => e.ErrorMessage)); + return BadRequest(new Response { Status = "Validation Error", Message = errors }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "DuplicateWoNumber") + { + return Conflict(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + } + + [HttpPost("{id:int}/cancel")] + public async Task CancelBoardWorkOrder(int id) + { + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var row = await _boardCancelService.CancelAsync(id, actorId); + return Ok(row); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto + { + Code = ex.Code, + Message = ex.Message + }); + } + } + + [HttpGet("{id:int}/detail")] + public async Task GetDetail(int id) + { + var detail = await _workOrderDetailService.GetDetailAsync(id); + if (detail == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(detail); + } + + [HttpGet("{id:int}/audit")] + public async Task GetAudit(int id, [FromQuery] int limit = 50) + { + var audit = await _workOrderDetailService.GetAuditAsync(id, limit); + if (audit == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(audit); + } + + [HttpGet("{id:int}/comments")] + public async Task GetBoardComments(int id) + { + var comments = await _workOrderCommentService.GetCommentsAsync(id); + if (comments == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(comments); + } + + [HttpPost("{id:int}/comments")] + public async Task AddBoardComment(int id, [FromBody] WorkOrderCommentCreateDto request) + { + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var comment = await _workOrderCommentService.AddCommentAsync(id, request, actorId); + return Ok(comment); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [HttpPatch("{id:int}/comments/{commentId:int}")] + public async Task UpdateBoardComment( + int id, + int commentId, + [FromBody] WorkOrderCommentCreateDto request) + { + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var isAdmin = User.IsInRole("Admin"); + var comment = await _workOrderCommentService.UpdateCommentAsync( + id, commentId, request, actorId, isAdmin); + return Ok(comment); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "Forbidden") + { + return StatusCode(StatusCodes.Status403Forbidden, + new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [HttpGet("completion-templates")] + public async Task GetCompletionTemplates( + [FromQuery] string? serviceKey = null, + [FromQuery] WorkOrderType? workOrderType = null) + { + var templates = await _workOrderCompletionService.GetTemplatesAsync(serviceKey, workOrderType); + return Ok(templates); + } + + [HttpGet("completion-templates/{id:int}")] + public async Task GetCompletionTemplate(int id) + { + var template = await _workOrderCompletionService.GetTemplateByIdAsync(id); + if (template == null) + return NotFound(new Response { Status = "Error", Message = "Template not found." }); + return Ok(template); + } + + [Authorize(Roles = "Admin")] + [HttpPost("completion-templates")] + public async Task CreateCompletionTemplate([FromBody] CompletionDocTemplateCreateDto request) + { + try + { + var created = await _workOrderCompletionService.CreateTemplateAsync(request); + return Ok(created); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [Authorize(Roles = "Admin")] + [HttpPut("completion-templates/{id:int}")] + public async Task UpdateCompletionTemplate(int id, [FromBody] CompletionDocTemplateCreateDto request) + { + try + { + var updated = await _workOrderCompletionService.UpdateTemplateAsync(id, request); + return Ok(updated); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [Authorize(Roles = "Admin")] + [HttpDelete("completion-templates/{id:int}")] + public async Task DeleteCompletionTemplate(int id) + { + try + { + await _workOrderCompletionService.DeleteTemplateAsync(id); + return NoContent(); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [HttpPost("{id:int}/completion-doc")] + [RequestSizeLimit(30_000_000)] + public async Task UploadCompletionDoc( + int id, + [FromForm] IFormFile file, + [FromForm] string? signOffName = null, + [FromForm] string? signOffSignature = null, + [FromForm] string? workOrderVersion = null) + { + if (file == null || file.Length == 0) + return BadRequest(new Response { Status = "Error", Message = "file is required." }); + + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var fileUrl = await SaveFileAsync(file); + var result = await _workOrderCompletionService.UploadCompletionDocAsync( + id, + new WorkOrderCompletionDocUploadDto + { + SignOffName = signOffName, + SignOffSignature = signOffSignature, + WorkOrderVersion = workOrderVersion + }, + fileUrl, + actorId); + return Ok(result); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code is "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code is "ConcurrencyConflict") + { + return Conflict(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [HttpGet("{id:int}/media")] + public async Task GetMedia(int id) + { + var media = await _workOrderMediaService.GetMediaAsync(id); + if (media == null) + return NotFound(new Response { Status = "Error", Message = "Work order not found." }); + return Ok(media); + } + + [HttpPost("{id:int}/media")] + [RequestSizeLimit(30_000_000)] + public async Task AddMedia( + int id, + [FromForm] WorkOrderMediaCategory category, + [FromForm] IFormFile file) + { + if (file == null || file.Length == 0) + return BadRequest(new Response { Status = "Error", Message = "file is required." }); + + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var fileUrl = await SaveFileAsync(file); + var media = await _workOrderMediaService.AddMediaAsync(id, category, fileUrl, actorId); + return Ok(media); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + + [HttpDelete("{id:int}/media/{mediaId:int}")] + public async Task DeleteMedia(int id, int mediaId) + { + try + { + var actorId = User.FindFirstValue(ClaimTypes.NameIdentifier); + await _workOrderMediaService.DeleteMediaAsync(id, mediaId, actorId); + return NoContent(); + } + catch (WorkOrderBoardValidationException ex) when (ex.Code == "NotFound") + { + return NotFound(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + catch (WorkOrderBoardValidationException ex) + { + return UnprocessableEntity(new WorkOrderBoardValidationErrorDto { Code = ex.Code, Message = ex.Message }); + } + } + [HttpPost] [Route("AddWorkorder")] public async Task Addworkorder([FromForm] Workorder_DTO model) @@ -295,6 +697,7 @@ namespace Api.SeaHavenIndustries.Controllers } + [Authorize(Roles = "Admin")] [HttpDelete] [Route("DeleteWorkorder")] public async Task Deleteworkorder([FromBody] EditWorkorder_DTO model) diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderIngestController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderIngestController.cs new file mode 100644 index 0000000..47b91a1 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderIngestController.cs @@ -0,0 +1,50 @@ +using Api.SeaHavenIndustries.Filters; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers +{ + [ApiController] + [Route("api/workorders")] + public class WorkOrderIngestController : ControllerBase + { + private const int MaxBatchSize = 100; + private readonly IWorkOrderIngestService _ingestService; + + public WorkOrderIngestController(IWorkOrderIngestService ingestService) + { + _ingestService = ingestService; + } + + /// + /// Idempotent upsert by ExternalWorkOrderId (Lambda cutover target). + /// Auth: X-Ingest-Key header. + /// + [HttpPost("ingest")] + [IngestApiKey] + [ProducesResponseType(typeof(WorkOrderIngestBatchResultDto), StatusCodes.Status200OK)] + public async Task> Ingest( + [FromBody] WorkOrderIngestBatchRequestDto? batch, + CancellationToken cancellationToken) + { + var items = NormalizeItems(batch); + if (items.Count == 0) + return BadRequest("At least one item with externalWorkOrderId is required."); + + if (items.Count > MaxBatchSize) + return BadRequest($"Max {MaxBatchSize} items per request."); + + var result = await _ingestService.UpsertBatchAsync(items, cancellationToken); + return Ok(result); + } + + private static List NormalizeItems(WorkOrderIngestBatchRequestDto? batch) + { + if (batch?.Items is { Count: > 0 }) + return batch.Items; + + return new List(); + } + } +} diff --git a/Api.SeaHavenIndustries/Controllers/WorkOrderJobsController.cs b/Api.SeaHavenIndustries/Controllers/WorkOrderJobsController.cs new file mode 100644 index 0000000..4277475 --- /dev/null +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderJobsController.cs @@ -0,0 +1,58 @@ +using Api.SeaHavenIndustries.Options; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace Api.SeaHavenIndustries.Controllers +{ + /// Ops-only endpoints for manual domain job reprocessing. + [Authorize(Roles = "Admin")] + [ApiController] + [Route("api/workorders/jobs")] + public class WorkOrderJobsController : ControllerBase + { + private readonly IWorkOrderWeekRolledService _weekRolledService; + private readonly IPastDueCacheService _pastDueCacheService; + + public WorkOrderJobsController( + IWorkOrderWeekRolledService weekRolledService, + IPastDueCacheService pastDueCacheService) + { + _weekRolledService = weekRolledService; + _pastDueCacheService = pastDueCacheService; + } + + /// + /// Reprocess WeekRolled for a specific operational week (Monday start). + /// Idempotent via WorkOrderWeekRolledLedger. + /// + [HttpPost("week-rolled")] + [ProducesResponseType(typeof(WeekRolledJobResult), StatusCodes.Status200OK)] + public async Task> RunWeekRolled( + [FromQuery] DateOnly? sourceWeekStart, + CancellationToken cancellationToken) + { + var weekStart = sourceWeekStart + ?? WorkOrderOperationalWeek.GetPreviousOperationalWeekStart(DateTime.UtcNow); + + if (weekStart.DayOfWeek != DayOfWeek.Monday) + return BadRequest("sourceWeekStart must be a Monday (operational week start)."); + + var result = await _weekRolledService.ProcessWeekRolledAsync(weekStart, cancellationToken); + return Ok(result); + } + + /// + /// Refresh optional OperationalFlags.PastDue cache (board isPastDue remains on-read). + /// + [HttpPost("past-due-cache")] + [ProducesResponseType(typeof(PastDueCacheJobResult), StatusCodes.Status200OK)] + public async Task> RunPastDueCache(CancellationToken cancellationToken) + { + var result = await _pastDueCacheService.RefreshAsync(cancellationToken); + return Ok(result); + } + } +} 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/Filters/ConcurrencyExceptionFilter.cs b/Api.SeaHavenIndustries/Filters/ConcurrencyExceptionFilter.cs new file mode 100644 index 0000000..6b84ee6 --- /dev/null +++ b/Api.SeaHavenIndustries/Filters/ConcurrencyExceptionFilter.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.EntityFrameworkCore; + +namespace Api.SeaHavenIndustries.Filters +{ + public class ConcurrencyExceptionFilter : IExceptionFilter + { + public void OnException(ExceptionContext context) + { + if (context.Exception is not DbUpdateConcurrencyException) return; + + context.Result = new ConflictObjectResult(new + { + status = "Conflict", + message = "The record was modified by another user. Refresh and retry.", + code = 409 + }); + context.ExceptionHandled = true; + } + } +} diff --git a/Api.SeaHavenIndustries/Filters/IngestApiKeyFilter.cs b/Api.SeaHavenIndustries/Filters/IngestApiKeyFilter.cs new file mode 100644 index 0000000..b98bfcc --- /dev/null +++ b/Api.SeaHavenIndustries/Filters/IngestApiKeyFilter.cs @@ -0,0 +1,67 @@ +using System.Security.Cryptography; +using System.Text; +using Api.SeaHavenIndustries.Options; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Options; + +namespace Api.SeaHavenIndustries.Filters +{ + /// Validates X-Ingest-Key for POST /api/workorders/ingest. + public class IngestApiKeyFilter : IAsyncActionFilter + { + private readonly WorkOrderIngestOptions _options; + + public IngestApiKeyFilter(IOptions options) + { + _options = options.Value; + } + + public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) + { + if (!_options.Enabled) + { + context.Result = new ObjectResult(new { message = "Work order ingest is disabled." }) + { + StatusCode = StatusCodes.Status503ServiceUnavailable + }; + return; + } + + if (string.IsNullOrWhiteSpace(_options.ApiKey) + || _options.ApiKey.Contains("${", StringComparison.Ordinal)) + { + context.Result = new ObjectResult(new { message = "Ingest API key is not configured." }) + { + StatusCode = StatusCodes.Status503ServiceUnavailable + }; + return; + } + + if (!context.HttpContext.Request.Headers.TryGetValue("X-Ingest-Key", out var provided) + || !FixedTimeEquals(provided.ToString(), _options.ApiKey)) + { + context.Result = new UnauthorizedObjectResult(new { message = "Invalid or missing X-Ingest-Key." }); + return; + } + + await next(); + } + + private static bool FixedTimeEquals(string provided, string expected) + { + var providedBytes = Encoding.UTF8.GetBytes(provided); + var expectedBytes = Encoding.UTF8.GetBytes(expected); + return providedBytes.Length == expectedBytes.Length + && CryptographicOperations.FixedTimeEquals(providedBytes, expectedBytes); + } + } + + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] + public sealed class IngestApiKeyAttribute : ServiceFilterAttribute + { + public IngestApiKeyAttribute() : base(typeof(IngestApiKeyFilter)) + { + } + } +} diff --git a/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs b/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs new file mode 100644 index 0000000..6daf1f7 --- /dev/null +++ b/Api.SeaHavenIndustries/HostedServices/PastDueCacheHostedService.cs @@ -0,0 +1,76 @@ +using Microsoft.Extensions.Options; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Api.SeaHavenIndustries.Options; + +namespace Api.SeaHavenIndustries.HostedServices +{ + public class PastDueCacheHostedService : BackgroundService + { + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly PastDueCacheJobOptions _options; + private readonly WorkOrderJobRunState _runState; + private DateOnly? _lastRunDate; + + public PastDueCacheHostedService( + IServiceScopeFactory scopeFactory, + IOptions options, + WorkOrderJobRunState runState, + ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + _options = options.Value.PastDueCache; + _runState = runState; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + if (_options.Enabled && ShouldRunNow()) + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + if (_lastRunDate != today) + { + if (await RunJobAsync(stoppingToken)) + _lastRunDate = today; + } + } + + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + } + } + + private async Task RunJobAsync(CancellationToken stoppingToken) + { + try + { + using var scope = _scopeFactory.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + await service.RefreshAsync(stoppingToken); + + _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; + } + } + + private bool ShouldRunNow() + { + var now = DateTime.UtcNow; + + if (!TimeOnly.TryParse(_options.RunAtUtc, out var runAt)) + runAt = new TimeOnly(0, 10); + + return TimeOnly.FromDateTime(now) >= runAt; + } + } +} diff --git a/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs b/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs new file mode 100644 index 0000000..dc5a309 --- /dev/null +++ b/Api.SeaHavenIndustries/HostedServices/WorkOrderWeekRolledHostedService.cs @@ -0,0 +1,84 @@ +using Microsoft.Extensions.Options; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using Api.SeaHavenIndustries.Options; + +namespace Api.SeaHavenIndustries.HostedServices +{ + public class WorkOrderWeekRolledHostedService : BackgroundService + { + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private readonly WeekRolledJobOptions _options; + private readonly WorkOrderJobRunState _runState; + private DateOnly? _lastRunDate; + + public WorkOrderWeekRolledHostedService( + IServiceScopeFactory scopeFactory, + IOptions options, + WorkOrderJobRunState runState, + ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + _options = options.Value.WeekRolled; + _runState = runState; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + if (_options.Enabled && ShouldRunNow()) + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + if (_lastRunDate != today) + { + if (await RunJobAsync(stoppingToken)) + _lastRunDate = today; + } + } + + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + } + } + + private async Task RunJobAsync(CancellationToken stoppingToken) + { + try + { + var sourceWeekStart = WorkOrderOperationalWeek.GetPreviousOperationalWeekStart(DateTime.UtcNow); + + using var scope = _scopeFactory.CreateScope(); + var service = scope.ServiceProvider.GetRequiredService(); + await service.ProcessWeekRolledAsync(sourceWeekStart, stoppingToken); + + _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; + } + } + + private bool ShouldRunNow() + { + var now = DateTime.UtcNow; + if (!Enum.TryParse(_options.DayOfWeek, ignoreCase: true, out var targetDay)) + targetDay = DayOfWeek.Monday; + + if (now.DayOfWeek != targetDay) + return false; + + if (!TimeOnly.TryParse(_options.RunAtUtc, out var runAt)) + runAt = new TimeOnly(0, 5); + + return TimeOnly.FromDateTime(now) >= runAt; + } + } +} diff --git a/Api.SeaHavenIndustries/Middleware/LegacyDeprecationMiddleware.cs b/Api.SeaHavenIndustries/Middleware/LegacyDeprecationMiddleware.cs new file mode 100644 index 0000000..0e2ccb6 --- /dev/null +++ b/Api.SeaHavenIndustries/Middleware/LegacyDeprecationMiddleware.cs @@ -0,0 +1,57 @@ +using Api.SeaHavenIndustries.Options; +using Microsoft.Extensions.Options; + +namespace Api.SeaHavenIndustries.Middleware +{ + /// Adds Sunset/Deprecation headers to legacy WorkOrder REST routes. + public class LegacyDeprecationMiddleware + { + private static readonly HashSet LegacyPathSegments = new(StringComparer.OrdinalIgnoreCase) + { + "GetWorkOrderList", + "AddWorkorder", + "EditWorkorder", + "ChangeStatus", + "ChangeAssignment", + "DeleteWorkorder", + "GetWorkorderById" + }; + + private readonly RequestDelegate _next; + private readonly LegacyEndpointOptions _options; + + public LegacyDeprecationMiddleware(RequestDelegate next, IOptions options) + { + _next = next; + _options = options.Value; + } + + public async Task InvokeAsync(HttpContext context) + { + if (_options.DeprecationEnabled) + { + var path = context.Request.Path.Value ?? ""; + var isLegacyWo = (path.Contains("/WorkOrder", StringComparison.OrdinalIgnoreCase) + || path.Contains("/workorders", StringComparison.OrdinalIgnoreCase)) + && LegacyPathSegments.Any(seg => path.Contains(seg, StringComparison.OrdinalIgnoreCase)); + + if (isLegacyWo) + { + context.Response.OnStarting(() => + { + context.Response.Headers["Deprecation"] = "true"; + if (!string.IsNullOrWhiteSpace(_options.SunsetDate) + && DateTimeOffset.TryParse(_options.SunsetDate, out var sunset)) + { + context.Response.Headers["Sunset"] = sunset.UtcDateTime.ToString("r"); + } + context.Response.Headers["Link"] = "; rel=\"successor-version\""; + return Task.CompletedTask; + }); + } + } + + await _next(context); + } + } +} 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/WorkOrderIngestOptions.cs b/Api.SeaHavenIndustries/Options/WorkOrderIngestOptions.cs new file mode 100644 index 0000000..96bed87 --- /dev/null +++ b/Api.SeaHavenIndustries/Options/WorkOrderIngestOptions.cs @@ -0,0 +1,13 @@ +namespace Api.SeaHavenIndustries.Options +{ + public class WorkOrderIngestOptions + { + public const string SectionName = "WorkOrderIngest"; + + /// When false, ingest endpoints return 503. Default false until a real ApiKey is provisioned. + public bool Enabled { get; set; } + + /// Shared secret for X-Ingest-Key header (Lambda / service accounts). + public string? ApiKey { get; set; } + } +} diff --git a/Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs b/Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs new file mode 100644 index 0000000..791638c --- /dev/null +++ b/Api.SeaHavenIndustries/Options/WorkOrderJobsOptions.cs @@ -0,0 +1,23 @@ +namespace Api.SeaHavenIndustries.Options +{ + public class WorkOrderJobsOptions + { + public const string SectionName = "WorkOrderJobs"; + + public WeekRolledJobOptions WeekRolled { get; set; } = new(); + public PastDueCacheJobOptions PastDueCache { get; set; } = new(); + } + + public class WeekRolledJobOptions + { + public bool Enabled { get; set; } = true; + public string RunAtUtc { get; set; } = "00:05"; + public string DayOfWeek { get; set; } = "Monday"; + } + + public class PastDueCacheJobOptions + { + public bool Enabled { get; set; } + public string RunAtUtc { get; set; } = "00:10"; + } +} diff --git a/Api.SeaHavenIndustries/Program.cs b/Api.SeaHavenIndustries/Program.cs index a826847..c85271b 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -1,4 +1,7 @@ using Api.SeaHavenIndustries.Helper; +using Api.SeaHavenIndustries.HostedServices; +using Api.SeaHavenIndustries.Middleware; +using Api.SeaHavenIndustries.Options; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; @@ -9,6 +12,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; @@ -22,7 +26,10 @@ builder.Services.AddIdentity(options => .AddEntityFrameworkStores() .AddDefaultTokenProviders(); -builder.Services.AddControllers().AddJsonOptions(options => +builder.Services.AddControllers(options => +{ + options.Filters.Add(); +}).AddJsonOptions(options => { options.JsonSerializerOptions.MaxDepth = 264; // or any other desired value }); @@ -48,6 +55,38 @@ builder.Services.AddSingleton(sp builder.Services.AddDataServices(); builder.Services.AddBusinessServices(); +builder.Services.Configure( + builder.Configuration.GetSection(WorkOrderJobsOptions.SectionName)); +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 => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; @@ -113,6 +152,7 @@ if (!app.Environment.IsDevelopment()) app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseCors(); +app.UseMiddleware(); app.UseAuthentication(); app.UseAuthorization(); diff --git a/Api.SeaHavenIndustries/appsettings.json b/Api.SeaHavenIndustries/appsettings.json index 2d5dc80..85941e0 100644 --- a/Api.SeaHavenIndustries/appsettings.json +++ b/Api.SeaHavenIndustries/appsettings.json @@ -1,4 +1,4 @@ -{ +{ "ConnectionStrings": { //"DefaultConnection": "Server=DESKTOP-64LC14O\SQLEXPRESS;Database=SeahavenIndustries;TrustServerCertificate=True;Trusted_Connection=True;MultipleActiveResultSets=true", @@ -23,5 +23,22 @@ "ValidIssuer": "http://localhost:7195", // Provide the real value via environment variable JWT__Secret or user-secrets. "Secret": "${JWT_SECRET}" - } -} \ No newline at end of file + }, + "WorkOrderJobs": { + "WeekRolled": { "Enabled": true, "RunAtUtc": "00:05", "DayOfWeek": "Monday" }, + "PastDueCache": { "Enabled": false, "RunAtUtc": "00:10" } + }, + "WorkOrderIngest": { + "Enabled": false, + "ApiKey": "${WORKORDER_INGEST_API_KEY}" + }, + "Sync": { + "Enabled": true + }, + "LegacyEndpoints": { + "DeprecationEnabled": false, + "SunsetDate": "2026-12-31" + }, + "FrontendBaseUrl": "https://shoc.seahaven.com" +} + diff --git a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs index ea60e8e..9216004 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -33,6 +33,77 @@ namespace Data.SeaHavenIndustries .HasIndex(u => new { u.DispatchId, u.Status }) .IsUnique() .HasFilter("[Status] = 'Pending'"); + + builder.Entity() + .HasIndex(l => new { l.WorkOrderId, l.FieldName }) + .IsUnique(); + + builder.Entity() + .HasIndex(l => new { l.WorkOrderId, l.SourceWeekStart }) + .IsUnique(); + + builder.Entity() + .HasIndex(t => new { t.ServiceKey, t.IsActive }); + + builder.Entity() + .Property(w => w.RowVersion) + .IsRowVersion(); + + builder.Entity() + .Property(w => w.SiteCode) + .HasMaxLength(450); + + builder.Entity() + .HasIndex(w => w.SiteCode) + .HasFilter("[istemplate] = 0"); + + builder.Entity() + .Property(w => w.InternalWONumber) + .HasMaxLength(450); + + builder.Entity() + .HasIndex(w => w.InternalWONumber) + .HasFilter("[istemplate] = 0"); + + builder.Entity() + .HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate }) + .HasFilter("[istemplate] = 0"); + + // Phase 1 board indexes (must stay in model to match snapshot / existing DBs). + builder.Entity() + .HasIndex(w => w.AssignTo); + + builder.Entity() + .HasIndex(w => w.ScheduledDate); + + builder.Entity() + .HasIndex(w => new { w.AssignTo, w.ScheduledDate }); + + builder.Entity() + .HasIndex(w => w.WorkOrderType); + + builder.Entity() + .Property(w => w.Service) + .HasMaxLength(128); + + builder.Entity() + .Property(w => w.ExternalWorkOrderId) + .HasMaxLength(450); + + builder.Entity() + .HasIndex(w => w.ExternalWorkOrderId) + .IsUnique() + .HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''"); + + builder.Entity() + .Property(d => d.RowVersion) + .IsRowVersion(); + + builder.Entity() + .HasOne(w => w.PrimaryDispatch) + .WithMany() + .HasForeignKey(w => w.PrimaryDispatchId) + .OnDelete(DeleteBehavior.Restrict); } public DbSet Categories { get; set; } public DbSet Locations { get; set; } @@ -46,7 +117,10 @@ namespace Data.SeaHavenIndustries public DbSet WorkOrderContacts { get; set; } public DbSet workOrderCategories { get; set; } public DbSet workOrderAttachments { get; set; } + public DbSet CompletionDocTemplates { get; set; } public DbSet WorkOrderAuditLogs { get; set; } + public DbSet WorkOrderFieldLocks { get; set; } + public DbSet WorkOrderWeekRolledLedgers { get; set; } public DbSet DropdownOptions { get; set; } public DbSet Vendors { get; set; } public DbSet VendorAccessTokens { get; set; } @@ -75,6 +149,51 @@ namespace Data.SeaHavenIndustries public DbSet Departments { get; set; } public DbSet JobTitles { get; set; } public DbSet Regions { get; set; } + + public override int SaveChanges() + { + BumpRowVersionsForInMemoryProvider(); + return base.SaveChanges(); + } + + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + BumpRowVersionsForInMemoryProvider(); + return base.SaveChangesAsync(cancellationToken); + } + + private void BumpRowVersionsForInMemoryProvider() + { + if (!Database.ProviderName?.Contains("InMemory", StringComparison.OrdinalIgnoreCase) ?? true) + return; + + foreach (var entry in ChangeTracker.Entries()) + { + if (entry.State == EntityState.Modified) + entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion); + } + + foreach (var entry in ChangeTracker.Entries()) + { + if (entry.State == EntityState.Modified) + entry.Entity.RowVersion = IncrementRowVersion(entry.Entity.RowVersion); + } + } + + private static byte[] IncrementRowVersion(byte[]? current) + { + var bytes = current != null && current.Length > 0 + ? (byte[])current.Clone() + : new byte[] { 0, 0, 0, 0, 0, 0, 0, 1 }; + + for (var i = 0; i < bytes.Length; i++) + { + if (++bytes[i] != 0) + break; + } + + return bytes; + } } public class ApplicationUser : IdentityUser @@ -88,6 +207,8 @@ namespace Data.SeaHavenIndustries public DateTime? DeletedDate { get; set; } public bool? IsDeleted { get; set; } public string? UserImage { get; set; } + public string? Initials { get; set; } + public string? Color { get; set; } public int? Type { get; set; } // 1 for users 0 for admin public ICollection