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 index 61d37c9..39e02db 100644 --- a/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs +++ b/Api.SeaHavenIndustries.Tests/WorkOrderControllerSearchTests.cs @@ -59,7 +59,12 @@ public class WorkOrderControllerSearchTests Mock.Of(), Mock.Of(), Mock.Of(), - advancedSearch); + advancedSearch, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of()); + controller.ControllerContext = new ControllerContext { 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 00763f0..8208af3 100644 --- a/Api.SeaHavenIndustries/Controllers/VendorController.cs +++ b/Api.SeaHavenIndustries/Controllers/VendorController.cs @@ -329,6 +329,7 @@ namespace Api.SeaHavenIndustries.Controllers v.CompanyName, v.ContactName, v.PreferredContact, + v.TradeSpecialties, v.Address, v.City, @@ -348,6 +349,7 @@ namespace Api.SeaHavenIndustries.Controllers v.CompanyName, v.ContactName, v.PreferredContact, + 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 1e4f617..9edc8e8 100644 --- a/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs +++ b/Api.SeaHavenIndustries/Controllers/WorkOrderController.cs @@ -50,6 +50,10 @@ namespace Api.SeaHavenIndustries.Controllers 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, @@ -71,7 +75,11 @@ namespace Api.SeaHavenIndustries.Controllers IWorkOrderBoardUpdateService boardUpdateService, IWorkOrderBoardCreateService boardCreateService, IWorkOrderBoardCancelService boardCancelService, - IWorkOrderAdvancedSearchService advancedSearchService) + IWorkOrderAdvancedSearchService advancedSearchService, + IWorkOrderDetailService workOrderDetailService, + IWorkOrderCommentService workOrderCommentService, + IWorkOrderCompletionService workOrderCompletionService, + IWorkOrderMediaService workOrderMediaService) { _userManager = userManager; _workOrderService = workOrderService; @@ -93,6 +101,10 @@ namespace Api.SeaHavenIndustries.Controllers _boardCreateService = boardCreateService; _boardCancelService = boardCancelService; _advancedSearchService = advancedSearchService; + _workOrderDetailService = workOrderDetailService; + _workOrderCommentService = workOrderCommentService; + _workOrderCompletionService = workOrderCompletionService; + _workOrderMediaService = workOrderMediaService; } /// @@ -268,6 +280,216 @@ namespace Api.SeaHavenIndustries.Controllers } } + [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 }); + } + } + + [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) 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/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/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 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/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/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 1661e8d..0e6c2a4 100644 --- a/Api.SeaHavenIndustries/Program.cs +++ b/Api.SeaHavenIndustries/Program.cs @@ -1,5 +1,6 @@ using Api.SeaHavenIndustries.Helper; using Api.SeaHavenIndustries.HostedServices; +using Api.SeaHavenIndustries.Middleware; using Api.SeaHavenIndustries.Options; using Data.SeaHavenIndustries; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -11,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; @@ -59,9 +61,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 => { @@ -128,6 +156,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 3c6dd39..4156b9d 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", @@ -34,5 +34,17 @@ "WorkOrderJobs": { "WeekRolled": { "Enabled": true, "RunAtUtc": "00:05", "DayOfWeek": "Monday" }, "PastDueCache": { "Enabled": false, "RunAtUtc": "00:10" } - } -} \ No newline at end of file + }, + "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 699dd15..48c34fe 100644 --- a/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs +++ b/Data.SeaHavenIndustries/Auth/ApplicationDbContext.cs @@ -42,6 +42,10 @@ namespace Data.SeaHavenIndustries .HasIndex(l => new { l.WorkOrderId, l.SourceWeekStart }) .IsUnique(); + builder.Entity() + .HasIndex(t => new { t.ServiceKey, t.IsActive }); + + builder.Entity() .Property(w => w.RowVersion) .IsRowVersion(); @@ -56,6 +60,7 @@ namespace Data.SeaHavenIndustries .Property(w => w.InternalWONumber) .HasMaxLength(11); + builder.Entity() .HasIndex(w => w.SiteCode) .HasFilter("[istemplate] = 0"); @@ -68,6 +73,16 @@ namespace Data.SeaHavenIndustries .HasIndex(w => new { w.LifecycleStatus, w.ScheduledDate }) .HasFilter("[istemplate] = 0"); + 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(); @@ -110,6 +125,7 @@ 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; } diff --git a/Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs b/Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs new file mode 100644 index 0000000..f52940d --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260625120000_Phase6_CompletionSlideOver.cs @@ -0,0 +1,88 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260625120000_Phase6_CompletionSlideOver")] + public partial class Phase6_CompletionSlideOver : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@" + IF COL_LENGTH('workOrderAttachments', 'Category') IS NULL + BEGIN + ALTER TABLE workOrderAttachments ADD Category int NULL; + END + "); + + migrationBuilder.Sql(@" + IF OBJECT_ID(N'dbo.CompletionDocTemplates', N'U') IS NULL + BEGIN + CREATE TABLE CompletionDocTemplates ( + Id int NOT NULL IDENTITY(1,1), + Name nvarchar(max) NOT NULL, + ServiceKey nvarchar(450) NOT NULL, + WorkOrderType int NULL, + TemplateUrl nvarchar(max) NOT NULL, + IsActive bit NOT NULL, + IsDeleted bit NULL, + createdby nvarchar(max) NULL, + DeleterUserId nvarchar(max) NULL, + DeletionTime datetime2 NULL, + CreatedDate datetime2 NULL, + LastModificationTime datetime2 NULL, + LastModifierUserId int NULL, + CONSTRAINT PK_CompletionDocTemplates PRIMARY KEY (Id) + ); + END + "); + + migrationBuilder.Sql(@" + IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE name = N'IX_CompletionDocTemplates_ServiceKey_IsActive' + AND object_id = OBJECT_ID(N'dbo.CompletionDocTemplates')) + BEGIN + CREATE INDEX IX_CompletionDocTemplates_ServiceKey_IsActive + ON CompletionDocTemplates (ServiceKey, IsActive); + END + "); + + migrationBuilder.Sql(@" + UPDATE workOrders + SET DocStatus = 1 + WHERE DocStatus IS NULL + AND SignOffAttachment IS NOT NULL + AND LTRIM(RTRIM(SignOffAttachment)) <> ''; + "); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(@" + UPDATE workOrders + SET DocStatus = NULL + WHERE DocStatus = 1 + AND SignOffAttachment IS NOT NULL + AND LTRIM(RTRIM(SignOffAttachment)) <> ''; + "); + + migrationBuilder.Sql(@" + IF OBJECT_ID(N'dbo.CompletionDocTemplates', N'U') IS NOT NULL + DROP TABLE CompletionDocTemplates; + "); + + migrationBuilder.Sql(@" + IF COL_LENGTH('workOrderAttachments', 'Category') IS NOT NULL + ALTER TABLE workOrderAttachments DROP COLUMN Category; + "); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260713120000_Phase7_ExternalWorkOrderIdUnique.cs b/Data.SeaHavenIndustries/Migrations/20260713120000_Phase7_ExternalWorkOrderIdUnique.cs new file mode 100644 index 0000000..291a952 --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260713120000_Phase7_ExternalWorkOrderIdUnique.cs @@ -0,0 +1,49 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + public partial class Phase7_ExternalWorkOrderIdUnique : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "ExternalWorkOrderId", + table: "workOrders", + type: "nvarchar(450)", + maxLength: 450, + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(max)", + oldNullable: true); + + migrationBuilder.CreateIndex( + name: "IX_workOrders_ExternalWorkOrderId", + table: "workOrders", + column: "ExternalWorkOrderId", + unique: true, + filter: "[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_workOrders_ExternalWorkOrderId", + table: "workOrders"); + + migrationBuilder.AlterColumn( + name: "ExternalWorkOrderId", + table: "workOrders", + type: "nvarchar(max)", + nullable: true, + oldClrType: typeof(string), + oldType: "nvarchar(450)", + oldMaxLength: 450, + oldNullable: true); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260715160000_WoServiceNotesStructuredFields.cs b/Data.SeaHavenIndustries/Migrations/20260715160000_WoServiceNotesStructuredFields.cs new file mode 100644 index 0000000..e49532f --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260715160000_WoServiceNotesStructuredFields.cs @@ -0,0 +1,121 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260715160000_WoServiceNotesStructuredFields")] + public partial class WoServiceNotesStructuredFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PocName", + table: "workOrders", + type: "nvarchar(200)", + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn( + name: "PocPhone", + table: "workOrders", + type: "nvarchar(50)", + maxLength: 50, + nullable: true); + + migrationBuilder.AddColumn( + name: "PocNotes", + table: "workOrders", + type: "nvarchar(2000)", + maxLength: 2000, + nullable: true); + + migrationBuilder.AddColumn( + name: "ExtraServices", + table: "workOrders", + type: "nvarchar(2000)", + maxLength: 2000, + nullable: true); + + migrationBuilder.AddColumn( + name: "ServiceNotes", + table: "workOrders", + type: "nvarchar(4000)", + maxLength: 4000, + nullable: true); + + migrationBuilder.AddColumn( + name: "TechPhone", + table: "workOrders", + type: "nvarchar(50)", + maxLength: 50, + nullable: true); + + migrationBuilder.AddColumn( + name: "VendorNotes", + table: "workOrders", + type: "nvarchar(2000)", + maxLength: 2000, + nullable: true); + + migrationBuilder.AddColumn( + name: "TechPhone", + table: "Dispatches", + type: "nvarchar(50)", + maxLength: 50, + nullable: true); + + migrationBuilder.AddColumn( + name: "VendorNotes", + table: "Dispatches", + type: "nvarchar(2000)", + maxLength: 2000, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PocName", + table: "workOrders"); + + migrationBuilder.DropColumn( + name: "PocPhone", + table: "workOrders"); + + migrationBuilder.DropColumn( + name: "PocNotes", + table: "workOrders"); + + migrationBuilder.DropColumn( + name: "ExtraServices", + table: "workOrders"); + + migrationBuilder.DropColumn( + name: "ServiceNotes", + table: "workOrders"); + + migrationBuilder.DropColumn( + name: "TechPhone", + table: "workOrders"); + + migrationBuilder.DropColumn( + name: "VendorNotes", + table: "workOrders"); + + migrationBuilder.DropColumn( + name: "TechPhone", + table: "Dispatches"); + + migrationBuilder.DropColumn( + name: "VendorNotes", + table: "Dispatches"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/20260715180000_WoFlagColor.cs b/Data.SeaHavenIndustries/Migrations/20260715180000_WoFlagColor.cs new file mode 100644 index 0000000..e48600e --- /dev/null +++ b/Data.SeaHavenIndustries/Migrations/20260715180000_WoFlagColor.cs @@ -0,0 +1,33 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.SeaHavenIndustries.Migrations +{ + /// + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260715180000_WoFlagColor")] + public partial class WoFlagColor : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FlagColor", + table: "workOrders", + type: "nvarchar(7)", + maxLength: 7, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "FlagColor", + table: "workOrders"); + } + } +} diff --git a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs index effb13d..7601a5f 100644 --- a/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Data.SeaHavenIndustries/Migrations/ApplicationDbContextModelSnapshot.cs @@ -107,7 +107,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Accounts"); + b.ToTable("Accounts", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Addresses", b => @@ -175,7 +175,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("ContactId"); - b.ToTable("Addresses"); + b.ToTable("Addresses", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.ApplicationUser", b => @@ -334,7 +334,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("LocationId"); - b.ToTable("Assets"); + b.ToTable("Assets", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Category", b => @@ -371,7 +371,62 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Categories"); + b.ToTable("Categories", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.CompletionDocTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedDate") + .HasColumnType("datetime2"); + + b.Property("DeleterUserId") + .HasColumnType("nvarchar(max)"); + + b.Property("DeletionTime") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("LastModificationTime") + .HasColumnType("datetime2"); + + b.Property("LastModifierUserId") + .HasColumnType("int"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ServiceKey") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("TemplateUrl") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("WorkOrderType") + .HasColumnType("int"); + + b.Property("createdby") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("ServiceKey", "IsActive") + .HasDatabaseName("IX_CompletionDocTemplates_ServiceKey_IsActive"); + + b.ToTable("CompletionDocTemplates", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Comments", b => @@ -438,7 +493,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkerOrderId"); - b.ToTable("Comments"); + b.ToTable("Comments", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.ContactDetails", b => @@ -504,7 +559,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("ContactId"); - b.ToTable("ContactDetails"); + b.ToTable("ContactDetails", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Contacts", b => @@ -596,7 +651,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("LocationId"); - b.ToTable("Contacts"); + b.ToTable("Contacts", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Department", b => @@ -614,7 +669,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Departments"); + b.ToTable("Departments", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Dispatch", b => @@ -704,9 +759,17 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("Status") .HasColumnType("nvarchar(max)"); + b.Property("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + b.Property("VendorId") .HasColumnType("int"); + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + b.Property("VerifiedAt") .HasColumnType("datetime2"); @@ -725,7 +788,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId"); - b.ToTable("Dispatches"); + b.ToTable("Dispatches", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.DispatchChecklistItem", b => @@ -784,7 +847,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId"); - b.ToTable("DispatchChecklistItems"); + b.ToTable("DispatchChecklistItems", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.DispatchSignoff", b => @@ -838,7 +901,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("DispatchId"); - b.ToTable("DispatchSignoffs"); + b.ToTable("DispatchSignoffs", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.DispatchUpliftRequest", b => @@ -908,7 +971,7 @@ namespace Data.SeaHavenIndustries.Migrations .IsUnique() .HasFilter("[Status] = 'Pending'"); - b.ToTable("DispatchUpliftRequests"); + b.ToTable("DispatchUpliftRequests", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.DispatchWorkOrder", b => @@ -931,7 +994,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId"); - b.ToTable("DispatchWorkOrders"); + b.ToTable("DispatchWorkOrders", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.DropdownOption", b => @@ -961,7 +1024,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("DropdownOptions"); + b.ToTable("DropdownOptions", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Employee", b => @@ -1066,7 +1129,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("RegionId"); - b.ToTable("Employees"); + b.ToTable("Employees", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeAddress", b => @@ -1106,7 +1169,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("EmployeeId") .IsUnique(); - b.ToTable("EmployeeAddresses"); + b.ToTable("EmployeeAddresses", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.EmployeeEmail", b => @@ -1134,7 +1197,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("EmployeeId"); - b.ToTable("EmployeeEmails"); + b.ToTable("EmployeeEmails", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.EmployeePhone", b => @@ -1164,7 +1227,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("EmployeeId"); - b.ToTable("EmployeePhones"); + b.ToTable("EmployeePhones", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Events", b => @@ -1312,7 +1375,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId"); - b.ToTable("FollowUps"); + b.ToTable("FollowUps", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.ForgetPasswordCode", b => @@ -1337,7 +1400,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("ForgetPasswordCodes"); + b.ToTable("ForgetPasswordCodes", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.JobTitle", b => @@ -1355,7 +1418,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("JobTitles"); + b.ToTable("JobTitles", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Locations", b => @@ -1425,7 +1488,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Locations"); + b.ToTable("Locations", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.PMSchedules", b => @@ -1483,7 +1546,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("LocationId"); - b.ToTable("PMSchedules"); + b.ToTable("PMSchedules", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Quotes", b => @@ -1557,7 +1620,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkorderId"); - b.ToTable("Quotes"); + b.ToTable("Quotes", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.QuotesLineItems", b => @@ -1605,7 +1668,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("QuoteId"); - b.ToTable("QuotesLines"); + b.ToTable("QuotesLines", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Region", b => @@ -1623,7 +1686,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("Regions"); + b.ToTable("Regions", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.SitePreferredVendor", b => @@ -1725,7 +1788,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasKey("Id"); - b.ToTable("TaskListTemplates"); + b.ToTable("TaskListTemplates", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.TaskListTemplateItem", b => @@ -1770,7 +1833,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("TaskListTemplateId"); - b.ToTable("TaskListTemplateItems"); + b.ToTable("TaskListTemplateItems", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Template", b => @@ -1872,7 +1935,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("LocationId"); - b.ToTable("Templates"); + b.ToTable("Templates", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.Vendor", b => @@ -1960,7 +2023,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("CompanyId"); - b.ToTable("Vendors"); + b.ToTable("Vendors", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.VendorAccessToken", b => @@ -2019,7 +2082,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("VendorId"); - b.ToTable("VendorAccessTokens"); + b.ToTable("VendorAccessTokens", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.VendorAuditLog", b => @@ -2277,7 +2340,16 @@ namespace Data.SeaHavenIndustries.Migrations .HasColumnType("datetime2"); b.Property("ExternalWorkOrderId") - .HasColumnType("nvarchar(max)"); + .HasMaxLength(450) + .HasColumnType("nvarchar(450)"); + + b.Property("ExtraServices") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("FlagColor") + .HasMaxLength(7) + .HasColumnType("nvarchar(7)"); b.Property("InternalWONumber") .HasMaxLength(11) @@ -2313,6 +2385,18 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("PO") .HasColumnType("nvarchar(max)"); + b.Property("PocName") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("PocNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("PocPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + b.Property("PrimaryDispatchId") .HasColumnType("int"); @@ -2346,6 +2430,10 @@ namespace Data.SeaHavenIndustries.Migrations .HasMaxLength(128) .HasColumnType("nvarchar(128)"); + b.Property("ServiceNotes") + .HasMaxLength(4000) + .HasColumnType("nvarchar(4000)"); + b.Property("Severity") .HasColumnType("nvarchar(max)"); @@ -2380,12 +2468,20 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("TargetWeek") .HasColumnType("date"); + b.Property("TechPhone") + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + b.Property("Trade") .HasColumnType("nvarchar(max)"); b.Property("VendorNTE") .HasColumnType("decimal(18,2)"); + b.Property("VendorNotes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + b.Property("WorkOrderType") .HasColumnType("int"); @@ -2407,6 +2503,10 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("AssignTo", "ScheduledDate"); + b.HasIndex("ExternalWorkOrderId") + .IsUnique() + .HasFilter("[ExternalWorkOrderId] IS NOT NULL AND [ExternalWorkOrderId] <> ''"); + b.HasIndex("InternalWONumber") .HasFilter("[istemplate] = 0"); @@ -2424,7 +2524,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderType"); - b.ToTable("workOrders"); + b.ToTable("workOrders", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAttachments", b => @@ -2438,6 +2538,9 @@ namespace Data.SeaHavenIndustries.Migrations b.Property("Attachments") .HasColumnType("nvarchar(max)"); + b.Property("Category") + .HasColumnType("int"); + b.Property("CreatedDate") .HasColumnType("datetime2"); @@ -2466,7 +2569,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkorderId"); - b.ToTable("workOrderAttachments"); + b.ToTable("workOrderAttachments", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderAuditLog", b => @@ -2516,7 +2619,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId"); - b.ToTable("WorkOrderAuditLogs"); + b.ToTable("WorkOrderAuditLogs", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderCategories", b => @@ -2560,7 +2663,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkorderId"); - b.ToTable("workOrderCategories"); + b.ToTable("workOrderCategories", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderContacts", b => @@ -2607,7 +2710,7 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkorderId"); - b.ToTable("WorkOrderContacts"); + b.ToTable("WorkOrderContacts", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderFieldLock", b => @@ -2636,7 +2739,36 @@ namespace Data.SeaHavenIndustries.Migrations b.HasIndex("WorkOrderId", "FieldName") .IsUnique(); - b.ToTable("WorkOrderFieldLocks"); + b.ToTable("WorkOrderFieldLocks", (string)null); + }); + + modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CorrelationId") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ProcessedAt") + .HasColumnType("datetime2"); + + b.Property("SourceWeekStart") + .HasColumnType("date"); + + b.Property("WorkOrderId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("WorkOrderId", "SourceWeekStart") + .IsUnique(); + + b.ToTable("WorkOrderWeekRolledLedgers", (string)null); }); modelBuilder.Entity("Data.SeaHavenIndustries.WorkOrderWeekRolledLedger", b => diff --git a/Data.SeaHavenIndustries/Models/CompletionDocTemplate.cs b/Data.SeaHavenIndustries/Models/CompletionDocTemplate.cs new file mode 100644 index 0000000..17cb4d3 --- /dev/null +++ b/Data.SeaHavenIndustries/Models/CompletionDocTemplate.cs @@ -0,0 +1,13 @@ +using Data.SeaHavenIndustries.Enums; + +namespace Data.SeaHavenIndustries +{ + public class CompletionDocTemplate : FullAuditEntity + { + public string Name { get; set; } = ""; + public string ServiceKey { get; set; } = ""; + public WorkOrderType? WorkOrderType { get; set; } + public string TemplateUrl { get; set; } = ""; + public bool IsActive { get; set; } = true; + } +} diff --git a/Data.SeaHavenIndustries/Models/Dispatch.cs b/Data.SeaHavenIndustries/Models/Dispatch.cs index 4e7b927..dddd2d5 100644 --- a/Data.SeaHavenIndustries/Models/Dispatch.cs +++ b/Data.SeaHavenIndustries/Models/Dispatch.cs @@ -34,6 +34,13 @@ namespace Data.SeaHavenIndustries public string? VerifiedBy { get; set; } public DateTime? VerifiedAt { get; set; } + /// Tech/crew phone for this dispatch (overrides Vendor.Phone on board). + [MaxLength(50)] + public string? TechPhone { get; set; } + /// Instructions for the vendor for this appointment/dispatch. + [MaxLength(2000)] + public string? VendorNotes { get; set; } + [Timestamp] public byte[]? RowVersion { get; set; } diff --git a/Data.SeaHavenIndustries/Models/WorkOrderAttachments.cs b/Data.SeaHavenIndustries/Models/WorkOrderAttachments.cs index ff8672b..fbeee87 100644 --- a/Data.SeaHavenIndustries/Models/WorkOrderAttachments.cs +++ b/Data.SeaHavenIndustries/Models/WorkOrderAttachments.cs @@ -1,15 +1,14 @@ -using System; -using System.ComponentModel.DataAnnotations.Schema; -using static System.Runtime.InteropServices.JavaScript.JSType; +using System.ComponentModel.DataAnnotations.Schema; +using Data.SeaHavenIndustries.Enums; namespace Data.SeaHavenIndustries { public class WorkOrderAttachments : FullAuditEntity { public string? Attachments { get; set; } + public WorkOrderMediaCategory? Category { get; set; } public int? WorkorderId { get; set; } [ForeignKey(nameof(WorkorderId))] public virtual WorkOrder? WorkOrder { get; set; } - } } diff --git a/Data.SeaHavenIndustries/Models/WorkerOrder.cs b/Data.SeaHavenIndustries/Models/WorkerOrder.cs index 81c6ad8..9ffe49e 100644 --- a/Data.SeaHavenIndustries/Models/WorkerOrder.cs +++ b/Data.SeaHavenIndustries/Models/WorkerOrder.cs @@ -33,6 +33,25 @@ namespace Data.SeaHavenIndustries [ForeignKey(nameof(PrimaryDispatchId))] public virtual Dispatch? PrimaryDispatch { get; set; } + // --- Service & Notes / POC (structured create) --- + [MaxLength(200)] + public string? PocName { get; set; } + [MaxLength(50)] + public string? PocPhone { get; set; } + [MaxLength(2000)] + public string? PocNotes { get; set; } + /// JSON array of extra service labels beyond primary (Trade). + [MaxLength(2000)] + public string? ExtraServices { get; set; } + [MaxLength(4000)] + public string? ServiceNotes { get; set; } + /// Tech/crew phone when no primary dispatch; otherwise prefer Dispatch.TechPhone. + [MaxLength(50)] + public string? TechPhone { get; set; } + /// Vendor instructions when no primary dispatch; otherwise prefer Dispatch.VendorNotes. + [MaxLength(2000)] + public string? VendorNotes { get; set; } + // --- Scheduling slice --- public DateTime? ScheduledDate { get; set; } public DateTime? ScheduledStart { get; set; } @@ -52,6 +71,10 @@ namespace Data.SeaHavenIndustries // --- Completion slice --- public DocStatus? DocStatus { get; set; } + /// Board flag color (#RRGGBB from fixed palette). Null = no flag. + [MaxLength(7)] + public string? FlagColor { get; set; } + // --- Legacy SQL columns (drift reconciliation) --- public string? AvettaTask { get; set; } public DateOnly? AssignDate { get; set; } diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs index 778035e..80d18df 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardProjection.cs @@ -20,7 +20,10 @@ namespace SeaHaven.DataServices.Helpers w.WorkOrderType, w.SiteCode, LocationName = w.Locations != null ? w.Locations.Name : null, - Poc = w.WorkOrderContacts! + WoPocName = w.PocName, + WoPocPhone = w.PocPhone, + WoPocNotes = w.PocNotes, + ContactPoc = w.WorkOrderContacts! .OrderBy(c => c.Id) .Select(c => new { @@ -52,55 +55,73 @@ namespace SeaHaven.DataServices.Helpers TechName = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null ? w.PrimaryDispatch.Vendor.ContactName : null, - TechPhone = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null + DispatchTechPhone = w.PrimaryDispatch != null ? w.PrimaryDispatch.TechPhone : null, + WoTechPhone = w.TechPhone, + VendorPhone = w.PrimaryDispatch != null && w.PrimaryDispatch.Vendor != null ? w.PrimaryDispatch.Vendor.Phone : null, DispatchApptDate = w.PrimaryDispatch != null ? w.PrimaryDispatch.ScheduledDate : null, w.Trade, w.Problem, + w.ServiceNotes, + w.ExtraServices, w.DocStatus, + w.FlagColor, w.PrimaryDispatchId, w.RowVersion, DispatchRowVersion = w.PrimaryDispatch != null ? w.PrimaryDispatch.RowVersion : null }) .ToListAsync(); - return rows.Select(w => new WorkOrderBoardRawRow( - w.Id, - w.InternalWONumber, - w.RescheduleCount, - w.CarriedOver, - w.WorkOrderType, - w.SiteCode, - w.LocationName, - w.Poc?.Name, - w.Poc?.PhoneNumber, - w.Poc?.Notes, - w.LifecycleStatus, - w.LegacyStatus ?? w.Status, - w.AssignTo, - w.FirstName, - w.LastName, - w.Initials, - w.Color, - w.DueDate, - w.ScheduledDate, - w.ScheduledStart, - w.ScheduledEnd, - w.TargetWeek, - w.ScheduleWeekOnly, - w.VendorId, - w.VendorName, - w.TechName, - w.TechPhone, - w.DispatchApptDate, - w.Trade ?? w.Problem, - w.Problem, - w.DocStatus, - w.PrimaryDispatchId, - w.RowVersion, - w.DispatchRowVersion, - isUnscheduled)).ToList(); + return rows.Select(w => + { + var pocName = !string.IsNullOrWhiteSpace(w.WoPocName) ? w.WoPocName : w.ContactPoc?.Name; + var pocPhone = !string.IsNullOrWhiteSpace(w.WoPocPhone) ? w.WoPocPhone : w.ContactPoc?.PhoneNumber; + var pocNotes = !string.IsNullOrWhiteSpace(w.WoPocNotes) ? w.WoPocNotes : w.ContactPoc?.Notes; + var techPhone = !string.IsNullOrWhiteSpace(w.DispatchTechPhone) + ? w.DispatchTechPhone + : (!string.IsNullOrWhiteSpace(w.WoTechPhone) ? w.WoTechPhone : w.VendorPhone); + + return new WorkOrderBoardRawRow( + w.Id, + w.InternalWONumber, + w.RescheduleCount, + w.CarriedOver, + w.WorkOrderType, + w.SiteCode, + w.LocationName, + pocName, + pocPhone, + pocNotes, + w.LifecycleStatus, + w.LegacyStatus ?? w.Status, + w.AssignTo, + w.FirstName, + w.LastName, + w.Initials, + w.Color, + w.DueDate, + w.ScheduledDate, + w.ScheduledStart, + w.ScheduledEnd, + w.TargetWeek, + w.ScheduleWeekOnly, + w.VendorId, + w.VendorName, + w.TechName, + techPhone, + w.DispatchApptDate, + w.Trade ?? w.Problem, + w.Problem, + w.ServiceNotes, + w.ExtraServices, + w.DocStatus, + w.FlagColor, + w.PrimaryDispatchId, + w.RowVersion, + w.DispatchRowVersion, + isUnscheduled); + }).ToList(); } } } diff --git a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs index ff64ae9..ffe6ef6 100644 --- a/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs +++ b/SeaHaven.DataServices/Helpers/WorkOrderBoardQueryFilters.cs @@ -143,5 +143,6 @@ namespace SeaHaven.DataServices.Helpers } #endregion + } } diff --git a/SeaHaven.DataServices/Implementation/CompletionDocTemplateDataService.cs b/SeaHaven.DataServices/Implementation/CompletionDocTemplateDataService.cs new file mode 100644 index 0000000..356b623 --- /dev/null +++ b/SeaHaven.DataServices/Implementation/CompletionDocTemplateDataService.cs @@ -0,0 +1,103 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class CompletionDocTemplateDataService : ICompletionDocTemplateDataService + { + private readonly ApplicationDbContext _context; + + public CompletionDocTemplateDataService(ApplicationDbContext context) + { + _context = context; + } + + public async Task> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType) + { + var query = _context.CompletionDocTemplates + .AsNoTracking() + .Where(t => t.IsActive && t.IsDeleted != true); + + if (!string.IsNullOrWhiteSpace(serviceKey)) + query = query.Where(t => t.ServiceKey == serviceKey); + + if (workOrderType.HasValue) + query = query.Where(t => t.WorkOrderType == null || t.WorkOrderType == workOrderType); + + return await query + .OrderBy(t => t.Name) + .Select(MapRow) + .ToListAsync(); + } + + public async Task GetByIdAsync(int id) + { + return await _context.CompletionDocTemplates + .AsNoTracking() + .Where(t => t.Id == id && t.IsDeleted != true) + .Select(MapRow) + .FirstOrDefaultAsync(); + } + + public async Task ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType) + { + if (!string.IsNullOrWhiteSpace(trade)) + { + var byTrade = await _context.CompletionDocTemplates + .AsNoTracking() + .Where(t => t.IsActive && t.IsDeleted != true && t.ServiceKey == trade) + .OrderBy(t => t.WorkOrderType == workOrderType ? 0 : 1) + .ThenBy(t => t.Name) + .ThenBy(t => t.Id) + .Select(MapRow) + .FirstOrDefaultAsync(); + + if (byTrade != null) + return byTrade; + } + + if (!workOrderType.HasValue) + return null; + + return await _context.CompletionDocTemplates + .AsNoTracking() + .Where(t => t.IsActive && t.IsDeleted != true && t.WorkOrderType == workOrderType) + .OrderBy(t => t.Name) + .ThenBy(t => t.Id) + .Select(MapRow) + .FirstOrDefaultAsync(); + } + + public async Task CreateAsync(CompletionDocTemplate template) + { + template.CreatedDate = DateTime.UtcNow; + await _context.CompletionDocTemplates.AddAsync(template); + await _context.SaveChangesAsync(); + return template; + } + + public async Task UpdateAsync(CompletionDocTemplate template) + { + template.LastModificationTime = DateTime.UtcNow; + _context.CompletionDocTemplates.Update(template); + await _context.SaveChangesAsync(); + } + + public async Task DeleteAsync(int id) + { + var entity = await _context.CompletionDocTemplates.FindAsync(id); + if (entity == null) + return false; + + entity.IsDeleted = true; + entity.DeletionTime = DateTime.UtcNow; + await _context.SaveChangesAsync(); + return true; + } + + private static readonly System.Linq.Expressions.Expression> MapRow = + t => new CompletionDocTemplateRow(t.Id, t.Name, t.ServiceKey, t.WorkOrderType, t.TemplateUrl, t.IsActive); + } +} diff --git a/SeaHaven.DataServices/Implementation/LocationDataService.cs b/SeaHaven.DataServices/Implementation/LocationDataService.cs index 65c46df..b48cd5b 100644 --- a/SeaHaven.DataServices/Implementation/LocationDataService.cs +++ b/SeaHaven.DataServices/Implementation/LocationDataService.cs @@ -60,6 +60,24 @@ namespace SeaHaven.DataServices.Implementation return (items, totalCount); } + public async Task> GetSiteOptionsAsync(string? search = null) + { + var query = _context.Locations.AsNoTracking(); + + if (!string.IsNullOrWhiteSpace(search)) + { + var s = search.ToLower(); + query = query.Where(l => + (l.Name ?? "").ToLower().Contains(s) || + (l.City ?? "").ToLower().Contains(s)); + } + + return await query + .OrderBy(l => l.Name) + .Select(l => new SiteOptionRow(l.Id, l.Name, l.City, l.State)) + .ToListAsync(); + } + public async Task AddAsync(Locations location) { location.CreatedDate = DateTime.Now; diff --git a/SeaHaven.DataServices/Implementation/UserDataService.cs b/SeaHaven.DataServices/Implementation/UserDataService.cs index 365ceca..436a806 100644 --- a/SeaHaven.DataServices/Implementation/UserDataService.cs +++ b/SeaHaven.DataServices/Implementation/UserDataService.cs @@ -33,5 +33,27 @@ namespace SeaHaven.DataServices.Implementation { return await _context.Users.AnyAsync(u => u.Id == id); } + + public async Task> GetDisplayNamesByIdsAsync(IEnumerable ids) + { + var idList = ids + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (idList.Count == 0) + return new Dictionary(StringComparer.Ordinal); + + var users = await _context.Users + .AsNoTracking() + .Where(u => idList.Contains(u.Id)) + .Select(u => new { u.Id, u.FirstName, u.LastName }) + .ToListAsync(); + + return users.ToDictionary( + u => u.Id, + u => $"{u.FirstName ?? ""} {u.LastName ?? ""}".Trim(), + StringComparer.Ordinal); + } } } diff --git a/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs b/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs new file mode 100644 index 0000000..e2526ab --- /dev/null +++ b/SeaHaven.DataServices/Implementation/WorkOrderDetailDataService.cs @@ -0,0 +1,95 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Helpers; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Implementation +{ + public class WorkOrderDetailDataService : IWorkOrderDetailDataService + { + private readonly ApplicationDbContext _context; + + public WorkOrderDetailDataService(ApplicationDbContext context) + { + _context = context; + } + + public Task ExistsAsync(int workOrderId) + => WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()) + .AnyAsync(w => w.Id == workOrderId); + + public async Task GetExtendedFieldsAsync(int workOrderId) + { + return await WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()) + .Where(w => w.Id == workOrderId) + .Select(w => new WorkOrderDetailExtendedFields( + w.Description, + w.Trade, + w.Problem, + w.SubTrade, + w.OriginalWeek, + w.OriginalDate, + w.CompletedDate, + w.SignOffName, + w.SignOffAttachment, + w.SignOffSignature, + w.ExtraServices, + w.ServiceNotes, + w.PrimaryDispatch != null && w.PrimaryDispatch.VendorNotes != null + ? w.PrimaryDispatch.VendorNotes + : w.VendorNotes)) + .FirstOrDefaultAsync(); + } + + public async Task> GetCommentsAsync(int workOrderId) + { + return await _context.Comments + .AsNoTracking() + .Where(c => c.WorkerOrderId == workOrderId && c.IsDeleted != true) + .OrderByDescending(c => c.CreatedDate) + .ToListAsync(); + } + + public async Task> GetAuditLogsAsync(int workOrderId, int? limit = null) + { + var query = _context.WorkOrderAuditLogs + .AsNoTracking() + .Where(a => a.WorkOrderId == workOrderId) + .OrderByDescending(a => a.CreatedAt); + + if (limit.HasValue) + return await query.Take(limit.Value).ToListAsync(); + + return await query.ToListAsync(); + } + + public async Task> GetAttachmentsAsync(int workOrderId) + { + return await _context.workOrderAttachments + .AsNoTracking() + .Where(a => a.WorkorderId == workOrderId && a.IsDeleted != true) + .OrderByDescending(a => a.CreatedDate) + .ToListAsync(); + } + + public async Task> GetDispatchSignoffsAsync(int workOrderId) + { + return await _context.DispatchSignoffs + .AsNoTracking() + .Where(s => s.Dispatch != null && s.Dispatch.WorkOrderId == workOrderId && s.IsDeleted != true) + .OrderByDescending(s => s.SignedAt) + .Select(s => new DispatchSignoffRow( + s.DispatchId, + s.SignoffType, + s.Name, + s.SignedAt)) + .ToListAsync(); + } + + public async Task GetWorkOrderForMediaAsync(int workOrderId) + { + return await WorkOrderBoardQueryFilters.ApplyBaseScope(_context.workOrders.AsNoTracking()) + .FirstOrDefaultAsync(w => w.Id == workOrderId); + } + } +} diff --git a/SeaHaven.DataServices/Interfaces/ILocationDataService.cs b/SeaHaven.DataServices/Interfaces/ILocationDataService.cs index 2865e42..679528e 100644 --- a/SeaHaven.DataServices/Interfaces/ILocationDataService.cs +++ b/SeaHaven.DataServices/Interfaces/ILocationDataService.cs @@ -2,6 +2,8 @@ using Data.SeaHavenIndustries; namespace SeaHaven.DataServices.Interfaces { + public record SiteOptionRow(int Id, string? Code, string? City, string? State); + public interface ILocationDataService { Task GetByIdAsync(int id); @@ -15,6 +17,8 @@ namespace SeaHaven.DataServices.Interfaces // For addressbook endpoint - returns simplified DTO Task<(IEnumerable Items, int TotalCount)> GetAddressbookPagedAsync(int page, int pageSize, string? search = null); + Task> GetSiteOptionsAsync(string? search = null); + Task AddAsync(Locations location); Task UpdateAsync(Locations location); Task DeleteAsync(int id); diff --git a/SeaHaven.DataServices/Interfaces/IUserDataService.cs b/SeaHaven.DataServices/Interfaces/IUserDataService.cs index 6524cc4..6cfed2d 100644 --- a/SeaHaven.DataServices/Interfaces/IUserDataService.cs +++ b/SeaHaven.DataServices/Interfaces/IUserDataService.cs @@ -8,5 +8,6 @@ namespace SeaHaven.DataServices.Interfaces Task> GetAllAsync(); Task GetByEmailAsync(string email); Task ExistsAsync(string id); + Task> GetDisplayNamesByIdsAsync(IEnumerable ids); } } diff --git a/SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs b/SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs new file mode 100644 index 0000000..3f61a70 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/IWorkOrderDetailDataService.cs @@ -0,0 +1,27 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using SeaHaven.DataServices.Interfaces; + +namespace SeaHaven.DataServices.Interfaces +{ + public interface IWorkOrderDetailDataService + { + Task ExistsAsync(int workOrderId); + Task GetExtendedFieldsAsync(int workOrderId); + Task> GetCommentsAsync(int workOrderId); + Task> GetAuditLogsAsync(int workOrderId, int? limit = null); + Task> GetAttachmentsAsync(int workOrderId); + Task> GetDispatchSignoffsAsync(int workOrderId); + Task GetWorkOrderForMediaAsync(int workOrderId); + } + + public interface ICompletionDocTemplateDataService + { + Task> GetActiveAsync(string? serviceKey, WorkOrderType? workOrderType); + Task GetByIdAsync(int id); + Task ResolveForWorkOrderAsync(string? trade, WorkOrderType? workOrderType); + Task CreateAsync(CompletionDocTemplate template); + Task UpdateAsync(CompletionDocTemplate template); + Task DeleteAsync(int id); + } +} diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs index 0173d07..c46ff55 100644 --- a/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs +++ b/SeaHaven.DataServices/Interfaces/WorkOrderBoardModels.cs @@ -42,7 +42,10 @@ namespace SeaHaven.DataServices.Interfaces DateTime? DispatchApptDate, string? Trade, string? Problem, + string? ServiceNotes, + string? ExtraServices, DocStatus? DocStatus, + string? FlagColor, int? PrimaryDispatchId, byte[]? RowVersion, byte[]? DispatchRowVersion, diff --git a/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs b/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs new file mode 100644 index 0000000..433b080 --- /dev/null +++ b/SeaHaven.DataServices/Interfaces/WorkOrderDetailModels.cs @@ -0,0 +1,33 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.DataServices.Interfaces +{ + public record WorkOrderDetailExtendedFields( + string? Description, + string? Trade, + string? Problem, + string? SubTrade, + DateOnly? OriginalWeek, + DateOnly? OriginalDate, + DateTime? CompletedDate, + string? SignOffName, + string? SignOffAttachment, + string? SignOffSignature, + string? ExtraServices, + string? ServiceNotes, + string? VendorNotes); + + public record DispatchSignoffRow( + int DispatchId, + string? SignoffType, + string? Name, + DateTime? SignedAt); + + public record CompletionDocTemplateRow( + int Id, + string Name, + string ServiceKey, + WorkOrderType? WorkOrderType, + string TemplateUrl, + bool IsActive); +} diff --git a/SeaHaven.Services/DTOs/LocationDTOs.cs b/SeaHaven.Services/DTOs/LocationDTOs.cs index 09e41b6..9b518d0 100644 --- a/SeaHaven.Services/DTOs/LocationDTOs.cs +++ b/SeaHaven.Services/DTOs/LocationDTOs.cs @@ -36,4 +36,12 @@ namespace SeaHaven.Services.DTOs public string? Zipcode { get; set; } public int? AccountId { get; set; } } + + public class SiteOptionDTO + { + public int Id { get; set; } + public string? Code { get; set; } + public string? City { get; set; } + public string? State { get; set; } + } } diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs index b35c303..f0b13f9 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardDTOs.cs @@ -51,7 +51,11 @@ namespace SeaHaven.Services.DTOs public DateTime? ApptDate { get; set; } public string? ApptTime { get; set; } public string? Pm { get; set; } + public string? ServiceNotes { get; set; } + public List? ExtraServices { get; set; } public DocStatus? DocStatus { get; set; } + /// Board flag color (#RRGGBB). Null = no flag. Distinct from Color (dispatcher avatar). + public string? FlagColor { get; set; } public int? PrimaryDispatchId { get; set; } public byte[]? RowVersion { get; set; } public byte[]? DispatchRowVersion { get; set; } diff --git a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs index 31fae59..d93e123 100644 --- a/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs +++ b/SeaHaven.Services/DTOs/WorkOrderBoardRequestDTOs.cs @@ -3,14 +3,25 @@ using SeaHaven.Services.Helpers; namespace SeaHaven.Services.DTOs { - /// Payload for creating a board work order (SHOC create wizard). + /// + /// Payload for creating a board work order (SHOC create wizard). + /// FE follow-up: extend WorkOrderBoardCreatePayload + mapper with PrimaryService, + /// ExtraServices, ServiceNotes, PocName, PocPhone, VendorNotes, TechPhone; stop embedding + /// POC/vendor/service notes in description (pocNotes already exists). Prefer structured + /// fields; keep Trade+Description temporarily for legacy clients. + /// public class WorkOrderBoardCreateRequestDto { public string? WoNumber { get; set; } public WorkOrderType WorkOrderType { get; set; } public string? SiteCode { get; set; } public string? Description { get; set; } + /// Legacy alias for primary service; prefer . public string? Trade { get; set; } + /// Primary service label; persisted as Trade / board pm. + public string? PrimaryService { get; set; } + public List? ExtraServices { get; set; } + public string? ServiceNotes { get; set; } public int? LocationId { get; set; } public DateTime? DueDate { get; set; } public string? AssignTo { get; set; } @@ -22,7 +33,11 @@ namespace SeaHaven.Services.DTOs public DateTime? ApptDate { get; set; } public string? ApptTime { get; set; } public int? PocContactId { get; set; } + public string? PocName { get; set; } + public string? PocPhone { get; set; } public string? PocNotes { get; set; } + public string? TechPhone { get; set; } + public string? VendorNotes { get; set; } } /// Advanced search / filter query for the board. diff --git a/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs new file mode 100644 index 0000000..105e112 --- /dev/null +++ b/SeaHaven.Services/DTOs/WorkOrderDetailDTOs.cs @@ -0,0 +1,106 @@ +using Data.SeaHavenIndustries.Enums; + +namespace SeaHaven.Services.DTOs +{ + public class WorkOrderDetailDto + { + public WorkOrderDetailInfoDto Info { get; set; } = new(); + public WorkOrderCompletionDto Completion { get; set; } = new(); + public List Comments { get; set; } = new(); + public List Audit { get; set; } = new(); + public List Media { get; set; } = new(); + } + + public class WorkOrderDetailInfoDto : WorkOrderBoardRowDto + { + public string? Description { get; set; } + public string? Trade { get; set; } + public string? Problem { get; set; } + public string? SubTrade { get; set; } + public DateOnly? OriginalWeek { get; set; } + public DateOnly? OriginalDate { get; set; } + public DateTime? CompletedDate { get; set; } + public string? VendorNotes { get; set; } + } + + public class CompletionDocTemplateDto + { + public int Id { get; set; } + public string Name { get; set; } = ""; + public string ServiceKey { get; set; } = ""; + public WorkOrderType? WorkOrderType { get; set; } + public string TemplateUrl { get; set; } = ""; + public bool IsActive { get; set; } + } + + public class CompletionDocTemplateCreateDto + { + public string Name { get; set; } = ""; + public string ServiceKey { get; set; } = ""; + public WorkOrderType? WorkOrderType { get; set; } + public string TemplateUrl { get; set; } = ""; + public bool IsActive { get; set; } = true; + } + + public class WorkOrderCompletionDto + { + public DocStatus? DocStatus { get; set; } + public CompletionDocTemplateDto? Template { get; set; } + public string? SignOffName { get; set; } + public string? SignOffAttachment { get; set; } + public string? SignOffSignature { get; set; } + public List DispatchSignoffs { get; set; } = new(); + } + + public class DispatchSignoffDto + { + public int DispatchId { get; set; } + public string? SignoffType { get; set; } + public string? Name { get; set; } + public string? SignedAt { get; set; } + } + + public class WorkOrderCommentDto + { + public int Id { get; set; } + public string? AuthorId { get; set; } + public string? AuthorName { get; set; } + public string Text { get; set; } = ""; + public string Time { get; set; } = ""; + public string? Documents { get; set; } + } + + public class WorkOrderCommentCreateDto + { + public string Text { get; set; } = ""; + } + + public class WorkOrderAuditEntryDto + { + public string Type { get; set; } = "manual"; + public string? DispatcherId { get; set; } + public string Action { get; set; } = ""; + public string? FieldName { get; set; } + public string OldValue { get; set; } = ""; + public string NewValue { get; set; } = ""; + public string Time { get; set; } = ""; + public int? DispatchId { get; set; } + } + + public class WorkOrderMediaFileDto + { + public int Id { get; set; } + public WorkOrderMediaCategory Category { get; set; } + public string Url { get; set; } = ""; + public string? FileName { get; set; } + public string? UploadedAt { get; set; } + public bool IsLegacy { get; set; } + } + + public class WorkOrderCompletionDocUploadDto + { + public string? SignOffName { get; set; } + public string? SignOffSignature { get; set; } + public string? WorkOrderVersion { get; set; } + } +} diff --git a/SeaHaven.Services/DTOs/WorkOrderPhase7DTOs.cs b/SeaHaven.Services/DTOs/WorkOrderPhase7DTOs.cs new file mode 100644 index 0000000..4f1087a --- /dev/null +++ b/SeaHaven.Services/DTOs/WorkOrderPhase7DTOs.cs @@ -0,0 +1,54 @@ +namespace SeaHaven.Services.DTOs +{ + public sealed class WorkOrderIngestPayloadDto + { + public required string ExternalWorkOrderId { get; set; } + public string? Description { get; set; } + public string? WoStatus { get; set; } + public string? Severity { get; set; } + public string? Customer { get; set; } + public string? SiteCode { get; set; } + public string? Building { get; set; } + public string? Address { get; set; } + public DateTime? DueDate { get; set; } + public DateTime? DateReported { get; set; } + public DateTime? ScheduledStart { get; set; } + public string? SourceEmailS3Key { get; set; } + public DateTime? CreatedAt { get; set; } + } + + public sealed class WorkOrderIngestBatchRequestDto + { + public List? Items { get; set; } + } + + public sealed class WorkOrderIngestItemResultDto + { + public required string ExternalWorkOrderId { get; set; } + public int WorkOrderId { get; set; } + public bool Created { get; set; } + } + + public sealed class WorkOrderIngestBatchResultDto + { + public int Created { get; set; } + public int Updated { get; set; } + public int Skipped { get; set; } + public List Results { get; set; } = new(); + } + + public sealed class WorkOrderOpsHealthDto + { + public DateTime? LastWeekRolledRunUtc { get; set; } + public DateTime? LastPastDueCacheRunUtc { get; set; } + public string? LastWeekRolledError { get; set; } + public string? LastPastDueCacheError { get; set; } + public int SyncRejectedLast24h { get; set; } + public int FieldLockCount { get; set; } + public bool SyncEnabled { get; set; } + public bool IngestEnabled { get; set; } + public bool LegacyDeprecationEnabled { get; set; } + public string? LegacySunsetDate { get; set; } + public DateTime CheckedAtUtc { get; set; } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs b/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs new file mode 100644 index 0000000..6a7e295 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderAuditProjection.cs @@ -0,0 +1,87 @@ +using Data.SeaHavenIndustries; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderAuditProjection + { + private const string UnassignedLabel = "Unassigned"; + + public static WorkOrderAuditEntryDto ToDto( + WorkOrderAuditLog log, + IReadOnlyDictionary? assignToDisplayNames = null) + { + var oldValue = log.OldValue ?? ""; + var newValue = log.NewValue ?? ""; + + if (IsAssignToField(log.FieldName)) + { + oldValue = ResolveAssignToDisplay(oldValue, assignToDisplayNames); + newValue = ResolveAssignToDisplay(newValue, assignToDisplayNames); + } + + return new WorkOrderAuditEntryDto + { + Type = MapEventType(log.EventType), + DispatcherId = log.UserId, + Action = log.Action ?? "", + FieldName = log.FieldName, + OldValue = oldValue, + NewValue = newValue, + Time = log.CreatedAt.ToString("o"), + DispatchId = log.DispatchId + }; + } + + public static List ToDtos( + IEnumerable logs, + IReadOnlyDictionary? assignToDisplayNames = null) + => logs.Select(log => ToDto(log, assignToDisplayNames)).ToList(); + + public static IEnumerable CollectAssignToUserIds(IEnumerable logs) + { + return logs + .Where(l => IsAssignToField(l.FieldName)) + .SelectMany(l => new[] { l.OldValue, l.NewValue }) + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Select(v => v!) + .Distinct(StringComparer.Ordinal); + } + + internal static bool IsAssignToField(string? fieldName) + => string.Equals(fieldName, "AssignTo", StringComparison.OrdinalIgnoreCase) + || string.Equals(fieldName, "AssignedTo", StringComparison.OrdinalIgnoreCase); + + private static string ResolveAssignToDisplay( + string value, + IReadOnlyDictionary? assignToDisplayNames) + { + if (string.IsNullOrWhiteSpace(value)) + return UnassignedLabel; + + if (assignToDisplayNames != null + && assignToDisplayNames.TryGetValue(value, out var name) + && !string.IsNullOrWhiteSpace(name)) + { + return name; + } + + return value; + } + + private static string MapEventType(string? eventType) + { + if (string.IsNullOrWhiteSpace(eventType)) + return "manual"; + + return eventType.ToLowerInvariant() switch + { + "manual" => "manual", + "system" => "system", + "sync" => "sync", + "vendor" => "vendor", + _ => eventType.ToLowerInvariant() + }; + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs index cf6e4e8..a76a7fe 100644 --- a/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs +++ b/SeaHaven.Services/Helpers/WorkOrderBoardFieldNames.cs @@ -15,6 +15,10 @@ namespace SeaHaven.Services.Helpers public const string ApptDate = "apptDate"; public const string ApptTime = "apptTime"; public const string DocStatus = "docStatus"; + public const string Pm = "pm"; + public const string ServiceNotes = "serviceNotes"; + public const string ExtraServices = "extraServices"; + public const string FlagColor = "flagColor"; public const string RescheduleCount = "RescheduleCount"; private static readonly HashSet DispatchFields = new(StringComparer.OrdinalIgnoreCase) @@ -38,7 +42,11 @@ namespace SeaHaven.Services.Helpers VendorId, ApptDate, ApptTime, - DocStatus + DocStatus, + Pm, + ServiceNotes, + ExtraServices, + FlagColor }; public static bool IsEditable(string field) => Canonicalize(field) != null; @@ -75,6 +83,10 @@ namespace SeaHaven.Services.Helpers ApptDate => "ApptDate", ApptTime => "ApptTime", DocStatus => "DocStatus", + Pm => "Trade", + ServiceNotes => "ServiceNotes", + ExtraServices => "ExtraServices", + FlagColor => "FlagColor", _ => canonical }; } diff --git a/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs b/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs new file mode 100644 index 0000000..cb4a2d5 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderCommentProjection.cs @@ -0,0 +1,52 @@ +using Data.SeaHavenIndustries; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderCommentProjection + { + public static WorkOrderCommentDto ToDto( + Comments comment, + IReadOnlyDictionary? displayNames = null) + { + return new WorkOrderCommentDto + { + Id = comment.Id, + AuthorId = comment.UserId, + AuthorName = ResolveAuthorName(comment, displayNames), + Text = comment.Commenttext ?? "", + Time = (comment.CreatedDate ?? DateTime.UtcNow).ToUniversalTime().ToString("o"), + Documents = comment.Documents + }; + } + + public static List ToDtos( + IEnumerable comments, + IReadOnlyDictionary? displayNames = null) + => comments.Select(c => ToDto(c, displayNames)).ToList(); + + public static IEnumerable CollectAuthorUserIds(IEnumerable comments) + => comments + .Where(c => !string.IsNullOrWhiteSpace(c.UserId)) + .Select(c => c.UserId!) + .Distinct(StringComparer.Ordinal); + + private static string? ResolveAuthorName( + Comments comment, + IReadOnlyDictionary? displayNames) + { + if (!string.IsNullOrWhiteSpace(comment.UserId) + && displayNames != null + && displayNames.TryGetValue(comment.UserId, out var name) + && !string.IsNullOrWhiteSpace(name)) + { + return name; + } + + if (!string.IsNullOrWhiteSpace(comment.Commenter)) + return comment.Commenter.Trim(); + + return null; + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderFlagColors.cs b/SeaHaven.Services/Helpers/WorkOrderFlagColors.cs new file mode 100644 index 0000000..6892bd8 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderFlagColors.cs @@ -0,0 +1,50 @@ +namespace SeaHaven.Services.Helpers +{ + /// + /// Fixed board flag palette (must match FE FLAG_COLORS). Values stored as canonical #RRGGBB. + /// + public static class WorkOrderFlagColors + { + public static readonly HashSet Allowed = new(StringComparer.OrdinalIgnoreCase) + { + "#EF4444", + "#F97316", + "#F59E0B", + "#EAB308", + "#22C55E", + "#14B8A6", + "#3B82F6", + "#8B5CF6" + }; + + /// + /// Normalizes to # + 6 uppercase hex digits, or null if empty. + /// Returns false if the value is non-empty but not a valid hex color. + /// + public static bool TryNormalize(string? value, out string? canonical) + { + canonical = null; + if (string.IsNullOrWhiteSpace(value)) + return true; + + var trimmed = value.Trim(); + if (trimmed.Length == 6 && trimmed[0] != '#') + trimmed = "#" + trimmed; + + if (trimmed.Length != 7 || trimmed[0] != '#') + return false; + + for (var i = 1; i < 7; i++) + { + if (!Char.IsAsciiHexDigit(trimmed[i])) + return false; + } + + canonical = "#" + trimmed[1..].ToUpperInvariant(); + return true; + } + + public static bool IsAllowed(string? canonical) + => canonical != null && Allowed.Contains(canonical); + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderIngestFieldMapper.cs b/SeaHaven.Services/Helpers/WorkOrderIngestFieldMapper.cs new file mode 100644 index 0000000..cc48e5e --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderIngestFieldMapper.cs @@ -0,0 +1,27 @@ +namespace SeaHaven.Services.Helpers +{ + /// DynamoDB / ingest field mapping shared by Sync and direct ingest. + public static class WorkOrderIngestFieldMapper + { + public static string? MapStatus(string? dynamoStatus) + { + return dynamoStatus switch + { + "new" => "Open", + "assigned" => "Open", + "in_progress" => "In Progress", + "on_hold" => "On Hold", + "completed" => "Done", + "cancelled" => "Cancelled", + "unknown" => "Open", + _ => string.IsNullOrWhiteSpace(dynamoStatus) ? null : "Open" + }; + } + + public static string? MapSeverityToPriority(string? severity) + { + if (string.IsNullOrWhiteSpace(severity)) return null; + return $"Sev {severity}"; + } + } +} diff --git a/SeaHaven.Services/Helpers/WorkOrderMediaProjection.cs b/SeaHaven.Services/Helpers/WorkOrderMediaProjection.cs new file mode 100644 index 0000000..cbacc09 --- /dev/null +++ b/SeaHaven.Services/Helpers/WorkOrderMediaProjection.cs @@ -0,0 +1,91 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Helpers +{ + public static class WorkOrderMediaProjection + { + public static List ProjectAll( + WorkOrder workOrder, + IEnumerable attachments) + { + var media = new List(); + var attachmentId = -1; + + if (!string.IsNullOrWhiteSpace(workOrder.BeforPhotoAttachment)) + { + media.Add(new WorkOrderMediaFileDto + { + Id = attachmentId--, + Category = WorkOrderMediaCategory.Before, + Url = workOrder.BeforPhotoAttachment, + FileName = ExtractFileName(workOrder.BeforPhotoAttachment), + IsLegacy = true + }); + } + + if (!string.IsNullOrWhiteSpace(workOrder.AfterPhotoAttachment)) + { + media.Add(new WorkOrderMediaFileDto + { + Id = attachmentId--, + Category = WorkOrderMediaCategory.After, + Url = workOrder.AfterPhotoAttachment, + FileName = ExtractFileName(workOrder.AfterPhotoAttachment), + IsLegacy = true + }); + } + + if (!string.IsNullOrWhiteSpace(workOrder.SignOffAttachment)) + { + media.Add(new WorkOrderMediaFileDto + { + Id = attachmentId--, + Category = WorkOrderMediaCategory.Completion, + Url = workOrder.SignOffAttachment, + FileName = ExtractFileName(workOrder.SignOffAttachment), + IsLegacy = true + }); + } + + foreach (var attachment in attachments.Where(a => a.IsDeleted != true)) + { + if (string.IsNullOrWhiteSpace(attachment.Attachments)) + continue; + + media.Add(new WorkOrderMediaFileDto + { + Id = attachment.Id, + Category = attachment.Category ?? WorkOrderMediaCategory.Extra, + Url = attachment.Attachments, + FileName = ExtractFileName(attachment.Attachments), + UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"), + IsLegacy = false + }); + } + + return media; + } + + private static string? ExtractFileName(string url) + { + if (string.IsNullOrWhiteSpace(url)) + return null; + + var lastSlash = url.LastIndexOf('/'); + var segment = lastSlash >= 0 && lastSlash < url.Length - 1 + ? url[(lastSlash + 1)..] + : url; + + try + { + return Uri.UnescapeDataString(segment); + } + catch (Exception) + { + return segment; + } + } + } +} diff --git a/SeaHaven.Services/Implementation/LocationService.cs b/SeaHaven.Services/Implementation/LocationService.cs index c625192..accf6ba 100644 --- a/SeaHaven.Services/Implementation/LocationService.cs +++ b/SeaHaven.Services/Implementation/LocationService.cs @@ -64,6 +64,20 @@ namespace SeaHaven.Services.Implementation }; } + public async Task> GetSiteOptionsAsync(string? search = null) + { + var rows = await _locationDataService.GetSiteOptionsAsync(search); + return rows + .Select(r => new SiteOptionDTO + { + Id = r.Id, + Code = r.Code, + City = r.City, + State = r.State + }) + .ToList(); + } + public async Task CreateLocationAsync(CreateLocationDTO dto, string userId) { var validationResult = await _createValidator.ValidateAsync(dto); diff --git a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs index 51df1cc..cc1f30a 100644 --- a/SeaHaven.Services/Implementation/WorkOrderAuditService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderAuditService.cs @@ -123,6 +123,5 @@ namespace SeaHaven.Services.Implementation CreatedAt = DateTime.UtcNow }); } - } } diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs index e55f9e2..b4429c0 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardCreateService.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using FluentValidation; @@ -41,6 +42,24 @@ namespace SeaHaven.Services.Implementation var woNumber = await ResolveWoNumberAsync(request.WoNumber); var siteCode = request.SiteCode!.Trim(); + var primaryService = ResolvePrimaryService(request); + var extraServicesJson = SerializeExtraServices(request.ExtraServices); + var serviceNotes = TrimOrNull(request.ServiceNotes); + var pocName = TrimOrNull(request.PocName); + var pocPhone = TrimOrNull(request.PocPhone); + var pocNotes = TrimOrNull(request.PocNotes); + var techPhone = TrimOrNull(request.TechPhone); + var vendorNotes = TrimOrNull(request.VendorNotes); + var hasVendorDispatch = request.VendorId.HasValue + || request.ApptDate.HasValue + || !string.IsNullOrWhiteSpace(request.ApptTime); + + if (request.VendorId.HasValue && request.VendorId.Value > 0) + { + var vendorExists = await _context.Vendors.AnyAsync(v => v.Id == request.VendorId.Value && v.IsDeleted != true); + if (!vendorExists) + throw new WorkOrderBoardValidationException("VendorNotFound", "vendorId does not exist."); + } var workOrder = new WorkOrder { @@ -49,7 +68,12 @@ namespace SeaHaven.Services.Implementation WorkOrderType = request.WorkOrderType, SiteCode = siteCode, Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), - Trade = string.IsNullOrWhiteSpace(request.Trade) ? null : request.Trade.Trim(), + Trade = primaryService, + ExtraServices = extraServicesJson, + ServiceNotes = serviceNotes, + PocName = pocName, + PocPhone = pocPhone, + PocNotes = pocNotes, LocationId = request.LocationId, DueDate = request.DueDate?.Date, AssignTo = string.IsNullOrWhiteSpace(request.AssignTo) ? null : request.AssignTo.Trim(), @@ -61,6 +85,12 @@ namespace SeaHaven.Services.Implementation istemplate = false }; + if (!hasVendorDispatch) + { + workOrder.TechPhone = techPhone; + workOrder.VendorNotes = vendorNotes; + } + WorkOrderBoardFieldMutations.SetInitialLifecycleStatus(workOrder); var changes = new List @@ -75,6 +105,20 @@ namespace SeaHaven.Services.Implementation changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Description", "", workOrder.Description)); if (workOrder.Trade != null) changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("Trade", "", workOrder.Trade)); + if (workOrder.ExtraServices != null) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ExtraServices", "", workOrder.ExtraServices)); + if (workOrder.ServiceNotes != null) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("ServiceNotes", "", workOrder.ServiceNotes)); + if (workOrder.PocName != null) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocName", "", workOrder.PocName)); + if (workOrder.PocPhone != null) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocPhone", "", workOrder.PocPhone)); + if (workOrder.PocNotes != null) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("PocNotes", "", workOrder.PocNotes)); + if (workOrder.TechPhone != null) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("TechPhone", "", workOrder.TechPhone)); + if (workOrder.VendorNotes != null) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("VendorNotes", "", workOrder.VendorNotes)); if (workOrder.LocationId.HasValue) changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("LocationId", "", workOrder.LocationId.Value.ToString())); if (workOrder.DueDate.HasValue) @@ -95,7 +139,7 @@ namespace SeaHaven.Services.Implementation changes.AddRange(WorkOrderBoardFieldMutations.ApplyScheduledDate(workOrder, request.ScheduledDate.Value.Date)); Dispatch? dispatch = null; - if (request.VendorId.HasValue || request.ApptDate.HasValue || !string.IsNullOrWhiteSpace(request.ApptTime)) + if (hasVendorDispatch) { if (!request.VendorId.HasValue || request.VendorId.Value <= 0) throw new WorkOrderBoardValidationException("DispatchRequired", "vendorId is required when setting appointment fields."); @@ -105,7 +149,9 @@ namespace SeaHaven.Services.Implementation VendorId = request.VendorId.Value, Status = "Pending", CreatedDate = DateTime.UtcNow, - ScheduledDate = request.ApptDate?.Date + ScheduledDate = request.ApptDate?.Date, + TechPhone = techPhone, + VendorNotes = vendorNotes }; _context.Dispatches.Add(dispatch); workOrder.PrimaryDispatch = dispatch; @@ -120,6 +166,11 @@ namespace SeaHaven.Services.Implementation if (apptChange.HasChanged) changes.Add(apptChange); } + + if (techPhone != null) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("TechPhone", "", techPhone)); + if (vendorNotes != null) + changes.Add(WorkOrderBoardFieldMutations.BoardFieldChange.Changed("VendorNotes", "", vendorNotes)); } // Two SaveChanges are required so generated WO/dispatch IDs can backfill FKs and @@ -139,6 +190,7 @@ namespace SeaHaven.Services.Implementation dispatch.WorkOrderId = workOrder.Id; } + await _auditService.StageCreatedAsync(workOrder.Id, actorId, woNumber); await StageChangesAsync(workOrder.Id, changes, actorId, dispatch); @@ -168,6 +220,51 @@ namespace SeaHaven.Services.Implementation return row ?? throw new WorkOrderBoardValidationException("NotFound", "Work order was created but could not be loaded."); } + private static string? ResolvePrimaryService(WorkOrderBoardCreateRequestDto request) + { + if (!string.IsNullOrWhiteSpace(request.PrimaryService)) + return request.PrimaryService.Trim(); + if (!string.IsNullOrWhiteSpace(request.Trade)) + return request.Trade.Trim(); + return null; + } + + private static string? SerializeExtraServices(List? extras) + { + if (extras == null || extras.Count == 0) + return null; + + var normalized = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var item in extras) + { + if (string.IsNullOrWhiteSpace(item)) + continue; + var trimmed = item.Trim(); + if (trimmed.Length > 128) + throw new WorkOrderBoardValidationException( + "InvalidValue", + "Each extra service must be at most 128 characters."); + if (!seen.Add(trimmed)) + continue; + normalized.Add(trimmed); + } + + if (normalized.Count == 0) + return null; + + var json = JsonSerializer.Serialize(normalized); + if (json.Length > 2000) + throw new WorkOrderBoardValidationException( + "InvalidValue", + "extraServices must serialize to at most 2000 characters."); + + return json; + } + + private static string? TrimOrNull(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + private async Task ResolveWoNumberAsync(string? requested) { if (!string.IsNullOrWhiteSpace(requested)) diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs index 093f39a..e381f23 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardService.cs @@ -109,7 +109,10 @@ namespace SeaHaven.Services.Implementation ApptDate = row.DispatchApptDate ?? row.ScheduledDate, ApptTime = WorkOrderDerivedFields.FormatApptTime(apptStart, apptEnd), Pm = row.Trade ?? row.Problem, + ServiceNotes = row.ServiceNotes, + ExtraServices = ParseExtraServices(row.ExtraServices), DocStatus = row.DocStatus, + FlagColor = row.FlagColor, PrimaryDispatchId = row.PrimaryDispatchId, RowVersion = row.RowVersion, DispatchRowVersion = row.DispatchRowVersion @@ -118,5 +121,21 @@ namespace SeaHaven.Services.Implementation private static string FormatName(string? firstName, string? lastName) => $"{firstName ?? ""} {lastName ?? ""}".Trim(); + + private static List? ParseExtraServices(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try + { + var list = System.Text.Json.JsonSerializer.Deserialize>(json); + return list is { Count: > 0 } ? list : null; + } + catch (System.Text.Json.JsonException) + { + return null; + } + } } } diff --git a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs index 98002a2..7ea2fad 100644 --- a/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs +++ b/SeaHaven.Services/Implementation/WorkOrderBoardUpdateService.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Data.SeaHavenIndustries; using Data.SeaHavenIndustries.Enums; using Microsoft.EntityFrameworkCore; @@ -186,6 +187,10 @@ namespace SeaHaven.Services.Implementation WorkOrderBoardFieldNames.ApptDate => new List { ApplyApptDate(dispatch!, value, auditField) }, WorkOrderBoardFieldNames.ApptTime => ApplyApptTime(workOrder, dispatch!, value), WorkOrderBoardFieldNames.DocStatus => new List { ApplyDocStatus(workOrder, value, auditField) }, + WorkOrderBoardFieldNames.Pm => new List { ApplyStringField(value, auditField, v => workOrder.Trade = v, () => workOrder.Trade) }, + WorkOrderBoardFieldNames.ServiceNotes => new List { ApplyServiceNotes(workOrder, value, auditField) }, + WorkOrderBoardFieldNames.ExtraServices => new List { ApplyExtraServices(workOrder, value, auditField) }, + WorkOrderBoardFieldNames.FlagColor => new List { ApplyFlagColor(workOrder, value, auditField) }, _ => throw new WorkOrderBoardValidationException("InvalidField", $"Field '{field}' is not editable.") }; } @@ -260,6 +265,11 @@ namespace SeaHaven.Services.Implementation if (string.IsNullOrWhiteSpace(value)) throw new WorkOrderBoardValidationException("InvalidValue", "workOrderType value is required."); + if (string.Equals(value.Trim(), "Overdue", StringComparison.OrdinalIgnoreCase)) + throw new WorkOrderBoardValidationException( + "InvalidValue", + "Overdue is not a work order type; it is derived from scheduledDate (isPastDue)."); + if (!WorkOrderTypeMapper.TryParseFeLabel(value, out var parsed)) throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid workOrderType: {value}"); @@ -286,6 +296,89 @@ namespace SeaHaven.Services.Implementation return FieldChange.ForField(auditField, old, normalized); } + private static FieldChange ApplyServiceNotes(WorkOrder workOrder, string? value, string auditField) + { + var normalized = string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + if (normalized != null && normalized.Length > 4000) + throw new WorkOrderBoardValidationException("InvalidValue", "serviceNotes must be at most 4000 characters."); + + var old = workOrder.ServiceNotes; + if (old == normalized) + return FieldChange.Unchanged(auditField); + + workOrder.ServiceNotes = normalized; + return FieldChange.ForField(auditField, old, normalized); + } + + private static FieldChange ApplyExtraServices(WorkOrder workOrder, string? value, string auditField) + { + string? normalizedJson = null; + if (!string.IsNullOrWhiteSpace(value)) + { + List? parsed; + try + { + parsed = JsonSerializer.Deserialize>(value.Trim()); + } + catch (JsonException) + { + throw new WorkOrderBoardValidationException("InvalidValue", "extraServices must be a JSON string array."); + } + + var items = NormalizeExtraServiceList(parsed); + if (items.Count > 0) + { + if (string.IsNullOrWhiteSpace(workOrder.Trade)) + { + throw new WorkOrderBoardValidationException( + "InvalidValue", + "primaryService (pm/trade) is required when extraServices is provided."); + } + + items = items + .Where(s => !string.Equals(s, workOrder.Trade, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + normalizedJson = items.Count > 0 ? JsonSerializer.Serialize(items) : null; + if (normalizedJson != null && normalizedJson.Length > 2000) + { + throw new WorkOrderBoardValidationException( + "InvalidValue", + "extraServices must serialize to at most 2000 characters."); + } + } + } + + var old = workOrder.ExtraServices; + if (old == normalizedJson) + return FieldChange.Unchanged(auditField); + + workOrder.ExtraServices = normalizedJson; + return FieldChange.ForField(auditField, old, normalizedJson); + } + + private static List NormalizeExtraServiceList(List? extras) + { + var normalized = new List(); + if (extras == null || extras.Count == 0) + return normalized; + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var item in extras) + { + if (string.IsNullOrWhiteSpace(item)) + continue; + var trimmed = item.Trim(); + if (trimmed.Length > 128) + throw new WorkOrderBoardValidationException("InvalidValue", "Each extra service must be at most 128 characters."); + if (!seen.Add(trimmed)) + continue; + normalized.Add(trimmed); + } + + return normalized; + } + private static FieldChange ApplyLifecycleStatus(WorkOrder workOrder, string? value, string auditField) { var parsed = LifecycleStatusMapper.ParseLifecycleStatus(value); @@ -503,6 +596,24 @@ namespace SeaHaven.Services.Implementation return FieldChange.ForField(auditField, old, parsed.ToString()); } + private static FieldChange ApplyFlagColor(WorkOrder workOrder, string? value, string auditField) + { + if (!WorkOrderFlagColors.TryNormalize(value, out var canonical)) + throw new WorkOrderBoardValidationException("InvalidValue", $"Invalid flagColor: {value}"); + + if (canonical != null && !WorkOrderFlagColors.IsAllowed(canonical)) + throw new WorkOrderBoardValidationException( + "InvalidValue", + $"flagColor must be one of the allowed palette values or empty to clear."); + + var old = workOrder.FlagColor; + if (string.Equals(old, canonical, StringComparison.OrdinalIgnoreCase)) + return FieldChange.Unchanged(auditField); + + workOrder.FlagColor = canonical; + return FieldChange.ForField(auditField, old, canonical); + } + private static List ApplyAutoScheduleSideEffects(WorkOrder workOrder) { var changes = new List(); diff --git a/SeaHaven.Services/Implementation/WorkOrderCommentService.cs b/SeaHaven.Services/Implementation/WorkOrderCommentService.cs new file mode 100644 index 0000000..d91599e --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderCommentService.cs @@ -0,0 +1,59 @@ +using Data.SeaHavenIndustries; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderCommentService : IWorkOrderCommentService + { + private readonly IWorkOrderDetailDataService _detailData; + private readonly ICommentDataService _commentData; + + public WorkOrderCommentService( + IWorkOrderDetailDataService detailData, + ICommentDataService commentData) + { + _detailData = detailData; + _commentData = commentData; + } + + public async Task?> GetCommentsAsync(int workOrderId) + { + if (!await _detailData.ExistsAsync(workOrderId)) + return null; + + var comments = await _detailData.GetCommentsAsync(workOrderId); + return WorkOrderCommentProjection.ToDtos(comments); + } + + public async Task AddCommentAsync( + int workOrderId, + WorkOrderCommentCreateDto request, + string? actorId, + string? documentUrl = null) + { + if (!await _detailData.ExistsAsync(workOrderId)) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + + if (string.IsNullOrWhiteSpace(request.Text)) + throw new WorkOrderBoardValidationException("InvalidValue", "Comment text is required."); + + var comment = new Comments + { + WorkerOrderId = workOrderId, + UserId = actorId, + Commenttext = request.Text.Trim(), + CommentType = "General", + RecordType = "WorkOrder", + Documents = documentUrl, + CreatedDate = DateTime.UtcNow + }; + + var saved = await _commentData.AddAsync(comment); + return WorkOrderCommentProjection.ToDto(saved); + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs b/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs new file mode 100644 index 0000000..1e71395 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderCompletionService.cs @@ -0,0 +1,178 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderCompletionService : IWorkOrderCompletionService + { + private readonly ApplicationDbContext _context; + private readonly ICompletionDocTemplateDataService _templateData; + private readonly IWorkOrderDetailDataService _detailData; + private readonly IWorkOrderAuditService _auditService; + + public WorkOrderCompletionService( + ApplicationDbContext context, + ICompletionDocTemplateDataService templateData, + IWorkOrderDetailDataService detailData, + IWorkOrderAuditService auditService) + { + _context = context; + _templateData = templateData; + _detailData = detailData; + _auditService = auditService; + } + + public async Task> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType) + { + var rows = await _templateData.GetActiveAsync(serviceKey, workOrderType); + return rows.Select(WorkOrderDetailService.MapTemplate).ToList(); + } + + public async Task GetTemplateByIdAsync(int id) + { + var row = await _templateData.GetByIdAsync(id); + return row == null ? null : WorkOrderDetailService.MapTemplate(row); + } + + public async Task UploadCompletionDocAsync( + int workOrderId, + WorkOrderCompletionDocUploadDto request, + string fileUrl, + string? actorId) + { + var workOrder = await _context.workOrders + .FirstOrDefaultAsync(w => w.Id == workOrderId && w.istemplate != true && (w.IsDeleted != true || w.IsDeleted == null)); + + if (workOrder == null) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + + if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) + throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); + + var workOrderVersion = ParseRowVersion(request.WorkOrderVersion); + if (workOrderVersion == null) + throw new WorkOrderBoardValidationException("WorkOrderVersionRequired", "workOrderVersion is required."); + + if (workOrder.RowVersion == null || !workOrder.RowVersion.AsSpan().SequenceEqual(workOrderVersion)) + throw new WorkOrderBoardValidationException("ConcurrencyConflict", "Work order was modified. Refresh and retry."); + + _context.Entry(workOrder).Property(w => w.RowVersion).OriginalValue = workOrderVersion; + + var oldDocStatus = workOrder.DocStatus?.ToString(); + var oldAttachment = workOrder.SignOffAttachment; + + workOrder.SignOffAttachment = fileUrl; + if (!string.IsNullOrWhiteSpace(request.SignOffName)) + workOrder.SignOffName = request.SignOffName.Trim(); + if (!string.IsNullOrWhiteSpace(request.SignOffSignature)) + workOrder.SignOffSignature = request.SignOffSignature.Trim(); + + workOrder.DocStatus = DocStatus.Yes; + + await _auditService.StageFieldChangedAsync(workOrderId, "SignOffAttachment", oldAttachment, fileUrl, actorId); + if (oldDocStatus != DocStatus.Yes.ToString()) + await _auditService.StageFieldChangedAsync(workOrderId, "DocStatus", oldDocStatus, DocStatus.Yes.ToString(), actorId); + + await _context.SaveChangesAsync(); + + var extended = await _detailData.GetExtendedFieldsAsync(workOrderId); + var template = await _templateData.ResolveForWorkOrderAsync(workOrder.Trade, workOrder.WorkOrderType); + var signoffs = await _detailData.GetDispatchSignoffsAsync(workOrderId); + + return new WorkOrderCompletionDto + { + DocStatus = workOrder.DocStatus, + Template = template == null ? null : WorkOrderDetailService.MapTemplate(template), + SignOffName = extended?.SignOffName, + SignOffAttachment = extended?.SignOffAttachment, + SignOffSignature = extended?.SignOffSignature, + DispatchSignoffs = signoffs.Select(s => new DispatchSignoffDto + { + DispatchId = s.DispatchId, + SignoffType = s.SignoffType, + Name = s.Name, + SignedAt = s.SignedAt?.ToUniversalTime().ToString("o") + }).ToList() + }; + } + + public async Task CreateTemplateAsync(CompletionDocTemplateCreateDto request) + { + ValidateTemplateRequest(request); + + var entity = new CompletionDocTemplate + { + Name = request.Name.Trim(), + ServiceKey = request.ServiceKey.Trim(), + WorkOrderType = request.WorkOrderType, + TemplateUrl = request.TemplateUrl.Trim(), + IsActive = request.IsActive + }; + + var created = await _templateData.CreateAsync(entity); + return WorkOrderDetailService.MapTemplate(new CompletionDocTemplateRow( + created.Id, created.Name, created.ServiceKey, created.WorkOrderType, created.TemplateUrl, created.IsActive)); + } + + public async Task UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request) + { + ValidateTemplateRequest(request); + + var entity = await _context.CompletionDocTemplates.FirstOrDefaultAsync(t => t.Id == id && t.IsDeleted != true); + if (entity == null) + throw new WorkOrderBoardValidationException("NotFound", "Template not found."); + + entity.Name = request.Name.Trim(); + entity.ServiceKey = request.ServiceKey.Trim(); + entity.WorkOrderType = request.WorkOrderType; + entity.TemplateUrl = request.TemplateUrl.Trim(); + entity.IsActive = request.IsActive; + + await _templateData.UpdateAsync(entity); + return WorkOrderDetailService.MapTemplate(new CompletionDocTemplateRow( + entity.Id, entity.Name, entity.ServiceKey, entity.WorkOrderType, entity.TemplateUrl, entity.IsActive)); + } + + public async Task DeleteTemplateAsync(int id) + { + if (!await _templateData.DeleteAsync(id)) + throw new WorkOrderBoardValidationException("NotFound", "Template not found."); + } + + private static void ValidateTemplateRequest(CompletionDocTemplateCreateDto request) + { + if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.ServiceKey)) + throw new WorkOrderBoardValidationException("InvalidValue", "Name and serviceKey are required."); + + if (string.IsNullOrWhiteSpace(request.TemplateUrl)) + throw new WorkOrderBoardValidationException("InvalidValue", "TemplateUrl is required."); + + if (!Uri.TryCreate(request.TemplateUrl.Trim(), UriKind.Absolute, out var uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + throw new WorkOrderBoardValidationException("InvalidValue", "TemplateUrl must be an absolute http(s) URL."); + } + } + + private static byte[]? ParseRowVersion(string? base64) + { + if (string.IsNullOrWhiteSpace(base64)) + return null; + + try + { + return Convert.FromBase64String(base64); + } + catch (FormatException) + { + throw new WorkOrderBoardValidationException("InvalidRowVersion", "Invalid workOrderVersion format."); + } + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderDetailService.cs b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs new file mode 100644 index 0000000..849c5f6 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderDetailService.cs @@ -0,0 +1,182 @@ +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderDetailService : IWorkOrderDetailService + { + private readonly IWorkOrderBoardService _boardService; + private readonly IWorkOrderDetailDataService _detailData; + private readonly ICompletionDocTemplateDataService _templateData; + private readonly IUserDataService _userDataService; + + public WorkOrderDetailService( + IWorkOrderBoardService boardService, + IWorkOrderDetailDataService detailData, + ICompletionDocTemplateDataService templateData, + IUserDataService userDataService) + { + _boardService = boardService; + _detailData = detailData; + _templateData = templateData; + _userDataService = userDataService; + } + + public async Task GetDetailAsync(int workOrderId) + { + if (!await _detailData.ExistsAsync(workOrderId)) + return null; + + var boardRow = await _boardService.GetBoardRowAsync(workOrderId); + if (boardRow == null) + return null; + + var extended = await _detailData.GetExtendedFieldsAsync(workOrderId); + var comments = await _detailData.GetCommentsAsync(workOrderId); + var audit = await _detailData.GetAuditLogsAsync(workOrderId, 50); + var attachments = await _detailData.GetAttachmentsAsync(workOrderId); + var signoffs = await _detailData.GetDispatchSignoffsAsync(workOrderId); + var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId); + + var template = await _templateData.ResolveForWorkOrderAsync( + extended?.Trade ?? boardRow.Pm, + boardRow.WorkOrderType); + + var authorIds = WorkOrderCommentProjection.CollectAuthorUserIds(comments); + var authorNames = await _userDataService.GetDisplayNamesByIdsAsync(authorIds); + + return new WorkOrderDetailDto + { + Info = MapInfo(boardRow, extended), + Completion = MapCompletion(extended, boardRow.DocStatus, template, signoffs), + Comments = WorkOrderCommentProjection.ToDtos(comments, authorNames), + Audit = await MapAuditAsync(audit), + Media = workOrder == null + ? new List() + : WorkOrderMediaProjection.ProjectAll(workOrder, attachments) + }; + } + + public async Task?> GetAuditAsync(int workOrderId, int limit = 50) + { + if (!await _detailData.ExistsAsync(workOrderId)) + return null; + + var logs = await _detailData.GetAuditLogsAsync(workOrderId, limit); + return await MapAuditAsync(logs); + } + + private async Task> MapAuditAsync(IReadOnlyList logs) + { + var userIds = WorkOrderAuditProjection.CollectAssignToUserIds(logs); + var displayNames = await _userDataService.GetDisplayNamesByIdsAsync(userIds); + return WorkOrderAuditProjection.ToDtos(logs, displayNames); + } + + private static WorkOrderDetailInfoDto MapInfo( + WorkOrderBoardRowDto row, + WorkOrderDetailExtendedFields? extended) + { + return new WorkOrderDetailInfoDto + { + Id = row.Id, + WoNumber = row.WoNumber, + RescheduleCount = row.RescheduleCount, + CarriedOver = row.CarriedOver, + WorkOrderType = row.WorkOrderType, + IsPastDue = row.IsPastDue, + SiteCode = row.SiteCode, + LocationName = row.LocationName, + PocName = row.PocName, + PocPhone = row.PocPhone, + PocNotes = row.PocNotes, + LifecycleStatus = row.LifecycleStatus, + LifecycleStatusLabel = row.LifecycleStatusLabel, + LegacyStatus = row.LegacyStatus, + DispatcherId = row.DispatcherId, + DispatcherName = row.DispatcherName, + Initials = row.Initials, + Color = row.Color, + DueDate = row.DueDate, + ScheduledDate = row.ScheduledDate, + TargetWeek = row.TargetWeek, + ScheduleWeekOnly = row.ScheduleWeekOnly, + DayGroup = row.DayGroup, + VendorId = row.VendorId, + VendorName = row.VendorName, + TechName = row.TechName, + TechPhone = row.TechPhone, + ApptDate = row.ApptDate, + ApptTime = row.ApptTime, + Pm = row.Pm, + ServiceNotes = extended?.ServiceNotes ?? row.ServiceNotes, + ExtraServices = ParseExtraServices(extended?.ExtraServices) ?? row.ExtraServices, + DocStatus = row.DocStatus, + FlagColor = row.FlagColor, + PrimaryDispatchId = row.PrimaryDispatchId, + RowVersion = row.RowVersion, + DispatchRowVersion = row.DispatchRowVersion, + Description = extended?.Description, + Trade = extended?.Trade, + Problem = extended?.Problem, + SubTrade = extended?.SubTrade, + OriginalWeek = extended?.OriginalWeek, + OriginalDate = extended?.OriginalDate, + CompletedDate = extended?.CompletedDate, + VendorNotes = extended?.VendorNotes + }; + } + + private static List? ParseExtraServices(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return null; + + try + { + var list = System.Text.Json.JsonSerializer.Deserialize>(json); + return list is { Count: > 0 } ? list : null; + } + catch (System.Text.Json.JsonException) + { + return null; + } + } + + private static WorkOrderCompletionDto MapCompletion( + WorkOrderDetailExtendedFields? extended, + Data.SeaHavenIndustries.Enums.DocStatus? docStatus, + CompletionDocTemplateRow? template, + IReadOnlyList signoffs) + { + return new WorkOrderCompletionDto + { + DocStatus = docStatus, + Template = template == null ? null : MapTemplate(template), + SignOffName = extended?.SignOffName, + SignOffAttachment = extended?.SignOffAttachment, + SignOffSignature = extended?.SignOffSignature, + DispatchSignoffs = signoffs.Select(s => new DispatchSignoffDto + { + DispatchId = s.DispatchId, + SignoffType = s.SignoffType, + Name = s.Name, + SignedAt = s.SignedAt?.ToUniversalTime().ToString("o") + }).ToList() + }; + } + + internal static CompletionDocTemplateDto MapTemplate(CompletionDocTemplateRow template) + => new() + { + Id = template.Id, + Name = template.Name, + ServiceKey = template.ServiceKey, + WorkOrderType = template.WorkOrderType, + TemplateUrl = template.TemplateUrl, + IsActive = template.IsActive + }; + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderIngestService.cs b/SeaHaven.Services/Implementation/WorkOrderIngestService.cs new file mode 100644 index 0000000..aa0638e --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderIngestService.cs @@ -0,0 +1,216 @@ +using Data.SeaHavenIndustries; +using Microsoft.EntityFrameworkCore; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderIngestService : IWorkOrderIngestService + { + private readonly ApplicationDbContext _db; + private readonly ISyncFieldMergePolicy _mergePolicy; + private readonly IWorkOrderFieldLockService _fieldLocks; + private readonly IWorkOrderAuditService _audit; + + public WorkOrderIngestService( + ApplicationDbContext db, + ISyncFieldMergePolicy mergePolicy, + IWorkOrderFieldLockService fieldLocks, + IWorkOrderAuditService audit) + { + _db = db; + _mergePolicy = mergePolicy; + _fieldLocks = fieldLocks; + _audit = audit; + } + + public async Task UpsertBatchAsync( + IReadOnlyList items, + CancellationToken cancellationToken = default) + { + var result = new WorkOrderIngestBatchResultDto(); + if (items.Count == 0) + return result; + + var useTransaction = _db.Database.IsRelational(); + await using var transaction = useTransaction + ? await _db.Database.BeginTransactionAsync(cancellationToken) + : null; + + try + { + var nextSeed = await AllocateNextWoSeedAsync(cancellationToken); + + foreach (var item in items) + { + if (string.IsNullOrWhiteSpace(item.ExternalWorkOrderId)) + { + result.Skipped++; + continue; + } + + var (upsert, updatedSeed) = await UpsertOneAsync(item, nextSeed, cancellationToken); + nextSeed = updatedSeed; + result.Results.Add(upsert); + if (upsert.Created) + result.Created++; + else + result.Updated++; + } + + await _db.SaveChangesAsync(cancellationToken); + if (transaction != null) + await transaction.CommitAsync(cancellationToken); + } + catch + { + if (transaction != null) + await transaction.RollbackAsync(cancellationToken); + throw; + } + + return result; + } + + private async Task<(WorkOrderIngestItemResultDto Result, int NextSeed)> UpsertOneAsync( + WorkOrderIngestPayloadDto item, + int nextSeed, + CancellationToken cancellationToken) + { + var existing = await _db.workOrders + .FirstOrDefaultAsync(w => w.ExternalWorkOrderId == item.ExternalWorkOrderId, cancellationToken); + + var locationId = await ResolveLocationIdAsync( + item.SiteCode, item.Building, item.Address, cancellationToken); + + if (existing == null) + { + var (woNumber, updatedSeed) = AllocateInternalWoNumber(nextSeed); + var wo = new WorkOrder + { + InternalWONumber = woNumber, + ExternalWorkOrderId = item.ExternalWorkOrderId, + WorkerOrderNumber = item.ExternalWorkOrderId, + WorkerOrderTitle = item.Description, + Description = item.Description, + Status = WorkOrderIngestFieldMapper.MapStatus(item.WoStatus) ?? "Open", + Priority = WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity), + Severity = item.Severity, + Customer = item.Customer, + SiteCode = item.SiteCode, + Building = item.Building, + LocationId = locationId, + DueDate = item.DueDate, + DateReported = item.DateReported, + ScheduledStart = item.ScheduledStart, + SourceEmailS3Key = item.SourceEmailS3Key, + CreatedDate = item.CreatedAt ?? DateTime.UtcNow, + istemplate = false + }; + _db.workOrders.Add(wo); + await _db.SaveChangesAsync(cancellationToken); + + return (new WorkOrderIngestItemResultDto + { + ExternalWorkOrderId = item.ExternalWorkOrderId, + WorkOrderId = wo.Id, + Created = true + }, updatedSeed); + } + + var syncContext = new WorkOrderSyncContext + { + WorkOrder = existing, + FieldLocks = _fieldLocks, + Audit = _audit + }; + + if (item.Description != null) + { + await _mergePolicy.TryApplyAsync(syncContext, "Description", item.Description); + await _mergePolicy.TryApplyAsync(syncContext, "WorkerOrderTitle", item.Description); + } + + var mappedStatus = WorkOrderIngestFieldMapper.MapStatus(item.WoStatus); + if (mappedStatus != null) + await _mergePolicy.TryApplyAsync(syncContext, "Status", mappedStatus); + + var priority = WorkOrderIngestFieldMapper.MapSeverityToPriority(item.Severity); + if (priority != null) + await _mergePolicy.TryApplyAsync(syncContext, "Priority", priority); + + if (item.Severity != null) + await _mergePolicy.TryApplyAsync(syncContext, "Severity", item.Severity); + + if (item.SiteCode != null) + await _mergePolicy.TryApplyAsync(syncContext, "SiteCode", item.SiteCode); + + if (item.Building != null) + await _mergePolicy.TryApplyAsync(syncContext, "Building", item.Building); + + if (locationId.HasValue) + await _mergePolicy.TryApplyAsync(syncContext, "LocationId", locationId.Value); + + if (item.DueDate.HasValue) + await _mergePolicy.TryApplyAsync(syncContext, "DueDate", item.DueDate.Value); + + if (item.Customer != null) + existing.Customer = item.Customer; + + // Sync-owned metadata (not in ShocOwnedFieldSet) + existing.DateReported = item.DateReported ?? existing.DateReported; + existing.ScheduledStart = item.ScheduledStart ?? existing.ScheduledStart; + existing.SourceEmailS3Key = item.SourceEmailS3Key ?? existing.SourceEmailS3Key; + + return (new WorkOrderIngestItemResultDto + { + ExternalWorkOrderId = item.ExternalWorkOrderId, + WorkOrderId = existing.Id, + Created = false + }, nextSeed); + } + + private async Task AllocateNextWoSeedAsync(CancellationToken cancellationToken) + { + var maxId = await _db.workOrders.MaxAsync(w => (int?)w.Id, cancellationToken) ?? 0; + return Math.Max(maxId + 1, 1); + } + + private static (string Number, int NextSeed) AllocateInternalWoNumber(int seed) + { + if (!WorkOrderNumberNormalizer.TryNormalize(seed.ToString(), out var normalized, out _)) + normalized = seed.ToString().PadLeft(11, '0'); + return (normalized, seed + 1); + } + + private async Task ResolveLocationIdAsync( + string? siteCode, + string? building, + string? address, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(siteCode) && string.IsNullOrWhiteSpace(building)) + return null; + + var matchCode = siteCode ?? building; + var existing = await _db.Locations + .FirstOrDefaultAsync(l => l.Name == matchCode || l.Title == matchCode, cancellationToken); + + if (existing != null) + return existing.Id; + + var location = new Locations + { + Name = matchCode, + Title = building, + Address1 = address, + Status = "Active" + }; + + _db.Locations.Add(location); + await _db.SaveChangesAsync(cancellationToken); + return location.Id; + } + } +} diff --git a/SeaHaven.Services/Implementation/WorkOrderMediaService.cs b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs new file mode 100644 index 0000000..8de3380 --- /dev/null +++ b/SeaHaven.Services/Implementation/WorkOrderMediaService.cs @@ -0,0 +1,134 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Interfaces; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Interfaces; + +namespace SeaHaven.Services.Implementation +{ + public class WorkOrderMediaService : IWorkOrderMediaService + { + private readonly ApplicationDbContext _context; + private readonly IWorkOrderDetailDataService _detailData; + + public WorkOrderMediaService(ApplicationDbContext context, IWorkOrderDetailDataService detailData) + { + _context = context; + _detailData = detailData; + } + + public async Task?> GetMediaAsync(int workOrderId) + { + if (!await _detailData.ExistsAsync(workOrderId)) + return null; + + var workOrder = await _detailData.GetWorkOrderForMediaAsync(workOrderId); + if (workOrder == null) + return null; + + var attachments = await _detailData.GetAttachmentsAsync(workOrderId); + return WorkOrderMediaProjection.ProjectAll(workOrder, attachments); + } + + public async Task AddMediaAsync( + int workOrderId, + WorkOrderMediaCategory category, + string fileUrl, + string? actorId) + { + if (!await _detailData.ExistsAsync(workOrderId)) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + + var workOrder = await _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId); + if (workOrder == null) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + + if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) + throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); + + if (category == WorkOrderMediaCategory.Completion) + { + throw new WorkOrderBoardValidationException( + "UseCompletionDocEndpoint", + "Completion documents must be uploaded via POST /api/workorders/{id}/completion-doc."); + } + + if (category == WorkOrderMediaCategory.Before) + { + workOrder.BeforPhotoAttachment = fileUrl; + await _context.SaveChangesAsync(); + return new WorkOrderMediaFileDto + { + Id = -1, + Category = category, + Url = fileUrl, + IsLegacy = true + }; + } + + if (category == WorkOrderMediaCategory.After) + { + workOrder.AfterPhotoAttachment = fileUrl; + await _context.SaveChangesAsync(); + return new WorkOrderMediaFileDto + { + Id = -2, + Category = category, + Url = fileUrl, + IsLegacy = true + }; + } + + var attachment = new WorkOrderAttachments + { + WorkorderId = workOrderId, + Attachments = fileUrl, + Category = category, + CreatedDate = DateTime.UtcNow, + createdby = actorId + }; + + _context.workOrderAttachments.Add(attachment); + await _context.SaveChangesAsync(); + + return new WorkOrderMediaFileDto + { + Id = attachment.Id, + Category = category, + Url = fileUrl, + UploadedAt = attachment.CreatedDate?.ToUniversalTime().ToString("o"), + IsLegacy = false + }; + } + + public async Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId) + { + if (mediaId <= 0) + throw new WorkOrderBoardValidationException("InvalidMedia", "Legacy media cannot be deleted via this endpoint."); + + if (!await _detailData.ExistsAsync(workOrderId)) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + + var workOrder = await _context.workOrders.FirstOrDefaultAsync(w => w.Id == workOrderId); + if (workOrder == null) + throw new WorkOrderBoardValidationException("NotFound", "Work order not found."); + + if (WorkOrderBoardMutationRules.IsReadOnly(workOrder.LifecycleStatus)) + throw new WorkOrderBoardValidationException("ReadOnly", "Work order is read-only in its current status."); + + var attachment = await _context.workOrderAttachments + .FirstOrDefaultAsync(a => a.Id == mediaId && a.WorkorderId == workOrderId && a.IsDeleted != true); + + if (attachment == null) + throw new WorkOrderBoardValidationException("NotFound", "Media not found."); + + attachment.IsDeleted = true; + attachment.DeletionTime = DateTime.UtcNow; + attachment.DeleterUserId = actorId; + await _context.SaveChangesAsync(); + } + } +} 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; } + } +} diff --git a/SeaHaven.Services/Interfaces/ILocationService.cs b/SeaHaven.Services/Interfaces/ILocationService.cs index 7bf9d4e..dd70421 100644 --- a/SeaHaven.Services/Interfaces/ILocationService.cs +++ b/SeaHaven.Services/Interfaces/ILocationService.cs @@ -9,6 +9,7 @@ namespace SeaHaven.Services.Interfaces Task> GetAllLocationsAsync(); Task> GetLocationsByAccountIdAsync(int accountId); Task> GetLocationsPagedAsync(int page, int pageSize, string? search = null); + Task> GetSiteOptionsAsync(string? search = null); Task CreateLocationAsync(CreateLocationDTO dto, string userId); Task UpdateLocationAsync(int id, UpdateLocationDTO dto, string userId); Task DeleteLocationAsync(int id, string userId); diff --git a/SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs b/SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs new file mode 100644 index 0000000..9b1e2a2 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderCommentService.cs @@ -0,0 +1,11 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderCommentService + { + Task?> GetCommentsAsync(int workOrderId); + Task AddCommentAsync(int workOrderId, WorkOrderCommentCreateDto request, string? actorId, string? documentUrl = null); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs b/SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs new file mode 100644 index 0000000..333e906 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderCompletionService.cs @@ -0,0 +1,19 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderCompletionService + { + Task> GetTemplatesAsync(string? serviceKey, WorkOrderType? workOrderType); + Task GetTemplateByIdAsync(int id); + Task UploadCompletionDocAsync( + int workOrderId, + WorkOrderCompletionDocUploadDto request, + string fileUrl, + string? actorId); + Task CreateTemplateAsync(CompletionDocTemplateCreateDto request); + Task UpdateTemplateAsync(int id, CompletionDocTemplateCreateDto request); + Task DeleteTemplateAsync(int id); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderDetailService.cs b/SeaHaven.Services/Interfaces/IWorkOrderDetailService.cs new file mode 100644 index 0000000..c59f968 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderDetailService.cs @@ -0,0 +1,10 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderDetailService + { + Task GetDetailAsync(int workOrderId); + Task?> GetAuditAsync(int workOrderId, int limit = 50); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs new file mode 100644 index 0000000..015cc83 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderMediaService.cs @@ -0,0 +1,12 @@ +using Data.SeaHavenIndustries.Enums; +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderMediaService + { + Task?> GetMediaAsync(int workOrderId); + Task AddMediaAsync(int workOrderId, WorkOrderMediaCategory category, string fileUrl, string? actorId); + Task DeleteMediaAsync(int workOrderId, int mediaId, string? actorId); + } +} diff --git a/SeaHaven.Services/Interfaces/IWorkOrderPhase7Services.cs b/SeaHaven.Services/Interfaces/IWorkOrderPhase7Services.cs new file mode 100644 index 0000000..0242a38 --- /dev/null +++ b/SeaHaven.Services/Interfaces/IWorkOrderPhase7Services.cs @@ -0,0 +1,16 @@ +using SeaHaven.Services.DTOs; + +namespace SeaHaven.Services.Interfaces +{ + public interface IWorkOrderIngestService + { + Task UpsertBatchAsync( + IReadOnlyList items, + CancellationToken cancellationToken = default); + } + + public interface IWorkOrderOpsHealthService + { + Task GetHealthAsync(CancellationToken cancellationToken = default); + } +} diff --git a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs index 3b00bed..8d0564d 100644 --- a/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs +++ b/SeaHaven.Services/Validation/WorkOrderBoardCreateValidation.cs @@ -30,6 +30,34 @@ namespace SeaHaven.Services.Validation .MaximumLength(128) .When(x => !string.IsNullOrEmpty(x.Trade)); + RuleFor(x => x.PrimaryService) + .MaximumLength(128) + .When(x => !string.IsNullOrEmpty(x.PrimaryService)); + + RuleFor(x => x.ServiceNotes) + .MaximumLength(4000) + .When(x => !string.IsNullOrEmpty(x.ServiceNotes)); + + RuleFor(x => x.PocName) + .MaximumLength(200) + .When(x => !string.IsNullOrEmpty(x.PocName)); + + RuleFor(x => x.PocPhone) + .MaximumLength(50) + .When(x => !string.IsNullOrEmpty(x.PocPhone)); + + RuleFor(x => x.PocNotes) + .MaximumLength(2000) + .When(x => !string.IsNullOrEmpty(x.PocNotes)); + + RuleFor(x => x.TechPhone) + .MaximumLength(50) + .When(x => !string.IsNullOrEmpty(x.TechPhone)); + + RuleFor(x => x.VendorNotes) + .MaximumLength(2000) + .When(x => !string.IsNullOrEmpty(x.VendorNotes)); + RuleFor(x => x.LocationId) .GreaterThan(0) .When(x => x.LocationId.HasValue); @@ -41,6 +69,26 @@ namespace SeaHaven.Services.Validation RuleFor(x => x.PocContactId) .GreaterThan(0) .When(x => x.PocContactId.HasValue); + + RuleFor(x => x) + .Must(HasPrimaryWhenExtras) + .WithMessage("primaryService (or trade) is required when extraServices is provided.") + .WithName("extraServices"); + + RuleForEach(x => x.ExtraServices) + .MaximumLength(128) + .When(x => x.ExtraServices != null); + } + + private static bool HasPrimaryWhenExtras(WorkOrderBoardCreateRequestDto request) + { + var hasExtras = request.ExtraServices != null + && request.ExtraServices.Any(s => !string.IsNullOrWhiteSpace(s)); + if (!hasExtras) + return true; + + return !string.IsNullOrWhiteSpace(request.PrimaryService) + || !string.IsNullOrWhiteSpace(request.Trade); } } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs index 6910f7a..b3171f2 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardCreateServiceTests.cs @@ -180,4 +180,173 @@ public class WorkOrderBoardCreateServiceTests Assert.All(logs, l => Assert.Equal(result.Id, l.WorkOrderId)); Assert.True(result.Id > 0); } + + [Fact] + public async Task Create_StructuredServiceNotes_PersistsAndReturnsPm() + { + var (context, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + PrimaryService = "HVAC PM", + ExtraServices = new List { "Filter change", "Coil clean", "Filter change" }, + ServiceNotes = "Unit on roof" + }, "actor-1"); + + Assert.Equal("HVAC PM", result.Pm); + Assert.Equal("Unit on roof", result.ServiceNotes); + Assert.Equal(new[] { "Filter change", "Coil clean" }, result.ExtraServices); + + var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id); + Assert.Equal("HVAC PM", wo.Trade); + Assert.Equal("Unit on roof", wo.ServiceNotes); + Assert.Contains("Filter change", wo.ExtraServices); + Assert.Contains("Coil clean", wo.ExtraServices); + Assert.Equal(1, System.Text.Json.JsonSerializer.Deserialize>(wo.ExtraServices!)! + .Count(s => s == "Filter change")); + } + + [Fact] + public async Task Create_PrimaryServiceTakesPrecedenceOverTrade() + { + var (_, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.Reactive, + SiteCode = "CHI", + Trade = "Legacy Trade", + PrimaryService = "Plumbing" + }, "actor-1"); + + Assert.Equal("Plumbing", result.Pm); + } + + [Fact] + public async Task Create_ExtraServicesWithoutPrimary_ThrowsValidation() + { + var (_, service) = CreateSut(); + + await Assert.ThrowsAsync(() => + service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + ExtraServices = new List { "Filter change" } + }, "actor-1")); + } + + [Fact] + public async Task Create_PocFreeText_WithoutContact_AppearsOnBoardRow() + { + var (context, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.Reactive, + SiteCode = "BK5", + PocName = "Jane Site Lead", + PocPhone = "+1 555-0100", + PocNotes = "Call 30 min before" + }, "actor-1"); + + Assert.Equal("Jane Site Lead", result.PocName); + Assert.Equal("+1 555-0100", result.PocPhone); + Assert.Equal("Call 30 min before", result.PocNotes); + + var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id); + Assert.Equal("Jane Site Lead", wo.PocName); + Assert.Empty(context.WorkOrderContacts); + } + + [Fact] + public async Task Create_WithoutVendor_PersistsTechPhoneAndVendorNotesOnWorkOrder() + { + var (context, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + TechPhone = "+1 555-0199", + VendorNotes = "Gate code 4421" + }, "actor-1"); + + Assert.Equal("+1 555-0199", result.TechPhone); + + var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id); + Assert.Equal("+1 555-0199", wo.TechPhone); + Assert.Equal("Gate code 4421", wo.VendorNotes); + Assert.Null(wo.PrimaryDispatchId); + } + + [Fact] + public async Task Create_WithVendor_PersistsTechPhoneAndVendorNotesOnDispatch() + { + var (context, service) = CreateSut(); + context.Vendors.Add(new Vendor { Id = 55, CompanyName = "Acme", Phone = "+1 555-0000" }); + await context.SaveChangesAsync(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + VendorId = 55, + ApptDate = new DateTime(2026, 7, 18), + TechPhone = "+1 555-0199", + VendorNotes = "Park behind dock" + }, "actor-1"); + + Assert.Equal("+1 555-0199", result.TechPhone); + Assert.Equal(55, result.VendorId); + + var wo = await context.workOrders.Include(w => w.PrimaryDispatch).SingleAsync(w => w.Id == result.Id); + Assert.Null(wo.TechPhone); + Assert.Null(wo.VendorNotes); + Assert.NotNull(wo.PrimaryDispatch); + Assert.Equal("+1 555-0199", wo.PrimaryDispatch!.TechPhone); + Assert.Equal("Park behind dock", wo.PrimaryDispatch.VendorNotes); + } + + [Fact] + public async Task Create_LegacyTradeAndDescription_StillWorks() + { + var (context, service) = CreateSut(); + + var result = await service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + Trade = "HVAC", + Description = "pmNote\nPOC: Jane · +1 555\nVendor notes: gate" + }, "actor-1"); + + Assert.Equal("HVAC", result.Pm); + Assert.Null(result.PocName); + + var wo = await context.workOrders.SingleAsync(w => w.Id == result.Id); + Assert.Equal("HVAC", wo.Trade); + Assert.Contains("Vendor notes:", wo.Description); + Assert.Null(wo.PocName); + Assert.Null(wo.ExtraServices); + Assert.Null(wo.ServiceNotes); + } + + [Fact] + public async Task Create_UnknownVendorId_Throws() + { + var (_, service) = CreateSut(); + + var ex = await Assert.ThrowsAsync(() => + service.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5", + VendorId = 999 + }, "actor-1")); + + Assert.Equal("VendorNotFound", ex.Code); + } } diff --git a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs index f0b4047..20f27e3 100644 --- a/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs +++ b/SeaHavenIndustries.Tests/WorkOrderBoardUpdateServiceTests.cs @@ -107,6 +107,33 @@ public class WorkOrderBoardUpdateServiceTests Assert.Equal("PastDueStatusBlocked", ex.Code); } + [Fact] + public async Task PatchField_RejectsOverdueAsWorkOrderType() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + WorkOrderType = WorkOrderType.Reactive, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.WorkOrderType, + Value = "Overdue", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("InvalidValue", ex.Code); + Assert.Contains("isPastDue", ex.Message, StringComparison.Ordinal); + Assert.Contains("scheduledDate", ex.Message, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task PatchField_BlocksEditWhenCanceled() { @@ -182,6 +209,303 @@ public class WorkOrderBoardUpdateServiceTests Assert.True(await context.WorkOrderFieldLocks.AnyAsync(l => l.FieldName == "SiteCode")); } + [Fact] + public async Task PatchField_NormalizesWoNumber() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + InternalWONumber = "00000009999", + LifecycleStatus = LifecycleStatus.Incomplete, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.WoNumber, + Value = "12345", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal("00000012345", result.WoNumber); + } + + [Fact] + public async Task PatchField_RejectsDuplicateWoNumber() + { + var (context, service) = CreateSut(); + context.workOrders.AddRange( + new WorkOrder { Id = 1, InternalWONumber = "00000011111", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }, + new WorkOrder { Id = 2, InternalWONumber = "00000022222", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } }); + await context.SaveChangesAsync(); + + var wo = await context.workOrders.FindAsync(1); + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.WoNumber, + Value = "22222", + WorkOrderVersion = ToVersion(wo!) + }, "actor-1")); + + Assert.Equal("DuplicateWoNumber", ex.Code); + } + + [Fact] + public async Task PatchField_UpdatesPmTrade() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + Trade = "HVAC", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.Pm, + Value = "Plumbing", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal("Plumbing", result.Pm); + Assert.Equal("Plumbing", (await context.workOrders.FindAsync(1))!.Trade); + } + + [Fact] + public async Task PatchField_UpdatesServiceNotesAndReturnsOnBoardRow() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + Trade = "Backflow Preventers", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ServiceNotes, + Value = "service notes", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal("service notes", result.ServiceNotes); + Assert.Equal("service notes", (await context.workOrders.FindAsync(1))!.ServiceNotes); + } + + [Fact] + public async Task PatchField_UpdatesExtraServices_WhenPrimaryExists() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + Trade = "Backflow Preventers", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ExtraServices, + Value = "[\"Filter change\",\"Coil clean\"]", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(new[] { "Filter change", "Coil clean" }, result.ExtraServices); + Assert.Contains("Filter change", (await context.workOrders.FindAsync(1))!.ExtraServices); + } + + [Fact] + public async Task PatchField_ExtraServicesWithoutPrimary_Throws() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.ExtraServices, + Value = "[\"Filter change\"]", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("InvalidValue", ex.Code); + } + + [Fact] + public async Task PatchField_SetsFlagColor_CanonicalUppercase() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.FlagColor, + Value = "#ef4444", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal("#EF4444", result.FlagColor); + Assert.Equal("#EF4444", (await context.workOrders.FindAsync(1))!.FlagColor); + } + + [Fact] + public async Task PatchField_ClearsFlagColor_WithNullOrEmpty() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + FlagColor = "#EF4444", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var cleared = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.FlagColor, + Value = "", + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Null(cleared.FlagColor); + Assert.Null((await context.workOrders.FindAsync(1))!.FlagColor); + } + + [Fact] + public async Task PatchField_ClearsFlagColor_WithNullValue() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + FlagColor = "#3B82F6", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var cleared = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.FlagColor, + Value = null, + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Null(cleared.FlagColor); + Assert.Null((await context.workOrders.FindAsync(1))!.FlagColor); + } + + [Fact] + public async Task PatchField_FlagColorOutsidePalette_Throws() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.FlagColor, + Value = "#000000", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("InvalidValue", ex.Code); + } + + [Fact] + public async Task PatchField_FlagColorMalformed_Throws() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.FlagColor, + Value = "not-a-color", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("InvalidValue", ex.Code); + } + + [Fact] + public async Task PatchField_FlagColor_DoesNotRequireDispatchVersion() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Incomplete, + PrimaryDispatchId = 10, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.Dispatches.Add(new Dispatch + { + Id = 10, + WorkOrderId = 1, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } + }); + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.FlagColor, + Value = "#8B5CF6", + WorkOrderVersion = ToVersion(wo) + // no DispatchVersion + }, "actor-1"); + + Assert.Equal("#8B5CF6", result.FlagColor); + } + [Fact] public async Task PatchField_SecondEditSameField_DoesNotDuplicateFieldLock() { @@ -232,51 +556,6 @@ public class WorkOrderBoardUpdateServiceTests Assert.Equal("NotFound", ex.Code); } - [Fact] - public async Task PatchField_NormalizesWoNumber() - { - var (context, service) = CreateSut(); - var wo = new WorkOrder - { - Id = 1, - InternalWONumber = "00000009999", - LifecycleStatus = LifecycleStatus.Incomplete, - RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } - }; - context.workOrders.Add(wo); - await context.SaveChangesAsync(); - - var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto - { - Field = WorkOrderBoardFieldNames.WoNumber, - Value = "12345", - WorkOrderVersion = ToVersion(wo) - }, "actor-1"); - - Assert.Equal("00000012345", result.WoNumber); - } - - [Fact] - public async Task PatchField_RejectsDuplicateWoNumber() - { - var (context, service) = CreateSut(); - context.workOrders.AddRange( - new WorkOrder { Id = 1, InternalWONumber = "00000011111", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } }, - new WorkOrder { Id = 2, InternalWONumber = "00000022222", RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 2 } }); - await context.SaveChangesAsync(); - - var wo = await context.workOrders.FindAsync(1); - var ex = await Assert.ThrowsAsync(() => - service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto - { - Field = WorkOrderBoardFieldNames.WoNumber, - Value = "22222", - WorkOrderVersion = ToVersion(wo!) - }, "actor-1")); - - Assert.Equal("DuplicateWoNumber", ex.Code); - } - [Fact] public async Task PatchField_WeekOnly_DoesNotAutoScheduleOnAssignTo() { diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs new file mode 100644 index 0000000..85d0a98 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderPhase6Tests.cs @@ -0,0 +1,406 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Exceptions; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderDocStatusPatchTests +{ + private static (ApplicationDbContext Context, WorkOrderBoardUpdateService Service) CreateSut() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + var boardData = new WorkOrderBoardDataService(context); + var fieldLocks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, fieldLocks); + var service = new WorkOrderBoardUpdateService(context, boardData, audit); + return (context, service); + } + + private static string ToVersion(WorkOrder workOrder) + => Convert.ToBase64String(workOrder.RowVersion ?? new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }); + + [Theory] + [InlineData("Yes", DocStatus.Yes)] + [InlineData("No", DocStatus.No)] + [InlineData("NotNeeded", DocStatus.NotNeeded)] + public async Task PatchField_DocStatus_SetsValue(string value, DocStatus expected) + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.DocStatus, + Value = value, + WorkOrderVersion = ToVersion(wo) + }, "actor-1"); + + Assert.Equal(expected, result.DocStatus); + var audit = await context.WorkOrderAuditLogs.SingleAsync(); + Assert.Equal("DocStatus", audit.FieldName); + } + + [Fact] + public async Task PatchField_DocStatus_ReadOnlyWorkOrder_Throws() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Canceled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.PatchFieldAsync(1, new WorkOrderBoardPatchRequestDto + { + Field = WorkOrderBoardFieldNames.DocStatus, + Value = "Yes", + WorkOrderVersion = ToVersion(wo) + }, "actor-1")); + + Assert.Equal("CanceledReadOnly", ex.Code); + } +} + +public class WorkOrderDetailServiceTests +{ + private static (ApplicationDbContext Context, WorkOrderDetailService Service) CreateSut() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + var boardData = new WorkOrderBoardDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var detailData = new WorkOrderDetailDataService(context); + var templateData = new CompletionDocTemplateDataService(context); + var userData = new UserDataService(context); + var service = new WorkOrderDetailService(boardService, detailData, templateData, userData); + return (context, service); + } + + [Fact] + public async Task GetDetail_ReturnsDerivedFieldsAndFeAuditMapping() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + InternalWONumber = "00000000001", + LifecycleStatus = LifecycleStatus.Scheduled, + ScheduledDate = DateTime.UtcNow.AddDays(-2), + Trade = "HVAC PM", + WorkOrderType = WorkOrderType.PM, + DocStatus = DocStatus.No, + Description = "Leak", + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + context.CompletionDocTemplates.Add(new CompletionDocTemplate + { + Name = "HVAC PM Completion", + ServiceKey = "HVAC PM", + WorkOrderType = WorkOrderType.PM, + TemplateUrl = "https://example.com/hvac.pdf", + IsActive = true + }); + context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + { + WorkOrderId = 1, + EventType = "System", + Action = "WeekRolled", + FieldName = "CarriedOver", + OldValue = "0", + NewValue = "1", + CreatedAt = DateTime.UtcNow + }); + context.Comments.Add(new Comments + { + WorkerOrderId = 1, + UserId = "user-1", + Commenttext = "Called vendor", + CreatedDate = new DateTime(2026, 6, 1, 12, 0, 0, DateTimeKind.Utc) + }); + await context.SaveChangesAsync(); + + var detail = await service.GetDetailAsync(1); + + Assert.NotNull(detail); + Assert.True(detail!.Info.IsPastDue); + Assert.Equal("HVAC PM", detail.Info.Trade); + Assert.Equal("Leak", detail.Info.Description); + Assert.NotNull(detail.Completion.Template); + Assert.Equal("HVAC PM Completion", detail.Completion.Template!.Name); + Assert.Single(detail.Comments); + Assert.Equal("user-1", detail.Comments[0].AuthorId); + Assert.Equal("Called vendor", detail.Comments[0].Text); + Assert.Single(detail.Audit); + Assert.Equal("system", detail.Audit[0].Type); + Assert.Equal("WeekRolled", detail.Audit[0].Action); + } +} + +public class WorkOrderCompletionServiceTests +{ + private static (ApplicationDbContext Context, WorkOrderCompletionService Service) CreateSut() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + var templateData = new CompletionDocTemplateDataService(context); + var detailData = new WorkOrderDetailDataService(context); + var fieldLocks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, fieldLocks); + var service = new WorkOrderCompletionService(context, templateData, detailData, audit); + return (context, service); + } + + [Fact] + public async Task UploadCompletionDoc_SetsDocStatusYesAndSignOff() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + Trade = "HVAC PM", + DocStatus = DocStatus.No, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var result = await service.UploadCompletionDocAsync( + 1, + new WorkOrderCompletionDocUploadDto + { + SignOffName = "Jane Dispatcher", + WorkOrderVersion = Convert.ToBase64String(wo.RowVersion!) + }, + "https://example.com/signed.pdf", + "actor-1"); + + Assert.Equal(DocStatus.Yes, result.DocStatus); + Assert.Equal("https://example.com/signed.pdf", result.SignOffAttachment); + Assert.Equal("Jane Dispatcher", result.SignOffName); + + var updated = await context.workOrders.FindAsync(1); + Assert.Equal(DocStatus.Yes, updated!.DocStatus); + Assert.Equal(2, await context.WorkOrderAuditLogs.CountAsync()); + } + + [Fact] + public async Task UploadCompletionDoc_SecondUpload_DoesNotDuplicateFieldLocks() + { + var (context, service) = CreateSut(); + var wo = new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + DocStatus = DocStatus.No, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }; + context.workOrders.Add(wo); + await context.SaveChangesAsync(); + + var version = Convert.ToBase64String(wo.RowVersion!); + await service.UploadCompletionDocAsync( + 1, + new WorkOrderCompletionDocUploadDto { WorkOrderVersion = version }, + "https://example.com/first.pdf", + "actor-1"); + + // Simulate a new request scope: clear local tracker then reload + context.ChangeTracker.Clear(); + var reloaded = await context.workOrders.SingleAsync(w => w.Id == 1); + // InMemory does not bump rowversion; keep same bytes for the second call + reloaded.RowVersion ??= new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }; + + await service.UploadCompletionDocAsync( + 1, + new WorkOrderCompletionDocUploadDto + { + WorkOrderVersion = Convert.ToBase64String(reloaded.RowVersion!) + }, + "https://example.com/second.pdf", + "actor-1"); + + var locks = await context.WorkOrderFieldLocks.ToListAsync(); + Assert.Equal(1, locks.Count(l => l.FieldName == "SignOffAttachment")); + Assert.Equal(1, locks.Count(l => l.FieldName == "DocStatus")); + Assert.Equal("https://example.com/second.pdf", reloaded.SignOffAttachment); + } + + [Fact] + public async Task UploadCompletionDoc_MissingVersion_Throws() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled, + RowVersion = new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 } + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.UploadCompletionDocAsync( + 1, + new WorkOrderCompletionDocUploadDto(), + "https://example.com/signed.pdf", + "actor-1")); + + Assert.Equal("WorkOrderVersionRequired", ex.Code); + } + + [Fact] + public async Task UploadCompletionDoc_ReadOnly_Throws() + { + var (context, service) = CreateSut(); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Completed + }); + await context.SaveChangesAsync(); + + var ex = await Assert.ThrowsAsync(() => + service.UploadCompletionDocAsync( + 1, + new WorkOrderCompletionDocUploadDto + { + WorkOrderVersion = Convert.ToBase64String(new byte[] { 1, 0, 0, 0, 0, 0, 0, 1 }) + }, + "https://example.com/signed.pdf", + "actor-1")); + + Assert.Equal("ReadOnly", ex.Code); + } +} + +public class WorkOrderCommentServiceTests +{ + [Fact] + public async Task AddComment_ReturnsFeSchema() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + context.workOrders.Add(new WorkOrder { Id = 1, LifecycleStatus = LifecycleStatus.Scheduled }); + await context.SaveChangesAsync(); + + var detailData = new WorkOrderDetailDataService(context); + var commentData = new CommentDataService(context); + var service = new WorkOrderCommentService(detailData, commentData); + + var result = await service.AddCommentAsync(1, new WorkOrderCommentCreateDto { Text = "Note" }, "user-abc"); + + Assert.Equal("user-abc", result.AuthorId); + Assert.Equal("Note", result.Text); + Assert.False(string.IsNullOrWhiteSpace(result.Time)); + } +} + +public class WorkOrderMediaServiceTests +{ + [Fact] + public async Task GetMedia_IncludesLegacyBeforeAfterAndExtra() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + context.workOrders.Add(new WorkOrder + { + Id = 1, + BeforPhotoAttachment = "https://example.com/before.jpg", + AfterPhotoAttachment = "https://example.com/after.jpg" + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + WorkorderId = 1, + Attachments = "https://example.com/extra.pdf", + Category = WorkOrderMediaCategory.Extra + }); + await context.SaveChangesAsync(); + + var detailData = new WorkOrderDetailDataService(context); + var service = new WorkOrderMediaService(context, detailData); + var media = await service.GetMediaAsync(1); + + Assert.NotNull(media); + Assert.Equal(3, media!.Count); + Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.Before); + Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.After); + Assert.Contains(media, m => m.Category == WorkOrderMediaCategory.Extra); + } + + [Fact] + public async Task DeleteMedia_ReadOnlyWorkOrder_Throws() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Completed + }); + context.workOrderAttachments.Add(new WorkOrderAttachments + { + Id = 10, + WorkorderId = 1, + Attachments = "https://example.com/extra.pdf", + Category = WorkOrderMediaCategory.Extra + }); + await context.SaveChangesAsync(); + + var service = new WorkOrderMediaService(context, new WorkOrderDetailDataService(context)); + var ex = await Assert.ThrowsAsync(() => + service.DeleteMediaAsync(1, 10, "actor-1")); + + Assert.Equal("ReadOnly", ex.Code); + } + + [Fact] + public async Task AddMedia_CompletionCategory_ThrowsUseCompletionDocEndpoint() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + var context = new ApplicationDbContext(options); + context.workOrders.Add(new WorkOrder + { + Id = 1, + LifecycleStatus = LifecycleStatus.Scheduled + }); + await context.SaveChangesAsync(); + + var service = new WorkOrderMediaService(context, new WorkOrderDetailDataService(context)); + var ex = await Assert.ThrowsAsync(() => + service.AddMediaAsync(1, WorkOrderMediaCategory.Completion, "https://example.com/doc.pdf", "actor-1")); + + Assert.Equal("UseCompletionDocEndpoint", ex.Code); + } +} diff --git a/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs b/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs new file mode 100644 index 0000000..d3c0764 --- /dev/null +++ b/SeaHavenIndustries.Tests/WorkOrderPhase7CoexistenceTests.cs @@ -0,0 +1,241 @@ +using Data.SeaHavenIndustries; +using Data.SeaHavenIndustries.Enums; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using SeaHaven.DataServices.Implementation; +using SeaHaven.Services.DTOs; +using SeaHaven.Services.Helpers; +using SeaHaven.Services.Implementation; +using SeaHaven.Services.Interfaces; +using SeaHaven.Services.Validation; + +namespace SeaHavenIndustries.Tests; + +public class WorkOrderPhase7CoexistenceTests +{ + [Fact] + public void IngestFieldMapper_MapsDynamoStatus() + { + Assert.Equal("Open", WorkOrderIngestFieldMapper.MapStatus("new")); + Assert.Equal("In Progress", WorkOrderIngestFieldMapper.MapStatus("in_progress")); + Assert.Equal("Done", WorkOrderIngestFieldMapper.MapStatus("completed")); + } + + [Fact] + public async Task IngestService_CreatesThenUpdates_IdempotentByExternalId() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + + var fieldLocks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, fieldLocks); + var ingest = new WorkOrderIngestService(context, new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit); + + var payload = new WorkOrderIngestPayloadDto + { + ExternalWorkOrderId = "ext-100", + Description = "Initial", + WoStatus = "new", + Severity = "2", + SiteCode = "BK1" + }; + + var create = await ingest.UpsertBatchAsync(new[] { payload }); + Assert.Equal(1, create.Created); + Assert.Equal(0, create.Updated); + + payload.Description = "Updated via ingest"; + var update = await ingest.UpsertBatchAsync(new[] { payload }); + Assert.Equal(0, update.Created); + Assert.Equal(1, update.Updated); + + var wo = await context.workOrders.SingleAsync(); + Assert.Equal("Updated via ingest", wo.Description); + Assert.Equal("ext-100", wo.ExternalWorkOrderId); + } + + [Fact] + public async Task IngestRespectsFieldLock_AfterShocBoardCreate() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + + var boardData = new WorkOrderBoardDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var fieldLocks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, fieldLocks); + var createService = new WorkOrderBoardCreateService( + context, boardData, boardService, audit, new WorkOrderBoardCreateValidation()); + var policy = new SyncFieldMergePolicy(fieldLocks); + var ingest = new WorkOrderIngestService(context, policy, fieldLocks, audit); + + await createService.CreateAsync(new WorkOrderBoardCreateRequestDto + { + WorkOrderType = WorkOrderType.PM, + SiteCode = "BK5" + }, "dispatcher-1"); + + var wo = await context.workOrders.SingleAsync(); + wo.ExternalWorkOrderId = "ext-shoc-1"; + await context.SaveChangesAsync(); + + await ingest.UpsertBatchAsync(new[] + { + new WorkOrderIngestPayloadDto + { + ExternalWorkOrderId = "ext-shoc-1", + SiteCode = "SYNC-SITE" + } + }); + + Assert.Equal("BK5", wo.SiteCode); + Assert.True(await context.WorkOrderAuditLogs.AnyAsync(l => l.Action == "SyncRejected")); + } + + [Fact] + public async Task IngestRespectsLocationIdFieldLock() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + + context.Locations.Add(new Locations { Id = 1, Name = "BK1", Status = "Active" }); + context.Locations.Add(new Locations { Id = 2, Name = "BK2", Status = "Active" }); + context.workOrders.Add(new WorkOrder + { + Id = 10, + ExternalWorkOrderId = "ext-loc-1", + LocationId = 1, + SiteCode = "BK1", + istemplate = false + }); + context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock + { + WorkOrderId = 10, + FieldName = "LocationId", + LockedAt = DateTime.UtcNow, + LockedByUserId = "dispatcher-1" + }); + await context.SaveChangesAsync(); + + var fieldLocks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, fieldLocks); + var ingest = new WorkOrderIngestService(context, new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit); + + await ingest.UpsertBatchAsync(new[] + { + new WorkOrderIngestPayloadDto + { + ExternalWorkOrderId = "ext-loc-1", + SiteCode = "BK2", + Building = "BK2" + } + }); + + var wo = await context.workOrders.SingleAsync(w => w.Id == 10); + Assert.Equal(1, wo.LocationId); + Assert.True(await context.WorkOrderAuditLogs.AnyAsync(l => + l.Action == "SyncRejected" && l.FieldName == "LocationId")); + } + + [Fact] + public async Task IngestBatch_SkipsItemsWithoutExternalId() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + + var fieldLocks = new WorkOrderFieldLockService(context); + var audit = new WorkOrderAuditService(context, fieldLocks); + var ingest = new WorkOrderIngestService(context, new SyncFieldMergePolicy(fieldLocks), fieldLocks, audit); + + var result = await ingest.UpsertBatchAsync(new[] + { + new WorkOrderIngestPayloadDto { ExternalWorkOrderId = " ", Description = "bad" }, + new WorkOrderIngestPayloadDto { ExternalWorkOrderId = "ext-ok", Description = "good" } + }); + + Assert.Equal(1, result.Created); + Assert.Equal(1, result.Skipped); + Assert.Equal(1, result.Results.Count); + Assert.Equal("ext-ok", result.Results[0].ExternalWorkOrderId); + } + + [Fact] + public async Task OpsHealth_ReturnsCoexistenceMetrics() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + + context.WorkOrderFieldLocks.Add(new WorkOrderFieldLock + { + WorkOrderId = 1, + FieldName = "SiteCode", + LockedAt = DateTime.UtcNow, + LockedByUserId = "u1" + }); + context.WorkOrderAuditLogs.Add(new WorkOrderAuditLog + { + WorkOrderId = 1, + Action = "SyncRejected", + FieldName = "Description", + CreatedAt = DateTime.UtcNow.AddHours(-1) + }); + await context.SaveChangesAsync(); + + var runState = new WorkOrderJobRunStateAccessor + { + State = { LastWeekRolledRunUtc = DateTime.UtcNow.AddDays(-1) } + }; + + var healthOptions = Options.Create(new WorkOrderOpsHealthOptions + { + SyncEnabled = true, + IngestEnabled = true, + LegacyDeprecationEnabled = true, + LegacySunsetDate = "2026-12-31" + }); + + var service = new WorkOrderOpsHealthService(context, runState, healthOptions); + var health = await service.GetHealthAsync(); + + Assert.Equal(1, health.FieldLockCount); + Assert.Equal(1, health.SyncRejectedLast24h); + Assert.True(health.SyncEnabled); + Assert.True(health.IngestEnabled); + Assert.Equal("2026-12-31", health.LegacySunsetDate); + } + + [Fact] + public async Task BoardAndLegacyEndpoints_CanCoexist_InSameDatabase() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + await using var context = new ApplicationDbContext(options); + + context.workOrders.Add(new WorkOrder + { + WorkerOrderTitle = "Legacy row", + Status = "Open", + InternalWONumber = "10000001", + istemplate = false + }); + await context.SaveChangesAsync(); + + var boardData = new WorkOrderBoardDataService(context); + var boardService = new WorkOrderBoardService(boardData); + var count = await context.workOrders.CountAsync(); + + Assert.Equal(1, count); + Assert.NotNull(boardService); + } +} diff --git a/SeaHavenIndustries/Data/Services/WorkorderService.cs b/SeaHavenIndustries/Data/Services/WorkorderService.cs index f616377..2e18cf2 100644 --- a/SeaHavenIndustries/Data/Services/WorkorderService.cs +++ b/SeaHavenIndustries/Data/Services/WorkorderService.cs @@ -22,14 +22,22 @@ namespace SeaHavenIndustries.Data.Services private UserManager _userManager; private UploadFileHp _uploadFileHelper; private readonly SendMessage _sendemail; + private readonly BlazorWorkOrderSunsetGuard _sunsetGuard; - public WorkorderService(SendMessage sendemail, ApplicationDbContext context, IHttpContextAccessor httpContextAccessor, UserManager userManager, UploadFileHp uploadFileHelper) + public WorkorderService( + SendMessage sendemail, + ApplicationDbContext context, + IHttpContextAccessor httpContextAccessor, + UserManager userManager, + UploadFileHp uploadFileHelper, + BlazorWorkOrderSunsetGuard sunsetGuard) { _context = context; _httpContextAccessor = httpContextAccessor; _userManager = userManager; _uploadFileHelper = uploadFileHelper; _sendemail = sendemail; + _sunsetGuard = sunsetGuard; } public async Task> Getworkorder() { @@ -178,6 +186,7 @@ namespace SeaHavenIndustries.Data.Services } public async Task Addworkorder(WorkOrder model, List files) { + _sunsetGuard.ThrowIfMutationsBlocked(); try { _context.ChangeTracker.Clear(); @@ -425,6 +434,7 @@ namespace SeaHavenIndustries.Data.Services } public async Task Deleteworkorder(int id) { + _sunsetGuard.ThrowIfMutationsBlocked(); _context.ChangeTracker.Clear(); try diff --git a/SeaHavenIndustries/Helper/BlazorWorkOrderSunsetGuard.cs b/SeaHavenIndustries/Helper/BlazorWorkOrderSunsetGuard.cs new file mode 100644 index 0000000..5c7bbf8 --- /dev/null +++ b/SeaHavenIndustries/Helper/BlazorWorkOrderSunsetGuard.cs @@ -0,0 +1,26 @@ +using Microsoft.Extensions.Options; +using SeaHavenIndustries.Options; + +namespace SeaHavenIndustries.Helper +{ + public class BlazorWorkOrderSunsetGuard + { + private readonly BlazorWorkOrderSunsetOptions _options; + + public BlazorWorkOrderSunsetGuard(IOptions options) + { + _options = options.Value; + } + + public bool IsSunsetEnabled => _options.Enabled; + + public string ShocBaseUrl => _options.ShocBaseUrl.TrimEnd('/'); + + public void ThrowIfMutationsBlocked() + { + if (_options.Enabled) + throw new InvalidOperationException( + "Work Orders module has moved to SHOC. Editing via Blazor is disabled."); + } + } +} diff --git a/SeaHavenIndustries/Options/BlazorWorkOrderSunsetOptions.cs b/SeaHavenIndustries/Options/BlazorWorkOrderSunsetOptions.cs new file mode 100644 index 0000000..e368d9f --- /dev/null +++ b/SeaHavenIndustries/Options/BlazorWorkOrderSunsetOptions.cs @@ -0,0 +1,11 @@ +namespace SeaHavenIndustries.Options +{ + public class BlazorWorkOrderSunsetOptions + { + public const string SectionName = "BlazorWorkOrderSunset"; + + public bool Enabled { get; set; } + + public string ShocBaseUrl { get; set; } = "https://shoc.seahaven.com"; + } +} diff --git a/SeaHavenIndustries/Program.cs b/SeaHavenIndustries/Program.cs index 5450abf..48b76c3 100644 --- a/SeaHavenIndustries/Program.cs +++ b/SeaHavenIndustries/Program.cs @@ -77,6 +77,9 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.Configure( + builder.Configuration.GetSection(SeaHavenIndustries.Options.BlazorWorkOrderSunsetOptions.SectionName)); +builder.Services.AddSingleton(); builder.Services.AddSession(options => { diff --git a/SeaHavenIndustries/appsettings.json b/SeaHavenIndustries/appsettings.json index 78d143e..fa26bef 100644 --- a/SeaHavenIndustries/appsettings.json +++ b/SeaHavenIndustries/appsettings.json @@ -1,10 +1,5 @@ { "ConnectionStrings": { - //"DefaultConnection": "Server=.;Database=SeahavenIndustries;TrustServerCertificate=True;Trusted_Connection=True;MultipleActiveResultSets=true", - //"DefaultConnection": "Server=.\\SqlExpress;Database=SeahavenIndustries;TrustServerCertificate=True;Trusted_Connection=True;MultipleActiveResultSets=true", - - // Provide the real value via environment variable ConnectionStrings__DefaultConnection, - // user-secrets, or appsettings.Development.json (which is gitignored). Do NOT commit credentials. "DefaultConnection": "${CONNECTION_STRING}", "excelconnection": "Provider=Microsoft.ACE.OLEDB.12.0;Data Source={0};Extended Properties='Excel 8.0;HDR=YES'" }, @@ -15,12 +10,14 @@ } }, "SendGrid": { - // Provide the real value via environment variable SendGrid__ApiKey or user-secrets. "ApiKey": "${SENDGRID_API_KEY}" }, "GoogleMaps": { - // Provide the real value via environment variable GoogleMaps__ApiKey or user-secrets. "ApiKey": "${GOOGLE_MAPS_API_KEY}" }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "BlazorWorkOrderSunset": { + "Enabled": false, + "ShocBaseUrl": "https://shoc.seahaven.com" + } }